Merge pull request #3592 from modelscope/codex/qwen3-asr-offline-vllm-3419

feat(qwen3-asr): add offline vLLM example
This commit is contained in:
zhifu gao
2026-09-02 14:15:10 +08:00
committed by GitHub
4 changed files with 309 additions and 0 deletions
@@ -0,0 +1,130 @@
#!/usr/bin/env python3
"""Transcribe long audio with Qwen3-ASR's native vLLM backend."""
import argparse
import json
import subprocess
import tempfile
from pathlib import Path
SAMPLE_RATE = 16000
def _result_value(result, key, default=None):
if isinstance(result, dict):
return result.get(key, default)
return getattr(result, key, default)
def transcribe_chunks(model, chunks, sample_rate, language):
"""Transcribe qwen-asr chunks and restore offsets on the source timeline."""
segments = []
for audio, offset_seconds in chunks:
results = model.transcribe(audio=(audio, sample_rate), language=language)
if len(results) != 1:
raise RuntimeError(
f"Qwen3-ASR must return one result per chunk, received {len(results)}"
)
result = results[0]
segments.append(
{
"start_ms": round(offset_seconds * 1000),
"end_ms": round(
(offset_seconds + len(audio) / sample_rate) * 1000
),
"text": (_result_value(result, "text", "") or "").strip(),
"language": _result_value(result, "language"),
}
)
return segments
def build_parser():
parser = argparse.ArgumentParser(
description="Offline long-audio Qwen3-ASR using its native vLLM backend"
)
parser.add_argument("audio", type=Path)
parser.add_argument("--model", default="Qwen/Qwen3-ASR-1.7B")
parser.add_argument("--language", default=None)
parser.add_argument("--chunk-seconds", type=float, default=180.0)
parser.add_argument("--max-inference-batch-size", type=int, default=4)
parser.add_argument("--gpu-memory-utilization", type=float, default=0.8)
parser.add_argument("--output", type=Path, default=None)
return parser
def _convert_to_mono_wav(audio_path, wav_path):
command = [
"ffmpeg",
"-v",
"error",
"-i",
str(audio_path),
"-ar",
str(SAMPLE_RATE),
"-ac",
"1",
"-y",
str(wav_path),
]
try:
subprocess.run(command, check=True)
except FileNotFoundError as exc:
raise RuntimeError("ffmpeg is required to normalize the input audio") from exc
def run(args):
if args.chunk_seconds <= 0:
raise ValueError("chunk_seconds must be positive")
if args.max_inference_batch_size <= 0:
raise ValueError("max_inference_batch_size must be positive")
if not args.audio.is_file():
raise FileNotFoundError(args.audio)
import soundfile as sf
from qwen_asr import Qwen3ASRModel
from qwen_asr.inference.utils import split_audio_into_chunks
with tempfile.TemporaryDirectory(prefix="funasr-qwen3-vllm-") as temp:
wav_path = Path(temp) / "input.wav"
_convert_to_mono_wav(args.audio, wav_path)
audio, sample_rate = sf.read(wav_path, dtype="float32")
chunks = split_audio_into_chunks(
audio,
sample_rate,
max_chunk_sec=args.chunk_seconds,
) or [(audio, 0.0)]
model = Qwen3ASRModel.LLM(
model=args.model,
gpu_memory_utilization=args.gpu_memory_utilization,
max_inference_batch_size=args.max_inference_batch_size,
)
segments = transcribe_chunks(model, chunks, sample_rate, args.language)
detected_language = next(
(segment["language"] for segment in segments if segment["language"]),
args.language,
)
payload = {
"text": "".join(segment["text"] for segment in segments),
"language": detected_language,
"segments": segments,
}
output = args.output or args.audio.with_suffix(".qwen3-vllm.json")
output.write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
return output, payload
def main():
args = build_parser().parse_args()
output, payload = run(args)
print(payload["text"])
print(f"Wrote {len(payload['segments'])} segments to {output}")
if __name__ == "__main__":
main()
@@ -0,0 +1,41 @@
# Qwen3-ASR 离线长音频 vLLM 示例
`transcribe_vllm_offline.py` 使用 Qwen3-ASR 自带的 `Qwen3ASRModel.LLM` 后端,不经过
`AutoModelVLLM`。后者服务于 FunASR 自有模型,当前不支持 Qwen3-ASR。
这个示例先用 ffmpeg 将输入统一为 16 kHz 单声道,再复用 qwen-asr 自带的静音边界切块,
逐块调用原生 vLLM 推理,并将结果还原到原始时间轴。默认块上限 180 秒,与 qwen-asr 的
长音频处理边界一致。该路径不需要额外 VAD 模型。
## 安装
建议使用独立环境,因为 `qwen-asr[vllm]==0.0.6` 固定依赖 `vllm==0.14.0`:
```bash
python -m venv .venv-qwen3-vllm
source .venv-qwen3-vllm/bin/activate
pip install -U "qwen-asr[vllm]==0.0.6" "transformers==4.57.6"
```
系统还需要可执行的 `ffmpeg`。
## 运行
```bash
python examples/industrial_data_pretraining/qwen3_asr/transcribe_vllm_offline.py \
recording.mp3 \
--model Qwen/Qwen3-ASR-1.7B \
--language Chinese \
--max-inference-batch-size 4
```
默认输出 `recording.qwen3-vllm.json`,包含全文以及每个块的 `start_ms`、`end_ms`、
`text` 和模型返回的语言。省略 `--language` 时启用自动语言识别。
可根据显存调整 `--max-inference-batch-size` 和 `--gpu-memory-utilization`。较短的
`--chunk-seconds` 能减小单次请求和重复退化的影响,但更多边界也可能增加漏字或断词;
修改前请在代表性音频上同时验证 CER、漏字和重复。
该示例受到 [qwen3-asr-service](https://github.com/LanceLRQ/qwen3-asr-service) 的离线
vLLM 实践以及 [FunASR #3419](https://github.com/modelscope/FunASR/issues/3419) 用户复测
启发。不同测试集、参考稿对齐方式和模型尺寸的 CER 不能直接横向等同。
@@ -0,0 +1,46 @@
# Offline long-form Qwen3-ASR with vLLM
`transcribe_vllm_offline.py` uses Qwen3-ASR's native `Qwen3ASRModel.LLM` backend. It does
not use `AutoModelVLLM`, which targets FunASR-native models and does not currently support
Qwen3-ASR.
The example normalizes input to mono 16 kHz audio, reuses qwen-asr's silence-aware audio
splitter, transcribes each chunk with native vLLM, and restores offsets on the source
timeline. The default 180-second limit matches qwen-asr's long-form boundary. No separate
VAD model is required for this path.
## Install
Use an isolated environment because `qwen-asr[vllm]==0.0.6` pins `vllm==0.14.0`:
```bash
python -m venv .venv-qwen3-vllm
source .venv-qwen3-vllm/bin/activate
pip install -U "qwen-asr[vllm]==0.0.6" "transformers==4.57.6"
```
An `ffmpeg` executable must also be available.
## Run
```bash
python examples/industrial_data_pretraining/qwen3_asr/transcribe_vllm_offline.py \
recording.mp3 \
--model Qwen/Qwen3-ASR-1.7B \
--language Chinese \
--max-inference-batch-size 4
```
The default output is `recording.qwen3-vllm.json`, containing the combined transcript and
each chunk's `start_ms`, `end_ms`, `text`, and detected language. Omit `--language` to
enable language detection.
Tune `--max-inference-batch-size` and `--gpu-memory-utilization` for the available GPU.
Shorter `--chunk-seconds` values bound individual requests and repetition, but additional
boundaries can introduce omissions or split words. Measure CER, omissions, and repetition
on representative audio before changing it.
This example was informed by the offline vLLM work in
[qwen3-asr-service](https://github.com/LanceLRQ/qwen3-asr-service) and the user evaluation
in [FunASR #3419](https://github.com/modelscope/FunASR/issues/3419). CER values from
different datasets, transcript alignment rules, or model sizes are not directly comparable.
@@ -0,0 +1,92 @@
import importlib.util
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SCRIPT_PATH = (
ROOT
/ "examples"
/ "industrial_data_pretraining"
/ "qwen3_asr"
/ "transcribe_vllm_offline.py"
)
def load_example():
spec = importlib.util.spec_from_file_location("qwen3_vllm_offline", SCRIPT_PATH)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
class FakeResult:
def __init__(self, text, language="Chinese"):
self.text = text
self.language = language
class FakeModel:
def __init__(self):
self.calls = []
def transcribe(self, *, audio, language):
self.calls.append((audio, language))
return [FakeResult(f"chunk-{len(self.calls)}")]
class Qwen3AsrVllmOfflineExampleTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.example = load_example()
def test_transcribe_chunks_preserves_offsets_and_language(self):
self.assertTrue(hasattr(self.example, "transcribe_chunks"))
model = FakeModel()
chunks = [([0.1] * 16000, 0.0), ([0.2] * 8000, 3.25)]
segments = self.example.transcribe_chunks(
model, chunks, sample_rate=16000, language="Chinese"
)
self.assertEqual(
segments,
[
{
"start_ms": 0,
"end_ms": 1000,
"text": "chunk-1",
"language": "Chinese",
},
{
"start_ms": 3250,
"end_ms": 3750,
"text": "chunk-2",
"language": "Chinese",
},
],
)
self.assertTrue(all(language == "Chinese" for _, language in model.calls))
def test_transcribe_chunks_rejects_result_count_mismatch(self):
class EmptyModel:
def transcribe(self, **kwargs):
return []
with self.assertRaisesRegex(RuntimeError, "one result"):
self.example.transcribe_chunks(
EmptyModel(), [([0.1], 0.0)], sample_rate=16000, language=None
)
def test_help_does_not_require_gpu_dependencies(self):
self.assertTrue(hasattr(self.example, "build_parser"))
parser = self.example.build_parser()
args = parser.parse_args(["input.mp3"])
self.assertEqual(args.audio, Path("input.mp3"))
self.assertEqual(args.chunk_seconds, 180.0)
self.assertEqual(args.max_inference_batch_size, 4)
if __name__ == "__main__":
unittest.main()