SDK example · full source

hello-audio

Controller-triggered SPU samples

On this page

What this program does

Initialize the SPU, parse and upload cooked audio, then trigger voices from the controller. The sample blobs are included from assets/audio/freesfx/psau in the checkout. The full source includes sound timing and the on-screen labels.

Read the step-by-step how-to.

PS1 homebrew, running in your browser. This player runs the compiled PS1 executable in the PSoXide emulator.

Click to run inside the player.

Focus the game to use the keyboard. Arrows: D-pad. F / G / H / X: Cross / Circle / Square / Triangle. Enter: Start. Sound starts muted; use the speaker button in the game. No touch controls.

Build and run

Use the documented SDK checkout and tool setup. Run these commands from the repository root.

make disc EXAMPLE=hello-audio

Open build/examples/mipsel-sony-psx/release/hello-audio.cue in the emulator with its sibling BIN in the same directory. The first-program walkthrough explains installation and loading.

For original hardware, read the burned-disc requirements and warning.

Complete source

Complete, unabridged files from SDK revision f2fec1c301f1. Build inside the repository checkout: the manifest uses relative SDK paths, and some programs embed assets from elsewhere in the repository.

Runtime dependencies: psx-asset, psx-font, psx-gpu, psx-pad, psx-rt, psx-spu, psx-vram.

Cargo.toml · 25 lines

View this file on GitHub

[package]
name    = "hello-audio"
version = "0.1.0"
edition = "2021"
license = "GPL-2.0-or-later"

[workspace]

[[bin]]
name = "hello-audio"
path = "src/main.rs"

[profile.release]
panic = "abort"
codegen-units = 1
lto = true

[dependencies]
psx-asset = { path = "../../crates/psx-asset" }
psx-font = { path = "../../crates/psx-font" }
psx-gpu  = { path = "../../crates/psx-gpu" }
psx-pad  = { path = "../../crates/psx-pad" }
psx-rt   = { path = "../../crates/psx-rt" }
psx-spu  = { path = "../../crates/psx-spu" }
psx-vram = { path = "../../crates/psx-vram" }
src/main.rs · 262 lines

View this file on GitHub

//! `hello-audio` -- the audio equivalent of hello-input.
//!
//! Face buttons, d-pad directions, and START trigger cooked `.psau`
//! gameplay SFX imported by `psxed audio-pack`:
//!
//! | Button   | Sample    |
//! |----------|-----------|
//! | CROSS    | Jump      |
//! | CIRCLE   | Coin      |
//! | TRIANGLE | Swoosh    |
//! | SQUARE   | Punch     |
//! | D-pad    | UI / step |
//! | START    | Explosion |
//!
//! Visual feedback:
//! - Background flashes the colour of whichever voice just triggered.
//! - Text rows show each button's state (playing / silent) + its
//!   configured pitch and waveform name.
//!
//! Proves the whole pad → SPU → DAC path: the controller IRQ
//! delivers button events, pad state drives key-on / key-off
//! writes to SPU voice registers, and the SPU's internal mixer
//! streams audio out through the bus' sample collector.

#![no_std]
#![no_main]

extern crate psx_rt;

use psx_asset::Audio;
use psx_font::{fonts::BASIC, FontAtlas};
use psx_gpu::display::{DisplayConfig, DoubleBuffer, Resolution, VideoMode};
use psx_gpu::Gpu;
use psx_pad::{button, poll_on, ButtonState, Port};
use psx_spu::{Adsr, Spu, SpuAddr, Voice, Volume};
use psx_vram::{Clut, TextureDepth, TexturePage};

/// Font atlas tpage -- past the 320-wide display buffers.
const FONT_TPAGE: TexturePage = TexturePage::new(320, 0, TextureDepth::Bit4);
const FONT_CLUT: Clut = Clut::new(320, 256);

