/*
 * audio/audio_engine.h — capture/playback + DSP + jitter buffer + mixer.
 *
 * Design: docs/voice.md §8–11. Real-time path:
 *   capture(miniaudio) → APM(AEC/NS/AGC/VAD, send-side) → Opus encode → ...
 *   ... → Opus decode → per-user recv NS (listener-chosen) → gain/mute → mix → playback
 *
 * REAL-TIME RULE: audio-callback threads never allocate, lock, or block (architecture.md §3).
 * The JitterBuffer and per-stream maps are accessed only under a try_lock; a failed lock
 * causes PLC for that period (acceptable for M2; lock-free ring buffer is the M3 upgrade).
 */
#ifndef VOICECAT_AUDIO_AUDIO_ENGINE_H
#define VOICECAT_AUDIO_AUDIO_ENGINE_H

#include <algorithm>
#include <atomic>
#include <cstring>
#include <cstdint>
#include <functional>
#include <map>
#include <memory>
#include <mutex>
#include <optional>
#include <thread>
#include <unordered_map>
#include <vector>

#ifdef VOICECAT_HAS_AUDIO
// miniaudio single-header — MINIAUDIO_IMPLEMENTATION defined in audio_engine.cpp
#include <miniaudio.h>
#endif

#ifdef VOICECAT_HAS_OPUS
#include "codec/opus_codec.h"
#endif

#include "audio/apm_processor.h"

namespace voicecat::audio {

// ── JitterBuffer ─────────────────────────────────────────────────────────────
// Per-ssrc adaptive jitter buffer. Thread-safe via internal mutex.
class JitterBuffer {
   public:
    struct Frame {
        uint64_t             seq;
        uint32_t             timestamp;
        bool                 fec_present;
        bool                 marker = false;  // kFlagMarker: first frame of a talkspurt
        std::vector<uint8_t> payload;
    };

    // Insert an incoming frame. Thread-safe.
    void push(Frame f);

    // Return the next frame whose timestamp <= playout_ts, or nullopt (caller should PLC).
    // Drops frames that are too old (more than kLateDropSamples late).
    std::optional<Frame> pop(uint32_t playout_ts);

    // Timestamp of the earliest buffered frame, or nullopt if empty/contended. Lets the playout
    // clock seed/re-sync itself to the arriving stream rather than free-running (see
    // AudioEngine::on_playback). Uses try_lock — never blocks the real-time callback.
    std::optional<uint32_t> peek_front_ts() const;

    // Timestamp of the newest (latest) buffered frame — the stream's leading edge. Lets the
    // playout clock keep a bounded depth behind the arriving stream and catch up (frame-skip)
    // when the backlog grows; see AudioEngine::on_playback. try_lock — never blocks the RT thread.
    std::optional<uint32_t> peek_back_ts() const;

    // If the front frame's timestamp == expected_ts, copies its raw Opus payload into out
    // (caller-allocated, max_sz bytes). Returns bytes copied, or 0 (lock miss / wrong ts /
    // empty). Caller pre-allocates out to avoid RT-thread allocation. Uses try_lock.
    size_t try_copy_front_payload(uint32_t expected_ts, uint8_t* out, size_t max_sz);

    // Erase all buffered frames whose timestamp is strictly before `ts` (oldest-first). Used by
    // the playout catch-up so a forward clock jump doesn't flood the decode loop with stale frames.
    void drop_before(uint32_t ts);

    uint32_t target_depth_ms()      const { return target_depth_ms_.load(); }
    uint32_t target_depth_samples() const { return target_depth_ms_.load() * 48; }  // @48 kHz
    uint32_t packets_lost()         const { return lost_.load(); }
    uint32_t duplicates()           const { return dup_.load(); }

    // Set the expected inter-arrival gap (= sender frame size in samples @48 kHz) so the jitter
    // EWMA and silence-gap outlier rejection are correct for non-20 ms channels. Call at init.
    void set_expected_gap(uint32_t samples) {
        if (samples > 0) expected_gap_samples_ = samples;
    }
    void     reset();

