Files
notes/Notes/Model/Note.swift
T
rzen 86d74806c9 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
2026-07-15 13:30:43 -04:00

177 lines
6.8 KiB
Swift

import Foundation
import IndieSync
/// The context snapshot captured at the moment a note is taken — the raw
/// material the relevance ranking later matches against "where am I, what
/// time is it" at recall time. Every field is optional: capture is
/// best-effort and a note taken with location off is still a note.
struct CaptureContext: Codable, Sendable, Equatable {
var latitude: Double?
var longitude: Double?
var horizontalAccuracy: Double?
/// Point-of-interest or venue name from reverse geocoding ("Blue Bottle
/// Coffee"). Stored as text so recall works even when geocoding is
/// unavailable later.
var placeName: String?
var thoroughfare: String?
var locality: String?
var administrativeArea: String?
var country: String?
/// Time zone the note was taken in; local hour / weekday are derived
/// from `meta.createdAt` under this zone rather than stored.
var timeZoneID: String?
/// "iPhone" / "Mac" — which device captured the note.
var device: String?
static let empty = CaptureContext()
var hasCoordinate: Bool { latitude != nil && longitude != nil }
/// Short human summary for rows and the composer ("Blue Bottle Coffee · Oakland").
var summary: String? {
let parts = [placeName, locality].compactMap { $0 }
return parts.isEmpty ? nil : parts.joined(separator: " · ")
}
}
/// One note = one JSON file in the iCloud container, filed under
/// `Records/YYYY/MM/<ULID>.json` (UTC-bucketed by the ULID's timestamp).
struct Note: SyncDocument, VersionedDocument, Equatable {
/// v2 added `Payload.audio` for voice notes. Because it is optional, a v1
/// file decodes unchanged (missing key ⇒ nil) and `migrateIfNeeded` only
/// bumps the stamped version — no field back-fill is needed.
static let currentSchemaVersion = 2
struct Meta: Codable, Sendable, Equatable {
var id: ULID
var schemaVersion: Int
var createdAt: Date
var modifiedAt: Date
}
/// How the note's text came to be. `transcribed` marks a voice note whose
/// text is (or will be) produced by on-device transcription.
enum Source: String, Codable, Sendable {
case typed
case transcribed
}
/// Where a voice note is in its transcription lifecycle.
enum TranscriptionStatus: String, Codable, Sendable {
case pending
case done
case failed
}
/// The audio-note sidecar metadata. Present only on voice notes; the audio
/// bytes themselves live in a `.m4a` next to the note JSON (path derived,
/// never stored — see `audioRelativePath`).
struct AudioInfo: Codable, Sendable, Equatable {
var duration: TimeInterval
/// Per-install UUID of the device that owns pending transcription. Only
/// the device whose `InstallIdentity.installID` matches this transcribes;
/// every other device renders status only. Re-transcribe reassigns it.
var transcriberDeviceID: String
var transcriptionStatus: TranscriptionStatus
/// BCP-47 locale the transcript was produced in; nil while pending.
var transcriptionLocaleID: String?
var transcribedAt: Date?
}
struct Payload: Codable, Sendable, Equatable {
var text: String
var source: Source
var tags: [String]
var context: CaptureContext
/// Voice-note sidecar metadata, nil for a plain typed note. Optional so
/// a v1 document (no `audio` key) decodes as a text note unchanged.
var audio: AudioInfo?
}
var meta: Meta
var payload: Payload
var id: ULID { meta.id }
var schemaVersion: Int { meta.schemaVersion }
var relativePath: String { Self.relativePath(forID: meta.id) }
static func relativePath(forID id: ULID) -> String {
TimeBucketedLayout.relativePath(for: id)
}
/// The `.m4a` sidecar path for a note, derived by swapping `.json` → `.m4a`
/// on the note's **actual** JSON path. The audio path is never stored — it
/// is always this swap — so it cannot drift from the note it belongs to, and
/// deleting a note whose file sits under a legacy (pre-fix) month bucket
/// still targets the sidecar that shares that bucket.
static func audioRelativePath(forJSONPath jsonPath: String) -> String {
guard jsonPath.hasSuffix(".json") else { return jsonPath + ".m4a" }
return String(jsonPath.dropLast(".json".count)) + ".m4a"
}
/// The `.m4a` sidecar path recomputed from a note id, sharing the note's
/// UTC-bucketed layout. Safe for load/warm paths because audio exists only
/// on v2+ records, which always file under the current UTC bucket — the
/// legacy local-month concern applies solely to v1 text notes (no audio).
static func audioRelativePath(forID id: ULID) -> String {
audioRelativePath(forJSONPath: relativePath(forID: id))
}
static func create(
text: String,
source: Source = .typed,
tags: [String] = [],
context: CaptureContext,
audio: AudioInfo? = nil
) -> Note {
let now = Date()
return Note(
meta: Meta(id: ULID(), schemaVersion: currentSchemaVersion, createdAt: now, modifiedAt: now),
payload: Payload(text: text, source: source, tags: tags, context: context, audio: audio)
)
}
/// Factory for a note ingested from another device's recording (the watch,
/// which has no iCloud of its own). The audio was captured elsewhere; this
/// device only stores and transcribes it. `createdAt` is the ULID's embedded
/// timestamp — the record-start instant on the capturing device — so the note
/// buckets under the month it was *recorded*, not the (possibly much later)
/// month it was ingested. Text is empty until transcription lands.
static func createIngested(
id: ULID,
duration: TimeInterval,
transcriberDeviceID: String,
tags: [String] = [],
context: CaptureContext
) -> Note {
let recordedAt = id.timestamp
return Note(
meta: Meta(id: id, schemaVersion: currentSchemaVersion, createdAt: recordedAt, modifiedAt: recordedAt),
payload: Payload(
text: "",
source: .transcribed,
tags: tags,
context: context,
audio: AudioInfo(
duration: duration,
transcriberDeviceID: transcriberDeviceID,
transcriptionStatus: .pending,
transcriptionLocaleID: nil,
transcribedAt: nil
)
)
)
}
/// First line of the text, used as the display title.
var title: String {
payload.text
.split(separator: "\n", omittingEmptySubsequences: true)
.first.map(String.init) ?? ""
}
}