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
+29 -15
View File
@@ -87,8 +87,8 @@ type
Format: LongWord; // TTCISampleType
Codec: LongWord; // всегда 0 (сжатие не реализовано)
CRC: LongWord; // всегда 0
DataLength: LongWord; // IQ — вещественных отсчётов, аудио — на канал
// (см. TCIFillHeader)
DataLength: LongWord; // вещественных отсчётов всего блока
// (комплексных/на канал = length/channels)
StreamType: LongWord; // TTCIStreamType
Channels: LongWord;
Reserv: array[0..7] of LongWord;
@@ -134,14 +134,30 @@ function TCIDefaultAudioSamples(RateHz: Integer): Integer;
{ Сколько сэмплов на канал влезает в блок с таким форматом и числом каналов. }
function TCIMaxBlockSamples(T: TTCISampleType; Channels: Integer): Integer;
{ Заголовок блока. Count — сэмплов НА КАНАЛ. В DataLength уходит то, что для
этого типа потока велит документ, а он для IQ и для аудио говорит РАЗНОЕ:
• IQ (§3.4): «количество вещественных отсчётов… количество комплексных
вычисляется как Stream.length/Stream.channels» ⇒ Count × Channels;
• аудио (§4.3, AUDIO_STREAM_SAMPLES): «arg1 — количество сэмплов,
указываемое в поле Stream.length», по умолчанию 2048 при 48 кГц ⇒ Count,
то есть сэмплов НА КАНАЛ. Сходится и с потолком data[16384]:
2048 × 2 канала × float32 — ровно 16384 байта. }
{ Заголовок блока. Count — сэмплов НА КАНАЛ, а в DataLength всегда уходит
Count × Channels: length измеряется ВЕЩЕСТВЕННЫМИ ОТСЧЁТАМИ всего блока, и
для IQ, и для аудио.
Для IQ так прямо написано в §3.4 («количество комплексных вычисляется как
Stream.length/Stream.channels»), а для аудио формулировка §4.3 про
AUDIO_STREAM_SAMPLES («arg1 — количество сэмплов, указываемое в поле
Stream.length») однажды уже увела нас в «сэмплы на канал». ★Так делать
нельзя, и вот доказательство из живого клиента — MSHV, network.cpp:
int cr2 = (int)pStream->length*bit_s; // сколько БАЙТ разбирать
for (int i = 0; i < cr2; i+=chan*bit_s) // шаг = кадр всех каналов
...
quint32 cr3 = pStream->length*bit_s; //plength = plength*channels*4-bytes
То есть клиент считает по length число байт блока. Стоит объявить length «на
канал» — и у стерео он разбирает ровно половину блока, а вторую выбрасывает:
звук идёт с дырами в 50%, водопад становится шире и грязнее, FT4/FT8 не
декодируются вовсе. Замерено стендом test/tci/ft4_bench.py: 50% темпа набора
звука и 1649 не до конца разобранных блоков за минуту.
Размер блока при этом остаётся прежним: AUDIO_STREAM_SAMPLES — сэмплы НА
КАНАЛ (2048 при 48 кГц = 42.7 мс, как у ExpertSDR3), и потолок data[16384]
сходится ровно: 2048 × 2 канала × float32. }
procedure TCIFillHeader(out H: TTCIStreamHeader; Kind: TTCIStreamType;
Rx, RateHz: Integer; T: TTCISampleType; Count, Channels: Integer);
@@ -399,11 +415,9 @@ begin
H.Receiver := LongWord(Rx);
H.SampleRate := LongWord(RateHz);
H.Format := LongWord(Ord(T));
// ★Единицы length разные у IQ и у аудио — см. шапку объявления.
if Kind = tstIQ then
H.DataLength := LongWord(Count * Channels)
else
H.DataLength := LongWord(Count);
// ★length — вещественные отсчёты всего блока, у всех типов потока одинаково
// (см. шапку объявления: «на канал» ломает реальных клиентов).
H.DataLength := LongWord(Count * Channels);
H.StreamType := LongWord(Ord(Kind));
H.Channels := LongWord(Channels);
end;