diff --git a/.github/workflows/metal-wheel.yml b/.github/workflows/metal-wheel.yml new file mode 100644 index 0000000..f8bde95 --- /dev/null +++ b/.github/workflows/metal-wheel.yml @@ -0,0 +1,71 @@ +name: Apple Silicon Metal wheel + +"on": + pull_request: + workflow_dispatch: + inputs: + qwentts_ref: + description: qwentts.cpp revision (must match the binding's verified ABI) + type: string + default: 7df559a8ca25f66fee02970514ebe5f01dee9055 + workflow_call: + inputs: + qwentts_ref: + type: string + default: 7df559a8ca25f66fee02970514ebe5f01dee9055 + +permissions: + contents: read + +jobs: + metal: + runs-on: macos-14 + env: + MACOSX_DEPLOYMENT_TARGET: "14.0" + QWENTTS_REF: ${{ inputs.qwentts_ref || '7df559a8ca25f66fee02970514ebe5f01dee9055' }} + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install build and test tools + run: python -m pip install build delocate twine pytest numpy huggingface-hub + - name: Fetch pinned native source + run: | + git init third_party/qwentts.cpp + git -C third_party/qwentts.cpp remote add origin https://github.com/ServeurpersoCom/qwentts.cpp + git -C third_party/qwentts.cpp fetch --depth 1 origin "$QWENTTS_REF" + git -C third_party/qwentts.cpp checkout --detach FETCH_HEAD + git -C third_party/qwentts.cpp submodule update --init --recursive --depth 1 + - name: Build and repair Metal wheel + run: | + test "$(uname -m)" = arm64 + python scripts/set_local_version.py metal + python scripts/build_native.py --backend metal --clean + # setup-python may provide universal2 Python; the bundled dylibs are arm64 only. + python -m build --wheel --config-setting=--build-option=--plat-name=macosx_14_0_arm64 + delocate-wheel --require-archs arm64 -w wheelhouse -v dist/*.whl + python -m twine check --strict wheelhouse/*.whl + - name: Check ctypes layouts against the pinned header + env: + PYTHONPATH: src + QWENTTS_CPP_SOURCE: third_party/qwentts.cpp + QWENTTS_CPP_LIBRARY: ${{ github.workspace }}/src/qwentts_cpp/lib/libqwen.dylib + run: python -m pytest -q tests + - name: Test installed wheel without the source or build tree + run: | + python -m venv "$RUNNER_TEMP/metal-wheel-test" + "$RUNNER_TEMP/metal-wheel-test/bin/python" -m pip install --only-binary=:all: wheelhouse/*.whl + mv build "$RUNNER_TEMP/native-build" + mv third_party "$RUNNER_TEMP/native-source" + mv src/qwentts_cpp/lib "$RUNNER_TEMP/package-libraries" + cd "$RUNNER_TEMP" + env -u QWENTTS_CPP_LIBRARY -u QWEN_LIBRARY_PATH -u DYLD_LIBRARY_PATH \ + "$RUNNER_TEMP/metal-wheel-test/bin/python" -I -c \ + "from qwentts_cpp import QwenLibrary; lib = QwenLibrary(); print(lib.path); print(lib.version())" + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: hf-wheel-metal-macosx-arm64 + path: wheelhouse/*.whl diff --git a/.github/workflows/publish-hf-wheels.yml b/.github/workflows/publish-hf-wheels.yml index 1869723..67f01e4 100644 --- a/.github/workflows/publish-hf-wheels.yml +++ b/.github/workflows/publish-hf-wheels.yml @@ -20,6 +20,11 @@ env: QWENTTS_REF: ${{ inputs.qwentts_ref || '7df559a8ca25f66fee02970514ebe5f01dee9055' }} jobs: + build-metal-wheel: + uses: ./.github/workflows/metal-wheel.yml + with: + qwentts_ref: ${{ inputs.qwentts_ref || '7df559a8ca25f66fee02970514ebe5f01dee9055' }} + build-wheels: name: Linux ${{ matrix.arch }} CUDA ${{ matrix.cuda_version }} HF wheel runs-on: ${{ matrix.runner }} @@ -154,8 +159,10 @@ jobs: - name: Fetch qwentts.cpp run: | - git clone --recursive https://github.com/ServeurpersoCom/qwentts.cpp third_party/qwentts.cpp - git -C third_party/qwentts.cpp checkout "$QWENTTS_REF" + git init third_party/qwentts.cpp + git -C third_party/qwentts.cpp remote add origin https://github.com/ServeurpersoCom/qwentts.cpp + git -C third_party/qwentts.cpp fetch --depth 1 origin "$QWENTTS_REF" + git -C third_party/qwentts.cpp checkout --detach FETCH_HEAD git -C third_party/qwentts.cpp submodule update --init --recursive - name: Build wheel @@ -222,8 +229,10 @@ jobs: - name: Fetch qwentts.cpp run: | - git clone --recursive https://github.com/ServeurpersoCom/qwentts.cpp third_party/qwentts.cpp - git -C third_party/qwentts.cpp checkout "$QWENTTS_REF" + git init third_party/qwentts.cpp + git -C third_party/qwentts.cpp remote add origin https://github.com/ServeurpersoCom/qwentts.cpp + git -C third_party/qwentts.cpp fetch --depth 1 origin "$QWENTTS_REF" + git -C third_party/qwentts.cpp checkout --detach FETCH_HEAD git -C third_party/qwentts.cpp submodule update --init --recursive - name: Build wheel @@ -252,6 +261,7 @@ jobs: needs: - build-wheels - build-cpu-wheels + - build-metal-wheel runs-on: ubuntu-latest steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index ef78c05..7a70b04 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -77,8 +77,10 @@ jobs: - name: Fetch qwentts.cpp run: | - git clone --recursive https://github.com/ServeurpersoCom/qwentts.cpp third_party/qwentts.cpp - git -C third_party/qwentts.cpp checkout "$QWENTTS_REF" + git init third_party/qwentts.cpp + git -C third_party/qwentts.cpp remote add origin https://github.com/ServeurpersoCom/qwentts.cpp + git -C third_party/qwentts.cpp fetch --depth 1 origin "$QWENTTS_REF" + git -C third_party/qwentts.cpp checkout --detach FETCH_HEAD git -C third_party/qwentts.cpp submodule update --init --recursive - name: Build wheel diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 6a996ec..986e181 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -9,6 +9,11 @@ name: qwentts-cpp-python wheels default: 7df559a8ca25f66fee02970514ebe5f01dee9055 jobs: + macos-metal: + uses: ./.github/workflows/metal-wheel.yml + with: + qwentts_ref: ${{ inputs.qwentts_ref || '7df559a8ca25f66fee02970514ebe5f01dee9055' }} + linux-cuda: name: Linux ${{ matrix.arch }} CUDA ${{ matrix.cuda_version }} wheel runs-on: ${{ matrix.runner }} @@ -121,8 +126,10 @@ jobs: - name: Fetch qwentts.cpp run: | - git clone --recursive https://github.com/ServeurpersoCom/qwentts.cpp third_party/qwentts.cpp - git -C third_party/qwentts.cpp checkout "$QWENTTS_REF" + git init third_party/qwentts.cpp + git -C third_party/qwentts.cpp remote add origin https://github.com/ServeurpersoCom/qwentts.cpp + git -C third_party/qwentts.cpp fetch --depth 1 origin "$QWENTTS_REF" + git -C third_party/qwentts.cpp checkout --detach FETCH_HEAD git -C third_party/qwentts.cpp submodule update --init --recursive - name: Build wheel @@ -184,8 +191,10 @@ jobs: - name: Fetch qwentts.cpp run: | - git clone --recursive https://github.com/ServeurpersoCom/qwentts.cpp third_party/qwentts.cpp - git -C third_party/qwentts.cpp checkout "$QWENTTS_REF" + git init third_party/qwentts.cpp + git -C third_party/qwentts.cpp remote add origin https://github.com/ServeurpersoCom/qwentts.cpp + git -C third_party/qwentts.cpp fetch --depth 1 origin "$QWENTTS_REF" + git -C third_party/qwentts.cpp checkout --detach FETCH_HEAD git -C third_party/qwentts.cpp submodule update --init --recursive - name: Build wheel diff --git a/AGENTS.md b/AGENTS.md index 0192686..271d8be 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,11 +23,17 @@ wheels from the pinned qwentts.cpp source. Additional backend-specific wheels can be published to Hugging Face Hub by manually dispatching `.github/workflows/publish-hf-wheels.yml`. That workflow builds local-version variants such as `0.2.0+cpu`, `0.2.0+cu124`, -`0.2.0+cu128`, and `0.2.0+cu130`, prepares static `--find-links` pages, +`0.2.0+cu128`, `0.2.0+cu130`, and `0.3.1+metal`, prepares static `--find-links` pages, creates the public dataset repo if needed, and uploads the wheel index using the `HF_TOKEN` repository secret. Do not upload those local-version variants to PyPI. +The `+metal` flavor is a macOS 14+ arm64 wheel with embedded Metal shaders and +bundled native dylibs. The reusable `.github/workflows/metal-wheel.yml` builds, +repairs, validates ABI layouts, and tests a clean wheel install. It runs for PRs +and is also called by validation and Hugging Face publishing. GPU synthesis is +tested locally with `scripts/smoke_stream.py --require-metal` and local weights. + Pull requests do not run the Linux wheel matrix. Do not run the validation workflow solely as a publishing prerequisite, because the publishing workflows perform fresh builds. @@ -38,8 +44,10 @@ To rebuild against a newer qwentts.cpp revision: 1. Resolve the latest upstream `master` commit to its full SHA. 2. Update every default and event fallback for `QWENTTS_REF` in - `.github/workflows/wheels.yml`, `.github/workflows/publish.yml`, and - `.github/workflows/publish-hf-wheels.yml`. + `.github/workflows/wheels.yml`, `.github/workflows/publish.yml`, + `.github/workflows/publish-hf-wheels.yml`, and `.github/workflows/metal-wheel.yml`. + Update `QWENTTS_NATIVE_REVISION` in `src/qwentts_cpp/_binding.py` and verify + ctypes layouts with `QWENTTS_CPP_SOURCE=/path/to/source python -m pytest tests/test_native_abi.py`. 3. Update the pinned revision and its summary in `README.md`. 4. Open a focused pull request containing the pin and documentation changes. 5. After merging, use the normal publishing paths when a release is intended: diff --git a/README.md b/README.md index 4b3bbdd..0fef687 100644 --- a/README.md +++ b/README.md @@ -29,9 +29,8 @@ python scripts/build_native.py \ QWENTTS_CPP_WHEEL_BUILD_TAG=1cpu python -m build --wheel ``` -`--backend cuda` is the default because `faster-qwen3-tts` is a CUDA-first -package. CPU builds are still useful for development and smoke tests, but they -are not the primary release target. +`--backend metal` is the default on macOS; other platforms default to CUDA. +CPU builds are still useful for development and smoke tests. ## Installation @@ -69,14 +68,82 @@ the same backend flavor. For example, the `cu128` page can host both `manylinux_2_35` wheels for Ubuntu 22.04+ and `manylinux_2_39` wheels for Ubuntu 24.04+. Pip selects the newest compatible wheel for the current machine. -Pull requests do not build the wheel matrix. The PyPI and Hugging Face +Pull requests do not build the Linux wheel matrix. The PyPI and Hugging Face publishing workflows each rebuild fresh wheels from the pinned qwentts.cpp revision; validation artifacts are not reused for publishing. +### Apple Silicon (Metal) + +The Hugging Face publisher builds a `+metal` wheel for **macOS 14 or newer, +arm64 Python 3.10+**. After this change is merged and the **Publish Hugging Face +Wheels** workflow has completed, install it with: + +```bash +python -m pip install --only-binary=qwentts-cpp-python "qwentts-cpp-python==0.3.1+metal" \ + -f https://huggingface.co/datasets/andito/qwentts-cpp-python-wheels/tree/main/whl/metal +python -c "from qwentts_cpp import QwenLibrary; print(QwenLibrary().version())" +``` + +No local CMake build, Homebrew libraries, or explicit `library_path` is needed. +The wheel bundles libqwen and the ggml CPU, Metal, BLAS, and core libraries; +Metal shader source is embedded in the Metal library. Apple frameworks come +from macOS. The build applies a shader-only workaround for the pinned ggml's +invalid scalar-to-BF16-vector fill cast, restoring the source checkout afterward; +the C ABI is unchanged. Use a native arm64 Python, rather than an Intel Python +under Rosetta. +The `+metal` version identifies the backend on Hugging Face; it is never sent to +PyPI and cannot collide with the Linux CUDA or other backend variants. + +Before publication, download the `hf-wheel-metal-macosx-arm64` artifact from +the PR's **Apple Silicon Metal wheel** check, unzip it, and install its `.whl` +with `python -m pip install /path/to/qwentts_cpp_python-0.3.1+metal-*.whl`. +This macOS check also runs for PRs; the Linux matrix remains dispatch-only. +The Hugging Face publisher rebuilds and checks its own Metal wheel. + +Run a streaming smoke test using local GGUF weights (weights are not bundled): + +```bash +python scripts/smoke_stream.py \ + --talker /path/to/qwen-talker-1.7b-base-Q8_0.gguf \ + --codec /path/to/qwen-tokenizer-12hz-Q8_0.gguf \ + --ref-spk /path/to/reference.spk \ + --require-metal --output metal-smoke.wav +``` + +For a CustomVoice talker, replace `--ref-spk` with `--speaker Vivian`; for a +VoiceDesign talker, use `--instruct "A calm, clear voice."`. `--require-metal` +forces the Metal device so the test fails if only CPU execution is available. +The script checks multiple nonempty, finite audio chunks and writes a mono +24 kHz PCM WAV. Listen to it with `afplay metal-smoke.wav`. Hosted macOS CI +checks packaging, ABI layouts, and installed loading; synthesis requires a Mac +with a working Metal device and local weights. + +To build the same wheel locally with the pinned source checkout: + +```bash +python -m pip install build delocate twine +export MACOSX_DEPLOYMENT_TARGET=14.0 +python scripts/set_local_version.py metal # changes local version metadata +python scripts/build_native.py --backend metal --clean +python -m build --wheel --config-setting=--build-option=--plat-name=macosx_14_0_arm64 +delocate-wheel --require-archs arm64 -w wheelhouse -v dist/*.whl +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. +The loader verifies this native revision before calling functions that write +ctypes parameter buffers. Upstream does not expose an ABI-version or struct-size +query, so other revisions (including unknown builds) are rejected with an +actionable error even if they may be compatible. When updating the pin, update +`QWENTTS_NATIVE_REVISION` in the binding and run `tests/test_native_abi.py` with +`QWENTTS_CPP_SOURCE` pointing to the new checkout to verify every struct size +and field offset. An incompatible library fails before model loading. + `QWENTTS_CPP_WHEEL_BUILD_TAG` is useful for local wheelhouses. For public indexes, publish one backend flavor per package/version/platform compatibility tag; otherwise pip has no way to choose between CPU and CUDA binaries. diff --git a/pyproject.toml b/pyproject.toml index fa39ca1..60fe8ac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,10 +33,16 @@ where = ["src"] qwentts_cpp = ["py.typed", "lib/*"] [tool.cibuildwheel] -build = "cp310-manylinux_*" +build = "cp310-manylinux_* cp310-macosx_arm64" skip = "*-musllinux_* *-manylinux_i686" before-build = "python scripts/build_native.py --backend cuda --clean" test-command = "python -c \"from qwentts_cpp import QwenLibrary; print(QwenLibrary().version())\"" [tool.cibuildwheel.linux] repair-wheel-command = "auditwheel repair -w {dest_dir} {wheel}" + +[tool.cibuildwheel.macos] +archs = ["arm64"] +environment = { MACOSX_DEPLOYMENT_TARGET = "14.0" } +before-build = "python scripts/build_native.py --backend metal --clean" +repair-wheel-command = "delocate-wheel --require-archs arm64 -w {dest_dir} -v {wheel}" diff --git a/scripts/build_native.py b/scripts/build_native.py index d2e651f..7ca6898 100644 --- a/scripts/build_native.py +++ b/scripts/build_native.py @@ -2,6 +2,7 @@ from __future__ import annotations import argparse +from contextlib import contextmanager import os import shutil import shlex @@ -19,6 +20,33 @@ def split_env_args(value: str | None) -> list[str]: return shlex.split(value or "") +@contextmanager +def metal_shader_compatibility(source: Path, enabled: bool): + """Work around the pinned ggml's invalid float -> bfloat4 fill cast. + + TC is float for scalar kernels and float4 for vector kernels. Broadcast + through TC before converting to T, as the other unary operations do. + Restore the checkout afterwards; only the embedded shader needs this fix. + """ + if not enabled: + yield + return + shader = source / "ggml/src/ggml-metal/ggml-metal.metal" + original = shader.read_text() + old = "dst_ptr[i0] = (T) args.val;" + new = "dst_ptr[i0] = (T) ((TC) args.val);" + if new in original: + yield + return + if original.count(old) != 1: + raise SystemExit("Metal fill shader changed; review the BF16 compatibility fix for this native revision") + try: + shader.write_text(original.replace(old, new)) + yield + finally: + shader.write_text(original) + + def find_first(root: Path, patterns: list[str]) -> Path | None: for pattern in patterns: matches = sorted(root.rglob(pattern)) @@ -40,6 +68,28 @@ def strip_shared_library(path: Path) -> None: pass +def relocate_macos_libraries(copied: list[Path], build_dir: Path) -> None: + # ggml uses versioned install names, while the package exposes unversioned + # filenames. Resolve every alias before replacing the Mach-O load commands. + destinations = {} + for path in build_dir.rglob("*.dylib"): + for dest in copied: + original = find_first(build_dir, [dest.name]) + if original and path.resolve() == original.resolve(): + destinations[path.name] = dest.name + for path in copied: + dependencies = subprocess.check_output(["otool", "-L", str(path)], text=True) + run(["install_name_tool", "-id", f"@rpath/{path.name}", str(path)]) + for line in dependencies.splitlines()[2:]: + dependency = line.strip().split(" (", 1)[0] + dest_name = destinations.get(Path(dependency).name) + if dest_name: + run(["install_name_tool", "-change", dependency, + f"@loader_path/{dest_name}", str(path)]) + # Changing load commands invalidates arm64's ad-hoc signature. + run(["codesign", "--force", "--sign", "-", str(path)]) + + def copy_shared_libraries(build_dir: Path, package_lib_dir: Path) -> None: package_lib_dir.mkdir(parents=True, exist_ok=True) for path in package_lib_dir.iterdir(): @@ -63,6 +113,8 @@ def copy_shared_libraries(build_dir: Path, package_lib_dir: Path) -> None: ("libqwen.dylib", ["libqwen.dylib"]), ("libggml-base.dylib", ["libggml-base.dylib"]), ("libggml-cpu.dylib", ["libggml-cpu.dylib"]), + ("libggml-metal.dylib", ["libggml-metal.dylib"]), + ("libggml-blas.dylib", ["libggml-blas.dylib"]), ("libggml-cuda.dylib", ["libggml-cuda.dylib"]), ("libggml-vulkan.dylib", ["libggml-vulkan.dylib"]), ("libggml-sycl.dylib", ["libggml-sycl.dylib"]), @@ -98,6 +150,9 @@ def copy_shared_libraries(build_dir: Path, package_lib_dir: Path) -> None: if not any(p.name.startswith(("libqwen", "qwen")) for p in copied): raise SystemExit(f"No qwentts shared library found in {build_dir}") + if sys.platform == "darwin": + relocate_macos_libraries(copied, build_dir) + patchelf = shutil.which("patchelf") if patchelf and sys.platform.startswith("linux"): for path in copied: @@ -122,8 +177,8 @@ def main() -> int: parser.add_argument("--build-dir", default=os.environ.get("QWENTTS_CPP_BUILD_DIR", "build/qwentts-cpp")) parser.add_argument( "--backend", - choices=["cpu", "cuda", "vulkan", "sycl"], - default=os.environ.get("QWENTTS_CPP_BACKEND", "cuda"), + choices=["cpu", "cuda", "metal", "vulkan", "sycl"], + default=os.environ.get("QWENTTS_CPP_BACKEND", "metal" if sys.platform == "darwin" else "cuda"), ) parser.add_argument("--cuda-compiler", default=os.environ.get("CMAKE_CUDA_COMPILER", "/usr/local/cuda/bin/nvcc")) parser.add_argument("--cmake-arg", action="append", default=[], help="Extra CMake configure argument; repeatable") @@ -132,6 +187,8 @@ def main() -> int: parser.add_argument("--skip-build", action="store_true", help="Only copy shared libraries from --build-dir") parser.add_argument("--clean", action="store_true") args = parser.parse_args() + if args.backend == "metal" and sys.platform != "darwin": + parser.error("The Metal backend requires macOS") root = Path(__file__).resolve().parents[1] source = Path(args.source).resolve() @@ -158,12 +215,21 @@ def main() -> int: "-DQWEN_SHARED=ON", "-DCMAKE_BUILD_TYPE=Release", "-DCMAKE_BUILD_RPATH_USE_ORIGIN=ON", - "-DCMAKE_INSTALL_RPATH=$ORIGIN", + f"-DCMAKE_INSTALL_RPATH={'@loader_path' if sys.platform == 'darwin' else '$ORIGIN'}", ] if args.backend == "cpu": - cmake_args.append("-DGGML_BLAS=OFF") + cmake_args.extend(["-DGGML_BLAS=OFF", "-DGGML_METAL=OFF"]) elif args.backend == "cuda": cmake_args.extend(["-DGGML_CUDA=ON", f"-DCMAKE_CUDA_COMPILER={args.cuda_compiler}"]) + elif args.backend == "metal": + cmake_args.extend([ + "-DGGML_METAL=ON", "-DGGML_METAL_EMBED_LIBRARY=ON", + "-DGGML_BLAS=ON", "-DGGML_BLAS_VENDOR=Apple", + "-DGGML_CUDA=OFF", "-DGGML_OPENMP=OFF", "-DGGML_NATIVE=OFF", + "-DCMAKE_OSX_ARCHITECTURES=arm64", + "-DCMAKE_BUILD_WITH_INSTALL_RPATH=ON", + "-DCMAKE_OSX_DEPLOYMENT_TARGET=" + os.environ.get("MACOSX_DEPLOYMENT_TARGET", "14.0"), + ]) elif args.backend == "vulkan": cmake_args.append("-DGGML_VULKAN=ON") elif args.backend == "sycl": @@ -171,8 +237,9 @@ def main() -> int: cmake_args.extend(split_env_args(os.environ.get("QWENTTS_CPP_CMAKE_ARGS"))) cmake_args.extend(args.cmake_arg) - run(cmake_args) - run(["cmake", "--build", str(build_dir), "--target", args.target, "-j", str(args.jobs)]) + with 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) return 0 diff --git a/scripts/smoke_stream.py b/scripts/smoke_stream.py new file mode 100644 index 0000000..ef7a9b5 --- /dev/null +++ b/scripts/smoke_stream.py @@ -0,0 +1,56 @@ +"""Exercise an installed wheel with local GGUF weights and write playable PCM WAV.""" +from __future__ import annotations + +import argparse +import os +from pathlib import Path +import wave + +import numpy as np + +from qwentts_cpp import QwenTTS, load_speaker_embedding + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--talker", type=Path, required=True) + parser.add_argument("--codec", type=Path, required=True) + parser.add_argument("--output", type=Path, default=Path("metal-smoke.wav")) + parser.add_argument("--text", default="Hello from the Apple Silicon Metal wheel.") + parser.add_argument("--speaker", help="Speaker name for a CustomVoice model") + parser.add_argument("--ref-spk", type=Path, help="Local .spk embedding for a Base model") + parser.add_argument("--instruct", help="Style instruction for a VoiceDesign model") + parser.add_argument("--require-metal", action="store_true") + args = parser.parse_args() + if args.require_metal: + # Explicit selection fails if Metal is unavailable, instead of silently + # falling back to CPU. This is the native ggml device name. + os.environ["GGML_BACKEND"] = "MTL0" + chunks = [] + rate = None + with QwenTTS(args.talker, args.codec) as tts: + print(f"Native version: {tts.library.version()}") + for chunk, sample_rate in tts.stream( + text=args.text, speaker=args.speaker, instruct=args.instruct, + ref_spk_emb=load_speaker_embedding(args.ref_spk) if args.ref_spk else None, + seed=42, max_new_tokens=128, codec_chunk_sec=0.5, + ): + assert chunk.size and np.isfinite(chunk).all(), "Invalid streaming audio" + assert rate in (None, sample_rate), "Sample rate changed during streaming" + rate = sample_rate + chunks.append(chunk) + print(f"Chunk {len(chunks)}: {chunk.size} samples at {rate} Hz", flush=True) + assert len(chunks) >= 2, "Expected multiple streaming callbacks" + audio = np.concatenate(chunks) + assert np.max(np.abs(audio)) > 1e-5, "Synthesized audio is silent" + pcm = (np.clip(audio, -1, 1) * 32767).astype(" None: + self._lib.qt_version.argtypes = [] + self._lib.qt_version.restype = ctypes.c_char_p + raw_version = self._lib.qt_version() + version = raw_version.decode("utf-8", errors="replace") if raw_version else "unknown" + revision = version.split(" ", 1)[0] + if not (7 <= len(revision) <= 40 and QWENTTS_NATIVE_REVISION.startswith(revision)): + raise QwenTTSError( + f"Unverified qwentts.cpp ABI at {self.path}: native version {version!r}; " + f"this binding requires revision {QWENTTS_NATIVE_REVISION} (ABI v{QT_ABI_VERSION}). " + "Install a matching wheel or rebuild that revision. Upstream provides no safe ABI query." + ) def _load_cdll(self, path: Path) -> ctypes.CDLL: mode = getattr(ctypes, "RTLD_GLOBAL", 0) diff --git a/tests/test_binding.py b/tests/test_binding.py index 8226309..bc3096d 100644 --- a/tests/test_binding.py +++ b/tests/test_binding.py @@ -13,6 +13,7 @@ LibraryNotFoundError, QwenLibrary, QwenTTS, + QwenTTSError, VoiceRef, load_rvq_codes, load_speaker_embedding, @@ -24,6 +25,42 @@ from qwentts_cpp._binding import QtInitParams, QtTTSParams, QtVoiceRef +@pytest.mark.parametrize("version", [b"unknown", b"abcdef0 (2026-01-01)", b"", None, b"7df"]) +def test_unverified_library_rejected_before_binding(monkeypatch, tmp_path, version): + from unittest.mock import Mock + + path = tmp_path / "libqwen.dylib" + path.touch() + native = Mock() + native.qt_version.return_value = version + monkeypatch.setattr(QwenLibrary, "_load_cdll", lambda self, path: native) + bind = Mock() + monkeypatch.setattr(QwenLibrary, "_bind", bind) + with pytest.raises(QwenTTSError, match="Unverified qwentts.cpp ABI"): + QwenLibrary(path) + bind.assert_not_called() + native.qt_init_default_params.assert_not_called() + native.qt_tts_default_params.assert_not_called() + + +def test_missing_native_symbol_reports_incompatible_library(monkeypatch, tmp_path): + path = tmp_path / "libqwen.dylib" + path.touch() + monkeypatch.setattr(QwenLibrary, "_load_cdll", lambda self, path: object()) + with pytest.raises(QwenTTSError, match="missing required C ABI symbol"): + QwenLibrary(path) + + +@pytest.mark.parametrize("revision", ["7df559a", "7df559a8", "7df559a8ca25f66fee02970514ebe5f01dee9055"]) +def test_verified_native_revision_is_accepted(revision): + from unittest.mock import Mock + + library = QwenLibrary.__new__(QwenLibrary) + library._lib = Mock() + library._lib.qt_version.return_value = f"{revision} (2026-05-01)".encode() + library._validate_native_revision() + + def _pack_rvq_codes(codes, code_bits=11): mask = (1 << code_bits) - 1 total_bits = len(codes) * code_bits diff --git a/tests/test_native_abi.py b/tests/test_native_abi.py new file mode 100644 index 0000000..23f5245 --- /dev/null +++ b/tests/test_native_abi.py @@ -0,0 +1,40 @@ +"""Compare ctypes sizes and every field offset with the actual native header.""" +import ctypes +import os +from pathlib import Path +import shutil +import subprocess + +import pytest + +from qwentts_cpp._binding import QT_ABI_VERSION, QtAudio, QtInitParams, QtTTSParams, QtVoiceRef + + +def test_native_header_layout(tmp_path): + source = os.environ.get("QWENTTS_CPP_SOURCE") + if not source: + pytest.skip("QWENTTS_CPP_SOURCE not set") + compiler = shutil.which("cc") + assert compiler, "A C compiler is required to validate the native ABI" + structs = { + "qt_audio": QtAudio, + "qt_init_params": QtInitParams, + "qt_tts_params": QtTTSParams, + "qt_voice_ref": QtVoiceRef, + } + statements = ['printf("%d\\n", QT_ABI_VERSION);'] + expected = [QT_ABI_VERSION] + for name, cls in structs.items(): + statements.append(f'printf("%zu\\n", sizeof(struct {name}));') + expected.append(ctypes.sizeof(cls)) + for field, _ in cls._fields_: + statements.append(f'printf("%zu\\n", offsetof(struct {name}, {field}));') + expected.append(getattr(cls, field).offset) + probe = tmp_path / "abi.c" + probe.write_text('#include \n#include \n#include "qwen.h"\n' + + 'int main(void) {\n' + '\n'.join(statements) + '\nreturn 0;\n}\n') + executable = tmp_path / "abi" + subprocess.run([compiler, "-std=c99", "-I", str(Path(source).resolve() / "src"), + str(probe), "-o", str(executable)], check=True) + actual = subprocess.check_output([str(executable)], text=True) + assert [int(value) for value in actual.splitlines()] == expected