From 552aa93f983a188e1894724a53fe2dd1bf9e104f Mon Sep 17 00:00:00 2001 From: Pascal Date: Thu, 14 May 2026 15:13:45 +0200 Subject: [PATCH] refactor: ABI --- CMakeLists.txt | 51 +++++- src/qt-error.cpp | 116 ------------- src/qt-error.h | 67 ++++---- src/qwen.cpp | 420 +++++++++++++++++++++++++++++++++++++++++++++++ src/qwen.h | 234 ++++++++++++++++++++++++++ 5 files changed, 737 insertions(+), 151 deletions(-) delete mode 100644 src/qt-error.cpp create mode 100644 src/qwen.cpp create mode 100644 src/qwen.h diff --git a/CMakeLists.txt b/CMakeLists.txt index c47b56a..6f23f73 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -89,12 +89,51 @@ macro(link_ggml_backends target) add_dependencies(${target} version) endmacro() -# Core library shared between binaries. Holds the shared infrastructure -# (error/log routing, future common helpers) that any binary linking the -# pipeline needs. STATIC because we have actual sources now. -add_library(qwen-core STATIC src/qt-error.cpp) +# Core library always STATIC : the bundled CLI tools include +# pipeline-tts.h / pipeline-codec.h / backend.h directly for the tests +# paths that need every pipeline_* / backend_* symbol resolved without +# going through the public ABI. QWENTTS_STATIC is propagated PUBLIC : +# the lib's own .cpp files see it (so QWEN_API resolves to empty when +# compiling qwen.cpp on Windows), and every consumer that links +# qwen-core inherits it too (same effect on their side, no spurious +# dllimport on a static archive). The shared library for ABI consumers +# is a separate, opt-in target below. +add_library(qwen-core STATIC + src/qwen.cpp + src/pipeline-tts.cpp + src/pipeline-codec.cpp + src/prompt-builder.cpp + src/talker-forward.cpp + src/code-predictor-forward.cpp +) +target_compile_definitions(qwen-core PUBLIC QWENTTS_STATIC) target_include_directories(qwen-core PUBLIC src) target_link_libraries(qwen-core PUBLIC ggml) +link_ggml_backends(qwen-core) + +# Public shared library for ABI consumers (Python ctypes, Rust bindgen, +# Go cgo). Opt-in : -DQWENTTS_SHARED=ON at configure time. Exports only +# the QWEN_API-marked symbols ; every internal pipeline_* / backend_* +# stays hidden inside the .so. Intentionally a different target name +# from qwen-core so the static path used by the tools is never affected. +option(QWENTTS_SHARED "Build the shared qwen library for ABI consumers" OFF) +if(QWENTTS_SHARED) + add_library(qwen SHARED + src/qwen.cpp + src/pipeline-tts.cpp + src/pipeline-codec.cpp + src/prompt-builder.cpp + src/talker-forward.cpp + src/code-predictor-forward.cpp + ) + target_compile_definitions(qwen PRIVATE QWENTTS_BUILD) + set_target_properties(qwen PROPERTIES + C_VISIBILITY_PRESET hidden + CXX_VISIBILITY_PRESET hidden + VISIBILITY_INLINES_HIDDEN ON + ) + link_ggml_backends(qwen) +endif() # quantize: GGUF requantizer (BF16 -> K-quants), shared policy with # omnivoice.cpp / acestep.cpp. @@ -102,11 +141,11 @@ add_executable(quantize tools/quantize.cpp) link_ggml_backends(quantize) # qwen-codec : standalone codec CLI (codes <-> WAV via 12Hz tokenizer) -add_executable(qwen-codec tools/qwen-codec.cpp src/pipeline-codec.cpp) +add_executable(qwen-codec tools/qwen-codec.cpp) target_link_libraries(qwen-codec PRIVATE qwen-core) link_ggml_backends(qwen-codec) # qwen-tts : full TTS pipeline (Talker LM + 12Hz tokenizer decoder). -add_executable(qwen-tts tools/qwen-tts.cpp src/pipeline-tts.cpp src/pipeline-codec.cpp src/prompt-builder.cpp src/talker-forward.cpp src/code-predictor-forward.cpp) +add_executable(qwen-tts tools/qwen-tts.cpp) target_link_libraries(qwen-tts PRIVATE qwen-core) link_ggml_backends(qwen-tts) diff --git a/src/qt-error.cpp b/src/qt-error.cpp deleted file mode 100644 index 181283e..0000000 --- a/src/qt-error.cpp +++ /dev/null @@ -1,116 +0,0 @@ -// qt-error.cpp : implementation of the qt_log / qt_set_error / qt_throw -// helpers declared in qt-error.h. Storage is thread_local for the error -// slot, atomic for the log callback so qt_log_set is wait-free. - -#include "qt-error.h" - -#include -#include -#include -#include -#include - -// 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(needed)); - std::vsnprintf(g_last_error.data(), static_cast(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); -} - -const char * qt_last_error(void) { - return g_last_error.c_str(); -} - -// 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 g_log_cb{ nullptr }; -static void * g_log_cb_user = nullptr; - -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); -} - -// 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(enum 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); - } -} diff --git a/src/qt-error.h b/src/qt-error.h index f631443..e3a041d 100644 --- a/src/qt-error.h +++ b/src/qt-error.h @@ -1,34 +1,47 @@ #pragma once -// qt-error.h : internal helpers backing the (future) public qt_last_error -// entry and the qt_log callback routing. +// qt-error.h : internal helpers backing the public qwen_last_error +// entry and the qwen_log callback routing. // -// Storage is thread_local so concurrent synthesize calls on different -// threads never race on each other's messages. The setter is variadic -// with printf semantics ; messages longer than the internal buffer are -// truncated, never split. Passing NULL as fmt clears the slot. +// Not part of the public ABI. Translation units that emit user-facing +// errors include this header to record a diagnostic on the calling +// thread before they return a negative qwen_status (or false). The +// actual storage and the public qwen_last_error() reader live in +// qwen.cpp. // -// qt_throw is the load-path counterpart : functions deep inside the GGUF -// reader and the codec load chain cannot return false up dozens of call -// sites without a massive cascade. They throw a std::runtime_error -// instead, which the binary entry point (main, or a future ABI boundary) -// catches and converts to qt_set_error plus a non-zero exit. Exceptions -// never cross any future C ABI. +// Storage is thread_local so concurrent qwen_synthesize calls on +// different threads never race on each other's messages. The setter is +// variadic with printf semantics ; messages longer than the internal +// buffer are truncated, never split. Passing NULL as fmt clears the +// slot. // -// qt_log routes a formatted message to the user-installed qt_log_cb, or -// to stderr when no callback is installed. Used by every translation +// qt_throw is the load-path counterpart : functions deep inside the +// GGUF reader and the codec load chain cannot return false up dozens +// of call sites without a massive cascade. They throw a +// std::runtime_error instead, which the ABI boundary entries +// (qwen_init, qwen_synthesize) catch and convert into qt_set_error +// plus a negative qwen_status. Exceptions never cross any future C ABI. +// +// qt_log routes a formatted message to the user-installed qwen_log_cb, +// or to stderr when no callback is installed. Used by every translation // unit in the lib that wants its diagnostics to be redirectable from a -// wrapper (Python logging, Rust tracing, ...). +// wrapper (Python logging, Rust tracing, ...). The level enum is the +// public qwen_log_level, re-exported here under the historic qt_log_level +// name so existing call sites stay pixel perfect. + +#include "qwen.h" #include -enum qt_log_level { - QT_LOG_DEBUG = 0, - QT_LOG_INFO = 1, - QT_LOG_WARN = 2, - QT_LOG_ERROR = 3, -}; +// Internal log level alias. Same values, same layout as the public +// qwen_log_level enum : a single underlying type means a single log +// callback installed through qwen_log_set routes every diagnostic +// without any cast or translation. +typedef enum qwen_log_level qt_log_level; -typedef void (*qt_log_cb)(enum qt_log_level level, const char * msg, void * user_data); +#define QT_LOG_DEBUG QWEN_LOG_DEBUG +#define QT_LOG_INFO QWEN_LOG_INFO +#define QT_LOG_WARN QWEN_LOG_WARN +#define QT_LOG_ERROR QWEN_LOG_ERROR void qt_set_error(const char * fmt, ...) #if defined(__GNUC__) || defined(__clang__) @@ -50,18 +63,14 @@ void qt_set_error_v(const char * fmt, va_list ap); ; // Routes a formatted message at the requested level to the installed -// callback, or to stderr when none is set. The message is the full line -// without trailing newline ; routing layers add their own framing. -void qt_log(enum qt_log_level level, const char * fmt, ...) +// callback, or to stderr when none is set. The message is the full +// line without trailing newline ; routing layers add their own framing. +void qt_log(qt_log_level level, const char * fmt, ...) #if defined(__GNUC__) || defined(__clang__) __attribute__((format(printf, 2, 3))) #endif ; -// Install a process-wide log callback. Pass NULL to revert to stderr. -// user_data is opaque, forwarded as-is to every callback invocation. -void qt_log_set(qt_log_cb cb, void * user_data); - // Returns the most recent error message recorded on the calling thread. // Returns "" if no error has been set on this thread. The pointer stays // valid until the next qt_set_error call on the same thread. diff --git a/src/qwen.cpp b/src/qwen.cpp new file mode 100644 index 0000000..6fda36c --- /dev/null +++ b/src/qwen.cpp @@ -0,0 +1,420 @@ +// 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 +// qwen_context opaque handle owns one BackendPair, one PipelineTTS +// (which embeds its PipelineCodec) and one BPETokenizer. qwen_init +// walks the load chain in dependency order and unwinds whatever it +// already allocated when any step fails. qwen_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 +// internal qt_log_level enum is a typedef of the public qwen_log_level +// (same values, same layout) so a single log callback installed via +// qwen_log_set routes every diagnostic, internal or public. + +#include "qwen.h" + +#include "backend.h" +#include "bpe.h" +#include "pipeline-tts.h" +#include "qt-error.h" +#include "version.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +// 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 qwen_context *`. PipelineTTS already +// embeds the PipelineCodec, so no separate codec field is needed. +struct qwen_context { + BackendPair bp; + PipelineTTS pt; + BPETokenizer tok; +}; + +// 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(needed)); + std::vsnprintf(g_last_error.data(), static_cast(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); +} + +const char * qt_last_error(void) { + return g_last_error.c_str(); +} + +// 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 qwen_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 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); + + qwen_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); + } +} + +extern "C" { + +const char * qwen_version(void) { + // QWEN_VERSION is a string literal injected by tools/version.cmake + // (" ()"), so its storage already has process + // lifetime and no formatting wrapper is needed. + return QWEN_VERSION; +} + +const char * qwen_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 qwen_audio_free(struct qwen_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 qwen_log_set(qwen_log_cb cb, void * user_data) { + g_log_cb_user = user_data; + g_log_cb.store(cb, std::memory_order_release); +} + +void qwen_init_default_params(struct qwen_init_params * p) { + p->abi_version = QWEN_ABI_VERSION; + p->talker_path = nullptr; + p->codec_path = nullptr; +} + +void qwen_tts_default_params(struct qwen_tts_params * p) { + p->abi_version = QWEN_ABI_VERSION; + p->text = nullptr; + p->lang = "english"; + 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->dump_dir = nullptr; +} + +struct qwen_context * qwen_init(const struct qwen_init_params * params) { + if (!params || !params->talker_path || !params->codec_path) { + qt_set_error("qwen_init : params, talker_path or codec_path is NULL"); + qt_log(QT_LOG_ERROR, "[qwen] qwen_init requires talker_path and codec_path"); + return nullptr; + } + if (params->abi_version > QWEN_ABI_VERSION) { + qt_set_error( + "qwen_init : params->abi_version %d > QWEN_ABI_VERSION %d (binding compiled against a newer header)", + params->abi_version, QWEN_ABI_VERSION); + qt_log(QT_LOG_ERROR, "[qwen] qwen_init params struct is from a newer ABI (%d > %d)", params->abi_version, + QWEN_ABI_VERSION); + return nullptr; + } + + qt_log(QT_LOG_INFO, "[qwen] qwentts.cpp %s", qwen_version()); + + // new qwen_context() value-initialises every field: POD aggregates + // (BackendPair, PipelineTTS) are zero-init, std containers in + // BPETokenizer construct empty. + qwen_context * q = new qwen_context(); + + // 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 + // qwen_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("qwen_init : backend_init failed (no GGML backend available)"); + } + + if (!pipeline_tts_load(&q->pt, params->talker_path, params->codec_path, q->bp)) { + qt_throw("qwen_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 mirrors what the standalone CLI used to + // do before the facade hoisted the load chain. + if (!load_bpe_from_gguf(&q->tok, params->talker_path)) { + qt_throw("qwen_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()); + qwen_free(q); + return nullptr; + } + + return q; +} + +void qwen_free(struct qwen_context * q) { + if (!q) { + return; + } + pipeline_tts_free(&q->pt); + backend_release(q->bp.backend, q->bp.cpu_backend); + delete q; +} + +// Resolve a -1 seed to a hardware random 64-bit value. Anything else is +// forwarded verbatim, so reproducibility is one explicit seed away. +static int64_t qwen_resolve_seed(int64_t seed) { + if (seed >= 0) { + return seed; + } + std::random_device rd; + return (int64_t) (((uint64_t) rd() << 32) ^ (uint64_t) rd()); +} + +enum qwen_status qwen_synthesize(struct qwen_context * q, + const struct qwen_tts_params * params, + struct qwen_audio * out) { + if (!q || !params || !out) { + qt_set_error("qwen_synthesize : q, params or out is NULL"); + if (out) { + qwen_audio_free(out); + } + return QWEN_STATUS_INVALID_PARAMS; + } + if (params->abi_version > QWEN_ABI_VERSION) { + qt_set_error( + "qwen_synthesize : params->abi_version %d > QWEN_ABI_VERSION %d (binding compiled against a newer header)", + params->abi_version, QWEN_ABI_VERSION); + qwen_audio_free(out); + return QWEN_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()); + qt_log(QT_LOG_ERROR, "[qwen] %s", qt_last_error()); + qwen_audio_free(out); + return QWEN_STATUS_MODE_INVALID; + } + if (params->instruct && mt == "base") { + qt_set_error("--instruct is not supported for base models"); + qt_log(QT_LOG_ERROR, "[qwen] %s", qt_last_error()); + qwen_audio_free(out); + return QWEN_STATUS_MODE_INVALID; + } + if (mt == "custom_voice" && !params->speaker) { + qt_set_error("custom_voice models require --speaker"); + qt_log(QT_LOG_ERROR, "[qwen] %s", qt_last_error()); + qwen_audio_free(out); + return QWEN_STATUS_MODE_INVALID; + } + if (mt == "voice_design" && (!params->instruct || params->instruct[0] == '\0')) { + qt_set_error("voice_design models require --instruct"); + qt_log(QT_LOG_ERROR, "[qwen] %s", qt_last_error()); + qwen_audio_free(out); + return QWEN_STATUS_MODE_INVALID; + } + if (params->ref_audio_24k && mt != "base") { + qt_set_error("--ref-audio is only valid for base models (loaded: %s)", mt.c_str()); + qt_log(QT_LOG_ERROR, "[qwen] %s", qt_last_error()); + qwen_audio_free(out); + return QWEN_STATUS_MODE_INVALID; + } + if (params->speaker && params->ref_audio_24k) { + qt_set_error("--speaker and --ref-audio are mutually exclusive"); + qt_log(QT_LOG_ERROR, "[qwen] %s", qt_last_error()); + qwen_audio_free(out); + return QWEN_STATUS_INVALID_PARAMS; + } + if (params->ref_text && !params->ref_audio_24k) { + qt_set_error("--ref-text requires --ref-audio"); + qt_log(QT_LOG_ERROR, "[qwen] %s", qt_last_error()); + qwen_audio_free(out); + return QWEN_STATUS_INVALID_PARAMS; + } + + // Translate the public POD params into the internal C++ struct + // expected by pipeline_tts_synthesize. Borrowed pointers are + // forwarded verbatim ; the lifetime contract on the public side + // (caller keeps strings alive for the duration of the call) + // matches what the pipeline already requires. + PipelineTTSSynthesizeParams p = {}; + p.text = params->text; + p.lang = params->lang; + p.instruct = params->instruct; + p.speaker = params->speaker; + p.ref_audio_24k = params->ref_audio_24k; + p.ref_n_samples = params->ref_n_samples; + p.ref_text = params->ref_text; + p.seed = qwen_resolve_seed(params->seed); + p.max_new_tokens = params->max_new_tokens; + p.do_sample = params->do_sample; + p.temperature = params->temperature; + p.top_k = params->top_k; + p.top_p = params->top_p; + p.repetition_penalty = params->repetition_penalty; + p.subtalker_do_sample = params->subtalker_do_sample; + p.subtalker_temperature = params->subtalker_temperature; + p.subtalker_top_k = params->subtalker_top_k; + p.subtalker_top_p = params->subtalker_top_p; + p.dump_dir = params->dump_dir; + + // Defense in depth: the synthesis path normally reports failures + // via bool 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 QWEN_STATUS_GENERATE_FAILED so an exception never + // crosses the extern "C" boundary. + try { + PipelineTTSSynthesizeOutput pout; + if (!pipeline_tts_synthesize(&q->pt, &q->tok, p, &pout)) { + qwen_audio_free(out); + return QWEN_STATUS_GENERATE_FAILED; + } + + // Copy the std::vector into a malloc-backed buffer the + // caller can free with std::free via qwen_audio_free. The + // vector itself goes out of scope at function exit, releasing + // its own storage independently. + const size_t n = pout.audio.size(); + const size_t bytes = n * sizeof(float); + float * buf = (float *) std::malloc(bytes > 0 ? bytes : 1); + if (!buf) { + qt_set_error("qwen_synthesize : malloc failed for %zu samples", n); + qt_log(QT_LOG_ERROR, "[qwen] %s", qt_last_error()); + qwen_audio_free(out); + return QWEN_STATUS_OOM; + } + if (n > 0) { + std::memcpy(buf, pout.audio.data(), bytes); + } + out->samples = buf; + out->n_samples = (int) n; + out->sample_rate = pout.sample_rate; + out->channels = 1; + return QWEN_STATUS_OK; + } catch (const std::exception & e) { + qt_set_error("%s", e.what()); + qt_log(QT_LOG_ERROR, "[qwen] %s", e.what()); + qwen_audio_free(out); + return QWEN_STATUS_GENERATE_FAILED; + } +} + +} // extern "C" diff --git a/src/qwen.h b/src/qwen.h new file mode 100644 index 0000000..819722e --- /dev/null +++ b/src/qwen.h @@ -0,0 +1,234 @@ +#pragma once +// qwen.h: public ABI for qwentts.cpp. +// +// Single-header public API. Pure C99, consumable from C and C++ alike. +// Bindings (Python ctypes, Rust bindgen, Go cgo) parse this file directly. +// Style follows whisper.h / llama.h / omnivoice.h: extern "C" linkage on +// every entry, POD structs only, const char * UTF-8 strings, qwen_status +// enum returns. +// +// The opaque qwen_context handle aggregates every module the synthesis +// path needs (Talker LM weights, code predictor MTP head, optional +// speaker encoder, 12 Hz audio tokenizer codec, BPE tokenizer, GGML +// backend pair). One init, one free, one synthesize call covers the +// full TTS path. The lower-level pipeline_tts_* / pipeline_codec_* +// entries declared in pipeline-tts.h / pipeline-codec.h stay available +// for tooling that needs partial init, but they are intentionally not +// part of this public ABI. + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +// Symbol visibility. Three Windows cases: building the SHARED target +// (QWENTTS_BUILD set, dllexport), consuming the SHARED target from +// outside (nothing set, dllimport), consuming the STATIC archive +// (QWENTTS_STATIC set by the static target's INTERFACE definitions, +// empty so the linker resolves the symbol directly without dllimport). +// On GCC/Clang the default-visibility attribute is harmless on static +// builds and required on shared builds. +#if defined(_WIN32) || defined(__CYGWIN__) +# if defined(QWENTTS_STATIC) +# define QWEN_API +# elif defined(QWENTTS_BUILD) +# define QWEN_API __declspec(dllexport) +# else +# define QWEN_API __declspec(dllimport) +# endif +#elif defined(__GNUC__) || defined(__clang__) +# define QWEN_API __attribute__((visibility("default"))) +#else +# define QWEN_API +#endif + +// Struct ABI version. Incremented every time a public POD struct grows a +// new field at the end. Callers fill `.abi_version = QWEN_ABI_VERSION` +// (or let qwen_*_default_params set it). Entries that consume those +// structs reject inputs whose abi_version exceeds the build-time +// constant: this guards a binary built against vN from receiving a +// struct laid out for vN+1 by a freshly compiled binding. Adding fields +// stays backward compat because the new tail is zero init in older +// callers and the lib reads only what its abi_version permits. +// +// There is no separate semver triple. The runtime build identity is the +// git short hash + commit date string returned by qwen_version() ; for +// binding compat checks, QWEN_ABI_VERSION is the only number that +// matters. Aligned on OV_ABI_VERSION = 2 for the omnivoice ABI cousin. +#define QWEN_ABI_VERSION 2 + +// Returns a static string of the form " ()" identifying +// the exact commit this binary was built from. Safe to call from any +// thread, no allocation. Pointer stays valid for the process lifetime. +QWEN_API const char * qwen_version(void); + +// Status code returned by every fallible entry. QWEN_STATUS_OK is always +// zero so `if (rc)` reads as `if (rc != QWEN_STATUS_OK)`. +enum qwen_status { + QWEN_STATUS_OK = 0, + QWEN_STATUS_INVALID_PARAMS = -1, + QWEN_STATUS_MODE_INVALID = -2, + QWEN_STATUS_GENERATE_FAILED = -3, + QWEN_STATUS_OOM = -4, +}; + +// Returns the last error message produced on the calling thread by any +// qwen_* entry, as a NUL terminated UTF-8 string. errno-style semantics: +// the pointer is only meaningful right after a failure (qwen_init +// returning NULL, or any qwen_* entry returning a negative qwen_status) ; +// calling it after a successful entry yields the previous message or an +// empty string. Storage is thread local so two threads running +// qwen_synthesize concurrently never race on each other's diagnostics. +// The pointer stays valid until the next failing qwen_* entry on the +// same thread. +QWEN_API const char * qwen_last_error(void); + +// Output audio buffer. Plain POD: the samples pointer is malloc +// allocated by qwen_synthesize, owned by the struct, released by +// qwen_audio_free. Do not free samples directly nor reassign without +// freeing first. Zero initialise before the first use: +// `struct qwen_audio a = {0};`. +struct qwen_audio { + float * samples; // mono PCM, malloc allocated + int n_samples; // length in samples + int sample_rate; // 24000 for the 12 Hz Qwen3-TTS tokenizer + int channels; // 1 (mono) +}; + +// Release the samples buffer and reset the struct to empty. Safe on a +// zero initialised struct (no double free, no NULL deref). +QWEN_API void qwen_audio_free(struct qwen_audio * a); + +// Opaque handle. Definition lives in qwen.cpp. Use qwen_init / qwen_free. +struct qwen_context; + +// Initialisation parameters. Both GGUF paths are required: the talker +// GGUF holds the LM weights, the code predictor MTP head and (for +// custom_voice / voice_design checkpoints) the speaker encoder ; the +// codec GGUF holds the 12 Hz audio tokenizer. abi_version stays first +// so a future struct growth keeps reading the version field at offset +// 0. No use_fa / clamp_fp16 yet : the current pipeline_tts_load picks +// flash attention from backend capability without a user knob. +struct qwen_init_params { + int abi_version; + const char * talker_path; + const char * codec_path; +}; + +// Initialise to the standard defaults: both paths NULL (caller must set +// them before calling qwen_init), abi_version set to QWEN_ABI_VERSION. +QWEN_API void qwen_init_default_params(struct qwen_init_params * p); + +// Allocate every module described by params. Returns NULL on any +// failure after releasing whatever it has allocated so far. The +// returned handle owns its GGML backend pair and must be released with +// qwen_free. +QWEN_API struct qwen_context * qwen_init(const struct qwen_init_params * params); + +// Release every module owned by the handle and free the handle itself. +// Safe on NULL. +QWEN_API void qwen_free(struct qwen_context * q); + +// Log severity. Numerically ordered so a callback can filter with a +// simple `if (level < threshold) return;`. ERROR is reserved for +// failure reports that the lib also surfaces via qwen_status / +// qwen_last_error ; WARN for recoverable surprises ; INFO for the +// normal load and synthesis cadence ; DEBUG for tensor-level cossim +// diagnostics. +enum qwen_log_level { + QWEN_LOG_DEBUG = 0, + QWEN_LOG_INFO = 1, + QWEN_LOG_WARN = 2, + QWEN_LOG_ERROR = 3, +}; + +// Logging callback. msg is a NUL terminated UTF-8 string already +// formatted by the lib, with no trailing newline (the callback is free +// to add one). user_data is forwarded verbatim from qwen_log_set. +// Called from any thread the lib runs on: the callback must be +// reentrant. +typedef void (*qwen_log_cb)(enum qwen_log_level level, const char * msg, void * user_data); + +// Install a global log callback. Passing cb == NULL restores the +// default behaviour (write to stderr). Safe to call at any point ; +// takes effect immediately on subsequent log emissions across every +// thread. Storage is process wide, not per handle, matching +// whisper_log_set / llama_log_set / ov_log_set. +QWEN_API void qwen_log_set(qwen_log_cb cb, void * user_data); + +// Synthesis parameters. Strings are NULL terminated UTF-8 ; NULL maps +// to empty where the underlying pipeline accepts it. The selection +// between base / custom_voice / voice_design synthesis mode is driven +// by the model_type read from the talker GGUF at qwen_init time, not +// by an explicit flag here ; the seven mode rules are enforced inside +// qwen_synthesize and surface as QWEN_STATUS_MODE_INVALID with a +// descriptive qwen_last_error(). abi_version stays first so the lib +// can route on it before reading any field that may have shifted in a +// future minor. +struct qwen_tts_params { + int abi_version; + + // Input text and language hint. lang accepts the upstream + // qwen3-tts language names ("english", "chinese", "auto", ...). + // instruct is the style instruction string ; required for + // voice_design, optional for custom_voice, rejected for base. + // speaker is the named speaker for custom_voice models, rejected + // for the other two modes. + const char * text; + const char * lang; + const char * instruct; + const char * speaker; + + // Optional voice reference for base mode voice cloning. Mode A + // (x_vector_only) sets ref_audio_24k only ; mode B (ICL) sets + // both ref_audio_24k and ref_text. ref_audio_24k is a mono float + // PCM buffer sampled at the codec sample rate (24 kHz). Mutually + // exclusive with speaker. Rejected for custom_voice / voice_design. + const float * ref_audio_24k; + int ref_n_samples; + const char * ref_text; + + // Sampling configuration. seed == -1 is resolved by qwen_synthesize + // to a hardware random seed via std::random_device, anything else + // is forwarded verbatim for deterministic replay across runs. + // Defaults match the upstream Python reference: do_sample true, + // temperature 0.9, top_k 50, top_p 1.0, repetition_penalty 1.05, + // subtalker mirrors talker, max_new_tokens 2048. + int64_t seed; + int max_new_tokens; + bool do_sample; + float temperature; + int top_k; + float top_p; + float repetition_penalty; + bool subtalker_do_sample; + float subtalker_temperature; + int subtalker_top_k; + float subtalker_top_p; + + // Intermediate tensor dump directory. NULL disables dumps. Debug + // only, slows the run. + const char * dump_dir; +}; + +// Initialise to the standard defaults. Strings NULL, seed -1, +// max_new_tokens 2048, do_sample true, temperature 0.9, top_k 50, +// top_p 1.0, repetition_penalty 1.05, subtalker mirrors talker, +// dump_dir NULL. +QWEN_API void qwen_tts_default_params(struct qwen_tts_params * p); + +// Run the full TTS synthesis. Validates the params against the loaded +// model_type (the seven base / custom_voice / voice_design rules), +// resolves the seed, hands off to pipeline_tts_synthesize and fills +// `out` with mono float PCM at the codec sample rate. Returns +// QWEN_STATUS_OK on success ; on any failure returns a negative +// qwen_status describing the cause and leaves `out` empty. +QWEN_API enum qwen_status qwen_synthesize(struct qwen_context * q, + const struct qwen_tts_params * params, + struct qwen_audio * out); + +#ifdef __cplusplus +} +#endif