using Avalonia; using Avalonia.Controls; using Avalonia.Layout; using Avalonia.Media; using Avalonia.Threading; using Nonemm.App.Configuration; using Nonemm.Core; using Nonemm.Session; namespace Nonemm.App.Windows; /// Every recording a phone message names, whether it is there, and a button to /// play it or record it again. The list is read out of the phone function key /// file and the QTC window's four, so it says what this station would actually /// play rather than what some other station's file names. /// /// The operator box picks whose folder is looked in. It starts at whoever is /// at the microphone, so an operator taking over records his own voice into /// his own folder without changing anything. public sealed partial class VoiceKeyerWindow : RefreshableWindow { private readonly AppSession session; public VoiceKeyerWindow(AppSession session) { this.session = session; InitializeComponent(); PlayBox.Text = session.Settings.VoicePlayCommand; RecordBox.Text = session.Settings.VoiceRecordCommand; PttBox.IsChecked = session.Settings.VoiceKeysPtt; PlayBox.LostFocus += (_, _) => SaveCommands(); RecordBox.LostFocus += (_, _) => SaveCommands(); PttBox.IsCheckedChanged += (_, _) => SaveCommands(); OperatorBox.SelectionChanged += (_, _) => Refresh(); FillOperators(); Refresh(); } public override void Refresh() { Lines.Children.Clear(); Lines.RowDefinitions.Clear(); string who = OperatorBox.SelectedItem as string ?? ""; FolderText.Text = session.Recordings.PathOf(who); IReadOnlyList lines = VoiceLines.For( Messages.For(ModeCategory.Phone, "", session.Settings.PhoneMessageFile), who); int at = 0; Header(at++); foreach (VoiceLine line in lines) { Row(line, at++); } int missing = lines.Count(line => !session.Recordings.Has(line.Name)); Status(lines.Count == 0 ? "no phone message names a recording — Config ▸ Change SSB Function Key Definitions" : $"{lines.Count - missing} of {lines.Count} recorded"); } /// The operators the settings know about, the station callsign last: a /// single-operator station keeps its recordings in the folder named after /// the station, which is what the empty operator means. private void FillOperators() { List names = [.. session.Settings.Operators .Select(o => o.Callsign.Trim().ToUpperInvariant()) .Where(name => name.Length > 0)]; string station = session.Settings.Station.Callsign.Trim().ToUpperInvariant(); if (station.Length > 0 && !names.Contains(station)) { names.Add(station); } names.Add(""); OperatorBox.ItemsSource = names; string current = session.Settings.CurrentOperator.Trim().ToUpperInvariant(); OperatorBox.SelectedItem = names.Contains(current) ? current : names[0]; } private void Header(int row) { Lines.RowDefinitions.Add(new RowDefinition(GridLength.Auto)); Put(Label("Where", header: true), row, 0); Put(Label("Says", header: true), row, 1); Put(Label("File", header: true), row, 2); Put(Label("Recorded", header: true), row, 3); } private void Row(VoiceLine line, int row) { Lines.RowDefinitions.Add(new RowDefinition(GridLength.Auto)); bool has = session.Recordings.Has(line.Name); Put(Label(line.Where), row, 0); Put(Label(line.Says), row, 1); Put(Label(FileName(line)), row, 2); Put( new TextBlock { Text = has ? "yes" : "no", Foreground = new SolidColorBrush(has ? Color.Parse("#27AE60") : Color.Parse("#C0392B")), VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 2, 8, 2), }, row, 3); Put(Buttons(line, has), row, 4); } private Control Buttons(VoiceLine line, bool has) { Button play = new() { Content = "Play", IsEnabled = has, Margin = new Thickness(0, 1, 4, 1) }; play.Click += (_, _) => _ = PlayAsync(line); bool isRecording = session.Recording == line.Name; Button record = new() { Content = isRecording ? "Stop" : has ? "Record again" : "Record", // one microphone, so the other rows wait for this one to finish. // The entry window's Ctrl+Shift+F keys record into the same one, // which is why the state is the session's rather than this // window's IsEnabled = session.Recording is null || isRecording, Margin = new Thickness(0, 1, 0, 1), }; record.Click += (_, _) => _ = RecordAsync(line); return new StackPanel { Orientation = Orientation.Horizontal, Children = { play, record }, }; } private async Task PlayAsync(VoiceLine line) { Status($"playing {line.Name}"); try { await session.Sound.PlayAsync(session.Recordings.PathOf(line.Name)); Status($"played {line.Name}"); } catch (Exception failure) when (failure is InvalidOperationException or IOException) { Status(failure.Message); } } /// Starts recording, or stops the recording that is running. What was /// recorded goes over the old file only when the recorder has finished /// with it, so a stop that fails leaves the old recording playable. private async Task RecordAsync(VoiceLine line) { if (session.Recording is not null) { session.StopRecording(); return; } if (!session.Sound.CanRecord) { Status("there is no recorder — name one in the box below"); return; } Status($"recording {line.Name} — press Stop when you have finished speaking"); string outcome = await session.RecordAsync(line.Name); // the redraw comes first: it writes the tally over the status line, and // what happened to this recording is worth more than the tally Refresh(); Status(outcome); } private void SaveCommands() { Settings settings = session.Settings with { VoicePlayCommand = (PlayBox.Text ?? "").Trim(), VoiceRecordCommand = (RecordBox.Text ?? "").Trim(), VoiceKeysPtt = PttBox.IsChecked == true, }; if (settings == session.Settings) { return; } session.Save(settings); // the keyer holds the commands, so it is built again with the new ones session.ApplyKeyerSettings(); } /// The file without the folder in front of it: the folder is the operator /// picked at the top and is the same for every row that has one. private static string FileName(VoiceLine line) => line.Name[(line.Name.LastIndexOfAny(['\\', '/']) + 1)..]; private void Status(string text) => StatusText.Text = text; private static TextBlock Label(string text, bool header = false) => new() { Text = text, FontSize = header ? 11 : 12, Opacity = header ? 0.7 : 1, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 2, 8, 2), }; private void Put(Control control, int row, int column) { Grid.SetRow(control, row); Grid.SetColumn(control, column); Lines.Children.Add(control); } }