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
+205 -5
View File
@@ -42,6 +42,23 @@ const
TCI_MODULATIONS = 'am,sam,dsb,lsb,usb,cw,nfm,wfm,digl,digu,dmr,fmraw';
// Границы, оговорённые протоколом (клампим сами — клиент шлёт что угодно).
// Бинарные потоки (§3.4). data[16384] в структуре Stream — это ПОТОЛОК блока,
// а не его размер: больше в один блок не кладут ни ExpertSDR3, ни клиенты.
TCI_STREAM_DATA_MAX = 16384; // байт данных в блоке
TCI_STREAM_HDR_SIZE = 64; // 16 × uint32
TCI_STREAM_MAX = TCI_STREAM_HDR_SIZE + TCI_STREAM_DATA_MAX;
// Умолчания параметров потоков (§4.3).
TCI_IQ_RATE_DEF = 48000;
TCI_AUDIO_RATE_DEF = 48000;
TCI_AUDIO_CHAN_DEF = 2;
TCI_TX_BUFFERING_DEF = 50; // мс
TCI_AUDIO_SAMPLES_MIN = 100;
TCI_AUDIO_SAMPLES_MAX = 2048;
TCI_TX_BUFFERING_MIN = 50;
TCI_TX_BUFFERING_MAX = 500;
TCI_RECORD_MAX_SEC = 300; // потолок записи линейного выхода
TCI_VOL_MIN_DB = -60; TCI_VOL_MAX_DB = 0;
TCI_SQL_MIN_DB = -140; TCI_SQL_MAX_DB = 0;
TCI_AGC_MIN_DB = -20; TCI_AGC_MAX_DB = 120;
@@ -59,8 +76,7 @@ type
ArgCount: Integer;
end;
{ Тип бинарного потока (§3.4). Потоки — следующий этап (см. doc/TCI.md),
но формат протокольный, поэтому объявлен здесь, а не в транспорте. }
{ Тип бинарного потока (§3.4). }
TTCIStreamType = (tstIQ, tstRXAudio, tstTXAudio, tstTXChrono, tstLineOut);
TTCISampleType = (tsyInt16, tsyInt24, tsyInt32, tsyFloat32);
@@ -104,6 +120,35 @@ function TCIValidIQRate(V: Integer): Boolean; // 48/96/192/384 кГц
function TCIValidAudioRate(V: Integer): Boolean; // 8/12/24/48 кГц
function TCIValidSampleType(const S: string): Boolean; // int16/int24/int32/float32
{ ── Бинарные потоки (§3.4) ─────────────────────────────────────────────── }
function TCISampleTypeByName(const S: string; out T: TTCISampleType): Boolean;
function TCISampleTypeName(T: TTCISampleType): string;
function TCISampleBytes(T: TTCISampleType): Integer;
{ Сколько сэмплов НА КАНАЛ класть в блок по умолчанию (§4.3): у ExpertSDR3
своё число на каждую частоту дискретизации, и все они дают ~42 мс звучания. }
function TCIDefaultAudioSamples(RateHz: Integer): Integer;
{ Сколько сэмплов на канал влезает в блок с таким форматом и числом каналов. }
function TCIMaxBlockSamples(T: TTCISampleType; Channels: Integer): Integer;
{ Заголовок блока. Count — сэмплов НА КАНАЛ; в DataLength уходит, как велит
протокол, количество ВЕЩЕСТВЕННЫХ отсчётов, то есть Count × Channels. }
procedure TCIFillHeader(out H: TTCIStreamHeader; Kind: TTCIStreamType;
Rx, RateHz: Integer; T: TTCISampleType; Count, Channels: Integer);
{ Упаковка вещественных отсчётов в формат клиента. Возвращает число байт.
Src читается подряд (уже с чередованием каналов), Dest обязан вмещать
N × TCISampleBytes(T) байт. }
function TCIPackSamples(const Src: array of Single; N: Integer;
T: TTCISampleType; Dest: PByte): Integer;
{ Обратная распаковка (TX-аудио от клиента). Возвращает число распакованных
вещественных отсчётов; лишнее сверх Length(Dest) отбрасывается. }
function TCIUnpackSamples(Src: PByte; Bytes: Integer; T: TTCISampleType;
var Dest: array of Single): Integer;
{ ── Сборка ─────────────────────────────────────────────────────────────── }
function TCIBuild(const Name: string): string; overload;
@@ -284,10 +329,165 @@ begin
end;
function TCIValidSampleType(const S: string): Boolean;
var T: string;
var T: TTCISampleType;
begin
T := LowerCase(Trim(S));
Result := (T = 'int16') or (T = 'int24') or (T = 'int32') or (T = 'float32');
Result := TCISampleTypeByName(S, T);
end;
{ ═══════════════════════════════════════════════════════════════════════════
Бинарные потоки (§3.4)
═══════════════════════════════════════════════════════════════════════════ }
function TCISampleTypeByName(const S: string; out T: TTCISampleType): Boolean;
var N: string;
begin
Result := True;
N := LowerCase(Trim(S));
if N = 'int16' then T := tsyInt16
else if N = 'int24' then T := tsyInt24
else if N = 'int32' then T := tsyInt32
else if N = 'float32' then T := tsyFloat32
else begin T := tsyFloat32; Result := False; end;
end;
function TCISampleTypeName(T: TTCISampleType): string;
begin
case T of
tsyInt16: Result := 'int16';
tsyInt24: Result := 'int24';
tsyInt32: Result := 'int32';
else Result := 'float32';
end;
end;
function TCISampleBytes(T: TTCISampleType): Integer;
begin
case T of
tsyInt16: Result := 2;
tsyInt24: Result := 3;
else Result := 4; // int32 и float32
end;
end;
function TCIDefaultAudioSamples(RateHz: Integer): Integer;
begin
case RateHz of
8000: Result := 256;
12000: Result := 512;
24000: Result := 1024;
else Result := 2048; // 48 кГц и всё непонятное
end;
end;
function TCIMaxBlockSamples(T: TTCISampleType; Channels: Integer): Integer;
begin
if Channels < 1 then Channels := 1;
Result := TCI_STREAM_DATA_MAX div (TCISampleBytes(T) * Channels);
end;
procedure TCIFillHeader(out H: TTCIStreamHeader; Kind: TTCIStreamType;
Rx, RateHz: Integer; T: TTCISampleType; Count, Channels: Integer);
begin
FillChar(H, SizeOf(H), 0);
H.Receiver := LongWord(Rx);
H.SampleRate := LongWord(RateHz);
H.Format := LongWord(Ord(T));
H.DataLength := LongWord(Count * Channels);
H.StreamType := LongWord(Ord(Kind));
H.Channels := LongWord(Channels);
end;
function TCIPackSamples(const Src: array of Single; N: Integer;
T: TTCISampleType; Dest: PByte): Integer;
// Клампим на входе: перегруз в целочисленных форматах иначе заворачивается
// через знак и вместо ограничения даёт треск обратной полярности.
var
i, V: Integer;
F: Single;
P: PByte;
begin
if N > Length(Src) then N := Length(Src);
if N < 0 then N := 0;
P := Dest;
for i := 0 to N - 1 do
begin
F := Src[i];
if F > 1.0 then F := 1.0;
if F < -1.0 then F := -1.0;
case T of
tsyInt16:
begin
V := Round(F * 32767);
P[0] := Byte(V); P[1] := Byte(V shr 8);
Inc(P, 2);
end;
tsyInt24:
begin
V := Round(F * 8388607);
P[0] := Byte(V); P[1] := Byte(V shr 8); P[2] := Byte(V shr 16);
Inc(P, 3);
end;
tsyInt32:
begin
// 2^31-1 в Single не представимо точно, поэтому масштаб берём
// на единицу младше — иначе Round на полной шкале переполняется.
V := Round(F * 2147483520.0);
P[0] := Byte(V); P[1] := Byte(V shr 8);
P[2] := Byte(V shr 16); P[3] := Byte(V shr 24);
Inc(P, 4);
end;
else
begin
PSingle(P)^ := F;
Inc(P, 4);
end;
end;
end;
Result := N * TCISampleBytes(T);
end;
function TCIUnpackSamples(Src: PByte; Bytes: Integer; T: TTCISampleType;
var Dest: array of Single): Integer;
var
i, N, W: Integer;
P: PByte;
begin
Result := 0;
if (Src = nil) or (Bytes <= 0) then Exit;
N := Bytes div TCISampleBytes(T);
if N > Length(Dest) then N := Length(Dest);
P := Src;
for i := 0 to N - 1 do
begin
case T of
tsyInt16:
begin
W := SmallInt(Word(P[0]) or (Word(P[1]) shl 8));
Dest[i] := W / 32768.0;
Inc(P, 2);
end;
tsyInt24:
begin
W := LongInt(P[0]) or (LongInt(P[1]) shl 8) or (LongInt(P[2]) shl 16);
if (W and $800000) <> 0 then W := W or LongInt($FF000000);
Dest[i] := W / 8388608.0;
Inc(P, 3);
end;
tsyInt32:
begin
W := LongInt(LongWord(P[0]) or (LongWord(P[1]) shl 8) or
(LongWord(P[2]) shl 16) or (LongWord(P[3]) shl 24));
Dest[i] := W / 2147483648.0;
Inc(P, 4);
end;
else
begin
Dest[i] := PSingle(P)^;
Inc(P, 4);
end;
end;
end;
Result := N;
end;
{ ═══════════════════════════════════════════════════════════════════════════