Files
2026-10-06 19:34:32 +03:00

66 lines
2.3 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import AVFoundation
import AppKit
/// Preloaded WAV players with near-zero latency.
/// Volume default 0.12 (matches prototype: gain ×6 then vol=0.12).
@MainActor
final class SoundEngine {
static let shared = SoundEngine()
var enabled: Bool = true
var volume: Float = 0.12 {
didSet { players.values.forEach { $0.forEach { $0.volume = volume } } }
}
// Pool of 3 players per sound to allow overlapping playback
private var players: [String: [AVAudioPlayer]] = [:]
private init() {
preload()
}
private func preload() {
let names = ["peek","open","close","hover","blip","slap","annoyed","dizzy","greet",
"work","finish","error","approval","question","approve","gulp","tick",
"send","love","pop","proud","wink","yawn","attach","think","search",
"rate","sleep","greeting"]
for name in names {
guard let url = Bundle.main.url(forResource: name, withExtension: "wav", subdirectory: "sounds") else { continue }
var pool: [AVAudioPlayer] = []
for _ in 0..<3 {
if let p = try? AVAudioPlayer(contentsOf: url) {
p.volume = volume
p.prepareToPlay()
pool.append(p)
}
}
if !pool.isEmpty { players[name] = pool }
}
}
/// Fade out all currently-playing instances of `name` over `duration` seconds,
/// then stop and reset them so they can be reused.
func fadeOut(_ name: String, duration: TimeInterval) {
guard let pool = players[name] else { return }
for player in pool where player.isPlaying {
player.setVolume(0, fadeDuration: duration)
DispatchQueue.main.asyncAfter(deadline: .now() + duration) { [weak player] in
guard let p = player else { return }
p.stop()
p.currentTime = 0
p.volume = self.volume
}
}
}
func play(_ name: String) {
guard enabled && AppState.shared.soundEnabled else { return }
guard let pool = players[name] else { return }
// Find a player that is not currently playing
let player = pool.first { !$0.isPlaying } ?? pool[0]
player.currentTime = 0
player.volume = volume
player.play()
}
}