   private:
    static constexpr uint32_t kLateDropSamples = 48000 / 2;  // 500 ms @48 kHz (hard floor/cap)

    mutable std::mutex       mu_;
    std::map<uint32_t, Frame> buf_;   // keyed by timestamp (u32 wraps are handled below)

    std::atomic<uint32_t> target_depth_ms_{40};
    std::atomic<uint32_t> lost_{0};
    std::atomic<uint32_t> dup_{0};

    // Jitter estimation (EWMA).
    uint32_t last_push_ts_        = 0;    // sender timestamp on last push
    uint32_t jitter_est_          = 0;    // EWMA jitter in samples
    uint32_t expected_gap_samples_ = 960;  // expected inter-arrival gap (frame size @48 kHz)
    uint32_t newest_ts_           = 0;    // latest buffered timestamp (leading edge)
    bool     have_newest_         = false;
    bool     first_push_          = true;
};

// ── AudioParams ──────────────────────────────────────────────────────────────
struct AudioParams {
    uint32_t    sample_rate        = 48000;
    uint32_t    capture_channels   = 1;  // mic capture: 1 = mono, 2 = stereo (set via vc_set_capture_channels)
    uint32_t    playback_channels  = 2;  // true stereo output (see audio_engine.cpp on_playback)
    uint32_t    frame_ms           = 20;
    std::string capture_device_id;   // "" = default; opaque id from AudioEngine::enumerate_devices
    std::string playback_device_id;  // "" = default; opaque id from AudioEngine::enumerate_devices
    // External capture: the mic is fed via inject_capture/vc_stream_feed_pcm (e.g. iOS VPIO),
    // so start() skips opening the hardware capture device. Derived from the MIC LocalStream's
    // external_feed flag in vc_client::ensure_audio_running().
    bool        external_capture   = false;
};

// One enumerated device, returned by AudioEngine::enumerate_devices(). `id` is an internal,
// opaque hex-encoded ma_device_id — callers must always round-trip an id that came from
// enumerate_devices(); never construct one by hand (names aren't guaranteed unique, so the id
// is the only stable handle miniaudio accepts back for device selection).
struct DeviceInfo {
    std::string id;
    std::string name;
    bool        is_default = false;
};

// ── AudioEngine ──────────────────────────────────────────────────────────────
// Owns miniaudio capture/playback, per-ssrc jitter buffers + Opus decoders, and the mixer.
class AudioEngine {
   public:
    // Callback type for encoded capture frames ready to be sent. `kind` identifies which
    // local stream this PCM belongs to (a vc_stream_kind value; 0 = MIC for the real capture
    // device, which is always the "primary" tap). `channels` is the channel count of the PCM
    // buffer (1 = mono, 2 = stereo interleaved) — the mic capture device is mono in v1, but
    // the WASAPI loopback path (SCREEN_AUDIO) captures in the channel's mode when stereo, so
    // the encoder sees real interleaved L/R PCM rather than a mono upmix. M3: multiple
    // concurrent local streams are possible (e.g. MIC + SCREEN_AUDIO), each fed via its own
    // injection tap (see inject_capture) since there is only one real hardware capture device.
    using CaptureCallback = std::function<void(int kind, const int16_t* pcm, int samples,
                                              int channels)>;

    AudioEngine();
    ~AudioEngine();

    AudioEngine(const AudioEngine&)            = delete;
    AudioEngine& operator=(const AudioEngine&) = delete;

    // Start capture+playback devices. capture_cb is called on the encode thread
    // for each capture frame (not on the audio callback thread).
    bool start(const AudioParams& p, CaptureCallback capture_cb = nullptr);
    void stop();

    bool running() const { return running_.load(std::memory_order_acquire); }

    // Pause/resume miniaudio device I/O (called from vc_audio_suspend/resume for
    // AVAudioSession interruptions on iOS). Thread-safe via ma_device_stop/start.
    // Returns true on success; false if a device stop/start fails.
    bool suspend();
    bool resume();

