Files
m3te/audio.sx
swipelab f0a13293bb P8.1: minimal match/clear SFX via iOS System Sound Services (sx FFI)
Feasibility spike outcome: iOS audio from sx is feasible with no sx-library
change. System Sound Services is plain C, reached with the same `#foreign`
FFI uikit.sx already uses (UIApplicationMain / dlsym / CACurrentMediaTime);
AudioToolbox + CoreFoundation are linked per-target in build.sx.

Smallest viable SFX: one short CC0 clip (Kenney Interface Sounds, CC0 1.0)
played when a swap clears a match. Purely additive — audio.sx reads/writes
no score/board/move state; the wiring in board_view only adds a call.

- audio.sx: load clear.wav once, AudioServicesPlaySystemSound on clear
- board_view.sx: trigger sfx_clear() on a legal swap that clears (>=1 round)
- main.sx: allocate + init g_audio at boot
- build.sx: link AudioToolbox + CoreFoundation on iOS
- assets/audio/clear.wav (+ one-line CC0 credit in LICENSE.txt)

Verified: ios-sim build links; 18/18 tests pass; sim boot log shows
"[sx] audio: clear cue loaded" (AudioServicesCreateSystemSoundID succeeded,
asset shipped in the bundle and decoded).
2026-06-05 18:19:33 +03:00

89 lines
3.4 KiB
Plaintext

// iOS sound effect via AudioToolbox System Sound Services (P8.1).
//
// A purely additive layer: it never reads or mutates score / board / move
// state — board_view tells it a match cleared and it plays one short clip.
//
// System Sound Services is plain C, so it is reached with sx's `#foreign` FFI
// exactly as uikit.sx reaches UIApplicationMain / dlsym / CACurrentMediaTime —
// no sx-library change. The AudioToolbox + CoreFoundation frameworks are linked
// per-target in build.sx. Every call is guarded by `inline if OS == .ios`, so
// other targets never reference these symbols nor need the frameworks.
#import "modules/std.sx";
#import "modules/std/objc.sx";
#import "modules/compiler.sx";
// AudioToolbox — System Sound Services. SystemSoundID is a UInt32; OSStatus a
// SInt32 (0 == noErr); the clip's file is passed as a CFURLRef (opaque ptr).
AudioServicesCreateSystemSoundID :: (url: *void, out_id: *u32) -> s32 #foreign;
AudioServicesPlaySystemSound :: (sound_id: u32) #foreign;
// CoreFoundation — build a file CFURL from an absolute path. `len` is a CFIndex
// (long); `is_dir` a Boolean (unsigned char); a NULL allocator = default.
CFURLCreateFromFileSystemRepresentation :: (allocator: *void, buffer: *u8, len: s64, is_dir: s8) -> *void #foreign;
CFRelease :: (cf: *void) #foreign;
// libc — getcwd to absolutize the bundle-relative asset path. The platform
// chdir's to the bundle's resource dir at boot, so CWD is the .app and the
// game's other relative `assets/...` loads already resolve against it.
getcwd :: (buf: *u8, size: usize) -> *u8 #foreign;
c_strlen :: (s: *u8) -> usize #foreign "strlen";
// Loaded once at startup; `play` is then a single C call per cleared match.
GameAudio :: struct {
clear_id: u32;
loaded: bool;
init :: (self: *GameAudio) {
self.clear_id = 0;
self.loaded = false;
inline if OS != .ios { return; }
self.clear_id = load_system_sound("clear.wav");
self.loaded = self.clear_id != 0;
if self.loaded { NSLog(xx "[sx] audio: clear cue loaded"); }
else { NSLog(xx "[sx] audio: load failed"); }
}
play_clear :: (self: *GameAudio) {
inline if OS != .ios { return; }
if !self.loaded { return; }
AudioServicesPlaySystemSound(self.clear_id);
NSLog(xx "[sx] sfx clear");
}
}
// Create a SystemSoundID for `assets/audio/<name>` (relative to the bundle).
// Returns 0 on any failure, which `play_clear` treats as "skip".
load_system_sound :: (name: string) -> u32 {
inline if OS != .ios { return 0; }
cwd_buf : [1024]u8 = ---;
if getcwd(@cwd_buf[0], 1024) == null { return 0; }
cwd : string = ---;
cwd.ptr = @cwd_buf[0];
cwd.len = cast(s64) c_strlen(@cwd_buf[0]);
// CFURLCreateFromFileSystemRepresentation takes an explicit byte length, so
// the formatted path needs no NUL terminator.
path := format("{}/assets/audio/{}", cwd, name);
url := CFURLCreateFromFileSystemRepresentation(null, path.ptr, path.len, 0);
if url == null { return 0; }
sound_id : u32 = 0;
status := AudioServicesCreateSystemSoundID(url, @sound_id);
CFRelease(url);
if status != 0 { return 0; }
sound_id
}
// The process-wide instance. main() allocates + inits it; board_view triggers
// the cue through `sfx_clear`. Null until init, so `sfx_clear` is a safe no-op
// before then.
g_audio : *GameAudio = null;
sfx_clear :: () {
if g_audio != null { g_audio.play_clear(); }
}