{
  Copyright (C)
  2026 - Uladzimir Karpenka, EW8BAK

  This program is free software; you can redistribute it and/or
  modify it under the terms of the GNU General Public License
  as published by the Free Software Foundation; either version 2
  of the License, or (at your option) any later version.

  This program is distributed in the hope that it will be useful,
  but WITHOUT ANY WARRANTY; without even the implied warranty of
  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
  GNU General Public License for more details.

  You should have received a copy of the GNU General Public License
  along with this program; if not, write to the Free Software
  Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.
}

unit WDSPEngine;

{
  WDSP DSP Engine for OpenHPSDR Transceiver
  Correct WDSP API usage based on actual WDSP.pas bindings:

  Spectrum pipeline:
    XCreateAnalyzer(disp, ...) — создать analyzer display
    SetDisplay*(disp, ...) — настроить detector/average/rate
    SetRXASpectrum(ch, 1, disp, 0, 0) — подключить RXA к display
    fexchange0() в цикле → WDSP внутри пишет данные в display буфер
    Spectrum0(1, disp, 0, 0, nil) — тригер snapshot
    GetPixels(disp, 0, pix, flag) — забрать пиксели

  OpenChannel сигнатура (13 параметров):
    channel, in_size, dsp_size, in_rate, dsp_rate, out_rate,
    atype, state, tdelayup, tslewup, tdelaydown, tslewdown, bfo
}

{$IFDEF FPC}
  {$MODE Delphi}
{$ENDIF}

interface

uses
  Classes, SysUtils, Math, SyncObjs,
  BeaconDecoder, RadioModes,
  WDSP;

const
  // WDSP mode integers (нет именованных констант в WDSP.pas).
  // Значения — enum rxaMode (RXA.h) / txaMode (TXA.h). До DRM включительно
  // нумерация совпадает, дальше расходится: TXA вставляет AM_LSB/AM_USB,
  // поэтому WFM у RXA = 12, а у TXA = 14. Отсюда две отдельные константы.
  WDSP_LSB  = 0;
  WDSP_USB  = 1;
  WDSP_DSB  = 2;
  WDSP_CWL  = 3;
  WDSP_CWU  = 4;
  WDSP_FM   = 5;
  WDSP_AM   = 6;
  WDSP_DIGU = 7;
  WDSP_DIGL = 9;
  WDSP_SAM  = 10;
  WDSP_RX_WFM = 12;
  WDSP_TX_WFM = 14;

  // Наши индексы режимов (совпадают с кнопками MainForm).
  // Новые режимы дописываются В КОНЕЦ: индекс уходит в settings.json,
  // band-cache, CAT (ZZMD/ZZMV) и web-API.
  MODE_LSB   = RadioModes.MODE_LSB;
  MODE_USB   = RadioModes.MODE_USB;
  MODE_DSB   = RadioModes.MODE_DSB;
  MODE_CWL   = RadioModes.MODE_CWL;
  MODE_CWU   = RadioModes.MODE_CWU;
  MODE_FM    = RadioModes.MODE_FM;
  MODE_AM    = RadioModes.MODE_AM;
  MODE_SAM   = RadioModes.MODE_SAM;
  MODE_DIGU  = RadioModes.MODE_DIGU;
  MODE_DIGL  = RadioModes.MODE_DIGL;
  MODE_WFM   = RadioModes.MODE_WFM;
  MODE_DMR   = RadioModes.MODE_DMR;
  MODE_FMRAW = RadioModes.MODE_FMRAW;
  MODE_MIN   = RadioModes.MODE_MIN;
  MODE_MAX   = RadioModes.MODE_MAX;
  MODE_COUNT = RadioModes.MODE_COUNT;

  // Минимальная длина FIR у WDSP: create_* поднимают fircore с
  // nc = max(2048, dsp_size). В firmin.c nfor = nc / size, idxmask = nfor - 1,
  // так что при nc < dsp_size получается nfor = 0 — пустой массив FFT-планов и
  // падение в fftw_execute. Любое изменение dsp_size обязано перевыставить nc.
  WDSP_MIN_NC = 2048;

  // WFM: девиация и параметры де-эмфазиса вещательного FM.
  WFM_DEVIATION   = 75000.0;
  WFM_AF_LOW      = 20.0;
  WFM_AF_HIGH     = 15000.0;
  WFM_TAU_DE_US   = 50.0e-6;   // Европа/Россия (Америка — 75 мкс)
  DMR_DEVIATION   = 2500.0;
  // FM RAW: плоский baseband для внешних декодеров — одна величина на RX-AF,
  // TX-AF и bp0 (раньше 20/7000 стояли числами в трёх местах).
  RAW_AF_LOW      = 20.0;
  RAW_AF_HIGH     = 7000.0;
  // Потолок dsp_rate для WFM. Вещательному каналу (200 кГц) хватает 192 кГц,
  // а гнать цепь на полном IQ-rate нельзя: при 576 кГц dsp_size стал бы 12288,
  // и fircore спланировал бы FFT на 2*12288 = 24576 точек. Это не степень
  // двойки — такого плана нет в wdspWisdom00, и FFTW_PATIENT меряет его ~25 с
  // при первом входе в режим. rsmpin децимирует in_rate → dsp_rate сам.
  WFM_DSP_RATE    = 192000;

  // AGC modes
  AGC_OFF    = 0;
  AGC_LONG   = 1;
  AGC_SLOW   = 2;
  AGC_MEDIUM = 3;
  AGC_FAST   = 4;

  // RX filter modes
  NR_MODE_OFF = 0;
  NR_MODE_1   = 1;
  NR_MODE_2   = 2;
  NR_MODE_3   = 3;
  NR_MODE_4   = 4;

  NB_MODE_OFF = 0;
  NB_MODE_1   = 1;
  NB_MODE_2   = 2;

  FILTER_NR_AGC_POSITION         = 1;
  FILTER_NB_TAU                  = 0.00001;
  FILTER_NB_ADVTIME              = 0.00001;
  FILTER_NB_HANGTIME             = 0.00001;
  FILTER_NB_THRESHOLD            = 4.95;
  FILTER_NB2_MODE                = 0;
  FILTER_NR2_GAIN_METHOD         = 2;
  FILTER_NR2_NPE_METHOD          = 0;
  FILTER_NR2_POST_RUN            = 0;
  FILTER_NR2_POST_TAPER          = 12;
  FILTER_NR2_POST_NLEVEL         = 15.0;
  FILTER_NR2_POST_FACTOR         = 15.0;
  FILTER_NR2_POST_RATE           = 5.0;
  FILTER_NR2_TRAINED_THRESHOLD   = -0.5;
  FILTER_NR2_TRAINED_T2          = 0.2;
  FILTER_NR4_REDUCTION_AMOUNT    = 10.0;
  FILTER_NR4_SMOOTHING_FACTOR    = 20.0;
  FILTER_NR4_WHITENING_FACTOR    = 0.0;
  FILTER_NR4_NOISE_RESCALE       = 2.0;
  FILTER_NR4_POST_THRESHOLD      = -3.0;
  FILTER_NR4_NOISE_SCALING_TYPE  = 0;

  // S-meter types для GetRXAMeter
  RXA_S_PK = 0;   // peak, dBm
  RXA_S_AV = 1;   // average, dBm

  RXA_CHAN = 0;
  TXA_CHAN = 1;   // используем разные каналы для RX и TX

  // --- Мультислайсы (софт-fan-out) ---
  MAX_SLICES      = 6;   // потолок одновременных софтовых слайсов
  SLICE_CHAN_BASE = 2;   // WDSP-каналы слайсов: 0=RXA, 1=TXA, слайсы с 2+

  RX_DISP_ID      = 0;    // analyzer для RX-спектра/водопада
  TX_DISP_ID      = 1;    // analyzer для TX-спектра/водопада
  // Отдельный analyzer лупы QO-100-маяка (окно BeaconScopeForm). Кормится тем
  // же IQ главного тракта, но со своим FFT и своим span-clip — узкое окно
  // вокруг маяка в высоком разрешении. Живёт только пока окно маяка открыто.
  BCN_DISP_ID     = 4;

  // --- Панадаптеры на аппаратных 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 занят лупой маяка
  // (BCN_DISP_ID), 5 держим свободным про запас.
  PAN_DISP_BASE   = 8;

  DSP_BUFSIZE     = 1024;
  SPECTRUM_PIXELS = 4096;  // потолок точек дисплея; синхронно с WaterfallView.WF_MAX_PIXELS
  // Точек в кадре лупы маяка. Фиксировано (а не по ширине виджета), чтобы
  // изменение размера окна маяка не переармировало analyzer: переарм сбрасывает
  // FFT-историю, а на крупных FFT она наполняется почти секунду. Рендер сам
  // ужимает кадр под свою ширину.
  BCN_ZOOM_PIXELS = 1024;
  // Частота кадров лупы. Своя, ниже главного дисплея — см.
  // ApplyBeaconAnalyzerSettings.
  BCN_ZOOM_FPS    = 15.0;
  DISPLAY_BLOCK_SIZE = 64;

  // Очередь 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 — должен быть степенью двойки
  TX_MIC_RING     = 8192;

  // TX/DUC sample rate. Фиксирован независимо от FSampleRate (RX).
  // TX rate постоянен: 192k для Hermes/Saturn DUC0,
  // меняя только RX rate. Это гарантирует, что DUC IQ всегда подаётся в том
  // темпе, который ожидает железо (DUC0RateHi/Lo в DUC Specific = 192).
  TX_SAMPLE_RATE  = 192000;

