Do not trust the engine's count as the pace

The second probe run read TxBufLen as 0 one millisecond and again fifty
milliseconds after twenty-one characters had been pushed, while those characters
were already going out: the first of them was decoded back off the air 420 ms
later. The number is right when it is a second old and wrong when it is fresh,
so it runs behind the engine.

A pump that fed on it would have handed over the whole message in half a second
and put all of it beyond reach, which is worse than the clock it replaced. So
the clock is the pace again, and the count is a check on it: the engine is never
given more than Lead + Slack characters however fast the clock says to feed, and
a message ends when the count and the clock estimate both say it has. Characters
fed since the last answer are added to it, so a stale answer cannot be spent
twice.

The engine is set to Character out: ABCD with no space after it went out at
once. Word out would have held it, so the holding case stays, but it now needs
the count to be neither going down nor being added to, since an engine kept
exactly at the cap has a count that does not move either.

The probe writes down every reading for two seconds after a push and does not
believe an empty one in that window, which is what the run needed to measure the
lag and did not do.

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 23:13:28 +00:00
parent 4118cd6d33
commit 5370ad3f6d
4 changed files with 142 additions and 192 deletions

View File

@@ -50,32 +50,30 @@ so it never runs dry and never transmits idle in the middle of a message. Those
two characters cannot be taken back; everything behind them can still be
rewritten.
How far the engine has got comes from the engine. MMTTY answers `TxBufLen` with
the number of characters it still has to transmit, so the pump asks, feeds while
the answer is under `Lead`, and asks again, 50 ms at a time. An engine that will
not answer is paced by the clock at the baud rate in the digital settings
instead, which drifts; that is what the baud setting is still for.
The pace comes from the clock at the baud rate in the digital settings. The
engine's own count of what it has left is a check on that rather than the pace
itself, for the reason in the table below: it is used as a cap, so the engine is
never given more than `Lead + Slack` characters however fast the clock says to
feed, and as the end of a message, together with the clock estimate. A count
that neither goes down nor is being added to means the engine is holding what it
has, and the pump feeds a character every `HoldingPatience` until it moves.
What the engine probe established on 2026-09-01, with MMTTY 1.70 under Wine on a
machine with a sound card:
What the engine probe established, with MMTTY 1.70 under Wine on a machine with
a sound card:
| What | Answer |
|---|---|
| Does the control answer `TxBufLen`? | Yes, and a name it does not know fails with `DISP_E_UNKNOWNNAME`, so the two are distinguishable. |
| What does it count? | Characters left to transmit. Seventeen were pushed; a second later, with five decoded back off the air, it read 12, and it counted down to 0 as the message went out. |
| How current is that number? | Not current. Read a millisecond and again fifty milliseconds after twenty-one characters had been pushed — and were already going out, the first of them decoded back a moment later — it still said 0. So a pump that fed on the count alone would hand over a whole message in half a second and put all of it beyond reach. How far behind it runs has not been measured; the probe now writes down every reading for the two seconds after a push, which is where the number will come from. |
| Does a backspace take back a character the engine has not transmitted? | No. Pushed in as a character it made the count go **up** by four, and the text went out unchanged. MMTTY's help describes its backspace key doing this in its own window; through `PostMmttyMessage(4, 8)` it is just another character. So text in the engine cannot be retracted, only aborted. |
| How long after a push does the count include it? | Not immediately. A count read a millisecond after the push still read 0, so one empty answer is not the end of a message. |
| Way to send | This engine is set to Character out: `ABCD` with no space after it went out at once, a character every 200 ms. MMTTY's help says Word out is the usual setting, which would hold that word, so the pump copes with both. |
Two things the probe has still to answer, and it now asks both:
- **Way to send.** MMTTY set to Word out holds a word until the space after it,
which the first run appeared to show but could not confirm, because the engine
was not keyed for that step. The pump handles it either way: a count that
stops going down means the engine is holding what it has, so the pump feeds
one character per look until it moves again.
- **Whether `{RX}` sends a held word.** `SetMmttyPTT(1)` stops once the buffer is
empty. If a held word does not count as buffer, a macro whose last word has no
space after it loses that word.
The report is worth reading for the receive side as well. With MMTTY's sound
loopback off, its help says the receive window is fed from the transmit window,
which would be a per-character report of what has gone out with no polling at
all. With loopback on, which is how this station runs it, the decoded text
arrives about 420 ms after the character goes out and matches what was sent.
```sh
./build.sh run --project tools/Nonemm.EngineProbe -- \
@@ -85,13 +83,6 @@ Two things the probe has still to answer, and it now asks both:
It transmits on the sound card for about twenty seconds and keys no serial port
unless `--ptt` names one. `EngineProbe` states what each answer means.
The receive side is worth watching in the report as well. With MMTTY's sound
loopback off, its help says the receive window is fed from the transmit window,
which would be a per-character report of what has gone out with no polling at
all. With loopback on it is the demodulator hearing the transmission, which
arrives about half a second late and can carry decode errors, so the count is
what the pump uses.
**Voice keying.** `MessageSender` was written to cover a voice keyer playing a
recording, and nothing implements it. Each operator's recordings folder is
stored with the rest of his settings and waits on the same thing. No DVK support either, so the second radio

View File

@@ -19,16 +19,23 @@ namespace Nonemm.Digital;
/// which is where the operator is typing: the pump stops when it reaches the
/// cursor, because the operator has not finished the word yet.
///
/// How far the engine has got comes from the engine when it can say. MMTTY
/// answers `TxBufLen` with the number of characters it still has to transmit,
/// so the pump asks, feeds while the answer is under `Lead`, and asks again. An
/// engine that will not answer is paced by the clock at the baud rate instead,
/// which drifts and is what the baud setting is for.
/// The pump is paced by the clock at the baud rate, and the engine's own count
/// of what it has left is a check on it rather than the pace. MMTTY answers
/// `TxBufLen` with the number of characters it still has to transmit, but the
/// answer is behind what the engine is really doing: the probe read 0 fifty
/// milliseconds after twenty-one characters had been pushed and were already
/// going out, and read a right-looking 12 a second after a push. A pump that
/// believed a stale 0 would hand over the whole message in half a second and
/// put all of it beyond reach, so the count is used two ways only:
///
/// - as a cap. The engine holding more than `Lead + Slack` characters stops the
/// pump until it comes down, whatever the clock thinks.
/// - as the end of a message, together with the clock estimate.
///
/// A count that stops going down means the engine is holding what it has:
/// MMTTY set to Word out keeps a word until the space after it arrives. The
/// pump feeds one character per look while that lasts, so the space gets there
/// and the word goes out.
/// pump feeds a character anyway while that lasts, so the space gets there and
/// the word goes out.
public sealed class TypeAhead : IDisposable
{
/// A RTTY character is a start bit, five data bits and a stop bit and a
@@ -52,6 +59,11 @@ public sealed class TypeAhead : IDisposable
/// stands while the operator is not typing into the pane.
public const int NoCursor = int.MaxValue;
/// How many characters the engine's count may be behind what it is really
/// doing. The count is what stops the pump when the engine falls behind the
/// clock, so it has to allow for the lag rather than fight it.
public const int Slack = 3;
/// How often the engine is asked how much it has left.
public static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(50);
@@ -60,9 +72,6 @@ public sealed class TypeAhead : IDisposable
/// character going out, so it is short against a character time.
private static readonly TimeSpan LongestTick = TimeSpan.FromMilliseconds(10);
/// An engine that has not answered by now is not going to.
private static readonly TimeSpan AnswerPatience = TimeSpan.FromSeconds(2);
private readonly Func<char, CancellationToken, Task> send;
private readonly EngineBuffer? counter;
private readonly Lock gate = new();
@@ -71,9 +80,16 @@ public sealed class TypeAhead : IDisposable
private CancellationTokenSource? stopping;
private Task pump = Task.CompletedTask;
private TaskCompletionSource<int>? asking;
private int cursor = NoCursor;
/// The engine's last answer: how many characters it still had to transmit,
/// or -1 before it has answered at all.
private int counted = -1;
/// How many answers have arrived, so the pump can tell a new one from the
/// one it has already added its own characters to.
private int answers;
public TypeAhead(Func<char, CancellationToken, Task> send, double baud = DefaultBaud)
{
this.send = send;
@@ -81,7 +97,7 @@ public sealed class TypeAhead : IDisposable
}
/// An engine that holds a buffer of its own and can say how much of it is
/// left, which is what paces the pump instead of the clock.
/// left, which is what the pump checks the clock against.
public TypeAhead(EngineBuffer engine, double baud = DefaultBaud)
: this((character, cancellation) => engine.TypeAsync(character, cancellation), baud)
{
@@ -89,16 +105,15 @@ public sealed class TypeAhead : IDisposable
engine.Buffered += WhenBuffered;
}
/// The speed the engine transmits at, which paces the pump when the engine
/// will not say how much it holds.
/// The speed the engine transmits at, which is what paces the pump.
public double Baud { get; set; }
/// How many characters may sit in the engine at once.
public int Lead { get; set; } = DefaultLead;
/// False once the engine has refused to say how much it holds, which puts
/// the pump back on the clock.
public bool Counts { get; private set; } = true;
/// True once the engine has said how much it holds. An engine that will not
/// say is paced by the clock alone.
public bool Counts { get; private set; }
public TimeSpan CharacterTime =>
TimeSpan.FromSeconds(BitsPerCharacter / (Baud > 0 ? Baud : DefaultBaud));
@@ -259,81 +274,20 @@ public sealed class TypeAhead : IDisposable
}
}
/// Feeds the engine on the clock, holds off when its own count says it is
/// further behind than that, and stops when both agree there is nothing
/// left.
private async Task RunAsync(CancellationToken cancellation)
{
int inEngine = 0;
int last = int.MaxValue;
int fedSince = 0;
int seen = -1;
DateTime nextOut = DateTime.MinValue;
DateTime lastAsked = DateTime.MinValue;
DateTime moved = DateTime.UtcNow;
try
{
if (counter is not null && Counts && await CountedAsync(cancellation).ConfigureAwait(false))
{
return;
}
await PacedAsync(cancellation).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
}
}
/// Asks the engine how much it has left, feeds it up to `Lead`, and asks
/// again. The pump is the only thing that writes to the engine, so an
/// answer is about characters it has already been given.
///
/// Returns false if the engine will not say, which puts the pump on the
/// clock instead.
private async Task<bool> CountedAsync(CancellationToken cancellation)
{
DateTime moved = DateTime.UtcNow;
int last = int.MaxValue;
bool wasEmpty = false;
while (!cancellation.IsCancellationRequested)
{
int left = await AskAsync(cancellation).ConfigureAwait(false);
if (left < 0)
{
Counts = false;
return false;
}
if (left < last)
{
moved = DateTime.UtcNow;
}
bool holding = DateTime.UtcNow - moved > HoldingPatience;
while ((left < Lead || holding) && Take() is { } next)
{
await send(next, cancellation).ConfigureAwait(false);
Changed?.Invoke(this, EventArgs.Empty);
left++;
holding = false;
moved = DateTime.UtcNow;
}
last = left;
// the engine takes a moment to count what it has just been given,
// so one empty answer is not the end of the message
if (left == 0 && !IsSending)
{
if (wasEmpty)
{
Drained?.Invoke(this, EventArgs.Empty);
return true;
}
wasEmpty = true;
}
else
{
wasEmpty = false;
}
await Task.Delay(PollInterval, cancellation).ConfigureAwait(false);
}
return true;
}
/// The fallback: count character times off the clock at the baud rate, so
/// the engine is fed at the speed it transmits. It drifts, which shows up
/// as a gap between characters near the end of a long message.
private async Task PacedAsync(CancellationToken cancellation)
{
int inEngine = 0;
DateTime nextOut = DateTime.MinValue;
while (!cancellation.IsCancellationRequested)
{
DateTime now = DateTime.UtcNow;
@@ -342,18 +296,45 @@ public sealed class TypeAhead : IDisposable
inEngine--;
nextOut += CharacterTime;
}
if (inEngine < Lead && Take() is { } next)
if (counter is not null && now - lastAsked >= PollInterval)
{
lastAsked = now;
await counter.AskBufferedAsync("", cancellation).ConfigureAwait(false);
}
int answered = Volatile.Read(ref answers);
if (answered != seen)
{
seen = answered;
fedSince = 0;
}
int left = Volatile.Read(ref counted);
if (left < last)
{
moved = now;
}
last = left;
// the answer says nothing about the characters fed since it
int held = left < 0 ? -1 : left + fedSince;
bool holding = left > 0 && now - moved > HoldingPatience;
bool room = holding
|| (inEngine < Lead && (held < 0 || held < Lead + Slack));
if (room && Take() is { } next)
{
if (inEngine == 0)
{
nextOut = now + CharacterTime;
}
inEngine++;
fedSince++;
// feeding explains a count that is not going down, so it
// counts as movement: an engine is only holding what it has
// if it neither transmits nor is given anything
moved = now;
await send(next, cancellation).ConfigureAwait(false);
Changed?.Invoke(this, EventArgs.Empty);
continue;
}
if (inEngine == 0 && !IsSending)
if (inEngine == 0 && left <= 0 && !IsSending)
{
Drained?.Invoke(this, EventArgs.Empty);
return;
@@ -361,6 +342,10 @@ public sealed class TypeAhead : IDisposable
await Task.Delay(Tick, cancellation).ConfigureAwait(false);
}
}
catch (OperationCanceledException)
{
}
}
private TimeSpan Tick
{
@@ -371,35 +356,11 @@ public sealed class TypeAhead : IDisposable
}
}
/// One question and its answer, or -1 when the engine would not say.
private async Task<int> AskAsync(CancellationToken cancellation)
{
if (counter is null)
{
return -1;
}
TaskCompletionSource<int> answer = new(TaskCreationOptions.RunContinuationsAsynchronously);
lock (gate)
{
asking = answer;
}
await counter.AskBufferedAsync("", cancellation).ConfigureAwait(false);
try
{
return await answer.Task.WaitAsync(AnswerPatience, cancellation).ConfigureAwait(false);
}
catch (TimeoutException)
{
return -1;
}
}
private void WhenBuffered(object? sender, int left)
{
lock (gate)
{
asking?.TrySetResult(left);
}
Counts = left >= 0;
Volatile.Write(ref counted, left);
Interlocked.Increment(ref answers);
}
/// The next character to send, or null when there is none to send now:

View File

@@ -212,39 +212,29 @@ public class TypeAheadTests
Assert.Equal(2, Sent.Length);
}
/// An engine that says how much it holds is asked rather than timed, so
/// nothing depends on the baud rate being right.
/// The engine's count of what it has left runs behind what it is really
/// doing: the probe read 0 fifty milliseconds after twenty-one characters
/// had been pushed and were already going out. A pump that believed that
/// would hand over the whole message at once, so the count only ever stops
/// it, and the engine is never given more than `Lead` plus `Slack`.
[Fact]
public async Task ACountingEngineIsAskedHowMuchItHolds()
public async Task AnEngineBehindTheClockIsNotFedPastTheCap()
{
FakeEngine engine = new();
using TypeAhead buffer = new(engine, baud: Slow);
// half the speed of the baud rate the pump is paced by
using Timer transmitting = new(_ => engine.Transmit(1), null, 0, 200);
buffer.Append("CQ TEST");
buffer.Append("CQ TEST DE OM5M");
await WaitForAsync(() => engine.Waiting.Length >= 2);
await Task.Delay(30);
Assert.Equal("CQ", engine.Waiting);
engine.Transmit(2);
await WaitForAsync(() => engine.Transmitted.Length + engine.Waiting.Length >= 4);
Assert.Equal("CQ T", engine.Transmitted + engine.Waiting);
int most = 0;
for (int look = 0; look < 50; look++)
{
most = Math.Max(most, engine.Waiting.Length);
await Task.Delay(20);
}
/// MMTTY set to Word out holds a word until the space after it, so the
/// count stops going down. Waiting for it to move would leave the message
/// sitting there, so the pump feeds on.
[Fact]
public async Task AnEngineHoldingWhatItHasIsFedAnyway()
{
FakeEngine engine = new();
using TypeAhead buffer = new(engine, baud: Slow);
buffer.Append("CQ TEST");
await WaitForAsync(() => engine.Waiting == "CQ TEST");
Assert.Equal("CQ TEST", engine.Waiting);
Assert.True(most <= TypeAhead.DefaultLead + TypeAhead.Slack, $"the engine was given {most}");
Assert.True(engine.Transmitted.Length > 0, "nothing was transmitted at all");
}
/// The clock is the fallback for an engine that will not answer.

View File

@@ -41,6 +41,11 @@ public sealed class EngineProbe
/// A count that has not moved for this long is not going to move.
private static readonly TimeSpan Still = TimeSpan.FromSeconds(5);
/// How long after a push the count is written down every time it is read,
/// and an empty answer is not believed. The count runs behind the engine,
/// and this is where the report says by how much.
private static readonly TimeSpan Settling = TimeSpan.FromSeconds(2);
/// How long the engine is given to key up and to drop again.
private static readonly TimeSpan Keying = TimeSpan.FromSeconds(3);
@@ -126,6 +131,7 @@ public sealed class EngineProbe
await KeyAsync(cancellation).ConfigureAwait(false);
await TypeAsync(Message, cancellation).ConfigureAwait(false);
log.Write($"pushed {Message.Length} characters");
log.Write("every reading for the next two seconds: the first one that is not 0 says how far behind the count is");
await DrainAsync(cancellation).ConfigureAwait(false);
log.Write($"received while transmitting: \"{TakeReceived()}\"");
await UnkeyAsync(cancellation).ConfigureAwait(false);
@@ -173,20 +179,22 @@ public sealed class EngineProbe
/// registered, not that the message has gone.
private async Task DrainAsync(CancellationToken cancellation)
{
DateTime giveUp = DateTime.UtcNow + Patience;
DateTime moved = DateTime.UtcNow;
DateTime started = DateTime.UtcNow;
DateTime giveUp = started + Patience;
DateTime moved = started;
int last = int.MinValue;
bool wasEmpty = false;
while (DateTime.UtcNow < giveUp)
{
int left = await AskAsync("", cancellation).ConfigureAwait(false);
if (left != last)
bool settling = DateTime.UtcNow - started < Settling;
if (left != last || settling)
{
log.Write($"TxBufLen: {Answer(left)}");
moved = left != last ? DateTime.UtcNow : moved;
last = left;
moved = DateTime.UtcNow;
}
if (left == 0 && wasEmpty)
if (left == 0 && wasEmpty && !settling)
{
return;
}