Files
ewsdr/PlatformUtils.pas
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

172 lines
5.3 KiB
ObjectPascal
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.
unit PlatformUtils;
{
PlatformUtils.pas - small helpers for desktop/runtime platform checks.
}
{$IFDEF FPC}
{$MODE Delphi}
{$ENDIF}
interface
{$IFNDEF HEADLESS}
uses
Controls;
{$ENDIF}
function HasOpenGLSpectrumSwitch: Boolean;
function CurrentScreenDPI: Integer;
// Целочисленный масштаб канвы экрана: 2 на Retina, 1 везде остальном.
// Windows/Linux всегда получают 1 ⇒ умножение на него ничего не меняет.
function GetScreenScale: Integer;
// То же, но для канвы конкретного контрола: на macOS масштаб берётся у окна,
// которому контрол принадлежит, а не у главного экрана. Это то самое число, в
// котором Cocoa рисует эту канву, и оно меняется, когда окно перетаскивают на
// монитор с другим масштабом. Так и надо считать размер offscreen-битмапа.
{$IFNDEF HEADLESS}
function GetControlScale(AControl: TControl): Integer;
{$ENDIF}
// Монотонные микросекунды одного источника для всех потоков. Нужны там, где
// интервалы считает не человек, а код: пейсинг запросов TX-аудио по TCI ведёт
// абсолютные дедлайны, и часы, способные прыгнуть от NTP, там не годятся.
function MonotonicUs: Int64;
// Возвращает каталог для хранения конфигурации (с завершающим разделителем).
// macOS: ~/Library/Application Support/ewsdr/
// Linux: ~/.config/ewsdr/
// Windows: %APPDATA%\ewsdr\
function GetAppCfgDir: string;
implementation
uses
SysUtils {$IFNDEF HEADLESS}, Forms{$ENDIF}
{$IFDEF WINDOWS}, Windows{$ENDIF}
{$IFDEF LINUX}, Linux, UnixType{$ENDIF}
{$IF DEFINED(UNIX) AND NOT DEFINED(LINUX)}, BaseUnix{$ENDIF}
{$IF DEFINED(DARWIN) AND NOT DEFINED(HEADLESS)}, MacScale{$ENDIF};
{$IFDEF WINDOWS}
var
QPCFreq: Int64 = 0;
{$ENDIF}
function MonotonicUs: Int64;
{$IFDEF WINDOWS}
var
V: Int64;
begin
if QPCFreq <= 0 then
if not QueryPerformanceFrequency(QPCFreq) then Exit(0);
QueryPerformanceCounter(V);
Result := (V * 1000000) div QPCFreq;
end;
{$ELSE}
{$IFDEF LINUX}
var
TS: TTimeSpec;
begin
if clock_gettime(CLOCK_MONOTONIC, @TS) <> 0 then Exit(0);
Result := Int64(TS.tv_sec) * 1000000 + TS.tv_nsec div 1000;
end;
{$ELSE}
var
TV: TTimeVal;
begin
fpgettimeofday(@TV, nil);
Result := Int64(TV.tv_sec) * 1000000 + TV.tv_usec;
end;
{$ENDIF}
{$ENDIF}
function HasOpenGLSpectrumSwitch: Boolean;
var
I: Integer;
S: string;
begin
Result := False;
for I := 1 to ParamCount do
begin
S := LowerCase(ParamStr(I));
if (S = '--opengl') or (S = '-opengl') or (S = '/opengl') then
Exit(True);
end;
// Бандл под LaunchServices не может передать argv, поэтому тот же
// переключатель читается из окружения (Info.plist -> LSEnvironment).
S := LowerCase(Trim(GetEnvironmentVariable('EWSDR_OPENGL')));
if (S = '1') or (S = 'true') or (S = 'yes') or (S = 'on') then
Exit(True);
end;
function GetScreenScale: Integer;
begin
{$IF DEFINED(DARWIN) AND NOT DEFINED(HEADLESS)}
// Cocoa рисует канву в физических пикселях, а TBitmap всегда 1x. Кто рисует
// через offscreen-битмап, должен растить его в GetScreenScale раз.
Result := MacBackingScale;
{$ELSE}
Result := 1;
{$ENDIF}
end;
{$IFNDEF HEADLESS}
function GetControlScale(AControl: TControl): Integer;
{$IF DEFINED(DARWIN)}
var
Host: TWinControl;
begin
// У TPaintBox и прочих TGraphicControl своего NSView нет — рисуют они на
// канве ближайшего родителя с хендлом, его окно и спрашиваем.
if AControl is TWinControl then
Host := TWinControl(AControl)
else if AControl <> nil then
Host := AControl.Parent
else
Host := nil;
while (Host <> nil) and not Host.HandleAllocated do
Host := Host.Parent;
if Host <> nil then
Result := MacBackingScaleForView(Pointer(Host.Handle))
else
Result := 0;
// Контрола ещё нет на экране: до первой отрисовки сгодится главный экран.
if Result < 1 then
Result := GetScreenScale;
{$ELSE}
begin
Result := 1;
{$ENDIF}
end;
{$ENDIF}
function CurrentScreenDPI: Integer;
begin
{$IFNDEF HEADLESS}
Result := Screen.PixelsPerInch;
if Result <= 0 then Result := 96;
{$ELSE}
Result := 96; // headless-демон без LCL: экрана нет, дефолт DPI
{$ENDIF}
end;
function GetAppCfgDir: string;
begin
{$IFDEF WINDOWS}
Result := GetEnvironmentVariable('APPDATA');
if Result = '' then
Result := GetEnvironmentVariable('LOCALAPPDATA');
if Result = '' then
Result := ExtractFilePath(ParamStr(0));
Result := IncludeTrailingPathDelimiter(Result) + 'ewsdr' + PathDelim;
{$ELSE}
Result := IncludeTrailingPathDelimiter(GetAppConfigDir(False));
{$ENDIF}
if not DirectoryExists(Result) then
ForceDirectories(Result);
end;
end.