type
  // Пакет в очереди между сетевым и DSP потоком
  TIQQueueItem = record
    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,
                  agcMedium = 3, agcFast = 4);

  TOnAudioReady    = procedure(const Left, Right: array of Single;
                                Count: Integer) of object;
  TOnSpectrumReady = procedure(const Pixels: array of Single;
                                Count: Integer) of object;
  // TX IQ callback — вызывается из TTXDSPThread при готовности блока
  // Buf: interleaved [I0,Q0,I1,Q1,...] doubles, Count — кол-во IQ пар
  TOnTXIQReady = procedure(const Buf: array of Double; Count: Integer) of object;
  TOnWaterfallReady = procedure(const Pixels: array of Single;
                                Count: Integer) of object;
  // Тап сырого RX-IQ (потоки IQ по TCI, §3.4). Зовётся из DSP-потока ОДИН РАЗ
  // на накопленный блок — не на сэмпл: сэмплов тут до 384 тысяч в секунду на
  // каждый пан, и вызов метода на каждый стоил бы дороже самой работы.
  // PanId: 0 = главный тракт, 1.. = доп. пан. I/Q — два массива по N отсчётов,
  // живущих только на время вызова.
  TIQTapEvent = procedure(PanId: Integer; PI_, PQ_: PDouble;
                          N, RateHz: Integer) of object;

  // Аудио готового слайса — вызывается из DSP-потока по каждому активному слайсу.
  // SliceId — логический id (из RadioController), не WDSP-канал.
  TOnSliceAudio = procedure(SliceId: Integer; const Left, Right: array of Single;
                            Count: Integer) of object;
  // Калибровочный офсет спектра/водопада (дБ) для дисплея: PanId 0 = главный,
  // 1..MAX_PANS-1 = доп. панадаптер. Спрашивается раз в кадр из display-потока:
  // величина зависит от диапазона/трансвертера, о которых знает только
  // TRadioController (аналог SMeterCalFor для стрелки).
  TOnDispCalQuery = function(PanId: Integer): Double 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 сэмплов.
  TPullMicSamplesFunc = function(Max: Integer): Integer of object;

  // TX mic source: откуда получаем сэмплы для модулятора.
  TTXMicSource = (txmsRadio, txmsSoundCard, txmsWeb);

  // Софтовый слайс: отдельный WDSP-канал, кормится тем же IQ-блоком, что и
  // RXA_CHAN, но со своим NCO-сдвигом/режимом/фильтром/AGC/громкостью и своим
  // аудио-callback'ом. Геометрия канала = как у RXA_CHAN.
  TSliceChan = record
    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;
    FilterHi: Integer;
    AGCMode:  TWDSPAGCMode;
    Volume:   Double;               // 0..1
    Muted:    Boolean;
    NRMode:   Integer;              // 0=off,1..4
    NBMode:   Integer;              // 0=off,1=NB,2=NB2
    SNBOn:    Boolean;
    ANFOn:    Boolean;
    FMSQOn:   Boolean;              // FM-шумоподавитель (squelch) включён
    FMSQLevel: Integer;            // порог squelch 0..100
    SMeter:   Double;               // последнее показание S-метра канала (dBm), пишет DSP-поток
    Opened:   Boolean;              // WDSP-канал реально открыт (после Open движка)
    TapWanted: Boolean;             // этот слайс слушает внешний потребитель (RX_AUDIO по TCI)
  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) жив
    ZoomFactor: Double;             // зум пана 0..1 (0 = весь span), как FZoomFactor
    PanSlider:  Double;             // положение окна 0..1
  end;

  { TWDSPEngine }
  TWDSPEngine = class
  private
    FInitialized:   Boolean;
    FAnalyzerOpen:  Boolean;
    FSampleRate:    Integer;
    FAudioRate:     Integer;
    // Текущий dsp_rate канала RXA. Обычно = FAudioRate; в WFM поднимается
    // до FSampleRate, иначе девиация 75 кГц не помещается в полосу.
    FRXDSPRate:     Integer;
    FTXDSPSize:     Integer;   // текущий dsp_size канала TXA (нижняя граница nc)
    FBufSize:       Integer;   // входной буфер @ FSampleRate
    FAudioBufSize:  Integer;   // выходной буфер @ FAudioRate (= FBufSize * FAudioRate / FSampleRate)
    FTXSampleRate:  Integer;   // фиксированный rate TX/DUC (TX_SAMPLE_RATE), не меняется при ChangeSampleRate
    FTXOutBufSize:  Integer;   // выходной буфер TXA в IQ парах @ FTXSampleRate (= FAudioBufSize * FTXSampleRate / FAudioRate)

    // Interleaved double I/Q буферы для fexchange0
    FRXIn:    array of Double;
    FRXOut:   array of Double;
    FTXIn:    array of Double;
    FTXOut:   array of Double;

    // Накопитель входного буфера (DDC пакеты могут быть мельче FBufSize)
    FRXAccI:  array of Double;
    FRXAccQ:  array of Double;
    FRXAccPos: Integer;

    // Копия входных IQ для Spectrum0 (до fexchange0, который данные in-place перезаписывает)
    FSpecBuf:  array of Double;
    // Отдельный небольшой feed для display-анализатора.
    FDispBuf:  array of Double;
    FDispPos:  Integer;
    // То же самое для TX-analyzer: TX_DISP_ID получает сэмплы чанками
    // по DISPLAY_BLOCK_SIZE из FTXOut в ProcessTXBlock.
    FTXDispBuf: array of Double;
    FTXDispPos: Integer;
    // Аудио выходные буферы — аллоцируем один раз
    FOutL:   array of Single;
    FOutR:   array of Single;
    // DSP поток + очередь пакетов
    FDSPThread:    TThread;
    FDisplayThread:TThread;
    FQueue:        array[0..IQ_QUEUE_SIZE - 1] of TIQQueueItem;
    FQueueHead:    Integer;   // пишет сетевой поток
    FQueueTail:    Integer;   // читает DSP поток
    FQueueSem:     PRTLEvent;  // сигнал: есть новые данные (RTLEvent)
    FDSPRunning:   Boolean;
    // Гейт главного тракта на время хирургической смены rate (ChangeSampleRate):
    // True = DSP-поток дропает main-пакеты (PanId=0) не блокируясь, паны
    // обрабатываются как обычно. FMainLock — барьер: DSP-поток держит его на
    // время обработки main-пакета, ChangeSampleRate делает Enter/Leave после
    // установки гейта, гарантируя что fexchange0 на RXA_CHAN завершён.
    FMainGated:    Boolean;
    FMainLock:     TCriticalSection;
    // ★Канал TXA принадлежит TX-потоку, но T/R трогает его же: SetTXRun сливает
    // канал своими fexchange0 через ОБЩИЕ буферы FTXIn/FTXOut. Гейт FTXActive
    // сам по себе не разделяет их — поток мог пройти проверку и войти в блок
    // ровно тогда, когда T/R уже сливает. Идиома та же, что на RX (FMainGated +
    // FMainLock): гейт + повторная проверка ПОД локом, а слив идёт под ним же.
    FTXLock:       TCriticalSection;

    FSpectrumPixels:   array[0..SPECTRUM_PIXELS - 1] of Single;
    FWaterfallPixels:  array[0..SPECTRUM_PIXELS - 1] of Single;
    FFlp:              array[0..0] of Integer;  // for SetAnalyzer flp parameter

    // Настройки анализатора
    FFFTSize:      Integer;
    FWindowType:   Integer;
    FDisplayFPS:   Integer;
    FEffectiveDisplayFPS: Double;
    FDisplayPixelCount: Integer;
    FSpecDetector: Integer;
    FSpecAvgMode:  Integer;
    FSpecAvgTimeMS: Double;
    FWfDetector:   Integer;
    FWfAvgMode:    Integer;
    FWfAvgTimeMS:  Double;

    // Zoom/pan анализатора (span-clip в SetAnalyzer). Конвенция WDSP:
    // FZoomFactor 0.0..1.0, где 0.0 = весь span (без зума), 1.0 = максимум.
    // FPanSlider 0..1 (положение окна по span). FViewLowHz/FViewHighHz —
    // границы видимого окна в Гц относительно DDC-центра (low отрицательный),
    // пересчитываются в ApplyRXAnalyzerSettings.
    FZoomFactor:   Double;
    FPanSlider:    Double;
    FViewLowHz:    Double;
    FViewHighHz:   Double;

    // --- Лупа маяка (BCN_DISP_ID) ---------------------------------------
    // Второй analyzer на том же IQ главного тракта: своя FFT (крупнее) и свой
    // span-clip, вырезающий узкое окно вокруг маяка. Создаётся/убивается по
    // запросу UI (окно маяка открыто/закрыто), поэтому весь доступ к disp'у
    // сериализуется FBcnLock: SetAnalyzer/Destroy зовёт UI-поток, Spectrum0 —
    // DSP-поток, GetPixels — display-поток.
    FBcnLock:      TCriticalSection;
    FBcnOpen:      Boolean;         // XCreateAnalyzer(BCN_DISP_ID) жив
    FBcnDispBuf:   array of Double; // feed анализатора (DISPLAY_BLOCK_SIZE пар)
    FBcnDispPos:   Integer;
    FBcnReqCenter: Double;          // запрошенный центр окна, Гц отн. DDC-центра
    FBcnReqSpan:   Double;          // запрошенная ширина окна, Гц
    FBcnLowHz:     Double;          // фактическое окно (снаппится по бинам FFT)
    FBcnHighHz:    Double;
    FBcnFFT:       Integer;         // размер FFT лупы (свой, не FFFTSize)
    FBcnPixCount:  Integer;         // точек в кадре лупы
    FBcnSpecPix:   array[0..SPECTRUM_PIXELS - 1] of Single;
    FBcnWfPix:     array[0..SPECTRUM_PIXELS - 1] of Single;
    FBcnSpecFresh: Boolean;         // в кэше есть непрочитанный кадр спектра
    FBcnWfFresh:   Boolean;

    FOnAudio:     TOnAudioReady;
    FOnDemodAudio:TOnAudioReady;  // pre-volume/mute tap for digital decoders
    FOnSpectrum:  TOnSpectrumReady;
    FOnWaterfall: TOnWaterfallReady;
    FOnDispCal:   TOnDispCalQuery;
    FOnTXIQ:      TOnTXIQReady;

    // --- Мультислайсы ---
    FSlices:      array[0..MAX_SLICES-1] of TSliceChan;
    FSliceLock:   TCriticalSection;   // сериализует add/remove/set vs DSP-обработку
    FSliceIn:     array of Double;     // interleaved вход fexchange0 (переиспользуем)
    FSliceOut:    array of Double;     // interleaved выход fexchange0
    FSliceOutL:   array of Single;     // деинтерливленный L для callback
    FSliceOutR:   array of Single;     // R
    FOnSliceAudio: TOnSliceAudio;
    FOnSliceDemodAudio: TOnSliceAudio; // pre-volume/mute tap for digital slices

    // --- Панадаптеры на аппаратных 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;
    FTXMicHead:  Integer;   // пишет receive thread
    FTXMicTail:  Integer;   // читает TX DSP thread
    FTXMicSem:   PRTLEvent; // сигнал: есть новые mic сэмплы
    FTXThread:   TThread;
    FTXActive:   Boolean;
    // DUP-флаг от UI. True — RX-анализатор и RXA-канал продолжают работать
    // во время TX (full-duplex). False — RX-IQ пакеты дропаются на входе DSP
    // потока и не кормят RXA/анализатор. Это не трогает SetChannelState
    // (мы выяснили на практике что стоп/старт RXA добавляет задержки на
    // Windows без видимого выигрыша).
    FKeepRXDuringTX: Boolean;
    // Полный дуплекс QO-100: пропускать RX-аудио на колонки и во время TX (слышим
    // свой downlink через транспондер). Имеет смысл только при FKeepRXDuringTX.
    // По умолчанию False — обычное поведение (RX-аудио глушится на TX).
    FMonitorRXDuringTX: Boolean;
    // Дедлайн (GetTickCount64), до которого RX-аудио не пишется в FOnAudio.
    // Используется на Windows для маскировки «хвоста» TX leakage (radio
    // FPGA TX-buffer + PA slew-down → RX1 IQ → audio) после MOX-off.
    // На Linux выставляется в 0 (UI не зовёт BeginPostTXMute) — поведение
    // не меняется.
    FPostTXMuteUntil: QWord;
    FActiveDisplayID: Integer;   // RX_DISP_ID или TX_DISP_ID
    FTXMicSource: TTXMicSource;
    FOnPullMic:  TPullMicSamplesFunc;
    // TUN активен (PostGen-тон): mic-вход TXA зануляется в ProcessTXBlock.
    // PostGen стоит ПОСЛЕ ALC — голос из микрофона складывался бы с тоном
    // поверх полной шкалы (тон ~0.98) → клип 24-бит → сплэттер на спектре.
    FTXToneOn:   Boolean;
    // 2TON активен (PostGen two-tone 700/1900 Гц для IMD/PureSignal-теста):
    // mic-вход глушится так же, как при TUN.
    FTXTTOn:     Boolean;

    // ---- PureSignal ----
    // FPSArmed: PS включён контроллером — PushPSFeedback подаёт feedback в
    // pscc (WDSP calcc). Пишется из UI-потока, читается в сетевом.
    FPSArmed:    Boolean;
    // Деинтерливленные буферы feedback-пакета: interleaved I/Q пары каждого
    // потока (≤119 пар на поток при 238 парах в пакете; 256 пар с запасом).
    // Используются только сетевым потоком.
    FPSTxBuf:    array[0..511] of Double;   // DDC1 = TX DAC (чистый эталон)
    FPSRxBuf:    array[0..511] of Double;   // DDC0 = RX feedback (после PA)
    // Кэш PS-параметров: применяются в ApplyTXChainSettings (т.е. и после
    // каждого пересоздания TXA-канала в Open/ChangeSampleRate).
    FPSMoxDelay:  Double;
    FPSLoopDelay: Double;
    FPSTXDelayNs: Integer;
    FPSRelaxPtol: Boolean;
    FPSPin:       Boolean;
    FPSMap:       Boolean;
    FPSStabilize: Boolean;
    FPSInts:      Integer;
    FPSSpi:       Integer;
    FPSHWPeak:    Double;
    FPSOutlierSigma: Double;   // eu2av MAD-фильтр перед фитом; 0 = выкл.

    // Кэшированные TX-параметры — применяются после OpenChannel(TXA) и при изменении.
    // Для Filter/Compressor/Leveler/ALC/PhaseRot/EQ/AM/FM/CTCSS.
    FTXFilterLow:     Integer;
    FTXFilterHigh:    Integer;
    FTXFilterNC:      Integer;
    FTXFilterMP:      Integer;
    FTXFilterWindow:  Integer;
    FTXMicGainDB:     Double;
    FTXCompressorOn:  Boolean;
    FTXCompressorGain:Double;
    FTXLevelerOn:     Boolean;
    FTXLevelerTop:    Double;
    FTXLevelerDecay:  Integer;
    FTXALCOn:         Boolean;
    FTXALCMaxGain:    Double;
    FTXALCDecay:      Integer;
    FTXPhaseRotOn:    Boolean;
    FTXPhaseRotStages:Integer;
    FTXPhaseRotFreq:  Double;
    FTXEQOn:          Boolean;
    FTXEQNumBands:    Integer;
    FTXEQGains:       array[0..10] of Double;
    FTXEQFreqs:       array[0..10] of Double;
    FTXAMCarrier:     Double;
    FTXFMDeviation:   Double;
    FTXFMLowCut:      Integer;
    FTXFMHighCut:     Integer;
    FTXFMEmphPos:     Integer;
    FTXCTCSSOn:       Boolean;
    FTXCTCSSFreq:     Double;

    // TX-display параметры (отдельные от RX) — применяются к TX_DISP_ID.
    FTXFFTSize:       Integer;
    FTXWindowType:    Integer;
    FTXSpecDetector:  Integer;
    FTXSpecAvgMode:   Integer;
    FTXSpecAvgTimeMS: Double;
    FTXWfDetector:    Integer;
    FTXWfAvgMode:     Integer;
    FTXWfAvgTimeMS:   Double;

    FMode:       Integer;
    FTXMode:     Integer;       // may differ from RX when a slice is TX source
    FRawFMDeviation: Double;
    FSMeter:     Double;
    FFilterLow:  Integer;
    FFilterHigh: Integer;
    FAGCMode:          TWDSPAGCMode;
    FAGCTop:           Double;   // AGC gain in dB
    FAGCSlope:         Integer;  // наклон АРУ в дБ (default 0)
    FAGCHangThreshold: Integer;  // порог hang 0..100
    FAGCHangLevel:     Double;   // читается из WDSP (GetRXAAGCHangLevel)
    FAGCThresh:        Double;   // читается из WDSP (GetRXAAGCThresh)
    FLastSpectrumW:    Integer;  // последняя известная ширина спектра (пикс)
    FDisplayResLimit:  Integer;  // потолок точек анализатора (настройка display_pixels)
    FShiftHz:          Double;   // NCO shift для CTUN
    FCWPitch:          Integer;  // Гц, тон телеграфа (см. CWLOOffset)
    FCWSpeed:          Integer;  // WPM — только для занимаемой полосы на экране
    FNRMode:     Integer;
    FNBMode:     Integer;
    FSNBEnabled: Boolean;
    FANFEnabled: Boolean;
    FMuted:      Boolean;
    FVolume:     Double;
    FLastError:  string;
    FBeaconDec:  TBeaconDecoder;   // не владеет; тап маяка в PushIQItemToDSP
    FIQTap:      TIQTapEvent;      // тап сырого IQ наружу (TCI), не владеет
    // ★Лок тапа. Снять тап обязан тот, кто вот-вот освободит его владельца
    // (адаптер TCI), а зовёт тап DSP-поток. Без лока SetIQTap(nil) возвращался
    // раньше, чем DSP-поток выходил из вызова, — и следующая же строка
    // деструктора адаптера убивала объект прямо под ним.
    FIQTapLock:  TCriticalSection;
    FIQTapOn:    Boolean;          // дешёвая проверка «тап вообще есть»
    // Кто-то (TCI/web) слушает аудио помимо динамика: тогда молчащий (mute)
    // слайс всё равно надо считать — RX_AUDIO снимается ДО громкости и мьюта.

    function  ModeToWDSP(Mode: Integer): Integer;
    function  ModeToWDSPTX(Mode: Integer): Integer;
    procedure ApplyDefaultFilter;
    // 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);
    procedure ApplyRXWFMToChan(Chan: Integer);
    procedure ApplyRXDMRToChan(Chan: Integer);
    procedure ApplyRXRawToChan(Chan: Integer);
    procedure ApplyTXWFMSettings;
    // Единая RX-режимная надстройка канала (WFM/DMR/FM RAW). Одна точка для
    // главного RXA и для слайсов — иначе дублирование по трём местам.
    procedure ApplyRXModeSettings(Chan, Mode: Integer);
    // Магнитуда TX-полосы (FTXFilter*) → знаковые края bp0 по боковой режима.
    // Единственный конвертер знака краёв TX-фильтра для выбранного режима,
    // через который обязан идти ЛЮБОЙ пуш TX-bandpass, иначе LSB уходит в USB.
    procedure TXSignedEdges(Mode: Integer; out Lo, Hi: Double);
    // --- CW pitch -----------------------------------------------------------
    // Смещение гетеродина демодулятора относительно VFO в телеграфе: станция,
    // стоящая ТОЧНО на частоте VFO, обязана звучать тоном CWPitch, а не нулём.
    // CWL слушаем ниже (гетеродин выше на pitch), CWU — наоборот. Всё остальное
    // в проекте (кромки фильтра, полоска на спектре, маркер VFO) остаётся в
    // координатах «относительно VFO»: конвертер ровно один — эти три метода.
    procedure PushShift(Chan: Integer; ShiftHz: Double; Mode: Integer);
    procedure PushPassband(Chan, Lo, Hi, Mode, Rate: Integer);
    // Mode-dependent TX overlay.  FM RAW uses the flat WFM modulator and
    // bypasses every speech processor without overwriting the saved TX setup.
    procedure ApplyTXModeSettings(Mode: Integer);
    // ЕДИНАЯ точка перевода TX-тракта в режим (модулятор + режимная цепь).
    // Через неё идут ВСЕ переходы: Open, SetMode, SetTXMode (в т.ч. слайс-TX).
    procedure ApplyTXMode(Mode: Integer);
    // --- Мультислайсы ---
    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 OpenMainRXChannel;   // RXA_CHAN под текущие FBufSize/FSampleRate
    procedure CloseMainRXChannel;  // state=0 + EXT-блоки + CloseChannel(RXA_CHAN)
    procedure OpenAnalyzer;
    procedure CloseAnalyzer;
    procedure ApplyAnalyzerSettings;
    function  CalcDisplayFFTSize: Integer;
    procedure CalcAnalyzerTiming(AnalyzerFFT: Integer; out Overlap: Integer;
      out EffectiveFPS: Double);
    function  NormalizeFFTSize(FFTSize: Integer): Integer;
    procedure NormalizeDisplayParams(var Detector, AvgMode: Integer;
      var AvgTimeMS: Double; ForWaterfall: Boolean);
    procedure AverageTimeToParams(AvgMode: Integer; AvgTimeMS: Double;
      out AvgCount: Integer; out Backmult: Double);
    procedure ApplyNRState;
    procedure ApplyNBState;
    procedure ApplySNBState;
    procedure ApplyANFState;
    procedure ApplyNRToChan(Chan, NRMode: Integer);
    procedure ApplyNBToChan(Chan, NBMode: Integer);
    procedure ApplySNBToChan(Chan: Integer; On_: Boolean);
    procedure ApplyANFToChan(Chan: Integer; On_: Boolean);
    procedure ApplyFMSQToChan(Chan: Integer; Enable: Boolean; Level: Integer);
    procedure ApplySliceFMSQ(Idx: Integer);   // squelch слайса с гейтом по режиму
    procedure ApplyNoiseFilterState;
    procedure ProcessTXBlock;   // вызывается из TTXDSPThread
    function  GetTXMicFill: Integer;
    procedure FeedTXDisplaySample(const I, Q: Double);
    procedure ApplyTXChainSettings;   // прокидывает все FTX* поля в WDSP
    procedure PushTXEQProfile;        // кривая EQ в WDSP (узлы + legacy 3 полосы)
    procedure ApplyPSParams;          // прокидывает FPS*-параметры в WDSP calcc
    procedure ApplyRXAnalyzerSettings; // применяет RX FFT/Window/Det/Avg к RX_DISP_ID
    procedure ApplyTXAnalyzerSettings; // применяет TX FFT/Window/Det/Avg к TX_DISP_ID
    // --- Лупа маяка ---
    procedure ApplyBeaconAnalyzerSettings;  // span-clip окна маяка; под FBcnLock
    function  BeaconFFTFor(SpanHz: Double): Integer;  // FFT под запрошенную ширину
    procedure CloseBeaconAnalyzer;          // Destroy + сброс (сам берёт FBcnLock)
    procedure RearmBeaconAnalyzer;          // полный переарм (смена rate)
    procedure ReapplyBeaconDisplayModes;    // детектор/усреднение без переарма

  public
    constructor Create(SampleRate: Integer = 192000;
                       AudioRate:  Integer = 48000;
                       BufSize:    Integer = DSP_BUFSIZE);
    destructor Destroy; override;

    // Открыть DSP каналы
    function  Open: Boolean;
    procedure Close;
    procedure ChangeSampleRate(NewRate: Integer);

    // QO-100 beacon-декодер: тап RX-IQ для демодуляции маяка (не владеет).
    procedure SetBeaconDecoder(D: TBeaconDecoder);

    // Тап сырого RX-IQ наружу (потоки IQ по TCI). nil — снять. Ставит и
    // снимает поток контроллера; вызывается тап из DSP-потока.
    procedure SetIQTap(T: TIQTapEvent);
    // Слушает ли слайс кто-то снаружи (поток RX_AUDIO по TCI). Влияет только
    // на молчащие слайсы: их всё равно надо считать, иначе клиент остаётся без
    // RX_AUDIO ровно потому, что оператор убрал звук у себя в комнате.
    // ★Флаг именно ПОСЛАЙСОВЫЙ. Общий «тапы включены» означал бы, что поднятый
    // TCI-сервер заставляет считать ВСЕ молчащие слайсы — и без клиентов, и
    // когда клиент качает один слайс из шести, и когда ему нужен только IQ.
    // Ставит поток контроллера, читает DSP-поток (оба под FSliceLock).
    procedure SetSliceTapWanted(Id: Integer; V: Boolean);

    // --- Панадаптеры на аппаратных 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);
    // Смена rate живого пана: пересбор аккумулятора/анализатора + переоткрытие
    // WDSP-каналов его слайсов на новом входном rate (state слайсов сохраняется).
    function  SetPanRate(PanId, RateHz: Integer): Boolean;
    function  PanActive(PanId: Integer): Boolean;

    // --- Мультислайсы (софт-fan-out) ---
    // Добавляет слайс с логическим Id (уникальным). ShiftHz = TargetHz - центр
    // захвата ЕГО пана. APanId>0 требует активного пана (AddPan).
    // Возвращает False если нет свободных слотов или Id уже есть.
    function  AddSlice(Id, Mode, FilterLo, FilterHi: Integer;
                       ShiftHz, Volume: Double; AGC: TWDSPAGCMode;
                       APanId: Integer = 0): Boolean;
    procedure RemoveSlice(Id: Integer);
    procedure SetSliceShift(Id: Integer; ShiftHz: Double);
    procedure SetSliceMode(Id, Mode: Integer);
    procedure SetSliceFilter(Id, Low, High: Integer);
    procedure SetSliceAGC(Id: Integer; Mode: TWDSPAGCMode);
    procedure SetSliceVolume(Id: Integer; Vol: Double);
    procedure SetSliceMute(Id: Integer; Mute: Boolean);
    procedure SetSliceNRMode(Id, Mode: Integer);
    procedure SetSliceNBMode(Id, Mode: Integer);
    procedure SetSliceSNB(Id: Integer; On_: Boolean);
    procedure SetSliceANF(Id: Integer; On_: Boolean);
    procedure SetSliceFMSquelch(Id: Integer; Enable: Boolean; Level: Integer);
    function  GetSliceSMeter(Id: Integer): Double;
    function  SliceCount: Integer;
    function  HasSlice(Id: Integer): Boolean;

    // Подача 24-bit big-endian IQ из DDC пакета
    // Buf — массив байт, DataOffset — смещение до IQ данных внутри Buf
    procedure PushDDCPacket(const Buf: array of Byte;
                             DataOffset: Integer;
                             IQPairs:    Integer);
    // IQ доп. панадаптера (аппаратный DDC пана PanId, этап 3). Пока (3.1)
    // заглушка-дроп; 3.2 добавит per-pan аккумулятор + Spectrum0(PAN_DISP_BASE+
    // PanId) + ProcessSlices по слайсам этого пана.
    procedure PushPanIQ(PanId: Integer; const Buf: array of Byte;
                        DataOffset: Integer; IQPairs: Integer);
    procedure FlushRX;
    // Замьютить RX-аудио на DurationMs от текущего момента. UI зовёт это
    // на TX→RX в non-DUP только под Windows — там OS-уровневый PortAudio
    // буфер удерживает «хвост» с TX leakage из радио, который AudioOut.Clear
    // не достаёт. На Linux буфер маленький и проблемы нет, поэтому метод
    // не вызывается.
    procedure BeginPostTXMute(DurationMs: Integer);

    // RX управление
    procedure SetMode(Mode: Integer);
    procedure SetFilter(Low, High: Integer);
    procedure SetAGC(Mode: TWDSPAGCMode; FixedGain: Double = 0.0);
    procedure SetAGCTop(TopDBm: Double);
    procedure SetAGCSlope(Slope: Integer);
    procedure SetAGCHangThreshold(Threshold: Integer);
    procedure UpdateAGCLines(SpectrumW: Integer);  // читает hang/thresh из WDSP для линии на спектре
    procedure SetSpectrumWidth(W: Integer);         // обновляет FLastSpectrumW для корректных AGC линий
    procedure SetDisplayResLimit(V: Integer);       // потолок точек анализатора (1024/2048/4096)
    procedure SetShift(ShiftHz: Double);  // CTUN NCO сдвиг
    // Тон телеграфа. Меняет положение гетеродина и полосы во ВСЕХ CW-каналах —
    // применяется немедленно (главный + слайсы), персист делает контроллер.
    procedure SetCWPitch(Hz: Integer);
    function  CWPitch: Integer;
    // Скорость кейера нужна движку ровно для одного — ширины полосы телеграфа
    // на экране (TXFilterEdgesHz). Тайминги делает прошивка, не мы.
    procedure SetCWSpeed(WPM: Integer);
    // Смещение гетеродина демодулятора относительно VFO в телеграфе (см.
    // комментарий у PushShift). Публично: тем же сдвигом контроллер двигает
    // DUC при настройке TUN, чтобы несущая легла ровно на VFO.
    function  CWLOOffset(Mode: Integer): Double;
    procedure SetNRMode(Mode: Integer);
    procedure SetNR(Enable: Boolean);
    procedure SetNBMode(Mode: Integer);
    procedure SetNB(Enable: Boolean);
    procedure SetSNB(Enable: Boolean);
    procedure SetANF(Enable: Boolean);
    procedure SetRXFMDeviation(Deviation: Double);
    procedure SetRawFMDeviation(Deviation: Double);
    procedure SetFMSquelch(Enable: Boolean; Level: Integer);
    procedure SetVolume(Vol: Double);
    procedure SetMute(Mute: Boolean);

    // TX управление
    procedure SetTXMode(Mode: Integer);
    procedure SetDriveLevel(Level: Double);
    procedure SetMicGain(GainDB: Double);
    procedure SetTXRun(Run: Boolean);
    // DUP: переключить ИСТОЧНИК пикселей display'а независимо от TX-аудио
    // (SetTXRun меняет и аудио, и источник; этот метод позволяет вернуть
    //  RX-источник пока TX-аудио продолжает работать).
    procedure SetDisplaySourceTX(UseTX: Boolean);
    // TUN: WDSP TXA PostGen — гонит чистый тон в DSP-цепь после всей
    // обработки. Mag — амплитуда в [0..1], Freq — Hz (audio).
    procedure SetTXTone(Enabled: Boolean; FreqHz, Mag: Double);
    // 2TON: PostGen two-tone (IMD-тест / калибровка PureSignal).
    // Тона Freq1/Freq2 Гц (для LSB/CWL/DIGL знак инвертируется), амплитуды
    // по 0.49, чтобы сумма оставалась чуть ниже полной шкалы.
    procedure SetTXTwoTone(Enabled: Boolean; Freq1, Freq2: Double);
    // Protocol 2: компенсирующий FIR перед FPGA CIC-интерполятором TX.
    // Для openHPSDR P2 всегда включён; для остальных бэкендов выключен.
    procedure SetTXCFIR(Enabled: Boolean);

    // ---- PureSignal (WDSP calcc на TXA_CHAN) ----
    // Армирование подачи feedback в pscc (кнопка PS). Сама калибровка
    // управляется PSControl (state machine в контроллере).
    procedure SetPureSignalArmed(Armed: Boolean);
    // Feedback-пакет синхронизированного DDC0 (interleaved: пара DDC0 =
    // RX-feedback, пара DDC1 = TX DAC, 24-bit BE). Вызывается из СЕТЕВОГО
    // потока; pscc потокобезопасен относительно
    // fexchange0 TX-потока). TotalPairs — всего IQ-пар в пакете (оба потока).
    procedure PushPSFeedback(const Buf: array of Byte; DataOffset: Integer;
                             TotalPairs: Integer);
    // Тонкие врапперы WDSP PS API (все на TXA_CHAN, guard FInitialized):
    procedure PSControl(Reset, Mancal, Automode, Turnon: Integer);
    procedure PSSetMox(Mox: Boolean);
    procedure PSGetInfo(var Info: array of Integer);       // 16 int
    function  PSGetMaxTX: Double;
    procedure PSSetParams(MoxDelay, LoopDelay: Double; TXDelayNs: Integer;
                          RelaxPtol: Boolean; Pin, Map, Stabilize: Boolean;
                          Ints, Spi: Integer; HWPeak: Double;
                          OutlierSigma: Double);

    // Расширенное TX управление (применяются мгновенно если канал открыт)
    procedure SetTXFilterFull(Low, High, NC, MP, Window: Integer);
    // Знаковые края TX-полосы текущего режима передачи — ровно то, что стоит
    // в bp0. Нужны спектру, чтобы на передаче рисовать TX-ширину, а не RX.
    procedure TXFilterEdgesHz(out Lo, Hi: Integer);
    procedure SetTXCompressor(Enable: Boolean; GainDB: Double);
    procedure SetTXLeveler(Enable: Boolean; TopDB: Double; DecayMS: Integer);
    procedure SetTXALC(Enable: Boolean; MaxGainDB: Double; DecayMS: Integer);
    procedure SetTXPhaseRot(Enable: Boolean; Stages: Integer; FreqHz: Double);
    procedure SetTXEQ(Enable: Boolean; NumBands: Integer;
                      const Gains, Freqs: array of Double);
    procedure SetTXAMCarrierLevel(Level: Double);
    procedure SetTXFMParams(Deviation: Double; LowCut, HighCut, EmphPos: Integer);
    procedure SetTXCTCSS(Enable: Boolean; FreqHz: Double);
    // Источник микрофона: Radio (HW mic-stream) или SoundCard (PortAudio).
    procedure SetTXMicSource(Source: TTXMicSource);

    // TX display: отдельные FFT/window/detector/avg для TX-analyzer (TX_DISP_ID).
    // Аналог RX-сеттеров, но применяются только к TX-anaylzer.
    procedure SetTXFFTParams(FFTSize, WinType: Integer);
    procedure SetTXSpectrumDisplay(Detector, AvgMode: Integer; AvgTimeMS: Double);
    procedure SetTXWaterfallDisplay(Detector, AvgMode: Integer; AvgTimeMS: Double);

    // TX mic вход — вызывается из receive thread (thread-safe lock-free).
    // Src: 16-bit big-endian hardware mic samples, N — кол-во сэмплов.
    // Если MicSource=SoundCard, эти сэмплы игнорируются.
    procedure PushTXMicSamples16(const Src: array of SmallInt; N: Integer);
    // Прямой push double-сэмпла (-1..+1), для sound-card пути.
    // Должен вызываться из ОДНОГО потока.
    procedure PushTXMicSampleD(const S: Double);
    // Batch-вариант: пушит N сэмплов одним вызовом, будит TX-поток один раз.
    procedure PushTXMicSamplesD(const Src: array of Double; N: Integer);

    // Настройки анализатора (применяются немедленно если открыт)
    procedure SetFFTParams(FFTSize, WinType: Integer);
    procedure SetDisplayFPS(FPS: Integer);
    procedure SetSpectrumDisplay(Detector, AvgMode: Integer; AvgTimeMS: Double);
    procedure SetWaterfallDisplay(Detector, AvgMode: Integer; AvgTimeMS: Double);

    // Spectrum — вызывать из таймера (~20 fps)
    procedure UpdateSpectrum;
    procedure GetSpectrumData(var Pixels: array of Single; var Count: Integer);

    // S-meter
    function GetSMeterDBm: Double;

    // Zoom/pan: задать коэффициент зума и положение пана и переармировать
    // RX-анализатор. View* — границы видимого окна (Гц отн. DDC-центра).
    procedure SetZoomPan(AZoom, APan: Double);
    // Зум/пан доп. пана (span-clip его анализатора). Видимое окно считает
    // контроллер той же формулой (GetPanViewWindow).
    procedure SetPanZoomPan(PanId: Integer; AZoom, APan: Double);
    property ZoomFactor:  Double           read FZoomFactor;
    property PanSlider:   Double           read FPanSlider;
    property ViewLowHz:   Double           read FViewLowHz;
    property ViewHighHz:  Double           read FViewHighHz;

    // --- Лупа маяка (BCN_DISP_ID) ---------------------------------------
    // Включает/выключает второй analyzer на IQ главного тракта и задаёт его
    // окно: ACenterHz/ASpanHz — В ГЕРЦАХ ОТНОСИТЕЛЬНО ЦЕНТРА DDC (как
    // ViewLowHz/ViewHighHz). Окно клампится в полосу захвата. Вызывать из
    // UI-потока. Enabled=False — analyzer уничтожается (нулевая цена, когда
    // окно маяка закрыто).
    procedure SetBeaconZoom(AEnabled: Boolean; ACenterHz, ASpanHz: Double);
    // Забрать последний кадр лупы. Fresh=False, если нового кадра не было —
    // вызывающий рисует предыдущий. Low/High — фактическое окно (Гц отн.
    // центра DDC), уже снаппенное по бинам FFT.
    function  GetBeaconZoomSpectrum(var Pixels: array of Single;
                out Count: Integer; out LowHz, HighHz: Double): Boolean;
    function  GetBeaconZoomWaterfall(var Pixels: array of Single;
                out Count: Integer): Boolean;
    property BeaconZoomOpen: Boolean read FBcnOpen;
    property BeaconZoomFFT:  Integer read FBcnFFT;

    property Initialized: Boolean          read FInitialized;
    property SampleRate:  Integer          read FSampleRate;
    property TXSampleRate: Integer         read FTXSampleRate;
    property AudioBufSize: Integer         read FAudioBufSize;
    property AGCTop:           Double  read FAGCTop;
    property AGCSlope:         Integer read FAGCSlope;
    property AGCHangThreshold: Integer read FAGCHangThreshold;
    property AGCHangLevel:     Double  read FAGCHangLevel;
    property AGCThresh:        Double  read FAGCThresh;
    property Mode:        Integer          read FMode;
    property FilterLow:   Integer          read FFilterLow;
    property FilterHigh:  Integer          read FFilterHigh;
    property NRMode:      Integer          read FNRMode;
    property NBMode:      Integer          read FNBMode;
    property SNBEnabled:  Boolean          read FSNBEnabled;
    property LastError:   string           read FLastError;
    property SMeter:      Double           read FSMeter;
    property FFTSize:      Integer          read FFFTSize;
    property WindowType:   Integer          read FWindowType;
    property SpecDetector: Integer          read FSpecDetector;
    property SpecAvgMode:  Integer          read FSpecAvgMode;
    property SpecAvgTimeMS: Double          read FSpecAvgTimeMS;
    property WfDetector:   Integer          read FWfDetector;
    property WfAvgMode:    Integer          read FWfAvgMode;
    property WfAvgTimeMS:  Double           read FWfAvgTimeMS;
    property OnAudio:      TOnAudioReady    read FOnAudio    write FOnAudio;
    property OnDemodAudio: TOnAudioReady    read FOnDemodAudio write FOnDemodAudio;
    property OnSliceAudio: TOnSliceAudio    read FOnSliceAudio write FOnSliceAudio;
    property OnSliceDemodAudio: TOnSliceAudio
             read FOnSliceDemodAudio write FOnSliceDemodAudio;
    // Офсет дисплея (дБ) на кадр — см. TOnDispCalQuery. nil = без коррекции.
    property OnDispCal:    TOnDispCalQuery  read FOnDispCal   write FOnDispCal;
    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;
    // Сколько mic-сэмплов ждёт обработки. Диагностическое: по нему видно, что
    // повторный MOX=True не сбрасывает кольцо на ходу (см. SetTXRun).
    property TXMicFill:    Integer            read GetTXMicFill;
    // Включает full-duplex поведение RX-тракта: RXA не останавливается на TX,
    // RX-анализатор продолжает получать IQ. Должен ставиться UI ДО SetTXRun.
    property KeepRXDuringTX: Boolean         read FKeepRXDuringTX
                                             write FKeepRXDuringTX;
    // Full-duplex self-monitor: при TX пропускать RX-аудио на колонки (QO-100 —
    // слышим себя со спутника). Действует только вместе с KeepRXDuringTX.
    property MonitorRXDuringTX: Boolean      read FMonitorRXDuringTX
                                             write FMonitorRXDuringTX;
    // Активный display ID — RX_DISP_ID или TX_DISP_ID (зависит от FTXActive).
    // SpectrumView/Waterfall должны читать пиксели именно из этого ID.
    property ActiveDisplayID: Integer        read FActiveDisplayID;
    property TXMicSource:  TTXMicSource      read FTXMicSource;
    property OnPullMicSamples: TPullMicSamplesFunc
                              read FOnPullMic write FOnPullMic;

    // Диагностика mic ring buffer: сколько сэмплов накоплено TX-цепью.
    function  TXMicRingAvail: Integer;

    property TXFFTSize:        Integer read FTXFFTSize;
    property TXWindowType:     Integer read FTXWindowType;
    property TXSpecDetector:   Integer read FTXSpecDetector;
    property TXSpecAvgMode:    Integer read FTXSpecAvgMode;
    property TXSpecAvgTimeMS:  Double  read FTXSpecAvgTimeMS;
    property TXWfDetector:     Integer read FTXWfDetector;
    property TXWfAvgMode:      Integer read FTXWfAvgMode;
    property TXWfAvgTimeMS:    Double  read FTXWfAvgTimeMS;
  end;

implementation

