import AVFoundation import Foundation import Speech /// Streaming on-device transcription via macOS 26's `SpeechAnalyzer` / `SpeechTranscriber`. /// /// No model ships with the app — the OS downloads and manages the assets, so the first /// run for a given locale may block briefly while `AssetInstallationRequest` completes. public actor AppleSpeechEngine: TranscriptionEngine { private let locale: Locale private var transcriber: SpeechTranscriber? private var analyzer: SpeechAnalyzer? private var inputContinuation: AsyncStream.Continuation? private var resultsTask: Task? /// Text the engine has committed. Volatile results are appended on top for display /// but discarded as soon as a final result covering the same range arrives. private var finalizedText = "" public init(locale: Locale = Locale.current) { self.locale = locale } public func preferredInputFormat() async -> AVAudioFormat? { let module = transcriber ?? Self.makeTranscriber(locale: locale) return await SpeechAnalyzer.bestAvailableAudioFormat(compatibleWith: [module]) } public func start() async throws -> AsyncThrowingStream { guard SpeechTranscriber.isAvailable else { throw TranscriptionError.localeUnsupported(locale) } let resolvedLocale = await SpeechTranscriber.supportedLocale(equivalentTo: locale) ?? Locale(identifier: "en-US") let transcriber = Self.makeTranscriber(locale: resolvedLocale) self.transcriber = transcriber try await Self.ensureModelInstalled(for: transcriber) let (inputStream, inputContinuation) = AsyncStream.makeStream() self.inputContinuation = inputContinuation // Bias the recognizer toward the dictionary's words before it hears anything. This // is a nudge, not a guarantee — `DictionaryCorrector` is the pass that actually // enforces spelling — but it's free and it catches things a post-hoc rewrite can't, // like a name the engine would otherwise split into two ordinary words. // // The list is capped at `DictionaryCorrector.biasLimit`. A long context list makes // these models drift: on quiet or ambiguous audio they start emitting the terms they // were primed with, which is a far worse failure than the misspelling it prevents. // Only the input-sequence initializers take a context up front, and this analyzer is // fed by `analyzer.start(inputSequence:)` later — so the context is applied here // instead. It must be set before any audio arrives to affect recognition. let analyzer = SpeechAnalyzer(modules: [transcriber]) self.analyzer = analyzer if let context = await Self.context() { try? await analyzer.setContext(context) } finalizedText = "" let (chunks, chunkContinuation) = AsyncThrowingStream.makeStream() // Drain the transcriber's results into our simpler chunk stream. resultsTask = Task { [weak self] in do { for try await result in transcriber.results { guard let self else { break } let snapshot = await self.absorb(result) chunkContinuation.yield(TranscriptionChunk(text: snapshot, isFinal: false)) } let final = await self?.finalizedText ?? "" chunkContinuation.yield(TranscriptionChunk(text: final, isFinal: true)) chunkContinuation.finish() } catch { Log.speech.error("results stream failed: \(error.localizedDescription)") chunkContinuation.finish(throwing: error) } } try await analyzer.start(inputSequence: inputStream) Log.speech.info("SpeechAnalyzer started for \(resolvedLocale.identifier)") return chunks } public func feed(_ chunk: AudioChunk) async { inputContinuation?.yield(AnalyzerInput(buffer: chunk.buffer)) } public func finish() async { inputContinuation?.finish() inputContinuation = nil do { try await analyzer?.finalizeAndFinishThroughEndOfInput() } catch { Log.speech.error("finalize failed: \(error.localizedDescription)") await analyzer?.cancelAndFinishNow() } analyzer = nil transcriber = nil resultsTask = nil } // MARK: - Result accumulation /// Folds one result into the running transcript and returns the full text to display. /// /// Final results are committed; a volatile result is shown appended to the committed /// text but never stored, so the next revision replaces it cleanly. private func absorb(_ result: SpeechTranscriber.Result) -> String { let text = String(result.text.characters) guard result.isFinal else { return (finalizedText + text).trimmingCharacters(in: .whitespaces) } finalizedText += text return finalizedText.trimmingCharacters(in: .whitespaces) } // MARK: - Setup helpers /// The dictionary's words, handed to the analyzer as contextual strings. /// /// Reads the store on the main actor because that's where it lives; the resulting array /// of strings is plain value data and crosses back safely. /// - Returns: nil when the dictionary is empty, so an empty context is never set for /// nothing. /// /// Hops to the main actor rather than asserting it. The store is main-actor isolated and /// this runs on the engine's own executor — `MainActor.assumeIsolated` here doesn't check /// that claim, it asserts it, and takes the whole process down when it's false. private static func context() async -> AnalysisContext? { let phrases = await MainActor.run { DictionaryStore.shared.biasPhrases } guard !phrases.isEmpty else { return nil } let context = AnalysisContext() context.contextualStrings[.general] = phrases Log.speech.info("biasing with \(phrases.count, privacy: .public) dictionary phrase(s)") return context } private static func makeTranscriber(locale: Locale) -> SpeechTranscriber { SpeechTranscriber( locale: locale, transcriptionOptions: [], // `.volatileResults` is what makes live text appear while you're still talking. reportingOptions: [.volatileResults], attributeOptions: [] ) } private static func ensureModelInstalled(for transcriber: SpeechTranscriber) async throws { let installed = await SpeechTranscriber.installedLocales let selected = transcriber.selectedLocales let alreadyThere = selected.allSatisfy { locale in installed.contains { $0.identifier(.bcp47) == locale.identifier(.bcp47) } } guard !alreadyThere else { return } do { if let request = try await AssetInventory.assetInstallationRequest(supporting: [transcriber]) { Log.speech.info("downloading speech model…") try await request.downloadAndInstall() Log.speech.info("speech model installed") } } catch { throw TranscriptionError.modelInstallFailed(error.localizedDescription) } } }