Skip to content

Giga: render audio from the DAC interrupt so long collections don't drop sound #290

Closed

Description

@bdbarnett

On the Arduino Giga R1, audio drops out for about 50 ms whenever the garbage collector runs longer than the DAC ring holds. The ring is 93 ms. Synth Station on the Giga hits this roughly once a minute. A flash write (140-280 ms for a /flash file) does the same.

The cause is that the Giga renders the audio graph from the interpreter's own loop, since there are no threads on that port. Anything that keeps the interpreter busy longer than the ring lets the DAC run dry. A collection under Synth Station takes 100-150 ms, and it grows with the number of live objects.

What would change

Render the graph from an interrupt tied to the DAC/I2S DMA instead of from the interpreter. A collection can't delay an interrupt, so long collections stop causing dropouts. The same approach applies to any board without threads, which is every board that renders from the interpreter today, and it leaves the boards that already render on a separate thread (the ESP32 family) as they are.

Flash writes only stop causing dropouts on the Giga, and only if the render interrupt runs at a priority the QSPI flash code doesn't mask. On RP2, i.MX RT, nRF and SAMD a flash write halts the CPU or disables interrupts, so those would get the collection fix only.

Things to settle

  • Nodes that allocate, raise or read files can't run in an interrupt. They'd be refused when attached, with an error naming the node, and Python-written graph parts would feed the graph through a ring filled from the interpreter.
  • The render priority on the Giga: high enough to survive flash writes, low enough not to starve SPI and display DMA.
  • Keeping today's interpreter-driven mode as a fallback for a release.

How we'd know it works

Synth Station on the Giga for ten minutes with a forced collection every few seconds and periodic flash writes: no dropouts, where the current build fails the same run.

Activity

  1. bdbarnett commented on Nov 10, 2026

    @bdbarnett
    ContributorAuthor

    Scope, to settle before work starts: this changes the STM32H7 driver (audioif_stm32.c) only. The ESP32, unix and Windows drivers already render on a thread of their own and stay exactly as they are. Anything the engine in audiodsp needs for an interrupt render, such as refusing nodes that allocate, raise or read files, is off unless a port asks for interrupt rendering. Those three hosts must render the same output before and after, checked with their existing tests and a render comparison.

    #291 adds a reason for this: on the Giga, aioble's Python callbacks hold off the render the same way a long collection does, and the Bluetooth stack on its own doesn't.

  2. bdbarnett commented on Nov 10, 2026

    @bdbarnett
    ContributorAuthor

    Measured on a GIGA R1 with the Display Shield, with #392 and audiodsp's interrupt hooks. Synth Station's electric piano held a chord for ten minutes while the board ran gc.collect() every 3 s and wrote 32 KB to /flash every 30 s:

    Firmware Starved frames Of played Longest gc Longest write
    Interpreter render (before) 1,000,908 28,808,278 (3.5%) 111 ms 856 ms
    Interrupt render 0 28,659,242 148 ms 1,172 ms
    Interrupt build, render_mode("interpreter"), 2 min 194,806 5,768,392 (3.4%) 107 ms 860 ms

    On the three things to settle:

    • Priority. The render interrupt runs at NVIC priority 7, just below the flash code's mask (6) and above I2C, the timers and PendSV. The flash code masks one block operation at a time rather than a whole write, so the render catches up between blocks: writes of over a second each starved nothing. The longest render was 24.9 ms, and the deepest stack 4,396 of its 12,288 bytes. A full-screen LVGL refresh took a median 97 ms on the interpreter render and 99 ms on the interrupt render, with nothing starved during either.
    • Nodes that allocate. The render runs with the heap locked, so a pull that tries to allocate gets a MemoryError, which the interrupt catches and counts rather than crashing. The engine's pull doesn't allocate or raise, so nothing is refused at attach time.
    • Fallback. _audioif.render_mode("interpreter") switches back at run time, and a build against an audiodsp without the hooks stays on the interpreter.

    On unix, a render through the audiodsp branch is byte-identical to one through its main. The ESP32, unix and Windows drivers don't change.

  3. bdbarnett commented on Nov 10, 2026

    @bdbarnett
    ContributorAuthor

    Fixed by #392, with audiodsp#403 (and audiodsp#405 for the CircuitPython build). The GIGA now renders its audio from the DAC interrupt, at NVIC priority 7, just below the QSPI flash code's mask, so a collection, a flash write or Python callbacks can't hold it up. The flash code masks one block at a time, and the render catches up between blocks.

    Measured on a GIGA R1 with the Display Shield. Synth Station ran for 10 minutes with gc.collect() every 3 s and a 32 KB /flash write every 30 s. The build before starved 1,000,908 of 28.8M frames (3.5%); this one starved 0 of 28.7M, with collections up to 148 ms and writes up to 1.17 s. The longest render was 24.9 ms, and a full-screen LVGL refresh took a median 99 ms against 97 ms before.

    render_mode("interpreter") keeps the old behaviour as a fallback. The ESP32, unix and Windows drivers didn't change, and unix renders are byte-identical before and after.

    Not measured on the board: the merged build itself, which also carries audiodsp's 20 ms level ramps. The figures above come from the same driver, built against audiodsp before those landed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions