{
  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 WebServer;

{
  WebServer.pas — HTTP + WebSocket сервер для удалённого управления трансивером.

  Архитектура веб-подсистемы:
  ─────────────────────────────────
  HTTP GET /          → index.html (см. WebPageHtml)
  HTTP GET /ws        → Upgrade: WebSocket
  WebSocket сессия:
    • Сервер → клиент:
        - каждые ~50 ms: бинарный фрейм типа 'S' + 1024×Float32 спектр
        - каждые ~50 ms: бинарный фрейм типа 'W' + N×Float32 waterfall строка
        - каждые ~100ms: бинарный фрейм типа 'A' + Opus-пакет (48kHz mono)
        - каждые ~200ms: JSON-текст со state (freq, mode, smeter, …)
    • Клиент → сервер: JSON-команды
        "cmd":"freq","hz":14200000
        "cmd":"mode","mode":1
        "cmd":"filter","bw":2700
        "cmd":"agc","mode":1
        "cmd":"agctop","db":90
        "cmd":"band","idx":5
        "cmd":"span","hz":192000
        "cmd":"volume","v":70
        "cmd":"wfagc","on":true
        "cmd":"wfnf","on":true

  Аудио: 48kHz mono Float32 → Opus (20ms frames, 32 kbps)
  Спектр: 1024 Float32 dBm значений
  Авторизация: Basic Auth через HTTP заголовок при первом запросе

  Зависимости: WebUtils, WsClient, WebPageHtml + RTL + libopus (динамическая загрузка)
  Платформы: Windows + Linux (Winsock2 / BSD sockets)

  ИСПРАВЛЕНИЯ:
    - (Windows build fix) SyncObjs перенесён в конец блока uses — устраняет
      конфликт идентификатора Create с символами из WinSock2 в {$MODE Delphi}.
    - (Windows runtime fix) Добавлены WSAStartup/WSACleanup в конструктор и
      деструктор — без этого socket/bind/listen возвращают WSANOTINITIALISED.
    - (Linux shutdown fix) В Stop: перед SockClose вызывается SockShutdown для
      listen-сокета и для каждого клиентского сокета. На Linux закрытие
      дескриптора не прерывает блокирующий fpAccept/fpRecv в чужом потоке —
      только shutdown(SHUT_RDWR) гарантированно разблокирует их, позволяя
      потокам выйти и WaitFor завершиться без зависания.
    - (Audio fix 1) Исправлена константа OPUS_APPLICATION_AUDIO: было 2101
      (невалидное значение), стало 2049 — правильное значение. Неверная
      константа приводила к Err!=0 из opus_encoder_create, FOpusEnc=nil,
      FOpusReady=false — аудио не кодировалось совсем.
    - (Audio fix 2) Заголовки COOP/COEP убраны — они блокировали WebSocket
      и загрузку CDN ресурсов (fonts, opus-decoder), из-за чего
      FWebClientActive никогда не становился true и десктоп звук не
      отключался при подключении веб-клиента.
    - (Audio fix 3) В JS исправлен вызов декодера: decodeFrame → decode
      (актуальный API opus-decoder@0.7.7). decodeFrame не существует в этой
      версии — silent fail, звука нет.
    - (Audio fix 4) Добавлен оверлей "Click to start audio" — AudioContext
      нельзя создать из WebSocket callback (не user gesture). Оверлей
      гарантирует создание AudioContext при первом кликe пользователя.
    - (Audio fix 5) Буферизация Opus-пакетов пока WASM не инициализирован —
      первые пакеты больше не теряются при медленной загрузке CDN.
    - (Audio fix 6) В state-JSON добавлено поле "opus" (= FOpusReady). Если
      libopus в системе нет, LoadOpus молча возвращает False, кадры 'A' не
      уходят и микрофон не декодируется — раньше это выглядело как немой
      звук без объяснений. Теперь страница показывает баннер.
    - (Audio fix 7) LoadOpus перебирает имена библиотеки (OPUS_LIBS), как
      загрузчик PortAudio, а не грузит одно жёстко зашитое: libopus-0.dll /
      libopus.dll / opus.dll, libopus.so.0 / .so, .dylib для macOS.
}

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

interface

uses
  Classes, SysUtils, Math,
  WebUtils, WsClient, WebPageHtml, RadioModes
  {$IFDEF WINDOWS}, Windows, WinSock2{$ELSE}, BaseUnix, Sockets{$ENDIF},
  SyncObjs;   // ← после платформенных юнитов: исключает конфликт идентификатора Create

const
  WEB_PORT         = 8080;
  OPUS_SAMPLE_RATE = 48000;
  OPUS_FRAME_MS    = 20;
  OPUS_FRAME_SAMP  = OPUS_SAMPLE_RATE * OPUS_FRAME_MS div 1000;  // 960 samples
  OPUS_BITRATE     = 32000;
  OPUS_CHANNELS    = 1;
  // ── TX-mic jitter handling (web → server) ──
  // Pre-roll: при старте новой TX-сессии задерживаем 40мс аудио, чтобы дать
  // FTXMicRing запас перед началом потребления — иначе при джиттере WiFi
  // ring мгновенно пустеет и WDSP TX-thread заливает тишину = "робот".
  MIC_PREROLL_MS    = 40;
  MIC_PREROLL_SAMP  = OPUS_SAMPLE_RATE * MIC_PREROLL_MS div 1000;  // 1920
  // Опоздание > MIC_IDLE_MS считаем концом TX-сессии: сбрасываем pre-roll
  // (на следующий пакет — снова накапливаем cushion).
  MIC_IDLE_MS       = 200;
  // Максимум подряд синтезированных PLC-кадров (Opus PLC деградирует после ~3–5 кадров).
  MIC_MAX_PLC       = 5;
  MAX_WS_CLIENTS   = 4;
  WS_GUID          = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
  // Потолок точек в кадре спектра/водопада (настройка web.spec_pixels режет
  // ниже). Совпадает с WDSPEngine.SPECTRUM_PIXELS — больше анализатор не даёт.
  WEB_SPEC_MAX     = 4096;

  // Типы бинарных фреймов (первый байт = тип)
  WS_MSG_SPECTRUM  = Byte(Ord('S'));  // S + N×Float32 (N = длина фрейма, <= WEB_SPEC_MAX)
  WS_MSG_WATERFALL = Byte(Ord('W'));  // W + N×Float32 (N = длина фрейма, <= WEB_SPEC_MAX)
  WS_MSG_AUDIO     = Byte(Ord('A'));  // A + Opus bytes
  WS_MSG_AUDIO_PCM = Byte(Ord('P'));  // P + N×Float32 (mono 48k)
  WS_MSG_STATE     = Byte(Ord('J'));  // J + JSON text

type
  // ── Opus dynamic binding ──────────────────────────────────────────────────
  POpusEncoder = Pointer;

  TOpus_encoder_create  = function(Fs, channels, application: Integer;
                                   error: PInteger): POpusEncoder; cdecl;
  TOpus_encoder_destroy = procedure(st: POpusEncoder); cdecl;
  TOpus_encode_float    = function(st: POpusEncoder;
                                   pcm: PSingle; frame_size: Integer;
                                   data: PByte; max_data_bytes: Integer): Integer; cdecl;
  TOpus_encoder_ctl_set = function(st: POpusEncoder;
                                   request: Integer; value: Integer): Integer; cdecl;

  POpusDecoder = Pointer;
  TOpus_decoder_create  = function(Fs, channels: Integer;
                                   error: PInteger): POpusDecoder; cdecl;
  TOpus_decode_float    = function(st: POpusDecoder; data: PByte; len: Integer;
                                   pcm: PSingle; frame_size, decode_fec: Integer): Integer; cdecl;
  TOpus_decoder_destroy = procedure(st: POpusDecoder); cdecl;

  // ── Callbacks в MainForm ──────────────────────────────────────────────────
  TWebCmdFreq    = procedure(Hz: Double) of object;
  TWebCmdMode    = procedure(Mode: Integer) of object;
  TWebCmdFilter  = procedure(BW: Integer) of object;
  TWebCmdAGC     = procedure(Mode: Integer) of object;
  TWebCmdAGCTop  = procedure(DB: Integer) of object;
  TWebCmdBand    = procedure(Idx: Integer) of object;
  TWebCmdSpan    = procedure(Hz: Integer) of object;
  TWebCmdVolume  = procedure(V: Integer) of object;
  TWebCmdWfAGC   = procedure(On_: Boolean) of object;
  TWebCmdWfNF    = procedure(On_: Boolean) of object;
  TWebCmdRun     = procedure(On_: Boolean) of object;
  TWebCmdMute    = procedure(On_: Boolean) of object;
  TWebCmdCtun    = procedure(On_: Boolean) of object;
  TWebCmdNRMode  = procedure(Mode: Integer) of object;
  TWebCmdNBMode  = procedure(Mode: Integer) of object;
  TWebCmdSNB       = procedure(On_: Boolean) of object;
  TWebCmdANF       = procedure(On_: Boolean) of object;
  TWebCmdFreqB     = procedure(Hz: Double) of object;
  TWebCmdActiveVfo = procedure(Idx: Integer) of object;
  TWebCmdCenter    = procedure(Hz: Double) of object;
  TWebCmdMOX       = procedure(On_: Boolean) of object;
  TWebCmdDrive     = procedure(V: Integer) of object;
  TWebCmdFreqA     = procedure(Hz: Double) of object;
  TWebCmdAttn      = procedure(Idx: Integer) of object;
  TWebCmdTun       = procedure(On_: Boolean) of object;
  TWebCmdFMStep    = procedure(Idx: Integer) of object;
  // XVTR-band: web клиент кликнул кнопку трансвертера (Idx 0..CFG_XVTR_COUNT-1).
  // Idx=-1 — выход в HF.
  TWebCmdXvtrBand  = procedure(Idx: Integer) of object;
  // QO-100 beacon lock: вкл/выкл лок и наведение (клик по маяку, доля 0..1 по ширине).
  TWebCmdBeacon     = procedure(On_: Boolean) of object;
  TWebCmdBeaconSeed = procedure(Frac: Double) of object;
  // Pluto RF-AGC (hw gain): режим FAST/SLOW/HYB/MAN + ручной gain дБ. RX MUTE — QO-100 self-monitor.
  TWebCmdRxGainMode = procedure(Mode: Integer) of object;
  TWebCmdRxGain     = procedure(Db: Integer) of object;
  TWebCmdRxMute     = procedure(On_: Boolean) of object;
  // Активность web-клиента изменилась (подключился/отключился последний клиент).
  // Хозяин зеркалит это в ядро (FController.FWebClientActive) для выбора mic-source.
  TWebClientActiveEvent = procedure(Active: Boolean) of object;
  // Один XVTR-слот для статуса (для отображения в web-bandSel).
  TWebXvtrInfo = record
    Idx: Integer;
    Name: string;
  end;
  TWebXvtrArray = array of TWebXvtrInfo;
  TWebMicCB        = procedure(Samples: PSingle; Count: Integer) of object;

  // ── Discovery / connect / saved-CRUD (паритет с десктоп-диалогом) ──────────
  TWebCmdConnect   = procedure(const IP: string) of object;   // подключиться к IP
  TWebCmdSimple    = procedure of object;                     // discover / disconnect
  TWebCmdDevAdd    = procedure(const AName, IP: string) of object;
  TWebCmdDevIdx    = procedure(Idx: Integer) of object;       // dev_remove / dev_autostart
  // Элемент списка устройств для web-overlay (saved или discovered).
  TWebDeviceInfo = record
    Name:      string;    // имя (saved) или display-строка (discovered)
    IP:        string;
    BoardType: Integer;
    IsSaved:   Boolean;   // true=сохранённое (с именем), false=найденное
    AutoStart: Boolean;   // только для saved
  end;
  TWebDeviceArray = array of TWebDeviceInfo;

  // ── Главный класс сервера ─────────────────────────────────────────────────
  TWebServer = class
  private
    // ── Opus ──
    FOpusLib:     THandle;
    FOpusEnc:     POpusEncoder;
    FOpusCreate:  TOpus_encoder_create;
    FOpusDestroy: TOpus_encoder_destroy;
    FOpusEncode:  TOpus_encode_float;
    FOpusCtl:     TOpus_encoder_ctl_set;
    FOpusBuf:     array[0..OPUS_FRAME_SAMP-1] of Single;
    FOpusBufPos:  Integer;
    FOpusOut:     array[0..3999] of Byte;
    FWsAudioBuf:  array[0..4000] of Byte;   // 1 байт типа + до 4000 байт Opus
    FOpusReady:   Boolean;
    // Opus decoder — для RX TX-mic аудио от браузера
    FOpusDec:        POpusDecoder;
    FOpusDecCreate:  TOpus_decoder_create;
    FOpusDecDecode:  TOpus_decode_float;
    FOpusDecDestroy: TOpus_decoder_destroy;
    FOnWebMic:       TWebMicCB;
    // ── Jitter buffer для TX-mic от веб-клиента ──
    // FMicLastTick = 0  → нет активной TX-сессии (на следующий пакет — pre-roll сброс)
    // FMicPreRollPos < MIC_PREROLL_SAMP → ещё копим pre-roll
    FMicLastTick:    QWord;
    FMicPreRollPos:  Integer;
    FMicPreRoll:     array[0..MIC_PREROLL_SAMP-1] of Single;

    // ── Staging buffer: выравнивание по границе DSP-блока ──────────────────
    // Opus декодирует по 960 сэмплов, а TX DSP потребляет по 512.
    // 960 mod 512 = 448 — в ring всегда остаётся хвост меньше порога,
    // из-за чего TTXDSPThread пропускает тики и DUC hardware голодает.
    // FlushMicToCallback накапливает сэмплы здесь и вызывает FOnWebMic
    // ровно тогда, когда накопился полный блок 512 сэмплов.
    // Гарантия: FTXMicRing всегда получает данные кратными 512.
    FMicStageBuf:  array[0..511] of Single;  // буфер одного DSP-блока
    FMicStageLen:  Integer;                   // сколько сэмплов накоплено

    // ── TCP ──
    FListenSock:  TSocket;
    FClients:     array[0..MAX_WS_CLIENTS-1] of TWsClient;
    FClientCount: Integer;
    FClientLock:  TCriticalSection;

    // ── Потоки ──
    FAcceptThread: TThread;
    FPushThread:   TThread;
    FRunning:      Boolean;

    // ── Авторизация ──
    FAuthToken: string;  // Base64(user:pass)
    // ── Сетевая конфигурация ──
    FPort:    Word;
    FBindIP:  string;

    // ── Состояние (обновляется из MainForm) ──
    FSpectrumBuf: array[0..WEB_SPEC_MAX-1] of Single;
    FSpecCount:   Integer;   // точек в кадре спектра (0 = кадра ещё не было)
    FWfBuf:       array[0..WEB_SPEC_MAX-1] of Single;
    FWfCount:     Integer;   // точек в строке водопада
    FWfFresh:     Boolean;   // строка накапливается (max-hold) и ещё не отдана
    FSMeter:      Double;
    FFreq:        Double;
    FMode:        Integer;
    FFilterBW:    Integer;
    FAGCMode:     Integer;
    FAGCTop:      Integer;
    FSpanHz:      Double;
    FVolume:      Integer;
    FWfAGC:       Boolean;
    FWfNF:        Boolean;
    FBandIdx:     Integer;
    FConnected:   Boolean;
    FTrxRunning:  Boolean;
    FMuted:       Boolean;
    FCtun:        Boolean;
    FNRMode:      Integer;
    FNBMode:      Integer;
    FSNB:         Boolean;
    FANF:         Boolean;
    FCenterHz:    Double;
    FFilterIdx:   Integer;
    FVfoB:        Double;
    FActiveVfo:   Integer;   // 0=A, 1=B
    FFMStepIdx:   Integer;
    FStateLock:   TCriticalSection;

    // ── Callbacks ──
    FOnFreq:   TWebCmdFreq;
    FOnMode:   TWebCmdMode;
    FOnFilter: TWebCmdFilter;
    FOnAGC:    TWebCmdAGC;
    FOnAGCTop: TWebCmdAGCTop;
    FOnBand:   TWebCmdBand;
    FOnSpan:   TWebCmdSpan;
    FOnVolume: TWebCmdVolume;
    FOnWfAGC:  TWebCmdWfAGC;
    FOnWfNF:   TWebCmdWfNF;
    FOnRun:    TWebCmdRun;
    FOnMute:   TWebCmdMute;
    FOnCtun:   TWebCmdCtun;
    FOnNR:     TWebCmdNRMode;
    FOnNB:     TWebCmdNBMode;
    FOnSNB:    TWebCmdSNB;
    FOnANF:    TWebCmdANF;
    FOnFreqB:     TWebCmdFreqB;
    FOnActiveVfo: TWebCmdActiveVfo;
    FOnCenter:    TWebCmdCenter;
    FOnMOX:       TWebCmdMOX;
    FOnDrive:     TWebCmdDrive;
    FTransmitting: Boolean;
    FDriveLevel:   Integer;
    FOnFreqA:     TWebCmdFreqA;
    FOnAttn:      TWebCmdAttn;
    FAttnDB:      Integer;   // шаговый аттенюатор ADC, дБ (0..31)
    FOnTun:       TWebCmdTun;
    FOnFMStep:    TWebCmdFMStep;
    FTuning:      Boolean;
    FDuplex:         Boolean;
    FFreqMhzDigits:  Integer;     // 3=999MHz, 4=9.999GHz, 5=99.999GHz
    FXvtrBands:   TWebXvtrArray;  // список enabled XVTR (для web-UI)
    FDevices:     TWebDeviceArray; // saved+discovered устройства (для web-overlay)
    FRatePresets: array of Integer; // sample-rate пресеты текущего бэкенда (для span-кнопок)
    FBandNames:   array of string;  // band-план текущего бэкенда (имена кнопок)
    FBandFreqs:   array of Double;  // band-план: дефолтные частоты (для подсветки selector)
    FFreqMin:     Double;           // диапазон перестройки бэкенда (клампинг drag/wheel)
    FFreqMax:     Double;
    FCurrentXvtr: Integer;        // -1 = HF, иначе индекс активного XVTR
    FOnXvtrBand:  TWebCmdXvtrBand;

    FFwdW:        Double;
    FSWR:         Double;
    FPAMaxPower:  Double;
    FStatusText:  string;
    FBoardText:   string;
    FIPText:      string;
    FSupplyText:  string;
    FPLLText:     string;
    FRXText:      string;
    FTXText:      string;
    FSeqText:     string;

    // QO-100 beacon lock (Pluto в трансвертере). Зеркалится в state как
    // beacon_visible/on/text/ref_hz/track_hz; команды set_beacon/beacon_seed.
    FBeaconVisible: Boolean;       // кнопка/статус активны (Pluto + XVTR)
    FBeaconOn:      Boolean;       // лок включён (подсветка кнопки)
    FBeaconText:    string;        // компактный статус (ячейка PLL, как десктоп)
    FBeaconRefHz:   Double;        // опорная частота маяка (маркер)
    FBeaconTrackHz: Double;        // измеренная частота маяка (маркер)
    FOnBeacon:      TWebCmdBeacon;
    FOnBeaconSeed:  TWebCmdBeaconSeed;

    // Pluto RF-AGC (hw gain) + QO-100 RX MUTE. Зеркалится в state как
    // rf_agc_visible/rx_gain_mode/rx_gain_db и rx_mute_visible/rx_mute_on;
    // команды set_rx_gain_mode/set_rx_gain/set_rx_mute.
    FRfAgcVisible:  Boolean;       // RF-AGC доступен (Pluto подключён, HasHWGain)
    FRxGainMode:    Integer;       // 0=manual,1=fast,2=slow,3=hybrid
    FRxGainDb:      Integer;       // ручной gain дБ (0..73)
    FRxMuteVisible: Boolean;       // RX MUTE доступна (Pluto + QO-100)
    FRxMuteOn:      Boolean;       // self-monitor mute активен
    FOnRxGainMode:  TWebCmdRxGainMode;
    FOnRxGain:      TWebCmdRxGain;
    FOnRxMute:      TWebCmdRxMute;

    FWebClientActive: Boolean;
    FOnClientActiveChanged: TWebClientActiveEvent;

    // Discovery / connect / saved-CRUD callbacks (маршалятся хозяином в GUI).
    FOnConnect:       TWebCmdConnect;
    FOnDiscoverDev:   TWebCmdSimple;
    FOnDisconnectDev: TWebCmdSimple;
    FOnDevAdd:        TWebCmdDevAdd;
    FOnDevRemove:     TWebCmdDevIdx;
    FOnDevAutoStart:  TWebCmdDevIdx;

    // Обновляет FWebClientActive и при изменении уведомляет хозяина. Вызывать
    // под уже взятой блокировкой поля (FStateLock/FClientLock) — сам не лочит.
    procedure SetClientActive(Value: Boolean);

    // ── Внутренние методы ──
    function  LoadOpus: Boolean;
    procedure UnloadOpus;
    procedure PushMicSamples(P: PSingle; N: Integer);
    procedure FlushMicToCallback(P: PSingle; N: Integer);
    function  InitListen: Boolean;
    procedure AcceptLoop;
    procedure PushLoop;
    procedure HandleClient(Client: TWsClient);
    procedure ProcessCommand(Client: TWsClient; const Json: string);
    procedure BroadcastBinary(const Data; Len: Integer);
    procedure BroadcastText(const S: string);
    procedure RemoveClient(Client: TWsClient);
    function  BuildStateJson: string;
    function  CheckAuth(const Header: string): Boolean;
    procedure SendHttp(Client: TWsClient; Code: Integer; const ContentType, Body: string);
    // Stub-методы (реализация встроена в HandleClient)
    procedure DoHandshake(Client: TWsClient);
    procedure ProcessWsFrame(Client: TWsClient; const Data: array of Byte; Len: Integer; Opcode: Byte);

  public
    constructor Create(const Username, Password: string;
                       Port: Word = 8080; const BindIP: string = '0.0.0.0');
    destructor  Destroy; override;

    function  Start: Boolean;
    procedure Stop;
    procedure Reconfigure(const Username, Password, BindIP: string; Port: Word);

    // Вызывается из DSP-потока (аудио, 48kHz mono)
    procedure PushAudio(const Samples: PSingle; Count: Integer);
    // Вызывается из таймера спектра (UI thread)
    procedure PushSpectrum(
      const Buf: array of Single; Count: Integer;
      const WfBuf_: array of Single; WfCount: Integer; WfNew: Boolean;
      SMeter: Double;
      Freq: Double; Mode, FilterBW, AGCMode, AGCTop: Integer;
      SpanHz: Double; Volume: Integer;
      WfAGC, WfNF: Boolean; BandIdx: Integer;
      TrxConnected: Boolean;
      TrxRunning, Muted, Ctun: Boolean;
      NRMode, NBMode: Integer; SNB, ANF: Boolean;
      CenterHz: Double; FilterIdx: Integer;
      VfoB: Double; ActiveVfo: Integer;
      Transmitting: Boolean; DriveLevel: Integer;
      AttnDB: Integer; Tuning: Boolean; Duplex: Boolean;
      FwdW, SWRV, PAMaxPower: Double;
      const StatusText, BoardText, IPText, SupplyText, PLLText,
      RXText, TXText, SeqText: string);

    property WebClientActive: Boolean read FWebClientActive;
    property OnClientActiveChanged: TWebClientActiveEvent
      read FOnClientActiveChanged write FOnClientActiveChanged;
    property FreqMhzDigits: Integer read FFreqMhzDigits write FFreqMhzDigits;
    property FMStepIdx: Integer read FFMStepIdx write FFMStepIdx;

    property OnFreq:   TWebCmdFreq   read FOnFreq   write FOnFreq;
    property OnMode:   TWebCmdMode   read FOnMode   write FOnMode;
    property OnFilter: TWebCmdFilter read FOnFilter write FOnFilter;
    property OnAGC:    TWebCmdAGC    read FOnAGC    write FOnAGC;
    property OnAGCTop: TWebCmdAGCTop read FOnAGCTop write FOnAGCTop;
    property OnBand:     TWebCmdBand     read FOnBand     write FOnBand;
    property OnXvtrBand: TWebCmdXvtrBand read FOnXvtrBand write FOnXvtrBand;
    procedure SetXvtrBands(const ABands: TWebXvtrArray; ACurrent: Integer);
    procedure SetDeviceList(const ADevices: TWebDeviceArray);
    procedure SetRatePresets(const ARates: array of Integer);
    procedure SetBands(const ANames: array of string; const AFreqs: array of Double);
    procedure SetFreqRange(AMin, AMax: Double);
    // QO-100 beacon lock: зеркало статуса в state (зовётся из PushState хозяина).
    procedure SetBeaconStatus(AVisible, AOn: Boolean; const AText: string;
                              ARefHz, ATrackHz: Double);
    // Pluto RF-AGC + QO-100 RX MUTE: зеркало в state (зовётся из PushState хозяина).
    procedure SetRxGainStatus(AVisible: Boolean; AMode, ADb: Integer);
    procedure SetRxMuteStatus(AVisible, AOn: Boolean);
    property OnBeacon:     TWebCmdBeacon     read FOnBeacon     write FOnBeacon;
    property OnBeaconSeed: TWebCmdBeaconSeed read FOnBeaconSeed write FOnBeaconSeed;
    property OnRxGainMode: TWebCmdRxGainMode read FOnRxGainMode write FOnRxGainMode;
    property OnRxGain:     TWebCmdRxGain     read FOnRxGain     write FOnRxGain;
    property OnRxMute:     TWebCmdRxMute     read FOnRxMute     write FOnRxMute;
    property OnSpan:   TWebCmdSpan   read FOnSpan   write FOnSpan;
    property OnVolume: TWebCmdVolume read FOnVolume write FOnVolume;
    property OnWfAGC:  TWebCmdWfAGC  read FOnWfAGC  write FOnWfAGC;
    property OnWfNF:   TWebCmdWfNF   read FOnWfNF   write FOnWfNF;
    property OnRun:    TWebCmdRun    read FOnRun    write FOnRun;
    property OnMute:   TWebCmdMute   read FOnMute   write FOnMute;
    property OnCtun:   TWebCmdCtun   read FOnCtun   write FOnCtun;
    property OnNR:     TWebCmdNRMode read FOnNR     write FOnNR;
    property OnNB:     TWebCmdNBMode read FOnNB     write FOnNB;
    property OnSNB:       TWebCmdSNB       read FOnSNB       write FOnSNB;
    property OnANF:       TWebCmdANF       read FOnANF       write FOnANF;
    property OnFreqB:     TWebCmdFreqB     read FOnFreqB     write FOnFreqB;
    property OnActiveVfo: TWebCmdActiveVfo read FOnActiveVfo write FOnActiveVfo;
    property OnCenter:    TWebCmdCenter    read FOnCenter    write FOnCenter;
    property OnMOX:       TWebCmdMOX       read FOnMOX       write FOnMOX;
    property OnDrive:     TWebCmdDrive     read FOnDrive     write FOnDrive;
    property OnFreqA:     TWebCmdFreqA     read FOnFreqA     write FOnFreqA;
    property OnAttn:      TWebCmdAttn      read FOnAttn      write FOnAttn;
    property OnTun:       TWebCmdTun       read FOnTun       write FOnTun;
    property OnFMStep:    TWebCmdFMStep    read FOnFMStep    write FOnFMStep;
    property OnWebMic:    TWebMicCB        read FOnWebMic    write FOnWebMic;
    property OnConnect:       TWebCmdConnect read FOnConnect       write FOnConnect;
    property OnDiscoverDev:   TWebCmdSimple  read FOnDiscoverDev   write FOnDiscoverDev;
    property OnDisconnectDev: TWebCmdSimple  read FOnDisconnectDev write FOnDisconnectDev;
    property OnDevAdd:        TWebCmdDevAdd  read FOnDevAdd        write FOnDevAdd;
    property OnDevRemove:     TWebCmdDevIdx  read FOnDevRemove     write FOnDevRemove;
    property OnDevAutoStart:  TWebCmdDevIdx  read FOnDevAutoStart  write FOnDevAutoStart;
  end;

implementation

{ ═══════════════════════════════════════════════════════════════════════════
  Внутренние классы потоков
  ═══════════════════════════════════════════════════════════════════════════ }

type
  TAcceptThread = class(TThread)
  private FServer: TWebServer;
  protected procedure Execute; override;
  public constructor Create(AServer: TWebServer);
  end;

  TPushThread = class(TThread)
  private FServer: TWebServer;
  protected procedure Execute; override;
  public constructor Create(AServer: TWebServer);
  end;

  TClientThread = class(TThread)
  private FServer: TWebServer; FClient: TWsClient;
  protected procedure Execute; override;
  public constructor Create(AServer: TWebServer; AClient: TWsClient);
  end;

constructor TAcceptThread.Create(AServer: TWebServer);
begin
  inherited Create(True);
  FServer := AServer;
  FreeOnTerminate := False;
end;

procedure TAcceptThread.Execute;
begin
  FServer.AcceptLoop;
end;

constructor TPushThread.Create(AServer: TWebServer);
begin
  inherited Create(True);
  FServer := AServer;
  FreeOnTerminate := False;
end;

procedure TPushThread.Execute;
begin
  FServer.PushLoop;
end;

constructor TClientThread.Create(AServer: TWebServer; AClient: TWsClient);
begin
  inherited Create(True);
  FServer := AServer;
  FClient := AClient;
  FreeOnTerminate := True;
end;

procedure TClientThread.Execute;
begin
  FServer.HandleClient(FClient);
end;

{ ═══════════════════════════════════════════════════════════════════════════
  TWebServer — конструктор / деструктор
  ═══════════════════════════════════════════════════════════════════════════ }

constructor TWebServer.Create(const Username, Password: string;
                              Port: Word; const BindIP: string);
{$IFDEF WINDOWS}
var
  WSAData: TWSAData;
{$ENDIF}
begin
  {$IFDEF WINDOWS}
  // Инициализация Winsock2 — обязательна перед любыми вызовами socket API
  WSAStartup($0202, WSAData);
  {$ENDIF}
  inherited Create;
  FAuthToken   := Base64EncodeStr(Username + ':' + Password);
  FPort        := Port;
  FBindIP      := BindIP;
  FListenSock  := SOCK_INVALID;
  FRunning     := False;
  FClientCount := 0;
  FOpusReady   := False;
  FOpusBufPos  := 0;
  FWebClientActive := False;
  FClientLock  := TCriticalSection.Create;
  FStateLock   := TCriticalSection.Create;
  // Начальные значения состояния
  FFreq      := 14200000;
  FMode      := MODE_USB;
  FFMStepIdx := 3;   // 25 kHz default
  FFilterBW:= 2700;
  FAGCMode := 1;
  FAGCTop  := 90;
  FSpanHz  := 192000;
  FVolume  := 70;
  FSMeter     := -120;
  FFwdW       := 0;
  FSWR        := 1.0;
  FPAMaxPower := 100.0;
  FStatusText := 'Disconnected';
  FBoardText  := 'Board --';
  FIPText     := 'IP --';
  FSupplyText := 'Supply --';
  FPLLText    := 'PLL --';
  FRXText     := 'RX idle';
  FTXText     := 'TX idle';
  FSeqText    := 'SEQ --';
  FBandIdx    := 5;
  FCurrentXvtr  := -1;
  FFreqMhzDigits := 3;
  FBeaconVisible := False;
  FBeaconOn      := False;
  FBeaconText    := '';
  FBeaconRefHz   := 0;
  FBeaconTrackHz := 0;
  FRfAgcVisible  := False;
  FRxGainMode    := 2;     // slow_attack по умолчанию (как контроллер)
  FRxGainDb      := 40;
  FRxMuteVisible := False;
  FRxMuteOn      := False;
  SetLength(FXvtrBands, 0);
  // Bootstrap-набор рейтов (до connect): хост перезапишет под бэкенд (HPSDR/Pluto).
  SetRatePresets([48000, 96000, 192000, 384000, 768000, 1536000]);
  // Bootstrap band-план (HF): хост перезапишет под бэкенд на connect.
  SetBands(['160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m'],
           [1840000,3700000,5357000,7100000,10120000,14200000,
            18130000,21300000,24940000,28400000,50150000]);
  SetFreqRange(0, 61440000);   // HPSDR bootstrap; хост перезапишет под бэкенд
end;

procedure TWebServer.SetXvtrBands(const ABands: TWebXvtrArray; ACurrent: Integer);
var i: Integer;
begin
  FStateLock.Enter;
  try
    SetLength(FXvtrBands, Length(ABands));
    for i := 0 to High(ABands) do
      FXvtrBands[i] := ABands[i];
    FCurrentXvtr := ACurrent;
  finally
    FStateLock.Leave;
  end;
end;

procedure TWebServer.SetBeaconStatus(AVisible, AOn: Boolean; const AText: string;
  ARefHz, ATrackHz: Double);
begin
  FStateLock.Enter;
  try
    FBeaconVisible := AVisible;
    FBeaconOn      := AOn;
    FBeaconText    := AText;
    FBeaconRefHz   := ARefHz;
    FBeaconTrackHz := ATrackHz;
  finally
    FStateLock.Leave;
  end;
end;

procedure TWebServer.SetRxGainStatus(AVisible: Boolean; AMode, ADb: Integer);
begin
  FStateLock.Enter;
  try
    FRfAgcVisible := AVisible;
    FRxGainMode   := AMode;
    FRxGainDb     := ADb;
  finally
    FStateLock.Leave;
  end;
end;

procedure TWebServer.SetRxMuteStatus(AVisible, AOn: Boolean);
begin
  FStateLock.Enter;
  try
    FRxMuteVisible := AVisible;
    FRxMuteOn      := AOn;
  finally
    FStateLock.Leave;
  end;
end;

procedure TWebServer.SetDeviceList(const ADevices: TWebDeviceArray);
var i: Integer;
begin
  FStateLock.Enter;
  try
    SetLength(FDevices, Length(ADevices));
    for i := 0 to High(ADevices) do
      FDevices[i] := ADevices[i];
  finally
    FStateLock.Leave;
  end;
end;

procedure TWebServer.SetRatePresets(const ARates: array of Integer);
// Список пресетов sample-rate текущего бэкенда (из TRadioController). Web строит
// span-кнопки из него — HPSDR и Pluto имеют разные наборы.
var i: Integer;
begin
  FStateLock.Enter;
  try
    SetLength(FRatePresets, Length(ARates));
    for i := 0 to High(ARates) do
      FRatePresets[i] := ARates[i];
  finally
    FStateLock.Leave;
  end;
end;

procedure TWebServer.SetBands(const ANames: array of string; const AFreqs: array of Double);
// Band-план текущего бэкенда (из TRadioController). Web строит band-selector из
// него — HPSDR (HF) и Pluto (VHF/UHF) имеют разные планы.
var i: Integer;
begin
  FStateLock.Enter;
  try
    SetLength(FBandNames, Length(ANames));
    SetLength(FBandFreqs, Length(AFreqs));
    for i := 0 to High(ANames) do FBandNames[i] := ANames[i];
    for i := 0 to High(AFreqs) do FBandFreqs[i] := AFreqs[i];
  finally
    FStateLock.Leave;
  end;
end;

procedure TWebServer.SetFreqRange(AMin, AMax: Double);
// Диапазон перестройки текущего бэкенда (caps): web клампит drag/wheel/click по
// спектру в эти границы. HPSDR 0..61.44МГц / Pluto 46.875МГц..6ГГц.
begin
  FStateLock.Enter;
  try
    FFreqMin := AMin;
    FFreqMax := AMax;
  finally
    FStateLock.Leave;
  end;
end;

destructor TWebServer.Destroy;
begin
  Stop;
  FClientLock.Free;
  FStateLock.Free;
  inherited;
  {$IFDEF WINDOWS}
  // Освобождение ресурсов Winsock2
  WSACleanup;
  {$ENDIF}
end;

{ ═══════════════════════════════════════════════════════════════════════════
  Загрузка / выгрузка Opus
  ═══════════════════════════════════════════════════════════════════════════ }

function TWebServer.LoadOpus: Boolean;
// Имя файла у каждой сборки libopus своё: MSYS2 кладёт рядом с exe libopus-0.dll,
// собранная руками или взятая из чужого дистрибутива — libopus.dll, у Unix это
// soname libopus.so.0, у Homebrew — .dylib в своём префиксе. Поэтому перебираем
// кандидатов, как это давно делает загрузчик PortAudio (AudioOutput.LoadLib):
// одно жёстко зашитое имя означало бы немой web там, где библиотека есть.
const
{$IFDEF DARWIN}
  OPUS_LIBS: array[0..3] of AnsiString = (
    'libopus.dylib',
    'libopus.0.dylib',
    '/usr/local/lib/libopus.dylib',
    '/opt/homebrew/lib/libopus.dylib'
  );
{$ELSE}
  {$IFDEF UNIX}
  OPUS_LIBS: array[0..2] of AnsiString = (
    'libopus.so.0',
    'libopus.so',
    'libopus.so.0.8.0'
  );
  {$ELSE}
  OPUS_LIBS: array[0..2] of AnsiString = (
    'libopus-0.dll',
    'libopus.dll',
    'opus.dll'
  );
  {$ENDIF}
{$ENDIF}
var Err, i: Integer;
begin
  Result := False;
  for i := 0 to High(OPUS_LIBS) do
  begin
    FOpusLib := LoadLibrary(OPUS_LIBS[i]);
    if FOpusLib <> 0 then Break;
  end;
  if FOpusLib = 0 then Exit;

  FOpusCreate  := TOpus_encoder_create( GetProcAddress(FOpusLib, 'opus_encoder_create'));
  FOpusDestroy := TOpus_encoder_destroy(GetProcAddress(FOpusLib, 'opus_encoder_destroy'));
  FOpusEncode  := TOpus_encode_float(   GetProcAddress(FOpusLib, 'opus_encode_float'));
  FOpusCtl     := TOpus_encoder_ctl_set(GetProcAddress(FOpusLib, 'opus_encoder_ctl'));
  FOpusDecCreate  := TOpus_decoder_create( GetProcAddress(FOpusLib, 'opus_decoder_create'));
  FOpusDecDecode  := TOpus_decode_float(   GetProcAddress(FOpusLib, 'opus_decode_float'));
  FOpusDecDestroy := TOpus_decoder_destroy(GetProcAddress(FOpusLib, 'opus_decoder_destroy'));

  if not Assigned(FOpusCreate) or not Assigned(FOpusEncode) then
  begin
    FreeLibrary(FOpusLib); FOpusLib := 0; Exit;
  end;

  FOpusEnc := FOpusCreate(OPUS_SAMPLE_RATE, OPUS_CHANNELS,
                           2049 {OPUS_APPLICATION_AUDIO}, @Err);
  if (FOpusEnc = nil) or (Err <> 0) then
  begin
    FreeLibrary(FOpusLib); FOpusLib := 0; Exit;
  end;
  // OPUS_SET_BITRATE_REQUEST = 4002
  if Assigned(FOpusCtl) then
    FOpusCtl(FOpusEnc, 4002, OPUS_BITRATE);

  // Декодер для TX mic (браузер → Opus → WDSP)
  if Assigned(FOpusDecCreate) then
  begin
    Err := 0;
    FOpusDec := FOpusDecCreate(OPUS_SAMPLE_RATE, OPUS_CHANNELS, @Err);
    if Err <> 0 then FOpusDec := nil;
  end;

  FOpusBufPos := 0;
  FOpusReady  := True;
  Result      := True;
end;

procedure TWebServer.FlushMicToCallback(P: PSingle; N: Integer);
// Аккумулирует сэмплы в FMicStageBuf и вызывает FOnWebMic ровно когда
// накопился полный DSP-блок (512 сэмплов). Неполный остаток хранится
// в буфере до следующего вызова.
//
// Почему 512: TX DSP поток читает из FTXMicRing блоками по FAudioBufSize=512.
// Если ring содержит 0 < Avail < 512, поток пропускает тик → DUC underflow.
// Staging гарантирует что ring всегда получает данные кратными 512.
var
  Src:  PSingle;
  Fill: Integer;
begin
  if not Assigned(FOnWebMic) or (N <= 0) then Exit;
  Src := P;
  while N > 0 do
  begin
    // Докладываем в stage сколько нужно до полного блока (или сколько есть)
    Fill := Min(N, 512 - FMicStageLen);
    Move(Src^, FMicStageBuf[FMicStageLen], Fill * SizeOf(Single));
    Inc(FMicStageLen, Fill);
    Inc(Src, Fill);
    Dec(N, Fill);

    // Полный блок готов — передаём в DSP-цепь и сбрасываем stage
    if FMicStageLen = 512 then
    begin
      FOnWebMic(@FMicStageBuf[0], 512);
      FMicStageLen := 0;
    end;
  end;
end;

procedure TWebServer.PushMicSamples(P: PSingle; N: Integer);
// Прокладка между Opus-декодером и FlushMicToCallback с pre-roll cushion.
// Первые MIC_PREROLL_SAMP сэмплов TX-сессии копим в FMicPreRoll и сливаем
// одним блоком — это даёт FTXMicRing запас глубины ~40мс на джиттер.
// После pre-roll все сэмплы уходят через FlushMicToCallback, которая
// выравнивает поток по границе 512 сэмплов.
var
  Want, Remainder: Integer;
  P2: PSingle;
begin
  if not Assigned(FOnWebMic) or (N <= 0) then Exit;
  if FMicPreRollPos < MIC_PREROLL_SAMP then
  begin
    Want := MIC_PREROLL_SAMP - FMicPreRollPos;
    if N <= Want then
    begin
      Move(P^, FMicPreRoll[FMicPreRollPos], N * SizeOf(Single));
      Inc(FMicPreRollPos, N);
      // N = Want точно заполняет pre-roll — надо слить (иначе буфер потерян)
      if FMicPreRollPos = MIC_PREROLL_SAMP then
        FlushMicToCallback(@FMicPreRoll[0], MIC_PREROLL_SAMP);
    end
    else
    begin
      // Pre-roll заполнен: сливаем накопленный буфер + остаток пакета
      Move(P^, FMicPreRoll[FMicPreRollPos], Want * SizeOf(Single));
      FMicPreRollPos := MIC_PREROLL_SAMP;
      FlushMicToCallback(@FMicPreRoll[0], MIC_PREROLL_SAMP);
      Remainder := N - Want;
      P2 := P;
      Inc(P2, Want);
      FlushMicToCallback(P2, Remainder);
    end;
  end
  else
    FlushMicToCallback(P, N);
end;

procedure TWebServer.UnloadOpus;
begin
  if FOpusReady and Assigned(FOpusDestroy) and (FOpusEnc <> nil) then
    FOpusDestroy(FOpusEnc);
  FOpusEnc   := nil;
  if Assigned(FOpusDec) and Assigned(FOpusDecDestroy) then
    FOpusDecDestroy(FOpusDec);
  FOpusDec   := nil;
  FOpusReady := False;
  if FOpusLib <> 0 then
  begin
    FreeLibrary(FOpusLib);
    FOpusLib := 0;
  end;
end;

{ ═══════════════════════════════════════════════════════════════════════════
  Start / Stop
  ═══════════════════════════════════════════════════════════════════════════ }

function ParseIPv4(const S: string): LongWord;
// Парсит dotted-decimal '1.2.3.4', возвращает сетевой порядок байт.
// '0.0.0.0' и '' → INADDR_ANY (0).
var
  P, Start: PChar;
  Parts: array[0..3] of Byte;
  Idx, V: Integer;
begin
  Result := 0;
  if (S = '') or (S = '0.0.0.0') then Exit;
  Idx := 0;
  P := PChar(S);
  Start := P;
  while True do
  begin
    if (P^ = '.') or (P^ = #0) then
    begin
      if Idx > 3 then Exit;
      V := StrToIntDef(Copy(S, Start - PChar(S) + 1, P - Start), -1);
      if (V < 0) or (V > 255) then Exit;
      Parts[Idx] := Byte(V);
      Inc(Idx);
      if P^ = #0 then Break;
      Inc(P);
      Start := P;
    end else
      Inc(P);
  end;
  if Idx <> 4 then Exit;
  // Сетевой порядок: старший байт первый
  Result := (LongWord(Parts[0]) shl 24) or (LongWord(Parts[1]) shl 16)
          or (LongWord(Parts[2]) shl  8) or  LongWord(Parts[3]);
  Result := htonl(Result);
end;

function TWebServer.InitListen: Boolean;
var
  Addr: {$IFDEF WINDOWS}TSockAddrIn{$ELSE}TInetSockAddr{$ENDIF};
  One: Integer;
begin
  Result := False;
  {$IFDEF WINDOWS}
  FListenSock := socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
  {$ELSE}
  FListenSock := fpSocket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
  {$ENDIF}
  if FListenSock = SOCK_INVALID then Exit;

  One := 1;
  {$IFDEF WINDOWS}
  setsockopt(FListenSock, SOL_SOCKET, SO_REUSEADDR, @One, SizeOf(One));
  FillChar(Addr, SizeOf(Addr), 0);
  Addr.sin_family      := AF_INET;
  Addr.sin_port        := htons(FPort);
  Addr.sin_addr.S_addr := ParseIPv4(FBindIP);
  if bind(FListenSock, @Addr, SizeOf(Addr)) = SOCKET_ERROR then Exit;
  if listen(FListenSock, 5) = SOCKET_ERROR then Exit;
  {$ELSE}
  fpSetSockOpt(FListenSock, SOL_SOCKET, SO_REUSEADDR, @One, SizeOf(One));
  FillChar(Addr, SizeOf(Addr), 0);
  Addr.sin_family      := AF_INET;
  Addr.sin_port        := htons(FPort);
  Addr.sin_addr.s_addr := ParseIPv4(FBindIP);
  if fpBind(FListenSock, @Addr, SizeOf(Addr)) <> 0 then Exit;
  if fpListen(FListenSock, 5) <> 0 then Exit;
  {$ENDIF}
  Result := True;
end;

function TWebServer.Start: Boolean;
begin
  Result := False;
  if FRunning then Exit;
  if not LoadOpus then ; // Opus опционален — продолжаем без него
  if not InitListen then Exit;
  FRunning := True;
  FAcceptThread := TAcceptThread.Create(Self);
  TAcceptThread(FAcceptThread).Start;
  FPushThread := TPushThread.Create(Self);
  TPushThread(FPushThread).Start;
  Result := True;
end;

procedure TWebServer.Stop;
var i: Integer;
begin
  if not FRunning then Exit;
  FRunning := False;

  // ── Шаг 1: shutdown + close listen-сокета ────────────────────────────────
  // SockShutdown ОБЯЗАТЕЛЕН перед SockClose на Linux: закрытие дескриптора
  // не прерывает fpAccept в AcceptThread — только shutdown разблокирует его.
  // На Windows это тоже корректно (SD_BOTH).
  if FListenSock <> SOCK_INVALID then
  begin
    SockShutdown(FListenSock);
    SockClose(FListenSock);
    FListenSock := SOCK_INVALID;
  end;

  // ── Шаг 2: shutdown всех клиентских сокетов ──────────────────────────────
  // Разблокирует все HandleClient, заблокированные в Client.Recv (fpRecv).
  // FreeOnTerminate=True у TClientThread — они освободятся сами после выхода.
  FClientLock.Enter;
  try
    for i := 0 to FClientCount - 1 do
      if FClients[i] <> nil then
      begin
        FClients[i].State := wsClosed;
        SockShutdown(FClients[i].Socket);  // ← разблокирует fpRecv в клиентском потоке
      end;
  finally
    FClientLock.Leave;
  end;

  // ── Шаг 3: ждём завершения фоновых потоков ───────────────────────────────
  // После shutdown потоки получат ошибку из recv/accept и выйдут сами.
  if FAcceptThread <> nil then begin FAcceptThread.WaitFor; FreeAndNil(FAcceptThread); end;
  if FPushThread   <> nil then begin FPushThread.WaitFor;   FreeAndNil(FPushThread);   end;

  // ── Шаг 4: освобождаем клиентов ──────────────────────────────────────────
  FClientLock.Enter;
  try
    for i := 0 to FClientCount - 1 do FreeAndNil(FClients[i]);
    FClientCount := 0;
  finally
    FClientLock.Leave;
  end;

  UnloadOpus;
end;

procedure TWebServer.Reconfigure(const Username, Password, BindIP: string; Port: Word);
begin
  Stop;
  FAuthToken := Base64EncodeStr(Username + ':' + Password);
  FPort      := Port;
  FBindIP    := BindIP;
end;

{ ═══════════════════════════════════════════════════════════════════════════
  Accept loop
  ═══════════════════════════════════════════════════════════════════════════ }

procedure TWebServer.AcceptLoop;
var
  CSock:  TSocket;
  Addr:   {$IFDEF WINDOWS}TSockAddrIn{$ELSE}TInetSockAddr{$ENDIF};
  ALen:   {$IFDEF WINDOWS}Integer{$ELSE}TSockLen{$ENDIF};
  Client: TWsClient;
  T:      TClientThread;
begin
  while FRunning do
  begin
    ALen := SizeOf(Addr);
    {$IFDEF WINDOWS}
    CSock := accept(FListenSock, @Addr, @ALen);
    {$ELSE}
    CSock := fpAccept(FListenSock, @Addr, @ALen);
    {$ENDIF}
    if CSock = SOCK_INVALID then
    begin
      if FRunning then Sleep(10);
      Continue;
    end;
    if FClientCount >= MAX_WS_CLIENTS then
    begin
      SockClose(CSock);
      Continue;
    end;
    // 1 секунда на отправку: если TCP-буфер клиента переполнен, SockSend
    // вернёт ошибку вместо того чтобы висеть и держать FClientLock вечно.
    SockSetSndTimeout(CSock, 1000);
    Client := TWsClient.Create(CSock);
    FClientLock.Enter;
    try
      FClients[FClientCount] := Client;
      Inc(FClientCount);
    finally
      FClientLock.Leave;
    end;
    T := TClientThread.Create(Self, Client);
    T.Start;
  end;
end;

{ ═══════════════════════════════════════════════════════════════════════════
  HTTP / WebSocket обработчик клиента
  ═══════════════════════════════════════════════════════════════════════════ }

function TWebServer.CheckAuth(const Header: string): Boolean;
var
  Pos_: Integer;
  Token, HeaderLC: string;
begin
  Result := False;
  HeaderLC := LowerCase(Header);
  Pos_ := System.Pos('authorization: basic ', HeaderLC);
  if Pos_ = 0 then Exit;
  Token := Copy(Header, Pos_ + 21, 200);
  Pos_ := System.Pos(#13, Token); if Pos_ > 0 then Token := Copy(Token, 1, Pos_ - 1);
  Pos_ := System.Pos(#10, Token); if Pos_ > 0 then Token := Copy(Token, 1, Pos_ - 1);
  Token := Trim(Token);
  Result := (Token = FAuthToken);
end;

procedure TWebServer.SendHttp(Client: TWsClient; Code: Integer;
  const ContentType, Body: string);
var
  StatusText, Response: string;
begin
  case Code of
    200: StatusText := 'OK';
    401: StatusText := 'Unauthorized';
    404: StatusText := 'Not Found';
    else StatusText := 'Error';
  end;
  Response := Format('HTTP/1.1 %d %s'#13#10 +
    'Content-Type: %s'#13#10 +
    'Content-Length: %d'#13#10 +
    'Connection: close'#13#10 +
    #13#10 + '%s', [Code, StatusText, ContentType, Length(Body), Body]);
  Client.SendRaw(Response[1], Length(Response));
end;

procedure TWebServer.HandleClient(Client: TWsClient);
var
  R, HeaderEnd:  Integer;
  Header, HeaderLC, Key, Path, AcceptKey: string;
  Response:      string;
  WsHandled:     Boolean;
  IsWsRequest:   Boolean;
  // WS frame parsing
  B0, B1:        Byte;
  Masked:        Boolean;
  PayLen:        Integer;
  Mask:          array[0..3] of Byte;
  Payload:       array of Byte;
  Opcode:        Byte;
  i, Need:       Integer;
  j:             Integer;
  P1, P2:        Integer;
  KPos, KEnd:    Integer;
  Consumed:      Integer;
  Raw:           array[0..8191] of Byte;
  RawLen:        Integer;
  PcmBuf:        array[0..5759] of Single;  // 120ms max @ 48kHz (для RX TX-mic)
  Decoded:       Integer;
  NowTick:       QWord;
  MissedFrames:  Integer;
  PlcDecoded:    Integer;
  k:             Integer;
begin
  WsHandled := False;
  RawLen    := 0;

  // ── Фаза 1: чтение HTTP-запроса ──────────────────────────────────────────
  Header := '';
  repeat
    R := SockRecv(Client.Socket, @Raw[RawLen], SizeOf(Raw) - RawLen, 0);
    if R <= 0 then begin Client.State := wsClosed; Break; end;
    Inc(RawLen, R);
    SetLength(Header, RawLen);
    Move(Raw[0], Header[1], RawLen);
    HeaderEnd := System.Pos(#13#10#13#10, Header);
  until (HeaderEnd > 0) or (RawLen >= SizeOf(Raw));

  if (Client.State = wsClosed) or (HeaderEnd = 0) then
  begin
    RemoveClient(Client); Exit;
  end;

  Header   := Copy(Header, 1, HeaderEnd + 3);
  HeaderLC := LowerCase(Header);

  // Извлечь путь
  Path := '';
  if System.Pos('GET /', Header) > 0 then
  begin
    P1 := System.Pos('GET ', Header) + 4;
    P2 := System.Pos(' HTTP', Header);
    if P2 > P1 then Path := Copy(Header, P1, P2 - P1);
  end;

  IsWsRequest := (Path = '/ws') and (System.Pos('upgrade: websocket', HeaderLC) > 0);

  // Basic Auth (только для HTTP-страниц; WS handshake без авторизации)
  if (not IsWsRequest) and (not CheckAuth(Header)) then
  begin
    Response := 'HTTP/1.1 401 Unauthorized'#13#10 +
      'WWW-Authenticate: Basic realm="HPSDR"'#13#10 +
      'Content-Length: 0'#13#10 +
      'Connection: close'#13#10#13#10;
    Client.SendRaw(Response[1], Length(Response));
    RemoveClient(Client); Exit;
  end;

  // WebSocket upgrade
  if System.Pos('upgrade: websocket', HeaderLC) > 0 then
  begin
    KPos := System.Pos('sec-websocket-key: ', HeaderLC);
    if KPos > 0 then
    begin
      Key  := Copy(Header, KPos + 19, 100);
      KEnd := System.Pos(#13, Key);
      if KEnd > 0 then Key := Copy(Key, 1, KEnd - 1);
      Key := Trim(Key);
    end;
    AcceptKey := Base64EncodeBytes(SHA1(Key + WS_GUID), 20);
    Response := 'HTTP/1.1 101 Switching Protocols'#13#10 +
      'Upgrade: websocket'#13#10 +
      'Connection: Upgrade'#13#10 +
      'Sec-WebSocket-Accept: ' + AcceptKey + #13#10#13#10;
    Client.SendRaw(Response[1], Length(Response));
    Client.State := wsOpen;

    FStateLock.Enter;
    SetClientActive(True);
    FStateLock.Leave;

    Client.SendText(BuildStateJson);
    WsHandled := True;
  end
  else if Path = '/' then
  begin
    SendHttp(Client, 200, 'text/html; charset=utf-8', GetIndexHtml);
    RemoveClient(Client); Exit;
  end
  else
  begin
    SendHttp(Client, 404, 'text/plain', 'Not Found');
    RemoveClient(Client); Exit;
  end;

  if not WsHandled then begin RemoveClient(Client); Exit; end;

  // ── Фаза 2: цикл WebSocket-сообщений ─────────────────────────────────────
  Client.BufLen := 0;
  while FRunning and (Client.State = wsOpen) do
  begin
    R := Client.Recv;
    if R <= 0 then Break;

    while Client.BufLen >= 2 do
    begin
      B0     := Client.BufData[0];
      B1     := Client.BufData[1];
      Opcode := B0 and $0F;
      Masked := (B1 and $80) <> 0;
      PayLen := B1 and $7F;

      Need := 2;
      if PayLen = 126 then Inc(Need, 2)
      else if PayLen = 127 then Inc(Need, 8);
      if Masked then Inc(Need, 4);

      if Client.BufLen < Need then Break;

      i := 2;
      if PayLen = 126 then
      begin
        PayLen := (Client.BufData[2] shl 8) or Client.BufData[3];
        Inc(i, 2);
      end
      else if PayLen = 127 then
      begin
        PayLen := (Client.BufData[6] shl 24) or (Client.BufData[7] shl 16) or
                  (Client.BufData[8] shl 8)  or  Client.BufData[9];
        Inc(i, 8);
      end;

      if Client.BufLen < Need + PayLen then Break;

      if Masked then
      begin
        Mask[0] := Client.BufData[i];   Mask[1] := Client.BufData[i+1];
        Mask[2] := Client.BufData[i+2]; Mask[3] := Client.BufData[i+3];
        Inc(i, 4);
      end;

      SetLength(Payload, PayLen);
      if PayLen > 0 then
      begin
        Move(Client.BufData[i], Payload[0], PayLen);
        if Masked then
          for j := 0 to PayLen - 1 do
            Payload[j] := Payload[j] xor Mask[j and 3];
      end;

      Consumed := i + PayLen;
      if Client.BufLen > Consumed then
        Move(Client.BufData[Consumed], Client.BufData[0], Client.BufLen - Consumed);
      Client.BufLen := Client.BufLen - Consumed;

      case Opcode of
        $01: // Text → команда
          begin
            SetLength(Header, PayLen);
            if PayLen > 0 then Move(Payload[0], Header[1], PayLen);
            ProcessCommand(Client, Header);
          end;
        $02: // Binary — TX mic Opus frame: byte 'M' + raw Opus packet
          begin
            if (PayLen > 1) and (Payload[0] = Ord('M')) and
               Assigned(FOpusDec) and Assigned(FOpusDecDecode) and
               Assigned(FOnWebMic) then
            begin
              NowTick := GetTickCount64;
              if (FMicLastTick = 0) or ((NowTick - FMicLastTick) > MIC_IDLE_MS) then
              begin
                // Новая TX-сессия (первый пакет или длинный простой):
                // сбрасываем pre-roll и staging, PLC не применяем —
                // нет состояния для предсказания.
                FMicPreRollPos := 0;
                FMicStageLen   := 0;
              end
              else
              begin
                // PLC для опоздавших/потерянных кадров по wall-clock.
                // expected_packets ≈ elapsed_ms / 20ms; out of them один — текущий,
                // остальные считаем "потерянными" и синтезируем Opus PLC.
                MissedFrames := Integer((NowTick - FMicLastTick) div OPUS_FRAME_MS);
                if MissedFrames > 0 then Dec(MissedFrames);
                if MissedFrames > MIC_MAX_PLC then MissedFrames := MIC_MAX_PLC;
                for k := 1 to MissedFrames do
                begin
                  // opus_decode_float(dec, NULL, 0, pcm, frame_size, 0) → PLC frame
                  PlcDecoded := FOpusDecDecode(FOpusDec, nil, 0,
                                               @PcmBuf[0], OPUS_FRAME_SAMP, 0);
                  if PlcDecoded > 0 then PushMicSamples(@PcmBuf[0], PlcDecoded);
                end;
              end;
              FMicLastTick := NowTick;
              Decoded := FOpusDecDecode(FOpusDec, @Payload[1], PayLen - 1,
                                        @PcmBuf[0], Length(PcmBuf), 0);
              if Decoded > 0 then
                PushMicSamples(@PcmBuf[0], Decoded);
            end;
          end;
        $08: // Close
          begin
            Client.State := wsClosed;
            Break;
          end;
        $09: // Ping → Pong
          Client.SendWsFrame($0A, Payload[0], PayLen);
      end;
    end;
  end;

  FStateLock.Enter;
  SetClientActive(FClientCount > 1);
  FStateLock.Leave;

  RemoveClient(Client);
end;

procedure TWebServer.SetClientActive(Value: Boolean);
begin
  if FWebClientActive = Value then Exit;
  FWebClientActive := Value;
  if Assigned(FOnClientActiveChanged) then FOnClientActiveChanged(Value);
end;

procedure TWebServer.RemoveClient(Client: TWsClient);
var i, j: Integer; NewActive: Boolean;
begin
  FClientLock.Enter;
  try
    for i := 0 to FClientCount - 1 do
      if FClients[i] = Client then
      begin
        FClients[i].Free;
        for j := i to FClientCount - 2 do FClients[j] := FClients[j+1];
        FClients[FClientCount-1] := nil;
        Dec(FClientCount);
        Break;
      end;
    NewActive := False;
    for i := 0 to FClientCount - 1 do
      if (FClients[i] <> nil) and (FClients[i].State = wsOpen) then
      begin NewActive := True; Break; end;
    SetClientActive(NewActive);
  finally
    FClientLock.Leave;
  end;
end;

{ ═══════════════════════════════════════════════════════════════════════════
  Обработка JSON-команд от браузера
  ═══════════════════════════════════════════════════════════════════════════ }

procedure TWebServer.ProcessCommand(Client: TWsClient; const Json: string);
var
  Cmd:  string;
  HzF:  Double;
  HzI, ModeValue, BW, DB, Idx, V: Integer;
  On_:  Boolean;
  S1, S2: string;
begin
  Cmd := JsonGetStr(Json, 'cmd');

  if Cmd = 'freq' then
  begin
    HzF := JsonGetFloat(Json, 'hz', FFreq);
    FStateLock.Enter; FFreq := HzF; FStateLock.Leave;
    if Assigned(FOnFreq) then FOnFreq(HzF);
  end
  else if Cmd = 'mode' then
  begin
    ModeValue := JsonGetInt(Json, 'mode', FMode);
    FStateLock.Enter; FMode := ModeValue; FStateLock.Leave;
    if Assigned(FOnMode) then FOnMode(ModeValue);
  end
  else if Cmd = 'filter' then
  begin
    BW := JsonGetInt(Json, 'bw', FFilterBW);
    FStateLock.Enter; FFilterBW := BW; FStateLock.Leave;
    if Assigned(FOnFilter) then FOnFilter(BW);
  end
  else if Cmd = 'agc' then
  begin
    ModeValue := JsonGetInt(Json, 'mode', FAGCMode);
    FStateLock.Enter; FAGCMode := ModeValue; FStateLock.Leave;
    if Assigned(FOnAGC) then FOnAGC(ModeValue);
  end
  else if Cmd = 'agctop' then
  begin
    DB := JsonGetInt(Json, 'db', FAGCTop);
    FStateLock.Enter; FAGCTop := DB; FStateLock.Leave;
    if Assigned(FOnAGCTop) then FOnAGCTop(DB);
  end
  else if Cmd = 'band' then
  begin
    Idx := JsonGetInt(Json, 'idx', FBandIdx);
    FStateLock.Enter; FBandIdx := Idx; FStateLock.Leave;
    if Assigned(FOnBand) then FOnBand(Idx);
  end
  else if Cmd = 'xvtr_band' then
  begin
    // Idx 0..CFG_XVTR_COUNT-1 — активировать XVTR; -1 — выйти в HF
    Idx := JsonGetInt(Json, 'idx', -1);
    if Assigned(FOnXvtrBand) then FOnXvtrBand(Idx);
  end
  else if Cmd = 'span' then
  begin
    HzI := JsonGetInt(Json, 'hz', Round(FSpanHz));
    FStateLock.Enter; FSpanHz := HzI; FStateLock.Leave;
    if Assigned(FOnSpan) then FOnSpan(HzI);
  end
  else if Cmd = 'volume' then
  begin
    V := JsonGetInt(Json, 'v', FVolume);
    FStateLock.Enter; FVolume := V; FStateLock.Leave;
    if Assigned(FOnVolume) then FOnVolume(V);
  end
  else if Cmd = 'wfagc' then
  begin
    On_ := JsonGetBool(Json, 'on', FWfAGC);
    FStateLock.Enter; FWfAGC := On_; FStateLock.Leave;
    if Assigned(FOnWfAGC) then FOnWfAGC(On_);
  end
  else if Cmd = 'wfnf' then
  begin
    On_ := JsonGetBool(Json, 'on', FWfNF);
    FStateLock.Enter; FWfNF := On_; FStateLock.Leave;
    if Assigned(FOnWfNF) then FOnWfNF(On_);
  end
  else if Cmd = 'set_run' then
  begin
    On_ := JsonGetBool(Json, 'on', FTrxRunning);
    FStateLock.Enter; FTrxRunning := On_; FStateLock.Leave;
    if Assigned(FOnRun) then FOnRun(On_);
  end
  else if Cmd = 'set_mute' then
  begin
    On_ := JsonGetBool(Json, 'on', FMuted);
    FStateLock.Enter; FMuted := On_; FStateLock.Leave;
    if Assigned(FOnMute) then FOnMute(On_);
  end
  else if Cmd = 'set_ctun' then
  begin
    On_ := JsonGetBool(Json, 'on', FCtun);
    FStateLock.Enter; FCtun := On_; FStateLock.Leave;
    if Assigned(FOnCtun) then FOnCtun(On_);
  end
  else if Cmd = 'set_nr' then
  begin
    On_ := JsonGetBool(Json, 'on', FNRMode <> 0);
    ModeValue := Ord(On_);
    FStateLock.Enter; FNRMode := ModeValue; FStateLock.Leave;
    if Assigned(FOnNR) then FOnNR(ModeValue);
  end
  else if Cmd = 'set_nr_mode' then
  begin
    ModeValue := JsonGetInt(Json, 'mode', FNRMode);
    if ModeValue < 0 then ModeValue := 0;
    if ModeValue > 4 then ModeValue := 4;
    FStateLock.Enter; FNRMode := ModeValue; FStateLock.Leave;
    if Assigned(FOnNR) then FOnNR(ModeValue);
  end
  else if Cmd = 'set_nb' then
  begin
    On_ := JsonGetBool(Json, 'on', FNBMode <> 0);
    ModeValue := Ord(On_);
    FStateLock.Enter; FNBMode := ModeValue; FStateLock.Leave;
    if Assigned(FOnNB) then FOnNB(ModeValue);
  end
  else if Cmd = 'set_nb_mode' then
  begin
    ModeValue := JsonGetInt(Json, 'mode', FNBMode);
    if ModeValue < 0 then ModeValue := 0;
    if ModeValue > 2 then ModeValue := 2;
    FStateLock.Enter; FNBMode := ModeValue; FStateLock.Leave;
    if Assigned(FOnNB) then FOnNB(ModeValue);
  end
  else if Cmd = 'set_snb' then
  begin
    On_ := JsonGetBool(Json, 'on', FSNB);
    FStateLock.Enter; FSNB := On_; FStateLock.Leave;
    if Assigned(FOnSNB) then FOnSNB(On_);
  end
  else if Cmd = 'set_anf' then
  begin
    On_ := JsonGetBool(Json, 'on', FANF);
    FStateLock.Enter; FANF := On_; FStateLock.Leave;
    if Assigned(FOnANF) then FOnANF(On_);
  end
  else if Cmd = 'freq_b' then
  begin
    HzF := JsonGetFloat(Json, 'hz', FVfoB);
    FStateLock.Enter; FVfoB := HzF; FStateLock.Leave;
    if Assigned(FOnFreqB) then FOnFreqB(HzF);
  end
  else if Cmd = 'set_active_vfo' then
  begin
    ModeValue := JsonGetInt(Json, 'idx', FActiveVfo);
    if ModeValue < 0 then ModeValue := 0;
    if ModeValue > 1 then ModeValue := 1;
    FStateLock.Enter; FActiveVfo := ModeValue; FStateLock.Leave;
    if Assigned(FOnActiveVfo) then FOnActiveVfo(ModeValue);
  end
  else if Cmd = 'set_center' then
  begin
    HzF := JsonGetFloat(Json, 'hz', FCenterHz);
    FStateLock.Enter; FCenterHz := HzF; FStateLock.Leave;
    if Assigned(FOnCenter) then FOnCenter(HzF);
  end
  else if Cmd = 'set_mox' then
  begin
    On_ := JsonGetBool(Json, 'on', FTransmitting);
    FStateLock.Enter; FTransmitting := On_; FStateLock.Leave;
    if Assigned(FOnMOX) then FOnMOX(On_);
  end
  else if Cmd = 'drive' then
  begin
    V := JsonGetInt(Json, 'v', FDriveLevel);
    if V < 0   then V := 0;
    if V > 100 then V := 100;
    FStateLock.Enter; FDriveLevel := V; FStateLock.Leave;
    if Assigned(FOnDrive) then FOnDrive(V);
  end
  else if Cmd = 'set_tun' then
  begin
    On_ := JsonGetBool(Json, 'on', FTuning);
    FStateLock.Enter; FTuning := On_; FStateLock.Leave;
    if Assigned(FOnTun) then FOnTun(On_);
  end
  else if Cmd = 'set_beacon' then
  begin
    On_ := JsonGetBool(Json, 'on', FBeaconOn);
    FStateLock.Enter; FBeaconOn := On_; FStateLock.Leave;
    if Assigned(FOnBeacon) then FOnBeacon(On_);
  end
  else if Cmd = 'beacon_seed' then
  begin
    // frac = доля позиции клика по ширине спектра (0..1). Абсолютную display-частоту
    // считает контроллер из ЖИВЫХ FCenterFreq/FSpanHz (как десктоп PixelToFreq) —
    // клиентский localCenter может отставать при ретюне LO во время лока.
    HzF := JsonGetFloat(Json, 'frac', -1);
    if (HzF >= 0) and Assigned(FOnBeaconSeed) then FOnBeaconSeed(HzF);
  end
  else if Cmd = 'set_rx_gain_mode' then
  begin
    Idx := JsonGetInt(Json, 'mode', FRxGainMode);   // 0=man,1=fast,2=slow,3=hybrid
    FStateLock.Enter; FRxGainMode := Idx; FStateLock.Leave;
    if Assigned(FOnRxGainMode) then FOnRxGainMode(Idx);
  end
  else if Cmd = 'set_rx_gain' then
  begin
    DB := JsonGetInt(Json, 'db', FRxGainDb);         // ручной gain 0..73 дБ
    FStateLock.Enter; FRxGainDb := DB; FStateLock.Leave;
    if Assigned(FOnRxGain) then FOnRxGain(DB);
  end
  else if Cmd = 'set_rx_mute' then
  begin
    On_ := JsonGetBool(Json, 'on', FRxMuteOn);
    FStateLock.Enter; FRxMuteOn := On_; FStateLock.Leave;
    if Assigned(FOnRxMute) then FOnRxMute(On_);
  end
  else if Cmd = 'freq_a' then
  begin
    HzF := JsonGetFloat(Json, 'hz', FFreq);
    FStateLock.Enter; FFreq := HzF; FStateLock.Leave;
    if Assigned(FOnFreqA) then FOnFreqA(HzF);
  end
  else if Cmd = 'attn' then
  begin
    Idx := JsonGetInt(Json, 'db', FAttnDB);
    if Idx < 0 then Idx := 0;
    if Idx > 31 then Idx := 31;
    FStateLock.Enter; FAttnDB := Idx; FStateLock.Leave;
    if Assigned(FOnAttn) then FOnAttn(Idx);
  end
  else if Cmd = 'fm_step' then
  begin
    Idx := JsonGetInt(Json, 'idx', FFMStepIdx);
    if Idx < 0 then Idx := 0;
    if Idx > 3 then Idx := 3;
    FStateLock.Enter; FFMStepIdx := Idx; FStateLock.Leave;
    if Assigned(FOnFMStep) then FOnFMStep(Idx);
  end
  // ── Discovery / connect / saved-CRUD ──────────────────────────────────────
  else if Cmd = 'discover' then
  begin
    if Assigned(FOnDiscoverDev) then FOnDiscoverDev;
  end
  else if Cmd = 'connect' then
  begin
    S1 := JsonGetStr(Json, 'ip');
    if (S1 <> '') and Assigned(FOnConnect) then FOnConnect(S1);
  end
  else if Cmd = 'disconnect' then
  begin
    if Assigned(FOnDisconnectDev) then FOnDisconnectDev;
  end
  else if Cmd = 'dev_add' then
  begin
    S1 := JsonGetStr(Json, 'name');
    S2 := JsonGetStr(Json, 'ip');
    if (S2 <> '') and Assigned(FOnDevAdd) then FOnDevAdd(S1, S2);
  end
  else if Cmd = 'dev_remove' then
  begin
    Idx := JsonGetInt(Json, 'idx', -1);
    if (Idx >= 0) and Assigned(FOnDevRemove) then FOnDevRemove(Idx);
  end
  else if Cmd = 'dev_autostart' then
  begin
    Idx := JsonGetInt(Json, 'idx', -1);
    if (Idx >= 0) and Assigned(FOnDevAutoStart) then FOnDevAutoStart(Idx);
  end;
end;

{ ═══════════════════════════════════════════════════════════════════════════
  Push-цикл — рассылает спектр / водопад / состояние всем клиентам
  ═══════════════════════════════════════════════════════════════════════════ }

procedure TWebServer.PushLoop;
var
  Tick, StateLastTick: QWord;
  SpecBuf: array[0..1 + WEB_SPEC_MAX*4] of Byte;
  WfBuf:   array[0..1 + WEB_SPEC_MAX*4] of Byte;
  i, SN, WN: Integer;
  HasClients: Boolean;
begin
  StateLastTick := GetTickCount64;
  while FRunning do
  begin
    Sleep(50);  // 20 fps
    Tick := GetTickCount64;

    FClientLock.Enter;
    HasClients := FClientCount > 0;
    FClientLock.Leave;

    if not HasClients then
    begin
      // Без клиентов строку никто не забирает — иначе накопитель max-hold рос
      // бы всё время простоя и первая строка после подключения была бы
      // максимумом за минуты.
      FStateLock.Enter;
      FWfFresh := False;
      FStateLock.Leave;
      Continue;
    end;

    FStateLock.Enter;
    try
      // Длина фрейма = число реально заполненных точек: клиент считает N из
      // byteLength и растягивает ровно их на всю ширину канваса.
      SN := FSpecCount;
      SpecBuf[0] := WS_MSG_SPECTRUM;
      for i := 0 to SN - 1 do
        PSingle(Pointer(PByte(@SpecBuf[1]) + i*4))^ := FSpectrumBuf[i];

      // Водопад — ТОЛЬКО если с прошлой отправки пришла новая строка. Иначе в
      // демоне (PushState 20 Гц и этот цикл 20 Гц идут вразнобой) на дрейфе фаз
      // одна и та же строка уезжала клиенту дважды — водопад «двоил» и полз
      // рывками, а на остановленном RX прокручивал застывшую строку вечно.
      // Спектр так гейтить нельзя: это живая кривая, её надо перерисовывать.
      if FWfFresh then
      begin
        WN := FWfCount;
        WfBuf[0] := WS_MSG_WATERFALL;
        for i := 0 to WN - 1 do
          PSingle(Pointer(PByte(@WfBuf[1]) + i*4))^ := FWfBuf[i];
        FWfFresh := False;   // следующий кадр DSP начнёт новое накопление
      end
      else
        WN := 0;
    finally
      FStateLock.Leave;
    end;

    // Кадр спектра шлём и пустым (SN=0, до первого кадра DSP): клиент
    // перерисовывает по нему линейку и оверлеи, а пустой спектр не рисует.
    BroadcastBinary(SpecBuf[0], 1 + SN*4);
    if WN > 0 then BroadcastBinary(WfBuf[0], 1 + WN*4);

    if Tick - StateLastTick >= 200 then
    begin
      StateLastTick := Tick;
      BroadcastText(BuildStateJson);
    end;
  end;
end;

procedure TWebServer.BroadcastBinary(const Data; Len: Integer);
var i: Integer;
begin
  FClientLock.Enter;
  try
    for i := 0 to FClientCount - 1 do
      if (FClients[i] <> nil) and (FClients[i].State = wsOpen) then
        FClients[i].SendBinary(Data, Len);
  finally
    FClientLock.Leave;
  end;
end;

procedure TWebServer.BroadcastText(const S: string);
var i: Integer;
begin
  FClientLock.Enter;
  try
    for i := 0 to FClientCount - 1 do
      if (FClients[i] <> nil) and (FClients[i].State = wsOpen) then
        FClients[i].SendText(S);
  finally
    FClientLock.Leave;
  end;
end;

{ ═══════════════════════════════════════════════════════════════════════════
  Формирование JSON-состояния
  ═══════════════════════════════════════════════════════════════════════════ }

function JsonEscape(const S: string): string;
var
  I: Integer;
begin
  Result := '';
  for I := 1 to Length(S) do
    case S[I] of
      '\': Result := Result + '\\';
      '"': Result := Result + '\"';
      #8:  Result := Result + '\b';
      #9:  Result := Result + '\t';
      #10: Result := Result + '\n';
      #12: Result := Result + '\f';
      #13: Result := Result + '\r';
    else
      Result := Result + S[I];
    end;
end;

function TWebServer.BuildStateJson: string;
const
  MODE_N: array[RadioModes.MODE_MIN..RadioModes.MODE_MAX] of string =
    ('LSB','USB','DSB','CWL','CWU','FM','AM','SAM','DIGU','DIGL','WFM','DMR','FM RAW');
var
  FS: TFormatSettings;
  XvtrJson, DevJson, RateJson, BandJson: string;
  i: Integer;
begin
  FS := DefaultFormatSettings;
  FS.DecimalSeparator := '.';
  FStateLock.Enter;
  try
    // Сборка массива rate_presets: [48000,96000,...] (span-кнопки web).
    RateJson := '[';
    for i := 0 to High(FRatePresets) do
    begin
      if i > 0 then RateJson := RateJson + ',';
      RateJson := RateJson + IntToStr(FRatePresets[i]);
    end;
    RateJson := RateJson + ']';
    // Сборка массива bands: [{"name":"6m","freq":50150000},...] (band-selector web).
    BandJson := '[';
    for i := 0 to High(FBandNames) do
    begin
      if i > 0 then BandJson := BandJson + ',';
      BandJson := BandJson +
        Format('{"name":"%s","freq":%.0f}',
               [JsonEscape(FBandNames[i]), FBandFreqs[i]], FS);
    end;
    BandJson := BandJson + ']';
    // Сборка массива xvtr_bands: [{"idx":0,"name":"2m"},...]
    XvtrJson := '[';
    for i := 0 to High(FXvtrBands) do
    begin
      if i > 0 then XvtrJson := XvtrJson + ',';
      XvtrJson := XvtrJson +
        Format('{"idx":%d,"name":"%s"}',
               [FXvtrBands[i].Idx, FXvtrBands[i].Name], FS);
    end;
    XvtrJson := XvtrJson + ']';
    // Сборка списка устройств (saved+discovered) для overlay-диалога.
    DevJson := '[';
    for i := 0 to High(FDevices) do
    begin
      if i > 0 then DevJson := DevJson + ',';
      DevJson := DevJson +
        Format('{"name":"%s","ip":"%s","board":%d,"saved":%s,"autostart":%s}',
               [JsonEscape(FDevices[i].Name), JsonEscape(FDevices[i].IP),
                FDevices[i].BoardType,
                BoolToStr(FDevices[i].IsSaved,   'true', 'false'),
                BoolToStr(FDevices[i].AutoStart, 'true', 'false')], FS);
    end;
    DevJson := DevJson + ']';
    Result := Format(
      '{"vfo_a_hz":%.0f,"vfo_b_hz":%.0f,"active_vfo":%d,' +
      '"mode":%d,"mode_name":"%s",' +
      '"filter":%d,"filter_bw":%d,' +
      '"agc_mode":%d,"agc_top":%d,' +
      '"span_hz":%.0f,"center_hz":%.0f,"volume":%d,' +
      '"wf_agc":%s,"wf_nf":%s,"band_idx":%d,"smeter_dbm":%.1f,' +
      '"running":%s,"mute":%s,"ctun":%s,' +
      '"nr_mode":%d,"nr":%s,"nb_mode":%d,"nb":%s,"snb":%s,"anf":%s,"connected":%s,' +
	      '"transmitting":%s,"drive":%d,"attn_db":%d,"tuning":%s,"dup":%s,' +
	      '"fwd_w":%.1f,"swr":%.2f,"pa_max_power":%.0f,' +
	      '"status_text":"%s","board_text":"%s","ip_text":"%s","supply_text":"%s",' +
	      '"pll_text":"%s","rx_text":"%s","tx_text":"%s","seq_text":"%s",' +
	      '"xvtr_current":%d,"xvtr_bands":%s,"freq_mhz_digits":%d,' +
	      '"fmstep_idx":%d,"devices":%s,"rate_presets":%s,"bands":%s,' +
	      '"freq_min":%.0f,"freq_max":%.0f,' +
	      '"beacon_visible":%s,"beacon_on":%s,"beacon_text":"%s",' +
	      '"beacon_ref_hz":%.0f,"beacon_track_hz":%.0f,' +
	      '"rf_agc_visible":%s,"rx_gain_mode":%d,"rx_gain_db":%d,' +
	      '"rx_mute_visible":%s,"rx_mute_on":%s,"opus":%s}',
      [FFreq, FVfoB, FActiveVfo,
       FMode, MODE_N[EnsureRange(FMode, MODE_MIN, MODE_MAX)],
       FFilterIdx, FFilterBW,
       FAGCMode, FAGCTop,
       FSpanHz, FCenterHz, FVolume,
       BoolToStr(FWfAGC,      'true', 'false'),
       BoolToStr(FWfNF,       'true', 'false'),
       FBandIdx, FSMeter,
       BoolToStr(FTrxRunning, 'true', 'false'),
       BoolToStr(FMuted,      'true', 'false'),
       BoolToStr(FCtun,       'true', 'false'),
       FNRMode,
       BoolToStr(FNRMode <> 0, 'true', 'false'),
       FNBMode,
       BoolToStr(FNBMode <> 0, 'true', 'false'),
       BoolToStr(FSNB,        'true', 'false'),
       BoolToStr(FANF,        'true', 'false'),
       BoolToStr(FConnected,  'true', 'false'),
       BoolToStr(FTransmitting, 'true', 'false'),
       FDriveLevel,
       FAttnDB,
       BoolToStr(FTuning,  'true', 'false'),
       BoolToStr(FDuplex, 'true', 'false'),
	       FFwdW, FSWR, FPAMaxPower,
	       JsonEscape(FStatusText), JsonEscape(FBoardText), JsonEscape(FIPText),
	       JsonEscape(FSupplyText), JsonEscape(FPLLText), JsonEscape(FRXText),
	       JsonEscape(FTXText), JsonEscape(FSeqText),
	       FCurrentXvtr, XvtrJson, FFreqMhzDigits,
       FFMStepIdx, DevJson, RateJson, BandJson,
       FFreqMin, FFreqMax,
       BoolToStr(FBeaconVisible, 'true', 'false'),
       BoolToStr(FBeaconOn,      'true', 'false'),
       JsonEscape(FBeaconText), FBeaconRefHz, FBeaconTrackHz,
       BoolToStr(FRfAgcVisible, 'true', 'false'), FRxGainMode, FRxGainDb,
       BoolToStr(FRxMuteVisible, 'true', 'false'),
       BoolToStr(FRxMuteOn, 'true', 'false'),
       // Без libopus сервер молча не шлёт кадры 'A' и не принимает микрофон —
       // страница показывает предупреждение, иначе отказ невидим.
       BoolToStr(FOpusReady, 'true', 'false')
      ], FS);
  finally
    FStateLock.Leave;
  end;
end;

{ ═══════════════════════════════════════════════════════════════════════════
  Аудио push (вызывается из DSP-потока)
  ═══════════════════════════════════════════════════════════════════════════ }

procedure TWebServer.PushAudio(const Samples: PSingle; Count: Integer);
var
  HasWs: Boolean;
  i, n, Enc: Integer;
begin
  if not FRunning then Exit;
  FClientLock.Enter;
  HasWs := FClientCount > 0;
  FClientLock.Leave;
  if not HasWs then Exit;
  if (Samples = nil) or (Count <= 0) then Exit;
  if not FOpusReady then Exit;

  i := 0;
  while i < Count do
  begin
    n := Count - i;
    if n > OPUS_FRAME_SAMP - FOpusBufPos then
      n := OPUS_FRAME_SAMP - FOpusBufPos;
    Move(Samples[i], FOpusBuf[FOpusBufPos], n * SizeOf(Single));
    Inc(FOpusBufPos, n);
    Inc(i, n);
    if FOpusBufPos >= OPUS_FRAME_SAMP then
    begin
      Enc := FOpusEncode(FOpusEnc, @FOpusBuf[0], OPUS_FRAME_SAMP,
                         @FOpusOut[0], SizeOf(FOpusOut));
      if Enc > 0 then
      begin
        FWsAudioBuf[0] := WS_MSG_AUDIO;
        Move(FOpusOut[0], FWsAudioBuf[1], Enc);
        BroadcastBinary(FWsAudioBuf[0], 1 + Enc);
      end;
      FOpusBufPos := 0;
    end;
  end;
end;

{ ═══════════════════════════════════════════════════════════════════════════
  Обновление состояния из MainForm (UI thread, из таймера спектра)
  ═══════════════════════════════════════════════════════════════════════════ }

procedure TWebServer.PushSpectrum(
  const Buf: array of Single; Count: Integer;
  const WfBuf_: array of Single; WfCount: Integer; WfNew: Boolean;
  SMeter: Double;
  Freq: Double; Mode, FilterBW, AGCMode, AGCTop: Integer;
  SpanHz: Double; Volume: Integer;
  WfAGC, WfNF: Boolean; BandIdx: Integer;
  TrxConnected: Boolean;
  TrxRunning, Muted, Ctun: Boolean;
  NRMode, NBMode: Integer; SNB, ANF: Boolean;
  CenterHz: Double; FilterIdx: Integer;
  VfoB: Double; ActiveVfo: Integer;
  Transmitting: Boolean; DriveLevel: Integer;
  AttnDB: Integer; Tuning: Boolean; Duplex: Boolean;
  FwdW, SWRV, PAMaxPower: Double;
  const StatusText, BoardText, IPText, SupplyText, PLLText,
  RXText, TXText, SeqText: string);
var N, W, i: Integer;
begin
  if not FRunning then Exit;
  N := EnsureRange(Min(Count, Length(Buf)),      0, WEB_SPEC_MAX);
  W := EnsureRange(Min(WfCount, Length(WfBuf_)), 0, WEB_SPEC_MAX);
  FStateLock.Enter;
  try
    for i := 0 to N-1 do FSpectrumBuf[i] := Buf[i];
    FSpecCount := N;
    // Водопад: зовут нас чаще, чем PushLoop отдаёт строку (в GUI 60 против 20
    // раз/с) — между отправками копим max-hold'ом, иначе кадры терялись бы.
    // WfNew=False — новой строки от DSP не было: не трогаем ни накопитель, ни
    // флаг, иначе PushLoop отправил бы прежнюю строку повторно.
    if WfNew then
    begin
      if (not FWfFresh) or (W <> FWfCount) then
      begin
        for i := 0 to W-1 do FWfBuf[i] := WfBuf_[i];
        FWfCount := W;
      end
      else
        for i := 0 to W-1 do
          if WfBuf_[i] > FWfBuf[i] then FWfBuf[i] := WfBuf_[i];
      FWfFresh := True;
    end;
    FSMeter     := SMeter;
    FFreq       := Freq;
    FMode       := Mode;
    FFilterBW   := FilterBW;
    FAGCMode    := AGCMode;
    FAGCTop     := AGCTop;
    FSpanHz     := SpanHz;
    FVolume     := Volume;
    FWfAGC      := WfAGC;
    FWfNF       := WfNF;
    FBandIdx    := BandIdx;
    FConnected  := TrxConnected;
    FTrxRunning := TrxRunning;
    FMuted      := Muted;
    FCtun       := Ctun;
    FNRMode     := NRMode;
    FNBMode     := NBMode;
    FSNB        := SNB;
    FANF        := ANF;
    FCenterHz     := CenterHz;
    FFilterIdx    := FilterIdx;
    FVfoB         := VfoB;
    FActiveVfo    := ActiveVfo;
    FTransmitting := Transmitting;
    FDriveLevel   := DriveLevel;
    FAttnDB       := AttnDB;
    FTuning       := Tuning;
    FDuplex       := Duplex;
    FFwdW         := FwdW;
    FSWR          := SWRV;
    FPAMaxPower   := PAMaxPower;
    FStatusText   := StatusText;
    FBoardText    := BoardText;
    FIPText       := IPText;
    FSupplyText   := SupplyText;
    FPLLText      := PLLText;
    FRXText       := RXText;
    FTXText       := TXText;
    FSeqText      := SeqText;
  finally
    FStateLock.Leave;
  end;
end;
{ ═══════════════════════════════════════════════════════════════════════════
  Stub-методы (реализация встроена в HandleClient)
  ═══════════════════════════════════════════════════════════════════════════ }

procedure TWebServer.DoHandshake(Client: TWsClient);
begin
  // Not used separately — handshake is in HandleClient
end;

procedure TWebServer.ProcessWsFrame(Client: TWsClient;
  const Data: array of Byte; Len: Integer; Opcode: Byte);
begin
  // Not used separately — frame processing is inlined in HandleClient
end;

end.
