Skip to content

Play sound through the DVI cable on RP2350 boards - #11510

Open
mikeysklar wants to merge 11 commits into
adafruit:mainfrom
mikeysklar:picodvi-audioout
Open

mikeysklar wants to merge 11 commits into
adafruit:mainfrom
mikeysklar:picodvi-audioout

Conversation

@mikeysklar

@mikeysklar mikeysklar commented Oct 5, 2026 •

Copy link
Copy Markdown
Collaborator

What

New picodvi.AudioOut plays sound through the DVI cable.

Why

Fruit Jam games can make sound on a TV with no extra parts.

import audiocore
import board
import displayio
import picodvi

displayio.release_displays()
fb = picodvi.Framebuffer(320, 240, clk_dp=board.CKP, clk_dn=board.CKN,
                         red_dp=board.D0P, red_dn=board.D0N,
                         green_dp=board.D1P, green_dn=board.D1N,
                         blue_dp=board.D2P, blue_dn=board.D2N, color_depth=8)
audio = picodvi.AudioOut(fb)
audio.play(audiocore.WaveFile("song.wav"), loop=True)  # 48 kHz WAV

Works like audiobusio.I2SOut. Nothing changes on the cable until an AudioOut exists.

Initial work by Phillip Torrone. Packet encoder from pico_hdmi (Unlicense).

Hardware tested

Fruit Jam, 11.0.0-alpha.1-33-gf03d8b6641, Ubuntu 26.04 and a monitor with speakers. Not tested: Metro RP2350, Feather RP2350.

How I tested it

Test Result
tests/circuitpython-manual/picodvi/audioout_formats.py All 8 formats, Mixer, synthio, pause; tones heard
audioout_bad_values.py Each bad value raises an existing error
audioout_release.py 25 rounds, RAM returned each time
Cold boots, USB power cut 20/20
Packet bytes vs pico_hdmi v0.0.19 Identical
CPU while playing (10 trials) Python runs about 19% slower; no cost when idle

Scope

48 kHz only. Fits 320x240, or 640x480 up to 4-bit color. CIRCUITPY_PICODVI_AUDIOOUT = 0 turns it off.

AI assistance

Claude Code helped write and test this. I listened to the tones and read the code.

🤖 Generated with Claude Code

picodvi.AudioOut(framebuffer) plays any 48 kHz audiocore sample
(WaveFile, RawSample, Mixer, synthio, MP3) through the DVI cable, for
displays with speakers. It is shaped like audiobusio.I2SOut.

Audio goes out as 48 kHz stereo data islands in the horizontal sync of
the 640-wide output, built from two pre-encoded frame banks that the
existing frame interrupt swaps and a background callback refills.
Nothing changes on the cable until an AudioOut is created, and deinit
gives the memory back. The packet encoder comes from pico_hdmi.

CIRCUITPY_PICODVI_AUDIOOUT turns it off for boards without a DVI
connector.

Initial work by Phillip Torrone.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@mikeysklar

Copy link
Copy Markdown
Collaborator Author

@ladyada @tannewt DVI audio for RP2350 is ready for review.

@ladyada
ladyada requested a balanced review from Copilot October 6, 2026 02:11

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@ladyada-eagleclaw ladyada-eagleclaw left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI-assisted review of 7445a68ab77b3fefda35c6af6c0888574c2472e1, using source inspection and focused host-side tests. I recommend addressing these four issues before merging.

  1. [P1] Prevent recursive audio refills. AudioOut.c:159-160, also resume() at lines 181-183.

    These call audioout_refill() directly, outside the background callback runner's reentry protection. Reading a WAV from an SD card runs background tasks inside the RP2350 SPI transfer, so a queued frame callback can enter the same refill while the outer read still holds the card's SPI lock. The nested read can time out and set source_done = true, stopping playback even if the outer read succeeds. Please guard both synchronous refill paths against reentry. A host test simulating the recursive read/error reproduced source_done=1 with zero samples queued; this was not a hardware reproduction.

  2. [P2] Handle empty looping samples. AudioOut.c:64-76.

    audio.play(audiocore.RawSample(bytearray(), sample_rate=48000), loop=True) never makes progress: each fetch returns zero frames and GET_BUFFER_DONE, then the loop resets the sample and retries indefinitely. There is no pending-interrupt check in this loop. Please reject or terminate playback when an entire loop produces no frames. The unchanged staging code with the RawSample buffer implementation timed out in the host test.

  3. [P2] Validate the framebuffer before running a queued callback. dvi_audio.c.inc:252-256.

    Framebuffers occupy the reusable static display_buses[] union. Deinitialization clears the audio pointer but leaves an already queued refill callback. If the slot is reused before that callback runs, such as releasing DVI and constructing a FourWire bus, the callback still casts the slot to a framebuffer. FourWire's SPI pointer occupies the same position as dvi_audio, so this can dereference unrelated memory and crash. This ordering is possible when calling an imported release_displays function, whose VM CALL_FUNCTION path does not drain background callbacks after the call. Please validate that the callback still refers to the active framebuffer before accessing its audio state, or invalidate the queued callback during teardown. This finding is from source inspection.

  4. [P2] Correct the audio clock-regeneration packet cadence. dvi_audio.c.inc:237-239.

    ACR packets are emitted on lines 0, 4, ..., 132: 34 packets per video frame, followed by a 493-line interval until the next ACR packet. For N=6144 and 48 kHz, HDMI requires an average CTS transmission rate of 128 * 48000 / 6144 = 1000 per second, near the nominal transmission times. Please schedule ACR packets across the frame at that cadence. This is a protocol-compliance issue; receiver-specific audible failures have not been measured. Reference: HDMI 1.3 section 7.8.2, printed page 111.

