Debugging Embedded Audio Static / Streaming-PCM Noise
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.