static JUMP_SFX: &[u8] = include_bytes!("../../../../assets/audio/freesfx/psau/jump.psau");
static COIN_SFX: &[u8] = include_bytes!("../../../../assets/audio/freesfx/psau/pickup_coin.psau");
static SWOOSH_SFX: &[u8] = include_bytes!("../../../../assets/audio/freesfx/psau/swoosh.psau");
static PUNCH_SFX: &[u8] = include_bytes!("../../../../assets/audio/freesfx/psau/hit_punch.psau");
static UI_SELECT_SFX: &[u8] =
    include_bytes!("../../../../assets/audio/freesfx/psau/ui_select.psau");
static METAL_SFX: &[u8] = include_bytes!("../../../../assets/audio/freesfx/psau/hit_metal.psau");
static BEEP_SFX: &[u8] = include_bytes!("../../../../assets/audio/freesfx/psau/ui_beep.psau");
static FOOTSTEP_SFX: &[u8] = include_bytes!("../../../../assets/audio/freesfx/psau/footstep.psau");
static EXPLOSION_SFX: &[u8] =
    include_bytes!("../../../../assets/audio/freesfx/psau/explosion_short.psau");

/// SPU RAM addresses. We park samples starting at 0x1010 -- the BIOS
/// convention is to leave 0x0000..0x1000 for system use, with the
/// required "zero" block at 0x1000.
const SPU_SAMPLE_BASE: u32 = 0x1010;
const FLASH_FRAMES: u8 = 12;
const SFX_COUNT: usize = 9;

struct SfxChannel {
    voice: Voice,
    button: u16,
    bytes: &'static [u8],
    volume: Volume,
    label: &'static str,
    tint: (u8, u8, u8),
}

const SFX_CHANNELS: [SfxChannel; SFX_COUNT] = [
    SfxChannel {
        voice: Voice::V0,
        button: button::CROSS,
        bytes: JUMP_SFX,
        volume: Volume::linear(1, 8),
        label: "CROSS    : jump",
        tint: (80, 160, 220),
    },
    SfxChannel {
        voice: Voice::V1,
        button: button::CIRCLE,
        bytes: COIN_SFX,
        volume: Volume::linear(1, 10),
        label: "CIRCLE   : coin",
        tint: (220, 170, 80),
    },
    SfxChannel {
        voice: Voice::V2,
        button: button::TRIANGLE,
        bytes: SWOOSH_SFX,
        volume: Volume::linear(1, 10),
        label: "TRIANGLE : swoosh",
        tint: (80, 220, 140),
    },
    SfxChannel {
        voice: Voice::V3,
        button: button::SQUARE,
        bytes: PUNCH_SFX,
        volume: Volume::linear(1, 10),
        label: "SQUARE   : punch",
        tint: (220, 90, 90),
    },
    SfxChannel {
        voice: Voice::V4,
        button: button::UP,
        bytes: UI_SELECT_SFX,
        volume: Volume::linear(1, 12),
        label: "UP       : select",
        tint: (140, 180, 240),
    },
    SfxChannel {
        voice: Voice::V5,
        button: button::RIGHT,
        bytes: METAL_SFX,
        volume: Volume::linear(1, 12),
        label: "RIGHT    : metal",
        tint: (180, 180, 190),
    },
    SfxChannel {
        voice: Voice::V6,
        button: button::DOWN,
        bytes: BEEP_SFX,
        volume: Volume::linear(1, 12),
        label: "DOWN     : beep",
        tint: (120, 220, 230),
    },
    SfxChannel {
        voice: Voice::V7,
        button: button::LEFT,
        bytes: FOOTSTEP_SFX,
        volume: Volume::linear(1, 8),
        label: "LEFT     : footstep",
        tint: (170, 130, 90),
    },
    SfxChannel {
        voice: Voice::new(8),
        button: button::START,
        bytes: EXPLOSION_SFX,
        volume: Volume::linear(1, 14),
        label: "START    : explosion",
        tint: (230, 120, 70),
    },
];

