diff --git a/README.md b/README.md index 608ded2..99ff2ed 100644 --- a/README.md +++ b/README.md @@ -128,9 +128,10 @@ mode stay where they were last typed. **Config → Keyer and messages** picks `cwdaemon` (a UDP port, usually 6789) or a WinKeyer (a serial port) and sets the speed. Escape stops sending. -The messages are edited as their file, in a plain text editor: right-click any -of the function key buttons under the entry window, or use the two buttons in -Config → Keyer and messages. One line per key — the button label, a comma, then +The messages are edited as their file, in a plain text editor: **Alt+K**, +right-click any of the function key buttons under the entry window, or use the +two buttons in Config → Keyer and messages. Alt+K and the right-click open the +file for the mode the radio is on. One line per key — the button label, a comma, then the message — and a line starting with `#` is a comment. Which key a line belongs to is decided by where it is in the file: the first twelve lines are F1 to F12 while running, and twelve more give the same keys while searching, with @@ -215,6 +216,13 @@ 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. +**Ctrl+Shift+F1** to **Ctrl+Shift+F12** record from the entry window, which is +N1MM's on-the-fly recording: the key names the file, so Ctrl+Shift+F1 records +over the CQ. The same pair again stops it, and so do Escape and sending +anything — there is one microphone, and it cannot be recorded and transmitted +at once. The status line says what is being recorded and what was saved. A key +that names several files records the first, as N1MM's does. + 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 diff --git a/src/Nonemm.App/AppSession.cs b/src/Nonemm.App/AppSession.cs index 4a7e532..2fa1330 100644 --- a/src/Nonemm.App/AppSession.cs +++ b/src/Nonemm.App/AppSession.cs @@ -41,6 +41,7 @@ public sealed class AppSession : IDisposable private DispatcherTimer? announcing; private MessageSender? keyer; private VoiceKeyer? voice; + private CancellationTokenSource? recordingStop; private SoundCard sound = new CommandSoundCard( CommandSoundCard.DefaultPlay, CommandSoundCard.DefaultRecord); @@ -177,9 +178,49 @@ public sealed class AppSession : IDisposable 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. + /// the voice keyer window auditions through it. public SoundCard Sound => sound; + /// The recording being made, or null. It is here rather than in the window + /// that started it because there is one microphone: the voice keyer window + /// and the entry window's Ctrl+Shift+F keys record into the same one. + public string? Recording { get; private set; } + + /// Records over the file named under `Wav` until `StopRecording`, and + /// answers with what to tell the operator: the file recorded, or why + /// nothing was. + public async Task RecordAsync(string name) + { + if (Recording is { } already) + { + return $"{already} is being recorded — stop that first"; + } + recordingStop = new CancellationTokenSource(); + Recording = name; + Changed?.Invoke(this, EventArgs.Empty); + try + { + await sound.RecordAsync(Recordings.PathOf(name), recordingStop.Token) + .ConfigureAwait(false); + return $"recorded {name}"; + } + catch (Exception failure) when (failure is InvalidOperationException or IOException) + { + return failure.Message; + } + finally + { + recordingStop.Dispose(); + recordingStop = null; + Recording = null; + Changed?.Invoke(this, EventArgs.Empty); + } + } + + /// Ends the recording, which is what saves it: the recorder is asked to + /// stop so it can close the file it is writing. + public void StopRecording() => recordingStop?.Cancel(); + /// 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. diff --git a/src/Nonemm.App/Windows/EntryWindow.Macros.cs b/src/Nonemm.App/Windows/EntryWindow.Macros.cs index 0beaa09..887a3aa 100644 --- a/src/Nonemm.App/Windows/EntryWindow.Macros.cs +++ b/src/Nonemm.App/Windows/EntryWindow.Macros.cs @@ -97,6 +97,9 @@ public sealed partial class EntryWindow { return; } + // the microphone cannot be recorded and transmitted at the same time, + // and N1MM ends a recording on anything that sends as well + StopRecording(); MessagePlan plan = MessagePlan.Read(template, Logging, session.Other(Logging)); foreach (MessageAction action in plan.Before) { diff --git a/src/Nonemm.App/Windows/EntryWindow.Recording.cs b/src/Nonemm.App/Windows/EntryWindow.Recording.cs new file mode 100644 index 0000000..591c61c --- /dev/null +++ b/src/Nonemm.App/Windows/EntryWindow.Recording.cs @@ -0,0 +1,55 @@ +using Nonemm.Keying; +using Nonemm.Session; + +namespace Nonemm.App.Windows; + +/// Recording a function key's message from the entry window, which is N1MM's +/// Ctrl+Shift+F1 to F12. The key already names the file it plays, so the key +/// says where the recording goes: an operator who has just heard his CQ come +/// back badly records it again without leaving the radio. +public sealed partial class EntryWindow +{ + /// Starts recording what this key plays, or ends the recording that is + /// running. N1MM ends one on any of the pair, on Escape, and on sending + /// anything, and so does this. + private void RecordFunctionKey(int index) + { + if (session.Recording is not null) + { + session.StopRecording(); + return; + } + if (Logging is null) + { + return; + } + string template = Keys()[index].Message; + MessagePlan plan = MessagePlan.Read(template, Logging, session.Other(Logging)); + // the first file, because that is the one the key plays first. A key + // that names none is a CW message or an empty key + if (VoiceFiles.NamesIn(plan.Text).FirstOrDefault() is not { } name) + { + Status($"F{index + 1} names no recording — Config ▸ Change SSB Function Key Definitions"); + return; + } + _ = RecordAsync(index, name); + } + + private async Task RecordAsync(int index, string name) + { + Status($"recording {name} — Ctrl+Shift+F{index + 1} again, or Escape, to stop"); + Refresh(); + Status(await session.RecordAsync(name)); + Refresh(); + } + + /// Ends a recording that is running, for the keys that end one without + /// being about recording at all. + private void StopRecording() + { + if (session.Recording is not null) + { + session.StopRecording(); + } + } +} diff --git a/src/Nonemm.App/Windows/EntryWindow.axaml.cs b/src/Nonemm.App/Windows/EntryWindow.axaml.cs index ab45d90..59f4595 100644 --- a/src/Nonemm.App/Windows/EntryWindow.axaml.cs +++ b/src/Nonemm.App/Windows/EntryWindow.axaml.cs @@ -333,6 +333,7 @@ public sealed partial class EntryWindow : Window case Key.Escape: e.Handled = true; sending = false; + StopRecording(); // N1MM's Escape holds alternating CQ where it is rather than // switching it off; the next CQ carries it on session.Alternating?.Hold(); @@ -441,6 +442,19 @@ public sealed partial class EntryWindow : Window e.Handled = true; ToggleRun(); break; + // N1MM opens the function key editor on Alt+K, for the mode the + // radio is on. Right-clicking a key button opens the same one + case Key.K when e.KeyModifiers.HasFlag(KeyModifiers.Alt): + e.Handled = true; + _ = EditMessages(); + break; + // N1MM records into the file this key plays, and stops on the + // same pair + case >= Key.F1 and <= Key.F12 when e.KeyModifiers.HasFlag(KeyModifiers.Control) + && e.KeyModifiers.HasFlag(KeyModifiers.Shift): + e.Handled = true; + RecordFunctionKey(e.Key - Key.F1); + break; case >= Key.F1 and <= Key.F12: e.Handled = true; RunFunctionKey(e.Key - Key.F1); diff --git a/src/Nonemm.App/Windows/VoiceKeyerWindow.axaml.cs b/src/Nonemm.App/Windows/VoiceKeyerWindow.axaml.cs index deb21fb..55df1bd 100644 --- a/src/Nonemm.App/Windows/VoiceKeyerWindow.axaml.cs +++ b/src/Nonemm.App/Windows/VoiceKeyerWindow.axaml.cs @@ -21,11 +21,6 @@ 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; @@ -115,12 +110,15 @@ public sealed partial class VoiceKeyerWindow : RefreshableWindow { Button play = new() { Content = "Play", IsEnabled = has, Margin = new Thickness(0, 1, 4, 1) }; play.Click += (_, _) => _ = PlayAsync(line); - bool isRecording = recordingName == line.Name; + bool isRecording = session.Recording == 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, + // one microphone, so the other rows wait for this one to finish. + // The entry window's Ctrl+Shift+F keys record into the same one, + // which is why the state is the session's rather than this + // window's + IsEnabled = session.Recording is null || isRecording, Margin = new Thickness(0, 1, 0, 1), }; record.Click += (_, _) => _ = RecordAsync(line); @@ -150,9 +148,9 @@ public sealed partial class VoiceKeyerWindow : RefreshableWindow /// with it, so a stop that fails leaves the old recording playable. private async Task RecordAsync(VoiceLine line) { - if (recording is { } running) + if (session.Recording is not null) { - running.Cancel(); + session.StopRecording(); return; } if (!session.Sound.CanRecord) @@ -160,28 +158,8 @@ public sealed partial class VoiceKeyerWindow : RefreshableWindow 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; - } + string outcome = await session.RecordAsync(line.Name); // 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();