把ASR换到实时语音转文字时踩过的坑
从静态ASR到实时语音转文字,我用过哪些方案,踩过哪些坑,最后怎么走到现在的。
但我当时有几个现实问题:
- 成本控制:项目预算有限,而且音频量会持续增长
- 隐私要求:有些录音包含敏感信息,不想上传到第三方
- 延迟要求:需要近实时的转文字能力,云端API的网络延迟无法接受
- 定制需求:需要训练特定领域的词汇表,比如一些行业术语
所以我决定先从本地部署入手。
为什么不用现成的云服务?
云服务确实方便,阿里云、腾讯云、科大讯飞都有现成的ASR API,按次付费,接入简单。但我当时有几个现实问题:
- 成本控制:项目预算有限,而且音频量会持续增长
- 隐私要求:有些录音包含敏感信息,不想上传到第三方
- 延迟要求:需要近实时的转文字能力,云端API的网络延迟无法接受
- 定制需求:需要训练特定领域的词汇表,比如一些行业术语
所以我决定先从本地部署入手。
本地部署方案:OpenAI Whisper
Whisper 是OpenAI开源的通用语音识别模型,支持多语言,效果还不错。我的第一步是安装:
pip install openai-whisper
pip install torch torchaudio
然后用最简单的命令测试:
whisper audio.mp3 --model base --output_format txt
第一次运行就遇到了坑:PyTorch 版本冲突,CUDA 显存不足,音频格式不支持。折腾了一下午,终于跑通了第一个测试文件。
模型选择和性能对比
Whisper 有多个模型大小可选:
| 模型 | 参数量 | 显存需求 | 速度 | 准确率 |
|---|---|---|---|---|
| tiny | 39M | ~1GB | 最快 | 基本可用 |
| base | 74M | ~1GB | 快 | 日常够用 |
| small | 244M | ~2GB | 中等 | 推荐使用 |
| medium | 769M | ~5GB | 慢 | 更准确 |
| large | 1550M | ~10GB | 很慢 | 最佳效果 |
Whisper 各档模型的显存占用差异很大,选型时先对照硬件上限比看参数量更实际。

