feat: migrate on-device Qwen3 ASR to CoreML for background Flow dictation

Replace MLX GPU inference with CoreML bundles so transcription continues
while the host app is backgrounded. Adds model download and warm-up,
vendored Qwen3Speech, and updates onboarding, settings, and copy for the
~1.6 GB CoreML package (iOS 18+).
This commit is contained in:
Rocky
2026-06-23 00:46:58 +08:00
parent 5e5122f172
commit df1c5ff32c
160 changed files with 22080 additions and 492 deletions
+99
View File
@@ -0,0 +1,99 @@
// swift-tools-version: 5.10
import PackageDescription
// Local fork of soniqo/speech-swift that ships ONLY what OSGKeyboard
// consumes: Qwen3ASR + Qwen3Chat. The original repo's `Package.swift`
// references a `CSpeechCore` binary target whose URL doesn't match
// its declared filename (`SpeechCore.xcframework.zip` vs target
// name `CSpeechCore`), which breaks SwiftPM resolve on a clean
// checkout. The only thing we need from speech-swift for the
// OSGKeyboard on-device path is the two Qwen3 modules and the
// AudioCommon / MLXCommon / SpeechVAD slices they depend on the
// AudioServer / AudioCLI / AudioCLILib targets that pulled in
// SpeechCore aren't part of our build graph.
//
// Source provenance: every `.swift` file in `Sources/<Target>/` is
// copied from https://github.com/soniqo/speech-swift (commit pinned
// to v0.0.21 of the upstream tag tree). Original copyright
// headers are preserved in each file. Apache-2.0 license.
//
// Track upstream: when soniqo fixes the binary-target mismatch in
// their main `Package.swift`, delete this local package and
// re-enable the upstream dependency in the host project.
let package = Package(
name: "Qwen3Speech",
platforms: [
.iOS("18.0"),
.macOS("15.0")
],
products: [
.library(name: "Qwen3ASR", targets: ["Qwen3ASR"]),
.library(name: "Qwen3Chat", targets: ["Qwen3Chat"]),
],
dependencies: [
// mlx-swift is the Apple MLX array framework bindings; Qwen3
// runtime depends on the GPU side, the chat runtime depends
// on the linear-attention kernels exposed by MLXNN / MLXFast.
//
// We pin to a local flattened copy at `~/.local/mlx-swift`
// (an exported snapshot of mlx-swift 0.31.4 with its Cmlx /
// mlx-c submodules baked in as plain directories) because
// SwiftPM can't reliably fetch the upstream's git submodules
// on this network the Cmlx/mlx submodule is ~700 MB of
// history and the clone drops mid-fetch. The snapshot is
// generated once on a healthy network, kept outside the
// project, and re-used on every resolve.
.package(path: "/Users/rocky/.local/mlx-swift"),
// swift-transformers exposes Hugging Face Hub and tokenizers
// AudioCommon uses Hub to resolve repo snapshot path.
.package(url: "https://github.com/huggingface/swift-transformers", from: "1.1.6"),
],
targets: [
.target(
name: "AudioCommon",
dependencies: [
.product(name: "Hub", package: "swift-transformers"),
]
),
.target(
name: "MLXCommon",
dependencies: [
"AudioCommon",
.product(name: "MLX", package: "mlx-swift"),
.product(name: "MLXNN", package: "mlx-swift"),
.product(name: "MLXFast", package: "mlx-swift"),
]
),
.target(
name: "SpeechVAD",
dependencies: [
"AudioCommon",
"MLXCommon",
.product(name: "MLX", package: "mlx-swift"),
.product(name: "MLXNN", package: "mlx-swift"),
]
),
.target(
name: "Qwen3ASR",
dependencies: [
"AudioCommon",
"MLXCommon",
"SpeechVAD",
.product(name: "MLX", package: "mlx-swift"),
.product(name: "MLXNN", package: "mlx-swift"),
.product(name: "MLXFast", package: "mlx-swift"),
]
),
.target(
name: "Qwen3Chat",
dependencies: [
"AudioCommon",
"MLXCommon",
.product(name: "MLX", package: "mlx-swift"),
.product(name: "MLXNN", package: "mlx-swift"),
.product(name: "MLXFast", package: "mlx-swift"),
]
),
]
)
@@ -0,0 +1,382 @@
import Foundation
import AVFoundation
/// Sample-rate-conversion quality. Both options fully drain the converter and
/// produce exact-length output; they differ only in the SRC filter.
public enum ResampleQuality {
/// Framework-default band-limited SRC (`Normal` algorithm). Anti-aliases
/// steep downsamples and retains high frequencies well below Nyquist;
/// rolls off slightly more near Nyquist than `.mastering`. The right
/// default for speech/voice, which is band-limited and usually
/// downsampled (e.g. 44.1k16k for ASR), where mastering-grade filtering
/// is wasted cost.
case standard
/// Mastering algorithm at maximum quality fullest high-frequency
/// retention right up to Nyquist, at higher cost. Use for music (source
/// separation) and upsampling/super-resolution, where full-band fidelity
/// matters.
case mastering
}
/// Loads audio files and converts to float samples
public enum AudioFileLoader {
/// Load audio file and return samples at target sample rate.
/// `quality` selects the SRC filter when resampling (default `.standard`;
/// pass `.mastering` for music/upsampling).
public static func load(url: URL, targetSampleRate: Int = 24000, quality: ResampleQuality = .standard) throws -> [Float] {
let audioFile = try AVAudioFile(forReading: url)
let format = audioFile.processingFormat
let frameCount = AVAudioFrameCount(audioFile.length)
guard let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: frameCount) else {
throw AudioLoadError.bufferCreationFailed
}
try audioFile.read(into: buffer)
guard let floatData = buffer.floatChannelData else {
throw AudioLoadError.noFloatData
}
// Get mono samples (use first channel)
let samples = Array(UnsafeBufferPointer(start: floatData[0], count: Int(buffer.frameLength)))
// Resample if needed
let inputSampleRate = Int(format.sampleRate)
if inputSampleRate != targetSampleRate {
return resample(samples, from: inputSampleRate, to: targetSampleRate, quality: quality)
}
return samples
}
/// Load audio file and return stereo channels at target sample rate.
/// Returns `[left, right]` mono files are duplicated to stereo.
/// `quality` selects the SRC filter when resampling (default `.standard`;
/// pass `.mastering` for music).
public static func loadStereo(url: URL, targetSampleRate: Int = 44100, quality: ResampleQuality = .standard) throws -> [[Float]] {
let audioFile = try AVAudioFile(forReading: url)
let format = audioFile.processingFormat
let frameCount = AVAudioFrameCount(audioFile.length)
guard let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: frameCount) else {
throw AudioLoadError.bufferCreationFailed
}
try audioFile.read(into: buffer)
guard let floatData = buffer.floatChannelData else {
throw AudioLoadError.noFloatData
}
let count = Int(buffer.frameLength)
let left = Array(UnsafeBufferPointer(start: floatData[0], count: count))
let right: [Float]
if format.channelCount >= 2 {
right = Array(UnsafeBufferPointer(start: floatData[1], count: count))
} else {
right = left // Mono duplicate
}
let inputSampleRate = Int(format.sampleRate)
if inputSampleRate != targetSampleRate {
// Resample both channels in one converter pass so L/R stay
// phase-aligned (two independent converters can drift).
return resampleStereo([left, right], from: inputSampleRate, to: targetSampleRate, quality: quality)
}
return [left, right]
}
/// Load WAV file directly (for 16-bit PCM)
public static func loadWAV(url: URL) throws -> (samples: [Float], sampleRate: Int) {
let data = try Data(contentsOf: url)
// Parse WAV header
guard data.count > 44 else {
throw AudioLoadError.invalidWAVFile
}
// Check RIFF header
let riff = String(data: data[0..<4], encoding: .ascii)
guard riff == "RIFF" else {
throw AudioLoadError.invalidWAVFile
}
// Check WAVE format
let wave = String(data: data[8..<12], encoding: .ascii)
guard wave == "WAVE" else {
throw AudioLoadError.invalidWAVFile
}
// Parse format chunk (handle unaligned reads)
let audioFormat = data[20..<22].withUnsafeBytes { $0.loadUnaligned(as: UInt16.self) }
let numChannels = data[22..<24].withUnsafeBytes { $0.loadUnaligned(as: UInt16.self) }
let sampleRate = data[24..<28].withUnsafeBytes { $0.loadUnaligned(as: UInt32.self) }
let bitsPerSample = data[34..<36].withUnsafeBytes { $0.loadUnaligned(as: UInt16.self) }
guard audioFormat == 1 else { // PCM
throw AudioLoadError.unsupportedFormat("Not PCM format")
}
guard numChannels > 0 else {
throw AudioLoadError.invalidWAVFile
}
guard bitsPerSample == 16 else {
throw AudioLoadError.unsupportedFormat("Not 16-bit")
}
// Find data chunk
var dataOffset = 36
var dataChunkSize: UInt32? = nil
while dataOffset < data.count - 8 {
let chunkId = String(data: data[dataOffset..<(dataOffset+4)], encoding: .ascii)
let chunkSize = data[(dataOffset+4)..<(dataOffset+8)].withUnsafeBytes { $0.loadUnaligned(as: UInt32.self) }
if chunkId == "data" {
dataOffset += 8
dataChunkSize = chunkSize
break
}
// Validate chunk advance to avoid out-of-bounds.
let nextOffset = dataOffset + 8 + Int(chunkSize)
guard nextOffset >= dataOffset, nextOffset <= data.count else {
throw AudioLoadError.invalidWAVFile
}
dataOffset = nextOffset
}
// Read samples
guard let chunkSize = dataChunkSize else {
throw AudioLoadError.invalidWAVFile
}
let chunkSizeInt = Int(chunkSize)
guard dataOffset >= 0, dataOffset <= data.count, dataOffset + chunkSizeInt <= data.count else {
throw AudioLoadError.invalidWAVFile
}
let sampleData = data[dataOffset..<(dataOffset + chunkSizeInt)]
let channels = Int(numChannels)
let bytesPerSample = 2
let frameSize = bytesPerSample * channels
let sampleCount = sampleData.count / frameSize
var samples = [Float](repeating: 0, count: sampleCount)
sampleData.withUnsafeBytes { ptr in
let int16Ptr = ptr.bindMemory(to: Int16.self)
for i in 0..<sampleCount {
// Take first channel only
let sampleIndex = i * channels
if sampleIndex < int16Ptr.count {
samples[i] = Float(int16Ptr[sampleIndex]) / 32768.0
}
}
}
return (samples, Int(sampleRate))
}
/// Resample mono audio using `AVAudioConverter`.
///
/// Fully drains the converter via `.endOfStream` so the filter tail isn't
/// truncated, and normalizes the result to the exact expected frame count.
/// `quality` selects the SRC filter: `.standard` (default) for speech,
/// `.mastering` for music/upsampling see ``ResampleQuality``.
///
/// - Parameters:
/// - samples: mono PCM Float32 audio
/// - inputRate: source sample rate in Hz
/// - outputRate: target sample rate in Hz
/// - quality: SRC filter quality (default `.standard`)
/// - Returns: resampled audio at `outputRate`. On converter-setup or
/// conversion failure, returns the original `samples` unchanged (callers
/// never receive partial/truncated output).
public static func resample(_ samples: [Float], from inputRate: Int, to outputRate: Int, quality: ResampleQuality = .standard) -> [Float] {
guard inputRate != outputRate, !samples.isEmpty else { return samples }
guard let sourceFormat = AVAudioFormat(
commonFormat: .pcmFormatFloat32, sampleRate: Double(inputRate),
channels: 1, interleaved: false),
let targetFormat = AVAudioFormat(
commonFormat: .pcmFormatFloat32, sampleRate: Double(outputRate),
channels: 1, interleaved: false),
let converter = AVAudioConverter(from: sourceFormat, to: targetFormat),
let sourceBuffer = AVAudioPCMBuffer(
pcmFormat: sourceFormat, frameCapacity: AVAudioFrameCount(samples.count))
else {
return samples
}
configureSRC(converter, quality: quality)
sourceBuffer.frameLength = AVAudioFrameCount(samples.count)
samples.withUnsafeBufferPointer { src in
sourceBuffer.floatChannelData![0].update(from: src.baseAddress!, count: samples.count)
}
let ratio = Double(outputRate) / Double(inputRate)
guard let out = convertDrained(
converter: converter, source: sourceBuffer, targetFormat: targetFormat,
inputFrames: samples.count, ratio: ratio, channels: 1)
else {
return samples
}
return out[0]
}
/// Resample a stereo signal in a single converter pass so the two channels
/// stay phase-aligned. `channels[0]` = left, `channels[1]` = right; both
/// must have equal length. `quality` selects the SRC filter (default
/// `.standard`; pass `.mastering` for music). Falls back to per-channel
/// mono resampling for non-stereo input or on converter-setup failure.
public static func resampleStereo(_ channels: [[Float]], from inputRate: Int, to outputRate: Int, quality: ResampleQuality = .standard) -> [[Float]] {
guard channels.count == 2 else {
return channels.map { resample($0, from: inputRate, to: outputRate, quality: quality) }
}
let n = channels[0].count
guard inputRate != outputRate, n > 0, channels[1].count == n else {
return channels
}
guard let sourceFormat = AVAudioFormat(
commonFormat: .pcmFormatFloat32, sampleRate: Double(inputRate),
channels: 2, interleaved: false),
let targetFormat = AVAudioFormat(
commonFormat: .pcmFormatFloat32, sampleRate: Double(outputRate),
channels: 2, interleaved: false),
let converter = AVAudioConverter(from: sourceFormat, to: targetFormat),
let sourceBuffer = AVAudioPCMBuffer(
pcmFormat: sourceFormat, frameCapacity: AVAudioFrameCount(n))
else {
return channels.map { resample($0, from: inputRate, to: outputRate, quality: quality) }
}
configureSRC(converter, quality: quality)
sourceBuffer.frameLength = AVAudioFrameCount(n)
channels[0].withUnsafeBufferPointer {
sourceBuffer.floatChannelData![0].update(from: $0.baseAddress!, count: n)
}
channels[1].withUnsafeBufferPointer {
sourceBuffer.floatChannelData![1].update(from: $0.baseAddress!, count: n)
}
let ratio = Double(outputRate) / Double(inputRate)
guard let out = convertDrained(
converter: converter, source: sourceBuffer, targetFormat: targetFormat,
inputFrames: n, ratio: ratio, channels: 2)
else {
return channels.map { resample($0, from: inputRate, to: outputRate, quality: quality) }
}
return out
}
/// Configure the converter's SRC filter. Must be set before the first
/// `convert`. `.standard` leaves the framework default (`Normal`); only
/// `.mastering` opts into the slower, full-band Mastering algorithm.
private static func configureSRC(_ converter: AVAudioConverter, quality: ResampleQuality) {
switch quality {
case .standard:
break // framework default Normal SRC already drains + exact length
case .mastering:
converter.sampleRateConverterAlgorithm = AVSampleRateConverterAlgorithm_Mastering
converter.sampleRateConverterQuality = .max
}
}
/// Run the converter to completion, draining its internal tail via
/// `.endOfStream`, and return one Float array per channel normalized to the
/// exact expected frame count.
///
/// Returns `nil` unless the converter reaches `.endOfStream` cleanly. Only
/// `.endOfStream` is success: `.error` (or a thrown `NSError`) is a hard
/// failure, and a no-progress step that isn't end-of-stream means the
/// converter is stuck. In every non-success case the partial output is
/// discarded rather than returned, so callers can fall back instead of
/// silently propagating a truncated buffer (which would desync downstream
/// audio/video).
private static func convertDrained(
converter: AVAudioConverter,
source: AVAudioPCMBuffer,
targetFormat: AVAudioFormat,
inputFrames: Int,
ratio: Double,
channels: Int
) -> [[Float]]? {
// ceil + headroom for the sinc filter's priming/tail latency.
let capacity = AVAudioFrameCount(ceil(Double(inputFrames) * ratio)) + 4096
guard let target = AVAudioPCMBuffer(pcmFormat: targetFormat, frameCapacity: capacity) else {
return nil
}
var out = [[Float]](repeating: [], count: channels)
for c in 0..<channels { out[c].reserveCapacity(Int(capacity)) }
var fed = false
while true {
target.frameLength = 0
var error: NSError?
let status = converter.convert(to: target, error: &error) { _, outStatus in
if fed {
// Signal end-of-stream so the converter flushes its sinc
// tail instead of stopping short.
outStatus.pointee = .endOfStream
return nil
}
fed = true
outStatus.pointee = .haveData
return source
}
let produced = Int(target.frameLength)
if produced > 0, let chans = target.floatChannelData {
for c in 0..<channels {
out[c].append(contentsOf: UnsafeBufferPointer(start: chans[c], count: produced))
}
}
// Order matters: a clean `.endOfStream` (success) usually arrives on
// a call that produces 0 frames, so check it before the no-progress
// guard. `.inputRanDry` is not terminal keep pulling until the
// input block signals `.endOfStream`.
if status == .error || error != nil { return nil }
if status == .endOfStream { break }
if produced == 0 { return nil }
}
// Normalize to the exact expected length so output is a strict function
// of input length and ratio (downstream stereo alignment and A/V sync
// rely on this). Mastering SRC compensates filter latency, so this is a
// 01 sample trim/pad in practice.
let expected = Int((Double(inputFrames) * ratio).rounded())
guard expected > 0, !out[0].isEmpty else { return nil }
for c in 0..<channels {
if out[c].count > expected {
out[c].removeLast(out[c].count - expected)
} else if out[c].count < expected {
out[c].append(contentsOf: repeatElement(0, count: expected - out[c].count))
}
}
return out
}
}
public enum AudioLoadError: Error, LocalizedError {
case bufferCreationFailed
case noFloatData
case invalidWAVFile
case unsupportedFormat(String)
public var errorDescription: String? {
switch self {
case .bufferCreationFailed:
return "Failed to create audio buffer"
case .noFloatData:
return "No float channel data available"
case .invalidWAVFile:
return "Invalid WAV file format"
case .unsupportedFormat(let reason):
return "Unsupported audio format: \(reason)"
}
}
}
@@ -0,0 +1,174 @@
#if canImport(AVFoundation)
import AVFoundation
import os
/// Reusable audio I/O manager handles mic capture, resampling, and playback.
///
/// Eliminates AVAudioEngine boilerplate that every demo app reimplements.
///
/// ```swift
/// let audio = AudioIO()
/// try audio.startMicrophone(targetSampleRate: 16000) { samples in
/// pipeline.pushAudio(samples)
/// }
/// audio.player.scheduleChunk(ttsOutput)
/// audio.stopMicrophone()
/// ```
public final class AudioIO {
/// Microphone state.
public enum MicrophoneState: Sendable {
case stopped, running, error(String)
}
/// Audio player for TTS output. Attached to the engine when mic starts.
public let player = StreamingAudioPlayer()
/// Current microphone state.
public private(set) var microphoneState: MicrophoneState = .stopped
/// RMS audio level (0.01.0) for UI meters. Updated on each mic buffer.
public private(set) var audioLevel: Float = 0
/// Whether to enable Voice Processing I/O for echo cancellation.
public let enableAEC: Bool
/// Playback sample rate (for TTS output).
public let playbackSampleRate: Double
private var engine: AVAudioEngine?
private static let log = Logger(subsystem: "audio.soniqo", category: "AudioIO")
public init(enableAEC: Bool = false, playbackSampleRate: Double = 24000) {
self.enableAEC = enableAEC
self.playbackSampleRate = playbackSampleRate
}
/// Start microphone capture, resampled to targetSampleRate.
///
/// Also attaches the player to the engine for simultaneous playback.
/// Call `player.scheduleChunk()` to play audio while recording.
///
/// - Parameters:
/// - targetSampleRate: Output sample rate for onSamples (default 16kHz for VAD/ASR)
/// - onSamples: Callback with resampled mono Float32 samples (called on audio thread)
public func startMicrophone(
targetSampleRate: Int = 16000,
onSamples: @escaping ([Float]) -> Void
) throws {
stopMicrophone()
#if os(iOS)
let session = AVAudioSession.sharedInstance()
if enableAEC {
try session.setCategory(.playAndRecord, mode: .voiceChat, options: [.defaultToSpeaker, .allowBluetoothHFP])
} else {
try session.setCategory(.playAndRecord, mode: .default, options: [.defaultToSpeaker, .allowBluetoothHFP])
}
try session.setActive(true)
#endif
let engine = AVAudioEngine()
let inputNode = engine.inputNode
let hwFormat = inputNode.outputFormat(forBus: 0)
// Mono intermediate at hardware rate
guard let monoFormat = AVAudioFormat(
commonFormat: .pcmFormatFloat32,
sampleRate: hwFormat.sampleRate,
channels: 1,
interleaved: false
) else {
microphoneState = .error("Cannot create mono format")
return
}
// Target format for VAD/ASR
guard let targetFormat = AVAudioFormat(
commonFormat: .pcmFormatFloat32,
sampleRate: Double(targetSampleRate),
channels: 1,
interleaved: false
) else {
microphoneState = .error("Cannot create target format")
return
}
guard let resampler = AVAudioConverter(from: monoFormat, to: targetFormat) else {
microphoneState = .error("Cannot create resampler")
return
}
inputNode.installTap(onBus: 0, bufferSize: 1024, format: hwFormat) { [weak self] buffer, _ in
guard let self else { return }
guard let srcData = buffer.floatChannelData else { return }
let frameLen = Int(buffer.frameLength)
guard frameLen > 0 else { return }
// Extract channel 0 into mono buffer
guard let monoBuffer = AVAudioPCMBuffer(pcmFormat: monoFormat, frameCapacity: buffer.frameCapacity) else { return }
monoBuffer.frameLength = buffer.frameLength
memcpy(monoBuffer.floatChannelData![0], srcData[0], frameLen * MemoryLayout<Float>.size)
// Resample
let outFrameCount = AVAudioFrameCount(Double(frameLen) * Double(targetSampleRate) / hwFormat.sampleRate)
guard outFrameCount > 0,
let outBuffer = AVAudioPCMBuffer(pcmFormat: targetFormat, frameCapacity: outFrameCount) else { return }
var error: NSError?
resampler.convert(to: outBuffer, error: &error) { _, outStatus in
outStatus.pointee = .haveData
return monoBuffer
}
if error != nil { return }
guard let outData = outBuffer.floatChannelData else { return }
let count = Int(outBuffer.frameLength)
guard count > 0 else { return }
let samples = Array(UnsafeBufferPointer(start: outData[0], count: count))
// RMS for audio level
var sum: Float = 0
for s in samples { sum += s * s }
self.audioLevel = sqrt(sum / max(Float(count), 1))
onSamples(samples)
}
// Attach player for TTS output
guard let playerFormat = AVAudioFormat(
commonFormat: .pcmFormatFloat32,
sampleRate: playbackSampleRate,
channels: 1,
interleaved: false
) else { return }
player.attach(to: engine, format: playerFormat)
do {
try engine.start()
player.startPlayback()
self.engine = engine
microphoneState = .running
Self.log.info("Microphone started at \(targetSampleRate)Hz, player at \(self.playbackSampleRate)Hz")
} catch {
microphoneState = .error(error.localizedDescription)
throw error
}
}
/// Stop microphone capture and detach player.
public func stopMicrophone() {
if let engine {
engine.inputNode.removeTap(onBus: 0)
player.detach(from: engine)
engine.stop()
}
engine = nil
audioLevel = 0
microphoneState = .stopped
}
deinit {
stopMicrophone()
}
}
#endif
@@ -0,0 +1,34 @@
import Foundation
/// Unified error type for audio model operations.
public enum AudioModelError: Error, LocalizedError {
/// Model failed to load from disk or network.
case modelLoadFailed(modelId: String, reason: String, underlying: Error? = nil)
/// Weight file could not be read or parsed.
case weightLoadingFailed(path: String, underlying: Error? = nil)
/// Inference or generation step failed.
case inferenceFailed(operation: String, reason: String)
/// Model configuration is invalid or incompatible.
case invalidConfiguration(model: String, reason: String)
/// Voice preset file not found.
case voiceNotFound(voice: String, searchPath: String)
public var errorDescription: String? {
switch self {
case .modelLoadFailed(let modelId, let reason, let underlying):
var msg = "Failed to load model '\(modelId)': \(reason)"
if let underlying { msg += " (\(underlying.localizedDescription))" }
return msg
case .weightLoadingFailed(let path, let underlying):
var msg = "Failed to load weights from '\(path)'"
if let underlying { msg += ": \(underlying.localizedDescription)" }
return msg
case .inferenceFailed(let operation, let reason):
return "Inference failed during \(operation): \(reason)"
case .invalidConfiguration(let model, let reason):
return "Invalid configuration for '\(model)': \(reason)"
case .voiceNotFound(let voice, let searchPath):
return "Voice preset '\(voice)' not found at '\(searchPath)'"
}
}
}
@@ -0,0 +1,75 @@
import Foundation
import os
/// Thread-safe ring buffer for passing audio between the audio capture thread and the MLX
/// inference thread. Writes drop oldest data when full; reads return zeros on underrun.
///
/// Uses `os_unfair_lock` for priority inheritance safe to call `write` from a real-time
/// Core Audio I/O thread without risking priority inversion.
public final class AudioRingBuffer: @unchecked Sendable {
private var buffer: [Float]
private var readPos = 0
private var writePos = 0
private var count = 0
private var _lock = os_unfair_lock()
private let capacity: Int
public init(capacity: Int) {
self.capacity = capacity
self.buffer = [Float](repeating: 0, count: capacity)
}
/// Called from audio capture thread non-blocking; drops oldest data if full.
public func write(_ samples: [Float]) {
os_unfair_lock_lock(&_lock)
defer { os_unfair_lock_unlock(&_lock) }
for sample in samples {
if count == capacity {
// Drop oldest sample
readPos = (readPos + 1) % capacity
count -= 1
}
buffer[writePos] = sample
writePos = (writePos + 1) % capacity
count += 1
}
}
/// Zero-copy write from a raw pointer preferred on real-time audio threads
/// to avoid heap allocation from `Array(UnsafeBufferPointer(...))`.
public func write(from pointer: UnsafePointer<Float>, count sampleCount: Int) {
os_unfair_lock_lock(&_lock)
defer { os_unfair_lock_unlock(&_lock) }
for i in 0..<sampleCount {
if count == capacity {
readPos = (readPos + 1) % capacity
count -= 1
}
buffer[writePos] = pointer[i]
writePos = (writePos + 1) % capacity
count += 1
}
}
/// Called from MLX inference thread returns zeros on underrun; never blocks.
public func read(_ n: Int) -> [Float] {
os_unfair_lock_lock(&_lock)
defer { os_unfair_lock_unlock(&_lock) }
var result = [Float](repeating: 0, count: n)
let available = min(n, count)
for i in 0..<available {
result[i] = buffer[(readPos + i) % capacity]
}
readPos = (readPos + available) % capacity
count -= available
// Remaining positions stay as zero (underrun padding)
return result
}
/// Number of samples currently available to read.
public var available: Int {
os_unfair_lock_lock(&_lock)
defer { os_unfair_lock_unlock(&_lock) }
return count
}
}
@@ -0,0 +1,49 @@
#if canImport(CoreML)
import CoreML
import Foundation
/// Resolves the `MLComputeUnits` a CoreML model should load with, honoring
/// the `SPEECH_COREML_COMPUTE_UNITS` environment override.
///
/// **Why this exists.** A `.mlmodelc` is compiled MIL, but the device-specific
/// program (ANE *or* GPU/Metal) is generated the *first time* the model loads.
/// On real M-series hardware that first-load codegen takes seconds; on a
/// virtualized GitHub `macos-15` runner it **hangs**: the runner has no usable
/// Neural Engine (so `.cpuAndNeuralEngine`/`.all` stall attempting the ANE
/// compile) AND its paravirtual GPU can't JIT CoreML's Metal program for a
/// stateful graph (so `.cpuAndGPU` stalls too). Both were observed as 17-27 min
/// silent hangs loading the Qwen3-ASR T=128 stateful decoder.
///
/// On-device we keep the normal default (callers pass it as `fallback`; the env
/// is unset so this is a no-op). In CI we set `SPEECH_COREML_COMPUTE_UNITS=cpuOnly`
/// so every loader skips ANE *and* GPU codegen pure CPU MIL execution loads
/// instantly, is deterministic, and yields identical text for our roundtrip
/// assertions (~86 ms/step for the T=128 decoder, fine for correctness tests).
public enum CoreMLComputeUnitsResolver {
public static let envKey = "SPEECH_COREML_COMPUTE_UNITS"
/// Returns the env-overridden compute units, or `fallback` when unset/unrecognized.
/// Accepted env values (case-insensitive): `ane`/`cpuAndNeuralEngine`,
/// `gpu`/`cpuAndGPU`, `cpu`/`cpuOnly`, `all`.
public static func resolved(default fallback: MLComputeUnits) -> MLComputeUnits {
guard let raw = ProcessInfo.processInfo.environment[envKey]?
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased(), !raw.isEmpty
else {
return fallback
}
switch raw {
case "ane", "cpuandneuralengine", "neuralengine":
return .cpuAndNeuralEngine
case "gpu", "cpuandgpu":
return .cpuAndGPU
case "cpu", "cpuonly":
return .cpuOnly
case "all":
return .all
default:
return fallback
}
}
}
#endif
@@ -0,0 +1,125 @@
import CoreML
import Foundation
#if canImport(os)
import os
#endif
/// CoreML model loader that surfaces Neural Engine fallback.
///
/// `MLModel(contentsOf:configuration:)` silently succeeds when
/// ``MILCompilerForANE`` fails the model just runs on CPU instead of
/// ANE. Users only see the performance cliff: RTF jumps from ~0.04 to
/// ~1.8 on wake-word, ASR slows 520×, etc. They have no way to
/// correlate this with the CoreML runtime's ``E5RT encountered an STL
/// exception. msg = MILCompilerForANE error`` stderr message.
///
/// This helper:
/// 1. Times the load.
/// 2. Logs a single structured line per model with name + compute
/// units + elapsed ms.
/// 3. When the requested compute units include `.cpuAndNeuralEngine`
/// (or `.all`) and the load completes faster than a typical ANE
/// compile, emits a one-time warning pointing users at the
/// fallback diagnostic.
///
/// Usage:
/// ```swift
/// let encoder = try CoreMLLoader.load(
/// url: cacheDir.appendingPathComponent("encoder.mlmodelc"),
/// computeUnits: .cpuAndNeuralEngine,
/// name: "parakeet-eou-encoder"
/// )
/// ```
public enum CoreMLLoader {
/// Seconds under which an ANE-eligible load is considered suspicious
/// (likely CPU fallback). Calibrated against observed behaviour:
/// - Successful ANE compile: ~200800 ms on cold cache, ~2050 ms
/// cached.
/// - CPU fallback after ANE compile failure: <10 ms regardless of
/// cache state.
///
/// Picking 15 ms keeps false positives low on warm caches while
/// still catching the silent-fallback case on cold systems.
private static let aneCompileFloorSeconds: Double = 0.015
/// Track which model names we've already warned about so we don't
/// spam the log. Protected by ``warnedQueue``.
private static var warnedNames = Set<String>()
private static let warnedQueue = DispatchQueue(
label: "com.qwen3speech.coreml-loader.warned"
)
/// Load a compiled CoreML model with instrumentation.
public static func load(
url: URL,
computeUnits: MLComputeUnits,
name: String? = nil
) throws -> MLModel {
let config = MLModelConfiguration()
config.computeUnits = computeUnits
return try load(url: url, configuration: config, name: name)
}
/// Load with an explicit ``MLModelConfiguration``.
public static func load(
url: URL,
configuration: MLModelConfiguration,
name: String? = nil
) throws -> MLModel {
// Honor the SPEECH_COREML_COMPUTE_UNITS override (CI forces cpuOnly to
// skip the runner's hanging ANE/GPU first-load compile). No-op on device.
configuration.computeUnits = CoreMLComputeUnitsResolver.resolved(
default: configuration.computeUnits)
let label = name ?? url.deletingPathExtension().lastPathComponent
let unitsLabel = describe(units: configuration.computeUnits)
let start = Date()
let model = try MLModel(contentsOf: url, configuration: configuration)
let elapsed = Date().timeIntervalSince(start)
let ms = Int((elapsed * 1000).rounded())
AudioLog.modelLoading.info("CoreML loaded \(label) in \(ms)ms (units=\(unitsLabel))")
let aneEligible =
configuration.computeUnits == .cpuAndNeuralEngine ||
configuration.computeUnits == .all
if aneEligible && elapsed < aneCompileFloorSeconds {
maybeWarn(
name: label,
message: """
CoreML model '\(label)' loaded in \(ms)ms with compute units \
\(unitsLabel). This is faster than a typical Neural Engine \
compile (~200800 ms cold, ~2050 ms cached). If console logs \
show 'MILCompilerForANE error', the model has fallen back to \
CPU and inference may be 520× slower than expected.
"""
)
}
return model
}
// MARK: - Private
private static func maybeWarn(name: String, message: String) {
warnedQueue.sync {
guard !warnedNames.contains(name) else { return }
warnedNames.insert(name)
AudioLog.modelLoading.warning("\(message)")
}
}
private static func describe(units: MLComputeUnits) -> String {
switch units {
case .cpuOnly: return "cpuOnly"
case .cpuAndGPU: return "cpuAndGPU"
case .all: return "all"
case .cpuAndNeuralEngine: return "cpuAndNeuralEngine"
@unknown default: return "unknown(\(units.rawValue))"
}
}
/// Reset the per-process warning set. Exposed for tests so a fresh
/// run of the helper can emit a warning again.
public static func resetWarningState() {
warnedQueue.sync { warnedNames.removeAll() }
}
}
@@ -0,0 +1,428 @@
import Foundation
import Hub
import os
/// Download errors
public enum DownloadError: Error, LocalizedError {
case failedToDownload(String)
case invalidRemoteFileName(String)
/// A download attempt made no progress for `seconds` and was aborted
/// so the caller's retry loop can fire instead of hanging.
case stalled(modelId: String, seconds: Int)
public var errorDescription: String? {
switch self {
case .failedToDownload(let file):
return "Failed to download: \(file)"
case .invalidRemoteFileName(let file):
return "Refusing to write unsafe remote file name: \(file)"
case .stalled(let modelId, let seconds):
return "Download stalled for \(modelId): no progress in \(seconds)s"
}
}
}
/// HuggingFace model downloader shared between ASR, TTS, VAD, etc.
///
/// Uses `HubApi` from the swift-transformers `Hub` module for downloads,
/// which provides HF token auth and metadata tracking. Files that finished
/// downloading are skipped on retry (etag/commit-hash check), but a file
/// interrupted mid-transfer restarts from byte 0 there is no usable
/// mid-file resume in the current Hub stack, which is why the stall guard
/// and retry ladder below favor patience over fast abort.
public enum HuggingFaceDownloader {
// MARK: - Cache Directory
/// Get cache directory for a model.
///
/// Returns the old flat cache path if it already contains model files (preserving
/// ~10 GB of existing cached models), otherwise returns the new Hub-style path.
public static func getCacheDirectory(for modelId: String, basePath: URL? = nil, cacheDirName: String = "qwen3-speech") throws -> URL {
let base = basePath ?? resolveBaseCacheDir(cacheDirName: cacheDirName)
let fm = FileManager.default
// Check old (flat) cache path for backward compat:
// ~/Library/Caches/qwen3-speech/aufklarer_Qwen3-ASR-0.6B-MLX-4bit/
let oldDir = base.appendingPathComponent(sanitizedCacheKey(for: modelId), isDirectory: true)
if weightsExist(in: oldDir) {
return oldDir
}
// New Hub-style path:
// ~/Library/Caches/qwen3-speech/models/aufklarer/Qwen3-ASR-0.6B-MLX-4bit/
let hub = HubApi(downloadBase: base)
let repo = Hub.Repo(id: modelId)
let dir = hub.localRepoLocation(repo)
try fm.createDirectory(at: dir, withIntermediateDirectories: true)
return dir
}
// MARK: - Weight Existence Check
/// Extensions recognised as cached model weights: the canonical
/// HF `.safetensors` layout plus Apple CoreML bundle directories
/// (`.mlmodelc`, `.mlpackage`) shipped by CoreML-only repos.
public static let weightFileExtensions: Set<String> = [
"safetensors", "mlmodelc", "mlpackage"
]
/// Returns `true` when `directory` contains at least one entry
/// whose extension matches `weightFileExtensions`. Used by
/// `downloadWeights` to short-circuit network requests when
/// `offlineMode: true` is set on caches that contain only CoreML
/// bundles and no `.safetensors` files.
public static func weightsExist(in directory: URL) -> Bool {
let fm = FileManager.default
guard fm.fileExists(atPath: directory.path) else { return false }
let contents: [URL]
do {
contents = try fm.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil)
} catch {
AudioLog.download.debug("Could not list directory \(directory.path): \(error)")
contents = []
}
return contents.contains { weightFileExtensions.contains($0.pathExtension) }
}
// MARK: - Download
/// Download model files from HuggingFace using `HubApi.snapshot()`.
///
/// Builds glob patterns from the file list:
/// - Always includes `config.json`
/// - If `additionalFiles` doesn't contain `.safetensors` files, adds `*.safetensors`
/// and `model.safetensors.index.json` to discover sharded weights automatically
/// - All entries in `additionalFiles` are added as-is (they work as glob patterns)
public static func downloadWeights(
modelId: String,
to directory: URL,
additionalFiles: [String] = [],
offlineMode: Bool = false,
hubEndpoint: String? = nil,
retryDelaysSeconds: [Int]? = nil,
progressHandler: ((Double) -> Void)? = nil
) async throws {
// Skip network requests when weights are already cached
if offlineMode && weightsExist(in: directory) {
progressHandler?(1.0)
return
}
prepareRepoDirectoryForDownload(at: directory)
var globs: [String] = ["config.json"]
let hasExplicitWeights = additionalFiles.contains { $0.hasSuffix(".safetensors") }
if !hasExplicitWeights {
globs.append("*.safetensors")
globs.append("model.safetensors.index.json")
}
for file in additionalFiles where !globs.contains(file) {
globs.append(file)
}
// Derive the download base from the directory.
// getCacheDirectory returns either:
// old: base/cacheKey (flat, already has weights won't reach here)
// new: base/models/org/model (Hub-style)
// For Hub API we need `base` as downloadBase.
//
// Forward `offlineMode` explicitly so HubApi doesn't fall through to
// its internal NWPathMonitor auto-detect, which on macOS can briefly
// report `.unsatisfied` and then refuse to download (manifesting as
// "Offline mode error: No files available locally for this repository"
// for a freshly-requested model).
let hub = makeHubApi(for: modelId, repoDir: directory, offlineMode: offlineMode, hubEndpoint: hubEndpoint)
let repo = Hub.Repo(id: modelId)
// Retry with capped backoff HuggingFace can timeout on slow
// connections or rate-limit, and flaky networks (hotspots, captive
// portals) drop out for minutes at a time. Each attempt is wrapped
// in a progress-stall guard so a wedged mid-transfer (which
// `hub.snapshot` won't surface on its own) aborts and retries
// instead of hanging until the CI job is killed.
//
// No retries in offline mode: the failure is a deterministic local
// cache miss, and 110 s of backoff can't change what's on disk.
let delays = offlineMode ? [] : (retryDelaysSeconds ?? downloadRetryDelaysSeconds)
let maxAttempts = delays.count + 1
var lastError: Error?
for attempt in 1...maxAttempts {
do {
try await withDownloadStallGuard(modelId: modelId) { reportProgress in
try await hub.snapshot(from: repo, matching: globs) { progress in
reportProgress(progress.fractionCompleted)
progressHandler?(progress.fractionCompleted)
}
}
return // Success
} catch {
lastError = error
if isRecoverableHubCacheError(error) {
prepareRepoDirectoryForDownload(at: directory, force: true)
}
if attempt < maxAttempts {
try await Task.sleep(for: .seconds(delays[attempt - 1]))
}
}
}
throw DownloadError.failedToDownload(
"\(modelId) after \(maxAttempts) attempt\(maxAttempts == 1 ? "" : "s") "
+ "(target: \(directory.path)): "
+ (lastError?.localizedDescription ?? "unknown"))
}
/// Download an explicit list of files from HuggingFace without adding any
/// implicit weight globs. This is useful for overlaying tokenizer or config
/// assets from a second repository on top of an existing cache.
public static func downloadFiles(
modelId: String,
to directory: URL,
files: [String],
offlineMode: Bool = false,
hubEndpoint: String? = nil,
retryDelaysSeconds: [Int]? = nil,
progressHandler: ((Double) -> Void)? = nil
) async throws {
if files.isEmpty {
progressHandler?(1.0)
return
}
prepareRepoDirectoryForDownload(at: directory)
let hub = makeHubApi(for: modelId, repoDir: directory, offlineMode: offlineMode, hubEndpoint: hubEndpoint)
let repo = Hub.Repo(id: modelId)
let globs = files.map { $0 }
// Same retry semantics as downloadWeights, including the offline
// no-retry rule keep the two loops in lockstep.
let delays = offlineMode ? [] : (retryDelaysSeconds ?? downloadRetryDelaysSeconds)
let maxAttempts = delays.count + 1
var lastError: Error?
for attempt in 1...maxAttempts {
do {
try await withDownloadStallGuard(modelId: modelId) { reportProgress in
try await hub.snapshot(from: repo, matching: globs) { progress in
reportProgress(progress.fractionCompleted)
progressHandler?(progress.fractionCompleted)
}
}
return
} catch {
lastError = error
if isRecoverableHubCacheError(error) {
prepareRepoDirectoryForDownload(at: directory, force: true)
}
if attempt < maxAttempts {
try await Task.sleep(for: .seconds(delays[attempt - 1]))
}
}
}
throw DownloadError.failedToDownload(
"\(modelId) after \(maxAttempts) attempt\(maxAttempts == 1 ? "" : "s") "
+ "(target: \(directory.path)): "
+ (lastError?.localizedDescription ?? "unknown"))
}
// MARK: - Retry ladder
/// Delays between download attempts. One more attempt than entries:
/// 5 attempts with 5/15/30/60 s pauses (~110 s of backoff on top of the
/// per-attempt stall patience). Generous on purpose abandoned attempts
/// restart files from byte 0 with the current Hub stack, so the cheap
/// resource here is wall-clock, not bytes. A network that's down for a
/// couple of minutes (AP roam, hotspot sleep, captive-portal re-auth)
/// should not kill a 2.75 GB first-run download.
static let downloadRetryDelaysSeconds = [5, 15, 30, 60]
/// Total attempts per download (retries + the initial try).
static var downloadMaxAttempts: Int { downloadRetryDelaysSeconds.count + 1 }
// MARK: - Download stall guard
/// Seconds of zero download progress after which an attempt is
/// considered wedged and aborted. `hub.snapshot` reports
/// `fractionCompleted` continuously while bytes flow, so a healthy
/// (even slow) transfer keeps resetting the clock; only a genuinely
/// stalled connection trips this.
///
/// The default is tuned for end users, not CI: aborted attempts restart
/// each file from byte 0 (the Hub stack's mid-file resume never engages
/// on a fresh download), so firing the guard on a connection that would
/// have recovered throws away every byte of that attempt. Flaky networks
/// AP roams, captive-portal re-auth, hotspot sleep routinely stall
/// for 13 minutes and then recover, hence 300 s. CI pins
/// `HF_DOWNLOAD_STALL_TIMEOUT=90` to keep failing fast (app users can't
/// set env vars; CI can).
static var downloadStallTimeoutSeconds: Int {
if let raw = ProcessInfo.processInfo.environment["HF_DOWNLOAD_STALL_TIMEOUT"],
let v = Int(raw), v > 0 {
return v
}
return 300
}
/// Thread-safe last-progress timestamp. `hub.snapshot`'s progress
/// callback may fire from a background queue, so guard with a lock.
private final class ProgressClock: @unchecked Sendable {
private let lock = NSLock()
private var last = Date()
func tick() { lock.lock(); last = Date(); lock.unlock() }
func idleSeconds() -> Double {
lock.lock(); defer { lock.unlock() }
return Date().timeIntervalSince(last)
}
}
/// Run a download `operation` that reports fractional progress, and
/// abort it if progress stalls for `downloadStallTimeoutSeconds`.
/// On stall the in-flight `hub.snapshot` task is cancelled (URLSession
/// honors cancellation) and `DownloadError.stalled` is thrown so the
/// caller's retry loop fires instead of hanging indefinitely.
static func withDownloadStallGuard(
modelId: String,
stallTimeoutSeconds: Int? = nil,
_ operation: @escaping (@escaping @Sendable (Double) -> Void) async throws -> Void
) async throws {
let stall = stallTimeoutSeconds ?? downloadStallTimeoutSeconds
let clock = ProgressClock()
try await withThrowingTaskGroup(of: Void.self) { group in
group.addTask {
try await operation { _ in clock.tick() }
}
group.addTask {
// Poll on a fraction of the window so we detect a stall
// within ~stall..stall+pollStep seconds.
let pollStep = max(1, stall / 3)
while true {
try await Task.sleep(for: .seconds(pollStep))
if clock.idleSeconds() >= Double(stall) {
throw DownloadError.stalled(modelId: modelId, seconds: stall)
}
}
}
// Whichever finishes first wins; cancel the other (the poller
// on success, or the download on stall).
defer { group.cancelAll() }
try await group.next()
}
}
// MARK: - Security Helpers (kept for backward compat + security tests)
/// Convert an arbitrary modelId into a single, safe path component for on-disk caching.
public static func sanitizedCacheKey(for modelId: String) -> String {
let replaced = modelId.replacingOccurrences(of: "/", with: "_")
let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-")
var scalars: [UnicodeScalar] = []
scalars.reserveCapacity(replaced.unicodeScalars.count)
for s in replaced.unicodeScalars {
scalars.append(allowed.contains(s) ? s : "_")
}
var cleaned = String(String.UnicodeScalarView(scalars))
cleaned = cleaned.trimmingCharacters(in: CharacterSet(charactersIn: "._"))
if cleaned.isEmpty || cleaned == "." || cleaned == ".." {
cleaned = "model"
}
return cleaned
}
/// Validate that a remote file name is safe.
public static func validatedRemoteFileName(_ file: String) throws -> String {
let base = URL(fileURLWithPath: file).lastPathComponent
guard base == file else {
throw DownloadError.invalidRemoteFileName(file)
}
guard !base.isEmpty, !base.hasPrefix("."), !base.contains("..") else {
throw DownloadError.invalidRemoteFileName(file)
}
guard base.range(of: #"^[A-Za-z0-9._-]+$"#, options: .regularExpression) != nil else {
throw DownloadError.invalidRemoteFileName(file)
}
return base
}
/// Validate that a local path stays within the expected directory.
public static func validatedLocalPath(directory: URL, fileName: String) throws -> URL {
let local = directory.appendingPathComponent(fileName, isDirectory: false)
let dirPath = directory.standardizedFileURL.path
let localPath = local.standardizedFileURL.path
let prefix = dirPath.hasSuffix("/") ? dirPath : (dirPath + "/")
guard localPath.hasPrefix(prefix) else {
throw DownloadError.invalidRemoteFileName(fileName)
}
return local
}
// MARK: - Private Helpers
/// Remove a repo folder that has Hub metadata but no complete weights.
/// Stale partial caches trigger "File metadata must have been retrieved from server".
static func prepareRepoDirectoryForDownload(at directory: URL, force: Bool = false) {
let fm = FileManager.default
guard fm.fileExists(atPath: directory.path) else { return }
if !force && weightsExist(in: directory) { return }
try? fm.removeItem(at: directory)
try? fm.createDirectory(at: directory, withIntermediateDirectories: true)
}
private static func isRecoverableHubCacheError(_ error: Error) -> Bool {
let text = (error as? LocalizedError)?.errorDescription
?? error.localizedDescription
return text.localizedCaseInsensitiveContains("metadata")
|| text.localizedCaseInsensitiveContains("offline mode")
}
/// Resolve the base cache directory from env vars or system default.
private static func resolveBaseCacheDir(cacheDirName: String) -> URL {
let fm = FileManager.default
let root: URL
if let override = ProcessInfo.processInfo.environment["QWEN3_CACHE_DIR"],
!override.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
root = URL(fileURLWithPath: override, isDirectory: true)
} else if let override = ProcessInfo.processInfo.environment["QWEN3_ASR_CACHE_DIR"],
!override.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
// Legacy env var support
root = URL(fileURLWithPath: override, isDirectory: true)
} else {
root = fm.urls(for: .cachesDirectory, in: .userDomainMask).first!
}
return root.appendingPathComponent(cacheDirName, isDirectory: true)
}
/// Create a `HubApi` whose `downloadBase` is derived from the repo directory that
/// `getCacheDirectory` returned (strips the `models/<org>/<model>` suffix).
///
/// `offlineMode` is forwarded as `useOfflineMode` so callers get the mode
/// they asked for instead of relying on `NWPathMonitor` auto-detection,
/// which can spuriously report `.unsatisfied` on macOS.
private static func makeHubApi(
for modelId: String,
repoDir: URL,
offlineMode: Bool,
hubEndpoint: String?
) -> HubApi {
// repoDir is base/models/org/model
// We need base
let repo = Hub.Repo(id: modelId)
let suffix = "/\(repo.type.rawValue)/\(repo.id)"
let repoDirPath = repoDir.path
let downloadBase: URL
if repoDirPath.hasSuffix(suffix) {
let basePath = String(repoDirPath.dropLast(suffix.count))
downloadBase = URL(fileURLWithPath: basePath, isDirectory: true)
} else {
// Fallback: old-style flat dir use its parent as downloadBase.
// Hub won't match this path, so we derive base from env/defaults.
downloadBase = resolveBaseCacheDir(cacheDirName: repoDir.deletingLastPathComponent().lastPathComponent)
}
return HubApi(downloadBase: downloadBase, endpoint: hubEndpoint, useOfflineMode: offlineMode)
}
}
@@ -0,0 +1,13 @@
import os
/// Centralized loggers for audio model subsystems.
public enum AudioLog {
/// Logger for model weight loading and initialization.
public static let modelLoading = Logger(subsystem: "com.qwen3speech", category: "ModelLoading")
/// Logger for inference and generation.
public static let inference = Logger(subsystem: "com.qwen3speech", category: "Inference")
/// Logger for HuggingFace downloads and caching.
public static let download = Logger(subsystem: "com.qwen3speech", category: "Download")
/// Logger for voice pipeline events.
public static let pipeline = Logger(subsystem: "com.qwen3speech", category: "Pipeline")
}
@@ -0,0 +1,175 @@
import Foundation
import os
/// Loaded model set holds references to all loaded models.
public struct ModelSet {
public let vad: (any StreamingVADProvider)?
public let stt: (any SpeechRecognitionModel)?
public let tts: (any SpeechGenerationModel)?
public init(
vad: (any StreamingVADProvider)? = nil,
stt: (any SpeechRecognitionModel)? = nil,
tts: (any SpeechGenerationModel)? = nil
) {
self.vad = vad
self.stt = stt
self.tts = tts
}
}
/// A model to load, with its factory closure and progress weight.
public struct ModelSpec: Sendable {
let name: String
let weight: Double
let group: Int // 0 = parallel group 1, 1 = sequential group 2
let loader: @Sendable (_ progress: @escaping (Double, String) -> Void) async throws -> any Sendable
/// VAD model spec.
public static func vad(
_ factory: @escaping @Sendable (_ progress: @escaping (Double, String) -> Void) async throws -> any StreamingVADProvider
) -> ModelSpec {
ModelSpec(name: "VAD", weight: 1, group: 0, loader: { progress in
try await factory(progress) as any Sendable
})
}
/// Speech-to-text model spec.
public static func stt(
_ factory: @escaping @Sendable (_ progress: @escaping (Double, String) -> Void) async throws -> any SpeechRecognitionModel
) -> ModelSpec {
ModelSpec(name: "ASR", weight: 15, group: 0, loader: { progress in
try await factory(progress) as any Sendable
})
}
/// Text-to-speech model spec.
public static func tts(
_ factory: @escaping @Sendable (_ progress: @escaping (Double, String) -> Void) async throws -> any SpeechGenerationModel
) -> ModelSpec {
ModelSpec(name: "TTS", weight: 20, group: 1, loader: { progress in
try await factory(progress) as any Sendable
})
}
}
/// Unified model loading orchestrator with aggregated progress.
///
/// Loads multiple speech models with coordinated progress reporting.
/// Group 0 models (VAD, ASR) load in parallel; Group 1 (TTS) loads after
/// to reduce peak memory.
///
/// ```swift
/// let models = try await ModelLoader.load([
/// .vad { p in try await SileroVADModel.fromPretrained(engine: .coreml, progressHandler: p) },
/// .stt { p in try await ParakeetASRModel.fromPretrained(progressHandler: p) },
/// .tts { p in try await KokoroTTSModel.fromPretrained(progressHandler: p) },
/// ], onProgress: { progress, stage in
/// self.loadProgress = progress
/// self.loadingStatus = stage
/// })
/// // models.vad, models.stt, models.tts are ready
/// ```
public enum ModelLoader {
private static let log = Logger(subsystem: "audio.soniqo", category: "ModelLoader")
/// Load the requested models with aggregated progress reporting.
public static func load(
_ specs: [ModelSpec],
onProgress: @escaping @Sendable (_ progress: Double, _ stage: String) -> Void = { _, _ in }
) async throws -> ModelSet {
let totalWeight = specs.reduce(0.0) { $0 + $1.weight }
guard totalWeight > 0 else { return ModelSet() }
let state = LoadState(totalWeight: totalWeight)
// Group 0: parallel (VAD + ASR)
let group0 = specs.filter { $0.group == 0 }
// Group 1: sequential after group 0 (TTS heavy, reduce peak memory)
let group1 = specs.filter { $0.group != 0 }
var results: [(String, any Sendable)] = []
// Load group 0 in parallel
if !group0.isEmpty {
try await withThrowingTaskGroup(of: (String, any Sendable).self) { group in
for spec in group0 {
group.addTask {
let model = try await loadSpec(spec, state: state, onProgress: onProgress)
return (spec.name, model)
}
}
for try await result in group {
results.append(result)
}
}
}
// Load group 1 sequentially
for spec in group1 {
let model = try await loadSpec(spec, state: state, onProgress: onProgress)
results.append((spec.name, model))
}
onProgress(1.0, "Ready")
log.info("All models loaded")
// Build ModelSet from results
var vad: (any StreamingVADProvider)?
var stt: (any SpeechRecognitionModel)?
var tts: (any SpeechGenerationModel)?
for (_, model) in results {
if let m = model as? any StreamingVADProvider { vad = m }
if let m = model as? any SpeechRecognitionModel { stt = m }
if let m = model as? any SpeechGenerationModel { tts = m }
}
return ModelSet(vad: vad, stt: stt, tts: tts)
}
// MARK: - Internal
private final class LoadState: @unchecked Sendable {
let totalWeight: Double
private var completed: Double = 0
private let lock = NSLock()
init(totalWeight: Double) { self.totalWeight = totalWeight }
func addCompleted(_ w: Double) {
lock.lock(); completed += w; lock.unlock()
}
var completedFraction: Double {
lock.lock(); defer { lock.unlock() }
return completed / totalWeight
}
func overallProgress(specWeight: Double, localFraction: Double) -> Double {
lock.lock(); defer { lock.unlock() }
return (completed + localFraction * specWeight) / totalWeight
}
}
private static func loadSpec(
_ spec: ModelSpec,
state: LoadState,
onProgress: @escaping @Sendable (Double, String) -> Void
) async throws -> any Sendable {
log.info("Loading \(spec.name)...")
onProgress(state.completedFraction, "\(spec.name)...")
let adapter: @Sendable (Double, String) -> Void = { fraction, status in
let overall = state.overallProgress(specWeight: spec.weight, localFraction: fraction)
let stage = status.isEmpty ? spec.name : "\(spec.name): \(status)"
onProgress(overall, stage)
}
let model = try await spec.loader(adapter)
state.addCompleted(spec.weight)
log.info("\(spec.name) loaded")
return model
}
}
@@ -0,0 +1,9 @@
import Foundation
/// Remote registry used when fetching on-device model weights.
public enum ModelRegistry: Sendable, Equatable {
/// Official Hugging Face Hub (`swift-transformers` / `HubApi`).
case huggingFace(hubEndpoint: String? = nil)
/// ModelScope.cn same `owner/model` ids as Hugging Face for aufklarer MLX repos.
case modelScope(baseURL: String = ModelScopeDownloader.defaultBaseURL, revision: String = "master")
}
@@ -0,0 +1,335 @@
import Foundation
/// Downloads model files from [ModelScope](https://www.modelscope.cn) using the
/// public repo API. Uses the same `owner/model` ids as Hugging Face for repos
/// mirrored on ModelScope (e.g. `aufklarer/Qwen3-ASR-0.6B-MLX-4bit`).
public enum ModelScopeDownloader {
public static let defaultBaseURL = "https://modelscope.cn"
private struct FilesPayload: Decodable {
struct Entry: Decodable {
let Path: String
let Size: Int64?
let entryType: String?
enum CodingKeys: String, CodingKey {
case Path
case Size
case entryType = "Type"
}
}
let Files: [Entry]
}
private struct APIResponse: Decodable {
let Data: FilesPayload
}
public struct RemoteFile: Sendable {
public let path: String
public let size: Int64
}
// MARK: - Public API
/// Mirror of `HuggingFaceDownloader.downloadWeights` for ModelScope.
public static func downloadWeights(
modelId: String,
to directory: URL,
additionalFiles: [String] = [],
baseURL: String = defaultBaseURL,
revision: String = "master",
retryDelaysSeconds: [Int]? = nil,
progressHandler: ((Double) -> Void)? = nil
) async throws {
HuggingFaceDownloader.prepareRepoDirectoryForDownload(at: directory)
let listed = try await listAllFiles(modelId: modelId, baseURL: baseURL, revision: revision)
var selected = Set<String>(["config.json"])
for file in additionalFiles {
selected.insert(file)
}
let hasExplicitWeights = additionalFiles.contains { $0.hasSuffix(".safetensors") }
if !hasExplicitWeights {
for file in listed where file.path.hasSuffix(".safetensors") {
selected.insert(file.path)
}
if listed.contains(where: { $0.path == "model.safetensors.index.json" }) {
selected.insert("model.safetensors.index.json")
}
}
let files = listed.filter { selected.contains($0.path) }.map(\.path)
guard !files.isEmpty else {
throw DownloadError.failedToDownload("\(modelId): no matching files on ModelScope")
}
try await downloadFiles(
modelId: modelId,
to: directory,
files: files,
fileSizes: Dictionary(uniqueKeysWithValues: listed.map { ($0.path, $0.size) }),
baseURL: baseURL,
revision: revision,
retryDelaysSeconds: retryDelaysSeconds,
progressHandler: progressHandler
)
}
/// Download an explicit list of repo-relative paths into `directory`.
public static func downloadFiles(
modelId: String,
to directory: URL,
files: [String],
fileSizes: [String: Int64] = [:],
baseURL: String = defaultBaseURL,
revision: String = "master",
retryDelaysSeconds: [Int]? = nil,
progressHandler: ((Double) -> Void)? = nil
) async throws {
if files.isEmpty {
progressHandler?(1.0)
return
}
HuggingFaceDownloader.prepareRepoDirectoryForDownload(at: directory)
let ordered = files.sorted()
var sizes = fileSizes
for path in ordered where sizes[path] == nil {
sizes[path] = 0
}
// Without byte sizes the old logic fell back to `(index + 1) / count`,
// which jumps to 50% as soon as two small JSON files finish. Resolve
// sizes from the repo listing whenever any entry is missing.
if ordered.contains(where: { (sizes[$0] ?? 0) <= 0 }) {
let listed = try await listAllFiles(
modelId: modelId,
baseURL: baseURL,
revision: revision
)
let listedMap = Dictionary(uniqueKeysWithValues: listed.map { ($0.path, $0.size) })
for path in ordered where (sizes[path] ?? 0) <= 0 {
if let remote = listedMap[path], remote > 0 {
sizes[path] = remote
}
}
}
let totalBytes = max(ordered.reduce(Int64(0)) { $0 + (sizes[$1] ?? 0) }, 1)
var completedBytes: Int64 = 0
let delays = retryDelaysSeconds ?? HuggingFaceDownloader.downloadRetryDelaysSeconds
let maxAttempts = delays.count + 1
for (index, path) in ordered.enumerated() {
let destination = directory.appendingPathComponent(path, isDirectory: false)
try FileManager.default.createDirectory(
at: destination.deletingLastPathComponent(),
withIntermediateDirectories: true
)
var lastError: Error?
for attempt in 1...maxAttempts {
do {
try await HuggingFaceDownloader.withDownloadStallGuard(modelId: modelId) { reportProgress in
try await fetchFile(
modelId: modelId,
filePath: path,
to: destination,
baseURL: baseURL,
revision: revision
) { fileBytes, fileExpectedBytes in
reportProgress(1.0)
let fileSize = sizes[path] ?? 0
let expected = fileSize > 0 ? fileSize : fileExpectedBytes
let overall: Double
if expected > 0, totalBytes > 1 {
overall = Double(completedBytes + min(fileBytes, expected)) / Double(totalBytes)
} else {
// Last resort when listing omits sizes: spread each
// file's slice by bytes received vs Content-Length.
let slice = 1.0 / Double(ordered.count)
let base = Double(index) * slice
let inFile = expected > 0
? min(Double(fileBytes) / Double(expected), 1.0) * slice
: slice
overall = base + inFile
}
progressHandler?(min(max(overall, 0), 1))
}
}
lastError = nil
break
} catch {
lastError = error
try? FileManager.default.removeItem(at: destination)
if attempt < maxAttempts {
try await Task.sleep(for: .seconds(delays[attempt - 1]))
}
}
}
if let lastError {
throw DownloadError.failedToDownload(
"\(modelId)/\(path) on ModelScope: \(lastError.localizedDescription)"
)
}
completedBytes += sizes[path] ?? 0
progressHandler?(min(Double(completedBytes) / Double(totalBytes), 1))
}
progressHandler?(1.0)
}
// MARK: - Listing
/// Recursively lists every file in a ModelScope repo (used for CoreML bundles).
public static func listAllFiles(
modelId: String,
baseURL: String,
revision: String
) async throws -> [RemoteFile] {
var collected: [RemoteFile] = []
try await listFiles(
modelId: modelId,
root: nil,
into: &collected,
baseURL: baseURL,
revision: revision
)
return collected
}
private static func listFiles(
modelId: String,
root: String?,
into collected: inout [RemoteFile],
baseURL: String,
revision: String
) async throws {
guard let url = listingURL(modelId: modelId, baseURL: baseURL, revision: revision, root: root) else {
throw DownloadError.failedToDownload("Invalid ModelScope listing URL for \(modelId)")
}
let (data, response) = try await URLSession.shared.data(from: url)
guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else {
throw DownloadError.failedToDownload("ModelScope listing failed for \(modelId)")
}
let payload = try JSONDecoder().decode(APIResponse.self, from: data)
for entry in payload.Data.Files {
if isDirectoryEntry(entry) {
try await listFiles(
modelId: modelId,
root: entry.Path,
into: &collected,
baseURL: baseURL,
revision: revision
)
} else {
collected.append(RemoteFile(path: entry.Path, size: entry.Size ?? 0))
}
}
}
private static func isDirectoryEntry(_ entry: FilesPayload.Entry) -> Bool {
if entry.entryType?.lowercased() == "tree" { return true }
let size = entry.Size ?? 0
return size == 0 && !entry.Path.contains(".")
}
// MARK: - Transfer
/// Streams a single repo file. `onBytes` receives `(bytesWritten, expectedBytes)`.
private static func fetchFile(
modelId: String,
filePath: String,
to destination: URL,
baseURL: String,
revision: String,
onBytes: @escaping (Int64, Int64) -> Void
) async throws {
guard let url = fileURL(modelId: modelId, baseURL: baseURL, revision: revision, filePath: filePath) else {
throw DownloadError.invalidRemoteFileName(filePath)
}
var request = URLRequest(url: url)
request.timeoutInterval = 3600
let (asyncBytes, response) = try await URLSession.shared.bytes(for: request)
guard let http = response as? HTTPURLResponse else {
throw DownloadError.failedToDownload(filePath)
}
guard (200...299).contains(http.statusCode) else {
throw DownloadError.failedToDownload("\(filePath) HTTP \(http.statusCode)")
}
let expectedBytes = http.value(forHTTPHeaderField: "Content-Length")
.flatMap(Int64.init) ?? 0
if FileManager.default.fileExists(atPath: destination.path) {
try FileManager.default.removeItem(at: destination)
}
FileManager.default.createFile(atPath: destination.path, contents: nil)
let handle = try FileHandle(forWritingTo: destination)
defer { try? handle.close() }
var buffer = Data()
buffer.reserveCapacity(1_048_576)
var written: Int64 = 0
for try await byte in asyncBytes {
try Task.checkCancellation()
buffer.append(byte)
if buffer.count >= 1_048_576 {
try handle.write(contentsOf: buffer)
written += Int64(buffer.count)
buffer.removeAll(keepingCapacity: true)
onBytes(written, expectedBytes)
}
}
if !buffer.isEmpty {
try handle.write(contentsOf: buffer)
written += Int64(buffer.count)
}
onBytes(written, expectedBytes)
}
// MARK: - URLs
private static func listingURL(
modelId: String,
baseURL: String,
revision: String,
root: String?
) -> URL? {
var components = URLComponents(string: "\(baseURL)/api/v1/models/\(modelId)/repo/files")
var items = [
URLQueryItem(name: "Revision", value: revision),
]
if let root, !root.isEmpty {
items.append(URLQueryItem(name: "Root", value: root))
}
components?.queryItems = items
return components?.url
}
private static func fileURL(
modelId: String,
baseURL: String,
revision: String,
filePath: String
) -> URL? {
var components = URLComponents(string: "\(baseURL)/api/v1/models/\(modelId)/repo")
components?.queryItems = [
URLQueryItem(name: "Revision", value: revision),
URLQueryItem(name: "FilePath", value: filePath),
]
return components?.url
}
}
@@ -0,0 +1,53 @@
// MARK: - LLM Protocol
/// Protocol for language model integration with voice pipelines.
///
/// Conforming types bridge an LLM (local or remote) to the VoicePipeline's
/// ASR LLM TTS flow. The pipeline calls `chat()` on a background thread
/// and expects blocking behavior (return when generation is complete).
public protocol PipelineLLM: AnyObject {
/// Generate a response given conversation messages.
///
/// Called on the pipeline's worker thread (blocking). Emit tokens via
/// `onToken(text, isFinal)` the pipeline forwards them to TTS.
func chat(messages: [(role: MessageRole, content: String)],
onToken: @escaping (String, Bool) -> Void)
/// Cancel in-progress generation. Thread-safe.
func cancel()
}
/// Message roles for LLM conversation.
public enum MessageRole: Int, Sendable {
case system = 0
case user = 1
case assistant = 2
case tool = 3
}
// MARK: - Tool Calling
/// A tool that can be invoked by the LLM during voice pipeline execution.
public struct PipelineTool {
public let name: String
public let description: String
public let handler: (String) -> String
public let cooldown: Int
/// - Parameters:
/// - name: Tool name (used by LLM to invoke)
/// - description: What the tool does (included in LLM system prompt)
/// - cooldown: Minimum seconds between invocations (0 = no limit)
/// - handler: Synchronous handler `(arguments) -> result`. Called on pipeline worker thread.
public init(
name: String,
description: String,
cooldown: Int = 0,
handler: @escaping (String) -> String
) {
self.name = name
self.description = description
self.cooldown = cooldown
self.handler = handler
}
}
@@ -0,0 +1,282 @@
import Foundation
// MARK: - Model Memory Management
/// Memory statistics for a loaded model.
public struct ModelMemoryStats: Sendable {
/// Estimated weight memory in bytes
public let weightMemory: Int
/// Current active GPU memory in bytes (MLX only)
public let activeMemory: Int
public init(weightMemory: Int, activeMemory: Int = 0) {
self.weightMemory = weightMemory
self.activeMemory = activeMemory
}
}
/// A model that supports explicit memory management.
///
/// Call `unload()` to release model weights and free GPU memory.
/// After unloading, the model cannot be used for inference until re-loaded.
public protocol ModelMemoryManageable: AnyObject {
/// Whether the model is currently loaded and ready for inference.
var isLoaded: Bool { get }
/// Release model weights and free GPU memory.
///
/// After calling this, `isLoaded` returns false and inference methods will fail.
/// To use the model again, create a new instance via `fromPretrained()`.
func unload()
/// Estimated memory footprint of the loaded model weights in bytes.
/// Returns 0 if the model is not loaded.
var memoryFootprint: Int { get }
}
// MARK: - Unified Audio Chunk
/// A chunk of audio produced during streaming synthesis or generation.
public struct AudioChunk: Sendable {
/// PCM audio samples (Float32)
public let samples: [Float]
/// Sample rate in Hz (e.g. 24000)
public let sampleRate: Int
/// Index of the first frame in this chunk
public let frameIndex: Int
/// True if this is the last chunk
public let isFinal: Bool
/// Wall-clock seconds since generation started (nil if not tracked)
public let elapsedTime: Double?
/// Text tokens generated alongside audio (populated on final chunk if available)
public let textTokens: [Int32]
public init(
samples: [Float],
sampleRate: Int,
frameIndex: Int,
isFinal: Bool,
elapsedTime: Double? = nil,
textTokens: [Int32] = []
) {
self.samples = samples
self.sampleRate = sampleRate
self.frameIndex = frameIndex
self.isFinal = isFinal
self.elapsedTime = elapsedTime
self.textTokens = textTokens
}
}
// MARK: - Aligned Word
/// A word with its aligned start and end timestamps (in seconds).
public struct AlignedWord: Sendable {
public let text: String
public let startTime: Float
public let endTime: Float
public init(text: String, startTime: Float, endTime: Float) {
self.text = text
self.startTime = startTime
self.endTime = endTime
}
}
// MARK: - Speech Generation (TTS)
/// A text-to-speech model that generates audio from text.
public protocol SpeechGenerationModel: AnyObject {
/// Output sample rate in Hz
var sampleRate: Int { get }
/// Synthesize audio from text (returns full waveform)
func generate(text: String, language: String?) async throws -> [Float]
/// Synthesize audio from text with streaming output.
/// Default implementation wraps `generate()` as a single chunk.
func generateStream(text: String, language: String?) -> AsyncThrowingStream<AudioChunk, Error>
}
extension SpeechGenerationModel {
/// Default: wraps `generate()` as a single-chunk stream.
public func generateStream(text: String, language: String?) -> AsyncThrowingStream<AudioChunk, Error> {
let rate = sampleRate
return AsyncThrowingStream { continuation in
Task {
do {
let samples = try await self.generate(text: text, language: language)
continuation.yield(AudioChunk(samples: samples, sampleRate: rate, frameIndex: 0, isFinal: true))
continuation.finish()
} catch {
continuation.finish(throwing: error)
}
}
}
}
}
// MARK: - Speech Recognition (STT)
/// A word with its confidence score.
public struct WordConfidence: Sendable {
public let word: String
/// Confidence score (0.01.0) derived from mean token log-probability.
public let confidence: Float
public init(word: String, confidence: Float) {
self.word = word
self.confidence = confidence
}
}
/// Result of speech recognition including detected language.
public struct TranscriptionResult: Sendable {
public let text: String
/// Detected language (e.g. "english", "russian"). Nil if model doesn't detect.
public let language: String?
/// Confidence score (0.01.0). Higher = more confident transcription.
/// Derived from average token log-probability. 0.0 if model doesn't provide.
public let confidence: Float
/// Per-word confidence scores. Nil if model doesn't provide.
public let words: [WordConfidence]?
public init(text: String, language: String? = nil, confidence: Float = 0.0, words: [WordConfidence]? = nil) {
self.text = text
self.language = language
self.confidence = confidence
self.words = words
}
}
/// A speech-to-text model that transcribes audio.
public protocol SpeechRecognitionModel: AnyObject {
/// Expected input sample rate in Hz
var inputSampleRate: Int { get }
/// Transcribe audio to text
func transcribe(audio: [Float], sampleRate: Int, language: String?) -> String
/// Transcribe audio to text with language detection
func transcribeWithLanguage(audio: [Float], sampleRate: Int, language: String?) -> TranscriptionResult
}
/// Default implementation: delegates to transcribe() with no language detection.
public extension SpeechRecognitionModel {
func transcribeWithLanguage(audio: [Float], sampleRate: Int, language: String?) -> TranscriptionResult {
TranscriptionResult(text: transcribe(audio: audio, sampleRate: sampleRate, language: language))
}
}
// MARK: - Forced Alignment
/// A model that aligns text to audio at the word level.
public protocol ForcedAlignmentModel: AnyObject {
/// Align text to audio, returning word-level timestamps
func align(audio: [Float], text: String, sampleRate: Int, language: String?) -> [AlignedWord]
}
// MARK: - Speech-to-Speech
/// A speech-to-speech model that generates a spoken response to spoken input.
public protocol SpeechToSpeechModel: AnyObject {
/// Output sample rate in Hz
var sampleRate: Int { get }
/// Generate response audio from input audio (blocking)
func respond(userAudio: [Float]) -> [Float]
/// Generate response audio from input audio with streaming output
func respondStream(userAudio: [Float]) -> AsyncThrowingStream<AudioChunk, Error>
}
// MARK: - Voice Activity Detection
/// A time segment where speech was detected.
public struct SpeechSegment: Sendable {
/// Start time in seconds
public let startTime: Float
/// End time in seconds
public let endTime: Float
public init(startTime: Float, endTime: Float) {
self.startTime = startTime
self.endTime = endTime
}
/// Duration in seconds
public var duration: Float { endTime - startTime }
}
/// A model that detects speech activity regions in audio.
public protocol VoiceActivityDetectionModel: AnyObject {
/// Expected input sample rate in Hz
var inputSampleRate: Int { get }
/// Detect speech segments in audio
func detectSpeech(audio: [Float], sampleRate: Int) -> [SpeechSegment]
}
/// A streaming VAD that processes fixed-size audio chunks and returns speech probability.
///
/// Maps directly to speech-core's `sc_vad_vtable_t` for pipeline integration.
public protocol StreamingVADProvider: AnyObject {
/// Expected input sample rate in Hz
var inputSampleRate: Int { get }
/// Number of samples per chunk
var chunkSize: Int { get }
/// Process a single audio chunk, returns speech probability in [0, 1]
func processChunk(_ samples: [Float]) -> Float
/// Reset internal state (LSTM hidden state, context buffer, etc.)
func resetState()
}
// MARK: - Speaker Diarization
/// A speech segment with an assigned speaker identity.
public struct DiarizedSegment: Sendable {
/// Start time in seconds
public let startTime: Float
/// End time in seconds
public let endTime: Float
/// Speaker identifier (0-based)
public let speakerId: Int
public init(startTime: Float, endTime: Float, speakerId: Int) {
self.startTime = startTime
self.endTime = endTime
self.speakerId = speakerId
}
/// Duration in seconds
public var duration: Float { endTime - startTime }
}
/// A model that produces speaker embeddings from audio.
public protocol SpeakerEmbeddingModel: AnyObject {
/// Expected input sample rate in Hz
var inputSampleRate: Int { get }
/// Embedding vector dimension
var embeddingDimension: Int { get }
/// Extract a speaker embedding from audio
func embed(audio: [Float], sampleRate: Int) -> [Float]
}
// MARK: - Speech Enhancement
/// A model that enhances speech by removing noise.
public protocol SpeechEnhancementModel: AnyObject {
/// Expected input sample rate in Hz
var inputSampleRate: Int { get }
/// Enhance audio by removing noise
func enhance(audio: [Float], sampleRate: Int) throws -> [Float]
}
/// A model that assigns speaker identities to speech segments.
public protocol SpeakerDiarizationModel: AnyObject {
/// Expected input sample rate in Hz
var inputSampleRate: Int { get }
/// Diarize audio into speaker-labeled segments
func diarize(audio: [Float], sampleRate: Int) -> [DiarizedSegment]
}
/// A diarization model that also supports extracting a specific speaker's segments
/// using a reference embedding. Not all engines support this (e.g. Sortformer is
/// end-to-end and does not produce speaker embeddings).
public protocol SpeakerExtractionCapable: SpeakerDiarizationModel {
/// Extract segments belonging to a target speaker identified by a reference embedding.
func extractSpeaker(audio: [Float], sampleRate: Int, targetEmbedding: [Float]) -> [SpeechSegment]
}
@@ -0,0 +1,182 @@
import Foundation
/// Minimal SentencePiece `.model` (`sentencepiece_model.proto`) reader.
///
/// Extracts the vocabulary list `(text, score, type)` for every piece
/// without requiring a protobuf runtime dependency. Modules build their own
/// encode/decode logic on top: this struct only owns the wire-format parse
/// and the raw piece array.
///
/// `sentencepiece_model.proto` excerpt:
/// ```
/// message ModelProto {
/// repeated SentencePiece pieces = 1; // field 1, length-delimited submsg
/// ...
/// }
/// message SentencePiece {
/// optional string piece = 1; // field 1, length-delimited string
/// optional float score = 2; // field 2, fixed32 (wire type 5)
/// optional Type type = 3; // field 3, varint (wire type 0)
/// }
/// ```
public struct SentencePieceModel: Sendable {
/// Piece type constants from `sentencepiece_model.proto`. Values not in
/// this enum are surfaced as `.unknown(rawValue)` so callers can apply
/// their own special-token handling.
public enum PieceType: Int32, Sendable {
case normal = 1
case unknown = 2
case control = 3
case userDefined = 4
case unused = 5
case byte = 6
}
public struct Piece: Sendable, Equatable {
public let text: String
public let score: Float
public let type: Int32
public init(text: String, score: Float, type: Int32) {
self.text = text
self.score = score
self.type = type
}
public var pieceType: PieceType? { PieceType(rawValue: type) }
public var isControlOrUnknown: Bool {
type == PieceType.control.rawValue ||
type == PieceType.unknown.rawValue ||
type == PieceType.unused.rawValue ||
type == PieceType.byte.rawValue
}
}
public let pieces: [Piece]
public var count: Int { pieces.count }
public subscript(_ id: Int) -> Piece? {
guard id >= 0, id < pieces.count else { return nil }
return pieces[id]
}
public init(contentsOf url: URL) throws {
let data = try Data(contentsOf: url)
try self.init(data: data)
}
public init(modelPath: String) throws {
try self.init(contentsOf: URL(fileURLWithPath: modelPath))
}
public init(data: Data) throws {
var parsed: [Piece] = []
var offset = 0
while offset < data.count {
let (fieldNumber, wireType, afterTag) = Self.readTag(data: data, offset: offset)
offset = afterTag
// Top-level field 1 = repeated SentencePiece, length-delimited (wire 2)
guard fieldNumber == 1, wireType == 2 else {
offset = Self.skipField(data: data, offset: offset, wireType: wireType)
continue
}
let (length, afterLen) = Self.readVarint(data: data, offset: afterTag)
offset = afterLen
let end = offset + length
var piece = ""
var score: Float = 0
var type: Int32 = PieceType.normal.rawValue
var sub = offset
while sub < end {
let (subField, subWire, afterSubTag) = Self.readTag(data: data, offset: sub)
sub = afterSubTag
switch (subField, subWire) {
case (1, 2): // piece string
let (strLen, afterStrLen) = Self.readVarint(data: data, offset: sub)
sub = afterStrLen
if let s = String(data: data[sub..<(sub + strLen)], encoding: .utf8) {
piece = s
}
sub += strLen
case (2, 5): // score (fixed32 / wire type 5)
score = data[sub..<(sub + 4)].withUnsafeBytes { $0.loadUnaligned(as: Float.self) }
sub += 4
case (3, 0): // type varint
let (typeValue, afterType) = Self.readVarint(data: data, offset: sub)
sub = afterType
type = Int32(typeValue)
default:
sub = Self.skipField(data: data, offset: sub, wireType: subWire)
}
}
parsed.append(Piece(text: piece, score: score, type: type))
offset = end
}
guard !parsed.isEmpty else {
throw SentencePieceModelError.emptyModel
}
self.pieces = parsed
}
// MARK: - Protobuf wire helpers
private static func readVarint(data: Data, offset: Int) -> (value: Int, newOffset: Int) {
var result = 0
var shift = 0
var off = offset
while off < data.count {
let byte = Int(data[off])
off += 1
result |= (byte & 0x7F) << shift
if byte & 0x80 == 0 { break }
shift += 7
}
return (result, off)
}
private static func readTag(data: Data, offset: Int) -> (fieldNumber: Int, wireType: Int, newOffset: Int) {
let (tag, newOffset) = readVarint(data: data, offset: offset)
return (tag >> 3, tag & 0x07, newOffset)
}
private static func skipField(data: Data, offset: Int, wireType: Int) -> Int {
switch wireType {
case 0:
let (_, newOffset) = readVarint(data: data, offset: offset)
return newOffset
case 1:
return offset + 8
case 2:
let (length, newOffset) = readVarint(data: data, offset: offset)
return newOffset + length
case 5:
return offset + 4
default:
return data.count
}
}
}
public enum SentencePieceModelError: Error, CustomStringConvertible {
case emptyModel
case invalidFile(URL)
public var description: String {
switch self {
case .emptyModel:
return "SentencePiece model contained no pieces"
case .invalidFile(let url):
return "Could not read SentencePiece model at \(url.path)"
}
}
}
@@ -0,0 +1,511 @@
#if canImport(AVFoundation)
import AVFoundation
import os
/// Lock-free SPSC ring buffer for audio samples.
/// Producer (TTS thread) writes, consumer (audio render thread) reads.
public final class AudioSampleRingBuffer: @unchecked Sendable {
private let buffer: UnsafeMutableBufferPointer<Float>
private let capacity: Int
private var writePos: Int = 0 // only written by producer
private var readPos: Int = 0 // only written by consumer
public init(capacity: Int) {
self.capacity = capacity
let ptr = UnsafeMutablePointer<Float>.allocate(capacity: capacity)
ptr.initialize(repeating: 0, count: capacity)
self.buffer = UnsafeMutableBufferPointer(start: ptr, count: capacity)
}
deinit {
buffer.baseAddress?.deinitialize(count: capacity)
buffer.baseAddress?.deallocate()
}
/// Number of samples available to read.
public var availableToRead: Int {
let w = writePos
let r = readPos
return w >= r ? w - r : capacity - r + w
}
/// Number of free slots for writing.
public var availableToWrite: Int {
return capacity - availableToRead - 1
}
/// Write samples into the buffer. Returns number actually written.
@discardableResult
public func write(_ samples: [Float]) -> Int {
let count = min(samples.count, availableToWrite)
guard count > 0 else { return 0 }
samples.withUnsafeBufferPointer { src in
let w = writePos
let firstChunk = min(count, capacity - w)
buffer.baseAddress!.advanced(by: w).update(from: src.baseAddress!, count: firstChunk)
if firstChunk < count {
buffer.baseAddress!.update(from: src.baseAddress!.advanced(by: firstChunk), count: count - firstChunk)
}
}
writePos = (writePos + count) % capacity
return count
}
/// Read samples from the buffer into dst. Returns number actually read.
@discardableResult
public func read(into dst: UnsafeMutablePointer<Float>, count: Int) -> Int {
let available = min(count, availableToRead)
guard available > 0 else { return 0 }
let r = readPos
let firstChunk = min(available, capacity - r)
dst.update(from: buffer.baseAddress!.advanced(by: r), count: firstChunk)
if firstChunk < available {
dst.advanced(by: firstChunk).update(from: buffer.baseAddress!, count: available - firstChunk)
}
readPos = (readPos + available) % capacity
return available
}
/// Reset both pointers (call when not actively reading/writing).
public func reset() {
readPos = 0
writePos = 0
}
}
/// Streams TTS audio via AVAudioEngine using an event-driven render callback.
///
/// Architecture:
/// ```
/// TTS (producer) [Ring Buffer] AVAudioSourceNode render callback (consumer)
/// pre-fill N sec hardware pulls when it needs data
/// ```
///
/// The render thread calls our callback when it needs audio. We read from the
/// ring buffer. If the buffer is empty (underflow), we output silence.
///
/// `preBufferDuration` controls how much audio must accumulate before playback
/// starts. This is the latency-quality tradeoff:
/// - Higher = more resilient to TTS jitter, but more latency
/// - Lower = less latency, but risk of underflow gaps
///
/// Typical values:
/// - 0s: single-pass TTS (Kokoro) where all audio arrives at once
/// - 2s: streaming TTS (Qwen3-TTS, RTF ~0.53)
public final class StreamingAudioPlayer: @unchecked Sendable {
private var engine: AVAudioEngine?
private var sourceNode: AVAudioSourceNode?
private var format: AVAudioFormat?
private let lock = NSLock()
private var ringBuffer: AudioSampleRingBuffer?
private var playbackStarted = false
private var generationComplete = false
private var isFirstChunk = true
private var upsampler: AVAudioConverter?
private var preBufferSamples: Int = 0
public private(set) var totalWritten: Int = 0
/// Number of samples written for external diagnostics.
public var totalWrittenSamples: Int { totalWritten }
private var totalRead: Int = 0
public private(set) var isPlaying = false
private var playbackFinishedFired = false
/// Pre-buffer duration in seconds. Playback starts after this much audio accumulates.
/// Default 1.0s sufficient for streaming TTS at RTF < 0.6.
public var preBufferDuration: Double = 1.0
/// Callback when all audio has finished playing.
public var onPlaybackFinished: (() -> Void)?
/// Ring buffer capacity in seconds. Default 30s enough for any TTS response.
public var ringBufferDuration: Double = 30
public init() {}
// MARK: - Standalone mode
/// Start playback engine at the given sample rate.
public func start(sampleRate: Double = 24000) throws {
stop()
let eng = AVAudioEngine()
guard let fmt = AVAudioFormat(
commonFormat: .pcmFormatFloat32,
sampleRate: sampleRate,
channels: 1,
interleaved: false
) else { return }
setupSourceNode(engine: eng, format: fmt)
try eng.start()
self.engine = eng
self.format = fmt
}
/// Create a standalone engine at the hardware's native sample rate.
public func ensureStandaloneEngine() {
guard sourceNode == nil else { return }
let eng = AVAudioEngine()
let mixerFormat = eng.mainMixerNode.outputFormat(forBus: 0)
guard let monoFormat = AVAudioFormat(
commonFormat: .pcmFormatFloat32,
sampleRate: mixerFormat.sampleRate,
channels: 1,
interleaved: false
) else { return }
setupSourceNode(engine: eng, format: monoFormat)
do {
try eng.start()
self.engine = eng
self.format = monoFormat
} catch {}
}
// MARK: - Attached mode
/// Attach to an existing AVAudioEngine.
public func attach(to engine: AVAudioEngine, format: AVAudioFormat) {
setupSourceNode(engine: engine, format: format)
self.format = format
}
/// Start the source node (for use when attaching before engine.start()).
public func startPlayback() {
// Source node is always running once attached no-op
}
/// Detach from an external engine.
public func detach(from engine: AVAudioEngine) {
if let node = sourceNode {
engine.disconnectNodeOutput(node)
engine.detach(node)
}
sourceNode = nil
format = nil
upsampler = nil
ringBuffer?.reset()
}
// MARK: - Audio Scheduling
/// Write a chunk of audio samples into the ring buffer.
/// If pre-buffer threshold is reached, playback begins automatically.
public func scheduleChunk(_ samples: [Float]) {
guard !samples.isEmpty else { return }
var output = samples
// Drop near-silent warmup chunks at start of generation
if isFirstChunk {
var sumSq: Float = 0
for s in samples { sumSq += s * s }
let rms = sqrt(sumSq / Float(samples.count))
if rms < 0.005 { return } // Only drop near-silence (codec init noise)
isFirstChunk = false
// 5ms fade-in to prevent pop
if let fmt = format {
let fadeFrames = min(samples.count, Int(fmt.sampleRate * 0.005))
for i in 0..<fadeFrames {
output[i] *= Float(i) / Float(fadeFrames)
}
}
}
lock.lock()
ringBuffer?.write(output)
totalWritten += output.count
isPlaying = true
if !playbackStarted && preBufferSamples > 0 {
if (ringBuffer?.availableToRead ?? 0) >= preBufferSamples {
playbackStarted = true
}
} else if preBufferSamples == 0 {
playbackStarted = true
}
lock.unlock()
}
/// Write samples with resampling from sourceSampleRate to the player's rate.
public func play(samples: [Float], sampleRate: Int) throws {
guard let fmt = format else { return }
if Double(sampleRate) == fmt.sampleRate {
scheduleChunk(samples)
} else {
guard let srcFmt = AVAudioFormat(commonFormat: .pcmFormatFloat32, sampleRate: Double(sampleRate), channels: 1, interleaved: false) else { return }
if upsampler == nil || upsampler?.inputFormat.sampleRate != Double(sampleRate) {
upsampler = AVAudioConverter(from: srcFmt, to: fmt)
}
guard let converter = upsampler else { return }
guard let inputBuffer = AVAudioPCMBuffer(pcmFormat: srcFmt, frameCapacity: AVAudioFrameCount(samples.count)) else { return }
inputBuffer.frameLength = AVAudioFrameCount(samples.count)
samples.withUnsafeBufferPointer { ptr in
inputBuffer.floatChannelData![0].update(from: ptr.baseAddress!, count: samples.count)
}
let outFrameCount = AVAudioFrameCount(Double(samples.count) * fmt.sampleRate / Double(sampleRate))
guard let outputBuffer = AVAudioPCMBuffer(pcmFormat: fmt, frameCapacity: outFrameCount) else { return }
var consumed = false
var error: NSError?
converter.convert(to: outputBuffer, error: &error) { _, outStatus in
if consumed { outStatus.pointee = .noDataNow; return nil }
consumed = true
outStatus.pointee = .haveData
return inputBuffer
}
let count = Int(outputBuffer.frameLength)
guard count > 0, let data = outputBuffer.floatChannelData else { return }
let resampled = Array(UnsafeBufferPointer(start: data[0], count: count))
scheduleChunk(resampled)
}
}
// MARK: - Completion
/// Signal that TTS generation is complete no more chunks will arrive.
/// The render callback will drain remaining samples, then fire onPlaybackFinished.
public func markGenerationComplete() {
lock.lock()
generationComplete = true
playbackStarted = true
let hasEngine = sourceNode != nil
let empty = (ringBuffer?.availableToRead ?? 0) == 0
let written = totalWritten
lock.unlock()
// No engine or nothing was written fire immediately
if !hasEngine || (empty && written == 0) {
guard !playbackFinishedFired else { return }
playbackFinishedFired = true
isPlaying = false
onPlaybackFinished?()
return
}
// Start polling: the render callback normally fires onPlaybackFinished
// when the buffer drains, but if the render thread isn't running (e.g.
// simulator, or audio route change), we poll the buffer to detect
// completion reliably. Works on both device and simulator.
startCompletionPolling()
}
private var completionPollTimer: DispatchSourceTimer?
private var lastPolledRead: Int = 0
private var noProgressPolls: Int = 0
private func startCompletionPolling() {
completionPollTimer?.cancel()
lastPolledRead = -1
noProgressPolls = 0
let timer = DispatchSource.makeTimerSource(queue: .main)
timer.schedule(deadline: .now() + 0.2, repeating: 0.2)
timer.setEventHandler { [weak self] in
guard let self else { return }
// Already fired by render callback stop polling
guard !self.playbackFinishedFired else {
self.completionPollTimer?.cancel()
self.completionPollTimer = nil
return
}
self.lock.lock()
let complete = self.generationComplete
let remaining = self.ringBuffer?.availableToRead ?? 0
let read = self.totalRead
let written = self.totalWritten
self.lock.unlock()
// All samples consumed (or render thread never started reading)
let drained = remaining == 0 && read >= written && written > 0
// Render thread never started audio engine not running
let stalled = complete && read == 0 && written > 0
// Render thread stalled mid-stream (partial read, no progress for
// 3 consecutive polls = 600 ms). Seen on virtualized macOS CI runners
// and on real iOS when an audio-session interrupt freezes the
// render thread between buffers.
if complete && read > 0 && read < written {
if read == self.lastPolledRead {
self.noProgressPolls += 1
} else {
self.noProgressPolls = 0
self.lastPolledRead = read
}
}
let frozen = complete && self.noProgressPolls >= 3 && read > 0 && read < written
if complete && (drained || stalled || frozen) {
self.completionPollTimer?.cancel()
self.completionPollTimer = nil
guard !self.playbackFinishedFired else { return }
self.playbackFinishedFired = true
self.isPlaying = false
self.onPlaybackFinished?()
}
}
completionPollTimer = timer
timer.resume()
}
/// Reset for a new generation cycle.
public func resetGeneration() {
completionPollTimer?.cancel()
completionPollTimer = nil
lastPolledRead = -1
noProgressPolls = 0
lock.lock()
generationComplete = false
playbackFinishedFired = false
playbackStarted = false
isFirstChunk = true
totalWritten = 0
totalRead = 0
ringBuffer?.reset()
lock.unlock()
}
/// Wait until all audio has finished playing.
public func waitForCompletion() async {
while isPlaying {
try? await Task.sleep(nanoseconds: 50_000_000) // 50ms poll
}
}
/// Stop immediately.
public func fadeOutAndStop() {
lock.lock()
generationComplete = false
playbackStarted = false
isFirstChunk = true
totalWritten = 0
totalRead = 0
ringBuffer?.reset()
lock.unlock()
isPlaying = false
}
/// Stop and release resources.
public func stop() {
completionPollTimer?.cancel()
completionPollTimer = nil
if let eng = engine, let node = sourceNode {
eng.disconnectNodeOutput(node)
eng.detach(node)
}
engine?.stop()
engine = nil
sourceNode = nil
format = nil
upsampler = nil
lock.lock()
generationComplete = false
playbackStarted = false
isFirstChunk = true
totalWritten = 0
totalRead = 0
ringBuffer?.reset()
lock.unlock()
isPlaying = false
}
// MARK: - Private
private func setupSourceNode(engine: AVAudioEngine, format: AVAudioFormat) {
let bufferCapacity = Int(format.sampleRate * ringBufferDuration)
let rb = AudioSampleRingBuffer(capacity: bufferCapacity)
self.ringBuffer = rb
self.preBufferSamples = Int(format.sampleRate * preBufferDuration)
let node = AVAudioSourceNode(format: format) { [weak self] _, _, frameCount, bufferList -> OSStatus in
guard let self else { return noErr }
let ablPointer = UnsafeMutableAudioBufferListPointer(bufferList)
guard let dst = ablPointer[0].mData?.assumingMemoryBound(to: Float.self) else {
return noErr
}
let frames = Int(frameCount)
self.lock.lock()
let started = self.playbackStarted
let complete = self.generationComplete
let available = rb.availableToRead
self.lock.unlock()
if !started {
// Pre-buffer not full yet output silence
dst.update(repeating: 0, count: frames)
return noErr
}
if available > 0 {
let read = rb.read(into: dst, count: min(frames, available))
// Zero-fill remainder if not enough
if read < frames {
dst.advanced(by: read).update(repeating: 0, count: frames - read)
}
self.lock.lock()
self.totalRead += read
self.lock.unlock()
} else if complete && !self.playbackFinishedFired {
// Buffer empty + generation done = playback finished (fire once)
self.playbackFinishedFired = true
dst.update(repeating: 0, count: frames)
DispatchQueue.main.async {
self.isPlaying = false
self.onPlaybackFinished?()
}
} else {
// Underflow output silence, keep waiting for more data
dst.update(repeating: 0, count: frames)
}
return noErr
}
engine.attach(node)
engine.connect(node, to: engine.mainMixerNode, format: format)
self.sourceNode = node
}
/// Compress long silent gaps to at most `maxSilence` samples.
/// TTS models produce long pauses between sentences (500ms+).
/// This shortens them while keeping a natural brief pause.
static func compressSilence(_ samples: [Float], maxSilence: Int, threshold: Float) -> [Float] {
guard samples.count > maxSilence else { return samples }
var result = [Float]()
result.reserveCapacity(samples.count)
var silenceRun = 0
// Process in small frames (240 samples = 10ms at 24kHz)
let frameSize = 240
var offset = 0
while offset < samples.count {
let end = min(offset + frameSize, samples.count)
let frame = samples[offset..<end]
// Compute frame RMS
var sumSq: Float = 0
for s in frame { sumSq += s * s }
let rms = sqrt(sumSq / Float(frame.count))
if rms < threshold {
silenceRun += frame.count
// Only keep silence up to maxSilence
if silenceRun <= maxSilence {
result.append(contentsOf: frame)
}
// Else: drop this frame (compress the silence)
} else {
silenceRun = 0
result.append(contentsOf: frame)
}
offset = end
}
return result
}
}
#endif
@@ -0,0 +1,335 @@
import Foundation
// Library logs route to stderr so they don't corrupt a stdout-based IPC
// channel (e.g. the speech-studio sidecar's NDJSON protocol).
@inline(__always)
private func logTokenizer(_ message: String) {
FileHandle.standardError.write(Data((message + "\n").utf8))
}
// MARK: - Errors
public enum TokenizerError: Error, LocalizedError {
case invalidFormat(String)
public var errorDescription: String? {
switch self {
case .invalidFormat(let reason):
return "Invalid tokenizer format: \(reason)"
}
}
}
/// Simple tokenizer for Qwen3 that loads from vocab.json
/// Supports decoding (id->text) and basic BPE encoding (text->ids) via merges.txt
public class Qwen3Tokenizer {
private var idToToken: [Int: String] = [:]
private var tokenToId: [String: Int] = [:]
private var bpeMerges: [(String, String)] = []
private var bpeMergeRanks: [String: Int] = [:]
public var eosTokenId: Int = 151643
public var padTokenId: Int = 151643
public var bosTokenId: Int = 151644
public init() {}
/// Test-only initializer with pre-built token mappings
internal init(idToToken: [Int: String]) {
self.idToToken = idToToken
for (id, token) in idToToken { tokenToId[token] = id }
}
/// Load tokenizer from vocab.json file (direct token->id mapping)
public func load(from url: URL) throws {
let data = try Data(contentsOf: url)
// vocab.json is a direct {token: id} mapping
guard let vocab = try JSONSerialization.jsonObject(with: data) as? [String: Int] else {
throw TokenizerError.invalidFormat("Expected {token: id} dictionary")
}
for (token, id) in vocab {
idToToken[id] = token
tokenToId[token] = id
}
// Also load added tokens from tokenizer_config.json if it exists
let configUrl = url.deletingLastPathComponent().appendingPathComponent("tokenizer_config.json")
if FileManager.default.fileExists(atPath: configUrl.path) {
try loadAddedTokens(from: configUrl)
}
// Load BPE merges if available
let mergesUrl = url.deletingLastPathComponent().appendingPathComponent("merges.txt")
if FileManager.default.fileExists(atPath: mergesUrl.path) {
try loadMerges(from: mergesUrl)
}
logTokenizer("Loaded tokenizer with \(idToToken.count) tokens, \(bpeMerges.count) merges")
}
/// Load added tokens from tokenizer_config.json
private func loadAddedTokens(from url: URL) throws {
let data = try Data(contentsOf: url)
guard let config = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return // Not a valid config, skip
}
// added_tokens_decoder is a dict with string keys (token IDs) and object values with "content" field
if let addedTokens = config["added_tokens_decoder"] as? [String: [String: Any]] {
var addedCount = 0
for (idString, tokenInfo) in addedTokens {
guard let id = Int(idString),
let content = tokenInfo["content"] as? String else {
continue
}
// Add to our mappings (overwrite if exists)
idToToken[id] = content
tokenToId[content] = id
addedCount += 1
}
logTokenizer("Loaded \(addedCount) added tokens from tokenizer_config.json")
}
}
/// Load BPE merge rules from merges.txt
private func loadMerges(from url: URL) throws {
let content = try String(contentsOf: url, encoding: .utf8)
let lines = content.components(separatedBy: .newlines)
for (index, line) in lines.enumerated() {
// Skip header line and empty lines
if line.hasPrefix("#") || line.isEmpty { continue }
let parts = line.components(separatedBy: " ")
guard parts.count == 2 else { continue }
bpeMerges.append((parts[0], parts[1]))
bpeMergeRanks["\(parts[0]) \(parts[1])"] = index
}
}
/// Decode token IDs to text using a unified byte buffer.
/// Collects all bytes before converting to UTF-8, so multi-byte characters
/// split across BPE tokens (e.g. CJK) decode correctly.
public func decode(tokens: [Int]) -> String {
var buffer: [UInt8] = []
for tokenId in tokens {
guard let token = idToToken[tokenId] else { continue }
// Skip <|...|> special tokens
if token.hasPrefix("<|") && token.hasSuffix("|>") {
continue
}
// Keep <asr_text> and similar markers append their UTF-8 bytes
if token.hasPrefix("<") && token.hasSuffix(">") && !token.contains("|") {
buffer.append(contentsOf: Array(token.utf8))
continue
}
// Convert each char via unicodeToByte (Ġ0x20 space is handled
// automatically since unicodeToByte maps Ġ (U+0120) byte 32)
for char in token {
if let byte = Self.unicodeToByte[char] {
buffer.append(byte)
} else {
buffer.append(contentsOf: String(char).utf8)
}
}
}
let text = String(bytes: buffer, encoding: .utf8)
?? String(decoding: buffer, as: UTF8.self)
return text.trimmingCharacters(in: .whitespaces)
}
/// Byte-to-unicode mapping table (GPT-2 style)
/// Built lazily on first use
private static var byteToUnicode: [UInt8: Character] = {
var mapping: [UInt8: Character] = [:]
var n = 0
// Printable ASCII and some extended chars map directly
let ranges: [(ClosedRange<UInt8>)] = [
(UInt8(ascii: "!")...UInt8(ascii: "~")), // 33-126
(0xA1...0xAC), // 161-172
(0xAE...0xFF), // 174-255
]
for range in ranges {
for b in range {
mapping[b] = Character(UnicodeScalar(b))
}
}
// Remaining bytes (0-32, 127-160, 173) map to U+0100 onwards
for b: UInt8 in 0...255 {
if mapping[b] == nil {
mapping[b] = Character(UnicodeScalar(0x100 + n)!)
n += 1
}
}
return mapping
}()
/// Unicode-to-byte reverse mapping
private static var unicodeToByte: [Character: UInt8] = {
var reverse: [Character: UInt8] = [:]
for (byte, char) in byteToUnicode {
reverse[char] = byte
}
return reverse
}()
/// Encode a byte-level BPE token string from raw text bytes
private func encodeByteLevelToken(_ text: String) -> String {
var result = ""
for byte in text.utf8 {
if let char = Self.byteToUnicode[byte] {
result.append(char)
}
}
return result
}
/// BPE encode text to token IDs
public func encode(_ text: String) -> [Int] {
guard !bpeMerges.isEmpty else {
// Fallback: character-level encoding
return characterEncode(text)
}
// Split text into words (whitespace-aware, GPT-2 style pre-tokenization)
// Simple approach: split on word boundaries, preserving leading spaces as Ġ
let words = preTokenize(text)
var tokens: [Int] = []
for word in words {
// Convert word to byte-level BPE representation
let bpeTokens = bpe(word)
for bpeToken in bpeTokens {
if let id = tokenToId[bpeToken] {
tokens.append(id)
}
}
}
return tokens
}
/// Pre-tokenize text into words (GPT-2 style)
private func preTokenize(_ text: String) -> [String] {
// Split on whitespace boundaries while preserving leading spaces as part of the next word
var words: [String] = []
var current = ""
for char in text {
if char == " " || char == "\n" || char == "\t" {
if !current.isEmpty {
words.append(encodeByteLevelToken(current))
current = ""
}
current = String(char)
} else {
current.append(char)
}
}
if !current.isEmpty {
words.append(encodeByteLevelToken(current))
}
return words
}
/// Apply BPE merges to a word
private func bpe(_ word: String) -> [String] {
var pieces = word.map { String($0) }
while pieces.count > 1 {
// Find the pair with lowest merge rank
var bestPair: (String, String)?
var bestRank = Int.max
for i in 0..<(pieces.count - 1) {
let pair = "\(pieces[i]) \(pieces[i + 1])"
if let rank = bpeMergeRanks[pair], rank < bestRank {
bestRank = rank
bestPair = (pieces[i], pieces[i + 1])
}
}
guard let (first, second) = bestPair else { break }
// Merge the pair
var newPieces: [String] = []
var i = 0
while i < pieces.count {
if i < pieces.count - 1 && pieces[i] == first && pieces[i + 1] == second {
newPieces.append(first + second)
i += 2
} else {
newPieces.append(pieces[i])
i += 1
}
}
pieces = newPieces
}
return pieces
}
/// Simple character-level encoding fallback
private func characterEncode(_ text: String) -> [Int] {
var tokens: [Int] = []
for char in text {
if let id = tokenToId[String(char)] {
tokens.append(id)
}
}
return tokens
}
/// Get token ID for a specific token string
public func getTokenId(for token: String) -> Int? {
return tokenToId[token]
}
/// Get token string for a specific ID
public func getToken(for id: Int) -> String? {
return idToToken[id]
}
/// Debug: print token mappings for common words
public func debugTokenMappings() {
let commonTokens = [
"<|im_start|>", "<|im_end|>", "<|audio_start|>", "<|audio_end|>",
"<|audio_pad|>", "<asr_text>", "<|endoftext|>",
"system", "user", "assistant", "language", "English",
"Ġsystem", "Ġuser", "Ġassistant", "Ġlanguage", "ĠEnglish",
"\n", "Ċ" // newline representations
]
print("Token ID mappings:")
for token in commonTokens {
if let id = tokenToId[token] {
print(" '\(token)' -> \(id)")
} else {
print(" '\(token)' -> NOT FOUND")
}
}
}
}
/// Protocol for tokenizer to allow different implementations
public protocol TokenizerProtocol {
func decode(tokens: [Int]) -> String
func encode(_ text: String) -> [Int]
}
extension Qwen3Tokenizer: TokenizerProtocol {}
@@ -0,0 +1,105 @@
import Foundation
/// Write float audio samples to WAV file
public enum WAVWriter {
/// Write mono float samples to a 16-bit PCM WAV file
/// - Parameters:
/// - samples: Float audio samples in [-1.0, 1.0] range
/// - sampleRate: Sample rate in Hz (default 24000)
/// - url: Output file URL
public static func write(samples: [Float], sampleRate: Int = 24000, to url: URL) throws {
let numChannels: UInt16 = 1
let bitsPerSample: UInt16 = 16
let bytesPerSample = Int(bitsPerSample) / 8
let dataSize = samples.count * bytesPerSample
let fileSize = 36 + dataSize
var data = Data(capacity: fileSize + 8)
// RIFF header
data.append(contentsOf: "RIFF".utf8)
appendUInt32(&data, UInt32(fileSize))
data.append(contentsOf: "WAVE".utf8)
// fmt chunk
data.append(contentsOf: "fmt ".utf8)
appendUInt32(&data, 16) // chunk size
appendUInt16(&data, 1) // PCM format
appendUInt16(&data, numChannels)
appendUInt32(&data, UInt32(sampleRate))
appendUInt32(&data, UInt32(sampleRate * Int(numChannels) * bytesPerSample)) // byte rate
appendUInt16(&data, numChannels * UInt16(bytesPerSample)) // block align
appendUInt16(&data, bitsPerSample)
// data chunk
data.append(contentsOf: "data".utf8)
appendUInt32(&data, UInt32(dataSize))
// Convert float samples to 16-bit PCM
for sample in samples {
let clamped = max(-1.0, min(1.0, sample))
let int16Value = Int16(clamped * 32767.0)
appendInt16(&data, int16Value)
}
try data.write(to: url)
}
/// Write stereo float samples to a 16-bit PCM WAV file.
/// - Parameters:
/// - left: Left channel float samples in [-1.0, 1.0]
/// - right: Right channel float samples in [-1.0, 1.0]
/// - sampleRate: Sample rate in Hz
/// - url: Output file URL
public static func writeStereo(left: [Float], right: [Float], sampleRate: Int = 44100, to url: URL) throws {
let numChannels: UInt16 = 2
let bitsPerSample: UInt16 = 16
let bytesPerSample = Int(bitsPerSample) / 8
let frameCount = min(left.count, right.count)
let dataSize = frameCount * Int(numChannels) * bytesPerSample
let fileSize = 36 + dataSize
var data = Data(capacity: fileSize + 8)
data.append(contentsOf: "RIFF".utf8)
appendUInt32(&data, UInt32(fileSize))
data.append(contentsOf: "WAVE".utf8)
data.append(contentsOf: "fmt ".utf8)
appendUInt32(&data, 16)
appendUInt16(&data, 1) // PCM
appendUInt16(&data, numChannels)
appendUInt32(&data, UInt32(sampleRate))
appendUInt32(&data, UInt32(sampleRate * Int(numChannels) * bytesPerSample))
appendUInt16(&data, numChannels * UInt16(bytesPerSample))
appendUInt16(&data, bitsPerSample)
data.append(contentsOf: "data".utf8)
appendUInt32(&data, UInt32(dataSize))
for i in 0..<frameCount {
let l = max(-1.0, min(1.0, left[i]))
let r = max(-1.0, min(1.0, right[i]))
appendInt16(&data, Int16(l * 32767.0))
appendInt16(&data, Int16(r * 32767.0))
}
try data.write(to: url)
}
private static func appendUInt32(_ data: inout Data, _ value: UInt32) {
var v = value.littleEndian
data.append(Data(bytes: &v, count: 4))
}
private static func appendUInt16(_ data: inout Data, _ value: UInt16) {
var v = value.littleEndian
data.append(Data(bytes: &v, count: 2))
}
private static func appendInt16(_ data: inout Data, _ value: Int16) {
var v = value.littleEndian
data.append(Data(bytes: &v, count: 2))
}
}
@@ -0,0 +1,78 @@
import Foundation
import MLX
import MLXNN
// MARK: - LSTM cell matching mlx-community EnCodec weight layout
/// Single-layer LSTM cell that consumes weight tensors stored under the keys
/// `Wx`, `Wh`, `bias` the exact layout used by the `mlx-community` EnCodec
/// safetensors (24 kHz, 32 kHz, 48 kHz variants).
///
/// Runs the full input sequence in one call and returns the per-step hidden
/// state stack `[B, T, hiddenSize]`. There is no streaming variant EnCodec
/// is used on bounded chunks so a full-sequence pass is fine and keeps the
/// gate math straightforward.
///
/// Gate ordering matches Apple's mlx-examples Encodec port (i, f, g, o).
public final class EncodecLSTMCell: Module {
@ParameterInfo public var Wx: MLXArray
@ParameterInfo public var Wh: MLXArray
@ParameterInfo public var bias: MLXArray
public let hiddenSize: Int
public init(inputSize: Int, hiddenSize: Int) {
self.hiddenSize = hiddenSize
self._Wx = ParameterInfo(wrappedValue: MLXArray.zeros([4 * hiddenSize, inputSize]))
self._Wh = ParameterInfo(wrappedValue: MLXArray.zeros([4 * hiddenSize, hiddenSize]))
self._bias = ParameterInfo(wrappedValue: MLXArray.zeros([4 * hiddenSize]))
super.init()
}
/// `x: [B, T, inputSize]` `[B, T, hiddenSize]`.
public func callAsFunction(_ x: MLXArray) -> MLXArray {
// Precompute Wx·x + b over all time steps.
let xT = matmul(x, Wx.T) + bias // [B, T, 4*H]
let B = x.dim(0)
let T = x.dim(1)
var h = MLXArray.zeros([B, hiddenSize], dtype: x.dtype)
var c = MLXArray.zeros([B, hiddenSize], dtype: x.dtype)
var outputs: [MLXArray] = []
outputs.reserveCapacity(T)
let H = hiddenSize
for t in 0..<T {
let xt = xT[0..., t, 0...] // [B, 4*H]
let gates = xt + matmul(h, Wh.T) // [B, 4*H]
let iGate = sigmoid(gates[0..., 0..<H])
let fGate = sigmoid(gates[0..., H..<(2 * H)])
let gGate = tanh(gates[0..., (2 * H)..<(3 * H)])
let oGate = sigmoid(gates[0..., (3 * H)..<(4 * H)])
c = fGate * c + iGate * gGate
h = oGate * tanh(c)
outputs.append(h)
}
return stacked(outputs, axis: 1)
}
}
// MARK: - Stacked LSTM with residual
/// EnCodec's LSTM block: `numLayers` of `EncodecLSTMCell` stacked, with a
/// residual add of the original input to the stack's final hidden output.
/// Weight key is `lstm.<i>.{Wx,Wh,bias}` per layer.
public final class EncodecLSTM: Module {
@ModuleInfo public var lstm: [EncodecLSTMCell]
public init(dimension: Int, numLayers: Int) {
self._lstm = ModuleInfo(wrappedValue: (0..<numLayers).map { _ in
EncodecLSTMCell(inputSize: dimension, hiddenSize: dimension)
})
super.init()
}
public func callAsFunction(_ x: MLXArray) -> MLXArray {
var h = x
for cell in lstm { h = cell(h) }
return h + x
}
}
@@ -0,0 +1,58 @@
import Foundation
import Cmlx
import MLX
/// Metal GPU memory budget utilities.
public enum MetalBudget {
/// Query real Metal headroom: recommended working set minus active allocations.
/// Returns nil if Metal device info is unavailable.
public static var availableBytes: Int? {
let info = GPU.deviceInfo()
let maxWorking = Int(info.maxRecommendedWorkingSetSize)
guard maxWorking > 0 else { return nil }
let active = Memory.activeMemory
let overhead = 256 * 1024 * 1024 // 256 MB safety margin
return max(0, maxWorking - active - overhead)
}
/// Total device memory in bytes.
public static var totalMemory: Int {
GPU.deviceInfo().memorySize
}
/// Maximum recommended working set size in bytes.
public static var maxRecommendedWorkingSet: Int {
Int(GPU.deviceInfo().maxRecommendedWorkingSetSize)
}
/// Currently active (non-cache) MLX memory in bytes.
public static var activeMemory: Int {
Memory.activeMemory
}
/// Pin GPU memory to prevent paging under pressure.
/// Uses 90% of recommended working set by default.
/// Only effective on macOS 15+ / iOS 18+.
@discardableResult
public static func pinMemory(fraction: Double = 0.9) -> Int {
let limit = Int(Double(maxRecommendedWorkingSet) * fraction)
var previous: size_t = 0
mlx_set_wired_limit(&previous, size_t(limit))
return Int(previous)
}
/// Unpin GPU memory (set wired limit to 0).
@discardableResult
public static func unpinMemory() -> Int {
var previous: size_t = 0
mlx_set_wired_limit(&previous, 0)
return Int(previous)
}
/// Check if a model of the given size (bytes) can fit in available GPU memory.
public static func canFit(modelBytes: Int) -> Bool {
guard let available = availableBytes else { return true }
return modelBytes <= available
}
}
@@ -0,0 +1,26 @@
import MLX
import MLXNN
extension Module {
/// Estimated memory footprint of all parameters in bytes.
public func parameterMemoryBytes() -> Int {
var total = 0
for array in allParameters() {
total += array.nbytes
}
return total
}
/// Replace all parameters with empty arrays to free GPU memory.
/// After calling this, the module is unusable for inference.
public func clearParameters() {
apply(filter: Self.filterAll) { _ in MLXArray() }
Memory.clearCache()
}
/// Collect all parameter arrays (flattened).
private func allParameters() -> [MLXArray] {
filterMap(filter: Self.filterAll, map: Self.mapParameters())
.flattenedValues()
}
}
@@ -0,0 +1,50 @@
import Foundation
import MLX
import MLXNN
/// Pre-quantized embedding that can be loaded directly from safetensors
public class PreQuantizedEmbedding: Module {
public let groupSize: Int
public let bits: Int
public let embeddingCount: Int
public let dimensions: Int
@ParameterInfo public var weight: MLXArray
@ParameterInfo public var scales: MLXArray
@ParameterInfo public var biases: MLXArray
public init(embeddingCount: Int, dimensions: Int, groupSize: Int = 64, bits: Int = 4) {
self.embeddingCount = embeddingCount
self.dimensions = dimensions
self.groupSize = groupSize
self.bits = bits
// Packed dimensions: 8 values per uint32 for 4-bit
let packedDim = dimensions / (32 / bits)
let numGroups = dimensions / groupSize
// Initialize with zeros - will be loaded from weights
self._weight.wrappedValue = MLXArray.zeros([embeddingCount, packedDim], dtype: .uint32)
self._scales.wrappedValue = MLXArray.zeros([embeddingCount, numGroups], dtype: .bfloat16)
self._biases.wrappedValue = MLXArray.zeros([embeddingCount, numGroups], dtype: .bfloat16)
super.init()
self.freeze()
}
public func callAsFunction(_ x: MLXArray) -> MLXArray {
let s = x.shape
let x = x.flattened()
let out = dequantized(
weight[x], scales: scales[x], biases: biases[x],
groupSize: groupSize, bits: bits)
return out.reshaped(s + [-1])
}
/// For use as LM head (matmul with transposed weight)
public func asLinear(_ x: MLXArray) -> MLXArray {
quantizedMM(
x, weight, scales: scales, biases: biases, transpose: true,
groupSize: groupSize, bits: bits)
}
}
@@ -0,0 +1,56 @@
import Foundation
import MLX
import MLXNN
/// SwiGLU MLP shared by ASR text decoder, TTS Talker, and Code Predictor.
/// Linear layers are quantized when `bits > 0`, plain Linear otherwise (bf16/fp32 path).
public class QuantizedMLP: Module {
@ModuleInfo public var gateProj: Linear
@ModuleInfo public var upProj: Linear
@ModuleInfo public var downProj: Linear
public init(hiddenSize: Int, intermediateSize: Int, groupSize: Int = 64, bits: Int = 4) {
self._gateProj.wrappedValue = makeMaybeQuantizedLinear(
hiddenSize, intermediateSize, bias: false,
groupSize: groupSize, bits: bits)
self._upProj.wrappedValue = makeMaybeQuantizedLinear(
hiddenSize, intermediateSize, bias: false,
groupSize: groupSize, bits: bits)
self._downProj.wrappedValue = makeMaybeQuantizedLinear(
intermediateSize, hiddenSize, bias: false,
groupSize: groupSize, bits: bits)
super.init()
}
public func callAsFunction(_ x: MLXArray) -> MLXArray {
// SwiGLU: down(silu(gate(x)) * up(x))
let gate = silu(gateProj(x))
let up = upProj(x)
return downProj(gate * up)
}
}
/// SwiGLU MLP with `Linear` projections used by modules that need to
/// support both bf16/fp16 and quantized bundles. The runtime swaps
/// Linear QuantizedLinear in place via `quantize(model:filter:)` when
/// the loaded weights carry `.scales` for these paths.
public class MLP: Module {
@ModuleInfo public var gateProj: Linear
@ModuleInfo public var upProj: Linear
@ModuleInfo public var downProj: Linear
public init(hiddenSize: Int, intermediateSize: Int) {
self._gateProj.wrappedValue = Linear(hiddenSize, intermediateSize, bias: false)
self._upProj.wrappedValue = Linear(hiddenSize, intermediateSize, bias: false)
self._downProj.wrappedValue = Linear(intermediateSize, hiddenSize, bias: false)
super.init()
}
public func callAsFunction(_ x: MLXArray) -> MLXArray {
let gate = silu(gateProj(x))
let up = upProj(x)
return downProj(gate * up)
}
}
@@ -0,0 +1,102 @@
import Foundation
import MLX
import MLXFast
/// Multi-head scaled dot-product attention helper used by every attention
/// module in the project. Takes already-projected per-token Q/K/V tensors of
/// shape `[B, T, numHeads * headDim]`, reshapes to `[B, H, T, headDim]`,
/// runs the optimised `MLXFast.scaledDotProductAttention` Metal kernel, then
/// merges the heads back to `[B, T, numHeads * headDim]` ready for the
/// output projection.
///
/// Each module still owns its own input projections (which vary in width,
/// quantisation, bias, etc.) and its own output projection this helper
/// only collapses the boilerplate around the SDPA call itself.
public enum SDPA {
/// Standard attention with optional bool/float mask.
public static func multiHead(
q: MLXArray, k: MLXArray, v: MLXArray,
numHeads: Int, headDim: Int, scale: Float,
mask: MLXArray? = nil
) -> MLXArray {
let qLen = q.dim(1)
let kLen = k.dim(1)
// Q: [B, T_q, H*D] [B, H, T_q, D]. Use -1 for the batch dim so the
// helper composes with compile(shapeless:) graphs that vary batch.
let qHeads = q.reshaped(-1, qLen, numHeads, headDim).transposed(0, 2, 1, 3)
let kHeads = k.reshaped(-1, kLen, numHeads, headDim).transposed(0, 2, 1, 3)
let vHeads = v.reshaped(-1, kLen, numHeads, headDim).transposed(0, 2, 1, 3)
let attn = MLXFast.scaledDotProductAttention(
queries: qHeads, keys: kHeads, values: vHeads,
scale: scale, mask: mask)
return attn.transposed(0, 2, 1, 3).reshaped(-1, qLen, numHeads * headDim)
}
/// GQA / MQA variant: query and key/value heads can have different
/// counts. The kv tensors are repeated to match the query head count
/// inside `MLXFast.scaledDotProductAttention`.
public static func multiHead(
q: MLXArray, k: MLXArray, v: MLXArray,
numQueryHeads: Int, numKVHeads: Int, headDim: Int, scale: Float,
mask: MLXArray? = nil
) -> MLXArray {
let qLen = q.dim(1)
let kLen = k.dim(1)
let qHeads = q.reshaped(-1, qLen, numQueryHeads, headDim).transposed(0, 2, 1, 3)
let kHeads = k.reshaped(-1, kLen, numKVHeads, headDim).transposed(0, 2, 1, 3)
let vHeads = v.reshaped(-1, kLen, numKVHeads, headDim).transposed(0, 2, 1, 3)
let attn = MLXFast.scaledDotProductAttention(
queries: qHeads, keys: kHeads, values: vHeads,
scale: scale, mask: mask)
return attn.transposed(0, 2, 1, 3).reshaped(-1, qLen, numQueryHeads * headDim)
}
/// Run SDPA on tensors that are already shaped `[B, H, T, D]` (e.g. after
/// RoPE / KV-cache concatenation in LLM-style attention) and merge the
/// heads back to `[B, T, H * D]`. Saves the boilerplate transpose+reshape
/// after the SDPA call without dictating where the projections live.
public static func attendAndMerge(
qHeads: MLXArray, kHeads: MLXArray, vHeads: MLXArray,
scale: Float,
mask: MLXArray? = nil
) -> MLXArray {
let attn = MLXFast.scaledDotProductAttention(
queries: qHeads, keys: kHeads, values: vHeads,
scale: scale, mask: mask)
return mergeHeads(attn)
}
/// `ScaledDotProductAttentionMaskMode` overload used by modules that
/// pass causal / additive masks via the newer mlx-swift API.
public static func attendAndMerge(
qHeads: MLXArray, kHeads: MLXArray, vHeads: MLXArray,
scale: Float,
mask: MLXFast.ScaledDotProductAttentionMaskMode
) -> MLXArray {
let attn = MLXFast.scaledDotProductAttention(
queries: qHeads, keys: kHeads, values: vHeads,
scale: scale, mask: mask)
return mergeHeads(attn)
}
/// Merge heads back: `[B, H, T, D] [B, T, H * D]`.
///
/// Uses `-1` for the batch dimension so the result composes with
/// `MLX.compile(shapeless: true)` graphs that vary the batch at runtime
/// (e.g. Qwen3-TTS Talker autoregressive decode with different batch
/// sizes per call).
@inline(__always)
public static func mergeHeads(_ attn: MLXArray) -> MLXArray {
let H = attn.dim(1)
let T = attn.dim(2)
let D = attn.dim(3)
return attn.transposed(0, 2, 1, 3).reshaped(-1, T, H * D)
}
}
@@ -0,0 +1,292 @@
import Foundation
import MLX
import MLXNN
/// Build a Linear layer that is quantized when bits > 0, plain when bits == 0.
/// QuantizedLinear inherits from Linear, so the return type is always Linear and
/// caller code can store it in a single `@ModuleInfo var x: Linear` field.
public func makeMaybeQuantizedLinear(
_ inputDimensions: Int,
_ outputDimensions: Int,
bias: Bool,
groupSize: Int,
bits: Int
) -> Linear {
if bits > 0 {
return QuantizedLinear(inputDimensions, outputDimensions, bias: bias,
groupSize: groupSize, bits: bits)
} else {
return Linear(inputDimensions, outputDimensions, bias: bias)
}
}
/// Generic weight loading utilities shared between ASR and TTS
public enum CommonWeightLoader {
/// Load weights from safetensors file
public static func loadSafetensors(url: URL) throws -> [String: MLXArray] {
try MLX.loadArrays(url: url)
}
/// Load all safetensors from a directory, optionally filtering by prefix
public static func loadAllSafetensors(
from directory: URL,
prefix: String? = nil,
stripPrefix: Bool = true
) throws -> [String: MLXArray] {
let fileManager = FileManager.default
let contents = try fileManager.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil)
let safetensorFiles = contents.filter { $0.pathExtension == "safetensors" }
guard !safetensorFiles.isEmpty else {
throw WeightLoadingError.noWeightsFound(directory)
}
var allWeights: [String: MLXArray] = [:]
for file in safetensorFiles {
let weights = try loadSafetensors(url: file)
allWeights.merge(weights) { _, new in new }
}
// Filter and strip prefix if specified
guard let prefix = prefix else { return allWeights }
var filtered: [String: MLXArray] = [:]
for (key, value) in allWeights {
if key.hasPrefix(prefix) {
let strippedKey = stripPrefix ? String(key.dropFirst(prefix.count)) : key
filtered[strippedKey] = value
}
}
return filtered
}
// MARK: - Quantized Weight Application Helpers
public static func applyQuantizedEmbeddingWeights(
to embedding: PreQuantizedEmbedding,
prefix: String,
from weights: [String: MLXArray]
) {
var params: [String: NestedItem<String, MLXArray>] = [:]
if let weight = weights["\(prefix).weight"] {
params["weight"] = .value(weight)
}
if let scales = weights["\(prefix).scales"] {
params["scales"] = .value(scales)
}
if let biases = weights["\(prefix).biases"] {
params["biases"] = .value(biases)
}
if !params.isEmpty {
embedding.update(parameters: ModuleParameters(values: params))
}
}
/// Apply weights to a Linear (or QuantizedLinear, since the latter inherits from
/// the former). When the layer is a QuantizedLinear, `.scales`/`.biases` are wired
/// in addition to `.weight`. For plain Linear those keys are absent in the
/// safetensors (bf16/fp32 model) and only `.weight` (+ optional `.bias`) apply.
public static func applyQuantizedLinearWeights(
to linear: Linear,
prefix: String,
from weights: [String: MLXArray]
) {
var params: [String: NestedItem<String, MLXArray>] = [:]
if let weight = weights["\(prefix).weight"] {
params["weight"] = .value(weight)
}
if linear is QuantizedLinear {
if let scales = weights["\(prefix).scales"] {
params["scales"] = .value(scales)
}
if let biases = weights["\(prefix).biases"] {
params["biases"] = .value(biases)
}
}
// Regular linear bias (separate from quantization biases)
if let bias = weights["\(prefix).bias"] {
params["bias"] = .value(bias)
}
if !params.isEmpty {
linear.update(parameters: ModuleParameters(values: params))
}
}
public static func applyRMSNormWeights(
to norm: RMSNorm,
prefix: String,
from weights: [String: MLXArray]
) {
var params: [String: NestedItem<String, MLXArray>] = [:]
if let weight = weights["\(prefix).weight"] {
params["weight"] = .value(weight)
}
if !params.isEmpty {
norm.update(parameters: ModuleParameters(values: params))
}
}
public static func applyLinearWeights(
to linear: Linear,
prefix: String,
from weights: [String: MLXArray]
) {
var params: [String: NestedItem<String, MLXArray>] = [:]
if let weight = weights["\(prefix).weight"] {
params["weight"] = .value(weight)
}
if let bias = weights["\(prefix).bias"] {
params["bias"] = .value(bias)
}
if !params.isEmpty {
linear.update(parameters: ModuleParameters(values: params))
}
}
public static func applyLayerNormWeights(
to layerNorm: LayerNorm,
prefix: String,
from weights: [String: MLXArray]
) {
var params: [String: NestedItem<String, MLXArray>] = [:]
if let weight = weights["\(prefix).weight"] {
params["weight"] = .value(weight)
}
if let bias = weights["\(prefix).bias"] {
params["bias"] = .value(bias)
}
if !params.isEmpty {
layerNorm.update(parameters: ModuleParameters(values: params))
}
}
public static func applyEmbeddingWeights(
to embedding: Embedding,
prefix: String,
from weights: [String: MLXArray]
) {
var params: [String: NestedItem<String, MLXArray>] = [:]
if let weight = weights["\(prefix).weight"] {
params["weight"] = .value(weight)
}
if !params.isEmpty {
embedding.update(parameters: ModuleParameters(values: params))
}
}
public static func applyConv1dWeights(
to conv: Conv1d,
prefix: String,
from weights: [String: MLXArray],
transpose: Bool = false
) {
var params: [String: NestedItem<String, MLXArray>] = [:]
if let weight = weights["\(prefix).weight"] {
// PyTorch Conv1d: [out, in, kernel] -> MLX Conv1d: [out, kernel, in]
let w = transpose ? weight.transposed(0, 2, 1) : weight
params["weight"] = .value(w)
}
if let bias = weights["\(prefix).bias"] {
params["bias"] = .value(bias)
}
if !params.isEmpty {
conv.update(parameters: ModuleParameters(values: params))
}
}
public static func applyConvTransposed1dWeights(
to conv: ConvTransposed1d,
prefix: String,
from weights: [String: MLXArray],
transpose: Bool = false
) {
var params: [String: NestedItem<String, MLXArray>] = [:]
if let weight = weights["\(prefix).weight"] {
// PyTorch ConvTranspose1d: [in, out, kernel] -> MLX ConvTransposed1d: [out, kernel, in]
let w = transpose ? weight.transposed(1, 2, 0) : weight
params["weight"] = .value(w)
}
if let bias = weights["\(prefix).bias"] {
params["bias"] = .value(bias)
}
if !params.isEmpty {
conv.update(parameters: ModuleParameters(values: params))
}
}
/// Apply QuantizedMLP weights (SwiGLU)
public static func applyQuantizedMLPWeights(
to mlp: QuantizedMLP,
prefix: String,
from weights: [String: MLXArray]
) {
applyQuantizedLinearWeights(to: mlp.gateProj, prefix: "\(prefix).gate_proj", from: weights)
applyQuantizedLinearWeights(to: mlp.upProj, prefix: "\(prefix).up_proj", from: weights)
applyQuantizedLinearWeights(to: mlp.downProj, prefix: "\(prefix).down_proj", from: weights)
}
/// Apply MLP weights (SwiGLU) dispatches to quantized or plain
/// projections per-leaf based on `.scales` presence. Use when the
/// surrounding module was declared with `Linear` and may have been
/// swapped to `QuantizedLinear` by `quantize(model:filter:)`.
public static func applyMLPWeights(
to mlp: MLP,
prefix: String,
from weights: [String: MLXArray]
) {
applyMaybeQuantizedLinearWeights(to: mlp.gateProj, prefix: "\(prefix).gate_proj", from: weights)
applyMaybeQuantizedLinearWeights(to: mlp.upProj, prefix: "\(prefix).up_proj", from: weights)
applyMaybeQuantizedLinearWeights(to: mlp.downProj, prefix: "\(prefix).down_proj", from: weights)
}
/// Apply weights to a `Linear` that may have been swapped to
/// `QuantizedLinear`. Picks the right keys (`weight`, optional `bias`
/// for plain Linear; plus `scales`, `biases` when quantized) based on
/// what is present in the safetensors.
public static func applyMaybeQuantizedLinearWeights(
to linear: Linear,
prefix: String,
from weights: [String: MLXArray]
) {
if weights["\(prefix).scales"] != nil, let q = linear as? QuantizedLinear {
applyQuantizedLinearWeights(to: q, prefix: prefix, from: weights)
} else {
applyLinearWeights(to: linear, prefix: prefix, from: weights)
}
}
}
/// Weight loading errors
public enum WeightLoadingError: Error, LocalizedError {
case noWeightsFound(URL)
case incompatibleWeights(String)
case missingRequiredWeight(String)
public var errorDescription: String? {
switch self {
case .noWeightsFound(let url):
return "No safetensors files found in: \(url.path)"
case .incompatibleWeights(let reason):
return "Incompatible weights: \(reason)"
case .missingRequiredWeight(let key):
return "Missing required weight: \(key)"
}
}
}
@@ -0,0 +1,512 @@
import Foundation
import MLX
import MLXNN
import MLXFast
import MLXCommon
import AudioCommon
/// Audio encoder configuration matching Qwen3-ASR HuggingFace model
public struct Qwen3AudioEncoderConfig: Sendable {
public let dModel: Int // 896
public let encoderAttentionHeads: Int // 14
public let encoderFFNDim: Int // 3584
public let encoderLayers: Int // 18
public let numMelBins: Int // 128
public let maxSourcePositions: Int // 1500
public let outputDim: Int // 1024
public let downsampleHiddenSize: Int // 480
public let convChunksize: Int // 500
public let nWindow: Int // 50 (chunk size = n_window * 2 = 100)
public let nWindowInfer: Int // 800
public let dropout: Float // 0.0
public let attentionDropout: Float // 0.0
public let activationDropout: Float // 0.0
public let layerNormEps: Float // 1e-5
public let convOutInputDim: Int // 7680 (480 channels * 16 spatial positions)
/// Config for Qwen3-ASR-0.6B (default)
public static let `default` = Qwen3AudioEncoderConfig(
dModel: 896,
encoderAttentionHeads: 14,
encoderFFNDim: 3584,
encoderLayers: 18,
numMelBins: 128,
maxSourcePositions: 1500,
outputDim: 1024,
downsampleHiddenSize: 480,
convChunksize: 500,
nWindow: 50,
nWindowInfer: 800,
dropout: 0.0,
attentionDropout: 0.0,
activationDropout: 0.0,
layerNormEps: 1e-5,
convOutInputDim: 7680
)
/// Alias for 0.6B config
public static let small = `default`
/// Config for Qwen3-ASR-1.7B
public static let large = Qwen3AudioEncoderConfig(
dModel: 1024,
encoderAttentionHeads: 16,
encoderFFNDim: 4096,
encoderLayers: 24,
numMelBins: 128,
maxSourcePositions: 1500,
outputDim: 2048,
downsampleHiddenSize: 480,
convChunksize: 500,
nWindow: 50,
nWindowInfer: 800,
dropout: 0.0,
attentionDropout: 0.0,
activationDropout: 0.0,
layerNormEps: 1e-5,
convOutInputDim: 7680
)
/// Config for Qwen3-ForcedAligner-0.6B (large encoder projecting to 1024-dim text decoder)
public static let forcedAligner = Qwen3AudioEncoderConfig(
dModel: 1024,
encoderAttentionHeads: 16,
encoderFFNDim: 4096,
encoderLayers: 24,
numMelBins: 128,
maxSourcePositions: 1500,
outputDim: 1024,
downsampleHiddenSize: 480,
convChunksize: 500,
nWindow: 50,
nWindowInfer: 800,
dropout: 0.0,
attentionDropout: 0.0,
activationDropout: 0.0,
layerNormEps: 1e-5,
convOutInputDim: 7680
)
}
/// Multi-head self-attention for audio encoder layers
/// Weight names: self_attn.q_proj, k_proj, v_proj, out_proj
public class AudioSelfAttention: Module {
let numHeads: Int
let headDim: Int
let scale: Float
@ModuleInfo(key: "q_proj") public var qProj: Linear
@ModuleInfo(key: "k_proj") public var kProj: Linear
@ModuleInfo(key: "v_proj") public var vProj: Linear
@ModuleInfo(key: "out_proj") public var outProj: Linear
public init(hiddenSize: Int, numHeads: Int) {
self.numHeads = numHeads
self.headDim = hiddenSize / numHeads
self.scale = 1.0 / sqrt(Float(headDim))
self._qProj.wrappedValue = Linear(hiddenSize, hiddenSize, bias: true)
self._kProj.wrappedValue = Linear(hiddenSize, hiddenSize, bias: true)
self._vProj.wrappedValue = Linear(hiddenSize, hiddenSize, bias: true)
self._outProj.wrappedValue = Linear(hiddenSize, hiddenSize, bias: true)
super.init()
}
public func callAsFunction(_ x: MLXArray, attentionMask: MLXArray? = nil) -> MLXArray {
let q = qProj(x)
let k = kProj(x)
let v = vProj(x)
let out = SDPA.multiHead(
q: q, k: k, v: v,
numHeads: numHeads, headDim: headDim, scale: scale,
mask: attentionMask)
return outProj(out)
}
}
/// Audio encoder transformer layer
/// Weight names: self_attn, self_attn_layer_norm, fc1, fc2, final_layer_norm
public class AudioEncoderLayer: Module {
@ModuleInfo(key: "self_attn") public var selfAttn: AudioSelfAttention
@ModuleInfo(key: "self_attn_layer_norm") public var selfAttnLayerNorm: LayerNorm
@ModuleInfo public var fc1: Linear
@ModuleInfo public var fc2: Linear
@ModuleInfo(key: "final_layer_norm") public var finalLayerNorm: LayerNorm
public init(hiddenSize: Int, numHeads: Int, ffnDim: Int, layerNormEps: Float) {
self._selfAttn.wrappedValue = AudioSelfAttention(hiddenSize: hiddenSize, numHeads: numHeads)
self._selfAttnLayerNorm.wrappedValue = LayerNorm(dimensions: hiddenSize, eps: layerNormEps)
self._fc1.wrappedValue = Linear(hiddenSize, ffnDim, bias: true)
self._fc2.wrappedValue = Linear(ffnDim, hiddenSize, bias: true)
self._finalLayerNorm.wrappedValue = LayerNorm(dimensions: hiddenSize, eps: layerNormEps)
super.init()
}
public func callAsFunction(_ x: MLXArray, attentionMask: MLXArray? = nil) -> MLXArray {
// Self attention with residual
var residual = x
var hidden = selfAttnLayerNorm(x)
hidden = selfAttn(hidden, attentionMask: attentionMask)
hidden = residual + hidden
// FFN with residual
residual = hidden
hidden = finalLayerNorm(hidden)
hidden = fc1(hidden)
hidden = gelu(hidden)
hidden = fc2(hidden)
hidden = residual + hidden
return hidden
}
}
/// Create sinusoidal position embeddings matching Python mlx-audio implementation
/// - Parameters:
/// - seqLen: Sequence length
/// - dModel: Model dimension (channels)
/// - Returns: Position embeddings [1, seqLen, dModel]
private func createSinusoidalPositionEmbeddings(seqLen: Int, dModel: Int) -> MLXArray {
let halfDim = dModel / 2
let maxTimescale: Float = 10000.0
// Python formula: log_timescale_increment = log(max_timescale) / (channels // 2 - 1)
// inv_timescales = exp(-log_timescale_increment * arange(channels // 2))
let logTimescaleIncrement = log(maxTimescale) / Float(halfDim - 1)
let invTimescales = exp(
MLXArray(0..<halfDim).asType(.float32) * (-logTimescaleIncrement)
)
// Position indices: [0, 1, 2, ..., seqLen-1]
let positions = MLXArray(0..<seqLen).asType(.float32)
// Compute scaled_time: positions[:, None] * inv_timescales[None, :]
// [seqLen, 1] * [1, halfDim] -> [seqLen, halfDim]
let scaledTime = positions.expandedDimensions(axis: 1) * invTimescales.expandedDimensions(axis: 0)
// Sin and cos embeddings
let sinEmbed = sin(scaledTime) // [seqLen, halfDim]
let cosEmbed = cos(scaledTime) // [seqLen, halfDim]
// Concatenate [sin, cos] along axis 1 (NOT interleave!)
// Python: concatenate([sin(scaled_time), cos(scaled_time)], axis=1)
let posEmbed = concatenated([sinEmbed, cosEmbed], axis: 1) // [seqLen, dModel]
// Add batch dimension
return posEmbed.expandedDimensions(axis: 0) // [1, seqLen, dModel]
}
/// Full Qwen3-ASR Audio Encoder (audio_tower)
/// Matches HuggingFace weight structure exactly
public class Qwen3AudioEncoder: Module {
public let config: Qwen3AudioEncoderConfig
// Cache for sinusoidal position embeddings keyed by sequence length
private var cachedPosEmbeddings: [Int: MLXArray] = [:]
// Conv frontend - using 2D convolutions
// Input: [batch, 1, mel=128, time] (single channel mel spectrogram image)
// Weight format in safetensors: [out, in, kH, kW] -> transpose to MLX [out, kH, kW, in]
@ModuleInfo public var conv2d1: Conv2d // 1 -> 480 channels, 3x3 kernel
@ModuleInfo public var conv2d2: Conv2d // 480 -> 480 channels, 3x3, stride 2
@ModuleInfo public var conv2d3: Conv2d // 480 -> 480 channels, 3x3, stride 2
// Output projection: flattened conv features -> d_model
// Weight: [896, 7680] -> Linear(7680, 896)
@ModuleInfo(key: "conv_out") public var convOut: Linear
// Post layer norm
@ModuleInfo(key: "ln_post") public var lnPost: LayerNorm
// Projector to text model dimension
// proj1: [896, 896] -> Linear(896, 896)
// proj2: [1024, 896] -> Linear(896, 1024)
@ModuleInfo public var proj1: Linear
@ModuleInfo public var proj2: Linear
// Transformer layers
@ModuleInfo public var layers: [AudioEncoderLayer]
public init(config: Qwen3AudioEncoderConfig = .default) {
self.config = config
// Conv2D layers for mel spectrogram processing
// All three convs have stride 2 for 8x downsampling
// Input: [batch, mel_bins=128, time, 1] in NHWC
self._conv2d1.wrappedValue = Conv2d(
inputChannels: 1,
outputChannels: config.downsampleHiddenSize, // 480
kernelSize: IntOrPair(3),
stride: IntOrPair(2), // Changed from 1 to 2
padding: IntOrPair(1)
)
self._conv2d2.wrappedValue = Conv2d(
inputChannels: config.downsampleHiddenSize,
outputChannels: config.downsampleHiddenSize,
kernelSize: IntOrPair(3),
stride: IntOrPair(2),
padding: IntOrPair(1)
)
self._conv2d3.wrappedValue = Conv2d(
inputChannels: config.downsampleHiddenSize,
outputChannels: config.downsampleHiddenSize,
kernelSize: IntOrPair(3),
stride: IntOrPair(2),
padding: IntOrPair(1)
)
// Output conv projection: flattened features (7680) -> d_model (896)
self._convOut.wrappedValue = Linear(config.convOutInputDim, config.dModel, bias: false)
// Post layer norm
self._lnPost.wrappedValue = LayerNorm(dimensions: config.dModel, eps: config.layerNormEps)
// Projector to text model dimension
// proj1: 896 -> 896
// proj2: 896 -> 1024
self._proj1.wrappedValue = Linear(config.dModel, config.dModel, bias: true)
self._proj2.wrappedValue = Linear(config.dModel, config.outputDim, bias: true)
// Transformer layers
self._layers.wrappedValue = (0..<config.encoderLayers).map { _ in
AudioEncoderLayer(
hiddenSize: config.dModel,
numHeads: config.encoderAttentionHeads,
ffnDim: config.encoderFFNDim,
layerNormEps: config.layerNormEps
)
}
super.init()
}
/// Calculate output length from input length using the chunking formula
/// This matches the Python _get_feat_extract_output_lengths function
private func getOutputLength(_ inputLength: Int) -> Int {
let chunkSize = config.nWindow * 2 // 100
let remainder = inputLength % chunkSize
// Process remainder through conv downsampling formula
var featLen = (remainder - 1) / 2 + 1 // First stride-2
featLen = (featLen - 1) / 2 + 1 // Second stride-2
featLen = (featLen - 1) / 2 + 1 // Third stride-2
// Full chunks each produce 13 tokens
let fullChunkTokens = (inputLength / chunkSize) * 13
// Handle edge case when remainder is 0
let remainderTokens = remainder > 0 ? max(featLen, 1) : 0
return fullChunkTokens + remainderTokens
}
/// Process a single chunk through conv layers
/// Input: [batch, mel=128, time, 1] in NHWC format
/// Output: [batch, time_tokens, features=7680]
private func processConvChunk(_ chunk: MLXArray) -> MLXArray {
var x = chunk
// Apply conv layers: 128 -> 64 -> 32 -> 16 in mel dimension
// Time dimension also downsampled 8x
x = conv2d1(x)
x = gelu(x)
x = conv2d2(x)
x = gelu(x)
x = conv2d3(x)
x = gelu(x)
// Shape after conv: [batch, mel/8=16, time/8, 480]
let batch = x.dim(0)
let height = x.dim(1) // 16 (mel after 3x stride-2)
let width = x.dim(2) // time/8
let channels = x.dim(3) // 480
// Flatten mel*channels: [batch, 16, time_tokens, 480] -> [batch, time_tokens, 16*480]
// Transpose to [batch, time, mel, channels] then flatten last two
x = x.transposed(0, 2, 1, 3) // [batch, time, mel, channels]
x = x.reshaped(batch, width, height * channels) // [batch, time_tokens, 7680]
return x
}
/// Create block attention mask for preventing cross-chunk attention
/// Each block in cu_seqlens can only attend to itself
/// Uses MLXArray broadcast comparison instead of scalar O(n^2) loop
private func createBlockAttentionMask(seqLen: Int, cuSeqlens: [Int]) -> MLXArray {
// Assign a block ID to each position
var blockIds = [Int32](repeating: 0, count: seqLen)
for i in 0..<(cuSeqlens.count - 1) {
let start = cuSeqlens[i]
let end = cuSeqlens[i + 1]
for pos in start..<end {
blockIds[pos] = Int32(i)
}
}
// Use broadcast comparison: mask[i,j] = 0 if same block, -1e9 otherwise
let rowIds = MLXArray(blockIds).expandedDimensions(axis: 1) // [seqLen, 1]
let colIds = MLXArray(blockIds).expandedDimensions(axis: 0) // [1, seqLen]
// Where block IDs match: 0 (attend), otherwise -1e9 (block)
let mask = MLX.where(rowIds .== colIds, MLXArray(Float(0)), MLXArray(Float(-1e9)))
// Add batch and head dimensions: [seqLen, seqLen] -> [1, 1, seqLen, seqLen]
return mask.expandedDimensions(axes: [0, 1])
}
/// Process mel spectrogram with time chunking (matching Python mlx-audio exactly)
/// Input: [batch, mel_bins, time]
/// Output: [time', output_dim] (no batch dim, matching Python)
public func callAsFunction(_ melFeatures: MLXArray) -> MLXArray {
let timeFrames = melFeatures.dim(2)
let chunkSize = config.nWindow * 2 // 100
// Calculate number of chunks
let numChunks = (timeFrames + chunkSize - 1) / chunkSize // ceil division
// Compute chunk lengths (all full except possibly last)
var chunkLengths = [Int]()
for i in 0..<numChunks {
if i == numChunks - 1 {
let remainder = timeFrames % chunkSize
chunkLengths.append(remainder == 0 ? chunkSize : remainder)
} else {
chunkLengths.append(chunkSize)
}
}
let maxChunkLen = chunkLengths.max() ?? chunkSize
// Extract and pad chunks - stack as batch dimension for parallel processing
var paddedChunks: [MLXArray] = []
var pos = 0
for i in 0..<numChunks {
let clen = chunkLengths[i]
// Extract chunk from first (and only) batch item
let feat = melFeatures[0, 0..., pos..<(pos + clen)] // [mel, clen]
pos += clen
// Pad if needed
var chunk: MLXArray
if clen < maxChunkLen {
let padWidth = maxChunkLen - clen
chunk = padded(feat, widths: [.init((low: 0, high: 0)), .init((low: 0, high: padWidth))])
} else {
chunk = feat
}
paddedChunks.append(chunk)
}
// Stack chunks as batch: [numChunks, mel, maxChunkLen]
let paddedFeature = stacked(paddedChunks, axis: 0)
// Add channel dim: [numChunks, mel, time, 1] for Conv2d (NHWC)
var x = paddedFeature.expandedDimensions(axis: -1)
// Process through conv layers
x = conv2d1(x)
x = gelu(x)
x = conv2d2(x)
x = gelu(x)
x = conv2d3(x)
x = gelu(x)
// Shape after conv: [numChunks, freq=16, time', channels=480]
let numChunksBatch = x.dim(0)
let freq = x.dim(1) // 16
let timeAfterConv = x.dim(2) // ~13 for 100 input frames
let channels = x.dim(3) // 480
// Transpose and reshape: [numChunks, freq, time, channels] -> [numChunks, time, channels*freq]
x = x.transposed(0, 2, 3, 1) // [numChunks, time, channels, freq]
x = x.reshaped(numChunksBatch, timeAfterConv, channels * freq) // [numChunks, time, 7680]
// Project through conv_out (7680 -> 896)
x = convOut(x)
// Add sinusoidal position embeddings - same for each chunk!
// Cache to avoid recomputing for the same sequence length
let posEmbed: MLXArray
if let cached = cachedPosEmbeddings[timeAfterConv] {
posEmbed = cached
} else {
let computed = createSinusoidalPositionEmbeddings(seqLen: timeAfterConv, dModel: config.dModel)
cachedPosEmbeddings[timeAfterConv] = computed
posEmbed = computed
}
x = x + posEmbed // Broadcasting: [numChunks, time, 896] + [1, time, 896]
// Calculate valid lengths after CNN for each chunk
var featureLensAfterCnn = [Int]()
for clen in chunkLengths {
// Formula from Python: (((clen-1)//2 + 1 - 1)//2 + 1 - 1)//2 + 1
var featLen = (clen - 1) / 2 + 1
featLen = (featLen - 1) / 2 + 1
featLen = (featLen - 1) / 2 + 1
featureLensAfterCnn.append(featLen)
}
// Extract valid portions and concatenate
var hiddenList: [MLXArray] = []
for i in 0..<numChunks {
let validLen = featureLensAfterCnn[i]
let chunkHidden = x[i, 0..<validLen, 0...] // [validLen, 896]
hiddenList.append(chunkHidden)
}
// Concatenate all valid hidden states: [totalTokens, 896]
var hiddenStates = concatenated(hiddenList, axis: 0)
let totalTokens = hiddenStates.dim(0)
// Build cumulative sequence lengths for block attention mask
let maxLenAfterCnn = featureLensAfterCnn.max() ?? 13
let windowAfterCnn = maxLenAfterCnn * (config.nWindowInfer / (config.nWindow * 2)) // 13 * 8 = 104
let totalCnnLen = getOutputLength(timeFrames) // 260
// Build chunk lengths for windowed attention (NOT starting with 0)
var cuChunkLens = [Int]()
let numFullWindows = totalCnnLen / windowAfterCnn
for _ in 0..<numFullWindows {
cuChunkLens.append(windowAfterCnn)
}
let windowRemainder = totalCnnLen % windowAfterCnn
if windowRemainder != 0 {
cuChunkLens.append(windowRemainder)
}
// Compute cumulative sums: [0, 104, 208, 260]
var cuSeqlens = [0]
var cumsum = 0
for len in cuChunkLens {
cumsum += len
cuSeqlens.append(cumsum)
}
// Create block attention mask
let attentionMask = createBlockAttentionMask(seqLen: totalTokens, cuSeqlens: cuSeqlens)
// Add batch dimension for transformer: [1, totalTokens, 896]
hiddenStates = hiddenStates.expandedDimensions(axis: 0)
// Apply transformer layers with attention mask
for layer in layers {
hiddenStates = layer(hiddenStates, attentionMask: attentionMask)
}
// Remove batch dimension: [totalTokens, 896]
hiddenStates = hiddenStates.squeezed(axis: 0)
// Post processing
hiddenStates = lnPost(hiddenStates)
// Project to text model dimension (GELU activation)
hiddenStates = proj1(hiddenStates)
hiddenStates = gelu(hiddenStates)
hiddenStates = proj2(hiddenStates)
return hiddenStates
}
}
@@ -0,0 +1,490 @@
import Foundation
import Accelerate
import MLX
import AudioCommon
/// MLX-free container for mel spectrogram data.
/// Layout: `data` is a flat [Float] in row-major [melBins, timeFrames] order.
public struct MelFeatures {
public let data: [Float]
public let melBins: Int
public let timeFrames: Int
public init(data: [Float], melBins: Int, timeFrames: Int) {
self.data = data
self.melBins = melBins
self.timeFrames = timeFrames
}
}
/// Whisper-style feature extractor for Qwen3-ASR
/// Converts raw audio to mel spectrograms
/// Parameters from HuggingFace preprocessor_config.json
public class WhisperFeatureExtractor {
public let sampleRate: Int = 16000 // HF WhisperFeatureExtractor uses 16kHz
public let nFFT: Int = 400 // FFT size (from config)
public let hopLength: Int = 160 // 10ms hop at 16kHz (from config)
public let nMels: Int = 128 // Mel filterbank bins (feature_size)
public let chunkLength: Int = 30 // Max audio chunk in seconds
private var melFilterbank: [Float]?
// Cached Hann window and FFT setup for Accelerate
private var hannWindow: [Float]
// Power-of-2 FFT: zero-pad nFFT=400 to paddedFFT=512 for vDSP compatibility
private let paddedFFT: Int = 512
private let log2PaddedFFT: vDSP_Length = 9 // log2(512) = 9
private var fftSetup: FFTSetup
public init() {
// Precompute periodic Hann window
hannWindow = [Float](repeating: 0, count: 400) // nFFT
for i in 0..<400 {
hannWindow[i] = 0.5 * (1.0 - cos(2.0 * Float.pi * Float(i) / Float(400)))
}
// Create power-of-2 FFT setup (512-point)
guard let setup = vDSP_create_fftsetup(9, FFTRadix(kFFTRadix2)) else {
fatalError("Failed to create vDSP FFT setup for paddedFFT=512")
}
fftSetup = setup
setupMelFilterbank()
}
deinit {
vDSP_destroy_fftsetup(fftSetup)
}
/// Setup mel filterbank matrix with slaney normalization
/// Matches HuggingFace transformers.audio_utils.mel_filter_bank exactly
private func setupMelFilterbank() {
let fMin: Float = 0.0
let fMax: Float = Float(sampleRate) / 2.0 // Nyquist frequency (8000 Hz for 16kHz)
// Slaney mel scale conversion functions (HuggingFace style)
// This is a piecewise function: linear below 1000 Hz, logarithmic above
let minLogHertz: Float = 1000.0
let minLogMel: Float = 15.0
let logstepHzToMel: Float = 27.0 / log(6.4) // For Hz->Mel
let logstepMelToHz: Float = log(6.4) / 27.0 // For Mel->Hz
func hzToMel(_ hz: Float) -> Float {
if hz < minLogHertz {
return 3.0 * hz / 200.0 // Linear region
} else {
return minLogMel + log(hz / minLogHertz) * logstepHzToMel // Log region
}
}
func melToHz(_ mel: Float) -> Float {
if mel < minLogMel {
return 200.0 * mel / 3.0 // Linear region
} else {
return minLogHertz * exp((mel - minLogMel) * logstepMelToHz) // Exp region
}
}
// Use paddedFFT for bin count since we zero-pad to 512 for FFT
let nBins = paddedFFT / 2 + 1 // 257 for paddedFFT=512
// FFT bin frequencies: k * fs / paddedFFT (not nFFT, since we zero-pad)
var fftFreqs = [Float](repeating: 0, count: nBins)
for i in 0..<nBins {
fftFreqs[i] = Float(i) * Float(sampleRate) / Float(paddedFFT)
}
// Create mel filter center frequencies
let melMin = hzToMel(fMin)
let melMax = hzToMel(fMax)
// nMels + 2 points for triangular filters (includes low and high edges)
let nMelPoints = nMels + 2
var melPoints = [Float](repeating: 0, count: nMelPoints)
for i in 0..<nMelPoints {
melPoints[i] = melMin + Float(i) * (melMax - melMin) / Float(nMelPoints - 1)
}
// Convert mel points to Hz - these are the filter edge frequencies
let filterFreqs = melPoints.map { melToHz($0) }
// Calculate filter frequency differences for normalization
var filterDiff = [Float](repeating: 0, count: nMelPoints - 1)
for i in 0..<(nMelPoints - 1) {
filterDiff[i] = filterFreqs[i + 1] - filterFreqs[i]
}
// Create filterbank using HuggingFace's _create_triangular_filter_bank approach
// This creates smooth triangular filters in frequency space
// Output shape: [nBins, nMels] - we'll transpose later for our use
var filterbank = [Float](repeating: 0, count: nBins * nMels)
for bin in 0..<nBins {
let fftFreq = fftFreqs[bin]
for mel in 0..<nMels {
// Filter edges: filterFreqs[mel], filterFreqs[mel+1], filterFreqs[mel+2]
let lowFreq = filterFreqs[mel]
let highFreq = filterFreqs[mel + 2]
// Calculate slopes (HuggingFace formula)
// slopes = filter_freqs - fft_freqs (broadcast)
// down_slopes = -slopes[:, :-2] / filter_diff[:-1]
// up_slopes = slopes[:, 2:] / filter_diff[1:]
let downSlope = (fftFreq - lowFreq) / filterDiff[mel] // Rising edge
let upSlope = (highFreq - fftFreq) / filterDiff[mel + 1] // Falling edge
// Triangular filter: max(0, min(down_slope, up_slope))
let filterValue = max(0.0, min(downSlope, upSlope))
// Store in [nBins, nMels] layout
filterbank[bin * nMels + mel] = filterValue
}
}
// Apply slaney normalization: 2.0 / (high_freq - low_freq) for each mel filter
for mel in 0..<nMels {
let enorm = 2.0 / (filterFreqs[mel + 2] - filterFreqs[mel])
for bin in 0..<nBins {
filterbank[bin * nMels + mel] *= enorm
}
}
// Transpose to [nMels, nBins] for our matrix multiplication
var filterbankTransposed = [Float](repeating: 0, count: nMels * nBins)
for mel in 0..<nMels {
for bin in 0..<nBins {
filterbankTransposed[mel * nBins + bin] = filterbank[bin * nMels + mel]
}
}
self.melFilterbank = filterbankTransposed
}
/// Extract mel spectrogram features from audio samples
/// - Parameter audio: Raw audio samples (Float array, mono, at sampleRate)
/// - Returns: Mel spectrogram [mel_bins, time_frames]
public func extractFeatures(_ audio: [Float]) -> MLXArray {
let nBins = paddedFFT / 2 + 1 // 257 bins for 512-point FFT
let halfPadded = paddedFFT / 2 // 256
// Pad audio with reflect padding (like Whisper/librosa)
let padLength = nFFT / 2
var paddedAudio = [Float](repeating: 0, count: padLength + audio.count + padLength)
// Reflect pad left side
for i in 0..<padLength {
let srcIdx = min(padLength - i, audio.count - 1)
paddedAudio[i] = audio[max(0, srcIdx)]
}
// Copy original audio
for i in 0..<audio.count {
paddedAudio[padLength + i] = audio[i]
}
// Reflect pad right side
for i in 0..<padLength {
let srcIdx = audio.count - 2 - i
paddedAudio[padLength + audio.count + i] = audio[max(0, srcIdx)]
}
// Calculate number of frames
let nFrames = (paddedAudio.count - nFFT) / hopLength + 1
// --- Accelerate FFT-based STFT using vDSP_fft_zrip ---
// Zero-pad nFFT=400 samples to paddedFFT=512 for power-of-2 FFT
// vDSP_fft_zrip uses split-complex in-place: even-indexed in realp, odd-indexed in imagp
// Output packing: DC in realp[0], Nyquist in imagp[0], bins 1..N/2-1 in realp[k]+j*imagp[k]
// Preallocate buffers outside the frame loop
var splitReal = [Float](repeating: 0, count: halfPadded)
var splitImag = [Float](repeating: 0, count: halfPadded)
var paddedFrame = [Float](repeating: 0, count: paddedFFT)
var magnitude = [Float](repeating: 0, count: nFrames * nBins)
for frame in 0..<nFrames {
let start = frame * hopLength
// Apply window and write into paddedFrame (first nFFT elements)
paddedAudio.withUnsafeBufferPointer { buf in
vDSP_vmul(buf.baseAddress! + start, 1, hannWindow, 1, &paddedFrame, 1, vDSP_Length(nFFT))
}
// Zero-pad the rest (nFFT..<paddedFFT)
for i in nFFT..<paddedFFT {
paddedFrame[i] = 0
}
// Pack into split-complex: realp[i] = frame[2*i], imagp[i] = frame[2*i+1]
for i in 0..<halfPadded {
splitReal[i] = paddedFrame[2 * i]
splitImag[i] = paddedFrame[2 * i + 1]
}
// Execute in-place real FFT
splitReal.withUnsafeMutableBufferPointer { realBuf in
splitImag.withUnsafeMutableBufferPointer { imagBuf in
var splitComplex = DSPSplitComplex(
realp: realBuf.baseAddress!,
imagp: imagBuf.baseAddress!)
vDSP_fft_zrip(fftSetup, &splitComplex, 1, log2PaddedFFT, FFTDirection(kFFTDirection_Forward))
}
}
// Extract power spectrum
let baseIdx = frame * nBins
// DC component: realp[0]^2 (DC is purely real, stored in realp[0])
magnitude[baseIdx] = splitReal[0] * splitReal[0]
// Nyquist component: imagp[0]^2 (Nyquist is purely real, packed in imagp[0])
magnitude[baseIdx + halfPadded] = splitImag[0] * splitImag[0]
// Bins 1 to N/2-1: realp[k]^2 + imagp[k]^2
for k in 1..<halfPadded {
magnitude[baseIdx + k] = splitReal[k] * splitReal[k] + splitImag[k] * splitImag[k]
}
}
// --- BLAS sgemm for mel filterbank ---
guard let filterbank = melFilterbank else {
fatalError("Mel filterbank not initialized")
}
var melSpec = [Float](repeating: 0, count: nFrames * nMels)
// melSpec[nFrames, nMels] = magnitude[nFrames, nBins] * filterbankT[nBins, nMels]
// filterbank is [nMels, nBins]. We need A * B^T.
// vDSP_mmul computes C = A * B, so we need to pre-transpose filterbank.
// Transpose filterbank [nMels, nBins] -> filterbankT [nBins, nMels]
var filterbankT = [Float](repeating: 0, count: nBins * nMels)
vDSP_mtrans(filterbank, 1, &filterbankT, 1, vDSP_Length(nBins), vDSP_Length(nMels))
// C[nFrames, nMels] = A[nFrames, nBins] * B[nBins, nMels]
vDSP_mmul(magnitude, 1, filterbankT, 1, &melSpec, 1,
vDSP_Length(nFrames), vDSP_Length(nMels), vDSP_Length(nBins))
// --- Vectorized log10, clamp, normalize ---
let count = melSpec.count
var countN = Int32(count)
// Clamp minimum to epsilon before log
var epsilon: Float = 1e-10
vDSP_vclip(melSpec, 1, &epsilon, [Float.greatestFiniteMagnitude], &melSpec, 1, vDSP_Length(count))
// log10 using vForce
vvlog10f(&melSpec, melSpec, &countN)
// Find max value for dynamic range compression
var maxVal: Float = -Float.infinity
vDSP_maxv(melSpec, 1, &maxVal, vDSP_Length(count))
// Clamp minimum to max - 8.0
var minClamp = maxVal - 8.0
var maxClamp = Float.greatestFiniteMagnitude
vDSP_vclip(melSpec, 1, &minClamp, &maxClamp, &melSpec, 1, vDSP_Length(count))
// Normalize: (x + 4.0) / 4.0 = x * 0.25 + 1.0
var scale: Float = 0.25
var offset: Float = 1.0
vDSP_vsmsa(melSpec, 1, &scale, &offset, &melSpec, 1, vDSP_Length(count))
// CRITICAL: HuggingFace WhisperFeatureExtractor removes the last frame: log_spec[:, :-1]
let trimmedFrames = nFrames - 1
let trimmedMelSpec = Array(melSpec.prefix(trimmedFrames * nMels))
// Qwen3-ASR encoder handles arbitrary-length audio via windowed attention.
// The chunkLength=30 from preprocessor_config.json is inherited from the HuggingFace
// WhisperFeatureExtractor class but is not enforced by the official Qwen3-ASR pipeline.
// Cap at 1200 seconds (120000 frames) to match the official pipeline's upper bound
// and prevent OOM on extremely long inputs.
let maxFrames = 1200 * sampleRate / hopLength // 120000 frames at 16kHz/160hop
let finalFrames: Int
let finalMelSpec: [Float]
if trimmedFrames > maxFrames {
finalFrames = maxFrames
finalMelSpec = Array(trimmedMelSpec.prefix(maxFrames * nMels))
} else {
finalFrames = trimmedFrames
finalMelSpec = trimmedMelSpec
}
let array = MLXArray(finalMelSpec, [finalFrames, nMels])
return array.transposed(1, 0) // [mel_bins, time_frames]
}
/// Process audio for Qwen3-ASR model
/// - Parameter audio: Raw audio samples (any sample rate)
/// - Parameter inputSampleRate: Sample rate of input audio
/// - Returns: Preprocessed mel features ready for the model
public func process(_ audio: [Float], sampleRate inputSampleRate: Int) -> MLXArray {
var processedAudio = audio
// Resample if needed
if inputSampleRate != sampleRate {
processedAudio = AudioFileLoader.resample(audio, from: inputSampleRate, to: sampleRate)
}
// NOTE: HuggingFace WhisperFeatureExtractor does NOT normalize audio amplitude
// The model expects raw audio values (typically in [-1, 1] range from int16 conversion)
// Do NOT divide by max absolute value!
// Extract features
return extractFeatures(processedAudio)
}
// MARK: - MLX-free variants (for CoreML-only path)
/// Extract mel spectrogram features without MLXArray dependency.
/// Produces identical output to `extractFeatures(_:)` but returns a plain `MelFeatures`
/// struct with layout `[melBins, timeFrames]` (transposed, same as the MLXArray version).
///
/// All mel computation uses the same Accelerate/vDSP pipeline; only the final
/// transpose is done with a pure-Swift loop instead of `MLXArray.transposed`.
public func extractFeaturesRaw(_ audio: [Float]) -> MelFeatures {
let nBins = paddedFFT / 2 + 1 // 257 bins for 512-point FFT
let halfPadded = paddedFFT / 2 // 256
// Pad audio with reflect padding (like Whisper/librosa)
let padLength = nFFT / 2
var paddedAudio = [Float](repeating: 0, count: padLength + audio.count + padLength)
// Reflect pad left side
for i in 0..<padLength {
let srcIdx = min(padLength - i, audio.count - 1)
paddedAudio[i] = audio[max(0, srcIdx)]
}
// Copy original audio
for i in 0..<audio.count {
paddedAudio[padLength + i] = audio[i]
}
// Reflect pad right side
for i in 0..<padLength {
let srcIdx = audio.count - 2 - i
paddedAudio[padLength + audio.count + i] = audio[max(0, srcIdx)]
}
// Calculate number of frames
let nFrames = (paddedAudio.count - nFFT) / hopLength + 1
// --- Accelerate FFT-based STFT using vDSP_fft_zrip ---
var splitReal = [Float](repeating: 0, count: halfPadded)
var splitImag = [Float](repeating: 0, count: halfPadded)
var paddedFrame = [Float](repeating: 0, count: paddedFFT)
var magnitude = [Float](repeating: 0, count: nFrames * nBins)
for frame in 0..<nFrames {
let start = frame * hopLength
paddedAudio.withUnsafeBufferPointer { buf in
vDSP_vmul(buf.baseAddress! + start, 1, hannWindow, 1, &paddedFrame, 1, vDSP_Length(nFFT))
}
for i in nFFT..<paddedFFT {
paddedFrame[i] = 0
}
for i in 0..<halfPadded {
splitReal[i] = paddedFrame[2 * i]
splitImag[i] = paddedFrame[2 * i + 1]
}
splitReal.withUnsafeMutableBufferPointer { realBuf in
splitImag.withUnsafeMutableBufferPointer { imagBuf in
var splitComplex = DSPSplitComplex(
realp: realBuf.baseAddress!,
imagp: imagBuf.baseAddress!)
vDSP_fft_zrip(fftSetup, &splitComplex, 1, log2PaddedFFT, FFTDirection(kFFTDirection_Forward))
}
}
let baseIdx = frame * nBins
magnitude[baseIdx] = splitReal[0] * splitReal[0]
magnitude[baseIdx + halfPadded] = splitImag[0] * splitImag[0]
for k in 1..<halfPadded {
magnitude[baseIdx + k] = splitReal[k] * splitReal[k] + splitImag[k] * splitImag[k]
}
}
// --- BLAS sgemm for mel filterbank ---
guard let filterbank = melFilterbank else {
fatalError("Mel filterbank not initialized")
}
var melSpec = [Float](repeating: 0, count: nFrames * nMels)
var filterbankT = [Float](repeating: 0, count: nBins * nMels)
vDSP_mtrans(filterbank, 1, &filterbankT, 1, vDSP_Length(nBins), vDSP_Length(nMels))
vDSP_mmul(magnitude, 1, filterbankT, 1, &melSpec, 1,
vDSP_Length(nFrames), vDSP_Length(nMels), vDSP_Length(nBins))
// --- Vectorized log10, clamp, normalize ---
let count = melSpec.count
var countN = Int32(count)
var epsilon: Float = 1e-10
vDSP_vclip(melSpec, 1, &epsilon, [Float.greatestFiniteMagnitude], &melSpec, 1, vDSP_Length(count))
vvlog10f(&melSpec, melSpec, &countN)
var maxVal: Float = -Float.infinity
vDSP_maxv(melSpec, 1, &maxVal, vDSP_Length(count))
var minClamp = maxVal - 8.0
var maxClamp = Float.greatestFiniteMagnitude
vDSP_vclip(melSpec, 1, &minClamp, &maxClamp, &melSpec, 1, vDSP_Length(count))
var scale: Float = 0.25
var offset: Float = 1.0
vDSP_vsmsa(melSpec, 1, &scale, &offset, &melSpec, 1, vDSP_Length(count))
// CRITICAL: HuggingFace WhisperFeatureExtractor removes the last frame
let trimmedFrames = nFrames - 1
let trimmedMelSpec = Array(melSpec.prefix(trimmedFrames * nMels))
let maxFrames = 1200 * sampleRate / hopLength // 120000 frames
let finalFrames: Int
let finalMelSpec: [Float]
if trimmedFrames > maxFrames {
finalFrames = maxFrames
finalMelSpec = Array(trimmedMelSpec.prefix(maxFrames * nMels))
} else {
finalFrames = trimmedFrames
finalMelSpec = trimmedMelSpec
}
// Transpose [timeFrames, melBins] -> [melBins, timeFrames] in pure Swift
var transposed = [Float](repeating: 0, count: finalFrames * nMels)
for t in 0..<finalFrames {
for m in 0..<nMels {
transposed[m * finalFrames + t] = finalMelSpec[t * nMels + m]
}
}
return MelFeatures(data: transposed, melBins: nMels, timeFrames: finalFrames)
}
/// Process audio for Qwen3-ASR without MLXArray dependency.
/// MLX-free equivalent of `process(_:sampleRate:)`.
/// - Parameter audio: Raw audio samples (any sample rate)
/// - Parameter inputSampleRate: Sample rate of input audio
/// - Returns: `MelFeatures` with layout `[melBins, timeFrames]`
public func processRaw(_ audio: [Float], sampleRate inputSampleRate: Int) -> MelFeatures {
var processedAudio = audio
// Resample if needed
if inputSampleRate != sampleRate {
processedAudio = AudioFileLoader.resample(audio, from: inputSampleRate, to: sampleRate)
}
// NOTE: HuggingFace WhisperFeatureExtractor does NOT normalize audio amplitude
// The model expects raw audio values (typically in [-1, 1] range from int16 conversion)
// Do NOT divide by max absolute value!
return extractFeaturesRaw(processedAudio)
}
}
@@ -0,0 +1,158 @@
import Foundation
import AudioCommon
/// Configuration for Qwen3-ASR audio encoder
public struct AudioEncoderConfig: Codable, Sendable {
public var inputDim: Int = 128 // Mel filterbank bins
public var hiddenDim: Int = 1024 // Transformer hidden dim
public var numLayers: Int = 18 // Transformer layers
public var numHeads: Int = 16 // Attention heads
public var kernelSize: Int = 3 // Conv kernel size
public var headDim: Int = 64 // Head dimension (hiddenDim / numHeads)
public var ffnDim: Int = 4096 // FFN intermediate dim
public var maxSourcePositions: Int = 1500
public var layerNormEps: Float = 1e-5
public var attentionDropout: Float = 0.0
public var dropoutRate: Float = 0.0
public var layerdrop: Float = 0.0
public var numMelBins: Int = 128
public var projectorHiddenAct: String = "silu"
public init() {}
/// Config for Qwen3-ASR-0.6B
public static var small: AudioEncoderConfig {
var config = AudioEncoderConfig()
config.hiddenDim = 768
config.numLayers = 12
config.numHeads = 12
config.headDim = 64
config.ffnDim = 3072
return config
}
/// Config for Qwen3-ASR-1.7B
public static var large: AudioEncoderConfig {
var config = AudioEncoderConfig()
config.hiddenDim = 1024
config.numLayers = 24
config.numHeads = 16
config.headDim = 64
config.ffnDim = 4096
return config
}
}
/// Configuration for Qwen3 text decoder
public struct TextDecoderConfig: Codable, Sendable {
public var vocabSize: Int = 151936
public var hiddenSize: Int = 1024 // Model dimension
public var numLayers: Int = 28 // Transformer layers
public var numHeads: Int = 16 // Attention heads
public var numKVHeads: Int = 8 // KV heads for GQA
public var headDim: Int = 64 // Head dimension (hiddenSize / numHeads for 0.6B)
public var intermediateSize: Int = 3072 // FFN intermediate size
public var maxPositionEmbeddings: Int = 65536
public var rmsNormEps: Float = 1e-6
public var ropeTheta: Float = 1000000.0
public var ropeScaling: RopeScaling? = nil
public var tieWordEmbeddings: Bool = true
// Quantization config
public var groupSize: Int = 64
public var bits: Int = 4
public init() {}
/// Config for Qwen3-ASR-0.6B decoder, 4-bit (from HuggingFace model config)
public static var small: TextDecoderConfig {
var config = TextDecoderConfig()
config.hiddenSize = 1024
config.numLayers = 28
config.numHeads = 16
config.numKVHeads = 8
config.headDim = 128 // From config.json: head_dim = 128
config.intermediateSize = 3072
config.groupSize = 64
config.bits = 4
return config
}
/// Config for Qwen3-ASR-0.6B decoder, 8-bit
public static var small8bit: TextDecoderConfig {
var config = small
config.bits = 8
return config
}
/// Config for Qwen3-ASR-1.7B decoder, 4-bit
public static var large: TextDecoderConfig {
var config = TextDecoderConfig()
config.hiddenSize = 2048
config.numLayers = 28
config.numHeads = 16
config.numKVHeads = 8
config.headDim = 128
config.intermediateSize = 6144
config.groupSize = 64
config.bits = 4
return config
}
/// Config for Qwen3-ASR-1.7B decoder, 8-bit
public static var large8bit: TextDecoderConfig {
var config = large
config.bits = 8
return config
}
}
/// RoPE scaling configuration
public struct RopeScaling: Codable, Sendable {
public var type: String
public var factor: Float?
public var originalMaxPositionEmbeddings: Int?
enum CodingKeys: String, CodingKey {
case type
case factor
case originalMaxPositionEmbeddings = "original_max_position_embeddings"
}
}
/// Combined Qwen3-ASR model configuration
public struct Qwen3ASRConfig: Codable, Sendable {
public var audioEncoder: AudioEncoderConfig
public var textDecoder: TextDecoderConfig
public var audioTokenIndex: Int = 151646
public var eosTokenId: Int = 151645
public var padTokenId: Int = 151643
// ForcedAligner-specific config
public var classifyNum: Int = 5000
public var timestampSegmentTime: Float = 0.08 // 80ms per timestamp class
public init(
audioEncoder: AudioEncoderConfig = AudioEncoderConfig(),
textDecoder: TextDecoderConfig = TextDecoderConfig()
) {
self.audioEncoder = audioEncoder
self.textDecoder = textDecoder
}
/// Config for Qwen3-ASR-0.6B
public static var small: Qwen3ASRConfig {
Qwen3ASRConfig(
audioEncoder: .small,
textDecoder: .small
)
}
/// Config for Qwen3-ASR-1.7B
public static var large: Qwen3ASRConfig {
Qwen3ASRConfig(
audioEncoder: .large,
textDecoder: .large
)
}
}
@@ -0,0 +1,389 @@
#if canImport(CoreML)
import CoreML
import Foundation
import MLX
import AudioCommon
/// Full CoreML ASR model: CoreML encoder + CoreML text decoder.
///
/// Runs the entire Qwen3-ASR pipeline on CoreML (Neural Engine + CPU),
/// eliminating the MLX GPU dependency. Requires macOS 15+ / iOS 18+
/// for MLState KV cache support.
public class CoreMLASRModel {
public let encoder: CoreMLASREncoder
public let decoder: CoreMLTextDecoder
public let featureExtractor: WhisperFeatureExtractor
private var tokenizer: Qwen3Tokenizer?
public init(encoder: CoreMLASREncoder, decoder: CoreMLTextDecoder) {
self.encoder = encoder
self.decoder = decoder
self.featureExtractor = WhisperFeatureExtractor()
}
/// Load full CoreML ASR from HuggingFace.
///
/// Downloads encoder and decoder models from `aufklarer/Qwen3-ASR-CoreML`.
///
/// Compute units are split per-component because the encoder and decoder
/// have different optimal backends: the encoder defaults to `.all` (the
/// 30 s fixed-shape graph runs well on GPU on most Macs), while the
/// decoder defaults to `.cpuAndNeuralEngine` (the autoregressive
/// MLState path is ANE-friendly and ~7× faster there than GPU per the
/// rebuilt-encoder PR). A single `computeUnits` parameter that
/// propagated into both calls silently overrode the decoder's stated
/// `.cpuAndNeuralEngine` default with `.all`, costing real ANE
/// throughput on the full-pipeline path.
public static func fromPretrained(
encoderModelId: String = CoreMLASREncoder.defaultModelId,
decoderModelId: String = CoreMLASREncoder.defaultModelId,
tokenizerModelId: String = "aufklarer/Qwen3-ASR-0.6B-MLX-4bit",
encoderComputeUnits: MLComputeUnits = CoreMLComputeUnitsResolver.resolved(default: .all),
decoderComputeUnits: MLComputeUnits = CoreMLComputeUnitsResolver.resolved(default: .cpuAndNeuralEngine),
cacheDir: URL? = nil,
offlineMode: Bool = false,
progressHandler: ((Double, String) -> Void)? = nil
) async throws -> CoreMLASRModel {
// Download encoder (0-30%)
progressHandler?(0.0, "Loading CoreML encoder...")
let enc = try await CoreMLASREncoder.fromPretrained(
modelId: encoderModelId,
computeUnits: encoderComputeUnits,
cacheDir: cacheDir,
offlineMode: offlineMode
) { p, msg in
progressHandler?(p * 0.3, msg)
}
// Download decoder (30-80%)
progressHandler?(0.3, "Loading CoreML decoder...")
let dec = try await CoreMLTextDecoder.fromPretrained(
modelId: decoderModelId,
computeUnits: decoderComputeUnits,
cacheDir: cacheDir,
offlineMode: offlineMode
) { p, msg in
progressHandler?(0.3 + p * 0.5, msg)
}
// Download tokenizer (80-90%)
progressHandler?(0.8, "Loading tokenizer...")
let tokenizerDir = try cacheDir ?? HuggingFaceDownloader.getCacheDirectory(for: tokenizerModelId)
try await HuggingFaceDownloader.downloadWeights(
modelId: tokenizerModelId,
to: tokenizerDir,
additionalFiles: ["vocab.json", "merges.txt", "tokenizer_config.json"],
offlineMode: offlineMode
)
let model = CoreMLASRModel(encoder: enc, decoder: dec)
let vocabPath = tokenizerDir.appendingPathComponent("vocab.json")
if FileManager.default.fileExists(atPath: vocabPath.path) {
let tokenizer = Qwen3Tokenizer()
try tokenizer.load(from: vocabPath)
model.tokenizer = tokenizer
}
progressHandler?(1.0, "Ready")
return model
}
/// Warm up both encoder and decoder.
public func warmUp() throws {
try encoder.warmUp()
try decoder.warmUp()
}
/// Transcribe audio to text using full CoreML pipeline.
///
/// The entire inference runs on CoreML (Neural Engine + CPU) without MLX GPU.
public func transcribe(
audio: [Float],
sampleRate: Int = 16000,
language: String? = nil,
maxTokens: Int = 448
) throws -> String {
let profile = ProcessInfo.processInfo.environment["COREML_ASR_PROFILE"] == "1"
let t0 = CFAbsoluteTimeGetCurrent()
let melFeatures = featureExtractor.process(audio, sampleRate: sampleRate)
let t1 = CFAbsoluteTimeGetCurrent()
// The encoder pads mel to a fixed 30 s shape and reports the real
// (un-padded) audio-token count via ``output_length``. We feed only
// the first ``numAudioTokens`` of the padded embeddings to the
// decoder so trailing zero-derived tokens don't pollute attention.
let (audioEmbeds, numAudioTokens) = try encoder.encode(melFeatures)
let t2 = CFAbsoluteTimeGetCurrent()
decoder.resetCache()
// Build chat template token sequence
let imStartId: Int32 = 151644
let imEndId: Int32 = 151645
let audioStartId: Int32 = 151669
let audioEndId: Int32 = 151670
let asrTextId: Int32 = 151704
let newlineId: Int32 = 198
let systemId: Int32 = 8948
let userId: Int32 = 872
let assistantId: Int32 = 77091
// <|im_start|>system\n<|im_end|>\n
var prefixTokens: [Int32] = [imStartId, systemId, newlineId, imEndId, newlineId]
// <|im_start|>user\n<|audio_start|>
prefixTokens += [imStartId, userId, newlineId, audioStartId]
// <|audio_end|><|im_end|>\n<|im_start|>assistant\n
var suffixTokens: [Int32] = [audioEndId, imEndId, newlineId, imStartId, assistantId, newlineId]
// Language hint + <|asr_text|>
if let lang = language, let tokenizer = tokenizer {
let langPrefix = "language \(lang)"
let langTokens = tokenizer.encode(langPrefix)
suffixTokens += langTokens.map { Int32($0) }
}
suffixTokens.append(asrTextId)
// Prefill: process all prefix tokens (one batched call)
var lastLogits: MLMultiArray?
lastLogits = try decoder.decoderPrefillTokens(prefixTokens)
let t3 = CFAbsoluteTimeGetCurrent()
// Prefill: process audio embeddings
// Bulk-extract the MLX audio embeddings once (single Metal sync)
// then feed them to the decoder in batched chunks of
// ``prefillBatchSize`` tokens. The fixed-T CoreML decoder packs
// T tokens per ANE dispatch, so a 20 s / 250-token clip becomes
// ~2 calls instead of 250 (each ANE dispatch costs ~30 ms the
// dispatch overhead dominated the per-step cost in profiling).
let _ = audioEmbeds.dim(2) // sanity-check hidden dim
let audioEmbedsFlat: [Float] = audioEmbeds.asArray(Float.self)
let chunk = decoder.prefillBatchSize
var consumed = 0
while consumed < numAudioTokens {
let n = min(chunk, numAudioTokens - consumed)
lastLogits = try decoder.decoderPrefill(
flatEmbeddings: audioEmbedsFlat,
offset: consumed,
realCount: n,
)
consumed += n
}
let t4 = CFAbsoluteTimeGetCurrent()
// Prefill: process suffix tokens (one batched call)
lastLogits = try decoder.decoderPrefillTokens(suffixTokens)
let t5 = CFAbsoluteTimeGetCurrent()
// Autoregressive generation
guard var logits = lastLogits else {
return "[CoreML decoder: no output]"
}
// Known WER issue with this path (multi-sentence utterances on
// LibriSpeech test-clean): the CoreML port emits ``<|im_end|>``
// after the first sentence-final period with a wide logit margin
// (~6+ nats over the runner-up). The MLX path at the same
// effective bit width keeps generating. We tried both a
// logit-margin guard and a force-first-EOS-suppression; neither
// helps the model's runner-up at the truncation point is also
// wrong (e.g. " The" instead of " on"), so substituting it just
// trades deletions for substitutions. The root cause is upstream
// encoder INT8 quantization / mel padding leakage into audio
// embeddings, or position drift in chunked prefill. Tracked as
// a separate fix that requires model re-export, not a sampler
// change. The ``argmax(skipping:)`` / ``logit(_:at:)`` helpers
// stay for that future work.
var generatedTokens: [Int32] = []
var nextToken = decoder.argmax(logits: logits)
// First-token EOS would mean the model thinks the audio yielded an
// empty transcript never right on real speech. Cheap to guard.
if nextToken == imEndId {
nextToken = decoder.argmax(logits: logits, skipping: imEndId)
}
generatedTokens.append(nextToken)
for _ in 1..<maxTokens {
if nextToken == imEndId { break }
let embedding = try decoder.embed(tokenId: nextToken)
logits = try decoder.decoderStep(embedding: embedding)
nextToken = decoder.argmax(logits: logits)
generatedTokens.append(nextToken)
}
if profile {
let audioDuration = Double(audio.count) / Double(sampleRate)
print("[COREML-ASR-PROFILE] audio=\(audioDuration)s gen=\(generatedTokens.count)")
}
let t6 = CFAbsoluteTimeGetCurrent()
if profile {
let ms = { (a: CFAbsoluteTime, b: CFAbsoluteTime) in (b - a) * 1000 }
print(String(format: "[COREML-ASR-PROFILE] mel=%.0fms encoder=%.0fms prefix=%.0fms audio_prefill=%.0fms(%dtok→%.1fms/tok) suffix=%.0fms gen=%.0fms(%dtok→%.1fms/tok) total=%.0fms",
ms(t0, t1), ms(t1, t2), ms(t2, t3),
ms(t3, t4), numAudioTokens, ms(t3, t4) / Double(max(numAudioTokens, 1)),
ms(t4, t5),
ms(t5, t6), generatedTokens.count, ms(t5, t6) / Double(max(generatedTokens.count, 1)),
ms(t0, t6)))
}
// Decode tokens
if let tokenizer = tokenizer {
let rawText = tokenizer.decode(tokens: generatedTokens.map { Int($0) })
if let range = rawText.range(of: "<asr_text>") {
return String(rawText[range.upperBound...]).trimmingCharacters(in: .whitespaces)
}
return rawText
} else {
return generatedTokens.map { String($0) }.joined(separator: " ")
}
}
// MARK: - MLX-Free Transcription
/// Transcribe audio to text without any MLX/Metal dependency.
///
/// Uses `featureExtractor.processRaw()` (CPU via Accelerate) and
/// `encoder.encode(melData:melBins:timeFrames:)` (CoreML) to produce
/// MLMultiArray embeddings, then decodes using `audioEmbeddingFromMultiArray()`.
///
/// This method is safe for iOS background execution where Metal GPU eval
/// (triggered by MLXArray operations) would cause a crash.
///
/// - Note: Requires `processRaw()` on WhisperFeatureExtractor and
/// `encode(melData:melBins:timeFrames:)` on CoreMLASREncoder, both added by T2.
public func transcribeWithoutMLX(
audio: [Float],
sampleRate: Int = 16000,
language: String? = nil,
maxTokens: Int = 448
) throws -> String {
// 1. Extract mel features (pure CPU via Accelerate no MLXArray)
let melFeatures = featureExtractor.processRaw(audio, sampleRate: sampleRate)
// 2. Encode audio MLMultiArray embeddings + real (un-padded)
// audio-token count from the encoder's ``output_length``.
let encoded = try encoder.encode(
melData: melFeatures.data,
melBins: melFeatures.melBins,
timeFrames: melFeatures.timeFrames
)
let audioEmbeds = encoded.embeddings
let numAudioTokens = encoded.outputLength
// 3. Reset decoder KV cache
decoder.resetCache()
// 4. Build chat template token sequence (identical to transcribe())
let imStartId: Int32 = 151644
let imEndId: Int32 = 151645
let audioStartId: Int32 = 151669
let audioEndId: Int32 = 151670
let asrTextId: Int32 = 151704
let newlineId: Int32 = 198
let systemId: Int32 = 8948
let userId: Int32 = 872
let assistantId: Int32 = 77091
// <|im_start|>system\n<|im_end|>\n
var prefixTokens: [Int32] = [imStartId, systemId, newlineId, imEndId, newlineId]
// <|im_start|>user\n<|audio_start|>
prefixTokens += [imStartId, userId, newlineId, audioStartId]
// <|audio_end|><|im_end|>\n<|im_start|>assistant\n
var suffixTokens: [Int32] = [audioEndId, imEndId, newlineId, imStartId, assistantId, newlineId]
// Language hint + <|asr_text|>
if let lang = language, let tokenizer = tokenizer {
let langPrefix = "language \(lang)"
let langTokens = tokenizer.encode(langPrefix)
suffixTokens += langTokens.map { Int32($0) }
}
suffixTokens.append(asrTextId)
// 5. Prefill: process all prefix tokens
var lastLogits: MLMultiArray?
for token in prefixTokens {
let embedding = try decoder.embed(tokenId: token)
lastLogits = try decoder.decoderStep(embedding: embedding)
}
// 6. Prefill: process audio embeddings (MLX-free path)
for i in 0..<numAudioTokens {
let audioEmbed = try decoder.audioEmbeddingFromMultiArray(audioEmbeds, at: i)
lastLogits = try decoder.decoderStep(embedding: audioEmbed)
}
// Prefill: process suffix tokens
for token in suffixTokens {
let embedding = try decoder.embed(tokenId: token)
lastLogits = try decoder.decoderStep(embedding: embedding)
}
// 7. Autoregressive generation (same EOS note as `transcribe()`
// see that path for the background).
guard var logits = lastLogits else {
return "[CoreML decoder: no output]"
}
var generatedTokens: [Int32] = []
var nextToken = decoder.argmax(logits: logits)
if nextToken == imEndId {
nextToken = decoder.argmax(logits: logits, skipping: imEndId)
}
generatedTokens.append(nextToken)
for _ in 1..<maxTokens {
if nextToken == imEndId { break }
let embedding = try decoder.embed(tokenId: nextToken)
logits = try decoder.decoderStep(embedding: embedding)
nextToken = decoder.argmax(logits: logits)
generatedTokens.append(nextToken)
}
// Decode tokens
if let tokenizer = tokenizer {
let rawText = tokenizer.decode(tokens: generatedTokens.map { Int($0) })
if let range = rawText.range(of: "<asr_text>") {
return String(rawText[range.upperBound...]).trimmingCharacters(in: .whitespaces)
}
return rawText
} else {
return generatedTokens.map { String($0) }.joined(separator: " ")
}
}
}
// MARK: - SpeechRecognitionModel
extension CoreMLASRModel: SpeechRecognitionModel {
public var inputSampleRate: Int { 16000 }
public func transcribe(audio: [Float], sampleRate: Int, language: String?) -> String {
do {
return try transcribe(audio: audio, sampleRate: sampleRate, language: language, maxTokens: 448)
} catch {
return "[CoreML error: \(error.localizedDescription)]"
}
}
}
// MARK: - Background-Safe Transcription
extension CoreMLASRModel {
/// Background-safe transcription (no MLX/Metal dependency).
///
/// Uses `transcribeWithoutMLX()` which avoids all MLXArray operations
/// that would trigger Metal GPU eval. Safe to call from iOS background
/// audio processing where GPU access is prohibited.
public func transcribeBackgroundSafe(audio: [Float], sampleRate: Int, language: String?) -> String {
do {
return try transcribeWithoutMLX(audio: audio, sampleRate: sampleRate, language: language)
} catch {
return "[CoreML error: \(error.localizedDescription)]"
}
}
}
#endif
@@ -0,0 +1,231 @@
#if canImport(CoreML)
import CoreML
import Foundation
import MLX
import AudioCommon
/// CoreML audio encoder for Qwen3-ASR.
///
/// Runs the audio encoder on Neural Engine via CoreML instead of GPU via MLX.
/// Produces audio embeddings that feed into the MLX text decoder. This enables
/// lower power consumption on macOS and is a step toward full iOS deployment.
///
/// The encoder uses a single fixed 30 s mel shape ``[1, 128, 3000]`` and
/// applies upstream's chunked block-attention (100-frame chunks 13 tokens
/// each, 8-chunk attention windows). Mel input is zero-padded to 3000 frames
/// and the real length is signaled via a separate ``mel_length`` input so
/// the in-graph block-attention bias can mask out the padded frames; the
/// model returns the matching real audio-token count via ``output_length``.
public class CoreMLASREncoder {
private let model: MLModel
/// Fixed mel length the chunked-attention encoder is exported with.
/// 3000 mel frames = 30 s @ 100 Hz hop, matching upstream training.
public static let paddedMelLength: Int = 3000
/// Max audio tokens out of the padded encoder (3000 mel / 8 conv stride
/// 30 chunks × 13 tokens). The model writes the real count to ``output_length``.
public static let paddedAudioTokens: Int = 390
public static let defaultModelId = "aufklarer/Qwen3-ASR-CoreML"
/// Embeddings + the real, un-padded audio-token count (from the model's
/// ``output_length`` output). Callers should iterate only the first
/// ``outputLength`` tokens of ``embeddings``.
public struct EncodedAudio {
public let embeddings: MLMultiArray
public let outputLength: Int
}
public init(model: MLModel) {
self.model = model
}
/// Load encoder from a directory containing `encoder.mlmodelc`.
public static func load(
from directory: URL,
computeUnits: MLComputeUnits = CoreMLComputeUnitsResolver.resolved(default: .all)
) throws -> CoreMLASREncoder {
let modelURL = directory.appendingPathComponent("encoder.mlmodelc", isDirectory: true)
guard FileManager.default.fileExists(atPath: modelURL.path) else {
throw AudioModelError.modelLoadFailed(
modelId: "encoder",
reason: "CoreML encoder not found at \(modelURL.path)")
}
let config = MLModelConfiguration()
config.computeUnits = computeUnits
let model = try MLModel(contentsOf: modelURL, configuration: config)
return CoreMLASREncoder(model: model)
}
/// Load encoder from HuggingFace.
public static func fromPretrained(
modelId: String = defaultModelId,
computeUnits: MLComputeUnits = CoreMLComputeUnitsResolver.resolved(default: .all),
cacheDir: URL? = nil,
offlineMode: Bool = false,
progressHandler: ((Double, String) -> Void)? = nil
) async throws -> CoreMLASREncoder {
let cacheDir = try cacheDir ?? HuggingFaceDownloader.getCacheDirectory(for: modelId)
progressHandler?(0.0, "Downloading CoreML encoder...")
try await HuggingFaceDownloader.downloadWeights(
modelId: modelId,
to: cacheDir,
additionalFiles: ["encoder.mlmodelc/**", "config.json"],
offlineMode: offlineMode
) { fraction in
progressHandler?(fraction * 0.8, "Downloading CoreML encoder...")
}
progressHandler?(0.9, "Loading CoreML encoder...")
let encoder = try load(from: cacheDir, computeUnits: computeUnits)
progressHandler?(1.0, "Ready")
return encoder
}
/// Warm up the encoder with a short dummy input to trigger CoreML compilation.
public func warmUp() throws {
// Use a small fake length so warmup doesn't depend on a real clip.
_ = try encodeRaw(melData: [Float](repeating: 0, count: 128 * Self.paddedMelLength),
melBins: 128, realFrames: 100)
}
/// Encode a mel spectrogram and return embeddings as an MLXArray.
///
/// - Parameter melFeatures: Mel spectrogram as MLXArray `[128, T]`
/// - Returns: `(embeddings: [1, paddedAudioTokens, 1024], outputLength)`
public func encode(_ melFeatures: MLXArray) throws -> (embeddings: MLXArray, outputLength: Int) {
let melBins = melFeatures.dim(0)
let melTime = melFeatures.dim(1)
let melData: [Float] = melFeatures.asArray(Float.self)
let raw = try encodeRaw(melData: melData, melBins: melBins, realFrames: melTime)
return (multiArrayToMLXArray(raw.embeddings), raw.outputLength)
}
// MARK: - MLX-free encoding (for iOS background / pure CoreML path)
/// Encode mel spectrogram to audio embeddings without any MLXArray dependency.
///
/// Accepts raw `[Float]` mel data in `[melBins, timeFrames]` layout (the same
/// layout produced by `WhisperFeatureExtractor.extractFeaturesRaw`).
/// Returns the encoder output as `MLMultiArray` directly, avoiding the
/// Metal GPU eval that `MLXArray` would trigger.
///
/// - Parameters:
/// - melData: Flat float array in row-major `[melBins, timeFrames]` order
/// - melBins: Number of mel frequency bins (typically 128)
/// - timeFrames: Number of time frames
/// - Returns: Audio embeddings as `MLMultiArray` with shape `[1, T/8, 1024]`
public func encode(melData: [Float], melBins: Int, timeFrames: Int) throws -> EncodedAudio {
return try encodeRaw(melData: melData, melBins: melBins, realFrames: timeFrames)
}
/// Convenience: encode a `MelFeatures` struct directly.
public func encode(melFeatures: MelFeatures) throws -> EncodedAudio {
return try encodeRaw(melData: melFeatures.data,
melBins: melFeatures.melBins,
realFrames: melFeatures.timeFrames)
}
/// Shared core: zero-pads ``melData`` to the fixed ``paddedMelLength``,
/// runs the two-input/two-output graph, and returns the model's reported
/// ``output_length`` alongside the full padded embeddings.
private func encodeRaw(
melData: [Float], melBins: Int, realFrames: Int
) throws -> EncodedAudio {
let padded = Self.paddedMelLength
guard realFrames <= padded else {
throw AudioModelError.inferenceFailed(
operation: "CoreML encoder",
reason: "Audio too long: \(realFrames) mel frames exceeds fixed shape \(padded). Segment with SpeechVAD or process in 30s windows.")
}
// Mel input: [1, melBins, paddedMelLength], zero-padded past realFrames.
let melArray = try MLMultiArray(
shape: [1, melBins as NSNumber, padded as NSNumber], dataType: .float32)
let mptr = melArray.dataPointer.assumingMemoryBound(to: Float.self)
for bin in 0..<melBins {
let src = bin * realFrames
let dst = bin * padded
for t in 0..<realFrames { mptr[dst + t] = melData[src + t] }
for t in realFrames..<padded { mptr[dst + t] = 0 }
}
// mel_length input: [1] int32 with the real (un-padded) frame count.
let lengthArray = try MLMultiArray(shape: [1], dataType: .int32)
lengthArray[0] = NSNumber(value: Int32(realFrames))
let input = try MLDictionaryFeatureProvider(dictionary: [
"mel": MLFeatureValue(multiArray: melArray),
"mel_length": MLFeatureValue(multiArray: lengthArray),
])
let output = try model.prediction(from: input)
guard let embeddings = output.featureValue(for: "audio_embeddings")?.multiArrayValue else {
throw AudioModelError.inferenceFailed(
operation: "CoreML encoder", reason: "Missing audio_embeddings output")
}
guard let lengthOut = output.featureValue(for: "output_length")?.multiArrayValue else {
throw AudioModelError.inferenceFailed(
operation: "CoreML encoder", reason: "Missing output_length output (encoder may be an older export without the chunked-attention mask)")
}
let outLen = max(0, Int(lengthOut[0].int32Value))
return EncodedAudio(embeddings: embeddings, outputLength: outLen)
}
private func multiArrayToMLXArray(_ array: MLMultiArray) -> MLXArray {
let shape = array.shape.map { $0.intValue }
let count = array.count
switch array.dataType {
case .float16:
let src = array.dataPointer.assumingMemoryBound(to: Float16.self)
var floats = [Float](repeating: 0, count: count)
for i in 0..<count { floats[i] = Float(src[i]) }
return MLXArray(floats, shape)
case .float32:
let src = array.dataPointer.assumingMemoryBound(to: Float.self)
return MLXArray(Array(UnsafeBufferPointer(start: src, count: count)), shape)
default:
let src = array.dataPointer.assumingMemoryBound(to: Float.self)
return MLXArray(Array(UnsafeBufferPointer(start: src, count: count)), shape)
}
}
}
// MARK: - Qwen3ASRModel Integration
extension Qwen3ASRModel {
/// Transcribe audio using CoreML encoder + MLX text decoder.
///
/// This hybrid approach runs the encoder on Neural Engine (CoreML) and the
/// text decoder on GPU (MLX), combining the power efficiency of ANE with
/// the flexibility of MLX for autoregressive decoding.
public func transcribe(
audio: [Float],
sampleRate: Int = 16000,
language: String? = nil,
maxTokens: Int = 448,
coremlEncoder: CoreMLASREncoder
) throws -> String {
let melFeatures = featureExtractor.process(audio, sampleRate: sampleRate)
// CoreML encoder returns the padded `[1, paddedAudioTokens, 1024]`
// embeddings + the real audio-token count via ``outputLength``.
let (audioEmbeds, audioLength) = try coremlEncoder.encode(melFeatures)
guard let textDecoder = textDecoder else {
return "[CoreML encoded: \(audioEmbeds.shape) length=\(audioLength)] - Text decoder not loaded"
}
// Slice the embeddings to the real (un-padded) length before passing
// to the MLX text decoder, otherwise trailing zero-derived tokens
// would pollute decoder cross-attention.
let realEmbeds = audioEmbeds[0..., 0..<audioLength, 0...]
return generateText(
audioEmbeds: realEmbeds,
textDecoder: textDecoder,
language: language,
maxTokens: maxTokens
)
}
}
#endif
@@ -0,0 +1,566 @@
#if canImport(CoreML)
import CoreML
import Foundation
import MLX
import AudioCommon
/// CoreML text decoder for Qwen3-ASR with MLState KV cache.
///
/// Runs the full text decoder on Neural Engine via CoreML instead of GPU via MLX.
/// Requires macOS 15+ / iOS 18+ for MLState support.
///
/// Architecture (three CoreML models for ANE compile-budget reasons):
/// - **embedding**: Token ID embedding vector lookup
/// - **decoder_part1**: Layers 0..(split-1), embedding-in hidden-out
/// - **decoder_part2**: Layers split..N-1 + norm + lm_head, hidden-in logits
///
/// Each part keeps its own ``MLState`` pool of KV caches. Hidden state
/// flows part1 part2.
///
/// **Batched dispatch.** Both decoder parts are converted with a fixed
/// ``T`` (per ``config.json: enumerated_t`` typically 128). One ANE
/// dispatch processes T tokens at once: prefill chunks a contiguous run
/// of new positions through a single call; single-token generation
/// reserves indices ``[0, T-2]`` for *scratch* positions (the last T-1
/// slots of the KV cache, addresses ``maxSeqLength - (T-1)..maxSeqLength``)
/// whose writes are discarded by attention masking. This collapses ~250
/// single-token audio-prefill dispatches into ~2 batched calls.
///
/// EnumeratedShapes(T) is **not** ANE-compatible at this layer count,
/// so the model is fixed-T and we wrap step calls with scratch padding
/// rather than running a smaller variant.
public class CoreMLTextDecoder {
private let embeddingModel: MLModel
private let decoderPart1Model: MLModel
private let decoderPart2Model: MLModel
private let maxSeqLength: Int
private let vocabSize: Int
private let hiddenSize: Int
/// Fixed batch size used by both decoder parts. Loaded from
/// ``config.json: enumerated_t``. Single-token decode pads to this.
private let batchSize: Int
/// One MLState per decoder part, each holds that part's KV caches.
private var part1State: MLState
private var part2State: MLState
/// Current real position in the KV cache (incremented per real token).
private var currentPosition: Int = 0
/// First slot index reserved for scratch writes by partial / step calls.
/// Cache writes at these positions are garbage; attention masks them.
/// Range: ``[scratchStart, maxSeqLength)``, length ``batchSize - 1``.
private var scratchStart: Int { maxSeqLength - (batchSize - 1) }
public static let defaultModelId = "aufklarer/Qwen3-ASR-CoreML"
public init(
embeddingModel: MLModel,
decoderPart1Model: MLModel,
decoderPart2Model: MLModel,
maxSeqLength: Int = 1024,
vocabSize: Int = 151936,
hiddenSize: Int = 1024,
batchSize: Int = 128
) {
self.embeddingModel = embeddingModel
self.decoderPart1Model = decoderPart1Model
self.decoderPart2Model = decoderPart2Model
self.maxSeqLength = maxSeqLength
self.vocabSize = vocabSize
self.hiddenSize = hiddenSize
self.batchSize = batchSize
self.part1State = decoderPart1Model.makeState()
self.part2State = decoderPart2Model.makeState()
}
/// Load decoder models from a directory containing
/// ``embedding.mlmodelc``, ``decoder_part1.mlmodelc`` and ``decoder_part2.mlmodelc``.
public static func load(
from directory: URL,
computeUnits: MLComputeUnits = CoreMLComputeUnitsResolver.resolved(default: .cpuAndNeuralEngine)
) throws -> CoreMLTextDecoder {
let config = MLModelConfiguration()
config.computeUnits = computeUnits
var maxSeq = 1024
var vocabSize = 151936
var hiddenSize = 1024
var batchSize = 128
let configPath = directory.appendingPathComponent("config.json")
if let data = try? Data(contentsOf: configPath),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
maxSeq = json["max_seq_length"] as? Int ?? 1024
vocabSize = json["vocab_size"] as? Int ?? 151936
hiddenSize = json["hidden_size"] as? Int ?? 1024
if let ts = json["enumerated_t"] as? [Int], let t = ts.first {
batchSize = t
}
}
let embURL = findModel(named: "embedding", in: directory)
let p1URL = findModel(named: "decoder_part1", in: directory)
let p2URL = findModel(named: "decoder_part2", in: directory)
guard let embURL else {
throw AudioModelError.modelLoadFailed(
modelId: "embedding",
reason: "CoreML embedding not found in \(directory.path)")
}
guard let p1URL else {
throw AudioModelError.modelLoadFailed(
modelId: "decoder_part1",
reason: "CoreML decoder_part1 not found in \(directory.path)")
}
guard let p2URL else {
throw AudioModelError.modelLoadFailed(
modelId: "decoder_part2",
reason: "CoreML decoder_part2 not found in \(directory.path)")
}
let embModel = try MLModel(contentsOf: embURL, configuration: config)
let p1Model = try MLModel(contentsOf: p1URL, configuration: config)
let p2Model = try MLModel(contentsOf: p2URL, configuration: config)
return CoreMLTextDecoder(
embeddingModel: embModel,
decoderPart1Model: p1Model,
decoderPart2Model: p2Model,
maxSeqLength: maxSeq,
vocabSize: vocabSize,
hiddenSize: hiddenSize,
batchSize: batchSize
)
}
/// Load from HuggingFace.
public static func fromPretrained(
modelId: String = defaultModelId,
computeUnits: MLComputeUnits = CoreMLComputeUnitsResolver.resolved(default: .cpuAndNeuralEngine),
cacheDir: URL? = nil,
offlineMode: Bool = false,
progressHandler: ((Double, String) -> Void)? = nil
) async throws -> CoreMLTextDecoder {
let cacheDir = try cacheDir ?? HuggingFaceDownloader.getCacheDirectory(for: modelId)
progressHandler?(0.0, "Downloading CoreML decoder...")
try await HuggingFaceDownloader.downloadWeights(
modelId: modelId,
to: cacheDir,
additionalFiles: [
"embedding.mlmodelc/**",
"decoder_part1.mlmodelc/**",
"decoder_part2.mlmodelc/**",
"config.json",
],
offlineMode: offlineMode
) { fraction in
progressHandler?(fraction * 0.8, "Downloading CoreML decoder...")
}
progressHandler?(0.9, "Loading CoreML decoder...")
let decoder = try load(from: cacheDir, computeUnits: computeUnits)
progressHandler?(1.0, "Ready")
return decoder
}
/// Warm up the three models so the first real call doesn't pay
/// the ANE compile / load latency. Uses throwaway MLStates so the
/// live KV cache stays untouched.
public func warmUp() throws {
let dummyToken = try MLMultiArray(shape: [1, 1], dataType: .int32)
dummyToken[0] = 0
_ = try embeddingModel.prediction(from: MLDictionaryFeatureProvider(dictionary: [
"token_id": MLFeatureValue(multiArray: dummyToken),
]))
let warmP1 = decoderPart1Model.makeState()
let warmP2 = decoderPart2Model.makeState()
let dummyEmbeds = try MLMultiArray(shape: [1, batchSize as NSNumber, hiddenSize as NSNumber],
dataType: .float32)
let dummyPositions = try MLMultiArray(shape: [batchSize as NSNumber], dataType: .int32)
for i in 0..<batchSize { dummyPositions[i] = NSNumber(value: Int32(i)) }
let dummyMask = try MLMultiArray(shape: [1, 1, batchSize as NSNumber, maxSeqLength as NSNumber],
dataType: .float32)
let mptr = dummyMask.dataPointer.assumingMemoryBound(to: Float.self)
for i in 0..<(batchSize * maxSeqLength) { mptr[i] = -1e4 }
let warmInputs = try MLDictionaryFeatureProvider(dictionary: [
"input_embeds": MLFeatureValue(multiArray: dummyEmbeds),
"positions": MLFeatureValue(multiArray: dummyPositions),
"attention_mask": MLFeatureValue(multiArray: dummyMask),
])
let p1Out = try decoderPart1Model.prediction(from: warmInputs, using: warmP1)
guard let hidden = p1Out.featureValue(for: "hidden_state")?.multiArrayValue else {
throw AudioModelError.inferenceFailed(
operation: "CoreML decoder part1 warmup",
reason: "Missing hidden_state output")
}
let p2Inputs = try MLDictionaryFeatureProvider(dictionary: [
"input_embeds": MLFeatureValue(multiArray: hidden),
"positions": MLFeatureValue(multiArray: dummyPositions),
"attention_mask": MLFeatureValue(multiArray: dummyMask),
])
_ = try decoderPart2Model.prediction(from: p2Inputs, using: warmP2)
}
/// Reset the KV caches in both parts for a new transcription.
public func resetCache() {
currentPosition = 0
part1State = decoderPart1Model.makeState()
part2State = decoderPart2Model.makeState()
}
// MARK: - Token Operations
/// Look up the embedding vector for a single token id.
/// Returns shape ``[1, 1, hidden_size]``.
public func embed(tokenId: Int32) throws -> MLMultiArray {
let tokenArray = try MLMultiArray(shape: [1, 1], dataType: .int32)
tokenArray[0] = NSNumber(value: tokenId)
let input = try MLDictionaryFeatureProvider(dictionary: [
"token_id": MLFeatureValue(multiArray: tokenArray),
])
let output = try embeddingModel.prediction(from: input)
guard let embedding = output.featureValue(for: "embedding")?.multiArrayValue else {
throw AudioModelError.inferenceFailed(
operation: "CoreML embedding", reason: "Missing embedding output")
}
return embedding
}
/// Run one decoder step on a single embedding.
///
/// Packs the one real token into the last input slot and fills the
/// remaining ``batchSize - 1`` slots with scratch positions whose
/// outputs and cache writes are discarded by masking. Single-token
/// decode pays the same ANE dispatch cost as a full chunked prefill.
public func decoderStep(embedding: MLMultiArray) throws -> MLMultiArray {
let bufs = try writeChunk(realEmbeddingsSource: { (slot, dstPtr) in
Self.copyRow(from: embedding, sourceRow: 0, hidden: self.hiddenSize,
to: dstPtr, destSlot: slot)
}, realCount: 1)
return try runParts(embeds: bufs.embeds, positions: bufs.positions, mask: bufs.mask)
}
/// Copy one ``hidden``-length row from an MLMultiArray (any float
/// dtype, any strides) into a contiguous Float32 destination row.
/// The decoder input is declared Float32, but CoreML model *outputs*
/// (embedding lookup, part1 hidden) may come back as Float16 with
/// padded strides on ANE; a raw ``assumingMemoryBound(to: Float)``
/// copy would then read garbage.
private static func copyRow(from src: MLMultiArray, sourceRow: Int, hidden: Int,
to dst: UnsafeMutablePointer<Float>, destSlot: Int) {
let rowStride = src.strides.count >= 2 ? src.strides[src.strides.count - 2].intValue : hidden
let lastStride = src.strides.last?.intValue ?? 1
let base = sourceRow * rowStride
let dstBase = destSlot * hidden
switch src.dataType {
case .float16:
let p = src.dataPointer.assumingMemoryBound(to: Float16.self)
for j in 0..<hidden { dst[dstBase + j] = Float(p[base + j * lastStride]) }
case .float32:
let p = src.dataPointer.assumingMemoryBound(to: Float.self)
for j in 0..<hidden { dst[dstBase + j] = p[base + j * lastStride] }
default:
let p = src.dataPointer.assumingMemoryBound(to: Float.self)
for j in 0..<hidden { dst[dstBase + j] = p[base + j * lastStride] }
}
}
/// Run a chunked multi-token prefill, source from MLMultiArray rows.
///
/// ``embeddings`` is shape ``[1, N, hidden]`` containing the real
/// next-N tokens to commit to positions ``[currentPosition,
/// currentPosition + N)``. ``N`` must be ``<= batchSize``. The
/// returned MLMultiArray is the logits for the last real position.
@discardableResult
public func decoderPrefill(embeddings: MLMultiArray, realCount n: Int) throws -> MLMultiArray {
precondition(n > 0 && n <= batchSize,
"realCount \(n) must be in 1...\(batchSize)")
let bufs = try writeChunk(realEmbeddingsSource: { (slot, dstPtr) in
let srcPtr = embeddings.dataPointer.assumingMemoryBound(to: Float.self)
for t in 0..<n {
let srcOff = t * self.hiddenSize
let dstOff = (slot + t) * self.hiddenSize
for j in 0..<self.hiddenSize {
dstPtr[dstOff + j] = srcPtr[srcOff + j]
}
}
}, realCount: n)
return try runParts(embeds: bufs.embeds, positions: bufs.positions, mask: bufs.mask)
}
/// Embed a contiguous run of token ids and prefill them in batched
/// chunks of ``batchSize``. Used for the chat-template prefix/suffix
/// runs, replacing per-token ``embed`` + ``decoderStep`` loops with
/// one ANE dispatch per chunk. Returns the logits for the last token.
@discardableResult
public func decoderPrefillTokens(_ tokenIds: [Int32]) throws -> MLMultiArray {
precondition(!tokenIds.isEmpty, "decoderPrefillTokens requires at least one token")
var lastLogits: MLMultiArray!
var consumed = 0
while consumed < tokenIds.count {
let n = min(batchSize, tokenIds.count - consumed)
// Pack n token embeddings into a [1, n, hidden] Float32 buffer.
let packed = try MLMultiArray(shape: [1, n as NSNumber, hiddenSize as NSNumber],
dataType: .float32)
let pptr = packed.dataPointer.assumingMemoryBound(to: Float.self)
for k in 0..<n {
let emb = try embed(tokenId: tokenIds[consumed + k])
Self.copyRow(from: emb, sourceRow: 0, hidden: hiddenSize,
to: pptr, destSlot: k)
}
lastLogits = try decoderPrefill(embeddings: packed, realCount: n)
consumed += n
}
return lastLogits
}
/// Run a chunked prefill where embeddings come from a Float buffer
/// (typical: bulk-extracted MLX audio embeddings).
@discardableResult
public func decoderPrefill(flatEmbeddings: [Float], offset: Int, realCount n: Int) throws -> MLMultiArray {
precondition(n > 0 && n <= batchSize,
"realCount \(n) must be in 1...\(batchSize)")
let bufs = try writeChunk(realEmbeddingsSource: { (slot, dstPtr) in
flatEmbeddings.withUnsafeBufferPointer { buf in
let src = buf.baseAddress!
for t in 0..<n {
let srcOff = (offset + t) * self.hiddenSize
let dstOff = (slot + t) * self.hiddenSize
for j in 0..<self.hiddenSize {
dstPtr[dstOff + j] = src[srcOff + j]
}
}
}
}, realCount: n)
return try runParts(embeds: bufs.embeds, positions: bufs.positions, mask: bufs.mask)
}
// MARK: - Internal: chunked dispatch primitives
/// Set up the input buffers (positions, mask, embeds) for a chunk of
/// ``realCount`` real tokens placed in the LAST ``realCount`` input
/// slots, with the remaining slots filled by scratch positions. The
/// ``realEmbeddingsSource`` callback is given the slot where real
/// data should land and a pointer into the embeds buffer.
///
/// Allocates FRESH MLMultiArrays each call. Reusing buffers across
/// calls was tried and produces wrong outputs CoreML appears to
/// hold references into the input buffer past prediction return,
/// so mutating it before the next call corrupts the stored KV state.
/// Per-call allocation costs ~0.5 ms (mask is 64 KB) which is well
/// below the dispatch budget.
private func writeChunk(
realEmbeddingsSource: (Int, UnsafeMutablePointer<Float>) -> Void,
realCount n: Int
) throws -> (embeds: MLMultiArray, positions: MLMultiArray, mask: MLMultiArray) {
precondition(currentPosition + n <= scratchStart,
"Cache overflow: would write real position \(currentPosition + n - 1) into scratch range starting at \(scratchStart)")
let T = batchSize
let firstRealSlot = T - n
let embeds = try MLMultiArray(shape: [1, T as NSNumber, hiddenSize as NSNumber],
dataType: .float32)
let positions = try MLMultiArray(shape: [T as NSNumber], dataType: .int32)
let mask = try MLMultiArray(shape: [1, 1, T as NSNumber, maxSeqLength as NSNumber],
dataType: .float32)
// Positions: scratch slots fill 0..firstRealSlot-1, real fills firstRealSlot..T-1
for i in 0..<firstRealSlot {
positions[i] = NSNumber(value: Int32(scratchStart + i))
}
for i in 0..<n {
positions[firstRealSlot + i] = NSNumber(value: Int32(currentPosition + i))
}
// Mask: shape [1, 1, T, MAX_SEQ]
let mptr = mask.dataPointer.assumingMemoryBound(to: Float.self)
let rowSize = maxSeqLength
let scratchStartLocal = scratchStart
for t in 0..<T {
let rowBase = t * rowSize
if t < firstRealSlot {
for j in 0..<rowSize {
mptr[rowBase + j] = -1e4
}
} else {
let realIdxInChunk = t - firstRealSlot
let absPosition = currentPosition + realIdxInChunk
for j in 0..<rowSize {
if j <= absPosition && j < scratchStartLocal {
mptr[rowBase + j] = 0
} else {
mptr[rowBase + j] = -1e4
}
}
}
}
// Embeddings: zero the scratch rows + populate the real rows.
let eptr = embeds.dataPointer.assumingMemoryBound(to: Float.self)
for s in 0..<firstRealSlot {
for j in 0..<hiddenSize { eptr[s * hiddenSize + j] = 0 }
}
realEmbeddingsSource(firstRealSlot, eptr)
currentPosition += n
return (embeds, positions, mask)
}
/// Dispatch part1 then part2 over the given input buffers. Returns
/// the part2 logits MLMultiArray (shape ``[1, 1, vocab]`` the last
/// real position's next-token distribution).
private func runParts(embeds: MLMultiArray, positions: MLMultiArray, mask: MLMultiArray) throws -> MLMultiArray {
let p1Input = try MLDictionaryFeatureProvider(dictionary: [
"input_embeds": MLFeatureValue(multiArray: embeds),
"positions": MLFeatureValue(multiArray: positions),
"attention_mask": MLFeatureValue(multiArray: mask),
])
let p1Out = try decoderPart1Model.prediction(from: p1Input, using: part1State)
guard let hidden = p1Out.featureValue(for: "hidden_state")?.multiArrayValue else {
throw AudioModelError.inferenceFailed(
operation: "CoreML decoder part1",
reason: "Missing hidden_state output")
}
let p2Input = try MLDictionaryFeatureProvider(dictionary: [
"input_embeds": MLFeatureValue(multiArray: hidden),
"positions": MLFeatureValue(multiArray: positions),
"attention_mask": MLFeatureValue(multiArray: mask),
])
let p2Out = try decoderPart2Model.prediction(from: p2Input, using: part2State)
guard let logits = p2Out.featureValue(for: "logits")?.multiArrayValue else {
throw AudioModelError.inferenceFailed(
operation: "CoreML decoder part2",
reason: "Missing logits output")
}
return logits
}
/// Expose the fixed batch size so callers can chunk audio prefill.
public var prefillBatchSize: Int { batchSize }
/// Get argmax token ID, optionally excluding a single token.
///
/// When ``skipToken`` is non-nil, the slot at that index is ignored
/// used by the ASR generation loop to suppress premature ``<|im_end|>``
/// (see `CoreMLASRModel.transcribe`). When it's nil this is the plain
/// argmax over the full vocab.
public func argmax(logits: MLMultiArray, skipping skipToken: Int32? = nil) -> Int32 {
return argmaxImpl(logits: logits, skipIdx: skipToken.map { Int($0) })
}
/// Read a single logit by token index. Stride-aware (matches `argmax`).
public func logit(_ logits: MLMultiArray, at index: Int32) -> Float {
let lastStride = logits.strides.last?.intValue ?? 1
let i = Int(index) * lastStride
switch logits.dataType {
case .float16:
let ptr = logits.dataPointer.assumingMemoryBound(to: Float16.self)
return Float(ptr[i])
case .float32:
let ptr = logits.dataPointer.assumingMemoryBound(to: Float.self)
return ptr[i]
default:
let ptr = logits.dataPointer.assumingMemoryBound(to: Float.self)
return ptr[i]
}
}
/// Get argmax token ID from logits.
///
/// Stride-aware: walks ``vocabSize`` (the logical last-dim length)
/// using ``strides.last`` as the step, correct for CoreML outputs
/// that may be strided (e.g. ANE padding). NaN-safe NaN values
/// are skipped, so one bad logit can't poison the argmax (the
/// previous flat ``ptr[i]`` loop with ``maxVal = -Float.infinity``
/// would silently keep ``maxIdx = 0`` since IEEE-754 ``NaN > x``
/// is always false).
private func argmaxImpl(logits: MLMultiArray, skipIdx: Int?) -> Int32 {
let vocab = logits.shape.last?.intValue ?? logits.count
let lastStride = logits.strides.last?.intValue ?? 1
var maxVal: Float = -Float.infinity
var maxIdx: Int32 = 0
var nanCount: Int = 0
switch logits.dataType {
case .float16:
let ptr = logits.dataPointer.assumingMemoryBound(to: Float16.self)
for i in 0..<vocab where i != skipIdx {
let val = Float(ptr[i * lastStride])
if val.isNaN { nanCount += 1; continue }
if val > maxVal {
maxVal = val
maxIdx = Int32(i)
}
}
case .float32:
let ptr = logits.dataPointer.assumingMemoryBound(to: Float.self)
for i in 0..<vocab where i != skipIdx {
let val = ptr[i * lastStride]
if val.isNaN { nanCount += 1; continue }
if val > maxVal {
maxVal = val
maxIdx = Int32(i)
}
}
default:
let ptr = logits.dataPointer.assumingMemoryBound(to: Float.self)
for i in 0..<vocab where i != skipIdx {
let val = ptr[i * lastStride]
if val.isNaN { nanCount += 1; continue }
if val > maxVal {
maxVal = val
maxIdx = Int32(i)
}
}
}
return maxIdx
}
// MARK: - Audio Embedding Injection
/// Convert MLXArray audio embeddings to MLMultiArray for decoder input.
public func audioEmbeddingToMultiArray(_ embedding: MLXArray, at index: Int) throws -> MLMultiArray {
let hidden = embedding.dim(2)
let result = try MLMultiArray(shape: [1, 1, hidden as NSNumber], dataType: .float32)
let ptr = result.dataPointer.assumingMemoryBound(to: Float.self)
let slice = embedding[0..., index..<(index + 1), 0...]
let data: [Float] = slice.asArray(Float.self)
for i in 0..<hidden {
ptr[i] = data[i]
}
return result
}
/// Extract audio embedding at index from MLMultiArray (no MLX dependency).
public func audioEmbeddingFromMultiArray(_ embeddings: MLMultiArray, at index: Int) throws -> MLMultiArray {
let hidden = embeddings.shape[2].intValue
let result = try MLMultiArray(shape: [1, 1, hidden as NSNumber], dataType: .float32)
let srcPtr = embeddings.dataPointer.assumingMemoryBound(to: Float.self)
let dstPtr = result.dataPointer.assumingMemoryBound(to: Float.self)
let offset = index * hidden
for i in 0..<hidden {
dstPtr[i] = srcPtr[offset + i]
}
return result
}
// MARK: - Helpers
private static func findModel(named name: String, in directory: URL) -> URL? {
let compiled = directory.appendingPathComponent("\(name).mlmodelc", isDirectory: true)
if FileManager.default.fileExists(atPath: compiled.path) {
return compiled
}
return nil
}
}
#endif
@@ -0,0 +1,3 @@
// Re-export AudioCommon so host apps linking Qwen3ASR can use
// `ModelRegistry` and other download helpers without a separate product.
@_exported import AudioCommon
@@ -0,0 +1,240 @@
import Foundation
import MLX
import MLXNN
import MLXFast
import MLXCommon
import AudioCommon
/// Protocol abstracting the text decoder for the ForcedAligner,
/// allowing both quantized and float (bf16) implementations.
public protocol ForcedAlignerTextDecoding: AnyObject {
func embeddings(for inputIds: MLXArray) -> MLXArray
func decode(
inputsEmbeds: MLXArray,
attentionMask: MLXArray?,
cache: [(MLXArray, MLXArray)]?
) -> (MLXArray, [(MLXArray, MLXArray)])
}
extension QuantizedTextModel: ForcedAlignerTextDecoding {
public func embeddings(for inputIds: MLXArray) -> MLXArray {
embedTokens(inputIds)
}
public func decode(
inputsEmbeds: MLXArray,
attentionMask: MLXArray?,
cache: [(MLXArray, MLXArray)]?
) -> (MLXArray, [(MLXArray, MLXArray)]) {
self(inputIds: nil, inputsEmbeds: inputsEmbeds, attentionMask: attentionMask, cache: cache)
}
}
// MARK: - Float (non-quantized) text decoder
public class FloatTextAttention: Module {
let numHeads: Int
let numKVHeads: Int
let headDim: Int
let scale: Float
@ModuleInfo var qProj: Linear
@ModuleInfo var kProj: Linear
@ModuleInfo var vProj: Linear
@ModuleInfo var oProj: Linear
@ModuleInfo var qNorm: RMSNorm
@ModuleInfo var kNorm: RMSNorm
let rope: MLXNN.RoPE
public init(config: TextDecoderConfig) {
self.numHeads = config.numHeads
self.numKVHeads = config.numKVHeads
self.headDim = config.headDim
self.scale = 1.0 / sqrt(Float(headDim))
let hiddenSize = config.hiddenSize
self._qProj.wrappedValue = Linear(hiddenSize, numHeads * headDim, bias: false)
self._kProj.wrappedValue = Linear(hiddenSize, numKVHeads * headDim, bias: false)
self._vProj.wrappedValue = Linear(hiddenSize, numKVHeads * headDim, bias: false)
self._oProj.wrappedValue = Linear(numHeads * headDim, hiddenSize, bias: false)
self._qNorm.wrappedValue = RMSNorm(dimensions: headDim, eps: config.rmsNormEps)
self._kNorm.wrappedValue = RMSNorm(dimensions: headDim, eps: config.rmsNormEps)
self.rope = MLXNN.RoPE(dimensions: headDim, traditional: false, base: config.ropeTheta)
super.init()
}
public func callAsFunction(
_ hiddenStates: MLXArray,
attentionMask: MLXArray? = nil,
cache: (MLXArray, MLXArray)? = nil
) -> (MLXArray, (MLXArray, MLXArray)) {
let (batch, seqLen, _) = (hiddenStates.dim(0), hiddenStates.dim(1), hiddenStates.dim(2))
var queries = qProj(hiddenStates)
var keys = kProj(hiddenStates)
var values = vProj(hiddenStates)
queries = queries.reshaped(batch, seqLen, numHeads, headDim)
keys = keys.reshaped(batch, seqLen, numKVHeads, headDim)
values = values.reshaped(batch, seqLen, numKVHeads, headDim)
queries = qNorm(queries)
keys = kNorm(keys)
queries = queries.transposed(0, 2, 1, 3)
keys = keys.transposed(0, 2, 1, 3)
values = values.transposed(0, 2, 1, 3)
let offset = cache?.0.dim(2) ?? 0
queries = rope(queries, offset: offset)
keys = rope(keys, offset: offset)
var cachedKeys = keys
var cachedValues = values
if let (prevKeys, prevValues) = cache {
cachedKeys = concatenated([prevKeys, keys], axis: 2)
cachedValues = concatenated([prevValues, values], axis: 2)
}
let merged = SDPA.attendAndMerge(
qHeads: queries, kHeads: cachedKeys, vHeads: cachedValues,
scale: scale, mask: attentionMask)
let output = oProj(merged)
return (output, (cachedKeys, cachedValues))
}
}
public class FloatTextMLP: Module {
@ModuleInfo var gateProj: Linear
@ModuleInfo var upProj: Linear
@ModuleInfo var downProj: Linear
public init(config: TextDecoderConfig) {
let hiddenSize = config.hiddenSize
let intermediateSize = config.intermediateSize
self._gateProj.wrappedValue = Linear(hiddenSize, intermediateSize, bias: false)
self._upProj.wrappedValue = Linear(hiddenSize, intermediateSize, bias: false)
self._downProj.wrappedValue = Linear(intermediateSize, hiddenSize, bias: false)
super.init()
}
public func callAsFunction(_ x: MLXArray) -> MLXArray {
let gate = silu(gateProj(x))
let up = upProj(x)
return downProj(gate * up)
}
}
public class FloatTextDecoderLayer: Module {
@ModuleInfo var selfAttn: FloatTextAttention
@ModuleInfo var mlp: FloatTextMLP
@ModuleInfo var inputLayerNorm: RMSNorm
@ModuleInfo var postAttentionLayerNorm: RMSNorm
public init(config: TextDecoderConfig) {
self._selfAttn.wrappedValue = FloatTextAttention(config: config)
self._mlp.wrappedValue = FloatTextMLP(config: config)
self._inputLayerNorm.wrappedValue = RMSNorm(dimensions: config.hiddenSize, eps: config.rmsNormEps)
self._postAttentionLayerNorm.wrappedValue = RMSNorm(dimensions: config.hiddenSize, eps: config.rmsNormEps)
super.init()
}
public func callAsFunction(
_ hiddenStates: MLXArray,
attentionMask: MLXArray? = nil,
cache: (MLXArray, MLXArray)? = nil
) -> (MLXArray, (MLXArray, MLXArray)) {
let residual = hiddenStates
var hidden = inputLayerNorm(hiddenStates)
let (attnOutput, newCache) = selfAttn(hidden, attentionMask: attentionMask, cache: cache)
hidden = residual + attnOutput
let residual2 = hidden
hidden = postAttentionLayerNorm(hidden)
hidden = mlp(hidden)
hidden = residual2 + hidden
return (hidden, newCache)
}
}
public class FloatTextModel: Module {
public let config: TextDecoderConfig
@ModuleInfo public var embedTokens: Embedding
@ModuleInfo var layers: [FloatTextDecoderLayer]
@ModuleInfo var norm: RMSNorm
public init(config: TextDecoderConfig) {
self.config = config
self._embedTokens.wrappedValue = Embedding(embeddingCount: config.vocabSize, dimensions: config.hiddenSize)
self._layers.wrappedValue = (0..<config.numLayers).map { _ in FloatTextDecoderLayer(config: config) }
self._norm.wrappedValue = RMSNorm(dimensions: config.hiddenSize, eps: config.rmsNormEps)
super.init()
}
public func callAsFunction(
inputIds: MLXArray? = nil,
inputsEmbeds: MLXArray? = nil,
attentionMask: MLXArray? = nil,
cache: [(MLXArray, MLXArray)]? = nil
) -> (MLXArray, [(MLXArray, MLXArray)]) {
var hiddenStates: MLXArray
if let embeds = inputsEmbeds {
hiddenStates = embeds
} else if let ids = inputIds {
hiddenStates = embedTokens(ids)
} else {
fatalError("Either inputIds or inputsEmbeds must be provided")
}
let seqLen = hiddenStates.dim(1)
let mask: MLXArray?
if let providedMask = attentionMask {
mask = providedMask
} else if seqLen == 1 {
mask = nil
} else {
let cacheLen = cache?.first?.0.dim(2) ?? 0
let totalLen = seqLen + cacheLen
let rows = (MLXArray(0..<Int32(seqLen)) + Int32(cacheLen)).expandedDimensions(axis: 1)
let cols = MLXArray(0..<Int32(totalLen)).expandedDimensions(axis: 0)
mask = MLX.where(cols .> rows, MLXArray(Float(-1e9)), MLXArray(Float(0)))
.expandedDimensions(axes: [0, 1])
.asType(hiddenStates.dtype)
}
var newCache: [(MLXArray, MLXArray)] = []
for (i, layer) in layers.enumerated() {
let layerCache = cache?[i]
let (output, updatedCache) = layer(hiddenStates, attentionMask: mask, cache: layerCache)
hiddenStates = output
newCache.append(updatedCache)
}
hiddenStates = norm(hiddenStates)
return (hiddenStates, newCache)
}
}
extension FloatTextModel: ForcedAlignerTextDecoding {
public func embeddings(for inputIds: MLXArray) -> MLXArray {
embedTokens(inputIds)
}
public func decode(
inputsEmbeds: MLXArray,
attentionMask: MLXArray?,
cache: [(MLXArray, MLXArray)]?
) -> (MLXArray, [(MLXArray, MLXArray)]) {
self(inputIds: nil, inputsEmbeds: inputsEmbeds, attentionMask: attentionMask, cache: cache)
}
}
@@ -0,0 +1,9 @@
import AudioCommon
// MARK: - ForcedAlignmentModel
extension Qwen3ForcedAligner: ForcedAlignmentModel {
public func align(audio: [Float], text: String, sampleRate: Int, language: String?) -> [AlignedWord] {
align(audio: audio, text: text, sampleRate: sampleRate, language: language ?? "English")
}
}
@@ -0,0 +1,482 @@
import Foundation
import MLXCommon
import MLX
import MLXNN
import MLXFast
import AudioCommon
// AlignedWord is defined in AudioCommon/Protocols.swift and re-exported via AudioCommon import above.
/// Forced aligner model variant
public enum ForcedAlignerVariant: String, CaseIterable, Sendable {
case mlx4bit = "aufklarer/Qwen3-ForcedAligner-0.6B-4bit"
case mlx8bit = "aufklarer/Qwen3-ForcedAligner-0.6B-8bit"
case bf16 = "aufklarer/Qwen3-ForcedAligner-0.6B-bf16"
/// Detect variant from model ID string
public static func detect(from modelId: String) -> ForcedAlignerVariant? {
if let exact = Self.allCases.first(where: { $0.rawValue == modelId }) {
return exact
}
if modelId.contains("bf16") || modelId.contains("float") { return .bf16 }
if modelId.contains("8bit") { return .mlx8bit }
if modelId.contains("4bit") { return .mlx4bit }
return nil
}
public var textConfig: TextDecoderConfig {
switch self {
case .mlx4bit:
var cfg = TextDecoderConfig.small
cfg.bits = 4
cfg.groupSize = 64
return cfg
case .mlx8bit:
var cfg = TextDecoderConfig.small
cfg.bits = 8
cfg.groupSize = 64
return cfg
case .bf16:
return .small
}
}
public var usesFloatTextDecoder: Bool {
self == .bf16
}
}
/// Qwen3 Forced Aligner predicts word-level timestamps for audio+text pairs.
///
/// Uses the same encoder-decoder architecture as Qwen3-ASR but replaces the
/// vocab lm_head with a 5000-class timestamp classification head.
/// Inference is non-autoregressive (single forward pass).
public class Qwen3ForcedAligner {
public let audioEncoder: Qwen3AudioEncoder
public let textDecoder: any ForcedAlignerTextDecoding
public let classifyHead: Linear
public let featureExtractor: WhisperFeatureExtractor
public var tokenizer: Qwen3Tokenizer?
private let config: Qwen3ASRConfig
public init(
audioConfig: Qwen3AudioEncoderConfig = .forcedAligner,
textConfig: TextDecoderConfig = .small,
classifyNum: Int = 5000,
useFloatTextDecoder: Bool = false
) {
self.audioEncoder = Qwen3AudioEncoder(config: audioConfig)
if useFloatTextDecoder {
self.textDecoder = FloatTextModel(config: textConfig)
} else {
self.textDecoder = QuantizedTextModel(config: textConfig)
}
self.classifyHead = Linear(textConfig.hiddenSize, classifyNum)
self.featureExtractor = WhisperFeatureExtractor()
var cfg = Qwen3ASRConfig()
cfg.classifyNum = classifyNum
self.config = cfg
}
/// Align text to audio with automatic chunking for long inputs.
///
/// The underlying classifier head emits a fixed-resolution timestamp
/// index (default `classifyNum=5000` × `0.08s` per slot = 400s
/// addressable range) but in practice the model's reliable range is
/// shorter (~270s on Qwen3-ForcedAligner-0.6B-4bit observed on TED-Ed
/// material). Past that, it produces low/non-monotonic indices that
/// LIS correction collapses into a flat plateau every trailing word
/// shares the same timestamp.
///
/// `alignLong` runs `align` on the full audio, detects the trailing
/// plateau, keeps the reliable prefix, then re-aligns the remaining
/// audio + remaining words and offsets timestamps. Iterates until no
/// plateau remains or the remaining work is below a minimum chunk size.
///
/// For audio shorter than the threshold this is a one-pass call into
/// `align`. For longer audio it pays one extra align pass per chunk.
public func alignLong(
audio: [Float],
text: String,
sampleRate: Int = 16000,
language: String = "English",
progressHandler: ((String) -> Void)? = nil
) -> [AlignedWord] {
// The model is reliable up to ~270s on the bundles we ship; we
// don't try to be too aggressive with the threshold so the
// single-pass case stays the common path. The plateau detector
// does the actual work this is just a fast bypass when there's
// no risk of saturation.
let bypassThresholdSeconds: Float = 240
let minChunkSeconds: Float = 5
let plateauTolerance: Float = 0.1 // seconds; "same start time" if diff < this
let plateauMinWords = 5 // need N stuck words to call it a plateau
var allAligned: [AlignedWord] = []
var remainingAudio = audio
var remainingText = text
var offsetSec: Float = 0
var pass = 1
while !remainingAudio.isEmpty && !remainingText.isEmpty {
let durationSec = Float(remainingAudio.count) / Float(sampleRate)
let aligned = align(
audio: remainingAudio,
text: remainingText,
sampleRate: sampleRate,
language: language
)
if aligned.isEmpty { break }
// Skip plateau detection on small chunks the model is reliable
// there, and detecting plateau on tiny outputs creates spurious
// splits.
if durationSec <= bypassThresholdSeconds || aligned.count < plateauMinWords * 2 {
allAligned.append(contentsOf: Self.offsetWords(aligned, by: offsetSec))
break
}
let plateauStart = Self.findTrailingPlateauStart(
aligned, tolerance: plateauTolerance, minSize: plateauMinWords
)
if plateauStart == aligned.count {
// No plateau alignment looks healthy.
allAligned.append(contentsOf: Self.offsetWords(aligned, by: offsetSec))
break
}
// Take the reliable prefix; recurse on the remainder.
let reliable = aligned.prefix(plateauStart)
let splitTime = reliable.last!.endTime
allAligned.append(contentsOf: Self.offsetWords(Array(reliable), by: offsetSec))
let splitSample = Int(splitTime * Float(sampleRate))
guard splitSample < remainingAudio.count else { break }
let nextAudio = Array(remainingAudio[splitSample...])
let remainingDuration = Float(nextAudio.count) / Float(sampleRate)
if remainingDuration < minChunkSeconds { break }
// Pull the words from the remainder by name. We split on the
// same boundary `align` used (whitespace) so words line up.
let wordsAll = remainingText.split(separator: " ", omittingEmptySubsequences: true)
guard plateauStart < wordsAll.count else { break }
let nextText = wordsAll[plateauStart...].joined(separator: " ")
progressHandler?(
"Audio \(String(format: "%.1f", durationSec))s saturated after word \(plateauStart) "
+ "(\(String(format: "%.1f", splitTime))s); chunking remaining \(String(format: "%.1f", remainingDuration))s "
+ "(pass \(pass + 1))"
)
remainingAudio = nextAudio
remainingText = nextText
offsetSec += splitTime
pass += 1
if pass > 10 { break } // belt-and-braces against pathological loops
}
return allAligned
}
static func offsetWords(_ words: [AlignedWord], by seconds: Float) -> [AlignedWord] {
guard seconds != 0 else { return words }
return words.map {
AlignedWord(text: $0.text, startTime: $0.startTime + seconds, endTime: $0.endTime + seconds)
}
}
/// Index of the first word in the trailing "stuck" plateau, or
/// `aligned.count` if no plateau is detected.
///
/// A plateau is ` minSize` consecutive trailing words whose start
/// times differ by less than `tolerance`. This is the LIS-clamp
/// signature: the model produced low/garbage indices for those
/// positions and the monotonicity pass collapsed them onto the last
/// reliable anchor.
static func findTrailingPlateauStart(
_ aligned: [AlignedWord], tolerance: Float, minSize: Int
) -> Int {
let n = aligned.count
guard n > minSize else { return n }
// Walk backward: when `aligned[i].startTime aligned[i-1].startTime`,
// both are in the plateau, so the plateau extends *to* index `i-1`.
// Stop at the first big jump.
var plateauStart = n
for i in (1..<n).reversed() {
let dt = abs(aligned[i].startTime - aligned[i - 1].startTime)
if dt < tolerance {
plateauStart = i - 1
} else {
break
}
}
return (n - plateauStart) >= minSize ? plateauStart : n
}
/// Align text to audio, producing word-level timestamps.
///
/// - Parameters:
/// - audio: Raw audio samples (mono)
/// - text: Text to align against the audio
/// - sampleRate: Sample rate of the audio (default 16000)
/// - language: Language hint for word splitting (default "English")
/// - Returns: Array of words with start/end timestamps in seconds
public func align(
audio: [Float],
text: String,
sampleRate: Int = 16000,
language: String = "English"
) -> [AlignedWord] {
guard let tokenizer = tokenizer else {
print("Error: tokenizer not loaded")
return []
}
// 1. Extract mel features audio encoder audio embeddings
let melFeatures = featureExtractor.process(audio, sampleRate: sampleRate)
let batchedFeatures = melFeatures.expandedDimensions(axis: 0)
var audioEmbeds = audioEncoder(batchedFeatures)
audioEmbeds = audioEmbeds.expandedDimensions(axis: 0) // [1, T_audio, hiddenSize]
let numAudioTokens = audioEmbeds.dim(1)
// 2. Prepare text with timestamp slots
let slotted = TextPreprocessor.prepareForAlignment(
text: text,
tokenizer: tokenizer,
language: language
)
guard !slotted.words.isEmpty else {
print("Warning: no words found in text")
return []
}
// 3. Build input_ids with chat template
let inputIds = buildInputIds(
slottedTokenIds: slotted.tokenIds,
numAudioTokens: numAudioTokens,
tokenizer: tokenizer,
language: language
)
// Track where the slotted text starts in the full sequence
let slottedTextStart = inputIds.count - slotted.tokenIds.count
// 4. Embed all tokens and replace audio_pad with audio embeddings
let inputIdsTensor = MLXArray(inputIds.map { Int32($0) }).expandedDimensions(axis: 0)
var inputEmbeds = textDecoder.embeddings(for: inputIdsTensor)
// Find audio_pad range and replace with audio embeddings
let audioStartIndex = findAudioPadStart(inputIds)
let audioEndIndex = audioStartIndex + numAudioTokens
let audioEmbedsTyped = audioEmbeds.asType(inputEmbeds.dtype)
let beforeAudio = inputEmbeds[0..., 0..<audioStartIndex, 0...]
let afterAudio = inputEmbeds[0..., audioEndIndex..., 0...]
inputEmbeds = concatenated([beforeAudio, audioEmbedsTyped, afterAudio], axis: 1)
// 5. Single forward pass through decoder (no cache, no autoregressive loop)
let (hiddenStates, _) = textDecoder.decode(inputsEmbeds: inputEmbeds, attentionMask: nil, cache: nil)
// 6. Apply classify head to ALL hidden states logits [1, seqLen, classifyNum]
let logits = classifyHead(hiddenStates)
// 7. Extract logits at timestamp positions argmax raw indices
// Adjust timestamp positions to account for the chat template prefix
let absoluteTimestampPositions = slotted.timestampPositions.map { $0 + slottedTextStart }
var rawIndices: [Int] = []
for pos in absoluteTimestampPositions {
let posLogits = logits[0, pos, 0...] // [classifyNum]
let idx = argMax(posLogits).item(Int32.self)
rawIndices.append(Int(idx))
}
// 8. Apply LIS monotonicity correction
let correctedIndices = TimestampCorrection.enforceMonotonicity(rawIndices)
// Optional raw/corrected dump for bug-triage of misaligned timestamps.
if ProcessInfo.processInfo.environment["ALIGN_DEBUG"] == "1" {
print("[align-debug] indices=\(rawIndices.count) numAudioTokens=\(numAudioTokens)")
print("[align-debug] first 10 raw: \(Array(rawIndices.prefix(10)))")
print("[align-debug] first 10 corrected: \(Array(correctedIndices.prefix(10)))")
print("[align-debug] last 60 raw: \(Array(rawIndices.suffix(60)))")
print("[align-debug] last 60 corrected: \(Array(correctedIndices.suffix(60)))")
}
// 9. Convert to seconds and pair as (start, end)
let segmentTime = config.timestampSegmentTime
var alignedWords: [AlignedWord] = []
for (wordIdx, word) in slotted.words.enumerated() {
let startIdx = wordIdx * 2 // even indices are start timestamps
let endIdx = wordIdx * 2 + 1 // odd indices are end timestamps
guard endIdx < correctedIndices.count else { break }
let startTime = Float(correctedIndices[startIdx]) * segmentTime
let endTime = Float(correctedIndices[endIdx]) * segmentTime
alignedWords.append(AlignedWord(
text: word,
startTime: startTime,
endTime: max(endTime, startTime) // ensure end >= start
))
}
return alignedWords
}
// MARK: - Private Helpers
/// Build full input_ids sequence with chat template
private func buildInputIds(
slottedTokenIds: [Int],
numAudioTokens: Int,
tokenizer: Qwen3Tokenizer,
language: String
) -> [Int] {
let imStartId = Qwen3ASRTokens.imStartTokenId
let imEndId = Qwen3ASRTokens.imEndTokenId
let audioStartId = Qwen3ASRTokens.audioStartTokenId
let audioEndId = Qwen3ASRTokens.audioEndTokenId
let audioPadId = Qwen3ASRTokens.audioTokenId
let newlineId = 198
// Token IDs for role names
let systemId = 8948
let userId = 872
let assistantId = 77091
var ids: [Int] = []
// <|im_start|>system\n<|im_end|>\n
ids.append(contentsOf: [imStartId, systemId, newlineId, imEndId, newlineId])
// <|im_start|>user\n<|audio_start|>
ids.append(contentsOf: [imStartId, userId, newlineId, audioStartId])
// <|audio_pad|> * numAudioTokens
for _ in 0..<numAudioTokens {
ids.append(audioPadId)
}
// <|audio_end|><|im_end|>\n
ids.append(contentsOf: [audioEndId, imEndId, newlineId])
// <|im_start|>assistant\n
ids.append(contentsOf: [imStartId, assistantId, newlineId])
// Slotted text with timestamp tokens
ids.append(contentsOf: slottedTokenIds)
return ids
}
/// Find the start index of audio_pad tokens in input_ids
private func findAudioPadStart(_ inputIds: [Int]) -> Int {
let audioPadId = Qwen3ASRTokens.audioTokenId
for (i, id) in inputIds.enumerated() {
if id == audioPadId { return i }
}
return 0
}
}
// MARK: - Model Loading
public extension Qwen3ForcedAligner {
/// Load forced aligner model from HuggingFace hub
static func fromPretrained(
modelId: String = "aufklarer/Qwen3-ForcedAligner-0.6B-4bit",
cacheDir: URL? = nil,
offlineMode: Bool = false,
progressHandler: ((Double, String) -> Void)? = nil
) async throws -> Qwen3ForcedAligner {
progressHandler?(0.0, "Downloading model...")
let cacheDir = try cacheDir ?? HuggingFaceDownloader.getCacheDirectory(for: modelId)
// Download weights and tokenizer files
try await HuggingFaceDownloader.downloadWeights(
modelId: modelId,
to: cacheDir,
additionalFiles: ["vocab.json", "merges.txt", "tokenizer_config.json",
"quantize_config.json"],
offlineMode: offlineMode,
progressHandler: { progress in
progressHandler?(progress * 0.8, "Downloading weights...")
}
)
progressHandler?(0.80, "Loading tokenizer...")
// Detect variant: try quantize_config.json first, fall back to model ID
let variant: ForcedAlignerVariant
let packaging = detectPackaging(in: cacheDir)
if let detected = packaging {
variant = detected
} else if let detected = ForcedAlignerVariant.detect(from: modelId) {
variant = detected
} else {
variant = .mlx4bit
}
let model = Qwen3ForcedAligner(
audioConfig: .forcedAligner,
textConfig: variant.textConfig,
useFloatTextDecoder: variant.usesFloatTextDecoder
)
// Load tokenizer
let vocabPath = cacheDir.appendingPathComponent("vocab.json")
if FileManager.default.fileExists(atPath: vocabPath.path) {
let tokenizer = Qwen3Tokenizer()
try tokenizer.load(from: vocabPath)
model.tokenizer = tokenizer
}
progressHandler?(0.85, "Loading audio encoder weights...")
// Load weights
try WeightLoader.loadForcedAlignerWeights(into: model, from: cacheDir)
progressHandler?(1.0, "Ready")
return model
}
/// Detect model variant from quantize_config.json
private static func detectPackaging(in cacheDir: URL) -> ForcedAlignerVariant? {
struct QuantizationFile: Decodable {
struct Quantization: Decodable {
let bits: Int?
let groupSize: Int?
enum CodingKeys: String, CodingKey {
case bits
case groupSize = "group_size"
}
}
let quantization: Quantization?
}
let configPath = cacheDir.appendingPathComponent("quantize_config.json")
guard let data = try? Data(contentsOf: configPath),
let file = try? JSONDecoder().decode(QuantizationFile.self, from: data),
let quant = file.quantization,
let bits = quant.bits else {
return nil
}
switch bits {
case 0: return .bf16
case 4: return .mlx4bit
case 8: return .mlx8bit
default: return nil
}
}
}
@@ -0,0 +1,252 @@
import Foundation
import MLXCommon
import MLX
import MLXNN
import MLXFast
import AudioCommon
/// Multi-head attention for Qwen3 text decoder with GQA and RoPE (quantized version)
public class QuantizedTextAttention: Module {
let numHeads: Int
let numKVHeads: Int
let headDim: Int
let scale: Float
@ModuleInfo var qProj: QuantizedLinear
@ModuleInfo var kProj: QuantizedLinear
@ModuleInfo var vProj: QuantizedLinear
@ModuleInfo var oProj: QuantizedLinear
@ModuleInfo var qNorm: RMSNorm
@ModuleInfo var kNorm: RMSNorm
let rope: MLXNN.RoPE
public init(config: TextDecoderConfig) {
self.numHeads = config.numHeads
self.numKVHeads = config.numKVHeads
self.headDim = config.headDim
self.scale = 1.0 / sqrt(Float(headDim))
let hiddenSize = config.hiddenSize
// Create quantized linear layers
self._qProj.wrappedValue = QuantizedLinear(
hiddenSize, numHeads * headDim, bias: false,
groupSize: config.groupSize, bits: config.bits)
self._kProj.wrappedValue = QuantizedLinear(
hiddenSize, numKVHeads * headDim, bias: false,
groupSize: config.groupSize, bits: config.bits)
self._vProj.wrappedValue = QuantizedLinear(
hiddenSize, numKVHeads * headDim, bias: false,
groupSize: config.groupSize, bits: config.bits)
self._oProj.wrappedValue = QuantizedLinear(
numHeads * headDim, hiddenSize, bias: false,
groupSize: config.groupSize, bits: config.bits)
// Q/K normalization (Qwen3 specific)
self._qNorm.wrappedValue = RMSNorm(dimensions: headDim, eps: config.rmsNormEps)
self._kNorm.wrappedValue = RMSNorm(dimensions: headDim, eps: config.rmsNormEps)
// MLXFast RoPE: split-half rotation (traditional=false), base from config
self.rope = MLXNN.RoPE(dimensions: headDim, traditional: false, base: config.ropeTheta)
super.init()
}
public func callAsFunction(
_ hiddenStates: MLXArray,
attentionMask: MLXArray? = nil,
cache: (MLXArray, MLXArray)? = nil
) -> (MLXArray, (MLXArray, MLXArray)) {
let (batch, seqLen, _) = (hiddenStates.dim(0), hiddenStates.dim(1), hiddenStates.dim(2))
// Project Q, K, V
var queries = qProj(hiddenStates)
var keys = kProj(hiddenStates)
var values = vProj(hiddenStates)
// Reshape for multi-head attention
queries = queries.reshaped(batch, seqLen, numHeads, headDim)
keys = keys.reshaped(batch, seqLen, numKVHeads, headDim)
values = values.reshaped(batch, seqLen, numKVHeads, headDim)
// Apply Q/K normalization
queries = qNorm(queries)
keys = kNorm(keys)
// Transpose to [batch, heads, seq, head_dim]
queries = queries.transposed(0, 2, 1, 3)
keys = keys.transposed(0, 2, 1, 3)
values = values.transposed(0, 2, 1, 3)
// Calculate offset for RoPE based on cache
let offset = cache?.0.dim(2) ?? 0
// Apply MLXFast RoPE (handles split-half rotation via optimized Metal kernel)
queries = rope(queries, offset: offset)
keys = rope(keys, offset: offset)
// Update cache
var cachedKeys = keys
var cachedValues = values
if let (prevKeys, prevValues) = cache {
cachedKeys = concatenated([prevKeys, keys], axis: 2)
cachedValues = concatenated([prevValues, values], axis: 2)
}
// SDPA handles GQA natively (N_q != N_kv), no need to tile KV heads
let merged = SDPA.attendAndMerge(
qHeads: queries, kHeads: cachedKeys, vHeads: cachedValues,
scale: scale, mask: attentionMask)
let output = oProj(merged)
return (output, (cachedKeys, cachedValues))
}
}
/// MLP for Qwen3 text decoder (SwiGLU activation, quantized)
/// Wraps the shared QuantizedMLP for backward compatibility
public class QuantizedTextMLP: Module {
@ModuleInfo var gateProj: QuantizedLinear
@ModuleInfo var upProj: QuantizedLinear
@ModuleInfo var downProj: QuantizedLinear
public init(config: TextDecoderConfig) {
let hiddenSize = config.hiddenSize
let intermediateSize = config.intermediateSize
self._gateProj.wrappedValue = QuantizedLinear(
hiddenSize, intermediateSize, bias: false,
groupSize: config.groupSize, bits: config.bits)
self._upProj.wrappedValue = QuantizedLinear(
hiddenSize, intermediateSize, bias: false,
groupSize: config.groupSize, bits: config.bits)
self._downProj.wrappedValue = QuantizedLinear(
intermediateSize, hiddenSize, bias: false,
groupSize: config.groupSize, bits: config.bits)
super.init()
}
public func callAsFunction(_ x: MLXArray) -> MLXArray {
// SwiGLU: down(silu(gate(x)) * up(x))
let gate = silu(gateProj(x))
let up = upProj(x)
return downProj(gate * up)
}
}
/// Decoder layer for Qwen3 text model (quantized)
public class QuantizedTextDecoderLayer: Module {
@ModuleInfo var selfAttn: QuantizedTextAttention
@ModuleInfo var mlp: QuantizedTextMLP
@ModuleInfo var inputLayerNorm: RMSNorm
@ModuleInfo var postAttentionLayerNorm: RMSNorm
public init(config: TextDecoderConfig) {
self._selfAttn.wrappedValue = QuantizedTextAttention(config: config)
self._mlp.wrappedValue = QuantizedTextMLP(config: config)
self._inputLayerNorm.wrappedValue = RMSNorm(dimensions: config.hiddenSize, eps: config.rmsNormEps)
self._postAttentionLayerNorm.wrappedValue = RMSNorm(dimensions: config.hiddenSize, eps: config.rmsNormEps)
super.init()
}
public func callAsFunction(
_ hiddenStates: MLXArray,
attentionMask: MLXArray? = nil,
cache: (MLXArray, MLXArray)? = nil
) -> (MLXArray, (MLXArray, MLXArray)) {
// Self attention with pre-norm
let residual = hiddenStates
var hidden = inputLayerNorm(hiddenStates)
let (attnOutput, newCache) = selfAttn(hidden, attentionMask: attentionMask, cache: cache)
hidden = residual + attnOutput
// MLP with pre-norm
let residual2 = hidden
hidden = postAttentionLayerNorm(hidden)
hidden = mlp(hidden)
hidden = residual2 + hidden
return (hidden, newCache)
}
}
/// Full Qwen3 text decoder model (quantized)
public class QuantizedTextModel: Module {
public let config: TextDecoderConfig
@ModuleInfo public var embedTokens: PreQuantizedEmbedding
@ModuleInfo var layers: [QuantizedTextDecoderLayer]
@ModuleInfo var norm: RMSNorm
public init(config: TextDecoderConfig) {
self.config = config
self._embedTokens.wrappedValue = PreQuantizedEmbedding(
embeddingCount: config.vocabSize,
dimensions: config.hiddenSize,
groupSize: config.groupSize,
bits: config.bits)
self._layers.wrappedValue = (0..<config.numLayers).map { _ in
QuantizedTextDecoderLayer(config: config)
}
self._norm.wrappedValue = RMSNorm(dimensions: config.hiddenSize, eps: config.rmsNormEps)
super.init()
}
/// Forward pass through text decoder
public func callAsFunction(
inputIds: MLXArray? = nil,
inputsEmbeds: MLXArray? = nil,
attentionMask: MLXArray? = nil,
cache: [(MLXArray, MLXArray)]? = nil
) -> (MLXArray, [(MLXArray, MLXArray)]) {
// Get embeddings
var hiddenStates: MLXArray
if let embeds = inputsEmbeds {
hiddenStates = embeds
} else if let ids = inputIds {
hiddenStates = embedTokens(ids)
} else {
fatalError("Either inputIds or inputsEmbeds must be provided")
}
let seqLen = hiddenStates.dim(1)
// Determine attention mask
let mask: MLXArray?
if let providedMask = attentionMask {
mask = providedMask
} else if seqLen == 1 {
// Autoregressive: single query can attend to all cached positions, no mask needed
mask = nil
} else {
// Prefill: create causal mask using MLX broadcast operations
let cacheLen = cache?.first?.0.dim(2) ?? 0
let totalLen = seqLen + cacheLen
let rows = (MLXArray(0..<Int32(seqLen)) + Int32(cacheLen)).expandedDimensions(axis: 1)
let cols = MLXArray(0..<Int32(totalLen)).expandedDimensions(axis: 0)
mask = MLX.where(cols .> rows, MLXArray(Float(-1e9)), MLXArray(Float(0)))
.expandedDimensions(axes: [0, 1])
.asType(hiddenStates.dtype)
}
// Apply decoder layers
var newCache: [(MLXArray, MLXArray)] = []
for (i, layer) in layers.enumerated() {
let layerCache = cache?[i]
let (output, updatedCache) = layer(hiddenStates, attentionMask: mask, cache: layerCache)
hiddenStates = output
newCache.append(updatedCache)
}
// Final norm
hiddenStates = norm(hiddenStates)
return (hiddenStates, newCache)
}
}
@@ -0,0 +1,27 @@
import AudioCommon
import MLX
extension Qwen3ASRModel: ModelMemoryManageable {
public var isLoaded: Bool { _isLoaded }
public func unload() {
guard _isLoaded else { return }
audioEncoder.clearParameters()
textDecoder?.clearParameters()
// Restore the MLX cache limit if we lowered it at load time. This
// un-leaks the cap from PersonaPlex / multi-model processes that
// co-load this ASR with a Mimi codec, LLM, or TTS that wants the
// full default cache budget.
if let prior = savedMLXCacheLimit {
MLX.Memory.cacheLimit = prior
savedMLXCacheLimit = nil
}
_isLoaded = false
}
public var memoryFootprint: Int {
guard _isLoaded else { return 0 }
return audioEncoder.parameterMemoryBytes()
+ (textDecoder?.parameterMemoryBytes() ?? 0)
}
}
@@ -0,0 +1,11 @@
import AudioCommon
// MARK: - SpeechRecognitionModel
extension Qwen3ASRModel: SpeechRecognitionModel {
public var inputSampleRate: Int { 16000 }
public func transcribe(audio: [Float], sampleRate: Int, language: String?) -> String {
transcribe(audio: audio, sampleRate: sampleRate, language: language, maxTokens: 448)
}
}
@@ -0,0 +1,930 @@
import Foundation
import MLXCommon
import MLX
import MLXNN
import MLXFast
import AudioCommon
/// Optional decoder tunables for `Qwen3ASRModel.transcribe(audio:options:)`.
///
/// Defaults match the historical greedy behaviour of `transcribe(audio:)`
/// so existing callers see zero change. Tune these when greedy decoding
/// collapses onto a single token (typical on silence or ambiguous phonemes).
///
/// The struct also carries the "long-input auto-escalation" knobs used by
/// the public `transcribe(...)` entry points to bound greedy degeneration
/// on >15 s audio without affecting short-clip behaviour. See
/// `adaptedFor(audioDurationSeconds:)`.
public struct Qwen3DecodingOptions: Sendable {
/// Cap on decoder output per chunk.
public var maxTokens: Int = 448
/// Optional language hint ("en", "zh", ). `nil` = auto-detect.
public var language: String?
/// Context hint prepended to the decoder prompt.
public var context: String?
/// HuggingFace-style repetition penalty. Divides the logits of tokens
/// already generated this chunk by this factor before `argMax`.
/// `1.0` disables; `1.1``1.3` is the common tuning range.
public var repetitionPenalty: Float = 1.0
/// If > 0, masks any next-token whose emission would form a repeated
/// n-gram of this size. `0` disables.
public var noRepeatNgramSize: Int = 0
/// `0` = greedy (argmax). `> 0` = sample with this temperature via
/// Gumbel-max. Higher = more random.
public var temperature: Float = 0.0
/// Adaptive decoding threshold. When the input audio is longer than this
/// many seconds AND the caller has left `noRepeatNgramSize` at 0 (the
/// default greedy path), the public `transcribe(...)` entry points
/// auto-escalate `noRepeatNgramSize` to `longInputNoRepeatNgramSize`
/// before forwarding into `generateText`. This bounds the 0.6B
/// greedy-decode degeneration observed on long-form audio without
/// affecting short clips. Set to `.infinity` to disable entirely.
public var longInputThresholdSeconds: Double = 15.0
/// n-gram size applied by the long-input auto-escalation path. Only
/// used when the threshold above triggers AND the caller hasn't
/// already set a custom `noRepeatNgramSize`. 3 mirrors the slow-path
/// default in `E2EQwen3DecodingOptionsTests`.
public var longInputNoRepeatNgramSize: Int = 3
public init(
maxTokens: Int = 448,
language: String? = nil,
context: String? = nil,
repetitionPenalty: Float = 1.0,
noRepeatNgramSize: Int = 0,
temperature: Float = 0.0,
longInputThresholdSeconds: Double = 15.0,
longInputNoRepeatNgramSize: Int = 3
) {
self.maxTokens = maxTokens
self.language = language
self.context = context
self.repetitionPenalty = repetitionPenalty
self.noRepeatNgramSize = noRepeatNgramSize
self.temperature = temperature
self.longInputThresholdSeconds = longInputThresholdSeconds
self.longInputNoRepeatNgramSize = longInputNoRepeatNgramSize
}
/// Length-gated auto-escalation. Returns a copy with
/// `noRepeatNgramSize` bumped to `longInputNoRepeatNgramSize` IFF
/// 1. `audioDurationSeconds > longInputThresholdSeconds`, AND
/// 2. the caller left `noRepeatNgramSize` at 0 (default greedy), AND
/// 3. `longInputNoRepeatNgramSize > 0` (escalation not disabled).
/// Otherwise returns `self` unchanged.
///
/// Any caller that has explicitly tuned `noRepeatNgramSize` including
/// setting it to a non-3 value is honoured. This preserves the
/// fast-path / slow-path routing semantics in `isGreedyFastPath`.
func adaptedFor(audioDurationSeconds: Double) -> Qwen3DecodingOptions {
guard audioDurationSeconds > longInputThresholdSeconds,
noRepeatNgramSize == 0,
longInputNoRepeatNgramSize > 0 else {
return self
}
var copy = self
copy.noRepeatNgramSize = longInputNoRepeatNgramSize
return copy
}
}
/// Special token IDs for Qwen3-ASR
public struct Qwen3ASRTokens: Sendable {
public static let audioTokenId = 151676 // <|audio_pad|>
public static let audioStartTokenId = 151669 // <|audio_start|>
public static let audioEndTokenId = 151670 // <|audio_end|>
public static let eosTokenId = 151645 // <|im_end|>
public static let padTokenId = 151643 // <|endoftext|>
public static let imStartTokenId = 151644 // <|im_start|>
public static let imEndTokenId = 151645 // <|im_end|>
public static let timestampTokenId = 151705 // <|timestamp|>
}
/// Main Qwen3-ASR model for speech recognition.
///
/// - Warning: This class is not thread-safe. Create separate instances for concurrent use.
public class Qwen3ASRModel {
/// Default HuggingFace model identifier the 0.6B 4-bit MLX bundle.
/// Mirrors `ASRModelSize.small.modelId`; kept as a top-level constant so
/// the AudioServer registry and other call sites have a single SSOT.
public static let defaultModelId = "aufklarer/Qwen3-ASR-0.6B-MLX-4bit"
/// 1.7B 8-bit MLX bundle higher-capacity sibling of the default.
public static let largeModelId = "aufklarer/Qwen3-ASR-1.7B-MLX-8bit"
/// CoreML-packaged variant for Neural Engine deployment.
public static let coreMLModelId = "aufklarer/Qwen3-ASR-CoreML"
public let audioEncoder: Qwen3AudioEncoder
public let featureExtractor: WhisperFeatureExtractor
public var textDecoder: QuantizedTextModel?
/// Tokenizer for decoding output tokens
private var tokenizer: Qwen3Tokenizer?
/// Text decoder config
public let textConfig: TextDecoderConfig
/// Whether the model weights are loaded and ready for inference.
var _isLoaded = true
/// MLX cache limit captured at load time for the .large variant. Stored
/// per-instance so `unload()` can restore it preventing the 4 GB cap
/// from leaking into co-loaded models (PersonaPlex loads ASR + LM + TTS
/// in the same process). `nil` when no cap was applied (small variant
/// or already-capped global state).
var savedMLXCacheLimit: Int?
init(
audioConfig: Qwen3AudioEncoderConfig = .default,
textConfig: TextDecoderConfig = .small
) {
self.audioEncoder = Qwen3AudioEncoder(config: audioConfig)
self.featureExtractor = WhisperFeatureExtractor()
self.textConfig = textConfig
// Text decoder will be initialized when loading weights
self.textDecoder = nil
}
/// Set tokenizer for text decoding
func setTokenizer(_ tokenizer: Qwen3Tokenizer) {
self.tokenizer = tokenizer
}
/// Initialize text decoder (called after loading)
func initializeTextDecoder() {
self.textDecoder = QuantizedTextModel(config: textConfig)
}
/// Transcribe audio to text with explicit decoder options.
///
/// The legacy `transcribe(audio:sampleRate:language:maxTokens:context:)`
/// overload below forwards into this path with default (greedy) options.
///
/// Long-input adaptive decoding: before forwarding into `generateText`,
/// `options.adaptedFor(audioDurationSeconds:)` is applied. On audio
/// longer than `options.longInputThresholdSeconds` (default 15 s) AND
/// when the caller hasn't customized `noRepeatNgramSize`, the options
/// are escalated to engage the no-repeat-n-gram slow path. Short clips
/// are unaffected; explicit caller settings are honoured.
public func transcribe(
audio: [Float],
sampleRate: Int = 16000,
options: Qwen3DecodingOptions
) -> String {
let durationSeconds = sampleRate > 0
? Double(audio.count) / Double(sampleRate)
: 0.0
let effective = options.adaptedFor(audioDurationSeconds: durationSeconds)
let melFeatures = featureExtractor.process(audio, sampleRate: sampleRate)
let batchedFeatures = melFeatures.expandedDimensions(axis: 0)
var audioEmbeds = audioEncoder(batchedFeatures)
audioEmbeds = audioEmbeds.expandedDimensions(axis: 0)
guard let textDecoder = textDecoder else {
let shape = audioEmbeds.shape
return "[Audio encoded: \(shape)] - Text decoder not loaded"
}
return generateText(
audioEmbeds: audioEmbeds,
textDecoder: textDecoder,
language: effective.language,
maxTokens: effective.maxTokens,
context: effective.context,
decodingOptions: effective
)
}
/// Transcribe audio to text
///
/// Long-input adaptive decoding: the legacy overload constructs a
/// default `Qwen3DecodingOptions` and routes through the same
/// length-gated escalation as the options-based path. Callers who pin
/// `noRepeatNgramSize` via `Qwen3DecodingOptions` directly are out of
/// scope here (they reach the other overload).
public func transcribe(
audio: [Float],
sampleRate: Int = 16000,
language: String? = nil,
maxTokens: Int = 448,
context: String? = nil
) -> String {
let durationSeconds = sampleRate > 0
? Double(audio.count) / Double(sampleRate)
: 0.0
let baseOptions = Qwen3DecodingOptions(
maxTokens: maxTokens, language: language, context: context)
let effective = baseOptions.adaptedFor(audioDurationSeconds: durationSeconds)
// Extract mel features
let melFeatures = featureExtractor.process(audio, sampleRate: sampleRate)
// Add batch dimension: [mel, time] -> [1, mel, time]
let batchedFeatures = melFeatures.expandedDimensions(axis: 0)
// Encode audio - returns [time, features] without batch dim (matching Python)
var audioEmbeds = audioEncoder(batchedFeatures)
// Add batch dimension for consistency: [time, features] -> [1, time, features]
audioEmbeds = audioEmbeds.expandedDimensions(axis: 0)
// Check if text decoder is loaded
guard let textDecoder = textDecoder else {
let shape = audioEmbeds.shape
return "[Audio encoded: \(shape)] - Text decoder not loaded"
}
// Long-form audio that triggered escalation routes through the
// options-aware codepath (which calls `isGreedyFastPath` and falls
// out to `generateSlow`); short clips with default greedy take the
// legacy fast-path call shape, bit-identical to today.
// Mirror `isGreedyFastPath` exactly so the two routes stay in sync
// even if a fourth decoder knob is added later.
if !Self.isGreedyFastPath(effective) {
return generateText(
audioEmbeds: audioEmbeds,
textDecoder: textDecoder,
language: effective.language,
maxTokens: effective.maxTokens,
context: effective.context,
decodingOptions: effective
)
}
return generateText(
audioEmbeds: audioEmbeds,
textDecoder: textDecoder,
language: effective.language,
maxTokens: effective.maxTokens,
context: effective.context
)
}
/// Generate text from audio embeddings.
///
/// When `decodingOptions` is supplied, the decoder loop applies an
/// HF-style repetition penalty, an optional no-repeat n-gram mask, and
/// optional temperature sampling before each token selection. With the
/// default `Qwen3DecodingOptions()` (repetition=1.0, no-repeat=0,
/// temperature=0) behaviour is bit-identical to plain greedy.
func generateText(
audioEmbeds: MLXArray,
textDecoder: QuantizedTextModel,
language: String?,
maxTokens: Int,
context: String? = nil,
decodingOptions: Qwen3DecodingOptions = Qwen3DecodingOptions()
) -> String {
// Special token IDs
let imStartId = 151644
let imEndId = 151645
let audioStartId = 151669
let audioEndId = 151670
let audioPadId = 151676
let asrTextId = 151704
let newlineId = 198
// Token IDs for "system", "user", "assistant"
let systemId = 8948
let userId = 872
let assistantId = 77091
// Number of audio tokens (from audio encoder output)
let numAudioTokens = audioEmbeds.dim(1)
// Build input_ids array with audio_pad placeholder tokens
var inputIds: [Int32] = []
// <|im_start|>system\n{context}<|im_end|>\n
inputIds.append(contentsOf: [imStartId, systemId, newlineId].map { Int32($0) })
if let context = context, !context.isEmpty, let tokenizer = tokenizer {
let contextTokens = tokenizer.encode(context)
inputIds.append(contentsOf: contextTokens.map { Int32($0) })
}
inputIds.append(contentsOf: [imEndId, newlineId].map { Int32($0) })
// <|im_start|>user\n<|audio_start|>
inputIds.append(contentsOf: [imStartId, userId, newlineId, audioStartId].map { Int32($0) })
// <|audio_pad|> * numAudioTokens (placeholder tokens that will be replaced)
let audioStartIndex = inputIds.count
for _ in 0..<numAudioTokens {
inputIds.append(Int32(audioPadId))
}
let audioEndIndex = inputIds.count
// <|audio_end|><|im_end|>\n
inputIds.append(contentsOf: [audioEndId, imEndId, newlineId].map { Int32($0) })
// <|im_start|>assistant\n
inputIds.append(contentsOf: [imStartId, assistantId, newlineId].map { Int32($0) })
// Add language hint if specified, then always add <|asr_text|> marker.
// Without <|asr_text|>, the model doesn't know it should transcribe.
// Without language hint, the model auto-detects and prepends "language XX" to output.
if let lang = language, let tokenizer = tokenizer {
let langPrefix = "language \(lang)"
let langTokens = tokenizer.encode(langPrefix)
inputIds.append(contentsOf: langTokens.map { Int32($0) })
}
inputIds.append(Int32(asrTextId))
// Get text embeddings for all tokens
let inputIdsTensor = MLXArray(inputIds).expandedDimensions(axis: 0)
var inputEmbeds = textDecoder.embedTokens(inputIdsTensor)
// Replace audio_pad token positions with actual audio embeddings
let audioEmbedsTyped = audioEmbeds.asType(inputEmbeds.dtype)
let beforeAudio = inputEmbeds[0..., 0..<audioStartIndex, 0...]
let afterAudio = inputEmbeds[0..., audioEndIndex..., 0...]
inputEmbeds = concatenated([beforeAudio, audioEmbedsTyped, afterAudio], axis: 1)
// Initialize KV cache
var cache: [(MLXArray, MLXArray)]? = nil
// First pass: process the full input embeddings
let (hiddenStates, newCache) = textDecoder(inputsEmbeds: inputEmbeds, cache: cache)
cache = newCache
// Get logits from the last position using embedding as LM head (tied weights)
let seqLen = hiddenStates.dim(1)
let lastHidden = hiddenStates[0..., (seqLen-1)..<seqLen, 0...]
let logits = textDecoder.embedTokens.asLinear(lastHidden)
// Greedy fast path uses a double-buffered asyncEval decode loop that
// overlaps the GPU forward pass for token N+1 with the host-side
// bookkeeping (EOS check + Swift array append) for token N. The
// legacy slow path stays on the per-token CPU sync because it pulls
// the full logits tensor to CPU for repetition / n-gram / temperature
// manipulation, which would defeat the overlap.
let generatedTokens: [Int32]
if Self.isGreedyFastPath(decodingOptions) {
generatedTokens = Self.generateGreedyAsyncEval(
textDecoder: textDecoder,
initialLogits: logits,
cache: cache!,
maxTokens: maxTokens
)
} else {
generatedTokens = Self.generateSlow(
textDecoder: textDecoder,
initialLogits: logits,
cache: cache!,
maxTokens: maxTokens,
options: decodingOptions
)
}
// Decode tokens to text
if let tokenizer = tokenizer {
let rawText = tokenizer.decode(tokens: generatedTokens.map { Int($0) })
// Strip "language XX<asr_text>" prefix if present (auto-detection output)
if let range = rawText.range(of: "<asr_text>") {
return String(rawText[range.upperBound...]).trimmingCharacters(in: .whitespaces)
}
return rawText
} else {
// Fallback: return token IDs
return generatedTokens.map { String($0) }.joined(separator: " ")
}
}
/// Greedy with default options: temperature 0, no repetition penalty,
/// no n-gram blocking. The double-buffered asyncEval loop only kicks
/// in for this configuration so we can guarantee bit-identical token
/// sequences vs. the legacy `argMax(...).item()` decoder.
static func isGreedyFastPath(_ options: Qwen3DecodingOptions) -> Bool {
return options.repetitionPenalty == 1.0
&& options.noRepeatNgramSize == 0
&& options.temperature == 0.0
}
/// Double-buffered greedy decode loop. The key trick is to keep the
/// "next token" as a lazy 0-D `MLXArray` (the result of `argMax`),
/// build the *next* step's forward pass on top of it (still lazy),
/// then call `MLX.asyncEval` so the GPU starts computing step N+1
/// before we sync step N's int32 to CPU. The host-side EOS check
/// and `generatedTokens.append` then overlap with the in-flight GPU
/// work for step N+1 instead of stalling between every token.
///
/// Greedy correctness invariant: argMax is deterministic, so this
/// produces the exact same token sequence as the legacy loop on
/// matching inputs.
static func generateGreedyAsyncEval(
textDecoder: QuantizedTextModel,
initialLogits: MLXArray,
cache initialCache: [(MLXArray, MLXArray)],
maxTokens: Int
) -> [Int32] {
var generatedTokens: [Int32] = []
guard maxTokens > 0 else { return generatedTokens }
// Stage 0: argmax of the prefill's last logits. Stays lazy until
// the first `.item()` below.
//
// Cast to int32 explicitly: MLX's `argmax` returns uint32, but the
// legacy loop fed `embedTokens` an int32 tensor (built from a Swift
// `Int32`). Quantized embedding lookup observably dispatches
// differently on uint32 vs. int32, producing tokens that diverge
// from the legacy path on a small fraction of inputs. Casting here
// restores exact dtype parity, so greedy stays token-for-token
// identical to the pre-optimisation decoder.
var nextTokenArr = argMax(initialLogits, axis: -1).squeezed().asType(.int32)
var cache = initialCache
// Kick off the GPU on the first token (and the prefill cache that
// step 1's graph will read from).
asyncEval(nextTokenArr, cache)
let eosToken = Int32(Qwen3ASRTokens.eosTokenId)
for step in 0..<maxTokens {
// Stage N+1's graph BEFORE syncing N. embedTokens expects a
// [batch, seq] int32 tensor; nextTokenArr is 0-D so we expand
// twice to [1, 1].
//
// Skip the speculative graph build on the LAST iteration
// we'd never consume it, and on tiny `maxTokens` the saved
// GPU/host work matters.
var nextTokenArrN1: MLXArray? = nil
var cacheN1: [(MLXArray, MLXArray)]? = nil
if step + 1 < maxTokens {
let nextEmbed = textDecoder.embedTokens(
nextTokenArr.expandedDimensions(axis: 0).expandedDimensions(axis: 0)
)
let (hiddenN1, newCacheN1) = textDecoder(inputsEmbeds: nextEmbed, cache: cache)
let lastHiddenN1 = hiddenN1[0..., (-1)..., .ellipsis]
let logitsN1 = textDecoder.embedTokens.asLinear(lastHiddenN1)
let argN1 = argMax(logitsN1, axis: -1).squeezed().asType(.int32)
// Kick GPU on N+1 (chains after the asyncEval that is
// still computing N).
asyncEval(argN1, newCacheN1)
nextTokenArrN1 = argN1
cacheN1 = newCacheN1
}
// Now sync N. By the time we reach `.item()` the GPU has
// very likely finished N already, and the cost of this call
// shrinks to a host-side memcpy of one int32 which itself
// overlaps with the in-flight N+1 forward pass.
let nextToken = nextTokenArr.item(Int32.self)
// Match the legacy loop semantics exactly: append the token
// first, *then* break on EOS. The legacy loop emitted EOS
// into `generatedTokens` whenever EOS was the most recent
// pick, so greedy stays bit-identical.
generatedTokens.append(nextToken)
if nextToken == eosToken { break }
guard let advancedCache = cacheN1, let advancedToken = nextTokenArrN1 else {
// Final iteration without speculative work nothing to
// advance to; the loop will exit naturally next.
break
}
cache = advancedCache
nextTokenArr = advancedToken
}
return generatedTokens
}
/// Legacy decode loop kept verbatim for the non-greedy slow path.
/// `pickNextToken` here pulls logits to CPU for repetition penalty,
/// n-gram masking, and temperature sampling, so there's no benefit
/// from `asyncEval` overlap.
static func generateSlow(
textDecoder: QuantizedTextModel,
initialLogits: MLXArray,
cache initialCache: [(MLXArray, MLXArray)],
maxTokens: Int,
options: Qwen3DecodingOptions
) -> [Int32] {
var generatedTokens: [Int32] = []
guard maxTokens > 0 else { return generatedTokens }
var cache: [(MLXArray, MLXArray)]? = initialCache
var nextToken = Self.pickNextToken(
logits: initialLogits,
generatedSoFar: generatedTokens,
options: options
)
generatedTokens.append(nextToken)
for _ in 1..<maxTokens {
if nextToken == Int32(Qwen3ASRTokens.eosTokenId) { break }
let tokenEmbeds = textDecoder.embedTokens(
MLXArray([nextToken]).expandedDimensions(axis: 0)
)
let (hiddenStates, newCache) = textDecoder(inputsEmbeds: tokenEmbeds, cache: cache)
cache = newCache
let lastHiddenNext = hiddenStates[0..., (-1)..., .ellipsis]
let logits = textDecoder.embedTokens.asLinear(lastHiddenNext)
nextToken = Self.pickNextToken(
logits: logits,
generatedSoFar: generatedTokens,
options: options
)
generatedTokens.append(nextToken)
}
return generatedTokens
}
// MARK: - Decoder knobs
/// Pick the next token from a logits tensor, applying repetition
/// penalty, no-repeat n-gram masking, and optional temperature sampling.
///
/// With default options (repetition=1.0, noRepeat=0, temperature=0) the
/// result is the same `argMax` the decoder used pre-refactor.
/// Implementation pulls logits to CPU (a 1-D Float array of vocab size)
/// so we can manipulate entries in-place without fighting MLX indexing.
///
/// Access is `internal static` (not `private`) so
/// ``Qwen3DecodingOptionsTests`` can exercise the sampler directly via
/// ``@testable import Qwen3ASR`` there is no GPU or model download
/// involved so the path is trivially unit-testable once reachable.
static func pickNextToken(
logits: MLXArray,
generatedSoFar: [Int32],
options: Qwen3DecodingOptions
) -> Int32 {
// Fast path pure greedy, no modifications.
if options.repetitionPenalty == 1.0,
options.noRepeatNgramSize == 0,
options.temperature == 0 {
return argMax(logits, axis: -1).squeezed().item(Int32.self)
}
// Pull logits to CPU. `logits` is [1, 1, vocabSize]; after squeeze
// and conversion we have a plain `[Float]` of length vocabSize.
let flat = logits.squeezed().asType(.float32)
let vocabSize = flat.size
var scores: [Float] = flat.asArray(Float.self)
precondition(scores.count == vocabSize, "pickNextToken: vocab size mismatch")
// Repetition penalty: divide logits for already-generated tokens.
if options.repetitionPenalty > 1.0 && !generatedSoFar.isEmpty {
let penalty = options.repetitionPenalty
for token in Set(generatedSoFar) {
let idx = Int(token)
guard idx >= 0, idx < vocabSize else { continue }
let v = scores[idx]
// Positive logits divide; negative logits multiply matches
// HuggingFace's implementation so the penalty always reduces
// the probability of the repeated token.
scores[idx] = v > 0 ? v / penalty : v * penalty
}
}
// No-repeat-ngram: any next token whose emission would form a
// repeated n-gram of size N gets pushed to -infinity.
let n = options.noRepeatNgramSize
if n > 0 && generatedSoFar.count >= n - 1 {
let lastPrefix = Array(generatedSoFar.suffix(n - 1))
// Walk every position where `lastPrefix` already appeared
// the token that followed it becomes forbidden as the NEXT
// token now.
if generatedSoFar.count >= n {
for i in 0...(generatedSoFar.count - n) {
let window = Array(generatedSoFar[i..<(i + n - 1)])
guard window == lastPrefix else { continue }
let forbidden = Int(generatedSoFar[i + n - 1])
if forbidden >= 0 && forbidden < vocabSize {
scores[forbidden] = -.infinity
}
}
}
}
// Temperature sampling via Gumbel-max trick:
// argmax(logits/T + Gumbel(0,1)) ~ categorical(softmax(logits/T)).
if options.temperature > 0 {
let t = options.temperature
for i in 0..<vocabSize {
let u = Float.random(in: 1e-6...1.0)
scores[i] = scores[i] / t - Float.log(-Float.log(u))
}
}
// Argmax of the adjusted scores.
var bestIdx = 0
var bestScore = -Float.infinity
for i in 0..<vocabSize where scores[i] > bestScore {
bestScore = scores[i]
bestIdx = i
}
return Int32(bestIdx)
}
}
// MARK: - Backward Compatibility (delegates to HuggingFaceDownloader)
public extension Qwen3ASRModel {
static func sanitizedCacheKey(for modelId: String) -> String {
HuggingFaceDownloader.sanitizedCacheKey(for: modelId)
}
static func validatedRemoteFileName(_ file: String) throws -> String {
try HuggingFaceDownloader.validatedRemoteFileName(file)
}
static func validatedLocalPath(directory: URL, fileName: String) throws -> URL {
try HuggingFaceDownloader.validatedLocalPath(directory: directory, fileName: fileName)
}
}
// MARK: - Model Size Detection
/// Supported ASR model sizes
public enum ASRModelSize {
case small // 0.6B
case large // 1.7B
/// Default model IDs on HuggingFace
public var defaultModelId: String {
switch self {
case .small: return "aufklarer/Qwen3-ASR-0.6B-MLX-4bit"
case .large: return "aufklarer/Qwen3-ASR-1.7B-MLX-8bit"
}
}
/// Audio encoder config for this model size
public var audioConfig: Qwen3AudioEncoderConfig {
switch self {
case .small: return .small
case .large: return .large
}
}
/// Text decoder config for this model size and quantization bits
public func textConfig(bits: Int) -> TextDecoderConfig {
switch (self, bits) {
case (.small, 8): return .small8bit
case (.small, _): return .small
case (.large, 8): return .large8bit
case (.large, _): return .large
}
}
/// Text decoder config for this model size (default bits)
public var textConfig: TextDecoderConfig {
switch self {
case .small: return .small
case .large: return .large
}
}
/// Detect model size from a HuggingFace model ID
public static func detect(from modelId: String) -> ASRModelSize {
if modelId.contains("1.7B") || modelId.contains("1.7b") {
return .large
}
return .small
}
/// Detect quantization bits from a HuggingFace model ID.
/// Returns 4 by default for 0.6B, 8 for 1.7B if not specified.
public static func detectBits(from modelId: String) -> Int {
let lower = modelId.lowercased()
if lower.contains("8bit") || lower.contains("8-bit") {
return 8
}
if lower.contains("4bit") || lower.contains("4-bit") {
return 4
}
// Default: 4 for small, 8 for large (backwards-compatible)
let size = detect(from: modelId)
return size == .large ? 8 : 4
}
}
// MARK: - Memory guards (Bug 4b/4f support)
internal enum Qwen3ASRMemory {
/// Threshold below which the 1.7B variant triggers a load-time RAM
/// warning. Total physical memory is the pragmatic signal observed
/// hangs cluster on 8/16 GB Macs with other apps open; 24 GB+ has
/// consistently completed inference in our benchmarks. Exposed
/// `internal` so unit tests can pin the threshold.
static let largeModelRAMWarningThresholdGB: Double = 24.0
/// MLX cache ceiling applied when loading the 1.7B variant. mlx-swift's
/// default tracks `recommendedMaxWorkingSetSize` which on a 16 GB Mac
/// can grow to several GB under sustained decoding pushing residency
/// past unified-memory headroom and triggering swap. We bound the
/// scratch pool to `min(4 GB, 25% of physical RAM)`: well above the
/// per-token decoder working set, but small enough to leave room for
/// the OS and other apps.
static func cacheLimitForLarge(physicalMemoryBytes: Int) -> Int {
let fourGB = 4 * 1024 * 1024 * 1024
let quarterRAM = physicalMemoryBytes / 4
return max(0, min(fourGB, quarterRAM))
}
/// True when the 1.7B variant should print the soft RAM warning. Total
/// (not available) RAM is the pragmatic signal see threshold doc.
static func shouldWarnForLarge(physicalMemoryBytes: UInt64) -> Bool {
let physicalGB = Double(physicalMemoryBytes) / 1_073_741_824.0
return physicalGB < largeModelRAMWarningThresholdGB
}
/// Emit a human-readable RAM-pressure warning to stderr (NDJSON-IPC safe).
/// Naming the alternative model IDs so the user can copy-paste.
static func emitLargeRAMWarning(physicalMemoryBytes: UInt64) {
let physicalGB = Double(physicalMemoryBytes) / 1_073_741_824.0
let msg = """
[Qwen3ASR] Warning: loading 1.7B variant on \(String(format: "%.0f", physicalGB)) GB Mac.
[Qwen3ASR] The 1.7B model has been observed to swap and stall on <\(Int(largeModelRAMWarningThresholdGB)) GB systems
[Qwen3ASR] when other apps are running. If you see a hang, consider:
[Qwen3ASR] aufklarer/Qwen3-ASR-0.6B-MLX-8bit (recommended for 8-16 GB)
[Qwen3ASR] aufklarer/Qwen3-ASR-1.7B-MLX-4bit (smaller, similar quality)
"""
FileHandle.standardError.write(Data((msg + "\n").utf8))
}
/// Format memory readings (active / cache / peak in bytes) for
/// human-readable logging. Centralized so the formatting is consistent
/// across load-time + transcribe-time telemetry. Sizes are reported in
/// MB. This overload takes `Int` directly so unit tests don't depend on
/// `MLX.Memory.Snapshot`'s sealed initializer.
static func formatSnapshot(active: Int, cache: Int, peak: Int, label: String) -> String {
let mb: (Int) -> String = { String(format: "%.0f MB", Double($0) / 1_048_576.0) }
return "[Qwen3ASR][mem] \(label): "
+ "active=\(mb(active)) "
+ "cache=\(mb(cache)) "
+ "peak=\(mb(peak))"
}
/// Production-callsite overload that adapts a live `MLX.Memory.Snapshot`.
static func formatSnapshot(_ s: MLX.Memory.Snapshot, label: String) -> String {
formatSnapshot(
active: s.activeMemory, cache: s.cacheMemory, peak: s.peakMemory,
label: label)
}
}
// MARK: - Model Loading
public extension Qwen3ASRModel {
/// Load model from HuggingFace hub with automatic weight downloading
static func fromPretrained(
modelId: String = "aufklarer/Qwen3-ASR-0.6B-MLX-4bit",
cacheDir: URL? = nil,
offlineMode: Bool = false,
progressHandler: ((Double, String) -> Void)? = nil
) async throws -> Qwen3ASRModel {
progressHandler?(0.0, "Downloading model...")
// Auto-detect model size and quantization bits from model ID
let modelSize = ASRModelSize.detect(from: modelId)
let detectedBits = ASRModelSize.detectBits(from: modelId)
// Bug 4b: soft RAM warning for the 1.7B variant. Emit BEFORE the
// download so users see it on the first byte, not after a 1.7 GB
// transfer. Routed to stderr to keep stdout clean for NDJSON-IPC
// consumers (speech-studio sidecar).
if modelSize == .large {
let physical = ProcessInfo.processInfo.physicalMemory
if Qwen3ASRMemory.shouldWarnForLarge(physicalMemoryBytes: physical) {
Qwen3ASRMemory.emitLargeRAMWarning(physicalMemoryBytes: physical)
}
}
// Bug 4f: pre-load memory snapshot for telemetry. Cheap to call;
// gives us a baseline to compare against the post-load snapshot.
let memBeforeLoad = MLX.Memory.snapshot()
// Get cache directory
let cacheDir = try cacheDir ?? HuggingFaceDownloader.getCacheDirectory(for: modelId)
// Download weights and tokenizer files (skips files that already exist on disk)
// Download is the slowest part give it 0-80% of progress
try await HuggingFaceDownloader.downloadWeights(
modelId: modelId,
to: cacheDir,
additionalFiles: ["vocab.json", "merges.txt", "tokenizer_config.json"],
offlineMode: offlineMode,
progressHandler: { progress in
progressHandler?(progress * 0.8, "Downloading weights...")
}
)
progressHandler?(0.80, "Loading tokenizer...")
// Create model with appropriate config for detected size and bits
let model = Qwen3ASRModel(
audioConfig: modelSize.audioConfig,
textConfig: modelSize.textConfig(bits: detectedBits)
)
// Load tokenizer from vocab.json
let vocabPath = cacheDir.appendingPathComponent("vocab.json")
if FileManager.default.fileExists(atPath: vocabPath.path) {
let tokenizer = Qwen3Tokenizer()
try tokenizer.load(from: vocabPath)
model.setTokenizer(tokenizer)
}
progressHandler?(0.85, "Loading audio encoder weights...")
// Load audio encoder weights
try WeightLoader.loadWeights(into: model.audioEncoder, from: cacheDir)
progressHandler?(0.92, "Loading text decoder weights...")
// Initialize and load text decoder
model.initializeTextDecoder()
if let textDecoder = model.textDecoder {
try WeightLoader.loadTextDecoderWeights(into: textDecoder, from: cacheDir)
}
MetalBudget.pinMemory()
// Bug 4b: cap MLX scratch pool for the 1.7B variant. Default cache
// limit tracks `recommendedMaxWorkingSetSize` which on a 16 GB Mac
// can grow to several GB during sustained decoding and trigger
// swap. Bounding to `min(4 GB, 25% of physical RAM)` leaves enough
// headroom for per-token decoder working set while keeping the
// total residency under the OS jetsam threshold. 0.6B path is
// unchanged.
//
// Process-global cap leak fix (adversarial review): we save the
// prior limit on the model instance and restore it in `unload()`,
// so co-loaded models in the same process (e.g. PersonaPlex
// loading ASR + LM + TTS) inherit our cap only for the lifetime
// of the loaded ASR. Stacks correctly across multiple ASR
// instances: each save captures whatever was active when it
// loaded, and each unload pops its own saved value.
if modelSize == .large {
let physical = Int(ProcessInfo.processInfo.physicalMemory)
let newCap = Qwen3ASRMemory.cacheLimitForLarge(physicalMemoryBytes: physical)
// Only apply the cap if it would lower the current limit
// never raise a limit a caller has already chosen for itself.
let currentLimit = MLX.Memory.cacheLimit
if newCap > 0 && newCap < currentLimit {
model.savedMLXCacheLimit = currentLimit
MLX.Memory.cacheLimit = newCap
}
}
// Bug 4f: post-load memory snapshot. Difference vs `memBeforeLoad`
// is the model's load-time footprint (weights + activations +
// metallib JIT). Useful for tuning the cache cap and for spotting
// load-time regressions in PRs.
let memAfterLoad = MLX.Memory.snapshot()
AudioLog.modelLoading.info("\(Qwen3ASRMemory.formatSnapshot(memBeforeLoad, label: "pre-load"))")
AudioLog.modelLoading.info("\(Qwen3ASRMemory.formatSnapshot(memAfterLoad, label: "post-load"))")
// Display max(0, delta): MLX can free cached weights between
// snapshots, which makes "active" go down; clamp at 0 so the
// load-delta label stays meaningful in logs.
let loadActiveDelta = max(0, memAfterLoad.activeMemory - memBeforeLoad.activeMemory)
AudioLog.modelLoading.info(
"[Qwen3ASR][mem] load delta (active): \(String(format: "%.0f MB", Double(loadActiveDelta) / 1_048_576.0))")
progressHandler?(1.0, "Ready")
return model
}
/// Download tokenizer + weight files only does not load MLX/Metal.
/// Use from Settings manual download; inference still calls `fromPretrained()`.
static func downloadWeightsOnly(
modelId: String = defaultModelId,
cacheDir: URL? = nil,
registry: ModelRegistry = .huggingFace(),
progressHandler: ((Double, String) -> Void)? = nil
) async throws {
progressHandler?(0.0, "Downloading model...")
let cacheDir = try cacheDir ?? HuggingFaceDownloader.getCacheDirectory(for: modelId)
switch registry {
case .huggingFace(let hubEndpoint):
try await HuggingFaceDownloader.downloadWeights(
modelId: modelId,
to: cacheDir,
additionalFiles: ["vocab.json", "merges.txt", "tokenizer_config.json"],
hubEndpoint: hubEndpoint,
progressHandler: { progress in
progressHandler?(progress, "Downloading weights...")
}
)
case .modelScope(let baseURL, let revision):
try await ModelScopeDownloader.downloadWeights(
modelId: modelId,
to: cacheDir,
additionalFiles: ["vocab.json", "merges.txt", "tokenizer_config.json"],
baseURL: baseURL,
revision: revision,
progressHandler: { progress in
progressHandler?(progress, "Downloading weights...")
}
)
}
progressHandler?(1.0, "Downloaded")
}
}
@@ -0,0 +1,277 @@
import Foundation
import AudioCommon
import SpeechVAD
// MARK: - TranscriptionSegment
public struct TranscriptionSegment: Sendable {
public let text: String
public let startTime: Float
public let endTime: Float
public let isFinal: Bool
public let segmentIndex: Int
public init(text: String, startTime: Float, endTime: Float, isFinal: Bool, segmentIndex: Int) {
self.text = text
self.startTime = startTime
self.endTime = endTime
self.isFinal = isFinal
self.segmentIndex = segmentIndex
}
}
// MARK: - StreamingASRConfig
public struct StreamingASRConfig: Sendable {
public var maxSegmentDuration: Float
public var vadConfig: VADConfig
public var language: String?
public var maxTokens: Int
public var emitPartialResults: Bool
public var partialResultInterval: Float
public var context: String?
public init(
maxSegmentDuration: Float = 10.0,
vadConfig: VADConfig = .sileroDefault,
language: String? = nil,
maxTokens: Int = 448,
emitPartialResults: Bool = false,
partialResultInterval: Float = 1.0,
context: String? = nil
) {
self.maxSegmentDuration = maxSegmentDuration
self.vadConfig = vadConfig
self.language = language
self.maxTokens = maxTokens
self.emitPartialResults = emitPartialResults
self.partialResultInterval = partialResultInterval
self.context = context
}
public static let `default` = StreamingASRConfig()
}
// MARK: - StreamingASR
/// Streaming ASR with VAD-guided segmentation.
///
/// - Warning: This class is not thread-safe. Create separate instances for concurrent use.
public class StreamingASR {
private let asrModel: Qwen3ASRModel
private let vadModel: SileroVADModel
public init(asrModel: Qwen3ASRModel, vadModel: SileroVADModel) {
self.asrModel = asrModel
self.vadModel = vadModel
}
public static func fromPretrained(
asrModelId: String = "aufklarer/Qwen3-ASR-0.6B-MLX-4bit",
vadModelId: String = SileroVADModel.defaultModelId,
cacheDir: URL? = nil,
offlineMode: Bool = false,
progressHandler: ((Double, String) -> Void)? = nil
) async throws -> StreamingASR {
let asr = try await Qwen3ASRModel.fromPretrained(
modelId: asrModelId, cacheDir: cacheDir, offlineMode: offlineMode, progressHandler: progressHandler)
let vad = try await SileroVADModel.fromPretrained(
modelId: vadModelId, cacheDir: cacheDir, offlineMode: offlineMode, progressHandler: progressHandler)
return StreamingASR(asrModel: asr, vadModel: vad)
}
/// Streaming transcription emits TranscriptionSegments as speech is detected.
public func transcribeStream(
audio: [Float],
sampleRate: Int = 16000,
config: StreamingASRConfig = .default
) -> AsyncThrowingStream<TranscriptionSegment, Error> {
AsyncThrowingStream { continuation in
let samples: [Float]
if sampleRate != 16000 {
samples = AudioFileLoader.resample(audio, from: sampleRate, to: 16000)
} else {
samples = audio
}
let processor = StreamingVADProcessor(model: vadModel, config: config.vadConfig)
let chunkSize = SileroVADModel.chunkSize
var segmentIndex = 0
var speechStartSample: Int?
// Phase 2 state
var lastPartialTime: Float = 0
var offset = 0
while offset < samples.count {
let end = min(offset + chunkSize, samples.count)
let chunk = Array(samples[offset..<end])
let events = processor.process(samples: chunk)
for event in events {
switch event {
case .speechStarted(let time):
speechStartSample = Int(time * 16000)
lastPartialTime = time
case .speechEnded(let segment):
if let startSample = speechStartSample {
let endSample = min(Int(segment.endTime * 16000), samples.count)
// After a force-split, startSample may equal endSample skip empty spans
guard startSample < endSample else {
speechStartSample = nil
continue
}
let segmentAudio = Array(samples[startSample..<endSample])
let text = asrModel.transcribe(
audio: segmentAudio, sampleRate: 16000,
language: config.language, maxTokens: config.maxTokens,
context: config.context)
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty {
continuation.yield(TranscriptionSegment(
text: trimmed,
startTime: segment.startTime,
endTime: segment.endTime,
isFinal: true,
segmentIndex: segmentIndex))
segmentIndex += 1
}
speechStartSample = nil
}
}
}
// Phase 2: emit partial results during speech
if config.emitPartialResults, let startSample = speechStartSample {
let currentTime = processor.currentTime
let speechStart = Float(startSample) / 16000
let speechDuration = currentTime - speechStart
if currentTime - lastPartialTime >= config.partialResultInterval {
let endSample = min(Int(currentTime * 16000), samples.count)
guard startSample < endSample else {
lastPartialTime = currentTime
continue
}
let segmentAudio = Array(samples[startSample..<endSample])
let text = asrModel.transcribe(
audio: segmentAudio, sampleRate: 16000,
language: config.language, maxTokens: config.maxTokens,
context: config.context)
let currentWords = text.trimmingCharacters(in: .whitespacesAndNewlines)
.split(separator: " ").map(String.init)
if !currentWords.isEmpty {
continuation.yield(TranscriptionSegment(
text: currentWords.joined(separator: " "),
startTime: speechStart,
endTime: currentTime,
isFinal: false,
segmentIndex: segmentIndex))
}
lastPartialTime = currentTime
}
// Force-split if speech exceeds maxSegmentDuration
if speechDuration >= config.maxSegmentDuration {
let endSample = min(Int(currentTime * 16000), samples.count)
guard startSample < endSample else {
speechStartSample = Int(currentTime * 16000)
lastPartialTime = currentTime
continue
}
let segmentAudio = Array(samples[startSample..<endSample])
let text = asrModel.transcribe(
audio: segmentAudio, sampleRate: 16000,
language: config.language, maxTokens: config.maxTokens,
context: config.context)
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty {
continuation.yield(TranscriptionSegment(
text: trimmed,
startTime: speechStart,
endTime: currentTime,
isFinal: true,
segmentIndex: segmentIndex))
segmentIndex += 1
}
speechStartSample = Int(currentTime * 16000)
lastPartialTime = currentTime
}
} else if !config.emitPartialResults, let startSample = speechStartSample {
// Force-split without partial results
let currentTime = processor.currentTime
let speechStart = Float(startSample) / 16000
let speechDuration = currentTime - speechStart
if speechDuration >= config.maxSegmentDuration {
let endSample = min(Int(currentTime * 16000), samples.count)
guard startSample < endSample else {
speechStartSample = Int(currentTime * 16000)
continue
}
let segmentAudio = Array(samples[startSample..<endSample])
let text = asrModel.transcribe(
audio: segmentAudio, sampleRate: 16000,
language: config.language, maxTokens: config.maxTokens,
context: config.context)
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty {
continuation.yield(TranscriptionSegment(
text: trimmed,
startTime: speechStart,
endTime: currentTime,
isFinal: true,
segmentIndex: segmentIndex))
segmentIndex += 1
}
speechStartSample = Int(currentTime * 16000)
}
}
offset = end
}
// Flush any remaining speech
let flushEvents = processor.flush()
for event in flushEvents {
if case .speechEnded(let segment) = event, let startSample = speechStartSample {
let endSample = min(Int(segment.endTime * 16000), samples.count)
guard startSample < endSample else { continue }
let segmentAudio = Array(samples[startSample..<endSample])
let text = asrModel.transcribe(
audio: segmentAudio, sampleRate: 16000,
language: config.language, maxTokens: config.maxTokens,
context: config.context)
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty {
continuation.yield(TranscriptionSegment(
text: trimmed,
startTime: segment.startTime,
endTime: segment.endTime,
isFinal: true,
segmentIndex: segmentIndex))
}
}
}
continuation.finish()
}
}
}
// MARK: - LocalAgreement Helper
/// Returns the longest common prefix of two word arrays (case-insensitive).
public func longestCommonPrefix(_ a: [String], _ b: [String]) -> [String] {
var result: [String] = []
for i in 0..<min(a.count, b.count) {
if a[i].lowercased() == b[i].lowercased() {
result.append(b[i])
} else {
break
}
}
return result
}
@@ -0,0 +1,308 @@
import Foundation
import NaturalLanguage
import AudioCommon
/// Result of preprocessing text for forced alignment
public struct SlottedText: Sendable {
/// Token IDs with timestamp tokens inserted around each word
public let tokenIds: [Int]
/// Indices within tokenIds that are timestamp tokens
public let timestampPositions: [Int]
/// Surface forms of the words (one per timestamp pair). Adjacent
/// punctuation is preserved here so callers can reconstruct sentences;
/// the model itself only sees the punctuation-stripped form.
public let words: [String]
}
/// Surface + cleaned form of a single word emitted by tokenization.
/// `surface` keeps adjacent punctuation; `cleaned` is what the model tokenizer
/// sees (letters, numbers, and combining marks only).
struct WordPair: Sendable {
var surface: String
let cleaned: String
}
/// Language-specific text preprocessing for forced alignment.
///
/// Three paths:
/// - Japanese morpheme-level segmentation
/// - Korean word-level segmentation
/// - Other whitespace split + per-Han-ideograph break
///
/// Tokens are filtered to keep only Unicode Letters / Numbers and the ASCII
/// apostrophe punctuation, symbols, and marks are stripped before
/// timestamp slots are inserted. Han-ideograph splitting is restricted to
/// CJK Unified + Extensions AE + Compatibility; hiragana, katakana, and
/// Hangul are NOT split per character (the model emits timestamp slots
/// between morphemes for those scripts, not between every kana or jamo).
///
/// Punctuation that surrounds a word (commas, periods, brackets, CJK
/// `` etc.) is preserved on the `surface` form so subtitle / SRT
/// pipelines can still split on sentence boundaries after alignment.
public enum TextPreprocessor {
/// Split text into words and insert timestamp slots for alignment.
///
/// For each word, inserts `<timestamp><timestamp>` pairs so the model
/// can predict start/end timestamps at those positions.
public static func prepareForAlignment(
text: String,
tokenizer: Qwen3Tokenizer,
language: String = "English"
) -> SlottedText {
let pairs = splitIntoWordPairs(text, language: language)
let tsId = Qwen3ASRTokens.timestampTokenId
var tokenIds: [Int] = []
var timestampPositions: [Int] = []
var validWords: [String] = []
for pair in pairs {
let wordTokens = tokenizer.encode(pair.cleaned)
guard !wordTokens.isEmpty else {
// Cleaned form unencodable: attach surface to previous word
// so we don't drop punctuation that anchored to it.
if !validWords.isEmpty {
validWords[validWords.count - 1] += pair.surface
}
continue
}
timestampPositions.append(tokenIds.count)
tokenIds.append(tsId)
tokenIds.append(contentsOf: wordTokens)
timestampPositions.append(tokenIds.count)
tokenIds.append(tsId)
validWords.append(pair.surface)
}
return SlottedText(
tokenIds: tokenIds,
timestampPositions: timestampPositions,
words: validWords
)
}
/// Split text into words using the language-appropriate strategy.
/// Returned strings are the cleaned (punctuation-stripped) forms,
/// intended for callers that don't care about surface preservation.
static func splitIntoWords(_ text: String, language: String) -> [String] {
return splitIntoWordPairs(text, language: language).map { $0.cleaned }
}
/// Split text into (surface, cleaned) pairs. Surface keeps adjacent
/// punctuation; cleaned is what the model tokenizer sees.
static func splitIntoWordPairs(_ text: String, language: String) -> [WordPair] {
let lang = language.lowercased()
if lang.contains("japanese") || lang == "ja" {
return nlTokenizePairs(text, language: .japanese)
}
if lang.contains("korean") || lang == "ko" {
return nlTokenizePairs(text, language: .korean)
}
// Scripts without word-level whitespace where Apple's NLTokenizer
// provides native segmentation. Without these dispatches the
// default whitespace path collapses each sentence to one token.
if let nlLang = nlLanguageForUnspaced(lang) {
return nlTokenizePairs(text, language: nlLang)
}
return tokenizeSpaceLangPairs(text)
}
private static func nlLanguageForUnspaced(_ lang: String) -> NLLanguage? {
if lang.contains("thai") || lang == "th" { return .thai }
if lang.contains("lao") || lang == "lo" { return .lao }
if lang.contains("khmer") || lang == "km" { return .khmer }
if lang.contains("burmese") || lang.contains("myanmar") || lang == "my" { return .burmese }
if lang.contains("tibetan") || lang == "bo" { return .tibetan }
return nil
}
// MARK: - Japanese / Korean / unspaced scripts
/// Apple's `NLTokenizer` reports word ranges (without surrounding
/// punctuation). We attach trailing non-letter, non-whitespace
/// characters between consecutive word ranges to the preceding word's
/// surface so commas, full-width periods, etc. ride along.
private static func nlTokenizePairs(_ text: String, language: NLLanguage) -> [WordPair] {
let tokenizer = NLTokenizer(unit: .word)
tokenizer.setLanguage(language)
tokenizer.string = text
var ranges: [Range<String.Index>] = []
tokenizer.enumerateTokens(in: text.startIndex..<text.endIndex) { range, _ in
ranges.append(range)
return true
}
var pairs: [WordPair] = []
for (i, range) in ranges.enumerated() {
let cleaned = cleanToken(String(text[range]))
guard !cleaned.isEmpty else { continue }
var surface = String(text[range])
let nextStart = i + 1 < ranges.count ? ranges[i + 1].lowerBound : text.endIndex
var idx = range.upperBound
while idx < nextStart {
let ch = text[idx]
if ch.isWhitespace { break }
let allPunct = String(ch).unicodeScalars.allSatisfy { !isKeptScalar($0) }
if !allPunct { break }
surface.append(ch)
idx = text.index(after: idx)
}
pairs.append(WordPair(surface: surface, cleaned: cleaned))
}
return pairs
}
// MARK: - Default path (whitespace + per-Han break)
/// Whitespace split, then per-Han-ideograph break inside each segment.
/// Default path for languages with reliable word-level whitespace
/// English, European languages, Hindi, Arabic, Vietnamese, Mongolian,
/// Indonesian, etc. Chinese works because most Chinese text has no
/// whitespace, so each ideograph becomes its own token via the per-Han
/// break inside the (single) whitespace-bounded segment.
static func tokenizeSpaceLangPairs(_ text: String) -> [WordPair] {
var pairs: [WordPair] = []
for raw in text.split(whereSeparator: \.isWhitespace) {
let segment = String(raw)
let segPairs = pairsForSegment(segment)
// Reattach a leading whitespace separator to the first new pair
// so the surface reads naturally when concatenated. We don't
// actually reinsert spaces callers can join with spaces but
// we do rejoin punctuation that ended up segment-leading with
// no anchor word (rare, e.g. a stray "") to the previous word.
if segPairs.isEmpty {
if !pairs.isEmpty {
pairs[pairs.count - 1].surface += segment
}
continue
}
pairs.append(contentsOf: segPairs)
}
return pairs
}
/// Convert one whitespace-bounded segment to (surface, cleaned) pairs.
/// For non-Han segments: a single pair with surface = segment, cleaned
/// = letters/numbers only. For segments containing Han ideographs:
/// each Han is its own pair; consecutive non-Han runs become their own
/// pair if they contain any letter/number, or attach to the
/// neighbouring pair's surface if they are pure punctuation/symbols.
private static func pairsForSegment(_ seg: String) -> [WordPair] {
let hasHan = seg.unicodeScalars.contains(where: isHanIdeograph)
if !hasHan {
let cleaned = cleanToken(seg)
if cleaned.isEmpty { return [] }
return [WordPair(surface: seg, cleaned: cleaned)]
}
var pairs: [WordPair] = []
var nonHanBuf = ""
func flushNonHan(beforeHan: Bool) {
guard !nonHanBuf.isEmpty else { return }
let cleaned = cleanToken(nonHanBuf)
if cleaned.isEmpty {
// Pure punctuation: attach to the previous pair's trailing
// surface. If we're at the start with no previous pair,
// leave the buffer for the upcoming Han to absorb.
if !pairs.isEmpty {
pairs[pairs.count - 1].surface += nonHanBuf
nonHanBuf = ""
} else if !beforeHan {
// Trailing pure-punct with no anchor at all drop.
nonHanBuf = ""
}
return
}
pairs.append(WordPair(surface: nonHanBuf, cleaned: cleaned))
nonHanBuf = ""
}
for scalar in seg.unicodeScalars {
if isHanIdeograph(scalar) {
flushNonHan(beforeHan: true)
let han = String(scalar)
if !nonHanBuf.isEmpty {
// Leading pure-punct waiting for a Han anchor.
pairs.append(WordPair(surface: nonHanBuf + han, cleaned: han))
nonHanBuf = ""
} else {
pairs.append(WordPair(surface: han, cleaned: han))
}
} else {
nonHanBuf.append(Character(scalar))
}
}
flushNonHan(beforeHan: false)
return pairs
}
// MARK: - Legacy entry points (kept for tests / external callers)
static func tokenizeJapanese(_ text: String) -> [String] {
return nlTokenizePairs(text, language: .japanese).map { $0.cleaned }
}
static func tokenizeKorean(_ text: String) -> [String] {
return nlTokenizePairs(text, language: .korean).map { $0.cleaned }
}
static func tokenizeSpaceLang(_ text: String) -> [String] {
return tokenizeSpaceLangPairs(text).map { $0.cleaned }
}
// MARK: - Cleaning + classification
/// Keep only Unicode Letters (`L*`), Numbers (`N*`), and ASCII apostrophe.
/// Strips punctuation (e.g. the full-width period ``), symbols,
/// separators, and marks.
static func cleanToken(_ token: String) -> String {
var out = ""
for scalar in token.unicodeScalars {
if isKeptScalar(scalar) {
out.unicodeScalars.append(scalar)
}
}
return out
}
private static func isKeptScalar(_ scalar: Unicode.Scalar) -> Bool {
if scalar == "'" { return true }
let cat = scalar.properties.generalCategory
switch cat {
case .uppercaseLetter, .lowercaseLetter, .titlecaseLetter,
.modifierLetter, .otherLetter,
.decimalNumber, .letterNumber, .otherNumber,
// Combining marks are essential for scripts like Thai, Lao,
// Khmer, Burmese, Tibetan, Devanagari, Bengali, Arabic harakat
// stripping them mangles the word (e.g. "" "").
.nonspacingMark, .spacingMark, .enclosingMark:
return true
default:
return false
}
}
/// Han ideograph ranges only. Notably **excludes** hiragana
/// (0x30400x309F), katakana (0x30A00x30FF), and Hangul syllables
/// (0xAC000xD7AF), which are handled by the language-specific
/// tokenizers (Japanese / Korean) instead.
static func isHanIdeograph(_ scalar: Unicode.Scalar) -> Bool {
let v = scalar.value
if v >= 0x4E00 && v <= 0x9FFF { return true } // CJK Unified
if v >= 0x3400 && v <= 0x4DBF { return true } // Extension A
if v >= 0x20000 && v <= 0x2A6DF { return true } // Extension B
if v >= 0x2A700 && v <= 0x2B73F { return true } // Extension C
if v >= 0x2B740 && v <= 0x2B81F { return true } // Extension D
if v >= 0x2B820 && v <= 0x2CEAF { return true } // Extension E
if v >= 0xF900 && v <= 0xFAFF { return true } // Compatibility
return false
}
}
@@ -0,0 +1,145 @@
import Foundation
/// Monotonicity correction for forced alignment timestamps using LIS
public enum TimestampCorrection {
/// Enforce monotonically increasing timestamps via LIS + interpolation.
///
/// 1. Find Longest Increasing Subsequence of raw timestamp indices (O(n log n))
/// 2. For positions not in LIS:
/// - Small gaps (<=2): nearest-neighbor correction
/// - Larger gaps: linear interpolation between LIS anchors
///
/// - Parameter rawIndices: Raw timestamp class indices from argmax
/// - Returns: Corrected monotonically increasing indices
public static func enforceMonotonicity(_ rawIndices: [Int]) -> [Int] {
guard rawIndices.count > 1 else { return rawIndices }
// Find LIS positions
let lisPositions = longestIncreasingSubsequencePositions(rawIndices)
let lisSet = Set(lisPositions)
// Build anchor points: (position_in_array, value)
var anchors: [(pos: Int, val: Int)] = []
for pos in lisPositions {
anchors.append((pos, rawIndices[pos]))
}
// If LIS covers everything, already monotonic
if anchors.count == rawIndices.count {
return rawIndices
}
var corrected = rawIndices
// Fill gaps between anchors
var anchorIdx = 0
var i = 0
while i < corrected.count {
if lisSet.contains(i) {
// This position is an anchor, keep it
anchorIdx = anchors.firstIndex(where: { $0.pos == i }) ?? anchorIdx
i += 1
continue
}
// Find surrounding anchors
let prevAnchor: (pos: Int, val: Int)?
let nextAnchor: (pos: Int, val: Int)?
if anchorIdx < anchors.count && anchors[anchorIdx].pos < i {
prevAnchor = anchors[anchorIdx]
} else if anchorIdx > 0 {
prevAnchor = anchors[anchorIdx - 1]
} else {
prevAnchor = nil
}
// Find next anchor after position i
var nextIdx = anchorIdx
while nextIdx < anchors.count && anchors[nextIdx].pos <= i {
nextIdx += 1
}
nextAnchor = nextIdx < anchors.count ? anchors[nextIdx] : nil
// Interpolate
if let prev = prevAnchor, let next = nextAnchor {
let gapSize = next.pos - prev.pos
if gapSize <= 3 {
// Small gap: nearest neighbor
let distToPrev = i - prev.pos
let distToNext = next.pos - i
corrected[i] = distToPrev <= distToNext ? prev.val : next.val
} else {
// Linear interpolation
let t = Float(i - prev.pos) / Float(next.pos - prev.pos)
corrected[i] = prev.val + Int(t * Float(next.val - prev.val))
}
} else if let prev = prevAnchor {
// After last anchor: clamp to last anchor value
corrected[i] = prev.val
} else if let next = nextAnchor {
// Before first anchor: clamp to first anchor value
corrected[i] = next.val
}
i += 1
}
// Final pass: ensure strict monotonicity
for i in 1..<corrected.count {
if corrected[i] < corrected[i - 1] {
corrected[i] = corrected[i - 1]
}
}
return corrected
}
/// Find positions of the Longest Increasing Subsequence (O(n log n))
static func longestIncreasingSubsequencePositions(_ arr: [Int]) -> [Int] {
guard !arr.isEmpty else { return [] }
let n = arr.count
// tails[i] = smallest tail element for increasing subsequence of length i+1
var tails: [Int] = []
// tailIndices[i] = index in arr where tails[i] comes from
var tailIndices: [Int] = []
// parent[i] = index of previous element in LIS ending at arr[i]
var parent = [Int](repeating: -1, count: n)
for i in 0..<n {
// Binary search for position to insert arr[i]
var lo = 0, hi = tails.count
while lo < hi {
let mid = (lo + hi) / 2
if tails[mid] < arr[i] {
lo = mid + 1
} else {
hi = mid
}
}
if lo == tails.count {
tails.append(arr[i])
tailIndices.append(i)
} else {
tails[lo] = arr[i]
tailIndices[lo] = i
}
parent[i] = lo > 0 ? tailIndices[lo - 1] : -1
}
// Reconstruct LIS positions
var positions: [Int] = []
var idx = tailIndices[tails.count - 1]
while idx != -1 {
positions.append(idx)
idx = parent[idx]
}
positions.reverse()
return positions
}
}
@@ -0,0 +1,321 @@
import Foundation
import MLXCommon
import MLX
import MLXNN
import AudioCommon
/// Weight loading utilities for Qwen3-ASR
/// Uses direct HuggingFace key paths model structure must match exactly.
///
/// All loaders stream weights per safetensors shard rather than accumulating
/// every tensor from every file into a single `[String: MLXArray]` dict before
/// applying. This keeps transient load-time peak memory at roughly
/// `model_size + one_shard` instead of `model_size + checkpoint_size` (the
/// shards plus the assembled dict were duplicating the entire model in RAM
/// during load observed ~1.52.0 GB peak on 1.7B). `Module.update(parameters:)`
/// is documented as partial-safe (mlx-swift `Module.swift:423`: "any omitted
/// values will be unchanged"), so applying every component against every shard
/// is correct even when a single layer's tensors are split across files.
public enum WeightLoader {
/// Load weights from safetensors file
public static func loadSafetensors(url: URL) throws -> [String: MLXArray] {
try CommonWeightLoader.loadSafetensors(url: url)
}
/// Load and apply weights to model using HuggingFace key paths directly.
/// Streams per shard see file docstring.
public static func loadWeights(
into audioEncoder: Qwen3AudioEncoder,
from directory: URL
) throws {
let files = try safetensorFiles(in: directory)
print("Found \(files.count) safetensor files")
var appliedTotal = 0
for file in files {
print("Loading: \(file.lastPathComponent)")
let raw = try loadSafetensors(url: file)
let audioTowerWeights = stripPrefix(raw, prefix: "audio_tower.")
if audioTowerWeights.isEmpty { continue }
applyAudioEncoderComponents(
to: audioEncoder, weights: audioTowerWeights,
transposeConv2dPyTorch: false)
appliedTotal += audioTowerWeights.count
// `raw` and `audioTowerWeights` go out of scope here; their
// MLXArray references release once each call to
// `update(parameters:)` above has adopted the tensors the
// model actually needed.
}
print("Applied weights to audio encoder (\(audioEncoder.layers.count) layers, \(appliedTotal) tensors)")
}
/// Load and apply weights to quantized text decoder. Per-shard streaming.
public static func loadTextDecoderWeights(
into textModel: QuantizedTextModel,
from directory: URL
) throws {
let files = try safetensorFiles(in: directory)
var appliedTotal = 0
for file in files {
let raw = try loadSafetensors(url: file)
let textWeights = stripPrefix(raw, prefix: "model.")
if textWeights.isEmpty { continue }
applyQuantizedTextDecoderComponents(to: textModel, weights: textWeights)
appliedTotal += textWeights.count
}
print("Applied weights to text decoder (\(textModel.layers.count) layers, \(appliedTotal) tensors)")
}
// MARK: - Forced Aligner Weight Loading
/// Load weights for the forced aligner model. Per-shard streaming.
///
/// Weight key structure (under optional `thinker.` prefix):
/// - `audio_tower.*` audio encoder
/// - `model.*` text decoder (quantized or float)
/// - `lm_head.*` classify head (Linear, NOT quantized)
public static func loadForcedAlignerWeights(
into model: Qwen3ForcedAligner,
from directory: URL
) throws {
let files = try safetensorFiles(in: directory)
var audioApplied = 0
var textApplied = 0
var headApplied = 0
for file in files {
print("Loading: \(file.lastPathComponent)")
let raw = try loadSafetensors(url: file)
// Strip `thinker.` if present so downstream prefix-strip logic
// sees a uniform key space.
let normalized = stripPrefix(raw, prefix: "thinker.", keepUnprefixed: true)
let audioTowerWeights = stripPrefix(normalized, prefix: "audio_tower.")
if !audioTowerWeights.isEmpty {
applyAudioEncoderComponents(
to: model.audioEncoder, weights: audioTowerWeights,
transposeConv2dPyTorch: true)
audioApplied += audioTowerWeights.count
}
let textWeights = stripPrefix(normalized, prefix: "model.")
if !textWeights.isEmpty {
if let quantized = model.textDecoder as? QuantizedTextModel {
applyQuantizedTextDecoderComponents(to: quantized, weights: textWeights)
} else if let floatModel = model.textDecoder as? FloatTextModel {
applyFloatTextDecoderComponents(to: floatModel, weights: textWeights)
}
textApplied += textWeights.count
}
let classifyWeights = filterPrefix(normalized, keepingPrefix: "lm_head.")
if !classifyWeights.isEmpty {
CommonWeightLoader.applyLinearWeights(
to: model.classifyHead, prefix: "lm_head", from: classifyWeights)
headApplied += classifyWeights.count
}
}
print("Audio tower: \(audioApplied), Text decoder: \(textApplied), Classify head: \(headApplied)")
print("Applied audio encoder weights (\(model.audioEncoder.layers.count) layers)")
if let quantized = model.textDecoder as? QuantizedTextModel {
print("Applied quantized text decoder weights (\(quantized.layers.count) layers)")
} else if let floatModel = model.textDecoder as? FloatTextModel {
print("Applied float text decoder weights (\(floatModel.layers.count) layers)")
}
print("Applied classify head weights")
}
// MARK: - Shard discovery + prefix filter helpers
private static func safetensorFiles(in directory: URL) throws -> [URL] {
let fileManager = FileManager.default
let contents = try fileManager.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil)
let files = contents.filter { $0.pathExtension == "safetensors" }
guard !files.isEmpty else {
throw WeightLoadingError.noWeightsFound(directory)
}
// Sort lexicographically so the load order is deterministic across
// runs purely cosmetic for the logs, but it makes regression
// diffs against captured stderr stable.
return files.sorted { $0.lastPathComponent < $1.lastPathComponent }
}
/// Return a new dict containing only keys with `prefix`, with the prefix
/// stripped off. `keepUnprefixed: true` retains keys that DON'T have the
/// prefix (used by the forced aligner where `thinker.` is optional).
private static func stripPrefix(
_ weights: [String: MLXArray],
prefix: String,
keepUnprefixed: Bool = false
) -> [String: MLXArray] {
var out: [String: MLXArray] = [:]
out.reserveCapacity(weights.count)
for (key, value) in weights {
if key.hasPrefix(prefix) {
out[String(key.dropFirst(prefix.count))] = value
} else if keepUnprefixed {
out[key] = value
}
}
return out
}
/// Return a new dict containing only keys with `keepingPrefix`,
/// preserving the prefix on the kept keys.
private static func filterPrefix(
_ weights: [String: MLXArray],
keepingPrefix: String
) -> [String: MLXArray] {
var out: [String: MLXArray] = [:]
for (key, value) in weights where key.hasPrefix(keepingPrefix) {
out[key] = value
}
return out
}
// MARK: - Per-shard component application
/// Apply every audio-encoder component slot against `weights`. Components
/// whose tensors aren't present in this shard are no-ops (each
/// `applyWeights` helper guards its `weights[key]` lookups).
private static func applyAudioEncoderComponents(
to audioEncoder: Qwen3AudioEncoder,
weights: [String: MLXArray],
transposeConv2dPyTorch: Bool
) {
applyConv2dWeights(to: audioEncoder.conv2d1, prefix: "conv2d1", from: weights, transposePyTorch: transposeConv2dPyTorch)
applyConv2dWeights(to: audioEncoder.conv2d2, prefix: "conv2d2", from: weights, transposePyTorch: transposeConv2dPyTorch)
applyConv2dWeights(to: audioEncoder.conv2d3, prefix: "conv2d3", from: weights, transposePyTorch: transposeConv2dPyTorch)
CommonWeightLoader.applyLinearWeights(to: audioEncoder.convOut, prefix: "conv_out", from: weights)
CommonWeightLoader.applyLayerNormWeights(to: audioEncoder.lnPost, prefix: "ln_post", from: weights)
CommonWeightLoader.applyLinearWeights(to: audioEncoder.proj1, prefix: "proj1", from: weights)
CommonWeightLoader.applyLinearWeights(to: audioEncoder.proj2, prefix: "proj2", from: weights)
for (index, layer) in audioEncoder.layers.enumerated() {
applyEncoderLayerWeights(to: layer, prefix: "layers.\(index)", from: weights)
}
}
private static func applyQuantizedTextDecoderComponents(
to textModel: QuantizedTextModel,
weights: [String: MLXArray]
) {
CommonWeightLoader.applyQuantizedEmbeddingWeights(
to: textModel.embedTokens, prefix: "embed_tokens", from: weights)
CommonWeightLoader.applyRMSNormWeights(
to: textModel.norm, prefix: "norm", from: weights)
for (index, layer) in textModel.layers.enumerated() {
applyQuantizedDecoderLayerWeights(
to: layer, prefix: "layers.\(index)", from: weights)
}
}
private static func applyFloatTextDecoderComponents(
to textModel: FloatTextModel,
weights: [String: MLXArray]
) {
CommonWeightLoader.applyEmbeddingWeights(
to: textModel.embedTokens, prefix: "embed_tokens", from: weights)
CommonWeightLoader.applyRMSNormWeights(
to: textModel.norm, prefix: "norm", from: weights)
for (index, layer) in textModel.layers.enumerated() {
applyFloatDecoderLayerWeights(
to: layer, prefix: "layers.\(index)", from: weights)
}
}
// MARK: - ASR-specific Weight Application Helpers
private static func applyQuantizedDecoderLayerWeights(
to layer: QuantizedTextDecoderLayer,
prefix: String,
from weights: [String: MLXArray]
) {
// Self attention
CommonWeightLoader.applyQuantizedLinearWeights(to: layer.selfAttn.qProj, prefix: "\(prefix).self_attn.q_proj", from: weights)
CommonWeightLoader.applyQuantizedLinearWeights(to: layer.selfAttn.kProj, prefix: "\(prefix).self_attn.k_proj", from: weights)
CommonWeightLoader.applyQuantizedLinearWeights(to: layer.selfAttn.vProj, prefix: "\(prefix).self_attn.v_proj", from: weights)
CommonWeightLoader.applyQuantizedLinearWeights(to: layer.selfAttn.oProj, prefix: "\(prefix).self_attn.o_proj", from: weights)
// Q/K norms
CommonWeightLoader.applyRMSNormWeights(to: layer.selfAttn.qNorm, prefix: "\(prefix).self_attn.q_norm", from: weights)
CommonWeightLoader.applyRMSNormWeights(to: layer.selfAttn.kNorm, prefix: "\(prefix).self_attn.k_norm", from: weights)
// Layer norms
CommonWeightLoader.applyRMSNormWeights(to: layer.inputLayerNorm, prefix: "\(prefix).input_layernorm", from: weights)
CommonWeightLoader.applyRMSNormWeights(to: layer.postAttentionLayerNorm, prefix: "\(prefix).post_attention_layernorm", from: weights)
// MLP
CommonWeightLoader.applyQuantizedLinearWeights(to: layer.mlp.gateProj, prefix: "\(prefix).mlp.gate_proj", from: weights)
CommonWeightLoader.applyQuantizedLinearWeights(to: layer.mlp.upProj, prefix: "\(prefix).mlp.up_proj", from: weights)
CommonWeightLoader.applyQuantizedLinearWeights(to: layer.mlp.downProj, prefix: "\(prefix).mlp.down_proj", from: weights)
}
private static func applyFloatDecoderLayerWeights(
to layer: FloatTextDecoderLayer,
prefix: String,
from weights: [String: MLXArray]
) {
CommonWeightLoader.applyLinearWeights(to: layer.selfAttn.qProj, prefix: "\(prefix).self_attn.q_proj", from: weights)
CommonWeightLoader.applyLinearWeights(to: layer.selfAttn.kProj, prefix: "\(prefix).self_attn.k_proj", from: weights)
CommonWeightLoader.applyLinearWeights(to: layer.selfAttn.vProj, prefix: "\(prefix).self_attn.v_proj", from: weights)
CommonWeightLoader.applyLinearWeights(to: layer.selfAttn.oProj, prefix: "\(prefix).self_attn.o_proj", from: weights)
CommonWeightLoader.applyRMSNormWeights(to: layer.selfAttn.qNorm, prefix: "\(prefix).self_attn.q_norm", from: weights)
CommonWeightLoader.applyRMSNormWeights(to: layer.selfAttn.kNorm, prefix: "\(prefix).self_attn.k_norm", from: weights)
CommonWeightLoader.applyRMSNormWeights(to: layer.inputLayerNorm, prefix: "\(prefix).input_layernorm", from: weights)
CommonWeightLoader.applyRMSNormWeights(to: layer.postAttentionLayerNorm, prefix: "\(prefix).post_attention_layernorm", from: weights)
CommonWeightLoader.applyLinearWeights(to: layer.mlp.gateProj, prefix: "\(prefix).mlp.gate_proj", from: weights)
CommonWeightLoader.applyLinearWeights(to: layer.mlp.upProj, prefix: "\(prefix).mlp.up_proj", from: weights)
CommonWeightLoader.applyLinearWeights(to: layer.mlp.downProj, prefix: "\(prefix).mlp.down_proj", from: weights)
}
// MARK: - Audio Encoder Weight Helpers
private static func applyConv2dWeights(
to conv: Conv2d,
prefix: String,
from weights: [String: MLXArray],
transposePyTorch: Bool = false
) {
var params: [String: NestedItem<String, MLXArray>] = [:]
if let weight = weights["\(prefix).weight"] {
if transposePyTorch {
// PyTorch Conv2d: [outC, inC, kH, kW] -> MLX Conv2d: [outC, kH, kW, inC]
params["weight"] = .value(weight.transposed(0, 2, 3, 1))
} else {
params["weight"] = .value(weight)
}
}
if let bias = weights["\(prefix).bias"] {
params["bias"] = .value(bias)
}
if !params.isEmpty {
conv.update(parameters: ModuleParameters(values: params))
}
}
private static func applyEncoderLayerWeights(
to layer: AudioEncoderLayer,
prefix: String,
from weights: [String: MLXArray]
) {
// Self attention
CommonWeightLoader.applyLinearWeights(to: layer.selfAttn.qProj, prefix: "\(prefix).self_attn.q_proj", from: weights)
CommonWeightLoader.applyLinearWeights(to: layer.selfAttn.kProj, prefix: "\(prefix).self_attn.k_proj", from: weights)
CommonWeightLoader.applyLinearWeights(to: layer.selfAttn.vProj, prefix: "\(prefix).self_attn.v_proj", from: weights)
CommonWeightLoader.applyLinearWeights(to: layer.selfAttn.outProj, prefix: "\(prefix).self_attn.out_proj", from: weights)
// Layer norms
CommonWeightLoader.applyLayerNormWeights(to: layer.selfAttnLayerNorm, prefix: "\(prefix).self_attn_layer_norm", from: weights)
CommonWeightLoader.applyLayerNormWeights(to: layer.finalLayerNorm, prefix: "\(prefix).final_layer_norm", from: weights)
// FFN
CommonWeightLoader.applyLinearWeights(to: layer.fc1, prefix: "\(prefix).fc1", from: weights)
CommonWeightLoader.applyLinearWeights(to: layer.fc2, prefix: "\(prefix).fc2", from: weights)
}
}
@@ -0,0 +1,107 @@
import Foundation
/// Standalone token sampler for text generation.
///
/// Supports temperature scaling, top-K filtering, top-P (nucleus) filtering,
/// and repetition penalty.
enum ChatSampler {
/// Sample a token from logits using the given sampling config.
///
/// - Parameters:
/// - logits: Raw logits array of size vocab_size
/// - config: Sampling parameters (temperature, topK, topP, repetitionPenalty)
/// - previousTokens: Recently generated tokens for repetition penalty
/// - Returns: Sampled token index
static func sample(
logits: [Float],
config: ChatSamplingConfig,
previousTokens: [Int] = []
) -> Int {
var logits = logits
// Repetition penalty
if config.repetitionPenalty > 1.0 {
let seen = Set(previousTokens.suffix(64))
for tokenId in seen {
if tokenId < logits.count {
if logits[tokenId] > 0 {
logits[tokenId] /= config.repetitionPenalty
} else {
logits[tokenId] *= config.repetitionPenalty
}
}
}
}
// Greedy (argmax) when temperature is 0
if config.temperature <= 0 {
var maxIdx = 0
var maxVal = logits[0]
for i in 1..<logits.count {
if logits[i] > maxVal {
maxVal = logits[i]
maxIdx = i
}
}
return maxIdx
}
// Temperature scaling
if config.temperature != 1.0 {
for i in 0..<logits.count {
logits[i] /= config.temperature
}
}
// Softmax
let maxLogit = logits.max() ?? 0
var probs = logits.map { exp($0 - maxLogit) }
let sum = probs.reduce(0, +)
probs = probs.map { $0 / sum }
// Top-K filtering
if config.topK > 0 && config.topK < probs.count {
let indexed = probs.enumerated().sorted { $0.element > $1.element }
let topK = Array(indexed.prefix(config.topK))
var filtered = [Float](repeating: 0, count: probs.count)
for (idx, prob) in topK {
filtered[idx] = prob
}
let filteredSum = filtered.reduce(0, +)
if filteredSum > 0 {
probs = filtered.map { $0 / filteredSum }
}
}
// Top-P (nucleus) filtering
if config.topP < 1.0 {
let indexed = probs.enumerated().sorted { $0.element > $1.element }
var cumProb: Float = 0
var mask = [Bool](repeating: false, count: probs.count)
for (idx, prob) in indexed {
cumProb += prob
mask[idx] = true
if cumProb >= config.topP { break }
}
for i in 0..<probs.count {
if !mask[i] { probs[i] = 0 }
}
let filteredSum = probs.reduce(0, +)
if filteredSum > 0 {
probs = probs.map { $0 / filteredSum }
}
}
// Sample from distribution
let r = Float.random(in: 0..<1)
var cumulative: Float = 0
for (i, p) in probs.enumerated() {
cumulative += p
if cumulative >= r {
return i
}
}
return probs.count - 1
}
}
@@ -0,0 +1,104 @@
import Foundation
/// Chat message for Qwen3.5 models.
public struct ChatMessage: Sendable {
public enum Role: String, Sendable {
case system
case user
case assistant
}
public let role: Role
public let content: String
public init(role: Role, content: String) {
self.role = role
self.content = content
}
}
/// Formats messages into Qwen3.5 chat template tokens.
///
/// ```
/// <|im_start|>system
/// {system_message}<|im_end|>
/// <|im_start|>user
/// {user_message}<|im_end|>
/// <|im_start|>assistant
/// ```
enum ChatTemplate {
// Qwen3.5 special token IDs (248K vocab)
static let imStartId = 248045 // <|im_start|>
static let imEndId = 248046 // <|im_end|>
static let endOfTextId = 248044 // <|endoftext|>
static let thinkStartId = 248068 // <think>
static let thinkEndId = 248069 // </think>
static let newlineId = 198 // \n
/// Strip thinking block from generated tokens.
///
/// Removes tokens from `<think>` through `</think>` (inclusive)
/// and any trailing newlines, returning only the response content.
static func stripThinking(from tokens: [Int]) -> [Int] {
let thinkTokens: Set<Int> = [thinkStartId, thinkEndId]
let newlines: Set<Int> = [newlineId, 271] // 198 = \n, 271 = \n\n
guard let startIdx = tokens.firstIndex(where: { thinkTokens.contains($0) && $0 == thinkStartId }) else {
// No <think> strip any leading </think> + newlines
// (happens when non-thinking template causes model to echo end-think)
var i = 0
while i < tokens.count && (tokens[i] == thinkEndId || newlines.contains(tokens[i])) {
i += 1
}
return i > 0 ? Array(tokens[i...]) : tokens
}
if let endIdx = tokens[startIdx...].firstIndex(of: thinkEndId) {
var afterThink = endIdx + 1
while afterThink < tokens.count && newlines.contains(tokens[afterThink]) {
afterThink += 1
}
return Array(tokens[0..<startIdx]) + Array(tokens[afterThink...])
}
return Array(tokens[0..<startIdx])
}
/// Encode a conversation into token IDs using Qwen3.5 chat template.
///
/// - Parameters:
/// - config: Model config (for future extensibility)
/// - enableThinking: If false, injects empty think block to skip reasoning
static func encode(
messages: [ChatMessage],
tokenizer: ChatTokenizer,
config: Qwen3ChatConfig? = nil,
addGenerationPrompt: Bool = true,
enableThinking: Bool = true
) -> [Int] {
var tokens: [Int] = []
for message in messages {
tokens.append(imStartId)
tokens.append(contentsOf: tokenizer.encode(message.role.rawValue))
tokens.append(newlineId)
tokens.append(contentsOf: tokenizer.encode(message.content))
tokens.append(imEndId)
tokens.append(newlineId)
}
if addGenerationPrompt {
tokens.append(imStartId)
tokens.append(contentsOf: tokenizer.encode("assistant"))
tokens.append(newlineId)
if !enableThinking {
let doubleNewline = tokenizer.encode("\n\n")
tokens.append(thinkStartId)
tokens.append(contentsOf: doubleNewline)
tokens.append(thinkEndId)
tokens.append(contentsOf: doubleNewline)
}
}
return tokens
}
}
@@ -0,0 +1,272 @@
import Foundation
/// Tokenizer for Qwen3 chat models.
///
/// Loads vocabulary from HuggingFace tokenizer files and provides
/// encode/decode functionality for chat text.
public final class ChatTokenizer: @unchecked Sendable {
private var idToToken: [Int: String] = [:]
private var tokenToId: [String: Int] = [:]
private var bpeMerges: [(String, String)] = []
private var bpeMergeRanks: [String: Int] = [:]
private var addedTokens: [String: Int] = [:]
public var eosTokenId: Int = 248046 // <|im_end|>
public var vocabSize: Int { idToToken.count }
public init() {}
/// Load tokenizer from a directory.
///
/// Supports two formats:
/// 1. `tokenizer.json` (HuggingFace format, preferred) contains vocab, merges, and added tokens
/// 2. `vocab.json` + `merges.txt` (legacy) separate files
public func load(from directory: URL) throws {
let tokenizerJsonURL = directory.appendingPathComponent("tokenizer.json")
let vocabURL = directory.appendingPathComponent("vocab.json")
if FileManager.default.fileExists(atPath: tokenizerJsonURL.path) {
try loadFromTokenizerJson(from: tokenizerJsonURL)
} else {
try loadVocab(from: vocabURL)
let mergesURL = directory.appendingPathComponent("merges.txt")
if FileManager.default.fileExists(atPath: mergesURL.path) {
try loadMerges(from: mergesURL)
}
}
let configURL = directory.appendingPathComponent("tokenizer_config.json")
if FileManager.default.fileExists(atPath: configURL.path) {
try loadAddedTokens(from: configURL)
}
}
/// Load from HuggingFace tokenizer.json (contains vocab + merges + added tokens).
private func loadFromTokenizerJson(from url: URL) throws {
let data = try Data(contentsOf: url)
guard let root = try JSONSerialization.jsonObject(with: data) as? [String: Any],
let model = root["model"] as? [String: Any],
let vocab = model["vocab"] as? [String: Int] else {
throw ChatModelError.tokenizerLoadFailed("Invalid tokenizer.json format")
}
tokenToId = vocab
idToToken = Dictionary(uniqueKeysWithValues: vocab.map { ($1, $0) })
// Load merges
if let merges = model["merges"] as? [[String]] {
for (i, pair) in merges.enumerated() {
guard pair.count == 2 else { continue }
bpeMerges.append((pair[0], pair[1]))
bpeMergeRanks["\(pair[0]) \(pair[1])"] = i
}
} else if let merges = model["merges"] as? [String] {
// Alternative format: each merge as "a b" string
for (i, merge) in merges.enumerated() {
let parts = merge.split(separator: " ", maxSplits: 1)
guard parts.count == 2 else { continue }
let pair = (String(parts[0]), String(parts[1]))
bpeMerges.append(pair)
bpeMergeRanks["\(pair.0) \(pair.1)"] = i
}
}
// Load added tokens
if let addedList = root["added_tokens"] as? [[String: Any]] {
for entry in addedList {
guard let content = entry["content"] as? String,
let id = entry["id"] as? Int else { continue }
addedTokens[content] = id
tokenToId[content] = id
idToToken[id] = content
}
}
}
private func loadVocab(from url: URL) throws {
let data = try Data(contentsOf: url)
guard let vocab = try JSONSerialization.jsonObject(with: data) as? [String: Int] else {
throw ChatModelError.tokenizerLoadFailed("Invalid vocab.json format")
}
tokenToId = vocab
idToToken = Dictionary(uniqueKeysWithValues: vocab.map { ($1, $0) })
}
private func loadMerges(from url: URL) throws {
let content = try String(contentsOf: url, encoding: .utf8)
let lines = content.components(separatedBy: "\n")
for (i, line) in lines.enumerated() {
if line.hasPrefix("#") || line.isEmpty { continue }
let parts = line.split(separator: " ", maxSplits: 1)
if parts.count == 2 {
let pair = (String(parts[0]), String(parts[1]))
bpeMerges.append(pair)
bpeMergeRanks["\(pair.0) \(pair.1)"] = i
}
}
}
private func loadAddedTokens(from url: URL) throws {
let data = try Data(contentsOf: url)
guard let config = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return
}
if let added = config["added_tokens_decoder"] as? [String: Any] {
for (idStr, value) in added {
guard let id = Int(idStr),
let info = value as? [String: Any],
let content = info["content"] as? String else { continue }
addedTokens[content] = id
tokenToId[content] = id
idToToken[id] = content
}
}
}
// MARK: - Encode
/// Encode text to token IDs using BPE.
public func encode(_ text: String) -> [Int] {
if text.isEmpty { return [] }
// Check if it's a special/added token
if let id = addedTokens[text] ?? tokenToId[text] {
return [id]
}
// Simple BPE encoding
var words = tokenizeToWords(text)
var allTokens: [Int] = []
for word in words {
let wordTokens = bpeEncode(word)
allTokens.append(contentsOf: wordTokens)
}
return allTokens
}
/// Split text into BPE-ready words (GPT-2/Qwen style).
private func tokenizeToWords(_ text: String) -> [String] {
// Simplified: split on spaces, prefix non-first words with Ġ (space marker)
var words: [String] = []
var isFirst = true
for part in text.components(separatedBy: " ") {
if part.isEmpty { continue }
if isFirst {
words.append(part)
isFirst = false
} else {
words.append("Ġ" + part)
}
}
return words
}
/// BPE encode a single word.
private func bpeEncode(_ word: String) -> [Int] {
if let id = tokenToId[word] {
return [id]
}
var symbols = word.map { String($0) }
if symbols.isEmpty { return [] }
while symbols.count > 1 {
// Find best merge
var bestRank = Int.max
var bestIdx = -1
for i in 0..<(symbols.count - 1) {
let pair = "\(symbols[i]) \(symbols[i + 1])"
if let rank = bpeMergeRanks[pair], rank < bestRank {
bestRank = rank
bestIdx = i
}
}
if bestIdx < 0 { break }
// Apply merge
let merged = symbols[bestIdx] + symbols[bestIdx + 1]
symbols.replaceSubrange(bestIdx...bestIdx + 1, with: [merged])
}
// Look up token IDs
return symbols.compactMap { tokenToId[$0] }
}
// MARK: - Decode
/// Decode token IDs to text.
///
/// Uses byte-level BPE decoding: token strings are mapped back to bytes
/// via the GPT-2 byte-to-unicode table, then assembled into UTF-8 text.
public func decode(_ tokenIds: [Int]) -> String {
let pieces = tokenIds.compactMap { idToToken[$0] }
let joined = pieces.joined()
return decodeBPEString(joined)
}
/// Decode a single token ID.
public func decodeToken(_ tokenId: Int) -> String? {
guard let piece = idToToken[tokenId] else { return nil }
return decodeBPEString(piece)
}
/// Convert a BPE token string to UTF-8 text.
///
/// GPT-2/Qwen byte-level BPE represents each byte as a specific Unicode
/// character. This reverses that mapping and decodes the bytes as UTF-8.
private func decodeBPEString(_ bpeString: String) -> String {
var bytes: [UInt8] = []
for char in bpeString {
if let byte = Self.unicodeToByte[char] {
bytes.append(byte)
}
}
return String(bytes: bytes, encoding: .utf8) ?? bpeString
}
/// GPT-2 byte-to-unicode mapping (reversed for decoding).
///
/// Maps Unicode characters back to the byte values they represent in
/// GPT-2/Qwen byte-level BPE vocabulary.
private static let unicodeToByte: [Character: UInt8] = {
// Build the standard GPT-2 bytes_to_unicode table
var byteToUnicode: [UInt8: Character] = [:]
var n = 0
// Printable ASCII + Latin supplement ranges that map to themselves
let ranges: [ClosedRange<UInt8>] = [
33...126, // ! through ~
161...172, // ¡ through ¬
174...255, // ® through ÿ
]
for range in ranges {
for b in range {
byteToUnicode[b] = Character(Unicode.Scalar(UInt32(b))!)
}
}
// Remaining bytes (0-32, 127-160, 173) map to 256+n
for b: UInt16 in 0...255 {
if byteToUnicode[UInt8(b)] == nil {
byteToUnicode[UInt8(b)] = Character(Unicode.Scalar(256 + UInt32(n))!)
n += 1
}
}
// Reverse the mapping: unicode char byte value
var result: [Character: UInt8] = [:]
for (byte, char) in byteToUnicode {
result[char] = byte
}
return result
}()
/// Check if a token ID is a special token (should not appear in output).
public func isSpecialToken(_ tokenId: Int) -> Bool {
guard let token = idToToken[tokenId] else { return false }
return token.hasPrefix("<|") && token.hasSuffix("|>")
}
}
@@ -0,0 +1,425 @@
import Foundation
import MLXCommon
import MLX
import MLXNN
import AudioCommon
import os.log
private let log = OSLog(subsystem: "com.soniqo.qwen3chat", category: "MLX")
// MARK: - MLX Generator for Qwen3.5 Chat
/// MLX-based text generator for Qwen3.5-0.8B hybrid model.
///
/// Uses MLX for GPU inference
/// on Apple Silicon GPUs. The hybrid DeltaNet + GatedAttention architecture
/// requires managing two types of state:
/// 1. DeltaNet recurrent states (carried across all tokens, O(1) per layer)
/// 2. GatedAttention KV caches (grow with sequence length, only 6 layers)
///
/// Usage:
/// ```swift
/// let model = try await Qwen35MLXChat.fromPretrained()
/// let response = try model.generate(messages: [
/// ChatMessage(role: .user, content: "Hello!")
/// ])
/// ```
public final class Qwen35MLXChat: @unchecked Sendable {
public static let defaultModelId = "aufklarer/Qwen3.5-0.8B-Chat-MLX"
public let config: Qwen3ChatConfig
public let tokenizer: ChatTokenizer
let model: Qwen35MLXModel
var state: Qwen35MLXModel.InferenceState
var _isLoaded = true
// MARK: - Metrics
/// Generation metrics for performance tracking.
public struct Metrics {
public var prefillTimeMs: Double = 0
public var prefillTokens: Int = 0
public var decodeTimeMs: Double = 0
public var decodeTokens: Int = 0
public var tokensPerSecond: Double {
guard decodeTimeMs > 0 else { return 0 }
return Double(decodeTokens) / (decodeTimeMs / 1000.0)
}
public var msPerToken: Double {
guard decodeTokens > 0 else { return 0 }
return decodeTimeMs / Double(decodeTokens)
}
public var prefillTokensPerSecond: Double {
guard prefillTimeMs > 0 else { return 0 }
return Double(prefillTokens) / (prefillTimeMs / 1000.0)
}
}
private(set) var metrics = Metrics()
/// Latest generation metrics.
public var lastMetrics: (tokensPerSec: Double, prefillMs: Double, decodeMs: Double, msPerToken: Double) {
(metrics.tokensPerSecond, metrics.prefillTimeMs, metrics.decodeTimeMs, metrics.msPerToken)
}
// MARK: - Init
private init(config: Qwen3ChatConfig, tokenizer: ChatTokenizer, model: Qwen35MLXModel) {
self.config = config
self.tokenizer = tokenizer
self.model = model
self.state = .initial(config: config)
}
// MARK: - Factory
/// Quantization variant.
public enum Quantization: String {
case int4
case int8
}
/// Load a pre-trained Qwen3.5 chat model from HuggingFace.
///
/// Downloads quantized safetensors and tokenizer on first use.
/// Model is loaded into MLX for GPU inference on Apple Silicon.
///
/// - Parameters:
/// - modelId: HuggingFace model ID (repo with int4/ and int8/ subdirs)
/// - quantization: INT4 (404 MB) or INT8 (763 MB)
/// - progressHandler: Optional callback for download/load progress
public static func fromPretrained(
modelId: String = defaultModelId,
quantization: Quantization = .int4,
cacheDir: URL? = nil,
offlineMode: Bool = false,
progressHandler: ((Double, String) -> Void)? = nil
) async throws -> Qwen35MLXChat {
let cacheDir = try cacheDir ?? HuggingFaceDownloader.getCacheDirectory(for: modelId)
let variant = quantization.rawValue
// Download model files from variant subdirectory (int4/ or int8/)
progressHandler?(0.05, "Downloading \(variant) model...")
try await HuggingFaceDownloader.downloadWeights(
modelId: modelId,
to: cacheDir,
additionalFiles: [
"\(variant)/model.safetensors",
"\(variant)/config.json",
"\(variant)/tokenizer.json",
"\(variant)/tokenizer_config.json",
],
offlineMode: offlineMode,
progressHandler: { progress in
progressHandler?(progress * 0.5, "Downloading...")
}
)
// Variant files are in a subdirectory
let variantDir = cacheDir.appendingPathComponent(variant)
// Load config
progressHandler?(0.5, "Loading config...")
let config: Qwen3ChatConfig
let configURL = variantDir.appendingPathComponent("config.json")
if FileManager.default.fileExists(atPath: configURL.path) {
config = try Qwen3ChatConfig.load(from: configURL)
} else {
config = .qwen35_08B
}
// Load tokenizer
progressHandler?(0.55, "Loading tokenizer...")
let tokenizer = ChatTokenizer()
try tokenizer.load(from: variantDir)
// Create model
progressHandler?(0.6, "Creating model...")
let model = Qwen35MLXModel(config: config)
// Load weights
progressHandler?(0.65, "Loading weights...")
try Qwen35WeightLoader.loadWeights(
into: model, from: variantDir,
progressHandler: { pct, msg in
progressHandler?(0.65 + pct * 0.3, msg)
})
progressHandler?(1.0, "Ready")
return Qwen35MLXChat(config: config, tokenizer: tokenizer, model: model)
}
/// Download tokenizer + weight files only does not load MLX/Metal.
public static func downloadWeightsOnly(
modelId: String = defaultModelId,
quantization: Quantization = .int4,
cacheDir: URL? = nil,
registry: ModelRegistry = .huggingFace(),
progressHandler: ((Double, String) -> Void)? = nil
) async throws {
let cacheDir = try cacheDir ?? HuggingFaceDownloader.getCacheDirectory(for: modelId)
let variant = quantization.rawValue
progressHandler?(0.0, "Downloading \(variant) model...")
let files = [
"\(variant)/model.safetensors",
"\(variant)/config.json",
"\(variant)/tokenizer.json",
"\(variant)/tokenizer_config.json",
]
switch registry {
case .huggingFace(let hubEndpoint):
try await HuggingFaceDownloader.downloadFiles(
modelId: modelId,
to: cacheDir,
files: files,
hubEndpoint: hubEndpoint,
progressHandler: { progress in
progressHandler?(progress, "Downloading...")
}
)
case .modelScope(let baseURL, let revision):
try await ModelScopeDownloader.downloadFiles(
modelId: modelId,
to: cacheDir,
files: files,
baseURL: baseURL,
revision: revision,
progressHandler: { progress in
progressHandler?(progress, "Downloading...")
}
)
}
progressHandler?(1.0, "Downloaded")
}
/// Load from a local directory (no HuggingFace download).
public static func fromLocal(
directory: URL,
progressHandler: ((Double, String) -> Void)? = nil
) async throws -> Qwen35MLXChat {
let config: Qwen3ChatConfig
let configURL = directory.appendingPathComponent("config.json")
if FileManager.default.fileExists(atPath: configURL.path) {
config = try Qwen3ChatConfig.load(from: configURL)
} else {
config = .qwen35_08B
}
let tokenizer = ChatTokenizer()
try tokenizer.load(from: directory)
let model = Qwen35MLXModel(config: config)
try Qwen35WeightLoader.loadWeights(
into: model, from: directory,
progressHandler: progressHandler)
return Qwen35MLXChat(config: config, tokenizer: tokenizer, model: model)
}
// MARK: - State Management
/// Reset all inference state for a new conversation.
public func resetState() {
state = .initial(config: config)
metrics = Metrics()
}
// MARK: - Generation
/// Generate a response from chat messages.
///
/// Encodes the messages using the chat template, prefills the prompt,
/// then generates tokens autoregressively until EOS or max tokens.
public func generate(
messages: [ChatMessage],
sampling: ChatSamplingConfig = .default
) throws -> String {
resetState()
let promptTokens = ChatTemplate.encode(
messages: messages,
tokenizer: tokenizer,
config: config,
enableThinking: false)
// Prefill
let prefillStart = CFAbsoluteTimeGetCurrent()
let promptArray = MLXArray(promptTokens.map { Int32($0) })
.expandedDimensions(axis: 0)
let (prefillLogits, prefillState) = model.forward(inputIds: promptArray, state: state)
eval(prefillLogits)
state = prefillState
let prefillMs = (CFAbsoluteTimeGetCurrent() - prefillStart) * 1000
metrics.prefillTimeMs = prefillMs
metrics.prefillTokens = promptTokens.count
// Extract last-position logits and sample first token
var logits = extractLastPositionLogits(prefillLogits)
var generatedTokens: [Int] = []
var inThinking = false
let thinkBudget = 100
// Decode loop
let decodeStart = CFAbsoluteTimeGetCurrent()
for _ in 0..<(sampling.maxTokens + thinkBudget) {
let nextToken = ChatSampler.sample(
logits: logits,
config: sampling,
previousTokens: promptTokens + generatedTokens)
if nextToken == config.eosTokenId { break }
if nextToken == ChatTemplate.imEndId { break }
generatedTokens.append(nextToken)
// Thinking token tracking (handle both Qwen3 and Qwen3.5 token IDs)
if nextToken == ChatTemplate.thinkStartId {
inThinking = true
} else if nextToken == ChatTemplate.thinkEndId {
inThinking = false
}
if inThinking && generatedTokens.count > thinkBudget {
let thinkEnd = ChatTemplate.thinkEndId
generatedTokens.append(thinkEnd)
let tokenArr = MLXArray([Int32(thinkEnd)])
.expandedDimensions(axis: 0)
let (stepLogits, newState) = model.forward(inputIds: tokenArr, state: state)
eval(stepLogits)
state = newState
logits = extractLastPositionLogits(stepLogits)
inThinking = false
continue
}
let thinkTokens: Set<Int> = [
ChatTemplate.thinkStartId, ChatTemplate.thinkEndId
]
let responseCount = generatedTokens.filter { !thinkTokens.contains($0) }.count
if !inThinking && responseCount >= sampling.maxTokens { break }
// Decode one step
let tokenArr = MLXArray([Int32(nextToken)]).expandedDimensions(axis: 0)
let (stepLogits, newState) = model.forward(inputIds: tokenArr, state: state)
eval(stepLogits)
state = newState
logits = extractLastPositionLogits(stepLogits)
}
let decodeMs = (CFAbsoluteTimeGetCurrent() - decodeStart) * 1000
metrics.decodeTimeMs = decodeMs
metrics.decodeTokens = generatedTokens.count
var memInfo = mach_task_basic_info()
var memCount = mach_msg_type_number_t(MemoryLayout<mach_task_basic_info>.size) / 4
_ = withUnsafeMutablePointer(to: &memInfo) {
$0.withMemoryRebound(to: integer_t.self, capacity: Int(memCount)) {
task_info(mach_task_self_, task_flavor_t(MACH_TASK_BASIC_INFO), $0, &memCount)
}
}
let memMB = Double(memInfo.resident_size) / 1024 / 1024
let tps = decodeMs > 0 ? Double(generatedTokens.count) / (decodeMs / 1000.0) : 0
os_log(.info, log: log,
"Generate done: prefill=%.0fms (%d tokens), decode=%.0fms (%d tokens, %.1f tok/s), memory=%.0f MB",
metrics.prefillTimeMs, promptTokens.count, decodeMs, generatedTokens.count, tps, memMB)
let responseTokens = ChatTemplate.stripThinking(from: generatedTokens)
return tokenizer.decode(responseTokens)
}
/// Generate a streaming response.
public func generateStream(
messages: [ChatMessage],
sampling: ChatSamplingConfig = .default
) -> AsyncThrowingStream<String, Error> {
AsyncThrowingStream { continuation in
Task {
self.resetState()
let promptTokens = ChatTemplate.encode(
messages: messages,
tokenizer: self.tokenizer,
config: self.config,
enableThinking: false)
let promptArray = MLXArray(promptTokens.map { Int32($0) })
.expandedDimensions(axis: 0)
let (prefillLogits, prefillState) = self.model.forward(
inputIds: promptArray, state: self.state)
eval(prefillLogits)
self.state = prefillState
var logits = self.extractLastPositionLogits(prefillLogits)
var generatedTokens: [Int] = []
var inThinking = false
for _ in 0..<sampling.maxTokens {
let nextToken = ChatSampler.sample(
logits: logits,
config: sampling,
previousTokens: promptTokens + generatedTokens)
if nextToken == self.config.eosTokenId { break }
if nextToken == ChatTemplate.imEndId { break }
generatedTokens.append(nextToken)
if nextToken == ChatTemplate.thinkStartId {
inThinking = true
} else if nextToken == ChatTemplate.thinkEndId {
inThinking = false
} else if !inThinking,
let text = self.tokenizer.decodeToken(nextToken),
!self.tokenizer.isSpecialToken(nextToken) {
continuation.yield(text)
}
let tokenArr = MLXArray([Int32(nextToken)])
.expandedDimensions(axis: 0)
let (stepLogits, newState) = self.model.forward(
inputIds: tokenArr, state: self.state)
eval(stepLogits)
self.state = newState
logits = self.extractLastPositionLogits(stepLogits)
}
continuation.finish()
}
}
}
// MARK: - Helpers
/// Extract logits for the last sequence position as a Float array.
private func extractLastPositionLogits(_ logits: MLXArray) -> [Float] {
let t = logits.dim(1)
let lastPos = logits[0, t - 1].asType(.float32) // [vocabSize]
eval(lastPos)
// Bulk extract all floats at once do NOT use per-element .item() (248K syncs)
let all: [Float] = lastPos.asArray(Float.self)
return Array(all.prefix(config.vocabSize))
}
}
// MARK: - Memory Management
extension Qwen35MLXChat: ModelMemoryManageable {
public var isLoaded: Bool { _isLoaded }
public func unload() {
guard _isLoaded else { return }
model.clearParameters()
state = .initial(config: config)
_isLoaded = false
}
public var memoryFootprint: Int {
guard _isLoaded else { return 0 }
return model.parameterMemoryBytes()
}
}
@@ -0,0 +1,381 @@
import CoreML
import Foundation
import AudioCommon
import os.log
private let log = OSLog(subsystem: "com.soniqo.qwen3chat", category: "CoreML")
/// CoreML-based Qwen3.5-0.8B chat for iOS Neural Engine.
///
/// Uses two CoreML models:
/// - `embedding.mlmodelc` token ID embedding vector
/// - `decoder.mlmodelc` autoregressive transformer with MLState
///
/// All DeltaNet recurrent states and GatedAttention KV caches are managed
/// by CoreML's MLState API no manual cache tracking needed.
public final class Qwen35CoreMLChat: @unchecked Sendable {
public static let defaultModelId = "aufklarer/Qwen3.5-0.8B-Chat-CoreML"
private let embeddingModel: MLModel
private let decoderModel: MLModel
private var decoderState: MLState
public let config: Qwen3ChatConfig
public let tokenizer: ChatTokenizer
private var position: Int = 0
private let maxSeqLen: Int
/// Quantization variant. Only INT8 available (INT4 removed CoreML dequantization issues).
public enum Quantization: String { case int8 }
// MARK: - Metrics
private var _prefillMs: Double = 0
private var _decodeMs: Double = 0
private var _decodeTokens: Int = 0
public var lastMetrics: (tokensPerSec: Double, prefillMs: Double, decodeMs: Double, msPerToken: Double) {
let tps = _decodeMs > 0 ? Double(_decodeTokens) / (_decodeMs / 1000.0) : 0
let mpt = _decodeTokens > 0 ? _decodeMs / Double(_decodeTokens) : 0
return (tps, _prefillMs, _decodeMs, mpt)
}
/// Current process memory in MB.
private static var memoryMB: Double {
var info = mach_task_basic_info()
var count = mach_msg_type_number_t(MemoryLayout<mach_task_basic_info>.size) / 4
let result = withUnsafeMutablePointer(to: &info) {
$0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) {
task_info(mach_task_self_, task_flavor_t(MACH_TASK_BASIC_INFO), $0, &count)
}
}
guard result == KERN_SUCCESS else { return 0 }
return Double(info.resident_size) / 1024 / 1024
}
// MARK: - Init
private init(embedding: MLModel, decoder: MLModel, state: MLState,
config: Qwen3ChatConfig, tokenizer: ChatTokenizer, maxSeqLen: Int) {
self.embeddingModel = embedding
self.decoderModel = decoder
self.decoderState = state
self.config = config
self.tokenizer = tokenizer
self.maxSeqLen = maxSeqLen
}
// MARK: - Factory
/// Load from HuggingFace.
public static func fromPretrained(
modelId: String = defaultModelId,
quantization: Quantization = .int8,
computeUnits: MLComputeUnits = .cpuAndNeuralEngine,
cacheDir: URL? = nil,
offlineMode: Bool = false,
progressHandler: ((Double, String) -> Void)? = nil
) async throws -> Qwen35CoreMLChat {
let cacheDir = try cacheDir ?? HuggingFaceDownloader.getCacheDirectory(for: modelId)
let variant = quantization.rawValue
progressHandler?(0.05, "Downloading \(variant) model...")
// Fetch the pre-compiled ``.mlmodelc`` bundle only. On-device
// ``MLModel.compileModel`` drifts per runtime, and the legacy
// ``.mlpackage`` internals (``*.mlmodel`` / ``Manifest.json``) would
// force that code path. Users with a stale ``.mlpackage`` cache
// transparently re-download because ``.mlmodelc`` is missing.
try await HuggingFaceDownloader.downloadWeights(
modelId: modelId,
to: cacheDir,
additionalFiles: [
"\(variant)/*.json",
"\(variant)/embedding.mlmodelc/**",
"\(variant)/decoder.mlmodelc/**",
],
offlineMode: offlineMode,
progressHandler: { p in progressHandler?(p * 0.5, "Downloading...") }
)
let variantDir = cacheDir.appendingPathComponent(variant)
return try await fromLocal(directory: variantDir, computeUnits: computeUnits,
progressHandler: progressHandler)
}
/// Load from a local directory.
public static func fromLocal(
directory: URL,
computeUnits: MLComputeUnits = .cpuAndNeuralEngine,
progressHandler: ((Double, String) -> Void)? = nil
) async throws -> Qwen35CoreMLChat {
progressHandler?(0.5, "Loading config...")
// Debug: list directory contents to diagnose missing file issues
os_log(.info, log: log, "Loading from directory: %{public}@", directory.path)
if let contents = try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil) {
for item in contents {
os_log(.info, log: log, " %{public}@", item.lastPathComponent)
}
} else {
os_log(.error, log: log, "Cannot list directory: %{public}@", directory.path)
}
// Use built-in config chat_config.json from CoreML conversion has a different schema
let config = Qwen3ChatConfig.qwen35_08B
os_log(.info, log: log, "Using built-in Qwen3.5-0.8B config")
progressHandler?(0.55, "Loading tokenizer...")
os_log(.info, log: log, "Loading tokenizer from: %{public}@", directory.resolvingSymlinksInPath().path)
let tokenizer = ChatTokenizer()
try tokenizer.load(from: directory.resolvingSymlinksInPath())
let memBefore = memoryMB
os_log(.info, log: log, "Loading CoreML models, memory before: %.0f MB", memBefore)
progressHandler?(0.6, "Compiling embedding...")
let embModel: MLModel
do {
embModel = try await loadModel(named: "embedding", from: directory, computeUnits: computeUnits)
os_log(.info, log: log, "Embedding loaded, memory: %.0f MB", memoryMB)
} catch {
os_log(.error, log: log, "Embedding load FAILED: %{public}@", error.localizedDescription)
throw error
}
progressHandler?(0.75, "Compiling decoder...")
let decModel: MLModel
do {
decModel = try await loadModel(named: "decoder", from: directory, computeUnits: computeUnits)
os_log(.info, log: log, "Decoder loaded, memory: %.0f MB", memoryMB)
} catch {
os_log(.error, log: log, "Decoder load FAILED: %{public}@", error.localizedDescription)
throw error
}
let state = decModel.makeState()
let maxSeq = config.maxSeqLen
os_log(.info, log: log, "Model ready, total memory: %.0f MB (delta: +%.0f MB)",
memoryMB, memoryMB - memBefore)
progressHandler?(1.0, "Ready")
return Qwen35CoreMLChat(
embedding: embModel, decoder: decModel, state: state,
config: config, tokenizer: tokenizer, maxSeqLen: maxSeq)
}
// MARK: - Model Loading Helpers
private static func loadModel(
named name: String, from dir: URL, computeUnits: MLComputeUnits
) async throws -> MLModel {
let compiledURL = dir.appendingPathComponent("\(name).mlmodelc")
guard FileManager.default.fileExists(atPath: compiledURL.path) else {
os_log(.error, log: log,
"Model not found: %{public}@.mlmodelc in %{public}@",
name, dir.path)
throw ChatModelError.modelNotFound(dir)
}
let mlConfig = MLModelConfiguration()
mlConfig.computeUnits = CoreMLComputeUnitsResolver.resolved(default: computeUnits)
return try await MLModel.load(contentsOf: compiledURL, configuration: mlConfig)
}
// MARK: - Generation
/// Reset state for a new conversation.
public func resetState() {
decoderState = decoderModel.makeState()
position = 0
_prefillMs = 0; _decodeMs = 0; _decodeTokens = 0
}
/// Generate a response from chat messages.
public func generate(
messages: [ChatMessage],
sampling: ChatSamplingConfig = .default
) throws -> String {
resetState()
let promptTokens = ChatTemplate.encode(
messages: messages, tokenizer: tokenizer,
config: config, enableThinking: false)
// Prefill: feed all prompt tokens one at a time
let prefillStart = CFAbsoluteTimeGetCurrent()
var lastLogits: [Float] = []
for token in promptTokens {
lastLogits = try forwardStep(tokenId: token)
}
_prefillMs = (CFAbsoluteTimeGetCurrent() - prefillStart) * 1000
// Decode loop
let decodeStart = CFAbsoluteTimeGetCurrent()
var generatedTokens: [Int] = []
for _ in 0..<sampling.maxTokens {
let nextToken = ChatSampler.sample(
logits: lastLogits, config: sampling,
previousTokens: promptTokens + generatedTokens)
if nextToken == config.eosTokenId { break }
if nextToken == ChatTemplate.imEndId { break }
generatedTokens.append(nextToken)
if generatedTokens.count <= 10 {
print("[CoreML] token[\(generatedTokens.count-1)]: \(nextToken) '\(tokenizer.decodeToken(nextToken) ?? "?")'")
}
lastLogits = try forwardStep(tokenId: nextToken)
}
_decodeMs = (CFAbsoluteTimeGetCurrent() - decodeStart) * 1000
_decodeTokens = generatedTokens.count
let tps = _decodeMs > 0 ? Double(_decodeTokens) / (_decodeMs / 1000.0) : 0
os_log(.info, log: log,
"Generate done: prefill=%.0fms (%d tokens), decode=%.0fms (%d tokens, %.1f tok/s), memory=%.0f MB",
_prefillMs, promptTokens.count, _decodeMs, _decodeTokens, tps, Self.memoryMB)
let responseTokens = ChatTemplate.stripThinking(from: generatedTokens)
let responseText = tokenizer.decode(responseTokens)
os_log(.info, log: log, "Response (%d tokens → %d after strip): '%{public}@'",
generatedTokens.count, responseTokens.count, responseText)
return responseText
}
/// Generate a streaming response.
public func generateStream(
messages: [ChatMessage],
sampling: ChatSamplingConfig = .default
) -> AsyncThrowingStream<String, Error> {
AsyncThrowingStream { continuation in
Task {
do {
self.resetState()
let promptTokens = ChatTemplate.encode(
messages: messages, tokenizer: self.tokenizer,
config: self.config, enableThinking: false)
var lastLogits: [Float] = []
for token in promptTokens {
lastLogits = try self.forwardStep(tokenId: token)
}
var generatedTokens: [Int] = []
var inThinking = false
let thinkBudget = 100
let thinkTokens: Set<Int> = [
ChatTemplate.thinkStartId, ChatTemplate.thinkEndId
]
for _ in 0..<(sampling.maxTokens + thinkBudget) {
let nextToken = ChatSampler.sample(
logits: lastLogits, config: sampling,
previousTokens: promptTokens + generatedTokens)
if nextToken == self.config.eosTokenId { break }
if nextToken == ChatTemplate.imEndId { break }
generatedTokens.append(nextToken)
if nextToken == ChatTemplate.thinkStartId { inThinking = true }
else if nextToken == ChatTemplate.thinkEndId { inThinking = false }
else if !inThinking,
let text = self.tokenizer.decodeToken(nextToken),
!self.tokenizer.isSpecialToken(nextToken) {
continuation.yield(text)
}
// Force-end thinking if budget exceeded
if inThinking && generatedTokens.count > thinkBudget {
generatedTokens.append(ChatTemplate.thinkEndId)
lastLogits = try self.forwardStep(tokenId: ChatTemplate.thinkEndId)
inThinking = false
continue
}
// Only count non-thinking tokens against maxTokens
let responseCount = generatedTokens.filter { !thinkTokens.contains($0) }.count
if !inThinking && responseCount >= sampling.maxTokens { break }
lastLogits = try self.forwardStep(tokenId: nextToken)
}
continuation.finish()
} catch {
continuation.finish(throwing: error)
}
}
}
}
// MARK: - Single Step
/// Run one decoder step: token ID logits.
private func forwardStep(tokenId: Int) throws -> [Float] {
// Embedding lookup
let tokenInput = try MLMultiArray(shape: [1, 1], dataType: .int32)
tokenInput[0] = NSNumber(value: Int32(tokenId))
let embFeatures = try MLDictionaryFeatureProvider(dictionary: [
"token_id": MLFeatureValue(multiArray: tokenInput)
])
let embResult = try embeddingModel.prediction(from: embFeatures)
guard let embedding = embResult.featureValue(for: "embedding")?.multiArrayValue else {
throw ChatModelError.inferenceFailed("Embedding output missing")
}
// Build attention mask: 0 for positions current, -FLT_MAX for future
let mask = try MLMultiArray(shape: [1, 1, 1, maxSeqLen as NSNumber], dataType: .float32)
let maskPtr = mask.dataPointer.bindMemory(to: Float.self, capacity: maxSeqLen)
for i in 0..<maxSeqLen {
maskPtr[i] = i <= position ? 0 : -Float.greatestFiniteMagnitude
}
// Position
let posArray = try MLMultiArray(shape: [1], dataType: .int32)
posArray[0] = NSNumber(value: Int32(position))
// Decoder forward
let decoderInput = try MLDictionaryFeatureProvider(dictionary: [
"input_embeds": MLFeatureValue(multiArray: embedding),
"position": MLFeatureValue(multiArray: posArray),
"attention_mask": MLFeatureValue(multiArray: mask),
])
let result = try decoderModel.prediction(
from: decoderInput, using: decoderState)
position += 1
// Extract logits
guard let logitsArray = result.featureValue(for: "logits")?.multiArrayValue else {
throw ChatModelError.inferenceFailed("Decoder output missing")
}
let vocabSize = config.vocabSize
// Debug: log dtype and shape on first few calls
if position <= 3 {
print("[CoreML] pos=\(position) logits shape=\(logitsArray.shape) dtype=\(logitsArray.dataType.rawValue) count=\(logitsArray.count)")
}
// Handle both Float16 and Float32 output
let logits: [Float]
if logitsArray.dataType == .float32 {
let ptr = logitsArray.dataPointer.bindMemory(to: Float.self, capacity: vocabSize)
logits = Array(UnsafeBufferPointer(start: ptr, count: vocabSize))
} else {
let ptr = logitsArray.dataPointer.bindMemory(to: Float16.self, capacity: vocabSize)
logits = (0..<vocabSize).map { Float(ptr[$0]) }
}
return logits
}
}
// MARK: - Memory Management
extension Qwen35CoreMLChat: ModelMemoryManageable {
public var isLoaded: Bool { true }
public func unload() { /* CoreML manages its own memory */ }
public var memoryFootprint: Int { 500 * 1024 * 1024 } // ~500 MB estimate
}
@@ -0,0 +1,710 @@
import Foundation
import MLXCommon
import MLX
import MLXNN
import MLXFast
// MARK: - DeltaNet Linear Attention
/// DeltaNet linear attention layer for Qwen3.5 hybrid model.
///
/// Uses linear attention (no softmax) with a recurrent state matrix S of shape [B, H, D, D].
/// The state evolves per-token: S = alpha * S + beta * (v outer k), where alpha/beta
/// are learned per-head scalar gates derived from the input via softplus/sigmoid.
///
/// A causal conv1d (kernel=4) provides short-range local context before the attention.
/// Output is gated: `o_proj(attention_output * silu(z))` where both are 2*hiddenSize = numHeads*headDim.
///
/// Weight shapes (HuggingFace safetensors):
/// - in_proj_qkv.weight: [6144, 1024] (3 * 16 * 128)
/// - in_proj_z.weight: [2048, 1024] (2 * hiddenSize, for gate)
/// - in_proj_b.weight: [16, 1024] (beta gate, per head)
/// - in_proj_a.weight: [16, 1024] (alpha gate, per head)
/// - conv1d.weight: [6144, 4, 1] (depthwise causal conv, MLX [C, K, 1] format)
/// - dt_bias: [16] (time-step bias)
/// - A_log: [16] (log of decay rate)
/// - norm.weight: [128] (per-head RMSNorm)
/// - out_proj.weight: [1024, 2048] (gated output projection)
public final class DeltaNetLayer: Module {
let numHeads: Int
let headDim: Int
let hiddenSize: Int
let convKernel: Int
let qkvDim: Int
@ModuleInfo(key: "in_proj_qkv") var inProjQKV: QuantizedLinear
@ModuleInfo(key: "in_proj_z") var inProjZ: QuantizedLinear
@ModuleInfo(key: "in_proj_b") var inProjB: QuantizedLinear
@ModuleInfo(key: "in_proj_a") var inProjA: QuantizedLinear
/// Conv1d weight: [C, 1, K] depthwise convolution applied to QKV before attention.
/// Stored under a flat key to avoid nested key path issues in MLXNN module traversal.
/// Weight loading applies this directly via `layer.convWeight = ...`.
@ParameterInfo(key: "conv1d_weight") var convWeight: MLXArray
@ParameterInfo(key: "dt_bias") var dtBias: MLXArray
@ParameterInfo(key: "A_log") var aLog: MLXArray
@ModuleInfo var norm: RMSNorm
@ModuleInfo(key: "out_proj") var outProj: QuantizedLinear
public init(config: Qwen3ChatConfig) {
self.numHeads = config.linearNumKeyHeads ?? 16
self.headDim = config.linearKeyHeadDim ?? 128
self.hiddenSize = config.hiddenSize
self.convKernel = config.linearConvKernelDim ?? 4
self.qkvDim = 3 * numHeads * headDim
let groupSize = 64
let bits = 4
self._inProjQKV = ModuleInfo(wrappedValue: QuantizedLinear(hiddenSize, qkvDim, bias: false, groupSize: groupSize, bits: bits))
self._inProjZ = ModuleInfo(wrappedValue: QuantizedLinear(hiddenSize, 2 * hiddenSize, bias: false, groupSize: groupSize, bits: bits))
self._inProjB = ModuleInfo(wrappedValue: QuantizedLinear(hiddenSize, numHeads, bias: false, groupSize: groupSize, bits: bits))
self._inProjA = ModuleInfo(wrappedValue: QuantizedLinear(hiddenSize, numHeads, bias: false, groupSize: groupSize, bits: bits))
self._convWeight = ParameterInfo(
wrappedValue: MLXArray.zeros([qkvDim, convKernel, 1]))
self._dtBias = ParameterInfo(wrappedValue: MLXArray.zeros([numHeads]))
self._aLog = ParameterInfo(wrappedValue: MLXArray.zeros([numHeads]))
self._norm = ModuleInfo(
wrappedValue: RMSNorm(dimensions: headDim, eps: Float(config.rmsNormEps)))
self._outProj = ModuleInfo(wrappedValue: QuantizedLinear(2 * hiddenSize, hiddenSize, bias: false, groupSize: groupSize, bits: bits))
super.init()
}
/// Recurrent state for a DeltaNet layer.
public struct State {
/// Recurrent state matrix [B, H, D, D]
var s: MLXArray
/// Conv1d ring buffer [B, C, K-1] storing last K-1 inputs
var convState: MLXArray
public static func initial(
batchSize: Int, numHeads: Int, headDim: Int,
qkvDim: Int, convKernel: Int, dtype: DType = .float32
) -> State {
State(
s: MLXArray.zeros([batchSize, numHeads, headDim, headDim], dtype: dtype),
convState: MLXArray.zeros([batchSize, qkvDim, convKernel - 1], dtype: dtype)
)
}
}
/// Forward pass processing a sequence of tokens.
///
/// Implements the gated delta rule recurrence (reference: mlx-lm/gated_delta.py):
/// 1. Decay: S = g * S
/// 2. Error: kv_mem = (S * k).sum(-1); delta = (v - kv_mem) * beta
/// 3. Update: S = S + k * delta
/// 4. Output: y = (S * q).sum(-1)
///
/// - Parameters:
/// - x: Input hidden states [B, T, hiddenSize]
/// - state: Previous recurrent state (nil for first call)
/// - Returns: (output [B, T, hiddenSize], updated state)
public func callAsFunction(_ x: MLXArray, state: State? = nil) -> (MLXArray, State) {
let b = x.dim(0)
let t = x.dim(1)
// Project inputs (separate projections matching HuggingFace weight format)
let qkvRaw = inProjQKV(x) // [B, T, 3*H*D=6144]
let zRaw = inProjZ(x) // [B, T, 2*hiddenSize=2048]
let bRaw = inProjB(x) // [B, T, H=16]
let aRaw = inProjA(x) // [B, T, H=16]
// Causal conv1d on QKV only (not Z, B, A)
let prevConvState: MLXArray
if let s = state {
prevConvState = s.convState
} else {
prevConvState = MLXArray.zeros([b, qkvDim, convKernel - 1], dtype: x.dtype)
}
let qkvTransposed = qkvRaw.transposed(0, 2, 1) // [B, C, T]
let padded = concatenated([prevConvState, qkvTransposed], axis: 2) // [B, C, T+K-1]
// Save new conv state (last K-1 columns)
let totalLen = padded.dim(2)
let newConvState = padded[0..., 0..., (totalLen - convKernel + 1)...]
// Apply depthwise causal conv1d + SiLU
let qkvConv = depthwiseConv1dCausal(padded, outputLen: t)
let qkvActivated = silu(qkvConv.transposed(0, 2, 1)) // [B, T, C]
// Split into Q, K, V each [B, T, H, D]
let hd = numHeads * headDim
var q = qkvActivated[0..., 0..., ..<hd].reshaped(b, t, numHeads, headDim)
var k = qkvActivated[0..., 0..., hd..<(2 * hd)].reshaped(b, t, numHeads, headDim)
let v = qkvActivated[0..., 0..., (2 * hd)...].reshaped(b, t, numHeads, headDim)
// Q/K normalization (reference: inv_scale * rms_norm, different scaling for Q and K)
// rms_norm(x, None, eps) = x / sqrt(mean(x^2) + eps)
// q = inv_scale^2 * rms_norm(q) where inv_scale = head_dim^(-0.5)
// k = inv_scale * rms_norm(k)
let invScale = Float(1.0) / sqrt(Float(headDim))
q = MLXArray(invScale * invScale) * rmsNormNoWeight(q)
k = MLXArray(invScale) * rmsNormNoWeight(k)
// Compute gating: g = exp(-exp(A_log) * softplus(a + dt_bias))
let g = computeDecayGate(aRaw: aRaw) // [B, T, H]
// beta = sigmoid(b_raw) (independent learned gate, NOT 1-alpha)
let beta = sigmoid(bRaw) // [B, T, H]
// Sequential gated delta rule recurrence
var currentS: MLXArray
if let s = state {
currentS = s.s
} else {
currentS = MLXArray.zeros([b, numHeads, headDim, headDim], dtype: x.dtype)
}
var outputSteps: [MLXArray] = []
outputSteps.reserveCapacity(t)
for step in 0..<t {
// Extract step: [B, H, D] or [B, H]
let qStep = q[0..., step..<(step + 1), 0..., 0...].squeezed(axis: 1) // [B, H, D]
let kStep = k[0..., step..<(step + 1), 0..., 0...].squeezed(axis: 1) // [B, H, D]
let vStep = v[0..., step..<(step + 1), 0..., 0...].squeezed(axis: 1) // [B, H, D]
let gStep = g[0..., step..<(step + 1), 0...].squeezed(axis: 1) // [B, H]
let betaStep = beta[0..., step..<(step + 1), 0...].squeezed(axis: 1) // [B, H]
// 1. Decay: S = g * S (g is scalar per-head: [B, H, 1, 1])
let decay = gStep.reshaped(b, numHeads, 1, 1)
currentS = currentS * decay
// 2. Error correction:
// kv_mem = (S * k[..., None, :]).sum(-1) [B, H, Dv]
// delta = (v - kv_mem) * beta[..., None] [B, H, Dv]
let kExpanded = kStep.expandedDimensions(axis: -2) // [B, H, 1, Dk]
let kvMem = (currentS * kExpanded).sum(axis: -1) // [B, H, Dv]
let delta = (vStep - kvMem) * betaStep.expandedDimensions(axis: -1) // [B, H, Dv]
// 3. Update: S = S + k[..., None, :] * delta[..., None]
// k: [B, H, Dk] [B, H, 1, Dk], delta: [B, H, Dv] [B, H, Dv, 1]
currentS = currentS + kExpanded * delta.expandedDimensions(axis: -1)
// 4. Output: y = (S * q[..., None, :]).sum(-1) [B, H, Dv]
let qExpanded = qStep.expandedDimensions(axis: -2) // [B, H, 1, Dk]
let oStep = (currentS * qExpanded).sum(axis: -1) // [B, H, Dv]
outputSteps.append(oStep)
}
// Stack: [B, T, H, D]
let output = stacked(outputSteps, axis: 1)
// RMSNormGated: norm(output) * silu(z)
// z has shape [B, T, 2*hiddenSize=2048], reshape to [B, T, H, D] for per-head norm
let zReshaped = zRaw.reshaped(b, t, numHeads, headDim)
let normedOutput = norm(output) // per-head RMSNorm, [B, T, H, D]
let gated = normedOutput * silu(zReshaped) // [B, T, H, D]
// Reshape to [B, T, H*D=2048] and project to hiddenSize
let result = outProj(gated.reshaped(b, t, numHeads * headDim)) // [B, T, 1024]
return (result, State(s: currentS, convState: newConvState))
}
/// Compute decay gate: g = exp(-exp(A_log) * softplus(a + dt_bias))
private func computeDecayGate(aRaw: MLXArray) -> MLXArray {
let a = aRaw + dtBias.reshaped(1, 1, numHeads)
let dt = softplus(a)
let negExpA = -exp(aLog.asType(.float32)).reshaped(1, 1, numHeads)
return exp(negExpA * dt.asType(.float32)).asType(aRaw.dtype)
}
/// RMS normalization without learnable weight (used for Q/K normalization).
private func rmsNormNoWeight(_ x: MLXArray) -> MLXArray {
let meanSq = (x * x).mean(axis: -1, keepDims: true)
return x * rsqrt(meanSq + MLXArray(Float(1e-6)))
}
// MARK: - Depthwise Conv1d
/// Depthwise causal conv1d via unfolding + element-wise multiply + sum.
/// - Parameter input: [B, C, T+K-1] (pre-padded with conv state)
/// - Parameter outputLen: number of output time steps T
/// - Returns: [B, C, T]
private func depthwiseConv1dCausal(_ input: MLXArray, outputLen: Int) -> MLXArray {
let c = input.dim(1)
let k = convKernel
// Unfold: gather windows of size K for each output position
var windows: [MLXArray] = []
windows.reserveCapacity(outputLen)
for t in 0..<outputLen {
windows.append(input[0..., 0..., t..<(t + k)]) // [B, C, K]
}
let unfolded = stacked(windows, axis: 2) // [B, C, T, K]
// Kernel: [C, K, 1] -> squeeze axis 2 -> [C, K] -> [1, C, 1, K]
let kernelBcast = convWeight.squeezed(axis: 2).reshaped(1, c, 1, k)
return (unfolded * kernelBcast).sum(axis: -1) // [B, C, T]
}
}
// MARK: - Softplus
private func softplus(_ x: MLXArray) -> MLXArray {
// Numerically stable: for large x, softplus(x) ~ x
MLX.where(x .> MLXArray(Float(20.0)), x, log(1 + exp(x)))
}
// MARK: - GatedAttention (Full Attention)
/// GatedAttention layer for Qwen3.5 hybrid model.
///
/// Standard multi-head attention with:
/// - GQA: 8 query heads, 2 KV heads, head_dim=256
/// - Partial RoPE: only first 25% of head_dim (64 dims) get rotary encoding
/// - QK norm: RMSNorm applied per-head to Q and K before RoPE
/// - Gated output: q_proj produces [Q; gate], both of dim numQHeads*headDim.
/// After attention, output is element-wise multiplied with silu(gate),
/// then projected through o_proj.
///
/// Weight shapes:
/// - q_proj: [4096, 1024] = [2 * numQHeads * headDim, hiddenSize] (Q + gate)
/// - k_proj: [512, 1024] = [numKVHeads * headDim, hiddenSize]
/// - v_proj: [512, 1024] = [numKVHeads * headDim, hiddenSize]
/// - o_proj: [1024, 2048] = [hiddenSize, numQHeads * headDim]
/// - q_norm: [256] = [headDim]
/// - k_norm: [256] = [headDim]
public final class GatedAttentionLayer: Module {
let numQHeads: Int
let numKVHeads: Int
let headDim: Int
let hiddenSize: Int
let scale: Float
let ropeDims: Int // partial RoPE dimensions
@ModuleInfo(key: "q_proj") var qProj: QuantizedLinear
@ModuleInfo(key: "k_proj") var kProj: QuantizedLinear
@ModuleInfo(key: "v_proj") var vProj: QuantizedLinear
@ModuleInfo(key: "o_proj") var oProj: QuantizedLinear
@ModuleInfo(key: "q_norm") var qNorm: RMSNorm
@ModuleInfo(key: "k_norm") var kNorm: RMSNorm
let rope: MLXNN.RoPE
public init(config: Qwen3ChatConfig) {
self.numQHeads = config.numAttentionHeads // 8
self.numKVHeads = config.numKeyValueHeads // 2
self.headDim = config.headDim // 256
self.hiddenSize = config.hiddenSize // 1024
self.scale = 1.0 / sqrt(Float(headDim))
let factor = config.partialRotaryFactor ?? 0.25
self.ropeDims = Int(Double(headDim) * factor) // 64
let groupSize = 64
let bits = 4
let qDim = numQHeads * headDim // 2048
// q_proj outputs 2 * qDim (Q + gate)
self._qProj = ModuleInfo(wrappedValue: QuantizedLinear(
hiddenSize, 2 * qDim, bias: false,
groupSize: groupSize, bits: bits))
self._kProj = ModuleInfo(wrappedValue: QuantizedLinear(
hiddenSize, numKVHeads * headDim, bias: false,
groupSize: groupSize, bits: bits))
self._vProj = ModuleInfo(wrappedValue: QuantizedLinear(
hiddenSize, numKVHeads * headDim, bias: false,
groupSize: groupSize, bits: bits))
// o_proj: qDim -> hiddenSize (after gating reduces 2*qDim to qDim)
self._oProj = ModuleInfo(wrappedValue: QuantizedLinear(
qDim, hiddenSize, bias: false,
groupSize: groupSize, bits: bits))
self._qNorm = ModuleInfo(wrappedValue: RMSNorm(dimensions: headDim, eps: Float(config.rmsNormEps)))
self._kNorm = ModuleInfo(wrappedValue: RMSNorm(dimensions: headDim, eps: Float(config.rmsNormEps)))
// Partial RoPE: only rotates first `ropeDims` of each head
self.rope = MLXNN.RoPE(
dimensions: ropeDims,
traditional: false,
base: Float(config.ropeTheta))
super.init()
}
/// Forward pass.
///
/// - Parameters:
/// - hiddenStates: [B, T, hiddenSize]
/// - cache: Optional (keys, values) from previous steps, each [B, H_kv, S, D]
/// - offset: RoPE position offset (used when cache is nil, e.g. first call)
/// - Returns: (output [B, T, hiddenSize], updated KV cache)
public func callAsFunction(
_ hiddenStates: MLXArray,
cache: (MLXArray, MLXArray)? = nil,
offset: Int = 0
) -> (MLXArray, (MLXArray, MLXArray)) {
let b = hiddenStates.dim(0)
let seqLen = hiddenStates.dim(1)
let qDim = numQHeads * headDim
// Q projection: [B, T, 2*qDim] reshape to [B, T, H, 2*D] split Q/gate INTERLEAVED per head
// CRITICAL: Must reshape BEFORE split (per Python reference).
// Interleaved format: for each head, first D dims are Q, next D are gate.
let qProjOut = qProj(hiddenStates) // [B, T, 4096 = 2*numQHeads*headDim]
let qProjReshaped = qProjOut.reshaped(b, seqLen, numQHeads, 2 * headDim)
let qgSplit = qProjReshaped.split(parts: 2, axis: -1)
var queries = qgSplit[0] // [B, T, H, D=256]
let gateSignal = qgSplit[1].reshaped(b, seqLen, qDim) // [B, T, 2048]
var keys = kProj(hiddenStates) // [B, T, numKVHeads * headDim]
var values = vProj(hiddenStates) // [B, T, numKVHeads * headDim]
// Reshape K/V to multi-head: [B, T, H, D]
keys = keys.reshaped(b, seqLen, numKVHeads, headDim)
values = values.reshaped(b, seqLen, numKVHeads, headDim)
// QK norm (per-head)
queries = qNorm(queries)
keys = kNorm(keys)
// Transpose to [B, H, T, D]
queries = queries.transposed(0, 2, 1, 3)
keys = keys.transposed(0, 2, 1, 3)
values = values.transposed(0, 2, 1, 3)
// Partial RoPE: MLXNN.RoPE with dimensions=ropeDims only rotates first ropeDims
let ropeOffset = cache?.0.dim(2) ?? offset
queries = rope(queries, offset: ropeOffset)
keys = rope(keys, offset: ropeOffset)
// Update KV cache
var cachedKeys = keys
var cachedValues = values
if let (prevK, prevV) = cache {
cachedKeys = concatenated([prevK, keys], axis: 2)
cachedValues = concatenated([prevV, values], axis: 2)
}
// Causal mask
let mask: MLXFast.ScaledDotProductAttentionMaskMode
if seqLen <= 1 && (cache != nil || offset > 0) {
mask = .none
} else {
let kvLen = cachedKeys.dim(2)
let pastLen = kvLen - seqLen
let causal = MLXArray.tri(seqLen, m: kvLen, k: pastLen, type: Float.self) - 1
let additiveMask = causal * Float.greatestFiniteMagnitude // 0 for attended, -FLT_MAX for masked
mask = .array(additiveMask.reshaped(1, 1, seqLen, kvLen).asType(queries.dtype))
}
// SDPA (handles GQA natively)
let attnOut = SDPA.attendAndMerge(
qHeads: queries, kHeads: cachedKeys, vHeads: cachedValues,
scale: scale, mask: mask)
// Gated output: attn_out * sigmoid(gate), then o_proj
// Reference: self.o_proj(output * mx.sigmoid(gate))
let gated = attnOut * sigmoid(gateSignal) // [B, T, qDim=2048]
let output = oProj(gated) // [B, T, hiddenSize=1024]
return (output, (cachedKeys, cachedValues))
}
}
// MARK: - Qwen3.5 Transformer Layer
/// A single transformer layer in the Qwen3.5 hybrid model.
///
/// Either a DeltaNet (linear_attention) or GatedAttention (full_attention) layer,
/// both sharing the same pre-norm structure and SwiGLU MLP.
///
/// The attention submodule is stored as the base `Module` type and registered
/// under the key `"self_attn"` via `@ModuleInfo`. This ensures the MLX Module
/// system discovers it for parameter traversal (`eval`, `clearParameters`, etc.)
/// and weight loading maps to the correct key path.
public final class Qwen35TransformerLayer: Module {
public let layerType: String
@ModuleInfo(key: "input_layernorm") var inputLayerNorm: RMSNorm
@ModuleInfo(key: "post_attention_layernorm") var postAttentionLayerNorm: RMSNorm
@ModuleInfo var mlp: Qwen35MLP
/// The attention submodule either DeltaNetLayer or GatedAttentionLayer.
/// Key is "linear_attn" for DeltaNet, "self_attn" for GatedAttention (HuggingFace convention).
@ModuleInfo var attn: Module
public init(config: Qwen3ChatConfig, layerType: String) {
self.layerType = layerType
self._inputLayerNorm = ModuleInfo(wrappedValue: RMSNorm(dimensions: config.hiddenSize, eps: Float(config.rmsNormEps)))
self._postAttentionLayerNorm = ModuleInfo(wrappedValue: RMSNorm(dimensions: config.hiddenSize, eps: Float(config.rmsNormEps)))
self._mlp = ModuleInfo(
wrappedValue: Qwen35MLP(config: config))
if layerType == "linear_attention" {
self._attn = ModuleInfo(
wrappedValue: DeltaNetLayer(config: config),
key: "linear_attn")
} else {
self._attn = ModuleInfo(
wrappedValue: GatedAttentionLayer(config: config),
key: "self_attn")
}
super.init()
}
/// Access the DeltaNet submodule (only valid for linear_attention layers).
public var deltaNet: DeltaNetLayer? { attn as? DeltaNetLayer }
/// Access the GatedAttention submodule (only valid for full_attention layers).
public var gatedAttn: GatedAttentionLayer? { attn as? GatedAttentionLayer }
/// Forward for DeltaNet (linear attention) layer.
public func forwardDeltaNet(
_ x: MLXArray,
state: DeltaNetLayer.State?
) -> (MLXArray, DeltaNetLayer.State) {
guard let dn = deltaNet else {
fatalError("forwardDeltaNet called on full_attention layer")
}
let normed = inputLayerNorm(x)
let (attnOut, newState) = dn(normed, state: state)
var h = x + attnOut
h = h + mlp(postAttentionLayerNorm(h))
return (h, newState)
}
/// Forward for GatedAttention (full attention) layer.
public func forwardGatedAttention(
_ x: MLXArray,
cache: (MLXArray, MLXArray)?,
offset: Int
) -> (MLXArray, (MLXArray, MLXArray)) {
guard let ga = gatedAttn else {
fatalError("forwardGatedAttention called on linear_attention layer")
}
let normed = inputLayerNorm(x)
let (attnOut, newCache) = ga(normed, cache: cache, offset: offset)
var h = x + attnOut
h = h + mlp(postAttentionLayerNorm(h))
return (h, newCache)
}
}
// MARK: - SwiGLU MLP
/// SwiGLU MLP for Qwen3.5 (quantized INT4).
public final class Qwen35MLP: Module {
@ModuleInfo(key: "gate_proj") var gateProj: QuantizedLinear
@ModuleInfo(key: "up_proj") var upProj: QuantizedLinear
@ModuleInfo(key: "down_proj") var downProj: QuantizedLinear
public init(config: Qwen3ChatConfig) {
let hs = config.hiddenSize
let is_ = config.intermediateSize
let gs = 64, bits = 4
self._gateProj = ModuleInfo(
wrappedValue: QuantizedLinear(hs, is_, bias: false, groupSize: gs, bits: bits),
key: "gate_proj")
self._upProj = ModuleInfo(
wrappedValue: QuantizedLinear(hs, is_, bias: false, groupSize: gs, bits: bits),
key: "up_proj")
self._downProj = ModuleInfo(
wrappedValue: QuantizedLinear(is_, hs, bias: false, groupSize: gs, bits: bits),
key: "down_proj")
super.init()
}
public func callAsFunction(_ x: MLXArray) -> MLXArray {
downProj(silu(gateProj(x)) * upProj(x))
}
}
// MARK: - Qwen3.5 Full Model
/// Qwen3.5-0.8B hybrid transformer with DeltaNet linear attention and GatedAttention.
///
/// Architecture: 24 layers in pattern [3x DeltaNet, 1x GatedAttention] x 6.
/// - DeltaNet layers (18 of 24): O(1) memory per step via recurrent state, no KV cache.
/// - GatedAttention layers (6 of 24): standard SDPA with KV cache, partial RoPE (25%).
/// - Tied embeddings: lm_head reuses embed_tokens weights (PreQuantizedEmbedding.asLinear).
///
/// This gives a favorable memory/compute tradeoff: recurrent DeltaNet layers handle
/// most computation with fixed memory, while sparse full attention layers provide
/// global context at every 4th layer.
public final class Qwen35MLXModel: Module {
public let config: Qwen3ChatConfig
public let layerTypes: [String]
public let fullAttentionIndices: [Int]
@ModuleInfo(key: "embed_tokens") var embedTokens: PreQuantizedEmbedding
@ModuleInfo var layers: [Qwen35TransformerLayer]
@ModuleInfo var norm: RMSNorm
public init(config: Qwen3ChatConfig) {
self.config = config
let types = config.layerTypes ?? Array(
repeating: "full_attention", count: config.numHiddenLayers)
self.layerTypes = types
self.fullAttentionIndices = types.enumerated().compactMap {
$0.element == "full_attention" ? $0.offset : nil
}
self._embedTokens = ModuleInfo(wrappedValue: PreQuantizedEmbedding(
embeddingCount: config.vocabSize,
dimensions: config.hiddenSize,
groupSize: 64, bits: 4))
self._layers = ModuleInfo(
wrappedValue: types.map { Qwen35TransformerLayer(config: config, layerType: $0) })
self._norm = ModuleInfo(
wrappedValue: RMSNorm(dimensions: config.hiddenSize, eps: Float(config.rmsNormEps)))
super.init()
}
// MARK: - Inference State
/// Combined inference state: DeltaNet recurrent states + GatedAttention KV caches.
public struct InferenceState {
/// Per-layer DeltaNet state (nil for full_attention layers).
public var deltaNetStates: [DeltaNetLayer.State?]
/// Per-layer KV cache (nil for linear_attention layers, and for full_attention
/// layers before any tokens have been processed).
public var kvCaches: [(MLXArray, MLXArray)?]
/// Current sequence position (for RoPE offset in GatedAttention layers).
public var position: Int
public static func initial(config: Qwen3ChatConfig, batchSize: Int = 1) -> InferenceState {
let types = config.layerTypes ?? Array(
repeating: "full_attention", count: config.numHiddenLayers)
let numHeads = config.linearNumKeyHeads ?? 16
let headDim = config.linearKeyHeadDim ?? 128
let qkvDim = 3 * numHeads * headDim
let convKernel = config.linearConvKernelDim ?? 4
return InferenceState(
deltaNetStates: types.map { type in
type == "linear_attention"
? DeltaNetLayer.State.initial(
batchSize: batchSize, numHeads: numHeads, headDim: headDim,
qkvDim: qkvDim, convKernel: convKernel)
: nil
},
kvCaches: types.map { _ in nil },
position: 0
)
}
}
// MARK: - Forward Pass
/// Forward pass through the full model.
///
/// - Parameters:
/// - inputIds: Token IDs [B, T]
/// - state: Inference state
/// - Returns: (logits [B, T, vocabSize], updated state)
public func forward(
inputIds: MLXArray,
state: InferenceState
) -> (MLXArray, InferenceState) {
let seqLen = inputIds.dim(1)
var hidden = embedTokens(inputIds) // [B, T, hiddenSize]
var newDeltaStates = state.deltaNetStates
var newKVCaches = state.kvCaches
for (i, layer) in layers.enumerated() {
if layerTypes[i] == "linear_attention" {
let (h, newState) = layer.forwardDeltaNet(hidden, state: state.deltaNetStates[i])
hidden = h
newDeltaStates[i] = newState
} else {
let (h, newCache) = layer.forwardGatedAttention(
hidden, cache: state.kvCaches[i], offset: state.position)
hidden = h
newKVCaches[i] = newCache
}
}
hidden = norm(hidden)
// Tied LM head
let logits = embedTokens.asLinear(hidden)
let newState = InferenceState(
deltaNetStates: newDeltaStates,
kvCaches: newKVCaches,
position: state.position + seqLen)
return (logits, newState)
}
// MARK: - Text Generation
/// Generate text tokens autoregressively.
///
/// - Parameters:
/// - promptIds: Prompt token IDs
/// - sampling: Sampling configuration
/// - Returns: Generated token IDs (excluding prompt)
public func generate(
promptIds: [Int],
sampling: ChatSamplingConfig = .default
) -> [Int] {
var state = InferenceState.initial(config: config)
// Prefill
let prompt = MLXArray(promptIds.map { Int32($0) }).expandedDimensions(axis: 0)
let (prefillLogits, prefillState) = forward(inputIds: prompt, state: state)
state = prefillState
eval(prefillLogits)
// Sample first token
var token = sampleFromLogits(prefillLogits, at: promptIds.count - 1,
config: sampling, history: promptIds)
if token == config.eosTokenId { return [] }
var generated = [token]
// Decode loop
for _ in 1..<sampling.maxTokens {
let input = MLXArray([Int32(token)]).expandedDimensions(axis: 0)
let (logits, newState) = forward(inputIds: input, state: state)
state = newState
eval(logits)
token = sampleFromLogits(logits, at: 0,
config: sampling, history: promptIds + generated)
if token == config.eosTokenId { break }
generated.append(token)
}
return generated
}
// MARK: - Helpers
private func sampleFromLogits(
_ logits: MLXArray, at position: Int,
config: ChatSamplingConfig, history: [Int]
) -> Int {
let posLogits = logits[0, position] // [vocabSize]
let f32 = posLogits.asType(.float32)
eval(f32)
let count = self.config.vocabSize
let floats: [Float] = f32.asArray(Float.self)
return ChatSampler.sample(logits: Array(floats.prefix(count)), config: config, previousTokens: history)
}
}
@@ -0,0 +1,79 @@
import Foundation
import AudioCommon
/// Common interface for Qwen3.5 chat backends (MLX or CoreML).
public protocol Qwen35ChatBackend: AnyObject {
var tokenizer: ChatTokenizer { get }
var config: Qwen3ChatConfig { get }
func generateStream(messages: [ChatMessage], sampling: ChatSamplingConfig)
-> AsyncThrowingStream<String, Error>
func resetState()
}
extension Qwen35MLXChat: Qwen35ChatBackend {}
extension Qwen35CoreMLChat: Qwen35ChatBackend {}
/// Bridges any Qwen3.5 backend to VoicePipeline's PipelineLLM protocol.
public final class Qwen35PipelineLLM: PipelineLLM {
private let model: any Qwen35ChatBackend
private let systemPrompt: String
private let sampling: ChatSamplingConfig
private var cancelled = false
public var onToken: ((String) -> Void)?
public init(
model: any Qwen35ChatBackend,
systemPrompt: String = "Your name is Tama. Give short direct answers. Do not explain your reasoning.",
sampling: ChatSamplingConfig = .default
) {
self.model = model
self.systemPrompt = systemPrompt
self.sampling = sampling
}
public func chat(
messages: [(role: MessageRole, content: String)],
onToken: @escaping (String, Bool) -> Void
) {
cancelled = false
let chatMessages = messages.compactMap { msg -> ChatMessage? in
switch msg.role {
case .system: return ChatMessage(role: .system, content: msg.content)
case .user: return ChatMessage(role: .user, content: msg.content)
case .assistant: return ChatMessage(role: .assistant, content: msg.content)
default: return nil
}
}
var fullMessages = [ChatMessage(role: .system, content: systemPrompt)]
fullMessages.append(contentsOf: chatMessages)
let stream = model.generateStream(messages: fullMessages, sampling: sampling)
let semaphore = DispatchSemaphore(value: 0)
var fullResponse = ""
Task {
do {
for try await chunk in stream {
guard !self.cancelled else { break }
fullResponse += chunk
self.onToken?(chunk)
onToken(chunk, false)
}
} catch { }
if !fullResponse.isEmpty {
onToken("", true)
}
semaphore.signal()
}
semaphore.wait()
}
public func cancel() {
cancelled = true
}
}
@@ -0,0 +1,226 @@
import Foundation
import MLXCommon
import MLX
import MLXNN
// MARK: - Weight Loading for Qwen3.5-0.8B MLX Model
/// Loads quantized safetensors weights into the Qwen3.5 MLX model.
///
/// Expected weight key structure (HuggingFace / mlx-community format):
///
/// Keys may have `model.` or `language_model.model.` prefix both are stripped.
///
/// - `embed_tokens.*` -> embed_tokens (PreQuantizedEmbedding)
/// - `layers.{i}.linear_attn.*` -> DeltaNet (linear_attention layers)
/// - `layers.{i}.self_attn.*` -> GatedAttention (full_attention layers)
/// - `layers.{i}.mlp.*` -> SwiGLU MLP
/// - `layers.{i}.input_layernorm.*`
/// - `layers.{i}.post_attention_layernorm.*`
/// - `norm.*` -> final RMSNorm
///
/// DeltaNet (linear_attention) weights under `linear_attn.`:
/// - `in_proj_qkv.{weight,scales,biases}`: quantized [6144, 1024]
/// - `in_proj_z.{weight,scales,biases}`: quantized [2048, 1024]
/// - `in_proj_b.{weight,scales,biases}`: quantized [16, 1024]
/// - `in_proj_a.{weight,scales,biases}`: quantized [16, 1024]
/// - `conv1d.weight`: [6144, 1, 4]
/// - `dt_bias`: [16]
/// - `A_log`: [16]
/// - `norm.weight`: [128]
/// - `out_proj.{weight,scales,biases}`: quantized [1024, 2048]
///
/// GatedAttention (full_attention) weights under `self_attn.`:
/// - `q_proj.{weight,scales,biases}`: quantized [4096, 1024]
/// - `k_proj.{weight,scales,biases}`: quantized [512, 1024]
/// - `v_proj.{weight,scales,biases}`: quantized [512, 1024]
/// - `o_proj.{weight,scales,biases}`: quantized [1024, 2048]
/// - `q_norm.weight`: [256]
/// - `k_norm.weight`: [256]
///
/// MLP weights under `mlp.`:
/// - `gate_proj.{weight,scales,biases}`: quantized [3584, 1024]
/// - `up_proj.{weight,scales,biases}`: quantized [3584, 1024]
/// - `down_proj.{weight,scales,biases}`: quantized [1024, 3584]
public enum Qwen35WeightLoader {
/// Load weights from a directory containing safetensors files.
///
/// - Parameters:
/// - model: The Qwen3.5 MLX model to load weights into
/// - directory: Directory containing safetensors files
/// - progressHandler: Optional progress callback
public static func loadWeights(
into model: Qwen35MLXModel,
from directory: URL,
progressHandler: ((Double, String) -> Void)? = nil
) throws {
progressHandler?(0.05, "Loading weight files...")
// Load all safetensors files from the directory
let allWeights = try CommonWeightLoader.loadAllSafetensors(from: directory)
progressHandler?(0.3, "Loaded \(allWeights.count) tensors")
// Strip prefix from keys. Handles two formats:
// - Our format: "model.layers.0.*"
// - mlx-community VLM: "language_model.model.layers.0.*" (also has vision_tower.* which we skip)
var modelWeights: [String: MLXArray] = [:]
for (key, value) in allWeights {
if key.hasPrefix("language_model.model.") {
modelWeights[String(key.dropFirst("language_model.model.".count))] = value
} else if key.hasPrefix("model.") {
modelWeights[String(key.dropFirst("model.".count))] = value
} else if key.hasPrefix("lm_head.") || key.hasPrefix("vision_tower.") {
// Skip lm_head is tied to embed_tokens, vision_tower not needed
continue
} else {
modelWeights[key] = value
}
}
progressHandler?(0.4, "Applying embedding weights...")
// Load embed_tokens (PreQuantizedEmbedding)
CommonWeightLoader.applyQuantizedEmbeddingWeights(
to: model.embedTokens,
prefix: "embed_tokens",
from: modelWeights)
// Load final norm
CommonWeightLoader.applyRMSNormWeights(
to: model.norm, prefix: "norm", from: modelWeights)
progressHandler?(0.5, "Loading transformer layers...")
// Load each layer
let numLayers = model.config.numHiddenLayers
for i in 0..<numLayers {
let prefix = "layers.\(i)"
let layer = model.layers[i]
// Layer norms
CommonWeightLoader.applyRMSNormWeights(
to: layer.inputLayerNorm,
prefix: "\(prefix).input_layernorm",
from: modelWeights)
CommonWeightLoader.applyRMSNormWeights(
to: layer.postAttentionLayerNorm,
prefix: "\(prefix).post_attention_layernorm",
from: modelWeights)
// MLP
applyQuantizedMLPWeights(
to: layer.mlp,
prefix: "\(prefix).mlp",
from: modelWeights)
// Attention (type-specific, different key prefix per HuggingFace convention)
if layer.layerType == "linear_attention" {
try applyDeltaNetWeights(
to: layer.deltaNet!,
prefix: "\(prefix).linear_attn",
from: modelWeights)
} else {
applyGatedAttentionWeights(
to: layer.gatedAttn!,
prefix: "\(prefix).self_attn",
from: modelWeights)
}
let pct = 0.5 + 0.45 * Double(i + 1) / Double(numLayers)
progressHandler?(pct, "Layer \(i + 1)/\(numLayers)")
}
// Evaluate all parameters
eval(model)
progressHandler?(1.0, "Weights loaded")
}
// MARK: - DeltaNet Weight Loading
/// Apply weights to a DeltaNet (linear attention) layer.
///
/// All DeltaNet projections are quantized INT4 (matching mlx-community format).
/// The conv1d weight and scalar parameters (dt_bias, A_log) are loaded directly.
private static func applyDeltaNetWeights(
to layer: DeltaNetLayer,
prefix: String,
from weights: [String: MLXArray]
) throws {
// Quantized projections
CommonWeightLoader.applyQuantizedLinearWeights(
to: layer.inProjQKV, prefix: "\(prefix).in_proj_qkv", from: weights)
CommonWeightLoader.applyQuantizedLinearWeights(
to: layer.inProjZ, prefix: "\(prefix).in_proj_z", from: weights)
CommonWeightLoader.applyQuantizedLinearWeights(
to: layer.inProjB, prefix: "\(prefix).in_proj_b", from: weights)
CommonWeightLoader.applyQuantizedLinearWeights(
to: layer.inProjA, prefix: "\(prefix).in_proj_a", from: weights)
// Raw parameters: conv1d weight, dt_bias, A_log
// Note: ParameterInfo key from property declaration is lost when reassigning
// in init(), so use Swift property names (convWeight, dtBias, aLog).
var rawParams: [String: NestedItem<String, MLXArray>] = [:]
if let w = weights["\(prefix).conv1d.weight"] {
rawParams["convWeight"] = .value(w)
}
if let dtb = weights["\(prefix).dt_bias"] {
rawParams["dtBias"] = .value(dtb)
}
if let alog = weights["\(prefix).A_log"] {
rawParams["aLog"] = .value(alog)
}
if !rawParams.isEmpty {
layer.update(parameters: ModuleParameters(values: rawParams))
}
// Per-head norm
CommonWeightLoader.applyRMSNormWeights(
to: layer.norm, prefix: "\(prefix).norm", from: weights)
// Output projection (quantized)
CommonWeightLoader.applyQuantizedLinearWeights(
to: layer.outProj, prefix: "\(prefix).out_proj", from: weights)
}
// MARK: - GatedAttention Weight Loading
/// Apply weights to a GatedAttention (full attention) layer.
///
/// All projections are quantized (INT4 with group_size=64).
private static func applyGatedAttentionWeights(
to layer: GatedAttentionLayer,
prefix: String,
from weights: [String: MLXArray]
) {
CommonWeightLoader.applyQuantizedLinearWeights(
to: layer.qProj, prefix: "\(prefix).q_proj", from: weights)
CommonWeightLoader.applyQuantizedLinearWeights(
to: layer.kProj, prefix: "\(prefix).k_proj", from: weights)
CommonWeightLoader.applyQuantizedLinearWeights(
to: layer.vProj, prefix: "\(prefix).v_proj", from: weights)
CommonWeightLoader.applyQuantizedLinearWeights(
to: layer.oProj, prefix: "\(prefix).o_proj", from: weights)
CommonWeightLoader.applyRMSNormWeights(
to: layer.qNorm, prefix: "\(prefix).q_norm", from: weights)
CommonWeightLoader.applyRMSNormWeights(
to: layer.kNorm, prefix: "\(prefix).k_norm", from: weights)
}
// MARK: - MLP Weight Loading
/// Apply quantized MLP weights (SwiGLU: gate_proj, up_proj, down_proj).
private static func applyQuantizedMLPWeights(
to mlp: Qwen35MLP,
prefix: String,
from weights: [String: MLXArray]
) {
CommonWeightLoader.applyQuantizedLinearWeights(
to: mlp.gateProj, prefix: "\(prefix).gate_proj", from: weights)
CommonWeightLoader.applyQuantizedLinearWeights(
to: mlp.upProj, prefix: "\(prefix).up_proj", from: weights)
CommonWeightLoader.applyQuantizedLinearWeights(
to: mlp.downProj, prefix: "\(prefix).down_proj", from: weights)
}
}
@@ -0,0 +1,146 @@
import Foundation
/// Model architecture type.
public enum ChatModelArch: String, Codable, Sendable {
/// Qwen3.5 hybrid (DeltaNet linear attention + GatedAttention)
case qwen35 = "qwen3_5_text"
}
/// Configuration for Qwen3.5 chat model.
public struct Qwen3ChatConfig: Codable, Sendable {
public let hiddenSize: Int
public let numHiddenLayers: Int
public let numAttentionHeads: Int
public let numKeyValueHeads: Int
public let headDim: Int
public let intermediateSize: Int
public let vocabSize: Int
public let maxSeqLen: Int
public let ropeTheta: Double
public let rmsNormEps: Double
public let eosTokenId: Int
public let padTokenId: Int
public let quantization: String
// Qwen3.5-specific fields
public let modelType: ChatModelArch?
/// Per-layer type: "linear_attention" (DeltaNet) or "full_attention" (GatedAttention)
public let layerTypes: [String]?
/// How often a full_attention layer appears (e.g., 4 = every 4th layer)
public let fullAttentionInterval: Int?
/// DeltaNet linear attention head config
public let linearNumKeyHeads: Int?
public let linearKeyHeadDim: Int?
public let linearNumValueHeads: Int?
public let linearValueHeadDim: Int?
/// Causal conv1d kernel size for DeltaNet
public let linearConvKernelDim: Int?
/// Partial RoPE factor for GatedAttention (e.g., 0.25)
public let partialRotaryFactor: Double?
/// Whether embeddings are tied (lm_head = embed_tokens)
public let tieWordEmbeddings: Bool?
enum CodingKeys: String, CodingKey {
case hiddenSize = "hidden_size"
case numHiddenLayers = "num_hidden_layers"
case numAttentionHeads = "num_attention_heads"
case numKeyValueHeads = "num_key_value_heads"
case headDim = "head_dim"
case intermediateSize = "intermediate_size"
case vocabSize = "vocab_size"
case maxSeqLen = "max_seq_len"
case ropeTheta = "rope_theta"
case rmsNormEps = "rms_norm_eps"
case eosTokenId = "eos_token_id"
case padTokenId = "pad_token_id"
case quantization
case modelType = "model_type"
case layerTypes = "layer_types"
case fullAttentionInterval = "full_attention_interval"
case linearNumKeyHeads = "linear_num_key_heads"
case linearKeyHeadDim = "linear_key_head_dim"
case linearNumValueHeads = "linear_num_value_heads"
case linearValueHeadDim = "linear_value_head_dim"
case linearConvKernelDim = "linear_conv_kernel_dim"
case partialRotaryFactor = "partial_rotary_factor"
case tieWordEmbeddings = "tie_word_embeddings"
}
/// Whether this is a Qwen3.5 hybrid model.
public var isQwen35: Bool {
modelType == .qwen35 || layerTypes != nil
}
/// Number of full-attention layers (that need KV cache).
public var numFullAttentionLayers: Int {
guard let types = layerTypes else { return numHiddenLayers }
return types.filter { $0 == "full_attention" }.count
}
/// Default config for Qwen3.5-0.8B.
public static let qwen35_08B = Qwen3ChatConfig(
hiddenSize: 1024,
numHiddenLayers: 24,
numAttentionHeads: 8,
numKeyValueHeads: 2,
headDim: 256,
intermediateSize: 3584,
vocabSize: 248320,
maxSeqLen: 2048,
ropeTheta: 10_000_000.0,
rmsNormEps: 1e-6,
eosTokenId: 248046, // <|im_end|> stops generation at end of assistant turn
padTokenId: 248044, // <|endoftext|>
quantization: "int4",
modelType: .qwen35,
layerTypes: [
"linear_attention", "linear_attention", "linear_attention", "full_attention",
"linear_attention", "linear_attention", "linear_attention", "full_attention",
"linear_attention", "linear_attention", "linear_attention", "full_attention",
"linear_attention", "linear_attention", "linear_attention", "full_attention",
"linear_attention", "linear_attention", "linear_attention", "full_attention",
"linear_attention", "linear_attention", "linear_attention", "full_attention",
],
fullAttentionInterval: 4,
linearNumKeyHeads: 16,
linearKeyHeadDim: 128,
linearNumValueHeads: 16,
linearValueHeadDim: 128,
linearConvKernelDim: 4,
partialRotaryFactor: 0.25,
tieWordEmbeddings: true
)
/// Load config from a JSON file.
public static func load(from url: URL) throws -> Qwen3ChatConfig {
let data = try Data(contentsOf: url)
return try JSONDecoder().decode(Qwen3ChatConfig.self, from: data)
}
}
/// Sampling parameters for text generation.
public struct ChatSamplingConfig: Sendable {
public var temperature: Float
public var topK: Int
public var topP: Float
public var maxTokens: Int
public var repetitionPenalty: Float
public init(
temperature: Float = 0.7,
topK: Int = 50,
topP: Float = 0.9,
maxTokens: Int = 256,
repetitionPenalty: Float = 1.1
) {
self.temperature = temperature
self.topK = topK
self.topP = topP
self.maxTokens = maxTokens
self.repetitionPenalty = repetitionPenalty
}
public static let `default` = ChatSamplingConfig()
public static let creative = ChatSamplingConfig(temperature: 0.9, topP: 0.95)
public static let precise = ChatSamplingConfig(temperature: 0.3, topK: 20, topP: 0.8)
}
@@ -0,0 +1,25 @@
import Foundation
/// Errors for Qwen3.5 chat model operations.
public enum ChatModelError: LocalizedError {
case modelLoadFailed(String)
case tokenizerLoadFailed(String)
case inferenceFailed(String)
case configNotFound(URL)
case modelNotFound(URL)
public var errorDescription: String? {
switch self {
case .modelLoadFailed(let reason):
"Failed to load chat model: \(reason)"
case .tokenizerLoadFailed(let reason):
"Failed to load tokenizer: \(reason)"
case .inferenceFailed(let reason):
"Inference failed: \(reason)"
case .configNotFound(let url):
"Config not found at \(url.path)"
case .modelNotFound(let url):
"Model not found at \(url.path)"
}
}
}
@@ -0,0 +1,100 @@
import Foundation
import MLX
import MLXNN
/// A single LSTM layer with updatable parameters.
///
/// Uses `var` properties so they can be loaded via `update(parameters:)`.
/// Parameter keys match MLX convention: `Wx` (inputhidden), `Wh` (hiddenhidden), `bias`.
class LSTMLayer: Module {
let hiddenSize: Int
@ParameterInfo(key: "Wx") var wx: MLXArray
@ParameterInfo(key: "Wh") var wh: MLXArray
var bias: MLXArray
init(inputSize: Int, hiddenSize: Int) {
self.hiddenSize = hiddenSize
let scale = 1.0 / Foundation.sqrt(Float(hiddenSize))
self._wx.wrappedValue = MLXRandom.uniform(low: -scale, high: scale, [4 * hiddenSize, inputSize])
self._wh.wrappedValue = MLXRandom.uniform(low: -scale, high: scale, [4 * hiddenSize, hiddenSize])
self.bias = MLXRandom.uniform(low: -scale, high: scale, [4 * hiddenSize])
}
/// Process a sequence.
/// - Parameter x: `[batch, seq_len, input_size]`
/// - Returns: `[batch, seq_len, hidden_size]`
func callAsFunction(_ x: MLXArray) -> MLXArray {
// Project all timesteps at once: [B, L, 4H]
let projected = addMM(bias, x, wx.T)
let seqLen = x.dim(-2)
var hidden: MLXArray? = nil
var cell: MLXArray? = nil
var allHidden = [MLXArray]()
for t in 0 ..< seqLen {
var ifgo = projected[.ellipsis, t, 0...]
if let h = hidden {
ifgo = ifgo + matmul(h, wh.T)
}
let pieces = split(ifgo, parts: 4, axis: -1)
let i = sigmoid(pieces[0])
let f = sigmoid(pieces[1])
let g = tanh(pieces[2])
let o = sigmoid(pieces[3])
if let c = cell {
cell = f * c + i * g
} else {
cell = i * g
}
hidden = o * tanh(cell!)
allHidden.append(hidden!)
}
return stacked(allHidden, axis: -2)
}
}
/// Container for LSTM layers (enables module parameter tree: `layers.0`, `layers.1`, etc.)
class LSTMStack: Module {
let layers: [LSTMLayer]
init(layers: [LSTMLayer]) {
self.layers = layers
}
}
/// Run bidirectional LSTM across multiple layers.
///
/// For each layer, runs forward LSTM on the sequence and backward LSTM on the
/// reversed sequence, then concatenates outputs along the feature dimension.
///
/// - Parameters:
/// - x: `[batch, seq_len, features]`
/// - fwd: forward LSTM stack
/// - bwd: backward LSTM stack
/// - Returns: `[batch, seq_len, 2 * hidden_size]`
func runBiLSTM(_ x: MLXArray, fwd: LSTMStack, bwd: LSTMStack) -> MLXArray {
var input = x
for i in 0 ..< fwd.layers.count {
// Forward direction
let fwdOut = fwd.layers[i](input)
// Backward direction: reverse process reverse back
let seqLen = input.dim(-2)
let indices = MLXArray(Array((0 ..< seqLen).reversed()))
let reversed = input.take(indices, axis: -2)
let bwdOutRev = bwd.layers[i](reversed)
let bwdOut = bwdOutRev.take(indices, axis: -2)
// Concatenate along feature dimension
input = concatenated([fwdOut, bwdOut], axis: -1)
}
return input
}
@@ -0,0 +1,92 @@
import Foundation
/// Model configuration for pyannote PyanNet segmentation.
public struct SegmentationConfig: Sendable {
/// Audio sample rate in Hz
public let sampleRate: Int
/// SincNet filter counts per layer
public let sincnetFilters: [Int]
/// SincNet kernel sizes per layer
public let sincnetKernelSizes: [Int]
/// SincNet strides per layer
public let sincnetStrides: [Int]
/// SincNet max-pool kernel sizes per layer
public let sincnetPoolSizes: [Int]
/// LSTM hidden size (per direction; output is 2x for bidirectional)
public let lstmHiddenSize: Int
/// Number of LSTM layers
public let lstmNumLayers: Int
/// Linear layer hidden size
public let linearHiddenSize: Int
/// Number of linear layers (before classifier)
public let linearNumLayers: Int
/// Number of output classes (powerset)
public let numClasses: Int
/// Default configuration for pyannote/segmentation-3.0
public static let `default` = SegmentationConfig(
sampleRate: 16000,
sincnetFilters: [80, 60, 60],
sincnetKernelSizes: [251, 5, 5],
sincnetStrides: [10, 1, 1],
sincnetPoolSizes: [3, 3, 3],
lstmHiddenSize: 128,
lstmNumLayers: 4,
linearHiddenSize: 128,
linearNumLayers: 2,
numClasses: 7
)
}
/// VAD pipeline configuration with default hysteresis thresholds.
public struct VADConfig: Sendable {
/// Onset threshold (speech starts when probability exceeds this)
public var onset: Float
/// Offset threshold (speech ends when probability drops below this)
public var offset: Float
/// Minimum speech duration in seconds
public var minSpeechDuration: Float
/// Minimum silence duration in seconds
public var minSilenceDuration: Float
/// Analysis window duration in seconds
public var windowDuration: Float
/// Step ratio for sliding window (fraction of window)
public var stepRatio: Float
public init(
onset: Float, offset: Float,
minSpeechDuration: Float, minSilenceDuration: Float,
windowDuration: Float, stepRatio: Float
) {
self.onset = onset
self.offset = offset
self.minSpeechDuration = minSpeechDuration
self.minSilenceDuration = minSilenceDuration
self.windowDuration = windowDuration
self.stepRatio = stepRatio
}
/// Default pyannote VAD thresholds
public static let `default` = VADConfig(
onset: 0.767,
offset: 0.377,
minSpeechDuration: 0.136,
minSilenceDuration: 0.067,
windowDuration: 10.0,
stepRatio: 0.1
)
/// Default Silero VAD thresholds (streaming-optimized)
public static let sileroDefault = VADConfig(
onset: 0.5,
offset: 0.35,
minSpeechDuration: 0.25,
minSilenceDuration: 0.1,
windowDuration: 0.032,
stepRatio: 1.0
)
}
@@ -0,0 +1,64 @@
#if canImport(CoreML)
import AudioCommon
import CoreML
import Foundation
extension SileroVADModel {
/// Run CoreML inference for one 576-sample chunk (64 context + 512 new).
///
/// Creates input MLMultiArrays in float16, runs prediction, and updates
/// the internal LSTM h/c state for the next chunk.
///
/// - Parameter fullSamples: 576 Float32 samples (context prepended)
/// - Returns: speech probability in `[0, 1]`
func processChunkCoreML(_ fullSamples: [Float]) throws -> Float {
guard let model = coremlModel else {
throw AudioModelError.inferenceFailed(
operation: "VAD", reason: "CoreML model not loaded")
}
// Create audio input: [1, 1, 576] float16
let audioArray = try MLMultiArray(shape: [1, 1, 576], dataType: .float16)
let audioPtr = audioArray.dataPointer.assumingMemoryBound(to: Float16.self)
for i in 0..<576 {
audioPtr[i] = Float16(fullSamples[i])
}
// Initialize h/c to zeros on first call
if coremlH == nil {
coremlH = try MLMultiArray(shape: [1, 1, 128], dataType: .float16)
coremlC = try MLMultiArray(shape: [1, 1, 128], dataType: .float16)
zeroFillFloat16(coremlH!)
zeroFillFloat16(coremlC!)
}
let input = try MLDictionaryFeatureProvider(dictionary: [
"audio": MLFeatureValue(multiArray: audioArray),
"h": MLFeatureValue(multiArray: coremlH!),
"c": MLFeatureValue(multiArray: coremlC!),
])
let result = try model.prediction(from: input)
// Update LSTM state
coremlH = result.featureValue(for: "h_out")!.multiArrayValue!
coremlC = result.featureValue(for: "c_out")!.multiArrayValue!
// Extract probability scalar
let probArray = result.featureValue(for: "probability")!.multiArrayValue!
let probPtr = probArray.dataPointer.assumingMemoryBound(to: Float16.self)
return Float(probPtr[0])
}
/// Zero-fill a float16 MLMultiArray.
private func zeroFillFloat16(_ array: MLMultiArray) {
let ptr = UnsafeMutableBufferPointer(
start: array.dataPointer.assumingMemoryBound(to: Float16.self),
count: array.count)
for i in 0..<ptr.count {
ptr[i] = 0
}
}
}
#endif
@@ -0,0 +1,76 @@
#if canImport(CoreML)
import AudioCommon
import CoreML
import Foundation
extension WeSpeakerModel {
/// Run CoreML inference to extract a 256-dim speaker embedding.
///
/// Pads the mel spectrogram to the nearest enumerated shape (required by
/// the CoreML model which uses EnumeratedShapes for the time dimension),
/// runs prediction, and extracts the embedding.
///
/// - Parameters:
/// - melSpec: flat Float array of log-mel features `[nFrames * 80]`
/// - nFrames: number of mel frames
/// - Returns: 256-dim L2-normalized speaker embedding
func embedCoreML(melSpec: [Float], nFrames: Int) throws -> [Float] {
guard let model = coremlModel else {
throw AudioModelError.inferenceFailed(
operation: "SpeakerEmbedding", reason: "CoreML model not loaded")
}
// Find nearest enumerated length >= nFrames
let targetLength = Self.enumeratedMelLengths.first { $0 >= nFrames }
?? Self.enumeratedMelLengths.last!
// Create input: [1, targetLength, 80] float16
// The CoreML model internally permutes (T,80) (80,T) to match
// the trained weight orientation (freq as height, time as width).
let melArray = try MLMultiArray(
shape: [1, targetLength as NSNumber, 80],
dataType: .float16
)
let melPtr = melArray.dataPointer.assumingMemoryBound(to: Float16.self)
// Fill with mel data (row-major: frame-major, 80 mels per frame)
let copyCount = min(nFrames, targetLength) * 80
for i in 0..<copyCount {
melPtr[i] = Float16(melSpec[i])
}
// Zero-pad remaining frames
let totalElements = targetLength * 80
for i in copyCount..<totalElements {
melPtr[i] = 0
}
let input = try MLDictionaryFeatureProvider(dictionary: [
"mel": MLFeatureValue(multiArray: melArray),
])
let result = try model.prediction(from: input)
// Extract "embedding" output: [1, 256]
guard let embArray = result.featureValue(for: "embedding")?.multiArrayValue else {
throw AudioModelError.inferenceFailed(
operation: "SpeakerEmbedding", reason: "Missing 'embedding' output")
}
// Read 256 float16 values
var embedding = [Float](repeating: 0, count: 256)
let embPtr = embArray.dataPointer.assumingMemoryBound(to: Float16.self)
for i in 0..<256 {
embedding[i] = Float(embPtr[i])
}
// L2 normalize
let norm = sqrt(embedding.reduce(Float(0)) { $0 + $1 * $1 })
if norm > 1e-10 {
for i in 0..<256 { embedding[i] /= norm }
}
return embedding
}
}
#endif
@@ -0,0 +1,408 @@
import Foundation
import AudioCommon
// MARK: - RTTM Format
/// RTTM (Rich Transcription Time Marked) segment for standard diarization evaluation.
public struct RTTMSegment: Sendable {
public let filename: String
public let startTime: Float
public let duration: Float
public let speakerLabel: String
public init(filename: String, startTime: Float, duration: Float, speakerLabel: String) {
self.filename = filename
self.startTime = startTime
self.duration = duration
self.speakerLabel = speakerLabel
}
/// Format as standard RTTM line: `SPEAKER <file> 1 <start> <dur> <NA> <NA> <speaker> <NA> <NA>`
public var rttmLine: String {
let s = String(format: "%.3f", startTime)
let d = String(format: "%.3f", duration)
return "SPEAKER \(filename) 1 \(s) \(d) <NA> <NA> \(speakerLabel) <NA> <NA>"
}
}
/// Convert diarization result to RTTM format.
public func toRTTM(segments: [DiarizedSegment], filename: String) -> [RTTMSegment] {
segments.map { seg in
RTTMSegment(
filename: filename,
startTime: seg.startTime,
duration: seg.duration,
speakerLabel: "speaker_\(seg.speakerId)"
)
}
}
/// Write RTTM segments to string.
public func formatRTTM(_ rttmSegments: [RTTMSegment]) -> String {
rttmSegments.map(\.rttmLine).joined(separator: "\n")
}
// MARK: - DER Computation
/// Diarization Error Rate result.
public struct DERResult: Sendable {
/// Total scored speech duration in seconds
public let totalSpeech: Float
/// False alarm duration (non-speech classified as speech)
public let falseAlarm: Float
/// Missed speech duration
public let missedSpeech: Float
/// Speaker confusion duration (wrong speaker assigned)
public let confusion: Float
/// Diarization Error Rate = (FA + Miss + Confusion) / TotalSpeech
public var der: Float {
guard totalSpeech > 0 else { return 0 }
return (falseAlarm + missedSpeech + confusion) / totalSpeech
}
/// Diarization Error Rate as percentage
public var derPercent: Float { der * 100 }
}
/// Compute Diarization Error Rate between reference and hypothesis.
///
/// Uses frame-level scoring with configurable resolution and collar.
/// Collar applies forgiveness around reference segment boundaries.
///
/// - Parameters:
/// - reference: reference (ground truth) segments
/// - hypothesis: hypothesis (system output) segments
/// - collar: forgiveness collar in seconds around boundaries (default 0.25s)
/// - resolution: scoring resolution in seconds (default 0.01s = 10ms)
/// - Returns: DER breakdown
public func computeDER(
reference: [DiarizedSegment],
hypothesis: [DiarizedSegment],
collar: Float = 0.25,
resolution: Float = 0.01
) -> DERResult {
guard !reference.isEmpty else {
let hTotal = hypothesis.reduce(Float(0)) { $0 + $1.duration }
return DERResult(totalSpeech: 0, falseAlarm: hTotal, missedSpeech: 0, confusion: 0)
}
// Find time range
let allSegments: [DiarizedSegment] = reference + hypothesis
let maxTime = allSegments.map(\.endTime).max()!
let numFrames = Int(ceil(maxTime / resolution))
guard numFrames > 0 else {
return DERResult(totalSpeech: 0, falseAlarm: 0, missedSpeech: 0, confusion: 0)
}
// Build collar mask: frames near reference boundaries are excluded from scoring
var collarMask = [Bool](repeating: false, count: numFrames)
if collar > 0 {
for seg in reference {
let startFrame = Int(seg.startTime / resolution)
let endFrame = Int(seg.endTime / resolution)
let collarFrames = Int(collar / resolution)
for f in max(0, startFrame - collarFrames)..<min(numFrames, startFrame + collarFrames) {
collarMask[f] = true
}
for f in max(0, endFrame - collarFrames)..<min(numFrames, endFrame + collarFrames) {
collarMask[f] = true
}
}
}
// Build per-frame speaker sets for reference and hypothesis
// Use sorted arrays of speaker IDs (faster than Set for small N)
let refSpeakers = buildFrameSpeakers(segments: reference, numFrames: numFrames, resolution: resolution)
let hypSpeakers = buildFrameSpeakers(segments: hypothesis, numFrames: numFrames, resolution: resolution)
// Score each frame
var totalSpeech: Float = 0
var falseAlarm: Float = 0
var missedSpeech: Float = 0
var confusion: Float = 0
for f in 0..<numFrames {
if collarMask[f] { continue }
let refCount = refSpeakers[f].count
let hypCount = hypSpeakers[f].count
if refCount == 0 && hypCount == 0 { continue }
if refCount == 0 {
// No reference speech, any hypothesis is false alarm
falseAlarm += Float(hypCount) * resolution
continue
}
// Reference speech exists count it
totalSpeech += Float(refCount) * resolution
if hypCount == 0 {
// All reference speech is missed
missedSpeech += Float(refCount) * resolution
continue
}
// Both have speakers compute exact match by speaker ID
let matched = countExactMatched(ref: refSpeakers[f], hyp: hypSpeakers[f])
let unmatchedRef = refCount - matched // ref speakers with no matching hyp
let unmatchedHyp = hypCount - matched // hyp speakers with no matching ref
// Confusion: min of unmatched ref/hyp (wrong speaker assigned)
let conf = min(unmatchedRef, unmatchedHyp)
// Missed: unmatched ref beyond confusion
let missed = unmatchedRef - conf
// False alarm: unmatched hyp beyond confusion
let fa = unmatchedHyp - conf
missedSpeech += Float(missed) * resolution
confusion += Float(conf) * resolution
falseAlarm += Float(fa) * resolution
}
return DERResult(
totalSpeech: totalSpeech,
falseAlarm: falseAlarm,
missedSpeech: missedSpeech,
confusion: confusion
)
}
// MARK: - Optimal Speaker Mapping
/// Compute DER with optimal 1-to-1 speaker mapping between reference and hypothesis.
///
/// Tries all permutations of hypothesis speaker labels to find the mapping
/// that minimizes DER. For >8 speakers, falls back to greedy matching.
public func computeDERWithOptimalMapping(
reference: [DiarizedSegment],
hypothesis: [DiarizedSegment],
collar: Float = 0.25,
resolution: Float = 0.01
) -> DERResult {
let refSpeakers = Set(reference.map(\.speakerId)).sorted()
let hypSpeakers = Set(hypothesis.map(\.speakerId)).sorted()
guard !refSpeakers.isEmpty, !hypSpeakers.isEmpty else {
return computeDER(reference: reference, hypothesis: hypothesis,
collar: collar, resolution: resolution)
}
// For small speaker counts, try all permutations
if hypSpeakers.count <= 8 {
return bruteForceOptimalMapping(
reference: reference, hypothesis: hypothesis,
refSpeakers: refSpeakers, hypSpeakers: hypSpeakers,
collar: collar, resolution: resolution
)
}
// For large speaker counts, use greedy matching
return greedyOptimalMapping(
reference: reference, hypothesis: hypothesis,
refSpeakers: refSpeakers, hypSpeakers: hypSpeakers,
collar: collar, resolution: resolution
)
}
// MARK: - RTTM Parsing
/// Parse RTTM file content into DiarizedSegments.
public func parseRTTM(_ content: String) -> [DiarizedSegment] {
var segments = [DiarizedSegment]()
var speakerMap = [String: Int]()
var nextId = 0
for line in content.split(separator: "\n") {
let trimmed = line.trimmingCharacters(in: .whitespaces)
guard !trimmed.isEmpty, trimmed.hasPrefix("SPEAKER") else { continue }
let parts = trimmed.split(whereSeparator: \.isWhitespace).map(String.init)
guard parts.count >= 8 else { continue }
guard let start = Float(parts[3]),
let dur = Float(parts[4]) else { continue }
let speaker = parts[7]
if speakerMap[speaker] == nil {
speakerMap[speaker] = nextId
nextId += 1
}
segments.append(DiarizedSegment(
startTime: start,
endTime: start + dur,
speakerId: speakerMap[speaker]!
))
}
return segments.sorted { $0.startTime < $1.startTime }
}
// MARK: - Internals
private func buildFrameSpeakers(
segments: [DiarizedSegment],
numFrames: Int,
resolution: Float
) -> [[Int]] {
var result = [[Int]](repeating: [], count: numFrames)
for seg in segments {
let startFrame = max(0, Int(seg.startTime / resolution))
let endFrame = min(numFrames, Int(seg.endTime / resolution))
for f in startFrame..<endFrame {
if !result[f].contains(seg.speakerId) {
result[f].append(seg.speakerId)
}
}
}
return result
}
/// Count speakers present in both ref and hyp by exact ID match.
private func countExactMatched(ref: [Int], hyp: [Int]) -> Int {
var matched = 0
for rSpk in ref {
if hyp.contains(rSpk) {
matched += 1
}
}
return matched
}
private func bruteForceOptimalMapping(
reference: [DiarizedSegment],
hypothesis: [DiarizedSegment],
refSpeakers: [Int],
hypSpeakers: [Int],
collar: Float,
resolution: Float
) -> DERResult {
let permutations = generatePermutations(Array(0..<hypSpeakers.count))
var bestResult: DERResult?
for perm in permutations {
// Build mapping: hypSpeakers[i] refSpeakers[perm[i]] (if in range)
var mapping = [Int: Int]()
for (i, p) in perm.enumerated() {
if p < refSpeakers.count {
mapping[hypSpeakers[i]] = refSpeakers[p]
} else {
mapping[hypSpeakers[i]] = 1000 + i // unmapped unique ID
}
}
let remapped = hypothesis.map { seg in
DiarizedSegment(
startTime: seg.startTime,
endTime: seg.endTime,
speakerId: mapping[seg.speakerId] ?? seg.speakerId
)
}
let result = computeDER(reference: reference, hypothesis: remapped,
collar: collar, resolution: resolution)
if bestResult == nil || result.der < bestResult!.der {
bestResult = result
}
}
return bestResult!
}
private func greedyOptimalMapping(
reference: [DiarizedSegment],
hypothesis: [DiarizedSegment],
refSpeakers: [Int],
hypSpeakers: [Int],
collar: Float,
resolution: Float
) -> DERResult {
// Compute overlap matrix between ref and hyp speakers
let allSegs: [DiarizedSegment] = reference + hypothesis
let maxTime = allSegs.map(\.endTime).max()!
let numFrames = Int(ceil(maxTime / resolution))
let refFrames = buildFrameSpeakers(segments: reference, numFrames: numFrames, resolution: resolution)
let hypFrames = buildFrameSpeakers(segments: hypothesis, numFrames: numFrames, resolution: resolution)
// Overlap[r][h] = number of frames where ref speaker r and hyp speaker h both active
var overlap = [[Int]](repeating: [Int](repeating: 0, count: hypSpeakers.count), count: refSpeakers.count)
let refIndex = Dictionary(uniqueKeysWithValues: refSpeakers.enumerated().map { ($1, $0) })
let hypIndex = Dictionary(uniqueKeysWithValues: hypSpeakers.enumerated().map { ($1, $0) })
for f in 0..<numFrames {
for rSpk in refFrames[f] {
guard let ri = refIndex[rSpk] else { continue }
for hSpk in hypFrames[f] {
guard let hi = hypIndex[hSpk] else { continue }
overlap[ri][hi] += 1
}
}
}
// Greedy match: pick highest overlap pair, assign, repeat
var mapping = [Int: Int]()
var usedRef = Set<Int>()
var usedHyp = Set<Int>()
for _ in 0..<min(refSpeakers.count, hypSpeakers.count) {
var bestR = -1, bestH = -1, bestOverlap = -1
for r in 0..<refSpeakers.count where !usedRef.contains(r) {
for h in 0..<hypSpeakers.count where !usedHyp.contains(h) {
if overlap[r][h] > bestOverlap {
bestOverlap = overlap[r][h]
bestR = r
bestH = h
}
}
}
guard bestR >= 0 else { break }
mapping[hypSpeakers[bestH]] = refSpeakers[bestR]
usedRef.insert(bestR)
usedHyp.insert(bestH)
}
let remapped = hypothesis.map { seg in
DiarizedSegment(
startTime: seg.startTime,
endTime: seg.endTime,
speakerId: mapping[seg.speakerId] ?? (1000 + seg.speakerId)
)
}
return computeDER(reference: reference, hypothesis: remapped,
collar: collar, resolution: resolution)
}
private func generatePermutations(_ elements: [Int]) -> [[Int]] {
if elements.count <= 1 { return [elements] }
var result = [[Int]]()
// Generate permutations of size elements.count from range 0..<elements.count
// For speaker mapping, we need arrangements: pick from 0..<max(ref,hyp) count
let n = elements.count
func permute(_ current: [Int], _ remaining: [Int]) {
if current.count == n {
result.append(current)
return
}
for (i, elem) in remaining.enumerated() {
var rest = remaining
rest.remove(at: i)
permute(current + [elem], rest)
}
}
// Permute indices 0..<n (mapping hyp speakers to ref speaker slots)
let indices = Array(0..<max(n, n))
permute([], indices)
return result
}
@@ -0,0 +1,183 @@
import Foundation
import AudioCommon
/// Shared helpers for diarization post-processing, used by both
/// PyannoteDiarizationPipeline and SortformerDiarizer.
enum DiarizationHelpers {
/// Merge adjacent segments from the same speaker when the gap is below `minSilence`.
///
/// Segments are grouped per-speaker, merged within each group, then sorted globally.
static func mergeSegments(
_ segments: [DiarizedSegment],
minSilence: Float
) -> [DiarizedSegment] {
guard !segments.isEmpty else { return [] }
var bySpeaker = [Int: [DiarizedSegment]]()
for seg in segments {
bySpeaker[seg.speakerId, default: []].append(seg)
}
var merged = [DiarizedSegment]()
for (spk, spkSegs) in bySpeaker {
let sorted = spkSegs.sorted { $0.startTime < $1.startTime }
var current = sorted[0]
for i in 1..<sorted.count {
let next = sorted[i]
if next.startTime - current.endTime < minSilence {
current = DiarizedSegment(
startTime: current.startTime,
endTime: next.endTime,
speakerId: spk
)
} else {
merged.append(current)
current = next
}
}
merged.append(current)
}
merged.sort { $0.startTime < $1.startTime }
return merged
}
/// Remap speaker IDs to contiguous 0-based range, preserving order of first appearance.
static func compactSpeakerIds(_ segments: [DiarizedSegment]) -> [DiarizedSegment] {
let usedIds = Set(segments.map(\.speakerId)).sorted()
let idMap = Dictionary(uniqueKeysWithValues: usedIds.enumerated().map { ($1, $0) })
return segments.map {
DiarizedSegment(
startTime: $0.startTime,
endTime: $0.endTime,
speakerId: idMap[$0.speakerId] ?? $0.speakerId
)
}
}
/// Resample audio via AVAudioConverter (delegates to AudioFileLoader).
static func resample(_ audio: [Float], from sourceSR: Int, to targetSR: Int) -> [Float] {
AudioFileLoader.resample(audio, from: sourceSR, to: targetSR)
}
// MARK: - Constrained Agglomerative Clustering
/// Item for constrained agglomerative clustering.
struct ClusterItem {
let windowIndex: Int
let localSpeakerId: Int
let embedding: [Float]
}
/// Constrained agglomerative clustering with centroid linkage and cosine distance.
///
/// Items from the same window can never be merged (same-window constraint).
/// Merges closest unconstrained pair until distance exceeds threshold.
///
/// - Parameters:
/// - items: per-window per-speaker embeddings
/// - threshold: cosine distance threshold (02). Pairs with distance >= threshold are not merged.
/// - Returns: cluster assignment for each item, and cluster centroids
static func constrainedAgglomerativeClustering(
items: [ClusterItem],
threshold: Float
) -> (clusterAssignment: [Int], centroids: [[Float]]) {
guard !items.isEmpty else { return ([], []) }
if items.count == 1 {
return ([0], [items[0].embedding])
}
let n = items.count
let dim = items[0].embedding.count
// Each item starts as its own cluster
var clusterOf = Array(0..<n) // item cluster ID
var centroids = items.map { $0.embedding } // cluster ID centroid
var clusterMembers = (0..<n).map { [$0] } // cluster ID member items
// Window indices per cluster (for constraint checking)
var clusterWindows = items.map { Set([$0.windowIndex]) }
var active = Set(0..<n)
while active.count > 1 {
// Find closest unconstrained pair
var bestDist: Float = Float.greatestFiniteMagnitude
var bestI = -1, bestJ = -1
let activeList = active.sorted()
for ai in 0..<activeList.count {
for aj in (ai + 1)..<activeList.count {
let ci = activeList[ai], cj = activeList[aj]
// Same-window constraint: if clusters share any window, skip
if !clusterWindows[ci].isDisjoint(with: clusterWindows[cj]) {
continue
}
let dist = cosineDistance(centroids[ci], centroids[cj])
if dist < bestDist {
bestDist = dist
bestI = ci
bestJ = cj
}
}
}
guard bestDist < threshold && bestI >= 0 else { break }
// Merge bestJ into bestI
let sizeI = clusterMembers[bestI].count
let sizeJ = clusterMembers[bestJ].count
let totalSize = Float(sizeI + sizeJ)
// Weighted average centroid
var newCentroid = [Float](repeating: 0, count: dim)
for d in 0..<dim {
newCentroid[d] = (centroids[bestI][d] * Float(sizeI) + centroids[bestJ][d] * Float(sizeJ)) / totalSize
}
centroids[bestI] = newCentroid
// Transfer members
for member in clusterMembers[bestJ] {
clusterOf[member] = bestI
}
clusterMembers[bestI].append(contentsOf: clusterMembers[bestJ])
// Propagate window constraints
clusterWindows[bestI].formUnion(clusterWindows[bestJ])
active.remove(bestJ)
}
// Build final compact assignment
let activeSorted = active.sorted()
var clusterMap = [Int: Int]() // old cluster ID new compact ID
for (newId, oldId) in activeSorted.enumerated() {
clusterMap[oldId] = newId
}
let assignment = (0..<n).map { clusterMap[clusterOf[$0]]! }
let finalCentroids = activeSorted.map { centroids[$0] }
return (assignment, finalCentroids)
}
/// Cosine distance between two vectors: 1 - cosine_similarity.
/// Returns value in [0, 2].
static func cosineDistance(_ a: [Float], _ b: [Float]) -> Float {
let n = min(a.count, b.count)
guard n > 0 else { return 2.0 }
var dot: Float = 0, normA: Float = 0, normB: Float = 0
for i in 0..<n {
dot += a[i] * b[i]
normA += a[i] * a[i]
normB += b[i] * b[i]
}
let denom = sqrt(normA) * sqrt(normB)
guard denom > 1e-10 else { return 2.0 }
return 1.0 - dot / denom
}
}
@@ -0,0 +1,570 @@
import Foundation
import MLXCommon
import MLX
import AudioCommon
// MARK: - Configuration
/// Configuration for speaker diarization thresholds.
///
/// Shared by all diarization engines (Pyannote and Sortformer).
public struct DiarizationConfig: Sendable {
/// Onset threshold for speaker activity
public var onset: Float
/// Offset threshold for speaker activity
public var offset: Float
/// Minimum speech segment duration in seconds
public var minSpeechDuration: Float
/// Minimum silence duration between segments in seconds
public var minSilenceDuration: Float
/// Cosine distance threshold for merging speaker clusters (0.0-2.0).
/// Lower = more merges (fewer speakers). Default 0.715.
public var clusteringThreshold: Float
public init(
onset: Float = 0.5,
offset: Float = 0.3,
minSpeechDuration: Float = 0.3,
minSilenceDuration: Float = 0.15,
clusteringThreshold: Float = 0.715
) {
self.onset = onset
self.offset = offset
self.minSpeechDuration = minSpeechDuration
self.minSilenceDuration = minSilenceDuration
self.clusteringThreshold = clusteringThreshold
}
public static let `default` = DiarizationConfig()
}
// MARK: - Result
/// Result of speaker diarization.
public struct DiarizationResult: Sendable {
/// Diarized speech segments with speaker IDs
public let segments: [DiarizedSegment]
/// Number of distinct speakers found
public let numSpeakers: Int
/// Centroid embedding for each speaker (speaker ID 256-dim embedding)
public let speakerEmbeddings: [[Float]]
public init(segments: [DiarizedSegment], numSpeakers: Int, speakerEmbeddings: [[Float]]) {
self.segments = segments
self.numSpeakers = numSpeakers
self.speakerEmbeddings = speakerEmbeddings
}
}
// MARK: - Pipeline
/// Pyannote-based speaker diarization: segmentation + per-window embedding + constrained clustering.
///
/// - Warning: This class is not thread-safe. Create separate instances for concurrent use.
///
/// Pipeline (with optional VAD pre-filter):
/// 0. **VAD Pre-filter** (optional): Silero VAD masks non-speech regions reduces false alarms
/// 1. **Segmentation**: Pyannote on 10s sliding windows (50% overlap) per-speaker probability tracks
/// 2. **Per-window Embedding**: WeSpeaker 256-dim embedding per local speaker from non-overlapping speech
/// 3. **Constrained Clustering**: Agglomerative clustering with same-window constraint global speaker IDs
///
/// ```swift
/// let pipeline = try await PyannoteDiarizationPipeline.fromPretrained(useVADFilter: true)
/// let result = pipeline.diarize(audio: samples, sampleRate: 16000)
/// for seg in result.segments {
/// print("Speaker \(seg.speakerId): [\(seg.startTime)s - \(seg.endTime)s]")
/// }
/// ```
public final class PyannoteDiarizationPipeline {
/// Pyannote segmentation model
let segmentationModel: SegmentationModel
/// Segmentation config
let segConfig: SegmentationConfig
/// WeSpeaker embedding model
public let embeddingModel: WeSpeakerModel
/// Optional Silero VAD for pre-filtering non-speech
let vadModel: SileroVADModel?
init(
segmentationModel: SegmentationModel,
segConfig: SegmentationConfig,
embeddingModel: WeSpeakerModel,
vadModel: SileroVADModel? = nil
) {
self.segmentationModel = segmentationModel
self.segConfig = segConfig
self.embeddingModel = embeddingModel
self.vadModel = vadModel
}
/// Load pre-trained models for diarization.
///
/// Downloads both the pyannote segmentation model and WeSpeaker embedding model.
///
/// - Parameters:
/// - segModelId: HuggingFace model ID for segmentation
/// - embModelId: HuggingFace model ID for speaker embeddings (auto-selected by engine if nil)
/// - embeddingEngine: inference backend for speaker embeddings (`.mlx` or `.coreml`)
/// - progressHandler: callback for download progress
/// - Returns: ready-to-use diarization pipeline
public static func fromPretrained(
segModelId: String = PyannoteVADModel.defaultModelId,
embModelId: String? = nil,
embeddingEngine: WeSpeakerEngine = .mlx,
useVADFilter: Bool = false,
cacheBaseDir: URL? = nil,
offlineMode: Bool = false,
progressHandler: ((Double, String) -> Void)? = nil
) async throws -> PyannoteDiarizationPipeline {
progressHandler?(0.0, "Downloading segmentation model...")
// Load segmentation model
let segCacheDir = try HuggingFaceDownloader.getCacheDirectory(for: segModelId, basePath: cacheBaseDir)
try await HuggingFaceDownloader.downloadWeights(
modelId: segModelId,
to: segCacheDir,
offlineMode: offlineMode,
progressHandler: { progress in
progressHandler?(progress * 0.3, "Downloading segmentation weights...")
}
)
let segConfig = SegmentationConfig.default
let segModel = SegmentationModel(config: segConfig)
try SegmentationWeightLoader.loadWeights(model: segModel, from: segCacheDir)
progressHandler?(0.3, "Downloading speaker embedding model...")
// Load embedding model
let resolvedEmbModelId = embModelId ?? (embeddingEngine == .coreml ? WeSpeakerModel.defaultCoreMLModelId : WeSpeakerModel.defaultModelId)
let embCacheDir: URL? = if let cacheBaseDir { try HuggingFaceDownloader.getCacheDirectory(for: resolvedEmbModelId, basePath: cacheBaseDir) } else { nil }
let embModel = try await WeSpeakerModel.fromPretrained(
modelId: embModelId,
engine: embeddingEngine,
cacheDir: embCacheDir,
offlineMode: offlineMode,
progressHandler: { progress, status in
progressHandler?(0.3 + progress * 0.4, status)
}
)
// Optionally load Silero VAD for pre-filtering
var vadModel: SileroVADModel? = nil
if useVADFilter {
progressHandler?(0.7, "Downloading VAD filter model...")
let vadCacheDir: URL? = if let cacheBaseDir { try HuggingFaceDownloader.getCacheDirectory(for: SileroVADModel.defaultModelId, basePath: cacheBaseDir) } else { nil }
vadModel = try await SileroVADModel.fromPretrained(
engine: .mlx,
cacheDir: vadCacheDir,
offlineMode: offlineMode,
progressHandler: { progress, status in
progressHandler?(0.7 + progress * 0.25, status)
}
)
}
progressHandler?(1.0, "Ready")
return PyannoteDiarizationPipeline(
segmentationModel: segModel,
segConfig: segConfig,
embeddingModel: embModel,
vadModel: vadModel
)
}
/// Run speaker diarization on audio.
///
/// - Parameters:
/// - audio: PCM Float32 audio samples
/// - sampleRate: sample rate of the input audio
/// - config: diarization configuration
/// - Returns: diarization result with speaker-labeled segments
public func diarize(
audio: [Float],
sampleRate: Int,
config: DiarizationConfig = .default
) -> DiarizationResult {
diarize(audio: audio, sampleRate: sampleRate, config: config, progressHandler: nil)
}
/// Diarize audio with progress reporting and optional cancellation.
///
/// Same as `diarize(audio:sampleRate:config:)` but reports progress during
/// the two most expensive stages (segmentation and embedding extraction).
/// The handler returns a `Bool`: `true` to continue, `false` to cancel.
/// When cancelled, an empty `DiarizationResult` is returned immediately.
///
/// - Parameters:
/// - audio: PCM Float32 audio samples
/// - sampleRate: sample rate of the input audio
/// - config: diarization configuration
/// - progressHandler: called with (progress 0.01.0, stage description);
/// return `true` to continue or `false` to cancel
/// - Returns: diarization result with speaker-labeled segments
public func diarize(
audio: [Float],
sampleRate: Int,
config: DiarizationConfig = .default,
progressHandler: ((Float, String) -> Bool)?
) -> DiarizationResult {
let emptyResult = DiarizationResult(segments: [], numSpeakers: 0, speakerEmbeddings: [])
let samples = DiarizationHelpers.resample(audio, from: sampleRate, to: segConfig.sampleRate)
// Stage 0 (optional): VAD pre-filter mask non-speech to reduce false alarms
let speechMask: [SpeechSegment]?
if let vadModel {
if progressHandler?(0, "VAD pre-filtering") == false {
return emptyResult
}
speechMask = vadModel.detectSpeech(
audio: samples, sampleRate: segConfig.sampleRate)
} else {
speechMask = nil
}
if let speechMask, speechMask.isEmpty {
return emptyResult
}
// Run embedding-clustered diarization pipeline
return runEmbeddingClusteredDiarization(
samples: samples, config: config, speechMask: speechMask,
progressHandler: progressHandler)
}
/// Extract segments of a target speaker from audio.
///
/// Given a reference embedding (from `WeSpeakerModel.embed()`), finds the
/// speaker with highest cosine similarity and returns their segments.
///
/// - Parameters:
/// - audio: PCM Float32 audio samples
/// - sampleRate: sample rate of the input audio
/// - targetEmbedding: 256-dim reference embedding of the target speaker
/// - config: diarization configuration
/// - Returns: speech segments belonging to the target speaker
public func extractSpeaker(
audio: [Float],
sampleRate: Int,
targetEmbedding: [Float],
config: DiarizationConfig = .default
) -> [SpeechSegment] {
let result = diarize(audio: audio, sampleRate: sampleRate, config: config)
guard result.numSpeakers > 0 else { return [] }
// Find speaker with highest cosine similarity to target
var bestSpeaker = 0
var bestSimilarity: Float = -1
for (i, centroid) in result.speakerEmbeddings.enumerated() {
let sim = WeSpeakerModel.cosineSimilarity(centroid, targetEmbedding)
if sim > bestSimilarity {
bestSimilarity = sim
bestSpeaker = i
}
}
return result.segments
.filter { $0.speakerId == bestSpeaker }
.map { SpeechSegment(startTime: $0.startTime, endTime: $0.endTime) }
}
// MARK: - Embedding-Clustered Diarization
/// Per-window raw probability tracks (3 speakers × nFrames).
private struct WindowProbs {
let startSample: Int
let endSample: Int
/// Speaker probability tracks [3][nFrames]
let tracks: [[Float]]
}
/// Per-window per-speaker embedding for clustering.
private struct WindowSpeakerEmbedding {
let windowIndex: Int
let localSpeakerId: Int
let embedding: [Float]
}
/// Run diarization using per-window speaker embeddings + constrained agglomerative clustering.
///
/// 1. Segment all windows per-speaker probability tracks
/// 2. Extract per-window per-speaker embeddings from non-overlapping speech
/// 3. Constrained clustering (same-window items never merge) global speaker IDs
/// 4. Map cluster IDs back to binarized segments
private func runEmbeddingClusteredDiarization(
samples: [Float],
config: DiarizationConfig,
speechMask: [SpeechSegment]?,
progressHandler: ((Float, String) -> Bool)? = nil
) -> DiarizationResult {
let windowDuration: Float = 10.0
let sampleRate = segConfig.sampleRate
let windowSamples = Int(windowDuration * Float(sampleRate))
let framesPerChunk = 589
let frameDuration = windowDuration / Float(framesPerChunk)
let stepSamples = windowSamples / 2 // 50% overlap
let numSamples = samples.count
guard numSamples > 0 else {
return DiarizationResult(segments: [], numSpeakers: 0, speakerEmbeddings: [])
}
// Generate window positions with 50% overlap
var positions = [(start: Int, end: Int)]()
if numSamples <= windowSamples {
positions.append((0, numSamples))
} else {
var start = 0
while start + windowSamples <= numSamples {
positions.append((start, start + windowSamples))
start += stepSamples
}
if positions.isEmpty || positions.last!.end < numSamples {
positions.append((numSamples - windowSamples, numSamples))
}
}
// Step 1: Run segmentation on all windows, collect probability tracks
// Progress: both steps iterate over all windows, so total = 2 * windowCount
let totalUnits = positions.count * 2
var completedUnits = 0
var windowProbs = [WindowProbs]()
let emptyResult = DiarizationResult(segments: [], numSpeakers: 0, speakerEmbeddings: [])
for (posIdx, (start, end)) in positions.enumerated() {
completedUnits += 1
if progressHandler?(Float(completedUnits) / Float(totalUnits), "Segmenting \(posIdx + 1)/\(positions.count)") == false {
return emptyResult
}
var window = Array(samples[start..<end])
if window.count < windowSamples {
window.append(contentsOf: [Float](repeating: 0, count: windowSamples - window.count))
}
let input = MLXArray(window).reshaped(1, 1, windowSamples)
let posteriors = segmentationModel(input)
let speakerProbs = PowersetDecoder.speakerProbabilities(from: posteriors)
eval(speakerProbs)
var tracks = [[Float]]()
for spk in 0..<3 {
tracks.append(speakerProbs[0, 0..., spk].asArray(Float.self))
}
windowProbs.append(WindowProbs(startSample: start, endSample: end, tracks: tracks))
}
guard !windowProbs.isEmpty else {
return DiarizationResult(segments: [], numSpeakers: 0, speakerEmbeddings: [])
}
// Step 2: Extract per-window per-speaker embeddings from non-overlapping speech
let minEmbeddingSamples = sampleRate / 2 // 0.5s minimum for embedding
var windowEmbeddings = [WindowSpeakerEmbedding]()
for (wIdx, wp) in windowProbs.enumerated() {
completedUnits += 1
if progressHandler?(Float(completedUnits) / Float(totalUnits), "Embedding \(wIdx + 1)/\(windowProbs.count)") == false {
return emptyResult
}
let windowStartSample = wp.startSample
for localSpk in 0..<3 {
let probs = wp.tracks[localSpk]
// Binarize this speaker's track
let binarySegments = PowersetDecoder.binarize(
probs: probs, onset: config.onset,
offset: config.offset, frameDuration: frameDuration)
guard !binarySegments.isEmpty else { continue }
// Collect audio from frames where ONLY this speaker is active (non-overlapping)
var spkAudio = [Float]()
for seg in binarySegments {
let segStartFrame = Int(seg.startTime / frameDuration)
let segEndFrame = min(Int(seg.endTime / frameDuration), probs.count)
for frame in segStartFrame..<segEndFrame {
// Check if other speakers are below offset at this frame
var otherActive = false
for otherSpk in 0..<3 where otherSpk != localSpk {
if wp.tracks[otherSpk][frame] >= config.offset {
otherActive = true
break
}
}
if otherActive { continue }
// Extract audio samples for this frame
let frameStartSample = windowStartSample + Int(Float(frame) * frameDuration * Float(sampleRate))
let frameEndSample = min(
windowStartSample + Int(Float(frame + 1) * frameDuration * Float(sampleRate)),
samples.count
)
if frameEndSample > frameStartSample {
spkAudio.append(contentsOf: samples[frameStartSample..<frameEndSample])
}
}
}
// Need minimum 0.5s of audio for a reliable embedding
guard spkAudio.count >= minEmbeddingSamples else { continue }
let embedding = embeddingModel.embed(audio: spkAudio, sampleRate: sampleRate)
windowEmbeddings.append(WindowSpeakerEmbedding(
windowIndex: wIdx, localSpeakerId: localSpk, embedding: embedding))
}
}
// Handle edge case: no embeddings could be extracted
guard !windowEmbeddings.isEmpty else {
return DiarizationResult(segments: [], numSpeakers: 0, speakerEmbeddings: [])
}
// Step 3: Constrained agglomerative clustering
let clusterItems = windowEmbeddings.map {
DiarizationHelpers.ClusterItem(
windowIndex: $0.windowIndex,
localSpeakerId: $0.localSpeakerId,
embedding: $0.embedding)
}
let (clusterAssignment, centroids) = DiarizationHelpers.constrainedAgglomerativeClustering(
items: clusterItems, threshold: config.clusteringThreshold)
// Build mapping: (windowIndex, localSpeakerId) global cluster ID
var localToGlobal = [Int: [Int: Int]]() // windowIndex (localSpeakerId globalId)
for (i, we) in windowEmbeddings.enumerated() {
localToGlobal[we.windowIndex, default: [:]][we.localSpeakerId] = clusterAssignment[i]
}
// Step 4: Build segments with global speaker IDs
var diarizedSegments = [DiarizedSegment]()
for (w, wp) in windowProbs.enumerated() {
let windowStartTime = Float(wp.startSample) / Float(sampleRate)
let windowEndTime = Float(wp.endSample) / Float(sampleRate)
// Center zone ownership (same as before)
let prevEnd = w > 0 ?
Float(positions[w - 1].end) / Float(sampleRate) : 0
let nextStart = w + 1 < positions.count ?
Float(positions[w + 1].start) / Float(sampleRate) : Float(numSamples) / Float(sampleRate)
let ownStart = w > 0 ? (windowStartTime + prevEnd) / 2 : 0
let ownEnd = w + 1 < positions.count ?
(windowEndTime + nextStart) / 2 : Float(numSamples) / Float(sampleRate)
for localSpk in 0..<3 {
guard let globalSpk = localToGlobal[w]?[localSpk] else {
continue // No embedding for this speaker skip (insufficient audio)
}
let probs = wp.tracks[localSpk]
let segments = PowersetDecoder.binarize(
probs: probs, onset: config.onset,
offset: config.offset, frameDuration: frameDuration)
for seg in segments {
let absStart = windowStartTime + seg.startTime
let absEnd = min(windowStartTime + seg.endTime, windowEndTime)
// Clip to center zone
let clippedStart = max(absStart, ownStart)
let clippedEnd = min(absEnd, ownEnd)
guard clippedEnd - clippedStart >= config.minSpeechDuration else { continue }
// Apply VAD mask if present
if let speechMask {
if let trimmed = trimToSpeechMask(
start: clippedStart, end: clippedEnd,
speechRegions: speechMask,
minDuration: config.minSpeechDuration
) {
diarizedSegments.append(DiarizedSegment(
startTime: trimmed.startTime,
endTime: trimmed.endTime,
speakerId: globalSpk
))
}
} else {
diarizedSegments.append(DiarizedSegment(
startTime: clippedStart,
endTime: clippedEnd,
speakerId: globalSpk
))
}
}
}
}
diarizedSegments.sort { $0.startTime < $1.startTime }
// Compact speaker IDs and merge
diarizedSegments = DiarizationHelpers.compactSpeakerIds(diarizedSegments)
let merged = DiarizationHelpers.mergeSegments(
diarizedSegments, minSilence: config.minSilenceDuration)
let numSpeakers = Set(merged.map(\.speakerId)).count
// Re-compact centroids to match compacted speaker IDs
let finalCentroids: [[Float]]
if numSpeakers <= centroids.count {
finalCentroids = Array(centroids.prefix(numSpeakers))
} else {
// Pad with zero embeddings for speakers that had no embedding
var padded = centroids
while padded.count < numSpeakers {
padded.append([Float](repeating: 0, count: 256))
}
finalCentroids = padded
}
return DiarizationResult(
segments: merged,
numSpeakers: numSpeakers,
speakerEmbeddings: finalCentroids
)
}
/// Trim a segment to intersect with speech regions.
private func trimToSpeechMask(
start: Float, end: Float,
speechRegions: [SpeechSegment],
minDuration: Float
) -> (startTime: Float, endTime: Float)? {
let segDuration = end - start
guard segDuration > 0 else { return nil }
var totalOverlap: Float = 0
var trimStart: Float = end
var trimEnd: Float = start
for vad in speechRegions {
let oStart = max(start, vad.startTime)
let oEnd = min(end, vad.endTime)
if oStart < oEnd {
totalOverlap += oEnd - oStart
trimStart = min(trimStart, oStart)
trimEnd = max(trimEnd, oEnd)
}
}
guard totalOverlap / segDuration >= 0.5,
trimEnd - trimStart >= minDuration else { return nil }
return (trimStart, trimEnd)
}
}
/// Backwards-compatible alias for the renamed pipeline.
public typealias DiarizationPipeline = PyannoteDiarizationPipeline
@@ -0,0 +1,505 @@
import Foundation
import Accelerate
import AudioCommon
#if canImport(CoreML)
import CoreML
#endif
/// Voice Activity Detection using FireRedVAD (DFSMN, CoreML).
///
/// A lightweight 588K-param model using DFSMN blocks (depthwise Conv1d)
/// for temporal context. Runs on Neural Engine + CPU via CoreML.
///
/// - Warning: This class is not thread-safe. Create separate instances for concurrent use.
///
/// ```swift
/// let vad = try await FireRedVADModel.fromPretrained()
/// let segments = vad.detectSpeech(audio: samples, sampleRate: 16000)
/// ```
public final class FireRedVADModel {
/// Default HuggingFace model ID
public static let defaultModelId = "aufklarer/FireRedVAD-CoreML"
/// Whether the model weights are loaded and ready for inference.
var _isLoaded = true
#if canImport(CoreML)
/// CoreML compiled model
private let coremlModel: MLModel
#endif
/// Feature extractor: 80-dim log Mel fbank (Kaldi-compatible)
private let featureExtractor: KaldiFbankExtractor
/// Post-processing config
public var speechThreshold: Float = 0.4
public var smoothWindowSize: Int = 5
public var minSpeechDuration: Float = 0.2
public var minSilenceDuration: Float = 0.2
#if canImport(CoreML)
init(coremlModel: MLModel) {
self.coremlModel = coremlModel
self.featureExtractor = KaldiFbankExtractor()
}
#endif
// MARK: - Model Loading
/// Load FireRedVAD from HuggingFace.
public static func fromPretrained(
modelId: String = defaultModelId,
cacheDir: URL? = nil,
offlineMode: Bool = false,
progressHandler: ((Double, String) -> Void)? = nil
) async throws -> FireRedVADModel {
#if canImport(CoreML)
progressHandler?(0.0, "Downloading model...")
let cacheDir = try cacheDir ?? HuggingFaceDownloader.getCacheDirectory(for: modelId)
try await HuggingFaceDownloader.downloadWeights(
modelId: modelId,
to: cacheDir,
additionalFiles: [
"fireredvad.mlmodelc/**",
"config.json",
"cmvn.json",
],
offlineMode: offlineMode,
progressHandler: { progress in
progressHandler?(progress * 0.8, "Downloading model...")
}
)
progressHandler?(0.8, "Loading CoreML model...")
let modelURL = cacheDir.appendingPathComponent(
"fireredvad.mlmodelc", isDirectory: true)
guard FileManager.default.fileExists(atPath: modelURL.path) else {
throw AudioModelError.modelLoadFailed(
modelId: modelId,
reason: "CoreML model not found at \(modelURL.path)")
}
let mlConfig = MLModelConfiguration()
mlConfig.computeUnits = CoreMLComputeUnitsResolver.resolved(default: .cpuAndNeuralEngine)
let model = try MLModel(contentsOf: modelURL, configuration: mlConfig)
progressHandler?(1.0, "Ready")
return FireRedVADModel(coremlModel: model)
#else
throw AudioModelError.invalidConfiguration(
model: "FireRedVAD", reason: "CoreML not available on this platform")
#endif
}
/// Load FireRedVAD from a local directory containing fireredvad.mlmodelc.
public static func fromLocal(path: String) throws -> FireRedVADModel {
#if canImport(CoreML)
let modelURL = URL(fileURLWithPath: path)
.appendingPathComponent("fireredvad.mlmodelc", isDirectory: true)
guard FileManager.default.fileExists(atPath: modelURL.path) else {
throw AudioModelError.modelLoadFailed(
modelId: path,
reason: "CoreML model not found at \(modelURL.path)")
}
let mlConfig = MLModelConfiguration()
mlConfig.computeUnits = CoreMLComputeUnitsResolver.resolved(default: .cpuAndNeuralEngine)
let model = try MLModel(contentsOf: modelURL, configuration: mlConfig)
return FireRedVADModel(coremlModel: model)
#else
throw AudioModelError.invalidConfiguration(
model: "FireRedVAD", reason: "CoreML not available on this platform")
#endif
}
// MARK: - Inference
/// Detect speech from pre-computed features (for testing with reference features).
public func detectSpeechFromFeatures(_ features: [Float]) -> [SpeechSegment] {
let numFrames = features.count / 80
guard numFrames > 0 else { return [] }
#if canImport(CoreML)
let maxFrames = 6000
let probs: [Float]
if numFrames <= maxFrames {
probs = runCoreML(features: features, numFrames: numFrames)
} else {
var allProbs = [Float]()
var offset = 0
while offset < numFrames {
let chunkFrames = min(maxFrames, numFrames - offset)
let chunkStart = offset * 80
let chunkEnd = chunkStart + chunkFrames * 80
let chunkFeatures = Array(features[chunkStart..<chunkEnd])
let chunkProbs = runCoreML(features: chunkFeatures, numFrames: chunkFrames)
allProbs.append(contentsOf: chunkProbs)
offset += chunkFrames
}
probs = allProbs
}
#else
return []
#endif
let smoothed = smoothProbabilities(probs, windowSize: smoothWindowSize)
return extractSegments(smoothed, threshold: speechThreshold, frameShift: 0.01,
minSpeechDuration: minSpeechDuration, minSilenceDuration: minSilenceDuration)
}
/// Detect speech segments in audio.
///
/// - Parameters:
/// - audio: Float32 PCM samples
/// - sampleRate: Sample rate of the audio
/// - Returns: Array of speech segments with start/end times
public func detectSpeech(
audio: [Float],
sampleRate: Int = 16000
) -> [SpeechSegment] {
// Resample if needed
let samples: [Float]
if sampleRate != 16000 {
samples = AudioFileLoader.resample(audio, from: sampleRate, to: 16000)
} else {
samples = audio
}
// Extract features
let features = featureExtractor.extract(samples)
guard features.count > 0 else { return [] }
let numFrames = features.count / 80
// Run CoreML inference (chunk if > 6000 frames / 60s)
#if canImport(CoreML)
let maxFrames = 6000
let probs: [Float]
if numFrames <= maxFrames {
probs = runCoreML(features: features, numFrames: numFrames)
} else {
// Process in chunks
var allProbs = [Float]()
var offset = 0
while offset < numFrames {
let chunkFrames = min(maxFrames, numFrames - offset)
let chunkStart = offset * 80
let chunkEnd = chunkStart + chunkFrames * 80
let chunkFeatures = Array(features[chunkStart..<chunkEnd])
let chunkProbs = runCoreML(features: chunkFeatures, numFrames: chunkFrames)
allProbs.append(contentsOf: chunkProbs)
offset += chunkFrames
}
probs = allProbs
}
#else
return []
#endif
// Post-process: smooth threshold segment
let smoothed = smoothProbabilities(probs, windowSize: smoothWindowSize)
return extractSegments(
smoothed,
threshold: speechThreshold,
frameShift: 0.01,
minSpeechDuration: minSpeechDuration,
minSilenceDuration: minSilenceDuration
)
}
#if canImport(CoreML)
private func runCoreML(features: [Float], numFrames: Int) -> [Float] {
// Create MLMultiArray [1, T, 80]
guard let input = try? MLMultiArray(
shape: [1, NSNumber(value: numFrames), 80],
dataType: .float32
) else { return [] }
let ptr = input.dataPointer.assumingMemoryBound(to: Float.self)
features.withUnsafeBufferPointer { src in
ptr.update(from: src.baseAddress!, count: min(features.count, numFrames * 80))
}
guard let prediction = try? coremlModel.prediction(
from: FireRedVADInput(features: input)
) else { return [] }
guard let outputArray = prediction.featureValue(
for: "probabilities"
)?.multiArrayValue else { return [] }
// Extract probabilities output is [1, T, 1]
var probs = [Float](repeating: 0, count: numFrames)
for i in 0..<numFrames {
probs[i] = outputArray[[0, NSNumber(value: i), 0]].floatValue
}
return probs
}
#endif
// MARK: - Post-processing
private func smoothProbabilities(_ probs: [Float], windowSize: Int) -> [Float] {
guard windowSize > 1, probs.count > windowSize else { return probs }
var smoothed = [Float](repeating: 0, count: probs.count)
let half = windowSize / 2
for i in 0..<probs.count {
let lo = max(0, i - half)
let hi = min(probs.count, i + half + 1)
var sum: Float = 0
for j in lo..<hi { sum += probs[j] }
smoothed[i] = sum / Float(hi - lo)
}
return smoothed
}
private func extractSegments(
_ probs: [Float],
threshold: Float,
frameShift: Float,
minSpeechDuration: Float,
minSilenceDuration: Float
) -> [SpeechSegment] {
// Binary decisions
let decisions = probs.map { $0 >= threshold }
// Find contiguous speech regions
var segments = [SpeechSegment]()
var speechStart: Int?
for i in 0...decisions.count {
let isSpeech = i < decisions.count ? decisions[i] : false
if isSpeech && speechStart == nil {
speechStart = i
} else if !isSpeech, let start = speechStart {
let startTime = Float(start) * frameShift
let endTime = Float(i) * frameShift
if endTime - startTime >= minSpeechDuration {
segments.append(SpeechSegment(
startTime: startTime, endTime: endTime))
}
speechStart = nil
}
}
// Merge segments with short silence gaps
guard segments.count > 1 else { return segments }
var merged = [segments[0]]
for i in 1..<segments.count {
let gap = segments[i].startTime - merged.last!.endTime
if gap < minSilenceDuration {
let last = merged.removeLast()
merged.append(SpeechSegment(
startTime: last.startTime,
endTime: segments[i].endTime))
} else {
merged.append(segments[i])
}
}
return merged
}
}
// MARK: - CoreML Input Wrapper
#if canImport(CoreML)
private class FireRedVADInput: MLFeatureProvider {
let features: MLMultiArray
init(features: MLMultiArray) {
self.features = features
}
var featureNames: Set<String> { ["features"] }
func featureValue(for featureName: String) -> MLFeatureValue? {
if featureName == "features" {
return MLFeatureValue(multiArray: features)
}
return nil
}
}
#endif
// MARK: - Kaldi-compatible Fbank Extractor
/// 80-dim log Mel filterbank extractor matching Kaldi's default settings.
///
/// Configuration: 16kHz, 25ms window, 10ms hop, 80 mel bins, snip_edges=true.
/// Uses Povey window (Hann-like) and log energy.
final class KaldiFbankExtractor {
let sampleRate: Int = 16000
let frameLength: Int = 400 // 25ms at 16kHz
let frameShift: Int = 160 // 10ms at 16kHz
let nMels: Int = 80
let nFFT: Int = 512
let preemphCoeff: Float = 0.97
private let window: [Float]
private let nBins: Int // nFFT/2 + 1 = 257
/// Pre-computed DFT basis: cos[k][n] and sin[k][n] for k=0..nBins-1, n=0..nFFT-1
/// Using matrix multiply instead of FFT for exact numerical match with numpy/torch.
private let dftCos: [Float] // [nBins * nFFT]
private let dftSin: [Float] // [nBins * nFFT]
private let melFilterbank: [Float] // [nMels * nBins]
init() {
// Povey window (Hann raised to 0.85 power)
var w = [Float](repeating: 0, count: 400)
for i in 0..<400 {
let hann = 0.5 - 0.5 * cos(2.0 * Float.pi * Float(i) / Float(400))
w[i] = pow(hann, 0.85)
}
self.window = w
self.nBins = 257
// Pre-compute DFT basis vectors
// X[k] = sum_n x[n] * exp(-j*2*pi*k*n/N)
// = sum_n x[n]*cos(2*pi*k*n/N) - j*sum_n x[n]*sin(2*pi*k*n/N)
var cosB = [Float](repeating: 0, count: 257 * 512)
var sinB = [Float](repeating: 0, count: 257 * 512)
for k in 0..<257 {
for n in 0..<512 {
let angle = 2.0 * Float.pi * Float(k) * Float(n) / 512.0
cosB[k * 512 + n] = cos(angle)
sinB[k * 512 + n] = sin(angle)
}
}
self.dftCos = cosB
self.dftSin = sinB
self.melFilterbank = KaldiFbankExtractor.buildMelFilterbank(
nMels: 80, nBins: 257, sampleRate: 16000, nFFT: 512)
}
/// Extract 80-dim log Mel features from audio samples.
/// Returns flat array [T * 80] where T is the number of frames.
///
/// Uses pre-computed DFT basis matrices with Accelerate `vDSP_mmul` for
/// exact numerical match with `numpy.fft.rfft` while maintaining high speed.
func extract(_ samples: [Float]) -> [Float] {
let numFrames = max(0, (samples.count - frameLength) / frameShift + 1)
guard numFrames > 0 else { return [] }
var allFeatures = [Float]()
allFeatures.reserveCapacity(numFrames * nMels)
var padded = [Float](repeating: 0, count: nFFT)
var realParts = [Float](repeating: 0, count: nBins)
var imagParts = [Float](repeating: 0, count: nBins)
var powerSpec = [Float](repeating: 0, count: nBins)
var melEnergies = [Float](repeating: 0, count: nMels)
for frame in 0..<numFrames {
let start = frame * frameShift
// Scale to int16 range
var rawFrame = [Float](repeating: 0, count: frameLength)
for i in 0..<frameLength {
rawFrame[i] = samples[start + i] * 32768.0
}
// DC offset removal
var dcMean: Float = 0
vDSP_meanv(rawFrame, 1, &dcMean, vDSP_Length(frameLength))
var negMean = -dcMean
vDSP_vsadd(rawFrame, 1, &negMean, &rawFrame, 1, vDSP_Length(frameLength))
// Pre-emphasis: y[0] = x[0]*(1-coeff), y[n] = x[n] - coeff*x[n-1]
for i in stride(from: frameLength - 1, through: 1, by: -1) {
rawFrame[i] -= preemphCoeff * rawFrame[i - 1]
}
rawFrame[0] *= (1.0 - preemphCoeff)
// Window + zero-pad
for i in 0..<frameLength { padded[i] = rawFrame[i] * window[i] }
for i in frameLength..<nFFT { padded[i] = 0 }
// DFT via Accelerate matrix-vector multiply
// real[k] = sum_n padded[n] * cos(2πkn/N) = dftCos[257×512] × padded[512]
// imag[k] = sum_n padded[n] * sin(2πkn/N) = dftSin[257×512] × padded[512]
vDSP_mmul(dftCos, 1, padded, 1, &realParts, 1,
vDSP_Length(nBins), 1, vDSP_Length(nFFT))
vDSP_mmul(dftSin, 1, padded, 1, &imagParts, 1,
vDSP_Length(nBins), 1, vDSP_Length(nFFT))
// Power spectrum: |X(k)|² = real² + imag²
vDSP_vsq(realParts, 1, &powerSpec, 1, vDSP_Length(nBins))
var imagSq = [Float](repeating: 0, count: nBins)
vDSP_vsq(imagParts, 1, &imagSq, 1, vDSP_Length(nBins))
vDSP_vadd(powerSpec, 1, imagSq, 1, &powerSpec, 1, vDSP_Length(nBins))
// Mel filterbank: mel[m] = sum_b fb[m,b] * power[b]
vDSP_mmul(melFilterbank, 1, powerSpec, 1, &melEnergies, 1,
vDSP_Length(nMels), 1, vDSP_Length(nBins))
// Log with Kaldi energy floor
for m in 0..<nMels {
melEnergies[m] = log(max(melEnergies[m], Float.ulpOfOne))
}
allFeatures.append(contentsOf: melEnergies)
}
return allFeatures
}
/// Build mel filterbank matrix [nMels, nBins] matching Kaldi's computation.
///
/// Kaldi uses Hz-domain center frequencies for triangular filter weights,
/// not integer bin indices. Each FFT bin maps to a Hz frequency via
/// `bin * sampleRate / nFFT`, and the weight is computed from the distance
/// to the neighboring mel center frequencies in Hz.
private static func buildMelFilterbank(
nMels: Int, nBins: Int, sampleRate: Int, nFFT: Int
) -> [Float] {
func hzToMel(_ hz: Float) -> Float {
return 1127.0 * log(1.0 + hz / 700.0)
}
func melToHz(_ mel: Float) -> Float {
return 700.0 * (exp(mel / 1127.0) - 1.0)
}
let fMin: Float = 20.0
let fMax = Float(sampleRate) / 2.0
let melMin = hzToMel(fMin)
let melMax = hzToMel(fMax)
// Mel center frequencies in Hz
var centerFreqs = [Float](repeating: 0, count: nMels + 2)
for i in 0..<(nMels + 2) {
let mel = melMin + Float(i) * (melMax - melMin) / Float(nMels + 1)
centerFreqs[i] = melToHz(mel)
}
// Build triangular filters using Hz-domain weights (Kaldi convention)
var filterbank = [Float](repeating: 0, count: nMels * nBins)
let binToHz = Float(sampleRate) / Float(nFFT)
for m in 0..<nMels {
let leftHz = centerFreqs[m]
let centerHz = centerFreqs[m + 1]
let rightHz = centerFreqs[m + 2]
for b in 0..<nBins {
let freqHz = Float(b) * binToHz
if freqHz >= leftHz && freqHz <= centerHz && centerHz > leftHz {
filterbank[m * nBins + b] = (freqHz - leftHz) / (centerHz - leftHz)
} else if freqHz > centerHz && freqHz <= rightHz && rightHz > centerHz {
filterbank[m * nBins + b] = (rightHz - freqHz) / (rightHz - centerHz)
}
}
}
return filterbank
}
}
@@ -0,0 +1,238 @@
import Foundation
import Accelerate
import MLX
/// 80-dim log-mel feature extractor for WeSpeaker (speaker embeddings).
///
/// Uses vDSP for FFT and mel filterbank computation.
/// Parameters: nFFT=400, hop=160, 80 mel bins, 16kHz.
/// Matches Kaldi FBank defaults used by WeSpeaker: pre-emphasis=0.97,
/// HTK mel scale, Povey window, fMin=20Hz.
class MelFeatureExtractor {
let sampleRate: Int = 16000
let nFFT: Int = 400
let hopLength: Int = 160
let nMels: Int = 80
let preEmphasis: Float = 0.97
private let paddedFFT: Int = 512
private let log2PaddedFFT: vDSP_Length = 9
private var fftSetup: FFTSetup
private var window: [Float]
private var melFilterbank: [Float] // [nMels, nBins]
init() {
// Hamming window: matches pyannote/wespeaker inference pipeline
// (pyannote uses window_type='hamming', NOT Kaldi default 'povey')
window = [Float](repeating: 0, count: 400)
for i in 0..<400 {
window[i] = 0.54 - 0.46 * cos(2.0 * Float.pi * Float(i) / Float(399))
}
guard let setup = vDSP_create_fftsetup(9, FFTRadix(kFFTRadix2)) else {
fatalError("Failed to create vDSP FFT setup")
}
fftSetup = setup
melFilterbank = []
setupMelFilterbank()
}
deinit {
vDSP_destroy_fftsetup(fftSetup)
}
private func setupMelFilterbank() {
let fMin: Float = 20.0
let fMax: Float = Float(sampleRate) / 2.0
// HTK mel scale (Kaldi default): mel = 2595 * log10(1 + hz/700)
func hzToMel(_ hz: Float) -> Float {
return 2595.0 * log10(1.0 + hz / 700.0)
}
func melToHz(_ mel: Float) -> Float {
return 700.0 * (pow(10.0, mel / 2595.0) - 1.0)
}
let nBins = paddedFFT / 2 + 1 // 257
var fftFreqs = [Float](repeating: 0, count: nBins)
for i in 0..<nBins {
fftFreqs[i] = Float(i) * Float(sampleRate) / Float(paddedFFT)
}
let melMin = hzToMel(fMin)
let melMax = hzToMel(fMax)
let nMelPoints = nMels + 2
var melPoints = [Float](repeating: 0, count: nMelPoints)
for i in 0..<nMelPoints {
melPoints[i] = melMin + Float(i) * (melMax - melMin) / Float(nMelPoints - 1)
}
let filterFreqs = melPoints.map { melToHz($0) }
var filterDiff = [Float](repeating: 0, count: nMelPoints - 1)
for i in 0..<(nMelPoints - 1) {
filterDiff[i] = filterFreqs[i + 1] - filterFreqs[i]
}
// Build filterbank [nBins, nMels]
var filterbank = [Float](repeating: 0, count: nBins * nMels)
for bin in 0..<nBins {
let freq = fftFreqs[bin]
for mel in 0..<nMels {
let lowFreq = filterFreqs[mel]
let highFreq = filterFreqs[mel + 2]
let downSlope = (freq - lowFreq) / filterDiff[mel]
let upSlope = (highFreq - freq) / filterDiff[mel + 1]
filterbank[bin * nMels + mel] = max(0.0, min(downSlope, upSlope))
}
}
// Slaney normalization
for mel in 0..<nMels {
let enorm = 2.0 / (filterFreqs[mel + 2] - filterFreqs[mel])
for bin in 0..<nBins {
filterbank[bin * nMels + mel] *= enorm
}
}
// Transpose to [nMels, nBins]
var transposed = [Float](repeating: 0, count: nMels * nBins)
for mel in 0..<nMels {
for bin in 0..<nBins {
transposed[mel * nBins + bin] = filterbank[bin * nMels + mel]
}
}
self.melFilterbank = transposed
}
/// Extract raw 80-dim log-mel features as a flat Float array.
///
/// This avoids creating an MLXArray, useful for CoreML paths that need
/// raw floats without an MLX round-trip.
///
/// - Parameter audio: PCM Float32 samples at 16kHz
/// - Returns: `(melSpec, nFrames)` where melSpec is a flat `[nFrames * 80]` array
func extractRaw(_ audio: [Float]) -> (melSpec: [Float], nFrames: Int) {
let nBins = paddedFFT / 2 + 1
let halfPadded = paddedFFT / 2
// Pre-emphasis: y[n] = x[n] - coeff * x[n-1]
var emphasized = [Float](repeating: 0, count: audio.count)
if !audio.isEmpty {
emphasized[0] = audio[0]
for i in 1..<audio.count {
emphasized[i] = audio[i] - preEmphasis * audio[i - 1]
}
}
// Reflect padding
let padLength = nFFT / 2
var paddedAudio = [Float](repeating: 0, count: padLength + emphasized.count + padLength)
for i in 0..<padLength {
let srcIdx = min(padLength - i, emphasized.count - 1)
paddedAudio[i] = emphasized[max(0, srcIdx)]
}
for i in 0..<emphasized.count {
paddedAudio[padLength + i] = emphasized[i]
}
for i in 0..<padLength {
let srcIdx = emphasized.count - 2 - i
paddedAudio[padLength + emphasized.count + i] = emphasized[max(0, srcIdx)]
}
let nFrames = (paddedAudio.count - nFFT) / hopLength + 1
var splitReal = [Float](repeating: 0, count: halfPadded)
var splitImag = [Float](repeating: 0, count: halfPadded)
var paddedFrame = [Float](repeating: 0, count: paddedFFT)
var magnitude = [Float](repeating: 0, count: nFrames * nBins)
for frame in 0..<nFrames {
let start = frame * hopLength
paddedAudio.withUnsafeBufferPointer { buf in
vDSP_vmul(buf.baseAddress! + start, 1, window, 1, &paddedFrame, 1, vDSP_Length(nFFT))
}
for i in nFFT..<paddedFFT {
paddedFrame[i] = 0
}
for i in 0..<halfPadded {
splitReal[i] = paddedFrame[2 * i]
splitImag[i] = paddedFrame[2 * i + 1]
}
splitReal.withUnsafeMutableBufferPointer { realBuf in
splitImag.withUnsafeMutableBufferPointer { imagBuf in
var splitComplex = DSPSplitComplex(
realp: realBuf.baseAddress!,
imagp: imagBuf.baseAddress!)
vDSP_fft_zrip(fftSetup, &splitComplex, 1, log2PaddedFFT, FFTDirection(kFFTDirection_Forward))
}
}
let baseIdx = frame * nBins
magnitude[baseIdx] = splitReal[0] * splitReal[0]
magnitude[baseIdx + halfPadded] = splitImag[0] * splitImag[0]
for k in 1..<halfPadded {
magnitude[baseIdx + k] = splitReal[k] * splitReal[k] + splitImag[k] * splitImag[k]
}
}
// Mel filterbank matmul
var melSpec = [Float](repeating: 0, count: nFrames * nMels)
var filterbankT = [Float](repeating: 0, count: nBins * nMels)
vDSP_mtrans(melFilterbank, 1, &filterbankT, 1, vDSP_Length(nBins), vDSP_Length(nMels))
vDSP_mmul(magnitude, 1, filterbankT, 1, &melSpec, 1,
vDSP_Length(nFrames), vDSP_Length(nMels), vDSP_Length(nBins))
// Simple log-mel: log(max(x, 1e-10))
let count = melSpec.count
var countN = Int32(count)
var epsilon: Float = 1e-10
vDSP_vclip(melSpec, 1, &epsilon, [Float.greatestFiniteMagnitude], &melSpec, 1, vDSP_Length(count))
vvlogf(&melSpec, melSpec, &countN)
// CMN (Cepstral Mean Normalization): subtract per-bin temporal mean.
// Matches WeSpeaker Python: feat = feat - torch.mean(feat, dim=0)
// Removes channel-specific spectral shape, critical for speaker discrimination.
if nFrames > 0 {
// Compute per-bin mean
var binMeans = [Float](repeating: 0, count: nMels)
for frame in 0..<nFrames {
let base = frame * nMels
for bin in 0..<nMels {
binMeans[bin] += melSpec[base + bin]
}
}
let invFrames = 1.0 / Float(nFrames)
vDSP_vsmul(binMeans, 1, [invFrames], &binMeans, 1, vDSP_Length(nMels))
// Subtract mean from each frame
for frame in 0..<nFrames {
let base = frame * nMels
for bin in 0..<nMels {
melSpec[base + bin] -= binMeans[bin]
}
}
}
return (melSpec, nFrames)
}
/// Extract 80-dim log-mel features from audio.
/// - Parameter audio: PCM Float32 samples at 16kHz
/// - Returns: `MLXArray [T, 80]` time-major mel features
func extract(_ audio: [Float]) -> MLXArray {
let (melSpec, nFrames) = extractRaw(audio)
return MLXArray(melSpec, [nFrames, nMels])
}
}
@@ -0,0 +1,73 @@
import MLX
/// Decodes pyannote's 7-class powerset output to per-speaker probabilities.
///
/// The 7 powerset classes represent all possible speaker combinations
/// for up to 3 speakers:
/// - 0: non-speech
/// - 1: speaker 1 alone
/// - 2: speaker 2 alone
/// - 3: speaker 3 alone
/// - 4: speakers 1+2 overlap
/// - 5: speakers 1+3 overlap
/// - 6: speakers 2+3 overlap
enum PowersetDecoder {
/// Convert 7-class powerset posteriors to per-speaker probabilities.
///
/// Each speaker's probability is the sum of all classes where that speaker
/// is active (alone or in overlap).
///
/// - Parameter posteriors: `[batch, frames, 7]` softmax probabilities
/// - Returns: `[batch, frames, 3]` per-speaker probabilities
static func speakerProbabilities(from posteriors: MLXArray) -> MLXArray {
// spk1: alone(1) + with_spk2(4) + with_spk3(5)
let spk1 = posteriors[0..., 0..., 1] + posteriors[0..., 0..., 4] + posteriors[0..., 0..., 5]
// spk2: alone(2) + with_spk1(4) + with_spk3(6)
let spk2 = posteriors[0..., 0..., 2] + posteriors[0..., 0..., 4] + posteriors[0..., 0..., 6]
// spk3: alone(3) + with_spk1(5) + with_spk2(6)
let spk3 = posteriors[0..., 0..., 3] + posteriors[0..., 0..., 5] + posteriors[0..., 0..., 6]
return stacked([spk1, spk2, spk3], axis: -1)
}
/// Apply hysteresis binarization to per-speaker probabilities.
///
/// Expects probabilities in `[0, 1]` range (post-softmax or post-sigmoid).
/// If values outside this range are passed, apply sigmoid first.
///
/// - Parameters:
/// - probs: per-frame probabilities for one speaker `[frames]`, values in [0, 1]
/// - onset: threshold to start a segment
/// - offset: threshold to end a segment
/// - frameDuration: duration of one frame in seconds
/// - Returns: array of (startTime, endTime) tuples
static func binarize(
probs: [Float],
onset: Float,
offset: Float,
frameDuration: Float
) -> [(startTime: Float, endTime: Float)] {
var segments = [(startTime: Float, endTime: Float)]()
var inSpeech = false
var speechStart: Float = 0
for (i, prob) in probs.enumerated() {
let time = Float(i) * frameDuration
if !inSpeech && prob >= onset {
inSpeech = true
speechStart = time
} else if inSpeech && prob < offset {
inSpeech = false
segments.append((speechStart, time))
}
}
if inSpeech {
let endTime = Float(probs.count) * frameDuration
segments.append((speechStart, endTime))
}
return segments
}
}
@@ -0,0 +1,16 @@
import AudioCommon
extension PyannoteVADModel: ModelMemoryManageable {
public var isLoaded: Bool { _isLoaded }
public func unload() {
guard _isLoaded else { return }
model.clearParameters()
_isLoaded = false
}
public var memoryFootprint: Int {
guard _isLoaded else { return 0 }
return model.parameterMemoryBytes()
}
}
@@ -0,0 +1,97 @@
import MLX
import MLXNN
/// PyanNet segmentation model: SincNet BiLSTM Linear Classifier.
///
/// Produces per-frame powerset class probabilities for up to 3 speakers.
/// Output classes (7): non-speech, spk1, spk2, spk3, spk1+2, spk1+3, spk2+3
///
/// Weight keys at model level:
/// ```
/// sincnet.{conv, norm, wav_norm}.*
/// lstm_fwd.layers.{i}.{Wx, Wh, bias}
/// lstm_bwd.layers.{i}.{Wx, Wh, bias}
/// linear.{0,1}.{weight, bias}
/// classifier.{weight, bias}
/// ```
class SegmentationModel: Module {
let config: SegmentationConfig
let sincnet: SincNet
/// Forward and backward LSTM stacks (at top level for weight loading)
@ModuleInfo(key: "lstm_fwd") var lstmFwd: LSTMStack
@ModuleInfo(key: "lstm_bwd") var lstmBwd: LSTMStack
let linear: [Linear]
let classifier: Linear
init(config: SegmentationConfig = .default) {
self.config = config
self.sincnet = SincNet(config: config)
// Build LSTM stacks
let sincnetOutputDim = config.sincnetFilters.last! // 60
var fwdLayers = [LSTMLayer]()
var bwdLayers = [LSTMLayer]()
for i in 0 ..< config.lstmNumLayers {
let inSize = (i == 0) ? sincnetOutputDim : config.lstmHiddenSize * 2
fwdLayers.append(LSTMLayer(inputSize: inSize, hiddenSize: config.lstmHiddenSize))
bwdLayers.append(LSTMLayer(inputSize: inSize, hiddenSize: config.lstmHiddenSize))
}
// Linear layers with LeakyReLU
let lstmOutputDim = config.lstmHiddenSize * 2 // 256 (bidirectional)
var layers = [Linear]()
for i in 0 ..< config.linearNumLayers {
let inDim = (i == 0) ? lstmOutputDim : config.linearHiddenSize
layers.append(Linear(inDim, config.linearHiddenSize))
}
self.linear = layers
self.classifier = Linear(config.linearHiddenSize, config.numClasses)
// Set @ModuleInfo properties after all stored properties
self._lstmFwd.wrappedValue = LSTMStack(layers: fwdLayers)
self._lstmBwd.wrappedValue = LSTMStack(layers: bwdLayers)
}
/// Run segmentation on audio.
/// - Parameter waveform: `[batch, 1, samples]` (mono, 16kHz)
/// - Returns: `[batch, num_frames, num_classes]` class probabilities
func callAsFunction(_ waveform: MLXArray) -> MLXArray {
// SincNet: [B, 1, T] [B, 60, ~293]
var x = sincnet(waveform)
// Transpose for LSTM: [B, 60, L] [B, L, 60]
x = x.transposed(0, 2, 1)
// BiLSTM: [B, L, 60] [B, L, 256]
x = runBiLSTM(x, fwd: lstmFwd, bwd: lstmBwd)
// Linear layers with LeakyReLU
for layer in linear {
x = layer(x)
x = leakyRelu(x)
}
// Classifier + softmax: [B, L, 7]
x = classifier(x)
x = softmax(x, axis: -1)
return x
}
/// Extract speech probability from powerset output.
///
/// Classes: [non-speech, spk1, spk2, spk3, spk1+2, spk1+3, spk2+3]
/// Speech probability = 1 - P(non-speech).
///
/// - Parameter posteriors: `[batch, frames, 7]`
/// - Returns: `[batch, frames]` speech probability per frame
static func speechProbability(from posteriors: MLXArray) -> MLXArray {
let nonSpeech = posteriors[0..., 0..., 0]
return 1.0 - nonSpeech
}
}
@@ -0,0 +1,186 @@
import Foundation
import MLX
import MLXNN
/// Silero VAD v5 neural network: STFT Encoder LSTM Decoder.
///
/// Processes 512-sample audio chunks (32ms @ 16kHz) with 64 samples of context
/// prepended from the previous chunk. The STFT uses a pre-computed DFT basis
/// stored as Conv1d weights.
///
/// Architecture:
/// ```
/// Input: 576 samples (64 context + 512 new)
/// ReflectionPad(right=64) 640 samples
/// STFT Conv1d(1258, k=256, s=128) 4 frames × 258
/// Magnitude: (real² + imag²) 4 × 129
/// Encoder: 4× Conv1d+ReLU 1 × 128
/// LSTM(128128, 1 layer) hidden state [B, 128]
/// ReLU Conv1d(1281, k=1) Sigmoid probability
/// ```
///
/// Weight keys:
/// ```
/// stft.weight [258, 256, 1]
/// encoder.{0-3}.weight Conv1d weights
/// encoder.{0-3}.bias Conv1d biases
/// lstm.{Wx, Wh, bias} LSTM parameters
/// decoder.{weight, bias} Final Conv1d
/// ```
class SileroVADNetwork: Module {
// STFT: pre-computed DFT basis as Conv1d (no bias)
@ModuleInfo(key: "stft") var stft: Conv1d
// Encoder: 4 Conv1d + ReLU
let encoder: [Conv1d]
// LSTM: 128128, 1 layer (reuses LSTMLayer for parameter structure)
@ModuleInfo(key: "lstm") var lstm: LSTMLayer
// Decoder: Conv1d(1281, k=1)
@ModuleInfo(key: "decoder") var decoder: Conv1d
override init() {
// STFT: filter_length=256, hop_length=128, 1258 channels (129 real + 129 imag)
self._stft.wrappedValue = Conv1d(
inputChannels: 1, outputChannels: 258, kernelSize: 256,
stride: 128, bias: false)
// Encoder
self.encoder = [
Conv1d(inputChannels: 129, outputChannels: 128, kernelSize: 3, stride: 1, padding: 1),
Conv1d(inputChannels: 128, outputChannels: 64, kernelSize: 3, stride: 2, padding: 1),
Conv1d(inputChannels: 64, outputChannels: 64, kernelSize: 3, stride: 2, padding: 1),
Conv1d(inputChannels: 64, outputChannels: 128, kernelSize: 3, stride: 1, padding: 1),
]
// LSTM: input_size=128, hidden_size=128
self._lstm.wrappedValue = LSTMLayer(inputSize: 128, hiddenSize: 128)
// Decoder: 1281 with kernel=1
self._decoder.wrappedValue = Conv1d(
inputChannels: 128, outputChannels: 1, kernelSize: 1)
}
/// Forward pass for a single chunk.
///
/// - Parameters:
/// - samples: `[B, T]` raw audio (576 samples: 64 context + 512 new)
/// - h: LSTM hidden state `[1, B, 128]` or nil for initial state
/// - c: LSTM cell state `[1, B, 128]` or nil for initial state
/// - Returns: `(probability [B], new_h [1, B, 128], new_c [1, B, 128])`
func forward(_ samples: MLXArray, h: MLXArray?, c: MLXArray?) -> (MLXArray, MLXArray, MLXArray) {
// [B, T] [B, T, 1] for channels-last Conv1d
var x = samples.expandedDimensions(axis: -1)
// Reflection padding: 64 samples on the RIGHT side only
// (matching Silero's pad(input, [0, 64], "reflect"))
x = reflectionPadRight(x, padding: 64)
// STFT via Conv1d: [B, 640, 1] [B, 4, 258]
x = stft(x)
// Split real/imaginary and compute magnitude
let real = x[0..., 0..., ..<129]
let imag = x[0..., 0..., 129...]
x = sqrt(real * real + imag * imag) // [B, 4, 129]
// Encoder: 4× Conv1d + ReLU [B, 1, 128]
for conv in encoder {
x = relu(conv(x))
}
// LSTM with explicit h/c state
// Encoder output is [B, 1, 128] single timestep
let (newH, newC) = lstmForward(x, h: h, c: c)
// Decoder: use LSTM hidden state h, not full sequence output
// h: [1, B, 128] [B, 128] [B, 1, 128] for Conv1d
let hForDecoder = newH.squeezed(axis: 0).expandedDimensions(axis: 1)
// ReLU Conv1d(1281, k=1) Sigmoid
let prob = sigmoid(decoder(relu(hForDecoder))) // [B, 1, 1]
return (prob.squeezed(axes: [1, 2]), newH, newC)
}
/// Run LSTM with explicit hidden/cell state for streaming.
///
/// Accesses LSTMLayer's parameters directly (Wx, Wh, bias) rather than
/// calling its `callAsFunction`, which doesn't support stateful operation.
///
/// Returns (new_h [1, B, H], new_c [1, B, H])
private func lstmForward(
_ x: MLXArray, h: MLXArray?, c: MLXArray?
) -> (MLXArray, MLXArray) {
// Project all timesteps: [B, T, 4*H]
let projected = addMM(lstm.bias, x, lstm.wx.T)
let seqLen = x.dim(-2)
// Squeeze state from [1, B, H] to [B, H]
var hidden = h?.squeezed(axis: 0)
var cell = c?.squeezed(axis: 0)
for t in 0 ..< seqLen {
var ifgo = projected[0..., t, 0...]
if let h = hidden {
ifgo = ifgo + matmul(h, lstm.wh.T)
}
let pieces = split(ifgo, parts: 4, axis: -1)
let i = sigmoid(pieces[0])
let f = sigmoid(pieces[1])
let g = tanh(pieces[2])
let o = sigmoid(pieces[3])
if let c = cell {
cell = f * c + i * g
} else {
cell = i * g
}
hidden = o * tanh(cell!)
}
let newH = hidden!.expandedDimensions(axis: 0) // [1, B, H]
let newC = cell!.expandedDimensions(axis: 0) // [1, B, H]
return (newH, newC)
}
}
// MARK: - Reflection Padding
/// Right-only reflection padding for 1D data in channels-last format `[B, T, C]`.
///
/// Matches Silero's `F.pad(input, [0, 64], mode='reflect')`.
/// For input `[a, b, c, d, e]` with padding=2: `[a, b, c, d, e, d, c]`
func reflectionPadRight(_ x: MLXArray, padding: Int) -> MLXArray {
let T = x.dim(1)
guard padding > 0, T > padding else { return x }
// Right: reflect indices [T-2, T-3, ..., T-1-padding]
let rightIndices = MLXArray(Array(stride(from: T - 2, through: T - 1 - padding, by: -1)))
let rightPad = x.take(rightIndices, axis: 1)
return concatenated([x, rightPad], axis: 1)
}
/// Symmetric reflection padding for 1D data in channels-last format `[B, T, C]`.
///
/// Pads the time dimension by reflecting values at both boundaries.
/// For input `[a, b, c, d, e]` with padding=2: `[c, b, a, b, c, d, e, d, c]`
func reflectionPad1d(_ x: MLXArray, padding: Int) -> MLXArray {
let T = x.dim(1)
guard padding > 0, T > padding else { return x }
// Left: reflect indices [padding, padding-1, ..., 1]
let leftIndices = MLXArray(Array(stride(from: padding, through: 1, by: -1)))
let leftPad = x.take(leftIndices, axis: 1)
// Right: reflect indices [T-2, T-3, ..., T-1-padding]
let rightIndices = MLXArray(Array(stride(from: T - 2, through: T - 1 - padding, by: -1)))
let rightPad = x.take(rightIndices, axis: 1)
return concatenated([leftPad, x, rightPad], axis: 1)
}
@@ -0,0 +1,19 @@
import AudioCommon
extension SileroVADModel: ModelMemoryManageable {
public var isLoaded: Bool { _isLoaded }
public func unload() {
guard _isLoaded else { return }
network?.clearParameters()
#if canImport(CoreML)
coremlModel = nil
#endif
_isLoaded = false
}
public var memoryFootprint: Int {
guard _isLoaded else { return 0 }
return network?.parameterMemoryBytes() ?? 0
}
}
@@ -0,0 +1,321 @@
import Foundation
import MLXCommon
import MLX
import AudioCommon
#if canImport(CoreML)
import CoreML
#endif
/// Inference engine for Silero VAD.
public enum SileroVADEngine: String, Sendable {
/// MLX backend runs on GPU via Metal shaders.
case mlx
/// CoreML backend runs on Neural Engine + CPU, freeing the GPU.
case coreml
}
/// Streaming Voice Activity Detection using Silero VAD v5.
///
/// A lightweight (~260K params) VAD model that processes 512-sample chunks
/// (32ms @ 16kHz) with sub-millisecond latency. Carries LSTM state across
/// chunks for streaming operation.
///
/// Supports two backends:
/// - `.mlx`: GPU-based inference via MLX (default)
/// - `.coreml`: Neural Engine inference via CoreML (lower power, frees GPU)
///
/// - Warning: This class is not thread-safe. Create separate instances for concurrent use.
///
/// ```swift
/// let vad = try await SileroVADModel.fromPretrained(engine: .coreml)
///
/// // Streaming: process one chunk at a time
/// let prob = vad.processChunk(samples512) // 0.0...1.0
///
/// // Batch: detect all speech segments
/// let segments = vad.detectSpeech(audio: samples, sampleRate: 16000)
/// ```
public final class SileroVADModel {
/// The inference engine in use.
public let engine: SileroVADEngine
/// Whether the model weights are loaded and ready for inference.
var _isLoaded = true
/// The MLX neural network (nil when using CoreML engine).
let network: SileroVADNetwork?
// MARK: - MLX State
/// LSTM hidden state (carried across chunks) MLX engine
private var h: MLXArray?
/// LSTM cell state (carried across chunks) MLX engine
private var c: MLXArray?
// MARK: - CoreML State
#if canImport(CoreML)
/// CoreML compiled model (nil when using MLX engine).
var coremlModel: MLModel?
/// CoreML LSTM hidden state
var coremlH: MLMultiArray?
/// CoreML LSTM cell state
var coremlC: MLMultiArray?
#endif
/// Context buffer: last 64 samples from previous chunk
private var context: [Float]
/// Default HuggingFace model ID (MLX weights)
public static let defaultModelId = "aufklarer/Silero-VAD-v5-MLX"
/// Default HuggingFace model ID (CoreML weights)
public static let defaultCoreMLModelId = "aufklarer/Silero-VAD-v5-CoreML"
/// Number of audio samples per chunk (32ms @ 16kHz)
public static let chunkSize = 512
/// Number of context samples prepended from previous chunk
public static let contextSize = 64
/// Expected input sample rate
public static let sampleRate = 16000
init(network: SileroVADNetwork) {
self.engine = .mlx
self.network = network
self.context = [Float](repeating: 0, count: Self.contextSize)
}
#if canImport(CoreML)
init(coremlModel: MLModel) {
self.engine = .coreml
self.network = nil
self.coremlModel = coremlModel
self.context = [Float](repeating: 0, count: Self.contextSize)
}
#endif
/// Process a single 512-sample audio chunk and return speech probability.
///
/// Maintains internal LSTM state across calls. Call `resetState()` between
/// different audio streams.
///
/// - Parameter samples: exactly 512 PCM Float32 samples at 16kHz
/// - Returns: speech probability in `[0, 1]`
public func processChunk(_ samples: [Float]) -> Float {
precondition(samples.count == Self.chunkSize,
"Chunk must be \(Self.chunkSize) samples, got \(samples.count)")
// Prepend 64-sample context from previous chunk
let fullSamples = context + samples
// Save last 64 samples as context for next chunk
context = Array(samples.suffix(Self.contextSize))
switch engine {
case .mlx:
return processChunkMLX(fullSamples)
case .coreml:
#if canImport(CoreML)
return (try? processChunkCoreML(fullSamples)) ?? 0.0
#else
fatalError("CoreML not available on this platform")
#endif
}
}
/// MLX inference path.
private func processChunkMLX(_ fullSamples: [Float]) -> Float {
guard let network else { fatalError("MLX network not loaded") }
let input = MLXArray(fullSamples).reshaped(1, fullSamples.count)
let (prob, newH, newC) = network.forward(input, h: h, c: c)
h = newH
c = newC
eval(prob)
return prob.item(Float.self)
}
/// Reset LSTM and context state.
///
/// Call this between processing different audio streams to prevent
/// state leakage.
public func resetState() {
h = nil
c = nil
#if canImport(CoreML)
coremlH = nil
coremlC = nil
#endif
context = [Float](repeating: 0, count: Self.contextSize)
}
/// Detect speech segments in complete audio (batch mode).
///
/// Processes the entire audio in 512-sample chunks, collects per-chunk
/// probabilities, then applies hysteresis thresholding and duration filtering.
///
/// - Parameters:
/// - audio: PCM Float32 audio samples
/// - sampleRate: sample rate of input audio (resampled to 16kHz if needed)
/// - config: VAD configuration (defaults to Silero-tuned thresholds)
/// - Returns: array of speech segments with start/end times in seconds
public func detectSpeech(
audio: [Float],
sampleRate: Int,
config: VADConfig = .sileroDefault
) -> [SpeechSegment] {
let samples: [Float]
if sampleRate != Self.sampleRate {
samples = AudioFileLoader.resample(audio, from: sampleRate, to: Self.sampleRate)
} else {
samples = audio
}
resetState()
// Collect per-chunk probabilities
var probs = [Float]()
var offset = 0
while offset + Self.chunkSize <= samples.count {
let chunk = Array(samples[offset ..< (offset + Self.chunkSize)])
probs.append(processChunk(chunk))
offset += Self.chunkSize
}
// Handle remaining samples with zero-padding
if offset < samples.count {
var lastChunk = Array(samples[offset...])
lastChunk.append(contentsOf: [Float](repeating: 0, count: Self.chunkSize - lastChunk.count))
probs.append(processChunk(lastChunk))
}
guard !probs.isEmpty else { return [] }
// Use VADPipeline for hysteresis binarization.
// Set windowDuration = probs.count * chunkDuration so frameDuration = 0.032s
let chunkDuration: Float = Float(Self.chunkSize) / Float(Self.sampleRate)
let batchPipeline = VADPipeline(
config: VADConfig(
onset: config.onset,
offset: config.offset,
minSpeechDuration: config.minSpeechDuration,
minSilenceDuration: config.minSilenceDuration,
windowDuration: Float(probs.count) * chunkDuration,
stepRatio: 1.0
),
sampleRate: Self.sampleRate,
framesPerChunk: probs.count
)
return batchPipeline.binarize(probs: probs)
}
/// Load a pre-trained Silero VAD model from HuggingFace.
///
/// Downloads model weights on first use, then caches locally.
///
/// - Parameters:
/// - modelId: HuggingFace model ID (auto-selected by engine if not specified)
/// - engine: inference backend (`.mlx` or `.coreml`)
/// - progressHandler: callback for download progress
/// - Returns: ready-to-use VAD model
public static func fromPretrained(
modelId: String? = nil,
engine: SileroVADEngine = .mlx,
cacheDir: URL? = nil,
offlineMode: Bool = false,
progressHandler: ((Double, String) -> Void)? = nil
) async throws -> SileroVADModel {
let resolvedModelId = modelId ?? (engine == .coreml ? defaultCoreMLModelId : defaultModelId)
progressHandler?(0.0, "Downloading model...")
let cacheDir = try cacheDir ?? HuggingFaceDownloader.getCacheDirectory(for: resolvedModelId)
switch engine {
case .mlx:
try await HuggingFaceDownloader.downloadWeights(
modelId: resolvedModelId,
to: cacheDir,
offlineMode: offlineMode,
progressHandler: { progress in
progressHandler?(progress * 0.8, "Downloading weights...")
}
)
progressHandler?(0.8, "Loading model...")
let network = SileroVADNetwork()
try SileroWeightLoader.loadWeights(model: network, from: cacheDir)
progressHandler?(1.0, "Ready")
return SileroVADModel(network: network)
case .coreml:
#if canImport(CoreML)
try await HuggingFaceDownloader.downloadWeights(
modelId: resolvedModelId,
to: cacheDir,
additionalFiles: ["silero_vad.mlmodelc/**", "config.json"],
offlineMode: offlineMode,
progressHandler: { progress in
progressHandler?(progress * 0.8, "Downloading CoreML model...")
}
)
progressHandler?(0.8, "Loading CoreML model...")
let modelURL = cacheDir.appendingPathComponent("silero_vad.mlmodelc", isDirectory: true)
guard FileManager.default.fileExists(atPath: modelURL.path) else {
throw AudioModelError.modelLoadFailed(
modelId: resolvedModelId,
reason: "CoreML model not found at \(modelURL.path)")
}
let mlConfig = MLModelConfiguration()
mlConfig.computeUnits = CoreMLComputeUnitsResolver.resolved(default: .cpuAndNeuralEngine)
let model: MLModel
do {
model = try MLModel(contentsOf: modelURL, configuration: mlConfig)
} catch {
throw AudioModelError.modelLoadFailed(
modelId: resolvedModelId,
reason: "Failed to load CoreML model",
underlying: error)
}
progressHandler?(1.0, "Ready")
return SileroVADModel(coremlModel: model)
#else
throw AudioModelError.invalidConfiguration(
model: "SileroVAD", reason: "CoreML not available on this platform")
#endif
}
}
}
// MARK: - VoiceActivityDetectionModel
extension SileroVADModel: VoiceActivityDetectionModel {
public var inputSampleRate: Int { Self.sampleRate }
/// Protocol conformance uses default Silero config.
public func detectSpeech(audio: [Float], sampleRate: Int) -> [SpeechSegment] {
detectSpeech(audio: audio, sampleRate: sampleRate, config: .sileroDefault)
}
}
// MARK: - StreamingVADProvider
extension SileroVADModel: StreamingVADProvider {
public var chunkSize: Int { Self.chunkSize }
}
@@ -0,0 +1,36 @@
import Foundation
import MLXCommon
import MLX
import MLXNN
import AudioCommon
/// Weight loading for the Silero VAD v5 model.
///
/// Loads from safetensors files produced by `scripts/convert_silero_vad.py`.
/// The conversion script transposes Conv1d weights and sums LSTM biases,
/// so loading is a straightforward parameter tree update.
enum SileroWeightLoader {
/// Load weights from a directory containing model.safetensors.
static func loadWeights(
model: SileroVADNetwork,
from directory: URL
) throws {
let weightsURL = directory.appendingPathComponent("model.safetensors")
guard FileManager.default.fileExists(atPath: weightsURL.path) else {
throw WeightLoadingError.noWeightsFound(directory)
}
let weights = try MLX.loadArrays(url: weightsURL)
// Build nested parameter tree from flat keys
let parameters = ModuleParameters.unflattened(weights)
// Apply to model
try model.update(parameters: parameters, verify: .noUnusedKeys)
// Materialize all parameters
MLX.eval(model.parameters())
}
}
@@ -0,0 +1,129 @@
import MLX
import MLXNN
/// SincNet feature extractor: 3 conv+pool+norm+activation layers.
///
/// The first conv layer uses pre-computed sinc bandpass filters (computed during
/// weight conversion). At runtime, all three layers are standard Conv1d.
///
/// All data flows in MLX channels-last format: `[batch, length, channels]`.
///
/// Architecture:
/// InstanceNorm(1) Conv1d(1,80,k=251,s=10) |·| MaxPool(3,3) InstanceNorm(80) LeakyReLU
/// Conv1d(80,60,k=5) MaxPool(3,3) InstanceNorm(60) LeakyReLU
/// Conv1d(60,60,k=5) MaxPool(3,3) InstanceNorm(60) LeakyReLU
class SincNet: Module {
/// Input waveform normalization
@ModuleInfo(key: "wav_norm") var wavNorm: InstanceNorm
/// Three conv layers (first is pre-computed sinc filterbank)
let conv: [Conv1d]
/// Instance norm after each conv+pool
let norm: [InstanceNorm]
init(config: SegmentationConfig) {
let filters = config.sincnetFilters
let kernels = config.sincnetKernelSizes
let strides = config.sincnetStrides
// Build conv layers: input channels are [1, 80, 60]
let inputChannels = [1] + Array(filters.dropLast())
self.conv = zip(zip(inputChannels, filters), zip(kernels, strides)).map { arg in
let ((inC, outC), (k, s)) = arg
return Conv1d(inputChannels: inC, outputChannels: outC, kernelSize: k, stride: s)
}
self.norm = filters.map { InstanceNorm(dimensions: $0) }
self._wavNorm.wrappedValue = InstanceNorm(dimensions: 1)
}
/// Forward pass.
/// - Parameter x: `[batch, 1, samples]` raw waveform (channels-first, transposed internally)
/// - Returns: `[batch, channels, frames]` feature frames (channels-first for LSTM transpose)
func callAsFunction(_ x: MLXArray) -> MLXArray {
// Convert from [B, C, T] to MLX channels-last [B, T, C]
var out = x.transposed(0, 2, 1)
// Normalize input waveform: [B, T, 1]
out = wavNorm(out)
for i in 0 ..< conv.count {
// Conv1d: [B, T, Cin] [B, T', Cout]
out = conv[i](out)
// First layer uses abs() (sinc filterbank energy)
if i == 0 {
out = abs(out)
}
// MaxPool1d(3, stride=3) pool over time (axis -2)
out = maxPool1d(out, kernelSize: 3, stride: 3)
// InstanceNorm + LeakyReLU
out = norm[i](out)
out = leakyRelu(out)
}
// Convert back to [B, C, T] for downstream compatibility
return out.transposed(0, 2, 1)
}
}
/// Instance normalization for 1D data (channels-last format).
///
/// Input shape: `[batch, length, channels]`
/// Normalizes over the length dimension per-channel, with learnable affine.
class InstanceNorm: Module {
let dimensions: Int
var weight: MLXArray
var bias: MLXArray
init(dimensions: Int) {
self.dimensions = dimensions
self.weight = MLXArray.ones([dimensions])
self.bias = MLXArray.zeros([dimensions])
}
func callAsFunction(_ x: MLXArray) -> MLXArray {
// x: [B, L, C] normalize over L dimension (axis 1)
let mean = x.mean(axis: 1, keepDims: true)
let variance = x.variance(axis: 1, keepDims: true)
let eps: Float = 1e-5
let normalized = (x - mean) * rsqrt(variance + eps)
// Scale and shift: weight and bias are [C], broadcast naturally over [B, L, C]
return normalized * weight + bias
}
}
/// 1D max pooling for channels-last data.
///
/// Input: `[batch, length, channels]` pools over the length dimension (axis -2).
///
/// - Parameters:
/// - x: `[batch, length, channels]`
/// - kernelSize: pooling window
/// - stride: pooling stride
/// - Returns: `[batch, floor((length - kernelSize) / stride) + 1, channels]`
func maxPool1d(_ x: MLXArray, kernelSize: Int, stride: Int) -> MLXArray {
let length = x.dim(-2) // time/length axis
let outLen = (length - kernelSize) / stride + 1
// Collect slices for each position in the kernel
var slices = [MLXArray]()
for k in 0 ..< kernelSize {
// Take every stride-th element starting at offset k along time axis
let slice = x[0..., .stride(from: k, to: k + outLen * stride, by: stride), 0...]
slices.append(slice)
}
// Stack along new axis and take max: [B, outLen, C, kernelSize] [B, outLen, C]
let stacked = MLX.stacked(slices, axis: -1)
return stacked.max(axis: -1)
}
/// LeakyReLU activation.
func leakyRelu(_ x: MLXArray, negativeSlope: Float = 0.01) -> MLXArray {
maximum(x, x * negativeSlope)
}
@@ -0,0 +1,114 @@
import Foundation
/// Configuration for the Sortformer diarization model.
///
/// Sortformer is NVIDIA's end-to-end neural diarization model that directly
/// predicts speaker activity without requiring separate embedding extraction
/// or clustering stages.
public struct SortformerConfig: Sendable {
// MARK: - Mel Feature Extraction
/// Number of mel frequency bins
public let nMels: Int
/// FFT window size in samples
public let nFFT: Int
/// Hop length in samples
public let hopLength: Int
/// Expected input sample rate in Hz
public let sampleRate: Int
// MARK: - Streaming Chunking
/// Chunk length in seconds for streaming inference
public let chunkLenSeconds: Float
/// Left context in seconds (prepended from previous chunk)
public let leftContextSeconds: Float
/// Right context in seconds (lookahead)
public let rightContextSeconds: Float
/// Subsampling factor of the encoder (frames mel frames)
public let subsamplingFactor: Int
// MARK: - State Dimensions
/// Speaker cache length (number of frames)
public let spkcacheLen: Int
/// FIFO buffer length (number of frames)
public let fifoLen: Int
/// Feature/hidden dimension of the model
public let fcDModel: Int
// MARK: - Model I/O Shapes
/// Maximum number of speakers the model can predict
public let maxSpeakers: Int
// MARK: - Post-processing
/// Onset threshold for speaker activity binarization
public var onset: Float
/// Offset threshold for speaker activity binarization
public var offset: Float
/// Minimum speech segment duration in seconds
public var minSpeechDuration: Float
/// Minimum silence gap to split segments, in seconds
public var minSilenceDuration: Float
// MARK: - Presets
/// Default streaming configuration matching the NeMo checkpoint.
public static let `default` = SortformerConfig(
nMels: 128,
nFFT: 400,
hopLength: 160,
sampleRate: 16000,
chunkLenSeconds: 6.0,
leftContextSeconds: 1.0,
rightContextSeconds: 7.0,
subsamplingFactor: 8,
spkcacheLen: 188,
fifoLen: 40,
fcDModel: 512,
maxSpeakers: 4,
onset: 0.5,
offset: 0.3,
minSpeechDuration: 0.3,
minSilenceDuration: 0.15
)
public init(
nMels: Int = 128,
nFFT: Int = 400,
hopLength: Int = 160,
sampleRate: Int = 16000,
chunkLenSeconds: Float = 6.0,
leftContextSeconds: Float = 1.0,
rightContextSeconds: Float = 7.0,
subsamplingFactor: Int = 8,
spkcacheLen: Int = 188,
fifoLen: Int = 40,
fcDModel: Int = 512,
maxSpeakers: Int = 4,
onset: Float = 0.5,
offset: Float = 0.3,
minSpeechDuration: Float = 0.3,
minSilenceDuration: Float = 0.15
) {
self.nMels = nMels
self.nFFT = nFFT
self.hopLength = hopLength
self.sampleRate = sampleRate
self.chunkLenSeconds = chunkLenSeconds
self.leftContextSeconds = leftContextSeconds
self.rightContextSeconds = rightContextSeconds
self.subsamplingFactor = subsamplingFactor
self.spkcacheLen = spkcacheLen
self.fifoLen = fifoLen
self.fcDModel = fcDModel
self.maxSpeakers = maxSpeakers
self.onset = onset
self.offset = offset
self.minSpeechDuration = minSpeechDuration
self.minSilenceDuration = minSilenceDuration
}
}
@@ -0,0 +1,432 @@
#if canImport(CoreML)
import CoreML
import Foundation
import AudioCommon
/// End-to-end neural speaker diarization using NVIDIA Sortformer (CoreML).
///
/// Sortformer directly predicts per-frame speaker activity for up to 4 speakers
/// without requiring separate embedding extraction or clustering. Runs on
/// Neural Engine at ~120x real-time.
///
/// ```swift
/// let diarizer = try await SortformerDiarizer.fromPretrained()
/// let result = diarizer.diarize(audio: samples, sampleRate: 16000)
/// for seg in result.segments {
/// print("Speaker \(seg.speakerId): [\(seg.startTime)s - \(seg.endTime)s]")
/// }
/// ```
public final class SortformerDiarizer {
/// Default HuggingFace model ID for the CoreML Sortformer model
public static let defaultModelId = "aufklarer/Sortformer-Diarization-CoreML"
private let model: SortformerCoreMLModel
private let melExtractor: SortformerMelExtractor
let config: SortformerConfig
/// Frame duration from model metadata (0.08s = 80ms per diarization frame)
private let frameDuration: Float = 0.08
// MARK: - Streaming State
/// Speaker cache buffer, flat `[spkcacheLen * fcDModel]`
private var spkcache: [Float]
/// Number of valid frames in speaker cache
private var spkcacheLength: Int = 0
/// FIFO buffer, flat `[fifoLen * fcDModel]`
private var fifo: [Float]
/// Number of valid frames in FIFO
private var fifoLength: Int = 0
init(model: SortformerCoreMLModel, config: SortformerConfig = .default) {
self.model = model
self.config = config
self.melExtractor = SortformerMelExtractor(config: config)
self.spkcache = [Float](repeating: 0, count: config.spkcacheLen * config.fcDModel)
self.fifo = [Float](repeating: 0, count: config.fifoLen * config.fcDModel)
}
/// Reset streaming state between different audio files.
public func resetState() {
spkcache = [Float](repeating: 0, count: config.spkcacheLen * config.fcDModel)
spkcacheLength = 0
fifo = [Float](repeating: 0, count: config.fifoLen * config.fcDModel)
fifoLength = 0
}
// MARK: - Loading
/// Load a pre-trained Sortformer model from HuggingFace.
///
/// - Parameters:
/// - modelId: HuggingFace model ID
/// - progressHandler: callback for download progress
/// - Returns: ready-to-use diarizer
public static func fromPretrained(
modelId: String = defaultModelId,
cacheDir: URL? = nil,
offlineMode: Bool = false,
progressHandler: ((Double, String) -> Void)? = nil
) async throws -> SortformerDiarizer {
progressHandler?(0.0, "Downloading Sortformer model...")
let cacheDir = try cacheDir ?? HuggingFaceDownloader.getCacheDirectory(for: modelId)
try await HuggingFaceDownloader.downloadWeights(
modelId: modelId,
to: cacheDir,
additionalFiles: ["Sortformer.mlmodelc/**", "config.json"],
offlineMode: offlineMode,
progressHandler: { progress in
progressHandler?(progress * 0.8, "Downloading Sortformer model...")
}
)
progressHandler?(0.8, "Loading CoreML model...")
let modelURL = cacheDir.appendingPathComponent("Sortformer.mlmodelc", isDirectory: true)
guard FileManager.default.fileExists(atPath: modelURL.path) else {
throw AudioModelError.modelLoadFailed(
modelId: modelId,
reason: "CoreML model not found at \(modelURL.path)")
}
let mlConfig = MLModelConfiguration()
mlConfig.computeUnits = CoreMLComputeUnitsResolver.resolved(default: .cpuAndNeuralEngine)
let mlModel: MLModel
do {
mlModel = try MLModel(contentsOf: modelURL, configuration: mlConfig)
} catch {
throw AudioModelError.modelLoadFailed(
modelId: modelId,
reason: "Failed to load CoreML model",
underlying: error)
}
let config = SortformerConfig.default
let coremlModel = SortformerCoreMLModel(model: mlModel, config: config)
progressHandler?(1.0, "Ready")
return SortformerDiarizer(model: coremlModel, config: config)
}
// MARK: - Diarization
/// Run speaker diarization on complete audio.
///
/// Processes audio in streaming chunks matching NeMo's streaming_feat_loader:
/// each chunk is 112 mel frames = (leftCtx + coreChunk + rightCtx) × subsampling.
/// Core predictions are extracted per chunk and concatenated.
///
/// - Parameters:
/// - audio: PCM Float32 audio samples
/// - sampleRate: sample rate of the input audio
/// - config: optional override for diarization thresholds
/// - Returns: diarization result with speaker-labeled segments
public func diarize(
audio: [Float],
sampleRate: Int,
config: DiarizationConfig = .default
) -> DiarizationResult {
diarize(audio: audio, sampleRate: sampleRate, config: config, progressHandler: nil)
}
/// Run speaker diarization with progress reporting and optional cancellation.
///
/// Same as `diarize(audio:sampleRate:config:)` but reports progress per chunk.
/// The handler returns a `Bool`: `true` to continue, `false` to cancel.
/// When cancelled, an empty `DiarizationResult` is returned immediately.
///
/// - Parameters:
/// - audio: PCM Float32 audio samples
/// - sampleRate: sample rate of the input audio
/// - config: optional override for diarization thresholds
/// - progressHandler: called with (progress 0.01.0, stage description);
/// return `true` to continue or `false` to cancel
/// - Returns: diarization result with speaker-labeled segments
public func diarize(
audio: [Float],
sampleRate: Int,
config: DiarizationConfig = .default,
progressHandler: ((Float, String) -> Bool)?
) -> DiarizationResult {
let samples = DiarizationHelpers.resample(audio, from: sampleRate, to: self.config.sampleRate)
guard !samples.isEmpty else {
return DiarizationResult(segments: [], numSpeakers: 0, speakerEmbeddings: [])
}
resetState()
// Extract mel features for the entire audio: [totalMelFrames, 128]
let (melSpec, totalMelFrames) = melExtractor.extract(samples)
guard totalMelFrames > 0 else {
return DiarizationResult(segments: [], numSpeakers: 0, speakerEmbeddings: [])
}
// Streaming chunking parameters (matching NeMo)
let subFactor = self.config.subsamplingFactor
let chunkLen = Int(self.config.chunkLenSeconds) // 6 encoder output frames
let leftCtx = Int(self.config.leftContextSeconds) // 1
let rightCtx = Int(self.config.rightContextSeconds) // 7
let coreMelFrames = chunkLen * subFactor // 48 mel frames per core chunk
let coreMLInputFrames = 112 // Fixed CoreML input size
let nMels = self.config.nMels
let numSpeakers = self.config.maxSpeakers
// Collect core predictions from each chunk
var allChunkProbs = [[Float]]() // Each entry: [coreFrames * numSpeakers]
let emptyResult = DiarizationResult(segments: [], numSpeakers: 0, speakerEmbeddings: [])
// Calculate total chunks for progress reporting
let totalChunks = max(1, (totalMelFrames + coreMelFrames - 1) / coreMelFrames)
var chunkIndex = 0
var sttFeat = 0
var endFeat = 0
while endFeat < totalMelFrames {
chunkIndex += 1
if progressHandler?(Float(chunkIndex) / Float(totalChunks), "Diarizing \(chunkIndex)/\(totalChunks)") == false {
return emptyResult
}
let leftOffset = min(leftCtx * subFactor, sttFeat)
endFeat = min(sttFeat + coreMelFrames, totalMelFrames)
let rightOffset = min(rightCtx * subFactor, totalMelFrames - endFeat)
let chunkStart = sttFeat - leftOffset
let chunkEnd = endFeat + rightOffset
let actualLen = chunkEnd - chunkStart
// Build padded mel chunk [coreMLInputFrames, nMels]
var chunkMel = [Float](repeating: 0, count: coreMLInputFrames * nMels)
let framesToCopy = min(actualLen, coreMLInputFrames)
for fi in 0..<framesToCopy {
let srcBase = (chunkStart + fi) * nMels
let dstBase = fi * nMels
for di in 0..<nMels {
chunkMel[dstBase + di] = melSpec[srcBase + di]
}
}
do {
let output = try model.predict(
chunk: chunkMel,
chunkLength: actualLen,
spkcache: spkcache,
spkcacheLength: spkcacheLength,
fifo: fifo,
fifoLength: fifoLength
)
// Extract core predictions (skip spkcache + fifo + left context,
// trim right context)
let validEmbs: Int = output.validEmbFrames
let lcFrames: Int = Int(Float(leftOffset) / Float(subFactor) + 0.5)
let rcFrames: Int = Int(ceil(Float(rightOffset) / Float(subFactor)))
let coreLen: Int = validEmbs - lcFrames - rcFrames
let corePredLen = coreLen > 0 ? coreLen : 0
let predOffset = spkcacheLength + fifoLength + lcFrames
let totalPredFrames = output.predsFrames
var chunkProbs = [Float]()
for f in 0..<corePredLen {
let predFrame = predOffset + f
guard predFrame < totalPredFrames else { break }
for s in 0..<numSpeakers {
chunkProbs.append(output.pred(frame: predFrame, speaker: s))
}
}
allChunkProbs.append(chunkProbs)
// Update streaming state (FIFO overflow spkcache)
updateState(from: output)
} catch {
print("Warning: Sortformer inference failed on chunk at mel frame \(sttFeat): \(error)")
}
sttFeat = endFeat
}
guard !allChunkProbs.isEmpty else {
return DiarizationResult(segments: [], numSpeakers: 0, speakerEmbeddings: [])
}
// Concatenate all core predictions
let audioDuration = Float(samples.count) / Float(self.config.sampleRate)
let segments = binarizeCorePredictions(
allChunkProbs: allChunkProbs,
audioDuration: audioDuration,
numSpeakers: numSpeakers,
onset: config.onset,
offset: config.offset,
minSpeechDuration: config.minSpeechDuration,
minSilenceDuration: config.minSilenceDuration
)
let usedSpeakers = Set(segments.map(\.speakerId))
return DiarizationResult(
segments: segments,
numSpeakers: usedSpeakers.count,
speakerEmbeddings: [] // End-to-end model, no separate embeddings
)
}
// MARK: - State Management (NeMo FIFOspkcache pattern)
/// Update spkcache and fifo buffers from encoder embeddings.
///
/// Follows NeMo's streaming_update: new embeddings go into FIFO.
/// When FIFO overflows, oldest frames move to spkcache.
private func updateState(from output: SortformerOutput) {
let validFrames = output.validEmbFrames
guard validFrames > 0 else { return }
let dim = config.fcDModel
let fifoCapacity = config.fifoLen
let cacheCapacity = config.spkcacheLen
if fifoLength + validFrames <= fifoCapacity {
// FIFO has room just append
for f in 0..<validFrames {
let srcBase = f * dim
let dstBase = (fifoLength + f) * dim
for d in 0..<dim {
fifo[dstBase + d] = output.encoderEmbs[srcBase + d]
}
}
fifoLength += validFrames
} else {
// FIFO overflow: move oldest frames to spkcache
let overflow = fifoLength + validFrames - fifoCapacity
// Move overflow frames from front of FIFO to spkcache
if spkcacheLength + overflow <= cacheCapacity {
// Append to spkcache
for f in 0..<overflow {
let srcBase = f * dim
let dstBase = (spkcacheLength + f) * dim
for d in 0..<dim {
spkcache[dstBase + d] = fifo[srcBase + d]
}
}
spkcacheLength += overflow
} else {
// Spkcache also overflows shift left and append
let cacheOverflow = spkcacheLength + overflow - cacheCapacity
let keep = spkcacheLength - cacheOverflow
if keep > 0 {
for f in 0..<keep {
let srcBase = (f + cacheOverflow) * dim
let dstBase = f * dim
for d in 0..<dim {
spkcache[dstBase + d] = spkcache[srcBase + d]
}
}
}
for f in 0..<overflow {
let srcBase = f * dim
let dstBase = (keep + f) * dim
for d in 0..<dim {
spkcache[dstBase + d] = fifo[srcBase + d]
}
}
spkcacheLength = min(cacheCapacity, keep + overflow)
}
// Shift FIFO left by overflow, then append new frames
let remaining = fifoLength - overflow
if remaining > 0 {
for f in 0..<remaining {
let srcBase = (f + overflow) * dim
let dstBase = f * dim
for d in 0..<dim {
fifo[dstBase + d] = fifo[srcBase + d]
}
}
}
fifoLength = remaining
for f in 0..<validFrames {
let srcBase = f * dim
let dstBase = (fifoLength + f) * dim
for d in 0..<dim {
fifo[dstBase + d] = output.encoderEmbs[srcBase + d]
}
}
fifoLength += validFrames
}
}
// MARK: - Binarization
/// Concatenate per-chunk core predictions and binarize into segments.
private func binarizeCorePredictions(
allChunkProbs: [[Float]],
audioDuration: Float,
numSpeakers: Int,
onset: Float,
offset: Float,
minSpeechDuration: Float,
minSilenceDuration: Float
) -> [DiarizedSegment] {
// Concatenate all chunk predictions into one flat array
var allProbs = [Float]()
for chunkProbs in allChunkProbs {
allProbs.append(contentsOf: chunkProbs)
}
let totalFrames = allProbs.count / numSpeakers
guard totalFrames > 0 else { return [] }
// Apply sigmoid if predictions are logits
for i in 0..<allProbs.count {
if allProbs[i] > 1.0 || allProbs[i] < 0.0 {
allProbs[i] = 1.0 / (1.0 + exp(-allProbs[i]))
}
}
// Binarize each speaker track
var allSegments = [DiarizedSegment]()
for spk in 0..<numSpeakers {
var probs = [Float](repeating: 0, count: totalFrames)
for f in 0..<totalFrames {
probs[f] = allProbs[f * numSpeakers + spk]
}
let rawSegments = PowersetDecoder.binarize(
probs: probs,
onset: onset,
offset: offset,
frameDuration: frameDuration
)
for seg in rawSegments {
let duration = seg.endTime - seg.startTime
guard duration >= minSpeechDuration else { continue }
allSegments.append(DiarizedSegment(
startTime: seg.startTime,
endTime: min(seg.endTime, audioDuration),
speakerId: spk
))
}
}
allSegments.sort { $0.startTime < $1.startTime }
let merged = DiarizationHelpers.mergeSegments(allSegments, minSilence: minSilenceDuration)
return DiarizationHelpers.compactSpeakerIds(merged)
}
}
// MARK: - SpeakerDiarizationModel
extension SortformerDiarizer: SpeakerDiarizationModel {
public var inputSampleRate: Int { config.sampleRate }
public func diarize(audio: [Float], sampleRate: Int) -> [DiarizedSegment] {
diarize(audio: audio, sampleRate: sampleRate, config: .default).segments
}
}
#endif
@@ -0,0 +1,205 @@
import Foundation
import Accelerate
/// 128-dim log-mel feature extractor for Sortformer diarization.
///
/// Matches NeMo's audio preprocessor: Hann window (no Povey), no pre-emphasis,
/// nFFT=400, hop=160, 128 mel bins, 16kHz. Uses vDSP for FFT and mel filterbank.
///
/// Key differences from `MelFeatureExtractor` (WeSpeaker):
/// - 128 mel bins (vs 80)
/// - Hann window (vs Povey window)
/// - No pre-emphasis (vs 0.97)
/// - Power spectrum (vs magnitude spectrum)
class SortformerMelExtractor {
let sampleRate: Int
let nFFT: Int
let hopLength: Int
let nMels: Int
private let paddedFFT: Int = 512
private let log2PaddedFFT: vDSP_Length = 9
private var fftSetup: FFTSetup
private var window: [Float]
private var melFilterbank: [Float] // [nMels, nBins]
init(config: SortformerConfig = .default) {
self.sampleRate = config.sampleRate
self.nFFT = config.nFFT
self.hopLength = config.hopLength
self.nMels = config.nMels
// Hann window (NeMo default, no Povey modification)
window = [Float](repeating: 0, count: config.nFFT)
for i in 0..<config.nFFT {
window[i] = 0.5 - 0.5 * cos(2.0 * Float.pi * Float(i) / Float(config.nFFT - 1))
}
guard let setup = vDSP_create_fftsetup(log2PaddedFFT, FFTRadix(kFFTRadix2)) else {
fatalError("Failed to create vDSP FFT setup")
}
fftSetup = setup
melFilterbank = []
setupMelFilterbank()
}
deinit {
vDSP_destroy_fftsetup(fftSetup)
}
private func setupMelFilterbank() {
let fMin: Float = 0.0
let fMax: Float = Float(sampleRate) / 2.0
// HTK mel scale
func hzToMel(_ hz: Float) -> Float {
2595.0 * log10(1.0 + hz / 700.0)
}
func melToHz(_ mel: Float) -> Float {
700.0 * (pow(10.0, mel / 2595.0) - 1.0)
}
let nBins = paddedFFT / 2 + 1 // 257
var fftFreqs = [Float](repeating: 0, count: nBins)
for i in 0..<nBins {
fftFreqs[i] = Float(i) * Float(sampleRate) / Float(paddedFFT)
}
let melMin = hzToMel(fMin)
let melMax = hzToMel(fMax)
let nMelPoints = nMels + 2
var melPoints = [Float](repeating: 0, count: nMelPoints)
for i in 0..<nMelPoints {
melPoints[i] = melMin + Float(i) * (melMax - melMin) / Float(nMelPoints - 1)
}
let filterFreqs = melPoints.map { melToHz($0) }
var filterDiff = [Float](repeating: 0, count: nMelPoints - 1)
for i in 0..<(nMelPoints - 1) {
filterDiff[i] = filterFreqs[i + 1] - filterFreqs[i]
}
// Build filterbank [nBins, nMels]
var filterbank = [Float](repeating: 0, count: nBins * nMels)
for bin in 0..<nBins {
let freq = fftFreqs[bin]
for mel in 0..<nMels {
let lowFreq = filterFreqs[mel]
let highFreq = filterFreqs[mel + 2]
let downSlope = (freq - lowFreq) / filterDiff[mel]
let upSlope = (highFreq - freq) / filterDiff[mel + 1]
filterbank[bin * nMels + mel] = max(0.0, min(downSlope, upSlope))
}
}
// Slaney normalization
for mel in 0..<nMels {
let enorm = 2.0 / (filterFreqs[mel + 2] - filterFreqs[mel])
for bin in 0..<nBins {
filterbank[bin * nMels + mel] *= enorm
}
}
// Transpose to [nMels, nBins]
var transposed = [Float](repeating: 0, count: nMels * nBins)
for mel in 0..<nMels {
for bin in 0..<nBins {
transposed[mel * nBins + bin] = filterbank[bin * nMels + mel]
}
}
self.melFilterbank = transposed
}
/// Extract 128-dim log-mel features from audio.
///
/// - Parameter audio: PCM Float32 samples at 16kHz
/// - Returns: `(melSpec, nFrames)` where melSpec is a flat `[nFrames * 128]` array
func extract(_ audio: [Float]) -> (melSpec: [Float], nFrames: Int) {
let nBins = paddedFFT / 2 + 1
let halfPadded = paddedFFT / 2
// No pre-emphasis for Sortformer (NeMo default)
guard !audio.isEmpty else { return ([], 0) }
// Reflect padding (same as torch.stft with center=True)
let padLength = nFFT / 2
var paddedAudio = [Float](repeating: 0, count: padLength + audio.count + padLength)
for i in 0..<padLength {
let srcIdx = min(padLength - i, audio.count - 1)
paddedAudio[i] = audio[max(0, srcIdx)]
}
for i in 0..<audio.count {
paddedAudio[padLength + i] = audio[i]
}
for i in 0..<padLength {
let srcIdx = audio.count - 2 - i
paddedAudio[padLength + audio.count + i] = audio[max(0, srcIdx)]
}
let nFrames = (paddedAudio.count - nFFT) / hopLength + 1
var splitReal = [Float](repeating: 0, count: halfPadded)
var splitImag = [Float](repeating: 0, count: halfPadded)
var paddedFrame = [Float](repeating: 0, count: paddedFFT)
var powerSpec = [Float](repeating: 0, count: nFrames * nBins)
for frame in 0..<nFrames {
let start = frame * hopLength
paddedAudio.withUnsafeBufferPointer { buf in
vDSP_vmul(buf.baseAddress! + start, 1, window, 1, &paddedFrame, 1, vDSP_Length(nFFT))
}
for i in nFFT..<paddedFFT {
paddedFrame[i] = 0
}
for i in 0..<halfPadded {
splitReal[i] = paddedFrame[2 * i]
splitImag[i] = paddedFrame[2 * i + 1]
}
splitReal.withUnsafeMutableBufferPointer { realBuf in
splitImag.withUnsafeMutableBufferPointer { imagBuf in
var splitComplex = DSPSplitComplex(
realp: realBuf.baseAddress!,
imagp: imagBuf.baseAddress!)
vDSP_fft_zrip(fftSetup, &splitComplex, 1, log2PaddedFFT, FFTDirection(kFFTDirection_Forward))
}
}
let baseIdx = frame * nBins
// Power spectrum: |X|^2
powerSpec[baseIdx] = splitReal[0] * splitReal[0]
powerSpec[baseIdx + halfPadded] = splitImag[0] * splitImag[0]
for k in 1..<halfPadded {
powerSpec[baseIdx + k] = splitReal[k] * splitReal[k] + splitImag[k] * splitImag[k]
}
}
// Mel filterbank matmul: [nFrames, nBins] × [nBins, nMels] = [nFrames, nMels]
var melSpec = [Float](repeating: 0, count: nFrames * nMels)
var filterbankT = [Float](repeating: 0, count: nBins * nMels)
vDSP_mtrans(melFilterbank, 1, &filterbankT, 1, vDSP_Length(nBins), vDSP_Length(nMels))
vDSP_mmul(powerSpec, 1, filterbankT, 1, &melSpec, 1,
vDSP_Length(nFrames), vDSP_Length(nMels), vDSP_Length(nBins))
// Log-mel: log(max(x, 1e-10))
let count = melSpec.count
var countN = Int32(count)
var epsilon: Float = 1e-10
vDSP_vclip(melSpec, 1, &epsilon, [Float.greatestFiniteMagnitude], &melSpec, 1, vDSP_Length(count))
vvlogf(&melSpec, melSpec, &countN)
return (melSpec, nFrames)
}
}
@@ -0,0 +1,161 @@
#if canImport(CoreML)
import CoreML
import Foundation
import AudioCommon
/// CoreML wrapper for the Sortformer streaming diarization model.
///
/// Runs on Neural Engine via CoreML. The model takes a chunk of mel features
/// plus streaming state buffers (spkcache, fifo) and outputs per-frame
/// speaker predictions for up to 4 speakers.
final class SortformerCoreMLModel {
private let model: MLModel
let config: SortformerConfig
/// Input shape constants derived from the CoreML model.
/// chunk: [1, 112, 128], spkcache: [1, 188, 512], fifo: [1, 40, 512]
private let chunkFrames: Int = 112
private let spkcacheFrames: Int
private let fifoFrames: Int
private let featureDim: Int
init(model: MLModel, config: SortformerConfig = .default) {
self.model = model
self.config = config
self.spkcacheFrames = config.spkcacheLen
self.fifoFrames = config.fifoLen
self.featureDim = config.fcDModel
}
/// Run one streaming inference step.
///
/// - Parameters:
/// - chunk: Mel features for this chunk, flat `[chunkFrames * nMels]` float array.
/// If fewer frames available, zero-pad on the right.
/// - chunkLength: Actual number of valid mel frames in the chunk
/// - spkcache: Speaker cache state, flat `[spkcacheFrames * fcDModel]`
/// - spkcacheLength: Number of valid frames in speaker cache
/// - fifo: FIFO buffer state, flat `[fifoFrames * fcDModel]`
/// - fifoLength: Number of valid frames in FIFO
/// - Returns: `SortformerOutput` with predictions and updated state
func predict(
chunk: [Float],
chunkLength: Int,
spkcache: [Float],
spkcacheLength: Int,
fifo: [Float],
fifoLength: Int
) throws -> SortformerOutput {
// Create input arrays
let chunkArray = try makeMultiArray(
shape: [1, NSNumber(value: chunkFrames), NSNumber(value: config.nMels)],
from: chunk)
let chunkLenArray = try makeScalarInt32Array(value: Int32(chunkLength))
let spkcacheArray = try makeMultiArray(
shape: [1, NSNumber(value: spkcacheFrames), NSNumber(value: featureDim)],
from: spkcache)
let spkcacheLenArray = try makeScalarInt32Array(value: Int32(spkcacheLength))
let fifoArray = try makeMultiArray(
shape: [1, NSNumber(value: fifoFrames), NSNumber(value: featureDim)],
from: fifo)
let fifoLenArray = try makeScalarInt32Array(value: Int32(fifoLength))
let input = try MLDictionaryFeatureProvider(dictionary: [
"chunk": MLFeatureValue(multiArray: chunkArray),
"chunk_lengths": MLFeatureValue(multiArray: chunkLenArray),
"spkcache": MLFeatureValue(multiArray: spkcacheArray),
"spkcache_lengths": MLFeatureValue(multiArray: spkcacheLenArray),
"fifo": MLFeatureValue(multiArray: fifoArray),
"fifo_lengths": MLFeatureValue(multiArray: fifoLenArray),
])
let result = try model.prediction(from: input)
// Extract outputs
let predsArray = result.featureValue(for: "speaker_preds_out")!.multiArrayValue!
let embsArray = result.featureValue(for: "chunk_pre_encoder_embs_out")!.multiArrayValue!
let embsLenArray = result.featureValue(for: "chunk_pre_encoder_lengths_out")!.multiArrayValue!
let predsShape = (0..<predsArray.shape.count).map { predsArray.shape[$0].intValue }
let embsShape = (0..<embsArray.shape.count).map { embsArray.shape[$0].intValue }
let totalPreds = predsShape.reduce(1, *)
let totalEmbs = embsShape.reduce(1, *)
var preds = [Float](repeating: 0, count: totalPreds)
let predsPtr = predsArray.dataPointer.assumingMemoryBound(to: Float.self)
for i in 0..<totalPreds { preds[i] = predsPtr[i] }
var embs = [Float](repeating: 0, count: totalEmbs)
let embsPtr = embsArray.dataPointer.assumingMemoryBound(to: Float.self)
for i in 0..<totalEmbs { embs[i] = embsPtr[i] }
let embsLenPtr = embsLenArray.dataPointer.assumingMemoryBound(to: Int32.self)
let validEmbFrames = Int(embsLenPtr[0])
return SortformerOutput(
speakerPreds: preds,
predsFrames: predsShape.count >= 2 ? predsShape[predsShape.count - 2] : totalPreds / config.maxSpeakers,
numSpeakers: config.maxSpeakers,
encoderEmbs: embs,
encoderEmbFrames: embsShape.count >= 2 ? embsShape[embsShape.count - 2] : validEmbFrames,
embDim: featureDim,
validEmbFrames: validEmbFrames
)
}
// MARK: - Helpers
private func makeMultiArray(shape: [NSNumber], from data: [Float]) throws -> MLMultiArray {
let array = try MLMultiArray(shape: shape, dataType: .float32)
let ptr = array.dataPointer.assumingMemoryBound(to: Float.self)
let count = min(data.count, array.count)
for i in 0..<count {
ptr[i] = data[i]
}
// Zero-fill remainder
for i in count..<array.count {
ptr[i] = 0
}
return array
}
private func makeScalarInt32Array(value: Int32) throws -> MLMultiArray {
let array = try MLMultiArray(shape: [1], dataType: .int32)
let ptr = array.dataPointer.assumingMemoryBound(to: Int32.self)
ptr[0] = value
return array
}
}
/// Output from one Sortformer inference step.
struct SortformerOutput {
/// Speaker predictions, flat `[predsFrames * numSpeakers]`, sigmoid probabilities
let speakerPreds: [Float]
/// Number of prediction frames
let predsFrames: Int
/// Number of speaker channels
let numSpeakers: Int
/// Pre-encoder embeddings for state update, flat `[encoderEmbFrames * embDim]`
let encoderEmbs: [Float]
/// Total encoder embedding frames
let encoderEmbFrames: Int
/// Embedding dimension
let embDim: Int
/// Number of valid (non-padding) embedding frames
let validEmbFrames: Int
/// Get speaker prediction probability at (frame, speaker).
func pred(frame: Int, speaker: Int) -> Float {
speakerPreds[frame * numSpeakers + speaker]
}
/// Get encoder embedding at (frame, dim).
func emb(frame: Int, dim: Int) -> Float {
encoderEmbs[frame * embDim + dim]
}
}
#endif
@@ -0,0 +1,29 @@
import AudioCommon
// MARK: - VoiceActivityDetectionModel
extension PyannoteVADModel: VoiceActivityDetectionModel {
public var inputSampleRate: Int { segConfig.sampleRate }
}
// MARK: - SpeakerEmbeddingModel
extension WeSpeakerModel: SpeakerEmbeddingModel {}
// MARK: - SpeakerDiarizationModel
extension PyannoteDiarizationPipeline: SpeakerDiarizationModel {
public var inputSampleRate: Int { segConfig.sampleRate }
public func diarize(audio: [Float], sampleRate: Int) -> [DiarizedSegment] {
diarize(audio: audio, sampleRate: sampleRate, config: .default).segments
}
}
// MARK: - SpeakerExtractionCapable
extension PyannoteDiarizationPipeline: SpeakerExtractionCapable {
public func extractSpeaker(audio: [Float], sampleRate: Int, targetEmbedding: [Float]) -> [SpeechSegment] {
extractSpeaker(audio: audio, sampleRate: sampleRate, targetEmbedding: targetEmbedding, config: .default)
}
}
@@ -0,0 +1,142 @@
import Foundation
import MLXCommon
import MLX
import AudioCommon
/// Voice Activity Detection using pyannote PyanNet segmentation.
///
/// Detects speech regions in audio using a sliding-window segmentation model
/// with hysteresis thresholding and duration filtering.
///
/// - Warning: This class is not thread-safe. Create separate instances for concurrent use.
///
/// ```swift
/// let vad = try await PyannoteVADModel.fromPretrained()
/// let segments = vad.detectSpeech(audio: samples, sampleRate: 16000)
/// for seg in segments {
/// print("Speech: \(seg.startTime)s - \(seg.endTime)s")
/// }
/// ```
public final class PyannoteVADModel {
/// The segmentation model
let model: SegmentationModel
/// VAD pipeline configuration
public let vadConfig: VADConfig
/// Segmentation model configuration
public let segConfig: SegmentationConfig
/// Default HuggingFace model ID
public static let defaultModelId = "aufklarer/Pyannote-Segmentation-MLX"
/// Whether the model weights are loaded and ready for inference.
var _isLoaded = true
init(model: SegmentationModel, segConfig: SegmentationConfig, vadConfig: VADConfig) {
self.model = model
self.segConfig = segConfig
self.vadConfig = vadConfig
}
/// Load a pre-trained VAD model from HuggingFace.
///
/// Downloads model weights on first use, then caches locally.
///
/// - Parameters:
/// - modelId: HuggingFace model ID
/// - vadConfig: VAD pipeline configuration (thresholds, durations)
/// - progressHandler: callback for download progress
/// - Returns: ready-to-use VAD model
public static func fromPretrained(
modelId: String = defaultModelId,
vadConfig: VADConfig = .default,
cacheDir: URL? = nil,
offlineMode: Bool = false,
progressHandler: ((Double, String) -> Void)? = nil
) async throws -> PyannoteVADModel {
progressHandler?(0.0, "Downloading model...")
let cacheDir = try cacheDir ?? HuggingFaceDownloader.getCacheDirectory(for: modelId)
try await HuggingFaceDownloader.downloadWeights(
modelId: modelId,
to: cacheDir,
offlineMode: offlineMode,
progressHandler: { progress in
progressHandler?(progress * 0.8, "Downloading weights...")
}
)
progressHandler?(0.8, "Loading model...")
let segConfig = SegmentationConfig.default
let model = SegmentationModel(config: segConfig)
try SegmentationWeightLoader.loadWeights(model: model, from: cacheDir)
progressHandler?(1.0, "Ready")
return PyannoteVADModel(model: model, segConfig: segConfig, vadConfig: vadConfig)
}
/// Detect speech segments in audio.
///
/// - Parameters:
/// - audio: PCM Float32 audio samples
/// - sampleRate: sample rate of the input audio (will resample to 16kHz if needed)
/// - Returns: array of speech segments with start/end times in seconds
public func detectSpeech(audio: [Float], sampleRate: Int) -> [SpeechSegment] {
let samples: [Float]
if sampleRate != segConfig.sampleRate {
samples = AudioFileLoader.resample(audio, from: sampleRate, to: segConfig.sampleRate)
} else {
samples = audio
}
let pipeline = VADPipeline(
config: vadConfig,
sampleRate: segConfig.sampleRate,
framesPerChunk: 589
)
let positions = pipeline.windowPositions(numSamples: samples.count)
guard !positions.isEmpty else { return [] }
let windowSamples = Int(vadConfig.windowDuration * Float(segConfig.sampleRate))
// Run segmentation on each window
var windowProbs = [[Float]]()
for (start, end) in positions {
// Extract window, zero-pad if needed
var window = Array(samples[start ..< end])
if window.count < windowSamples {
window.append(contentsOf: [Float](repeating: 0, count: windowSamples - window.count))
}
// Run model: [1, 1, samples] [1, frames, 7]
let input = MLXArray(window).reshaped(1, 1, windowSamples)
let posteriors = model(input)
// Extract speech probability: [1, frames] [frames]
let speechProb = SegmentationModel.speechProbability(from: posteriors)
eval(speechProb)
let probArray = speechProb[0].asArray(Float.self)
windowProbs.append(probArray)
}
// Aggregate overlapping windows
let aggregated = pipeline.aggregateFrames(
windowProbs: windowProbs,
positions: positions,
numSamples: samples.count
)
// Binarize with hysteresis
return pipeline.binarize(probs: aggregated)
}
}
@@ -0,0 +1,210 @@
import Foundation
import AudioCommon
/// Events emitted by the streaming VAD processor.
public enum VADEvent: Sendable {
/// Speech has been detected and confirmed (duration minSpeechDuration).
case speechStarted(time: Float)
/// Speech has ended (silence minSilenceDuration).
case speechEnded(segment: SpeechSegment)
}
/// Event-driven streaming VAD processor.
///
/// Wraps a `SileroVADModel` to provide event-based speech detection.
/// Accepts audio samples of any length, buffers them into 512-sample chunks,
/// runs the model, and applies hysteresis with duration filtering via a
/// four-state machine.
///
/// - Warning: This class is not thread-safe. Create separate instances for concurrent use.
///
/// ```swift
/// let model = try await SileroVADModel.fromPretrained()
/// let processor = StreamingVADProcessor(model: model)
///
/// // Feed audio samples (any length)
/// let events = processor.process(samples: audioBuffer)
/// for event in events {
/// switch event {
/// case .speechStarted(let time):
/// print("Speech started at \(time)s")
/// case .speechEnded(let segment):
/// print("Speech: \(segment.startTime)s - \(segment.endTime)s")
/// }
/// }
///
/// // At end of stream, flush any pending segment
/// let finalEvents = processor.flush()
/// ```
public final class StreamingVADProcessor {
private let model: SileroVADModel
private let config: VADConfig
private let chunkDuration: Float // seconds per chunk (0.032)
/// Buffer for accumulating samples until we have a full chunk
private var buffer: [Float] = []
/// Number of chunks processed so far
private var chunkCount: Int = 0
/// State machine for hysteresis + duration filtering
private enum State {
/// No speech detected
case silence
/// Onset threshold crossed, waiting for minSpeechDuration
case pendingSpeech(startTime: Float)
/// Speech confirmed and speechStarted emitted
case speech(startTime: Float)
/// Offset threshold crossed, waiting for minSilenceDuration
case pendingSilence(speechStart: Float, silenceStart: Float)
}
private var state: State = .silence
/// Create a streaming VAD processor.
///
/// - Parameters:
/// - model: Silero VAD model instance
/// - config: VAD configuration (thresholds, durations)
public init(model: SileroVADModel, config: VADConfig = .sileroDefault) {
self.model = model
self.config = config
self.chunkDuration = Float(SileroVADModel.chunkSize) / Float(SileroVADModel.sampleRate)
}
/// Feed audio samples and get VAD events back.
///
/// Samples are buffered internally. Events are emitted as soon as the
/// state machine confirms speech start/end with the configured thresholds
/// and duration constraints.
///
/// - Parameter samples: PCM Float32 samples at 16kHz (any length)
/// - Returns: zero or more VAD events
public func process(samples: [Float]) -> [VADEvent] {
buffer.append(contentsOf: samples)
var events = [VADEvent]()
while buffer.count >= SileroVADModel.chunkSize {
let chunk = Array(buffer.prefix(SileroVADModel.chunkSize))
buffer.removeFirst(SileroVADModel.chunkSize)
let prob = model.processChunk(chunk)
let time = Float(chunkCount) * chunkDuration
chunkCount += 1
events.append(contentsOf: processProb(prob, time: time))
}
return events
}
/// Flush any pending speech segment at end of stream.
///
/// Call this when the audio stream ends to close any open speech segment.
///
/// - Returns: zero or more final VAD events
public func flush() -> [VADEvent] {
// Process any remaining buffered samples (zero-padded)
var events = [VADEvent]()
if !buffer.isEmpty {
var lastChunk = buffer
lastChunk.append(contentsOf: [Float](repeating: 0, count: SileroVADModel.chunkSize - lastChunk.count))
buffer.removeAll()
let prob = model.processChunk(lastChunk)
let time = Float(chunkCount) * chunkDuration
chunkCount += 1
events.append(contentsOf: processProb(prob, time: time))
}
let endTime = Float(chunkCount) * chunkDuration
// Close any open state
switch state {
case .silence:
break
case .pendingSpeech(let startTime):
// Check if pending speech meets minimum duration
if endTime - startTime >= config.minSpeechDuration {
events.append(.speechStarted(time: startTime))
events.append(.speechEnded(segment: SpeechSegment(
startTime: startTime, endTime: endTime)))
}
case .speech(let startTime):
events.append(.speechEnded(segment: SpeechSegment(
startTime: startTime, endTime: endTime)))
case .pendingSilence(let speechStart, let silenceStart):
// End at the silence start point
events.append(.speechEnded(segment: SpeechSegment(
startTime: speechStart, endTime: silenceStart)))
}
state = .silence
return events
}
/// Reset all state (model + processor).
///
/// Call between processing different audio streams.
public func reset() {
buffer.removeAll()
chunkCount = 0
state = .silence
model.resetState()
}
/// Current time position in seconds.
public var currentTime: Float {
Float(chunkCount) * chunkDuration
}
// MARK: - State Machine
private func processProb(_ prob: Float, time: Float) -> [VADEvent] {
var events = [VADEvent]()
let nextTime = time + chunkDuration
switch state {
case .silence:
if prob >= config.onset {
state = .pendingSpeech(startTime: time)
}
case .pendingSpeech(let startTime):
if prob < config.offset {
// False alarm speech too brief, return to silence
state = .silence
} else if nextTime - startTime >= config.minSpeechDuration {
// Speech confirmed
events.append(.speechStarted(time: startTime))
state = .speech(startTime: startTime)
}
// else: still pending, keep waiting
case .speech(let startTime):
if prob < config.offset {
// Speech may be ending
state = .pendingSilence(speechStart: startTime, silenceStart: time)
}
case .pendingSilence(let speechStart, let silenceStart):
if prob >= config.onset {
// Speech resumed cancel silence
state = .speech(startTime: speechStart)
} else if nextTime - silenceStart >= config.minSilenceDuration {
// Silence confirmed emit speechEnded
events.append(.speechEnded(segment: SpeechSegment(
startTime: speechStart, endTime: silenceStart)))
// Check if new speech is starting
if prob >= config.onset {
state = .pendingSpeech(startTime: time)
} else {
state = .silence
}
}
// else: still waiting for silence confirmation
}
return events
}
}
@@ -0,0 +1,181 @@
import Foundation
import MLX
import AudioCommon
/// VAD pipeline: sliding window segmentation aggregation binarization.
///
/// Processes audio in overlapping 10-second windows, runs the segmentation model
/// on each window, aggregates overlapping frame predictions, then applies
/// hysteresis thresholding and duration filtering to produce speech segments.
public struct VADPipeline: Sendable {
/// Configuration for the pipeline
public let config: VADConfig
/// Sample rate expected by the segmentation model
public let sampleRate: Int
/// Number of output frames per 10s chunk (from the segmentation model)
public let framesPerChunk: Int
public init(config: VADConfig = .default, sampleRate: Int = 16000, framesPerChunk: Int = 589) {
self.config = config
self.sampleRate = sampleRate
self.framesPerChunk = framesPerChunk
}
/// Duration of one frame in seconds.
public var frameDuration: Float {
config.windowDuration / Float(framesPerChunk)
}
// MARK: - Sliding Window
/// Generate sliding window positions for the given audio length.
/// - Parameter numSamples: total audio samples
/// - Returns: array of (start, end) sample indices
func windowPositions(numSamples: Int) -> [(start: Int, end: Int)] {
let windowSamples = Int(config.windowDuration * Float(sampleRate))
let stepSamples = Int(config.windowDuration * config.stepRatio * Float(sampleRate))
guard numSamples > 0 else { return [] }
// If audio is shorter than one window, just use one window (zero-padded)
if numSamples <= windowSamples {
return [(0, numSamples)]
}
var positions = [(start: Int, end: Int)]()
var start = 0
while start + windowSamples <= numSamples {
positions.append((start, start + windowSamples))
start += stepSamples
}
// Handle the last partial window
if positions.isEmpty || positions.last!.end < numSamples {
positions.append((numSamples - windowSamples, numSamples))
}
return positions
}
// MARK: - Frame Aggregation
/// Aggregate overlapping frame-level speech probabilities from multiple windows.
///
/// Each window produces `framesPerChunk` frames. Overlapping regions are
/// averaged across windows.
///
/// - Parameters:
/// - windowProbs: array of per-window speech probability arrays (each `framesPerChunk` long)
/// - positions: corresponding window positions (sample indices)
/// - numSamples: total audio length in samples
/// - Returns: aggregated speech probability per frame for the entire audio
func aggregateFrames(
windowProbs: [[Float]],
positions: [(start: Int, end: Int)],
numSamples: Int
) -> [Float] {
let totalDuration = Float(numSamples) / Float(sampleRate)
let numFrames = Int(ceil(totalDuration / frameDuration))
guard numFrames > 0 else { return [] }
var sumProbs = [Float](repeating: 0, count: numFrames)
var counts = [Float](repeating: 0, count: numFrames)
for (windowIdx, (start, _)) in positions.enumerated() {
let probs = windowProbs[windowIdx]
let windowStartTime = Float(start) / Float(sampleRate)
for (frameIdx, prob) in probs.enumerated() {
let frameTime = windowStartTime + Float(frameIdx) * frameDuration
let globalFrame = Int(frameTime / frameDuration)
if globalFrame >= 0 && globalFrame < numFrames {
sumProbs[globalFrame] += prob
counts[globalFrame] += 1
}
}
}
// Average where we have overlapping windows
return zip(sumProbs, counts).map { sum, count in
count > 0 ? sum / count : 0
}
}
// MARK: - Binarization (Hysteresis Thresholding)
/// Apply hysteresis thresholding to speech probabilities.
///
/// Speech starts when probability exceeds `onset` and ends when it drops
/// below `offset`. This two-threshold approach prevents rapid toggling.
///
/// - Parameter probs: per-frame speech probabilities
/// - Returns: array of `SpeechSegment` with start/end times
public func binarize(probs: [Float]) -> [SpeechSegment] {
var segments = [SpeechSegment]()
var inSpeech = false
var speechStart: Float = 0
for (i, prob) in probs.enumerated() {
let time = Float(i) * frameDuration
if !inSpeech && prob >= config.onset {
inSpeech = true
speechStart = time
} else if inSpeech && prob < config.offset {
inSpeech = false
let segment = SpeechSegment(startTime: speechStart, endTime: time)
segments.append(segment)
}
}
// Close any open segment
if inSpeech {
let endTime = Float(probs.count) * frameDuration
segments.append(SpeechSegment(startTime: speechStart, endTime: endTime))
}
return filterDurations(segments)
}
// MARK: - Duration Filtering
/// Filter segments by minimum speech and silence durations.
///
/// 1. Remove speech segments shorter than `minSpeechDuration`
/// 2. Merge segments separated by silence shorter than `minSilenceDuration`
func filterDurations(_ segments: [SpeechSegment]) -> [SpeechSegment] {
guard !segments.isEmpty else { return [] }
// Filter short speech segments
let filtered = segments.filter { $0.duration >= config.minSpeechDuration }
guard !filtered.isEmpty else { return [] }
// Merge segments separated by short silence
var merged = [SpeechSegment]()
var current = filtered[0]
for i in 1 ..< filtered.count {
let next = filtered[i]
let gap = next.startTime - current.endTime
if gap < config.minSilenceDuration {
// Merge: extend current segment
current = SpeechSegment(
startTime: current.startTime,
endTime: next.endTime
)
} else {
merged.append(current)
current = next
}
}
merged.append(current)
return merged
}
}
@@ -0,0 +1,19 @@
import AudioCommon
extension WeSpeakerModel: ModelMemoryManageable {
public var isLoaded: Bool { _isLoaded }
public func unload() {
guard _isLoaded else { return }
network?.clearParameters()
#if canImport(CoreML)
coremlModel = nil
#endif
_isLoaded = false
}
public var memoryFootprint: Int {
guard _isLoaded else { return 0 }
return network?.parameterMemoryBytes() ?? 0
}
}
@@ -0,0 +1,231 @@
import Foundation
import MLXCommon
import MLX
import AudioCommon
#if canImport(CoreML)
import CoreML
#endif
/// Inference engine for WeSpeaker speaker embeddings.
public enum WeSpeakerEngine: String, Sendable {
/// MLX backend runs on GPU via Metal shaders.
case mlx
/// CoreML backend runs on Neural Engine + CPU, freeing the GPU.
case coreml
}
/// Speaker embedding model using WeSpeaker ResNet34-LM.
///
/// Produces 256-dimensional L2-normalized speaker embeddings from audio.
/// Uses 80-dim log-mel features at 16kHz.
///
/// Supports two backends:
/// - `.mlx`: GPU-based inference via MLX (default)
/// - `.coreml`: Neural Engine inference via CoreML (lower power, frees GPU)
///
/// This class is thread-safe: all properties are immutable after construction and
/// inference is pure computation with no mutable state. `MLModel.prediction(from:)` is
/// documented as thread-safe by Apple.
///
/// ```swift
/// let model = try await WeSpeakerModel.fromPretrained(engine: .coreml)
/// let embedding = model.embed(audio: samples, sampleRate: 16000)
/// // embedding: [Float] of length 256
/// ```
public final class WeSpeakerModel {
/// The inference engine in use.
public let engine: WeSpeakerEngine
/// Whether the model weights are loaded and ready for inference.
var _isLoaded = true
/// The ResNet34 network (nil when using CoreML engine)
let network: WeSpeakerNetwork?
/// Mel feature extractor
let melExtractor: MelFeatureExtractor
#if canImport(CoreML)
/// CoreML compiled model (nil when using MLX engine)
var coremlModel: MLModel?
#endif
/// Default HuggingFace model ID (MLX weights)
public static let defaultModelId = "aufklarer/WeSpeaker-ResNet34-LM-MLX"
/// Default HuggingFace model ID (CoreML weights)
public static let defaultCoreMLModelId = "aufklarer/WeSpeaker-ResNet34-LM-CoreML"
/// Embedding dimension
public let embeddingDimension: Int = 256
/// Expected input sample rate
public let inputSampleRate: Int = 16000
/// Enumerated mel frame lengths supported by the CoreML model.
static let enumeratedMelLengths = [20, 50, 100, 200, 300, 500, 750, 1000, 1500, 2000]
init(network: WeSpeakerNetwork) {
self.engine = .mlx
self.network = network
self.melExtractor = MelFeatureExtractor()
#if canImport(CoreML)
self.coremlModel = nil
#endif
}
#if canImport(CoreML)
init(coremlModel: MLModel) {
self.engine = .coreml
self.network = nil
self.coremlModel = coremlModel
self.melExtractor = MelFeatureExtractor()
}
#endif
/// Load a pre-trained speaker embedding model from HuggingFace.
///
/// Downloads model weights on first use, then caches locally.
///
/// - Parameters:
/// - modelId: HuggingFace model ID (auto-selected by engine if not specified)
/// - engine: inference backend (`.mlx` or `.coreml`)
/// - progressHandler: callback for download progress
/// - Returns: ready-to-use speaker embedding model
public static func fromPretrained(
modelId: String? = nil,
engine: WeSpeakerEngine = .mlx,
cacheDir: URL? = nil,
offlineMode: Bool = false,
progressHandler: ((Double, String) -> Void)? = nil
) async throws -> WeSpeakerModel {
let resolvedModelId = modelId ?? (engine == .coreml ? defaultCoreMLModelId : defaultModelId)
progressHandler?(0.0, "Downloading speaker embedding model...")
let cacheDir = try cacheDir ?? HuggingFaceDownloader.getCacheDirectory(for: resolvedModelId)
switch engine {
case .mlx:
try await HuggingFaceDownloader.downloadWeights(
modelId: resolvedModelId,
to: cacheDir,
offlineMode: offlineMode,
progressHandler: { progress in
progressHandler?(progress * 0.8, "Downloading weights...")
}
)
progressHandler?(0.8, "Loading model...")
let network = WeSpeakerNetwork()
try WeSpeakerWeightLoader.loadWeights(model: network, from: cacheDir)
progressHandler?(1.0, "Ready")
return WeSpeakerModel(network: network)
case .coreml:
#if canImport(CoreML)
try await HuggingFaceDownloader.downloadWeights(
modelId: resolvedModelId,
to: cacheDir,
additionalFiles: ["wespeaker.mlmodelc/**", "config.json"],
offlineMode: offlineMode,
progressHandler: { progress in
progressHandler?(progress * 0.8, "Downloading CoreML model...")
}
)
progressHandler?(0.8, "Loading CoreML model...")
let modelURL = cacheDir.appendingPathComponent("wespeaker.mlmodelc", isDirectory: true)
guard FileManager.default.fileExists(atPath: modelURL.path) else {
throw AudioModelError.modelLoadFailed(
modelId: resolvedModelId,
reason: "CoreML model not found at \(modelURL.path)")
}
let mlConfig = MLModelConfiguration()
mlConfig.computeUnits = CoreMLComputeUnitsResolver.resolved(default: .cpuAndNeuralEngine)
let model: MLModel
do {
model = try MLModel(contentsOf: modelURL, configuration: mlConfig)
} catch {
throw AudioModelError.modelLoadFailed(
modelId: resolvedModelId,
reason: "Failed to load CoreML model",
underlying: error)
}
progressHandler?(1.0, "Ready")
return WeSpeakerModel(coremlModel: model)
#else
throw AudioModelError.invalidConfiguration(
model: "WeSpeaker", reason: "CoreML not available on this platform")
#endif
}
}
/// Extract a 256-dimensional speaker embedding from audio.
///
/// - Parameters:
/// - audio: PCM Float32 audio samples
/// - sampleRate: sample rate of the input audio
/// - Returns: 256-dim L2-normalized speaker embedding
public func embed(audio: [Float], sampleRate: Int) -> [Float] {
let samples: [Float]
if sampleRate != inputSampleRate {
samples = AudioFileLoader.resample(audio, from: sampleRate, to: inputSampleRate)
} else {
samples = audio
}
switch engine {
case .mlx:
return embedMLX(samples)
case .coreml:
#if canImport(CoreML)
let (melSpec, nFrames) = melExtractor.extractRaw(samples)
return (try? embedCoreML(melSpec: melSpec, nFrames: nFrames)) ?? [Float](repeating: 0, count: embeddingDimension)
#else
fatalError("CoreML not available on this platform")
#endif
}
}
/// MLX inference path.
private func embedMLX(_ samples: [Float]) -> [Float] {
guard let network else { fatalError("MLX network not loaded") }
// Extract mel features: [T, 80]
let mel = melExtractor.extract(samples)
// Add batch and channel dims: [1, T, 80, 1]
let input = mel.reshaped(1, mel.dim(0), mel.dim(1), 1)
// Forward pass: [1, 256]
let emb = network(input)
eval(emb)
return emb[0].asArray(Float.self)
}
/// Compute cosine similarity between two embeddings.
public static func cosineSimilarity(_ a: [Float], _ b: [Float]) -> Float {
guard a.count == b.count, !a.isEmpty else { return 0 }
var dot: Float = 0
var normA: Float = 0
var normB: Float = 0
for i in 0..<a.count {
dot += a[i] * b[i]
normA += a[i] * a[i]
normB += b[i] * b[i]
}
let denom = sqrt(normA) * sqrt(normB)
return denom > 0 ? dot / denom : 0
}
}
@@ -0,0 +1,167 @@
import MLX
import MLXNN
/// ResNet BasicBlock with BN fused into Conv2d.
///
/// Each block has two 3×3 Conv2d layers with bias (fused BatchNorm).
/// Shortcut Conv2d(1×1) is added when stride1 or channels change.
class BasicBlock: Module {
let conv1: Conv2d
let conv2: Conv2d
let shortcut: Conv2d?
let stride: Int
init(inChannels: Int, outChannels: Int, stride: Int = 1) {
self.stride = stride
self.conv1 = Conv2d(
inputChannels: inChannels, outputChannels: outChannels,
kernelSize: 3, stride: IntOrPair(arrayLiteral: stride, stride),
padding: 1, bias: true
)
self.conv2 = Conv2d(
inputChannels: outChannels, outputChannels: outChannels,
kernelSize: 3, stride: 1, padding: 1, bias: true
)
if stride != 1 || inChannels != outChannels {
self.shortcut = Conv2d(
inputChannels: inChannels, outputChannels: outChannels,
kernelSize: 1, stride: IntOrPair(arrayLiteral: stride, stride),
padding: 0, bias: true
)
} else {
self.shortcut = nil
}
}
func callAsFunction(_ x: MLXArray) -> MLXArray {
var out = relu(conv1(x))
out = conv2(out)
let residual: MLXArray
if let shortcut {
residual = shortcut(x)
} else {
residual = x
}
return relu(out + residual)
}
}
/// WeSpeaker ResNet34 speaker embedding network (BN-fused).
///
/// Architecture:
/// ```
/// Input: [B, T, 80, 1] mel spectrogram
/// Conv2d(132, k=3, p=1) + ReLU
/// Layer1: 3× BasicBlock(3232)
/// Layer2: 4× BasicBlock(3264, s=2)
/// Layer3: 6× BasicBlock(64128, s=2)
/// Layer4: 3× BasicBlock(128256, s=2)
/// Statistics Pooling: mean + std [B, 5120]
/// Linear(5120256) L2 normalize
/// Output: [B, 256] speaker embedding
/// ```
class WeSpeakerNetwork: Module {
let conv1: Conv2d
let layer1: [BasicBlock]
let layer2: [BasicBlock]
let layer3: [BasicBlock]
let layer4: [BasicBlock]
let embedding: Linear
override init() {
self.conv1 = Conv2d(
inputChannels: 1, outputChannels: 32,
kernelSize: 3, stride: 1, padding: 1, bias: true
)
// Layer1: 3 blocks, 3232
var blocks1 = [BasicBlock]()
for _ in 0..<3 {
blocks1.append(BasicBlock(inChannels: 32, outChannels: 32))
}
self.layer1 = blocks1
// Layer2: 4 blocks, 3264, first stride=2
var blocks2 = [BasicBlock]()
for i in 0..<4 {
blocks2.append(BasicBlock(
inChannels: i == 0 ? 32 : 64,
outChannels: 64,
stride: i == 0 ? 2 : 1
))
}
self.layer2 = blocks2
// Layer3: 6 blocks, 64128, first stride=2
var blocks3 = [BasicBlock]()
for i in 0..<6 {
blocks3.append(BasicBlock(
inChannels: i == 0 ? 64 : 128,
outChannels: 128,
stride: i == 0 ? 2 : 1
))
}
self.layer3 = blocks3
// Layer4: 3 blocks, 128256, first stride=2
var blocks4 = [BasicBlock]()
for i in 0..<3 {
blocks4.append(BasicBlock(
inChannels: i == 0 ? 128 : 256,
outChannels: 256,
stride: i == 0 ? 2 : 1
))
}
self.layer4 = blocks4
// Pooling output: T/8 * 10 * 256 mean+std 2 * 10 * 256 = 5120
self.embedding = Linear(5120, 256)
}
/// Forward pass.
/// - Parameter mel: `[B, T, 80, 1]` mel spectrogram (channels-last)
/// - Returns: `[B, 256]` L2-normalized speaker embedding
func callAsFunction(_ mel: MLXArray) -> MLXArray {
// mel: [B, T, 80, 1]
// Python WeSpeaker permutes input: (B,T,F) -> (B,F,T) -> (B,1,F,T)
// In MLX NHWC: (B,1,F,T) maps to [B, F, T, 1]
var x = mel.transposed(0, 2, 1, 3) // [B, 80, T, 1] = [B, F, T, C]
x = relu(conv1(x))
// ResNet layers
for block in layer1 { x = block(x) }
for block in layer2 { x = block(x) }
for block in layer3 { x = block(x) }
for block in layer4 { x = block(x) }
// x: [B, F'=10, T'=T/8, 256] (NHWC)
// Corresponds to Python's [B, 256, F'=10, T'=T/8] (NCHW)
// Flatten freq and channels: [B, 10, T/8, 256] [B, T/8, 10*256]
// Match Python: [B, 256, 10, T'] [B, 256*10, T'] via reshape (C*F order)
// MLX: transpose to [B, T/8, 256, 10] then reshape
let B = x.dim(0)
let Tp = x.dim(2) // T/8 (time is dim 2 now)
x = x.transposed(0, 2, 3, 1) // [B, T/8, 256, 10]
x = x.reshaped(B, Tp, -1) // [B, T/8, 2560] in C*F order
// Statistics pooling: mean + std over time (dim=1) [B, 5120]
let mean = x.mean(axis: 1) // [B, 2560]
let variance = x.variance(axis: 1) // [B, 2560]
let std = sqrt(variance + 1e-10)
let pooled = concatenated([mean, std], axis: -1) // [B, 5120]
// Embedding projection
var emb = embedding(pooled) // [B, 256]
// L2 normalize
let norm = sqrt((emb * emb).sum(axis: -1, keepDims: true) + 1e-10)
emb = emb / norm
return emb
}
}
@@ -0,0 +1,33 @@
import Foundation
import MLXCommon
import MLX
import MLXNN
import AudioCommon
/// Weight loading for the WeSpeaker ResNet34-LM speaker embedding model.
///
/// Loads from safetensors files produced by `scripts/convert_wespeaker.py`.
/// The conversion script fuses BatchNorm into Conv2d and transposes weights,
/// so loading is a straightforward parameter tree update.
enum WeSpeakerWeightLoader {
/// Load weights from a directory containing model.safetensors.
static func loadWeights(
model: WeSpeakerNetwork,
from directory: URL
) throws {
let weightsURL = directory.appendingPathComponent("model.safetensors")
guard FileManager.default.fileExists(atPath: weightsURL.path) else {
throw WeightLoadingError.noWeightsFound(directory)
}
let weights = try MLX.loadArrays(url: weightsURL)
let parameters = ModuleParameters.unflattened(weights)
try model.update(parameters: parameters, verify: .noUnusedKeys)
MLX.eval(model.parameters())
}
}
@@ -0,0 +1,37 @@
import Foundation
import MLXCommon
import MLX
import MLXNN
import AudioCommon
/// Weight loading for the PyanNet segmentation model.
///
/// Loads from safetensors files produced by `scripts/convert_pyannote.py`.
/// The conversion script pre-computes sinc filters and transposes Conv1d weights,
/// so loading is a straightforward parameter tree update.
enum SegmentationWeightLoader {
/// Load weights from a directory containing model.safetensors.
static func loadWeights(
model: SegmentationModel,
from directory: URL
) throws {
let weightsURL = directory.appendingPathComponent("model.safetensors")
guard FileManager.default.fileExists(atPath: weightsURL.path) else {
throw WeightLoadingError.noWeightsFound(directory)
}
let weights = try MLX.loadArrays(url: weightsURL)
// The conversion script produces keys matching our module structure directly.
// Use ModuleParameters.unflattened to build the nested parameter tree.
let parameters = ModuleParameters.unflattened(weights)
// Apply to model
try model.update(parameters: parameters, verify: .noUnusedKeys)
// Evaluate all parameters to ensure they're materialized
MLX.eval(model.parameters())
}
}