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:
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user