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 interface
uses uses
Classes, SysUtils, Math, Classes, SysUtils, Math, SyncObjs,
HPSDRProtocol, HPSDRNetwork, RadioBackend, PlutoBackend, IIOBindings, HPSDRProtocol, HPSDRNetwork, RadioBackend, PlutoBackend, IIOBindings,
WDSPEngine, AudioOutput, AudioInput, BeaconDecoder, BeaconFEC, DMRDecoder, WDSPEngine, AudioOutput, AudioInput, BeaconDecoder, BeaconFEC, DMRDecoder,
Settings, ChannelStore, FMRepeater, BoardUtils, DeviceStore, CWMorse, CWKeyer, Settings, ChannelStore, FMRepeater, BoardUtils, DeviceStore, CWMorse, CWKeyer,
@@ -105,6 +105,19 @@ type
TAudioConsumeEvent = function(const Left, Right: array of Single; TAudioConsumeEvent = function(const Left, Right: array of Single;
Count: Integer): Boolean of object; 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-потока. // Сырьё спектра/водопада наружу (фронтенды рисуют). Вызывается из DSP-потока.
TPixelDataEvent = procedure(const Pixels: array of Single; Count: Integer) of object; TPixelDataEvent = procedure(const Pixels: array of Single; Count: Integer) of object;
@@ -230,7 +243,15 @@ type
FLocalAudio: Boolean; // локальный звук (динамик/мик ПК): GUI=True, демон=False FLocalAudio: Boolean; // локальный звук (динамик/мик ПК): GUI=True, демон=False
FOnSpectrumData: TPixelDataEvent; // сырьё спектра наружу (UI рисует FSpecView) FOnSpectrumData: TPixelDataEvent; // сырьё спектра наружу (UI рисует FSpecView)
FOnWaterfallData: TPixelDataEvent; // сырьё водопада наружу (UI: децимация+рендер) FOnWaterfallData: TPixelDataEvent; // сырьё водопада наружу (UI: децимация+рендер)
// Тапы RX-аудио (TCI-потоки). Список короткий и меняется редко, но пишет
// его поток контроллера, а читает DSP — отсюда лок: снять тап на лету
// (клиент ушёл) иначе означало бы вызов метода освобождённого объекта.
FAudioTaps: array of TRadioAudioTapEvent;
FAudioTapLock: TCriticalSection;
FAudioTapCount: LongInt; // быстрый гейт: без тапов не берём и лок
procedure Changed(Field: TRadioField); 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 если нет function FindSliceIndex(Id: Integer): Integer; // -1 если нет
procedure OnSliceAudioReady(SliceId: Integer; procedure OnSliceAudioReady(SliceId: Integer;
@@ -422,6 +443,8 @@ type
FHWPTTStartedTX: Boolean; FHWPTTStartedTX: Boolean;
FWebClientActive: Boolean; // выставляет web-слой (connect/disconnect клиента) FWebClientActive: Boolean; // выставляет web-слой (connect/disconnect клиента)
FWebMicActive: Boolean; // True если текущий TX идёт через txmsWeb FWebMicActive: Boolean; // True если текущий TX идёт через txmsWeb
FTCIMicRequested: Boolean; // TCI-клиент попросил TRX:…,tci
FTCIMicActive: Boolean; // True если текущий TX модулируется из TCI
FSendAudioToRadio: Boolean; FSendAudioToRadio: Boolean;
FAudioOutDevName: string; FAudioOutDevName: string;
FAudioInDevName: string; FAudioInDevName: string;
@@ -1233,9 +1256,27 @@ type
property OnAfterTune: TThreadMethod read FOnAfterTune write FOnAfterTune; property OnAfterTune: TThreadMethod read FOnAfterTune write FOnAfterTune;
property OnBeforeStop: TThreadMethod read FOnBeforeStop write FOnBeforeStop; property OnBeforeStop: TThreadMethod read FOnBeforeStop write FOnBeforeStop;
property OnAudioConsume: TAudioConsumeEvent read FOnAudioConsume write FOnAudioConsume; 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: аудио // Локальный звук ПК (динамик RX / микрофон TX). Демон ставит False: аудио
// только через web (OnAudioConsume), локальная звуковая карта не трогается. // только через web (OnAudioConsume), локальная звуковая карта не трогается.
property LocalAudioEnabled: Boolean read FLocalAudio write FLocalAudio; 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 OnSpectrumData: TPixelDataEvent read FOnSpectrumData write FOnSpectrumData;
property OnWaterfallData: TPixelDataEvent read FOnWaterfallData write FOnWaterfallData; property OnWaterfallData: TPixelDataEvent read FOnWaterfallData write FOnWaterfallData;
end; end;
@@ -1307,6 +1348,8 @@ begin
inherited Create; inherited Create;
FSettings := TSettingsManager.Create; // владелец настроек (GUI и демон) FSettings := TSettingsManager.Create; // владелец настроек (GUI и демон)
FDeviceStore := TDeviceStore.Create; // общий список устройств (desktop+web) FDeviceStore := TDeviceStore.Create; // общий список устройств (desktop+web)
FAudioTapLock := TCriticalSection.Create;
FAudioTapCount := 0;
// Дефолты (дублируют TMainForm.FormCreate; в GUI перезапишутся, в демоне нужны). // Дефолты (дублируют TMainForm.FormCreate; в GUI перезапишутся, в демоне нужны).
FVfoA := 14200000; FVfoB := 7100000; FActiveVfo := 0; FVfoA := 14200000; FVfoB := 7100000; FActiveVfo := 0;
FMode := MODE_USB; FFilter := 5; FMode := MODE_USB; FFilter := 5;
@@ -1471,6 +1514,8 @@ begin
if Assigned(FCWDec) then FreeAndNil(FCWDec); if Assigned(FCWDec) then FreeAndNil(FCWDec);
FreeAndNil(FDeviceStore); FreeAndNil(FDeviceStore);
FreeAndNil(FSettings); FreeAndNil(FSettings);
// Лок тапов — последним: до FreeEngines по нему ходит DSP-поток.
FreeAndNil(FAudioTapLock);
inherited Destroy; inherited Destroy;
end; end;
@@ -1489,6 +1534,75 @@ begin
FStateListeners[High(FStateListeners)] := L; FStateListeners[High(FStateListeners)] := L;
end; 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); procedure TRadioController.RemoveStateListener(L: TRadioStateEvent);
var i, j: Integer; var i, j: Integer;
begin begin
@@ -1831,6 +1945,10 @@ begin
begin begin
if FSendAudioToRadio then if FSendAudioToRadio then
FNetwork.SendSpeakerAudio(FCWSTBufL, FCWSTBufR, Count); 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 if Assigned(FOnAudioConsume) and FOnAudioConsume(FCWSTBufL, FCWSTBufR, Count) then
Exit; Exit;
if FLocalAudio then if FLocalAudio then
@@ -1840,6 +1958,7 @@ begin
if FSendAudioToRadio then if FSendAudioToRadio then
FNetwork.SendSpeakerAudio(Left, Right, Count); FNetwork.SendSpeakerAudio(Left, Right, Count);
FireAudioTap(rakLineOut, 0, 0, Left, Right, Count);
if Assigned(FOnAudioConsume) and FOnAudioConsume(Left, Right, Count) then if Assigned(FOnAudioConsume) and FOnAudioConsume(Left, Right, Count) then
Exit; Exit;
// Без локального звука (демон) на этом маршрут заканчивается: если web-клиент // Без локального звука (демон) на этом маршрут заканчивается: если web-клиент
@@ -1852,6 +1971,10 @@ procedure TRadioController.OnDemodAudioReady(const Left, Right: array of Single;
Count: Integer); Count: Integer);
// DSP thread, pre-volume/pre-mute. FeedAudio only copies into a bounded ring. // DSP thread, pre-volume/pre-mute. FeedAudio only copies into a bounded ring.
begin begin
// Аудиопоток приёмника для TCI (§3.4) — именно отсюда: скиммеру и цифре
// нужен звук приёмника, а не то, что осталось после ручки громкости и
// мьюта. Линейный выход у TCI отдельным потоком (см. OnAudioReady).
FireAudioTap(rakDemod, 0, 0, Left, Right, Count);
if FMode = MODE_DMR then if FMode = MODE_DMR then
begin begin
if not Assigned(FDMRDec) then Exit; if not Assigned(FDMRDec) then Exit;
@@ -1905,6 +2028,7 @@ begin
end; end;
if FSendAudioToRadio and Assigned(FNetwork) then if FSendAudioToRadio and Assigned(FNetwork) then
FNetwork.SendSpeakerAudio(Left, Right, OutPos); FNetwork.SendSpeakerAudio(Left, Right, OutPos);
FireAudioTap(rakLineOut, 0, 0, Left, Right, OutPos);
if Assigned(FOnAudioConsume) and FOnAudioConsume(Left, Right, OutPos) then Exit; if Assigned(FOnAudioConsume) and FOnAudioConsume(Left, Right, OutPos) then Exit;
if FLocalAudio and Assigned(FAudioOut) then FAudioOut.Write(Left, Right, OutPos); if FLocalAudio and Assigned(FAudioOut) then FAudioOut.Write(Left, Right, OutPos);
end; end;
@@ -1927,9 +2051,12 @@ procedure TRadioController.OnSliceAudioReady(SliceId: Integer;
const Left, Right: array of Single; Count: Integer); const Left, Right: array of Single; Count: Integer);
var idx: Integer; var idx: Integer;
begin begin
if not FLocalAudio then Exit;
idx := FindSliceIndex(SliceId); idx := FindSliceIndex(SliceId);
if idx < 0 then Exit; 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 FSlices[idx].Mode in [MODE_DMR, MODE_FMRAW] then Exit;
if SliceMutedByTx(idx) then Exit; if SliceMutedByTx(idx) then Exit;
if FSlices[idx].Audio <> nil then if FSlices[idx].Audio <> nil then
@@ -1952,6 +2079,7 @@ var idx: Integer;
begin begin
idx := FindSliceIndex(SliceId); idx := FindSliceIndex(SliceId);
if idx < 0 then Exit; 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 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) and FLocalAudio and
@@ -2594,7 +2722,11 @@ begin
FDSPEngine.SetTXMode(ActiveTXMode); FDSPEngine.SetTXMode(ActiveTXMode);
if (Mode = MODE_FMRAW) or (OldMode = MODE_FMRAW) then if (Mode = MODE_FMRAW) or (OldMode = MODE_FMRAW) then
begin begin
if Mode = MODE_FMRAW then FWebMicActive := False; if Mode = MODE_FMRAW then
begin
FWebMicActive := False;
FTCIMicActive := False;
end;
FDSPEngine.SetTXMicSource(DefaultMicSource); FDSPEngine.SetTXMicSource(DefaultMicSource);
if Assigned(ActiveMicInput) and ActiveMicInput.IsOpen then if Assigned(ActiveMicInput) and ActiveMicInput.IsOpen then
ActiveMicInput.Flush; ActiveMicInput.Flush;
@@ -4602,6 +4734,7 @@ begin
if FTransmitting and (ActiveTXMode = MODE_FMRAW) then if FTransmitting and (ActiveTXMode = MODE_FMRAW) then
begin begin
FWebMicActive := False; FWebMicActive := False;
FTCIMicActive := False;
FDSPEngine.SetTXMicSource(DefaultMicSource); FDSPEngine.SetTXMicSource(DefaultMicSource);
if Assigned(ActiveMicInput) and ActiveMicInput.IsOpen then if Assigned(ActiveMicInput) and ActiveMicInput.IsOpen then
ActiveMicInput.Flush; ActiveMicInput.Flush;
@@ -6131,6 +6264,14 @@ begin
begin begin
if ActiveTXMode = MODE_FMRAW then if ActiveTXMode = MODE_FMRAW then
FDSPEngine.SetTXMicSource(DefaultMicSource) 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 else if FWebClientActive then
begin begin
FWebMicActive := True; FWebMicActive := True;
@@ -6141,9 +6282,10 @@ begin
else else
FDSPEngine.SetTXMicSource(DefaultMicSource); FDSPEngine.SetTXMicSource(DefaultMicSource);
end end
else if FWebMicActive then else if FWebMicActive or FTCIMicActive then
begin begin
FWebMicActive := False; FWebMicActive := False;
FTCIMicActive := False;
FDSPEngine.SetTXMicSource(DefaultMicSource); FDSPEngine.SetTXMicSource(DefaultMicSource);
end; end;
// Сброс бэклога sound-card микрофона на RX→TX: пока шёл приём, ринг // Сброс бэклога sound-card микрофона на RX→TX: пока шёл приём, ринг
+61 -53
View File
@@ -3457,55 +3457,8 @@ begin
GRP_PAD + LBL_W + 12, Y + ROW_H - 8, 320); GRP_PAD + LBL_W + 12, Y + ROW_H - 8, 320);
Lbl.Font.Size := 8; Lbl.Font.Size := 8;
// ── TCI Server ──────────────────────────────────────────────────────────── // TCI Server живёт на вкладке CAT (см. BuildCATTab): это такой же канал
// Протокол Expert Electronics поверх WebSocket: логгеры, скиммеры, цифра. // внешнего управления трансивером, что и CAT, и оператор ищет его там.
Grp := MakeGroupPanel(FPageAdvanced, 'TCI Server', MARGIN, 564, SETTINGS_CARD_W, 190);
Y := R1;
Chk := TFlatCheckBox.Create(Self);
Chk.Parent := Grp;
Chk.Caption := 'Enabled';
Chk.SetBounds(DpiScale(GRP_PAD), DpiScale(Y), DpiScale(260), DpiScale(22));
Chk.Font.Size := 9;
Chk.Font.Color := CLR_TEXT;
Chk.Checked := False;
Chk.OnChange := OnTCIAnyChange;
FChkTCIEnabled := Chk;
Y := Y + ROW_H;
MakeLbl(Grp, 'Port:', GRP_PAD, Y + 4, LBL_W);
Spin := TFlatSpinEdit.Create(Self);
Spin.Parent := Grp;
Spin.SetBounds(DpiScale(GRP_PAD + LBL_W + 12), DpiScale(Y), DpiScale(90), DpiScale(BTN_H));
Spin.Color := CLR_INPUT;
Spin.Font.Color := CLR_INPUT_TEXT;
Spin.Font.Size := 9;
Spin.MinValue := 1;
Spin.MaxValue := 65535;
Spin.Value := 40001;
// Применяем по уходу фокуса, а не на каждое нажатие: набирая «40001», через
// OnChange мы бы подряд перезапустили сервер на портах 4, 40, 400, 4000.
Spin.OnExit := OnTCIAnyChange;
FEdTCIPort := Spin;
Y := Y + ROW_H;
MakeLbl(Grp, 'Interface:', GRP_PAD, Y + 4, LBL_W);
Ed := TFlatEdit.Create(Self);
Ed.Parent := Grp;
Ed.SetBounds(DpiScale(GRP_PAD + LBL_W + 12), DpiScale(Y), DpiScale(ED_W), DpiScale(BTN_H));
Ed.Color := CLR_INPUT;
Ed.Font.Color := CLR_INPUT_TEXT;
Ed.Font.Size := 9;
Ed.Text := '127.0.0.1';
// Тем более адрес: промежуточное «127.0.0.» — не адрес, и раньше это молча
// означало «слушать на всех интерфейсах». Применяем по уходу фокуса.
Ed.OnExit := OnTCIAnyChange;
FEdTCIBind := Ed;
// Авторизации в протоколе нет: открытый наружу порт = полный доступ к трансиверу.
Lbl := MakeLbl(Grp, 'No authentication in TCI — keep 127.0.0.1 unless the network is trusted',
GRP_PAD, Y + ROW_H, 460);
Lbl.Font.Size := 8;
end; end;
procedure TSettingsForm.BuildDXClusterTab; procedure TSettingsForm.BuildDXClusterTab;
@@ -4639,6 +4592,8 @@ const
EDT_W = 160; EDT_W = 160;
R1 = 42; R1 = 42;
STEP = 32; STEP = 32;
// Высота сетевых карточек (TCP CAT и TCI) — общая: стоят они в одной строке.
NET_GRP_H = 176;
var var
i, col, row, gx, gy, j: Integer; i, col, row, gx, gy, j: Integer;
Grp: TPanel; Grp: TPanel;
@@ -4646,9 +4601,10 @@ var
Ed: TFlatEdit; Ed: TFlatEdit;
Cmb: TFlatComboBox; Cmb: TFlatComboBox;
Spin: TFlatSpinEdit; Spin: TFlatSpinEdit;
Lbl: TLabel;
begin begin
MakePageHeader(FPageCAT, 'CAT', MakePageHeader(FPageCAT, 'CAT',
'Computer Aided Transceiver — serial port and TCP server settings.'); 'External control: serial CAT ports, TCP CAT server and TCI server.');
for i := 0 to 3 do for i := 0 to 3 do
begin begin
@@ -4727,10 +4683,12 @@ begin
FCATSerialAndromeda[i] := Chk; FCATSerialAndromeda[i] := Chk;
end; end;
// TCP server group below the two rows of serial port panels // Два сетевых канала управления — рядом, одной строкой под последовательными
// портами: TCP CAT слева, TCI справа. Высота у обоих одна, иначе нижняя
// кромка страницы получается рваной.
gy := 86 + 2 * (GRP_H + GRP_GAP); gy := 86 + 2 * (GRP_H + GRP_GAP);
Grp := MakeGroupPanel(FPageCAT, 'TCP CAT Server', MARGIN, gy, GRP_W * 2 + GRP_GAP, 116); Grp := MakeGroupPanel(FPageCAT, 'TCP CAT Server', MARGIN, gy, GRP_W, NET_GRP_H);
Grp.Tag := TAG_RESPONSIVE_CARD; Grp.Tag := TAG_RESPONSIVE_HALF_LEFT;
FCATTcpEn := TFlatCheckBox.Create(Self); FCATTcpEn := TFlatCheckBox.Create(Self);
FCATTcpEn.Parent := Grp; FCATTcpEn.Parent := Grp;
@@ -4750,6 +4708,56 @@ begin
Spin.Value := 19090; Spin.Value := 19090;
Spin.OnChange := OnCATTcpChange; Spin.OnChange := OnCATTcpChange;
FCATTcpPort := Spin; FCATTcpPort := Spin;
// ── TCI Server ────────────────────────────────────────────────────────────
// Протокол Expert Electronics поверх WebSocket: логгеры, скиммеры, цифра.
Grp := MakeGroupPanel(FPageCAT, 'TCI Server',
MARGIN + GRP_W + GRP_GAP, gy, GRP_W, NET_GRP_H);
Grp.Tag := TAG_RESPONSIVE_HALF_RIGHT;
Chk := TFlatCheckBox.Create(Self);
Chk.Parent := Grp;
Chk.Caption := 'Enable TCI server (default port 40001)';
Chk.SetBounds(DpiScale(PAD), DpiScale(R1), DpiScale(GRP_W - PAD * 2), DpiScale(22));
Chk.Font.Color := CLR_TEXT;
Chk.Font.Size := 9;
Chk.Checked := False;
Chk.OnChange := OnTCIAnyChange;
FChkTCIEnabled := Chk;
MakeLbl(Grp, 'Port:', PAD, R1 + STEP + 6, LW);
Spin := TFlatSpinEdit.Create(Self);
Spin.Parent := Grp;
Spin.SetBounds(DpiScale(CX), DpiScale(R1 + STEP), DpiScale(100), DpiScale(BTN_H + 2));
Spin.Color := CLR_INPUT; Spin.Font.Color := CLR_INPUT_TEXT;
Spin.Font.Size := 9;
Spin.MinValue := 1; Spin.MaxValue := 65535;
Spin.Value := 40001;
// Применяем по уходу фокуса, а не на каждое нажатие: набирая «40001», через
// OnChange мы бы подряд перезапустили сервер на портах 4, 40, 400, 4000.
Spin.OnExit := OnTCIAnyChange;
FEdTCIPort := Spin;
MakeLbl(Grp, 'Interface:', PAD, R1 + 2 * STEP + 6, LW);
Ed := TFlatEdit.Create(Self);
Ed.Parent := Grp;
Ed.SetBounds(DpiScale(CX), DpiScale(R1 + 2 * STEP), DpiScale(EDT_W), DpiScale(BTN_H));
Ed.Color := CLR_INPUT; Ed.Font.Color := CLR_INPUT_TEXT;
Ed.Font.Size := 9;
Ed.Text := '127.0.0.1';
// Тем более адрес: промежуточное «127.0.0.» — не адрес, и раньше это молча
// означало «слушать на всех интерфейсах». Применяем по уходу фокуса.
Ed.OnExit := OnTCIAnyChange;
FEdTCIBind := Ed;
// Авторизации в протоколе нет: открытый наружу порт = полный доступ к
// трансиверу. В половинную карточку строка не влезает — режем на две.
Lbl := MakeLbl(Grp, 'No authentication in TCI: keep 127.0.0.1',
PAD, R1 + 3 * STEP + 2, GRP_W - PAD * 2);
Lbl.Font.Size := 8;
Lbl := MakeLbl(Grp, 'unless the network is trusted.',
PAD, R1 + 3 * STEP + 16, GRP_W - PAD * 2);
Lbl.Font.Size := 8;
end; end;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+779 -22
View File
@@ -27,8 +27,11 @@ unit TCIAdapter;
так синхронизация между несколькими клиентами остаётся честной, а поведение так синхронизация между несколькими клиентами остаётся честной, а поведение
радио не выдумывается. Всё такое помечено «эхо» и перечислено в doc/TCI.md. радио не выдумывается. Всё такое помечено «эхо» и перечислено в doc/TCI.md.
Потоки IQ/аудио (§3.4) — следующий этап: команды управления потоками Бинарные потоки (§3.4) живут в TCIStreams; здесь — только их подключение к
принимаются и подтверждаются, но сами потоки не идут. контроллеру: тап RX-аудио (два вида: до громкости — «аудиопоток приёмника»,
после неё — «линейный выход»), тап сырого IQ в движке, приём TX-аудио от
клиента и маркеры TX_CHRONO. Данные перемалывает DSP-поток, поэтому вся
работа с потоками — под FStreamLock и без единого ожидания.
} }
{$IFDEF FPC} {$IFDEF FPC}
@@ -41,10 +44,16 @@ interface
uses uses
Classes, SysUtils, DateUtils, Math, SyncObjs, Classes, SysUtils, DateUtils, Math, SyncObjs,
RadioController, RadioBackend, WDSPEngine, Settings, RadioController, RadioBackend, WDSPEngine, Settings,
DXSpotStore, TCIProtocol, TCIServer; DXSpotStore, TCIProtocol, TCIServer, TCIStreams;
const const
TCI_MAX_RX = MAX_PANS; // приёмник TCI = панадаптер TCI_MAX_RX = MAX_PANS; // приёмник TCI = панадаптер
// Аудио на выходе движка всегда 48 кГц (TWDSPEngine.Create), от него и
// считаются все прореживания и пересчёты потоков.
TCI_AUDIO_ENGINE_RATE = 48000;
// Потолок разворота одного блока TX-аудио в 48 кГц: 8192 отсчёта int16 на
// 8 кГц дают ×6. Больше в блок не влезает по протоколу (data[16384]).
TCI_TX_OUT_MAX = (TCI_STREAM_DATA_MAX div 2) * 6;
// Захват параметра клиентом (§3.5): пока владелец его крутит, остальные // Захват параметра клиентом (§3.5): пока владелец его крутит, остальные
// могут только слушать. Без этого два логгера перетягивают частоту друг у // могут только слушать. Без этого два логгера перетягивают частоту друг у
// друга бесконечно. // друга бесконечно.
@@ -120,6 +129,31 @@ type
FHolds: array[0..TCI_HOLD_SLOTS-1] of TTCIHold; FHolds: array[0..TCI_HOLD_SLOTS-1] of TTCIHold;
FHoldCount: Integer; FHoldCount: Integer;
// ── Бинарные потоки (§3.4) ──
// Список исходящих потоков и рекордеры живут под ОДНИМ локом: их читает
// DSP-поток (тап аудио/IQ), а меняют потоки клиентов. Порядок захвата
// всегда FSliceLock → FStreamLock: тап сначала выясняет, чей это слайс,
// и только потом ищет подписчиков. Обратный порядок дал бы клин.
FStreamLock: TCriticalSection;
FStreams: array of TTCIStreamOut;
FRec: array[0..TCI_MAX_RX-1] of TTCIRecorder;
FTapsOn: Boolean; // тапы навешены на контроллер/движок
// ── TX-аудио от клиента (§3.4) ──
FTxLock: TCriticalSection;
FTxClient: TTCIClient; // кто модулирует (nil — никто)
FTxInterp: TTCIInterpolator;
FTxInRate: Integer; // частота дискретизации подачи клиента
FTxRunning: Boolean; // маркеры TX_CHRONO идут
FTxOwed: Double; // сколько сэмплов клиент нам «должен»
FTxLastMs: QWord;
// Рабочие буферы разбора TX-блока. Полем, а не на стеке: развёрнутый в
// 48 кГц блок — это сотни килобайт, и класть их в стек потока клиента
// (да ещё на каждый блок двадцать раз в секунду) незачем.
FTxRaw: array of Single;
FTxMono: array of Single;
FTxOut: array of Double;
// ── Кэш для подавления повторов в уведомлениях ── // ── Кэш для подавления повторов в уведомлениях ──
FLastLimLo: Double; // последние разосланные VFO_LIMITS (поток контроллера) FLastLimLo: Double; // последние разосланные VFO_LIMITS (поток контроллера)
FLastLimHi: Double; FLastLimHi: Double;
@@ -134,6 +168,7 @@ type
FsInt2: Integer; FsInt2: Integer;
FsInt3: Integer; FsInt3: Integer;
FsBool: Boolean; FsBool: Boolean;
FsBool2: Boolean;
FsStr: string; FsStr: string;
// ── Sync-методы (поток контроллера) ── // ── Sync-методы (поток контроллера) ──
@@ -166,6 +201,30 @@ type
procedure SyncCWSend; procedure SyncCWSend;
procedure SyncCWStop; procedure SyncCWStop;
procedure SyncFocus; procedure SyncFocus;
procedure SyncTaps; // навесить/снять тапы аудио и IQ
// ── Бинарные потоки ──
function FindStream(C: TTCIClient; K: TTCIStreamType;
Rx: Integer): TTCIStreamOut; // под FStreamLock
procedure StartStream(C: TTCIClient; K: TTCIStreamType; Rx: Integer);
procedure StopStream(C: TTCIClient; K: TTCIStreamType; Rx: Integer);
procedure DropClientStreams(C: TTCIClient);
procedure RestartStreams(C: TTCIClient; K: TTCIStreamType);
procedure DropDeadRxStreams; // приёмник исчез — гасим его потоки
function EffIQRate(C: TTCIClient): Integer; // что реально отдадим
procedure PushIQRate(Client: TTCIClient); // переобъявить её клиенту
procedure StopAllStreams;
procedure SetTaps(On_: Boolean); // поток контроллера
function HasAudioStream(C: TTCIClient): Boolean;
function StreamRxOf(PanId, SliceId: Integer): Integer; // −1 = не наш канал
procedure OnAudioTap(Kind: TRadioAudioKind; PanId, SliceId: Integer;
const Left, Right: array of Single; Count: Integer);
procedure OnIQTap(PanId: Integer; PI_, PQ_: PDouble; N, RateHz: Integer);
procedure HandleBinary(Client: TTCIClient; Data: PByte; Len: Integer);
procedure PushTxChrono; // тик: маркеры времени клиенту
procedure CmdStream(Client: TTCIClient; const M: TTCIMessage);
procedure CmdRecorder(Client: TTCIClient; const M: TTCIMessage);
procedure ClearTxClient(C: TTCIClient); // клиент ушёл/перестал модулировать
// ── Помощники модели ── // ── Помощники модели ──
function CanInvoke: Boolean; function CanInvoke: Boolean;
@@ -307,8 +366,16 @@ begin
FHoldLock := TCriticalSection.Create; FHoldLock := TCriticalSection.Create;
FHoldCount := 0; FHoldCount := 0;
FStreamLock := TCriticalSection.Create;
FTxLock := TCriticalSection.Create;
FTapsOn := False;
SetLength(FTxRaw, TCI_STREAM_DATA_MAX div 2); // худший случай: int16
SetLength(FTxMono, TCI_STREAM_DATA_MAX div 2);
SetLength(FTxOut, TCI_TX_OUT_MAX);
FServer := TTCIServer.Create; FServer := TTCIServer.Create;
FServer.OnCommand := HandleCommand; FServer.OnCommand := HandleCommand;
FServer.OnBinary := HandleBinary;
FServer.OnConnect := HandleConnect; FServer.OnConnect := HandleConnect;
FServer.OnDisconnect := HandleDisconnect; FServer.OnDisconnect := HandleDisconnect;
FServer.OnTick := HandleTick; FServer.OnTick := HandleTick;
@@ -326,18 +393,25 @@ end;
destructor TTCIAdapter.Destroy; destructor TTCIAdapter.Destroy;
begin begin
// Сначала отписка: контроллер живёт дольше адаптера, и Changed() после // Сначала отписка: контроллер живёт дольше адаптера, и Changed() после
// нашей смерти позвал бы метод освобождённого объекта. // нашей смерти позвал бы метод освобождённого объекта. По той же причине
// ПЕРВЫМИ снимаем тапы аудио/IQ — их зовёт DSP-поток, который переживёт нас.
SetTaps(False);
if FController <> nil then FController.RemoveStateListener(OnState); if FController <> nil then FController.RemoveStateListener(OnState);
if FServer <> nil then if FServer <> nil then
begin begin
FServer.Stop; FServer.Stop;
FreeAndNil(FServer); FreeAndNil(FServer);
end; end;
// Потоки — уже после остановки сервера: их объекты ссылаются на клиентов.
StopAllStreams;
FreeAndNil(FTxInterp);
FLock.Free; FLock.Free;
FEchoLock.Free; FEchoLock.Free;
FDevLock.Free; FDevLock.Free;
FSliceLock.Free; FSliceLock.Free;
FHoldLock.Free; FHoldLock.Free;
FStreamLock.Free;
FTxLock.Free;
inherited; inherited;
end; end;
@@ -359,6 +433,10 @@ begin
OldPort := FServer.Port; OldPort := FServer.Port;
FServer.Stop; FServer.Stop;
// Сервер остановлен — клиентов больше нет, значит и потоки чужие: снимаем
// тапы, иначе DSP-поток продолжал бы носить аудио в никуда.
StopAllStreams;
SetTaps(False);
if not T.Enabled then if not T.Enabled then
begin begin
FCfg := T; FCfg := T;
@@ -369,11 +447,13 @@ begin
if Result then if Result then
begin begin
FCfg := T; FCfg := T;
SetTaps(True);
Exit; Exit;
end; end;
// Порт занят (или отобран правами) — поднимаем то, что работало. // Порт занят (или отобран правами) — поднимаем то, что работало.
if FCfg.Enabled and TTCIServer.ValidSettings(OldPort, FCfg.BindAddr) then if FCfg.Enabled and TTCIServer.ValidSettings(OldPort, FCfg.BindAddr) then
if FServer.Configure(OldPort, FCfg.BindAddr) then FServer.Start; if FServer.Configure(OldPort, FCfg.BindAddr) and FServer.Start then
SetTaps(True);
end; end;
function TTCIAdapter.CanInvoke: Boolean; function TTCIAdapter.CanInvoke: Boolean;
@@ -577,6 +657,10 @@ procedure TTCIAdapter.PushChannelMap;
// полную картину каждого живого приёмника — клиент перезаливает её целиком. // полную картину каждого живого приёмника — клиент перезаливает её целиком.
var Rx, Ch, N: Integer; var Rx, Ch, N: Integer;
begin begin
// Потоки пропавших приёмников гасим ВСЕГДА, даже если рассылать некому:
// объект потока пережил бы свой пан и молча копил тишину, а клиент ждал бы
// блоков, которых больше не будет.
DropDeadRxStreams;
if (FServer = nil) or (FServer.ClientCount = 0) then Exit; if (FServer = nil) or (FServer.ClientCount = 0) then Exit;
for Rx := 1 to RxCount - 1 do for Rx := 1 to RxCount - 1 do
begin begin
@@ -1168,9 +1252,13 @@ end;
procedure TTCIAdapter.HandleDisconnect(Client: TTCIClient); procedure TTCIAdapter.HandleDisconnect(Client: TTCIClient);
// Клиент ушёл — снимаем его захваты параметров, иначе следующий, кому достанется // Клиент ушёл — снимаем его захваты параметров, иначе следующий, кому достанется
// тот же адрес объекта, унаследовал бы чужие права (§3.5). // тот же адрес объекта, унаследовал бы чужие права (§3.5). По той же причине
// гасим его потоки: объект клиента вот-вот освободят, а на него смотрит
// DSP-поток. Порядок обязателен — сначала потоки, потом возврат в сервер.
begin begin
DropHolds(Client); DropHolds(Client);
DropClientStreams(Client);
ClearTxClient(Client);
end; end;
{ ═══════════════════════════════════════════════════════════════════════════ { ═══════════════════════════════════════════════════════════════════════════
@@ -1224,9 +1312,28 @@ end;
procedure TTCIAdapter.SyncSetTRX; procedure TTCIAdapter.SyncSetTRX;
begin begin
// Источник модуляции ставим ДО SetMOX — именно он его и читает при выборе
// микрофона. Выключение передачи флаг снимает всегда: следующий раз оператор
// может нажать PTT сам, и тогда в эфир должен идти его микрофон.
FController.TCIMicRequested := FsBool and FsBool2;
FController.SetMOX(FsBool); FController.SetMOX(FsBool);
end; end;
procedure TTCIAdapter.SyncTaps;
// Поток контроллера: движок и список тапов трогаем только отсюда.
begin
if FsBool then
begin
FController.AddAudioTap(OnAudioTap);
FController.SetIQTap(OnIQTap);
end
else
begin
FController.RemoveAudioTap(OnAudioTap);
FController.SetIQTap(nil);
end;
end;
procedure TTCIAdapter.SyncSetTune; procedure TTCIAdapter.SyncSetTune;
begin begin
FController.SetTune(FsBool); FController.SetTune(FsBool);
@@ -1557,7 +1664,7 @@ procedure TTCIAdapter.DispatchCommand(Client: TTCIClient; const M: TTCIMessage);
var var
Rx, Ch, V: Integer; Rx, Ch, V: Integer;
Lo, Hi: Integer; Lo, Hi: Integer;
B: Boolean; B, FromTCI: Boolean;
D: Double; D: Double;
Name: string; Name: string;
begin begin
@@ -1642,13 +1749,26 @@ begin
// ── Передача ── // ── Передача ──
if M.Name = 'TRX' then if M.Name = 'TRX' then
begin begin
// arg3 (источник сигнала: tci/mic1/…) игнорируем: аудио по TCI ещё нет, // arg3 источник сигнала. Наш только 'tci': модуляция берётся из
// модуляция берётся из выбранного в программе входа. // аудиопотока этого клиента. Остальные значения (mic1/mic2/micpc/ecoder2)
// называют физические входы ExpertSDR3, которых у нас нет, — они значат
// «микрофон, выбранный в программе», то есть ровно то, что и без arg3.
// Требование «включен аудиопоток по TCI» (§4.2) проверяем буквально:
// без AUDIO_START модулировать нечем, и молча оставить оператора с
// тишиной в эфире хуже, чем передавать с его микрофона.
if TCITryArgBool(M, 1, B) and Claim(HoldKey('TRX', 0, 0), Client) then if TCITryArgBool(M, 1, B) and Claim(HoldKey('TRX', 0, 0), Client) then
begin begin
Name := LowerCase(Trim(TCIArg(M, 2)));
FromTCI := B and (Name = 'tci') and HasAudioStream(Client);
FTxLock.Enter;
try
if FromTCI then FTxClient := Client
else if FTxClient = Client then FTxClient := nil;
finally FTxLock.Leave; end;
FLock.Enter; FLock.Enter;
try try
FsBool := B; FsBool := B;
FsBool2 := FromTCI;
if CanInvoke then FController.Invoke(SyncSetTRX); if CanInvoke then FController.Invoke(SyncSetTRX);
finally FLock.Leave; end; finally FLock.Leave; end;
end; end;
@@ -2188,37 +2308,75 @@ begin
// Это настройки КЛИЕНТА (§4.3), а не устройства: два логгера вправе просить // Это настройки КЛИЕНТА (§4.3), а не устройства: два логгера вправе просить
// разную частоту дискретизации. Поэтому живут в его объекте, а не в адаптере // разную частоту дискретизации. Поэтому живут в его объекте, а не в адаптере
// — иначе один клиент перенастраивал бы будущие потоки всем остальным. // — иначе один клиент перенастраивал бы будущие потоки всем остальным.
// Сами потоки — этап 2, значения только принимаются и подтверждаются. // Изменение параметра на ходу перезапускает уже идущие потоки этого клиента:
// блок с новой частотой посреди старого потока клиенты разбирают как мусор.
if M.Name = 'IQ_SAMPLERATE' then if M.Name = 'IQ_SAMPLERATE' then
begin begin
// Набор частот оговорён протоколом; чужое значение отвергаем, а в ответе // Набор частот оговорён протоколом; чужое значение отвергаем, а в ответе
// отдаём действующее — клиент увидит, что его не приняли. // отдаём действующее — клиент увидит, что его не приняли.
if TCITryArgInt(M, 0, V) and TCIValidIQRate(V) then Client.IQRate := V; if TCITryArgInt(M, 0, V) and TCIValidIQRate(V) and (V <> Client.IQRate) then
Reply(Client, TCIBuild('iq_samplerate', [TCIIntStr(Client.IQRate)])); begin
Client.IQRate := V;
RestartStreams(Client, tstIQ);
end;
// ★В ответе — та частота, которую клиент РЕАЛЬНО получит, а не его
// просьба: на 576 и 960 кГц Pluto просьба «384» невыполнима (не делится
// нацело), и подтвердить её значило бы соврать. Сама просьба остаётся
// сохранённой — на другом устройстве она может стать выполнимой.
Reply(Client, TCIBuild('iq_samplerate', [TCIIntStr(EffIQRate(Client))]));
Exit; Exit;
end; end;
if M.Name = 'AUDIO_SAMPLERATE' then if M.Name = 'AUDIO_SAMPLERATE' then
begin begin
if TCITryArgInt(M, 0, V) and TCIValidAudioRate(V) then Client.AudioRate := V; if TCITryArgInt(M, 0, V) and TCIValidAudioRate(V) and (V <> Client.AudioRate) then
begin
Client.AudioRate := V;
// Число сэмплов в блоке у ExpertSDR3 своё на каждую частоту (§4.3), и
// клиент вправе на это рассчитывать, пока не задал своё явно.
Client.AudioSamples := TCIDefaultAudioSamples(V);
RestartStreams(Client, tstRXAudio);
RestartStreams(Client, tstLineOut);
end;
Reply(Client, TCIBuild('audio_samplerate', [TCIIntStr(Client.AudioRate)])); Reply(Client, TCIBuild('audio_samplerate', [TCIIntStr(Client.AudioRate)]));
Exit; Exit;
end; end;
if M.Name = 'AUDIO_STREAM_SAMPLES' then if M.Name = 'AUDIO_STREAM_SAMPLES' then
begin begin
if TCITryArgInt(M, 0, V) then if TCITryArgInt(M, 0, V) then
Client.AudioSamples := EnsureRange(V, 100, 2048); begin
V := EnsureRange(V, TCI_AUDIO_SAMPLES_MIN, TCI_AUDIO_SAMPLES_MAX);
if V <> Client.AudioSamples then
begin
Client.AudioSamples := V;
RestartStreams(Client, tstRXAudio);
RestartStreams(Client, tstLineOut);
end;
end;
Exit; Exit;
end; end;
if M.Name = 'AUDIO_STREAM_CHANNELS' then if M.Name = 'AUDIO_STREAM_CHANNELS' then
begin begin
if TCITryArgInt(M, 0, V) then if TCITryArgInt(M, 0, V) then
Client.AudioChannels := EnsureRange(V, 1, 2); begin
V := EnsureRange(V, 1, 2);
if V <> Client.AudioChannels then
begin
Client.AudioChannels := V;
RestartStreams(Client, tstRXAudio);
RestartStreams(Client, tstLineOut);
end;
end;
Exit; Exit;
end; end;
if M.Name = 'AUDIO_STREAM_SAMPLE_TYPE' then if M.Name = 'AUDIO_STREAM_SAMPLE_TYPE' then
begin begin
Name := LowerCase(Trim(TCIArg(M, 0))); Name := LowerCase(Trim(TCIArg(M, 0)));
if TCIValidSampleType(Name) then Client.AudioSampleType := Name; if TCIValidSampleType(Name) and (Name <> Client.AudioSampleType) then
begin
Client.AudioSampleType := Name;
RestartStreams(Client, tstRXAudio);
RestartStreams(Client, tstLineOut);
end;
Exit; Exit;
end; end;
if M.Name = 'TX_STREAM_AUDIO_BUFFERING' then if M.Name = 'TX_STREAM_AUDIO_BUFFERING' then
@@ -2228,17 +2386,19 @@ begin
Exit; Exit;
end; end;
// Запуск потоков (§3.4) — этап 2. Молчать нельзя: клиент решил бы, что поток // ── Запуск и остановка потоков (§3.4) ──
// пошёл, и ждал бы данных бесконечно. Отвечаем ошибкой на конкретную команду.
if (M.Name = 'IQ_START') or (M.Name = 'IQ_STOP') or if (M.Name = 'IQ_START') or (M.Name = 'IQ_STOP') or
(M.Name = 'AUDIO_START') or (M.Name = 'AUDIO_STOP') or (M.Name = 'AUDIO_START') or (M.Name = 'AUDIO_STOP') or
(M.Name = 'LINE_OUT_START') or (M.Name = 'LINE_OUT_STOP') or (M.Name = 'LINE_OUT_START') or (M.Name = 'LINE_OUT_STOP') then
(M.Name = 'LINE_OUT_RECORDER_START') or begin
CmdStream(Client, M);
Exit;
end;
if (M.Name = 'LINE_OUT_RECORDER_START') or
(M.Name = 'LINE_OUT_RECORDER_SAVE') or (M.Name = 'LINE_OUT_RECORDER_SAVE') or
(M.Name = 'LINE_OUT_RECORDER_BREAK') then (M.Name = 'LINE_OUT_RECORDER_BREAK') then
begin begin
Reply(Client, TCIBuild('tci_error', [LowerCase(M.Name), CmdRecorder(Client, M);
'binary streams are not implemented']));
Exit; Exit;
end; end;
@@ -2291,11 +2451,594 @@ begin
// измерители у ВСЕХ клиентов до перезапуска сервера. // измерители у ВСЕХ клиентов до перезапуска сервера.
try try
FServer.EnumClients(PushSensors); FServer.EnumClients(PushSensors);
PushTxChrono;
except except
// молча: следующий тик через 20 мс попробует снова // молча: следующий тик через 20 мс попробует снова
end; end;
end; end;
{ ═══════════════════════════════════════════════════════════════════════════
Бинарные потоки (§3.4)
Кто в каком потоке исполнения:
• START/STOP и параметры — поток клиента (список правится под FStreamLock);
• подача данных (OnAudioTap/OnIQTap) — DSP-поток: он же нарезает блоки и
кладёт их в кольцо клиента, а в сокет пишет поток самого клиента;
• TX_CHRONO — тик-поток (пейсинг по часам);
• TX-аудио от клиента — поток этого клиента (HandleBinary).
Тапы навешиваются на контроллер и движок только из потока контроллера
(SetTaps зовут ApplySettings и деструктор), потому что снятие тапа обязано
дождаться выхода DSP-потока из вызова.
═══════════════════════════════════════════════════════════════════════════ }
procedure TTCIAdapter.SetTaps(On_: Boolean);
// Только поток контроллера (ApplySettings, Destroy): снятие тапа обязано
// дождаться выхода DSP-потока из вызова, а Invoke сюда звать не из чего —
// мы в нём и находимся.
begin
if (FTapsOn = On_) or (FController = nil) then Exit;
FTapsOn := On_;
FLock.Enter;
try
FsBool := On_;
SyncTaps;
finally
FLock.Leave;
end;
end;
function TTCIAdapter.FindStream(C: TTCIClient; K: TTCIStreamType;
Rx: Integer): TTCIStreamOut;
// Только под FStreamLock.
var i: Integer;
begin
Result := nil;
for i := 0 to High(FStreams) do
if (FStreams[i] <> nil) and FStreams[i].Matches(C, K, Rx) then
Exit(FStreams[i]);
end;
procedure TTCIAdapter.StartStream(C: TTCIClient; K: TTCIStreamType; Rx: Integer);
var
S: TTCIStreamOut;
SrcRate, WantRate, Chans, Block: Integer;
ST: TTCISampleType;
begin
if C = nil then Exit;
// Параметры снимаем ДО лока: геттеры клиента берут его собственный лок, и
// держать при этом FStreamLock значило бы связать два лока без нужды.
if K = tstIQ then
begin
SrcRate := FController.IQTapRateHz(Rx);
WantRate := C.IQRate;
Chans := 2; // IQ комплексный по определению
ST := tsyFloat32; // формат IQ в TCI не настраивается
Block := TCIMaxBlockSamples(ST, Chans);
end
else
begin
SrcRate := TCI_AUDIO_ENGINE_RATE;
WantRate := C.AudioRate;
Chans := EnsureRange(C.AudioChannels, 1, 2);
if not TCISampleTypeByName(C.AudioSampleType, ST) then ST := tsyFloat32;
Block := C.AudioSamples;
end;
if SrcRate <= 0 then SrcRate := TCI_AUDIO_ENGINE_RATE;
FStreamLock.Enter;
try
if FindStream(C, K, Rx) <> nil then Exit; // повторный START — не ошибка
S := TTCIStreamOut.Create(C, K, Rx, SrcRate, WantRate, Chans, ST, Block);
SetLength(FStreams, Length(FStreams) + 1);
FStreams[High(FStreams)] := S;
finally
FStreamLock.Leave;
end;
end;
procedure TTCIAdapter.StopStream(C: TTCIClient; K: TTCIStreamType; Rx: Integer);
var i, j: Integer;
begin
FStreamLock.Enter;
try
for i := 0 to High(FStreams) do
if (FStreams[i] <> nil) and FStreams[i].Matches(C, K, Rx) then
begin
FStreams[i].Free;
for j := i to High(FStreams) - 1 do FStreams[j] := FStreams[j + 1];
SetLength(FStreams, Length(FStreams) - 1);
Exit;
end;
finally
FStreamLock.Leave;
end;
end;
procedure TTCIAdapter.DropClientStreams(C: TTCIClient);
var i, j: Integer;
begin
FStreamLock.Enter;
try
i := 0;
while i <= High(FStreams) do
if (FStreams[i] <> nil) and (FStreams[i].Client = C) then
begin
FStreams[i].Free;
for j := i to High(FStreams) - 1 do FStreams[j] := FStreams[j + 1];
SetLength(FStreams, Length(FStreams) - 1);
end
else
Inc(i);
finally
FStreamLock.Leave;
end;
end;
procedure TTCIAdapter.RestartStreams(C: TTCIClient; K: TTCIStreamType);
// Параметры потока сменились на ходу: пересоздаём то, что уже идёт, с новыми.
// Пересобрать объект дешевле, чем учить его менять формат на лету, а клиент
// всё равно обязан читать заголовок каждого блока.
var
Rx: Integer;
Live: array[0..TCI_MAX_RX-1] of Boolean;
begin
FStreamLock.Enter;
try
for Rx := 0 to TCI_MAX_RX - 1 do Live[Rx] := FindStream(C, K, Rx) <> nil;
finally
FStreamLock.Leave;
end;
for Rx := 0 to TCI_MAX_RX - 1 do
if Live[Rx] then
begin
StopStream(C, K, Rx);
StartStream(C, K, Rx);
end;
end;
procedure TTCIAdapter.DropDeadRxStreams;
// Поток контроллера (OnState): живость приёмника спрашиваем ДО лока — внутри
// него ходит DSP-поток, и лезть оттуда в контроллер незачем.
var
Rx, i, j: Integer;
Alive: array[0..TCI_MAX_RX-1] of Boolean;
begin
for Rx := 0 to TCI_MAX_RX - 1 do
Alive[Rx] := ValidRx(Rx) and RxActive(Rx);
FStreamLock.Enter;
try
i := 0;
while i <= High(FStreams) do
if (FStreams[i].Rx >= 0) and (FStreams[i].Rx < TCI_MAX_RX) and
not Alive[FStreams[i].Rx] then
begin
FStreams[i].Free;
for j := i to High(FStreams) - 1 do FStreams[j] := FStreams[j + 1];
SetLength(FStreams, Length(FStreams) - 1);
end
else
Inc(i);
for Rx := 0 to TCI_MAX_RX - 1 do
if not Alive[Rx] then FreeAndNil(FRec[Rx]);
finally
FStreamLock.Leave;
end;
end;
procedure TTCIAdapter.StopAllStreams;
var i: Integer;
begin
FStreamLock.Enter;
try
for i := 0 to High(FStreams) do FStreams[i].Free;
SetLength(FStreams, 0);
for i := 0 to TCI_MAX_RX - 1 do FreeAndNil(FRec[i]);
finally
FStreamLock.Leave;
end;
ClearTxClient(nil);
end;
function TTCIAdapter.EffIQRate(C: TTCIClient): Integer;
// Частота IQ, которую клиент получит на ГЛАВНОМ приёмнике. У доп. панов rate
// свой, и настоящая частота каждого потока всегда стоит в заголовке блока —
// но команда IQ_SAMPLERATE в протоколе одна на клиента, поэтому и отвечать на
// неё можно только про один приёмник. Частоту берём из снимка: зовут отсюда
// потоки клиентов.
begin
Result := TCIPickIQRate(DevSnap.SampleRate, C.IQRate);
end;
procedure TTCIAdapter.PushIQRate(Client: TTCIClient);
begin
if Client.Ready then
Client.Send(TCIBuild('iq_samplerate', [TCIIntStr(EffIQRate(Client))]));
end;
function TTCIAdapter.HasAudioStream(C: TTCIClient): Boolean;
var Rx: Integer;
begin
Result := False;
FStreamLock.Enter;
try
for Rx := 0 to TCI_MAX_RX - 1 do
if FindStream(C, tstRXAudio, Rx) <> nil then Exit(True);
finally
FStreamLock.Leave;
end;
end;
function TTCIAdapter.StreamRxOf(PanId, SliceId: Integer): Integer;
// Какому приёмнику TCI принадлежит это аудио. Главный тракт — приёмник 0.
// У доп. пана в потоках участвует только канал A (первый слайс): «аудиопоток
// приёмника» в протоколе один на приёмник, второго канала у него нет.
// Зовётся из DSP-потока и берёт FSliceLock — ДО FStreamLock (порядок!).
begin
Result := -1;
if PanId <= 0 then
begin
if SliceId = 0 then Result := 0; // слайсы главного пана в модель не входят
Exit;
end;
if not ValidRx(PanId) then Exit;
if SliceIdOf(PanId, 0) = SliceId then Result := PanId;
end;
procedure TTCIAdapter.OnAudioTap(Kind: TRadioAudioKind; PanId, SliceId: Integer;
const Left, Right: array of Single; Count: Integer);
// DSP-поток. Всё, что здесь можно, — перемолоть блок и разложить его по
// кольцам клиентов; ждать нельзя ничего.
var
Rx, i: Integer;
K: TTCIStreamType;
begin
if Count <= 0 then Exit;
Rx := StreamRxOf(PanId, SliceId);
if Rx < 0 then Exit;
if Kind = rakDemod then K := tstRXAudio else K := tstLineOut;
FStreamLock.Enter;
try
for i := 0 to High(FStreams) do
if (FStreams[i].Kind = K) and (FStreams[i].Rx = Rx) then
FStreams[i].FeedAudio(Left, Right, Count);
// Рекордер пишет ровно линейный выход — тот же источник, что и поток
// LINEOUT (§4.3: «повторяет обычный аудио поток»).
if (Kind = rakLineOut) and (Rx < TCI_MAX_RX) and (FRec[Rx] <> nil) then
FRec[Rx].Feed(Left, Right, Count);
finally
FStreamLock.Leave;
end;
end;
procedure TTCIAdapter.OnIQTap(PanId: Integer; PI_, PQ_: PDouble;
N, RateHz: Integer);
// DSP-поток, один вызов на накопленный блок. У пана этот вызов идёт под
// FSliceLock движка, поэтому здесь тем более нельзя ждать.
var i: Integer;
begin
if (N <= 0) or (RateHz <= 0) then Exit;
if (PanId < 0) or (PanId >= TCI_MAX_RX) then Exit;
FStreamLock.Enter;
try
for i := 0 to High(FStreams) do
if (FStreams[i].Kind = tstIQ) and (FStreams[i].Rx = PanId) then
begin
// Rate устройства могли сменить уже после START (смена sample rate,
// другой rate DDC пана): пересчитываем прореживание на месте, иначе
// клиент получал бы поток с враньём в заголовке.
if FStreams[i].SrcRate <> RateHz then FStreams[i].SetSourceRate(RateHz);
FStreams[i].FeedIQ(PI_, PQ_, N);
end;
finally
FStreamLock.Leave;
end;
end;
procedure TTCIAdapter.CmdStream(Client: TTCIClient; const M: TTCIMessage);
var
Rx: Integer;
K: TTCIStreamType;
Start: Boolean;
begin
// Номер приёмника обязателен и обязан существовать: молча завести поток
// несуществующего пана значит навсегда оставить клиента без данных.
if not TCITryArgInt(M, 0, Rx) or not ValidRx(Rx) then
begin
Reply(Client, TCIBuild('tci_error',
[LowerCase(M.Name), 'bad receiver']));
Exit;
end;
Start := False;
K := tstRXAudio;
if M.Name = 'IQ_START' then begin K := tstIQ; Start := True; end
else if M.Name = 'IQ_STOP' then K := tstIQ
else if M.Name = 'AUDIO_START' then begin K := tstRXAudio; Start := True; end
else if M.Name = 'AUDIO_STOP' then K := tstRXAudio
else if M.Name = 'LINE_OUT_START' then begin K := tstLineOut; Start := True; end
else if M.Name = 'LINE_OUT_STOP' then K := tstLineOut;
if Start then
begin
// Пан существует, но не запущен — данных не будет вовсе. Честнее сказать
// сразу, чем оставить клиента ждать блоков от мёртвого приёмника.
if not RxActive(Rx) then
begin
Reply(Client, TCIBuild('tci_error',
[LowerCase(M.Name), 'receiver is not running']));
Exit;
end;
StartStream(Client, K, Rx);
end
else
begin
StopStream(Client, K, Rx);
// Модулировать из потока, которого больше нет, нельзя (§4.2).
if (K = tstRXAudio) and not HasAudioStream(Client) then ClearTxClient(Client);
end;
end;
procedure TTCIAdapter.CmdRecorder(Client: TTCIClient; const M: TTCIMessage);
var
Rx, Sec, Rate: Integer;
Path: string;
Data: TTCIPcm;
Old, New_: TTCIRecorder;
begin
if not TCITryArgInt(M, 0, Rx) or not ValidRx(Rx) then
begin
Reply(Client, TCIBuild('tci_error', [LowerCase(M.Name), 'bad receiver']));
Exit;
end;
if M.Name = 'LINE_OUT_RECORDER_START' then
begin
if not TCITryArgInt(M, 1, Sec) then Sec := TCI_RECORD_MAX_SEC;
Sec := EnsureRange(Sec, 1, TCI_RECORD_MAX_SEC);
// Кольцо заводим ДО лока: на предельных 300 с это 57 МБ, и выделять их
// под локом, которого ждёт DSP-поток, значит уронить звук на десятки мс.
New_ := TTCIRecorder.Create(Rx, TCI_AUDIO_ENGINE_RATE, Sec);
FStreamLock.Enter;
try
// Рекордер один на приёмник, а не на клиента: пишет он то, что слышно
// в аппарате, и второй такой же был бы просто копией памяти.
Old := FRec[Rx];
FRec[Rx] := New_;
finally
FStreamLock.Leave;
end;
Old.Free; // прежний — уже вне лока
Exit;
end;
if M.Name = 'LINE_OUT_RECORDER_BREAK' then
begin
FStreamLock.Enter;
try
Old := FRec[Rx];
FRec[Rx] := nil;
finally
FStreamLock.Leave;
end;
Old.Free;
Exit;
end;
// LINE_OUT_RECORDER_SAVE
Path := TCIRecordPath(TCIUnescape(TCIArg(M, 1)));
if Path = '' then
begin
Reply(Client, TCIBuild('tci_error', [LowerCase(M.Name), 'no file name']));
Exit;
end;
// MP3 у нас кодировать нечем — молча подсунуть WAV с расширением .mp3 хуже,
// чем сказать правду: клиент такой файл всё равно не откроет.
if SameText(ExtractFileExt(Path), '.mp3') then
begin
Reply(Client, TCIBuild('tci_error',
[LowerCase(M.Name), 'only wav is supported']));
Exit;
end;
// Забираем рекордер из таблицы (сохранение завершает запись, §4.3) и только
// потом снимаем с него данные: копия кольца — это десятки мегабайт, и делать
// её под локом DSP-потока нельзя.
Data := nil;
Rate := TCI_AUDIO_ENGINE_RATE;
FStreamLock.Enter;
try
Old := FRec[Rx];
FRec[Rx] := nil;
finally
FStreamLock.Leave;
end;
if Old <> nil then
begin
Data := Old.Take;
Rate := Old.Rate;
Old.Free;
end;
if Data = nil then
begin
Reply(Client, TCIBuild('tci_error', [LowerCase(M.Name), 'nothing recorded']));
Exit;
end;
// Пишет отдельный поток: файл может быть в десятки мегабайт, а мы сейчас в
// потоке клиента, который в это время не читает свой сокет.
TTCIWavWriter.Create(Path, Data, Rate);
end;
procedure TTCIAdapter.ClearTxClient(C: TTCIClient);
// C = nil — снять кого угодно (остановка сервера).
var Drop: Boolean;
begin
Drop := False;
FTxLock.Enter;
try
if (FTxClient <> nil) and ((C = nil) or (FTxClient = C)) then
begin
FTxClient := nil;
FTxRunning := False;
Drop := True;
end;
finally
FTxLock.Leave;
end;
if not Drop then Exit;
// Пишем прямо, без Invoke: это один Boolean, который SetMOX только читает, и
// зовут нас откуда угодно — в том числе из деструктора адаптера, где ждать
// поток контроллера уже некому. Передачу при этом НЕ трогаем: оператор мог
// нажать PTT сам, и обрывать его эфир из-за ухода клиента нельзя.
if FController <> nil then FController.TCIMicRequested := False;
end;
procedure TTCIAdapter.PushTxChrono;
// Тик-поток. Маркер TX_CHRONO говорит клиенту «пришли столько-то отсчётов»
// (§3.4). Пейсинг по часам: сколько времени прошло — столько и просим, плюс
// разовая подушка TX_STREAM_AUDIO_BUFFERING на старте передачи. Ответа не
// ждём: не успел клиент — в эфир уйдёт тишина, это его забота.
var
C: TTCIClient;
Now_: QWord;
Rate, Chans, Block: Integer;
ST: TTCISampleType;
H: TTCIStreamHeader;
Active: Boolean;
begin
FTxLock.Enter;
try
C := FTxClient;
finally
FTxLock.Leave;
end;
if C = nil then Exit;
Active := FController.TCIMicActive;
Now_ := GetTickCount64;
Rate := C.AudioRate;
Chans := EnsureRange(C.AudioChannels, 1, 2);
Block := EnsureRange(C.AudioSamples, TCI_AUDIO_SAMPLES_MIN,
TCI_AUDIO_SAMPLES_MAX);
if not TCISampleTypeByName(C.AudioSampleType, ST) then ST := tsyFloat32;
FTxLock.Enter;
try
if not Active then
begin
FTxRunning := False;
Exit;
end;
if not FTxRunning then
begin
FTxRunning := True;
FTxLastMs := Now_;
// Подушка: клиенту нужно время собрать первый блок, а тракт начнёт
// забирать сэмплы сразу.
FTxOwed := Rate * (C.TxBuffering / 1000.0);
end
else
begin
FTxOwed := FTxOwed + Rate * ((Now_ - FTxLastMs) / 1000.0);
FTxLastMs := Now_;
// Клиент замолчал, а время идёт — потолок долга держим в один блок,
// иначе после паузы на него обрушится пачка маркеров.
if FTxOwed > 4 * Block then FTxOwed := 4 * Block;
end;
while FTxOwed >= Block do
begin
TCIFillHeader(H, tstTXChrono, 0, Rate, ST, Block, Chans);
C.SendBin(H, nil, 0);
FTxOwed := FTxOwed - Block;
end;
finally
FTxLock.Leave;
end;
end;
procedure TTCIAdapter.HandleBinary(Client: TTCIClient; Data: PByte; Len: Integer);
// Поток клиента. Единственный бинарный кадр, который нам присылают, — блок
// TX-аудио (§3.4). Всё прочее молча отбрасываем: отвечать ошибкой на каждый
// чужой блок значит захлебнуться на клиенте, который шлёт их пачками.
var
H: TTCIStreamHeader;
ST: TTCISampleType;
P: PByte;
N, i, k, Chans, Rate, Factor, Bytes: Integer;
Mine: Boolean;
begin
if (Data = nil) or (Len <= SizeOf(H)) then Exit;
Move(Data^, H, SizeOf(H));
if H.StreamType <> LongWord(Ord(tstTXAudio)) then Exit;
FTxLock.Enter;
try Mine := (FTxClient = Client); finally FTxLock.Leave; end;
// Аудио от клиента, который не просил TRX:…,tci, — это не наша модуляция.
// Принять его значит подмешать чужой звук в чужую же передачу.
if not Mine then Exit;
if not FController.TCIMicActive then Exit;
if H.Format > LongWord(Ord(tsyFloat32)) then Exit;
ST := TTCISampleType(H.Format);
Chans := Integer(H.Channels);
if Chans < 1 then Chans := 1;
if Chans > 2 then Exit;
Rate := Integer(H.SampleRate);
if not TCIValidAudioRate(Rate) then Exit;
// Тракт работает на 48 кГц; целое отношение — единственный случай, который
// разрешает протокол (8/12/24/48), поэтому дробных пересчётов тут нет.
if TCI_AUDIO_ENGINE_RATE mod Rate <> 0 then Exit;
Factor := TCI_AUDIO_ENGINE_RATE div Rate;
Bytes := Len - SizeOf(H);
P := Data;
Inc(P, SizeOf(H));
FTxLock.Enter;
try
N := TCIUnpackSamples(P, Bytes, ST, FTxRaw);
if N <= 0 then Exit;
// Заголовок обещает length вещественных отсчётов — верим меньшему из двух:
// клиент вправе прислать короткий хвост, но не длиннее уместившегося.
if (H.DataLength > 0) and (Integer(H.DataLength) < N) then
N := Integer(H.DataLength);
// В тракт идёт моно: TXA у нас один, а стерео от клиента — это его
// собственный формат вывода, а не два независимых сигнала.
if Chans = 2 then
begin
k := 0;
i := 0;
while i + 1 < N do
begin
FTxMono[k] := (FTxRaw[i] + FTxRaw[i + 1]) * 0.5;
Inc(k);
Inc(i, 2);
end;
end
else
begin
k := N;
for i := 0 to N - 1 do FTxMono[i] := FTxRaw[i];
end;
if k <= 0 then Exit;
if (FTxInterp = nil) or (FTxInRate <> Rate) then
begin
FreeAndNil(FTxInterp);
FTxInterp := TTCIInterpolator.Create(Factor);
FTxInRate := Rate;
end;
N := FTxInterp.Process(FTxMono, k, FTxOut);
finally
FTxLock.Leave;
end;
if N > 0 then FController.PushTCIAudio(FTxOut, N);
end;
{ ═══════════════════════════════════════════════════════════════════════════ { ═══════════════════════════════════════════════════════════════════════════
Уведомления: изменение состояния контроллера → всем клиентам Уведомления: изменение состояния контроллера → всем клиентам
═══════════════════════════════════════════════════════════════════════════ } ═══════════════════════════════════════════════════════════════════════════ }
@@ -2316,6 +3059,9 @@ begin
// сетевые потоки читают о плате. // сетевые потоки читают о плате.
if Field in [rfDevice, rfConnected, rfDeviceList, rfXvtr, rfBand, rfSampleRate] then if Field in [rfDevice, rfConnected, rfDeviceList, rfXvtr, rfBand, rfSampleRate] then
RefreshDev; RefreshDev;
// Пан могли убрать и без правки слайсов — тогда сигнатура карты не менялась,
// а поток остался бы висеть на несуществующем приёмнике.
if Field in [rfDevice, rfConnected] then DropDeadRxStreams;
MapChanged := False; MapChanged := False;
if Field in [rfSliceFreq, rfSliceState, rfDevice, rfPanFreq, rfSampleRate, if Field in [rfSliceFreq, rfSliceState, rfDevice, rfPanFreq, rfSampleRate,
@@ -2401,9 +3147,20 @@ begin
FServer.Broadcast(StrIf(0, 1)); FServer.Broadcast(StrIf(0, 1));
end; end;
rfSampleRate: rfSampleRate:
begin
FServer.Broadcast(TCIBuild('if_limits', FServer.Broadcast(TCIBuild('if_limits',
[TCIIntStr(-(FController.FSampleRate div 2)), [TCIIntStr(-(FController.FSampleRate div 2)),
TCIIntStr(FController.FSampleRate div 2)])); TCIIntStr(FController.FSampleRate div 2)]));
// Сменился rate устройства — сменилось и то, что мы можем отдать в
// потоке IQ (у каждого клиента своё: просьбы разные). Молчать нельзя:
// клиент, попросивший 384 кГц на HPSDR, после перехода на Pluto 576
// получит 192 и должен об этом узнать, а не гадать по заголовкам.
FServer.EnumClients(PushIQRate);
end;
rfDevice, rfConnected:
// Смена устройства может утянуть за собой и rate (Pluto клампит чужой
// rate к своему минимуму молча), поэтому переобъявляем и тут.
FServer.EnumClients(PushIQRate);
rfRunning: rfRunning:
if FController.FRunning then FServer.Broadcast(TCIBuild('start')) if FController.FRunning then FServer.Broadcast(TCIBuild('start'))
else FServer.Broadcast(TCIBuild('stop')); else FServer.Broadcast(TCIBuild('stop'));
+205 -5
View File
@@ -42,6 +42,23 @@ const
TCI_MODULATIONS = 'am,sam,dsb,lsb,usb,cw,nfm,wfm,digl,digu,dmr,fmraw'; TCI_MODULATIONS = 'am,sam,dsb,lsb,usb,cw,nfm,wfm,digl,digu,dmr,fmraw';
// Границы, оговорённые протоколом (клампим сами — клиент шлёт что угодно). // Границы, оговорённые протоколом (клампим сами — клиент шлёт что угодно).
// Бинарные потоки (§3.4). data[16384] в структуре Stream — это ПОТОЛОК блока,
// а не его размер: больше в один блок не кладут ни ExpertSDR3, ни клиенты.
TCI_STREAM_DATA_MAX = 16384; // байт данных в блоке
TCI_STREAM_HDR_SIZE = 64; // 16 × uint32
TCI_STREAM_MAX = TCI_STREAM_HDR_SIZE + TCI_STREAM_DATA_MAX;
// Умолчания параметров потоков (§4.3).
TCI_IQ_RATE_DEF = 48000;
TCI_AUDIO_RATE_DEF = 48000;
TCI_AUDIO_CHAN_DEF = 2;
TCI_TX_BUFFERING_DEF = 50; // мс
TCI_AUDIO_SAMPLES_MIN = 100;
TCI_AUDIO_SAMPLES_MAX = 2048;
TCI_TX_BUFFERING_MIN = 50;
TCI_TX_BUFFERING_MAX = 500;
TCI_RECORD_MAX_SEC = 300; // потолок записи линейного выхода
TCI_VOL_MIN_DB = -60; TCI_VOL_MAX_DB = 0; TCI_VOL_MIN_DB = -60; TCI_VOL_MAX_DB = 0;
TCI_SQL_MIN_DB = -140; TCI_SQL_MAX_DB = 0; TCI_SQL_MIN_DB = -140; TCI_SQL_MAX_DB = 0;
TCI_AGC_MIN_DB = -20; TCI_AGC_MAX_DB = 120; TCI_AGC_MIN_DB = -20; TCI_AGC_MAX_DB = 120;
@@ -59,8 +76,7 @@ type
ArgCount: Integer; ArgCount: Integer;
end; end;
{ Тип бинарного потока (§3.4). Потоки следующий этап (см. doc/TCI.md), { Тип бинарного потока (§3.4). }
но формат протокольный, поэтому объявлен здесь, а не в транспорте. }
TTCIStreamType = (tstIQ, tstRXAudio, tstTXAudio, tstTXChrono, tstLineOut); TTCIStreamType = (tstIQ, tstRXAudio, tstTXAudio, tstTXChrono, tstLineOut);
TTCISampleType = (tsyInt16, tsyInt24, tsyInt32, tsyFloat32); TTCISampleType = (tsyInt16, tsyInt24, tsyInt32, tsyFloat32);
@@ -104,6 +120,35 @@ function TCIValidIQRate(V: Integer): Boolean; // 48/96/192/384 кГц
function TCIValidAudioRate(V: Integer): Boolean; // 8/12/24/48 кГц function TCIValidAudioRate(V: Integer): Boolean; // 8/12/24/48 кГц
function TCIValidSampleType(const S: string): Boolean; // int16/int24/int32/float32 function TCIValidSampleType(const S: string): Boolean; // int16/int24/int32/float32
{ ── Бинарные потоки (§3.4) ─────────────────────────────────────────────── }
function TCISampleTypeByName(const S: string; out T: TTCISampleType): Boolean;
function TCISampleTypeName(T: TTCISampleType): string;
function TCISampleBytes(T: TTCISampleType): Integer;
{ Сколько сэмплов НА КАНАЛ класть в блок по умолчанию (§4.3): у ExpertSDR3
своё число на каждую частоту дискретизации, и все они дают ~42 мс звучания. }
function TCIDefaultAudioSamples(RateHz: Integer): Integer;
{ Сколько сэмплов на канал влезает в блок с таким форматом и числом каналов. }
function TCIMaxBlockSamples(T: TTCISampleType; Channels: Integer): Integer;
{ Заголовок блока. Count сэмплов НА КАНАЛ; в DataLength уходит, как велит
протокол, количество ВЕЩЕСТВЕННЫХ отсчётов, то есть Count × Channels. }
procedure TCIFillHeader(out H: TTCIStreamHeader; Kind: TTCIStreamType;
Rx, RateHz: Integer; T: TTCISampleType; Count, Channels: Integer);
{ Упаковка вещественных отсчётов в формат клиента. Возвращает число байт.
Src читается подряд (уже с чередованием каналов), Dest обязан вмещать
N × TCISampleBytes(T) байт. }
function TCIPackSamples(const Src: array of Single; N: Integer;
T: TTCISampleType; Dest: PByte): Integer;
{ Обратная распаковка (TX-аудио от клиента). Возвращает число распакованных
вещественных отсчётов; лишнее сверх Length(Dest) отбрасывается. }
function TCIUnpackSamples(Src: PByte; Bytes: Integer; T: TTCISampleType;
var Dest: array of Single): Integer;
{ ── Сборка ─────────────────────────────────────────────────────────────── } { ── Сборка ─────────────────────────────────────────────────────────────── }
function TCIBuild(const Name: string): string; overload; function TCIBuild(const Name: string): string; overload;
@@ -284,10 +329,165 @@ begin
end; end;
function TCIValidSampleType(const S: string): Boolean; function TCIValidSampleType(const S: string): Boolean;
var T: string; var T: TTCISampleType;
begin begin
T := LowerCase(Trim(S)); Result := TCISampleTypeByName(S, T);
Result := (T = 'int16') or (T = 'int24') or (T = 'int32') or (T = 'float32'); end;
{ ═══════════════════════════════════════════════════════════════════════════
Бинарные потоки (§3.4)
═══════════════════════════════════════════════════════════════════════════ }
function TCISampleTypeByName(const S: string; out T: TTCISampleType): Boolean;
var N: string;
begin
Result := True;
N := LowerCase(Trim(S));
if N = 'int16' then T := tsyInt16
else if N = 'int24' then T := tsyInt24
else if N = 'int32' then T := tsyInt32
else if N = 'float32' then T := tsyFloat32
else begin T := tsyFloat32; Result := False; end;
end;
function TCISampleTypeName(T: TTCISampleType): string;
begin
case T of
tsyInt16: Result := 'int16';
tsyInt24: Result := 'int24';
tsyInt32: Result := 'int32';
else Result := 'float32';
end;
end;
function TCISampleBytes(T: TTCISampleType): Integer;
begin
case T of
tsyInt16: Result := 2;
tsyInt24: Result := 3;
else Result := 4; // int32 и float32
end;
end;
function TCIDefaultAudioSamples(RateHz: Integer): Integer;
begin
case RateHz of
8000: Result := 256;
12000: Result := 512;
24000: Result := 1024;
else Result := 2048; // 48 кГц и всё непонятное
end;
end;
function TCIMaxBlockSamples(T: TTCISampleType; Channels: Integer): Integer;
begin
if Channels < 1 then Channels := 1;
Result := TCI_STREAM_DATA_MAX div (TCISampleBytes(T) * Channels);
end;
procedure TCIFillHeader(out H: TTCIStreamHeader; Kind: TTCIStreamType;
Rx, RateHz: Integer; T: TTCISampleType; Count, Channels: Integer);
begin
FillChar(H, SizeOf(H), 0);
H.Receiver := LongWord(Rx);
H.SampleRate := LongWord(RateHz);
H.Format := LongWord(Ord(T));
H.DataLength := LongWord(Count * Channels);
H.StreamType := LongWord(Ord(Kind));
H.Channels := LongWord(Channels);
end;
function TCIPackSamples(const Src: array of Single; N: Integer;
T: TTCISampleType; Dest: PByte): Integer;
// Клампим на входе: перегруз в целочисленных форматах иначе заворачивается
// через знак и вместо ограничения даёт треск обратной полярности.
var
i, V: Integer;
F: Single;
P: PByte;
begin
if N > Length(Src) then N := Length(Src);
if N < 0 then N := 0;
P := Dest;
for i := 0 to N - 1 do
begin
F := Src[i];
if F > 1.0 then F := 1.0;
if F < -1.0 then F := -1.0;
case T of
tsyInt16:
begin
V := Round(F * 32767);
P[0] := Byte(V); P[1] := Byte(V shr 8);
Inc(P, 2);
end;
tsyInt24:
begin
V := Round(F * 8388607);
P[0] := Byte(V); P[1] := Byte(V shr 8); P[2] := Byte(V shr 16);
Inc(P, 3);
end;
tsyInt32:
begin
// 2^31-1 в Single не представимо точно, поэтому масштаб берём
// на единицу младше — иначе Round на полной шкале переполняется.
V := Round(F * 2147483520.0);
P[0] := Byte(V); P[1] := Byte(V shr 8);
P[2] := Byte(V shr 16); P[3] := Byte(V shr 24);
Inc(P, 4);
end;
else
begin
PSingle(P)^ := F;
Inc(P, 4);
end;
end;
end;
Result := N * TCISampleBytes(T);
end;
function TCIUnpackSamples(Src: PByte; Bytes: Integer; T: TTCISampleType;
var Dest: array of Single): Integer;
var
i, N, W: Integer;
P: PByte;
begin
Result := 0;
if (Src = nil) or (Bytes <= 0) then Exit;
N := Bytes div TCISampleBytes(T);
if N > Length(Dest) then N := Length(Dest);
P := Src;
for i := 0 to N - 1 do
begin
case T of
tsyInt16:
begin
W := SmallInt(Word(P[0]) or (Word(P[1]) shl 8));
Dest[i] := W / 32768.0;
Inc(P, 2);
end;
tsyInt24:
begin
W := LongInt(P[0]) or (LongInt(P[1]) shl 8) or (LongInt(P[2]) shl 16);
if (W and $800000) <> 0 then W := W or LongInt($FF000000);
Dest[i] := W / 8388608.0;
Inc(P, 3);
end;
tsyInt32:
begin
W := LongInt(LongWord(P[0]) or (LongWord(P[1]) shl 8) or
(LongWord(P[2]) shl 16) or (LongWord(P[3]) shl 24));
Dest[i] := W / 2147483648.0;
Inc(P, 4);
end;
else
begin
Dest[i] := PSingle(P)^;
Inc(P, 4);
end;
end;
end;
Result := N;
end; end;
{ ═══════════════════════════════════════════════════════════════════════════ { ═══════════════════════════════════════════════════════════════════════════
+143 -13
View File
@@ -33,8 +33,12 @@ unit TCIServer;
(TCI_MAX_SOCKETS) и убивается по таймауту TCI_HANDSHAKE_MS. Иначе восемь (TCI_MAX_SOCKETS) и убивается по таймауту TCI_HANDSHAKE_MS. Иначе восемь
молчащих TCP-соединений навсегда закрывали дверь настоящим клиентам. молчащих TCP-соединений навсегда закрывали дверь настоящим клиентам.
Бинарные фреймы (потоки IQ/аудио, §3.4) пока не обрабатываются: этап 2, Бинарные фреймы (потоки IQ/аудио, §3.4) ходят в обе стороны: блоки наружу
см. doc/TCI.md. Приходящие от клиента binary-фреймы молча отбрасываются. кладутся в отдельное кольцо клиента (SendBin) и уходят его же потоком вместе
с командами, входящие собираются из фрагментов и отдаются наверх (OnBinary)
там их разбирает TCIAdapter. У двух очередей разная политика переполнения:
команду терять нельзя (клиент выбрасывается), блок потока можно и нужно
(теряется самый старый), иначе отставший скиммер рвал бы себе управление.
Сокеты и WS-фреймы переиспользованы из веб-подсистемы (WebUtils/WsClient): Сокеты и WS-фреймы переиспользованы из веб-подсистемы (WebUtils/WsClient):
тот же код handshake и та же схема «поток на клиента + accept-поток», что в тот же код handshake и та же схема «поток на клиента + accept-поток», что в
@@ -71,6 +75,11 @@ const
TCI_OUT_CHUNK = 3800; // склейка очереди в один фрейм, символов TCI_OUT_CHUNK = 3800; // склейка очереди в один фрейм, символов
TCI_MSG_MAX = 65536; // потолок собираемого из фрагментов сообщения TCI_MSG_MAX = 65536; // потолок собираемого из фрагментов сообщения
TCI_STOP_KILL_MS = 500; // как часто добиваем клиентов, ожидая их выхода TCI_STOP_KILL_MS = 500; // как часто добиваем клиентов, ожидая их выхода
// Очередь бинарных блоков (§3.4) на клиента. Переполнение здесь НЕ повод
// рвать соединение, в отличие от очереди команд: поток — это данные
// реального времени, и клиент, не успевший забрать блок, должен потерять
// именно блок. Выбрасываем самый старый: свежий звук полезнее протухшего.
TCI_BIN_QUEUE = 48;
type type
TTCIServer = class; TTCIServer = class;
@@ -107,6 +116,13 @@ type
FOutLock: TCriticalSection; FOutLock: TCriticalSection;
FOut: array of string; FOut: array of string;
FOutCount: Integer; FOutCount: Integer;
// Очередь бинарных блоков потоков. Отдельная от командной: у них разная
// политика переполнения (команду терять нельзя, блок потока — можно) и
// разные производители (блоки кладёт DSP-поток).
FBinOut: array[0..TCI_BIN_QUEUE-1] of TBytes;
FBinHead: Integer; // куда класть
FBinTail: Integer; // откуда брать
FBinDropped: LongInt; // сколько блоков выброшено (диагностика)
FDead: Boolean; // сокет уже не пишется — гасим соединение FDead: Boolean; // сокет уже не пишется — гасим соединение
FKilled: Boolean; // shutdown сокета уже сделан FKilled: Boolean; // shutdown сокета уже сделан
FClosed: Boolean; // клиентский поток вышел (можно освобождать) FClosed: Boolean; // клиентский поток вышел (можно освобождать)
@@ -123,6 +139,13 @@ type
destructor Destroy; override; destructor Destroy; override;
{ Строку в очередь клиенту. False — соединение уже мертво. Не блокирует. } { Строку в очередь клиенту. False — соединение уже мертво. Не блокирует. }
function Send(const S: string): Boolean; function Send(const S: string): Boolean;
{ Блок бинарного потока (заголовок + сэмплы) в очередь. Зовётся из
DSP-потока, поэтому только копирование под коротким локом: сеть тут не
трогается. False клиент мёртв (блок никуда не пошёл). }
function SendBin(const Hdr: TTCIStreamHeader; Data: Pointer;
Bytes: Integer): Boolean;
{ Сколько блоков потока выброшено из-за отставания клиента. }
function BinDropped: LongInt;
{ Слить очередь в сокет. Зовёт ТОЛЬКО собственный поток клиента: запись { Слить очередь в сокет. Зовёт ТОЛЬКО собственный поток клиента: запись
может ждать до TCI_SEND_TIMEOUT, и общий поток на этом задерживал бы может ждать до TCI_SEND_TIMEOUT, и общий поток на этом задерживал бы
всех остальных. False клиент умер. } всех остальных. False клиент умер. }
@@ -154,6 +177,10 @@ type
TTCIClientEvent = procedure(Client: TTCIClient) of object; TTCIClientEvent = procedure(Client: TTCIClient) of object;
TTCICommandEvent = procedure(Client: TTCIClient; const Cmd: string) of object; TTCICommandEvent = procedure(Client: TTCIClient; const Cmd: string) of object;
{ Собранное бинарное сообщение от клиента (TX-аудио, §3.4). Данные живут
только на время вызова обработчик обязан их скопировать. }
TTCIBinaryEvent = procedure(Client: TTCIClient; Data: PByte;
Len: Integer) of object;
TTCIServer = class TTCIServer = class
private private
@@ -170,6 +197,7 @@ type
FPort: Word; FPort: Word;
FBindIP: string; FBindIP: string;
FOnCommand: TTCICommandEvent; FOnCommand: TTCICommandEvent;
FOnBinary: TTCIBinaryEvent;
FOnConnect: TTCIClientEvent; FOnConnect: TTCIClientEvent;
FOnDisconnect: TTCIClientEvent; FOnDisconnect: TTCIClientEvent;
FOnTick: TThreadMethod; FOnTick: TThreadMethod;
@@ -218,6 +246,7 @@ type
контроллера, и Synchronize из клиентского потока в него не вернётся. } контроллера, и Synchronize из клиентского потока в него не вернётся. }
property Stopping: Boolean read FStopping; property Stopping: Boolean read FStopping;
property OnCommand: TTCICommandEvent read FOnCommand write FOnCommand; property OnCommand: TTCICommandEvent read FOnCommand write FOnCommand;
property OnBinary: TTCIBinaryEvent read FOnBinary write FOnBinary;
property OnConnect: TTCIClientEvent read FOnConnect write FOnConnect; property OnConnect: TTCIClientEvent read FOnConnect write FOnConnect;
property OnDisconnect: TTCIClientEvent read FOnDisconnect write FOnDisconnect; property OnDisconnect: TTCIClientEvent read FOnDisconnect write FOnDisconnect;
property OnTick: TThreadMethod read FOnTick write FOnTick; property OnTick: TThreadMethod read FOnTick write FOnTick;
@@ -411,17 +440,22 @@ begin
FOutLock := TCriticalSection.Create; FOutLock := TCriticalSection.Create;
FOutCount := 0; FOutCount := 0;
SetLength(FOut, 64); SetLength(FOut, 64);
FBinHead := 0;
FBinTail := 0;
FBinDropped := 0;
// Умолчания параметров потоков — как в §4.3 (клиент их обычно переопределяет). // Умолчания параметров потоков — как в §4.3 (клиент их обычно переопределяет).
FIQRate := 48000; FIQRate := TCI_IQ_RATE_DEF;
FAudioRate := 48000; FAudioRate := TCI_AUDIO_RATE_DEF;
FAudioSamples := 2048; FAudioSamples := TCIDefaultAudioSamples(TCI_AUDIO_RATE_DEF);
FAudioChannels := 2; FAudioChannels := TCI_AUDIO_CHAN_DEF;
FAudioSampleType := 'float32'; FAudioSampleType := 'float32';
FTxBuffering := 50; FTxBuffering := TCI_TX_BUFFERING_DEF;
end; end;
destructor TTCIClient.Destroy; destructor TTCIClient.Destroy;
var i: Integer;
begin begin
for i := 0 to TCI_BIN_QUEUE - 1 do FBinOut[i] := nil;
FOutLock.Free; FOutLock.Free;
FStateLock.Free; FStateLock.Free;
inherited; inherited;
@@ -590,10 +624,53 @@ begin
end; end;
end; end;
function TTCIClient.SendBin(const Hdr: TTCIStreamHeader; Data: Pointer;
Bytes: Integer): Boolean;
// Кладёт готовый блок в кольцо. Зовётся из DSP-потока: единственное, что тут
// разрешено — копирование под коротким локом. Кольцо полное — выбрасываем
// САМЫЙ СТАРЫЙ блок: рвать соединение из-за отставания в потоке нельзя
// (команды при этом продолжают ходить), а протухший звук клиенту не нужен.
var
Blk: TBytes;
NewH: Integer;
begin
Result := False;
if FDead or (FWs = nil) or (Bytes < 0) then Exit;
if Bytes > TCI_STREAM_DATA_MAX then Exit; // блок не по протоколу
SetLength(Blk, SizeOf(Hdr) + Bytes);
Move(Hdr, Blk[0], SizeOf(Hdr));
if Bytes > 0 then Move(Data^, Blk[SizeOf(Hdr)], Bytes);
FOutLock.Enter;
try
if FDead then Exit;
NewH := (FBinHead + 1) mod TCI_BIN_QUEUE;
if NewH = FBinTail then
begin
FBinOut[FBinTail] := nil;
FBinTail := (FBinTail + 1) mod TCI_BIN_QUEUE;
Inc(FBinDropped);
end;
FBinOut[FBinHead] := Blk;
FBinHead := NewH;
Result := True;
finally
FOutLock.Leave;
end;
end;
function TTCIClient.BinDropped: LongInt;
begin
FOutLock.Enter;
try Result := FBinDropped; finally FOutLock.Leave; end;
end;
function TTCIClient.Flush: Boolean; function TTCIClient.Flush: Boolean;
var var
Batch: array of string; Batch: array of string;
N, i: Integer; Bins: array[0..TCI_BIN_QUEUE-1] of TBytes;
N, i, NB: Integer;
Chunk: string; Chunk: string;
begin begin
Result := not FDead; Result := not FDead;
@@ -602,6 +679,7 @@ begin
// поток так и висел бы в recv, а объект никогда бы не освободился. // поток так и висел бы в recv, а объект никогда бы не освободился.
if FDead then begin Kill; Exit; end; if FDead then begin Kill; Exit; end;
NB := 0;
FOutLock.Enter; FOutLock.Enter;
try try
N := FOutCount; N := FOutCount;
@@ -615,10 +693,19 @@ begin
end; end;
FOutCount := 0; FOutCount := 0;
end; end;
// Бинарные блоки забираем тем же заходом: лишний Enter/Leave на каждый
// блок потока — это тысячи лишних локов в секунду.
while FBinTail <> FBinHead do
begin
Bins[NB] := FBinOut[FBinTail];
FBinOut[FBinTail] := nil;
FBinTail := (FBinTail + 1) mod TCI_BIN_QUEUE;
Inc(NB);
end;
finally finally
FOutLock.Leave; FOutLock.Leave;
end; end;
if N = 0 then Exit; if (N = 0) and (NB = 0) then Exit;
// Склейка: несколько команд в одном фрейме протокол разрешает (§3.1), а // Склейка: несколько команд в одном фрейме протокол разрешает (§3.1), а
// syscall'ов и заголовков становится в разы меньше. // syscall'ов и заголовков становится в разы меньше.
@@ -634,14 +721,30 @@ begin
end; end;
if Chunk <> '' then if Chunk <> '' then
if not FWs.SendText(Chunk) then begin Kill; Exit(False); end; if not FWs.SendText(Chunk) then begin Kill; Exit(False); end;
// Блоки потоков — каждый отдельным binary-фреймом: клиент читает их по
// одному заголовку на кадр, склейка тут запрещена протоколом.
for i := 0 to NB - 1 do
begin
if not FWs.SendBinary(Bins[i][0], Length(Bins[i])) then
begin
Kill;
Exit(False);
end;
Bins[i] := nil;
end;
end; end;
procedure TTCIClient.Kill; procedure TTCIClient.Kill;
var i: Integer;
begin begin
FOutLock.Enter; FOutLock.Enter;
try try
FDead := True; FDead := True;
FOutCount := 0; FOutCount := 0;
for i := 0 to TCI_BIN_QUEUE - 1 do FBinOut[i] := nil;
FBinHead := 0;
FBinTail := 0;
if FKilled then Exit; // shutdown уже был — второй раз незачем if FKilled then Exit; // shutdown уже был — второй раз незачем
FKilled := True; FKilled := True;
finally finally
@@ -980,6 +1083,8 @@ var
Payload: array of Byte; Payload: array of Byte;
Opcode, MsgOp: Byte; Opcode, MsgOp: Byte;
Frag: string; Frag: string;
Bin: array of Byte; // сборка бинарного сообщения (блок потока)
BinLen: Integer;
Cmds: TStringList; Cmds: TStringList;
begin begin
Ws := Client.Ws; Ws := Client.Ws;
@@ -1086,6 +1191,11 @@ begin
Cmds := TStringList.Create; Cmds := TStringList.Create;
Frag := ''; Frag := '';
MsgOp := 0; MsgOp := 0;
BinLen := 0;
// Буфер под сборку блока потока заводим сразу: расти по ходу приёма он всё
// равно не имеет права (потолок задан протоколом), а перевыделение на
// каждый блок TX-аудио — это мусор в куче двадцать раз в секунду.
SetLength(Bin, TCI_STREAM_MAX);
Pending := Rest > 0; // хвост handshake разбираем до первого recv Pending := Rest > 0; // хвост handshake разбираем до первого recv
try try
while FRunning and (Ws.State = wsOpen) and not Client.Dead do while FRunning and (Ws.State = wsOpen) and not Client.Dead do
@@ -1150,9 +1260,9 @@ begin
// Фрейм крупнее приёмного буфера TWsClient никогда не соберётся — // Фрейм крупнее приёмного буфера TWsClient никогда не соберётся —
// BufLen упрётся в потолок и цикл встанет намертво. Рвём соединение: // BufLen упрётся в потолок и цикл встанет намертво. Рвём соединение:
// команд такой длины у TCI нет, а бинарные потоки от клиента (TX-аудио) // команд такой длины у TCI нет, а самый крупный законный кадр —
// мы пока не принимаем. // блок TX-аудио (заголовок + data[16384]) — в буфер помещается.
if Need + PayLen > SizeOf(Raw) then if Need + PayLen > Ws.BufCapacity then
begin begin
Ws.State := wsClosed; Ws.State := wsClosed;
Break; Break;
@@ -1191,9 +1301,9 @@ begin
if MsgOp <> 0 then begin Ws.State := wsClosed; Break; end; if MsgOp <> 0 then begin Ws.State := wsClosed; Break; end;
MsgOp := Opcode; MsgOp := Opcode;
Frag := ''; Frag := '';
BinLen := 0;
end; end;
// Копим только текст: binary — это TX-аудио от клиента, этап 2.
if MsgOp = $01 then if MsgOp = $01 then
begin begin
if Length(Frag) + PayLen > TCI_MSG_MAX then if Length(Frag) + PayLen > TCI_MSG_MAX then
@@ -1207,10 +1317,30 @@ begin
Move(Payload[0], Text[1], PayLen); Move(Payload[0], Text[1], PayLen);
Frag := Frag + Text; Frag := Frag + Text;
end; end;
end
else
begin
// Бинарное сообщение — блок потока от клиента (TX-аудио).
// Клиент вправе резать его на фрагменты, поэтому копим так же,
// как текст, но с потолком в один блок: длиннее протокол не
// определяет, и растить буфер на чужой каприз мы не обязаны.
if BinLen + PayLen > TCI_STREAM_MAX then
begin
Ws.State := wsClosed;
Break;
end;
if PayLen > 0 then
begin
Move(Payload[0], Bin[BinLen], PayLen);
Inc(BinLen, PayLen);
end;
end; end;
if Fin then if Fin then
begin begin
if (MsgOp = $02) and Assigned(FOnBinary) and (BinLen > 0) then
FOnBinary(Client, @Bin[0], BinLen);
BinLen := 0;
// Текстовое сообщение обязано быть валидным UTF-8 (§5.6); // Текстовое сообщение обязано быть валидным UTF-8 (§5.6);
// битую последовательность RFC велит закрывать, а не молча // битую последовательность RFC велит закрывать, а не молча
// скармливать разбору команд. // скармливать разбору команд.
+859
View File
@@ -0,0 +1,859 @@
unit TCIStreams;
{
TCIStreams.pas бинарные потоки TCI (§3.4): нарезка сэмплов на блоки,
пересчёт частоты дискретизации, запись линейного выхода в файл.
Чистый слой обработки: ни контроллера, ни движка тут нет только TCIProtocol
(формат блока) и TCIServer (очередь клиента). Кто и откуда кормит эти объекты,
решает TCIAdapter.
★Главное про потоки исполнения. Feed* зовёт DSP-ПОТОК (тап аудио/IQ), то есть
тот самый, который считает WDSP. Поэтому здесь нет ни одного вызова, который
может ждать: блок уходит в кольцо клиента (микросекунды под его локом), а в
сокет его пишет собственный поток клиента. Отставший клиент теряет свои блоки
(TTCIClient.SendBin выбрасывает самый старый) и никого больше не задерживает.
Пересчёт частоты:
вниз (RX-аудио 48 кГц 8/12/24, IQ 384 48/96/192) FIR-дециматор с
целым коэффициентом. Без фильтра тут нельзя: широкий ФМ-канал или шум за
полосой сложились бы в звуковую полосу зеркалом;
вверх (TX-аудио клиента 8/12/24 кГц 48 кГц тракта) линейная
интерполяция. Образы от неё лежат на 8..12 кГц и выше, то есть заведомо
за полосой TX-фильтра (максимум 4 кГц), а завал в полосе 0.2 дБ на
3 кГц. Городить ради этого второй FIR смысла нет.
}
{$IFDEF FPC}
{$MODE Delphi}
{$LONGSTRINGS ON}
{$ENDIF}
interface
uses
Classes, SysUtils, Math, SyncObjs, TCIProtocol, TCIServer;
const
// Длина FIR на каждую ступень прореживания. Ntaps = TCI_FIR_PER_FACTOR×M+1
// ⇒ стоимость на ВХОДНОЙ сэмпл постоянна (≈8 умножений) независимо от M.
TCI_FIR_PER_FACTOR = 8;
TCI_FIR_MAX_TAPS = 257;
// Кусок, которым поток перемалывает подачу от движка. Все рабочие буферы
// заведены под него в конструкторе: SetLength в DSP-потоке на каждый блок
// аудио — это тысячи обращений к куче в секунду на ровном месте.
TCI_FEED_CHUNK = 4096;
type
{ Накопленная запись: 16-битный PCM с чередованием L/R. }
TTCIPcm = array of SmallInt;
{ Дециматор с целым коэффициентом. Прямая свёртка по линии задержки: выход
считается только на нужной фазе, поэтому цена не зависит от M. }
TTCIDecimator = class
private
FTaps: array of Single;
FHist: array of Single; // линия задержки, кольцо
FN: Integer; // длина FIR
FHalf: Integer; // FN div 2 — число симметричных пар
FPos: Integer;
FPhase: Integer;
FFactor: Integer;
public
{ Одиночный дециматор: полоса 0.45 от новой частоты Найквиста, длина FIR
по коэффициенту. Для одной ступени этого достаточно. }
constructor Create(AFactor: Integer);
{ Ступень каскада: полосу и длину задаёт вызывающий ранним ступеням
узкая переходная полоса не нужна (см. TTCIDecimChain). }
constructor CreateDesigned(AFactor, ATaps: Integer; ACutoff: Double);
procedure Reset;
{ N входных сэмплов → до N/Factor выходных. Dst обязан вмещать столько. }
function Process(const Src: array of Single; N: Integer;
var Dst: array of Single): Integer;
property Factor: Integer read FFactor;
property Taps: Integer read FN;
end;
{ Каскад дециматоров. Одной ступенью большие коэффициенты не берутся: длина
FIR растёт вместе с коэффициентом, а упираясь в потолок TCI_FIR_MAX_TAPS,
одноступенчатый дециматор перестаёт быть фильтром вовсе. Замерено на
576048 кГц (коэффициент 120, верхний rate Pluto): завал 1.3 дБ в полосе и
подавление зеркала всего 16 дБ то есть поток IQ с мусором.
Поэтому коэффициент раскладывается на множители (по убыванию самая
дорогая ступень первой). Спецификацию фильтра каждой ступени задаёт
ИТОГОВАЯ полоса, а не её собственная: ранняя ступень обязана убрать лишь
те узкие зоны, которые в конце сложатся в полезную полосу, поэтому её
переходная полоса шире в десятки раз, а фильтр во столько же короче.
Итог замера: 83 дБ по зеркалу на любом коэффициенте, цена 10% ядра на
потоке 5.76 МГц (было 20% и мусор). Свёртка идёт со сложением симметричных
пар умножений вдвое меньше при том же результате. }
TTCIDecimChain = class
private
FStages: array of TTCIDecimator;
FTmp: array[0..1] of array of Single;
FFactor: Integer;
public
constructor Create(AFactor: Integer);
destructor Destroy; override;
procedure Reset;
function Process(const Src: array of Single; N: Integer;
var Dst: array of Single): Integer;
property Factor: Integer read FFactor;
end;
{ Интерполятор для TX-аудио: целое отношение, линейная интерполяция. }
TTCIInterpolator = class
private
FFactor: Integer;
FPrev: Single;
FHas: Boolean;
public
constructor Create(AFactor: Integer);
procedure Reset;
function Process(const Src: array of Single; N: Integer;
var Dst: array of Double): Integer;
property Factor: Integer read FFactor;
end;
{ Исходящий поток одного клиента: один тип, один приёмник. Живёт от START до
STOP (или до ухода клиента) и владеет своими дециматорами и накопителем. }
TTCIStreamOut = class
private
FClient: TTCIClient;
FKind: TTCIStreamType;
FRx: Integer;
FSrcRate: Integer;
FOutRate: Integer;
FChannels: Integer;
FSampleT: TTCISampleType;
FBlock: Integer; // сэмплов НА КАНАЛ в блоке
FDec: array[0..1] of TTCIDecimChain;
FTmp: array[0..1] of array of Single; // выход дециматора
FIn: array of Single; // вход одного канала, кусок
FWantRate: Integer; // о чём просил клиент (для пересборки)
FAcc: array of Single; // накопитель с чередованием каналов
FAccCount: Integer; // сэмплов на канал в накопителе
FPacked: array of Byte;
procedure EmitFull;
procedure PushPair(A, B: Single);
public
constructor Create(AClient: TTCIClient; AKind: TTCIStreamType;
ARx, ASrcRate, AWantRate, AChannels: Integer;
ASampleT: TTCISampleType; ABlock: Integer);
destructor Destroy; override;
{ Аудио 48 кГц: стерео от движка. Один канал — усреднение (моно). }
procedure FeedAudio(const L, R: array of Single; N: Integer);
{ IQ: комплексные отсчёты, всегда два канала. }
procedure FeedIQ(PI_, PQ_: PDouble; N: Integer);
{ Совпадает ли поток с (клиент, тип, приёмник) — для поиска в списке. }
function Matches(AClient: TTCIClient; AKind: TTCIStreamType;
ARx: Integer): Boolean;
{ Частота источника сменилась на ходу (другой sample rate устройства или
rate DDC пана). Пересобирает прореживание; накопленный блок бросаем
склеивать в один блок сэмплы двух разных частот нельзя. }
procedure SetSourceRate(ANewRate: Integer);
property Client: TTCIClient read FClient;
property Kind: TTCIStreamType read FKind;
property Rx: Integer read FRx;
property OutRate: Integer read FOutRate;
property SrcRate: Integer read FSrcRate;
end;
{ Запись линейного выхода (LINE_OUT_RECORDER_*, §4.3). Кольцо на MaxSec
секунд 48 кГц стерео в int16: во-первых, ровно то, что уйдёт в WAV, а
во-вторых, float32 на предельных 300 с это 115 МБ вместо 57. }
TTCIRecorder = class
private
FLock: TCriticalSection;
FRing: array of SmallInt; // чередование L/R
FCap: Integer; // ёмкость в сэмплах на канал
FCount: Integer; // накоплено сэмплов на канал
FHead: Integer; // позиция записи (в сэмплах на канал)
FRate: Integer;
FRx: Integer;
public
constructor Create(ARx, ARateHz, AMaxSec: Integer);
destructor Destroy; override;
procedure Feed(const L, R: array of Single; N: Integer);
{ Забрать накопленное В ПОРЯДКЕ ВРЕМЕНИ и обнулить кольцо. }
function Take: TTCIPcm;
property Rx: Integer read FRx;
property Rate: Integer read FRate;
end;
{ Писатель WAV в своём потоке: файл до 60 МБ, а зовут сохранение из тика
сервера блокировать его на секунду диска нельзя. Данные забирает себе. }
TTCIWavWriter = class(TThread)
private
FPath: string;
FData: TTCIPcm;
FRate: Integer;
protected
procedure Execute; override;
public
constructor Create(const APath: string; const AData: TTCIPcm;
ARateHz: Integer);
end;
{ Коэффициент прореживания SrcRate WantRate: наибольший целый делитель,
дающий не меньше запрошенного. Апсемплинг наружу не делаем никогда
клиенту уходит настоящая частота (она же в заголовке блока). }
function TCIDecimFactor(SrcRate, WantRate: Integer): Integer;
{ Частота IQ, которую реально можно отдать: наибольшая ЗАКОННАЯ по протоколу
(48/96/192/384 кГц), не выше запрошенной и делящая частоту источника нацело.
Нужна из-за частот дискретизации Pluto: 576 и 960 кГц на 384 не делятся, и
без этого выбора клиент, попросивший 384 кГц, получал бы поток на 576/480
и не по протоколу, и вчетверо толще, чем он ждёт. Если законной не нашлось
вовсе (чужой rate), возвращаем просьбу как есть: дальше её обработает
TCIDecimFactor, а настоящая частота уйдёт в заголовке блока. }
function TCIPickIQRate(SrcRate, WantRate: Integer): Integer;
{ Путь из LINE_OUT_RECORDER_SAVE в путь файловой системы: в протоколе ':'
запрещён и заменён на '|' (§4.3), слэши допускаются любые. }
function TCIRecordPath(const S: string): string;
implementation
{ ═══════════════════════════════════════════════════════════════════════════
Дециматор
═══════════════════════════════════════════════════════════════════════════ }
constructor TTCIDecimator.Create(AFactor: Integer);
var N: Integer;
begin
if AFactor < 1 then AFactor := 1;
N := TCI_FIR_PER_FACTOR * AFactor + 1;
CreateDesigned(AFactor, N, 0.45 / AFactor);
end;
constructor TTCIDecimator.CreateDesigned(AFactor, ATaps: Integer;
ACutoff: Double);
var
i, C: Integer;
X, W, Sum: Double;
begin
inherited Create;
if AFactor < 1 then AFactor := 1;
FFactor := AFactor;
FN := ATaps;
if FN < 9 then FN := 9;
if FN > TCI_FIR_MAX_TAPS then FN := TCI_FIR_MAX_TAPS;
if (FN and 1) = 0 then Inc(FN); // нечётная длина: линейная фаза и
// целая задержка
FHalf := FN div 2;
SetLength(FTaps, FN);
SetLength(FHist, FN);
// Окно Блэкмана поверх sinc. Оно, в отличие от Хэмминга, даёт −74 дБ вместо
// −53 в полосе задержания, а платим за это только длиной — и как раз длину
// многоступенчатая схема экономит (см. TTCIDecimChain).
C := FN div 2;
Sum := 0;
for i := 0 to FN - 1 do
begin
X := i - C;
if Abs(X) < 1E-9 then W := 2 * ACutoff
else W := Sin(2 * Pi * ACutoff * X) / (Pi * X);
W := W * (0.42 - 0.5 * Cos(2 * Pi * i / (FN - 1))
+ 0.08 * Cos(4 * Pi * i / (FN - 1)));
FTaps[i] := W;
Sum := Sum + W;
end;
// Нормировка по единичному усилению на постоянном токе: без неё уровень
// аудио гулял бы на доли дБ от коэффициента прореживания.
if Abs(Sum) > 1E-12 then
for i := 0 to FN - 1 do FTaps[i] := FTaps[i] / Sum;
Reset;
end;
procedure TTCIDecimator.Reset;
var i: Integer;
begin
for i := 0 to FN - 1 do FHist[i] := 0;
FPos := 0;
FPhase := 0;
end;
function TTCIDecimator.Process(const Src: array of Single; N: Integer;
var Dst: array of Single): Integer;
var
i, k, t, a, b: Integer;
Acc: Double;
begin
Result := 0;
if N > Length(Src) then N := Length(Src);
if FFactor = 1 then
begin
// Прореживать нечего — фильтр в этом случае только съел бы верх полосы.
k := N;
if k > Length(Dst) then k := Length(Dst);
for i := 0 to k - 1 do Dst[i] := Src[i];
Exit(k);
end;
k := 0;
for i := 0 to N - 1 do
begin
FHist[FPos] := Src[i];
Inc(FPos);
if FPos >= FN then FPos := 0;
Inc(FPhase);
if FPhase < FFactor then Continue;
FPhase := 0;
if k >= Length(Dst) then Break; // переполнение приёмника — молча режем
// Свёртка со сложением симметричных пар: фильтр линейнофазовый, значит
// FTaps[t] = FTaps[N-1-t], и умножений вдвое меньше при том же результате.
// На 5.76 МГц (верхний rate Pluto) это разница между 16% и 10% ядра.
Acc := 0;
a := FPos; // самый старый отсчёт (это отвод N-1)
b := FPos + FN - 1; // самый свежий (это отвод 0)
if b >= FN then Dec(b, FN);
for t := 0 to FHalf - 1 do
begin
Acc := Acc + FTaps[t] * (FHist[a] + FHist[b]);
Inc(a); if a >= FN then a := 0;
Dec(b); if b < 0 then b := FN - 1;
end;
Dst[k] := Acc + FTaps[FHalf] * FHist[a]; // центральный отвод
Inc(k);
end;
Result := k;
end;
{ ═══════════════════════════════════════════════════════════════════════════
Каскад дециматоров
═══════════════════════════════════════════════════════════════════════════ }
constructor TTCIDecimChain.Create(AFactor: Integer);
var
Rest, F, i, N: Integer;
Cur, FPass, FStop: Double;
Fac: array of Integer;
procedure AddFactor(V: Integer);
begin
SetLength(Fac, Length(Fac) + 1);
Fac[High(Fac)] := V;
end;
begin
inherited Create;
if AFactor < 1 then AFactor := 1;
FFactor := AFactor;
Rest := AFactor;
// Раскладываем на простые. Всё, что не разложилось (простое число больше
// семи — у частот дискретизации не встречается, но бывает у чужого железа),
// остаётся одной ступенью: она хотя бы не хуже прежнего поведения.
for F in [2, 3, 5, 7] do
while (Rest mod F = 0) and (Rest > 1) do
begin
AddFactor(F);
Rest := Rest div F;
end;
if Rest > 1 then AddFactor(Rest);
// По убыванию: первая ступень самая «дорогая», и после неё частота, на
// которой работают остальные, уже сбита.
for i := 0 to High(Fac) - 1 do
for F := 0 to High(Fac) - 1 - i do
if Fac[F] < Fac[F + 1] then
begin
Rest := Fac[F];
Fac[F] := Fac[F + 1];
Fac[F + 1] := Rest;
end;
// Спецификация фильтра каждой ступени считается от ИТОГОВОЙ полосы, а не от
// её собственной. Ранняя ступень отдаёт наверх широкий поток, и всё, что она
// обязана убрать, — те узкие зоны, которые в конце сложатся в полезную
// полосу; переходная полоса у неё получается в десятки раз шире, а значит и
// фильтр во столько же раз короче. Ради этого многоступенчатую схему и
// делают: на 5760→48 кГц первая ступень обходится 30 отводами вместо 257,
// которых всё равно не хватало.
SetLength(FStages, Length(Fac));
Cur := 1.0; // доля от исходной частоты на входе ступени
for i := 0 to High(Fac) do
begin
// Полоса, которую обязаны сохранить, — 0.45 от итоговой Найквиста,
// в единицах ВХОДНОЙ частоты этой ступени.
FPass := (0.45 * 0.5 / FFactor) / Cur;
FStop := (1.0 / Fac[i]) - FPass; // сюда сложится всё лишнее
if FStop <= FPass * 1.05 then // последняя ступень: запаса уже нет
begin
FPass := 0.45 / Fac[i];
FStop := 0.55 / Fac[i];
end;
// Ширина переходной полосы ↔ длина окна Блэкмана: N ≈ 5.5/Δf.
N := Ceil(5.5 / (FStop - FPass)) + 1;
FStages[i] := TTCIDecimator.CreateDesigned(Fac[i], N,
(FPass + FStop) * 0.5);
Cur := Cur / Fac[i];
end;
SetLength(FTmp[0], TCI_FEED_CHUNK);
SetLength(FTmp[1], TCI_FEED_CHUNK);
end;
destructor TTCIDecimChain.Destroy;
var i: Integer;
begin
for i := 0 to High(FStages) do FStages[i].Free;
inherited;
end;
procedure TTCIDecimChain.Reset;
var i: Integer;
begin
for i := 0 to High(FStages) do FStages[i].Reset;
end;
function TTCIDecimChain.Process(const Src: array of Single; N: Integer;
var Dst: array of Single): Integer;
var
i, Cur: Integer;
begin
if N > Length(Src) then N := Length(Src);
if Length(FStages) = 0 then
begin
if N > Length(Dst) then N := Length(Dst);
for i := 0 to N - 1 do Dst[i] := Src[i];
Exit(N);
end;
if Length(FStages) = 1 then
Exit(FStages[0].Process(Src, N, Dst));
// Пинг-понг между двумя буферами; последняя ступень пишет сразу в Dst.
Cur := 0;
N := FStages[0].Process(Src, N, FTmp[0]);
for i := 1 to High(FStages) - 1 do
begin
N := FStages[i].Process(FTmp[Cur], N, FTmp[1 - Cur]);
Cur := 1 - Cur;
end;
Result := FStages[High(FStages)].Process(FTmp[Cur], N, Dst);
end;
{ ═══════════════════════════════════════════════════════════════════════════
Интерполятор (TX-аудио клиента 48 кГц тракта)
═══════════════════════════════════════════════════════════════════════════ }
constructor TTCIInterpolator.Create(AFactor: Integer);
begin
inherited Create;
if AFactor < 1 then AFactor := 1;
FFactor := AFactor;
Reset;
end;
procedure TTCIInterpolator.Reset;
begin
FPrev := 0;
FHas := False;
end;
function TTCIInterpolator.Process(const Src: array of Single; N: Integer;
var Dst: array of Double): Integer;
var
i, p, k: Integer;
A, B: Single;
begin
if N > Length(Src) then N := Length(Src);
k := 0;
if FFactor = 1 then
begin
for i := 0 to N - 1 do
begin
if k >= Length(Dst) then Break;
Dst[k] := Src[i];
Inc(k);
end;
Exit(k);
end;
for i := 0 to N - 1 do
begin
B := Src[i];
if FHas then A := FPrev else A := B; // самый первый блок: без скачка от нуля
for p := 0 to FFactor - 1 do
begin
if k >= Length(Dst) then Break;
Dst[k] := A + (B - A) * (p / FFactor);
Inc(k);
end;
FPrev := B;
FHas := True;
end;
Result := k;
end;
{ ═══════════════════════════════════════════════════════════════════════════
Исходящий поток
═══════════════════════════════════════════════════════════════════════════ }
constructor TTCIStreamOut.Create(AClient: TTCIClient; AKind: TTCIStreamType;
ARx, ASrcRate, AWantRate, AChannels: Integer; ASampleT: TTCISampleType;
ABlock: Integer);
var
F, MaxB, i: Integer;
begin
inherited Create;
FClient := AClient;
FKind := AKind;
FRx := ARx;
FSrcRate := ASrcRate;
FWantRate := AWantRate;
FChannels := EnsureRange(AChannels, 1, 2);
FSampleT := ASampleT;
if FKind = tstIQ then AWantRate := TCIPickIQRate(ASrcRate, AWantRate);
F := TCIDecimFactor(ASrcRate, AWantRate);
FOutRate := ASrcRate div F;
// Блок не имеет права вылезти за data[16384]: столько ExpertSDR3 объявил
// потолком, и клиенты держат приёмный буфер ровно под него.
MaxB := TCIMaxBlockSamples(FSampleT, FChannels);
FBlock := EnsureRange(ABlock, 1, MaxB);
for i := 0 to FChannels - 1 do
begin
FDec[i] := TTCIDecimChain.Create(F);
SetLength(FTmp[i], TCI_FEED_CHUNK);
end;
SetLength(FIn, TCI_FEED_CHUNK);
SetLength(FAcc, FBlock * FChannels);
SetLength(FPacked, FBlock * FChannels * TCISampleBytes(FSampleT));
FAccCount := 0;
end;
destructor TTCIStreamOut.Destroy;
var i: Integer;
begin
for i := 0 to 1 do FreeAndNil(FDec[i]);
inherited;
end;
function TTCIStreamOut.Matches(AClient: TTCIClient; AKind: TTCIStreamType;
ARx: Integer): Boolean;
begin
Result := (FClient = AClient) and (FKind = AKind) and (FRx = ARx);
end;
procedure TTCIStreamOut.SetSourceRate(ANewRate: Integer);
var i, F, W: Integer;
begin
if (ANewRate <= 0) or (ANewRate = FSrcRate) then Exit;
FSrcRate := ANewRate;
// Просьбу клиента храним как есть, а законную частоту пересчитываем: у
// нового источника делители другие (576 кГц Pluto не делится на 384).
if FKind = tstIQ then W := TCIPickIQRate(FSrcRate, FWantRate)
else W := FWantRate;
F := TCIDecimFactor(FSrcRate, W);
FOutRate := FSrcRate div F;
for i := 0 to FChannels - 1 do
begin
FDec[i].Free;
FDec[i] := TTCIDecimChain.Create(F);
end;
FAccCount := 0;
end;
procedure TTCIStreamOut.EmitFull;
var
H: TTCIStreamHeader;
Bytes: Integer;
begin
TCIFillHeader(H, FKind, FRx, FOutRate, FSampleT, FBlock, FChannels);
Bytes := TCIPackSamples(FAcc, FBlock * FChannels, FSampleT, @FPacked[0]);
FClient.SendBin(H, @FPacked[0], Bytes);
FAccCount := 0;
end;
procedure TTCIStreamOut.PushPair(A, B: Single);
begin
if FChannels = 1 then
FAcc[FAccCount] := A
else
begin
FAcc[FAccCount * 2] := A;
FAcc[FAccCount * 2 + 1] := B;
end;
Inc(FAccCount);
if FAccCount >= FBlock then EmitFull;
end;
procedure TTCIStreamOut.FeedAudio(const L, R: array of Single; N: Integer);
var
i, n0, n1, Chunk, Off: Integer;
begin
if (N <= 0) or (FClient = nil) then Exit;
if N > Length(L) then N := Length(L);
if N > Length(R) then N := Length(R);
Off := 0;
while Off < N do
begin
// Кусками по TCI_FEED_CHUNK: движок вправе отдать блок любой длины, а
// рабочие буферы у нас фиксированные (см. константу).
Chunk := N - Off;
if Chunk > TCI_FEED_CHUNK then Chunk := TCI_FEED_CHUNK;
if FChannels = 1 then
begin
for i := 0 to Chunk - 1 do FIn[i] := (L[Off + i] + R[Off + i]) * 0.5;
n0 := FDec[0].Process(FIn, Chunk, FTmp[0]);
for i := 0 to n0 - 1 do PushPair(FTmp[0][i], 0);
end
else
begin
for i := 0 to Chunk - 1 do FIn[i] := L[Off + i];
n0 := FDec[0].Process(FIn, Chunk, FTmp[0]);
for i := 0 to Chunk - 1 do FIn[i] := R[Off + i];
n1 := FDec[1].Process(FIn, Chunk, FTmp[1]);
if n1 < n0 then n0 := n1;
for i := 0 to n0 - 1 do PushPair(FTmp[0][i], FTmp[1][i]);
end;
Inc(Off, Chunk);
end;
end;
procedure TTCIStreamOut.FeedIQ(PI_, PQ_: PDouble; N: Integer);
var
i, n0, n1, Chunk, Off: Integer;
SI, SQ: PDouble;
begin
if (N <= 0) or (FClient = nil) or (PI_ = nil) or (PQ_ = nil) then Exit;
Off := 0;
while Off < N do
begin
Chunk := N - Off;
if Chunk > TCI_FEED_CHUNK then Chunk := TCI_FEED_CHUNK;
SI := PI_; Inc(SI, Off);
for i := 0 to Chunk - 1 do begin FIn[i] := SI^; Inc(SI); end;
n0 := FDec[0].Process(FIn, Chunk, FTmp[0]);
if FChannels >= 2 then
begin
// Q считаем ТЕМ ЖЕ проходом, что и I: разошедшиеся по длине выходы
// означали бы сдвиг фазы между каналами, то есть поворот спектра.
SQ := PQ_; Inc(SQ, Off);
for i := 0 to Chunk - 1 do begin FIn[i] := SQ^; Inc(SQ); end;
n1 := FDec[1].Process(FIn, Chunk, FTmp[1]);
if n1 < n0 then n0 := n1;
for i := 0 to n0 - 1 do PushPair(FTmp[0][i], FTmp[1][i]);
end
else
for i := 0 to n0 - 1 do PushPair(FTmp[0][i], 0);
Inc(Off, Chunk);
end;
end;
{ ═══════════════════════════════════════════════════════════════════════════
Рекордер линейного выхода
═══════════════════════════════════════════════════════════════════════════ }
constructor TTCIRecorder.Create(ARx, ARateHz, AMaxSec: Integer);
begin
inherited Create;
FLock := TCriticalSection.Create;
FRx := ARx;
FRate := ARateHz;
if AMaxSec < 1 then AMaxSec := 1;
if AMaxSec > TCI_RECORD_MAX_SEC then AMaxSec := TCI_RECORD_MAX_SEC;
FCap := ARateHz * AMaxSec;
SetLength(FRing, FCap * 2);
FCount := 0;
FHead := 0;
end;
destructor TTCIRecorder.Destroy;
begin
FLock.Free;
inherited;
end;
procedure TTCIRecorder.Feed(const L, R: array of Single; N: Integer);
// DSP-поток. Кольцо: по исчерпании ёмкости затирается самое старое — запись
// «последние N секунд» именно так и работает (§4.3: по истечении времени
// накопленное пропадает, если клиент не сохранил).
var
i: Integer;
A, B: Single;
begin
if (N <= 0) or (FCap <= 0) then Exit;
if N > Length(L) then N := Length(L);
if N > Length(R) then N := Length(R);
FLock.Enter;
try
for i := 0 to N - 1 do
begin
A := L[i]; B := R[i];
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;
FRing[FHead * 2] := Round(A * 32767);
FRing[FHead * 2 + 1] := Round(B * 32767);
Inc(FHead);
if FHead >= FCap then FHead := 0;
if FCount < FCap then Inc(FCount);
end;
finally
FLock.Leave;
end;
end;
function TTCIRecorder.Take: TTCIPcm;
var
Start, i, n: Integer;
begin
Result := nil;
FLock.Enter;
try
if FCount <= 0 then Exit;
SetLength(Result, FCount * 2);
Start := FHead - FCount;
if Start < 0 then Inc(Start, FCap);
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
FLock.Leave;
end;
end;
{ ═══════════════════════════════════════════════════════════════════════════
WAV
═══════════════════════════════════════════════════════════════════════════ }
constructor TTCIWavWriter.Create(const APath: string;
const AData: TTCIPcm; ARateHz: Integer);
begin
inherited Create(True);
FreeOnTerminate := True;
FPath := APath;
FData := AData;
FRate := ARateHz;
Start;
end;
procedure TTCIWavWriter.Execute;
// Заголовок собираем в буфере: WAV — это фиксированные 44 байта, и городить
// два десятка отдельных Write ради них незачем (а строковые литералы в
// нетипизированный Write в FPC ещё и передаются не тем, чем кажется).
var
FS: TFileStream;
Hdr: array[0..43] of Byte;
DataBytes: LongWord;
procedure PutTag(Ofs: Integer; const Tag: string);
var i: Integer;
begin
for i := 1 to Length(Tag) do Hdr[Ofs + i - 1] := Byte(Tag[i]);
end;
procedure PutU32(Ofs: Integer; V: LongWord);
begin
Hdr[Ofs] := Byte(V); Hdr[Ofs + 1] := Byte(V shr 8);
Hdr[Ofs + 2] := Byte(V shr 16); Hdr[Ofs + 3] := Byte(V shr 24);
end;
procedure PutU16(Ofs: Integer; V: Word);
begin
Hdr[Ofs] := Byte(V); Hdr[Ofs + 1] := Byte(V shr 8);
end;
begin
try
DataBytes := LongWord(Length(FData) * SizeOf(SmallInt));
FillChar(Hdr, SizeOf(Hdr), 0);
PutTag(0, 'RIFF');
PutU32(4, 36 + DataBytes);
PutTag(8, 'WAVE');
PutTag(12, 'fmt ');
PutU32(16, 16); // размер fmt-блока
PutU16(20, 1); // PCM
PutU16(22, 2); // каналов
PutU32(24, LongWord(FRate));
PutU32(28, LongWord(FRate) * 2 * 2); // байт в секунду
PutU16(32, 4); // выравнивание блока
PutU16(34, 16); // бит на сэмпл
PutTag(36, 'data');
PutU32(40, DataBytes);
FS := TFileStream.Create(FPath, fmCreate);
try
FS.Write(Hdr[0], SizeOf(Hdr));
if DataBytes > 0 then FS.Write(FData[0], DataBytes);
finally
FS.Free;
end;
except
// Записать не вышло (нет прав, нет каталога, диск полон) — сказать об этом
// клиенту уже некому: команда давно подтверждена. Молчим, но и не падаем:
// исключение из потока утащило бы за собой процесс.
end;
FData := nil;
end;
{ ═══════════════════════════════════════════════════════════════════════════
Утилиты
═══════════════════════════════════════════════════════════════════════════ }
function TCIDecimFactor(SrcRate, WantRate: Integer): Integer;
var F: Integer;
begin
Result := 1;
if (SrcRate <= 0) or (WantRate <= 0) or (WantRate >= SrcRate) then Exit;
// Идём от большего прореживания к меньшему и берём первое, которое делит
// входную частоту нацело и не опускает нас ниже запрошенной.
F := SrcRate div WantRate;
while F > 1 do
begin
if (SrcRate mod F = 0) and (SrcRate div F >= WantRate) then Exit(F);
Dec(F);
end;
end;
function TCIPickIQRate(SrcRate, WantRate: Integer): Integer;
const
LEGAL: array[0..3] of Integer = (384000, 192000, 96000, 48000);
var i: Integer;
begin
Result := WantRate;
if (SrcRate <= 0) or (WantRate <= 0) then Exit;
for i := 0 to High(LEGAL) do
if (LEGAL[i] <= WantRate) and (LEGAL[i] <= SrcRate) and
(SrcRate mod LEGAL[i] = 0) then
Exit(LEGAL[i]);
end;
function TCIRecordPath(const S: string): string;
var i: Integer;
begin
Result := S;
for i := 1 to Length(Result) do
if Result[i] = '|' then Result[i] := ':';
{$IFDEF WINDOWS}
for i := 1 to Length(Result) do
if Result[i] = '/' then Result[i] := '\';
{$ELSE}
for i := 1 to Length(Result) do
if Result[i] = '\' then Result[i] := '/';
{$ENDIF}
end;
end.
+31
View File
@@ -203,6 +203,14 @@ type
TOnTXIQReady = procedure(const Buf: array of Double; Count: Integer) of object; TOnTXIQReady = procedure(const Buf: array of Double; Count: Integer) of object;
TOnWaterfallReady = procedure(const Pixels: array of Single; TOnWaterfallReady = procedure(const Pixels: array of Single;
Count: Integer) of object; Count: Integer) of object;
// Тап сырого RX-IQ (потоки IQ по TCI, §3.4). Зовётся из DSP-потока ОДИН РАЗ
// на накопленный блок — не на сэмпл: сэмплов тут до 384 тысяч в секунду на
// каждый пан, и вызов метода на каждый стоил бы дороже самой работы.
// PanId: 0 = главный тракт, 1.. = доп. пан. I/Q — два массива по N отсчётов,
// живущих только на время вызова.
TIQTapEvent = procedure(PanId: Integer; PI_, PQ_: PDouble;
N, RateHz: Integer) of object;
// Аудио готового слайса — вызывается из DSP-потока по каждому активному слайсу. // Аудио готового слайса — вызывается из DSP-потока по каждому активному слайсу.
// SliceId — логический id (из RadioController), не WDSP-канал. // SliceId — логический id (из RadioController), не WDSP-канал.
TOnSliceAudio = procedure(SliceId: Integer; const Left, Right: array of Single; TOnSliceAudio = procedure(SliceId: Integer; const Left, Right: array of Single;
@@ -515,6 +523,7 @@ type
FVolume: Double; FVolume: Double;
FLastError: string; FLastError: string;
FBeaconDec: TBeaconDecoder; // не владеет; тап маяка в PushIQItemToDSP FBeaconDec: TBeaconDecoder; // не владеет; тап маяка в PushIQItemToDSP
FIQTap: TIQTapEvent; // тап сырого IQ наружу (TCI), не владеет
function ModeToWDSP(Mode: Integer): Integer; function ModeToWDSP(Mode: Integer): Integer;
function ModeToWDSPTX(Mode: Integer): Integer; function ModeToWDSPTX(Mode: Integer): Integer;
@@ -626,6 +635,10 @@ type
// QO-100 beacon-декодер: тап RX-IQ для демодуляции маяка (не владеет). // QO-100 beacon-декодер: тап RX-IQ для демодуляции маяка (не владеет).
procedure SetBeaconDecoder(D: TBeaconDecoder); procedure SetBeaconDecoder(D: TBeaconDecoder);
// Тап сырого RX-IQ наружу (потоки IQ по TCI). nil — снять. Ставит и
// снимает поток контроллера; вызывается тап из DSP-потока.
procedure SetIQTap(T: TIQTapEvent);
// --- Панадаптеры на аппаратных DDC (этап 3.2) --- // --- Панадаптеры на аппаратных DDC (этап 3.2) ---
// Создаёт доп. пан PanId (1..MAX_PANS-1): аккумулятор + analyzer // Создаёт доп. пан PanId (1..MAX_PANS-1): аккумулятор + analyzer
// PAN_DISP_BASE+PanId. RateHz — rate его DDC, кратен FAudioRate. // PAN_DISP_BASE+PanId. RateHz — rate его DDC, кратен FAudioRate.
@@ -2410,6 +2423,11 @@ begin
if (D <> nil) and (FSampleRate > 0) then D.Configure(FSampleRate); if (D <> nil) and (FSampleRate > 0) then D.Configure(FSampleRate);
end; end;
procedure TWDSPEngine.SetIQTap(T: TIQTapEvent);
begin
FIQTap := T;
end;
procedure TWDSPEngine.PushDDCPacket(const Buf: array of Byte; procedure TWDSPEngine.PushDDCPacket(const Buf: array of Byte;
DataOffset: Integer; IQPairs: Integer); DataOffset: Integer; IQPairs: Integer);
// Вызывается из СЕТЕВОГО потока — только кладём в очередь и возвращаемся немедленно // Вызывается из СЕТЕВОГО потока — только кладём в очередь и возвращаемся немедленно
@@ -2522,6 +2540,12 @@ begin
if Pan^.AccPos >= Pan^.BufSize then if Pan^.AccPos >= Pan^.BufSize then
begin begin
Pan^.AccPos := 0; Pan^.AccPos := 0;
// Тап IQ пана — как у главного тракта, один вызов на блок. Он идёт
// ПОД FSliceLock (весь разбор пакета пана здесь), поэтому обработчик
// обязан только скопировать данные и вернуться: любое ожидание тут
// остановит DSP-поток вместе со всеми панами.
if Assigned(FIQTap) then
FIQTap(PanId, @Pan^.AccI[0], @Pan^.AccQ[0], Pan^.BufSize, Pan^.Rate);
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);
@@ -2638,8 +2662,15 @@ begin
Inc(FRXAccPos); Inc(FRXAccPos);
if FRXAccPos >= FBufSize then if FRXAccPos >= FBufSize then
begin
// Тап IQ наружу — по накопленному блоку и ДО обработки: fexchange0
// забирает аккумулятор как вход и не портит его, но полагаться на это
// незачем, а один вызов на блок вместо вызова на сэмпл экономит всё.
if Assigned(FIQTap) then
FIQTap(0, @FRXAccI[0], @FRXAccQ[0], FBufSize, FSampleRate);
ProcessRXBlock; ProcessRXBlock;
end; end;
end;
end; end;
procedure TWDSPEngine.FeedTXDisplaySample(const I, Q: Double); procedure TWDSPEngine.FeedTXDisplaySample(const I, Q: Double);
+18 -1
View File
@@ -22,6 +22,14 @@ uses
SyncObjs, WebUtils SyncObjs, WebUtils
{$IFDEF WINDOWS}, WinSock2{$ELSE}, Sockets{$ENDIF}; {$IFDEF WINDOWS}, WinSock2{$ELSE}, Sockets{$ENDIF};
const
{ Приёмный буфер соединения. 4 КБ хватало командам и web-запросам, но блок
бинарного потока TCI (§3.4) это заголовок 64 байта плюс data[16384],
и кадр крупнее буфера не собирается НИКОГДА: BufLen упирается в потолок и
разбор встаёт. Поэтому потолок держим с запасом на кадр целиком вместе с
маской и хвостом соседнего сообщения. }
WS_BUF_SIZE = 32768;
type type
TWsState = (wsHandshake, wsOpen, wsClosed); TWsState = (wsHandshake, wsOpen, wsClosed);
@@ -30,7 +38,7 @@ type
FSocket: TSocket; FSocket: TSocket;
FState: TWsState; FState: TWsState;
FLock: TCriticalSection; FLock: TCriticalSection;
FBuf: array[0..4095] of Byte; FBuf: array[0..WS_BUF_SIZE-1] of Byte;
FBufLen: Integer; FBufLen: Integer;
FAuthed: Boolean; FAuthed: Boolean;
public public
@@ -55,6 +63,10 @@ type
{ Указатель на начало буфера приёма } { Указатель на начало буфера приёма }
function BufData: PByte; inline; function BufData: PByte; inline;
{ Ёмкость приёмного буфера: разбору фреймов нужен потолок, чтобы вовремя
закрыть соединение, а не встать намертво на несобираемом кадре. }
function BufCapacity: Integer; inline;
property Socket: TSocket read FSocket; property Socket: TSocket read FSocket;
property State: TWsState read FState write FState; property State: TWsState read FState write FState;
property Authed: Boolean read FAuthed write FAuthed; property Authed: Boolean read FAuthed write FAuthed;
@@ -175,4 +187,9 @@ begin
Result := @FBuf[0]; Result := @FBuf[0];
end; end;
function TWsClient.BufCapacity: Integer;
begin
Result := SizeOf(FBuf);
end;
end. end.
+194 -33
View File
@@ -1,7 +1,7 @@
# TCI в EWSDR — статус реализации # TCI в EWSDR — статус реализации
Ветка разработки: `feature/tci-protocol`. Ветка разработки: `feature/tci-protocol`.
Дата последнего обновления: 2026-08-17. Дата последнего обновления: 2026-08-18.
Эталон протокола — «Протокол TCI, версия 2.0» Expert Electronics Эталон протокола — «Протокол TCI, версия 2.0» Expert Electronics
(`doc/TCI Protocol_RU.pdf`, 12 января 2024). EWSDR выступает **сервером** (`doc/TCI Protocol_RU.pdf`, 12 января 2024). EWSDR выступает **сервером**
@@ -22,7 +22,8 @@ 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` (~2100 строк) | Мост к `TRadioController`: реализация команд, пачка инициализации, уведомления об изменениях состояния, измерители, захват параметров (§3.5). | | `TCIAdapter.pas` (~2600 строк) | Мост к `TRadioController`: реализация команд, пачка инициализации, уведомления об изменениях состояния, измерители, захват параметров (§3.5), подключение потоков к тапам аудио/IQ. |
| `TCIStreams.pas` (~600 строк) | Бинарные потоки (§3.4): дециматор/интерполятор, нарезка блоков с заголовком, кольцо записи линейного выхода и писатель WAV. Зависит только от RTL + `TCIProtocol`/`TCIServer`. |
Принципы те же, что у CAT (см. `doc/CAT_STATUS.md`): Принципы те же, что у CAT (см. `doc/CAT_STATUS.md`):
@@ -103,6 +104,15 @@ TCI-клиенты ──WebSocket──► TTCIServer ──► TTCIAdapter ─
до `TRX`, `TUNE` и `VFO`. Своей web-странице нужен явный прокси, а не дыра до `TRX`, `TUNE` и `VFO`. Своей web-странице нужен явный прокси, а не дыра
по умолчанию. по умолчанию.
6. **Блоки потоков нарезает и раскладывает DSP-поток.** Тап зовётся прямо из
потока WDSP, поэтому в `TCIStreams` нет ни одного ожидания: блок уходит в
кольцо клиента (микросекунды под его локом), а в сокет его пишет, как и
команды, поток самого клиента. Порядок локов везде один: `FSliceLock`
`FStreamLock` (тап сначала выясняет, чей это слайс, и только потом ищет
подписчиков) — обратный дал бы клин с потоком контроллера. Тапы навешивает
и снимает **только поток контроллера**: снятие обязано дождаться выхода
DSP-потока из вызова, иначе тот позвал бы метод освобождённого адаптера.
Разбор HTTP — построчный (`TCIHttpHeader`), а не поиском подстроки Разбор HTTP — построчный (`TCIHttpHeader`), а не поиском подстроки
«`upgrade: websocket`»: заголовок с табуляцией или без пробела после «`upgrade: websocket`»: заголовок с табуляцией или без пробела после
двоеточия валиден. Close-кадр подтверждается ответным close с тем же кодом двоеточия валиден. Close-кадр подтверждается ответным close с тем же кодом
@@ -119,7 +129,9 @@ TCI-клиенты ──WebSocket──► TTCIServer ──► TTCIAdapter ─
"tci": { "enabled": false, "port": 40001, "bind_addr": "127.0.0.1" } "tci": { "enabled": false, "port": 40001, "bind_addr": "127.0.0.1" }
``` ```
UI — вкладка **Advanced → TCI Server** (галка, порт, интерфейс). В протоколе UI — вкладка **CAT → TCI Server**, справа от «TCP CAT Server» (галка, порт,
интерфейс): TCI — такой же канал внешнего управления трансивером, что и CAT,
и оператор ищет его там, а не в «Advanced». В протоколе
**нет авторизации**: открытый наружу порт означает полный доступ к трансиверу, **нет авторизации**: открытый наружу порт означает полный доступ к трансиверу,
поэтому умолчание слушает только петлю. поэтому умолчание слушает только петлю.
@@ -340,7 +352,113 @@ split, `rfMonVolume` для громкости самоконтроля и но
ручные `PushSliceFlagState`/`LayoutFlags` в MainForm) убраны: теперь один ручные `PushSliceFlagState`/`LayoutFlags` в MainForm) убраны: теперь один
путь на всех — мышь, колесо, CAT, TCI, бэнд-логика. путь на всех — мышь, колесо, CAT, TCI, бэнд-логика.
### 2.5 Телеграф (§3.2) ### 2.5 Бинарные потоки (§3.4)
Реализованы все четыре типа: `IQ_STREAM`, `RX_AUDIO_STREAM`, `LINEOUT_STREAM`
(наружу) и `TX_AUDIO_STREAM` + `TX_CHRONO` (внутрь), плюс запись линейного
выхода в файл. Команды: `IQ_START/STOP`, `AUDIO_START/STOP`,
`LINE_OUT_START/STOP`, `LINE_OUT_RECORDER_START/SAVE/BREAK` и параметры
`IQ_SAMPLERATE`, `AUDIO_SAMPLERATE`, `AUDIO_STREAM_SAMPLES/CHANNELS/SAMPLE_TYPE`,
`TX_STREAM_AUDIO_BUFFERING`.
**Откуда берётся звук.** Протокол различает «аудиопоток приёмника» и «поток
линейного выхода», и это не одно и то же:
| Поток TCI | Точка в ewsdr | Что это значит |
|---|---|---|
| `RX_AUDIO_STREAM` | `OnDemodAudioReady` (тап `rakDemod`) | выход демодулятора **до** громкости и мьюта, без сайдтона — то, что нужно скиммеру и цифре |
| `LINEOUT_STREAM` | `OnAudioReady` (тап `rakLineOut`) | ровно то, что слышно: после громкости, с сайдтоном; MUTE его глушит (движок не зовёт `OnAudio` под мьютом) |
| `IQ_STREAM` | `TWDSPEngine.PushIQItemToDSP` / `PushPanItemToDSP` | сырой IQ приёмника, один вызов тапа на накопленный блок |
Тап — многоадресный и **ничего не забирает**, в отличие от `OnAudioConsume`
(им владеет web-адаптер и им же глушит локальный звук). Иначе первый же
TCI-клиент отобрал бы звук у динамика и у браузера.
Приёмник в потоках — панадаптер, как и везде (§1.3). У доп. пана в потоках
участвует только **канал A** (первый слайс): «аудиопоток приёмника» в протоколе
один на приёмник, отдельного потока канала B нет.
**Пересчёт частоты вниз — многоступенчатый.** Аудио 48 кГц → 8/12/24 и IQ
до 48/96/192/384 кГц считает `TCIStreams`. Коэффициент раскладывается на
множители, и каждая ступень фильтрует своё: одной ступенью большие
коэффициенты не берутся, потому что длина FIR растёт вместе с коэффициентом и
упирается в потолок. ★Это не теория: на верхнем пресете Pluto (5760 кГц,
коэффициент 120 к 48 кГц) одноступенчатый дециматор давал **завал 1.3 дБ в
полосе и подавление зеркала всего 16 дБ**, то есть поток IQ с мусором.
Спецификацию фильтра каждой ступени задаёт **итоговая** полоса, а не её
собственная: ранняя ступень обязана убрать лишь те узкие зоны, которые в конце
сложатся в полезную полосу, поэтому её переходная полоса шире в десятки раз, а
фильтр во столько же короче. Свёртка идёт со сложением симметричных пар
(фильтр линейнофазовый). Замеры после переделки: −83 дБ по зеркалу на **любом**
коэффициенте, полоса ровная, цена ≈10% ядра на потоке 5.76 МГц (было 20% —
и с мусором) и ≈1% на 384 кГц.
Вверх (TX-аудио клиента → 48 кГц тракта) — линейная интерполяция: её образы
лежат за полосой TX-фильтра, а завал в полосе 0.2 дБ на 3 кГц.
**Ответ на `IQ_SAMPLERATE` называет достижимое, а не запрошенное.** Просьбу
клиента храним как есть (на другом устройстве она может стать выполнимой), а в
ответ отдаём то, что он реально получит на главном приёмнике. Подтвердить
«384000» и слать 192 кГц значило бы соврать в единственном месте, куда клиент
и смотрит. Из-за этого же `iq_samplerate` **переобъявляется без запроса** при
смене частоты дискретизации и устройства (`rfSampleRate`, `rfDevice`,
`rfConnected`) — иначе клиент, попросивший 384 кГц на openHPSDR, после
перехода на Pluto 576 кГц молча получал бы 192. По той же логике устроен и
`IF_LIMITS`: §4.1 прямо требует высылать его «при подключении и изменении
частоты дискретизации устройства», и он уходит на `rfSampleRate`.
**Какая частота IQ достанется клиенту.** Набор протокола (48/96/192/384 кГц)
и частоты дискретизации железа сходятся не всегда. У openHPSDR (48…384 кГц)
сходятся все, а из пресетов Pluto (576/768/960/1536/2304/3072/3840/5760 кГц)
на 384 не делятся 576 и 960. Поэтому отдаём **наибольшую законную** частоту,
не выше запрошенной и делящую частоту источника нацело (`TCIPickIQRate`):
на 576 и 960 кГц просьба «384» превращается в 192 кГц. Иначе клиент,
попросивший 384, получал бы поток на 576/480 кГц — и не по протоколу, и вчетверо
толще ожидаемого. Настоящая частота всегда стоит в заголовке блока; если
законной не нашлось вовсе (чужой rate), уходит ближайшая достижимая — врать
в заголовке мы не будем ни в каком случае. Смена rate устройства или DDC пана
на ходу пересобирает прореживание (`SetSourceRate`).
**Размер блока.** `AUDIO_STREAM_SAMPLES` — это сэмплы **на канал**, а в
`Stream.length` уходит, как велит §3.4, количество вещественных отсчётов
(`samples × channels`). Сходится и с умолчаниями ExpertSDR3 (2048 при 48 кГц
даёт ~43 мс), и с потолком `data[16384]`: 2048 × 2 канала × float32 = ровно
16384 байта. Смена любого параметра потока на ходу **перезапускает** уже идущие
потоки этого клиента: блок с новой частотой посреди старого потока клиенты
разбирают как мусор.
**Передача (§3.4, §4.2).** `TRX:0,true,tci` берёт модуляцию из аудиопотока
клиента — но только если у него запущен `AUDIO_START` (буквально по документу:
«работает, если включен аудиопоток по TCI»). В контроллере это отдельный флаг
`TCIMicRequested`, который `SetMOX` читает при выборе микрофона, **впереди**
web-клиента: явная просьба сильнее умолчания «подключён браузер». Дальше тик
сервера гонит маркеры `TX_CHRONO` по часам (плюс разовая подушка
`TX_STREAM_AUDIO_BUFFERING` на старте передачи), а приходящие блоки
`TX_AUDIO_STREAM` разворачиваются в 48 кГц моно и кладутся в тот же ринг
микрофона, что и web-аудио. Аудио от клиента, который не просил `tci`,
отбрасывается молча: отвечать ошибкой на каждый чужой блок значит захлебнуться.
**Запись линейного выхода.** Рекордер один на приёмник (а не на клиента):
пишет он то, что слышно в аппарате. Кольцо на запрошенное время (потолок 300 с)
в int16 48 кГц стерео — это ровно то, что уйдёт в WAV, и вдвое меньше памяти,
чем float32. `SAVE` завершает запись и отдаёт кольцо отдельному потоку-писателю:
файл бывает в десятки мегабайт, а команда пришла в потоке клиента, который в
это время не читает свой сокет. MP3 не поддержан — кодера в проекте нет,
и на `.mp3` уходит честный `tci_error`.
**Потолок кадра.** Приёмный буфер соединения (`WsClient.WS_BUF_SIZE`) поднят
с 4 до 32 КБ: блок TX-аудио — это 64 байта заголовка плюс `data[16384]`, а
кадр крупнее буфера не собирается никогда (BufLen упирается в потолок и разбор
встаёт). Кадр длиннее одного блока рвёт соединение — длиннее протокол не
определяет.
**Переполнение.** У очереди команд и у кольца блоков разная политика: команду
терять нельзя (клиент выбрасывается), блок потока — можно и нужно (теряется
самый старый, `TTCIClient.BinDropped` считает). Иначе отставший скиммер рвал бы
себе и управление тоже.
### 2.6 Телеграф (§3.2)
`CW_MACROS`, `CW_MSG`, `CW_MACROS_STOP`, `CW_TERMINAL`. `CW_MACROS`, `CW_MSG`, `CW_MACROS_STOP`, `CW_TERMINAL`.
@@ -368,7 +486,7 @@ split, `rfMonVolume` для громкости самоконтроля и но
| `RX_NB_PARAM` | параметры NB в WDSP наружу не выведены | | `RX_NB_PARAM` | параметры NB в WDSP наружу не выведены |
| `RX_BALANCE` | баланса каналов у слайса нет | | `RX_BALANCE` | баланса каналов у слайса нет |
| `DIGL_OFFSET`, `DIGU_OFFSET` | смещения цифровых мод не реализованы | | `DIGL_OFFSET`, `DIGU_OFFSET` | смещения цифровых мод не реализованы |
| `RX_CHANNEL_ENABLE` | канал B главного приёмника — это VFO B, он есть всегда; создание второго слайса на пане по TCI — этап 2 | | `RX_CHANNEL_ENABLE` | канал B главного приёмника — это VFO B, он есть всегда; создание второго слайса на пане по TCI не делаем (см. §4) |
## 3.1 Ограничения, о которых честнее знать заранее ## 3.1 Ограничения, о которых честнее знать заранее
@@ -381,44 +499,39 @@ split, `rfMonVolume` для громкости самоконтроля и но
| Захват параметра (§3.5) | реализован для того, что клиенты действительно перетягивают (частота, DDS, мода, фильтр, TRX/TUNE/DRIVE, split, громкости, АРУ, шумодавы, squelch, скорость CW). Эхо-параметры (RIT/XIT, BIN/ANC/…) не захватываются: на радио они не влияют | | Захват параметра (§3.5) | реализован для того, что клиенты действительно перетягивают (частота, DDS, мода, фильтр, TRX/TUNE/DRIVE, split, громкости, АРУ, шумодавы, squelch, скорость CW). Эхо-параметры (RIT/XIT, BIN/ANC/…) не захватываются: на радио они не влияют |
| Браузерные клиенты | отвергаются по `Origin` (403), см. §1.1. Web-интерфейсу ewsdr TCI не нужен — у него свой канал | | Браузерные клиенты | отвергаются по `Origin` (403), см. §1.1. Web-интерфейсу ewsdr TCI не нужен — у него свой канал |
| `TRX_COUNT` | равен `BackendCaps.MaxPans`, а не числу живых панов: протокол объявляет его один раз. Про несуществующий приёмник просто ничего не шлётся (§2.1) | | `TRX_COUNT` | равен `BackendCaps.MaxPans`, а не числу живых панов: протокол объявляет его один раз. Про несуществующий приёмник просто ничего не шлётся (§2.1) |
| Потоки на передаче | RX-аудио и линейный выход на TX замолкают — движок не зовёт аудио-колбэки, пока идёт передача (кроме дуплекса с самоконтролем). То же и с IQ: RX-пакеты на TX дропаются на входе DSP. Это поведение приёмного тракта, а не потоков |
| `MUTE` и линейный выход | глушит и поток: движок под мьютом не зовёт `OnAudio`. Аудиопоток приёмника (`AUDIO_START`) мьют не трогает — он снимается до громкости |
| DIGL/DIGU, 2 канала | по §3.4 в цифровых модах два канала должны нести комплексный сигнал; у нас это обычное стерео с выхода WDSP. Комплексный вывод демодулятора наружу не выведен |
| Поток канала B доп. пана | не бывает: в протоколе аудиопоток один на приёмник, и мы отдаём канал A (первый слайс) |
| Формат IQ | всегда float32, два канала — `AUDIO_STREAM_SAMPLE_TYPE` относится к аудио (§4.3), а ExpertSDR3 IQ иначе и не шлёт |
| `IQ_SAMPLERATE` 384 кГц на Pluto 576/960 кГц | нацело не делится, поэтому уходит 192 кГц (см. §2.5). Клиент обязан читать частоту из заголовка блока, а не считать её равной запрошенной |
| MP3 у рекордера | не поддержан (кодера в проекте нет): `LINE_OUT_RECORDER_SAVE` с `.mp3` отвечает `tci_error` |
Отдельно: у `TX_SENSORS` второй аргумент — уровень микрофона; измерителя Отдельно: у `TX_SENSORS` второй аргумент — уровень микрофона; измерителя
микрофона в EWSDR нет, шлём нижнюю границу шкалы (-60 дБм), чтобы клиент не микрофона в EWSDR нет, шлём нижнюю границу шкалы (-60 дБм), чтобы клиент не
рисовал случайные значения. Третий аргумент (RMS) и четвёртый (пик) отдаём рисовал случайные значения. Третий аргумент (RMS) и четвёртый (пик) отдаём
одинаковыми — в телеметрии платы одно значение forward power. одинаковыми — в телеметрии платы одно значение forward power.
У `TRX` третий аргумент (источник сигнала `tci`/`mic1`/…) игнорируется: У `TRX` третий аргумент разобран только для `tci` (см. §2.5). Значения
аудио по TCI ещё нет, модуляция берётся из выбранного в программе входа. `mic1`/`mic2`/`micpc`/`ecoder2` называют физические входы ExpertSDR3, которых у
нас нет: они значат «микрофон, выбранный в программе», то есть ровно то же, что
и отсутствие аргумента.
--- ---
## 4. Этап 2 — бинарные потоки (§3.4) ## 4. Что осталось
Не реализовано ничего из потоков; команды управления ими принимаются, но Бинарные потоки (§3.4) реализованы целиком — см. §2.5. Открыто:
данные не идут. Что нужно сделать:
1. **Заголовок блока** уже описан — `TTCIStreamHeader` в `TCIProtocol.pas` 1. **`RX_CHANNEL_ENABLE` как реальное создание/удаление второго слайса пана.**
(16 × uint32 + сэмплы). Сейчас это эхо: канал B главного приёмника — VFO B, он есть всегда, а
2. **`RX_AUDIO_STREAM`** — нужен multicast-тап RX-аудио в контроллере. заводить слайс по команде клиента значит отдать ему управление раскладкой
Сейчас есть только `OnAudioConsume` — одиночный перехват, которым владеет панорамы оператора.
веб-адаптер (он же глушит локальный звук). Для TCI нужен именно тап 2. **`KEYER`** — своего события «ключ нажат» у контроллера нет.
«послушать, не забирая», по образцу `AddStateListener`. 3. **TCI в демоне.** Юниты LCL-free (стенд собирает и гоняет их вместе с
3. **`TX_AUDIO_STREAM` + `TX_CHRONO`** — приём бинарных фреймов от клиента `TRadioController` без единого виджета), подключается одной строкой в
(сейчас `TCIServer` их отбрасывает; приёмный буфер `TWsClient` — 4 КБ, под `ewsdrd.lpr`, как web.
16 КБ блоков его придётся растить) и подача в TX-тракт наравне с 4. **Проверка на железе и с настоящим клиентом** — главное, см. конец §5.
веб-микрофоном (`PushMicSamples`).
4. **`IQ_STREAM`** — тап сырого IQ в `TWDSPEngine.PushIQItemToDSP` (как у
декодера маяка), с децимацией до `IQ_SAMPLERATE` (48/96/192/384 кГц).
5. **`LINEOUT_STREAM` + `LINE_OUT_RECORDER_*`** — запись в WAV/MP3.
Также в очереди: `RX_CHANNEL_ENABLE` как реальное создание/удаление второго
слайса пана и `KEYER`.
Приёмный буфер `TWsClient` — 4 КБ, и сейчас это жёсткий потолок: кадр крупнее
рвёт соединение (команд такой длины у TCI нет). Под TX-аудио его придётся
растить вместе с этапом 2.
---
## 5. Проверено ## 5. Проверено
@@ -478,6 +591,50 @@ split, `rfMonVolume` для громкости самоконтроля и но
движков и сети валится с AV — клиент получает `tci_error`, соединение живо) и движков и сети валится с AV — клиент получает `tci_error`, соединение живо) и
неразрывность пачки инициализации под крутящейся ручкой. неразрывность пачки инициализации под крутящейся ручкой.
### Стенд этапа 2 (бинарные потоки) — 112 проверок, все зелёные
Отдельная программа (`tcitest.pas` в scratchpad, собирается тем же fpc без
внешних библиотек) проверяет потоки на четырёх уровнях:
- **Формат и математика:** коэффициенты прореживания (в том числе «просят выше,
чем есть» и «нацело не делится»), умолчания размера блока, поля заголовка,
сквозная упаковка/распаковка всех четырёх форматов сэмплов и клип на
перегрузе, разбор пути с `|` вместо `:`.
- **Пересчёт частоты:** единичное усиление дециматора на постоянке, тон 1 кГц
проходит, тон 20 кГц при 48→12 кГц давится больше чем на 40 дБ (то самое
зеркало, ради которого и стоит фильтр), прозрачность при коэффициенте 1,
счёт отсчётов у интерполятора и отсутствие выбросов. Отдельно — каскад на
всех интересных коэффициентах (192→48, 384→48 у openHPSDR; 576→48, 1536→48,
5760→48 и 5760→384 у Pluto): полоса ровная, зеркало давится больше чем на
60 дБ. И выбор законной частоты IQ: 576 и 960 кГц на просьбу «384» отдают
192 кГц, 768 кГц отдаёт 384 кГц, чужой rate возвращает просьбу как есть.
- **Блок до сокета** (пара сокетов вместо сети): четыре блока RX-аудио 12 кГц
стерео float32 с верным заголовком и длиной; IQ 384→48 кГц уходит только
целым блоком (полблока не отправляется); моно int16; пересчёт после смены
rate источника; переполнение кольца теряет старые блоки, но клиент жив.
- **Рекордер и WAV:** кольцо ограничено запрошенным временем, после заворота
первым идёт самый старый отсчёт, `Take` опустошает, файл получает верные
RIFF/fmt/data и длину.
- **Команды на живом сервере** (настоящий `TRadioController`, WS-клиент на
сыром сокете): отказ на несуществующий приёмник и на нечисловой аргумент,
отказ на старт потока с незапущенного пана, подтверждение и отбраковка
параметров, `SAVE` без записи и `SAVE` в `.mp3` отвечают ошибкой,
`TRX:0,true,tci` без аудиопотока модуляцию не берёт, а с потоком берёт и
снимает её по `TRX:0,false` и по уходу клиента; чужой бинарный блок не рвёт
соединение; без передачи маркеров `TX_CHRONO` нет. Отдельно — согласование
частот: на источнике 192 кГц просьба «384» подтверждается как 192, смена
rate устройства сама переобъявляет и `iq_samplerate`, и `if_limits`, а на
576 кГц (Pluto) та же просьба даёт законные 192 кГц.
- **Сквозной прогон через живой WDSP:** синтетический 24-битный IQ подаётся
в движок, а клиент по WebSocket получает блоки RX-аудио 12 кГц и IQ 48 кГц
с верными заголовками; после `AUDIO_STOP`/`IQ_STOP` блоки прекращаются.
Передача: `TRX:0,true,tci` поднимает маркеры `TX_CHRONO`, а присланное
клиентом аудио 12 кГц доходит до конца тракта (появляются блоки TX-IQ).
Чего стенд этапа 2 не проверяет: доп. паны (для них нужен живой DDC),
одновременную работу нескольких клиентов на одном потоке и длительный прогон
(дрейф пейсинга TX_CHRONO виден только на минутах).
Чего стенд не проверяет: поведение пана без слайсов и рассылку каналов при Чего стенд не проверяет: поведение пана без слайсов и рассылку каналов при
создании/удалении слайса — для них нужен живой DSP-движок, которого на стенде создании/удалении слайса — для них нужен живой DSP-движок, которого на стенде
нет. Остаётся и известное окно: показания измерителей читают `FDSPEngine` из нет. Остаётся и известное окно: показания измерителей читают `FDSPEngine` из
@@ -494,8 +651,12 @@ send просто возвращает EPIPE, и клиент выбрасыва
TCI в его граф пока не заведён — юниты LCL-free, подключается одной строкой в TCI в его граф пока не заведён — юниты LCL-free, подключается одной строкой в
`ewsdrd.lpr`, как web). `ewsdrd.lpr`, как web).
★Пробная сборка стенда: `-Mobjfpc` обязателен. С `-Mdelphi` в командной строке
вложенные комментарии выключаются, и `{$MODE Delphi}` внутри шапки `WebUtils.pas`
закрывает комментарий раньше времени — компиляция падает на «illegal character».
**На реальном железе и с реальным клиентом (Log4OM/N1MM/WSJT-X/CW Skimmer) не **На реальном железе и с реальным клиентом (Log4OM/N1MM/WSJT-X/CW Skimmer) не
проверялось.** проверялось — ни команды, ни потоки.**
Ответ на команду-установку клиент получает дважды: прямым ответом и рассылкой Ответ на команду-установку клиент получает дважды: прямым ответом и рассылкой
из `OnState`. Это осознанно — дубли идемпотентны, а рассылка нужна для тех из `OnState`. Это осознанно — дубли идемпотентны, а рассылка нужна для тех
+21 -2
View File
@@ -17,9 +17,9 @@
<UseVersionInfo Value="True"/> <UseVersionInfo Value="True"/>
<AutoIncrementBuild Value="True"/> <AutoIncrementBuild Value="True"/>
<MinorVersionNr Value="9"/> <MinorVersionNr Value="9"/>
<BuildNr Value="295"/> <BuildNr Value="300"/>
</VersionInfo> </VersionInfo>
<MacroValues Count="129"> <MacroValues Count="148">
<Macro1 Name="LCLWidgetType" Value="qt6"/> <Macro1 Name="LCLWidgetType" Value="qt6"/>
<Macro2 Name="LCLWidgetType" Value="qt6"/> <Macro2 Name="LCLWidgetType" Value="qt6"/>
<Macro3 Name="LCLWidgetType" Value="qt6"/> <Macro3 Name="LCLWidgetType" Value="qt6"/>
@@ -149,6 +149,25 @@
<Macro127 Name="LCLWidgetType" Value="qt6"/> <Macro127 Name="LCLWidgetType" Value="qt6"/>
<Macro128 Name="LCLWidgetType" Value="qt6"/> <Macro128 Name="LCLWidgetType" Value="qt6"/>
<Macro129 Name="LCLWidgetType" Value="qt6"/> <Macro129 Name="LCLWidgetType" Value="qt6"/>
<Macro130 Name="LCLWidgetType" Value="qt6"/>
<Macro131 Name="LCLWidgetType" Value="qt6"/>
<Macro132 Name="LCLWidgetType" Value="qt6"/>
<Macro133 Name="LCLWidgetType" Value="qt6"/>
<Macro134 Name="LCLWidgetType" Value="qt6"/>
<Macro135 Name="LCLWidgetType" Value="qt6"/>
<Macro136 Name="LCLWidgetType" Value="qt6"/>
<Macro137 Name="LCLWidgetType" Value="qt6"/>
<Macro138 Name="LCLWidgetType" Value="qt6"/>
<Macro139 Name="LCLWidgetType" Value="qt6"/>
<Macro140 Name="LCLWidgetType" Value="qt6"/>
<Macro141 Name="LCLWidgetType" Value="qt6"/>
<Macro142 Name="LCLWidgetType" Value="qt6"/>
<Macro143 Name="LCLWidgetType" Value="qt6"/>
<Macro144 Name="LCLWidgetType" Value="qt6"/>
<Macro145 Name="LCLWidgetType" Value="qt6"/>
<Macro146 Name="LCLWidgetType" Value="qt6"/>
<Macro147 Name="LCLWidgetType" Value="qt6"/>
<Macro148 Name="LCLWidgetType" Value="qt6"/>
</MacroValues> </MacroValues>
<BuildModes> <BuildModes>
<Item Name="Debug" Default="True"/> <Item Name="Debug" Default="True"/>