diff --git a/.gitignore b/.gitignore index 609e5df..5333b98 100644 --- a/.gitignore +++ b/.gitignore @@ -118,3 +118,7 @@ webserver_debug.log bin/ bin/* hpsdr_settings.json + +# Python bytecode (стенды в test/) +__pycache__/ +*.pyc diff --git a/MainForm.pas b/MainForm.pas index ea226c0..234d8b4 100644 --- a/MainForm.pas +++ b/MainForm.pas @@ -1901,11 +1901,15 @@ begin end; end; - rfSliceFreq: + rfSliceFreq, rfSliceState: begin // Слайс перестроен по своему CAT-порту в пределах захваченной полосы: // окно DDC не двигалось (rfPanFreq не придёт), но цифры и полоса // фильтра во флаге — его собственные поля, их надо перезалить. + // ★rfSliceState — то же самое для всего остального: моды, фильтра, + // АРУ, DSP, шумодава, громкости. Правит их и UI, и CAT слайса, и TCI, + // а без этой ветки флаг доп. панорамы оставался с прежними надписями + // (частота при этом двигалась — она приходит отдельным rfSliceFreq). PushFlagStateAllPans(FController.FSliceFreqId); LayoutFlagsAllPans; for i := 0 to MAX_PANS - 1 do @@ -4358,6 +4362,10 @@ begin end; FController.FWDSPReady := False; FController.FDSPEngine := TWDSPEngine.Create(ASampleRate, 48000, 512); + // Внутренние колбэки контроллера (аудио слайсов, отвод демодулятора для + // декодеров и RX_AUDIO по TCI, калибровка дисплея, тап IQ, декодер маяка) — + // одним вызовом: их навешивает CreateEngines, а новый движок про них не знал. + FController.AttachEngineCallbacks; FController.FDSPEngine.OnAudio := FController.OnAudioReady; FController.FDSPEngine.OnSpectrum := FController.OnSpectrumReady; FController.FDSPEngine.OnWaterfall := FController.OnWaterfallReady; diff --git a/RadioController.pas b/RadioController.pas index 4e086e1..0ce84ed 100644 --- a/RadioController.pas +++ b/RadioController.pas @@ -249,7 +249,9 @@ type FAudioTaps: array of TRadioAudioTapEvent; FAudioTapLock: TCriticalSection; FAudioTapCount: LongInt; // быстрый гейт: без тапов не берём и лок + FIQTap: TIQTapEvent; // тап IQ (TCI): помним ради пересоздания движка procedure Changed(Field: TRadioField); + procedure PushAudioTapsActive; procedure FireAudioTap(Kind: TRadioAudioKind; PanId, SliceId: Integer; const Left, Right: array of Single; Count: Integer); // --- Мультислайсы --- @@ -627,6 +629,11 @@ type // FreeEngines закрывает и освобождает их; вызывается из Destroy. procedure CreateEngines(ASampleRate: Integer); procedure FreeEngines; + // Внутренние колбэки движка (аудио слайсов, отвод демодулятора, калибровка + // дисплея, тапы TCI). Зовёт CreateEngines — и ОБЯЗАН звать всякий, кто + // пересоздал FDSPEngine: без этого новый движок остаётся без отвода + // демодулятора (декодеры CW/DMR/FM RAW и RX_AUDIO по TCI) и без тапа IQ. + procedure AttachEngineCallbacks; // ---- Колбэки движков (вызываются из рабочих потоков сети/DSP) ---- // Чистая обработка данных без UI; навешиваются на FNetwork/FDSPEngine @@ -1388,6 +1395,31 @@ begin FillChar(FDevMAC, SizeOf(FDevMAC), 0); end; +procedure TRadioController.AttachEngineCallbacks; +// Всё, что движок должен знать о САМОМ контроллере. Отдельным методом, потому +// что движок пересоздают на ходу (смена sample rate до старта): раньше эти +// колбэки навешивались только в CreateEngines, и после пересоздания движок +// оставался без отвода демодулятора — молча пропадали декодеры CW/DMR/FM RAW, +// аудио слайсов, калибровка дисплея и оба тапа TCI. +begin + if not Assigned(FDSPEngine) then Exit; + // Роутинг аудио слайсов на их звуковухи — внутренний (не зависит от + // фронтенда, в отличие от OnAudio/OnSpectrum). + FDSPEngine.OnSliceAudio := OnSliceAudioReady; + FDSPEngine.OnSliceDemodAudio := OnSliceDemodAudioReady; + FDSPEngine.OnDemodAudio := OnDemodAudioReady; + // Калибровка уровня спектра/водопада на диапазон/трансвертер — движок + // спрашивает её раз в кадр (сам он про диапазоны не знает). + FDSPEngine.OnDispCal := DispCalFor; + FDSPEngine.SetDisplayResLimit(FDisplayPixels); + // Тапы наружу (TCI): их владелец живёт дольше движка. + FDSPEngine.SetIQTap(FIQTap); + PushAudioTapsActive; + // Декодер маяка QO-100 создаётся позже самого движка (в CreateEngines ниже), + // зато при ПЕРЕсоздании движка он уже есть и его надо вернуть на место. + if Assigned(FBeaconDec) then FDSPEngine.SetBeaconDecoder(FBeaconDec); +end; + procedure TRadioController.CreateEngines(ASampleRate: Integer); // Создаёт движки в полях контроллера. Колбэки (OnAudio/OnSpectrum/OnHPStatus…) // навешивает вызывающий после возврата — они указывают на UI/демон. @@ -1397,15 +1429,7 @@ begin // BufSize=512: латентность 512/48000 ≈ 10.7мс — допустимо для SDR; меньший // буфер вдвое сокращает построение downsampler в OpenChannel RXA. FDSPEngine := TWDSPEngine.Create(ASampleRate, 48000, 512); - // Роутинг аудио слайсов на их звуковухи — внутренний, вешаем сразу (не - // зависит от фронтенда, в отличие от OnAudio/OnSpectrum). - FDSPEngine.OnSliceAudio := OnSliceAudioReady; - FDSPEngine.OnSliceDemodAudio := OnSliceDemodAudioReady; - FDSPEngine.OnDemodAudio := OnDemodAudioReady; - // Калибровка уровня спектра/водопада на диапазон/трансвертер — движок - // спрашивает её раз в кадр (сам он про диапазоны не знает). - FDSPEngine.OnDispCal := DispCalFor; - FDSPEngine.SetDisplayResLimit(FDisplayPixels); + AttachEngineCallbacks; // QO-100 beacon lock: эталон частоты = декодер (см. ServiceBeaconLock). FBeaconRefHz := 10489750000.0; // центральный BPSK-маяк NB-транспондера (downlink) @@ -1545,6 +1569,7 @@ begin finally FAudioTapLock.Leave; end; + PushAudioTapsActive; end; procedure TRadioController.RemoveAudioTap(T: TRadioAudioTapEvent); @@ -1560,11 +1585,20 @@ begin for j := i to High(FAudioTaps) - 1 do FAudioTaps[j] := FAudioTaps[j + 1]; SetLength(FAudioTaps, Length(FAudioTaps) - 1); FAudioTapCount := Length(FAudioTaps); - Exit; + Break; end; finally FAudioTapLock.Leave; end; + PushAudioTapsActive; +end; + +procedure TRadioController.PushAudioTapsActive; +// Движку интересен только факт «слушатели есть»: от него зависит, считать ли +// молчащие (mute) слайсы — их звук всё равно уходит в RX_AUDIO по TCI. +begin + if Assigned(FDSPEngine) then + FDSPEngine.SetAudioTapsActive(FAudioTapCount > 0); end; procedure TRadioController.FireAudioTap(Kind: TRadioAudioKind; @@ -1585,7 +1619,11 @@ begin end; procedure TRadioController.SetIQTap(T: TIQTapEvent); +// Тап помним у себя: движок пересоздают (RecreateDSPEngine при смене sample +// rate до старта), и новый про тап не знал бы — поток IQ по TCI молча умирал +// на всю сессию. Восстанавливает его AttachEngineCallbacks. begin + FIQTap := T; if Assigned(FDSPEngine) then FDSPEngine.SetIQTap(T); end; @@ -1984,6 +2022,10 @@ begin else if FMode = MODE_FMRAW then begin // FM RAW decoder audio deliberately bypasses speaker volume and mute. + // Значит и линейный выход у него — вот этот сигнал: OnAudioReady для + // FM RAW выходит в самом начале, и без этой строки поток LINEOUT по TCI + // на FM RAW не шёл вовсе. + FireAudioTap(rakLineOut, 0, 0, Left, Right, Count); if FLocalAudio and Assigned(FAudioOut) then FAudioOut.Write(Left, Right, Count); end @@ -2082,9 +2124,15 @@ begin FireAudioTap(rakDemod, FSlices[idx].PanId, SliceId, Left, Right, Count); if (FSlices[idx].Mode = MODE_DMR) and Assigned(FSlices[idx].DMR) then FSlices[idx].DMR.FeedAudio(Left, Right, Count); - if (FSlices[idx].Mode = MODE_FMRAW) and FLocalAudio and - Assigned(FSlices[idx].Audio) then - FSlices[idx].Audio.Write(Left, Right, Count); + if FSlices[idx].Mode = MODE_FMRAW then + begin + // У FM RAW звук идёт мимо громкости и мьюта, поэтому линейный выход слайса + // — это он же (OnSliceAudioReady для FM RAW не вызывается вовсе). Без этой + // строки поток LINEOUT по TCI на FM RAW-слайсе молчал. + FireAudioTap(rakLineOut, FSlices[idx].PanId, SliceId, Left, Right, Count); + if FLocalAudio and Assigned(FSlices[idx].Audio) then + FSlices[idx].Audio.Write(Left, Right, Count); + end; end; // DMR worker thread: decoded 8 kHz mono -> this slice's 48 kHz local output. @@ -2095,11 +2143,13 @@ var idx, i, Phase, OutPos, N: Integer; A, B, V, Gain: Single; begin - if not FLocalAudio or (Count <= 0) then Exit; + if (Count <= 0) or not (FLocalAudio or (FAudioTapCount > 0)) then Exit; idx := FindSliceIndex(SliceId); - if (idx < 0) or (FSlices[idx].Mode <> MODE_DMR) or - FSlices[idx].Muted or not Assigned(FSlices[idx].Audio) then Exit; + if (idx < 0) or (FSlices[idx].Mode <> MODE_DMR) or FSlices[idx].Muted then Exit; if SliceMutedByTx(idx) then Exit; + // Звуковухи может не быть вовсе (демон), но линейный выход слайса слушает + // ещё и TCI — тогда декодированный звук всё равно надо построить. + if not (Assigned(FSlices[idx].Audio) or (FAudioTapCount > 0)) then Exit; N := Min(Count, Length(PCM)); SetLength(Left, N * 6); SetLength(Right, N * 6); @@ -2117,7 +2167,11 @@ begin Inc(OutPos); end; end; - FSlices[idx].Audio.Write(Left, Right, OutPos); + // Линейный выход DMR-слайса — декодированный звук, как и у главного тракта + // (см. OnDMRAudioReady): 4FSK до декодера в LINEOUT слать нечего. + FireAudioTap(rakLineOut, FSlices[idx].PanId, SliceId, Left, Right, OutPos); + if FLocalAudio and Assigned(FSlices[idx].Audio) then + FSlices[idx].Audio.Write(Left, Right, OutPos); end; procedure TRadioController.RefreshSliceShifts; diff --git a/TCIAdapter.pas b/TCIAdapter.pas index 452dd21..31092e0 100644 --- a/TCIAdapter.pas +++ b/TCIAdapter.pas @@ -142,6 +142,7 @@ type // ── TX-аудио от клиента (§3.4) ── FTxLock: TCriticalSection; FTxClient: TTCIClient; // кто модулирует (nil — никто) + FTrxOwner: TTCIClient; // кто поставил трансивер в эфир (§4.2) FTxInterp: TTCIInterpolator; FTxInRate: Integer; // частота дискретизации подачи клиента FTxRunning: Boolean; // маркеры TX_CHRONO идут @@ -177,6 +178,7 @@ type procedure SyncSetMode; procedure SyncSetFilter; procedure SyncSetTRX; + procedure SyncStopTRX; procedure SyncSetTune; procedure SyncSetDrive; procedure SyncSetTuneDrive; @@ -225,6 +227,8 @@ type procedure CmdStream(Client: TTCIClient; const M: TTCIMessage); procedure CmdRecorder(Client: TTCIClient; const M: TTCIMessage); procedure ClearTxClient(C: TTCIClient); // клиент ушёл/перестал модулировать + procedure StopTxOf(C: TTCIClient); // ★снять эфир, начатый этим клиентом + procedure ForgetTxOwner; // передача кончилась не по TCI // ── Помощники модели ── function CanInvoke: Boolean; @@ -1259,6 +1263,10 @@ begin DropHolds(Client); DropClientStreams(Client); ClearTxClient(Client); + // ★И только теперь — сама передача: если в эфир нас поставил именно этот + // клиент, снимаем MOX. Оставить включённый передатчик за ушедшим клиентом + // нельзя ни при какой модуляции (§4.2). + StopTxOf(Client); end; { ═══════════════════════════════════════════════════════════════════════════ @@ -1319,6 +1327,16 @@ begin FController.SetMOX(FsBool); end; +procedure TTCIAdapter.SyncStopTRX; +// Поток контроллера. Передачу, начатую ушедшим клиентом, снимаем безусловно: +// решение принято там, где известно, кто её начал, а здесь остаётся только +// проверить, что она вообще идёт (оператор мог отпустить PTT сам). +begin + if (FController = nil) or not FController.FTransmitting then Exit; + FController.TCIMicRequested := False; + FController.SetMOX(False); +end; + procedure TTCIAdapter.SyncTaps; // Поток контроллера: движок и список тапов трогаем только отсюда. begin @@ -1764,6 +1782,11 @@ begin try if FromTCI then FTxClient := Client else if FTxClient = Client then FTxClient := nil; + // Кто поставил трансивер в эфир: его уход обязан эфир и снять + // (см. StopTxOf). Источник модуляции тут ни при чём — несущую без + // хозяина оставлять нельзя в любом случае. + if B then FTrxOwner := Client + else if FTrxOwner = Client then FTrxOwner := nil; finally FTxLock.Leave; end; FLock.Enter; try @@ -2638,6 +2661,8 @@ begin FStreamLock.Leave; end; ClearTxClient(nil); + // Страховка: клиент мог держать эфир и без своего аудио (TRX без 'tci'). + StopTxOf(nil); end; function TTCIAdapter.EffIQRate(C: TTCIClient): Integer; @@ -2890,9 +2915,55 @@ begin if not Drop then Exit; // Пишем прямо, без Invoke: это один Boolean, который SetMOX только читает, и // зовут нас откуда угодно — в том числе из деструктора адаптера, где ждать - // поток контроллера уже некому. Передачу при этом НЕ трогаем: оператор мог - // нажать PTT сам, и обрывать его эфир из-за ухода клиента нельзя. - if FController <> nil then FController.TCIMicRequested := False; + // поток контроллера уже некому. + if FController = nil then Exit; + FController.TCIMicRequested := False; + // ★А вот саму передачу, если она ИДЁТ ИМЕННО ИЗ ЭТОГО ПОТОКА, оставлять + // нельзя: источник модуляции только что исчез, и в эфире осталась бы + // несущая с тишиной (или с последним, что застряло в ринге), которую никто + // не снимет. Передачу с микрофона оператора это не трогает — там + // TCIMicActive не поднят. + if FController.TCIMicActive then StopTxOf(nil); +end; + +procedure TTCIAdapter.StopTxOf(C: TTCIClient); +// ★Безопасность (§4.2). Клиент, поставивший трансивер в эфир, ушёл — MOX +// обязан упасть. C = nil — снять эфир, кем бы из клиентов он ни был начат +// (остановка сервера, потеря источника модуляции). +var Mine: Boolean; +begin + Mine := False; + FTxLock.Enter; + try + if (FTrxOwner <> nil) and ((C = nil) or (FTrxOwner = C)) then + begin + FTrxOwner := nil; + Mine := True; + end; + finally + FTxLock.Leave; + end; + if not Mine or (FController = nil) then Exit; + if CanInvoke then + FController.Invoke(SyncStopTRX) + else + // CanInvoke=False бывает ровно в одном случае — сервер останавливается, а + // останавливает его поток контроллера: он же сейчас и исполняет нас + // (TTCIServer.Stop, шаг 5 → Disconnected). Ждать самого себя нельзя, и не + // нужно — вызов и так на правильном потоке. + SyncStopTRX; +end; + +procedure TTCIAdapter.ForgetTxOwner; +// Передача кончилась сама (оператор, CAT, PTT, тайм-аут) — забываем, кто её +// начал. Иначе уход того клиента через час снимал бы уже чужой эфир. +begin + FTxLock.Enter; + try + FTrxOwner := nil; + finally + FTxLock.Leave; + end; end; procedure TTCIAdapter.PushTxChrono; @@ -2966,7 +3037,7 @@ var H: TTCIStreamHeader; ST: TTCISampleType; P: PByte; - N, i, k, Chans, Rate, Factor, Bytes: Integer; + N, i, k, Chans, Rate, Factor, Bytes, Want: Integer; Mine: Boolean; begin if (Data = nil) or (Len <= SizeOf(H)) then Exit; @@ -3001,10 +3072,16 @@ begin try N := TCIUnpackSamples(P, Bytes, ST, FTxRaw); if N <= 0 then Exit; - // Заголовок обещает length вещественных отсчётов — верим меньшему из двух: + // В аудиопотоке length — это сэмплы НА КАНАЛ (см. TCIFillHeader), значит + // вещественных отсчётов в блоке length × channels. Верим меньшему из двух: // клиент вправе прислать короткий хвост, но не длиннее уместившегося. - if (H.DataLength > 0) and (Integer(H.DataLength) < N) then - N := Integer(H.DataLength); + // Заведомо чужое число (мусор в заголовке) просто игнорируем — длину нам + // и так ограничил размер кадра. + if (H.DataLength > 0) and (H.DataLength <= LongWord(N)) then + begin + Want := Integer(H.DataLength) * Chans; + if Want < N then N := Want; + end; // В тракт идёт моно: TXA у нас один, а стерео от клиента — это его // собственный формат вывода, а не два независимых сигнала. @@ -3063,6 +3140,17 @@ begin // а поток остался бы висеть на несуществующем приёмнике. if Field in [rfDevice, rfConnected] then DropDeadRxStreams; + // Передача кончилась — чья бы она ни была. Забываем хозяина эфира (иначе его + // уход когда-нибудь потом снял бы уже чужую передачу) и снимаем просьбу + // «модулируй из потока TCI»: она относилась ровно к той передаче, которую + // клиент и начал, а следующий PTT оператора обязан идти с его микрофона. + if (Field = rfTransmitting) and (FController <> nil) + and not FController.FTransmitting then + begin + ForgetTxOwner; + FController.TCIMicRequested := False; + end; + MapChanged := False; if Field in [rfSliceFreq, rfSliceState, rfDevice, rfPanFreq, rfSampleRate, rfBand, rfXvtr, rfCenterFreq] then diff --git a/TCIProtocol.pas b/TCIProtocol.pas index 2ed3761..1eb1b0c 100644 --- a/TCIProtocol.pas +++ b/TCIProtocol.pas @@ -87,7 +87,8 @@ type Format: LongWord; // TTCISampleType Codec: LongWord; // всегда 0 (сжатие не реализовано) CRC: LongWord; // всегда 0 - DataLength: LongWord; // количество вещественных отсчётов в data[] + DataLength: LongWord; // IQ — вещественных отсчётов, аудио — на канал + // (см. TCIFillHeader) StreamType: LongWord; // TTCIStreamType Channels: LongWord; Reserv: array[0..7] of LongWord; @@ -133,8 +134,14 @@ function TCIDefaultAudioSamples(RateHz: Integer): Integer; { Сколько сэмплов на канал влезает в блок с таким форматом и числом каналов. } function TCIMaxBlockSamples(T: TTCISampleType; Channels: Integer): Integer; -{ Заголовок блока. Count — сэмплов НА КАНАЛ; в DataLength уходит, как велит - протокол, количество ВЕЩЕСТВЕННЫХ отсчётов, то есть Count × Channels. } +{ Заголовок блока. Count — сэмплов НА КАНАЛ. В DataLength уходит то, что для + этого типа потока велит документ, а он для IQ и для аудио говорит РАЗНОЕ: + • IQ (§3.4): «количество вещественных отсчётов… количество комплексных + вычисляется как Stream.length/Stream.channels» ⇒ Count × Channels; + • аудио (§4.3, AUDIO_STREAM_SAMPLES): «arg1 — количество сэмплов, + указываемое в поле Stream.length», по умолчанию 2048 при 48 кГц ⇒ Count, + то есть сэмплов НА КАНАЛ. Сходится и с потолком data[16384]: + 2048 × 2 канала × float32 — ровно 16384 байта. } procedure TCIFillHeader(out H: TTCIStreamHeader; Kind: TTCIStreamType; Rx, RateHz: Integer; T: TTCISampleType; Count, Channels: Integer); @@ -392,7 +399,11 @@ begin H.Receiver := LongWord(Rx); H.SampleRate := LongWord(RateHz); H.Format := LongWord(Ord(T)); - H.DataLength := LongWord(Count * Channels); + // ★Единицы length разные у IQ и у аудио — см. шапку объявления. + if Kind = tstIQ then + H.DataLength := LongWord(Count * Channels) + else + H.DataLength := LongWord(Count); H.StreamType := LongWord(Ord(Kind)); H.Channels := LongWord(Channels); end; diff --git a/TCIServer.pas b/TCIServer.pas index e2c8c7b..496558e 100644 --- a/TCIServer.pas +++ b/TCIServer.pas @@ -1203,9 +1203,16 @@ begin if not Pending then begin R := Ws.Recv; - // R <= 0 — либо разрыв, либо просто истёк TCI_POLL_MS. Второе штатно: - // просыпаемся, чтобы отдать накопившуюся очередь. - if (R <= 0) and not SockRecvTimedOut then Break; + // R = 0 — клиент закрыл свою сторону (EOF). Это НЕ ошибка, errno при + // этом не трогается и вполне может нести EAGAIN от прошлого истёкшего + // TCI_POLL_MS — спрашивать его тут нельзя, иначе обычный TCP-разрыв + // без close-кадра выглядит как таймаут и слот клиента не освобождается + // до остановки сервера. Место в буфере есть всегда (кадр крупнее + // буфера рвётся выше), так что нулю иного смысла нет. + if R = 0 then Break; + // R < 0 — ошибка: либо разрыв, либо просто истёк TCI_POLL_MS. Второе + // штатно: просыпаемся, чтобы отдать накопившуюся очередь. + if (R < 0) and not SockRecvTimedOut then Break; end; Pending := False; diff --git a/TCIStreams.pas b/TCIStreams.pas index de81a0a..18024a8 100644 --- a/TCIStreams.pas +++ b/TCIStreams.pas @@ -165,23 +165,37 @@ type property SrcRate: Integer read FSrcRate; end; - { Запись линейного выхода (LINE_OUT_RECORDER_*, §4.3). Кольцо на MaxSec - секунд 48 кГц стерео в int16: во-первых, ровно то, что уйдёт в WAV, а - во-вторых, float32 на предельных 300 с — это 115 МБ вместо 57. } + { Запись линейного выхода (LINE_OUT_RECORDER_*, §4.3). Буфер на MaxSec секунд + 48 кГц стерео в int16: во-первых, ровно то, что уйдёт в WAV, а во-вторых, + float32 на предельных 300 с — это 115 МБ вместо 57. + + ★Не кольцо. Документ говорит про arg2 «максимальное время записи», и дальше + прямо: «по истечении времени запись УДАЛЯЕТСЯ, чтобы сохранить запись в файл + необходимо в заданном временном интервале выслать LINE_OUT_RECORDER_SAVE». + То есть START открывает окно длиной MaxSec, и всё, что не сохранили внутри + него, пропадает. Кольцо «последние N секунд» вело себя иначе в обе стороны: + начало активной записи затирало само себя, а SAVE через час после START + отдавал файл, которого у ExpertSDR3 давно бы не было. + + Срок считаем по ЧАСАМ, а не по накопленным сэмплам: линейный выход молчит + (мьют, пауза приёмника), а время записи всё равно идёт. } TTCIRecorder = class private FLock: TCriticalSection; - FRing: array of SmallInt; // чередование L/R + FBuf: array of SmallInt; // чередование L/R FCap: Integer; // ёмкость в сэмплах на канал FCount: Integer; // накоплено сэмплов на канал - FHead: Integer; // позиция записи (в сэмплах на канал) FRate: Integer; FRx: Integer; + FExpired: Boolean; // окно записи закрылось, данные удалены + FEndsAt: QWord; // GetTickCount64 конца окна + procedure DropData; // под FLock public constructor Create(ARx, ARateHz, AMaxSec: Integer); destructor Destroy; override; procedure Feed(const L, R: array of Single; N: Integer); - { Забрать накопленное В ПОРЯДКЕ ВРЕМЕНИ и обнулить кольцо. } + { Забрать накопленное и завершить запись. nil — либо не записано ничего, + либо окно уже истекло (по документу это одно и то же: записи нет). } function Take: TTCIPcm; property Rx: Integer read FRx; property Rate: Integer read FRate; @@ -671,9 +685,12 @@ begin 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; + SetLength(FBuf, FCap * 2); + FCount := 0; + FExpired := False; + // Окно открывается прямо здесь: клиент отсчитывает его от своей команды + // START, и ждать первого блока аудио, чтобы завести часы, нельзя. + FEndsAt := GetTickCount64 + QWord(AMaxSec) * 1000; end; destructor TTCIRecorder.Destroy; @@ -682,10 +699,17 @@ begin inherited; end; +procedure TTCIRecorder.DropData; +// Под FLock. Память отдаём сразу: истёкшая запись на 300 с держала бы 57 МБ +// до тех пор, пока клиент не вспомнит про BREAK. +begin + FCount := 0; + FExpired := True; + SetLength(FBuf, 0); +end; + procedure TTCIRecorder.Feed(const L, R: array of Single; N: Integer); -// DSP-поток. Кольцо: по исчерпании ёмкости затирается самое старое — запись -// «последние N секунд» именно так и работает (§4.3: по истечении времени -// накопленное пропадает, если клиент не сохранил). +// DSP-поток. Пишем линейно до конца окна; истекло — данных больше нет (§4.3). var i: Integer; A, B: Single; @@ -695,17 +719,24 @@ begin if N > Length(R) then N := Length(R); FLock.Enter; try + if FExpired then Exit; + // Часы и ёмкость закрывают окно вместе: ёмкость — это те же MaxSec звука, + // но при паузе в аудио доживёт до конца только счёт по часам. + if GetTickCount64 >= FEndsAt then + begin + DropData; + Exit; + end; + if N > FCap - FCount then N := FCap - FCount; for i := 0 to N - 1 do 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); + FBuf[(FCount + i) * 2] := Round(A * 32767); + FBuf[(FCount + i) * 2 + 1] := Round(B * 32767); end; + Inc(FCount, N); finally FLock.Leave; end; @@ -713,23 +744,18 @@ end; function TTCIRecorder.Take: TTCIPcm; var - Start, i, n: Integer; + i: Integer; begin Result := nil; FLock.Enter; try - if FCount <= 0 then Exit; + // Проверка срока и здесь: аудио могло не идти вовсе (мьют, стоящий + // приёмник), и тогда Feed часы не смотрел ни разу. + if (not FExpired) and (GetTickCount64 >= FEndsAt) then DropData; + if FExpired or (FCount <= 0) then Exit; SetLength(Result, FCount * 2); - 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; + for i := 0 to FCount * 2 - 1 do Result[i] := FBuf[i]; + DropData; // SAVE завершает запись (§4.3) finally FLock.Leave; end; diff --git a/WDSPEngine.pas b/WDSPEngine.pas index 9b8546b..b867dea 100644 --- a/WDSPEngine.pas +++ b/WDSPEngine.pas @@ -524,6 +524,15 @@ type FLastError: string; FBeaconDec: TBeaconDecoder; // не владеет; тап маяка в PushIQItemToDSP FIQTap: TIQTapEvent; // тап сырого IQ наружу (TCI), не владеет + // ★Лок тапа. Снять тап обязан тот, кто вот-вот освободит его владельца + // (адаптер TCI), а зовёт тап DSP-поток. Без лока SetIQTap(nil) возвращался + // раньше, чем DSP-поток выходил из вызова, — и следующая же строка + // деструктора адаптера убивала объект прямо под ним. + FIQTapLock: TCriticalSection; + FIQTapOn: Boolean; // дешёвая проверка «тап вообще есть» + // Кто-то (TCI/web) слушает аудио помимо динамика: тогда молчащий (mute) + // слайс всё равно надо считать — RX_AUDIO снимается ДО громкости и мьюта. + FAudioTapsActive: Boolean; function ModeToWDSP(Mode: Integer): Integer; function ModeToWDSPTX(Mode: Integer): Integer; @@ -638,6 +647,10 @@ type // Тап сырого RX-IQ наружу (потоки IQ по TCI). nil — снять. Ставит и // снимает поток контроллера; вызывается тап из DSP-потока. procedure SetIQTap(T: TIQTapEvent); + // Есть ли слушатели аудио-тапов (TCI/web). Влияет только на молчащие + // слайсы: с ними их всё равно надо считать, иначе клиент TCI остаётся без + // RX_AUDIO ровно потому, что оператор убрал звук у себя в комнате. + procedure SetAudioTapsActive(V: Boolean); // --- Панадаптеры на аппаратных DDC (этап 3.2) --- // Создаёт доп. пан PanId (1..MAX_PANS-1): аккумулятор + analyzer @@ -1506,6 +1519,11 @@ begin FBcnPixCount := 0; SetLength(FBcnDispBuf, DISPLAY_BLOCK_SIZE * 2); + // Тап IQ наружу (TCI) + FIQTapLock := TCriticalSection.Create; + FIQTapOn := False; + FAudioTapsActive := False; + // Мультислайсы FSliceLock := TCriticalSection.Create; FillChar(FSlices, SizeOf(FSlices), 0); @@ -1606,6 +1624,7 @@ begin FSliceLock.Free; FMainLock.Free; FBcnLock.Free; + FIQTapLock.Free; inherited; end; @@ -2424,8 +2443,21 @@ begin end; procedure TWDSPEngine.SetIQTap(T: TIQTapEvent); +// Поток контроллера. Под локом: снятие обязано ДОЖДАТЬСЯ выхода DSP-потока из +// вызова — сразу после нас владельца тапа освобождают. begin - FIQTap := T; + FIQTapLock.Enter; + try + FIQTap := T; + FIQTapOn := Assigned(T); + finally + FIQTapLock.Leave; + end; +end; + +procedure TWDSPEngine.SetAudioTapsActive(V: Boolean); +begin + FAudioTapsActive := V; end; procedure TWDSPEngine.PushDDCPacket(const Buf: array of Byte; @@ -2543,9 +2575,19 @@ begin // Тап IQ пана — как у главного тракта, один вызов на блок. Он идёт // ПОД FSliceLock (весь разбор пакета пана здесь), поэтому обработчик // обязан только скопировать данные и вернуться: любое ожидание тут - // остановит DSP-поток вместе со всеми панами. - if Assigned(FIQTap) then - FIQTap(PanId, @Pan^.AccI[0], @Pan^.AccQ[0], Pan^.BufSize, Pan^.Rate); + // остановит DSP-поток вместе со всеми панами. Порядок локов + // FSliceLock → FIQTapLock; обратного нет нигде (SetIQTap не берёт + // FSliceLock), так что клина не выходит. + if FIQTapOn then + begin + FIQTapLock.Enter; + try + if Assigned(FIQTap) then + FIQTap(PanId, @Pan^.AccI[0], @Pan^.AccQ[0], Pan^.BufSize, Pan^.Rate); + finally + FIQTapLock.Leave; + end; + end; if Assigned(FOnSliceAudio) or Assigned(FOnSliceDemodAudio) then try ProcessSlicesFor(PanId, Pan^.AccI, Pan^.AccQ, Pan^.BufSize); @@ -2666,8 +2708,17 @@ begin // Тап IQ наружу — по накопленному блоку и ДО обработки: fexchange0 // забирает аккумулятор как вход и не портит его, но полагаться на это // незачем, а один вызов на блок вместо вызова на сэмпл экономит всё. - if Assigned(FIQTap) then - FIQTap(0, @FRXAccI[0], @FRXAccQ[0], FBufSize, FSampleRate); + // Под FIQTapLock: см. SetIQTap. + if FIQTapOn then + begin + FIQTapLock.Enter; + try + if Assigned(FIQTap) then + FIQTap(0, @FRXAccI[0], @FRXAccQ[0], FBufSize, FSampleRate); + finally + FIQTapLock.Leave; + end; + end; ProcessRXBlock; end; end; @@ -2923,7 +2974,10 @@ begin if not (FSlices[s].Active and FSlices[s].Opened) then Continue; if FSlices[s].PanId <> PanId then Continue; // Muting a DMR speaker must not make its decoder lose synchronization. - if FSlices[s].Muted and not (FSlices[s].Mode in [MODE_DMR, MODE_FMRAW]) then Continue; + // ★То же и для тапов: RX_AUDIO по TCI снимается ДО громкости и мьюта, и + // молчащий в комнате слайс обязан продолжать отдавать звук клиенту. + if FSlices[s].Muted and not (FSlices[s].Mode in [MODE_DMR, MODE_FMRAW]) + and not FAudioTapsActive then Continue; // Свежая копия входного IQ — fexchange0 портит вход in-place. for i := 0 to N - 1 do begin @@ -2939,13 +2993,17 @@ begin FSliceOutL[k] := FSliceOut[k * 2]; FSliceOutR[k] := FSliceOut[k * 2 + 1]; end; - if FSlices[s].Mode in [MODE_DMR, MODE_FMRAW] then - begin - if Assigned(FOnSliceDemodAudio) then - FOnSliceDemodAudio(FSlices[s].Id, FSliceOutL, FSliceOutR, - FAudioBufSize); - Continue; // decoder-grade signal uses the independent pre-volume route - end; + // Отвод ДО громкости и мьюта — у КАЖДОГО слайса, а не только у цифровых. + // Это выход демодулятора: им кормятся декодеры (DMR/FM RAW) и он же уходит + // в RX_AUDIO по TCI. Пока вызов стоял только в цифровой ветке, клиент на + // обычном слайсе (DIGU у скиммера) не получал аудиопоток вовсе. + if Assigned(FOnSliceDemodAudio) then + FOnSliceDemodAudio(FSlices[s].Id, FSliceOutL, FSliceOutR, FAudioBufSize); + // Цифровым дальше нельзя: их звук идёт мимо громкости и мьюта (см. выше). + if FSlices[s].Mode in [MODE_DMR, MODE_FMRAW] then Continue; + // Мьют — это тишина в динамике и в линейном выходе; сюда мы дошли только + // ради тапа выше. + if FSlices[s].Muted then Continue; if Assigned(FOnSliceAudio) then begin V := FSlices[s].Volume; diff --git a/doc/TCI.md b/doc/TCI.md index 8661079..3e297f4 100644 --- a/doc/TCI.md +++ b/doc/TCI.md @@ -23,7 +23,7 @@ TCI-клиенты ──WebSocket──► TTCIServer ──► TTCIAdapter ─ | `TCIProtocol.pas` (~380 строк) | Чистый слой протокола: разбор `имя:арг1,арг2;`, сборка строк, экранирование `^ ~ *`, словарь видов связи, пересчёт громкости/порога в дБ. Зависит только от RTL + `RadioModes`. | | `TCIServer.pas` (~1250 строк) | WebSocket-сервер: accept-поток, поток на клиента, HTTP-Upgrade, разбор фреймов, рассылка, тик 20 мс. Сокеты и фреймы переиспользованы из веб-подсистемы (`WebUtils`, `WsClient`). | | `TCIAdapter.pas` (~2600 строк) | Мост к `TRadioController`: реализация команд, пачка инициализации, уведомления об изменениях состояния, измерители, захват параметров (§3.5), подключение потоков к тапам аудио/IQ. | -| `TCIStreams.pas` (~600 строк) | Бинарные потоки (§3.4): дециматор/интерполятор, нарезка блоков с заголовком, кольцо записи линейного выхода и писатель WAV. Зависит только от RTL + `TCIProtocol`/`TCIServer`. | +| `TCIStreams.pas` (~600 строк) | Бинарные потоки (§3.4): дециматор/интерполятор, нарезка блоков с заголовком, буфер записи линейного выхода и писатель WAV. Зависит только от RTL + `TCIProtocol`/`TCIServer`. | Принципы те же, что у CAT (см. `doc/CAT_STATUS.md`): @@ -98,13 +98,23 @@ TCI-клиенты ──WebSocket──► TTCIServer ──► TTCIAdapter ─ уложиться в `TCI_HANDSHAKE_MS` (5 с), иначе закрывается по таймауту сокета; восемь слотов `TCI_MAX_CLIENTS` считаются только среди поднявшихся. Раньше восемь молчащих TCP-соединений навсегда закрывали дверь настоящим клиентам. -5. **Браузерные клиенты не пускаются.** Handshake с заголовком `Origin` +5. **`recv` = 0 — это конец связи, а не таймаут.** Приёмный цикл клиента ждёт + не дольше `TCI_POLL_MS`, поэтому «ошибка» `EAGAIN` для него — норма, но + спрашивать `errno` разрешено **только при отрицательном** результате: ноль + означает EOF, `errno` при нём не трогается и вполне может нести `EAGAIN` от + прошлого истёкшего опроса. Пока проверка была общей (`R <= 0`), обычный + TCP-разрыв без close-кадра — упавший клиент, выдернутый кабель — выглядел + как таймаут: поток крутился впустую, слот не освобождался до остановки + сервера, и после нескольких аварийных отключений новые клиенты упирались в + `TCI_MAX_CLIENTS`. Места в буфере при этом всегда хватает (кадр крупнее + буфера рвётся раньше), так что иного смысла у нуля нет. +6. **Браузерные клиенты не пускаются.** Handshake с заголовком `Origin` получает 403. Origin шлёт только браузер, а авторизации в TCI нет: без этой проверки любая открытая вкладка дотягивалась бы по `ws://127.0.0.1:40001` до `TRX`, `TUNE` и `VFO`. Своей web-странице нужен явный прокси, а не дыра по умолчанию. -6. **Блоки потоков нарезает и раскладывает DSP-поток.** Тап зовётся прямо из +7. **Блоки потоков нарезает и раскладывает DSP-поток.** Тап зовётся прямо из потока WDSP, поэтому в `TCIStreams` нет ни одного ожидания: блок уходит в кольцо клиента (микросекунды под его локом), а в сокет его пишет, как и команды, поток самого клиента. Порядок локов везде один: `FSliceLock` → @@ -420,13 +430,17 @@ TCI-клиент отобрал бы звук у динамика и у брау в заголовке мы не будем ни в каком случае. Смена rate устройства или DDC пана на ходу пересобирает прореживание (`SetSourceRate`). -**Размер блока.** `AUDIO_STREAM_SAMPLES` — это сэмплы **на канал**, а в -`Stream.length` уходит, как велит §3.4, количество вещественных отсчётов -(`samples × channels`). Сходится и с умолчаниями ExpertSDR3 (2048 при 48 кГц -даёт ~43 мс), и с потолком `data[16384]`: 2048 × 2 канала × float32 = ровно -16384 байта. Смена любого параметра потока на ходу **перезапускает** уже идущие -потоки этого клиента: блок с новой частотой посреди старого потока клиенты -разбирают как мусор. +**Размер блока.** `AUDIO_STREAM_SAMPLES` — это сэмплы **на канал**, и у +аудиопотоков ровно это число уходит в `Stream.length`: §4.3 говорит про arg1 +«количество сэмплов, указываемое в поле Stream.length», а умолчание 2048 при +48 кГц сходится с потолком `data[16384]` только так — 2048 × 2 канала × +float32 = ровно 16384 байта, то есть `length` считает сэмплы, а не отсчёты. +У **IQ** единица другая: §3.4 определяет число комплексных отсчётов как +`length / channels`, поэтому там в `length` уходит `samples × channels`. +Развилку держит `TCIFillHeader` (по типу потока), обратную — разбор +`TX_AUDIO_STREAM` в `HandleBinary`. Смена любого параметра потока на ходу +**перезапускает** уже идущие потоки этого клиента: блок с новой частотой +посреди старого потока клиенты разбирают как мусор. **Передача (§3.4, §4.2).** `TRX:0,true,tci` берёт модуляцию из аудиопотока клиента — но только если у него запущен `AUDIO_START` (буквально по документу: @@ -440,9 +454,19 @@ web-клиента: явная просьба сильнее умолчания отбрасывается молча: отвечать ошибкой на каждый чужой блок значит захлебнуться. **Запись линейного выхода.** Рекордер один на приёмник (а не на клиента): -пишет он то, что слышно в аппарате. Кольцо на запрошенное время (потолок 300 с) +пишет он то, что слышно в аппарате. Буфер на запрошенное время (потолок 300 с) в int16 48 кГц стерео — это ровно то, что уйдёт в WAV, и вдвое меньше памяти, -чем float32. `SAVE` завершает запись и отдаёт кольцо отдельному потоку-писателю: +чем float32. ★Буфер **линейный, не кольцевой**: §4.3 называет arg2 +максимальным временем записи и прямо говорит, что по его истечении запись +удаляется, а чтобы сохранить файл, `SAVE` нужно прислать внутри интервала. +Поэтому `START` открывает окно длиной arg2, пишем от его начала, а по концу +окна данные выбрасываются вместе с памятью (`DropData` из `Feed` и из `Take`). +Срок считается **по часам** от `START`, а не по накопленным сэмплам: линейный +выход может молчать (мьют, стоящий приёмник), а время записи всё равно идёт. +Кольцо «последние N секунд» вело себя иначе в обе стороны — начало записи +затирало само себя, а `SAVE` через час после `START` отдавал файл, которого у +ExpertSDR3 давно бы не было. +`SAVE` завершает запись и отдаёт буфер отдельному потоку-писателю: файл бывает в десятки мегабайт, а команда пришла в потоке клиента, который в это время не читает свой сокет. MP3 не поддержан — кодера в проекте нет, и на `.mp3` уходит честный `tci_error`. @@ -563,8 +587,10 @@ web-клиента: явная просьба сильнее умолчания HTTP-заголовков. - **Транспорт:** 101 на заголовки с табуляцией и без пробела после двоеточия; отказ 403 при `Origin`; ровно восемь поднявшихся клиентов и 503 девятому; - освобождение слотов после отключения; молчащий сокет уходит по таймауту - handshake; ответный close-кадр; `Stop` с живым клиентом. + освобождение слотов после отключения — **обоими** путями, и штатным + close-кадром, и обычным TCP-разрывом без него (см. правило 5 в §1.1); + молчащий сокет уходит по таймауту handshake; ответный close-кадр; + `Stop` с живым клиентом. - **Адаптер:** `vfo:0,0,abc`, `vfo:0,0,-1`, `dds:0,broken`, `rx_filter_band:0,x,y` не меняют ничего, а годная частота проходит; захват параметра (второй клиент не перебивает первого раньше 200 мс и перебивает @@ -593,8 +619,8 @@ web-клиента: явная просьба сильнее умолчания ### Стенд этапа 2 (бинарные потоки) — 112 проверок, все зелёные -Отдельная программа (`tcitest.pas` в scratchpad, собирается тем же fpc без -внешних библиотек) проверяет потоки на четырёх уровнях: +Отдельная программа (`test/tci/tcitest.pas`, прогон — `test/tci/run.sh`, +внешних библиотек не требует) проверяет потоки на четырёх уровнях: - **Формат и математика:** коэффициенты прореживания (в том числе «просят выше, чем есть» и «нацело не делится»), умолчания размера блока, поля заголовка, @@ -612,9 +638,10 @@ web-клиента: явная просьба сильнее умолчания стерео float32 с верным заголовком и длиной; IQ 384→48 кГц уходит только целым блоком (полблока не отправляется); моно int16; пересчёт после смены rate источника; переполнение кольца теряет старые блоки, но клиент жив. -- **Рекордер и WAV:** кольцо ограничено запрошенным временем, после заворота - первым идёт самый старый отсчёт, `Take` опустошает, файл получает верные - RIFF/fmt/data и длину. +- **Рекордер и WAV:** буфер ограничен запрошенным временем и пишется с начала + окна (переполнение отбрасывает новое, а не затирает старое), до срока запись + есть, после срока её нет и истёкший рекордер не оживает, `Take` завершает + запись, файл получает верные RIFF/fmt/data и длину. - **Команды на живом сервере** (настоящий `TRadioController`, WS-клиент на сыром сокете): отказ на несуществующий приёмник и на нечисловой аргумент, отказ на старт потока с незапущенного пана, подтверждение и отбраковка @@ -651,9 +678,9 @@ send просто возвращает EPIPE, и клиент выбрасыва TCI в его граф пока не заведён — юниты LCL-free, подключается одной строкой в `ewsdrd.lpr`, как web). -★Пробная сборка стенда: `-Mobjfpc` обязателен. С `-Mdelphi` в командной строке -вложенные комментарии выключаются, и `{$MODE Delphi}` внутри шапки `WebUtils.pas` -закрывает комментарий раньше времени — компиляция падает на «illegal character». +★Про сборку стенда — `test/tci/README.md`: там записаны обе грабли (обязательный +`-Mobjfpc` и отдельный каталог `.ppu` с абсолютным путём, иначе линковка тянет +`PlatformUtils` из GUI-сборки, собранный с LCL). **На реальном железе и с реальным клиентом (Log4OM/N1MM/WSJT-X/CW Skimmer) не проверялось — ни команды, ни потоки.** diff --git a/test/tci/README.md b/test/tci/README.md new file mode 100644 index 0000000..0d83f35 --- /dev/null +++ b/test/tci/README.md @@ -0,0 +1,47 @@ +# Стенд TCI + +Прогон: + +```sh +test/tci/run.sh +``` + +Собирает `tcitest.pas` и запускает его; код возврата ненулевой, если хоть одна +проверка провалена. Внешних библиотек стенд не требует — WebSocket-клиент в нём +написан на сыром сокете. `libwdsp` нужна только последней части (сквозной +прогон через движок); без неё эта часть сообщает о пропуске, а остальные идут +как обычно. + +Что проверяется — в шапке `tcitest.pas`; чего стенд **не** покрывает — +в `doc/TCI.md`, §5. + +Две грабли, из-за которых стенд собирается именно так (обе стоили по часу): + +* **`-Mobjfpc` обязателен.** С `-Mdelphi` в командной строке выключаются + вложенные комментарии, и `{$MODE Delphi}` внутри шапки `WebUtils.pas` + закрывает комментарий раньше времени — компиляция падает на + «illegal character». +* **Каталог `.ppu` — свой, с именем не `lib`, и путь абсолютный.** + Относительное `lib/-` компилятор ищет и относительно `-Fu`, то есть + находит units GUI-сборки в корне проекта, а там `PlatformUtils` собран с LCL: + стенд падает на линковке с `undefined reference to TC_$FORMS_$$_SCREEN`. + По той же причине сборка идёт с `-dHEADLESS`, как у демона. + +Каталоги `units/` и `bin/` — выход сборки, в git не попадают. + +## Проверка на живом эфире без передачи + +```sh +python3 test/tci/live_rx_test.py --host 127.0.0.1 --port 40001 +``` + +Наглядный UI-прогон с автоматическим возвратом к исходным значениям: + +```sh +python3 -u test/tci/live_rx_test.py --visible --commands-only --dwell 1.5 +``` + +Стенд проверяет receive-only команды, IQ, аудио демодулятора и +line-out для всех обнаруженных приёмников. Он никогда не посылает +`START`, `STOP`, установку `TRX`/`TUNE`, TX-аудио и CW-текст. Если радио +уже на передаче, прогон аварийно прекращается. diff --git a/test/tci/live_rx_test.py b/test/tci/live_rx_test.py new file mode 100644 index 0000000..3ef88c9 --- /dev/null +++ b/test/tci/live_rx_test.py @@ -0,0 +1,527 @@ +#!/usr/bin/env python3 +"""Live receive-only TCI acceptance test. Uses only Python's standard library. + +The test never sends START/STOP, TRX with a value, TUNE with a value, TX audio, +CW text, or any other command capable of keying the transmitter. +""" + +import argparse +import math +import os +import select +import socket +import struct +import sys +import time + + +HDR = struct.Struct("<16I") +STREAM_NAMES = {0: "IQ", 1: "RX_AUDIO", 2: "TX_AUDIO", 3: "TX_CHRONO", 4: "LINEOUT"} +SAMPLE_BYTES = {0: 2, 1: 3, 2: 4, 3: 4} + + +def split_commands(text): + return [part.strip() + ";" for part in text.split(";") if part.strip()] + + +def parse_command(command): + body = command.rstrip(";") + if ":" not in body: + return body.lower(), [] + name, args = body.split(":", 1) + return name.lower(), args.split(",") + + +class WS: + def __init__(self, host, port): + self.sock = socket.create_connection((host, port), timeout=4) + self.sock.settimeout(None) + key = "dGhlIHNhbXBsZSBub25jZQ==" + req = (f"GET / HTTP/1.1\r\nHost: {host}:{port}\r\n" + "Upgrade: websocket\r\nConnection: Upgrade\r\n" + f"Sec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n\r\n") + self.sock.sendall(req.encode("ascii")) + raw = b"" + while b"\r\n\r\n" not in raw: + raw += self.sock.recv(4096) + head, self.buf = raw.split(b"\r\n\r\n", 1) + if b" 101 " not in head.split(b"\r\n", 1)[0]: + raise RuntimeError(head.decode("latin1", "replace")) + + def close(self): + # Finish the WebSocket protocol explicitly. A bare TCP EOF is also + # legal, but the server's EOF path is tested separately and must not + # make every acceptance run consume a leaked client slot. + try: + self.send_frame(8, b"") + ready, _, _ = select.select([self.sock], [], [], 0.25) + if ready: + self.sock.recv(4096) + except OSError: + pass + try: + self.sock.shutdown(socket.SHUT_RDWR) + except OSError: + pass + self.sock.close() + + def send_frame(self, opcode, payload=b""): + mask = os.urandom(4) + n = len(payload) + if n <= 125: + head = bytes((0x80 | opcode, 0x80 | n)) + elif n <= 65535: + head = bytes((0x80 | opcode, 0xFE)) + struct.pack("!H", n) + else: + head = bytes((0x80 | opcode, 0xFF)) + struct.pack("!Q", n) + masked = bytes(v ^ mask[i & 3] for i, v in enumerate(payload)) + self.sock.sendall(head + mask + masked) + + def send_text(self, command): + self.send_frame(1, command.encode("utf-8")) + + def _need(self, n, deadline): + while len(self.buf) < n: + left = deadline - time.monotonic() + if left <= 0: + return False + ready, _, _ = select.select([self.sock], [], [], left) + if not ready: + return False + block = self.sock.recv(65536) + if not block: + raise ConnectionError("TCI connection closed") + self.buf += block + return True + + def recv_frame(self, timeout=1.0): + deadline = time.monotonic() + timeout + if not self._need(2, deadline): + return None + b0, b1 = self.buf[0], self.buf[1] + pos, n = 2, b1 & 0x7f + if n == 126: + if not self._need(4, deadline): + return None + n, pos = struct.unpack("!H", self.buf[2:4])[0], 4 + elif n == 127: + if not self._need(10, deadline): + return None + n, pos = struct.unpack("!Q", self.buf[2:10])[0], 10 + if not self._need(pos + n, deadline): + return None + payload = self.buf[pos:pos + n] + self.buf = self.buf[pos + n:] + opcode = b0 & 0x0f + if opcode == 9: + self.send_frame(10, payload) + return self.recv_frame(max(0.0, deadline - time.monotonic())) + return opcode, payload + + +class LiveTest: + def __init__(self, ws, seconds): + self.ws = ws + self.seconds = seconds + self.passed = 0 + self.failed = 0 + self.warned = 0 + self.text = [] + self.binary = [] + self.initial = [] + + def ok(self, label, condition, detail=""): + if condition: + self.passed += 1 + print(f" ok {label}" + (f" — {detail}" if detail else "")) + else: + self.failed += 1 + print(f" FAIL {label}" + (f" — {detail}" if detail else "")) + + def warn(self, label, detail=""): + self.warned += 1 + print(f" WARN {label}" + (f" — {detail}" if detail else "")) + + def pump(self, seconds): + deadline = time.monotonic() + seconds + while time.monotonic() < deadline: + frame = self.ws.recv_frame(min(0.2, deadline - time.monotonic())) + if frame is None: + continue + opcode, payload = frame + if opcode == 1: + self.text.extend(split_commands(payload.decode("utf-8", "replace"))) + elif opcode == 2: + self.binary.append(payload) + + def wait_text(self, name, seconds=1.5): + deadline = time.monotonic() + seconds + while time.monotonic() < deadline: + for i, command in enumerate(self.text): + got, args = parse_command(command) + if got == name: + self.text.pop(i) + return command, args + self.pump(min(0.1, deadline - time.monotonic())) + return None, [] + + def init(self): + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + self.pump(0.2) + ready = next((x for x in self.text if parse_command(x)[0] == "ready"), None) + if ready: + self.initial = list(self.text) + self.text.clear() + return + raise RuntimeError("READY not received") + + def initial_by_name(self, name): + return [parse_command(x)[1] for x in self.initial if parse_command(x)[0] == name] + + def initial_checks(self): + names = [parse_command(x)[0] for x in self.initial] + required = { + "protocol", "device", "receive_only", "trx_count", "channel_count", + "vfo_limits", "if_limits", "modulations_list", "dds", "vfo", "if", + "modulation", "rx_filter_band", "agc_mode", "tx_enable", "trx", + "tune", "drive", "volume", "mute", "iq_samplerate", + "audio_samplerate", "tx_frequency", "app_focus", "ready", + } + missing = sorted(required.difference(names)) + self.ok("initial state contains required commands", not missing, + "missing=" + ",".join(missing) if missing else f"commands={len(names)}") + self.ok("READY is last initialization command", + bool(names) and names[-1] == "ready", names[-1] if names else "empty") + + def query(self, command, response, echo_same=False): + self.text.clear() + self.ws.send_text(command) + got, args = self.wait_text(response) + self.ok(command.rstrip(";"), got is not None, got or "no response") + if echo_same and got is not None: + self.text.clear() + self.ws.send_text(got) + echoed, _ = self.wait_text(response) + self.ok("SET same " + command.rstrip(";"), echoed is not None, + echoed or "no confirmation") + return args + + def command_checks(self, receivers, channels): + print("Commands: receive-only queries") + for rx in receivers: + for name in ("dds", "modulation", "rx_filter_band", "agc_mode", "agc_gain", + "lock", "sql_enable", "sql_level", "rx_mute", "rx_nr_enable", + "rx_nb_enable", "rx_anf_enable", "rx_nb_param", "rx_bin_enable", + "rx_anc_enable", "rx_apf_enable", "rx_dse_enable", "rx_nf_enable", + "rit_enable", "rit_offset", "xit_enable", "xit_offset"): + self.query(f"{name}:{rx};", name, echo_same=True) + for ch in channels.get(rx, []): + for name in ("vfo", "if", "rx_volume", "rx_balance"): + self.query(f"{name}:{rx},{ch};", name, echo_same=True) + # Stub today, but its wire contract still has to be valid. + self.query(f"rx_channel_enable:{rx},1;", "rx_channel_enable", echo_same=True) + for command, response in (("trx:0;", "trx"), ("tune:0;", "tune"), + ("drive:0;", "drive"), ("tune_drive:0;", "tune_drive"), + ("split_enable:0;", "split_enable"), ("volume;", "volume"), + ("mute;", "mute"), ("mon_volume;", "mon_volume"), + ("mon_enable;", "mon_enable"), + ("cw_macros_speed;", "cw_macros_speed"), + ("cw_macros_delay;", "cw_macros_delay"), + ("digl_offset;", "digl_offset"), + ("digu_offset;", "digu_offset")): + self.query(command, response, + echo_same=response not in ("trx", "tune")) + + # Exercise safe one-way commands without changing persistent radio state. + self.ws.send_text("rx_sensors_enable:true,100;") + got, _ = self.wait_text("rx_sensors", 2) + self.ok("RX_SENSORS_ENABLE", got is not None, got or "no sensor report") + self.ws.send_text("rx_sensors_enable:false;") + self.ws.send_text("tx_sensors_enable:true,100;") + got, _ = self.wait_text("tx_sensors", 2) + self.ok("TX_SENSORS_ENABLE while RX", got is not None, + got or "no sensor report") + self.ws.send_text("tx_sensors_enable:false;") + self.query("cw_keyer_speed;", "cw_macros_speed") + self.ws.send_text("spot:ZZ0TCITEST,usb,14074000,4294967295,TCI RX test;") + self.ws.send_text("spot_delete:ZZ0TCITEST;") + self.ok("SPOT/SPOT_DELETE", True, "temporary marker removed") + + # Negative TX safety guard: malformed values must not key anything. + self.ws.send_text("trx:0,not-a-bool,tci;") + _, trx = self.wait_text("trx") + self.ok("TRX malformed value rejected", len(trx) >= 2 and trx[1].lower() == "false", + ",".join(trx)) + self.ws.send_text("tune:0,not-a-bool;") + _, tune = self.wait_text("tune") + self.ok("TUNE malformed value rejected", len(tune) >= 2 and tune[1].lower() == "false", + ",".join(tune)) + + def visible_change(self, label, response, changed, restored, dwell): + """Apply one RX-only change, leave it visible, then restore it.""" + self.text.clear() + self.ws.send_text(changed) + got, args = self.wait_text(response, 2) + self.ok(label + " changed", got is not None, got or "no confirmation") + time.sleep(dwell) + self.text.clear() + self.ws.send_text(restored) + got, args = self.wait_text(response, 2) + self.ok(label + " restored", got is not None, got or "no confirmation") + time.sleep(0.3) + + def visible_checks(self, receivers, channels, dwell): + print(f"Visible UI sweep: dwell={dwell:.1f}s; every value is restored") + for rx in receivers: + dds = self.query(f"dds:{rx};", "dds") + if len(dds) >= 2: + old = int(float(dds[1])) + self.visible_change(f"DDS rx={rx}", "dds", + f"dds:{rx},{old + 5000};", f"dds:{rx},{old};", dwell) + + mode = self.query(f"modulation:{rx};", "modulation") + filt = self.query(f"rx_filter_band:{rx};", "rx_filter_band") + if len(mode) >= 2: + old_mode = mode[1].lower() + alternate = {"nfm": "am", "digu": "usb", "digl": "lsb", + "fmraw": "nfm", "dmr": "nfm"}.get(old_mode, "am") + if alternate == old_mode: + alternate = "usb" + self.visible_change(f"MODULATION rx={rx}", "modulation", + f"modulation:{rx},{alternate};", + f"modulation:{rx},{old_mode};", dwell) + + if len(filt) >= 3: + lo, hi = int(filt[1]), int(filt[2]) + if hi - lo > 400: + self.visible_change(f"FILTER rx={rx}", "rx_filter_band", + f"rx_filter_band:{rx},{lo + 100},{hi - 100};", + f"rx_filter_band:{rx},{lo},{hi};", dwell) + + agc = self.query(f"agc_mode:{rx};", "agc_mode") + if len(agc) >= 2: + old_agc = agc[1].lower() + new_agc = "fast" if old_agc != "fast" else "normal" + self.visible_change(f"AGC rx={rx}", "agc_mode", + f"agc_mode:{rx},{new_agc};", + f"agc_mode:{rx},{old_agc};", dwell) + + nr = self.query(f"rx_nr_enable:{rx};", "rx_nr_enable") + if len(nr) >= 2: + old_nr = nr[1].lower() + new_nr = "false" if old_nr == "true" else "true" + self.visible_change(f"NR rx={rx}", "rx_nr_enable", + f"rx_nr_enable:{rx},{new_nr};", + f"rx_nr_enable:{rx},{old_nr};", dwell) + + sql = self.query(f"sql_enable:{rx};", "sql_enable") + if len(sql) >= 2: + old_sql = sql[1].lower() + new_sql = "false" if old_sql == "true" else "true" + self.visible_change(f"SQL rx={rx}", "sql_enable", + f"sql_enable:{rx},{new_sql};", + f"sql_enable:{rx},{old_sql};", dwell) + + for ch in channels.get(rx, []): + vfo = self.query(f"vfo:{rx},{ch};", "vfo") + if len(vfo) >= 3: + old_vfo = int(float(vfo[2])) + self.visible_change(f"VFO rx={rx} ch={ch}", "vfo", + f"vfo:{rx},{ch},{old_vfo + 2000};", + f"vfo:{rx},{ch},{old_vfo};", dwell) + vol = self.query(f"rx_volume:{rx},{ch};", "rx_volume") + if len(vol) >= 3: + old_vol = int(float(vol[2])) + new_vol = old_vol - 12 if old_vol > -49 else old_vol + 12 + new_vol = max(-60, min(0, new_vol)) + self.visible_change(f"VOLUME rx={rx} ch={ch}", "rx_volume", + f"rx_volume:{rx},{ch},{new_vol};", + f"rx_volume:{rx},{ch},{old_vol};", dwell) + + def sample_stats(self, payload, fmt, length): + data = payload[HDR.size:] + count = min(length, 4096) + vals = [] + if fmt == 3: + vals = struct.unpack_from(f"<{count}f", data) + elif fmt == 0: + vals = [v / 32768.0 for v in struct.unpack_from(f"<{count}h", data)] + elif fmt == 1: + vals = [] + for i in range(min(count, len(data) // 3)): + v = data[i * 3] | (data[i * 3 + 1] << 8) | (data[i * 3 + 2] << 16) + if v & 0x800000: + v -= 1 << 24 + vals.append(v / 8388608.0) + elif fmt == 2: + vals = [v / 2147483648.0 for v in struct.unpack_from(f"<{count}i", data)] + finite = [v for v in vals if math.isfinite(v)] + if not finite: + return 0.0, 0.0, False + rms = math.sqrt(sum(v * v for v in finite) / len(finite)) + return rms, max(abs(v) for v in finite), len(finite) == len(vals) + + def stream(self, rx, start, stop, wanted_type, requested_length=None): + self.binary.clear() + self.text.clear() + self.ws.send_text(f"{start}:{rx};") + self.pump(self.seconds) + self.ws.send_text(f"{stop}:{rx};") + self.pump(0.25) + errors = [x for x in self.text if parse_command(x)[0] == "tci_error"] + blocks = [] + for payload in self.binary: + if len(payload) < HDR.size: + continue + h = HDR.unpack_from(payload) + if h[6] == wanted_type and h[0] == rx: + blocks.append((payload, h)) + label = f"{STREAM_NAMES[wanted_type]} rx={rx}" + self.ok(label + " blocks", bool(blocks), + errors[0] if errors else f"blocks={len(blocks)}") + if not blocks: + return + bad = 0 + rms_values = [] + rates = set() + for payload, h in blocks: + receiver, rate, fmt, codec, crc, length, kind, chans = h[:8] + rates.add(rate) + # IQ length counts real values across I/Q; audio length is samples + # per channel, so its payload additionally includes every channel. + payload_samples = length if kind == 0 else length * chans + expected = HDR.size + payload_samples * SAMPLE_BYTES.get(fmt, 0) + if codec != 0 or crc != 0 or chans not in (1, 2) or expected != len(payload): + bad += 1 + if requested_length is not None and length != requested_length: + bad += 1 + rms, peak, finite = self.sample_stats(payload, fmt, length) + if not finite: + bad += 1 + rms_values.append(rms) + self.ok(label + " headers", bad == 0, + f"blocks={len(blocks)}, bad={bad}, rates={sorted(rates)}") + rms = max(rms_values) if rms_values else 0.0 + if rms > 1e-7: + self.ok(label + " signal", True, f"max RMS={rms:.6g}") + else: + self.warn(label + " signal is silent", "check mute/squelch and tuned signal") + + def stream_checks(self, receivers): + print("Streams: IQ, demod audio and line-out") + self.ws.send_text("iq_samplerate:48000;") + self.wait_text("iq_samplerate") + self.ws.send_text("audio_samplerate:12000;") + self.wait_text("audio_samplerate") + self.ws.send_text("audio_stream_channels:2;") + self.ws.send_text("audio_stream_sample_type:float32;") + self.ws.send_text("audio_stream_samples:512;") + self.pump(0.2) + for rx in receivers: + self.stream(rx, "iq_start", "iq_stop", 0) + self.stream(rx, "audio_start", "audio_stop", 1, 512) + self.stream(rx, "line_out_start", "line_out_stop", 4, 512) + + def format_matrix(self, receivers): + """Exercise every negotiated audio representation on one live RX.""" + rx = receivers[1] if len(receivers) > 1 else receivers[0] + old_seconds = self.seconds + self.seconds = 0.7 + print(f"Audio format/rate matrix on rx={rx}") + try: + self.ws.send_text("audio_samplerate:12000;") + self.wait_text("audio_samplerate") + self.ws.send_text("audio_stream_samples:128;") + for channels in (1, 2): + self.ws.send_text(f"audio_stream_channels:{channels};") + for sample_type in ("int16", "int24", "int32", "float32"): + self.ws.send_text(f"audio_stream_sample_type:{sample_type};") + self.pump(0.05) + print(f" config channels={channels}, type={sample_type}") + self.stream(rx, "audio_start", "audio_stop", 1, 128) + self.stream(rx, "line_out_start", "line_out_stop", 4, 128) + self.ws.send_text("audio_stream_channels:2;") + self.ws.send_text("audio_stream_sample_type:float32;") + for rate in (8000, 12000, 24000, 48000): + self.ws.send_text(f"audio_samplerate:{rate};") + self.wait_text("audio_samplerate") + self.ws.send_text("audio_stream_samples:128;") + print(f" config rate={rate}") + self.stream(rx, "audio_start", "audio_stop", 1, 128) + finally: + self.seconds = old_seconds + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--host", default="127.0.0.1") + ap.add_argument("--port", type=int, default=40001) + ap.add_argument("--seconds", type=float, default=2.0, + help="capture time for each stream and receiver") + ap.add_argument("--commands-only", action="store_true", + help="skip IQ/audio capture") + ap.add_argument("--streams-only", action="store_true", + help="skip command matrix and capture only IQ/audio") + ap.add_argument("--format-matrix", action="store_true", + help="test every audio sample type/channel count and rate") + ap.add_argument("--visible", action="store_true", + help="visibly change RX parameters and restore every value") + ap.add_argument("--dwell", type=float, default=1.5, + help="seconds to leave each visible test value active") + args = ap.parse_args() + + print(f"Connecting to ws://{args.host}:{args.port} (RX-only safety mode)") + ws = WS(args.host, args.port) + test = LiveTest(ws, args.seconds) + try: + test.init() + test.initial_checks() + protocol = test.initial_by_name("protocol") + test.ok("READY/init", bool(protocol), str(protocol)) + trx = test.initial_by_name("trx") + if trx and len(trx[-1]) >= 2 and trx[-1][1].lower() == "true": + raise RuntimeError("radio is already transmitting; aborting RX-only test") + + channels = {} + for row in test.initial_by_name("vfo"): + if len(row) >= 3: + channels.setdefault(int(row[0]), set()).add(int(row[1])) + channels = {rx: sorted(value) for rx, value in channels.items()} + receivers = sorted({int(row[0]) for row in test.initial_by_name("dds") if row}) + print("Discovered receivers/channels:", receivers, channels) + test.ok("live receivers found", bool(receivers), str(receivers)) + test.ok("three slices/channels visible", sum(map(len, channels.values())) >= 3, + str(channels)) + + if args.format_matrix: + test.format_matrix(receivers) + elif args.visible: + test.visible_checks(receivers, channels, args.dwell) + elif not args.streams_only: + test.command_checks(receivers, channels) + if not args.commands_only and not args.visible and not args.format_matrix: + test.stream_checks(receivers) + print("Skipped by RF-safety policy: START, STOP, TRX/TUNE setters, TX audio, " + "CW text/keying, SPOT_CLEAR and SET_IN_FOCUS") + finally: + # Idempotent receive-side cleanup only. Never touch TRX/TUNE. + for rx in range(8): + for name in ("iq_stop", "audio_stop", "line_out_stop"): + try: + ws.send_text(f"{name}:{rx};") + except OSError: + pass + try: + ws.send_text("rx_sensors_enable:false;") + except OSError: + pass + ws.close() + + print(f"\nResult: {test.passed + test.failed} checks, " + f"failed={test.failed}, warnings={test.warned}") + return 1 if test.failed else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/test/tci/run.sh b/test/tci/run.sh new file mode 100755 index 0000000..1e4981e --- /dev/null +++ b/test/tci/run.sh @@ -0,0 +1,29 @@ +#!/bin/sh +# Стенд TCI: сборка и прогон. Запускать откуда угодно — скрипт сам перейдёт +# в свой каталог. Возвращает ненулевой код, если хоть одна проверка провалена. +# +# Флаги те же, что у демона (см. build-ewsdrd.sh), и по тем же причинам: +# -Mobjfpc — командный режим по умолчанию: включает вложенные комментарии. +# С -Mdelphi {$MODE Delphi} внутри шапки WebUtils.pas закрывает +# комментарий раньше времени, и компиляция падает на +# «illegal character». Юниты дальше сами ставят свой {$mode}. +# -dHEADLESS — PlatformUtils не тянет Forms (LCL): стенду виджетсет не нужен, +# а без этого сборка требует всю LCL. +# ★Каталог .ppu — свой, с именем НЕ «lib», и путь абсолютный. Относительное +# «lib/-» компилятор ищет и относительно -Fu, то есть находит units +# GUI-сборки в корне проекта — а там PlatformUtils собран с LCL, и стенд падает +# на линковке с «undefined reference to TC_$FORMS_$$_SCREEN». +# libwdsp нужна только части E (сквозной прогон через движок); без неё эта +# часть сообщает о пропуске, а остальные идут как обычно. +set -e +cd "$(dirname "$0")" +HERE=$(pwd) +CPU=$(fpc -iTP) +OS=$(fpc -iTO) +OUTUNITS="$HERE/units/${CPU}-${OS}" +OUT="$HERE/bin/${CPU}-${OS}" +mkdir -p "$OUTUNITS" "$OUT" +fpc -Mobjfpc -O2 -dHEADLESS \ + -Fu../.. -FU"$OUTUNITS" -k-L/usr/local/lib \ + -o"$OUT/tcitest" tcitest.pas +exec "$OUT/tcitest" "$@" diff --git a/test/tci/tcitest.pas b/test/tci/tcitest.pas new file mode 100644 index 0000000..8805841 --- /dev/null +++ b/test/tci/tcitest.pas @@ -0,0 +1,1272 @@ +program tcitest; + +{ + Стенд этапа 2 TCI: бинарные потоки (§3.4). Запуск: test/tci/run.sh + + Проверяет слои по отдельности и сквозным прогоном: + A. формат блока и математика — TCIProtocol; + A2. пересчёт частоты — дециматор, каскад, интерполятор; + B. исходящий блок до сокета — TTCIStreamOut → TTCIClient → socketpair; + C. рекордер и WAV; + D. команды потоков в живом сервере с настоящим TRadioController; + E. сквозной прогон через ЖИВОЙ WDSP: синтетический IQ в движок → блоки + RX-аудио и IQ у настоящего WS-клиента, и обратно TX-аудио клиента → + блоки TX-IQ. + + Внешних библиотек не требует: WS-клиент написан на сыром сокете. Для части E + нужен libwdsp (тот же, что и приложению); если он не поднялся, эта часть + сообщает о пропуске, а не валит прогон. + + ★Собирать только с -Mobjfpc (см. run.sh): в режиме Delphi выключаются + вложенные комментарии, и {$MODE Delphi} внутри шапки WebUtils.pas закрывает + комментарий раньше времени — компиляция падает на «illegal character». +} + +{$MODE Delphi} +{$LONGSTRINGS ON} + +uses + cthreads, Classes, SysUtils, Math, SyncObjs, Sockets, BaseUnix, + TCIProtocol, TCIStreams, TCIServer, TCIAdapter, RadioController, + WsClient, WebUtils, WDSPEngine, Settings; + +var + Passed, Failed: Integer; + +procedure Check(const Name: string; OK: Boolean; const Detail: string = ''); +begin + if OK then + begin + Inc(Passed); + WriteLn(' ok ', Name); + end + else + begin + Inc(Failed); + WriteLn(' FAIL ', Name, ' ', Detail); + end; +end; + +function Near(A, B, Eps: Double): Boolean; +begin + Result := Abs(A - B) <= Eps; +end; + +function RMS(const A: array of Single; N: Integer): Double; +var i: Integer; S: Double; +begin + S := 0; + for i := 0 to N - 1 do S := S + A[i] * A[i]; + if N = 0 then Result := 0 else Result := Sqrt(S / N); +end; + +{ ═══════════════════════════════════════════════════════════════════════════ + A. Протокол и математика + ═══════════════════════════════════════════════════════════════════════════ } + +procedure TestProtocol; +var + H: TTCIStreamHeader; + Src: array[0..7] of Single; + Dst: array[0..7] of Single; + Buf: array[0..63] of Byte; + N, i: Integer; + T: TTCISampleType; +begin + WriteLn('A. Протокол потоков'); + + Check('decim 48k→12k = 4', TCIDecimFactor(48000, 12000) = 4); + Check('decim 48k→8k = 6', TCIDecimFactor(48000, 8000) = 6); + Check('decim 48k→48k = 1', TCIDecimFactor(48000, 48000) = 1); + Check('decim 384k→48k = 8', TCIDecimFactor(384000, 48000) = 8); + Check('decim 192k→48k = 4', TCIDecimFactor(192000, 48000) = 4); + // Запрошено больше, чем есть, — вверх не тянем, отдаём как есть. + Check('decim 48k→96k = 1', TCIDecimFactor(48000, 96000) = 1); + // Нацело не делится: берём ближайший делитель, не опускаясь ниже просьбы. + Check('decim 50k→12k = 4', TCIDecimFactor(50000, 12000) = 4, + IntToStr(TCIDecimFactor(50000, 12000))); + + Check('samples@48k = 2048', TCIDefaultAudioSamples(48000) = 2048); + Check('samples@12k = 512', TCIDefaultAudioSamples(12000) = 512); + Check('block float32×2', TCIMaxBlockSamples(tsyFloat32, 2) = 2048); + Check('block int16×1', TCIMaxBlockSamples(tsyInt16, 1) = 8192); + Check('bytes int24 = 3', TCISampleBytes(tsyInt24) = 3); + + Check('type by name int24', TCISampleTypeByName('INT24', T) and (T = tsyInt24)); + Check('type by name мусор', not TCISampleTypeByName('int8', T)); + Check('type name float32', TCISampleTypeName(tsyFloat32) = 'float32'); + + TCIFillHeader(H, tstRXAudio, 2, 12000, tsyInt16, 512, 2); + Check('header receiver', H.Receiver = 2); + Check('header rate', H.SampleRate = 12000); + Check('header format', H.Format = LongWord(Ord(tsyInt16))); + // ★Аудио: length — сэмплы НА КАНАЛ, ровно то число, что назвал + // AUDIO_STREAM_SAMPLES (§4.3). Раньше сюда уходило вдвое больше. + Check('header length аудио = на канал', H.DataLength = 512); + Check('header type', H.StreamType = LongWord(Ord(tstRXAudio))); + Check('header channels', H.Channels = 2); + Check('header codec/crc', (H.Codec = 0) and (H.CRC = 0)); + Check('header size 64', SizeOf(H) = TCI_STREAM_HDR_SIZE); + // ★IQ: а тут length — вещественные отсчёты, то есть вдвое больше + // комплексных (§3.4 прямо: комплексных = length/channels). + TCIFillHeader(H, tstIQ, 0, 48000, tsyFloat32, 512, 2); + Check('header length IQ = ×каналы', H.DataLength = 1024); + // Маркер времени TX_CHRONO живёт по правилам аудио: он называет клиенту + // размер блока, который тот пришлёт (§3.4). + TCIFillHeader(H, tstTXChrono, 0, 12000, tsyFloat32, 256, 2); + Check('header length chrono = на канал', H.DataLength = 256); + + // Упаковка/распаковка: сквозной проход по всем форматам. + for i := 0 to 7 do Src[i] := (i - 4) / 8.0; // −0.5 … +0.375 + for T := tsyInt16 to tsyFloat32 do + begin + N := TCIPackSamples(Src, 8, T, @Buf[0]); + Check('pack размер ' + TCISampleTypeName(T), N = 8 * TCISampleBytes(T)); + N := TCIUnpackSamples(@Buf[0], N, T, Dst); + Check('unpack счёт ' + TCISampleTypeName(T), N = 8); + Check('round-trip ' + TCISampleTypeName(T), + Near(Dst[0], Src[0], 1E-4) and Near(Dst[7], Src[7], 1E-4), + Format('%.6f vs %.6f', [Dst[0], Src[0]])); + end; + + // Клип: перегруз обязан упереться в шкалу, а не завернуться через знак. + Src[0] := 2.0; + Src[1] := -2.0; + TCIPackSamples(Src, 2, tsyInt16, @Buf[0]); + Check('клип int16 +', SmallInt(Word(Buf[0]) or (Word(Buf[1]) shl 8)) = 32767); + Check('клип int16 −', SmallInt(Word(Buf[2]) or (Word(Buf[3]) shl 8)) = -32767); + + Check('путь | → :', TCIRecordPath('home|user/rec.wav') = 'home:user/rec.wav'); + + // Частоты Pluto (576…5760 кГц) на 384 делятся не все: клиенту обязана + // достаться ЗАКОННАЯ частота из набора протокола, а не 576/480 кГц. + Check('IQ rate 576к, просят 384к → 192к', + TCIPickIQRate(576000, 384000) = 192000, + IntToStr(TCIPickIQRate(576000, 384000))); + Check('IQ rate 960к, просят 384к → 192к', + TCIPickIQRate(960000, 384000) = 192000, + IntToStr(TCIPickIQRate(960000, 384000))); + Check('IQ rate 768к, просят 384к → 384к', + TCIPickIQRate(768000, 384000) = 384000); + Check('IQ rate 5760к, просят 48к → 48к', + TCIPickIQRate(5760000, 48000) = 48000); + Check('IQ rate 192к, просят 384к → 192к', + TCIPickIQRate(192000, 384000) = 192000); + Check('IQ rate чужой 50к → просьба как есть', + TCIPickIQRate(50000, 48000) = 48000); +end; + +{ ═══════════════════════════════════════════════════════════════════════════ + A2. Дециматор и интерполятор + ═══════════════════════════════════════════════════════════════════════════ } + +{ Прогон каскада: тон в полосе обязан пройти без потерь, тон за новой границей + Найквиста — исчезнуть. Кормим кусками по 4096, как это делает поток. } +procedure TestChain(M: Integer; SrcRate, PassHz, StopHz: Double; + const What: string); +const + NTOT = 65536; +var + C: TTCIDecimChain; + Src: array[0..4095] of Single; + Dst: array[0..4095] of Single; + i, off, n: Integer; + Acc: Double; + + function RunTone(ToneHz: Double): Double; + var j: Integer; + begin + C := TTCIDecimChain.Create(M); + try + Acc := 0; + n := 0; + off := 0; + while off + 4096 <= NTOT do + begin + for j := 0 to 4095 do + Src[j] := Sin(2 * Pi * ToneHz * (off + j) / SrcRate); + i := C.Process(Src, 4096, Dst); + // Первую половину прогона пропускаем: там переходный процесс фильтра. + if off >= NTOT div 2 then + for j := 0 to i - 1 do + begin + Acc := Acc + Dst[j] * Dst[j]; + Inc(n); + end; + Inc(off, 4096); + end; + if n = 0 then Result := 0 else Result := Sqrt(Acc / n); + finally + C.Free; + end; + end; + +var + Pass, Stop: Double; +begin + Pass := RunTone(PassHz); + Stop := RunTone(StopHz); + Check(What + ': полоса цела', Abs(Pass - 0.707) < 0.03, + Format('%.4f', [Pass])); + Check(What + ': зеркало давится (>60 дБ)', Stop < 0.0007, + Format('%.6f', [Stop])); +end; + +procedure TestResampling; +const + NIN = 8192; +var + D: TTCIDecimator; + I_: TTCIInterpolator; + Src: array[0..NIN-1] of Single; + Dst: array[0..NIN-1] of Single; + Out_: array[0..NIN*8-1] of Double; + i, n: Integer; + Ph, Lo, Hi: Double; +begin + WriteLn('A2. Пересчёт частоты'); + + // Постоянка: усиление на нуле обязано быть единичным, иначе поток тише/громче + // ровно на коэффициент прореживания. + D := TTCIDecimator.Create(4); + try + for i := 0 to NIN - 1 do Src[i] := 1.0; + n := D.Process(Src, NIN, Dst); + Check('децим счёт = N/M', n = NIN div 4, IntToStr(n)); + Check('децим усиление 1.0', Near(Dst[n - 1], 1.0, 0.01), + Format('%.4f', [Dst[n - 1]])); + finally + D.Free; + end; + + // Тон в полосе проходит, тон за новой границей Найквиста — давится. + D := TTCIDecimator.Create(4); // 48к → 12к, Найквист 6 кГц + try + Ph := 0; + for i := 0 to NIN - 1 do + begin + Src[i] := Sin(2 * Pi * 1000 * i / 48000); + Ph := Ph; + end; + n := D.Process(Src, NIN, Dst); + Check('децим 1 кГц проходит', Near(RMS(Dst, n), 0.707, 0.05), + Format('%.4f', [RMS(Dst, n)])); + finally + D.Free; + end; + + D := TTCIDecimator.Create(4); + try + for i := 0 to NIN - 1 do Src[i] := Sin(2 * Pi * 20000 * i / 48000); + n := D.Process(Src, NIN, Dst); + Check('децим 20 кГц давится (>40 дБ)', RMS(Dst, n) < 0.007, + Format('%.5f', [RMS(Dst, n)])); + finally + D.Free; + end; + + // Коэффициент 1 — сквозной проход без фильтра (иначе теряли бы верх полосы). + D := TTCIDecimator.Create(1); + try + for i := 0 to 15 do Src[i] := i / 16.0; + n := D.Process(Src, 16, Dst); + Check('децим ×1 прозрачен', (n = 16) and Near(Dst[15], 15 / 16.0, 1E-6)); + finally + D.Free; + end; + + // ★Каскад на коэффициентах Pluto. Одной ступенью это не берётся: замер до + // каскада дал на M=120 завал 1.3 дБ в полосе и подавление зеркала 16 дБ. + TestChain(4, 192000, 12000, 40000, 'HPSDR 192→48'); + TestChain(8, 384000, 12000, 40000, 'HPSDR 384→48'); + TestChain(12, 576000, 12000, 40000, 'Pluto 576→48'); + TestChain(32, 1536000, 12000, 40000, 'Pluto 1536→48'); + TestChain(120,5760000, 12000, 40000, 'Pluto 5760→48'); + TestChain(15, 5760000, 100000, 300000, 'Pluto 5760→384'); + + // Интерполятор: счёт и отсутствие выбросов за пределы соседних отсчётов. + I_ := TTCIInterpolator.Create(4); + try + for i := 0 to 99 do Src[i] := Sin(2 * Pi * i / 25); + n := I_.Process(Src, 100, Out_); + Check('интерп счёт = N×M', n = 400, IntToStr(n)); + // Смотрим ТОЛЬКО заполненную часть: хвост буфера — мусор со стека, и + // MaxValue по нему ловит сигнальный NaN (проверено: с -O2 прогон падал + // с EInvalidOp ровно здесь). + Lo := Out_[0]; + Hi := Out_[0]; + for i := 1 to n - 1 do + begin + if Out_[i] < Lo then Lo := Out_[i]; + if Out_[i] > Hi then Hi := Out_[i]; + end; + Check('интерп без выбросов', (Hi <= 1.0001) and (Lo >= -1.0001), + Format('%.4f..%.4f', [Lo, Hi])); + finally + I_.Free; + end; + + I_ := TTCIInterpolator.Create(1); + try + for i := 0 to 9 do Src[i] := i; + n := I_.Process(Src, 10, Out_); + Check('интерп ×1 прозрачен', (n = 10) and Near(Out_[9], 9, 1E-9)); + finally + I_.Free; + end; +end; + +{ ═══════════════════════════════════════════════════════════════════════════ + B. Блок до сокета: TTCIStreamOut → TTCIClient → socketpair + ═══════════════════════════════════════════════════════════════════════════ } + +type + { Приёмник кадров с другого конца socketpair: разбирает WS-кадры сервера + (немаскированные, FIN=1) и отдаёт полезную нагрузку. } + TFrameSink = record + Fd: Integer; + Buf: array[0..262143] of Byte; + Len: Integer; + end; + +procedure SinkDrain(var S: TFrameSink); +var R: Integer; +begin + repeat + R := fpRecv(S.Fd, @S.Buf[S.Len], SizeOf(S.Buf) - S.Len, MSG_DONTWAIT); + if R > 0 then Inc(S.Len, R); + until R <= 0; +end; + +{ Достаёт следующий кадр. False — кадров больше нет. } +function SinkNext(var S: TFrameSink; out Opcode: Byte; out Payload: TBytes): Boolean; +var + PayLen, Need, i: Integer; +begin + Result := False; + Payload := nil; + if S.Len < 2 then Exit; + Opcode := S.Buf[0] and $0F; + PayLen := S.Buf[1] and $7F; + Need := 2; + if PayLen = 126 then + begin + if S.Len < 4 then Exit; + PayLen := (S.Buf[2] shl 8) or S.Buf[3]; + Need := 4; + end + else if PayLen = 127 then + begin + if S.Len < 10 then Exit; + PayLen := (S.Buf[6] shl 24) or (S.Buf[7] shl 16) or (S.Buf[8] shl 8) or S.Buf[9]; + Need := 10; + end; + if S.Len < Need + PayLen then Exit; + SetLength(Payload, PayLen); + for i := 0 to PayLen - 1 do Payload[i] := S.Buf[Need + i]; + if S.Len > Need + PayLen then + Move(S.Buf[Need + PayLen], S.Buf[0], S.Len - Need - PayLen); + Dec(S.Len, Need + PayLen); + Result := True; +end; + +procedure TestStreamOut; +var + Fds: array[0..1] of Integer; + Ws: TWsClient; + C: TTCIClient; + S: TTCIStreamOut; + Sink: TFrameSink; + L, R: array[0..8191] of Single; + i, Blocks: Integer; + Op: Byte; + Pay: TBytes; + H: TTCIStreamHeader; + IQI, IQQ: array[0..8191] of Double; + OkHdr: Boolean; +begin + WriteLn('B. Исходящий блок до сокета'); + + if fpSocketPair(AF_UNIX, SOCK_STREAM, 0, @Fds[0]) <> 0 then + begin + Check('socketpair', False, 'создать пару не удалось'); + Exit; + end; + FillChar(Sink, SizeOf(Sink), 0); + Sink.Fd := Fds[1]; + + Ws := TWsClient.Create(Fds[0]); + Ws.State := wsOpen; + C := TTCIClient.Create(Ws); + try + // 48 кГц стерео → 12 кГц стерео float32, блок 512 сэмплов на канал. + S := TTCIStreamOut.Create(C, tstRXAudio, 0, 48000, 12000, 2, tsyFloat32, 512); + try + for i := 0 to 8191 do + begin + L[i] := Sin(2 * Pi * 1000 * i / 48000); + R[i] := L[i] * 0.5; + end; + S.FeedAudio(L, R, 8192); // 8192/4 = 2048 выходных = 4 блока по 512 + Check('поток: частота на выходе', S.OutRate = 12000, IntToStr(S.OutRate)); + finally + S.Free; + end; + OkHdr := C.Flush; + Check('аудио: flush socketpair', OkHdr, + 'dead=' + BoolToStr(C.Dead, True) + ', errno=' + IntToStr(fpGetErrno) + + ', txfd=' + IntToStr(Fds[0]) + ', rxfd=' + IntToStr(Fds[1])); + SinkDrain(Sink); + + Blocks := 0; + OkHdr := True; + while SinkNext(Sink, Op, Pay) do + begin + if Op <> $02 then begin OkHdr := False; Continue; end; + if Length(Pay) < SizeOf(H) then begin OkHdr := False; Continue; end; + Move(Pay[0], H, SizeOf(H)); + if (H.SampleRate <> 12000) or (H.Channels <> 2) or + (H.DataLength <> 512) or // сэмплов НА КАНАЛ + (H.StreamType <> LongWord(Ord(tstRXAudio))) or + (H.Format <> LongWord(Ord(tsyFloat32))) or + (Length(Pay) <> SizeOf(H) + 512 * 2 * 4) then OkHdr := False; + Inc(Blocks); + end; + Check('аудио: 4 блока', Blocks = 4, IntToStr(Blocks)); + Check('аудио: заголовок и размер', OkHdr); + + // IQ: 384 кГц → 48 кГц, блок 2048 комплексных. + S := TTCIStreamOut.Create(C, tstIQ, 1, 384000, 48000, 2, tsyFloat32, 2048); + try + Check('IQ: частота на выходе', S.OutRate = 48000, IntToStr(S.OutRate)); + for i := 0 to 8191 do + begin + IQI[i] := Cos(2 * Pi * 1000 * i / 384000); + IQQ[i] := Sin(2 * Pi * 1000 * i / 384000); + end; + // 8192/8 = 1024 выходных — половина блока, отправки быть не должно. + S.FeedIQ(@IQI[0], @IQQ[0], 8192); + C.Flush; + SinkDrain(Sink); + Check('IQ: полблока не уходит', not SinkNext(Sink, Op, Pay)); + S.FeedIQ(@IQI[0], @IQQ[0], 8192); // ещё половина — теперь блок целый + C.Flush; + SinkDrain(Sink); + OkHdr := SinkNext(Sink, Op, Pay); + if OkHdr then + begin + Move(Pay[0], H, SizeOf(H)); + OkHdr := (H.Receiver = 1) and (H.SampleRate = 48000) and + (H.DataLength = 4096) and + (H.StreamType = LongWord(Ord(tstIQ))) and + (Length(Pay) = SizeOf(H) + 4096 * 4); + end; + Check('IQ: блок целиком и по формату', OkHdr); + + // Смена частоты источника на ходу (сменили rate DDC). + S.SetSourceRate(192000); + Check('IQ: пересчёт после смены rate', S.OutRate = 48000); + finally + S.Free; + end; + + // Моно: 1 канал = среднее двух. + S := TTCIStreamOut.Create(C, tstRXAudio, 0, 48000, 48000, 1, tsyInt16, 100); + try + for i := 0 to 199 do begin L[i] := 0.5; R[i] := -0.5; end; + S.FeedAudio(L, R, 200); + C.Flush; + SinkDrain(Sink); + OkHdr := SinkNext(Sink, Op, Pay); + if OkHdr then + begin + Move(Pay[0], H, SizeOf(H)); + OkHdr := (H.Channels = 1) and (H.DataLength = 100) and + (H.Format = LongWord(Ord(tsyInt16))) and + (Length(Pay) = SizeOf(H) + 100 * 2); + end; + Check('моно int16: заголовок и размер', OkHdr); + finally + S.Free; + end; + + // Переполнение кольца блоков: клиент не читает — теряем старые блоки, + // но соединение живо и команды по нему ходят. + S := TTCIStreamOut.Create(C, tstRXAudio, 0, 48000, 48000, 2, tsyFloat32, 100); + try + for i := 0 to 8191 do begin L[i] := 0.1; R[i] := 0.1; end; + for i := 0 to 99 do S.FeedAudio(L, R, 8192); // много блоков без Flush + Check('переполнение: блоки теряются', C.BinDropped > 0, + IntToStr(C.BinDropped)); + Check('переполнение: клиент жив', not C.Dead); + finally + S.Free; + end; + finally + C.Free; + Ws.Free; + fpClose(Fds[1]); + end; +end; + +{ ═══════════════════════════════════════════════════════════════════════════ + C. Рекордер и WAV + ═══════════════════════════════════════════════════════════════════════════ } + +procedure TestRecorder; +var + Rec: TTCIRecorder; + L, R: array[0..8191] of Single; + Data: TTCIPcm; + i, Total: Integer; + Path: string; + FS: TFileStream; + Hdr: array[0..43] of Byte; + Sz: LongWord; + W: TTCIWavWriter; + Waited: Integer; +begin + WriteLn('C. Рекордер линейного выхода'); + + // Максимум записи — 1 секунда: подаём полторы, лишнее не берём. ★Именно НЕ + // берём: окно записи по §4.3 начинается со START, а не «последняя секунда». + Rec := TTCIRecorder.Create(0, 48000, 1); + try + Total := 0; + for i := 0 to 8191 do begin L[i] := 0.25; R[i] := -0.25; end; + while Total < 72000 do + begin + Rec.Feed(L, R, 8192); + Inc(Total, 8192); + end; + Data := Rec.Take; + Check('рекордер: длина по максимуму', Length(Data) = 48000 * 2, + IntToStr(Length(Data))); + Check('рекордер: уровень сохранён', + (Data[0] = Round(0.25 * 32767)) and (Data[1] = -Round(0.25 * 32767))); + Check('рекордер: Take завершает запись', Length(Rec.Take) = 0); + finally + Rec.Free; + end; + + // Порядок отсчётов: первым обязан идти самый ПЕРВЫЙ записанный. + Rec := TTCIRecorder.Create(0, 1000, 1); // ёмкость 1000 отсчётов + try + for i := 0 to 1499 do + begin + L[0] := i / 4000.0; + R[0] := 0; + Rec.Feed(L, R, 1); + end; + Data := Rec.Take; + Check('рекордер: с начала записи, а не с конца', + (Length(Data) = 2000) and (Data[0] = 0) and + (Abs(Data[1998] - Round((999 / 4000.0) * 32767)) <= 1), + IntToStr(Data[1998])); + finally + Rec.Free; + end; + + // ★Истечение срока: по документу «по истечении времени запись удаляется». + // Секунду ждать незачем — окно берём минимальное и смотрим на часы. + Rec := TTCIRecorder.Create(0, 48000, 1); + try + for i := 0 to 8191 do begin L[i] := 0.25; R[i] := -0.25; end; + Rec.Feed(L, R, 8192); + Check('рекордер: до срока запись есть', Length(Rec.Take) > 0); + finally + Rec.Free; + end; + Rec := TTCIRecorder.Create(0, 48000, 1); + try + Rec.Feed(L, R, 8192); + Sleep(1100); // окно закрылось + Check('рекордер: после срока запись удалена', Length(Rec.Take) = 0); + Rec.Feed(L, R, 8192); // и новое аудио уже не принимает + Check('рекордер: истёкший не оживает', Length(Rec.Take) = 0); + finally + Rec.Free; + end; + + // WAV: заголовок и длина. + Path := GetTempDir + 'tcitest_rec.wav'; + DeleteFile(Path); + SetLength(Data, 2000); + for i := 0 to 1999 do Data[i] := i * 8; + W := TTCIWavWriter.Create(Path, Data, 48000); + Waited := 0; + while (not FileExists(Path)) and (Waited < 2000) do + begin + Sleep(10); + Inc(Waited, 10); + end; + Sleep(50); + if not FileExists(Path) then + Check('WAV: файл создан', False) + else + begin + FS := TFileStream.Create(Path, fmOpenRead); + try + Check('WAV: размер', FS.Size = 44 + 2000 * 2, IntToStr(FS.Size)); + FS.Read(Hdr[0], 44); + Check('WAV: RIFF/WAVE', + (Hdr[0] = Ord('R')) and (Hdr[1] = Ord('I')) and + (Hdr[8] = Ord('W')) and (Hdr[9] = Ord('A'))); + Sz := LongWord(Hdr[24]) or (LongWord(Hdr[25]) shl 8) or + (LongWord(Hdr[26]) shl 16) or (LongWord(Hdr[27]) shl 24); + Check('WAV: частота 48000', Sz = 48000, IntToStr(Sz)); + Check('WAV: 2 канала, 16 бит', + (Hdr[22] = 2) and (Hdr[34] = 16)); + Sz := LongWord(Hdr[40]) or (LongWord(Hdr[41]) shl 8) or + (LongWord(Hdr[42]) shl 16) or (LongWord(Hdr[43]) shl 24); + Check('WAV: длина данных', Sz = 4000, IntToStr(Sz)); + finally + FS.Free; + end; + DeleteFile(Path); + end; +end; + +{ ═══════════════════════════════════════════════════════════════════════════ + D. Живой сервер: команды потоков + ═══════════════════════════════════════════════════════════════════════════ } + +type + { Минимальный WS-клиент на сыром сокете: handshake, отправка команд с + маской (как обязан клиент), приём текста и бинарных блоков. } + TRawClient = class + private + FSock: Integer; + FIn: array[0..262143] of Byte; + FLen: Integer; + public + function Connect(Port: Word): Boolean; + procedure SendText(const S: string); + procedure SendBinary(const Data; Len: Integer); + { Штатное прощание по RFC 6455: close-кадр с кодом 1000. Второй путь + отключения — просто закрыть сокет (Close_), и сервер обязан отпускать + слот в обоих. } + procedure SendClose; + procedure Pump(Ms: Integer); + function NextFrame(out Opcode: Byte; out Payload: TBytes): Boolean; + { Ждать строку с подстрокой Needle не дольше Ms. } + function WaitText(const Needle: string; Ms: Integer): string; + function Alive: Boolean; + procedure Close_; + end; + +function TRawClient.Connect(Port: Word): Boolean; +var + Addr: TInetSockAddr; + Req: string; +begin + Result := False; + FLen := 0; + FSock := fpSocket(AF_INET, SOCK_STREAM, 0); + if FSock < 0 then Exit; + FillChar(Addr, SizeOf(Addr), 0); + Addr.sin_family := AF_INET; + Addr.sin_port := htons(Port); + Addr.sin_addr.s_addr := htonl($7F000001); + if fpConnect(FSock, @Addr, SizeOf(Addr)) <> 0 then Exit; + Req := 'GET / HTTP/1.1'#13#10 + + 'Host: 127.0.0.1'#13#10 + + 'Upgrade: websocket'#13#10 + + 'Connection: Upgrade'#13#10 + + 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ=='#13#10 + + 'Sec-WebSocket-Version: 13'#13#10#13#10; + fpSend(FSock, @Req[1], Length(Req), 0); + // Ответ handshake: ждём конца заголовков и выкидываем их из буфера. + Pump(1000); + Result := FLen > 0; + if Result then + begin + Req := ''; + SetLength(Req, FLen); + Move(FIn[0], Req[1], FLen); + if Pos(#13#10#13#10, Req) = 0 then Exit(False); + Result := Pos('101', Copy(Req, 1, 20)) > 0; + FLen := FLen - (Pos(#13#10#13#10, Req) + 3); + if FLen > 0 then Move(FIn[Pos(#13#10#13#10, Req) + 3], FIn[0], FLen); + end; +end; + +procedure TRawClient.SendText(const S: string); +var + Frame: array of Byte; + i, HLen, N: Integer; + Mask: array[0..3] of Byte; +begin + N := Length(S); + if N <= 125 then HLen := 2 else HLen := 4; + SetLength(Frame, HLen + 4 + N); + Frame[0] := $81; + if N <= 125 then Frame[1] := $80 or Byte(N) + else + begin + Frame[1] := $80 or 126; + Frame[2] := Byte(N shr 8); + Frame[3] := Byte(N); + end; + for i := 0 to 3 do Mask[i] := Byte(Random(256)); + for i := 0 to 3 do Frame[HLen + i] := Mask[i]; + for i := 0 to N - 1 do + Frame[HLen + 4 + i] := Byte(S[i + 1]) xor Mask[i and 3]; + fpSend(FSock, @Frame[0], Length(Frame), 0); +end; + +procedure TRawClient.SendClose; +var + Frame: array[0..7] of Byte; + i: Integer; + Mask: array[0..3] of Byte; + Code: array[0..1] of Byte; +begin + Code[0] := $03; Code[1] := $E8; // 1000 — normal closure + for i := 0 to 3 do Mask[i] := Byte(Random(256)); + Frame[0] := $88; + Frame[1] := $80 or 2; + for i := 0 to 3 do Frame[2 + i] := Mask[i]; + for i := 0 to 1 do Frame[6 + i] := Code[i] xor Mask[i]; + fpSend(FSock, @Frame[0], SizeOf(Frame), 0); +end; + +procedure TRawClient.SendBinary(const Data; Len: Integer); +var + Frame: array of Byte; + P: PByte; + i, HLen: Integer; + Mask: array[0..3] of Byte; +begin + if Len <= 125 then HLen := 2 + else if Len <= 65535 then HLen := 4 + else HLen := 10; + SetLength(Frame, HLen + 4 + Len); + Frame[0] := $82; + if Len <= 125 then Frame[1] := $80 or Byte(Len) + else if Len <= 65535 then + begin + Frame[1] := $80 or 126; + Frame[2] := Byte(Len shr 8); + Frame[3] := Byte(Len); + end + else + begin + Frame[1] := $80 or 127; + Frame[2] := 0; Frame[3] := 0; Frame[4] := 0; Frame[5] := 0; + Frame[6] := Byte(Len shr 24); Frame[7] := Byte(Len shr 16); + Frame[8] := Byte(Len shr 8); Frame[9] := Byte(Len); + end; + for i := 0 to 3 do Mask[i] := Byte(Random(256)); + for i := 0 to 3 do Frame[HLen + i] := Mask[i]; + P := @Data; + for i := 0 to Len - 1 do Frame[HLen + 4 + i] := P[i] xor Mask[i and 3]; + fpSend(FSock, @Frame[0], Length(Frame), 0); +end; + +procedure TRawClient.Pump(Ms: Integer); +var + R, Waited: Integer; +begin + Waited := 0; + repeat + R := fpRecv(FSock, @FIn[FLen], SizeOf(FIn) - FLen, MSG_DONTWAIT); + if R > 0 then Inc(FLen, R) + else + begin + Sleep(10); + Inc(Waited, 10); + end; + until Waited >= Ms; +end; + +function TRawClient.NextFrame(out Opcode: Byte; out Payload: TBytes): Boolean; +var PayLen, Need, i: Integer; +begin + Result := False; + Payload := nil; + if FLen < 2 then Exit; + Opcode := FIn[0] and $0F; + PayLen := FIn[1] and $7F; + Need := 2; + if PayLen = 126 then + begin + if FLen < 4 then Exit; + PayLen := (FIn[2] shl 8) or FIn[3]; + Need := 4; + end + else if PayLen = 127 then + begin + if FLen < 10 then Exit; + PayLen := (FIn[6] shl 24) or (FIn[7] shl 16) or (FIn[8] shl 8) or FIn[9]; + Need := 10; + end; + if FLen < Need + PayLen then Exit; + SetLength(Payload, PayLen); + for i := 0 to PayLen - 1 do Payload[i] := FIn[Need + i]; + if FLen > Need + PayLen then Move(FIn[Need + PayLen], FIn[0], FLen - Need - PayLen); + Dec(FLen, Need + PayLen); + Result := True; +end; + +function TRawClient.WaitText(const Needle: string; Ms: Integer): string; +var + Op: Byte; + Pay: TBytes; + S: string; + Waited: Integer; +begin + Result := ''; + Waited := 0; + repeat + while NextFrame(Op, Pay) do + if Op = $01 then + begin + S := ''; + SetLength(S, Length(Pay)); + if Length(Pay) > 0 then Move(Pay[0], S[1], Length(Pay)); + if Pos(Needle, LowerCase(S)) > 0 then Exit(S); + end; + Pump(50); + Inc(Waited, 50); + until Waited >= Ms; +end; + +function TRawClient.Alive: Boolean; +var R: Integer; +begin + R := fpSend(FSock, @R, 0, MSG_NOSIGNAL); + Result := R >= 0; +end; + +procedure TRawClient.Close_; +begin + if FSock >= 0 then fpClose(FSock); + FSock := -1; +end; + +type + { Хост: маршалинг Invoke в наш поток не нужен — исполняем на месте, как + делает демон (у него тоже нет очереди главного потока в этом смысле). } + THost = class + procedure DoInvoke(M: TThreadMethod); + procedure OnTXIQ(const Buf: array of Double; Count: Integer); + end; + +var + TXIQBlocks: Integer = 0; + +procedure THost.DoInvoke(M: TThreadMethod); +begin + M(); +end; + +procedure THost.OnTXIQ(const Buf: array of Double; Count: Integer); +// Готовый блок TX-IQ: единственный наблюдаемый признак того, что аудио +// клиента прошло весь тракт (ринг микрофона вычерпывается быстрее, чем его +// успевает увидеть опрос). +begin + if Count > 0 then Inc(TXIQBlocks); +end; + +procedure TestServer; +const + PORT = 40099; +var + Ctrl: TRadioController; + Ad: TTCIAdapter; + Host: THost; + Cfg: TTCISettings; + C, C2: TRawClient; + S: string; + Blk: array[0..1023] of Byte; + H: TTCIStreamHeader; + Op: Byte; + Pay: TBytes; + GotBinary, GotClose: Boolean; +begin + WriteLn('D. Команды потоков на живом сервере'); + + Host := THost.Create; + Ctrl := TRadioController.Create; + Ctrl.LocalAudioEnabled := False; + Ctrl.OnInvoke := Host.DoInvoke; + Ad := TTCIAdapter.Create(Ctrl, nil); + C := TRawClient.Create; + try + Cfg.Enabled := True; + Cfg.Port := PORT; + Cfg.BindAddr := '127.0.0.1'; + if not Ad.ApplySettings(Cfg) then + begin + Check('сервер поднялся', False, 'порт занят?'); + Exit; + end; + Check('сервер поднялся', Ad.Active); + + if not C.Connect(PORT) then + begin + Check('клиент подключился', False); + Exit; + end; + Check('клиент подключился', True); + Check('пришёл ready', C.WaitText('ready;', 2000) <> ''); + + // Приёмник вне диапазона — отказ, а не молчание. + C.SendText('audio_start:9;'); + S := C.WaitText('tci_error', 1500); + Check('audio_start:9 → ошибка', Pos('bad receiver', S) > 0, S); + + C.SendText('iq_start:abc;'); + S := C.WaitText('tci_error', 1500); + Check('iq_start:abc → ошибка', Pos('bad receiver', S) > 0, S); + + // Пан 1: без подключённого радио его нет ни в потолке (TRX_COUNT=1), ни + // живьём — в обоих случаях клиент обязан получить отказ, а не тишину. + C.SendText('audio_start:1;'); + S := C.WaitText('tci_error', 1500); + Check('audio_start на несуществующий пан → ошибка', + (Pos('not running', S) > 0) or (Pos('bad receiver', S) > 0), S); + + // Главный тракт живой: старт обязан пройти молча. + C.SendText('audio_start:0;'); + S := C.WaitText('tci_error', 400); + Check('audio_start:0 без ошибок', S = '', S); + + // Параметры потока принимаются и подтверждаются. + C.SendText('audio_samplerate:12000;'); + S := C.WaitText('audio_samplerate', 1500); + Check('audio_samplerate:12000 подтверждён', Pos('12000', S) > 0, S); + C.SendText('audio_samplerate:44100;'); + S := C.WaitText('audio_samplerate', 1500); + Check('audio_samplerate:44100 отвергнут', Pos('12000', S) > 0, S); + C.SendText('iq_samplerate:96000;'); + S := C.WaitText('iq_samplerate', 1500); + Check('iq_samplerate:96000 подтверждён', Pos('96000', S) > 0, S); + + // ★Ответ обязан называть частоту, которую клиент РЕАЛЬНО получит. + // Контроллер сейчас на 192 кГц: просьба «384» невыполнима. + C.SendText('iq_samplerate:384000;'); + S := C.WaitText('iq_samplerate', 1500); + Check('iq_samplerate:384к при 192к источника → 192к', + Pos('192000', S) > 0, S); + + // Сменился rate устройства — та же просьба стала выполнимой, и клиенту + // обязано прийти переобъявление без всякого запроса. + Ctrl.SetSampleRate(384000); + S := C.WaitText('iq_samplerate', 1500); + Check('смена rate переобъявляет iq_samplerate', Pos('384000', S) > 0, S); + + // Частоты Pluto: 576 кГц на 384 не делится — отдаём законные 192 кГц. + Ctrl.SetSampleRate(576000); + S := C.WaitText('iq_samplerate', 1500); + Check('Pluto 576к: просьба 384к → 192к', Pos('192000', S) > 0, S); + Ctrl.SetSampleRate(192000); + C.WaitText('iq_samplerate', 1000); + + // IF_LIMITS привязаны к rate устройства (§4.1: «высылается при подключении + // и изменении частоты дискретизации»). + Ctrl.SetSampleRate(96000); + S := C.WaitText('if_limits', 1500); + Check('смена rate переобъявляет if_limits', + (Pos('-48000', S) > 0) and (Pos('48000', S) > 0), S); + Ctrl.SetSampleRate(192000); + C.WaitText('if_limits', 1000); + + // Рекордер: сохранять нечего — честная ошибка вместо пустого файла. + C.SendText('line_out_recorder_save:0,' + + StringReplace(GetTempDir + 'tcitest_none.wav', ':', '|', [rfReplaceAll]) + ';'); + S := C.WaitText('tci_error', 1500); + Check('save без записи → ошибка', Pos('nothing recorded', S) > 0, S); + + C.SendText('line_out_recorder_start:0,10;'); + C.SendText('line_out_recorder_save:0,' + + StringReplace(GetTempDir + 'tcitest_none.mp3', ':', '|', [rfReplaceAll]) + ';'); + S := C.WaitText('tci_error', 1500); + Check('save в mp3 → ошибка', Pos('only wav', S) > 0, S); + C.SendText('line_out_recorder_break:0;'); + + // TRX с источником tci: без запущенного аудиопотока просьба не действует. + C.SendText('audio_stop:0;'); + C.SendText('trx:0,true,tci;'); + C.WaitText('trx:', 1000); + Check('trx tci без потока не берёт модуляцию', + not Ctrl.TCIMicRequested); + C.SendText('trx:0,false;'); + C.WaitText('trx:', 1000); + + C.SendText('audio_start:0;'); + C.SendText('trx:0,true,tci;'); + C.WaitText('trx:', 1000); + Check('trx tci с потоком берёт модуляцию', Ctrl.TCIMicRequested); + C.SendText('trx:0,false;'); + C.WaitText('trx:', 1000); + Check('снятие TRX снимает источник', not Ctrl.TCIMicRequested); + + // Бинарный блок не нашего типа обязан быть проигнорирован, а соединение — + // остаться рабочим (клиент шлёт их пачками, рвать связь нельзя). + TCIFillHeader(H, tstIQ, 0, 48000, tsyFloat32, 4, 2); + Move(H, Blk[0], SizeOf(H)); + C.SendBinary(Blk[0], SizeOf(H) + 32); + C.SendText('iq_samplerate:48000;'); + S := C.WaitText('iq_samplerate', 1500); + Check('чужой бинарный блок не рвёт связь', Pos('48000', S) > 0, S); + + // Маркеров TX_CHRONO без передачи быть не должно. + C.Pump(200); + GotBinary := False; + while C.NextFrame(Op, Pay) do + if Op = $02 then GotBinary := True; + Check('без передачи маркеров нет', not GotBinary); + + // ★Обычный TCP-разрыв БЕЗ close-кадра: так уходит и упавший клиент, и + // выдернутый кабель. recv отдаёт 0, и это EOF, а не таймаут — errno при + // нём не трогается и вполне может нести EAGAIN от прошлого истёкшего + // TCI_POLL_MS. Пока сервер спрашивал errno, слот не освобождался вовсе: + // после нескольких аварийных отключений новые клиенты не подключались. + C.Close_; + Sleep(300); + Check('после ухода клиента источник снят', not Ctrl.TCIMicRequested); + Check('TCP-разрыв без close-кадра освобождает слот', Ad.ClientCount = 0, + IntToStr(Ad.ClientCount)); + + // Второй путь — штатное прощание по RFC: тоже слот, но через close-кадр. + C2 := TRawClient.Create; + try + Check('второй клиент подключился', C2.Connect(PORT)); + Check('второму пришёл ready', C2.WaitText('ready;', 2000) <> ''); + C2.SendClose; + // Сервер обязан ответить своим close-кадром (§5.5.1) и уйти. + GotClose := False; + C2.Pump(300); + while C2.NextFrame(Op, Pay) do + if Op = $08 then GotClose := True; + Check('на close-кадр отвечает close-кадром', GotClose); + Sleep(200); + Check('close-кадр освобождает слот', Ad.ClientCount = 0, + IntToStr(Ad.ClientCount)); + finally + C2.Close_; + C2.Free; + end; + finally + C.Free; + Ad.Free; + Ctrl.Free; + Host.Free; + end; +end; + +{ ═══════════════════════════════════════════════════════════════════════════ + E. Сквозной прогон: живой движок → тап → блоки клиенту + + Единственная часть, где проверяется сам маршрут данных: синтетический + 24-битный IQ подаётся в WDSP, а на другом конце WebSocket ожидаются блоки + RX-аудио и IQ с верными заголовками. + ═══════════════════════════════════════════════════════════════════════════ } + +procedure TestEndToEnd; +const + PORT = 40098; + RATE = 48000; + PAIRS = 238; // как в пакете DDC openHPSDR +var + Ctrl: TRadioController; + Ad: TTCIAdapter; + Host: THost; + Cfg: TTCISettings; + C: TRawClient; + Pkt: array[0..PAIRS * 6 - 1] of Byte; + i, k, n, Phase: Integer; + V: LongInt; + Op: Byte; + Pay: TBytes; + H: TTCIStreamHeader; + AudioBlocks, IQBlocks, BadHdr: Integer; +begin + WriteLn('E. Сквозной прогон через движок'); + + Host := THost.Create; + Ctrl := TRadioController.Create; + Ctrl.LocalAudioEnabled := False; + Ctrl.OnInvoke := Host.DoInvoke; + Ctrl.FSampleRate := RATE; + Ctrl.CreateEngines(RATE); + // Линейный выход навешивает хост (в GUI и демоне — тоже он). + Ctrl.FDSPEngine.OnAudio := Ctrl.OnAudioReady; + Ctrl.FDSPEngine.OnTXIQ := Host.OnTXIQ; + C := nil; + Ad := nil; + try + if not Ctrl.FDSPEngine.Open then + begin + WriteLn(' -- движок не поднялся (', Ctrl.FDSPEngine.LastError, + ') — сквозной прогон пропущен'); + Exit; + end; + Ad := TTCIAdapter.Create(Ctrl, nil); + Cfg.Enabled := True; + Cfg.Port := PORT; + Cfg.BindAddr := '127.0.0.1'; + if not Ad.ApplySettings(Cfg) then + begin + Check('сквозной: сервер поднялся', False); + Exit; + end; + + C := TRawClient.Create; + if not C.Connect(PORT) then + begin + Check('сквозной: клиент подключился', False); + Exit; + end; + C.WaitText('ready;', 2000); + + C.SendText('audio_samplerate:12000;'); + C.WaitText('audio_samplerate', 1000); + C.SendText('audio_stream_samples:256;'); + C.SendText('audio_start:0;'); + C.SendText('iq_samplerate:48000;'); + C.SendText('iq_start:0;'); + C.Pump(200); + + // Полсекунды тона 1 кГц пакетами по 238 пар. + Phase := 0; + for k := 0 to (RATE div 2) div PAIRS do + begin + for i := 0 to PAIRS - 1 do + begin + V := Round(Cos(2 * Pi * 1000 * Phase / RATE) * 4000000); + Pkt[i * 6] := Byte(V shr 16); + Pkt[i * 6 + 1] := Byte(V shr 8); + Pkt[i * 6 + 2] := Byte(V); + V := Round(Sin(2 * Pi * 1000 * Phase / RATE) * 4000000); + Pkt[i * 6 + 3] := Byte(V shr 16); + Pkt[i * 6 + 4] := Byte(V shr 8); + Pkt[i * 6 + 5] := Byte(V); + Inc(Phase); + end; + Ctrl.FDSPEngine.PushDDCPacket(Pkt, 0, PAIRS); + // Реальный темп: 238 пар при 48 кГц — это ~5 мс. + if (k mod 10) = 0 then Sleep(5); + end; + C.Pump(500); + + AudioBlocks := 0; + IQBlocks := 0; + BadHdr := 0; + while C.NextFrame(Op, Pay) do + begin + if Op <> $02 then Continue; + if Length(Pay) < SizeOf(H) then begin Inc(BadHdr); Continue; end; + Move(Pay[0], H, SizeOf(H)); + if H.StreamType = LongWord(Ord(tstRXAudio)) then + begin + Inc(AudioBlocks); + // В аудиопотоке length — сэмплы НА КАНАЛ (AUDIO_STREAM_SAMPLES), то + // есть ровно то, что просили, а байт в блоке length × channels × 4. + if (H.SampleRate <> 12000) or (H.Receiver <> 0) or + (H.DataLength <> 256) or + (Length(Pay) <> SizeOf(H) + + Integer(H.DataLength) * Integer(H.Channels) * 4) then + Inc(BadHdr); + end + else if H.StreamType = LongWord(Ord(tstIQ)) then + begin + Inc(IQBlocks); + // А в IQ — вещественные отсчёты (комплексных вдвое меньше, §3.4). + if (H.SampleRate <> 48000) or (H.Channels <> 2) or + (Length(Pay) <> SizeOf(H) + Integer(H.DataLength) * 4) then Inc(BadHdr); + end; + end; + Check('сквозной: блоки RX-аудио пришли', AudioBlocks > 0, IntToStr(AudioBlocks)); + Check('сквозной: блоки IQ пришли', IQBlocks > 0, IntToStr(IQBlocks)); + Check('сквозной: заголовки верны', BadHdr = 0, IntToStr(BadHdr)); + + // Остановка потока обязана прекратить подачу. + C.SendText('audio_stop:0;'); + C.SendText('iq_stop:0;'); + C.Pump(200); + while C.NextFrame(Op, Pay) do ; // выгребаем хвост + for k := 0 to 40 do + begin + Ctrl.FDSPEngine.PushDDCPacket(Pkt, 0, PAIRS); + Sleep(1); + end; + C.Pump(300); + n := 0; + while C.NextFrame(Op, Pay) do + if Op = $02 then Inc(n); + Check('сквозной: после STOP блоков нет', n = 0, IntToStr(n)); + + // ── TX: маркеры времени и приём аудио клиента ── + // Радио не подключено, поэтому передачу поднимаем «на движке»: нам важен + // маршрут TCI → ринг микрофона, а не сама излучающая часть. + Ctrl.FWDSPReady := True; + C.SendText('audio_samplerate:12000;'); + C.WaitText('audio_samplerate', 1000); + C.SendText('audio_stream_samples:256;'); + C.SendText('audio_start:0;'); + C.SendText('trx:0,true,tci;'); + C.WaitText('trx:', 1000); + Check('TX: модуляция из TCI взята', Ctrl.TCIMicActive); + + C.Pump(400); + n := 0; + while C.NextFrame(Op, Pay) do + if (Op = $02) and (Length(Pay) >= SizeOf(H)) then + begin + Move(Pay[0], H, SizeOf(H)); + if H.StreamType = LongWord(Ord(tstTXChrono)) then + begin + Inc(n); + if (H.SampleRate <> 12000) or (H.DataLength <> 256) then + Inc(BadHdr); + end; + end; + Check('TX: маркеры TX_CHRONO идут', n > 0, IntToStr(n)); + + // Блок TX-аудио 12 кГц float32 моно: в ринг микрофона должно попасть + // вчетверо больше отсчётов (12 → 48 кГц). + TCIFillHeader(H, tstTXAudio, 0, 12000, tsyFloat32, 256, 1); + Move(H, Pkt[0], SizeOf(H)); + for i := 0 to 255 do + PSingle(@Pkt[SizeOf(H) + i * 4])^ := Sin(2 * Pi * 700 * i / 12000) * 0.5; + C.SendBinary(Pkt[0], SizeOf(H) + 256 * 4); + // Наблюдаем не ринг (его вычерпывает TX-поток быстрее опроса), а выход + // тракта: блоки TX-IQ появляются только если аудио клиента дошло. + for i := 0 to 7 do C.SendBinary(Pkt[0], SizeOf(H) + 256 * 4); + Sleep(400); + Check('TX: аудио клиента прошло тракт', TXIQBlocks > 0, IntToStr(TXIQBlocks)); + + // Обрыв прямо во время клиентской TRX: здесь движок и FNetwork + // созданы, но радиобэкенд не подключён — RF-выхода нет. + Check('перед обрывом TRX активен', Ctrl.FTransmitting); + C.Close_; + Sleep(300); + Check('после ухода клиента источник снят', not Ctrl.TCIMicRequested); + Check('после ухода клиента MOX снят', not Ctrl.FTransmitting); + Ctrl.FWDSPReady := False; + finally + if C <> nil then begin C.Close_; C.Free; end; + Ad.Free; + Ctrl.Free; + Host.Free; + end; +end; + +begin + Randomize; + Passed := 0; + Failed := 0; + WriteLn('=== Стенд TCI, этап 2 (бинарные потоки) ==='); + TestProtocol; + TestResampling; + TestStreamOut; + TestRecorder; + TestServer; + TestEndToEnd; + WriteLn; + WriteLn(Format('Итого: %d проверок, провалено %d', [Passed + Failed, Failed])); + if Failed > 0 then Halt(1); +end.