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:
2026-07-15 13:30:43 -04:00
parent 6a93cedaa6
commit 86d74806c9
44 changed files with 3329 additions and 123 deletions
+36
View File
@@ -30,6 +30,17 @@ final class NoteEntity {
var timeZoneID: String?
var device: String?
// Flattened AudioInfo nil columns mean a plain text note. `hasAudio` is
// stored rather than derived from `audioDuration` so a zero-length recording
// is still recognizably a voice note. `transcribedAt` is deliberately not
// cached (the file is authoritative for that timestamp), so a cache-only
// reconstruction of a completed note has a nil `transcribedAt`.
var hasAudio: Bool = false
var audioDuration: Double?
var transcriptionStatusRaw: String?
var transcriptionLocaleID: String?
var transcriberDeviceID: String?
init(id: String, jsonRelativePath: String) {
self.id = id
self.jsonRelativePath = jsonRelativePath
@@ -70,6 +81,31 @@ extension NoteEntity {
Note.Source(rawValue: sourceRaw) ?? .typed
}
/// Reconstructs `AudioInfo` from the flattened columns (nil for a text
/// note). `transcribedAt` is not cached, so it comes back nil even for a
/// completed transcription acceptable for the cache-only offline fallback,
/// where the file (which carries it) simply wasn't reachable.
var audio: Note.AudioInfo? {
get {
guard hasAudio else { return nil }
return Note.AudioInfo(
duration: audioDuration ?? 0,
transcriberDeviceID: transcriberDeviceID ?? "",
transcriptionStatus: transcriptionStatusRaw
.flatMap(Note.TranscriptionStatus.init(rawValue:)) ?? .pending,
transcriptionLocaleID: transcriptionLocaleID,
transcribedAt: nil
)
}
set {
hasAudio = newValue != nil
audioDuration = newValue?.duration
transcriberDeviceID = newValue?.transcriberDeviceID
transcriptionStatusRaw = newValue?.transcriptionStatus.rawValue
transcriptionLocaleID = newValue?.transcriptionLocaleID
}
}
/// First line of the text, used as the display title.
var title: String {
text.split(separator: "\n", omittingEmptySubsequences: true)