feat(tci): KEYER — чужой ключ очередью элементов, а не сетевыми фронтами

Последняя невыполненная команда протокола. Ключ к ней в том, что arg3 —
длительность интервала, который ТОЛЬКО ЧТО кончился, а не начинающегося.
Документ задаёт это алгоритмом: первое нажатие keyer:0,true,0, отпускание
keyer:0,false,142 («посылка длилась 142 мс»), следующее нажатие
keyer:0,true,58 («пауза длилась 58 мс»). Отсюда перевод: тип элемента — это
состояние ключа ДО фронта, то есть обратное пришедшему; arg3 = 0 играть нечего.

Почему не дёргать ключ по приходу пакета: приход говорит, что интервал
кончился, а не сколько он длился, и манипуляция «по приходу» — это сетевой
джиттер прямо в эфир, тот самый «пьяный матрос», ради которого третий аргумент
в протоколе и появился. Новый TCWElemPlayer (CWMorse.pas) держит очередь
элементов и играет их подряд по абсолютным дедлайнам: сумма длительностей
равна времени у клиента, значит отставание постоянно (сеть + один элемент) и
не накапливается. Очередь опустела — ключ отпускается и следующая пачка
начинается с чистого дедлайна: элементы чередуются, искажения нет, зато
оборвавшийся клиент не оставляет в эфире несущую.

Контроллер: CWKeyerElement(Mark, Ms) ставит элемент в очередь, CWElemKey
раздаёт фронты — чужая манипуляция это прямой ключ с точными длительностями,
поэтому у Pluto она идёт во вход прямого ключа локального генератора (он сам
поднимает сессию, рисует огибающую и сайдтон), а у openHPSDR в бит CWX
прошивки (тем же путём идёт передача текста). Гейт CWTXActive, как у CWXSend;
обрыв общий с текстом — касание манипулятора, снятие MOX и уход из телеграфа
гасят чужую манипуляцию тем же CWXAbort. Передачу KEYER не поднимает: при
break-in PTT даёт прошивка (или сессия генератора), без него оператор держит
MOX сам.

Адаптер: номер передатчика разбирается как у TRX (bad receiver / receiver is
not running), захват §3.5 общий с TRX — передатчик один, и ключ держит тот же,
кто держит эфир. Паузы обрезаются TCI_KEYER_GAP_MAX_MS = 1 с (пауза целиком
прибавляется к отставанию от клиента, а дольше секунды — это «оператор
задумался», и честнее догнать реальное время), посылки — 5 с.

Стенд test/tci: 238 проверок (было 219). Новая часть C2 меряет ДЛИТЕЛЬНОСТИ по
фронтам ключа (посылка 150 / пауза 60 / посылка 150, допуск 30 мс), проверяет
отпускание на пустой очереди, старт следующей пачки без «догона» дедлайна,
обрыв и нулевую длительность; в части D — разбор аргументов команды и сквозная
проверка, что keyer:0,true,<мс> ключ не замыкает (это пауза), а
keyer:0,false,<мс> замыкает. Негативный контроль на инверсию перевода.
★Локальный генератор вооружается только при живом устройстве, поэтому сквозная
проверка подставляет FDevConnected/FRunning на время.

doc/TCI.md: §2.6 описывает команду целиком, §3.1 и §4 переписаны под то, что из
пары KEYER/TX_FOOTSWITCH остался только второй; попутно убран устаревший абзац
§2.3 про «потоки — этап 2».

