Keep the engine fed instead of feeding it a character at a time

The pump handed the engine one character and then waited a character time
before the next, on purpose, so it stayed behind the engine rather than ahead.
That leaves the engine with an empty buffer between every two characters of a
message, and an engine with an empty buffer transmits idle rather than waiting.
The idle is added to how long the message takes, which in a contest is time
paid for nothing.

The pump now keeps two characters in the engine: one being transmitted and one
behind it, so the engine never runs dry. The clock at the baud rate says when
the engine has room for the next one, and the engine reporting that it has
stopped transmitting sets the estimate back to an empty buffer.

The last two characters are now beyond reach rather than the last one. Everything
before them can still be rewritten, which is what the pane is for.

A message is finished when the buffer is empty and the engine is estimated to
have transmitted what it holds, so {END} and {RX} no longer run while the engine
still has the last characters of the message.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RtspmWmS7f8kUvcyaHpRWZ
This commit is contained in:
2026-09-01 21:56:41 +00:00
parent 9d0af06686
commit da7f7fb3f5
4 changed files with 184 additions and 50 deletions

View File

@@ -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);
}

View File

@@ -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<char, CancellationToken, Task> 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<char, CancellationToken, Task> 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;
}
}
}