Files
notes/Notes Watch App/WatchAppServices.swift
rzen c29d25748a 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
2026-07-15 19:42:24 -04:00

96 lines
3.5 KiB
Swift

import Foundation
import Observation
import OSLog
import SwiftUI
/// Composition root for the watch app. Owns the recorder and the transfer
/// service and mediates between them: a finished recording is handed to the
/// transfer queue. The watch has no iCloud or local database — its whole job is
/// to capture audio and get it to the phone.
@Observable
@MainActor
final class WatchAppServices {
let recorder = WatchAudioRecorder()
let transfer = WatchTransferService()
func bootstrap() {
transfer.activate()
transfer.reconcileQueue()
// The 10-minute safety cap ends a take without a Stop tap — queue it
// rather than lose it.
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.
func toggleRecording() {
if recorder.isRecording {
stopAndQueue()
} else {
Task { await startRecording() }
}
}
private let logger = Logger(subsystem: "dev.rzen.indie.Notes.watchkitapp", category: "recording")
/// Why the last start attempt failed, surfaced in the UI — on a real watch
/// there's no console, so a silent failure is indistinguishable from a dead
/// button. Cleared when a recording starts successfully.
private(set) var lastError: String?
/// Request permission (once) then begin recording. A no-op if already
/// recording or permission is refused.
func startRecording() async {
guard !recorder.isRecording else { return }
guard await recorder.requestPermission() else {
logger.warning("microphone permission refused")
return
}
do {
try await recorder.startRecording()
lastError = nil
} catch {
logger.error("could not start recording: \(error, privacy: .public)")
lastError = error.localizedDescription
}
}
private func stopAndQueue() {
if let recording = recorder.stopRecording() {
transfer.enqueue(recording)
}
}
/// Complication deep link (`contextful://record?autostart=1`) → auto-start a
/// recording. Plain launches never carry the URL, so they never auto-record.
func handleDeepLink(_ url: URL) {
guard url.scheme == "contextful", url.host == "record" else { return }
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
let autostart = components?.queryItems?.first { $0.name == "autostart" }?.value == "1"
guard autostart else { return }
Task { await startRecording() }
}
func handleScenePhase(_ phase: ScenePhase) {
switch phase {
case .active:
// Catch up any file whose delivery we never confirmed.
transfer.reconcileQueue()
case .background:
// Never lose audio to the app being suspended: stop-and-queue
// whatever is recording rather than dropping the take. (A brief
// wrist-down is `.inactive` and is covered by the recorder's
// frontmost-timeout extension, so it keeps recording.)
stopAndQueue()
default:
break
}
}
}