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:
@@ -40,6 +40,10 @@ public sealed class AppSession : IDisposable
|
||||
private DateTime statusSent;
|
||||
private DispatcherTimer? announcing;
|
||||
private MessageSender? keyer;
|
||||
private VoiceKeyer? voice;
|
||||
private SoundCard sound = new CommandSoundCard(
|
||||
CommandSoundCard.DefaultPlay,
|
||||
CommandSoundCard.DefaultRecord);
|
||||
private AlternatingCq? alternating;
|
||||
private MmttyEngine? digital;
|
||||
private DigitalEngineSender? digitalSender;
|
||||
@@ -63,6 +67,7 @@ public sealed class AppSession : IDisposable
|
||||
Paths = paths;
|
||||
Settings = settings;
|
||||
paths.CreateFolders();
|
||||
Recordings = new VoiceFiles(paths.Wav);
|
||||
Countries = LoadCountryFile(paths.CountryFile);
|
||||
Calls = LoadCallDatabase(paths.CallDatabaseFile);
|
||||
History = LoadCallHistory(settings.CallHistoryFile);
|
||||
@@ -152,6 +157,29 @@ public sealed class AppSession : IDisposable
|
||||
|
||||
public MessageSender? Keyer => keyer;
|
||||
|
||||
/// What sends a message on that mode: the digital engine on a digital
|
||||
/// mode, the voice keyer on phone, the CW keyer otherwise. Everything that
|
||||
/// sends asks here, so a message always goes out the way the mode needs.
|
||||
public MessageSender? SenderFor(ModeCategory mode) => mode switch
|
||||
{
|
||||
ModeCategory.Digital when digitalSender is { IsReady: true } engine => engine,
|
||||
ModeCategory.Phone when voice is { IsReady: true } speaking => speaking,
|
||||
_ => keyer,
|
||||
};
|
||||
|
||||
/// What plays the recordings on phone. It is there whether or not there is
|
||||
/// a CW keyer: the recordings go out of the sound card, not the keying
|
||||
/// port.
|
||||
public VoiceKeyer? Voice => voice;
|
||||
|
||||
/// The recordings under the `Wav` folder, for the voice keyer and for the
|
||||
/// window that records them.
|
||||
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.
|
||||
public SoundCard Sound => sound;
|
||||
|
||||
/// 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.
|
||||
@@ -175,6 +203,12 @@ public sealed class AppSession : IDisposable
|
||||
|
||||
public event EventHandler? ContestChanged;
|
||||
|
||||
/// What a keyer had to say while a message was going out — a recording
|
||||
/// that would not play, most of the time. The entry window puts it in the
|
||||
/// status line: by then the caller has been answered and there is nothing
|
||||
/// left to throw at.
|
||||
public event EventHandler<string>? Reported;
|
||||
|
||||
public void Save(Settings settings)
|
||||
{
|
||||
Settings = settings;
|
||||
@@ -571,6 +605,8 @@ public sealed class AppSession : IDisposable
|
||||
alternating = null;
|
||||
keyer?.Dispose();
|
||||
keyer = null;
|
||||
voice?.Dispose();
|
||||
voice = null;
|
||||
switch (Settings.KeyerKind.ToLowerInvariant())
|
||||
{
|
||||
case "cwdaemon":
|
||||
@@ -585,11 +621,21 @@ public sealed class AppSession : IDisposable
|
||||
if (keyer is not null)
|
||||
{
|
||||
_ = keyer.SetSpeedAsync(Settings.KeyerSpeed);
|
||||
alternating = new AlternatingCq(
|
||||
keyer,
|
||||
CallCqOnAsync,
|
||||
TimeSpan.FromMilliseconds(Settings.AlternatingCqGapMs));
|
||||
}
|
||||
sound = new CommandSoundCard(Settings.VoicePlayCommand, Settings.VoiceRecordCommand);
|
||||
voice = new VoiceKeyer(
|
||||
sound,
|
||||
Recordings,
|
||||
// only cwdaemon has a way in to the keying port from here, and
|
||||
// only when the operator has not said the radio keys itself
|
||||
Settings.VoiceKeysPtt && keyer is CwDaemonSender daemon ? daemon.SetPttAsync : null);
|
||||
voice.Reported += (_, what) => Reported?.Invoke(this, what);
|
||||
// a CQ goes out through the keyer the radio's mode calls for, so
|
||||
// alternating CQ listens to both of them
|
||||
alternating = new AlternatingCq(
|
||||
keyer is null ? [voice] : [keyer, voice],
|
||||
CallCqOnAsync,
|
||||
TimeSpan.FromMilliseconds(Settings.AlternatingCqGapMs));
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
@@ -696,9 +742,9 @@ public sealed class AppSession : IDisposable
|
||||
{
|
||||
OperatingPosition position = positions.FirstOrDefault(p => p.RadioNumber == radioNumber)
|
||||
?? throw new InvalidOperationException($"there is no radio {radioNumber}");
|
||||
if (keyer is null)
|
||||
if (SenderFor(position.Mode.Category) is not { } sender)
|
||||
{
|
||||
throw new InvalidOperationException("there is no keyer");
|
||||
throw new InvalidOperationException($"there is no keyer for {position.Mode.Name}");
|
||||
}
|
||||
string template = Messages
|
||||
.For(position.Mode.Category, Settings.CwMessageFile, Settings.PhoneMessageFile)
|
||||
@@ -710,7 +756,7 @@ public sealed class AppSession : IDisposable
|
||||
}
|
||||
MoveToRadio(radioNumber);
|
||||
await PointTransmitAtAsync(radioNumber).ConfigureAwait(false);
|
||||
await keyer.SendAsync(MessageExpander.Expand(template, position, Other(position))).ConfigureAwait(false);
|
||||
await sender.SendAsync(MessageExpander.Expand(template, position, Other(position))).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// Puts both radios in the headphones, or goes back to one. An operator
|
||||
@@ -886,6 +932,7 @@ public sealed class AppSession : IDisposable
|
||||
link?.Dispose();
|
||||
alternating?.Dispose();
|
||||
keyer?.Dispose();
|
||||
voice?.Dispose();
|
||||
box?.Dispose();
|
||||
store?.Dispose();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using Nonemm.Core;
|
||||
using Nonemm.Keying;
|
||||
using Nonemm.Network;
|
||||
using Nonemm.Session;
|
||||
|
||||
@@ -171,6 +172,18 @@ public sealed record Settings
|
||||
/// 100 ms, because an SO2R box works relays.
|
||||
public int AlternatingCqGapMs { get; init; } = 100;
|
||||
|
||||
/// What the voice keyer plays and records with. `{file}` in each command
|
||||
/// stands for the file. Empty means the program has nothing to play or
|
||||
/// record with, and the voice keyer window says so.
|
||||
public string VoicePlayCommand { get; init; } = CommandSoundCard.DefaultPlay;
|
||||
|
||||
public string VoiceRecordCommand { get; init; } = CommandSoundCard.DefaultRecord;
|
||||
|
||||
/// Whether the voice keyer keys the transmitter itself, through the same
|
||||
/// cwdaemon that keys CW. Turned off at a station whose radio hears the
|
||||
/// audio and keys itself on VOX.
|
||||
public bool VoiceKeysPtt { get; init; } = true;
|
||||
|
||||
/// How the WAE QTC window behaves. The names and the defaults are N1MM's:
|
||||
/// it skips the confirm and ready steps out of the box, clears the header
|
||||
/// when the header is asked for again, and leaves a line alone when the
|
||||
|
||||
+19
-12
@@ -28,20 +28,27 @@ public static class Messages
|
||||
F12 Wipe,
|
||||
""";
|
||||
|
||||
/// Phone messages name a recording to play; the text is what would be said.
|
||||
/// Phone messages name a recording to play, which is what N1MM's own SSB
|
||||
/// file holds. `{WAVDIR}` is the folder named after whoever is at the
|
||||
/// microphone, so each operator's own voice goes out. The recordings are
|
||||
/// made in the voice keyer window, which lists these names.
|
||||
///
|
||||
/// F5 is empty, as it is in N1MM's file: sending the other station's call
|
||||
/// needs a recording per letter, which this program does not play.
|
||||
public const string DefaultPhone =
|
||||
"""
|
||||
# Phone function keys.
|
||||
F1 CQ,CQ CONTEST {MYCALL}
|
||||
F2 Exch,{EXCH}
|
||||
F3 TU,THANK YOU {MYCALL}
|
||||
F4 MyCall,{MYCALL}
|
||||
F5 HisCall,!
|
||||
F6 QSO B4,QSO BEFORE
|
||||
F7 ?,PLEASE REPEAT
|
||||
F8 Agn,AGAIN
|
||||
F9 Nr?,NUMBER PLEASE
|
||||
F10 Call?,YOUR CALL PLEASE
|
||||
# Phone function keys. Each one names a recording under the Wav folder;
|
||||
# {WAVDIR} is the folder of whoever is at the microphone.
|
||||
F1 CQ,{WAVDIR}\Cq.wav
|
||||
F2 Exch,{WAVDIR}\Exchange.wav
|
||||
F3 TU,{WAVDIR}\Thanks.wav
|
||||
F4 MyCall,{WAVDIR}\MyCall.wav
|
||||
F5 HisCall,
|
||||
F6 QSO B4,{WAVDIR}\QsoB4.wav
|
||||
F7 ?,{WAVDIR}\Repeat.wav
|
||||
F8 Agn,{WAVDIR}\AllAgain.wav
|
||||
F9 Nr?,{WAVDIR}\NumberAgain.wav
|
||||
F10 Call?,{WAVDIR}\CallAgain.wav
|
||||
F11 Spot,
|
||||
F12 Wipe,
|
||||
""";
|
||||
|
||||
@@ -16,13 +16,10 @@ public sealed partial class EntryWindow
|
||||
/// actions after `{END}` still run.
|
||||
private static readonly TimeSpan SendingPatience = TimeSpan.FromMinutes(2);
|
||||
|
||||
/// What sends this radio's messages: the digital engine on a digital mode,
|
||||
/// and the CW or voice keyer otherwise. A message therefore goes out the
|
||||
/// way the mode needs without the caller choosing.
|
||||
/// What sends this radio's messages, which the mode decides: a message
|
||||
/// therefore goes out the way the mode needs without the caller choosing.
|
||||
private MessageSender? Sender =>
|
||||
Logging?.Mode.Category == ModeCategory.Digital && session.DigitalKeyer is { IsReady: true } engine
|
||||
? engine
|
||||
: session.Keyer;
|
||||
session.SenderFor(Logging?.Mode.Category ?? ModeCategory.Cw);
|
||||
|
||||
/// What to say when a message has nowhere to go. The mode decides which
|
||||
/// keyer a message uses, so the mode is part of the answer: an operator on
|
||||
|
||||
@@ -464,6 +464,9 @@ public sealed partial class EntryWindow
|
||||
|
||||
private void OnShowTelnet(object? sender, RoutedEventArgs e) => Show(() => new TelnetWindow(session, Tune));
|
||||
|
||||
private void OnShowVoiceKeyer(object? sender, RoutedEventArgs e) =>
|
||||
Show(() => new VoiceKeyerWindow(session));
|
||||
|
||||
/// N1MM's digital interface window, which is where RTTY is worked from.
|
||||
private void OnShowDigital(object? sender, RoutedEventArgs e) =>
|
||||
Show(() => new DigitalWindow(session, this, radioNumber));
|
||||
|
||||
@@ -117,6 +117,7 @@ public sealed partial class EntryWindow
|
||||
(nameof(GraylineWindow), () => _ = Show(() => new GraylineWindow(session), activate: false)),
|
||||
(nameof(TelnetWindow), () => _ = Show(() => new TelnetWindow(session, Tune), activate: false)),
|
||||
(nameof(DigitalWindow), () => _ = Show(() => new DigitalWindow(session, this, radioNumber), activate: false)),
|
||||
(nameof(VoiceKeyerWindow), () => _ = Show(() => new VoiceKeyerWindow(session), activate: false)),
|
||||
];
|
||||
|
||||
/// Puts a window where this operator last had it. Windows are opened one at
|
||||
|
||||
@@ -129,6 +129,7 @@
|
||||
<MenuItem Header="Statistics" Click="OnShowStatistics" />
|
||||
<MenuItem Header="Telnet" Click="OnShowTelnet" />
|
||||
<MenuItem Header="Digital Interface" Click="OnShowDigital" />
|
||||
<MenuItem Header="Voice Keyer" Click="OnShowVoiceKeyer" />
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
|
||||
|
||||
@@ -68,6 +68,9 @@ public sealed partial class EntryWindow : Window
|
||||
InitializeComponent();
|
||||
BuildFunctionKeys();
|
||||
session.Changed += (_, _) => Dispatcher.UIThread.Post(Refresh);
|
||||
// a keyer speaks on its own thread, and a recording that would not
|
||||
// play has nowhere else to be said
|
||||
session.Reported += (_, what) => Dispatcher.UIThread.Post(() => Status(what));
|
||||
session.ContestChanged += (_, _) => Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
BuildEntryBoxes();
|
||||
@@ -195,10 +198,9 @@ public sealed partial class EntryWindow : Window
|
||||
{
|
||||
yield return ("station network", session.ApplyNetworkSettings);
|
||||
}
|
||||
if (session.Settings.KeyerKind != "none")
|
||||
{
|
||||
yield return ("keyer", session.ApplyKeyerSettings);
|
||||
}
|
||||
// always, whatever the CW keyer is: this is also what builds the voice
|
||||
// keyer, which plays out of the sound card rather than a keying port
|
||||
yield return ("keyer", session.ApplyKeyerSettings);
|
||||
if (session.Settings.So2rBoxPort.Length > 0)
|
||||
{
|
||||
yield return ("SO2R box", session.ApplySo2rSettings);
|
||||
|
||||
@@ -102,7 +102,7 @@ public sealed partial class QtcWindow : Window
|
||||
return isSending
|
||||
? IsRtty
|
||||
? "Send All reads the whole series out — Snd n sends one QTC again"
|
||||
: "Nothing goes out on SSB: read the lines out yourself, then Close to log them."
|
||||
: "The buttons play the QRV, Agn, Cfm and TU recordings. Read the lines out yourself, then Close to log them."
|
||||
: "Type the header, then a line per QTC: time, callsign, serial number.";
|
||||
}
|
||||
return isSending
|
||||
@@ -116,9 +116,11 @@ public sealed partial class QtcWindow : Window
|
||||
|
||||
private bool IsRtty => position.Contest.Modes.Contains(ModeCategory.Digital);
|
||||
|
||||
/// Whether this window puts anything on the air. SSB needs a voice keyer
|
||||
/// and there is none here, so on SSB the operator speaks and the window
|
||||
/// only writes the traffic down.
|
||||
private bool IsPhone => !IsCw && !IsRtty;
|
||||
|
||||
/// Whether this window keys the traffic itself. On SSB it plays N1MM's
|
||||
/// four recordings and the operator reads the lines out: a QTC line is a
|
||||
/// time, a callsign and a serial, and no recording can say one.
|
||||
private bool Keys => IsCw || IsRtty;
|
||||
|
||||
private Settings Settings => session.Settings;
|
||||
@@ -149,8 +151,10 @@ public sealed partial class QtcWindow : Window
|
||||
CountsText.Text = traffic.CountsFor(station).Text;
|
||||
KeysText.Text = KeysLine();
|
||||
// N1MM's labels: the buttons say what they do in the direction the
|
||||
// traffic is going, and the ones with nothing to do are hidden
|
||||
ReadyButton.IsVisible = Keys || !isSending;
|
||||
// traffic is going, and the ones with nothing to do are hidden. This
|
||||
// one always has something to do — on SSB it plays the QRV recording
|
||||
// whichever way the traffic is going
|
||||
ReadyButton.IsVisible = true;
|
||||
ReadyButton.Content = !IsCw ? isSending ? "R U QRV" : "RX Ready"
|
||||
: isSending ? "R U QRV" : "QRV";
|
||||
HeaderAgainButton.Content = isSending ? "Snd Hdr" : "Hdr Agn";
|
||||
@@ -488,8 +492,8 @@ public sealed partial class QtcWindow : Window
|
||||
}
|
||||
|
||||
/// Reading traffic out, this asks the other station whether it is ready;
|
||||
/// taking it down, it says we are. N1MM keys `QRV?` and `QRV` and plays a
|
||||
/// recording on SSB, which there is no keyer for here.
|
||||
/// taking it down, it says we are. N1MM keys `QRV?` and `QRV` on CW and
|
||||
/// plays one recording either way on SSB.
|
||||
private void OnReady(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (IsCw)
|
||||
@@ -501,6 +505,10 @@ public sealed partial class QtcWindow : Window
|
||||
_ = send(QtcMessages.Rtty(
|
||||
isSending ? Settings.QtcRttyAreYouReady : Settings.QtcRttyReady));
|
||||
}
|
||||
else
|
||||
{
|
||||
_ = send(VoiceLines.QtcReady);
|
||||
}
|
||||
Control next = isSending ? SendAllButton.IsVisible ? SendAllButton : rows[0].Again
|
||||
: HeaderBox;
|
||||
next.Focus();
|
||||
@@ -526,6 +534,10 @@ public sealed partial class QtcWindow : Window
|
||||
{
|
||||
_ = send(QtcMessages.Rtty(QtcMessages.RttyHeaderAgain));
|
||||
}
|
||||
else
|
||||
{
|
||||
_ = send(VoiceLines.QtcAgain);
|
||||
}
|
||||
if (session.Settings.QtcHeaderAgainClears)
|
||||
{
|
||||
HeaderBox.Text = "";
|
||||
@@ -533,7 +545,14 @@ public sealed partial class QtcWindow : Window
|
||||
HeaderBox.Focus();
|
||||
}
|
||||
|
||||
private void OnHeaderConfirm(object? sender, RoutedEventArgs e) => rows[0].Time.Focus();
|
||||
private void OnHeaderConfirm(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (IsPhone)
|
||||
{
|
||||
_ = send(VoiceLines.QtcConfirm);
|
||||
}
|
||||
rows[0].Time.Focus();
|
||||
}
|
||||
|
||||
/// N1MM's All Agn: asks for the header and every line of the series again,
|
||||
/// and clears what was taken down so the series can be copied afresh.
|
||||
@@ -578,6 +597,12 @@ public sealed partial class QtcWindow : Window
|
||||
{
|
||||
_ = send($"AGN QTC {at + 1}");
|
||||
}
|
||||
else
|
||||
{
|
||||
// one recording for every line: it says "again", and the
|
||||
// operator says which line
|
||||
_ = send(VoiceLines.QtcAgain);
|
||||
}
|
||||
if (Settings.QtcAgainClearsLine)
|
||||
{
|
||||
rows[at].Clear();
|
||||
@@ -750,7 +775,11 @@ public sealed partial class QtcWindow : Window
|
||||
// N1MM sends a message as the window closes: the TU on CW, and on RTTY
|
||||
// `R R R ALL OK BK` from the station that took the traffic down or
|
||||
// `GL DE <call>` from the one that read it out
|
||||
if (IsCw && Settings.QtcCwTu.Trim().Length > 0)
|
||||
if (IsPhone)
|
||||
{
|
||||
_ = send(VoiceLines.QtcThanks);
|
||||
}
|
||||
else if (IsCw && Settings.QtcCwTu.Trim().Length > 0)
|
||||
{
|
||||
_ = send(QtcMessages.Tu(Settings.QtcCwTu, HeaderBox.Text ?? ""));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
<local:RefreshableWindow xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:local="using:Nonemm.App.Windows"
|
||||
x:Class="Nonemm.App.Windows.VoiceKeyerWindow"
|
||||
Title="Voice Keyer" Width="760" Height="540">
|
||||
<DockPanel Margin="10">
|
||||
<Grid DockPanel.Dock="Top" ColumnDefinitions="Auto,8,Auto,8,*">
|
||||
<TextBlock Text="Operator" VerticalAlignment="Center" FontSize="11" Opacity="0.7" />
|
||||
<ComboBox Name="OperatorBox" Grid.Column="2" Width="140" />
|
||||
<TextBlock Name="FolderText" Grid.Column="4" VerticalAlignment="Center" FontSize="11"
|
||||
Opacity="0.7" TextTrimming="CharacterEllipsis" />
|
||||
</Grid>
|
||||
|
||||
<StackPanel DockPanel.Dock="Bottom" Spacing="4" Margin="0,8,0,0">
|
||||
<TextBlock Name="StatusText" FontSize="11" Opacity="0.7" TextWrapping="Wrap" />
|
||||
<Grid ColumnDefinitions="*,8,*,8,Auto" RowDefinitions="Auto,Auto">
|
||||
<TextBlock Text="Play command" FontSize="11" Opacity="0.7" Margin="0,0,0,1" />
|
||||
<TextBox Name="PlayBox" Grid.Row="1" />
|
||||
<TextBlock Grid.Column="2" Text="Record command" FontSize="11" Opacity="0.7" Margin="0,0,0,1" />
|
||||
<TextBox Name="RecordBox" Grid.Row="1" Grid.Column="2" />
|
||||
<CheckBox Name="PttBox" Grid.Row="1" Grid.Column="4" Content="Key PTT through cwdaemon"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
|
||||
<ScrollViewer>
|
||||
<Grid Name="Lines" ColumnDefinitions="110,120,*,70,Auto" Margin="0,8,14,0" />
|
||||
</ScrollViewer>
|
||||
</DockPanel>
|
||||
</local:RefreshableWindow>
|
||||
@@ -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