    // Enumerate input or output devices (for UI pickers / vc_list_devices). Static: works
    // before any AudioEngine instance is running (device pickers need to populate pre-connect).
    // Inits a throwaway ma_context if VOICECAT_HAS_AUDIO; returns {} otherwise. See DeviceInfo
    // above for the `id` encoding contract.
    static std::vector<DeviceInfo> enumerate_devices(bool capture);

    // Real desktop-audio loopback capture (Windows/WASAPI only, VOICECAT_HAS_LOOPBACK). Feeds
    // `kind`'s capture_cb_ directly, same pattern as the real mic capture device — NOT routed
    // through inject_capture()'s test-only ring. `channels` is the channel count to open the
    // loopback device with (1 = mono downmix of the system mix, 2 = stereo capture when the
    // channel is configured stereo); the accumulator and capture_cb_ invocation are shaped to
    // match. No-op (returns false) when unsupported.
    bool start_loopback_capture(int kind, int channels);
    void stop_loopback_capture();

    // Inject synthetic PCM directly into the capture pipeline (bypasses real device).
    // Thread-safe; can be called from any thread including tests. `kind` selects which local
    // stream's injection tap to feed (each gets its own ring buffer); the 2-arg overload
    // targets kind 0 (MIC) for source compatibility with existing callers.
    void inject_capture(int kind, const int16_t* pcm, size_t n);
    void inject_capture(const int16_t* pcm, size_t n) { inject_capture(0, pcm, n); }

    // Called by the net thread when a decoded voice frame arrives for a remote stream.
    void push_recv_frame(uint32_t ssrc, JitterBuffer::Frame f);

    // Global output volume applied after mixing all streams (default 1.0). Thread-safe.
    void set_output_volume(float gain) { output_volume_.store(gain, std::memory_order_relaxed); }

    // Per-stream receive-side controls (safe from any thread).
    void set_stream_gain(uint32_t ssrc, float gain);      // 0.0–2.0, default 1.0
    void set_stream_mute(uint32_t ssrc, bool mute);
    // Listener-chosen, local-only noise reduction on a specific remote stream (docs/voice.md
    // §10) — lazily instantiates an ApmProcessor on first enable, frees it on disable.
    void set_stream_noise_reduction(uint32_t ssrc, bool enable);
    // Read back a stream's current receive-side state. Returns true and fills *out if the
    // stream is known (even if defaults — gain=1, mute=false, nr=false), false if it has
    // never been seen (no RemoteStream entry yet).
    bool get_stream_state(uint32_t ssrc, float& gain, bool& mute, bool& noise_reduction);
    void remove_stream(uint32_t ssrc);

    // Edge-triggered talk-state transitions since the last call (docs/voice.md §7: talk state
    // is derived from recent frame arrival, no protocol message). Call from a lightweight
    // poller, not the audio callback thread. Returns {ssrc, now_talking} for each stream whose
    // state flipped.
    std::vector<std::pair<uint32_t, bool>> poll_talk_transitions();

    // Get stats for a remote stream's jitter buffer.
    uint32_t stream_packets_lost(uint32_t ssrc) const;
    uint32_t stream_target_depth_ms(uint32_t ssrc) const;
    uint32_t stream_duplicates(uint32_t ssrc) const;
    uint64_t stream_underruns(uint32_t ssrc) const;

    // TEST-ONLY: current playout depth in samples (newest buffered ts - playout_ts), i.e. the
    // standing latency held in the jitter buffer. Used by test_jitter_depth to assert the
    // bounded-depth invariant. Returns 0 if the stream is unknown or not yet playing out.
    int32_t  stream_playout_depth_samples(uint32_t ssrc) const;

#ifdef VOICECAT_HAS_OPUS
    // Configure the Opus decoder for an incoming ssrc (must be called before
    // push_recv_frame for that ssrc). user_id/stream_id identify the source for the
    // pcm_sink_ callback. is_voice marks a MIC stream so the receive-side NR pass knows it may
    // denoise it (a stereo mic is folded to mono first); screen-audio shares are never voice.
    // Thread-safe.
    void init_recv_stream(uint32_t ssrc, const codec::OpusParams& p,
                          uint32_t user_id, uint32_t stream_id, bool is_voice);
#endif

