mirror of
https://github.com/modelscope/FunASR.git
synced 2026-10-12 02:19:18 +00:00
Merge pull request #3592 from modelscope/codex/qwen3-asr-offline-vllm-3419
feat(qwen3-asr): add offline vLLM example
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user