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:
parent
47ba3d9028
commit
fdf9866e88
10 changed files with 338 additions and 0 deletions
|
|
@ -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) |
|
||||
|
|
|
|||
64
packages/ludic.audio/README.md
Normal file
64
packages/ludic.audio/README.md
Normal 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.
|
||||
43
packages/ludic.audio/emit.ludic
Normal file
43
packages/ludic.audio/emit.ludic
Normal 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 }
|
||||
9
packages/ludic.audio/index.ludic
Normal file
9
packages/ludic.audio/index.ludic
Normal 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"
|
||||
49
packages/ludic.audio/load.ludic
Normal file
49
packages/ludic.audio/load.ludic
Normal 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)]
|
||||
}
|
||||
6
packages/ludic.audio/package.ludic
Normal file
6
packages/ludic.audio/package.ludic
Normal 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
|
||||
36
packages/ludic.audio/place.ludic
Normal file
36
packages/ludic.audio/place.ludic
Normal 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
|
||||
}
|
||||
14
packages/ludic.audio/ports.ludic
Normal file
14
packages/ludic.audio/ports.ludic
Normal 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 "" }
|
||||
100
packages/ludic.audio/tests/audio_test.ludic
Normal file
100
packages/ludic.audio/tests/audio_test.ludic
Normal 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)
|
||||
}
|
||||
}
|
||||
16
packages/ludic.audio/wav.ludic
Normal file
16
packages/ludic.audio/wav.ludic
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue