// // HapticEnginePlayer.swift // Keging // // Core Haptics 정밀 패턴 재생 — 아이폰 전용·포그라운드 전용 (watchOS SDK엔 CoreHaptics 없음). // 시스템 바이브로는 불가능한 짧은 간격의 연타와 1초 이상의 연속 진동을 만든다. // // ⚠️엔진은 반드시 공유 AVAudioSession에 부착해 만든다(CHHapticEngine(audioSession:)). // 기본 생성하면 햅틱 엔진이 독자 오디오 정책으로 세션을 건드려, 운동 중 백그라운드 유지용 // 무음 루프가 끊길 수 있다(실기기 3차 사고 — 포그라운드에서 진동 몇 번 후 잠그면 DI 멈춤). // 엔진이 죽어 있으면 1회 재생성 후 재시도, 그래도 실패면 false(호출 측이 시스템 진동으로 폴백). // #if canImport(CoreHaptics) import AVFoundation import CoreHaptics @MainActor final class HapticEnginePlayer { static let shared = HapticEnginePlayer() private var engine: CHHapticEngine? private init() {} @discardableResult func play(_ pattern: HapticPattern) -> Bool { play(events: pattern.hapticEvents) } /// 완주 축하 진동 — 아주 긴 "우우우우웅" @discardableResult func playFinish() -> Bool { play(events: FinishHaptic.events) } private func play(events: [(time: Double, duration: Double, intensity: Float, sharpness: Float)]) -> Bool { guard CHHapticEngine.capabilitiesForHardware().supportsHaptics else { return false } ensureSession() if attempt(events) { return true } engine = nil return attempt(events) } /// 공유 세션을 재생 카테고리로 유지 — 운동 중엔 BackgroundAudioKeeper와 같은 설정이라 무해하고, /// 세팅의 '미리 느끼기'처럼 키퍼가 없는 상황에서도 엔진이 도는 기반이 된다 private func ensureSession() { let session = AVAudioSession.sharedInstance() if session.category != .playback { try? session.setCategory(.playback, mode: .default, options: [.mixWithOthers]) } try? session.setActive(true) } private func attempt(_ specs: [(time: Double, duration: Double, intensity: Float, sharpness: Float)]) -> Bool { do { let engine = try currentEngine() let events = specs.map { spec in CHHapticEvent( eventType: .hapticContinuous, parameters: [ CHHapticEventParameter(parameterID: .hapticIntensity, value: spec.intensity), CHHapticEventParameter(parameterID: .hapticSharpness, value: spec.sharpness), ], relativeTime: spec.time, duration: spec.duration ) } let player = try engine.makePlayer(with: try CHHapticPattern(events: events, parameters: [])) try engine.start() try player.start(atTime: CHHapticTimeImmediate) return true } catch { return false } } private func currentEngine() throws -> CHHapticEngine { if let engine { return engine } let newEngine = try CHHapticEngine(audioSession: AVAudioSession.sharedInstance()) newEngine.isAutoShutdownEnabled = true newEngine.resetHandler = { [weak self] in Task { @MainActor in self?.engine = nil } } engine = newEngine return newEngine } } #endif