intellema-vdk 0.2.0__py3-none-any.whl → 0.2.2__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.
- intellema_vdk/__init__.py +67 -10
- intellema_vdk/config.py +14 -0
- intellema_vdk/providers/__init__.py +35 -0
- intellema_vdk/providers/livekit/__init__.py +19 -0
- intellema_vdk/providers/livekit/client.py +612 -0
- intellema_vdk/providers/livekit/exceptions.py +23 -0
- intellema_vdk/providers/protocols.py +33 -0
- intellema_vdk/providers/retell/__init__.py +17 -0
- intellema_vdk/providers/retell/client.py +468 -0
- intellema_vdk/providers/retell/exceptions.py +19 -0
- intellema_vdk/{retell_lib → providers/retell}/import_phone_number.py +1 -1
- intellema_vdk/stt/__init__.py +17 -0
- intellema_vdk/stt/client.py +482 -0
- intellema_vdk/stt/exceptions.py +19 -0
- intellema_vdk/tts/__init__.py +15 -0
- intellema_vdk/tts/__pycache__/__init__.cpython-312.pyc +0 -0
- intellema_vdk/tts/__pycache__/client.cpython-312.pyc +0 -0
- intellema_vdk/tts/__pycache__/exceptions.cpython-312.pyc +0 -0
- intellema_vdk/tts/__pycache__/providers.cpython-312.pyc +0 -0
- intellema_vdk/tts/client.py +541 -0
- intellema_vdk/tts/exceptions.py +15 -0
- intellema_vdk/tts/providers.py +293 -0
- intellema_vdk/utils/logger_config.py +41 -0
- intellema_vdk-0.2.2.dist-info/METADATA +311 -0
- intellema_vdk-0.2.2.dist-info/RECORD +29 -0
- {intellema_vdk-0.2.0.dist-info → intellema_vdk-0.2.2.dist-info}/WHEEL +1 -1
- intellema_vdk/livekit_lib/__init__.py +0 -3
- intellema_vdk/livekit_lib/client.py +0 -280
- intellema_vdk/retell_lib/retell_client.py +0 -248
- intellema_vdk/speech_lib/__init__.py +0 -2
- intellema_vdk/speech_lib/stt_client.py +0 -108
- intellema_vdk/speech_lib/tts_streamer.py +0 -188
- intellema_vdk-0.2.0.dist-info/METADATA +0 -221
- intellema_vdk-0.2.0.dist-info/RECORD +0 -14
- /intellema_vdk/{retell_lib/__init__.py → stt/providers.py} +0 -0
- {intellema_vdk-0.2.0.dist-info → intellema_vdk-0.2.2.dist-info}/licenses/LICENSE +0 -0
- {intellema_vdk-0.2.0.dist-info → intellema_vdk-0.2.2.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
"""TTS streaming client for real-time text-to-speech audio playback."""
|
|
2
|
+
|
|
3
|
+
import queue
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
import logging
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import platform
|
|
10
|
+
from typing import Optional, Union, Literal, overload, TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
# Lazy import pyaudio - only load when TTSStreamer is instantiated
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
import pyaudio
|
|
15
|
+
else:
|
|
16
|
+
pyaudio = None
|
|
17
|
+
|
|
18
|
+
from .providers import (
|
|
19
|
+
TTSProvider,
|
|
20
|
+
TogetherTTSProvider,
|
|
21
|
+
OpenAITTSProvider,
|
|
22
|
+
TogetherTTSConfig,
|
|
23
|
+
OpenAITTSConfig,
|
|
24
|
+
)
|
|
25
|
+
from ..config import (
|
|
26
|
+
get_env,
|
|
27
|
+
TTS_AUDIO_SAMPLE_RATE,
|
|
28
|
+
)
|
|
29
|
+
from .exceptions import (
|
|
30
|
+
TTSConfigurationError,
|
|
31
|
+
TTSStreamError,
|
|
32
|
+
TTSAPIError,
|
|
33
|
+
TTSError
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# Setup logger for this module.
|
|
38
|
+
logger = logging.getLogger(__name__)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class TTSStreamer:
|
|
44
|
+
"""
|
|
45
|
+
Real-time text-to-speech streaming and playback.
|
|
46
|
+
|
|
47
|
+
Streams text-to-speech audio from various providers (Together AI, OpenAI)
|
|
48
|
+
and plays it in real-time with minimal latency. Designed for continuous
|
|
49
|
+
text streams (e.g., from language models) with immediate audio feedback.
|
|
50
|
+
|
|
51
|
+
The streamer uses separate threads for fetching and playing audio to
|
|
52
|
+
ensure smooth, non-blocking playback. Text is buffered until sentence
|
|
53
|
+
boundaries are detected, then immediately converted to speech.
|
|
54
|
+
|
|
55
|
+
Supported Providers:
|
|
56
|
+
- Together AI: Low-latency streaming with Orpheus model
|
|
57
|
+
- OpenAI: High-quality voices with tts-1 and tts-1-hd models
|
|
58
|
+
|
|
59
|
+
Attributes:
|
|
60
|
+
provider: The TTS provider instance for generating audio.
|
|
61
|
+
p: PyAudio instance for audio I/O.
|
|
62
|
+
stream: Audio stream for playback (24kHz, 16-bit PCM, mono).
|
|
63
|
+
text_queue: Thread-safe queue for incoming text sentences.
|
|
64
|
+
audio_queue: Thread-safe queue for outgoing audio chunks.
|
|
65
|
+
text_buffer: Buffer accumulating text until sentence completion.
|
|
66
|
+
is_running: Flag controlling thread execution state.
|
|
67
|
+
threads_started: Flag indicating if worker threads are active.
|
|
68
|
+
|
|
69
|
+
Examples:
|
|
70
|
+
Basic usage with Together AI:
|
|
71
|
+
>>> streamer = TTSStreamer(provider="together")
|
|
72
|
+
>>> streamer.feed("Hello, world. ")
|
|
73
|
+
>>> streamer.feed("How are you?")
|
|
74
|
+
>>> streamer.flush()
|
|
75
|
+
>>> streamer.close()
|
|
76
|
+
|
|
77
|
+
Using OpenAI with custom voice:
|
|
78
|
+
>>> streamer = TTSStreamer(
|
|
79
|
+
... provider="openai",
|
|
80
|
+
... voice="nova",
|
|
81
|
+
... model="tts-1-hd"
|
|
82
|
+
... )
|
|
83
|
+
>>> for token in text.split():
|
|
84
|
+
... streamer.feed(token + " ")
|
|
85
|
+
>>> streamer.flush()
|
|
86
|
+
>>> streamer.close()
|
|
87
|
+
|
|
88
|
+
Streaming LLM output:
|
|
89
|
+
>>> streamer = TTSStreamer(provider="together")
|
|
90
|
+
>>> for chunk in llm_stream:
|
|
91
|
+
... streamer.feed(chunk)
|
|
92
|
+
>>> streamer.flush()
|
|
93
|
+
>>> streamer.close()
|
|
94
|
+
|
|
95
|
+
Notes:
|
|
96
|
+
- Call flush() to wait for current audio to finish playing
|
|
97
|
+
- Call close() to stop all threads and release audio resources
|
|
98
|
+
- Requires system audio output and PyAudio installed
|
|
99
|
+
- Works best with whole words/phrases but also handles character streaming
|
|
100
|
+
- Sentence boundaries (. ! ? \n) trigger immediate speech synthesis
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
@overload
|
|
104
|
+
def __init__(
|
|
105
|
+
self,
|
|
106
|
+
provider: Literal["together"],
|
|
107
|
+
api_key: Optional[str] = None,
|
|
108
|
+
model: Literal["canopylabs/orpheus-3b-0.1-ft"] = "canopylabs/orpheus-3b-0.1-ft",
|
|
109
|
+
voice: Literal["tara"] = "tara",
|
|
110
|
+
) -> None: ...
|
|
111
|
+
|
|
112
|
+
@overload
|
|
113
|
+
def __init__(
|
|
114
|
+
self,
|
|
115
|
+
provider: Literal["openai"],
|
|
116
|
+
api_key: Optional[str] = None,
|
|
117
|
+
model: Literal["tts-1", "tts-1-hd"] = "tts-1",
|
|
118
|
+
voice: Literal["alloy", "echo", "fable", "onyx", "nova", "shimmer"] = "alloy",
|
|
119
|
+
) -> None: ...
|
|
120
|
+
|
|
121
|
+
@overload
|
|
122
|
+
def __init__(
|
|
123
|
+
self,
|
|
124
|
+
provider: TTSProvider,
|
|
125
|
+
api_key: Optional[str] = None,
|
|
126
|
+
**provider_kwargs
|
|
127
|
+
) -> None: ...
|
|
128
|
+
|
|
129
|
+
def __init__(self,
|
|
130
|
+
provider: Union[Literal["together", "openai"], TTSProvider] = "together",
|
|
131
|
+
api_key: Optional[str] = None,
|
|
132
|
+
**provider_kwargs) -> None:
|
|
133
|
+
"""
|
|
134
|
+
Initialize the TTS streamer with a provider.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
provider: Either a provider name or a custom TTSProvider instance.
|
|
138
|
+
- "together": Use Together AI (requires TOGETHER_API_KEY)
|
|
139
|
+
- "openai": Use OpenAI (requires OPENAI_API_KEY)
|
|
140
|
+
- TTSProvider instance: Use custom provider
|
|
141
|
+
api_key: API key for the provider. If not provided, reads from
|
|
142
|
+
environment variables (TOGETHER_API_KEY or OPENAI_API_KEY).
|
|
143
|
+
**provider_kwargs: Provider-specific configuration options:
|
|
144
|
+
|
|
145
|
+
For Together AI:
|
|
146
|
+
model: Model identifier (default: "canopylabs/orpheus-3b-0.1-ft")
|
|
147
|
+
voice: Voice identifier (default: "tara")
|
|
148
|
+
|
|
149
|
+
For OpenAI:
|
|
150
|
+
model: "tts-1" (fast) or "tts-1-hd" (high quality)
|
|
151
|
+
voice: "alloy", "echo", "fable", "onyx", "nova", or "shimmer"
|
|
152
|
+
|
|
153
|
+
Raises:
|
|
154
|
+
TTSConfigurationError: If API key is missing or provider is invalid.
|
|
155
|
+
TTSStreamError: If audio stream initialization fails.
|
|
156
|
+
ImportError: If required dependencies (pyaudio, provider SDK) are missing.
|
|
157
|
+
|
|
158
|
+
Examples:
|
|
159
|
+
>>> # Together AI with defaults
|
|
160
|
+
>>> streamer = TTSStreamer(provider="together")
|
|
161
|
+
|
|
162
|
+
>>> # OpenAI with custom voice
|
|
163
|
+
>>> streamer = TTSStreamer(
|
|
164
|
+
... provider="openai",
|
|
165
|
+
... voice="nova",
|
|
166
|
+
... model="tts-1-hd"
|
|
167
|
+
... )
|
|
168
|
+
|
|
169
|
+
>>> # Custom provider
|
|
170
|
+
>>> custom = MyCustomProvider()
|
|
171
|
+
>>> streamer = TTSStreamer(provider=custom)
|
|
172
|
+
"""
|
|
173
|
+
# Lazy import pyaudio - only install when actually used
|
|
174
|
+
global pyaudio
|
|
175
|
+
if pyaudio is None:
|
|
176
|
+
try:
|
|
177
|
+
import pyaudio as _pyaudio
|
|
178
|
+
pyaudio = _pyaudio
|
|
179
|
+
except ImportError:
|
|
180
|
+
print("\n" + "="*70)
|
|
181
|
+
print("PyAudio is not installed.")
|
|
182
|
+
print("="*70)
|
|
183
|
+
print("\nPyAudio requires the PortAudio library to be installed on your system.")
|
|
184
|
+
print("\nInstallation instructions by platform:")
|
|
185
|
+
print("\n Windows:")
|
|
186
|
+
print(" Option 1: pip install pipwin && pipwin install pyaudio")
|
|
187
|
+
print(" Option 2: pip install pyaudio")
|
|
188
|
+
print(" Option 3: Download wheel from https://www.lfd.uci.edu/~gohlke/pythonlibs/#pyaudio")
|
|
189
|
+
print("\n macOS:")
|
|
190
|
+
print(" brew install portaudio")
|
|
191
|
+
print(" pip install pyaudio")
|
|
192
|
+
print("\n Linux (Debian/Ubuntu):")
|
|
193
|
+
print(" sudo apt-get install portaudio19-dev")
|
|
194
|
+
print(" pip install pyaudio")
|
|
195
|
+
print("\n Linux (Fedora):")
|
|
196
|
+
print(" sudo dnf install portaudio-devel")
|
|
197
|
+
print(" pip install pyaudio")
|
|
198
|
+
print("\n" + "="*70)
|
|
199
|
+
|
|
200
|
+
# Attempt automatic installation
|
|
201
|
+
current_os = platform.system()
|
|
202
|
+
print(f"\nDetected OS: {current_os}")
|
|
203
|
+
print("Attempting automatic installation...")
|
|
204
|
+
|
|
205
|
+
try:
|
|
206
|
+
if current_os == "Windows":
|
|
207
|
+
# Try pipwin first, fall back to pip
|
|
208
|
+
try:
|
|
209
|
+
subprocess.check_call([sys.executable, "-m", "pip", "install", "pipwin"],
|
|
210
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
211
|
+
subprocess.check_call([sys.executable, "-m", "pipwin", "install", "pyaudio"])
|
|
212
|
+
print("✓ PyAudio installed successfully via pipwin!")
|
|
213
|
+
except:
|
|
214
|
+
# Fall back to pip
|
|
215
|
+
subprocess.check_call([sys.executable, "-m", "pip", "install", "pyaudio>=0.2.13"])
|
|
216
|
+
print("✓ PyAudio installed successfully via pip!")
|
|
217
|
+
else:
|
|
218
|
+
# For macOS/Linux, just try pip (user needs to install PortAudio first)
|
|
219
|
+
subprocess.check_call([sys.executable, "-m", "pip", "install", "pyaudio>=0.2.13"])
|
|
220
|
+
print("✓ PyAudio installed successfully!")
|
|
221
|
+
|
|
222
|
+
import pyaudio as _pyaudio
|
|
223
|
+
pyaudio = _pyaudio
|
|
224
|
+
except Exception as e:
|
|
225
|
+
error_msg = (
|
|
226
|
+
"\nFailed to install PyAudio automatically. Please install manually:\n"
|
|
227
|
+
" pip install intellema-vdk[audio]\n"
|
|
228
|
+
"or follow the platform-specific instructions above.\n"
|
|
229
|
+
)
|
|
230
|
+
if current_os != "Windows":
|
|
231
|
+
error_msg += "\nMake sure PortAudio is installed on your system first!\n"
|
|
232
|
+
raise TTSConfigurationError(error_msg) from e
|
|
233
|
+
|
|
234
|
+
# Initialize the provider
|
|
235
|
+
if isinstance(provider, str):
|
|
236
|
+
if provider == "together":
|
|
237
|
+
api_key = api_key or get_env("TOGETHER_API_KEY")
|
|
238
|
+
if not api_key:
|
|
239
|
+
raise TTSConfigurationError(
|
|
240
|
+
"Together API Key is missing. Set TOGETHER_API_KEY env var or pass api_key."
|
|
241
|
+
)
|
|
242
|
+
self.provider = TogetherTTSProvider(api_key=api_key, **provider_kwargs)
|
|
243
|
+
elif provider == "openai":
|
|
244
|
+
api_key = api_key or get_env("OPENAI_API_KEY")
|
|
245
|
+
if not api_key:
|
|
246
|
+
raise TTSConfigurationError(
|
|
247
|
+
"OpenAI API Key is missing. Set OPENAI_API_KEY env var or pass api_key."
|
|
248
|
+
)
|
|
249
|
+
self.provider = OpenAITTSProvider(api_key=api_key, **provider_kwargs)
|
|
250
|
+
else:
|
|
251
|
+
raise TTSConfigurationError(
|
|
252
|
+
f"Unknown provider: {provider}. Supported providers: 'together', 'openai'."
|
|
253
|
+
)
|
|
254
|
+
else:
|
|
255
|
+
# Custom provider instance
|
|
256
|
+
self.provider = provider
|
|
257
|
+
|
|
258
|
+
# Audio configuration.
|
|
259
|
+
try:
|
|
260
|
+
self.p = pyaudio.PyAudio()
|
|
261
|
+
self.stream = self.p.open(
|
|
262
|
+
format=pyaudio.paInt16, channels=1, rate=TTS_AUDIO_SAMPLE_RATE, output=True
|
|
263
|
+
)
|
|
264
|
+
except Exception as e:
|
|
265
|
+
raise TTSStreamError(
|
|
266
|
+
f"Failed to initialize audio stream: {e}") from e
|
|
267
|
+
|
|
268
|
+
# Queues for inter-thread communication.
|
|
269
|
+
self.text_queue = queue.Queue()
|
|
270
|
+
self.audio_queue = queue.Queue()
|
|
271
|
+
|
|
272
|
+
# State management.
|
|
273
|
+
self.text_buffer = ""
|
|
274
|
+
self.is_running = True
|
|
275
|
+
self.threads_started = False
|
|
276
|
+
|
|
277
|
+
# Thread placeholders.
|
|
278
|
+
self.fetcher_thread = None
|
|
279
|
+
self.player_thread = None
|
|
280
|
+
|
|
281
|
+
def _ensure_started(self) -> None:
|
|
282
|
+
"""Initialize and start worker threads for audio fetching and playback.
|
|
283
|
+
|
|
284
|
+
Creates and starts two daemon threads:
|
|
285
|
+
- fetcher_thread: Converts text to audio using the TTS provider
|
|
286
|
+
- player_thread: Plays audio chunks through the audio device
|
|
287
|
+
|
|
288
|
+
This method is called lazily on the first call to feed().
|
|
289
|
+
"""
|
|
290
|
+
if self.threads_started:
|
|
291
|
+
return
|
|
292
|
+
|
|
293
|
+
# Start the threads for fetching and playing audio.
|
|
294
|
+
self.fetcher_thread = threading.Thread(
|
|
295
|
+
target=self._tts_fetcher, daemon=True)
|
|
296
|
+
self.player_thread = threading.Thread(
|
|
297
|
+
target=self._audio_player, daemon=True)
|
|
298
|
+
|
|
299
|
+
self.fetcher_thread.start()
|
|
300
|
+
self.player_thread.start()
|
|
301
|
+
self.threads_started = True
|
|
302
|
+
|
|
303
|
+
def feed(self, text_chunk: str) -> None:
|
|
304
|
+
"""
|
|
305
|
+
Feed a chunk of text to the streamer for conversion to speech.
|
|
306
|
+
|
|
307
|
+
Text is buffered until a sentence-ending punctuation mark is detected
|
|
308
|
+
(. ! ? or newline), then the complete sentence is queued for TTS processing.
|
|
309
|
+
Audio playback begins as soon as the first audio chunks are received.
|
|
310
|
+
|
|
311
|
+
This method is thread-safe and can be called repeatedly to stream text.
|
|
312
|
+
|
|
313
|
+
Args:
|
|
314
|
+
text_chunk: A piece of text to convert to speech. Can be a single
|
|
315
|
+
character, word, or multiple sentences. Empty strings are ignored.
|
|
316
|
+
|
|
317
|
+
Examples:
|
|
318
|
+
>>> streamer.feed("Hello, world. ") # Processes "Hello, world."
|
|
319
|
+
>>> streamer.feed("How are you?") # Processes "How are you?"
|
|
320
|
+
>>>
|
|
321
|
+
>>> # Streaming word by word
|
|
322
|
+
>>> for word in sentence.split():
|
|
323
|
+
... streamer.feed(word + " ")
|
|
324
|
+
>>>
|
|
325
|
+
>>> # Streaming character by character
|
|
326
|
+
>>> for char in text:
|
|
327
|
+
... streamer.feed(char)
|
|
328
|
+
|
|
329
|
+
Note:
|
|
330
|
+
The streamer will only speak complete sentences. Any partial text
|
|
331
|
+
at the end (not ending with . ! ? or \n) remains in the buffer until
|
|
332
|
+
more text is fed or flush() is called.
|
|
333
|
+
"""
|
|
334
|
+
if not self.is_running or not text_chunk:
|
|
335
|
+
return
|
|
336
|
+
|
|
337
|
+
self._ensure_started()
|
|
338
|
+
|
|
339
|
+
self.text_buffer += text_chunk
|
|
340
|
+
sentence_endings = [".", "!", "?", "\n"]
|
|
341
|
+
|
|
342
|
+
# Split text into sentences and queue them.
|
|
343
|
+
for ending in sentence_endings:
|
|
344
|
+
if ending in self.text_buffer:
|
|
345
|
+
parts = self.text_buffer.split(ending)
|
|
346
|
+
for sentence in parts[:-1]:
|
|
347
|
+
if sentence.strip():
|
|
348
|
+
full_sentence = sentence.strip() + ending
|
|
349
|
+
self.text_queue.put(full_sentence)
|
|
350
|
+
self.text_buffer = parts[-1] # Keep the remainder.
|
|
351
|
+
|
|
352
|
+
def flush(self) -> None:
|
|
353
|
+
"""
|
|
354
|
+
Process any remaining buffered text and wait for all audio to finish playing.
|
|
355
|
+
|
|
356
|
+
This method:
|
|
357
|
+
1. Converts any remaining text in the buffer to speech
|
|
358
|
+
2. Waits for the text queue to be fully processed
|
|
359
|
+
3. Waits for the audio queue to be fully played
|
|
360
|
+
4. Adds a small delay for the hardware audio buffer to drain
|
|
361
|
+
|
|
362
|
+
This method is non-destructive - the streamer can be reused after flushing.
|
|
363
|
+
Call this when you've finished feeding text and want to ensure all audio
|
|
364
|
+
has been spoken before continuing.
|
|
365
|
+
|
|
366
|
+
Examples:
|
|
367
|
+
>>> streamer.feed("Hello world")
|
|
368
|
+
>>> streamer.flush() # Wait for "Hello world" to finish playing
|
|
369
|
+
>>> streamer.feed("More text") # Can continue using the streamer
|
|
370
|
+
>>> streamer.flush()
|
|
371
|
+
>>> streamer.close() # Final cleanup
|
|
372
|
+
|
|
373
|
+
Note:
|
|
374
|
+
If no text has been fed yet (threads not started), this method returns
|
|
375
|
+
immediately without doing anything.
|
|
376
|
+
"""
|
|
377
|
+
if not self.threads_started:
|
|
378
|
+
return # Nothing to flush if never started.
|
|
379
|
+
|
|
380
|
+
# Push any remaining text from the buffer.
|
|
381
|
+
if self.text_buffer.strip():
|
|
382
|
+
self.text_queue.put(self.text_buffer.strip())
|
|
383
|
+
self.text_buffer = ""
|
|
384
|
+
|
|
385
|
+
# Wait for both queues to be empty.
|
|
386
|
+
self.text_queue.join()
|
|
387
|
+
self.audio_queue.join()
|
|
388
|
+
|
|
389
|
+
# A small delay to allow the hardware audio buffer to drain.
|
|
390
|
+
time.sleep(0.5)
|
|
391
|
+
|
|
392
|
+
def close(self) -> None:
|
|
393
|
+
"""
|
|
394
|
+
Stop all threads and immediately close the audio stream.
|
|
395
|
+
|
|
396
|
+
This method performs complete cleanup:
|
|
397
|
+
1. Sets the running flag to False to signal threads to stop
|
|
398
|
+
2. Sends poison pills to both queues to unblock waiting threads
|
|
399
|
+
3. Clears both queues of any pending items
|
|
400
|
+
4. Closes the PyAudio stream and terminates PyAudio
|
|
401
|
+
|
|
402
|
+
After calling close(), the streamer cannot be reused. Create a new
|
|
403
|
+
instance if you need to stream more audio.
|
|
404
|
+
|
|
405
|
+
It's safe to call this method multiple times - subsequent calls will
|
|
406
|
+
have no effect.
|
|
407
|
+
|
|
408
|
+
Examples:
|
|
409
|
+
>>> streamer = TTSStreamer(provider="together")
|
|
410
|
+
>>> streamer.feed("Hello world.")
|
|
411
|
+
>>> streamer.flush() # Wait for audio to finish
|
|
412
|
+
>>> streamer.close() # Clean up resources
|
|
413
|
+
>>>
|
|
414
|
+
>>> # Using with context manager pattern
|
|
415
|
+
>>> streamer = TTSStreamer(provider="openai")
|
|
416
|
+
>>> try:
|
|
417
|
+
... streamer.feed("Some text")
|
|
418
|
+
... streamer.flush()
|
|
419
|
+
>>> finally:
|
|
420
|
+
... streamer.close()
|
|
421
|
+
|
|
422
|
+
Note:
|
|
423
|
+
Always call close() when finished with the streamer to free system
|
|
424
|
+
resources and prevent audio device locks.
|
|
425
|
+
"""
|
|
426
|
+
if not self.is_running:
|
|
427
|
+
return
|
|
428
|
+
|
|
429
|
+
self.is_running = False
|
|
430
|
+
|
|
431
|
+
# Send 'poison pill' to threads to signal them to stop.
|
|
432
|
+
self.text_queue.put(None)
|
|
433
|
+
self.audio_queue.put(None)
|
|
434
|
+
|
|
435
|
+
# Clear queues to unblock threads that might be waiting.
|
|
436
|
+
with self.text_queue.mutex:
|
|
437
|
+
self.text_queue.queue.clear()
|
|
438
|
+
with self.audio_queue.mutex:
|
|
439
|
+
self.audio_queue.queue.clear()
|
|
440
|
+
|
|
441
|
+
# Close the audio stream.
|
|
442
|
+
try:
|
|
443
|
+
self.stream.stop_stream()
|
|
444
|
+
self.stream.close()
|
|
445
|
+
self.p.terminate()
|
|
446
|
+
except Exception as e:
|
|
447
|
+
logger.warning(f"Error during audio stream closure: {e}")
|
|
448
|
+
|
|
449
|
+
def _tts_fetcher(self) -> None:
|
|
450
|
+
"""
|
|
451
|
+
Worker thread that fetches TTS audio from the provider API.
|
|
452
|
+
|
|
453
|
+
Continuously pulls text from the text_queue, sends it to the TTS provider's
|
|
454
|
+
stream() method, and pushes received audio chunks to the audio_queue.
|
|
455
|
+
|
|
456
|
+
Implements retry logic with up to 2 retries for failed API calls.
|
|
457
|
+
If all retries fail, the text chunk is dropped and an error is logged.
|
|
458
|
+
|
|
459
|
+
The thread runs until:
|
|
460
|
+
- is_running flag is set to False, or
|
|
461
|
+
- A poison pill (None) is received from the text queue
|
|
462
|
+
|
|
463
|
+
Note:
|
|
464
|
+
This is a daemon thread started automatically by _ensure_started().
|
|
465
|
+
"""
|
|
466
|
+
while self.is_running:
|
|
467
|
+
try:
|
|
468
|
+
text = self.text_queue.get(timeout=0.5)
|
|
469
|
+
except queue.Empty:
|
|
470
|
+
continue
|
|
471
|
+
|
|
472
|
+
if text is None: # Poison pill received.
|
|
473
|
+
break
|
|
474
|
+
|
|
475
|
+
max_retries = 2
|
|
476
|
+
for attempt in range(max_retries + 1):
|
|
477
|
+
try:
|
|
478
|
+
# Use the provider's stream method
|
|
479
|
+
for audio_data in self.provider.stream(text):
|
|
480
|
+
if not self.is_running:
|
|
481
|
+
break
|
|
482
|
+
if audio_data:
|
|
483
|
+
self.audio_queue.put(audio_data)
|
|
484
|
+
|
|
485
|
+
break # Success, exit retry loop.
|
|
486
|
+
|
|
487
|
+
except Exception as e:
|
|
488
|
+
logger.error(
|
|
489
|
+
f"TTS Error (Attempt {attempt + 1}/{max_retries + 1}): {e}")
|
|
490
|
+
if attempt >= max_retries:
|
|
491
|
+
logger.error(
|
|
492
|
+
f"Max retries reached. Dropping text chunk: {text[:50]}...")
|
|
493
|
+
|
|
494
|
+
self.text_queue.task_done()
|
|
495
|
+
|
|
496
|
+
def _audio_player(self) -> None:
|
|
497
|
+
"""
|
|
498
|
+
Worker thread that plays audio chunks from the audio queue.
|
|
499
|
+
|
|
500
|
+
Continuously pulls audio data from the audio_queue and writes it to the
|
|
501
|
+
PyAudio stream for playback. Maintains a small buffer to handle frame
|
|
502
|
+
alignment (audio must be written in complete 16-bit samples).
|
|
503
|
+
|
|
504
|
+
The thread runs until:
|
|
505
|
+
- is_running flag is set to False, or
|
|
506
|
+
- A poison pill (None) is received from the audio queue
|
|
507
|
+
|
|
508
|
+
Error Handling:
|
|
509
|
+
If an OSError occurs while writing to the audio stream (e.g., device
|
|
510
|
+
disconnected), the error is logged and the thread exits gracefully.
|
|
511
|
+
|
|
512
|
+
Note:
|
|
513
|
+
This is a daemon thread started automatically by _ensure_started().
|
|
514
|
+
"""
|
|
515
|
+
buffer = b""
|
|
516
|
+
while self.is_running:
|
|
517
|
+
try:
|
|
518
|
+
audio_data = self.audio_queue.get(timeout=0.5)
|
|
519
|
+
except queue.Empty:
|
|
520
|
+
continue
|
|
521
|
+
|
|
522
|
+
if audio_data is None: # Poison pill received.
|
|
523
|
+
break
|
|
524
|
+
|
|
525
|
+
buffer += audio_data
|
|
526
|
+
|
|
527
|
+
# Play audio in chunks aligned with frame boundaries.
|
|
528
|
+
if len(buffer) >= 2:
|
|
529
|
+
frame_count = len(buffer) // 2
|
|
530
|
+
bytes_to_play = frame_count * 2
|
|
531
|
+
play_chunk = buffer[:bytes_to_play]
|
|
532
|
+
buffer = buffer[bytes_to_play:]
|
|
533
|
+
|
|
534
|
+
try:
|
|
535
|
+
self.stream.write(play_chunk)
|
|
536
|
+
except OSError as e:
|
|
537
|
+
logger.error(f"Error writing to audio stream: {e}")
|
|
538
|
+
self.audio_queue.task_done()
|
|
539
|
+
break
|
|
540
|
+
|
|
541
|
+
self.audio_queue.task_done()
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
class TTSError(Exception):
|
|
2
|
+
"""Base exception for all TTS-related errors."""
|
|
3
|
+
pass
|
|
4
|
+
|
|
5
|
+
class TTSConfigurationError(TTSError):
|
|
6
|
+
"""Raised when configuration (API keys) is missing or invalid."""
|
|
7
|
+
pass
|
|
8
|
+
|
|
9
|
+
class TTSStreamError(TTSError):
|
|
10
|
+
"""Raised when there are issues with the audio stream."""
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
class TTSAPIError(TTSError):
|
|
14
|
+
"""Raised when the TTS API (Together) returns an error."""
|
|
15
|
+
pass
|