Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
95 changes: 94 additions & 1 deletion scripts/build_native.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import argparse
from contextlib import contextmanager
import os
import re
import shutil
import shlex
import subprocess
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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)
Expand Down
70 changes: 67 additions & 3 deletions src/qwentts_cpp/_binding.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,24 +76,53 @@ 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:
"""Keep one native callback alive for each loaded library path."""

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()
Expand Down Expand Up @@ -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] = []
Expand All @@ -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}). "
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand All @@ -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

Expand All @@ -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(
Expand Down Expand Up @@ -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,
*,
Expand Down
62 changes: 61 additions & 1 deletion tests/test_binding.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"])
Expand Down Expand Up @@ -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):
Expand Down
Loading