Smooth FLAC: buses, buffers and an audio interrupt
FLAC playback had become the touchy part of the project: every change near it broke something. CD-quality files starved the VS1053, and the UI froze or stuttered whenever the player was buffering. This post is the write-up of how we untangled it: what was actually slow, what we measured, what we changed, and the rules we now follow to keep audio and UI smooth together. It replaces the older memory-improvements.md notes, and is meant as the reference for transfer speeds and buffer sizes in the project.
The short version:
- The buses were far slower than everyone assumed. The VS1053 data clock ran at 1.41 MHz, the PSRAM at 5.6 MHz, both one byte per call.
- Some FLAC files are simply too heavy for the VS1053 to decode, whatever the buffering: they need to be re-encoded.
- Feeding the VS1053 from the main loop can't work with a UI that blocks for 100+ ms. We moved it to a timer interrupt reading from a RAM buffer.
- On the breadboard, that interrupt corrupted the stream whenever it overlapped the display or the wheel's I2C: the fix is a shared bus lock and a lower SPI clock, both of which go away once the VS1053 gets its own SPI bus on the PCB.
Current status
The hardware
| Part | Bus | Notes |
|---|---|---|
| STM32F446 | 180 MHz core | APB2 = 90 MHz (SPI1), APB1 = 45 MHz (SPI2, SPI3) |
| VS1053 (Adafruit breakout) | SPI1 | Shares SCK/MOSI/MISO with the SD card on the breakout |
| SD card (on the breakout) | SPI1 | SdFat, 22.5 MHz |
| APS6404L PSRAM, 8 MB | SPI2 | Audio ring buffer, cover cache, next-track prefetch, music index |
| ILI9341 display | 16-bit parallel (GPIOC) | Bit-banged, WR on PB9 |
| MPR121 wheel + DRV2605L haptics | I2C3 | SCL on PA8, SDA on PB4 |
Every audio byte travels SD → MCU → PSRAM → MCU → VS1053: two crossings of SPI1 and two of SPI2.
An STM32 SPI clock is always the bus clock divided by a power of two, and the Arduino core rounds a requested frequency down to the nearest one:
| Bus | Possible clocks |
|---|---|
| SPI1 (APB2 90 MHz) | 45, 22.5, 11.25, 5.625, 2.81, 1.41, 0.70 MHz… |
| SPI2 / SPI3 (APB1 45 MHz) | 22.5, 11.25, 5.625, 2.81 MHz… |
Asking for 8 MHz gives 5.6 MHz; asking for 2 MHz gives 1.41 MHz.
What was actually wrong
Most of the comments in the code were describing problems that weren't the real ones. In the order we found them:
- The VS1053 data clock was 0.70 MHz, or 1.41 MHz for FLAC. The driver started at 1 MHz (→ 0.70 MHz) and "doubled" it for FLAC (2 MHz → 1.41 MHz). The datasheet allows SDI writes up to CLKI/4: 9 MHz with the default clock, 13.8 MHz with the FLAC clock.
- The FLAC clock wasn't what the comment said.
CLOCKF = 0x8800is SC_MULT 3.5× plus SC_ADD +1.0×, but SC_ADD only applies to WMA and AAC. FLAC was decoding at 43 MHz, not 55. It's now0xC000(4.5×, 55.3 MHz, the chip's maximum). - Every transfer went one byte per call, with a full wait for each byte to come back before sending the next one.
- The PSRAM ran at 5.6 MHz and held chip select low for whole 4 KB transfers. The APS6404L datasheet limits CE# low to 8 µs (tCEM) so the chip can refresh itself. 4 KB at 5.6 MHz is about 9 ms, 1000× too long. It survived at room temperature, but it's out of spec.
- Interrupts were disabled around every PSRAM write (~9 ms each), to protect against an interrupt handler that didn't exist.
- Feeding blocked on DREQ. The feed sent 512 bytes at a time, but DREQ only promises room for 32: each feed then busy-waited for the decoder to drain the rest, up to ~30 ms per call on MP3.
- The refill hysteresis didn't work. A refill started below the low watermark but stopped as soon as one pass lifted the buffer above it, so the 3.5 MB buffer hovered at ~450 KB and refilled 5 times a second, each time blocking for 40 ms.
- The prime at track start was cover art. FLAC files carry their embedded cover in the header (100–540 KB in our library). The 64 KB "prime" was all picture data, which the VS1053 skips at SPI speed, emptying the buffer right at the start of every track.
- Some files were undecodable in real time (see below), and no amount of buffering could fix that.
- Adopting the prefetched next track copied 1 MB PSRAM → PSRAM synchronously: a 1.6 s freeze on every auto-advance.
Measured transfer speeds
All numbers from the boot-time link bench (enableLinkBench in main.cpp).
SD card → MCU (SPI1 at 22.5 MHz, SdFat, a real FLAC file):
| Read size | Throughput | Worst single read |
|---|---|---|
| 512 B | 440 KB/s | 2.4 ms |
| 4 KB | 1,114 KB/s | 4.7 ms |
| 16 KB | 1,330 KB/s | 12.7 ms |
MCU ↔ PSRAM (SPI2, 64 KB write then read back):
| Mode | Write | Read |
|---|---|---|
| Old: 5.6 MHz, one byte per call, long bursts | 402 KB/s | 402 KB/s |
| 5.6 MHz bulk, long bursts | 666 KB/s | 666 KB/s |
| 11.25 MHz bulk, long bursts | 1,285 KB/s | 1,289 KB/s |
| 22.5 MHz bulk, long bursts (out of spec) | 1,982 KB/s | 1,992 KB/s |
| 22.5 MHz bulk, tCEM bursts (15 B) — current | 1,322 KB/s | 1,275 KB/s |
| 11.25 MHz, tCEM bursts (4 B) | 476 KB/s | 437 KB/s |
| 5.6 MHz, tCEM bursts (1 B) | 101 KB/s | 88 KB/s |
Respecting tCEM costs about a third of the throughput at 22.5 MHz, and below 22.5 MHz the bursts get so short it isn't worth it (the driver falls back to long bursts and warns). A 1 MB write / 1 s wait / read back test showed zero bad bytes in every mode at room temperature.
MCU → VS1053 SDI (SPI1, clocking only, no chip selected):
| Clock | One byte per call | 32-byte bulk |
|---|---|---|
| 0.70 MHz | 85 KB/s | 85 KB/s |
| 1.41 MHz | 159 KB/s | 171 KB/s |
| 2.81 MHz | 281 KB/s | 324 KB/s |
| 5.63 MHz | 456 KB/s | 580 KB/s |
| 11.25 MHz | 664 KB/s | 959 KB/s |
In playback, one 32-byte burst takes 55 µs at 5.6 MHz and 33 µs at 11.25 MHz.
What it costs per second of CD-quality FLAC (~90 KB/s):
| Before | After Phase 1 (measured) | |
|---|---|---|
| VS1053 clocking | ~775 ms (estimated) | ~180 ms |
| PSRAM write + read | ~575 ms (estimated) | ~150 ms |
| SD reads | ~75 ms | ~75 ms |
| Total | > 1 s: guaranteed starvation | ~400 ms |
With the feeder interrupt at 11.25 MHz, the interrupt itself costs about 0.75 µs per byte: ~70 ms per second of CD-quality FLAC, ~145 ms at 196 KB/s (24-bit FLAC), where the whole path (SD, PSRAM both ways, interrupt) totals ~740 ms of CPU per second. That's the practical ceiling on the breadboard: it plays cleanly, but the UI gets sluggish while a refill runs. Converting 24-bit files to 16-bit in the sync pipeline would bring them back to ~120–130 KB/s.
The FLAC files themselves
LPC order. FLAC's "streamable subset" limits the predictor order to 12 for audio up to 48 kHz. Encoders can go further: ffmpeg's -compression_level 9 to 12, or flac --lax, use orders up to 32, which is roughly 2.5× the decoding work per sample. Two files in our library looked identical (16-bit/44.1 kHz, ~738 kbps, same block size), but one used orders 23–32: the data reached the chip on time, the buffer was full, and the audio still stuttered constantly, because the VS1053 couldn't decode it fast enough.
The sync pipeline now handles it:
sync/flac-max-lpc-order.pyreads the frame headers (no decoding) and prints the highest predictor order. A frame only counts if its CRC-8 matches and it carries the next frame number in sequence: in ~23 MB of compressed audio, a fake sync + CRC match shows up about once per file.sync/convert-flac.shre-encodes files above order 12 losslessly (-compression_level 8, same rate, depth and channels; the decoded PCM is bit-identical) and keeps tags and cover art.sync/sync.shre-checks already staged files once whenever the conversion rules change (policy stamp insd-card/opentone/.convert-policy).
Block size (4096 vs 4608) and coefficient precision (12 vs 15 bits) turned out not to matter: ffmpeg's level 8 output plays fine.
Metadata. The decoder only needs the fLaC marker and STREAMINFO. Audio_buffer::skipFLACMetadata() sends exactly that (a 42-byte synthetic header) and starts streaming at the first audio frame, skipping cover art, tags, seek table and padding (100–540 KB per file).
End fill bytes. The FLAC plugin has a 12 KiB stream buffer and needs 12,288 end fill bytes when stopping or jumping, where the built-in decoders need 2,052 (VLSI's vs1053b-patches.pdf, "FLAC Decoder").
The plugin is lost on any reset. A soft reset wipes the plugin from the chip's RAM, but CLOCKF survives it, so "is the plugin loaded" can't be inferred from the clock register. The driver tracks it in a flag.
Stopping the decoder
The datasheet describes two procedures, and they're not interchangeable:
- End of stream (10.5.1): send the end fill bytes first, so the decoder plays out what it holds, then set SM_CANCEL and keep sending end fill bytes until it clears.
- Stopping mid-track (10.5.2), for skips and track changes: set SM_CANCEL first, so the decoder drops what it holds right away, then send the end fill bytes.
Both get one deadline of one second, the datasheet's own limit before falling back to a software reset. A 50 ms timeout per 32-byte block was too short for FLAC: DREQ can stay low for a whole frame (~100 ms) while the decoder catches up, which triggered a soft reset (and lost the plugin) on every stop.
A stop takes 50–120 ms, and it used to block the main loop for all of it on every track change. It's now asynchronous: the end fill bytes go through the fifo like audio, the feeder sends them, and loop() advances a small state machine (AudioPlayer::serviceStop(): drain → cancel → flush), polling SM_CANCEL once a millisecond when DREQ is high. Meanwhile the next track primes into PSRAM, and only reaches the fifo once the decoder is clear. The only blocking cases left are loading the FLAC plugin (which needs a stopped decoder), and the soft-reset fallback.
Buffers
From the decoder backwards:
| Buffer | Size | Holds (CD-quality FLAC) | Notes |
|---|---|---|---|
| VS1053 stream buffer | 2 KB (12 KiB with the FLAC plugin) | ~130 ms | DREQ high = room for ≥ 32 bytes |
| L1 fifo, MCU RAM | 32 KB | ~350 ms | AudioFifo, fed by the main loop, drained by the feeder interrupt |
| PSRAM ring | 4 MB (3.5 MB target) | ~38 s | Refilled in bursts from SD |
| PSRAM next-track prefetch | 1 MB | ~11 s | Only the first 32 KB is adopted at track change |
| PSRAM cover cache | 1 MB | Album art | |
| PSRAM music index | 2 MB | Size on a large library still to be audited |
Thresholds and sizes in the code:
| Constant | Value | What it does |
|---|---|---|
AUDIO_PRELOAD_CHUNK_SIZE | 4 KB | One SD read |
AUDIO_PRIME_BYTES | 24 KB | Read before a track starts (straight into the fifo, ~25 ms of SD) |
AUDIO_SEEK_PRIME_BYTES | 32 KB | Read right after a seek |
NEXT_TRACK_ADOPT_MAX_BYTES | 32 KB | Taken from the prefetch at track change |
FEEDER_MAX_BYTES_PER_TICK | 1 KB (512 B with the 5.6 MHz cap) | ~0.75 ms of SPI, below the 1 ms tick |
FIFO_TOPUP_MAX_BYTES | 16 KB | PSRAM → fifo per main-loop pass |
The refill decisions (src/sound/RefillPolicy.h) work in runway: how long the buffered audio lasts at the stream's rate. The rate is the higher of the file's average (size ÷ indexed duration) and what the feeder actually sends; until either is known, CD PCM (176 KB/s) is assumed, which underestimates the runway (refill early, never late).
| Constant | Value | What it does |
|---|---|---|
LOW_RUNWAY_MS | 5 s | A refill starts below this and runs up to the PSRAM target |
URGENT_RUNWAY_MS | 2.5 s | Below it, refill passes get the full 40 ms budget instead of 8 ms |
CRITICAL_RUNWAY_MS | 0.7 s | Below it, the refill ignores user activity |
BUSY_CHUNK_INTERVAL_MS | 20 ms | While the user interacts: at most one trickle pass per interval |
BUSY_BOOST_RUNWAY_MS | 1.5 s | Below it, a trickle pass reads two chunks instead of one (a 138 KB/s FLAC only just held its ground with one) |
load() takes a budget in microseconds: it always reads one chunk, then more only while elapsed + estimated chunk cost fits. The chunk cost is a running average of the measured SD read + PSRAM write (~9–15 ms on the breadboard, shown as chunk= in the monitor).
For CD-quality FLAC a full refill (~450 KB → 3.5 MB) takes about 8 s at the measured ~450 KB/s net, after which the SD card stays completely idle for ~35 s of FLAC (minutes of MP3). That idle window is what a future SD power switch would use (a MOSFET on the card's 3.3 V rail, re-begin() before the next refill).
The audio path now
SD ──(SPI1, main loop)──┬──► PSRAM ring 4 MB ──(SPI2, main loop)──┐
│ (refill bursts) │
└──── bypass while PSRAM is empty ────────┤
▼
L1 fifo 32 KB (MCU RAM)
│
TIM7 1 kHz interrupt, ≤ 512 B/tick,
only while DREQ is high and
the SPI1 lock is free
▼
VS1053 (SPI1 SDI)- The feeder interrupt (
AudioPlayer::feederIsr) runs at the lowest priority, so UART, I2C and SysTick preempt it. It programs SPI1 directly (switches the clock divider, sends, restores it), and never waits: when DREQ is low or the bus is busy it just returns. - The bus lock (
src/sound/Spi1Bus.h) is held by every main-loop user of SPI1: SdFat throughSharedSpiDriver(activate/deactivate bracket each card exchange), the VS1053 register and data functions, the link bench. The feeder skips its tick while it's held, and if it wanted data, the next release fires it immediately (a software update event on TIM7) instead of waiting up to 1 ms. - Seeking queues the end fill bytes in the fifo ahead of the new position's data, so it doesn't block while the chip drains.
- Track start loads 24 KB straight into the fifo and starts; the main loop refills PSRAM from there.
The breadboard crosstalk saga
With the interrupt in place, everything measured perfectly (no underruns, regular 1 ms ticks, the chip never waiting more than a few ms for data), yet the audio glitched while scrolling and switching screens. The previous version, which fed from the main loop, didn't.
The difference: from the main loop, feeding and everything else took turns. From an interrupt, the 11.25 MHz SPI traffic to the VS1053 could happen during other bus activity. On breadboard jumpers, that activity coupled into the SPI lines and corrupted bytes on their way to the chip; the FLAC plugin checks each frame's CRC and drops bad ones, which is an audible glitch. We ruled things out one at a time:
| Test | Result |
|---|---|
| Haptics disabled | No change |
| Display transfers hold the bus lock | Scrolling better |
| All display writes locked, in 1024-pixel slices | Better, screen changes still glitch |
| Feeder SDI clock capped at 5.6 MHz | Scrolling clean |
Feed from the main loop (OPENTONE_FEED_MAIN_LOOP) | Everything clean |
| Interrupt feeder + 5.6 MHz cap + wheel/haptics I2C hold the lock | Everything clean |
| Same, cap removed (back to 11.25 MHz) | Still clean, and half the interrupt cost |
The worst offender was the wheel's I2C: SCL is on PA8, right next to the VS1053's XDCS on PA9. A stray edge on XDCS mid-transfer knocks the chip's byte alignment out. The touch sensor is read continuously while a finger is on the wheel, which is exactly when the glitches happened.
What's in place now:
- Display:
fillRect,fillScreenandpushPixelshold the lock in 1024-pixel slices (~0.15 ms each);writeCommand,writeDataandwriteData16hold it for each write. wheel.update(), wheel calibration andHaptics::play()hold the lock.OPENTONE_FEEDER_SDI_MAX_HZ=5625000inplatformio.inican cap the feeder's SPI clock. It's commented out: with the locks above, 11.25 MHz turned out clean, and at 5.6 MHz the interrupt alone cost ~300 ms of CPU per second of 24-bit FLAC, enough to underrun while browsing. Turn it back on if glitches return on different wiring.
Rules for a smooth UI with audio
- Never feed the decoder from the main loop. The chip's 2 KB buffer covers ~50 ms of 320 kbps MP3, and the FLAC plugin's 12 KiB ~130 ms of FLAC; a UI frame or an album-art blit can take longer than that. Feed from an interrupt, out of a RAM buffer deep enough to cover the worst main-loop stall (32 KB ≈ 350 ms of FLAC).
- Never busy-wait on DREQ. Send while it's high, then return.
- Yield inside long main-loop work. Only the main loop refills the RAM buffer from PSRAM, so a 240 ms list load drained it to 0 with a 113 KB/s FLAC (the chip's own buffer saved it). Long loops call
audioYield()(src/sound/AudioYield.h) every few iterations: list loading every 16 items, album-art reads every 8 rows. It returns at once when the fifo is full enough. - Keep every exclusive section short. Locks and interrupt-off windows in microseconds, not milliseconds: bulk transfers in slices, PSRAM bursts under 8 µs, no
noInterrupts()around anything slow. - Let background I/O yield to the user. While the wheel is touched or a screen transition runs, the SD refill pauses (more than 2.5 s of runway) or trickles one chunk per 20 ms; it only runs at full budget below 0.7 s. The next-track prefetch waits.
- Think in time, not bytes. The same 256 KB is 16 s of 128 kbps MP3 and 1.7 s of 24-bit FLAC: thresholds are runway in milliseconds.
- Refill in bursts, not continuously. Low watermark → target, then leave the SD card alone.
- Do the expensive work offline. Re-encode heavy FLACs in the sync pipeline, skip metadata the decoder doesn't need.
- Measure before believing a comment. The profiler and the link bench below found every one of the problems above; most of the comments we started from were wrong.
Reading the diagnostics
With enableAudioMonitor = true in main.cpp, two lines print every second (UI and input chatter stays off unless you build with -DOPENTONE_UI_VERBOSE=1):
[audio] runway=15.8s min=13.7s rate=94KB/s buf=1466KB fifoMin=21KB
underrun=0 stall=3ms tick=1011us load=546ms chunk=8643us refilling
[prof 1001ms] loops=142 maxLoop=51ms | sd 61ms … | psW 49ms … | psR 83ms …
| audio 196ms … | ui 0ms … | isr 70ms max744us 94KB| Field | Healthy | Meaning |
|---|---|---|
runway, min | > 0.7 s | Buffered audio in time (now / lowest in the last second) |
rate | Stream rate used for the runway | |
buf, fifoMin | fifoMin well above 0 | PSRAM + fifo now; lowest fifo level in the last second |
underrun | 0 | Ticks where the chip wanted data and the fifo was empty |
stall | a few ms | Longest time the chip asked for data and got none |
tick | ~1000 µs | Longest time between two feeder ticks (larger: interrupts were held off) |
load, chunk | Time spent refilling from SD in the last second; estimated cost of one chunk | |
refilling | Shown while a refill burst is in progress | |
maxLoop | < ~50 ms | Worst main-loop pass: UI smoothness |
isr | 60–150 ms | CPU time in the feeder interrupt per second (~0.75 µs per byte at 11.25 MHz) |
printResetCause() also prints why the MCU last restarted. On STM32 the "reset-pin" flag is set on every reset, so only the other flags tell you anything (a "power-on brown-out" turned out to be a shorted breakout, not firmware).
If the VS1053 gets its own SPI bus
The PCB can give the VS1053 its own SPI (SPI3 on PC10/11/12, on APB1, so 11.25 MHz max for SDI), with short traces, the I2C lines routed away from XDCS and a firm XDCS pull-up. Then most of the breadboard workarounds can go.
Revert:
- Display locks in
ILI9341_GFX.h(PIXEL_SLICEslicing infillPixels/pushPixels) andILI9341_driver.h(writeCommand,writeData,writeData16). - Wheel and haptics locks in
main.cpp(wheel.update(),wheel.updateCalibration()) andHaptics::play(). - The SDI clock cap option (
OPENTONE_FEEDER_SDI_MAX_HZ, already off by default). - SdFat's custom driver:
SharedSpiDriverand-DSPI_DRIVER_SELECT=3only exist to lock SPI1 against the feeder. With the SD alone on SPI1, go back to SdFat's built-in driver, and considerDEDICATED_SPIfor faster multi-block reads. - The SPI1 clock-divider switching in
VS1053_driver::isrFeed(): on a dedicated bus the SDI clock can stay set.
Keep:
- The fifo, the feeder interrupt and the never-wait-on-DREQ rule.
- A lock between the feeder and the main-loop VS1053 register accesses (they still share the VS1053's own bus): only
VS1053_driverneeds to hold it. - The UI-aware refill, tCEM-compliant PSRAM bursts, the FLAC metadata skip, the stop procedures and the plugin tracking.
Then re-run the link bench and the monitor, and check underrun, maxStall and tickGap while scrolling and switching screens. A dedicated bus is also the right moment to move the feeder to DMA.
Still open
- Seek transition: the end fill + jump sounds rough; to be looked at.
- Main-loop stalls while scrolling: list page loads still cost 60–100 ms per pass; that's UI work, not audio.
- Music index size: the 2 MB PSRAM budget for the music index isn't enforced; check it against a full library.
- SD power switch: a hardware change for the next board revision, to cut the card's power during the idle windows.
- Hi-res FLAC (24/96) would need more decoding power and bandwidth: the STM32U5A5's OCTOSPI (with chip-select boundary and refresh support built for this kind of PSRAM) is the way there.