// ===========================================================================
// TX DSP поток — читает mic ring buffer, вызывает fexchange0(TXA_CHAN),
// сигнализирует о готовых IQ данных через FOnTXIQ callback.
// Поток обработки TX-аудио и формирования IQ.
// ===========================================================================
type
  TDisplayThread = class(TThread)
  private
    FEngine: TWDSPEngine;
  protected
    procedure Execute; override;
  public
    constructor Create(AEngine: TWDSPEngine);
  end;

  TTXDSPThread = class(TThread)
  private
    FEngine: TWDSPEngine;
  protected
    procedure Execute; override;
  public
    constructor Create(AEngine: TWDSPEngine);
  end;

function UsePanNormOneHz(Detector: Integer): Integer;
begin
  // Нормализация к 1 Гц включается только для panadapter
  // и только для detector modes 2/3/4.
  if Detector in [2, 3, 4] then
    Result := 1
  else
    Result := 0;
end;

constructor TDisplayThread.Create(AEngine: TWDSPEngine);
begin
  FEngine := AEngine;
  FreeOnTerminate := False;
  inherited Create(False);
end;

procedure TDisplayThread.Execute;
var
  NextTick: QWord;
  NowTick: QWord;
  DelayMS: Integer;
begin
  NextTick := GetTickCount64;
  while not Terminated do
  begin
    if FEngine.FInitialized and FEngine.FAnalyzerOpen then
      FEngine.UpdateSpectrum;

    Inc(NextTick, QWord(Max(1, Round(1000.0 / Max(1, FEngine.FDisplayFPS)))));
    NowTick := GetTickCount64;
    if NextTick > NowTick then
    begin
      DelayMS := Integer(NextTick - NowTick);
      if DelayMS > 0 then
        Sleep(DelayMS);
    end
    else
      NextTick := NowTick;
  end;
end;

constructor TTXDSPThread.Create(AEngine: TWDSPEngine);
begin
  FEngine := AEngine;
  FreeOnTerminate := False;
  inherited Create(False);
end;

procedure TTXDSPThread.Execute;
// Поток работает с периодом одного mic-буфера (FAudioBufSize/FAudioRate).
// Если mic-данные накопились — обрабатывает их; если нет — обрабатывает тишину.
// Это обеспечивает непрерывный поток DUC IQ к железу и TX-сигнал на спектре/водопаде.
var
  Avail:      Integer;
  PeriodMs:   Integer;
  PeriodUs:   Integer;
  AccUs:      Integer;
begin
  // 512 samples / 48000 Hz = 10.666 ms.  Do not floor this to 10 ms:
  // that over-produces TX IQ by ~6.7%, eventually forcing DUC queue
  // corrections/drops and producing periodic sidebands on a pure TUN tone.
  PeriodUs   := Max(1000, FEngine.FAudioBufSize * 1000000 div FEngine.FAudioRate);
  AccUs      := 0;

  while not Terminated do
  begin
    Inc(AccUs, PeriodUs);
    PeriodMs := Max(1, AccUs div 1000);
    Dec(AccUs, PeriodMs * 1000);

    // Ждём сигнала от mic-данных ИЛИ истечения периода —
    // Здесь timing задаётся таймаутом, синхронизированным с размером буфера.
    RTLEventWaitFor(FEngine.FTXMicSem, PeriodMs);
    if Terminated then Break;
    if not FEngine.FTXActive then Continue;

    // Если выбран sound-card mic — тянем сэмплы из внешнего источника
    // (PortAudio через FOnPullMic в MainForm) ПЕРЕД проверкой ring buffer.
    // Callback сам пушит сэмплы в FTXMicRing через PushTXMicSampleD.
    if (FEngine.FTXMicSource = txmsSoundCard) and
       Assigned(FEngine.FOnPullMic) then
      FEngine.FOnPullMic(FEngine.FAudioBufSize * 2);

    // Обрабатываем накопившиеся блоки с реальными mic-данными (строго
    // mic-гейтед: сколько полных mic-блоков в ring — столько TX-блоков).
    repeat
      if Terminated then Break;
      Avail := (FEngine.FTXMicHead - FEngine.FTXMicTail + TX_MIC_RING)
                and (TX_MIC_RING - 1);
      if Avail < FEngine.FAudioBufSize then Break;
      FEngine.FTXLock.Enter;
      try
        // ★Повторная проверка гейта ПОД локом — как на RX (FMainGated).
        // Снаружи она ничего не гарантирует: между ней и входом в блок T/R
        // успевает опустить FTXActive и начать слив канала.
        if not FEngine.FTXActive then Break;
        FEngine.ProcessTXBlock;
      finally
        FEngine.FTXLock.Leave;
      end;
    until False;

    if Terminated then Break;

    // Silence-инъекция УБРАНА. Раньше при пустом ring
    // (>6 тиков) сюда впрыскивался блок тишины «чтобы DUC IQ не прерывался».
    // Но это фабрикация данных не на радиоклоке: для TUN такой «silence» несёт
    // PostGen-тон, и лишний блок ломает темп DUC IQ (боковые/тр-р-р); плюс он
    // над-производит блоки сверх mic-темпа. Продюсер теперь строго mic-гейтед:
    // блок создаётся ТОЛЬКО когда в ring накопился полный mic-блок (радиокварц
    // шлёт mic-пакеты непрерывно и на TX/TUN → очередь пополняется радиоклоком).
    // Реальный обрыв mic (сеть) = короткий разрыв, покрываемый подушкой
    // FPGA-FIFO радио, без фабрикации тишины в поток.
  end;
end;

// ===========================================================================
// DSP поток обрабатывает IQ-пакеты из очереди.
// Сетевой поток только кладёт пакеты, этот поток занимается DSP.
// ===========================================================================
type
  TDSPThread = class(TThread)
  private
    FEngine: TWDSPEngine;
  protected
    procedure Execute; override;
  public
    constructor Create(AEngine: TWDSPEngine);
  end;

constructor TDSPThread.Create(AEngine: TWDSPEngine);
begin
  FEngine := AEngine;
  FreeOnTerminate := False;
  inherited Create(False);
end;

procedure TDSPThread.Execute;
var
  Item: ^TIQQueueItem;
begin
  while not Terminated do
  begin
    // Ждём сигнала от сетевого потока.
    RTLEventWaitFor(FEngine.FQueueSem, 100);
    if Terminated then Break;

    // Разбираем все накопившиеся пакеты (общая очередь: главный + паны)
    while FEngine.FQueueTail <> FEngine.FQueueHead do
    begin
      Item := @FEngine.FQueue[FEngine.FQueueTail];
      if Item^.PanId = 0 then
      begin
        // Гейт смены rate главного (ChangeSampleRate): main-пакеты дропаем
        // НЕ блокируясь — пан-пакеты позади в очереди не должны ждать
        // пересборку RXA. Повторная проверка под локом закрывает гонку
        // с установкой гейта из UI-потока.
        if not FEngine.FMainGated then
        begin
          FEngine.FMainLock.Enter;
          try
            if not FEngine.FMainGated then
              FEngine.PushIQItemToDSP(Item^);
          finally
            FEngine.FMainLock.Leave;
          end;
        end;
      end
      else
        FEngine.PushPanItemToDSP(Item^.PanId, Item^);
      FEngine.FQueueTail := (FEngine.FQueueTail + 1) and (IQ_QUEUE_SIZE - 1);
    end;
  end;
end;

// ---------------------------------------------------------------------------

function TWDSPEngine.ModeToWDSP(Mode: Integer): Integer;
begin
  case Mode of
    MODE_LSB:  Result := WDSP_LSB;
    MODE_USB:  Result := WDSP_USB;
    MODE_DSB:  Result := WDSP_DSB;
    MODE_CWL:  Result := WDSP_CWL;
    MODE_CWU:  Result := WDSP_CWU;
    MODE_FM:   Result := WDSP_FM;
    MODE_AM:   Result := WDSP_AM;
    MODE_SAM:  Result := WDSP_SAM;
    MODE_DIGU: Result := WDSP_DIGU;
    MODE_DIGL: Result := WDSP_DIGL;
    MODE_WFM:  Result := WDSP_RX_WFM;
    // The narrow-FM path always applies its 300..3000 Hz speech filters and
    // de-emphasis.  Those filters collapse the four DMR discriminator levels.
    // WFMD is a plain quadrature discriminator and permits flat data audio.
    MODE_DMR:  Result := WDSP_RX_WFM;
    MODE_FMRAW: Result := WDSP_RX_WFM;
    else       Result := WDSP_USB;
  end;
end;

function TWDSPEngine.ModeToWDSPTX(Mode: Integer): Integer;
// txaMode расходится с rxaMode после DRM — отдельный маппинг только для WFM.
begin
  // The narrow FM modulator has mandatory speech pre-emphasis.  FM RAW uses
  // wfmmod even at NFM deviation because that path has an explicit flat mode.
  if Mode in [MODE_WFM, MODE_FMRAW] then Result := WDSP_TX_WFM
  else if Mode = MODE_DMR then Result := WDSP_FM
  else Result := ModeToWDSP(Mode);
end;

function TWDSPEngine.WantWideDSP(Mode: Integer): Boolean;
// WFM демодулируется квадратурным дискриминатором на dsp_rate. При 48 кГц
// полоса ±24 кГц — вещательный канал (±100 кГц) туда не влезает, поэтому в
// WFM цепь RXA поднимается до WFMDSPRate. Если поднимать некуда — работаем
// как есть (узкий WFM), но dsp_rate не трогаем.
begin
  Result := (Mode = MODE_WFM) and (WFMDSPRate > FAudioRate);
end;

function TWDSPEngine.WFMDSPRateFor(InRate: Integer): Integer;
// Наибольший rate вида FAudioRate * 2^k, который не выше WFM_DSP_RATE и делит
// InRate нацело. Оба требования жёсткие:
//   * pre_main_build считает dsp_insize через ЦЕЛОЧИСЛЕННОЕ in_rate/dsp_rate,
//     поэтому отношение обязано быть точным;
//   * dsp_size = FAudioBufSize * (rate/FAudioRate) должен остаться степенью
//     двойки, иначе fircore планирует FFT нестандартного размера мимо wisdom.
var
  R: Integer;
begin
  R := WFM_DSP_RATE div FAudioRate;          // 4 при 48 кГц аудио
  while R > 1 do
  begin
    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
  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);
// Переводит RXA/TXA между «узким» (dsp_rate = FAudioRate) и «широким» режимом.
// dsp_size всегда = FAudioBufSize * (dsp_rate / FAudioRate), поэтому dsp_outsize
// остаётся FAudioBufSize и буферы fexchange0 (FRXIn/FRXOut/FTXIn/FTXOut)
// переаллокации не требуют:
//   RXA wide: dsp_size=4096 @192k          -> out=FAudioBufSize
//   RXA narw: dsp_size=FAudioBufSize @48k  -> out=FAudioBufSize
//   TXA wide: dsp_size=FTXOutBufSize @192k -> in=FAudioBufSize, out=FTXOutBufSize
//   TXA narw: dsp_size=FAudioBufSize @48k  -> in=FAudioBufSize, out=FTXOutBufSize
// Зовётся и из Open — до FInitialized, но уже после OpenChannel обоих каналов.
// RXA и TXA гейтятся раздельно: у TXA рейты свои (FAudioRate вход,
// FTXSampleRate выход) и от FSampleRate не зависят, поэтому wfmmod уходит
// на широкий dsp_rate даже когда приёмник остался на 48 кГц.
var
  Wide: Boolean;
  RXRate, RXSize, TXRate, TXSize, Old: Integer;
begin
  Wide := (Mode = MODE_WFM);

  if Wide then RXRate := WFMDSPRate else RXRate := FAudioRate;
  RXSize := FAudioBufSize * (RXRate div FAudioRate);
  if RXRate <> FRXDSPRate then
  begin
    // rate, size и nc меняем под одной остановкой канала: между вызовами
    // dsp_insize не совпал бы с in_size, и fexchange0 прочитал бы мусор.
    // RXASetNC/TXASetNC обязательны — см. комментарий к WDSP_MIN_NC.
    Old := SetChannelState(RXA_CHAN, 0, 1);
    SetDSPSamplerate(RXA_CHAN, RXRate);
    SetDSPBuffsize  (RXA_CHAN, RXSize);
    FRXDSPRate := RXRate;
    RXASetNC        (RXA_CHAN, Max(WDSP_MIN_NC, RXSize));
    SetChannelState (RXA_CHAN, Old, 0);
  end;

  if Wide then TXRate := FTXSampleRate else TXRate := FAudioRate;
  TXSize := FAudioBufSize * (TXRate div FAudioRate);
  if TXSize <> FTXDSPSize then
  begin
    Old := SetChannelState(TXA_CHAN, 0, 1);
    SetDSPSamplerate(TXA_CHAN, TXRate);
    SetDSPBuffsize  (TXA_CHAN, TXSize);
    FTXDSPSize := TXSize;
    TXASetNC        (TXA_CHAN, EffectiveTXNC);
    SetChannelState (TXA_CHAN, Old, 0);
  end;
end;

function TWDSPEngine.EffectiveTXNC: Integer;
// Пользовательский FilterNC (1024/2048/4096) не может быть короче dsp_size
// канала: иначе nfor = nc / dsp_size = 0. В WFM dsp_size = FTXOutBufSize.
begin
  Result := Max(FTXFilterNC, FTXDSPSize);
end;

procedure TWDSPEngine.ApplyRXWFMToChan(Chan: Integer);
// create_wfmd поднимается с американской постоянной 75 мкс.
begin
  SetRXAWFMDeviation (Chan, WFM_DEVIATION);
  SetRXAWFMAFFilter  (Chan, WFM_AF_LOW, WFM_AF_HIGH);
  SetRXAWFMDeemphRun (Chan, 1);
  SetRXAWFMDeemphTau (Chan, WFM_TAU_DE_US);
end;

procedure TWDSPEngine.ApplyRXDMRToChan(Chan: Integer);
begin
  // DMR needs the flat 4FSK discriminator, not broadcast-FM deemphasis.
  SetRXAWFMDeviation (Chan, DMR_DEVIATION);
  SetRXAWFMAFFilter  (Chan, 20.0, 7000.0);
  SetRXAWFMDeemphRun (Chan, 0);
end;

procedure TWDSPEngine.ApplyRXRawToChan(Chan: Integer);
begin
  // Decoder-grade discriminator: no broadcast de-emphasis or limiter.
  SetRXAWFMDeviation (Chan, FRawFMDeviation);
  SetRXAWFMAFFilter  (Chan, RAW_AF_LOW, RAW_AF_HIGH);
  SetRXAWFMDeemphRun (Chan, 0);
  SetRXAWFMLimRun    (Chan, 0);
end;

procedure TWDSPEngine.ApplyTXWFMSettings;
// TX-часть широкого ЧМ. RX-сторона WFM ставится через ApplyRXModeSettings.
begin
  SetTXAWFMDeviation  (TXA_CHAN, WFM_DEVIATION);
  SetTXAWFMAFFreqs    (TXA_CHAN, WFM_AF_LOW, WFM_AF_HIGH);
  SetTXAWFMPreEmphRun (TXA_CHAN, 1);
  SetTXAWFMPreEmphTau (TXA_CHAN, WFM_TAU_DE_US);
end;

procedure TWDSPEngine.ApplyRXModeSettings(Chan, Mode: Integer);
// Режимная RX-надстройка канала. Для «нормальных» режимов ничего не нужно —
// всё делает SetRXAMode; спец-обвязку требуют только FM-семейства.
begin
  if Mode = MODE_WFM then ApplyRXWFMToChan(Chan)
  else if Mode = MODE_DMR then ApplyRXDMRToChan(Chan)
  else if Mode = MODE_FMRAW then ApplyRXRawToChan(Chan);
end;

procedure TWDSPEngine.TXSignedEdges(Mode: Integer; out Lo, Hi: Double);
// Вход — магнитуда TX-аудио-полосы
// (FTXFilterLow/High, всегда положительная); выход — знаковые края bp0 под
// боковую режима. Нижняя боковая зеркалит полосу в отрицательные частоты.
var
  L, H, Half: Double;
begin
  L := FTXFilterLow;
  H := FTXFilterHigh;
  case Mode of
    MODE_LSB, MODE_CWL, MODE_DIGL:
      begin Lo := -H;  Hi := -L;  end;      // нижняя боковая
    MODE_USB, MODE_CWU, MODE_DIGU:
      begin Lo :=  L;  Hi :=  H;  end;      // верхняя боковая
    MODE_DSB, MODE_AM, MODE_SAM:
      begin Lo := -H;  Hi :=  H;  end;      // симметрично
    MODE_FM:
      begin                                  // Карсон: ±(девиация + верхний срез)
        Half := FTXFMDeviation + FTXFMHighCut;
        Lo := -Half;  Hi := Half;
      end;
    MODE_WFM:
      // Широкий ЧМ: bp0 — это АУДИО-полосовик перед модулятором, а не РЧ-полоса
      // канала (FILT_WFM_BW=120..200 кГц — селективность RX, другая величина).
      // Открываем его в аудио-полосу вещательного FM = RX AF-фильтр WFM
      // (ApplyRXWFMToChan). Без этого else-ветка душила бы аудио до (200,3100).
      begin Lo := WFM_AF_LOW;  Hi := WFM_AF_HIGH;  end;
    MODE_FMRAW:
      // Плоский baseband для внешних декодеров (совпадает с RX AF в ApplyRXRawToChan
      // и с явным SetTXABandpassFreqs в ApplyTXModeSettings). РЧ-ширина FILT_RAW_BW
      // (6..25 кГц) — селективность RX, к аудио-полосовику отношения не имеет.
      begin Lo := RAW_AF_LOW;  Hi := RAW_AF_HIGH;  end;
  else
    begin Lo := L;  Hi := H;  end;          // прочее — как есть
  end;
end;

function TWDSPEngine.CWLOOffset(Mode: Integer): Double;
// Для CWL смещение положительное, для CWU — отрицательное.
begin
  case Mode of
    MODE_CWL: Result :=  FCWPitch;
    MODE_CWU: Result := -FCWPitch;
  else        Result := 0.0;
  end;
end;

procedure TWDSPEngine.PushShift(Chan: Integer; ShiftHz: Double; Mode: Integer);
// ShiftHz приходит в координатах «VFO минус центр DDC»; в эфир гетеродин
// уезжает ещё на CWLOOffset, чтобы принимаемая посылка легла на pitch.
begin
  SetRXAShiftFreq(Chan, ShiftHz + CWLOOffset(Mode));
end;

procedure TWDSPEngine.PushPassband(Chan, Lo, Hi, Mode, Rate: Integer);
// Кромки хранятся относительно VFO (у CW — симметрично нулю: «500 Гц вокруг
// корреспондента»). В baseband демодулятора они уезжают навстречу гетеродину,
// т.е. у CWU получается pitch±bw/2.
var Off: Integer;
begin
  Off := Round(CWLOOffset(Mode));
  RXASetPassband(Chan, ClampToNyquist(Lo - Off, Rate),
                       ClampToNyquist(Hi - Off, Rate));
end;

procedure TWDSPEngine.ApplyTXModeSettings(Mode: Integer);
begin
  if not FInitialized then Exit;
  if Mode = MODE_FMRAW then
  begin
    // Decoder/modem-grade FM input.  A full-scale PCM peak produces the
    // selected peak deviation. Keep only a broad linear-phase AF bandpass;
    // APRS and other external modems must not see voice EQ, dynamics or emphasis.
    SetTXAWFMDeviation  (TXA_CHAN, FRawFMDeviation);
    SetTXAWFMAFFreqs    (TXA_CHAN, RAW_AF_LOW, RAW_AF_HIGH);
    SetTXAWFMPreEmphRun (TXA_CHAN, 0);
    SetTXABandpassFreqs (TXA_CHAN, RAW_AF_LOW, RAW_AF_HIGH);
    SetTXAPanelGain1    (TXA_CHAN, 1.0);
    SetTXACompressorRun (TXA_CHAN, 0);
    SetTXALevelerSt     (TXA_CHAN, 0);
    SetTXAALCSt         (TXA_CHAN, 0);
    SetTXAPHROTRun      (TXA_CHAN, 0);
    SetTXAEQRun         (TXA_CHAN, 0);
    SetTXACTCSSRun      (TXA_CHAN, 0);
  end
  else
  begin
    // Restore the user's normal voice/data chain after leaving FM RAW.
    // bp1 (aux bandpass) НЕ трогаем вручную — им управляет компрессор через
    // bp0 всегда активен сам; здесь обновляем остальную цепочку TX.
    ApplyTXChainSettings;
    if Mode = MODE_WFM then ApplyTXWFMSettings;
  end;
end;

procedure TWDSPEngine.ApplyTXMode(Mode: Integer);
// ЕДИНАЯ точка перевода TX-тракта в режим. SetTXAMode пересобирает модулятор и
// bp0/bp1 под режим (TXASetupBPFilters), ApplyTXModeSettings кладёт режимную цепь.
begin
  if not FInitialized then Exit;
  SetTXAMode(TXA_CHAN, ModeToWDSPTX(Mode));
  ApplyTXModeSettings(Mode);
end;

function TWDSPEngine.ClampToNyquist(Hz, Rate: Integer): Integer;
var Limit: Integer;
begin
  Limit := Rate div 2 - 1;
  Result := Max(-Limit, Min(Limit, Hz));
end;

procedure TWDSPEngine.ApplyDefaultFilter;
begin
  case FMode of
    MODE_LSB:  SetFilter(-2400, -100);
    MODE_USB:  SetFilter(  100, 2400);
    MODE_DSB:  SetFilter(-2400, 2400);
    MODE_CWL:  SetFilter( -800, -200);
    MODE_CWU:  SetFilter(  200,  800);
    MODE_FM:   SetFilter(-5000, 5000);
    MODE_AM:   SetFilter(-4000, 4000);
    MODE_SAM:  SetFilter(-4000, 4000);
    MODE_DIGU: SetFilter(    0, 3000);
    MODE_DIGL: SetFilter(-3000,    0);
    MODE_WFM:  SetFilter(-80000, 80000);
    MODE_DMR:  SetFilter(-6250, 6250);
    MODE_FMRAW: SetFilter(-6250, 6250);
  end;
end;

// ---------------------------------------------------------------------------
// Constructor / Destructor
// ---------------------------------------------------------------------------

