diff --git a/README.md b/README.md index 1c1cd00..608ded2 100644 --- a/README.md +++ b/README.md @@ -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 | | 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 | -| 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 | | 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 | | 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 | | 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 | ### 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 | | `{OTHERFREQ}`, `{OTHERFREQROUND}`, `{OTHERMHZ}`, `{OTHERBAND}`, `{LRMHZ}`, `{RRMHZ}` | the other radio, for passing a station | | `{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}`, `{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. 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 -still sends the right characters. +the digital TNC macros, `{SETWAVDIR}` — are read and passed over, so a message +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 +`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 @@ -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 | | 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/`, 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/`, 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 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 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 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 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 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 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. 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 any message. -Nothing is sent on SSB or RTTY. N1MM plays four recordings on SSB — QRV, Agn, -Cfm and TU — and sends RTTY through its digital window; this program has no -voice keyer and no digital window, so on those two modes the buttons move the -cursor as they did before. N1MM's RTTY window also has a Send All button that -keys the whole series at once; there is nothing to send it with here. +On RTTY the traffic goes out through the digital window's engine, with N1MM's +messages and N1MM's defaults on both sides: R U QRV and Save for the station +reading a series out, RX Ready, All Agn, Agn n and Save for the station taking +it down. Send All is there too — the heading, every line with the operator's +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 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.Spotting` | Spots, the bandmap, the DX cluster client | | `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.Session` | The contest in progress, and one entry position per radio — no UI toolkit | | `Nonemm.App` | The Avalonia windows | @@ -970,11 +1027,11 @@ covered by plain unit tests. ## Where it stands -Working: logging a contest end to end, live dupe and multiplier checking, eight -built-in contests plus user-defined ones, Cabrillo and ADIF export, ADIF import, -the log, check, graphical bandmap, score and telnet windows, editing and deleting logged -contacts, radio control, DX cluster spots, contacts shared between networked -stations, and CW keying. +Working: logging a contest end to end, live dupe and multiplier checking, +twenty-six built-in contest families plus user-defined ones, Cabrillo and ADIF +export, ADIF import, the log, check, graphical bandmap, score and telnet +windows, editing and deleting logged contacts, radio control, DX cluster spots, +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 reads the contest, its categories and the contacts. See diff --git a/docs/keying.md b/docs/keying.md index a2b8d90..446684c 100644 --- a/docs/keying.md +++ b/docs/keying.md @@ -1,6 +1,6 @@ # 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: @@ -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 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 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 `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 +`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`. diff --git a/docs/unfinished.md b/docs/unfinished.md index b385050..b73941f 100644 --- a/docs/unfinished.md +++ b/docs/unfinished.md @@ -20,6 +20,7 @@ is still sitting there undiscovered. | `CwDaemonSender` | the UDP messages, and a fake daemon that answers the `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` | | `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 `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. @@ -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 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 -number. +written. -**Voice keying.** `MessageSender` was written to cover a voice keyer playing a -recording, and nothing implements it. Each operator's recordings folder is -stored with the rest of his settings and waits on the same thing. No DVK support either, so the second radio -of an SO2R station cannot call CQ by voice. Alternating CQ therefore works on CW -only, though nothing in it is CW-specific: a voice keyer that reports when the -recording has finished would drive it as it stands. +**Voice keying.** The keyer is written: a phone function key names a wav file, +`VoiceKeyer` plays it out of the sound card and keys the transmitter through +cwdaemon while it plays, and the voice keyer window lists every recording the +messages name, says which are there and records the rest. Alternating CQ duels +on phone as well as on CW, because the voice keyer says when a recording has +finished. -**The QTC window sends on CW and RTTY, not on SSB.** 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. +What is not written: N1MM's advanced voicing, which says a callsign or a serial +number by stringing together a recording per letter and per number out of +`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 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 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 -a voice keyer, and the green mark N1MM puts on each line as Send All reads it +On SSB the four buttons N1MM has recordings for play them — `WAEQRV.wav`, +`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. **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, `{STEREOON}` and `{STEREOOFF}`, `{CONDJUMP}`, `{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. The CW keyer macros are not read either: `<` and `>` for speed, `~` for a half diff --git a/src/Nonemm.App/AppSession.cs b/src/Nonemm.App/AppSession.cs index 6d1e601..4a7e532 100644 --- a/src/Nonemm.App/AppSession.cs +++ b/src/Nonemm.App/AppSession.cs @@ -40,6 +40,10 @@ public sealed class AppSession : IDisposable private DateTime statusSent; private DispatcherTimer? announcing; private MessageSender? keyer; + private VoiceKeyer? voice; + private SoundCard sound = new CommandSoundCard( + CommandSoundCard.DefaultPlay, + CommandSoundCard.DefaultRecord); private AlternatingCq? alternating; private MmttyEngine? digital; private DigitalEngineSender? digitalSender; @@ -63,6 +67,7 @@ public sealed class AppSession : IDisposable Paths = paths; Settings = settings; paths.CreateFolders(); + Recordings = new VoiceFiles(paths.Wav); Countries = LoadCountryFile(paths.CountryFile); Calls = LoadCallDatabase(paths.CallDatabaseFile); History = LoadCallHistory(settings.CallHistoryFile); @@ -152,6 +157,29 @@ public sealed class AppSession : IDisposable public MessageSender? Keyer => keyer; + /// What sends a message on that mode: the digital engine on a digital + /// mode, the voice keyer on phone, the CW keyer otherwise. Everything that + /// sends asks here, so a message always goes out the way the mode needs. + public MessageSender? SenderFor(ModeCategory mode) => mode switch + { + ModeCategory.Digital when digitalSender is { IsReady: true } engine => engine, + ModeCategory.Phone when voice is { IsReady: true } speaking => speaking, + _ => keyer, + }; + + /// What plays the recordings on phone. It is there whether or not there is + /// a CW keyer: the recordings go out of the sound card, not the keying + /// port. + public VoiceKeyer? Voice => voice; + + /// The recordings under the `Wav` folder, for the voice keyer and for the + /// window that records them. + public VoiceFiles Recordings { get; } + + /// What plays and records a wav file. The voice keyer sends through it and + /// the voice keyer window auditions and records through it. + public SoundCard Sound => sound; + /// The digital modem, started by the digital window rather than at startup: /// it runs an engine under Wine, which is not something to do to an /// operator who is working CW. @@ -175,6 +203,12 @@ public sealed class AppSession : IDisposable public event EventHandler? ContestChanged; + /// What a keyer had to say while a message was going out — a recording + /// that would not play, most of the time. The entry window puts it in the + /// status line: by then the caller has been answered and there is nothing + /// left to throw at. + public event EventHandler? Reported; + public void Save(Settings settings) { Settings = settings; @@ -571,6 +605,8 @@ public sealed class AppSession : IDisposable alternating = null; keyer?.Dispose(); keyer = null; + voice?.Dispose(); + voice = null; switch (Settings.KeyerKind.ToLowerInvariant()) { case "cwdaemon": @@ -585,11 +621,21 @@ public sealed class AppSession : IDisposable if (keyer is not null) { _ = keyer.SetSpeedAsync(Settings.KeyerSpeed); - alternating = new AlternatingCq( - keyer, - CallCqOnAsync, - TimeSpan.FromMilliseconds(Settings.AlternatingCqGapMs)); } + sound = new CommandSoundCard(Settings.VoicePlayCommand, Settings.VoiceRecordCommand); + voice = new VoiceKeyer( + sound, + Recordings, + // only cwdaemon has a way in to the keying port from here, and + // only when the operator has not said the radio keys itself + Settings.VoiceKeysPtt && keyer is CwDaemonSender daemon ? daemon.SetPttAsync : null); + voice.Reported += (_, what) => Reported?.Invoke(this, what); + // a CQ goes out through the keyer the radio's mode calls for, so + // alternating CQ listens to both of them + alternating = new AlternatingCq( + keyer is null ? [voice] : [keyer, voice], + CallCqOnAsync, + TimeSpan.FromMilliseconds(Settings.AlternatingCqGapMs)); Changed?.Invoke(this, EventArgs.Empty); } @@ -696,9 +742,9 @@ public sealed class AppSession : IDisposable { OperatingPosition position = positions.FirstOrDefault(p => p.RadioNumber == radioNumber) ?? throw new InvalidOperationException($"there is no radio {radioNumber}"); - if (keyer is null) + if (SenderFor(position.Mode.Category) is not { } sender) { - throw new InvalidOperationException("there is no keyer"); + throw new InvalidOperationException($"there is no keyer for {position.Mode.Name}"); } string template = Messages .For(position.Mode.Category, Settings.CwMessageFile, Settings.PhoneMessageFile) @@ -710,7 +756,7 @@ public sealed class AppSession : IDisposable } MoveToRadio(radioNumber); await PointTransmitAtAsync(radioNumber).ConfigureAwait(false); - await keyer.SendAsync(MessageExpander.Expand(template, position, Other(position))).ConfigureAwait(false); + await sender.SendAsync(MessageExpander.Expand(template, position, Other(position))).ConfigureAwait(false); } /// Puts both radios in the headphones, or goes back to one. An operator @@ -886,6 +932,7 @@ public sealed class AppSession : IDisposable link?.Dispose(); alternating?.Dispose(); keyer?.Dispose(); + voice?.Dispose(); box?.Dispose(); store?.Dispose(); } diff --git a/src/Nonemm.App/Configuration/Settings.cs b/src/Nonemm.App/Configuration/Settings.cs index 02593e2..889758e 100644 --- a/src/Nonemm.App/Configuration/Settings.cs +++ b/src/Nonemm.App/Configuration/Settings.cs @@ -1,6 +1,7 @@ using System.Reflection; using System.Text.Json; using Nonemm.Core; +using Nonemm.Keying; using Nonemm.Network; using Nonemm.Session; @@ -171,6 +172,18 @@ public sealed record Settings /// 100 ms, because an SO2R box works relays. public int AlternatingCqGapMs { get; init; } = 100; + /// What the voice keyer plays and records with. `{file}` in each command + /// stands for the file. Empty means the program has nothing to play or + /// record with, and the voice keyer window says so. + public string VoicePlayCommand { get; init; } = CommandSoundCard.DefaultPlay; + + public string VoiceRecordCommand { get; init; } = CommandSoundCard.DefaultRecord; + + /// Whether the voice keyer keys the transmitter itself, through the same + /// cwdaemon that keys CW. Turned off at a station whose radio hears the + /// audio and keys itself on VOX. + public bool VoiceKeysPtt { get; init; } = true; + /// How the WAE QTC window behaves. The names and the defaults are N1MM's: /// it skips the confirm and ready steps out of the box, clears the header /// when the header is asked for again, and leaves a line alone when the diff --git a/src/Nonemm.App/Messages.cs b/src/Nonemm.App/Messages.cs index b8cdd91..2d48923 100644 --- a/src/Nonemm.App/Messages.cs +++ b/src/Nonemm.App/Messages.cs @@ -28,20 +28,27 @@ public static class Messages F12 Wipe, """; - /// Phone messages name a recording to play; the text is what would be said. + /// Phone messages name a recording to play, which is what N1MM's own SSB + /// file holds. `{WAVDIR}` is the folder named after whoever is at the + /// microphone, so each operator's own voice goes out. The recordings are + /// made in the voice keyer window, which lists these names. + /// + /// F5 is empty, as it is in N1MM's file: sending the other station's call + /// needs a recording per letter, which this program does not play. public const string DefaultPhone = """ - # Phone function keys. - F1 CQ,CQ CONTEST {MYCALL} - F2 Exch,{EXCH} - F3 TU,THANK YOU {MYCALL} - F4 MyCall,{MYCALL} - F5 HisCall,! - F6 QSO B4,QSO BEFORE - F7 ?,PLEASE REPEAT - F8 Agn,AGAIN - F9 Nr?,NUMBER PLEASE - F10 Call?,YOUR CALL PLEASE + # Phone function keys. Each one names a recording under the Wav folder; + # {WAVDIR} is the folder of whoever is at the microphone. + F1 CQ,{WAVDIR}\Cq.wav + F2 Exch,{WAVDIR}\Exchange.wav + F3 TU,{WAVDIR}\Thanks.wav + F4 MyCall,{WAVDIR}\MyCall.wav + F5 HisCall, + F6 QSO B4,{WAVDIR}\QsoB4.wav + F7 ?,{WAVDIR}\Repeat.wav + F8 Agn,{WAVDIR}\AllAgain.wav + F9 Nr?,{WAVDIR}\NumberAgain.wav + F10 Call?,{WAVDIR}\CallAgain.wav F11 Spot, F12 Wipe, """; diff --git a/src/Nonemm.App/Windows/EntryWindow.Macros.cs b/src/Nonemm.App/Windows/EntryWindow.Macros.cs index 34c8ab2..0beaa09 100644 --- a/src/Nonemm.App/Windows/EntryWindow.Macros.cs +++ b/src/Nonemm.App/Windows/EntryWindow.Macros.cs @@ -16,13 +16,10 @@ public sealed partial class EntryWindow /// actions after `{END}` still run. private static readonly TimeSpan SendingPatience = TimeSpan.FromMinutes(2); - /// What sends this radio's messages: the digital engine on a digital mode, - /// and the CW or voice keyer otherwise. A message therefore goes out the - /// way the mode needs without the caller choosing. + /// What sends this radio's messages, which the mode decides: a message + /// therefore goes out the way the mode needs without the caller choosing. private MessageSender? Sender => - Logging?.Mode.Category == ModeCategory.Digital && session.DigitalKeyer is { IsReady: true } engine - ? engine - : session.Keyer; + session.SenderFor(Logging?.Mode.Category ?? ModeCategory.Cw); /// What to say when a message has nowhere to go. The mode decides which /// keyer a message uses, so the mode is part of the answer: an operator on diff --git a/src/Nonemm.App/Windows/EntryWindow.Menu.cs b/src/Nonemm.App/Windows/EntryWindow.Menu.cs index 52684e7..059f12d 100644 --- a/src/Nonemm.App/Windows/EntryWindow.Menu.cs +++ b/src/Nonemm.App/Windows/EntryWindow.Menu.cs @@ -464,6 +464,9 @@ public sealed partial class EntryWindow private void OnShowTelnet(object? sender, RoutedEventArgs e) => Show(() => new TelnetWindow(session, Tune)); + private void OnShowVoiceKeyer(object? sender, RoutedEventArgs e) => + Show(() => new VoiceKeyerWindow(session)); + /// N1MM's digital interface window, which is where RTTY is worked from. private void OnShowDigital(object? sender, RoutedEventArgs e) => Show(() => new DigitalWindow(session, this, radioNumber)); diff --git a/src/Nonemm.App/Windows/EntryWindow.Operators.cs b/src/Nonemm.App/Windows/EntryWindow.Operators.cs index 0b525cb..f0ef77c 100644 --- a/src/Nonemm.App/Windows/EntryWindow.Operators.cs +++ b/src/Nonemm.App/Windows/EntryWindow.Operators.cs @@ -117,6 +117,7 @@ public sealed partial class EntryWindow (nameof(GraylineWindow), () => _ = Show(() => new GraylineWindow(session), activate: false)), (nameof(TelnetWindow), () => _ = Show(() => new TelnetWindow(session, Tune), activate: false)), (nameof(DigitalWindow), () => _ = Show(() => new DigitalWindow(session, this, radioNumber), activate: false)), + (nameof(VoiceKeyerWindow), () => _ = Show(() => new VoiceKeyerWindow(session), activate: false)), ]; /// Puts a window where this operator last had it. Windows are opened one at diff --git a/src/Nonemm.App/Windows/EntryWindow.axaml b/src/Nonemm.App/Windows/EntryWindow.axaml index de1c720..f8c80cd 100644 --- a/src/Nonemm.App/Windows/EntryWindow.axaml +++ b/src/Nonemm.App/Windows/EntryWindow.axaml @@ -129,6 +129,7 @@ + diff --git a/src/Nonemm.App/Windows/EntryWindow.axaml.cs b/src/Nonemm.App/Windows/EntryWindow.axaml.cs index 83ea719..ab45d90 100644 --- a/src/Nonemm.App/Windows/EntryWindow.axaml.cs +++ b/src/Nonemm.App/Windows/EntryWindow.axaml.cs @@ -68,6 +68,9 @@ public sealed partial class EntryWindow : Window InitializeComponent(); BuildFunctionKeys(); session.Changed += (_, _) => Dispatcher.UIThread.Post(Refresh); + // a keyer speaks on its own thread, and a recording that would not + // play has nowhere else to be said + session.Reported += (_, what) => Dispatcher.UIThread.Post(() => Status(what)); session.ContestChanged += (_, _) => Dispatcher.UIThread.Post(() => { BuildEntryBoxes(); @@ -195,10 +198,9 @@ public sealed partial class EntryWindow : Window { yield return ("station network", session.ApplyNetworkSettings); } - if (session.Settings.KeyerKind != "none") - { - yield return ("keyer", session.ApplyKeyerSettings); - } + // always, whatever the CW keyer is: this is also what builds the voice + // keyer, which plays out of the sound card rather than a keying port + yield return ("keyer", session.ApplyKeyerSettings); if (session.Settings.So2rBoxPort.Length > 0) { yield return ("SO2R box", session.ApplySo2rSettings); diff --git a/src/Nonemm.App/Windows/QtcWindow.axaml.cs b/src/Nonemm.App/Windows/QtcWindow.axaml.cs index b710e53..5bb5090 100644 --- a/src/Nonemm.App/Windows/QtcWindow.axaml.cs +++ b/src/Nonemm.App/Windows/QtcWindow.axaml.cs @@ -102,7 +102,7 @@ public sealed partial class QtcWindow : Window return isSending ? IsRtty ? "Send All reads the whole series out — Snd n sends one QTC again" - : "Nothing goes out on SSB: read the lines out yourself, then Close to log them." + : "The buttons play the QRV, Agn, Cfm and TU recordings. Read the lines out yourself, then Close to log them." : "Type the header, then a line per QTC: time, callsign, serial number."; } return isSending @@ -116,9 +116,11 @@ public sealed partial class QtcWindow : Window private bool IsRtty => position.Contest.Modes.Contains(ModeCategory.Digital); - /// Whether this window puts anything on the air. SSB needs a voice keyer - /// and there is none here, so on SSB the operator speaks and the window - /// only writes the traffic down. + private bool IsPhone => !IsCw && !IsRtty; + + /// Whether this window keys the traffic itself. On SSB it plays N1MM's + /// four recordings and the operator reads the lines out: a QTC line is a + /// time, a callsign and a serial, and no recording can say one. private bool Keys => IsCw || IsRtty; private Settings Settings => session.Settings; @@ -149,8 +151,10 @@ public sealed partial class QtcWindow : Window CountsText.Text = traffic.CountsFor(station).Text; KeysText.Text = KeysLine(); // N1MM's labels: the buttons say what they do in the direction the - // traffic is going, and the ones with nothing to do are hidden - ReadyButton.IsVisible = Keys || !isSending; + // traffic is going, and the ones with nothing to do are hidden. This + // one always has something to do — on SSB it plays the QRV recording + // whichever way the traffic is going + ReadyButton.IsVisible = true; ReadyButton.Content = !IsCw ? isSending ? "R U QRV" : "RX Ready" : isSending ? "R U QRV" : "QRV"; HeaderAgainButton.Content = isSending ? "Snd Hdr" : "Hdr Agn"; @@ -488,8 +492,8 @@ public sealed partial class QtcWindow : Window } /// Reading traffic out, this asks the other station whether it is ready; - /// taking it down, it says we are. N1MM keys `QRV?` and `QRV` and plays a - /// recording on SSB, which there is no keyer for here. + /// taking it down, it says we are. N1MM keys `QRV?` and `QRV` on CW and + /// plays one recording either way on SSB. private void OnReady(object? sender, RoutedEventArgs e) { if (IsCw) @@ -501,6 +505,10 @@ public sealed partial class QtcWindow : Window _ = send(QtcMessages.Rtty( isSending ? Settings.QtcRttyAreYouReady : Settings.QtcRttyReady)); } + else + { + _ = send(VoiceLines.QtcReady); + } Control next = isSending ? SendAllButton.IsVisible ? SendAllButton : rows[0].Again : HeaderBox; next.Focus(); @@ -526,6 +534,10 @@ public sealed partial class QtcWindow : Window { _ = send(QtcMessages.Rtty(QtcMessages.RttyHeaderAgain)); } + else + { + _ = send(VoiceLines.QtcAgain); + } if (session.Settings.QtcHeaderAgainClears) { HeaderBox.Text = ""; @@ -533,7 +545,14 @@ public sealed partial class QtcWindow : Window HeaderBox.Focus(); } - private void OnHeaderConfirm(object? sender, RoutedEventArgs e) => rows[0].Time.Focus(); + private void OnHeaderConfirm(object? sender, RoutedEventArgs e) + { + if (IsPhone) + { + _ = send(VoiceLines.QtcConfirm); + } + rows[0].Time.Focus(); + } /// N1MM's All Agn: asks for the header and every line of the series again, /// and clears what was taken down so the series can be copied afresh. @@ -578,6 +597,12 @@ public sealed partial class QtcWindow : Window { _ = send($"AGN QTC {at + 1}"); } + else + { + // one recording for every line: it says "again", and the + // operator says which line + _ = send(VoiceLines.QtcAgain); + } if (Settings.QtcAgainClearsLine) { rows[at].Clear(); @@ -750,7 +775,11 @@ public sealed partial class QtcWindow : Window // N1MM sends a message as the window closes: the TU on CW, and on RTTY // `R R R ALL OK BK` from the station that took the traffic down or // `GL DE ` 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 ?? "")); } diff --git a/src/Nonemm.App/Windows/VoiceKeyerWindow.axaml b/src/Nonemm.App/Windows/VoiceKeyerWindow.axaml new file mode 100644 index 0000000..5f1bed5 --- /dev/null +++ b/src/Nonemm.App/Windows/VoiceKeyerWindow.axaml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Nonemm.App/Windows/VoiceKeyerWindow.axaml.cs b/src/Nonemm.App/Windows/VoiceKeyerWindow.axaml.cs new file mode 100644 index 0000000..deb21fb --- /dev/null +++ b/src/Nonemm.App/Windows/VoiceKeyerWindow.axaml.cs @@ -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 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 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); + } +} diff --git a/src/Nonemm.Keying/CommandSoundCard.cs b/src/Nonemm.Keying/CommandSoundCard.cs new file mode 100644 index 0000000..486ed33 --- /dev/null +++ b/src/Nonemm.Keying/CommandSoundCard.cs @@ -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 Split(string command) + { + List 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 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); +} diff --git a/src/Nonemm.Keying/CwDaemonSender.cs b/src/Nonemm.Keying/CwDaemonSender.cs index 4d24642..8d1d6c3 100644 --- a/src/Nonemm.Keying/CwDaemonSender.cs +++ b/src/Nonemm.Keying/CwDaemonSender.cs @@ -48,6 +48,12 @@ public sealed class CwDaemonSender : MessageSender public Task SetSpeedAsync(int wordsPerMinute, CancellationToken cancellation = default) => WriteAsync(Escape('2', wordsPerMinute.ToString()), cancellation); + /// cwdaemon's `a`: the transmitter is keyed and stays keyed until it + /// is told to drop. The voice keyer needs it — the recording goes out of + /// the sound card, so something else has to key the radio. + public Task SetPttAsync(bool on, CancellationToken cancellation = default) => + WriteAsync(Escape('a', on ? "1" : "0"), cancellation); + public void Dispose() { reading.Cancel(); diff --git a/src/Nonemm.Keying/SoundCard.cs b/src/Nonemm.Keying/SoundCard.cs new file mode 100644 index 0000000..608534b --- /dev/null +++ b/src/Nonemm.Keying/SoundCard.cs @@ -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); +} diff --git a/src/Nonemm.Keying/VoiceFiles.cs b/src/Nonemm.Keying/VoiceFiles.cs new file mode 100644 index 0000000..f61e200 --- /dev/null +++ b/src/Nonemm.Keying/VoiceFiles.cs @@ -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 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)); +} diff --git a/src/Nonemm.Keying/VoiceKeyer.cs b/src/Nonemm.Keying/VoiceKeyer.cs new file mode 100644 index 0000000..2429381 --- /dev/null +++ b/src/Nonemm.Keying/VoiceKeyer.cs @@ -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 `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? 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? 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? 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 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 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}"); + } + } +} diff --git a/src/Nonemm.Session/AlternatingCq.cs b/src/Nonemm.Session/AlternatingCq.cs index 1c4c156..808e92a 100644 --- a/src/Nonemm.Session/AlternatingCq.cs +++ b/src/Nonemm.Session/AlternatingCq.cs @@ -17,10 +17,14 @@ namespace Nonemm.Session; /// from the length of the text would be a guess, and a guess that runs short /// keys the second radio while the first is still sending. /// +/// There is a keyer per mode — the CW keyer and the voice keyer — and a CQ +/// goes out through whichever the radio's mode calls for, so it listens to all +/// of them. +/// /// Two radios, because that is what an SO2R station has. public sealed class AlternatingCq : IDisposable { - private readonly MessageSender keyer; + private readonly IReadOnlyList keyers; private readonly Func callCqOn; private readonly Func wait; private readonly Lock gate = new(); @@ -30,16 +34,19 @@ public sealed class AlternatingCq : IDisposable /// `callCqOn` moves the operator to that radio and sends the CQ message. /// `wait` is there so a test does not have to sleep. public AlternatingCq( - MessageSender keyer, + IReadOnlyList keyers, Func callCqOn, TimeSpan gap, Func? wait = null) { - this.keyer = keyer; + this.keyers = keyers; this.callCqOn = callCqOn; Gap = gap; this.wait = wait ?? Task.Delay; - keyer.Finished += OnFinished; + foreach (MessageSender keyer in keyers) + { + keyer.Finished += OnFinished; + } } /// How long to leave between the message ending and the other radio @@ -93,8 +100,8 @@ public sealed class AlternatingCq : IDisposable /// not raise it. public event EventHandler? Stopped; - /// True when the keyer can drive this at all. - public bool IsPossible => keyer.ReportsCompletion; + /// True when a keyer that can drive this at all is connected. + public bool IsPossible => keyers.Any(keyer => keyer.ReportsCompletion); public void Start(int radioNumber) { @@ -127,7 +134,10 @@ public sealed class AlternatingCq : IDisposable public void Dispose() { - keyer.Finished -= OnFinished; + foreach (MessageSender keyer in keyers) + { + keyer.Finished -= OnFinished; + } Stop(); } diff --git a/src/Nonemm.Session/MessageExpander.cs b/src/Nonemm.Session/MessageExpander.cs index 9c5a9a2..4830a96 100644 --- a/src/Nonemm.Session/MessageExpander.cs +++ b/src/Nonemm.Session/MessageExpander.cs @@ -78,8 +78,9 @@ public static class MessageExpander { "MYCALL" => session.Me.Callsign, // whoever is at the radio, or the station callsign at a station - // where nobody has said - "OPERATOR" => session.Session.Operator.Length > 0 + // where nobody has said. `{WAVDIR}` is the folder his recordings + // are in, which is named after him, so it stands for the same + "OPERATOR" or "WAVDIR" => session.Session.Operator.Length > 0 ? session.Session.Operator : session.Me.Callsign, "CALL" => TheirCall(session), diff --git a/src/Nonemm.Session/VoiceLine.cs b/src/Nonemm.Session/VoiceLine.cs new file mode 100644 index 0000000..6b8b151 --- /dev/null +++ b/src/Nonemm.Session/VoiceLine.cs @@ -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); diff --git a/src/Nonemm.Session/VoiceLines.cs b/src/Nonemm.Session/VoiceLines.cs new file mode 100644 index 0000000..a674906 --- /dev/null +++ b/src/Nonemm.Session/VoiceLines.cs @@ -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 For(MessageFile phone, string operatorCallsign) + { + List 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 lines, IReadOnlyList 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 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(); +} diff --git a/tests/Nonemm.Keying.Tests/CommandSoundCardTests.cs b/tests/Nonemm.Keying.Tests/CommandSoundCardTests.cs new file mode 100644 index 0000000..33b01e9 --- /dev/null +++ b/tests/Nonemm.Keying.Tests/CommandSoundCardTests.cs @@ -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( + () => 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( + () => 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( + () => 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(() => 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(() => card.RecordAsync(In("Cq.wav"))); + } +} diff --git a/tests/Nonemm.Keying.Tests/VoiceFilesTests.cs b/tests/Nonemm.Keying.Tests/VoiceFilesTests.cs new file mode 100644 index 0000000..b9b8c3c --- /dev/null +++ b/tests/Nonemm.Keying.Tests/VoiceFilesTests.cs @@ -0,0 +1,60 @@ +namespace Nonemm.Keying.Tests; + +public class VoiceFilesTests +{ + [Fact] + public void AMessageNamesTheFilesItPlays() + { + IReadOnlyList 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); + } + } +} diff --git a/tests/Nonemm.Keying.Tests/VoiceKeyerTests.cs b/tests/Nonemm.Keying.Tests/VoiceKeyerTests.cs new file mode 100644 index 0000000..81eff79 --- /dev/null +++ b/tests/Nonemm.Keying.Tests/VoiceKeyerTests.cs @@ -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 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 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( + () => 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(() => keyer.SendAsync("Thanks.wav")); + + Assert.Empty(keying); + } + + [Fact] + public async Task TextWithNoRecordingInItIsRefused() + { + using VoiceKeyer keyer = Keyer(); + + InvalidOperationException refused = await Assert.ThrowsAsync( + () => 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 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 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(); + } +} diff --git a/tests/Nonemm.Session.Tests/AlternatingCqTests.cs b/tests/Nonemm.Session.Tests/AlternatingCqTests.cs index bfc2c16..4f7c058 100644 --- a/tests/Nonemm.Session.Tests/AlternatingCqTests.cs +++ b/tests/Nonemm.Session.Tests/AlternatingCqTests.cs @@ -34,7 +34,7 @@ public class AlternatingCqTests private static AlternatingCq Driving(FakeKeyer keyer, List called) => new( - keyer, + [keyer], radio => { called.Add(radio); @@ -154,13 +154,37 @@ public class AlternatingCqTests 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 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] public void AKeyerThatHasGoneAwayStopsTheWholeThing() { FakeKeyer keyer = new(); List reasons = []; using AlternatingCq cq = new( - keyer, + [keyer], _ => throw new InvalidOperationException("could not reach cwdaemon at 127.0.0.1:6789"), TimeSpan.Zero, NoWait); diff --git a/tests/Nonemm.Session.Tests/VoiceLinesTests.cs b/tests/Nonemm.Session.Tests/VoiceLinesTests.cs new file mode 100644 index 0000000..3ab7b3d --- /dev/null +++ b/tests/Nonemm.Session.Tests/VoiceLinesTests.cs @@ -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 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 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 lines = VoiceLines.For(Phone(@"F1 CQ,{WAVDIR}\Cq.wav"), ""); + + Assert.Equal("Cq.wav", lines[0].Name); + } + + [Fact] + public void AKeyThatIsWordsRatherThanARecordingIsNotListed() + { + IReadOnlyList 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 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)); +}