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:
@@ -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<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:
|
||||
/// it runs an engine under Wine, which is not something to do to an
|
||||
/// operator who is working CW.
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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:
|
||||
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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user