    // External PCM tap: callback fired once per decoded Opus frame per remote stream, on the
    // playback (RT) thread. Matching signature to vc_pcm_sink_cb (cast at the C-ABI boundary).
    // Pass nullptr to disable. Thread-safe (atomic store; the RT read is relaxed-load).
    using PcmSink = void(*)(void*, uint32_t, uint32_t, const int16_t*, size_t, uint32_t, uint32_t);
    void set_pcm_sink(PcmSink cb, void* user);

    // External mixed-output sink (iOS VPIO). Receives the FINAL mixed PCM (post output-volume,
    // all remote streams summed) on the mixer-timer thread when external playback is enabled.
    // Matching signature to vc_mixed_output_cb (cast at the C-ABI boundary). Pass nullptr to
    // disable. Thread-safe (atomic store; the timer read is relaxed-load).
    using MixedSink = void(*)(void*, const int16_t*, size_t, uint32_t, uint32_t);
    void set_mixed_output_sink(MixedSink cb, void* user);

    // External-playback mode (iOS VPIO): when enabled, start() does NOT open a hardware
    // playback device; a timer thread drives the mixer (on_playback) on a ~20 ms cadence and
    // ships the final mix to the mixed-output sink. Set before start() (or apply via restart).
    void set_external_playback(bool enable) { external_playback_ = enable; }

    // External PCM feed overload: stereo-aware variant of inject_capture. samples_per_channel
    // is samples per channel; total samples written = samples_per_channel * channels.
    void inject_capture(int kind, const int16_t* pcm, size_t samples_per_channel, int channels);

#ifdef VOICECAT_HAS_AUDIO
    // TEST-ONLY — exposes the playback mixer without a real ma_device, so tests can verify
    // stereo mixing end-to-end (no audio hardware needed). Same logic the real playback
    // callback uses; safe to call any time after start() (no ma_device touched).
    void mix_for_test(int16_t* out, uint32_t frames) { on_playback(out, frames); }

