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
+78 -21
View File
@@ -48,14 +48,14 @@ scorer: red for a dupe, green for a new multiplier, blue for points.
| Log | N1MM `.s3db`, Cabrillo 3.0 out, ADIF in and out | | Log | N1MM `.s3db`, Cabrillo 3.0 out, ADIF in and out |
| While typing | dupe check, multiplier check, points, country and zone from the country file, exchange filled from a call history file | | While typing | dupe check, multiplier check, points, country and zone from the country file, exchange filled from a call history file |
| While transmitting | a digital message is held in the transmit pane and fed to the engine a character at a time, so what has not gone out can still be rewritten; what has gone out is red, and the engine idles when it catches up with the cursor | | While transmitting | a digital message is held in the transmit pane and fed to the engine a character at a time, so what has not gone out can still be rewritten; what has gone out is red, and the engine idles when it catches up with the cursor |
| Windows | entry, log, check, bandmap, available mults and Qs, score summary, statistics, grey line, telnet, digital interface | | Windows | entry, log, check, bandmap, available mults and Qs, score summary, statistics, grey line, telnet, digital interface, voice keyer |
| Statistics | the log counted under any two of its fields, as N1MM's table with its `Tot` and `Accum` columns or as a stacked column graph; either one copies to the clipboard, as spaced text or as a picture | | Statistics | the log counted under any two of its fields, as N1MM's table with its `Tot` and `Accum` columns or as a stacked column graph; either one copies to the clipboard, as spaced text or as a picture |
| Editing | double-click a cell in the log, or open the whole contact with Ctrl+Y; Delete removes it. All of it goes out to the other stations | | Editing | double-click a cell in the log, or open the whole contact with Ctrl+Y; Delete removes it. All of it goes out to the other stations |
| Radio | one or two radios over hamlib `rigctld`, split, reconnecting on its own | | Radio | one or two radios over hamlib `rigctld`, split, reconnecting on its own |
| Cluster | DX cluster over telnet: a telnet window with the node's traffic, the published list of nodes, command buttons and spot filters, spots feeding the bandmap, Alt+P to spot a station | | Cluster | DX cluster over telnet: a telnet window with the node's traffic, the published list of nodes, command buttons and spot filters, spots feeding the bandmap, Alt+P to spot a station |
| Bandmap | drawn like N1MM's: a frequency scale with the receiver on it and callsigns beside it, joined by leader lines | | Bandmap | drawn like N1MM's: a frequency scale with the receiver on it and callsigns beside it, joined by leader lines |
| Network | contacts shared with the other stations of a multi-operator entry, in N1MM's own contact message | | Network | contacts shared with the other stations of a multi-operator entry, in N1MM's own contact message |
| Keying | CW through `cwdaemon` or a WinKeyer, with N1MM's message macros | | Keying | CW through `cwdaemon` or a WinKeyer, with N1MM's message macros; phone through the voice keyer, which plays each operator's own recordings |
| Digital | N1MM's digital interface window, running MMTTY or 2Tone under Wine through the same `XMMT.ocx` N1MM drives them with | | Digital | N1MM's digital interface window, running MMTTY or 2Tone under Wine through the same `XMMT.ocx` N1MM drives them with |
### The digital window ### The digital window
@@ -164,6 +164,7 @@ macros that are filled in:
| `{FREQ}`, `{FREQROUND}` | this radio's frequency in kilohertz, with `R` for the decimal point on CW | | `{FREQ}`, `{FREQROUND}` | this radio's frequency in kilohertz, with `R` for the decimal point on CW |
| `{OTHERFREQ}`, `{OTHERFREQROUND}`, `{OTHERMHZ}`, `{OTHERBAND}`, `{LRMHZ}`, `{RRMHZ}` | the other radio, for passing a station | | `{OTHERFREQ}`, `{OTHERFREQROUND}`, `{OTHERMHZ}`, `{OTHERBAND}`, `{LRMHZ}`, `{RRMHZ}` | the other radio, for passing a station |
| `{TIMESTAMP}`, `{TIME2}` | the time now | | `{TIMESTAMP}`, `{TIME2}` | the time now |
| `{WAVDIR}` | the folder the voice keyer plays from, which is named after whoever is at the microphone |
The action macros run rather than standing for text: `{WIPE}`, `{LOG}`, The action macros run rather than standing for text: `{WIPE}`, `{LOG}`,
`{RUN}`, `{S&P}`, `{SPACE}`, `{SPOTME}`, `{STOPTX}`, `{FREQUP}`, `{FREQDN}`, `{RUN}`, `{S&P}`, `{SPACE}`, `{SPOTME}`, `{STOPTX}`, `{FREQUP}`, `{FREQDN}`,
@@ -181,8 +182,54 @@ How far `{FREQUP}` and the rest move the radio is `FrequencyStepHertz` and
for the same two numbers in its Configurer. for the same two numbers in its Configurer.
The rest of N1MM's action macros — the CAT families, audio, rotators, stereo, The rest of N1MM's action macros — the CAT families, audio, rotators, stereo,
the digital TNC macros — are read and passed over, so a message that holds one the digital TNC macros, `{SETWAVDIR}` — are read and passed over, so a message
still sends the right characters. that holds one still sends the right characters.
### The voice keyer
On phone a function key plays a recording, which is what N1MM does. The message
is the name of a wav file rather than words:
F1 CQ,{WAVDIR}\Cq.wav
`{WAVDIR}` is the folder named after whoever is at the microphone, so each
operator calls CQ in his own voice without anything being edited when the radio
changes hands. A name is taken relative to `Wav`, so `WAEQRV.wav` is the one
file the whole station shares and `OM3KFF\Cq.wav` is one operator's. Several
files separated by commas play one after the other. All of this is N1MM's, so
an SSB `.mc` file written for N1MM plays here.
**Window → Voice Keyer** lists every recording the messages name, says which
ones are there, and records the ones that are not:
| Column | |
|---|---|
| Where | the keys that play it — `F1 run, F1 S&P` — or `QTC window` |
| Says | what the key's label says, which is what the recording has to say |
| File | the file name, in the folder named at the top |
| Recorded | whether it is there, for the operator picked at the top |
**Play** auditions a recording. It does not key the transmitter, so what goes
out on the air is only what a function key sends — unless the radio is on VOX,
which hears the audio and keys itself either way. **Record** replaces the file,
and the button becomes **Stop**; nothing is written over until the recorder has
finished, so a recording that goes wrong leaves the old one playable.
Playing and recording are `aplay` and `arecord` out of the box, and the two
commands are boxes at the bottom of the window: `{file}` in a command stands
for the file. A station on JACK, or one on Windows, writes its own. Why it runs
a program rather than driving the sound card itself is in
[`docs/keying.md`](docs/keying.md).
The transmitter is keyed through the same `cwdaemon` that keys CW — its
`<ESC>a` — for as long as the recording plays. The tick box turns that off for
a station whose radio keys itself on VOX. A WinKeyer station has no PTT path
from here and needs VOX.
What is not here is N1MM's advanced voicing: the recording per letter and per
number in `LettersFiles` that N1MM strings together to say a callsign or a
serial. So F5 (his call) is empty, as it is in N1MM's own default file, and a
serial number is read out by the operator.
### ESM — Enter sends the message ### ESM — Enter sends the message
@@ -455,16 +502,13 @@ pressing at a multi-operator station:
|---|---| |---|---|
| Window positions | where every open window sits is stored against the operator leaving, and the operator taking over gets his own back. A window opened later is placed as it appears | | Window positions | where every open window sits is stored against the operator leaving, and the operator taking over gets his own back. A window opened later is placed as it appears |
| Colours | the theme he last had | | Colours | the theme he last had |
| Recordings folder | the folder his wav files are in, since a voice keyer plays the recordings of whoever is at the microphone. It starts at `Wav/<his callsign>`, which is where N1MM looks, and the folder is made when he takes the radio | | Recordings folder | the folder his wav files are in: the voice keyer plays the recordings of whoever is at the microphone. It is `Wav/<his callsign>`, which is where N1MM looks, and the folder is made when he takes the radio |
An operator whose callsign is not in the list is made by typing it, and starts An operator whose callsign is not in the list is made by typing it, and starts
with the windows and colours on the screen. **Remove** drops one from the list; with the windows and colours on the screen. **Remove** drops one from the list;
the contacts he logged keep his callsign, because those are in the log rather the contacts he logged keep his callsign, because those are in the log rather
than in the settings. than in the settings.
Nothing plays the recordings yet — there is no voice keyer — so the folder is
stored and passed on rather than used.
### The log window ### The log window
The pane under the log lists what has already been worked with the call being The pane under the log lists what has already been worked with the call being
@@ -661,7 +705,9 @@ cannot drive alternating CQ, and says so rather than keying the second radio
while the first is still sending. **Config → Keyer and messages** sets the gap while the first is still sending. **Config → Keyer and messages** sets the gap
between the two, which is 100 ms by default because an SO2R box works relays. between the two, which is 100 ms by default because an SO2R box works relays.
CW only for now: it sends the F1 message, and there is no voice keyer. It sends the F1 message for the mode the radio is on, so it duels on phone as
well as on CW: the voice keyer says when a recording has finished playing the
same way `cwdaemon` says when a CW message has gone out.
Why the keying is `cwdaemon`'s job rather than the logger's, and what it would Why the keying is `cwdaemon`'s job rather than the logger's, and what it would
take to key the port directly as N1MM does, is in take to key the port directly as N1MM does, is in
@@ -753,7 +799,8 @@ worked is.
Right-click the window for **Setup**: how many QTCs a series carries, whether Right-click the window for **Setup**: how many QTCs a series carries, whether
the RX Ready and Cfm steps are stopped at, whether Hdr Agn and Agn clear what the RX Ready and Cfm steps are stopped at, whether Hdr Agn and Agn clear what
they ask for again, and the CW messages below. The defaults are N1MM's — both they ask for again, and the CW and RTTY messages. The SSB recordings are named
by N1MM and are not settings. The defaults are N1MM's — both
steps skipped, the header cleared, the line left alone. steps skipped, the header cleared, the line left alone.
On CW the window puts the traffic on the air itself, through the same keyer a On CW the window puts the traffic on the air itself, through the same keyer a
@@ -787,11 +834,21 @@ out as it stands. All of it is edited in Setup, with N1MM's defaults. The serial
number goes out as digits: N1MM can send cut numbers, and nothing here does, for number goes out as digits: N1MM can send cut numbers, and nothing here does, for
any message. any message.
Nothing is sent on SSB or RTTY. N1MM plays four recordings on SSB — QRV, Agn, On RTTY the traffic goes out through the digital window's engine, with N1MM's
Cfm and TU — and sends RTTY through its digital window; this program has no messages and N1MM's defaults on both sides: R U QRV and Save for the station
voice keyer and no digital window, so on those two modes the buttons move the reading a series out, RX Ready, All Agn, Agn n and Save for the station taking
cursor as they did before. N1MM's RTTY window also has a Send All button that it down. Send All is there too — the heading, every line with the operator's
keys the whole series at once; there is nothing to send it with here. spacing between them, then the ending, all as one message with `{TX}` and
`{RX}` around it. Clicking a line in the digital window fills the next row,
which is how a series is copied.
On SSB the buttons play N1MM's four recordings — `WAEQRV.wav`, `WAEAgain.wav`,
`WAEConfirm.wav` and `WAETu.wav`, in the `Wav` folder rather than an operator's,
which is where N1MM keeps them. RX Ready and R U QRV play the QRV one, Hdr Agn
and every Agn play the again one, Cfm plays the confirm one, and Close plays the
TU. The lines themselves are read out by the operator: a QTC line is a time, a
callsign and a serial number, and no recording can say one. Record the four in
Window ▸ Voice Keyer.
In a WAE contest the log window has N1MM's two columns, whose names do not read In a WAE contest the log window has N1MM's two columns, whose names do not read
the way they sound: **Series** says whether the row is a contact or traffic — the way they sound: **Series** says whether the row is a contact or traffic —
@@ -959,7 +1016,7 @@ windows redraw where they stand.
| `Nonemm.Rig` | Radio control over `rigctld`, and the SO2R box | | `Nonemm.Rig` | Radio control over `rigctld`, and the SO2R box |
| `Nonemm.Spotting` | Spots, the bandmap, the DX cluster client | | `Nonemm.Spotting` | Spots, the bandmap, the DX cluster client |
| `Nonemm.Network` | Contacts shared between the stations of a multi-operator entry | | `Nonemm.Network` | Contacts shared between the stations of a multi-operator entry |
| `Nonemm.Keying` | CW through `cwdaemon` or a WinKeyer | | `Nonemm.Keying` | CW through `cwdaemon` or a WinKeyer, and the voice keyer's recordings |
| `Nonemm.Digital` | MMTTY and 2Tone, over the Wine bridge in `bridge/` | | `Nonemm.Digital` | MMTTY and 2Tone, over the Wine bridge in `bridge/` |
| `Nonemm.Session` | The contest in progress, and one entry position per radio — no UI toolkit | | `Nonemm.Session` | The contest in progress, and one entry position per radio — no UI toolkit |
| `Nonemm.App` | The Avalonia windows | | `Nonemm.App` | The Avalonia windows |
@@ -970,11 +1027,11 @@ covered by plain unit tests.
## Where it stands ## Where it stands
Working: logging a contest end to end, live dupe and multiplier checking, eight Working: logging a contest end to end, live dupe and multiplier checking,
built-in contests plus user-defined ones, Cabrillo and ADIF export, ADIF import, twenty-six built-in contest families plus user-defined ones, Cabrillo and ADIF
the log, check, graphical bandmap, score and telnet windows, editing and deleting logged export, ADIF import, the log, check, graphical bandmap, score and telnet
contacts, radio control, DX cluster spots, contacts shared between networked windows, editing and deleting logged contacts, radio control, DX cluster spots,
stations, and CW keying. contacts shared between networked stations, CW keying and the voice keyer.
Checked against N1MM 1.0.11031: a log this program wrote opens in N1MM, which Checked against N1MM 1.0.11031: a log this program wrote opens in N1MM, which
reads the contest, its categories and the contacts. See reads the contest, its categories and the contacts. See
+39 -1
View File
@@ -1,6 +1,6 @@
# How CW gets on the air, and why # How CW gets on the air, and why
Written 2026-08-27. Written 2026-08-27. The voice keyer was added 2026-09-11.
Nonemm has two keying paths and neither of them times the Morse itself: Nonemm has two keying paths and neither of them times the Morse itself:
@@ -85,3 +85,41 @@ Alternating CQ needs the end of a message, and both paths report it:
Timing the message from the length of its text was rejected. The guess runs Timing the message from the length of its text was rejected. The guess runs
short exactly when the operator has turned the speed up, and a short guess keys short exactly when the operator has turned the speed up, and a short guess keys
the second radio while the first is still sending. the second radio while the first is still sending.
## The voice keyer
A phone message is a recording rather than characters, so nothing has to be
timed: the file is played, and the transmitter stays keyed until it has been.
Two decisions are worth writing down.
**The sound card is a program, not a library.** `CommandSoundCard` runs
`aplay` and `arecord`, and both commands are settings. The reason is the reason
above: the audio path belongs to whatever the station runs — ALSA, PulseAudio,
PipeWire, JACK — and a library picks one of them for the operator. A station
that routes its audio some other way writes its own command with `{file}` in
it, and a station on Windows, which has no player on the path, gets a
PowerShell line that plays through .NET's own `SoundPlayer`.
The cost is a process per message. It is a few milliseconds at the front of a
recording that runs for seconds, and no part of a voice message needs the
timing that CW needs.
**A recorder is stopped with an interrupt, not a kill.** `arecord` closes the
wav file and writes its header when it is interrupted; killed outright it
leaves a file with a header that says nothing was recorded, which nothing can
play. So the stop is `SIGINT`, and the kill is only what follows two seconds
later for a command that ignores it. 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.
**PTT is cwdaemon's `<ESC>a`**, which keys the transmitter and leaves it keyed.
That is the only way into the keying port from here, so a station with a
WinKeyer, or with no CW keyer at all, runs the voice keyer on VOX. The
`<ESC>b` request, which tells cwdaemon whether the SSB audio comes from the
microphone or the sound card, is not sent: what it switches is the daemon's own
wiring, and a station that needs it has already set it.
None of this has been run against a radio. This machine has no sound card, so
what has been exercised is the program being run, the file being filled in, the
exit code and the interrupt — with ordinary shell commands standing in for
`aplay` and `arecord`.
+29 -14
View File
@@ -20,6 +20,7 @@ is still sitting there undiscovered.
| `CwDaemonSender` | the UDP messages, and a fake daemon that answers the `<ESC>h` reply request | no `cwdaemon`, no radio keyed. | | `CwDaemonSender` | the UDP messages, and a fake daemon that answers the `<ESC>h` reply request | no `cwdaemon`, no radio keyed. |
| `MmttyEngine` and the Wine bridge | MMTTY 1.70 under Wine 10 on a machine with a sound card: started, keyed, a message transmitted and decoded back off the air, and stopped | no radio. No FSK through EXTFSK, no PTT on a serial port, and 2Tone has never been run. Keying was wrong until 2026-09-01: `SetMmttyPTT` stops a transmission and does not start one, which is why nothing ever went out before then. `docs/digital-bridge.md` | | `MmttyEngine` and the Wine bridge | MMTTY 1.70 under Wine 10 on a machine with a sound card: started, keyed, a message transmitted and decoded back off the air, and stopped | no radio. No FSK through EXTFSK, no PTT on a serial port, and 2Tone has never been run. Keying was wrong until 2026-09-01: `SetMmttyPTT` stops a transmission and does not start one, which is why nothing ever went out before then. `docs/digital-bridge.md` |
| `WinkeyerSender` | the status-byte reader, on its own | no test of the serial side, and no WinKeyer. The host-mode open sequence is from the WinKeyer datasheet; the status bits are from N1MM's `Winkey.cs`. | | `WinkeyerSender` | the status-byte reader, on its own | no test of the serial side, and no WinKeyer. The host-mode open sequence is from the WinKeyer datasheet; the status bits are from N1MM's `Winkey.cs`. |
| `CommandSoundCard` and the voice keyer | shell commands standing in for `aplay` and `arecord`: the program being run, the file filled in, the exit code, the interrupt that ends a recording | no sound card on this machine, so nothing has been played or recorded, and nothing has gone on the air. cwdaemon's `<ESC>a` PTT is from its published protocol and has never keyed a radio. |
The Cabrillo output has not been put in front of a contest sponsor's robot. The Cabrillo output has not been put in front of a contest sponsor's robot.
@@ -511,19 +512,27 @@ finishes, which is the flag on the end of `CHECKSUM`.
What the link does not do yet: the log check that compares two stations' logs What the link does not do yet: the log check that compares two stations' logs
row by row. It is a message type this program can already frame; it is not row by row. It is a message type this program can already frame; it is not
written. There is also no guard against two stations taking the same station written.
number.
**Voice keying.** `MessageSender` was written to cover a voice keyer playing a **Voice keying.** The keyer is written: a phone function key names a wav file,
recording, and nothing implements it. Each operator's recordings folder is `VoiceKeyer` plays it out of the sound card and keys the transmitter through
stored with the rest of his settings and waits on the same thing. No DVK support either, so the second radio cwdaemon while it plays, and the voice keyer window lists every recording the
of an SO2R station cannot call CQ by voice. Alternating CQ therefore works on CW messages name, says which are there and records the rest. Alternating CQ duels
only, though nothing in it is CW-specific: a voice keyer that reports when the on phone as well as on CW, because the voice keyer says when a recording has
recording has finished would drive it as it stands. finished.
**The QTC window sends on CW and RTTY, not on SSB.** On CW the header, the What is not written: N1MM's advanced voicing, which says a callsign or a serial
lines, the QRV, the TU and the again messages go out through the keyer, with number by stringing together a recording per letter and per number out of
N1MM's messages and N1MM's defaults. `LettersFiles`. That is why F5 sends nothing on phone, as it does in N1MM's own
default file. There is no DVK support either — the recordings are played on the
station's own sound card, so two radios transmitting at once share one card and
the second radio is what an SO2R station adds a box for. `{SETWAVDIR}`, which
points the folder somewhere else in the middle of a contest, is read and passed
over.
**The QTC window sends on all three modes.** On CW the header, the lines, the
QRV, the TU and the again messages go out through the keyer, with N1MM's
messages and N1MM's defaults.
On RTTY the station reading traffic out has Send All, which is N1MM's: the whole On RTTY the station reading traffic out has Send All, which is N1MM's: the whole
series as one message, the heading, then every line with the operator's spacing series as one message, the heading, then every line with the operator's spacing
@@ -547,8 +556,13 @@ Agn n and Save, its `WAERXReadyText`, `WAEAllAgnText`, `WAEAGNText` and
setup area, with N1MM's defaults. Clicking a line in the digital window fills setup area, with N1MM's defaults. Clicking a line in the digital window fills
the next row, which is how a series is copied on RTTY. the next row, which is how a series is copied on RTTY.
What is not here: N1MM's four SSB recordings — QRV, Agn, Cfm and TU — which need On SSB the four buttons N1MM has recordings for play them — `WAEQRV.wav`,
a voice keyer, and the green mark N1MM puts on each line as Send All reads it `WAEAgain.wav`, `WAEConfirm.wav` and `WAETu.wav`, out of the `Wav` folder
rather than an operator's, which is where N1MM plays them from. The lines
themselves are read out by the operator: a line is a time, a callsign and a
serial number, and advanced voicing is what N1MM says those with.
What is not here: the green mark N1MM puts on each line as Send All reads it
out, which this sends as one message and cannot follow line by line. out, which this sends as one message and cannot follow line by line.
**Cut numbers are not sent.** N1MM can key a serial number as letters — `N` for **Cut numbers are not sent.** N1MM can key a serial number as letters — `N` for
@@ -579,7 +593,8 @@ that this program can carry out are listed in the README. What is read and
passed over: the `{CAT…}` and radio-hex families, the audio and rotator macros, passed over: the `{CAT…}` and radio-hex families, the audio and rotator macros,
`{STEREOON}` and `{STEREOOFF}`, `{CONDJUMP}`, `{STEREOON}` and `{STEREOOFF}`, `{CONDJUMP}`,
`{QSYCQ}`, `{FORCELOG}`, `{SwapContests}`, the digital TNC macros (`{ENTER}`, `{QSYCQ}`, `{FORCELOG}`, `{SwapContests}`, the digital TNC macros (`{ENTER}`,
`{ESC}`, `{CTRL-A}`…), and the wav-directory macros, which wait on voice keying. `{ESC}`, `{CTRL-A}`…), and `{SETWAVDIR}`, which moves the voice keyer's folder
in the middle of a contest.
A message holding any of them still sends the right characters. A message holding any of them still sends the right characters.
The CW keyer macros are not read either: `<` and `>` for speed, `~` for a half The CW keyer macros are not read either: `<` and `>` for speed, `~` for a half
+54 -7
View File
@@ -40,6 +40,10 @@ public sealed class AppSession : IDisposable
private DateTime statusSent; private DateTime statusSent;
private DispatcherTimer? announcing; private DispatcherTimer? announcing;
private MessageSender? keyer; private MessageSender? keyer;
private VoiceKeyer? voice;
private SoundCard sound = new CommandSoundCard(
CommandSoundCard.DefaultPlay,
CommandSoundCard.DefaultRecord);
private AlternatingCq? alternating; private AlternatingCq? alternating;
private MmttyEngine? digital; private MmttyEngine? digital;
private DigitalEngineSender? digitalSender; private DigitalEngineSender? digitalSender;
@@ -63,6 +67,7 @@ public sealed class AppSession : IDisposable
Paths = paths; Paths = paths;
Settings = settings; Settings = settings;
paths.CreateFolders(); paths.CreateFolders();
Recordings = new VoiceFiles(paths.Wav);
Countries = LoadCountryFile(paths.CountryFile); Countries = LoadCountryFile(paths.CountryFile);
Calls = LoadCallDatabase(paths.CallDatabaseFile); Calls = LoadCallDatabase(paths.CallDatabaseFile);
History = LoadCallHistory(settings.CallHistoryFile); History = LoadCallHistory(settings.CallHistoryFile);
@@ -152,6 +157,29 @@ public sealed class AppSession : IDisposable
public MessageSender? Keyer => keyer; 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: /// 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 /// it runs an engine under Wine, which is not something to do to an
/// operator who is working CW. /// operator who is working CW.
@@ -175,6 +203,12 @@ public sealed class AppSession : IDisposable
public event EventHandler? ContestChanged; 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) public void Save(Settings settings)
{ {
Settings = settings; Settings = settings;
@@ -571,6 +605,8 @@ public sealed class AppSession : IDisposable
alternating = null; alternating = null;
keyer?.Dispose(); keyer?.Dispose();
keyer = null; keyer = null;
voice?.Dispose();
voice = null;
switch (Settings.KeyerKind.ToLowerInvariant()) switch (Settings.KeyerKind.ToLowerInvariant())
{ {
case "cwdaemon": case "cwdaemon":
@@ -585,11 +621,21 @@ public sealed class AppSession : IDisposable
if (keyer is not null) if (keyer is not null)
{ {
_ = keyer.SetSpeedAsync(Settings.KeyerSpeed); _ = 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); Changed?.Invoke(this, EventArgs.Empty);
} }
@@ -696,9 +742,9 @@ public sealed class AppSession : IDisposable
{ {
OperatingPosition position = positions.FirstOrDefault(p => p.RadioNumber == radioNumber) OperatingPosition position = positions.FirstOrDefault(p => p.RadioNumber == radioNumber)
?? throw new InvalidOperationException($"there is no radio {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 string template = Messages
.For(position.Mode.Category, Settings.CwMessageFile, Settings.PhoneMessageFile) .For(position.Mode.Category, Settings.CwMessageFile, Settings.PhoneMessageFile)
@@ -710,7 +756,7 @@ public sealed class AppSession : IDisposable
} }
MoveToRadio(radioNumber); MoveToRadio(radioNumber);
await PointTransmitAtAsync(radioNumber).ConfigureAwait(false); 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 /// Puts both radios in the headphones, or goes back to one. An operator
@@ -886,6 +932,7 @@ public sealed class AppSession : IDisposable
link?.Dispose(); link?.Dispose();
alternating?.Dispose(); alternating?.Dispose();
keyer?.Dispose(); keyer?.Dispose();
voice?.Dispose();
box?.Dispose(); box?.Dispose();
store?.Dispose(); store?.Dispose();
} }
+13
View File
@@ -1,6 +1,7 @@
using System.Reflection; using System.Reflection;
using System.Text.Json; using System.Text.Json;
using Nonemm.Core; using Nonemm.Core;
using Nonemm.Keying;
using Nonemm.Network; using Nonemm.Network;
using Nonemm.Session; using Nonemm.Session;
@@ -171,6 +172,18 @@ public sealed record Settings
/// 100 ms, because an SO2R box works relays. /// 100 ms, because an SO2R box works relays.
public int AlternatingCqGapMs { get; init; } = 100; 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: /// 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 /// 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 /// 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, 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 = public const string DefaultPhone =
""" """
# Phone function keys. # Phone function keys. Each one names a recording under the Wav folder;
F1 CQ,CQ CONTEST {MYCALL} # {WAVDIR} is the folder of whoever is at the microphone.
F2 Exch,{EXCH} F1 CQ,{WAVDIR}\Cq.wav
F3 TU,THANK YOU {MYCALL} F2 Exch,{WAVDIR}\Exchange.wav
F4 MyCall,{MYCALL} F3 TU,{WAVDIR}\Thanks.wav
F5 HisCall,! F4 MyCall,{WAVDIR}\MyCall.wav
F6 QSO B4,QSO BEFORE F5 HisCall,
F7 ?,PLEASE REPEAT F6 QSO B4,{WAVDIR}\QsoB4.wav
F8 Agn,AGAIN F7 ?,{WAVDIR}\Repeat.wav
F9 Nr?,NUMBER PLEASE F8 Agn,{WAVDIR}\AllAgain.wav
F10 Call?,YOUR CALL PLEASE F9 Nr?,{WAVDIR}\NumberAgain.wav
F10 Call?,{WAVDIR}\CallAgain.wav
F11 Spot, F11 Spot,
F12 Wipe, F12 Wipe,
"""; """;
+3 -6
View File
@@ -16,13 +16,10 @@ public sealed partial class EntryWindow
/// actions after `{END}` still run. /// actions after `{END}` still run.
private static readonly TimeSpan SendingPatience = TimeSpan.FromMinutes(2); private static readonly TimeSpan SendingPatience = TimeSpan.FromMinutes(2);
/// What sends this radio's messages: the digital engine on a digital mode, /// What sends this radio's messages, which the mode decides: a message
/// and the CW or voice keyer otherwise. A message therefore goes out the /// therefore goes out the way the mode needs without the caller choosing.
/// way the mode needs without the caller choosing.
private MessageSender? Sender => private MessageSender? Sender =>
Logging?.Mode.Category == ModeCategory.Digital && session.DigitalKeyer is { IsReady: true } engine session.SenderFor(Logging?.Mode.Category ?? ModeCategory.Cw);
? engine
: session.Keyer;
/// What to say when a message has nowhere to go. The mode decides which /// 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 /// 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 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. /// N1MM's digital interface window, which is where RTTY is worked from.
private void OnShowDigital(object? sender, RoutedEventArgs e) => private void OnShowDigital(object? sender, RoutedEventArgs e) =>
Show(() => new DigitalWindow(session, this, radioNumber)); Show(() => new DigitalWindow(session, this, radioNumber));
@@ -117,6 +117,7 @@ public sealed partial class EntryWindow
(nameof(GraylineWindow), () => _ = Show(() => new GraylineWindow(session), activate: false)), (nameof(GraylineWindow), () => _ = Show(() => new GraylineWindow(session), activate: false)),
(nameof(TelnetWindow), () => _ = Show(() => new TelnetWindow(session, Tune), activate: false)), (nameof(TelnetWindow), () => _ = Show(() => new TelnetWindow(session, Tune), activate: false)),
(nameof(DigitalWindow), () => _ = Show(() => new DigitalWindow(session, this, radioNumber), 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 /// 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="Statistics" Click="OnShowStatistics" />
<MenuItem Header="Telnet" Click="OnShowTelnet" /> <MenuItem Header="Telnet" Click="OnShowTelnet" />
<MenuItem Header="Digital Interface" Click="OnShowDigital" /> <MenuItem Header="Digital Interface" Click="OnShowDigital" />
<MenuItem Header="Voice Keyer" Click="OnShowVoiceKeyer" />
</MenuItem> </MenuItem>
</Menu> </Menu>
+6 -4
View File
@@ -68,6 +68,9 @@ public sealed partial class EntryWindow : Window
InitializeComponent(); InitializeComponent();
BuildFunctionKeys(); BuildFunctionKeys();
session.Changed += (_, _) => Dispatcher.UIThread.Post(Refresh); 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(() => session.ContestChanged += (_, _) => Dispatcher.UIThread.Post(() =>
{ {
BuildEntryBoxes(); BuildEntryBoxes();
@@ -195,10 +198,9 @@ public sealed partial class EntryWindow : Window
{ {
yield return ("station network", session.ApplyNetworkSettings); yield return ("station network", session.ApplyNetworkSettings);
} }
if (session.Settings.KeyerKind != "none") // 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); yield return ("keyer", session.ApplyKeyerSettings);
}
if (session.Settings.So2rBoxPort.Length > 0) if (session.Settings.So2rBoxPort.Length > 0)
{ {
yield return ("SO2R box", session.ApplySo2rSettings); yield return ("SO2R box", session.ApplySo2rSettings);
+39 -10
View File
@@ -102,7 +102,7 @@ public sealed partial class QtcWindow : Window
return isSending return isSending
? IsRtty ? IsRtty
? "Send All reads the whole series out — Snd n sends one QTC again" ? "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."; : "Type the header, then a line per QTC: time, callsign, serial number.";
} }
return isSending return isSending
@@ -116,9 +116,11 @@ public sealed partial class QtcWindow : Window
private bool IsRtty => position.Contest.Modes.Contains(ModeCategory.Digital); private bool IsRtty => position.Contest.Modes.Contains(ModeCategory.Digital);
/// Whether this window puts anything on the air. SSB needs a voice keyer private bool IsPhone => !IsCw && !IsRtty;
/// and there is none here, so on SSB the operator speaks and the window
/// only writes the traffic down. /// 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 bool Keys => IsCw || IsRtty;
private Settings Settings => session.Settings; private Settings Settings => session.Settings;
@@ -149,8 +151,10 @@ public sealed partial class QtcWindow : Window
CountsText.Text = traffic.CountsFor(station).Text; CountsText.Text = traffic.CountsFor(station).Text;
KeysText.Text = KeysLine(); KeysText.Text = KeysLine();
// N1MM's labels: the buttons say what they do in the direction the // 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 // traffic is going, and the ones with nothing to do are hidden. This
ReadyButton.IsVisible = Keys || !isSending; // 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" ReadyButton.Content = !IsCw ? isSending ? "R U QRV" : "RX Ready"
: isSending ? "R U QRV" : "QRV"; : isSending ? "R U QRV" : "QRV";
HeaderAgainButton.Content = isSending ? "Snd Hdr" : "Hdr Agn"; 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; /// 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 /// taking it down, it says we are. N1MM keys `QRV?` and `QRV` on CW and
/// recording on SSB, which there is no keyer for here. /// plays one recording either way on SSB.
private void OnReady(object? sender, RoutedEventArgs e) private void OnReady(object? sender, RoutedEventArgs e)
{ {
if (IsCw) if (IsCw)
@@ -501,6 +505,10 @@ public sealed partial class QtcWindow : Window
_ = send(QtcMessages.Rtty( _ = send(QtcMessages.Rtty(
isSending ? Settings.QtcRttyAreYouReady : Settings.QtcRttyReady)); isSending ? Settings.QtcRttyAreYouReady : Settings.QtcRttyReady));
} }
else
{
_ = send(VoiceLines.QtcReady);
}
Control next = isSending ? SendAllButton.IsVisible ? SendAllButton : rows[0].Again Control next = isSending ? SendAllButton.IsVisible ? SendAllButton : rows[0].Again
: HeaderBox; : HeaderBox;
next.Focus(); next.Focus();
@@ -526,6 +534,10 @@ public sealed partial class QtcWindow : Window
{ {
_ = send(QtcMessages.Rtty(QtcMessages.RttyHeaderAgain)); _ = send(QtcMessages.Rtty(QtcMessages.RttyHeaderAgain));
} }
else
{
_ = send(VoiceLines.QtcAgain);
}
if (session.Settings.QtcHeaderAgainClears) if (session.Settings.QtcHeaderAgainClears)
{ {
HeaderBox.Text = ""; HeaderBox.Text = "";
@@ -533,7 +545,14 @@ public sealed partial class QtcWindow : Window
HeaderBox.Focus(); 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, /// 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. /// 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}"); _ = 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) if (Settings.QtcAgainClearsLine)
{ {
rows[at].Clear(); 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 // 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 // `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 // `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 ?? "")); _ = 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) => public Task SetSpeedAsync(int wordsPerMinute, CancellationToken cancellation = default) =>
WriteAsync(Escape('2', wordsPerMinute.ToString()), cancellation); 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() public void Dispose()
{ {
reading.Cancel(); 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 /// 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. /// 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. /// Two radios, because that is what an SO2R station has.
public sealed class AlternatingCq : IDisposable public sealed class AlternatingCq : IDisposable
{ {
private readonly MessageSender keyer; private readonly IReadOnlyList<MessageSender> keyers;
private readonly Func<int, Task> callCqOn; private readonly Func<int, Task> callCqOn;
private readonly Func<TimeSpan, CancellationToken, Task> wait; private readonly Func<TimeSpan, CancellationToken, Task> wait;
private readonly Lock gate = new(); 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. /// `callCqOn` moves the operator to that radio and sends the CQ message.
/// `wait` is there so a test does not have to sleep. /// `wait` is there so a test does not have to sleep.
public AlternatingCq( public AlternatingCq(
MessageSender keyer, IReadOnlyList<MessageSender> keyers,
Func<int, Task> callCqOn, Func<int, Task> callCqOn,
TimeSpan gap, TimeSpan gap,
Func<TimeSpan, CancellationToken, Task>? wait = null) Func<TimeSpan, CancellationToken, Task>? wait = null)
{ {
this.keyer = keyer; this.keyers = keyers;
this.callCqOn = callCqOn; this.callCqOn = callCqOn;
Gap = gap; Gap = gap;
this.wait = wait ?? Task.Delay; 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 /// How long to leave between the message ending and the other radio
@@ -93,8 +100,8 @@ public sealed class AlternatingCq : IDisposable
/// not raise it. /// not raise it.
public event EventHandler<string>? Stopped; public event EventHandler<string>? Stopped;
/// True when the keyer can drive this at all. /// True when a keyer that can drive this at all is connected.
public bool IsPossible => keyer.ReportsCompletion; public bool IsPossible => keyers.Any(keyer => keyer.ReportsCompletion);
public void Start(int radioNumber) public void Start(int radioNumber)
{ {
@@ -127,7 +134,10 @@ public sealed class AlternatingCq : IDisposable
public void Dispose() public void Dispose()
{ {
keyer.Finished -= OnFinished; foreach (MessageSender keyer in keyers)
{
keyer.Finished -= OnFinished;
}
Stop(); Stop();
} }
+3 -2
View File
@@ -78,8 +78,9 @@ public static class MessageExpander
{ {
"MYCALL" => session.Me.Callsign, "MYCALL" => session.Me.Callsign,
// whoever is at the radio, or the station callsign at a station // whoever is at the radio, or the station callsign at a station
// where nobody has said // where nobody has said. `{WAVDIR}` is the folder his recordings
"OPERATOR" => session.Session.Operator.Length > 0 // 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.Session.Operator
: session.Me.Callsign, : session.Me.Callsign,
"CALL" => TheirCall(session), "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();
}
@@ -0,0 +1,134 @@
namespace Nonemm.Keying.Tests;
/// The sound card is driven with ordinary shell commands standing in for
/// `aplay` and `arecord`, so what the program does with a command — the file
/// it fills in, the exit code, the interrupt — is what is asserted.
public class CommandSoundCardTests : IDisposable
{
private readonly string folder = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
public CommandSoundCardTests() => Directory.CreateDirectory(folder);
public void Dispose()
{
Directory.Delete(folder, recursive: true);
GC.SuppressFinalize(this);
}
private string In(string name) => Path.Combine(folder, name);
/// Double quotes hold a word together, so a command whose argument has
/// spaces in it arrives as one argument.
[Fact]
public async Task AQuotedWordHoldsTogether()
{
string played = In("played");
File.WriteAllText(In("Cq.wav"), "hello");
CommandSoundCard card = new($$"""sh -c "cp $0 {{played}}" {file}""", "");
await card.PlayAsync(In("Cq.wav"));
Assert.Equal("hello", File.ReadAllText(played));
}
[Fact]
public async Task PlayingRunsTheCommandOnTheFile()
{
string played = In("played");
File.WriteAllText(In("Cq.wav"), "hello");
CommandSoundCard card = new($"cp {{file}} {played}", "");
await card.PlayAsync(In("Cq.wav"));
Assert.Equal("hello", File.ReadAllText(played));
}
[Fact]
public async Task PlayingAFileThatIsNotThereSaysWhichFile()
{
CommandSoundCard card = new("cp {file} /dev/null", "");
FileNotFoundException missing = await Assert.ThrowsAsync<FileNotFoundException>(
() => card.PlayAsync(In("Cq.wav")));
Assert.Contains("Cq.wav", missing.Message);
}
[Fact]
public async Task APlayerThatIsNotInstalledSaysSo()
{
File.WriteAllText(In("Cq.wav"), "");
CommandSoundCard card = new("nonemm-no-such-player {file}", "");
InvalidOperationException failed = await Assert.ThrowsAsync<InvalidOperationException>(
() => card.PlayAsync(In("Cq.wav")));
Assert.Contains("nonemm-no-such-player", failed.Message);
}
[Fact]
public async Task APlayerThatExitsWithAnErrorIsNotPassedOver()
{
File.WriteAllText(In("Cq.wav"), "");
CommandSoundCard card = new("""sh -c "echo broken pipe >&2; exit 3" """, "");
InvalidOperationException failed = await Assert.ThrowsAsync<InvalidOperationException>(
() => card.PlayAsync(In("Cq.wav")));
Assert.Contains("code 3", failed.Message);
Assert.Contains("broken pipe", failed.Message);
}
[Fact]
public async Task RecordingWritesTheFile()
{
CommandSoundCard card = new("", """sh -c "echo recorded > $0" {file}""");
await card.RecordAsync(In("Cq.wav"));
Assert.Equal("recorded", File.ReadAllText(In("Cq.wav")).Trim());
}
/// Stopping a recorder is what the operator does at the end of every
/// recording, so what it was writing is the recording rather than a
/// failure.
[Fact]
public async Task ARecorderThatIsStoppedKeepsWhatItWrote()
{
CommandSoundCard card = new("", """sh -c "echo recorded > $0; sleep 30" {file}""");
using CancellationTokenSource stopping = new();
Task recording = card.RecordAsync(In("Cq.wav"), stopping.Token);
while (!File.Exists(In("Cq.wav") + ".recording"))
{
await Task.Delay(20);
}
await stopping.CancelAsync();
await recording.WaitAsync(TimeSpan.FromSeconds(10));
Assert.Equal("recorded", File.ReadAllText(In("Cq.wav")).Trim());
}
/// The old recording is worth more than a failed one: a station that
/// cannot record today still has the voice it recorded last year.
[Fact]
public async Task ARecorderThatFailsLeavesTheOldRecordingAlone()
{
File.WriteAllText(In("Cq.wav"), "the old one");
CommandSoundCard card = new("", "nonemm-no-such-recorder {file}");
await Assert.ThrowsAsync<InvalidOperationException>(() => card.RecordAsync(In("Cq.wav")));
Assert.Equal("the old one", File.ReadAllText(In("Cq.wav")));
Assert.False(File.Exists(In("Cq.wav") + ".recording"));
}
[Fact]
public async Task AStationWithNoRecorderIsToldRatherThanLeftWaiting()
{
CommandSoundCard card = new("aplay {file}", "");
Assert.False(card.CanRecord);
await Assert.ThrowsAsync<InvalidOperationException>(() => card.RecordAsync(In("Cq.wav")));
}
}
@@ -0,0 +1,60 @@
namespace Nonemm.Keying.Tests;
public class VoiceFilesTests
{
[Fact]
public void AMessageNamesTheFilesItPlays()
{
IReadOnlyList<string> names = VoiceFiles.NamesIn(@"OM3KFF\Cq.wav, OM3KFF\Exchange.wav");
Assert.Equal([@"OM3KFF\Cq.wav", @"OM3KFF\Exchange.wav"], names);
}
/// A phone message that is words rather than a file name is what the
/// operator would say himself, and no keyer can say it.
[Fact]
public void TextThatNamesNoRecordingIsNoFile()
{
Assert.Empty(VoiceFiles.NamesIn("CQ CONTEST OM5M"));
}
/// N1MM writes the separator as a backslash whatever it is running on.
[Fact]
public void TheFolderSeparatorIsN1mmsWhateverThisRunsOn()
{
VoiceFiles files = new("/home/op/Wav");
Assert.Equal(
Path.Combine("/home/op/Wav", "OM3KFF", "Cq.wav"),
files.PathOf(@"OM3KFF\Cq.wav"));
}
/// A rooted name would be combined away and would play a file from
/// somewhere else entirely.
[Fact]
public void ANameIsAlwaysUnderTheWavFolder()
{
VoiceFiles files = new("/home/op/Wav");
Assert.Equal(Path.Combine("/home/op/Wav", "etc", "shadow"), files.PathOf("/etc/shadow"));
}
[Fact]
public void TheFirstRecordingThatIsNotThereIsNamed()
{
string folder = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
Directory.CreateDirectory(folder);
try
{
File.WriteAllText(Path.Combine(folder, "Cq.wav"), "");
VoiceFiles files = new(folder);
Assert.Null(files.FirstMissing("Cq.wav"));
Assert.Equal("Exchange.wav", files.FirstMissing("Cq.wav,Exchange.wav"));
}
finally
{
Directory.Delete(folder, recursive: true);
}
}
}
@@ -0,0 +1,169 @@
namespace Nonemm.Keying.Tests;
/// The keyer is driven with a sound card that records what it was asked to
/// play instead of playing it, so what goes to the card and what the
/// transmitter does are both asserted.
public class VoiceKeyerTests : IDisposable
{
private readonly string folder = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
private readonly FakeSoundCard card = new();
private readonly List<bool> keying = [];
public VoiceKeyerTests()
{
Directory.CreateDirectory(folder);
Record("Cq.wav");
Record("Exchange.wav");
}
public void Dispose()
{
Directory.Delete(folder, recursive: true);
GC.SuppressFinalize(this);
}
private void Record(string name) => File.WriteAllText(Path.Combine(folder, name), "");
private VoiceKeyer Keyer() =>
new(card, new VoiceFiles(folder), (on, _) =>
{
lock (keying)
{
keying.Add(on);
}
return Task.CompletedTask;
});
private static async Task Finished(VoiceKeyer keyer, Func<Task> send)
{
TaskCompletionSource done = new();
keyer.Finished += (_, _) => done.TrySetResult();
await send();
await done.Task.WaitAsync(TimeSpan.FromSeconds(5));
}
[Fact]
public async Task TheFilesAMessageNamesArePlayedInOrder()
{
using VoiceKeyer keyer = Keyer();
await Finished(keyer, () => keyer.SendAsync("Cq.wav,Exchange.wav"));
Assert.Equal(
[Path.Combine(folder, "Cq.wav"), Path.Combine(folder, "Exchange.wav")],
card.Played);
}
/// The recording goes out of the sound card, so the transmitter has to be
/// keyed for as long as it plays and dropped at the end.
[Fact]
public async Task TheTransmitterIsKeyedForTheMessageAndDroppedAfterIt()
{
using VoiceKeyer keyer = Keyer();
await Finished(keyer, () => keyer.SendAsync("Cq.wav"));
Assert.Equal([true, false], keying);
}
[Fact]
public async Task AMessageThatNamesARecordingThatIsNotThereIsRefused()
{
using VoiceKeyer keyer = Keyer();
InvalidOperationException refused = await Assert.ThrowsAsync<InvalidOperationException>(
() => keyer.SendAsync("Cq.wav,Thanks.wav"));
Assert.Contains("Thanks.wav", refused.Message);
Assert.Empty(card.Played);
}
/// Nothing is keyed for a message that could not be played: a transmitter
/// held up over a missing file is a stuck carrier.
[Fact]
public async Task ARefusedMessageDoesNotKeyTheTransmitter()
{
using VoiceKeyer keyer = Keyer();
await Assert.ThrowsAsync<InvalidOperationException>(() => keyer.SendAsync("Thanks.wav"));
Assert.Empty(keying);
}
[Fact]
public async Task TextWithNoRecordingInItIsRefused()
{
using VoiceKeyer keyer = Keyer();
InvalidOperationException refused = await Assert.ThrowsAsync<InvalidOperationException>(
() => keyer.SendAsync("CQ CONTEST OM5M"));
Assert.Contains("names no .wav", refused.Message);
}
/// Escape mid-message: what is playing stops, and the transmitter comes
/// down with it.
[Fact]
public async Task AbortStopsTheRecordingAndDropsTheTransmitter()
{
card.HoldUntilCancelled = true;
using VoiceKeyer keyer = Keyer();
await Finished(keyer, async () =>
{
await keyer.SendAsync("Cq.wav,Exchange.wav");
await card.Playing.Task.WaitAsync(TimeSpan.FromSeconds(5));
await keyer.AbortAsync();
});
Assert.Single(card.Played);
Assert.Equal([true, false], keying);
}
/// A card that will not play says so while the message is going out, by
/// which time the caller has been answered and there is nothing left to
/// throw at.
[Fact]
public async Task APlayerThatFailsIsReported()
{
card.Failure = new InvalidOperationException("aplay is not installed");
using VoiceKeyer keyer = Keyer();
List<string> reported = [];
keyer.Reported += (_, what) => reported.Add(what);
await Finished(keyer, () => keyer.SendAsync("Cq.wav"));
Assert.Equal(["aplay is not installed"], reported);
Assert.Equal([true, false], keying);
}
private sealed class FakeSoundCard : SoundCard
{
public List<string> Played { get; } = [];
public TaskCompletionSource Playing { get; } = new();
public bool HoldUntilCancelled { get; set; }
public Exception? Failure { get; set; }
public bool CanRecord => true;
public async Task PlayAsync(string file, CancellationToken cancellation = default)
{
Played.Add(file);
Playing.TrySetResult();
if (Failure is not null)
{
throw Failure;
}
if (HoldUntilCancelled)
{
await Task.Delay(Timeout.Infinite, cancellation);
}
}
public Task RecordAsync(string file, CancellationToken cancellation = default) =>
throw new NotSupportedException();
}
}
@@ -34,7 +34,7 @@ public class AlternatingCqTests
private static AlternatingCq Driving(FakeKeyer keyer, List<int> called) => private static AlternatingCq Driving(FakeKeyer keyer, List<int> called) =>
new( new(
keyer, [keyer],
radio => radio =>
{ {
called.Add(radio); called.Add(radio);
@@ -154,13 +154,37 @@ public class AlternatingCqTests
Assert.Empty(called); Assert.Empty(called);
} }
/// There is a keyer per mode, and a CQ that goes out through the voice
/// keyer has to carry the alternation on the same way a CW one does.
[Fact]
public void AMessageFinishingAtAnyOfTheKeyersMovesTheCq()
{
FakeKeyer cw = new();
FakeKeyer voice = new();
List<int> called = [];
using AlternatingCq cq = new(
[cw, voice],
radio =>
{
called.Add(radio);
return Task.CompletedTask;
},
TimeSpan.Zero,
NoWait);
cq.Start(1);
voice.FinishMessage();
Assert.Equal([1, 2], called);
}
[Fact] [Fact]
public void AKeyerThatHasGoneAwayStopsTheWholeThing() public void AKeyerThatHasGoneAwayStopsTheWholeThing()
{ {
FakeKeyer keyer = new(); FakeKeyer keyer = new();
List<string> reasons = []; List<string> reasons = [];
using AlternatingCq cq = new( using AlternatingCq cq = new(
keyer, [keyer],
_ => throw new InvalidOperationException("could not reach cwdaemon at 127.0.0.1:6789"), _ => throw new InvalidOperationException("could not reach cwdaemon at 127.0.0.1:6789"),
TimeSpan.Zero, TimeSpan.Zero,
NoWait); NoWait);
@@ -0,0 +1,69 @@
using Nonemm.Session;
namespace Nonemm.Session.Tests;
/// What the voice keyer window lists: the recordings the phone function keys
/// name, for one operator.
public class VoiceLinesTests
{
private static MessageFile Phone(string text) => MessageFile.Parse(text);
[Fact]
public void AKeyIsListedUnderTheFileItNames()
{
IReadOnlyList<VoiceLine> lines = VoiceLines.For(
Phone(@"F1 CQ,{WAVDIR}\Cq.wav"),
"om3kff");
VoiceLine cq = lines[0];
Assert.Equal("F1 run, F1 S&P", cq.Where);
Assert.Equal("CQ", cq.Says);
Assert.Equal(@"OM3KFF\Cq.wav", cq.Name);
}
/// Two keys naming one file are one line: there is one recording to make.
[Fact]
public void TheSameFileOnTwoKeysIsOneLine()
{
IReadOnlyList<VoiceLine> lines = VoiceLines.For(
Phone($"F1 CQ,Cq.wav\n{Blank(11)}F1 S&P CQ,Cq.wav"),
"");
VoiceLine cq = Assert.Single(lines, line => line.Name == "Cq.wav");
Assert.Equal("F1 run, F1 S&P", cq.Where);
}
/// With nobody at the microphone the recordings are the ones in the `Wav`
/// folder itself, not a path starting at the root.
[Fact]
public void WithNoOperatorTheFolderGoesAway()
{
IReadOnlyList<VoiceLine> lines = VoiceLines.For(Phone(@"F1 CQ,{WAVDIR}\Cq.wav"), "");
Assert.Equal("Cq.wav", lines[0].Name);
}
[Fact]
public void AKeyThatIsWordsRatherThanARecordingIsNotListed()
{
IReadOnlyList<VoiceLine> lines = VoiceLines.For(Phone("F1 CQ,CQ CONTEST OM5M"), "OM5M");
Assert.DoesNotContain(lines, line => line.Where.StartsWith("F1"));
}
/// WAE's four are always there: the QTC window plays them, and they are in
/// the `Wav` folder rather than an operator's, which is where N1MM keeps
/// them.
[Fact]
public void TheQtcRecordingsAreListedWhateverTheMessagesSay()
{
IReadOnlyList<VoiceLine> lines = VoiceLines.For(Phone("F1 CQ,"), "OM3KFF");
Assert.Equal(
[VoiceLines.QtcReady, VoiceLines.QtcAgain, VoiceLines.QtcConfirm, VoiceLines.QtcThanks],
lines.Select(line => line.Name));
Assert.All(lines, line => Assert.Equal("QTC window", line.Where));
}
private static string Blank(int lines) => string.Concat(Enumerable.Repeat("\n", lines));
}