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>
209 lines
7.6 KiB
C#
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);
|
|
}
|
|
}
|