Files
2026-07-13 13:25:10 +08:00

145 lines
5.8 KiB
Markdown

# Fun-ASR-Nano on llama.cpp / GGUF
Run **Fun-ASR-Nano** entirely on the [llama.cpp](https://github.com/ggml-org/llama.cpp)
/ ggml stack — **CPU, edge, a single binary, no Python at runtime**. This is to
Fun-ASR what [whisper.cpp](https://github.com/ggml-org/whisper.cpp) is to Whisper.
## Why this exists
Fun-ASR-Nano normally runs on PyTorch / vLLM (GPU). That is great for a server
serving many requests, but it cannot run where there is no GPU and no Python.
This runtime ports the model to **ggml + GGUF**, so Fun-ASR-Nano can run:
- on a laptop / phone / Raspberry Pi / edge box, offline, CPU-only;
- embedded directly into a C/C++ application (one static binary);
- with quantized weights (Q8 / Q4), shrinking the model to ~1.3 GB total.
| | vLLM (existing) | this runtime (llama.cpp) |
|---|---|---|
| target | GPU server, high QPS | CPU / edge / embedded |
| deps | Python + CUDA + PyTorch | none (C/C++ single binary) |
| weights | HF fp16/bf16 | GGUF, quantized |
| best for | online service, batch | offline, on-device |
## Architecture
Fun-ASR-Nano = **SenseVoice SAN-M encoder (70 layers) + adaptor + Qwen3-0.6B LLM**.
The whole pipeline runs in C++:
```
audio.wav (16k mono)
│ kaldi 80-mel fbank + LFR (C++)
features [T, 560]
│ SAN-M encoder + adaptor (ggml) ── funasr-encoder.gguf
audio embeds [T', 1024]
│ keep first fake_token_len frames (low-frame-rate)
[ prefix tokens | audio embeds | suffix tokens ]
│ Qwen3-0.6B, embeds injected via llama_decode (llava/mtmd style) ── qwen3-0.6b.gguf
transcription
```
The audio embeddings are fed into the LLM through `llama_decode`'s embedding-input
path — exactly how llava/mtmd inject vision embeddings.
## Quickstart
**1. Build** (drop the examples into a llama.cpp checkout):
```bash
git clone https://github.com/ggml-org/llama.cpp && cd llama.cpp
cp -r /path/to/runtime/llama.cpp/funasr-cli examples/
echo 'add_subdirectory(funasr-cli)' >> examples/CMakeLists.txt
cmake -B build -DGGML_NATIVE=ON -DLLAMA_CURL=OFF
cmake --build build -j --target llama-funasr-cli
```
**2. Convert weights to GGUF** (one-time; needs the checkpoint, e.g.
`FunAudioLLM/Fun-ASR-Nano-2512`):
```bash
# LLM half — Qwen3-0.6B is natively supported by llama.cpp
python llama.cpp/convert_hf_to_gguf.py <model>/Qwen3-0.6B-vllm \
--outfile qwen3-0.6b-f32.gguf --outtype f32
build/bin/llama-quantize qwen3-0.6b-f32.gguf qwen3-0.6b-q8_0.gguf Q8_0 # smaller, recommended
# audio half — SenseVoice encoder + adaptor
python runtime/llama.cpp/export_encoder_gguf.py \
--model_pt <model>/model.pt --out funasr-encoder.gguf # f32, 935 MB
python runtime/llama.cpp/export_encoder_gguf.py \
--model_pt <model>/model.pt --out funasr-encoder-f16.gguf --wtype f16 # 469 MB
```
**3. Transcribe:**
```bash
build/bin/llama-funasr-cli \
--enc funasr-encoder.gguf -m qwen3-0.6b-q8_0.gguf \
-a audio.wav --chunk 15
```
Expected output (one of the benchmark clips):
```
我想问我在滨海新区有房我一直没有照顾孩子但是我想要抚养权...你觉得这是正常的想法吗
[done] 7.40s ; chunk=15s
```
## Models & sizes
| file | dtype | size |
|---|---|---|
| funasr-encoder.gguf | f32 | 935 MB |
| funasr-encoder-f16.gguf | f16 (matmul weights) | 469 MB |
| qwen3-0.6b-f32.gguf | f32 | 3.0 GB |
| qwen3-0.6b-q8_0.gguf | Q8_0 | 805 MB |
| qwen3-0.6b-q4km.gguf | Q4_K_M | 484 MB |
Fully-quantized config (f16 encoder + Q8 LLM) ≈ **1.3 GB**, edge-friendly.
## Accuracy & validation
Validated against the PyTorch reference on the 184-file benchmark:
- **Encoder + adaptor (ggml) vs PyTorch:** cosine **1.000000**, max_abs_diff **5e-3** (f32).
- **kaldi fbank (C++) vs torchaudio:** cosine **1.000000**.
- **End-to-end CER, identical conditions (f32 LLM, 15 s chunking):**
C++ macro 17.41% / micro 11.68% vs PyTorch macro 17.42% / micro 11.70%
→ aggregate CER matches to **0.02%**; the port is faithful.
- Best practical config (Q8 LLM + 15 s chunking): **micro-CER 9.51%** (production
VAD-segmented reference is ~8.2%; the gap is fixed-window vs VAD, not the port).
## Tips & gotchas
- **Use `--chunk 15`** for long audio. Decoding a whole 60 s clip in one segment is
out-of-distribution and makes greedy decoding loop; 15 s windows fix it
(micro-CER 29% → 9.5%).
- **Low-frame-rate truncation** is required: only the first `fake_token_len`
adaptor frames are real audio tokens. The CLI does this automatically; feeding
all frames makes the LLM repeat.
- **Use bf16/fp32, avoid fp16 for the audio path** — the adaptor output has large
magnitude (std ≈ 28, |max| ≈ 1187); fp16 can overflow. The GGUFs here are f32/f16
weights with f32 activations, which is safe.
- **WAV input** currently assumes 16 kHz mono PCM16. Resample first if needed.
- Q8 quantization slightly *helps* greedy stability (quant noise regularizes away
from repetition loops), so Q8 is a good default.
## Implementation notes
- FSMN depthwise memory is an exact f32 shift-accumulate (avoids the F16-only,
upstream-flagged `ggml_conv_1d_dw`).
- LayerNorm eps = 1e-5; sinusoidal position encoding depth = input feature dim (560),
positions start at 1; encoder input pre-scaled by sqrt(512).
- Prompt is fed as tokens via `llama_tokenize(parse_special=true)` (prefix = 18
tokens, matching the HF tokenizer), so no Python embedding table is needed.
## Files
```
funasr-cli/ integrated binary: WAV → transcription
funasr-encoder/ encoder+adaptor only (ggml) — validation/debugging
funasr-embd/ LLM decode from precomputed embeds — validation/debugging
export_encoder_gguf.py export the audio encoder + adaptor to GGUF
```
## Roadmap
- True FSMN-VAD segmentation (replace fixed windows; closes the last ~1.3% CER).
- Arbitrary WAV formats / resampling; encoder Q8 quantization; single packaged GGUF.