- Schema v2: Note.AudioInfo, .m4a sidecar next to the note JSON in iCloud - AudioRecorderService (iOS/macOS) + SpeechAnalyzer transcription pipeline with enabled-language set and confidence-based auto-pick, re-transcribe menu - Settings > Transcription > Languages with asset download/reserve handling - watchOS app + accessory complication: one-button recorder, WCSession transferFile to phone, WatchInbox staging, direct-upsert ingestion - Player UI, transcription status chips, quick-capture mic in notes list - Version 0.2, changelog + README updated
74 lines
2.6 KiB
Swift
74 lines
2.6 KiB
Swift
import Foundation
|
|
import Observation
|
|
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)
|
|
}
|
|
}
|
|
|
|
/// Single-button behaviour: start if idle, stop-and-queue if recording.
|
|
func toggleRecording() {
|
|
if recorder.isRecording {
|
|
stopAndQueue()
|
|
} else {
|
|
Task { await startRecording() }
|
|
}
|
|
}
|
|
|
|
/// 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 { return }
|
|
try? recorder.startRecording()
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|