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.
- wfloat-1.0.0/MANIFEST.in +2 -0
- wfloat-1.0.0/PKG-INFO +71 -0
- wfloat-1.0.0/README.md +46 -0
- wfloat-1.0.0/pyproject.toml +3 -0
- wfloat-1.0.0/python/wfloat/__init__.py +47 -0
- wfloat-1.0.0/python/wfloat/__main__.py +5 -0
- wfloat-1.0.0/python/wfloat/_assets.py +124 -0
- wfloat-1.0.0/python/wfloat/_bindings.py +58 -0
- wfloat-1.0.0/python/wfloat/_cache.py +235 -0
- wfloat-1.0.0/python/wfloat/_cli.py +74 -0
- wfloat-1.0.0/python/wfloat/_constants.py +147 -0
- wfloat-1.0.0/python/wfloat/_download.py +130 -0
- wfloat-1.0.0/python/wfloat/_model.py +332 -0
- wfloat-1.0.0/python/wfloat/_native.py +48 -0
- wfloat-1.0.0/python/wfloat/_results.py +90 -0
- wfloat-1.0.0/python/wfloat/_version.py +1 -0
- wfloat-1.0.0/python/wfloat.egg-info/PKG-INFO +71 -0
- wfloat-1.0.0/python/wfloat.egg-info/SOURCES.txt +24 -0
- wfloat-1.0.0/python/wfloat.egg-info/dependency_links.txt +1 -0
- wfloat-1.0.0/python/wfloat.egg-info/entry_points.txt +2 -0
- wfloat-1.0.0/python/wfloat.egg-info/not-zip-safe +1 -0
- wfloat-1.0.0/python/wfloat.egg-info/requires.txt +1 -0
- wfloat-1.0.0/python/wfloat.egg-info/top_level.txt +1 -0
- wfloat-1.0.0/setup.cfg +4 -0
- wfloat-1.0.0/setup.py +55 -0
- wfloat-1.0.0/tests/test_basic.py +277 -0
- wfloat-0.0.1/LICENSE +0 -21
- wfloat-0.0.1/PKG-INFO +0 -17
- wfloat-0.0.1/README.md +0 -3
- wfloat-0.0.1/pyproject.toml +0 -22
- wfloat-0.0.1/src/wfloat/__init__.py +0 -0
- wfloat-0.0.1/src/wfloat/example.py +0 -2
wfloat-1.0.0/MANIFEST.in
ADDED
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,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,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
|