feat(tci): этап 2 — бинарные потоки IQ/аудио, TX-аудио и запись линейного выхода

Реализованы все четыре потока §3.4 и рекордер:

  • RX_AUDIO_STREAM — тап ДО громкости и мьюта (OnDemodAudioReady): скиммеру
    и цифре нужен звук приёмника, а не то, что осталось после ручки;
  • LINEOUT_STREAM — тап ПОСЛЕ (OnAudioReady), то есть что слышно;
  • IQ_STREAM — тап сырого IQ в движке, ОДИН вызов на накопленный блок;
  • TX_AUDIO_STREAM + TX_CHRONO — TRX:0,true,tci берёт модуляцию из потока
    клиента (флаг TCIMicRequested впереди web в SetMOX), маркеры времени идут
    из тика по часам, аудио клиента разворачивается в 48 кГц моно в тот же
    ринг, что и web-микрофон;
  • LINE_OUT_RECORDER_* — кольцо int16 на приёмник, WAV пишет отдельный поток.

Тапы аудио в контроллере многоадресные (AddAudioTap): слушают, ничего не
забирая, в отличие от OnAudioConsume, которым владеет web. Блоки нарезает и
раскладывает по кольцам клиентов сам DSP-поток, в сокет пишет поток клиента —
та же дисциплина, что у команд. Порядок локов везде FSliceLock → FStreamLock.
У очереди команд и кольца блоков разная политика переполнения: команду терять
нельзя, блок потока — можно (теряется самый старый).

Пересчёт частоты многоступенчатый (TCIStreams). Одноступенчатый FIR на верхнем
пресете Pluto (5760 кГц, коэффициент 120) упирался в потолок отводов и давал
завал 1.3 дБ в полосе при подавлении зеркала 16 дБ — то есть поток IQ с
мусором. Теперь коэффициент раскладывается на множители, спецификацию фильтра
каждой ступени задаёт ИТОГОВАЯ полоса, а свёртка идёт со сложением
симметричных пар: −83 дБ на любом коэффициенте, ≈10% ядра на 5.76 МГц.

Согласование частот с железом: из пресетов Pluto 576 и 960 кГц на 384 не
делятся, поэтому отдаём наибольшую ЗАКОННУЮ частоту, делящую источник нацело
(576/960 → 192 кГц). Ответ на IQ_SAMPLERATE называет достижимое, а не просьбу
клиента, и переобъявляется без запроса при смене rate и устройства.

Приёмный буфер соединения 4 → 32 КБ: блок TX-аудио это 64 байта заголовка плюс
data[16384], а кадр крупнее буфера не собирается никогда.

Настройки TCI переехали из Advanced на вкладку CAT, справа от TCP CAT Server:
это такой же канал внешнего управления трансивером.

