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:
@@ -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") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)")
|
||||||
|
|||||||
Reference in New Issue
Block a user