constructor TWDSPEngine.Create(SampleRate, AudioRate, BufSize: Integer);
begin
  inherited Create;
  FSampleRate   := SampleRate;
  FAudioRate    := AudioRate;
  // BufSize = dsp_size @ AudioRate; внутренний DSP buffer_size = 1024.
  // FBufSize = in_size @ SampleRate = BufSize * (SampleRate/AudioRate)
  // in_size = buffer_size * sample_rate/48000 = 1024*4 = 4096 @ 192kHz.
  FAudioBufSize := BufSize;                            // dsp_size = out_size @ AudioRate
  FBufSize      := BufSize * SampleRate div AudioRate; // in_size @ SampleRate = 4096
  // TX rate фиксирован — не зависит от RX. FTXOutBufSize = out_size TXA @ FTXSampleRate.
  FTXSampleRate := TX_SAMPLE_RATE;
  FTXOutBufSize := BufSize * FTXSampleRate div AudioRate; // = 4096 @ 48k audio, 192k tx
  FRXDSPRate    := AudioRate;   // «узкий» режим; поднимается только в WFM
  FTXDSPSize    := BufSize;     // dsp_size TXA в «узком» режиме
  FInitialized  := False;
  FAnalyzerOpen := False;
  FMode         := MODE_USB;
  FTXMode       := MODE_USB;
  FFilterLow    :=  100;
  FFilterHigh   := 2400;
  FAGCMode      := agcMedium;
  FShiftHz      := 0.0;
  FCWPitch      := 600;
  FCWSpeed      := 20;
  FAGCTop       := -90.0;
  FVolume       := 0.7;
  FSMeter       := -130.0;
  FNRMode       := NR_MODE_OFF;
  FNBMode       := NB_MODE_OFF;
  FSNBEnabled   := False;
  FANFEnabled   := False;
  FRXAccPos     := 0;
  // Analyzer defaults
  FFFTSize      := 131072;
  FWindowType   := 2;     // Hann
  FDisplayFPS   := 60;
  FEffectiveDisplayFPS := 60.0;
  FDisplayPixelCount := SPECTRUM_PIXELS;
  FDisplayResLimit   := SPECTRUM_PIXELS;
  FSpecDetector := 0;     // Peak
  FSpecAvgMode  := 3;     // Log Recursive
  FSpecAvgTimeMS := 30.0;
  FWfDetector   := 0;     // Peak
  FWfAvgMode    := 3;     // Log Recursive
  FWfAvgTimeMS  := 120.0;
  FZoomFactor   := 0.0;   // 0.0 = весь span, без зума
  FPanSlider    := 0.5;   // окно по центру
  FViewLowHz    := -FSampleRate / 2.0;
  FViewHighHz   :=  FSampleRate / 2.0;

  SetLength(FRXIn,   FBufSize * 2);         // in_size пар @ FSampleRate (4096*2)
  SetLength(FRXOut,  FAudioBufSize * 2);    // out_size пар @ FAudioRate  (1024*2)
  SetLength(FTXIn,   FAudioBufSize * 2);    // in_size пар @ FAudioRate
  SetLength(FTXOut,  FTXOutBufSize * 2);    // out_size пар @ FTXSampleRate (фикс)
  SetLength(FRXAccI, FBufSize);
  SetLength(FRXAccQ, FBufSize);
  SetLength(FSpecBuf, FBufSize * 2);
  SetLength(FDispBuf, DISPLAY_BLOCK_SIZE * 2);
  SetLength(FTXDispBuf, DISPLAY_BLOCK_SIZE * 2);
  FDispPos := 0;
  FTXDispPos := 0;
  SetLength(FOutL, FAudioBufSize);
  SetLength(FOutR, FAudioBufSize);

  // Очередь и DSP поток
  FQueueHead  := 0;
  FQueueTail  := 0;
  FDSPRunning := False;
  FQueueSem   := RTLEventCreate;
  FDSPThread  := nil;
  FDisplayThread := nil;

  // Гейт главного тракта (хирургическая смена rate)
  FMainGated := False;
  FMainLock  := TCriticalSection.Create;
  FTXLock    := TCriticalSection.Create;

  // Лупа маяка — analyzer создаётся по требованию UI (SetBeaconZoom)
  FBcnLock      := TCriticalSection.Create;
  FBcnOpen      := False;
  FBcnDispPos   := 0;
  FBcnReqCenter := 0.0;
  FBcnReqSpan   := 40000.0;
  FBcnFFT       := 0;
  FBcnPixCount  := 0;
  SetLength(FBcnDispBuf, DISPLAY_BLOCK_SIZE * 2);

  // Тап IQ наружу (TCI)
  FIQTapLock := TCriticalSection.Create;
  FIQTapOn   := False;

  // Мультислайсы
  FSliceLock := TCriticalSection.Create;
  FillChar(FSlices, SizeOf(FSlices), 0);
  FOnSliceAudio := nil;
  FOnSliceDemodAudio := nil;
  AllocSliceBuffers;

  // TX инициализация
  FTXMicHead       := 0;
  FTXMicTail       := 0;
  FTXActive        := False;
  FTXToneOn        := False;
  FTXTTOn          := False;
  FKeepRXDuringTX  := False;
  // PureSignal — значения по умолчанию для протокола 2.
  FPSArmed         := False;
  FPSMoxDelay      := 0.2;      // с — задержка после MOX до сбора данных
  FPSLoopDelay     := 0.0;      // с — пауза между итерациями калибровки
  FPSTXDelayNs     := 150;      // нс — компенсация задержки TX-тракта
  FPSRelaxPtol     := False;
  FPSPin           := True;
  FPSMap           := True;
  FPSStabilize     := False;
  FPSInts          := 16;
  FPSSpi           := 256;
  FPSHWPeak        := 0.2899;   // протокол 2 (Saturn: 0.6121)
  FPSOutlierSigma  := 0.0;      // выкл. до ApplyPSSettingsToDSP (авто по плате)
  FMonitorRXDuringTX := False;
  FPostTXMuteUntil := 0;
  FActiveDisplayID := RX_DISP_ID;
  FTXMicSource     := txmsRadio;
  FOnPullMic       := nil;
  FTXThread        := nil;
  FTXMicSem        := RTLEventCreate;
  FillChar(FTXMicRing, SizeOf(FTXMicRing), 0);

  // TX-цепь — дефолты, согласуются с Settings.DefaultTX
  FTXFilterLow     := 200;
  FTXFilterHigh    := 3100;
  FTXFilterNC      := 1024;
  FTXFilterMP      := 1;
  FTXFilterWindow  := 1;
  FTXMicGainDB     := 10.0;
  FTXCompressorOn  := False;
  FTXCompressorGain:= 5.0;
  FTXLevelerOn     := True;
  FTXLevelerTop    := 5.0;
  FTXLevelerDecay  := 500;
  FTXALCOn         := True;
  FTXALCMaxGain    := 0.0;
  FTXALCDecay      := 10;
  FTXPhaseRotOn    := False;
  FTXPhaseRotStages:= 8;
  FTXPhaseRotFreq  := 338.0;
  FTXEQOn          := False;
  FTXEQNumBands    := 10;
  FillChar(FTXEQGains, SizeOf(FTXEQGains), 0);
  // Дефолтные центры полос EQ (Hz). Должны быть валидными ДАЖЕ при EQOn=False —
  // SetTXAEQProfile вызывается в ApplyTXChainSettings всегда, и нулевые freqs
  // могут сломать TX-фильтры внутри WDSP.
  FTXEQFreqs[0]  := 0;
  FTXEQFreqs[1]  := 100;
  FTXEQFreqs[2]  := 200;
  FTXEQFreqs[3]  := 400;
  FTXEQFreqs[4]  := 700;
  FTXEQFreqs[5]  := 1100;
  FTXEQFreqs[6]  := 1500;
  FTXEQFreqs[7]  := 2000;
  FTXEQFreqs[8]  := 2500;
  FTXEQFreqs[9]  := 3000;
  FTXEQFreqs[10] := 3500;
  FTXAMCarrier     := 0.5;
  FTXFMDeviation   := 5000.0;
  FRawFMDeviation  := 2500.0;
  FTXFMLowCut      := 300;
  FTXFMHighCut     := 3000;
  FTXFMEmphPos     := 1;
  FTXCTCSSOn       := False;
  FTXCTCSSFreq     := 100.0;

  // TX-display defaults
  FTXFFTSize       := 16384;
  FTXWindowType    := 2;
  FTXSpecDetector  := 0;
  FTXSpecAvgMode   := 3;
  FTXSpecAvgTimeMS := 30.0;
  FTXWfDetector    := 0;
  FTXWfAvgMode     := 3;
  FTXWfAvgTimeMS   := 120.0;
end;

destructor TWDSPEngine.Destroy;
begin
  // ★Сразу Close, без SetTXRun(False): тот поток НЕ останавливает — он лишь
  // опускает FTXActive и тут же сам сливает канал TXA своими fexchange0, то
  // есть лезет в канал одновременно с ещё живым потоком, прямо перед сносом
  // объекта. Close опускает гейт, будит поток, дожидается его WaitFor и лишь
  // потом закрывает каналы — это и есть правильный порядок.
  Close;
  RTLEventDestroy(FQueueSem);
  RTLEventDestroy(FTXMicSem);
  FSliceLock.Free;
  FMainLock.Free;
  FTXLock.Free;
  FBcnLock.Free;
  FIQTapLock.Free;
  inherited;
end;

// ---------------------------------------------------------------------------
// Analyzer display
// ---------------------------------------------------------------------------

procedure TWDSPEngine.OpenAnalyzer;
var
  Success, p: Integer;
begin
  if FAnalyzerOpen then Exit;

  Success := -1;
  XCreateAnalyzer(RX_DISP_ID, @Success, 262144, 1, 1, nil);
  if Success <> 0 then
  begin
    FLastError := Format('XCreateAnalyzer(RX) failed: %d', [Success]);
    Exit;
  end;

  // Отдельный analyzer для TX-спектра/водопада. Ошибку логируем в LastError,
  // но не считаем фатальной — RX без TX-display продолжает работать.
  Success := -1;
  XCreateAnalyzer(TX_DISP_ID, @Success, 262144, 1, 1, nil);
  if Success <> 0 then
    FLastError := Format('XCreateAnalyzer(TX) failed: %d', [Success]);

  FFlp[0] := 0;
  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;

procedure TWDSPEngine.ApplyRXAnalyzerSettings;
// Применяет FFFTSize / FWindowType / FSpec* / FWf* к RX_DISP_ID.
var
  AnalyzerFFT, AvgCount, WfAvgCount, MaxW, Overlap, DisplayPixels: Integer;
  Backmult, WfBackmult: Double;
  Bins, SpanClipL, SpanClipH, ZWidth: Integer;
  BinWidth, BW, ZoomSlider: Double;
const
  KEEP_TIME_SEC = 0.10;
  ZOOM_LIMIT    = 100.0;  // максимальная кратность зума
begin
  AnalyzerFFT := CalcDisplayFFTSize;
  FDispPos := 0;
  MaxW := AnalyzerFFT + Round(Min(KEEP_TIME_SEC * FSampleRate,
            KEEP_TIME_SEC * AnalyzerFFT * EnsureRange(FDisplayFPS, 5, 100)));
  CalcAnalyzerTiming(AnalyzerFFT, Overlap, FEffectiveDisplayFPS);
  DisplayPixels := EnsureRange(FLastSpectrumW, 64,
    EnsureRange(FDisplayResLimit, 64, SPECTRUM_PIXELS));
  FDisplayPixelCount := DisplayPixels;

  AverageTimeToParams(FSpecAvgMode, FSpecAvgTimeMS, AvgCount, Backmult);
  AverageTimeToParams(FWfAvgMode, FWfAvgTimeMS, WfAvgCount, WfBackmult);

  // Zoom/pan: считаем span-clip для SetAnalyzer (fscLin/fscHin).
  // clp=0 — оставляем полный span FFT, чтобы при зуме 0.0 видимая полоса была
  // ровно == FSampleRate (остальной код это предполагает). Зум делает только
  // span-clip: FZoomFactor=0.0 → ZWidth=Bins → SpanClipL=SpanClipH=0.
  BinWidth  := FSampleRate / AnalyzerFFT;
  Bins      := AnalyzerFFT;
  BW        := Bins * BinWidth;
  ZoomSlider := Log10(9.0 * EnsureRange(FZoomFactor, 0.0, 1.0) + 1.0);
  ZWidth    := Round(Bins * (1.0 - (1.0 - 1.0 / ZOOM_LIMIT) * ZoomSlider));
  ZWidth    := EnsureRange(ZWidth, 1, Bins);
  SpanClipL := Floor(EnsureRange(FPanSlider, 0.0, 1.0) * (Bins - ZWidth));
  SpanClipH := Bins - ZWidth - SpanClipL;
  // Видимое окно в Гц относительно DDC-центра (low отрицательный). Поправка
  // BinWidth/2 — у комплексного FFT на одну отрицательную ячейку больше.
  FViewLowHz  := -(0.5 * BW - SpanClipL * BinWidth + BinWidth / 2.0);
  FViewHighHz :=  (0.5 * BW - SpanClipH * BinWidth - BinWidth / 2.0);

  SetAnalyzer(RX_DISP_ID, 2, 1, 1, @FFlp[0], AnalyzerFFT, DISPLAY_BLOCK_SIZE,
              FWindowType, 14.0, Overlap, 0, SpanClipL, SpanClipH, DisplayPixels,
              1, 0, 0.0, 0.0, MaxW);
  SetDisplayDetectorMode(RX_DISP_ID, 0, FSpecDetector);
  SetDisplayAverageMode (RX_DISP_ID, 0, FSpecAvgMode);
  SetDisplayNumAverage  (RX_DISP_ID, 0, AvgCount);
  SetDisplayAvBackmult  (RX_DISP_ID, 0, Backmult);
  SetDisplayNormOneHz   (RX_DISP_ID, 0, UsePanNormOneHz(FSpecDetector));
  SetDisplayDetectorMode(RX_DISP_ID, 1, FWfDetector);
  SetDisplayAverageMode (RX_DISP_ID, 1, FWfAvgMode);
  SetDisplayNumAverage  (RX_DISP_ID, 1, WfAvgCount);
  SetDisplayAvBackmult  (RX_DISP_ID, 1, WfBackmult);
  SetDisplayNormOneHz   (RX_DISP_ID, 1, 0);
  SetDisplaySampleRate  (RX_DISP_ID, FSampleRate);
end;

procedure TWDSPEngine.ApplyTXAnalyzerSettings;
// Применяет FTXFFTSize / FTXWindowType / FTXSpec* / FTXWf* к TX_DISP_ID.
// FFT/Window/Det/Avg отдельные от RX — TX-спектр обычно нужен мельче по FFT
// (TX-полоса узкая) и с другим усреднением (быстрее реагировать на голос).
var
  AnalyzerFFT, AvgCount, WfAvgCount, MaxW, Overlap, DisplayPixels: Integer;
  Backmult, WfBackmult: Double;
  EffectiveFPS: Double;
const KEEP_TIME_SEC = 0.10;
begin
  AnalyzerFFT := NormalizeFFTSize(FTXFFTSize);
  FTXDispPos := 0;
  MaxW := AnalyzerFFT + Round(Min(KEEP_TIME_SEC * FTXSampleRate,
            KEEP_TIME_SEC * AnalyzerFFT * EnsureRange(FDisplayFPS, 5, 100)));
  CalcAnalyzerTiming(AnalyzerFFT, Overlap, EffectiveFPS);
  DisplayPixels := EnsureRange(FLastSpectrumW, 64,
    EnsureRange(FDisplayResLimit, 64, SPECTRUM_PIXELS));

  AverageTimeToParams(FTXSpecAvgMode, FTXSpecAvgTimeMS, AvgCount, Backmult);
  AverageTimeToParams(FTXWfAvgMode,   FTXWfAvgTimeMS,   WfAvgCount, WfBackmult);

  SetAnalyzer(TX_DISP_ID, 2, 1, 1, @FFlp[0], AnalyzerFFT, DISPLAY_BLOCK_SIZE,
              FTXWindowType, 14.0, Overlap, 0, 0.0, 0.0, DisplayPixels,
              1, 0, 0.0, 0.0, MaxW);
  SetDisplayDetectorMode(TX_DISP_ID, 0, FTXSpecDetector);
  SetDisplayAverageMode (TX_DISP_ID, 0, FTXSpecAvgMode);
  SetDisplayNumAverage  (TX_DISP_ID, 0, AvgCount);
  SetDisplayAvBackmult  (TX_DISP_ID, 0, Backmult);
  SetDisplayNormOneHz   (TX_DISP_ID, 0, UsePanNormOneHz(FTXSpecDetector));
  SetDisplayDetectorMode(TX_DISP_ID, 1, FTXWfDetector);
  SetDisplayAverageMode (TX_DISP_ID, 1, FTXWfAvgMode);
  SetDisplayNumAverage  (TX_DISP_ID, 1, WfAvgCount);
  SetDisplayAvBackmult  (TX_DISP_ID, 1, WfBackmult);
  SetDisplayNormOneHz   (TX_DISP_ID, 1, 0);
  SetDisplaySampleRate  (TX_DISP_ID, FTXSampleRate);
end;

procedure TWDSPEngine.ApplyPanAnalyzerSettings(PanId: Integer);
// Параметризованная копия ApplyRXAnalyzerSettings для analyzer'а доп. пана:
// rate пана + СВОЙ span-clip/zoom (ZoomFactor/PanSlider записи пана);
// FFT/детектор/усреднение пока общие с главным. Вызывать под FSliceLock.
var
  AnalyzerFFT, AvgCount, WfAvgCount, MaxW, Overlap, DisplayPixels, Disp: Integer;
  Backmult, WfBackmult: Double;
  EffFPS: Double;
  Bins, SpanClipL, SpanClipH, ZWidth: Integer;
  ZoomSlider: Double;
