Hold a digital message back so it can still be changed
A digital engine takes a whole message and transmits it at 45.45 baud, which
takes several seconds. Once it has the message nothing can be changed, so an
operator who sees the wrong call going out has to stop the transmission and
start again.
TypeAhead keeps the message instead and feeds the engine one character at a
time. What has not gone out yet can be rewritten, added to or deleted. Cursor
is how much of it may go: the pump stops there and the engine idles on the air,
which is the diddle a RTTY engine sends between characters anyway.
The pump counts character times off the clock at the baud rate rather than
asking the engine what it has left, so it runs a little behind: a gap between
two characters is idle on the air and costs nothing, while feeding faster than
the engine transmits would put text out of reach again.
Every digital message goes through it — the function keys, ESM, the QTC window
and the digital window's own buttons — because they all send through
DigitalEngineSender. Finished is now raised when the buffer runs dry, which is
when the message has really gone, so what stands after {END} runs then and {RX}
drops the transmitter at the right moment. The transmitter dropping only counts
as the end when nothing is left to send: an engine that keys itself off what it
is given drops between two characters as well.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RoGtneMQaz4M9w7Kk49AVD
This commit is contained in:
@@ -5,40 +5,71 @@ namespace Nonemm.Digital;
|
||||
/// The digital engine as something that sends a message, so a macro goes out
|
||||
/// through the same expander, the same `{END}` handling and the same ESM as a
|
||||
/// CW message does.
|
||||
///
|
||||
/// A message is not handed to the engine whole. It goes into the type-ahead
|
||||
/// buffer, which feeds the engine a character at a time, so the operator can
|
||||
/// still rewrite the part that has not gone out. `Finished` is raised when the
|
||||
/// buffer runs dry, which is when the message has really gone.
|
||||
public sealed class DigitalEngineSender : MessageSender
|
||||
{
|
||||
private readonly DigitalEngine engine;
|
||||
|
||||
public DigitalEngineSender(DigitalEngine engine)
|
||||
public DigitalEngineSender(DigitalEngine engine, double baud = TypeAhead.DefaultBaud)
|
||||
{
|
||||
this.engine = engine;
|
||||
TypeAhead = new TypeAhead(
|
||||
(character, cancellation) => engine.SendAsync(character.ToString(), cancellation),
|
||||
baud);
|
||||
TypeAhead.Drained += WhenDrained;
|
||||
engine.TransmitChanged += WhenTransmitChanged;
|
||||
}
|
||||
|
||||
/// What is waiting to go out, which the digital window shows and edits.
|
||||
public TypeAhead TypeAhead { get; }
|
||||
|
||||
public bool IsReady => engine.IsConnected;
|
||||
|
||||
/// The engine says when it has stopped transmitting, which is the same
|
||||
/// thing a keyer reports.
|
||||
/// The buffer says when the last character has gone to the engine, and the
|
||||
/// engine says when it has stopped transmitting.
|
||||
public bool ReportsCompletion => true;
|
||||
|
||||
public event EventHandler? Finished;
|
||||
|
||||
public Task SendAsync(string text, CancellationToken cancellation = default) =>
|
||||
engine.SendAsync(text, cancellation);
|
||||
public Task SendAsync(string text, CancellationToken cancellation = default)
|
||||
{
|
||||
TypeAhead.Append(text);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task AbortAsync(CancellationToken cancellation = default) =>
|
||||
engine.AbortAsync(cancellation);
|
||||
/// Escape and the RX button: what has not gone to the engine is dropped,
|
||||
/// and the engine drops what it still holds.
|
||||
public Task AbortAsync(CancellationToken cancellation = default)
|
||||
{
|
||||
TypeAhead.Drop();
|
||||
return engine.AbortAsync(cancellation);
|
||||
}
|
||||
|
||||
/// RTTY runs at the speed the engine is set to, so there is nothing to set
|
||||
/// per message.
|
||||
public Task SetSpeedAsync(int wordsPerMinute, CancellationToken cancellation = default) =>
|
||||
Task.CompletedTask;
|
||||
|
||||
public void Dispose() => engine.TransmitChanged -= WhenTransmitChanged;
|
||||
public void Dispose()
|
||||
{
|
||||
TypeAhead.Drained -= WhenDrained;
|
||||
TypeAhead.Dispose();
|
||||
engine.TransmitChanged -= WhenTransmitChanged;
|
||||
}
|
||||
|
||||
private void WhenDrained(object? sender, EventArgs e) => Finished?.Invoke(this, EventArgs.Empty);
|
||||
|
||||
/// The engine dropping the transmitter ends the message, but only when
|
||||
/// there is nothing left to send: an engine that keys itself off what it
|
||||
/// is given drops between two characters of a message the pump is still
|
||||
/// feeding, and that is not the end of anything.
|
||||
private void WhenTransmitChanged(object? sender, bool transmitting)
|
||||
{
|
||||
if (!transmitting)
|
||||
if (!transmitting && !TypeAhead.IsSending)
|
||||
{
|
||||
Finished?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
264
src/Nonemm.Digital/TypeAhead.cs
Normal file
264
src/Nonemm.Digital/TypeAhead.cs
Normal file
@@ -0,0 +1,264 @@
|
||||
using System.Text;
|
||||
|
||||
namespace Nonemm.Digital;
|
||||
|
||||
/// The text waiting to go out, held here rather than handed to the engine in
|
||||
/// one piece.
|
||||
///
|
||||
/// A digital engine takes a whole message and transmits it at the baud rate,
|
||||
/// which is slow: a callsign and a report take several seconds. Once the engine
|
||||
/// has the message nothing can be changed, so the operator who sees a wrong
|
||||
/// call go out has to stop the transmission and start again. This keeps the
|
||||
/// message here instead and feeds the engine one character at a time, so
|
||||
/// everything that has not gone out yet can still be rewritten, added to or
|
||||
/// deleted.
|
||||
///
|
||||
/// `Sent` is what has gone to the engine and cannot be taken back. `Pending` is
|
||||
/// what is still to go. `Cursor` is how much of the pending text may go out,
|
||||
/// which is where the operator is typing: the pump stops when it reaches the
|
||||
/// cursor and the engine idles on the air until the operator moves on. RTTY
|
||||
/// fills that idle with the diddle the engine is set to send, which is what a
|
||||
/// live typist sounds like anyway.
|
||||
///
|
||||
/// The pump paces itself on `CharacterTime` rather than asking the engine what
|
||||
/// it has left, so it runs a little behind rather than ahead: a gap between two
|
||||
/// characters is idle on the air and costs nothing, while feeding faster than
|
||||
/// the engine transmits would put text beyond reach again.
|
||||
public sealed class TypeAhead : IDisposable
|
||||
{
|
||||
/// A RTTY character is a start bit, five data bits and a stop bit and a
|
||||
/// half.
|
||||
public const double BitsPerCharacter = 7.5;
|
||||
|
||||
/// MMTTY's own default, and the speed nearly every RTTY contest runs at.
|
||||
public const double DefaultBaud = 45.45;
|
||||
|
||||
/// How much of the text that has gone out is kept. It is there to be read
|
||||
/// back, not to be a log.
|
||||
public const int KeptSent = 2000;
|
||||
|
||||
/// `Cursor` set to this lets everything pending go out, which is where it
|
||||
/// stands while the operator is not typing into the pane.
|
||||
public const int NoCursor = int.MaxValue;
|
||||
|
||||
private readonly Func<char, CancellationToken, Task> send;
|
||||
private readonly Lock gate = new();
|
||||
private readonly StringBuilder pending = new();
|
||||
private readonly StringBuilder sent = new();
|
||||
|
||||
private CancellationTokenSource? stopping;
|
||||
private Task pump = Task.CompletedTask;
|
||||
private int cursor = NoCursor;
|
||||
|
||||
public TypeAhead(Func<char, CancellationToken, Task> send, double baud = DefaultBaud)
|
||||
{
|
||||
this.send = send;
|
||||
Baud = baud;
|
||||
}
|
||||
|
||||
/// The speed the engine transmits at, which is what the pump is paced by.
|
||||
public double Baud { get; set; }
|
||||
|
||||
public TimeSpan CharacterTime =>
|
||||
TimeSpan.FromSeconds(BitsPerCharacter / (Baud > 0 ? Baud : DefaultBaud));
|
||||
|
||||
/// What has gone to the engine.
|
||||
public string Sent
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
return sent.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What is still to go.
|
||||
public string Pending
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
return pending.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How many of the pending characters may go out. The window sets it to
|
||||
/// where the operator's cursor is; `NoCursor` while nobody is typing.
|
||||
public int Cursor
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
return cursor;
|
||||
}
|
||||
}
|
||||
set
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
cursor = Math.Max(0, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsSending
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
return pending.Length > 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The text moved: a character went out, or a message was added. Raised on
|
||||
/// the pump's thread, so a handler that touches the screen has to post.
|
||||
public event EventHandler? Changed;
|
||||
|
||||
/// Everything that was waiting has gone out. This is what tells the entry
|
||||
/// window that a message is finished, so what stands after `{END}` runs and
|
||||
/// `{RX}` drops the transmitter at the right moment.
|
||||
public event EventHandler? Drained;
|
||||
|
||||
/// A message to send. It goes on the end of what is already waiting, so two
|
||||
/// function keys pressed together send one after the other rather than one
|
||||
/// over the other.
|
||||
public void Append(string text)
|
||||
{
|
||||
if (text.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
lock (gate)
|
||||
{
|
||||
pending.Append(text);
|
||||
if (cursor != NoCursor)
|
||||
{
|
||||
// text added behind the operator's cursor is still text to
|
||||
// send, so the cursor moves out with it
|
||||
cursor += text.Length;
|
||||
}
|
||||
}
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
Start();
|
||||
}
|
||||
|
||||
/// The operator rewrote what has not gone out yet.
|
||||
public void Rewrite(string text, int wanted)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
pending.Clear();
|
||||
pending.Append(text);
|
||||
cursor = Math.Clamp(wanted, 0, text.Length);
|
||||
}
|
||||
Start();
|
||||
}
|
||||
|
||||
/// Drops what has not gone out. Escape and the RX button do this: what is
|
||||
/// already in the engine cannot be stopped from here, and the engine's own
|
||||
/// abort takes care of that.
|
||||
public void Drop()
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
pending.Clear();
|
||||
cursor = NoCursor;
|
||||
}
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
/// Empties the pane, which is what the CLR button does between
|
||||
/// transmissions.
|
||||
public void Clear()
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
pending.Clear();
|
||||
sent.Clear();
|
||||
cursor = NoCursor;
|
||||
}
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
stopping?.Cancel();
|
||||
stopping?.Dispose();
|
||||
stopping = null;
|
||||
}
|
||||
|
||||
private void Start()
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
if (!pump.IsCompleted)
|
||||
{
|
||||
return;
|
||||
}
|
||||
stopping?.Dispose();
|
||||
stopping = new CancellationTokenSource();
|
||||
pump = Task.Run(() => RunAsync(stopping.Token));
|
||||
}
|
||||
}
|
||||
|
||||
/// One character per character time, until there is nothing left to send.
|
||||
/// A pump held at the cursor keeps running: the operator is typing, and the
|
||||
/// engine idles until the next character is theirs to send.
|
||||
private async Task RunAsync(CancellationToken cancellation)
|
||||
{
|
||||
try
|
||||
{
|
||||
while (!cancellation.IsCancellationRequested)
|
||||
{
|
||||
if (Take() is not { } next)
|
||||
{
|
||||
if (!IsSending)
|
||||
{
|
||||
Drained?.Invoke(this, EventArgs.Empty);
|
||||
return;
|
||||
}
|
||||
await Task.Delay(CharacterTime, cancellation).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
await send(next, cancellation).ConfigureAwait(false);
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
await Task.Delay(CharacterTime, cancellation).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/// The next character to send, or null when there is none to send now:
|
||||
/// either nothing is waiting, or what is waiting is behind the cursor.
|
||||
private char? Take()
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
if (pending.Length == 0 || cursor == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
char next = pending[0];
|
||||
pending.Remove(0, 1);
|
||||
if (cursor != NoCursor)
|
||||
{
|
||||
cursor--;
|
||||
}
|
||||
sent.Append(next);
|
||||
if (sent.Length > KeptSent)
|
||||
{
|
||||
sent.Remove(0, sent.Length - KeptSent);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
}
|
||||
}
|
||||
188
tests/Nonemm.Digital.Tests/TypeAheadTests.cs
Normal file
188
tests/Nonemm.Digital.Tests/TypeAheadTests.cs
Normal file
@@ -0,0 +1,188 @@
|
||||
using System.Text;
|
||||
using Nonemm.Digital;
|
||||
|
||||
namespace Nonemm.Digital.Tests;
|
||||
|
||||
/// The text waiting to go out, fed to the engine a character at a time. The
|
||||
/// pump is run at a baud rate no radio uses so the tests do not wait for RTTY.
|
||||
public class TypeAheadTests
|
||||
{
|
||||
private static readonly TimeSpan Patience = TimeSpan.FromSeconds(5);
|
||||
|
||||
/// Fast enough that a message goes out in milliseconds, slow enough that a
|
||||
/// test can still catch the pump partway through.
|
||||
private const double Fast = 7500;
|
||||
|
||||
private readonly StringBuilder went = new();
|
||||
|
||||
private string Sent
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (went)
|
||||
{
|
||||
return went.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private TypeAhead Buffer(double baud = Fast) =>
|
||||
new(
|
||||
(character, _) =>
|
||||
{
|
||||
lock (went)
|
||||
{
|
||||
went.Append(character);
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
baud);
|
||||
|
||||
private static async Task WaitForAsync(Func<bool> ready)
|
||||
{
|
||||
DateTime giveUp = DateTime.UtcNow + Patience;
|
||||
while (!ready() && DateTime.UtcNow < giveUp)
|
||||
{
|
||||
await Task.Delay(2);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AMessageGoesOutOneCharacterAtATime()
|
||||
{
|
||||
using TypeAhead buffer = Buffer();
|
||||
|
||||
buffer.Append("CQ TEST");
|
||||
|
||||
await WaitForAsync(() => Sent == "CQ TEST");
|
||||
Assert.Equal("CQ TEST", Sent);
|
||||
Assert.Equal("CQ TEST", buffer.Sent);
|
||||
Assert.Equal("", buffer.Pending);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WhatHasGoneOutIsNotPendingAnyMore()
|
||||
{
|
||||
using TypeAhead buffer = Buffer(baud: 40);
|
||||
|
||||
buffer.Append("CQ TEST DE OM5M");
|
||||
|
||||
await WaitForAsync(() => Sent.Length >= 2);
|
||||
Assert.StartsWith(buffer.Sent, "CQ TEST DE OM5M", StringComparison.Ordinal);
|
||||
Assert.Equal("CQ TEST DE OM5M", buffer.Sent + buffer.Pending);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheOperatorRewritesWhatHasNotGoneOut()
|
||||
{
|
||||
using TypeAhead buffer = Buffer(baud: 60);
|
||||
buffer.Append("OM5X 599 001");
|
||||
|
||||
await WaitForAsync(() => buffer.Sent.Length >= 5);
|
||||
buffer.Rewrite("599 002", TypeAhead.NoCursor);
|
||||
|
||||
await WaitForAsync(() => buffer.Pending.Length == 0);
|
||||
Assert.EndsWith("599 002", Sent, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("001", Sent, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NothingGoesOutFromBehindTheCursor()
|
||||
{
|
||||
using TypeAhead buffer = Buffer();
|
||||
|
||||
buffer.Rewrite("CQ TEST", 2);
|
||||
|
||||
await WaitForAsync(() => Sent.Length == 2);
|
||||
await Task.Delay(50);
|
||||
Assert.Equal("CQ", Sent);
|
||||
Assert.Equal(" TEST", buffer.Pending);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheRestGoesOutWhenTheCursorMovesOn()
|
||||
{
|
||||
using TypeAhead buffer = Buffer();
|
||||
buffer.Rewrite("CQ TEST", 2);
|
||||
await WaitForAsync(() => Sent.Length == 2);
|
||||
|
||||
buffer.Cursor = TypeAhead.NoCursor;
|
||||
|
||||
await WaitForAsync(() => Sent == "CQ TEST");
|
||||
Assert.Equal("CQ TEST", Sent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TextAddedBehindTheCursorStillGoesOut()
|
||||
{
|
||||
using TypeAhead buffer = Buffer();
|
||||
buffer.Rewrite("CQ", 2);
|
||||
await WaitForAsync(() => Sent == "CQ");
|
||||
|
||||
buffer.Append(" TEST");
|
||||
|
||||
await WaitForAsync(() => Sent == "CQ TEST");
|
||||
Assert.Equal("CQ TEST", Sent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheBufferSaysWhenTheMessageHasGoneOut()
|
||||
{
|
||||
using TypeAhead buffer = Buffer();
|
||||
int drained = 0;
|
||||
buffer.Drained += (_, _) => Interlocked.Increment(ref drained);
|
||||
|
||||
buffer.Append("TU");
|
||||
|
||||
await WaitForAsync(() => Volatile.Read(ref drained) == 1);
|
||||
Assert.Equal(1, Volatile.Read(ref drained));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AMessageAddedWhileOneIsGoingOutFollowsIt()
|
||||
{
|
||||
using TypeAhead buffer = Buffer(baud: 400);
|
||||
buffer.Append("CQ ");
|
||||
|
||||
buffer.Append("DE OM5M");
|
||||
|
||||
await WaitForAsync(() => buffer.Pending.Length == 0);
|
||||
Assert.Equal("CQ DE OM5M", Sent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DroppingLeavesWhatHasAlreadyGone()
|
||||
{
|
||||
using TypeAhead buffer = Buffer(baud: 60);
|
||||
buffer.Append("CQ TEST DE OM5M");
|
||||
await WaitForAsync(() => buffer.Sent.Length >= 3);
|
||||
|
||||
buffer.Drop();
|
||||
|
||||
string gone = Sent;
|
||||
await Task.Delay(60);
|
||||
Assert.Equal(gone, Sent);
|
||||
Assert.Equal("", buffer.Pending);
|
||||
Assert.Equal(gone, buffer.Sent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClearingEmptiesBothHalves()
|
||||
{
|
||||
using TypeAhead buffer = Buffer(baud: 1);
|
||||
buffer.Append("CQ TEST");
|
||||
|
||||
buffer.Clear();
|
||||
|
||||
Assert.Equal("", buffer.Pending);
|
||||
Assert.Equal("", buffer.Sent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ACharacterTakesAsLongAsTheBaudRateSays()
|
||||
{
|
||||
using TypeAhead buffer = Buffer(baud: TypeAhead.DefaultBaud);
|
||||
|
||||
Assert.Equal(165, buffer.CharacterTime.TotalMilliseconds, 0.5);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user