- CHHapticEngine을 공유 AVAudioSession에 부착(기본 생성 시 독자 오디오 정책이 백그라운드 무음 루프를 끊던 것이 DI 멈춤 원인) + 오디오 키퍼 하드닝(루트 변경· 미디어 서버 리셋·ensureAlive) + 운동 중 2초 하트비트(오디오·엔진·LA 자가 치유) - Live Activity: 실제 phaseStart 사용·동일 상태 no-op·staleDate 8초·isStale 흐림 - 워치 세팅 동기화 3중화: 변경 push + reachability 재푸시 + 워치 앱 열릴 때 sendMessage pull - 완주 진동 신설: 폰 2.4초 연속(백그라운드 6방 체인, 오디오 정지 4초 지연), 워치 retry×2+success - 통계 하루 탭 맨 위 '오늘의 타임라인'(24시간 축 점 그래프, 시각 라벨·지금 룰) - 폰 칼로리 공식을 애플워치 '기타' 운동 규칙(빠르게 걷기 상당)에 정합: MET 4.0+강도×0.15 - 도움말 4건 갱신·en/ja 번역·CFBundleName 보충, CLAUDE.md 현행화 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UPmp29DQHY6Ahfss5SRopT
133 lines
5.3 KiB
Swift
133 lines
5.3 KiB
Swift
//
|
|
// 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
|
|
}()
|
|
}
|