feat(slices): multi-slice receivers (FlexRadio-style) — soft fan-out + per-slice flag/audio

Independent RX slices sharing one panadapter: each has its own
freq/mode/filter/AGC/NR-NB-SNB-ANF, S-meter, volume and sound card.
VFO A = main receiver; B+ are soft fan-out WDSP channels within the
captured span (ideal for Pluto/QO-100; HW DDC path is Stage 3).

- WDSPEngine: soft fan-out (TSliceChan, ProcessSlices in DSP thread,
  per-slice NCO/mode/filter/AGC/NR/NB/SNB/ANF + S-meter, OnSliceAudio),
  FSliceLock serialises add/remove/set vs DSP; reopened on rate change.
- RadioController: TCtrlSlice with its own TAudioOutput/device,
  Add/Remove/SetSlice*, RefreshSliceShifts keeps slices on target Hz
  across centre changes. Slice letters start at 'B' (A = main receiver).
- UI: per-slice flags (VfoOverlay) with letter badge/close, filter-band
  markers in BOTH GL and CPU renderers, drag-retune, wheel; flags placed
  beside the filter band with flip, like the main flag. Ctrl+click adds.
- Main flag (slice A) visible whenever the left panel is hidden OR any
  slice exists; synced with panel-driven DSP/mode/AGC changes.
- Perf: S-meter decoupled from flag chrome cache (FMeterDirty +
  meter-only RefreshMeter via EnsureRendered); InvalidateVfoOverlay
  avoids global overlay-cache churn on meter ticks.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-01 23:46:30 +03:00
