diff --git a/WhackerLinkConsoleV2/Assets/dualpage.png b/WhackerLinkConsoleV2/Assets/dualpage.png new file mode 100644 index 0000000..7ab2bf5 Binary files /dev/null and b/WhackerLinkConsoleV2/Assets/dualpage.png differ diff --git a/WhackerLinkConsoleV2/Assets/pager.png b/WhackerLinkConsoleV2/Assets/pager.png new file mode 100644 index 0000000..b5f4353 Binary files /dev/null and b/WhackerLinkConsoleV2/Assets/pager.png differ diff --git a/WhackerLinkConsoleV2/ChannelBox.xaml.cs b/WhackerLinkConsoleV2/ChannelBox.xaml.cs index 18c9d2d..f6750c9 100644 --- a/WhackerLinkConsoleV2/ChannelBox.xaml.cs +++ b/WhackerLinkConsoleV2/ChannelBox.xaml.cs @@ -336,5 +336,10 @@ private void PttButton_MouseLeave(object sender, System.Windows.Input.MouseEvent ((Button)sender).Background = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#FFDDDDDD")); } + + private void SetPageState(bool state) + { + PageState = state; + } } } diff --git a/WhackerLinkConsoleV2/MainWindow.xaml b/WhackerLinkConsoleV2/MainWindow.xaml index 3cc66f8..58b36fb 100644 --- a/WhackerLinkConsoleV2/MainWindow.xaml +++ b/WhackerLinkConsoleV2/MainWindow.xaml @@ -41,8 +41,11 @@ - - + + + + + diff --git a/WhackerLinkConsoleV2/MainWindow.xaml.cs b/WhackerLinkConsoleV2/MainWindow.xaml.cs index d9098ad..c8c75c0 100644 --- a/WhackerLinkConsoleV2/MainWindow.xaml.cs +++ b/WhackerLinkConsoleV2/MainWindow.xaml.cs @@ -49,6 +49,7 @@ using NWaves.Signals; using static WhackerLinkConsoleV2.P25Crypto; using static WhackerLinkLib.Models.Radio.Codeplug; +using System.Threading.Channels; namespace WhackerLinkConsoleV2 { @@ -67,6 +68,9 @@ public partial class MainWindow : Window private SettingsManager _settingsManager = new SettingsManager(); private SelectedChannelsManager _selectedChannelsManager; + + private List<(double ToneA, double ToneB)> _selectedToneSets = new List<(double, double)>(); + private FlashingBackgroundManager _flashingManager; private WaveFilePlaybackManager _emergencyAlertPlayback; private WebSocketManager _webSocketManager = new WebSocketManager(); @@ -124,6 +128,11 @@ public MainWindow() Loaded += MainWindow_Loaded; } + private class YamlConfig + { + public List Tones { get; set; } + } + private void OpenCodeplug_Click(object sender, RoutedEventArgs e) { OpenFileDialog openFileDialog = new OpenFileDialog @@ -303,8 +312,7 @@ private void GenerateChannelWidgets() systemStatusBox.ConnectionState = "Disconnected"; } }); - } else - { + } else { _fneSystemManager.AddFneSystem(system.Name, system, this); PeerSystem peer = _fneSystemManager.GetFneSystem(system.Name); @@ -344,6 +352,7 @@ private void GenerateChannelWidgets() } } + if (_settingsManager.ShowChannels && Codeplug != null) { foreach (var zone in Codeplug.Zones) @@ -415,6 +424,137 @@ private void GenerateChannelWidgets() } } + // Add ToneSet boxes if enabled + if (_settingsManager.ShowQCTones && Codeplug != null && Codeplug?.Tones != null) + { + foreach (var tone in Codeplug.Tones) + { + var toneSetControl = new ToneSet(tone.Name, tone.ToneA, tone.ToneB); + + // Hook up events + toneSetControl.PlayClicked += async (s, e) => + { + if (_selectedToneSets.Count > 0) + { + var selectedTones = _selectedToneSets.ToList(); + var initiallyActiveChannels = _selectedChannelsManager + .GetSelectedChannels() + .Where(c => c.PageState) + .ToList(); + + for (int i = 0; i < selectedTones.Count; i++) + { + var selected = selectedTones[i]; + var key = (selected.ToneA, selected.ToneB); + + // Check if any of the original channels have lost their PageState + var prematurelyCleared = initiallyActiveChannels + .Where(c => !c.PageState) + .ToList(); + + if (prematurelyCleared.Any()) + { + // Pause logic: restore PageState for affected channels + foreach (var ch in prematurelyCleared) + { + ch.PageState = true; + Dispatcher.Invoke(() => + { + ch.PageSelectButton.Background = ch.orangeGradient; + }); + } + + // Wait a moment to visually reflect the reset before continuing + await Task.Delay(300); + } + + // Play the tone + await PlayTone(selected.ToneA.ToString(), selected.ToneB.ToString()); + + // Remove from selected set + _selectedToneSets.Remove(key); + + // Deselect the tone visually + foreach (var child in ChannelsCanvas.Children) + { + if (child is ToneSet ts && ts.ToneA == key.ToneA && ts.ToneB == key.ToneB) + { + ts.SetSelected(false); + break; + } + } + } + + // Now that all tones are done, clean up page state + foreach (var ch in initiallyActiveChannels) + { + ch.PageState = false; + ch.PageSelectButton.Background = ch.grayGradient; + } + } + else + { + var hasActivePage = _selectedChannelsManager.GetSelectedChannels().Any(c => c.PageState); + if (hasActivePage) + { + await PlayTone(tone.ToneA.ToString(), tone.ToneB.ToString()); + + // Clear PageState after this tone + foreach (var channel in _selectedChannelsManager.GetSelectedChannels()) + { + channel.PageState = false; + channel.PageSelectButton.Background = channel.grayGradient; + } + } + } + }; + + + toneSetControl.SelectToggled += (s, e) => + { + var key = (tone.ToneA, tone.ToneB); + if (_selectedToneSets.Contains(key)) + { + _selectedToneSets.Remove(key); + toneSetControl.SetSelected(false); + } + else + { + _selectedToneSets.Add(key); + toneSetControl.SetSelected(true); + } + }; + + // Position like ToneSet Boxes using same layout logic + if (_settingsManager.QCToneSetPositions.TryGetValue(tone.Name, out var position)) + { + Canvas.SetLeft(toneSetControl, position.X); + Canvas.SetTop(toneSetControl, position.Y); + } + else + { + Canvas.SetLeft(toneSetControl, offsetX); + Canvas.SetTop(toneSetControl, offsetY); + } + + toneSetControl.MouseLeftButtonDown += ToneSet_MouseLeftButtonDown; + toneSetControl.MouseMove += ToneSet_MouseMove; + toneSetControl.MouseRightButtonDown += ToneSet_MouseRightButtonDown; + + ChannelsCanvas.Children.Add(toneSetControl); + + offsetX += 225; + + if (offsetX + 220 > ChannelsCanvas.ActualWidth) + { + offsetX = 20; + offsetY += 106; + } + } + } + + + playbackChannelBox = new ChannelBox(_selectedChannelsManager, _audioManager, PLAYBACKCHNAME, PLAYBACKSYS, PLAYBACKTG); if (_settingsManager.ChannelPositions.TryGetValue(PLAYBACKCHNAME, out var pos)) @@ -448,6 +588,95 @@ private void GenerateChannelWidgets() AdjustCanvasHeight(); } + private const int GridSize = 5; + + private void ToneSet_MouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + if (!isEditMode || !(sender is UIElement element)) return; + + _draggedElement = element; + _startPoint = e.GetPosition(ChannelsCanvas); + _offsetX = _startPoint.X - Canvas.GetLeft(_draggedElement); + _offsetY = _startPoint.Y - Canvas.GetTop(_draggedElement); + _isDragging = true; + + element.CaptureMouse(); + } + + private void ToneSet_MouseMove(object sender, MouseEventArgs e) + { + if (!isEditMode || !_isDragging || _draggedElement == null) return; + + Point currentPosition = e.GetPosition(ChannelsCanvas); + + // Calculate the new position with snapping to the grid + double newLeft = Math.Round((currentPosition.X - _offsetX) / GridSize) * GridSize; + double newTop = Math.Round((currentPosition.Y - _offsetY) / GridSize) * GridSize; + + // Ensure the box stays within canvas bounds + newLeft = Math.Max(0, Math.Min(newLeft, ChannelsCanvas.ActualWidth - _draggedElement.RenderSize.Width)); + newTop = Math.Max(0, Math.Min(newTop, ChannelsCanvas.ActualHeight - _draggedElement.RenderSize.Height)); + + // Apply snapped position + Canvas.SetLeft(_draggedElement, newLeft); + Canvas.SetTop(_draggedElement, newTop); + + // Save the new position if it's a ToneSet + if (_draggedElement is ToneSet toneSet) + { + _settingsManager.UpdateQCToneSetPosition(toneSet.ToneName, newLeft, newTop); + } + + AdjustCanvasHeight(); + } + + private void ToneSet_MouseRightButtonDown(object sender, MouseButtonEventArgs e) + { + if (!isEditMode || !_isDragging || _draggedElement == null) return; + + _isDragging = false; + _draggedElement.ReleaseMouseCapture(); + _draggedElement = null; + } + + + private async void ToneSet_PlayClicked(object sender, EventArgs e) + { + if (sender is ToneSet toneSet) + { + if (_selectedToneSets.Count > 0) + { + foreach (var selected in _selectedToneSets) + { + await PlayTone(selected.ToneA.ToString(), selected.ToneB.ToString()); + } + } + else + { + await PlayTone(toneSet.ToneA.ToString(), toneSet.ToneB.ToString()); + } + } + } + + private void ToneSet_SelectClicked(object sender, EventArgs e) + { + if (sender is ToneSet toneSet) + { + var key = (toneSet.ToneA, toneSet.ToneB); + + if (_selectedToneSets.Contains(key)) + { + _selectedToneSets.Remove(key); + toneSet.SetSelected(false); + } + else + { + _selectedToneSets.Add(key); + toneSet.SetSelected(true); + } + } + } + private void WaveIn_RecordingStopped(object sender, EventArgs e) { /* stub */ @@ -934,6 +1163,7 @@ private void SelectWidgets_Click(object sender, RoutedEventArgs e) _settingsManager.ShowSystemStatus = widgetSelectionWindow.ShowSystemStatus; _settingsManager.ShowChannels = widgetSelectionWindow.ShowChannels; _settingsManager.ShowAlertTones = widgetSelectionWindow.ShowAlertTones; + _settingsManager.ShowQCTones = widgetSelectionWindow.ShowQCTones; GenerateChannelWidgets(); _settingsManager.SaveSettings(); @@ -1301,8 +1531,6 @@ private void ChannelBox_MouseLeftButtonDown(object sender, MouseButtonEventArgs element.CaptureMouse(); } - private const int GridSize = 5; - private void ChannelBox_MouseMove(object sender, MouseEventArgs e) { if (!isEditMode || !_isDragging || _draggedElement == null) return; @@ -1455,6 +1683,7 @@ private void MainWindow_Loaded(object sender, RoutedEventArgs e) if (!string.IsNullOrEmpty(_settingsManager.LastCodeplugPath) && File.Exists(_settingsManager.LastCodeplugPath)) { LoadCodeplug(_settingsManager.LastCodeplugPath); + //LoadToneSets(); } else { @@ -1462,6 +1691,116 @@ private void MainWindow_Loaded(object sender, RoutedEventArgs e) } } + private async Task PlayTone(string ToneA, string ToneB) + { + var selectedChannels = _selectedChannelsManager.GetSelectedChannels(); + + // Check if any selected channel has PageState = true + if (!selectedChannels.Any(ch => ch.PageState)) + { + // No channels with active page state - do nothing + return; + } + + foreach (ChannelBox channel in selectedChannels) + { + if (!channel.PageState) + continue; // skip channels without page state + + Codeplug.System system = Codeplug.GetSystemForChannel(channel.ChannelName); + Codeplug.Channel cpgChannel = Codeplug.GetChannelByName(channel.ChannelName); + + ToneGenerator generator = new ToneGenerator(); + + double toneADuration = 1.0; + double toneBDuration = 3.0; + + byte[] toneA = generator.GenerateTone(double.Parse(ToneA), toneADuration); + byte[] toneB = generator.GenerateTone(double.Parse(ToneB), toneBDuration); + + byte[] combinedAudio = new byte[toneA.Length + toneB.Length]; + Buffer.BlockCopy(toneA, 0, combinedAudio, 0, toneA.Length); + Buffer.BlockCopy(toneB, 0, combinedAudio, toneA.Length, toneB.Length); + + int chunkSize = system.IsDvm ? 320 : 1600; + int totalChunks = (combinedAudio.Length + chunkSize - 1) / chunkSize; + + _audioManager.AddTalkgroupStream(cpgChannel.Tgid, combinedAudio); + + await Task.Run(() => + { + for (int i = 0; i < totalChunks; i++) + { + int offset = i * chunkSize; + int size = Math.Min(chunkSize, combinedAudio.Length - offset); + + byte[] chunk = new byte[chunkSize]; + Buffer.BlockCopy(combinedAudio, offset, chunk, 0, size); + + if (!system.IsDvm) + { + IPeer handler = _webSocketManager.GetWebSocketHandler(system.Name); + + AudioPacket voicePacket = new AudioPacket + { + Data = chunk, + VoiceChannel = new VoiceChannel + { + Frequency = channel.VoiceChannel, + DstId = cpgChannel.Tgid, + SrcId = system.Rid, + Site = system.Site + }, + Site = system.Site, + LopServerVocode = true + }; + + handler.SendMessage(voicePacket.GetData()); + } + else + { + PeerSystem handler = _fneSystemManager.GetFneSystem(system.Name); + + if (chunk.Length == 320) + { + P25EncodeAudioFrame(chunk, handler, channel, cpgChannel, system); + } + } + } + }); + + double totalDurationMs = (toneADuration + toneBDuration) * 1000 + 750; + await Task.Delay((int)totalDurationMs); + + if (!system.IsDvm) + { + IPeer handler = _webSocketManager.GetWebSocketHandler(system.Name); + + GRP_VCH_RLS release = new GRP_VCH_RLS + { + SrcId = system.Rid, + DstId = cpgChannel.Tgid, + Channel = channel.VoiceChannel, + Site = system.Site + }; + + handler.SendMessage(release.GetData()); + } + else + { + PeerSystem handler = _fneSystemManager.GetFneSystem(system.Name); + + await Task.Delay(4000); + handler.SendP25TDU(uint.Parse(system.Rid), uint.Parse(cpgChannel.Tgid), false); + } + + Dispatcher.Invoke(() => + { + channel.PageSelectButton.Background = channel.grayGradient; + }); + } + } + private async void OnHoldTimerElapsed(object sender, ElapsedEventArgs e) { foreach (ChannelBox channel in _selectedChannelsManager.GetSelectedChannels()) diff --git a/WhackerLinkConsoleV2/SettingsManager.cs b/WhackerLinkConsoleV2/SettingsManager.cs index 96690b9..5e3f682 100644 --- a/WhackerLinkConsoleV2/SettingsManager.cs +++ b/WhackerLinkConsoleV2/SettingsManager.cs @@ -30,6 +30,7 @@ public class SettingsManager public bool ShowSystemStatus { get; set; } = true; public bool ShowChannels { get; set; } = true; public bool ShowAlertTones { get; set; } = true; + public bool ShowQCTones { get; set; } = true; public string LastCodeplugPath { get; set; } = null; @@ -37,6 +38,7 @@ public class SettingsManager public Dictionary SystemStatusPositions { get; set; } = new Dictionary(); public List AlertToneFilePaths { get; set; } = new List(); public Dictionary AlertTonePositions { get; set; } = new Dictionary(); + public Dictionary QCToneSetPositions { get; set; } = new Dictionary(); public Dictionary ChannelOutputDevices { get; set; } = new Dictionary(); public void LoadSettings() @@ -53,11 +55,13 @@ public void LoadSettings() ShowSystemStatus = loadedSettings.ShowSystemStatus; ShowChannels = loadedSettings.ShowChannels; ShowAlertTones = loadedSettings.ShowAlertTones; + ShowQCTones = loadedSettings.ShowQCTones; LastCodeplugPath = loadedSettings.LastCodeplugPath; ChannelPositions = loadedSettings.ChannelPositions ?? new Dictionary(); SystemStatusPositions = loadedSettings.SystemStatusPositions ?? new Dictionary(); AlertToneFilePaths = loadedSettings.AlertToneFilePaths ?? new List(); AlertTonePositions = loadedSettings.AlertTonePositions ?? new Dictionary(); + QCToneSetPositions = loadedSettings.QCToneSetPositions ?? new Dictionary(); ChannelOutputDevices = loadedSettings.ChannelOutputDevices ?? new Dictionary(); } } @@ -88,6 +92,12 @@ public void UpdateChannelPosition(string channelName, double x, double y) SaveSettings(); } + public void UpdateQCToneSetPosition(string name, double x, double y) + { + QCToneSetPositions[name] = new ChannelPosition { X = x, Y = y }; + SaveSettings(); + } + public void UpdateSystemStatusPosition(string systemName, double x, double y) { SystemStatusPositions[systemName] = new ChannelPosition { X = x, Y = y }; diff --git a/WhackerLinkConsoleV2/ToneSet.xaml b/WhackerLinkConsoleV2/ToneSet.xaml new file mode 100644 index 0000000..b3cc0bc --- /dev/null +++ b/WhackerLinkConsoleV2/ToneSet.xaml @@ -0,0 +1,30 @@ + + + + + + + + + + + + diff --git a/WhackerLinkConsoleV2/ToneSet.xaml.cs b/WhackerLinkConsoleV2/ToneSet.xaml.cs new file mode 100644 index 0000000..66e2c14 --- /dev/null +++ b/WhackerLinkConsoleV2/ToneSet.xaml.cs @@ -0,0 +1,72 @@ +using System; +using System.ComponentModel; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Media; + +namespace WhackerLinkConsoleV2.Controls +{ + public partial class ToneSet : UserControl, INotifyPropertyChanged + { + public event PropertyChangedEventHandler PropertyChanged; + + public string ToneName { get; set; } + public double ToneA { get; set; } + public double ToneB { get; set; } + + public bool IsEditMode { get; set; } + + public event EventHandler PlayClicked; + public event EventHandler SelectToggled; + + private bool _isSelected = false; + + public ToneSet(string toneName, double toneA, double toneB) + { + InitializeComponent(); + ToneName = toneName; + ToneA = toneA; + ToneB = toneB; + + MouseLeftButtonDown += ToneSet_MouseLeftButtonDown; + + DataContext = this; + } + + private void ToneSet_MouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + if (IsEditMode) return; + } + + + private void ToneSetPlayBtn_Click(object sender, RoutedEventArgs e) + { + PlayClicked?.Invoke(this, EventArgs.Empty); + } + + private void ToneSetSelectBtn_Click(object sender, RoutedEventArgs e) + { + SelectToggled?.Invoke(this, EventArgs.Empty); + } + + public void SetSelected(bool selected) + { + _isSelected = selected; + UpdateSelectButton(); + if (selected) + this.Background = System.Windows.Media.Brushes.LightBlue; + else + this.Background = System.Windows.Media.Brushes.Transparent; + } + + private void UpdateSelectButton() + { + //if (ToneSetSelectBtn != null) + { + //ToneSetSelectBtn.Content = _isSelected ? "Deselect" : "Select"; + //ToneSetSelectBtn.Background = _isSelected ? Brushes.LightGreen : Brushes.LightGray; + } + } + } +} diff --git a/WhackerLinkConsoleV2/WhackerLinkConsoleV2.csproj b/WhackerLinkConsoleV2/WhackerLinkConsoleV2.csproj index 5441dd9..77f5a9e 100644 --- a/WhackerLinkConsoleV2/WhackerLinkConsoleV2.csproj +++ b/WhackerLinkConsoleV2/WhackerLinkConsoleV2.csproj @@ -13,6 +13,8 @@ + + @@ -71,6 +73,8 @@ + + diff --git a/WhackerLinkConsoleV2/WhackerLinkConsoleV2.csproj.user b/WhackerLinkConsoleV2/WhackerLinkConsoleV2.csproj.user new file mode 100644 index 0000000..0f14913 --- /dev/null +++ b/WhackerLinkConsoleV2/WhackerLinkConsoleV2.csproj.user @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/WhackerLinkConsoleV2/WidgetSelectionWindow.xaml b/WhackerLinkConsoleV2/WidgetSelectionWindow.xaml index d2cd7e3..183fa9a 100644 --- a/WhackerLinkConsoleV2/WidgetSelectionWindow.xaml +++ b/WhackerLinkConsoleV2/WidgetSelectionWindow.xaml @@ -7,6 +7,7 @@ +