feat(audio): Audio.* standard library over a native AVAudioPlayer backend (#22)
Adds the Audio.* namespace and its platform backend, the audio subsystem #22 was blocked on. - runtime/native/audio.ll: the macOS backend, AVAudioPlayer driven through the objc runtime C ABI (no ObjC/C source), same style as cocoa.ll — snd_load / play / stop / playing / set_volume / set_rate. Spliced and linked with AVFoundation only when a windowed build actually uses Audio.* (needed_framework, since AVAudioPlayer is reached by name). - runtime/native/audio.ludic: the Audio.* runtime — a handle table, master volume/pitch, a single music channel. load/play/play_sound/play_music/stop/ stop_music/stop_all/volume/pitch/is_playing. Every native call is is_windowed()-guarded, so a headless build carries the API as no-ops (load returns 0, is_playing false) and needs no audio device. - compiler: Audio.* namespace dispatch, g_uses_audio splice, snd_* intrinsics + declarations, and the conditional AVFoundation link in both the canonical (main.ludic) and dev-runner (x app) paths. - docs: a full docs/language/audio section (10 method pages); check-impl green. - test: examples/library/audio.ludic self-asserts the headless no-op path. Playback is out-of-band and never feeds the deterministic sim, but triggers are frame-driven so replays fire the same sounds. Reseeded; suites green (80 + 29). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
d6ca320269
commit
4eef5ebbce
23 changed files with 22294 additions and 21004 deletions
152
runtime/native/audio.ll
Normal file
152
runtime/native/audio.ll
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
; ============================================================================
|
||||
; audio.ll — the macOS audio backend, written in LLVM IR.
|
||||
;
|
||||
; Like cocoa.ll, this compiles no Objective-C and no C: it drives AVAudioPlayer
|
||||
; through the Objective-C runtime's C ABI (objc_getClass / sel_registerName /
|
||||
; objc_msgSend). ludicc splices it in — alongside cocoa.ll — only when a
|
||||
; windowed build actually uses Audio.* (g_uses_audio), and links AVFoundation
|
||||
; then. A sound is loaded once into an AVAudioPlayer and the opaque player
|
||||
; pointer is handed back to the Ludic side (audio.ludic), which keeps the handle
|
||||
; table; these functions just operate on one player at a time.
|
||||
;
|
||||
; Exposed to Ludic (through the snd_* intrinsics, all is_windowed()-guarded so
|
||||
; they vanish from a headless build):
|
||||
; snd_load(path) -> player snd_play(p, loops, rate, vol)
|
||||
; snd_stop(p) snd_playing(p) -> bool
|
||||
; snd_set_volume(p, vol) snd_set_rate(p, rate)
|
||||
; loops: 0 = once, -1 = forever. rate / vol are 16.16 fixed (value/65536).
|
||||
; ============================================================================
|
||||
|
||||
declare ptr @objc_getClass(ptr)
|
||||
declare ptr @sel_registerName(ptr)
|
||||
declare ptr @objc_msgSend(ptr, ptr, ...)
|
||||
|
||||
@.a_avp = private unnamed_addr constant [14 x i8] c"AVAudioPlayer\00"
|
||||
@.a_nsstr = private unnamed_addr constant [9 x i8] c"NSString\00"
|
||||
@.a_nsurl = private unnamed_addr constant [6 x i8] c"NSURL\00"
|
||||
@.a_utf8 = private unnamed_addr constant [22 x i8] c"stringWithUTF8String:\00"
|
||||
@.a_furl = private unnamed_addr constant [17 x i8] c"fileURLWithPath:\00"
|
||||
@.a_alloc = private unnamed_addr constant [6 x i8] c"alloc\00"
|
||||
@.a_init = private unnamed_addr constant [29 x i8] c"initWithContentsOfURL:error:\00"
|
||||
@.a_enrat = private unnamed_addr constant [15 x i8] c"setEnableRate:\00"
|
||||
@.a_prep = private unnamed_addr constant [14 x i8] c"prepareToPlay\00"
|
||||
@.a_svol = private unnamed_addr constant [11 x i8] c"setVolume:\00"
|
||||
@.a_srate = private unnamed_addr constant [9 x i8] c"setRate:\00"
|
||||
@.a_sloop = private unnamed_addr constant [18 x i8] c"setNumberOfLoops:\00"
|
||||
@.a_sctime= private unnamed_addr constant [16 x i8] c"setCurrentTime:\00"
|
||||
@.a_play = private unnamed_addr constant [5 x i8] c"play\00"
|
||||
@.a_stop = private unnamed_addr constant [5 x i8] c"stop\00"
|
||||
@.a_isply = private unnamed_addr constant [10 x i8] c"isPlaying\00"
|
||||
|
||||
; Load a sound file into an AVAudioPlayer; returns the player (or null).
|
||||
define ptr @snd_load(ptr %path) {
|
||||
entry:
|
||||
%strcls = call ptr @objc_getClass(ptr @.a_nsstr)
|
||||
%sel_u = call ptr @sel_registerName(ptr @.a_utf8)
|
||||
%s = call ptr (ptr, ptr, ptr) @objc_msgSend(ptr %strcls, ptr %sel_u, ptr %path)
|
||||
%urlcls = call ptr @objc_getClass(ptr @.a_nsurl)
|
||||
%sel_f = call ptr @sel_registerName(ptr @.a_furl)
|
||||
%url = call ptr (ptr, ptr, ptr) @objc_msgSend(ptr %urlcls, ptr %sel_f, ptr %s)
|
||||
%avpcls = call ptr @objc_getClass(ptr @.a_avp)
|
||||
%nocls = icmp eq ptr %avpcls, null
|
||||
br i1 %nocls, label %fail, label %alloc
|
||||
alloc:
|
||||
%sel_a = call ptr @sel_registerName(ptr @.a_alloc)
|
||||
%p0 = call ptr (ptr, ptr) @objc_msgSend(ptr %avpcls, ptr %sel_a)
|
||||
%sel_i = call ptr @sel_registerName(ptr @.a_init)
|
||||
; initWithContentsOfURL:error: — pass NULL for the NSError** out-parameter.
|
||||
%p = call ptr (ptr, ptr, ptr, ptr) @objc_msgSend(ptr %p0, ptr %sel_i, ptr %url, ptr null)
|
||||
%nop = icmp eq ptr %p, null
|
||||
br i1 %nop, label %fail, label %ready
|
||||
ready:
|
||||
; enable rate control so setRate: takes effect, and pre-buffer.
|
||||
%sel_er = call ptr @sel_registerName(ptr @.a_enrat)
|
||||
%e = call ptr (ptr, ptr, i8) @objc_msgSend(ptr %p, ptr %sel_er, i8 1)
|
||||
%sel_pr = call ptr @sel_registerName(ptr @.a_prep)
|
||||
%pr = call ptr (ptr, ptr) @objc_msgSend(ptr %p, ptr %sel_pr)
|
||||
ret ptr %p
|
||||
fail:
|
||||
ret ptr null
|
||||
}
|
||||
|
||||
; Rewind to the start and play, applying loops / rate / volume.
|
||||
define void @snd_play(ptr %p, i32 %loops, i32 %rate, i32 %vol) {
|
||||
entry:
|
||||
%nil = icmp eq ptr %p, null
|
||||
br i1 %nil, label %out, label %go
|
||||
go:
|
||||
%vf = sitofp i32 %vol to float
|
||||
%v = fdiv float %vf, 6.5536e+04
|
||||
%sel_v = call ptr @sel_registerName(ptr @.a_svol)
|
||||
%rv = call ptr (ptr, ptr, float) @objc_msgSend(ptr %p, ptr %sel_v, float %v)
|
||||
%rf = sitofp i32 %rate to float
|
||||
%r = fdiv float %rf, 6.5536e+04
|
||||
%sel_r = call ptr @sel_registerName(ptr @.a_srate)
|
||||
%rr = call ptr (ptr, ptr, float) @objc_msgSend(ptr %p, ptr %sel_r, float %r)
|
||||
%l64 = sext i32 %loops to i64
|
||||
%sel_l = call ptr @sel_registerName(ptr @.a_sloop)
|
||||
%rl = call ptr (ptr, ptr, i64) @objc_msgSend(ptr %p, ptr %sel_l, i64 %l64)
|
||||
%sel_ct = call ptr @sel_registerName(ptr @.a_sctime)
|
||||
%rc = call ptr (ptr, ptr, double) @objc_msgSend(ptr %p, ptr %sel_ct, double 0.0)
|
||||
%sel_p = call ptr @sel_registerName(ptr @.a_play)
|
||||
%rp = call ptr (ptr, ptr) @objc_msgSend(ptr %p, ptr %sel_p)
|
||||
br label %out
|
||||
out:
|
||||
ret void
|
||||
}
|
||||
|
||||
define void @snd_stop(ptr %p) {
|
||||
entry:
|
||||
%nil = icmp eq ptr %p, null
|
||||
br i1 %nil, label %out, label %go
|
||||
go:
|
||||
%sel_s = call ptr @sel_registerName(ptr @.a_stop)
|
||||
%rs = call ptr (ptr, ptr) @objc_msgSend(ptr %p, ptr %sel_s)
|
||||
%sel_ct = call ptr @sel_registerName(ptr @.a_sctime)
|
||||
%rc = call ptr (ptr, ptr, double) @objc_msgSend(ptr %p, ptr %sel_ct, double 0.0)
|
||||
br label %out
|
||||
out:
|
||||
ret void
|
||||
}
|
||||
|
||||
define i32 @snd_playing(ptr %p) {
|
||||
entry:
|
||||
%nil = icmp eq ptr %p, null
|
||||
br i1 %nil, label %zero, label %ask
|
||||
ask:
|
||||
%sel_ip = call ptr @sel_registerName(ptr @.a_isply)
|
||||
%b = call i8 (ptr, ptr) @objc_msgSend(ptr %p, ptr %sel_ip)
|
||||
%bi = zext i8 %b to i32
|
||||
%r = and i32 %bi, 1
|
||||
ret i32 %r
|
||||
zero:
|
||||
ret i32 0
|
||||
}
|
||||
|
||||
define void @snd_set_volume(ptr %p, i32 %vol) {
|
||||
entry:
|
||||
%nil = icmp eq ptr %p, null
|
||||
br i1 %nil, label %out, label %go
|
||||
go:
|
||||
%vf = sitofp i32 %vol to float
|
||||
%v = fdiv float %vf, 6.5536e+04
|
||||
%sel_v = call ptr @sel_registerName(ptr @.a_svol)
|
||||
%rv = call ptr (ptr, ptr, float) @objc_msgSend(ptr %p, ptr %sel_v, float %v)
|
||||
br label %out
|
||||
out:
|
||||
ret void
|
||||
}
|
||||
|
||||
define void @snd_set_rate(ptr %p, i32 %rate) {
|
||||
entry:
|
||||
%nil = icmp eq ptr %p, null
|
||||
br i1 %nil, label %out, label %go
|
||||
go:
|
||||
%rf = sitofp i32 %rate to float
|
||||
%r = fdiv float %rf, 6.5536e+04
|
||||
%sel_r = call ptr @sel_registerName(ptr @.a_srate)
|
||||
%rr = call ptr (ptr, ptr, float) @objc_msgSend(ptr %p, ptr %sel_r, float %r)
|
||||
br label %out
|
||||
out:
|
||||
ret void
|
||||
}
|
||||
140
runtime/native/audio.ludic
Normal file
140
runtime/native/audio.ludic
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
# ============================================================================
|
||||
# audio.ludic — the Audio.* standard library (#22).
|
||||
#
|
||||
# A small, game-shaped sound API over the platform audio backend (audio.ll,
|
||||
# AVAudioPlayer 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
|
||||
|
||||
var snd_ready: bool = false
|
||||
var snd_tab: pointers = null # handle (1-based) -> AVAudioPlayer pointer
|
||||
var snd_master: fixed = 1.0 # master volume, applied to every play
|
||||
var snd_rate: fixed = 1.0 # playback rate / pitch (1.0 = normal)
|
||||
var snd_music: int = 0 # the handle currently playing as music (0 = none)
|
||||
|
||||
function audio_init() -> void {
|
||||
if snd_ready { return }
|
||||
snd_tab = bytes(AUDIO_CAP * 8) # one 8-byte pointer slot per handle
|
||||
fill(snd_tab, 0, AUDIO_CAP * 8) # malloc does not zero; empty slots must read null
|
||||
snd_ready = true
|
||||
}
|
||||
|
||||
# Resolve a 1-based handle to its player pointer (null if out of range / empty).
|
||||
function audio_get(id: int) -> pointer {
|
||||
audio_init()
|
||||
if (id < 1) or (id > AUDIO_CAP) { return null }
|
||||
return snd_tab[id - 1]
|
||||
}
|
||||
|
||||
# Load a sound file and return its handle (>= 1), or 0 on failure / headless.
|
||||
function audio_load(path: pointer) -> int {
|
||||
audio_init()
|
||||
if not is_windowed() { return 0 }
|
||||
var i = 0
|
||||
while i < AUDIO_CAP {
|
||||
if snd_tab[i] == null {
|
||||
let p = snd_load(path)
|
||||
if p == null { return 0 }
|
||||
snd_tab[i] = p
|
||||
return i + 1
|
||||
}
|
||||
i = i + 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
# Fire a one-shot sound from the start.
|
||||
function audio_play(id: int) -> void {
|
||||
if not is_windowed() { return }
|
||||
let p = audio_get(id)
|
||||
if p == null { return }
|
||||
snd_play(p, 0, snd_rate, snd_master)
|
||||
}
|
||||
|
||||
# Play a sound as looping background music on the single music channel; any
|
||||
# previous music is stopped first.
|
||||
function audio_play_music(id: int) -> void {
|
||||
if not is_windowed() { return }
|
||||
let p = audio_get(id)
|
||||
if p == null { return }
|
||||
audio_stop_music()
|
||||
snd_music = id
|
||||
snd_play(p, -1, snd_rate, snd_master)
|
||||
}
|
||||
|
||||
# Stop one sound.
|
||||
function audio_stop(id: int) -> void {
|
||||
if not is_windowed() { return }
|
||||
let p = audio_get(id)
|
||||
if p == null { return }
|
||||
snd_stop(p)
|
||||
if id == snd_music { snd_music = 0 }
|
||||
}
|
||||
|
||||
# Stop the current music channel.
|
||||
function audio_stop_music() -> void {
|
||||
if not is_windowed() { return }
|
||||
if snd_music != 0 {
|
||||
let p = audio_get(snd_music)
|
||||
if p != null { snd_stop(p) }
|
||||
snd_music = 0
|
||||
}
|
||||
}
|
||||
|
||||
# Stop every loaded sound.
|
||||
function audio_stop_all() -> void {
|
||||
if not is_windowed() { return }
|
||||
audio_init()
|
||||
var i = 0
|
||||
while i < AUDIO_CAP {
|
||||
if snd_tab[i] != null { snd_stop(snd_tab[i]) }
|
||||
i = i + 1
|
||||
}
|
||||
snd_music = 0
|
||||
}
|
||||
|
||||
# Master volume (0.0 .. 1.0) — applied to every currently-loaded sound now and
|
||||
# to every future play.
|
||||
function audio_volume(v: fixed) -> void {
|
||||
snd_master = v
|
||||
if not is_windowed() { return }
|
||||
audio_init()
|
||||
var i = 0
|
||||
while i < AUDIO_CAP {
|
||||
if snd_tab[i] != null { snd_set_volume(snd_tab[i], v) }
|
||||
i = i + 1
|
||||
}
|
||||
}
|
||||
|
||||
# Playback rate / pitch (1.0 = normal, 0.5 = an octave down, 2.0 = up).
|
||||
function audio_pitch(v: fixed) -> void {
|
||||
snd_rate = v
|
||||
if not is_windowed() { return }
|
||||
audio_init()
|
||||
var i = 0
|
||||
while i < AUDIO_CAP {
|
||||
if snd_tab[i] != null { snd_set_rate(snd_tab[i], v) }
|
||||
i = i + 1
|
||||
}
|
||||
}
|
||||
|
||||
# Is this sound currently playing?
|
||||
function audio_is_playing(id: int) -> bool {
|
||||
if not is_windowed() { return false }
|
||||
let p = audio_get(id)
|
||||
if p == null { return false }
|
||||
return snd_playing(p) != 0
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue