Play a recording on phone, and record it in a window
A phone function key names a wav file instead of words, which is what N1MM's
own SSB file holds: `F1 CQ,{WAVDIR}\Cq.wav`. `{WAVDIR}` is the folder named
after whoever is at the microphone, a name is taken relative to `Wav`, and
commas play several files in a row. An SSB `.mc` file written for N1MM plays
here.
Window ▸ Voice Keyer lists every recording the messages name, says which of
them are there for the operator picked at the top, plays one, and records over
one. The recording is written beside the wanted file and moved over it at the
end, so a recorder that will not start takes nothing away.
`CommandSoundCard` runs `aplay` and `arecord`, and both commands are settings:
the audio path belongs to whatever the station runs, and a library would pick
one of them for the operator. A recorder is stopped with an interrupt so it
closes its wav file, and killed only if it ignores that. PTT is cwdaemon's
`<ESC>a` for as long as the recording plays, off for a station on VOX.
Two things follow. The QTC window sends on SSB: N1MM's four recordings on the
Ready, Agn, Cfm and Close buttons, with the lines still read out by the
operator. And alternating CQ duels on phone, because the voice keyer says when
a recording has finished — which it had to, since the CQ message is picked by
the radio's mode and would otherwise have been keyed as Morse.
Nothing here has been played on a radio: this machine has no sound card, so
what is tested is the program being run, the file filled in, the exit code and
the interrupt, with shell commands standing in for the two.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0157i7PiZyqBkquxS9A6FTdQ
This commit is contained in:
@@ -0,0 +1,231 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Threading;
|
||||
using Nonemm.App.Configuration;
|
||||
using Nonemm.Core;
|
||||
using Nonemm.Session;
|
||||
|
||||
namespace Nonemm.App.Windows;
|
||||
|
||||
/// Every recording a phone message names, whether it is there, and a button to
|
||||
/// play it or record it again. The list is read out of the phone function key
|
||||
/// file and the QTC window's four, so it says what this station would actually
|
||||
/// play rather than what some other station's file names.
|
||||
///
|
||||
/// The operator box picks whose folder is looked in. It starts at whoever is
|
||||
/// at the microphone, so an operator taking over records his own voice into
|
||||
/// his own folder without changing anything.
|
||||
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;
|
||||
InitializeComponent();
|
||||
PlayBox.Text = session.Settings.VoicePlayCommand;
|
||||
RecordBox.Text = session.Settings.VoiceRecordCommand;
|
||||
PttBox.IsChecked = session.Settings.VoiceKeysPtt;
|
||||
PlayBox.LostFocus += (_, _) => SaveCommands();
|
||||
RecordBox.LostFocus += (_, _) => SaveCommands();
|
||||
PttBox.IsCheckedChanged += (_, _) => SaveCommands();
|
||||
OperatorBox.SelectionChanged += (_, _) => Refresh();
|
||||
session.Changed += (_, _) => Dispatcher.UIThread.Post(Refresh);
|
||||
FillOperators();
|
||||
Refresh();
|
||||
}
|
||||
|
||||
public override void Refresh()
|
||||
{
|
||||
Lines.Children.Clear();
|
||||
Lines.RowDefinitions.Clear();
|
||||
string who = OperatorBox.SelectedItem as string ?? "";
|
||||
FolderText.Text = session.Recordings.PathOf(who);
|
||||
IReadOnlyList<VoiceLine> lines = VoiceLines.For(
|
||||
Messages.For(ModeCategory.Phone, "", session.Settings.PhoneMessageFile),
|
||||
who);
|
||||
int at = 0;
|
||||
Header(at++);
|
||||
foreach (VoiceLine line in lines)
|
||||
{
|
||||
Row(line, at++);
|
||||
}
|
||||
int missing = lines.Count(line => !session.Recordings.Has(line.Name));
|
||||
Status(lines.Count == 0
|
||||
? "no phone message names a recording — Config ▸ Change SSB Function Key Definitions"
|
||||
: $"{lines.Count - missing} of {lines.Count} recorded");
|
||||
}
|
||||
|
||||
/// The operators the settings know about, the station callsign last: a
|
||||
/// single-operator station keeps its recordings in the folder named after
|
||||
/// the station, which is what the empty operator means.
|
||||
private void FillOperators()
|
||||
{
|
||||
List<string> names = [.. session.Settings.Operators
|
||||
.Select(o => o.Callsign.Trim().ToUpperInvariant())
|
||||
.Where(name => name.Length > 0)];
|
||||
string station = session.Settings.Station.Callsign.Trim().ToUpperInvariant();
|
||||
if (station.Length > 0 && !names.Contains(station))
|
||||
{
|
||||
names.Add(station);
|
||||
}
|
||||
names.Add("");
|
||||
OperatorBox.ItemsSource = names;
|
||||
string current = session.Settings.CurrentOperator.Trim().ToUpperInvariant();
|
||||
OperatorBox.SelectedItem = names.Contains(current) ? current : names[0];
|
||||
}
|
||||
|
||||
private void Header(int row)
|
||||
{
|
||||
Lines.RowDefinitions.Add(new RowDefinition(GridLength.Auto));
|
||||
Put(Label("Where", header: true), row, 0);
|
||||
Put(Label("Says", header: true), row, 1);
|
||||
Put(Label("File", header: true), row, 2);
|
||||
Put(Label("Recorded", header: true), row, 3);
|
||||
}
|
||||
|
||||
private void Row(VoiceLine line, int row)
|
||||
{
|
||||
Lines.RowDefinitions.Add(new RowDefinition(GridLength.Auto));
|
||||
bool has = session.Recordings.Has(line.Name);
|
||||
Put(Label(line.Where), row, 0);
|
||||
Put(Label(line.Says), row, 1);
|
||||
Put(Label(FileName(line)), row, 2);
|
||||
Put(
|
||||
new TextBlock
|
||||
{
|
||||
Text = has ? "yes" : "no",
|
||||
Foreground = new SolidColorBrush(has ? Color.Parse("#27AE60") : Color.Parse("#C0392B")),
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Margin = new Thickness(0, 2, 8, 2),
|
||||
},
|
||||
row,
|
||||
3);
|
||||
Put(Buttons(line, has), row, 4);
|
||||
}
|
||||
|
||||
private Control Buttons(VoiceLine line, bool has)
|
||||
{
|
||||
Button play = new() { Content = "Play", IsEnabled = has, Margin = new Thickness(0, 1, 4, 1) };
|
||||
play.Click += (_, _) => _ = PlayAsync(line);
|
||||
bool isRecording = recordingName == 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,
|
||||
Margin = new Thickness(0, 1, 0, 1),
|
||||
};
|
||||
record.Click += (_, _) => _ = RecordAsync(line);
|
||||
return new StackPanel
|
||||
{
|
||||
Orientation = Orientation.Horizontal,
|
||||
Children = { play, record },
|
||||
};
|
||||
}
|
||||
|
||||
private async Task PlayAsync(VoiceLine line)
|
||||
{
|
||||
Status($"playing {line.Name}");
|
||||
try
|
||||
{
|
||||
await session.Sound.PlayAsync(session.Recordings.PathOf(line.Name));
|
||||
Status($"played {line.Name}");
|
||||
}
|
||||
catch (Exception failure) when (failure is InvalidOperationException or IOException)
|
||||
{
|
||||
Status(failure.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts recording, or stops the recording that is running. What was
|
||||
/// recorded goes over the old file only when the recorder has finished
|
||||
/// with it, so a stop that fails leaves the old recording playable.
|
||||
private async Task RecordAsync(VoiceLine line)
|
||||
{
|
||||
if (recording is { } running)
|
||||
{
|
||||
running.Cancel();
|
||||
return;
|
||||
}
|
||||
if (!session.Sound.CanRecord)
|
||||
{
|
||||
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;
|
||||
}
|
||||
// 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();
|
||||
Status(outcome);
|
||||
}
|
||||
|
||||
private void SaveCommands()
|
||||
{
|
||||
Settings settings = session.Settings with
|
||||
{
|
||||
VoicePlayCommand = (PlayBox.Text ?? "").Trim(),
|
||||
VoiceRecordCommand = (RecordBox.Text ?? "").Trim(),
|
||||
VoiceKeysPtt = PttBox.IsChecked == true,
|
||||
};
|
||||
if (settings == session.Settings)
|
||||
{
|
||||
return;
|
||||
}
|
||||
session.Save(settings);
|
||||
// the keyer holds the commands, so it is built again with the new ones
|
||||
session.ApplyKeyerSettings();
|
||||
}
|
||||
|
||||
/// The file without the folder in front of it: the folder is the operator
|
||||
/// picked at the top and is the same for every row that has one.
|
||||
private static string FileName(VoiceLine line) =>
|
||||
line.Name[(line.Name.LastIndexOfAny(['\\', '/']) + 1)..];
|
||||
|
||||
private void Status(string text) => StatusText.Text = text;
|
||||
|
||||
private static TextBlock Label(string text, bool header = false) =>
|
||||
new()
|
||||
{
|
||||
Text = text,
|
||||
FontSize = header ? 11 : 12,
|
||||
Opacity = header ? 0.7 : 1,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Margin = new Thickness(0, 2, 8, 2),
|
||||
};
|
||||
|
||||
private void Put(Control control, int row, int column)
|
||||
{
|
||||
Grid.SetRow(control, row);
|
||||
Grid.SetColumn(control, column);
|
||||
Lines.Children.Add(control);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user