From fdf9866e88059eca39bda0b92fe43fd211e8ccea Mon Sep 17 00:00:00 2001 From: Orkuncakilkaya Date: Fri, 25 Sep 2026 08:35:29 +0300 Subject: [PATCH] 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 --- packages/README.md | 1 + packages/ludic.audio/README.md | 64 +++++++++++++ packages/ludic.audio/emit.ludic | 43 +++++++++ packages/ludic.audio/index.ludic | 9 ++ packages/ludic.audio/load.ludic | 49 ++++++++++ packages/ludic.audio/package.ludic | 6 ++ packages/ludic.audio/place.ludic | 36 +++++++ packages/ludic.audio/ports.ludic | 14 +++ packages/ludic.audio/tests/audio_test.ludic | 100 ++++++++++++++++++++ packages/ludic.audio/wav.ludic | 16 ++++ 10 files changed, 338 insertions(+) create mode 100644 packages/ludic.audio/README.md create mode 100644 packages/ludic.audio/emit.ludic create mode 100644 packages/ludic.audio/index.ludic create mode 100644 packages/ludic.audio/load.ludic create mode 100644 packages/ludic.audio/package.ludic create mode 100644 packages/ludic.audio/place.ludic create mode 100644 packages/ludic.audio/ports.ludic create mode 100644 packages/ludic.audio/tests/audio_test.ludic create mode 100644 packages/ludic.audio/wav.ludic diff --git a/packages/README.md b/packages/README.md index b36d6423..67ee8b27 100644 --- a/packages/README.md +++ b/packages/README.md @@ -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`, 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) | diff --git a/packages/ludic.audio/README.md b/packages/ludic.audio/README.md new file mode 100644 index 00000000..3a7d8887 --- /dev/null +++ b/packages/ludic.audio/README.md @@ -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. diff --git a/packages/ludic.audio/emit.ludic b/packages/ludic.audio/emit.ludic new file mode 100644 index 00000000..8b3d9f8e --- /dev/null +++ b/packages/ludic.audio/emit.ludic @@ -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 } diff --git a/packages/ludic.audio/index.ludic b/packages/ludic.audio/index.ludic new file mode 100644 index 00000000..ac625f4b --- /dev/null +++ b/packages/ludic.audio/index.ludic @@ -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" diff --git a/packages/ludic.audio/load.ludic b/packages/ludic.audio/load.ludic new file mode 100644 index 00000000..bdb162d4 --- /dev/null +++ b/packages/ludic.audio/load.ludic @@ -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)] +} diff --git a/packages/ludic.audio/package.ludic b/packages/ludic.audio/package.ludic new file mode 100644 index 00000000..459f58b7 --- /dev/null +++ b/packages/ludic.audio/package.ludic @@ -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 diff --git a/packages/ludic.audio/place.ludic b/packages/ludic.audio/place.ludic new file mode 100644 index 00000000..813527ce --- /dev/null +++ b/packages/ludic.audio/place.ludic @@ -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 +} diff --git a/packages/ludic.audio/ports.ludic b/packages/ludic.audio/ports.ludic new file mode 100644 index 00000000..4afed9b2 --- /dev/null +++ b/packages/ludic.audio/ports.ludic @@ -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 "" } diff --git a/packages/ludic.audio/tests/audio_test.ludic b/packages/ludic.audio/tests/audio_test.ludic new file mode 100644 index 00000000..d8f5c4dd --- /dev/null +++ b/packages/ludic.audio/tests/audio_test.ludic @@ -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) + } +} diff --git a/packages/ludic.audio/wav.ludic b/packages/ludic.audio/wav.ludic new file mode 100644 index 00000000..54a776df --- /dev/null +++ b/packages/ludic.audio/wav.ludic @@ -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 +}