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,57 @@
|
||||
import Foundation
|
||||
import Speech
|
||||
|
||||
/// One language's showing in the sampling pass: how well `SpeechTranscriber`
|
||||
/// transcribed the first few seconds of audio in that locale.
|
||||
struct LanguageCandidate: Equatable {
|
||||
/// BCP-47 identifier of the locale (e.g. `en-US`, `ru-RU`).
|
||||
let localeID: String
|
||||
/// Character-count-weighted mean of the per-run transcription confidence,
|
||||
/// 0…1. A locale that produced no text scores 0.
|
||||
let meanConfidence: Double
|
||||
/// Number of characters the transcript came out to — the tie-breaker and the
|
||||
/// reason an empty transcript can't win.
|
||||
let transcriptLength: Int
|
||||
}
|
||||
|
||||
/// Pure, testable language selection for a voice note. No `SpeechAnalyzer` calls
|
||||
/// live here: the service transcribes a ~15 s sample in each enabled language,
|
||||
/// turns each into a `LanguageCandidate`, and this picks the winner. Keeping it
|
||||
/// side-effect-free is what makes the (undocumented, cross-locale) confidence
|
||||
/// calibration unit-testable.
|
||||
enum LanguagePicker {
|
||||
/// Character-count-weighted mean confidence over an attributed transcript.
|
||||
/// Only runs that actually carry a `transcriptionConfidence` attribute
|
||||
/// contribute, weighted by their character length; a transcript with no
|
||||
/// confidence anywhere (or no characters) scores 0.
|
||||
static func meanConfidence(of attributed: AttributedString) -> Double {
|
||||
var weighted = 0.0
|
||||
var totalCharacters = 0
|
||||
for run in attributed.runs {
|
||||
guard let confidence = run.transcriptionConfidence else { continue }
|
||||
let length = attributed[run.range].characters.count
|
||||
guard length > 0 else { continue }
|
||||
weighted += confidence * Double(length)
|
||||
totalCharacters += length
|
||||
}
|
||||
return totalCharacters > 0 ? weighted / Double(totalCharacters) : 0
|
||||
}
|
||||
|
||||
/// The best candidate, or nil when given nothing. Argmax on `meanConfidence`;
|
||||
/// a zero-length transcript can only win if every candidate is empty. Ties
|
||||
/// break deterministically — higher confidence, then longer transcript, then
|
||||
/// lexicographically smaller `localeID` — so the same samples always yield the
|
||||
/// same pick.
|
||||
static func pick(from candidates: [LanguageCandidate]) -> LanguageCandidate? {
|
||||
candidates.max { lhs, rhs in
|
||||
if lhs.meanConfidence != rhs.meanConfidence {
|
||||
return lhs.meanConfidence < rhs.meanConfidence
|
||||
}
|
||||
if lhs.transcriptLength != rhs.transcriptLength {
|
||||
return lhs.transcriptLength < rhs.transcriptLength
|
||||
}
|
||||
// `max` keeps the greater element; invert so the smaller localeID wins.
|
||||
return lhs.localeID > rhs.localeID
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user