diff --git a/RadioController.pas b/RadioController.pas index 43d539b..be38326 100644 --- a/RadioController.pas +++ b/RadioController.pas @@ -53,10 +53,8 @@ uses Settings, ChannelStore, FMRepeater, BoardUtils, DeviceStore; const - // Панадаптеры (этап 3): потолок панов = главный + до 3 доп. аппаратных DDC - // (кап по Ethernet/CPU, см. doc/SLICES_PLAN.md 3.1). Фактический лимит - // бэкенда — Caps.MaxPans (Pluto = 1). - MAX_PANS = 4; + // Панадаптеры (этап 3): потолок MAX_PANS живёт в WDSPEngine (общий для + // движка/контроллера/UI). Фактический лимит бэкенда — Caps.MaxPans (Pluto=1). // Допустимые rate доп. пан-DDC, кГц (дефолт 384). PAN_DDC_RATES: array[0..2] of Word = (192, 384, 768); @@ -136,6 +134,7 @@ type Active: Boolean; Id: Integer; // = id слайса в TWDSPEngine Letter: Char; // буква флага ('A'..'F'), пока для UI + PanId: Integer; // пан слайса: 0 = главный, 1.. = доп. DDC-пан TargetHz: Double; // абсолютная частота слайса Mode: Integer; FilterLo: Integer; @@ -166,9 +165,10 @@ type function FindSliceIndex(Id: Integer): Integer; // -1 если нет procedure OnSliceAudioReady(SliceId: Integer; const Left, Right: array of Single; Count: Integer); - procedure RefreshSliceShifts; // пересчёт ShiftHz всех слайсов при смене центра - // --- Панадаптеры на аппаратных DDC (этап 3.1) --- + procedure RefreshSliceShifts; // пересчёт ShiftHz слайсов пана 0 при смене центра + // --- Панадаптеры на аппаратных DDC (этап 3.1/3.2) --- procedure ResetPanDDCMaps; // все пан-слоты свободны, DDC→Pan пуст + function PanCenterHz(PanId: Integer): Double; // центр пана (0 = FCenterFreq) procedure ApplyAGCToEngine; // применяет FAGCMode к WDSP (единый маппинг) procedure ApplyModeDefaults; // дефолтный фильтр/девиация под текущий FMode procedure SanitizeFilterForMode; // FM: индекс фильтра 0/1 + BW/девиация (state) @@ -534,10 +534,12 @@ type // ---- Мультислайсы (софт-fan-out) ---- // Создаёт слайс на TargetHz: заводит WDSP-канал в движке + свой аудио-выход // на устройстве DevIndex (-1=default). Возвращает Id слайса или -1 при отказе - // (нет слотов / TargetHz вне захваченной полосы). DevName для персиста/UI. + // (нет слотов / TargetHz вне полосы пана). DevName для персиста/UI. + // APanId>0 — слайс внутри доп. DDC-пана (тот должен быть создан AddPanDDC). function AddSlice(TargetHz: Double; Mode, FilterLo, FilterHi: Integer; AGC: TWDSPAGCMode; Vol: Double; - DevIndex: Integer; const DevName: string): Integer; + DevIndex: Integer; const DevName: string; + APanId: Integer = 0): Integer; procedure RemoveSlice(Id: Integer); procedure SetSliceTarget(Id: Integer; TargetHz: Double); procedure SetSliceMode(Id, Mode: Integer); @@ -554,9 +556,9 @@ type procedure SetSliceDevice(Id, DevIndex: Integer; const DevName: string); function SliceCount: Integer; function GetSlice(Id: Integer; out S: TCtrlSlice): Boolean; - // Диапазон захваченной полосы вокруг центра, куда можно ставить софт-слайс. - procedure CapturedSpan(out LoHz, HiHz: Double); - function SliceFitsCapture(TargetHz: Double): Boolean; + // Диапазон захваченной полосы пана (0 = главный), куда можно ставить слайс. + procedure CapturedSpan(out LoHz, HiHz: Double; PanId: Integer = 0); + function SliceFitsCapture(TargetHz: Double; PanId: Integer = 0): Boolean; // ---- Панадаптеры на аппаратных DDC (этап 3.1; UI/движок — 3.2/3.3) ---- // Создаёт пан PanId (1..MAX_PANS-1) на своём DDC: FreqHz = центр (сырые Гц @@ -1128,27 +1130,41 @@ begin end; procedure TRadioController.RefreshSliceShifts; +// Смена центра ГЛАВНОГО захвата: пересчитываем только слайсы пана 0 — +// слайсы доп. панов привязаны к центрам своих DDC (SetPanDDCFreq). var i: Integer; begin if not Assigned(FDSPEngine) then Exit; for i := 0 to MAX_SLICES - 1 do - if FSlices[i].Active then + if FSlices[i].Active and (FSlices[i].PanId = 0) then FDSPEngine.SetSliceShift(FSlices[i].Id, FSlices[i].TargetHz - FCenterFreq); end; -procedure TRadioController.CapturedSpan(out LoHz, HiHz: Double); -// Полезная полоса ±47.5% от захвата (запас на краях фильтра/анти-алиасинга). -var Half: Double; +function TRadioController.PanCenterHz(PanId: Integer): Double; begin - Half := FSampleRate * 0.475; - LoHz := FCenterFreq - Half; - HiHz := FCenterFreq + Half; + if (PanId >= 1) and (PanId < MAX_PANS) and (FPanDDCIdx[PanId] >= 0) then + Result := FPanFreqHz[PanId] + else + Result := FCenterFreq; end; -function TRadioController.SliceFitsCapture(TargetHz: Double): Boolean; +procedure TRadioController.CapturedSpan(out LoHz, HiHz: Double; PanId: Integer); +// Полезная полоса ±47.5% от захвата (запас на краях фильтра/анти-алиасинга). +var Half, Rate: Double; +begin + if (PanId >= 1) and (PanId < MAX_PANS) and (FPanDDCIdx[PanId] >= 0) then + Rate := FPanRateKHz[PanId] * 1000.0 + else + Rate := FSampleRate; + Half := Rate * 0.475; + LoHz := PanCenterHz(PanId) - Half; + HiHz := PanCenterHz(PanId) + Half; +end; + +function TRadioController.SliceFitsCapture(TargetHz: Double; PanId: Integer): Boolean; var Lo, Hi: Double; begin - CapturedSpan(Lo, Hi); + CapturedSpan(Lo, Hi, PanId); Result := (TargetHz >= Lo) and (TargetHz <= Hi); end; @@ -1191,6 +1207,11 @@ begin if (i < MAX_DDCS) and (FDDCPanMap[i] < 0) then begin ddc := i; Break; end; if ddc < 0 then Exit; + // Движок: аккумулятор + analyzer пана (до включения DDC — к приходу первого + // пакета пан уже готов; PushPanIQ без пана просто дропает). + if FWDSPReady and not FDSPEngine.AddPan(PanId, Integer(RateKHz) * 1000) then + Exit; + FPanDDCIdx[PanId] := ddc; FPanFreqHz[PanId] := FreqHz; FPanRateKHz[PanId] := RateKHz; @@ -1200,27 +1221,38 @@ begin end; procedure TRadioController.RemovePanDDC(PanId: Integer); -var ddc: Integer; +var ddc, i: Integer; begin if (PanId < 1) or (PanId >= MAX_PANS) then Exit; ddc := FPanDDCIdx[PanId]; if ddc < 0 then Exit; // Сначала снимаем роутинг (OnDDCIQ в сетевом потоке перестаёт брать пакеты - // этого DDC), потом гасим DDC в железе. + // этого DDC), потом слайсы пана (их звуковухи), потом движок и DDC в железе. FDDCPanMap[ddc] := -1; FPanDDCIdx[PanId] := -1; FPanFreqHz[PanId] := 0; FPanRateKHz[PanId] := 0; + for i := 0 to MAX_SLICES - 1 do + if FSlices[i].Active and (FSlices[i].PanId = PanId) then + RemoveSlice(FSlices[i].Id); + if FWDSPReady then FDSPEngine.RemovePan(PanId); if Assigned(FNetwork) then FNetwork.SetPanDDC(ddc, False, 0, 0, 0); end; procedure TRadioController.SetPanDDCFreq(PanId: Integer; FreqHz: Double); +var i: Integer; begin if (PanId < 1) or (PanId >= MAX_PANS) then Exit; if FPanDDCIdx[PanId] < 0 then Exit; FPanFreqHz[PanId] := FreqHz; if Assigned(FNetwork) then FNetwork.SetPanDDCFreq(FPanDDCIdx[PanId], FreqHz); + // Слайсы пана держат абсолютный TargetHz — обновляем их NCO-сдвиги + // относительно нового центра (аналог RefreshSliceShifts главного). + if Assigned(FDSPEngine) then + for i := 0 to MAX_SLICES - 1 do + if FSlices[i].Active and (FSlices[i].PanId = PanId) then + FDSPEngine.SetSliceShift(FSlices[i].Id, FSlices[i].TargetHz - FreqHz); end; function TRadioController.PanDDCActive(PanId: Integer): Boolean; @@ -1242,14 +1274,16 @@ end; function TRadioController.AddSlice(TargetHz: Double; Mode, FilterLo, FilterHi: Integer; AGC: TWDSPAGCMode; Vol: Double; - DevIndex: Integer; const DevName: string): Integer; + DevIndex: Integer; const DevName: string; APanId: Integer): Integer; var i, slot, Id: Integer; AO: TAudioOutput; begin Result := -1; if not Assigned(FDSPEngine) then Exit; - if not SliceFitsCapture(TargetHz) then Exit; // вне захваченной полосы (Этап 3=HW DDC) + if (APanId < 0) or (APanId >= MAX_PANS) then Exit; + if (APanId > 0) and (FPanDDCIdx[APanId] < 0) then Exit; // пан не создан + if not SliceFitsCapture(TargetHz, APanId) then Exit; // вне полосы пана slot := -1; for i := 0 to MAX_SLICES - 1 do @@ -1258,7 +1292,8 @@ begin Id := FNextSliceId; if not FDSPEngine.AddSlice(Id, Mode, FilterLo, FilterHi, - TargetHz - FCenterFreq, Vol, AGC) then Exit; + TargetHz - PanCenterHz(APanId), Vol, AGC, + APanId) then Exit; Inc(FNextSliceId); // Свой аудио-выход слайса на выбранной звуковухе. @@ -1272,6 +1307,7 @@ begin // Буква флага. Слайс A — это главный приёмник (не в FSlices[]), поэтому доп. // слайсы нумеруются с 'B' (slot 0 → B, 1 → C, …), иначе получалось два «A». FSlices[slot].Letter := Chr(Ord('B') + slot); + FSlices[slot].PanId := APanId; FSlices[slot].TargetHz := TargetHz; FSlices[slot].Mode := Mode; FSlices[slot].FilterLo := FilterLo; @@ -1311,7 +1347,7 @@ begin if idx < 0 then Exit; FSlices[idx].TargetHz := TargetHz; if Assigned(FDSPEngine) then - FDSPEngine.SetSliceShift(Id, TargetHz - FCenterFreq); + FDSPEngine.SetSliceShift(Id, TargetHz - PanCenterHz(FSlices[idx].PanId)); end; procedure TRadioController.SetSliceMode(Id, Mode: Integer); diff --git a/WDSPEngine.pas b/WDSPEngine.pas index f6bd5b7..3b2dd82 100644 --- a/WDSPEngine.pas +++ b/WDSPEngine.pas @@ -132,12 +132,23 @@ const RX_DISP_ID = 0; // analyzer для RX-спектра/водопада TX_DISP_ID = 1; // analyzer для TX-спектра/водопада + + // --- Панадаптеры на аппаратных DDC (этап 3) --- + // Пан 0 = главный тракт (RXA/RX_DISP_ID), доп. паны 1..MAX_PANS-1 живут на + // своих DDC. Единый потолок для движка/контроллера/UI. + MAX_PANS = 4; + // Analyzer id пана N = PAN_DISP_BASE + N (9..11). ID 4/5 заняты веткой + // feature/qo100-zoom-panel — не пересекаемся при будущем merge. + PAN_DISP_BASE = 8; + DSP_BUFSIZE = 1024; SPECTRUM_PIXELS = 4096; // потолок точек дисплея; синхронно с WaterfallView.WF_MAX_PIXELS DISPLAY_BLOCK_SIZE = 64; - // Очередь IQ пакетов между сетевым и DSP потоком - IQ_QUEUE_SIZE = 64; // кол-во слотов (степень двойки для AND-маски) + // Очередь IQ пакетов между сетевым и DSP потоком (общая: главный + паны). + // 256: главный 1.536М (~6.5к пкт/с) + 3 пана по 768к (~10к пкт/с) должны + // переживать scheduling-паузы ~15мс без дропов. + IQ_QUEUE_SIZE = 256; // кол-во слотов (степень двойки для AND-маски) IQ_PKT_MAXBYTES = 1428; // max байт IQ данных в пакете (238*6) // TX mic ring buffer — должен быть степенью двойки @@ -155,6 +166,7 @@ type Data: array[0..IQ_PKT_MAXBYTES - 1] of Byte; DataLen: Integer; // реальная длина данных IQPairs: Integer; // количество IQ пар + PanId: Integer; // 0 = главный тракт, 1..MAX_PANS-1 = доп. пан end; TWDSPAGCMode = (agcOff = 0, agcLong = 1, agcSlow = 2, @@ -173,6 +185,10 @@ type // SliceId — логический id (из RadioController), не WDSP-канал. TOnSliceAudio = procedure(SliceId: Integer; const Left, Right: array of Single; Count: Integer) of object; + // Пиксели спектра/водопада доп. пана — из display-потока (как FOnSpectrum/ + // FOnWaterfall главного). Внутри колбэка НЕ звать slice/pan API движка. + TOnPanPixels = procedure(PanId: Integer; const Pixels: array of Single; + Count: Integer) of object; // Sound-card mic source: TTXDSPThread дёргает этот колбэк перед каждым тиком, // чтобы вытянуть накопленные сэмплы из внешнего буфера (например PortAudio). // Реализация должна вернуть кол-во отправленных в WDSP сэмплов. @@ -188,6 +204,7 @@ type Active: Boolean; // слот занят Id: Integer; // логический id слайса Chan: Integer; // WDSP-канал (SLICE_CHAN_BASE + слот) + PanId: Integer; // пан слайса: 0 = главный, 1.. = доп. DDC-пан ShiftHz: Double; // NCO-сдвиг = TargetHz - CapturedCenterHz Mode: Integer; // индекс режима (MODE_*) FilterLo: Integer; @@ -205,6 +222,21 @@ type Opened: Boolean; // WDSP-канал реально открыт (после Open движка) end; + // Доп. панадаптер (этап 3): свой DDC-поток (rate 192-768к), свой analyzer + // (PAN_DISP_BASE+PanId), свои слайсы (TSliceChan.PanId). Доступ к записи — + // только под FSliceLock (кроме read-only Active в PushPanIQ). + TPanChan = record + Active: Boolean; + Rate: Integer; // входной rate пан-DDC, Гц (кратен FAudioRate) + BufSize: Integer; // блок @ Rate = FAudioBufSize * Rate / FAudioRate + AccI: array of Double; // аккумулятор входных IQ до BufSize + AccQ: array of Double; + AccPos: Integer; + DispBuf: array of Double; // feed анализатора (DISPLAY_BLOCK_SIZE пар) + DispPos: Integer; + DispOpen: Boolean; // XCreateAnalyzer(PAN_DISP_BASE+PanId) жив + end; + { TWDSPEngine } TWDSPEngine = class private @@ -294,6 +326,11 @@ type FSliceOutR: array of Single; // R FOnSliceAudio: TOnSliceAudio; + // --- Панадаптеры на аппаратных DDC (этап 3.2) --- + FPans: array[1..MAX_PANS-1] of TPanChan; + FOnPanSpectrum: TOnPanPixels; + FOnPanWaterfall: TOnPanPixels; + // TX mic ring buffer (lock-free: один writer — receive thread, // один reader — TX DSP thread) FTXMicRing: array[0..TX_MIC_RING-1] of Double; @@ -395,7 +432,10 @@ type // WFM требует dsp_rate >= 192k (девиация 75 кГц не влезает в 48 кГц). function WantWideDSP(Mode: Integer): Boolean; function WFMDSPRate: Integer; + function WFMDSPRateFor(InRate: Integer): Integer; // WFMDSPRate для произвольного входа function SliceDSPRate(Mode: Integer): Integer; + // dsp_rate слайса от режима и ЕГО входного rate (у пан-слайсов вход = rate пана) + function SliceDSPRateFor(Mode, InRate: Integer): Integer; function ClampToNyquist(Hz, Rate: Integer): Integer; function EffectiveTXNC: Integer; procedure ApplyDSPRate(Mode: Integer); @@ -403,11 +443,22 @@ type procedure ApplyWFMSettings; // --- Мультислайсы --- function FindSliceIdx(Id: Integer): Integer; // -1 если нет + function SliceInRate(Idx: Integer): Integer; // входной rate слайса (его пан) + function SliceInSize(Idx: Integer): Integer; // входной блок слайса (его пан) procedure OpenSliceChannel(Idx: Integer); // OpenChannel + настройки procedure CloseSliceChannel(Idx: Integer); // CloseChannel (запись хранит) procedure ApplyAGCToChan(Chan: Integer; Mode: TWDSPAGCMode; FixedGain: Double); procedure ProcessSlices; // fan-out в ProcessRXBlock + // Прогон блока IQ пана через его слайсы. Вызывать ПОД FSliceLock. + procedure ProcessSlicesFor(PanId: Integer; + const AccI, AccQ: array of Double; N: Integer); procedure AllocSliceBuffers; // (пере)аллокация буферов + // --- Панадаптеры (этап 3.2) --- + procedure CreatePanAnalyzer(PanId: Integer); // XCreateAnalyzer + настройки + procedure DestroyPanAnalyzer(PanId: Integer); + procedure ApplyPanAnalyzerSettings(PanId: Integer); + procedure ReapplyPanDisplaySettings; // все активные паны, под локом + procedure PushPanItemToDSP(PanId: Integer; const Item: TIQQueueItem); procedure ProcessRXBlock; procedure PushIQItemToDSP(const Item: TIQQueueItem); procedure OpenAnalyzer; @@ -451,11 +502,22 @@ type // QO-100 beacon-декодер: тап RX-IQ для демодуляции маяка (не владеет). procedure SetBeaconDecoder(D: TBeaconDecoder); + // --- Панадаптеры на аппаратных DDC (этап 3.2) --- + // Создаёт доп. пан PanId (1..MAX_PANS-1): аккумулятор + analyzer + // PAN_DISP_BASE+PanId. RateHz — rate его DDC, кратен FAudioRate. + // Слайсы пана создаются отдельно (AddSlice с APanId). + function AddPan(PanId, RateHz: Integer): Boolean; + // Убирает пан вместе с его слайсами (аудио-выходы слайсов — забота вызывающего). + procedure RemovePan(PanId: Integer); + function PanActive(PanId: Integer): Boolean; + // --- Мультислайсы (софт-fan-out) --- // Добавляет слайс с логическим Id (уникальным). ShiftHz = TargetHz - центр - // захвата. Возвращает False если нет свободных слотов или Id уже есть. + // захвата ЕГО пана. APanId>0 требует активного пана (AddPan). + // Возвращает False если нет свободных слотов или Id уже есть. function AddSlice(Id, Mode, FilterLo, FilterHi: Integer; - ShiftHz, Volume: Double; AGC: TWDSPAGCMode): Boolean; + ShiftHz, Volume: Double; AGC: TWDSPAGCMode; + APanId: Integer = 0): Boolean; procedure RemoveSlice(Id: Integer); procedure SetSliceShift(Id: Integer; ShiftHz: Double); procedure SetSliceMode(Id, Mode: Integer); @@ -606,6 +668,9 @@ type property OnSliceAudio: TOnSliceAudio read FOnSliceAudio write FOnSliceAudio; property OnSpectrum: TOnSpectrumReady read FOnSpectrum write FOnSpectrum; property OnWaterfall: TOnWaterfallReady read FOnWaterfall write FOnWaterfall; + // Пиксели доп. панов (display-поток). UI вешает при появлении панов (3.3). + property OnPanSpectrum: TOnPanPixels read FOnPanSpectrum write FOnPanSpectrum; + property OnPanWaterfall: TOnPanPixels read FOnPanWaterfall write FOnPanWaterfall; property OnTXIQ: TOnTXIQReady read FOnTXIQ write FOnTXIQ; property TXActive: Boolean read FTXActive; // Включает full-duplex поведение RX-тракта: RXA не останавливается на TX, @@ -804,11 +869,14 @@ begin RTLEventWaitFor(FEngine.FQueueSem, 100); if Terminated then Break; - // Разбираем все накопившиеся пакеты + // Разбираем все накопившиеся пакеты (общая очередь: главный + паны) while FEngine.FQueueTail <> FEngine.FQueueHead do begin Item := @FEngine.FQueue[FEngine.FQueueTail]; - FEngine.PushIQItemToDSP(Item^); + if Item^.PanId = 0 then + FEngine.PushIQItemToDSP(Item^) + else + FEngine.PushPanItemToDSP(Item^.PanId, Item^); FEngine.FQueueTail := (FEngine.FQueueTail + 1) and (IQ_QUEUE_SIZE - 1); end; end; @@ -850,9 +918,9 @@ begin Result := (Mode = MODE_WFM) and (WFMDSPRate > FAudioRate); end; -function TWDSPEngine.WFMDSPRate: Integer; +function TWDSPEngine.WFMDSPRateFor(InRate: Integer): Integer; // Наибольший rate вида FAudioRate * 2^k, который не выше WFM_DSP_RATE и делит -// FSampleRate нацело. Оба требования жёсткие: +// InRate нацело. Оба требования жёсткие: // * pre_main_build считает dsp_insize через ЦЕЛОЧИСЛЕННОЕ in_rate/dsp_rate, // поэтому отношение обязано быть точным; // * dsp_size = FAudioBufSize * (rate/FAudioRate) должен остаться степенью @@ -863,17 +931,30 @@ begin R := WFM_DSP_RATE div FAudioRate; // 4 при 48 кГц аудио while R > 1 do begin - if (FAudioRate * R <= FSampleRate) and (FSampleRate mod (FAudioRate * R) = 0) then + if (FAudioRate * R <= InRate) and (InRate mod (FAudioRate * R) = 0) then Break; R := R div 2; end; Result := FAudioRate * R; end; +function TWDSPEngine.WFMDSPRate: Integer; +begin + Result := WFMDSPRateFor(FSampleRate); +end; + function TWDSPEngine.SliceDSPRate(Mode: Integer): Integer; begin - if WantWideDSP(Mode) then Result := WFMDSPRate - else Result := FAudioRate; + Result := SliceDSPRateFor(Mode, FSampleRate); +end; + +function TWDSPEngine.SliceDSPRateFor(Mode, InRate: Integer): Integer; +// Пан-слайс поднимается в широкий WFM-режим от rate СВОЕГО пана, а не главного. +begin + if (Mode = MODE_WFM) and (WFMDSPRateFor(InRate) > FAudioRate) then + Result := WFMDSPRateFor(InRate) + else + Result := FAudioRate; end; procedure TWDSPEngine.ApplyDSPRate(Mode: Integer); @@ -1137,7 +1218,7 @@ end; procedure TWDSPEngine.OpenAnalyzer; var - Success: Integer; + Success, p: Integer; begin if FAnalyzerOpen then Exit; @@ -1160,6 +1241,17 @@ begin ApplyAnalyzerSettings; FAnalyzerOpen := True; + + // Анализаторы активных доп. панов (пересоздание после ChangeSampleRate: + // Close их разрушил, записи FPans живы). + FSliceLock.Enter; + try + for p := 1 to MAX_PANS - 1 do + if FPans[p].Active then CreatePanAnalyzer(p); + finally + FSliceLock.Leave; + end; + if FDisplayThread = nil then FDisplayThread := TDisplayThread.Create(Self); end; @@ -1257,11 +1349,91 @@ begin SetDisplaySampleRate (TX_DISP_ID, FTXSampleRate); end; +procedure TWDSPEngine.ApplyPanAnalyzerSettings(PanId: Integer); +// Параметризованная копия ApplyRXAnalyzerSettings для analyzer'а доп. пана: +// rate пана, БЕЗ span-clip/zoom (пан-зум — этап 3.3+), FFT/детектор/усреднение +// пока общие с главным. Вызывать под FSliceLock. +var + AnalyzerFFT, AvgCount, WfAvgCount, MaxW, Overlap, DisplayPixels, Disp: Integer; + Backmult, WfBackmult: Double; + EffFPS: Double; +const + KEEP_TIME_SEC = 0.10; +begin + if not FPans[PanId].DispOpen then Exit; + Disp := PAN_DISP_BASE + PanId; + AnalyzerFFT := CalcDisplayFFTSize; + FPans[PanId].DispPos := 0; + MaxW := AnalyzerFFT + Round(Min(KEEP_TIME_SEC * FPans[PanId].Rate, + KEEP_TIME_SEC * AnalyzerFFT * EnsureRange(FDisplayFPS, 5, 100))); + CalcAnalyzerTiming(AnalyzerFFT, Overlap, EffFPS); + DisplayPixels := EnsureRange(FLastSpectrumW, 64, + EnsureRange(FDisplayResLimit, 64, SPECTRUM_PIXELS)); + + AverageTimeToParams(FSpecAvgMode, FSpecAvgTimeMS, AvgCount, Backmult); + AverageTimeToParams(FWfAvgMode, FWfAvgTimeMS, WfAvgCount, WfBackmult); + + SetAnalyzer(Disp, 2, 1, 1, @FFlp[0], AnalyzerFFT, DISPLAY_BLOCK_SIZE, + FWindowType, 14.0, Overlap, 0, 0, 0, DisplayPixels, + 1, 0, 0.0, 0.0, MaxW); + SetDisplayDetectorMode(Disp, 0, FSpecDetector); + SetDisplayAverageMode (Disp, 0, FSpecAvgMode); + SetDisplayNumAverage (Disp, 0, AvgCount); + SetDisplayAvBackmult (Disp, 0, Backmult); + SetDisplayNormOneHz (Disp, 0, UsePanNormOneHz(FSpecDetector)); + SetDisplayDetectorMode(Disp, 1, FWfDetector); + SetDisplayAverageMode (Disp, 1, FWfAvgMode); + SetDisplayNumAverage (Disp, 1, WfAvgCount); + SetDisplayAvBackmult (Disp, 1, WfBackmult); + SetDisplayNormOneHz (Disp, 1, 0); + SetDisplaySampleRate (Disp, FPans[PanId].Rate); +end; + +procedure TWDSPEngine.CreatePanAnalyzer(PanId: Integer); +// Вызывать под FSliceLock, при живом WDSP (OpenAnalyzer уже прошёл). +var + Success: Integer; +begin + if FPans[PanId].DispOpen then Exit; + Success := -1; + XCreateAnalyzer(PAN_DISP_BASE + PanId, @Success, 262144, 1, 1, nil); + if Success <> 0 then + begin + FLastError := Format('XCreateAnalyzer(PAN%d) failed: %d', [PanId, Success]); + Exit; // пан без спектра: слайсы работают, DispOpen=False гейтит Spectrum0 + end; + FPans[PanId].DispOpen := True; + ApplyPanAnalyzerSettings(PanId); +end; + +procedure TWDSPEngine.DestroyPanAnalyzer(PanId: Integer); +// Вызывать под FSliceLock (display-поток читает DispOpen/зовёт GetPixels под ним). +begin + if not FPans[PanId].DispOpen then Exit; + FPans[PanId].DispOpen := False; + DestroyAnalyzer(PAN_DISP_BASE + PanId); +end; + +procedure TWDSPEngine.ReapplyPanDisplaySettings; +// Перекатывает общие настройки дисплея (FFT/детектор/усреднение/пиксели) на +// анализаторы всех активных панов. +var p: Integer; +begin + FSliceLock.Enter; + try + for p := 1 to MAX_PANS - 1 do + if FPans[p].Active then ApplyPanAnalyzerSettings(p); + finally + FSliceLock.Leave; + end; +end; + procedure TWDSPEngine.ApplyAnalyzerSettings; -// Совместимый вход: применяет настройки и к RX, и к TX analyzer'у. +// Совместимый вход: применяет настройки и к RX, и к TX analyzer'у, и к панам. begin ApplyRXAnalyzerSettings; ApplyTXAnalyzerSettings; + ReapplyPanDisplaySettings; end; procedure TWDSPEngine.SetZoomPan(AZoom, APan: Double); @@ -1275,6 +1447,7 @@ begin end; procedure TWDSPEngine.CloseAnalyzer; +var p: Integer; begin if not FAnalyzerOpen then Exit; if Assigned(FDisplayThread) then @@ -1285,6 +1458,14 @@ begin end; DestroyAnalyzer(RX_DISP_ID); DestroyAnalyzer(TX_DISP_ID); + // Анализаторы доп. панов (записи FPans сохраняются — пересоздание в OpenAnalyzer). + FSliceLock.Enter; + try + for p := 1 to MAX_PANS - 1 do + DestroyPanAnalyzer(p); + finally + FSliceLock.Leave; + end; FAnalyzerOpen := False; end; @@ -1465,6 +1646,10 @@ end; procedure TWDSPEngine.ChangeSampleRate(NewRate: Integer); // Меняет samplerate на лету: Close → сброс очереди → пересчёт буферов → Open. +// Панов не трогает: их rate свой; аккумуляторы сбрасываем (очередь общая, +// их пакеты умерли вместе с ней), анализаторы пересоздаст OpenAnalyzer. +var + p: Integer; begin if NewRate = FSampleRate then Exit; @@ -1476,6 +1661,12 @@ begin FQueueHead := 0; FQueueTail := 0; FRXAccPos := 0; + for p := 1 to MAX_PANS - 1 do + if FPans[p].Active then + begin + FPans[p].AccPos := 0; + FPans[p].DispPos := 0; + end; // Обновляем параметры FSampleRate := NewRate; @@ -1523,6 +1714,7 @@ begin Move(Buf[DataOffset], Item^.Data[0], DataBytes); Item^.DataLen := DataBytes; Item^.IQPairs := IQPairs; + Item^.PanId := 0; FQueueHead := NextHead; RTLEventSetEvent(FQueueSem); // будим DSP поток (как sem_post в piHPSDR) @@ -1530,9 +1722,102 @@ end; procedure TWDSPEngine.PushPanIQ(PanId: Integer; const Buf: array of Byte; DataOffset: Integer; IQPairs: Integer); -// Сетевой поток. Этап 3.2 положит пакет в аккумулятор пана PanId; до тех пор -// поток доп. пан-DDC отбрасывается (панов ещё никто не создаёт). +// Сетевой поток: пакет пан-DDC в ОБЩУЮ очередь с меткой пана (порядок прихода +// сохраняется, один семафор). Active читаем без лока: Boolean, худший случай — +// один лишний пакет в очереди, который DSP-поток отсеет уже под локом. +var + NextHead: Integer; + Item: ^TIQQueueItem; + DataBytes: Integer; begin + if (PanId < 1) or (PanId >= MAX_PANS) then Exit; + if not FPans[PanId].Active then Exit; + + NextHead := (FQueueHead + 1) and (IQ_QUEUE_SIZE - 1); + if NextHead = FQueueTail then Exit; // очередь полна — пропускаем пакет + + Item := @FQueue[FQueueHead]; + DataBytes := IQPairs * 6; + if DataBytes > IQ_PKT_MAXBYTES then DataBytes := IQ_PKT_MAXBYTES; + + Move(Buf[DataOffset], Item^.Data[0], DataBytes); + Item^.DataLen := DataBytes; + Item^.IQPairs := IQPairs; + Item^.PanId := PanId; + + FQueueHead := NextHead; + RTLEventSetEvent(FQueueSem); +end; + +procedure TWDSPEngine.PushPanItemToDSP(PanId: Integer; const Item: TIQQueueItem); +// DSP-поток: декод 24-бит IQ пан-DDC → feed анализатора пана + аккумулятор; +// по накоплению блока — слайсы этого пана. Всё под FSliceLock: Add/RemovePan +// и слайс-API меняют состояние из UI-потока. TX-гейты зеркалят главный тракт. +var + i, Pos: Integer; + IR, QR: LongInt; + DI, DQ: Double; + P: PByte; + Pan: ^TPanChan; +const + SCALE = 1.0 / 8388608.0; +begin + if FTXActive and not FKeepRXDuringTX then Exit; + if (FPostTXMuteUntil <> 0) and (GetTickCount64 < FPostTXMuteUntil) then Exit; + if not FInitialized then Exit; + + FSliceLock.Enter; + try + Pan := @FPans[PanId]; + if not Pan^.Active then Exit; + + Pos := 0; + P := @Item.Data[0]; + for i := 0 to Item.IQPairs - 1 do + begin + if Pos + 5 >= Item.DataLen then Break; + + IR := (LongInt(P[0]) shl 16) or (LongInt(P[1]) shl 8) or LongInt(P[2]); + QR := (LongInt(P[3]) shl 16) or (LongInt(P[4]) shl 8) or LongInt(P[5]); + Inc(P, 6); + Inc(Pos, 6); + + if (IR and $800000) <> 0 then IR := IR or LongInt($FF000000); + if (QR and $800000) <> 0 then QR := QR or LongInt($FF000000); + + DI := IR * SCALE; + DQ := QR * SCALE; + + if Pan^.DispOpen then + begin + Pan^.DispBuf[Pan^.DispPos * 2] := DI; + Pan^.DispBuf[Pan^.DispPos * 2 + 1] := DQ; + Inc(Pan^.DispPos); + if Pan^.DispPos >= DISPLAY_BLOCK_SIZE then + begin + Spectrum0(1, PAN_DISP_BASE + PanId, 0, 0, @Pan^.DispBuf[0]); + Pan^.DispPos := 0; + end; + end; + + Pan^.AccI[Pan^.AccPos] := DI; + Pan^.AccQ[Pan^.AccPos] := DQ; + Inc(Pan^.AccPos); + + if Pan^.AccPos >= Pan^.BufSize then + begin + Pan^.AccPos := 0; + if Assigned(FOnSliceAudio) then + try + ProcessSlicesFor(PanId, Pan^.AccI, Pan^.AccQ, Pan^.BufSize); + except + // Защита от падения libwdsp в потоке — пропускаем блок (как ProcessRXBlock) + end; + end; + end; + finally + FSliceLock.Leave; + end; end; procedure TWDSPEngine.FlushRX; @@ -1699,9 +1984,16 @@ end; // --------------------------------------------------------------------------- procedure TWDSPEngine.AllocSliceBuffers; +// FSliceIn/Out должны вмещать наибольший входной блок среди главного тракта +// и активных панов (пан на 768к при главном 192к имеет блок больше FBufSize). +var + Need, p: Integer; begin - SetLength(FSliceIn, FBufSize * 2); - SetLength(FSliceOut, FBufSize * 2); // с запасом (>= FAudioBufSize*2) + Need := FBufSize; + for p := 1 to MAX_PANS - 1 do + if FPans[p].Active and (FPans[p].BufSize > Need) then Need := FPans[p].BufSize; + SetLength(FSliceIn, Need * 2); + SetLength(FSliceOut, Need * 2); // с запасом (>= FAudioBufSize*2) SetLength(FSliceOutL, FAudioBufSize); SetLength(FSliceOutR, FAudioBufSize); end; @@ -1714,6 +2006,29 @@ begin if FSlices[i].Active and (FSlices[i].Id = Id) then Exit(i); end; +function TWDSPEngine.SliceInRate(Idx: Integer): Integer; +// Входной rate слайса = rate его пана (пан 0 = главный захват). +var p: Integer; +begin + p := FSlices[Idx].PanId; + if (p >= 1) and (p < MAX_PANS) and FPans[p].Active then + Result := FPans[p].Rate + else + Result := FSampleRate; +end; + +function TWDSPEngine.SliceInSize(Idx: Integer): Integer; +// Входной блок слайса = блок его пана. Выходной блок при этом всегда +// FAudioBufSize (in_size/in_rate = const по построению BufSize панов). +var p: Integer; +begin + p := FSlices[Idx].PanId; + if (p >= 1) and (p < MAX_PANS) and FPans[p].Active then + Result := FPans[p].BufSize + else + Result := FBufSize; +end; + // Применяет AGC к произвольному каналу — вынесено из SetAGC чтобы делить с слайсами. procedure TWDSPEngine.ApplyAGCToChan(Chan: Integer; Mode: TWDSPAGCMode; FixedGain: Double); @@ -1746,20 +2061,23 @@ end; // Открывает WDSP-канал слайса с той же геометрией, что RXA_CHAN. Вызывать // под FSliceLock и только при FInitialized. procedure TWDSPEngine.OpenSliceChannel(Idx: Integer); -var Chan, DSPRate, DSPSize: Integer; +var Chan, DSPRate, DSPSize, InRate, InSize: Integer; begin if not FInitialized then Exit; if FSlices[Idx].Opened then Exit; Chan := FSlices[Idx].Chan; + // Вход канала = rate/блок ПАНА слайса (пан 0 = главный захват). + InRate := SliceInRate(Idx); + InSize := SliceInSize(Idx); // WFM-слайс, как и главный канал, живёт на WFMDSPRate: rsmpin децимирует // вход, rsmpout сводит к FAudioRate, так что out_size остаётся FAudioBufSize. - DSPRate := SliceDSPRate(FSlices[Idx].Mode); + DSPRate := SliceDSPRateFor(FSlices[Idx].Mode, InRate); DSPSize := FAudioBufSize * (DSPRate div FAudioRate); - OpenChannel(Chan, FBufSize, DSPSize, FSampleRate, DSPRate, FAudioRate, + OpenChannel(Chan, InSize, DSPSize, InRate, DSPRate, FAudioRate, 0, 0, 0.010, 0.010, 0.010, 0.010, 0); // NB (ANB/NOB) требует создания EXT-блоков на канале, как для RXA_CHAN в Open. - create_anbEXT(Chan, 1, FBufSize, FSampleRate, 0.0001, 0.0001, 0.0001, 0.05, 20); - create_nobEXT(Chan, 1, 0, FBufSize, FSampleRate, 0.0001, 0.0001, 0.0001, 0.05, 20); + create_anbEXT(Chan, 1, InSize, InRate, 0.0001, 0.0001, 0.0001, 0.05, 20); + create_nobEXT(Chan, 1, 0, InSize, InRate, 0.0001, 0.0001, 0.0001, 0.05, 20); SetRXABandpassWindow(Chan, 1); SetRXAMode(Chan, ModeToWDSP(FSlices[Idx].Mode)); if FSlices[Idx].Mode = MODE_WFM then ApplyRXWFMToChan(Chan); @@ -1791,51 +2109,123 @@ begin FSlices[Idx].Opened := False; end; -// Fan-out: прогоняет тот же входной IQ-блок через каждый активный слайс-канал. -// Вызывается из ProcessRXBlock (DSP-поток) после основного канала. +// Fan-out слайсов главного пана: вызывается из ProcessRXBlock (DSP-поток) +// после основного канала. procedure TWDSPEngine.ProcessSlices; -var - s, i, k, Err: Integer; - V: Double; begin if not Assigned(FOnSliceAudio) then Exit; FSliceLock.Enter; try - for s := 0 to MAX_SLICES - 1 do - begin - if not (FSlices[s].Active and FSlices[s].Opened) then Continue; - if FSlices[s].Muted then Continue; - // Свежая копия входного IQ — fexchange0 портит вход in-place. - for i := 0 to FBufSize - 1 do - begin - FSliceIn[i * 2] := FRXAccI[i]; - FSliceIn[i * 2 + 1] := FRXAccQ[i]; - end; - Err := 0; - fexchange0(FSlices[s].Chan, @FSliceIn[0], @FSliceOut[0], @Err); - // S-метр канала читаем здесь (DSP-поток) — GetRXAMeter сбрасывает аккумулятор. - FSlices[s].SMeter := GetRXAMeter(FSlices[s].Chan, RXA_S_AV); - V := FSlices[s].Volume; - for k := 0 to FAudioBufSize - 1 do - begin - FSliceOutL[k] := FSliceOut[k * 2] * V; - FSliceOutR[k] := FSliceOut[k * 2 + 1] * V; - end; - FOnSliceAudio(FSlices[s].Id, FSliceOutL, FSliceOutR, FAudioBufSize); - end; + ProcessSlicesFor(0, FRXAccI, FRXAccQ, FBufSize); finally FSliceLock.Leave; end; end; +// Прогоняет входной IQ-блок пана PanId через каждый его активный слайс-канал. +// Вызывать ПОД FSliceLock (общая дисциплина слайсов/панов vs DSP). +procedure TWDSPEngine.ProcessSlicesFor(PanId: Integer; + const AccI, AccQ: array of Double; N: Integer); +var + s, i, k, Err: Integer; + V: Double; +begin + for s := 0 to MAX_SLICES - 1 do + begin + if not (FSlices[s].Active and FSlices[s].Opened) then Continue; + if FSlices[s].PanId <> PanId then Continue; + if FSlices[s].Muted then Continue; + // Свежая копия входного IQ — fexchange0 портит вход in-place. + for i := 0 to N - 1 do + begin + FSliceIn[i * 2] := AccI[i]; + FSliceIn[i * 2 + 1] := AccQ[i]; + end; + Err := 0; + fexchange0(FSlices[s].Chan, @FSliceIn[0], @FSliceOut[0], @Err); + // S-метр канала читаем здесь (DSP-поток) — GetRXAMeter сбрасывает аккумулятор. + FSlices[s].SMeter := GetRXAMeter(FSlices[s].Chan, RXA_S_AV); + V := FSlices[s].Volume; + for k := 0 to FAudioBufSize - 1 do + begin + FSliceOutL[k] := FSliceOut[k * 2] * V; + FSliceOutR[k] := FSliceOut[k * 2 + 1] * V; + end; + FOnSliceAudio(FSlices[s].Id, FSliceOutL, FSliceOutR, FAudioBufSize); + end; +end; + +// --------------------------------------------------------------------------- +// Панадаптеры на аппаратных DDC (этап 3.2) +// --------------------------------------------------------------------------- + +function TWDSPEngine.AddPan(PanId, RateHz: Integer): Boolean; +begin + Result := False; + if (PanId < 1) or (PanId >= MAX_PANS) then Exit; + // Кратность rate: и BufSize (блок = FAudioBufSize*Rate/FAudioRate), и WDSP + // pre_main_build (целочисленное in_rate/dsp_rate) требуют точного отношения. + if (RateHz < FAudioRate) or (RateHz mod FAudioRate <> 0) then Exit; + FSliceLock.Enter; + try + if FPans[PanId].Active then Exit; + FPans[PanId].Rate := RateHz; + FPans[PanId].BufSize := FAudioBufSize * (RateHz div FAudioRate); + SetLength(FPans[PanId].AccI, FPans[PanId].BufSize); + SetLength(FPans[PanId].AccQ, FPans[PanId].BufSize); + SetLength(FPans[PanId].DispBuf, DISPLAY_BLOCK_SIZE * 2); + FPans[PanId].AccPos := 0; + FPans[PanId].DispPos := 0; + FPans[PanId].DispOpen := False; + FPans[PanId].Active := True; + AllocSliceBuffers; // FSliceIn/Out под новый макс. блок + if FAnalyzerOpen then CreatePanAnalyzer(PanId); + Result := True; + finally + FSliceLock.Leave; + end; +end; + +procedure TWDSPEngine.RemovePan(PanId: Integer); +var s: Integer; +begin + if (PanId < 1) or (PanId >= MAX_PANS) then Exit; + FSliceLock.Enter; + try + if not FPans[PanId].Active then Exit; + // Слайсы пана: обычно контроллер уже убрал их (вместе со звуковухами); + // здесь — страховка, чтобы канал не остался кормиться из мёртвого пана. + for s := 0 to MAX_SLICES - 1 do + if FSlices[s].Active and (FSlices[s].PanId = PanId) then + begin + CloseSliceChannel(s); + FSlices[s].Active := False; + end; + DestroyPanAnalyzer(PanId); + FPans[PanId].Active := False; + FPans[PanId].AccI := nil; + FPans[PanId].AccQ := nil; + FPans[PanId].DispBuf := nil; + finally + FSliceLock.Leave; + end; +end; + +function TWDSPEngine.PanActive(PanId: Integer): Boolean; +begin + Result := (PanId >= 1) and (PanId < MAX_PANS) and FPans[PanId].Active; +end; + function TWDSPEngine.AddSlice(Id, Mode, FilterLo, FilterHi: Integer; - ShiftHz, Volume: Double; AGC: TWDSPAGCMode): Boolean; + ShiftHz, Volume: Double; AGC: TWDSPAGCMode; APanId: Integer): Boolean; var i, slot: Integer; begin Result := False; FSliceLock.Enter; try if FindSliceIdx(Id) >= 0 then Exit; // уже существует + if (APanId < 0) or (APanId >= MAX_PANS) then Exit; + if (APanId > 0) and not FPans[APanId].Active then Exit; // пан не создан slot := -1; for i := 0 to MAX_SLICES - 1 do if not FSlices[i].Active then begin slot := i; Break; end; @@ -1843,6 +2233,7 @@ begin FSlices[slot].Active := True; FSlices[slot].Id := Id; FSlices[slot].Chan := SLICE_CHAN_BASE + slot; + FSlices[slot].PanId := APanId; FSlices[slot].Mode := Mode; FSlices[slot].FilterLo := FilterLo; FSlices[slot].FilterHi := FilterHi; @@ -1899,7 +2290,8 @@ begin // Уход в/из WFM меняет dsp_rate канала — проще пересоздать его целиком, // чем гонять SetDSPSamplerate: слайс всё равно перестраивает все фильтры. Rerate := FSlices[idx].Opened and - (SliceDSPRate(Mode) <> SliceDSPRate(FSlices[idx].Mode)); + (SliceDSPRateFor(Mode, SliceInRate(idx)) <> + SliceDSPRateFor(FSlices[idx].Mode, SliceInRate(idx))); FSlices[idx].Mode := Mode; if Rerate then begin @@ -1926,7 +2318,7 @@ begin FSlices[idx].FilterHi := High; if FSlices[idx].Opened then begin - Rate := SliceDSPRate(FSlices[idx].Mode); + Rate := SliceDSPRateFor(FSlices[idx].Mode, SliceInRate(idx)); RXASetPassband(FSlices[idx].Chan, ClampToNyquist(Low, Rate), ClampToNyquist(High, Rate)); end; @@ -2843,10 +3235,12 @@ procedure TWDSPEngine.UpdateSpectrum; // при FFT=16384 WDSP использует уже накопленные данные из overlap-save // буфера, а не ждёт следующего полного блока. var - PixBuf: array[0..SPECTRUM_PIXELS - 1] of Single; - Flag: Integer; - i, N: Integer; - DispID: Integer; + PixBuf: array[0..SPECTRUM_PIXELS - 1] of Single; + WfBuf: array[0..SPECTRUM_PIXELS - 1] of Single; + Flag: Integer; + i, N, p: Integer; + DispID: Integer; + GotSpec, GotWf: Boolean; begin if not FInitialized or not FAnalyzerOpen then Exit; N := EnsureRange(FDisplayPixelCount, 1, SPECTRUM_PIXELS); @@ -2871,6 +3265,34 @@ begin if Assigned(FOnWaterfall) then FOnWaterfall(FWaterfallPixels, N); end; + + // Доп. паны: GetPixels под FSliceLock (гонка с DestroyPanAnalyzer из + // RemovePan в UI-потоке), колбэки — уже без лока (UI в них может звать + // slice/pan API). Пиксели пана не кэшируем (аналог FSpectrumPixels не нужен). + if Assigned(FOnPanSpectrum) or Assigned(FOnPanWaterfall) then + for p := 1 to MAX_PANS - 1 do + begin + GotSpec := False; + GotWf := False; + FSliceLock.Enter; + try + if FPans[p].Active and FPans[p].DispOpen then + begin + Flag := 0; + GetPixels(PAN_DISP_BASE + p, 0, @PixBuf[0], @Flag); + GotSpec := Flag <> 0; + Flag := 0; + GetPixels(PAN_DISP_BASE + p, 1, @WfBuf[0], @Flag); + GotWf := Flag <> 0; + end; + finally + FSliceLock.Leave; + end; + if GotSpec and Assigned(FOnPanSpectrum) then + FOnPanSpectrum(p, PixBuf, N); + if GotWf and Assigned(FOnPanWaterfall) then + FOnPanWaterfall(p, WfBuf, N); + end; end; procedure TWDSPEngine.GetSpectrumData(var Pixels: array of Single; @@ -3015,6 +3437,7 @@ begin SetDisplayNumAverage(RX_DISP_ID, 0, AvgCount); SetDisplayAvBackmult(RX_DISP_ID, 0, Backmult); SetDisplayNormOneHz(RX_DISP_ID, 0, UsePanNormOneHz(FSpecDetector)); + ReapplyPanDisplaySettings; // детектор/усреднение общие с панами end; procedure TWDSPEngine.SetWaterfallDisplay(Detector, AvgMode: Integer; @@ -3039,6 +3462,7 @@ begin SetDisplayNumAverage(RX_DISP_ID, 1, AvgCount); SetDisplayAvBackmult(RX_DISP_ID, 1, Backmult); SetDisplayNormOneHz(RX_DISP_ID, 1, 0); + ReapplyPanDisplaySettings; // детектор/усреднение общие с панами end; end. diff --git a/doc/SLICES_PLAN.md b/doc/SLICES_PLAN.md index 4053e25..9c63086 100644 --- a/doc/SLICES_PLAN.md +++ b/doc/SLICES_PLAN.md @@ -283,6 +283,43 @@ static/глобального. (CPU-LUT палитры общий — это о создание/удаление пана только под локом, как переоткрытие слайсов при ChangeSampleRate). +**СТАТУС 3.2 (2026-07-11): РЕАЛИЗОВАНО + СМОУК-ТЕСТ БЕЗ ЖЕЛЕЗА ПРОШЁЛ +ПОЛНОСТЬЮ.** Отличия/уточнения против текста плана: + +- Пан-пакеты идут в ОБЩУЮ очередь IQ с меткой `TIQQueueItem.PanId` (0 = + главный) — порядок прихода сохранён, один семафор; `IQ_QUEUE_SIZE` 64→256. + Диспетчер в `TDSPThread` → `PushPanItemToDSP` (декод + feed анализатора + + аккумулятор + `ProcessSlicesFor(PanId)`), всё под `FSliceLock`. +- `MAX_PANS=4` переехал в WDSPEngine (единый для движка/контроллера/UI); + `PAN_DISP_BASE=8` (id панов 9..11). +- Блок пана `BufSize = FAudioBufSize·Rate/FAudioRate` ⇒ выходной аудио-блок + слайсов ЛЮБОГО пана всегда `FAudioBufSize` (буферы не перевыделяются); + `AllocSliceBuffers` берёт максимум блока по всем панам. Rate пана обязан + быть кратен 48к (гейт в `AddPan`). +- `TSliceChan.PanId` + `AddSlice(..., APanId)`; `OpenSliceChannel` открывает + канал от rate/блока СВОЕГО пана; `SliceDSPRateFor(Mode, InRate)` — WFM-слайс + поднимается от rate пана. Слайсы панов переживают `ChangeSampleRate` + главного (пересоздаются в Open, как раньше). +- Анализатор пана: `CreatePanAnalyzer`/`ApplyPanAnalyzerSettings` (без + span-clip/zoom — пан-зум в 3.3+), живёт в OpenAnalyzer/CloseAnalyzer + (пересоздание при смене rate главного). Смена детектора/усреднения/FFT/ + пикселей главного перекатывается на паны (`ReapplyPanDisplaySettings`). +- Выдача пикселей: `OnPanSpectrum`/`OnPanWaterfall(PanId, Pixels, N)` из + display-потока; `GetPixels` пана под `FSliceLock` (гонка с RemovePan), + колбэки — вне лока. +- Контроллер: `TCtrlSlice.PanId`, `AddSlice(..., APanId)`, гейт/`CapturedSpan` + per-pan, `SetSliceTarget`/`RefreshSliceShifts` относительно центра СВОЕГО + пана, ретюн пана двигает NCO его слайсов; `AddPanDDC` создаёт пан в движке + (до включения DDC), `RemovePanDDC` убирает слайсы (со звуковухами) + движок + + DDC. +- Смоук-тест (scratchpad `pantest.pas`, консольный, libwdsp): AddPan-валидации, + спектр/водопад пана 384к с тоном +52к — пик ровно на месте (пиксель 650/651), + слайс пана (USB, NCO +51к) выдал аудио-тон 1043 Гц RMS 0.68, главный тракт + параллельно жив (пик −30к на месте), RemovePan мгновенно глушит слайс, без + крэшей. Попутно зафиксирована конвенция синтетического IQ: «+Гц на дисплее» + = I=cos, Q=−sin (полезно для будущих тестов). +- НЕ проверено на эфире (нужен UI 3.3, панов никто не создаёт из GUI). + ### 3.3 — UI: второй панадаптер в главном окне - **Стек панов**: область спектра = вертикальный стек `TPanafallPanel` с