Skip to content

Audio & music

Two ways to make sound on the PicoPad, both over its PWM audio pin. picogame_audio plays sample data (.wav files and generated tones) through a mixer so effects can overlap; picogame_synth generates tones in real time with synthio, so a whole SFX set costs almost no RAM. See /hardware/ for the audio pin and /reference/ for the bare signatures.

A thin convenience layer over CircuitPython’s audio stack (audiopwmio + audiocore + audiomixer). Reach for it when you have .wav assets to play, or want simple beeps without bundling files. It sets up a mixer with several voices so a shot, an explosion, and looping music can all sound at once. The defaults (22050 Hz, mono, 16-bit signed) match typical ugame .wav assets - any sample you play must match that format.

Audio(pin=None, voices=4, sample_rate=22050, channels=1, bits=16, signed=True)

Section titled “Audio(pin=None, voices=4, sample_rate=22050, channels=1, bits=16, signed=True)”

Constructs the audio output and starts the mixer playing immediately. pin=None uses board.AUDIO. voices is the number of simultaneous channels; voice 0 is reserved for music and voices 1..N-1 are used round-robin for sound effects. The other args define the sample format every clip must match.

  • load(path) - opens a .wav file as a reusable WaveFile sample. Build it once and keep the returned object alive (it holds the open file); replaying it is cheap.
  • play(sample, *, voice=None, loop=False, volume=1.0) - plays a sample. voice is keyword-only; None picks the next round-robin sfx voice. volume sets that voice’s level (0.0-1.0). Returns the voice index it used.
  • sfx(sample, volume=1.0) - fire-and-forget effect on a free sfx voice (calls play with loop=False). Returns the voice index.
  • music(sample, loop=True, volume=1.0) - plays on the reserved music voice (voice 0), looping by default.
  • stop(voice=None) - stops one voice, or every voice if voice is None.
  • stop_music() - stops just the music voice.
  • is_playing (property) - True if any voice is currently playing.
  • deinit() - releases the audio output. The board.AUDIO PWM pin is a singleton, so call this before constructing a second Audio() - otherwise the next Audio() raises pin in use.

tone(frequency=440, ms=120, sample_rate=22050, volume=0.6)

Section titled “tone(frequency=440, ms=120, sample_rate=22050, volume=0.6)”

Builds a short square-wave RawSample in RAM - a beep with no .wav file needed. Good for prototyping and simple blips. The result matches a mono 16-bit signed mixer, so you can pass it straight to sfx/play.

import picogame_audio
import picogame_input
audio = picogame_audio.Audio() # PWM audio, 4 voices
pew = picogame_audio.tone(880, 90) # high blip, built once
boom = picogame_audio.tone(140, 200) # low thud
btns = picogame_input.Buttons()
while True:
btns.poll()
if btns.just_pressed(btns.A):
audio.sfx(pew) # overlaps on a free voice
if btns.just_pressed(btns.B):
audio.sfx(boom, volume=0.8)

Gotchas:

  • Every sample must match the mixer’s format. A 44100 Hz or stereo .wav will play wrong (or error); resample assets to 22050 Hz mono 16-bit first.
  • Keep load() results alive - if the WaveFile is garbage-collected the open file goes with it. Load clips once at startup, not per frame.
  • You only have voices-1 sfx channels; rapid-fire effects round-robin and will cut each other off once they wrap.

Real-time synthesis via synthio - oscillators, ADSR envelopes, pitch-bend LFOs and low-pass filters, with no sample buffers. Reach for it when you want a rich SFX set or background music but cannot spare the RAM (or the asset files) that WAV samples need. It wraps the PWM-out -> mixer -> synthesizer chain and ships built-in waveforms. This is a device-only module: synthio is not in the simulator, so guard the import or only run it on hardware. For sample playback instead, use picogame_audio above; see /memory/ for why the RAM savings matter.

Built-in waveform constants (one-cycle, signed 16-bit arrays you share across notes): SINE, SAW, TRIANGLE, SQUARE, NOISE. The functions sine(), saw(), triangle(), square(), noise() build fresh copies if you need them.

note(midi, waveform=None, attack=0.005, decay=0.06, sustain=0.0, release=0.08, amplitude=0.6, bend=None, cutoff=None)

Section titled “note(midi, waveform=None, attack=0.005, decay=0.06, sustain=0.0, release=0.08, amplitude=0.6, bend=None, cutoff=None)”

Builds a reusable note/SFX/instrument - the core building block. midi is a MIDI note number (60 = middle C, 72 = C5). waveform is one of the constants above. attack/decay/sustain/release shape the ADSR envelope in seconds (a short decay with sustain=0.0 gives a percussive blip). amplitude is loudness (0.0-1.0). bend takes a pitch_bend LFO for a slide; cutoff adds a low-pass filter at that many Hz to round off harsh tones. Build each note once and replay it.

pitch_bend(semitones, ms, waveform=None, once=True)

Section titled “pitch_bend(semitones, ms, waveform=None, once=True)”

Returns a synthio.LFO suitable for a note’s bend - it slides the pitch by semitones over ms milliseconds. Use a positive value for a rising zap, negative for a falling drop. With once=True it runs the slide a single time per trigger.

Synth(pin=None, sample_rate=22050, buffer_size=2048, music_level=0.4, sfx_level=0.7)

Section titled “Synth(pin=None, sample_rate=22050, buffer_size=2048, music_level=0.4, sfx_level=0.7)”

Sets up PWM out and a 2-voice mixer: voice 0 for music (a MidiTrack), voice 1 for the live synth used by SFX. pin=None uses board.AUDIO. music_level/sfx_level are the starting mix levels for those two voices.

  • sfx(n) - plays note n as a one-shot effect. It retriggers the note’s LFOs first (so a repeated zap sounds identical every time), then calls release_all_then_press, so back-to-back SFX cut cleanly.
  • press(n) / release(n) - hold and release a note manually, for sounds that last as long as a button is held rather than firing once.
  • music(midi_track) - plays a MidiTrack (from load_midi) on voice 0, looping.
  • stop_music() - stops the music voice.

load_midi(path, sample_rate=22050, waveform=None, envelope=None, tempo=120, ppqn=240)

Section titled “load_midi(path, sample_rate=22050, waveform=None, envelope=None, tempo=120, ppqn=240)”

Loads a .mid file as a synthio.MidiTrack to feed to Synth.music. waveform and envelope pick the instrument voice for the whole track; tempo (BPM) and ppqn set playback speed. It auto-skips a standard format-0 SMF header so a plain exported MIDI just works.

import picogame_synth as snd
import picogame_input
s = snd.Synth()
# Each SFX is a Note built ONCE (envelope + optional bend + filter).
blip = snd.note(72, snd.SQUARE, decay=0.10)
zap = snd.note(60, snd.SAW, decay=0.18, cutoff=4000, bend=snd.pitch_bend(12, 180))
boom = snd.note(45, snd.NOISE, attack=0.0, decay=0.30, amplitude=0.55, cutoff=2800)
btn = picogame_input.Buttons()
while True:
btn.poll()
if btn.just_pressed(btn.A):
s.sfx(zap)

Gotchas:

  • Device only - synthio is absent in the simulator. Import picogame_synth inside an if/try or only on real hardware, the way the train and dinorun examples do.
  • Build each note() once at startup and replay it; sfx handles retriggering. Rebuilding notes per frame wastes time and breaks LFO retrigger.
  • It is the synthesizer that makes sound, not the note object - effects only play through a live Synth, and there are just two mixer voices (one music, one SFX), so overlapping synth SFX share a single voice.