mirror of
https://git.vladimir.cc/vladimir/ewsdr.git
synced 2026-08-25 18:43:51 +00:00
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:
+145
-15
@@ -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 велит закрывать, а не молча
|
||||
// скармливать разбору команд.
|
||||
|
||||
Reference in New Issue
Block a user