Files
OSGKeyboard/OSGKeyboard/ThirdParty/Qwen3Speech/Sources/Qwen3ASR/AudioPreprocessing.swift
T
Rocky df1c5ff32c 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+).
2026-06-23 00:46:58 +08:00

491 lines
20 KiB
Swift

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)
}
}