From 3a2e4afd3aaf6e15a3d49f13574e02fd1286759a Mon Sep 17 00:00:00 2001 From: Andres Marafioti Date: Wed, 30 Sep 2026 22:37:59 +0200 Subject: [PATCH] Upgrade qwentts.cpp pin and bindings to ABI v5 --- .github/workflows/metal-wheel.yml | 6 +- .github/workflows/publish-hf-wheels.yml | 6 +- .github/workflows/publish.yml | 6 +- .github/workflows/wheels.yml | 8 +- README.md | 27 +++-- scripts/build_native.py | 62 +++-------- src/qwentts_cpp/_binding.py | 77 ++++++++------ tests/test_binding.py | 133 +++++++++++++++++++++++- 8 files changed, 223 insertions(+), 102 deletions(-) diff --git a/.github/workflows/metal-wheel.yml b/.github/workflows/metal-wheel.yml index e0971a5..0c57538 100644 --- a/.github/workflows/metal-wheel.yml +++ b/.github/workflows/metal-wheel.yml @@ -7,7 +7,7 @@ name: Apple Silicon Metal wheel qwentts_ref: description: qwentts.cpp revision (must match the binding's verified ABI) type: string - default: 7df559a8ca25f66fee02970514ebe5f01dee9055 + default: 6fae92914045cd83364d2845ceaa0f7969727319 workflow_call: inputs: pypi: @@ -16,7 +16,7 @@ name: Apple Silicon Metal wheel default: false qwentts_ref: type: string - default: 7df559a8ca25f66fee02970514ebe5f01dee9055 + default: 6fae92914045cd83364d2845ceaa0f7969727319 permissions: contents: read @@ -26,7 +26,7 @@ jobs: runs-on: macos-14 env: MACOSX_DEPLOYMENT_TARGET: "14.0" - QWENTTS_REF: ${{ inputs.qwentts_ref || '7df559a8ca25f66fee02970514ebe5f01dee9055' }} + QWENTTS_REF: ${{ inputs.qwentts_ref || '6fae92914045cd83364d2845ceaa0f7969727319' }} steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: diff --git a/.github/workflows/publish-hf-wheels.yml b/.github/workflows/publish-hf-wheels.yml index a69a041..ce748bd 100644 --- a/.github/workflows/publish-hf-wheels.yml +++ b/.github/workflows/publish-hf-wheels.yml @@ -6,7 +6,7 @@ name: Publish Hugging Face Wheels qwentts_ref: description: qwentts.cpp commit, tag, or branch to build required: true - default: 7df559a8ca25f66fee02970514ebe5f01dee9055 + default: 6fae92914045cd83364d2845ceaa0f7969727319 hf_repo_id: description: Hugging Face dataset repo for wheel hosting required: true @@ -17,13 +17,13 @@ permissions: env: HF_WHEEL_REPO_ID: ${{ inputs.hf_repo_id || vars.HF_WHEEL_REPO_ID || 'andito/qwentts-cpp-python-wheels' }} - QWENTTS_REF: ${{ inputs.qwentts_ref || '7df559a8ca25f66fee02970514ebe5f01dee9055' }} + QWENTTS_REF: ${{ inputs.qwentts_ref || '6fae92914045cd83364d2845ceaa0f7969727319' }} jobs: build-metal-wheel: uses: ./.github/workflows/metal-wheel.yml with: - qwentts_ref: ${{ inputs.qwentts_ref || '7df559a8ca25f66fee02970514ebe5f01dee9055' }} + qwentts_ref: ${{ inputs.qwentts_ref || '6fae92914045cd83364d2845ceaa0f7969727319' }} build-wheels: name: Linux ${{ matrix.arch }} CUDA ${{ matrix.cuda_version }} HF wheel diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index e637e2c..57c03f2 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -9,7 +9,7 @@ name: Publish qwentts_ref: description: qwentts.cpp commit, tag, or branch to build required: true - default: 7df559a8ca25f66fee02970514ebe5f01dee9055 + default: 6fae92914045cd83364d2845ceaa0f7969727319 permissions: contents: read @@ -19,7 +19,7 @@ jobs: uses: ./.github/workflows/metal-wheel.yml with: pypi: true - qwentts_ref: ${{ inputs.qwentts_ref || '7df559a8ca25f66fee02970514ebe5f01dee9055' }} + qwentts_ref: ${{ inputs.qwentts_ref || '6fae92914045cd83364d2845ceaa0f7969727319' }} build-wheels: name: Linux ${{ matrix.arch }} CUDA 12.8 wheel @@ -46,7 +46,7 @@ jobs: QWENTTS_CPP_BACKEND: cuda QWENTTS_CPP_BUILD_JOBS: ${{ matrix.build_jobs }} QWENTTS_CPP_WHEEL_BUILD_TAG: 1cu128 - QWENTTS_REF: ${{ inputs.qwentts_ref || '7df559a8ca25f66fee02970514ebe5f01dee9055' }} + QWENTTS_REF: ${{ inputs.qwentts_ref || '6fae92914045cd83364d2845ceaa0f7969727319' }} CUDA_ARCHITECTURES: ${{ matrix.cuda_architectures }} steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 986e181..d8833de 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -6,13 +6,13 @@ name: qwentts-cpp-python wheels qwentts_ref: description: qwentts.cpp commit, tag, or branch to build required: true - default: 7df559a8ca25f66fee02970514ebe5f01dee9055 + default: 6fae92914045cd83364d2845ceaa0f7969727319 jobs: macos-metal: uses: ./.github/workflows/metal-wheel.yml with: - qwentts_ref: ${{ inputs.qwentts_ref || '7df559a8ca25f66fee02970514ebe5f01dee9055' }} + qwentts_ref: ${{ inputs.qwentts_ref || '6fae92914045cd83364d2845ceaa0f7969727319' }} linux-cuda: name: Linux ${{ matrix.arch }} CUDA ${{ matrix.cuda_version }} wheel @@ -91,7 +91,7 @@ jobs: QWENTTS_CPP_BACKEND: cuda QWENTTS_CPP_BUILD_JOBS: ${{ matrix.build_jobs }} QWENTTS_CPP_WHEEL_BUILD_TAG: ${{ matrix.wheel_build_tag }} - QWENTTS_REF: ${{ inputs.qwentts_ref || '7df559a8ca25f66fee02970514ebe5f01dee9055' }} + QWENTTS_REF: ${{ inputs.qwentts_ref || '6fae92914045cd83364d2845ceaa0f7969727319' }} CUDA_ARCHITECTURES: ${{ matrix.cuda_architectures }} steps: - uses: actions/checkout@v4 @@ -175,7 +175,7 @@ jobs: QWENTTS_CPP_BACKEND: cpu QWENTTS_CPP_BUILD_JOBS: "2" QWENTTS_CPP_WHEEL_BUILD_TAG: 1cpu - QWENTTS_REF: ${{ inputs.qwentts_ref || '7df559a8ca25f66fee02970514ebe5f01dee9055' }} + QWENTTS_REF: ${{ inputs.qwentts_ref || '6fae92914045cd83364d2845ceaa0f7969727319' }} steps: - uses: actions/checkout@v4 diff --git a/README.md b/README.md index 0127574..a3653ca 100644 --- a/README.md +++ b/README.md @@ -143,8 +143,23 @@ python -m twine check --strict wheelhouse/*.whl ### Native ABI compatibility The CI wheel build defaults to qwentts.cpp -`7df559a8ca25f66fee02970514ebe5f01dee9055`, which retains ABI v2 and includes -the latest static-graph, streaming-decode, and widened voice-route changes. +`6fae92914045cd83364d2845ceaa0f7969727319` (September 28, 2026), which requires +ABI v5 and includes the upstream batched compute worker, codec memory +controls, language/model queries, and latest ggml fork update. + +ABI v5 changes the initialization and synthesis struct layouts; libraries +built from the previous ABI v2 pin must be rebuilt. Python's `do_sample=False` +and `subtalker_do_sample=False` now select greedy decoding by setting the +corresponding native temperature to zero. Buffered codec chunk size is set +once with `QwenTTS(..., codec_chunk_sec=24.0)` or +`QwenTTS.from_pretrained(..., codec_chunk_sec=24.0)`, rather than on +`synthesize()`. Zero selects the native default. The codec derives its own +left context; `codec_left_context_sec` is no longer accepted. Streaming's +`codec_chunk_sec` still controls Python output packet size independently. +The wrapper continues to serialize requests on each context (native +`max_batch=1`). `tts.language_names()` lists the model's languages (synthesis +also accepts `lang="auto"`), and `tts.model_type()` reports `base`, +`custom_voice`, or `voice_design`. The loader verifies this native revision before calling functions that write ctypes parameter buffers. Upstream does not expose an ABI-version or struct-size @@ -229,15 +244,15 @@ limit flushes any remaining audio as a short packet, including utterances shorter than the requested first packet. Cancellation or errors discard the unfinished packet; closing the iterator requests native cancellation. -Packet assembly happens in Python, using the existing verified ABI v2 library. +Packet assembly happens in Python, using the verified ABI v5 library. The native decoder still emits its fixed 1→2→4→8-frame ramp, then 8-frame chunks. Consequently, first packets of 1, 2, 4, and 8 frames become available after native output has reached 1, 3, 7, and 15 frames respectively (or earlier at end-of-speech). Packet boundaries preserve every PCM sample but do not change native decode scheduling. Later packets may become available together when a native callback spans several packet boundaries; this is not a timed -playback scheduler. `codec_left_context_sec` is ignored by the stateful native -stream. Buffered `synthesize()` retains its native codec chunking behavior. +playback scheduler. Buffered `synthesize()` uses the native codec chunk size +configured when the context is created; the native codec derives its left context. `last_stream_profile` keeps `first_callback_*` and `callback_count` for raw native callbacks. `first_packet_ready_ms`, `first_packet_audio_s`, and @@ -281,7 +296,7 @@ the larger first packet is assembled by the binding. ## Cached voice references -qwentts.cpp ABI v2 can skip reference WAV encoding for Base voice cloning by +qwentts.cpp can skip reference WAV encoding for Base voice cloning by passing precomputed latents: - `.spk`: raw float32 speaker embedding from `qwen-codec --talker` diff --git a/scripts/build_native.py b/scripts/build_native.py index d14b3de..d04e1df 100644 --- a/scripts/build_native.py +++ b/scripts/build_native.py @@ -4,7 +4,6 @@ import argparse from contextlib import contextmanager import os -import re import shutil import shlex import subprocess @@ -32,7 +31,7 @@ def metal_shader_compatibility(source: Path, enabled: bool): if not enabled: yield return - shader = source / "ggml/src/ggml-metal/ggml-metal.metal" + shader = source / "ggml/src/ggml-metal/kernels/unary.metal" original = shader.read_text() old = "dst_ptr[i0] = (T) args.val;" new = "dst_ptr[i0] = (T) ((TC) args.val);" @@ -83,59 +82,22 @@ def native_logging_compatibility(source: Path): @contextmanager def native_diagnostic_compatibility(source: Path): - """Route the pinned source's remaining direct diagnostics through qt_log. + """Keep a missing tokenizer visible at error log level. - These headers predate qwentts.cpp's callback API. Keep severity when - converting them, and restore the checkout once the wheel is built. + Upstream routes diagnostics through qt_log now, but reports this load + failure at INFO. Restore the checkout after building. """ - expected = { - "audio-io.h": 10, "audio-resample.h": 2, "bpe.h": 9, - "code-predictor-forward.h": 5, "code-predictor-weights.h": 4, - "convnext-block.h": 3, "dac-decoder-v2.h": 2, - "encoder-downsample.h": 2, "encoder-transformer.h": 3, - "gguf-weights.h": 9, "graph-arena.h": 1, "kv-cache.h": 3, - "prompt-builder.h": 13, "quantizer-decode.h": 4, - "quantizer-encode.h": 2, "rvq-file.h": 7, "seanet-encoder.h": 3, - "speaker-encoder-extract.h": 6, "speaker-encoder-weights.h": 3, - "talker-forward.h": 7, "talker-weights.h": 3, - "tokenizer-transformer.h": 3, "wav.h": 7, "weight-ctx.h": 2, - } - pattern = re.compile(r"fprintf\(stderr,\s*(.*?)\);", re.DOTALL) - originals = {} + path = source / "src/bpe.h" + original = path.read_text() + old = 'qt_log(QT_LOG_INFO, "[BPE] Tokenizer not found in %s", gguf_path);' + new = 'qt_log(QT_LOG_ERROR, "[BPE] Tokenizer not found in %s", gguf_path);' + if original.count(old) != 1: + raise SystemExit("Native tokenizer diagnostic changed; review the logging compatibility fix") try: - for name, count in expected.items(): - path = source / "src" / name - original = path.read_text() - if len(pattern.findall(original)) != count or not original.startswith("#pragma once\n"): - raise SystemExit(f"Native diagnostics changed in {name}; review the logging compatibility fix") - - def replace(match): - args = match.group(1) - format_match = re.search(r'"((?:[^"\\]|\\.)*)"', args) - if format_match is None: - raise SystemExit(f"Native diagnostic format changed in {name}") - message = format_match.group(1).lower() - if "warning" in message or "no spk_enc." in message: - level = "QT_LOG_WARN" - elif any(word in message for word in ( - "fatal", "failed", "cannot", "oom", "unsupported", - "not a valid", "not found", "no audio data", "unknown format", - )): - level = "QT_LOG_ERROR" - else: - level = "QT_LOG_INFO" - # qt_log and the Python trampoline each add the line ending. - args = args.replace(r'\n"', '"') - return f"qt_log({level}, {args});" - - transformed = pattern.sub(replace, original) - transformed = transformed.replace("#pragma once\n", '#pragma once\n#include "qt-error.h"\n', 1) - originals[path] = original - path.write_text(transformed) + path.write_text(original.replace(old, new)) yield finally: - for path, original in originals.items(): - path.write_text(original) + path.write_text(original) def find_first(root: Path, patterns: list[str]) -> Path | None: diff --git a/src/qwentts_cpp/_binding.py b/src/qwentts_cpp/_binding.py index 70cdcf6..0ef4b5b 100644 --- a/src/qwentts_cpp/_binding.py +++ b/src/qwentts_cpp/_binding.py @@ -16,10 +16,10 @@ import numpy as np -QT_ABI_VERSION = 2 +QT_ABI_VERSION = 5 # Upstream has no ABI/sizeof query. Check identity before any native function # writes a parameter struct; probing default_params itself is not memory-safe. -QWENTTS_NATIVE_REVISION = "7df559a8ca25f66fee02970514ebe5f01dee9055" +QWENTTS_NATIVE_REVISION = "6fae92914045cd83364d2845ceaa0f7969727319" RVQ_CODE_BITS = 11 CODEC_FRAME_SAMPLES = 1920 # Fixed 12.5 Hz codec at 24 kHz. @@ -144,6 +144,8 @@ class QtInitParams(ctypes.Structure): ("codec_path", ctypes.c_char_p), ("use_fa", ctypes.c_bool), ("clamp_fp16", ctypes.c_bool), + ("max_batch", ctypes.c_int), + ("codec_chunk_sec", ctypes.c_float), ] @@ -159,12 +161,10 @@ class QtTTSParams(ctypes.Structure): ("ref_text", ctypes.c_char_p), ("seed", ctypes.c_int64), ("max_new_tokens", ctypes.c_int), - ("do_sample", ctypes.c_bool), ("temperature", ctypes.c_float), ("top_k", ctypes.c_int), ("top_p", ctypes.c_float), ("repetition_penalty", ctypes.c_float), - ("subtalker_do_sample", ctypes.c_bool), ("subtalker_temperature", ctypes.c_float), ("subtalker_top_k", ctypes.c_int), ("subtalker_top_p", ctypes.c_float), @@ -173,8 +173,6 @@ class QtTTSParams(ctypes.Structure): ("cancel_user_data", ctypes.c_void_p), ("on_chunk", QT_AUDIO_CHUNK_CB), ("on_chunk_user_data", ctypes.c_void_p), - ("codec_chunk_sec", ctypes.c_float), - ("codec_left_context_sec", ctypes.c_float), ("ref_spk_emb", ctypes.POINTER(ctypes.c_float)), ("ref_spk_dim", ctypes.c_int), ("ref_codes", ctypes.POINTER(ctypes.c_int32)), @@ -516,6 +514,12 @@ def _bind(self) -> None: lib.qt_log_set.restype = None lib.qt_duration_sec_to_tokens.argtypes = [ctypes.c_void_p, ctypes.c_float] lib.qt_duration_sec_to_tokens.restype = ctypes.c_int + lib.qt_n_languages.argtypes = [ctypes.c_void_p] + lib.qt_n_languages.restype = ctypes.c_int + lib.qt_language_name.argtypes = [ctypes.c_void_p, ctypes.c_int] + lib.qt_language_name.restype = ctypes.c_char_p + lib.qt_model_type.argtypes = [ctypes.c_void_p] + lib.qt_model_type.restype = ctypes.c_char_p try: lib.qt_num_codebooks.argtypes = [ctypes.c_void_p] lib.qt_num_codebooks.restype = ctypes.c_int @@ -618,15 +622,19 @@ def __init__( library_path: str | os.PathLike[str] | None = None, use_fa: bool = True, clamp_fp16: bool = False, + codec_chunk_sec: float = 24.0, log_level: str | None = None, ): + if not math.isfinite(codec_chunk_sec) or codec_chunk_sec < 0: + raise ValueError("codec_chunk_sec must be finite and nonnegative (0 selects the native default)") self.library = QwenLibrary(library_path, log_level=log_level) self._ctx: int | None = None self._lock = threading.Lock() self.last_synthesize_profile: dict[str, Any] | None = None self.last_stream_profile: dict[str, Any] | None = None self.last_extract_voice_ref_profile: dict[str, Any] | None = None - self._init(talker_path, codec_path, use_fa=use_fa, clamp_fp16=clamp_fp16) + self._init(talker_path, codec_path, use_fa=use_fa, clamp_fp16=clamp_fp16, + codec_chunk_sec=codec_chunk_sec) @classmethod def from_pretrained( @@ -639,6 +647,7 @@ def from_pretrained( library_path: str | os.PathLike[str] | None = None, use_fa: bool = True, clamp_fp16: bool = False, + codec_chunk_sec: float = 24.0, log_level: str | None = None, ) -> "QwenTTS": from .models import resolve_gguf_paths @@ -655,6 +664,7 @@ def from_pretrained( library_path=library_path, use_fa=use_fa, clamp_fp16=clamp_fp16, + codec_chunk_sec=codec_chunk_sec, log_level=log_level, ) @@ -665,14 +675,18 @@ def _init( *, use_fa: bool, clamp_fp16: bool, + codec_chunk_sec: float, ) -> None: keepalive: list[bytes] = [] params = QtInitParams() self.library._lib.qt_init_default_params(ctypes.byref(params)) + if params.abi_version != QT_ABI_VERSION: + raise QwenTTSError(f"Initialization requires qwentts.cpp ABI v{QT_ABI_VERSION}") params.talker_path = _as_utf8(talker_path, keepalive) params.codec_path = _as_utf8(codec_path, keepalive) params.use_fa = bool(use_fa) params.clamp_fp16 = bool(clamp_fp16) + params.codec_chunk_sec = float(codec_chunk_sec) ctx = self.library._lib.qt_init(ctypes.byref(params)) if not ctx: raise QwenTTSError(self.library.last_error()) @@ -705,7 +719,7 @@ def duration_sec_to_tokens(self, seconds: float) -> int: def num_codebooks(self) -> int: if not self.library._has_qt_num_codebooks: - raise QwenTTSError("qt_num_codebooks is unavailable; cached RVQ references require qwentts.cpp ABI v2") + raise QwenTTSError("qt_num_codebooks is unavailable in this qwentts.cpp library") value = int(self.library._lib.qt_num_codebooks(self._require_ctx())) if value <= 0: raise QwenTTSError(self.library.last_error() or "qt_num_codebooks returned 0") @@ -713,7 +727,7 @@ def num_codebooks(self) -> int: def speaker_names(self) -> list[str]: if not (self.library._has_qt_n_speakers and self.library._has_qt_speaker_name): - raise QwenTTSError("Speaker enumeration requires qwentts.cpp ABI v2") + raise QwenTTSError("Speaker enumeration is unavailable in this qwentts.cpp library") count = int(self.library._lib.qt_n_speakers(self._require_ctx())) names: list[str] = [] for i in range(count): @@ -722,6 +736,21 @@ def speaker_names(self) -> list[str]: names.append(value.decode("utf-8", errors="replace")) return names + def language_names(self) -> list[str]: + """Return supported language names; synthesis also accepts ``auto``.""" + ctx = self._require_ctx() + names: list[str] = [] + for i in range(int(self.library._lib.qt_n_languages(ctx))): + value = self.library._lib.qt_language_name(ctx, i) + if value: + names.append(value.decode("utf-8", errors="replace")) + return names + + def model_type(self) -> str: + """Return ``base``, ``custom_voice``, or ``voice_design``.""" + value = self.library._lib.qt_model_type(self._require_ctx()) + return value.decode("utf-8", errors="replace") if value else "" + def load_rvq_codes(self, path: str | os.PathLike[str], *, code_bits: int = RVQ_CODE_BITS) -> np.ndarray: return load_rvq_codes(path, self.num_codebooks(), code_bits=code_bits) @@ -737,7 +766,7 @@ def load_voice_ref( def extract_voice_ref(self, ref_audio_24k: np.ndarray) -> VoiceRef: """Extract reusable Base voice-clone conditioning from 24 kHz mono audio.""" if not (self.library._has_qt_extract_voice_ref and self.library._has_qt_voice_ref_free): - raise QwenTTSError("qt_extract_voice_ref is unavailable; voice reference extraction requires qwentts.cpp ABI v2") + raise QwenTTSError("Voice reference extraction is unavailable in this qwentts.cpp library") profile: dict[str, Any] = {} start = time.perf_counter() @@ -826,8 +855,6 @@ def synthesize( subtalker_temperature: float | None = None, subtalker_top_k: int | None = None, subtalker_top_p: float | None = None, - codec_chunk_sec: float = 24.0, - codec_left_context_sec: float = 2.0, dump_dir: str | os.PathLike[str] | None = None, ) -> Tuple[np.ndarray, int]: profile: dict[str, Any] = {"mode": "buffered"} @@ -853,8 +880,6 @@ def synthesize( subtalker_temperature=subtalker_temperature, subtalker_top_k=subtalker_top_k, subtalker_top_p=subtalker_top_p, - codec_chunk_sec=codec_chunk_sec, - codec_left_context_sec=codec_left_context_sec, dump_dir=dump_dir, ) profile["make_params_ms"] = (time.perf_counter() - params_start) * 1000 @@ -916,7 +941,6 @@ def stream( subtalker_top_k: int | None = None, subtalker_top_p: float | None = None, codec_chunk_sec: float = 0.64, - codec_left_context_sec: float = 2.0, first_chunk_frames: int = 4, dump_dir: str | os.PathLike[str] | None = None, ) -> Iterator[Tuple[np.ndarray, int]]: @@ -928,7 +952,6 @@ def stream( A short final packet flushes on successful EOS. Python assembles packets from the native 1/2/4/8-frame callback ramp; a packet can therefore wait for a native chunk crossing its boundary. - codec_left_context_sec is ignored by the stateful native stream. """ if (isinstance(first_chunk_frames, bool) or not isinstance(first_chunk_frames, numbers.Integral) @@ -944,7 +967,6 @@ def stream( "packet_frames": later_frames, "packet_count": 0, "codec_chunk_sec": float(codec_chunk_sec), - "codec_left_context_sec": float(codec_left_context_sec), "callback_count": 0, "callback_copy_ms_total": 0.0, "callback_queue_ms_total": 0.0, @@ -1026,8 +1048,6 @@ def producer() -> None: subtalker_temperature=subtalker_temperature, subtalker_top_k=subtalker_top_k, subtalker_top_p=subtalker_top_p, - codec_chunk_sec=codec_chunk_sec, - codec_left_context_sec=codec_left_context_sec, dump_dir=dump_dir, ) profile["make_params_ms"] = (time.perf_counter() - params_start) * 1000 @@ -1107,16 +1127,14 @@ def _make_tts_params( subtalker_temperature: float | None, subtalker_top_k: int | None, subtalker_top_p: float | None, - codec_chunk_sec: float, - codec_left_context_sec: float, dump_dir: str | os.PathLike[str] | None, ) -> tuple[QtTTSParams, list[object]]: keepalive: list[object] = [] params = QtTTSParams() self.library._lib.qt_tts_default_params(ctypes.byref(params)) - if (ref_spk_emb is not None or ref_codes is not None) and params.abi_version < QT_ABI_VERSION: - raise QwenTTSError("Cached speaker/RVQ references require qwentts.cpp ABI v2") + if params.abi_version != QT_ABI_VERSION: + raise QwenTTSError(f"Synthesis requires qwentts.cpp ABI v{QT_ABI_VERSION}") if ref_audio_24k is not None and (ref_spk_emb is not None or ref_codes is not None): raise ValueError("ref_audio_24k is mutually exclusive with ref_spk_emb/ref_codes") if ref_codes is not None and ref_spk_emb is None: @@ -1153,17 +1171,18 @@ def _make_tts_params( params.seed = int(seed) params.max_new_tokens = int(max_new_tokens) - params.do_sample = bool(do_sample) - params.temperature = float(temperature) + # ABI v5 uses temperature=0 for greedy decoding on each stack. + params.temperature = float(temperature) if do_sample else 0.0 params.top_k = int(top_k) params.top_p = float(top_p) params.repetition_penalty = float(repetition_penalty) - params.subtalker_do_sample = bool(do_sample if subtalker_do_sample is None else subtalker_do_sample) - params.subtalker_temperature = float(temperature if subtalker_temperature is None else subtalker_temperature) + subtalker_sample = do_sample if subtalker_do_sample is None else subtalker_do_sample + params.subtalker_temperature = ( + float(temperature if subtalker_temperature is None else subtalker_temperature) + if subtalker_sample else 0.0 + ) params.subtalker_top_k = int(top_k if subtalker_top_k is None else subtalker_top_k) params.subtalker_top_p = float(top_p if subtalker_top_p is None else subtalker_top_p) - params.codec_chunk_sec = float(codec_chunk_sec) - params.codec_left_context_sec = float(codec_left_context_sec) return params, keepalive def _prepare_ref_codes(self, ref_codes: np.ndarray) -> tuple[np.ndarray, int]: diff --git a/tests/test_binding.py b/tests/test_binding.py index d10fc46..a0d4813 100644 --- a/tests/test_binding.py +++ b/tests/test_binding.py @@ -24,10 +24,13 @@ save_speaker_embedding, save_voice_ref, ) -from qwentts_cpp._binding import QtInitParams, QtTTSParams, QtVoiceRef, _LogCallbackState +from qwentts_cpp._binding import ( + QT_ABI_VERSION, QWENTTS_NATIVE_REVISION, QtInitParams, QtTTSParams, + QtVoiceRef, _LogCallbackState, +) -@pytest.mark.parametrize("version", [b"unknown", b"abcdef0 (2026-01-01)", b"", None, b"7df"]) +@pytest.mark.parametrize("version", [b"unknown", b"abcdef0 (2026-01-01)", b"", None, b"7df", b"7df559a (2026-07-17)"]) def test_unverified_library_rejected_before_binding(monkeypatch, tmp_path, version): from unittest.mock import Mock @@ -53,7 +56,7 @@ def test_missing_native_symbol_reports_incompatible_library(monkeypatch, tmp_pat QwenLibrary(path) -@pytest.mark.parametrize("revision", ["7df559a", "7df559a8", "7df559a8ca25f66fee02970514ebe5f01dee9055"]) +@pytest.mark.parametrize("revision", [QWENTTS_NATIVE_REVISION[:7], QWENTTS_NATIVE_REVISION[:8], QWENTTS_NATIVE_REVISION]) def test_verified_native_revision_is_accepted(revision): from unittest.mock import Mock @@ -63,6 +66,128 @@ def test_verified_native_revision_is_accepted(revision): library._validate_native_revision() +def test_native_defaults_use_abi_v5_when_available(): + path = os.environ.get("QWENTTS_CPP_LIBRARY") + if not path: + pytest.skip("QWENTTS_CPP_LIBRARY not set") + library = QwenLibrary(path) + init = QtInitParams() + tts = QtTTSParams() + library._lib.qt_init_default_params(ctypes.byref(init)) + library._lib.qt_tts_default_params(ctypes.byref(tts)) + assert init.abi_version == tts.abi_version == QT_ABI_VERSION == 5 + assert init.max_batch == 1 + assert init.codec_chunk_sec == 24.0 + assert tts.temperature == pytest.approx(0.9) + assert tts.subtalker_temperature == pytest.approx(0.9) + + +@pytest.mark.parametrize("chunk_sec", [0.0, 0.64, 24.0]) +def test_init_passes_buffered_codec_chunk_size(monkeypatch, chunk_sec): + def defaults(params): + params._obj.abi_version = QT_ABI_VERSION + params._obj.max_batch = 1 + + def init(params): + params = params._obj + assert params.abi_version == 5 + assert params.max_batch == 1 + assert params.codec_chunk_sec == pytest.approx(chunk_sec) + assert params.talker_path == b"talker.gguf" + assert params.codec_path == b"codec.gguf" + return 123 + + native = SimpleNamespace(qt_init_default_params=defaults, qt_init=init, qt_free=Mock()) + monkeypatch.setattr("qwentts_cpp._binding.QwenLibrary", lambda *args, **kwargs: SimpleNamespace(_lib=native)) + with QwenTTS("talker.gguf", "codec.gguf", codec_chunk_sec=chunk_sec): + pass + native.qt_free.assert_called_once_with(123) + + +@pytest.mark.parametrize("chunk_sec", [-1.0, float("nan"), float("inf")]) +def test_invalid_buffered_chunk_size_rejected_before_loading(monkeypatch, chunk_sec): + loader = Mock() + monkeypatch.setattr("qwentts_cpp._binding.QwenLibrary", loader) + with pytest.raises(ValueError, match="codec_chunk_sec"): + QwenTTS("talker.gguf", "codec.gguf", codec_chunk_sec=chunk_sec) + loader.assert_not_called() + + +def test_from_pretrained_forwards_buffered_chunk_size(monkeypatch): + resolve = Mock(return_value=("talker.gguf", "codec.gguf")) + monkeypatch.setattr("qwentts_cpp.models.resolve_gguf_paths", resolve) + + class CaptureTTS(QwenTTS): + def __init__(self, *args, **kwargs): + self.args, self.kwargs = args, kwargs + + tts = CaptureTTS.from_pretrained("model", codec_chunk_sec=3.2, local_files_only=True) + assert tts.args == ("talker.gguf", "codec.gguf") + assert tts.kwargs["codec_chunk_sec"] == 3.2 + + +@pytest.mark.parametrize("stream", [False, True]) +@pytest.mark.parametrize("do_sample,subtalker_do_sample,talker_temp,subtalker_temp", [ + (True, None, 0.7, 0.3), + (False, None, 0.0, 0.0), + (True, False, 0.7, 0.0), + (False, True, 0.0, 0.3), +]) +def test_sampling_flags_translate_to_native_temperatures( + stream, do_sample, subtalker_do_sample, talker_temp, subtalker_temp, +): + def defaults(params): + params._obj.abi_version = QT_ABI_VERSION + + def synthesize(ctx, params, audio): + params = params._obj + assert params.abi_version == 5 + assert params.temperature == pytest.approx(talker_temp) + assert params.subtalker_temperature == pytest.approx(subtalker_temp) + assert params.top_k == 17 + assert params.subtalker_top_k == 11 + assert params.text == b"test" + assert params.lang == b"auto" + return 0 + + tts = QwenTTS.__new__(QwenTTS) + tts._ctx = 123 + tts._lock = threading.Lock() + tts.library = SimpleNamespace(_lib=SimpleNamespace( + qt_tts_default_params=defaults, qt_synthesize=synthesize, qt_audio_free=Mock(), + qt_free=Mock(), + )) + kwargs = dict(text="test", lang="auto", do_sample=do_sample, + subtalker_do_sample=subtalker_do_sample, temperature=0.7, + subtalker_temperature=0.3, top_k=17, subtalker_top_k=11) + if stream: + assert list(tts.stream(**kwargs)) == [] + else: + audio, rate = tts.synthesize(**kwargs) + assert audio.size == 0 + assert rate == 24000 + + +def test_language_and_model_queries_use_context(): + native = SimpleNamespace( + qt_n_languages=Mock(return_value=2), + qt_language_name=Mock(side_effect=[b"english", b"chinese"]), + qt_model_type=Mock(return_value=b"base"), qt_free=Mock(), + ) + tts = QwenTTS.__new__(QwenTTS) + tts._ctx = 123 + tts.library = SimpleNamespace(_lib=native) + assert tts.language_names() == ["english", "chinese"] + assert tts.model_type() == "base" + native.qt_n_languages.assert_called_once_with(123) + native.qt_model_type.assert_called_once_with(123) + tts.close() + with pytest.raises(QwenTTSError, match="closed"): + tts.language_names() + with pytest.raises(QwenTTSError, match="closed"): + tts.model_type() + + def _pack_rvq_codes(codes, code_bits=11): mask = (1 << code_bits) - 1 total_bits = len(codes) * code_bits @@ -262,7 +387,7 @@ def test_native_log_callback_survives_repeated_init_when_available(): second.set_log_callback(None) -def test_tts_params_contains_abi_v2_latent_tail_fields(): +def test_tts_params_contains_latent_tail_fields(): assert [name for name, _ctype in QtTTSParams._fields_[-4:]] == [ "ref_spk_emb", "ref_spk_dim",