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)