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.
- {deepctl_cmd_speak-0.0.2 → deepctl_cmd_speak-0.0.4}/PKG-INFO +2 -2
- {deepctl_cmd_speak-0.0.2 → deepctl_cmd_speak-0.0.4}/pyproject.toml +2 -2
- deepctl_cmd_speak-0.0.4/src/deepctl_cmd_speak/command.py +513 -0
- {deepctl_cmd_speak-0.0.2 → deepctl_cmd_speak-0.0.4}/src/deepctl_cmd_speak.egg-info/PKG-INFO +2 -2
- {deepctl_cmd_speak-0.0.2 → deepctl_cmd_speak-0.0.4}/src/deepctl_cmd_speak.egg-info/requires.txt +1 -1
- deepctl_cmd_speak-0.0.2/src/deepctl_cmd_speak/command.py +0 -185
- {deepctl_cmd_speak-0.0.2 → deepctl_cmd_speak-0.0.4}/README.md +0 -0
- {deepctl_cmd_speak-0.0.2 → deepctl_cmd_speak-0.0.4}/setup.cfg +0 -0
- {deepctl_cmd_speak-0.0.2 → deepctl_cmd_speak-0.0.4}/src/deepctl_cmd_speak/__init__.py +0 -0
- {deepctl_cmd_speak-0.0.2 → deepctl_cmd_speak-0.0.4}/src/deepctl_cmd_speak/models.py +0 -0
- {deepctl_cmd_speak-0.0.2 → deepctl_cmd_speak-0.0.4}/src/deepctl_cmd_speak.egg-info/SOURCES.txt +0 -0
- {deepctl_cmd_speak-0.0.2 → deepctl_cmd_speak-0.0.4}/src/deepctl_cmd_speak.egg-info/dependency_links.txt +0 -0
- {deepctl_cmd_speak-0.0.2 → deepctl_cmd_speak-0.0.4}/src/deepctl_cmd_speak.egg-info/entry_points.txt +0 -0
- {deepctl_cmd_speak-0.0.2 → deepctl_cmd_speak-0.0.4}/src/deepctl_cmd_speak.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: deepctl-cmd-speak
|
|
3
|
-
Version: 0.0.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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,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))
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{deepctl_cmd_speak-0.0.2 → deepctl_cmd_speak-0.0.4}/src/deepctl_cmd_speak.egg-info/SOURCES.txt
RENAMED
|
File without changes
|
|
File without changes
|
{deepctl_cmd_speak-0.0.2 → deepctl_cmd_speak-0.0.4}/src/deepctl_cmd_speak.egg-info/entry_points.txt
RENAMED
|
File without changes
|
{deepctl_cmd_speak-0.0.2 → deepctl_cmd_speak-0.0.4}/src/deepctl_cmd_speak.egg-info/top_level.txt
RENAMED
|
File without changes
|