feat(mac): add Qwen3 MLX streaming dictation
Replace the Sherpa offline pipeline with native MLX streaming, resilient model downloads, live transcript previews, and supporting tests and documentation.
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
// MacMLXStreamingASRProvider.swift
|
||||
// OSGKeyboard · Mac
|
||||
//
|
||||
// Loads and caches Qwen3 MLX models; builds streaming sessions with bias.
|
||||
|
||||
import Foundation
|
||||
import MLX
|
||||
import MLXAudioSTT
|
||||
|
||||
actor MacMLXStreamingASRProvider {
|
||||
static let shared = MacMLXStreamingASRProvider()
|
||||
|
||||
private var cachedModelId: String?
|
||||
private var cachedModel: Qwen3ASRModel?
|
||||
private var didWarmup = false
|
||||
|
||||
func prepare(model: LocalASRModelDefinition) async throws {
|
||||
_ = try await loadModel(model)
|
||||
try await warmupIfNeeded()
|
||||
}
|
||||
|
||||
func makeSession(
|
||||
model: LocalASRModelDefinition,
|
||||
bias: LocalASRBiasPayload?,
|
||||
locale: Locale
|
||||
) async throws -> MacMLXStreamingSession {
|
||||
let qwen = try await loadModel(model)
|
||||
var config = StreamingConfig(
|
||||
decodeIntervalSeconds: 0.5,
|
||||
boundaryDecodeIntervalSeconds: 0.2,
|
||||
boundaryBoostSeconds: 1.0,
|
||||
encoderWindowOverlapSeconds: 1.0,
|
||||
maxCachedWindows: 8,
|
||||
delayPreset: .realtime,
|
||||
language: MacQwen3LanguageHint.from(locale: locale),
|
||||
context: bias?.promptBias,
|
||||
temperature: 0,
|
||||
maxTokensPerPass: 512,
|
||||
minAgreementPasses: 2,
|
||||
boundaryMinAgreementPasses: 2,
|
||||
maxDecodeWindows: 1,
|
||||
finalizeCompletedWindows: true
|
||||
)
|
||||
return MacMLXStreamingSession(model: qwen, config: config)
|
||||
}
|
||||
|
||||
func transcribeBatch(
|
||||
samples: [Float],
|
||||
model: LocalASRModelDefinition,
|
||||
locale: Locale,
|
||||
bias: LocalASRBiasPayload?
|
||||
) async throws -> String {
|
||||
let qwen = try await loadModel(model)
|
||||
let audio = MLXArray(samples.map { Float32($0) })
|
||||
let language = MacQwen3LanguageHint.from(locale: locale)
|
||||
let context = bias?.promptBias ?? ""
|
||||
let output = qwen.generate(
|
||||
audio: audio,
|
||||
context: context,
|
||||
language: language
|
||||
)
|
||||
let cleaned = MacHallucinationFilter.strip(output.text)
|
||||
guard !cleaned.isEmpty else { throw MacLocalASRError.emptyTranscript }
|
||||
return cleaned
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func loadModel(_ definition: LocalASRModelDefinition) async throws -> Qwen3ASRModel {
|
||||
if cachedModelId == definition.id, let cachedModel {
|
||||
return cachedModel
|
||||
}
|
||||
guard let root = LocalASRModelInstallState.modelRootURL(definition) else {
|
||||
throw MacLocalASRError.qwen3ModelMissing
|
||||
}
|
||||
let model = try await Qwen3ASRModel.fromModelDirectory(root)
|
||||
cachedModelId = definition.id
|
||||
cachedModel = model
|
||||
didWarmup = false
|
||||
return model
|
||||
}
|
||||
|
||||
private func warmupIfNeeded() async throws {
|
||||
guard !didWarmup else { return }
|
||||
guard let model = cachedModel else { return }
|
||||
didWarmup = true
|
||||
// One second of silence — primes Metal kernels before the first user take.
|
||||
let silence = MLXArray([Float](repeating: 0, count: 16_000).map { Float32($0) })
|
||||
_ = model.generate(audio: silence, language: MacQwen3LanguageHint.from(locale: Locale(identifier: "zh-CN")))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user