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,190 @@
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
import IndieSync
|
||||
|
||||
/// Records a single voice note into a temporary `<ULID>.m4a` (AAC mono
|
||||
/// 44.1 kHz ~48 kbps). Modeled on `ContextService`: `@Observable @MainActor`,
|
||||
/// so the UI observes `isRecording` / `elapsed` / `level` directly.
|
||||
///
|
||||
/// Never records into the iCloud container — the file is written to the temp
|
||||
/// directory and handed up as a URL; `SyncEngine.createAudioNote` is what copies
|
||||
/// the finished bytes into the container (audio before JSON). `AVAudioRecorder`
|
||||
/// is not Sendable, so it stays confined to the main actor for its whole life.
|
||||
@Observable
|
||||
@MainActor
|
||||
final class AudioRecorderService {
|
||||
/// One finished recording: the temp file and how long it runs. The caller
|
||||
/// owns the file from here on (moves its bytes into the container, then
|
||||
/// discards the temp copy).
|
||||
struct Recording: Equatable {
|
||||
let url: URL
|
||||
let duration: TimeInterval
|
||||
}
|
||||
|
||||
private(set) var isRecording = false
|
||||
/// Seconds recorded so far, updated by the metering loop while recording.
|
||||
private(set) var elapsed: TimeInterval = 0
|
||||
/// Normalized input level (0…1) for the level-bar UI; 0 when idle.
|
||||
private(set) var level: Float = 0
|
||||
|
||||
/// Fired when a system interruption (phone call, Siri) forces recording to
|
||||
/// stop. The partial file is finalized and handed up rather than lost, so the
|
||||
/// UI can move straight to the "recorded" state. nil unless a recorder wires
|
||||
/// it up for the duration of a take.
|
||||
var onInterrupted: ((Recording) -> Void)?
|
||||
|
||||
private var recorder: AVAudioRecorder?
|
||||
private var meterTask: Task<Void, Never>?
|
||||
private var currentURL: URL?
|
||||
|
||||
#if os(iOS)
|
||||
private var interruptionObserver: (any NSObjectProtocol)?
|
||||
#endif
|
||||
|
||||
/// Ask for microphone access, returning whether it was granted. Safe to call
|
||||
/// every time recording starts — the system only prompts once.
|
||||
func requestPermission() async -> Bool {
|
||||
await AVAudioApplication.requestRecordPermission()
|
||||
}
|
||||
|
||||
/// Whether the mic has been explicitly denied (so the UI can show a jump to
|
||||
/// Settings instead of a dead record button).
|
||||
var permissionDenied: Bool {
|
||||
AVAudioApplication.shared.recordPermission == .denied
|
||||
}
|
||||
|
||||
/// Begin recording into a fresh temp file. Throws if the audio session or
|
||||
/// recorder can't be configured (surfaced by the UI). A no-op if already
|
||||
/// recording.
|
||||
func startRecording() throws {
|
||||
guard !isRecording else { return }
|
||||
|
||||
#if os(iOS)
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
try session.setCategory(.playAndRecord, mode: .spokenAudio, options: [.duckOthers])
|
||||
try session.setActive(true)
|
||||
observeInterruptions()
|
||||
#endif
|
||||
|
||||
let url = FileManager.default.temporaryDirectory
|
||||
.appending(path: "\(ULID().stringValue).m4a")
|
||||
let settings: [String: Any] = [
|
||||
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
||||
AVSampleRateKey: 44_100.0,
|
||||
AVNumberOfChannelsKey: 1,
|
||||
AVEncoderBitRateKey: 48_000,
|
||||
AVEncoderAudioQualityKey: AVAudioQuality.medium.rawValue,
|
||||
]
|
||||
|
||||
let recorder = try AVAudioRecorder(url: url, settings: settings)
|
||||
recorder.isMeteringEnabled = true
|
||||
guard recorder.record() else {
|
||||
throw RecorderError.couldNotStart
|
||||
}
|
||||
self.recorder = recorder
|
||||
self.currentURL = url
|
||||
isRecording = true
|
||||
elapsed = 0
|
||||
level = 0
|
||||
startMetering()
|
||||
}
|
||||
|
||||
/// Stop recording and return the finished file plus its duration, or nil if
|
||||
/// nothing was recording. Deactivates the audio session on iOS.
|
||||
@discardableResult
|
||||
func stopRecording() -> Recording? {
|
||||
guard let recorder, let url = currentURL else { return nil }
|
||||
let duration = recorder.currentTime
|
||||
recorder.stop()
|
||||
teardown()
|
||||
return Recording(url: url, duration: duration)
|
||||
}
|
||||
|
||||
/// Abandon the in-progress (or just-finished) recording and delete its temp
|
||||
/// file. Used by "Discard" and when the composer is dismissed unsaved.
|
||||
func discard() {
|
||||
recorder?.stop()
|
||||
if let url = currentURL {
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
}
|
||||
teardown()
|
||||
}
|
||||
|
||||
// MARK: - Metering
|
||||
|
||||
/// ~30 Hz loop that refreshes `elapsed` and `level` while recording. Runs on
|
||||
/// the main actor (a plain `Task` inherits this actor), so it can touch the
|
||||
/// non-Sendable recorder safely.
|
||||
private func startMetering() {
|
||||
meterTask?.cancel()
|
||||
meterTask = Task { [weak self] in
|
||||
while !Task.isCancelled {
|
||||
guard let self, let recorder = self.recorder, recorder.isRecording else { return }
|
||||
recorder.updateMeters()
|
||||
self.elapsed = recorder.currentTime
|
||||
self.level = Self.normalizedLevel(recorder.averagePower(forChannel: 0))
|
||||
try? await Task.sleep(for: .milliseconds(33))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a dBFS reading (roughly -60…0) to a 0…1 bar height.
|
||||
private static func normalizedLevel(_ power: Float) -> Float {
|
||||
let floorDB: Float = -50
|
||||
guard power > floorDB else { return 0 }
|
||||
return min(1, (power - floorDB) / -floorDB)
|
||||
}
|
||||
|
||||
private func teardown() {
|
||||
meterTask?.cancel()
|
||||
meterTask = nil
|
||||
recorder = nil
|
||||
currentURL = nil
|
||||
isRecording = false
|
||||
elapsed = 0
|
||||
level = 0
|
||||
#if os(iOS)
|
||||
if let interruptionObserver {
|
||||
NotificationCenter.default.removeObserver(interruptionObserver)
|
||||
self.interruptionObserver = nil
|
||||
}
|
||||
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - Interruptions (iOS)
|
||||
|
||||
#if os(iOS)
|
||||
private func observeInterruptions() {
|
||||
guard interruptionObserver == nil else { return }
|
||||
interruptionObserver = NotificationCenter.default.addObserver(
|
||||
forName: AVAudioSession.interruptionNotification,
|
||||
object: AVAudioSession.sharedInstance(),
|
||||
queue: .main
|
||||
) { [weak self] note in
|
||||
// Extract the Sendable interruption type before hopping — the
|
||||
// Notification itself isn't Sendable. Delivered on the main queue, so
|
||||
// assuming main-actor isolation is sound.
|
||||
let began = (note.userInfo?[AVAudioSessionInterruptionTypeKey] as? UInt)
|
||||
.flatMap(AVAudioSession.InterruptionType.init(rawValue:)) == .began
|
||||
guard began else { return }
|
||||
MainActor.assumeIsolated {
|
||||
self?.handleInterruptionBegan()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleInterruptionBegan() {
|
||||
guard isRecording else { return }
|
||||
// Finalize rather than drop the take — the partial recording is still a
|
||||
// valid note.
|
||||
if let finished = stopRecording() {
|
||||
onInterrupted?(finished)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
enum RecorderError: Error {
|
||||
case couldNotStart
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import Foundation
|
||||
|
||||
/// Stable per-install identifier — a UUID minted once on first access and kept
|
||||
/// in UserDefaults for the life of the install. Distinct from the iCloud
|
||||
/// account: two devices signed into the same account get different install IDs.
|
||||
///
|
||||
/// It answers "which device owns a pending transcription?" A voice note stamps
|
||||
/// the recording device's `installID` into `AudioInfo.transcriberDeviceID`; only
|
||||
/// the device whose `installID` matches transcribes it, while the others render
|
||||
/// status only. Re-transcribing on any device reassigns the id to itself, which
|
||||
/// is also the recovery path when the original recording device is gone.
|
||||
enum InstallIdentity {
|
||||
private static let key = "Notes.installID"
|
||||
|
||||
/// The per-install UUID, created lazily on first read and reused forever.
|
||||
static var installID: String {
|
||||
if let existing = UserDefaults.standard.string(forKey: key) {
|
||||
return existing
|
||||
}
|
||||
let fresh = UUID().uuidString
|
||||
UserDefaults.standard.set(fresh, forKey: key)
|
||||
return fresh
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
import IndieSync
|
||||
import Speech
|
||||
import SwiftData
|
||||
|
||||
/// On-device, after-the-fact transcription of voice notes. `@Observable
|
||||
/// @MainActor` with a FIFO one-at-a-time queue: a `SpeechAnalyzer` is memory- and
|
||||
/// model-heavy, so notes are transcribed strictly one after another. The analyzer
|
||||
/// work is awaited (the analyzer is an actor), so it doesn't block the main actor
|
||||
/// despite this class being main-actor confined.
|
||||
///
|
||||
/// Only this device's own pending notes are ever enqueued — ownership is decided
|
||||
/// by `AudioInfo.transcriberDeviceID` upstream (`SyncEngine`), which this service
|
||||
/// trusts. A failed job is terminal until the user asks to retry.
|
||||
@Observable
|
||||
@MainActor
|
||||
final class TranscriptionService {
|
||||
private let syncEngine: SyncEngine
|
||||
private let settings: TranscriptionSettings
|
||||
private let modelContainer: ModelContainer
|
||||
|
||||
/// Seconds of audio the sampling pass listens to when choosing a language.
|
||||
private static let sampleSeconds: Double = 15
|
||||
|
||||
private struct Job {
|
||||
let noteID: ULID
|
||||
/// A fresh recording's temp file, passed on enqueue for the fast path; nil
|
||||
/// for re-transcription, which pulls the audio back down from the container.
|
||||
let localAudioURL: URL?
|
||||
/// A forced language (re-transcribe menu), else auto-select.
|
||||
let forcedLocaleID: String?
|
||||
}
|
||||
|
||||
private var queue: [Job] = []
|
||||
/// Ids currently queued or in flight, so overlapping triggers (launch resume,
|
||||
/// watch ingest, a re-transcribe) can't enqueue the same note twice.
|
||||
private var inFlightIDs: Set<String> = []
|
||||
private var pumpTask: Task<Void, Never>?
|
||||
|
||||
init(syncEngine: SyncEngine, settings: TranscriptionSettings, modelContainer: ModelContainer) {
|
||||
self.syncEngine = syncEngine
|
||||
self.settings = settings
|
||||
self.modelContainer = modelContainer
|
||||
}
|
||||
|
||||
// MARK: - Queue
|
||||
|
||||
/// Queue a note for transcription. `localAudioURL` is the fresh-recording fast
|
||||
/// path (skips the container round-trip); `forcedLocaleID` skips language
|
||||
/// auto-selection. Duplicate enqueues of an in-flight note are ignored.
|
||||
func enqueue(noteID: ULID, localAudioURL: URL? = nil, forcedLocaleID: String? = nil) {
|
||||
let key = noteID.stringValue
|
||||
guard !inFlightIDs.contains(key) else { return }
|
||||
inFlightIDs.insert(key)
|
||||
queue.append(Job(noteID: noteID, localAudioURL: localAudioURL, forcedLocaleID: forcedLocaleID))
|
||||
pump()
|
||||
}
|
||||
|
||||
/// On launch, re-enqueue every note this device owns that is still pending —
|
||||
/// recovering work that a crash or kill interrupted mid-transcription. Reads
|
||||
/// the cache (already rebuilt by the sync engine's connect).
|
||||
func resumePendingWork() {
|
||||
let context = modelContainer.mainContext
|
||||
let installID = TranscriptionSettings.installID
|
||||
let pendingRaw = Note.TranscriptionStatus.pending.rawValue
|
||||
let descriptor = FetchDescriptor<NoteEntity>(predicate: #Predicate {
|
||||
$0.hasAudio
|
||||
&& $0.transcriptionStatusRaw == pendingRaw
|
||||
&& $0.transcriberDeviceID == installID
|
||||
})
|
||||
let rows = (try? context.fetch(descriptor)) ?? []
|
||||
for row in rows {
|
||||
if let id = ULID(string: row.id) { enqueue(noteID: id) }
|
||||
}
|
||||
}
|
||||
|
||||
private func pump() {
|
||||
guard pumpTask == nil else { return }
|
||||
pumpTask = Task { [weak self] in
|
||||
while let self, let job = self.dequeue() {
|
||||
await self.process(job)
|
||||
self.inFlightIDs.remove(job.noteID.stringValue)
|
||||
}
|
||||
self?.pumpTask = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func dequeue() -> Job? {
|
||||
queue.isEmpty ? nil : queue.removeFirst()
|
||||
}
|
||||
|
||||
// MARK: - Processing
|
||||
|
||||
/// Transcribe one job, retrying once before marking the note failed. A failed
|
||||
/// note is left for the user to retry explicitly (re-transcribe / Retry).
|
||||
private func process(_ job: Job) async {
|
||||
do {
|
||||
try await transcribe(job)
|
||||
} catch {
|
||||
do {
|
||||
try await transcribe(job)
|
||||
} catch {
|
||||
print("[Transcription] \(job.noteID.stringValue) failed: \(error)")
|
||||
try? await syncEngine.markTranscriptionFailed(id: job.noteID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func transcribe(_ job: Job) async throws {
|
||||
let audio = try await resolveAudioFile(for: job)
|
||||
defer { cleanUp(audio: audio, job: job) }
|
||||
|
||||
let locale = try await selectLocale(fileURL: audio.url, forcedLocaleID: job.forcedLocaleID)
|
||||
let transcript = try await runTranscription(fileURL: audio.url, locale: locale)
|
||||
let text = String(transcript.characters).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
try await syncEngine.applyTranscription(
|
||||
id: job.noteID,
|
||||
text: text,
|
||||
localeID: TranscriptionSettings.canonicalID(locale)
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Audio acquisition
|
||||
|
||||
private struct AudioFile {
|
||||
let url: URL
|
||||
/// Whether we wrote a temp copy that must be cleaned up after the job.
|
||||
let isTemporary: Bool
|
||||
}
|
||||
|
||||
/// The fresh recording if it's still on disk, else a temp copy pulled back
|
||||
/// from the container (bounded wait for an evicted file).
|
||||
private func resolveAudioFile(for job: Job) async throws -> AudioFile {
|
||||
if let local = job.localAudioURL, FileManager.default.fileExists(atPath: local.path) {
|
||||
return AudioFile(url: local, isTemporary: false)
|
||||
}
|
||||
let data = try await syncEngine.loadAudioDataWaiting(for: job.noteID)
|
||||
let url = FileManager.default.temporaryDirectory
|
||||
.appending(path: "transcribe-\(job.noteID.stringValue).m4a")
|
||||
try data.write(to: url)
|
||||
return AudioFile(url: url, isTemporary: true)
|
||||
}
|
||||
|
||||
private func cleanUp(audio: AudioFile, job: Job) {
|
||||
let fm = FileManager.default
|
||||
if audio.isTemporary { try? fm.removeItem(at: audio.url) }
|
||||
// A fresh recording's temp file is no longer needed once the container
|
||||
// holds the bytes; drop it to keep the temp dir tidy.
|
||||
if let local = job.localAudioURL,
|
||||
local.path.hasPrefix(fm.temporaryDirectory.path) {
|
||||
try? fm.removeItem(at: local)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Language selection
|
||||
|
||||
private func selectLocale(fileURL: URL, forcedLocaleID: String?) async throws -> Locale {
|
||||
if let forcedLocaleID {
|
||||
return Locale(identifier: forcedLocaleID)
|
||||
}
|
||||
let candidates = await settings.enabledInstalledLocales()
|
||||
guard !candidates.isEmpty else { throw TranscriptionError.noInstalledLanguage }
|
||||
if candidates.count == 1 { return candidates[0] }
|
||||
|
||||
// Sampling pass: transcribe a short clip in each language and pick the
|
||||
// most confident. Run sequentially — one analyzer at a time keeps the
|
||||
// memory ceiling low.
|
||||
let sample = try makeSampleClip(from: fileURL, seconds: Self.sampleSeconds)
|
||||
defer { if sample.isTemporary { try? FileManager.default.removeItem(at: sample.url) } }
|
||||
|
||||
var scored: [LanguageCandidate] = []
|
||||
for locale in candidates {
|
||||
let transcript = (try? await runTranscription(fileURL: sample.url, locale: locale)) ?? AttributedString()
|
||||
scored.append(LanguageCandidate(
|
||||
localeID: TranscriptionSettings.canonicalID(locale),
|
||||
meanConfidence: LanguagePicker.meanConfidence(of: transcript),
|
||||
transcriptLength: transcript.characters.count
|
||||
))
|
||||
}
|
||||
guard let winner = LanguagePicker.pick(from: scored) else { return candidates[0] }
|
||||
return Locale(identifier: winner.localeID)
|
||||
}
|
||||
|
||||
/// The first `seconds` of the recording as its own file, so the sampling pass
|
||||
/// isn't run over a whole long note. Returns the original file (no copy) when
|
||||
/// the recording is already shorter than the sample window.
|
||||
private func makeSampleClip(from fileURL: URL, seconds: Double) throws -> AudioFile {
|
||||
let source = try AVAudioFile(forReading: fileURL)
|
||||
let format = source.processingFormat
|
||||
let wantFrames = format.sampleRate * seconds
|
||||
guard Double(source.length) > wantFrames else {
|
||||
return AudioFile(url: fileURL, isTemporary: false)
|
||||
}
|
||||
let frameCount = AVAudioFrameCount(wantFrames)
|
||||
guard frameCount > 0, let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: frameCount) else {
|
||||
throw TranscriptionError.audioUnreadable
|
||||
}
|
||||
try source.read(into: buffer, frameCount: frameCount)
|
||||
let clipURL = FileManager.default.temporaryDirectory
|
||||
.appending(path: "sample-\(UUID().uuidString).caf")
|
||||
let clip = try AVAudioFile(forWriting: clipURL, settings: format.settings)
|
||||
try clip.write(from: buffer)
|
||||
return AudioFile(url: clipURL, isTemporary: true)
|
||||
}
|
||||
|
||||
// MARK: - Analyzer
|
||||
|
||||
/// Transcribe a whole file in one locale and return the finalized attributed
|
||||
/// transcript (carrying per-run confidence). Follows the documented pattern:
|
||||
/// start a collector over `transcriber.results`, feed the file, finalize.
|
||||
private func runTranscription(fileURL: URL, locale: Locale) async throws -> AttributedString {
|
||||
let transcriber = SpeechTranscriber(
|
||||
locale: locale,
|
||||
transcriptionOptions: [],
|
||||
reportingOptions: [],
|
||||
attributeOptions: [.transcriptionConfidence]
|
||||
)
|
||||
let analyzer = SpeechAnalyzer(
|
||||
modules: [transcriber],
|
||||
options: SpeechAnalyzer.Options(priority: .utility, modelRetention: .whileInUse)
|
||||
)
|
||||
let file = try AVAudioFile(forReading: fileURL)
|
||||
|
||||
// Collect finalized results concurrently; `results` terminates when the
|
||||
// analyzer finishes, ending the loop.
|
||||
let collector = Task {
|
||||
var full = AttributedString()
|
||||
for try await result in transcriber.results where result.isFinal {
|
||||
full.append(result.text)
|
||||
}
|
||||
return full
|
||||
}
|
||||
|
||||
do {
|
||||
if let lastSample = try await analyzer.analyzeSequence(from: file) {
|
||||
try await analyzer.finalizeAndFinish(through: lastSample)
|
||||
} else {
|
||||
await analyzer.cancelAndFinishNow()
|
||||
}
|
||||
} catch {
|
||||
collector.cancel()
|
||||
throw error
|
||||
}
|
||||
return try await collector.value
|
||||
}
|
||||
|
||||
enum TranscriptionError: Error {
|
||||
case audioUnreadable
|
||||
case noInstalledLanguage
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import Foundation
|
||||
import Speech
|
||||
|
||||
/// The user's transcription-language configuration and the Speech asset state
|
||||
/// behind it. `@Observable @MainActor` so the Settings UI binds to it directly.
|
||||
///
|
||||
/// There is no automatic language detection in `SpeechAnalyzer`, so the user
|
||||
/// enables a small set of languages; a new voice note is sampled in each and the
|
||||
/// highest-confidence one wins (see `TranscriptionService`). Enabling a language
|
||||
/// *reserves* it with `AssetInventory` — which is both what keeps its model from
|
||||
/// being purged and what the system caps at `AssetInventory.maximumReservedLocales`
|
||||
/// — so "enabled" and "reserved" are kept in lock-step and the UI enforces the cap.
|
||||
@Observable
|
||||
@MainActor
|
||||
final class TranscriptionSettings {
|
||||
private static let enabledKey = "Notes.enabledTranscriptionLocales"
|
||||
private static let seededKey = "Notes.transcriptionLocalesSeeded"
|
||||
|
||||
/// Per-install id of the transcription owner, reused from `InstallIdentity`
|
||||
/// — a voice note stamps this into `AudioInfo.transcriberDeviceID` so only the
|
||||
/// recording device transcribes it.
|
||||
static var installID: String { InstallIdentity.installID }
|
||||
|
||||
/// Canonical BCP-47 ids of the enabled languages, persisted in UserDefaults.
|
||||
private(set) var enabledLocaleIDs: [String]
|
||||
|
||||
/// Every locale `SpeechTranscriber` can transcribe (36+), loaded once by
|
||||
/// `prepare()`. Empty until then.
|
||||
private(set) var supportedLocales: [Locale] = []
|
||||
|
||||
/// Runtime cap on how many languages can be enabled at once — read from the
|
||||
/// system, not hard-coded, because it can change.
|
||||
private(set) var maximumReserved = 0
|
||||
|
||||
/// Per-language asset state, keyed by canonical BCP-47 id, for the row UI.
|
||||
private(set) var assetStates: [String: AssetState] = [:]
|
||||
|
||||
/// Where a language's on-device model sits.
|
||||
enum AssetState: Equatable {
|
||||
case unsupported
|
||||
case notInstalled
|
||||
/// Downloading; the fraction is nil until progress is known.
|
||||
case downloading(Double?)
|
||||
case installed
|
||||
}
|
||||
|
||||
private let defaults: UserDefaults
|
||||
|
||||
init(defaults: UserDefaults = .standard) {
|
||||
self.defaults = defaults
|
||||
self.enabledLocaleIDs = defaults.stringArray(forKey: Self.enabledKey) ?? []
|
||||
}
|
||||
|
||||
// MARK: - Canonical ids
|
||||
|
||||
/// The stable key for a locale everywhere in the app: its BCP-47 identifier
|
||||
/// (`en-US`), so it round-trips through `Locale(identifier:)`.
|
||||
static func canonicalID(_ locale: Locale) -> String { locale.identifier(.bcp47) }
|
||||
|
||||
/// The enabled languages as `Locale`s, in the order the user has them.
|
||||
var enabledLocales: [Locale] { enabledLocaleIDs.map(Locale.init(identifier:)) }
|
||||
|
||||
/// Localized display name for a locale ("English (United States)"), falling
|
||||
/// back to the raw identifier.
|
||||
static func displayName(for locale: Locale) -> String {
|
||||
Locale.current.localizedString(forIdentifier: locale.identifier(.bcp47))
|
||||
?? locale.identifier(.bcp47)
|
||||
}
|
||||
|
||||
// MARK: - Loading
|
||||
|
||||
/// One-time load of the supported-locale list and the reservation cap, plus a
|
||||
/// first-run seed of the enabled set from the user's preferred languages. Idempotent
|
||||
/// and cheap to call again — it re-fetches the list but only seeds once.
|
||||
func prepare() async {
|
||||
maximumReserved = AssetInventory.maximumReservedLocales
|
||||
supportedLocales = await SpeechTranscriber.supportedLocales
|
||||
.sorted { Self.displayName(for: $0) < Self.displayName(for: $1) }
|
||||
|
||||
if !defaults.bool(forKey: Self.seededKey) {
|
||||
seedDefaults()
|
||||
defaults.set(true, forKey: Self.seededKey)
|
||||
}
|
||||
await refreshStates()
|
||||
}
|
||||
|
||||
/// Seed the enabled set from `Locale.preferredLanguages ∩ supportedLocales`,
|
||||
/// matched by language code and capped at the reservation limit. Falls back to
|
||||
/// English (or the first supported locale) so a note recorded before the user
|
||||
/// visits Settings still has something to transcribe with.
|
||||
private func seedDefaults() {
|
||||
guard enabledLocaleIDs.isEmpty else { return }
|
||||
var seed: [String] = []
|
||||
for preferred in Locale.preferredLanguages.map(Locale.init(identifier:)) {
|
||||
guard let match = supportedLocales.first(where: {
|
||||
$0.language.languageCode == preferred.language.languageCode
|
||||
}) else { continue }
|
||||
let id = Self.canonicalID(match)
|
||||
if !seed.contains(id) { seed.append(id) }
|
||||
}
|
||||
if seed.isEmpty {
|
||||
let fallback = supportedLocales.first { $0.language.languageCode?.identifier == "en" }
|
||||
?? supportedLocales.first
|
||||
if let fallback { seed = [Self.canonicalID(fallback)] }
|
||||
}
|
||||
enabledLocaleIDs = Array(seed.prefix(max(1, maximumReserved)))
|
||||
persist()
|
||||
// Reserve the seeded languages so their models are protected from purge.
|
||||
Task { await self.reserveEnabled() }
|
||||
}
|
||||
|
||||
private func reserveEnabled() async {
|
||||
for locale in enabledLocales {
|
||||
_ = try? await AssetInventory.reserve(locale: locale)
|
||||
}
|
||||
}
|
||||
|
||||
/// Refresh every supported locale's asset state (installed / downloadable /
|
||||
/// unsupported). Runs sequentially — `status(forModules:)` is cheap but there
|
||||
/// are dozens of locales, and there's no need to fan out.
|
||||
func refreshStates() async {
|
||||
for locale in supportedLocales {
|
||||
let id = Self.canonicalID(locale)
|
||||
// Preserve an in-flight download's fraction rather than stomping it.
|
||||
if case .downloading = assetStates[id] { continue }
|
||||
assetStates[id] = await currentState(for: locale)
|
||||
}
|
||||
}
|
||||
|
||||
private func currentState(for locale: Locale) async -> AssetState {
|
||||
switch await AssetInventory.status(forModules: [Self.transcriber(for: locale)]) {
|
||||
case .unsupported: return .unsupported
|
||||
case .supported: return .notInstalled
|
||||
case .downloading: return .downloading(nil)
|
||||
case .installed: return .installed
|
||||
@unknown default: return .notInstalled
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Enable / disable
|
||||
|
||||
var isAtCapacity: Bool { enabledLocaleIDs.count >= maximumReserved }
|
||||
|
||||
func isEnabled(_ locale: Locale) -> Bool {
|
||||
enabledLocaleIDs.contains(Self.canonicalID(locale))
|
||||
}
|
||||
|
||||
/// Enable a language: reserve it, mark it enabled, and download its model if
|
||||
/// needed (surfacing progress through `assetStates`). Throws `atCapacity` when
|
||||
/// already at the reservation cap.
|
||||
func enable(_ locale: Locale) async throws {
|
||||
let id = Self.canonicalID(locale)
|
||||
guard !enabledLocaleIDs.contains(id) else { return }
|
||||
guard !isAtCapacity else { throw SettingsError.atCapacity }
|
||||
|
||||
_ = try await AssetInventory.reserve(locale: locale)
|
||||
enabledLocaleIDs.append(id)
|
||||
persist()
|
||||
try await installIfNeeded(locale)
|
||||
}
|
||||
|
||||
/// Disable a language: drop it from the enabled set and release its
|
||||
/// reservation (letting the system reclaim the model later). Best-effort.
|
||||
func disable(_ locale: Locale) async {
|
||||
let id = Self.canonicalID(locale)
|
||||
enabledLocaleIDs.removeAll { $0 == id }
|
||||
persist()
|
||||
_ = await AssetInventory.release(reservedLocale: locale)
|
||||
assetStates[id] = await currentState(for: locale)
|
||||
}
|
||||
|
||||
private func installIfNeeded(_ locale: Locale) async throws {
|
||||
let id = Self.canonicalID(locale)
|
||||
let transcriber = Self.transcriber(for: locale)
|
||||
guard await AssetInventory.status(forModules: [transcriber]) != .installed else {
|
||||
assetStates[id] = .installed
|
||||
return
|
||||
}
|
||||
guard let request = try await AssetInventory.assetInstallationRequest(supporting: [transcriber]) else {
|
||||
assetStates[id] = await currentState(for: locale)
|
||||
return
|
||||
}
|
||||
let progress = request.progress
|
||||
assetStates[id] = .downloading(0)
|
||||
// Poll the Progress so the @Observable dict updates the row; Progress KVO
|
||||
// doesn't drive SwiftUI on its own.
|
||||
let poll = Task { [weak self] in
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(for: .milliseconds(300))
|
||||
guard let self else { return }
|
||||
self.assetStates[id] = .downloading(progress.fractionCompleted)
|
||||
if progress.isFinished { return }
|
||||
}
|
||||
}
|
||||
defer { poll.cancel() }
|
||||
try await request.downloadAndInstall()
|
||||
assetStates[id] = await currentState(for: locale)
|
||||
}
|
||||
|
||||
/// The subset of enabled languages whose models are installed — the only ones
|
||||
/// `TranscriptionService` can actually run.
|
||||
func enabledInstalledLocales() async -> [Locale] {
|
||||
var installed: [Locale] = []
|
||||
for locale in enabledLocales
|
||||
where await AssetInventory.status(forModules: [Self.transcriber(for: locale)]) == .installed {
|
||||
installed.append(locale)
|
||||
}
|
||||
return installed
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
/// A minimal transcriber for a locale, used purely for asset queries. The
|
||||
/// attribute options don't affect asset status, so they're omitted here.
|
||||
static func transcriber(for locale: Locale) -> SpeechTranscriber {
|
||||
SpeechTranscriber(
|
||||
locale: locale,
|
||||
transcriptionOptions: [],
|
||||
reportingOptions: [],
|
||||
attributeOptions: []
|
||||
)
|
||||
}
|
||||
|
||||
private func persist() {
|
||||
defaults.set(enabledLocaleIDs, forKey: Self.enabledKey)
|
||||
}
|
||||
|
||||
enum SettingsError: Error {
|
||||
case atCapacity
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user