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:
@@ -54,4 +54,69 @@ struct NoteMapperTests {
|
||||
#expect(restored.payload == note.payload)
|
||||
#expect(restored.meta.id == note.meta.id)
|
||||
}
|
||||
|
||||
// MARK: - Audio columns
|
||||
|
||||
@Test func pendingAudioColumnsRoundTrip() throws {
|
||||
let (container, context) = try makeStore()
|
||||
defer { withExtendedLifetime(container) {} }
|
||||
var ctx = CaptureContext.empty
|
||||
ctx.device = "Watch"
|
||||
let audio = Note.AudioInfo(
|
||||
duration: 30,
|
||||
transcriberDeviceID: "install-42",
|
||||
transcriptionStatus: .pending,
|
||||
transcriptionLocaleID: nil,
|
||||
transcribedAt: nil
|
||||
)
|
||||
let note = Note.create(text: "", source: .transcribed, context: ctx, audio: audio)
|
||||
NoteMapper.upsert(note, relativePath: note.relativePath, into: context)
|
||||
|
||||
let entity = try #require(NoteMapper.fetch(id: note.meta.id.stringValue, in: context))
|
||||
#expect(entity.hasAudio)
|
||||
#expect(entity.audioDuration == 30)
|
||||
#expect(entity.transcriberDeviceID == "install-42")
|
||||
#expect(entity.transcriptionStatusRaw == "pending")
|
||||
#expect(entity.source == .transcribed)
|
||||
|
||||
// A pending audio note carries no `transcribedAt`, so it reconstructs
|
||||
// losslessly (that field is the only one the cache intentionally drops).
|
||||
let restored = try #require(NoteMapper.note(from: entity))
|
||||
#expect(restored.payload == note.payload)
|
||||
#expect(restored.payload.audio == audio)
|
||||
}
|
||||
|
||||
@Test func doneTranscriptionCachesColumnsButNotTranscribedAt() throws {
|
||||
let (container, context) = try makeStore()
|
||||
defer { withExtendedLifetime(container) {} }
|
||||
let audio = Note.AudioInfo(
|
||||
duration: 5,
|
||||
transcriberDeviceID: "install-7",
|
||||
transcriptionStatus: .done,
|
||||
transcriptionLocaleID: "en_US",
|
||||
transcribedAt: Date()
|
||||
)
|
||||
let note = Note.create(text: "Hello there", source: .transcribed, context: .empty, audio: audio)
|
||||
NoteMapper.upsert(note, relativePath: note.relativePath, into: context)
|
||||
|
||||
let entity = try #require(NoteMapper.fetch(id: note.meta.id.stringValue, in: context))
|
||||
#expect(entity.transcriptionStatusRaw == "done")
|
||||
#expect(entity.transcriptionLocaleID == "en_US")
|
||||
// The file is authoritative for the timestamp — the cache doesn't store it.
|
||||
#expect(entity.audio?.transcribedAt == nil)
|
||||
#expect(entity.audio?.transcriptionStatus == .done)
|
||||
}
|
||||
|
||||
@Test func textNoteHasNoAudioColumns() throws {
|
||||
let (container, context) = try makeStore()
|
||||
defer { withExtendedLifetime(container) {} }
|
||||
let note = makeNote()
|
||||
NoteMapper.upsert(note, relativePath: note.relativePath, into: context)
|
||||
|
||||
let entity = try #require(NoteMapper.fetch(id: note.meta.id.stringValue, in: context))
|
||||
#expect(!entity.hasAudio)
|
||||
#expect(entity.audio == nil)
|
||||
#expect(entity.audioDuration == nil)
|
||||
#expect(entity.transcriptionStatusRaw == nil)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user