Fix silent watch recordings: async session activation, encoder settings, error surfacing

Watch recordings arrived on the phone as 28-byte header-only m4a files
(record() reported success but no audio was ever captured). Activate the
audio session via watchOS's async activate(options:), drop the .spokenAudio
mode experiment, match the phone recorder's proven AAC settings
(44.1 kHz / 48 kbps), and surface silent failures: an AVAudioRecorderDelegate
catches encode errors mid-take, and a stop-time guard detects a dead
header-only file, deletes it, and shows the failure on the watch instead of
shipping an unplayable note to the phone.

Claude-Session: https://claude.ai/code/session_014esDWi42URLEC6Cj17hGQ3
This commit is contained in:
2026-07-15 19:42:24 -04:00
parent 35e4d6750b
commit c29d25748a
3 changed files with 80 additions and 13 deletions
+2
View File
@@ -1,5 +1,7 @@
**July 2026** **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 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 A single toolbar button now starts a new note that opens straight to typing, with voice recording just one tap away
@@ -5,7 +5,7 @@ import OSLog
import WatchKit import WatchKit
/// Records a single voice note on the watch into a persisted /// Records a single voice note on the watch into a persisted
/// `Documents/Recordings/<ULID>.m4a` (AAC mono 22.05 kHz ~32 kbps). The ULID is /// `Documents/Recordings/<ULID>.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 /// 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 /// once ingested on the phone reflects when it was recorded, not when it later
/// reaches the phone. /// reaches the phone.
@@ -36,6 +36,13 @@ final class WatchAudioRecorder {
/// which calls `stopRecording()` directly.) /// which calls `stopRecording()` directly.)
var onInvoluntaryFinish: ((Recording) -> Void)? 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 /// 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. /// the battery down. The finished file is queued, never dropped.
private static let maxDuration: TimeInterval = 600 private static let maxDuration: TimeInterval = 600
@@ -43,6 +50,8 @@ final class WatchAudioRecorder {
private var recorder: AVAudioRecorder? private var recorder: AVAudioRecorder?
private var current: (id: ULID, url: URL, startedAt: Date)? private var current: (id: ULID, url: URL, startedAt: Date)?
private var tickTask: Task<Void, Never>? private var tickTask: Task<Void, Never>?
/// Kept strongly `AVAudioRecorder.delegate` is weak.
private var recorderDelegate: RecorderDelegate?
/// Directory the finished recordings (and their `.meta.plist` sidecars) live /// Directory the finished recordings (and their `.meta.plist` sidecars) live
/// in until the phone confirms delivery. Persisted, so a queued file survives /// in until the phone confirms delivery. Persisted, so a queued file survives
@@ -67,34 +76,41 @@ final class WatchAudioRecorder {
/// Begin recording into a fresh `<ULID>.m4a`. A no-op if already recording. /// Begin recording into a fresh `<ULID>.m4a`. A no-op if already recording.
/// Throws if the audio session or recorder can't be configured. /// Throws if the audio session or recorder can't be configured.
func startRecording() throws { func startRecording() async throws {
guard !isRecording else { return } guard !isRecording else { return }
let session = AVAudioSession.sharedInstance() 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 let dir = Self.recordingsDirectory
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
let id = ULID() let id = ULID()
let url = dir.appending(path: "\(id.stringValue).m4a") let url = dir.appending(path: "\(id.stringValue).m4a")
// Mirrors the phone recorder's proven settings (AudioRecorderService).
let settings: [String: Any] = [ let settings: [String: Any] = [
AVFormatIDKey: kAudioFormatMPEG4AAC, AVFormatIDKey: kAudioFormatMPEG4AAC,
AVSampleRateKey: 22_050.0, AVSampleRateKey: 44_100.0,
AVNumberOfChannelsKey: 1, AVNumberOfChannelsKey: 1,
AVEncoderBitRateKey: 32_000, AVEncoderBitRateKey: 48_000,
AVEncoderAudioQualityKey: AVAudioQuality.medium.rawValue, AVEncoderAudioQualityKey: AVAudioQuality.medium.rawValue,
] ]
let recorder = try AVAudioRecorder(url: url, settings: settings) 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.recorder = recorder
self.current = (id, url, Date()) self.current = (id, url, Date())
isRecording = true isRecording = true
@@ -128,6 +144,17 @@ final class WatchAudioRecorder {
.warning("recording produced no file (simulator has no mic input?): \(recording.url.lastPathComponent, privacy: .public)") .warning("recording produced no file (simulator has no mic input?): \(recording.url.lastPathComponent, privacy: .public)")
return nil 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 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() { private func teardown() {
tickTask?.cancel() tickTask?.cancel()
tickTask = nil tickTask = nil
recorder?.delegate = nil
recorder = nil recorder = nil
recorderDelegate = nil
current = nil current = nil
isRecording = false isRecording = false
elapsed = 0 elapsed = 0
@@ -175,3 +216,22 @@ final class WatchAudioRecorder {
case couldNotStart 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") }
}
}
+6 -1
View File
@@ -21,6 +21,11 @@ final class WatchAppServices {
recorder.onInvoluntaryFinish = { [weak self] recording in recorder.onInvoluntaryFinish = { [weak self] recording in
self?.transfer.enqueue(recording) 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. /// Single-button behaviour: start if idle, stop-and-queue if recording.
@@ -48,7 +53,7 @@ final class WatchAppServices {
return return
} }
do { do {
try recorder.startRecording() try await recorder.startRecording()
lastError = nil lastError = nil
} catch { } catch {
logger.error("could not start recording: \(error, privacy: .public)") logger.error("could not start recording: \(error, privacy: .public)")