ludic/packages/ludic.audio
Orkuncakilkaya a5f6c02a09 runtime/audio (macOS): voices on one AVAudioEngine instead of an AVAudioPlayer per sound
AVFAudio keeps a 64-byte AudioQueueOwner for good on every AVAudioPlayer play
after the clip has finished - measured one a play, whatever is called around
it (prepareToPlay, pause, no rewind) and still there after the player is
released; a stop before the play made one every time. The windowed walk showed
it as AudioQueueOwner 73 -> 87 in a minute.

A sound is now decoded once into a PCM buffer and played by a voice of its own
on one shared engine: a player node (the buffer scheduled again on each play,
looping for -1), a varispeed (the rate) and a small mixer (volume and pan).
snd_playing compares the uptime clock with the end worked out when the clip was
played (asking the node where it is made two AVAudioTime objects a call), and
snd_play drains an autorelease pool of its own. The C interface is unchanged.

A harness driving snd_* directly (400 plays past the end, the playing flag
checked during and after each, a loop and a stop, the setters) holds the heap
flat to a block and gets the flag right 400 of 400; the windowed valley
compiles and links against it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 23:00:59 +03:00
..
tests wip(0.S3): packages and examples migrated again from their pre-0.S sources in one run 2026-09-25 15:31:34 +03:00
emit.ludic wip(0.S3): packages and examples migrated again from their pre-0.S sources in one run 2026-09-25 15:31:34 +03:00
index.ludic feat(packages): ludic.audio - world sounds with a gain, a pan and a pitch from where they are relative to a listener port, flat interface sounds, clips loaded past Audio.load's blind spot for packed assets, loops and the master volume; the one door to Audio.* 2026-09-25 08:35:29 +03:00
load.ludic runtime/audio (macOS): voices on one AVAudioEngine instead of an AVAudioPlayer per sound 2026-09-28 23:00:59 +03:00
package.ludic feat(packages): ludic.audio - world sounds with a gain, a pan and a pitch from where they are relative to a listener port, flat interface sounds, clips loaded past Audio.load's blind spot for packed assets, loops and the master volume; the one door to Audio.* 2026-09-25 08:35:29 +03:00
place.ludic feat(packages): ludic.audio - world sounds with a gain, a pan and a pitch from where they are relative to a listener port, flat interface sounds, clips loaded past Audio.load's blind spot for packed assets, loops and the master volume; the one door to Audio.* 2026-09-25 08:35:29 +03:00
ports.ludic feat(packages): ludic.audio - world sounds with a gain, a pan and a pitch from where they are relative to a listener port, flat interface sounds, clips loaded past Audio.load's blind spot for packed assets, loops and the master volume; the one door to Audio.* 2026-09-25 08:35:29 +03:00
README.md feat(packages): ludic.audio - world sounds with a gain, a pan and a pitch from where they are relative to a listener port, flat interface sounds, clips loaded past Audio.load's blind spot for packed assets, loops and the master volume; the one door to Audio.* 2026-09-25 08:35:29 +03:00
wav.ludic feat(packages): ludic.audio - world sounds with a gain, a pan and a pitch from where they are relative to a listener port, flat interface sounds, clips loaded past Audio.load's blind spot for packed assets, loops and the master volume; the one door to Audio.* 2026-09-25 08:35:29 +03:00

ludic.audio

A game's sounds, and the one door to the runtime's Audio.*. Uses nothing.

Audio.play is the interface; the world goes through aud_emit. A click, a chime, a shutter and a warning happen in the player's head and are played flat (aud_ui). Everything that happens in the world - a foot, an axe, a pot, a splash, a call - carries a gain, a pitch and a pan from where it is relative to the listener, even when that is the listener's own position. A game that played half its world flat had an axe across the camp as loud as your own and a pot at the fire as loud from the far shore; nothing said so, because audio is a no-op headless.

import "ludic.audio"
bind AudioListener { x: fn ear_x, z: fn ear_z, yaw: fn ear_yaw }
bind AudioFiles { cache_dir: fn audio_cache }            # a writable directory
let axe = aud_load("assets/kit/audio/chop1.wav")
aud_emit_at(axe, tree.x, tree.z, 90.0)                   # heard within 90 m, panned, a little lower far off
aud_ui(click)

Ports

port AudioListener { x: fn() -> float, z: fn() -> float, yaw: fn() -> float }  # yaw: radians, 0 down -z
port AudioFiles { cache_dir: fn() -> string }            # "" (the default): a packed clip stays silent

API

aud_emit(h, gain, pitch, pan) a world sound, as given: the ONE call to Audio.play_at, and the one place a float becomes the runtime's Q16.16
aud_emit_at(h, x, z, range) -> bool a world sound at a place, gain, pitch and pan from the listener; false when out of earshot
aud_gain_at(x, z, range), aud_pan_at(x, z), aud_pitch_at(gain) a shaped roll-off (not an inverse square: that is deafening or inaudible in a valley of trees), the side from the listener's heading (ahead and behind centred), and a semitone lower at the edge of hearing
aud_ui(h) an interface sound, flat: the ONE call to Audio.play
aud_music(h), aud_stop_music(), aud_master(v) the loop and the master volume
aud_load(path) -> int, aud_unpack(path, name), aud_tried(), aud_live() a clip, loaded past Audio.load's blind spot; how many were asked for and how many loaded
aud_wav_seconds(path, fallback) a PCM WAV's length from its header, for re-triggering a loop before it ends
aud_last_gain(), aud_last_pitch(), aud_last_pan(), aud_emitted() what the last shot handed the runtime: the only thing a headless test can hear

The pack

Audio.load takes a real filesystem path - it bottoms out in AVAudioPlayer's initWithContentsOfURL: - not the pack-aware file_open every other asset goes through. In a project the two are the same; in a bundle every clip loads as 0, while Fs.exists (pack-aware) says it is there. So aud_load tries the path and, on 0, lifts the bytes out of the pack into AudioFiles.cache_dir() and loads that. A cached copy is trusted only when it is as long as the packed one. Say aud_live() of aud_tried() at start-up: a silent game is otherwise indistinguishable from a working one.

The guard

A game keeps the rule with a grep (Maroon Lake's tests/audio.sh): nothing outside this package names Audio.*, Audio.play appears once here (in aud_ui) and Audio.play_at once (in aud_emit), and the game calls aud_ui only for its interface sounds.

Tests

ludic test packages/ludic.audio

A listener bound to a fake: the roll-off, the pan on each side and turning with the listener, the pitch, emit's numbers, earshot, a WAV header, a packed file lifted once and a short copy replaced.