Let MMTTY hold the text waiting to go out, behind a setting

MMTTY has a type-ahead buffer of its own: characters go into it, a backspace
takes back one it has not transmitted, and TxBufLen says how many are left. Used
that way it paces itself, so there is no gap between characters to tune and no
baud rate to keep in step with the engine.

EngineTypeAhead does that, and Config > Digital picks between it and the pump
that is there now. Off is still the default: three things it rests on have never
been seen with a real engine.

tools/Nonemm.EngineProbe asks the engine those three questions and writes the
answers to a file. It starts MMTTY through the bridge, pushes a message, polls
TxBufLen while it goes out, backspaces over text that has and has not been
transmitted, and logs what came back on the receive side and when.

The bridge learns one verb for it: `buffer` reads TxBufLen and answers with the
count, or -1 when the control will not say. A property the control does not know
is a log line rather than an error, since it stops nothing.

TypeAhead and EngineTypeAhead share the TransmitBuffer interface, which is what
the digital window now works through, so the window does not know which one it
has.

docs/unfinished.md states what each buffer assumes and how to run the probe.

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 22:41:18 +00:00
parent da7f7fb3f5
commit 54b9181c06
25 changed files with 1311 additions and 68 deletions

View File

@@ -6,26 +6,36 @@ namespace Nonemm.Digital;
/// 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 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.
/// A message goes into the transmit buffer rather than to the engine whole, so
/// the part that has not been transmitted can still be rewritten. `Finished` is
/// raised when the buffer has run dry and the engine has transmitted what it
/// was given.
///
/// Which buffer depends on `engineHoldsBuffer`: `EngineTypeAhead` pushes
/// everything to the engine and takes it back with backspaces, `TypeAhead`
/// holds it here and feeds the engine a couple of characters at a time. Only an
/// engine that has a buffer of its own can hold one.
public sealed class DigitalEngineSender : MessageSender
{
private readonly DigitalEngine engine;
public DigitalEngineSender(DigitalEngine engine, double baud = TypeAhead.DefaultBaud)
public DigitalEngineSender(
DigitalEngine engine,
double baud = TypeAhead.DefaultBaud,
bool engineHoldsBuffer = false)
{
this.engine = engine;
TypeAhead = new TypeAhead(
(character, cancellation) => engine.SendAsync(character.ToString(), cancellation),
baud);
TypeAhead.Drained += WhenDrained;
Buffer = engineHoldsBuffer && engine is EngineBuffer holder
? new EngineTypeAhead(holder)
: new TypeAhead(
(character, cancellation) => engine.SendAsync(character.ToString(), cancellation),
baud);
Buffer.Drained += WhenDrained;
engine.TransmitChanged += WhenTransmitChanged;
}
/// What is waiting to go out, which the digital window shows and edits.
public TypeAhead TypeAhead { get; }
public TransmitBuffer Buffer { get; }
public bool IsReady => engine.IsConnected;
@@ -37,7 +47,7 @@ public sealed class DigitalEngineSender : MessageSender
public Task SendAsync(string text, CancellationToken cancellation = default)
{
TypeAhead.Append(text);
Buffer.Append(text);
return Task.CompletedTask;
}
@@ -45,7 +55,7 @@ public sealed class DigitalEngineSender : MessageSender
/// and the engine drops what it still holds.
public Task AbortAsync(CancellationToken cancellation = default)
{
TypeAhead.Drop();
Buffer.Drop();
return engine.AbortAsync(cancellation);
}
@@ -56,8 +66,8 @@ public sealed class DigitalEngineSender : MessageSender
public void Dispose()
{
TypeAhead.Drained -= WhenDrained;
TypeAhead.Dispose();
Buffer.Drained -= WhenDrained;
Buffer.Dispose();
engine.TransmitChanged -= WhenTransmitChanged;
}
@@ -75,8 +85,8 @@ public sealed class DigitalEngineSender : MessageSender
{
return;
}
TypeAhead.EngineIdle();
if (!TypeAhead.IsSending)
Buffer.EngineIdle();
if (!Buffer.IsSending)
{
Finished?.Invoke(this, EventArgs.Empty);
}

View File

@@ -0,0 +1,23 @@
namespace Nonemm.Digital;
/// An engine that holds a transmit buffer of its own, takes characters into it
/// one at a time, and says how many it still has. MMTTY does: its type-ahead is
/// what its own keyboard drives, and `XMMT.ocx` exposes both the keystroke and
/// the count.
///
/// A backspace is a character like any other. MMTTY's help says it erases from
/// the end of the buffer and works only until the letter has been transmitted,
/// which is what makes `EngineTypeAhead` possible.
public interface EngineBuffer
{
/// The answer to `AskBufferedAsync`: how many characters are left to
/// transmit, or -1 when the engine would not say.
event EventHandler<int>? Buffered;
/// One character into the engine's buffer.
Task TypeAsync(char character, CancellationToken cancellation = default);
/// Asks how many characters are left. There is no event for it, so it is
/// polled.
Task AskBufferedAsync(string property = "", CancellationToken cancellation = default);
}

View File

@@ -0,0 +1,370 @@
using System.Text;
using System.Threading.Channels;
namespace Nonemm.Digital;
/// The transmit buffer with the engine holding the text rather than this.
///
/// MMTTY has a type-ahead buffer of its own, which is what its keyboard drives:
/// characters go into it, a backspace takes back one that has not been
/// transmitted, and `TxBufLen` says how many are left. Everything is pushed to
/// it as soon as it is typed, so the engine never runs out of characters
/// partway through a message and never transmits idle in the middle of one.
///
/// An edit is expressed as backspaces followed by the new text. How much can be
/// taken back is what `TxBufLen` last said, less `Guard`: the count is a poll
/// old, and a backspace over a character that has already been transmitted is
/// refused by the engine, which would put the text after it on the air twice.
/// Holding one character back stops that, at the cost of one character that
/// could have been retracted.
///
/// Characters, backspaces and the questions all go to the engine through one
/// queue, in the order they were made, so an answer is about the text the
/// engine had when it was asked. An answer that arrives after something else
/// has been written is thrown away rather than guessed at.
///
/// `docs/unfinished.md` lists what has been checked against a real engine and
/// what has not.
public sealed class EngineTypeAhead : TransmitBuffer
{
/// How often the engine is asked how much it has left. Under a third of a
/// character at 45.45 baud, which is what keeps `Guard` at one character.
public static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(50);
/// How many characters at the front of the engine's buffer are treated as
/// gone even though the last count said they were still there.
public const int Guard = 1;
private readonly EngineBuffer engine;
private readonly TimeSpan pollInterval;
private readonly Lock gate = new();
private readonly StringBuilder sent = new();
private readonly StringBuilder pending = new();
private readonly Channel<Instruction> outgoing = Channel.CreateUnbounded<Instruction>();
private CancellationTokenSource? stopping;
private Task pump = Task.CompletedTask;
private Task writer = Task.CompletedTask;
/// Characters of `pending` that have been queued for the engine.
private int held;
/// Characters of `pending` the engine has actually been given. It trails
/// `held` by whatever is still in the queue.
private int given;
/// Counts everything written to the engine, so an answer can be checked
/// against the state it was asked in.
private long written;
private long writtenWhenAsked = -1;
private int givenWhenAsked;
private int cursor = TransmitBuffer.NoCursor;
public EngineTypeAhead(EngineBuffer engine, TimeSpan? pollInterval = null)
{
this.engine = engine;
this.pollInterval = pollInterval ?? PollInterval;
engine.Buffered += WhenBuffered;
}
/// False once the engine has refused to say how much it holds. It still
/// takes text; nothing can be taken back and the pane shows what has been
/// handed over rather than what has gone out.
public bool Counts { get; private set; } = true;
public string Sent
{
get
{
lock (gate)
{
return sent.ToString() + pending.ToString(0, Frozen);
}
}
}
public string Pending
{
get
{
lock (gate)
{
return pending.ToString(Frozen, pending.Length - Frozen);
}
}
}
public int Cursor
{
get
{
lock (gate)
{
return cursor;
}
}
set
{
lock (gate)
{
cursor = Math.Max(0, value);
Push();
}
Start();
}
}
public bool IsSending
{
get
{
lock (gate)
{
return pending.Length > 0;
}
}
}
public event EventHandler? Changed;
public event EventHandler? Drained;
public void Append(string text)
{
if (text.Length == 0)
{
return;
}
lock (gate)
{
pending.Append(text);
if (cursor != TransmitBuffer.NoCursor)
{
cursor += text.Length;
}
Push();
}
Changed?.Invoke(this, EventArgs.Empty);
Start();
}
/// The text the operator left in the box, which is everything except what
/// this has already given up on taking back.
public void Rewrite(string text, int wanted)
{
lock (gate)
{
string whole = pending.ToString(0, Frozen) + text;
int shared = Shared(pending, whole);
for (int over = held - shared; over > 0; over--)
{
Write(new Instruction('\b'));
}
held = Math.Min(held, shared);
pending.Clear();
pending.Append(whole);
cursor = Math.Clamp(wanted, 0, text.Length);
Push();
}
Start();
}
/// The engine's own abort empties its buffer, so nothing has to be taken
/// back a character at a time.
public void Drop()
{
lock (gate)
{
Empty();
}
Changed?.Invoke(this, EventArgs.Empty);
}
public void Clear()
{
lock (gate)
{
Empty();
sent.Clear();
}
Changed?.Invoke(this, EventArgs.Empty);
}
/// The engine stopped transmitting, so everything it had been given has
/// gone out. What is still in the queue has not.
public void EngineIdle()
{
lock (gate)
{
Transmitted(given);
}
Changed?.Invoke(this, EventArgs.Empty);
}
public void Dispose()
{
engine.Buffered -= WhenBuffered;
outgoing.Writer.TryComplete();
stopping?.Cancel();
stopping?.Dispose();
stopping = null;
}
/// How many characters two strings start the same.
private static int Shared(StringBuilder was, string now)
{
int same = 0;
while (same < was.Length && same < now.Length && was[same] == now[same])
{
same++;
}
return same;
}
private int Frozen => Math.Min(held, Guard);
/// Queues everything the operator has released. Called with the lock held.
private void Push()
{
int limit = cursor == TransmitBuffer.NoCursor
? pending.Length
: Math.Min(pending.Length, Frozen + cursor);
while (held < limit)
{
Write(new Instruction(pending[held]));
held++;
}
}
private void Write(Instruction instruction)
{
written++;
outgoing.Writer.TryWrite(instruction);
}
private void Empty()
{
pending.Clear();
held = 0;
given = 0;
cursor = TransmitBuffer.NoCursor;
}
/// An answer to a question asked when the engine held `givenWhenAsked`
/// characters. Anything written since then makes it useless: the engine had
/// already moved on when it answered.
private void WhenBuffered(object? sender, int left)
{
if (left < 0)
{
Counts = false;
return;
}
Counts = true;
lock (gate)
{
if (written != writtenWhenAsked)
{
return;
}
Transmitted(givenWhenAsked - left);
}
Changed?.Invoke(this, EventArgs.Empty);
}
/// The first `gone` characters have been transmitted and cannot be taken
/// back. Called with the lock held.
private void Transmitted(int gone)
{
gone = Math.Min(gone, pending.Length);
if (gone <= 0)
{
return;
}
int frozenBefore = Frozen;
sent.Append(pending.ToString(0, gone));
if (sent.Length > TransmitBuffer.KeptSent)
{
sent.Remove(0, sent.Length - TransmitBuffer.KeptSent);
}
pending.Remove(0, gone);
held -= gone;
given -= gone;
if (cursor != TransmitBuffer.NoCursor)
{
// the visible text starts at `Frozen`, which moves as well
cursor = Math.Max(0, cursor - gone - Frozen + frozenBefore);
}
}
private void Start()
{
lock (gate)
{
if (writer.IsCompleted)
{
writer = Task.Run(WriteAsync);
}
if (!pump.IsCompleted)
{
return;
}
stopping?.Dispose();
stopping = new CancellationTokenSource();
pump = Task.Run(() => PollAsync(stopping.Token));
}
}
/// Puts a question in the queue behind whatever is waiting, until there is
/// nothing left to send.
private async Task PollAsync(CancellationToken cancellation)
{
try
{
while (!cancellation.IsCancellationRequested)
{
lock (gate)
{
Write(new Instruction(Ask: true));
}
await Task.Delay(pollInterval, cancellation).ConfigureAwait(false);
if (!IsSending)
{
Drained?.Invoke(this, EventArgs.Empty);
return;
}
}
}
catch (OperationCanceledException)
{
}
}
/// The one place the engine is written to, so characters, backspaces and
/// questions reach it in the order they were made.
private async Task WriteAsync()
{
await foreach (Instruction instruction in outgoing.Reader.ReadAllAsync().ConfigureAwait(false))
{
if (instruction.Ask)
{
lock (gate)
{
writtenWhenAsked = written;
givenWhenAsked = given;
}
await engine.AskBufferedAsync("").ConfigureAwait(false);
continue;
}
await engine.TypeAsync(instruction.Character).ConfigureAwait(false);
lock (gate)
{
given += instruction.Character == '\b' ? -1 : 1;
}
}
}
/// One thing to do to the engine: a character to type, a backspace, or the
/// question about how much is left.
private readonly record struct Instruction(char Character = '\0', bool Ask = false);
}

View File

@@ -54,6 +54,10 @@ public sealed class MmttyEngine : DigitalEngine
/// Anything the bridge says about itself, for the status line.
public event EventHandler<string>? Reported;
/// The answer to `AskBufferedAsync`: how many characters the engine still
/// has to transmit.
public event EventHandler<int>? Buffered;
public async Task StartAsync(CancellationToken cancellation = default)
{
settings = MmttySettings.Read(options.SettingsPath);
@@ -75,6 +79,13 @@ public sealed class MmttyEngine : DigitalEngine
public Task TypeAsync(char character, CancellationToken cancellation = default) =>
PostAsync(MmttyMessage.TypeCharacter, character, cancellation);
/// Asks how many characters the engine still holds. The control has no
/// event for it, so it is polled; the answer arrives on `Buffered`. A
/// property name other than `TxBufLen` is only for finding out what the
/// control answers to.
public Task AskBufferedAsync(string property = "", CancellationToken cancellation = default) =>
bridge.SendAsync(BridgeLine.Write("buffer", property), cancellation);
/// N1MM's abort, which is what its RX button and Escape do: drop PTT and
/// let the engine stop where it is.
public Task AbortAsync(CancellationToken cancellation = default) =>
@@ -154,6 +165,9 @@ public sealed class MmttyEngine : DigitalEngine
case "rx":
Received?.Invoke(this, ((char)Number(fields, 0)).ToString());
break;
case "buffer":
Buffered?.Invoke(this, Number(fields, 0));
break;
case "tx":
IsTransmitting = Number(fields, 0) == 1;
TransmitChanged?.Invoke(this, IsTransmitting);

View File

@@ -0,0 +1,57 @@
namespace Nonemm.Digital;
/// The text waiting to go out. Two of these exist: `TypeAhead` holds the text
/// here and feeds the engine, `EngineTypeAhead` hands it to the engine and
/// takes it back with backspaces. The digital window works through this and
/// does not know which one it has.
public interface TransmitBuffer : IDisposable
{
/// `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;
/// 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;
/// What has gone out and can no longer be changed.
string Sent { get; }
/// What is still to go, which is what the operator edits.
string Pending { get; }
/// 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.
int Cursor { get; set; }
/// There is text to send, or the engine has not finished what it was given.
bool IsSending { get; }
/// The text moved: a character went out, or a message was added. Raised off
/// the screen thread, so a handler that draws has to post.
event EventHandler? Changed;
/// Everything that was waiting has gone out and the engine has 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.
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.
void Append(string text);
/// The operator rewrote what has not gone out yet.
void Rewrite(string text, int cursor);
/// Drops what has not gone out. Escape and the RX button do this.
void Drop();
/// Empties the pane, which is what the CLR button does between
/// transmissions.
void Clear();
/// The engine has stopped transmitting, so whatever it held has gone out.
void EngineIdle();
}

View File

@@ -30,7 +30,7 @@ namespace Nonemm.Digital;
/// 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
public sealed class TypeAhead : TransmitBuffer
{
/// A RTTY character is a start bit, five data bits and a stop bit and a
/// half.
@@ -45,14 +45,6 @@ public sealed class TypeAhead : IDisposable
/// 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;
/// `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;
/// 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.
@@ -65,7 +57,7 @@ public sealed class TypeAhead : IDisposable
private CancellationTokenSource? stopping;
private Task pump = Task.CompletedTask;
private int cursor = NoCursor;
private int cursor = TransmitBuffer.NoCursor;
private int inEngine;
private DateTime nextOut = DateTime.MinValue;
@@ -110,7 +102,7 @@ public sealed class TypeAhead : IDisposable
}
/// 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.
/// where the operator's cursor is; `TransmitBuffer.NoCursor` while nobody is typing.
public int Cursor
{
get
@@ -165,7 +157,7 @@ public sealed class TypeAhead : IDisposable
lock (gate)
{
pending.Append(text);
if (cursor != NoCursor)
if (cursor != TransmitBuffer.NoCursor)
{
// text added behind the operator's cursor is still text to
// send, so the cursor moves out with it
@@ -209,7 +201,7 @@ public sealed class TypeAhead : IDisposable
{
pending.Clear();
inEngine = 0;
cursor = NoCursor;
cursor = TransmitBuffer.NoCursor;
}
Changed?.Invoke(this, EventArgs.Empty);
}
@@ -223,7 +215,7 @@ public sealed class TypeAhead : IDisposable
pending.Clear();
sent.Clear();
inEngine = 0;
cursor = NoCursor;
cursor = TransmitBuffer.NoCursor;
}
Changed?.Invoke(this, EventArgs.Empty);
}
@@ -301,7 +293,7 @@ public sealed class TypeAhead : IDisposable
}
char next = pending[0];
pending.Remove(0, 1);
if (cursor != NoCursor)
if (cursor != TransmitBuffer.NoCursor)
{
cursor--;
}
@@ -311,9 +303,9 @@ public sealed class TypeAhead : IDisposable
}
inEngine++;
sent.Append(next);
if (sent.Length > KeptSent)
if (sent.Length > TransmitBuffer.KeptSent)
{
sent.Remove(0, sent.Length - KeptSent);
sent.Remove(0, sent.Length - TransmitBuffer.KeptSent);
}
return next;
}