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 try
N := TCIUnpackSamples(P, Bytes, ST, FTxRaw); N := TCIUnpackSamples(P, Bytes, ST, FTxRaw);
if N <= 0 then Exit; if N <= 0 then Exit;
// В аудиопотоке length — это сэмплы НА КАНАЛ (см. TCIFillHeader), значит // length — вещественные отсчёты всего блока (см. TCIFillHeader), то есть
// вещественных отсчётов в блоке length × channels. Верим меньшему из двух: // ровно то, сколько их и распаковалось. Верим меньшему из двух: клиент
// клиент вправе прислать короткий хвост, но не длиннее уместившегося. // вправе прислать короткий хвост, но не длиннее уместившегося. Заведомо
// Заведомо чужое число (мусор в заголовке) просто игнорируем — длину нам // чужое число (мусор в заголовке) просто игнорируем — длину нам и так
// и так ограничил размер кадра. // ограничил размер кадра. Так же считает и MSHV, когда сам заполняет
if (H.DataLength > 0) and (H.DataLength <= LongWord(N)) then // заголовок: `t_txStream->length = (cr3/bit_s)`, где cr3 — байты блока.
if H.DataLength > 0 then
begin begin
Want := Integer(H.DataLength) * Chans; Want := Integer(H.DataLength);
if Want < N then N := Want; if Want < N then N := Want;
end; end;
+29 -15
View File
@@ -87,8 +87,8 @@ type
Format: LongWord; // TTCISampleType Format: LongWord; // TTCISampleType
Codec: LongWord; // всегда 0 (сжатие не реализовано) Codec: LongWord; // всегда 0 (сжатие не реализовано)
CRC: LongWord; // всегда 0 CRC: LongWord; // всегда 0
DataLength: LongWord; // IQ — вещественных отсчётов, аудио — на канал DataLength: LongWord; // вещественных отсчётов всего блока
// (см. TCIFillHeader) // (комплексных/на канал = length/channels)
StreamType: LongWord; // TTCIStreamType StreamType: LongWord; // TTCIStreamType
Channels: LongWord; Channels: LongWord;
Reserv: array[0..7] of LongWord; Reserv: array[0..7] of LongWord;
@@ -134,14 +134,30 @@ function TCIDefaultAudioSamples(RateHz: Integer): Integer;
{ Сколько сэмплов на канал влезает в блок с таким форматом и числом каналов. } { Сколько сэмплов на канал влезает в блок с таким форматом и числом каналов. }
function TCIMaxBlockSamples(T: TTCISampleType; Channels: Integer): Integer; function TCIMaxBlockSamples(T: TTCISampleType; Channels: Integer): Integer;
{ Заголовок блока. Count сэмплов НА КАНАЛ. В DataLength уходит то, что для { Заголовок блока. Count сэмплов НА КАНАЛ, а в DataLength всегда уходит
этого типа потока велит документ, а он для IQ и для аудио говорит РАЗНОЕ: Count × Channels: length измеряется ВЕЩЕСТВЕННЫМИ ОТСЧЁТАМИ всего блока, и
IQ (§3.4): «количество вещественных отсчётов… количество комплексных для IQ, и для аудио.
вычисляется как Stream.length/Stream.channels» Count × Channels;
аудио (§4.3, AUDIO_STREAM_SAMPLES): «arg1 количество сэмплов, Для IQ так прямо написано в §3.4 («количество комплексных вычисляется как
указываемое в поле Stream.length», по умолчанию 2048 при 48 кГц Count, Stream.length/Stream.channels»), а для аудио формулировка §4.3 про
то есть сэмплов НА КАНАЛ. Сходится и с потолком data[16384]: AUDIO_STREAM_SAMPLES («arg1 количество сэмплов, указываемое в поле
2048 × 2 канала × float32 ровно 16384 байта. } 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; procedure TCIFillHeader(out H: TTCIStreamHeader; Kind: TTCIStreamType;
Rx, RateHz: Integer; T: TTCISampleType; Count, Channels: Integer); Rx, RateHz: Integer; T: TTCISampleType; Count, Channels: Integer);
@@ -399,11 +415,9 @@ begin
H.Receiver := LongWord(Rx); H.Receiver := LongWord(Rx);
H.SampleRate := LongWord(RateHz); H.SampleRate := LongWord(RateHz);
H.Format := LongWord(Ord(T)); H.Format := LongWord(Ord(T));
// ★Единицы length разные у IQ и у аудио — см. шапку объявления. // ★length — вещественные отсчёты всего блока, у всех типов потока одинаково
if Kind = tstIQ then // (см. шапку объявления: «на канал» ломает реальных клиентов).
H.DataLength := LongWord(Count * Channels) H.DataLength := LongWord(Count * Channels);
else
H.DataLength := LongWord(Count);
H.StreamType := LongWord(Ord(Kind)); H.StreamType := LongWord(Ord(Kind));
H.Channels := LongWord(Channels); H.Channels := LongWord(Channels);
end; end;
+18 -9
View File
@@ -456,15 +456,24 @@ TCI-клиент отобрал бы звук у динамика и у брау
в заголовке мы не будем ни в каком случае. Смена rate устройства или DDC пана в заголовке мы не будем ни в каком случае. Смена rate устройства или DDC пана
на ходу пересобирает прореживание (`SetSourceRate`). на ходу пересобирает прореживание (`SetSourceRate`).
**Размер блока.** `AUDIO_STREAM_SAMPLES` — это сэмплы **на канал**, и у **Размер блока.** Две разные величины, которые §4.3 называет одним словом.
аудиопотоков ровно это число уходит в `Stream.length`: §4.3 говорит про arg1 `AUDIO_STREAM_SAMPLES` — это кадры **на канал** (умолчание 2048 при 48 кГц =
«количество сэмплов, указываемое в поле Stream.length», а умолчание 2048 при 42.7 мс), а `Stream.length` — вещественные отсчёты **всего блока**, то есть
48 кГц сходится с потолком `data[16384]` только так — 2048 × 2 канала × `кадры × channels`, и так у **всех** типов потока. Единицу `length` задаёт
float32 = ровно 16384 байта, то есть `length` считает сэмплы, а не отсчёты. §3.4 для IQ («количество вещественных отсчётов… комплексных =
У **IQ** единица другая: §3.4 определяет число комплексных отсчётов как `length/channels`») и тут же распространяет на аудио: «аудиопоток приёмника
`length / channels`, поэтому там в `length` уходит `samples × channels`. полностью повторяет IQ поток», отличия перечислены и их ровно три — каналы,
Развилку держит `TCIFillHeader` (по типу потока), обратную — разбор формат сэмплов, число сэмплов в пакете. Формулировка §4.3 «arg1 — количество
`TX_AUDIO_STREAM` в `HandleBinary`. Смена любого параметра потока на ходу сэмплов, указываемое в поле 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` маркера). Смена любого параметра потока на ходу
**перезапускает** уже идущие потоки этого клиента: блок с новой частотой **перезапускает** уже идущие потоки этого клиента: блок с новой частотой
посреди старого потока клиенты разбирают как мусор. посреди старого потока клиенты разбирают как мусор.
+377
View File
@@ -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("<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())
+19 -13
View File
@@ -100,9 +100,10 @@ begin
Check('header receiver', H.Receiver = 2); Check('header receiver', H.Receiver = 2);
Check('header rate', H.SampleRate = 12000); Check('header rate', H.SampleRate = 12000);
Check('header format', H.Format = LongWord(Ord(tsyInt16))); Check('header format', H.Format = LongWord(Ord(tsyInt16)));
// ★Аудио: length — сэмплы НА КАНАЛ, ровно то число, что назвал // ★length — вещественные отсчёты ВСЕГО блока, и у аудио тоже: клиенты
// AUDIO_STREAM_SAMPLES (§4.3). Раньше сюда уходило вдвое больше. // считают по нему число байт (MSHV: `cr2 = length*bit_s`). Объявишь «на
Check('header length аудио = на канал', H.DataLength = 512); // канал» — у стерео разберётся половина блока, и звук пойдёт с дырами.
Check('header length аудио = ×каналы', H.DataLength = 1024);
Check('header type', H.StreamType = LongWord(Ord(tstRXAudio))); Check('header type', H.StreamType = LongWord(Ord(tstRXAudio)));
Check('header channels', H.Channels = 2); Check('header channels', H.Channels = 2);
Check('header codec/crc', (H.Codec = 0) and (H.CRC = 0)); Check('header codec/crc', (H.Codec = 0) and (H.CRC = 0));
@@ -111,10 +112,13 @@ begin
// комплексных (§3.4 прямо: комплексных = length/channels). // комплексных (§3.4 прямо: комплексных = length/channels).
TCIFillHeader(H, tstIQ, 0, 48000, tsyFloat32, 512, 2); TCIFillHeader(H, tstIQ, 0, 48000, tsyFloat32, 512, 2);
Check('header length IQ = ×каналы', H.DataLength = 1024); Check('header length IQ = ×каналы', H.DataLength = 1024);
// Маркер времени TX_CHRONO живёт по правилам аудио: он называет клиенту // Маркер времени TX_CHRONO живёт по тем же правилам: он называет клиенту
// размер блока, который тот пришлёт (§3.4). // размер блока, который тот пришлёт (§3.4).
TCIFillHeader(H, tstTXChrono, 0, 12000, tsyFloat32, 256, 2); 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 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; if Length(Pay) < SizeOf(H) then begin OkHdr := False; Continue; end;
Move(Pay[0], H, SizeOf(H)); Move(Pay[0], H, SizeOf(H));
if (H.SampleRate <> 12000) or (H.Channels <> 2) or 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.StreamType <> LongWord(Ord(tstRXAudio))) or
(H.Format <> LongWord(Ord(tsyFloat32))) 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); Inc(Blocks);
end; end;
Check('аудио: 4 блока', Blocks = 4, IntToStr(Blocks)); Check('аудио: 4 блока', Blocks = 4, IntToStr(Blocks));
@@ -1279,12 +1285,11 @@ begin
if H.StreamType = LongWord(Ord(tstRXAudio)) then if H.StreamType = LongWord(Ord(tstRXAudio)) then
begin begin
Inc(AudioBlocks); Inc(AudioBlocks);
// В аудиопотоке length — сэмплы НА КАНАЛ (AUDIO_STREAM_SAMPLES), то // length — вещественные отсчёты всего блока: просили 256 сэмплов на
// есть ровно то, что просили, а байт в блоке length × channels × 4. // канал, каналов 2, значит 512 отсчётов и столько же × 4 байта.
if (H.SampleRate <> 12000) or (H.Receiver <> 0) or if (H.SampleRate <> 12000) or (H.Receiver <> 0) or
(H.DataLength <> 256) or (H.DataLength <> 256 * Integer(H.Channels)) or
(Length(Pay) <> SizeOf(H) + (Length(Pay) <> SizeOf(H) + Integer(H.DataLength) * 4) then
Integer(H.DataLength) * Integer(H.Channels) * 4) then
Inc(BadHdr); Inc(BadHdr);
end end
else if H.StreamType = LongWord(Ord(tstIQ)) then else if H.StreamType = LongWord(Ord(tstIQ)) then
@@ -1394,7 +1399,8 @@ begin
if H.StreamType = LongWord(Ord(tstTXChrono)) then if H.StreamType = LongWord(Ord(tstTXChrono)) then
begin begin
Inc(n); 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); Inc(BadHdr);
end; end;
end; end;