diff --git a/TCIAdapter.pas b/TCIAdapter.pas index 3f11a52..ac2c615 100644 --- a/TCIAdapter.pas +++ b/TCIAdapter.pas @@ -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; diff --git a/TCIProtocol.pas b/TCIProtocol.pas index 1eb1b0c..66377cf 100644 --- a/TCIProtocol.pas +++ b/TCIProtocol.pas @@ -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; diff --git a/doc/TCI.md b/doc/TCI.md index 17780c2..ff88265 100644 --- a/doc/TCI.md +++ b/doc/TCI.md @@ -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` маркера). Смена любого параметра потока на ходу **перезапускает** уже идущие потоки этого клиента: блок с новой частотой посреди старого потока клиенты разбирают как мусор. diff --git a/test/tci/ft4_bench.py b/test/tci/ft4_bench.py new file mode 100755 index 0000000..4e373da --- /dev/null +++ b/test/tci/ft4_bench.py @@ -0,0 +1,377 @@ +#!/usr/bin/env python3 +"""Стенд декодирования FT4: аудио TCI против виртуального кабеля, бок о бок. + +Зачем: MSHV по виртуальному аудиокабелю FT4 декодирует, а по TCI — нет, хотя +источник звука один и тот же. Стенд снимает оба потока ОДНОВРЕМЕННО, режет их +на нарезки FT4 по единым часам и отдаёт каждую нарезку настоящему декодеру +(`jt9 --ft4` из WSJT-X). Дальше остаётся смотреть в таблицу, а не гадать. + +Три колонки не случайны: + + cable — эталон: то, что слышит MSHV сейчас (PipeWire-источник, обычно + `wsjtx_tx_source`); + tci — наш поток как есть, включая обрезание по ±1.0 при укладке в int16 + (ровно то, что делает клиент, попросивший int16); + tci-n — тот же поток, но нормированный по пику перед укладкой в WAV. + +Если декодируется `tci-n`, а `tci` — нет, значит дело в УРОВНЕ: тап RX_AUDIO +снят до регулятора громкости, и пики WDSP выходят за ±1.0 (в этом проекте они +доходили до 2.07). Всё, что выше единицы, у клиента срезается — сигнал в +водопаде становится шире и грязнее, а декодер разваливается. Если не +декодируется ни то, ни другое, а cable декодируется — смотреть надо на +непрерывность потока (потерянные блоки) и на частоту дискретизации. + +Стенд ТОЛЬКО СЛУШАЕТ: ни одной команды, способной тронуть передачу. + +Запуск (приложение работает, TCI поднят, в MSHV идёт приём FT4): + test/tci/ft4_bench.py --rx 1 --windows 4 + test/tci/ft4_bench.py --rx 0 --device wsjtx_tx_source --keep +""" + +import argparse +import math +import os +import struct +import subprocess +import sys +import tempfile +import threading +import time +import wave + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from live_rx_test import WS # noqa: E402 + +HDR = struct.Struct("<16I") +STREAM_RX_AUDIO = 1 +FMT_FLOAT32 = 3 +RATE = 12000 # то, что ждёт jt9 +FT4_PERIOD = 7.5 # длина нарезки FT4, с +FT4_SAMPLES = int(RATE * FT4_PERIOD) + + +# ── съём звука ─────────────────────────────────────────────────────────── +class TciCapture(threading.Thread): + """RX-аудио одного приёмника TCI: float32, моно, 12 кГц. + + Просим именно float32 — иначе истинный уровень не увидеть: в int16 сервер + сам зажимает всё, что выше ±1.0, и стенд мерил бы уже обрезанное. + """ + + def __init__(self, host, port, rx, channels=2, like_mshv=True): + super().__init__(daemon=True) + self.ws = WS(host, port) + self.rx = rx + self.channels = channels + # ★Разбирать блок КАК MSHV: он берёт число байт из поля length + # (`cr2 = pStream->length*bit_s`) и шагает кадрами `chan*bit_s`, то + # есть считает length вещественными отсчётами ВСЕГО блока. Если сервер + # понимает length как «на канал», у стерео разбирается ровно половина + # блока — и звук у клиента идёт с дырами. Режим --raw читает всю + # полезную нагрузку, игнорируя length: так видно, что данные-то целы. + self.like_mshv = like_mshv + self.short_blocks = 0 + self.samples = [] # float + self.t0 = None # время первого отсчёта, monotonic + self.blocks = 0 + self.bad_rate = 0 + self.stop_flag = False + self.lock = threading.Lock() + + def start_stream(self): + self.ws.send_text(f"audio_samplerate:{RATE};") + self.ws.send_text("audio_stream_sample_type:float32;") + self.ws.send_text(f"audio_stream_channels:{self.channels};") + self.ws.send_text("audio_stream_samples:512;") + self.ws.send_text(f"audio_start:{self.rx};") + + def run(self): + while not self.stop_flag: + frame = self.ws.recv_frame(0.3) + if not frame: + continue + op, payload = frame + if op != 2 or len(payload) < HDR.size: + continue + head = HDR.unpack(payload[:HDR.size]) + receiver, srate, fmt, length = head[0], head[1], head[2], head[5] + stype, chans = head[6], head[7] + if stype != STREAM_RX_AUDIO or receiver != self.rx: + continue + if srate != RATE: + self.bad_rate += 1 + continue + data = payload[HDR.size:] + if self.like_mshv: + want = int(length) * 4 # cr2 = length * bit_s + if want < len(data): + self.short_blocks += 1 + data = data[:min(want, len(data))] + n = len(data) // 4 + vals = struct.unpack(f"<{n}f", data[:n * 4]) + if chans == 2: # берём левый канал + vals = vals[0::2] + now = time.monotonic() + with self.lock: + if self.t0 is None: + self.t0 = now - len(vals) / RATE + self.samples.extend(vals) + self.blocks += 1 + _ = length + + def close(self): + self.stop_flag = True + try: + self.ws.send_text(f"audio_stop:{self.rx};") + time.sleep(0.2) + except OSError: + pass + self.ws.close() + + +class CableCapture(threading.Thread): + """Виртуальный кабель через parec: моно, 12 кГц, s16 — эталонная дорожка.""" + + def __init__(self, device): + super().__init__(daemon=True) + self.proc = subprocess.Popen( + ["parec", f"--device={device}", "--format=s16le", + f"--rate={RATE}", "--channels=1", "--latency-msec=50"], + stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) + self.samples = [] + self.t0 = None + self.stop_flag = False + self.lock = threading.Lock() + + def run(self): + while not self.stop_flag: + block = self.proc.stdout.read(4096) + if not block: + break + n = len(block) // 2 + vals = struct.unpack(f"<{n}h", block[:n * 2]) + now = time.monotonic() + with self.lock: + if self.t0 is None: + self.t0 = now - n / RATE + self.samples.extend(v / 32768.0 for v in vals) + + def close(self): + self.stop_flag = True + try: + self.proc.terminate() + self.proc.wait(timeout=2) + except Exception: + self.proc.kill() + + +# ── нарезка, WAV, декодер ──────────────────────────────────────────────── +def cut(cap, t_start, count): + """Отсчёты, начинающиеся в момент t_start (monotonic). None — не хватило.""" + with cap.lock: + if cap.t0 is None: + return None + first = int(round((t_start - cap.t0) * RATE)) + if first < 0 or first + count > len(cap.samples): + return None + return list(cap.samples[first:first + count]) + + +def stats(vals): + if not vals: + return 0.0, 0.0, 0.0 + peak = max(abs(v) for v in vals) + rms = math.sqrt(sum(v * v for v in vals) / len(vals)) + over = sum(1 for v in vals if abs(v) >= 1.0) + return peak, rms, 100.0 * over / len(vals) + + +def write_wav(path, vals, normalize): + scale = 1.0 + if normalize: + peak = max((abs(v) for v in vals), default=0.0) + if peak > 1e-9: + scale = 0.9 / peak + frames = bytearray() + for v in vals: + s = int(round(v * scale * 32767.0)) + frames += struct.pack(""] + out = [] + for line in res.stdout.splitlines(): + line = line.strip() + if not line or line.startswith("8} | {'cable':>5} | {'tci':>5} | {'tci-n':>5} | " + f"уровень TCI (пик / RMS / за ±1.0)") + print("-" * 78) + + # Нарезки FT4 идут по единым часам: старт кратен 7.5 с от начала минуты. + wall = time.time() + mono = time.monotonic() + n_ok = {"cable": 0, "tci": 0, "tcin": 0} + done = 0 + # ★Потолок по времени: если поток идёт медленнее реального времени + # (клиент разбирает не весь блок), нарезка не наберётся НИКОГДА, и без + # этого потолка стенд просто висел бы. Само по себе срабатывание — + # диагноз, поэтому ниже печатается темп набора. + started = time.monotonic() + limit = started + args.windows * FT4_PERIOD * 3 + 25 + while done < args.windows: + if time.monotonic() > limit: + with tci.lock: + got = len(tci.samples) / RATE + elapsed = time.monotonic() - started + print("-" * 78) + print(f" FAIL нарезки не набираются: за {elapsed:.1f} с " + f"реального времени поток TCI дал {got:.1f} с звука " + f"({100.0 * got / max(elapsed, 0.1):.0f}% темпа)") + if tci.short_blocks: + print(f" ★и {tci.short_blocks} блоков разобраны НЕ ЦЕЛИКОМ: " + f"поле length меньше присланных данных — клиент вроде " + f"MSHV теряет остаток каждого блока") + break + now_wall = time.time() + seq = math.floor(now_wall / FT4_PERIOD) * FT4_PERIOD + t_start_wall = seq - FT4_PERIOD # предыдущая, уже целая + t_start = mono + (t_start_wall - wall) + # Ждём, пока накопится хвост нарезки у обоих потоков. + block_tci = cut(tci, t_start, FT4_SAMPLES) + block_cab = None if cable is None else cut(cable, t_start, FT4_SAMPLES) + if block_tci is None or (cable is not None and block_cab is None): + time.sleep(0.3) + continue + + label = time.strftime("%H:%M:%S", time.localtime(t_start_wall)) + peak, rms, over = stats(block_tci) + + f_tci = os.path.join(workdir, f"tci_{label.replace(':', '')}.wav") + f_tcn = os.path.join(workdir, f"tcin_{label.replace(':', '')}.wav") + write_wav(f_tci, block_tci, normalize=False) + write_wav(f_tcn, block_tci, normalize=True) + d_tci = decode(f_tci, workdir) + d_tcn = decode(f_tcn, workdir) + d_cab = [] + if block_cab is not None: + f_cab = os.path.join(workdir, f"cable_{label.replace(':', '')}.wav") + write_wav(f_cab, block_cab, normalize=False) + d_cab = decode(f_cab, workdir) + + n_ok["cable"] += len(d_cab) + n_ok["tci"] += len(d_tci) + n_ok["tcin"] += len(d_tcn) + print(f"{label:>8} | {len(d_cab):>5} | {len(d_tci):>5} | " + f"{len(d_tcn):>5} | пик {peak:.3f} RMS {rms:.3f} " + f"{over:.2f}%") + for src, lines in (("cable", d_cab), ("tci", d_tci), ("tci-n", d_tcn)): + for line in lines[:3]: + print(f" {src:>5}: {line}") + done += 1 + # Следующая нарезка появится не раньше чем через период. + time.sleep(FT4_PERIOD * 0.9) + + print("-" * 78) + print(f"Итого декодов: cable {n_ok['cable']}, tci {n_ok['tci']}, " + f"tci-n {n_ok['tcin']}") + if tci.short_blocks: + print(f"★блоков, у которых length меньше присланных данных: " + f"{tci.short_blocks} — клиент вроде MSHV разбирает только " + f"часть блока, остальное теряется") + if n_ok["tci"] == 0 and n_ok["tcin"] > 0: + print("⇒ дело в УРОВНЕ: поток TCI режется по ±1.0 (см. шапку стенда)") + elif n_ok["tci"] == 0 and n_ok["cable"] > 0: + print("⇒ уровень ни при чём: смотреть непрерывность потока и частоту") + finally: + tci.close() + if cable is not None: + cable.close() + if not args.keep: + for name in os.listdir(workdir): + try: + os.unlink(os.path.join(workdir, name)) + except OSError: + pass + try: + os.rmdir(workdir) + except OSError: + pass + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/test/tci/tcitest.pas b/test/tci/tcitest.pas index 4e401f6..dd35972 100644 --- a/test/tci/tcitest.pas +++ b/test/tci/tcitest.pas @@ -100,9 +100,10 @@ begin Check('header receiver', H.Receiver = 2); Check('header rate', H.SampleRate = 12000); Check('header format', H.Format = LongWord(Ord(tsyInt16))); - // ★Аудио: length — сэмплы НА КАНАЛ, ровно то число, что назвал - // AUDIO_STREAM_SAMPLES (§4.3). Раньше сюда уходило вдвое больше. - Check('header length аудио = на канал', H.DataLength = 512); + // ★length — вещественные отсчёты ВСЕГО блока, и у аудио тоже: клиенты + // считают по нему число байт (MSHV: `cr2 = length*bit_s`). Объявишь «на + // канал» — у стерео разберётся половина блока, и звук пойдёт с дырами. + Check('header length аудио = ×каналы', H.DataLength = 1024); Check('header type', H.StreamType = LongWord(Ord(tstRXAudio))); Check('header channels', H.Channels = 2); Check('header codec/crc', (H.Codec = 0) and (H.CRC = 0)); @@ -111,10 +112,13 @@ begin // комплексных (§3.4 прямо: комплексных = length/channels). TCIFillHeader(H, tstIQ, 0, 48000, tsyFloat32, 512, 2); Check('header length IQ = ×каналы', H.DataLength = 1024); - // Маркер времени TX_CHRONO живёт по правилам аудио: он называет клиенту + // Маркер времени TX_CHRONO живёт по тем же правилам: он называет клиенту // размер блока, который тот пришлёт (§3.4). TCIFillHeader(H, tstTXChrono, 0, 12000, tsyFloat32, 256, 2); - Check('header length chrono = на канал', H.DataLength = 256); + Check('header length chrono = ×каналы', H.DataLength = 512); + // Моно: отсчётов столько же, сколько сэмплов на канал. + TCIFillHeader(H, tstRXAudio, 0, 12000, tsyInt16, 512, 1); + Check('header length моно = сэмплы', H.DataLength = 512); // Упаковка/распаковка: сквозной проход по всем форматам. for i := 0 to 7 do Src[i] := (i - 4) / 8.0; // −0.5 … +0.375 @@ -426,10 +430,12 @@ begin if Length(Pay) < SizeOf(H) then begin OkHdr := False; Continue; end; Move(Pay[0], H, SizeOf(H)); if (H.SampleRate <> 12000) or (H.Channels <> 2) or - (H.DataLength <> 512) or // сэмплов НА КАНАЛ + (H.DataLength <> 1024) or // вещественных отсчётов = 512 × 2 (H.StreamType <> LongWord(Ord(tstRXAudio))) or (H.Format <> LongWord(Ord(tsyFloat32))) or - (Length(Pay) <> SizeOf(H) + 512 * 2 * 4) then OkHdr := False; + // ★Правило, по которому живут клиенты: байт данных = length × размер + // отсчёта. Ровно так считает MSHV (`cr2 = length*bit_s`). + (Length(Pay) <> SizeOf(H) + Integer(H.DataLength) * 4) then OkHdr := False; Inc(Blocks); end; Check('аудио: 4 блока', Blocks = 4, IntToStr(Blocks)); @@ -1279,12 +1285,11 @@ begin if H.StreamType = LongWord(Ord(tstRXAudio)) then begin Inc(AudioBlocks); - // В аудиопотоке length — сэмплы НА КАНАЛ (AUDIO_STREAM_SAMPLES), то - // есть ровно то, что просили, а байт в блоке length × channels × 4. + // length — вещественные отсчёты всего блока: просили 256 сэмплов на + // канал, каналов 2, значит 512 отсчётов и столько же × 4 байта. if (H.SampleRate <> 12000) or (H.Receiver <> 0) or - (H.DataLength <> 256) or - (Length(Pay) <> SizeOf(H) + - Integer(H.DataLength) * Integer(H.Channels) * 4) then + (H.DataLength <> 256 * Integer(H.Channels)) or + (Length(Pay) <> SizeOf(H) + Integer(H.DataLength) * 4) then Inc(BadHdr); end else if H.StreamType = LongWord(Ord(tstIQ)) then @@ -1394,7 +1399,8 @@ begin if H.StreamType = LongWord(Ord(tstTXChrono)) then begin Inc(n); - if (H.SampleRate <> 12000) or (H.DataLength <> 256) then + if (H.SampleRate <> 12000) or + (H.DataLength <> 256 * Integer(H.Channels)) then Inc(BadHdr); end; end;