From 31ad6fb1924cb044813537fc27210c110591ee07 Mon Sep 17 00:00:00 2001 From: ericek111 Date: Fri, 11 Sep 2026 08:24:21 +0000 Subject: [PATCH] Record a message with Ctrl+Shift+F, and open the editor with Alt+K MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit N1MM records a phone message from the entry window: Ctrl+Shift+F1 to Ctrl+Shift+F12 records into the file that key plays, and the same pair again stops and saves it. Escape and sending anything stop one too — there is one microphone, and it cannot be recorded and transmitted at the same time. The status line says what is being recorded and what was saved, which is where N1MM says it. The recording being made is the session's rather than the window's, for the same reason: the voice keyer window and these keys record into the same microphone, and now each can see what the other started. Alt+K opens the function key editor for the mode the radio is on, which is N1MM's shortcut for what right-clicking a key button already did. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0157i7PiZyqBkquxS9A6FTdQ --- README.md | 14 ++++- src/Nonemm.App/AppSession.cs | 43 ++++++++++++++- src/Nonemm.App/Windows/EntryWindow.Macros.cs | 3 + .../Windows/EntryWindow.Recording.cs | 55 +++++++++++++++++++ src/Nonemm.App/Windows/EntryWindow.axaml.cs | 14 +++++ .../Windows/VoiceKeyerWindow.axaml.cs | 40 +++----------- 6 files changed, 134 insertions(+), 35 deletions(-) create mode 100644 src/Nonemm.App/Windows/EntryWindow.Recording.cs 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();