#[no_mangle]
fn main() {
    let Some(peripherals) = psx_rt::Peripherals::take() else {
        return;
    };
    let mut gpu = Gpu::new(
        peripherals.gpu_dma,
        DisplayConfig::new(VideoMode::Ntsc, Resolution::R320X240),
    );
    let mut fb = DoubleBuffer::new(Resolution::R320X240);
    gpu.set_draw_area((0, 0), (319, 239));
    gpu.set_draw_offset((0, 0));

    // Audio side init: SPU on, unmuted, main volume full.
    let mut spu_driver = Spu::new(peripherals.spu_dma);
    let mut port = peripherals.controller_port;

    // Upload each cooked PSAU sample into SPU RAM and configure its
    // voice once. PSAU payloads are ADPCM blocks, so advancing by the
    // byte length keeps the next start address aligned.
    let mut next_addr = SPU_SAMPLE_BASE;
    for ch in SFX_CHANNELS.iter() {
        let audio = Audio::from_bytes(ch.bytes).expect("psau sample");
        let addr = SpuAddr::new(next_addr);
        spu_driver.upload_adpcm(addr, audio.adpcm_bytes());
        ch.voice
            .configure_sample(addr, audio.sample_rate_hz(), ch.volume, Adsr::sample());
        next_addr += audio.adpcm_bytes().len() as u32;
    }

    let font = FontAtlas::upload(&BASIC, FONT_TPAGE, FONT_CLUT);

    // Edge-detect pad state so we only start voices on transitions
    // -- otherwise we'd retrigger the attack phase every frame, which
    // sounds like a constant click.
    let mut prev_pad = ButtonState::NONE;
    let mut flashes = [0u8; SFX_COUNT];

    loop {
        let pad = poll_on(&mut port, Port::One).buttons;

        // Compute which one-shot channels are newly pressed.
        let mut on_mask: u32 = 0;
        for (i, ch) in SFX_CHANNELS.iter().enumerate() {
            let now = pad.is_held(ch.button);
            let was = prev_pad.is_held(ch.button);
            if now && !was {
                on_mask |= ch.voice.mask();
                flashes[i] = FLASH_FRAMES;
            }
        }
        if on_mask != 0 {
            Voice::start(on_mask);
        }
        prev_pad = pad;
        for flash in flashes.iter_mut() {
            if *flash != 0 {
                *flash -= 1;
            }
        }

        // Visual feedback -- tint the background with the sum of
        // currently-active channel colours for a low-effort "you
        // can see what you hear" cue.
        let (r, g, b) = mix_background(&flashes);
        fb.clear(&mut gpu, (r, g, b));

        // Header.
        font.draw_text(4, 4, "hello-audio", (200, 200, 200));
        font.draw_text(
            4,
            14,
            "Press a mapped button to trigger a sample.",
            (140, 140, 140),
        );

        // Per-channel row: label + trigger indicator.
        let mut y: i16 = 32;
        for (i, ch) in SFX_CHANNELS.iter().enumerate() {
            let (line_tint, state_text) = if flashes[i] != 0 {
                (ch.tint, "PLAYING")
            } else {
                ((100, 100, 100), "silent ")
            };
            font.draw_text(4, y, ch.label, line_tint);
            font.draw_text(4 + 8 * 24, y, state_text, line_tint);
            y = y.wrapping_add(12);
        }

        // Footer hint.
        font.draw_text(
            4,
            220,
            "face buttons + d-pad + start trigger samples",
            (120, 120, 120),
        );

        gpu.wait_idle();
        psx_rt::interrupts::wait_vblank();
        fb.swap(&mut gpu);
    }
}

/// Mix a background tint from recently-triggered samples. Additive
/// blend keeps simultaneous hits visible without covering the text.
fn mix_background(flashes: &[u8; SFX_COUNT]) -> (u8, u8, u8) {
    let mut r: u16 = 8;
    let mut g: u16 = 8;
    let mut b: u16 = 20;
    for (i, ch) in SFX_CHANNELS.iter().enumerate() {
        if flashes[i] != 0 {
            r = r.saturating_add((ch.tint.0 >> 2) as u16);
            g = g.saturating_add((ch.tint.1 >> 2) as u16);
            b = b.saturating_add((ch.tint.2 >> 2) as u16);
        }
    }
    (r.min(255) as u8, g.min(255) as u8, b.min(255) as u8)
}