// hot-step-server.cpp: HOT-Step HTTP server for ACE-Step music generation
//
// Based on upstream ace-server.cpp with HOT-Step extensions:
//   - VAE model selection (multiple VAEs via vae_model field)
//   - /vram endpoint for GPU memory reporting
//   - Output format from URL ?format= param (backward compat)
//   - Adapter absolute-path fallback
//
// Single binary, one port. All compute endpoints (POST /lm, POST /synth,
// POST /understand) are asynchronous: they validate the request, create a
// job, push it to a FIFO queue, and return the job ID immediately.
// A single worker thread processes jobs in order.
// Clients poll GET /job?id=N for status and fetch results with
// GET /job?id=N&result=1. POST /job?id=N&cancel=1 cancels a job.
//
// Job IDs are random 64-bit hex strings (non-predictable).
// Completed jobs are evicted FIFO when the pool exceeds MAX_JOBS.
// Running jobs are never evicted.
//
// Models are discovered by scanning --models directory at startup
// (reads GGUF metadata only, no weights loaded).
// Each request loads the model, executes, and frees it. No model persists
// in VRAM between requests unless --keep-loaded is set. GPU access is
// serialized by the single worker thread (no mutex needed).
//
// Available models are classified by their GGUF general.architecture:
//   acestep-lm       -> lm bucket
//   acestep-dit      -> dit bucket
//   acestep-text-enc -> text-enc bucket (singleton, first entry used)
//   acestep-vae      -> vae bucket      (singleton, first entry used)
//
// Endpoint requirements:
//   /lm         LM
//   /synth      DiT + Text-Enc + VAE
//   /understand LM + DiT + VAE

#include "audio-io.h"
#include "audio-resample.h"
#include "denoiser.h"
#include "spectral-lifter.h"
#include "supersep.h"
#include "hot-step-params.h"
#include "lua-plugin-registry.h"

// ── Linker guard: verify hot-step-sampler.h is active ────────────────
// hot-step-sampler.h defines hotstep_sampler_linked_ with external linkage.
// pipeline-synth-ops.cpp includes it, compiling the symbol into acestep-core.lib.
// If upstream sync clobbers the include back to dit-sampler.h, this symbol
// vanishes and the linker fails here — making the regression a build error.
extern int hotstep_sampler_linked_;
static volatile int * _hotstep_guard_ = &hotstep_sampler_linked_;

#include "model-registry.h"
#include "model-store.h"
#include "vae.h"
#include "vae-enc.h"
#include "pipeline-lm.h"
#include "pipeline-synth.h"
#include "pipeline-understand.h"
#include "request.h"
#include "synth-batch-runner.h"
#include "task-types.h"
#include "version.h"
#include "yyjson.h"

// embedded webui (generated by xxd.cmake from tools/webui/public/index.html.gz)
#include "index.html.gz.hpp"

// suppress warnings in third-party headers
#ifdef __GNUC__
#    pragma GCC diagnostic push
#    pragma GCC diagnostic ignored "-Wshadow"
#endif
#include "httplib.h"
#ifdef __GNUC__
#    pragma GCC diagnostic pop
#endif

#include <atomic>
#include <condition_variable>
#include <csignal>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <deque>
#include <functional>
#include <memory>
#include <mutex>
#include <random>
#include <string>
#include <thread>
#include <unordered_map>
#include <vector>

#ifdef _WIN32
#    include <fcntl.h>
#    include <io.h>
#    ifndef STDERR_FILENO
#        define STDERR_FILENO 2
#    endif
#else
#    include <unistd.h>
#endif

#ifdef GGML_USE_CUDA
#    include <cuda_runtime_api.h>
#endif

// portable fd wrappers. avoids macros that collide with C++ method names
// (e.g. sink.write() in httplib would be eaten by a write() macro).
#ifdef _WIN32
static int fd_pipe(int fd[2]) {
    return _pipe(fd, 4096, _O_BINARY);
}

static int fd_dup(int fd) {
    return _dup(fd);
}

static int fd_dup2(int src, int dst) {
    return _dup2(src, dst);
}

static int fd_read(int fd, void * buf, size_t n) {
    return _read(fd, buf, (unsigned) n);
}

static int fd_write(int fd, const void * buf, size_t n) {
    return _write(fd, buf, (unsigned) n);
}

static void fd_close(int fd) {
    _close(fd);
}
#else
static int fd_pipe(int fd[2]) {
    return pipe(fd);
}

static int fd_dup(int fd) {
    return dup(fd);
}

static int fd_dup2(int src, int dst) {
    return dup2(src, dst);
}

static int fd_read(int fd, void * buf, size_t n) {
    return (int) read(fd, buf, n);
}

static int fd_write(int fd, const void * buf, size_t n) {
    return (int) write(fd, buf, n);
}

static void fd_close(int fd) {
    close(fd);
}
#endif

// server instance pointer for the signal handler
static httplib::Server * g_svr = nullptr;

static void on_signal(int) {
    if (g_svr) {
        g_svr->stop();
    }
}

// work queue: all GPU jobs go through a single FIFO queue processed
// by one worker thread. GPU access is serialized by construction.
static std::deque<std::function<void()>> g_work_queue;
static std::mutex                        mtx_work;
static std::condition_variable           cv_work;
static bool                              g_work_stop = false;

static void work_push(std::function<void()> fn) {
    std::lock_guard<std::mutex> lock(mtx_work);
    g_work_queue.push_back(std::move(fn));
    cv_work.notify_one();
}

// worker thread: consume jobs in FIFO order until shutdown.
// on stop: finishes the current job, discards pending ones.
static void worker_main() {
    for (;;) {
        std::function<void()> fn;
        {
            std::unique_lock<std::mutex> lock(mtx_work);
            cv_work.wait(lock, [] { return g_work_stop || !g_work_queue.empty(); });
            if (g_work_stop) {
                break;
            }
            fn = std::move(g_work_queue.front());
            g_work_queue.pop_front();
        }
        fn();
    }
}

// central GGML module store shared across pipelines. Policy picked at startup
// from --keep-loaded: STRICT by default (one GPU module resident at a time),
// NEVER when the flag is set (accumulate across requests).
static ModelStore * g_store = nullptr;

// model registry (populated at startup from GGUF metadata)
static ModelRegistry g_registry;

// loaded model names (empty = nothing loaded)
static std::string g_loaded_lm;
static std::string g_loaded_dit;
static std::string g_loaded_adapter;
static float       g_loaded_adapter_scale = 1.0f;
static std::string g_loaded_und_dit;
static std::string g_loaded_vae;

// pipeline params (rebuilt from registry paths on each load)
static AceLmParams         g_lm_params;
static AceSynthParams      g_synth_params;
static AceUnderstandParams g_und_params;

// limits
static int  g_max_batch   = 1;
static int  g_mp3_kbps    = 128;
static bool g_keep_loaded = false;

// speculative decoding: path to 0.6B draft model (auto-discovered or --draft-lm)
static std::string g_draft_lm_path;

// ONNX model directory (optional, for TensorRT/CUDA EP accelerated VAE)
static const char * g_onnx_dir = nullptr;

// HOT-Step: pre-computed noise profile for spectral denoiser.
// Loaded once at startup from a reference noise sample WAV.
static NoiseProfile g_noise_profile;

// latent format constants (matching upstream ace-server.cpp)
static const int MAX_T_LATENT      = 15000;  // ~10min at 25Hz
static const int LATENT_CHANNELS   = 64;
static const int LATENT_FRAME_BYTES = LATENT_CHANNELS * (int) sizeof(float);

// job system: all compute endpoints create a job and return its ID
// immediately. the worker thread processes jobs in FIFO order, stores
// the result. the client polls GET /job?id=N until done, then fetches
// the result with GET /job?id=N&result=1.
// cancel: POST /job?id=N&cancel=1 sets the per-job flag.
struct Job {
    std::string       id;
    std::atomic<int>  status{ 0 };  // 0=running 1=done 2=failed 3=cancelled
    std::string       result_body;
    std::string       result_mime;
    std::string       result_lrc;   // LRC timestamp text (base64), empty if not generated
    std::vector<float> result_latent; // post-DiT latent [T*64] float32, empty if not captured
    std::atomic<bool> cancel{ false };

    // memory ordering contract: result_body and result_mime are written
    // before status is stored (seq_cst). the client loads status (seq_cst)
    // and only reads result fields after seeing done/failed. this guarantees
    // visibility without an explicit mutex on the result fields.
};

static std::mutex                                            mtx_jobs;
static std::unordered_map<std::string, std::shared_ptr<Job>> g_jobs;
static std::deque<std::string>                               g_job_order;
static const int                                             MAX_JOBS = 32;

// generate a random hex ID (64 bits of entropy, non-predictable)
static std::string job_make_id() {
    static std::mt19937_64      rng(std::random_device{}());
    static std::mutex           mtx_rng;
    std::lock_guard<std::mutex> lock(mtx_rng);
    char                        buf[17];
    snprintf(buf, sizeof(buf), "%016llx", (unsigned long long) rng());
    return buf;
}

static std::shared_ptr<Job> job_create() {
    std::lock_guard<std::mutex> lock(mtx_jobs);
    auto                        job = std::make_shared<Job>();
    job->id                         = job_make_id();
    g_jobs[job->id]                 = job;
    g_job_order.push_back(job->id);

    // evict oldest completed jobs to stay under MAX_JOBS.
    // running jobs (status 0) are never evicted.
    while ((int) g_job_order.size() > MAX_JOBS) {
        bool evicted = false;
        for (auto it = g_job_order.begin(); it != g_job_order.end(); ++it) {
            auto jit = g_jobs.find(*it);
            if (jit == g_jobs.end() || jit->second->status.load() != 0) {
                if (jit != g_jobs.end()) {
                    g_jobs.erase(jit);
                }
                g_job_order.erase(it);
                evicted = true;
                break;
            }
        }
        if (!evicted) {
            break;
        }
    }
    return job;
}

static std::shared_ptr<Job> job_find(const std::string & id) {
    std::lock_guard<std::mutex> lock(mtx_jobs);
    auto                        it = g_jobs.find(id);
    return it != g_jobs.end() ? it->second : nullptr;
}

static const char * job_status_str(int s) {
    switch (s) {
        case 0:
            return "running";
        case 1:
            return "done";
        case 2:
            return "failed";
        case 3:
            return "cancelled";
        default:
            return "unknown";
    }
}

// log capture: intercept stderr via pipe, forward to terminal + ring buffer.
// SSE clients connect to /logs and receive lines in real time.
#define LOG_RING_BITS 9
#define LOG_RING_SIZE (1 << LOG_RING_BITS)
#define LOG_RING_MASK (LOG_RING_SIZE - 1)

static std::mutex              mtx_log;
static std::condition_variable cv_log;
static std::string             log_ring[LOG_RING_SIZE];
static uint64_t                log_seq = 0;

static int g_real_stderr_fd = -1;
static int g_pipe_read_fd   = -1;

static void setup_log_capture() {
    g_real_stderr_fd = fd_dup(STDERR_FILENO);
    int pipefd[2];
    if (fd_pipe(pipefd) != 0) {
        g_real_stderr_fd = -1;
        return;
    }
    g_pipe_read_fd = pipefd[0];
    fd_dup2(pipefd[1], STDERR_FILENO);
    fd_close(pipefd[1]);
}

// reader thread: drain pipe, forward to real stderr, push lines to ring.
// exits when the write end of the pipe is closed (fd_dup2 restores real stderr).
static void log_reader_main() {
    char        buf[4096];
    std::string partial;
    for (;;) {
        int n = fd_read(g_pipe_read_fd, buf, sizeof(buf));
        if (n <= 0) {
            break;
        }
        fd_write(g_real_stderr_fd, buf, (size_t) n);
        partial.append(buf, (size_t) n);
        size_t pos;
        while ((pos = partial.find('\n')) != std::string::npos) {
            std::lock_guard<std::mutex> lock(mtx_log);
            log_ring[log_seq & LOG_RING_MASK] = partial.substr(0, pos);
            log_seq++;
            cv_log.notify_all();
            partial.erase(0, pos + 1);
        }
    }
    if (!partial.empty()) {
        std::lock_guard<std::mutex> lock(mtx_log);
        log_ring[log_seq & LOG_RING_MASK] = std::move(partial);
        log_seq++;
        cv_log.notify_all();
    }
    fd_close(g_pipe_read_fd);
}

static void teardown_log_capture() {
    if (g_real_stderr_fd < 0) {
        return;
    }
    fflush(stderr);
    fd_dup2(g_real_stderr_fd, STDERR_FILENO);
    // g_real_stderr_fd stays open: the reader thread writes to it
}

// RAII: captures stderr on construction, restores + joins reader on destruction.
// safe on any exit path (early arg errors, model load failures, normal shutdown).
struct LogCapture {
    std::thread reader;

    LogCapture() {
        setup_log_capture();
        reader = std::thread(log_reader_main);
    }

    ~LogCapture() {
        teardown_log_capture();
        cv_log.notify_all();
        if (reader.joinable()) {
            reader.join();
        }

        // reader is done draining the pipe, safe to close
        if (g_real_stderr_fd >= 0) {
            fd_close(g_real_stderr_fd);
            g_real_stderr_fd = -1;
        }
    }
};

// GET /logs: SSE stream of stderr lines.
// sends backlog (up to LOG_RING_SIZE) then streams new lines in real time.
static void handle_logs(const httplib::Request &, httplib::Response & res) {
    res.set_header("Cache-Control", "no-cache");
    res.set_header("X-Accel-Buffering", "no");
    res.set_chunked_content_provider(
        "text/event-stream", [cursor = uint64_t(0), init = false](size_t, httplib::DataSink & sink) mutable -> bool {
            std::unique_lock<std::mutex> lock(mtx_log);
            if (!init) {
                uint64_t avail = log_seq < LOG_RING_SIZE ? log_seq : (uint64_t) LOG_RING_SIZE;
                cursor         = log_seq - avail;
                while (cursor < log_seq) {
                    std::string ev = "data: " + log_ring[cursor & LOG_RING_MASK] + "\n\n";
                    cursor++;
                    lock.unlock();
                    if (!sink.write(ev.c_str(), ev.size())) {
                        return false;
                    }
                    lock.lock();
                }
                init = true;
            }
            cv_log.wait_for(lock, std::chrono::seconds(2));
            while (cursor < log_seq) {
                std::string ev = "data: " + log_ring[cursor & LOG_RING_MASK] + "\n\n";
                cursor++;
                lock.unlock();
                if (!sink.write(ev.c_str(), ev.size())) {
                    return false;
                }
                lock.lock();
            }
            return true;
        });
}

// cancel callback: checks the per-job cancel flag.
static bool server_cancel_job(void * data) {
    auto * flag = (const std::atomic<bool> *) data;
    return flag && flag->load(std::memory_order_relaxed);
}

// helper: set a JSON error response
static void json_error(httplib::Response & res, int status, const char * msg) {
    yyjson_mut_doc * doc  = yyjson_mut_doc_new(NULL);
    yyjson_mut_val * root = yyjson_mut_obj(doc);
    yyjson_mut_doc_set_root(doc, root);
    yyjson_mut_obj_add_str(doc, root, "error", msg);
    char * json = yyjson_mut_write(doc, 0, NULL);
    yyjson_mut_doc_free(doc);
    res.status = status;
    res.set_content(json, "application/json");
    free(json);
}

// resolve model name: explicit request > already loaded > first in bucket
static std::string resolve_name(const std::vector<ModelEntry> & bucket,
                                const std::string &             requested,
                                const std::string &             loaded) {
    if (!requested.empty()) {
        return requested;
    }
    if (!loaded.empty()) {
        return loaded;
    }
    if (!bucket.empty()) {
        return bucket[0].name;
    }
    return "";
}

// =====================================================================
// HOT-STEP EXTENSIONS
// =====================================================================

