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.*

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-25 08:35:29 +03:00
parent 47ba3d9028
commit fdf9866e88
10 changed files with 338 additions and 0 deletions

View file

@ -15,6 +15,7 @@ section. The rules are in [ludic.base](ludic.base/README.md).
| [ludic.base](ludic.base/README.md) | the shared vocabulary: Tick and phases, `Queue<T>`, rng streams, the save tree, the system runner (`Systems`, an open registry) |
| [ludic.anim](ludic.anim/README.md) | glTF animation clips sampled and cross-faded onto a render3d Skin (uses `ludic_render3d`) |
| [ludic.character](ludic.character/README.md) | a third-person walking body: gait, steps and slopes, a jump, wading, swimming, sitting, a safe put-down, the orbit camera |
| [ludic.audio](ludic.audio/README.md) | world sounds with a gain, pan and pitch from a listener port, flat interface sounds, packed clips loaded, the one door to `Audio.*` |
| [ludic.clock](ludic.clock/README.md) | the hour, the day, the moon and a calendar of seasons |
| [ludic.crafting](ludic.crafting/README.md) | recipes: what goes in and out, where and how long, making, refunding, hold-to-make |
| [ludic.effects](ludic.effects/README.md) | timed modifiers that run down in game time (a meal's warmth, a drink's legs) |

View file

