mycode/myApp/Keging/IOS/BackgroundAudioKeeper.swift
songyc macbook 8f634c83f4 fix(keging): 실기기 3차 피드백 6건 — DI 멈춤 근본 원인(햅틱 엔진 세션 충돌) 제거·워치 세팅 pull 동기화·완료 진동·하루 타임라인·칼로리 정합
- 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
2026-09-02 14:30:08 +09:00

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
}()
}