Validation: inspected the complete PR diff, sample providers, SPI/background callback paths, display teardown/reuse, and packet encoding; git diff --check passed. The host tests used unchanged staging/refill functions with stubbed surrounding interfaces. No hardware tests were performed. The merge from main leaves the reviewed audio code unchanged.

mikeysklar and others added 4 commits October 6, 2026 08:29
play() and resume() refill directly. Background tasks that run during an
SD card read could start the frame refill inside that one and stop
playback early.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
play(RawSample(bytearray()), loop=True) looped forever without making
progress. Stop when a whole pass gives no frames.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The framebuffer can be deinitialized and its storage reused by another
display before a queued refill runs. Only run it for the active
framebuffer.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
ACR packets went on every 4th blanking line: 34 per frame, then nothing
for the rest of the frame. Send them every 37.5 lines instead, about
1000 per second as N = 6144 at 48 kHz expects, including on active lines.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

@tannewt tannewt left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some questions about the object lifetime. I think the AudioOut API looks good. Want to have it on by default on Fruit Jam? Maybe play a start up sound? It should be on board then too.

Comment on lines +21 to +22
#if CIRCUITPY_PICODVI_AUDIOOUT
{ MP_ROM_QSTR(MP_QSTR_AudioOut), MP_ROM_PTR(&picodvi_audioout_type) },

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't conditionalize the name. instead, have the make_new raise NotImplementedError(). That is a bit clearer than an unknown name.

self->sample = MP_OBJ_NULL;
// Keep this object alive while it is attached, as the frame interrupt
// schedules refills for it.
MP_STATE_PORT(picodvi_audioout) = MP_OBJ_FROM_PTR(self);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Want to tie it's lifetime to the Framebuffer instead? That way it could live outside the VM.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why .inc? I think this could either be in the file itself or a separate source file that is linked with the other. Including a c source is weird.

} picodvi_framebuffer_obj_t;

#if CIRCUITPY_PICODVI_AUDIOOUT
// Audio over DVI, used by picodvi.AudioOut.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why can't these go in AudioOut.h?

@mikeysklar

Copy link
Copy Markdown
Collaborator Author

@ladyada-eagleclaw thanks! Fixed all four:

  • Refills can't start inside another refill.
  • Empty looping samples stop instead of hanging.
  • Queued refills skip a framebuffer that's gone.
  • Audio clock packets spread across the whole frame.
  • All tested on Fruit Jam, all passed.

mikeysklar and others added 3 commits October 6, 2026 10:26
AudioOut was missing from picodvi where it is not supported, such as on
RP2040. Keep the name and have the constructor raise NotImplementedError
instead, as pulseio.PulseOut does.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
No more .c.inc files. The framebuffer audio code moves into AudioOut.c,
its only user, and the frame interrupt hooks are declared in AudioOut.h.
The pico_hdmi packet encoder is now dvi_audio_packet.c with a header. The
DVI timing defines both files use move to Framebuffer_RP2350.h.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The framebuffer now holds its AudioOut instead of a global root pointer,
and runs its refills directly. Framebuffer deinit and VM reset release
it through the framebuffer, and displayio marks it for the GC when the
framebuffer lives in a display slot.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@mikeysklar

Copy link
Copy Markdown
Collaborator Author

@tannewt thanks! Changes:

  • AudioOut always exists, raises NotImplementedError where unsupported.
  • Framebuffer now owns its AudioOut.
  • No more .c.inc; encoder is its own file.
  • Audio prototypes moved into AudioOut.h.
  • All tested on Fruit Jam, all passed.

Follow-up PR:

  • On by default for Fruit Jam, on board.
  • Startup sound, once audio is on by default.
  • Needs RAM: 640x480 8-bit boot display leaves too little.

@ladyada-eagleclaw

Copy link
Copy Markdown

Thanks for the fixes. The empty-sample and recursive-refill host checks now pass on 7746f82, and the framebuffer identity guard addresses the queued-callback finding. I also checked that those guards remain in the latest f2f5dc3 refactor. These were AI-assisted source/host checks, not independent hardware tests.

One part of finding 4 remains: the average ACR rate. The packets are now spread across the frame, but AudioOut.c:137-146 builds one fixed line bitmap and replays it every frame. The accumulator's fractional remainder is not carried between video frames.

At the default RP2350 clock, the calculation is:

  • HSTX clock: 150 MHz; pixel clock: 30 MHz.
  • Video frame: 800 pixels x 625 lines, giving 60 frames/sec.
  • The current loop schedules 17 ACR packets per frame: 1,020 packets/sec, rather than the required 128 * 48000 / 6144 = 1,000.

A host calculation of this loop confirms the 17-packet count. It happens to give exactly 1,000/sec with a 25 MHz pixel clock / 50 Hz frame rate, so that case would miss this issue.

Please preserve the ACR timing phase across frame boundaries, including the idle/silence output. At 60 Hz, three frames should carry 50 ACR packets in total, rather than the current 51. The packet distribution fix is an improvement, but I would keep this part of the original finding open. Reference: HDMI section 7.8.2, printed page 111. No receiver-specific audible failure has been measured.

The ACR schedule restarted every frame, giving 17 per frame, 1020 per
second at 60 Hz. Carry the timing from frame to frame instead: 17, 17,
16, so 1000 per second. When nothing is playing, the refill now queues
silence frames so the timing carries on; the fixed base list is only
used if no frame is ready.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@mikeysklar

Copy link
Copy Markdown
Collaborator Author

@ladyada-eagleclaw thanks, good catch on the rate:

  • ACR timing now carries across frames.
  • 17, 17, 16 per frame: 1,000 per second.
  • Idle output keeps the timing with queued silence frames.
  • Tested on Fruit Jam: tones clean, all tests passed.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants