本教程系统讲解自动语音识别(ASR)技术,详细介绍 OpenMOSS 团队开源的端到端语音转写模型 MOSS-Transcribe-Diarize 0.9B 及其 vLLM 高性能部署方案。通过完整示例项目 voice2text,你将学会在 WSL2 中用 vLLM 部署该模型,用 FastAPI 构建语音识别后端服务,实现"用户上传音频或用麦克风录音,输出识别文字",并对外暴露 OpenAI 兼容的语音识别接口;同时支持把识别结果转换为带时间轴的字幕,一键下载 SRT / VTT 字幕文件。教程内容覆盖 ASR 技术概览、MOSS-Transcribe-Diarize 模型详解、WSL2 环境准备与综合示例的完整链路,适合希望在本地搭建语音识别与字幕生成服务的开发者。

前置教程

如想快速开始学习本教程,你可能需要先完成以下前置教程:

资源下载

1. ASR 技术概览

1.1 什么是 ASR

ASR(Automatic Speech Recognition,自动语音识别) 是指将人类的语音音频自动转换为对应文字文本的技术,是人工智能语音领域的核心基础能力之一,与 TTS(文本转语音)恰好互为反向任务。

ASR 的应用场景非常广泛:

  • 语音输入:手机输入法、语音笔记、会议纪要的自动转写
  • 智能交互:语音助手、智能音箱、客服系统的语音理解前端
  • 字幕生成:视频、直播、网课的自动字幕
  • 语音翻译:先识别再翻译,构成语音翻译链路
  • 无障碍服务:为听障人群提供实时字幕

现代 ASR 系统的核心处理流程如下:

graph LR A[语音音频] --> B[音频预处理
采样率统一、降噪] B --> C[声学特征提取
如梅尔频谱] C --> D[声学模型与解码
音频映射为文字] D --> E[输出文本] classDef input fill:#d6eaf8,stroke:#1a6b8a,stroke-width:2px classDef process fill:#d5f5e3,stroke:#1e8449,stroke-width:2px classDef model fill:#ffecd6,stroke:#ca6f1e,stroke-width:2px classDef output fill:#ebdef0,stroke:#6c3483,stroke-width:2px class A input class B process class C,D model class E output
阶段 作用 说明
音频预处理 统一采样率、静音切除 保证输入质量一致
声学特征提取 将音频波形转为特征向量 常用梅尔频谱、特征滤波器组
声学模型与解码 将声学特征映射为文字序列 决定识别准确率与语种支持

ASR 的本质是"音频到文字"的映射,工程上的重点是选择合适的识别模型,并把它封装成易于调用的服务。

1.2 在线 ASR 与本地部署 ASR 的对比

调用 ASR 有两种主流方式:调用在线 ASR 服务 与 本地部署 ASR 模型。两者的对比如下:

对比项 在线 ASR 服务 本地部署 ASR(MOSS-Transcribe-Diarize)
联网要求 需要联网调用云端服务 无需联网,完全离线可用
调用成本 按量计费或订阅 模型开源免费,需自备硬件
数据隐私 音频上传至云端 数据不出本地,隐私可控
部署门槛 低,注册即可调用 较高,需要配置深度学习环境
语种支持 视服务商而定 支持 50+ 种语言
识别速度 受网络影响 GPU 加速后可达实时的 4 至 5 倍

1.3 本教程的选型

  • MOSS-Transcribe-Diarize 0.9B:OpenMOSS 团队开源的端到端语音转写模型,将语音识别与说话人分离合并为单模型一次推理,直接输出带秒级时间戳与说话人标签的转写文本;支持 50+ 种语言、单次直推最长 90 分钟的长音频,并可通过热词提示提升专名识别准确率,采用 Apache-2.0 许可证。
  • vLLM 推理服务:主流的大模型推理框架,原生注册了 MOSS-Transcribe-Diarize 模型,对外直接暴露 OpenAI 兼容的 POST /v1/audio/transcriptions 接口,通过显存分配与批处理调度把长音频转写优化到实时的 4 至 5 倍速度。

选择建议:需要离线、隐私可控、长音频与多说话人转写的场景选择本地部署 MOSS-Transcribe-Diarize;快速原型或语音量小的场景可选用在线服务。本教程的示例项目把 vLLM 服务再封装一层,补齐文本清洗、字幕生成与前端交互能力,便于与既有系统集成。

2. MOSS-Transcribe-Diarize 模型详解

2.1 模型简介

MOSS-Transcribe-Diarize 0.9B 是 OpenMOSS 团队于 2026 年 7 月开源的端到端音频理解模型,专注长音频、多说话人场景的转写任务。传统方案需要"语音识别 + 说话人分离"两条流水线拼接,结果对齐困难且误差叠加;该模型把两个任务统一建模,一次推理同时产出文字、时间戳与说话人标签。模型在第二届 MLC-SLM Challenge(INTERSPEECH 2026)覆盖 14 种语言的赛道中获得第一名。

模型总参数量约 0.9B,由文本解码器与音频编码器两部分组成:

graph LR A[16kHz 音频输入] --> B[音频特征提取
80 维梅尔频谱 30 秒窗口] B --> C[Whisper-Medium 音频编码器] C --> D[4 倍时间合并
与 MLP 适配层] D --> E[Qwen3-0.6B 文本解码器
自回归输出] E --> F["段式转写文本
起始时间戳 + 说话人标签 + 正文 + 结束时间戳"] classDef input fill:#d6eaf8,stroke:#1a6b8a,stroke-width:2px classDef model fill:#ffecd6,stroke:#ca6f1e,stroke-width:2px classDef output fill:#ebdef0,stroke:#6c3483,stroke-width:2px class A input class B,C,D,E model class F output
组件 规格 作用
音频前端 WhisperFeatureExtractor,16kHz、80 梅尔频带、30 秒窗口 把波形转为梅尔频谱特征
音频编码器 Whisper-Medium 编码器配置 将频谱编码为音频特征向量
音频文本桥接 4 倍时间合并与 MLP 适配层 压缩音频序列长度,对齐到文本词元空间
文本解码器 Qwen3-0.6B 同结构因果解码器 自回归生成转写文本与标签

2.2 核心能力

  • 转写与说话人分离一体:单模型一次推理同时输出文字与 [S01]、[S02] 等说话人标签,无需外挂分离模型,也不用做多流水线结果对齐
  • 秒级时间戳:每段语音自带起止时间戳,是自动字幕的关键能力
  • 多语言识别:支持 50+ 种语言,中文效果位居开源模型第一梯队
  • 长音频直推:单次推理最长支持 90 分钟录音,无需预先切片
  • 热词提示:把专名写入提示词即可引导模型输出,显著减少产品名、人名、术语的错别字
  • 逐字转写:可要求模型保留语气词与口误,适合视频剪辑对轴场景
  • 声学事件感知:支持输出掌声、音乐等声学事件标注,为下游系统提供更丰富的音频理解

2.3 段式输出格式

模型输出的转写文本是紧凑的段式结构,每一段以起始时间戳和说话人标签开头,正文为语音内容,段末标注结束时间戳,相邻段落共用中间的时间戳:

[0.48][S01]大家好,欢迎来到本期教程[4.10][12.26][S02]今天我们聊一聊语音识别模型的本地部署[15.80][16.02][S01]好的,我们正式开始[18.76]

按正则表达式解析后得到结构化分段:

起始(秒) 结束(秒) 说话人 文本
0.48 4.10 S01 大家好,欢迎来到本期教程
12.26 15.80 S02 今天我们聊一聊语音识别模型的本地部署
16.02 18.76 S01 好的,我们正式开始

解析用的正则表达式如下,.*? 配合 re.S 标志跨行匹配正文:

import re

pattern = re.compile(r"\[(\d+(?:\.\d+)?)\]\[S(\d+)\](.*?)\[(\d+(?:\.\d+)?)\]", re.S)

2.4 vLLM 推理服务

MOSS-Transcribe-Diarize 官方支持 vLLM 部署,vLLM 0.26.0 起原生包含该模型的注册代码,无需额外插件。推理服务通过以下参数控制资源与行为,这些取值来自实测调优后的稳定配置:

启动参数 取值 说明
--trust-remote-code 必带 允许加载模型仓库中的自定义代码
--port 8001 服务监听端口
--gpu-memory-utilization 0.55 显存占用比例,实测整机显存峰值约 15.3GB
--max-model-len 32768 上下文长度上限,覆盖 90 分钟音频的词元数
--max-num-batched-tokens 16384 单批词元上限,同时决定音频编码缓存大小
--served-model-name moss-asr 对外暴露的模型名称,调用时按此名称传参

在 WSL2 环境中运行还需要三个环境变量:

环境变量 取值 说明
VLLM_WSL2_ENABLE_PIN_MEMORY 1 WSL2 默认禁用锁页内存,必须打开,否则启动报 UVA 不可用
VLLM_MAX_AUDIO_DECODE_DURATION_S 5400 单文件音频时长上限(秒),放开到模型支持的 90 分钟
VLLM_MAX_AUDIO_CLIP_FILESIZE_MB 200 单文件体积上限(MB),默认 25MB 只够约 15 分钟的 wav

注意:模型目录中 generation_config.json 的 max_new_tokens 建议改为 16384(默认值较小),否则长音频的转写文本会在生成到上限时被截断。

3. 环境准备

本章在 WSL2 的 Ubuntu 中完成模型服务的部署,Windows 侧只需要保证有 ffmpeg 与 curl 命令(用于音频抽取与接口验证,可从 ffmpeg 官网下载后加入 PATH)。

3.1 创建 conda 环境并安装 vLLM

进入 WSL2 的 Ubuntu 终端(Windows 命令行执行 wsl -d Ubuntu 即可进入),创建独立 conda 环境 vllm,依赖统一使用 uv 安装:

# 创建并激活 conda 环境(vLLM 官方推荐 Python 3.12)
conda create -n vllm python=3.12 -y
conda activate vllm

# 安装 uv(Python 包管理工具)
pip install uv

# 安装 vLLM(0.26.0 起原生包含 MOSS-Transcribe-Diarize 模型注册,自动匹配 CUDA 版本的 PyTorch)
uv pip install -U vllm --torch-backend=auto

# 安装音频解码依赖
uv pip install soundfile

3.2 下载模型权重

从本教程资源下载区块的夸克网盘地址下载模型压缩包 MOSS-Transcribe-Diarize.zip(约 1.4 GB),解压后放置到 WSL 的 ~/models/MOSS-Transcribe-Diarize 目录:

  1. 通过夸克网盘把压缩包下载到本地(Windows 侧下载即可)
  2. 解压压缩包,把得到的模型文件放入 WSL 的 ~/models/MOSS-Transcribe-Diarize 目录;若解压出的是同名嵌套文件夹,把文件夹内的全部内容移动到目标目录,保证最终目录中直接包含 config.json、generation_config.json、model.safetensors 等文件

下载完成后检查生成配置,放开单次生成的词元上限:

# 编辑 ~/models/MOSS-Transcribe-Diarize/generation_config.json
# 将 max_new_tokens 修改为 16384,防止长音频转写被截断
{
  "max_new_tokens": 16384
}

如需自定义模型路径,同步修改第 3.3 节启动命令中的模型目录即可。

3.3 启动 vLLM 服务

在 WSL2 的 Ubuntu 终端中,激活环境后先导出三个环境变量,再启动服务:

conda activate vllm

# WSL2 环境必需的三个环境变量
export VLLM_WSL2_ENABLE_PIN_MEMORY=1
export VLLM_MAX_AUDIO_DECODE_DURATION_S=5400
export VLLM_MAX_AUDIO_CLIP_FILESIZE_MB=200

# 启动服务(前台运行,关闭终端服务即停)
vllm serve ~/models/MOSS-Transcribe-Diarize \
  --trust-remote-code \
  --port 8001 \
  --gpu-memory-utilization 0.55 \
  --max-model-len 32768 \
  --max-num-batched-tokens 16384 \
  --served-model-name moss-asr

服务为常驻进程,建议保持该终端窗口不关闭;也可以用 nohup 挂到后台运行。冷启动(模型加载到显存)约需 2 至 3 分钟,看到日志出现 Application startup complete 即启动完成。停止服务可在 WSL 内执行 fuser -k 8001/tcp。

另开一个终端检查服务健康状态:

curl http://localhost:8001/v1/models

返回以下内容说明模型已就绪:

{"object": "list", "data": [{"id": "moss-asr", "object": "model"}]}

3.4 快速验证转写

准备一段音频,用 ffmpeg 统一为 16kHz 单声道 wav 后调用转写接口(在 Windows 侧执行):

# 抽取音轨为 16kHz 单声道 wav
ffmpeg -y -i test.mp3 -vn -ac 1 -ar 16000 test.wav

# 调用 vLLM 转写接口
curl -sS http://localhost:8001/v1/audio/transcriptions \
  -F "model=moss-asr" \
  -F "response_format=json" \
  -F "temperature=0" \
  -F "file=@test.wav"

返回的 text 字段即第 2.3 节介绍的段式转写文本:

{"text": "[0.48][S01]大家好,欢迎来到本期教程[4.10][12.26][S02]今天我们聊一聊语音识别模型的本地部署[15.80]"}

提示:请求中的 prompt 字段可以追加指令,例如热词提示与逐字转写要求,具体用法见第 4.4 节。

4. 综合示例:voice2text 语音识别服务

4.1 项目目标与架构

示例项目 voice2text 把 MOSS-Transcribe-Diarize 转写服务封装为一个完整的语音识别应用,实现以下目标:

  • 用户上传一段音频,或用麦克风录音,服务识别并输出文字
  • 支持热词提示与逐字转写开关,改善专名识别与剪辑对轴体验
  • 对外暴露 OpenAI 兼容的 POST /v1/audio/transcriptions 接口
  • 识别结果可转换为带时间轴的字幕,一键下载 SRT / VTT 字幕文件
  • 构建前端页面,支持文件上传与浏览器内麦克风录音

推理由 WSL2 内的 vLLM 服务承担,voice2text 作为客户端负责调用、解析与封装,整体架构如下:

graph LR U[前端页面
static/index.html] -->|上传音频 / 录音 WAV| A[FastAPI 后端
main.py] A --> S[asr_service.py
调用 vLLM 服务并解析段式文本] A --> P[subtitle.py
SRT / VTT 格式化] S -->|ffmpeg 抽取 16kHz 单声道 wav| F[ffmpeg 预处理
生成临时 wav] F -->|上传 wav 转写| V[vLLM 服务 :8001
WSL2 Ubuntu] V --> M[MOSS 模型
MOSS-Transcribe-Diarize 0.9B] S -->|识别文字| U P -->|字幕文件下载| U classDef frontend fill:#d6eaf8,stroke:#1a6b8a,stroke-width:2px classDef backend fill:#d5f5e3,stroke:#1e8449,stroke-width:2px classDef engine fill:#ebdef0,stroke:#6c3483,stroke-width:2px classDef model fill:#ffecd6,stroke:#ca6f1e,stroke-width:2px class U frontend class A,P,F backend class S,V engine class M model

4.2 项目结构

voice2text/
├── config.py                  # 服务配置(端口、vLLM 服务地址、热词、字幕分段时长)
├── main.py                    # FastAPI 后端,识别与字幕接口
├── asr_service.py             # ASR 识别服务(vLLM 客户端与段式文本解析)
├── subtitle.py                # 字幕格式化(SRT / VTT 生成)
├── requirements.txt           # 依赖管理
├── static/
│   └── index.html             # 前端页面(上传 + 录音 + 字幕下载)
└── README.md                  # 项目说明

4.3 配置文件 config.py

所有配置统一在 config.py 中修改,包括监听端口、vLLM 服务地址、模型名称、热词与字幕参数:

# config.py(核心片段,完整代码请查看 voice2text/config.py)
PORT = 8000

# WSL2 内 vLLM 转写服务地址(承载 MOSS-Transcribe-Diarize 模型)
ASR_SERVICE_URL = "http://localhost:8001"

# vLLM 服务的模型名称(对应启动服务时的 --served-model-name)
ASR_MODEL_NAME = "moss-asr"

# 采样温度,转写任务固定为 0 保证同一音频的结果稳定
ASR_TEMPERATURE = 0

# 常驻热词(逗号分隔),产品名、人名、术语等,可显著减少专名错别字
ASR_HOTWORDS = ""

# 逐字转写:保留语气词(嗯、呃、啊)与口误,剪辑对轴场景建议开启
ASR_VERBATIM = False

# 转写请求超时(秒),长音频转写耗时数分钟,放宽到 1 小时
REQUEST_TIMEOUT_SEC = 3600

# 字幕单条最大时长(秒),值越小每条字幕越短、时间轴越细
SUBTITLE_MAX_LENGTH_S = 5

# 字幕是否去除句读标点(句号、逗号等),仅影响字幕下载,正文聚合识别不受影响
SUBTITLE_STRIP_PUNCTUATION = True

4.4 ASR 识别服务

asr_service.py 封装了对 vLLM 服务的调用逻辑。由于推理由远端服务承担,客户端的核心工作是三件事:组装提示词、预处理音频与解析段式文本。转写请求使用全局锁串行化,因为 vLLM 按整段音频推理,并发请求只会互相排队:

# asr_service.py(核心片段,完整代码请查看 voice2text/asr_service.py)
# 默认提示词:让模型输出带时间戳与说话人编号的段式文本(与官方推荐一致)
DEFAULT_PROMPT = (
    "请将音频转写为文本,每一段需以起始时间戳和说话人编号"
    "([S01]、[S02]、[S03]…)开头,正文为对应的语音内容,"
    "并在段末标注结束时间戳,以清晰标明该段语音范围。"
)

# 逐字转写后缀:保留语气词与口误,适合剪辑对轴场景
VERBATIM_SUFFIX = "请逐字转写,保留语气词(嗯、呃、啊)和口误,不要润色或省略。"

# 整洁文稿后缀:与逐字转写二选一,实测追加指令后缀可稳定模型的段式输出格式
CONCISE_SUFFIX = "请省略语气词与口误,输出整洁文稿。"

# 段式文本解析:[起始秒][S01]正文[结束秒],相邻段落共用中间的时间戳
_SEGMENT_PATTERN = re.compile(
    r"\[(\d+(?:\.\d+)?)\]\[S(\d+)\](.*?)\[(\d+(?:\.\d+)?)\]", re.S
)


def build_prompt(hotwords, verbatim):
    """组装提示词:热词前置 + 基础指令 + 逐字/整洁后缀(二选一)"""
    prompt = ""
    if hotwords:
        prompt += f"热词提示:{hotwords}。"
    prompt += DEFAULT_PROMPT
    prompt += VERBATIM_SUFFIX if verbatim else CONCISE_SUFFIX
    return prompt


def _extract_wav(audio_path):
    """用 ffmpeg 把任意格式音频统一为 16kHz 单声道 wav(模型要求的输入格式)"""
    tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".wav")
    tmp.close()
    subprocess.run(
        ["ffmpeg", "-y", "-v", "error", "-i", audio_path,
         "-vn", "-ac", "1", "-ar", "16000", tmp.name],
        check=True,
    )
    return tmp.name


