- 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
115 lines
4.9 KiB
Swift
115 lines
4.9 KiB
Swift
#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
|