diff --git a/README.md b/README.md index a5bed5e..0127574 100644 --- a/README.md +++ b/README.md @@ -171,6 +171,38 @@ PY Model files are resolved with `huggingface-hub` by `QwenTTS.from_pretrained(...)` or passed directly to `QwenTTS(...)` as GGUF paths. +## Native logging in Python applications + +Pass `log_level="warning"` when creating a context to hide routine qwentts.cpp +and GGML/Metal messages from model loading and synthesis while keeping warnings +and errors on stderr. `"quiet"` is an alias for `"warning"`; `"info"` and +`"debug"` expose diagnostics, and `"error"` shows errors only. + +```python +from qwentts_cpp import QwenTTS + +with QwenTTS.from_pretrained( + "Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice", + quant="Q4_K_M", + log_level="warning", +) as tts: + audio, sample_rate = tts.synthesize( + text="The sky is blue today.", speaker="Vivian" + ) +``` + +The same `log_level` argument works with `QwenTTS(talker_path, codec_path)` and +`QwenLibrary(...)`. Call `set_log_level("debug")` on either object to expose all +diagnostics later. Configure the level before creating a `QwenTTS` to cover +model initialization. The level and both native callbacks are process-wide: +the most recent setting applies to all contexts, persists after a context +closes, and should be changed only when no other thread is running native +inference. This setting does not redirect the process stderr file descriptor. +Existing `set_log_callback()` receives filtered qwentts.cpp messages; GGML +messages use stderr. The package's wheel builds retain an application-installed +GGML callback during first backend initialization; custom native builds must +use `scripts/build_native.py` for the same behavior. + ## Streaming packet sizes `QwenTTS.stream()` defaults to a **4-frame first packet** (320 ms of mono diff --git a/scripts/build_native.py b/scripts/build_native.py index 7ca6898..9c13b7d 100644 --- a/scripts/build_native.py +++ b/scripts/build_native.py @@ -4,6 +4,7 @@ import argparse from contextlib import contextmanager import os +import re import shutil import shlex import subprocess @@ -47,6 +48,96 @@ def metal_shader_compatibility(source: Path, enabled: bool): shader.write_text(original) +@contextmanager +def native_logging_compatibility(source: Path): + """Preserve an embedding application's GGML callback during backend init. + + The pinned native source replaces it unconditionally on first init. Keep + its existing deduplicating logger when GGML still has the default callback. + Restore the checkout after building; no native source changes are committed. + """ + header = source / "src/backend.h" + original = header.read_text() + old = " ggml_log_set(qt_ggml_log, nullptr);" + new = ( + " // ggml's default callback is exported by this pinned revision.\n" + " ggml_log_callback callback = nullptr;\n" + " void * user_data = nullptr;\n" + " ggml_log_get(&callback, &user_data);\n" + " if (callback == ggml_log_callback_default) {\n" + " ggml_log_set(qt_ggml_log, nullptr);\n" + " }" + ) + if original.count(old) != 1: + raise SystemExit("Native backend logging changed; review the logging compatibility fix for this revision") + declaration = "extern \"C\" void ggml_log_callback_default(enum ggml_log_level, const char *, void *);\n" + marker = "static BackendPair backend_init(const char * label) {" + if original.count(marker) != 1: + raise SystemExit("Native backend layout changed; review the logging compatibility fix for this revision") + try: + header.write_text(original.replace(marker, declaration + marker).replace(old, new)) + yield + finally: + header.write_text(original) + + +@contextmanager +def native_diagnostic_compatibility(source: Path): + """Route the pinned source's remaining direct diagnostics through qt_log. + + These headers predate qwentts.cpp's callback API. Keep severity when + converting them, and restore the checkout once the wheel is built. + """ + 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 = {} + 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", "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) + yield + finally: + for path, original in originals.items(): + path.write_text(original) + + def find_first(root: Path, patterns: list[str]) -> Path | None: for pattern in patterns: matches = sorted(root.rglob(pattern)) @@ -237,7 +328,9 @@ def main() -> int: cmake_args.extend(split_env_args(os.environ.get("QWENTTS_CPP_CMAKE_ARGS"))) cmake_args.extend(args.cmake_arg) - with metal_shader_compatibility(source, args.backend == "metal"): + with (native_logging_compatibility(source), + native_diagnostic_compatibility(source), + metal_shader_compatibility(source, args.backend == "metal")): run(cmake_args) run(["cmake", "--build", str(build_dir), "--target", args.target, "-j", str(args.jobs)]) copy_shared_libraries(build_dir, package_lib_dir) diff --git a/src/qwentts_cpp/_binding.py b/src/qwentts_cpp/_binding.py index fe184f4..70cdcf6 100644 --- a/src/qwentts_cpp/_binding.py +++ b/src/qwentts_cpp/_binding.py @@ -76,6 +76,19 @@ class LibraryNotFoundError(QwenTTSError): ctypes.c_void_p, ) QT_LOG_CB = ctypes.CFUNCTYPE(None, ctypes.c_int, ctypes.c_char_p, ctypes.c_void_p) +GGML_LOG_CB = ctypes.CFUNCTYPE(None, ctypes.c_int, ctypes.c_char_p, ctypes.c_void_p) + +_LOG_LEVELS = {"debug": 0, "info": 1, "warning": 2, "error": 3} +_LOG_ALIASES = {"quiet": "warning", "warn": "warning", "verbose": "debug"} + + +def _normalize_log_level(level: str) -> str: + if not isinstance(level, str): + raise ValueError("log_level must be debug, info, warning, or error") + normalized = _LOG_ALIASES.get(level.lower(), level.lower()) + if normalized not in _LOG_LEVELS: + raise ValueError("log_level must be debug, info, warning, or error") + return normalized class _LogCallbackState: @@ -83,17 +96,33 @@ class _LogCallbackState: def __init__(self) -> None: self.owner: weakref.ReferenceType[QwenLibrary] | None = None + self.level: str | None = None + self.last_ggml_level = 2 self.native_callback = QT_LOG_CB(self._dispatch) + self.ggml_callback = GGML_LOG_CB(self._dispatch_ggml) def _dispatch(self, level: int, message: bytes, _user_data) -> None: owner = self.owner() if self.owner is not None else None handler = owner._log_callback_handler if owner is not None else None + if self.level is not None and int(level) < _LOG_LEVELS[self.level]: + return text = message.decode("utf-8", errors="replace") if message else "" if handler is not None: handler(int(level), text) else: print(text, file=sys.stderr) + def _dispatch_ggml(self, level: int, message: bytes, _user_data) -> None: + # GGML_CONT extends the previous message and has no severity of its own. + if level == 5: + level = self.last_ggml_level + else: + self.last_ggml_level = int(level) + if self.level is not None and level < _LOG_LEVELS[self.level] + 1: + return + if message: + sys.stderr.write(message.decode("utf-8", errors="replace")) + _log_callback_states: dict[Path, _LogCallbackState] = {} _log_callback_lock = threading.RLock() @@ -406,7 +435,9 @@ def find_library(explicit_path: str | os.PathLike[str] | None = None) -> Path: class QwenLibrary: """Thin loader for the `qwentts.cpp` C ABI.""" - def __init__(self, library_path: str | os.PathLike[str] | None = None): + def __init__(self, library_path: str | os.PathLike[str] | None = None, *, log_level: str | None = None): + if log_level is not None: + log_level = _normalize_log_level(log_level) self.path = find_library(library_path) self._dll_dir_handle = None self._dependency_handles: list[ctypes.CDLL] = [] @@ -420,6 +451,8 @@ def __init__(self, library_path: str | os.PathLike[str] | None = None): try: self._validate_native_revision() self._bind() + if log_level is not None: + self.set_log_level(log_level) except AttributeError as exc: raise QwenTTSError( f"Incompatible qwentts.cpp library at {self.path}: missing required C ABI symbol ({exc}). " @@ -546,7 +579,32 @@ def set_log_callback(self, callback) -> None: previous_owner._log_callback_handler = None self._log_callback_handler = callback state.owner = weakref.ref(self) if callback is not None else None - self._lib.qt_log_set(state.native_callback if callback is not None else QT_LOG_CB(), None) + active = callback is not None or state.level is not None + self._lib.qt_log_set(state.native_callback if active else QT_LOG_CB(), None) + + def set_log_level(self, level: str) -> None: + """Filter qwentts.cpp and GGML logs process-wide, including model initialization. + + The most recent configuration wins. Native callbacks stay alive for the + process lifetime; this method does not redirect the stderr descriptor. + """ + normalized = _normalize_log_level(level) + ggml = next((dep for dep in self._dependency_handles if hasattr(dep, "ggml_log_set")), None) + if ggml is None and hasattr(self._lib, "ggml_log_set"): + ggml = self._lib + if ggml is None: + raise QwenTTSError("GGML logging is unavailable: libggml-base does not export ggml_log_set") + ggml.ggml_log_set.argtypes = [GGML_LOG_CB, ctypes.c_void_p] + ggml.ggml_log_set.restype = None + with _log_callback_lock: + path = self.path.resolve() + state = _log_callback_states.get(path) + if state is None: + state = _LogCallbackState() + _log_callback_states[path] = state + state.level = normalized + self._lib.qt_log_set(state.native_callback, None) + ggml.ggml_log_set(state.ggml_callback, None) class QwenTTS: @@ -560,8 +618,9 @@ def __init__( library_path: str | os.PathLike[str] | None = None, use_fa: bool = True, clamp_fp16: bool = False, + log_level: str | None = None, ): - self.library = QwenLibrary(library_path) + 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 @@ -580,6 +639,7 @@ def from_pretrained( library_path: str | os.PathLike[str] | None = None, use_fa: bool = True, clamp_fp16: bool = False, + log_level: str | None = None, ) -> "QwenTTS": from .models import resolve_gguf_paths @@ -595,6 +655,7 @@ def from_pretrained( library_path=library_path, use_fa=use_fa, clamp_fp16=clamp_fp16, + log_level=log_level, ) def _init( @@ -740,6 +801,9 @@ def save_voice_ref( def set_log_callback(self, callback) -> None: self.library.set_log_callback(callback) + def set_log_level(self, level: str) -> None: + self.library.set_log_level(level) + def synthesize( self, *, diff --git a/tests/test_binding.py b/tests/test_binding.py index bc3096d..d10fc46 100644 --- a/tests/test_binding.py +++ b/tests/test_binding.py @@ -5,6 +5,8 @@ import os import threading import weakref +from types import SimpleNamespace +from unittest.mock import Mock import numpy as np import pytest @@ -22,7 +24,7 @@ save_speaker_embedding, save_voice_ref, ) -from qwentts_cpp._binding import QtInitParams, QtTTSParams, QtVoiceRef +from qwentts_cpp._binding import QtInitParams, QtTTSParams, QtVoiceRef, _LogCallbackState @pytest.mark.parametrize("version", [b"unknown", b"abcdef0 (2026-01-01)", b"", None, b"7df"]) @@ -148,6 +150,64 @@ def install(handler): assert native.callback_address is None +def test_quiet_log_level_filters_both_native_sources(capsys): + state = _LogCallbackState() + state.level = "warning" + for level, message in enumerate((b"qt debug", b"qt info", b"qt warning", b"qt error")): + state.native_callback(level, message, None) + for level, message in ((1, b"ggml debug\n"), (2, b"ggml info\n"), + (3, b"ggml warning"), (5, b" continued\n"), + (4, b"ggml error\n")): + state.ggml_callback(level, message, None) + assert capsys.readouterr().err == ( + "qt warning\nqt error\nggml warning continued\nggml error\n" + ) + + state.level = "debug" + state.native_callback(0, b"qt debug", None) + state.ggml_callback(1, b"ggml debug\n", None) + assert capsys.readouterr().err == "qt debug\nggml debug\n" + + +def test_log_level_callbacks_survive_repeated_loaders(tmp_path, capsys): + qt_set = Mock() + ggml_set = Mock() + native = SimpleNamespace(qt_log_set=qt_set) + ggml = SimpleNamespace(ggml_log_set=ggml_set) + path = tmp_path / "libqwen.so" + + def install(level): + loader = QwenLibrary.__new__(QwenLibrary) + loader.path = path + loader._lib = native + loader._dependency_handles = [ggml] + loader._log_callback_handler = None + loader.set_log_level(level) + return loader + + first = install("quiet") + qt_callback = qt_set.call_args.args[0] + ggml_callback = ggml_set.call_args.args[0] + first_ref = weakref.ref(first) + del first + gc.collect() + assert first_ref() is None + qt_callback(1, b"routine", None) + ggml_callback(2, b"routine\n", None) + qt_callback(2, b"warning", None) + ggml_callback(3, b"warning\n", None) + assert capsys.readouterr().err == "warning\nwarning\n" + + second = install("verbose") + assert qt_set.call_args.args[0] is qt_callback + assert ggml_set.call_args.args[0] is ggml_callback + qt_callback(1, b"routine", None) + ggml_callback(2, b"routine\n", None) + assert capsys.readouterr().err == "routine\nroutine\n" + with pytest.raises(ValueError, match="log_level"): + second.set_log_level("silent") + + def test_log_handler_does_not_keep_tts_context_alive(tmp_path): class FakeNativeLibrary: def __init__(self):