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
196 lines
5.6 KiB
C#
196 lines
5.6 KiB
C#
using Nonemm.Keying;
|
|
|
|
namespace Nonemm.Session;
|
|
|
|
/// Calls CQ on one radio, and when that message has gone out, moves to the
|
|
/// other radio and calls there. N1MM calls it dueling CQs and toggles it with
|
|
/// Ctrl+B.
|
|
///
|
|
/// Only the CQ message carries the alternation on. A station answers, the
|
|
/// operator sends their call and the exchange, and those messages leave the
|
|
/// alternation where it is until the next CQ goes out. N1MM works the same
|
|
/// way: it holds on any other message and on Escape, and carries on once the
|
|
/// CQ message is sent again, which is what happens after the contact is
|
|
/// logged.
|
|
///
|
|
/// It runs off the keyer's completion signal. Working out when a message ends
|
|
/// 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 IReadOnlyList<MessageSender> keyers;
|
|
private readonly Func<int, Task> callCqOn;
|
|
private readonly Func<TimeSpan, CancellationToken, Task> wait;
|
|
private readonly Lock gate = new();
|
|
private CancellationTokenSource? running;
|
|
private bool carriesOn = true;
|
|
|
|
/// `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(
|
|
IReadOnlyList<MessageSender> keyers,
|
|
Func<int, Task> callCqOn,
|
|
TimeSpan gap,
|
|
Func<TimeSpan, CancellationToken, Task>? wait = null)
|
|
{
|
|
this.keyers = keyers;
|
|
this.callCqOn = callCqOn;
|
|
Gap = gap;
|
|
this.wait = wait ?? Task.Delay;
|
|
foreach (MessageSender keyer in keyers)
|
|
{
|
|
keyer.Finished += OnFinished;
|
|
}
|
|
}
|
|
|
|
/// How long to leave between the message ending and the other radio
|
|
/// starting. The relays of an SO2R box need a moment.
|
|
public TimeSpan Gap { get; }
|
|
|
|
public bool IsRunning
|
|
{
|
|
get
|
|
{
|
|
lock (gate)
|
|
{
|
|
return running is not null;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The radio it is calling on now.
|
|
public int RadioNumber { get; private set; }
|
|
|
|
/// True while a message finishing moves the CQ to the other radio, false
|
|
/// while the operator is working a station.
|
|
public bool CarriesOn
|
|
{
|
|
get
|
|
{
|
|
lock (gate)
|
|
{
|
|
return carriesOn;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Says the CQ message has gone out, so the next message that finishes
|
|
/// moves the CQ to the other radio.
|
|
public void CarryOn() => Set(true);
|
|
|
|
/// Says something else has gone out, or that the operator has stopped the
|
|
/// keyer, so the CQ stays where it is until the next one is sent.
|
|
public void Hold() => Set(false);
|
|
|
|
private void Set(bool value)
|
|
{
|
|
lock (gate)
|
|
{
|
|
carriesOn = value;
|
|
}
|
|
}
|
|
|
|
/// Raised when it stops by itself, with the reason. Stopping by hand does
|
|
/// not raise it.
|
|
public event EventHandler<string>? Stopped;
|
|
|
|
/// 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)
|
|
{
|
|
if (!IsPossible)
|
|
{
|
|
throw new InvalidOperationException("this keyer does not report when a message has gone out");
|
|
}
|
|
CancellationToken token;
|
|
lock (gate)
|
|
{
|
|
running?.Cancel();
|
|
running?.Dispose();
|
|
running = new CancellationTokenSource();
|
|
token = running.Token;
|
|
}
|
|
RadioNumber = radioNumber;
|
|
Set(true);
|
|
_ = CallAsync(radioNumber, token);
|
|
}
|
|
|
|
public void Stop()
|
|
{
|
|
lock (gate)
|
|
{
|
|
running?.Cancel();
|
|
running?.Dispose();
|
|
running = null;
|
|
}
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
foreach (MessageSender keyer in keyers)
|
|
{
|
|
keyer.Finished -= OnFinished;
|
|
}
|
|
Stop();
|
|
}
|
|
|
|
private void OnFinished(object? sender, EventArgs e)
|
|
{
|
|
CancellationToken token;
|
|
lock (gate)
|
|
{
|
|
if (running is null || !carriesOn)
|
|
{
|
|
return;
|
|
}
|
|
token = running.Token;
|
|
}
|
|
_ = NextAsync(token);
|
|
}
|
|
|
|
private async Task NextAsync(CancellationToken token)
|
|
{
|
|
try
|
|
{
|
|
await wait(Gap, token).ConfigureAwait(false);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
return;
|
|
}
|
|
if (token.IsCancellationRequested)
|
|
{
|
|
return;
|
|
}
|
|
RadioNumber = RadioNumber == 1 ? 2 : 1;
|
|
await CallAsync(RadioNumber, token).ConfigureAwait(false);
|
|
}
|
|
|
|
/// A keyer that has gone away stops the whole thing rather than leaving one
|
|
/// radio calling into a dead port.
|
|
private async Task CallAsync(int radioNumber, CancellationToken token)
|
|
{
|
|
try
|
|
{
|
|
await callCqOn(radioNumber).ConfigureAwait(false);
|
|
}
|
|
catch (Exception e) when (e is InvalidOperationException or IOException)
|
|
{
|
|
// read before Stop, which cancels this same token
|
|
bool stoppedByHand = token.IsCancellationRequested;
|
|
Stop();
|
|
if (!stoppedByHand)
|
|
{
|
|
Stopped?.Invoke(this, e.Message);
|
|
}
|
|
}
|
|
}
|
|
}
|