From 62106422976b9c18d271a3b46f4d6d445bc24ebc Mon Sep 17 00:00:00 2001 From: gshall Date: Mon, 27 Jul 2026 01:07:12 -0400 Subject: [PATCH] fix(signal): detect M4A-branded voice notes so iOS audio reaches STT iOS Signal delivers voice notes as MP4-container AAC carrying an audio ftyp brand ("M4A "). `_guess_extension()` returned ".mp4" for every `ftyp` file regardless of brand, so those attachments were cached as documents instead of audio and STT rejected the upload: API error: Error code: 400 - Invalid file format. Supported formats: ['flac','m4a','mp3','mp4','mpeg','mpga','oga', 'ogg','wav','webm'] Read the 4-byte brand at offset 8 and return ".m4a" for audio brands ("M4A ", "M4B ") so they satisfy `_is_audio_ext()` and route to `cache_audio_from_bytes()`. Video brands (isom/mp42/avc1/qt) still return ".mp4". This mirrors the existing brand/form-type disambiguation already used in this function for RIFF (WEBP vs WAVE) and for ADTS AAC vs MP3. Verified against a real iOS Signal voice note: pre-fix the upload was rejected with the 400 above; post-fix the same bytes transcribe successfully. --- gateway/platforms/signal.py | 8 ++++++++ tests/gateway/test_signal.py | 20 ++++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/gateway/platforms/signal.py b/gateway/platforms/signal.py index 843f808c64fe..eb3539c7b682 100644 --- a/gateway/platforms/signal.py +++ b/gateway/platforms/signal.py @@ -106,6 +106,14 @@ def _guess_extension(data: bytes) -> str: if data[:4] == b"%PDF": return ".pdf" if len(data) >= 8 and data[4:8] == b"ftyp": + # iOS Signal delivers voice notes as MP4-container AAC carrying an + # audio ftyp brand ("M4A ", "M4B "). Returning ".mp4" for those made + # them cache as documents and STT reject them ("Invalid file + # format") even though the bytes are valid audio. Read the brand so + # audio-branded files land as ".m4a" and route to the audio cache. + brand = data[8:12].lower() if len(data) >= 12 else b"" + if brand in (b"m4a ", b"m4b "): + return ".m4a" return ".mp4" if data[:4] == b"OggS": return ".ogg" diff --git a/tests/gateway/test_signal.py b/tests/gateway/test_signal.py index 45259d01a12e..8b85b807a813 100644 --- a/tests/gateway/test_signal.py +++ b/tests/gateway/test_signal.py @@ -201,6 +201,26 @@ class TestSignalHelpers: webp = b"RIFF\x24\x08\x00\x00WEBPVP8 " + b"\x00" * 100 assert _guess_extension(webp) == ".webp" + def test_guess_extension_m4a_audio_brand(self): + """iOS Signal voice notes are MP4-container AAC with an M4A ftyp brand. + + Classifying them as ``.mp4`` sent them to the document cache and made + STT reject the upload ("Invalid file format") even though the bytes + were valid audio. Audio brands must resolve to ``.m4a``. + """ + from gateway.platforms.signal import _guess_extension, _is_audio_ext + for brand in (b"M4A ", b"M4B ", b"m4a "): + data = b"\x00\x00\x00\x1cftyp" + brand + b"\x00" * 100 + assert _guess_extension(data) == ".m4a", brand + assert _is_audio_ext(_guess_extension(data)) is True + + def test_guess_extension_video_brands_stay_mp4(self): + """Real video ftyp brands must not be swept up by the M4A fix.""" + from gateway.platforms.signal import _guess_extension + for brand in (b"isom", b"mp42", b"avc1", b"qt "): + data = b"\x00\x00\x00\x1cftyp" + brand + b"\x00" * 100 + assert _guess_extension(data) == ".mp4", brand + def test_guess_extension_aac_adts_unprotected(self): """ADTS AAC, MPEG-4, no CRC (the canonical Android Signal voice note).