import AVFoundation import Foundation import IndieSync /// Records a single voice note into a temporary `.m4a` (AAC mono /// 44.1 kHz ~48 kbps). Modeled on `ContextService`: `@Observable @MainActor`, /// so the UI observes `isRecording` / `elapsed` / `level` directly. /// /// Never records into the iCloud container — the file is written to the temp /// directory and handed up as a URL; `SyncEngine.createAudioNote` is what copies /// the finished bytes into the container (audio before JSON). `AVAudioRecorder` /// is not Sendable, so it stays confined to the main actor for its whole life. @Observable @MainActor final class AudioRecorderService { /// One finished recording: the temp file and how long it runs. The caller /// owns the file from here on (moves its bytes into the container, then /// discards the temp copy). struct Recording: Equatable { let url: URL let duration: TimeInterval } private(set) var isRecording = false /// Seconds recorded so far, updated by the metering loop while recording. private(set) var elapsed: TimeInterval = 0 /// Normalized input level (0…1) for the level-bar UI; 0 when idle. private(set) var level: Float = 0 /// Fired when a system interruption (phone call, Siri) forces recording to /// stop. The partial file is finalized and handed up rather than lost, so the /// UI can move straight to the "recorded" state. nil unless a recorder wires /// it up for the duration of a take. var onInterrupted: ((Recording) -> Void)? private var recorder: AVAudioRecorder? private var meterTask: Task? private var currentURL: URL? #if os(iOS) private var interruptionObserver: (any NSObjectProtocol)? #endif /// Ask for microphone access, returning whether it was granted. Safe to call /// every time recording starts — the system only prompts once. func requestPermission() async -> Bool { await AVAudioApplication.requestRecordPermission() } /// Whether the mic has been explicitly denied (so the UI can show a jump to /// Settings instead of a dead record button). var permissionDenied: Bool { AVAudioApplication.shared.recordPermission == .denied } /// Begin recording into a fresh temp file. Throws if the audio session or /// recorder can't be configured (surfaced by the UI). A no-op if already /// recording. func startRecording() throws { guard !isRecording else { return } #if os(iOS) let session = AVAudioSession.sharedInstance() try session.setCategory(.playAndRecord, mode: .spokenAudio, options: [.duckOthers]) try session.setActive(true) observeInterruptions() #endif let url = FileManager.default.temporaryDirectory .appending(path: "\(ULID().stringValue).m4a") let settings: [String: Any] = [ AVFormatIDKey: kAudioFormatMPEG4AAC, AVSampleRateKey: 44_100.0, AVNumberOfChannelsKey: 1, AVEncoderBitRateKey: 48_000, AVEncoderAudioQualityKey: AVAudioQuality.medium.rawValue, ] let recorder = try AVAudioRecorder(url: url, settings: settings) recorder.isMeteringEnabled = true guard recorder.record() else { throw RecorderError.couldNotStart } self.recorder = recorder self.currentURL = url isRecording = true elapsed = 0 level = 0 startMetering() } /// Stop recording and return the finished file plus its duration, or nil if /// nothing was recording. Deactivates the audio session on iOS. @discardableResult func stopRecording() -> Recording? { guard let recorder, let url = currentURL else { return nil } let duration = recorder.currentTime recorder.stop() teardown() return Recording(url: url, duration: duration) } /// Abandon the in-progress (or just-finished) recording and delete its temp /// file. Used by "Discard" and when the composer is dismissed unsaved. func discard() { recorder?.stop() if let url = currentURL { try? FileManager.default.removeItem(at: url) } teardown() } // MARK: - Metering /// ~30 Hz loop that refreshes `elapsed` and `level` while recording. Runs on /// the main actor (a plain `Task` inherits this actor), so it can touch the /// non-Sendable recorder safely. private func startMetering() { meterTask?.cancel() meterTask = Task { [weak self] in while !Task.isCancelled { guard let self, let recorder = self.recorder, recorder.isRecording else { return } recorder.updateMeters() self.elapsed = recorder.currentTime self.level = Self.normalizedLevel(recorder.averagePower(forChannel: 0)) try? await Task.sleep(for: .milliseconds(33)) } } } /// Map a dBFS reading (roughly -60…0) to a 0…1 bar height. private static func normalizedLevel(_ power: Float) -> Float { let floorDB: Float = -50 guard power > floorDB else { return 0 } return min(1, (power - floorDB) / -floorDB) } private func teardown() { meterTask?.cancel() meterTask = nil recorder = nil currentURL = nil isRecording = false elapsed = 0 level = 0 #if os(iOS) if let interruptionObserver { NotificationCenter.default.removeObserver(interruptionObserver) self.interruptionObserver = nil } try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) #endif } // MARK: - Interruptions (iOS) #if os(iOS) private func observeInterruptions() { guard interruptionObserver == nil else { return } interruptionObserver = NotificationCenter.default.addObserver( forName: AVAudioSession.interruptionNotification, object: AVAudioSession.sharedInstance(), queue: .main ) { [weak self] note in // Extract the Sendable interruption type before hopping — the // Notification itself isn't Sendable. Delivered on the main queue, so // assuming main-actor isolation is sound. let began = (note.userInfo?[AVAudioSessionInterruptionTypeKey] as? UInt) .flatMap(AVAudioSession.InterruptionType.init(rawValue:)) == .began guard began else { return } MainActor.assumeIsolated { self?.handleInterruptionBegan() } } } private func handleInterruptionBegan() { guard isRecording else { return } // Finalize rather than drop the take — the partial recording is still a // valid note. if let finished = stopRecording() { onInterrupted?(finished) } } #endif enum RecorderError: Error { case couldNotStart } }