mirror of
https://git.vladimir.cc/vladimir/ewsdr.git
synced 2026-08-25 18:43:51 +00:00
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:
+146
-4
@@ -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: пока шёл приём, ринг
|
||||
|
||||
Reference in New Issue
Block a user