QUAN-Transcribe-1.0 (Core ML)
QUAN-Transcribe-1.0, a fast Vietnamese speech recognition model, converted to Core ML to run on Apple devices. The Python package in this repo does not need PyTorch.
- Same accuracy as the PyTorch model: on FLEURS vi_vn, word error rates match to within 0.02 points (table below).
- About 270× faster than real time on an M3 Max GPU (fp16).
- Every feature of the original: files and waveforms, long recordings, streaming mode, live microphone transcription, optional KenLM language model, CLI and Python API.
Quick start
Requirements: macOS 15 or later, Python 3.10 or later. Apple Silicon is recommended.
hf download qnguyen3/QUAN-Transcribe-1.0-CoreML --local-dir QUAN-Transcribe-CoreML --exclude "QuanTranscribe-fp32.mlpackage/*"
cd QUAN-Transcribe-CoreML
uv sync # or: pip install .
uv run quan-transcribe-coreml --model . audio.wav
uv run quan-transcribe-coreml --model . --mic
Without --model ., the files are downloaded from this repo into the Hugging Face cache, and only the precision
you use is fetched.
from quan_transcribe_coreml import QuanTranscribeCoreML
asr = QuanTranscribeCoreML.from_pretrained("qnguyen3/QUAN-Transcribe-1.0-CoreML")
print(asr.transcribe("audio.wav"))
print(asr.transcribe("audio.wav", streaming=True))
# waveforms and lists work too
texts = asr.transcribe([wav_a, wav_b], sample_rate=16000)
- Input: any file libsndfile can read (wav, flac, ogg, mp3, ...), or a numpy waveform. Audio is converted to 16 kHz mono.
- Output: lowercase Vietnamese without punctuation, numbers written as words.
- Long audio: recordings are cut into pieces of at most 30 s at quiet points (
chunk_seconds). - First run: the model is compiled once (a few seconds) and cached in
~/.cache/quan_transcribe_coreml. Each input length then loads in about a second the first time it is used.asr.warmup()loads all of them up front.
CLI
quan-transcribe-coreml audio.wav
quan-transcribe-coreml --streaming audio.wav
quan-transcribe-coreml -v *.mp3 # several files, with duration and speed
quan-transcribe-coreml --mic # live from the microphone, Ctrl+C to stop
quan-transcribe-coreml --lm audio.wav # beam search with the n-gram LM
| option | |
|---|---|
--model |
repo id (default qnguyen3/QUAN-Transcribe-1.0-CoreML) or local directory |
--precision |
fp16 or fp32 (default: fp16, or fp32 with --compute-units cpu_only) |
--compute-units |
cpu_and_gpu (default), cpu_only, all, cpu_and_ne |
--streaming, --left-chunks |
streaming mode, and how many past 0.48 s chunks the reader sees |
--chunk-seconds |
max piece length for long audio (at most 30) |
--lm, --lm-path, --beam-width, --lm-alpha, --lm-beta |
n-gram LM beam search |
--mic, --input-device, --list-devices, --endpoint |
live microphone |
Streaming mode
streaming=True decodes the way the model runs in streaming mode. The reader's attention covers only the current
0.48 s chunk and earlier ones. The encoder's attention is already chunked with left context only, and the reader's
convolution is causal, so nothing looks more than one chunk ahead.
Live microphone
--mic shows the sentence in progress, dimmed and updated every 0.48 s. Streaming mode keeps the text from finished
chunks fixed, so only the newest words can change. After a pause (--endpoint, default 0.8 s) the sentence is
decoded once more with full context (and with the LM when --lm is given) and printed on its own line. All model
lengths are loaded before listening starts, so no update stalls later. When stdout is not a terminal, only finished
sentences are written.
from quan_transcribe_coreml.live import LiveSession
session = LiveSession(asr)
for block in blocks: # 16 kHz mono float32 audio, any block size
for sentence in session.accept(block): # finished sentences
print(sentence)
print(session.partial) # sentence in progress
print(session.flush())
n-gram language model
A 4-gram KenLM model (lm/, 91 MB) can be used for CTC beam search. It lowers WER by about 2 points.
uv sync --extra lm # or: pip install ".[lm]" (builds kenlm; needs a C++ compiler)
asr = QuanTranscribeCoreML.from_pretrained("qnguyen3/QUAN-Transcribe-1.0-CoreML", lm=True)
asr.transcribe("audio.wav") # beam search with the LM
asr.transcribe("audio.wav", use_lm=False) # greedy
The LM was trained on about 29M words of Vietnamese news and Wikipedia sentences from the Leipzig Corpora Collection (news 2022, Wikipedia 2021), plus training transcripts. It is the same LM as in the PyTorch repo.
Accuracy and speed
FLEURS vi_vn validation, the 317 utterances whose reference has no digits (61 minutes of audio), same text normalization and scoring for every row:
| full context | streaming | + LM (beam 32) | |
|---|---|---|---|
| PyTorch (reference) | 6.70% | 8.38% | 4.80% |
| Core ML fp16, GPU | 6.71% | 8.36% | 4.80% |
| Core ML fp32, GPU | 6.70% | 8.37% | 4.79% |
| Core ML fp32, CPU only | 6.70% | ||
| Core ML fp16, CPU only | 7.56% |
Speed on an Apple M3 Max with greedy decoding, models warmed up, from waveform to text:
| precision | compute units | speed |
|---|---|---|
| fp16 | CPU + GPU | 268× real time |
| fp32 | CPU + GPU | 238× real time |
| fp32 | CPU only | 67× real time |
Use fp16 on the GPU. On the CPU, fp16 arithmetic costs accuracy, so compute_units="cpu_only" loads fp32 by default.
The Neural Engine cannot compile this model, so all and cpu_and_ne fall back to the GPU and CPU after a long
first load. Use the default cpu_and_gpu.
Files
| file | |
|---|---|
QuanTranscribe-fp16.mlpackage |
Core ML model, fp16 (665 MB) |
QuanTranscribe-fp32.mlpackage |
Core ML model, fp32 (1.3 GB) |
config.json |
sample rate, input-length buckets and their function names |
units.json |
text piece of every output unit (0 = blank, 1 = unknown) |
lm/ |
optional 4-gram KenLM model, unigram list and default weights |
quan_transcribe_coreml/ |
Python inference package (no PyTorch) |
convert/convert_coreml.py |
script that produced the .mlpackage files from the PyTorch model |
Using the model from Swift or other Core ML code
Each .mlpackage is a multifunction model (macOS 15 / iOS 18 or later). There is one function per input length,
len_2s, len_4s, len_6s, len_8s, len_10s, len_15s, len_20s, len_25s and len_30s, all sharing the
same weights. Pick the shortest one that fits the audio and zero-pad up to its length.
| input | shape | |
|---|---|---|
audio |
(1, N) float32 | 16 kHz mono in [-1, 1], zero padded to the function's length N |
length |
(1,) int32 | number of real samples |
streaming |
(1,) float32 | 1 for streaming mode, 0 for full context |
left_chunks |
(1,) float32 | streaming: past chunks the reader may see (10000 = all) |
| output | shape | |
|---|---|---|
log_probs |
(1, T, 14919) float32 | CTC log-probabilities, 25 frames per second |
frame_mask |
(1, T) float32 | 1 for real frames; use the first sum(frame_mask) frames |
Log-mel features and all attention masks are computed inside the model. To get text, take the argmax per frame,
merge repeats, drop unit 0 (blank) and 1 (unknown), join the pieces from units.json and replace ▁ with a space.
Set the model's compute units to CPU and GPU.
How it was converted
convert/convert_coreml.py traces the PyTorch model one input length at a time with coremltools 9.0 and merges the
lengths into one multifunction package. Some changes keep the converted model exact and safe in fp16:
- The log-mel spectrogram is a strided convolution with DFT weights, so the model takes raw audio.
- Masks are built from the
lengthinput with integer math (sample positions overflow fp16 beyond about 4 s) and use a finite -10000 instead of -infinity, so padding rows never turn into NaN. - Inputs to layer norms and RMS norms are divided by 64 first. Activations reach about 1,200, and their squares would overflow fp16. Normalization is unchanged by scaling, apart from its tiny epsilon.
To reproduce: pip install ".[convert]", then python convert/convert_coreml.py --out ..
License
This model is a derivative of EmbeddingGemma 2. It contains the EmbeddingGemma 2 audio encoder weights and is
subject to the Gemma Terms of Use and the
Gemma Prohibited Use Policy. See NOTICE.