    // TEST-ONLY — drives the capture-side frame accumulator directly with an explicit
    // callback, bypassing capture_cb_. Call after engine.start(p) WITHOUT a capture
    // callback so the real mic (if any) never touches capture_accum_ (on_capture returns
    // early when capture_cb_ is null). The explicit `cb` is invoked only when a full
    // frame_samples_ chunk is ready — that is the invariant under test.
    void feed_capture_for_test(const int16_t* pcm, int frames, const CaptureCallback& cb) {
        if (frame_samples_ <= 0 || !cb || capture_accum_.buf.empty()) return;
        const int16_t* src = pcm;
        auto remaining = frames;
        while (remaining > 0) {
            int space = frame_samples_ - capture_accum_.count;
            int copy  = std::min(remaining, space);
            std::memcpy(capture_accum_.buf.data() + capture_accum_.count, src,
                        static_cast<size_t>(copy) * sizeof(int16_t));
            capture_accum_.count += copy;
            src       += copy;
            remaining -= copy;
            if (capture_accum_.count == frame_samples_) {
                cb(0, capture_accum_.buf.data(), frame_samples_, 1);
                capture_accum_.count = 0;
            }
        }
    }
    // TEST-ONLY — stereo-aware variant: drives the capture accumulator with interleaved L/R
    // PCM (channels=2) or mono (channels=1). Sizes the accumulator to frame_samples_*channels
    // and invokes `cb` with the channel count passed through — mirrors feed_loopback_for_test.
    // Use to verify stereo mic capture (vc_set_capture_channels → on_capture's accumulator).
    void feed_capture_for_test(const int16_t* pcm, int frames_per_channel, int channels,
                               const CaptureCallback& cb) {
        if (frame_samples_ <= 0 || !cb) return;
        const int ch = std::max(1, channels);
        const int full = frame_samples_ * ch;
        if (static_cast<int>(capture_accum_.buf.size()) != full) {
            capture_accum_.buf.assign(static_cast<size_t>(full), 0);
            capture_accum_.count = 0;
        }
        const int16_t* src = pcm;
        auto remaining = frames_per_channel * ch;
        while (remaining > 0) {
            int space = full - capture_accum_.count;
            int copy  = std::min(remaining, space);
            std::memcpy(capture_accum_.buf.data() + capture_accum_.count, src,
                        static_cast<size_t>(copy) * sizeof(int16_t));
            capture_accum_.count += copy;
            src       += copy;
            remaining -= copy;
            if (capture_accum_.count == full) {
                cb(0, capture_accum_.buf.data(), frame_samples_, ch);
                capture_accum_.count = 0;
            }
        }
    }
#endif

#ifdef VOICECAT_HAS_LOOPBACK
    // TEST-ONLY — drives the loopback accumulator directly with an explicit callback, the
    // loopback analogue of feed_capture_for_test. Self-contained: sizes the accumulator and
    // sets loopback_channels_ itself, so it works on headless CI where start_loopback_capture
    // can't init a real WASAPI device. `channels` selects mono (1) or interleaved stereo (2).
    // PCM is interleaved L/R when channels==2. Invokes `cb` once per full frame_samples_
    // per-channel chunk, with the channel count passed through so the encoder branch in
    // on_capture_frame sees real stereo (channels==2) rather than a mono upmix.
    void feed_loopback_for_test(const int16_t* pcm, int frames_per_channel, int channels,
                                const CaptureCallback& cb) {
        if (frame_samples_ <= 0 || !cb) return;
        const int ch = std::max(1, channels);
        const int full = frame_samples_ * ch;
        // Size the accumulator for the requested channel count (off the RT thread; this is a
        // test-only path). start_loopback_capture() does the same sizing when it opens a real
        // device, but on headless CI that init fails — so do it here too.
        if (static_cast<int>(loopback_accum_.buf.size()) != full) {
            loopback_accum_.buf.assign(static_cast<size_t>(full), 0);
            loopback_accum_.count = 0;
        }
        loopback_channels_ = ch;
        const int16_t* src = pcm;
        auto remaining = frames_per_channel * ch;
        while (remaining > 0) {
            int space = full - loopback_accum_.count;
            int copy  = std::min(remaining, space);
            std::memcpy(loopback_accum_.buf.data() + loopback_accum_.count, src,
                        static_cast<size_t>(copy) * sizeof(int16_t));
            loopback_accum_.count += copy;
            src       += copy;
            remaining -= copy;
            if (loopback_accum_.count == full) {
                cb(loopback_kind_, loopback_accum_.buf.data(), frame_samples_, ch);
                loopback_accum_.count = 0;
            }
        }
    }
#endif

   private:
#ifdef VOICECAT_HAS_AUDIO
    static void capture_data_cb(ma_device*, void*, const void*, ma_uint32);
    static void playback_data_cb(ma_device*, void*, const void*, ma_uint32);
    void on_capture(const int16_t* pcm, ma_uint32 frames);
    void on_playback(int16_t* out, ma_uint32 frames);

    // Build the ma_context config that keeps miniaudio from managing AVAudioSession on iOS.
    // With a NULL context, ma_device_init runs miniaudio's iOS "hack" (miniaudio.h ~44057)
    // that calls setCategory()/setActive() on EVERY device open — capture →
    // AVAudioSessionCategoryRecord with zero options. That obliterates the category/mode/
    // options the Swift IOSAudioRouter configured (notably .playAndRecord and
    // .allowBluetoothA2DP), which is what killed headphone/A2DP output when stereo was
    // selected. The iOS Swift layer is the SOLE owner of the audio session (activated in
    // AppState on connect, configured by IOSAudioRouter); miniaudio must only open the
    // AudioUnit against the already-configured, already-active route. These coreaudio fields
    // are no-ops on non-Apple backends.
    static ma_context_config make_context_config();