Стенд (scratchpad, tcitest.pas): 112 проверок, все зелёные — включая сквозной
прогон через живой WDSP (синтетический IQ → блоки RX-аудио и IQ у настоящего
WS-клиента, и обратно TX-аудио клиента → блоки TX-IQ).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 12:36:50 +03:00
co-authored by Claude Opus 5
parent 4165cbe9a5
commit de0830fd19
10 changed files with 2463 additions and 139 deletions
+145 -15
View File
@@ -33,8 +33,12 @@ unit TCIServer;
(TCI_MAX_SOCKETS) и убивается по таймауту TCI_HANDSHAKE_MS. Иначе восемь
молчащих TCP-соединений навсегда закрывали дверь настоящим клиентам.
Бинарные фреймы (потоки IQ/аудио, §3.4) пока не обрабатываются: этап 2,
см. doc/TCI.md. Приходящие от клиента binary-фреймы молча отбрасываются.
Бинарные фреймы (потоки IQ/аудио, §3.4) ходят в обе стороны: блоки наружу
кладутся в отдельное кольцо клиента (SendBin) и уходят его же потоком вместе
с командами, входящие собираются из фрагментов и отдаются наверх (OnBinary)
— там их разбирает TCIAdapter. У двух очередей разная политика переполнения:
команду терять нельзя (клиент выбрасывается), блок потока — можно и нужно
(теряется самый старый), иначе отставший скиммер рвал бы себе управление.
Сокеты и WS-фреймы переиспользованы из веб-подсистемы (WebUtils/WsClient):
тот же код handshake и та же схема «поток на клиента + accept-поток», что в
@@ -71,6 +75,11 @@ const
TCI_OUT_CHUNK = 3800; // склейка очереди в один фрейм, символов
TCI_MSG_MAX = 65536; // потолок собираемого из фрагментов сообщения
TCI_STOP_KILL_MS = 500; // как часто добиваем клиентов, ожидая их выхода
// Очередь бинарных блоков (§3.4) на клиента. Переполнение здесь НЕ повод
// рвать соединение, в отличие от очереди команд: поток — это данные
// реального времени, и клиент, не успевший забрать блок, должен потерять
// именно блок. Выбрасываем самый старый: свежий звук полезнее протухшего.
TCI_BIN_QUEUE = 48;
type
TTCIServer = class;
@@ -107,6 +116,13 @@ type
FOutLock: TCriticalSection;
FOut: array of string;
FOutCount: Integer;
// Очередь бинарных блоков потоков. Отдельная от командной: у них разная
// политика переполнения (команду терять нельзя, блок потока — можно) и
// разные производители (блоки кладёт DSP-поток).
FBinOut: array[0..TCI_BIN_QUEUE-1] of TBytes;
FBinHead: Integer; // куда класть
FBinTail: Integer; // откуда брать
FBinDropped: LongInt; // сколько блоков выброшено (диагностика)
FDead: Boolean; // сокет уже не пишется — гасим соединение
FKilled: Boolean; // shutdown сокета уже сделан
FClosed: Boolean; // клиентский поток вышел (можно освобождать)
@@ -123,6 +139,13 @@ type
destructor Destroy; override;
{ Строку в очередь клиенту. False — соединение уже мертво. Не блокирует. }
function Send(const S: string): Boolean;
{ Блок бинарного потока (заголовок + сэмплы) в очередь. Зовётся из
DSP-потока, поэтому только копирование под коротким локом: сеть тут не
трогается. False — клиент мёртв (блок никуда не пошёл). }
function SendBin(const Hdr: TTCIStreamHeader; Data: Pointer;
Bytes: Integer): Boolean;
{ Сколько блоков потока выброшено из-за отставания клиента. }
function BinDropped: LongInt;
{ Слить очередь в сокет. Зовёт ТОЛЬКО собственный поток клиента: запись
может ждать до TCI_SEND_TIMEOUT, и общий поток на этом задерживал бы
всех остальных. False — клиент умер. }
@@ -154,6 +177,10 @@ type
TTCIClientEvent = procedure(Client: TTCIClient) of object;
TTCICommandEvent = procedure(Client: TTCIClient; const Cmd: string) of object;
{ Собранное бинарное сообщение от клиента (TX-аудио, §3.4). Данные живут
только на время вызова — обработчик обязан их скопировать. }
TTCIBinaryEvent = procedure(Client: TTCIClient; Data: PByte;
Len: Integer) of object;
TTCIServer = class
private
@@ -170,6 +197,7 @@ type
FPort: Word;
FBindIP: string;
FOnCommand: TTCICommandEvent;
FOnBinary: TTCIBinaryEvent;
FOnConnect: TTCIClientEvent;
FOnDisconnect: TTCIClientEvent;
FOnTick: TThreadMethod;
@@ -218,6 +246,7 @@ type
контроллера, и Synchronize из клиентского потока в него не вернётся. }
property Stopping: Boolean read FStopping;
property OnCommand: TTCICommandEvent read FOnCommand write FOnCommand;
property OnBinary: TTCIBinaryEvent read FOnBinary write FOnBinary;
property OnConnect: TTCIClientEvent read FOnConnect write FOnConnect;
property OnDisconnect: TTCIClientEvent read FOnDisconnect write FOnDisconnect;
property OnTick: TThreadMethod read FOnTick write FOnTick;
@@ -411,17 +440,22 @@ begin
FOutLock := TCriticalSection.Create;
FOutCount := 0;
SetLength(FOut, 64);
FBinHead := 0;
FBinTail := 0;
FBinDropped := 0;
// Умолчания параметров потоков — как в §4.3 (клиент их обычно переопределяет).
FIQRate := 48000;
FAudioRate := 48000;
FAudioSamples := 2048;
FAudioChannels := 2;
FIQRate := TCI_IQ_RATE_DEF;
FAudioRate := TCI_AUDIO_RATE_DEF;
FAudioSamples := TCIDefaultAudioSamples(TCI_AUDIO_RATE_DEF);
FAudioChannels := TCI_AUDIO_CHAN_DEF;
FAudioSampleType := 'float32';
FTxBuffering := 50;
FTxBuffering := TCI_TX_BUFFERING_DEF;
end;
destructor TTCIClient.Destroy;
var i: Integer;
begin
for i := 0 to TCI_BIN_QUEUE - 1 do FBinOut[i] := nil;
FOutLock.Free;
FStateLock.Free;
inherited;
@@ -590,10 +624,53 @@ begin
end;
end;
function TTCIClient.SendBin(const Hdr: TTCIStreamHeader; Data: Pointer;
Bytes: Integer): Boolean;
// Кладёт готовый блок в кольцо. Зовётся из DSP-потока: единственное, что тут
// разрешено — копирование под коротким локом. Кольцо полное — выбрасываем
// САМЫЙ СТАРЫЙ блок: рвать соединение из-за отставания в потоке нельзя
// (команды при этом продолжают ходить), а протухший звук клиенту не нужен.
var
Blk: TBytes;
NewH: Integer;
begin
Result := False;
if FDead or (FWs = nil) or (Bytes < 0) then Exit;
if Bytes > TCI_STREAM_DATA_MAX then Exit; // блок не по протоколу
SetLength(Blk, SizeOf(Hdr) + Bytes);
Move(Hdr, Blk[0], SizeOf(Hdr));
if Bytes > 0 then Move(Data^, Blk[SizeOf(Hdr)], Bytes);
FOutLock.Enter;
try
if FDead then Exit;
NewH := (FBinHead + 1) mod TCI_BIN_QUEUE;
if NewH = FBinTail then
begin
FBinOut[FBinTail] := nil;
FBinTail := (FBinTail + 1) mod TCI_BIN_QUEUE;
Inc(FBinDropped);
end;
FBinOut[FBinHead] := Blk;
FBinHead := NewH;
Result := True;
finally
FOutLock.Leave;
end;
end;
function TTCIClient.BinDropped: LongInt;
begin
FOutLock.Enter;
try Result := FBinDropped; finally FOutLock.Leave; end;
end;
function TTCIClient.Flush: Boolean;
var
Batch: array of string;
N, i: Integer;
Bins: array[0..TCI_BIN_QUEUE-1] of TBytes;
N, i, NB: Integer;
Chunk: string;
begin
Result := not FDead;
@@ -602,6 +679,7 @@ begin
// поток так и висел бы в recv, а объект никогда бы не освободился.
if FDead then begin Kill; Exit; end;
NB := 0;
FOutLock.Enter;
try
N := FOutCount;
@@ -615,10 +693,19 @@ begin
end;
FOutCount := 0;
end;
// Бинарные блоки забираем тем же заходом: лишний Enter/Leave на каждый
// блок потока — это тысячи лишних локов в секунду.
while FBinTail <> FBinHead do
begin
Bins[NB] := FBinOut[FBinTail];
FBinOut[FBinTail] := nil;
FBinTail := (FBinTail + 1) mod TCI_BIN_QUEUE;
Inc(NB);
end;
finally
FOutLock.Leave;
end;
if N = 0 then Exit;
if (N = 0) and (NB = 0) then Exit;
// Склейка: несколько команд в одном фрейме протокол разрешает (§3.1), а
// syscall'ов и заголовков становится в разы меньше.
@@ -634,14 +721,30 @@ begin
end;
if Chunk <> '' then
if not FWs.SendText(Chunk) then begin Kill; Exit(False); end;
// Блоки потоков — каждый отдельным binary-фреймом: клиент читает их по
// одному заголовку на кадр, склейка тут запрещена протоколом.
for i := 0 to NB - 1 do
begin
if not FWs.SendBinary(Bins[i][0], Length(Bins[i])) then
begin
Kill;
Exit(False);
end;
Bins[i] := nil;
end;
end;
procedure TTCIClient.Kill;
var i: Integer;
begin
FOutLock.Enter;
try
FDead := True;
FOutCount := 0;
for i := 0 to TCI_BIN_QUEUE - 1 do FBinOut[i] := nil;
FBinHead := 0;
FBinTail := 0;
if FKilled then Exit; // shutdown уже был — второй раз незачем
FKilled := True;
finally
@@ -980,6 +1083,8 @@ var
Payload: array of Byte;
Opcode, MsgOp: Byte;
Frag: string;
Bin: array of Byte; // сборка бинарного сообщения (блок потока)
BinLen: Integer;
Cmds: TStringList;
begin
Ws := Client.Ws;
@@ -1086,6 +1191,11 @@ begin
Cmds := TStringList.Create;
Frag := '';
MsgOp := 0;
BinLen := 0;
// Буфер под сборку блока потока заводим сразу: расти по ходу приёма он всё
// равно не имеет права (потолок задан протоколом), а перевыделение на
// каждый блок TX-аудио — это мусор в куче двадцать раз в секунду.
SetLength(Bin, TCI_STREAM_MAX);
Pending := Rest > 0; // хвост handshake разбираем до первого recv
try
while FRunning and (Ws.State = wsOpen) and not Client.Dead do
@@ -1150,9 +1260,9 @@ begin
// Фрейм крупнее приёмного буфера TWsClient никогда не соберётся —
// BufLen упрётся в потолок и цикл встанет намертво. Рвём соединение:
// команд такой длины у TCI нет, а бинарные потоки от клиента (TX-аудио)
// мы пока не принимаем.
if Need + PayLen > SizeOf(Raw) then
// команд такой длины у TCI нет, а самый крупный законный кадр —
// блок TX-аудио (заголовок + data[16384]) — в буфер помещается.
if Need + PayLen > Ws.BufCapacity then
begin
Ws.State := wsClosed;
Break;
@@ -1189,11 +1299,11 @@ begin
begin
// Новое сообщение поверх недособранного — тоже рассинхрон.
if MsgOp <> 0 then begin Ws.State := wsClosed; Break; end;
MsgOp := Opcode;
Frag := '';
MsgOp := Opcode;
Frag := '';
BinLen := 0;
end;
// Копим только текст: binary — это TX-аудио от клиента, этап 2.
if MsgOp = $01 then
begin
if Length(Frag) + PayLen > TCI_MSG_MAX then
@@ -1207,10 +1317,30 @@ begin
Move(Payload[0], Text[1], PayLen);
Frag := Frag + Text;
end;
end
else
begin
// Бинарное сообщение — блок потока от клиента (TX-аудио).
// Клиент вправе резать его на фрагменты, поэтому копим так же,
// как текст, но с потолком в один блок: длиннее протокол не
// определяет, и растить буфер на чужой каприз мы не обязаны.
if BinLen + PayLen > TCI_STREAM_MAX then
begin
Ws.State := wsClosed;
Break;
end;
if PayLen > 0 then
begin
Move(Payload[0], Bin[BinLen], PayLen);
Inc(BinLen, PayLen);
end;
end;
if Fin then
begin
if (MsgOp = $02) and Assigned(FOnBinary) and (BinLen > 0) then
FOnBinary(Client, @Bin[0], BinLen);
BinLen := 0;
// Текстовое сообщение обязано быть валидным UTF-8 (§5.6);
// битую последовательность RFC велит закрывать, а не молча
// скармливать разбору команд.