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
96 lines
3.5 KiB
Swift
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
|
|
}
|
|
}
|
|
}
|