mirror of
https://git.vladimir.cc/vladimir/ewsdr.git
synced 2026-08-25 20:37:33 +00:00
Посреди передачи из 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
220 lines
10 KiB
Python
Executable File
220 lines
10 KiB
Python
Executable File
#!/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())
|