    // Owned context, shared by the playback, capture, and loopback devices so they all honor
    // the no-session-management config above. Lives for the engine's lifetime (init lazily in
    // start(), reused across stop()/start() restarts, uninit in the destructor) — loopback has
    // an independent start/stop lifecycle, so the context must outlive a single stop().
    ma_context context_{};
    bool       context_inited_ = false;

    ma_device capture_device_{};
    ma_device playback_device_{};
    bool      capture_started_  = false;
    bool      playback_started_ = false;

    // External-playback mode (iOS VPIO): no hardware playback device; this timer thread drives
    // on_playback() on a ~20 ms cadence and delivers the final mix to mixed_sink_. mixer_scratch_
    // is pre-sized in start() (frame_samples_ * playback_channels) so the loop never allocates.
    std::thread          mixer_timer_thread_;
    std::atomic<bool>    mixer_timer_stop_{false};
    std::vector<int16_t> mixer_scratch_;
    void                 run_mixer_timer();

#ifdef VOICECAT_HAS_LOOPBACK
    // Desktop-audio loopback capture (SCREEN_AUDIO) — own lifecycle, decoupled from
    // capture_device_/playback_device_ start/stop (a screen-share can start/stop independently
    // of the mic and of whether anything is currently playing back).
    static void loopback_data_cb(ma_device*, void*, const void*, ma_uint32);
    void        on_loopback(const int16_t* pcm, ma_uint32 frames);

    ma_device loopback_device_{};
    bool      loopback_started_ = false;
    int       loopback_kind_    = 0;
    int       loopback_channels_ = 1;  // channel count the loopback device was opened with
#endif
#endif

    AudioParams      params_{};
    CaptureCallback  capture_cb_;
    std::atomic<bool>  running_{false};
    std::atomic<float> output_volume_{1.0f};

    // Capture-side frame accumulators: miniaudio fires the capture (and loopback) callback at
    // whatever period the hardware/driver chooses — commonly 480 samples (10 ms) on WASAPI
    // shared mode, while the Opus encoder requires exactly frame_samples_ per call (960 for
    // 20 ms @ 48 kHz). Accumulate incoming PCM until a full frame is ready, then call
    // capture_cb_. This mirrors the RemoteStream::ring fix on the playback side. Both
    // accumulators are pre-allocated once in start(); never resized from the RT callback
    // thread (satisfies architecture.md §3 — no allocation on RT threads).
    struct CaptureAccum {
        std::vector<int16_t> buf;    // pre-sized to frame_samples_ in start()
        int                  count = 0;
    };
    CaptureAccum capture_accum_;   // mic / real capture device (on_capture)
    CaptureAccum loopback_accum_;  // screen-audio WASAPI loopback (on_loopback)

    // Inject ring(s): stores raw int16 PCM written by inject_capture(), one ring per local
    // stream kind so e.g. MIC and SCREEN_AUDIO can each be fed independently in tests.
    // The encode thread reads from these (no real capture device needed in tests).
    struct InjectTap {
        std::vector<int16_t> ring;            // circular, size = kInjectCapSamples
        std::atomic<size_t>  write{0};
        std::atomic<size_t>  read{0};
        int                  channels{1};     // channel count last written; resets ring on change
    };
    std::mutex                         inject_mu_;
    std::unordered_map<int, std::unique_ptr<InjectTap>> inject_taps_;
    static constexpr size_t kInjectCapSamples = 48000 * 2;  // 2 s @48 kHz mono

