53b4f60da1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
66 lines
2.3 KiB
Swift
66 lines
2.3 KiB
Swift
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()
|
||
}
|
||
}
|