feat(tci): этап 2 — бинарные потоки IQ/аудио, TX-аудио и запись линейного выхода

Реализованы все четыре потока §3.4 и рекордер:

  • RX_AUDIO_STREAM — тап ДО громкости и мьюта (OnDemodAudioReady): скиммеру
    и цифре нужен звук приёмника, а не то, что осталось после ручки;
  • LINEOUT_STREAM — тап ПОСЛЕ (OnAudioReady), то есть что слышно;
  • IQ_STREAM — тап сырого IQ в движке, ОДИН вызов на накопленный блок;
  • TX_AUDIO_STREAM + TX_CHRONO — TRX:0,true,tci берёт модуляцию из потока
    клиента (флаг TCIMicRequested впереди web в SetMOX), маркеры времени идут
    из тика по часам, аудио клиента разворачивается в 48 кГц моно в тот же
    ринг, что и web-микрофон;
  • LINE_OUT_RECORDER_* — кольцо int16 на приёмник, WAV пишет отдельный поток.

Тапы аудио в контроллере многоадресные (AddAudioTap): слушают, ничего не
забирая, в отличие от OnAudioConsume, которым владеет web. Блоки нарезает и
раскладывает по кольцам клиентов сам DSP-поток, в сокет пишет поток клиента —
та же дисциплина, что у команд. Порядок локов везде FSliceLock → FStreamLock.
У очереди команд и кольца блоков разная политика переполнения: команду терять
нельзя, блок потока — можно (теряется самый старый).

Пересчёт частоты многоступенчатый (TCIStreams). Одноступенчатый FIR на верхнем
пресете Pluto (5760 кГц, коэффициент 120) упирался в потолок отводов и давал
завал 1.3 дБ в полосе при подавлении зеркала 16 дБ — то есть поток IQ с
мусором. Теперь коэффициент раскладывается на множители, спецификацию фильтра
каждой ступени задаёт ИТОГОВАЯ полоса, а свёртка идёт со сложением
симметричных пар: −83 дБ на любом коэффициенте, ≈10% ядра на 5.76 МГц.

Согласование частот с железом: из пресетов Pluto 576 и 960 кГц на 384 не
делятся, поэтому отдаём наибольшую ЗАКОННУЮ частоту, делящую источник нацело
(576/960 → 192 кГц). Ответ на IQ_SAMPLERATE называет достижимое, а не просьбу
клиента, и переобъявляется без запроса при смене rate и устройства.

Приёмный буфер соединения 4 → 32 КБ: блок TX-аудио это 64 байта заголовка плюс
data[16384], а кадр крупнее буфера не собирается никогда.

Настройки TCI переехали из Advanced на вкладку CAT, справа от TCP CAT Server:
это такой же канал внешнего управления трансивером.