// server-side routing fields parsed from JSON (not part of AceRequest).
// these are HOT-Step additions that travel alongside the upstream request.
struct ServerFields {
    std::string vae_model;       // explicit VAE selection ("": use first in registry)
    std::string emb_model;       // explicit text encoder selection ("": use first in registry)
    std::string solver_name;     // "euler", "rk4", "heun", etc.
    std::string scheduler;       // "composite:...", "bong_tangent", etc.
    std::string guidance_mode;   // "apg", "dynamic_cfg", etc.
    float       apg_momentum       = 0.75f;
    float       apg_norm_threshold = 2.5f;
    int         stork_substeps     = 10;
    float       beat_stability     = 0.25f;
    float       frequency_damping  = 0.4f;
    float       temporal_smoothing = 0.13f;
    AdapterGroupScales group_scales;  // per-group adapter scale multipliers
    std::string adapter_mode;         // "merge" (default, F32 promoted) or "runtime"
    // DCW (Differential Correction in Wavelet domain)
    bool        dcw_enabled      = false;
    std::string dcw_mode         = "low";
    float       dcw_scaler       = 0.1f;
    float       dcw_high_scaler  = 0.0f;
    // Latent post-processing
    float       latent_shift     = 0.0f;
    float       latent_rescale   = 1.0f;
    float       cfg_cutoff_ratio = 1.0f;
    float       cache_ratio      = 0.0f;
    std::string custom_timesteps = "";
    // Post-VAE spectral denoiser (HOT-Step)
    float       denoise_strength  = 0.0f;   // 0 = off, 1 = max
    float       denoise_smoothing = 0.7f;
    float       denoise_mix       = 0.25f;
    // Lua plugin params: {"pluginName:key": "value", ...}
    std::unordered_map<std::string, std::string> plugin_params;
};

static void parse_server_fields(const char * json, ServerFields * sf) {
    sf->vae_model       = "";
    sf->emb_model       = "";
    sf->solver_name     = "euler";
    sf->scheduler       = "";
    sf->guidance_mode   = "apg";
    sf->adapter_mode    = "merge";
    sf->apg_momentum       = 0.75f;
    sf->apg_norm_threshold = 2.5f;
    sf->stork_substeps     = 10;
    sf->beat_stability     = 0.25f;
    sf->frequency_damping  = 0.4f;
    sf->temporal_smoothing = 0.13f;

    yyjson_doc * doc = yyjson_read(json, strlen(json), 0);
    if (!doc) return;
    yyjson_val * root = yyjson_doc_get_root(doc);
    if (!root) { yyjson_doc_free(doc); return; }
    yyjson_val * obj = root;
    if (yyjson_is_arr(root)) {
        obj = yyjson_arr_get_first(root);
    }
    if (!obj || !yyjson_is_obj(obj)) { yyjson_doc_free(doc); return; }

    yyjson_val * v;
    if ((v = yyjson_obj_get(obj, "vae_model")) && yyjson_is_str(v)) {
        sf->vae_model = yyjson_get_str(v);
    }
    if ((v = yyjson_obj_get(obj, "emb_model")) && yyjson_is_str(v)) {
        sf->emb_model = yyjson_get_str(v);
    }
    // Solver / scheduler / guidance
    if ((v = yyjson_obj_get(obj, "infer_method")) && yyjson_is_str(v)) {
        sf->solver_name = yyjson_get_str(v);
    }
    if ((v = yyjson_obj_get(obj, "scheduler")) && yyjson_is_str(v)) {
        sf->scheduler = yyjson_get_str(v);
    }
    if ((v = yyjson_obj_get(obj, "guidance_mode")) && yyjson_is_str(v)) {
        sf->guidance_mode = yyjson_get_str(v);
    }
    if ((v = yyjson_obj_get(obj, "adapter_mode")) && yyjson_is_str(v)) {
        sf->adapter_mode = yyjson_get_str(v);
    }
    // APG tuning
    if ((v = yyjson_obj_get(obj, "apg_momentum")) && yyjson_is_num(v)) {
        sf->apg_momentum = (float) yyjson_get_real(v);
    }
    if ((v = yyjson_obj_get(obj, "apg_norm_threshold")) && yyjson_is_num(v)) {
        sf->apg_norm_threshold = (float) yyjson_get_real(v);
    }
    // STORK solver params
    if ((v = yyjson_obj_get(obj, "stork_substeps")) && yyjson_is_int(v)) {
        sf->stork_substeps = (int) yyjson_get_int(v);
    }
    if ((v = yyjson_obj_get(obj, "beat_stability")) && yyjson_is_num(v)) {
        sf->beat_stability = (float) yyjson_get_real(v);
    }
    if ((v = yyjson_obj_get(obj, "frequency_damping")) && yyjson_is_num(v)) {
        sf->frequency_damping = (float) yyjson_get_real(v);
    }
    if ((v = yyjson_obj_get(obj, "temporal_smoothing")) && yyjson_is_num(v)) {
        sf->temporal_smoothing = (float) yyjson_get_real(v);
    }
    // Per-group adapter scales: {"adapter_group_scales": {"self_attn": 1.0, ...}}
    // NOTE: JSON integer 1 vs float 1.0 — yyjson_get_real returns 0 for ints.
    // Use a lambda that handles both.
    auto get_num = [](yyjson_val * val) -> float {
        return yyjson_is_real(val) ? (float) yyjson_get_real(val) : (float) yyjson_get_int(val);
    };
    yyjson_val * gs_obj = yyjson_obj_get(obj, "adapter_group_scales");
    if (gs_obj && yyjson_is_obj(gs_obj)) {
        if ((v = yyjson_obj_get(gs_obj, "self_attn")) && yyjson_is_num(v))
            sf->group_scales.self_attn = get_num(v);
        if ((v = yyjson_obj_get(gs_obj, "cross_attn")) && yyjson_is_num(v))
            sf->group_scales.cross_attn = get_num(v);
        if ((v = yyjson_obj_get(gs_obj, "mlp")) && yyjson_is_num(v))
            sf->group_scales.mlp = get_num(v);
        if ((v = yyjson_obj_get(gs_obj, "cond_embed")) && yyjson_is_num(v))
            sf->group_scales.cond_embed = get_num(v);
        if ((v = yyjson_obj_get(gs_obj, "time_embed")) && yyjson_is_num(v))
            sf->group_scales.time_embed = get_num(v);
        if ((v = yyjson_obj_get(gs_obj, "proj_in")) && yyjson_is_num(v))
            sf->group_scales.proj_in = get_num(v);
        fprintf(stderr, "[DIAG] Parsed adapter_group_scales from JSON: sa=%.2f ca=%.2f mlp=%.2f ce=%.2f te=%.2f pi=%.2f\n",
                sf->group_scales.self_attn, sf->group_scales.cross_attn,
                sf->group_scales.mlp, sf->group_scales.cond_embed, sf->group_scales.time_embed, sf->group_scales.proj_in);
    } else {
        fprintf(stderr, "[DIAG] adapter_group_scales: gs_obj=%p is_obj=%d\n",
                (void*)gs_obj, gs_obj ? yyjson_is_obj(gs_obj) : -1);
    }
    // DCW fields
    if ((v = yyjson_obj_get(obj, "dcw_enabled"))) {
        if (yyjson_is_bool(v)) {
            sf->dcw_enabled = yyjson_get_bool(v);
        } else if (yyjson_is_str(v)) {
            sf->dcw_enabled = (strcmp(yyjson_get_str(v), "true") == 0);
        }
    }
    if ((v = yyjson_obj_get(obj, "dcw_mode")) && yyjson_is_str(v)) {
        sf->dcw_mode = yyjson_get_str(v);
    }
    if ((v = yyjson_obj_get(obj, "dcw_scaler")) && yyjson_is_num(v)) {
        sf->dcw_scaler = (float) yyjson_get_real(v);
    }
    if ((v = yyjson_obj_get(obj, "dcw_high_scaler")) && yyjson_is_num(v)) {
        sf->dcw_high_scaler = (float) yyjson_get_real(v);
    }
    // Latent post-processing
    if ((v = yyjson_obj_get(obj, "latent_shift")) && yyjson_is_num(v)) {
        sf->latent_shift = (float) yyjson_get_real(v);
    }
    if ((v = yyjson_obj_get(obj, "latent_rescale")) && yyjson_is_num(v)) {
        sf->latent_rescale = (float) yyjson_get_real(v);
    }
    if ((v = yyjson_obj_get(obj, "cfg_cutoff_ratio")) && yyjson_is_num(v)) {
        sf->cfg_cutoff_ratio = (float) yyjson_get_real(v);
    }
    if ((v = yyjson_obj_get(obj, "cache_ratio")) && yyjson_is_num(v)) {
        sf->cache_ratio = (float) yyjson_get_real(v);
    }
    if ((v = yyjson_obj_get(obj, "custom_timesteps")) && yyjson_is_str(v)) {
        sf->custom_timesteps = yyjson_get_str(v);
    }
    // Post-VAE spectral denoiser (HOT-Step)
    // NOTE: use get_num — JS may serialize whole numbers as integers (1 not 1.0)
    if ((v = yyjson_obj_get(obj, "denoise_strength")) && yyjson_is_num(v)) {
        sf->denoise_strength = get_num(v);
    }
    if ((v = yyjson_obj_get(obj, "denoise_smoothing")) && yyjson_is_num(v)) {
        sf->denoise_smoothing = get_num(v);
    }
    if ((v = yyjson_obj_get(obj, "denoise_mix")) && yyjson_is_num(v)) {
        sf->denoise_mix = get_num(v);
    }
    // Lua plugin params: iterate "plugin_params" object
    yyjson_val * pp_obj = yyjson_obj_get(obj, "plugin_params");
    if (pp_obj && yyjson_is_obj(pp_obj)) {
        sf->plugin_params.clear();
        yyjson_val * pp_key, * pp_val;
        yyjson_obj_iter pp_iter;
        yyjson_obj_iter_init(pp_obj, &pp_iter);
        while ((pp_key = yyjson_obj_iter_next(&pp_iter))) {
            pp_val = yyjson_obj_iter_get_val(pp_key);
            std::string k = yyjson_get_str(pp_key);
            std::string v_str;
            if (yyjson_is_str(pp_val)) {
                v_str = yyjson_get_str(pp_val);
            } else if (yyjson_is_real(pp_val)) {
                v_str = std::to_string(yyjson_get_real(pp_val));
            } else if (yyjson_is_int(pp_val)) {
                v_str = std::to_string(yyjson_get_int(pp_val));
            } else if (yyjson_is_bool(pp_val)) {
                v_str = yyjson_get_bool(pp_val) ? "true" : "false";
            }
            sf->plugin_params[k] = v_str;
        }
        if (!sf->plugin_params.empty()) {
            fprintf(stderr, "[DIAG] Parsed %d plugin_params\n", (int) sf->plugin_params.size());
        }
    }
    yyjson_doc_free(doc);
}

// =====================================================================

// LM worker: generates metadata + lyrics + codes, stores JSON result in job.
static void lm_worker(std::shared_ptr<Job> job, AceRequest ace_req, int lm_batch_size, int mode) {
    if (job->cancel.load()) {
        job->status.store(3);
        return;
    }

    // Resolve model name and build per-request params from the template.
    std::string        lm_name = resolve_name(g_registry.lm, ace_req.lm_model, g_loaded_lm);
    const ModelEntry * entry   = registry_find(g_registry.lm, lm_name.c_str());
    if (!entry) {
        fprintf(stderr, "[Server] LM not found: %s\n", lm_name.c_str());
        job->status.store(2);
        return;
    }
    AceLmParams p = g_lm_params;
    p.model_path  = entry->path.c_str();

    // Acquire a fresh LM ctx from the shared store. Under EVICT_STRICT the
    // module is reloaded if another pipeline evicted it; under EVICT_NEVER
    // the store returns the cached instance.
    AceLm * ctx = ace_lm_load(g_store, &p);
    if (!ctx) {
        fprintf(stderr, "[Server] FATAL: LM load failed\n");
        job->status.store(2);
        return;
    }

    // Execute and always free the ctx, success or failure: the store decides
    // whether the underlying GPU module stays resident.
    // Tie LM seed to DiT seed: locked seed → both deterministic, random → both random.
    ace_req.lm_seed = ace_req.seed;
    request_resolve_lm_seed(&ace_req);
    std::vector<AceRequest> out(lm_batch_size);
    int rc = ace_lm_generate(ctx, &ace_req, lm_batch_size, out.data(), NULL, NULL, server_cancel_job,
                             (void *) &job->cancel, mode);
    ace_lm_free(ctx);

    if (rc != 0) {
        job->status.store(job->cancel.load() ? 3 : 2);
        return;
    }

    // Sticky name hint for resolve_name under --keep-loaded. Master clears it
    // in the default mode since the ctx is gone; we match that behavior.
    if (g_keep_loaded) {
        g_loaded_lm = lm_name;
    } else {
        g_loaded_lm.clear();
    }

    // serialize output as a JSON array
    std::string body = "[";
    for (int i = 0; i < lm_batch_size; i++) {
        if (i > 0) {
            body += ",";
        }
        body += request_to_json(&out[i]);
    }
    body += "]";

    job->result_body = std::move(body);
    job->result_mime = "application/json";
    job->status.store(1);
    fprintf(stderr, "[Server] Job %s done (LM, %d results)\n", job->id.c_str(), lm_batch_size);
}

// POST /lm
// accepts: AceRequest JSON (lm_mode in the body selects the generation mode).
// returns: JSON {"id":"N"} immediately. result is a JSON array of enriched
// AceRequests (lm_batch_size controls count).
// modes (AceRequest.lm_mode):
//   generate  metadata + lyrics + audio_codes  (full composer pass)
//   inspire   metadata + lyrics                (audio_codes stays empty)
//   format    metadata + lyrics                (audio_codes stays empty)
static void handle_lm(const httplib::Request & req, httplib::Response & res) {
    if (g_registry.lm.empty()) {
        json_error(res, 501, "No LM models in registry");
        return;
    }

    // Co-resident mode: flip store policy BEFORE the LM loads so it stays
    // cached. Without this, gen 1 frees the LM under STRICT (the synth
    // worker flips to NEVER too late), and gen 2 reloads ~8 GB on top of
    // the synth models that are already resident.
    const bool req_keep_loaded = req.has_param("keep_loaded") && req.get_param_value("keep_loaded") == "1";
    if (req_keep_loaded && !g_keep_loaded) {
        g_keep_loaded = true;
        store_set_policy(g_store, EVICT_NEVER);
        fprintf(stderr, "[Server] Co-resident mode activated (from /lm)\n");
    }

    // parse request
    AceRequest ace_req;
    if (!request_parse_json(&ace_req, req.body.c_str())) {
        json_error(res, 400, "Invalid JSON");
        return;
    }
    if (ace_req.caption.empty()) {
        json_error(res, 400, "Caption is required");
        return;
    }

    // Resolve lm_mode string to integer mode used by ace_lm_generate.
    int mode;
    if (ace_req.lm_mode == LM_MODE_NAME_GENERATE) {
        mode = LM_MODE_GENERATE;
    } else if (ace_req.lm_mode == LM_MODE_NAME_INSPIRE) {
        mode = LM_MODE_INSPIRE;
    } else if (ace_req.lm_mode == LM_MODE_NAME_FORMAT) {
        mode = LM_MODE_FORMAT;
    } else {
        json_error(res, 400, "Invalid lm_mode (use: generate, inspire, format)");
        return;
    }

    // clamp lm_batch_size to [1, max_batch]
    int lm_batch_size = ace_req.lm_batch_size;
    if (lm_batch_size < 1) {
        lm_batch_size = 1;
    }
    if (lm_batch_size > g_max_batch) {
        lm_batch_size = g_max_batch;
    }

    auto job = job_create();
    fprintf(stderr, "[Server] Job %s created (LM, mode=%d)%s\n", job->id.c_str(), mode,
            g_keep_loaded ? " [keep-loaded]" : "");

    work_push([job, ace_req, lm_batch_size, mode]() { lm_worker(job, ace_req, lm_batch_size, mode); });

    std::string body = "{\"id\":\"" + job->id + "\"}";
    res.set_content(body, "application/json");
}

