diff --git a/CHANGELOG.md b/CHANGELOG.md index 565e496..a5ecb3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ **July 2026** +Fixed watch voice notes arriving on the iPhone without audio, so they couldn't be played or transcribed + Transcription now supports many more languages, including Russian, Ukrainian, and Polish A single toolbar button now starts a new note that opens straight to typing, with voice recording just one tap away diff --git a/Notes Watch App/Services/WatchAudioRecorder.swift b/Notes Watch App/Services/WatchAudioRecorder.swift index b5057aa..51d528f 100644 --- a/Notes Watch App/Services/WatchAudioRecorder.swift +++ b/Notes Watch App/Services/WatchAudioRecorder.swift @@ -5,7 +5,7 @@ import OSLog import WatchKit /// Records a single voice note on the watch into a persisted -/// `Documents/Recordings/.m4a` (AAC mono 22.05 kHz ~32 kbps). The ULID is +/// `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. @@ -36,6 +36,13 @@ final class WatchAudioRecorder { /// 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 @@ -43,6 +50,8 @@ final class WatchAudioRecorder { 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 @@ -67,34 +76,41 @@ final class WatchAudioRecorder { /// 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() throws { + func startRecording() async throws { guard !isRecording else { return } let session = AVAudioSession.sharedInstance() - do { - try session.setCategory(.record, mode: .spokenAudio) - } catch { - // .spokenAudio isn't guaranteed compatible with .record on every - // route — fall back rather than refuse to record at all. - try session.setCategory(.record, mode: .default) + 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 } - try session.setActive(true) 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: 22_050.0, + AVSampleRateKey: 44_100.0, AVNumberOfChannelsKey: 1, - AVEncoderBitRateKey: 32_000, + AVEncoderBitRateKey: 48_000, AVEncoderAudioQualityKey: AVAudioQuality.medium.rawValue, ] let recorder = try AVAudioRecorder(url: url, settings: settings) - guard recorder.record() else { throw RecorderError.couldNotStart } + 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 @@ -128,6 +144,17 @@ final class WatchAudioRecorder { .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 } @@ -161,10 +188,24 @@ final class WatchAudioRecorder { } } + /// 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 @@ -175,3 +216,22 @@ final class WatchAudioRecorder { 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") } + } +} diff --git a/Notes Watch App/WatchAppServices.swift b/Notes Watch App/WatchAppServices.swift index 9274741..07a569b 100644 --- a/Notes Watch App/WatchAppServices.swift +++ b/Notes Watch App/WatchAppServices.swift @@ -21,6 +21,11 @@ final class WatchAppServices { recorder.onInvoluntaryFinish = { [weak self] recording in self?.transfer.enqueue(recording) } + // A capture that silently produced no audio (encode error, dead input) + // surfaces on screen — there's no console on a real watch. + recorder.onCaptureFailure = { [weak self] message in + self?.lastError = message + } } /// Single-button behaviour: start if idle, stop-and-queue if recording. @@ -48,7 +53,7 @@ final class WatchAppServices { return } do { - try recorder.startRecording() + try await recorder.startRecording() lastError = nil } catch { logger.error("could not start recording: \(error, privacy: .public)")