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
+8 -7
View File
@@ -3255,14 +3255,15 @@ begin
try
N := TCIUnpackSamples(P, Bytes, ST, FTxRaw);
if N <= 0 then Exit;
// В аудиопотоке length — это сэмплы НА КАНАЛ (см. TCIFillHeader), значит
// вещественных отсчётов в блоке length × channels. Верим меньшему из двух:
// клиент вправе прислать короткий хвост, но не длиннее уместившегося.
// Заведомо чужое число (мусор в заголовке) просто игнорируем — длину нам
// и так ограничил размер кадра.
if (H.DataLength > 0) and (H.DataLength <= LongWord(N)) then
// length — вещественные отсчёты всего блока (см. TCIFillHeader), то есть
// ровно то, сколько их и распаковалось. Верим меньшему из двух: клиент
// вправе прислать короткий хвост, но не длиннее уместившегося. Заведомо
// чужое число (мусор в заголовке) просто игнорируем — длину нам и так
// ограничил размер кадра. Так же считает и MSHV, когда сам заполняет
// заголовок: `t_txStream->length = (cr3/bit_s)`, где cr3 — байты блока.
if H.DataLength > 0 then
begin
Want := Integer(H.DataLength) * Chans;
Want := Integer(H.DataLength);
if Want < N then N := Want;
end;