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,155 @@
|
||||
import Foundation
|
||||
import IndieSync
|
||||
import os
|
||||
import WatchConnectivity
|
||||
|
||||
/// Watch side of the recording hand-off. The watch has no iCloud; a finished
|
||||
/// recording becomes durable on the phone, which ingests and transcribes it.
|
||||
///
|
||||
/// Each queued recording is a `<ULID>.m4a` plus a `<ULID>.meta.plist` sidecar in
|
||||
/// `Documents/Recordings/`. `transferFile` hands the audio to WatchConnectivity
|
||||
/// with an `AudioNotePayload` as metadata. Local files are deleted **only** in
|
||||
/// `didFinish` with a nil error — so a delivery we never confirm (app killed
|
||||
/// mid-flight, transfer failed) is re-enqueued by `reconcileQueue()` from the
|
||||
/// persisted sidecar on the next activation. `pendingCount` (files still on disk)
|
||||
/// drives the "N waiting for iPhone" footer.
|
||||
///
|
||||
/// Strict concurrency: the delegate callbacks are nonisolated and run on
|
||||
/// WatchConnectivity's background queue — they extract Sendable values (a file
|
||||
/// name string) before hopping to the main actor. `WCSessionFile*` are not
|
||||
/// Sendable and never cross the hop.
|
||||
@Observable
|
||||
@MainActor
|
||||
final class WatchTransferService: NSObject {
|
||||
private nonisolated static let log = Logger(
|
||||
subsystem: "dev.rzen.indie.Notes.watchkitapp", category: "watch-transfer")
|
||||
|
||||
/// Recordings still on disk (queued or in-flight) — the count the phone has
|
||||
/// not yet confirmed receiving.
|
||||
private(set) var pendingCount = 0
|
||||
|
||||
private var session: WCSession?
|
||||
|
||||
func activate() {
|
||||
guard WCSession.isSupported() else { return }
|
||||
let session = WCSession.default
|
||||
session.delegate = self
|
||||
session.activate()
|
||||
self.session = session
|
||||
refreshPendingCount()
|
||||
}
|
||||
|
||||
/// Queue a finished recording for delivery. Writes the sidecar first (so a
|
||||
/// relaunch before delivery can re-enqueue it), then hands the file to
|
||||
/// `transferFile`.
|
||||
func enqueue(_ recording: WatchAudioRecorder.Recording) {
|
||||
let tz = TimeZone.current.identifier
|
||||
writeSidecar(base: recording.id.stringValue,
|
||||
recordedAt: recording.recordedAt, duration: recording.duration, timeZoneID: tz)
|
||||
startTransfer(id: recording.id.stringValue, url: recording.url,
|
||||
recordedAt: recording.recordedAt, duration: recording.duration, timeZoneID: tz)
|
||||
refreshPendingCount()
|
||||
}
|
||||
|
||||
/// Re-enqueue any recording still on disk that the session isn't already
|
||||
/// transferring — a file whose delivery we never confirmed. Runs when the
|
||||
/// session activates (relaunch) and when the app returns to the foreground.
|
||||
func reconcileQueue() {
|
||||
guard let session else { refreshPendingCount(); return }
|
||||
let inFlight = Set(session.outstandingFileTransfers.map { $0.file.fileURL.lastPathComponent })
|
||||
let fm = FileManager.default
|
||||
let dir = WatchAudioRecorder.recordingsDirectory
|
||||
let entries = (try? fm.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil)) ?? []
|
||||
for m4a in entries where m4a.pathExtension == "m4a" {
|
||||
if inFlight.contains(m4a.lastPathComponent) { continue }
|
||||
let base = m4a.deletingPathExtension().lastPathComponent
|
||||
guard ULID(string: base) != nil else { continue }
|
||||
let meta = readSidecar(base: base)
|
||||
startTransfer(id: base, url: m4a,
|
||||
recordedAt: meta?.recordedAt ?? Date(),
|
||||
duration: meta?.duration ?? 0,
|
||||
timeZoneID: meta?.timeZoneID)
|
||||
}
|
||||
refreshPendingCount()
|
||||
}
|
||||
|
||||
// MARK: - Transfer
|
||||
|
||||
private func startTransfer(id: String, url: URL, recordedAt: Date, duration: TimeInterval, timeZoneID: String?) {
|
||||
guard let session, session.activationState == .activated,
|
||||
FileManager.default.fileExists(atPath: url.path) else { return }
|
||||
let payload = AudioNotePayload(
|
||||
id: id, recordedAt: recordedAt, duration: duration, timeZoneID: timeZoneID)
|
||||
session.transferFile(url, metadata: payload.metadata())
|
||||
}
|
||||
|
||||
/// Delete a delivered recording and its sidecar (called only on a confirmed
|
||||
/// `didFinish` with no error).
|
||||
private func completeTransfer(fileName: String) {
|
||||
let dir = WatchAudioRecorder.recordingsDirectory
|
||||
let base = (fileName as NSString).deletingPathExtension
|
||||
try? FileManager.default.removeItem(at: dir.appending(path: fileName))
|
||||
try? FileManager.default.removeItem(at: sidecarURL(base: base))
|
||||
refreshPendingCount()
|
||||
}
|
||||
|
||||
// MARK: - Pending count
|
||||
|
||||
private func refreshPendingCount() {
|
||||
let dir = WatchAudioRecorder.recordingsDirectory
|
||||
let entries = (try? FileManager.default.contentsOfDirectory(
|
||||
at: dir, includingPropertiesForKeys: nil)) ?? []
|
||||
pendingCount = entries.filter { $0.pathExtension == "m4a" }.count
|
||||
}
|
||||
|
||||
// MARK: - Sidecar (persisted metadata for re-enqueue after relaunch)
|
||||
|
||||
private func sidecarURL(base: String) -> URL {
|
||||
WatchAudioRecorder.recordingsDirectory.appending(path: "\(base).meta.plist")
|
||||
}
|
||||
|
||||
private func writeSidecar(base: String, recordedAt: Date, duration: TimeInterval, timeZoneID: String?) {
|
||||
var dict: [String: Any] = ["recordedAt": recordedAt, "duration": duration]
|
||||
if let timeZoneID { dict["timeZoneID"] = timeZoneID }
|
||||
if let data = try? PropertyListSerialization.data(fromPropertyList: dict, format: .binary, options: 0) {
|
||||
try? data.write(to: sidecarURL(base: base))
|
||||
}
|
||||
}
|
||||
|
||||
private func readSidecar(base: String) -> (recordedAt: Date, duration: TimeInterval, timeZoneID: String?)? {
|
||||
guard let data = try? Data(contentsOf: sidecarURL(base: base)),
|
||||
let dict = try? PropertyListSerialization.propertyList(from: data, options: [], format: nil) as? [String: Any],
|
||||
let recordedAt = dict["recordedAt"] as? Date,
|
||||
let duration = dict["duration"] as? Double else { return nil }
|
||||
return (recordedAt, duration, dict["timeZoneID"] as? String)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - WCSessionDelegate
|
||||
|
||||
extension WatchTransferService: WCSessionDelegate {
|
||||
nonisolated func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: (any Error)?) {
|
||||
if let error { Self.log.error("watch WC activation failed: \(error, privacy: .public)") }
|
||||
Task { @MainActor in self.reconcileQueue() }
|
||||
}
|
||||
|
||||
nonisolated func sessionReachabilityDidChange(_ session: WCSession) {
|
||||
// A queued file whose transfer never registered (session wasn't yet
|
||||
// activated at enqueue) gets re-driven once the phone is reachable again.
|
||||
if session.isReachable {
|
||||
Task { @MainActor in self.reconcileQueue() }
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func session(_ session: WCSession, didFinish fileTransfer: WCSessionFileTransfer, error: (any Error)?) {
|
||||
// Extract the Sendable file name before hopping — WCSessionFileTransfer /
|
||||
// WCSessionFile are not Sendable.
|
||||
let fileName = fileTransfer.file.fileURL.lastPathComponent
|
||||
if let error {
|
||||
Self.log.warning("file transfer failed (\(fileName, privacy: .public)): \(error, privacy: .public)")
|
||||
Task { @MainActor in self.refreshPendingCount() }
|
||||
return
|
||||
}
|
||||
Task { @MainActor in self.completeTransfer(fileName: fileName) }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user