Record a message with Ctrl+Shift+F, and open the editor with Alt+K

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0157i7PiZyqBkquxS9A6FTdQ
This commit is contained in:
2026-09-11 08:24:21 +00:00
co-authored by Claude Opus 5
parent 082fa75a40
commit 31ad6fb192
6 changed files with 134 additions and 35 deletions
+11 -3
View File
@@ -128,9 +128,10 @@ mode stay where they were last typed.
**Config → Keyer and messages** picks `cwdaemon` (a UDP port, usually 6789) or a **Config → Keyer and messages** picks `cwdaemon` (a UDP port, usually 6789) or a
WinKeyer (a serial port) and sets the speed. Escape stops sending. 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 The messages are edited as their file, in a plain text editor: **Alt+K**,
of the function key buttons under the entry window, or use the two buttons in right-click any of the function key buttons under the entry window, or use the
Config → Keyer and messages. One line per key — the button label, a comma, then 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 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 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 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 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. 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 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 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 for the file. A station on JACK, or one on Windows, writes its own. Why it runs
+42 -1
View File
@@ -41,6 +41,7 @@ public sealed class AppSession : IDisposable
private DispatcherTimer? announcing; private DispatcherTimer? announcing;
private MessageSender? keyer; private MessageSender? keyer;
private VoiceKeyer? voice; private VoiceKeyer? voice;
private CancellationTokenSource? recordingStop;
private SoundCard sound = new CommandSoundCard( private SoundCard sound = new CommandSoundCard(
CommandSoundCard.DefaultPlay, CommandSoundCard.DefaultPlay,
CommandSoundCard.DefaultRecord); CommandSoundCard.DefaultRecord);
@@ -177,9 +178,49 @@ public sealed class AppSession : IDisposable
public VoiceFiles Recordings { get; } public VoiceFiles Recordings { get; }
/// What plays and records a wav file. The voice keyer sends through it and /// 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; 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<string> 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: /// The digital modem, started by the digital window rather than at startup:
/// it runs an engine under Wine, which is not something to do to an /// it runs an engine under Wine, which is not something to do to an
/// operator who is working CW. /// operator who is working CW.
@@ -97,6 +97,9 @@ public sealed partial class EntryWindow
{ {
return; 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)); MessagePlan plan = MessagePlan.Read(template, Logging, session.Other(Logging));
foreach (MessageAction action in plan.Before) foreach (MessageAction action in plan.Before)
{ {
@@ -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();
}
}
}
@@ -333,6 +333,7 @@ public sealed partial class EntryWindow : Window
case Key.Escape: case Key.Escape:
e.Handled = true; e.Handled = true;
sending = false; sending = false;
StopRecording();
// N1MM's Escape holds alternating CQ where it is rather than // N1MM's Escape holds alternating CQ where it is rather than
// switching it off; the next CQ carries it on // switching it off; the next CQ carries it on
session.Alternating?.Hold(); session.Alternating?.Hold();
@@ -441,6 +442,19 @@ public sealed partial class EntryWindow : Window
e.Handled = true; e.Handled = true;
ToggleRun(); ToggleRun();
break; 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: case >= Key.F1 and <= Key.F12:
e.Handled = true; e.Handled = true;
RunFunctionKey(e.Key - Key.F1); RunFunctionKey(e.Key - Key.F1);
@@ -21,11 +21,6 @@ public sealed partial class VoiceKeyerWindow : RefreshableWindow
{ {
private readonly AppSession session; 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) public VoiceKeyerWindow(AppSession session)
{ {
this.session = 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) }; Button play = new() { Content = "Play", IsEnabled = has, Margin = new Thickness(0, 1, 4, 1) };
play.Click += (_, _) => _ = PlayAsync(line); play.Click += (_, _) => _ = PlayAsync(line);
bool isRecording = recordingName == line.Name; bool isRecording = session.Recording == line.Name;
Button record = new() Button record = new()
{ {
Content = isRecording ? "Stop" : has ? "Record again" : "Record", Content = isRecording ? "Stop" : has ? "Record again" : "Record",
// one microphone, so the other rows wait for this one to finish // one microphone, so the other rows wait for this one to finish.
IsEnabled = recording is null || isRecording, // 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), Margin = new Thickness(0, 1, 0, 1),
}; };
record.Click += (_, _) => _ = RecordAsync(line); 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. /// with it, so a stop that fails leaves the old recording playable.
private async Task RecordAsync(VoiceLine line) private async Task RecordAsync(VoiceLine line)
{ {
if (recording is { } running) if (session.Recording is not null)
{ {
running.Cancel(); session.StopRecording();
return; return;
} }
if (!session.Sound.CanRecord) 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"); Status("there is no recorder — name one in the box below");
return; return;
} }
recording = new CancellationTokenSource();
recordingName = line.Name;
Refresh();
Status($"recording {line.Name} — press Stop when you have finished speaking"); Status($"recording {line.Name} — press Stop when you have finished speaking");
string outcome; string outcome = await session.RecordAsync(line.Name);
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 // the redraw comes first: it writes the tally over the status line, and
// what happened to this recording is worth more than the tally // what happened to this recording is worth more than the tally
Refresh(); Refresh();