На железе с настоящим ключом по сети ещё не гонялось.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 22:46:37 +03:00
co-authored by Claude Opus 5
parent 66892fc7e0
commit 84b90b23c0
6 changed files with 598 additions and 26 deletions
+221
View File
@@ -64,6 +64,68 @@ type
function Backspace: Boolean;
end;
{ Элемент манипуляции с ЗАДАННОЙ длительностью: посылка (Mark = True) или
пауза. Из таких элементов состоит чужая манипуляция, пришедшая по сети —
у неё нет ни скорости, ни веса, есть только измеренные интервалы. }
TCWElement = record
Mark: Boolean;
Ms: Integer;
end;
{ Проигрыватель чужой манипуляции: очередь элементов → тот же колбэк ключа.
ЗАЧЕМ ОТДЕЛЬНО ОТ TCWSender. Текст мы разбираем сами и знаем скорость; здесь
наоборот — знаки не разбираются вовсе, а длительности приходят готовыми
(команда TCI KEYER: клиент замеряет свой ключ и присылает длину КАЖДОГО
завершившегося интервала). Проиграть их надо ровно так, как они были нажаты.
★Почему нельзя просто дёргать ключ по приходу пакета. Между клиентом и нами
сеть: приход пакета говорит, что интервал КОНЧИЛСЯ, а не сколько он длился.
Манипуляция «по приходу» — это сетевой джиттер прямо в эфир, тот самый
«пьяный матрос», ради которого в протоколе и заведён третий аргумент.
Поэтому элементы становятся в очередь и играются ПОДРЯД по абсолютным
дедлайнам: сумма длительностей равна времени у клиента, значит отставание
постоянно (сеть + один элемент) и не накапливается.
Очередь опустела — ключ отпускаем и начинаем следующую пачку с чистого
дедлайна: элементы всегда чередуются (посылка, пауза, посылка…), так что
отпускание на пустой очереди ничего не искажает, зато не оставляет в эфире
несущую, если клиент замолчал или отвалился. }
TCWElemPlayer = class(TThread)
private
FKeyEvent: TCWKeyEvent;
FLock: TCriticalSection;
FWake: TEvent;
FQ: array of TCWElement; // кольцо (под FLock)
FHead: Integer;
FCount: Integer;
FAbortReq: Boolean;
FBusy: Boolean;
FDown: Boolean; // состояние ключа (только поток)
procedure Key(Down: Boolean);
function Take(out E: TCWElement): Boolean;
function Aborted: Boolean;
function Hold(Deadline: QWord): Boolean;
protected
procedure Execute; override;
public
constructor Create(AKeyEvent: TCWKeyEvent);
destructor Destroy; override;
{ Поставить элемент в очередь. Слишком длинные обрезаются вызывающим —
здесь принимается то, что дали. }
procedure Enqueue(Mark: Boolean; Ms: Integer);
{ Бросить очередь и отпустить ключ (оператор тронул манипулятор, снят MOX). }
procedure AbortPlay;
function Busy: Boolean;
function Pending: Integer;
end;
// Сколько элементов держим в очереди. Живая манипуляция даёт ~30 элементов в
// секунду, так что это секунды звука: клиент, сыплющий быстрее, чем играется,
// нам не друг, и лишнее просто отбрасывается.
const
CW_ELEM_QUEUE_MAX = 512;
// Код знака: строка из '.' и '-'. Пустая — знака нет в таблице (пропускаем,
// иначе опечатка в макросе превратилась бы в мусор в эфире).
function MorseCode(Ch: Char): string;
@@ -379,4 +441,163 @@ begin
Key(False);
end;
{ ═══════════════════════════════════════════════════════════════════════════
Проигрыватель чужой манипуляции
═══════════════════════════════════════════════════════════════════════════ }
constructor TCWElemPlayer.Create(AKeyEvent: TCWKeyEvent);
begin
FKeyEvent := AKeyEvent;
FLock := TCriticalSection.Create;
FWake := TEvent.Create(nil, False, False, '');
SetLength(FQ, CW_ELEM_QUEUE_MAX);
FreeOnTerminate := False;
inherited Create(False);
end;
destructor TCWElemPlayer.Destroy;
begin
Terminate;
FWake.SetEvent;
WaitFor;
FWake.Free;
FLock.Free;
inherited Destroy;
end;
procedure TCWElemPlayer.Enqueue(Mark: Boolean; Ms: Integer);
var i: Integer;
begin
if Ms <= 0 then Exit;
FLock.Enter;
try
// Очередь переполнена — молча теряем новый элемент. Ронять уже принятую
// манипуляцию из-за захлебнувшегося клиента незачем.
if FCount >= Length(FQ) then Exit;
i := (FHead + FCount) mod Length(FQ);
FQ[i].Mark := Mark;
FQ[i].Ms := Ms;
Inc(FCount);
FAbortReq := False;
FBusy := True;
finally
FLock.Leave;
end;
FWake.SetEvent;
end;
function TCWElemPlayer.Take(out E: TCWElement): Boolean;
begin
Result := False;
FLock.Enter;
try
if FAbortReq or (FCount = 0) then Exit;
E := FQ[FHead];
FHead := (FHead + 1) mod Length(FQ);
Dec(FCount);
Result := True;
finally
FLock.Leave;
end;
end;
procedure TCWElemPlayer.AbortPlay;
begin
FLock.Enter;
try
FAbortReq := True;
FHead := 0;
FCount := 0;
finally
FLock.Leave;
end;
FWake.SetEvent;
end;
function TCWElemPlayer.Aborted: Boolean;
begin
FLock.Enter;
try
Result := FAbortReq;
finally
FLock.Leave;
end;
end;
function TCWElemPlayer.Busy: Boolean;
begin
FLock.Enter;
try
Result := FBusy;
finally
FLock.Leave;
end;
end;
function TCWElemPlayer.Pending: Integer;
begin
FLock.Enter;
try
Result := FCount;
finally
FLock.Leave;
end;
end;
procedure TCWElemPlayer.Key(Down: Boolean);
begin
if Down = FDown then Exit; // лишних фронтов в эфир не шлём
FDown := Down;
if Assigned(FKeyEvent) then FKeyEvent(Down);
end;
function TCWElemPlayer.Hold(Deadline: QWord): Boolean;
// Тот же приём, что у TCWSender: абсолютный дедлайн и дробный сон, чтобы обрыв
// не ждал конца длинного тире.
var Now_: QWord; Slice: Integer;
begin
repeat
if Terminated or Aborted then Exit(False);
Now_ := GetTickCount64;
if Now_ >= Deadline then Exit(True);
Slice := Integer(Deadline - Now_);
if Slice > 5 then Slice := 5;
Sleep(Slice);
until False;
end;
procedure TCWElemPlayer.Execute;
var
E: TCWElement;
Deadline: QWord;
begin
Deadline := 0;
while not Terminated do
begin
FWake.WaitFor(200);
if Terminated then Break;
while Take(E) do
begin
// Первая пачка (или после простоя) начинается «сейчас»: догонять прошлое
// нечего, а старый дедлайн проиграл бы очередь одним махом.
if (Deadline = 0) or (GetTickCount64 > Deadline) then
Deadline := GetTickCount64;
Key(E.Mark);
Inc(Deadline, QWord(E.Ms));
if not Hold(Deadline) then Break;
end;
// Очередь кончилась (или её бросили) — ключ отпускаем всегда: несущая без
// хозяина в эфире не остаётся, а следующая пачка начнётся заново.
Key(False);
Deadline := 0;
FLock.Enter;
try
if FCount = 0 then FBusy := False;
finally
FLock.Leave;
end;
end;
Key(False);
end;
end.