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:
+205
-5
@@ -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;
|
||||
|
||||
{ ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
Reference in New Issue
Block a user