Skip to content

feat(voice): Grok streaming TTS, STT, optional realtime bridge, smoke#6

Merged
Bartok9 merged 4 commits into
mainfrom
feat/grok-stream-stt-realtime
Jul 11, 2026
Merged

feat(voice): Grok streaming TTS, STT, optional realtime bridge, smoke#6
Bartok9 merged 4 commits into
mainfrom
feat/grok-stream-stt-realtime

Conversation

@Bartok9

@Bartok9 Bartok9 commented Jul 11, 2026

Copy link
Copy Markdown
Owner

Summary

Implements OpenVoiceUI upgrade round 2 Priority A–D against main (public fork hygiene: no secrets, XAI_API_KEY env only).

A — Streaming Grok TTS (TTFA)

  • Documented bidirectional WS wss://api.x.ai/v1/tts on GrokProvider.stream_speech_sync / stream_speech / stream_speech_async
  • Query params include optimize_streaming_latency (default 1 on stream path for lower time-to-first-audio)
  • Injectable connect_fn for mocked unit tests (tests/test_grok_tts_stream.py)
  • Registry adapter providers/tts/grok_provider.py delegates stream_speech

B — Grok STT

  • providers/stt/grok_provider.pyPOST https://api.x.ai/v1/stt multipart (file last)
  • Registry id grok; missing-key + happy-path mocks (tests/test_grok_stt_provider.py)
  • Docs: docs/features/grok-stt.md

C — Experimental realtime bridge (FLAG OFF)

  • services/grok_realtime_bridge.py
  • Flag: OPENVOICEUI_GROK_REALTIME (default off)
  • Target: wss://api.x.ai/v1/realtime?model=grok-voice-latest
  • Minimal session.update (voice=eve, server_vad)
  • Server-side only; helper for ephemeral client secrets endpoint (no browser raw key)
  • Mocked tests: tests/test_grok_realtime_bridge.py

D — Dev smoke

  • scripts/smoke_grok_tts.py writes under /tmp only, exit 0/1 (soft-skip without key)
  • README: Grok works if you have XAI_API_KEY

Test plan

  • pytest -q on Grok suites → 29 passed
  • Live smoke with key → /tmp/openvoiceui-grok-tts-smoke-*.mp3

Security

  • No keys in repo; realtime never ships raw key to browsers

Note

Medium Risk
New outbound xAI integrations (STT multipart, TTS/realtime WebSockets) and env-gated realtime helpers; keys stay server-side but misconfiguration or enabling the bridge increases exposure surface.

Overview
Expands xAI / Grok voice support under a single XAI_API_KEY: batch STT, WebSocket streaming TTS for lower time-to-first-audio, plus an opt-in server-side realtime bridge.

Grok STT adds registry id grok (GrokSTTProvider) with multipart POST /v1/stt (file field last), word-level segments, and httpx as a dependency. Streaming TTS on canonical GrokProvider uses wss://api.x.ai/v1/tts via stream_speech_sync / stream_speech / stream_speech_async, with optimize_streaming_latency and injectable connect_fn for tests; the TTS registry adapter delegates stream_speech.

Experimental realtime lives in services/grok_realtime_bridge.py, gated by OPENVOICEUI_GROK_REALTIME (default off), targeting wss://api.x.ai/v1/realtime with minimal session.update and a helper for ephemeral client secrets—server-only, no browser raw keys.

Docs (.env.example, README, grok-tts / new grok-stt), scripts/smoke_grok_tts.py (/tmp output), and mocked pytest suites cover the new paths.

Reviewed by Cursor Bugbot for commit b3a38d2. Bugbot is set up for automated code reviews on this repo. Configure here.

