Start the transmit pane again on every new message
The macro buttons went wrong at the end of a transmission, both faults in
DigitalEngineSender:
- The engine reports the transmitter drop on its own thread.
WhenTransmitChanged took the state lock, decided the message had ended,
released the lock, and only then called Buffer.Ended(), which clears
what has gone to the engine. A macro pressed on the last character got
through StartAsync in that gap and had already flushed its own text into
Sent, so Ended() wiped the new text off the pane while the engine
transmitted it. Ended() is now called inside the same lock.
- The pane only started again when the engine reported a drop. Two macros
in a row keep the transmitter up, so that report never came and Sent
grew with every press. Everything in Sent is locked, because it is in
the engine and cannot be taken back, so the whole pane became
read-only. TypeAhead.Started() drops the last message's sent text and
keeps what was typed ahead, and StartAsync calls it whenever it keys a
new transmission.
The rest of this commit is the digital transmit work these fixes sit on:
the pane as one coloured box, the sender's three keying states, the
type-ahead feeder paced by the clock with the engine's count as a brake,
{RX} flushing what is left in one piece, and the entry window's function
keys reading the digital macros on a digital mode.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PdAYHcdRktqKry7nk414TU
This commit is contained in:
@@ -28,4 +28,18 @@ public interface DigitalEngine : IDisposable
|
||||
|
||||
/// Drops whatever has not gone out yet, which is what Escape does.
|
||||
Task AbortAsync(CancellationToken cancellation = default);
|
||||
|
||||
/// N1MM's `{TX}`: the transmitter comes up now, before there is anything
|
||||
/// to send.
|
||||
Task KeyAsync(CancellationToken cancellation = default);
|
||||
|
||||
/// N1MM's `{RX}`: the transmitter drops once the modem has transmitted what
|
||||
/// it still holds.
|
||||
Task ReturnToReceiveAsync(CancellationToken cancellation = default);
|
||||
|
||||
/// The key back down, without waiting for anything. MMTTY needs this: its
|
||||
/// `SetMmttyPTT(1)` leaves the transmitter up whatever N1MM's source says,
|
||||
/// so `{RX}` waits for the modem to say its buffer is empty and then puts
|
||||
/// the key down here.
|
||||
Task ReleaseKeyAsync(CancellationToken cancellation = default);
|
||||
}
|
||||
|
||||
@@ -8,15 +8,71 @@ namespace Nonemm.Digital;
|
||||
///
|
||||
/// A message goes into the type-ahead buffer rather than to the engine whole,
|
||||
/// so the part that has not gone out can still be rewritten. `Finished` is
|
||||
/// raised when the buffer has run dry and the engine has transmitted what it
|
||||
/// was given.
|
||||
/// raised when the buffer has run dry, with the last characters of the message
|
||||
/// in the engine and about a third of a second of it still to go out.
|
||||
///
|
||||
/// An engine that can say how much it still has to transmit paces the buffer;
|
||||
/// the rest are paced by the clock at `baud`.
|
||||
/// The buffer is paced by the clock at `baud`, and the engine's own count of
|
||||
/// what it has left is a brake on it.
|
||||
///
|
||||
/// Three states, which is what the transmitter can be doing:
|
||||
///
|
||||
/// | State | Key | Gate | Engine |
|
||||
/// |---|---|---|---|
|
||||
/// | `Down` | down | shut | holds nothing |
|
||||
/// | `Keyed` | up | open, the feeder paces text over | holds `Ahead` characters |
|
||||
/// | `Ending` | up | shut, everything left was flushed in one piece | holds the rest of the message |
|
||||
///
|
||||
/// | From | Event | To |
|
||||
/// |---|---|---|
|
||||
/// | `Down` | `Transmit`, or a message to send | `Keyed` |
|
||||
/// | `Keyed` | more text | `Keyed` |
|
||||
/// | `Keyed` | `{RX}` | `Ending` |
|
||||
/// | `Keyed` | the engine drops with the message unfinished | `Keyed`, keyed again |
|
||||
/// | `Ending` | the engine drops | `Down` |
|
||||
/// | `Ending` | the engine never drops | `Down`, the key put down here |
|
||||
/// | `Ending` | `Transmit`, or a message to send | `Keyed`, once the engine is empty and its stop is cleared |
|
||||
/// | any | `AbortAsync` | `Down` |
|
||||
///
|
||||
/// The last two rows are why there is a state at all rather than a flag.
|
||||
/// Ending a message takes as long as the engine takes to transmit what it
|
||||
/// holds, and the operator can key again inside that time. An ending that went
|
||||
/// on running put the key down in the middle of the message after it.
|
||||
///
|
||||
/// Coming back out of `Ending` is not free either. The stop is inside the
|
||||
/// engine by then, waiting for its buffer to empty, and MMTTY fed while that
|
||||
/// stands stayed keyed and transmitted nothing: the characters went in and
|
||||
/// never came out. So a new message waits for what the engine holds to go out,
|
||||
/// clears the stop with an abort, and keys again.
|
||||
public sealed class DigitalEngineSender : MessageSender
|
||||
{
|
||||
/// How long the engine may make no progress at all, after it has been told
|
||||
/// to stop, before the key goes down anyway. It is not a limit on the whole
|
||||
/// wait: a flushed message is seconds of transmission and the engine is
|
||||
/// entitled to all of it. Measured against the whole wait instead, it cut
|
||||
/// a CQ off with 21 symbols still in the engine.
|
||||
public static readonly TimeSpan StopPatience = TimeSpan.FromSeconds(1.5);
|
||||
|
||||
/// The longest the stop waits for the engine to say it has the message.
|
||||
/// The stop does nothing at all if it arrives at an engine with an empty
|
||||
/// buffer, so it goes out as soon as the count says there is something to
|
||||
/// stop, and after this long whether the count says so or not. It is
|
||||
/// N1MM's number, which N1MM sleeps outright.
|
||||
public static readonly TimeSpan StopDelay = TimeSpan.FromMilliseconds(400);
|
||||
|
||||
private readonly DigitalEngine engine;
|
||||
|
||||
private readonly Lock gate = new();
|
||||
|
||||
/// Where the transmitter is, as far as this program knows.
|
||||
private Keying state = Keying.Down;
|
||||
|
||||
/// Which transmission this is. It goes up whenever one starts or is
|
||||
/// abandoned, and the ending of a message checks it after every step: an
|
||||
/// ending belongs to one transmission and must not act on the next one.
|
||||
/// Without it the ending of one message put the key down in the middle of
|
||||
/// the message after it.
|
||||
private int transmission;
|
||||
|
||||
public DigitalEngineSender(DigitalEngine engine, double baud = TypeAhead.DefaultBaud)
|
||||
{
|
||||
this.engine = engine;
|
||||
@@ -25,7 +81,7 @@ public sealed class DigitalEngineSender : MessageSender
|
||||
: new TypeAhead(
|
||||
(character, cancellation) => engine.SendAsync(character.ToString(), cancellation),
|
||||
baud);
|
||||
Buffer.Drained += WhenDrained;
|
||||
Buffer.Given += WhenGiven;
|
||||
engine.TransmitChanged += WhenTransmitChanged;
|
||||
}
|
||||
|
||||
@@ -40,16 +96,157 @@ public sealed class DigitalEngineSender : MessageSender
|
||||
|
||||
public event EventHandler? Finished;
|
||||
|
||||
public Task SendAsync(string text, CancellationToken cancellation = default)
|
||||
/// A message to send. Text is the operator asking for the transmitter, so
|
||||
/// it keys as well: text arriving while the last message was ending
|
||||
/// abandons that ending, and text arriving with the transmitter down brings
|
||||
/// it up rather than letting MMTTY key itself off the first character.
|
||||
public async Task SendAsync(string text, CancellationToken cancellation = default)
|
||||
{
|
||||
await StartAsync(cancellation).ConfigureAwait(false);
|
||||
Buffer.Append(text);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// Escape and the RX button: what has not gone to the engine is dropped,
|
||||
/// and the engine drops what it still holds.
|
||||
private async Task TransmitAsync()
|
||||
{
|
||||
await StartAsync().ConfigureAwait(false);
|
||||
Buffer.Transmit();
|
||||
}
|
||||
|
||||
/// The TX button, Ctrl+Enter and `{TX}`: the transmitter comes up and what
|
||||
/// is in the pane goes out, and so does whatever is typed into it from now
|
||||
/// on.
|
||||
///
|
||||
/// Keying belongs here rather than in the window so that it is always the
|
||||
/// first thing the engine is told. MMTTY keys itself off a character it is
|
||||
/// given while the transmitter is down, sends it and drops again, so a key
|
||||
/// that arrives behind the text puts a keyed-up gap in the middle of a
|
||||
/// message.
|
||||
public void Transmit() => _ = TransmitAsync();
|
||||
|
||||
/// N1MM's `{RX}`: everything still waiting goes to the engine in one piece
|
||||
/// and the engine is then asked to stop. A macro that ends with it goes out
|
||||
/// in full, and so does anything the operator has typed ahead of it.
|
||||
///
|
||||
/// It does not wait for the feeder to hand the message over first. Waiting
|
||||
/// is what made the stop useless: by the time the last character had gone
|
||||
/// over, the engine was empty again, and MMTTY ignores a stop that reaches
|
||||
/// it empty.
|
||||
public void ReturnToReceiveWhenSent() => _ = StopAsync();
|
||||
|
||||
/// A transmission begins, or the one that was ending goes on. True when the
|
||||
/// engine has to be keyed, which is every time but one already keyed and
|
||||
/// still going.
|
||||
private async Task StartAsync(CancellationToken cancellation = default)
|
||||
{
|
||||
bool ending;
|
||||
lock (gate)
|
||||
{
|
||||
ending = state == Keying.Ending;
|
||||
}
|
||||
if (ending)
|
||||
{
|
||||
// the last message is ending and its stop is inside the engine,
|
||||
// waiting for the buffer to empty. Feeding an engine with that
|
||||
// standing left MMTTY keyed with nothing going out and the
|
||||
// characters swallowed, so what it still holds is let out and the
|
||||
// stop is cleared with an abort before it is keyed again
|
||||
await WaitUntilAiredAsync(StopPatience).ConfigureAwait(false);
|
||||
await engine.AbortAsync(cancellation).ConfigureAwait(false);
|
||||
}
|
||||
bool key;
|
||||
lock (gate)
|
||||
{
|
||||
key = state != Keying.Keyed;
|
||||
if (key)
|
||||
{
|
||||
transmission++;
|
||||
}
|
||||
state = Keying.Keyed;
|
||||
}
|
||||
if (key)
|
||||
{
|
||||
// the pane starts again on the message that has gone out. The
|
||||
// engine reporting the drop does this too, but it never reports one
|
||||
// when a message follows the last close enough to keep the
|
||||
// transmitter up, and the pane then held the whole run with none of
|
||||
// it editable
|
||||
Buffer.Started();
|
||||
await engine.KeyAsync(cancellation).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// Waits until the engine says it holds something, or `patience` runs out.
|
||||
/// The stop does nothing at an engine with an empty buffer, so it goes out
|
||||
/// as soon as there is something to stop. N1MM sleeps 400 ms here instead,
|
||||
/// which is the same wait without the question: it never reads the count.
|
||||
/// A sleep is also wrong on a short message — `TU` is 330 ms of air at
|
||||
/// 45.45 baud, so 400 ms of it puts the stop back where it does nothing.
|
||||
private async Task WaitUntilHoldingAsync(TimeSpan patience)
|
||||
{
|
||||
DateTime giveUp = DateTime.UtcNow + patience;
|
||||
while (Buffer.EngineHolds <= 0 && DateTime.UtcNow < giveUp)
|
||||
{
|
||||
await Task.Delay(TypeAhead.PollInterval).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// Waits until the engine has transmitted everything it was given, or until
|
||||
/// it has made no progress for `patience`.
|
||||
///
|
||||
/// `patience` is not a limit on the whole wait: a flushed message is
|
||||
/// seconds of transmission and the engine is entitled to all of it. What it
|
||||
/// catches is an engine that has stopped moving. Measured against the whole
|
||||
/// wait, it cut a CQ off with 21 symbols still in the engine.
|
||||
private async Task WaitUntilAiredAsync(TimeSpan patience)
|
||||
{
|
||||
if (Buffer.Outstanding <= 0 && Buffer.EngineHolds <= 0 && !Buffer.IsSending)
|
||||
{
|
||||
return;
|
||||
}
|
||||
TaskCompletionSource aired = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
void WhenAired(object? sender, EventArgs e) => aired.TrySetResult();
|
||||
Buffer.Aired += WhenAired;
|
||||
try
|
||||
{
|
||||
int holds = Buffer.EngineHolds;
|
||||
int outstanding = Buffer.Outstanding;
|
||||
DateTime giveUp = DateTime.UtcNow + patience;
|
||||
while (!aired.Task.IsCompleted && DateTime.UtcNow < giveUp)
|
||||
{
|
||||
await Task.Delay(TypeAhead.PollInterval).ConfigureAwait(false);
|
||||
if (Buffer.EngineHolds < holds || Buffer.Outstanding < outstanding)
|
||||
{
|
||||
holds = Buffer.EngineHolds;
|
||||
outstanding = Buffer.Outstanding;
|
||||
giveUp = DateTime.UtcNow + patience;
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
Buffer.Aired -= WhenAired;
|
||||
}
|
||||
}
|
||||
|
||||
/// True while `mine` is still the transmission being ended. False once
|
||||
/// something has started another one, ended this one, or aborted.
|
||||
private bool Ending(int mine)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
return state == Keying.Ending && transmission == mine;
|
||||
}
|
||||
}
|
||||
|
||||
/// Escape: what has not gone to the engine is dropped, and the engine drops
|
||||
/// what it still holds.
|
||||
public Task AbortAsync(CancellationToken cancellation = default)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
state = Keying.Down;
|
||||
transmission++;
|
||||
}
|
||||
Buffer.Drop();
|
||||
return engine.AbortAsync(cancellation);
|
||||
}
|
||||
@@ -61,26 +258,134 @@ public sealed class DigitalEngineSender : MessageSender
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Buffer.Drained -= WhenDrained;
|
||||
Buffer.Given -= WhenGiven;
|
||||
Buffer.Dispose();
|
||||
engine.TransmitChanged -= WhenTransmitChanged;
|
||||
}
|
||||
|
||||
private void WhenDrained(object? sender, EventArgs e) => Finished?.Invoke(this, EventArgs.Empty);
|
||||
/// The message is finished when the last character has been handed to the
|
||||
/// engine, not when the engine has transmitted it. What stands after
|
||||
/// `{END}` runs there, `{RX}` among it, and `{RX}` has to reach the engine
|
||||
/// while the engine still holds something to send.
|
||||
private void WhenGiven(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.
|
||||
/// Tells the engine to stop, waits for it to transmit what it still holds,
|
||||
/// and puts the key down.
|
||||
///
|
||||
/// This is N1MM's ending, which is not the same as its keying. N1MM hands
|
||||
/// MMTTY the whole message with `SendString` and calls `SetMmttyPTT(1)`
|
||||
/// with the message still in the engine, and MMTTY ends the transmission
|
||||
/// itself at the last character. So the feeding stops here: what is left
|
||||
/// goes over in one piece and the engine is asked to stop on a full buffer.
|
||||
/// Nothing after the flush can be rewritten, which is what `{RX}` means.
|
||||
///
|
||||
/// What sits between the two is the count, not a sleep. Two of N1MM's three
|
||||
/// MMTTY send paths call the stop straight after the text and the third
|
||||
/// sleeps 400 ms first, which is a fixed wait for something this program
|
||||
/// can ask about: the stop goes out as soon as the engine says it holds
|
||||
/// something. A fixed sleep is also wrong on a short message — `TU` is
|
||||
/// 330 ms of air at 45.45 baud, so 400 ms of sleeping puts the stop back
|
||||
/// where it does nothing.
|
||||
///
|
||||
/// The key going down is the fallback for an engine that ignores the stop,
|
||||
/// which is what MMTTY did every time it was asked on an empty buffer. It
|
||||
/// waits for the engine's own count to reach 0 and one symbol time on
|
||||
/// top, which covers the count being read every `TypeAhead.PollInterval`
|
||||
/// rather than the transmission. Every millisecond here is turnaround time
|
||||
/// in a contest.
|
||||
private async Task StopAsync()
|
||||
{
|
||||
int mine;
|
||||
lock (gate)
|
||||
{
|
||||
if (state == Keying.Down)
|
||||
{
|
||||
return;
|
||||
}
|
||||
state = Keying.Ending;
|
||||
mine = transmission;
|
||||
}
|
||||
string rest = await Buffer
|
||||
.FlushAsync((text, cancellation) => engine.SendAsync(text, cancellation))
|
||||
.ConfigureAwait(false);
|
||||
if (rest.Length > 0 && Ending(mine))
|
||||
{
|
||||
await WaitUntilHoldingAsync(StopDelay).ConfigureAwait(false);
|
||||
}
|
||||
if (!Ending(mine))
|
||||
{
|
||||
return;
|
||||
}
|
||||
await engine.ReturnToReceiveAsync().ConfigureAwait(false);
|
||||
await WaitUntilAiredAsync(StopPatience).ConfigureAwait(false);
|
||||
if (!Ending(mine))
|
||||
{
|
||||
return;
|
||||
}
|
||||
await Task.Delay(Buffer.SymbolTime).ConfigureAwait(false);
|
||||
if (Ending(mine))
|
||||
{
|
||||
await engine.ReleaseKeyAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// The engine dropping the transmitter ends the message, but only after
|
||||
/// `{RX}` or an abort asked it to stop. The pane then starts again: what
|
||||
/// has gone out is cleared, what the operator typed ahead is kept, and
|
||||
/// nothing more is fed until the transmitter is keyed again.
|
||||
///
|
||||
/// A drop nobody asked for is the engine keying itself off what it is
|
||||
/// given, which happens between the characters of a message that is still
|
||||
/// going out, and is not the end of anything.
|
||||
private void WhenTransmitChanged(object? sender, bool transmitting)
|
||||
{
|
||||
if (transmitting)
|
||||
{
|
||||
return;
|
||||
}
|
||||
bool unfinished = Buffer.IsTransmitting && Buffer.IsSending;
|
||||
bool ended;
|
||||
lock (gate)
|
||||
{
|
||||
ended = state == Keying.Ending;
|
||||
if (ended)
|
||||
{
|
||||
state = Keying.Down;
|
||||
transmission++;
|
||||
// inside the lock, so a message starting at this moment cannot
|
||||
// have its own text cleared by the end of the one before it.
|
||||
// The drop arrives from the engine on its own thread: a macro
|
||||
// pressed on the last character of a message got as far as
|
||||
// handing its text to the engine before this ran, and the pane
|
||||
// was then wiped while the text went out
|
||||
Buffer.Ended();
|
||||
}
|
||||
}
|
||||
if (!ended)
|
||||
{
|
||||
// the engine dropped in the middle of a message. What is left of it
|
||||
// would go out into a transmitter that is down, so it comes back
|
||||
// up; the engine keying itself off the next character would leave
|
||||
// that character half sent
|
||||
if (unfinished)
|
||||
{
|
||||
_ = engine.KeyAsync();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!Buffer.IsSending)
|
||||
{
|
||||
Finished?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the transmitter is. `Ending` is one message: everything left of it
|
||||
/// has gone to the engine and nothing more is fed, and the engine is
|
||||
/// transmitting what it holds.
|
||||
private enum Keying
|
||||
{
|
||||
Down,
|
||||
Keyed,
|
||||
Ending,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,11 @@ namespace Nonemm.Digital;
|
||||
/// N1MM's: the engine is given a title, the PTT port out of Mmtty.INI and a
|
||||
/// command line, and an engine that fails to initialise is started again up to
|
||||
/// ten times, which is what N1MM's retry does.
|
||||
public sealed class MmttyEngine : DigitalEngine
|
||||
///
|
||||
/// It holds a buffer of its own and says how much of it is left, so the
|
||||
/// type-ahead feeds it one character at a time the way N1MM does and paces on
|
||||
/// the count.
|
||||
public sealed class MmttyEngine : DigitalEngine, EngineBuffer
|
||||
{
|
||||
private const int StartAttempts = 10;
|
||||
|
||||
@@ -91,6 +95,12 @@ public sealed class MmttyEngine : DigitalEngine
|
||||
public Task AbortAsync(CancellationToken cancellation = default) =>
|
||||
bridge.SendAsync(BridgeLine.Write("ptt", "0"), cancellation);
|
||||
|
||||
/// The control's `PTT` property back to false, which is the key the other
|
||||
/// way rather than a stop. N1MM never does this: it keys with the property
|
||||
/// and leaves `SetMmttyPTT(1)` to drop the transmitter.
|
||||
public Task ReleaseKeyAsync(CancellationToken cancellation = default) =>
|
||||
bridge.SendAsync(BridgeLine.Write("key", "0"), cancellation);
|
||||
|
||||
/// N1MM's `{TX}` and `{RX}`. Keying is the control's `PTT` property;
|
||||
/// unkeying is `SetMmttyPTT(1)`, which waits for the buffer to empty first,
|
||||
/// so a macro that ends with `{RX}` still goes out in full.
|
||||
@@ -99,6 +109,9 @@ public sealed class MmttyEngine : DigitalEngine
|
||||
on ? BridgeLine.Write("key", "1") : BridgeLine.Write("ptt", "1"),
|
||||
cancellation);
|
||||
|
||||
public Task KeyAsync(CancellationToken cancellation = default) =>
|
||||
SetPttAsync(true, cancellation);
|
||||
|
||||
public Task ReturnToReceiveAsync(CancellationToken cancellation = default) =>
|
||||
SetPttAsync(false, cancellation);
|
||||
|
||||
|
||||
@@ -5,90 +5,154 @@ namespace Nonemm.Digital;
|
||||
/// The text waiting to go out, held here rather than handed to the engine in
|
||||
/// one piece.
|
||||
///
|
||||
/// A digital engine takes a whole message and transmits it at the baud rate,
|
||||
/// which is slow: a callsign and a report take several seconds. Once the engine
|
||||
/// has a character nothing can take it back — MMTTY treats a backspace as
|
||||
/// another character to transmit rather than as an edit, which the engine probe
|
||||
/// showed on the air. So the message is held here and the engine is given
|
||||
/// `Lead` characters at a time: one being transmitted and one behind it, so it
|
||||
/// never runs dry and never transmits idle in the middle of a message, while
|
||||
/// everything further back can still be rewritten, added to or deleted.
|
||||
/// A digital engine transmits at the baud rate, which is slow: a callsign and a
|
||||
/// report take several seconds. Once the engine has a character nothing can
|
||||
/// take it back — MMTTY treats a backspace as another character to transmit
|
||||
/// rather than as an edit, which the engine probe showed on the air. So the
|
||||
/// message is held here and the engine is kept just short of running dry, and
|
||||
/// everything behind that 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, because the operator has not finished the word yet.
|
||||
/// Two things happen to a character and they are not the same: it is **given**
|
||||
/// to the engine, and later it **goes out** on the air. Everything here is one
|
||||
/// or the other.
|
||||
///
|
||||
/// 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 Baudot symbols it still has to transmit, which
|
||||
/// is more than the characters handed to it: a digit costs a shift to figures
|
||||
/// and a shift back, so `599 001` is seven characters and eleven symbols. The
|
||||
/// answer also lags the engine by about a character time. A pump that believed
|
||||
/// a fresh 0 would hand over a whole message in half a second and put all of it
|
||||
/// beyond reach, so the count is used two ways only:
|
||||
/// | Given to the engine | Gone out on the air |
|
||||
/// |---|---|
|
||||
/// | `Sent`, and it cannot be taken back | `OnAir`, how much of `Sent` has been transmitted |
|
||||
/// | `Outstanding`, given but not out yet | |
|
||||
/// | `Given` is raised when there is nothing left to give | `Aired` is raised when the engine has transmitted it too |
|
||||
///
|
||||
/// - 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.
|
||||
/// `Pending` is the rest: what has not been given to the engine and can still
|
||||
/// be rewritten. `EngineHolds` is the engine's own answer to the same question
|
||||
/// as `Outstanding`, in Baudot symbols rather than characters.
|
||||
///
|
||||
/// 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 a character anyway while that lasts, so the space gets there and
|
||||
/// the word goes out.
|
||||
/// Nothing goes out until `Transmit`, which is the TX button, Ctrl+Enter or a
|
||||
/// function key. From then on everything in `Pending` goes out, and so does
|
||||
/// anything added to it: the operator can go on typing and can press a function
|
||||
/// key, and both follow what is already going. `Ended` shuts the gate again
|
||||
/// without dropping what is waiting, so text typed ahead survives the end of a
|
||||
/// message.
|
||||
///
|
||||
/// The pace is the clock at the baud rate: one character every
|
||||
/// `SymbolTime`, with the engine kept `Ahead` characters ahead so it never
|
||||
/// runs dry and transmits the idle tone in the middle of a word.
|
||||
/// RTTY runs at a fixed speed, so the clock is right, and the
|
||||
/// engine's own count of what it has left cannot replace it. The engine probe
|
||||
/// shows why: `TxBufLen` read 0 for the first 150 ms after twenty-one
|
||||
/// characters were pushed, and read 0 again for three seconds while the engine
|
||||
/// was still holding `ABCD` on Word out. The count is only to be believed while
|
||||
/// it is large and going down, and the feeder keeps the engine nearly empty,
|
||||
/// so a feeder that paced on the count fed on a reading of 0 and ran ahead of
|
||||
/// the air.
|
||||
///
|
||||
/// The count is a brake instead. `Slack` is how many Baudot symbols the engine
|
||||
/// may be behind — MMTTY answers in symbols, which is more than the characters
|
||||
/// it was given, because a digit costs a shift to figures and the letter after
|
||||
/// it a shift back. An exchange full of digits is slower than the clock thinks,
|
||||
/// and this is what stops the clock running away on it.
|
||||
///
|
||||
/// A count that is not going down means the engine is holding what it has:
|
||||
/// MMTTY on Word out keeps a word until the space after it arrives, and that
|
||||
/// space is the character the brake would hold back. So the brake lets go after
|
||||
/// `HoldingPatience`.
|
||||
public sealed class TypeAhead : IDisposable
|
||||
{
|
||||
/// A RTTY character is a start bit, five data bits and a stop bit and a
|
||||
/// half.
|
||||
public const double BitsPerCharacter = 7.5;
|
||||
/// A Baudot symbol is a start bit, five data bits and a stop bit and a
|
||||
/// half. A character is one symbol, or two when it needs a shift first.
|
||||
public const double BitsPerSymbol = 7.5;
|
||||
|
||||
/// 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 many characters the engine is kept ahead by. Two is the smallest
|
||||
/// number that keeps it transmitting without a gap: one on the air and one
|
||||
/// in hand for when that one finishes. An engine left with an empty buffer
|
||||
/// transmits the idle tone instead, which is audible between the characters
|
||||
/// of a long word. At 45.45 baud this puts the last third of a second of
|
||||
/// the message beyond reach.
|
||||
public const int DefaultAhead = 2;
|
||||
|
||||
/// How many Baudot symbols the engine may be behind the clock before the
|
||||
/// feeder waits for it. Six is about a second at 45.45 baud, and well above
|
||||
/// what `Ahead` characters of ordinary text come to, so the brake only
|
||||
/// bites on a run of digits, which costs more symbols than the clock
|
||||
/// thinks.
|
||||
public const int DefaultSlack = 6;
|
||||
|
||||
/// 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;
|
||||
/// How far behind the engine's count runs. The probe pushed twenty-one
|
||||
/// characters and read 0 for three answers, 100 to 150 ms, before the count
|
||||
/// caught up with them. A 0 newer than this says nothing.
|
||||
public static readonly TimeSpan CountLag = TimeSpan.FromMilliseconds(250);
|
||||
|
||||
/// How much the engine may hold above `Lead` before the pump stops feeding.
|
||||
/// It covers the count's lag, measured at 100 to 150 ms, and the shift
|
||||
/// symbols the engine has to transmit that the clock does not know about.
|
||||
public const int Slack = 3;
|
||||
|
||||
/// How often the engine is asked how much it has left.
|
||||
/// How often the engine is asked how much it has left, and how closely the
|
||||
/// feeder follows its own clock. It is well under a character time at any
|
||||
/// speed RTTY is worked at.
|
||||
public static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(50);
|
||||
|
||||
/// The longest the clock-paced 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 EngineBuffer? counter;
|
||||
private readonly Lock gate = new();
|
||||
|
||||
/// Held while a character or a flush is on its way to the engine, so the
|
||||
/// two cannot cross. Without it a flush overtook the character the feeder
|
||||
/// had already taken and was still sending, and that character went out
|
||||
/// behind the rest of the message.
|
||||
private readonly SemaphoreSlim handing = new(1, 1);
|
||||
private readonly StringBuilder pending = new();
|
||||
private readonly StringBuilder sent = new();
|
||||
|
||||
private CancellationTokenSource? stopping;
|
||||
private Task pump = Task.CompletedTask;
|
||||
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.
|
||||
/// True once `Transmit` has been called and until the message ends: the
|
||||
/// gate between what the operator has typed and the engine.
|
||||
private bool open;
|
||||
|
||||
/// True while no feeder is running, so anything waiting for the end of the
|
||||
/// message runs at once rather than waiting for a feeder that never starts.
|
||||
private bool idle = true;
|
||||
|
||||
/// True once there has been nothing left to hand to the engine, so the end
|
||||
/// of a message is announced once rather than at every poll after it.
|
||||
private bool given = true;
|
||||
|
||||
/// The engine's last answer: how many symbols 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;
|
||||
/// How many characters at the front of `sent` the engine has transmitted.
|
||||
private int aired;
|
||||
|
||||
/// The count the engine last answered, and how many symbols it has been
|
||||
/// seen to drop that have not been charged to a character yet. Together
|
||||
/// they are the air's own rate: a count that falls by four means four
|
||||
/// symbols left the engine.
|
||||
private int lastCount = -1;
|
||||
private int budget;
|
||||
|
||||
/// When the engine was last given a character. Its count reads 0 for the
|
||||
/// first 150 ms after a push, so a 0 within `CountLag` of one is the answer
|
||||
/// not having caught up rather than an empty engine.
|
||||
private DateTime gaveAt = DateTime.MinValue;
|
||||
|
||||
/// How many characters the engine holds that have not gone out yet, and
|
||||
/// when the one it is transmitting now is finished. The engine transmits at
|
||||
/// the baud rate, so what it holds goes out one character time apart. The
|
||||
/// feeder keeps these, and a flush adds to them in one go.
|
||||
///
|
||||
/// They outlive the feeder, which starts and stops with the text: a feeder
|
||||
/// that started after a flush and reset them forgot a whole message the
|
||||
/// engine was still holding, and the key went down in the middle of it.
|
||||
private int outstanding;
|
||||
private DateTime nextOut = DateTime.UtcNow;
|
||||
|
||||
/// Whether the engine is in the figures shift, on the air and at the front
|
||||
/// of the feeder. The two run apart: the feeder is `Ahead` characters in
|
||||
/// front of what is being transmitted.
|
||||
private bool airShift;
|
||||
private bool feedShift;
|
||||
|
||||
public TypeAhead(Func<char, CancellationToken, Task> send, double baud = DefaultBaud)
|
||||
{
|
||||
@@ -97,7 +161,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 the pump checks the clock against.
|
||||
/// left, which is what paces the feeder.
|
||||
public TypeAhead(EngineBuffer engine, double baud = DefaultBaud)
|
||||
: this((character, cancellation) => engine.TypeAsync(character, cancellation), baud)
|
||||
{
|
||||
@@ -105,23 +169,27 @@ public sealed class TypeAhead : IDisposable
|
||||
engine.Buffered += WhenBuffered;
|
||||
}
|
||||
|
||||
/// The speed the engine transmits at, which is what paces the pump.
|
||||
/// The speed the engine transmits at, which is what paces the feeder.
|
||||
public double Baud { get; set; }
|
||||
|
||||
/// How many characters may sit in the engine at once.
|
||||
public int Lead { get; set; } = DefaultLead;
|
||||
/// How many characters the engine is kept ahead by.
|
||||
public int Ahead { get; set; } = DefaultAhead;
|
||||
|
||||
/// True once the engine has said how much it holds. An engine that will not
|
||||
/// say is paced by the clock alone.
|
||||
/// How many symbols the engine may be behind before the feeder waits.
|
||||
public int Slack { get; set; } = DefaultSlack;
|
||||
|
||||
/// True once the engine has said how much it holds.
|
||||
public bool Counts { get; private set; }
|
||||
|
||||
public TimeSpan CharacterTime =>
|
||||
TimeSpan.FromSeconds(BitsPerCharacter / (Baud > 0 ? Baud : DefaultBaud));
|
||||
/// How long one Baudot symbol takes on the air. A character takes one of
|
||||
/// these, or two when the engine has to shift to figures or back first.
|
||||
public TimeSpan SymbolTime =>
|
||||
TimeSpan.FromSeconds(BitsPerSymbol / (Baud > 0 ? Baud : DefaultBaud));
|
||||
|
||||
/// How long the engine may hold what it has before the pump feeds it
|
||||
/// anyway. Three characters is longer than any gap between transmitted
|
||||
/// characters and short enough that a held word goes out at once.
|
||||
public TimeSpan HoldingPatience => CharacterTime * 3;
|
||||
/// How long the brake may hold before it lets go. Three character times is
|
||||
/// longer than any gap between transmitted characters and short enough that
|
||||
/// a word the engine is holding goes out at once.
|
||||
public TimeSpan HoldingPatience => SymbolTime * 3;
|
||||
|
||||
/// What has gone to the engine.
|
||||
public string Sent
|
||||
@@ -135,6 +203,30 @@ public sealed class TypeAhead : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// How many characters of `Sent` have gone out over the air.
|
||||
///
|
||||
/// The engine is kept `Ahead` characters ahead, so the last characters
|
||||
/// handed to it are still in its buffer. They cannot be taken back, but
|
||||
/// they have not been heard yet, so the pane draws them as text still to
|
||||
/// go. Without this, a character typed after the engine has caught up went
|
||||
/// red as it was typed: the feeder hands it over at once, and the buffer
|
||||
/// had no other measure of the air.
|
||||
///
|
||||
/// It is counted on the same clock as the feeder: the engine transmits at
|
||||
/// the baud rate, so a character handed to an engine that is already
|
||||
/// transmitting goes out one character time after the one before it, and a
|
||||
/// character handed to an empty engine one character time from now.
|
||||
public int OnAir
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
return Math.Min(aired, sent.Length);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What is still to go.
|
||||
public string Pending
|
||||
{
|
||||
@@ -147,22 +239,14 @@ 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.
|
||||
public int Cursor
|
||||
/// True while what is waiting is being fed to the engine.
|
||||
public bool IsTransmitting
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
return cursor;
|
||||
}
|
||||
}
|
||||
set
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
cursor = Math.Max(0, value);
|
||||
return open;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -179,18 +263,26 @@ public sealed class TypeAhead : IDisposable
|
||||
}
|
||||
|
||||
/// The text moved: a character went out, or a message was added. Raised on
|
||||
/// the pump's thread, so a handler that touches the screen has to post.
|
||||
/// the feeder's thread, so a handler that touches the screen has to post.
|
||||
public event EventHandler? Changed;
|
||||
|
||||
/// Everything that was waiting has been handed to the engine, which still
|
||||
/// holds the last `Ahead` characters of it. This is what tells the entry
|
||||
/// window that a message is finished, so what stands after `{END}` runs and
|
||||
/// `{RX}` reaches the engine while it has something left to send.
|
||||
public event EventHandler? Given;
|
||||
|
||||
/// 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.
|
||||
public event EventHandler? Drained;
|
||||
/// it, which is `Ahead` characters later than `Given`.
|
||||
public event EventHandler? Aired;
|
||||
|
||||
/// 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.
|
||||
/// over the other, and a function key pressed while the operator is typing
|
||||
/// follows what has been typed.
|
||||
///
|
||||
/// A function key is the operator asking for the message, so it also opens
|
||||
/// the gate: there is no second key to press.
|
||||
public void Append(string text)
|
||||
{
|
||||
if (text.Length == 0)
|
||||
@@ -200,38 +292,167 @@ public sealed class TypeAhead : IDisposable
|
||||
lock (gate)
|
||||
{
|
||||
pending.Append(text);
|
||||
if (cursor != NoCursor)
|
||||
{
|
||||
// text added behind the operator's cursor is still text to
|
||||
// send, so the cursor moves out with it
|
||||
cursor += text.Length;
|
||||
}
|
||||
open = true;
|
||||
given = false;
|
||||
}
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
Start();
|
||||
}
|
||||
|
||||
/// The operator rewrote what has not gone out yet.
|
||||
public void Rewrite(string text, int wanted)
|
||||
/// The operator rewrote the pane. `text` is the whole pane, what has gone
|
||||
/// out and what is still to go, and `changedAt` is where in it the first
|
||||
/// changed character is.
|
||||
///
|
||||
/// The text that has gone out cannot be changed, so as much of `text` as
|
||||
/// matches it is dropped and the rest becomes the pending text. Reading the
|
||||
/// whole pane rather than the pending half is what makes this safe against
|
||||
/// the feeder: a character the feeder took between the window reading the
|
||||
/// pane and this call is still at the front of `text`, and is dropped with
|
||||
/// the rest of what has gone out.
|
||||
///
|
||||
/// An edit does not open or close the gate. Typing before the transmitter
|
||||
/// is keyed stays off the air; typing while it is keyed goes out behind
|
||||
/// what is already going.
|
||||
public void Edit(string text)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
int gone = Math.Min(sent.Length, text.Length);
|
||||
pending.Clear();
|
||||
pending.Append(text);
|
||||
cursor = Math.Clamp(wanted, 0, text.Length);
|
||||
pending.Append(text, gone, text.Length - gone);
|
||||
given = given && pending.Length == 0;
|
||||
}
|
||||
Start();
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// The TX button, Ctrl+Enter and Alt+T: what is in the pane goes out, and
|
||||
/// so does whatever is added to it, until the transmitter drops.
|
||||
public void Transmit()
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
open = true;
|
||||
given = given && pending.Length == 0;
|
||||
}
|
||||
Start();
|
||||
}
|
||||
|
||||
/// The message is over: the transmitter has dropped, so the pane starts
|
||||
/// again with what has gone out cleared and whatever was typed ahead kept.
|
||||
public void Ended()
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
open = false;
|
||||
sent.Clear();
|
||||
aired = 0;
|
||||
outstanding = 0;
|
||||
}
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
/// A new transmission begins. What the last one sent is dropped from the
|
||||
/// pane, and what the operator typed ahead is kept. The gate is left as it
|
||||
/// is: the caller opens it with `Append` or `Transmit`.
|
||||
///
|
||||
/// `Ended` does the same at the end of a message, but only when the engine
|
||||
/// reports that the transmitter dropped. Two messages sent one after the
|
||||
/// other keep the transmitter up, so that report never comes, and without
|
||||
/// this the pane kept every message of the run and none of it could be
|
||||
/// edited: text that has gone to the engine cannot be taken back.
|
||||
public void Started()
|
||||
{
|
||||
bool had;
|
||||
lock (gate)
|
||||
{
|
||||
had = sent.Length > 0;
|
||||
sent.Clear();
|
||||
aired = 0;
|
||||
}
|
||||
if (had)
|
||||
{
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
/// The engine's own answer: how many symbols it still holds, or -1 before
|
||||
/// it has answered at all. It is in symbols, which is more than the
|
||||
/// characters it was given, because a digit costs a shift to figures and
|
||||
/// the letter after it a shift back.
|
||||
public int EngineHolds => Volatile.Read(ref counted);
|
||||
|
||||
/// Everything still waiting goes to the engine in one piece through `push`,
|
||||
/// and nothing more can be rewritten. Returns what was sent.
|
||||
///
|
||||
/// The feeder cannot be handing a character over at the same time: this
|
||||
/// takes the same turn the feeder takes, so a character already on its way
|
||||
/// arrives first and the rest follows it in order.
|
||||
public async Task<string> FlushAsync(
|
||||
Func<string, CancellationToken, Task> push,
|
||||
CancellationToken cancellation = default)
|
||||
{
|
||||
await handing.WaitAsync(cancellation).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
string rest = TakePending();
|
||||
if (rest.Length > 0)
|
||||
{
|
||||
await push(rest, cancellation).ConfigureAwait(false);
|
||||
}
|
||||
return rest;
|
||||
}
|
||||
finally
|
||||
{
|
||||
handing.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything still waiting, taken in one piece and marked as gone to the
|
||||
/// engine. The caller sends it, and nothing more can be rewritten.
|
||||
///
|
||||
/// `{RX}` uses this. N1MM hands MMTTY the whole message with `SendString`
|
||||
/// and asks it to stop 400 ms later, with the message still in the engine's
|
||||
/// buffer, and MMTTY ends the transmission itself at exactly the right
|
||||
/// moment. `SetMmttyPTT(1)` sent to an engine that has been fed one
|
||||
/// character at a time, and is therefore nearly empty, does nothing at all.
|
||||
private string TakePending()
|
||||
{
|
||||
string rest;
|
||||
lock (gate)
|
||||
{
|
||||
rest = pending.ToString();
|
||||
pending.Clear();
|
||||
sent.Append(rest);
|
||||
if (sent.Length > KeptSent)
|
||||
{
|
||||
int dropped = sent.Length - KeptSent;
|
||||
sent.Remove(0, dropped);
|
||||
aired = Math.Max(0, aired - dropped);
|
||||
}
|
||||
given = true;
|
||||
// inside the same lock as the pending text, so the feeder cannot
|
||||
// see an empty buffer and call the message over before the engine
|
||||
// is counted as holding what it was just given
|
||||
Gave(rest.Length, DateTime.UtcNow);
|
||||
}
|
||||
if (rest.Length > 0)
|
||||
{
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
return rest;
|
||||
}
|
||||
|
||||
/// Drops what has not gone out. Escape does this: what is already in the
|
||||
/// engine cannot be stopped from here, and the engine's own abort takes
|
||||
/// care of that.
|
||||
public void Drop()
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
pending.Clear();
|
||||
cursor = NoCursor;
|
||||
open = false;
|
||||
// the engine's own abort goes with this, so it holds nothing either
|
||||
outstanding = 0;
|
||||
}
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
@@ -244,7 +465,8 @@ public sealed class TypeAhead : IDisposable
|
||||
{
|
||||
pending.Clear();
|
||||
sent.Clear();
|
||||
cursor = NoCursor;
|
||||
aired = 0;
|
||||
open = false;
|
||||
}
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
@@ -258,101 +480,291 @@ public sealed class TypeAhead : IDisposable
|
||||
stopping?.Cancel();
|
||||
stopping?.Dispose();
|
||||
stopping = null;
|
||||
handing.Dispose();
|
||||
}
|
||||
|
||||
private void Start()
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
if (!pump.IsCompleted)
|
||||
if (!idle)
|
||||
{
|
||||
return;
|
||||
}
|
||||
idle = false;
|
||||
stopping?.Dispose();
|
||||
stopping = new CancellationTokenSource();
|
||||
pump = Task.Run(() => RunAsync(stopping.Token));
|
||||
_ = Task.Run(() => RunAsync(stopping.Token));
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// Hands the engine a character every character time, keeping it `Ahead`
|
||||
/// characters ahead of the air, unless its own count says it is more than
|
||||
/// `Slack` symbols behind and still moving.
|
||||
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;
|
||||
// when the next character is owed. It advances by exactly one character
|
||||
// time per character handed over, never from the time the poll happened:
|
||||
// a poll is up to `PollInterval` late, and starting the next character
|
||||
// from there made every one late by a little and the engine run dry
|
||||
DateTime due = DateTime.MinValue;
|
||||
DateTime moved = DateTime.UtcNow;
|
||||
int last = int.MaxValue;
|
||||
try
|
||||
{
|
||||
while (!cancellation.IsCancellationRequested)
|
||||
{
|
||||
DateTime now = DateTime.UtcNow;
|
||||
while (inEngine > 0 && now >= nextOut)
|
||||
if (counter is not null)
|
||||
{
|
||||
inEngine--;
|
||||
nextOut += CharacterTime;
|
||||
}
|
||||
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;
|
||||
}
|
||||
DateTime now = DateTime.UtcNow;
|
||||
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)
|
||||
bool wentOut = Counts ? WentOutByCount(left, now) : WentOutByClock(now);
|
||||
if (wentOut)
|
||||
{
|
||||
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 && left <= 0 && !IsSending)
|
||||
bool behind = left > Slack && now - moved < HoldingPatience;
|
||||
// the feeder owes at most `Ahead` characters at any moment,
|
||||
// so a start, a brake letting go or an empty pane does not turn
|
||||
// into a burst that puts the whole message beyond reach
|
||||
DateTime earliest = now - (SymbolTime * (Ahead - 1));
|
||||
if (due < earliest)
|
||||
{
|
||||
due = earliest;
|
||||
}
|
||||
await handing.WaitAsync(cancellation).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
while (!behind && now >= due && Take(now) is { } next)
|
||||
{
|
||||
due += SymbolTime * Symbols(next, ref feedShift);
|
||||
await send(next, cancellation).ConfigureAwait(false);
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
handing.Release();
|
||||
}
|
||||
if (NothingLeftToGive())
|
||||
{
|
||||
Given?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
// the engine is still holding what it has not transmitted, so
|
||||
// the message is not over and the last characters of it have
|
||||
// not been marked as gone out yet
|
||||
if (left <= 0 && Outstanding == 0 && now >= due && AllAired())
|
||||
{
|
||||
Drained?.Invoke(this, EventArgs.Empty);
|
||||
return;
|
||||
}
|
||||
await Task.Delay(Tick, cancellation).ConfigureAwait(false);
|
||||
await Task.Delay(PollInterval, cancellation).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
finally
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
idle = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private TimeSpan Tick
|
||||
/// Everything waiting has gone out and the engine has transmitted it, or
|
||||
/// the gate has closed on what is left. `Aired` says so. False when text
|
||||
/// arrived while the feeder was deciding, which is what keeps a message
|
||||
/// added at the last moment from being stranded.
|
||||
private bool AllAired()
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
if (open && pending.Length > 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
idle = true;
|
||||
}
|
||||
Aired?.Invoke(this, EventArgs.Empty);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// True the first time there is nothing left to hand to the engine, and
|
||||
/// false again once more text arrives.
|
||||
private bool NothingLeftToGive()
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
if (given || (open && pending.Length > 0))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
given = true;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// One character the engine held has gone out, on the clock. False when it
|
||||
/// is still transmitting it or holds nothing.
|
||||
///
|
||||
/// The character that goes out is the first one that has not, which is
|
||||
/// `sent[aired]`, and what it costs says when the one after it is due.
|
||||
/// What the engine has transmitted, from the engine itself. Its count is in
|
||||
/// symbols and falls as they go out, so what it drops between two answers
|
||||
/// is what went on the air between them. That is the air's own rate, and
|
||||
/// nothing here has to know what a character costs to follow it: the
|
||||
/// symbols are spent on the characters at the front of what has not gone
|
||||
/// out, at whatever `Symbols` says they cost.
|
||||
///
|
||||
/// A count of 0 is the end of it: the engine holds nothing, so everything
|
||||
/// it was given has gone out, whatever the symbols added up to. That is
|
||||
/// what makes a wrong guess about the shift correct itself every message
|
||||
/// rather than accumulating.
|
||||
///
|
||||
/// The count reads 0 for the first 150 ms after a push, so a 0 that new is
|
||||
/// passed over: it is the answer trailing what the engine was given, not an
|
||||
/// engine that has transmitted it.
|
||||
private bool WentOutByCount(int symbols, DateTime now)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
if (symbols < 0 || (symbols == 0 && now - gaveAt < CountLag))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (lastCount > symbols)
|
||||
{
|
||||
budget += lastCount - symbols;
|
||||
}
|
||||
lastCount = symbols;
|
||||
bool moved = false;
|
||||
while (aired < sent.Length && outstanding > 0 && budget >= NextSymbols())
|
||||
{
|
||||
budget -= NextSymbols();
|
||||
OneOut();
|
||||
moved = true;
|
||||
}
|
||||
if (symbols == 0)
|
||||
{
|
||||
budget = 0;
|
||||
while (aired < sent.Length && outstanding > 0)
|
||||
{
|
||||
OneOut();
|
||||
moved = true;
|
||||
}
|
||||
}
|
||||
return moved;
|
||||
}
|
||||
}
|
||||
|
||||
/// One character off the front of what has not gone out. The caller holds
|
||||
/// `gate`.
|
||||
private void OneOut()
|
||||
{
|
||||
bool figures = airShift;
|
||||
Symbols(sent[aired], ref figures);
|
||||
airShift = figures;
|
||||
aired++;
|
||||
outstanding--;
|
||||
}
|
||||
|
||||
/// What the engine has transmitted, on the clock, for an engine that does
|
||||
/// not count.
|
||||
private bool WentOutByClock(DateTime now)
|
||||
{
|
||||
bool moved = false;
|
||||
while (WentOut(now))
|
||||
{
|
||||
moved = true;
|
||||
}
|
||||
return moved;
|
||||
}
|
||||
|
||||
private bool WentOut(DateTime now)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
if (outstanding <= 0 || now < nextOut || aired >= sent.Length)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
bool figures = airShift;
|
||||
Symbols(sent[aired], ref figures);
|
||||
airShift = figures;
|
||||
outstanding--;
|
||||
aired++;
|
||||
nextOut += SymbolTime * NextSymbols();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Marks everything up to `mark` as gone out, whatever the clock had
|
||||
/// reached. True when that moved. The caller holds `gate`.
|
||||
private bool AirTo(int mark, DateTime now)
|
||||
{
|
||||
if (mark <= aired)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
while (aired < mark && aired < sent.Length)
|
||||
{
|
||||
bool figures = airShift;
|
||||
Symbols(sent[aired], ref figures);
|
||||
airShift = figures;
|
||||
aired++;
|
||||
outstanding = Math.Max(0, outstanding - 1);
|
||||
}
|
||||
nextOut = now + SymbolTime * NextSymbols();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// How many symbols the character now at the front of what has not gone out
|
||||
/// takes. The caller holds `gate`.
|
||||
private int NextSymbols()
|
||||
{
|
||||
bool figures = airShift;
|
||||
return aired < sent.Length ? Symbols(sent[aired], ref figures) : 1;
|
||||
}
|
||||
|
||||
/// `count` more characters are in the engine as of `now`. The caller holds
|
||||
/// `gate`: what the engine holds and what is still to go are one fact and
|
||||
/// are written together.
|
||||
private void Gave(int count, DateTime now)
|
||||
{
|
||||
if (count <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (outstanding == 0)
|
||||
{
|
||||
nextOut = now + (SymbolTime * NextSymbols());
|
||||
}
|
||||
outstanding += count;
|
||||
// the count reads 0 for the first 150 ms after a push, so a 0 from here
|
||||
// on is the answer not having caught up rather than an empty engine.
|
||||
// What it has already been seen to drop still stands: those symbols
|
||||
// went out whatever is given after them.
|
||||
gaveAt = now;
|
||||
}
|
||||
|
||||
/// How many characters the engine has been given that have not gone out
|
||||
/// yet, counted on the clock rather than on the engine's own answer.
|
||||
public int Outstanding
|
||||
{
|
||||
get
|
||||
{
|
||||
TimeSpan quarter = CharacterTime / 4;
|
||||
return quarter < LongestTick ? quarter : LongestTick;
|
||||
lock (gate)
|
||||
{
|
||||
return outstanding;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -360,29 +772,60 @@ public sealed class TypeAhead : IDisposable
|
||||
{
|
||||
Counts = left >= 0;
|
||||
Volatile.Write(ref counted, left);
|
||||
Interlocked.Increment(ref answers);
|
||||
}
|
||||
|
||||
/// How many symbols `character` takes, and the shift it leaves the engine
|
||||
/// in. A letter sent while the engine is in figures costs a shift symbol
|
||||
/// and the character, and the same the other way.
|
||||
///
|
||||
/// This is why the clock alone ran ahead of the air. It paced one character
|
||||
/// every symbol time, and a callsign with a digit in it takes more than
|
||||
/// that: 26 characters of one CQ went out in 29 symbols, one part in nine
|
||||
/// slower than the clock thought.
|
||||
///
|
||||
/// A space is taken to put the engine back in letters, which is
|
||||
/// unshift-on-space. It is a setting — `TXUOS` in MMTTY's `UserPara.ini`,
|
||||
/// per profile, and a button on its own display that the operator can press
|
||||
/// mid-contest — so this cannot be read once and believed. It is assumed on
|
||||
/// because MMTTY's help says that is the usual setting, and because being
|
||||
/// wrong that way charges a symbol too many and leaves the pane behind the
|
||||
/// air rather than in front of it. `AllowedOutstanding` is what corrects
|
||||
/// the rest.
|
||||
private static int Symbols(char character, ref bool figures)
|
||||
{
|
||||
if (character is ' ' or '\r' or '\n')
|
||||
{
|
||||
figures = false;
|
||||
return 1;
|
||||
}
|
||||
bool wants = !char.IsAsciiLetter(character);
|
||||
if (wants == figures)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
figures = wants;
|
||||
return 2;
|
||||
}
|
||||
|
||||
/// The next character to send, or null when there is none to send now:
|
||||
/// nothing is waiting, or what is waiting is behind the cursor.
|
||||
private char? Take()
|
||||
/// nothing is waiting, or the gate is shut.
|
||||
private char? Take(DateTime now)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
if (pending.Length == 0 || cursor == 0)
|
||||
if (!open || pending.Length == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
char next = pending[0];
|
||||
pending.Remove(0, 1);
|
||||
if (cursor != NoCursor)
|
||||
{
|
||||
cursor--;
|
||||
}
|
||||
sent.Append(next);
|
||||
Gave(1, now);
|
||||
if (sent.Length > KeptSent)
|
||||
{
|
||||
sent.Remove(0, sent.Length - KeptSent);
|
||||
int dropped = sent.Length - KeptSent;
|
||||
sent.Remove(0, dropped);
|
||||
aired = Math.Max(0, aired - dropped);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
@@ -12,17 +12,28 @@ public sealed class WineBridgeChannel : BridgeChannel
|
||||
private readonly string wine;
|
||||
private readonly string bridgePath;
|
||||
private readonly string? prefix;
|
||||
private readonly string? logFolder;
|
||||
private readonly Queue<string> lastErrors = new();
|
||||
private readonly SemaphoreSlim writing = new(1, 1);
|
||||
private readonly Lock writingLog = new();
|
||||
private Process? bridge;
|
||||
private StreamWriter? log;
|
||||
private DateTime started;
|
||||
private int lastCount = int.MinValue;
|
||||
|
||||
/// `prefix` is the WINEPREFIX to run in. Left null, Wine uses its default,
|
||||
/// which is what a station with one prefix wants.
|
||||
public WineBridgeChannel(string bridgePath, string? prefix = null, string wine = "wine")
|
||||
/// which is what a station with one prefix wants. `logFolder`, when it is
|
||||
/// given, is where the protocol log for this run is written.
|
||||
public WineBridgeChannel(
|
||||
string bridgePath,
|
||||
string? prefix = null,
|
||||
string wine = "wine",
|
||||
string? logFolder = null)
|
||||
{
|
||||
this.bridgePath = bridgePath;
|
||||
this.prefix = prefix;
|
||||
this.wine = wine;
|
||||
this.logFolder = logFolder;
|
||||
}
|
||||
|
||||
public event EventHandler<string>? LineReceived;
|
||||
@@ -31,6 +42,7 @@ public sealed class WineBridgeChannel : BridgeChannel
|
||||
|
||||
public Task StartAsync(CancellationToken cancellation = default)
|
||||
{
|
||||
OpenLog();
|
||||
bridge = Start(bridgePath);
|
||||
_ = ReadOutputAsync(bridge);
|
||||
_ = ReadErrorsAsync(bridge);
|
||||
@@ -46,6 +58,7 @@ public sealed class WineBridgeChannel : BridgeChannel
|
||||
await running.StandardInput.WriteLineAsync(line.AsMemory(), cancellation)
|
||||
.ConfigureAwait(false);
|
||||
await running.StandardInput.FlushAsync(cancellation).ConfigureAwait(false);
|
||||
Log(">", line);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -84,6 +97,71 @@ public sealed class WineBridgeChannel : BridgeChannel
|
||||
}
|
||||
bridge?.Dispose();
|
||||
writing.Dispose();
|
||||
lock (writingLog)
|
||||
{
|
||||
log?.Dispose();
|
||||
log = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// One file per run of the engine, named for when it started. It is the
|
||||
/// whole protocol with a millisecond stamp on every line, which is what
|
||||
/// says who keyed the transmitter and when.
|
||||
private void OpenLog()
|
||||
{
|
||||
if (logFolder is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(logFolder);
|
||||
started = DateTime.Now;
|
||||
log = new StreamWriter(
|
||||
Path.Combine(logFolder, $"digital-{started:yyyyMMdd-HHmmss}.log"),
|
||||
append: false)
|
||||
{
|
||||
AutoFlush = true,
|
||||
};
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// a log that cannot be written stops nothing
|
||||
log = null;
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
log = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// The buffer question goes out every 50 ms and is answered just as often,
|
||||
/// which would bury everything else. The question is left out and the
|
||||
/// answer is written only when the count has changed.
|
||||
private void Log(string direction, string line)
|
||||
{
|
||||
if (log is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
(string verb, string[] fields) = BridgeLine.Read(line);
|
||||
if (verb == "buffer")
|
||||
{
|
||||
if (direction == ">" || fields.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (!int.TryParse(fields[0], out int count) || count == lastCount)
|
||||
{
|
||||
return;
|
||||
}
|
||||
lastCount = count;
|
||||
}
|
||||
lock (writingLog)
|
||||
{
|
||||
log?.WriteLine(
|
||||
$"{(DateTime.Now - started).TotalMilliseconds,9:0} ms {direction} {line.Replace('\t', ' ')}");
|
||||
}
|
||||
}
|
||||
|
||||
private Process Start(string program, params string[] arguments)
|
||||
@@ -113,6 +191,7 @@ public sealed class WineBridgeChannel : BridgeChannel
|
||||
{
|
||||
while (await running.StandardOutput.ReadLineAsync().ConfigureAwait(false) is { } line)
|
||||
{
|
||||
Log("<", line);
|
||||
LineReceived?.Invoke(this, line);
|
||||
}
|
||||
await running.WaitForExitAsync().ConfigureAwait(false);
|
||||
|
||||
Reference in New Issue
Block a user