Files
enne2 7942e61b3a
Docker / build (cpu, cpu) (push) Canceled after 0s
Docker / build (nvidia/cuda:12.9.2-devel-ubuntu22.04, nvidia/cuda:12.9.2-runtime-ubuntu22.04, cuda, cuda12) (push) Canceled after 0s
Docker / build (nvidia/cuda:13.3.1-devel-ubuntu22.04, nvidia/cuda:13.3.1-runtime-ubuntu22.04, cuda, cuda13) (push) Canceled after 0s
Docker / build (vulkan, vulkan) (push) Canceled after 0s
feat: anti-loop guards + KV cache configurabile
Prevenzione allucinazioni/loop infiniti (Qwen3-TTS autoregressivo):
- block_repeated_ngrams: maschera i token che ricreerebbero un n-gram gia visto (n=4)
- has_repeating_cycle: ferma la generazione su cicli periodici (periodo 1-16, 4 ripetizioni)
- stuck detector: token dominante nella finestra recente (4 occorrenze in 8 token)
- fallback EOS quando tutti i logits sono mascherati (evita NaN)
- KV cache talker configurabile (--kv-cache, default 8192): 4096 overflowava con
  reference lunghe + testi lunghi ("decode would overflow cache")
- parametri esposti via API (no_repeat_ngram_size, loop_max_period, loop_repeats,
  loop_window) e CLI (--no-repeat-ngram, --loop-period, --loop-repeats, --loop-window)
- Docker: TTS_KV_CACHE env (default 8192)

Validato: testo 2788 char che prima degenerava in loop ora sintetizza pulito
(141s via API, nessuna ripetizione).
2026-08-18 15:10:02 +02:00

720 lines
26 KiB
C++