显存从 tiny 的约 1GB 到 large 的约 10GB 跨了一个数量级,small 落在中间偏下的 sweet spot。
我的经验是:除非对准确率有极致要求,否则 small 模型是性价比最高的选择。在 Intel i7 + 16G RAM 的机器上,一段10分钟的音频大约需要30秒处理。
代码封装和实际使用
很快就发现命令行不够用,需要集成到Python代码里:
import whisper
import time
model = whisper.load_model("small")
def transcribe_audio(audio_path):
start_time = time.time()
result = model.transcribe(audio_path, language="zh", fp16=False)
duration = time.time() - start_time
return {
"text": result["text"],
"segments": result["segments"],
"processing_time": duration
}
result = transcribe_audio("meeting_recording.mp3")
print(f"处理时间: {result['processing_time']:.2f}秒")
print(f"转文字结果: {result['text'][:100]}...")
这里有个重要细节:fp16=False。如果用半精度浮点数,速度确实会快一些,但在某些老显卡上会报错。为了兼容性,我关掉了这个优化。
第一个坑:音频格式处理
Whisper 对音频格式挺挑剔,WAV、MP3、FLAC 都支持,但录音笔生成的 M4A 文件直接喂进去就会出错。需要先转换:
ffmpeg -i input.m4a -acodec pcm_s16le -ar 16000 -ac 1 output.wav
-ar 16000 很关键,把采样率降到16kHz,既能保证识别质量,又能减少处理时间。太高了浪费资源,太低了损失细节。
实时转文字的需求
静态ASR很快用上了,但新需求来了:需要实时转文字,比如在线会议、直播字幕。这完全是另一个问题。
实时处理的技术难点
实时和离线有几个本质区别:
- 需要边录音边识别,不能等整段音频录完再处理
- 延迟要够低,最好能在说话结束后1-2秒内出结果
- 音频是流式的,需要分段处理,又不能简单切分破坏语义
- 需要处理音频重叠、说话人切换的情况
VAD:语音活动检测
第一个问题是知道什么时候有语音、什么时候是静音。纯能量检测不够用,环境噪音、键盘敲击都会误判。我用了 webrtcvad 库:
import webrtcvad
import collections
vad = webrtcvad.Vad(2) # 0-3,越大越严格
sample_rate = 16000
frame_duration = 30 # ms
def vad_collector(audio_stream, aggressiveness=3):
vad = webrtcvad.Vad(aggressiveness)
frames = []
for frame in audio_stream:
is_speech = vad.is_speech(frame, sample_rate)
if is_speech:
frames.append(frame)
elif frames:
# 收集到一段语音,返回
yield b''.join(frames)
frames = []
if frames:
yield b''.join(frames)
VAD 的 aggressiveness 参数很关键,从0到3,数字越大过滤越严格。我的经验是:会议室环境用2-3,安静的独白环境用1-2就行。
流式Whisper处理
有了VAD分段,就可以用Whisper做流式处理了。但不能简单地把每段语音独立喂给模型,因为上下文丢失会影响准确率。
我的方案是维护一个滑动窗口,保留最近几段的识别结果作为上下文:
class RealtimeTranscriber:
def __init__(self, model_name="small"):
self.model = whisper.load_model(model_name)
self.history = collections.deque(maxlen=3)
self.buffer = []
def process_chunk(self, audio_chunk):
# 把历史上下文拼接起来
context = " ".join([h for h in self.history])
# 先识别当前段落
result = self.model.transcribe(audio_chunk, language="zh")
current_text = result["text"].strip()
# 如果历史上下文和当前文本有重复,尝试合并
if self.history:
last = self.history[-1]
if current_text.startswith(last[-20:]):
current_text = current_text[len(last[-20:]):].strip()
self.history.append(current_text)
return current_text
这个方案不算完美,但在实际使用中表现还可以。延迟控制在2-3秒,准确率比纯段落处理高出不少。
Web端实时转文字:WebAssembly
项目后来有了Web端需求,需要在浏览器里做实时转文字。这又是另一个故事。
为什么选WebAssembly
纯JavaScript做不了这么重的计算,WebAssembly可以把Whisper模型跑在浏览器里,虽然慢一点,但不用上传音频,隐私保护好,延迟也低。
Whisper.cpp和ONNX Runtime
我试了两个方案:
- whisper.cpp: C++重写的Whisper,编译成WASM,体积小、速度快
- ONNX Runtime: 把Whisper转成ONNX格式,用ORT的WASM版本跑
最后选了 whisper.cpp,因为它更轻量,社区活跃,有现成的WASM版本:
<script src="https://cdn.jsdelivr.net/npm/@xenova/whisper"></script>
实际使用代码
// 加载模型
const transcriber = await pipeline('automatic-speech-recognition', 'Xenova/whisper-tiny');
// 处理音频流
async function processAudio(audioBuffer) {
const result = await transcriber(audioBuffer, {
language: 'chinese',
task: 'transcribe',
chunk_length_s: 30,
stride_length_s: 5
});
return result.text;
}
这里的 chunk_length_s 和 stride_length_s 很关键,它们控制分段和重叠策略。我的经验是:chunk_length_s=30,stride_length_s=5,在浏览器里能稳定运行。
Web端的性能坑
浏览器里跑Whisper有几个坑:
- 模型加载慢: tiny模型也要几秒,大模型直接卡死
- 内存占用高: Chrome单标签页内存有限,medium模型就吃紧
- 兼容性问题: iOS Safari的WASM支持有bug,经常崩溃
- 电池消耗: 手机上跑几分钟就发热严重
我的妥协方案是:Web端只跑 tiny 模型,而且做了降级处理——检测到性能问题时切换到云端API。
// 检测性能
const startTime = performance.now();
await transcriber(dummy_audio);
const loadTime = performance.now() - startTime;
if (loadTime > 2000) {
// 切换到云端API
return useCloudAPI(audioBuffer);
}
云端API兜底方案
虽然项目以本地部署为主,但云端API还是做了兜底。选了讯飞开放星的实时语音转文字API,原因简单:
- 低价位套餐够用
- 支持WebSocket长连接
- 对中文优化好
- 文档相对清晰
WebSocket接入代码
const ws = new WebSocket('wss://rtasr.xfyun.cn/v1/ws');
ws.onopen = () => {
// 发送认证信息
const auth = generateAuth(appId, apiKey, apiSecret);
ws.send(JSON.stringify({ auth }));
};
ws.onmessage = (event) => {
const result = JSON.parse(event.data);
if (result.result) {
console.log('实时结果:', result.result.ws[0].cw[0].w);
}
};
// 发送音频数据
function sendAudio(audioData) {
const base64 = arrayBufferToBase64(audioData);
ws.send(JSON.stringify({
data: {
status: 1, // 1: 首包, 2: 中间包, 3: 尾包
format: 'audio/L16;rate=16000',
audio: base64,
encoding: 'raw'
}
}));
}
这里的 status 字段很重要:1表示第一段音频,2表示中间段落,3表示结束。讯飞的WebSocket协议还算规范,但文档里有些细节没写清楚,我是看了开源项目才搞明白的。
云端API的坑
云端API虽然方便,但也有问题:
- 成本累积: 按时长计费,长期使用下来不便宜
- 网络依赖: 网络波动会中断连接,需要重连机制
- 隐私顾虑: 音频数据上传到第三方
- 定制限制: 热词、行业词汇需要额外付费
我的折中方案是:重要录音用本地Whisper,实时场景先用Web端WASM,性能不够时再切云端API。
性能优化的一些经验
折腾这么久,积累了一些性能优化的经验:
1. 音频预处理
- 统一采样率到16kHz:大部分语音识别模型在这个采样率下表现最好
- 单声道:立体声没必要,还浪费计算
- 动态范围压缩:提高低音量的识别率
ffmpeg -i input.mp3 -ac 1 -ar 16000 -filter:a "acompressor" output.wav
2. 模型量化
把模型从FP32量化到INT8,能减少75%的显存,速度提升30%,准确率损失不到1%。
# 使用whisper.cpp的量化工具
./quantize ./models/ggml-small.bin ./models/ggml-small-q8_0.bin q8_0
3. 批处理优化
如果有多个音频文件,不要串行处理,可以用Python的多进程:
from multiprocessing import Pool
def process_single(audio_path):
model = whisper.load_model("small")
result = model.transcribe(audio_path)
return result["text"]
with Pool(4) as p:
results = p.map(process_single, audio_files)
但要注意,每个进程都会加载模型,内存占用会翻倍。我的经验是:CPU核心数的一半就够了。
4. 结果缓存
重复的音频(比如反复测试的录音)可以缓存识别结果,避免重复计算:
import hashlib
import pickle
def get_cache_key(audio_path):
with open(audio_path, 'rb') as f:
return hashlib.md5(f.read()).hexdigest()
cache = {}
def transcribe_with_cache(audio_path):
cache_key = get_cache_key(audio_path)
if cache_key in cache:
return cache[cache_key]
result = transcribe_audio(audio_path)
cache[cache_key] = result
return result
实际项目中的架构
最终的项目架构大概是这个样子:
选择逻辑是:
- 先判断能否本地处理(性能、隐私要求)
- Web端优先用WASM,性能不够切云端
- 重要录音用本地Whisper,保证准确率和隐私
- 实时场景优先低延迟,准确率可以稍微牺牲
还没解决的问题
折腾了这么久,有些问题还是没解决好:
- 说话人分离: 多人会议时很难区分谁说了什么,用pyannote.audio效果一般
- 标点符号优化: Whisper生成的标点经常不准确,需要后处理
- 同音字纠错: “建设"和"见解"这种,ASR经常搞混
- 实时滚动显示: WebSocket返回的是增量结果,做平滑滚动展示挺麻烦
标点符号我试过几种方案,最后用的是简单规则+模型校验:
import re
def fix_punctuation(text):
# 简单规则
text = re.sub(r',。', ',', text) # 连续标点
text = re.sub(r'([。,!?])\1+', r'\1', text) # 重复标点
# 长句子加标点
if len(text) > 50 and ',' not in text:
text = text[:25] + ',' + text[25:]
return text
不算完美,但在实际场景里够用了。
一点个人判断
语音识别这几年进步确实大,从十年前的垃圾到现在的可用,已经是实用级别的技术了。但我不觉得它会完全替代人工输入,有些场景文字输入还是更精确、更可控。
实时转文字的场景反而是最有价值的:会议记录、直播字幕、辅助听障。这些场景对准确率要求不那么苛刻,但对实时性、隐私性要求高,正好是本地ASR的用武之地。
我的建议是:如果你只是偶尔要转一段录音,用云服务就行;但如果要做产品级别的功能,本地部署值得投入。前期开发成本高一些,但后期维护、成本控制、用户体验都更好。
最后一句:语音识别已经过了"看起来很酷"的阶段,进入了"真正解决问题"的阶段。这时候的技术选择,不能只看演示效果,要看具体场景的限制条件。
参考资料
版权声明: 本文首发于 指尖魔法屋-把ASR换到实时语音转文字时踩过的坑(https://blog.thinkmoon.cn/post/154-asr-realtime-speech-to-text/) 转载或引用必须申明原指尖魔法屋来源及源地址!
评论
使用 GitHub 账号登录后即可留言,支持 Markdown。