    // Per remote stream (protected by streams_mu_).
    struct RemoteStream {
        JitterBuffer     jitter;
#ifdef VOICECAT_HAS_OPUS
        codec::OpusDecoder decoder;
#endif
        float            gain  = 1.0f;
        bool             mute  = false;
        uint32_t         playout_ts = 0;
        // playout_ts free-runs (advances every callback via PLC), so it must be seeded from, and
        // periodically re-synced to, the actual stream timeline — otherwise it drifts past the
        // jitter buffer's drop window across VAD/PTT gaps and late joins and every frame is
        // dropped/never-due (silent playback). false until the first frame seeds it (on_playback).
        bool             playout_started = false;

        // Set by push_recv_frame when a kFlagMarker (talkspurt-start) frame arrives; consumed by
        // on_playback to force an immediate playout-clock reseed at the new talkspurt, so the
        // bounded-depth target is re-established cleanly across silence gaps. See on_playback.
        bool             pending_marker = false;

        // Diagnostic: times the decode/playback ring underran (produced silence because the
        // jitter buffer had nothing due) while the stream was actively playing out — i.e. the
        // "frames arriving but silent / latency starved" signal. Polled via stream_underruns().
        std::atomic<uint64_t> underruns{0};

        // PLC cap (defense-in-depth): consecutive samples produced by packet-loss
        // concealment since the last real decoded frame. Reset to 0 on every real frame.
        // When it exceeds kPlcCapSamples (audio_engine.cpp), on_playback stops calling
        // opus_decode(nullptr,0,...) and emits silence instead — bounding the comfort-noise
        // hiss to ~2 s so a stale stream can never hiss forever even if remove_stream is
        // never called. See on_playback's decode loop.
        int64_t          plc_samples_since_real = 0;

        // M3: listener-chosen, local-only noise reduction (docs/voice.md §10). Lazily
        // created only when enabled — bounded by how many remote streams this listener
        // subscribes to, so no separate instance cap is needed.
        bool                           noise_reduction_enabled = false;
        std::unique_ptr<ApmProcessor>  recv_ns;

        // True for a MIC stream (voice). The receive-side NR pass only denoises voice — a stereo
        // mic is folded to mono first (RNNoise is mono-only); a stereo screen-audio share is not
        // voice and is left untouched. Set in init_recv_stream. (docs/voice.md §10.)
        bool                           is_voice = false;

        // DRED: pre-allocated scratch for loss recovery. dred_state_ is per-stream; see
        // AudioEngine::dred_dec_ (shared). Allocated in init_recv_stream(); freed in remove_stream().
#ifdef VOICECAT_HAS_OPUS
        ::OpusDRED*          dred_state_ = nullptr;
#endif
        std::vector<uint8_t> dred_payload_scratch_;  // pre-sized to 4000 bytes

        // In-band FEC: whether the sender negotiated OPUS_SET_INBAND_FEC for this stream.
        // When set, on_playback attempts decode_fec=1 from the next buffered packet to recover a
        // lost frame (between DRED and PLC). Captured from OpusParams at init_recv_stream().
        bool                 fec_enabled_ = false;

        // Source identity: stored at init_recv_stream() so the pcm_sink_ callback can receive
        // (user_id, stream_id) without a separate map lookup from the RT playback thread.
        uint32_t user_id   = 0;
        uint32_t stream_id = 0;

        // M3: talk-indicator edge detection (docs/voice.md §7) — updated by push_recv_frame
        // (already off the real-time audio thread), polled by poll_talk_transitions().
        std::atomic<int64_t> last_voice_ms{0};
        bool                  talking = false;

