fix(signal): detect ADTS AAC voice notes and remux to MP4

Android Signal delivers voice notes as raw ADTS AAC frames, which
share the `0xFF 0xFx` sync word with MPEG-1/2 Layer 3 (MP3). The
`_guess_extension` byte-signature test in gateway/platforms/signal.py
was matching both, so ADTS AAC was being misclassified as MP3 — saved
to disk with the wrong extension and rejected by every major STT API
(Groq, OpenAI) because their server-side format sniffers inspect the
actual codec, not the file extension.

Two changes:

1. Tighten the MP3 vs ADTS disambiguator. ADTS packs `ID`,
   `layer`, and `protection_absent` into bits 3-0 of byte 1, where
   `ID=0` and `layer=00` for AAC. Real MP3 has `ID=1` and
   `layer` in {01, 10, 11}. The mask `0xF6` against target `0xF0`
   cleanly separates them.

2. Remux raw ADTS AAC to MP4 container at the cache step via
   `ffmpeg -c:a copy`. Single demux/remux, no re-encode, no quality
   loss, sub-100ms on a Pi 5. The cached file is a normal `.m4a`
   that all major STT providers accept. ffmpeg is a transitive
   dependency of many other Hermes features (TTS, video skills) so
   this isn't a new install requirement; the remux degrades
   gracefully to a no-op if ffmpeg is missing.

The new helper `_remux_aac_to_m4a` is unit-tested with a real
Android voice note from the audio cache that originally triggered
the bug, plus synthetic ADTS frames for the byte-level
disambiguator and garbage-input graceful failure.

Closes the gap that broke transcription for any Android Signal user
sending voice messages to Hermes.
This commit is contained in:
jasnoorgill 2026-06-17 15:06:24 +05:30 committed by kshitijk4poor
parent 905820b59f
commit da34fca2bb
2 changed files with 155 additions and 1 deletions

View file

@ -163,6 +163,78 @@ class TestSignalHelpers:
from gateway.platforms.signal import _guess_extension
assert _guess_extension(b"\x00\x00\x00\x18ftypisom" + b"\x00" * 100) == ".mp4"
def test_guess_extension_aac_adts_unprotected(self):
"""ADTS AAC, MPEG-4, no CRC (the canonical Android Signal voice note).
Byte 0 = 0xFF (sync high), byte 1 = 0xF1 (sync low + ID=0 + layer=00
+ protection_absent=1). Must NOT be misclassified as MP3 the old
code's ``(b[1] & 0xE0) == 0xE0`` test wrongly returned ``.mp3``.
"""
from gateway.platforms.signal import _guess_extension
assert _guess_extension(b"\xff\xf1" + b"\x00" * 200) == ".aac"
def test_guess_extension_aac_adts_protected(self):
"""ADTS AAC, MPEG-4, CRC present (protection_absent=0)."""
from gateway.platforms.signal import _guess_extension
assert _guess_extension(b"\xff\xf0" + b"\x00" * 200) == ".aac"
def test_guess_extension_mp3_mpeg1_layer3(self):
"""Real MP3 frame, MPEG-1 Layer 3: byte1 = 0xFB (ID=1, layer=01, prot=1)."""
from gateway.platforms.signal import _guess_extension
assert _guess_extension(b"\xff\xfb" + b"\x00" * 200) == ".mp3"
def test_guess_extension_mp3_mpeg2_layer3(self):
"""Real MP3 frame, MPEG-2 Layer 3: byte1 = 0xF3 (ID=1, layer=01, prot=1)."""
from gateway.platforms.signal import _guess_extension
assert _guess_extension(b"\xff\xf3" + b"\x00" * 200) == ".mp3"
def test_guess_extension_aac_routes_to_audio_cache(self):
"""ADTS-detected files must be routed to the audio cache, not document.
``_is_audio_ext(``.aac``)`` is True, so a Signal attachment that
begins with the ADTS sync word ends up in ``cache_audio_from_bytes``,
which the remux step then converts to MP4 container.
"""
from gateway.platforms.signal import _is_audio_ext, _guess_extension
ext = _guess_extension(b"\xff\xf1" + b"\x00" * 200)
assert ext == ".aac"
assert _is_audio_ext(ext) is True
def test_remux_aac_to_m4a_round_trip(self):
"""Real ADTS file from the audio cache remuxes to a valid MP4 container.
Round-trips the actual Android voice note that triggered the
bug report proves the end-to-end fix.
"""
import os
import shutil
from gateway.platforms.signal import _remux_aac_to_m4a
src = "/home/pi/.hermes/audio_cache/audio_fcfc38390b47.mp3"
if not os.path.exists(src) or not shutil.which("ffmpeg"):
import pytest
pytest.skip("ffmpeg or source file not available in this env")
with open(src, "rb") as f:
aac_data = f.read()
result = _remux_aac_to_m4a(aac_data)
assert result is not None
m4a_bytes, ext = result
assert ext == ".m4a"
# MP4 files start with a 4-byte size, then ``ftyp`` at offset 4.
assert m4a_bytes[4:8] == b"ftyp", \
f"expected MP4 ftyp box, got {m4a_bytes[:12]!r}"
# File must be at least as long as the input (MP4 has overhead).
assert len(m4a_bytes) >= len(aac_data) * 0.5
def test_remux_aac_to_m4a_handles_garbage(self):
"""Garbage input should return None, not raise."""
from gateway.platforms.signal import _remux_aac_to_m4a
result = _remux_aac_to_m4a(b"\xff\xf1garbage_no_aac_frames")
# Either returns None (ffmpeg errored) or a real M4A. If it returned
# bytes, the bytes must look like an MP4. Otherwise it returns None.
if result is not None:
m4a_bytes, ext = result
assert ext == ".m4a"
def test_guess_extension_unknown(self):
from gateway.platforms.signal import _guess_extension
assert _guess_extension(b"\x00\x01\x02\x03" * 10) == ".bin"