// // 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 /// 백그라운드 전환 직후 ~30초 유예 태스크 — 전환 순간 오디오가 죽어도 되살릴 시간을 번다 private var bridgeTask: UIBackgroundTaskIdentifier = .invalid private init() {} func start() { isActive = true activateSessionAndPlay() installObservers() } func stop() { isActive = false removeObservers() endBridgeTask() 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() } }) observers.append(center.addObserver( forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: .main ) { [weak self] _ in guard let self else { return } Task { @MainActor in self.beginBridgeTask() self.ensureAlive() } }) observers.append(center.addObserver( forName: UIApplication.didBecomeActiveNotification, object: nil, queue: .main ) { [weak self] _ in guard let self else { return } Task { @MainActor in self.endBridgeTask() self.ensureAlive() } }) } private func removeObservers() { observers.forEach { NotificationCenter.default.removeObserver($0) } observers.removeAll() } private func beginBridgeTask() { guard isActive, bridgeTask == .invalid else { return } bridgeTask = UIApplication.shared.beginBackgroundTask(withName: "keging.audio.bridge") { [weak self] in Task { @MainActor in self?.endBridgeTask() } } } private func endBridgeTask() { guard bridgeTask != .invalid else { return } UIApplication.shared.endBackgroundTask(bridgeTask) bridgeTask = .invalid } /// 20초짜리 초저레벨 노이즈 WAV (8kHz 16bit mono, ±2 LSB ≈ -84dBFS) — 파일 없이 메모리에서 생성. /// ⚠️완전한 0 샘플은 시스템이 '실질 무음'으로 보고 백그라운드 앱을 정지시킬 여지가 있어 /// 들리지 않는 디더 노이즈를 깐다(volume 0.1을 곱하면 -100dB대 — 어떤 기기에서도 무음). /// 루프 경계 이벤트를 줄이기 위해 넉넉한 길이로 만든다 private static let silenceWAV: Data = { let sampleRate = 8000 let sampleCount = sampleRate * 20 let dataSize = sampleCount * 2 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)) var samples = Data(capacity: dataSize) for _ in 0..