- ActivityKit엔 미래 상태 예약 API가 없음을 확인(푸시 서버 없이는 국면 갱신이 앱 런타임 의존) → 상태에 세트 전체 일정을 실어 시스템 렌더 요소로 심장을 재구성: 국면 색 타임라인 바(하드 스톱 그라데이션) + 실시간 플레이헤드 ProgressView(timerInterval:) + 전체 남은 시간. 플레이헤드가 걸친 색 = 지금 국면 - 국면 라벨·횟수는 앱 갱신 유지(syncCurrent 단일 진입점), stale 시 흐림 - 오디오 키퍼: 완전 무음 대신 ±2 LSB 디더 노이즈(무음 감지 정지 회피) + 백그라운드 전환 ~30초 브리지 태스크 - 통계 타임라인 점별 시각 라벨 제거(겹침 — 시각은 오늘 기록 목록에서) - 도움말·번역 갱신, CLAUDE.md 현행화. 시뮬: DI 백그라운드 80초+(10/20) 진행 확인 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UPmp29DQHY6Ahfss5SRopT
169 lines
6.8 KiB
Swift
169 lines
6.8 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
|
||
/// 백그라운드 전환 직후 ~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..<sampleCount {
|
||
let v = Int16.random(in: -2...2)
|
||
withUnsafeBytes(of: v.littleEndian) { samples.append(contentsOf: $0) }
|
||
}
|
||
d.append(samples)
|
||
return d
|
||
}()
|
||
}
|