Стенд (scratchpad, tcitest.pas): 112 проверок, все зелёные — включая сквозной
прогон через живой WDSP (синтетический IQ → блоки RX-аудио и IQ у настоящего
WS-клиента, и обратно TX-аудио клиента → блоки TX-IQ).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 12:36:50 +03:00
co-authored by Claude Opus 5
parent 4165cbe9a5
commit de0830fd19
10 changed files with 2463 additions and 139 deletions
+146 -4
View File
@@ -47,7 +47,7 @@ unit RadioController;
interface
uses
Classes, SysUtils, Math,
Classes, SysUtils, Math, SyncObjs,
HPSDRProtocol, HPSDRNetwork, RadioBackend, PlutoBackend, IIOBindings,
WDSPEngine, AudioOutput, AudioInput, BeaconDecoder, BeaconFEC, DMRDecoder,
Settings, ChannelStore, FMRepeater, BoardUtils, DeviceStore, CWMorse, CWKeyer,
@@ -105,6 +105,19 @@ type
TAudioConsumeEvent = function(const Left, Right: array of Single;
Count: Integer): Boolean of object;
// Тап RX-аудио: «послушать, не забирая», в отличие от OnAudioConsume. Нужен
// потокам TCI (§3.4): их может быть несколько, и ни один не имеет права
// отбирать звук у локальной звуковухи или у web-клиента.
// rakDemod — ДО громкости и мьюта, без сайдтона (аудиопоток приёмника);
// rakLineOut — то, что реально уходит на выход (поток линейного выхода).
// PanId: 0 = главный тракт, 1.. = доп. пан. SliceId — слайс-источник
// (0 у главного тракта): по нему потребитель отличает канал A от канала B.
// Вызывается из DSP-потока: обработчик обязан быть быстрым и не ждать.
TRadioAudioKind = (rakDemod, rakLineOut);
TRadioAudioTapEvent = procedure(Kind: TRadioAudioKind; PanId, SliceId: Integer;
const Left, Right: array of Single;
Count: Integer) of object;
// Сырьё спектра/водопада наружу (фронтенды рисуют). Вызывается из DSP-потока.
TPixelDataEvent = procedure(const Pixels: array of Single; Count: Integer) of object;
@@ -230,7 +243,15 @@ type
FLocalAudio: Boolean; // локальный звук (динамик/мик ПК): GUI=True, демон=False
FOnSpectrumData: TPixelDataEvent; // сырьё спектра наружу (UI рисует FSpecView)
FOnWaterfallData: TPixelDataEvent; // сырьё водопада наружу (UI: децимация+рендер)
// Тапы RX-аудио (TCI-потоки). Список короткий и меняется редко, но пишет
// его поток контроллера, а читает DSP — отсюда лок: снять тап на лету
// (клиент ушёл) иначе означало бы вызов метода освобождённого объекта.
FAudioTaps: array of TRadioAudioTapEvent;
FAudioTapLock: TCriticalSection;
FAudioTapCount: LongInt; // быстрый гейт: без тапов не берём и лок
procedure Changed(Field: TRadioField);
procedure FireAudioTap(Kind: TRadioAudioKind; PanId, SliceId: Integer;
const Left, Right: array of Single; Count: Integer);
// --- Мультислайсы ---
function FindSliceIndex(Id: Integer): Integer; // -1 если нет
procedure OnSliceAudioReady(SliceId: Integer;
@@ -422,6 +443,8 @@ type
FHWPTTStartedTX: Boolean;
FWebClientActive: Boolean; // выставляет web-слой (connect/disconnect клиента)
FWebMicActive: Boolean; // True если текущий TX идёт через txmsWeb
FTCIMicRequested: Boolean; // TCI-клиент попросил TRX:…,tci
FTCIMicActive: Boolean; // True если текущий TX модулируется из TCI
FSendAudioToRadio: Boolean;
FAudioOutDevName: string;
FAudioInDevName: string;
@@ -1233,9 +1256,27 @@ type
property OnAfterTune: TThreadMethod read FOnAfterTune write FOnAfterTune;
property OnBeforeStop: TThreadMethod read FOnBeforeStop write FOnBeforeStop;
property OnAudioConsume: TAudioConsumeEvent read FOnAudioConsume write FOnAudioConsume;
// Тапы RX-аудио: слушают, ничего не забирая (см. TRadioAudioTapEvent).
// Снимать обязан тот, кто умирает раньше контроллера.
procedure AddAudioTap(T: TRadioAudioTapEvent);
procedure RemoveAudioTap(T: TRadioAudioTapEvent);
// Тап сырого RX-IQ (потоки IQ по TCI). Пас в движок: он же и владелец
// потока, из которого тап зовётся. nil — снять.
procedure SetIQTap(T: TIQTapEvent);
// Частота дискретизации источника IQ для приёмника: главный тракт — rate
// устройства, доп. пан — rate его DDC.
function IQTapRateHz(PanId: Integer): Integer;
// TX-аудио от TCI-клиента: 48 кГц моно в тот же ринг, что и web-микрофон.
procedure PushTCIAudio(const Samples: array of Double; N: Integer);
// Локальный звук ПК (динамик RX / микрофон TX). Демон ставит False: аудио
// только через web (OnAudioConsume), локальная звуковая карта не трогается.
property LocalAudioEnabled: Boolean read FLocalAudio write FLocalAudio;
// Клиент TCI просит брать модуляцию из своего аудиопотока (TRX:0,true,tci,
// §4.2). Ставит адаптер, читает SetMOX при выборе источника.
property TCIMicRequested: Boolean read FTCIMicRequested write FTCIMicRequested;
// Идёт ли текущая передача с модуляцией из TCI (адаптеру — гнать ли
// маркеры TX_CHRONO).
property TCIMicActive: Boolean read FTCIMicActive;
property OnSpectrumData: TPixelDataEvent read FOnSpectrumData write FOnSpectrumData;
property OnWaterfallData: TPixelDataEvent read FOnWaterfallData write FOnWaterfallData;
end;
@@ -1307,6 +1348,8 @@ begin
inherited Create;
FSettings := TSettingsManager.Create; // владелец настроек (GUI и демон)
FDeviceStore := TDeviceStore.Create; // общий список устройств (desktop+web)
FAudioTapLock := TCriticalSection.Create;
FAudioTapCount := 0;
// Дефолты (дублируют TMainForm.FormCreate; в GUI перезапишутся, в демоне нужны).
FVfoA := 14200000; FVfoB := 7100000; FActiveVfo := 0;
FMode := MODE_USB; FFilter := 5;
@@ -1471,6 +1514,8 @@ begin
if Assigned(FCWDec) then FreeAndNil(FCWDec);
FreeAndNil(FDeviceStore);
FreeAndNil(FSettings);
// Лок тапов — последним: до FreeEngines по нему ходит DSP-поток.
FreeAndNil(FAudioTapLock);
inherited Destroy;
end;
@@ -1489,6 +1534,75 @@ begin
FStateListeners[High(FStateListeners)] := L;
end;
procedure TRadioController.AddAudioTap(T: TRadioAudioTapEvent);
begin
if not Assigned(T) then Exit;
FAudioTapLock.Enter;
try
SetLength(FAudioTaps, Length(FAudioTaps) + 1);
FAudioTaps[High(FAudioTaps)] := T;
FAudioTapCount := Length(FAudioTaps);
finally
FAudioTapLock.Leave;
end;
end;
procedure TRadioController.RemoveAudioTap(T: TRadioAudioTapEvent);
var i, j: Integer;
begin
if not Assigned(T) then Exit;
FAudioTapLock.Enter;
try
for i := 0 to High(FAudioTaps) do
if (TMethod(FAudioTaps[i]).Code = TMethod(T).Code) and
(TMethod(FAudioTaps[i]).Data = TMethod(T).Data) then
begin
for j := i to High(FAudioTaps) - 1 do FAudioTaps[j] := FAudioTaps[j + 1];
SetLength(FAudioTaps, Length(FAudioTaps) - 1);
FAudioTapCount := Length(FAudioTaps);
Exit;
end;
finally
FAudioTapLock.Leave;
end;
end;
procedure TRadioController.FireAudioTap(Kind: TRadioAudioKind;
PanId, SliceId: Integer; const Left, Right: array of Single; Count: Integer);
// DSP-поток. Без единого тапа (обычный случай) — одна проверка целого и выход:
// лок в звуковом маршруте на каждый блок иначе стоил бы дороже самой работы.
var i: Integer;
begin
if FAudioTapCount = 0 then Exit;
FAudioTapLock.Enter;
try
for i := 0 to High(FAudioTaps) do
if Assigned(FAudioTaps[i]) then
FAudioTaps[i](Kind, PanId, SliceId, Left, Right, Count);
finally
FAudioTapLock.Leave;
end;
end;
procedure TRadioController.SetIQTap(T: TIQTapEvent);
begin
if Assigned(FDSPEngine) then FDSPEngine.SetIQTap(T);
end;
function TRadioController.IQTapRateHz(PanId: Integer): Integer;
begin
if PanId <= 0 then Result := FSampleRate
else Result := PanDDCRateKHz(PanId) * 1000;
end;
procedure TRadioController.PushTCIAudio(const Samples: array of Double; N: Integer);
// Поток клиента TCI. Ринг микрофона в движке лок-фри и рассчитан ровно на это
// (тем же путём ходит web-микрофон); переполнение — дроп, как у железа.
begin
if FWDSPReady and Assigned(FDSPEngine) then
FDSPEngine.PushTXMicSamplesD(Samples, N);
end;
procedure TRadioController.RemoveStateListener(L: TRadioStateEvent);
var i, j: Integer;
begin
@@ -1831,6 +1945,10 @@ begin
begin
if FSendAudioToRadio then
FNetwork.SendSpeakerAudio(FCWSTBufL, FCWSTBufR, Count);
// Линейный выход (TCI LINEOUT_STREAM) — здесь: это ровно то, что слышно,
// вместе с сайдтоном и после громкости. Тап слушает, ничего не забирая,
// поэтому стоит ДО перехвата web-клиентом.
FireAudioTap(rakLineOut, 0, 0, FCWSTBufL, FCWSTBufR, Count);
if Assigned(FOnAudioConsume) and FOnAudioConsume(FCWSTBufL, FCWSTBufR, Count) then
Exit;
if FLocalAudio then
@@ -1840,6 +1958,7 @@ begin
if FSendAudioToRadio then
FNetwork.SendSpeakerAudio(Left, Right, Count);
FireAudioTap(rakLineOut, 0, 0, Left, Right, Count);
if Assigned(FOnAudioConsume) and FOnAudioConsume(Left, Right, Count) then
Exit;
// Без локального звука (демон) на этом маршрут заканчивается: если web-клиент
@@ -1852,6 +1971,10 @@ procedure TRadioController.OnDemodAudioReady(const Left, Right: array of Single;
Count: Integer);
// DSP thread, pre-volume/pre-mute. FeedAudio only copies into a bounded ring.
begin
// Аудиопоток приёмника для TCI (§3.4) — именно отсюда: скиммеру и цифре
// нужен звук приёмника, а не то, что осталось после ручки громкости и
// мьюта. Линейный выход у TCI отдельным потоком (см. OnAudioReady).
FireAudioTap(rakDemod, 0, 0, Left, Right, Count);
if FMode = MODE_DMR then
begin
if not Assigned(FDMRDec) then Exit;
@@ -1905,6 +2028,7 @@ begin
end;
if FSendAudioToRadio and Assigned(FNetwork) then
FNetwork.SendSpeakerAudio(Left, Right, OutPos);
FireAudioTap(rakLineOut, 0, 0, Left, Right, OutPos);
if Assigned(FOnAudioConsume) and FOnAudioConsume(Left, Right, OutPos) then Exit;
if FLocalAudio and Assigned(FAudioOut) then FAudioOut.Write(Left, Right, OutPos);
end;
@@ -1927,9 +2051,12 @@ procedure TRadioController.OnSliceAudioReady(SliceId: Integer;
const Left, Right: array of Single; Count: Integer);
var idx: Integer;
begin
if not FLocalAudio then Exit;
idx := FindSliceIndex(SliceId);
if idx < 0 then Exit;
// Линейный выход слайса для TCI — до гейта локального звука: в демоне
// звуковухи нет вовсе, а поток клиенту идти обязан.
FireAudioTap(rakLineOut, FSlices[idx].PanId, SliceId, Left, Right, Count);
if not FLocalAudio then Exit;
if FSlices[idx].Mode in [MODE_DMR, MODE_FMRAW] then Exit;
if SliceMutedByTx(idx) then Exit;
if FSlices[idx].Audio <> nil then
@@ -1952,6 +2079,7 @@ var idx: Integer;
begin
idx := FindSliceIndex(SliceId);
if idx < 0 then Exit;
FireAudioTap(rakDemod, FSlices[idx].PanId, SliceId, Left, Right, Count);
if (FSlices[idx].Mode = MODE_DMR) and Assigned(FSlices[idx].DMR) then
FSlices[idx].DMR.FeedAudio(Left, Right, Count);
if (FSlices[idx].Mode = MODE_FMRAW) and FLocalAudio and
@@ -2594,7 +2722,11 @@ begin
FDSPEngine.SetTXMode(ActiveTXMode);
if (Mode = MODE_FMRAW) or (OldMode = MODE_FMRAW) then
begin
if Mode = MODE_FMRAW then FWebMicActive := False;
if Mode = MODE_FMRAW then
begin
FWebMicActive := False;
FTCIMicActive := False;
end;
FDSPEngine.SetTXMicSource(DefaultMicSource);
if Assigned(ActiveMicInput) and ActiveMicInput.IsOpen then
ActiveMicInput.Flush;
@@ -4602,6 +4734,7 @@ begin
if FTransmitting and (ActiveTXMode = MODE_FMRAW) then
begin
FWebMicActive := False;
FTCIMicActive := False;
FDSPEngine.SetTXMicSource(DefaultMicSource);
if Assigned(ActiveMicInput) and ActiveMicInput.IsOpen then
ActiveMicInput.Flush;
@@ -6131,6 +6264,14 @@ begin
begin
if ActiveTXMode = MODE_FMRAW then
FDSPEngine.SetTXMicSource(DefaultMicSource)
// TCI впереди web: клиент попросил модуляцию из своего потока ЯВНО
// (TRX:0,true,tci), а web-микрофон включается самим фактом подключения
// браузера. Явная просьба сильнее умолчания.
else if FTCIMicRequested then
begin
FTCIMicActive := True;
FDSPEngine.SetTXMicSource(txmsWeb); // тот же ринг внешней подачи
end
else if FWebClientActive then
begin
FWebMicActive := True;
@@ -6141,9 +6282,10 @@ begin
else
FDSPEngine.SetTXMicSource(DefaultMicSource);
end
else if FWebMicActive then
else if FWebMicActive or FTCIMicActive then
begin
FWebMicActive := False;
FTCIMicActive := False;
FDSPEngine.SetTXMicSource(DefaultMicSource);
end;
// Сброс бэклога sound-card микрофона на RX→TX: пока шёл приём, ринг