From 7fd1c02d7c6c38adfe529f72dfae72f9ec1cad73 Mon Sep 17 00:00:00 2001 From: Greg Holmes Date: Tue, 14 Jul 2026 14:43:33 +0100 Subject: [PATCH 1/5] feat(speak): add Flux TTS (Speak v2 WebSocket streaming) Route flux-* models through the Speak v2 WebSocket streaming client (speak.v2.connect); aura-* stay on Speak v1 batch REST. Raw linear16 output is wrapped in WAV so it is directly playable. Adds a client.speak_text_stream() helper and an --encoding guard (Flux streaming is linear16/mulaw/alaw only). Also: - Fix _create_client environment: derive https (REST) + wss (WebSocket) from the configured host and include agent_rest (required by the SDK). The old builder crashed on any custom base URL. - Fix verify_api_key to validate against the configured base URL instead of hardcoded production, so non-prod (staging) keys pass the auth guard. - Add global --base-url and --api-key options (the explicit-credential path already existed but nothing fed it). - Document Flux (v1 vs v2) in speak --help/examples/agent_help, the skills Developer Guide (migrated off the removed pre-Fern SpeakOptions API), and the README. - Bump deepgram-sdk to >=7.5.0 (adds the Speak v2 streaming client). Tests: add Flux speak coverage; update the custom base-url client test for the new environment derivation. --- README.md | 11 +- packages/deepctl-cmd-login/pyproject.toml | 2 +- packages/deepctl-cmd-projects/pyproject.toml | 2 +- .../src/deepctl_cmd_speak/command.py | 100 +++++++++++++++++- .../tests/unit/test_speak_command.py | 74 +++++++++++++ packages/deepctl-cmd-usage/pyproject.toml | 2 +- packages/deepctl-core/pyproject.toml | 2 +- .../deepctl-core/src/deepctl_core/auth.py | 7 +- .../src/deepctl_core/base_command.py | 5 +- .../deepctl-core/src/deepctl_core/client.py | 48 ++++++++- .../src/deepctl_core/skill_generator.py | 71 +++++++++---- .../deepctl-core/tests/unit/test_client.py | 8 +- pyproject.toml | 2 +- src/deepctl/main.py | 25 +++++ 14 files changed, 322 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 65cdbd4..98f221f 100644 --- a/README.md +++ b/README.md @@ -173,11 +173,20 @@ cat audio.raw | dg listen --encoding linear16 --sample-rate 16000 Convert text to natural speech. Supports file output and piping. +`aura-*` models use the Speak v1 batch REST API. `flux-*` (Flux TTS) models use +the Speak v2 WebSocket API and stream by default; their raw `linear16` output is +wrapped in a WAV container so it is directly playable. + ```bash +# Aura (v1, batch REST) dg speak "Welcome to Deepgram" -o welcome.mp3 dg speak --file script.txt -o output.mp3 -m aura-2-luna-en echo "Hello" | dg speak -o greeting.mp3 -dg speak "Stream me" | ffplay -nodisp - # pipe to audio player +dg speak "Stream me" | ffplay -nodisp - # pipe to audio player + +# Flux TTS (v2, WebSocket streaming) +dg speak "Hello from Flux" -m flux-alexis-en -o hello.wav +dg speak "Hello from Flux" -m flux-alexis-en | ffplay -nodisp - ``` ### Text Intelligence diff --git a/packages/deepctl-cmd-login/pyproject.toml b/packages/deepctl-cmd-login/pyproject.toml index 3ac1e27..6be8d12 100644 --- a/packages/deepctl-cmd-login/pyproject.toml +++ b/packages/deepctl-cmd-login/pyproject.toml @@ -24,7 +24,7 @@ dependencies = [ "deepctl-core>=0.1.10", "click>=8.0.0", "rich>=13.0.0", - "deepgram-sdk>=6.0.0rc2", + "deepgram-sdk>=7.5.0", "pydantic>=2.0.0", ] diff --git a/packages/deepctl-cmd-projects/pyproject.toml b/packages/deepctl-cmd-projects/pyproject.toml index 6ae12cd..d3fe8c6 100644 --- a/packages/deepctl-cmd-projects/pyproject.toml +++ b/packages/deepctl-cmd-projects/pyproject.toml @@ -24,7 +24,7 @@ dependencies = [ "deepctl-core>=0.1.10", "click>=8.0.0", "rich>=13.0.0", - "deepgram-sdk>=6.0.0rc2", + "deepgram-sdk>=7.5.0", "pydantic>=2.0.0", ] diff --git a/packages/deepctl-cmd-speak/src/deepctl_cmd_speak/command.py b/packages/deepctl-cmd-speak/src/deepctl_cmd_speak/command.py index 17d4016..947b768 100644 --- a/packages/deepctl-cmd-speak/src/deepctl_cmd_speak/command.py +++ b/packages/deepctl-cmd-speak/src/deepctl_cmd_speak/command.py @@ -2,7 +2,9 @@ from __future__ import annotations +import io import sys +import wave from pathlib import Path from typing import Any @@ -20,6 +22,19 @@ console = Console(stderr=True) +def _pcm_to_wav( + pcm: bytes, *, sample_rate: int, channels: int = 1, sample_width: int = 2 +) -> bytes: + """Wrap raw signed 16-bit little-endian PCM in a WAV container.""" + buf = io.BytesIO() + with wave.open(buf, "wb") as wav: + wav.setnchannels(channels) + wav.setsampwidth(sample_width) + wav.setframerate(sample_rate) + wav.writeframes(pcm) + return buf.getvalue() + + class SpeakCommand(BaseCommand): """Command for generating speech from text using Deepgram TTS.""" @@ -38,11 +53,17 @@ class SpeakCommand(BaseCommand): 'echo "Hello" | dg speak -o hello.mp3', 'dg speak "Hello" | ffplay -nodisp -', 'dg speak "Hello" -m aura-2-luna-en -o hello.wav --encoding linear16 --container wav', + # Flux TTS — WebSocket streaming (flux-* models), streaming by default: + 'dg speak "Hello from Flux" -m flux-alexis-en -o hello.wav', + 'dg speak "Hello from Flux" -m flux-alexis-en | ffplay -nodisp -', ] agent_help = ( "Convert text to speech using Deepgram's TTS API. " "Text can be provided as an argument, from a file, or piped via stdin. " "Audio is written to a file (--output) or stdout for piping. " + "aura-* models use Speak v1 (batch REST). flux-* (Flux TTS) models use " + "Speak v2, streaming over WebSocket by default and emitting raw audio; " + "linear16 output is wrapped in a WAV container so it is directly playable. " "Supports model selection and audio format options." ) @@ -62,20 +83,31 @@ def get_arguments(self) -> list[dict[str, Any]]: }, { "names": ["--model", "-m"], - "help": "TTS model (default: aura-2-asteria-en)", + "help": ( + "TTS model. aura-* = Speak v1 (REST batch; default " + "aura-2-asteria-en); flux-* = Flux TTS / Speak v2 (WebSocket " + "streaming, e.g. flux-alexis-en)." + ), "type": str, "is_option": True, "default": "aura-2-asteria-en", }, { "names": ["--encoding"], - "help": "Audio encoding (mp3, linear16, flac, mulaw, alaw, opus, aac)", + "help": ( + "Audio encoding. Aura (v1): mp3, linear16, flac, mulaw, alaw, " + "opus, aac. Flux (v2) streaming is raw only: linear16 " + "(default), mulaw, alaw." + ), "type": str, "is_option": True, }, { "names": ["--container"], - "help": "Audio container (none, wav, ogg)", + "help": ( + "Audio container (none, wav, ogg). Aura (v1) only; ignored for " + "flux-* (linear16 is auto-wrapped in WAV)." + ), "type": str, "is_option": True, }, @@ -133,7 +165,69 @@ def handle( message="No output specified. Use -o/--output to save to file, or pipe stdout.", ) + # Flux models stream over the WebSocket (speak.v2); Aura uses REST (speak.v1). + is_flux = model.lower().startswith("flux") + try: + if is_flux: + # WebSocket streaming path. Streaming output is raw audio, so + # default to linear16 @ 24kHz and wrap it in WAV for playback. + eff_encoding = encoding or "linear16" + if eff_encoding not in ("linear16", "mulaw", "alaw"): + return BaseResult( + status="error", + message=( + f"Encoding '{eff_encoding}' is not supported for Flux " + "(Speak v2) streaming, which emits raw audio. Use " + "linear16 (default), mulaw, or alaw." + ), + ) + eff_sample_rate = sample_rate or 24000.0 + console.print( + f"[blue]Generating speech with {model} " + f"via WebSocket streaming...[/blue]" + ) + + pcm = bytearray() + for chunk in client.speak_text_stream( + text=text, + model=model, + encoding=eff_encoding, + sample_rate=eff_sample_rate, + ): + pcm.extend(chunk) + + if eff_encoding == "linear16": + audio_bytes = _pcm_to_wav( + bytes(pcm), sample_rate=int(eff_sample_rate) + ) + else: + audio_bytes = bytes(pcm) + + total_bytes = len(audio_bytes) + if output_path: + Path(output_path).write_bytes(audio_bytes) + console.print( + f"[green]Audio saved to {output_path}[/green] " + f"({total_bytes:,} bytes)" + ) + return SpeakResult( + status="success", + message=f"Audio saved to {output_path}", + output_path=output_path, + model=model, + bytes_written=total_bytes, + ) + sys.stdout.buffer.write(audio_bytes) + sys.stdout.buffer.flush() + return SpeakResult( + status="success", + message=f"Wrote {total_bytes:,} bytes to stdout", + model=model, + bytes_written=total_bytes, + ) + + # REST path (Aura v1) — unchanged. console.print(f"[blue]Generating speech with {model}...[/blue]") audio_iter = client.speak_text( diff --git a/packages/deepctl-cmd-speak/tests/unit/test_speak_command.py b/packages/deepctl-cmd-speak/tests/unit/test_speak_command.py index 82bf02a..476a73a 100644 --- a/packages/deepctl-cmd-speak/tests/unit/test_speak_command.py +++ b/packages/deepctl-cmd-speak/tests/unit/test_speak_command.py @@ -231,6 +231,80 @@ def test_handle_write_to_file( assert output_file.read_bytes() == b"chunk1chunk2" + @patch("deepctl_cmd_speak.command.sys") + def test_handle_flux_streams_and_wraps_wav( + self, + mock_sys, + command, + mock_config, + mock_auth_manager, + mock_client, + tmp_path, + ): + """flux-* models route to WebSocket streaming (v2) and wrap PCM in WAV.""" + mock_sys.stdin.isatty.return_value = True + mock_sys.stdout.isatty.return_value = True + + pcm = b"\x01\x00\x02\x00\x03\x00\x04\x00" # raw 16-bit PCM + mock_client.speak_text_stream.return_value = iter([pcm[:4], pcm[4:]]) + + output_file = tmp_path / "hello.wav" + result = command.handle( + config=mock_config, + auth_manager=mock_auth_manager, + client=mock_client, + text="Hello from Flux", + output=str(output_file), + model="flux-alexis-en", + encoding=None, + container=None, + sample_rate=None, + file=None, + ) + + assert isinstance(result, SpeakResult) + assert result.status == "success" + assert result.model == "flux-alexis-en" + # Routed to streaming (v2), not batch REST (v1). + mock_client.speak_text_stream.assert_called_once() + mock_client.speak_text.assert_not_called() + # Output is a valid WAV container wrapping the streamed PCM. + data = output_file.read_bytes() + assert data[:4] == b"RIFF" + assert data[8:12] == b"WAVE" + assert pcm in data + + @patch("deepctl_cmd_speak.command.sys") + def test_handle_flux_rejects_non_raw_encoding( + self, + mock_sys, + command, + mock_config, + mock_auth_manager, + mock_client, + tmp_path, + ): + """flux-* with a containerized encoding errors clearly (streaming is raw).""" + mock_sys.stdin.isatty.return_value = True + mock_sys.stdout.isatty.return_value = True + + result = command.handle( + config=mock_config, + auth_manager=mock_auth_manager, + client=mock_client, + text="Hello", + output=str(tmp_path / "x.mp3"), + model="flux-alexis-en", + encoding="mp3", + container=None, + sample_rate=None, + file=None, + ) + + assert result.status == "error" + assert "not supported for Flux" in result.message + mock_client.speak_text_stream.assert_not_called() + @patch("deepctl_cmd_speak.command.sys") def test_handle_write_to_stdout( self, mock_sys, command, mock_config, mock_auth_manager, mock_client diff --git a/packages/deepctl-cmd-usage/pyproject.toml b/packages/deepctl-cmd-usage/pyproject.toml index ec48f2a..ad1771d 100644 --- a/packages/deepctl-cmd-usage/pyproject.toml +++ b/packages/deepctl-cmd-usage/pyproject.toml @@ -25,7 +25,7 @@ dependencies = [ "deepctl-shared-utils>=0.1.10", "click>=8.0.0", "rich>=13.0.0", - "deepgram-sdk>=6.0.0rc2", + "deepgram-sdk>=7.5.0", "pydantic>=2.0.0", ] diff --git a/packages/deepctl-core/pyproject.toml b/packages/deepctl-core/pyproject.toml index 92e5ecf..9e40483 100644 --- a/packages/deepctl-core/pyproject.toml +++ b/packages/deepctl-core/pyproject.toml @@ -23,7 +23,7 @@ keywords = ["deepgram", "core", "auth", "config", "client"] requires-python = ">=3.10" dependencies = [ "click>=8.0.0", - "deepgram-sdk>=6.0.0rc2", + "deepgram-sdk>=7.5.0", "pydantic>=2.0.0", "rich>=13.0.0", "httpx>=0.24.0", diff --git a/packages/deepctl-core/src/deepctl_core/auth.py b/packages/deepctl-core/src/deepctl_core/auth.py index 5d3850c..ca27c71 100644 --- a/packages/deepctl-core/src/deepctl_core/auth.py +++ b/packages/deepctl-core/src/deepctl_core/auth.py @@ -1,6 +1,7 @@ """Cross-platform authentication system for deepctl using dx-id OIDC provider.""" import os +import re import time import webbrowser from datetime import datetime, timedelta, timezone @@ -273,8 +274,12 @@ def verify_api_key( "Content-Type": "application/json", } + # Verify against the configured base URL (not hardcoded prod), so a + # custom base (e.g. staging) validates its own keys correctly. + base = self.config.get_profile().base_url or "https://api.deepgram.com" + host = re.sub(r"^[a-z]+://", "", base).rstrip("/") response = self.client.get( - "https://api.deepgram.com/v1/projects", + f"https://{host}/v1/projects", headers=headers, ) diff --git a/packages/deepctl-core/src/deepctl_core/base_command.py b/packages/deepctl-core/src/deepctl_core/base_command.py index 21281b5..1ea6ebb 100644 --- a/packages/deepctl-core/src/deepctl_core/base_command.py +++ b/packages/deepctl-core/src/deepctl_core/base_command.py @@ -56,7 +56,10 @@ def execute(self, ctx: click.Context, **kwargs: Any) -> None: config = Config() # Extract explicit credentials from kwargs if provided - explicit_api_key = kwargs.get("api_key") + # (or from the global --api-key option stored on ctx.obj) + explicit_api_key = kwargs.get("api_key") or ( + ctx.obj.get("api_key") if ctx.obj else None + ) explicit_project_id = kwargs.get("project_id") # Create auth manager with explicit credentials diff --git a/packages/deepctl-core/src/deepctl_core/client.py b/packages/deepctl-core/src/deepctl_core/client.py index 462619f..4ba3784 100644 --- a/packages/deepctl-core/src/deepctl_core/client.py +++ b/packages/deepctl-core/src/deepctl_core/client.py @@ -2,6 +2,7 @@ from __future__ import annotations +import re from pathlib import Path from typing import TYPE_CHECKING, Any, cast @@ -64,10 +65,17 @@ def _create_client(self) -> DGClient: current_profile.base_url and current_profile.base_url != "https://api.deepgram.com" ): + # Derive both the REST (https) and WebSocket (wss) endpoints from + # the configured host, so a custom base URL works for batch REST + # *and* streaming. agent_rest is required by the SDK environment. + host = re.sub(r"^[a-z]+://", "", current_profile.base_url).rstrip( + "/" + ) kwargs["environment"] = DeepgramClientEnvironment( - base=current_profile.base_url, - production=current_profile.base_url, - agent=current_profile.base_url, + base=f"https://{host}", + production=f"wss://{host}", + agent=f"wss://{host}", + agent_rest=f"https://{host}", ) client = DGClient(**kwargs) @@ -158,6 +166,40 @@ def speak_text( except Exception as e: raise ApiError(body=f"Text-to-speech failed: {e}") + def speak_text_stream( + self, + text: str, + model: str, + encoding: str | None = None, + sample_rate: float | None = None, + ) -> Iterator[bytes]: + """Stream TTS audio over the Flux v2 WebSocket (speak.v2.connect). + + Yields raw audio chunks as they arrive. The streaming transport emits + raw (non-containerized) audio, so only linear16/mulaw/alaw encodings + apply and sample_rate is sent as the string the streaming API expects. + """ + from deepgram.speak.v2.types.speak_v2speak import SpeakV2Speak + + connect_kwargs: dict[str, Any] = {"model": model} + if encoding: + connect_kwargs["encoding"] = encoding + if sample_rate: + connect_kwargs["sample_rate"] = str(int(sample_rate)) + + try: + with self.client.speak.v2.connect(**connect_kwargs) as conn: + conn.send_speak(SpeakV2Speak(type="Speak", text=text)) + conn.send_flush() + conn.send_close() + for message in conn: + # Audio arrives as raw bytes; control messages are parsed + # objects, which we skip here. + if isinstance(message, bytes): + yield message + except Exception as e: + raise ApiError(body=f"Text-to-speech streaming failed: {e}") + # ── Text Intelligence (Read) ─────────────────────────────────── def analyze_text( diff --git a/packages/deepctl-core/src/deepctl_core/skill_generator.py b/packages/deepctl-core/src/deepctl_core/skill_generator.py index 1af9e20..6a1ae7f 100644 --- a/packages/deepctl-core/src/deepctl_core/skill_generator.py +++ b/packages/deepctl-core/src/deepctl_core/skill_generator.py @@ -426,47 +426,78 @@ def render_developer_guide( "- **Aura-2** — Latest generation. High quality, low latency, many voices." ) lines.append("- **Aura** — Previous generation. Solid quality and performance.") + lines.append( + "- **Flux** — Conversational TTS on the Speak v2 WebSocket API " + "(streaming, turn-based). Voices like `flux-alexis-en`." + ) lines.append("") lines.append("### Popular Voices") lines.append("") - lines.append("Voices follow the naming pattern `aura-2-{name}-en`. Examples:") + lines.append("Aura voices follow `aura-2-{name}-en`. Examples:") lines.append("- `aura-2-andromeda-en`, `aura-2-arcas-en`, `aura-2-atlas-en`") lines.append("- `aura-2-luna-en`, `aura-2-stella-en`, `aura-2-helios-en`") lines.append("") + lines.append( + "Flux (Speak v2) voices follow `flux-{name}-en` (English at launch), " + "e.g. `flux-alexis-en`." + ) + lines.append("") lines.append("Full voice list: ") lines.append("") - lines.append("### TTS Example (Python)") + lines.append("### Aura TTS — Speak v1, batch REST (Python)") lines.append("") lines.append("```python") - lines.append("from deepgram import DeepgramClient, SpeakOptions") + lines.append("from deepgram import DeepgramClient") lines.append("") - lines.append('dg = DeepgramClient("DEEPGRAM_API_KEY")') + lines.append('client = DeepgramClient(api_key="DEEPGRAM_API_KEY")') lines.append("") - lines.append("options = SpeakOptions(") - lines.append(' model="aura-2-andromeda-en"') + lines.append("audio = client.speak.v1.audio.generate(") + lines.append(' text="Hello from Deepgram!",') + lines.append(' model="aura-2-andromeda-en",') + lines.append(' encoding="mp3",') lines.append(")") + lines.append('with open("output.mp3", "wb") as f:') + lines.append(" for chunk in audio:") + lines.append(" f.write(chunk)") + lines.append("```") lines.append("") - lines.append('response = dg.speak.rest.v("1").save(') - lines.append(' "output.mp3",') - lines.append(' {"text": "Hello from Deepgram!"},') - lines.append(" options,") - lines.append(")") + lines.append("### Flux TTS — Speak v2, WebSocket streaming (Python)") + lines.append("") + lines.append("```python") + lines.append("from deepgram import DeepgramClient") + lines.append( + "from deepgram.speak.v2.types.speak_v2speak import SpeakV2Speak" + ) + lines.append("") + lines.append('client = DeepgramClient(api_key="DEEPGRAM_API_KEY")') + lines.append("") + lines.append("with client.speak.v2.connect(") + lines.append(' model="flux-alexis-en", encoding="linear16", sample_rate="24000"') + lines.append(") as conn:") + lines.append( + ' conn.send_speak(SpeakV2Speak(type="Speak", text="Hello from Flux!"))' + ) + lines.append(" conn.send_flush()") + lines.append(" conn.send_close()") + lines.append(' with open("output.raw", "wb") as f:') + lines.append(" for message in conn:") + lines.append(" if isinstance(message, bytes):") + lines.append(" f.write(message) # raw linear16 PCM, 24kHz mono") lines.append("```") lines.append("") - lines.append("### TTS Example (JavaScript)") + lines.append("### Aura TTS — Speak v1 (JavaScript)") lines.append("") lines.append("```javascript") lines.append('import { createClient } from "@deepgram/sdk";') lines.append("") - lines.append('const dg = createClient("DEEPGRAM_API_KEY");') - lines.append("") - lines.append("const response = await dg.speak.request(") - lines.append(' { text: "Hello from Deepgram!" },') - lines.append(' { model: "aura-2-andromeda-en" }') - lines.append(");") + lines.append('const client = createClient("DEEPGRAM_API_KEY");') lines.append("") - lines.append("const stream = await response.getStream();") - lines.append("// Write stream to file or audio output") + lines.append("const response = await client.speak.v1.audio.generate({") + lines.append(' text: "Hello from Deepgram!",') + lines.append(' model: "aura-2-andromeda-en",') + lines.append("});") + lines.append("const buffer = await response.arrayBuffer();") + lines.append("// Write buffer to a file or audio output") lines.append("```") lines.append("") diff --git a/packages/deepctl-core/tests/unit/test_client.py b/packages/deepctl-core/tests/unit/test_client.py index d5772ee..9882128 100644 --- a/packages/deepctl-core/tests/unit/test_client.py +++ b/packages/deepctl-core/tests/unit/test_client.py @@ -113,11 +113,13 @@ def test_create_client_with_custom_base_url( # Create client result = client._create_client() - # Verify environment was created with custom URL + # Verify environment was derived from the host: https for REST + # (base/agent_rest) and wss for WebSocket (production/agent). mock_env.assert_called_once_with( base="https://custom.deepgram.com", - production="https://custom.deepgram.com", - agent="https://custom.deepgram.com", + production="wss://custom.deepgram.com", + agent="wss://custom.deepgram.com", + agent_rest="https://custom.deepgram.com", ) # Verify DGClient was created with environment kwarg diff --git a/pyproject.toml b/pyproject.toml index 455e9f4..63f7440 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,7 +34,7 @@ keywords = [ requires-python = ">=3.10" dependencies = [ "click>=8.0.0", - "deepgram-sdk>=6.0.0rc2", + "deepgram-sdk>=7.5.0", "deepctl-core>=0.1.10", "deepctl-cmd-login>=0.1.10", "deepctl-cmd-projects>=0.1.10", diff --git a/src/deepctl/main.py b/src/deepctl/main.py index 56c8bb9..4d8b2f7 100644 --- a/src/deepctl/main.py +++ b/src/deepctl/main.py @@ -3,6 +3,7 @@ from __future__ import annotations import importlib.metadata +import os import sys from contextlib import contextmanager from typing import TYPE_CHECKING @@ -130,6 +131,21 @@ def preprocess_hyphenated_commands(args: list[str]) -> list[str]: "-p", help="Configuration profile to use", ) +@click.option( + "--base-url", + help=( + "Override the API base URL (e.g. https://api.staging.deepgram.com). " + "REST and WebSocket endpoints are derived from this host. " + "Also settable via the DEEPGRAM_BASE_URL environment variable." + ), +) +@click.option( + "--api-key", + help=( + "Deepgram API key to use, overriding login/profile/env credentials. " + "Intended for testing and CI." + ), +) @click.option( "--output", "-o", @@ -179,6 +195,8 @@ def cli( ctx: click.Context, config: str | None, profile: str | None, + base_url: str | None, + api_key: str | None, output: str | None, quiet: bool, verbose: bool, @@ -206,9 +224,16 @@ def cli( enable_timing() with TimingContext("cli_initialization"): + # A --base-url flag overrides the configured/env base URL (flag wins). + # Config reads DEEPGRAM_BASE_URL at init, so set it before constructing. + if base_url: + os.environ["DEEPGRAM_BASE_URL"] = base_url + # Initialize configuration ctx.ensure_object(dict) ctx.obj["config"] = Config(config_path=config, profile=profile) + # Global --api-key passthrough (highest-precedence explicit credential). + ctx.obj["api_key"] = api_key ctx.obj["timing"] = timing or timing_detailed ctx.obj["timing_detailed"] = timing_detailed From 32c227288c4352ac3d336c61c67deed236fcf36b Mon Sep 17 00:00:00 2001 From: Greg Holmes Date: Wed, 15 Jul 2026 14:00:49 +0100 Subject: [PATCH 2/5] fix(speak): fail loudly, preserve base-url scheme, stream Flux to the pipe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address PR review (B2/S1/S2) and complete the "streaming by default" acceptance criterion for Flux TTS. - B2: guard empty Flux streams — raise instead of writing a header-only WAV and reporting success (file and pipe paths). Confirmed the SDK iterator yields audio as raw bytes, so the isinstance filter is correct. - S1: Flux error paths (encoding guard, streaming failure, no-audio) now raise click.ClickException, so they exit non-zero and are visible in every output format instead of returning an error result and exiting 0. - S2: preserve the base-url transport via _split_base_url (http->ws, else https/wss) in client.py and auth.py, so a plaintext local/staging endpoint isn't forced onto TLS. agent_rest confirmed required on the SDK environment. - Streaming: the pipe path now writes each chunk to stdout as it arrives (streaming WAV header on the first frame, flush per chunk) so playback starts on the first frame; the file path stays buffered because a WAV needs its data length up front. The streaming header uses 0x7FFFFFFF so ffmpeg reads it without the "may be invalid" warning while macOS CoreAudio still estimates duration for redirect-to-file. - Progress: scrolling stderr output (time-to-first-audio + throttled "received N" lines + final summary), TTY-only so agent/CI stays clean. - Docs/examples show the `ffplay -loglevel error` clean-demo form. Tests: add empty-audio, streaming-failure, stdout-streaming, progress, and http-scheme coverage. --- README.md | 4 +- .../src/deepctl_cmd_speak/command.py | 250 +++++++++++++++--- .../tests/unit/test_speak_command.py | 191 ++++++++++++- .../deepctl-core/src/deepctl_core/auth.py | 8 +- .../deepctl-core/src/deepctl_core/client.py | 37 ++- .../deepctl-core/tests/unit/test_client.py | 91 ++++--- 6 files changed, 480 insertions(+), 101 deletions(-) diff --git a/README.md b/README.md index 98f221f..097f8e2 100644 --- a/README.md +++ b/README.md @@ -186,7 +186,9 @@ dg speak "Stream me" | ffplay -nodisp - # pipe to audio player # Flux TTS (v2, WebSocket streaming) dg speak "Hello from Flux" -m flux-alexis-en -o hello.wav -dg speak "Hello from Flux" -m flux-alexis-en | ffplay -nodisp - +# Piped audio is a streaming WAV; -loglevel error hides ffmpeg's cosmetic +# end-of-stream notice (the audio is complete). +dg speak "Hello from Flux" -m flux-alexis-en | ffplay -loglevel error -nodisp -autoexit - ``` ### Text Intelligence diff --git a/packages/deepctl-cmd-speak/src/deepctl_cmd_speak/command.py b/packages/deepctl-cmd-speak/src/deepctl_cmd_speak/command.py index 947b768..26d784d 100644 --- a/packages/deepctl-cmd-speak/src/deepctl_cmd_speak/command.py +++ b/packages/deepctl-cmd-speak/src/deepctl_cmd_speak/command.py @@ -3,11 +3,14 @@ from __future__ import annotations import io +import struct import sys +import time import wave from pathlib import Path -from typing import Any +from typing import TYPE_CHECKING, Any +import click from deepctl_core import ( AuthManager, BaseCommand, @@ -19,9 +22,21 @@ from .models import SpeakResult +if TYPE_CHECKING: + from collections.abc import Iterator + console = Console(stderr=True) +def _fmt_bytes(n: int) -> str: + """Human-readable byte count for progress display.""" + if n < 1024: + return f"{n} B" + if n < 1024 * 1024: + return f"{n / 1024:.0f} KB" + return f"{n / 1024 / 1024:.1f} MB" + + def _pcm_to_wav( pcm: bytes, *, sample_rate: int, channels: int = 1, sample_width: int = 2 ) -> bytes: @@ -35,6 +50,106 @@ def _pcm_to_wav( return buf.getvalue() +def _streaming_wav_header( + *, sample_rate: int, channels: int = 1, sample_width: int = 2 +) -> bytes: + """A 44-byte PCM WAV header with placeholder (streaming) length fields. + + stdout is not seekable, so we can't back-patch the RIFF/data sizes after + the audio has streamed. A player reading a piped WAV reads until EOF, so we + emit an oversized length up front and let the closing pipe stop playback. + This lets ``dg speak ... | ffplay -`` start playing on the first chunk + instead of waiting for the whole utterance. + + The length is 0x7FFFFFFF rather than 0xFFFFFFFF (which ffmpeg treats as a + sentinel and warns "Ignoring maximum wav data size, file may be invalid") + and rather than 0 (which macOS CoreAudio trusts literally, so a redirected + ``dg speak ... > out.wav`` would look empty). An oversized-but-not-sentinel + length is read-to-EOF by ffmpeg and estimated-from-bytes by CoreAudio, so + both the pipe and the redirect-to-file cases work. + """ + byte_rate = sample_rate * channels * sample_width + block_align = channels * sample_width + bits_per_sample = sample_width * 8 + placeholder = 0x7FFFFFFF # oversized "unknown / streaming" length + return struct.pack( + "<4sI4s4sIHHIIHH4sI", + b"RIFF", + placeholder, + b"WAVE", + b"fmt ", + 16, # fmt chunk size + 1, # PCM + channels, + sample_rate, + byte_rate, + block_align, + bits_per_sample, + b"data", + placeholder, + ) + + +class _StreamProgress: + """Scrolling stderr progress for a Flux audio stream. + + Prints a line as the first audio arrives (with its latency) and then a + throttled ``received N`` line as bytes accumulate, so the stream is visibly + arriving over time. Also tracks the byte total and time-to-first-audio for + the caller's final summary. Wrap the SDK stream with ``track()``. + + Scrolling lines are shown only on an interactive stderr (a TTY); agent/CI + runs get just the caller's one-line summary. Nothing is written to stdout, + so a piped audio stream is never corrupted. + """ + + _INTERVAL = 0.2 # min seconds between scrolling "received N" lines + + def __init__(self, model: str) -> None: + self.model = model + self.total = 0 + self.first_audio: float | None = None + self._start = time.monotonic() + self._last_print = 0.0 + self._live = console.is_terminal + + def track(self, stream: Iterator[bytes]) -> Iterator[bytes]: + """Yield each chunk unchanged while emitting scrolling progress.""" + for chunk in stream: + self.total += len(chunk) + if chunk and self.first_audio is None: + self.first_audio = time.monotonic() - self._start + self._emit(first=True) + elif chunk: + self._emit() + yield chunk + + def _emit(self, first: bool = False) -> None: + if not self._live: + return + now = time.monotonic() + if not first and now - self._last_print < self._INTERVAL: + return + self._last_print = now + if first: + assert self.first_audio is not None # set by the caller before first=True + console.print( + f"[dim]Streaming {self.model} — " + f"first audio {self.first_audio * 1000:.0f} ms[/dim]" + ) + else: + console.print(f"[dim] received {_fmt_bytes(self.total)}[/dim]") + + def timing(self) -> str: + """A 'first audio X ms, Ys total' suffix for the final summary.""" + fa = ( + f"{self.first_audio * 1000:.0f} ms" + if self.first_audio is not None + else "n/a" + ) + return f"first audio {fa}, {time.monotonic() - self._start:.1f}s total" + + class SpeakCommand(BaseCommand): """Command for generating speech from text using Deepgram TTS.""" @@ -53,9 +168,11 @@ class SpeakCommand(BaseCommand): 'echo "Hello" | dg speak -o hello.mp3', 'dg speak "Hello" | ffplay -nodisp -', 'dg speak "Hello" -m aura-2-luna-en -o hello.wav --encoding linear16 --container wav', - # Flux TTS — WebSocket streaming (flux-* models), streaming by default: + # Flux TTS — WebSocket streaming (flux-* models), streaming by default. + # Piped audio is a streaming WAV (unknown length up front), so pass + # `-loglevel error` to silence ffmpeg's cosmetic end-of-stream notice. 'dg speak "Hello from Flux" -m flux-alexis-en -o hello.wav', - 'dg speak "Hello from Flux" -m flux-alexis-en | ffplay -nodisp -', + 'dg speak "Hello from Flux" -m flux-alexis-en | ffplay -loglevel error -nodisp -autoexit -', ] agent_help = ( "Convert text to speech using Deepgram's TTS API. " @@ -168,34 +285,52 @@ def handle( # Flux models stream over the WebSocket (speak.v2); Aura uses REST (speak.v1). is_flux = model.lower().startswith("flux") - try: - if is_flux: - # WebSocket streaming path. Streaming output is raw audio, so - # default to linear16 @ 24kHz and wrap it in WAV for playback. - eff_encoding = encoding or "linear16" - if eff_encoding not in ("linear16", "mulaw", "alaw"): - return BaseResult( - status="error", - message=( - f"Encoding '{eff_encoding}' is not supported for Flux " - "(Speak v2) streaming, which emits raw audio. Use " - "linear16 (default), mulaw, or alaw." - ), - ) - eff_sample_rate = sample_rate or 24000.0 - console.print( - f"[blue]Generating speech with {model} " - f"via WebSocket streaming...[/blue]" + if is_flux: + # WebSocket streaming path. Streaming output is raw audio, so + # default to linear16 @ 24kHz and wrap it in WAV for playback. + # These paths raise (rather than returning an error result) so a + # failure exits non-zero and is visible in every output format — + # a returned error is only printed in structured output and still + # exits 0. + eff_encoding = encoding or "linear16" + if eff_encoding not in ("linear16", "mulaw", "alaw"): + raise click.ClickException( + f"Encoding '{eff_encoding}' is not supported for Flux " + "(Speak v2) streaming, which emits raw audio. Use " + "linear16 (default), mulaw, or alaw." ) + eff_sample_rate = sample_rate or 24000.0 - pcm = bytearray() - for chunk in client.speak_text_stream( + # A live spinner + byte counter + time-to-first-audio on stderr, so + # the stream is visibly arriving rather than a single static line. + prog = _StreamProgress(model) + stream = prog.track( + client.speak_text_stream( text=text, model=model, encoding=eff_encoding, sample_rate=eff_sample_rate, - ): - pcm.extend(chunk) + ) + ) + + if output_path: + # A WAV file must declare its data length in the header, which + # we only know once the stream ends — so buffer, then wrap and + # write. (Streaming to disk has no user-visible benefit here.) + pcm = bytearray() + try: + for chunk in stream: + pcm.extend(chunk) + except Exception as e: + raise click.ClickException(f"Flux streaming failed: {e}") + + if not pcm: + # No audio means the stream errored upstream or returned + # nothing. Wrapping empty PCM yields a valid header-only + # WAV, so guard rather than report success on a silent file. + raise click.ClickException( + "Flux (Speak v2) streaming returned no audio." + ) if eff_encoding == "linear16": audio_bytes = _pcm_to_wav( @@ -205,29 +340,60 @@ def handle( audio_bytes = bytes(pcm) total_bytes = len(audio_bytes) - if output_path: - Path(output_path).write_bytes(audio_bytes) - console.print( - f"[green]Audio saved to {output_path}[/green] " - f"({total_bytes:,} bytes)" - ) - return SpeakResult( - status="success", - message=f"Audio saved to {output_path}", - output_path=output_path, - model=model, - bytes_written=total_bytes, - ) - sys.stdout.buffer.write(audio_bytes) - sys.stdout.buffer.flush() + Path(output_path).write_bytes(audio_bytes) + console.print( + f"[green]Audio saved to {output_path}[/green] " + f"({total_bytes:,} bytes — {prog.timing()})" + ) return SpeakResult( status="success", - message=f"Wrote {total_bytes:,} bytes to stdout", + message=f"Audio saved to {output_path}", + output_path=output_path, model=model, bytes_written=total_bytes, ) - # REST path (Aura v1) — unchanged. + # Pipe path — write each chunk to stdout as it arrives so a + # downstream player starts on the first frame (the point of + # "streaming by default"). For linear16 we emit a streaming WAV + # header before the first frame so `| ffplay -` plays with no + # extra flags; raw mulaw/alaw stream as-is. + out = sys.stdout.buffer + wrote_header = False + try: + for chunk in stream: + if not chunk: + continue + if eff_encoding == "linear16" and not wrote_header: + out.write( + _streaming_wav_header(sample_rate=int(eff_sample_rate)) + ) + wrote_header = True + out.write(chunk) + out.flush() + except Exception as e: + raise click.ClickException(f"Flux streaming failed: {e}") + + if prog.total == 0: + # Guard before anything is written: the header is only emitted + # on the first frame, so a no-audio stream writes nothing. + raise click.ClickException( + "Flux (Speak v2) streaming returned no audio." + ) + + console.print( + f"[green]✓ Streamed {prog.total:,} bytes to stdout[/green] " + f"({prog.timing()})" + ) + return SpeakResult( + status="success", + message=f"Streamed {prog.total:,} bytes to stdout", + model=model, + bytes_written=prog.total, + ) + + # REST path (Aura v1) — unchanged. + try: console.print(f"[blue]Generating speech with {model}...[/blue]") audio_iter = client.speak_text( diff --git a/packages/deepctl-cmd-speak/tests/unit/test_speak_command.py b/packages/deepctl-cmd-speak/tests/unit/test_speak_command.py index 476a73a..e59a937 100644 --- a/packages/deepctl-cmd-speak/tests/unit/test_speak_command.py +++ b/packages/deepctl-cmd-speak/tests/unit/test_speak_command.py @@ -2,12 +2,45 @@ from unittest.mock import Mock, patch +import click import pytest -from deepctl_cmd_speak.command import SpeakCommand +from deepctl_cmd_speak.command import ( + SpeakCommand, + _fmt_bytes, + _StreamProgress, +) from deepctl_cmd_speak.models import SpeakResult from deepctl_core import AuthManager, BaseResult, Config, DeepgramClient +class TestStreamProgress: + """The live-progress helper used by the Flux streaming path.""" + + def test_fmt_bytes(self): + assert _fmt_bytes(512) == "512 B" + assert _fmt_bytes(2048) == "2 KB" + assert _fmt_bytes(3 * 1024 * 1024) == "3.0 MB" + + def test_track_passes_chunks_through_and_counts(self): + prog = _StreamProgress("flux-alexis-en") + out = list(prog.track(iter([b"ab", b"", b"cde"]))) + + # Chunks are yielded unchanged (including the empty one). + assert out == [b"ab", b"", b"cde"] + # Total counts every byte; the empty chunk contributes nothing. + assert prog.total == 5 + # Time-to-first-audio is recorded from the first non-empty chunk. + assert prog.first_audio is not None + assert "first audio" in prog.timing() + + def test_track_no_audio_leaves_first_audio_unset(self): + prog = _StreamProgress("flux-alexis-en") + assert list(prog.track(iter([]))) == [] + assert prog.total == 0 + assert prog.first_audio is None + assert "n/a" in prog.timing() + + class TestSpeakCommand: """Test cases for SpeakCommand.""" @@ -284,26 +317,168 @@ def test_handle_flux_rejects_non_raw_encoding( mock_client, tmp_path, ): - """flux-* with a containerized encoding errors clearly (streaming is raw).""" + """flux-* with a containerized encoding fails loudly (streaming is raw). + + The guard raises so the process exits non-zero rather than printing + nothing and exiting 0 in the default output format. + """ mock_sys.stdin.isatty.return_value = True mock_sys.stdout.isatty.return_value = True + with pytest.raises(click.ClickException, match="not supported for Flux"): + command.handle( + config=mock_config, + auth_manager=mock_auth_manager, + client=mock_client, + text="Hello", + output=str(tmp_path / "x.mp3"), + model="flux-alexis-en", + encoding="mp3", + container=None, + sample_rate=None, + file=None, + ) + + mock_client.speak_text_stream.assert_not_called() + + @patch("deepctl_cmd_speak.command.sys") + def test_handle_flux_empty_audio_fails( + self, + mock_sys, + command, + mock_config, + mock_auth_manager, + mock_client, + tmp_path, + ): + """A Flux stream that yields no audio fails loudly and writes nothing.""" + mock_sys.stdin.isatty.return_value = True + mock_sys.stdout.isatty.return_value = True + + mock_client.speak_text_stream.return_value = iter([]) + + output_file = tmp_path / "empty.wav" + with pytest.raises(click.ClickException, match="returned no audio"): + command.handle( + config=mock_config, + auth_manager=mock_auth_manager, + client=mock_client, + text="Hello from Flux", + output=str(output_file), + model="flux-alexis-en", + encoding=None, + container=None, + sample_rate=None, + file=None, + ) + + # No header-only WAV is left behind on failure. + assert not output_file.exists() + + @patch("deepctl_cmd_speak.command.sys") + def test_handle_flux_streaming_failure_raises( + self, + mock_sys, + command, + mock_config, + mock_auth_manager, + mock_client, + tmp_path, + ): + """A streaming error surfaces as a non-zero exit, not a success/exit-0.""" + mock_sys.stdin.isatty.return_value = True + mock_sys.stdout.isatty.return_value = True + + def _boom(): + raise RuntimeError("socket closed") + yield # pragma: no cover — make this a generator + + mock_client.speak_text_stream.return_value = _boom() + + with pytest.raises(click.ClickException, match="Flux streaming failed"): + command.handle( + config=mock_config, + auth_manager=mock_auth_manager, + client=mock_client, + text="Hello from Flux", + output=str(tmp_path / "x.wav"), + model="flux-alexis-en", + encoding=None, + container=None, + sample_rate=None, + file=None, + ) + + @patch("deepctl_cmd_speak.command.sys") + def test_handle_flux_streams_to_stdout_incrementally( + self, mock_sys, command, mock_config, mock_auth_manager, mock_client + ): + """flux-* piped to stdout emits a WAV header then each chunk, flushing + per chunk so a downstream player starts on the first frame.""" + mock_sys.stdin.isatty.return_value = True + mock_sys.stdout.isatty.return_value = False + mock_buffer = Mock() + mock_sys.stdout.buffer = mock_buffer + + mock_client.speak_text_stream.return_value = iter( + [b"\x01\x00\x02\x00", b"\x03\x00\x04\x00"] + ) + result = command.handle( config=mock_config, auth_manager=mock_auth_manager, client=mock_client, - text="Hello", - output=str(tmp_path / "x.mp3"), + text="Hello from Flux", + output=None, model="flux-alexis-en", - encoding="mp3", + encoding=None, container=None, sample_rate=None, file=None, ) - assert result.status == "error" - assert "not supported for Flux" in result.message - mock_client.speak_text_stream.assert_not_called() + assert isinstance(result, SpeakResult) + assert result.status == "success" + # bytes_written counts audio only, not the injected header. + assert result.bytes_written == 8 + + writes = [c.args[0] for c in mock_buffer.write.call_args_list] + # First write is a streaming WAV header; then the raw PCM chunks. + assert writes[0][:4] == b"RIFF" + assert writes[0][8:12] == b"WAVE" + assert b"\x01\x00\x02\x00" in writes + assert b"\x03\x00\x04\x00" in writes + # Flushed per chunk (low latency), not a single flush at the end. + assert mock_buffer.flush.call_count >= 2 + + @patch("deepctl_cmd_speak.command.sys") + def test_handle_flux_stdout_empty_audio_fails( + self, mock_sys, command, mock_config, mock_auth_manager, mock_client + ): + """An empty Flux stream to stdout fails loudly and writes nothing — + no lone header, so a player never sees a valid-but-silent WAV.""" + mock_sys.stdin.isatty.return_value = True + mock_sys.stdout.isatty.return_value = False + mock_buffer = Mock() + mock_sys.stdout.buffer = mock_buffer + + mock_client.speak_text_stream.return_value = iter([]) + + with pytest.raises(click.ClickException, match="returned no audio"): + command.handle( + config=mock_config, + auth_manager=mock_auth_manager, + client=mock_client, + text="Hello from Flux", + output=None, + model="flux-alexis-en", + encoding=None, + container=None, + sample_rate=None, + file=None, + ) + + mock_buffer.write.assert_not_called() @patch("deepctl_cmd_speak.command.sys") def test_handle_write_to_stdout( diff --git a/packages/deepctl-core/src/deepctl_core/auth.py b/packages/deepctl-core/src/deepctl_core/auth.py index ca27c71..f15c04a 100644 --- a/packages/deepctl-core/src/deepctl_core/auth.py +++ b/packages/deepctl-core/src/deepctl_core/auth.py @@ -1,7 +1,6 @@ """Cross-platform authentication system for deepctl using dx-id OIDC provider.""" import os -import re import time import webbrowser from datetime import datetime, timedelta, timezone @@ -13,6 +12,7 @@ from rich.console import Console from rich.progress import Progress, SpinnerColumn, TextColumn +from .client import _split_base_url from .config import Config from .models import ProfileInfo, ProfilesResult @@ -276,10 +276,12 @@ def verify_api_key( # Verify against the configured base URL (not hardcoded prod), so a # custom base (e.g. staging) validates its own keys correctly. + # Preserve the input scheme so a plaintext endpoint isn't forced + # onto TLS. base = self.config.get_profile().base_url or "https://api.deepgram.com" - host = re.sub(r"^[a-z]+://", "", base).rstrip("/") + rest_scheme, _, host = _split_base_url(base) response = self.client.get( - f"https://{host}/v1/projects", + f"{rest_scheme}://{host}/v1/projects", headers=headers, ) diff --git a/packages/deepctl-core/src/deepctl_core/client.py b/packages/deepctl-core/src/deepctl_core/client.py index 4ba3784..4239c56 100644 --- a/packages/deepctl-core/src/deepctl_core/client.py +++ b/packages/deepctl-core/src/deepctl_core/client.py @@ -21,6 +21,22 @@ console = Console() +def _split_base_url(base_url: str) -> tuple[str, str, str]: + """Split a base URL into (rest_scheme, ws_scheme, host). + + Preserves the transport implied by the input scheme so a plaintext + endpoint is not forced onto TLS: an ``http://`` base maps to ``http`` for + REST and ``ws`` for WebSocket; anything else (including a bare host) maps + to ``https``/``wss``. The host is returned without scheme or trailing slash. + """ + match = re.match(r"^([a-z]+)://", base_url) + scheme = match.group(1) if match else "https" + host = re.sub(r"^[a-z]+://", "", base_url).rstrip("/") + if scheme in ("http", "ws"): + return "http", "ws", host + return "https", "wss", host + + class DeepgramClient: """Wrapper around Deepgram SDK with authentication integration.""" @@ -65,17 +81,18 @@ def _create_client(self) -> DGClient: current_profile.base_url and current_profile.base_url != "https://api.deepgram.com" ): - # Derive both the REST (https) and WebSocket (wss) endpoints from - # the configured host, so a custom base URL works for batch REST - # *and* streaming. agent_rest is required by the SDK environment. - host = re.sub(r"^[a-z]+://", "", current_profile.base_url).rstrip( - "/" - ) + # Derive both the REST and WebSocket endpoints from the + # configured base URL, so a custom base works for batch REST + # *and* streaming. Preserve the transport implied by the input + # scheme (http→ws, https→wss) so a plaintext local/staging + # endpoint isn't forced onto TLS. agent_rest is required by the + # SDK environment. + rest_scheme, ws_scheme, host = _split_base_url(current_profile.base_url) kwargs["environment"] = DeepgramClientEnvironment( - base=f"https://{host}", - production=f"wss://{host}", - agent=f"wss://{host}", - agent_rest=f"https://{host}", + base=f"{rest_scheme}://{host}", + production=f"{ws_scheme}://{host}", + agent=f"{ws_scheme}://{host}", + agent_rest=f"{rest_scheme}://{host}", ) client = DGClient(**kwargs) diff --git a/packages/deepctl-core/tests/unit/test_client.py b/packages/deepctl-core/tests/unit/test_client.py index 9882128..4cab216 100644 --- a/packages/deepctl-core/tests/unit/test_client.py +++ b/packages/deepctl-core/tests/unit/test_client.py @@ -128,6 +128,29 @@ def test_create_client_with_custom_base_url( environment=mock_env.return_value, ) + @patch("deepctl_core.client.DGClient") + @patch("deepctl_core.client.DeepgramClientEnvironment") + def test_create_client_preserves_plaintext_scheme( + self, mock_env, mock_dg_client, mock_config, mock_auth_manager + ): + """A plaintext (http) base URL keeps http/ws — it isn't forced to TLS.""" + mock_profile = Mock() + mock_profile.base_url = "http://localhost:8080" + mock_config.get_profile.return_value = mock_profile + + client = DeepgramClient(mock_config, mock_auth_manager) + mock_dg_client.return_value = Mock() + + client._create_client() + + # http REST + ws WebSocket, so a plaintext local/staging server works. + mock_env.assert_called_once_with( + base="http://localhost:8080", + production="ws://localhost:8080", + agent="ws://localhost:8080", + agent_rest="http://localhost:8080", + ) + def test_create_client_no_api_key(self, client, mock_auth_manager): """Test creating client without API key raises error.""" mock_auth_manager.get_api_key.return_value = None @@ -148,9 +171,7 @@ def test_create_client_error_handling( @patch("deepctl_core.client.DGClient") @patch("deepctl_core.client.Path.exists") @patch("builtins.open", new_callable=mock_open, read_data=b"audio data") - def test_transcribe_file( - self, mock_file, mock_exists, mock_dg_client, client - ): + def test_transcribe_file(self, mock_file, mock_exists, mock_dg_client, client): """Test transcribing a file.""" # Setup mocks mock_exists.return_value = True @@ -195,9 +216,7 @@ def test_transcribe_url(self, mock_dg_client, client): @patch("deepctl_core.client.DGClient") @patch("deepctl_core.client.Path.exists") - def test_transcribe_file_not_found( - self, mock_exists, mock_dg_client, client - ): + def test_transcribe_file_not_found(self, mock_exists, mock_dg_client, client): """Test error when file not found.""" mock_exists.return_value = False @@ -215,7 +234,9 @@ def test_get_projects(self, mock_dg_client, client): {"project_id": "proj2", "name": "Project 2"}, ] } - mock_instance.manage.v1.projects.list.return_value = _mock_sdk_response(mock_data) + mock_instance.manage.v1.projects.list.return_value = _mock_sdk_response( + mock_data + ) mock_dg_client.return_value = mock_instance # Get projects @@ -230,7 +251,9 @@ def test_get_project(self, mock_dg_client, client): # Setup mock mock_instance = Mock() mock_data = {"project_id": "test-project", "name": "Test Project"} - mock_instance.manage.v1.projects.get.return_value = _mock_sdk_response(mock_data) + mock_instance.manage.v1.projects.get.return_value = _mock_sdk_response( + mock_data + ) mock_dg_client.return_value = mock_instance # Get project @@ -245,7 +268,9 @@ def test_get_usage(self, mock_dg_client, client): # Setup mock mock_instance = Mock() mock_data = {"minutes": 1000, "cost": 25.00} - mock_instance.manage.v1.projects.usage.get.return_value = _mock_sdk_response(mock_data) + mock_instance.manage.v1.projects.usage.get.return_value = _mock_sdk_response( + mock_data + ) mock_dg_client.return_value = mock_instance # Get usage with individual date parameters @@ -266,7 +291,9 @@ def test_create_project(self, mock_dg_client, client): # Setup mock mock_instance = Mock() mock_data = {"project_id": "new-proj", "name": "New Project"} - mock_instance.manage.v1.projects.update.return_value = _mock_sdk_response(mock_data) + mock_instance.manage.v1.projects.update.return_value = _mock_sdk_response( + mock_data + ) mock_dg_client.return_value = mock_instance # Create project @@ -280,9 +307,7 @@ def test_list_models(self, mock_dg_client, client): """Test listing models.""" mock_instance = Mock() mock_data = {"stt": [], "tts": []} - mock_instance.manage.v1.models.list.return_value = _mock_sdk_response( - mock_data - ) + mock_instance.manage.v1.models.list.return_value = _mock_sdk_response(mock_data) mock_dg_client.return_value = mock_instance result = client.list_models() @@ -295,9 +320,7 @@ def test_get_model(self, mock_dg_client, client): """Test getting a specific model.""" mock_instance = Mock() mock_data = {"uuid": "model-id", "name": "Nova-3"} - mock_instance.manage.v1.models.get.return_value = _mock_sdk_response( - mock_data - ) + mock_instance.manage.v1.models.get.return_value = _mock_sdk_response(mock_data) mock_dg_client.return_value = mock_instance result = client.get_model("model-id") @@ -325,9 +348,7 @@ def test_analyze_text(self, mock_dg_client, client): """Test analyzing text.""" mock_instance = Mock() mock_data = {"results": {"summary": {"text": "hi"}}} - mock_instance.read.v1.text.analyze.return_value = _mock_sdk_response( - mock_data - ) + mock_instance.read.v1.text.analyze.return_value = _mock_sdk_response(mock_data) mock_dg_client.return_value = mock_instance result = client.analyze_text("Hello world", summarize=True) @@ -340,8 +361,8 @@ def test_list_keys(self, mock_dg_client, client): """Test listing API keys.""" mock_instance = Mock() mock_data = {"api_keys": []} - mock_instance.manage.v1.projects.keys.list.return_value = ( - _mock_sdk_response(mock_data) + mock_instance.manage.v1.projects.keys.list.return_value = _mock_sdk_response( + mock_data ) mock_dg_client.return_value = mock_instance @@ -355,14 +376,12 @@ def test_create_key(self, mock_dg_client, client): """Test creating an API key.""" mock_instance = Mock() mock_data = {"api_key_id": "k1", "key": "sk-xxx"} - mock_instance.manage.v1.projects.keys.create.return_value = ( - _mock_sdk_response(mock_data) + mock_instance.manage.v1.projects.keys.create.return_value = _mock_sdk_response( + mock_data ) mock_dg_client.return_value = mock_instance - result = client.create_key( - project_id="test-project", comment="test key" - ) + result = client.create_key(project_id="test-project", comment="test key") assert result == mock_data mock_instance.manage.v1.projects.keys.create.assert_called_once() @@ -372,8 +391,8 @@ def test_get_key(self, mock_dg_client, client): """Test getting a specific API key.""" mock_instance = Mock() mock_data = {"api_key_id": "key-id"} - mock_instance.manage.v1.projects.keys.get.return_value = ( - _mock_sdk_response(mock_data) + mock_instance.manage.v1.projects.keys.get.return_value = _mock_sdk_response( + mock_data ) mock_dg_client.return_value = mock_instance @@ -389,8 +408,8 @@ def test_delete_key(self, mock_dg_client, client): """Test deleting an API key.""" mock_instance = Mock() mock_data = {} - mock_instance.manage.v1.projects.keys.delete.return_value = ( - _mock_sdk_response(mock_data) + mock_instance.manage.v1.projects.keys.delete.return_value = _mock_sdk_response( + mock_data ) mock_dg_client.return_value = mock_instance @@ -421,8 +440,8 @@ def test_get_request(self, mock_dg_client, client): """Test getting a specific API request.""" mock_instance = Mock() mock_data = {"request_id": "req-id"} - mock_instance.manage.v1.projects.requests.get.return_value = ( - _mock_sdk_response(mock_data) + mock_instance.manage.v1.projects.requests.get.return_value = _mock_sdk_response( + mock_data ) mock_dg_client.return_value = mock_instance @@ -468,8 +487,8 @@ def test_list_members(self, mock_dg_client, client): """Test listing project members.""" mock_instance = Mock() mock_data = {"members": []} - mock_instance.manage.v1.projects.members.list.return_value = ( - _mock_sdk_response(mock_data) + mock_instance.manage.v1.projects.members.list.return_value = _mock_sdk_response( + mock_data ) mock_dg_client.return_value = mock_instance @@ -520,9 +539,7 @@ def test_create_invite(self, mock_dg_client, client): ) mock_dg_client.return_value = mock_instance - result = client.create_invite( - "a@b.com", "member", project_id="test-project" - ) + result = client.create_invite("a@b.com", "member", project_id="test-project") assert result == mock_data mock_instance.manage.v1.projects.members.invites.create.assert_called_once() From 1907eaafb04d66d8620a9691c52c6c0c4f3ea091 Mon Sep 17 00:00:00 2001 From: Greg Holmes Date: Wed, 15 Jul 2026 15:50:09 +0100 Subject: [PATCH 3/5] fix(speak): return None on pipe paths so agentic JSON can't corrupt audio MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In agentic/CI mode (CI, CLAUDECODE, --agent-friendly, --non-interactive) or with --output json, setup_output switches the default format to json and output_result serializes the returned result via the stdout console. On the pipe paths that stdout already carries the raw audio, so the JSON summary was appended to the audio stream — e.g. `CLAUDECODE=1 dg speak ... > out.wav` ended with trailing JSON, breaking the redirect-to-file guarantee. Return None from both the Flux and Aura stdout branches so the framework skips output_result (it guards on `result is not None`); the one-line summary already goes to the stderr console. The -o file paths keep returning SpeakResult since their stdout is free. Tests updated to assert the pipe paths return None. --- .../src/deepctl_cmd_speak/command.py | 26 +++++++++---------- .../tests/unit/test_speak_command.py | 22 +++++++++------- 2 files changed, 25 insertions(+), 23 deletions(-) diff --git a/packages/deepctl-cmd-speak/src/deepctl_cmd_speak/command.py b/packages/deepctl-cmd-speak/src/deepctl_cmd_speak/command.py index 26d784d..4c9dd83 100644 --- a/packages/deepctl-cmd-speak/src/deepctl_cmd_speak/command.py +++ b/packages/deepctl-cmd-speak/src/deepctl_cmd_speak/command.py @@ -248,7 +248,7 @@ def handle( auth_manager: AuthManager, client: DeepgramClient, **kwargs: Any, - ) -> BaseResult: + ) -> BaseResult | None: text = kwargs.get("text") output_path = kwargs.get("output") model = kwargs.get("model") or "aura-2-asteria-en" @@ -385,12 +385,12 @@ def handle( f"[green]✓ Streamed {prog.total:,} bytes to stdout[/green] " f"({prog.timing()})" ) - return SpeakResult( - status="success", - message=f"Streamed {prog.total:,} bytes to stdout", - model=model, - bytes_written=prog.total, - ) + # Return None so the framework skips output_result: in agentic/CI + # (or --output json) mode it would serialize the result to the + # stdout console — i.e. append JSON to the audio we just streamed, + # corrupting a `> out.wav` redirect. The summary above already went + # to the stderr console. + return None # REST path (Aura v1) — unchanged. try: @@ -433,12 +433,12 @@ def handle( total_bytes += len(chunk) stdout_buffer.flush() - return SpeakResult( - status="success", - message=f"Wrote {total_bytes:,} bytes to stdout", - model=model, - bytes_written=total_bytes, - ) + console.print(f"[green]✓ Wrote {total_bytes:,} bytes to stdout[/green]") + # Return None so the framework skips output_result — otherwise + # agentic/CI (or --output json) mode serializes JSON onto the + # same stdout as the audio, corrupting a `> out` redirect. The + # summary above went to the stderr console. + return None except Exception as e: console.print(f"[red]Error generating speech:[/red] {e}") diff --git a/packages/deepctl-cmd-speak/tests/unit/test_speak_command.py b/packages/deepctl-cmd-speak/tests/unit/test_speak_command.py index e59a937..29c0ce5 100644 --- a/packages/deepctl-cmd-speak/tests/unit/test_speak_command.py +++ b/packages/deepctl-cmd-speak/tests/unit/test_speak_command.py @@ -159,8 +159,8 @@ def test_handle_text_from_file( file="test.txt", ) - assert isinstance(result, SpeakResult) - assert result.status == "success" + # Aura stdout path returns None (audio already written to the pipe). + assert result is None mock_path_cls.assert_any_call("test.txt") mock_path_instance.read_text.assert_called_once() mock_client.speak_text.assert_called_once_with( @@ -437,10 +437,9 @@ def test_handle_flux_streams_to_stdout_incrementally( file=None, ) - assert isinstance(result, SpeakResult) - assert result.status == "success" - # bytes_written counts audio only, not the injected header. - assert result.bytes_written == 8 + # Returns None so the framework never serializes a result to stdout — + # in agentic/json mode that JSON would corrupt the piped audio. + assert result is None writes = [c.args[0] for c in mock_buffer.write.call_args_list] # First write is a streaming WAV header; then the raw PCM chunks. @@ -484,7 +483,12 @@ def test_handle_flux_stdout_empty_audio_fails( def test_handle_write_to_stdout( self, mock_sys, command, mock_config, mock_auth_manager, mock_client ): - """Test writing audio output to stdout when not a TTY.""" + """Aura piped to stdout writes audio and returns None. + + Returning None keeps the framework from serializing a result to the + stdout console — in agentic/json mode that JSON would be appended to + the audio, corrupting a `> out` redirect. + """ mock_sys.stdin.isatty.return_value = True mock_sys.stdout.isatty.return_value = False @@ -506,9 +510,7 @@ def test_handle_write_to_stdout( file=None, ) - assert isinstance(result, SpeakResult) - assert result.status == "success" - assert result.bytes_written == len(b"chunk1") + len(b"chunk2") + assert result is None mock_stdout_buffer.write.assert_any_call(b"chunk1") mock_stdout_buffer.write.assert_any_call(b"chunk2") From bb9ce9f21c41292aed46a449395cc68f21993c51 Mon Sep 17 00:00:00 2001 From: Greg Holmes Date: Wed, 15 Jul 2026 20:35:46 +0100 Subject: [PATCH 4/5] style(speak): satisfy ruff format on skill_generator --- packages/deepctl-core/src/deepctl_core/skill_generator.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/deepctl-core/src/deepctl_core/skill_generator.py b/packages/deepctl-core/src/deepctl_core/skill_generator.py index 6a1ae7f..842ca28 100644 --- a/packages/deepctl-core/src/deepctl_core/skill_generator.py +++ b/packages/deepctl-core/src/deepctl_core/skill_generator.py @@ -465,9 +465,7 @@ def render_developer_guide( lines.append("") lines.append("```python") lines.append("from deepgram import DeepgramClient") - lines.append( - "from deepgram.speak.v2.types.speak_v2speak import SpeakV2Speak" - ) + lines.append("from deepgram.speak.v2.types.speak_v2speak import SpeakV2Speak") lines.append("") lines.append('client = DeepgramClient(api_key="DEEPGRAM_API_KEY")') lines.append("") From 01c71a6f204dc2b162447d095414dc1046878b4e Mon Sep 17 00:00:00 2001 From: Greg Holmes Date: Wed, 15 Jul 2026 20:41:29 +0100 Subject: [PATCH 5/5] fix(lint): skip numpy stub analysis so mypy passes under py3.10 numpy>=2.2 stubs use PEP 695 'type' aliases that mypy rejects when python_version=3.10, aborting the type-check before our code is reached. ignore_missing_imports doesn't help (the stub is found, not missing), so add follow_imports=skip for the already-listed numpy/sounddevice override. --- pyproject.toml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 63f7440..4e7deca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -144,6 +144,11 @@ module = [ # Optional microphone dependencies (sounddevice, numpy) and the MCP SDK # (deepgram_mcp) ship without type stubs. ignore_missing_imports = true +# numpy>=2.2 ships PEP 695 `type` aliases in its stubs, which mypy rejects +# under python_version = "3.10" (aborting before our code is checked). Skip +# analyzing these third-party stubs entirely; usages fall back to Any. +follow_imports = "skip" +follow_imports_for_stubs = true [tool.pytest.ini_options] testpaths = ["tests", "packages/*/tests"]