import AVFoundation import Foundation import IndieSync import OSLog import WatchKit /// Records a single voice note on the watch into a persisted /// `Documents/Recordings/.m4a` (AAC mono 44.1 kHz ~48 kbps). The ULID is /// minted at record **start**, so the note's identity — and its UTC time bucket /// once ingested on the phone — reflects when it was recorded, not when it later /// reaches the phone. /// /// The file lands in the app's Documents (not temp) so a queued recording /// survives suspension until `WatchTransferService` confirms the phone received /// it. `AVAudioRecorder` is not Sendable, so the recorder stays confined to the /// main actor for its whole life. @Observable @MainActor final class WatchAudioRecorder { /// One finished recording: the file on disk, its length, its ULID, and the /// instant it started. The caller (`WatchTransferService`) owns delivery. struct Recording: Equatable, Sendable { let id: ULID let url: URL let duration: TimeInterval let recordedAt: Date } private(set) var isRecording = false /// Seconds recorded so far, refreshed by the tick loop while recording. private(set) var elapsed: TimeInterval = 0 /// Fired when a recording ends without the user tapping Stop — the 10-minute /// safety cap fires. The finished file is handed up to be queued rather than /// lost. (Scene-deactivation stop-and-queue is driven by `WatchAppServices`, /// which calls `stopRecording()` directly.) var onInvoluntaryFinish: ((Recording) -> Void)? /// Fired when a take captures no audio: an encode error mid-recording, or a /// stop that finds a header-only file (`record()` can report success on /// watchOS while the capture silently fails — the failure only surfaces /// through the delegate or in the dead file). The message is shown in the /// watch UI; there's no console on a real watch. var onCaptureFailure: ((String) -> Void)? /// Hard cap on a single take so a recording the user forgot about can't run /// the battery down. The finished file is queued, never dropped. private static let maxDuration: TimeInterval = 600 private var recorder: AVAudioRecorder? private var current: (id: ULID, url: URL, startedAt: Date)? private var tickTask: Task? /// Kept strongly — `AVAudioRecorder.delegate` is weak. private var recorderDelegate: RecorderDelegate? /// Directory the finished recordings (and their `.meta.plist` sidecars) live /// in until the phone confirms delivery. Persisted, so a queued file survives /// app suspension and can be re-enqueued on the next launch. static var recordingsDirectory: URL { URL.documentsDirectory.appending(path: "Recordings", directoryHint: .isDirectory) } /// 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 { let granted = await AVAudioApplication.requestRecordPermission() permissionDenied = !granted return granted } /// Whether the mic has been explicitly denied, so the UI can show a jump to /// Settings instead of a dead record button. Stored (not computed from /// `AVAudioApplication.shared.recordPermission`) so Observation tracks it and /// the UI actually flips to the denied state the moment a request is refused. private(set) var permissionDenied = AVAudioApplication.shared.recordPermission == .denied /// Begin recording into a fresh `.m4a`. A no-op if already recording. /// Throws if the audio session or recorder can't be configured. func startRecording() async throws { guard !isRecording else { return } let session = AVAudioSession.sharedInstance() try session.setCategory(.record, mode: .default) // watchOS requires the async activation (`setActive(true)` is not the // sanctioned path there and can leave the session without a live input, // yielding header-only files while `record()` still reports success). guard try await session.activate(options: []) else { throw RecorderError.couldNotStart } let dir = Self.recordingsDirectory try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) let id = ULID() let url = dir.appending(path: "\(id.stringValue).m4a") // Mirrors the phone recorder's proven settings (AudioRecorderService). 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) let delegate = RecorderDelegate { [weak self] message in Task { @MainActor in self?.handleEncodeError(message) } } recorder.delegate = delegate recorderDelegate = delegate guard recorder.prepareToRecord(), recorder.record() else { throw RecorderError.couldNotStart } self.recorder = recorder self.current = (id, url, Date()) isRecording = true elapsed = 0 // Continuity through a wrist-down rests on the `audio` background mode // (declared in Info-watchOS.plist) keeping the active audio session // alive; the old `WKExtension.isFrontmostTimeoutExtended` knob is // deprecated and unsupported since watchOS 7. The stop-and-queue on // scene-background (see `WatchAppServices`) is the never-lose-audio net. WKInterfaceDevice.current().play(.start) startTicking() } /// Stop recording and return the finished file plus its duration, or nil if /// nothing was recording. @discardableResult func stopRecording() -> Recording? { guard let recorder, let current else { return nil } let duration = recorder.currentTime recorder.stop() WKInterfaceDevice.current().play(.stop) let recording = Recording( id: current.id, url: current.url, duration: duration, recordedAt: current.startedAt) teardown() // The watch simulator has no audio input device: record() reports // success and currentTime advances, but no file is ever written. Don't // hand a phantom recording up to the transfer queue. guard FileManager.default.fileExists(atPath: recording.url.path) else { Logger(subsystem: "dev.rzen.indie.Notes.watchkitapp", category: "recording") .warning("recording produced no file (simulator has no mic input?): \(recording.url.lastPathComponent, privacy: .public)") return nil } // A capture that silently failed leaves a header-only m4a (tens of // bytes, currentTime frozen at 0). Surface it on the watch instead of // shipping a dead note to the phone. let size = ((try? FileManager.default.attributesOfItem(atPath: recording.url.path))?[.size] as? Int) ?? 0 guard recording.duration > 0.1, size > 512 else { try? FileManager.default.removeItem(at: recording.url) Logger(subsystem: "dev.rzen.indie.Notes.watchkitapp", category: "recording") .error("recording captured no audio (duration \(recording.duration), \(size) bytes)") onCaptureFailure?("No audio was captured. Check that Contextful has microphone access in the Watch app on iPhone.") return nil } return recording } /// Abandon the in-progress recording and delete its file. (Not surfaced in the /// single-button UI, but kept for symmetry / future use.) func discard() { recorder?.stop() if let url = current?.url { try? FileManager.default.removeItem(at: url) } teardown() } // MARK: - Metering / watchdog /// ~5 Hz loop that refreshes `elapsed` and enforces the safety cap. Runs on /// the main actor (a plain `Task` inherits it), so it can touch the /// non-Sendable recorder safely. private func startTicking() { tickTask?.cancel() tickTask = Task { [weak self] in while !Task.isCancelled { guard let self, let recorder = self.recorder, recorder.isRecording else { return } self.elapsed = recorder.currentTime if self.elapsed >= Self.maxDuration { if let finished = self.stopRecording() { self.onInvoluntaryFinish?(finished) } return } try? await Task.sleep(for: .milliseconds(200)) } } } /// An encode error mid-take means the file is dead no matter how long the /// user keeps talking: stop, drop it, and surface the failure. private func handleEncodeError(_ message: String) { guard isRecording else { return } recorder?.stop() if let url = current?.url { try? FileManager.default.removeItem(at: url) } teardown() Logger(subsystem: "dev.rzen.indie.Notes.watchkitapp", category: "recording") .error("encode error during recording: \(message, privacy: .public)") onCaptureFailure?("Recording failed: \(message)") } private func teardown() { tickTask?.cancel() tickTask = nil recorder?.delegate = nil recorder = nil recorderDelegate = nil current = nil isRecording = false elapsed = 0 try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) } enum RecorderError: Error { case couldNotStart } } /// Bridges `AVAudioRecorderDelegate` (nonisolated callbacks on a system queue) /// to the main-actor recorder via a Sendable closure. Errors during an AAC /// encode are only ever reported here — `record()` keeps returning success. private final class RecorderDelegate: NSObject, AVAudioRecorderDelegate, Sendable { private let onError: @Sendable (String) -> Void init(onError: @escaping @Sendable (String) -> Void) { self.onError = onError } func audioRecorderEncodeErrorDidOccur(_ recorder: AVAudioRecorder, error: (any Error)?) { onError(error?.localizedDescription ?? "unknown encode error") } func audioRecorderDidFinishRecording(_ recorder: AVAudioRecorder, successfully flag: Bool) { if !flag { onError("the audio file could not be finalized") } } }