// synth worker: processes synth request, stores audio result in job.
static void synth_worker(std::shared_ptr<Job>    job,
                         std::vector<AceRequest> ace_reqs,
                         ServerFields            sf,
                         float *                 src_interleaved,
                         int                     src_len,
                         float *                 src_latents,
                         int                     src_T_latent,
                         float *                 ref_interleaved,
                         int                     ref_len,
                         float *                 ref_latents,
                         int                     ref_T_latent,
                         bool                    output_wav,
                         WavFormat               wav_fmt,
                         int                     peak_clip,
                         bool                    req_keep_loaded) {
    // Generate every request in one DiT batch. synth_batch_size expands each
    // request into per-seed variants. Total clamped to DiT max 9.
    const int batch_n     = (int) ace_reqs.size();
    int       total_alloc = 0;
    for (int ri = 0; ri < batch_n; ri++) {
        int sbs = ace_reqs[ri].synth_batch_size;
        total_alloc += sbs < 1 ? 1 : (sbs > 9 ? 9 : sbs);
    }
    if (total_alloc > 9) {
        fprintf(stderr, "[Server] Batch %d exceeds DiT max 9, clamping\n", total_alloc);
        total_alloc = 9;
    }
    std::vector<AceAudio> audio(total_alloc);

    if (job->cancel.load()) {
        free(src_interleaved);
        free(src_latents);
        free(ref_interleaved);
        free(ref_latents);
        job->status.store(3);
        return;
    }

    // Resolve DiT, adapter and the text-encoder / VAE singletons.
    std::string        dit_name = resolve_name(g_registry.dit, ace_reqs[0].synth_model, g_loaded_dit);
    const ModelEntry * dit      = registry_find(g_registry.dit, dit_name.c_str());
    if (!dit) {
        fprintf(stderr, "[Server] DiT not found: %s\n", dit_name.c_str());
        free(src_interleaved);
        free(src_latents);
        free(ref_interleaved);
        free(ref_latents);
        job->status.store(2);
        return;
    }
    if (g_registry.text_enc.empty() || g_registry.vae.empty()) {
        fprintf(stderr, "[Server] Missing Text-Enc or VAE in registry\n");
        free(src_interleaved);
        free(src_latents);
        free(ref_interleaved);
        free(ref_latents);
        job->status.store(2);
        return;
    }

    AceSynthParams p    = g_synth_params;
    // HOT-STEP: Text encoder model selection. Resolve by name from registry.
    const ModelEntry * emb_entry = nullptr;
    if (!sf.emb_model.empty()) {
        emb_entry = registry_find(g_registry.text_enc, sf.emb_model.c_str());
        if (!emb_entry) {
            fprintf(stderr, "[Server] Text encoder not found: %s, using default\n", sf.emb_model.c_str());
        }
    }
    p.text_encoder_path = emb_entry ? emb_entry->path.c_str() : g_registry.text_enc[0].path.c_str();
    p.dit_path          = dit->path.c_str();
    // HOT-STEP: VAE model selection. Resolve by name from registry.
    // ONNX VAE files are decoder-only — they go through the ORT decode path,
    // NOT the GGML encode path. If the user selects an ONNX VAE, we route it
    // to onnx_vae_path and use the first GGUF/safetensors VAE for encoding.
    const ModelEntry * vae_entry = nullptr;
    bool               vae_is_onnx = false;
    if (!sf.vae_model.empty()) {
        vae_entry = registry_find(g_registry.vae, sf.vae_model.c_str());
        if (!vae_entry) {
            fprintf(stderr, "[Server] VAE not found: %s, using default\n", sf.vae_model.c_str());
        } else if (vae_entry->name.size() >= 5 &&
                   vae_entry->name.substr(vae_entry->name.size() - 5) == ".onnx") {
            vae_is_onnx = true;
            // Route ONNX VAE to ORT decode path
            p.onnx_vae_path = vae_entry->path.c_str();
            fprintf(stderr, "[Server] ONNX VAE selected: %s → ORT decode path\n", vae_entry->name.c_str());
            // Fall back to GGUF/safetensors for encoding
            vae_entry = registry_find_non_onnx(g_registry.vae);
        }
    }
    if (!vae_entry) {
        vae_entry = registry_find_non_onnx(g_registry.vae);
    }
    p.vae_path = vae_entry ? vae_entry->path.c_str() : g_registry.vae[0].path.c_str();
    // PP-VAE: auto-detect from registry, prefer highest precision: F32 > BF16 > F16
    p.pp_vae_path = nullptr;
    if (!g_registry.pp_vae.empty()) {
        const char * pref[] = { "F32", "BF16", "F16" };
        for (const char * tag : pref) {
            for (const auto & e : g_registry.pp_vae) {
                if (e.name.find(tag) != std::string::npos) {
                    p.pp_vae_path = e.path.c_str();
                    break;
                }
            }
            if (p.pp_vae_path) break;
        }
        if (!p.pp_vae_path) p.pp_vae_path = g_registry.pp_vae[0].path.c_str();
    }
    p.adapter_path      = nullptr;
    p.adapter_scale     = 1.0f;
    if (!ace_reqs[0].adapter.empty()) {
        const AdapterEntry * adapter = registry_find_adapter(g_registry, ace_reqs[0].adapter.c_str());
        if (!adapter) {
            // HOT-STEP: absolute-path fallback for adapters not in the registry
            // (e.g. user provides a full path to a safetensors file)
            static std::string abs_path_buf;
            abs_path_buf = ace_reqs[0].adapter;
            FILE * test = fopen(abs_path_buf.c_str(), "rb");
            if (test) {
                fclose(test);
                fprintf(stderr, "[Server] Adapter absolute path: %s\n", abs_path_buf.c_str());
                p.adapter_path  = abs_path_buf.c_str();
                p.adapter_scale = ace_reqs[0].adapter_scale;
            } else {
                fprintf(stderr, "[Server] Adapter not found: %s\n", ace_reqs[0].adapter.c_str());
                free(src_interleaved);
                free(src_latents);
                free(ref_interleaved);
                free(ref_latents);
                job->status.store(2);
                return;
            }
        } else {
            p.adapter_path  = adapter->path.c_str();
            p.adapter_scale = ace_reqs[0].adapter_scale;
        }
    }
    fprintf(stderr, "[Server] Text encoder: %s\n", emb_entry ? sf.emb_model.c_str() : g_registry.text_enc[0].name.c_str());
    fprintf(stderr, "[Server] Loading synth: DiT=%s%s%s%s\n", dit_name.c_str(),
            ace_reqs[0].adapter.empty() ? "" : " Adapter=", ace_reqs[0].adapter.c_str(),
            (g_keep_loaded || req_keep_loaded) ? " [keep-loaded]" : "");

    // HOT-STEP: per-request co-resident mode. Once flipped to NEVER, stays
    // that way until restart (going back to STRICT would need a full eviction
    // pass and is not safe mid-flight).
    if (req_keep_loaded && !g_keep_loaded) {
        g_keep_loaded = true;
        store_set_policy(g_store, EVICT_NEVER);
    }

    // HOT-Step sideband: push custom params to global BEFORE synth load.
    // Critical: adapter_group_scales must be set before ace_synth_load()
    // because the adapter merge (inside dit_ggml_load) reads them from the
    // global at merge time. Setting them after load uses stale scales.
    g_hotstep_params.solver_name         = sf.solver_name;
    g_hotstep_params.scheduler           = sf.scheduler;
    g_hotstep_params.guidance_mode       = sf.guidance_mode;
    g_hotstep_params.apg_momentum        = sf.apg_momentum;
    g_hotstep_params.apg_norm_threshold  = sf.apg_norm_threshold;
    g_hotstep_params.stork_substeps      = sf.stork_substeps;
    g_hotstep_params.beat_stability      = sf.beat_stability;
    g_hotstep_params.frequency_damping   = sf.frequency_damping;
    g_hotstep_params.temporal_smoothing  = sf.temporal_smoothing;
    g_hotstep_params.adapter_group_scales = sf.group_scales;
    g_hotstep_params.adapter_mode         = sf.adapter_mode;
    g_hotstep_params.dcw_enabled          = sf.dcw_enabled;
    g_hotstep_params.dcw_mode             = sf.dcw_mode;
    g_hotstep_params.dcw_scaler           = sf.dcw_scaler;
    g_hotstep_params.dcw_high_scaler      = sf.dcw_high_scaler;
    g_hotstep_params.latent_shift          = sf.latent_shift;
    g_hotstep_params.latent_rescale        = sf.latent_rescale;
    g_hotstep_params.custom_timesteps      = sf.custom_timesteps;
    g_hotstep_params.cfg_cutoff_ratio      = sf.cfg_cutoff_ratio;
    g_hotstep_params.cache_ratio           = sf.cache_ratio;
    g_hotstep_params.plugin_params         = sf.plugin_params;
    fprintf(stderr, "[Server] HOT-Step params: solver=%s, guidance=%s, scheduler=%s\n",
            sf.solver_name.c_str(), sf.guidance_mode.c_str(),
            sf.scheduler.empty() ? "(default)" : sf.scheduler.c_str());
    fprintf(stderr, "[Server] Adapter group scales: self_attn=%.2f, cross_attn=%.2f, mlp=%.2f, cond_embed=%.2f\n",
            sf.group_scales.self_attn, sf.group_scales.cross_attn,
            sf.group_scales.mlp, sf.group_scales.cond_embed);
    if (sf.dcw_enabled) {
        fprintf(stderr, "[Server] DCW: mode=%s scaler=%.3f high_scaler=%.3f\n",
                sf.dcw_mode.c_str(), sf.dcw_scaler, sf.dcw_high_scaler);
    }
    if (sf.cfg_cutoff_ratio < 1.0f) {
        fprintf(stderr, "[Server] CFG cutoff: ratio=%.2f (CFG for first %.0f%% of steps)\n",
                sf.cfg_cutoff_ratio, sf.cfg_cutoff_ratio * 100.0f);
    }
    if (sf.cache_ratio > 0.0f) {
        fprintf(stderr, "[Server] Step cache: ratio=%.2f (skip ~%.0f%% of forward passes)\n",
                sf.cache_ratio, sf.cache_ratio * 100.0f);
    }

    AceSynth * ctx = ace_synth_load(g_store, &p);
    if (!ctx) {
        fprintf(stderr, "[Server] FATAL: synth load failed\n");
        free(src_interleaved);
        free(src_latents);
        free(ref_interleaved);
        free(ref_latents);
        job->status.store(2);
        return;
    }

    // HOT-Step: restore auto-shift that upstream removed.
    // When shift == -1, compute adaptive shift from duration + step count.
    // base_shift=3.0 always — merge/turbo models need high shift.
    // Upstream treats shift <= 0 as "default" (1.0 for non-turbo), which is wrong for our models.
    // Must run BEFORE groups are built (copies are taken below).
    for (int ri = 0; ri < batch_n; ri++) {
        if (ace_reqs[ri].shift == -1.0f) {
            float dur    = ace_reqs[ri].duration > 0.0f ? (float) ace_reqs[ri].duration : 60.0f;
            int   steps  = ace_reqs[ri].inference_steps > 0 ? ace_reqs[ri].inference_steps : 20;
            float dur_f  = 1.0f + 0.15f * ((dur - 60.0f) / 60.0f);
            dur_f        = fmaxf(0.8f, fminf(1.4f, dur_f));
            float step_f = 1.0f + 0.1f * ((30.0f - (float) steps) / 30.0f);
            step_f       = fmaxf(0.8f, fminf(1.4f, step_f));
            float computed = fmaxf(1.0f, fminf(6.0f, 3.0f * dur_f * step_f));
            ace_reqs[ri].shift = computed;
            if (ri == 0) {
                fprintf(stderr, "[Server] Auto shift: duration=%.0fs, steps=%d → shift=%.3f\n",
                        dur, steps, computed);
            }
        }
    }

    // Build the flat batch. Seeds are resolved per original request, then
    // synth_batch_size is expanded into per-seed variants in groups[0].
    std::vector<std::vector<AceRequest>> groups(1);
    groups[0].reserve(total_alloc);
    int off = 0;
    for (int ri = 0; ri < batch_n && off < total_alloc; ri++) {
        auto & r   = ace_reqs[ri];
        int    sbs = r.synth_batch_size;
        if (sbs < 1) {
            sbs = 1;
        }
        if (sbs > 9) {
            sbs = 9;
        }
        if (off + sbs > total_alloc) {
            sbs = total_alloc - off;
        }
        request_resolve_seed(&r);
        const long long base_seed = r.seed;

        for (int i = 0; i < sbs; i++) {
            AceRequest v = r;
            v.seed       = base_seed + i;
            groups[0].push_back(v);
        }
        off += sbs;
    }

    if (total_alloc > 1) {
        fprintf(stderr, "[Server] Batch: %d track(s) from %d request(s)\n", total_alloc, batch_n);
    }

    // Two-phase run (+ optional Phase 3 LRC).
    std::vector<std::string> lrc_results(total_alloc);
    std::vector<std::vector<float>> captured_latents;
    const int rc = synth_batch_run(ctx, groups,
                                   src_interleaved, src_len,
                                   src_latents, src_T_latent,
                                   ref_interleaved, ref_len,
                                   ref_latents, ref_T_latent,
                                   audio.data(),
                                   lrc_results.data(),
                                   &captured_latents,
                                   server_cancel_job, (void *) &job->cancel);
    ace_synth_free(ctx);
    free(src_interleaved);
    free(src_latents);
    free(ref_interleaved);
    free(ref_latents);

    // Store first track's post-DiT latent for retrieval via /job?latent=1
    if (!captured_latents.empty() && !captured_latents[0].empty()) {
        job->result_latent = std::move(captured_latents[0]);
        fprintf(stderr, "[Server] Latent captured: T=%zu (%.1fs @ 25Hz)\n",
                job->result_latent.size() / 64, (float)(job->result_latent.size() / 64) / 25.0f);
    }

    // Store LRC for the first track (used by the Node server)
    if (!lrc_results.empty() && !lrc_results[0].empty()) {
        // Base64 encode the LRC text for safe transport in HTTP header
        const std::string & lrc = lrc_results[0];
        static const char b64[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
        std::string encoded_lrc;
        encoded_lrc.reserve((lrc.size() + 2) / 3 * 4);
        for (size_t i = 0; i < lrc.size(); i += 3) {
            uint32_t v = ((uint8_t)lrc[i]) << 16;
            if (i + 1 < lrc.size()) v |= ((uint8_t)lrc[i + 1]) << 8;
            if (i + 2 < lrc.size()) v |= ((uint8_t)lrc[i + 2]);
            encoded_lrc += b64[(v >> 18) & 0x3F];
            encoded_lrc += b64[(v >> 12) & 0x3F];
            encoded_lrc += (i + 1 < lrc.size()) ? b64[(v >> 6) & 0x3F] : '=';
            encoded_lrc += (i + 2 < lrc.size()) ? b64[v & 0x3F] : '=';
        }
        job->result_lrc = encoded_lrc;
        fprintf(stderr, "[Server] LRC: %zu bytes raw, %zu base64\n", lrc.size(), encoded_lrc.size());
    }

    if (rc != 0) {
        for (auto & a : audio) {
            ace_audio_free(&a);
        }
        job->status.store(job->cancel.load() ? 3 : 2);
        return;
    }

    // Sticky name hints for resolve_name under --keep-loaded. Master clears
    // them in the default mode since the ctx is gone; we match that behavior.
    if (g_keep_loaded) {
        g_loaded_dit           = dit_name;
        g_loaded_adapter       = ace_reqs[0].adapter;
        g_loaded_adapter_scale = ace_reqs[0].adapter_scale;
        g_loaded_vae           = sf.vae_model;
    } else {
        g_loaded_dit.clear();
        g_loaded_adapter.clear();
        g_loaded_adapter_scale = 1.0f;
        g_loaded_vae.clear();
    }

    const int total_tracks = total_alloc;

    // encode each track (peak normalize + encode)
    const char * mime = output_wav ? "audio/wav" : "audio/mpeg";

    std::vector<std::string> encoded(total_tracks);
    for (int b = 0; b < total_tracks; b++) {
        if (!audio[b].samples) {
            continue;
        }
        // Normalize first: the noise profile was computed from normalized audio
        // (peak ≈ 1.0), so the denoiser must run at normalized levels to match.
        if (!output_wav || wav_fmt != WAV_F32) {
            audio_normalize(audio[b].samples, audio[b].n_samples * 2, peak_clip);
        }
        // HOT-Step: Post-VAE spectral denoiser. Runs on the normalized planar
        // stereo buffer to remove VAE fuzz/fizz using the noise profile.
        if (sf.denoise_strength > 0.0f) {
            audio_denoise(audio[b].samples, audio[b].n_samples, 48000,
                          sf.denoise_strength, sf.denoise_smoothing, sf.denoise_mix,
                          g_noise_profile.valid ? &g_noise_profile : nullptr);
        }
        if (output_wav) {
            encoded[b] = audio_encode_wav(audio[b].samples, audio[b].n_samples, 48000, wav_fmt);
        } else {
            encoded[b] = audio_encode_mp3(audio[b].samples, audio[b].n_samples, 48000, g_mp3_kbps, server_cancel_job,
                                          (void *) &job->cancel);
        }
        ace_audio_free(&audio[b]);
    }

    // store result in job
    // single track: raw audio body
    if (total_tracks == 1) {
        job->result_body = std::move(encoded[0]);
        job->result_mime = mime;
    } else {
        // multiple tracks: multipart/mixed, each part is raw audio
        std::string boundary = "ace-batch-boundary";
        std::string body;
        for (int b = 0; b < total_tracks; b++) {
            body += "--" + boundary + "\r\n";
            body += "Content-Type: ";
            body += mime;
            body += "\r\n\r\n";
            body += encoded[b];
            body += "\r\n";
        }
        body += "--" + boundary + "--\r\n";
        job->result_body = std::move(body);
        job->result_mime = "multipart/mixed; boundary=" + boundary;
    }

    job->status.store(job->cancel.load() ? 3 : 1);
    fprintf(stderr, "[Server] Job %s done (%d tracks)\n", job->id.c_str(), total_tracks);
}

// POST /synth[?format=wav16|wav24|wav32]
// returns JSON {"id":"N"} immediately.
// input:
//   application/json body        -> single request {} or batch [{req0}, {req1}, ...]
//   multipart/form-data          -> single request + audio file(s)
//     part "request":   JSON text
//     part "audio":     source audio (WAV or MP3)
//     part "ref_audio": timbre reference audio (WAV or MP3), optional
// output: audio/mpeg (default) or audio/wav (?format=wav16|wav24|wav32)
//   batch == 1: raw audio body
//   batch >  1: multipart/mixed, each part is raw audio
// Batch size = number of JSON objects (after synth_batch_size expansion, clamped to 9).
// Metadata (seed, duration, etc) is already in the request JSON from /lm.
static void handle_synth(const httplib::Request & req, httplib::Response & res) {
    if (g_registry.dit.empty() || g_registry.text_enc.empty() || g_registry.vae.empty()) {
        json_error(res, 501, "No synth models in registry (need dit + text-encoder + vae)");
        return;
    }

    // parse HOT-Step server fields (vae_model) from JSON body
    ServerFields sf;

    // parse request: plain JSON (single or array) or multipart (JSON + audio file).
    // synth_model, lm_model, adapter, adapter_scale travel inside AceRequest now.
    std::vector<AceRequest> ace_reqs;
    float *                 src_interleaved = nullptr;
    int                     src_len         = 0;
    float *                 src_latents     = nullptr;
    int                     src_T_latent    = 0;
    float *                 ref_interleaved = nullptr;
    int                     ref_len         = 0;
    float *                 ref_latents     = nullptr;
    int                     ref_T_latent    = 0;

    if (req.is_multipart_form_data()) {
        // multipart mode: single request + optional audio files
        AceRequest ace_req;

        std::string json_body;
        if (req.form.has_file("request")) {
            json_body = req.form.get_file("request").content;
        } else if (req.form.has_field("request")) {
            json_body = req.form.get_field("request");
        } else {
            json_error(res, 400, "Multipart: missing 'request' part");
            return;
        }
        parse_server_fields(json_body.c_str(), &sf);
        if (!request_parse_json(&ace_req, json_body.c_str())) {
            json_error(res, 400, "Multipart: invalid JSON in 'request' part");
            return;
        }

        if (req.form.has_file("audio")) {
            auto file = req.form.get_file("audio");
            if (file.content.empty()) {
                json_error(res, 400, "Multipart: empty 'audio' part");
                return;
            }
            int     T_audio = 0;
            float * planar  = audio_read_48k_buf((const uint8_t *) file.content.data(), file.content.size(), &T_audio);
            if (!planar || T_audio <= 0) {
                json_error(res, 400, "Failed to decode audio");
                return;
            }
            fprintf(stderr, "[Server] Source audio: %.2fs @ 48kHz\n", (float) T_audio / 48000.0f);
            src_interleaved = audio_planar_to_interleaved(planar, T_audio);
            free(planar);
            src_len = T_audio;
        }

        if (req.form.has_file("ref_audio")) {
            auto file = req.form.get_file("ref_audio");
            if (!file.content.empty()) {
                int     T_audio = 0;
                float * planar =
                    audio_read_48k_buf((const uint8_t *) file.content.data(), file.content.size(), &T_audio);
                if (planar && T_audio > 0) {
                    fprintf(stderr, "[Server] Reference audio: %.2fs @ 48kHz\n", (float) T_audio / 48000.0f);
                    ref_interleaved = audio_planar_to_interleaved(planar, T_audio);
                    free(planar);
                    ref_len = T_audio;
                } else {
                    fprintf(stderr, "[Server] WARNING: failed to decode ref_audio, ignoring\n");
                }
            }
        }

        // Source latents (raw float32, alternative to source audio — skips VAE encode)
        if (req.form.has_file("src_latents")) {
            auto file = req.form.get_file("src_latents");
            if (!file.content.empty()) {
                if (file.content.size() % (64 * sizeof(float)) != 0) {
                    json_error(res, 400, "src_latents size must be a multiple of 256 bytes (64 * float32)");
                    return;
                }
                src_T_latent = (int)(file.content.size() / (64 * sizeof(float)));
                src_latents = (float *) malloc(file.content.size());
                memcpy(src_latents, file.content.data(), file.content.size());
                fprintf(stderr, "[Server] Source latents: T=%d (%.2fs @ 25Hz)\n",
                        src_T_latent, (float)src_T_latent / 25.0f);
            }
        }

        // Reference latents (raw float32, alternative to ref audio — skips timbre VAE encode)
        if (req.form.has_file("ref_latents")) {
            auto file = req.form.get_file("ref_latents");
            if (!file.content.empty()) {
                if (file.content.size() % (64 * sizeof(float)) != 0) {
                    json_error(res, 400, "ref_latents size must be a multiple of 256 bytes (64 * float32)");
                    return;
                }
                ref_T_latent = (int)(file.content.size() / (64 * sizeof(float)));
                ref_latents = (float *) malloc(file.content.size());
                memcpy(ref_latents, file.content.data(), file.content.size());
                fprintf(stderr, "[Server] Reference latents: T=%d (%.2fs @ 25Hz)\n",
                        ref_T_latent, (float)ref_T_latent / 25.0f);
            }
        }

        ace_reqs.push_back(ace_req);
    } else {
        // plain JSON body: single object {} or array [{}, ...]
        fprintf(stderr, "[DIAG] /synth body (first 300 chars): %.300s\n", req.body.c_str());
        parse_server_fields(req.body.c_str(), &sf);
        if (!request_parse_json_array(req.body.c_str(), &ace_reqs)) {
            json_error(res, 400, "Invalid JSON");
            return;
        }
    }

    if (ace_reqs.empty()) {
        json_error(res, 400, "Empty request");
        return;
    }
    if (ace_reqs[0].caption.empty() && ace_reqs[0].task_type != TASK_LEGO && ace_reqs[0].task_type != TASK_EXTRACT &&
        ace_reqs[0].task_type != TASK_COMPLETE && ace_reqs[0].task_type != TASK_COVER && ace_reqs[0].task_type != TASK_REPAINT) {
        json_error(res, 400, "Caption is required");
        return;
    }

    // HOT-STEP: Output format from URL ?format= param (backward compat with our Node.js)
    // Falls back to AceRequest.output_format if URL param not present.
    bool      output_wav = false;
    WavFormat wav_fmt    = WAV_S16;
    {
        std::string fmt_str;
        if (req.has_param("format")) {
            fmt_str = req.get_param_value("format");
        } else {
            fmt_str = ace_reqs[0].output_format;
        }
        bool is_mp3 = true;
        if (!audio_parse_format(fmt_str.c_str(), is_mp3, wav_fmt)) {
            json_error(res, 400, "Invalid format (use: mp3, wav16, wav24, wav32)");
            return;
        }
        output_wav = !is_mp3;
    }
    int peak_clip = ace_reqs[0].peak_clip;

    // create job, spawn worker, return ID
    auto job = job_create();
    fprintf(stderr, "[Server] Job %s created (%d requests)\n", job->id.c_str(), (int) ace_reqs.size());

    // per-request co-resident mode: ?keep_loaded=1
    const bool req_keep_loaded = req.has_param("keep_loaded") && req.get_param_value("keep_loaded") == "1";

    work_push([job, reqs = std::move(ace_reqs), sf, src_interleaved, src_len, src_latents, src_T_latent,
               ref_interleaved, ref_len, ref_latents, ref_T_latent, output_wav, wav_fmt, peak_clip, req_keep_loaded]() mutable {
        synth_worker(job, std::move(reqs), sf, src_interleaved, src_len, src_latents, src_T_latent,
                     ref_interleaved, ref_len, ref_latents, ref_T_latent, output_wav, wav_fmt, peak_clip, req_keep_loaded);
    });

    // return job ID immediately
    std::string body = "{\"id\":\"" + job->id + "\"}";
    res.set_content(body, "application/json");
}

// understand worker: load LM + tokenizer, run understand, store JSON result in job.
static void understand_worker(std::shared_ptr<Job> job, AceRequest ace_req, float * src_interleaved, int src_len) {
    if (job->cancel.load()) {
        free(src_interleaved);
        job->status.store(3);
        return;
    }

    // Resolve LM + DiT (the DiT path carries the tokenizer weights).
    std::string        lm_name  = resolve_name(g_registry.lm, ace_req.lm_model, g_loaded_lm);
    std::string        dit_name = resolve_name(g_registry.dit, ace_req.synth_model, g_loaded_dit);
    const ModelEntry * lm_entry = registry_find(g_registry.lm, lm_name.c_str());
    const ModelEntry * dit      = registry_find(g_registry.dit, dit_name.c_str());
    if (!lm_entry || !dit) {
        fprintf(stderr, "[Server] LM or DiT not found: lm=%s dit=%s\n", lm_name.c_str(), dit_name.c_str());
        free(src_interleaved);
        job->status.store(2);
        return;
    }

    AceUnderstandParams p = g_und_params;
    p.model_path          = lm_entry->path.c_str();
    p.dit_path            = dit->path.c_str();

    AceUnderstand * ctx = ace_understand_load(g_store, &p);
    if (!ctx) {
        fprintf(stderr, "[Server] FATAL: understand load failed\n");
        free(src_interleaved);
        job->status.store(2);
        return;
    }

    AceRequest out;
    int        rc = ace_understand_generate(ctx, src_interleaved, src_len,
                                            nullptr, 0,  // src_latents (audio path)
                                            &ace_req, &out,
                                            nullptr, nullptr,  // latent_out, T_latent_out
                                            server_cancel_job, (void *) &job->cancel);
    ace_understand_free(ctx);
    free(src_interleaved);

    if (rc != 0) {
        job->status.store(job->cancel.load() ? 3 : 2);
        return;
    }

    // Sticky name hints for resolve_name under --keep-loaded. Master clears
    // them in the default mode since the ctx is gone; we match that behavior.
    if (g_keep_loaded) {
        g_loaded_lm      = lm_name;
        g_loaded_und_dit = dit_name;
    } else {
        g_loaded_lm.clear();
        g_loaded_und_dit.clear();
    }

    job->result_body = "[" + request_to_json(&out) + "]";
    job->result_mime = "application/json";
    job->status.store(1);
    fprintf(stderr, "[Server] Job %s done (understand)\n", job->id.c_str());
}

// POST /understand
// multipart/form-data: full pipeline (audio + optional JSON params)
//   part "audio":   WAV or MP3 file (required)
//   part "request": JSON text (optional, for model selection and sampling params)
// returns: JSON {"id":"N"} immediately.
static void handle_understand(const httplib::Request & req, httplib::Response & res) {
    if (g_registry.lm.empty() || g_registry.dit.empty() || g_registry.vae.empty()) {
        json_error(res, 501, "Understand requires LM, DiT and VAE models");
        return;
    }

    if (!req.is_multipart_form_data()) {
        json_error(res, 400, "Understand requires multipart/form-data");
        return;
    }

    // parse multipart: required "audio" part, optional "request" part for sampling params.
    // synth_model, lm_model, adapter, adapter_scale travel inside AceRequest.
    AceRequest ace_req;
    request_init(&ace_req);
    ace_req.lm_temperature = 0.3f;  // understand default: lower than generation
    ace_req.lm_top_p       = 1.0f;  // understand default: no nucleus sampling

    if (req.form.has_file("request")) {
        const std::string & json = req.form.get_file("request").content;
        if (!request_parse_json(&ace_req, json.c_str())) {
            json_error(res, 400, "Multipart: invalid JSON in 'request' part");
            return;
        }
    } else if (req.form.has_field("request")) {
        const std::string & json = req.form.get_field("request");
        if (!request_parse_json(&ace_req, json.c_str())) {
            json_error(res, 400, "Multipart: invalid JSON in 'request' part");
            return;
        }
    }

    if (!req.form.has_file("audio")) {
        json_error(res, 400, "Multipart: missing 'audio' part");
        return;
    }
    auto file = req.form.get_file("audio");
    if (file.content.empty()) {
        json_error(res, 400, "Multipart: empty 'audio' part");
        return;
    }

    // decode directly from multipart buffer (WAV/MP3 auto-detected)
    int     T_audio = 0;
    float * planar  = audio_read_48k_buf((const uint8_t *) file.content.data(), file.content.size(), &T_audio);
    if (!planar || T_audio <= 0) {
        json_error(res, 400, "Failed to decode audio");
        return;
    }

    fprintf(stderr, "[Server] Understand source: %.2fs @ 48kHz\n", (float) T_audio / 48000.0f);

    // convert planar [L:T][R:T] to interleaved [L0,R0,L1,R1,...] for pipeline
    float * src_interleaved = audio_planar_to_interleaved(planar, T_audio);
    free(planar);
    int src_len = T_audio;

    auto job = job_create();
    fprintf(stderr, "[Server] Job %s created (understand)\n", job->id.c_str());

    work_push(
        [job, ace_req, src_interleaved, src_len]() { understand_worker(job, ace_req, src_interleaved, src_len); });

    std::string body = "{\"id\":\"" + job->id + "\"}";
    res.set_content(body, "application/json");
}

// ────────────────────────────────────────────────────────────────────────
// /vae endpoint: standalone VAE encode/decode (ported from upstream)
// ────────────────────────────────────────────────────────────────────────

// decode worker: VAE decode only. Loads the requested VAE decoder,
// decodes latents to 48kHz stereo audio, encodes to requested format,
// stores in job. Client already holds the latents it sent.
static void vae_decode_worker(std::shared_ptr<Job> job,
                              AceRequest           ace_req,
                              std::vector<float>   src_latents,
                              int                  src_T_latent,
                              bool                 output_wav,
                              WavFormat            wav_fmt,
                              int                  peak_clip) {
    if (job->cancel.load()) {
        job->status.store(3);
        return;
    }

    std::string        vae_name  = resolve_name(g_registry.vae, ace_req.vae, g_loaded_vae);
    const ModelEntry * vae_entry = registry_find(g_registry.vae, vae_name.c_str());
    if (!vae_entry) {
        fprintf(stderr, "[Server] decode: VAE not found: %s\n", vae_name.c_str());
        job->status.store(2);
        return;
    }

    ModelKey vae_key;
    vae_key.kind          = MODEL_VAE_DEC;
    vae_key.path          = vae_entry->path;
    vae_key.adapter_scale = 1.0f;

    auto     t_start = std::chrono::steady_clock::now();
    VAEGGML * vae = store_require_vae_dec(g_store, vae_key);
    if (!vae) {
        fprintf(stderr, "[Server] decode: store_require_vae_dec failed\n");
        job->status.store(2);
        return;
    }
    ModelHandle vae_guard(g_store, vae);

    int                T_audio_max = (src_T_latent + 64) * 1920;
    std::vector<float> audio_buf((size_t) T_audio_max * 2);
    int T_audio = vae_ggml_decode_tiled(vae, src_latents.data(), src_T_latent, audio_buf.data(), T_audio_max,
                                        g_synth_params.vae_chunk, g_synth_params.vae_overlap);
    if (T_audio < 0) {
        fprintf(stderr, "[Server] decode: vae_ggml_decode_tiled failed\n");
        job->status.store(2);
        return;
    }
    auto t_end = std::chrono::steady_clock::now();
    float ms = (float) std::chrono::duration_cast<std::chrono::microseconds>(t_end - t_start).count() / 1000.0f;
    fprintf(stderr, "[Server] decode: %d latent frames -> %d audio samples (%.2fs), %.0fms\n", src_T_latent, T_audio,
            (float) T_audio / 48000.0f, ms);

    if (g_keep_loaded) {
        g_loaded_vae = vae_name;
    } else {
        g_loaded_vae.clear();
    }

    if (!output_wav || wav_fmt != WAV_F32) {
        audio_normalize(audio_buf.data(), T_audio * 2, peak_clip);
    }
    std::string  encoded;
    const char * mime = output_wav ? "audio/wav" : "audio/mpeg";
    if (output_wav) {
        encoded = audio_encode_wav(audio_buf.data(), T_audio, 48000, wav_fmt);
    } else {
        encoded = audio_encode_mp3(audio_buf.data(), T_audio, 48000, ace_req.mp3_bitrate, server_cancel_job,
                                   (void *) &job->cancel);
    }

    job->result_body = std::move(encoded);
    job->result_mime = mime;
    job->status.store(job->cancel.load() ? 3 : 1);
    fprintf(stderr, "[Server] Job %s done (decode)\n", job->id.c_str());
}

// encode worker: VAE encode only. Encodes 48kHz interleaved stereo
// audio into latents [T_25Hz, 64] time-major, stores raw f32 in job.
// Prefers ONNX/TRT encoder when available (faster via TensorRT fusion),
// falls back to GGML encoder for GGUF/safetensors VAE models.
static void vae_encode_worker(std::shared_ptr<Job> job, AceRequest ace_req, float * src_interleaved, int src_len) {
    struct buf_guard {
        float * p;
        ~buf_guard() { if (p) free(p); }
    } buf{ src_interleaved };

    if (job->cancel.load()) {
        job->status.store(3);
        return;
    }

    int T_latent_max = src_len / 1920 + 64;
    if (T_latent_max > MAX_T_LATENT) {
        T_latent_max = MAX_T_LATENT;
    }
    std::vector<float> latent((size_t) T_latent_max * LATENT_CHANNELS);
    int T_latent = -1;
    std::string vae_name_used;

    auto t_start = std::chrono::steady_clock::now();

    // ── Try ONNX encoder first ─────────────────────────────────────
    // Look for a *_encoder.onnx file matching the selected (or default) VAE.
    // E.g., if user selected "scragvae_decoder.onnx", look for "scragvae_encoder.onnx".
    // Also auto-detect from the onnx/ directory if no specific VAE is selected.
    bool tried_ort = false;
    {
        std::string enc_onnx_path;
        // If a specific VAE was requested and it's ONNX, derive encoder path
        if (!ace_req.vae.empty()) {
            const ModelEntry * entry = registry_find(g_registry.vae, ace_req.vae.c_str());
            if (entry && entry->name.size() >= 5 &&
                entry->name.substr(entry->name.size() - 5) == ".onnx") {
                // Replace "_decoder.onnx" with "_encoder.onnx"
                std::string p = entry->path;
                auto pos = p.rfind("_decoder.onnx");
                if (pos != std::string::npos) {
                    enc_onnx_path = p.substr(0, pos) + "_encoder.onnx";
                }
            }
        }
        // If no specific ONNX VAE selected, check the registry for any ONNX decoder
        // and derive the encoder path from it
        if (enc_onnx_path.empty()) {
            for (const auto & e : g_registry.vae) {
                if (e.name.size() >= 5 && e.name.substr(e.name.size() - 5) == ".onnx") {
                    std::string p = e.path;
                    auto pos = p.rfind("_decoder.onnx");
                    if (pos != std::string::npos) {
                        std::string candidate = p.substr(0, pos) + "_encoder.onnx";
                        FILE * f = fopen(candidate.c_str(), "rb");
                        if (f) {
                            fclose(f);
                            enc_onnx_path = candidate;
                            break;
                        }
                    }
                }
            }
        }

        // If we found an encoder ONNX, try ORT
        if (!enc_onnx_path.empty()) {
            FILE * f = fopen(enc_onnx_path.c_str(), "rb");
            if (f) {
                fclose(f);
                tried_ort = true;
                ModelKey ort_key;
                ort_key.kind = MODEL_VAE_ENC_ORT;
                ort_key.path = enc_onnx_path;

                VaeEncOrt * enc_ort = store_require_vae_enc_ort(g_store, ort_key);
                if (enc_ort) {
                    ModelHandle guard(g_store, enc_ort);
                    T_latent = vae_enc_ort_encode_tiled(enc_ort, src_interleaved, src_len,
                                                         latent.data(), T_latent_max,
                                                         g_synth_params.vae_chunk, g_synth_params.vae_overlap);
                    if (T_latent >= 0) {
                        // Extract basename for logging
                        auto slash = enc_onnx_path.find_last_of("/\\");
                        vae_name_used = (slash != std::string::npos) ? enc_onnx_path.substr(slash + 1) : enc_onnx_path;
                    } else {
                        fprintf(stderr, "[Server] encode: ORT encode failed, falling back to GGML\n");
                    }
                } else {
                    fprintf(stderr, "[Server] encode: ORT session load failed, falling back to GGML\n");
                }
            }
        }
    }

    // ── GGML fallback ──────────────────────────────────────────────
    if (T_latent < 0) {
        const ModelEntry * vae_entry = registry_find_non_onnx(g_registry.vae, ace_req.vae.c_str());
        if (!vae_entry) {
            vae_entry = registry_find_non_onnx(g_registry.vae);
        }
        if (!vae_entry) {
            fprintf(stderr, "[Server] encode: no GGUF/safetensors VAE available for encoding\n");
            job->status.store(2);
            return;
        }

        ModelKey vae_key;
        vae_key.kind          = MODEL_VAE_ENC;
        vae_key.path          = vae_entry->path;
        vae_key.adapter_scale = 1.0f;

        VAEEncoder * vae = store_require_vae_enc(g_store, vae_key);
        if (!vae) {
            fprintf(stderr, "[Server] encode: store_require_vae_enc failed\n");
            job->status.store(2);
            return;
        }
        ModelHandle vae_guard(g_store, vae);

        T_latent = vae_enc_encode_tiled(vae, src_interleaved, src_len, latent.data(), T_latent_max,
                                         g_synth_params.vae_chunk, g_synth_params.vae_overlap);
        if (T_latent < 0) {
            fprintf(stderr, "[Server] encode: vae_enc_encode_tiled failed\n");
            job->status.store(2);
            return;
        }
        vae_name_used = vae_entry->name;
    }

    auto t_end = std::chrono::steady_clock::now();
    float ms = (float) std::chrono::duration_cast<std::chrono::microseconds>(t_end - t_start).count() / 1000.0f;
    fprintf(stderr, "[Server] encode: %d audio samples (%.2fs) -> %d latent frames, %.0fms (%s)\n", src_len,
            (float) src_len / 48000.0f, T_latent, ms, vae_name_used.c_str());

    if (g_keep_loaded) {
        g_loaded_vae = vae_name_used;
    } else {
        g_loaded_vae.clear();
    }

    std::string body;
    body.resize((size_t) T_latent * LATENT_FRAME_BYTES);
    std::memcpy(body.data(), latent.data(), body.size());
    job->result_body = std::move(body);
    job->result_mime = "application/octet-stream";
    job->status.store(job->cancel.load() ? 3 : 1);
    fprintf(stderr, "[Server] Job %s done (encode)\n", job->id.c_str());
}

// POST /vae
// multipart/form-data: single VAE entrypoint, direction depends on input.
//   part "audio":       WAV or MP3 source audio -> encode path, latents out
//   part "src_latents": raw f32 latent bytes    -> decode path, audio out
//   part "request":     JSON text (optional, for VAE selection, output format)
// Returns JSON {"id":"N"} immediately. Result is raw latent bytes (encode)
// or audio (decode). Only one direction at a time.
static void handle_vae(const httplib::Request & req, httplib::Response & res) {
    if (g_registry.vae.empty()) {
        json_error(res, 501, "VAE endpoint requires a VAE in the registry");
        return;
    }
    if (!req.is_multipart_form_data()) {
        json_error(res, 400, "VAE endpoint requires multipart/form-data");
        return;
    }

    AceRequest ace_req;
    request_init(&ace_req);

    if (req.form.has_file("request")) {
        const std::string & json = req.form.get_file("request").content;
        if (!request_parse_json(&ace_req, json.c_str())) {
            json_error(res, 400, "Multipart: invalid JSON in 'request' part");
            return;
        }
    } else if (req.form.has_field("request")) {
        const std::string & json = req.form.get_field("request");
        if (!request_parse_json(&ace_req, json.c_str())) {
            json_error(res, 400, "Multipart: invalid JSON in 'request' part");
            return;
        }
    }

    bool has_audio   = req.form.has_file("audio");
    bool has_latents = req.form.has_file("src_latents");
    if (has_audio == has_latents) {
        json_error(res, 400, "Multipart: provide exactly one of 'audio' (encode) or 'src_latents' (decode)");
        return;
    }

    if (has_audio) {
        // encode path: audio in -> raw latents out
        const auto & file = req.form.get_file("audio");
        if (file.content.empty()) {
            json_error(res, 400, "Multipart: empty 'audio' part");
            return;
        }
        int     T_audio = 0;
        float * planar  = audio_read_48k_buf((const uint8_t *) file.content.data(), file.content.size(), &T_audio);
        if (!planar || T_audio <= 0) {
            if (planar) free(planar);
            json_error(res, 400, "Failed to decode audio");
            return;
        }
        if ((int64_t) T_audio / 1920 >= (int64_t) MAX_T_LATENT) {
            free(planar);
            json_error(res, 413, "audio exceeds max duration (10 min)");
            return;
        }
        float * src_interleaved = audio_planar_to_interleaved(planar, T_audio);
        free(planar);
        int src_len = T_audio;

        auto job = job_create();
        fprintf(stderr, "[Server] Job %s created (vae encode, %.2fs audio)\n", job->id.c_str(),
                (float) src_len / 48000.0f);

        work_push([job, ace_req, src_interleaved, src_len]() mutable {
            vae_encode_worker(job, ace_req, src_interleaved, src_len);
        });

        std::string body = "{\"id\":\"" + job->id + "\"}";
        res.set_content(body, "application/json");
        return;
    }

    // decode path: raw latents in -> audio out
    const auto & file = req.form.get_file("src_latents");
    if (file.content.empty() || (file.content.size() % LATENT_FRAME_BYTES) != 0) {
        json_error(res, 400, "src_latents size not a multiple of 64*4 bytes");
        return;
    }
    int T = (int) (file.content.size() / (size_t) LATENT_FRAME_BYTES);
    if (T > MAX_T_LATENT) {
        json_error(res, 413, "src_latents exceeds max frames");
        return;
    }
    std::vector<float> src_latents(reinterpret_cast<const float *>(file.content.data()),
                                   reinterpret_cast<const float *>(file.content.data()) + (size_t) T * LATENT_CHANNELS);

    bool      output_wav = false;
    WavFormat wav_fmt    = WAV_S16;
    {
        bool is_mp3 = true;
        if (!audio_parse_format(ace_req.output_format.c_str(), is_mp3, wav_fmt)) {
            json_error(res, 400, "Invalid output_format (use: mp3, wav16, wav24, wav32)");
            return;
        }
        output_wav = !is_mp3;
    }
    int peak_clip = ace_req.peak_clip;

    auto job = job_create();
    fprintf(stderr, "[Server] Job %s created (vae decode, %d latent frames)\n", job->id.c_str(), T);

    work_push([job, ace_req, latents = std::move(src_latents), T, output_wav, wav_fmt, peak_clip]() mutable {
        vae_decode_worker(job, ace_req, std::move(latents), T, output_wav, wav_fmt, peak_clip);
    });

    std::string body = "{\"id\":\"" + job->id + "\"}";
    res.set_content(body, "application/json");
}

// GET /props
// server configuration, available models, and default request.
// the webui reads this at boot to populate dropdowns and status indicators.
static void handle_props(const httplib::Request &, httplib::Response & res) {
    yyjson_mut_doc * doc  = yyjson_mut_doc_new(NULL);
    yyjson_mut_val * root = yyjson_mut_obj(doc);
    yyjson_mut_doc_set_root(doc, root);

    yyjson_mut_obj_add_str(doc, root, "version", ACE_VERSION);

    // helper: build a JSON array of model entry names
    auto add_names = [&](yyjson_mut_val * parent, const char * key, const std::vector<ModelEntry> & bucket) {
        yyjson_mut_val * arr = yyjson_mut_arr(doc);
        for (const auto & e : bucket) {
            yyjson_mut_arr_add_str(doc, arr, e.name.c_str());
        }
        yyjson_mut_obj_add_val(doc, parent, key, arr);
    };

    // models: available model names per bucket
    yyjson_mut_val * models = yyjson_mut_obj(doc);
    yyjson_mut_obj_add_val(doc, root, "models", models);
    add_names(models, "lm", g_registry.lm);
    add_names(models, "embedding", g_registry.text_enc);
    add_names(models, "dit", g_registry.dit);
    add_names(models, "vae", g_registry.vae);

    // adapters: available adapter names
    yyjson_mut_val * adapters_arr = yyjson_mut_arr(doc);
    for (const auto & e : g_registry.adapters) {
        yyjson_mut_arr_add_str(doc, adapters_arr, e.name.c_str());
    }
    yyjson_mut_obj_add_val(doc, root, "adapters", adapters_arr);

    // cli: server settings
    yyjson_mut_val * cli = yyjson_mut_obj(doc);
    yyjson_mut_obj_add_val(doc, root, "cli", cli);
    yyjson_mut_obj_add_int(doc, cli, "max_batch", g_max_batch);
    yyjson_mut_obj_add_int(doc, cli, "mp3_bitrate", g_mp3_kbps);

    // default: full AceRequest with all defaults from request_init().
    // the webui reads this to populate LM placeholders.
    // DiT fields (inference_steps, guidance_scale, shift) are 0 = auto-detect;
    // their resolved placeholders come from presets below.
    AceRequest defaults;
    request_init(&defaults);
    std::string      defaults_str  = request_to_json(&defaults, false);
    yyjson_doc *     defaults_doc  = yyjson_read(defaults_str.c_str(), defaults_str.size(), 0);
    yyjson_mut_val * defaults_copy = yyjson_val_mut_copy(doc, yyjson_doc_get_root(defaults_doc));
    yyjson_mut_obj_add_val(doc, root, "default", defaults_copy);
    yyjson_doc_free(defaults_doc);

    // presets: auto-detect values for DiT sampling params.
    // the webui switches placeholders based on the selected DiT model.
    yyjson_mut_val * presets = yyjson_mut_obj(doc);
    yyjson_mut_obj_add_val(doc, root, "presets", presets);

    yyjson_mut_val * turbo = yyjson_mut_obj(doc);
    yyjson_mut_obj_add_int(doc, turbo, "inference_steps", 8);
    yyjson_mut_obj_add_real(doc, turbo, "guidance_scale", 1.0);
    yyjson_mut_obj_add_real(doc, turbo, "shift", 3.0);
    yyjson_mut_obj_add_val(doc, presets, "turbo", turbo);

    yyjson_mut_val * sft = yyjson_mut_obj(doc);
    yyjson_mut_obj_add_int(doc, sft, "inference_steps", 50);
    yyjson_mut_obj_add_real(doc, sft, "guidance_scale", 1.0);
    yyjson_mut_obj_add_real(doc, sft, "shift", 1.0);
    yyjson_mut_obj_add_val(doc, presets, "sft", sft);

    // serialize
    yyjson_write_flag flags = YYJSON_WRITE_PRETTY | YYJSON_WRITE_PRETTY_TWO_SPACES | YYJSON_WRITE_FP_TO_FIXED(2);
    char *            json  = yyjson_mut_write(doc, flags, NULL);
    yyjson_mut_doc_free(doc);
    res.set_content(json, "application/json");
    free(json);
}

static void usage(const char * prog) {
    AceLmParams    lm_d;
    AceSynthParams synth_d;
    ace_lm_default_params(&lm_d);
    ace_synth_default_params(&synth_d);

    fprintf(stderr, "acestep.cpp %s\n\n", ACE_VERSION);
    fprintf(stderr,
            "Usage: %s --models <dir> [options]\n"
            "\n"
            "Required:\n"
            "  --models <dir>          Directory of GGUF model files\n"
            "\n"
            "Adapter:\n"
            "  --adapters <dir>        Directory of adapters\n"
            "\n"
            "Memory control:\n"
            "  --keep-loaded           Keep models in VRAM between requests\n"
            "  --vae-chunk <N>         Latent frames per tile (default: %d)\n"
            "  --vae-overlap <N>       Overlap frames per side (default: %d)\n"
            "\n"
            "ONNX/TensorRT:\n"
            "  --onnx-dir <dir>        Directory with ONNX models (e.g. vae_decoder.onnx)\n"
            "\n"
            "Speculative decoding:\n"
            "  --draft-lm <path>        Path to 0.6B draft LM (auto-discovers if omitted)\n"
            "  --no-draft               Disable draft model auto-discovery\n"
            "\n"
            "Output:\n"
            "  --mp3-bitrate <kbps>    MP3 bitrate (default: %d)\n"
            "\n"
            "Server:\n"
            "  --host <addr>           Listen address (default: 127.0.0.1)\n"
            "  --port <N>              Listen port (default: 8080)\n"
            "  --max-batch <N>         LM batch limit (default: %d)\n"
            "  --max-seq <N>           KV cache size (default: %d)\n"
            "\n"
            "Debug:\n"
            "  --no-fsm                Disable FSM constrained decoding\n"
            "  --no-fa                 Disable flash attention\n"
            "  --no-batch-cfg          Split CFG into two separate forwards (LM + DiT)\n"
            "  --clamp-fp16            Clamp hidden states to FP16 range\n",
            prog, synth_d.vae_chunk, synth_d.vae_overlap, g_mp3_kbps, g_max_batch, lm_d.max_seq);
}

int main(int argc, char ** argv) {
    ace_lm_default_params(&g_lm_params);
    ace_synth_default_params(&g_synth_params);

    const char * host         = "127.0.0.1";
    int          port         = 8080;
    const char * models_dir         = nullptr;
    const char * adapters_dir       = nullptr;
    const char * noise_profile_path = nullptr;

    if (argc < 2) {
        usage(argv[0]);
        return 1;
    }

    for (int i = 1; i < argc; i++) {
        if (!strcmp(argv[i], "--models") && i + 1 < argc) {
            models_dir = argv[++i];
        } else if (!strcmp(argv[i], "--adapters") && i + 1 < argc) {
            adapters_dir = argv[++i];
        } else if (!strcmp(argv[i], "--noise-profile") && i + 1 < argc) {
            noise_profile_path = argv[++i];
        } else if (!strcmp(argv[i], "--max-seq") && i + 1 < argc) {
            g_lm_params.max_seq = atoi(argv[++i]);

            // vae tiling
        } else if (!strcmp(argv[i], "--vae-chunk") && i + 1 < argc) {
            g_synth_params.vae_chunk = atoi(argv[++i]);
        } else if (!strcmp(argv[i], "--vae-overlap") && i + 1 < argc) {
            g_synth_params.vae_overlap = atoi(argv[++i]);
        } else if (!strcmp(argv[i], "--keep-loaded")) {
            g_keep_loaded = true;

            // output
        } else if (!strcmp(argv[i], "--mp3-bitrate") && i + 1 < argc) {
            g_mp3_kbps = atoi(argv[++i]);

            // server
        } else if (!strcmp(argv[i], "--host") && i + 1 < argc) {
            host = argv[++i];
        } else if (!strcmp(argv[i], "--port") && i + 1 < argc) {
            port = atoi(argv[++i]);
        } else if (!strcmp(argv[i], "--max-batch") && i + 1 < argc) {
            g_max_batch = atoi(argv[++i]);

            // debug
        } else if (!strcmp(argv[i], "--no-fsm")) {
            g_lm_params.use_fsm = false;
        } else if (!strcmp(argv[i], "--no-fa")) {
            g_lm_params.use_fa    = false;
            g_synth_params.use_fa = false;
        } else if (!strcmp(argv[i], "--no-batch-cfg")) {
            g_lm_params.use_batch_cfg    = false;
            g_synth_params.use_batch_cfg = false;
        } else if (!strcmp(argv[i], "--clamp-fp16")) {
            g_lm_params.clamp_fp16    = true;
            g_synth_params.clamp_fp16 = true;

            // speculative decoding
        } else if (!strcmp(argv[i], "--draft-lm") && i + 1 < argc) {
            g_draft_lm_path = argv[++i];
        } else if (!strcmp(argv[i], "--no-draft")) {
            g_draft_lm_path = "none";

        } else if (!strcmp(argv[i], "--onnx-dir") && i + 1 < argc) {
            g_onnx_dir = argv[++i];

        } else if (!strcmp(argv[i], "--help") || !strcmp(argv[i], "-h")) {
            usage(argv[0]);
            return 0;
        } else {
            fprintf(stderr, "Unknown option: %s\n", argv[i]);
            usage(argv[0]);
            return 1;
        }
    }

    // --models is required
    if (!models_dir) {
        fprintf(stderr, "[Server] ERROR: --models is required\n");
        usage(argv[0]);
        return 1;
    }

    // stderr capture for SSE /logs (must be after arg parsing so --help prints directly)
    LogCapture log_capture;

    // scan models directory (reads GGUF metadata only)
    fprintf(stderr, "[Server] Scanning models in %s\n", models_dir);
    if (!registry_scan(&g_registry, models_dir)) {
        fprintf(stderr, "[Server] ERROR: no models found in %s\n", models_dir);
        return 1;
    }

    // Also scan the onnx/ subdirectory for ONNX models (TRT acceleration)
    {
        std::string onnx_subdir = std::string(models_dir) + REGISTRY_SEP + "onnx";
        registry_scan(&g_registry, onnx_subdir.c_str());
    }

    // speculative decoding: only via explicit --draft-lm flag
    // Auto-discovery DISABLED — GGML per-call overhead (~10ms) makes the 0.6B
    // draft nearly as expensive as the 4B target. Re-enable when persistent
    // graphs or CUDA graph capture reduce overhead below ~2ms.
    if (g_draft_lm_path == "none") {
        fprintf(stderr, "[Server] Draft LM disabled (--no-draft)\n");
        g_draft_lm_path.clear();
    } else if (!g_draft_lm_path.empty()) {
        fprintf(stderr, "[Server] Draft LM (explicit): %s\n", g_draft_lm_path.c_str());
    }

    // scan adapters directory (optional)
    if (adapters_dir) {
        fprintf(stderr, "[Server] Scanning adapters in %s\n", adapters_dir);
        registry_scan_adapters(&g_registry, adapters_dir);
    }

    // HOT-Step: load noise profile for spectral denoiser (optional)
    if (noise_profile_path) {
        fprintf(stderr, "[Server] Loading noise profile: %s\n", noise_profile_path);
        int     np_T  = 0;
        int     np_sr = 0;
        float * np_audio = audio_io_read_wav(noise_profile_path, &np_T, &np_sr);
        if (np_audio && np_T > 0) {
            // audio_io_read_wav returns planar stereo [L: T][R: T] — average to mono
            std::vector<float> mono(np_T);
            for (int i = 0; i < np_T; i++) {
                mono[i] = (np_audio[i] + np_audio[np_T + i]) * 0.5f;
            }
            free(np_audio);

            if (audio_denoise_compute_profile(mono.data(), np_T, np_sr, &g_noise_profile) == 0) {
                fprintf(stderr, "[Server] Noise profile loaded successfully (%d frames, %d Hz)\n",
                        g_noise_profile.n_frames, g_noise_profile.sample_rate);
            } else {
                fprintf(stderr, "[Server] WARNING: failed to compute noise profile\n");
            }
        } else {
            fprintf(stderr, "[Server] WARNING: could not read noise profile WAV: %s\n", noise_profile_path);
        }
    }

    // ONNX/TensorRT: auto-detect vae_decoder.onnx in --onnx-dir
    // Try new subdirectory layout first (onnx/vae/), fall back to legacy flat layout.
    static std::string g_onnx_vae_path_buf;
    if (g_onnx_dir) {
        // Try new location: onnx_dir/vae/vae_decoder.onnx
        g_onnx_vae_path_buf = std::string(g_onnx_dir) + "/vae/vae_decoder.onnx";
        FILE * f = fopen(g_onnx_vae_path_buf.c_str(), "rb");
        if (!f) {
            // Fall back to legacy flat layout: onnx_dir/vae_decoder.onnx
            g_onnx_vae_path_buf = std::string(g_onnx_dir) + "/vae_decoder.onnx";
            f = fopen(g_onnx_vae_path_buf.c_str(), "rb");
        }
        if (f) {
            fclose(f);
            g_synth_params.onnx_vae_path = g_onnx_vae_path_buf.c_str();
            fprintf(stderr, "[Server] ONNX VAE decoder: %s\n", g_onnx_vae_path_buf.c_str());
        } else {
            fprintf(stderr, "[Server] WARNING: --onnx-dir specified but no vae_decoder.onnx found in %s\n",
                    g_onnx_dir);
            g_onnx_vae_path_buf.clear();
        }
    }

    // validate pipeline
    bool have_lm    = !g_registry.lm.empty();
    bool have_dit   = !g_registry.dit.empty();
    bool have_enc   = !g_registry.text_enc.empty();
    bool have_vae   = !g_registry.vae.empty();
    bool have_synth = have_dit && have_enc && have_vae;

    // partial synth: some components found but pipeline incomplete
    if (!have_synth && (have_dit || have_enc || have_vae)) {
        char missing[64];
        int  n = 0;
        if (!have_dit) {
            n += snprintf(missing + n, sizeof(missing) - n, "%sDiT", n ? ", " : "");
        }
        if (!have_enc) {
            n += snprintf(missing + n, sizeof(missing) - n, "%sText-Enc", n ? ", " : "");
        }
        if (!have_vae) {
            n += snprintf(missing + n, sizeof(missing) - n, "%sVAE", n ? ", " : "");
        }
        if (have_lm) {
            fprintf(stderr, "[Server] WARNING: /synth unavailable, missing: %s\n", missing);
        } else {
            fprintf(stderr, "[Server] ERROR: no usable pipeline, synth missing: %s\n", missing);
            return 1;
        }
    }

    // clamp max_batch
    if (g_max_batch < 1) {
        g_max_batch = 1;
    }
    if (g_max_batch > 9) {
        g_max_batch = 9;
    }
    g_lm_params.max_batch = g_max_batch;
    if (!g_draft_lm_path.empty()) {
        g_lm_params.draft_model_path = g_draft_lm_path.c_str();
    }

    // init understand params (vae for audio encoding, dit resolved per-request)
    ace_understand_default_params(&g_und_params);
    g_und_params.use_fa      = g_lm_params.use_fa;
    g_und_params.use_fsm     = g_lm_params.use_fsm;
    g_und_params.max_seq     = g_lm_params.max_seq;         // must match ace_lm: part of the LM ModelKey
    g_und_params.max_batch   = g_lm_params.max_batch;       // must match ace_lm: part of the LM ModelKey
    g_und_params.vae_chunk   = g_synth_params.vae_chunk;    // share --vae-chunk with /synth
    g_und_params.vae_overlap = g_synth_params.vae_overlap;  // share --vae-overlap with /synth
    if (have_vae) {
        g_und_params.vae_path = g_registry.vae[0].path.c_str();
    }

    bool have_understand = have_lm && have_dit && have_vae;

    // central store: one policy for the whole server lifetime. STRICT keeps
    // at most one GPU module resident at a time; --keep-loaded flips it to
    // NEVER and lets the working set accumulate across requests.
    g_store = store_create(g_keep_loaded ? EVICT_NEVER : EVICT_STRICT);

    // Initialize Lua plugin system.
    // engine_dir: derive from executable path.
    // Binary location varies by build system:
    //   - Visual Studio (multi-config): engine/build/Release/ace-server.exe  (3 levels up)
    //   - Ninja / Makefiles / macOS:    engine/build/ace-server              (2 levels up)
    //   - Portable release:             engine/ace-server                    (1 level up)
    // Scans both engine/plugins/ (native) and <project-root>/plugins/ (community)
    {
        std::filesystem::path exe_path = std::filesystem::canonical(argv[0]);
        std::filesystem::path exe_dir = exe_path.parent_path();
        std::string dir_name = exe_dir.filename().string();

        std::filesystem::path engine_dir;
        if (dir_name == "Release" || dir_name == "Debug" ||
            dir_name == "RelWithDebInfo" || dir_name == "MinSizeRel") {
            // Multi-config generator: engine/build/Release/ → engine/ is 3 levels
            engine_dir = exe_dir.parent_path().parent_path();
        } else if (dir_name == "build") {
            // Single-config generator: engine/build/ → engine/ is 1 level
            engine_dir = exe_dir.parent_path();
        } else {
            // Portable release: engine/ → engine/ is 0 levels (already there)
            engine_dir = exe_dir;
        }
        // Project root is one more level up from engine/
        std::filesystem::path project_dir = engine_dir.parent_path();
        PluginRegistry::instance().init(engine_dir.string(), project_dir.string());
    }

    // setup HTTP server
    httplib::Server svr;
    g_svr = &svr;

    // per-operation socket idle timeout (httplib default is 5s).
    // generous margin for slow networks and large audio transfers.
    svr.set_read_timeout(600);
    svr.set_write_timeout(600);

    // SO_REUSEADDR: allow rebind after TIME_WAIT (normal restart).
    // no SO_REUSEPORT: fail if another process is actively listening.
    svr.set_socket_options([](socket_t sock) {
        int one = 1;
#ifdef _WIN32
        setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, (const char *) &one, sizeof(one));
#else
        setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));
#endif
    });

    // reject oversized bodies (256 MB: src + ref audio, up to 10min WAV each)
    svr.set_payload_max_length(256 * 1024 * 1024);

    // all endpoints are always registered. handlers return 501 when the
    // backing pipeline has no models in the registry.
    svr.Post("/lm", handle_lm);
    svr.Post("/synth", handle_synth);
    svr.Post("/understand", handle_understand);
    svr.Post("/vae", handle_vae);
    svr.Get("/health", [](const httplib::Request &, httplib::Response & res) {
        res.set_content("{\"status\":\"ok\"}", "application/json");
    });
    svr.Get("/props", handle_props);
    svr.Get("/logs", handle_logs);
    // HOT-STEP: Lua plugin registry endpoint
    svr.Get("/plugins", [](const httplib::Request &, httplib::Response & res) {
        std::string json = PluginRegistry::instance().to_json();
        res.set_content(json, "application/json");
    });

    // HOT-STEP: GET /vram — GPU memory usage (CUDA only)
    svr.Get("/vram", [](const httplib::Request &, httplib::Response & res) {
#ifdef GGML_USE_CUDA
        size_t free_bytes = 0, total_bytes = 0;
        cudaError_t err = cudaMemGetInfo(&free_bytes, &total_bytes);
        if (err != cudaSuccess) {
            json_error(res, 500, cudaGetErrorString(err));
            return;
        }
        size_t used_bytes = total_bytes - free_bytes;
        char buf[256];
        snprintf(buf, sizeof(buf),
                 "{\"used_mb\":%.0f,\"total_mb\":%.0f,\"free_mb\":%.0f}",
                 (double) used_bytes / (1024.0 * 1024.0),
                 (double) total_bytes / (1024.0 * 1024.0),
                 (double) free_bytes / (1024.0 * 1024.0));
        res.set_content(buf, "application/json");
#else
        res.set_content("{\"used_mb\":0,\"total_mb\":0,\"free_mb\":0}", "application/json");
#endif
    });
    // job system endpoints
    svr.Get("/job", [](const httplib::Request & req, httplib::Response & res) {
        if (!req.has_param("id")) {
            json_error(res, 400, "Missing id parameter");
            return;
        }
        auto job = job_find(req.get_param_value("id"));
        if (!job) {
            json_error(res, 404, "Job not found");
            return;
        }
        // ?latent=1: return raw post-DiT latent bytes (float32, [T*64])
        if (req.has_param("latent") && req.get_param_value("latent") == "1") {
            if (job->status.load() != 1 || job->result_latent.empty()) {
                json_error(res, 404, "Latent not available");
                return;
            }
            res.set_content(
                reinterpret_cast<const char *>(job->result_latent.data()),
                job->result_latent.size() * sizeof(float),
                "application/octet-stream");
            return;
        }
        // ?result=1: return result body
        if (req.has_param("result") && req.get_param_value("result") == "1") {
            if (job->status.load() != 1) {
                json_error(res, 404, "Result not ready");
                return;
            }
            res.set_content(job->result_body, job->result_mime);
            if (!job->result_lrc.empty()) {
                res.set_header("X-LRC-Text", job->result_lrc);
            }
            return;
        }
        // default: return status JSON
        std::string body = "{\"status\":\"";
        body += job_status_str(job->status.load());
        body += "\"}";
        res.set_content(body, "application/json");
    });
    svr.Post("/job", [](const httplib::Request & req, httplib::Response & res) {
        if (!req.has_param("id")) {
            json_error(res, 400, "Missing id parameter");
            return;
        }
        auto job = job_find(req.get_param_value("id"));
        if (!job) {
            json_error(res, 404, "Job not found");
            return;
        }
        // ?cancel=1: cancel the job
        if (req.has_param("cancel") && req.get_param_value("cancel") == "1") {
            job->cancel.store(true);
            fprintf(stderr, "[Server] Cancel requested for job %s\n", job->id.c_str());
            res.set_content("{\"status\":\"cancelled\"}", "application/json");
            return;
        }
        json_error(res, 400, "Unknown action");
    });

    // POST /pp-vae-reencode — synchronous PP-VAE re-encode processing.
    // Accepts WAV audio body. Runs PP-VAE encode→decode round-trip with
    // RMS gain matching. Returns processed WAV (same sample rate, 16-bit).
    // Requires PP-VAE models in registry. Non-fatal: returns 501 if unavailable.
    svr.Post("/pp-vae-reencode", [](const httplib::Request & req, httplib::Response & res) {
        if (req.body.empty()) {
            json_error(res, 400, "Empty body (expected WAV audio)");
            return;
        }

        // Parse blend from query string (0.0 = fully PP-VAE, 1.0 = fully original)
        float blend = 0.0f;
        if (req.has_param("blend")) {
            blend = std::strtof(req.get_param_value("blend").c_str(), nullptr);
            if (blend < 0.0f) blend = 0.0f;
            if (blend > 1.0f) blend = 1.0f;
        }

        // Parse backend preference: "onnx" = force ORT/TRT, "gguf" = force GGML, absent = auto
        std::string backend = "auto";
        if (req.has_param("backend")) {
            backend = req.get_param_value("backend");
        }

        // Resolve PP-VAE model path from registry (prefer F32 > BF16 > F16)
        if (g_registry.pp_vae.empty()) {
            json_error(res, 501, "No PP-VAE model in registry");
            return;
        }
        const char * pp_vae_path = nullptr;
        const char * pref[] = { "F32", "BF16", "F16" };
        for (const char * tag : pref) {
            for (const auto & e : g_registry.pp_vae) {
                if (e.name.find(tag) != std::string::npos) {
                    pp_vae_path = e.path.c_str();
                    break;
                }
            }
            if (pp_vae_path) break;
        }
        if (!pp_vae_path) pp_vae_path = g_registry.pp_vae[0].path.c_str();

        // Decode WAV from body → planar stereo [L:T][R:T]
        int     T_audio = 0;
        float * planar  = audio_read_48k_buf((const uint8_t *) req.body.data(), req.body.size(), &T_audio);
        if (!planar || T_audio <= 0) {
            json_error(res, 400, "Failed to decode WAV audio");
            return;
        }

        fprintf(stderr, "[Server] PP-VAE re-encode: %.2fs @ 48kHz, model=%s, blend=%.2f, backend=%s\n",
                (float) T_audio / 48000.0f, pp_vae_path, blend, backend.c_str());

        // If blend is 1.0 (fully original), skip processing entirely
        if (blend >= 1.0f) {
            fprintf(stderr, "[Server] PP-VAE: blend=1.0, returning original audio\n");
            std::string wav = audio_encode_wav(planar, T_audio, 48000, WAV_S16);
            free(planar);
            res.set_content(wav, "audio/wav");
            return;
        }

        // Measure input RMS + peak
        double in_sum_sq = 0.0;
        float  in_peak = 0.0f;
        int    n_total = T_audio * 2;
        for (int i = 0; i < n_total; i++) {
            float v = planar[i];
            in_sum_sq += (double) v * v;
            float av = fabsf(v);
            if (av > in_peak) in_peak = av;
        }
        float in_rms = (float) sqrt(in_sum_sq / (double) n_total);

        // Resolve PP-VAE ONNX paths for ORT/TRT acceleration.
        // Look for pp-vae_encoder.onnx / pp-vae_decoder.onnx in models/onnx/
        // Try new subdirectory layout (onnx/pp-vae/) first, fall back to legacy flat layout.
        // Skipped entirely when backend=gguf.
        std::string pp_dir;
        {
            std::string p = pp_vae_path;
            auto slash = p.find_last_of("/\\");
            pp_dir = (slash != std::string::npos) ? p.substr(0, slash) : ".";
        }
        std::string onnx_dir = pp_dir + "/" + "onnx";
        std::string onnx_enc_path, onnx_dec_path;
        if (backend != "gguf") {
            {
                // Try new location first: onnx/pp-vae/pp-vae_encoder.onnx
                std::string ep = onnx_dir + "/" + "pp-vae" + "/" + "pp-vae_encoder.onnx";
                FILE * f = fopen(ep.c_str(), "rb");
                if (!f) {
                    // Fall back to legacy flat layout
                    ep = onnx_dir + "/" + "pp-vae_encoder.onnx";
                    f = fopen(ep.c_str(), "rb");
                }
                if (f) { fclose(f); onnx_enc_path = ep; }
            }
            {
                // Try new location first: onnx/pp-vae/pp-vae_decoder.onnx
                std::string dp = onnx_dir + "/" + "pp-vae" + "/" + "pp-vae_decoder.onnx";
                FILE * f = fopen(dp.c_str(), "rb");
                if (!f) {
                    // Fall back to legacy flat layout
                    dp = onnx_dir + "/" + "pp-vae_decoder.onnx";
                    f = fopen(dp.c_str(), "rb");
                }
                if (f) { fclose(f); onnx_dec_path = dp; }
            }
            if (backend == "onnx" && (onnx_enc_path.empty() || onnx_dec_path.empty())) {
                fprintf(stderr, "[Server] PP-VAE backend=onnx but ONNX models not found in %s, falling back to GGML\n",
                        onnx_dir.c_str());
            }
        } else {
            fprintf(stderr, "[Server] PP-VAE backend=gguf, skipping ONNX discovery\n");
        }

        // Default VAE tiling params (match scragvae: same Oobleck architecture)
        int vae_chunk   = 1024;
        int vae_overlap = 64;

        // Phase 1: Encode (planar → interleaved → VAE encoder → latents)
        // Prefers ORT/TRT when pp-vae_encoder.onnx exists, falls back to GGML.
        std::vector<float> latents;
        int T_latent = 0;

        // Convert planar → interleaved for encoder
        std::vector<float> interleaved(T_audio * 2);
        {
            const float * L = planar;
            const float * R = planar + T_audio;
            for (int i = 0; i < T_audio; i++) {
                interleaved[i * 2 + 0] = L[i];
                interleaved[i * 2 + 1] = R[i];
            }
        }

        int max_T = (T_audio / 1920) + 64;
        latents.resize((size_t) max_T * 64);

        if (!onnx_enc_path.empty()) {
            // Try ORT encoder
            ModelKey enc_ort_key;
            enc_ort_key.kind = MODEL_VAE_ENC_ORT;
            enc_ort_key.path = onnx_enc_path;
            VaeEncOrt * enc_ort = store_require_vae_enc_ort(g_store, enc_ort_key);
            if (enc_ort) {
                ModelHandle enc_guard(g_store, enc_ort);
                fprintf(stderr, "[Server] PP-VAE encoding via ORT/TRT: %s\n", onnx_enc_path.c_str());
                T_latent = vae_enc_ort_encode_tiled(enc_ort, interleaved.data(), T_audio,
                                                     latents.data(), max_T, vae_chunk, vae_overlap);
            } else {
                fprintf(stderr, "[Server] PP-VAE ORT encoder load failed, falling back to GGML\n");
            }
        }
        if (T_latent <= 0) {
            // Fall back to GGML encoder
            ModelKey enc_key;
            enc_key.kind = MODEL_VAE_ENC;
            enc_key.path = pp_vae_path;
            VAEEncoder * enc = store_require_vae_enc(g_store, enc_key);
            if (!enc) {
                free(planar);
                json_error(res, 500, "Failed to load PP-VAE encoder");
                return;
            }
            ModelHandle enc_guard(g_store, enc);
            fprintf(stderr, "[Server] PP-VAE encoding via GGML\n");
            T_latent = vae_enc_encode_tiled(enc, interleaved.data(), T_audio,
                                             latents.data(), max_T, vae_chunk, vae_overlap);
            if (T_latent <= 0) {
                free(planar);
                json_error(res, 500, "PP-VAE encode failed");
                return;
            }
        }
        fprintf(stderr, "[Server] PP-VAE encode: T_latent=%d\n", T_latent);

        // Phase 2: Decode (latents → VAE decoder → planar PCM)
        // Prefers ORT/TRT when pp-vae_decoder.onnx exists, falls back to GGML.
        std::vector<float> decoded;
        int T_decoded = 0;

        int T_audio_max = T_latent * 1920;
        decoded.resize(2 * T_audio_max);

        if (!onnx_dec_path.empty()) {
            // Try ORT decoder
            ModelKey dec_ort_key;
            dec_ort_key.kind = MODEL_VAE_DEC_ORT;
            dec_ort_key.path = onnx_dec_path;
            VaeOrt * dec_ort = store_require_vae_dec_ort(g_store, dec_ort_key);
            if (dec_ort) {
                ModelHandle dec_guard(g_store, dec_ort);
                fprintf(stderr, "[Server] PP-VAE decoding via ORT/TRT: %s\n", onnx_dec_path.c_str());
                T_decoded = vae_ort_decode_tiled(dec_ort, latents.data(), T_latent,
                                                  decoded.data(), T_audio_max, vae_chunk, vae_overlap);
            } else {
                fprintf(stderr, "[Server] PP-VAE ORT decoder load failed, falling back to GGML\n");
            }
        }
        if (T_decoded <= 0) {
            // Fall back to GGML decoder
            ModelKey dec_key;
            dec_key.kind = MODEL_VAE_DEC;
            dec_key.path = pp_vae_path;
            VAEGGML * dec = store_require_vae_dec(g_store, dec_key);
            if (!dec) {
                free(planar);
                json_error(res, 500, "Failed to load PP-VAE decoder");
                return;
            }
            ModelHandle dec_guard(g_store, dec);
            fprintf(stderr, "[Server] PP-VAE decoding via GGML\n");
            T_decoded = vae_ggml_decode_tiled(dec, latents.data(), T_latent,
                                               decoded.data(), T_audio_max, vae_chunk, vae_overlap, NULL, NULL);
            if (T_decoded <= 0) {
                free(planar);
                json_error(res, 500, "PP-VAE decode failed");
                return;
            }
        }
        fprintf(stderr, "[Server] PP-VAE decode: T_decoded=%d\n", T_decoded);

        // Phase 3: RMS gain match (scale output to match input RMS, cap at input peak)
        double out_sum_sq = 0.0;
        float  out_peak = 0.0f;
        int    dec_total = T_decoded * 2;
        for (int i = 0; i < dec_total; i++) {
            float v = decoded[i];
            out_sum_sq += (double) v * v;
            float av = fabsf(v);
            if (av > out_peak) out_peak = av;
        }
        float out_rms = (float) sqrt(out_sum_sq / (double) dec_total);

        float gain = 1.0f;
        if (out_rms > 1e-8f) {
            gain = in_rms / out_rms;
            if (out_peak * gain > in_peak + 0.01f) {
                gain = in_peak / (out_peak + 1e-8f);
            }
        }
        for (int i = 0; i < dec_total; i++) {
            decoded[i] *= gain;
        }

        // Phase 4: Blend original audio into PP-VAE output
        // blend=0 → fully PP-VAE, blend=1 → fully original
        if (blend > 0.0f) {
            int blend_len = std::min(n_total, dec_total);
            float wet = 1.0f - blend;
            for (int i = 0; i < blend_len; i++) {
                decoded[i] = decoded[i] * wet + planar[i] * blend;
            }
            fprintf(stderr, "[Server] PP-VAE blend: %.0f%% PP-VAE + %.0f%% original\n",
                    wet * 100.0f, blend * 100.0f);
        }

        fprintf(stderr, "[Server] PP-VAE done: gain=%.3f (in_rms=%.4f, out_rms=%.4f)\n",
                gain, in_rms, out_rms);

        free(planar);

        // Encode to WAV16 and return
        std::string wav = audio_encode_wav(decoded.data(), T_decoded, 48000, WAV_S16);
        res.set_content(wav, "audio/wav");
    });

    // ═══════════════════════════════════════════════════════════════════
    // SuperSep: Native stem separation via ONNX Runtime
    // ═══════════════════════════════════════════════════════════════════

    // Global SuperSep context (lazy-initialized on first request)
    static SuperSep * g_supersep = nullptr;
    static std::mutex mtx_supersep;

    // SuperSep job results (separate from main job pool since stems are large)
    struct SuperSepJob {
        std::string          id;
        std::atomic<int>     status{0};   // 0=running, 1=done, 2=failed
        std::atomic<bool>    cancel{false};
        float                progress{0.0f};
        std::string          progress_msg;
        std::mutex           mtx_progress;
        SuperSepResult *     result{nullptr};
        std::string          model_dir;
        std::string          error_msg;

        ~SuperSepJob() {
            if (result) supersep_result_free(result);
        }
    };

    static std::mutex mtx_sep_jobs;
    static std::unordered_map<std::string, std::shared_ptr<SuperSepJob>> g_sep_jobs;

    // POST /supersep/separate — start async stem separation
    // Body: raw WAV or MP3 audio
    // Query params: level=0..3 (BASIC/VOCAL_SPLIT/FULL/MAXIMUM)
    // Returns: {"id": "..."}
    svr.Post("/supersep/separate", [models_dir](const httplib::Request & req, httplib::Response & res) {
        if (req.body.empty()) {
            json_error(res, 400, "Empty body (expected audio)");
            return;
        }

        int level = 0;
        if (req.has_param("level")) {
            level = atoi(req.get_param_value("level").c_str());
            if (level < 0) level = 0;
            if (level > 3) level = 3;
        }

        // Decode audio to interleaved stereo 44100 Hz
        int     T_audio = 0, sr = 0;
        float * planar = audio_read_buf((const uint8_t *)req.body.data(), req.body.size(), &T_audio, &sr);
        if (!planar || T_audio <= 0) {
            json_error(res, 400, "Failed to decode audio");
            return;
        }

        // Resample to 44100 if needed
        if (sr != 44100) {
            int T_rs = 0;
            float * resampled = audio_resample(planar, T_audio, sr, 44100, 2, &T_rs);
            free(planar);
            if (!resampled) {
                json_error(res, 500, "Resample to 44100 failed");
                return;
            }
            planar = resampled;
            T_audio = T_rs;
        }

        // Convert planar to interleaved for SuperSep
        float * interleaved = audio_planar_to_interleaved(planar, T_audio);
        free(planar);
        if (!interleaved) {
            json_error(res, 500, "OOM converting to interleaved");
            return;
        }

        // Create job
        auto job = std::make_shared<SuperSepJob>();
        job->id = job_make_id();
        job->model_dir = std::string(models_dir) + "/supersep";

        {
            std::lock_guard<std::mutex> lock(mtx_sep_jobs);
            g_sep_jobs[job->id] = job;
        }

        int n_frames = T_audio;
        SuperSepLevel sep_level = (SuperSepLevel)level;

        // Push to work queue (GPU-serialized with DiT/LM jobs)
        work_push([job, interleaved, n_frames, sep_level]() {
            // Initialize SuperSep if needed
            {
                std::lock_guard<std::mutex> lock(mtx_supersep);
                if (!g_supersep) {
                    g_supersep = supersep_init(job->model_dir.c_str(), 0);
                }
            }

            if (!g_supersep) {
                fprintf(stderr, "[Server] SuperSep init failed\n");
                free(interleaved);
                job->status.store(2);
                return;
            }

            auto progress_cb = [](int stage, const char *msg, float pct, void *ud) {
                auto *j = (SuperSepJob *)ud;
                std::lock_guard<std::mutex> lock(j->mtx_progress);
                j->progress = pct;
                j->progress_msg = msg ? msg : "";
            };

            auto cancel_cb = [](void *ud) -> bool {
                auto *j = (SuperSepJob *)ud;
                return j->cancel.load();
            };

            SuperSepResult *result = supersep_run(
                g_supersep, interleaved, n_frames, sep_level,
                progress_cb, cancel_cb, (void *)job.get()
            );
            free(interleaved);

            if (result) {
                job->result = result;
                job->status.store(1);
                fprintf(stderr, "[Server] SuperSep job %s done (%d stems)\n",
                        job->id.c_str(), result->n_stems);
            } else {
                // Capture the last progress message as the error
                {
                    std::lock_guard<std::mutex> lock(job->mtx_progress);
                    if (job->error_msg.empty()) {
                        job->error_msg = job->progress_msg.empty()
                            ? "Unknown error during separation"
                            : job->progress_msg;
                    }
                }
                job->status.store(job->cancel.load() ? 3 : 2);
                fprintf(stderr, "[Server] SuperSep job %s failed: %s\n",
                        job->id.c_str(), job->error_msg.c_str());
            }

            // Release ONNX sessions to reclaim VRAM immediately
            supersep_release_models(g_supersep);
        });

        fprintf(stderr, "[Server] SuperSep job %s created (level=%d, %.1fs audio)\n",
                job->id.c_str(), level, (float)T_audio / 44100.0f);

        std::string body = "{\"id\":\"" + job->id + "\"}";
        res.set_content(body, "application/json");
    });

    // GET /supersep/progress?id=... — poll progress
    svr.Get("/supersep/progress", [](const httplib::Request & req, httplib::Response & res) {
        if (!req.has_param("id")) { json_error(res, 400, "Missing id"); return; }
        std::string id = req.get_param_value("id");

        std::shared_ptr<SuperSepJob> job;
        {
            std::lock_guard<std::mutex> lock(mtx_sep_jobs);
            auto it = g_sep_jobs.find(id);
            if (it == g_sep_jobs.end()) { json_error(res, 404, "Job not found"); return; }
            job = it->second;
        }

        yyjson_mut_doc * doc = yyjson_mut_doc_new(NULL);
        yyjson_mut_val * root = yyjson_mut_obj(doc);
        yyjson_mut_doc_set_root(doc, root);

        int status = job->status.load();
        yyjson_mut_obj_add_str(doc, root, "status", job_status_str(status));

        {
            std::lock_guard<std::mutex> lock(job->mtx_progress);
            yyjson_mut_obj_add_real(doc, root, "progress", job->progress);
            yyjson_mut_obj_add_str(doc, root, "message", job->progress_msg.c_str());
        }

        if (status == 1 && job->result) {
            yyjson_mut_obj_add_int(doc, root, "n_stems", job->result->n_stems);
        }
        if (status == 2) {
            std::lock_guard<std::mutex> lock2(job->mtx_progress);
            if (!job->error_msg.empty()) {
                yyjson_mut_obj_add_str(doc, root, "error", job->error_msg.c_str());
            }
        }

        char * json = yyjson_mut_write(doc, 0, NULL);
        yyjson_mut_doc_free(doc);
        res.set_content(json, "application/json");
        free(json);
    });

    // GET /supersep/result?id=... — get stem list (metadata, not audio)
    svr.Get("/supersep/result", [](const httplib::Request & req, httplib::Response & res) {
        if (!req.has_param("id")) { json_error(res, 400, "Missing id"); return; }
        std::string id = req.get_param_value("id");

        std::shared_ptr<SuperSepJob> job;
        {
            std::lock_guard<std::mutex> lock(mtx_sep_jobs);
            auto it = g_sep_jobs.find(id);
            if (it == g_sep_jobs.end()) { json_error(res, 404, "Job not found"); return; }
            job = it->second;
        }

        if (job->status.load() != 1 || !job->result) {
            json_error(res, 409, "Job not complete");
            return;
        }

        yyjson_mut_doc * doc = yyjson_mut_doc_new(NULL);
        yyjson_mut_val * root = yyjson_mut_obj(doc);
        yyjson_mut_doc_set_root(doc, root);

        yyjson_mut_val * arr = yyjson_mut_arr(doc);
        for (int i = 0; i < job->result->n_stems; i++) {
            SuperSepStem & s = job->result->stems[i];
            yyjson_mut_val * obj = yyjson_mut_obj(doc);
            yyjson_mut_obj_add_str(doc, obj, "name", s.name);
            yyjson_mut_obj_add_str(doc, obj, "category", s.category);
            yyjson_mut_obj_add_str(doc, obj, "stem_type", s.stem_type);
            yyjson_mut_obj_add_int(doc, obj, "n_frames", s.n_frames);
            yyjson_mut_obj_add_int(doc, obj, "stage", s.stage);
            yyjson_mut_obj_add_int(doc, obj, "index", i);
            yyjson_mut_obj_add_bool(doc, obj, "hidden", s.hidden);
            yyjson_mut_arr_append(arr, obj);
        }
        yyjson_mut_obj_add_val(doc, root, "stems", arr);
        yyjson_mut_obj_add_str(doc, root, "id", id.c_str());

        char * json = yyjson_mut_write(doc, 0, NULL);
        yyjson_mut_doc_free(doc);
        res.set_content(json, "application/json");
        free(json);
    });

    // GET /supersep/serve?id=...&stem=N — download individual stem as WAV
    svr.Get("/supersep/serve", [](const httplib::Request & req, httplib::Response & res) {
        if (!req.has_param("id") || !req.has_param("stem")) {
            json_error(res, 400, "Missing id or stem"); return;
        }

        std::string id = req.get_param_value("id");
        int stem_idx = atoi(req.get_param_value("stem").c_str());

        std::shared_ptr<SuperSepJob> job;
        {
            std::lock_guard<std::mutex> lock(mtx_sep_jobs);
            auto it = g_sep_jobs.find(id);
            if (it == g_sep_jobs.end()) { json_error(res, 404, "Job not found"); return; }
            job = it->second;
        }

        if (job->status.load() != 1 || !job->result) {
            json_error(res, 409, "Job not complete"); return;
        }
        if (stem_idx < 0 || stem_idx >= job->result->n_stems) {
            json_error(res, 400, "Invalid stem index"); return;
        }

        SuperSepStem & s = job->result->stems[stem_idx];

        // Convert interleaved to planar for WAV encoder
        float * planar = (float *)malloc(sizeof(float) * s.n_frames * 2);
        if (!planar) { json_error(res, 500, "OOM"); return; }
        for (int i = 0; i < s.n_frames; i++) {
            planar[i]              = s.samples[i * 2 + 0];
            planar[s.n_frames + i] = s.samples[i * 2 + 1];
        }

        std::string wav = audio_encode_wav(planar, s.n_frames, 44100, WAV_S16);
        free(planar);
        res.set_content(wav, "audio/wav");
    });

    // POST /supersep/recombine — mix stems with volume/mute, return WAV
    // Body: JSON {"id":"...", "stems":[{"index":0,"volume":1.0,"muted":false},...]}
    svr.Post("/supersep/recombine", [](const httplib::Request & req, httplib::Response & res) {
        yyjson_doc * doc = yyjson_read(req.body.c_str(), req.body.size(), 0);
        if (!doc) { json_error(res, 400, "Invalid JSON"); return; }
        yyjson_val * root = yyjson_doc_get_root(doc);

        yyjson_val * v_id = yyjson_obj_get(root, "id");
        if (!v_id) { yyjson_doc_free(doc); json_error(res, 400, "Missing id"); return; }
        std::string id = yyjson_get_str(v_id);

        std::shared_ptr<SuperSepJob> job;
        {
            std::lock_guard<std::mutex> lock(mtx_sep_jobs);
            auto it = g_sep_jobs.find(id);
            if (it == g_sep_jobs.end()) {
                yyjson_doc_free(doc);
                json_error(res, 404, "Job not found"); return;
            }
            job = it->second;
        }

        if (job->status.load() != 1 || !job->result) {
            yyjson_doc_free(doc);
            json_error(res, 409, "Job not complete"); return;
        }

        // Parse stem controls
        yyjson_val * arr = yyjson_obj_get(root, "stems");
        int n = job->result->n_stems;
        std::vector<float> volumes(n, 1.0f);
        // NB: std::vector<bool> is a packed-bit proxy — no .data().
        // Use a real bool array for the C API.
        std::unique_ptr<bool[]> muted(new bool[n]());

        if (arr && yyjson_is_arr(arr)) {
            yyjson_val * item;
            size_t idx, max_val;
            yyjson_arr_foreach(arr, idx, max_val, item) {
                yyjson_val * vi = yyjson_obj_get(item, "index");
                if (!vi) continue;
                int si = (int)yyjson_get_int(vi);
                if (si < 0 || si >= n) continue;

                yyjson_val * vv = yyjson_obj_get(item, "volume");
                if (vv && yyjson_is_num(vv)) volumes[si] = (float)yyjson_get_num(vv);

                yyjson_val * vm = yyjson_obj_get(item, "muted");
                if (vm && yyjson_is_bool(vm)) muted[si] = yyjson_get_bool(vm);
            }
        }
        yyjson_doc_free(doc);

        // Debug: log the effective mix controls
        fprintf(stderr, "[SuperSep] Recombine request: %d stems\n", n);
        for (int i = 0; i < n; i++) {
            fprintf(stderr, "  [%d] %-20s vol=%.2f muted=%d\n",
                    i, job->result->stems[i].name, volumes[i], (int)muted[i]);
        }

        int out_frames = 0;
        float * mixed = supersep_recombine(
            job->result->stems, volumes.data(), muted.get(), n, &out_frames);

        if (!mixed || out_frames <= 0) {
            json_error(res, 500, "Recombine produced no audio");
            return;
        }

        // Convert interleaved to planar for resampling
        float * planar44 = (float *)malloc(sizeof(float) * out_frames * 2);
        for (int i = 0; i < out_frames; i++) {
            planar44[i]              = mixed[i * 2 + 0];
            planar44[out_frames + i] = mixed[i * 2 + 1];
        }
        free(mixed);

        // Resample 44100 → 48000 Hz (engine expects 48 kHz)
        int out48_frames = 0;
        float * planar48 = audio_resample(planar44, out_frames, 44100, 48000, 2, &out48_frames);
        free(planar44);

        if (!planar48 || out48_frames <= 0) {
            json_error(res, 500, "Resample to 48kHz failed");
            return;
        }
        fprintf(stderr, "[SuperSep] Recombined: %d frames @44.1k → %d frames @48k\n",
                out_frames, out48_frames);

        std::string wav = audio_encode_wav(planar48, out48_frames, 48000, WAV_S16);
        free(planar48);
        res.set_content(wav, "audio/wav");
    });

    // POST /spectral-lifter — synchronous Spectral Lifter processing.
    // Accepts WAV audio body. SL params are in query string:
    //   ?denoise_strength=0.3&noise_floor=0.1&hf_mix=0&transient_boost=0&shimmer_reduction=6
    // Returns processed WAV audio body (same sample rate, format).
    // Runs synchronously (no job queue) — it's pure CPU DSP, typically <1s.
    svr.Post("/spectral-lifter", [](const httplib::Request & req, httplib::Response & res) {
        if (req.body.empty()) {
            json_error(res, 400, "Empty body (expected WAV audio)");
            return;
        }

        // Parse SL params from query string (with defaults)
        SpectralLifterParams slp;
        spectral_lifter_default(&slp);
        if (req.has_param("denoise_strength")) slp.denoise_strength = strtof(req.get_param_value("denoise_strength").c_str(), nullptr);
        if (req.has_param("noise_floor"))      slp.noise_floor      = strtof(req.get_param_value("noise_floor").c_str(), nullptr);
        if (req.has_param("hf_mix"))           slp.hf_mix           = strtof(req.get_param_value("hf_mix").c_str(), nullptr);
        if (req.has_param("transient_boost"))  slp.transient_boost  = strtof(req.get_param_value("transient_boost").c_str(), nullptr);
        if (req.has_param("shimmer_reduction")) slp.shimmer_reduction = strtof(req.get_param_value("shimmer_reduction").c_str(), nullptr);

        // Decode WAV from body
        int     T_audio = 0;
        float * planar  = audio_read_48k_buf((const uint8_t *) req.body.data(), req.body.size(), &T_audio);
        if (!planar || T_audio <= 0) {
            json_error(res, 400, "Failed to decode WAV audio");
            return;
        }

        fprintf(stderr, "[Server] Spectral Lifter: %.2fs @ 48kHz (denoise=%.2f, floor=%.2f, hf=%.2f, transient=%.2f, shimmer=%.1fdB)\n",
                (float) T_audio / 48000.0f, slp.denoise_strength, slp.noise_floor,
                slp.hf_mix, slp.transient_boost, slp.shimmer_reduction);

        // Process in-place
        spectral_lifter_process(planar, T_audio, 48000, &slp);

        // Encode back to WAV16
        std::string wav = audio_encode_wav(planar, T_audio, 48000, WAV_S16);
        free(planar);

        res.set_content(wav, "audio/wav");
    });

    // embedded webui: gzipped single-page app (built by tools/webui/).
    // the browser decompresses transparently via Content-Encoding: gzip.
    // the .gz is committed to git so cloning + cmake + make gives a working UI.
    if (index_html_gz_len > 0) {
        svr.Get("/", [](const httplib::Request & req, httplib::Response & res) {
            if (req.get_header_value("Accept-Encoding").find("gzip") == std::string::npos) {
                res.set_content("Error: gzip is not supported by this browser", "text/plain");
            } else {
                res.set_header("Content-Encoding", "gzip");
                res.set_content(reinterpret_cast<const char *>(index_html_gz), index_html_gz_len,
                                "text/html; charset=utf-8");
            }
        });
    }

    // graceful shutdown on SIGINT/SIGTERM
    signal(SIGINT, on_signal);
    signal(SIGTERM, on_signal);

    // start FIFO worker thread (processes all GPU jobs in order)
    std::thread worker(worker_main);

    fprintf(stderr, "[Server] acestep.cpp %s\n", ACE_VERSION);
    fprintf(stderr, "[Server] Listening on %s:%d\n", host, port);
    fprintf(stderr, "[Server] Pipelines:%s%s%s\n", have_lm ? " /lm" : "", have_synth ? " /synth" : "",
            have_understand ? " /understand" : "");
    fprintf(stderr, "[Server] Models: %zu LM, %zu Text-Enc, %zu DiT, %zu VAE, %zu Adapter\n", g_registry.lm.size(),
            g_registry.text_enc.size(), g_registry.dit.size(), g_registry.vae.size(), g_registry.adapters.size());
    if (!svr.listen(host, port)) {
        fprintf(stderr, "[Server] FATAL: cannot bind %s:%d\n", host, port);
    }

    // stop worker thread (finishes current job, discards pending)
    {
        std::lock_guard<std::mutex> lock(mtx_work);
        g_work_stop = true;
    }
    cv_work.notify_one();
    worker.join();

    // cleanup
    fprintf(stderr, "[Server] Shutting down...\n");
    store_free(g_store);
    fprintf(stderr, "[Server] Done\n");

    return 0;
}
