From c75bd6113a2266c2d7d0cbe0b9a54b3618c38aeb Mon Sep 17 00:00:00 2001 From: Mitchell Sayre Date: Sat, 9 May 2026 18:49:40 -0400 Subject: [PATCH 1/8] Init --- .github/workflows/wheels.yml | 68 +++++++ .gitignore | 222 +++++++++++++++++++++++ CONTRIBUTING.md | 155 ++++++++++++++++ README.md | 66 ++++--- out.wav | Bin 25132 -> 0 bytes pyproject.toml | 5 + python/wfloat/__init__.py | 77 +++++--- python/wfloat/__main__.py | 5 + python/wfloat/_assets.py | 124 +++++++++++++ python/wfloat/_cache.py | 235 +++++++++++++++++++++++++ python/wfloat/_cli.py | 74 ++++++++ python/wfloat/_constants.py | 147 ++++++++++++++++ python/wfloat/_download.py | 130 ++++++++++++++ python/wfloat/_model.py | 332 +++++++++++++++++++++++++++++++++++ python/wfloat/_native.py | 48 +++++ python/wfloat/_results.py | 90 ++++++++++ python/wfloat/_version.py | 1 + setup.py | 43 ++--- tests/test_basic.py | 291 ++++++++++++++++++++++-------- 19 files changed, 1970 insertions(+), 143 deletions(-) create mode 100644 .github/workflows/wheels.yml create mode 100644 CONTRIBUTING.md delete mode 100644 out.wav create mode 100644 python/wfloat/__main__.py create mode 100644 python/wfloat/_assets.py create mode 100644 python/wfloat/_cache.py create mode 100644 python/wfloat/_cli.py create mode 100644 python/wfloat/_constants.py create mode 100644 python/wfloat/_download.py create mode 100644 python/wfloat/_model.py create mode 100644 python/wfloat/_native.py create mode 100644 python/wfloat/_results.py create mode 100644 python/wfloat/_version.py diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml new file mode 100644 index 0000000..66865ee --- /dev/null +++ b/.github/workflows/wheels.yml @@ -0,0 +1,68 @@ +name: Wheels + +on: + workflow_dispatch: + pull_request: + push: + branches: + - main + tags: + - "v*" + +permissions: + contents: read + +env: + SHERPA_ONNX_REF: cc9366cb03e06ebcc19a7f3cdfe4c23bf219ee0b + +jobs: + build: + name: ${{ matrix.name }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - name: linux-x86_64 + os: ubuntu-latest + - name: windows-x86_64 + os: windows-latest + - name: macos-x86_64 + os: macos-15-intel + - name: macos-arm64 + os: macos-15 + + steps: + - name: Check out wfloat-python + uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Check out sherpa-onnx + uses: actions/checkout@v6 + with: + repository: wfloat/sherpa-onnx + ref: ${{ env.SHERPA_ONNX_REF }} + path: sherpa-onnx + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.13" + + - name: Install cibuildwheel + run: python -m pip install --upgrade pip cibuildwheel==3.4.1 + + - name: Build wheels + env: + CIBW_ENVIRONMENT_PASS_LINUX: WFLOAT_SHERPA_ONNX_SOURCE_DIR MACOSX_DEPLOYMENT_TARGET + MACOSX_DEPLOYMENT_TARGET: "11.0" + WFLOAT_SHERPA_ONNX_SOURCE_DIR: ${{ runner.os == 'Linux' && '/project/sherpa-onnx' || format('{0}/sherpa-onnx', github.workspace) }} + run: python -m cibuildwheel --output-dir wheelhouse + + - name: Upload wheels + uses: actions/upload-artifact@v4 + with: + name: wheels-${{ matrix.name }} + path: wheelhouse/*.whl diff --git a/.gitignore b/.gitignore index 205d757..89e3c79 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .venv*/ +build-*/ __pycache__/ build/ @@ -9,3 +10,224 @@ python/wfloat/lib/*.pyd python/wfloat/lib/*.dll python/wfloat/lib/*.dylib python/wfloat/lib/*.lib + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[codz] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py.cover +*.lcov +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +# Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +# poetry.lock +# poetry.toml + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. +# https://pdm-project.org/en/latest/usage/project/#working-with-version-control +# pdm.lock +# pdm.toml +.pdm-python +.pdm-build/ + +# pixi +# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. +# pixi.lock +# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one +# in the .venv directory. It is recommended not to include this directory in version control. +.pixi/* +!.pixi/config.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule* +celerybeat.pid + +# Redis +*.rdb +*.aof +*.pid + +# RabbitMQ +mnesia/ +rabbitmq/ +rabbitmq-data/ + +# ActiveMQ +activemq-data/ + +# SageMath parsed files +*.sage.py + +# Environments +.env +.envrc +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +# .idea/ + +# Abstra +# Abstra is an AI-powered process automation framework. +# Ignore directories containing user credentials, local state, and settings. +# Learn more at https://abstra.io/docs +.abstra/ + +# Visual Studio Code +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore +# and can be added to the global gitignore or merged into this file. However, if you prefer, +# you could uncomment the following to ignore the entire vscode folder +# .vscode/ +# Temporary file for partial code execution +tempCodeRunnerFile.py + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +# Marimo +marimo/_static/ +marimo/_lsp/ +__marimo__/ + +# Streamlit +.streamlit/secrets.toml diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..0698936 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,155 @@ +# Contributing + +This package wraps a native `sherpa-onnx` build, so building and publishing it +is a little different from a pure-Python package. + +## Prerequisites + +You will need: + +- Python 3.8+ +- `cmake` +- a working C/C++ toolchain +- the `sherpa-onnx` source tree available locally + +By default, `setup.py` expects the source tree to live at: + +```text +../../sherpa-onnx +``` + +If your checkout lives somewhere else, set: + +```bash +export WFLOAT_SHERPA_ONNX_SOURCE_DIR=/absolute/path/to/sherpa-onnx +``` + +## Local Setup + +Create a virtualenv and install the packaging tools: + +```bash +python3 -m venv .venv +source .venv/bin/activate +python -m pip install --upgrade pip +python -m pip install setuptools wheel build twine ninja +``` + +`ninja` is recommended for faster native builds, but it is not required. + +## Build For Local Development + +If you just want to compile the native extension locally, run: + +```bash +python setup.py build_ext --build-lib ./build-out --build-temp ./build-temp +``` + +This produces a local install tree under `build-out/` and temporary native build +files under `build-temp/`. + +These directories are generated artifacts and should not be committed. + +## Build Release Artifacts + +For release builds, set `WFLOAT_SHERPA_ONNX_SOURCE_DIR` to an absolute path. +This package's wheel build compiles native code from the sibling +`sherpa-onnx` checkout, and build tools may run parts of the build from a +temporary directory where the default relative path no longer works. + +```bash +export WFLOAT_SHERPA_ONNX_SOURCE_DIR=/absolute/path/to/sherpa-onnx +``` + +To build a release wheel: + +```bash +rm -rf build dist +python -m build --wheel --no-isolation +``` + +That should produce: + +- `dist/*.whl` + +The package version comes from: + +```text +python/wfloat/_version.py +``` + +Update that file before cutting a new PyPI release. + +## CI Wheels + +Multi-platform wheels are built in GitHub Actions with `cibuildwheel` via: + +```text +.github/workflows/wheels.yml +``` + +That workflow checks out `wfloat-python`, checks out `wfloat/sherpa-onnx` as a +workspace sibling, and builds wheel artifacts for: + +- Linux x86_64 +- Windows x86_64 +- macOS x86_64 +- macOS arm64 + +The `sherpa-onnx` ref is currently controlled in the workflow file with the +`SHERPA_ONNX_REF` environment variable. It is pinned to a specific commit so CI +rebuilds against a stable native dependency. + +The CI workflow intentionally builds wheels only. We should not publish an +sdist until the source-distribution story no longer depends on an external +`sherpa-onnx` checkout. + +## Publish To PyPI + +1. Build a fresh wheel: + +```bash +rm -rf build dist +python -m build --wheel --no-isolation +``` + +2. Check the generated wheel: + +```bash +python -m twine check dist/* +``` + +3. Upload to PyPI: + +```bash +python -m twine upload dist/* +``` + +If you want to dry-run the process first, upload to TestPyPI: + +```bash +python -m twine upload --repository testpypi dist/* +``` + +## Notes + +- The package publishes as `wfloat` on PyPI. +- The CLI entrypoint is also `wfloat`. +- Native builds may download third-party dependencies during the CMake step if + they are not already available locally. +- `python -m build` without `WFLOAT_SHERPA_ONNX_SOURCE_DIR` may fail because + the wheel build can run from a temporary unpacked source tree. +- We currently publish wheels, not a self-contained source distribution. The + source distribution path should only be documented after it no longer depends + on an external sibling `sherpa-onnx` checkout. +- Release artifacts should be built from a clean tree so generated files do not + get mixed into the package contents. + +## Sanity Checks + +Before publishing, it is worth running: + +```bash +PYTHONPATH=python python3 -m unittest discover -s tests -v +python -m wfloat --help +``` diff --git a/README.md b/README.md index 56b64fd..e0a2725 100644 --- a/README.md +++ b/README.md @@ -1,34 +1,56 @@ # wfloat -Low-level Python bindings for Wfloat TTS built from the modified `sherpa-onnx` -source in this monorepo. +`wfloat` is the Python package for loading Wfloat-compatible speech models and +generating audio files with `sherpa-onnx` under the hood. -This package intentionally exposes the native binding surface directly. Model -download, filesystem cache management, and higher-level client APIs are out of -scope for this package skeleton. +The intended high-level flow is: -## Scope +```python +import wfloat -- Native `OfflineTts` binding for Wfloat TTS -- Low-level config objects -- Text preparation helpers -- Wave writing helpers +model = wfloat.load("wfloat/wfloat-tts") -## Build Notes +result = model.generate( + text="The signal is clean. Start the recording.", + voice_id="narrator_woman", + emotion="neutral", + intensity=0.5, + speed=1.0, +) -This package builds against the sibling `sherpa-onnx` source tree in this repo. -It is meant to produce platform-specific wheels that bundle the compiled native -extension inside the `wfloat` package. +result.audio.save("out.wav") -```bash -python3.12 -m venv .venv -source .venv/bin/activate -python -m pip install --upgrade pip setuptools wheel build cmake ninja +print(result.audio.sample_rate) +print(result.timeline.chunks[0].start_sec) +``` + +## Status + +The package now contains the high-level Python API shape: + +- `wfloat.load(...)` +- `model.generate(...)` +- `model.generate_dialogue(...)` +- `result.audio.save(...)` +- timing metadata via `result.timeline` -# optional override: export WFLOAT_SHERPA_ONNX_SOURCE_DIR=/some/other/sherpa-onnx -python -m build --wheel +The Python model asset endpoint still needs to return Python-compatible asset +metadata for live downloads to work end to end. -# Install the built wheel into the same venv for testing: -python -m pip install dist/*.whl +## CLI + +The package also exposes a `wfloat` CLI: + +```bash +wfloat synth --text "Hello world." --out out.wav ``` +## Notes + +- The package is designed to cache model assets locally. +- Voice IDs can be passed as strings such as `narrator_woman` or as numeric + speaker IDs. +- The public API is intentionally high-level; low-level native config objects + are not the primary integration surface. + +More implementation detail lives in [DESIGN.md](./DESIGN.md). diff --git a/out.wav b/out.wav deleted file mode 100644 index 51c05d7b687d1051fb9d66da38dc4bbe1e62d40b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 25132 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 diff --git a/pyproject.toml b/pyproject.toml index c2d608d..150774d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,3 +2,8 @@ requires = ["setuptools>=68", "wheel"] build-backend = "setuptools.build_meta" +[tool.cibuildwheel] +build = "cp38-* cp39-* cp310-* cp311-* cp312-* cp313-*" +skip = "pp* *-win32 *-manylinux_i686 *-musllinux_*" +build-frontend = { name = "pip", args = ["--no-build-isolation"] } +test-command = 'python -c "from wfloat import version, write_wave; print(version)"' diff --git a/python/wfloat/__init__.py b/python/wfloat/__init__.py index 3478b1e..08aa229 100644 --- a/python/wfloat/__init__.py +++ b/python/wfloat/__init__.py @@ -1,30 +1,55 @@ -from ._bindings import ( - GeneratedAudio, - GenerationConfig, - OfflineTts, - OfflineTtsConfig, - OfflineTtsModelConfig, - OfflineTtsWfloatModelConfig, - WfloatPreparedText, - git_date, - git_sha1, - prepare_wfloat_text, - version, - write_wave, -) +from ._constants import SPEAKER_IDS, VALID_EMOTIONS, VALID_SIDS +from ._model import Model, load +from ._results import Audio, GenerationResult, Timeline, TimelineChunk +from ._version import __version__ + +_LOW_LEVEL_IMPORT_ERROR = None + +try: + from ._bindings import ( + GeneratedAudio, + GenerationConfig, + OfflineTts, + OfflineTtsConfig, + OfflineTtsModelConfig, + OfflineTtsWfloatModelConfig, + WfloatPreparedText, + git_date, + git_sha1, + prepare_wfloat_text, + version, + write_wave, + ) +except ImportError as exc: + _LOW_LEVEL_IMPORT_ERROR = exc + __all__ = [ - "GeneratedAudio", - "GenerationConfig", - "OfflineTts", - "OfflineTtsConfig", - "OfflineTtsModelConfig", - "OfflineTtsWfloatModelConfig", - "WfloatPreparedText", - "git_date", - "git_sha1", - "prepare_wfloat_text", - "version", - "write_wave", + "Audio", + "GenerationResult", + "Model", + "SPEAKER_IDS", + "Timeline", + "TimelineChunk", + "VALID_EMOTIONS", + "VALID_SIDS", + "load", ] +if _LOW_LEVEL_IMPORT_ERROR is None: + __all__.extend( + [ + "GeneratedAudio", + "GenerationConfig", + "OfflineTts", + "OfflineTtsConfig", + "OfflineTtsModelConfig", + "OfflineTtsWfloatModelConfig", + "WfloatPreparedText", + "git_date", + "git_sha1", + "prepare_wfloat_text", + "version", + "write_wave", + ] + ) diff --git a/python/wfloat/__main__.py b/python/wfloat/__main__.py new file mode 100644 index 0000000..7f52345 --- /dev/null +++ b/python/wfloat/__main__.py @@ -0,0 +1,5 @@ +from ._cli import main + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(main()) diff --git a/python/wfloat/_assets.py b/python/wfloat/_assets.py new file mode 100644 index 0000000..e872006 --- /dev/null +++ b/python/wfloat/_assets.py @@ -0,0 +1,124 @@ +import json +import os +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, Optional +from urllib.parse import urlencode, urlparse +from urllib.request import Request, urlopen + +from ._version import __version__ as PACKAGE_VERSION + + +DEFAULT_MODEL_ASSET_HOST = "https://wfloat.com" +DEFAULT_MODEL_ASSET_PATH = "/api/model-assets" + + +@dataclass(frozen=True) +class ModelAssets: + model_onnx: str + model_onnx_checksum: str + model_tokens: str + model_tokens_checksum: str + espeak_data: str + espeak_checksum: str + persistent_id: Optional[str] = None + + @classmethod + def from_dict(cls, data: Dict[str, object]) -> "ModelAssets": + required_fields = ( + "model_onnx", + "model_onnx_checksum", + "model_tokens", + "model_tokens_checksum", + "espeak_data", + "espeak_checksum", + ) + + missing = [ + field_name + for field_name in required_fields + if not isinstance(data.get(field_name), str) or not str(data.get(field_name)).strip() + ] + if missing: + raise ValueError( + "Model asset response is missing required fields: %s" + % ", ".join(missing) + ) + + return cls( + model_onnx=str(data["model_onnx"]), + model_onnx_checksum=str(data["model_onnx_checksum"]), + model_tokens=str(data["model_tokens"]), + model_tokens_checksum=str(data["model_tokens_checksum"]), + espeak_data=str(data["espeak_data"]), + espeak_checksum=str(data["espeak_checksum"]), + persistent_id=str(data["persistent_id"]).strip() + if isinstance(data.get("persistent_id"), str) and str(data.get("persistent_id")).strip() + else None, + ) + + def to_dict(self) -> Dict[str, str]: + return { + "model_onnx": self.model_onnx, + "model_onnx_checksum": self.model_onnx_checksum, + "model_tokens": self.model_tokens, + "model_tokens_checksum": self.model_tokens_checksum, + "espeak_data": self.espeak_data, + "espeak_checksum": self.espeak_checksum, + **({"persistent_id": self.persistent_id} if self.persistent_id else {}), + } + + +def get_package_version(default: str = "0.0.0") -> str: + return PACKAGE_VERSION or default + + +def get_model_asset_host() -> str: + return os.environ.get("WFLOAT_MODEL_ASSET_HOST", DEFAULT_MODEL_ASSET_HOST).rstrip("/") + + +def filename_from_url(url: str, fallback: str) -> str: + parsed = urlparse(url) + filename = Path(parsed.path).name + return filename or fallback + + +def fetch_model_assets( + model_name: str, + *, + persistent_id: Optional[str] = None, + package_version_override: Optional[str] = None, + timeout: float = 60.0, +) -> ModelAssets: + version = package_version_override or get_package_version() + query = { + "platform": "python", + "version": version, + "model_name": model_name, + } + if persistent_id: + query["persistent_id"] = persistent_id + + params = urlencode(query) + url = "%s%s?%s" % (get_model_asset_host(), DEFAULT_MODEL_ASSET_PATH, params) + request = Request( + url, + headers={ + "Accept": "application/json", + "User-Agent": "wfloat-python/%s" % version, + }, + method="GET", + ) + + with urlopen(request, timeout=timeout) as response: + payload = response.read().decode("utf-8") + + try: + data = json.loads(payload) + except json.JSONDecodeError as exc: + raise RuntimeError("Failed to decode model asset response JSON.") from exc + + if not isinstance(data, dict): + raise RuntimeError("Model asset response must be a JSON object.") + + return ModelAssets.from_dict(data) diff --git a/python/wfloat/_cache.py b/python/wfloat/_cache.py new file mode 100644 index 0000000..2135650 --- /dev/null +++ b/python/wfloat/_cache.py @@ -0,0 +1,235 @@ +import json +import os +import shutil +import tempfile +import uuid +from dataclasses import dataclass +from pathlib import Path +from typing import Optional + +from ._assets import ModelAssets, filename_from_url +from ._download import ( + download_file, + extract_archive, + normalize_checksum, + resolve_extracted_data_directory, + verify_checksum, +) + + +@dataclass(frozen=True) +class CachedModelAssets: + model_name: str + cache_dir: Path + model_path: Path + tokens_path: Path + espeak_data_dir: Path + manifest_path: Path + + +def get_default_cache_dir() -> Path: + if os.name == "nt": + local_appdata = os.environ.get("LOCALAPPDATA") + if local_appdata: + return Path(local_appdata) / "wfloat" / "Cache" + return Path.home() / "AppData" / "Local" / "wfloat" / "Cache" + + if sys_platform_startswith("darwin"): + return Path.home() / "Library" / "Caches" / "wfloat" + + xdg_cache_home = os.environ.get("XDG_CACHE_HOME") + if xdg_cache_home: + return Path(xdg_cache_home) / "wfloat" + + return Path.home() / ".cache" / "wfloat" + + +def sys_platform_startswith(prefix: str) -> bool: + import sys + + return sys.platform.startswith(prefix) + + +def normalize_model_name(model_name: str) -> str: + normalized = model_name.strip().replace("\\", "/") + normalized = normalized.replace("/", "--") + normalized = normalized.replace(" ", "-") + return normalized + + +def _ensure_directory(path: Path) -> None: + path.mkdir(parents=True, exist_ok=True) + + +def get_persistent_id_path(cache_dir: Optional[Path] = None) -> Path: + cache_root = Path(cache_dir) if cache_dir is not None else get_default_cache_dir() + return cache_root / "persistent_id" + + +def load_persistent_id(cache_dir: Optional[Path] = None) -> Optional[str]: + path = get_persistent_id_path(cache_dir) + if not path.is_file(): + return None + + try: + value = path.read_text(encoding="utf-8").strip() + except OSError: + return None + + return value or None + + +def save_persistent_id(persistent_id: Optional[str], cache_dir: Optional[Path] = None) -> None: + if not persistent_id: + return + + path = get_persistent_id_path(cache_dir) + _ensure_directory(path.parent) + path.write_text(persistent_id.strip() + "\n", encoding="utf-8") + + +def _cleanup_stale_model_files(model_dir: Path, active_names) -> None: + if not model_dir.exists(): + return + + for child in model_dir.iterdir(): + if child.name in active_names: + continue + if child.is_file(): + child.unlink() + + +def _write_manifest(manifest_path: Path, model_name: str, assets: ModelAssets) -> None: + manifest_payload = { + "model_name": model_name, + "assets": assets.to_dict(), + } + manifest_path.write_text(json.dumps(manifest_payload, indent=2, sort_keys=True) + "\n") + + +def _ensure_cached_file( + source_url: str, + checksum: str, + destination: Path, + downloads_dir: Path, + *, + force_download: bool, +) -> Path: + if not force_download and verify_checksum(destination, checksum): + return destination + + suffix = destination.suffix or ".bin" + temp_download = downloads_dir / (uuid.uuid4().hex + suffix) + download_file(source_url, temp_download, expected_checksum=checksum) + _ensure_directory(destination.parent) + os.replace(str(temp_download), str(destination)) + return destination + + +def _install_espeak_data( + assets: ModelAssets, + cache_root: Path, + downloads_dir: Path, + *, + force_download: bool, +) -> Path: + checksum = normalize_checksum(assets.espeak_checksum) + espeak_root = cache_root / "espeak" / checksum + data_dir = espeak_root / "espeak-ng-data" + ready_marker = espeak_root / ".ready" + + if ( + not force_download + and ready_marker.is_file() + and data_dir.is_dir() + ): + return data_dir + + if espeak_root.exists(): + shutil.rmtree(espeak_root) + + _ensure_directory(espeak_root) + archive_name = filename_from_url(assets.espeak_data, checksum + ".zip") + temp_archive = downloads_dir / (uuid.uuid4().hex + "-" + archive_name) + download_file( + assets.espeak_data, + temp_archive, + expected_checksum=assets.espeak_checksum, + ) + + extraction_root = Path( + tempfile.mkdtemp(prefix="wfloat-espeak-", dir=str(downloads_dir)) + ) + try: + extract_archive(temp_archive, extraction_root) + resolved_data_dir = resolve_extracted_data_directory(extraction_root) + if data_dir.exists(): + shutil.rmtree(data_dir) + shutil.copytree(str(resolved_data_dir), str(data_dir)) + ready_marker.write_text("ready\n") + finally: + if temp_archive.exists(): + temp_archive.unlink() + if extraction_root.exists(): + shutil.rmtree(extraction_root) + + return data_dir + + +def cache_model_assets( + model_name: str, + assets: ModelAssets, + *, + cache_dir: Optional[Path] = None, + force_download: bool = False, +) -> CachedModelAssets: + cache_root = Path(cache_dir) if cache_dir is not None else get_default_cache_dir() + models_dir = cache_root / "models" + downloads_dir = cache_root / "downloads" + + _ensure_directory(models_dir) + _ensure_directory(downloads_dir) + + model_dir = models_dir / normalize_model_name(model_name) + _ensure_directory(model_dir) + + model_filename = filename_from_url(assets.model_onnx, "model.onnx") + tokens_filename = filename_from_url(assets.model_tokens, "tokens.txt") + manifest_path = model_dir / "manifest.json" + + _cleanup_stale_model_files( + model_dir, + active_names={model_filename, tokens_filename, "manifest.json"}, + ) + + model_path = _ensure_cached_file( + assets.model_onnx, + assets.model_onnx_checksum, + model_dir / model_filename, + downloads_dir, + force_download=force_download, + ) + tokens_path = _ensure_cached_file( + assets.model_tokens, + assets.model_tokens_checksum, + model_dir / tokens_filename, + downloads_dir, + force_download=force_download, + ) + espeak_data_dir = _install_espeak_data( + assets, + cache_root, + downloads_dir, + force_download=force_download, + ) + + _write_manifest(manifest_path, model_name, assets) + + return CachedModelAssets( + model_name=model_name, + cache_dir=cache_root, + model_path=model_path, + tokens_path=tokens_path, + espeak_data_dir=espeak_data_dir, + manifest_path=manifest_path, + ) diff --git a/python/wfloat/_cli.py b/python/wfloat/_cli.py new file mode 100644 index 0000000..deb2537 --- /dev/null +++ b/python/wfloat/_cli.py @@ -0,0 +1,74 @@ +import argparse + +from ._model import load + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="wfloat") + subparsers = parser.add_subparsers(dest="command") + + synth = subparsers.add_parser("synth", help="Generate speech and write a WAV file.") + synth.add_argument("--model", default="wfloat/wfloat-tts", help="Model name to load.") + synth.add_argument("--text", required=True, help="Text to synthesize.") + synth.add_argument("--out", required=True, help="Output WAV path.") + synth.add_argument("--voice-id", default=None, help="Voice ID name or numeric SID.") + synth.add_argument("--emotion", default=None, help="Emotion name.") + synth.add_argument("--intensity", type=float, default=None, help="Emotion intensity.") + synth.add_argument("--speed", type=float, default=None, help="Speech speed.") + synth.add_argument( + "--silence-padding-sec", + type=float, + default=None, + help="Silence padding between generated sentence chunks.", + ) + synth.add_argument( + "--cache-dir", + default=None, + help="Optional override for the cache directory.", + ) + synth.add_argument( + "--force-download", + action="store_true", + help="Redownload model assets even if cached copies are present.", + ) + + return parser + + +def _maybe_parse_voice_id(value): + if value is None: + return None + + try: + return int(value) + except (TypeError, ValueError): + return value + + +def main(argv=None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + + if args.command != "synth": + parser.print_help() + return 1 + + model = load( + args.model, + cache_dir=args.cache_dir, + force_download=args.force_download, + ) + result = model.generate( + text=args.text, + voice_id=_maybe_parse_voice_id(args.voice_id), + emotion=args.emotion, + intensity=args.intensity, + speed=args.speed, + silence_padding_sec=args.silence_padding_sec, + ) + result.audio.save(args.out) + print( + "Saved %s (duration=%.2fs, sample_rate=%d)" + % (args.out, result.audio.duration_sec, result.audio.sample_rate) + ) + return 0 diff --git a/python/wfloat/_constants.py b/python/wfloat/_constants.py new file mode 100644 index 0000000..8c57129 --- /dev/null +++ b/python/wfloat/_constants.py @@ -0,0 +1,147 @@ +from typing import Optional, Union + +VoiceId = Union[str, int] + +VALID_EMOTIONS = [ + "neutral", + "joy", + "sadness", + "anger", + "fear", + "surprise", + "dismissive", + "confusion", +] + +SPEAKER_IDS = { + "skilled_hero_man": 0, + "skilled_hero_woman": 1, + "fun_hero_man": 2, + "fun_hero_woman": 3, + "strong_hero_man": 4, + "strong_hero_woman": 5, + "mad_scientist_man": 6, + "mad_scientist_woman": 7, + "clever_villain_man": 8, + "clever_villain_woman": 9, + "narrator_man": 10, + "narrator_woman": 11, + "wise_elder_man": 12, + "wise_elder_woman": 13, + "outgoing_anime_man": 14, + "outgoing_anime_woman": 15, + "scary_villain_man": 16, + "scary_villain_woman": 17, + "news_reporter_man": 18, + "news_reporter_woman": 19, +} + +VALID_SIDS = tuple(SPEAKER_IDS.values()) + +DEFAULT_VOICE_ID = 0 +DEFAULT_EMOTION = "neutral" +DEFAULT_INTENSITY = 0.5 +DEFAULT_SPEED = 1.0 +DEFAULT_SILENCE_PADDING_SEC = 0.1 +DEFAULT_SILENCE_BETWEEN_SEGMENTS_SEC = 0.2 +DEFAULT_NUM_THREADS = 1 +DEFAULT_PROVIDER = "cpu" +DEFAULT_MODEL_NAME = "wfloat/wfloat-tts" + + +def normalize_text(text: str) -> str: + if not isinstance(text, str): + raise TypeError("text must be a string.") + + if not text.strip(): + raise ValueError("text is required.") + + return text + + +def normalize_voice_id(voice_id: Optional[VoiceId]) -> int: + if voice_id is None: + return DEFAULT_VOICE_ID + + if isinstance(voice_id, int): + if voice_id not in VALID_SIDS: + raise ValueError("Invalid numeric voice_id: %s" % voice_id) + return voice_id + + if isinstance(voice_id, str): + trimmed = voice_id.strip() + if not trimmed: + return DEFAULT_VOICE_ID + + mapped_sid = SPEAKER_IDS.get(trimmed) + if mapped_sid is None: + raise ValueError("Invalid string voice_id: %s" % trimmed) + + return mapped_sid + + raise TypeError("voice_id must be a string, integer, or None.") + + +def normalize_emotion(emotion: Optional[str]) -> str: + if emotion is None: + return DEFAULT_EMOTION + + if not isinstance(emotion, str): + raise TypeError("emotion must be a string or None.") + + trimmed = emotion.strip() + if not trimmed: + return DEFAULT_EMOTION + + if trimmed not in VALID_EMOTIONS: + raise ValueError("Invalid emotion: %s" % trimmed) + + return trimmed + + +def normalize_intensity(intensity: Optional[float]) -> float: + if intensity is None: + return DEFAULT_INTENSITY + + try: + value = float(intensity) + except (TypeError, ValueError): + raise TypeError("intensity must be a finite number between 0 and 1.") + + if value < 0 or value > 1: + raise ValueError("intensity must be between 0 and 1.") + + return value + + +def normalize_speed(speed: Optional[float], default: float = DEFAULT_SPEED) -> float: + if speed is None: + return default + + try: + value = float(speed) + except (TypeError, ValueError): + raise TypeError("speed must be a finite number greater than 0.") + + if value <= 0: + raise ValueError("speed must be greater than 0.") + + return value + + +def normalize_silence_padding_sec( + silence_padding_sec: Optional[float], + default: float = DEFAULT_SILENCE_PADDING_SEC, +) -> float: + if silence_padding_sec is None: + return default + + try: + value = float(silence_padding_sec) + except (TypeError, ValueError): + raise TypeError("silence_padding_sec must be a finite number >= 0.") + + if value < 0: + raise ValueError("silence_padding_sec must be >= 0.") + + return value diff --git a/python/wfloat/_download.py b/python/wfloat/_download.py new file mode 100644 index 0000000..3005255 --- /dev/null +++ b/python/wfloat/_download.py @@ -0,0 +1,130 @@ +import hashlib +import os +import shutil +import tarfile +import uuid +import zipfile +from pathlib import Path +from typing import Optional +from urllib.request import Request, urlopen + + +def normalize_checksum(checksum: str) -> str: + normalized = checksum.strip().lower() + if normalized.startswith("sha256:"): + return normalized.split(":", 1)[1] + if normalized.startswith("sha256-"): + return normalized.split("-", 1)[1] + return normalized + + +def sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as file_obj: + while True: + chunk = file_obj.read(1024 * 1024) + if not chunk: + break + digest.update(chunk) + return digest.hexdigest() + + +def verify_checksum(path: Path, checksum: str) -> bool: + if not path.is_file(): + return False + return sha256_file(path) == normalize_checksum(checksum) + + +def download_file( + url: str, + destination: Path, + *, + expected_checksum: Optional[str] = None, + timeout: float = 60.0, +) -> Path: + destination.parent.mkdir(parents=True, exist_ok=True) + temp_path = destination.with_name(destination.name + ".tmp-" + uuid.uuid4().hex) + + request = Request( + url, + headers={ + "Accept": "*/*", + "User-Agent": "wfloat-python/0.0.0", + }, + method="GET", + ) + + try: + with urlopen(request, timeout=timeout) as response: + with temp_path.open("wb") as file_obj: + shutil.copyfileobj(response, file_obj) + + if expected_checksum is not None and not verify_checksum(temp_path, expected_checksum): + raise RuntimeError( + "Downloaded file checksum did not match expected value for %s." % url + ) + + os.replace(str(temp_path), str(destination)) + return destination + finally: + if temp_path.exists(): + temp_path.unlink() + + +def _assert_within_destination(path: Path, destination: Path) -> None: + resolved_destination = destination.resolve() + resolved_path = path.resolve() + destination_str = str(resolved_destination) + path_str = str(resolved_path) + if path_str != destination_str and not path_str.startswith(destination_str + os.sep): + raise RuntimeError("Archive member would extract outside destination: %s" % path) + + +def _extract_zip(archive_path: Path, destination: Path) -> None: + with zipfile.ZipFile(archive_path) as archive: + for member in archive.infolist(): + member_path = destination / member.filename + _assert_within_destination(member_path, destination) + archive.extractall(destination) + + +def _extract_tar(archive_path: Path, destination: Path) -> None: + with tarfile.open(archive_path) as archive: + for member in archive.getmembers(): + member_path = destination / member.name + _assert_within_destination(member_path, destination) + archive.extractall(destination) + + +def extract_archive(archive_path: Path, destination: Path) -> None: + destination.mkdir(parents=True, exist_ok=True) + if zipfile.is_zipfile(archive_path): + _extract_zip(archive_path, destination) + return + + if tarfile.is_tarfile(archive_path): + _extract_tar(archive_path, destination) + return + + raise RuntimeError( + "Unsupported archive format for %s. Python model assets should provide a zip or tar archive." + % archive_path + ) + + +def resolve_extracted_data_directory(extraction_root: Path) -> Path: + visible_contents = [ + path + for path in extraction_root.iterdir() + if not path.name.startswith(".") and path.name != "__MACOSX" + ] + + named_directory = extraction_root / "espeak-ng-data" + if named_directory.is_dir(): + return named_directory + + child_directories = [path for path in visible_contents if path.is_dir()] + if len(visible_contents) == 1 and len(child_directories) == 1: + return child_directories[0] + + return extraction_root diff --git a/python/wfloat/_model.py b/python/wfloat/_model.py new file mode 100644 index 0000000..d7d8618 --- /dev/null +++ b/python/wfloat/_model.py @@ -0,0 +1,332 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Dict, List, Mapping, Optional, Sequence + +from ._assets import fetch_model_assets +from ._cache import ( + CachedModelAssets, + cache_model_assets, + load_persistent_id, + save_persistent_id, +) +from ._constants import ( + DEFAULT_MODEL_NAME, + DEFAULT_SILENCE_BETWEEN_SEGMENTS_SEC, + normalize_emotion, + normalize_intensity, + normalize_silence_padding_sec, + normalize_speed, + normalize_text, + normalize_voice_id, +) +from ._native import create_native_tts +from ._results import Audio, GenerationResult, Timeline, TimelineChunk + + +@dataclass +class _PreparedChunk: + text: str + text_clean: str + highlight_start: int + highlight_end: int + sid: int + voice_id: Optional[object] + emotion: str + intensity: float + speed: float + silence_padding_sec: float + segment_index: Optional[int] + + +class Model: + def __init__( + self, + model_name: str, + native_tts, + *, + cached_assets: Optional[CachedModelAssets] = None, + ) -> None: + self.model_name = model_name + self._native_tts = native_tts + self._cached_assets = cached_assets + + @property + def sample_rate(self) -> int: + return int(self._native_tts.sample_rate) + + @property + def num_speakers(self) -> int: + return int(self._native_tts.num_speakers) + + def __repr__(self) -> str: + return "Model(model_name=%r, sample_rate=%r, num_speakers=%r)" % ( + self.model_name, + self.sample_rate, + self.num_speakers, + ) + + def generate( + self, + *, + text: str, + voice_id=None, + emotion: Optional[str] = None, + intensity: Optional[float] = None, + speed: Optional[float] = None, + silence_padding_sec: Optional[float] = None, + ) -> GenerationResult: + normalized_text = normalize_text(text) + normalized_emotion = normalize_emotion(emotion) + normalized_intensity = normalize_intensity(intensity) + normalized_speed = normalize_speed(speed) + normalized_silence_padding_sec = normalize_silence_padding_sec( + silence_padding_sec + ) + sid = normalize_voice_id(voice_id) + + prepared = self._native_tts.prepare_wfloat_text( + normalized_text, + normalized_emotion, + normalized_intensity, + ) + prepared_chunks = self._build_prepared_chunks( + prepared.text, + prepared.text_clean, + sid=sid, + voice_id=voice_id, + emotion=normalized_emotion, + intensity=normalized_intensity, + speed=normalized_speed, + silence_padding_sec=normalized_silence_padding_sec, + segment_index=None, + ) + + result = self._synthesize_chunks( + prepared_chunks, + result_text=normalized_text, + ) + return result + + def generate_dialogue( + self, + *, + segments: Sequence[Mapping[str, Any]], + speed: Optional[float] = None, + silence_between_segments_sec: Optional[float] = None, + ) -> GenerationResult: + if not segments: + raise ValueError("segments is required.") + + default_speed = normalize_speed(speed) + dialogue_silence_sec = normalize_silence_padding_sec( + silence_between_segments_sec, + default=DEFAULT_SILENCE_BETWEEN_SEGMENTS_SEC, + ) + + prepared_chunks = [] + dialogue_texts = [] + + for segment_index, segment in enumerate(segments): + if not isinstance(segment, Mapping): + raise TypeError("Each segment must be a mapping.") + + normalized_segment = self._normalize_dialogue_segment( + segment, + default_speed=default_speed, + ) + dialogue_texts.append(normalized_segment["text"]) + + prepared = self._native_tts.prepare_wfloat_text( + normalized_segment["text"], + normalized_segment["emotion"], + normalized_segment["intensity"], + ) + segment_chunks = self._build_prepared_chunks( + prepared.text, + prepared.text_clean, + sid=normalized_segment["sid"], + voice_id=normalized_segment["voice_id"], + emotion=normalized_segment["emotion"], + intensity=normalized_segment["intensity"], + speed=normalized_segment["speed"], + silence_padding_sec=normalized_segment["sentence_silence_padding_sec"], + segment_index=segment_index, + ) + prepared_chunks.extend(segment_chunks) + + if segment_index < len(segments) - 1 and segment_chunks: + prepared_chunks[-1].silence_padding_sec = dialogue_silence_sec + + return self._synthesize_chunks( + prepared_chunks, + result_text=" ".join(dialogue_texts), + ) + + def _normalize_dialogue_segment( + self, + segment: Mapping[str, Any], + *, + default_speed: float, + ) -> Dict[str, Any]: + text = normalize_text(segment.get("text")) # type: ignore[arg-type] + voice_id = segment.get("voice_id") + emotion = normalize_emotion(segment.get("emotion")) + intensity = normalize_intensity(segment.get("intensity")) + speed = normalize_speed(segment.get("speed"), default=default_speed) + sentence_silence_padding_sec = normalize_silence_padding_sec( + segment.get("sentence_silence_padding_sec") + ) + sid = normalize_voice_id(voice_id) + + return { + "text": text, + "voice_id": voice_id, + "emotion": emotion, + "intensity": intensity, + "speed": speed, + "sentence_silence_padding_sec": sentence_silence_padding_sec, + "sid": sid, + } + + def _build_prepared_chunks( + self, + raw_text_chunks: Sequence[str], + clean_text_chunks: Sequence[str], + *, + sid: int, + voice_id, + emotion: str, + intensity: float, + speed: float, + silence_padding_sec: float, + segment_index: Optional[int], + ) -> List[_PreparedChunk]: + chunks = [] + raw_cursor = 0 + for index, raw_chunk_text in enumerate(raw_text_chunks): + highlight_start = raw_cursor + highlight_end = raw_cursor + len(raw_chunk_text) + raw_cursor = highlight_end + + chunks.append( + _PreparedChunk( + text=raw_chunk_text, + text_clean=clean_text_chunks[index], + highlight_start=highlight_start, + highlight_end=highlight_end, + sid=sid, + voice_id=voice_id, + emotion=emotion, + intensity=intensity, + speed=speed, + silence_padding_sec=silence_padding_sec, + segment_index=segment_index, + ) + ) + return chunks + + def _synthesize_chunks( + self, + chunks: Sequence[_PreparedChunk], + *, + result_text: str, + ) -> GenerationResult: + if not chunks: + raise RuntimeError("Text preparation produced no synthesizeable chunks.") + + all_samples = [] + timeline_chunks = [] + total_chunks = len(chunks) + cumulative_samples = 0 + sample_rate = self.sample_rate + + for index, chunk in enumerate(chunks): + generated_audio = self._native_tts.generate( + chunk.text_clean, + chunk.sid, + chunk.speed, + ) + chunk_samples = [float(sample) for sample in generated_audio.samples] + chunk_sample_rate = int(generated_audio.sample_rate) + if chunk_sample_rate <= 0: + raise RuntimeError("Native generation returned an invalid sample rate.") + + if index == 0: + sample_rate = chunk_sample_rate + elif chunk_sample_rate != sample_rate: + raise RuntimeError( + "Native generation returned inconsistent sample rates across chunks." + ) + + start_sec = cumulative_samples / float(sample_rate) + all_samples.extend(chunk_samples) + cumulative_samples += len(chunk_samples) + end_sec = cumulative_samples / float(sample_rate) + + timeline_chunks.append( + TimelineChunk( + index=index, + text=chunk.text, + highlight_start=chunk.highlight_start, + highlight_end=chunk.highlight_end, + start_sec=start_sec, + end_sec=end_sec, + duration_sec=end_sec - start_sec, + progress=float(index + 1) / float(total_chunks), + voice_id=chunk.voice_id, + sid=chunk.sid, + emotion=chunk.emotion, + intensity=chunk.intensity, + speed=chunk.speed, + segment_index=chunk.segment_index, + ) + ) + + if index < total_chunks - 1 and chunk.silence_padding_sec > 0: + silence_samples = int(round(chunk.silence_padding_sec * sample_rate)) + if silence_samples > 0: + all_samples.extend([0.0] * silence_samples) + cumulative_samples += silence_samples + + audio = Audio(samples=all_samples, sample_rate=sample_rate) + timeline = Timeline(chunks=timeline_chunks, duration_sec=audio.duration_sec) + result = GenerationResult( + audio=audio, + timeline=timeline, + text=result_text, + model_name=self.model_name, + ) + + return result + + +def load( + model_name: str = DEFAULT_MODEL_NAME, + *, + cache_dir=None, + force_download: bool = False, +) -> Model: + normalized_model_name = normalize_text(model_name) + resolved_cache_dir = Path(cache_dir) if cache_dir is not None else None + persistent_id = load_persistent_id(resolved_cache_dir) + assets = fetch_model_assets( + normalized_model_name, + persistent_id=persistent_id, + ) + save_persistent_id(assets.persistent_id or persistent_id, resolved_cache_dir) + cached_assets = cache_model_assets( + normalized_model_name, + assets, + cache_dir=resolved_cache_dir, + force_download=force_download, + ) + native_tts = create_native_tts( + cached_assets.model_path, + cached_assets.tokens_path, + cached_assets.espeak_data_dir, + ) + return Model( + normalized_model_name, + native_tts, + cached_assets=cached_assets, + ) diff --git a/python/wfloat/_native.py b/python/wfloat/_native.py new file mode 100644 index 0000000..672b215 --- /dev/null +++ b/python/wfloat/_native.py @@ -0,0 +1,48 @@ +from pathlib import Path +from typing import Any + +from ._constants import DEFAULT_NUM_THREADS, DEFAULT_PROVIDER + +_BINDINGS_IMPORT_ERROR = None + +try: + from . import _bindings as bindings +except ImportError as exc: + bindings = None + _BINDINGS_IMPORT_ERROR = exc + + +def require_bindings() -> Any: + if bindings is None: + raise ImportError( + "Failed to import the wfloat native extension. " + "Install a platform wheel or build the package with sherpa-onnx support." + ) from _BINDINGS_IMPORT_ERROR + return bindings + + +def create_native_tts(model_path: Path, tokens_path: Path, espeak_data_dir: Path): + native_bindings = require_bindings() + + model_config = native_bindings.OfflineTtsWfloatModelConfig( + model=str(model_path), + tokens=str(tokens_path), + data_dir=str(espeak_data_dir), + noise_scale=0.667, + noise_scale_w=0.8, + length_scale=1.0, + ) + + tts_model_config = native_bindings.OfflineTtsModelConfig( + wfloat=model_config, + num_threads=DEFAULT_NUM_THREADS, + debug=False, + provider=DEFAULT_PROVIDER, + ) + + config = native_bindings.OfflineTtsConfig( + model=tts_model_config, + max_num_sentences=1, + ) + + return native_bindings.OfflineTts(config) diff --git a/python/wfloat/_results.py b/python/wfloat/_results.py new file mode 100644 index 0000000..20001f8 --- /dev/null +++ b/python/wfloat/_results.py @@ -0,0 +1,90 @@ +import io +import struct +import wave +from dataclasses import dataclass +from pathlib import Path +from typing import Iterable, List, Optional + + +def _coerce_samples(samples: Iterable[float]) -> List[float]: + return [float(sample) for sample in samples] + + +def _sample_to_pcm16(sample: float) -> int: + clipped = max(-1.0, min(1.0, float(sample))) + if clipped <= -1.0: + return -32768 + return int(round(clipped * 32767.0)) + + +def _write_wave_bytes(samples: List[float], sample_rate: int) -> bytes: + buffer = io.BytesIO() + with wave.open(buffer, "wb") as wav_file: + wav_file.setnchannels(1) + wav_file.setsampwidth(2) + wav_file.setframerate(sample_rate) + frame_bytes = bytearray() + for sample in samples: + frame_bytes.extend(struct.pack(" None: + self.samples = _coerce_samples(self.samples) + self.sample_rate = int(self.sample_rate) + + @property + def duration_sec(self) -> float: + if self.sample_rate <= 0: + return 0.0 + return len(self.samples) / float(self.sample_rate) + + def save(self, path) -> None: + output_path = Path(path) + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_bytes(self.wav_bytes()) + + def wav_bytes(self) -> bytes: + return _write_wave_bytes(self.samples, self.sample_rate) + + +@dataclass +class TimelineChunk: + index: int + text: str + highlight_start: int + highlight_end: int + start_sec: float + end_sec: float + duration_sec: float + progress: float + voice_id: Optional[object] + sid: int + emotion: str + intensity: float + speed: float + segment_index: Optional[int] = None + + +@dataclass +class Timeline: + chunks: List[TimelineChunk] + duration_sec: float + + +@dataclass +class GenerationResult: + audio: Audio + timeline: Timeline + text: str + model_name: str + + def __iter__(self): + yield self.audio + yield self.timeline diff --git a/python/wfloat/_version.py b/python/wfloat/_version.py new file mode 100644 index 0000000..3b93d0b --- /dev/null +++ b/python/wfloat/_version.py @@ -0,0 +1 @@ +__version__ = "0.0.2" diff --git a/setup.py b/setup.py index 1a4b4b5..c8a49e1 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,6 @@ import importlib.util import os -import re from pathlib import Path import setuptools @@ -34,6 +33,17 @@ def read_long_description() -> str: return (ROOT_DIR / "README.md").read_text(encoding="utf8") +def read_package_version() -> str: + version_file = ROOT_DIR / "python" / "wfloat" / "_version.py" + namespace = {} + exec(version_file.read_text(encoding="utf8"), namespace) + version = namespace.get("__version__") + if not isinstance(version, str) or not version.strip(): + raise RuntimeError(f"Could not determine package version from {version_file}") + + return version.strip() + + def get_sherpa_onnx_source_dir() -> Path: env_dir = os.environ.get("WFLOAT_SHERPA_ONNX_SOURCE_DIR") if env_dir: @@ -42,33 +52,9 @@ def get_sherpa_onnx_source_dir() -> Path: return (ROOT_DIR.parent.parent / "sherpa-onnx").resolve() -def get_package_version() -> str: - env_version = os.environ.get("WFLOAT_PYTHON_VERSION") - if env_version: - return env_version - - cmake_lists = get_sherpa_onnx_source_dir() / "CMakeLists.txt" - content = cmake_lists.read_text(encoding="utf8") - match = re.search(r'set\(SHERPA_ONNX_VERSION (.*)\)', content) - if not match: - raise RuntimeError(f"Could not determine version from {cmake_lists}") - - version = match.group(1).strip('"') - - cmake_args = os.environ.get("SHERPA_ONNX_CMAKE_ARGS", "") - if "-DSHERPA_ONNX_ENABLE_GPU=ON" in cmake_args: - version += "+cuda" - - cuda_version = os.environ.get("SHERPA_ONNX_CUDA_VERSION", "") - if cuda_version: - version += cuda_version - - return version - - setuptools.setup( name="wfloat", - version=get_package_version(), + version=read_package_version(), description="Low-level Python bindings for Wfloat TTS", long_description=read_long_description(), long_description_content_type="text/markdown", @@ -79,6 +65,11 @@ def get_package_version() -> str: package_dir={"": "python"}, packages=setuptools.find_packages(where="python"), package_data={"wfloat": ["lib/*"], "wfloat.lib": ["*"]}, + entry_points={ + "console_scripts": [ + "wfloat=wfloat._cli:main", + ] + }, include_package_data=True, ext_modules=[cmake_extension("_sherpa_onnx")], cmdclass={"build_ext": BuildExtension, "bdist_wheel": bdist_wheel}, diff --git a/tests/test_basic.py b/tests/test_basic.py index 727da12..4344f31 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -1,79 +1,232 @@ +import hashlib +import tempfile import unittest - -try: - import wfloat -except ImportError as exc: - wfloat = None - IMPORT_ERROR = exc -else: - IMPORT_ERROR = None - - -class TestWfloatSmoke(unittest.TestCase): - def test_import_wfloat(self) -> None: - if IMPORT_ERROR is not None: - self.fail( - "Could not import wfloat. Build/install the package first. " - f"Original error: {IMPORT_ERROR}" +import zipfile +from pathlib import Path +from unittest import mock + +import wfloat +from wfloat._assets import ModelAssets +from wfloat._cache import ( + CachedModelAssets, + cache_model_assets, + load_persistent_id, + normalize_model_name, + save_persistent_id, +) +from wfloat._download import sha256_file +from wfloat._model import Model +from wfloat._results import Audio + + +class FakePreparedText: + def __init__(self, text, text_clean): + self.text = text + self.text_clean = text_clean + self.text_phonemes = ["phonemes"] * len(text) + + +class FakeGeneratedAudio: + def __init__(self, samples, sample_rate): + self.samples = samples + self.sample_rate = sample_rate + + +class FakeNativeTts: + def __init__(self, sample_rate=10): + self.sample_rate = sample_rate + self.num_speakers = len(wfloat.VALID_SIDS) + self.prepare_calls = [] + self.generate_calls = [] + + def prepare_wfloat_text(self, text, emotion, intensity): + self.prepare_calls.append((text, emotion, intensity)) + if text == "Hello. World!": + return FakePreparedText( + text=["Hello.", " World!"], + text_clean=["Hello.clean", "World.clean"], ) - def test_public_api_exports(self) -> None: - if IMPORT_ERROR is not None: - self.skipTest(f"wfloat is not importable yet: {IMPORT_ERROR}") + if text == "The door is locked.": + return FakePreparedText( + text=["The door is locked."], + text_clean=["door.clean"], + ) - expected_exports = { - "GeneratedAudio", - "GenerationConfig", - "OfflineTts", - "OfflineTtsConfig", - "OfflineTtsModelConfig", - "OfflineTtsWfloatModelConfig", - "WfloatPreparedText", - "git_date", - "git_sha1", - "prepare_wfloat_text", - "version", - "write_wave", - } + if text == "Then we open it the loud way.": + return FakePreparedText( + text=["Then we open it the loud way."], + text_clean=["loud.clean"], + ) - self.assertTrue(expected_exports.issubset(set(wfloat.__all__))) - for name in expected_exports: - self.assertTrue(hasattr(wfloat, name), f"Missing export: {name}") + raise AssertionError("Unexpected text in fake native TTS: %s" % text) + + def generate(self, text, sid, speed): + self.generate_calls.append((text, sid, speed)) + if text == "Hello.clean": + return FakeGeneratedAudio([0.1, 0.2], self.sample_rate) + if text == "World.clean": + return FakeGeneratedAudio([0.3], self.sample_rate) + if text == "door.clean": + return FakeGeneratedAudio([0.4, 0.5], self.sample_rate) + if text == "loud.clean": + return FakeGeneratedAudio([0.6], self.sample_rate) + raise AssertionError("Unexpected clean text in fake native TTS: %s" % text) + + +class TestHighLevelApi(unittest.TestCase): + def test_top_level_exports_exist(self): + self.assertTrue(hasattr(wfloat, "load")) + self.assertTrue(hasattr(wfloat, "Model")) + self.assertTrue(hasattr(wfloat, "Audio")) + self.assertTrue(hasattr(wfloat, "GenerationResult")) + self.assertIn("narrator_woman", wfloat.SPEAKER_IDS) + + def test_audio_can_write_wave_bytes_without_numpy(self): + audio = Audio(samples=[0.0, 0.5, -0.5], sample_rate=22050) + wav_bytes = audio.wav_bytes() + + self.assertTrue(wav_bytes.startswith(b"RIFF")) + self.assertGreater(len(wav_bytes), 44) + + def test_generate_returns_audio_and_timeline(self): + fake_native_tts = FakeNativeTts(sample_rate=10) + model = Model("wfloat/wfloat-tts", fake_native_tts) + + result = model.generate( + text="Hello. World!", + voice_id="narrator_woman", + emotion="neutral", + intensity=0.5, + speed=1.0, + silence_padding_sec=0.2, + ) + + self.assertEqual(result.audio.sample_rate, 10) + self.assertEqual(len(result.timeline.chunks), 2) + self.assertEqual(result.timeline.chunks[0].highlight_start, 0) + self.assertEqual(result.timeline.chunks[0].highlight_end, 6) + self.assertEqual(result.timeline.chunks[1].highlight_start, 6) + self.assertEqual(result.timeline.chunks[1].highlight_end, 13) + self.assertAlmostEqual(result.timeline.chunks[0].start_sec, 0.0) + self.assertAlmostEqual(result.timeline.chunks[0].end_sec, 0.2) + self.assertAlmostEqual(result.timeline.chunks[1].start_sec, 0.4) + self.assertAlmostEqual(result.timeline.chunks[1].end_sec, 0.5) + self.assertAlmostEqual(result.audio.duration_sec, 0.5) + self.assertEqual(fake_native_tts.generate_calls[0][1], 11) + self.assertEqual(list(result), [result.audio, result.timeline]) + + with tempfile.TemporaryDirectory() as tmpdir: + output_path = Path(tmpdir) / "out.wav" + result.audio.save(output_path) + self.assertTrue(output_path.is_file()) + + def test_generate_dialogue_tracks_segment_indices(self): + fake_native_tts = FakeNativeTts(sample_rate=10) + model = Model("wfloat/wfloat-tts", fake_native_tts) + + result = model.generate_dialogue( + segments=[ + { + "text": "The door is locked.", + "voice_id": "narrator_man", + "emotion": "neutral", + }, + { + "text": "Then we open it the loud way.", + "voice_id": "strong_hero_woman", + "emotion": "joy", + "intensity": 0.65, + }, + ], + silence_between_segments_sec=0.3, + ) + + self.assertEqual(len(result.timeline.chunks), 2) + self.assertEqual(result.timeline.chunks[0].segment_index, 0) + self.assertEqual(result.timeline.chunks[1].segment_index, 1) + self.assertAlmostEqual(result.timeline.chunks[1].start_sec, 0.5) + + def test_cache_model_assets_downloads_and_extracts_from_local_urls(self): + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + source_dir = root / "source" + source_dir.mkdir() + + model_file = source_dir / "model.onnx" + model_file.write_bytes(b"model-bytes") + + tokens_file = source_dir / "tokens.txt" + tokens_file.write_text("token-bytes") + + espeak_archive = source_dir / "espeak.zip" + with zipfile.ZipFile(espeak_archive, "w") as archive: + archive.writestr("espeak-ng-data/voices.txt", "voice-data") + + assets = ModelAssets( + model_onnx=model_file.as_uri(), + model_onnx_checksum=sha256_file(model_file), + model_tokens=tokens_file.as_uri(), + model_tokens_checksum=sha256_file(tokens_file), + espeak_data=espeak_archive.as_uri(), + espeak_checksum=sha256_file(espeak_archive), + ) - def test_version_matches_expected(self) -> None: - if IMPORT_ERROR is not None: - self.skipTest(f"wfloat is not importable yet: {IMPORT_ERROR}") + cached = cache_model_assets( + "wfloat/wfloat-tts", + assets, + cache_dir=root / "cache", + ) - self.assertEqual(wfloat.version, "1.12.23") + self.assertTrue(cached.model_path.is_file()) + self.assertTrue(cached.tokens_path.is_file()) + self.assertTrue((cached.espeak_data_dir / "voices.txt").is_file()) + self.assertTrue(cached.manifest_path.is_file()) + self.assertEqual(normalize_model_name("wfloat/wfloat-tts"), "wfloat--wfloat-tts") + + def test_persistent_id_is_stored_and_loaded_best_effort(self): + with tempfile.TemporaryDirectory() as tmpdir: + cache_dir = Path(tmpdir) / "cache" + self.assertIsNone(load_persistent_id(cache_dir)) + save_persistent_id("persist-123", cache_dir) + self.assertEqual(load_persistent_id(cache_dir), "persist-123") + + def test_load_wires_endpoint_cache_and_native_builder(self): + fake_assets = ModelAssets( + model_onnx="https://example.com/model.onnx", + model_onnx_checksum="abc", + model_tokens="https://example.com/tokens.txt", + model_tokens_checksum="def", + espeak_data="https://example.com/espeak.zip", + espeak_checksum="ghi", + persistent_id="persist-456", + ) + fake_cached = CachedModelAssets( + model_name="wfloat/wfloat-tts", + cache_dir=Path("/tmp/cache"), + model_path=Path("/tmp/cache/model.onnx"), + tokens_path=Path("/tmp/cache/tokens.txt"), + espeak_data_dir=Path("/tmp/cache/espeak"), + manifest_path=Path("/tmp/cache/manifest.json"), + ) + fake_native_tts = FakeNativeTts() + + with tempfile.TemporaryDirectory() as tmpdir: + cache_dir = Path(tmpdir) / "cache" + save_persistent_id("persist-123", cache_dir) + with mock.patch("wfloat._model.fetch_model_assets", return_value=fake_assets) as fetch_mock, mock.patch( + "wfloat._model.cache_model_assets", return_value=fake_cached + ), mock.patch("wfloat._model.create_native_tts", return_value=fake_native_tts): + model = wfloat.load("wfloat/wfloat-tts", cache_dir=cache_dir) + + self.assertIsInstance(model, Model) + self.assertEqual(model.model_name, "wfloat/wfloat-tts") + fetch_mock.assert_called_once_with( + "wfloat/wfloat-tts", + persistent_id="persist-123", + ) + self.assertEqual(load_persistent_id(cache_dir), "persist-456") if __name__ == "__main__": - # unittest.main() - - import wfloat - - model = wfloat.OfflineTtsWfloatModelConfig( - model="../wfloat-web/assets/models/wfloat-model/1.0.0/wfloat-model-1.0.0.onnx", - tokens="../wfloat-web/assets/models/wfloat-model/1.0.0/wfloat-model-1.0.0_tokens.txt", - data_dir="../../sherpa-onnx/wasm/tts/assets/espeak-ng-data", - ) - - tts_model = wfloat.OfflineTtsModelConfig( - wfloat=model, - num_threads=1, - debug=False, - provider="cpu", - ) - - config = wfloat.OfflineTtsConfig( - model=tts_model, - max_num_sentences=1, - ) - - tts = wfloat.OfflineTts(config) - audio = tts.generate("Hello world.", sid=0, speed=1.0) - ok = wfloat.write_wave("out.wav", audio.samples, audio.sample_rate) - - print("sample_rate:", audio.sample_rate) - print("num_samples:", len(audio.samples)) - print("write_wave:", ok) + unittest.main() From ac788b5fa074d3fb1734273ab88ee2f2c9aa657c Mon Sep 17 00:00:00 2001 From: Mitchell Sayre Date: Sat, 9 May 2026 18:57:27 -0400 Subject: [PATCH 2/8] Fix build --- CONTRIBUTING.md | 1 + pyproject.toml | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0698936..9cfbe98 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -139,6 +139,7 @@ python -m twine upload --repository testpypi dist/* they are not already available locally. - `python -m build` without `WFLOAT_SHERPA_ONNX_SOURCE_DIR` may fail because the wheel build can run from a temporary unpacked source tree. +- CI wheel builds use normal PEP 517 build isolation through `cibuildwheel`. - We currently publish wheels, not a self-contained source distribution. The source distribution path should only be documented after it no longer depends on an external sibling `sherpa-onnx` checkout. diff --git a/pyproject.toml b/pyproject.toml index 150774d..a5fb4b3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,5 +5,4 @@ build-backend = "setuptools.build_meta" [tool.cibuildwheel] build = "cp38-* cp39-* cp310-* cp311-* cp312-* cp313-*" skip = "pp* *-win32 *-manylinux_i686 *-musllinux_*" -build-frontend = { name = "pip", args = ["--no-build-isolation"] } test-command = 'python -c "from wfloat import version, write_wave; print(version)"' From d7bcee5bd06a5ef6a0576ddab8d485f67734a7b3 Mon Sep 17 00:00:00 2001 From: Mitchell Sayre Date: Sat, 9 May 2026 19:12:39 -0400 Subject: [PATCH 3/8] Fix mac and windows build --- .github/workflows/wheels.yml | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 66865ee..def1361 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -57,7 +57,7 @@ jobs: - name: Build wheels env: CIBW_ENVIRONMENT_PASS_LINUX: WFLOAT_SHERPA_ONNX_SOURCE_DIR MACOSX_DEPLOYMENT_TARGET - MACOSX_DEPLOYMENT_TARGET: "11.0" + MACOSX_DEPLOYMENT_TARGET: "13.4" WFLOAT_SHERPA_ONNX_SOURCE_DIR: ${{ runner.os == 'Linux' && '/project/sherpa-onnx' || format('{0}/sherpa-onnx', github.workspace) }} run: python -m cibuildwheel --output-dir wheelhouse diff --git a/pyproject.toml b/pyproject.toml index a5fb4b3..50c0723 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,4 +5,4 @@ build-backend = "setuptools.build_meta" [tool.cibuildwheel] build = "cp38-* cp39-* cp310-* cp311-* cp312-* cp313-*" skip = "pp* *-win32 *-manylinux_i686 *-musllinux_*" -test-command = 'python -c "from wfloat import version, write_wave; print(version)"' +test-command = 'python -c "import wfloat; from wfloat._bindings import version, write_wave; print(wfloat.__version__, version)"' From 0b7142506158f3ce0d768d70d1cb696ffdce7f05 Mon Sep 17 00:00:00 2001 From: Mitchell Sayre Date: Sat, 9 May 2026 19:34:11 -0400 Subject: [PATCH 4/8] Fix wheel builds to bind pybind11 --- _build_support/cmake_extension.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/_build_support/cmake_extension.py b/_build_support/cmake_extension.py index 119a7cd..405ff9a 100644 --- a/_build_support/cmake_extension.py +++ b/_build_support/cmake_extension.py @@ -59,6 +59,16 @@ def _run(cmd): subprocess.run(cmd, check=True) +def _remove_stale_runtime_files(lib_dir: Path) -> None: + if not lib_dir.is_dir(): + return + + for pattern in ("*.so", "*.pyd", "*.dll", "*.dylib", "*.lib", "*.exp"): + for candidate in lib_dir.glob(pattern): + if candidate.is_file(): + candidate.unlink() + + class BuildExtension(build_ext): def build_extension(self, ext: setuptools.extension.Extension): del ext @@ -67,9 +77,11 @@ def build_extension(self, ext: setuptools.extension.Extension): build_lib = Path(self.build_lib).resolve() install_dir = build_lib / "wfloat" source_dir = _get_sherpa_onnx_source_dir() + python_root_dir = Path(sys.prefix).resolve() build_temp.mkdir(parents=True, exist_ok=True) build_lib.mkdir(parents=True, exist_ok=True) + _remove_stale_runtime_files(install_dir / "lib") user_cmake_args = shlex.split(os.environ.get("SHERPA_ONNX_CMAKE_ARGS", "")) default_cmake_args = [ @@ -87,10 +99,25 @@ def build_extension(self, ext: setuptools.extension.Extension): "-DSHERPA_ONNX_ENABLE_TTS=ON", "-DSHERPA_ONNX_ENABLE_BINARY=OFF", "-DSHERPA_ONNX_ENABLE_PORTAUDIO=OFF", + "-DPYBIND11_FINDPYTHON=ON", ] if not any(arg.startswith("-DPYTHON_EXECUTABLE=") for arg in user_cmake_args): default_cmake_args.append(f"-DPYTHON_EXECUTABLE={sys.executable}") + if not any(arg.startswith("-DPython_EXECUTABLE=") for arg in user_cmake_args): + default_cmake_args.append(f"-DPython_EXECUTABLE={sys.executable}") + if not any(arg.startswith("-DPython3_EXECUTABLE=") for arg in user_cmake_args): + default_cmake_args.append(f"-DPython3_EXECUTABLE={sys.executable}") + if not any(arg.startswith("-DPython_ROOT_DIR=") for arg in user_cmake_args): + default_cmake_args.append(f"-DPython_ROOT_DIR={python_root_dir}") + if not any(arg.startswith("-DPython3_ROOT_DIR=") for arg in user_cmake_args): + default_cmake_args.append(f"-DPython3_ROOT_DIR={python_root_dir}") + if platform.system() == "Darwin" and not any( + arg.startswith("-DCMAKE_OSX_ARCHITECTURES=") for arg in user_cmake_args + ): + machine = platform.machine().lower() + if machine in ("arm64", "x86_64"): + default_cmake_args.append(f"-DCMAKE_OSX_ARCHITECTURES={machine}") configure_cmd = [ "cmake", From 00200302b7697dfe88763f5d992b84e4e997c0a1 Mon Sep 17 00:00:00 2001 From: Mitchell Sayre Date: Sat, 9 May 2026 20:44:27 -0400 Subject: [PATCH 5/8] Fix macos arm64 --- _build_support/cmake_extension.py | 1 + 1 file changed, 1 insertion(+) diff --git a/_build_support/cmake_extension.py b/_build_support/cmake_extension.py index 405ff9a..ad08e66 100644 --- a/_build_support/cmake_extension.py +++ b/_build_support/cmake_extension.py @@ -92,6 +92,7 @@ def build_extension(self, ext: setuptools.extension.Extension): "-DBUILD_PIPER_PHONMIZE_TESTS=OFF", "-DBUILD_ESPEAK_NG_EXE=OFF", "-DBUILD_ESPEAK_NG_TESTS=OFF", + "-DSHERPA_ONNX_USE_PRE_INSTALLED_ONNXRUNTIME_IF_AVAILABLE=OFF", "-DSHERPA_ONNX_ENABLE_C_API=OFF", "-DSHERPA_ONNX_BUILD_C_API_EXAMPLES=OFF", "-DSHERPA_ONNX_ENABLE_CHECK=OFF", From ff82768e920dd982cdc1f3adb889888d9bee196e Mon Sep 17 00:00:00 2001 From: Mitchell Sayre Date: Sun, 10 May 2026 02:59:30 -0400 Subject: [PATCH 6/8] Pull pre-built sherpa-onnx instead of building it here --- .github/workflows/wheels.yml | 80 ++++++++---- CONTRIBUTING.md | 143 ++++---------------- MANIFEST.in | 1 - README.md | 48 +++---- _build_support/__init__.py | 1 - _build_support/cmake_extension.py | 154 ---------------------- pyproject.toml | 5 - python/wfloat/_bindings.py | 7 +- python/wfloat/_native.py | 4 +- python/wfloat/lib/__init__.py | 1 - setup.py | 40 +----- tests/test_basic.py | 210 +++++++++--------------------- 12 files changed, 172 insertions(+), 522 deletions(-) delete mode 100644 _build_support/__init__.py delete mode 100644 _build_support/cmake_extension.py delete mode 100644 python/wfloat/lib/__init__.py diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index def1361..fe4ffda 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -1,4 +1,4 @@ -name: Wheels +name: Python on: workflow_dispatch: @@ -12,11 +12,35 @@ on: permissions: contents: read -env: - SHERPA_ONNX_REF: cc9366cb03e06ebcc19a7f3cdfe4c23bf219ee0b - jobs: build: + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.13" + + - name: Install build tools + run: python -m pip install --upgrade pip build + + - name: Build artifacts + run: python -m build + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/* + + unit-tests: + needs: build name: ${{ matrix.name }} runs-on: ${{ matrix.os }} strategy: @@ -25,44 +49,50 @@ jobs: include: - name: linux-x86_64 os: ubuntu-latest + python-version: "3.9" + sherpa-onnx-url: https://github.com/wfloat/sherpa-onnx/releases/download/v1.12.23/sherpa_onnx-1.12.23-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl - name: windows-x86_64 os: windows-latest + python-version: "3.10" + sherpa-onnx-url: https://github.com/wfloat/sherpa-onnx/releases/download/v1.12.23/sherpa_onnx-1.12.23-cp310-cp310-win_amd64.whl - name: macos-x86_64 os: macos-15-intel + python-version: "3.12" + sherpa-onnx-url: https://github.com/wfloat/sherpa-onnx/releases/download/v1.12.23/sherpa_onnx-1.12.23-cp312-cp312-macosx_10_15_x86_64.whl - name: macos-arm64 os: macos-15 + python-version: "3.13" + sherpa-onnx-url: https://github.com/wfloat/sherpa-onnx/releases/download/v1.12.23/sherpa_onnx-1.12.23-cp313-cp313-macosx_11_0_arm64.whl steps: - - name: Check out wfloat-python + - name: Check out repository uses: actions/checkout@v6 with: persist-credentials: false - - name: Check out sherpa-onnx - uses: actions/checkout@v6 + - name: Download artifacts + uses: actions/download-artifact@v4 with: - repository: wfloat/sherpa-onnx - ref: ${{ env.SHERPA_ONNX_REF }} - path: sherpa-onnx - persist-credentials: false + name: dist + path: dist - name: Set up Python uses: actions/setup-python@v6 with: - python-version: "3.13" + python-version: ${{ matrix.python-version }} - - name: Install cibuildwheel - run: python -m pip install --upgrade pip cibuildwheel==3.4.1 + - name: Install sherpa-onnx release wheel + shell: bash + run: | + python -m pip install --upgrade pip + python -m pip install "${{ matrix.sherpa-onnx-url }}" - - name: Build wheels - env: - CIBW_ENVIRONMENT_PASS_LINUX: WFLOAT_SHERPA_ONNX_SOURCE_DIR MACOSX_DEPLOYMENT_TARGET - MACOSX_DEPLOYMENT_TARGET: "13.4" - WFLOAT_SHERPA_ONNX_SOURCE_DIR: ${{ runner.os == 'Linux' && '/project/sherpa-onnx' || format('{0}/sherpa-onnx', github.workspace) }} - run: python -m cibuildwheel --output-dir wheelhouse + - name: Install wfloat artifact + shell: bash + run: python -m pip install --no-deps dist/*.whl - - name: Upload wheels - uses: actions/upload-artifact@v4 - with: - name: wheels-${{ matrix.name }} - path: wheelhouse/*.whl + - name: Run unit tests + run: python -m unittest discover -s tests -v + + - name: Run integration smoke test + run: python -c "import sherpa_onnx, wfloat; import wfloat._bindings as bindings; print(wfloat.__version__); print(sherpa_onnx.__version__); print(bindings.OfflineTts)" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9cfbe98..bdc20c2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,156 +1,69 @@ # Contributing -This package wraps a native `sherpa-onnx` build, so building and publishing it -is a little different from a pure-Python package. +`wfloat` is now a pure Python package. Native code comes from a separately +installed `sherpa-onnx` wheel. ## Prerequisites -You will need: +- Python 3.9+ +- a compatible `sherpa-onnx` wheel available from GitHub Releases or another + package source -- Python 3.8+ -- `cmake` -- a working C/C++ toolchain -- the `sherpa-onnx` source tree available locally - -By default, `setup.py` expects the source tree to live at: - -```text -../../sherpa-onnx -``` - -If your checkout lives somewhere else, set: - -```bash -export WFLOAT_SHERPA_ONNX_SOURCE_DIR=/absolute/path/to/sherpa-onnx -``` - -## Local Setup - -Create a virtualenv and install the packaging tools: +## Local setup ```bash python3 -m venv .venv source .venv/bin/activate python -m pip install --upgrade pip -python -m pip install setuptools wheel build twine ninja +python -m pip install setuptools wheel build twine ``` -`ninja` is recommended for faster native builds, but it is not required. - -## Build For Local Development - -If you just want to compile the native extension locally, run: +Install `sherpa-onnx` first if you want to exercise the low-level bindings: ```bash -python setup.py build_ext --build-lib ./build-out --build-temp ./build-temp +pip install https://github.com/wfloat/sherpa-onnx/releases/download//.whl ``` -This produces a local install tree under `build-out/` and temporary native build -files under `build-temp/`. - -These directories are generated artifacts and should not be committed. - -## Build Release Artifacts - -For release builds, set `WFLOAT_SHERPA_ONNX_SOURCE_DIR` to an absolute path. -This package's wheel build compiles native code from the sibling -`sherpa-onnx` checkout, and build tools may run parts of the build from a -temporary directory where the default relative path no longer works. +Then install `wfloat`: ```bash -export WFLOAT_SHERPA_ONNX_SOURCE_DIR=/absolute/path/to/sherpa-onnx +pip install -e . ``` -To build a release wheel: +## Build release artifacts ```bash rm -rf build dist -python -m build --wheel --no-isolation +python -m build ``` -That should produce: +That produces: - `dist/*.whl` +- `dist/*.tar.gz` -The package version comes from: - -```text -python/wfloat/_version.py -``` - -Update that file before cutting a new PyPI release. - -## CI Wheels - -Multi-platform wheels are built in GitHub Actions with `cibuildwheel` via: +## Tests -```text -.github/workflows/wheels.yml -``` - -That workflow checks out `wfloat-python`, checks out `wfloat/sherpa-onnx` as a -workspace sibling, and builds wheel artifacts for: - -- Linux x86_64 -- Windows x86_64 -- macOS x86_64 -- macOS arm64 - -The `sherpa-onnx` ref is currently controlled in the workflow file with the -`SHERPA_ONNX_REF` environment variable. It is pinned to a specific commit so CI -rebuilds against a stable native dependency. - -The CI workflow intentionally builds wheels only. We should not publish an -sdist until the source-distribution story no longer depends on an external -`sherpa-onnx` checkout. - -## Publish To PyPI - -1. Build a fresh wheel: +Unit tests do not require `sherpa-onnx`: ```bash -rm -rf build dist -python -m build --wheel --no-isolation +python -m unittest discover -s tests -v ``` -2. Check the generated wheel: +If you have installed a compatible `sherpa-onnx` wheel, you can also run a +simple smoke check: ```bash -python -m twine check dist/* +python -c "import sherpa_onnx, wfloat; print(wfloat.__version__)" ``` -3. Upload to PyPI: +## CI -```bash -python -m twine upload dist/* -``` +CI now: -If you want to dry-run the process first, upload to TestPyPI: +- builds pure Python artifacts once +- installs those artifacts on each target platform +- runs the unit test suite -```bash -python -m twine upload --repository testpypi dist/* -``` - -## Notes - -- The package publishes as `wfloat` on PyPI. -- The CLI entrypoint is also `wfloat`. -- Native builds may download third-party dependencies during the CMake step if - they are not already available locally. -- `python -m build` without `WFLOAT_SHERPA_ONNX_SOURCE_DIR` may fail because - the wheel build can run from a temporary unpacked source tree. -- CI wheel builds use normal PEP 517 build isolation through `cibuildwheel`. -- We currently publish wheels, not a self-contained source distribution. The - source distribution path should only be documented after it no longer depends - on an external sibling `sherpa-onnx` checkout. -- Release artifacts should be built from a clean tree so generated files do not - get mixed into the package contents. - -## Sanity Checks - -Before publishing, it is worth running: - -```bash -PYTHONPATH=python python3 -m unittest discover -s tests -v -python -m wfloat --help -``` +After `sherpa-onnx` release assets are published, CI can add a platform-specific +install step that pulls those wheel URLs before running integration smoke tests. diff --git a/MANIFEST.in b/MANIFEST.in index a3dd566..e7b75b5 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,3 +1,2 @@ include README.md -recursive-include _build_support *.py recursive-include python *.py diff --git a/README.md b/README.md index e0a2725..e150a26 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,18 @@ # wfloat -`wfloat` is the Python package for loading Wfloat-compatible speech models and -generating audio files with `sherpa-onnx` under the hood. +`wfloat` is a high-level Python wrapper around `sherpa-onnx` for loading +Wfloat-compatible speech models and generating audio files. -The intended high-level flow is: +## Install + +Install a compatible `sherpa-onnx` wheel first, then install `wfloat`: + +```bash +pip install https://github.com/wfloat/sherpa-onnx/releases/download//.whl +pip install ./packages/wfloat-python +``` + +## Usage ```python import wfloat @@ -19,38 +28,11 @@ result = model.generate( ) result.audio.save("out.wav") - -print(result.audio.sample_rate) -print(result.timeline.chunks[0].start_sec) -``` - -## Status - -The package now contains the high-level Python API shape: - -- `wfloat.load(...)` -- `model.generate(...)` -- `model.generate_dialogue(...)` -- `result.audio.save(...)` -- timing metadata via `result.timeline` - -The Python model asset endpoint still needs to return Python-compatible asset -metadata for live downloads to work end to end. - -## CLI - -The package also exposes a `wfloat` CLI: - -```bash -wfloat synth --text "Hello world." --out out.wav ``` ## Notes -- The package is designed to cache model assets locally. -- Voice IDs can be passed as strings such as `narrator_woman` or as numeric - speaker IDs. +- `wfloat` does not build or bundle native libraries. +- Low-level bindings come from the installed `sherpa-onnx` wheel. - The public API is intentionally high-level; low-level native config objects - are not the primary integration surface. - -More implementation detail lives in [DESIGN.md](./DESIGN.md). + are re-exported only for advanced use. diff --git a/_build_support/__init__.py b/_build_support/__init__.py deleted file mode 100644 index 8b13789..0000000 --- a/_build_support/__init__.py +++ /dev/null @@ -1 +0,0 @@ - diff --git a/_build_support/cmake_extension.py b/_build_support/cmake_extension.py deleted file mode 100644 index ad08e66..0000000 --- a/_build_support/cmake_extension.py +++ /dev/null @@ -1,154 +0,0 @@ -# Derived from sherpa-onnx/cmake/cmake_extension.py and adapted for the -# standalone wfloat Python package. - -import os -import platform -import shlex -import shutil -import subprocess -import sys -from pathlib import Path - -import setuptools -from setuptools.command.build_ext import build_ext - - -def is_windows() -> bool: - return platform.system() == "Windows" - - -try: - from wheel.bdist_wheel import bdist_wheel as _bdist_wheel - - class bdist_wheel(_bdist_wheel): - def finalize_options(self): - _bdist_wheel.finalize_options(self) - self.root_is_pure = False - -except ImportError: - bdist_wheel = None - - -def cmake_extension(name, *args, **kwargs) -> setuptools.Extension: - kwargs["language"] = "c++" - return setuptools.Extension(name, sources=[], *args, **kwargs) - - -def _get_repo_root() -> Path: - return Path(__file__).resolve().parents[3] - - -def _get_sherpa_onnx_source_dir() -> Path: - env_dir = os.environ.get("WFLOAT_SHERPA_ONNX_SOURCE_DIR") - if env_dir: - source_dir = Path(env_dir).expanduser().resolve() - else: - source_dir = (_get_repo_root() / "sherpa-onnx").resolve() - - if not source_dir.is_dir(): - raise RuntimeError( - f"Could not find sherpa-onnx source tree at {source_dir}. " - "Set WFLOAT_SHERPA_ONNX_SOURCE_DIR to override." - ) - - return source_dir - - -def _run(cmd): - print("Running:", " ".join(cmd)) - subprocess.run(cmd, check=True) - - -def _remove_stale_runtime_files(lib_dir: Path) -> None: - if not lib_dir.is_dir(): - return - - for pattern in ("*.so", "*.pyd", "*.dll", "*.dylib", "*.lib", "*.exp"): - for candidate in lib_dir.glob(pattern): - if candidate.is_file(): - candidate.unlink() - - -class BuildExtension(build_ext): - def build_extension(self, ext: setuptools.extension.Extension): - del ext - - build_temp = Path(self.build_temp).resolve() - build_lib = Path(self.build_lib).resolve() - install_dir = build_lib / "wfloat" - source_dir = _get_sherpa_onnx_source_dir() - python_root_dir = Path(sys.prefix).resolve() - - build_temp.mkdir(parents=True, exist_ok=True) - build_lib.mkdir(parents=True, exist_ok=True) - _remove_stale_runtime_files(install_dir / "lib") - - user_cmake_args = shlex.split(os.environ.get("SHERPA_ONNX_CMAKE_ARGS", "")) - default_cmake_args = [ - "-DCMAKE_BUILD_TYPE=Release", - f"-DCMAKE_INSTALL_PREFIX={install_dir}", - "-DBUILD_SHARED_LIBS=ON", - "-DBUILD_PIPER_PHONMIZE_EXE=OFF", - "-DBUILD_PIPER_PHONMIZE_TESTS=OFF", - "-DBUILD_ESPEAK_NG_EXE=OFF", - "-DBUILD_ESPEAK_NG_TESTS=OFF", - "-DSHERPA_ONNX_USE_PRE_INSTALLED_ONNXRUNTIME_IF_AVAILABLE=OFF", - "-DSHERPA_ONNX_ENABLE_C_API=OFF", - "-DSHERPA_ONNX_BUILD_C_API_EXAMPLES=OFF", - "-DSHERPA_ONNX_ENABLE_CHECK=OFF", - "-DSHERPA_ONNX_ENABLE_PYTHON=ON", - "-DSHERPA_ONNX_ENABLE_TTS=ON", - "-DSHERPA_ONNX_ENABLE_BINARY=OFF", - "-DSHERPA_ONNX_ENABLE_PORTAUDIO=OFF", - "-DPYBIND11_FINDPYTHON=ON", - ] - - if not any(arg.startswith("-DPYTHON_EXECUTABLE=") for arg in user_cmake_args): - default_cmake_args.append(f"-DPYTHON_EXECUTABLE={sys.executable}") - if not any(arg.startswith("-DPython_EXECUTABLE=") for arg in user_cmake_args): - default_cmake_args.append(f"-DPython_EXECUTABLE={sys.executable}") - if not any(arg.startswith("-DPython3_EXECUTABLE=") for arg in user_cmake_args): - default_cmake_args.append(f"-DPython3_EXECUTABLE={sys.executable}") - if not any(arg.startswith("-DPython_ROOT_DIR=") for arg in user_cmake_args): - default_cmake_args.append(f"-DPython_ROOT_DIR={python_root_dir}") - if not any(arg.startswith("-DPython3_ROOT_DIR=") for arg in user_cmake_args): - default_cmake_args.append(f"-DPython3_ROOT_DIR={python_root_dir}") - if platform.system() == "Darwin" and not any( - arg.startswith("-DCMAKE_OSX_ARCHITECTURES=") for arg in user_cmake_args - ): - machine = platform.machine().lower() - if machine in ("arm64", "x86_64"): - default_cmake_args.append(f"-DCMAKE_OSX_ARCHITECTURES={machine}") - - configure_cmd = [ - "cmake", - *default_cmake_args, - *user_cmake_args, - "-B", - str(build_temp), - "-S", - str(source_dir), - ] - - build_cmd = ["cmake", "--build", str(build_temp), "--target", "install"] - if is_windows(): - build_cmd.extend(["--config", "Release"]) - - parallel_level = os.environ.get("CMAKE_BUILD_PARALLEL_LEVEL") - if parallel_level: - build_cmd.extend(["--parallel", parallel_level]) - else: - build_cmd.extend(["--parallel", "4"]) - - _run(configure_cmd) - _run(build_cmd) - - # Keep only the runtime files that belong inside the Python package. - for extra_dir in ("bin", "include", "share"): - candidate = install_dir / extra_dir - if candidate.is_dir(): - shutil.rmtree(candidate) - - pkgconfig_dir = install_dir / "lib" / "pkgconfig" - if pkgconfig_dir.is_dir(): - shutil.rmtree(pkgconfig_dir) diff --git a/pyproject.toml b/pyproject.toml index 50c0723..642ab3c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,8 +1,3 @@ [build-system] requires = ["setuptools>=68", "wheel"] build-backend = "setuptools.build_meta" - -[tool.cibuildwheel] -build = "cp38-* cp39-* cp310-* cp311-* cp312-* cp313-*" -skip = "pp* *-win32 *-manylinux_i686 *-musllinux_*" -test-command = 'python -c "import wfloat; from wfloat._bindings import version, write_wave; print(wfloat.__version__, version)"' diff --git a/python/wfloat/_bindings.py b/python/wfloat/_bindings.py index 3eab094..be0085f 100644 --- a/python/wfloat/_bindings.py +++ b/python/wfloat/_bindings.py @@ -1,5 +1,5 @@ try: - from wfloat.lib._sherpa_onnx import ( + from sherpa_onnx import ( GeneratedAudio, GenerationConfig, OfflineTts, @@ -15,8 +15,8 @@ ) except ImportError as exc: raise ImportError( - "Failed to import the wfloat native extension. " - "Build the package from this repo or install a wheel for your platform." + "Failed to import sherpa-onnx. " + "Install a compatible sherpa-onnx wheel before using wfloat." ) from exc @@ -34,4 +34,3 @@ "version", "write_wave", ] - diff --git a/python/wfloat/_native.py b/python/wfloat/_native.py index 672b215..a29a645 100644 --- a/python/wfloat/_native.py +++ b/python/wfloat/_native.py @@ -15,8 +15,8 @@ def require_bindings() -> Any: if bindings is None: raise ImportError( - "Failed to import the wfloat native extension. " - "Install a platform wheel or build the package with sherpa-onnx support." + "Failed to import sherpa-onnx. " + "Install a compatible sherpa-onnx wheel before using wfloat." ) from _BINDINGS_IMPORT_ERROR return bindings diff --git a/python/wfloat/lib/__init__.py b/python/wfloat/lib/__init__.py deleted file mode 100644 index 8b13789..0000000 --- a/python/wfloat/lib/__init__.py +++ /dev/null @@ -1 +0,0 @@ - diff --git a/setup.py b/setup.py index c8a49e1..6926a47 100644 --- a/setup.py +++ b/setup.py @@ -1,7 +1,5 @@ #!/usr/bin/env python3 -import importlib.util -import os from pathlib import Path import setuptools @@ -10,25 +8,6 @@ ROOT_DIR = Path(__file__).resolve().parent -def load_build_support(): - support_path = ROOT_DIR / "_build_support" / "cmake_extension.py" - spec = importlib.util.spec_from_file_location( - "wfloat_build_support.cmake_extension", support_path - ) - if spec is None or spec.loader is None: - raise RuntimeError(f"Could not load build support module from {support_path}") - - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - - -build_support = load_build_support() -BuildExtension = build_support.BuildExtension -bdist_wheel = build_support.bdist_wheel -cmake_extension = build_support.cmake_extension - - def read_long_description() -> str: return (ROOT_DIR / "README.md").read_text(encoding="utf8") @@ -44,39 +23,28 @@ def read_package_version() -> str: return version.strip() -def get_sherpa_onnx_source_dir() -> Path: - env_dir = os.environ.get("WFLOAT_SHERPA_ONNX_SOURCE_DIR") - if env_dir: - return Path(env_dir).expanduser().resolve() - - return (ROOT_DIR.parent.parent / "sherpa-onnx").resolve() - - setuptools.setup( name="wfloat", version=read_package_version(), - description="Low-level Python bindings for Wfloat TTS", + description="High-level Python wrapper for Wfloat TTS", long_description=read_long_description(), long_description_content_type="text/markdown", author="wfloat", license="MIT", - python_requires=">=3.8", + python_requires=">=3.9", url="https://github.com/wfloat/wfloat", package_dir={"": "python"}, packages=setuptools.find_packages(where="python"), - package_data={"wfloat": ["lib/*"], "wfloat.lib": ["*"]}, + include_package_data=True, + install_requires=["sherpa-onnx>=1.12.23"], entry_points={ "console_scripts": [ "wfloat=wfloat._cli:main", ] }, - include_package_data=True, - ext_modules=[cmake_extension("_sherpa_onnx")], - cmdclass={"build_ext": BuildExtension, "bdist_wheel": bdist_wheel}, zip_safe=False, classifiers=[ "Programming Language :: Python :: 3", - "Programming Language :: C++", "Operating System :: Microsoft :: Windows", "Operating System :: POSIX :: Linux", "Operating System :: MacOS :: MacOS X", diff --git a/tests/test_basic.py b/tests/test_basic.py index c6829e0..dbdbf03 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -1,24 +1,54 @@ import hashlib import tempfile import unittest -import json +import zipfile from pathlib import Path +from unittest import mock -try: - import wfloat -except ImportError as exc: - wfloat = None - IMPORT_ERROR = exc -else: - IMPORT_ERROR = None +import wfloat +from wfloat._assets import ModelAssets +from wfloat._cache import ( + CachedModelAssets, + cache_model_assets, + load_persistent_id, + normalize_model_name, + save_persistent_id, +) +from wfloat._model import Model +from wfloat._results import Audio -class TestWfloatSmoke(unittest.TestCase): - def test_import_wfloat(self) -> None: - if IMPORT_ERROR is not None: - self.fail( - "Could not import wfloat. Build/install the package first. " - f"Original error: {IMPORT_ERROR}" +def sha256_file(path: Path) -> str: + digest = hashlib.sha256() + digest.update(path.read_bytes()) + return digest.hexdigest() + + +class FakePreparedText: + def __init__(self, *, text, text_clean): + self.text = text + self.text_clean = text_clean + + +class FakeGeneratedAudio: + def __init__(self, samples, sample_rate): + self.samples = samples + self.sample_rate = sample_rate + + +class FakeNativeTts: + def __init__(self, sample_rate=16000): + self.sample_rate = sample_rate + self.num_speakers = 20 + self.generate_calls = [] + + def prepare_wfloat_text(self, text, emotion, intensity): + del emotion, intensity + + if text == "Hello. World!": + return FakePreparedText( + text=["Hello.", " World!"], + text_clean=["Hello.clean", "World.clean"], ) if text == "The door is locked.": @@ -33,10 +63,11 @@ def test_import_wfloat(self) -> None: text_clean=["loud.clean"], ) - raise AssertionError("Unexpected text in fake native TTS: %s" % text) + raise AssertionError(f"Unexpected text in fake native TTS: {text}") def generate(self, text, sid, speed): self.generate_calls.append((text, sid, speed)) + if text == "Hello.clean": return FakeGeneratedAudio([0.1, 0.2], self.sample_rate) if text == "World.clean": @@ -45,11 +76,12 @@ def generate(self, text, sid, speed): return FakeGeneratedAudio([0.4, 0.5], self.sample_rate) if text == "loud.clean": return FakeGeneratedAudio([0.6], self.sample_rate) - raise AssertionError("Unexpected clean text in fake native TTS: %s" % text) + raise AssertionError(f"Unexpected clean text in fake native TTS: {text}") -class TestHighLevelApi(unittest.TestCase): - def test_top_level_exports_exist(self): + +class TestWfloatSmoke(unittest.TestCase): + def test_import_wfloat(self): self.assertTrue(hasattr(wfloat, "load")) self.assertTrue(hasattr(wfloat, "Model")) self.assertTrue(hasattr(wfloat, "Audio")) @@ -156,7 +188,10 @@ def test_cache_model_assets_downloads_and_extracts_from_local_urls(self): self.assertTrue(cached.tokens_path.is_file()) self.assertTrue((cached.espeak_data_dir / "voices.txt").is_file()) self.assertTrue(cached.manifest_path.is_file()) - self.assertEqual(normalize_model_name("wfloat/wfloat-tts"), "wfloat--wfloat-tts") + self.assertEqual( + normalize_model_name("wfloat/wfloat-tts"), + "wfloat--wfloat-tts", + ) def test_persistent_id_is_stored_and_loaded_best_effort(self): with tempfile.TemporaryDirectory() as tmpdir: @@ -188,9 +223,16 @@ def test_load_wires_endpoint_cache_and_native_builder(self): with tempfile.TemporaryDirectory() as tmpdir: cache_dir = Path(tmpdir) / "cache" save_persistent_id("persist-123", cache_dir) - with mock.patch("wfloat._model.fetch_model_assets", return_value=fake_assets) as fetch_mock, mock.patch( - "wfloat._model.cache_model_assets", return_value=fake_cached - ), mock.patch("wfloat._model.create_native_tts", return_value=fake_native_tts): + with mock.patch( + "wfloat._model.fetch_model_assets", + return_value=fake_assets, + ) as fetch_mock, mock.patch( + "wfloat._model.cache_model_assets", + return_value=fake_cached, + ), mock.patch( + "wfloat._model.create_native_tts", + return_value=fake_native_tts, + ): model = wfloat.load("wfloat/wfloat-tts", cache_dir=cache_dir) self.assertIsInstance(model, Model) @@ -203,126 +245,4 @@ def test_load_wires_endpoint_cache_and_native_builder(self): if __name__ == "__main__": - # unittest.main() - - import wfloat - - print(wfloat.version) - - SPEAKER_IDS = { - "skilled_hero_man": 0, - "skilled_hero_woman": 1, - "fun_hero_man": 2, - "fun_hero_woman": 3, - "strong_hero_man": 4, - "strong_hero_woman": 5, - "mad_scientist_man": 6, - "mad_scientist_woman": 7, - "clever_villain_man": 8, - "clever_villain_woman": 9, - "narrator_man": 10, - "narrator_woman": 11, - "wise_elder_man": 12, - "wise_elder_woman": 13, - "outgoing_anime_man": 14, - "outgoing_anime_woman": 15, - "scary_villain_man": 16, - "scary_villain_woman": 17, - "news_reporter_man": 18, - "news_reporter_woman": 19, - } - - model = wfloat.OfflineTtsWfloatModelConfig( - model="../wfloat-web/assets/models/wfloat-model/1.0.0/wfloat-model-1.0.0.onnx", - tokens="../wfloat-web/assets/models/wfloat-model/1.0.0/wfloat-model-1.0.0_tokens.txt", - data_dir="../../sherpa-onnx/wasm/tts/assets/espeak-ng-data", - ) - - tts_model = wfloat.OfflineTtsModelConfig( - wfloat=model, - num_threads=1, - debug=False, - provider="cpu", - ) - - config = wfloat.OfflineTtsConfig( - model=tts_model, - max_num_sentences=1, - ) - - tts = wfloat.OfflineTts(config) - - voices_path = "../../web/assets/js/voices.js" # string path to the JSON file - with open(voices_path, "r", encoding="utf-8") as f: - voices_text = f.read() - prefix = "export const VOICES = " - voices_text = voices_text[len(prefix) :] - - voices = json.loads(voices_text) - out_dir = Path("out") - out_dir.mkdir(exist_ok=True) - progress_by_voice = {} - - for v in voices: - sid = SPEAKER_IDS[v["voiceId"]] - silence_padding_sec = v["padding"] - speed = v["speed"] - final_samples = [] - sample_rate = None - raw_text_cursor = 0 - current_time_sec = 0.0 - progress_events = [] - - prepared = tts.prepare_wfloat_text( - v["text"], - emotion=v["emotion"], - intensity=v["intensity"], - ) - - for i in range(len(prepared.text)): - audio = tts.generate(prepared.text_clean[i], sid=sid, speed=speed) - if sample_rate is None: - sample_rate = audio.sample_rate - elif sample_rate != audio.sample_rate: - raise ValueError( - f"Sample rate changed for {v['voiceId']}: " - f"{sample_rate} != {audio.sample_rate}" - ) - - raw_chunk_text = prepared.text[i] or "" - highlight_start = raw_text_cursor - highlight_end = raw_text_cursor + len(raw_chunk_text) - raw_text_cursor = highlight_end - - chunk_duration_sec = len(audio.samples) / sample_rate - padding_sec = silence_padding_sec if i < len(prepared.text) - 1 else 0.0 - start_time_sec = current_time_sec - end_time_sec = start_time_sec + chunk_duration_sec + padding_sec - - progress_events.append( - { - "text": raw_chunk_text, - "progress": (i + 1) / len(prepared.text), - "textHighlightStart": highlight_start, - "textHighlightEnd": highlight_end, - "startTimeSec": start_time_sec, - "endTimeSec": end_time_sec, - } - ) - - final_samples.extend(audio.samples) - current_time_sec += chunk_duration_sec - - if i < len(prepared.text) - 1: - silence_samples = int(sample_rate * silence_padding_sec) - final_samples.extend([0] * silence_samples) - current_time_sec += silence_padding_sec - - output_path = out_dir / f"{v['voiceId']}.wav" - ok = wfloat.write_wave(str(output_path), final_samples, sample_rate) - progress_by_voice[v["voiceId"]] = progress_events - print(f"{output_path}: sample_rate={sample_rate} num_samples={len(final_samples)} write_wave={ok}") - - progress_path = out_dir / "progress.json" - with open(progress_path, "w", encoding="utf-8") as f: - json.dump(progress_by_voice, f, ensure_ascii=False, indent=2) + unittest.main() From b4b38bbc751b9f4dcaa35a7dacde4fc1aefce63d Mon Sep 17 00:00:00 2001 From: Mitchell Sayre Date: Sun, 10 May 2026 03:08:21 -0400 Subject: [PATCH 7/8] Fix url --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 6926a47..4e0afab 100644 --- a/setup.py +++ b/setup.py @@ -32,7 +32,7 @@ def read_package_version() -> str: author="wfloat", license="MIT", python_requires=">=3.9", - url="https://github.com/wfloat/wfloat", + url="https://github.com/wfloat/wfloat-python", package_dir={"": "python"}, packages=setuptools.find_packages(where="python"), include_package_data=True, From 83144898aa3a2d3edb91b400ba08c1edf205bfb1 Mon Sep 17 00:00:00 2001 From: Mitchell Sayre Date: Sun, 10 May 2026 14:36:09 -0400 Subject: [PATCH 8/8] Switch to pulling a prebuilt wheel of the sherpa-onnx fork --- .github/workflows/wheels.yml | 35 ++++++++++++++------ CONTRIBUTING.md | 27 +++++---------- README.md | 14 ++++++-- python/wfloat/__init__.py | 64 ++++++++++++++++-------------------- python/wfloat/_bindings.py | 56 +++++++++++++++++++++---------- python/wfloat/_native.py | 4 +-- python/wfloat/_version.py | 2 +- setup.py | 4 ++- tests/test_basic.py | 29 ++++++++++++++++ 9 files changed, 147 insertions(+), 88 deletions(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index fe4ffda..2495724 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -50,19 +50,15 @@ jobs: - name: linux-x86_64 os: ubuntu-latest python-version: "3.9" - sherpa-onnx-url: https://github.com/wfloat/sherpa-onnx/releases/download/v1.12.23/sherpa_onnx-1.12.23-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl - name: windows-x86_64 os: windows-latest python-version: "3.10" - sherpa-onnx-url: https://github.com/wfloat/sherpa-onnx/releases/download/v1.12.23/sherpa_onnx-1.12.23-cp310-cp310-win_amd64.whl - name: macos-x86_64 os: macos-15-intel python-version: "3.12" - sherpa-onnx-url: https://github.com/wfloat/sherpa-onnx/releases/download/v1.12.23/sherpa_onnx-1.12.23-cp312-cp312-macosx_10_15_x86_64.whl - name: macos-arm64 os: macos-15 python-version: "3.13" - sherpa-onnx-url: https://github.com/wfloat/sherpa-onnx/releases/download/v1.12.23/sherpa_onnx-1.12.23-cp313-cp313-macosx_11_0_arm64.whl steps: - name: Check out repository @@ -81,18 +77,37 @@ jobs: with: python-version: ${{ matrix.python-version }} - - name: Install sherpa-onnx release wheel + - name: Install wfloat artifact with dependencies shell: bash run: | python -m pip install --upgrade pip - python -m pip install "${{ matrix.sherpa-onnx-url }}" - - - name: Install wfloat artifact - shell: bash - run: python -m pip install --no-deps dist/*.whl + python -m pip install dist/*.whl - name: Run unit tests run: python -m unittest discover -s tests -v - name: Run integration smoke test run: python -c "import sherpa_onnx, wfloat; import wfloat._bindings as bindings; print(wfloat.__version__); print(sherpa_onnx.__version__); print(bindings.OfflineTts)" + + publish: + name: Publish to PyPI + if: github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/v') + needs: + - build + - unit-tests + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/project/wfloat/ + permissions: + id-token: write + + steps: + - name: Download artifacts + uses: actions/download-artifact@v4 + with: + name: dist + path: dist + + - name: Publish package distributions to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bdc20c2..30c9b8e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,13 +1,11 @@ # Contributing -`wfloat` is now a pure Python package. Native code comes from a separately -installed `sherpa-onnx` wheel. +`wfloat` is a pure Python package. Native code comes from the +`wfloat-sherpa-onnx` dependency, which provides `import sherpa_onnx`. ## Prerequisites - Python 3.9+ -- a compatible `sherpa-onnx` wheel available from GitHub Releases or another - package source ## Local setup @@ -18,18 +16,14 @@ python -m pip install --upgrade pip python -m pip install setuptools wheel build twine ``` -Install `sherpa-onnx` first if you want to exercise the low-level bindings: - -```bash -pip install https://github.com/wfloat/sherpa-onnx/releases/download//.whl -``` - -Then install `wfloat`: +Install `wfloat`: ```bash pip install -e . ``` +That will also install the matching `wfloat-sherpa-onnx` dependency. + ## Build release artifacts ```bash @@ -44,14 +38,13 @@ That produces: ## Tests -Unit tests do not require `sherpa-onnx`: +Unit tests do not require `sherpa_onnx`: ```bash python -m unittest discover -s tests -v ``` -If you have installed a compatible `sherpa-onnx` wheel, you can also run a -simple smoke check: +You can also run a smoke check: ```bash python -c "import sherpa_onnx, wfloat; print(wfloat.__version__)" @@ -63,7 +56,5 @@ CI now: - builds pure Python artifacts once - installs those artifacts on each target platform -- runs the unit test suite - -After `sherpa-onnx` release assets are published, CI can add a platform-specific -install step that pulls those wheel URLs before running integration smoke tests. +- relies on normal dependency resolution for `wfloat-sherpa-onnx` +- runs the unit test suite and an integration smoke test diff --git a/README.md b/README.md index e150a26..b7ded86 100644 --- a/README.md +++ b/README.md @@ -5,10 +5,17 @@ Wfloat-compatible speech models and generating audio files. ## Install -Install a compatible `sherpa-onnx` wheel first, then install `wfloat`: +Install `wfloat` normally: + +```bash +pip install wfloat +``` + +That will also install the matching `wfloat-sherpa-onnx` dependency from PyPI. + +When installing from this repo locally: ```bash -pip install https://github.com/wfloat/sherpa-onnx/releases/download//.whl pip install ./packages/wfloat-python ``` @@ -33,6 +40,7 @@ result.audio.save("out.wav") ## Notes - `wfloat` does not build or bundle native libraries. -- Low-level bindings come from the installed `sherpa-onnx` wheel. +- Low-level bindings come from the installed `wfloat-sherpa-onnx` dependency, + which provides `import sherpa_onnx`. - The public API is intentionally high-level; low-level native config objects are re-exported only for advanced use. diff --git a/python/wfloat/__init__.py b/python/wfloat/__init__.py index 08aa229..9263cc1 100644 --- a/python/wfloat/__init__.py +++ b/python/wfloat/__init__.py @@ -3,25 +3,19 @@ from ._results import Audio, GenerationResult, Timeline, TimelineChunk from ._version import __version__ -_LOW_LEVEL_IMPORT_ERROR = None - -try: - from ._bindings import ( - GeneratedAudio, - GenerationConfig, - OfflineTts, - OfflineTtsConfig, - OfflineTtsModelConfig, - OfflineTtsWfloatModelConfig, - WfloatPreparedText, - git_date, - git_sha1, - prepare_wfloat_text, - version, - write_wave, - ) -except ImportError as exc: - _LOW_LEVEL_IMPORT_ERROR = exc +_LOW_LEVEL_EXPORTS = { + "GenerationConfig", + "OfflineTts", + "OfflineTtsConfig", + "OfflineTtsModelConfig", + "OfflineTtsWfloatModelConfig", + "WfloatPreparedText", + "git_date", + "git_sha1", + "prepare_wfloat_text", + "version", + "write_wave", +} __all__ = [ @@ -35,21 +29,19 @@ "VALID_SIDS", "load", ] +__all__.extend(sorted(_LOW_LEVEL_EXPORTS)) -if _LOW_LEVEL_IMPORT_ERROR is None: - __all__.extend( - [ - "GeneratedAudio", - "GenerationConfig", - "OfflineTts", - "OfflineTtsConfig", - "OfflineTtsModelConfig", - "OfflineTtsWfloatModelConfig", - "WfloatPreparedText", - "git_date", - "git_sha1", - "prepare_wfloat_text", - "version", - "write_wave", - ] - ) + +def __getattr__(name): + if name not in _LOW_LEVEL_EXPORTS: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + from . import _bindings + + value = getattr(_bindings, name) + globals()[name] = value + return value + + +def __dir__(): + return sorted(set(globals()) | _LOW_LEVEL_EXPORTS) diff --git a/python/wfloat/_bindings.py b/python/wfloat/_bindings.py index be0085f..d3ef4cc 100644 --- a/python/wfloat/_bindings.py +++ b/python/wfloat/_bindings.py @@ -1,27 +1,49 @@ try: - from sherpa_onnx import ( - GeneratedAudio, - GenerationConfig, - OfflineTts, - OfflineTtsConfig, - OfflineTtsModelConfig, - OfflineTtsWfloatModelConfig, - WfloatPreparedText, - git_date, - git_sha1, - prepare_wfloat_text, - version, - write_wave, - ) + import sherpa_onnx except ImportError as exc: raise ImportError( - "Failed to import sherpa-onnx. " - "Install a compatible sherpa-onnx wheel before using wfloat." + "Failed to import sherpa_onnx. " + "Reinstall wfloat so pip can install the matching wfloat-sherpa-onnx dependency." ) from exc +_REQUIRED_EXPORTS = ( + "GenerationConfig", + "OfflineTts", + "OfflineTtsConfig", + "OfflineTtsModelConfig", + "OfflineTtsWfloatModelConfig", + "WfloatPreparedText", + "git_date", + "git_sha1", + "prepare_wfloat_text", + "version", + "write_wave", +) + +missing_exports = [name for name in _REQUIRED_EXPORTS if not hasattr(sherpa_onnx, name)] +if missing_exports: + raise ImportError( + "Installed sherpa_onnx is missing required exports: " + f"{', '.join(missing_exports)}. " + "Reinstall wfloat so pip can install a compatible wfloat-sherpa-onnx build." + ) + + +GenerationConfig = sherpa_onnx.GenerationConfig +OfflineTts = sherpa_onnx.OfflineTts +OfflineTtsConfig = sherpa_onnx.OfflineTtsConfig +OfflineTtsModelConfig = sherpa_onnx.OfflineTtsModelConfig +OfflineTtsWfloatModelConfig = sherpa_onnx.OfflineTtsWfloatModelConfig +WfloatPreparedText = sherpa_onnx.WfloatPreparedText +git_date = sherpa_onnx.git_date +git_sha1 = sherpa_onnx.git_sha1 +prepare_wfloat_text = sherpa_onnx.prepare_wfloat_text +version = sherpa_onnx.version +write_wave = sherpa_onnx.write_wave + + __all__ = [ - "GeneratedAudio", "GenerationConfig", "OfflineTts", "OfflineTtsConfig", diff --git a/python/wfloat/_native.py b/python/wfloat/_native.py index a29a645..73367be 100644 --- a/python/wfloat/_native.py +++ b/python/wfloat/_native.py @@ -15,8 +15,8 @@ def require_bindings() -> Any: if bindings is None: raise ImportError( - "Failed to import sherpa-onnx. " - "Install a compatible sherpa-onnx wheel before using wfloat." + "Failed to import sherpa_onnx. " + "Reinstall wfloat so pip can install the matching wfloat-sherpa-onnx dependency." ) from _BINDINGS_IMPORT_ERROR return bindings diff --git a/python/wfloat/_version.py b/python/wfloat/_version.py index 3b93d0b..5becc17 100644 --- a/python/wfloat/_version.py +++ b/python/wfloat/_version.py @@ -1 +1 @@ -__version__ = "0.0.2" +__version__ = "1.0.0" diff --git a/setup.py b/setup.py index 4e0afab..20bf3d2 100644 --- a/setup.py +++ b/setup.py @@ -35,8 +35,10 @@ def read_package_version() -> str: url="https://github.com/wfloat/wfloat-python", package_dir={"": "python"}, packages=setuptools.find_packages(where="python"), + install_requires=[ + "wfloat-sherpa-onnx==1.12.23", + ], include_package_data=True, - install_requires=["sherpa-onnx>=1.12.23"], entry_points={ "console_scripts": [ "wfloat=wfloat._cli:main", diff --git a/tests/test_basic.py b/tests/test_basic.py index dbdbf03..00aed13 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -1,5 +1,8 @@ import hashlib +import importlib +import sys import tempfile +import types import unittest import zipfile from pathlib import Path @@ -95,6 +98,32 @@ def test_audio_can_write_wave_bytes_without_numpy(self): self.assertTrue(wav_bytes.startswith(b"RIFF")) self.assertGreater(len(wav_bytes), 44) + def test_bindings_import_without_generated_audio(self): + fake_module = types.SimpleNamespace( + GenerationConfig=object(), + OfflineTts=object(), + OfflineTtsConfig=object(), + OfflineTtsModelConfig=object(), + OfflineTtsWfloatModelConfig=object(), + WfloatPreparedText=object(), + git_date="today", + git_sha1="abc123", + prepare_wfloat_text=lambda text, *args, **kwargs: text, + version="1.12.23", + write_wave=lambda *args, **kwargs: None, + ) + + original_module = sys.modules.pop("wfloat._bindings", None) + try: + with mock.patch.dict(sys.modules, {"sherpa_onnx": fake_module}): + bindings = importlib.import_module("wfloat._bindings") + self.assertIs(bindings.OfflineTts, fake_module.OfflineTts) + self.assertNotIn("GeneratedAudio", bindings.__all__) + finally: + sys.modules.pop("wfloat._bindings", None) + if original_module is not None: + sys.modules["wfloat._bindings"] = original_module + def test_generate_returns_audio_and_timeline(self): fake_native_tts = FakeNativeTts(sample_rate=10) model = Model("wfloat/wfloat-tts", fake_native_tts)