Files
ew8bakandClaude Opus 5 adbb8d02ac 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>
2026-08-19 12:26:29 +03:00

378 lines
17 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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("<h", max(-32768, min(32767, s)))
with wave.open(path, "wb") as w:
w.setnchannels(1)
w.setsampwidth(2)
w.setframerate(RATE)
w.writeframes(bytes(frames))
def decode(path, workdir):
"""jt9 --ft4. Возвращает список строк декодера."""
try:
res = subprocess.run(
["jt9", "--ft4", "-p", "7.5", "-d", "3", "-L", "100", "-H", "3000",
"-a", workdir, "-t", workdir, os.path.abspath(path)],
cwd=workdir, capture_output=True, text=True, timeout=60)
except FileNotFoundError:
print("jt9 не найден — поставьте WSJT-X", file=sys.stderr)
sys.exit(2)
except subprocess.TimeoutExpired:
return ["<таймаут декодера>"]
out = []
for line in res.stdout.splitlines():
line = line.strip()
if not line or line.startswith("<Decode"):
continue
out.append(line)
return out
def main():
ap = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--host", default="127.0.0.1")
ap.add_argument("--port", type=int, default=40001)
ap.add_argument("--rx", type=int, default=1,
help="приёмник TCI (1 = «TCI Client rx2» в MSHV)")
ap.add_argument("--device", default="wsjtx_tx_source",
help="источник PipeWire с тем же звуком (виртуальный кабель)")
ap.add_argument("--windows", type=int, default=4,
help="сколько нарезок FT4 снять (по 7.5 с)")
ap.add_argument("--channels", type=int, default=2, choices=(1, 2),
help="каналов в потоке TCI (2 = как просит MSHV)")
ap.add_argument("--raw", action="store_true",
help="читать всю полезную нагрузку, игнорируя поле length "
"(MSHV так НЕ умеет — см. шапку)")
ap.add_argument("--no-cable", action="store_true",
help="без эталона: только поток TCI")
ap.add_argument("--keep", action="store_true",
help="оставить WAV-файлы и назвать каталог")
args = ap.parse_args()
workdir = tempfile.mkdtemp(prefix="ft4bench-")
print(f"Стенд FT4: приёмник TCI {args.rx}"
+ ("" if args.no_cable else f", эталон — {args.device}"))
print(f"рабочий каталог: {workdir}")
tci = TciCapture(args.host, args.port, args.rx, args.channels,
like_mshv=not args.raw)
cable = None
try:
tci.start()
time.sleep(0.5)
tci.start_stream()
if not args.no_cable:
cable = CableCapture(args.device)
cable.start()
# Ждём, пока пойдут оба потока: иначе первая нарезка окажется пустой.
deadline = time.monotonic() + 5
while time.monotonic() < deadline:
if tci.t0 is not None and (cable is None or cable.t0 is not None):
break
time.sleep(0.1)
if tci.t0 is None:
print(" FAIL блоков RX-аудио от TCI нет вовсе"
+ (f" (заголовков с чужой частотой: {tci.bad_rate})"
if tci.bad_rate else ""))
return 1
if cable is not None and cable.t0 is None:
print(f" FAIL с источника {args.device} звук не идёт")
return 1
print(f"{'нарезка':>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())