Files
Nonemm/src/Nonemm.App/Windows/VoiceKeyerWindow.axaml.cs
T
erikbrockoandClaude Sonnet 5.5 fe70f9b058 Cut CPU use and move heavy work off the UI thread
Redraws:
- Merge bursts of session and bandmap events into one redraw
  (Coalesced). Closed Bandmap and Available windows now unsubscribe.
- Grayline and Voice Keyer no longer subscribe to the session twice.
- Available summary, Score table and Log rows redraw only when their
  contents change. Telnet lines are added in batches.
- Bandmap view reuses its layout, shaped text and brushes. Grayline
  draws the coastlines as one cached shape.

Threads:
- Available, Bandmap colours, Check and Statistics do their judging
  and counting on the thread pool and draw the result on the UI thread.
- ContestLog takes a lock in Judge and in each method that changes the
  log, so a background Judge cannot read the indexes mid-edit.

Refresh rate:
- Statistics refreshes every 30 seconds and when the QSO count changes.
- Grayline refreshes at most every 15 seconds on session events.

Publish with ReadyToRun to cut startup JIT time.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 12:25:46 +00:00

209 lines
7.6 KiB
C#

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<VoiceLine> 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<string> 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);
}
}