① 다이나믹 아일랜드 멈춤(2중 방어): - 오디오 키퍼 하드닝 — volume 0.1(0.0은 유지 풀림 사례)·prepareToPlay·인터럽션/씬 전환 revive() - 엔진을 벽시계 기준으로 재설계 — 전 국면이 sessionStart로부터 결정적(computeTarget), 타이머는 경계 재장전용, scenePhase .active에서 resyncAfterWake()로 정확한 국면 자가 교정 ② 진동 패턴 동일(톡=톡톡): 시스템 바이브는 재생 중 재호출이 무시됨(실측) — 시간 오프셋 방식 폐기, AudioServicesPlaySystemSoundWithCompletion 완료 체이닝으로 재작성 (톡 1방 / 톡톡 0.25s 쉬고 1방 / 드르륵 0.15s 3연타 / 지잉 즉시 이어붙인 3방) ③ 운동 칼로리 0: 빌더 운동은 에너지 샘플 직접 추가 필요 — iOS는 MET 추정(1.8+강도×0.17, 70kg 가정) activeEnergyBurned 샘플 add, 워치는 심박·활성 에너지 읽기 권한 추가로 센서 실측 수집, 권한 문구 3언어 갱신 검증: Debug·Release·워치 빌드 그린, 완주 경로 시뮬 재검증(엔진 재설계 후). DI 갱신·진동 구분·칼로리는 실기기 재확인 필요(재실행 시 '활성 에너지' 권한 시트 뜸) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UPmp29DQHY6Ahfss5SRopT
89 lines
3.5 KiB
Swift
89 lines
3.5 KiB
Swift
//
|
|
// BackgroundAudioKeeper.swift
|
|
// Keging
|
|
//
|
|
// 운동 중 무음 오디오 루프 재생 — 앱을 나가도 타이머·진동·Live Activity 갱신이 유지되게 한다.
|
|
// (UIBackgroundModes=audio, .mixWithOthers라 음악 재생을 방해하지 않음)
|
|
// 인터럽션(전화 등)·백그라운드 전환에서 재생이 끊기면 다시 살린다.
|
|
//
|
|
|
|
import AVFoundation
|
|
import UIKit
|
|
|
|
@MainActor
|
|
final class BackgroundAudioKeeper {
|
|
static let shared = BackgroundAudioKeeper()
|
|
|
|
private var player: AVAudioPlayer?
|
|
private var observers: [NSObjectProtocol] = []
|
|
|
|
private init() {}
|
|
|
|
func start() {
|
|
guard player == nil else { return }
|
|
let session = AVAudioSession.sharedInstance()
|
|
try? session.setCategory(.playback, mode: .default, options: [.mixWithOthers])
|
|
try? session.setActive(true)
|
|
player = try? AVAudioPlayer(data: Self.silenceWAV)
|
|
player?.numberOfLoops = -1
|
|
// 0.0은 일부 환경에서 백그라운드 유지가 풀리는 사례가 있어 소량으로 — 샘플이 무음이라 들리지 않는다
|
|
player?.volume = 0.1
|
|
player?.prepareToPlay()
|
|
player?.play()
|
|
installObservers()
|
|
}
|
|
|
|
func stop() {
|
|
removeObservers()
|
|
player?.stop()
|
|
player = nil
|
|
try? AVAudioSession.sharedInstance().setActive(false, options: [.notifyOthersOnDeactivation])
|
|
}
|
|
|
|
/// 재생이 죽었으면 되살린다 (인터럽션 종료·씬 전환 시)
|
|
private func revive() {
|
|
guard let player, !player.isPlaying else { return }
|
|
try? AVAudioSession.sharedInstance().setActive(true)
|
|
player.play()
|
|
}
|
|
|
|
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 info = note.userInfo,
|
|
let rawType = info[AVAudioSessionInterruptionTypeKey] as? UInt,
|
|
AVAudioSession.InterruptionType(rawValue: rawType) == .ended else { return }
|
|
Task { @MainActor in self?.revive() }
|
|
})
|
|
for name in [UIApplication.didEnterBackgroundNotification, UIApplication.didBecomeActiveNotification] {
|
|
observers.append(center.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in
|
|
Task { @MainActor in self?.revive() }
|
|
})
|
|
}
|
|
}
|
|
|
|
private func removeObservers() {
|
|
observers.forEach { NotificationCenter.default.removeObserver($0) }
|
|
observers.removeAll()
|
|
}
|
|
|
|
/// 1초짜리 무음 WAV (8kHz 16bit mono) — 파일 없이 메모리에서 생성
|
|
private static let silenceWAV: Data = {
|
|
let sampleRate = 8000
|
|
let dataSize = sampleRate * 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))
|
|
d.append(Data(count: dataSize))
|
|
return d
|
|
}()
|
|
}
|