Search by

php-io-extensions / sdl3-audio

projectsaturnstudios

1:1 PHP bindings of SDL 3's audio API (SDL_audio.h), built on ext-sdl3

Package info

github.com/php-io-extensions/sdl3-audio

Language:C

Type:php-ext

Ext name:ext-sdl3_audio

pkg:composer/php-io-extensions/sdl3-audio

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

0.11.x-dev 2026-11-12 04:04 UTC

This package is auto-updated.

Last update: 2026-11-12 04:05:38 UTC


README

ext-sdl3-audio

SDL 3's audio API bound 1:1 into PHP 8.4+, on ext-sdl3's object model

CI SDL audio API Packagist PHP License Platform OKF PIE

Sound out of PHP has meant handing a file to a player process: no device choice, no stream to feed, no way to hear what you just computed. Anything that generates audio, resamples it or mixes it needs a device it can push samples into and a callback that asks for more.

SDL 3's audio subsystem is that layer. It enumerates playback and recording devices, opens them by ID, converts between sample formats, channel layouts and rates through SDL_AudioStream, binds any number of streams to one device and mixes them, and hands you the final mix in a postmix callback. It loads WAV, mixes buffers and converts samples on its own. It runs on Core Audio, PipeWire, PulseAudio and ALSA.

The only problem? PHP can't reach it, and SDL calls its callbacks on an audio thread where PHP cannot run. This extension binds SDL_audio.h one to one: every function, constant and struct, under its C name. It builds on ext-sdl3: SDL_Init, SDL_GetError, properties and SDL_IOStream come from there, and an SDL_AudioStream is one of its handles. Callbacks are PHP callables, served on the PHP thread that set them. No defaults, no composites, no opinions: what SDL documents is what you call.

Basic usage

SDL_Init(SDL_INIT_AUDIO) || throw new RuntimeException(SDL_GetError());

$phase = 0.0;
$stream = SDL_OpenAudioDeviceStream(
    SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK,
    new SDL_AudioSpec(SDL_AUDIO_F32, 1, 48000),
    function (SDL_AudioStream $stream, int $additional, int $total) use (&$phase) {
        $samples = '';
        for ($i = 0; $i < intdiv($additional, 4); $i++) {
            $samples .= pack('g', 0.2 * sin($phase));
            $phase += 2 * M_PI * 440 / 48000;
        }
        SDL_PutAudioStreamData($stream, $samples, strlen($samples));
    },
);
SDL_ResumeAudioStreamDevice($stream);

$end = microtime(true) + 1.0;
while (microtime(true) < $end) {
    // PHP code keeps running: SDL's requests are served at the VM's interrupt points.
}
SDL_DestroyAudioStream($stream);
SDL_Quit();

Push instead of pull, from a WAV through ext-sdl3's SDL_IOStream:

SDL_Init(SDL_INIT_AUDIO) || throw new RuntimeException(SDL_GetError());

$spec = new SDL_AudioSpec(); // SDL fills it
$bytes = null;
$length = 0;
SDL_LoadWAV_IO(SDL_IOFromFile('blip.wav', 'rb'), true, $spec, $bytes, $length) || throw new RuntimeException(SDL_GetError());

$stream = SDL_OpenAudioDeviceStream(SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK, $spec, null);
SDL_PutAudioStreamData($stream, $bytes, $length);
SDL_FlushAudioStream($stream);
SDL_ResumeAudioStreamDevice($stream);
while (SDL_GetAudioStreamQueued($stream) > 0) {
    usleep(10000);
}
SDL_DestroyAudioStream($stream);

Requirements

PHP 8.4 or newer, NTS or ZTS
OS Linux, macOS
Build a C compiler, pkg-config, phpize and php-config for the PHP you install into
SDL 3.2 or newer. Built against 3.4, the 3.4 functions are bound too
ext-sdl3 0.11.2 or newer, installed into the same PHP first: this extension compiles against its php_sdl3_api.h and loads after it
# Debian trixie, Raspberry Pi OS
sudo apt install build-essential php8.4-dev libsdl3-dev pkg-config

# macOS: Xcode Command Line Tools, and Homebrew's PHP ships phpize and php-config
xcode-select --install
brew install sdl3 pkgconf

Installation

Install ext-sdl3 first, the same way.

With PHP PIE (recommended)

pie install php-io-extensions/sdl3-audio

PIE builds the module against the PHP it runs under and enables it.

Manually (install script)

The repository ships two installers. Each stops if ext-sdl3 is not loaded, builds for its PHP, installs sdl3_audio.so into that PHP's extension_dir, writes 30-sdl3_audio.ini (it sorts after 30-sdl3.ini), and checks the module loads. The macOS one builds for Homebrew php@8.4 and php@8.4-zts and ad-hoc signs the module; pass other PHP binaries as arguments to install those instead. The Debian one configures against the distro SDL in /usr/lib/$(gcc -dumpmachine) and puts /usr/include ahead of /usr/local/include, as ext-sdl3's does.

git clone https://github.com/php-io-extensions/sdl3-audio.git
cd sdl3-audio
./install-macos.sh            # macOS
./install-debian-trixie.sh    # Debian 13, Raspberry Pi OS

Manually (phpize)

