Files
ewsdr/test/hpsdr/txtrace_scan.py
T
ew8bakandClaude Opus 5 d2096976df fix(tci): всплески на передаче — TX-аудио просилось общим тиком 20 мс
Посреди передачи из MSHV на водопаде появлялись всплески своего сигнала.
Цепочка: очередь DUC пустеет дольше подушки отправителя (DUC_FIFO_THROTTLE
= 2000 отсчётов = 10.4 мс) → FIFO радио сохнет → модуляция обрывается → в
эфире остаётся голая несущая на частоте гетеродина DUC, в стороне от тона
ровно на звуковой сдвиг. Доказано pcap-съёмом: шесть всплесков в дампе —
ровно столько, сколько видел оператор, и каждый стоит за паузой 10.3-23.5 мс,
а паузы 8 мс и короче не дали ни одного.

Виноват не клиент и не блокировка UI, а зернистость НАШЕГО запроса. Слой
первый: PushTxChrono жил на общем тике сервера 20 мс, а просил блок клиента
целиком (2048 отсчётов = 42.7 мс) — маркер выходил через два или три тика,
то есть через 40 или 60 мс. Слой второй: MSHV отвечает пачками по 4-5 блоков
раз в ~44 мс (STREAM_C = 4096 при 96 кГц), и мелкий квант этого не лечит —
нужен запас не меньше пачки.

Сделано:
* квант запроса = один блок TXA (512 отсчётов движка), а не блок клиента;
* свой поток-планировщик TxTickLoop с АБСОЛЮТНЫМИ дедлайнами (опоздание
  одного пробуждения не сдвигает сетку); общий тик маркеров больше не шлёт;
* бухгалтерия Owed/InFlight в кадрах на канал, гасится по k до интерполятора;
  потолок долга обязан быть выше окна в полёте (TCI_TX_OWED_HEADROOM_Q),
  иначе связывающим становится он и подача падает до 58% реального времени
  при полностью исправном клиенте;
* SendBinNow: маркеры пишутся в сокет напрямую под FWriteLock, минуя очередь
  (та выпускается лишь на пробуждении потока клиента, TCI_POLL_MS = 20 мс —
  вдвое больше кванта, и подача снова рвалась);
* FReapLock: планировщик TX — новый поток, а правило «клиента освобождает
  только тик-поток» держалось на том, что им же он и пользуется;
* аванс под зернистость клиента: измеряется по ПЕРИОДУ между пачками (размер
  пачки зависит от того, сколько мы запросили ⇒ положительная обратная связь),
  переживает конец передачи, умеет уменьшаться по выдержке TCI_TX_LEAD_DOWN_MS,
  потолок TCI_TX_LEAD_MAX_MS;
* старт передачи: KickTxTick будит планировщика на фронте PTT, TxPreWarm шлёт
  один маркер ДО SetMOX (41 мс раздумий клиента накладываются на нашу же
  подготовку тракта) под гейтом «передатчик свободен и чужого источника нет»,
  PrimeDUCIQ для источника TCI растянут до Max(6096, аванс×4) — путь микрофона
  радио, CW и web не затронут;
* посев аванса TCI_TX_LEAD_DEF_MS = 50 мс, пока про клиента ничего не известно:
  обучение к первому осушению физически не успевает.

Монотонные часы одного источника для всех потоков — PlatformUtils.MonotonicUs
(абсолютные дедлайны не терпят часов, способных прыгнуть от NTP).

На железе: опасных осушений посреди передачи НОЛЬ (было 12 за 11 с), четыре
передачи из пяти вообще без единого, включая старт; прогон 15:21 чист везде,
в том числе на первой передаче после подключения. Всплесков оператор больше
не видит.

Приборы: TxTrace.pas (EWSDR_TXTRACE=1) и стенд test/hpsdr — кольцевой tcpdump
capture.sh, разбор дампа pcap_tx_scan.py, разбор трассы txtrace_scan.py.
Стенд test/tci: часть F «Пейсинг TX», 260 проверок, провалов нет; живой клиент
с рампой, RTT и потерями — test/tci/tx_chrono_bench.py.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014gmVQnna1i4EbSZ2VGm6KD
2026-08-23 22:06:10 +03:00

