Play a recording on phone, and record it in a window

A phone function key names a wav file instead of words, which is what N1MM's
own SSB file holds: `F1 CQ,{WAVDIR}\Cq.wav`. `{WAVDIR}` is the folder named
after whoever is at the microphone, a name is taken relative to `Wav`, and
commas play several files in a row. An SSB `.mc` file written for N1MM plays
here.

Window ▸ Voice Keyer lists every recording the messages name, says which of
them are there for the operator picked at the top, plays one, and records over
one. The recording is written beside the wanted file and moved over it at the
end, so a recorder that will not start takes nothing away.

`CommandSoundCard` runs `aplay` and `arecord`, and both commands are settings:
the audio path belongs to whatever the station runs, and a library would pick
one of them for the operator. A recorder is stopped with an interrupt so it
closes its wav file, and killed only if it ignores that. PTT is cwdaemon's
`<ESC>a` for as long as the recording plays, off for a station on VOX.

Two things follow. The QTC window sends on SSB: N1MM's four recordings on the
Ready, Agn, Cfm and Close buttons, with the lines still read out by the
operator. And alternating CQ duels on phone, because the voice keyer says when
a recording has finished — which it had to, since the CQ message is picked by
the radio's mode and would otherwise have been keyed as Morse.

Nothing here has been played on a radio: this machine has no sound card, so
what is tested is the program being run, the file filled in, the exit code and
the interrupt, with shell commands standing in for the two.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0157i7PiZyqBkquxS9A6FTdQ
This commit is contained in:
2026-09-11 07:51:58 +00:00
co-authored by Claude Opus 5
parent dae1f4f197
commit 082fa75a40
28 changed files with 1564 additions and 86 deletions
+54 -7
View File
@@ -40,6 +40,10 @@ public sealed class AppSession : IDisposable
private DateTime statusSent;
private DispatcherTimer? announcing;
private MessageSender? keyer;
private VoiceKeyer? voice;
private SoundCard sound = new CommandSoundCard(
CommandSoundCard.DefaultPlay,
CommandSoundCard.DefaultRecord);
private AlternatingCq? alternating;
private MmttyEngine? digital;
private DigitalEngineSender? digitalSender;
@@ -63,6 +67,7 @@ public sealed class AppSession : IDisposable
Paths = paths;
Settings = settings;
paths.CreateFolders();
Recordings = new VoiceFiles(paths.Wav);
Countries = LoadCountryFile(paths.CountryFile);
Calls = LoadCallDatabase(paths.CallDatabaseFile);
History = LoadCallHistory(settings.CallHistoryFile);
@@ -152,6 +157,29 @@ public sealed class AppSession : IDisposable
public MessageSender? Keyer => keyer;
/// What sends a message on that mode: the digital engine on a digital
/// mode, the voice keyer on phone, the CW keyer otherwise. Everything that
/// sends asks here, so a message always goes out the way the mode needs.
public MessageSender? SenderFor(ModeCategory mode) => mode switch
{
ModeCategory.Digital when digitalSender is { IsReady: true } engine => engine,
ModeCategory.Phone when voice is { IsReady: true } speaking => speaking,
_ => keyer,
};
/// What plays the recordings on phone. It is there whether or not there is
/// a CW keyer: the recordings go out of the sound card, not the keying
/// port.
public VoiceKeyer? Voice => voice;
/// The recordings under the `Wav` folder, for the voice keyer and for the
/// window that records them.
public VoiceFiles Recordings { get; }
/// What plays and records a wav file. The voice keyer sends through it and
/// the voice keyer window auditions and records through it.
public SoundCard Sound => sound;
/// The digital modem, started by the digital window rather than at startup:
/// it runs an engine under Wine, which is not something to do to an
/// operator who is working CW.
@@ -175,6 +203,12 @@ public sealed class AppSession : IDisposable
public event EventHandler? ContestChanged;
/// What a keyer had to say while a message was going out — a recording
/// that would not play, most of the time. The entry window puts it in the
/// status line: by then the caller has been answered and there is nothing
/// left to throw at.
public event EventHandler<string>? Reported;
public void Save(Settings settings)
{
Settings = settings;
@@ -571,6 +605,8 @@ public sealed class AppSession : IDisposable
alternating = null;
keyer?.Dispose();
keyer = null;
voice?.Dispose();
voice = null;
switch (Settings.KeyerKind.ToLowerInvariant())
{
case "cwdaemon":
@@ -585,11 +621,21 @@ public sealed class AppSession : IDisposable
if (keyer is not null)
{
_ = keyer.SetSpeedAsync(Settings.KeyerSpeed);
alternating = new AlternatingCq(
keyer,
CallCqOnAsync,
TimeSpan.FromMilliseconds(Settings.AlternatingCqGapMs));
}
sound = new CommandSoundCard(Settings.VoicePlayCommand, Settings.VoiceRecordCommand);
voice = new VoiceKeyer(
sound,
Recordings,
// only cwdaemon has a way in to the keying port from here, and
// only when the operator has not said the radio keys itself
Settings.VoiceKeysPtt && keyer is CwDaemonSender daemon ? daemon.SetPttAsync : null);
voice.Reported += (_, what) => Reported?.Invoke(this, what);
// a CQ goes out through the keyer the radio's mode calls for, so
// alternating CQ listens to both of them
alternating = new AlternatingCq(
keyer is null ? [voice] : [keyer, voice],
CallCqOnAsync,
TimeSpan.FromMilliseconds(Settings.AlternatingCqGapMs));
Changed?.Invoke(this, EventArgs.Empty);
}
@@ -696,9 +742,9 @@ public sealed class AppSession : IDisposable
{
OperatingPosition position = positions.FirstOrDefault(p => p.RadioNumber == radioNumber)
?? throw new InvalidOperationException($"there is no radio {radioNumber}");
if (keyer is null)
if (SenderFor(position.Mode.Category) is not { } sender)
{
throw new InvalidOperationException("there is no keyer");
throw new InvalidOperationException($"there is no keyer for {position.Mode.Name}");
}
string template = Messages
.For(position.Mode.Category, Settings.CwMessageFile, Settings.PhoneMessageFile)
@@ -710,7 +756,7 @@ public sealed class AppSession : IDisposable
}
MoveToRadio(radioNumber);
await PointTransmitAtAsync(radioNumber).ConfigureAwait(false);
await keyer.SendAsync(MessageExpander.Expand(template, position, Other(position))).ConfigureAwait(false);
await sender.SendAsync(MessageExpander.Expand(template, position, Other(position))).ConfigureAwait(false);
}
/// Puts both radios in the headphones, or goes back to one. An operator
@@ -886,6 +932,7 @@ public sealed class AppSession : IDisposable
link?.Dispose();
alternating?.Dispose();
keyer?.Dispose();
voice?.Dispose();
box?.Dispose();
store?.Dispose();
}
+13
View File
@@ -1,6 +1,7 @@
using System.Reflection;
using System.Text.Json;
using Nonemm.Core;
using Nonemm.Keying;
using Nonemm.Network;
using Nonemm.Session;
@@ -171,6 +172,18 @@ public sealed record Settings
/// 100 ms, because an SO2R box works relays.
public int AlternatingCqGapMs { get; init; } = 100;
/// What the voice keyer plays and records with. `{file}` in each command
/// stands for the file. Empty means the program has nothing to play or
/// record with, and the voice keyer window says so.
public string VoicePlayCommand { get; init; } = CommandSoundCard.DefaultPlay;
public string VoiceRecordCommand { get; init; } = CommandSoundCard.DefaultRecord;
/// Whether the voice keyer keys the transmitter itself, through the same
/// cwdaemon that keys CW. Turned off at a station whose radio hears the
/// audio and keys itself on VOX.
public bool VoiceKeysPtt { get; init; } = true;
/// How the WAE QTC window behaves. The names and the defaults are N1MM's:
/// it skips the confirm and ready steps out of the box, clears the header
/// when the header is asked for again, and leaves a line alone when the
+19 -12
View File
@@ -28,20 +28,27 @@ public static class Messages
F12 Wipe,
""";
/// Phone messages name a recording to play; the text is what would be said.
/// Phone messages name a recording to play, which is what N1MM's own SSB
/// file holds. `{WAVDIR}` is the folder named after whoever is at the
/// microphone, so each operator's own voice goes out. The recordings are
/// made in the voice keyer window, which lists these names.
///
/// F5 is empty, as it is in N1MM's file: sending the other station's call
/// needs a recording per letter, which this program does not play.
public const string DefaultPhone =
"""
# Phone function keys.
F1 CQ,CQ CONTEST {MYCALL}
F2 Exch,{EXCH}
F3 TU,THANK YOU {MYCALL}
F4 MyCall,{MYCALL}
F5 HisCall,!
F6 QSO B4,QSO BEFORE
F7 ?,PLEASE REPEAT
F8 Agn,AGAIN
F9 Nr?,NUMBER PLEASE
F10 Call?,YOUR CALL PLEASE
# Phone function keys. Each one names a recording under the Wav folder;
# {WAVDIR} is the folder of whoever is at the microphone.
F1 CQ,{WAVDIR}\Cq.wav
F2 Exch,{WAVDIR}\Exchange.wav
F3 TU,{WAVDIR}\Thanks.wav
F4 MyCall,{WAVDIR}\MyCall.wav
F5 HisCall,
F6 QSO B4,{WAVDIR}\QsoB4.wav
F7 ?,{WAVDIR}\Repeat.wav
F8 Agn,{WAVDIR}\AllAgain.wav
F9 Nr?,{WAVDIR}\NumberAgain.wav
F10 Call?,{WAVDIR}\CallAgain.wav
F11 Spot,
F12 Wipe,
""";
+3 -6
View File
@@ -16,13 +16,10 @@ public sealed partial class EntryWindow
/// actions after `{END}` still run.
private static readonly TimeSpan SendingPatience = TimeSpan.FromMinutes(2);
/// What sends this radio's messages: the digital engine on a digital mode,
/// and the CW or voice keyer otherwise. A message therefore goes out the
/// way the mode needs without the caller choosing.
/// What sends this radio's messages, which the mode decides: a message
/// therefore goes out the way the mode needs without the caller choosing.
private MessageSender? Sender =>
Logging?.Mode.Category == ModeCategory.Digital && session.DigitalKeyer is { IsReady: true } engine
? engine
: session.Keyer;
session.SenderFor(Logging?.Mode.Category ?? ModeCategory.Cw);
/// What to say when a message has nowhere to go. The mode decides which
/// keyer a message uses, so the mode is part of the answer: an operator on
@@ -464,6 +464,9 @@ public sealed partial class EntryWindow
private void OnShowTelnet(object? sender, RoutedEventArgs e) => Show(() => new TelnetWindow(session, Tune));
private void OnShowVoiceKeyer(object? sender, RoutedEventArgs e) =>
Show(() => new VoiceKeyerWindow(session));
/// N1MM's digital interface window, which is where RTTY is worked from.
private void OnShowDigital(object? sender, RoutedEventArgs e) =>
Show(() => new DigitalWindow(session, this, radioNumber));
@@ -117,6 +117,7 @@ public sealed partial class EntryWindow
(nameof(GraylineWindow), () => _ = Show(() => new GraylineWindow(session), activate: false)),
(nameof(TelnetWindow), () => _ = Show(() => new TelnetWindow(session, Tune), activate: false)),
(nameof(DigitalWindow), () => _ = Show(() => new DigitalWindow(session, this, radioNumber), activate: false)),
(nameof(VoiceKeyerWindow), () => _ = Show(() => new VoiceKeyerWindow(session), activate: false)),
];
/// Puts a window where this operator last had it. Windows are opened one at
+1
View File
@@ -129,6 +129,7 @@
<MenuItem Header="Statistics" Click="OnShowStatistics" />
<MenuItem Header="Telnet" Click="OnShowTelnet" />
<MenuItem Header="Digital Interface" Click="OnShowDigital" />
<MenuItem Header="Voice Keyer" Click="OnShowVoiceKeyer" />
</MenuItem>
</Menu>
+6 -4
View File
@@ -68,6 +68,9 @@ public sealed partial class EntryWindow : Window
InitializeComponent();
BuildFunctionKeys();
session.Changed += (_, _) => Dispatcher.UIThread.Post(Refresh);
// a keyer speaks on its own thread, and a recording that would not
// play has nowhere else to be said
session.Reported += (_, what) => Dispatcher.UIThread.Post(() => Status(what));
session.ContestChanged += (_, _) => Dispatcher.UIThread.Post(() =>
{
BuildEntryBoxes();
@@ -195,10 +198,9 @@ public sealed partial class EntryWindow : Window
{
yield return ("station network", session.ApplyNetworkSettings);
}
if (session.Settings.KeyerKind != "none")
{
yield return ("keyer", session.ApplyKeyerSettings);
}
// always, whatever the CW keyer is: this is also what builds the voice
// keyer, which plays out of the sound card rather than a keying port
yield return ("keyer", session.ApplyKeyerSettings);
if (session.Settings.So2rBoxPort.Length > 0)
{
yield return ("SO2R box", session.ApplySo2rSettings);
+39 -10
View File
@@ -102,7 +102,7 @@ public sealed partial class QtcWindow : Window
return isSending
? IsRtty
? "Send All reads the whole series out — Snd n sends one QTC again"
: "Nothing goes out on SSB: read the lines out yourself, then Close to log them."
: "The buttons play the QRV, Agn, Cfm and TU recordings. Read the lines out yourself, then Close to log them."
: "Type the header, then a line per QTC: time, callsign, serial number.";
}
return isSending
@@ -116,9 +116,11 @@ public sealed partial class QtcWindow : Window
private bool IsRtty => position.Contest.Modes.Contains(ModeCategory.Digital);
/// Whether this window puts anything on the air. SSB needs a voice keyer
/// and there is none here, so on SSB the operator speaks and the window
/// only writes the traffic down.
private bool IsPhone => !IsCw && !IsRtty;
/// Whether this window keys the traffic itself. On SSB it plays N1MM's
/// four recordings and the operator reads the lines out: a QTC line is a
/// time, a callsign and a serial, and no recording can say one.
private bool Keys => IsCw || IsRtty;
private Settings Settings => session.Settings;
@@ -149,8 +151,10 @@ public sealed partial class QtcWindow : Window
CountsText.Text = traffic.CountsFor(station).Text;
KeysText.Text = KeysLine();
// N1MM's labels: the buttons say what they do in the direction the
// traffic is going, and the ones with nothing to do are hidden
ReadyButton.IsVisible = Keys || !isSending;
// traffic is going, and the ones with nothing to do are hidden. This
// one always has something to do — on SSB it plays the QRV recording
// whichever way the traffic is going
ReadyButton.IsVisible = true;
ReadyButton.Content = !IsCw ? isSending ? "R U QRV" : "RX Ready"
: isSending ? "R U QRV" : "QRV";
HeaderAgainButton.Content = isSending ? "Snd Hdr" : "Hdr Agn";
@@ -488,8 +492,8 @@ public sealed partial class QtcWindow : Window
}
/// Reading traffic out, this asks the other station whether it is ready;
/// taking it down, it says we are. N1MM keys `QRV?` and `QRV` and plays a
/// recording on SSB, which there is no keyer for here.
/// taking it down, it says we are. N1MM keys `QRV?` and `QRV` on CW and
/// plays one recording either way on SSB.
private void OnReady(object? sender, RoutedEventArgs e)
{
if (IsCw)
@@ -501,6 +505,10 @@ public sealed partial class QtcWindow : Window
_ = send(QtcMessages.Rtty(
isSending ? Settings.QtcRttyAreYouReady : Settings.QtcRttyReady));
}
else
{
_ = send(VoiceLines.QtcReady);
}
Control next = isSending ? SendAllButton.IsVisible ? SendAllButton : rows[0].Again
: HeaderBox;
next.Focus();
@@ -526,6 +534,10 @@ public sealed partial class QtcWindow : Window
{
_ = send(QtcMessages.Rtty(QtcMessages.RttyHeaderAgain));
}
else
{
_ = send(VoiceLines.QtcAgain);
}
if (session.Settings.QtcHeaderAgainClears)
{
HeaderBox.Text = "";
@@ -533,7 +545,14 @@ public sealed partial class QtcWindow : Window
HeaderBox.Focus();
}
private void OnHeaderConfirm(object? sender, RoutedEventArgs e) => rows[0].Time.Focus();
private void OnHeaderConfirm(object? sender, RoutedEventArgs e)
{
if (IsPhone)
{
_ = send(VoiceLines.QtcConfirm);
}
rows[0].Time.Focus();
}
/// N1MM's All Agn: asks for the header and every line of the series again,
/// and clears what was taken down so the series can be copied afresh.
@@ -578,6 +597,12 @@ public sealed partial class QtcWindow : Window
{
_ = send($"AGN QTC {at + 1}");
}
else
{
// one recording for every line: it says "again", and the
// operator says which line
_ = send(VoiceLines.QtcAgain);
}
if (Settings.QtcAgainClearsLine)
{
rows[at].Clear();
@@ -750,7 +775,11 @@ public sealed partial class QtcWindow : Window
// N1MM sends a message as the window closes: the TU on CW, and on RTTY
// `R R R ALL OK BK` from the station that took the traffic down or
// `GL DE <call>` from the one that read it out
if (IsCw && Settings.QtcCwTu.Trim().Length > 0)
if (IsPhone)
{
_ = send(VoiceLines.QtcThanks);
}
else if (IsCw && Settings.QtcCwTu.Trim().Length > 0)
{
_ = send(QtcMessages.Tu(Settings.QtcCwTu, HeaderBox.Text ?? ""));
}
@@ -0,0 +1,30 @@
<local:RefreshableWindow xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="using:Nonemm.App.Windows"
x:Class="Nonemm.App.Windows.VoiceKeyerWindow"
Title="Voice Keyer" Width="760" Height="540">
<DockPanel Margin="10">
<Grid DockPanel.Dock="Top" ColumnDefinitions="Auto,8,Auto,8,*">
<TextBlock Text="Operator" VerticalAlignment="Center" FontSize="11" Opacity="0.7" />
<ComboBox Name="OperatorBox" Grid.Column="2" Width="140" />
<TextBlock Name="FolderText" Grid.Column="4" VerticalAlignment="Center" FontSize="11"
Opacity="0.7" TextTrimming="CharacterEllipsis" />
</Grid>
<StackPanel DockPanel.Dock="Bottom" Spacing="4" Margin="0,8,0,0">
<TextBlock Name="StatusText" FontSize="11" Opacity="0.7" TextWrapping="Wrap" />
<Grid ColumnDefinitions="*,8,*,8,Auto" RowDefinitions="Auto,Auto">
<TextBlock Text="Play command" FontSize="11" Opacity="0.7" Margin="0,0,0,1" />
<TextBox Name="PlayBox" Grid.Row="1" />
<TextBlock Grid.Column="2" Text="Record command" FontSize="11" Opacity="0.7" Margin="0,0,0,1" />
<TextBox Name="RecordBox" Grid.Row="1" Grid.Column="2" />
<CheckBox Name="PttBox" Grid.Row="1" Grid.Column="4" Content="Key PTT through cwdaemon"
VerticalAlignment="Center" />
</Grid>
</StackPanel>
<ScrollViewer>
<Grid Name="Lines" ColumnDefinitions="110,120,*,70,Auto" Margin="0,8,14,0" />
</ScrollViewer>
</DockPanel>
</local:RefreshableWindow>
@@ -0,0 +1,231 @@
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;
/// The line being recorded, or null while nothing is. One at a time: two
/// recorders on one microphone would each get half of what was said.
private CancellationTokenSource? recording;
private string? recordingName;
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();
session.Changed += (_, _) => Dispatcher.UIThread.Post(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 = recordingName == line.Name;
Button record = new()
{
Content = isRecording ? "Stop" : has ? "Record again" : "Record",
// one microphone, so the other rows wait for this one to finish
IsEnabled = 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 (recording is { } running)
{
running.Cancel();
return;
}
if (!session.Sound.CanRecord)
{
Status("there is no recorder — name one in the box below");
return;
}
recording = new CancellationTokenSource();
recordingName = line.Name;
Refresh();
Status($"recording {line.Name} — press Stop when you have finished speaking");
string outcome;
try
{
await session.Sound.RecordAsync(
session.Recordings.PathOf(line.Name),
recording.Token);
outcome = $"recorded {line.Name}";
}
catch (Exception failure) when (failure is InvalidOperationException or IOException)
{
outcome = failure.Message;
}
finally
{
recording.Dispose();
recording = null;
recordingName = null;
}
// 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);
}
}
+240
View File
@@ -0,0 +1,240 @@
using System.Diagnostics;
using System.Runtime.InteropServices;
namespace Nonemm.Keying;
/// Plays and records by running a program: `aplay` and `arecord` on Linux,
/// which are in alsa-utils and are what a station already has. `{file}` in the
/// command stands for the file.
///
/// The reason it is a program rather than a sound library is the reason the CW
/// timing is cwdaemon's, in `docs/keying.md`: the audio path belongs to
/// whatever the station runs — ALSA, PulseAudio, PipeWire, JACK — and a logger
/// that picks one of them for the operator is wrong at three stations out of
/// four. The command is a setting, so a station on JACK writes its own.
public sealed class CommandSoundCard : SoundCard
{
/// The signal a recorder is stopped with. `arecord` closes the file and
/// writes its header on this; killed outright it leaves a wav nothing can
/// play.
private const int Interrupt = 2;
/// How long a recorder is given to finish its file after the interrupt.
/// One that is still there after it is killed: a command that ignores the
/// signal would otherwise hold the microphone for the rest of the contest.
private static readonly TimeSpan StopGrace = TimeSpan.FromSeconds(2);
private readonly string play;
private readonly string record;
public CommandSoundCard(string play, string record)
{
this.play = play;
this.record = record;
}
/// Windows has no player on the path, so the default runs the one in
/// .NET's own class through PowerShell. PowerShell joins what follows
/// `-Command`, so the spaces in the expression do not need quoting.
public static string DefaultPlay =>
OperatingSystem.IsWindows()
? "powershell -NoProfile -Command (New-Object Media.SoundPlayer '{file}').PlaySync()"
: "aplay -q {file}";
/// Mono at 22050 Hz, which is what a voice message needs and what N1MM's
/// own recordings are. Windows has nothing to record with out of the box,
/// so there the operator names a recorder — `sox` or `ffmpeg` — before the
/// record button does anything.
public static string DefaultRecord =>
OperatingSystem.IsWindows()
? ""
: "arecord -q -f S16_LE -c 1 -r 22050 -t wav {file}";
public bool CanRecord => record.Trim().Length > 0;
public async Task PlayAsync(string file, CancellationToken cancellation = default)
{
if (!File.Exists(file))
{
throw new FileNotFoundException($"{file} is not there", file);
}
await RunAsync(play, file, cancellation).ConfigureAwait(false);
// an aborted message stops here rather than going on to the next file
cancellation.ThrowIfCancellationRequested();
}
/// The recording goes to a file beside the wanted one and is moved over it
/// at the end, so a recorder that will not start takes nothing away.
///
/// The cancellation is how a recording ends — the operator has finished
/// speaking — so it keeps the file rather than throwing it away.
public async Task RecordAsync(string file, CancellationToken cancellation = default)
{
if (!CanRecord)
{
throw new InvalidOperationException(
"there is no recorder — name one in the voice keyer window");
}
Directory.CreateDirectory(Path.GetDirectoryName(file) ?? ".");
string writing = file + ".recording";
try
{
await RunAsync(record, writing, cancellation).ConfigureAwait(false);
File.Move(writing, file, overwrite: true);
}
catch
{
Delete(writing);
throw;
}
}
/// Splits a command the way a shell would, on spaces, with double quotes
/// holding a word together. It is not a shell: a station that wants pipes
/// or a variable writes `sh -c "…"`.
private static IReadOnlyList<string> Split(string command)
{
List<string> words = [];
string word = "";
bool quoted = false;
bool has = false;
foreach (char c in command)
{
if (c == '"')
{
quoted = !quoted;
has = true;
continue;
}
if (c == ' ' && !quoted)
{
if (has)
{
words.Add(word);
}
word = "";
has = false;
continue;
}
word += c;
has = true;
}
if (has)
{
words.Add(word);
}
return words;
}
/// Runs the command with `{file}` filled in, and finishes when it has
/// exited. A cancellation stops it with an interrupt rather than a kill,
/// which is what lets a recorder finish the file it is writing.
private static async Task RunAsync(
string command,
string file,
CancellationToken cancellation)
{
IReadOnlyList<string> words = Split(command);
if (words.Count == 0)
{
throw new InvalidOperationException("no sound card command is set");
}
ProcessStartInfo start = new()
{
FileName = words[0],
RedirectStandardError = true,
UseShellExecute = false,
};
foreach (string argument in words.Skip(1))
{
start.ArgumentList.Add(argument.Replace("{file}", file));
}
Process running;
try
{
running = Process.Start(start)
?? throw new InvalidOperationException($"{words[0]} did not start");
}
catch (Exception cause) when (cause is System.ComponentModel.Win32Exception or IOException)
{
throw new InvalidOperationException($"{words[0]} could not be run: {cause.Message}", cause);
}
using (running)
using (cancellation.Register(() => _ = StopAsync(running)))
{
string errors = await running.StandardError.ReadToEndAsync(CancellationToken.None)
.ConfigureAwait(false);
await running.WaitForExitAsync(CancellationToken.None).ConfigureAwait(false);
// an interrupted recorder exits non-zero on some systems and zero
// on others, and either way it has written the file we asked for
if (running.ExitCode != 0 && !cancellation.IsCancellationRequested)
{
throw new InvalidOperationException(
$"{words[0]} exited with code {running.ExitCode}: {Tail(errors)}");
}
}
}
/// Asks the command to stop, and makes it stop if it will not. The ask is
/// the interrupt; what follows is the kill, and it takes the children with
/// it, because a command written as `sh -c "…"` leaves the program that is
/// really holding the sound card behind it.
private static async Task StopAsync(Process running)
{
try
{
if (running.HasExited)
{
return;
}
if (!OperatingSystem.IsWindows() && kill(running.Id, Interrupt) == 0)
{
await running.WaitForExitAsync().WaitAsync(StopGrace).ConfigureAwait(false);
return;
}
running.Kill(entireProcessTree: true);
}
catch (TimeoutException)
{
Kill(running);
}
catch (Exception failure) when (failure is InvalidOperationException or SystemException)
{
// it stopped on its own between two of the calls above, which is
// the end we were asking for
}
}
private static void Kill(Process running)
{
try
{
running.Kill(entireProcessTree: true);
}
catch (Exception failure) when (failure is InvalidOperationException or SystemException)
{
// it stopped while we were reaching for it
}
}
private static void Delete(string file)
{
try
{
File.Delete(file);
}
catch (Exception failure) when (failure is IOException or UnauthorizedAccessException)
{
// the half-written file is worth no more than a message about it
}
}
private static string Tail(string errors)
{
string[] lines = errors.Split('\n', StringSplitOptions.RemoveEmptyEntries);
return lines.Length == 0 ? "no output" : lines[^1].Trim();
}
[DllImport("libc", SetLastError = true)]
private static extern int kill(int pid, int signal);
}
+6
View File
@@ -48,6 +48,12 @@ public sealed class CwDaemonSender : MessageSender
public Task SetSpeedAsync(int wordsPerMinute, CancellationToken cancellation = default) =>
WriteAsync(Escape('2', wordsPerMinute.ToString()), cancellation);
/// cwdaemon's `<ESC>a`: the transmitter is keyed and stays keyed until it
/// is told to drop. The voice keyer needs it — the recording goes out of
/// the sound card, so something else has to key the radio.
public Task SetPttAsync(bool on, CancellationToken cancellation = default) =>
WriteAsync(Escape('a', on ? "1" : "0"), cancellation);
public void Dispose()
{
reading.Cancel();
+18
View File
@@ -0,0 +1,18 @@
namespace Nonemm.Keying;
/// The station's sound card, as far as a voice keyer needs one: play a wav
/// file, or record one.
public interface SoundCard
{
/// True when there is something to record with. A station that can play
/// but not record still keys its messages.
bool CanRecord { get; }
/// Finishes when the file has been played.
Task PlayAsync(string file, CancellationToken cancellation = default);
/// Records until the cancellation, and finishes when the recorder has
/// closed the file. The file is replaced only if the recording worked, so
/// a recorder that will not start leaves the old one where it was.
Task RecordAsync(string file, CancellationToken cancellation = default);
}
+36
View File
@@ -0,0 +1,36 @@
namespace Nonemm.Keying;
/// The recordings under the `Wav` folder. A message names its files the way
/// N1MM names them: relative to that folder, separated by commas when there is
/// more than one, and with a backslash between the folder and the file
/// whatever the program is running on. `Cq.wav` is therefore `Wav/Cq.wav` and
/// `OM3KFF\Cq.wav` is one operator's own.
public sealed class VoiceFiles
{
private readonly string folder;
public VoiceFiles(string folder) => this.folder = folder;
/// The files a message names, in the order they would be played. A message
/// that names none is text for the operator to read out, which no keyer
/// can say.
public static IReadOnlyList<string> NamesIn(string message) =>
[.. message
.Split(',')
.Select(name => name.Trim())
.Where(name => name.EndsWith(".wav", StringComparison.OrdinalIgnoreCase))];
/// The name is always under the `Wav` folder: a rooted one would be
/// combined away and would play a file from somewhere else entirely.
public string PathOf(string name) =>
Path.Combine(
folder,
name.Trim().Replace('\\', Path.DirectorySeparatorChar).TrimStart(Path.DirectorySeparatorChar));
public bool Has(string name) => File.Exists(PathOf(name));
/// The first file the message names that is not recorded, or null when
/// every one of them is there.
public string? FirstMissing(string message) =>
NamesIn(message).FirstOrDefault(name => !Has(name));
}
+140
View File
@@ -0,0 +1,140 @@
namespace Nonemm.Keying;
/// Puts a recording on the air, which is what a phone function key sends. The
/// message names the files, the sound card plays them, and the transmitter is
/// keyed from the first file to the last.
///
/// `keyTransmitter` is what keys it: cwdaemon's `<ESC>a`, at a station whose
/// CW keyer is a cwdaemon on the same port. Left null, nothing is keyed and
/// the radio's VOX hears the audio and keys itself, which is how most SSB
/// stations run a voice keyer anyway.
public sealed class VoiceKeyer : MessageSender
{
private readonly SoundCard sound;
private readonly VoiceFiles files;
private readonly Func<bool, CancellationToken, Task>? keyTransmitter;
/// One message plays at a time; a second waits for the first, which is
/// what the interface says a keyer does.
private readonly SemaphoreSlim playing = new(1, 1);
private readonly Lock gate = new();
private CancellationTokenSource? current;
private bool disposed;
public VoiceKeyer(
SoundCard sound,
VoiceFiles files,
Func<bool, CancellationToken, Task>? keyTransmitter = null)
{
this.sound = sound;
this.files = files;
this.keyTransmitter = keyTransmitter;
}
public bool IsReady => !disposed;
public bool ReportsCompletion => true;
public event EventHandler? Finished;
/// A recording that would not play. It happens while a message is on the
/// air rather than when it is sent, so it cannot be thrown at the caller;
/// the entry window puts it in the status line.
public event EventHandler<string>? Reported;
/// Starts the recordings and returns. `Finished` says when the last one
/// has been played, which is what alternating CQ and the actions after
/// `{END}` wait for.
public Task SendAsync(string text, CancellationToken cancellation = default)
{
IReadOnlyList<string> names = VoiceFiles.NamesIn(text);
if (names.Count == 0)
{
throw new InvalidOperationException(
$"nothing to play: \"{text}\" names no .wav file — Window ▸ Voice Keyer");
}
if (files.FirstMissing(text) is { } missing)
{
throw new InvalidOperationException(
$"no recording {missing} — Window ▸ Voice Keyer");
}
_ = PlayAsync(names);
return Task.CompletedTask;
}
public Task AbortAsync(CancellationToken cancellation = default)
{
lock (gate)
{
current?.Cancel();
}
return Task.CompletedTask;
}
/// A recording plays at the speed it was recorded at.
public Task SetSpeedAsync(int wordsPerMinute, CancellationToken cancellation = default) =>
Task.CompletedTask;
public void Dispose()
{
disposed = true;
lock (gate)
{
current?.Cancel();
current?.Dispose();
current = null;
}
playing.Dispose();
}
private async Task PlayAsync(IReadOnlyList<string> names)
{
await playing.WaitAsync().ConfigureAwait(false);
CancellationTokenSource stopping = new();
lock (gate)
{
current?.Dispose();
current = stopping;
}
try
{
await Key(true, stopping.Token).ConfigureAwait(false);
foreach (string name in names)
{
await sound.PlayAsync(files.PathOf(name), stopping.Token).ConfigureAwait(false);
}
}
catch (OperationCanceledException)
{
// Escape stopped the message, which is not a fault
}
catch (Exception failure) when (failure is InvalidOperationException or IOException)
{
Reported?.Invoke(this, failure.Message);
}
finally
{
await Unkey().ConfigureAwait(false);
playing.Release();
Finished?.Invoke(this, EventArgs.Empty);
}
}
private Task Key(bool on, CancellationToken cancellation) =>
keyTransmitter?.Invoke(on, cancellation) ?? Task.CompletedTask;
/// The transmitter comes down whatever went wrong above it. A logger that
/// leaves a station keyed because a file was missing is worse than one
/// that says nothing at all.
private async Task Unkey()
{
try
{
await Key(false, CancellationToken.None).ConfigureAwait(false);
}
catch (Exception failure) when (failure is InvalidOperationException or IOException)
{
Reported?.Invoke(this, $"the transmitter could not be unkeyed: {failure.Message}");
}
}
}
+17 -7
View File
@@ -17,10 +17,14 @@ namespace Nonemm.Session;
/// from the length of the text would be a guess, and a guess that runs short
/// keys the second radio while the first is still sending.
///
/// There is a keyer per mode — the CW keyer and the voice keyer — and a CQ
/// goes out through whichever the radio's mode calls for, so it listens to all
/// of them.
///
/// Two radios, because that is what an SO2R station has.
public sealed class AlternatingCq : IDisposable
{
private readonly MessageSender keyer;
private readonly IReadOnlyList<MessageSender> keyers;
private readonly Func<int, Task> callCqOn;
private readonly Func<TimeSpan, CancellationToken, Task> wait;
private readonly Lock gate = new();
@@ -30,16 +34,19 @@ public sealed class AlternatingCq : IDisposable
/// `callCqOn` moves the operator to that radio and sends the CQ message.
/// `wait` is there so a test does not have to sleep.
public AlternatingCq(
MessageSender keyer,
IReadOnlyList<MessageSender> keyers,
Func<int, Task> callCqOn,
TimeSpan gap,
Func<TimeSpan, CancellationToken, Task>? wait = null)
{
this.keyer = keyer;
this.keyers = keyers;
this.callCqOn = callCqOn;
Gap = gap;
this.wait = wait ?? Task.Delay;
keyer.Finished += OnFinished;
foreach (MessageSender keyer in keyers)
{
keyer.Finished += OnFinished;
}
}
/// How long to leave between the message ending and the other radio
@@ -93,8 +100,8 @@ public sealed class AlternatingCq : IDisposable
/// not raise it.
public event EventHandler<string>? Stopped;
/// True when the keyer can drive this at all.
public bool IsPossible => keyer.ReportsCompletion;
/// True when a keyer that can drive this at all is connected.
public bool IsPossible => keyers.Any(keyer => keyer.ReportsCompletion);
public void Start(int radioNumber)
{
@@ -127,7 +134,10 @@ public sealed class AlternatingCq : IDisposable
public void Dispose()
{
keyer.Finished -= OnFinished;
foreach (MessageSender keyer in keyers)
{
keyer.Finished -= OnFinished;
}
Stop();
}
+3 -2
View File
@@ -78,8 +78,9 @@ public static class MessageExpander
{
"MYCALL" => session.Me.Callsign,
// whoever is at the radio, or the station callsign at a station
// where nobody has said
"OPERATOR" => session.Session.Operator.Length > 0
// where nobody has said. `{WAVDIR}` is the folder his recordings
// are in, which is named after him, so it stands for the same
"OPERATOR" or "WAVDIR" => session.Session.Operator.Length > 0
? session.Session.Operator
: session.Me.Callsign,
"CALL" => TheirCall(session),
+5
View File
@@ -0,0 +1,5 @@
namespace Nonemm.Session;
/// One recording the program would play: which key or button plays it, what it
/// should say, and the file the message names, relative to the `Wav` folder.
public sealed record VoiceLine(string Where, string Says, string Name);
+95
View File
@@ -0,0 +1,95 @@
using System.Text.RegularExpressions;
namespace Nonemm.Session;
/// Every recording the program would play, read out of the messages that name
/// them: the phone function keys, running and searching, and WAE's four QTC
/// recordings.
///
/// A key that names the same file as another key is one line, listing both
/// keys, because there is one file to record.
public static partial class VoiceLines
{
/// N1MM's four WAE recordings, under the names it plays them from. They
/// sit in the `Wav` folder itself rather than in an operator's folder, so
/// a station's four are shared by whoever is at the microphone.
public const string QtcReady = "WAEQRV.wav";
public const string QtcAgain = "WAEAgain.wav";
public const string QtcConfirm = "WAEConfirm.wav";
public const string QtcThanks = "WAETu.wav";
/// The lines for one operator. `operatorCallsign` fills in `{WAVDIR}` and
/// `{OPERATOR}`, which is how a message names the folder of whoever is at
/// the microphone.
public static IReadOnlyList<VoiceLine> For(MessageFile phone, string operatorCallsign)
{
List<VoiceLine> lines = [];
Add(lines, phone.Keys(isRunning: true), "run");
Add(lines, phone.Keys(isRunning: false), "S&P");
foreach ((string says, string name) in Qtc())
{
Add(lines, "QTC window", says, name);
}
return [.. lines.Select(line => line with { Name = Expand(line.Name, operatorCallsign) })];
}
private static (string Says, string Name)[] Qtc() =>
[
("QRV", QtcReady),
("Again", QtcAgain),
("Confirm", QtcConfirm),
("Thanks", QtcThanks),
];
private static void Add(List<VoiceLine> lines, IReadOnlyList<FunctionKey> keys, string when)
{
for (int at = 0; at < keys.Count; at++)
{
foreach (string name in Keying.VoiceFiles.NamesIn(keys[at].Message))
{
Add(lines, $"F{at + 1} {when}", Says(keys[at]), name);
}
}
}
/// What the button says is what the operator wrote on it, without the key
/// number N1MM's files start the label with: the column beside it already
/// says which key it is.
private static string Says(FunctionKey key)
{
string label = key.Label.Trim();
int space = label.IndexOf(' ');
return space > 0 && label.StartsWith('F') ? label[(space + 1)..] : label;
}
private static void Add(List<VoiceLine> lines, string where, string says, string name)
{
int at = lines.FindIndex(line =>
string.Equals(line.Name, name, StringComparison.OrdinalIgnoreCase));
if (at < 0)
{
lines.Add(new VoiceLine(where, says, name));
return;
}
lines[at] = lines[at] with { Where = $"{lines[at].Where}, {where}" };
}
/// Both macros stand for the same thing: the folder under `Wav` named
/// after whoever is at the microphone. N1MM matches them without regard to
/// case, and so does this. With nobody at the microphone the separator
/// behind the macro goes too, so `{WAVDIR}\Cq.wav` is the `Cq.wav` in the
/// `Wav` folder itself rather than a path starting at the root.
private static string Expand(string name, string operatorCallsign)
{
string folder = operatorCallsign.Trim().ToUpperInvariant();
return WavFolder().Replace(
name,
found => folder.Length == 0 ? "" : folder + found.Groups[1].Value);
}
[GeneratedRegex(@"(?:\{WAVDIR\}|\{OPERATOR\})([\\/]?)", RegexOptions.IgnoreCase)]
private static partial Regex WavFolder();
}