const
  KEEP_TIME_SEC = 0.10;
  ZOOM_LIMIT    = 100.0;  // как у главного (ApplyRXAnalyzerSettings)
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);

  // Span-clip зума пана — та же математика, что у главного (clp=0, зум только
  // клипует края FFT). Контроллер считает видимое окно той же формулой.
  Bins       := AnalyzerFFT;
  ZoomSlider := Log10(9.0 * EnsureRange(FPans[PanId].ZoomFactor, 0.0, 1.0) + 1.0);
  ZWidth     := Round(Bins * (1.0 - (1.0 - 1.0 / ZOOM_LIMIT) * ZoomSlider));
  ZWidth     := EnsureRange(ZWidth, 1, Bins);
  SpanClipL  := Floor(EnsureRange(FPans[PanId].PanSlider, 0.0, 1.0) * (Bins - ZWidth));
  SpanClipH  := Bins - ZWidth - SpanClipL;

  SetAnalyzer(Disp, 2, 1, 1, @FFlp[0], AnalyzerFFT, DISPLAY_BLOCK_SIZE,
              FWindowType, 14.0, Overlap, 0, SpanClipL, SpanClipH, 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;

// ---------------------------------------------------------------------------
// Лупа маяка (BCN_DISP_ID)
// ---------------------------------------------------------------------------

function TWDSPEngine.BeaconFFTFor(SpanHz: Double): Integer;
// FFT лупы выбираем ОТ ШИРИНЫ ОКНА, а не от настройки дисплея: цель — держать
// ~BINS_PER_PIXEL бинов на точку кадра, чтобы узкое окно оставалось детальным
// (в этом весь смысл лупы). Span-clip НЕ удешевляет FFT — она всё равно по всей
// полосе захвата, поэтому потолок жёсткий: 262144 (= max_size в
// XCreateAnalyzer), а частота кадров лупы понижена (BCN_ZOOM_FPS).
const
  BINS_PER_PIXEL = 2.0;
var
  Want: Double;
begin
  if SpanHz <= 0 then Exit(65536);
  Want := BINS_PER_PIXEL * BCN_ZOOM_PIXELS * FSampleRate / SpanHz;
  Result := 16384;
  while (Result < Want) and (Result < 262144) do
    Result := Result shl 1;
end;

procedure TWDSPEngine.ApplyBeaconAnalyzerSettings;
// Переармирует analyzer лупы под FBcnReqCenter/FBcnReqSpan. Вызывать ПОД
// FBcnLock при FBcnOpen=True.
//
// Окно вырезается тем же span-clip'ом, что и зум главного спектра, но здесь
// клипы считаются НЕ из зум-слайдера, а прямо из желаемых границ в Гц —
// анализатор умеет ассиметричный клип, поэтому окно можно поставить в любое
// место полосы захвата, а не только вокруг центра DDC.
var
  AvgCount, WfAvgCount, MaxW, Overlap: Integer;
  Backmult, WfBackmult: Double;
  Bins, ClipL, ClipH: Integer;
  BinWidth, BW, WantLo, WantHi: Double;
begin
  FBcnFFT := BeaconFFTFor(FBcnReqSpan);
  FBcnDispPos := 0;
  BinWidth := FSampleRate / FBcnFFT;
  Bins     := FBcnFFT;
  BW       := Bins * BinWidth;

  // Желаемые границы, зажатые в полосу захвата с запасом в один бин.
  WantLo := FBcnReqCenter - FBcnReqSpan / 2.0;
  WantHi := FBcnReqCenter + FBcnReqSpan / 2.0;
  if WantLo < -0.5 * BW + BinWidth then WantLo := -0.5 * BW + BinWidth;
  if WantHi >  0.5 * BW - BinWidth then WantHi :=  0.5 * BW - BinWidth;
  if WantHi - WantLo < 4 * BinWidth then WantHi := WantLo + 4 * BinWidth;

  ClipL := Floor((WantLo + 0.5 * BW + BinWidth / 2.0) / BinWidth);
  ClipH := Floor((0.5 * BW - BinWidth / 2.0 - WantHi) / BinWidth);
  ClipL := EnsureRange(ClipL, 0, Bins - 4);
  ClipH := EnsureRange(ClipH, 0, Bins - 4 - ClipL);
  // Фактическое окно — то же выражение, что в ApplyRXAnalyzerSettings.
  FBcnLowHz  := -(0.5 * BW - ClipL * BinWidth + BinWidth / 2.0);
  FBcnHighHz :=  (0.5 * BW - ClipH * BinWidth - BinWidth / 2.0);

  // Кадры лупы считаем реже главного дисплея: FFT здесь крупная (до 262144) и
  // идёт по ВСЕЙ полосе захвата, а для наведения на маяк 15 кадров/с с запасом.
  // На 60 fps это была бы полноценная загрузка ядра ради картинки, в которой
  // ничего не меняется.
  Overlap := Max(0, Ceil(FBcnFFT - FSampleRate / BCN_ZOOM_FPS));
  MaxW := FBcnFFT + Round(Min(0.10 * FSampleRate, 0.10 * FBcnFFT * BCN_ZOOM_FPS));
  AverageTimeToParams(FSpecAvgMode, FSpecAvgTimeMS, AvgCount, Backmult);
  AverageTimeToParams(FWfAvgMode,   FWfAvgTimeMS,   WfAvgCount, WfBackmult);
  FBcnPixCount := BCN_ZOOM_PIXELS;

  SetAnalyzer(BCN_DISP_ID, 2, 1, 1, @FFlp[0], FBcnFFT, DISPLAY_BLOCK_SIZE,
              FWindowType, 14.0, Overlap, 0, ClipL, ClipH, BCN_ZOOM_PIXELS,
              1, 0, 0.0, 0.0, MaxW);
  SetDisplayDetectorMode(BCN_DISP_ID, 0, FSpecDetector);
  SetDisplayAverageMode (BCN_DISP_ID, 0, FSpecAvgMode);
  SetDisplayNumAverage  (BCN_DISP_ID, 0, AvgCount);
  SetDisplayAvBackmult  (BCN_DISP_ID, 0, Backmult);
  SetDisplayNormOneHz   (BCN_DISP_ID, 0, UsePanNormOneHz(FSpecDetector));
  SetDisplayDetectorMode(BCN_DISP_ID, 1, FWfDetector);
  SetDisplayAverageMode (BCN_DISP_ID, 1, FWfAvgMode);
  SetDisplayNumAverage  (BCN_DISP_ID, 1, WfAvgCount);
  SetDisplayAvBackmult  (BCN_DISP_ID, 1, WfBackmult);
  SetDisplayNormOneHz   (BCN_DISP_ID, 1, 0);
  SetDisplaySampleRate  (BCN_DISP_ID, FSampleRate);
end;

procedure TWDSPEngine.CloseBeaconAnalyzer;
begin
  FBcnLock.Enter;
  try
    if not FBcnOpen then Exit;
    FBcnOpen := False;      // DSP/display-потоки перестают трогать disp
    DestroyAnalyzer(BCN_DISP_ID);
    FBcnSpecFresh := False;
    FBcnWfFresh   := False;
    FBcnPixCount  := 0;
    FBcnDispPos   := 0;
  finally
    FBcnLock.Leave;
  end;
end;

procedure TWDSPEngine.RearmBeaconAnalyzer;
// Полный переарм лупы — после смены sample rate: и окно, и выбор FFT считаются
// от FSampleRate.
begin
  if not FBcnOpen then Exit;
  FBcnLock.Enter;
  try
    if FBcnOpen then ApplyBeaconAnalyzerSettings;
  finally
    FBcnLock.Leave;
  end;
end;

procedure TWDSPEngine.ReapplyBeaconDisplayModes;
// Детектор/усреднение с главного дисплея на лупу БЕЗ SetAnalyzer: переарм
// сбросил бы FFT-историю (на 256к бинах это ~0.5с замершего кадра), а сменой
// детектора/усреднения окно лупы не меняется.
var
  AvgCount, WfAvgCount: Integer;
  Backmult, WfBackmult: Double;
begin
  if not FBcnOpen then Exit;
  FBcnLock.Enter;
  try
    if not FBcnOpen then Exit;
    AverageTimeToParams(FSpecAvgMode, FSpecAvgTimeMS, AvgCount, Backmult);
    AverageTimeToParams(FWfAvgMode,   FWfAvgTimeMS,   WfAvgCount, WfBackmult);
    SetDisplayDetectorMode(BCN_DISP_ID, 0, FSpecDetector);
    SetDisplayAverageMode (BCN_DISP_ID, 0, FSpecAvgMode);
    SetDisplayNumAverage  (BCN_DISP_ID, 0, AvgCount);
    SetDisplayAvBackmult  (BCN_DISP_ID, 0, Backmult);
    SetDisplayNormOneHz   (BCN_DISP_ID, 0, UsePanNormOneHz(FSpecDetector));
    SetDisplayDetectorMode(BCN_DISP_ID, 1, FWfDetector);
    SetDisplayAverageMode (BCN_DISP_ID, 1, FWfAvgMode);
    SetDisplayNumAverage  (BCN_DISP_ID, 1, WfAvgCount);
    SetDisplayAvBackmult  (BCN_DISP_ID, 1, WfBackmult);
  finally
    FBcnLock.Leave;
  end;
end;

procedure TWDSPEngine.SetBeaconZoom(AEnabled: Boolean; ACenterHz, ASpanHz: Double);
var
  Success: Integer;
  SameWindow: Boolean;
begin
  if (not AEnabled) or (not FInitialized) or (not FAnalyzerOpen) then
  begin
    CloseBeaconAnalyzer;
    Exit;
  end;

  ASpanHz := EnsureRange(ASpanHz, 200.0, FSampleRate);
  ACenterHz := EnsureRange(ACenterHz, -FSampleRate / 2.0, FSampleRate / 2.0);

  FBcnLock.Enter;
  try
    // Переарм сбрасывает историю FFT (кадр «замирает» на время наполнения) —
    // на неизменившемся окне не дёргаем, лупа зовётся из таймера UI.
    SameWindow := FBcnOpen and
      (Abs(ACenterHz - FBcnReqCenter) < 0.5) and
      (Abs(ASpanHz - FBcnReqSpan) < 0.5);
    FBcnReqCenter := ACenterHz;
    FBcnReqSpan   := ASpanHz;
    if SameWindow then Exit;

    if not FBcnOpen then
    begin
      Success := -1;
      XCreateAnalyzer(BCN_DISP_ID, @Success, 262144, 1, 1, nil);
      if Success <> 0 then
      begin
        FLastError := Format('XCreateAnalyzer(BCN) failed: %d', [Success]);
        Exit;       // лупа не поднялась — окно маяка просто останется пустым
      end;
      FBcnOpen := True;
    end;
    ApplyBeaconAnalyzerSettings;
  finally
    FBcnLock.Leave;
  end;
end;

function TWDSPEngine.GetBeaconZoomSpectrum(var Pixels: array of Single;
  out Count: Integer; out LowHz, HighHz: Double): Boolean;
var N: Integer;
begin
  Result := False;
  Count  := 0;
  LowHz  := 0;
  HighHz := 0;
  if not FBcnOpen then Exit;
  FBcnLock.Enter;
  try
    if not FBcnOpen then Exit;
    LowHz  := FBcnLowHz;
    HighHz := FBcnHighHz;
    N := Min(FBcnPixCount, Length(Pixels));
    if N <= 0 then Exit;
    Move(FBcnSpecPix[0], Pixels[0], N * SizeOf(Single));
    Count  := N;
    Result := FBcnSpecFresh;
    FBcnSpecFresh := False;
  finally
    FBcnLock.Leave;
  end;
end;

function TWDSPEngine.GetBeaconZoomWaterfall(var Pixels: array of Single;
  out Count: Integer): Boolean;
var N: Integer;
begin
  Result := False;
  Count  := 0;
  if not FBcnOpen then Exit;
  FBcnLock.Enter;
  try
    if not FBcnOpen then Exit;
    N := Min(FBcnPixCount, Length(Pixels));
    if N <= 0 then Exit;
    Move(FBcnWfPix[0], Pixels[0], N * SizeOf(Single));
    Count  := N;
    Result := FBcnWfFresh;
    FBcnWfFresh := False;
  finally
    FBcnLock.Leave;
  end;
end;

procedure TWDSPEngine.ApplyAnalyzerSettings;
// Совместимый вход: применяет настройки и к RX, и к TX analyzer'у, и к панам.
begin
  ApplyRXAnalyzerSettings;
  ApplyTXAnalyzerSettings;
  ReapplyPanDisplaySettings;
  ReapplyBeaconDisplayModes;
end;

procedure TWDSPEngine.SetZoomPan(AZoom, APan: Double);
// Задаёт зум/пан и переармирует RX-анализатор (SetAnalyzer внутри WDSP
// защищён собственной критической секцией, дисплей-поток не мешает).
var
  NZ, NP: Double;
begin
  NZ := EnsureRange(AZoom, 0.0, 1.0);
  NP := EnsureRange(APan, 0.0, 1.0);
  // Без изменений — выходим БЕЗ переарма: SetAnalyzer сбрасывает FFT-историю,
  // и спектр/водопад замирают на время наполнения окна (~0.7с @ 192к/131072).
  // RestoreBand зовёт SetZoomPan безусловно — это било по КАЖДОЙ смене
  // диапазона, даже когда зум обоих бандов одинаковый (обычно 0).
  if (Abs(NZ - FZoomFactor) < 1E-9) and (Abs(NP - FPanSlider) < 1E-9) then Exit;
  FZoomFactor := NZ;
  FPanSlider  := NP;
  if FAnalyzerOpen then
    ApplyRXAnalyzerSettings;
end;

procedure TWDSPEngine.CloseAnalyzer;
var p: Integer;
begin
  if not FAnalyzerOpen then Exit;
  if Assigned(FDisplayThread) then
  begin
    FDisplayThread.Terminate;
    FDisplayThread.WaitFor;
    FreeAndNil(FDisplayThread);
  end;
  DestroyAnalyzer(RX_DISP_ID);
  DestroyAnalyzer(TX_DISP_ID);
  // Лупа маяка: не пересоздаётся в OpenAnalyzer — окно маяка запросит её сама
  // (SetBeaconZoom зовётся с таймера формы).
  CloseBeaconAnalyzer;
  // Анализаторы доп. панов (записи FPans сохраняются — пересоздание в OpenAnalyzer).
  FSliceLock.Enter;
  try
    for p := 1 to MAX_PANS - 1 do
      DestroyPanAnalyzer(p);
  finally
    FSliceLock.Leave;
  end;
  FAnalyzerOpen := False;
end;

// ---------------------------------------------------------------------------
// Open / Close
// ---------------------------------------------------------------------------

function TWDSPEngine.Open: Boolean;
var
  DllPath: string;
  i: Integer;
begin
  Result := False;
  FLastError := '';
  if FInitialized then Exit;

  if not Assigned(@OpenChannel) or not Assigned(@XCreateAnalyzer) or
     not Assigned(@SetAnalyzer) then
  begin
    FInitialized := False;
    DllPath := IncludeTrailingPathDelimiter(ExtractFilePath(ParamStr(0))) + WDSP_LIB;
    if FileExists(DllPath) then
      FLastError := Format('Symbols from %s are not resolved. Check architecture and library version (need WDSP with analyzer API).', [DllPath])
    else
      FLastError := Format('WDSP library not found: %s', [DllPath]);
    Exit;
  end;

  try
    // Анализатор открывается перед DSP-каналом.
    OpenAnalyzer;
    if not FAnalyzerOpen then
    begin
      FLastError := 'XCreateAnalyzer failed (WDSP init failed).';
      Exit;
    end;

    OpenMainRXChannel;
    // TXA только что создан узким; забываем «широкую» геометрию прошлой
    // сессии (RX-половину сбрасывает OpenMainRXChannel).
    FTXDSPSize := FAudioBufSize;

    // TXA: mic @ FAudioRate → IQ @ FTXSampleRate (фиксированный DUC rate, не зависит от RX).
    // out_size = FAudioBufSize * FTXSampleRate/FAudioRate = FTXOutBufSize (4096 @ 192k tx).
    // FTXOut уже имеет размер FTXOutBufSize*2.
    OpenChannel(
      TXA_CHAN,
      FAudioBufSize,
      FAudioBufSize,
      FAudioRate,
      FAudioRate,
      FTXSampleRate,
      1,
      0,
      0.010, 0.010, 0.010, 0.010,
      1     // bfo=1: fexchange0 БЛОКИРУЕТСЯ до готовности выходного блока (как
            // bfo=0 давал async-чтение кольца r2 со сдвигом outidx →
            // изредка фазовый разрыв PostGen-тона (всплеск на водопаде + щелчок).
    );

    FTXMode := FMode;
    SetTXAPanelRun(TXA_CHAN, 1);
    // PatchPanel: 1=Q, 2=I. Mic подаётся в I (FTXIn[i*2]), Q=0 — выбираем I.
    SetTXAPanelSelect(TXA_CHAN, 2);

    FInitialized := True;

    // Оба канала созданы — только теперь можно поднять dsp_rate под WFM.
    // SetMode переставит rate, режимы обоих каналов и параметры wfmd/wfmmod;
    // SetFilter пересчитает полосу уже под новый dsp_rate.
    if FMode = MODE_WFM then
    begin
      SetMode(FMode);           // внутри: ApplyRXModeSettings + ApplyTXMode
      SetFilter(FFilterLow, FFilterHigh);
    end;
    // Прокидываем все TX-параметры из FTX*-полей: фильтр, mic gain, EQ,
    // Leveler, ALC, Compressor, Phase Rotator, AM Carrier, FM, CTCSS.
    ApplyTXChainSettings;
    // Перевод TX-тракта в режим через единую точку (для WFM уже сделал SetMode).
    if FMode <> MODE_WFM then ApplyTXMode(FTXMode);
    ApplyNoiseFilterState;

    // Переоткрываем WDSP-каналы активных слайсов (например после ChangeSampleRate,
    // где Close закрыл их, но записи FSlices сохранились).
    FSliceLock.Enter;
    try
      for i := 0 to MAX_SLICES - 1 do
        if FSlices[i].Active and not FSlices[i].Opened then
          OpenSliceChannel(i);
    finally
      FSliceLock.Leave;
    end;

    Result := True;

    // Запускаем DSP поток после успешной инициализации WDSP
    // Запускаем отдельный поток обработки IQ.
    FDSPThread := TDSPThread.Create(Self);
    FDSPThread.Priority := tpHighest;  // DSP критический поток

  except
    on E: Exception do
    begin
      FLastError := E.ClassName + ': ' + E.Message;
      FInitialized := False;
      Result := False;
    end;
  end;
end;

procedure TWDSPEngine.Close;
var
  i: Integer;
begin
  // ★TX-DSP поток живёт весь сеанс (не пересоздаётся на T/R) и гасится ЗДЕСЬ —
  // ДО гейта FInitialized, а не после. Создаёт его SetTXRun, которому открытый
  // движок не нужен, поэтому на неоткрытом движке гейт ниже выпускал нас
  // раньше, чем поток останавливался: он переживал Free самого движка и
  // продолжал ходить по освобождённой памяти. Ловится это не там, где сделано:
  // следующий TThread.Create в процессе падал с AV (в стенде — на секции часов
  // после части «пейсинг», а раньше на третьем TRadioController в FreeEngines).
  // Остановка всё так же идёт прежде закрытия WDSP-каналов — поток зовёт
  // fexchange0 на TXA_CHAN.
  FTXActive := False;
  if Assigned(FTXThread) then
  begin
    FTXThread.Terminate;
    RTLEventSetEvent(FTXMicSem);
    FTXThread.WaitFor;
    FreeAndNil(FTXThread);
  end;

  if not FInitialized then Exit;

  // PureSignal: снять подачу feedback ДО закрытия TXA — pscc зовётся из
  // сетевого потока напрямую и не должен попасть в закрываемый канал.
  FPSArmed := False;

  // Останавливаем DSP поток перед закрытием WDSP каналов
  if Assigned(FDSPThread) then
  begin
    FDSPThread.Terminate;
    RTLEventSetEvent(FQueueSem);   // разбудить чтобы увидел Terminated
    FDSPThread.WaitFor;
    FreeAndNil(FDSPThread);
  end;

  // Закрываем WDSP-каналы слайсов (записи FSlices сохраняем — переоткроются в Open).
  FSliceLock.Enter;
  try
    for i := 0 to MAX_SLICES - 1 do
      if FSlices[i].Opened then CloseSliceChannel(i);
  finally
    FSliceLock.Leave;
  end;

  CloseAnalyzer;
  CloseMainRXChannel;
  CloseChannel(TXA_CHAN);
  FInitialized := False;
end;

procedure TWDSPEngine.OpenMainRXChannel;
// Открывает RXA_CHAN под текущие FBufSize/FSampleRate и восстанавливает его
// состояние из полей движка. Канал создаётся «узким» (dsp_rate = FAudioRate);
// широкий WFM поднимает вызывающий через SetMode (ApplyDSPRate).
begin
  OpenChannel(
    RXA_CHAN,
    FBufSize,
    FAudioBufSize,
    FSampleRate,
    FAudioRate,
    FAudioRate,
    0,
    0,             // state=0: создать но не запускать
    0.010, 0.010, 0.010, 0.010,
    0
  );
  FRXDSPRate := FAudioRate;

  create_anbEXT(RXA_CHAN, 1, FBufSize, FSampleRate, 0.0001, 0.0001, 0.0001, 0.05, 20);
  create_nobEXT(RXA_CHAN, 1, 0, FBufSize, FSampleRate, 0.0001, 0.0001, 0.0001, 0.05, 20);

  SetRXABandpassWindow(RXA_CHAN, 1);
  SetRXAMode(RXA_CHAN, ModeToWDSP(FMode));
  SetRXAShiftRun(RXA_CHAN, 1);
  PushShift(RXA_CHAN, FShiftHz, FMode);
  PushPassband(RXA_CHAN, FFilterLow, FFilterHigh, FMode, FRXDSPRate);
  SetAGC(FAGCMode, 50.0);
  SetRXAPanelGain1(RXA_CHAN, FVolume);
  SetRXAPanelSelect(RXA_CHAN, 3);
  SetRXAPanelRun(RXA_CHAN, 1);
  SetChannelState(RXA_CHAN, 1, 0);
end;

procedure TWDSPEngine.CloseMainRXChannel;
// dmode=0: канал к этому моменту гарантированно idle (Close — DSP-поток уже
// мёртв; ChangeSampleRate — FMainGated+барьер), а dmode=1 на простаивающем
// канале зря ждёт флаш ~130мс.
begin
  SetChannelState(RXA_CHAN, 0, 0);
  destroy_anbEXT(RXA_CHAN);
  destroy_nobEXT(RXA_CHAN);
  CloseChannel(RXA_CHAN);
end;

procedure TWDSPEngine.ChangeSampleRate(NewRate: Integer);
// Смена rate главного тракта ХИРУРГИЧЕСКИ: DSP-поток, display-поток,
// анализаторы и паны N живут — закрываем/переоткрываем только RXA_CHAN и
// слайс-каналы пана 0. Пакеты панов идут через общую очередь без пауз:
// DSP-поток лишь дропает main-пакеты, пока стоит FMainGated. (Раньше здесь
// был полный Close/Open движка — паны замирали на всё время пересборки.)
var
  i: Integer;
begin
  if NewRate = FSampleRate then Exit;

  if not FInitialized then
  begin
    // Движок не открыт: только запоминаем параметры — Open создаст всё сам.
    FSampleRate := NewRate;
    FBufSize    := FAudioBufSize * NewRate div FAudioRate;
    SetLength(FRXIn,    FBufSize * 2);
    SetLength(FRXOut,   FBufSize * 2);
    SetLength(FSpecBuf, FBufSize * 2);
    SetLength(FRXAccI,  FBufSize);
    SetLength(FRXAccQ,  FBufSize);
    FRXAccPos := 0;
    Exit;
  end;

  // Гейт: DSP-поток перестаёт брать main-пакеты (дропает их из очереди);
  // барьер Enter/Leave гарантирует, что текущий PushIQItemToDSP
  // (fexchange0 на RXA_CHAN) завершён.
  FMainGated := True;
  FMainLock.Enter;
  FMainLock.Leave;
  try
    try
      // Слайсы пана 0 открыты с InRate = главный rate — закрываем на пересборку.
      FSliceLock.Enter;
      try
        for i := 0 to MAX_SLICES - 1 do
          if FSlices[i].Opened and (FSlices[i].PanId = 0) then
            CloseSliceChannel(i);
      finally
        FSliceLock.Leave;
      end;
      CloseMainRXChannel;

      // Новый rate/буферы главного тракта.
      FSampleRate := NewRate;
      FBufSize    := FAudioBufSize * NewRate div FAudioRate;
      SetLength(FRXIn,    FBufSize * 2);
      SetLength(FRXOut,   FBufSize * 2);
      SetLength(FSpecBuf, FBufSize * 2);
      SetLength(FRXAccI,  FBufSize);
      SetLength(FRXAccQ,  FBufSize);
      FRXAccPos := 0;
      FDispPos  := 0;
      AllocSliceBuffers;

      // Переоткрываем главный канал и слайсы пана 0 (состояние из полей/записей).
      OpenMainRXChannel;
      if FMode = MODE_WFM then
      begin
        // Канал создан узким — SetMode поднимет dsp_rate под новый FSampleRate,
        // SetFilter пересчитает полосу (как в Open).
        SetMode(FMode);
        SetFilter(FFilterLow, FFilterHigh);
      end;
      ApplyNoiseFilterState;

      FSliceLock.Enter;
      try
        for i := 0 to MAX_SLICES - 1 do
          if FSlices[i].Active and (not FSlices[i].Opened)
             and (FSlices[i].PanId = 0) then
            OpenSliceChannel(i);
      finally
        FSliceLock.Leave;
      end;

      // RX-анализатор не разрушался — переармируем под новый rate. FFT-история
      // главного дисплея сбрасывается (при смене rate это неизбежно), паны
      // не затронуты.
      if FAnalyzerOpen then ApplyRXAnalyzerSettings;
      // Лупа маяка тоже считает окно и FFT от rate — переармируем.
      RearmBeaconAnalyzer;

      // Beacon-декодер пересчитывает децимацию/RRC под новый IQ-rate.
      if FBeaconDec <> nil then FBeaconDec.Configure(FSampleRate);
    except
      on E: Exception do
      begin
        // Главный тракт в неопределённом состоянии — гасим FInitialized:
        // ProcessRXBlock/слайсы/паны выходят по нему, контроллер увидит
        // по Initialized и сбросит FWDSPReady.
        FLastError   := E.ClassName + ': ' + E.Message;
        FInitialized := False;
      end;
    end;
  finally
    FMainGated := False;
  end;
end;

procedure TWDSPEngine.SetBeaconDecoder(D: TBeaconDecoder);
begin
  FBeaconDec := D;
  if (D <> nil) and (FSampleRate > 0) then D.Configure(FSampleRate);
end;

procedure TWDSPEngine.SetIQTap(T: TIQTapEvent);
// Поток контроллера. Под локом: снятие обязано ДОЖДАТЬСЯ выхода DSP-потока из
// вызова — сразу после нас владельца тапа освобождают.
begin
  FIQTapLock.Enter;
  try
    FIQTap   := T;
    FIQTapOn := Assigned(T);
  finally
    FIQTapLock.Leave;
  end;
end;


procedure TWDSPEngine.PushDDCPacket(const Buf: array of Byte;
  DataOffset: Integer; IQPairs: Integer);
// Вызывается из СЕТЕВОГО потока — только кладём в очередь и возвращаемся немедленно
// DSP поток разбудится семафором и займётся обработкой
var
  NextHead: Integer;
  Item:     ^TIQQueueItem;
  DataBytes: Integer;
begin
  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   := 0;

  FQueueHead := NextHead;
  RTLEventSetEvent(FQueueSem);  // будим DSP-поток
end;

procedure TWDSPEngine.PushPanIQ(PanId: Integer; const Buf: array of Byte;
  DataOffset: Integer; IQPairs: Integer);
// Сетевой поток: пакет пан-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;
        // Тап IQ пана — как у главного тракта, один вызов на блок. Он идёт
        // ПОД FSliceLock (весь разбор пакета пана здесь), поэтому обработчик
        // обязан только скопировать данные и вернуться: любое ожидание тут
        // остановит 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);
        except
          // Защита от падения libwdsp в потоке — пропускаем блок (как ProcessRXBlock)
        end;
      end;
    end;
  finally
    FSliceLock.Leave;
  end;
end;

procedure TWDSPEngine.FlushRX;
begin
  FQueueHead := FQueueTail;
  FRXAccPos  := 0;
  FDispPos   := 0;
  if Length(FRXAccI) > 0 then FillChar(FRXAccI[0], Length(FRXAccI) * SizeOf(Double), 0);
  if Length(FRXAccQ) > 0 then FillChar(FRXAccQ[0], Length(FRXAccQ) * SizeOf(Double), 0);
  if Length(FRXIn)   > 0 then FillChar(FRXIn[0],   Length(FRXIn)   * SizeOf(Double), 0);
  if Length(FRXOut)  > 0 then FillChar(FRXOut[0],  Length(FRXOut)  * SizeOf(Double), 0);
end;

procedure TWDSPEngine.BeginPostTXMute(DurationMs: Integer);
begin
  if DurationMs <= 0 then
    FPostTXMuteUntil := 0
  else
    FPostTXMuteUntil := GetTickCount64 + QWord(DurationMs);
end;

procedure TWDSPEngine.PushIQItemToDSP(const Item: TIQQueueItem);
// Вызывается из DSP потока — декодирует IQ и накапливает до FBufSize.
// В non-DUP TX (FTXActive and not FKeepRXDuringTX) полностью пропускаем
// IQ-пакет: не кормим RX-анализатор и не накапливаем для fexchange0.
// Иначе TX leakage из DDC оседает в RXA pipeline / RX_DISP_ID FFT-истории
// и звучит/виден на водопаде первые секунды после возврата на RX.
var
  i, Pos: Integer;
  IR, QR: LongInt;
  DI, DQ: Double;        // сэмпл во float — считаем ОДИН раз на всех трёх потребителей
  AnOpen: Boolean;       // FAnalyzerOpen и FBeaconDec внутри цикла не меняются:
  Bec: TBeaconDecoder;   // держим локально, не перечитываем поле на каждый сэмпл
  P: PByte;              // бегущий указатель вместо Pos := i * 6
const
  SCALE = 1.0 / 8388608.0;
begin
  if FTXActive and not FKeepRXDuringTX then Exit;
  // Post-TX hold: дропаем входящие RX-IQ пакеты пока активно окно мьюта.
  // Это закрывает ОБА канала сразу — и водопад/спектр (анализатор не
  // кормится), и аудио (RXA не запускается, FOnAudio не вызывается).
  // Радио продолжает дотравливать FPGA TX-buffer + PA slew-down первые
  // ~50–200мс после MOX-off; этот хвост попадает в RX1 как leak. Дропаем
  // его на входе — single-source-of-truth.
  if (FPostTXMuteUntil <> 0) and (GetTickCount64 < FPostTXMuteUntil) then Exit;
  AnOpen := FAnalyzerOpen;
  Bec    := FBeaconDec;
  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;

    // FeedDisplaySample развёрнут сюда: вызов метода на каждый сэмпл (576к/с)
    // стоил дороже самой работы — двух записей в буфер.
    if AnOpen then
    begin
      FDispBuf[FDispPos * 2]     := DI;
      FDispBuf[FDispPos * 2 + 1] := DQ;
      Inc(FDispPos);
      if FDispPos >= DISPLAY_BLOCK_SIZE then
      begin
        Spectrum0(1, RX_DISP_ID, 0, 0, @FDispBuf[0]);
        FDispPos := 0;
      end;
    end;

    // Лупа маяка — второй analyzer на том же потоке сэмплов. Живёт только пока
    // окно маяка открыто, поэтому в обычном режиме это одна проверка Boolean.
    if FBcnOpen then
    begin
      FBcnDispBuf[FBcnDispPos * 2]     := DI;
      FBcnDispBuf[FBcnDispPos * 2 + 1] := DQ;
      Inc(FBcnDispPos);
      if FBcnDispPos >= DISPLAY_BLOCK_SIZE then
      begin
        FBcnDispPos := 0;
        FBcnLock.Enter;      // UI-поток может в этот момент рвать analyzer
        try
          if FBcnOpen then
            Spectrum0(1, BCN_DISP_ID, 0, 0, @FBcnDispBuf[0]);
        finally
          FBcnLock.Leave;
        end;
      end;
    end;

    if Bec <> nil then Bec.Feed(DI, DQ);

    FRXAccI[FRXAccPos] := DI;
    FRXAccQ[FRXAccPos] := DQ;
    Inc(FRXAccPos);

    if FRXAccPos >= FBufSize then
    begin
      // Тап IQ наружу — по накопленному блоку и ДО обработки: fexchange0
      // забирает аккумулятор как вход и не портит его, но полагаться на это
      // незачем, а один вызов на блок вместо вызова на сэмпл экономит всё.
      // Под 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;
end;

procedure TWDSPEngine.FeedTXDisplaySample(const I, Q: Double);
begin
  if not FAnalyzerOpen then Exit;

  FTXDispBuf[FTXDispPos * 2]     := I;
  FTXDispBuf[FTXDispPos * 2 + 1] := Q;
  Inc(FTXDispPos);

  if FTXDispPos >= DISPLAY_BLOCK_SIZE then
  begin
    Spectrum0(1, TX_DISP_ID, 0, 0, @FTXDispBuf[0]);
    FTXDispPos := 0;
  end;
end;

procedure TWDSPEngine.ProcessRXBlock;
var
  i:    Integer;
  Err:  Integer;
  // OutL/OutR теперь поля класса — не аллоцируем каждый раз
begin
  FRXAccPos := 0;
  if not FInitialized then Exit;

  try
    // Упаковываем в interleaved буфер для fexchange0
    for i := 0 to FBufSize - 1 do
    begin
      FRXIn[i * 2]     := FRXAccI[i];
      FRXIn[i * 2 + 1] := FRXAccQ[i];
    end;

    // DSP обработка
    Err := 0;
    fexchange0(RXA_CHAN, @FRXIn[0], @FRXOut[0], @Err);

    // S-meter: читаем ОДИН раз в DSP-колбэке и кешируем в FSMeter.
    // GetRXAMeter сбрасывает аккумулятор после вызова, поэтому второй
    // вызов вернёт 0/-∞ — отсюда "падение до нуля". Только здесь!
    FSMeter := GetRXAMeter(RXA_CHAN, RXA_S_AV);

    // Аудио колбэк — FAudioBufSize сэмплов @ FAudioRate (после децимации 4:1).
    // При TX RX-аудио глушим в режиме non-duplex MOX,
    // иначе оператор слышит свой эфир через RX-цепь и получает feedback.
    // Дополнительный гейт FPostTXMuteUntil закрывает «хвост» TX leakage
    // на Windows (radio FPGA TX-buffer + PA slew-down). Cheap-path: проверка
    // через unsigned-сравнение, GetTickCount64 не зовётся когда дедлайн = 0.
    if (FPostTXMuteUntil <> 0) and (GetTickCount64 >= FPostTXMuteUntil) then
      FPostTXMuteUntil := 0;
    // Full-duplex self-monitor (QO-100): при FMonitorRXDuringTX не глушим RX-аудио
    // на TX — слышим свой downlink со спутника. Иначе (по умолчанию) глушим, как
    // FPostTXMuteUntil-хвост уважаем и при non-duplex MOX.
    if (not FTXActive or (FKeepRXDuringTX and FMonitorRXDuringTX))
       and (FPostTXMuteUntil = 0) then
    begin
      // Decoder tap is independent from speaker mute and volume. This is the
      // actual demodulator output; digital sync must survive MUTE/volume=0.
      if Assigned(FOnDemodAudio) then
      begin
        for i := 0 to FAudioBufSize - 1 do
        begin
          FOutL[i] := FRXOut[i * 2];
          FOutR[i] := FRXOut[i * 2 + 1];
        end;
        FOnDemodAudio(FOutL, FOutR, FAudioBufSize);
      end;
      if Assigned(FOnAudio) and not FMuted then
      begin
        for i := 0 to FAudioBufSize - 1 do
        begin
          FOutL[i] := FRXOut[i * 2]     * FVolume;
          FOutR[i] := FRXOut[i * 2 + 1] * FVolume;
        end;
        FOnAudio(FOutL, FOutR, FAudioBufSize);
      end;
    end;

    // Софтовые слайсы: fan-out того же IQ-блока на доп. WDSP-каналы.
    ProcessSlices;

  except
    // Защита от падения libwdsp в потоке — просто пропускаем блок
  end;
end;

// ---------------------------------------------------------------------------
// Мультислайсы (софт-fan-out)
// ---------------------------------------------------------------------------

procedure TWDSPEngine.AllocSliceBuffers;
// FSliceIn/Out должны вмещать наибольший входной блок среди главного тракта
// и активных панов (пан на 768к при главном 192к имеет блок больше FBufSize).
var
  Need, p: Integer;
begin
  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;

function TWDSPEngine.FindSliceIdx(Id: Integer): Integer;
var i: Integer;
begin
  Result := -1;
  for i := 0 to MAX_SLICES - 1 do
    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);
