// iOS sound-effect bank via AudioToolbox System Sound Services (P10.2). // // A purely additive layer: it never reads or mutates score / board / move // state — board_view tells it an event happened 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/ffi/objc.sx"; #import "modules/build.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) -> i32 #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: i64, is_dir: i8) -> *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"; // The cascade clips combo1..combo5, in ascending pitch. `combo_ids` is indexed // by `cascade_cue_index`. COMBO_CLIPS :: 5; // The whole bank, each cue loaded once at startup into its own SystemSoundID; // every `play_*` is then a single C call. `loaded` is true only when every cue // loaded, so a partial/failed bank mutes rather than playing a 0 id. GameAudio :: struct { swap_id: u32; match_id: u32; combo_ids: [COMBO_CLIPS]u32; win_id: u32; lose_id: u32; loaded: bool; init :: (self: *GameAudio) { self.loaded = false; inline if OS != .ios { return; } self.swap_id = load_cue("swap.wav", "[sx] audio: loaded swap", "[sx] audio: load failed swap"); self.match_id = load_cue("match.wav", "[sx] audio: loaded match", "[sx] audio: load failed match"); self.combo_ids[0] = load_cue("combo1.wav", "[sx] audio: loaded combo1", "[sx] audio: load failed combo1"); self.combo_ids[1] = load_cue("combo2.wav", "[sx] audio: loaded combo2", "[sx] audio: load failed combo2"); self.combo_ids[2] = load_cue("combo3.wav", "[sx] audio: loaded combo3", "[sx] audio: load failed combo3"); self.combo_ids[3] = load_cue("combo4.wav", "[sx] audio: loaded combo4", "[sx] audio: load failed combo4"); self.combo_ids[4] = load_cue("combo5.wav", "[sx] audio: loaded combo5", "[sx] audio: load failed combo5"); self.win_id = load_cue("win.wav", "[sx] audio: loaded win", "[sx] audio: load failed win"); self.lose_id = load_cue("lose.wav", "[sx] audio: loaded lose", "[sx] audio: load failed lose"); self.loaded = self.swap_id != 0 and self.match_id != 0 and self.combo_ids[0] != 0 and self.combo_ids[1] != 0 and self.combo_ids[2] != 0 and self.combo_ids[3] != 0 and self.combo_ids[4] != 0 and self.win_id != 0 and self.lose_id != 0; } play_swap :: (self: *GameAudio) { inline if OS != .ios { return; } if !self.loaded { return; } NSLog(xx "[sx] audio: cue swap"); AudioServicesPlaySystemSound(self.swap_id); } play_match :: (self: *GameAudio) { inline if OS != .ios { return; } if !self.loaded { return; } NSLog(xx "[sx] audio: cue match"); AudioServicesPlaySystemSound(self.match_id); } // Pick the ascending cascade clip by clamping the cascade depth into the // combo1..combo5 range (see `cascade_cue_index`). play_cascade :: (self: *GameAudio, depth: i64) { inline if OS != .ios { return; } if !self.loaded { return; } idx := cascade_cue_index(depth); NSLog(xx cascade_cue_name(idx)); AudioServicesPlaySystemSound(self.combo_ids[idx]); } play_win :: (self: *GameAudio) { inline if OS != .ios { return; } if !self.loaded { return; } NSLog(xx "[sx] audio: cue win"); AudioServicesPlaySystemSound(self.win_id); } play_lose :: (self: *GameAudio) { inline if OS != .ios { return; } if !self.loaded { return; } NSLog(xx "[sx] audio: cue lose"); AudioServicesPlaySystemSound(self.lose_id); } } // The cascade cue's log line, one stable literal per combo clip so the play-time // `log show` shows the clip stepping up with cascade depth. Literals only — the // string→NSString bridge needs NUL-terminated bytes (a formatted string may not // be). `idx` is a clamped `cascade_cue_index`, so it is always 0..COMBO_CLIPS-1. cascade_cue_name :: (idx: i64) -> string { if idx <= 0 { return "[sx] audio: cue combo1"; } if idx == 1 { return "[sx] audio: cue combo2"; } if idx == 2 { return "[sx] audio: cue combo3"; } if idx == 3 { return "[sx] audio: cue combo4"; } "[sx] audio: cue combo5" } // Cascade depth (number of cleared rounds) → combo clip index 0..COMBO_CLIPS-1 // (combo1..combo5). Clamps: depth <= 1 → 0, depth >= 5 → 4. Pure arithmetic and // OS-agnostic so it can be snapshot-tested headlessly (P10.4). cascade_cue_index :: (depth: i64) -> i64 { if depth <= 1 { return 0; } if depth >= COMBO_CLIPS { return COMBO_CLIPS - 1; } depth - 1 } // Load one cue into a SystemSoundID and log the outcome. `loaded_msg` / // `failed_msg` are string literals (NUL-terminated, so safe to bridge to // NSString) naming the cue. Returns 0 on any failure, which the play methods // treat as "skip" via the `loaded` gate. load_cue :: (name: string, loaded_msg: string, failed_msg: string) -> u32 { inline if OS != .ios { return 0; } id := load_system_sound(name); if id != 0 { NSLog(xx loaded_msg); } else { NSLog(xx failed_msg); } return id; } // Create a SystemSoundID for `assets/audio/` (relative to the bundle). // Returns 0 on any failure. 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(i64) 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 swap/match/cascade cues through the `sfx_*` shims on a committed gesture, // and main's frame loop fires the win/lose stinger edge-triggered. Null until // init, so every shim is a safe no-op before then. g_audio : *GameAudio = null; sfx_swap :: () { if g_audio != null { g_audio.play_swap(); } } sfx_match :: () { if g_audio != null { g_audio.play_match(); } } sfx_cascade :: (depth: i64) { if g_audio != null { g_audio.play_cascade(depth); } } sfx_win :: () { if g_audio != null { g_audio.play_win(); } } sfx_lose :: () { if g_audio != null { g_audio.play_lose(); } }