|
| 1 | +import AVFoundation |
| 2 | +import Combine |
| 3 | +import Foundation |
| 4 | +import Shared |
| 5 | + |
| 6 | +// MARK: - Ambient Audio Detector |
| 7 | + |
| 8 | +/// Detects ambient audio levels using the device microphone |
| 9 | +@MainActor |
| 10 | +public final class AmbientAudioDetector: ObservableObject { |
| 11 | + // MARK: - Singleton |
| 12 | + |
| 13 | + public static let shared = AmbientAudioDetector() |
| 14 | + |
| 15 | + // MARK: - Published State |
| 16 | + |
| 17 | + /// Whether detection is currently active |
| 18 | + @Published public private(set) var isActive: Bool = false |
| 19 | + |
| 20 | + /// Current ambient audio level in decibels (normalized 0-1) |
| 21 | + @Published public private(set) var audioLevel: Float = 0 |
| 22 | + |
| 23 | + /// Current audio level in dB |
| 24 | + @Published public private(set) var audioLevelDB: Float = -160 |
| 25 | + |
| 26 | + /// Whether loud audio is currently detected |
| 27 | + @Published public private(set) var loudAudioDetected: Bool = false |
| 28 | + |
| 29 | + /// Microphone authorization status |
| 30 | + @Published public private(set) var authorizationStatus: AVAudioSession.RecordPermission = .undetermined |
| 31 | + |
| 32 | + /// Error message if detection failed |
| 33 | + @Published public private(set) var errorMessage: String? |
| 34 | + |
| 35 | + // MARK: - Callbacks |
| 36 | + |
| 37 | + /// Called when loud audio is detected |
| 38 | + public var onLoudAudioDetected: (() -> Void)? |
| 39 | + |
| 40 | + /// Called when audio level crosses threshold |
| 41 | + public var onThresholdCrossed: ((Bool) -> Void)? |
| 42 | + |
| 43 | + // MARK: - Private |
| 44 | + |
| 45 | + private var settings: KioskSettings { KioskModeManager.shared.settings } |
| 46 | + private var audioRecorder: AVAudioRecorder? |
| 47 | + private var meteringTimer: Timer? |
| 48 | + |
| 49 | + // Detection settings |
| 50 | + private let sampleInterval: TimeInterval = 0.1 // 100ms |
| 51 | + private var loudThresholdDB: Float = -20 // Adjustable |
| 52 | + private var quietThresholdDB: Float = -50 |
| 53 | + private var consecutiveLoudSamples: Int = 0 |
| 54 | + private let loudSampleThreshold: Int = 3 // Samples needed to confirm loud |
| 55 | + |
| 56 | + // MARK: - Initialization |
| 57 | + |
| 58 | + private init() { |
| 59 | + checkAuthorizationStatus() |
| 60 | + } |
| 61 | + |
| 62 | + deinit { |
| 63 | + audioRecorder?.stop() |
| 64 | + audioRecorder = nil |
| 65 | + meteringTimer?.invalidate() |
| 66 | + meteringTimer = nil |
| 67 | + } |
| 68 | + |
| 69 | + // MARK: - Public Methods |
| 70 | + |
| 71 | + /// Start ambient audio detection |
| 72 | + public func start() { |
| 73 | + guard !isActive else { return } |
| 74 | + guard authorizationStatus == .granted else { |
| 75 | + Current.Log.warning("Microphone not authorized for ambient detection") |
| 76 | + return |
| 77 | + } |
| 78 | + |
| 79 | + Current.Log.info("Starting ambient audio detection") |
| 80 | + |
| 81 | + setupAudioRecorder() |
| 82 | + startMetering() |
| 83 | + isActive = true |
| 84 | + errorMessage = nil |
| 85 | + } |
| 86 | + |
| 87 | + /// Stop ambient audio detection |
| 88 | + public func stop() { |
| 89 | + guard isActive else { return } |
| 90 | + |
| 91 | + Current.Log.info("Stopping ambient audio detection") |
| 92 | + |
| 93 | + stopMetering() |
| 94 | + audioRecorder?.stop() |
| 95 | + audioRecorder = nil |
| 96 | + isActive = false |
| 97 | + audioLevel = 0 |
| 98 | + audioLevelDB = -160 |
| 99 | + loudAudioDetected = false |
| 100 | + } |
| 101 | + |
| 102 | + /// Request microphone authorization |
| 103 | + public func requestAuthorization() async -> Bool { |
| 104 | + return await withCheckedContinuation { continuation in |
| 105 | + AVAudioSession.sharedInstance().requestRecordPermission { granted in |
| 106 | + Task { @MainActor in |
| 107 | + self.checkAuthorizationStatus() |
| 108 | + continuation.resume(returning: granted) |
| 109 | + } |
| 110 | + } |
| 111 | + } |
| 112 | + } |
| 113 | + |
| 114 | + /// Set detection threshold in dB (e.g., -20 for loud, -50 for quiet) |
| 115 | + public func setThreshold(loud: Float, quiet: Float) { |
| 116 | + loudThresholdDB = loud |
| 117 | + quietThresholdDB = quiet |
| 118 | + } |
| 119 | + |
| 120 | + // MARK: - Private Methods |
| 121 | + |
| 122 | + private func checkAuthorizationStatus() { |
| 123 | + authorizationStatus = AVAudioSession.sharedInstance().recordPermission |
| 124 | + } |
| 125 | + |
| 126 | + private func setupAudioRecorder() { |
| 127 | + let audioSession = AVAudioSession.sharedInstance() |
| 128 | + |
| 129 | + do { |
| 130 | + try audioSession.setCategory(.playAndRecord, mode: .measurement, options: [.mixWithOthers, .defaultToSpeaker]) |
| 131 | + try audioSession.setActive(true) |
| 132 | + |
| 133 | + // Create temporary file for recording (required by AVAudioRecorder but we don't use it) |
| 134 | + let tempDir = FileManager.default.temporaryDirectory |
| 135 | + let tempFile = tempDir.appendingPathComponent("ambient_meter.caf") |
| 136 | + |
| 137 | + let settings: [String: Any] = [ |
| 138 | + AVFormatIDKey: Int(kAudioFormatAppleIMA4), |
| 139 | + AVSampleRateKey: 44100.0, |
| 140 | + AVNumberOfChannelsKey: 1, |
| 141 | + AVEncoderAudioQualityKey: AVAudioQuality.low.rawValue, |
| 142 | + ] |
| 143 | + |
| 144 | + audioRecorder = try AVAudioRecorder(url: tempFile, settings: settings) |
| 145 | + audioRecorder?.isMeteringEnabled = true |
| 146 | + audioRecorder?.record() |
| 147 | + |
| 148 | + Current.Log.info("Audio recorder initialized for ambient detection") |
| 149 | + } catch { |
| 150 | + errorMessage = "Failed to setup audio recorder: \(error.localizedDescription)" |
| 151 | + Current.Log.error("Audio recorder setup error: \(error)") |
| 152 | + } |
| 153 | + } |
| 154 | + |
| 155 | + private func startMetering() { |
| 156 | + meteringTimer = Timer.scheduledTimer(withTimeInterval: sampleInterval, repeats: true) { [weak self] _ in |
| 157 | + Task { @MainActor in |
| 158 | + self?.updateMetering() |
| 159 | + } |
| 160 | + } |
| 161 | + } |
| 162 | + |
| 163 | + private func stopMetering() { |
| 164 | + meteringTimer?.invalidate() |
| 165 | + meteringTimer = nil |
| 166 | + } |
| 167 | + |
| 168 | + private func updateMetering() { |
| 169 | + guard let recorder = audioRecorder else { return } |
| 170 | + |
| 171 | + recorder.updateMeters() |
| 172 | + |
| 173 | + // Get average power in dB (-160 to 0) |
| 174 | + let averagePower = recorder.averagePower(forChannel: 0) |
| 175 | + audioLevelDB = averagePower |
| 176 | + |
| 177 | + // Normalize to 0-1 range |
| 178 | + // -160 dB = 0, 0 dB = 1 |
| 179 | + audioLevel = max(0, min(1, (averagePower + 160) / 160)) |
| 180 | + |
| 181 | + // Check for loud audio |
| 182 | + if averagePower > loudThresholdDB { |
| 183 | + consecutiveLoudSamples += 1 |
| 184 | + |
| 185 | + if consecutiveLoudSamples >= loudSampleThreshold && !loudAudioDetected { |
| 186 | + loudAudioDetected = true |
| 187 | + onLoudAudioDetected?() |
| 188 | + onThresholdCrossed?(true) |
| 189 | + Current.Log.info("Loud audio detected: \(averagePower) dB") |
| 190 | + } |
| 191 | + } else if averagePower < quietThresholdDB { |
| 192 | + if loudAudioDetected { |
| 193 | + loudAudioDetected = false |
| 194 | + onThresholdCrossed?(false) |
| 195 | + Current.Log.info("Audio returned to quiet: \(averagePower) dB") |
| 196 | + } |
| 197 | + consecutiveLoudSamples = 0 |
| 198 | + } |
| 199 | + } |
| 200 | +} |
| 201 | + |
| 202 | +// MARK: - Sensor State Access |
| 203 | + |
| 204 | +extension AmbientAudioDetector { |
| 205 | + /// Get audio level for HA sensor reporting (0-100) |
| 206 | + public var audioLevelPercent: Int { |
| 207 | + Int(audioLevel * 100) |
| 208 | + } |
| 209 | + |
| 210 | + /// Get sensor attributes for HA |
| 211 | + public var sensorAttributes: [String: Any] { |
| 212 | + [ |
| 213 | + "level_db": audioLevelDB, |
| 214 | + "level_percent": audioLevelPercent, |
| 215 | + "loud_detected": loudAudioDetected, |
| 216 | + "detection_active": isActive, |
| 217 | + "threshold_loud_db": loudThresholdDB, |
| 218 | + "threshold_quiet_db": quietThresholdDB, |
| 219 | + ] |
| 220 | + } |
| 221 | +} |
| 222 | + |
| 223 | +// MARK: - Use Cases |
| 224 | + |
| 225 | +extension AmbientAudioDetector { |
| 226 | + /// Configure for voice activity detection |
| 227 | + public func configureForVoiceDetection() { |
| 228 | + setThreshold(loud: -30, quiet: -45) |
| 229 | + } |
| 230 | + |
| 231 | + /// Configure for loud noise detection (e.g., smoke alarm) |
| 232 | + public func configureForLoudNoiseDetection() { |
| 233 | + setThreshold(loud: -15, quiet: -30) |
| 234 | + } |
| 235 | + |
| 236 | + /// Configure for quiet room detection |
| 237 | + public func configureForQuietRoomDetection() { |
| 238 | + setThreshold(loud: -40, quiet: -55) |
| 239 | + } |
| 240 | +} |
0 commit comments