def _call_service(wav_path, prompt):
    """上传 wav 到 vLLM 服务,返回模型输出的段式文本"""
    with open(wav_path, "rb") as f:
        resp = requests.post(
            f"{ASR_SERVICE_URL}/v1/audio/transcriptions",
            files={"file": ("audio.wav", f, "audio/wav")},
            data={
                "model": ASR_MODEL_NAME,
                "response_format": "json",
                "temperature": ASR_TEMPERATURE,
                "prompt": prompt,
            },
            timeout=REQUEST_TIMEOUT_SEC,
        )
    resp.raise_for_status()
    return resp.json().get("text", "")


def parse_segments(text):
    """解析 [起始][S01]正文[结束] 段式文本,返回带说话人的分段列表"""
    segments = []
    for m in _SEGMENT_PATTERN.finditer(text):
        segments.append({
            "start": int(float(m.group(1)) * 1000),
            "end": int(float(m.group(4)) * 1000),
            "speaker": f"S{m.group(2)}",
            "text": m.group(3).strip(),
        })
    return segments


def transcribe(audio_path, hotwords=None, verbatim=None):
    """整段转写:返回拼接后的纯文本(保留标点,去除时间戳与说话人标签)"""
    hot = ASR_HOTWORDS if hotwords is None else hotwords
    verb = ASR_VERBATIM if verbatim is None else verbatim
    segments = _transcribe(audio_path, hot, verb)
    return "".join(seg["text"] for seg in segments)


