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

@@ -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

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)
{
await send(next, cancellation).ConfigureAwait(false);
Changed?.Invoke(this, EventArgs.Empty);
continue;
}
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);
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;
}
}
}

View File

@@ -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()
{