From 84b90b23c0ae1221234d6575b65ccc454f8d1d3f Mon Sep 17 00:00:00 2001 From: Vladimir Date: Wed, 19 Aug 2026 22:46:37 +0300 Subject: [PATCH] =?UTF-8?q?feat(tci):=20KEYER=20=E2=80=94=20=D1=87=D1=83?= =?UTF-8?q?=D0=B6=D0=BE=D0=B9=20=D0=BA=D0=BB=D1=8E=D1=87=20=D0=BE=D1=87?= =?UTF-8?q?=D0=B5=D1=80=D0=B5=D0=B4=D1=8C=D1=8E=20=D1=8D=D0=BB=D0=B5=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D1=82=D0=BE=D0=B2,=20=D0=B0=20=D0=BD=D0=B5=20?= =?UTF-8?q?=D1=81=D0=B5=D1=82=D0=B5=D0=B2=D1=8B=D0=BC=D0=B8=20=D1=84=D1=80?= =?UTF-8?q?=D0=BE=D0=BD=D1=82=D0=B0=D0=BC=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Последняя невыполненная команда протокола. Ключ к ней в том, что 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 --- CWMorse.pas | 221 +++++++++++++++++++++++++++++++++++++++++++ RadioController.pas | 57 ++++++++++- TCIAdapter.pas | 67 +++++++++++++ TCIProtocol.pas | 7 ++ doc/TCI.md | 92 +++++++++++++----- test/tci/tcitest.pas | 180 ++++++++++++++++++++++++++++++++++- 6 files changed, 598 insertions(+), 26 deletions(-) diff --git a/CWMorse.pas b/CWMorse.pas index dd6f027..c2c215f 100644 --- a/CWMorse.pas +++ b/CWMorse.pas @@ -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. diff --git a/RadioController.pas b/RadioController.pas index 8540a05..520bbcc 100644 --- a/RadioController.pas +++ b/RadioController.pas @@ -507,6 +507,9 @@ type // Программная передача текста (бит CWX). Поток создаётся лениво — у тех, // кто телеграфом не пользуется, лишней нити не заводится. FCWSender: TCWSender; + // Чужая манипуляция по сети (TCI KEYER): очередь готовых интервалов. + // Тоже лениво — заводится на первом элементе. + FCWElem: TCWElemPlayer; // Состояние ключа для ПРОГРАММНОГО сайдтона. Пишется потоком передачи и // разбором HP-статуса (прямой ключ), читается DSP-потоком в MixSidetone — // толерантная гонка на одном булеве, как FHPp*. @@ -723,6 +726,7 @@ type function CWOptionsByte: Byte; // DUC Specific байт 5 procedure SyncCWKeyer; // байт 5 под текущий TX-режим procedure CWXKeyEvent(Down: Boolean); // колбэк потока передачи + procedure CWElemKey(Down: Boolean); // колбэк проигрывателя KEYER // Сдвиг несущей манипуляции от гетеродина (уход от утечки LO у zero-IF). // На него же уезжает DUC/LO, чтобы РЧ встала ровно на VFO — см. TXTuneFreqHz. function CWCarrierOffsetHz: Double; @@ -1128,6 +1132,10 @@ type // Thetis cwx.cs). Занят ли передатчик — CWXBusy; ЛЮБОЕ касание // манипулятора или снятие MOX обрывает передачу (CWXAbort). procedure CWXSend(const Text: string); + // Чужая манипуляция: один готовый интервал (посылка или пауза) заданной + // длины. Так приходит телеграф по TCI (команда KEYER) — знаков там нет, + // есть измеренные клиентом длительности. См. TCWElemPlayer. + procedure CWKeyerElement(Mark: Boolean; Ms: Integer); procedure CWXAbort; function CWXBusy: Boolean; // ---- Окно-терминал ----------------------------------------------------- @@ -1534,6 +1542,11 @@ begin FCWSender.AbortSending; FreeAndNil(FCWSender); end; + if Assigned(FCWElem) then + begin + FCWElem.AbortPlay; + FreeAndNil(FCWElem); + end; if Assigned(FCWDec) then FCWDec.Enabled := False; // кольцо больше не кормим FreeEngines; // Декодер — ПОСЛЕ движка: его кольцо наполняет DSP-поток, и освобождать @@ -6584,7 +6597,8 @@ begin // на бэнд с DoNotTx, правка Alex. Перечислять такие точки поимённо — гарантия // однажды пропустить очередную (ровно так уже вышло с DUC-частотой и мощностью). if (not (ActiveTXMode in [MODE_CWL, MODE_CWU])) - and Assigned(FCWSender) and FCWSender.Busy then CWXAbort; + and ((Assigned(FCWSender) and FCWSender.Busy) + or (Assigned(FCWElem) and FCWElem.Busy)) then CWXAbort; // Вооружённый кейер = железо может выйти в эфир само ⇒ бэкенд обязан класть // уровень мощности в КАЖДЫЙ HP-кадр, а не только на передаче. Локальный // генератор — та же история: FTransmitting в телеграфе не поднимается, а @@ -6759,7 +6773,43 @@ end; function TRadioController.CWXBusy: Boolean; begin Result := (Assigned(FCWSender) and FCWSender.Busy) - or (Assigned(FCWLocal) and FCWLocal.Busy); + or (Assigned(FCWLocal) and FCWLocal.Busy) + or (Assigned(FCWElem) and FCWElem.Busy); +end; + +procedure TRadioController.CWElemKey(Down: Boolean); +// Поток проигрывателя. Чужая манипуляция — это ПРЯМОЙ КЛЮЧ, только с точными +// длительностями, поэтому и уходит она в те же две двери, что и ключ оператора: +// у Pluto — во вход прямого ключа локального генератора (он сам поднимает +// сессию, рисует огибающую и сайдтон), у openHPSDR — в бит CWX прошивки (тем же +// путём идёт и передача текста). +begin + if CWLocalSource then + begin + if Assigned(FCWLocal) then FCWLocal.StraightKey(Down); + Exit; + end; + CWXKeyEvent(Down); +end; + +procedure TRadioController.CWKeyerElement(Mark: Boolean; Ms: Integer); +// Поток контроллера (адаптер TCI маршалит через Invoke). Ставим элемент в +// очередь — играет её отдельный поток по абсолютным дедлайнам. +begin + if Ms <= 0 then Exit; + // Тот же гейт, что у CWXSend: CWTXActive учитывает и режим, и запрет + // передачи (RX-only слот трансвертера, бэнд с DoNotTx) — там кейер разоружён, + // и трогать ключ незачем. + if not CWTXActive then Exit; + // У Pluto манипуляцию рисуем сами: генератор обязан быть заведён и вооружён, + // иначе первый же элемент уйдёт в никуда (то же делает CWXSend). + if CWLocalSource then + begin + EnsureCWLocal; + SyncCWKeyer; + end; + if not Assigned(FCWElem) then FCWElem := TCWElemPlayer.Create(Self.CWElemKey); + FCWElem.Enqueue(Mark, Ms); end; procedure TRadioController.CWXSend(const Text: string); @@ -6790,6 +6840,9 @@ procedure TRadioController.CWXAbort; begin if Assigned(FCWSender) then FCWSender.AbortSending; if Assigned(FCWLocal) then FCWLocal.AbortText; + // Чужая манипуляция обрывается тем же движением: касание манипулятора, + // снятие MOX и уход из телеграфа гасят её так же, как передачу текста. + if Assigned(FCWElem) then FCWElem.AbortPlay; end; function TRadioController.CWXBackspace: Boolean; diff --git a/TCIAdapter.pas b/TCIAdapter.pas index 1573336..78dbf9d 100644 --- a/TCIAdapter.pas +++ b/TCIAdapter.pas @@ -215,6 +215,7 @@ type procedure SyncSetCWDelay; procedure SyncCWSend; procedure SyncCWStop; + procedure SyncKeyerElement; procedure SyncFocus; procedure SyncTaps; // навесить/снять тапы аудио и IQ @@ -334,6 +335,7 @@ type // ── Отдельные команды (чтобы HandleCommand не превратился в простыню) ── procedure CmdFreq(Client: TTCIClient; const M: TTCIMessage; IsIF: Boolean); procedure CmdCWMacros(const M: TTCIMessage; IsMsg: Boolean); + procedure CmdKeyer(Client: TTCIClient; const M: TTCIMessage); procedure CmdSpot(const M: TTCIMessage); public constructor Create(AController: TRadioController; ASpots: TDXSpotStore = nil); @@ -1673,6 +1675,12 @@ begin FController.CWXAbort; end; +procedure TTCIAdapter.SyncKeyerElement; +// Поток контроллера. FsBool — посылка (иначе пауза), FsInt — её длительность. +begin + FController.CWKeyerElement(FsBool, FsInt); +end; + procedure TTCIAdapter.SyncFocus; // SET_IN_FOCUS: поднять окно программы. Само окно адаптеру недоступно (он // равноправный клиент контроллера) — действие ставит UI через OnFocusRequest. @@ -1785,6 +1793,64 @@ begin FServer.Broadcast(TCIBuild('callsign_send', [Call])); end; +procedure TTCIAdapter.CmdKeyer(Client: TTCIClient; const M: TTCIMessage); +// KEYER:arg1,arg2,arg3; — arg1 передатчик, arg2 «ключ нажат», arg3 длительность +// ПРЕДЫДУЩЕГО интервала в мс (§4.3, «однонаправленное управление»). +// +// ★Ключ к команде — что arg3 описывает интервал, который ТОЛЬКО ЧТО кончился, а +// не тот, который начинается. Документ описывает это алгоритмом: первое нажатие +// даёт keyer:0,true,0, отпускание — keyer:0,false,142, то есть «посылка длилась +// 142 мс», следующее нажатие — keyer:0,true,58, то есть «пауза длилась 58 мс». +// Отсюда правило перевода в элементы: тип интервала — это состояние ключа ДО +// фронта, то есть ОБРАТНОЕ пришедшему (пришло false — кончилась посылка). +// Первое сообщение (arg3 = 0) играть нечего: оно лишь открывает передачу. +// +// Почему элементы, а не «дёрнуть ключ прямо сейчас»: между клиентом и нами +// сеть, и манипуляция по приходу пакетов — это её джиттер в эфире. Ради этого +// в протоколе и заведён третий аргумент; проигрывает очередь TCWElemPlayer. +var + Rx, Ms: Integer; + Down: Boolean; +begin + if not TCITryArgInt(M, 0, Rx) or not ValidRx(Rx) then + begin + Reply(Client, TCIBuild('tci_error', [LowerCase(M.Name), 'bad receiver'])); + Exit; + end; + // Номер объявлен потолком железа, но пана под ним может не быть — как у TRX. + if (Rx > 0) and not RxActive(Rx) then + begin + Reply(Client, TCIBuild('tci_error', + [LowerCase(M.Name), 'receiver is not running'])); + Exit; + end; + if not TCITryArgBool(M, 1, Down) then + begin + Reply(Client, TCIBuild('tci_error', [LowerCase(M.Name), 'bad state'])); + Exit; + end; + if not TCITryArgInt(M, 2, Ms) then Ms := 0; + if Ms <= 0 then Exit; // первое нажатие: играть ещё нечего + // Ключ — это передатчик, а он один: держит его тот же, кто держит TRX (§3.5). + // Чужую передачу с чужого ключа не портим. + if not Claim(HoldKey('TRX', 0, 0), Client) then Exit; + if Down then + begin + // Кончилась ПАУЗА (ключ был отпущен). + if Ms > TCI_KEYER_GAP_MAX_MS then Ms := TCI_KEYER_GAP_MAX_MS; + end + else if Ms > TCI_KEYER_MARK_MAX_MS then + Ms := TCI_KEYER_MARK_MAX_MS; + FLock.Enter; + try + FsBool := not Down; // посылка, если ключ ОТПУСТИЛИ + FsInt := Ms; + if CanInvoke then FController.Invoke(SyncKeyerElement); + finally + FLock.Leave; + end; +end; + procedure TTCIAdapter.CmdSpot(const M: TTCIMessage); // SPOT:позывной,мода,частота,цвет ARGB,текст; var @@ -2472,6 +2538,7 @@ begin Exit; end; + if M.Name = 'KEYER' then begin CmdKeyer(Client, M); Exit; end; if M.Name = 'CW_MACROS' then begin CmdCWMacros(M, False); Exit; end; if M.Name = 'CW_MSG' then begin CmdCWMacros(M, True); Exit; end; if M.Name = 'CW_MACROS_STOP' then diff --git a/TCIProtocol.pas b/TCIProtocol.pas index c96c4d7..eac6468 100644 --- a/TCIProtocol.pas +++ b/TCIProtocol.pas @@ -58,6 +58,13 @@ const TCI_TX_BUFFERING_MIN = 50; TCI_TX_BUFFERING_MAX = 500; TCI_RECORD_MAX_SEC = 300; // потолок записи линейного выхода + // Потолки длительностей у KEYER (§4.3). Посылка длиннее пяти секунд — это + // уже не телеграф, а залипший ключ; паузу же обрезаем куда жёстче: она + // прибавляется к отставанию проигрывателя от клиента, а всё, что дольше + // секунды, — это «оператор задумался», и после такой паузы честнее + // догнать реальное время, чем тащить его дальше. + TCI_KEYER_MARK_MAX_MS = 5000; + TCI_KEYER_GAP_MAX_MS = 1000; // ★Общий потолок памяти ВСЕХ рекордеров сразу. Приёмников у нас // 1 + MAX_SLICES, и предельные 300 с на каждом — это 57.6 МБ × 7 ≈ 403 МБ, // которые неавторизованный клиент выпрашивал бы семью строками. Бюджет diff --git a/doc/TCI.md b/doc/TCI.md index 1f5ad9b..8686eea 100644 --- a/doc/TCI.md +++ b/doc/TCI.md @@ -331,8 +331,9 @@ B независимо от того, где он живёт: на openHPSDR э `SPOT`, `SPOT_DELETE`, `SPOT_CLEAR` (в `TDXSpotStore`, спот виден на всех панадаптерах; время спота — UTC, как у кластера, а не местное), `RX_SENSORS_ENABLE`, `TX_SENSORS_ENABLE` (период — на клиента), +`KEYER` (чужой ключ — см. §2.6), `IQ_SAMPLERATE`, `AUDIO_SAMPLERATE`, `AUDIO_STREAM_*`, `TX_STREAM_AUDIO_BUFFERING` -(значения принимаются и подтверждаются; сами потоки — этап 2). Параметры +(значения принимаются и подтверждаются). Параметры потоков — настройки **клиента**, а не устройства: живут в `TTCIClient`, и один клиент не переопределяет их остальным; подписки и параметры читаются/пишутся под локом клиента, потому что пишет их его поток, а читает тик-поток. @@ -343,8 +344,7 @@ int16/int24/int32/float32. Чужое значение не принимаетс действующее. Команды **запуска** потоков (`IQ_START`/`IQ_STOP`, `AUDIO_START`/`AUDIO_STOP`, -`LINE_OUT_*`) отвечают `tci_error:<команда>,binary streams are not implemented`. -Молчать нельзя: клиент решил бы, что поток пошёл, и ждал бы данных бесконечно. +`LINE_OUT_*`) разобраны в §2.5 — этап 2 сделан, и потоки настоящие. ### 2.4 Уведомления (§4.4, §4.5) @@ -699,7 +699,7 @@ ExpertSDR3 давно бы не было. ### 2.6 Телеграф (§3.2) -`CW_MACROS`, `CW_MSG`, `CW_MACROS_STOP`, `CW_TERMINAL`. +`CW_MACROS`, `CW_MSG`, `CW_MACROS_STOP`, `CW_TERMINAL`, `KEYER`. Текст приводится к тому, что понимает передатчик текста ewsdr (`CWXSend`): экранирование `^ ~ *` снимается, `CALL$N` разворачивается в N повторов @@ -709,6 +709,52 @@ ExpertSDR3 давно бы не было. скорости и не знает прос-знаков. Доотправка позывного (`cw_msg:arg1;`) игнорируется: уже отданный в очередь текст не редактируется. +**`KEYER` — чужой ключ (§4.3).** `KEYER:<передатчик>,<нажата>,<мс>` — не «нажми +сейчас», а **описание уже закончившегося интервала**. Документ задаёт это +алгоритмом: первое нажатие даёт `keyer:0,true,0`, отпускание — +`keyer:0,false,142` («посылка длилась 142 мс»), следующее нажатие — +`keyer:0,true,58` («пауза длилась 58 мс»). Отсюда правило перевода: тип +интервала — это состояние ключа **до** фронта, то есть обратное пришедшему; +`arg3 = 0` играть нечего, это лишь открытие передачи. + +★Ключевое место — **почему нельзя дёргать ключ по приходу пакета**. Приход +говорит, что интервал кончился, а не сколько он длился: манипуляция «по +приходу» — это сетевой джиттер прямо в эфир, тот самый «пьяный матрос», ради +которого третий аргумент в протоколе и появился. Поэтому элементы становятся в +очередь и играются подряд по абсолютным дедлайнам (`TCWElemPlayer` в +`CWMorse.pas`): сумма длительностей равна времени у клиента, значит отставание +постоянно (сеть + один элемент) и не накапливается. + +Дальше элементы уходят туда же, куда ключ оператора, — чужая манипуляция это и +есть прямой ключ, только с точными длительностями: у Pluto во вход прямого +ключа локального генератора (он сам поднимает сессию, рисует огибающую и +сайдтон), у openHPSDR в бит `CWX` прошивки (тем же путём идёт передача текста). +Гейт тот же, что у `CWXSend`, — `CWTXActive`: не телеграфный режим, RX-only +слот трансвертера или бэнд с `DoNotTx` — кейер разоружён, и трогать ключ +незачем. Передачу `KEYER` не поднимает: как и у текста, при включённом break-in +PTT даёт сама прошивка (или сессия локального генератора), при выключенном +оператор держит MOX сам. + +Три оговорки, о которых честно: + +- **очередь опустела — ключ отпускается**, и следующая пачка начинается с + чистого дедлайна. Элементы всегда чередуются, так что искажения тут нет, + зато оборвавшийся клиент не оставляет в эфире несущую; +- **паузы обрезаются** `TCI_KEYER_GAP_MAX_MS` = 1 с, посылки — + `TCI_KEYER_MARK_MAX_MS` = 5 с. Пауза целиком прибавляется к отставанию от + клиента; всё, что дольше секунды, — это «оператор задумался», и честнее + догнать реальное время, чем тащить его дальше. Посылка длиннее пяти секунд — + уже не телеграф, а залипший ключ; +- **обрыв — общий с текстом**: касание манипулятора, снятие MOX, уход из + телеграфа гасят чужую манипуляцию тем же `CWXAbort`. Оператор всегда сильнее + сети. + +`arg1` проверяется, как у `TRX` (не число или вне `TRX_COUNT` — +`bad receiver`; номер в потолке, но пана под ним нет — `receiver is not +running`), а сам телеграф уходит **текущему TX-источнику** — ровно так же, как +`CW_MACROS`. Захват (§3.5) общий с `TRX`: передатчик один, и ключ держит тот +же, кто держит эфир. + ### 2.7 Кто именно уходит в эфир (`TRX`, `TUNE`) ★`arg1` у этих двух команд — **номер передатчика**, и он не декорация. Клиент @@ -832,7 +878,7 @@ ExpertSDR3 давно бы не было. | `DDS` у приёмника-слайса | только читается (центр его панорамы): панорама под слайсом общая, и увести её по просьбе одного клиента значит утащить соседей по пану и картинку оператора. Слайсу двигаться незачем — за окном DDC следит `TuneSliceInBand` | | Панорама без слайсов | в TCI не видна вовсе: приёмник = слайс. Слушать там нечего, но и IQ такой панорамы клиенту недоступен | | Цвет спота (`SPOT`, arg4 ARGB) | не читается: `TDXSpot` цвета не хранит, подписи красятся по моде/возрасту | -| `KEYER`, `TX_FOOTSWITCH` | не реализованы, причины разные — см. ниже под таблицей | +| `TX_FOOTSWITCH` | не реализована — см. ниже под таблицей (`KEYER` сделан, см. §2.6) | | Канал B | есть только у приёмника 0 (VFO B). У приёмника-слайса канал один: слайс — это и есть «приёмник» целиком, со своими модой, фильтром, АРУ, шумодавами и потоками | | Захват параметра (§3.5) | реализован для того, что клиенты действительно перетягивают (частота, DDS, мода, фильтр, TRX/TUNE/DRIVE, split, громкости, АРУ, шумодавы, squelch, скорость CW). Эхо-параметры (RIT/XIT, BIN/ANC/…) не захватываются: на радио они не влияют | | Браузерные клиенты | отвергаются по `Origin` (403), см. §1.1. Web-интерфейсу ewsdr TCI не нужен — у него свой канал | @@ -845,18 +891,8 @@ ExpertSDR3 давно бы не было. | `IQ_SAMPLERATE` 384 кГц на Pluto 576/960 кГц | нацело не делится, поэтому уходит 192 кГц (см. §2.5). Клиент обязан читать частоту из заголовка блока, а не считать её равной запрошенной | | MP3 у рекордера | не поддержан: кодера в проекте нет, а тащить внешний (lame) ради рекордера — это новая зависимость и её лицензия в сборке, которых у ewsdr сейчас нигде нет. WAV пишется без потерь и открывается всем; на `.mp3` уходит честный `tci_error`, а не молчаливый WAV с чужим расширением | -**`KEYER` и `TX_FOOTSWITCH` — почему их нет.** Команды противоположные по -направлению, и мешать их в один пункт «не сделано» неправильно. - -`KEYER` шлёт **клиент**: это его «клоподав», прокинутый к нам, и третий -аргумент — длительность предыдущего знака — существует ровно затем, чтобы -телеграфное ядро воспроизвело тайминг точно. У нас несущую в CW даёт либо -прошивка (бит CWX / аппаратный кейер), либо программный кейер для Pluto, и оба -берут фронты **как пришли**. Прокинуть в них фронты из WebSocket значит -отправить в эфир сетевой джиттер, а `arg3` подставить некуда: команды «сыграй -знак длиной 142 мс» у openHPSDR нет. Точка входа готова (`CWXKeyEvent`), но -делать это надо вместе с очередью знаков по `arg3` — и после того, как весь -передающий тракт TCI пройдёт живого клиента (он ещё не проверялся). +**`TX_FOOTSWITCH` — почему его нет.** (`KEYER` был в этом же пункте и теперь +сделан — см. §2.6: очередь элементов по `arg3`, а не проброс сетевых фронтов.) `TX_FOOTSWITCH` шлёт **сервер**, и это просто не выведено: флаг у контроллера уже есть — `HPS_PTT` из HP-статуса ловится по фронту (`FHPpHWPTT` → @@ -892,15 +928,16 @@ ExpertSDR3 давно бы не было. рабочий слайс вместе с его модой, фильтром и портом. Правильный порядок: сперва завести создание слайса в контроллер (от этого выиграет и демон), и только потом привязать к нему команду. -2. **`KEYER`** — см. разбор под таблицей §3.1: нужна очередь знаков по `arg3`, - а не проброс сетевых фронтов в ключ. -3. **TCI в демоне.** Юниты LCL-free (стенд собирает и гоняет их вместе с +2. **TCI в демоне.** Юниты LCL-free (стенд собирает и гоняет их вместе с `TRadioController` без единого виджета), подключается одной строкой в `ewsdrd.lpr`, как web. Не сделано намеренно и в этом порядке: в headless сперва попадает то, что уже прошло живого клиента в GUI, где отказ виден глазами. Цена ошибки в демоне выше — грабля с `SIGPIPE` (см. конец §5) убивала именно `ewsdrd`, целиком и молча. -4. **Проверка на железе и с настоящим клиентом** — главное, см. конец §5. +3. **`TX_FOOTSWITCH`** — новый `rf`-эвент контроллера и строчка в адаптере, + см. §3.1 (флаг `FHWPTTActive` уже есть, наружу не выведен). +4. **Проверка `KEYER` на железе** — код прошёл стенд, живого ключа по сети ещё + не было. ## 5. Проверено @@ -962,7 +999,7 @@ ExpertSDR3 давно бы не было. движков и сети валится с AV — клиент получает `tci_error`, соединение живо) и неразрывность пачки инициализации под крутящейся ручкой. -### Стенд этапа 2 (бинарные потоки) — 219 проверок, все зелёные +### Стенд этапа 2 (бинарные потоки) — 238 проверок, все зелёные Отдельная программа (`test/tci/tcitest.pas`, прогон — `test/tci/run.sh`, внешних библиотек не требует) проверяет потоки на четырёх уровнях: @@ -1006,6 +1043,12 @@ ExpertSDR3 давно бы не было. просьбы отбрасывается — абсолютный путь, `..` и буква диска наружу не выводят; пусто, `..`, не-`.wav`, управляющий символ и отсутствие каталога записей дают отказ; существующий файл писатель не перезаписывает. +- **Чужая манипуляция** (`TCWElemPlayer`, отдельная часть стенда): интервалы + играются подряд и **их длительности сохраняются** (посылка 150, пауза 60, + посылка 150 — по фронтам ключа с допуском 30 мс на планировщик), на пустой + очереди ключ отпускается, следующая пачка после простоя играется целиком (а + не «догоняет» старый дедлайн), обрыв гасит очередь и отпускает ключ, нулевая + длительность не порождает ни одного фронта. - **Команды на живом сервере** (настоящий `TRadioController`, WS-клиент на сыром сокете): отказ на несуществующий приёмник и на нечисловой аргумент, отказ на старт потока с незапущенного пана, подтверждение и отбраковка @@ -1019,7 +1062,10 @@ ExpertSDR3 давно бы не было. чужой бинарный блок не рвёт соединение; без передачи маркеров `TX_CHRONO` нет. Номер передатчика (§2.7): `trx:9`, `trx:abc` и `tune:9` отвечают ошибкой и **не поднимают эфир**, приёмник без живого пана — тоже; ответ называет - передающий приёмник; `TUNE` через TCI включается и гасится, не оставляя за + передающий приёмник; у `KEYER` номер разбирается так же (`keyer:9`, + `keyer:abc`, незапущенный приёмник и нечисловое состояние — ошибка), первое + нажатие (`arg3 = 0`) не ошибка и ничего не играет, а `keyer:0,true,<мс>` + ключ **не** замыкает (это пауза) в отличие от `keyer:0,false,<мс>`; `TUNE` через TCI включается и гасится, не оставляя за собой ни тона, ни поднятой PTT. Чужая передача: поверх MOX оператора `TRX` не уводит микрофон и не снимает передачу, `TUNE` не включает тон, а уход такого клиента передачу оператора не гасит. Конкурирующий TCI-клиент diff --git a/test/tci/tcitest.pas b/test/tci/tcitest.pas index b8b99db..b32a587 100644 --- a/test/tci/tcitest.pas +++ b/test/tci/tcitest.pas @@ -28,7 +28,7 @@ program tcitest; uses cthreads, Classes, SysUtils, Math, SyncObjs, Sockets, BaseUnix, TCIProtocol, TCIStreams, TCIServer, TCIAdapter, RadioController, - WsClient, WebUtils, WDSPEngine, Settings; + WsClient, WebUtils, WDSPEngine, Settings, CWMorse; var Passed, Failed: Integer; @@ -1005,6 +1005,123 @@ begin Big := nil; end; +{ ═══════════════════════════════════════════════════════════════════════════ + C2. Чужая манипуляция (KEYER, §4.3) + ═══════════════════════════════════════════════════════════════════════════ } + +type + { Приёмник фронтов ключа: пишет, ЧТО и КОГДА пришло. } + TKeyLog = class + public + Down: array[0..63] of Boolean; + At: array[0..63] of QWord; + Count: Integer; + T0: QWord; + procedure Key(D: Boolean); + end; + +procedure TKeyLog.Key(D: Boolean); +begin + if Count > High(Down) then Exit; + if Count = 0 then T0 := GetTickCount64; + Down[Count] := D; + At[Count] := GetTickCount64 - T0; + Inc(Count); +end; + +procedure TestKeyPlayer; +// ★Смысл проигрывателя: длительности приходят ГОТОВЫМИ (клиент замерил свой +// ключ), и в эфир они обязаны лечь такими же, сколько бы ни болталась сеть. +// Поэтому проверяем не «дёрнулся ключ», а ДЛИНЫ интервалов между фронтами. +var + Log: TKeyLog; + P: TCWElemPlayer; + Ev: TCWKeyEvent; + i, Waited, Bad: Integer; + D1, D2, D3: Int64; +begin + WriteLn('C2. Чужая манипуляция (KEYER)'); + Log := TKeyLog.Create; + Ev := Log.Key; + P := TCWElemPlayer.Create(Ev); + try + // Посылка 150 мс, пауза 60, посылка 150 — «точка-тире» чужим ключом. + P.Enqueue(True, 150); + P.Enqueue(False, 60); + P.Enqueue(True, 150); + Waited := 0; + while (P.Busy or (P.Pending > 0)) and (Waited < 3000) do + begin + Sleep(10); + Inc(Waited, 10); + end; + Sleep(50); + Check('KEYER: фронтов ровно четыре', Log.Count = 4, IntToStr(Log.Count)); + if Log.Count = 4 then + begin + Check('KEYER: чередование вниз-вверх-вниз-вверх', + Log.Down[0] and (not Log.Down[1]) and Log.Down[2] and + (not Log.Down[3])); + D1 := Int64(Log.At[1]) - Int64(Log.At[0]); + D2 := Int64(Log.At[2]) - Int64(Log.At[1]); + D3 := Int64(Log.At[3]) - Int64(Log.At[2]); + // Допуск на планировщик — тот же порядок, что у дробного сна (5 мс). + Check('KEYER: длительность посылки сохранена', + (Abs(D1 - 150) < 30) and (Abs(D3 - 150) < 30), + Format('%d/%d', [D1, D3])); + Check('KEYER: длительность паузы сохранена', Abs(D2 - 60) < 30, + IntToStr(D2)); + end + else + begin + Check('KEYER: чередование вниз-вверх-вниз-вверх', False); + Check('KEYER: длительность посылки сохранена', False); + Check('KEYER: длительность паузы сохранена', False); + end; + // ★Очередь опустела — ключ ОТПУЩЕН. Иначе оборвавшийся клиент оставил бы в + // эфире несущую, снять которую некому. + Check('KEYER: на пустой очереди ключ отпущен', + (Log.Count > 0) and (not Log.Down[Log.Count - 1])); + + // Пачка после простоя начинается сразу, а не «догоняет» старый дедлайн. + Log.Count := 0; + Sleep(300); + P.Enqueue(True, 100); + Waited := 0; + while (Log.Count < 2) and (Waited < 2000) do + begin + Sleep(10); + Inc(Waited, 10); + end; + Check('KEYER: следующая пачка играется целиком', + (Log.Count = 2) and (Abs(Int64(Log.At[1]) - Int64(Log.At[0]) - 100) < 30), + IntToStr(Log.Count)); + + // Обрыв: очередь бросается, ключ отпускается. + Log.Count := 0; + for i := 0 to 9 do P.Enqueue(True, 400); + Sleep(60); + P.AbortPlay; + Sleep(120); + Bad := Log.Count; + Sleep(300); + Check('KEYER: обрыв гасит очередь', Log.Count = Bad, IntToStr(Log.Count - Bad)); + Check('KEYER: после обрыва ключ отпущен', + (Log.Count > 0) and (not Log.Down[Log.Count - 1])); + // Нулевые и отрицательные длительности не элементы: первое нажатие клиента + // (keyer:0,true,0) не должно порождать ни одного фронта. + Log.Count := 0; + P.Enqueue(True, 0); + P.Enqueue(False, -5); + Sleep(150); + Check('KEYER: нулевая длительность ничего не играет', Log.Count = 0, + IntToStr(Log.Count)); + finally + P.Free; + Log.Free; + end; +end; + { ═══════════════════════════════════════════════════════════════════════════ D. Живой сервер: команды потоков ═══════════════════════════════════════════════════════════════════════════ } @@ -1257,6 +1374,7 @@ var Cfg: TTCISettings; C, C2: TRawClient; S: string; + WasConn, WasRun: Boolean; Blk: array[0..1023] of Byte; H: TTCIStreamHeader; Op: Byte; @@ -1464,6 +1582,65 @@ begin Check('tune:9 → ошибка', Pos('bad receiver', S) > 0, S); Check('tune:9 не включил TUN', not Ctrl.FTuning); + // ── KEYER (§4.3): чужой ключ ──────────────────────────────────────── + // Номер передатчика разбирается так же, как у TRX: командой из сети в эфир + // по несуществующему приёмнику не уходят. + C.SendText('keyer:9,true,0;'); + S := C.WaitText('tci_error', 1500); + Check('keyer:9 → ошибка', Pos('bad receiver', S) > 0, S); + C.SendText('keyer:abc,true,0;'); + S := C.WaitText('tci_error', 1500); + Check('keyer:abc → ошибка', Pos('bad receiver', S) > 0, S); + C.SendText('keyer:1,true,0;'); + S := C.WaitText('tci_error', 1500); + Check('keyer на незапущенный приёмник → ошибка', + (Pos('not running', S) > 0) or (Pos('bad receiver', S) > 0), S); + C.SendText('keyer:0,maybe,0;'); + S := C.WaitText('tci_error', 1500); + Check('keyer с нечисловым состоянием → ошибка', Pos('bad state', S) > 0, S); + // Первое нажатие (arg3 = 0) — не ошибка и не звук: играть ещё нечего. + C.SendText('keyer:0,true,0;'); + S := C.WaitText('tci_error', 400); + Check('keyer:0,true,0 не ошибка', S = '', S); + Check('keyer:0,true,0 не поднял манипуляцию', not Ctrl.CWXBusy); + + // ★Сквозная проверка перевода: keyer:0,false,150 значит «посылка длилась + // 150 мс», и она обязана дойти до манипуляции. Без телеграфного режима + // трогать ключ нечем (CWTXActive), поэтому режим ставим сами. + Ctrl.SetMode(MODE_CWU); + if not Ctrl.CWTXActive then + WriteLn(' .. keyer: манипуляция недоступна (CWTXActive=false), пропуск') + else + begin + // ★Локальный генератор вооружается только при живом устройстве + // (FDevConnected/FRunning — без радио манипулировать некуда), а радио на + // стенде нет. Подставляем эти два флага на время проверки: нас интересует + // перевод «пришло false ⇒ кончилась ПОСЫЛКА», а не излучающая часть. + WasConn := Ctrl.FDevConnected; + WasRun := Ctrl.FRunning; + Ctrl.FDevConnected := True; + Ctrl.FRunning := True; + // Первое нажатие открывает передачу, второе сообщение говорит «кончилась + // ПАУЗА 300 мс» — ключ при этом замыкаться не должен. + C.SendText('keyer:0,true,0;'); + C.SendText('keyer:0,true,300;'); + C.Pump(120); + Check('keyer: манипуляция пошла', Ctrl.CWXBusy); + Check('keyer: пауза ключ не замыкает', not Ctrl.FCWLocalTX); + // «Кончилась ПОСЫЛКА 200 мс» — вот она и есть звук в эфире. Ждём конца + // паузы и смотрим в середину посылки. + C.SendText('keyer:0,false,200;'); + C.Pump(250); + Check('keyer: посылка замыкает ключ', Ctrl.FCWLocalTX); + Ctrl.CWXAbort; + C.Pump(150); + Check('keyer: обрыв гасит манипуляцию', not Ctrl.CWXBusy); + Ctrl.FDevConnected := WasConn; + Ctrl.FRunning := WasRun; + Ctrl.SyncCWKeyer; // разоружить обратно + end; + Ctrl.SetMode(MODE_USB); + // Ответ называет приёмник, чей слайс реально в эфире. Без радио источник // передачи — главный VFO, то есть 0. C.SendText('trx:0;'); @@ -1938,6 +2115,7 @@ begin TestResampling; TestStreamOut; TestRecorder; + TestKeyPlayer; TestServer; TestEndToEnd; WriteLn;