def transcribe_segments(audio_path, hotwords=None, verbatim=None):
    """字幕转写:返回 [{"start": 毫秒, "end": 毫秒, "text": 文本}]"""
    hot = ASR_HOTWORDS if hotwords is None else hotwords
    verb = ASR_VERBATIM if verbatim is None else verbatim
    segments = _transcribe(audio_path, hot, verb)
    # 超过字幕时长上限的段落二次细分,切口不落入英文数字单元内部
    segments = split_segments(segments, SUBTITLE_MAX_LENGTH_S * 1000)
    # 字幕场景默认去除句读标点,与正文聚合识别结果区分
    if SUBTITLE_STRIP_PUNCTUATION:
        segments = strip_punctuation(segments)
    return segments

这套设计有三个要点。其一,MOSS-Transcribe-Diarize 的能力由提示词驱动:默认提示词让模型输出段式转写文本,逐字转写与整洁文稿两个后缀二选一追加(裸的默认提示词实测会让模型偶尔退化为不带时间戳的输出,追加指令后缀后格式稳定),热词以 热词提示:词1,词2。 的形式前置到提示词开头——实测热词追加在末尾时短音频容易丢时间戳,前置则时间戳与专名写法都能稳定输出。其二,模型要求 16kHz 单声道输入,客户端统一用 ffmpeg 预处理,因此 mp3、m4a、flac 等常见格式都能直接上传。其三,解析正则利用"相邻段落共用中间时间戳"的特性,一次匹配即可同时拿到上一段的结束时间与下一段的起始时间。