phpize
./configure --enable-sdl3-audio
make -j"$(nproc 2>/dev/null || sysctl -n hw.ncpu)"
sudo make install
echo "extension=sdl3_audio" | sudo tee "$(php -r 'echo PHP_CONFIG_FILE_SCAN_DIR;')/30-sdl3_audio.ini"

Verify

php --ri sdl3_audio
sdl3_audio support => enabled
Version => 0.11.0
SDL version => 3.4.20

API

58 functions, all bound on SDL 3.4; 56 on SDL 3.2. SUPPORT.md lists each one with the SDL version that added it. SDL's audio documentation and SDL_audio.h document every call; the stubs in stubs/ give the PHP signature of each.

Translations

  • Names. Functions, constants and classes are the C names.
  • ext-sdl3's translations apply: a list and its C count are separate parameters, bytes are strings with their C length, out-parameters are by reference.
  • SDL_AudioSpec is a class with format, channels and freq. Where SDL fills an SDL_AudioSpec * (SDL_LoadWAV_IO, SDL_GetAudioDeviceFormat), you pass an SDL_AudioSpec and SDL writes into it.
  • SDL_AudioStream is an ext-sdl3 handle: final, one PHP object per native pointer, pointer() and fromPointer(). SDL_DestroyAudioStream marks it released; a released stream reaches SDL as NULL, which SDL refuses itself.
  • Samples are strings of packed bytes in the stream's format (pack('g*', …) for SDL_AUDIO_F32).
  • Failure is false (or 0, -1, null as SDL returns it) and SDL_GetError(), as in C.
  • Callbacks take a PHP callable and no userdata: a closure carries its own state.

Errors

The extension declines a call only where SDL would read memory it does not own. A length past the PHP string in SDL_PutAudioStreamData, SDL_PutAudioStreamDataNoCopy, SDL_PutAudioStreamPlanarData or SDL_MixAudio throws ValueError. The check has a probe test that crashes with it off (tests/Probes).

Threads and callbacks

SDL calls stream and postmix callbacks on its audio thread. The binding runs the PHP callable on the thread that set it, at that thread's next VM interrupt point (a loop iteration, a function call), and SDL's thread waits for it as long as the requested audio lasts. A thread that sleeps or sits in a long C call misses its turn, and SDL plays what it has. SDL calls the callable makes run on SDL's waiting thread, which holds the stream's lock. When PHP itself calls SDL_GetAudioStreamData or SDL_PutAudioStreamData, the callback runs inline. An exception thrown in a callback surfaces on that PHP thread.

The postmix callable gets the mixed float32 buffer as a by-reference string; a string of the same length is copied back, any other length leaves SDL's buffer as it was.

Do not destroy a stream inside its own callback (SDL's rule). SDL_QuitSubSystem(SDL_INIT_AUDIO) and SDL_Quit() release every SDL_AudioStream object and the callables the binding held. Request end clears every callback first; a forked child ends cleanly while the parent keeps its callbacks.

SDL 3.2

SDL_PutAudioStreamDataNoCopy, SDL_PutAudioStreamPlanarData and SDL_PROP_AUDIOSTREAM_AUTO_CLEANUP_BOOLEAN exist only when built against SDL 3.4. On SDL 3.2, SDL_GetAudioDeviceChannelMap and SDL_GetAudioStream{Input,Output}ChannelMap crash inside SDL when no channel map is set; fixed in SDL 3.4.0.

Extensions you might use with this one

Agentic Documentation

This repository carries an Open Knowledge Format bundle at .okf/: Markdown with YAML frontmatter, readable by you and loadable by a coding agent. It records what the README doesn't: the bindings by group, how SDL_AudioStream lives in ext-sdl3's identity table, the callback queue and call forwarding, and how to build and test.

Resources

Running the test suite

Pest v4: formats and constants, device queries, streams, devices and binding, WAV loading, mixing and conversion, every callback, request end and fork, and the support list. phpunit.xml puts SDL on its dummy output, so nothing is audible and no device is needed. The tests load ext-sdl3 and this module from extension_dir, so install both first:

git clone https://github.com/php-io-extensions/sdl3-audio.git
cd sdl3-audio
composer install
php -d memory_limit=128M vendor/bin/pest
Tests:    3 skipped, 54 passed

The skipped tests are probe halves: they run only on a probe build (./configure --enable-sdl3-audio --enable-sdl3-audio-probes, never shipped), where the length check can be switched off to show the crash it prevents. On SDL 3.2 the 3.4-only tests skip too.

One test plays a one-second 440 Hz tone on the real default output. It is excluded by default:

php -d memory_limit=128M vendor/bin/pest --group=hardware

Contributing

Open an issue first for anything beyond a typo. Every binding is written by hand in src/, declared in its stub; regenerate arginfo with gen_stub, run php scripts/support.php (it rewrites SUPPORT.md; --check exits 1 when it is stale or a function is missing), and add a Pest test. CI must be green on every cell of the matrix, NTS and ZTS alike. Read AGENTS.md for the rules the code follows.

Security

ext-sdl3-audio runs SDL's audio thread inside the PHP process. See SECURITY.md for what it guards against and how to report a vulnerability.

License

MIT. See LICENSE. SDL is zlib, Copyright (c) Sam Lantinga.