Add voice notes: on-device transcription, multi-language, watch capture
- 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
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
#if os(iOS)
|
||||
import Foundation
|
||||
import os
|
||||
import WatchConnectivity
|
||||
|
||||
/// Phone side of the watch recording hand-off. Receives a `WCSessionFile` the
|
||||
/// watch sent via `transferFile`, stages it into the local `WatchInbox` (with the
|
||||
/// `<ULID>.m4a` + `<ULID>.meta.plist` layout `SyncEngine.ingestStagedWatchRecordings`
|
||||
/// expects), then asks the sync engine to drain the inbox.
|
||||
///
|
||||
/// The move happens **synchronously in the nonisolated delegate callback**: the
|
||||
/// system deletes the received inbox file the instant `didReceive` returns, so
|
||||
/// deferring the move to a `MainActor` hop would race the bytes away. Only after
|
||||
/// staging do we hop to the main actor to trigger ingest.
|
||||
///
|
||||
/// Activated from the `UIApplicationDelegate` (not a SwiftUI `.task`) so a
|
||||
/// background launch the system makes purely to deliver a transfer still stages
|
||||
/// the file. `syncEngine` is wired in later, once `AppServices` exists; when it's
|
||||
/// nil (a background launch with no UI yet) the staged file is drained on the
|
||||
/// next foreground connect instead.
|
||||
@MainActor
|
||||
final class PhoneWatchReceiver: NSObject {
|
||||
private nonisolated static let log = Logger(
|
||||
subsystem: "dev.rzen.indie.Notes", category: "phone-watch-receiver")
|
||||
|
||||
/// The live sync engine, set once `AppServices` is built. Weak: `AppServices`
|
||||
/// owns it for the app's lifetime; the receiver only borrows it to trigger a
|
||||
/// drain when one is available.
|
||||
weak var syncEngine: SyncEngine?
|
||||
|
||||
private var session: WCSession?
|
||||
|
||||
func activate() {
|
||||
guard WCSession.isSupported() else { return }
|
||||
let session = WCSession.default
|
||||
session.delegate = self
|
||||
session.activate()
|
||||
self.session = session
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - WCSessionDelegate
|
||||
|
||||
extension PhoneWatchReceiver: WCSessionDelegate {
|
||||
nonisolated func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: (any Error)?) {
|
||||
if let error { Self.log.error("phone WC activation failed: \(error, privacy: .public)") }
|
||||
}
|
||||
|
||||
nonisolated func sessionDidBecomeInactive(_ session: WCSession) {}
|
||||
|
||||
nonisolated func sessionDidDeactivate(_ session: WCSession) {
|
||||
// Reactivate for a switched watch.
|
||||
session.activate()
|
||||
}
|
||||
|
||||
nonisolated func session(_ session: WCSession, didReceive file: WCSessionFile) {
|
||||
// Synchronous move — the inbox file is deleted the moment this returns.
|
||||
Self.stage(file: file)
|
||||
// Drain if the engine is live; otherwise the next foreground connect drains.
|
||||
Task { @MainActor in await self.syncEngine?.ingestStagedWatchRecordings() }
|
||||
}
|
||||
|
||||
/// Move the received audio into `WatchInbox` and write the metadata sidecar
|
||||
/// `SyncEngine`'s drain reads. On a decodable payload this stages a full
|
||||
/// `<ULID>.m4a` + `<ULID>.meta.plist` pair. On an unknown/newer payload
|
||||
/// version (the forward gate) the bytes are still quarantined in the inbox —
|
||||
/// but without a sidecar, so the drain skips them and a future build can pick
|
||||
/// them up.
|
||||
nonisolated private static func stage(file: WCSessionFile) {
|
||||
let fm = FileManager.default
|
||||
let inbox = SyncEngine.watchInboxURL
|
||||
try? fm.createDirectory(at: inbox, withIntermediateDirectories: true)
|
||||
|
||||
let metadata = file.metadata ?? [:]
|
||||
|
||||
guard let payload = AudioNotePayload(metadata: metadata) else {
|
||||
// Forward gate: keep the bytes but don't make them ingestible.
|
||||
let base = (metadata["id"] as? String) ?? UUID().uuidString
|
||||
let dest = inbox.appending(path: "\(base).m4a")
|
||||
try? fm.removeItem(at: dest)
|
||||
do {
|
||||
try fm.moveItem(at: file.fileURL, to: dest)
|
||||
log.error("staged watch recording \(base, privacy: .public) with unknown payload version — skipping ingest")
|
||||
} catch {
|
||||
log.error("failed to stage unknown-version recording: \(error, privacy: .public)")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let base = payload.id
|
||||
let audioDest = inbox.appending(path: "\(base).m4a")
|
||||
let metaDest = inbox.appending(path: "\(base).meta.plist")
|
||||
// Idempotent for a redelivery of the same recording.
|
||||
try? fm.removeItem(at: audioDest)
|
||||
do {
|
||||
try fm.moveItem(at: file.fileURL, to: audioDest)
|
||||
} catch {
|
||||
log.error("failed to stage recording \(base, privacy: .public): \(error, privacy: .public)")
|
||||
return
|
||||
}
|
||||
|
||||
var meta: [String: Any] = [
|
||||
"id": payload.id,
|
||||
"recordedAt": payload.recordedAt,
|
||||
"duration": payload.duration,
|
||||
]
|
||||
if let tz = payload.timeZoneID { meta["timeZoneID"] = tz }
|
||||
if let data = try? PropertyListSerialization.data(fromPropertyList: meta, format: .binary, options: 0) {
|
||||
try? data.write(to: metaDest)
|
||||
}
|
||||
log.info("staged watch recording \(base, privacy: .public) for ingest")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
Reference in New Issue
Block a user