co-authored by Claude Opus 4.8
parent a04c592f1d
commit 1527b0f849
8 changed files with 1915 additions and 139 deletions
+181
View File
@@ -0,0 +1,181 @@
# Мультислайсы (несколько приёмных фильтров, как во FlexRadio)
Статус: **план; UI-каркас флага слайса уже сделан** (см. «Что уже готово»),
DSP/роутинг (Этапы 1/3) — не начаты. Ничего не коммитить без явной просьбы.
## Что уже готово
`VfoOverlay.pas` переделан в **«флаг слайса»** в стиле SmartSDR (6 коммитов
`4a92256..a04c592`, уже в `main`). Это ровно UI-управление одним слайсом из
Этапа 2, только пока в единственном экземпляре:
- Флаг-накладка поверх спектра с буквой слайса (`FSlice`, сейчас захардкожено `'A'`),
fly-out пикерами **MODE / DSP / AUD**, слайдером громкости, mute, SPLIT,
кликабельным **TX-бейджем** (выбор TX-слайса) и S-метром.
- **Per-flag выбор аудио-устройства** уже есть: `SetAudioDevices` + `OnAudioDevice`
(`VfoOverlay.pas:31,125,136`) — прокручиваемый список в AUD-fly-out. Это готовый
UI под «своё звуковое устройство на слайс» из абстракции `TSlice.DeviceIndex`.
- События наружу: `OnSelect/OnDSPChange/OnAGCChange/OnVolume/OnMute/OnAudioDevice/`
`OnSplit/OnTxSelect/OnInvalidate` (`VfoOverlay.pas:131-139`).
- Автопозиционирование: флаг флипается влево, если не влезает справа (`6ad5f29`).
Привязка в приложении **пока одиночная**: один `FVfoOverlay` в `MainForm`
(`MainForm.pas:125`), созданный/подключённый на `MainForm.pas:962-972`, привязан к
VFO A (`UpdateVfo(FController.FVfoA)`, `MainForm.pas:3265`); хендлеры
`OnVfoOverlay*` шлют изменения в главный тракт. `SpectrumView` держит один
`FVfoOverlay` и рисует его в `DrawOverlay` (`SpectrumView.pas:72,220,1010`).
Чтобы превратить это в мультислайс — нужно из одиночного флага сделать **коллекцию
флагов (по одному на слайс)** и развести их события на конкретный `SliceId` вместо
глобального VFO A. Детали — в обновлённом Этапе 2.
## Цель
Несколько независимых приёмных «слайсов» на одном ADC/одном IQ-потоке:
у каждого слайса своя частота, режим, полоса, AGC и **своё звуковое
устройство** (как DAX/наушники/динамик во Flex). Панадаптер (спектр+водопад)
остаётся один, слайсы рисуются маркерами поверх.
## Как это устроено у Flex и что из этого берём
Слайс во Flex — это **не «ещё один фильтр» на одном приёмнике**, а полноценный
независимый узкополосный приёмник (свой DDC в FPGA + демодулятор + фильтр + AGC +
аудио). Общий у слайсов — только панадаптер (широкий FFT-захват).
Один WDSP-канал = один демодулятор = один фильтр = один аудиопоток. Значит для
«разный звук/режим/полоса на каждый слайс» нужен **отдельный WDSP-канал на слайс**.
Способ получить узкополосный IQ для канала различается по железу — см. ниже.
## Ключевое различие бэкендов
```
openHPSDR (Protocol 2, V4.3, MAX_DDCS=80):
ADC0 ─┬─ DDC (FPGA-тюнер) → IQ-поток порт 1035+n → WDSP-канал → фильтр/AGC → AudioOut[dev]
├─ DDC → ...
└─ DDC → ...
• Аппаратные DDC: слайс тюнится КУДА УГОДНО в полосе ADC (весь КВ), дёшево по CPU.
• На Angelia/Orion/Saturn DDC0/DDC1 заняты PureSignal → слайсы с DDC2+ (в коде DDCBase=2).
• RX2 = ВТОРОЙ ADC (ADC1), отдельная антенна/diversity — ОРТОГОНАЛЬНО слайсам, не нужно.
Pluto (AD9363, libiio):
один RX-тракт: один LO, один rf_bandwidth, один IQ-поток (притворяется одним DDC,
пушится как TDDCIQPacket в тот же конвейер).
• Аппаратных DDC НЕТ. Слайсы = СОФТОВЫЙ fan-out одного IQ-блока на N WDSP-каналов,
каждый со своим SetRXAShiftFreq в пределах захваченной полосы вокруг LO.
• Для QO-100 идеально: весь транспондер (~500 кГц) в захвате, LO залочен на маяк.
```
## Единая абстракция
```pascal
TSlice = record
Id: Integer;
TargetHz: Double; // абсолютная частота слайса
Mode: TRxMode; // USB/LSB/CW/FM/...
FilterLo,
FilterHi: Integer;
AGCMode: TWDSPAGCMode;
Volume: Double;
WDSPChannel: Integer; // канал WDSP (0..N); 0=главный, 1=TX заняты
Source: (srcSoftShift, srcHardDDC); // как получаем узкий IQ
DDCIndex: Integer; // если srcHardDDC — какой аппаратный DDC
ShiftHz: Double; // если srcSoftShift — сдвиг = TargetHz - CapturedCenterHz
AudioOut: TAudioOutput; // свой инстанс со своим DeviceIndex
DeviceIndex: Integer; // выбранная звуковуха (или -1 = default)
Enabled: Boolean;
end;
```
Правило маршрутизации по частоте (одно на оба бэкенда):
- слайс **внутри** текущего захваченного span → `srcSoftShift` (Pluto и HPSDR);
- слайс **вне** захваченного span → `srcHardDDC` (только HPSDR; Pluto так не умеет).
## Порядок работ
### Этап 1 — Софтовый fan-out в WDSPEngine (backend-agnostic)
Даёт слайсы сразу на Pluto/QO-100 (единственный режим) и на HPSDR для слайсов
внутри видимой полосы. Pluto уже пушит IQ через тот же DDC-конвейер, поэтому
fan-out живёт в движке и не зависит от бэкенда.
- `WDSPEngine.pas`: сейчас `RXA_CHAN=0` один канал, один `FRXOut`, один аудиовыход
(`WDSPEngine.pas:96`, alloc буферов ~`:159`, OpenChannel ~`:1081`).
- Добавить массив слайс-каналов: для каждого активного слайса `OpenChannel(chN, ...)`
с той же геометрией, что RXA_CHAN, + `SetRXAShiftRun/SetRXAShiftFreq(chN, ShiftHz)`
(механизм уже используется: `WDSPEngine.pas:1099-1100`), `SetRXAMode`,
`RXASetPassband`, AGC, `SetRXAPanelGain1`.
- В DSP-потоке после накопления IQ-блока (`FRXAccI/Q`, `FRXAccPos`) прогонять
`fexchange0` по КАЖДОМУ активному каналу из ОДНОГО и того же входного буфера
(входной буфер не портить in-place между каналами — держать копию, как `FSpecBuf`).
- Выход каждого канала → свой callback `OnSliceAudio(SliceId, L, R, Count)`.
- `AudioOutput.pas`: уже готов к мульти-устройству — `DeviceIndex`, `EnumOutputDevices`,
`FindDeviceByName` (`AudioOutput.pas:137-151`). Создавать **отдельный `TAudioOutput`
на слайс**, каждый со своим `DeviceIndex`.
- `RadioController.pas`: `TSlice[]` (add/remove/retune/setmode/setdevice), роутинг
`OnSliceAudio` → нужный `TAudioOutput`.
Проверка Этапа 1: Pluto/QO-100 — 2 слайса (VFO + маяк-монитор), звук в две разные
звуковухи, независимые режимы/полосы. Сборка: `lazbuild --ws=qt6 ewsdr.lpr`.
### Этап 2 — UI слайсов на водопаде + выбор устройства
Каркас флага слайса уже сделан (см. «Что уже готово»): один `TVfoOverlay` со всеми
пикерами и per-flag выбором устройства. Остаётся размножить его на N слайсов и
привязать к позиции по частоте. Выпадашка устройства и dropdown-паттерн **уже есть в
самом флаге** (AUD fly-out) — отдельный `DeviceForm` не нужен.
- **Коллекция флагов вместо одного.** Из `MainForm.FVfoOverlay` (одиночный,
`MainForm.pas:125`) сделать `array of TVfoOverlay` / список, по флагу на слайс;
`SpectrumView.FVfoOverlay` (`SpectrumView.pas:72`) — тоже коллекция, рисовать все в
`DrawOverlay` (`SpectrumView.pas:1010`). Разместить: `SetSlice(Id, Letter)` в оверлей,
события `OnVfoOverlay*` должны нести `SliceId` (сейчас бьют по VFO A напрямую —
завести маппинг оверлей→слайс, роутить в нужный `TSlice`/WDSP-канал из Этапа 1).
- **Позиционирование по частоте.** `PositionVfoOverlay` (`MainForm.pas:518`) сейчас
ставит один флаг; расширить на маппинг `TargetHz → X` через `GetViewWindow`/
`SyncSpecViewFreq` (зум-работа). Слайс за краем видимой полосы — маркер у края
(флип влево/вправо уже есть, `6ad5f29`).
- **ЛКМ по водопаду** = поставить/двинуть активный слайс; выделение активного
(подсветка буквы/рамки флага). Хит-тест мыши флага уже разведён
(`HandleMouseDown/Move/Up`, `MainForm.pas:4424`) — добавить «клик мимо флага по
спектру = ретюн активного слайса».
- **TX-select** уже кликабелен (`OnTxSelect`, `08c71c4`) — при мультислайсе выбирать,
какой слайс идёт в TX; снимать флаг с остальных.
- Персист: список слайсов в `Settings.pas` (per-band или глобально — решить), включая
`DeviceIndex`/имя устройства каждого флага.
### Этап 3 — Аппаратные DDC для openHPSDR (слайс куда угодно по КВ)
Апгрейд поверх общего каркаса; нужен только чтобы ставить слайс ВНЕ захваченной
полосы. Плита ~на 70% есть:
- `ConfigureDDCs(NumDDCs, ...)` уже включает N DDC с per-DDC конфигом/ADC-source
(`HPSDRNetwork.pas:220`, `DDCConfig[ddc*6+0]=ADCSource`).
- Приём разведён по DDC: порты `1035+n``HandleDDCIQ(Buf,Len,DDCIdx)`
(`HPSDRNetwork.pas:392`).
- High Priority уже держит частоту на КАЖДЫЙ DDC (bytes 9-12=DDC0, 13-16=DDC1, …)
`HPSDRProtocol.pas:271`.
Чего не хватает:
1. Per-DDC частота: сейчас `SetRunAndFreq(...; DDC0FreqHz; ...)` знает только DDC0
(`HPSDRNetwork.pas:224`) — писать частотное слово каждого DDC в High Priority.
2. Роутинг `HandleDDCIQ(DDCIdx)` → соответствующий WDSP-канал слайса.
3. Учёт DDCBase=2 (PureSignal) и Ethernet-бюджета (реально 4–8 слайсов, не 80).
`feature/rx2-support` — это про ВТОРОЙ ADC, не про слайсы; но её каркас
(доп. WDSP-канал + доп. аудиовыход + роутинг по индексу) частично переиспользуем.
## Оговорки / риски
- CPU софтовых слайсов ~линейно от их числа: каждый WDSP-канал жуёт полный rate и
децимирует сам (аппаратный DDC дешевле — децимирует железо). Для QO-100 span мал,
несколько слайсов — норма.
- Не портить входной IQ-буфер между каналами (fexchange0 пишет in-place).
- Pluto: слайсы только в пределах захваченной полосы; выход за край = ретюн LO
(двигает всё, при QO-100 LO залочен на маяк — не трогать).
- Пороговая аккуратность: старт/стоп WDSP-каналов на лету (см. заметку про задержки
RXA стоп/старт в `WDSPEngine.pas:~230`).
## Связанные заметки памяти
- project_pluto_integration, project_qo100_zoom_panel, project_qo100_decoder_lock
- project_spectrum_zoom (GetViewWindow / оверлеи / маппинг мыши)
- project_channels_feature (dropdown/форма-редактор как паттерн UI)