From 64c0b7b25a3eb1ed59e2abdfab0d7b2ee4d50d6c Mon Sep 17 00:00:00 2001 From: agentforce314 Date: Sat, 8 Aug 2026 09:38:16 -0700 Subject: [PATCH] fix(desktop): voice decoder handles MediaRecorder MIME params (invalid audio payload) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mic showed 'Voice transcription failed: invalid audio payload'. MediaRecorder emits blobs typed 'audio/webm;codecs=opus', so the recorded data URL is 'data:audio/webm;codecs=opus;base64,' — the mediatype carries a ';codecs=' parameter BEFORE ';base64'. The decoder's regex (^data:([^;,]*)(;base64)?,) assumed a parameter-free mediatype and failed to match, so every real recording was rejected as an invalid payload before the request was ever sent. Rewrote _decode_data_url to split on the first comma (a mediatype can't contain one), detect a trailing ';base64' on the header, and take the base mediatype from everything before the first ';param'. Now audio/webm;codecs= opus, audio/ogg; codecs=opus, and plain audio/wav all decode. Regression test covers the codec-param formats. 62 desktop tests green. Note: the request now reaches the STT endpoint; for a chat-only gateway with no Whisper model it surfaces the existing actionable 'no speech-to-text model' message instead of the cryptic decode error. Co-Authored-By: Claude Fable 5 --- src/server/desktop_audio.py | 33 ++++++++++++++++++++++-------- tests/server/test_desktop_audio.py | 15 ++++++++++++++ 2 files changed, 40 insertions(+), 8 deletions(-) diff --git a/src/server/desktop_audio.py b/src/server/desktop_audio.py index 3fb8fd53..493bc4e9 100644 --- a/src/server/desktop_audio.py +++ b/src/server/desktop_audio.py @@ -14,7 +14,6 @@ import base64 import binascii import logging -import re from dataclasses import dataclass import httpx @@ -26,7 +25,6 @@ # 404 the route, so we don't guess. _STT_PROVIDERS = ("openai", "groq") _STT_MODEL = {"openai": "whisper-1", "groq": "whisper-large-v3"} -_DATA_URL_RE = re.compile(r"^data:([^;,]*)(;base64)?,(.*)$", re.DOTALL) @dataclass @@ -38,14 +36,33 @@ class TranscriptionResult: def _decode_data_url(data_url: str) -> tuple[bytes, str] | None: - """(bytes, mime) from a data: URL, or None if it isn't one.""" - match = _DATA_URL_RE.match(data_url or "") - if not match: + """(bytes, mime) from a data: URL, or None if it isn't one. + + MediaRecorder produces MIME types with parameters, e.g. + ``data:audio/webm;codecs=opus;base64,`` — the mediatype segment can + carry its own ``;param=value`` pairs before the ``;base64`` marker. Split + on the FIRST comma (the mediatype can't contain one) rather than a regex + that assumes the mediatype is parameter-free. + """ + if not isinstance(data_url, str) or not data_url.startswith("data:"): return None - mime = match.group(1) or "audio/webm" - payload = match.group(3) + comma = data_url.find(",") + if comma == -1: + return None + header = data_url[len("data:"):comma] # e.g. "audio/webm;codecs=opus;base64" + payload = data_url[comma + 1:] + is_base64 = header.rstrip().endswith(";base64") + if is_base64: + header = header.rstrip()[: -len(";base64")] + # Base mediatype is everything before the first parameter (";codecs=…"). + mime = header.split(";", 1)[0].strip() or "audio/webm" try: - raw = base64.b64decode(payload) if match.group(2) else payload.encode("utf-8") + if is_base64: + raw = base64.b64decode(payload) + else: + from urllib.parse import unquote_to_bytes + + raw = unquote_to_bytes(payload) except (binascii.Error, ValueError): return None return raw, mime diff --git a/tests/server/test_desktop_audio.py b/tests/server/test_desktop_audio.py index 4927b177..34b3b19e 100644 --- a/tests/server/test_desktop_audio.py +++ b/tests/server/test_desktop_audio.py @@ -28,6 +28,21 @@ def test_decode_rejects_non_data_url() -> None: assert _decode_data_url("not a data url") is None +def test_decode_mediarecorder_mime_with_codec_param() -> None: + """Regression: MediaRecorder emits ``audio/webm;codecs=opus`` — the mediatype + carries a ``;codecs=`` param before ``;base64``. The old regex failed this + and the mic showed 'invalid audio payload'.""" + raw = b"\x1aE\xdf\xa3webm-bytes" + for header in ("audio/webm;codecs=opus", "audio/ogg; codecs=opus", "audio/mp4"): + url = f"data:{header};base64," + base64.b64encode(raw).decode() + decoded = _decode_data_url(url) + assert decoded is not None, header + audio, mime = decoded + assert audio == raw + # Base mediatype only — params stripped. + assert mime == header.split(";", 1)[0].strip() + + @pytest.mark.asyncio async def test_transcribe_no_provider_is_actionable(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(desktop_audio, "_configured_stt_provider", lambda: None)