Files
ewsdr/CWMorse.pas
T
ew8bakandClaude Opus 5 84b90b23c0 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>
2026-08-19 22:46:37 +03:00

604 lines
20 KiB
ObjectPascal
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
unit CWMorse;
{$mode objfpc}{$H+}
// ---------------------------------------------------------------------------
// Программная передача телеграфа: текст → точки/тире → ключ.
//
// Кто держит тайминги. У openHPSDR P2 есть ДВА пути в эфир:
// * манипулятор в разъёме трансивера — элементы формирует кейер в FPGA, PC
// только отдаёт ему скорость/вес (DUC Specific байт 5..10). Джиттера нет;
// * бит CWX в High Priority — это «ключ нажат», сырая линия. Прошивка по
// нему даёт несущую с той же формой фронта (CWRampPeriod), но КОГДА
// нажать и отпустить, решает PC. Именно этот путь используется для
// передачи текста (макросы, CAT KY) — так же устроен Thetis (cwx.cs).
//
// Отсюда устройство юнита: отдельный поток, который спит до дедлайнов и
// дёргает колбэк ключа. Дедлайны АБСОЛЮТНЫЕ (GetTickCount64), иначе ошибка
// каждого сна накапливалась бы и знак «плыл» бы по длине.
// ---------------------------------------------------------------------------
interface
uses
Classes, SysUtils, SyncObjs;
type
// Вызывается из потока передачи на каждом фронте ключа.
TCWKeyEvent = procedure(Down: Boolean) of object;
TCWSender = class(TThread)
private
FKeyEvent: TCWKeyEvent;
FLock: TCriticalSection;
FWake: TEvent;
FPending: string; // очередь текста (под FLock)
FAbortReq: Boolean; // «бросить и отпустить ключ» (под FLock)
FBusy: Boolean; // идёт передача (под FLock)
FWPM: Integer; // под FLock
FWeight: Integer; // 33..66, 50 = симметрично (под FLock)
// Эхо для окна-терминала: ушедшее в эфир и хвост очереди (под FLock).
FSent: string;
FUnsent: string;
FTrimTail: Boolean; // Backspace дотянулся до уже взятого текста
function TakeText: string;
function Aborted: Boolean;
procedure Key(Down: Boolean);
// Выдержать Ms миллисекунд, просыпаясь дробно ради отзывчивости на abort.
// Возвращает False, если передачу оборвали.
function Hold(Deadline: QWord): Boolean;
procedure SendChar(Ch: Char; var Deadline: QWord);
protected
procedure Execute; override;
public
constructor Create(AKeyEvent: TCWKeyEvent);
destructor Destroy; override;
procedure Enqueue(const S: string);
procedure AbortSending;
procedure SetSpeed(WPM, Weight: Integer);
function Busy: Boolean;
// Терминал: знаки, ушедшие в эфир с прошлого опроса; хвост очереди; стереть
// последний ненабранный знак. Смысл тот же, что у TCWLocalKeyer.
function TakeSentText: string;
function PendingText: string;
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;
implementation
const
// Точка при 1 WPM = 1200 мс (стандарт PARIS).
DIT_MS_AT_1WPM = 1200;
function MorseCode(Ch: Char): string;
begin
case UpCase(Ch) of
'A': Result := '.-'; 'B': Result := '-...'; 'C': Result := '-.-.';
'D': Result := '-..'; 'E': Result := '.'; 'F': Result := '..-.';
'G': Result := '--.'; 'H': Result := '....'; 'I': Result := '..';
'J': Result := '.---'; 'K': Result := '-.-'; 'L': Result := '.-..';
'M': Result := '--'; 'N': Result := '-.'; 'O': Result := '---';
'P': Result := '.--.'; 'Q': Result := '--.-'; 'R': Result := '.-.';
'S': Result := '...'; 'T': Result := '-'; 'U': Result := '..-';
'V': Result := '...-'; 'W': Result := '.--'; 'X': Result := '-..-';
'Y': Result := '-.--'; 'Z': Result := '--..';
'0': Result := '-----'; '1': Result := '.----'; '2': Result := '..---';
'3': Result := '...--'; '4': Result := '....-'; '5': Result := '.....';
'6': Result := '-....'; '7': Result := '--...'; '8': Result := '---..';
'9': Result := '----.';
'.': Result := '.-.-.-'; ',': Result := '--..--'; '?': Result := '..--..';
'/': Result := '-..-.'; '=': Result := '-...-'; '+': Result := '.-.-.';
'-': Result := '-....-'; '(': Result := '-.--.'; ')': Result := '-.--.-';
':': Result := '---...'; '''':Result := '.----.'; '"': Result := '.-..-.';
'@': Result := '.--.-.'; '!': Result := '-.-.--'; ';': Result := '-.-.-.';
'$': Result := '...-..-';
else
Result := '';
end;
end;
constructor TCWSender.Create(AKeyEvent: TCWKeyEvent);
begin
FKeyEvent := AKeyEvent;
FLock := TCriticalSection.Create;
FWake := TEvent.Create(nil, False, False, '');
FWPM := 20;
FWeight := 50;
FreeOnTerminate := False;
inherited Create(False);
end;
destructor TCWSender.Destroy;
begin
Terminate;
FWake.SetEvent;
WaitFor;
FWake.Free;
FLock.Free;
inherited Destroy;
end;
procedure TCWSender.Enqueue(const S: string);
begin
if S = '' then Exit;
FLock.Enter;
try
FAbortReq := False;
FPending := FPending + S;
finally
FLock.Leave;
end;
FWake.SetEvent;
end;
procedure TCWSender.AbortSending;
begin
FLock.Enter;
try
FPending := '';
FAbortReq := True;
finally
FLock.Leave;
end;
FWake.SetEvent;
end;
procedure TCWSender.SetSpeed(WPM, Weight: Integer);
begin
if WPM < 5 then WPM := 5;
if WPM > 60 then WPM := 60;
if Weight < 33 then Weight := 33;
if Weight > 66 then Weight := 66;
FLock.Enter;
try
FWPM := WPM;
FWeight := Weight;
finally
FLock.Leave;
end;
end;
function TCWSender.Busy: Boolean;
begin
FLock.Enter;
try
Result := FBusy or (FPending <> '');
finally
FLock.Leave;
end;
end;
function TCWSender.TakeSentText: string;
begin
FLock.Enter;
try
Result := FSent;
FSent := '';
finally
FLock.Leave;
end;
end;
function TCWSender.PendingText: string;
begin
FLock.Enter;
try
Result := FUnsent + FPending;
finally
FLock.Leave;
end;
end;
function TCWSender.Backspace: Boolean;
// Стираем с хвоста очереди: то, что уже звучит, вернуть нельзя.
begin
FLock.Enter;
try
Result := False;
if FPending <> '' then
begin
SetLength(FPending, Length(FPending) - 1);
Result := True;
end
else if FUnsent <> '' then
begin
SetLength(FUnsent, Length(FUnsent) - 1);
FTrimTail := True;
Result := True;
end;
finally
FLock.Leave;
end;
end;
function TCWSender.TakeText: string;
begin
FLock.Enter;
try
Result := FPending;
FPending := '';
finally
FLock.Leave;
end;
end;
function TCWSender.Aborted: Boolean;
begin
FLock.Enter;
try
Result := FAbortReq;
finally
FLock.Leave;
end;
end;
procedure TCWSender.Key(Down: Boolean);
begin
if Assigned(FKeyEvent) then FKeyEvent(Down);
end;
function TCWSender.Hold(Deadline: QWord): Boolean;
var Now_: QWord; Slice: Integer;
begin
Result := True;
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 TCWSender.SendChar(Ch: Char; var Deadline: QWord);
// Deadline — момент, к которому знак должен закончиться; ведём его вперёд,
// чтобы длительности не «уползали» на ошибке каждого Sleep.
var
Code: string;
i, Dit, Mark, Gap, WPM, Weight: Integer;
begin
FLock.Enter;
try
WPM := FWPM;
Weight := FWeight;
finally
FLock.Leave;
end;
Dit := DIT_MS_AT_1WPM div WPM;
// Вес: 50 = точка равна паузе. Выше — посылки длиннее за счёт пауз внутри
// знака (межзнаковые интервалы остаются стандартными, иначе «плывёт» темп).
Mark := (Dit * Weight) div 50;
Gap := (Dit * (100 - Weight)) div 50;
if Mark < 1 then Mark := 1;
if Gap < 1 then Gap := 1;
if Ch = ' ' then
begin
// Межсловный интервал = 7 точек; 3 из них уже отданы после прошлого знака.
Inc(Deadline, 4 * Dit);
Hold(Deadline);
Exit;
end;
Code := MorseCode(Ch);
if Code = '' then Exit;
for i := 1 to Length(Code) do
begin
Key(True);
if Code[i] = '-' then Inc(Deadline, 3 * Mark) else Inc(Deadline, Mark);
if not Hold(Deadline) then begin Key(False); Exit; end;
Key(False);
Inc(Deadline, Gap); // пауза между элементами знака
if not Hold(Deadline) then Exit;
end;
Inc(Deadline, 2 * Dit); // добор до 3 точек между знаками
Hold(Deadline);
end;
procedure TCWSender.Execute;
var
Text: string;
i, KeepLen: Integer;
Trim: Boolean;
Deadline: QWord;
begin
while not Terminated do
begin
FWake.WaitFor(200);
if Terminated then Break;
Text := TakeText;
if Text = '' then Continue;
FLock.Enter;
try
FBusy := True;
FAbortReq := False;
finally
FLock.Leave;
end;
try
Deadline := GetTickCount64;
i := 1;
while i <= Length(Text) do
begin
if Terminated or Aborted then Break;
// Backspace мог съесть хвост уже взятого текста — отсекаем.
FLock.Enter;
try
Trim := FTrimTail;
FTrimTail := False;
KeepLen := i - 1 + Length(FUnsent);
finally
FLock.Leave;
end;
if Trim then
begin
if KeepLen < i then Break; // стёрли всё впереди
if Length(Text) > KeepLen then SetLength(Text, KeepLen);
if i > Length(Text) then Break;
end;
// Эхо в окно-терминал: знак пошёл в эфир; хвост очереди — для строки
// набора (там должно остаться только НЕ переданное).
FLock.Enter;
try
FSent := FSent + Text[i];
FUnsent := Copy(Text, i + 1, MaxInt);
if Length(FSent) > 4096 then FSent := Copy(FSent, Length(FSent) - 4095, 4096);
finally
FLock.Leave;
end;
SendChar(Text[i], Deadline);
// Дописанное в очередь по ходу передачи подхватываем без паузы —
// иначе поток допередал бы «старый» текст и уснул на 200 мс.
if i = Length(Text) then Text := Text + TakeText;
Inc(i);
end;
finally
Key(False); // ключ всегда отпущен, что бы ни случилось
FLock.Enter;
try
FUnsent := '';
FTrimTail := False;
finally
FLock.Leave;
end;
FLock.Enter;
try
FBusy := False;
finally
FLock.Leave;
end;
end;
end;
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.