        // ── Decode/playback decoupling ring ─────────────────────────────────────
        // opus_decode() must be called with max_samples == the encoder's fixed frame size
        // (decoder.frame_samples(), e.g. 960 @ 20ms/48kHz) — that's a property of the bitstream,
        // not a choice. miniaudio's playback callback period is a *separate*, independently
        // chosen value (often smaller, e.g. ~480 @ low-latency WASAPI defaults) and must never
        // be passed to opus_decode as max_samples (doing so made decode fail basically every
        // callback — silent playback bug, fixed by this ring). on_playback() tops this ring up
        // by decoding whole Opus frames (decoder's channel count) and drains exactly the
        // hardware-requested sample count from it each callback, padding with silence (PLC) on
        // underrun. Sized once in init_ring() (called off the audio thread); never resized from
        // on_playback (real-time rule).
        std::vector<int16_t> ring;            // capacity = (frame_samples * 8) frames * ring_channels
        size_t                ring_channels = 1;
        size_t                ring_head     = 0;  // next frame (sample-per-channel) to read
        size_t                ring_count    = 0;  // buffered frames (samples-per-channel) ready
        std::vector<int16_t> decode_scratch;       // pre-sized: frame_samples * ring_channels

        void init_ring(int channels, int frame_samples) {
            ring_channels = static_cast<size_t>(std::max(1, channels));
            size_t cap_frames = static_cast<size_t>(std::max(1, frame_samples)) * 8;  // ~160ms @20ms frames
            ring.assign(cap_frames * ring_channels, 0);
            ring_head  = 0;
            ring_count = 0;
            decode_scratch.assign(static_cast<size_t>(std::max(1, frame_samples)) * ring_channels, 0);
        }

        // Appends `n_frames` samples-per-channel (ring_channels each) from `pcm`. Drops the
        // tail (rather than overwriting unread data) if the ring is unexpectedly full — should
        // not happen with the generous 8x sizing above.
        void push_ring(const int16_t* pcm, size_t n_frames) {
            if (ring.empty() || ring_channels == 0) return;
            size_t cap_frames = ring.size() / ring_channels;
            for (size_t i = 0; i < n_frames; ++i) {
                if (ring_count >= cap_frames) return;
                size_t widx = (ring_head + ring_count) % cap_frames;
                for (size_t c = 0; c < ring_channels; ++c)
                    ring[widx * ring_channels + c] = pcm[i * ring_channels + c];
                ++ring_count;
            }
        }

        // Pops one frame (sample-per-channel) into `out` (sized `out_channels`, zero-filled
        // first — covers both a fully-drained ring and ring_channels < out_channels).
        void pop_ring(int16_t* out, size_t out_channels) {
            for (size_t c = 0; c < out_channels; ++c) out[c] = 0;
            if (ring_count == 0 || ring.empty() || ring_channels == 0) return;
            size_t cap_frames = ring.size() / ring_channels;
            for (size_t c = 0; c < ring_channels && c < out_channels; ++c)
                out[c] = ring[ring_head * ring_channels + c];
            ring_head = (ring_head + 1) % cap_frames;
            --ring_count;
        }
    };
    mutable std::mutex                              streams_mu_;
    std::unordered_map<uint32_t, RemoteStream>      streams_;

    int frame_samples_ = 960;  // 20 ms @48 kHz

    // External PCM tap: atomic fn-ptr + user-ptr pair. Written by set_pcm_sink (any thread);
    // read by on_playback (RT thread) via relaxed load — safe for pointer-sized atomics.
    std::atomic<PcmSink> pcm_sink_{nullptr};
    std::atomic<void*>   pcm_sink_user_{nullptr};

    // External mixed-output sink + mode flag (iOS VPIO). mixed_sink_ is written by
    // set_mixed_output_sink (any thread); read by run_mixer_timer via relaxed load.
    // external_playback_ is read in start() to gate hardware-playback-device creation.
    std::atomic<MixedSink> mixed_sink_{nullptr};
    std::atomic<void*>     mixed_sink_user_{nullptr};
    bool                   external_playback_ = false;

#ifdef VOICECAT_HAS_OPUS
    ::OpusDREDDecoder* dred_dec_ = nullptr;  // shared DRED decoder; null if unsupported
#endif

    static constexpr int64_t kTalkHangoverMs = 300;
};

}  // namespace voicecat::audio

#endif  // VOICECAT_AUDIO_AUDIO_ENGINE_H
