diff --git a/docs/unfinished.md b/docs/unfinished.md index 11bcb8d..b191b6c 100644 --- a/docs/unfinished.md +++ b/docs/unfinished.md @@ -44,14 +44,22 @@ type-ahead transmit pane were checked. Nothing has been decoded from a real signal and nothing has gone on the air: this machine has no sound card, and MMTTY does not start on it. -**What paces the type-ahead pump.** It counts character times off the clock at -the baud rate in the digital settings, so it runs a little behind the engine -rather than ahead of it. The engine can be asked instead: `XMMT.ocx` has a -`TxBufLen` property, and a probe against the control shows it answers, though -what it answers while a message is going out could not be measured here because -the engine will not start. Reporting it from the bridge would make the red text -exact and let the pump keep the engine's buffer full rather than leaving a gap -between characters. +**What paces the type-ahead pump.** The pump keeps two characters in the +engine and counts character times off the clock at the baud rate in the digital +settings to work out when the engine has room for the next one. Two characters +is what stops the engine running dry between characters, which would make it +transmit idle and add that idle to the time the message takes; the price is that +the last two characters cannot be taken back. The estimate is corrected when the +engine reports that it has stopped transmitting, which means its buffer is +empty. + +The engine can be asked instead of estimated: `XMMT.ocx` has a `TxBufLen` +property, and a probe against the control shows it answers, though what it +answers while a message is going out could not be measured here because the +engine will not start. Reporting it from the bridge would make the red text +exact and would take out the one error the estimate can still make: a baud rate +that does not match what the engine transmits at leaves a small gap between +characters near the end of a long message. **Voice keying.** `MessageSender` was written to cover a voice keyer playing a recording, and nothing implements it. Each operator's recordings folder is diff --git a/src/Nonemm.Digital/DigitalEngineSender.cs b/src/Nonemm.Digital/DigitalEngineSender.cs index 7c0a597..485a564 100644 --- a/src/Nonemm.Digital/DigitalEngineSender.cs +++ b/src/Nonemm.Digital/DigitalEngineSender.cs @@ -7,9 +7,9 @@ namespace Nonemm.Digital; /// 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. +/// buffer, which keeps the engine a couple of characters ahead of the operator, +/// so the rest can still be rewritten. `Finished` is raised when the buffer has +/// run dry and the engine has transmitted what it was given. public sealed class DigitalEngineSender : MessageSender { private readonly DigitalEngine engine; @@ -29,7 +29,7 @@ public sealed class DigitalEngineSender : MessageSender public bool IsReady => engine.IsConnected; - /// The buffer says when the last character has gone to the engine, and the + /// The buffer says when the last character has been transmitted, and the /// engine says when it has stopped transmitting. public bool ReportsCompletion => true; @@ -63,13 +63,20 @@ public sealed class DigitalEngineSender : MessageSender 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. + /// The engine dropping the transmitter empties its buffer, which the + /// type-ahead buffer is told so it stops waiting for characters that have + /// already gone. It ends the message 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 && !TypeAhead.IsSending) + if (transmitting) + { + return; + } + TypeAhead.EngineIdle(); + if (!TypeAhead.IsSending) { Finished?.Invoke(this, EventArgs.Empty); } diff --git a/src/Nonemm.Digital/TypeAhead.cs b/src/Nonemm.Digital/TypeAhead.cs index a9a2dad..fa4ff60 100644 --- a/src/Nonemm.Digital/TypeAhead.cs +++ b/src/Nonemm.Digital/TypeAhead.cs @@ -9,21 +9,27 @@ namespace Nonemm.Digital; /// 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 +/// message here instead and feeds it to the engine a few characters 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. +/// cursor, because the operator has not finished the word yet. /// -/// 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. +/// The pump keeps `Lead` characters in the engine rather than one. An engine +/// that runs out of characters partway through a message does not wait: it +/// transmits idle until the next one arrives, so every gap the pump leaves is +/// added to the time the message takes. Keeping a second character queued +/// behind the one being transmitted means the engine never runs dry, and the +/// cost is that the last `Lead` characters cannot be taken back rather than the +/// last one. +/// +/// `Lead` characters at the front are timed off the clock at the baud rate, +/// which is an estimate of how far the engine has got. It is corrected by +/// `EngineIdle`: an engine that has stopped transmitting has an empty buffer, +/// whatever the estimate says. public sealed class TypeAhead : IDisposable { /// A RTTY character is a start bit, five data bits and a stop bit and a @@ -33,6 +39,12 @@ public sealed class TypeAhead : IDisposable /// MMTTY's own default, and the speed nearly every RTTY contest runs at. public const double DefaultBaud = 45.45; + /// How many characters may sit in the engine. Two is the smallest number + /// that keeps the engine transmitting without a gap: one on the air and one + /// behind it. At 45.45 baud that puts the last third of a second of the + /// message beyond reach. + public const int DefaultLead = 2; + /// 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; @@ -41,6 +53,11 @@ public sealed class TypeAhead : IDisposable /// stands while the operator is not typing into the pane. public const int NoCursor = int.MaxValue; + /// The longest the pump sleeps between looks at the buffer. It is what + /// stands between the operator moving the cursor on and the next character + /// going out, so it is short against a character time. + private static readonly TimeSpan LongestTick = TimeSpan.FromMilliseconds(10); + private readonly Func send; private readonly Lock gate = new(); private readonly StringBuilder pending = new(); @@ -49,6 +66,8 @@ public sealed class TypeAhead : IDisposable private CancellationTokenSource? stopping; private Task pump = Task.CompletedTask; private int cursor = NoCursor; + private int inEngine; + private DateTime nextOut = DateTime.MinValue; public TypeAhead(Func send, double baud = DefaultBaud) { @@ -56,9 +75,13 @@ public sealed class TypeAhead : IDisposable Baud = baud; } - /// The speed the engine transmits at, which is what the pump is paced by. + /// The speed the engine transmits at, which is what the estimate of its + /// buffer is paced by. public double Baud { get; set; } + /// How many characters may sit in the engine at once. + public int Lead { get; set; } = DefaultLead; + public TimeSpan CharacterTime => TimeSpan.FromSeconds(BitsPerCharacter / (Baud > 0 ? Baud : DefaultBaud)); @@ -106,13 +129,16 @@ public sealed class TypeAhead : IDisposable } } + /// True while there is text to send or the engine is estimated to be still + /// transmitting what it was given. public bool IsSending { get { lock (gate) { - return pending.Length > 0; + Advance(DateTime.UtcNow); + return pending.Length > 0 || inEngine > 0; } } } @@ -121,9 +147,10 @@ public sealed class TypeAhead : IDisposable /// 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. + /// Everything that was waiting has gone out, and the engine is estimated to + /// have transmitted it. 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 @@ -161,6 +188,18 @@ public sealed class TypeAhead : IDisposable Start(); } + /// The engine has stopped transmitting, so whatever it was given has gone + /// out. This corrects the estimate: an engine that is faster than the + /// estimate would otherwise be left waiting for a character it could have + /// had. + public void EngineIdle() + { + lock (gate) + { + inEngine = 0; + } + } + /// 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. @@ -169,6 +208,7 @@ public sealed class TypeAhead : IDisposable lock (gate) { pending.Clear(); + inEngine = 0; cursor = NoCursor; } Changed?.Invoke(this, EventArgs.Empty); @@ -182,6 +222,7 @@ public sealed class TypeAhead : IDisposable { pending.Clear(); sent.Clear(); + inEngine = 0; cursor = NoCursor; } Changed?.Invoke(this, EventArgs.Empty); @@ -208,28 +249,27 @@ public sealed class TypeAhead : IDisposable } } - /// 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. + /// Hands the engine a character whenever it has room for one, until there + /// is nothing left to send. A pump held at the cursor keeps running: the + /// operator is typing, and the next character is theirs to release. private async Task RunAsync(CancellationToken cancellation) { try { while (!cancellation.IsCancellationRequested) { - if (Take() is not { } next) + if (Take() is { } next) { - if (!IsSending) - { - Drained?.Invoke(this, EventArgs.Empty); - return; - } - await Task.Delay(CharacterTime, cancellation).ConfigureAwait(false); + await send(next, cancellation).ConfigureAwait(false); + Changed?.Invoke(this, EventArgs.Empty); continue; } - await send(next, cancellation).ConfigureAwait(false); - Changed?.Invoke(this, EventArgs.Empty); - await Task.Delay(CharacterTime, cancellation).ConfigureAwait(false); + if (!IsSending) + { + Drained?.Invoke(this, EventArgs.Empty); + return; + } + await Task.Delay(Tick, cancellation).ConfigureAwait(false); } } catch (OperationCanceledException) @@ -237,13 +277,25 @@ public sealed class TypeAhead : IDisposable } } - /// 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 TimeSpan Tick + { + get + { + TimeSpan quarter = CharacterTime / 4; + return quarter < LongestTick ? quarter : LongestTick; + } + } + + /// The next character to send, or null when there is none to send now: the + /// engine is full, nothing is waiting, or what is waiting is behind the + /// cursor. private char? Take() { lock (gate) { - if (pending.Length == 0 || cursor == 0) + DateTime now = DateTime.UtcNow; + Advance(now); + if (pending.Length == 0 || cursor == 0 || inEngine >= Lead) { return null; } @@ -253,6 +305,11 @@ public sealed class TypeAhead : IDisposable { cursor--; } + if (inEngine == 0) + { + nextOut = now + CharacterTime; + } + inEngine++; sent.Append(next); if (sent.Length > KeptSent) { @@ -261,4 +318,15 @@ public sealed class TypeAhead : IDisposable return next; } } + + /// Takes off the estimate the characters the engine has had time to + /// transmit since the last look. Called with the lock held. + private void Advance(DateTime now) + { + while (inEngine > 0 && now >= nextOut) + { + inEngine--; + nextOut += CharacterTime; + } + } } diff --git a/tests/Nonemm.Digital.Tests/TypeAheadTests.cs b/tests/Nonemm.Digital.Tests/TypeAheadTests.cs index fbf6836..c3b2efc 100644 --- a/tests/Nonemm.Digital.Tests/TypeAheadTests.cs +++ b/tests/Nonemm.Digital.Tests/TypeAheadTests.cs @@ -3,8 +3,9 @@ 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. +/// The text waiting to go out, fed to the engine a few characters 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); @@ -13,6 +14,10 @@ public class TypeAheadTests /// test can still catch the pump partway through. private const double Fast = 7500; + /// A character time of 100 ms, so a test can tell the characters sent ahead + /// from the ones that wait for the engine. + private const double Slow = 75; + private readonly StringBuilder went = new(); private string Sent @@ -48,7 +53,7 @@ public class TypeAheadTests } [Fact] - public async Task AMessageGoesOutOneCharacterAtATime() + public async Task AMessageGoesOut() { using TypeAhead buffer = Buffer(); @@ -178,6 +183,52 @@ public class TypeAheadTests Assert.Equal("", buffer.Sent); } + /// The engine is given the second character before it has transmitted the + /// first, so it has one in hand when the first is done. An engine left with + /// an empty buffer transmits idle instead, and that idle is added to how + /// long the message takes. + [Fact] + public async Task TheEngineIsKeptOneCharacterAhead() + { + using TypeAhead buffer = Buffer(baud: Slow); + + buffer.Append("CQ TEST"); + + await WaitForAsync(() => Sent.Length >= 2); + Assert.Equal("CQ", Sent); + } + + /// Only the lead goes out ahead. The rest waits, which is what leaves it + /// where the operator can still change it. + [Fact] + public async Task NoMoreThanTheLeadGoesToTheEngineAtOnce() + { + using TypeAhead buffer = Buffer(baud: Slow); + + buffer.Append("CQ TEST"); + + await WaitForAsync(() => Sent.Length >= 2); + await Task.Delay(20); + Assert.Equal(2, Sent.Length); + } + + /// The estimate of what the engine still holds is only an estimate. An + /// engine that says it has stopped transmitting has an empty buffer, and + /// the next character goes to it at once rather than a character time + /// later. + [Fact] + public async Task AnIdleEngineIsFedWithoutWaiting() + { + using TypeAhead buffer = Buffer(baud: Slow); + buffer.Append("CQ TEST"); + await WaitForAsync(() => Sent.Length >= 2); + + buffer.EngineIdle(); + + await WaitForAsync(() => Sent.Length >= 4); + Assert.Equal("CQ T", Sent); + } + [Fact] public void ACharacterTakesAsLongAsTheBaudRateSays() {