fix(tts): strip Apple FLLR chunk -> canonical WAV
Repo State / repo-state (pull_request) Failing after 41s
Repo State / repo-state (push) Failing after 41s
Validate Zacus refactor / validate (pull_request) Failing after 5m52s
Validate Zacus refactor / validate (push) Failing after 6m6s

macOS say inserts a ~4 KB FLLR filler chunk between fmt and data. The
PLIP firmware parses only a small header buffer and never reaches a
data chunk pushed past it (WAV parse failed -> silent). Rebuild a
canonical 44-byte-header RIFF/fmt/data WAV before staging. Verified:
Zacus greeting plays 5.05s from /sdcard on pickup, no gateway/model.
This commit was merged in pull request #177.
This commit is contained in:
clement
2026-06-18 18:16:27 +02:00
parent fbe9c7dfb7
commit f55da57b2c
+34 -1
View File
@@ -28,6 +28,7 @@ from __future__ import annotations
import argparse
import json
import re
import struct
import subprocess
import sys
import unicodedata
@@ -57,6 +58,34 @@ def slug(text: str) -> str:
return t or "x"
def clean_wav(raw: bytes) -> bytes:
"""Rebuild a canonical 44-byte-header PCM WAV (RIFF/fmt/data only).
macOS `say` inserts an Apple `FLLR` filler chunk (~4 KB) between `fmt ` and
`data`. Standard players skip it, but the PLIP firmware parses only a small
header buffer (audio.c::parse_wav_header) and never reaches a `data` chunk
pushed past it. Stripping every non-essential chunk yields a WAV the
firmware decodes (and drops the dead 4 KB of padding)."""
if raw[:4] != b"RIFF" or raw[8:12] != b"WAVE":
return raw # not a WAV we recognise — leave it
i, fmt, pcm = 12, None, None
while i + 8 <= len(raw):
cid = raw[i:i + 4]
sz = int.from_bytes(raw[i + 4:i + 8], "little")
body = raw[i + 8:i + 8 + sz]
if cid == b"fmt ":
fmt = body[:16]
elif cid == b"data":
pcm = body
break
i += 8 + sz + (sz & 1)
if fmt is None or pcm is None:
return raw
return (b"RIFF" + struct.pack("<I", 4 + 8 + len(fmt) + 8 + len(pcm)) + b"WAVE"
+ b"fmt " + struct.pack("<I", len(fmt)) + fmt
+ b"data" + struct.pack("<I", len(pcm)) + pcm)
def say_wav(text: str, voice: str, out: Path, force: bool) -> bool:
"""Synthesise `text` to a 16 kHz mono 16-bit WAV at `out`. Returns True on
success (or cached). Falls back to DEFAULT_VOICE if `voice` is unavailable."""
@@ -70,7 +99,11 @@ def say_wav(text: str, voice: str, out: Path, force: bool) -> bool:
"--data-format=LEI16@16000", "-o", str(out), text],
check=True, capture_output=True, timeout=60,
)
return out.exists() and out.stat().st_size > 44
if not (out.exists() and out.stat().st_size > 44):
return False
# Strip Apple's FLLR filler → canonical WAV the PLIP firmware decodes.
out.write_bytes(clean_wav(out.read_bytes()))
return out.stat().st_size > 44
except subprocess.CalledProcessError:
continue # voice missing → try fallback
except Exception as exc: # noqa: BLE001