fix(tci): Stream.length — вещественные отсчёты всего блока, а не на канал

MSHV не декодировал FT4 по TCI (по виртуальному кабелю — декодировал).
Регрессия из dc6f996: там length аудио был переведён на «сэмплы на канал»
по формулировке §4.3, а живые клиенты считают по нему БАЙТЫ блока
(network.cpp: `int cr2 = pStream->length*bit_s;` с шагом `chan*bit_s`,
на передаче `quint32 cr3 = pStream->length*bit_s;`). При Channels=2
(умолчание MSHV) клиент разбирал половину каждого блока: звук с дырами
50%, водопад шире и грязнее, декодер разваливался. В моно дефекта не
видно — единицы совпадают, поэтому первый прогон стенда увёл в сторону.

Правка верна и по документу, а не только по клиенту: §3.4 после IQ
(«количество вещественных отсчётов… комплексных = length/channels»)
говорит «аудиопоток приёмника ПОЛНОСТЬЮ ПОВТОРЯЕТ IQ поток» и
перечисляет ровно три отличия — каналы, формат сэмплов, число сэмплов в
пакете. Единиц length среди них нет, поле в struct Stream одно.
Развилка по типу потока была вычитана из воздуха.

§4.3 путает две величины, и её формулировка верна лишь для моно:
AUDIO_STREAM_SAMPLES — кадры НА КАНАЛ, Stream.length — отсчёты ВСЕГО
блока. Что arg1 считает кадры, видно из самой §4.3 дважды: минимум
512/256/128/100 на 48/24/12/8 кГц даёт обещанные «не меньше 10 мс»
только при счёте на канал, и потолок data[16384] = 2048 × 2 × float32.
TX_CHRONO замыкает круг: клиент шлёт столько отсчётов, сколько названо
в length маркера, и возвращает то же число обратно.

Размер блока не менялся (2048@48к = 42.7 мс). Гипотеза про клиппинг
тапа RX_AUDIO проверена замером и снята: пик 0.115.

Стенд: проверки length переписаны на инвариант «байт = length × размер
отсчёта»; новый test/tci/ft4_bench.py снимает поток TCI и PipeWire-
источник одновременно, режет на нарезки FT4 по общим часам и гоняет
через настоящий jt9 --ft4 (блок разбирает КАК MSHV — этим и поймал).
Проверено вживую: MSHV декодирует FT4 по TCI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 12:26:29 +03:00
co-authored by Claude Opus 5
parent bb48f5d3fa
commit adbb8d02ac
5 changed files with 451 additions and 44 deletions
+18 -9
View File
@@ -456,15 +456,24 @@ TCI-клиент отобрал бы звук у динамика и у брау
в заголовке мы не будем ни в каком случае. Смена rate устройства или DDC пана
на ходу пересобирает прореживание (`SetSourceRate`).
**Размер блока.** `AUDIO_STREAM_SAMPLES` — это сэмплы **на канал**, и у
аудиопотоков ровно это число уходит в `Stream.length`: §4.3 говорит про arg1
«количество сэмплов, указываемое в поле Stream.length», а умолчание 2048 при
48 кГц сходится с потолком `data[16384]` только так — 2048 × 2 канала ×
float32 = ровно 16384 байта, то есть `length` считает сэмплы, а не отсчёты.
У **IQ** единица другая: §3.4 определяет число комплексных отсчётов как
`length / channels`, поэтому там в `length` уходит `samples × channels`.
Развилку держит `TCIFillHeader` (по типу потока), обратную — разбор
`TX_AUDIO_STREAM` в `HandleBinary`. Смена любого параметра потока на ходу
**Размер блока.** Две разные величины, которые §4.3 называет одним словом.
`AUDIO_STREAM_SAMPLES` — это кадры **на канал** (умолчание 2048 при 48 кГц =
42.7 мс), а `Stream.length` — вещественные отсчёты **всего блока**, то есть
`кадры × channels`, и так у **всех** типов потока. Единицу `length` задаёт
§3.4 для IQ («количество вещественных отсчётов… комплексных =
`length/channels`») и тут же распространяет на аудио: «аудиопоток приёмника
полностью повторяет IQ поток», отличия перечислены и их ровно три — каналы,
формат сэмплов, число сэмплов в пакете. Формулировка §4.3 «arg1 — количество
сэмплов, указываемое в поле Stream.length» верна только для моно, и однажды
она уже увела нас в развилку по типу потока: у стерео клиент разбирал
половину блока (звук с дырами 50%, FT4/FT8 не декодировались вовсе). Что
`arg1` считает именно кадры на канал, видно из самой же §4.3: рекомендуемый
минимум 512/256/128/100 сэмплов на 48/24/12/8 кГц даёт «не меньше 10 мс»
только при счёте на канал, и потолок `data[16384]` сходится тем же счётом —
2048 × 2 канала × float32 = ровно 16384 байта. Единое правило держит
`TCIFillHeader`, обратное — разбор `TX_AUDIO_STREAM` в `HandleBinary`;
`TX_CHRONO` замыкает круг (клиент шлёт столько отсчётов, сколько названо в
`length` маркера). Смена любого параметра потока на ходу
**перезапускает** уже идущие потоки этого клиента: блок с новой частотой
посреди старого потока клиенты разбирают как мусор.