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
+783 -26
View File
@@ -27,8 +27,11 @@ unit TCIAdapter;
так синхронизация между несколькими клиентами остаётся честной, а поведение
радио не выдумывается. Всё такое помечено «эхо» и перечислено в doc/TCI.md.
Потоки IQ/аудио (§3.4) — следующий этап: команды управления потоками
принимаются и подтверждаются, но сами потоки не идут.
Бинарные потоки (§3.4) живут в TCIStreams; здесь — только их подключение к
контроллеру: тап RX-аудио (два вида: до громкости — «аудиопоток приёмника»,
после неё — «линейный выход»), тап сырого IQ в движке, приём TX-аудио от
клиента и маркеры TX_CHRONO. Данные перемалывает DSP-поток, поэтому вся
работа с потоками — под FStreamLock и без единого ожидания.
}
{$IFDEF FPC}
@@ -41,10 +44,16 @@ interface
uses
Classes, SysUtils, DateUtils, Math, SyncObjs,
RadioController, RadioBackend, WDSPEngine, Settings,
DXSpotStore, TCIProtocol, TCIServer;
DXSpotStore, TCIProtocol, TCIServer, TCIStreams;
const
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): пока владелец его крутит, остальные
// могут только слушать. Без этого два логгера перетягивают частоту друг у
// друга бесконечно.
@@ -120,6 +129,31 @@ type
FHolds: array[0..TCI_HOLD_SLOTS-1] of TTCIHold;
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 (поток контроллера)
FLastLimHi: Double;
@@ -134,6 +168,7 @@ type
FsInt2: Integer;
FsInt3: Integer;
FsBool: Boolean;
FsBool2: Boolean;
FsStr: string;
// ── Sync-методы (поток контроллера) ──
@@ -166,6 +201,30 @@ type
procedure SyncCWSend;
procedure SyncCWStop;
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;
@@ -307,8 +366,16 @@ begin
FHoldLock := TCriticalSection.Create;
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.OnCommand := HandleCommand;
FServer.OnBinary := HandleBinary;
FServer.OnConnect := HandleConnect;
FServer.OnDisconnect := HandleDisconnect;
FServer.OnTick := HandleTick;
@@ -326,18 +393,25 @@ end;
destructor TTCIAdapter.Destroy;
begin
// Сначала отписка: контроллер живёт дольше адаптера, и Changed() после
// нашей смерти позвал бы метод освобождённого объекта.
// нашей смерти позвал бы метод освобождённого объекта. По той же причине
// ПЕРВЫМИ снимаем тапы аудио/IQ — их зовёт DSP-поток, который переживёт нас.
SetTaps(False);
if FController <> nil then FController.RemoveStateListener(OnState);
if FServer <> nil then
begin
FServer.Stop;
FreeAndNil(FServer);
end;
// Потоки — уже после остановки сервера: их объекты ссылаются на клиентов.
StopAllStreams;
FreeAndNil(FTxInterp);
FLock.Free;
FEchoLock.Free;
FDevLock.Free;
FSliceLock.Free;
FHoldLock.Free;
FStreamLock.Free;
FTxLock.Free;
inherited;
end;
@@ -359,6 +433,10 @@ begin
OldPort := FServer.Port;
FServer.Stop;
// Сервер остановлен — клиентов больше нет, значит и потоки чужие: снимаем
// тапы, иначе DSP-поток продолжал бы носить аудио в никуда.
StopAllStreams;
SetTaps(False);
if not T.Enabled then
begin
FCfg := T;
@@ -369,11 +447,13 @@ begin
if Result then
begin
FCfg := T;
SetTaps(True);
Exit;
end;
// Порт занят (или отобран правами) — поднимаем то, что работало.
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;
function TTCIAdapter.CanInvoke: Boolean;
@@ -577,6 +657,10 @@ procedure TTCIAdapter.PushChannelMap;
// полную картину каждого живого приёмника — клиент перезаливает её целиком.
var Rx, Ch, N: Integer;
begin
// Потоки пропавших приёмников гасим ВСЕГДА, даже если рассылать некому:
// объект потока пережил бы свой пан и молча копил тишину, а клиент ждал бы
// блоков, которых больше не будет.
DropDeadRxStreams;
if (FServer = nil) or (FServer.ClientCount = 0) then Exit;
for Rx := 1 to RxCount - 1 do
begin
@@ -1168,9 +1252,13 @@ end;
procedure TTCIAdapter.HandleDisconnect(Client: TTCIClient);
// Клиент ушёл — снимаем его захваты параметров, иначе следующий, кому достанется
// тот же адрес объекта, унаследовал бы чужие права (§3.5).
// тот же адрес объекта, унаследовал бы чужие права (§3.5). По той же причине
// гасим его потоки: объект клиента вот-вот освободят, а на него смотрит
// DSP-поток. Порядок обязателен — сначала потоки, потом возврат в сервер.
begin
DropHolds(Client);
DropClientStreams(Client);
ClearTxClient(Client);
end;
{ ═══════════════════════════════════════════════════════════════════════════
@@ -1224,9 +1312,28 @@ end;
procedure TTCIAdapter.SyncSetTRX;
begin
// Источник модуляции ставим ДО SetMOX — именно он его и читает при выборе
// микрофона. Выключение передачи флаг снимает всегда: следующий раз оператор
// может нажать PTT сам, и тогда в эфир должен идти его микрофон.
FController.TCIMicRequested := FsBool and FsBool2;
FController.SetMOX(FsBool);
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;
begin
FController.SetTune(FsBool);
@@ -1557,7 +1664,7 @@ procedure TTCIAdapter.DispatchCommand(Client: TTCIClient; const M: TTCIMessage);
var
Rx, Ch, V: Integer;
Lo, Hi: Integer;
B: Boolean;
B, FromTCI: Boolean;
D: Double;
Name: string;
begin
@@ -1642,13 +1749,26 @@ begin
// ── Передача ──
if M.Name = 'TRX' then
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
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;
try
FsBool := B;
FsBool := B;
FsBool2 := FromTCI;
if CanInvoke then FController.Invoke(SyncSetTRX);
finally FLock.Leave; end;
end;
@@ -2188,37 +2308,75 @@ begin
// Это настройки КЛИЕНТА (§4.3), а не устройства: два логгера вправе просить
// разную частоту дискретизации. Поэтому живут в его объекте, а не в адаптере
// — иначе один клиент перенастраивал бы будущие потоки всем остальным.
// Сами потоки — этап 2, значения только принимаются и подтверждаются.
// Изменение параметра на ходу перезапускает уже идущие потоки этого клиента:
// блок с новой частотой посреди старого потока клиенты разбирают как мусор.
if M.Name = 'IQ_SAMPLERATE' then
begin
// Набор частот оговорён протоколом; чужое значение отвергаем, а в ответе
// отдаём действующее — клиент увидит, что его не приняли.
if TCITryArgInt(M, 0, V) and TCIValidIQRate(V) then Client.IQRate := V;
Reply(Client, TCIBuild('iq_samplerate', [TCIIntStr(Client.IQRate)]));
if TCITryArgInt(M, 0, V) and TCIValidIQRate(V) and (V <> Client.IQRate) then
begin
Client.IQRate := V;
RestartStreams(Client, tstIQ);
end;
// ★В ответе — та частота, которую клиент РЕАЛЬНО получит, а не его
// просьба: на 576 и 960 кГц Pluto просьба «384» невыполнима (не делится
// нацело), и подтвердить её значило бы соврать. Сама просьба остаётся
// сохранённой — на другом устройстве она может стать выполнимой.
Reply(Client, TCIBuild('iq_samplerate', [TCIIntStr(EffIQRate(Client))]));
Exit;
end;
if M.Name = 'AUDIO_SAMPLERATE' then
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)]));
Exit;
end;
if M.Name = 'AUDIO_STREAM_SAMPLES' then
begin
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;
end;
if M.Name = 'AUDIO_STREAM_CHANNELS' then
begin
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;
end;
if M.Name = 'AUDIO_STREAM_SAMPLE_TYPE' then
begin
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;
end;
if M.Name = 'TX_STREAM_AUDIO_BUFFERING' then
@@ -2228,17 +2386,19 @@ begin
Exit;
end;
// Запуск потоков (§3.4) — этап 2. Молчать нельзя: клиент решил бы, что поток
// пошёл, и ждал бы данных бесконечно. Отвечаем ошибкой на конкретную команду.
// ── Запуск и остановка потоков (§3.4) ──
if (M.Name = 'IQ_START') or (M.Name = 'IQ_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_RECORDER_START') or
(M.Name = 'LINE_OUT_START') or (M.Name = 'LINE_OUT_STOP') then
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_BREAK') then
begin
Reply(Client, TCIBuild('tci_error', [LowerCase(M.Name),
'binary streams are not implemented']));
CmdRecorder(Client, M);
Exit;
end;
@@ -2291,11 +2451,594 @@ begin
// измерители у ВСЕХ клиентов до перезапуска сервера.
try
FServer.EnumClients(PushSensors);
PushTxChrono;
except
// молча: следующий тик через 20 мс попробует снова
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
RefreshDev;
// Пан могли убрать и без правки слайсов — тогда сигнатура карты не менялась,
// а поток остался бы висеть на несуществующем приёмнике.
if Field in [rfDevice, rfConnected] then DropDeadRxStreams;
MapChanged := False;
if Field in [rfSliceFreq, rfSliceState, rfDevice, rfPanFreq, rfSampleRate,
@@ -2401,9 +3147,20 @@ begin
FServer.Broadcast(StrIf(0, 1));
end;
rfSampleRate:
FServer.Broadcast(TCIBuild('if_limits',
[TCIIntStr(-(FController.FSampleRate div 2)),
TCIIntStr(FController.FSampleRate div 2)]));
begin
FServer.Broadcast(TCIBuild('if_limits',
[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:
if FController.FRunning then FServer.Broadcast(TCIBuild('start'))
else FServer.Broadcast(TCIBuild('stop'));