parse_segments() 返回的分段携带 speaker 说话人标签,本项目生成字幕时未使用它;如果需要按说话人区分字幕(如 [S01] 前缀或分轨字幕),在 transcribe_segments() 中把标签拼入文本即可。

4.5 后端接口

main.py 是服务的核心入口,提供三个业务接口:自定义的 /api/transcribe、OpenAI 兼容的 /v1/audio/transcriptions,以及生成字幕文件的 /api/subtitle:

# main.py(核心片段,完整代码请查看 voice2text/main.py)
@app.post("/api/transcribe")
def transcribe(
    file: UploadFile = File(...),
    hotwords: str = Form(""),
    verbatim: bool = Form(False),
):
    """自定义识别接口:上传音频文件,返回识别文字"""
    audio_path = _save_audio(file)               # 保存为临时文件
    try:
        text = asr_service.transcribe(audio_path, hotwords=hotwords or None,
                                      verbatim=verbatim)
    finally:
        os.unlink(audio_path)                    # 识别后清理临时文件
    return {"text": text}

@app.post("/v1/audio/transcriptions")
def transcriptions(
    file: UploadFile = File(...),
    model: str = Form("moss-asr"),
):
    """OpenAI 兼容的语音识别接口(model 参数仅做兼容)"""
    audio_path = _save_audio(file)
    try:
        text = asr_service.transcribe(audio_path)
    finally:
        os.unlink(audio_path)
    return {"text": text}                        # OpenAI 兼容返回格式