begin
  SetRXAAGCMode(Chan, Ord(Mode));
  SetRXAAGCSlope(Chan, FAGCSlope);
  SetRXAAGCTop(Chan, FAGCTop);
  case Mode of
    agcOff:
      SetRXAAGCFixed(Chan, FixedGain);
    agcLong: begin
      SetRXAAGCAttack(Chan, 2); SetRXAAGCHang(Chan, 2000);
      SetRXAAGCDecay(Chan, 2000); SetRXAAGCHangThreshold(Chan, FAGCHangThreshold);
    end;
    agcSlow: begin
      SetRXAAGCAttack(Chan, 2); SetRXAAGCHang(Chan, 1000);
      SetRXAAGCDecay(Chan, 500); SetRXAAGCHangThreshold(Chan, FAGCHangThreshold);
    end;
    agcMedium: begin
      SetRXAAGCAttack(Chan, 2); SetRXAAGCHang(Chan, 0);
      SetRXAAGCDecay(Chan, 250); SetRXAAGCHangThreshold(Chan, 100);
    end;
    agcFast: begin
      SetRXAAGCAttack(Chan, 2); SetRXAAGCHang(Chan, 0);
      SetRXAAGCDecay(Chan, 50); SetRXAAGCHangThreshold(Chan, 100);
    end;
  end;
end;

// Открывает WDSP-канал слайса с той же геометрией, что RXA_CHAN. Вызывать
// под FSliceLock и только при FInitialized.
procedure TWDSPEngine.OpenSliceChannel(Idx: 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 := SliceDSPRateFor(FSlices[Idx].Mode, InRate);
  DSPSize := FAudioBufSize * (DSPRate div 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, 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));
  ApplyRXModeSettings(Chan, FSlices[Idx].Mode);
  SetRXAShiftRun(Chan, 1);
  PushShift(Chan, FSlices[Idx].ShiftHz, FSlices[Idx].Mode);
  PushPassband(Chan, FSlices[Idx].FilterLo, FSlices[Idx].FilterHi,
               FSlices[Idx].Mode, DSPRate);
  ApplyAGCToChan(Chan, FSlices[Idx].AGCMode, 50.0);
  // NR/NB/SNB/ANF из сохранённого состояния слайса.
  ApplyNRToChan(Chan, FSlices[Idx].NRMode);
  ApplyNBToChan(Chan, FSlices[Idx].NBMode);
  ApplySNBToChan(Chan, FSlices[Idx].SNBOn);
  ApplyANFToChan(Chan, FSlices[Idx].ANFOn);
  ApplyFMSQToChan(Chan, FSlices[Idx].FMSQOn and (FSlices[Idx].Mode = MODE_FM),
                  FSlices[Idx].FMSQLevel);
  // Decoder input must not disappear or change scale with speaker volume.
  if FSlices[Idx].Mode in [MODE_DMR, MODE_FMRAW] then
    SetRXAPanelGain1(Chan, 1.0)
  else
    SetRXAPanelGain1(Chan, FSlices[Idx].Volume);
  SetRXAPanelSelect(Chan, 3);
  SetRXAPanelRun(Chan, 1);
  SetChannelState(Chan, 1, 0);
  FSlices[Idx].Opened := True;
end;

procedure TWDSPEngine.CloseSliceChannel(Idx: Integer);
// dmode=0: fexchange0 слайс-каналов зовётся ТОЛЬКО под FSliceLock (ProcessSlices/
// PushPanItemToDSP), и все вызовы CloseSliceChannel — под тем же локом, т.е.
// канал заведомо idle. dmode=1 здесь зря ждал флаш ~130мс на канал — при смене
// rate главного эта пауза (под локом) замораживала обработку панов.
begin
  if not FSlices[Idx].Opened then Exit;
  SetChannelState(FSlices[Idx].Chan, 0, 0);
  destroy_anbEXT(FSlices[Idx].Chan);
  destroy_nobEXT(FSlices[Idx].Chan);
  CloseChannel(FSlices[Idx].Chan);
  FSlices[Idx].Opened := False;
end;

// Fan-out слайсов главного пана: вызывается из ProcessRXBlock (DSP-поток)
// после основного канала.
procedure TWDSPEngine.ProcessSlices;
begin
  if not Assigned(FOnSliceAudio) and not Assigned(FOnSliceDemodAudio) then Exit;
  FSliceLock.Enter;
  try
    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;
    // Muting a DMR speaker must not make its decoder lose synchronization.
    // ★То же и для тапов: RX_AUDIO по TCI снимается ДО громкости и мьюта, и
    // молчащий в комнате слайс обязан продолжать отдавать звук клиенту.
    if FSlices[s].Muted and not (FSlices[s].Mode in [MODE_DMR, MODE_FMRAW])
       and not FSlices[s].TapWanted 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);
    for k := 0 to FAudioBufSize - 1 do
    begin
      FSliceOutL[k] := FSliceOut[k * 2];
      FSliceOutR[k] := FSliceOut[k * 2 + 1];
    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;
      for k := 0 to FAudioBufSize - 1 do
      begin
        FSliceOutL[k] := FSliceOutL[k] * V;
        FSliceOutR[k] := FSliceOutR[k] * V;
      end;
      FOnSliceAudio(FSlices[s].Id, FSliceOutL, FSliceOutR, FAudioBufSize);
    end;
  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].ZoomFactor := 0.0;   // без зума
    FPans[PanId].PanSlider  := 0.5;
    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.SetPanRate(PanId, RateHz: Integer): Boolean;
var s: Integer;
begin
  Result := False;
  if (PanId < 1) or (PanId >= MAX_PANS) then Exit;
  // Те же требования кратности, что в AddPan.
  if (RateHz < FAudioRate) or (RateHz mod FAudioRate <> 0) then Exit;
  FSliceLock.Enter;
  try
    if not FPans[PanId].Active then Exit;
    if FPans[PanId].Rate = RateHz then Exit(True);
    // Анализатор держит старый rate — снести до пересбора аккумулятора.
    DestroyPanAnalyzer(PanId);
    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);
    FPans[PanId].AccPos  := 0;
    FPans[PanId].DispPos := 0;
    AllocSliceBuffers;                 // FSliceIn/Out под новый макс. блок
    if FAnalyzerOpen then CreatePanAnalyzer(PanId);
    // Слайсы пана: их каналы открыты с InRate/InSize пана — переоткрываем
    // (OpenSliceChannel восстанавливает mode/filter/AGC/DSP из записи слайса).
    for s := 0 to MAX_SLICES - 1 do
      if FSlices[s].Active and (FSlices[s].PanId = PanId) then
      begin
        CloseSliceChannel(s);
        OpenSliceChannel(s);
      end;
    Result := True;
  finally
    FSliceLock.Leave;
  end;
end;

procedure TWDSPEngine.SetPanZoomPan(PanId: Integer; AZoom, APan: Double);
// Переармирует анализатор пана под новый span-clip. No-op guard обязателен:
// SetAnalyzer сбрасывает FFT-историю (урок SetZoomPan главного, dae0c19).
var NZ, NP: Double;
begin
  if (PanId < 1) or (PanId >= MAX_PANS) then Exit;
  NZ := EnsureRange(AZoom, 0.0, 1.0);
  NP := EnsureRange(APan, 0.0, 1.0);
  FSliceLock.Enter;
  try
    if not FPans[PanId].Active then Exit;
    if (Abs(NZ - FPans[PanId].ZoomFactor) < 1E-9) and
       (Abs(NP - FPans[PanId].PanSlider) < 1E-9) then Exit;
    FPans[PanId].ZoomFactor := NZ;
    FPans[PanId].PanSlider  := NP;
    ApplyPanAnalyzerSettings(PanId);   // внутри гейт по DispOpen
  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; 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;
    if slot < 0 then Exit;                 // нет свободных слотов
    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;
    FSlices[slot].ShiftHz  := ShiftHz;
    FSlices[slot].Volume   := Volume;
    FSlices[slot].AGCMode  := AGC;
    FSlices[slot].Muted    := False;
    FSlices[slot].NRMode   := NR_MODE_OFF;
    FSlices[slot].NBMode   := NB_MODE_OFF;
    FSlices[slot].SNBOn    := False;
    FSlices[slot].ANFOn    := False;
    FSlices[slot].SMeter   := -130.0;
    FSlices[slot].Opened   := False;
    // Слушателей у нового слайса нет: их проставит владелец потоков (TCI),
    // когда клиент попросит RX_AUDIO именно с него.
    FSlices[slot].TapWanted := False;
    if FInitialized then OpenSliceChannel(slot);
    Result := True;
  finally
    FSliceLock.Leave;
  end;
end;

procedure TWDSPEngine.RemoveSlice(Id: Integer);
var idx: Integer;
begin
  FSliceLock.Enter;
  try
    idx := FindSliceIdx(Id);
    if idx < 0 then Exit;
    CloseSliceChannel(idx);
    FSlices[idx].Active := False;
  finally
    FSliceLock.Leave;
  end;
end;

procedure TWDSPEngine.SetSliceShift(Id: Integer; ShiftHz: Double);
var idx: Integer;
begin
  FSliceLock.Enter;
  try
    idx := FindSliceIdx(Id); if idx < 0 then Exit;
    FSlices[idx].ShiftHz := ShiftHz;
    if FSlices[idx].Opened then
      PushShift(FSlices[idx].Chan, ShiftHz, FSlices[idx].Mode);
  finally
    FSliceLock.Leave;
  end;
end;

procedure TWDSPEngine.SetSliceMode(Id, Mode: Integer);
var idx: Integer; Rerate: Boolean;
begin
  FSliceLock.Enter;
  try
    idx := FindSliceIdx(Id); if idx < 0 then Exit;
    // Уход в/из WFM меняет dsp_rate канала — проще пересоздать его целиком,
    // чем гонять SetDSPSamplerate: слайс всё равно перестраивает все фильтры.
    Rerate := FSlices[idx].Opened and
              (SliceDSPRateFor(Mode, SliceInRate(idx)) <>
               SliceDSPRateFor(FSlices[idx].Mode, SliceInRate(idx)));
    FSlices[idx].Mode := Mode;
    if Rerate then
    begin
      CloseSliceChannel(idx);
      OpenSliceChannel(idx);
    end
    else if FSlices[idx].Opened then
    begin
      SetRXAMode(FSlices[idx].Chan, ModeToWDSP(Mode));
      ApplyRXModeSettings(FSlices[idx].Chan, Mode);
      if Mode in [MODE_DMR, MODE_FMRAW] then
        SetRXAPanelGain1(FSlices[idx].Chan, 1.0)
      else
        SetRXAPanelGain1(FSlices[idx].Chan, FSlices[idx].Volume);
      // Ушли из FM — снять squelch (иначе слайс остаётся заглушенным, а
      // шумодава в не-FM режимах нет); вернулись в FM — восстановить выбор.
      ApplySliceFMSQ(idx);
    end;
    // Приход/уход телеграфа меняет CWLOOffset — гетеродин и полосу надо
    // переложить под новый режим, даже если канал не пересоздавали.
    if FSlices[idx].Opened then
    begin
      PushShift(FSlices[idx].Chan, FSlices[idx].ShiftHz, Mode);
      PushPassband(FSlices[idx].Chan, FSlices[idx].FilterLo, FSlices[idx].FilterHi,
                   Mode, SliceDSPRateFor(Mode, SliceInRate(idx)));
    end;
  finally
    FSliceLock.Leave;
  end;
end;

procedure TWDSPEngine.SetSliceFilter(Id, Low, High: Integer);
var idx, Rate: Integer;
begin
  FSliceLock.Enter;
  try
    idx := FindSliceIdx(Id); if idx < 0 then Exit;
    FSlices[idx].FilterLo := Low;
    FSlices[idx].FilterHi := High;
    if FSlices[idx].Opened then
    begin
      Rate := SliceDSPRateFor(FSlices[idx].Mode, SliceInRate(idx));
      PushPassband(FSlices[idx].Chan, Low, High, FSlices[idx].Mode, Rate);
    end;
  finally
    FSliceLock.Leave;
  end;
end;

procedure TWDSPEngine.SetSliceAGC(Id: Integer; Mode: TWDSPAGCMode);
var idx: Integer;
begin
  FSliceLock.Enter;
  try
    idx := FindSliceIdx(Id); if idx < 0 then Exit;
    FSlices[idx].AGCMode := Mode;
    if FSlices[idx].Opened then ApplyAGCToChan(FSlices[idx].Chan, Mode, 50.0);
  finally
    FSliceLock.Leave;
  end;
end;

procedure TWDSPEngine.SetSliceVolume(Id: Integer; Vol: Double);
var idx: Integer;
begin
  FSliceLock.Enter;
  try
    idx := FindSliceIdx(Id); if idx < 0 then Exit;
    FSlices[idx].Volume := Vol;
    if FSlices[idx].Opened and not (FSlices[idx].Mode in [MODE_DMR, MODE_FMRAW]) then
      SetRXAPanelGain1(FSlices[idx].Chan, Vol);
  finally
    FSliceLock.Leave;
  end;
end;

procedure TWDSPEngine.SetSliceTapWanted(Id: Integer; V: Boolean);
var idx: Integer;
begin
  FSliceLock.Enter;
  try
    idx := FindSliceIdx(Id); if idx < 0 then Exit;
    FSlices[idx].TapWanted := V;
  finally
    FSliceLock.Leave;
  end;
end;

procedure TWDSPEngine.SetSliceMute(Id: Integer; Mute: Boolean);
var idx: Integer;
begin
  FSliceLock.Enter;
  try
    idx := FindSliceIdx(Id); if idx < 0 then Exit;
    FSlices[idx].Muted := Mute;
  finally
    FSliceLock.Leave;
  end;
end;

procedure TWDSPEngine.SetSliceNRMode(Id, Mode: Integer);
var idx: Integer;
begin
  if Mode < NR_MODE_OFF then Mode := NR_MODE_OFF;
  if Mode > NR_MODE_4 then Mode := NR_MODE_4;
  FSliceLock.Enter;
  try
    idx := FindSliceIdx(Id); if idx < 0 then Exit;
    FSlices[idx].NRMode := Mode;
    if FSlices[idx].Opened then ApplyNRToChan(FSlices[idx].Chan, Mode);
  finally
    FSliceLock.Leave;
  end;
end;

procedure TWDSPEngine.SetSliceNBMode(Id, Mode: Integer);
var idx: Integer;
begin
  if Mode < NB_MODE_OFF then Mode := NB_MODE_OFF;
  if Mode > NB_MODE_2 then Mode := NB_MODE_2;
  FSliceLock.Enter;
  try
    idx := FindSliceIdx(Id); if idx < 0 then Exit;
    FSlices[idx].NBMode := Mode;
    if FSlices[idx].Opened then ApplyNBToChan(FSlices[idx].Chan, Mode);
  finally
    FSliceLock.Leave;
  end;
end;

procedure TWDSPEngine.SetSliceSNB(Id: Integer; On_: Boolean);
var idx: Integer;
begin
  FSliceLock.Enter;
  try
    idx := FindSliceIdx(Id); if idx < 0 then Exit;
    FSlices[idx].SNBOn := On_;
    if FSlices[idx].Opened then ApplySNBToChan(FSlices[idx].Chan, On_);
  finally
    FSliceLock.Leave;
  end;
end;

procedure TWDSPEngine.SetSliceANF(Id: Integer; On_: Boolean);
var idx: Integer;
begin
  FSliceLock.Enter;
  try
    idx := FindSliceIdx(Id); if idx < 0 then Exit;
    FSlices[idx].ANFOn := On_;
    if FSlices[idx].Opened then ApplyANFToChan(FSlices[idx].Chan, On_);
  finally
    FSliceLock.Leave;
  end;
end;

procedure TWDSPEngine.SetSliceFMSquelch(Id: Integer; Enable: Boolean; Level: Integer);
var idx: Integer;
begin
  FSliceLock.Enter;
  try
    idx := FindSliceIdx(Id); if idx < 0 then Exit;
    FSlices[idx].FMSQOn    := Enable;
    FSlices[idx].FMSQLevel := Level;
    ApplySliceFMSQ(idx);
  finally
    FSliceLock.Leave;
  end;
end;

// Squelch слайса с гейтом по режиму — как у главного приёмника
// (ApplyModeFilter: включён только в MODE_FM). Блок fmsq WDSP стоит в тракте
// и глушит звук по шумовому выходу FM-дискриминатора: оставшись включённым
// после ухода из FM, он затыкает слайс намертво, а органов управления
// шумодавом в не-FM режимах нет. Пользовательский выбор при этом сохраняется
// в FMSQOn и возвращается вместе с режимом FM.
procedure TWDSPEngine.ApplySliceFMSQ(Idx: Integer);
begin
  if (Idx < 0) or (Idx > High(FSlices)) or not FSlices[Idx].Opened then Exit;
  ApplyFMSQToChan(FSlices[Idx].Chan,
                  FSlices[Idx].FMSQOn and (FSlices[Idx].Mode = MODE_FM),
                  FSlices[Idx].FMSQLevel);
end;

function TWDSPEngine.GetSliceSMeter(Id: Integer): Double;
var idx: Integer;
begin
  Result := -130.0;
  FSliceLock.Enter;
  try
    idx := FindSliceIdx(Id);
    if idx >= 0 then Result := FSlices[idx].SMeter;
  finally
    FSliceLock.Leave;
  end;
end;

function TWDSPEngine.SliceCount: Integer;
var i: Integer;
begin
  Result := 0;
  FSliceLock.Enter;
  try
    for i := 0 to MAX_SLICES - 1 do
      if FSlices[i].Active then Inc(Result);
  finally
    FSliceLock.Leave;
  end;
end;

function TWDSPEngine.HasSlice(Id: Integer): Boolean;
begin
  FSliceLock.Enter;
  try
    Result := FindSliceIdx(Id) >= 0;
  finally
    FSliceLock.Leave;
  end;
end;

// ---------------------------------------------------------------------------
// RX управление
// ---------------------------------------------------------------------------

procedure TWDSPEngine.SetMode(Mode: Integer);
begin
  FMode := Mode;
  FTXMode := Mode;
  if not FInitialized then Exit;
  // Rate — до режима: setDSPSamplerate_rxa перестраивает всю цепь (в т.ч. wfmd),
  // а SetRXAMode затем пересчитает bpsnba под уже актуальный dsp_rate.
  ApplyDSPRate(Mode);
  SetRXAMode(RXA_CHAN, ModeToWDSP(Mode));
  ApplyRXModeSettings(RXA_CHAN, Mode);
  // Вход/выход из телеграфа меняет CWLOOffset: гетеродин надо переложить сразу
  // (кромки приедут из UI следом, но сдвиг оттуда не приходит).
  PushShift(RXA_CHAN, FShiftHz, Mode);
  ApplyTXMode(FTXMode);
  // НЕ вызываем ApplyDefaultFilter — фильтр устанавливается явно из UI
end;

procedure TWDSPEngine.SetFilter(Low, High: Integer);
begin
  FFilterLow  := Low;
  FFilterHigh := High;
  if not FInitialized then Exit;
  // Кромки не могут выходить за Найквист текущего dsp_rate: в WFM они доходят
  // до ±100 кГц, а dsp_rate зависит от SampleRate. Клампим только то, что
  // уходит в WDSP — запрошенные значения храним как есть, иначе понижение
  // SampleRate необратимо «съело» бы полосу.
  PushPassband(RXA_CHAN, Low, High, FMode, FRXDSPRate);
end;

procedure TWDSPEngine.SetAGC(Mode: TWDSPAGCMode; FixedGain: Double);
begin
  FAGCMode := Mode;
  if not FInitialized then Exit;

  // Настройка параметров AGC приёмного канала.
  ApplyAGCToChan(RXA_CHAN, Mode, FixedGain);

  // Читаем обратно hang level и thresh для линии на спектре
  UpdateAGCLines(FLastSpectrumW);
end;

procedure TWDSPEngine.SetShift(ShiftHz: Double);
begin
  FShiftHz := ShiftHz;
  if not FInitialized then Exit;
  PushShift(RXA_CHAN, ShiftHz, FMode);
end;

function TWDSPEngine.CWPitch: Integer;
begin
  Result := FCWPitch;
end;

procedure TWDSPEngine.SetCWPitch(Hz: Integer);
var i, Rate: Integer;
begin
  if Hz = FCWPitch then Exit;
  FCWPitch := Hz;
  if not FInitialized then Exit;
  if FMode in [MODE_CWL, MODE_CWU] then
  begin
    PushShift(RXA_CHAN, FShiftHz, FMode);
    PushPassband(RXA_CHAN, FFilterLow, FFilterHigh, FMode, FRXDSPRate);
  end;
  FSliceLock.Enter;
  try
    for i := 0 to MAX_SLICES - 1 do
      if FSlices[i].Active and FSlices[i].Opened and
         (FSlices[i].Mode in [MODE_CWL, MODE_CWU]) then
      begin
        Rate := SliceDSPRateFor(FSlices[i].Mode, SliceInRate(i));
        PushShift(FSlices[i].Chan, FSlices[i].ShiftHz, FSlices[i].Mode);
        PushPassband(FSlices[i].Chan, FSlices[i].FilterLo, FSlices[i].FilterHi,
                     FSlices[i].Mode, Rate);
      end;
  finally
    FSliceLock.Leave;
  end;
end;

procedure TWDSPEngine.SetCWSpeed(WPM: Integer);
begin
  if WPM < 5  then WPM := 5;
  if WPM > 60 then WPM := 60;
  FCWSpeed := WPM;
end;

procedure TWDSPEngine.SetAGCTop(TopDBm: Double);
begin
  FAGCTop := TopDBm;
  if not FInitialized then Exit;
  SetRXAAGCTop(RXA_CHAN, TopDBm);
  UpdateAGCLines(FLastSpectrumW);
end;

procedure TWDSPEngine.SetAGCSlope(Slope: Integer);
begin
  FAGCSlope := Slope;
  if not FInitialized then Exit;
  SetRXAAGCSlope(RXA_CHAN, Slope);
end;

procedure TWDSPEngine.SetAGCHangThreshold(Threshold: Integer);
begin
  FAGCHangThreshold := Threshold;
  if not FInitialized then Exit;
  SetRXAAGCHangThreshold(RXA_CHAN, Threshold);
  UpdateAGCLines(FLastSpectrumW);
end;

procedure TWDSPEngine.SetSpectrumWidth(W: Integer);
begin
  if W <= 0 then Exit;
  if FLastSpectrumW = W then Exit;
  FLastSpectrumW := W;
  if FAnalyzerOpen then
    ApplyAnalyzerSettings;
end;

procedure TWDSPEngine.SetDisplayResLimit(V: Integer);
begin
  V := EnsureRange(V, 64, SPECTRUM_PIXELS);
  if FDisplayResLimit = V then Exit;
  FDisplayResLimit := V;
  if FAnalyzerOpen then
    ApplyAnalyzerSettings;
end;

procedure TWDSPEngine.UpdateAGCLines(SpectrumW: Integer);
begin
  if not FInitialized then Exit;
  if SpectrumW <= 0 then Exit;  // окно ещё не показано — не читаем
  GetRXAAGCHangLevel(RXA_CHAN, @FAGCHangLevel);
  GetRXAAGCThresh(RXA_CHAN, @FAGCThresh,
                  SpectrumW,     // реальная ширина дисплея в пикселях
                  FSampleRate);
end;

