wavhost 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.
- wavhost/__init__.py +31 -0
- wavhost/__main__.py +6 -0
- wavhost/backends.py +274 -0
- wavhost/cli.py +471 -0
- wavhost/config.py +73 -0
- wavhost/dependencies.py +266 -0
- wavhost/download.py +195 -0
- wavhost/exceptions.py +50 -0
- wavhost/logging_config.py +50 -0
- wavhost/registry.py +236 -0
- wavhost/server.py +405 -0
- wavhost/storage.py +668 -0
- wavhost-0.1.0.dist-info/METADATA +400 -0
- wavhost-0.1.0.dist-info/RECORD +19 -0
- wavhost-0.1.0.dist-info/WHEEL +5 -0
- wavhost-0.1.0.dist-info/entry_points.txt +2 -0
- wavhost-0.1.0.dist-info/licenses/LICENSE +202 -0
- wavhost-0.1.0.dist-info/licenses/NOTICE +4 -0
- wavhost-0.1.0.dist-info/top_level.txt +1 -0
wavhost/__init__.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Wavhost - Local-first TTS runtime."""
|
|
2
|
+
|
|
3
|
+
from wavhost.backends import TTSBackend, ChatterboxBackend, create_backend
|
|
4
|
+
from wavhost.config import VERSION
|
|
5
|
+
from wavhost.exceptions import (
|
|
6
|
+
WavhostError,
|
|
7
|
+
ModelNotFoundError,
|
|
8
|
+
ModelNotInstalledError,
|
|
9
|
+
BackendError,
|
|
10
|
+
StorageError,
|
|
11
|
+
ValidationError,
|
|
12
|
+
)
|
|
13
|
+
from wavhost.registry import ModelRegistry, ModelInfo
|
|
14
|
+
from wavhost.storage import WavhostStorage
|
|
15
|
+
|
|
16
|
+
__version__ = VERSION
|
|
17
|
+
__all__ = [
|
|
18
|
+
"__version__",
|
|
19
|
+
"TTSBackend",
|
|
20
|
+
"ChatterboxBackend",
|
|
21
|
+
"create_backend",
|
|
22
|
+
"ModelRegistry",
|
|
23
|
+
"ModelInfo",
|
|
24
|
+
"WavhostStorage",
|
|
25
|
+
"WavhostError",
|
|
26
|
+
"ModelNotFoundError",
|
|
27
|
+
"ModelNotInstalledError",
|
|
28
|
+
"BackendError",
|
|
29
|
+
"StorageError",
|
|
30
|
+
"ValidationError",
|
|
31
|
+
]
|
wavhost/__main__.py
ADDED
wavhost/backends.py
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""TTS backend protocol and implementations."""
|
|
2
|
+
|
|
3
|
+
from abc import abstractmethod
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional, Protocol, Union
|
|
6
|
+
|
|
7
|
+
import torch
|
|
8
|
+
|
|
9
|
+
from wavhost import dependencies
|
|
10
|
+
from wavhost.config import CPU_DEVICE, DEFAULT_DEVICE, DEFAULT_SAMPLE_RATE
|
|
11
|
+
from wavhost.exceptions import BackendError
|
|
12
|
+
from wavhost.logging_config import get_logger
|
|
13
|
+
from wavhost.registry import ModelInfo
|
|
14
|
+
|
|
15
|
+
logger = get_logger(__name__)
|
|
16
|
+
|
|
17
|
+
BACKEND_NAME = "chatterbox"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TTSBackend(Protocol):
|
|
21
|
+
"""Protocol for TTS backend implementations.
|
|
22
|
+
|
|
23
|
+
All TTS backends must implement this interface for consistency.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
@abstractmethod
|
|
27
|
+
def generate(
|
|
28
|
+
self,
|
|
29
|
+
text: str,
|
|
30
|
+
voice: Optional[str] = None,
|
|
31
|
+
**kwargs
|
|
32
|
+
) -> tuple[torch.Tensor, int]:
|
|
33
|
+
"""Generate speech from text.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
text: Text to synthesize
|
|
37
|
+
voice: Optional voice identifier or reference audio path
|
|
38
|
+
**kwargs: Backend-specific parameters
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
Tuple of (audio_tensor, sample_rate)
|
|
42
|
+
|
|
43
|
+
Raises:
|
|
44
|
+
BackendError: If generation fails
|
|
45
|
+
"""
|
|
46
|
+
...
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
@abstractmethod
|
|
50
|
+
def sample_rate(self) -> int:
|
|
51
|
+
"""Get the sample rate of generated audio.
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
Sample rate in Hz
|
|
55
|
+
"""
|
|
56
|
+
...
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ChatterboxBackend:
|
|
60
|
+
"""Chatterbox TTS backend implementation.
|
|
61
|
+
|
|
62
|
+
Loads weights from a local checkpoint directory produced by `wavhost pull`
|
|
63
|
+
(never calls the Hugging Face Hub client at runtime).
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def __init__(
|
|
67
|
+
self,
|
|
68
|
+
model_class: str,
|
|
69
|
+
checkpoint_path: Union[str, Path],
|
|
70
|
+
model_kwargs: Optional[dict] = None,
|
|
71
|
+
device: Optional[str] = None
|
|
72
|
+
):
|
|
73
|
+
"""Initialize Chatterbox backend.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
model_class: Name of the model class ('ChatterboxTTS' or 'ChatterboxTurboTTS')
|
|
77
|
+
checkpoint_path: Local directory with model files (from pull)
|
|
78
|
+
model_kwargs: Unused; kept for API compatibility
|
|
79
|
+
device: Device to run on ('cuda', 'cpu', or 'mps'). Auto-detects if None.
|
|
80
|
+
|
|
81
|
+
Raises:
|
|
82
|
+
BackendError: If model class is invalid or checkpoint is missing
|
|
83
|
+
"""
|
|
84
|
+
self._validate_model_class(model_class)
|
|
85
|
+
|
|
86
|
+
self._model_class = model_class
|
|
87
|
+
self._model_kwargs = model_kwargs or {}
|
|
88
|
+
self._checkpoint_path = Path(checkpoint_path)
|
|
89
|
+
self._device = device or self._detect_device()
|
|
90
|
+
self._model = None
|
|
91
|
+
self._sr = DEFAULT_SAMPLE_RATE
|
|
92
|
+
|
|
93
|
+
if not self._checkpoint_path.is_dir():
|
|
94
|
+
raise BackendError(
|
|
95
|
+
f"Checkpoint directory not found: {self._checkpoint_path}. "
|
|
96
|
+
f"Pull the model first with: wavhost pull <model>"
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
logger.info(
|
|
100
|
+
f"Initialized {model_class} backend on {self._device} "
|
|
101
|
+
f"from {self._checkpoint_path}"
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
@staticmethod
|
|
105
|
+
def _validate_model_class(model_class: str) -> None:
|
|
106
|
+
"""Validate that the model class is supported.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
model_class: Model class name
|
|
110
|
+
|
|
111
|
+
Raises:
|
|
112
|
+
BackendError: If model class is invalid
|
|
113
|
+
"""
|
|
114
|
+
valid_classes = {"ChatterboxTTS", "ChatterboxTurboTTS"}
|
|
115
|
+
if model_class not in valid_classes:
|
|
116
|
+
raise BackendError(
|
|
117
|
+
f"Invalid model class '{model_class}'. "
|
|
118
|
+
f"Must be one of: {', '.join(valid_classes)}"
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
@staticmethod
|
|
122
|
+
def _detect_device() -> str:
|
|
123
|
+
"""Detect the best available device.
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
Device string ('cuda', 'mps', or 'cpu')
|
|
127
|
+
"""
|
|
128
|
+
if torch.cuda.is_available():
|
|
129
|
+
return DEFAULT_DEVICE
|
|
130
|
+
elif hasattr(torch.backends, 'mps') and torch.backends.mps.is_available():
|
|
131
|
+
return "mps"
|
|
132
|
+
return CPU_DEVICE
|
|
133
|
+
|
|
134
|
+
def _load_model(self) -> None:
|
|
135
|
+
"""Lazy load the model on first use from the local checkpoint.
|
|
136
|
+
|
|
137
|
+
Raises:
|
|
138
|
+
BackendError: If model loading fails
|
|
139
|
+
"""
|
|
140
|
+
if self._model is not None:
|
|
141
|
+
return
|
|
142
|
+
|
|
143
|
+
if not dependencies.is_installed(BACKEND_NAME):
|
|
144
|
+
raise BackendError(dependencies.missing_engine_message(BACKEND_NAME))
|
|
145
|
+
|
|
146
|
+
logger.info(
|
|
147
|
+
f"Loading {self._model_class} from {self._checkpoint_path}..."
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
if self._model_class == "ChatterboxTurboTTS":
|
|
152
|
+
from chatterbox.tts_turbo import ChatterboxTurboTTS
|
|
153
|
+
self._model = ChatterboxTurboTTS.from_local(
|
|
154
|
+
self._checkpoint_path,
|
|
155
|
+
self._device,
|
|
156
|
+
)
|
|
157
|
+
else:
|
|
158
|
+
from chatterbox.tts import ChatterboxTTS
|
|
159
|
+
self._model = ChatterboxTTS.from_local(
|
|
160
|
+
self._checkpoint_path,
|
|
161
|
+
self._device,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
self._sr = self._model.sr
|
|
165
|
+
logger.info(f"Model loaded successfully (sample rate: {self._sr} Hz)")
|
|
166
|
+
|
|
167
|
+
except ImportError as e:
|
|
168
|
+
raise BackendError(
|
|
169
|
+
f"Failed to import Chatterbox. "
|
|
170
|
+
f"Install with: {dependencies.install_hint(BACKEND_NAME)}\n"
|
|
171
|
+
f"Error: {e}"
|
|
172
|
+
) from e
|
|
173
|
+
except Exception as e:
|
|
174
|
+
# Keep the original type and chain the cause so the real frame
|
|
175
|
+
# (e.g. a broken transitive dependency) is recoverable from logs.
|
|
176
|
+
logger.debug("Chatterbox model load failed", exc_info=True)
|
|
177
|
+
raise BackendError(
|
|
178
|
+
f"Failed to load Chatterbox model: {type(e).__name__}: {e}"
|
|
179
|
+
) from e
|
|
180
|
+
|
|
181
|
+
def generate(
|
|
182
|
+
self,
|
|
183
|
+
text: str,
|
|
184
|
+
voice: Optional[str] = None,
|
|
185
|
+
**kwargs
|
|
186
|
+
) -> tuple[torch.Tensor, int]:
|
|
187
|
+
"""Generate speech from text using Chatterbox.
|
|
188
|
+
|
|
189
|
+
Args:
|
|
190
|
+
text: Text to synthesize
|
|
191
|
+
voice: Optional path to reference voice audio (for voice cloning)
|
|
192
|
+
**kwargs: Additional parameters passed to model.generate()
|
|
193
|
+
|
|
194
|
+
Returns:
|
|
195
|
+
Tuple of (audio_tensor, sample_rate)
|
|
196
|
+
|
|
197
|
+
Raises:
|
|
198
|
+
BackendError: If generation fails
|
|
199
|
+
"""
|
|
200
|
+
self._load_model()
|
|
201
|
+
|
|
202
|
+
generate_kwargs = kwargs.copy()
|
|
203
|
+
|
|
204
|
+
if voice:
|
|
205
|
+
voice_path = Path(voice)
|
|
206
|
+
if not voice_path.exists():
|
|
207
|
+
raise BackendError(f"Reference voice audio not found: {voice}")
|
|
208
|
+
|
|
209
|
+
logger.debug(f"Cloning voice from {voice_path}")
|
|
210
|
+
generate_kwargs['audio_prompt_path'] = str(voice_path)
|
|
211
|
+
|
|
212
|
+
try:
|
|
213
|
+
logger.debug(f"Generating speech for text (length: {len(text)})")
|
|
214
|
+
wav = self._model.generate(text, **generate_kwargs)
|
|
215
|
+
return wav, self._sr
|
|
216
|
+
|
|
217
|
+
except Exception as e:
|
|
218
|
+
raise BackendError(f"Speech generation failed: {e}")
|
|
219
|
+
|
|
220
|
+
@property
|
|
221
|
+
def sample_rate(self) -> int:
|
|
222
|
+
"""Get the sample rate of generated audio.
|
|
223
|
+
|
|
224
|
+
Returns:
|
|
225
|
+
Sample rate in Hz
|
|
226
|
+
"""
|
|
227
|
+
return self._sr
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def create_backend(
|
|
231
|
+
model_info: ModelInfo,
|
|
232
|
+
device: Optional[str] = None,
|
|
233
|
+
checkpoint_path: Optional[Union[str, Path]] = None,
|
|
234
|
+
) -> TTSBackend:
|
|
235
|
+
"""Factory function to create a TTS backend from model info.
|
|
236
|
+
|
|
237
|
+
Args:
|
|
238
|
+
model_info: Model information from registry
|
|
239
|
+
device: Optional device override ('cuda', 'cpu', or 'mps')
|
|
240
|
+
checkpoint_path: Local checkpoint directory from `wavhost pull`
|
|
241
|
+
|
|
242
|
+
Returns:
|
|
243
|
+
Initialized backend instance
|
|
244
|
+
|
|
245
|
+
Raises:
|
|
246
|
+
BackendError: If backend type is not supported or checkpoint is missing
|
|
247
|
+
"""
|
|
248
|
+
backend_type = model_info.backend
|
|
249
|
+
|
|
250
|
+
if backend_type == BACKEND_NAME:
|
|
251
|
+
if checkpoint_path is None:
|
|
252
|
+
raise BackendError(
|
|
253
|
+
"checkpoint_path is required. "
|
|
254
|
+
"Pull the model first with: wavhost pull <model>"
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
actual_device = device or model_info.recommended_device
|
|
258
|
+
|
|
259
|
+
if actual_device == DEFAULT_DEVICE and not torch.cuda.is_available():
|
|
260
|
+
message = "GPU not available, falling back to CPU"
|
|
261
|
+
gpu_hint = dependencies.gpu_build_warning()
|
|
262
|
+
if gpu_hint:
|
|
263
|
+
message = f"{message}\n{gpu_hint}"
|
|
264
|
+
logger.warning(message)
|
|
265
|
+
actual_device = CPU_DEVICE
|
|
266
|
+
|
|
267
|
+
return ChatterboxBackend(
|
|
268
|
+
model_class=model_info.model_class,
|
|
269
|
+
checkpoint_path=checkpoint_path,
|
|
270
|
+
model_kwargs=model_info.model_kwargs,
|
|
271
|
+
device=actual_device
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
raise BackendError(f"Unsupported backend type: {backend_type}")
|