- WS stream TTS (wss://api.x.ai/v1/tts) with TTFA optimize_streaming_latency
- Grok STT provider POST /v1/stt + unit tests (mock)
- Experimental realtime bridge flag OPENVOICEUI_GROK_REALTIME default off
- scripts/smoke_grok_tts.py writes /tmp only; README white-label docs

No secrets. XAI_API_KEY env only. pytest green for new Grok suites.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Streaming ignores voice_id kwarg
    • stream_speech_sync/async now resolve voice_id like generate_speech, and the registry adapter pops voice_id before delegating.
  • ✅ Fixed: WS stream lacks receive timeout
    • Both sync and async WS receive loops now enforce a STREAM_TIMEOUT deadline and raise RuntimeError on expiry.

Create PR

Or push these changes by commenting:

@cursor push 22595d11c5
Preview (22595d11c5)
diff --git a/providers/tts/grok_provider.py b/providers/tts/grok_provider.py
--- a/providers/tts/grok_provider.py
+++ b/providers/tts/grok_provider.py
@@ -78,7 +78,7 @@
         self.validate_text(text)
         if not self.api_key:
             raise TTSError("grok", "XAI_API_KEY not set")
-        voice = kwargs.pop("voice", self.default_voice)
+        voice = kwargs.pop("voice_id", None) or kwargs.pop("voice", self.default_voice)
         language = kwargs.pop("language", None) or kwargs.pop("lang", None) or self.default_language
         try:
             return self._get_impl().stream_speech_sync(

diff --git a/tests/test_grok_tts_stream.py b/tests/test_grok_tts_stream.py
--- a/tests/test_grok_tts_stream.py
+++ b/tests/test_grok_tts_stream.py
@@ -18,7 +18,7 @@
     def send(self, data):
         self.sent.append(data)
 
-    def recv(self):
+    def recv(self, timeout=None):
         if not self._frames:
             raise RuntimeError("no more frames")
         return self._frames.pop(0)

diff --git a/tts_providers/grok_provider.py b/tts_providers/grok_provider.py
--- a/tts_providers/grok_provider.py
+++ b/tts_providers/grok_provider.py
@@ -175,6 +175,7 @@
         speed: Optional[float] = None,
         text_normalization: bool = False,
         connect_fn=None,
+        **kwargs: Any,
     ) -> Iterator[bytes]:
         """
         Stream TTS via bidirectional WebSocket ``wss://api.x.ai/v1/tts`` (TTFA path).
@@ -187,6 +188,7 @@
         if not self.api_key:
             raise RuntimeError("XAI_API_KEY not set")
 
+        voice = kwargs.get("voice_id") or voice or "eve"
         self.validate_text(text)
         # WS path has no hard total length on the session; still guard unary-like
         # single-delta size
@@ -235,8 +237,19 @@
         with connector(uri, headers) as ws:
             ws.send(json.dumps({"type": "text.delta", "delta": text}))
             ws.send(json.dumps({"type": "text.done"}))
+            deadline = t0 + STREAM_TIMEOUT
             while True:
-                raw = ws.recv()
+                remaining = deadline - time.time()
+                if remaining <= 0:
+                    raise RuntimeError(
+                        f"[grok:timeout] TTS stream after {STREAM_TIMEOUT}s"
+                    )
+                try:
+                    raw = ws.recv(timeout=remaining)
+                except TimeoutError as exc:
+                    raise RuntimeError(
+                        f"[grok:timeout] TTS stream after {STREAM_TIMEOUT}s"
+                    ) from exc
                 if isinstance(raw, bytes):
                     # Some stacks may deliver binary audio frames — yield as-is
                     if first:
@@ -296,10 +309,12 @@
         speed: Optional[float] = None,
         text_normalization: bool = False,
         connect_fn=None,
+        **kwargs: Any,
     ) -> AsyncIterator[bytes]:
         """Async variant of WebSocket streaming TTS (prefer TTFA path)."""
         if not self.api_key:
             raise RuntimeError("XAI_API_KEY not set")
+        voice = kwargs.get("voice_id") or voice or "eve"
         self.validate_text(text)
         if len(text) > MAX_CHARACTERS:
             raise ValueError(f"Text exceeds max {MAX_CHARACTERS} characters per delta")
@@ -330,7 +345,19 @@
         async with connector(uri, headers) as ws:
             await ws.send(json.dumps({"type": "text.delta", "delta": text}))
             await ws.send(json.dumps({"type": "text.done"}))
-            async for raw in ws:
+            deadline = t0 + STREAM_TIMEOUT
+            while True:
+                remaining = deadline - time.time()
+                if remaining <= 0:
+                    raise RuntimeError(
+                        f"[grok:timeout] TTS stream after {STREAM_TIMEOUT}s"
+                    )
+                try:
+                    raw = await asyncio.wait_for(ws.recv(), timeout=remaining)
+                except asyncio.TimeoutError as exc:
+                    raise RuntimeError(
+                        f"[grok:timeout] TTS stream after {STREAM_TIMEOUT}s"
+                    ) from exc
                 if isinstance(raw, bytes):
                     if first:
                         first = False

You can send follow-ups to the cloud agent here.

Comment thread tts_providers/grok_provider.py
Comment thread tts_providers/grok_provider.py
…n WS recv

- stream_speech_sync/async now accept voice_id alias (parity with unary generate_speech), fixing wrong-default/TypeError for voice_id callers
- registry stream adapter forwards voice_id
- WS recv now bounded by STREAM_TIMEOUT (was defined but unused); raises TimeoutError instead of blocking forever if audio.done never arrives
- tests: voice_id alias + stream-timeout coverage (Bugbot findings)

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Async stream silent early exit
    • Replaced the StopAsyncIteration break with a RuntimeError so premature WebSocket close surfaces as a failure instead of silently completing with partial audio.

Create PR

Or push these changes by commenting:

@cursor push c93b74917e
Preview (c93b74917e)
diff --git a/tts_providers/grok_provider.py b/tts_providers/grok_provider.py
--- a/tts_providers/grok_provider.py
+++ b/tts_providers/grok_provider.py
@@ -360,7 +360,9 @@
                 try:
                     raw = await asyncio.wait_for(aiter.__anext__(), timeout=remaining)
                 except StopAsyncIteration:
-                    break
+                    raise RuntimeError(
+                        "[grok:ws] WebSocket closed before audio.done"
+                    )
                 except asyncio.TimeoutError as exc:
                     raise TimeoutError(
                         f"[grok:ws] recv timed out after {STREAM_TIMEOUT}s"

You can send follow-ups to the cloud agent here.

Comment thread tts_providers/grok_provider.py Outdated
@Bartok9

Bartok9 commented Jul 11, 2026

Copy link
Copy Markdown
Owner Author

@cursor push c93b749

After push, leave Ready for review so CI/Bugbot re-run — I will merge when green.

@Bartok9

Bartok9 commented Jul 11, 2026

Copy link
Copy Markdown
Owner Author

@cursor push c93b749

Raise RuntimeError when the async WebSocket iterator ends without an
audio.done event, matching sync stream behavior and surfacing truncated
audio to callers.

Applied via @cursor push command
@cursor

cursor Bot commented Jul 11, 2026

Copy link
Copy Markdown

The Bugbot autofix is already on the PR branch and pushed.

Push status

  • Branch feat/grok-stream-stt-realtime is up to date with origin (git pushEverything up-to-date).
  • Autofix commit c93b74917e is on the branch as 1e335a2 (same patch; message notes Applied via @cursor push command). git diff c93b74917e 1e335a2 is empty.

Fix applied — in stream_speech_async, premature WebSocket close now raises instead of silently returning partial audio:

                except StopAsyncIteration:
                    raise RuntimeError(
                        "[grok:ws] WebSocket closed before audio.done"
                    )

PR #6feat(voice): Grok streaming TTS, STT, optional realtime bridge, smoke

  • Ready for review (isDraft: false)
  • Tests — passed
  • Security Scan — passed
  • Cursor Bugbot — still running on the new commit

No further action needed on your side until Bugbot finishes; you can merge when that check is green.

Open in Web Open in Cursor 

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 3 potential issues.

Fix All in Cursor

Bugbot Autofix prepared fixes for all 3 issues found in the latest run.

  • ✅ Fixed: Streaming TTS rejects lang
    • stream_speech now resolves the lang alias to language before calling stream_speech_sync, matching unary generate_speech behavior.
  • ✅ Fixed: Stream forwards extra kwargs
    • stream_speech filters kwargs to stream_speech_sync's accepted parameters and the adapter delegates through that wrapper instead of forwarding unknown keys.
  • ✅ Fixed: Stream mutates caller kwargs
    • GrokTTSProvider.stream_speech now uses non-destructive .get reads like generate_speech, leaving the caller's options dict unchanged.

Create PR

Or push these changes by commenting:

@cursor push 6c0d1ed8bf
Preview (6c0d1ed8bf)
diff --git a/providers/tts/grok_provider.py b/providers/tts/grok_provider.py
--- a/providers/tts/grok_provider.py
+++ b/providers/tts/grok_provider.py
@@ -78,11 +78,20 @@
         self.validate_text(text)
         if not self.api_key:
             raise TTSError("grok", "XAI_API_KEY not set")
-        voice = kwargs.pop("voice_id", None) or kwargs.pop("voice", None) or self.default_voice
-        language = kwargs.pop("language", None) or kwargs.pop("lang", None) or self.default_language
+        voice = kwargs.get("voice_id") or kwargs.get("voice") or self.default_voice
+        language = (
+            kwargs.get("language") or kwargs.get("lang") or self.default_language
+        )
         try:
-            return self._get_impl().stream_speech_sync(
-                text, voice=voice, language=language, **kwargs
+            return self._get_impl().stream_speech(
+                text,
+                voice=voice,
+                language=language,
+                **{
+                    k: v
+                    for k, v in kwargs.items()
+                    if k not in ("voice", "voice_id", "language", "lang")
+                },
             )
         except TTSError:
             raise

diff --git a/tts_providers/grok_provider.py b/tts_providers/grok_provider.py
--- a/tts_providers/grok_provider.py
+++ b/tts_providers/grok_provider.py
@@ -298,7 +298,29 @@
         **kwargs: Any,
     ) -> Iterator[bytes]:
         """Alias for :meth:`stream_speech_sync` (iterator of audio bytes)."""
-        return self.stream_speech_sync(text, voice=voice, **kwargs)
+        language = kwargs.get("language") or kwargs.get("lang") or "en"
+        voice_id = kwargs.get("voice_id")
+        stream_kwargs = {
+            k: v
+            for k, v in kwargs.items()
+            if k
+            in (
+                "codec",
+                "sample_rate",
+                "bit_rate",
+                "optimize_streaming_latency",
+                "speed",
+                "text_normalization",
+                "connect_fn",
+            )
+        }
+        return self.stream_speech_sync(
+            text,
+            voice=voice,
+            voice_id=voice_id,
+            language=language,
+            **stream_kwargs,
+        )
 
     async def stream_speech_async(
         self,

You can send follow-ups to the cloud agent here.

Reviewed by Cursor Bugbot for commit 1e335a2. Configure here.

Comment thread tts_providers/grok_provider.py Outdated
Comment thread providers/tts/grok_provider.py
Comment thread providers/tts/grok_provider.py Outdated
…peError

Bugbot follow-ups after initial voice_id + timeout + async fixes:
- stream_speech strips extra kwargs that unary tolerates
- registry adapter uses .get (not .pop) so shared option dicts survive
- language/lang alias forwarded into stream_speech_sync
@Bartok9
Bartok9 merged commit 6a32ca8 into main Jul 11, 2026
3 checks passed
@Bartok9
Bartok9 deleted the feat/grok-stream-stt-realtime branch July 11, 2026 21:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants