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.
@@ -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__ = []
@@ -0,0 +1,6 @@
1
+ """Permite `python -m tts_sidecar` como vía de invocación equivalente al entry point."""
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
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
@@ -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()