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
This commit is contained in:
2026-08-23 22:06:10 +03:00
co-authored by Claude Opus 5
parent ced8581ada
commit d2096976df
15 changed files with 2796 additions and 39 deletions
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env bash
# Съём трафика радио openHPSDR для разбора всплесков на передаче.
#
# sudo test/hpsdr/capture.sh [-i bridge0] [-h 172.16.2.200] [-d /tmp/hpsdrcap]
#
# Пишет кольцевой буфер pcap (по умолчанию 24 x 150 МБ ~= 3.6 ГБ, это ~10-15 мин
# полного дуплекса) плюс лог счётчиков UDP-потерь ядра раз в секунду.
# Услышал/увидел всплеск — ЗАСЕКИ ВРЕМЯ и жми Ctrl-C: последние файлы кольца
# и есть нужный кусок.
set -euo pipefail
IFACE=bridge0
RADIO=172.16.2.200
OUTDIR=/tmp/hpsdrcap
FILES=24
SIZE=150
while getopts "i:h:d:n:s:" opt; do
case "$opt" in
i) IFACE=$OPTARG ;;
h) RADIO=$OPTARG ;;
d) OUTDIR=$OPTARG ;;
n) FILES=$OPTARG ;;
s) SIZE=$OPTARG ;;
*) echo "usage: $0 [-i iface] [-h radio_ip] [-d outdir] [-n ring_files] [-s mb_per_file]" >&2; exit 2 ;;
esac
done
if [ "$(id -u)" != 0 ]; then
echo "нужен root (tcpdump): sudo $0 ..." >&2
exit 1
fi
mkdir -p "$OUTDIR"
STAMP=$(date +%Y%m%d-%H%M%S)
echo "iface=$IFACE radio=$RADIO out=$OUTDIR/hpsdr-$STAMP-NNN.pcap ring=${FILES}x${SIZE}MB"
echo "старт: $(date -Ins)"
# Счётчики потерь ядра. Если растут — пакеты терялись НЕ на проводе, а в сокете
# приложения (мал rcvbuf / поток приёма не успевает), и в pcap всё будет чисто.
# drops=N в skmem — счётчик отброшенных ядром датаграмм этого сокета.
(
while :; do
printf '%s' "$(date +%s.%N)"
awk '/^Udp:/{getline; printf " InErrors=%s RcvbufErrors=%s", $4, $6; exit}' /proc/net/snmp
ss -uanm 2>/dev/null | awk '
/^(UNCONN|ESTAB)/ { port=$4; rq=$2; next }
/skmem:/ {
rb=0; d=0;
if (match($0, /rb[0-9]+/)) rb = substr($0, RSTART+2, RLENGTH-2);
if (match($0, /,d[0-9]+\)/)) d = substr($0, RSTART+2, RLENGTH-3);
if (rb+0 > 1000000) printf " sock=%s rq=%s drops=%s", port, rq, d;
}'
echo
sleep 1
done
) > "$OUTDIR/hpsdr-$STAMP.drops" &
DROPPID=$!
trap 'kill $DROPPID 2>/dev/null || true' EXIT
# Без exec: exec заменил бы процесс оболочки и trap EXIT не сработал бы,
# оставив сборщик .drops сиротой навсегда.
tcpdump -i "$IFACE" -s 0 -B 65536 -n --time-stamp-precision=nano \
-W "$FILES" -C "$SIZE" -w "$OUTDIR/hpsdr-$STAMP.pcap" \
"udp and host $RADIO"