@app.post("/api/subtitle")
def generate_subtitle(
    file: UploadFile = File(...),
    hotwords: str = Form(""),
    verbatim: bool = Form(False),
    fmt: str = Form("srt"),                      # srt / vtt
):
    """字幕识别接口:返回可下载的字幕文件"""
    audio_path = _save_audio(file)
    try:
        segments = asr_service.transcribe_segments(audio_path, hotwords=hotwords or None,
                                                   verbatim=verbatim)
    finally:
        os.unlink(audio_path)
    content = subtitle.to_vtt(segments) if fmt == "vtt" else subtitle.to_srt(segments)
    return Response(
        content=content,
        media_type="text/vtt; charset=utf-8" if fmt == "vtt"
                   else "application/x-subrip; charset=utf-8",
        headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(filename)}"},
    )

代码要点说明:

功能模块 实现方式 说明
音频接收 UploadFile(multipart) 前端通过表单上传音频文件
临时文件 tempfile.NamedTemporaryFile 识别结束后清理,不落盘
自定义接口 POST /api/transcribe 前端使用的简单接口,支持热词与逐字转写参数
兼容接口 POST /v1/audio/transcriptions 与 OpenAI 规范一致,返回 {"text": ...}
字幕接口 POST /api/subtitle 返回 SRT / VTT 文本流,Content-Disposition 标记为附件下载

三个接口都声明为普通 def 而非 async def:转写链路中的 ffmpeg 抽取与 vLLM 调用都是阻塞操作,FastAPI 会自动把同步接口放入线程池执行,避免长时间占用事件循环。

4.6 前端页面

前端为单页 HTML 应用,位于 static/index.html。你可以前往 voice2text/static/index.html 查看完整的示例代码。页面主要功能:

  1. 上传音频:选择本地音频文件(wav/mp3/m4a/flac 等)作为识别输入
  2. 麦克风录音:调用浏览器麦克风录音,通过 Web Audio API 实时采集并编码为 16kHz WAV
  3. 热词提示:输入逗号分隔的热词(如产品名、人名、术语),随请求发送给后端组装进提示词
  4. 逐字转写开关:勾选后保留语气词与口误,适合视频剪辑对轴;默认关闭,输出更干净的文稿
  5. 音频预览:识别前可播放已选音频或录音
  6. 识别展示:点击"开始识别",通过 fetch 以 multipart 表单上传音频,展示返回的文字
  7. 字幕下载:识别完成后出现"下载 SRT 字幕"与"下载 VTT 字幕"按钮,点击后请求 /api/subtitle 并将返回的字幕文件保存到本地

麦克风录音采用 Web Audio API 采集原始 PCM 并编码为 WAV,不依赖 WebM/Opus,因此无需额外解码即可在服务端处理。

4.7 字幕生成与下载

