Static on an I2S speaker (MAX98357A, PCM510x, etc.) has several distinct causes that sound similar to the human reporting them, and the wrong ones are seductive because they're about hardware — the amp, the clock, the ground. This skill exists because in the robocar-unified voice bring-up (2026-07) two hardware theories were wrong (uninitialised DMA content; BCLK stop/start pops) and cost several reflash cycles each. The fix only came from measuring the actual samples at each stage. Measure first; theorise last.
The one law
You cannot hear the audio; the user can, and the samples can. Do not reason about the amp/clock/DMA from the symptom word "static." Get the PCM bytes and compute statistics on them — that localises the corruption to a pipeline stage in one pass, with no hardware and no guessing.
Step 1 — get a precise symptom from the user (it discriminates the cause)
"Terrible static" underdetermines everything. Ask which:
| Symptom | Most likely stage |
|---|---|
| Static before/after the line, silence-adjacent | Amp idle / clock-gating / floating DIN (analog) |
| Speech intelligible but with static bursts, or alternating clean↔noise | Sample misalignment in the transport (see Step 3) |
| Speech fully garbled, no words | Format mismatch: endianness, bit width, or sample rate |
| Faint constant hiss under clean speech | Amp noise floor / gain / power rail |
Step 2 — prove whether the source data is clean (before blaming the pipeline)
Capture the PCM the device receives (e.g. the base64 inlineData from a TTS
response, or a dump from the decode buffer) and characterise it. Clean speech
and byte-swapped/garbage have unmistakably different statistics:
python3 - <<'PY'
import json, base64, struct, math
raw = base64.b64decode(json.load(open("resp.json"))
["candidates"][0]["content"]["parts"][0]["inlineData"]["data"])
n = len(raw)//2
def stats(fmt, label):
s = struct.unpack(("<" if fmt=="LE" else ">")+"%dh"%n, raw[:n*2])
rms = math.sqrt(sum(x*x for x in s)/n)
big = sum(1 for x in s if abs(x) > 16384)/n # fraction past half-scale
zc = sum(1 for i in range(1,n) if (s[i-1]>=0)!=(s[i]>=0))/(n-1) # zero-cross rate
print(f"{label}: RMS={rms:7.0f} >half-scale={big*100:4.1f}% zero-cross={zc*100:4.1f}%")
stats("LE","little-endian"); stats("BE","big-endian")
PY
- Clean speech: low zero-cross rate (~5–15%), few samples past half-scale (~1–3%), moderate RMS. The interpretation that yields this is the correct one.
- Byte-swapped / garbage: zero-cross ~40–50% (near-random), ~40% past half-scale, high RMS.
Real finding: Gemini TTS advertises audio/L16 (nominally big-endian per
RFC) but actually returns little-endian — the LE stats were clean, BE was
noise. Verify endianness from the data before swapping bytes; a "fix" based
on the label would have broken working audio. If the source is clean, the
corruption is downstream — go to Step 3.
Step 3 — the streaming-PCM alignment trap (the robocar root cause)
16-bit samples must never be split across a transport boundary. The classic break, and the one that produced "speech + terrible static, alternating":
- A FreeRTOS
RINGBUF_TYPE_BYTEBUF(byte-granular) carries the PCM. - The producer feeds it odd-length chunks — a base64 decoder emits 3-byte groups per quartet, and 3 is odd.
- The consumer reads
gotbytes and doessamples = got / sizeof(int16_t), then returns the whole item — so an oddgotdrops the straggler byte. - That drop shifts every subsequent sample by one byte. The shift is cumulative: after an even number of drops the stream is re-aligned (clean), after an odd number it's misaligned (static) — hence the audio oscillates between clean speech and loud static, which is the tell.
Fix — keep the ring 16-bit aligned end to end. Hold back an odd trailing
byte in the writer and prepend it to the next write, so only even-length runs
enter the ring (splice the carried byte + the first new byte into one 2-byte
sample, send the even remainder, carry any new odd tail). Reset the carry at
utterance end/abort so a half-sample can't bleed into the next utterance. With
even writes into an even-sized buffer and even-sized reads, alignment holds
through wraps. See packages/robocar/unified/main/audio_player.c
(audio_player_write) for the reference implementation.
Related sizing gotcha found the same day: the speech text buffer
(SPEECH_TEXT_MAX) must fit the rendered sentence — Finnish words are long, so
a 25-word line ran ~250 chars and a 160-char buffer truncated both text and
audio mid-word. Size for the language.
Step 3b — starvation: the network can be slower than real time (2026-07)
Distinct from misalignment, and the cause of the second robocar voice bug. A streamed source can produce audio more slowly than the DAC consumes it, and the resulting underrun does not sound like a gap — it sounds like broadband noise. Two facts, both verified against ESP-IDF v5.4 source:
- Measure the real-time factor before theorising. Across seven live Gemini
TTS captures the RTF (audio-ms produced per wallclock-ms after first byte)
was
0.55 / 0.79 / 0.84 / 1.88 / 2.70 / 3.08 / 3.81— three of seven below 1.0. The slow ones were all the short lines: a fixed 0.9–2.7 s time-to-first-byte amortised over less audio. Log anrtf=figure per utterance; below 1.00 is normal and must be absorbed, not prevented. - Underrun tears, it does not merely gap.
i2s_common.ccreatesmsg_queuewith depthdesc_num - 1and, when it is full, the TX ISR drops the oldest entry (xQueueIsQueueFullFromISR→xQueueReceiveFromISRinto a dummy) before pushing the finished descriptor. After a writer stall longer thandesc_num-1descriptor periods,i2s_channel_write()is handed the oldest free descriptor — the very next one the DMA will transmit — so itsmemcpylands inside a buffer being read out.auto_clearzeroing the buffer after send does not save you; that is why the symptom is noise rather than silence.
Fix shape: gate playback on a preroll — start when N ms are banked OR
the fetch completes, whichever comes first. The OR is the load-bearing half: a
short utterance (the class that starves) then plays from a complete buffer and
cannot underrun at all. Make the gate a property of the ring, not of an
utterance, or back-to-back speech inherits an already-open gate and skips it.
Widen dma_desc_num for margin, and batch the producer's writes — a decoder
emitting 3 bytes per call forces a portYIELD_WITHIN_API() to the
higher-priority player on every ring send (~25 000/s), starving the producer
exactly when it needs to get ahead.
Step 4 — only now consider the analog side
If the source is clean AND the transport is aligned AND it still hisses/pops in the silence around speech, it's the amp: the MAX98357A has no shutdown GPIO on this board (budget full), so it amplifies continuously; gating BCLK / letting DIN float between utterances pops. Options, in order: drive continuous zeros (keep the channel enabled, feed silence when idle — trades pop for a possible faint hiss), or gate the amp's SD pin via a freed/expander GPIO. Judge by ear — this is the one part only the user can settle.
Rationale
Every wrong turn in this bug was a hardware theory reasoned from the word
"static"; every correct step was a measurement of the bytes. The statistics
test in Step 2 costs one python3 run and tells you which stage is lying. This
is diagnose-at-the-failure-point for audio: the samples at each stage are the
authoritative source, above any theory about the amp.