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,134 @@
|
||||
namespace Nonemm.Keying.Tests;
|
||||
|
||||
/// The sound card is driven with ordinary shell commands standing in for
|
||||
/// `aplay` and `arecord`, so what the program does with a command — the file
|
||||
/// it fills in, the exit code, the interrupt — is what is asserted.
|
||||
public class CommandSoundCardTests : IDisposable
|
||||
{
|
||||
private readonly string folder = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
|
||||
|
||||
public CommandSoundCardTests() => Directory.CreateDirectory(folder);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Directory.Delete(folder, recursive: true);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
private string In(string name) => Path.Combine(folder, name);
|
||||
|
||||
/// Double quotes hold a word together, so a command whose argument has
|
||||
/// spaces in it arrives as one argument.
|
||||
[Fact]
|
||||
public async Task AQuotedWordHoldsTogether()
|
||||
{
|
||||
string played = In("played");
|
||||
File.WriteAllText(In("Cq.wav"), "hello");
|
||||
CommandSoundCard card = new($$"""sh -c "cp $0 {{played}}" {file}""", "");
|
||||
|
||||
await card.PlayAsync(In("Cq.wav"));
|
||||
|
||||
Assert.Equal("hello", File.ReadAllText(played));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PlayingRunsTheCommandOnTheFile()
|
||||
{
|
||||
string played = In("played");
|
||||
File.WriteAllText(In("Cq.wav"), "hello");
|
||||
CommandSoundCard card = new($"cp {{file}} {played}", "");
|
||||
|
||||
await card.PlayAsync(In("Cq.wav"));
|
||||
|
||||
Assert.Equal("hello", File.ReadAllText(played));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PlayingAFileThatIsNotThereSaysWhichFile()
|
||||
{
|
||||
CommandSoundCard card = new("cp {file} /dev/null", "");
|
||||
|
||||
FileNotFoundException missing = await Assert.ThrowsAsync<FileNotFoundException>(
|
||||
() => card.PlayAsync(In("Cq.wav")));
|
||||
|
||||
Assert.Contains("Cq.wav", missing.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task APlayerThatIsNotInstalledSaysSo()
|
||||
{
|
||||
File.WriteAllText(In("Cq.wav"), "");
|
||||
CommandSoundCard card = new("nonemm-no-such-player {file}", "");
|
||||
|
||||
InvalidOperationException failed = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => card.PlayAsync(In("Cq.wav")));
|
||||
|
||||
Assert.Contains("nonemm-no-such-player", failed.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task APlayerThatExitsWithAnErrorIsNotPassedOver()
|
||||
{
|
||||
File.WriteAllText(In("Cq.wav"), "");
|
||||
CommandSoundCard card = new("""sh -c "echo broken pipe >&2; exit 3" """, "");
|
||||
|
||||
InvalidOperationException failed = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => card.PlayAsync(In("Cq.wav")));
|
||||
|
||||
Assert.Contains("code 3", failed.Message);
|
||||
Assert.Contains("broken pipe", failed.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecordingWritesTheFile()
|
||||
{
|
||||
CommandSoundCard card = new("", """sh -c "echo recorded > $0" {file}""");
|
||||
|
||||
await card.RecordAsync(In("Cq.wav"));
|
||||
|
||||
Assert.Equal("recorded", File.ReadAllText(In("Cq.wav")).Trim());
|
||||
}
|
||||
|
||||
/// Stopping a recorder is what the operator does at the end of every
|
||||
/// recording, so what it was writing is the recording rather than a
|
||||
/// failure.
|
||||
[Fact]
|
||||
public async Task ARecorderThatIsStoppedKeepsWhatItWrote()
|
||||
{
|
||||
CommandSoundCard card = new("", """sh -c "echo recorded > $0; sleep 30" {file}""");
|
||||
using CancellationTokenSource stopping = new();
|
||||
Task recording = card.RecordAsync(In("Cq.wav"), stopping.Token);
|
||||
|
||||
while (!File.Exists(In("Cq.wav") + ".recording"))
|
||||
{
|
||||
await Task.Delay(20);
|
||||
}
|
||||
await stopping.CancelAsync();
|
||||
await recording.WaitAsync(TimeSpan.FromSeconds(10));
|
||||
|
||||
Assert.Equal("recorded", File.ReadAllText(In("Cq.wav")).Trim());
|
||||
}
|
||||
|
||||
/// The old recording is worth more than a failed one: a station that
|
||||
/// cannot record today still has the voice it recorded last year.
|
||||
[Fact]
|
||||
public async Task ARecorderThatFailsLeavesTheOldRecordingAlone()
|
||||
{
|
||||
File.WriteAllText(In("Cq.wav"), "the old one");
|
||||
CommandSoundCard card = new("", "nonemm-no-such-recorder {file}");
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => card.RecordAsync(In("Cq.wav")));
|
||||
|
||||
Assert.Equal("the old one", File.ReadAllText(In("Cq.wav")));
|
||||
Assert.False(File.Exists(In("Cq.wav") + ".recording"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AStationWithNoRecorderIsToldRatherThanLeftWaiting()
|
||||
{
|
||||
CommandSoundCard card = new("aplay {file}", "");
|
||||
|
||||
Assert.False(card.CanRecord);
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => card.RecordAsync(In("Cq.wav")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
namespace Nonemm.Keying.Tests;
|
||||
|
||||
public class VoiceFilesTests
|
||||
{
|
||||
[Fact]
|
||||
public void AMessageNamesTheFilesItPlays()
|
||||
{
|
||||
IReadOnlyList<string> names = VoiceFiles.NamesIn(@"OM3KFF\Cq.wav, OM3KFF\Exchange.wav");
|
||||
|
||||
Assert.Equal([@"OM3KFF\Cq.wav", @"OM3KFF\Exchange.wav"], names);
|
||||
}
|
||||
|
||||
/// A phone message that is words rather than a file name is what the
|
||||
/// operator would say himself, and no keyer can say it.
|
||||
[Fact]
|
||||
public void TextThatNamesNoRecordingIsNoFile()
|
||||
{
|
||||
Assert.Empty(VoiceFiles.NamesIn("CQ CONTEST OM5M"));
|
||||
}
|
||||
|
||||
/// N1MM writes the separator as a backslash whatever it is running on.
|
||||
[Fact]
|
||||
public void TheFolderSeparatorIsN1mmsWhateverThisRunsOn()
|
||||
{
|
||||
VoiceFiles files = new("/home/op/Wav");
|
||||
|
||||
Assert.Equal(
|
||||
Path.Combine("/home/op/Wav", "OM3KFF", "Cq.wav"),
|
||||
files.PathOf(@"OM3KFF\Cq.wav"));
|
||||
}
|
||||
|
||||
/// A rooted name would be combined away and would play a file from
|
||||
/// somewhere else entirely.
|
||||
[Fact]
|
||||
public void ANameIsAlwaysUnderTheWavFolder()
|
||||
{
|
||||
VoiceFiles files = new("/home/op/Wav");
|
||||
|
||||
Assert.Equal(Path.Combine("/home/op/Wav", "etc", "shadow"), files.PathOf("/etc/shadow"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheFirstRecordingThatIsNotThereIsNamed()
|
||||
{
|
||||
string folder = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
|
||||
Directory.CreateDirectory(folder);
|
||||
try
|
||||
{
|
||||
File.WriteAllText(Path.Combine(folder, "Cq.wav"), "");
|
||||
VoiceFiles files = new(folder);
|
||||
|
||||
Assert.Null(files.FirstMissing("Cq.wav"));
|
||||
Assert.Equal("Exchange.wav", files.FirstMissing("Cq.wav,Exchange.wav"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(folder, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
namespace Nonemm.Keying.Tests;
|
||||
|
||||
/// The keyer is driven with a sound card that records what it was asked to
|
||||
/// play instead of playing it, so what goes to the card and what the
|
||||
/// transmitter does are both asserted.
|
||||
public class VoiceKeyerTests : IDisposable
|
||||
{
|
||||
private readonly string folder = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
|
||||
private readonly FakeSoundCard card = new();
|
||||
private readonly List<bool> keying = [];
|
||||
|
||||
public VoiceKeyerTests()
|
||||
{
|
||||
Directory.CreateDirectory(folder);
|
||||
Record("Cq.wav");
|
||||
Record("Exchange.wav");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Directory.Delete(folder, recursive: true);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
private void Record(string name) => File.WriteAllText(Path.Combine(folder, name), "");
|
||||
|
||||
private VoiceKeyer Keyer() =>
|
||||
new(card, new VoiceFiles(folder), (on, _) =>
|
||||
{
|
||||
lock (keying)
|
||||
{
|
||||
keying.Add(on);
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
private static async Task Finished(VoiceKeyer keyer, Func<Task> send)
|
||||
{
|
||||
TaskCompletionSource done = new();
|
||||
keyer.Finished += (_, _) => done.TrySetResult();
|
||||
await send();
|
||||
await done.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheFilesAMessageNamesArePlayedInOrder()
|
||||
{
|
||||
using VoiceKeyer keyer = Keyer();
|
||||
|
||||
await Finished(keyer, () => keyer.SendAsync("Cq.wav,Exchange.wav"));
|
||||
|
||||
Assert.Equal(
|
||||
[Path.Combine(folder, "Cq.wav"), Path.Combine(folder, "Exchange.wav")],
|
||||
card.Played);
|
||||
}
|
||||
|
||||
/// The recording goes out of the sound card, so the transmitter has to be
|
||||
/// keyed for as long as it plays and dropped at the end.
|
||||
[Fact]
|
||||
public async Task TheTransmitterIsKeyedForTheMessageAndDroppedAfterIt()
|
||||
{
|
||||
using VoiceKeyer keyer = Keyer();
|
||||
|
||||
await Finished(keyer, () => keyer.SendAsync("Cq.wav"));
|
||||
|
||||
Assert.Equal([true, false], keying);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AMessageThatNamesARecordingThatIsNotThereIsRefused()
|
||||
{
|
||||
using VoiceKeyer keyer = Keyer();
|
||||
|
||||
InvalidOperationException refused = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => keyer.SendAsync("Cq.wav,Thanks.wav"));
|
||||
|
||||
Assert.Contains("Thanks.wav", refused.Message);
|
||||
Assert.Empty(card.Played);
|
||||
}
|
||||
|
||||
/// Nothing is keyed for a message that could not be played: a transmitter
|
||||
/// held up over a missing file is a stuck carrier.
|
||||
[Fact]
|
||||
public async Task ARefusedMessageDoesNotKeyTheTransmitter()
|
||||
{
|
||||
using VoiceKeyer keyer = Keyer();
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => keyer.SendAsync("Thanks.wav"));
|
||||
|
||||
Assert.Empty(keying);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TextWithNoRecordingInItIsRefused()
|
||||
{
|
||||
using VoiceKeyer keyer = Keyer();
|
||||
|
||||
InvalidOperationException refused = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => keyer.SendAsync("CQ CONTEST OM5M"));
|
||||
|
||||
Assert.Contains("names no .wav", refused.Message);
|
||||
}
|
||||
|
||||
/// Escape mid-message: what is playing stops, and the transmitter comes
|
||||
/// down with it.
|
||||
[Fact]
|
||||
public async Task AbortStopsTheRecordingAndDropsTheTransmitter()
|
||||
{
|
||||
card.HoldUntilCancelled = true;
|
||||
using VoiceKeyer keyer = Keyer();
|
||||
|
||||
await Finished(keyer, async () =>
|
||||
{
|
||||
await keyer.SendAsync("Cq.wav,Exchange.wav");
|
||||
await card.Playing.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
await keyer.AbortAsync();
|
||||
});
|
||||
|
||||
Assert.Single(card.Played);
|
||||
Assert.Equal([true, false], keying);
|
||||
}
|
||||
|
||||
/// A card that will not play says so while the message is going out, by
|
||||
/// which time the caller has been answered and there is nothing left to
|
||||
/// throw at.
|
||||
[Fact]
|
||||
public async Task APlayerThatFailsIsReported()
|
||||
{
|
||||
card.Failure = new InvalidOperationException("aplay is not installed");
|
||||
using VoiceKeyer keyer = Keyer();
|
||||
List<string> reported = [];
|
||||
keyer.Reported += (_, what) => reported.Add(what);
|
||||
|
||||
await Finished(keyer, () => keyer.SendAsync("Cq.wav"));
|
||||
|
||||
Assert.Equal(["aplay is not installed"], reported);
|
||||
Assert.Equal([true, false], keying);
|
||||
}
|
||||
|
||||
private sealed class FakeSoundCard : SoundCard
|
||||
{
|
||||
public List<string> Played { get; } = [];
|
||||
|
||||
public TaskCompletionSource Playing { get; } = new();
|
||||
|
||||
public bool HoldUntilCancelled { get; set; }
|
||||
|
||||
public Exception? Failure { get; set; }
|
||||
|
||||
public bool CanRecord => true;
|
||||
|
||||
public async Task PlayAsync(string file, CancellationToken cancellation = default)
|
||||
{
|
||||
Played.Add(file);
|
||||
Playing.TrySetResult();
|
||||
if (Failure is not null)
|
||||
{
|
||||
throw Failure;
|
||||
}
|
||||
if (HoldUntilCancelled)
|
||||
{
|
||||
await Task.Delay(Timeout.Infinite, cancellation);
|
||||
}
|
||||
}
|
||||
|
||||
public Task RecordAsync(string file, CancellationToken cancellation = default) =>
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,7 @@ public class AlternatingCqTests
|
||||
|
||||
private static AlternatingCq Driving(FakeKeyer keyer, List<int> called) =>
|
||||
new(
|
||||
keyer,
|
||||
[keyer],
|
||||
radio =>
|
||||
{
|
||||
called.Add(radio);
|
||||
@@ -154,13 +154,37 @@ public class AlternatingCqTests
|
||||
Assert.Empty(called);
|
||||
}
|
||||
|
||||
/// There is a keyer per mode, and a CQ that goes out through the voice
|
||||
/// keyer has to carry the alternation on the same way a CW one does.
|
||||
[Fact]
|
||||
public void AMessageFinishingAtAnyOfTheKeyersMovesTheCq()
|
||||
{
|
||||
FakeKeyer cw = new();
|
||||
FakeKeyer voice = new();
|
||||
List<int> called = [];
|
||||
using AlternatingCq cq = new(
|
||||
[cw, voice],
|
||||
radio =>
|
||||
{
|
||||
called.Add(radio);
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
TimeSpan.Zero,
|
||||
NoWait);
|
||||
|
||||
cq.Start(1);
|
||||
voice.FinishMessage();
|
||||
|
||||
Assert.Equal([1, 2], called);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AKeyerThatHasGoneAwayStopsTheWholeThing()
|
||||
{
|
||||
FakeKeyer keyer = new();
|
||||
List<string> reasons = [];
|
||||
using AlternatingCq cq = new(
|
||||
keyer,
|
||||
[keyer],
|
||||
_ => throw new InvalidOperationException("could not reach cwdaemon at 127.0.0.1:6789"),
|
||||
TimeSpan.Zero,
|
||||
NoWait);
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
using Nonemm.Session;
|
||||
|
||||
namespace Nonemm.Session.Tests;
|
||||
|
||||
/// What the voice keyer window lists: the recordings the phone function keys
|
||||
/// name, for one operator.
|
||||
public class VoiceLinesTests
|
||||
{
|
||||
private static MessageFile Phone(string text) => MessageFile.Parse(text);
|
||||
|
||||
[Fact]
|
||||
public void AKeyIsListedUnderTheFileItNames()
|
||||
{
|
||||
IReadOnlyList<VoiceLine> lines = VoiceLines.For(
|
||||
Phone(@"F1 CQ,{WAVDIR}\Cq.wav"),
|
||||
"om3kff");
|
||||
|
||||
VoiceLine cq = lines[0];
|
||||
Assert.Equal("F1 run, F1 S&P", cq.Where);
|
||||
Assert.Equal("CQ", cq.Says);
|
||||
Assert.Equal(@"OM3KFF\Cq.wav", cq.Name);
|
||||
}
|
||||
|
||||
/// Two keys naming one file are one line: there is one recording to make.
|
||||
[Fact]
|
||||
public void TheSameFileOnTwoKeysIsOneLine()
|
||||
{
|
||||
IReadOnlyList<VoiceLine> lines = VoiceLines.For(
|
||||
Phone($"F1 CQ,Cq.wav\n{Blank(11)}F1 S&P CQ,Cq.wav"),
|
||||
"");
|
||||
|
||||
VoiceLine cq = Assert.Single(lines, line => line.Name == "Cq.wav");
|
||||
Assert.Equal("F1 run, F1 S&P", cq.Where);
|
||||
}
|
||||
|
||||
/// With nobody at the microphone the recordings are the ones in the `Wav`
|
||||
/// folder itself, not a path starting at the root.
|
||||
[Fact]
|
||||
public void WithNoOperatorTheFolderGoesAway()
|
||||
{
|
||||
IReadOnlyList<VoiceLine> lines = VoiceLines.For(Phone(@"F1 CQ,{WAVDIR}\Cq.wav"), "");
|
||||
|
||||
Assert.Equal("Cq.wav", lines[0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AKeyThatIsWordsRatherThanARecordingIsNotListed()
|
||||
{
|
||||
IReadOnlyList<VoiceLine> lines = VoiceLines.For(Phone("F1 CQ,CQ CONTEST OM5M"), "OM5M");
|
||||
|
||||
Assert.DoesNotContain(lines, line => line.Where.StartsWith("F1"));
|
||||
}
|
||||
|
||||
/// WAE's four are always there: the QTC window plays them, and they are in
|
||||
/// the `Wav` folder rather than an operator's, which is where N1MM keeps
|
||||
/// them.
|
||||
[Fact]
|
||||
public void TheQtcRecordingsAreListedWhateverTheMessagesSay()
|
||||
{
|
||||
IReadOnlyList<VoiceLine> lines = VoiceLines.For(Phone("F1 CQ,"), "OM3KFF");
|
||||
|
||||
Assert.Equal(
|
||||
[VoiceLines.QtcReady, VoiceLines.QtcAgain, VoiceLines.QtcConfirm, VoiceLines.QtcThanks],
|
||||
lines.Select(line => line.Name));
|
||||
Assert.All(lines, line => Assert.Equal("QTC window", line.Where));
|
||||
}
|
||||
|
||||
private static string Blank(int lines) => string.Concat(Enumerable.Repeat("\n", lines));
|
||||
}
|
||||
Reference in New Issue
Block a user