mirror of
https://git.vladimir.cc/vladimir/ewsdr.git
synced 2026-08-25 20:27:33 +00:00
fix(tci): дефекты живого прогона — length аудио, маршруты тапов, MOX, рекордер, EOF сокета
Восемь дефектов, найденных прогоном настоящего TCI-клиента (три приёмника: NFM, DIGU, FMRAW) и его отчётом. 1. UI доп. панорам не перерисовывался: rfSliceState рассылался, но ветки в MainForm.OnControllerState не было (частоту несёт отдельный rfSliceFreq). 2. Stream.length у аудио — сэмплы НА КАНАЛ (§4.3), у IQ — вещественные отсчёты (§3.4: комплексных = length/channels). Было ×каналы везде, у стерео получалось вдвое больше. Развилка в TCIFillHeader + разбор TX-аудио в HandleBinary. 3+4. Дыры в маршрутах аудио движка: demod-тап звался только для DMR/FMRAW (у DIGU не было RX_AUDIO), а пост-громкостный — только для нецифровых (у FMRAW не было LINEOUT). Плюс мьют слайса больше не убивает RX_AUDIO: движку сообщают SetAudioTapsActive. 5. Клиент, поставивший TRX, уходил — MOX оставался. FTrxOwner + StopTxOf; TCIMicRequested снимается и по окончании любой передачи. 6. Гонка снятия IQ-тапа: SetIQTap(nil) возвращался раньше, чем DSP-поток выходил из вызова. FIQTapLock (порядок FSliceLock → FIQTapLock). 7. Рекордер был кольцом «последние N секунд», а §4.3 говорит про МАКСИМАЛЬНОЕ время записи с удалением по истечении. Переделан в линейный буфер с окном по часам от START. 8. TCIServer.HandleClient считал recv = 0 таймаутом: ноль — это EOF, errno при нём не трогается и несёт EAGAIN от прошлого истёкшего TCI_POLL_MS. Обычный TCP-разрыв без close-кадра не освобождал слот до остановки сервера, и после нескольких аварийных отключений новые клиенты упирались в TCI_MAX_CLIENTS. Теперь R = 0 рвёт связь безусловно, errno спрашивается только при R < 0. Попутно: MainForm.RecreateDSPEngine (смена sample rate до START) терял внутренние колбэки контроллера — введён AttachEngineCallbacks. Стенд test/tci заведён в репозиторий (run.sh, 126/126 зелёных, включая сквозной прогон через живой WDSP), доп. проверки на оба пути отключения клиента. doc/TCI.md приведена в соответствие: правило про recv = 0 в §1.1, единицы Stream.length, линейный буфер рекордера. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -118,3 +118,7 @@ webserver_debug.log
|
|||||||
bin/
|
bin/
|
||||||
bin/*
|
bin/*
|
||||||
hpsdr_settings.json
|
hpsdr_settings.json
|
||||||
|
|
||||||
|
# Python bytecode (стенды в test/)
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
|||||||
+9
-1
@@ -1901,11 +1901,15 @@ begin
|
|||||||
end;
|
end;
|
||||||
end;
|
end;
|
||||||
|
|
||||||
rfSliceFreq:
|
rfSliceFreq, rfSliceState:
|
||||||
begin
|
begin
|
||||||
// Слайс перестроен по своему CAT-порту в пределах захваченной полосы:
|
// Слайс перестроен по своему CAT-порту в пределах захваченной полосы:
|
||||||
// окно DDC не двигалось (rfPanFreq не придёт), но цифры и полоса
|
// окно DDC не двигалось (rfPanFreq не придёт), но цифры и полоса
|
||||||
// фильтра во флаге — его собственные поля, их надо перезалить.
|
// фильтра во флаге — его собственные поля, их надо перезалить.
|
||||||
|
// ★rfSliceState — то же самое для всего остального: моды, фильтра,
|
||||||
|
// АРУ, DSP, шумодава, громкости. Правит их и UI, и CAT слайса, и TCI,
|
||||||
|
// а без этой ветки флаг доп. панорамы оставался с прежними надписями
|
||||||
|
// (частота при этом двигалась — она приходит отдельным rfSliceFreq).
|
||||||
PushFlagStateAllPans(FController.FSliceFreqId);
|
PushFlagStateAllPans(FController.FSliceFreqId);
|
||||||
LayoutFlagsAllPans;
|
LayoutFlagsAllPans;
|
||||||
for i := 0 to MAX_PANS - 1 do
|
for i := 0 to MAX_PANS - 1 do
|
||||||
@@ -4358,6 +4362,10 @@ begin
|
|||||||
end;
|
end;
|
||||||
FController.FWDSPReady := False;
|
FController.FWDSPReady := False;
|
||||||
FController.FDSPEngine := TWDSPEngine.Create(ASampleRate, 48000, 512);
|
FController.FDSPEngine := TWDSPEngine.Create(ASampleRate, 48000, 512);
|
||||||
|
// Внутренние колбэки контроллера (аудио слайсов, отвод демодулятора для
|
||||||
|
// декодеров и RX_AUDIO по TCI, калибровка дисплея, тап IQ, декодер маяка) —
|
||||||
|
// одним вызовом: их навешивает CreateEngines, а новый движок про них не знал.
|
||||||
|
FController.AttachEngineCallbacks;
|
||||||
FController.FDSPEngine.OnAudio := FController.OnAudioReady;
|
FController.FDSPEngine.OnAudio := FController.OnAudioReady;
|
||||||
FController.FDSPEngine.OnSpectrum := FController.OnSpectrumReady;
|
FController.FDSPEngine.OnSpectrum := FController.OnSpectrumReady;
|
||||||
FController.FDSPEngine.OnWaterfall := FController.OnWaterfallReady;
|
FController.FDSPEngine.OnWaterfall := FController.OnWaterfallReady;
|
||||||
|
|||||||
+71
-17
@@ -249,7 +249,9 @@ type
|
|||||||
FAudioTaps: array of TRadioAudioTapEvent;
|
FAudioTaps: array of TRadioAudioTapEvent;
|
||||||
FAudioTapLock: TCriticalSection;
|
FAudioTapLock: TCriticalSection;
|
||||||
FAudioTapCount: LongInt; // быстрый гейт: без тапов не берём и лок
|
FAudioTapCount: LongInt; // быстрый гейт: без тапов не берём и лок
|
||||||
|
FIQTap: TIQTapEvent; // тап IQ (TCI): помним ради пересоздания движка
|
||||||
procedure Changed(Field: TRadioField);
|
procedure Changed(Field: TRadioField);
|
||||||
|
procedure PushAudioTapsActive;
|
||||||
procedure FireAudioTap(Kind: TRadioAudioKind; PanId, SliceId: Integer;
|
procedure FireAudioTap(Kind: TRadioAudioKind; PanId, SliceId: Integer;
|
||||||
const Left, Right: array of Single; Count: Integer);
|
const Left, Right: array of Single; Count: Integer);
|
||||||
// --- Мультислайсы ---
|
// --- Мультислайсы ---
|
||||||
@@ -627,6 +629,11 @@ type
|
|||||||
// FreeEngines закрывает и освобождает их; вызывается из Destroy.
|
// FreeEngines закрывает и освобождает их; вызывается из Destroy.
|
||||||
procedure CreateEngines(ASampleRate: Integer);
|
procedure CreateEngines(ASampleRate: Integer);
|
||||||
procedure FreeEngines;
|
procedure FreeEngines;
|
||||||
|
// Внутренние колбэки движка (аудио слайсов, отвод демодулятора, калибровка
|
||||||
|
// дисплея, тапы TCI). Зовёт CreateEngines — и ОБЯЗАН звать всякий, кто
|
||||||
|
// пересоздал FDSPEngine: без этого новый движок остаётся без отвода
|
||||||
|
// демодулятора (декодеры CW/DMR/FM RAW и RX_AUDIO по TCI) и без тапа IQ.
|
||||||
|
procedure AttachEngineCallbacks;
|
||||||
|
|
||||||
// ---- Колбэки движков (вызываются из рабочих потоков сети/DSP) ----
|
// ---- Колбэки движков (вызываются из рабочих потоков сети/DSP) ----
|
||||||
// Чистая обработка данных без UI; навешиваются на FNetwork/FDSPEngine
|
// Чистая обработка данных без UI; навешиваются на FNetwork/FDSPEngine
|
||||||
@@ -1388,6 +1395,31 @@ begin
|
|||||||
FillChar(FDevMAC, SizeOf(FDevMAC), 0);
|
FillChar(FDevMAC, SizeOf(FDevMAC), 0);
|
||||||
end;
|
end;
|
||||||
|
|
||||||
|
procedure TRadioController.AttachEngineCallbacks;
|
||||||
|
// Всё, что движок должен знать о САМОМ контроллере. Отдельным методом, потому
|
||||||
|
// что движок пересоздают на ходу (смена sample rate до старта): раньше эти
|
||||||
|
// колбэки навешивались только в CreateEngines, и после пересоздания движок
|
||||||
|
// оставался без отвода демодулятора — молча пропадали декодеры CW/DMR/FM RAW,
|
||||||
|
// аудио слайсов, калибровка дисплея и оба тапа TCI.
|
||||||
|
begin
|
||||||
|
if not Assigned(FDSPEngine) then Exit;
|
||||||
|
// Роутинг аудио слайсов на их звуковухи — внутренний (не зависит от
|
||||||
|
// фронтенда, в отличие от OnAudio/OnSpectrum).
|
||||||
|
FDSPEngine.OnSliceAudio := OnSliceAudioReady;
|
||||||
|
FDSPEngine.OnSliceDemodAudio := OnSliceDemodAudioReady;
|
||||||
|
FDSPEngine.OnDemodAudio := OnDemodAudioReady;
|
||||||
|
// Калибровка уровня спектра/водопада на диапазон/трансвертер — движок
|
||||||
|
// спрашивает её раз в кадр (сам он про диапазоны не знает).
|
||||||
|
FDSPEngine.OnDispCal := DispCalFor;
|
||||||
|
FDSPEngine.SetDisplayResLimit(FDisplayPixels);
|
||||||
|
// Тапы наружу (TCI): их владелец живёт дольше движка.
|
||||||
|
FDSPEngine.SetIQTap(FIQTap);
|
||||||
|
PushAudioTapsActive;
|
||||||
|
// Декодер маяка QO-100 создаётся позже самого движка (в CreateEngines ниже),
|
||||||
|
// зато при ПЕРЕсоздании движка он уже есть и его надо вернуть на место.
|
||||||
|
if Assigned(FBeaconDec) then FDSPEngine.SetBeaconDecoder(FBeaconDec);
|
||||||
|
end;
|
||||||
|
|
||||||
procedure TRadioController.CreateEngines(ASampleRate: Integer);
|
procedure TRadioController.CreateEngines(ASampleRate: Integer);
|
||||||
// Создаёт движки в полях контроллера. Колбэки (OnAudio/OnSpectrum/OnHPStatus…)
|
// Создаёт движки в полях контроллера. Колбэки (OnAudio/OnSpectrum/OnHPStatus…)
|
||||||
// навешивает вызывающий после возврата — они указывают на UI/демон.
|
// навешивает вызывающий после возврата — они указывают на UI/демон.
|
||||||
@@ -1397,15 +1429,7 @@ begin
|
|||||||
// BufSize=512: латентность 512/48000 ≈ 10.7мс — допустимо для SDR; меньший
|
// BufSize=512: латентность 512/48000 ≈ 10.7мс — допустимо для SDR; меньший
|
||||||
// буфер вдвое сокращает построение downsampler в OpenChannel RXA.
|
// буфер вдвое сокращает построение downsampler в OpenChannel RXA.
|
||||||
FDSPEngine := TWDSPEngine.Create(ASampleRate, 48000, 512);
|
FDSPEngine := TWDSPEngine.Create(ASampleRate, 48000, 512);
|
||||||
// Роутинг аудио слайсов на их звуковухи — внутренний, вешаем сразу (не
|
AttachEngineCallbacks;
|
||||||
// зависит от фронтенда, в отличие от OnAudio/OnSpectrum).
|
|
||||||
FDSPEngine.OnSliceAudio := OnSliceAudioReady;
|
|
||||||
FDSPEngine.OnSliceDemodAudio := OnSliceDemodAudioReady;
|
|
||||||
FDSPEngine.OnDemodAudio := OnDemodAudioReady;
|
|
||||||
// Калибровка уровня спектра/водопада на диапазон/трансвертер — движок
|
|
||||||
// спрашивает её раз в кадр (сам он про диапазоны не знает).
|
|
||||||
FDSPEngine.OnDispCal := DispCalFor;
|
|
||||||
FDSPEngine.SetDisplayResLimit(FDisplayPixels);
|
|
||||||
|
|
||||||
// QO-100 beacon lock: эталон частоты = декодер (см. ServiceBeaconLock).
|
// QO-100 beacon lock: эталон частоты = декодер (см. ServiceBeaconLock).
|
||||||
FBeaconRefHz := 10489750000.0; // центральный BPSK-маяк NB-транспондера (downlink)
|
FBeaconRefHz := 10489750000.0; // центральный BPSK-маяк NB-транспондера (downlink)
|
||||||
@@ -1545,6 +1569,7 @@ begin
|
|||||||
finally
|
finally
|
||||||
FAudioTapLock.Leave;
|
FAudioTapLock.Leave;
|
||||||
end;
|
end;
|
||||||
|
PushAudioTapsActive;
|
||||||
end;
|
end;
|
||||||
|
|
||||||
procedure TRadioController.RemoveAudioTap(T: TRadioAudioTapEvent);
|
procedure TRadioController.RemoveAudioTap(T: TRadioAudioTapEvent);
|
||||||
@@ -1560,11 +1585,20 @@ begin
|
|||||||
for j := i to High(FAudioTaps) - 1 do FAudioTaps[j] := FAudioTaps[j + 1];
|
for j := i to High(FAudioTaps) - 1 do FAudioTaps[j] := FAudioTaps[j + 1];
|
||||||
SetLength(FAudioTaps, Length(FAudioTaps) - 1);
|
SetLength(FAudioTaps, Length(FAudioTaps) - 1);
|
||||||
FAudioTapCount := Length(FAudioTaps);
|
FAudioTapCount := Length(FAudioTaps);
|
||||||
Exit;
|
Break;
|
||||||
end;
|
end;
|
||||||
finally
|
finally
|
||||||
FAudioTapLock.Leave;
|
FAudioTapLock.Leave;
|
||||||
end;
|
end;
|
||||||
|
PushAudioTapsActive;
|
||||||
|
end;
|
||||||
|
|
||||||
|
procedure TRadioController.PushAudioTapsActive;
|
||||||
|
// Движку интересен только факт «слушатели есть»: от него зависит, считать ли
|
||||||
|
// молчащие (mute) слайсы — их звук всё равно уходит в RX_AUDIO по TCI.
|
||||||
|
begin
|
||||||
|
if Assigned(FDSPEngine) then
|
||||||
|
FDSPEngine.SetAudioTapsActive(FAudioTapCount > 0);
|
||||||
end;
|
end;
|
||||||
|
|
||||||
procedure TRadioController.FireAudioTap(Kind: TRadioAudioKind;
|
procedure TRadioController.FireAudioTap(Kind: TRadioAudioKind;
|
||||||
@@ -1585,7 +1619,11 @@ begin
|
|||||||
end;
|
end;
|
||||||
|
|
||||||
procedure TRadioController.SetIQTap(T: TIQTapEvent);
|
procedure TRadioController.SetIQTap(T: TIQTapEvent);
|
||||||
|
// Тап помним у себя: движок пересоздают (RecreateDSPEngine при смене sample
|
||||||
|
// rate до старта), и новый про тап не знал бы — поток IQ по TCI молча умирал
|
||||||
|
// на всю сессию. Восстанавливает его AttachEngineCallbacks.
|
||||||
begin
|
begin
|
||||||
|
FIQTap := T;
|
||||||
if Assigned(FDSPEngine) then FDSPEngine.SetIQTap(T);
|
if Assigned(FDSPEngine) then FDSPEngine.SetIQTap(T);
|
||||||
end;
|
end;
|
||||||
|
|
||||||
@@ -1984,6 +2022,10 @@ begin
|
|||||||
else if FMode = MODE_FMRAW then
|
else if FMode = MODE_FMRAW then
|
||||||
begin
|
begin
|
||||||
// FM RAW decoder audio deliberately bypasses speaker volume and mute.
|
// FM RAW decoder audio deliberately bypasses speaker volume and mute.
|
||||||
|
// Значит и линейный выход у него — вот этот сигнал: OnAudioReady для
|
||||||
|
// FM RAW выходит в самом начале, и без этой строки поток LINEOUT по TCI
|
||||||
|
// на FM RAW не шёл вовсе.
|
||||||
|
FireAudioTap(rakLineOut, 0, 0, Left, Right, Count);
|
||||||
if FLocalAudio and Assigned(FAudioOut) then
|
if FLocalAudio and Assigned(FAudioOut) then
|
||||||
FAudioOut.Write(Left, Right, Count);
|
FAudioOut.Write(Left, Right, Count);
|
||||||
end
|
end
|
||||||
@@ -2082,9 +2124,15 @@ begin
|
|||||||
FireAudioTap(rakDemod, FSlices[idx].PanId, SliceId, Left, Right, Count);
|
FireAudioTap(rakDemod, FSlices[idx].PanId, SliceId, Left, Right, Count);
|
||||||
if (FSlices[idx].Mode = MODE_DMR) and Assigned(FSlices[idx].DMR) then
|
if (FSlices[idx].Mode = MODE_DMR) and Assigned(FSlices[idx].DMR) then
|
||||||
FSlices[idx].DMR.FeedAudio(Left, Right, Count);
|
FSlices[idx].DMR.FeedAudio(Left, Right, Count);
|
||||||
if (FSlices[idx].Mode = MODE_FMRAW) and FLocalAudio and
|
if FSlices[idx].Mode = MODE_FMRAW then
|
||||||
Assigned(FSlices[idx].Audio) then
|
begin
|
||||||
FSlices[idx].Audio.Write(Left, Right, Count);
|
// У FM RAW звук идёт мимо громкости и мьюта, поэтому линейный выход слайса
|
||||||
|
// — это он же (OnSliceAudioReady для FM RAW не вызывается вовсе). Без этой
|
||||||
|
// строки поток LINEOUT по TCI на FM RAW-слайсе молчал.
|
||||||
|
FireAudioTap(rakLineOut, FSlices[idx].PanId, SliceId, Left, Right, Count);
|
||||||
|
if FLocalAudio and Assigned(FSlices[idx].Audio) then
|
||||||
|
FSlices[idx].Audio.Write(Left, Right, Count);
|
||||||
|
end;
|
||||||
end;
|
end;
|
||||||
|
|
||||||
// DMR worker thread: decoded 8 kHz mono -> this slice's 48 kHz local output.
|
// DMR worker thread: decoded 8 kHz mono -> this slice's 48 kHz local output.
|
||||||
@@ -2095,11 +2143,13 @@ var
|
|||||||
idx, i, Phase, OutPos, N: Integer;
|
idx, i, Phase, OutPos, N: Integer;
|
||||||
A, B, V, Gain: Single;
|
A, B, V, Gain: Single;
|
||||||
begin
|
begin
|
||||||
if not FLocalAudio or (Count <= 0) then Exit;
|
if (Count <= 0) or not (FLocalAudio or (FAudioTapCount > 0)) then Exit;
|
||||||
idx := FindSliceIndex(SliceId);
|
idx := FindSliceIndex(SliceId);
|
||||||
if (idx < 0) or (FSlices[idx].Mode <> MODE_DMR) or
|
if (idx < 0) or (FSlices[idx].Mode <> MODE_DMR) or FSlices[idx].Muted then Exit;
|
||||||
FSlices[idx].Muted or not Assigned(FSlices[idx].Audio) then Exit;
|
|
||||||
if SliceMutedByTx(idx) then Exit;
|
if SliceMutedByTx(idx) then Exit;
|
||||||
|
// Звуковухи может не быть вовсе (демон), но линейный выход слайса слушает
|
||||||
|
// ещё и TCI — тогда декодированный звук всё равно надо построить.
|
||||||
|
if not (Assigned(FSlices[idx].Audio) or (FAudioTapCount > 0)) then Exit;
|
||||||
N := Min(Count, Length(PCM));
|
N := Min(Count, Length(PCM));
|
||||||
SetLength(Left, N * 6);
|
SetLength(Left, N * 6);
|
||||||
SetLength(Right, N * 6);
|
SetLength(Right, N * 6);
|
||||||
@@ -2117,7 +2167,11 @@ begin
|
|||||||
Inc(OutPos);
|
Inc(OutPos);
|
||||||
end;
|
end;
|
||||||
end;
|
end;
|
||||||
FSlices[idx].Audio.Write(Left, Right, OutPos);
|
// Линейный выход DMR-слайса — декодированный звук, как и у главного тракта
|
||||||
|
// (см. OnDMRAudioReady): 4FSK до декодера в LINEOUT слать нечего.
|
||||||
|
FireAudioTap(rakLineOut, FSlices[idx].PanId, SliceId, Left, Right, OutPos);
|
||||||
|
if FLocalAudio and Assigned(FSlices[idx].Audio) then
|
||||||
|
FSlices[idx].Audio.Write(Left, Right, OutPos);
|
||||||
end;
|
end;
|
||||||
|
|
||||||
procedure TRadioController.RefreshSliceShifts;
|
procedure TRadioController.RefreshSliceShifts;
|
||||||
|
|||||||
+95
-7
@@ -142,6 +142,7 @@ type
|
|||||||
// ── TX-аудио от клиента (§3.4) ──
|
// ── TX-аудио от клиента (§3.4) ──
|
||||||
FTxLock: TCriticalSection;
|
FTxLock: TCriticalSection;
|
||||||
FTxClient: TTCIClient; // кто модулирует (nil — никто)
|
FTxClient: TTCIClient; // кто модулирует (nil — никто)
|
||||||
|
FTrxOwner: TTCIClient; // кто поставил трансивер в эфир (§4.2)
|
||||||
FTxInterp: TTCIInterpolator;
|
FTxInterp: TTCIInterpolator;
|
||||||
FTxInRate: Integer; // частота дискретизации подачи клиента
|
FTxInRate: Integer; // частота дискретизации подачи клиента
|
||||||
FTxRunning: Boolean; // маркеры TX_CHRONO идут
|
FTxRunning: Boolean; // маркеры TX_CHRONO идут
|
||||||
@@ -177,6 +178,7 @@ type
|
|||||||
procedure SyncSetMode;
|
procedure SyncSetMode;
|
||||||
procedure SyncSetFilter;
|
procedure SyncSetFilter;
|
||||||
procedure SyncSetTRX;
|
procedure SyncSetTRX;
|
||||||
|
procedure SyncStopTRX;
|
||||||
procedure SyncSetTune;
|
procedure SyncSetTune;
|
||||||
procedure SyncSetDrive;
|
procedure SyncSetDrive;
|
||||||
procedure SyncSetTuneDrive;
|
procedure SyncSetTuneDrive;
|
||||||
@@ -225,6 +227,8 @@ type
|
|||||||
procedure CmdStream(Client: TTCIClient; const M: TTCIMessage);
|
procedure CmdStream(Client: TTCIClient; const M: TTCIMessage);
|
||||||
procedure CmdRecorder(Client: TTCIClient; const M: TTCIMessage);
|
procedure CmdRecorder(Client: TTCIClient; const M: TTCIMessage);
|
||||||
procedure ClearTxClient(C: TTCIClient); // клиент ушёл/перестал модулировать
|
procedure ClearTxClient(C: TTCIClient); // клиент ушёл/перестал модулировать
|
||||||
|
procedure StopTxOf(C: TTCIClient); // ★снять эфир, начатый этим клиентом
|
||||||
|
procedure ForgetTxOwner; // передача кончилась не по TCI
|
||||||
|
|
||||||
// ── Помощники модели ──
|
// ── Помощники модели ──
|
||||||
function CanInvoke: Boolean;
|
function CanInvoke: Boolean;
|
||||||
@@ -1259,6 +1263,10 @@ begin
|
|||||||
DropHolds(Client);
|
DropHolds(Client);
|
||||||
DropClientStreams(Client);
|
DropClientStreams(Client);
|
||||||
ClearTxClient(Client);
|
ClearTxClient(Client);
|
||||||
|
// ★И только теперь — сама передача: если в эфир нас поставил именно этот
|
||||||
|
// клиент, снимаем MOX. Оставить включённый передатчик за ушедшим клиентом
|
||||||
|
// нельзя ни при какой модуляции (§4.2).
|
||||||
|
StopTxOf(Client);
|
||||||
end;
|
end;
|
||||||
|
|
||||||
{ ═══════════════════════════════════════════════════════════════════════════
|
{ ═══════════════════════════════════════════════════════════════════════════
|
||||||
@@ -1319,6 +1327,16 @@ begin
|
|||||||
FController.SetMOX(FsBool);
|
FController.SetMOX(FsBool);
|
||||||
end;
|
end;
|
||||||
|
|
||||||
|
procedure TTCIAdapter.SyncStopTRX;
|
||||||
|
// Поток контроллера. Передачу, начатую ушедшим клиентом, снимаем безусловно:
|
||||||
|
// решение принято там, где известно, кто её начал, а здесь остаётся только
|
||||||
|
// проверить, что она вообще идёт (оператор мог отпустить PTT сам).
|
||||||
|
begin
|
||||||
|
if (FController = nil) or not FController.FTransmitting then Exit;
|
||||||
|
FController.TCIMicRequested := False;
|
||||||
|
FController.SetMOX(False);
|
||||||
|
end;
|
||||||
|
|
||||||
procedure TTCIAdapter.SyncTaps;
|
procedure TTCIAdapter.SyncTaps;
|
||||||
// Поток контроллера: движок и список тапов трогаем только отсюда.
|
// Поток контроллера: движок и список тапов трогаем только отсюда.
|
||||||
begin
|
begin
|
||||||
@@ -1764,6 +1782,11 @@ begin
|
|||||||
try
|
try
|
||||||
if FromTCI then FTxClient := Client
|
if FromTCI then FTxClient := Client
|
||||||
else if FTxClient = Client then FTxClient := nil;
|
else if FTxClient = Client then FTxClient := nil;
|
||||||
|
// Кто поставил трансивер в эфир: его уход обязан эфир и снять
|
||||||
|
// (см. StopTxOf). Источник модуляции тут ни при чём — несущую без
|
||||||
|
// хозяина оставлять нельзя в любом случае.
|
||||||
|
if B then FTrxOwner := Client
|
||||||
|
else if FTrxOwner = Client then FTrxOwner := nil;
|
||||||
finally FTxLock.Leave; end;
|
finally FTxLock.Leave; end;
|
||||||
FLock.Enter;
|
FLock.Enter;
|
||||||
try
|
try
|
||||||
@@ -2638,6 +2661,8 @@ begin
|
|||||||
FStreamLock.Leave;
|
FStreamLock.Leave;
|
||||||
end;
|
end;
|
||||||
ClearTxClient(nil);
|
ClearTxClient(nil);
|
||||||
|
// Страховка: клиент мог держать эфир и без своего аудио (TRX без 'tci').
|
||||||
|
StopTxOf(nil);
|
||||||
end;
|
end;
|
||||||
|
|
||||||
function TTCIAdapter.EffIQRate(C: TTCIClient): Integer;
|
function TTCIAdapter.EffIQRate(C: TTCIClient): Integer;
|
||||||
@@ -2890,9 +2915,55 @@ begin
|
|||||||
if not Drop then Exit;
|
if not Drop then Exit;
|
||||||
// Пишем прямо, без Invoke: это один Boolean, который SetMOX только читает, и
|
// Пишем прямо, без Invoke: это один Boolean, который SetMOX только читает, и
|
||||||
// зовут нас откуда угодно — в том числе из деструктора адаптера, где ждать
|
// зовут нас откуда угодно — в том числе из деструктора адаптера, где ждать
|
||||||
// поток контроллера уже некому. Передачу при этом НЕ трогаем: оператор мог
|
// поток контроллера уже некому.
|
||||||
// нажать PTT сам, и обрывать его эфир из-за ухода клиента нельзя.
|
if FController = nil then Exit;
|
||||||
if FController <> nil then FController.TCIMicRequested := False;
|
FController.TCIMicRequested := False;
|
||||||
|
// ★А вот саму передачу, если она ИДЁТ ИМЕННО ИЗ ЭТОГО ПОТОКА, оставлять
|
||||||
|
// нельзя: источник модуляции только что исчез, и в эфире осталась бы
|
||||||
|
// несущая с тишиной (или с последним, что застряло в ринге), которую никто
|
||||||
|
// не снимет. Передачу с микрофона оператора это не трогает — там
|
||||||
|
// TCIMicActive не поднят.
|
||||||
|
if FController.TCIMicActive then StopTxOf(nil);
|
||||||
|
end;
|
||||||
|
|
||||||
|
procedure TTCIAdapter.StopTxOf(C: TTCIClient);
|
||||||
|
// ★Безопасность (§4.2). Клиент, поставивший трансивер в эфир, ушёл — MOX
|
||||||
|
// обязан упасть. C = nil — снять эфир, кем бы из клиентов он ни был начат
|
||||||
|
// (остановка сервера, потеря источника модуляции).
|
||||||
|
var Mine: Boolean;
|
||||||
|
begin
|
||||||
|
Mine := False;
|
||||||
|
FTxLock.Enter;
|
||||||
|
try
|
||||||
|
if (FTrxOwner <> nil) and ((C = nil) or (FTrxOwner = C)) then
|
||||||
|
begin
|
||||||
|
FTrxOwner := nil;
|
||||||
|
Mine := True;
|
||||||
|
end;
|
||||||
|
finally
|
||||||
|
FTxLock.Leave;
|
||||||
|
end;
|
||||||
|
if not Mine or (FController = nil) then Exit;
|
||||||
|
if CanInvoke then
|
||||||
|
FController.Invoke(SyncStopTRX)
|
||||||
|
else
|
||||||
|
// CanInvoke=False бывает ровно в одном случае — сервер останавливается, а
|
||||||
|
// останавливает его поток контроллера: он же сейчас и исполняет нас
|
||||||
|
// (TTCIServer.Stop, шаг 5 → Disconnected). Ждать самого себя нельзя, и не
|
||||||
|
// нужно — вызов и так на правильном потоке.
|
||||||
|
SyncStopTRX;
|
||||||
|
end;
|
||||||
|
|
||||||
|
procedure TTCIAdapter.ForgetTxOwner;
|
||||||
|
// Передача кончилась сама (оператор, CAT, PTT, тайм-аут) — забываем, кто её
|
||||||
|
// начал. Иначе уход того клиента через час снимал бы уже чужой эфир.
|
||||||
|
begin
|
||||||
|
FTxLock.Enter;
|
||||||
|
try
|
||||||
|
FTrxOwner := nil;
|
||||||
|
finally
|
||||||
|
FTxLock.Leave;
|
||||||
|
end;
|
||||||
end;
|
end;
|
||||||
|
|
||||||
procedure TTCIAdapter.PushTxChrono;
|
procedure TTCIAdapter.PushTxChrono;
|
||||||
@@ -2966,7 +3037,7 @@ var
|
|||||||
H: TTCIStreamHeader;
|
H: TTCIStreamHeader;
|
||||||
ST: TTCISampleType;
|
ST: TTCISampleType;
|
||||||
P: PByte;
|
P: PByte;
|
||||||
N, i, k, Chans, Rate, Factor, Bytes: Integer;
|
N, i, k, Chans, Rate, Factor, Bytes, Want: Integer;
|
||||||
Mine: Boolean;
|
Mine: Boolean;
|
||||||
begin
|
begin
|
||||||
if (Data = nil) or (Len <= SizeOf(H)) then Exit;
|
if (Data = nil) or (Len <= SizeOf(H)) then Exit;
|
||||||
@@ -3001,10 +3072,16 @@ 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 вещественных отсчётов — верим меньшему из двух:
|
// В аудиопотоке length — это сэмплы НА КАНАЛ (см. TCIFillHeader), значит
|
||||||
|
// вещественных отсчётов в блоке length × channels. Верим меньшему из двух:
|
||||||
// клиент вправе прислать короткий хвост, но не длиннее уместившегося.
|
// клиент вправе прислать короткий хвост, но не длиннее уместившегося.
|
||||||
if (H.DataLength > 0) and (Integer(H.DataLength) < N) then
|
// Заведомо чужое число (мусор в заголовке) просто игнорируем — длину нам
|
||||||
N := Integer(H.DataLength);
|
// и так ограничил размер кадра.
|
||||||
|
if (H.DataLength > 0) and (H.DataLength <= LongWord(N)) then
|
||||||
|
begin
|
||||||
|
Want := Integer(H.DataLength) * Chans;
|
||||||
|
if Want < N then N := Want;
|
||||||
|
end;
|
||||||
|
|
||||||
// В тракт идёт моно: TXA у нас один, а стерео от клиента — это его
|
// В тракт идёт моно: TXA у нас один, а стерео от клиента — это его
|
||||||
// собственный формат вывода, а не два независимых сигнала.
|
// собственный формат вывода, а не два независимых сигнала.
|
||||||
@@ -3063,6 +3140,17 @@ begin
|
|||||||
// а поток остался бы висеть на несуществующем приёмнике.
|
// а поток остался бы висеть на несуществующем приёмнике.
|
||||||
if Field in [rfDevice, rfConnected] then DropDeadRxStreams;
|
if Field in [rfDevice, rfConnected] then DropDeadRxStreams;
|
||||||
|
|
||||||
|
// Передача кончилась — чья бы она ни была. Забываем хозяина эфира (иначе его
|
||||||
|
// уход когда-нибудь потом снял бы уже чужую передачу) и снимаем просьбу
|
||||||
|
// «модулируй из потока TCI»: она относилась ровно к той передаче, которую
|
||||||
|
// клиент и начал, а следующий PTT оператора обязан идти с его микрофона.
|
||||||
|
if (Field = rfTransmitting) and (FController <> nil)
|
||||||
|
and not FController.FTransmitting then
|
||||||
|
begin
|
||||||
|
ForgetTxOwner;
|
||||||
|
FController.TCIMicRequested := False;
|
||||||
|
end;
|
||||||
|
|
||||||
MapChanged := False;
|
MapChanged := False;
|
||||||
if Field in [rfSliceFreq, rfSliceState, rfDevice, rfPanFreq, rfSampleRate,
|
if Field in [rfSliceFreq, rfSliceState, rfDevice, rfPanFreq, rfSampleRate,
|
||||||
rfBand, rfXvtr, rfCenterFreq] then
|
rfBand, rfXvtr, rfCenterFreq] then
|
||||||
|
|||||||
+15
-4
@@ -87,7 +87,8 @@ type
|
|||||||
Format: LongWord; // TTCISampleType
|
Format: LongWord; // TTCISampleType
|
||||||
Codec: LongWord; // всегда 0 (сжатие не реализовано)
|
Codec: LongWord; // всегда 0 (сжатие не реализовано)
|
||||||
CRC: LongWord; // всегда 0
|
CRC: LongWord; // всегда 0
|
||||||
DataLength: LongWord; // количество вещественных отсчётов в data[]
|
DataLength: LongWord; // IQ — вещественных отсчётов, аудио — на канал
|
||||||
|
// (см. TCIFillHeader)
|
||||||
StreamType: LongWord; // TTCIStreamType
|
StreamType: LongWord; // TTCIStreamType
|
||||||
Channels: LongWord;
|
Channels: LongWord;
|
||||||
Reserv: array[0..7] of LongWord;
|
Reserv: array[0..7] of LongWord;
|
||||||
@@ -133,8 +134,14 @@ function TCIDefaultAudioSamples(RateHz: Integer): Integer;
|
|||||||
{ Сколько сэмплов на канал влезает в блок с таким форматом и числом каналов. }
|
{ Сколько сэмплов на канал влезает в блок с таким форматом и числом каналов. }
|
||||||
function TCIMaxBlockSamples(T: TTCISampleType; Channels: Integer): Integer;
|
function TCIMaxBlockSamples(T: TTCISampleType; Channels: Integer): Integer;
|
||||||
|
|
||||||
{ Заголовок блока. Count — сэмплов НА КАНАЛ; в DataLength уходит, как велит
|
{ Заголовок блока. Count — сэмплов НА КАНАЛ. В DataLength уходит то, что для
|
||||||
протокол, количество ВЕЩЕСТВЕННЫХ отсчётов, то есть Count × Channels. }
|
этого типа потока велит документ, а он для IQ и для аудио говорит РАЗНОЕ:
|
||||||
|
• IQ (§3.4): «количество вещественных отсчётов… количество комплексных
|
||||||
|
вычисляется как Stream.length/Stream.channels» ⇒ Count × Channels;
|
||||||
|
• аудио (§4.3, AUDIO_STREAM_SAMPLES): «arg1 — количество сэмплов,
|
||||||
|
указываемое в поле Stream.length», по умолчанию 2048 при 48 кГц ⇒ Count,
|
||||||
|
то есть сэмплов НА КАНАЛ. Сходится и с потолком data[16384]:
|
||||||
|
2048 × 2 канала × float32 — ровно 16384 байта. }
|
||||||
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);
|
||||||
|
|
||||||
@@ -392,7 +399,11 @@ 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));
|
||||||
H.DataLength := LongWord(Count * Channels);
|
// ★Единицы length разные у IQ и у аудио — см. шапку объявления.
|
||||||
|
if Kind = tstIQ then
|
||||||
|
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;
|
||||||
|
|||||||
+10
-3
@@ -1203,9 +1203,16 @@ begin
|
|||||||
if not Pending then
|
if not Pending then
|
||||||
begin
|
begin
|
||||||
R := Ws.Recv;
|
R := Ws.Recv;
|
||||||
// R <= 0 — либо разрыв, либо просто истёк TCI_POLL_MS. Второе штатно:
|
// R = 0 — клиент закрыл свою сторону (EOF). Это НЕ ошибка, errno при
|
||||||
// просыпаемся, чтобы отдать накопившуюся очередь.
|
// этом не трогается и вполне может нести EAGAIN от прошлого истёкшего
|
||||||
if (R <= 0) and not SockRecvTimedOut then Break;
|
// TCI_POLL_MS — спрашивать его тут нельзя, иначе обычный TCP-разрыв
|
||||||
|
// без close-кадра выглядит как таймаут и слот клиента не освобождается
|
||||||
|
// до остановки сервера. Место в буфере есть всегда (кадр крупнее
|
||||||
|
// буфера рвётся выше), так что нулю иного смысла нет.
|
||||||
|
if R = 0 then Break;
|
||||||
|
// R < 0 — ошибка: либо разрыв, либо просто истёк TCI_POLL_MS. Второе
|
||||||
|
// штатно: просыпаемся, чтобы отдать накопившуюся очередь.
|
||||||
|
if (R < 0) and not SockRecvTimedOut then Break;
|
||||||
end;
|
end;
|
||||||
Pending := False;
|
Pending := False;
|
||||||
|
|
||||||
|
|||||||
+55
-29
@@ -165,23 +165,37 @@ type
|
|||||||
property SrcRate: Integer read FSrcRate;
|
property SrcRate: Integer read FSrcRate;
|
||||||
end;
|
end;
|
||||||
|
|
||||||
{ Запись линейного выхода (LINE_OUT_RECORDER_*, §4.3). Кольцо на MaxSec
|
{ Запись линейного выхода (LINE_OUT_RECORDER_*, §4.3). Буфер на MaxSec секунд
|
||||||
секунд 48 кГц стерео в int16: во-первых, ровно то, что уйдёт в WAV, а
|
48 кГц стерео в int16: во-первых, ровно то, что уйдёт в WAV, а во-вторых,
|
||||||
во-вторых, float32 на предельных 300 с — это 115 МБ вместо 57. }
|
float32 на предельных 300 с — это 115 МБ вместо 57.
|
||||||
|
|
||||||
|
★Не кольцо. Документ говорит про arg2 «максимальное время записи», и дальше
|
||||||
|
прямо: «по истечении времени запись УДАЛЯЕТСЯ, чтобы сохранить запись в файл
|
||||||
|
необходимо в заданном временном интервале выслать LINE_OUT_RECORDER_SAVE».
|
||||||
|
То есть START открывает окно длиной MaxSec, и всё, что не сохранили внутри
|
||||||
|
него, пропадает. Кольцо «последние N секунд» вело себя иначе в обе стороны:
|
||||||
|
начало активной записи затирало само себя, а SAVE через час после START
|
||||||
|
отдавал файл, которого у ExpertSDR3 давно бы не было.
|
||||||
|
|
||||||
|
Срок считаем по ЧАСАМ, а не по накопленным сэмплам: линейный выход молчит
|
||||||
|
(мьют, пауза приёмника), а время записи всё равно идёт. }
|
||||||
TTCIRecorder = class
|
TTCIRecorder = class
|
||||||
private
|
private
|
||||||
FLock: TCriticalSection;
|
FLock: TCriticalSection;
|
||||||
FRing: array of SmallInt; // чередование L/R
|
FBuf: array of SmallInt; // чередование L/R
|
||||||
FCap: Integer; // ёмкость в сэмплах на канал
|
FCap: Integer; // ёмкость в сэмплах на канал
|
||||||
FCount: Integer; // накоплено сэмплов на канал
|
FCount: Integer; // накоплено сэмплов на канал
|
||||||
FHead: Integer; // позиция записи (в сэмплах на канал)
|
|
||||||
FRate: Integer;
|
FRate: Integer;
|
||||||
FRx: Integer;
|
FRx: Integer;
|
||||||
|
FExpired: Boolean; // окно записи закрылось, данные удалены
|
||||||
|
FEndsAt: QWord; // GetTickCount64 конца окна
|
||||||
|
procedure DropData; // под FLock
|
||||||
public
|
public
|
||||||
constructor Create(ARx, ARateHz, AMaxSec: Integer);
|
constructor Create(ARx, ARateHz, AMaxSec: Integer);
|
||||||
destructor Destroy; override;
|
destructor Destroy; override;
|
||||||
procedure Feed(const L, R: array of Single; N: Integer);
|
procedure Feed(const L, R: array of Single; N: Integer);
|
||||||
{ Забрать накопленное В ПОРЯДКЕ ВРЕМЕНИ и обнулить кольцо. }
|
{ Забрать накопленное и завершить запись. nil — либо не записано ничего,
|
||||||
|
либо окно уже истекло (по документу это одно и то же: записи нет). }
|
||||||
function Take: TTCIPcm;
|
function Take: TTCIPcm;
|
||||||
property Rx: Integer read FRx;
|
property Rx: Integer read FRx;
|
||||||
property Rate: Integer read FRate;
|
property Rate: Integer read FRate;
|
||||||
@@ -671,9 +685,12 @@ begin
|
|||||||
if AMaxSec < 1 then AMaxSec := 1;
|
if AMaxSec < 1 then AMaxSec := 1;
|
||||||
if AMaxSec > TCI_RECORD_MAX_SEC then AMaxSec := TCI_RECORD_MAX_SEC;
|
if AMaxSec > TCI_RECORD_MAX_SEC then AMaxSec := TCI_RECORD_MAX_SEC;
|
||||||
FCap := ARateHz * AMaxSec;
|
FCap := ARateHz * AMaxSec;
|
||||||
SetLength(FRing, FCap * 2);
|
SetLength(FBuf, FCap * 2);
|
||||||
FCount := 0;
|
FCount := 0;
|
||||||
FHead := 0;
|
FExpired := False;
|
||||||
|
// Окно открывается прямо здесь: клиент отсчитывает его от своей команды
|
||||||
|
// START, и ждать первого блока аудио, чтобы завести часы, нельзя.
|
||||||
|
FEndsAt := GetTickCount64 + QWord(AMaxSec) * 1000;
|
||||||
end;
|
end;
|
||||||
|
|
||||||
destructor TTCIRecorder.Destroy;
|
destructor TTCIRecorder.Destroy;
|
||||||
@@ -682,10 +699,17 @@ begin
|
|||||||
inherited;
|
inherited;
|
||||||
end;
|
end;
|
||||||
|
|
||||||
|
procedure TTCIRecorder.DropData;
|
||||||
|
// Под FLock. Память отдаём сразу: истёкшая запись на 300 с держала бы 57 МБ
|
||||||
|
// до тех пор, пока клиент не вспомнит про BREAK.
|
||||||
|
begin
|
||||||
|
FCount := 0;
|
||||||
|
FExpired := True;
|
||||||
|
SetLength(FBuf, 0);
|
||||||
|
end;
|
||||||
|
|
||||||
procedure TTCIRecorder.Feed(const L, R: array of Single; N: Integer);
|
procedure TTCIRecorder.Feed(const L, R: array of Single; N: Integer);
|
||||||
// DSP-поток. Кольцо: по исчерпании ёмкости затирается самое старое — запись
|
// DSP-поток. Пишем линейно до конца окна; истекло — данных больше нет (§4.3).
|
||||||
// «последние N секунд» именно так и работает (§4.3: по истечении времени
|
|
||||||
// накопленное пропадает, если клиент не сохранил).
|
|
||||||
var
|
var
|
||||||
i: Integer;
|
i: Integer;
|
||||||
A, B: Single;
|
A, B: Single;
|
||||||
@@ -695,17 +719,24 @@ begin
|
|||||||
if N > Length(R) then N := Length(R);
|
if N > Length(R) then N := Length(R);
|
||||||
FLock.Enter;
|
FLock.Enter;
|
||||||
try
|
try
|
||||||
|
if FExpired then Exit;
|
||||||
|
// Часы и ёмкость закрывают окно вместе: ёмкость — это те же MaxSec звука,
|
||||||
|
// но при паузе в аудио доживёт до конца только счёт по часам.
|
||||||
|
if GetTickCount64 >= FEndsAt then
|
||||||
|
begin
|
||||||
|
DropData;
|
||||||
|
Exit;
|
||||||
|
end;
|
||||||
|
if N > FCap - FCount then N := FCap - FCount;
|
||||||
for i := 0 to N - 1 do
|
for i := 0 to N - 1 do
|
||||||
begin
|
begin
|
||||||
A := L[i]; B := R[i];
|
A := L[i]; B := R[i];
|
||||||
if A > 1.0 then A := 1.0; if A < -1.0 then A := -1.0;
|
if A > 1.0 then A := 1.0; if A < -1.0 then A := -1.0;
|
||||||
if B > 1.0 then B := 1.0; if B < -1.0 then B := -1.0;
|
if B > 1.0 then B := 1.0; if B < -1.0 then B := -1.0;
|
||||||
FRing[FHead * 2] := Round(A * 32767);
|
FBuf[(FCount + i) * 2] := Round(A * 32767);
|
||||||
FRing[FHead * 2 + 1] := Round(B * 32767);
|
FBuf[(FCount + i) * 2 + 1] := Round(B * 32767);
|
||||||
Inc(FHead);
|
|
||||||
if FHead >= FCap then FHead := 0;
|
|
||||||
if FCount < FCap then Inc(FCount);
|
|
||||||
end;
|
end;
|
||||||
|
Inc(FCount, N);
|
||||||
finally
|
finally
|
||||||
FLock.Leave;
|
FLock.Leave;
|
||||||
end;
|
end;
|
||||||
@@ -713,23 +744,18 @@ end;
|
|||||||
|
|
||||||
function TTCIRecorder.Take: TTCIPcm;
|
function TTCIRecorder.Take: TTCIPcm;
|
||||||
var
|
var
|
||||||
Start, i, n: Integer;
|
i: Integer;
|
||||||
begin
|
begin
|
||||||
Result := nil;
|
Result := nil;
|
||||||
FLock.Enter;
|
FLock.Enter;
|
||||||
try
|
try
|
||||||
if FCount <= 0 then Exit;
|
// Проверка срока и здесь: аудио могло не идти вовсе (мьют, стоящий
|
||||||
|
// приёмник), и тогда Feed часы не смотрел ни разу.
|
||||||
|
if (not FExpired) and (GetTickCount64 >= FEndsAt) then DropData;
|
||||||
|
if FExpired or (FCount <= 0) then Exit;
|
||||||
SetLength(Result, FCount * 2);
|
SetLength(Result, FCount * 2);
|
||||||
Start := FHead - FCount;
|
for i := 0 to FCount * 2 - 1 do Result[i] := FBuf[i];
|
||||||
if Start < 0 then Inc(Start, FCap);
|
DropData; // SAVE завершает запись (§4.3)
|
||||||
for i := 0 to FCount - 1 do
|
|
||||||
begin
|
|
||||||
n := (Start + i) mod FCap;
|
|
||||||
Result[i * 2] := FRing[n * 2];
|
|
||||||
Result[i * 2 + 1] := FRing[n * 2 + 1];
|
|
||||||
end;
|
|
||||||
FCount := 0;
|
|
||||||
FHead := 0;
|
|
||||||
finally
|
finally
|
||||||
FLock.Leave;
|
FLock.Leave;
|
||||||
end;
|
end;
|
||||||
|
|||||||
+72
-14
@@ -524,6 +524,15 @@ type
|
|||||||
FLastError: string;
|
FLastError: string;
|
||||||
FBeaconDec: TBeaconDecoder; // не владеет; тап маяка в PushIQItemToDSP
|
FBeaconDec: TBeaconDecoder; // не владеет; тап маяка в PushIQItemToDSP
|
||||||
FIQTap: TIQTapEvent; // тап сырого IQ наружу (TCI), не владеет
|
FIQTap: TIQTapEvent; // тап сырого IQ наружу (TCI), не владеет
|
||||||
|
// ★Лок тапа. Снять тап обязан тот, кто вот-вот освободит его владельца
|
||||||
|
// (адаптер TCI), а зовёт тап DSP-поток. Без лока SetIQTap(nil) возвращался
|
||||||
|
// раньше, чем DSP-поток выходил из вызова, — и следующая же строка
|
||||||
|
// деструктора адаптера убивала объект прямо под ним.
|
||||||
|
FIQTapLock: TCriticalSection;
|
||||||
|
FIQTapOn: Boolean; // дешёвая проверка «тап вообще есть»
|
||||||
|
// Кто-то (TCI/web) слушает аудио помимо динамика: тогда молчащий (mute)
|
||||||
|
// слайс всё равно надо считать — RX_AUDIO снимается ДО громкости и мьюта.
|
||||||
|
FAudioTapsActive: Boolean;
|
||||||
|
|
||||||
function ModeToWDSP(Mode: Integer): Integer;
|
function ModeToWDSP(Mode: Integer): Integer;
|
||||||
function ModeToWDSPTX(Mode: Integer): Integer;
|
function ModeToWDSPTX(Mode: Integer): Integer;
|
||||||
@@ -638,6 +647,10 @@ type
|
|||||||
// Тап сырого RX-IQ наружу (потоки IQ по TCI). nil — снять. Ставит и
|
// Тап сырого RX-IQ наружу (потоки IQ по TCI). nil — снять. Ставит и
|
||||||
// снимает поток контроллера; вызывается тап из DSP-потока.
|
// снимает поток контроллера; вызывается тап из DSP-потока.
|
||||||
procedure SetIQTap(T: TIQTapEvent);
|
procedure SetIQTap(T: TIQTapEvent);
|
||||||
|
// Есть ли слушатели аудио-тапов (TCI/web). Влияет только на молчащие
|
||||||
|
// слайсы: с ними их всё равно надо считать, иначе клиент TCI остаётся без
|
||||||
|
// RX_AUDIO ровно потому, что оператор убрал звук у себя в комнате.
|
||||||
|
procedure SetAudioTapsActive(V: Boolean);
|
||||||
|
|
||||||
// --- Панадаптеры на аппаратных DDC (этап 3.2) ---
|
// --- Панадаптеры на аппаратных DDC (этап 3.2) ---
|
||||||
// Создаёт доп. пан PanId (1..MAX_PANS-1): аккумулятор + analyzer
|
// Создаёт доп. пан PanId (1..MAX_PANS-1): аккумулятор + analyzer
|
||||||
@@ -1506,6 +1519,11 @@ begin
|
|||||||
FBcnPixCount := 0;
|
FBcnPixCount := 0;
|
||||||
SetLength(FBcnDispBuf, DISPLAY_BLOCK_SIZE * 2);
|
SetLength(FBcnDispBuf, DISPLAY_BLOCK_SIZE * 2);
|
||||||
|
|
||||||
|
// Тап IQ наружу (TCI)
|
||||||
|
FIQTapLock := TCriticalSection.Create;
|
||||||
|
FIQTapOn := False;
|
||||||
|
FAudioTapsActive := False;
|
||||||
|
|
||||||
// Мультислайсы
|
// Мультислайсы
|
||||||
FSliceLock := TCriticalSection.Create;
|
FSliceLock := TCriticalSection.Create;
|
||||||
FillChar(FSlices, SizeOf(FSlices), 0);
|
FillChar(FSlices, SizeOf(FSlices), 0);
|
||||||
@@ -1606,6 +1624,7 @@ begin
|
|||||||
FSliceLock.Free;
|
FSliceLock.Free;
|
||||||
FMainLock.Free;
|
FMainLock.Free;
|
||||||
FBcnLock.Free;
|
FBcnLock.Free;
|
||||||
|
FIQTapLock.Free;
|
||||||
inherited;
|
inherited;
|
||||||
end;
|
end;
|
||||||
|
|
||||||
@@ -2424,8 +2443,21 @@ begin
|
|||||||
end;
|
end;
|
||||||
|
|
||||||
procedure TWDSPEngine.SetIQTap(T: TIQTapEvent);
|
procedure TWDSPEngine.SetIQTap(T: TIQTapEvent);
|
||||||
|
// Поток контроллера. Под локом: снятие обязано ДОЖДАТЬСЯ выхода DSP-потока из
|
||||||
|
// вызова — сразу после нас владельца тапа освобождают.
|
||||||
begin
|
begin
|
||||||
FIQTap := T;
|
FIQTapLock.Enter;
|
||||||
|
try
|
||||||
|
FIQTap := T;
|
||||||
|
FIQTapOn := Assigned(T);
|
||||||
|
finally
|
||||||
|
FIQTapLock.Leave;
|
||||||
|
end;
|
||||||
|
end;
|
||||||
|
|
||||||
|
procedure TWDSPEngine.SetAudioTapsActive(V: Boolean);
|
||||||
|
begin
|
||||||
|
FAudioTapsActive := V;
|
||||||
end;
|
end;
|
||||||
|
|
||||||
procedure TWDSPEngine.PushDDCPacket(const Buf: array of Byte;
|
procedure TWDSPEngine.PushDDCPacket(const Buf: array of Byte;
|
||||||
@@ -2543,9 +2575,19 @@ begin
|
|||||||
// Тап IQ пана — как у главного тракта, один вызов на блок. Он идёт
|
// Тап IQ пана — как у главного тракта, один вызов на блок. Он идёт
|
||||||
// ПОД FSliceLock (весь разбор пакета пана здесь), поэтому обработчик
|
// ПОД FSliceLock (весь разбор пакета пана здесь), поэтому обработчик
|
||||||
// обязан только скопировать данные и вернуться: любое ожидание тут
|
// обязан только скопировать данные и вернуться: любое ожидание тут
|
||||||
// остановит DSP-поток вместе со всеми панами.
|
// остановит DSP-поток вместе со всеми панами. Порядок локов
|
||||||
if Assigned(FIQTap) then
|
// FSliceLock → FIQTapLock; обратного нет нигде (SetIQTap не берёт
|
||||||
FIQTap(PanId, @Pan^.AccI[0], @Pan^.AccQ[0], Pan^.BufSize, Pan^.Rate);
|
// FSliceLock), так что клина не выходит.
|
||||||
|
if FIQTapOn then
|
||||||
|
begin
|
||||||
|
FIQTapLock.Enter;
|
||||||
|
try
|
||||||
|
if Assigned(FIQTap) then
|
||||||
|
FIQTap(PanId, @Pan^.AccI[0], @Pan^.AccQ[0], Pan^.BufSize, Pan^.Rate);
|
||||||
|
finally
|
||||||
|
FIQTapLock.Leave;
|
||||||
|
end;
|
||||||
|
end;
|
||||||
if Assigned(FOnSliceAudio) or Assigned(FOnSliceDemodAudio) then
|
if Assigned(FOnSliceAudio) or Assigned(FOnSliceDemodAudio) then
|
||||||
try
|
try
|
||||||
ProcessSlicesFor(PanId, Pan^.AccI, Pan^.AccQ, Pan^.BufSize);
|
ProcessSlicesFor(PanId, Pan^.AccI, Pan^.AccQ, Pan^.BufSize);
|
||||||
@@ -2666,8 +2708,17 @@ begin
|
|||||||
// Тап IQ наружу — по накопленному блоку и ДО обработки: fexchange0
|
// Тап IQ наружу — по накопленному блоку и ДО обработки: fexchange0
|
||||||
// забирает аккумулятор как вход и не портит его, но полагаться на это
|
// забирает аккумулятор как вход и не портит его, но полагаться на это
|
||||||
// незачем, а один вызов на блок вместо вызова на сэмпл экономит всё.
|
// незачем, а один вызов на блок вместо вызова на сэмпл экономит всё.
|
||||||
if Assigned(FIQTap) then
|
// Под FIQTapLock: см. SetIQTap.
|
||||||
FIQTap(0, @FRXAccI[0], @FRXAccQ[0], FBufSize, FSampleRate);
|
if FIQTapOn then
|
||||||
|
begin
|
||||||
|
FIQTapLock.Enter;
|
||||||
|
try
|
||||||
|
if Assigned(FIQTap) then
|
||||||
|
FIQTap(0, @FRXAccI[0], @FRXAccQ[0], FBufSize, FSampleRate);
|
||||||
|
finally
|
||||||
|
FIQTapLock.Leave;
|
||||||
|
end;
|
||||||
|
end;
|
||||||
ProcessRXBlock;
|
ProcessRXBlock;
|
||||||
end;
|
end;
|
||||||
end;
|
end;
|
||||||
@@ -2923,7 +2974,10 @@ begin
|
|||||||
if not (FSlices[s].Active and FSlices[s].Opened) then Continue;
|
if not (FSlices[s].Active and FSlices[s].Opened) then Continue;
|
||||||
if FSlices[s].PanId <> PanId then Continue;
|
if FSlices[s].PanId <> PanId then Continue;
|
||||||
// Muting a DMR speaker must not make its decoder lose synchronization.
|
// Muting a DMR speaker must not make its decoder lose synchronization.
|
||||||
if FSlices[s].Muted and not (FSlices[s].Mode in [MODE_DMR, MODE_FMRAW]) then Continue;
|
// ★То же и для тапов: RX_AUDIO по TCI снимается ДО громкости и мьюта, и
|
||||||
|
// молчащий в комнате слайс обязан продолжать отдавать звук клиенту.
|
||||||
|
if FSlices[s].Muted and not (FSlices[s].Mode in [MODE_DMR, MODE_FMRAW])
|
||||||
|
and not FAudioTapsActive then Continue;
|
||||||
// Свежая копия входного IQ — fexchange0 портит вход in-place.
|
// Свежая копия входного IQ — fexchange0 портит вход in-place.
|
||||||
for i := 0 to N - 1 do
|
for i := 0 to N - 1 do
|
||||||
begin
|
begin
|
||||||
@@ -2939,13 +2993,17 @@ begin
|
|||||||
FSliceOutL[k] := FSliceOut[k * 2];
|
FSliceOutL[k] := FSliceOut[k * 2];
|
||||||
FSliceOutR[k] := FSliceOut[k * 2 + 1];
|
FSliceOutR[k] := FSliceOut[k * 2 + 1];
|
||||||
end;
|
end;
|
||||||
if FSlices[s].Mode in [MODE_DMR, MODE_FMRAW] then
|
// Отвод ДО громкости и мьюта — у КАЖДОГО слайса, а не только у цифровых.
|
||||||
begin
|
// Это выход демодулятора: им кормятся декодеры (DMR/FM RAW) и он же уходит
|
||||||
if Assigned(FOnSliceDemodAudio) then
|
// в RX_AUDIO по TCI. Пока вызов стоял только в цифровой ветке, клиент на
|
||||||
FOnSliceDemodAudio(FSlices[s].Id, FSliceOutL, FSliceOutR,
|
// обычном слайсе (DIGU у скиммера) не получал аудиопоток вовсе.
|
||||||
FAudioBufSize);
|
if Assigned(FOnSliceDemodAudio) then
|
||||||
Continue; // decoder-grade signal uses the independent pre-volume route
|
FOnSliceDemodAudio(FSlices[s].Id, FSliceOutL, FSliceOutR, FAudioBufSize);
|
||||||
end;
|
// Цифровым дальше нельзя: их звук идёт мимо громкости и мьюта (см. выше).
|
||||||
|
if FSlices[s].Mode in [MODE_DMR, MODE_FMRAW] then Continue;
|
||||||
|
// Мьют — это тишина в динамике и в линейном выходе; сюда мы дошли только
|
||||||
|
// ради тапа выше.
|
||||||
|
if FSlices[s].Muted then Continue;
|
||||||
if Assigned(FOnSliceAudio) then
|
if Assigned(FOnSliceAudio) then
|
||||||
begin
|
begin
|
||||||
V := FSlices[s].Volume;
|
V := FSlices[s].Volume;
|
||||||
|
|||||||
+49
-22
@@ -23,7 +23,7 @@ TCI-клиенты ──WebSocket──► TTCIServer ──► TTCIAdapter ─
|
|||||||
| `TCIProtocol.pas` (~380 строк) | Чистый слой протокола: разбор `имя:арг1,арг2;`, сборка строк, экранирование `^ ~ *`, словарь видов связи, пересчёт громкости/порога в дБ. Зависит только от RTL + `RadioModes`. |
|
| `TCIProtocol.pas` (~380 строк) | Чистый слой протокола: разбор `имя:арг1,арг2;`, сборка строк, экранирование `^ ~ *`, словарь видов связи, пересчёт громкости/порога в дБ. Зависит только от RTL + `RadioModes`. |
|
||||||
| `TCIServer.pas` (~1250 строк) | WebSocket-сервер: accept-поток, поток на клиента, HTTP-Upgrade, разбор фреймов, рассылка, тик 20 мс. Сокеты и фреймы переиспользованы из веб-подсистемы (`WebUtils`, `WsClient`). |
|
| `TCIServer.pas` (~1250 строк) | WebSocket-сервер: accept-поток, поток на клиента, HTTP-Upgrade, разбор фреймов, рассылка, тик 20 мс. Сокеты и фреймы переиспользованы из веб-подсистемы (`WebUtils`, `WsClient`). |
|
||||||
| `TCIAdapter.pas` (~2600 строк) | Мост к `TRadioController`: реализация команд, пачка инициализации, уведомления об изменениях состояния, измерители, захват параметров (§3.5), подключение потоков к тапам аудио/IQ. |
|
| `TCIAdapter.pas` (~2600 строк) | Мост к `TRadioController`: реализация команд, пачка инициализации, уведомления об изменениях состояния, измерители, захват параметров (§3.5), подключение потоков к тапам аудио/IQ. |
|
||||||
| `TCIStreams.pas` (~600 строк) | Бинарные потоки (§3.4): дециматор/интерполятор, нарезка блоков с заголовком, кольцо записи линейного выхода и писатель WAV. Зависит только от RTL + `TCIProtocol`/`TCIServer`. |
|
| `TCIStreams.pas` (~600 строк) | Бинарные потоки (§3.4): дециматор/интерполятор, нарезка блоков с заголовком, буфер записи линейного выхода и писатель WAV. Зависит только от RTL + `TCIProtocol`/`TCIServer`. |
|
||||||
|
|
||||||
Принципы те же, что у CAT (см. `doc/CAT_STATUS.md`):
|
Принципы те же, что у CAT (см. `doc/CAT_STATUS.md`):
|
||||||
|
|
||||||
@@ -98,13 +98,23 @@ TCI-клиенты ──WebSocket──► TTCIServer ──► TTCIAdapter ─
|
|||||||
уложиться в `TCI_HANDSHAKE_MS` (5 с), иначе закрывается по таймауту сокета;
|
уложиться в `TCI_HANDSHAKE_MS` (5 с), иначе закрывается по таймауту сокета;
|
||||||
восемь слотов `TCI_MAX_CLIENTS` считаются только среди поднявшихся. Раньше
|
восемь слотов `TCI_MAX_CLIENTS` считаются только среди поднявшихся. Раньше
|
||||||
восемь молчащих TCP-соединений навсегда закрывали дверь настоящим клиентам.
|
восемь молчащих TCP-соединений навсегда закрывали дверь настоящим клиентам.
|
||||||
5. **Браузерные клиенты не пускаются.** Handshake с заголовком `Origin`
|
5. **`recv` = 0 — это конец связи, а не таймаут.** Приёмный цикл клиента ждёт
|
||||||
|
не дольше `TCI_POLL_MS`, поэтому «ошибка» `EAGAIN` для него — норма, но
|
||||||
|
спрашивать `errno` разрешено **только при отрицательном** результате: ноль
|
||||||
|
означает EOF, `errno` при нём не трогается и вполне может нести `EAGAIN` от
|
||||||
|
прошлого истёкшего опроса. Пока проверка была общей (`R <= 0`), обычный
|
||||||
|
TCP-разрыв без close-кадра — упавший клиент, выдернутый кабель — выглядел
|
||||||
|
как таймаут: поток крутился впустую, слот не освобождался до остановки
|
||||||
|
сервера, и после нескольких аварийных отключений новые клиенты упирались в
|
||||||
|
`TCI_MAX_CLIENTS`. Места в буфере при этом всегда хватает (кадр крупнее
|
||||||
|
буфера рвётся раньше), так что иного смысла у нуля нет.
|
||||||
|
6. **Браузерные клиенты не пускаются.** Handshake с заголовком `Origin`
|
||||||
получает 403. Origin шлёт только браузер, а авторизации в TCI нет: без этой
|
получает 403. Origin шлёт только браузер, а авторизации в TCI нет: без этой
|
||||||
проверки любая открытая вкладка дотягивалась бы по `ws://127.0.0.1:40001`
|
проверки любая открытая вкладка дотягивалась бы по `ws://127.0.0.1:40001`
|
||||||
до `TRX`, `TUNE` и `VFO`. Своей web-странице нужен явный прокси, а не дыра
|
до `TRX`, `TUNE` и `VFO`. Своей web-странице нужен явный прокси, а не дыра
|
||||||
по умолчанию.
|
по умолчанию.
|
||||||
|
|
||||||
6. **Блоки потоков нарезает и раскладывает DSP-поток.** Тап зовётся прямо из
|
7. **Блоки потоков нарезает и раскладывает DSP-поток.** Тап зовётся прямо из
|
||||||
потока WDSP, поэтому в `TCIStreams` нет ни одного ожидания: блок уходит в
|
потока WDSP, поэтому в `TCIStreams` нет ни одного ожидания: блок уходит в
|
||||||
кольцо клиента (микросекунды под его локом), а в сокет его пишет, как и
|
кольцо клиента (микросекунды под его локом), а в сокет его пишет, как и
|
||||||
команды, поток самого клиента. Порядок локов везде один: `FSliceLock` →
|
команды, поток самого клиента. Порядок локов везде один: `FSliceLock` →
|
||||||
@@ -420,13 +430,17 @@ TCI-клиент отобрал бы звук у динамика и у брау
|
|||||||
в заголовке мы не будем ни в каком случае. Смена rate устройства или DDC пана
|
в заголовке мы не будем ни в каком случае. Смена rate устройства или DDC пана
|
||||||
на ходу пересобирает прореживание (`SetSourceRate`).
|
на ходу пересобирает прореживание (`SetSourceRate`).
|
||||||
|
|
||||||
**Размер блока.** `AUDIO_STREAM_SAMPLES` — это сэмплы **на канал**, а в
|
**Размер блока.** `AUDIO_STREAM_SAMPLES` — это сэмплы **на канал**, и у
|
||||||
`Stream.length` уходит, как велит §3.4, количество вещественных отсчётов
|
аудиопотоков ровно это число уходит в `Stream.length`: §4.3 говорит про arg1
|
||||||
(`samples × channels`). Сходится и с умолчаниями ExpertSDR3 (2048 при 48 кГц
|
«количество сэмплов, указываемое в поле Stream.length», а умолчание 2048 при
|
||||||
даёт ~43 мс), и с потолком `data[16384]`: 2048 × 2 канала × float32 = ровно
|
48 кГц сходится с потолком `data[16384]` только так — 2048 × 2 канала ×
|
||||||
16384 байта. Смена любого параметра потока на ходу **перезапускает** уже идущие
|
float32 = ровно 16384 байта, то есть `length` считает сэмплы, а не отсчёты.
|
||||||
потоки этого клиента: блок с новой частотой посреди старого потока клиенты
|
У **IQ** единица другая: §3.4 определяет число комплексных отсчётов как
|
||||||
разбирают как мусор.
|
`length / channels`, поэтому там в `length` уходит `samples × channels`.
|
||||||
|
Развилку держит `TCIFillHeader` (по типу потока), обратную — разбор
|
||||||
|
`TX_AUDIO_STREAM` в `HandleBinary`. Смена любого параметра потока на ходу
|
||||||
|
**перезапускает** уже идущие потоки этого клиента: блок с новой частотой
|
||||||
|
посреди старого потока клиенты разбирают как мусор.
|
||||||
|
|
||||||
**Передача (§3.4, §4.2).** `TRX:0,true,tci` берёт модуляцию из аудиопотока
|
**Передача (§3.4, §4.2).** `TRX:0,true,tci` берёт модуляцию из аудиопотока
|
||||||
клиента — но только если у него запущен `AUDIO_START` (буквально по документу:
|
клиента — но только если у него запущен `AUDIO_START` (буквально по документу:
|
||||||
@@ -440,9 +454,19 @@ web-клиента: явная просьба сильнее умолчания
|
|||||||
отбрасывается молча: отвечать ошибкой на каждый чужой блок значит захлебнуться.
|
отбрасывается молча: отвечать ошибкой на каждый чужой блок значит захлебнуться.
|
||||||
|
|
||||||
**Запись линейного выхода.** Рекордер один на приёмник (а не на клиента):
|
**Запись линейного выхода.** Рекордер один на приёмник (а не на клиента):
|
||||||
пишет он то, что слышно в аппарате. Кольцо на запрошенное время (потолок 300 с)
|
пишет он то, что слышно в аппарате. Буфер на запрошенное время (потолок 300 с)
|
||||||
в int16 48 кГц стерео — это ровно то, что уйдёт в WAV, и вдвое меньше памяти,
|
в int16 48 кГц стерео — это ровно то, что уйдёт в WAV, и вдвое меньше памяти,
|
||||||
чем float32. `SAVE` завершает запись и отдаёт кольцо отдельному потоку-писателю:
|
чем float32. ★Буфер **линейный, не кольцевой**: §4.3 называет arg2
|
||||||
|
максимальным временем записи и прямо говорит, что по его истечении запись
|
||||||
|
удаляется, а чтобы сохранить файл, `SAVE` нужно прислать внутри интервала.
|
||||||
|
Поэтому `START` открывает окно длиной arg2, пишем от его начала, а по концу
|
||||||
|
окна данные выбрасываются вместе с памятью (`DropData` из `Feed` и из `Take`).
|
||||||
|
Срок считается **по часам** от `START`, а не по накопленным сэмплам: линейный
|
||||||
|
выход может молчать (мьют, стоящий приёмник), а время записи всё равно идёт.
|
||||||
|
Кольцо «последние N секунд» вело себя иначе в обе стороны — начало записи
|
||||||
|
затирало само себя, а `SAVE` через час после `START` отдавал файл, которого у
|
||||||
|
ExpertSDR3 давно бы не было.
|
||||||
|
`SAVE` завершает запись и отдаёт буфер отдельному потоку-писателю:
|
||||||
файл бывает в десятки мегабайт, а команда пришла в потоке клиента, который в
|
файл бывает в десятки мегабайт, а команда пришла в потоке клиента, который в
|
||||||
это время не читает свой сокет. MP3 не поддержан — кодера в проекте нет,
|
это время не читает свой сокет. MP3 не поддержан — кодера в проекте нет,
|
||||||
и на `.mp3` уходит честный `tci_error`.
|
и на `.mp3` уходит честный `tci_error`.
|
||||||
@@ -563,8 +587,10 @@ web-клиента: явная просьба сильнее умолчания
|
|||||||
HTTP-заголовков.
|
HTTP-заголовков.
|
||||||
- **Транспорт:** 101 на заголовки с табуляцией и без пробела после двоеточия;
|
- **Транспорт:** 101 на заголовки с табуляцией и без пробела после двоеточия;
|
||||||
отказ 403 при `Origin`; ровно восемь поднявшихся клиентов и 503 девятому;
|
отказ 403 при `Origin`; ровно восемь поднявшихся клиентов и 503 девятому;
|
||||||
освобождение слотов после отключения; молчащий сокет уходит по таймауту
|
освобождение слотов после отключения — **обоими** путями, и штатным
|
||||||
handshake; ответный close-кадр; `Stop` с живым клиентом.
|
close-кадром, и обычным TCP-разрывом без него (см. правило 5 в §1.1);
|
||||||
|
молчащий сокет уходит по таймауту handshake; ответный close-кадр;
|
||||||
|
`Stop` с живым клиентом.
|
||||||
- **Адаптер:** `vfo:0,0,abc`, `vfo:0,0,-1`, `dds:0,broken`,
|
- **Адаптер:** `vfo:0,0,abc`, `vfo:0,0,-1`, `dds:0,broken`,
|
||||||
`rx_filter_band:0,x,y` не меняют ничего, а годная частота проходит; захват
|
`rx_filter_band:0,x,y` не меняют ничего, а годная частота проходит; захват
|
||||||
параметра (второй клиент не перебивает первого раньше 200 мс и перебивает
|
параметра (второй клиент не перебивает первого раньше 200 мс и перебивает
|
||||||
@@ -593,8 +619,8 @@ web-клиента: явная просьба сильнее умолчания
|
|||||||
|
|
||||||
### Стенд этапа 2 (бинарные потоки) — 112 проверок, все зелёные
|
### Стенд этапа 2 (бинарные потоки) — 112 проверок, все зелёные
|
||||||
|
|
||||||
Отдельная программа (`tcitest.pas` в scratchpad, собирается тем же fpc без
|
Отдельная программа (`test/tci/tcitest.pas`, прогон — `test/tci/run.sh`,
|
||||||
внешних библиотек) проверяет потоки на четырёх уровнях:
|
внешних библиотек не требует) проверяет потоки на четырёх уровнях:
|
||||||
|
|
||||||
- **Формат и математика:** коэффициенты прореживания (в том числе «просят выше,
|
- **Формат и математика:** коэффициенты прореживания (в том числе «просят выше,
|
||||||
чем есть» и «нацело не делится»), умолчания размера блока, поля заголовка,
|
чем есть» и «нацело не делится»), умолчания размера блока, поля заголовка,
|
||||||
@@ -612,9 +638,10 @@ web-клиента: явная просьба сильнее умолчания
|
|||||||
стерео float32 с верным заголовком и длиной; IQ 384→48 кГц уходит только
|
стерео float32 с верным заголовком и длиной; IQ 384→48 кГц уходит только
|
||||||
целым блоком (полблока не отправляется); моно int16; пересчёт после смены
|
целым блоком (полблока не отправляется); моно int16; пересчёт после смены
|
||||||
rate источника; переполнение кольца теряет старые блоки, но клиент жив.
|
rate источника; переполнение кольца теряет старые блоки, но клиент жив.
|
||||||
- **Рекордер и WAV:** кольцо ограничено запрошенным временем, после заворота
|
- **Рекордер и WAV:** буфер ограничен запрошенным временем и пишется с начала
|
||||||
первым идёт самый старый отсчёт, `Take` опустошает, файл получает верные
|
окна (переполнение отбрасывает новое, а не затирает старое), до срока запись
|
||||||
RIFF/fmt/data и длину.
|
есть, после срока её нет и истёкший рекордер не оживает, `Take` завершает
|
||||||
|
запись, файл получает верные RIFF/fmt/data и длину.
|
||||||
- **Команды на живом сервере** (настоящий `TRadioController`, WS-клиент на
|
- **Команды на живом сервере** (настоящий `TRadioController`, WS-клиент на
|
||||||
сыром сокете): отказ на несуществующий приёмник и на нечисловой аргумент,
|
сыром сокете): отказ на несуществующий приёмник и на нечисловой аргумент,
|
||||||
отказ на старт потока с незапущенного пана, подтверждение и отбраковка
|
отказ на старт потока с незапущенного пана, подтверждение и отбраковка
|
||||||
@@ -651,9 +678,9 @@ send просто возвращает EPIPE, и клиент выбрасыва
|
|||||||
TCI в его граф пока не заведён — юниты LCL-free, подключается одной строкой в
|
TCI в его граф пока не заведён — юниты LCL-free, подключается одной строкой в
|
||||||
`ewsdrd.lpr`, как web).
|
`ewsdrd.lpr`, как web).
|
||||||
|
|
||||||
★Пробная сборка стенда: `-Mobjfpc` обязателен. С `-Mdelphi` в командной строке
|
★Про сборку стенда — `test/tci/README.md`: там записаны обе грабли (обязательный
|
||||||
вложенные комментарии выключаются, и `{$MODE Delphi}` внутри шапки `WebUtils.pas`
|
`-Mobjfpc` и отдельный каталог `.ppu` с абсолютным путём, иначе линковка тянет
|
||||||
закрывает комментарий раньше времени — компиляция падает на «illegal character».
|
`PlatformUtils` из GUI-сборки, собранный с LCL).
|
||||||
|
|
||||||
**На реальном железе и с реальным клиентом (Log4OM/N1MM/WSJT-X/CW Skimmer) не
|
**На реальном железе и с реальным клиентом (Log4OM/N1MM/WSJT-X/CW Skimmer) не
|
||||||
проверялось — ни команды, ни потоки.**
|
проверялось — ни команды, ни потоки.**
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# Стенд TCI
|
||||||
|
|
||||||
|
Прогон:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
test/tci/run.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Собирает `tcitest.pas` и запускает его; код возврата ненулевой, если хоть одна
|
||||||
|
проверка провалена. Внешних библиотек стенд не требует — WebSocket-клиент в нём
|
||||||
|
написан на сыром сокете. `libwdsp` нужна только последней части (сквозной
|
||||||
|
прогон через движок); без неё эта часть сообщает о пропуске, а остальные идут
|
||||||
|
как обычно.
|
||||||
|
|
||||||
|
Что проверяется — в шапке `tcitest.pas`; чего стенд **не** покрывает —
|
||||||
|
в `doc/TCI.md`, §5.
|
||||||
|
|
||||||
|
Две грабли, из-за которых стенд собирается именно так (обе стоили по часу):
|
||||||
|
|
||||||
|
* **`-Mobjfpc` обязателен.** С `-Mdelphi` в командной строке выключаются
|
||||||
|
вложенные комментарии, и `{$MODE Delphi}` внутри шапки `WebUtils.pas`
|
||||||
|
закрывает комментарий раньше времени — компиляция падает на
|
||||||
|
«illegal character».
|
||||||
|
* **Каталог `.ppu` — свой, с именем не `lib`, и путь абсолютный.**
|
||||||
|
Относительное `lib/<cpu>-<os>` компилятор ищет и относительно `-Fu`, то есть
|
||||||
|
находит units GUI-сборки в корне проекта, а там `PlatformUtils` собран с LCL:
|
||||||
|
стенд падает на линковке с `undefined reference to TC_$FORMS_$$_SCREEN`.
|
||||||
|
По той же причине сборка идёт с `-dHEADLESS`, как у демона.
|
||||||
|
|
||||||
|
Каталоги `units/` и `bin/` — выход сборки, в git не попадают.
|
||||||
|
|
||||||
|
## Проверка на живом эфире без передачи
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 test/tci/live_rx_test.py --host 127.0.0.1 --port 40001
|
||||||
|
```
|
||||||
|
|
||||||
|
Наглядный UI-прогон с автоматическим возвратом к исходным значениям:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 -u test/tci/live_rx_test.py --visible --commands-only --dwell 1.5
|
||||||
|
```
|
||||||
|
|
||||||
|
Стенд проверяет receive-only команды, IQ, аудио демодулятора и
|
||||||
|
line-out для всех обнаруженных приёмников. Он никогда не посылает
|
||||||
|
`START`, `STOP`, установку `TRX`/`TUNE`, TX-аудио и CW-текст. Если радио
|
||||||
|
уже на передаче, прогон аварийно прекращается.
|
||||||
@@ -0,0 +1,527 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Live receive-only TCI acceptance test. Uses only Python's standard library.
|
||||||
|
|
||||||
|
The test never sends START/STOP, TRX with a value, TUNE with a value, TX audio,
|
||||||
|
CW text, or any other command capable of keying the transmitter.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
import select
|
||||||
|
import socket
|
||||||
|
import struct
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
|
||||||
|
HDR = struct.Struct("<16I")
|
||||||
|
STREAM_NAMES = {0: "IQ", 1: "RX_AUDIO", 2: "TX_AUDIO", 3: "TX_CHRONO", 4: "LINEOUT"}
|
||||||
|
SAMPLE_BYTES = {0: 2, 1: 3, 2: 4, 3: 4}
|
||||||
|
|
||||||
|
|
||||||
|
def split_commands(text):
|
||||||
|
return [part.strip() + ";" for part in text.split(";") if part.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def parse_command(command):
|
||||||
|
body = command.rstrip(";")
|
||||||
|
if ":" not in body:
|
||||||
|
return body.lower(), []
|
||||||
|
name, args = body.split(":", 1)
|
||||||
|
return name.lower(), args.split(",")
|
||||||
|
|
||||||
|
|
||||||
|
class WS:
|
||||||
|
def __init__(self, host, port):
|
||||||
|
self.sock = socket.create_connection((host, port), timeout=4)
|
||||||
|
self.sock.settimeout(None)
|
||||||
|
key = "dGhlIHNhbXBsZSBub25jZQ=="
|
||||||
|
req = (f"GET / HTTP/1.1\r\nHost: {host}:{port}\r\n"
|
||||||
|
"Upgrade: websocket\r\nConnection: Upgrade\r\n"
|
||||||
|
f"Sec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n\r\n")
|
||||||
|
self.sock.sendall(req.encode("ascii"))
|
||||||
|
raw = b""
|
||||||
|
while b"\r\n\r\n" not in raw:
|
||||||
|
raw += self.sock.recv(4096)
|
||||||
|
head, self.buf = raw.split(b"\r\n\r\n", 1)
|
||||||
|
if b" 101 " not in head.split(b"\r\n", 1)[0]:
|
||||||
|
raise RuntimeError(head.decode("latin1", "replace"))
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
# Finish the WebSocket protocol explicitly. A bare TCP EOF is also
|
||||||
|
# legal, but the server's EOF path is tested separately and must not
|
||||||
|
# make every acceptance run consume a leaked client slot.
|
||||||
|
try:
|
||||||
|
self.send_frame(8, b"")
|
||||||
|
ready, _, _ = select.select([self.sock], [], [], 0.25)
|
||||||
|
if ready:
|
||||||
|
self.sock.recv(4096)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
self.sock.shutdown(socket.SHUT_RDWR)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
self.sock.close()
|
||||||
|
|
||||||
|
def send_frame(self, opcode, payload=b""):
|
||||||
|
mask = os.urandom(4)
|
||||||
|
n = len(payload)
|
||||||
|
if n <= 125:
|
||||||
|
head = bytes((0x80 | opcode, 0x80 | n))
|
||||||
|
elif n <= 65535:
|
||||||
|
head = bytes((0x80 | opcode, 0xFE)) + struct.pack("!H", n)
|
||||||
|
else:
|
||||||
|
head = bytes((0x80 | opcode, 0xFF)) + struct.pack("!Q", n)
|
||||||
|
masked = bytes(v ^ mask[i & 3] for i, v in enumerate(payload))
|
||||||
|
self.sock.sendall(head + mask + masked)
|
||||||
|
|
||||||
|
def send_text(self, command):
|
||||||
|
self.send_frame(1, command.encode("utf-8"))
|
||||||
|
|
||||||
|
def _need(self, n, deadline):
|
||||||
|
while len(self.buf) < n:
|
||||||
|
left = deadline - time.monotonic()
|
||||||
|
if left <= 0:
|
||||||
|
return False
|
||||||
|
ready, _, _ = select.select([self.sock], [], [], left)
|
||||||
|
if not ready:
|
||||||
|
return False
|
||||||
|
block = self.sock.recv(65536)
|
||||||
|
if not block:
|
||||||
|
raise ConnectionError("TCI connection closed")
|
||||||
|
self.buf += block
|
||||||
|
return True
|
||||||
|
|
||||||
|
def recv_frame(self, timeout=1.0):
|
||||||
|
deadline = time.monotonic() + timeout
|
||||||
|
if not self._need(2, deadline):
|
||||||
|
return None
|
||||||
|
b0, b1 = self.buf[0], self.buf[1]
|
||||||
|
pos, n = 2, b1 & 0x7f
|
||||||
|
if n == 126:
|
||||||
|
if not self._need(4, deadline):
|
||||||
|
return None
|
||||||
|
n, pos = struct.unpack("!H", self.buf[2:4])[0], 4
|
||||||
|
elif n == 127:
|
||||||
|
if not self._need(10, deadline):
|
||||||
|
return None
|
||||||
|
n, pos = struct.unpack("!Q", self.buf[2:10])[0], 10
|
||||||
|
if not self._need(pos + n, deadline):
|
||||||
|
return None
|
||||||
|
payload = self.buf[pos:pos + n]
|
||||||
|
self.buf = self.buf[pos + n:]
|
||||||
|
opcode = b0 & 0x0f
|
||||||
|
if opcode == 9:
|
||||||
|
self.send_frame(10, payload)
|
||||||
|
return self.recv_frame(max(0.0, deadline - time.monotonic()))
|
||||||
|
return opcode, payload
|
||||||
|
|
||||||
|
|
||||||
|
class LiveTest:
|
||||||
|
def __init__(self, ws, seconds):
|
||||||
|
self.ws = ws
|
||||||
|
self.seconds = seconds
|
||||||
|
self.passed = 0
|
||||||
|
self.failed = 0
|
||||||
|
self.warned = 0
|
||||||
|
self.text = []
|
||||||
|
self.binary = []
|
||||||
|
self.initial = []
|
||||||
|
|
||||||
|
def ok(self, label, condition, detail=""):
|
||||||
|
if condition:
|
||||||
|
self.passed += 1
|
||||||
|
print(f" ok {label}" + (f" — {detail}" if detail else ""))
|
||||||
|
else:
|
||||||
|
self.failed += 1
|
||||||
|
print(f" FAIL {label}" + (f" — {detail}" if detail else ""))
|
||||||
|
|
||||||
|
def warn(self, label, detail=""):
|
||||||
|
self.warned += 1
|
||||||
|
print(f" WARN {label}" + (f" — {detail}" if detail else ""))
|
||||||
|
|
||||||
|
def pump(self, seconds):
|
||||||
|
deadline = time.monotonic() + seconds
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
frame = self.ws.recv_frame(min(0.2, deadline - time.monotonic()))
|
||||||
|
if frame is None:
|
||||||
|
continue
|
||||||
|
opcode, payload = frame
|
||||||
|
if opcode == 1:
|
||||||
|
self.text.extend(split_commands(payload.decode("utf-8", "replace")))
|
||||||
|
elif opcode == 2:
|
||||||
|
self.binary.append(payload)
|
||||||
|
|
||||||
|
def wait_text(self, name, seconds=1.5):
|
||||||
|
deadline = time.monotonic() + seconds
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
for i, command in enumerate(self.text):
|
||||||
|
got, args = parse_command(command)
|
||||||
|
if got == name:
|
||||||
|
self.text.pop(i)
|
||||||
|
return command, args
|
||||||
|
self.pump(min(0.1, deadline - time.monotonic()))
|
||||||
|
return None, []
|
||||||
|
|
||||||
|
def init(self):
|
||||||
|
deadline = time.monotonic() + 5
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
self.pump(0.2)
|
||||||
|
ready = next((x for x in self.text if parse_command(x)[0] == "ready"), None)
|
||||||
|
if ready:
|
||||||
|
self.initial = list(self.text)
|
||||||
|
self.text.clear()
|
||||||
|
return
|
||||||
|
raise RuntimeError("READY not received")
|
||||||
|
|
||||||
|
def initial_by_name(self, name):
|
||||||
|
return [parse_command(x)[1] for x in self.initial if parse_command(x)[0] == name]
|
||||||
|
|
||||||
|
def initial_checks(self):
|
||||||
|
names = [parse_command(x)[0] for x in self.initial]
|
||||||
|
required = {
|
||||||
|
"protocol", "device", "receive_only", "trx_count", "channel_count",
|
||||||
|
"vfo_limits", "if_limits", "modulations_list", "dds", "vfo", "if",
|
||||||
|
"modulation", "rx_filter_band", "agc_mode", "tx_enable", "trx",
|
||||||
|
"tune", "drive", "volume", "mute", "iq_samplerate",
|
||||||
|
"audio_samplerate", "tx_frequency", "app_focus", "ready",
|
||||||
|
}
|
||||||
|
missing = sorted(required.difference(names))
|
||||||
|
self.ok("initial state contains required commands", not missing,
|
||||||
|
"missing=" + ",".join(missing) if missing else f"commands={len(names)}")
|
||||||
|
self.ok("READY is last initialization command",
|
||||||
|
bool(names) and names[-1] == "ready", names[-1] if names else "empty")
|
||||||
|
|
||||||
|
def query(self, command, response, echo_same=False):
|
||||||
|
self.text.clear()
|
||||||
|
self.ws.send_text(command)
|
||||||
|
got, args = self.wait_text(response)
|
||||||
|
self.ok(command.rstrip(";"), got is not None, got or "no response")
|
||||||
|
if echo_same and got is not None:
|
||||||
|
self.text.clear()
|
||||||
|
self.ws.send_text(got)
|
||||||
|
echoed, _ = self.wait_text(response)
|
||||||
|
self.ok("SET same " + command.rstrip(";"), echoed is not None,
|
||||||
|
echoed or "no confirmation")
|
||||||
|
return args
|
||||||
|
|
||||||
|
def command_checks(self, receivers, channels):
|
||||||
|
print("Commands: receive-only queries")
|
||||||
|
for rx in receivers:
|
||||||
|
for name in ("dds", "modulation", "rx_filter_band", "agc_mode", "agc_gain",
|
||||||
|
"lock", "sql_enable", "sql_level", "rx_mute", "rx_nr_enable",
|
||||||
|
"rx_nb_enable", "rx_anf_enable", "rx_nb_param", "rx_bin_enable",
|
||||||
|
"rx_anc_enable", "rx_apf_enable", "rx_dse_enable", "rx_nf_enable",
|
||||||
|
"rit_enable", "rit_offset", "xit_enable", "xit_offset"):
|
||||||
|
self.query(f"{name}:{rx};", name, echo_same=True)
|
||||||
|
for ch in channels.get(rx, []):
|
||||||
|
for name in ("vfo", "if", "rx_volume", "rx_balance"):
|
||||||
|
self.query(f"{name}:{rx},{ch};", name, echo_same=True)
|
||||||
|
# Stub today, but its wire contract still has to be valid.
|
||||||
|
self.query(f"rx_channel_enable:{rx},1;", "rx_channel_enable", echo_same=True)
|
||||||
|
for command, response in (("trx:0;", "trx"), ("tune:0;", "tune"),
|
||||||
|
("drive:0;", "drive"), ("tune_drive:0;", "tune_drive"),
|
||||||
|
("split_enable:0;", "split_enable"), ("volume;", "volume"),
|
||||||
|
("mute;", "mute"), ("mon_volume;", "mon_volume"),
|
||||||
|
("mon_enable;", "mon_enable"),
|
||||||
|
("cw_macros_speed;", "cw_macros_speed"),
|
||||||
|
("cw_macros_delay;", "cw_macros_delay"),
|
||||||
|
("digl_offset;", "digl_offset"),
|
||||||
|
("digu_offset;", "digu_offset")):
|
||||||
|
self.query(command, response,
|
||||||
|
echo_same=response not in ("trx", "tune"))
|
||||||
|
|
||||||
|
# Exercise safe one-way commands without changing persistent radio state.
|
||||||
|
self.ws.send_text("rx_sensors_enable:true,100;")
|
||||||
|
got, _ = self.wait_text("rx_sensors", 2)
|
||||||
|
self.ok("RX_SENSORS_ENABLE", got is not None, got or "no sensor report")
|
||||||
|
self.ws.send_text("rx_sensors_enable:false;")
|
||||||
|
self.ws.send_text("tx_sensors_enable:true,100;")
|
||||||
|
got, _ = self.wait_text("tx_sensors", 2)
|
||||||
|
self.ok("TX_SENSORS_ENABLE while RX", got is not None,
|
||||||
|
got or "no sensor report")
|
||||||
|
self.ws.send_text("tx_sensors_enable:false;")
|
||||||
|
self.query("cw_keyer_speed;", "cw_macros_speed")
|
||||||
|
self.ws.send_text("spot:ZZ0TCITEST,usb,14074000,4294967295,TCI RX test;")
|
||||||
|
self.ws.send_text("spot_delete:ZZ0TCITEST;")
|
||||||
|
self.ok("SPOT/SPOT_DELETE", True, "temporary marker removed")
|
||||||
|
|
||||||
|
# Negative TX safety guard: malformed values must not key anything.
|
||||||
|
self.ws.send_text("trx:0,not-a-bool,tci;")
|
||||||
|
_, trx = self.wait_text("trx")
|
||||||
|
self.ok("TRX malformed value rejected", len(trx) >= 2 and trx[1].lower() == "false",
|
||||||
|
",".join(trx))
|
||||||
|
self.ws.send_text("tune:0,not-a-bool;")
|
||||||
|
_, tune = self.wait_text("tune")
|
||||||
|
self.ok("TUNE malformed value rejected", len(tune) >= 2 and tune[1].lower() == "false",
|
||||||
|
",".join(tune))
|
||||||
|
|
||||||
|
def visible_change(self, label, response, changed, restored, dwell):
|
||||||
|
"""Apply one RX-only change, leave it visible, then restore it."""
|
||||||
|
self.text.clear()
|
||||||
|
self.ws.send_text(changed)
|
||||||
|
got, args = self.wait_text(response, 2)
|
||||||
|
self.ok(label + " changed", got is not None, got or "no confirmation")
|
||||||
|
time.sleep(dwell)
|
||||||
|
self.text.clear()
|
||||||
|
self.ws.send_text(restored)
|
||||||
|
got, args = self.wait_text(response, 2)
|
||||||
|
self.ok(label + " restored", got is not None, got or "no confirmation")
|
||||||
|
time.sleep(0.3)
|
||||||
|
|
||||||
|
def visible_checks(self, receivers, channels, dwell):
|
||||||
|
print(f"Visible UI sweep: dwell={dwell:.1f}s; every value is restored")
|
||||||
|
for rx in receivers:
|
||||||
|
dds = self.query(f"dds:{rx};", "dds")
|
||||||
|
if len(dds) >= 2:
|
||||||
|
old = int(float(dds[1]))
|
||||||
|
self.visible_change(f"DDS rx={rx}", "dds",
|
||||||
|
f"dds:{rx},{old + 5000};", f"dds:{rx},{old};", dwell)
|
||||||
|
|
||||||
|
mode = self.query(f"modulation:{rx};", "modulation")
|
||||||
|
filt = self.query(f"rx_filter_band:{rx};", "rx_filter_band")
|
||||||
|
if len(mode) >= 2:
|
||||||
|
old_mode = mode[1].lower()
|
||||||
|
alternate = {"nfm": "am", "digu": "usb", "digl": "lsb",
|
||||||
|
"fmraw": "nfm", "dmr": "nfm"}.get(old_mode, "am")
|
||||||
|
if alternate == old_mode:
|
||||||
|
alternate = "usb"
|
||||||
|
self.visible_change(f"MODULATION rx={rx}", "modulation",
|
||||||
|
f"modulation:{rx},{alternate};",
|
||||||
|
f"modulation:{rx},{old_mode};", dwell)
|
||||||
|
|
||||||
|
if len(filt) >= 3:
|
||||||
|
lo, hi = int(filt[1]), int(filt[2])
|
||||||
|
if hi - lo > 400:
|
||||||
|
self.visible_change(f"FILTER rx={rx}", "rx_filter_band",
|
||||||
|
f"rx_filter_band:{rx},{lo + 100},{hi - 100};",
|
||||||
|
f"rx_filter_band:{rx},{lo},{hi};", dwell)
|
||||||
|
|
||||||
|
agc = self.query(f"agc_mode:{rx};", "agc_mode")
|
||||||
|
if len(agc) >= 2:
|
||||||
|
old_agc = agc[1].lower()
|
||||||
|
new_agc = "fast" if old_agc != "fast" else "normal"
|
||||||
|
self.visible_change(f"AGC rx={rx}", "agc_mode",
|
||||||
|
f"agc_mode:{rx},{new_agc};",
|
||||||
|
f"agc_mode:{rx},{old_agc};", dwell)
|
||||||
|
|
||||||
|
nr = self.query(f"rx_nr_enable:{rx};", "rx_nr_enable")
|
||||||
|
if len(nr) >= 2:
|
||||||
|
old_nr = nr[1].lower()
|
||||||
|
new_nr = "false" if old_nr == "true" else "true"
|
||||||
|
self.visible_change(f"NR rx={rx}", "rx_nr_enable",
|
||||||
|
f"rx_nr_enable:{rx},{new_nr};",
|
||||||
|
f"rx_nr_enable:{rx},{old_nr};", dwell)
|
||||||
|
|
||||||
|
sql = self.query(f"sql_enable:{rx};", "sql_enable")
|
||||||
|
if len(sql) >= 2:
|
||||||
|
old_sql = sql[1].lower()
|
||||||
|
new_sql = "false" if old_sql == "true" else "true"
|
||||||
|
self.visible_change(f"SQL rx={rx}", "sql_enable",
|
||||||
|
f"sql_enable:{rx},{new_sql};",
|
||||||
|
f"sql_enable:{rx},{old_sql};", dwell)
|
||||||
|
|
||||||
|
for ch in channels.get(rx, []):
|
||||||
|
vfo = self.query(f"vfo:{rx},{ch};", "vfo")
|
||||||
|
if len(vfo) >= 3:
|
||||||
|
old_vfo = int(float(vfo[2]))
|
||||||
|
self.visible_change(f"VFO rx={rx} ch={ch}", "vfo",
|
||||||
|
f"vfo:{rx},{ch},{old_vfo + 2000};",
|
||||||
|
f"vfo:{rx},{ch},{old_vfo};", dwell)
|
||||||
|
vol = self.query(f"rx_volume:{rx},{ch};", "rx_volume")
|
||||||
|
if len(vol) >= 3:
|
||||||
|
old_vol = int(float(vol[2]))
|
||||||
|
new_vol = old_vol - 12 if old_vol > -49 else old_vol + 12
|
||||||
|
new_vol = max(-60, min(0, new_vol))
|
||||||
|
self.visible_change(f"VOLUME rx={rx} ch={ch}", "rx_volume",
|
||||||
|
f"rx_volume:{rx},{ch},{new_vol};",
|
||||||
|
f"rx_volume:{rx},{ch},{old_vol};", dwell)
|
||||||
|
|
||||||
|
def sample_stats(self, payload, fmt, length):
|
||||||
|
data = payload[HDR.size:]
|
||||||
|
count = min(length, 4096)
|
||||||
|
vals = []
|
||||||
|
if fmt == 3:
|
||||||
|
vals = struct.unpack_from(f"<{count}f", data)
|
||||||
|
elif fmt == 0:
|
||||||
|
vals = [v / 32768.0 for v in struct.unpack_from(f"<{count}h", data)]
|
||||||
|
elif fmt == 1:
|
||||||
|
vals = []
|
||||||
|
for i in range(min(count, len(data) // 3)):
|
||||||
|
v = data[i * 3] | (data[i * 3 + 1] << 8) | (data[i * 3 + 2] << 16)
|
||||||
|
if v & 0x800000:
|
||||||
|
v -= 1 << 24
|
||||||
|
vals.append(v / 8388608.0)
|
||||||
|
elif fmt == 2:
|
||||||
|
vals = [v / 2147483648.0 for v in struct.unpack_from(f"<{count}i", data)]
|
||||||
|
finite = [v for v in vals if math.isfinite(v)]
|
||||||
|
if not finite:
|
||||||
|
return 0.0, 0.0, False
|
||||||
|
rms = math.sqrt(sum(v * v for v in finite) / len(finite))
|
||||||
|
return rms, max(abs(v) for v in finite), len(finite) == len(vals)
|
||||||
|
|
||||||
|
def stream(self, rx, start, stop, wanted_type, requested_length=None):
|
||||||
|
self.binary.clear()
|
||||||
|
self.text.clear()
|
||||||
|
self.ws.send_text(f"{start}:{rx};")
|
||||||
|
self.pump(self.seconds)
|
||||||
|
self.ws.send_text(f"{stop}:{rx};")
|
||||||
|
self.pump(0.25)
|
||||||
|
errors = [x for x in self.text if parse_command(x)[0] == "tci_error"]
|
||||||
|
blocks = []
|
||||||
|
for payload in self.binary:
|
||||||
|
if len(payload) < HDR.size:
|
||||||
|
continue
|
||||||
|
h = HDR.unpack_from(payload)
|
||||||
|
if h[6] == wanted_type and h[0] == rx:
|
||||||
|
blocks.append((payload, h))
|
||||||
|
label = f"{STREAM_NAMES[wanted_type]} rx={rx}"
|
||||||
|
self.ok(label + " blocks", bool(blocks),
|
||||||
|
errors[0] if errors else f"blocks={len(blocks)}")
|
||||||
|
if not blocks:
|
||||||
|
return
|
||||||
|
bad = 0
|
||||||
|
rms_values = []
|
||||||
|
rates = set()
|
||||||
|
for payload, h in blocks:
|
||||||
|
receiver, rate, fmt, codec, crc, length, kind, chans = h[:8]
|
||||||
|
rates.add(rate)
|
||||||
|
# IQ length counts real values across I/Q; audio length is samples
|
||||||
|
# per channel, so its payload additionally includes every channel.
|
||||||
|
payload_samples = length if kind == 0 else length * chans
|
||||||
|
expected = HDR.size + payload_samples * SAMPLE_BYTES.get(fmt, 0)
|
||||||
|
if codec != 0 or crc != 0 or chans not in (1, 2) or expected != len(payload):
|
||||||
|
bad += 1
|
||||||
|
if requested_length is not None and length != requested_length:
|
||||||
|
bad += 1
|
||||||
|
rms, peak, finite = self.sample_stats(payload, fmt, length)
|
||||||
|
if not finite:
|
||||||
|
bad += 1
|
||||||
|
rms_values.append(rms)
|
||||||
|
self.ok(label + " headers", bad == 0,
|
||||||
|
f"blocks={len(blocks)}, bad={bad}, rates={sorted(rates)}")
|
||||||
|
rms = max(rms_values) if rms_values else 0.0
|
||||||
|
if rms > 1e-7:
|
||||||
|
self.ok(label + " signal", True, f"max RMS={rms:.6g}")
|
||||||
|
else:
|
||||||
|
self.warn(label + " signal is silent", "check mute/squelch and tuned signal")
|
||||||
|
|
||||||
|
def stream_checks(self, receivers):
|
||||||
|
print("Streams: IQ, demod audio and line-out")
|
||||||
|
self.ws.send_text("iq_samplerate:48000;")
|
||||||
|
self.wait_text("iq_samplerate")
|
||||||
|
self.ws.send_text("audio_samplerate:12000;")
|
||||||
|
self.wait_text("audio_samplerate")
|
||||||
|
self.ws.send_text("audio_stream_channels:2;")
|
||||||
|
self.ws.send_text("audio_stream_sample_type:float32;")
|
||||||
|
self.ws.send_text("audio_stream_samples:512;")
|
||||||
|
self.pump(0.2)
|
||||||
|
for rx in receivers:
|
||||||
|
self.stream(rx, "iq_start", "iq_stop", 0)
|
||||||
|
self.stream(rx, "audio_start", "audio_stop", 1, 512)
|
||||||
|
self.stream(rx, "line_out_start", "line_out_stop", 4, 512)
|
||||||
|
|
||||||
|
def format_matrix(self, receivers):
|
||||||
|
"""Exercise every negotiated audio representation on one live RX."""
|
||||||
|
rx = receivers[1] if len(receivers) > 1 else receivers[0]
|
||||||
|
old_seconds = self.seconds
|
||||||
|
self.seconds = 0.7
|
||||||
|
print(f"Audio format/rate matrix on rx={rx}")
|
||||||
|
try:
|
||||||
|
self.ws.send_text("audio_samplerate:12000;")
|
||||||
|
self.wait_text("audio_samplerate")
|
||||||
|
self.ws.send_text("audio_stream_samples:128;")
|
||||||
|
for channels in (1, 2):
|
||||||
|
self.ws.send_text(f"audio_stream_channels:{channels};")
|
||||||
|
for sample_type in ("int16", "int24", "int32", "float32"):
|
||||||
|
self.ws.send_text(f"audio_stream_sample_type:{sample_type};")
|
||||||
|
self.pump(0.05)
|
||||||
|
print(f" config channels={channels}, type={sample_type}")
|
||||||
|
self.stream(rx, "audio_start", "audio_stop", 1, 128)
|
||||||
|
self.stream(rx, "line_out_start", "line_out_stop", 4, 128)
|
||||||
|
self.ws.send_text("audio_stream_channels:2;")
|
||||||
|
self.ws.send_text("audio_stream_sample_type:float32;")
|
||||||
|
for rate in (8000, 12000, 24000, 48000):
|
||||||
|
self.ws.send_text(f"audio_samplerate:{rate};")
|
||||||
|
self.wait_text("audio_samplerate")
|
||||||
|
self.ws.send_text("audio_stream_samples:128;")
|
||||||
|
print(f" config rate={rate}")
|
||||||
|
self.stream(rx, "audio_start", "audio_stop", 1, 128)
|
||||||
|
finally:
|
||||||
|
self.seconds = old_seconds
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--host", default="127.0.0.1")
|
||||||
|
ap.add_argument("--port", type=int, default=40001)
|
||||||
|
ap.add_argument("--seconds", type=float, default=2.0,
|
||||||
|
help="capture time for each stream and receiver")
|
||||||
|
ap.add_argument("--commands-only", action="store_true",
|
||||||
|
help="skip IQ/audio capture")
|
||||||
|
ap.add_argument("--streams-only", action="store_true",
|
||||||
|
help="skip command matrix and capture only IQ/audio")
|
||||||
|
ap.add_argument("--format-matrix", action="store_true",
|
||||||
|
help="test every audio sample type/channel count and rate")
|
||||||
|
ap.add_argument("--visible", action="store_true",
|
||||||
|
help="visibly change RX parameters and restore every value")
|
||||||
|
ap.add_argument("--dwell", type=float, default=1.5,
|
||||||
|
help="seconds to leave each visible test value active")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
print(f"Connecting to ws://{args.host}:{args.port} (RX-only safety mode)")
|
||||||
|
ws = WS(args.host, args.port)
|
||||||
|
test = LiveTest(ws, args.seconds)
|
||||||
|
try:
|
||||||
|
test.init()
|
||||||
|
test.initial_checks()
|
||||||
|
protocol = test.initial_by_name("protocol")
|
||||||
|
test.ok("READY/init", bool(protocol), str(protocol))
|
||||||
|
trx = test.initial_by_name("trx")
|
||||||
|
if trx and len(trx[-1]) >= 2 and trx[-1][1].lower() == "true":
|
||||||
|
raise RuntimeError("radio is already transmitting; aborting RX-only test")
|
||||||
|
|
||||||
|
channels = {}
|
||||||
|
for row in test.initial_by_name("vfo"):
|
||||||
|
if len(row) >= 3:
|
||||||
|
channels.setdefault(int(row[0]), set()).add(int(row[1]))
|
||||||
|
channels = {rx: sorted(value) for rx, value in channels.items()}
|
||||||
|
receivers = sorted({int(row[0]) for row in test.initial_by_name("dds") if row})
|
||||||
|
print("Discovered receivers/channels:", receivers, channels)
|
||||||
|
test.ok("live receivers found", bool(receivers), str(receivers))
|
||||||
|
test.ok("three slices/channels visible", sum(map(len, channels.values())) >= 3,
|
||||||
|
str(channels))
|
||||||
|
|
||||||
|
if args.format_matrix:
|
||||||
|
test.format_matrix(receivers)
|
||||||
|
elif args.visible:
|
||||||
|
test.visible_checks(receivers, channels, args.dwell)
|
||||||
|
elif not args.streams_only:
|
||||||
|
test.command_checks(receivers, channels)
|
||||||
|
if not args.commands_only and not args.visible and not args.format_matrix:
|
||||||
|
test.stream_checks(receivers)
|
||||||
|
print("Skipped by RF-safety policy: START, STOP, TRX/TUNE setters, TX audio, "
|
||||||
|
"CW text/keying, SPOT_CLEAR and SET_IN_FOCUS")
|
||||||
|
finally:
|
||||||
|
# Idempotent receive-side cleanup only. Never touch TRX/TUNE.
|
||||||
|
for rx in range(8):
|
||||||
|
for name in ("iq_stop", "audio_stop", "line_out_stop"):
|
||||||
|
try:
|
||||||
|
ws.send_text(f"{name}:{rx};")
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
ws.send_text("rx_sensors_enable:false;")
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
ws.close()
|
||||||
|
|
||||||
|
print(f"\nResult: {test.passed + test.failed} checks, "
|
||||||
|
f"failed={test.failed}, warnings={test.warned}")
|
||||||
|
return 1 if test.failed else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Executable
+29
@@ -0,0 +1,29 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Стенд TCI: сборка и прогон. Запускать откуда угодно — скрипт сам перейдёт
|
||||||
|
# в свой каталог. Возвращает ненулевой код, если хоть одна проверка провалена.
|
||||||
|
#
|
||||||
|
# Флаги те же, что у демона (см. build-ewsdrd.sh), и по тем же причинам:
|
||||||
|
# -Mobjfpc — командный режим по умолчанию: включает вложенные комментарии.
|
||||||
|
# С -Mdelphi {$MODE Delphi} внутри шапки WebUtils.pas закрывает
|
||||||
|
# комментарий раньше времени, и компиляция падает на
|
||||||
|
# «illegal character». Юниты дальше сами ставят свой {$mode}.
|
||||||
|
# -dHEADLESS — PlatformUtils не тянет Forms (LCL): стенду виджетсет не нужен,
|
||||||
|
# а без этого сборка требует всю LCL.
|
||||||
|
# ★Каталог .ppu — свой, с именем НЕ «lib», и путь абсолютный. Относительное
|
||||||
|
# «lib/<cpu>-<os>» компилятор ищет и относительно -Fu, то есть находит units
|
||||||
|
# GUI-сборки в корне проекта — а там PlatformUtils собран с LCL, и стенд падает
|
||||||
|
# на линковке с «undefined reference to TC_$FORMS_$$_SCREEN».
|
||||||
|
# libwdsp нужна только части E (сквозной прогон через движок); без неё эта
|
||||||
|
# часть сообщает о пропуске, а остальные идут как обычно.
|
||||||
|
set -e
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
HERE=$(pwd)
|
||||||
|
CPU=$(fpc -iTP)
|
||||||
|
OS=$(fpc -iTO)
|
||||||
|
OUTUNITS="$HERE/units/${CPU}-${OS}"
|
||||||
|
OUT="$HERE/bin/${CPU}-${OS}"
|
||||||
|
mkdir -p "$OUTUNITS" "$OUT"
|
||||||
|
fpc -Mobjfpc -O2 -dHEADLESS \
|
||||||
|
-Fu../.. -FU"$OUTUNITS" -k-L/usr/local/lib \
|
||||||
|
-o"$OUT/tcitest" tcitest.pas
|
||||||
|
exec "$OUT/tcitest" "$@"
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user