66 lines
2.6 KiB
Swift
66 lines
2.6 KiB
Swift
import AVFoundation
|
|
import Foundation
|
|
|
|
/// One buffer of captured audio, in transit from the audio thread to the speech engine.
|
|
///
|
|
/// `AVAudioPCMBuffer` isn't `Sendable`, and `AVAudioEngine` recycles the buffer it hands
|
|
/// to a tap the moment the callback returns. The unchecked conformance is only sound
|
|
/// because `AudioCapture` allocates a **fresh** buffer for every chunk and never touches
|
|
/// it again after handing it over — don't construct one of these around a borrowed buffer.
|
|
public struct AudioChunk: @unchecked Sendable {
|
|
public let buffer: AVAudioPCMBuffer
|
|
|
|
public init(buffer: AVAudioPCMBuffer) {
|
|
self.buffer = buffer
|
|
}
|
|
}
|
|
|
|
/// A snapshot of the running transcript.
|
|
///
|
|
/// `text` is always the **full transcript so far**, not a delta — engines revise
|
|
/// earlier words as more audio arrives, so consumers should replace rather than append.
|
|
public struct TranscriptionChunk: Sendable {
|
|
public let text: String
|
|
/// `true` once the engine has committed everything it will emit for this session.
|
|
public let isFinal: Bool
|
|
}
|
|
|
|
/// The seam that keeps the app engine-agnostic.
|
|
///
|
|
/// Apple's `SpeechAnalyzer` ships with macOS 26 and needs no model download, so it is
|
|
/// the default. Parakeet (FluidAudio, CoreML/ANE) scores better on English and is the
|
|
/// intended upgrade — implementing this protocol is the whole cost of switching.
|
|
public protocol TranscriptionEngine: Actor {
|
|
/// Audio format the engine wants buffers delivered in. `AudioCapture` converts to it.
|
|
func preferredInputFormat() async -> AVAudioFormat?
|
|
|
|
/// Prepare models and open a session. Emits snapshots until `finish()` is called.
|
|
func start() async throws -> AsyncThrowingStream<TranscriptionChunk, Error>
|
|
|
|
/// Feed one buffer of captured microphone audio, already in `preferredInputFormat()`.
|
|
func feed(_ chunk: AudioChunk) async
|
|
|
|
/// Close the session and flush any pending final results.
|
|
func finish() async
|
|
}
|
|
|
|
public enum TranscriptionError: LocalizedError {
|
|
case localeUnsupported(Locale)
|
|
case modelInstallFailed(String)
|
|
case noAudioFormat
|
|
case notRunning
|
|
|
|
public var errorDescription: String? {
|
|
switch self {
|
|
case .localeUnsupported(let locale):
|
|
return "Dictation isn't available for \(locale.identifier) on this Mac."
|
|
case .modelInstallFailed(let detail):
|
|
return "Couldn't install the speech model: \(detail)"
|
|
case .noAudioFormat:
|
|
return "No compatible audio format available for the speech engine."
|
|
case .notRunning:
|
|
return "The transcription engine isn't running."
|
|
}
|
|
}
|
|
}
|