@ -0,0 +1,64 @@
# 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.
```ludic
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
```ludic
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
```bash
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.

View file

@ -0,0 +1,43 @@
# emit.ludic - the only calls into the runtime's playback. aud_emit is the ONE place a float
# crosses into Audio.play_at's Q16.16: handed over unconverted, 0.94 as a pitch clamped to 4x and
# -0.85 as a pan to hard left, and every sound in a valley played fast out of one speaker
var last_gain: float = 0.0
var last_pitch: float = 0.0
var last_pan: float = 0.0
var emitted: int = 0
export function aud_emit(h: int, gain: float, pitch: float, pan: float) -> void {
if h == 0 { return }
last_gain = gain
last_pitch = pitch
last_pan = pan
emitted += 1
Audio.play_at(h, fixed(gain), fixed(pitch), fixed(pan))
}
# a sound at a place in the world, heard within `range` metres: false (and nothing played) when the
# listener is out of earshot
export function aud_emit_at(h: int, x: float, z: float, range: float) -> bool {
let g = aud_gain_at(x, z, range)
if g == 0.0 { return false }
aud_emit(h, g, aud_pitch_at(g), aud_pan_at(x, z))
return true
}
# a click, a chime, a shutter, a warning: in the player's head, flat
export function aud_ui(h: int) -> void {
if h == 0 { return }
emitted += 1
Audio.play(h)
}
export function aud_music(h: int) -> void { if h != 0 { Audio.play_music(h) } }
export function aud_stop_music() -> void { Audio.stop_music() }
export function aud_master(v: float) -> void { Audio.volume(fixed(Math.clamp(v, 0.0, 1.0))) }
# what the last emit handed the runtime, and how many shots so far: Audio.* is a no-op headless, so
# the numbers are the only thing a test can hear
export function aud_last_gain() -> float { return last_gain }
export function aud_last_pitch() -> float { return last_pitch }
export function aud_last_pan() -> float { return last_pan }
export function aud_emitted() -> int { return emitted }

View file

@ -0,0 +1,9 @@
# ludic.audio - Audio.play is the INTERFACE; everything that happens in the world goes through
# aud_emit with a gain and a pan from where it is. Nothing outside this module names Audio.*.
module ludic_audio uses
numbers float
import "ports.ludic"
import "place.ludic"
import "emit.ludic"
import "load.ludic"
import "wav.ludic"

View file

@ -0,0 +1,49 @@
# load.ludic - a clip the runtime can actually open. Audio.load takes a real FILESYSTEM path (it
# bottoms out in AVAudioPlayer), not the pack-aware file_open every other asset arrives through: in a
# bundle every clip loads as 0 while Fs.exists, which IS pack-aware, says it is there. So try the
# runtime's path, and on 0 lift the bytes out of the pack into the cache directory and load that.
var tried: int = 0
var live: int = 0
export function aud_load(path: string) -> int {
tried += 1
if not is_windowed() { return 0 }
var h = Audio.load(path)
if h == 0 {
let out = aud_unpack(path, base_name(path))
if out != null { h = Audio.load(out) }
}
if h != 0 { live += 1 }
return h
}
# how many clips were asked for and how many loaded: a silent game looks exactly like a working one
# unless something says so
export function aud_tried() -> int { return tried }
export function aud_live() -> int { return live }
# One packed file out to the cache, or null. A cached copy is trusted only if it is as long as the
# packed one: a write interrupted halfway would otherwise play truncated for ever.
export function aud_unpack(path: string, name: string) -> string {
let dir = AudioFiles.cache_dir()
if len(dir) == 0 { return null }
Fs.mkdir(dir)
let dst = dir + "/" + name
let f = file_open(path, "rb")
if f == null { return null }
file_seek(f, 0, 2)
let n = file_tell(f)
file_close(f)
if n <= 0 { return null }
if Fs.exists(dst) and Fs.size(dst) == n { return dst }
let buf = Fs.read_bytes(path)
if buf == null or len(buf) < n { return null }
if not Fs.write_bytes(dst, buf, n) { return null }
return dst
}
function base_name(path: string) -> string {
var i = len(path) - 1
while i >= 0 and path[i] != 47 { i -= 1 }
return path[i + 1..len(path)]
}

View file

@ -0,0 +1,6 @@
# ludic.audio - a world's sounds with a gain, a pan and a pitch from where they are relative to a
# listener, flat interface sounds, clips loaded past Audio.load's blind spot for packed assets,
# loops and the master volume: the one door to the runtime's Audio.*. Uses nothing. See README.md.
package "ludic.audio"
version "0.1.0"
kind source

View file

@ -0,0 +1,36 @@
# place.ludic - where a sound is, heard from the listener. Not an inverse square, which is right for
# a point in free air and wrong for a valley full of trees: a shaped roll-off keeps a call audible
# across a meadow and gone across a lake
const TAU: float = 6.2831853
const HALF_TURN: float = 3.14159265
export function aud_gain_at(x: float, z: float, range: float) -> float {
let dx = x - AudioListener.x()
let dz = z - AudioListener.z()
let d = Math.sqrt(dx * dx + dz * dz)
if d > range or range <= 0.0 { return 0.0 }
let t = 1.0 - d / range
return Math.clamp(t * t, 0.0, 1.0)
}
# Which side, -1 left .. 1 right, from the listener's heading. Ahead and behind are both centred:
# that is all a two-speaker pan can honestly say.
export function aud_pan_at(x: float, z: float) -> float {
let dx = x - AudioListener.x()
let dz = z - AudioListener.z()
if dx * dx + dz * dz < 1.0 { return 0.0 }
let rel = wrap(Math.atan2(-dx, -dz) - AudioListener.yaw())
# forward for a yaw is (-sin, -cos), so a bearing to the LEFT is positive out of atan2
return Math.clamp(-Math.sin(rel) * 0.85, -1.0, 1.0)
}
# Air takes the top off a distant sound: a semitone lower at the edge of hearing is most of why a
# far call reads as far rather than as a quiet near one.
export function aud_pitch_at(gain: float) -> float { return 0.94 + gain * 0.09 }
function wrap(a: float) -> float {
var d = a
while d > HALF_TURN { d = d - TAU }
while d < -HALF_TURN { d = d + TAU }
return d
}

View file

@ -0,0 +1,14 @@
# ports.ludic - what the sounds ask the game: where the listener is and which way it faces, and a
# writable directory to lift packed clips into
export port AudioListener {
x: fn() -> float
z: fn() -> float
yaw: fn() -> float = fn audio__no_yaw # radians, 0 looking down -z (render3d's cam_yaw)
}
export port AudioFiles {
cache_dir: fn() -> string = fn audio__no_cache # "" is none: a packed clip then stays silent
}
function audio__no_yaw() -> float { return 0.0 }
function audio__no_cache() -> string { return "" }

View file

@ -0,0 +1,100 @@
# audio_test.ludic - a listener at the origin facing -z: the roll-off, the pan on each side, the pitch
# of a far sound, emit's numbers (Audio.* itself is a no-op headless), a packed file lifted into the
# cache once, and a WAV's length from its header
import "ludic.audio"
program AudioTest {
numbers float
var lx: float = 0.0
var lz: float = 0.0
var lyaw: float = 0.0
function t_x() -> float { return lx }
function t_z() -> float { return lz }
function t_yaw() -> float { return lyaw }
function dir() -> string { return Os.temp_dir() + "/ludic-audio-test" }
function cache() -> string { return dir() + "/cache" }
bind AudioListener { x: fn t_x, z: fn t_z, yaw: fn t_yaw }
bind AudioFiles { cache_dir: fn cache }
test "the gain falls off with distance and is gone past the range" {
let near = aud_gain_at(0.0, -10.0, 200.0)
let mid = aud_gain_at(0.0, -100.0, 200.0)
let far = aud_gain_at(0.0, -150.0, 200.0)
expect(near > mid and mid > far and far > 0.0)
expect_eq(aud_gain_at(0.0, -400.0, 200.0), 0.0)
expect_near(aud_gain_at(0.0, 0.0, 50.0), 1.0, 0.0001)
lx = 1000.0
expect_near(aud_gain_at(1000.0, 0.0, 50.0), 1.0, 0.0001)
}
test "the pan says which side, ahead and behind are centred, and it turns with the listener" {
expect(aud_pan_at(-60.0, 0.0) < -0.5)
expect(aud_pan_at(60.0, 0.0) > 0.5)
expect_near(aud_pan_at(0.0, -60.0), 0.0, 0.01)
expect_near(aud_pan_at(0.0, 60.0), 0.0, 0.01)
expect_near(aud_pan_at(0.5, 0.0), 0.0, 0.0001)
lyaw = 3.14159265
expect(aud_pan_at(-60.0, 0.0) > 0.5)
}
test "a far sound is a little lower" {
expect(aud_pitch_at(1.0) > aud_pitch_at(0.1))
expect(aud_pitch_at(0.0) > 0.9 and aud_pitch_at(1.0) < 1.1)
}
test "emit hands the runtime what it was told, and a sound out of earshot is not played" {
aud_emit(7, 0.5, 0.97, -0.25)
expect_near(aud_last_gain(), 0.5, 0.0001)
expect_near(aud_last_pan(), -0.25, 0.0001)
expect_eq(aud_emitted(), 1)
aud_emit(0, 1.0, 1.0, 0.0)
expect_eq(aud_emitted(), 1)
expect(not aud_emit_at(7, 0.0, -500.0, 100.0))
expect(aud_emit_at(7, 30.0, 0.0, 100.0))
expect(aud_last_pan() > 0.5)
expect(aud_last_gain() > 0.4 and aud_last_gain() < 0.5)
expect(aud_last_pitch() < 1.0)
}
function wav(path: string, rate: int, frames: int) -> void {
let n = 44 + frames * 2
let b = buffer(n)
b[22] = 1
b[24] = rate & 255
b[25] = (rate >> 8) & 255
b[26] = (rate >> 16) & 255
b[34] = 16
let data = frames * 2
b[40] = data & 255
b[41] = (data >> 8) & 255
b[42] = (data >> 16) & 255
Fs.write_bytes(path, b, n)
}
test "a WAV's length from its header, and a fallback for anything else" {
Fs.mkdir(dir())
wav(dir() + "/two.wav", 22050, 44100)
expect_near(aud_wav_seconds(dir() + "/two.wav", 9.0), 2.0, 0.001)
wav(dir() + "/blip.wav", 22050, 100)
expect_near(aud_wav_seconds(dir() + "/blip.wav", 9.0), 9.0, 0.001)
expect_near(aud_wav_seconds(dir() + "/none.wav", 5.4), 5.4, 0.001)
}
test "a packed clip is lifted into the cache once, and a short copy is replaced" {
Fs.mkdir(dir())
wav(dir() + "/click.wav", 22050, 300)
Fs.remove(cache() + "/click.wav")
let out = aud_unpack(dir() + "/click.wav", "click.wav")
expect(out == cache() + "/click.wav")
expect_eq(Fs.size(out), 44 + 600)
Fs.write_text(out, "cut")
let again = aud_unpack(dir() + "/click.wav", "click.wav")
expect_eq(Fs.size(again), 44 + 600)
expect(aud_unpack(dir() + "/nothing.wav", "nothing.wav") == null)
}
test "nothing loads headless, and the count says so" {
expect_eq(aud_load(dir() + "/click.wav"), 0)
expect_eq(aud_tried(), 1)
expect_eq(aud_live(), 0)
}
}

View file

@ -0,0 +1,16 @@
# wav.ludic - a PCM WAV's length from its own header (the sample rate at byte 24, the data length at
# 40), so a loop re-triggered before its clip ends follows whatever the fetcher wrote
export function aud_wav_seconds(path: string, fallback: float) -> float {
let hdr = Fs.read_bytes(path)
if hdr == null { return fallback }
if len(hdr) < 44 { return fallback }
let rate = hdr[24] | (hdr[25] << 8) | (hdr[26] << 16) | (hdr[27] << 24)
let bps = hdr[34]
let ch = hdr[22]
let data = hdr[40] | (hdr[41] << 8) | (hdr[42] << 16) | (hdr[43] << 24)
if rate <= 0 or bps <= 0 or ch <= 0 { return fallback }
let frames = data / (ch * (bps / 8))
let secs = float(frames) / float(rate)
if secs < 0.2 { return fallback }
return secs
}