phononsynth 0.1.0__py3-none-any.whl

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,54 @@
1
+ """Phonon synthesis frontend backed by OnnxVoice."""
2
+
3
+ from importlib.metadata import PackageNotFoundError
4
+ from importlib.metadata import version as _distribution_version
5
+
6
+ from .api_contract import REQUEST_API_VERSION, request_api_contract
7
+ from .config import SynthesisConfig
8
+ from .discovery import DescribedVoice, DiscoveredModel, discover_models
9
+ from .errors import (
10
+ CatalogDiscoveryError,
11
+ CatalogUnavailableError,
12
+ EmptyTextError,
13
+ InvalidSynthesisConfigError,
14
+ ManagedModelDependencyError,
15
+ ModelInferenceError,
16
+ OnnxVoiceContractError,
17
+ PhononSynthError,
18
+ TokenizerError,
19
+ UnsupportedModelError,
20
+ )
21
+ from .identity import runtime_identity
22
+ from .types import StreamChunk, SynthesisMeasure, SynthesisResult, VoiceInfo
23
+ from .voice import PhononVoice
24
+
25
+ try:
26
+ __version__ = _distribution_version("phononsynth")
27
+ except PackageNotFoundError:
28
+ __version__ = "0+unknown"
29
+
30
+ __all__ = [
31
+ "__version__",
32
+ "REQUEST_API_VERSION",
33
+ "PhononVoice",
34
+ "SynthesisConfig",
35
+ "SynthesisResult",
36
+ "SynthesisMeasure",
37
+ "StreamChunk",
38
+ "VoiceInfo",
39
+ "DescribedVoice",
40
+ "DiscoveredModel",
41
+ "discover_models",
42
+ "runtime_identity",
43
+ "request_api_contract",
44
+ "PhononSynthError",
45
+ "EmptyTextError",
46
+ "TokenizerError",
47
+ "UnsupportedModelError",
48
+ "ManagedModelDependencyError",
49
+ "OnnxVoiceContractError",
50
+ "CatalogDiscoveryError",
51
+ "CatalogUnavailableError",
52
+ "ModelInferenceError",
53
+ "InvalidSynthesisConfigError",
54
+ ]
@@ -0,0 +1,85 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ from collections.abc import Sequence
6
+
7
+ from .discovery import discover_models
8
+ from .voice import PhononVoice
9
+
10
+
11
+ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
12
+ parser = argparse.ArgumentParser(description="Synthesize one prepared Phonon request")
13
+ parser.add_argument("text", nargs="?")
14
+ parser.add_argument("--model", default="phonon-31m-en")
15
+ parser.add_argument("--quality")
16
+ parser.add_argument("--voice")
17
+ parser.add_argument("--duration-delta", type=float)
18
+ parser.add_argument("--temperature", type=float)
19
+ parser.add_argument("--seed", type=int)
20
+ parser.add_argument("--eos-threshold", type=float)
21
+ parser.add_argument("--frames-after-eos", type=int)
22
+ parser.add_argument("--min-frames-before-eos", type=int)
23
+ parser.add_argument("--max-frames", type=int)
24
+ parser.add_argument("-o", "--output", default="phonon.wav")
25
+ parser.add_argument("--cache-dir")
26
+ parser.add_argument("--catalog-url")
27
+ parser.add_argument("--offline", action="store_true")
28
+ parser.add_argument("--list-models", action="store_true")
29
+ return parser.parse_args(argv)
30
+
31
+
32
+ def main(argv: Sequence[str] | None = None) -> int:
33
+ args = parse_args(argv)
34
+ if args.list_models:
35
+ models = discover_models(
36
+ offline=args.offline,
37
+ cache_dir=args.cache_dir,
38
+ catalog_url=args.catalog_url,
39
+ )
40
+ print(
41
+ json.dumps(
42
+ [
43
+ {
44
+ "id": model.id,
45
+ "release_version": model.release_version,
46
+ "languages": model.languages,
47
+ "qualities": model.quality_choices,
48
+ "voices": model.voice_ids,
49
+ "default_voice": model.default_voice,
50
+ "license": model.license_spdx,
51
+ "commercial_use": model.commercial_use,
52
+ }
53
+ for model in models
54
+ ],
55
+ indent=2,
56
+ )
57
+ )
58
+ return 0
59
+ if not args.text:
60
+ raise SystemExit("text is required unless --list-models is used")
61
+ with PhononVoice.from_pretrained(
62
+ args.model,
63
+ quality=args.quality,
64
+ cache_dir=args.cache_dir,
65
+ catalog_url=args.catalog_url,
66
+ offline=args.offline,
67
+ ) as model:
68
+ result = model.synthesize_prepared(
69
+ args.text,
70
+ voice=args.voice,
71
+ duration_delta=args.duration_delta,
72
+ temperature=args.temperature,
73
+ seed=args.seed,
74
+ eos_threshold=args.eos_threshold,
75
+ frames_after_eos=args.frames_after_eos,
76
+ min_frames_before_eos=args.min_frames_before_eos,
77
+ max_frames=args.max_frames,
78
+ )
79
+ result.save_wav(args.output)
80
+ print(args.output)
81
+ return 0
82
+
83
+
84
+ if __name__ == "__main__":
85
+ raise SystemExit(main())
@@ -0,0 +1,280 @@
1
+ from __future__ import annotations
2
+
3
+ import inspect
4
+ import os
5
+ from collections.abc import Mapping, Sequence
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from .errors import ManagedModelDependencyError, OnnxVoiceContractError, UnsupportedModelError
11
+
12
+ DEFAULT_CATALOG_URL = (
13
+ "https://raw.githubusercontent.com/buchwandler/phonon-onnx-bundles/main/catalog/bundles.json"
14
+ )
15
+
16
+
17
+ @dataclass(frozen=True, slots=True)
18
+ class ResolvedPhononModel:
19
+ ref: str
20
+ model_id: str
21
+ tokenizer_path: Path
22
+ sample_rate: int
23
+ voices: tuple[str, ...]
24
+ default_voice: str | None
25
+ metadata: dict[str, Any]
26
+ installation: Any
27
+
28
+
29
+ def _module() -> Any:
30
+ try:
31
+ import onnxvoice
32
+ except ModuleNotFoundError as exc:
33
+ raise OnnxVoiceContractError("OnnxVoice is required for Phonon synthesis") from exc
34
+ return onnxvoice
35
+
36
+
37
+ def _require_phonon_contract(module: Any) -> None:
38
+ """Fail early when OnnxVoice lacks the released Phonon manager/adapter API."""
39
+ manager_type = getattr(module, "OnnxVoice", None)
40
+ missing: list[str] = []
41
+ if not callable(manager_type):
42
+ missing.append("OnnxVoice manager")
43
+ else:
44
+ required = {
45
+ "OnnxVoice constructor": (manager_type, ("cache_dir", "catalog_sources", "offline")),
46
+ "OnnxVoice.install": (
47
+ getattr(manager_type, "install", None),
48
+ ("quality", "refresh", "force", "progress"),
49
+ ),
50
+ "OnnxVoice.open": (
51
+ getattr(manager_type, "open", None),
52
+ ("providers", "provider_options", "session_options"),
53
+ ),
54
+ "OnnxVoice.open_local": (
55
+ getattr(manager_type, "open_local", None),
56
+ ("system", "artifacts", "providers", "provider_options", "session_options"),
57
+ ),
58
+ }
59
+ for label, (callable_obj, parameters) in required.items():
60
+ if not callable(callable_obj):
61
+ missing.append(label)
62
+ continue
63
+ try:
64
+ signature = inspect.signature(callable_obj)
65
+ except (TypeError, ValueError):
66
+ missing.append(f"inspectable {label} signature")
67
+ continue
68
+ accepts_keywords = any(
69
+ parameter.kind is inspect.Parameter.VAR_KEYWORD
70
+ for parameter in signature.parameters.values()
71
+ )
72
+ absent = [name for name in parameters if name not in signature.parameters]
73
+ if absent and not accepts_keywords:
74
+ missing.append(f"{label} parameters: {', '.join(absent)}")
75
+ try:
76
+ from onnxvoice.systems import get_adapter
77
+
78
+ get_adapter("phonon")
79
+ except Exception as exc:
80
+ missing.append(f"registered Phonon adapter ({exc})")
81
+ if missing:
82
+ details = "; ".join(missing)
83
+ raise OnnxVoiceContractError(
84
+ "Installed OnnxVoice does not provide the required Phonon contract "
85
+ f"({details}). Install a compatible release: onnxvoice>=0.2.15,<0.3."
86
+ )
87
+
88
+
89
+ def _optional_dependency_error(exc: Exception) -> ManagedModelDependencyError | None:
90
+ try:
91
+ from onnxvoice.errors import OptionalDependencyError
92
+ except ImportError:
93
+ return None
94
+ if not isinstance(exc, OptionalDependencyError):
95
+ return None
96
+
97
+ detail = str(exc)
98
+ normalized = detail.casefold()
99
+ if "huggingface" in normalized or "hugging face" in normalized:
100
+ hint = "Install 'phononsynth[huggingface]' only when using Hugging Face downloads."
101
+ elif any(
102
+ token in normalized for token in ("onnxruntime", "onnx runtime", "execution provider")
103
+ ):
104
+ hint = "For CPU inference, install 'phononsynth[cpu]'."
105
+ else:
106
+ hint = ""
107
+ suffix = f" {hint}" if hint else ""
108
+ return ManagedModelDependencyError(
109
+ f"OnnxVoice reports a missing optional dependency: {detail}.{suffix}"
110
+ )
111
+
112
+
113
+ def normalize_ref(value: str) -> str:
114
+ value = str(value).strip()
115
+ if not value:
116
+ raise ValueError("model reference cannot be empty")
117
+ return value if ":" in value else f"phonon:{value}"
118
+
119
+
120
+ def _manager(*, cache_dir: str | Path | None, offline: bool, catalog_url: str | None) -> Any:
121
+ module = _module()
122
+ _require_phonon_contract(module)
123
+ source = catalog_url or os.getenv("PHONONSYNTH_CATALOG_URL") or DEFAULT_CATALOG_URL
124
+ try:
125
+ return module.OnnxVoice(
126
+ cache_dir=cache_dir,
127
+ catalog_sources={"phonon": source},
128
+ offline=offline,
129
+ )
130
+ except TypeError as exc:
131
+ raise OnnxVoiceContractError(
132
+ "Installed OnnxVoice does not expose the expected manager constructor"
133
+ ) from exc
134
+
135
+
136
+ def installation_to_model(installation: Any) -> ResolvedPhononModel:
137
+ if str(getattr(installation, "system", "")) != "phonon":
138
+ raise UnsupportedModelError("expected an OnnxVoice Phonon installation")
139
+ try:
140
+ tokenizer_path = Path(installation.artifact("tokenizer").path)
141
+ except (KeyError, AttributeError) as exc:
142
+ raise UnsupportedModelError("Phonon installation must contain tokenizer.json") from exc
143
+ metadata = dict(getattr(installation, "metadata", {}) or {})
144
+ sample_rate = int(
145
+ getattr(installation, "sample_rate", None)
146
+ or (metadata.get("runtime") or {}).get("sample_rate")
147
+ or 24000
148
+ )
149
+ voices = tuple(str(value) for value in (getattr(installation, "voices", ()) or ()))
150
+ default_voice = getattr(installation, "default_voice", None)
151
+ return ResolvedPhononModel(
152
+ ref=str(getattr(installation, "ref", f"phonon:{getattr(installation, 'id', 'unknown')}")),
153
+ model_id=str(getattr(installation, "id", "unknown")),
154
+ tokenizer_path=tokenizer_path,
155
+ sample_rate=sample_rate,
156
+ voices=voices,
157
+ default_voice=default_voice if isinstance(default_voice, str) else None,
158
+ metadata=metadata,
159
+ installation=installation,
160
+ )
161
+
162
+
163
+ def install_pretrained_model(
164
+ ref: str,
165
+ *,
166
+ quality: str | None = None,
167
+ cache_dir: str | Path | None = None,
168
+ offline: bool = False,
169
+ refresh_catalog: bool = False,
170
+ force_download: bool = False,
171
+ catalog_url: str | None = None,
172
+ progress: Any | None = None,
173
+ ) -> ResolvedPhononModel:
174
+ manager = _manager(cache_dir=cache_dir, offline=offline, catalog_url=catalog_url)
175
+ try:
176
+ installation = manager.install(
177
+ normalize_ref(ref),
178
+ quality=quality,
179
+ refresh=refresh_catalog,
180
+ force=force_download,
181
+ progress=progress,
182
+ )
183
+ except Exception as exc:
184
+ dependency_error = _optional_dependency_error(exc)
185
+ if dependency_error is not None:
186
+ raise dependency_error from exc
187
+ raise
188
+ return installation_to_model(installation)
189
+
190
+
191
+ def open_installed_model(
192
+ resolved: ResolvedPhononModel,
193
+ *,
194
+ providers: Sequence[Any] | str | None = None,
195
+ provider_options: Sequence[dict[str, Any]] | dict[str, dict[str, Any]] | None = None,
196
+ session_options: Any | None = None,
197
+ cache_dir: str | Path | None = None,
198
+ offline: bool = False,
199
+ catalog_url: str | None = None,
200
+ ) -> Any:
201
+ manager = _manager(cache_dir=cache_dir, offline=offline, catalog_url=catalog_url)
202
+ return manager.open(
203
+ resolved.installation,
204
+ providers=providers,
205
+ provider_options=provider_options,
206
+ session_options=session_options,
207
+ )
208
+
209
+
210
+ def local_artifacts(bundle_dir: str | Path) -> tuple[dict[str, Path], dict[str, Any]]:
211
+ root = Path(bundle_dir)
212
+ config_path = root / "config.json"
213
+ tokenizer_path = root / "tokenizer.json"
214
+ if not config_path.is_file() or not tokenizer_path.is_file():
215
+ raise UnsupportedModelError("local Phonon bundle requires config.json and tokenizer.json")
216
+ import json
217
+
218
+ try:
219
+ config = json.loads(config_path.read_text(encoding="utf-8"))
220
+ except (OSError, json.JSONDecodeError) as exc:
221
+ raise UnsupportedModelError("local Phonon config.json is invalid") from exc
222
+ if not isinstance(config, Mapping):
223
+ raise UnsupportedModelError("local Phonon config.json must contain an object")
224
+
225
+ artifacts: dict[str, Path] = {"config": config_path, "tokenizer": tokenizer_path}
226
+ for component in ("text_conditioner", "flow_lm_main", "flow_lm_flow", "mimi_decoder"):
227
+ path = root / "onnx" / f"{component}.onnx"
228
+ if not path.is_file():
229
+ raise UnsupportedModelError(f"local Phonon bundle is missing {path.relative_to(root)}")
230
+ artifacts[f"model:{component}"] = path
231
+
232
+ runtime_config = config.get("runtime", {})
233
+ if runtime_config is not None and not isinstance(runtime_config, Mapping):
234
+ raise UnsupportedModelError("local Phonon config runtime must be an object")
235
+ if isinstance(runtime_config, Mapping) and "sample_rate" in runtime_config:
236
+ sample_rate = runtime_config["sample_rate"]
237
+ if isinstance(sample_rate, bool) or not isinstance(sample_rate, int) or sample_rate <= 0:
238
+ raise UnsupportedModelError(
239
+ "local Phonon runtime.sample_rate must be a positive integer"
240
+ )
241
+
242
+ raw_voices = config.get("voices")
243
+ if isinstance(raw_voices, Mapping):
244
+ declarations = list(raw_voices.items())
245
+ if any(not isinstance(entry, Mapping) for _, entry in declarations):
246
+ raise UnsupportedModelError("local Phonon mapping voices must contain objects")
247
+ elif isinstance(raw_voices, Sequence) and not isinstance(raw_voices, (str, bytes)):
248
+ declarations = [
249
+ (entry.get("name"), entry) for entry in raw_voices if isinstance(entry, Mapping)
250
+ ]
251
+ if len(declarations) != len(raw_voices):
252
+ raise UnsupportedModelError("local Phonon config contains an invalid voice")
253
+ else:
254
+ raise UnsupportedModelError("local Phonon config must declare voices")
255
+
256
+ voices: list[str] = []
257
+ for name, _entry in declarations:
258
+ if (
259
+ not isinstance(name, str)
260
+ or not name
261
+ or name != name.strip()
262
+ or name in {".", ".."}
263
+ or "/" in name
264
+ or "\\" in name
265
+ or "\x00" in name
266
+ or Path(name).is_absolute()
267
+ ):
268
+ raise UnsupportedModelError("local Phonon config contains an unsafe voice name")
269
+ if name in voices:
270
+ raise UnsupportedModelError(f"local Phonon config declares duplicate voice {name!r}")
271
+ prompt = root / "voices" / f"{name}.bin"
272
+ if not prompt.is_file():
273
+ raise UnsupportedModelError(f"local Phonon bundle is missing voices/{name}.bin")
274
+ artifacts[f"voice_prompt:{name}"] = prompt
275
+ voices.append(name)
276
+
277
+ default_voice = config.get("default_voice")
278
+ if default_voice is not None and default_voice not in voices:
279
+ raise UnsupportedModelError("local Phonon default_voice must be declared in voices")
280
+ return artifacts, {**dict(config), "_voices": tuple(voices)}
@@ -0,0 +1,41 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Sequence
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from .errors import TokenizerError
8
+
9
+
10
+ def load_tokenizer(path: str | Path) -> Any:
11
+ try:
12
+ from tokenizers import Tokenizer
13
+ except ModuleNotFoundError as exc:
14
+ raise TokenizerError(
15
+ "The 'tokenizers' package is required to read Phonon tokenizer.json"
16
+ ) from exc
17
+ try:
18
+ return Tokenizer.from_file(str(Path(path)))
19
+ except Exception as exc:
20
+ raise TokenizerError(f"Could not load Phonon tokenizer from {path}: {exc}") from exc
21
+
22
+
23
+ def encode_text(tokenizer: Any, text: str) -> tuple[int, ...]:
24
+ try:
25
+ encoded = tokenizer.encode(text, add_special_tokens=True)
26
+ except TypeError:
27
+ encoded = tokenizer.encode(text)
28
+ except Exception as exc:
29
+ raise TokenizerError(f"Phonon tokenization failed: {exc}") from exc
30
+
31
+ values: Any = getattr(encoded, "ids", encoded)
32
+ if isinstance(values, (str, bytes)) or not isinstance(values, Sequence):
33
+ raise TokenizerError("Phonon tokenizer returned an unsupported token sequence")
34
+ result: list[int] = []
35
+ for value in values:
36
+ if isinstance(value, bool) or not isinstance(value, int) or value < 0:
37
+ raise TokenizerError("Phonon tokenizer returned invalid token IDs")
38
+ result.append(value)
39
+ if not result:
40
+ raise TokenizerError("Phonon tokenizer returned no token IDs")
41
+ return tuple(result)
@@ -0,0 +1,23 @@
1
+ from __future__ import annotations
2
+
3
+ REQUEST_API_VERSION = "1"
4
+
5
+
6
+ def request_api_contract() -> dict[str, object]:
7
+ return {
8
+ "version": REQUEST_API_VERSION,
9
+ "engine": "phonon",
10
+ "entrypoint": "PhononVoice.synthesize_prepared",
11
+ "stream_entrypoint": "PhononVoice.stream_prepared",
12
+ "measure_entrypoint": "PhononVoice.measure_prepared",
13
+ "input": "prepared_text",
14
+ "measurement_unit": "model_tokens",
15
+ "max_input_tokens": None,
16
+ "features": {
17
+ "named_voices": True,
18
+ "streaming": True,
19
+ "voice_cloning": False,
20
+ "word_timings": False,
21
+ "automatic_chunking": False,
22
+ },
23
+ }
phononsynth/config.py ADDED
@@ -0,0 +1,69 @@
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ from dataclasses import dataclass
5
+
6
+ from .errors import InvalidSynthesisConfigError
7
+
8
+
9
+ def _finite_optional(
10
+ value: float | None, name: str, *, minimum: float | None = None
11
+ ) -> float | None:
12
+ if value is None:
13
+ return None
14
+ number = float(value)
15
+ if not math.isfinite(number) or (minimum is not None and number < minimum):
16
+ raise InvalidSynthesisConfigError(f"{name} is outside its valid range")
17
+ return number
18
+
19
+
20
+ def _int_optional(value: int | None, name: str, *, minimum: int) -> int | None:
21
+ if value is None:
22
+ return None
23
+ if isinstance(value, bool) or not isinstance(value, int) or value < minimum:
24
+ raise InvalidSynthesisConfigError(f"{name} must be an integer >= {minimum}")
25
+ return value
26
+
27
+
28
+ @dataclass(frozen=True, slots=True)
29
+ class SynthesisConfig:
30
+ """Optional Phonon generation overrides.
31
+
32
+ ``None`` uses the selected bundle default, or a Phonon-compatible fallback when absent.
33
+ """
34
+
35
+ duration_delta: float | None = None
36
+ temperature: float | None = None
37
+ seed: int | None = None
38
+ eos_threshold: float | None = None
39
+ frames_after_eos: int | None = None
40
+ min_frames_before_eos: int | None = None
41
+ max_frames: int | None = None
42
+
43
+ def validated(self) -> SynthesisConfig:
44
+ seed = self.seed
45
+ if seed is not None and (isinstance(seed, bool) or not isinstance(seed, int)):
46
+ raise InvalidSynthesisConfigError("seed must be an integer or None")
47
+ return SynthesisConfig(
48
+ duration_delta=_finite_optional(self.duration_delta, "duration_delta"),
49
+ temperature=_finite_optional(self.temperature, "temperature", minimum=0.0),
50
+ seed=seed,
51
+ eos_threshold=_finite_optional(self.eos_threshold, "eos_threshold"),
52
+ frames_after_eos=_int_optional(self.frames_after_eos, "frames_after_eos", minimum=0),
53
+ min_frames_before_eos=_int_optional(
54
+ self.min_frames_before_eos, "min_frames_before_eos", minimum=0
55
+ ),
56
+ max_frames=_int_optional(self.max_frames, "max_frames", minimum=1),
57
+ )
58
+
59
+ def runtime_kwargs(self) -> dict[str, object | None]:
60
+ value = self.validated()
61
+ return {
62
+ "duration_delta": value.duration_delta,
63
+ "temperature": value.temperature,
64
+ "seed": value.seed,
65
+ "eos_threshold": value.eos_threshold,
66
+ "frames_after_eos": value.frames_after_eos,
67
+ "min_frames_before_eos": value.min_frames_before_eos,
68
+ "max_frames": value.max_frames,
69
+ }