// Параметризованные по каналу версии — делятся между главным каналом и слайсами.
procedure TWDSPEngine.ApplyNRToChan(Chan, NRMode: Integer);
begin
  SetRXAANRVals(Chan, 64, 16, 16e-4, 10e-7);
  SetRXAANRPosition(Chan, FILTER_NR_AGC_POSITION);
  SetRXAANRRun(Chan, Ord(NRMode = NR_MODE_1));

  SetRXAEMNRPosition(Chan, FILTER_NR_AGC_POSITION);
  SetRXAEMNRgainMethod(Chan, FILTER_NR2_GAIN_METHOD);
  SetRXAEMNRnpeMethod(Chan, FILTER_NR2_NPE_METHOD);
  SetRXAEMNRtrainZetaThresh(Chan, FILTER_NR2_TRAINED_THRESHOLD);
  SetRXAEMNRtrainT2(Chan, FILTER_NR2_TRAINED_T2);
  SetRXAEMNRpost2Taper(Chan, FILTER_NR2_POST_TAPER);
  SetRXAEMNRpost2Nlevel(Chan, FILTER_NR2_POST_NLEVEL);
  SetRXAEMNRpost2Factor(Chan, FILTER_NR2_POST_FACTOR);
  SetRXAEMNRpost2Rate(Chan, FILTER_NR2_POST_RATE);
  SetRXAEMNRaeRun(Chan, 1);
  SetRXAEMNRpost2Run(Chan, FILTER_NR2_POST_RUN);
  SetRXAEMNRRun(Chan, Ord(NRMode = NR_MODE_2));

  SetRXARNNRPosition(Chan, FILTER_NR_AGC_POSITION);
  SetRXARNNRRun(Chan, Ord(NRMode = NR_MODE_3));

  SetRXASBNRreductionAmount(Chan, FILTER_NR4_REDUCTION_AMOUNT);
  SetRXASBNRsmoothingFactor(Chan, FILTER_NR4_SMOOTHING_FACTOR);
  SetRXASBNRwhiteningFactor(Chan, FILTER_NR4_WHITENING_FACTOR);
  SetRXASBNRnoiseRescale(Chan, FILTER_NR4_NOISE_RESCALE);
  SetRXASBNRpostFilterThreshold(Chan, FILTER_NR4_POST_THRESHOLD);
  SetRXASBNRnoiseScalingType(Chan, FILTER_NR4_NOISE_SCALING_TYPE);
  SetRXASBNRPosition(Chan, FILTER_NR_AGC_POSITION);
  SetRXASBNRRun(Chan, Ord(NRMode = NR_MODE_4));
end;

procedure TWDSPEngine.ApplyNBToChan(Chan, NBMode: Integer);
begin
  SetEXTANBTau(Chan,                  FILTER_NB_TAU);
  SetEXTANBHangtime(Chan,             FILTER_NB_HANGTIME);
  SetEXTANBAdvtime(Chan,              FILTER_NB_ADVTIME);
  SetEXTANBThreshold(Chan,            FILTER_NB_THRESHOLD);
  SetEXTANBRun(Chan,                  Ord(NBMode = NB_MODE_1));

  SetEXTNOBMode(Chan,                 FILTER_NB2_MODE);
  SetEXTNOBTau(Chan,                  FILTER_NB_TAU);
  SetEXTNOBHangtime(Chan,             FILTER_NB_HANGTIME);
  SetEXTNOBAdvtime(Chan,              FILTER_NB_ADVTIME);
  SetEXTNOBThreshold(Chan,            FILTER_NB_THRESHOLD);
  SetEXTNOBRun(Chan,                  Ord(NBMode = NB_MODE_2));
end;

procedure TWDSPEngine.ApplySNBToChan(Chan: Integer; On_: Boolean);
begin
  SetRXASNBARun(Chan, Ord(On_));
end;

procedure TWDSPEngine.ApplyANFToChan(Chan: Integer; On_: Boolean);
begin
  SetRXAANFPosition(Chan, FILTER_NR_AGC_POSITION);
  SetRXAANFRun(Chan, Ord(On_));
end;

// FM-squelch на произвольном канале (RXA или слайс). Порог 0..100 → 0.0..0.5,
// как в SetFMSquelch для главного приёмника.
procedure TWDSPEngine.ApplyFMSQToChan(Chan: Integer; Enable: Boolean; Level: Integer);
begin
  if Enable then
  begin
    SetRXAFMSQThreshold(Chan, Level / 200.0);
    SetRXAFMSQRun(Chan, 1);
  end
  else
    SetRXAFMSQRun(Chan, 0);
end;

procedure TWDSPEngine.ApplyNRState;
begin
  if not FInitialized then Exit;
  ApplyNRToChan(RXA_CHAN, FNRMode);
end;

procedure TWDSPEngine.ApplyNBState;
begin
  if not FInitialized then Exit;
  ApplyNBToChan(RXA_CHAN, FNBMode);
end;

procedure TWDSPEngine.ApplySNBState;
begin
  if not FInitialized then Exit;
  ApplySNBToChan(RXA_CHAN, FSNBEnabled);
end;

procedure TWDSPEngine.ApplyANFState;
begin
  if not FInitialized then Exit;
  ApplyANFToChan(RXA_CHAN, FANFEnabled);
end;

procedure TWDSPEngine.ApplyNoiseFilterState;
begin
  ApplyNRState;
  ApplyNBState;
  ApplySNBState;
  ApplyANFState;
end;

procedure TWDSPEngine.SetNRMode(Mode: Integer);
begin
  if Mode < NR_MODE_OFF then Mode := NR_MODE_OFF;
  if Mode > NR_MODE_4 then Mode := NR_MODE_4;
  FNRMode := Mode;
  ApplyNRState;
end;

procedure TWDSPEngine.SetNR(Enable: Boolean);
begin
  if Enable then SetNRMode(NR_MODE_1)
  else SetNRMode(NR_MODE_OFF);
end;

procedure TWDSPEngine.SetNBMode(Mode: Integer);
begin
  if Mode < NB_MODE_OFF then Mode := NB_MODE_OFF;
  if Mode > NB_MODE_2 then Mode := NB_MODE_2;
  FNBMode := Mode;
  ApplyNBState;
end;

procedure TWDSPEngine.SetNB(Enable: Boolean);
begin
  if Enable then SetNBMode(NB_MODE_1)
  else SetNBMode(NB_MODE_OFF);
end;

procedure TWDSPEngine.SetSNB(Enable: Boolean);
begin
  FSNBEnabled := Enable;
  ApplySNBState;
end;

procedure TWDSPEngine.SetANF(Enable: Boolean);
begin
  FANFEnabled := Enable;
  ApplyANFState;
end;

procedure TWDSPEngine.SetRXFMDeviation(Deviation: Double);
begin
  if not FInitialized then Exit;
  if FMode = MODE_FMRAW then SetRawFMDeviation(Deviation)
  else if FMode = MODE_DMR then
    SetRXAWFMDeviation(RXA_CHAN, Deviation)
  else
    SetRXAFMDeviation(RXA_CHAN, Deviation);
end;

procedure TWDSPEngine.SetRawFMDeviation(Deviation: Double);
var i: Integer;
begin
  FRawFMDeviation := EnsureRange(Deviation, 500.0, 10000.0);
  if not FInitialized then Exit;
  if FMode = MODE_FMRAW then SetRXAWFMDeviation(RXA_CHAN, FRawFMDeviation);
  if FTXMode = MODE_FMRAW then SetTXAWFMDeviation(TXA_CHAN, FRawFMDeviation);
  // One calibration value applies to every active FM RAW decoder output.
  FSliceLock.Enter;
  try
    for i := 0 to MAX_SLICES - 1 do
      if FSlices[i].Active and FSlices[i].Opened and
         (FSlices[i].Mode = MODE_FMRAW) then
        SetRXAWFMDeviation(FSlices[i].Chan, FRawFMDeviation);
  finally
    FSliceLock.Leave;
  end;
end;

procedure TWDSPEngine.SetFMSquelch(Enable: Boolean; Level: Integer);
var Threshold: Double;
begin
  if not FInitialized then Exit;
  if Enable then
  begin
    Threshold := Level / 200.0;   // 0..100 → 0.0..0.5
    SetRXAFMSQThreshold(RXA_CHAN, Threshold);
    SetRXAFMSQRun(RXA_CHAN, 1);
  end
  else
    SetRXAFMSQRun(RXA_CHAN, 0);
end;

procedure TWDSPEngine.SetVolume(Vol: Double);
begin
  FVolume := Max(0.0, Min(1.0, Vol));
  if not FInitialized then Exit;
  SetRXAPanelGain1(RXA_CHAN, FVolume);
end;

procedure TWDSPEngine.SetMute(Mute: Boolean);
begin
  FMuted := Mute;
  if not FInitialized then Exit;
  SetRXAPanelRun(RXA_CHAN, Ord(not Mute));
end;

// ---------------------------------------------------------------------------
// TX управление
// ---------------------------------------------------------------------------

procedure TWDSPEngine.SetTXMode(Mode: Integer);
begin
  FTXMode := Mode;
  if not FInitialized then Exit;
  ApplyTXMode(FTXMode);
end;

procedure TWDSPEngine.SetDriveLevel(Level: Double);
begin
  if not FInitialized then Exit;
  SetTXAALCMaxGain(TXA_CHAN, Max(0.0, Min(1.0, Level)));
end;

procedure TWDSPEngine.SetMicGain(GainDB: Double);
begin
  FTXMicGainDB := GainDB;
  if not FInitialized then Exit;
  SetTXAPanelGain1(TXA_CHAN, Power(10.0, GainDB / 20.0));
end;

procedure TWDSPEngine.SetTXRun(Run: Boolean);
var
  k, Err: Integer;
begin
  // ★Повторный старт при уже идущей передаче — не событие, а шум: SetMOX не
  // отсекает MOX=True поверх MOX=True (RadioController.SetMOX), а прислать его
  // снова вправе и CAT, и web, и TCI. Перезапускать тракт по такому вызову
  // НЕЛЬЗЯ: сброс индексов mic-кольца гонялся бы с чтением из TX-потока
  // (правило «tail пишет только TX-поток» ломается), а SetChannelState — с его
  // fexchange0. Худший исход не теоретический: поток дописывает свой локальный
  // tail поверх обнулённого, кольцо выглядит почти полным СТАРЫХ данных, и они
  // уходят в эфир пачкой — то есть всплеск.
  if Run and FTXActive then Exit;
  FDispPos := 0;
  FTXDispPos := 0;
  if Run then
  begin
    // ★Весь старт — одной транзакцией под FTXLock, флаг публикуется последним:
    // до него поток, взявший лок, перечитает гейт и уйдёт в простой, а после —
    // канал уже поднят, кольцо пусто, буферы продуты.
    FTXLock.Enter;
    try
      FActiveDisplayID := TX_DISP_ID;
      // Сброс mic ring ДО активации, чтобы простаивавший поток не прочитал старое.
      FTXMicHead := 0;
      FTXMicTail := 0;
      if FInitialized then
      begin
        SetChannelState(TXA_CHAN, 1, 0);   // run, no delay
        // Продувка канала: страховка от остатка прошлой TX-сессии в буферах/
        // фильтрах TXA (без неё огрызок — после TUN это полноразмахный тон —
        // выплёвывался в эфир первым же блоком нового TX). Основной слив теперь
        // на стопе (ниже), но нештатные пути останова канал не чистят. 4 нулевых
        // блока через fexchange0, выход в мусор (не в FOnTXIQ/display).
        FillChar(FTXIn[0], FAudioBufSize * 2 * SizeOf(Double), 0);
        for k := 1 to 4 do
        begin
          Err := 0;
          fexchange0(TXA_CHAN, @FTXIn[0], @FTXOut[0], @Err);
        end;
        // Сброс TX-анализатора: его усредняющая история хранит спектр ПРОШЛОЙ
        // сессии (после TUN — тон), и при переключении на TX-дисплей первые
        // кадры показывали «всплеск» старого тона (виден в non-DUP, где на
        // экране TX-спектр; эфир при этом чист — в DUP старт чистый).
        // ResetPixelBuffers — лёгкое зануление pixel/average буферов (MW0LGE);
        // НЕ ApplyTXAnalyzerSettings: тот зовёт SetAnalyzer (перепланирование
        // FFT в UI-потоке) — давал видимую задержку отрисовки на старте TX.
        if FAnalyzerOpen then
          ResetPixelBuffers(TX_DISP_ID);
      end;
      // TX-DSP поток создаётся ОДИН раз и живёт весь сеанс. НЕ пересоздаём на каждом
      // T/R: join (WaitFor) давал фиксированные ~100 мс фриза GUI на отпускании PTT.
      // При FTXActive=False поток простаивает (см. TTXDSPThread.Execute: Continue).
      if not Assigned(FTXThread) then
      begin
        FTXThread := TTXDSPThread.Create(Self);
        FTXThread.Priority := tpHighest;
      end;
      FTXActive := True;                    // активируем последним
    finally
      FTXLock.Leave;
    end;
  end else begin
    FTXActive := False;                   // поток уходит в простой (не трогает канал/ring)
    FActiveDisplayID := RX_DISP_ID;
    if FInitialized then
    begin
      // Мгновенный стоп + синхронный слив. Раньше здесь был dmode=1: он
      // спинует Sleep(1)×100 в ожидании фейда, который должен прокрутить
      // fexchange — а продюсер уже остановлен, fexchange никто не зовёт →
      // чистый таймаут ~106мс (остаточный фриз GUI на отпускании PTT) и
      // канал, брошенный с недобранным сигналом в буферах. dmode=0 лишь
      // взводит downflag/flushflag и возвращается сразу; down-slew (фейд
      // 10мс) и штатный flush буферов исполняют наши же fexchange0 с нулевым
      // входом — суммарно <1мс, выход в мусор (в эфир не идёт: DUC-очередь
      // чистится контроллером на T/R).
      // ★Под локом: FTXActive уже опущен, но поток мог войти в блок раньше —
      // лок дожидается его конца и не пускает в канал следующий (гейт он
      // перечитывает под этим же локом). Ждать приходится не дольше одного
      // блока TXA — сотня-другая микросекунд.
      FTXLock.Enter;
      try
        SetChannelState(TXA_CHAN, 0, 0);    // stop, без ожидания
        FillChar(FTXIn[0], FAudioBufSize * 2 * SizeOf(Double), 0);
        for k := 1 to 4 do
        begin
          Err := 0;
          fexchange0(TXA_CHAN, @FTXIn[0], @FTXOut[0], @Err);
        end;
      finally
        FTXLock.Leave;
      end;
    end;
    FTXMicHead := 0;
    FTXMicTail := 0;
  end;
end;

procedure TWDSPEngine.SetTXTone(Enabled: Boolean; FreqHz, Mag: Double);
var
  SignedFreq: Double;
begin
  if not FInitialized then Exit;
  FTXToneOn := Enabled;   // мьют mic-входа на TUN (см. ProcessTXBlock)
  if Enabled then
  begin
    // gen1 (PostGen) стоит после всех модуляторов и бандпасса.
    // Положительная частота даёт тон ВЫШЕ несущей (USB-сторона).
    // Для LSB и CWL нужен тон НИЖЕ несущей — инвертируем знак.
    if (FMode = MODE_LSB) or (FMode = MODE_CWL) then
      SignedFreq := -Abs(FreqHz)
    else
      SignedFreq := Abs(FreqHz);
    SetTXAPostGenMode(TXA_CHAN, 0);   // 0 = tone
    SetTXAPostGenToneFreq(TXA_CHAN, SignedFreq);
    SetTXAPostGenToneMag(TXA_CHAN, Max(0.0, Min(1.0, Mag)));
    SetTXAPostGenRun(TXA_CHAN, 1);
  end else
    SetTXAPostGenRun(TXA_CHAN, 0);
end;

procedure TWDSPEngine.SetTXTwoTone(Enabled: Boolean; Freq1, Freq2: Double);
// 2TON: двухтональник PostGen (mode 1) для IMD-теста и проверки PureSignal.
// Амплитуды 0.49+0.49 дают сумму чуть ниже полной шкалы.
var
  F1, F2: Double;
begin
  if not FInitialized then Exit;
  FTXTTOn := Enabled;   // мьют mic-входа (см. ProcessTXBlock)
  if Enabled then
  begin
    // Для нижней боковой тона должны лежать НИЖЕ несущей.
    if (FMode = MODE_LSB) or (FMode = MODE_CWL) or (FMode = MODE_DIGL) then
    begin
      F1 := -Abs(Freq1);
      F2 := -Abs(Freq2);
    end
    else
    begin
      F1 := Abs(Freq1);
      F2 := Abs(Freq2);
    end;
    SetTXAPostGenMode(TXA_CHAN, 1);   // 1 = two-tone
    SetTXAPostGenTTFreq(TXA_CHAN, F1, F2);
    SetTXAPostGenTTMag(TXA_CHAN, 0.49, 0.49);
    SetTXAPostGenRun(TXA_CHAN, 1);
  end else
    SetTXAPostGenRun(TXA_CHAN, 0);
end;

procedure TWDSPEngine.SetTXCFIR(Enabled: Boolean);
begin
  if not FInitialized then Exit;
  SetTXACFIRRun(TXA_CHAN, Ord(Enabled));
end;

procedure TWDSPEngine.SetDisplaySourceTX(UseTX: Boolean);
begin
  // Тот же сброс позиций, что и в SetTXRun — analyzer-буферы у обоих DispID
  // независимы, но мы переключаем источник, и старые байты в накапливающем
  // буфере на новой стороне могут повести себя как «всплеск», если их не
  // обнулить.
  FDispPos := 0;
  FTXDispPos := 0;
  if UseTX then
    FActiveDisplayID := TX_DISP_ID
  else
    FActiveDisplayID := RX_DISP_ID;
end;

procedure TWDSPEngine.ProcessTXBlock;
// Вызывается из TTXDSPThread — обрабатывает один блок FAudioBufSize mic сэмплов
// через WDSP TXA, выдаёт FTXOutBufSize IQ пар @ FTXSampleRate в FOnTXIQ callback.
// Если mic ring buffer содержит достаточно данных — берём их,
// иначе обрабатываем тишину (нули).
var
  i, tail: Integer;
  Err:     Integer;
  Avail:   Integer;
begin
  if not FInitialized then Exit;

  Avail := (FTXMicHead - FTXMicTail + TX_MIC_RING) and (TX_MIC_RING - 1);

  if Avail >= FAudioBufSize then
  begin
    // Читаем FAudioBufSize сэмплов из ring buffer → FTXIn
    tail := FTXMicTail;
    for i := 0 to FAudioBufSize - 1 do
    begin
      FTXIn[i * 2]     := FTXMicRing[tail];
      FTXIn[i * 2 + 1] := 0.0;   // Q = 0, mic — монофонический сигнал
      tail := (tail + 1) and (TX_MIC_RING - 1);
    end;
    FTXMicTail := tail;  // Атомарно обновляем tail (один writer — TX thread)
    // TUN/2TON: ринг потребляем (иначе поток зациклится на «есть полный блок»),
    // но в TXA подаём тишину — тон PostGen должен идти без голоса поверх.
    if FTXToneOn or FTXTTOn then
      FillChar(FTXIn[0], FAudioBufSize * 2 * SizeOf(Double), 0);
  end else
  begin
    // Нет mic-данных — заполняем входной TX-буфер нулями.
    FillChar(FTXIn[0], FAudioBufSize * 2 * SizeOf(Double), 0);
  end;

  Err := 0;
  fexchange0(TXA_CHAN, @FTXIn[0], @FTXOut[0], @Err);

  // TX сигнал → отдельный TX-analyzer (TX_DISP_ID).
  // Подаём чанками по DISPLAY_BLOCK_SIZE — analyzer ожидает именно такой
  // размер блока (см. SetAnalyzer). Если подавать FTXOut целиком
  // (FTXOutBufSize пар), analyzer прочитает только первые DISPLAY_BLOCK_SIZE
  // и проигнорирует остальное → спектр будет пустой.
  if FAnalyzerOpen then
    for i := 0 to FTXOutBufSize - 1 do
      FeedTXDisplaySample(FTXOut[i * 2], FTXOut[i * 2 + 1]);

  // FTXOut содержит FTXOutBufSize IQ пар @ FTXSampleRate (interleaved double)
  if Assigned(FOnTXIQ) then
    FOnTXIQ(FTXOut, FTXOutBufSize);
end;

procedure TWDSPEngine.PushTXMicSamples16(const Src: array of SmallInt; N: Integer);
// Вызывается из receive thread — lock-free, кладёт сэмплы в ring buffer
// 16-bit big-endian samples из аппаратного микрофона
var
  i, cnt, newHead: Integer;
  S: SmallInt;
begin
  cnt := N;
  if cnt > Length(Src) then cnt := Length(Src);

  for i := 0 to cnt - 1 do
  begin
    // Аппаратный mic приходит в big-endian, нужно swap байт
    S := SmallInt(((Src[i] and $FF) shl 8) or ((Src[i] shr 8) and $FF));
    newHead := (FTXMicHead + 1) and (TX_MIC_RING - 1);
    if newHead <> FTXMicTail then  // буфер не полон
    begin
      FTXMicRing[FTXMicHead] := S / 32768.0;
      FTXMicHead := newHead;
    end;
    // если буфер полон — сэмпл отбрасывается (overrun)
  end;

  RTLEventSetEvent(FTXMicSem);  // будим TX поток
end;

function TWDSPEngine.GetTXMicFill: Integer;
begin
  Result := (FTXMicHead - FTXMicTail + TX_MIC_RING) and (TX_MIC_RING - 1);
end;

procedure TWDSPEngine.PushTXMicSampleD(const S: Double);
var newHead: Integer;
begin
  newHead := (FTXMicHead + 1) and (TX_MIC_RING - 1);
  if newHead <> FTXMicTail then
  begin
    FTXMicRing[FTXMicHead] := S;
    FTXMicHead := newHead;
  end;
  // overrun → дроп, как в HW path
  RTLEventSetEvent(FTXMicSem);
end;

procedure TWDSPEngine.PushTXMicSamplesD(const Src: array of Double; N: Integer);
// Batch-вариант: одна будилка семафора на N сэмплов.
var
  i, cnt, newHead: Integer;
begin
  cnt := N;
  if cnt > Length(Src) then cnt := Length(Src);

  for i := 0 to cnt - 1 do
  begin
    newHead := (FTXMicHead + 1) and (TX_MIC_RING - 1);
    if newHead <> FTXMicTail then
    begin
      FTXMicRing[FTXMicHead] := Src[i];
      FTXMicHead := newHead;
    end;
    // overrun → дроп
  end;

  if cnt > 0 then
    RTLEventSetEvent(FTXMicSem);
end;

// ---------------------------------------------------------------------------
// TX chain: применение всех TX-параметров к WDSP-каналу
// ---------------------------------------------------------------------------

procedure TWDSPEngine.ApplyTXChainSettings;
// Вызывается после OpenChannel(TXA) и при старте TX. Прокидывает все
// FTX*-поля в WDSP. Идемпотентна, ничего не меняет если канал не открыт.
var
  BpLo, BpHi: Double;
begin
  if not FInitialized then Exit;

  // Bandpass — магнитуду TX-полосы прогоняем через знак боковой режима
  // (иначе LSB/DIGL уходят в USB, когда этот пуш оказывается последним, напр. MOX).
  if (FTXFilterLow > 0) and (FTXFilterHigh > FTXFilterLow) then
  begin
    TXSignedEdges(FTXMode, BpLo, BpHi);
    SetTXABandpassFreqs(TXA_CHAN, BpLo, BpHi);
  end;
  if FTXFilterWindow >= 0 then
    SetTXABandpassWindow(TXA_CHAN, FTXFilterWindow);
  if FTXFilterNC > 0 then
    TXASetNC(TXA_CHAN, EffectiveTXNC);
  if FTXFilterMP >= 0 then
    TXASetMP(TXA_CHAN, FTXFilterMP);

  // Mic gain (panel gain1)
  SetTXAPanelGain1(TXA_CHAN, Power(10.0, FTXMicGainDB / 20.0));

  // Compressor
  SetTXACompressorRun(TXA_CHAN, Ord(FTXCompressorOn));
  SetTXACompressorGain(TXA_CHAN, FTXCompressorGain);

  // Leveler
  SetTXALevelerSt(TXA_CHAN, Ord(FTXLevelerOn));
  SetTXALevelerTop(TXA_CHAN, FTXLevelerTop);
  SetTXALevelerDecay(TXA_CHAN, FTXLevelerDecay);

  // ALC
  SetTXAALCSt(TXA_CHAN, Ord(FTXALCOn));
  SetTXAALCMaxGain(TXA_CHAN, FTXALCMaxGain);
  SetTXAALCDecay(TXA_CHAN, FTXALCDecay);

  // Phase Rotator
  SetTXAPHROTRun(TXA_CHAN, Ord(FTXPhaseRotOn));
  SetTXAPHROTNstages(TXA_CHAN, FTXPhaseRotStages);
  SetTXAPHROTCorner(TXA_CHAN, FTXPhaseRotFreq);

  // EQ TX ([0]=preamp, [1..10]=точки кривой)
  PushTXEQProfile;

  // AM
  SetTXAAMCarrierLevel(TXA_CHAN, FTXAMCarrier);

  // FM
  SetTXAFMDeviation(TXA_CHAN, FTXFMDeviation);
  if (FTXFMLowCut < FTXFMHighCut) then
    SetTXAFMAFFilter(TXA_CHAN, FTXFMLowCut, FTXFMHighCut);
  SetTXAFMEmphPosition(TXA_CHAN, FTXFMEmphPos);

  // CTCSS
  SetTXACTCSSFreq(TXA_CHAN, FTXCTCSSFreq);
  SetTXACTCSSRun(TXA_CHAN, Ord(FTXCTCSSOn));

  // PureSignal (calcc живёт внутри TXA-канала — после пересоздания канала
  // все его параметры надо прокинуть заново)
  ApplyPSParams;
end;

