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,240 @@
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace Nonemm.Keying;
|
||||
|
||||
/// Plays and records by running a program: `aplay` and `arecord` on Linux,
|
||||
/// which are in alsa-utils and are what a station already has. `{file}` in the
|
||||
/// command stands for the file.
|
||||
///
|
||||
/// The reason it is a program rather than a sound library is the reason the CW
|
||||
/// timing is cwdaemon's, in `docs/keying.md`: the audio path belongs to
|
||||
/// whatever the station runs — ALSA, PulseAudio, PipeWire, JACK — and a logger
|
||||
/// that picks one of them for the operator is wrong at three stations out of
|
||||
/// four. The command is a setting, so a station on JACK writes its own.
|
||||
public sealed class CommandSoundCard : SoundCard
|
||||
{
|
||||
/// The signal a recorder is stopped with. `arecord` closes the file and
|
||||
/// writes its header on this; killed outright it leaves a wav nothing can
|
||||
/// play.
|
||||
private const int Interrupt = 2;
|
||||
|
||||
/// How long a recorder is given to finish its file after the interrupt.
|
||||
/// One that is still there after it is killed: a command that ignores the
|
||||
/// signal would otherwise hold the microphone for the rest of the contest.
|
||||
private static readonly TimeSpan StopGrace = TimeSpan.FromSeconds(2);
|
||||
|
||||
private readonly string play;
|
||||
private readonly string record;
|
||||
|
||||
public CommandSoundCard(string play, string record)
|
||||
{
|
||||
this.play = play;
|
||||
this.record = record;
|
||||
}
|
||||
|
||||
/// Windows has no player on the path, so the default runs the one in
|
||||
/// .NET's own class through PowerShell. PowerShell joins what follows
|
||||
/// `-Command`, so the spaces in the expression do not need quoting.
|
||||
public static string DefaultPlay =>
|
||||
OperatingSystem.IsWindows()
|
||||
? "powershell -NoProfile -Command (New-Object Media.SoundPlayer '{file}').PlaySync()"
|
||||
: "aplay -q {file}";
|
||||
|
||||
/// Mono at 22050 Hz, which is what a voice message needs and what N1MM's
|
||||
/// own recordings are. Windows has nothing to record with out of the box,
|
||||
/// so there the operator names a recorder — `sox` or `ffmpeg` — before the
|
||||
/// record button does anything.
|
||||
public static string DefaultRecord =>
|
||||
OperatingSystem.IsWindows()
|
||||
? ""
|
||||
: "arecord -q -f S16_LE -c 1 -r 22050 -t wav {file}";
|
||||
|
||||
public bool CanRecord => record.Trim().Length > 0;
|
||||
|
||||
public async Task PlayAsync(string file, CancellationToken cancellation = default)
|
||||
{
|
||||
if (!File.Exists(file))
|
||||
{
|
||||
throw new FileNotFoundException($"{file} is not there", file);
|
||||
}
|
||||
await RunAsync(play, file, cancellation).ConfigureAwait(false);
|
||||
// an aborted message stops here rather than going on to the next file
|
||||
cancellation.ThrowIfCancellationRequested();
|
||||
}
|
||||
|
||||
/// The recording goes to a file beside the wanted one and is moved over it
|
||||
/// at the end, so a recorder that will not start takes nothing away.
|
||||
///
|
||||
/// The cancellation is how a recording ends — the operator has finished
|
||||
/// speaking — so it keeps the file rather than throwing it away.
|
||||
public async Task RecordAsync(string file, CancellationToken cancellation = default)
|
||||
{
|
||||
if (!CanRecord)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"there is no recorder — name one in the voice keyer window");
|
||||
}
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(file) ?? ".");
|
||||
string writing = file + ".recording";
|
||||
try
|
||||
{
|
||||
await RunAsync(record, writing, cancellation).ConfigureAwait(false);
|
||||
File.Move(writing, file, overwrite: true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
Delete(writing);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// Splits a command the way a shell would, on spaces, with double quotes
|
||||
/// holding a word together. It is not a shell: a station that wants pipes
|
||||
/// or a variable writes `sh -c "…"`.
|
||||
private static IReadOnlyList<string> Split(string command)
|
||||
{
|
||||
List<string> words = [];
|
||||
string word = "";
|
||||
bool quoted = false;
|
||||
bool has = false;
|
||||
foreach (char c in command)
|
||||
{
|
||||
if (c == '"')
|
||||
{
|
||||
quoted = !quoted;
|
||||
has = true;
|
||||
continue;
|
||||
}
|
||||
if (c == ' ' && !quoted)
|
||||
{
|
||||
if (has)
|
||||
{
|
||||
words.Add(word);
|
||||
}
|
||||
word = "";
|
||||
has = false;
|
||||
continue;
|
||||
}
|
||||
word += c;
|
||||
has = true;
|
||||
}
|
||||
if (has)
|
||||
{
|
||||
words.Add(word);
|
||||
}
|
||||
return words;
|
||||
}
|
||||
|
||||
/// Runs the command with `{file}` filled in, and finishes when it has
|
||||
/// exited. A cancellation stops it with an interrupt rather than a kill,
|
||||
/// which is what lets a recorder finish the file it is writing.
|
||||
private static async Task RunAsync(
|
||||
string command,
|
||||
string file,
|
||||
CancellationToken cancellation)
|
||||
{
|
||||
IReadOnlyList<string> words = Split(command);
|
||||
if (words.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("no sound card command is set");
|
||||
}
|
||||
ProcessStartInfo start = new()
|
||||
{
|
||||
FileName = words[0],
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
};
|
||||
foreach (string argument in words.Skip(1))
|
||||
{
|
||||
start.ArgumentList.Add(argument.Replace("{file}", file));
|
||||
}
|
||||
Process running;
|
||||
try
|
||||
{
|
||||
running = Process.Start(start)
|
||||
?? throw new InvalidOperationException($"{words[0]} did not start");
|
||||
}
|
||||
catch (Exception cause) when (cause is System.ComponentModel.Win32Exception or IOException)
|
||||
{
|
||||
throw new InvalidOperationException($"{words[0]} could not be run: {cause.Message}", cause);
|
||||
}
|
||||
using (running)
|
||||
using (cancellation.Register(() => _ = StopAsync(running)))
|
||||
{
|
||||
string errors = await running.StandardError.ReadToEndAsync(CancellationToken.None)
|
||||
.ConfigureAwait(false);
|
||||
await running.WaitForExitAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
// an interrupted recorder exits non-zero on some systems and zero
|
||||
// on others, and either way it has written the file we asked for
|
||||
if (running.ExitCode != 0 && !cancellation.IsCancellationRequested)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"{words[0]} exited with code {running.ExitCode}: {Tail(errors)}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Asks the command to stop, and makes it stop if it will not. The ask is
|
||||
/// the interrupt; what follows is the kill, and it takes the children with
|
||||
/// it, because a command written as `sh -c "…"` leaves the program that is
|
||||
/// really holding the sound card behind it.
|
||||
private static async Task StopAsync(Process running)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (running.HasExited)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (!OperatingSystem.IsWindows() && kill(running.Id, Interrupt) == 0)
|
||||
{
|
||||
await running.WaitForExitAsync().WaitAsync(StopGrace).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
running.Kill(entireProcessTree: true);
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
Kill(running);
|
||||
}
|
||||
catch (Exception failure) when (failure is InvalidOperationException or SystemException)
|
||||
{
|
||||
// it stopped on its own between two of the calls above, which is
|
||||
// the end we were asking for
|
||||
}
|
||||
}
|
||||
|
||||
private static void Kill(Process running)
|
||||
{
|
||||
try
|
||||
{
|
||||
running.Kill(entireProcessTree: true);
|
||||
}
|
||||
catch (Exception failure) when (failure is InvalidOperationException or SystemException)
|
||||
{
|
||||
// it stopped while we were reaching for it
|
||||
}
|
||||
}
|
||||
|
||||
private static void Delete(string file)
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(file);
|
||||
}
|
||||
catch (Exception failure) when (failure is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// the half-written file is worth no more than a message about it
|
||||
}
|
||||
}
|
||||
|
||||
private static string Tail(string errors)
|
||||
{
|
||||
string[] lines = errors.Split('\n', StringSplitOptions.RemoveEmptyEntries);
|
||||
return lines.Length == 0 ? "no output" : lines[^1].Trim();
|
||||
}
|
||||
|
||||
[DllImport("libc", SetLastError = true)]
|
||||
private static extern int kill(int pid, int signal);
|
||||
}
|
||||
@@ -48,6 +48,12 @@ public sealed class CwDaemonSender : MessageSender
|
||||
public Task SetSpeedAsync(int wordsPerMinute, CancellationToken cancellation = default) =>
|
||||
WriteAsync(Escape('2', wordsPerMinute.ToString()), cancellation);
|
||||
|
||||
/// cwdaemon's `<ESC>a`: the transmitter is keyed and stays keyed until it
|
||||
/// is told to drop. The voice keyer needs it — the recording goes out of
|
||||
/// the sound card, so something else has to key the radio.
|
||||
public Task SetPttAsync(bool on, CancellationToken cancellation = default) =>
|
||||
WriteAsync(Escape('a', on ? "1" : "0"), cancellation);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
reading.Cancel();
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace Nonemm.Keying;
|
||||
|
||||
/// The station's sound card, as far as a voice keyer needs one: play a wav
|
||||
/// file, or record one.
|
||||
public interface SoundCard
|
||||
{
|
||||
/// True when there is something to record with. A station that can play
|
||||
/// but not record still keys its messages.
|
||||
bool CanRecord { get; }
|
||||
|
||||
/// Finishes when the file has been played.
|
||||
Task PlayAsync(string file, CancellationToken cancellation = default);
|
||||
|
||||
/// Records until the cancellation, and finishes when the recorder has
|
||||
/// closed the file. The file is replaced only if the recording worked, so
|
||||
/// a recorder that will not start leaves the old one where it was.
|
||||
Task RecordAsync(string file, CancellationToken cancellation = default);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
namespace Nonemm.Keying;
|
||||
|
||||
/// The recordings under the `Wav` folder. A message names its files the way
|
||||
/// N1MM names them: relative to that folder, separated by commas when there is
|
||||
/// more than one, and with a backslash between the folder and the file
|
||||
/// whatever the program is running on. `Cq.wav` is therefore `Wav/Cq.wav` and
|
||||
/// `OM3KFF\Cq.wav` is one operator's own.
|
||||
public sealed class VoiceFiles
|
||||
{
|
||||
private readonly string folder;
|
||||
|
||||
public VoiceFiles(string folder) => this.folder = folder;
|
||||
|
||||
/// The files a message names, in the order they would be played. A message
|
||||
/// that names none is text for the operator to read out, which no keyer
|
||||
/// can say.
|
||||
public static IReadOnlyList<string> NamesIn(string message) =>
|
||||
[.. message
|
||||
.Split(',')
|
||||
.Select(name => name.Trim())
|
||||
.Where(name => name.EndsWith(".wav", StringComparison.OrdinalIgnoreCase))];
|
||||
|
||||
/// The name is always under the `Wav` folder: a rooted one would be
|
||||
/// combined away and would play a file from somewhere else entirely.
|
||||
public string PathOf(string name) =>
|
||||
Path.Combine(
|
||||
folder,
|
||||
name.Trim().Replace('\\', Path.DirectorySeparatorChar).TrimStart(Path.DirectorySeparatorChar));
|
||||
|
||||
public bool Has(string name) => File.Exists(PathOf(name));
|
||||
|
||||
/// The first file the message names that is not recorded, or null when
|
||||
/// every one of them is there.
|
||||
public string? FirstMissing(string message) =>
|
||||
NamesIn(message).FirstOrDefault(name => !Has(name));
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
namespace Nonemm.Keying;
|
||||
|
||||
/// Puts a recording on the air, which is what a phone function key sends. The
|
||||
/// message names the files, the sound card plays them, and the transmitter is
|
||||
/// keyed from the first file to the last.
|
||||
///
|
||||
/// `keyTransmitter` is what keys it: cwdaemon's `<ESC>a`, at a station whose
|
||||
/// CW keyer is a cwdaemon on the same port. Left null, nothing is keyed and
|
||||
/// the radio's VOX hears the audio and keys itself, which is how most SSB
|
||||
/// stations run a voice keyer anyway.
|
||||
public sealed class VoiceKeyer : MessageSender
|
||||
{
|
||||
private readonly SoundCard sound;
|
||||
private readonly VoiceFiles files;
|
||||
private readonly Func<bool, CancellationToken, Task>? keyTransmitter;
|
||||
|
||||
/// One message plays at a time; a second waits for the first, which is
|
||||
/// what the interface says a keyer does.
|
||||
private readonly SemaphoreSlim playing = new(1, 1);
|
||||
private readonly Lock gate = new();
|
||||
private CancellationTokenSource? current;
|
||||
private bool disposed;
|
||||
|
||||
public VoiceKeyer(
|
||||
SoundCard sound,
|
||||
VoiceFiles files,
|
||||
Func<bool, CancellationToken, Task>? keyTransmitter = null)
|
||||
{
|
||||
this.sound = sound;
|
||||
this.files = files;
|
||||
this.keyTransmitter = keyTransmitter;
|
||||
}
|
||||
|
||||
public bool IsReady => !disposed;
|
||||
|
||||
public bool ReportsCompletion => true;
|
||||
|
||||
public event EventHandler? Finished;
|
||||
|
||||
/// A recording that would not play. It happens while a message is on the
|
||||
/// air rather than when it is sent, so it cannot be thrown at the caller;
|
||||
/// the entry window puts it in the status line.
|
||||
public event EventHandler<string>? Reported;
|
||||
|
||||
/// Starts the recordings and returns. `Finished` says when the last one
|
||||
/// has been played, which is what alternating CQ and the actions after
|
||||
/// `{END}` wait for.
|
||||
public Task SendAsync(string text, CancellationToken cancellation = default)
|
||||
{
|
||||
IReadOnlyList<string> names = VoiceFiles.NamesIn(text);
|
||||
if (names.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"nothing to play: \"{text}\" names no .wav file — Window ▸ Voice Keyer");
|
||||
}
|
||||
if (files.FirstMissing(text) is { } missing)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"no recording {missing} — Window ▸ Voice Keyer");
|
||||
}
|
||||
_ = PlayAsync(names);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task AbortAsync(CancellationToken cancellation = default)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
current?.Cancel();
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// A recording plays at the speed it was recorded at.
|
||||
public Task SetSpeedAsync(int wordsPerMinute, CancellationToken cancellation = default) =>
|
||||
Task.CompletedTask;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
disposed = true;
|
||||
lock (gate)
|
||||
{
|
||||
current?.Cancel();
|
||||
current?.Dispose();
|
||||
current = null;
|
||||
}
|
||||
playing.Dispose();
|
||||
}
|
||||
|
||||
private async Task PlayAsync(IReadOnlyList<string> names)
|
||||
{
|
||||
await playing.WaitAsync().ConfigureAwait(false);
|
||||
CancellationTokenSource stopping = new();
|
||||
lock (gate)
|
||||
{
|
||||
current?.Dispose();
|
||||
current = stopping;
|
||||
}
|
||||
try
|
||||
{
|
||||
await Key(true, stopping.Token).ConfigureAwait(false);
|
||||
foreach (string name in names)
|
||||
{
|
||||
await sound.PlayAsync(files.PathOf(name), stopping.Token).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Escape stopped the message, which is not a fault
|
||||
}
|
||||
catch (Exception failure) when (failure is InvalidOperationException or IOException)
|
||||
{
|
||||
Reported?.Invoke(this, failure.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await Unkey().ConfigureAwait(false);
|
||||
playing.Release();
|
||||
Finished?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
private Task Key(bool on, CancellationToken cancellation) =>
|
||||
keyTransmitter?.Invoke(on, cancellation) ?? Task.CompletedTask;
|
||||
|
||||
/// The transmitter comes down whatever went wrong above it. A logger that
|
||||
/// leaves a station keyed because a file was missing is worse than one
|
||||
/// that says nothing at all.
|
||||
private async Task Unkey()
|
||||
{
|
||||
try
|
||||
{
|
||||
await Key(false, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception failure) when (failure is InvalidOperationException or IOException)
|
||||
{
|
||||
Reported?.Invoke(this, $"the transmitter could not be unkeyed: {failure.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user