fix(tts): strip Apple FLLR chunk -> canonical WAV
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user