wfloat 0.0.1__tar.gz → 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2 @@
1
+ include README.md
2
+ recursive-include python *.py
wfloat-1.0.0/PKG-INFO ADDED
@@ -0,0 +1,71 @@
1
+ Metadata-Version: 2.4
2
+ Name: wfloat
3
+ Version: 1.0.0
4
+ Summary: High-level Python wrapper for Wfloat TTS
5
+ Home-page: https://github.com/wfloat/wfloat-python
6
+ Author: wfloat
7
+ License: MIT
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Operating System :: Microsoft :: Windows
10
+ Classifier: Operating System :: POSIX :: Linux
11
+ Classifier: Operating System :: MacOS :: MacOS X
12
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
13
+ Requires-Python: >=3.9
14
+ Description-Content-Type: text/markdown
15
+ Requires-Dist: wfloat-sherpa-onnx==1.12.23
16
+ Dynamic: author
17
+ Dynamic: classifier
18
+ Dynamic: description
19
+ Dynamic: description-content-type
20
+ Dynamic: home-page
21
+ Dynamic: license
22
+ Dynamic: requires-dist
23
+ Dynamic: requires-python
24
+ Dynamic: summary
25
+
26
+ # wfloat
27
+
28
+ `wfloat` is a high-level Python wrapper around `sherpa-onnx` for loading
29
+ Wfloat-compatible speech models and generating audio files.
30
+
31
+ ## Install
32
+
33
+ Install `wfloat` normally:
34
+
35
+ ```bash
36
+ pip install wfloat
37
+ ```
38
+
39
+ That will also install the matching `wfloat-sherpa-onnx` dependency from PyPI.
40
+
41
+ When installing from this repo locally:
42
+
43
+ ```bash
44
+ pip install ./packages/wfloat-python
45
+ ```
46
+
47
+ ## Usage
48
+
49
+ ```python
50
+ import wfloat
51
+
52
+ model = wfloat.load("wfloat/wfloat-tts")
53
+
54
+ result = model.generate(
55
+ text="The signal is clean. Start the recording.",
56
+ voice_id="narrator_woman",
57
+ emotion="neutral",
58
+ intensity=0.5,
59
+ speed=1.0,
60
+ )
61
+
62
+ result.audio.save("out.wav")
63
+ ```
64
+
65
+ ## Notes
66
+
67
+ - `wfloat` does not build or bundle native libraries.
68
+ - Low-level bindings come from the installed `wfloat-sherpa-onnx` dependency,
69
+ which provides `import sherpa_onnx`.
70
+ - The public API is intentionally high-level; low-level native config objects
71
+ are re-exported only for advanced use.
wfloat-1.0.0/README.md ADDED
@@ -0,0 +1,46 @@
1
+ # wfloat
2
+
3
+ `wfloat` is a high-level Python wrapper around `sherpa-onnx` for loading
4
+ Wfloat-compatible speech models and generating audio files.
5
+
6
+ ## Install
7
+
8
+ Install `wfloat` normally:
9
+
10
+ ```bash
11
+ pip install wfloat
12
+ ```
13
+
14
+ That will also install the matching `wfloat-sherpa-onnx` dependency from PyPI.
15
+
16
+ When installing from this repo locally:
17
+
18
+ ```bash
19
+ pip install ./packages/wfloat-python
20
+ ```
21
+
22
+ ## Usage
23
+
24
+ ```python
25
+ import wfloat
26
+
27
+ model = wfloat.load("wfloat/wfloat-tts")
28
+
29
+ result = model.generate(
30
+ text="The signal is clean. Start the recording.",
31
+ voice_id="narrator_woman",
32
+ emotion="neutral",
33
+ intensity=0.5,
34
+ speed=1.0,
35
+ )
36
+
37
+ result.audio.save("out.wav")
38
+ ```
39
+
40
+ ## Notes
41
+
42
+ - `wfloat` does not build or bundle native libraries.
43
+ - Low-level bindings come from the installed `wfloat-sherpa-onnx` dependency,
44
+ which provides `import sherpa_onnx`.
45
+ - The public API is intentionally high-level; low-level native config objects
46
+ are re-exported only for advanced use.
@@ -0,0 +1,3 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
@@ -0,0 +1,47 @@
1
+ from ._constants import SPEAKER_IDS, VALID_EMOTIONS, VALID_SIDS
2
+ from ._model import Model, load
3
+ from ._results import Audio, GenerationResult, Timeline, TimelineChunk
4
+ from ._version import __version__
5
+
6
+ _LOW_LEVEL_EXPORTS = {
7
+ "GenerationConfig",
8
+ "OfflineTts",
9
+ "OfflineTtsConfig",
10
+ "OfflineTtsModelConfig",
11
+ "OfflineTtsWfloatModelConfig",
12
+ "WfloatPreparedText",
13
+ "git_date",
14
+ "git_sha1",
15
+ "prepare_wfloat_text",
16
+ "version",
17
+ "write_wave",
18
+ }
19
+
20
+
21
+ __all__ = [
22
+ "Audio",
23
+ "GenerationResult",
24
+ "Model",
25
+ "SPEAKER_IDS",
26
+ "Timeline",
27
+ "TimelineChunk",
28
+ "VALID_EMOTIONS",
29
+ "VALID_SIDS",
30
+ "load",
31
+ ]
32
+ __all__.extend(sorted(_LOW_LEVEL_EXPORTS))
33
+
34
+
35
+ def __getattr__(name):
36
+ if name not in _LOW_LEVEL_EXPORTS:
37
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
38
+
39
+ from . import _bindings
40
+
41
+ value = getattr(_bindings, name)
42
+ globals()[name] = value
43
+ return value
44
+
45
+
46
+ def __dir__():
47
+ return sorted(set(globals()) | _LOW_LEVEL_EXPORTS)
@@ -0,0 +1,5 @@
1
+ from ._cli import main
2
+
3
+
4
+ if __name__ == "__main__": # pragma: no cover
5
+ raise SystemExit(main())
@@ -0,0 +1,124 @@
1
+ import json
2
+ import os
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ from typing import Dict, Optional
6
+ from urllib.parse import urlencode, urlparse
7
+ from urllib.request import Request, urlopen
8
+
9
+ from ._version import __version__ as PACKAGE_VERSION
10
+
11
+
12
+ DEFAULT_MODEL_ASSET_HOST = "https://wfloat.com"
13
+ DEFAULT_MODEL_ASSET_PATH = "/api/model-assets"
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class ModelAssets:
18
+ model_onnx: str
19
+ model_onnx_checksum: str
20
+ model_tokens: str
21
+ model_tokens_checksum: str
22
+ espeak_data: str
23
+ espeak_checksum: str
24
+ persistent_id: Optional[str] = None
25
+
26
+ @classmethod
27
+ def from_dict(cls, data: Dict[str, object]) -> "ModelAssets":
28
+ required_fields = (
29
+ "model_onnx",
30
+ "model_onnx_checksum",
31
+ "model_tokens",
32
+ "model_tokens_checksum",
33
+ "espeak_data",
34
+ "espeak_checksum",
35
+ )
36
+
37
+ missing = [
38
+ field_name
39
+ for field_name in required_fields
40
+ if not isinstance(data.get(field_name), str) or not str(data.get(field_name)).strip()
41
+ ]
42
+ if missing:
43
+ raise ValueError(
44
+ "Model asset response is missing required fields: %s"
45
+ % ", ".join(missing)
46
+ )
47
+
48
+ return cls(
49
+ model_onnx=str(data["model_onnx"]),
50
+ model_onnx_checksum=str(data["model_onnx_checksum"]),
51
+ model_tokens=str(data["model_tokens"]),
52
+ model_tokens_checksum=str(data["model_tokens_checksum"]),
53
+ espeak_data=str(data["espeak_data"]),
54
+ espeak_checksum=str(data["espeak_checksum"]),
55
+ persistent_id=str(data["persistent_id"]).strip()
56
+ if isinstance(data.get("persistent_id"), str) and str(data.get("persistent_id")).strip()
57
+ else None,
58
+ )
59
+
60
+ def to_dict(self) -> Dict[str, str]:
61
+ return {
62
+ "model_onnx": self.model_onnx,
63
+ "model_onnx_checksum": self.model_onnx_checksum,
64
+ "model_tokens": self.model_tokens,
65
+ "model_tokens_checksum": self.model_tokens_checksum,
66
+ "espeak_data": self.espeak_data,
67
+ "espeak_checksum": self.espeak_checksum,
68
+ **({"persistent_id": self.persistent_id} if self.persistent_id else {}),
69
+ }
70
+
71
+
72
+ def get_package_version(default: str = "0.0.0") -> str:
73
+ return PACKAGE_VERSION or default
74
+
75
+
76
+ def get_model_asset_host() -> str:
77
+ return os.environ.get("WFLOAT_MODEL_ASSET_HOST", DEFAULT_MODEL_ASSET_HOST).rstrip("/")
78
+
79
+
80
+ def filename_from_url(url: str, fallback: str) -> str:
81
+ parsed = urlparse(url)
82
+ filename = Path(parsed.path).name
83
+ return filename or fallback
84
+
85
+
86
+ def fetch_model_assets(
87
+ model_name: str,
88
+ *,
89
+ persistent_id: Optional[str] = None,
90
+ package_version_override: Optional[str] = None,
91
+ timeout: float = 60.0,
92
+ ) -> ModelAssets:
93
+ version = package_version_override or get_package_version()
94
+ query = {
95
+ "platform": "python",
96
+ "version": version,
97
+ "model_name": model_name,
98
+ }
99
+ if persistent_id:
100
+ query["persistent_id"] = persistent_id
101
+
102
+ params = urlencode(query)
103
+ url = "%s%s?%s" % (get_model_asset_host(), DEFAULT_MODEL_ASSET_PATH, params)
104
+ request = Request(
105
+ url,
106
+ headers={
107
+ "Accept": "application/json",
108
+ "User-Agent": "wfloat-python/%s" % version,
109
+ },
110
+ method="GET",
111
+ )
112
+
113
+ with urlopen(request, timeout=timeout) as response:
114
+ payload = response.read().decode("utf-8")
115
+
116
+ try:
117
+ data = json.loads(payload)
118
+ except json.JSONDecodeError as exc:
119
+ raise RuntimeError("Failed to decode model asset response JSON.") from exc
120
+
121
+ if not isinstance(data, dict):
122
+ raise RuntimeError("Model asset response must be a JSON object.")
123
+
124
+ return ModelAssets.from_dict(data)
@@ -0,0 +1,58 @@
1
+ try:
2
+ import sherpa_onnx
3
+ except ImportError as exc:
4
+ raise ImportError(
5
+ "Failed to import sherpa_onnx. "
6
+ "Reinstall wfloat so pip can install the matching wfloat-sherpa-onnx dependency."
7
+ ) from exc
8
+
9
+
10
+ _REQUIRED_EXPORTS = (
11
+ "GenerationConfig",
12
+ "OfflineTts",
13
+ "OfflineTtsConfig",
14
+ "OfflineTtsModelConfig",
15
+ "OfflineTtsWfloatModelConfig",
16
+ "WfloatPreparedText",
17
+ "git_date",
18
+ "git_sha1",
19
+ "prepare_wfloat_text",
20
+ "version",
21
+ "write_wave",
22
+ )
23
+
24
+ missing_exports = [name for name in _REQUIRED_EXPORTS if not hasattr(sherpa_onnx, name)]
25
+ if missing_exports:
26
+ raise ImportError(
27
+ "Installed sherpa_onnx is missing required exports: "
28
+ f"{', '.join(missing_exports)}. "
29
+ "Reinstall wfloat so pip can install a compatible wfloat-sherpa-onnx build."
30
+ )
31
+
32
+
33
+ GenerationConfig = sherpa_onnx.GenerationConfig
34
+ OfflineTts = sherpa_onnx.OfflineTts
35
+ OfflineTtsConfig = sherpa_onnx.OfflineTtsConfig
36
+ OfflineTtsModelConfig = sherpa_onnx.OfflineTtsModelConfig
37
+ OfflineTtsWfloatModelConfig = sherpa_onnx.OfflineTtsWfloatModelConfig
38
+ WfloatPreparedText = sherpa_onnx.WfloatPreparedText
39
+ git_date = sherpa_onnx.git_date
40
+ git_sha1 = sherpa_onnx.git_sha1
41
+ prepare_wfloat_text = sherpa_onnx.prepare_wfloat_text
42
+ version = sherpa_onnx.version
43
+ write_wave = sherpa_onnx.write_wave
44
+
45
+
46
+ __all__ = [
47
+ "GenerationConfig",
48
+ "OfflineTts",
49
+ "OfflineTtsConfig",
50
+ "OfflineTtsModelConfig",
51
+ "OfflineTtsWfloatModelConfig",
52
+ "WfloatPreparedText",
53
+ "git_date",
54
+ "git_sha1",
55
+ "prepare_wfloat_text",
56
+ "version",
57
+ "write_wave",
58
+ ]
@@ -0,0 +1,235 @@
1
+ import json
2
+ import os
3
+ import shutil
4
+ import tempfile
5
+ import uuid
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Optional
9
+
10
+ from ._assets import ModelAssets, filename_from_url
11
+ from ._download import (
12
+ download_file,
13
+ extract_archive,
14
+ normalize_checksum,
15
+ resolve_extracted_data_directory,
16
+ verify_checksum,
17
+ )
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class CachedModelAssets:
22
+ model_name: str
23
+ cache_dir: Path
24
+ model_path: Path
25
+ tokens_path: Path
26
+ espeak_data_dir: Path
27
+ manifest_path: Path
28
+
29
+
30
+ def get_default_cache_dir() -> Path:
31
+ if os.name == "nt":
32
+ local_appdata = os.environ.get("LOCALAPPDATA")
33
+ if local_appdata:
34
+ return Path(local_appdata) / "wfloat" / "Cache"
35
+ return Path.home() / "AppData" / "Local" / "wfloat" / "Cache"
36
+
37
+ if sys_platform_startswith("darwin"):
38
+ return Path.home() / "Library" / "Caches" / "wfloat"
39
+
40
+ xdg_cache_home = os.environ.get("XDG_CACHE_HOME")
41
+ if xdg_cache_home:
42
+ return Path(xdg_cache_home) / "wfloat"
43
+
44
+ return Path.home() / ".cache" / "wfloat"
45
+
46
+
47
+ def sys_platform_startswith(prefix: str) -> bool:
48
+ import sys
49
+
50
+ return sys.platform.startswith(prefix)
51
+
52
+
53
+ def normalize_model_name(model_name: str) -> str:
54
+ normalized = model_name.strip().replace("\\", "/")
55
+ normalized = normalized.replace("/", "--")
56
+ normalized = normalized.replace(" ", "-")
57
+ return normalized
58
+
59
+
60
+ def _ensure_directory(path: Path) -> None:
61
+ path.mkdir(parents=True, exist_ok=True)
62
+
63
+
64
+ def get_persistent_id_path(cache_dir: Optional[Path] = None) -> Path:
65
+ cache_root = Path(cache_dir) if cache_dir is not None else get_default_cache_dir()
66
+ return cache_root / "persistent_id"
67
+
68
+
69
+ def load_persistent_id(cache_dir: Optional[Path] = None) -> Optional[str]:
70
+ path = get_persistent_id_path(cache_dir)
71
+ if not path.is_file():
72
+ return None
73
+
74
+ try:
75
+ value = path.read_text(encoding="utf-8").strip()
76
+ except OSError:
77
+ return None
78
+
79
+ return value or None
80
+
81
+
82
+ def save_persistent_id(persistent_id: Optional[str], cache_dir: Optional[Path] = None) -> None:
83
+ if not persistent_id:
84
+ return
85
+
86
+ path = get_persistent_id_path(cache_dir)
87
+ _ensure_directory(path.parent)
88
+ path.write_text(persistent_id.strip() + "\n", encoding="utf-8")
89
+
90
+
91
+ def _cleanup_stale_model_files(model_dir: Path, active_names) -> None:
92
+ if not model_dir.exists():
93
+ return
94
+
95
+ for child in model_dir.iterdir():
96
+ if child.name in active_names:
97
+ continue
98
+ if child.is_file():
99
+ child.unlink()
100
+
101
+
102
+ def _write_manifest(manifest_path: Path, model_name: str, assets: ModelAssets) -> None:
103
+ manifest_payload = {
104
+ "model_name": model_name,
105
+ "assets": assets.to_dict(),
106
+ }
107
+ manifest_path.write_text(json.dumps(manifest_payload, indent=2, sort_keys=True) + "\n")
108
+
109
+
110
+ def _ensure_cached_file(
111
+ source_url: str,
112
+ checksum: str,
113
+ destination: Path,
114
+ downloads_dir: Path,
115
+ *,
116
+ force_download: bool,
117
+ ) -> Path:
118
+ if not force_download and verify_checksum(destination, checksum):
119
+ return destination
120
+
121
+ suffix = destination.suffix or ".bin"
122
+ temp_download = downloads_dir / (uuid.uuid4().hex + suffix)
123
+ download_file(source_url, temp_download, expected_checksum=checksum)
124
+ _ensure_directory(destination.parent)
125
+ os.replace(str(temp_download), str(destination))
126
+ return destination
127
+
128
+
129
+ def _install_espeak_data(
130
+ assets: ModelAssets,
131
+ cache_root: Path,
132
+ downloads_dir: Path,
133
+ *,
134
+ force_download: bool,
135
+ ) -> Path:
136
+ checksum = normalize_checksum(assets.espeak_checksum)
137
+ espeak_root = cache_root / "espeak" / checksum
138
+ data_dir = espeak_root / "espeak-ng-data"
139
+ ready_marker = espeak_root / ".ready"
140
+
141
+ if (
142
+ not force_download
143
+ and ready_marker.is_file()
144
+ and data_dir.is_dir()
145
+ ):
146
+ return data_dir
147
+
148
+ if espeak_root.exists():
149
+ shutil.rmtree(espeak_root)
150
+
151
+ _ensure_directory(espeak_root)
152
+ archive_name = filename_from_url(assets.espeak_data, checksum + ".zip")
153
+ temp_archive = downloads_dir / (uuid.uuid4().hex + "-" + archive_name)
154
+ download_file(
155
+ assets.espeak_data,
156
+ temp_archive,
157
+ expected_checksum=assets.espeak_checksum,
158
+ )
159
+
160
+ extraction_root = Path(
161
+ tempfile.mkdtemp(prefix="wfloat-espeak-", dir=str(downloads_dir))
162
+ )
163
+ try:
164
+ extract_archive(temp_archive, extraction_root)
165
+ resolved_data_dir = resolve_extracted_data_directory(extraction_root)
166
+ if data_dir.exists():
167
+ shutil.rmtree(data_dir)
168
+ shutil.copytree(str(resolved_data_dir), str(data_dir))
169
+ ready_marker.write_text("ready\n")
170
+ finally:
171
+ if temp_archive.exists():
172
+ temp_archive.unlink()
173
+ if extraction_root.exists():
174
+ shutil.rmtree(extraction_root)
175
+
176
+ return data_dir
177
+
178
+
179
+ def cache_model_assets(
180
+ model_name: str,
181
+ assets: ModelAssets,
182
+ *,
183
+ cache_dir: Optional[Path] = None,
184
+ force_download: bool = False,
185
+ ) -> CachedModelAssets:
186
+ cache_root = Path(cache_dir) if cache_dir is not None else get_default_cache_dir()
187
+ models_dir = cache_root / "models"
188
+ downloads_dir = cache_root / "downloads"
189
+
190
+ _ensure_directory(models_dir)
191
+ _ensure_directory(downloads_dir)
192
+
193
+ model_dir = models_dir / normalize_model_name(model_name)
194
+ _ensure_directory(model_dir)
195
+
196
+ model_filename = filename_from_url(assets.model_onnx, "model.onnx")
197
+ tokens_filename = filename_from_url(assets.model_tokens, "tokens.txt")
198
+ manifest_path = model_dir / "manifest.json"
199
+
200
+ _cleanup_stale_model_files(
201
+ model_dir,
202
+ active_names={model_filename, tokens_filename, "manifest.json"},
203
+ )
204
+
205
+ model_path = _ensure_cached_file(
206
+ assets.model_onnx,
207
+ assets.model_onnx_checksum,
208
+ model_dir / model_filename,
209
+ downloads_dir,
210
+ force_download=force_download,
211
+ )
212
+ tokens_path = _ensure_cached_file(
213
+ assets.model_tokens,
214
+ assets.model_tokens_checksum,
215
+ model_dir / tokens_filename,
216
+ downloads_dir,
217
+ force_download=force_download,
218
+ )
219
+ espeak_data_dir = _install_espeak_data(
220
+ assets,
221
+ cache_root,
222
+ downloads_dir,
223
+ force_download=force_download,
224
+ )
225
+
226
+ _write_manifest(manifest_path, model_name, assets)
227
+
228
+ return CachedModelAssets(
229
+ model_name=model_name,
230
+ cache_dir=cache_root,
231
+ model_path=model_path,
232
+ tokens_path=tokens_path,
233
+ espeak_data_dir=espeak_data_dir,
234
+ manifest_path=manifest_path,
235
+ )
@@ -0,0 +1,74 @@
1
+ import argparse
2
+
3
+ from ._model import load
4
+
5
+
6
+ def build_parser() -> argparse.ArgumentParser:
7
+ parser = argparse.ArgumentParser(prog="wfloat")
8
+ subparsers = parser.add_subparsers(dest="command")
9
+
10
+ synth = subparsers.add_parser("synth", help="Generate speech and write a WAV file.")
11
+ synth.add_argument("--model", default="wfloat/wfloat-tts", help="Model name to load.")
12
+ synth.add_argument("--text", required=True, help="Text to synthesize.")
13
+ synth.add_argument("--out", required=True, help="Output WAV path.")
14
+ synth.add_argument("--voice-id", default=None, help="Voice ID name or numeric SID.")
15
+ synth.add_argument("--emotion", default=None, help="Emotion name.")
16
+ synth.add_argument("--intensity", type=float, default=None, help="Emotion intensity.")
17
+ synth.add_argument("--speed", type=float, default=None, help="Speech speed.")
18
+ synth.add_argument(
19
+ "--silence-padding-sec",
20
+ type=float,
21
+ default=None,
22
+ help="Silence padding between generated sentence chunks.",
23
+ )
24
+ synth.add_argument(
25
+ "--cache-dir",
26
+ default=None,
27
+ help="Optional override for the cache directory.",
28
+ )
29
+ synth.add_argument(
30
+ "--force-download",
31
+ action="store_true",
32
+ help="Redownload model assets even if cached copies are present.",
33
+ )
34
+
35
+ return parser
36
+
37
+
38
+ def _maybe_parse_voice_id(value):
39
+ if value is None:
40
+ return None
41
+
42
+ try:
43
+ return int(value)
44
+ except (TypeError, ValueError):
45
+ return value
46
+
47
+
48
+ def main(argv=None) -> int:
49
+ parser = build_parser()
50
+ args = parser.parse_args(argv)
51
+
52
+ if args.command != "synth":
53
+ parser.print_help()
54
+ return 1
55
+
56
+ model = load(
57
+ args.model,
58
+ cache_dir=args.cache_dir,
59
+ force_download=args.force_download,
60
+ )
61
+ result = model.generate(
62
+ text=args.text,
63
+ voice_id=_maybe_parse_voice_id(args.voice_id),
64
+ emotion=args.emotion,
65
+ intensity=args.intensity,
66
+ speed=args.speed,
67
+ silence_padding_sec=args.silence_padding_sec,
68
+ )
69
+ result.audio.save(args.out)
70
+ print(
71
+ "Saved %s (duration=%.2fs, sample_rate=%d)"
72
+ % (args.out, result.audio.duration_sec, result.audio.sample_rate)
73
+ )
74
+ return 0