tts-sidecar 0.2.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.
- tts_sidecar/__init__.py +27 -0
- tts_sidecar/__main__.py +6 -0
- tts_sidecar/audio.py +218 -0
- tts_sidecar/bootstrap.py +72 -0
- tts_sidecar/cli.py +1118 -0
- tts_sidecar/daemon/__init__.py +32 -0
- tts_sidecar/daemon/daemon.py +307 -0
- tts_sidecar/daemon/ipc.py +184 -0
- tts_sidecar/daemon/protocol.py +91 -0
- tts_sidecar/daemon/run.py +156 -0
- tts_sidecar/daemon/server.py +225 -0
- tts_sidecar/engine.py +870 -0
- tts_sidecar/model_cache.py +207 -0
- tts_sidecar/paths.py +68 -0
- tts_sidecar/timing.py +264 -0
- tts_sidecar/voices/default/reference.wav +0 -0
- tts_sidecar/voices/default/speech.wav +0 -0
- tts_sidecar/voices.py +204 -0
- tts_sidecar-0.2.0.dist-info/METADATA +305 -0
- tts_sidecar-0.2.0.dist-info/RECORD +24 -0
- tts_sidecar-0.2.0.dist-info/WHEEL +5 -0
- tts_sidecar-0.2.0.dist-info/entry_points.txt +2 -0
- tts_sidecar-0.2.0.dist-info/licenses/LICENSE +674 -0
- tts_sidecar-0.2.0.dist-info/top_level.txt +1 -0
tts_sidecar/__init__.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""
|
|
2
|
+
TTS Sidecar — síntesis de voz con clonación de voz.
|
|
3
|
+
100% local, licencia GPL-3.0-or-later, soporte para español latinoamericano.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
__version__ = "0.2.0"
|
|
7
|
+
__author__ = "TTS Sidecar Team"
|
|
8
|
+
__license__ = "GPL-3.0-or-later"
|
|
9
|
+
|
|
10
|
+
# Imports perezosos: permite ejecutar --help sin que las dependencias pesadas estén instaladas
|
|
11
|
+
def __getattr__(name):
|
|
12
|
+
"""
|
|
13
|
+
Resuelve imports perezosos de los símbolos públicos del paquete.
|
|
14
|
+
|
|
15
|
+
ChatterboxEngine y AudioPlayer se importan solo cuando se acceden por primera
|
|
16
|
+
vez, evitando cargar torch/chatterbox al invocar subcomandos ligeros como
|
|
17
|
+
--help, version o devices.
|
|
18
|
+
"""
|
|
19
|
+
if name == "ChatterboxEngine":
|
|
20
|
+
from .engine import ChatterboxEngine
|
|
21
|
+
return ChatterboxEngine
|
|
22
|
+
if name == "AudioPlayer":
|
|
23
|
+
from .audio import AudioPlayer
|
|
24
|
+
return AudioPlayer
|
|
25
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
26
|
+
|
|
27
|
+
__all__ = []
|
tts_sidecar/__main__.py
ADDED
tts_sidecar/audio.py
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Reproducción de audio nativa para Windows, Linux y macOS.
|
|
3
|
+
Usa APIs nativas de cada SO para un rendimiento óptimo.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import warnings
|
|
7
|
+
warnings.filterwarnings("ignore", message="pkg_resources is deprecated")
|
|
8
|
+
|
|
9
|
+
import platform
|
|
10
|
+
import sys
|
|
11
|
+
import wave
|
|
12
|
+
import io
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AudioPlayer:
|
|
19
|
+
"""
|
|
20
|
+
Reproducción de audio multiplataforma usando APIs nativas.
|
|
21
|
+
|
|
22
|
+
Prioridad por plataforma:
|
|
23
|
+
1. Windows: winsound (built-in)
|
|
24
|
+
2. Linux: sounddevice (PortAudio)
|
|
25
|
+
3. macOS: afplay (nativo)
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self):
|
|
29
|
+
self.system = platform.system()
|
|
30
|
+
self._player = self._init_player()
|
|
31
|
+
|
|
32
|
+
def _init_player(self):
|
|
33
|
+
"""Inicializa el player de audio apropiado para la plataforma."""
|
|
34
|
+
if self.system == "Windows":
|
|
35
|
+
return self._init_windows()
|
|
36
|
+
elif self.system == "Darwin":
|
|
37
|
+
return self._init_macos()
|
|
38
|
+
elif self.system == "Linux":
|
|
39
|
+
return self._init_linux()
|
|
40
|
+
else:
|
|
41
|
+
raise RuntimeError(f"Plataforma no soportada: {self.system}")
|
|
42
|
+
|
|
43
|
+
def _init_windows(self):
|
|
44
|
+
"""Inicializa el player de audio para Windows."""
|
|
45
|
+
# winsound es built-in: no requiere dependencias externas
|
|
46
|
+
return WindowsAudioPlayer()
|
|
47
|
+
|
|
48
|
+
def _init_macos(self):
|
|
49
|
+
"""Inicializa el player de audio para macOS usando afplay (built-in)."""
|
|
50
|
+
try:
|
|
51
|
+
import subprocess # noqa: F401 — verificación de disponibilidad
|
|
52
|
+
return MacOSAudioPlayer()
|
|
53
|
+
except Exception as e:
|
|
54
|
+
raise RuntimeError(f"Error al inicializar audio en macOS: {e}")
|
|
55
|
+
|
|
56
|
+
def _init_linux(self):
|
|
57
|
+
"""Inicializa el player de audio para Linux."""
|
|
58
|
+
try:
|
|
59
|
+
import sounddevice as sd
|
|
60
|
+
return SoundDevicePlayer(sd)
|
|
61
|
+
except ImportError:
|
|
62
|
+
raise ImportError(
|
|
63
|
+
"No hay librería de reproducción de audio disponible para Linux. "
|
|
64
|
+
"Instala sounddevice."
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
def play(self, audio_bytes: bytes) -> None:
|
|
68
|
+
"""Reproduce audio desde bytes WAV."""
|
|
69
|
+
self._player.play(audio_bytes)
|
|
70
|
+
|
|
71
|
+
def play_file(self, file_path: str) -> None:
|
|
72
|
+
"""Reproduce audio desde un archivo WAV."""
|
|
73
|
+
with open(file_path, 'rb') as f:
|
|
74
|
+
audio_bytes = f.read()
|
|
75
|
+
self.play(audio_bytes)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class WindowsAudioPlayer:
|
|
79
|
+
"""Reproducción de audio en Windows usando winsound (built-in)."""
|
|
80
|
+
|
|
81
|
+
def play(self, audio_bytes: bytes) -> None:
|
|
82
|
+
"""Reproduce bytes WAV en Windows usando winsound built-in."""
|
|
83
|
+
import winsound
|
|
84
|
+
import tempfile
|
|
85
|
+
import os
|
|
86
|
+
|
|
87
|
+
# winsound.PlaySound requiere una ruta de archivo, no bytes en memoria
|
|
88
|
+
with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as f:
|
|
89
|
+
f.write(audio_bytes)
|
|
90
|
+
temp_path = f.name
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
winsound.PlaySound(temp_path, winsound.SND_FILENAME)
|
|
94
|
+
finally:
|
|
95
|
+
os.unlink(temp_path)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class MacOSAudioPlayer:
|
|
99
|
+
"""Reproducción de audio en macOS usando afplay (built-in)."""
|
|
100
|
+
|
|
101
|
+
def __init__(self):
|
|
102
|
+
import subprocess
|
|
103
|
+
self.subprocess = subprocess
|
|
104
|
+
|
|
105
|
+
def play(self, audio_bytes: bytes) -> None:
|
|
106
|
+
"""Reproduce audio usando afplay."""
|
|
107
|
+
import tempfile
|
|
108
|
+
import os
|
|
109
|
+
|
|
110
|
+
# afplay requiere una ruta de archivo
|
|
111
|
+
with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as f:
|
|
112
|
+
f.write(audio_bytes)
|
|
113
|
+
temp_path = f.name
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
self.subprocess.run(['afplay', temp_path], check=True)
|
|
117
|
+
finally:
|
|
118
|
+
os.unlink(temp_path)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class SoundDevicePlayer:
|
|
122
|
+
"""Reproducción de audio multiplataforma usando sounddevice (PortAudio)."""
|
|
123
|
+
|
|
124
|
+
def __init__(self, sd):
|
|
125
|
+
self.sd = sd
|
|
126
|
+
|
|
127
|
+
def play(self, audio_bytes: bytes) -> None:
|
|
128
|
+
"""Reproduce bytes WAV usando sounddevice."""
|
|
129
|
+
wav_io = io.BytesIO(audio_bytes)
|
|
130
|
+
with wave.open(wav_io, 'rb') as wf:
|
|
131
|
+
n_channels = wf.getnchannels()
|
|
132
|
+
sample_rate = wf.getframerate()
|
|
133
|
+
sample_width = wf.getsampwidth()
|
|
134
|
+
audio_data = wf.readframes(wf.getnframes())
|
|
135
|
+
|
|
136
|
+
if sample_width != 2:
|
|
137
|
+
raise ValueError(
|
|
138
|
+
f"WAV con ancho de muestra no soportado ({sample_width * 8} bits); "
|
|
139
|
+
"se espera PCM de 16 bits."
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
# Convierte a float32 normalizado en [-1, 1]
|
|
143
|
+
audio_np = np.frombuffer(audio_data, dtype=np.int16)
|
|
144
|
+
audio_np = audio_np.astype(np.float32) / 32768.0
|
|
145
|
+
|
|
146
|
+
# Un WAV multicanal llega intercalado: sin el reshape, sounddevice lo
|
|
147
|
+
# reproduciría como mono al doble de velocidad.
|
|
148
|
+
if n_channels > 1:
|
|
149
|
+
audio_np = audio_np.reshape((-1, n_channels))
|
|
150
|
+
|
|
151
|
+
self.sd.play(audio_np, samplerate=sample_rate, blocking=True)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def get_audio_devices_with_status() -> tuple[list[dict], bool]:
|
|
155
|
+
"""
|
|
156
|
+
Lista los dispositivos de salida de audio disponibles.
|
|
157
|
+
|
|
158
|
+
Returns:
|
|
159
|
+
Tupla (dispositivos, degraded): `degraded` es True cuando la enumeración
|
|
160
|
+
real falló y se devolvió el fallback genérico "Default" — usado por
|
|
161
|
+
`doctor`/`setup` (WARNING-03) para distinguir un subsistema de audio
|
|
162
|
+
real de uno degradado, algo que `import pycaw` por sí solo no revela.
|
|
163
|
+
"""
|
|
164
|
+
system = platform.system()
|
|
165
|
+
|
|
166
|
+
if system == "Windows":
|
|
167
|
+
try:
|
|
168
|
+
from pycaw.pycaw import AudioUtilities, EDataFlow, DEVICE_STATE
|
|
169
|
+
# Enumera SOLO endpoints de render (salida) activos, descartando los de
|
|
170
|
+
# captura (micrófonos). GetAllDevices() no distingue el data-flow, así que
|
|
171
|
+
# se usa el IMMDeviceEnumerator con eRender, análogo al filtro de Linux.
|
|
172
|
+
enumerator = AudioUtilities.GetDeviceEnumerator()
|
|
173
|
+
collection = enumerator.EnumAudioEndpoints(
|
|
174
|
+
EDataFlow.eRender.value, DEVICE_STATE.ACTIVE.value
|
|
175
|
+
)
|
|
176
|
+
count = collection.GetCount()
|
|
177
|
+
result = []
|
|
178
|
+
for i in range(count):
|
|
179
|
+
dev = AudioUtilities.CreateDevice(collection.Item(i))
|
|
180
|
+
result.append({
|
|
181
|
+
"id": i,
|
|
182
|
+
"name": dev.FriendlyName,
|
|
183
|
+
"latency": getattr(dev, "Latency", 0.0),
|
|
184
|
+
})
|
|
185
|
+
return result, False
|
|
186
|
+
except Exception:
|
|
187
|
+
# No solo ImportError: un fallo COM de pycaw (sesiones RDP, hosts
|
|
188
|
+
# sin audio) también debe degradar al fallback, no crashear.
|
|
189
|
+
return [{"id": 0, "name": "Default", "latency": 0.1}], True
|
|
190
|
+
|
|
191
|
+
elif system in ("Darwin", "Linux"):
|
|
192
|
+
# sounddevice (PortAudio) enumera en ambas plataformas; se filtran los
|
|
193
|
+
# dispositivos de salida, análogo al filtro eRender de Windows.
|
|
194
|
+
try:
|
|
195
|
+
import sounddevice as sd
|
|
196
|
+
return [
|
|
197
|
+
{"id": i, "name": info['name'], "latency": info['default_low_output_latency']}
|
|
198
|
+
for i, info in enumerate(sd.query_devices())
|
|
199
|
+
if info['max_output_channels'] > 0
|
|
200
|
+
], False
|
|
201
|
+
except Exception:
|
|
202
|
+
# No solo ImportError: un fallo de PortAudio en tiempo de
|
|
203
|
+
# enumeración (sin backend ALSA/CoreAudio, host sin audio) también
|
|
204
|
+
# debe degradar al fallback, no crashear, igual que en Windows.
|
|
205
|
+
return [{"id": 0, "name": "Default", "latency": 0.1}], True
|
|
206
|
+
|
|
207
|
+
return [{"id": 0, "name": "Default", "latency": 0.1}], True
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def get_audio_devices() -> list[dict]:
|
|
211
|
+
"""
|
|
212
|
+
Lista los dispositivos de salida de audio disponibles.
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
Lista de dicts con claves 'id', 'name', 'latency'
|
|
216
|
+
"""
|
|
217
|
+
devices, _degraded = get_audio_devices_with_status()
|
|
218
|
+
return devices
|
tts_sidecar/bootstrap.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Bootstrap pre-import de tts-sidecar.
|
|
3
|
+
|
|
4
|
+
Fuente única de la preparación que debe correr antes de importar cualquier
|
|
5
|
+
dependencia pesada (chatterbox, perth, transformers): supresión de warnings,
|
|
6
|
+
variables de entorno, niveles de logging y el mock de `pkg_resources` que
|
|
7
|
+
Python 3.13 necesita porque el módulo fue eliminado de la stdlib pero `perth`
|
|
8
|
+
(dependencia de chatterbox) lo importa en tiempo de import.
|
|
9
|
+
|
|
10
|
+
`apply()` es idempotente y debe invocarse al principio de cada vía de entrada
|
|
11
|
+
del proceso (entry point pip/uv, `bin/tts-sidecar`, `python -m tts_sidecar`,
|
|
12
|
+
subcomando congelado `daemon serve`), antes de cualquier otro import del
|
|
13
|
+
paquete que pueda arrastrar `chatterbox`/`perth` transitivamente.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import importlib.machinery
|
|
17
|
+
import importlib.util
|
|
18
|
+
import logging
|
|
19
|
+
import os
|
|
20
|
+
import sys
|
|
21
|
+
import types
|
|
22
|
+
import warnings
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
_applied = False
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _install_pkg_resources_mock() -> None:
|
|
29
|
+
"""Instala un mock mínimo de `pkg_resources` si no está disponible.
|
|
30
|
+
|
|
31
|
+
El mock debe ser un módulo real con `__spec__`: un objeto bare haría que
|
|
32
|
+
cualquier llamada posterior a `importlib.util.find_spec('pkg_resources')`
|
|
33
|
+
lanzara "pkg_resources.__spec__ is not set" (p. ej. desde el subcomando
|
|
34
|
+
congelado `daemon serve`, que corre en el mismo proceso que el entry point
|
|
35
|
+
del CLI y reconsulta el spec).
|
|
36
|
+
"""
|
|
37
|
+
if 'pkg_resources' in sys.modules:
|
|
38
|
+
return
|
|
39
|
+
if importlib.util.find_spec('pkg_resources') is not None:
|
|
40
|
+
return
|
|
41
|
+
|
|
42
|
+
def _resource_filename(package, resource):
|
|
43
|
+
spec = importlib.util.find_spec(package)
|
|
44
|
+
if spec and spec.submodule_search_locations:
|
|
45
|
+
return str(Path(spec.submodule_search_locations[0]) / resource)
|
|
46
|
+
return resource
|
|
47
|
+
|
|
48
|
+
mock = types.ModuleType('pkg_resources')
|
|
49
|
+
mock.resource_filename = _resource_filename
|
|
50
|
+
mock.__spec__ = importlib.machinery.ModuleSpec('pkg_resources', None)
|
|
51
|
+
sys.modules['pkg_resources'] = mock
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def apply() -> None:
|
|
55
|
+
"""Aplica el bootstrap pre-import. Idempotente: una segunda invocación es no-op."""
|
|
56
|
+
global _applied
|
|
57
|
+
if _applied:
|
|
58
|
+
return
|
|
59
|
+
_applied = True
|
|
60
|
+
|
|
61
|
+
warnings.filterwarnings("ignore")
|
|
62
|
+
os.environ["PYTHONWARNINGS"] = "ignore"
|
|
63
|
+
os.environ["HF_HUB_DISABLE_IMPLICIT_TOKEN"] = "1"
|
|
64
|
+
os.environ["TRANSFORMERS_VERBOSITY"] = "error"
|
|
65
|
+
os.environ["TRANSFORMERS_NO_ADVISORY_WARNINGS"] = "1"
|
|
66
|
+
os.environ["TOKENIZERS_PARALLELISM"] = "false"
|
|
67
|
+
|
|
68
|
+
logging.getLogger("huggingface_hub").setLevel(logging.ERROR)
|
|
69
|
+
logging.getLogger("chatterbox.models.tokenizers.tokenizer").setLevel(logging.ERROR)
|
|
70
|
+
logging.getLogger("chatterbox.models.t3.inference.alignment_stream_analyzer").setLevel(logging.ERROR)
|
|
71
|
+
|
|
72
|
+
_install_pkg_resources_mock()
|