diff --git a/src/Nonemm.Digital/DigitalEngineSender.cs b/src/Nonemm.Digital/DigitalEngineSender.cs index ea00195..7c0a597 100644 --- a/src/Nonemm.Digital/DigitalEngineSender.cs +++ b/src/Nonemm.Digital/DigitalEngineSender.cs @@ -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); } diff --git a/src/Nonemm.Digital/TypeAhead.cs b/src/Nonemm.Digital/TypeAhead.cs new file mode 100644 index 0000000..a9a2dad --- /dev/null +++ b/src/Nonemm.Digital/TypeAhead.cs @@ -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 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 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; + } + } +} diff --git a/tests/Nonemm.Digital.Tests/TypeAheadTests.cs b/tests/Nonemm.Digital.Tests/TypeAheadTests.cs new file mode 100644 index 0000000..fbf6836 --- /dev/null +++ b/tests/Nonemm.Digital.Tests/TypeAheadTests.cs @@ -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 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); + } +}