// qwen.cpp: public ABI implementation.
//
// Every entry declared in qwen.h lives here under one extern "C" block
// so the symbols carry C linkage and are linkable from C, Rust, Go,
// Python ctypes and any other binding generator. The struct
// qt_context opaque handle owns one BackendPair, one PipelineTTS
// (which already embeds the PipelineCodec) and one BPETokenizer.
// qt_init walks the load chain in dependency order and unwinds
// whatever it already allocated when any step fails. qt_free mirrors
// that order in reverse.
//
// This translation unit also absorbs the internal qt_set_error /
// qt_throw / qt_log helpers that the rest of the codebase calls. The
// log callback installed via qt_log_set routes every diagnostic from
// any caller, internal or public.
#include "qwen.h"
#include "backend.h"
#include "bpe.h"
#include "pipeline-tts.h"
#include "qt-error.h"
#include "speaker-encoder-extract.h"
#include "timer.h"
#include "version.h"
#include <atomic>
#include <condition_variable>
#include <cstdarg>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <deque>
#include <mutex>
#include <new>
#include <random>
#include <stdexcept>
#include <string>
#include <thread>
#include <vector>
// Internal definition of the opaque handle. C++ types are fine here
// because nothing in this struct ever crosses the public ABI boundary :
// callers only ever see `struct qt_context *`. PipelineTTS already
// embeds the PipelineCodec, so no separate codec field is needed.
//
// gpu_mu serializes every GPU touching entry: the batch worker holds
// it per engine phase (admit, frame step) so a voice extraction or a
// max_batch == 1 synthesize slips between frames instead of racing the
// backend from another thread.
//
// The scheduler members drive the max_batch > 1 mode: qt_synthesize
// enqueues a TtsJob and blocks on cv_done until the worker retires it.
// The worker owns the long lived TtsEngine, admits queued jobs into
// free slots between frames and steps the batch until both the queue
// and the slots drain.
struct qt_context {
BackendPair bp;
PipelineTTS pt;
BPETokenizer tok;
std::mutex gpu_mu;
int max_batch = 1;
std::thread worker;
std::mutex mu;
std::condition_variable cv_work;
std::condition_variable cv_done;
std::deque<TtsJob *> queue;
bool stop = false;
};
// Thread-local backing store for qt_last_error(). std::string sized once
// per thread, grows on demand, never freed across calls: the std runtime
// reclaims it on thread exit. An empty string means "no error recorded
// on this thread yet", which qt_last_error() exposes as "".
static thread_local std::string g_last_error;
void qt_set_error_v(const char * fmt, va_list ap) {
if (!fmt) {
g_last_error.clear();
return;
}
// Two-pass vsnprintf: first call sizes the buffer, second writes the
// message. va_copy keeps the original ap valid for the second pass.
va_list ap2;
va_copy(ap2, ap);
int needed = std::vsnprintf(nullptr, 0, fmt, ap2);
va_end(ap2);
if (needed < 0) {
g_last_error = "qt_set_error: vsnprintf failed";
return;
}
g_last_error.resize(static_cast<size_t>(needed));
std::vsnprintf(g_last_error.data(), static_cast<size_t>(needed) + 1, fmt, ap);
}
void qt_set_error(const char * fmt, ...) {
va_list ap;
va_start(ap, fmt);
qt_set_error_v(fmt, ap);
va_end(ap);
}
// Formats a message with printf semantics and throws std::runtime_error.
// The catch site at the binary entry inspects the what() string and feeds
// it into qt_set_error so the user-visible diagnostic is identical
// whether the failure used the bool-return path or the throw path.
void qt_throw(const char * fmt, ...) {
char buf[1024];
if (fmt) {
va_list ap;
va_start(ap, fmt);
std::vsnprintf(buf, sizeof(buf), fmt, ap);
va_end(ap);
} else {
buf[0] = '\0';
}
throw std::runtime_error(buf);
}
// Process-wide log callback. Atomic so qt_log_set can replace it without
// locking: write happens with memory_order_release, every reader sees a
// fully published callback pointer paired with its user_data slot.
// std::atomic on a function pointer is lock-free on every platform we
// target. user_data is a plain pointer because it is only ever published
// alongside cb under the same release ordering.
static std::atomic<qt_log_cb> g_log_cb{ nullptr };
static void * g_log_cb_user = nullptr;
// Routes one log line to the installed callback or to stderr. Two-pass
// vsnprintf sizes the heap buffer when the message exceeds the stack
// scratchpad, which keeps the common case allocation-free.
void qt_log(qt_log_level level, const char * fmt, ...) {
if (!fmt) {
return;
}
char stackbuf[512];
char * buf = stackbuf;
int needed = 0;
va_list ap;
va_start(ap, fmt);
{
va_list ap2;
va_copy(ap2, ap);
needed = std::vsnprintf(stackbuf, sizeof(stackbuf), fmt, ap2);
va_end(ap2);
}
if (needed < 0) {
va_end(ap);
return;
}
std::string heapbuf;
if ((size_t) needed >= sizeof(stackbuf)) {
heapbuf.resize((size_t) needed);
std::vsnprintf(heapbuf.data(), (size_t) needed + 1, fmt, ap);
buf = heapbuf.data();
}
va_end(ap);
qt_log_cb cb = g_log_cb.load(std::memory_order_acquire);
if (cb) {
cb(level, buf, g_log_cb_user);
} else {
std::fprintf(stderr, "%s\n", buf);
}
}
// Resolve a -1 seed to a hardware random 64-bit value. Anything else is
// forwarded verbatim, so reproducibility is one explicit seed away. The
// resolved value travels into pipeline_tts_synthesize so the dump traces
// log the exact seed that drove the sampler, even when the caller asked
// for non determinism.
static int64_t qt_resolve_seed(int64_t seed) {
if (seed >= 0) {
return seed;
}
std::random_device rd;
return (int64_t) (((uint64_t) rd() << 32) ^ (uint64_t) rd());
}
extern "C" {
const char * qt_version(void) {
// QWEN_VERSION is a string literal injected by tools/version.cmake
// ("<git-hash> (<date>)"), so its storage already has process
// lifetime and no formatting wrapper is needed.
return QWEN_VERSION;
}
const char * qt_last_error(void) {
// c_str() on an empty std::string is guaranteed to point to a NUL
// byte by C++11, so callers never have to NULL-check the result.
return g_last_error.c_str();
}
void qt_audio_free(struct qt_audio * a) {
if (!a) {
return;
}
if (a->samples) {
std::free(a->samples);
}
a->samples = nullptr;
a->n_samples = 0;
a->sample_rate = 0;
a->channels = 0;
}
void qt_log_set(qt_log_cb cb, void * user_data) {
g_log_cb_user = user_data;
g_log_cb.store(cb, std::memory_order_release);
}
// Codec chunk default, shared by qt_init_default_params and the
// qt_init resolution of an unset value.
static const float QT_CODEC_CHUNK_SEC_DEFAULT = 24.0f;
void qt_init_default_params(struct qt_init_params * p) {
p->abi_version = QT_ABI_VERSION;
p->talker_path = nullptr;
p->codec_path = nullptr;
p->use_fa = true;
p->clamp_fp16 = false;
p->max_batch = 1;
p->talker_kv_size = 8192;
p->codec_chunk_sec = QT_CODEC_CHUNK_SEC_DEFAULT;
}
void qt_tts_default_params(struct qt_tts_params * p) {
p->abi_version = QT_ABI_VERSION;
p->text = nullptr;
p->lang = nullptr;
p->instruct = nullptr;
p->speaker = nullptr;
p->ref_audio_24k = nullptr;
p->ref_n_samples = 0;
p->ref_text = nullptr;
p->seed = -1;
p->max_new_tokens = 2048;
p->do_sample = true;
p->temperature = 0.9f;
p->top_k = 50;
p->top_p = 1.0f;
p->repetition_penalty = 1.05f;
p->subtalker_do_sample = true;
p->subtalker_temperature = 0.9f;
p->subtalker_top_k = 50;
p->subtalker_top_p = 1.0f;
p->no_repeat_ngram_size = 4;
p->loop_max_period = 16;
p->loop_repeats = 4;
p->loop_window = 64;
p->dump_dir = nullptr;
p->cancel = nullptr;
p->cancel_user_data = nullptr;
p->on_chunk = nullptr;
p->on_chunk_user_data = nullptr;
p->ref_spk_emb = nullptr;
p->ref_spk_dim = 0;
p->ref_codes = nullptr;
p->ref_T = 0;
}
int qt_num_codebooks(const struct qt_context * q) {
if (!q) {
qt_set_error("qt_num_codebooks: q is NULL");
return 0;
}
return q->pt.num_code_groups;
}
// Batch worker: owns the long lived TtsEngine and the GPU hot loop.
// Sleeps until work arrives, then admits queued jobs into free slots
// between frames and steps the batch until both the queue and the
// slots drain. Every GPU touching phase runs under gpu_mu so a voice
// extraction or another entry can slip between frames from its own
// thread. Retired jobs get their done flag under mu and a cv_done
// broadcast so the blocked qt_synthesize callers wake.
static void qt_batch_worker(qt_context * q) {
TtsEngine * e = tts_engine_new(&q->pt, &q->tok);
std::vector<TtsJob *> retired;
std::unique_lock<std::mutex> lk(q->mu);
for (;;) {
q->cv_work.wait(lk, [&] { return q->stop || !q->queue.empty(); });
if (q->stop && q->queue.empty()) {
break;
}
while (!q->queue.empty() || tts_engine_active(e) > 0) {
// Admit up to max_batch: joins happen at frame boundaries,
// each one stalls the active slots for one prefill.
while (!q->queue.empty() && tts_engine_active(e) < q->max_batch) {
TtsJob * j = q->queue.front();
q->queue.pop_front();
lk.unlock();
bool admitted;
{
std::lock_guard<std::mutex> gpu(q->gpu_mu);
admitted = tts_engine_admit(e, j);
}
lk.lock();
if (!admitted) {
j->done = true;
q->cv_done.notify_all();
}
}
if (tts_engine_active(e) == 0) {
break;
}
lk.unlock();
retired.clear();
{
std::lock_guard<std::mutex> gpu(q->gpu_mu);
tts_engine_step(e, &retired);
}
lk.lock();
for (TtsJob * j : retired) {
j->done = true;
}
if (!retired.empty()) {
q->cv_done.notify_all();
}
}
}
lk.unlock();
tts_engine_free(e);
}
struct qt_context * qt_init(const struct qt_init_params * params) {
if (!params || !params->talker_path || !params->codec_path) {
qt_set_error("qt_init: params, talker_path or codec_path is NULL");
qt_log(QT_LOG_ERROR, "[Qwen] qt_init requires talker_path and codec_path");
return nullptr;
}
if (params->abi_version > QT_ABI_VERSION || params->abi_version < QT_ABI_MIN_VERSION) {
qt_set_error("qt_init: params->abi_version %d outside the supported range [%d, %d]", params->abi_version,
QT_ABI_MIN_VERSION, QT_ABI_VERSION);
qt_log(QT_LOG_ERROR, "[Qwen] qt_init params struct carries an unsupported ABI (%d, supported [%d, %d])",
params->abi_version, QT_ABI_MIN_VERSION, QT_ABI_VERSION);
return nullptr;
}
qt_log(QT_LOG_INFO, "[Qwen] qwentts.cpp %s", qt_version());
const int max_batch = params->max_batch > 1 ? params->max_batch : 1;
// The chunk width resolves once here: it is a property of the
// handle, read by every buffered decode it runs.
const float chunk_sec = params->codec_chunk_sec > 0.0f ? params->codec_chunk_sec : QT_CODEC_CHUNK_SEC_DEFAULT;
// new qt_context() value-initialises every field: POD aggregates
// (BackendPair, PipelineTTS) are zero-init, std containers in
// BPETokenizer construct empty.
qt_context * q = new qt_context();
q->max_batch = max_batch;
// The load chain runs inside a try block. Any failure deep in the
// GGUF reader, the codec load or the LM weight load throws via
// qt_throw; the catch funnels every variant into one cleanup via
// qt_free, which is idempotent on partial state (NULL-safe sched,
// NULL GGUF handles, refcount-correct backend release).
try {
q->bp = backend_init("Talker");
if (!q->bp.backend) {
qt_throw("qt_init: backend_init failed (no GGML backend available)");
}
if (!pipeline_tts_load(&q->pt, params->talker_path, params->codec_path, q->bp, params->use_fa,
params->clamp_fp16, max_batch, chunk_sec, params->talker_kv_size)) {
qt_throw("qt_init: pipeline_tts_load failed for '%s' / '%s'", params->talker_path, params->codec_path);
}
// BPE tokenizer payload lives inside the talker GGUF. Load the
// base vocab + the qwen3-tts text specials in one shot. The
// specials key list matches the keys written by the conversion
// script under the qwen3-tts.text.* namespace.
if (!load_bpe_from_gguf(&q->tok, params->talker_path)) {
qt_throw("qt_init: load_bpe_from_gguf failed for '%s'", params->talker_path);
}
const char * specials_keys[] = {
"qwen3-tts.text.im_start_id", "qwen3-tts.text.im_end_id", "qwen3-tts.text.tts_pad_id",
"qwen3-tts.text.tts_bos_id", "qwen3-tts.text.tts_eos_id",
};
bpe_load_specials_from_keys(&q->tok, params->talker_path, specials_keys, 5);
} catch (const std::exception & e) {
qt_set_error("%s", e.what());
qt_log(QT_LOG_ERROR, "[Qwen] %s", e.what());
qt_free(q);
return nullptr;
}
// Batch mode: one worker thread owns the long lived engine and the
// GPU hot loop; qt_synthesize enqueues and blocks on completion.
if (q->max_batch > 1) {
q->worker = std::thread(qt_batch_worker, q);
qt_log(QT_LOG_INFO, "[Qwen] Batch scheduler started (max_batch=%d)", q->max_batch);
}
return q;
}
void qt_free(struct qt_context * q) {
if (!q) {
return;
}
if (q->worker.joinable()) {
{
std::lock_guard<std::mutex> lk(q->mu);
q->stop = true;
}
q->cv_work.notify_all();
q->worker.join();
}
pipeline_tts_free(&q->pt);
backend_release(q->bp.backend, q->bp.cpu_backend);
delete q;
}
void qt_voice_ref_free(struct qt_voice_ref * ref) {
if (!ref) {
return;
}
if (ref->ref_spk_emb) {
std::free(ref->ref_spk_emb);
}
if (ref->ref_codes) {
std::free(ref->ref_codes);
}
ref->ref_spk_emb = nullptr;
ref->ref_spk_dim = 0;
ref->ref_codes = nullptr;
ref->ref_T = 0;
ref->num_codebooks = 0;
}
enum qt_status qt_extract_voice_ref(struct qt_context * q,
const float * ref_audio_24k,
int ref_n_samples,
struct qt_voice_ref * out) {
if (out) {
qt_voice_ref_free(out);
}
if (!q || !ref_audio_24k || !out) {
qt_set_error("qt_extract_voice_ref: q, ref_audio_24k or out is NULL");
return QT_STATUS_INVALID_PARAMS;
}
if (ref_n_samples < TOKENIZER_HOP_LENGTH) {
qt_set_error("qt_extract_voice_ref: ref_audio_24k too short for RVQ encode (%d samples, need at least %d)",
ref_n_samples, TOKENIZER_HOP_LENGTH);
return QT_STATUS_INVALID_PARAMS;
}
const std::string & mt = q->pt.model_type;
if (mt != "base") {
qt_set_error("qt_extract_voice_ref: voice references are only valid for base models (loaded: %s)", mt.c_str());
return QT_STATUS_MODE_INVALID;
}
if (!q->pt.has_speaker_encoder) {
qt_set_error("qt_extract_voice_ref: loaded base model has no speaker encoder");
return QT_STATUS_GENERATE_FAILED;
}
if (q->pt.num_code_groups <= 0) {
qt_set_error("qt_extract_voice_ref: invalid codebook count %d", q->pt.num_code_groups);
return QT_STATUS_GENERATE_FAILED;
}
try {
// Serialize against the batch worker and any concurrent
// synthesize: the extraction slips between two engine frames.
std::lock_guard<std::mutex> gpu(q->gpu_mu);
// Lazy residency: the first reference audio request pays the
// weight load once, mirroring the qt_synthesize ref_audio path.
if (!q->pt.spk_enc_loaded) {
Timer t_spk_load;
if (!speaker_encoder_weights_load(&q->pt.speaker_encoder, q->pt.gguf_talker, q->pt.backend) ||
q->pt.speaker_encoder.weight_buf == NULL) {
q->pt.has_speaker_encoder = false;
qt_set_error("qt_extract_voice_ref: speaker encoder load failed");
return QT_STATUS_GENERATE_FAILED;
}
q->pt.spk_enc_loaded = true;
qt_log(QT_LOG_INFO, "[Qwen] Speaker encoder lazy loaded in %.0f ms", t_spk_load.ms());
}
std::vector<float> emb;
if (!speaker_encoder_extract(&q->pt.speaker_encoder, q->pt.sched, ref_audio_24k, ref_n_samples, emb)) {
qt_set_error("qt_extract_voice_ref: speaker embedding extraction failed");
return QT_STATUS_GENERATE_FAILED;
}
if ((int) emb.size() != q->pt.talker.hidden_size) {
qt_set_error("qt_extract_voice_ref: speaker embedding size %zu mismatches talker hidden %d", emb.size(),
q->pt.talker.hidden_size);
return QT_STATUS_GENERATE_FAILED;
}
const int aligned_n = (ref_n_samples / TOKENIZER_HOP_LENGTH) * TOKENIZER_HOP_LENGTH;
const int ref_T = aligned_n / TOKENIZER_HOP_LENGTH;
std::vector<int32_t> codes = pipeline_codec_encode(&q->pt.codec, ref_audio_24k, aligned_n);
if (codes.empty()) {
qt_set_error("qt_extract_voice_ref: pipeline_codec_encode returned empty codes");
return QT_STATUS_GENERATE_FAILED;
}
const int num_codebooks = q->pt.num_code_groups;
if ((codes.size() % (size_t) num_codebooks) != 0) {
qt_set_error("qt_extract_voice_ref: encoded code count %zu is not divisible by %d", codes.size(),
num_codebooks);
return QT_STATUS_GENERATE_FAILED;
}
const int codes_T = (int) (codes.size() / (size_t) num_codebooks);
if (codes_T != ref_T) {
qt_set_error("qt_extract_voice_ref: encoded frame count %d mismatches aligned frame count %d", codes_T,
ref_T);
return QT_STATUS_GENERATE_FAILED;
}
const size_t emb_bytes = emb.size() * sizeof(float);
const size_t codes_bytes = codes.size() * sizeof(int32_t);
float * emb_copy = (float *) std::malloc(emb_bytes);
int32_t * codes_copy = (int32_t *) std::malloc(codes_bytes);
if (!emb_copy || !codes_copy) {
std::free(emb_copy);
std::free(codes_copy);
qt_set_error("qt_extract_voice_ref: malloc failed for %zu emb bytes and %zu code bytes", emb_bytes,
codes_bytes);
return QT_STATUS_OOM;
}
std::memcpy(emb_copy, emb.data(), emb_bytes);
std::memcpy(codes_copy, codes.data(), codes_bytes);
out->ref_spk_emb = emb_copy;
out->ref_spk_dim = (int) emb.size();
out->ref_codes = codes_copy;
out->ref_T = ref_T;
out->num_codebooks = num_codebooks;
qt_log(QT_LOG_INFO, "[Qwen] Extracted voice ref: spk_dim=%d K=%d T=%d (%d/%d samples)", out->ref_spk_dim,
out->num_codebooks, out->ref_T, aligned_n, ref_n_samples);
return QT_STATUS_OK;
} catch (const std::bad_alloc &) {
qt_set_error("qt_extract_voice_ref: out of memory");
qt_voice_ref_free(out);
return QT_STATUS_OOM;
} catch (const std::exception & e) {
qt_set_error("%s", e.what());
qt_log(QT_LOG_ERROR, "[Qwen] %s", e.what());
qt_voice_ref_free(out);
return QT_STATUS_GENERATE_FAILED;
}
}
enum qt_status qt_synthesize(struct qt_context * q, const struct qt_tts_params * params, struct qt_audio * out) {
if (!q || !params) {
qt_set_error("qt_synthesize: q or params is NULL");
if (out) {
qt_audio_free(out);
}
return QT_STATUS_INVALID_PARAMS;
}
// Streaming mode (on_chunk non NULL) emits through the callback and
// leaves out unused, so out=NULL is valid there. Buffered mode
// requires out to receive the synthesised waveform.
if (!params->on_chunk && !out) {
qt_set_error("qt_synthesize: out is NULL in buffered mode");
return QT_STATUS_INVALID_PARAMS;
}
if (params->abi_version > QT_ABI_VERSION || params->abi_version < QT_ABI_MIN_VERSION) {
qt_set_error("qt_synthesize: params->abi_version %d outside the supported range [%d, %d]", params->abi_version,
QT_ABI_MIN_VERSION, QT_ABI_VERSION);
if (out) {
qt_audio_free(out);
}
return QT_STATUS_INVALID_PARAMS;
}
if (!params->text || !params->text[0]) {
qt_set_error("qt_synthesize: params->text is NULL or empty");
if (out) {
qt_audio_free(out);
}
return QT_STATUS_INVALID_PARAMS;
}
// Mode validation. Mirrors the upstream Python which raises
// ValueError when generate_voice_design is called on a non
// voice_design model and the same shape applies to
// generate_custom_voice. Explicit and KISS, so the caller never
// gets a silently wrong synthesis. Messages preserved verbatim
// from the previous CLI-side checks.
const std::string & mt = q->pt.model_type;
if (params->speaker && mt != "custom_voice") {
qt_set_error("--speaker is only valid for custom_voice models (loaded: %s)", mt.c_str());
if (out) {
qt_audio_free(out);
}
return QT_STATUS_MODE_INVALID;
}
if (params->instruct && mt == "base") {
qt_set_error("--instruct is not supported for base models");
if (out) {
qt_audio_free(out);
}
return QT_STATUS_MODE_INVALID;
}
if (mt == "custom_voice" && !params->speaker) {
qt_set_error("custom_voice models require --speaker");
if (out) {
qt_audio_free(out);
}
return QT_STATUS_MODE_INVALID;
}
if (mt == "voice_design" && (!params->instruct || params->instruct[0] == '\0')) {
qt_set_error("voice_design models require --instruct");
if (out) {
qt_audio_free(out);
}
return QT_STATUS_MODE_INVALID;
}
const bool has_lat_spk = params->ref_spk_emb && params->ref_spk_dim > 0;
const bool has_lat_codes = params->ref_codes && params->ref_T > 0;
if ((params->ref_audio_24k || has_lat_spk) && mt != "base") {
qt_set_error("--ref-wav / --ref-spk is only valid for base models (loaded: %s)", mt.c_str());
if (out) {
qt_audio_free(out);
}
return QT_STATUS_MODE_INVALID;
}
if (params->speaker && (params->ref_audio_24k || has_lat_spk)) {
qt_set_error("--speaker and --ref-wav / --ref-spk are mutually exclusive");
if (out) {
qt_audio_free(out);
}
return QT_STATUS_INVALID_PARAMS;
}
if (params->ref_text && !params->ref_audio_24k && !has_lat_codes) {
qt_set_error("--ref-text requires --ref-wav or --ref-rvq");
if (out) {
qt_audio_free(out);
}
return QT_STATUS_INVALID_PARAMS;
}
// Defense in depth: the synthesis path normally reports failures
// via qt_status return + qt_set_error. A future load-style throw or
// any std::bad_alloc deep inside the GGML backend is caught here
// and converted to QT_STATUS_GENERATE_FAILED so an exception never
// crosses the extern "C" boundary.
try {
const int64_t resolved_seed = qt_resolve_seed(params->seed);
if (q->max_batch <= 1) {
// Single sequence mode: run synchronously on the calling
// thread under gpu_mu, so concurrent callers serialize FIFO
// and callbacks fire on their own caller's thread.
std::lock_guard<std::mutex> gpu(q->gpu_mu);
return pipeline_tts_synthesize(&q->pt, &q->tok, params, resolved_seed, out);
}
// Batch mode: enqueue and block until the worker retires the
// job. qt_last_error is thread local, so the engine captured
// the worker side message into job.error at retirement; replay
// it into this caller's slot so the errno style contract holds
// across the thread hop.
TtsJob job;
job.params = params;
job.resolved_seed = resolved_seed;
job.out = out;
job.status = QT_STATUS_OK;
job.done = false;
{
std::lock_guard<std::mutex> lk(q->mu);
q->queue.push_back(&job);
}
q->cv_work.notify_all();
{
std::unique_lock<std::mutex> lk(q->mu);
q->cv_done.wait(lk, [&] { return job.done; });
}
if (job.status != QT_STATUS_OK && !job.error.empty()) {
qt_set_error("%s", job.error.c_str());
}
return job.status;
} catch (const std::exception & e) {
qt_set_error("%s", e.what());
qt_log(QT_LOG_ERROR, "[Qwen] %s", e.what());
if (out) {
qt_audio_free(out);
}
return QT_STATUS_GENERATE_FAILED;
}
}
int qt_duration_sec_to_tokens(const struct qt_context * q, float duration_sec) {
if (!q) {
qt_set_error("qt_duration_sec_to_tokens: q is NULL");
qt_log(QT_LOG_ERROR, "[Qwen] qt_duration_sec_to_tokens requires a valid handle");
return 1;
}
return pipeline_tts_duration_sec_to_tokens(&q->pt, duration_sec);
}
int qt_n_speakers(const struct qt_context * q) {
if (!q) {
qt_set_error("qt_n_speakers: q is NULL");
return 0;
}
return (int) q->pt.speakers.size();
}
const char * qt_speaker_name(const struct qt_context * q, int i) {
if (!q || i < 0 || i >= (int) q->pt.speakers.size()) {
return NULL;
}
return q->pt.speakers[(size_t) i].name.c_str();
}
} // extern "C"