procedure TWDSPEngine.ApplyPSParams;
begin
  if not FInitialized then Exit;
  // Feedback приходит с DDC0/1 @ 192 кС/с (фиксированный PS-rate протокола 2)
  SetPSFeedbackRate(TXA_CHAN, TX_SAMPLE_RATE);
  SetPSMoxDelay(TXA_CHAN, FPSMoxDelay);
  SetPSLoopDelay(TXA_CHAN, FPSLoopDelay);
  SetPSTXDelay(TXA_CHAN, FPSTXDelayNs * 1.0e-9);
  if FPSRelaxPtol then
    SetPSPtol(TXA_CHAN, 0.400)
  else
    SetPSPtol(TXA_CHAN, 0.800);
  SetPSPinMode(TXA_CHAN, Ord(FPSPin));
  SetPSMapMode(TXA_CHAN, Ord(FPSMap));
  SetPSStabilize(TXA_CHAN, Ord(FPSStabilize));
  SetPSIntsAndSpi(TXA_CHAN, FPSInts, FPSSpi);
  SetPSHWPeak(TXA_CHAN, FPSHWPeak);
  SetPSOutlierSigma(TXA_CHAN, FPSOutlierSigma);
end;

procedure TWDSPEngine.PSSetParams(MoxDelay, LoopDelay: Double; TXDelayNs: Integer;
  RelaxPtol: Boolean; Pin, Map, Stabilize: Boolean; Ints, Spi: Integer;
  HWPeak: Double; OutlierSigma: Double);
begin
  FPSMoxDelay  := MoxDelay;
  FPSLoopDelay := LoopDelay;
  FPSTXDelayNs := TXDelayNs;
  FPSRelaxPtol := RelaxPtol;
  FPSPin       := Pin;
  FPSMap       := Map;
  FPSStabilize := Stabilize;
  if Ints > 0 then FPSInts := Ints;
  if Spi  > 0 then FPSSpi  := Spi;
  if HWPeak > 0 then FPSHWPeak := HWPeak;
  if OutlierSigma >= 0 then FPSOutlierSigma := OutlierSigma;
  ApplyPSParams;
end;

procedure TWDSPEngine.SetPureSignalArmed(Armed: Boolean);
begin
  FPSArmed := Armed;
end;

procedure TWDSPEngine.PushPSFeedback(const Buf: array of Byte;
  DataOffset: Integer; TotalPairs: Integer);
// Сетевой поток: interleaved 24-bit BE пары [DDC0 I,Q][DDC1 I,Q]... →
// Деинтерливим feedback-потоки и передаём их в WDSP pscc:
// DDC0 = RX-feedback (искажённый сигнал после PA), DDC1 = TX DAC (эталон).
var
  i, n, Pos: Integer;
  IR, QR: LongInt;
  P: PByte;
const
  SCALE = 1.0 / 8388608.0;
begin
  if not (FPSArmed and FInitialized) then Exit;
  n := TotalPairs div 2;                       // пар на каждый поток
  if n > 256 then n := 256;                    // ёмкость FPSTx/RxBuf
  if n <= 0 then Exit;
  if (DataOffset + n * 2 * 6) > Length(Buf) then Exit;

  P := @Buf[DataOffset];
  for i := 0 to n * 2 - 1 do
  begin
    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);
    if (IR and $800000) <> 0 then IR := IR or LongInt($FF000000);
    if (QR and $800000) <> 0 then QR := QR or LongInt($FF000000);
    Pos := i shr 1;                            // индекс пары в потоке
    if (i and 1) = 0 then
    begin
      FPSRxBuf[Pos * 2]     := IR * SCALE;     // чётные = DDC0 (RX feedback)
      FPSRxBuf[Pos * 2 + 1] := QR * SCALE;
    end
    else
    begin
      FPSTxBuf[Pos * 2]     := IR * SCALE;     // нечётные = DDC1 (TX DAC)
      FPSTxBuf[Pos * 2 + 1] := QR * SCALE;
    end;
  end;

  pscc(TXA_CHAN, n, @FPSTxBuf[0], @FPSRxBuf[0]);
end;

procedure TWDSPEngine.PSControl(Reset, Mancal, Automode, Turnon: Integer);
begin
  if not FInitialized then Exit;
  SetPSControl(TXA_CHAN, Reset, Mancal, Automode, Turnon);
end;

procedure TWDSPEngine.PSSetMox(Mox: Boolean);
begin
  if not FInitialized then Exit;
  SetPSMox(TXA_CHAN, Ord(Mox));
end;

procedure TWDSPEngine.PSGetInfo(var Info: array of Integer);
begin
  if (not FInitialized) or (Length(Info) < 16) then Exit;
  GetPSInfo(TXA_CHAN, @Info[0]);
end;

function TWDSPEngine.PSGetMaxTX: Double;
begin
  Result := 0.0;
  if not FInitialized then Exit;
  GetPSMaxTX(TXA_CHAN, @Result);
end;

procedure TWDSPEngine.TXFilterEdgesHz(out Lo, Hi: Integer);
// ЭКРАННЫЕ кромки — занимаемая полоса вокруг несущей, а НЕ bp0. У SSB/CW/DIGI/
// AM/DSB это одно и то же, поэтому здесь и стояла обёртка над TXSignedEdges.
// Но у ЧМ bp0 — АУДИО-полосовик ПЕРЕД модулятором: у FM RAW он (20..7000), у
// WFM (20..15000), и полоса на спектре уезжала вверх от несущей — визуально
// «переключалась на USB». Для ЧМ считаем по Карсону: ±(девиация + верхний срез
// аудио), симметрично несущей. У MODE_FM TXSignedEdges отдаёт ровно эту
// величину и без нас, но формула остаётся в одном месте.
var L, H, Half: Double;
begin
  Half := 0.0;
  case FTXMode of
    MODE_FM:    Half := FTXFMDeviation + FTXFMHighCut;
    MODE_WFM:   Half := WFM_DEVIATION + WFM_AF_HIGH;
    MODE_FMRAW: Half := FRawFMDeviation + RAW_AF_HIGH;
    // ★Телеграф. TXSignedEdges отдаёт для CWL/CWU ГОЛОСОВУЮ боковую (bp0 обязан
    // оставаться открытым: через него идёт тон pitch при настройке TUN), но в
    // эфир уходит манипулируемая НЕСУЩАЯ на самом VFO. Рисовать её юбкой SSB
    // нельзя — полоса выглядела как в SSB и уезжала вбок от несущей.
    // Занимаемая полоса телеграфа: B = WPM/1.2 бод, BW ≈ 5·B при мягкой
    // манипуляции (фронт CWRampPeriod). Симметрично несущей.
    MODE_CWL, MODE_CWU:
      begin
        Half := 5.0 * (FCWSpeed / 1.2) / 2.0;
        if Half < 25.0 then Half := 25.0;   // не вырождаться в нулевую полоску
      end;
  end;
  if Half > 0.0 then
  begin
    Lo := -Round(Half);
    Hi :=  Round(Half);
    Exit;
  end;
  TXSignedEdges(FTXMode, L, H);
  Lo := Round(L);
  Hi := Round(H);
end;

procedure TWDSPEngine.SetTXFilterFull(Low, High, NC, MP, Window: Integer);
var BpLo, BpHi: Double;
begin
  FTXFilterLow    := Low;
  FTXFilterHigh   := High;
  FTXFilterNC     := NC;
  FTXFilterMP     := MP;
  FTXFilterWindow := Window;
  FFilterLow      := Low;
  FFilterHigh     := High;
  if not FInitialized then Exit;
  // Магнитуда диалога → знаковые края под боковую режима (см. TXSignedEdges).
  TXSignedEdges(FTXMode, BpLo, BpHi);
  SetTXABandpassFreqs(TXA_CHAN, BpLo, BpHi);
  SetTXABandpassWindow(TXA_CHAN, Window);
  if NC > 0 then TXASetNC(TXA_CHAN, EffectiveTXNC);
  if MP >= 0 then TXASetMP(TXA_CHAN, MP);
end;

procedure TWDSPEngine.SetTXCompressor(Enable: Boolean; GainDB: Double);
begin
  FTXCompressorOn   := Enable;
  FTXCompressorGain := GainDB;
  if not FInitialized then Exit;
  SetTXACompressorGain(TXA_CHAN, GainDB);
  SetTXACompressorRun(TXA_CHAN, Ord(Enable));
end;

procedure TWDSPEngine.SetTXLeveler(Enable: Boolean; TopDB: Double; DecayMS: Integer);
begin
  FTXLevelerOn    := Enable;
  FTXLevelerTop   := TopDB;
  FTXLevelerDecay := DecayMS;
  if not FInitialized then Exit;
  SetTXALevelerTop(TXA_CHAN, TopDB);
  SetTXALevelerDecay(TXA_CHAN, DecayMS);
  SetTXALevelerSt(TXA_CHAN, Ord(Enable));
end;

procedure TWDSPEngine.SetTXALC(Enable: Boolean; MaxGainDB: Double; DecayMS: Integer);
begin
  FTXALCOn      := Enable;
  FTXALCMaxGain := MaxGainDB;
  FTXALCDecay   := DecayMS;
  if not FInitialized then Exit;
  SetTXAALCMaxGain(TXA_CHAN, MaxGainDB);
  SetTXAALCDecay(TXA_CHAN, DecayMS);
  SetTXAALCSt(TXA_CHAN, Ord(Enable));
end;

procedure TWDSPEngine.SetTXPhaseRot(Enable: Boolean; Stages: Integer; FreqHz: Double);
begin
  FTXPhaseRotOn     := Enable;
  FTXPhaseRotStages := Stages;
  FTXPhaseRotFreq   := FreqHz;
  if not FInitialized then Exit;
  SetTXAPHROTNstages(TXA_CHAN, Stages);
  SetTXAPHROTCorner(TXA_CHAN, FreqHz);
  SetTXAPHROTRun(TXA_CHAN, Ord(Enable));
end;

procedure TWDSPEngine.PushTXEQProfile;
// Единственная точка, отдающая кривую EQ в WDSP.
// F[1..n] — узлы ломаной АЧХ, между ними интерполяция по
// децибелам, а за крайними узлами при ctfmode=0 (так создаётся eqp в TXA.c)
// идёт кумулятивный скат (f/f0)^4 на бин — фактически обрыв. Поэтому:
//   • 10 полос — узлы берём из профиля как есть (за краями обязан быть запас,
//     см. заводские ESSB-профили в Settings.DefaultTXProfiles);
//   • 3 полосы — legacy-раскладка самого WDSP (SetTXAGrphEQ): 150/400/1500/
//     6000, ручка Low держит полку 150..400, High тянет до 6 кГц. Отдавать
//     здесь первые три узла 10-полосной сетки (100/200/400) нельзя — весь
//     голос выше 400 Гц ушёл бы в ноль.
var
  F, G: array[0..10] of Double;
  i, NF: Integer;
begin
  if not FInitialized then Exit;
  if FTXEQNumBands = 3 then
  begin
    NF   := 4;
    F[0] := 0.0;    G[0] := FTXEQGains[0];   // preamp
    F[1] := 150.0;  G[1] := FTXEQGains[1];   // Low
    F[2] := 400.0;  G[2] := FTXEQGains[1];
    F[3] := 1500.0; G[3] := FTXEQGains[2];   // Mid
    F[4] := 6000.0; G[4] := FTXEQGains[3];   // High
  end
  else
  begin
    NF := 10;
    for i := 0 to 10 do
    begin
      F[i] := FTXEQFreqs[i];
      G[i] := FTXEQGains[i];
    end;
  end;
  SetTXAEQProfile(TXA_CHAN, NF, @F[0], @G[0]);
  SetTXAEQRun(TXA_CHAN, Ord(FTXEQOn));
end;

procedure TWDSPEngine.SetTXEQ(Enable: Boolean; NumBands: Integer;
                              const Gains, Freqs: array of Double);
var
  i, n: Integer;
begin
  FTXEQOn       := Enable;
  FTXEQNumBands := NumBands;
  n := Min(11, Min(Length(Gains), Length(Freqs)));
  for i := 0 to n - 1 do
  begin
    FTXEQGains[i] := Gains[i];
    FTXEQFreqs[i] := Freqs[i];
  end;
  PushTXEQProfile;
end;

procedure TWDSPEngine.SetTXAMCarrierLevel(Level: Double);
begin
  FTXAMCarrier := Level;
  if not FInitialized then Exit;
  WDSP.SetTXAAMCarrierLevel(TXA_CHAN, Level);
end;

procedure TWDSPEngine.SetTXFMParams(Deviation: Double; LowCut, HighCut, EmphPos: Integer);
begin
  FTXFMDeviation := Deviation;
  FTXFMLowCut    := LowCut;
  FTXFMHighCut   := HighCut;
  FTXFMEmphPos   := EmphPos;
  if not FInitialized then Exit;
  SetTXAFMDeviation(TXA_CHAN, Deviation);
  if LowCut < HighCut then
    SetTXAFMAFFilter(TXA_CHAN, LowCut, HighCut);
  SetTXAFMEmphPosition(TXA_CHAN, EmphPos);
end;

procedure TWDSPEngine.SetTXCTCSS(Enable: Boolean; FreqHz: Double);
begin
  FTXCTCSSOn   := Enable;
  FTXCTCSSFreq := FreqHz;
  if not FInitialized then Exit;
  SetTXACTCSSFreq(TXA_CHAN, FreqHz);
  SetTXACTCSSRun(TXA_CHAN, Ord(Enable));
end;

function TWDSPEngine.TXMicRingAvail: Integer;
begin
  Result := (FTXMicHead - FTXMicTail + TX_MIC_RING) and (TX_MIC_RING - 1);
end;

procedure TWDSPEngine.SetTXMicSource(Source: TTXMicSource);
begin
  if FTXMicSource = Source then Exit;
  FTXMicSource := Source;
  // При смене источника сбрасываем ring, чтобы не подмешивать остатки
  // от предыдущего producer'а (race window игнорируется — TX обычно неактивен).
  FTXMicHead := 0;
  FTXMicTail := 0;
end;

procedure TWDSPEngine.SetTXFFTParams(FFTSize, WinType: Integer);
begin
  FTXFFTSize    := NormalizeFFTSize(FFTSize);
  FTXWindowType := EnsureRange(WinType, 0, 6);
  if FAnalyzerOpen then ApplyTXAnalyzerSettings;
end;

procedure TWDSPEngine.SetTXSpectrumDisplay(Detector, AvgMode: Integer; AvgTimeMS: Double);
begin
  NormalizeDisplayParams(Detector, AvgMode, AvgTimeMS, False);
  FTXSpecDetector  := Detector;
  FTXSpecAvgMode   := AvgMode;
  FTXSpecAvgTimeMS := AvgTimeMS;
  if FAnalyzerOpen then ApplyTXAnalyzerSettings;
end;

procedure TWDSPEngine.SetTXWaterfallDisplay(Detector, AvgMode: Integer; AvgTimeMS: Double);
begin
  NormalizeDisplayParams(Detector, AvgMode, AvgTimeMS, True);
  FTXWfDetector  := Detector;
  FTXWfAvgMode   := AvgMode;
  FTXWfAvgTimeMS := AvgTimeMS;
  if FAnalyzerOpen then ApplyTXAnalyzerSettings;
end;

// ---------------------------------------------------------------------------
// Spectrum
// ---------------------------------------------------------------------------

procedure TWDSPEngine.UpdateSpectrum;
// Вызывается из таймера главного потока (настраиваемый FPS).
// Здесь вызываем Spectrum0 — WDSP берёт снапшот из своего кольцевого
// буфера (который fexchange0 непрерывно заполняет из DSP-потока).
// Это обеспечивает равномерный FPS независимо от размера FFT:
// при FFT=16384 WDSP использует уже накопленные данные из overlap-save
// буфера, а не ждёт следующего полного блока.
var
  PixBuf:   array[0..SPECTRUM_PIXELS - 1] of Single;
  WfBuf:    array[0..SPECTRUM_PIXELS - 1] of Single;
  Flag:     Integer;
  i, N, p:  Integer;
  DispID:   Integer;
  Cal:      Single;
  MainCal:  Single;   // калибровка главного тракта (Cal ниже перетирается панами)
  GotSpec, GotWf: Boolean;
begin
  if not FInitialized or not FAnalyzerOpen then Exit;
  N := EnsureRange(FDisplayPixelCount, 1, SPECTRUM_PIXELS);
  DispID := FActiveDisplayID;   // RX_DISP_ID или TX_DISP_ID, зависит от FTXActive

  // Калибровка дисплея (дБ) — сдвиг всей трассы, спрашиваем раз в кадр.
  // На TX-дисплее коррекция входного тракта не при чём.
  if Assigned(FOnDispCal) and (DispID = RX_DISP_ID) then Cal := FOnDispCal(0)
  else Cal := 0;
  MainCal := Cal;

  Flag := 0;
  GetPixels(DispID, 0, @PixBuf[0], @Flag);
  if Flag <> 0 then
  begin
    for i := 0 to N - 1 do
      FSpectrumPixels[i] := PixBuf[i] + Cal;
    if Assigned(FOnSpectrum) then
      FOnSpectrum(FSpectrumPixels, N);
  end;

  Flag := 0;
  GetPixels(DispID, 1, @PixBuf[0], @Flag);
  if Flag <> 0 then
  begin
    for i := 0 to N - 1 do
      FWaterfallPixels[i] := PixBuf[i] + Cal;
    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;
      // Пан может стоять на своём диапазоне — офсет спрашиваем по его PanId.
      if GotSpec or GotWf then
      begin
        if Assigned(FOnDispCal) then Cal := FOnDispCal(p) else Cal := 0;
        if Cal <> 0 then
        begin
          if GotSpec then
            for i := 0 to N - 1 do PixBuf[i] := PixBuf[i] + Cal;
          if GotWf then
            for i := 0 to N - 1 do WfBuf[i] := WfBuf[i] + Cal;
        end;
      end;
      if GotSpec and Assigned(FOnPanSpectrum) then
        FOnPanSpectrum(p, PixBuf, N);
      if GotWf and Assigned(FOnPanWaterfall) then
        FOnPanWaterfall(p, WfBuf, N);
    end;

  // Лупа маяка: кадр кладём в кэш, а не в колбэк — окно маяка тянет его своим
  // таймером (форма живёт отдельно от главного рендера и может быть закрыта).
  if FBcnOpen then
  begin
    FBcnLock.Enter;
    try
      if FBcnOpen then
      begin
        N := EnsureRange(FBcnPixCount, 0, SPECTRUM_PIXELS);
        Flag := 0;
        GetPixels(BCN_DISP_ID, 0, @PixBuf[0], @Flag);
        if Flag <> 0 then
        begin
          for i := 0 to N - 1 do
            FBcnSpecPix[i] := PixBuf[i] + MainCal;
          FBcnSpecFresh := True;
        end;
        Flag := 0;
        GetPixels(BCN_DISP_ID, 1, @WfBuf[0], @Flag);
        if Flag <> 0 then
        begin
          for i := 0 to N - 1 do
            FBcnWfPix[i] := WfBuf[i] + MainCal;
          FBcnWfFresh := True;
        end;
      end;
    finally
      FBcnLock.Leave;
    end;
  end;
end;

procedure TWDSPEngine.GetSpectrumData(var Pixels: array of Single;
  var Count: Integer);
var
  N: Integer;
begin
  N := Min(SPECTRUM_PIXELS, Length(Pixels));
  if N > 0 then
    Move(FSpectrumPixels[0], Pixels[0], N * SizeOf(Single));
  Count := N;
end;

function TWDSPEngine.GetSMeterDBm: Double;
begin
  // НЕ вызываем GetRXAMeter здесь повторно — значение уже обновлено
  // в DSP-колбэке. Повторный вызов сбрасывает аккумулятор WDSP → 0.
  // Калибровочный офсет здесь НЕ прибавляется: он зависит от диапазона/
  // трансвертера, о которых движок не знает — его добавляет TRadioController
  // (ReadSMeterDBm / SliceSMeter).
  Result := FSMeter;
end;

// ---------------------------------------------------------------------------
// Настройки анализатора (применяются немедленно)
// ---------------------------------------------------------------------------

function TWDSPEngine.NormalizeFFTSize(FFTSize: Integer): Integer;
begin
  // Диапазон display FFT.
  if FFTSize < 4096 then FFTSize := 4096;
  Result := 4096;
  while Result < FFTSize do
    Result := Result shl 1;
  if Result > 262144 then
    Result := 262144;
end;

function TWDSPEngine.CalcDisplayFFTSize: Integer;
begin
  // FFT Size управляет display-analyzer,
  // но не влияет на аудио DSP цепочку.
  Result := FFFTSize;
end;

procedure TWDSPEngine.CalcAnalyzerTiming(AnalyzerFFT: Integer; out Overlap: Integer;
  out EffectiveFPS: Double);
var
  TargetFPS: Double;
begin
  TargetFPS := EnsureRange(FDisplayFPS, 5, 100);
  // Тайминг обновления дисплея:
  // целимся в пользовательский FPS и разрешаем большой overlap даже на крупных FFT.
  Overlap := Max(0, Ceil(AnalyzerFFT - (FSampleRate / TargetFPS)));
  EffectiveFPS := TargetFPS;
end;

procedure TWDSPEngine.NormalizeDisplayParams(var Detector, AvgMode: Integer;
  var AvgTimeMS: Double; ForWaterfall: Boolean);
begin
  Detector := EnsureRange(Detector, 0, 4);

  if ForWaterfall then
  begin
    // Peak hold на водопаде даёт грязную и залипающую картинку.
    if AvgMode < 0 then AvgMode := 1;
  end
  else
    if AvgMode < -1 then AvgMode := -1;

  if AvgMode > 3 then AvgMode := 3;

  if AvgMode <= 0 then
    AvgTimeMS := 0.0
  else
    AvgTimeMS := EnsureRange(AvgTimeMS, 10.0, 5000.0);
end;

procedure TWDSPEngine.AverageTimeToParams(AvgMode: Integer; AvgTimeMS: Double;
  out AvgCount: Integer; out Backmult: Double);
var
  TimeSec: Double;
  Frames: Double;
  FPSv: Double;
begin
  if AvgMode <= 0 then
  begin
    AvgCount := 1;
    Backmult := 0.0;
    Exit;
  end;

  TimeSec := EnsureRange(AvgTimeMS, 10.0, 5000.0) * 0.001;
  FPSv := Max(1.0, FEffectiveDisplayFPS);
  Frames := FPSv * TimeSec;
  AvgCount := EnsureRange(Round(Frames), 2, 60);
  Backmult := Exp(-1.0 / Max(1E-6, FPSv * TimeSec));
  Backmult := EnsureRange(Backmult, 0.0, 0.9999);
end;

procedure TWDSPEngine.SetFFTParams(FFTSize, WinType: Integer);
var
  NewFFT: Integer;
begin
  NewFFT := NormalizeFFTSize(FFTSize);
  if WinType < 0 then WinType := 0;
  if WinType > 6 then WinType := 6;

  if (FFFTSize = NewFFT) and (FWindowType = WinType) then Exit;

  FFFTSize    := NewFFT;
  FWindowType := WinType;
  if FAnalyzerOpen then
    ApplyAnalyzerSettings;
end;

procedure TWDSPEngine.SetDisplayFPS(FPS: Integer);
begin
  FPS := EnsureRange(FPS, 1, 100);
  if FDisplayFPS = FPS then Exit;
  FDisplayFPS := FPS;
  if FAnalyzerOpen then
    ApplyAnalyzerSettings;
end;

procedure TWDSPEngine.SetSpectrumDisplay(Detector, AvgMode: Integer;
  AvgTimeMS: Double);
var
  AvgCount: Integer;
  Backmult: Double;
begin
  NormalizeDisplayParams(Detector, AvgMode, AvgTimeMS, False);

  if (FSpecDetector = Detector) and
     (FSpecAvgMode = AvgMode) and
     (Abs(FSpecAvgTimeMS - AvgTimeMS) < 1E-6) then Exit;

  FSpecDetector := Detector;
  FSpecAvgMode  := AvgMode;
  FSpecAvgTimeMS := AvgTimeMS;
  if not FAnalyzerOpen then Exit;
  AverageTimeToParams(FSpecAvgMode, FSpecAvgTimeMS, AvgCount, Backmult);
  SetDisplayDetectorMode(RX_DISP_ID, 0, FSpecDetector);
  SetDisplayAverageMode(RX_DISP_ID,  0, FSpecAvgMode);
  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;
  AvgTimeMS: Double);
var
  AvgCount: Integer;
  Backmult: Double;
begin
  NormalizeDisplayParams(Detector, AvgMode, AvgTimeMS, True);

  if (FWfDetector = Detector) and
     (FWfAvgMode = AvgMode) and
     (Abs(FWfAvgTimeMS - AvgTimeMS) < 1E-6) then Exit;

  FWfDetector := Detector;
  FWfAvgMode  := AvgMode;
  FWfAvgTimeMS := AvgTimeMS;
  if not FAnalyzerOpen then Exit;
  AverageTimeToParams(FWfAvgMode, FWfAvgTimeMS, AvgCount, Backmult);
  SetDisplayDetectorMode(RX_DISP_ID, 1, FWfDetector);
  SetDisplayAverageMode(RX_DISP_ID,  1, FWfAvgMode);
  SetDisplayNumAverage(RX_DISP_ID,   1, AvgCount);
  SetDisplayAvBackmult(RX_DISP_ID,   1, Backmult);
  SetDisplayNormOneHz(RX_DISP_ID,    1, 0);
  ReapplyPanDisplaySettings;   // детектор/усреднение общие с панами
end;

end.
