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();
}