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:
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user