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>
206 lines
7.9 KiB
Text
206 lines
7.9 KiB
Text
# ============================================================================
|
|
# audio.ludic — the Audio.* standard library (#22).
|
|
#
|
|
# A small, game-shaped sound API over the platform audio backend (audio.ll,
|
|
# AVAudioEngine on macOS). A sound is loaded once into a handle; sfx are fired
|
|
# one-shot, music plays on a single looping channel. Playback itself is not part
|
|
# of the deterministic simulation — the device output is real-time and
|
|
# non-deterministic — but every *trigger* here is an ordinary function call the
|
|
# game makes from a frame handler, so a recorded/replayed run fires exactly the
|
|
# same sounds at exactly the same frames. Nothing in this file feeds the
|
|
# lockstep sim.
|
|
#
|
|
# Headless builds carry the whole API as no-ops: every native call is guarded by
|
|
# is_windowed(), which folds to a compile-time constant, so the snd_* intrinsics
|
|
# (and AVFoundation) are dead-stripped out of a headless binary. Audio.load then
|
|
# returns 0 and Audio.is_playing returns false, so the same game code runs in the
|
|
# test harness without an audio device.
|
|
# ============================================================================
|
|
|
|
const AUDIO_CAP: int = 64 # max simultaneously-loaded sounds
|
|
|
|
export state RtAudioState {
|
|
snd_ready: bool = false
|
|
snd_tab: pointers = null # handle (1-based) -> the backend's voice pointer
|
|
snd_master: fixed = 1.0 # master volume, applied to every play (and to audio_play_at's gain)
|
|
snd_rate: fixed = 1.0 # playback rate / pitch (1.0 = normal)
|
|
snd_music: int = 0 # the handle currently playing as music (0 = none)
|
|
snd_bank_name: pointers = null
|
|
snd_bank_id: words = null
|
|
snd_bank_n: int = 0
|
|
}
|
|
# the sound bank: sounds registered by name (Audio.define), played by name (Audio.play(name:))
|
|
|
|
function audio_init(rt_audio_st: mut RtAudioState) -> void {
|
|
if rt_audio_st.snd_ready { return }
|
|
rt_audio_st.snd_tab = pointers(AUDIO_CAP) # one 8-byte pointer slot per handle
|
|
fill(rt_audio_st.snd_tab, 0, AUDIO_CAP * 8) # malloc does not zero; empty slots must read null
|
|
rt_audio_st.snd_bank_name = pointers(AUDIO_CAP)
|
|
rt_audio_st.snd_bank_id = words(AUDIO_CAP)
|
|
rt_audio_st.snd_ready = true
|
|
}
|
|
|
|
# ---- the sound bank: Audio.define(name, path) then Audio.play(name: "…") ------
|
|
# Load a sound and remember it under a name; returns the handle (0 headless).
|
|
function audio_define(rt_audio_st: mut RtAudioState, name: pointer, path: pointer) -> int {
|
|
audio_init(rt_audio_st)
|
|
let id = audio_load(rt_audio_st, path)
|
|
if rt_audio_st.snd_bank_n < AUDIO_CAP {
|
|
rt_audio_st.snd_bank_name[rt_audio_st.snd_bank_n] = name
|
|
rt_audio_st.snd_bank_id[rt_audio_st.snd_bank_n] = id
|
|
rt_audio_st.snd_bank_n += 1
|
|
}
|
|
return id
|
|
}
|
|
# the handle registered under `name`, or 0
|
|
function audio_named(rt_audio_st: mut RtAudioState, name: pointer) -> int {
|
|
audio_init(rt_audio_st)
|
|
var i = 0
|
|
while i < rt_audio_st.snd_bank_n {
|
|
if rt_audio_st.snd_bank_name[i] == name { return rt_audio_st.snd_bank_id[i] }
|
|
i += 1
|
|
}
|
|
return 0
|
|
}
|
|
function audio_play_named(rt_audio_st: mut RtAudioState, name: pointer) -> void { audio_play(rt_audio_st, audio_named(rt_audio_st, name)) }
|
|
function audio_play_music_named(rt_audio_st: mut RtAudioState, name: pointer) -> void { audio_play_music(rt_audio_st, audio_named(rt_audio_st, name)) }
|
|
|
|
# Resolve a 1-based handle to its player pointer (null if out of range / empty).
|
|
function audio_get(rt_audio_st: mut RtAudioState, id: int) -> pointer {
|
|
audio_init(rt_audio_st)
|
|
if (id < 1) or (id > AUDIO_CAP) { return null }
|
|
return rt_audio_st.snd_tab[id - 1]
|
|
}
|
|
|
|
# Load a sound file and return its handle (>= 1), or 0 on failure / headless.
|
|
function audio_load(rt_audio_st: mut RtAudioState, path: pointer) -> int {
|
|
audio_init(rt_audio_st)
|
|
if not is_windowed() { return 0 }
|
|
var i = 0
|
|
while i < AUDIO_CAP {
|
|
if rt_audio_st.snd_tab[i] == null {
|
|
let p = snd_load(path)
|
|
if p == null { return 0 }
|
|
rt_audio_st.snd_tab[i] = p
|
|
return i + 1
|
|
}
|
|
i += 1
|
|
}
|
|
return 0
|
|
}
|
|
|
|
# Fire a one-shot sound from the start.
|
|
function audio_play(rt_audio_st: mut RtAudioState, id: int) -> void {
|
|
if not is_windowed() { return }
|
|
let p = audio_get(rt_audio_st, id)
|
|
if p == null { return }
|
|
snd_set_pan(p, 0.0)
|
|
snd_play(p, 0, rt_audio_st.snd_rate, rt_audio_st.snd_master)
|
|
}
|
|
|
|
# Fire a one-shot with its OWN gain, pitch and stereo position, leaving the master
|
|
# settings alone.
|
|
#
|
|
# Audio.volume and Audio.pitch are global: they exist to let a player turn the game down,
|
|
# and a game that used them to place a sound in the world would be fighting its own
|
|
# options screen. This is the per-voice version, and it is what distance attenuation is
|
|
# made of - a 3D game works out how far away and which side a sound is, and says so here.
|
|
#
|
|
# gain 0.0 .. 1.0, multiplied by the master volume, so the options screen still wins
|
|
# pitch 1.0 is as recorded; 0.5 an octave down, 2.0 an octave up
|
|
# pan -1.0 hard left .. 0.0 centred .. 1.0 hard right
|
|
#
|
|
# The one honest limitation: a sound is one player, so firing the same handle again
|
|
# restarts it rather than layering a second copy. Load a handle per variant when several
|
|
# need to overlap - which is what a game does anyway to stop a repeated sound machine-gunning.
|
|
function audio_play_at(rt_audio_st: mut RtAudioState, id: int, gain: fixed, pitch: fixed, pan: fixed) -> void {
|
|
if not is_windowed() { return }
|
|
let p = audio_get(rt_audio_st, id)
|
|
if p == null { return }
|
|
var g = gain
|
|
if g < 0.0 { g = 0.0 }
|
|
if g > 1.0 { g = 1.0 }
|
|
var pn = pan
|
|
if pn < -1.0 { pn = -1.0 }
|
|
if pn > 1.0 { pn = 1.0 }
|
|
var pt = pitch
|
|
if pt < 0.25 { pt = 0.25 } # the varispeed's own rate range
|
|
if pt > 4.0 { pt = 4.0 }
|
|
snd_set_pan(p, pn)
|
|
snd_play(p, 0, pt, g * rt_audio_st.snd_master)
|
|
}
|
|
|
|
# Play a sound as looping background music on the single music channel; any
|
|
# previous music is stopped first.
|
|
function audio_play_music(rt_audio_st: mut RtAudioState, id: int) -> void {
|
|
if not is_windowed() { return }
|
|
let p = audio_get(rt_audio_st, id)
|
|
if p == null { return }
|
|
audio_stop_music(rt_audio_st)
|
|
rt_audio_st.snd_music = id
|
|
snd_play(p, -1, rt_audio_st.snd_rate, rt_audio_st.snd_master)
|
|
}
|
|
|
|
# Stop one sound.
|
|
function audio_stop(rt_audio_st: mut RtAudioState, id: int) -> void {
|
|
if not is_windowed() { return }
|
|
let p = audio_get(rt_audio_st, id)
|
|
if p == null { return }
|
|
snd_stop(p)
|
|
if id == rt_audio_st.snd_music { rt_audio_st.snd_music = 0 }
|
|
}
|
|
|
|
# Stop the current music channel.
|
|
function audio_stop_music(rt_audio_st: mut RtAudioState) -> void {
|
|
if not is_windowed() { return }
|
|
if rt_audio_st.snd_music != 0 {
|
|
let p = audio_get(rt_audio_st, rt_audio_st.snd_music)
|
|
if p != null { snd_stop(p) }
|
|
rt_audio_st.snd_music = 0
|
|
}
|
|
}
|
|
|
|
# Stop every loaded sound.
|
|
function audio_stop_all(rt_audio_st: mut RtAudioState) -> void {
|
|
if not is_windowed() { return }
|
|
audio_init(rt_audio_st)
|
|
var i = 0
|
|
while i < AUDIO_CAP {
|
|
if rt_audio_st.snd_tab[i] != null { snd_stop(rt_audio_st.snd_tab[i]) }
|
|
i += 1
|
|
}
|
|
rt_audio_st.snd_music = 0
|
|
}
|
|
|
|
# Master volume (0.0 .. 1.0) — applied to every currently-loaded sound now and
|
|
# to every future play.
|
|
function audio_volume(rt_audio_st: mut RtAudioState, v: fixed) -> void {
|
|
rt_audio_st.snd_master = v
|
|
if not is_windowed() { return }
|
|
audio_init(rt_audio_st)
|
|
var i = 0
|
|
while i < AUDIO_CAP {
|
|
if rt_audio_st.snd_tab[i] != null { snd_set_volume(rt_audio_st.snd_tab[i], v) }
|
|
i += 1
|
|
}
|
|
}
|
|
|
|
# Playback rate / pitch (1.0 = normal, 0.5 = an octave down, 2.0 = up).
|
|
function audio_pitch(rt_audio_st: mut RtAudioState, v: fixed) -> void {
|
|
rt_audio_st.snd_rate = v
|
|
if not is_windowed() { return }
|
|
audio_init(rt_audio_st)
|
|
var i = 0
|
|
while i < AUDIO_CAP {
|
|
if rt_audio_st.snd_tab[i] != null { snd_set_rate(rt_audio_st.snd_tab[i], v) }
|
|
i += 1
|
|
}
|
|
}
|
|
|
|
# Is this sound currently playing?
|
|
function audio_is_playing(rt_audio_st: mut RtAudioState, id: int) -> bool {
|
|
if not is_windowed() { return false }
|
|
let p = audio_get(rt_audio_st, id)
|
|
if p == null { return false }
|
|
return snd_playing(p) != 0
|
|
}
|