字幕的本质是"带时间轴的纯文本":每条字幕由起始时间、结束时间与文本三部分组成。示例项目的 subtitle.py 承担三件事:先用 split_segments() 把超过时长上限的分段按标点二次切分,再用 strip_punctuation() 去除句读标点(受 SUBTITLE_STRIP_PUNCTUATION 控制),最后把时间戳分段格式化为标准字幕文件:

# subtitle.py(核心片段,完整代码请查看 voice2text/subtitle.py)
def _format_timestamp(ms, decimal_sep):
    """毫秒转 HH:MM:SS,mmm(SRT 用逗号)或 HH:MM:SS.mmm(VTT 用点)"""
    hours, rem = divmod(max(0, int(ms)), 3600_000)
    minutes, rem = divmod(rem, 60_000)
    seconds, millis = divmod(rem, 1000)
    return f"{hours:02d}:{minutes:02d}:{seconds:02d}{decimal_sep}{millis:03d}"

def to_srt(segments):
    """生成 SRT 格式字幕内容"""
    cues = []
    for i, seg in enumerate(segments, start=1):
        start = _format_timestamp(seg["start"], ",")
        end = _format_timestamp(seg["end"], ",")
        cues.append(f"{i}\n{start} --> {end}\n{seg['text']}")
    return "\n\n".join(cues) + "\n"

SRT 与 VTT 是两种最常用的字幕格式,主流播放器与视频平台均支持:

对比项 SRT VTT
全称 SubRip Text WebVTT(Web Video Text Tracks)
时间戳秒的分隔符 英文逗号 , 英文句点 .
文件头 无 必须以 WEBVTT 开头
典型用途 本地播放器、剪映、抖音等 网页 <video> 标签、HTML5 播放器

生成的字幕文件内容形如下方所示,可直接导入播放器或剪辑软件:

1
00:00:00,480 --> 00:00:04,100
大家好欢迎来到本期教程

2
00:00:12,260 --> 00:00:15,800
今天我们聊一聊语音识别模型的本地部署

也可以不经过前端页面,直接用 curl 下载字幕文件:

# fmt 可选 srt / vtt,-o 指定保存路径
curl.exe -X POST http://localhost:8000/api/subtitle -F "file=@C:/path/to/audio.mp3" -F "fmt=srt" -o audio.srt

4.8 运行与测试

第一步:确认 vLLM 服务可用