220 lines
10 KiB
Python
Executable File
Raw 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
"""Разбор кольцевой трассировки TX-тракта (файлы txtrace-*.txt).
Дамп пишет сама программа, запущенная с EWSDR_TXTRACE=1: кольцо сбрасывается
на диск в конце каждой передачи в каталог настроек (~/.config/ewsdr/).
Зачем: pcap показывает ПАУЗУ в потоке DUC, но не показывает, кто её сделал.
Здесь видно, кто именно не наполнил очередь: продюсер звука (TCI/звуковая
карта), TX-поток DSP или сетевой поток приёма.
Запуск: txtrace_scan.py <файл> [--context N]
"""
import sys
import statistics
from collections import defaultdict
# Подушка отправителя DUC: 2000 отсчётов @192 кГц (HPSDRNetwork.pas).
CUSHION_MS = 2000 / 192.0
def parse(path):
rows = []
for line in open(path, encoding='utf-8', errors='replace'):
if line.startswith('#') or not line.strip():
continue
f = line.split()
if len(f) != 5:
continue
try:
rows.append((float(f[0]), f[1], int(f[2]), int(f[3]), int(f[4])))
except ValueError:
continue
return rows
def segments(rows):
"""Кольцо накапливается через всю сессию: файл второй передачи содержит и
первую. Режем по фронтам PTT — сравнивать можно только передачу с
передачей."""
segs, cur = [], None
for r in rows:
if r[1] == 'PTT' and r[2] == 1:
cur = [r]
segs.append(cur)
continue
if cur is not None:
cur.append(r)
if r[1] == 'PTT' and r[2] == 0:
cur = None
return segs if segs else [rows]
def inflight_tail(rows, seg):
"""Маркеры, оставшиеся в полёте на снятии PTT: планировщик пишет это
(NOTE A=-3) на СЛЕДУЮЩЕМ тике, уже за границей передачи. Без учёта этой
метки разбор считает их потерянными клиентом."""
end = seg[-1][0]
for r in rows:
if r[0] < end or r[1] != 'NOTE' or r[2] != -3:
continue
if r[0] - end > 200:
break
return r[3]
return 0
def stats(name, vals, unit='мкс'):
if not vals:
return f' {name:<22} —'
vals = sorted(vals)
p99 = vals[min(len(vals) - 1, int(len(vals) * 0.99))]
return (f' {name:<22} n={len(vals):<6} медиана={statistics.median(vals):>9.1f} '
f'p99={p99:>9.1f} макс={vals[-1]:>9.1f} {unit}')
def main():
if len(sys.argv) < 2:
print(__doc__)
return 1
path = sys.argv[1]
ctx = 8
if '--context' in sys.argv:
ctx = int(sys.argv[sys.argv.index('--context') + 1])
all_rows = parse(path)
if not all_rows:
print('пусто: в файле нет записей')
return 1
segs = segments(all_rows)
print(f'=== {path}')
print(f'записей {len(all_rows)}, передач в кольце {len(segs)}')
for seg_no, seg in enumerate(segs, 1):
report(seg, seg_no, ctx, inflight_tail(all_rows, seg))
return 0
def report(rows, seg_no, ctx, tail_frames=0):
by_kind = defaultdict(list)
for r in rows:
by_kind[r[1]].append(r)
span = rows[-1][0] - rows[0][0]
# Источник берём с ЗАКРЫВАЮЩЕГО фронта PTT: на открывающем он ещё старый
# (SetMOX ставит метку до того, как ApplyMOX выберет источник).
src_code = rows[0][3]
for r in reversed(rows):
if r[1] == 'PTT' and r[2] == 0:
src_code = r[3]
break
src = {0: 'микрофон радио', 1: 'звуковая карта',
2: 'внешняя подача (web/TCI)'}.get(src_code, '?')
print(f'\n===== ПЕРЕДАЧА {seg_no}: {span/1000.0:.2f} с, источник — {src}, '
f'подушка отправителя {CUSHION_MS:.2f} мс\n')
print('РАСПРЕДЕЛЕНИЯ')
print(stats('Synchronize (HP)', [r[2] for r in by_kind['HP-SYNC']]))
print(stats('обработчик приёма', [r[2] for r in by_kind['HANDLER']]))
print(stats('пауза в приёме', [r[2] for r in by_kind['RECV-GAP']]))
print(stats('зазор тика TX', [r[2] for r in by_kind['TX-TICK']]))
print(stats('fexchange0', [r[2] for r in by_kind['TX-PHASE']]))
print(stats('TX-анализатор', [r[3] for r in by_kind['TX-PHASE']]))
print(stats('отдача IQ', [r[4] for r in by_kind['TX-PHASE']]))
print(stats('период TX_CHRONO', [r[4] for r in by_kind['CHRONO']]))
print(stats('период TX-аудио', [r[2] for r in by_kind['TX-AUDIO']]))
print(stats('простой очереди DUC', [r[2] for r in by_kind['DUC-RUN']]))
# Продюсер: сколько блоков TX-поток делает за тик и как часто ринг пуст.
ticks = by_kind['TX-TICK']
if ticks:
dry = sum(1 for t in ticks if t[4] == 0)
burst = [t[4] for t in ticks if t[4] > 1]
print(f'\n тиков TX всего {len(ticks)}, из них вхолостую (ринг пуст) '
f'{dry} ({100.0*dry/len(ticks):.1f}%), '
f'пачками >1 блока {len(burst)}')
# Учёт каналов: length в шапке против моно-отсчётов после интерполяции.
audio = by_kind['TX-AUDIO']
if audio:
pairs = {(a[3], a[4]) for a in audio}
print(f' TX-аудио: пары (length, отсчётов после интерполяции) = '
f'{sorted(pairs)[:6]}')
# ★Баланс подачи. Просим по часам, а списываем долг при ОТПРАВКЕ маркера:
# каждый маркер без ответа = безвозвратно потерянный кусок звука, наверстать
# его нечем. Дефицит съедает единственный запас (аванс + pre-roll), и когда
# запас кончился — очередь пустеет на каждой порции.
chrono = by_kind['CHRONO']
# Предварительный маркер (NOTE A=-4) уходит до фронта PTT, вне бухгалтерии
# и вне статистики периода — но в баланс он входит наравне с прочими.
prewarm = [n for n in by_kind['NOTE'] if n[2] == -4]
if chrono and audio:
asked = sum(c[2] for c in chrono) + sum(p[3] for p in prewarm)
got = sum(a[4] for a in audio)
secs = span / 1000.0
print(f'\n БАЛАНС ПОДАЧИ: маркеров {len(chrono) + len(prewarm)} '
f'(из них предварительных {len(prewarm)}), порций {len(audio)}, '
f'проглочено {len(chrono) + len(prewarm) - len(audio)}')
print(f' запрошено {asked} отсч., получено {got} '
f'({got/secs:.0f} отсч/с при номинале 48000)')
print(f' дефицит {asked-got} отсч. = {(asked-got)/48.0:.1f} мс звука, '
f'которого в эфире не будет')
# NOTE несёт три разных записи: A>=0 — счётчик выброшенного очередью,
# A=-1 — прощение зависшего кредита, A=-2 — выдача аванса,
# A=-3 — сколько маркеров осталось В ПОЛЁТЕ на снятии PTT.
notes = by_kind['NOTE']
drops = [n for n in notes if n[2] >= 0]
if drops:
lost = drops[-1][2] - drops[0][2]
print(f' из них выброшено НАШЕЙ очередью к клиенту: {lost} блок(ов)')
if tail_frames > 0:
q = chrono[-1][2] if chrono else 0
n_tail = tail_frames // q if q else 0
print(f' ★из «проглоченных» {n_tail} — это маркеры в полёте на '
f'снятии PTT, а не потеря')
lead = [n for n in notes if n[2] == -2]
if lead:
print(f' аванс под зернистость клиента: {lead[-1][3]} кадров '
f'({lead[-1][3] / 48.0:.1f} мс), выдан {len(lead)} раз(а)')
forg = [n for n in notes if n[2] == -1]
if forg:
print(f' прощений зависшего кредита: {len(forg)}')
# Главное: каждый простой очереди — с историей перед ним.
print('\nПРОСТОИ ОЧЕРЕДИ DUC (кто молчал перед пустой очередью)')
n_over = 0
for i, r in enumerate(rows):
if r[1] != 'DUC-DRY':
continue
dur = None
for q in rows[i + 1:]:
if q[1] == 'DUC-RUN':
dur = q[2] / 1000.0
break
if q[1] == 'DUC-DRY':
break
# Простой короче одного пакета (240 отсчётов @192 кГц = 1.25 мс) —
# не голодание: быстрее отправитель всё равно не умеет. Такие точки
# ловятся на самом фронте PTT, когда очередь ещё пуста, а pre-roll в
# неё только заливается.
over = (dur is not None and dur > CUSHION_MS
and dur > 1.25)
if over:
n_over += 1
mark = '★ДЛИННЕЕ ПОДУШКИ' if over else ''
print(f'\n t={r[0]:.3f} мс простой {dur if dur is None else round(dur,2)} мс '
f'(FIFO={r[2]} отсч., в очереди {r[3]} пак.) {mark}')
for q in rows[max(0, i - ctx):i]:
print(f' {r[0]-q[0]:8.3f} мс {q[1]:<9} {q[2]:>9} {q[3]:>9} {q[4]:>9}')
tx_s = span / 1000.0
if tx_s > 0:
print(f'\n★ПРОСТОЕВ ДЛИННЕЕ ПОДУШКИ: {n_over} '
f'({n_over/tx_s:.2f} на секунду передачи)')
if __name__ == '__main__':
sys.exit(main())