deepctl-cmd-speak 0.0.2__tar.gz → 0.0.4__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: deepctl-cmd-speak
3
- Version: 0.0.2
3
+ Version: 0.0.4
4
4
  Summary: Speak (text-to-speech) command for deepctl
5
5
  Author-email: Deepgram <devrel@deepgram.com>
6
6
  Maintainer-email: Deepgram <devrel@deepgram.com>
@@ -14,7 +14,7 @@ Classifier: Programming Language :: Python :: 3.11
14
14
  Classifier: Programming Language :: Python :: 3.12
15
15
  Requires-Python: >=3.10
16
16
  Description-Content-Type: text/markdown
17
- Requires-Dist: deepctl-core>=0.1.10
17
+ Requires-Dist: deepctl-core>=0.2.15
18
18
  Requires-Dist: click>=8.0.0
19
19
  Requires-Dist: rich>=13.0.0
20
20
  Requires-Dist: pydantic>=2.0.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "deepctl-cmd-speak"
7
- version = "0.0.2" # x-release-please-version
7
+ version = "0.0.4" # x-release-please-version
8
8
  description = "Speak (text-to-speech) command for deepctl"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -21,7 +21,7 @@ classifiers = [
21
21
  keywords = ["deepgram", "cli", "tts", "text-to-speech", "speak"]
22
22
  requires-python = ">=3.10"
23
23
  dependencies = [
24
- "deepctl-core>=0.1.10",
24
+ "deepctl-core>=0.2.15",
25
25
  "click>=8.0.0",
26
26
  "rich>=13.0.0",
27
27
  "pydantic>=2.0.0",
@@ -0,0 +1,513 @@
1
+ """Speak (text-to-speech) command for deepctl."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import io
6
+ import struct
7
+ import sys
8
+ import time
9
+ import wave
10
+ from pathlib import Path
11
+ from typing import TYPE_CHECKING, Any
12
+
13
+ import click
14
+ from deepctl_core import (
15
+ AuthManager,
16
+ BaseCommand,
17
+ BaseResult,
18
+ Config,
19
+ DeepgramClient,
20
+ )
21
+ from rich.console import Console
22
+
23
+ from .models import SpeakResult
24
+
25
+ if TYPE_CHECKING:
26
+ from collections.abc import Iterator
27
+
28
+ console = Console(stderr=True)
29
+
30
+ # Flux (Speak v2) streaming controls, per the /v2/speak API. `speed` is a
31
+ # 0.05-increment multiplier and `expressivity` is a small integer range; both
32
+ # are validated up front so we fail with a clear message instead of surfacing
33
+ # a raw SPEED_OUT_OF_RANGE / server error mid-stream.
34
+ _FLUX_SPEEDS = (0.85, 0.90, 0.95, 1.00, 1.05, 1.10, 1.15)
35
+ _FLUX_EXPRESSIVITY = (-2, -1, 0, 1, 2)
36
+
37
+
38
+ def _fmt_bytes(n: int) -> str:
39
+ """Human-readable byte count for progress display."""
40
+ if n < 1024:
41
+ return f"{n} B"
42
+ if n < 1024 * 1024:
43
+ return f"{n / 1024:.0f} KB"
44
+ return f"{n / 1024 / 1024:.1f} MB"
45
+
46
+
47
+ def _pcm_to_wav(
48
+ pcm: bytes, *, sample_rate: int, channels: int = 1, sample_width: int = 2
49
+ ) -> bytes:
50
+ """Wrap raw signed 16-bit little-endian PCM in a WAV container."""
51
+ buf = io.BytesIO()
52
+ with wave.open(buf, "wb") as wav:
53
+ wav.setnchannels(channels)
54
+ wav.setsampwidth(sample_width)
55
+ wav.setframerate(sample_rate)
56
+ wav.writeframes(pcm)
57
+ return buf.getvalue()
58
+
59
+
60
+ def _streaming_wav_header(
61
+ *, sample_rate: int, channels: int = 1, sample_width: int = 2
62
+ ) -> bytes:
63
+ """A 44-byte PCM WAV header with placeholder (streaming) length fields.
64
+
65
+ stdout is not seekable, so we can't back-patch the RIFF/data sizes after
66
+ the audio has streamed. A player reading a piped WAV reads until EOF, so we
67
+ emit an oversized length up front and let the closing pipe stop playback.
68
+ This lets ``dg speak ... | ffplay -`` start playing on the first chunk
69
+ instead of waiting for the whole utterance.
70
+
71
+ The length is 0x7FFFFFFF rather than 0xFFFFFFFF (which ffmpeg treats as a
72
+ sentinel and warns "Ignoring maximum wav data size, file may be invalid")
73
+ and rather than 0 (which macOS CoreAudio trusts literally, so a redirected
74
+ ``dg speak ... > out.wav`` would look empty). An oversized-but-not-sentinel
75
+ length is read-to-EOF by ffmpeg and estimated-from-bytes by CoreAudio, so
76
+ both the pipe and the redirect-to-file cases work.
77
+ """
78
+ byte_rate = sample_rate * channels * sample_width
79
+ block_align = channels * sample_width
80
+ bits_per_sample = sample_width * 8
81
+ placeholder = 0x7FFFFFFF # oversized "unknown / streaming" length
82
+ return struct.pack(
83
+ "<4sI4s4sIHHIIHH4sI",
84
+ b"RIFF",
85
+ placeholder,
86
+ b"WAVE",
87
+ b"fmt ",
88
+ 16, # fmt chunk size
89
+ 1, # PCM
90
+ channels,
91
+ sample_rate,
92
+ byte_rate,
93
+ block_align,
94
+ bits_per_sample,
95
+ b"data",
96
+ placeholder,
97
+ )
98
+
99
+
100
+ class _StreamProgress:
101
+ """Scrolling stderr progress for a Flux audio stream.
102
+
103
+ Prints a line as the first audio arrives (with its latency) and then a
104
+ throttled ``received N`` line as bytes accumulate, so the stream is visibly
105
+ arriving over time. Also tracks the byte total and time-to-first-audio for
106
+ the caller's final summary. Wrap the SDK stream with ``track()``.
107
+
108
+ Scrolling lines are shown only on an interactive stderr (a TTY); agent/CI
109
+ runs get just the caller's one-line summary. Nothing is written to stdout,
110
+ so a piped audio stream is never corrupted.
111
+ """
112
+
113
+ _INTERVAL = 0.2 # min seconds between scrolling "received N" lines
114
+
115
+ def __init__(self, model: str) -> None:
116
+ self.model = model
117
+ self.total = 0
118
+ self.first_audio: float | None = None
119
+ self._start = time.monotonic()
120
+ self._last_print = 0.0
121
+ self._live = console.is_terminal
122
+
123
+ def track(self, stream: Iterator[bytes]) -> Iterator[bytes]:
124
+ """Yield each chunk unchanged while emitting scrolling progress."""
125
+ for chunk in stream:
126
+ self.total += len(chunk)
127
+ if chunk and self.first_audio is None:
128
+ self.first_audio = time.monotonic() - self._start
129
+ self._emit(first=True)
130
+ elif chunk:
131
+ self._emit()
132
+ yield chunk
133
+
134
+ def _emit(self, first: bool = False) -> None:
135
+ if not self._live:
136
+ return
137
+ now = time.monotonic()
138
+ if not first and now - self._last_print < self._INTERVAL:
139
+ return
140
+ self._last_print = now
141
+ if first:
142
+ assert self.first_audio is not None # set by the caller before first=True
143
+ console.print(
144
+ f"[dim]Streaming {self.model} — "
145
+ f"first audio {self.first_audio * 1000:.0f} ms[/dim]"
146
+ )
147
+ else:
148
+ console.print(f"[dim] received {_fmt_bytes(self.total)}[/dim]")
149
+
150
+ def timing(self) -> str:
151
+ """A 'first audio X ms, Ys total' suffix for the final summary."""
152
+ fa = (
153
+ f"{self.first_audio * 1000:.0f} ms"
154
+ if self.first_audio is not None
155
+ else "n/a"
156
+ )
157
+ return f"first audio {fa}, {time.monotonic() - self._start:.1f}s total"
158
+
159
+
160
+ class SpeakCommand(BaseCommand):
161
+ """Command for generating speech from text using Deepgram TTS."""
162
+
163
+ name = "speak"
164
+ help = "Convert text to speech using Deepgram TTS"
165
+ short_help = "Text-to-speech"
166
+
167
+ requires_auth = True
168
+ requires_project = False
169
+ ci_friendly = True
170
+
171
+ examples = [
172
+ # Flux TTS is the default (flux-alexis-en) — WebSocket streaming,
173
+ # raw audio wrapped in WAV. Piped audio is a streaming WAV (unknown
174
+ # length up front), so pass `-loglevel error` to silence ffmpeg's
175
+ # cosmetic end-of-stream notice.
176
+ 'dg speak "Hello world"',
177
+ 'dg speak "Hello world" -o hello.wav',
178
+ "dg speak --file message.txt -o output.wav",
179
+ 'dg speak "Hello" | ffplay -loglevel error -nodisp -autoexit -',
180
+ # Flux TTS streaming controls: --speed (0.85-1.15) and beta
181
+ # --expressivity (-2..2, default 0).
182
+ 'dg speak "A little slower, please" --speed 0.9 -o slow.wav',
183
+ 'dg speak "So exciting!" --expressivity 2 -o lively.wav # beta; default: 0',
184
+ # Aura (Speak v1, batch REST) — opt in with -m aura-*; needed for
185
+ # containerized formats like mp3.
186
+ 'dg speak "Hello" -m aura-2-asteria-en -o hello.mp3',
187
+ 'dg speak "Hello" -m aura-2-luna-en -o hello.wav --encoding linear16 --container wav',
188
+ 'echo "Hello" | dg speak -o hello.mp3 -m aura-2-asteria-en',
189
+ # Aura-2 Spanish voice (run `dg models` for the full list).
190
+ 'dg speak "Hola, mundo" -m aura-2-selena-es -o hola.mp3',
191
+ ]
192
+ agent_help = (
193
+ "Convert text to speech using Deepgram's TTS API. "
194
+ "Text can be provided as an argument, from a file, or piped via stdin. "
195
+ "Audio is written to a file (--output) or stdout for piping. "
196
+ "By default (flux-* models, e.g. flux-alexis-en) uses Flux TTS / Speak "
197
+ "v2, streaming over WebSocket and emitting raw audio; linear16 output is "
198
+ "wrapped in a WAV container so it is directly playable. Pass an aura-* "
199
+ "model to use Speak v1 (batch REST), which supports containerized "
200
+ "formats like mp3. Supports model selection and audio format options. "
201
+ "Flux TTS models also accept --speed (0.85–1.15) and beta "
202
+ "--expressivity (-2..2; default 0 = nominal) streaming controls; these "
203
+ "are rejected for other models."
204
+ )
205
+
206
+ def get_arguments(self) -> list[dict[str, Any]]:
207
+ return [
208
+ {
209
+ "name": "text",
210
+ "help": "Text to convert to speech",
211
+ "required": False,
212
+ "default": None,
213
+ },
214
+ {
215
+ "names": ["--output", "-o"],
216
+ "help": "Output file path (required if stdout is a terminal)",
217
+ "type": str,
218
+ "is_option": True,
219
+ },
220
+ {
221
+ "names": ["--model", "-m"],
222
+ "help": (
223
+ "TTS model. flux-* = Flux TTS / Speak v2 (WebSocket "
224
+ "streaming; default flux-alexis-en); aura-* = Speak v1 "
225
+ "(REST batch, e.g. aura-2-asteria-en)."
226
+ ),
227
+ "type": str,
228
+ "is_option": True,
229
+ "default": "flux-alexis-en",
230
+ },
231
+ {
232
+ "names": ["--encoding"],
233
+ "help": (
234
+ "Audio encoding. Aura (v1): mp3, linear16, flac, mulaw, alaw, "
235
+ "opus, aac. Flux (v2) streaming is raw only: linear16 "
236
+ "(default), mulaw, alaw."
237
+ ),
238
+ "type": str,
239
+ "is_option": True,
240
+ },
241
+ {
242
+ "names": ["--container"],
243
+ "help": (
244
+ "Audio container (none, wav, ogg). Aura (v1) only; ignored for "
245
+ "flux-* (linear16 is auto-wrapped in WAV)."
246
+ ),
247
+ "type": str,
248
+ "is_option": True,
249
+ },
250
+ {
251
+ "names": ["--sample-rate"],
252
+ "help": "Audio sample rate in Hz",
253
+ "type": float,
254
+ "is_option": True,
255
+ },
256
+ {
257
+ "names": ["--speed"],
258
+ "help": (
259
+ "Flux TTS (v2) only. Speech-rate multiplier: 0.85, 0.90, 0.95, "
260
+ "1.00, 1.05, 1.10, or 1.15 (1.00 = nominal)."
261
+ ),
262
+ "type": float,
263
+ "is_option": True,
264
+ },
265
+ {
266
+ "names": ["--expressivity"],
267
+ "help": (
268
+ "Flux TTS (v2) only, beta. Expressive range: -2, -1, 0, 1, "
269
+ "or 2 (default 0 = nominal; negative flatter, positive more "
270
+ "animated). Fixed for the connection."
271
+ ),
272
+ "type": int,
273
+ "is_option": True,
274
+ },
275
+ {
276
+ "names": ["--file", "-f"],
277
+ "help": "Read text from file",
278
+ "type": str,
279
+ "is_option": True,
280
+ },
281
+ ]
282
+
283
+ def handle(
284
+ self,
285
+ config: Config,
286
+ auth_manager: AuthManager,
287
+ client: DeepgramClient,
288
+ **kwargs: Any,
289
+ ) -> BaseResult | None:
290
+ text = kwargs.get("text")
291
+ output_path = kwargs.get("output")
292
+ model = kwargs.get("model") or "flux-alexis-en"
293
+ encoding = kwargs.get("encoding")
294
+ container = kwargs.get("container")
295
+ sample_rate = kwargs.get("sample_rate")
296
+ speed = kwargs.get("speed")
297
+ expressivity = kwargs.get("expressivity")
298
+ file_path = kwargs.get("file")
299
+
300
+ # Resolve text input: arg > --file > stdin
301
+ if not text and file_path:
302
+ path = Path(file_path)
303
+ if not path.exists():
304
+ return BaseResult(
305
+ status="error", message=f"File not found: {file_path}"
306
+ )
307
+ text = path.read_text().strip()
308
+ elif not text and not sys.stdin.isatty():
309
+ text = sys.stdin.read().strip()
310
+
311
+ if not text:
312
+ return BaseResult(
313
+ status="error",
314
+ message="No text provided. Pass text as argument, use --file, or pipe via stdin.",
315
+ )
316
+
317
+ # If stdout is a TTY and no output file, require --output
318
+ stdout_is_tty = sys.stdout.isatty()
319
+ if not output_path and stdout_is_tty:
320
+ return BaseResult(
321
+ status="error",
322
+ message="No output specified. Use -o/--output to save to file, or pipe stdout.",
323
+ )
324
+
325
+ # Only the documented flux-* namespace uses speak.v2. Aura and unknown
326
+ # model names pass through to the REST API so the service can resolve them.
327
+ is_flux = model.lower().startswith("flux-")
328
+
329
+ # speed / expressivity are Flux (Speak v2) connect controls; reject them
330
+ # for other models rather than silently dropping them. Raise (not return)
331
+ # so the failure exits non-zero in every output mode.
332
+ if not is_flux and (speed is not None or expressivity is not None):
333
+ raise click.ClickException(
334
+ "--speed and --expressivity are only supported for Flux TTS "
335
+ "(Speak v2) models (flux-*). They are not available for "
336
+ f"Speak v1 model '{model}'."
337
+ )
338
+ if speed is not None and speed not in _FLUX_SPEEDS:
339
+ allowed = ", ".join(f"{s:.2f}" for s in _FLUX_SPEEDS)
340
+ raise click.ClickException(
341
+ f"--speed must be one of: {allowed} (got {speed})."
342
+ )
343
+ if expressivity is not None and expressivity not in _FLUX_EXPRESSIVITY:
344
+ allowed = ", ".join(str(e) for e in _FLUX_EXPRESSIVITY)
345
+ raise click.ClickException(
346
+ f"--expressivity must be one of: {allowed} (got {expressivity})."
347
+ )
348
+
349
+ if is_flux:
350
+ # WebSocket streaming path. Streaming output is raw audio, so
351
+ # default to linear16 @ 24kHz and wrap it in WAV for playback.
352
+ # These paths raise (rather than returning an error result) so a
353
+ # failure exits non-zero and is visible in every output format —
354
+ # a returned error is only printed in structured output and still
355
+ # exits 0.
356
+ eff_encoding = encoding or "linear16"
357
+ if eff_encoding not in ("linear16", "mulaw", "alaw"):
358
+ raise click.ClickException(
359
+ f"Encoding '{eff_encoding}' is not supported for Flux "
360
+ "(Speak v2) streaming, which emits raw audio. Use "
361
+ "linear16 (default), mulaw, or alaw."
362
+ )
363
+ eff_sample_rate = sample_rate or 24000.0
364
+
365
+ # A live spinner + byte counter + time-to-first-audio on stderr, so
366
+ # the stream is visibly arriving rather than a single static line.
367
+ prog = _StreamProgress(model)
368
+ stream = prog.track(
369
+ client.speak_text_stream(
370
+ text=text,
371
+ model=model,
372
+ encoding=eff_encoding,
373
+ sample_rate=eff_sample_rate,
374
+ speed=speed,
375
+ expressivity=expressivity,
376
+ )
377
+ )
378
+
379
+ if output_path:
380
+ # A WAV file must declare its data length in the header, which
381
+ # we only know once the stream ends — so buffer, then wrap and
382
+ # write. (Streaming to disk has no user-visible benefit here.)
383
+ pcm = bytearray()
384
+ try:
385
+ for chunk in stream:
386
+ pcm.extend(chunk)
387
+ except Exception as e:
388
+ raise click.ClickException(f"Flux streaming failed: {e}")
389
+
390
+ if not pcm:
391
+ # No audio means the stream errored upstream or returned
392
+ # nothing. Wrapping empty PCM yields a valid header-only
393
+ # WAV, so guard rather than report success on a silent file.
394
+ raise click.ClickException(
395
+ "Flux (Speak v2) streaming returned no audio."
396
+ )
397
+
398
+ if eff_encoding == "linear16":
399
+ audio_bytes = _pcm_to_wav(
400
+ bytes(pcm), sample_rate=int(eff_sample_rate)
401
+ )
402
+ else:
403
+ audio_bytes = bytes(pcm)
404
+
405
+ total_bytes = len(audio_bytes)
406
+ Path(output_path).write_bytes(audio_bytes)
407
+ console.print(
408
+ f"[green]Audio saved to {output_path}[/green] "
409
+ f"({total_bytes:,} bytes — {prog.timing()})"
410
+ )
411
+ return SpeakResult(
412
+ status="success",
413
+ message=f"Audio saved to {output_path}",
414
+ output_path=output_path,
415
+ model=model,
416
+ bytes_written=total_bytes,
417
+ )
418
+
419
+ # Pipe path — write each chunk to stdout as it arrives so a
420
+ # downstream player starts on the first frame (the point of
421
+ # "streaming by default"). For linear16 we emit a streaming WAV
422
+ # header before the first frame so `| ffplay -` plays with no
423
+ # extra flags; raw mulaw/alaw stream as-is.
424
+ out = sys.stdout.buffer
425
+ wrote_header = False
426
+ try:
427
+ for chunk in stream:
428
+ if not chunk:
429
+ continue
430
+ if eff_encoding == "linear16" and not wrote_header:
431
+ out.write(
432
+ _streaming_wav_header(sample_rate=int(eff_sample_rate))
433
+ )
434
+ wrote_header = True
435
+ out.write(chunk)
436
+ out.flush()
437
+ except Exception as e:
438
+ raise click.ClickException(f"Flux streaming failed: {e}")
439
+
440
+ if prog.total == 0:
441
+ # Guard before anything is written: the header is only emitted
442
+ # on the first frame, so a no-audio stream writes nothing.
443
+ raise click.ClickException(
444
+ "Flux (Speak v2) streaming returned no audio."
445
+ )
446
+
447
+ console.print(
448
+ f"[green]✓ Streamed {prog.total:,} bytes to stdout[/green] "
449
+ f"({prog.timing()})"
450
+ )
451
+ # Return None so the framework skips output_result: in agentic/CI
452
+ # (or --output json) mode it would serialize the result to the
453
+ # stdout console — i.e. append JSON to the audio we just streamed,
454
+ # corrupting a `> out.wav` redirect. The summary above already went
455
+ # to the stderr console.
456
+ return None
457
+
458
+ # REST path (Speak v1, including Aura and unknown-model pass-through).
459
+ try:
460
+ console.print(f"[blue]Generating speech with {model}...[/blue]")
461
+
462
+ audio_iter = client.speak_text(
463
+ text=text,
464
+ model=model,
465
+ encoding=encoding,
466
+ container=container,
467
+ sample_rate=sample_rate,
468
+ )
469
+
470
+ total_bytes = 0
471
+
472
+ if output_path:
473
+ # Write to file
474
+ out = Path(output_path)
475
+ with open(out, "wb") as f:
476
+ for chunk in audio_iter:
477
+ f.write(chunk)
478
+ total_bytes += len(chunk)
479
+
480
+ console.print(
481
+ f"[green]Audio saved to {output_path}[/green] ({total_bytes:,} bytes)"
482
+ )
483
+
484
+ return SpeakResult(
485
+ status="success",
486
+ message=f"Audio saved to {output_path}",
487
+ output_path=output_path,
488
+ model=model,
489
+ bytes_written=total_bytes,
490
+ )
491
+ else:
492
+ # Write to stdout (piping)
493
+ stdout_buffer = sys.stdout.buffer
494
+ for chunk in audio_iter:
495
+ stdout_buffer.write(chunk)
496
+ total_bytes += len(chunk)
497
+ stdout_buffer.flush()
498
+
499
+ console.print(f"[green]✓ Wrote {total_bytes:,} bytes to stdout[/green]")
500
+ # Return None so the framework skips output_result — otherwise
501
+ # agentic/CI (or --output json) mode serializes JSON onto the
502
+ # same stdout as the audio, corrupting a `> out` redirect. The
503
+ # summary above went to the stderr console.
504
+ return None
505
+
506
+ except click.ClickException:
507
+ raise
508
+ except Exception as e:
509
+ # Raise (not return an error result) so the failure exits non-zero
510
+ # in every output mode — a returned BaseResult is only printed and
511
+ # still exits 0. Reachable now that unknown models (e.g. a bare
512
+ # "flux" typo) route here instead of the raising v2 path.
513
+ raise click.ClickException(f"Error generating speech: {e}")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: deepctl-cmd-speak
3
- Version: 0.0.2
3
+ Version: 0.0.4
4
4
  Summary: Speak (text-to-speech) command for deepctl
5
5
  Author-email: Deepgram <devrel@deepgram.com>
6
6
  Maintainer-email: Deepgram <devrel@deepgram.com>
@@ -14,7 +14,7 @@ Classifier: Programming Language :: Python :: 3.11
14
14
  Classifier: Programming Language :: Python :: 3.12
15
15
  Requires-Python: >=3.10
16
16
  Description-Content-Type: text/markdown
17
- Requires-Dist: deepctl-core>=0.1.10
17
+ Requires-Dist: deepctl-core>=0.2.15
18
18
  Requires-Dist: click>=8.0.0
19
19
  Requires-Dist: rich>=13.0.0
20
20
  Requires-Dist: pydantic>=2.0.0
@@ -1,4 +1,4 @@
1
- deepctl-core>=0.1.10
1
+ deepctl-core>=0.2.15
2
2
  click>=8.0.0
3
3
  rich>=13.0.0
4
4
  pydantic>=2.0.0
@@ -1,185 +0,0 @@
1
- """Speak (text-to-speech) command for deepctl."""
2
-
3
- from __future__ import annotations
4
-
5
- import sys
6
- from pathlib import Path
7
- from typing import Any
8
-
9
- from deepctl_core import (
10
- AuthManager,
11
- BaseCommand,
12
- BaseResult,
13
- Config,
14
- DeepgramClient,
15
- )
16
- from rich.console import Console
17
-
18
- from .models import SpeakResult
19
-
20
- console = Console(stderr=True)
21
-
22
-
23
- class SpeakCommand(BaseCommand):
24
- """Command for generating speech from text using Deepgram TTS."""
25
-
26
- name = "speak"
27
- help = "Convert text to speech using Deepgram TTS"
28
- short_help = "Text-to-speech"
29
-
30
- requires_auth = True
31
- requires_project = False
32
- ci_friendly = True
33
-
34
- examples = [
35
- 'dg speak "Hello world"',
36
- 'dg speak "Hello world" -o hello.mp3',
37
- "dg speak --file message.txt -o output.mp3",
38
- 'echo "Hello" | dg speak -o hello.mp3',
39
- 'dg speak "Hello" | ffplay -nodisp -',
40
- 'dg speak "Hello" -m aura-2-luna-en -o hello.wav --encoding linear16 --container wav',
41
- ]
42
- agent_help = (
43
- "Convert text to speech using Deepgram's TTS API. "
44
- "Text can be provided as an argument, from a file, or piped via stdin. "
45
- "Audio is written to a file (--output) or stdout for piping. "
46
- "Supports model selection and audio format options."
47
- )
48
-
49
- def get_arguments(self) -> list[dict[str, Any]]:
50
- return [
51
- {
52
- "name": "text",
53
- "help": "Text to convert to speech",
54
- "required": False,
55
- "default": None,
56
- },
57
- {
58
- "names": ["--output", "-o"],
59
- "help": "Output file path (required if stdout is a terminal)",
60
- "type": str,
61
- "is_option": True,
62
- },
63
- {
64
- "names": ["--model", "-m"],
65
- "help": "TTS model (default: aura-2-asteria-en)",
66
- "type": str,
67
- "is_option": True,
68
- "default": "aura-2-asteria-en",
69
- },
70
- {
71
- "names": ["--encoding"],
72
- "help": "Audio encoding (mp3, linear16, flac, mulaw, alaw, opus, aac)",
73
- "type": str,
74
- "is_option": True,
75
- },
76
- {
77
- "names": ["--container"],
78
- "help": "Audio container (none, wav, ogg)",
79
- "type": str,
80
- "is_option": True,
81
- },
82
- {
83
- "names": ["--sample-rate"],
84
- "help": "Audio sample rate in Hz",
85
- "type": float,
86
- "is_option": True,
87
- },
88
- {
89
- "names": ["--file", "-f"],
90
- "help": "Read text from file",
91
- "type": str,
92
- "is_option": True,
93
- },
94
- ]
95
-
96
- def handle(
97
- self,
98
- config: Config,
99
- auth_manager: AuthManager,
100
- client: DeepgramClient,
101
- **kwargs: Any,
102
- ) -> BaseResult:
103
- text = kwargs.get("text")
104
- output_path = kwargs.get("output")
105
- model = kwargs.get("model") or "aura-2-asteria-en"
106
- encoding = kwargs.get("encoding")
107
- container = kwargs.get("container")
108
- sample_rate = kwargs.get("sample_rate")
109
- file_path = kwargs.get("file")
110
-
111
- # Resolve text input: arg > --file > stdin
112
- if not text and file_path:
113
- path = Path(file_path)
114
- if not path.exists():
115
- return BaseResult(
116
- status="error", message=f"File not found: {file_path}"
117
- )
118
- text = path.read_text().strip()
119
- elif not text and not sys.stdin.isatty():
120
- text = sys.stdin.read().strip()
121
-
122
- if not text:
123
- return BaseResult(
124
- status="error",
125
- message="No text provided. Pass text as argument, use --file, or pipe via stdin.",
126
- )
127
-
128
- # If stdout is a TTY and no output file, require --output
129
- stdout_is_tty = sys.stdout.isatty()
130
- if not output_path and stdout_is_tty:
131
- return BaseResult(
132
- status="error",
133
- message="No output specified. Use -o/--output to save to file, or pipe stdout.",
134
- )
135
-
136
- try:
137
- console.print(f"[blue]Generating speech with {model}...[/blue]")
138
-
139
- audio_iter = client.speak_text(
140
- text=text,
141
- model=model,
142
- encoding=encoding,
143
- container=container,
144
- sample_rate=sample_rate,
145
- )
146
-
147
- total_bytes = 0
148
-
149
- if output_path:
150
- # Write to file
151
- out = Path(output_path)
152
- with open(out, "wb") as f:
153
- for chunk in audio_iter:
154
- f.write(chunk)
155
- total_bytes += len(chunk)
156
-
157
- console.print(
158
- f"[green]Audio saved to {output_path}[/green] ({total_bytes:,} bytes)"
159
- )
160
-
161
- return SpeakResult(
162
- status="success",
163
- message=f"Audio saved to {output_path}",
164
- output_path=output_path,
165
- model=model,
166
- bytes_written=total_bytes,
167
- )
168
- else:
169
- # Write to stdout (piping)
170
- stdout_buffer = sys.stdout.buffer
171
- for chunk in audio_iter:
172
- stdout_buffer.write(chunk)
173
- total_bytes += len(chunk)
174
- stdout_buffer.flush()
175
-
176
- return SpeakResult(
177
- status="success",
178
- message=f"Wrote {total_bytes:,} bytes to stdout",
179
- model=model,
180
- bytes_written=total_bytes,
181
- )
182
-
183
- except Exception as e:
184
- console.print(f"[red]Error generating speech:[/red] {e}")
185
- return BaseResult(status="error", message=str(e))