若已按第 3 章完成部署,确认 vLLM 服务仍在运行(curl http://localhost:8001/v1/models 有响应);否则先按第 3.3 节启动。

第二步:创建环境并安装依赖

conda create -n voice2text python=3.10 -y
conda activate voice2text
pip install uv

cd voice2text

# 安装服务依赖(无需 PyTorch,安装很轻量)
uv pip install -r requirements.txt --index-url https://pypi.tuna.tsinghua.edu.cn/simple

第三步:启动服务

python main.py

启动后访问 http://localhost:8000,即可看到语音识别界面。

第四步:验证功能

  • 在页面中上传一段音频或点击录音,点击"开始识别",确认能正确输出文字
  • 识别完成后点击"下载 SRT 字幕",用记事本打开下载的 .srt 文件,确认包含时间轴与文本
  • 也可使用 curl 直接验证接口:
curl.exe -X POST http://localhost:8000/api/transcribe -F "file=@C:/path/to/audio.wav"

# 带热词转写(修正专名错别字)
curl.exe -X POST http://localhost:8000/api/transcribe -F "file=@C:/path/to/audio.wav" -F "hotwords=FastAPI,vLLM"

# 验证字幕接口
curl.exe -X POST http://localhost:8000/api/subtitle -F "file=@C:/path/to/audio.mp3" -F "fmt=srt" -o audio.srt

第五步(可选):验证热词与逐字转写

  • 在热词输入框填入一段口播中出现的专有名词,对比开启前后的识别结果,确认专名拼写被修正
  • 勾选"逐字转写"重新识别,确认结果保留了语气词与口误

识别完成后的页面效果如下,热词 MOSS 被正确写入识别结果,并出现 SRT / VTT 字幕下载按钮:

voice2text 语音识别页面

4.9 使用 OpenAI SDK 验证接口兼容性

由于服务暴露了 OpenAI 兼容接口,可以直接使用 openai 官方 Python SDK 调用:

# 使用 openai SDK 调用本地 voice2text 服务
from openai import OpenAI

client = OpenAI(
    base_url="http://127.0.0.1:8000/v1",  # 本地服务地址
    api_key="not-needed",                 # 本地服务不校验 Key
)

with open("audio.wav", "rb") as f:
    result = client.audio.transcriptions.create(
        model="moss-asr",
        file=f,
    )
print(result.text)

把 base_url 换成 http://127.0.0.1:8001/v1 也可以直接调用 vLLM 服务本身,但拿到的是未经解析的段式原文(含时间戳与说话人标签);voice2text 服务则返回清洗后的纯文本,并额外提供字幕生成能力。

5. 总结

5.1 核心内容回顾

  • ASR 技术:语音转文字,核心流程为音频预处理、声学特征提取、声学模型与解码
  • MOSS-Transcribe-Diarize 0.9B:端到端转写模型,一次推理同时输出文字、秒级时间戳与说话人标签,支持 50+ 语言与 90 分钟长音频,热词提示可引导专名输出
  • vLLM 部署:0.26.0 起原生支持该模型,WSL2 环境需要打开锁页内存并放开音频时长与体积上限,实测显存峰值约 15.3GB、转写速度达实时的 4 至 5 倍
  • 服务封装:FastAPI 作为 vLLM 客户端,负责提示词组装、ffmpeg 音频预处理与段式文本解析,实现 /api/transcribe 与 OpenAI 兼容的 /v1/audio/transcriptions 接口
  • 字幕生成:解析出的时间戳分段经超长段二次细分与去标点后,格式化为 SRT / VTT 字幕文件供用户下载
  • 前端应用:支持上传音频与麦克风录音,提供热词提示与逐字转写开关

5.2 常见问题与解答

问:服务启动后第一次转写为什么要等两三分钟?

答:vLLM 冷启动需要把模型权重加载到显存并完成预分配,约需 2 至 3 分钟(日志出现 Application startup complete 即就绪)。就绪后模型常驻显存,后续转写按音频时长计费,实测为实时的 4 至 5 倍速度。

问:启动时报显存不足或 KV cache 超预算怎么办?

答:确认 --max-model-len 为 32768(调回模型默认的 131072 会预分配过大的 KV cache 导致启动失败)。显存仍不足时,把 --gpu-memory-utilization 与 --max-num-batched-tokens 调低,代价是可容纳的单文件音频变短。

问:报错 UVA is not available?

答:WSL2 默认禁用锁页内存。启动前必须导出 VLLM_WSL2_ENABLE_PIN_MEMORY=1(见第 3.3 节)。

问:报错 Audio exceeds maximum allowed duration 或 Maximum file size exceeded?

答:分别是音频时长与体积超出服务端上限。导出 VLLM_MAX_AUDIO_DECODE_DURATION_S=5400 与 VLLM_MAX_AUDIO_CLIP_FILESIZE_MB=200 即可放开到 90 分钟(90 分钟 16kHz 单声道 wav 约 173MB)。

问:报错 exceeds the pre-allocated encoder cache size?

答:音频词元数超出音频编码缓存。有效参数是 --max-num-batched-tokens(已内置 16384,覆盖约 1.5 小时音频),报错信息提示的 --limit-mm-per-prompt 对此无效。

问:长音频转写到一半文本被截断?

答:把模型目录 generation_config.json 的 max_new_tokens 改为 16384(见第 3.2 节)。若重新下载模型,需要重新修改。

问:产品名、人名、术语总是识别错怎么办?

答:使用热词提示。在 config.py 的 ASR_HOTWORDS 配置常驻热词,或在请求时通过 hotwords 参数传入(前端热词输入框、curl 的 -F "hotwords=..." 均可),热词会以 热词提示: 的形式前置进提示词引导模型输出。

问:识别结果里语气词(嗯、呃、啊)太多或太少?

答:由逐字转写开关控制。勾选逐字转写(或请求传 verbatim=true)保留语气词与口误,适合剪辑对轴;默认关闭,输出干净文稿。两种模式都保留完整标点。

问:麦克风录音后识别为空或失败?

答:确认浏览器允许麦克风权限;录音时长过短可能识别为空,建议录制 1 秒以上。也可改用上传音频方式测试。

问:上传 mp3/m4a 无法识别?

答:服务端统一用 ffmpeg 解码为 16kHz 单声道 wav,需要 Windows PATH 中有 ffmpeg 命令。若仍失败,可先手动转码为 wav 再上传。

问:转写耗时异常长怎么办?

答:确认服务只有一个实例在运行——多个旧实例占用端口时请求会被排队,可在 WSL 内执行 fuser -k 8001/tcp 后重启;同时确认带上了热词提示,无热词时英文专名的识别质量会明显下降。