// // BackgroundAudioKeeper.swift // Keging // // 운동 중 무음 오디오 루프 재생 — 앱을 나가도 타이머·진동·Live Activity 갱신이 유지되게 한다. // (UIBackgroundModes=audio, .mixWithOthers라 음악 재생을 방해하지 않음) // // 실기기에서 재생이 죽는 경로가 여럿이라(전화 인터럽션, 이어폰 연결 해제 같은 루트 변경, // 미디어 서버 리셋 — 이 경우 플레이어 객체 자체가 무효) 전부 감시해 되살리고, // 운동 중에는 WorkoutCoordinator의 하트비트가 ensureAlive()를 주기 호출해 이중 방어한다. // import AVFoundation import UIKit @MainActor final class BackgroundAudioKeeper { static let shared = BackgroundAudioKeeper() private var player: AVAudioPlayer? private var observers: [NSObjectProtocol] = [] /// 운동 중 여부 — stop() 후에 늦게 도착한 알림이 재생을 되살리는 것 방지 private var isActive = false private init() {} func start() { isActive = true activateSessionAndPlay() installObservers() } func stop() { isActive = false removeObservers() player?.stop() player = nil try? AVAudioSession.sharedInstance().setActive(false, options: [.notifyOthersOnDeactivation]) } /// 어떤 이유로든 재생이 죽었으면 되살린다 — 알림 옵저버·하트비트 공용 진입점 func ensureAlive() { guard isActive else { return } if let player, player.isPlaying { return } activateSessionAndPlay() } private func activateSessionAndPlay() { let session = AVAudioSession.sharedInstance() try? session.setCategory(.playback, mode: .default, options: [.mixWithOthers]) try? session.setActive(true) if player == nil { player = Self.makePlayer() } if player?.play() != true { // 미디어 서버 리셋 등으로 플레이어가 무효화됐으면 새로 만들어 재시도 player = Self.makePlayer() player?.play() } } private static func makePlayer() -> AVAudioPlayer? { let player = try? AVAudioPlayer(data: silenceWAV) player?.numberOfLoops = -1 // 0.0은 일부 환경에서 백그라운드 유지가 풀리는 사례가 있어 소량으로 — 샘플이 무음이라 들리지 않는다 player?.volume = 0.1 player?.prepareToPlay() return player } private func installObservers() { guard observers.isEmpty else { return } let center = NotificationCenter.default // 인터럽션(전화 등) 종료 → 재개 observers.append(center.addObserver( forName: AVAudioSession.interruptionNotification, object: nil, queue: .main ) { [weak self] note in guard let self, let info = note.userInfo, let rawType = info[AVAudioSessionInterruptionTypeKey] as? UInt, AVAudioSession.InterruptionType(rawValue: rawType) == .ended else { return } Task { @MainActor in self.ensureAlive() } }) // 이어폰 연결 해제 등 루트 변경 — 시스템이 재생을 멈출 수 있다 observers.append(center.addObserver( forName: AVAudioSession.routeChangeNotification, object: nil, queue: .main ) { [weak self] _ in guard let self else { return } Task { @MainActor in self.ensureAlive() } }) // 미디어 서버 리셋 — 플레이어·세션이 통째로 무효화되므로 전부 재구축 observers.append(center.addObserver( forName: AVAudioSession.mediaServicesWereResetNotification, object: nil, queue: .main ) { [weak self] _ in guard let self else { return } Task { @MainActor in self.player = nil self.ensureAlive() } }) for name in [UIApplication.didEnterBackgroundNotification, UIApplication.didBecomeActiveNotification] { observers.append(center.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in guard let self else { return } Task { @MainActor in self.ensureAlive() } }) } } private func removeObservers() { observers.forEach { NotificationCenter.default.removeObserver($0) } observers.removeAll() } /// 20초짜리 무음 WAV (8kHz 16bit mono) — 파일 없이 메모리에서 생성. /// 루프 경계 이벤트를 줄이기 위해 넉넉한 길이로 만든다 private static let silenceWAV: Data = { let sampleRate = 8000 let dataSize = sampleRate * 2 * 20 var d = Data() func append(_ s: String) { d.append(s.data(using: .ascii)!) } func appendU32(_ v: UInt32) { withUnsafeBytes(of: v.littleEndian) { d.append(contentsOf: $0) } } func appendU16(_ v: UInt16) { withUnsafeBytes(of: v.littleEndian) { d.append(contentsOf: $0) } } append("RIFF"); appendU32(UInt32(36 + dataSize)); append("WAVE") append("fmt "); appendU32(16); appendU16(1); appendU16(1) appendU32(UInt32(sampleRate)); appendU32(UInt32(sampleRate * 2)); appendU16(2); appendU16(16) append("data"); appendU32(UInt32(dataSize)) d.append(Data(count: dataSize)) return d }() }