AI语音识别与实时转写完全教程

教程简介

本教程全面讲解AI语音识别与实时转写的核心技术,涵盖Whisper/Paraformer/WeNet模型对比、Whisper部署与使用、实时流识别方案、多语言与方言识别、说话人分离、语音增强降噪、标点恢复、会议实时转写系统搭建等核心内容,提供完整的多语言实时字幕系统实战案例。

AI语音识别与实时转写完全教程

1. ASR语音识别技术概述

自动语音识别(Automatic Speech Recognition, ASR)是将语音信号转换为文本的技术。现代ASR系统的核心架构经历了从传统GMM-HMM到端到端深度学习的演进。

技术演进路线

传统方法(2012年前):GMM-HMM(高斯混合模型-隐马尔可夫模型)是经典方案。声学特征(MFPL/MFCC)经过GMM建模后,通过HMM进行时序对齐,配合N-gram语言模型解码。精度有限,需要大量人工调参。

深度学习方法(2012-2018):DNN替代GMM作为声学模型,CTC(Connectionist Temporal Classification)损失函数解决了输入输出长度不对齐的问题。Listen, Attend and Spell(LAS)引入了注意力机制的端到端架构。

预训练大模型时代(2022至今):Whisper的出现标志着ASR进入大模型时代。通过68万小时多语言弱监督数据训练,单一模型即可处理多语言、多任务(识别、翻译、时间戳、语种检测)。Conformer架构(CNN+Transformer结合)成为工业界主流。

核心技术指标

  • WER(Word Error Rate):词错误率,最核心的评估指标,计算方式为 (插入+删除+替换) / 总词数
  • CER(Character Error Rate):字符错误率,中文场景更常用
  • RTF(Real Time Factor):实时率,处理时间/音频时长,RTF < 1表示能实时处理
  • 延迟(Latency):从说话结束到文本输出的时间,流式场景要求 < 500ms

2. 主流模型对比(Whisper / Paraformer / WeNet)

Whisper

OpenAI开源的通用语音识别模型,基于Encoder-Decoder Transformer架构。

模型 参数量 显存占用 英文WER 中文CER 速度
tiny 39M ~1GB 7.6% ~14% 极快
base 74M ~1GB 5.4% ~10% 很快
small 244M ~2GB 4.3% ~8%
medium 769M ~5GB 3.5% ~6% 中等
large-v3 1550M ~10GB 2.7% ~4% 较慢

优势:多语言支持好(99种语言)、零样本能力强、生态丰富 劣势:非流式设计(需完整音频段)、中文精度不如专用模型、大模型推理慢

Paraformer

阿里达摩院开源的非自回归端到端模型,中文识别精度极高。

优势:中文CER低、推理速度快(比Whisper large快10倍+)、支持流式 劣势:多语言支持有限、社区生态不如Whisper

WeNet

出门问问开源的生产级ASR工具包,主打易部署和流式支持。

优势:原生流式支持、C++ Runtime部署简单、支持多种解码方式 劣势:需自行训练或使用预训练模型、开箱即用体验不如Whisper

选型建议

场景 推荐方案
快速原型/多语言 Whisper
中文生产环境 Paraformer
流式实时场景 WeNet / Paraformer-streaming
离线批量处理 Whisper large-v3 / FunASR
低资源边缘设备 Whisper tiny / WeNet

3. Whisper模型部署与使用

基础安装与使用

pip install openai-whisper
# 或使用更快的实现
pip install faster-whisper

基本用法

import whisper

# 加载模型(首次运行会自动下载)
model = whisper.load_model("medium")

# 转录音频文件
result = model.transcribe("audio.mp3", language="zh")
print(result["text"])

# 带时间戳的转录
for segment in result["segments"]:
    start = segment["start"]
    end = segment["end"]
    text = segment["text"]
    print(f"[{start:.1f}s - {end:.1f}s] {text}")

faster-whisper:高性能推理

faster-whisper基于CTranslate2,推理速度比原版快4倍,显存占用更少:

from faster_whisper import WhisperModel

# 使用float16量化,GPU推理
model = WhisperModel("large-v3", device="cuda", compute_type="float16")

segments, info = model.transcribe(
    "audio.mp3",
    language="zh",
    beam_size=5,
    vad_filter=True,           # 启用VAD过滤静音段
    vad_parameters=dict(
        min_silence_duration_ms=500,
        speech_pad_ms=200,
    ),
)

print(f"检测到语言: {info.language}, 置信度: {info.language_probability:.2f}")

for segment in segments:
    print(f"[{segment.start:.2f}s -> {segment.end:.2f}s] {segment.text}")

Whisper的Prompt机制

Whisper支持传入prompt来引导解码,提高专业术语识别率:

# 上下文提示:提供相关的专业术语
initial_prompt = """
以下是一段关于人工智能的技术讨论,可能涉及以下术语:
大语言模型、Transformer、注意力机制、梯度下降、反向传播、
CUDA、PyTorch、TensorFlow、LoRA、量化、蒸馏
"""

result = model.transcribe(
    "tech_meeting.mp3",
    language="zh",
    initial_prompt=initial_prompt,
    condition_on_previous_text=True,  # 利用前文上下文
)

批量处理与GPU优化

import torch
from faster_whisper import WhisperModel
from pathlib import Path
from concurrent.futures import ThreadPoolExecutor

model = WhisperModel("large-v3", device="cuda", compute_type="float16")

def transcribe_file(audio_path: str) -> dict:
    segments, info = model.transcribe(audio_path, language="zh", beam_size=5)
    text = "".join(seg.text for seg in segments)
    return {"file": audio_path, "text": text, "language": info.language}

# 批量处理
audio_files = list(Path("./audio_files").glob("*.mp3"))
results = [transcribe_file(str(f)) for f in audio_files]

# 利用batch推理进一步提速
from faster_whisper import BatchedInferencePipeline

batched_model = BatchedInferencePipeline(model=model)
segments, info = batched_model.transcribe(
    "long_audio.mp3",
    language="zh",
    batch_size=16,  # 批量大小
)

4. 实时语音流识别方案

实时流式识别是会议转写、实时字幕等场景的核心需求。实现方案取决于模型架构。

方案一:VAD + 分段识别(Whisper适用)

Whisper本身不支持流式,但可以通过VAD(Voice Activity Detection)将音频切成片段后逐段识别:

import numpy as np
import sounddevice as sd
from faster_whisper import WhisperModel
from silero_vad import load_silero_vad, get_speech_timestamps

model = WhisperModel("small", device="cuda", compute_type="float16")
vad_model = load_silero_vad()

class StreamingTranscriber:
    def __init__(self, sample_rate=16000, chunk_duration=0.5):
        self.sample_rate = sample_rate
        self.chunk_size = int(sample_rate * chunk_duration)
        self.audio_buffer = np.array([], dtype=np.float32)
        self.silence_counter = 0
        self.min_speech_duration = 1.0    # 最少1秒才识别
        self.max_speech_duration = 30.0   # 最多30秒强制切段
        self.silence_threshold = 3        # 连续3个chunk无语音则结束

    def process_chunk(self, audio_chunk: np.ndarray) -> str | None:
        """处理一个音频chunk,返回识别文本或None"""
        self.audio_buffer = np.concatenate([self.audio_buffer, audio_chunk])

        # VAD检测当前chunk是否有语音
        speech_prob = vad_model(torch.from_numpy(audio_chunk), self.sample_rate).item()
        has_speech = speech_prob > 0.5

        if has_speech:
            self.silence_counter = 0
        else:
            self.silence_counter += 1

        buffer_duration = len(self.audio_buffer) / self.sample_rate

        # 触发识别条件:检测到静音且有足够音频,或超过最大时长
        should_transcribe = (
            (self.silence_counter >= self.silence_threshold and buffer_duration >= self.min_speech_duration)
            or buffer_duration >= self.max_speech_duration
        )

        if should_transcribe and buffer_duration >= self.min_speech_duration:
            segments, _ = model.transcribe(
                self.audio_buffer,
                language="zh",
                beam_size=3,
                vad_filter=True,
            )
            text = "".join(seg.text for seg in segments)
            self.audio_buffer = np.array([], dtype=np.float32)
            self.silence_counter = 0
            return text

        return None

# 实时录音+识别
def run_realtime():
    transcriber = StreamingTranscriber()
    print("开始说话... (Ctrl+C停止)")

    def audio_callback(indata, frames, time_info, status):
        audio_chunk = indata[:, 0].astype(np.float32)
        result = transcriber.process_chunk(audio_chunk)
        if result:
            print(f"识别结果: {result}")

    with sd.InputStream(
        samplerate=16000,
        channels=1,
        dtype='float32',
        blocksize=8000,  # 0.5秒
        callback=audio_callback,
    ):
        import time
        while True:
            time.sleep(0.1)

if __name__ == "__main__":
    run_realtime()

方案二:Paraformer流式模式

Paraformer原生支持流式识别,延迟更低:

from funasr import AutoModel

# 加载流式模型
model = AutoModel(
    model="paraformer-zh-streaming",
    vad_model="fsmn-vad",
    punc_model="ct-punc",
)

# 流式推理
import numpy as np
import sounddevice as sd

cache = {}
chunk_size = [0, 10, 5]  # [look-back, chunk, look-ahead],单位60ms

def stream_callback(indata, frames, time_info, status):
    audio_chunk = indata[:, 0]

    result = model.generate(
        input=audio_chunk,
        cache=cache,
        chunk_size=chunk_size,
        is_final=False,
        language="zh",
    )

    if result and result[0]["text"]:
        print(f"实时: {result[0]['text']}", end="\r")

with sd.InputStream(samplerate=16000, channels=1, dtype='float32',
                     blocksize=960, callback=stream_callback):  # 60ms
    import time
    while True:
        time.sleep(0.1)

方案三:WebSocket流式服务

搭建WebSocket服务,客户端实时发送音频流,服务端返回识别结果:

# 服务端
import asyncio
import websockets
import numpy as np
from faster_whisper import WhisperModel

model = WhisperModel("small", device="cuda", compute_type="float16")

async def handle_client(websocket):
    audio_buffer = bytearray()

    async for message in websocket:
        if isinstance(message, bytes):
            # 接收音频数据
            audio_buffer.extend(message)

            # 每收到约1秒音频就进行识别
            if len(audio_buffer) >= 16000 * 2:  # 16kHz * 2bytes * 1s
                audio_np = np.frombuffer(bytes(audio_buffer), dtype=np.int16).astype(np.float32) / 32768.0
                audio_buffer.clear()

                segments, _ = model.transcribe(audio_np, language="zh", beam_size=3)
                text = "".join(seg.text for seg in segments)

                if text.strip():
                    await websocket.send(json.dumps({
                        "type": "transcript",
                        "text": text,
                        "is_final": False,
                    }))

async def main():
    async with websockets.serve(handle_client, "0.0.0.0", 8765):
        print("WebSocket ASR服务启动在 ws://0.0.0.0:8765")
        await asyncio.Future()

asyncio.run(main())
// 浏览器客户端
class StreamingASR {
  constructor(wsUrl) {
    this.ws = new WebSocket(wsUrl);
    this.ws.binaryType = 'arraybuffer';
    this.mediaRecorder = null;
    this.onTranscript = () => {};
  }

  async start() {
    const stream = await navigator.mediaDevices.getUserMedia({
      audio: { sampleRate: 16000, channelCount: 1 }
    });

    const audioContext = new AudioContext({ sampleRate: 16000 });
    const source = audioContext.createMediaStreamSource(stream);
    const processor = audioContext.createScriptProcessor(4096, 1, 1);

    processor.onaudioprocess = (e) => {
      const audioData = e.inputBuffer.getChannelData(0);
      // Float32 -> Int16
      const int16Data = new Int16Array(audioData.length);
      for (let i = 0; i < audioData.length; i++) {
        int16Data[i] = Math.max(-32768, Math.min(32767, audioData[i] * 32768));
      }
      this.ws.send(int16Data.buffer);
    };

    source.connect(processor);
    processor.connect(audioContext.destination);

    this.ws.onmessage = (event) => {
      const data = JSON.parse(event.data);
      if (data.type === 'transcript') {
        this.onTranscript(data.text, data.is_final);
      }
    };
  }

  stop() {
    this.mediaRecorder?.stop();
    this.ws.close();
  }
}

// 使用
const asr = new StreamingASR('ws://localhost:8765');
asr.onTranscript = (text, isFinal) => {
  document.getElementById('output').textContent += text;
};
asr.start();

5. 多语言与方言识别

语种检测

Whisper内置语种检测能力,可以在转录前自动识别语言:

import whisper

model = whisper.load_model("medium")
audio = whisper.load_audio("unknown_language.mp3")
audio = whisper.pad_or_trim(audio)

# 生成梅尔频谱图
mel = whisper.log_mel_spectrogram(audio).to(model.device)

# 检测语言
_, probs = model.detect_language(mel)
detected_lang = max(probs, key=probs.get)
print(f"检测到语言: {detected_lang},置信度: {probs[detected_lang]:.2f}")

# 打印所有语言概率(Top 10)
sorted_langs = sorted(probs.items(), key=lambda x: x[1], reverse=True)[:10]
for lang, prob in sorted_langs:
    print(f"  {lang}: {prob:.3f}")

方言识别策略

中文方言识别是一个挑战。通用策略:

# 策略1:使用方言专用模型
# FunASR提供了粤语等方言模型
from funasr import AutoModel

# 粤语模型
cantonese_model = AutoModel(model="paraformer-zh")

# 策略2:通过prompt引导Whisper识别方言
result = model.transcribe(
    "cantonese_audio.mp3",
    language="zh",           # 仍标记为中文
    initial_prompt="以下是粤语对话,",  # prompt引导
)

# 策略3:多模型投票
def ensemble_transcribe(audio_path, models):
    results = []
    for m in models:
        result = m.transcribe(audio_path, language="zh")
        results.append(result["text"])

    # 取最长的结果(通常方言识别中,正确识别的结果包含更多细节)
    return max(results, key=len)

多语言混合场景

处理中英混合(Code-Switching)场景:

# Whisper对中英混合天然支持较好
result = model.transcribe(
    "code_switching.mp3",
    language=None,       # 不指定语言,让模型自动判断
    task="transcribe",   # "transcribe"保持原语言,"translate"翻译为英文
    condition_on_previous_text=True,
)

# 逐段检测语言
for segment in result["segments"]:
    # whisper每个segment没有直接的语言字段
    # 可以通过检测文本中的字符来判断
    text = segment["text"]
    has_chinese = any('\u4e00' <= c <= '\u9fff' for c in text)
    has_english = any(c.isascii() and c.isalpha() for c in text)
    if has_chinese and has_english:
        lang_tag = "中英混合"
    elif has_chinese:
        lang_tag = "中文"
    else:
        lang_tag = "英文"
    print(f"[{segment['start']:.1f}s] ({lang_tag}) {text}")

6. 说话人分离(Speaker Diarization)

说话人分离是将音频中的不同说话人区分开来的技术,在会议转写中至关重要。

基于pyannote的说话人分离

# pip install pyannote.audio
from pyannote.audio import Pipeline

# 加载预训练的说话人分离Pipeline(需要HuggingFace Token)
pipeline = Pipeline.from_pretrained(
    "pyannote/speaker-diarization-3.1",
    use_auth_token="YOUR_HF_TOKEN",
)

# 执行分离
diarization = pipeline("meeting.wav")

# 打印结果
for turn, _, speaker in diarization.itertracks(yield_label=True):
    print(f"{turn.start:.1f}s - {turn.end:.1f}s: {speaker}")

ASR + 说话人分离联合

将识别结果与说话人信息合并:

from faster_whisper import WhisperModel
from pyannote.audio import Pipeline
import numpy as np

def transcribe_with_speakers(audio_path: str) -> list[dict]:
    # 1. ASR识别
    asr_model = WhisperModel("medium", device="cuda", compute_type="float16")
    segments_iter, _ = asr_model.transcribe(audio_path, language="zh")
    asr_segments = [(seg.start, seg.end, seg.text) for seg in segments_iter]

    # 2. 说话人分离
    diarization_pipeline = Pipeline.from_pretrained("pyannote/speaker-diarization-3.1")
    diarization = diarization_pipeline(audio_path)

    # 3. 对齐:将ASR片段分配给说话人
    results = []
    for seg_start, seg_end, text in asr_segments:
        # 找到与当前ASR片段重叠最大的说话人
        best_speaker = "UNKNOWN"
        best_overlap = 0

        for turn, _, speaker in diarization.itertracks(yield_label=True):
            overlap_start = max(seg_start, turn.start)
            overlap_end = min(seg_end, turn.end)
            overlap = max(0, overlap_end - overlap_start)

            if overlap > best_overlap:
                best_overlap = overlap
                best_speaker = speaker

        results.append({
            "start": seg_start,
            "end": seg_end,
            "speaker": best_speaker,
            "text": text,
        })

    return results

# 使用
results = transcribe_with_speakers("meeting.wav")
for r in results:
    print(f"[{r['start']:.1f}s-{r['end']:.1f}s] {r['speaker']}: {r['text']}")

合并连续同说话人片段

def merge_consecutive_speaker_segments(segments: list[dict], gap_threshold: float = 1.0) -> list[dict]:
    """合并同一说话人的连续片段"""
    if not segments:
        return []

    merged = [segments[0].copy()]
    for seg in segments[1:]:
        last = merged[-1]
        if seg["speaker"] == last["speaker"] and seg["start"] - last["end"] < gap_threshold:
            last["end"] = seg["end"]
            last["text"] += seg["text"]
        else:
            merged.append(seg.copy())

    return merged

7. 语音增强与降噪预处理

在真实环境中,音频往往包含噪声、回声、混响等干扰。预处理能显著提升识别精度。

使用noisereduce降噪

# pip install noisereduce
import noisereduce as nr
import soundfile as sf

def denoise_audio(input_path: str, output_path: str):
    # 读取音频
    audio, sr = sf.read(input_path)

    # 降噪
    reduced_noise = nr.reduce_noise(
        y=audio,
        sr=sr,
        stationary=False,     # 非稳态噪声(更通用)
        prop_decrease=0.75,   # 噪声衰减比例
        freq_mask_smooth_hz=500,
        time_mask_smooth_ms=50,
    )

    sf.write(output_path, reduced_noise, sr)
    print(f"降噪完成: {output_path}")

音频预处理Pipeline

import numpy as np
import soundfile as sf
from scipy import signal

class AudioPreprocessor:
    def __init__(self, target_sr=16000):
        self.target_sr = target_sr

    def process(self, audio_path: str) -> np.ndarray:
        """完整的音频预处理流水线"""
        # 1. 读取并重采样
        audio, sr = sf.read(audio_path)
        if len(audio.shape) > 1:
            audio = np.mean(audio, axis=1)  # 立体声转单声道
        if sr != self.target_sr:
            audio = signal.resample(audio, int(len(audio) * self.target_sr / sr))

        # 2. 音量归一化
        audio = self._normalize(audio)

        # 3. 高通滤波去除低频噪声
        audio = self._highpass_filter(audio, cutoff=80)

        # 4. 降噪
        audio = self._denoise(audio)

        # 5. 去除静音段(可选)
        audio = self._remove_silence(audio)

        return audio

    def _normalize(self, audio: np.ndarray, target_db: float = -20.0) -> np.ndarray:
        """响度归一化"""
        rms = np.sqrt(np.mean(audio ** 2))
        current_db = 20 * np.log10(rms + 1e-10)
        gain = 10 ** ((target_db - current_db) / 20)
        return np.clip(audio * gain, -1.0, 1.0)

    def _highpass_filter(self, audio: np.ndarray, cutoff: int = 80) -> np.ndarray:
        """高通滤波"""
        nyq = self.target_sr / 2
        b, a = signal.butter(4, cutoff / nyq, btype='high')
        return signal.filtfilt(b, a, audio)

    def _denoise(self, audio: np.ndarray) -> np.ndarray:
        """降噪"""
        import noisereduce as nr
        return nr.reduce_noise(y=audio, sr=self.target_sr, stationary=False, prop_decrease=0.7)

    def _remove_silence(self, audio: np.ndarray, threshold_db: float = -40) -> np.ndarray:
        """去除首尾静音"""
        frame_len = int(self.target_sr * 0.025)
        energy = np.array([
            np.mean(audio[i:i+frame_len] ** 2)
            for i in range(0, len(audio) - frame_len, frame_len)
        ])
        energy_db = 10 * np.log10(energy + 1e-10)

        # 找到有效语音区域
        mask = energy_db > threshold_db
        if not mask.any():
            return audio

        first_speech = np.argmax(mask) * frame_len
        last_speech = (len(mask) - 1 - np.argmax(mask[::-1])) * frame_len
        return audio[first_speech:last_speech + frame_len]

8. 标点恢复与文本后处理

ASR输出通常是无标点的连续文本,标点恢复对可读性至关重要。

基于模型的标点恢复

# FunASR内置标点模型
from funasr import AutoModel

punc_model = AutoModel(model="ct-punc")

def restore_punctuation(text: str) -> str:
    result = punc_model.generate(input=text)
    return result[0]["text"]

# 示例
raw_text = "今天天气很好我们去公园散步吧你觉得怎么样"
punctuated = restore_punctuation(raw_text)
print(punctuated)
# 输出: "今天天气很好,我们去公园散步吧,你觉得怎么样?"

文本后处理Pipeline

import re

class TextPostProcessor:
    def __init__(self):
        # 常见错误修正词典
        self.corrections = {
            "人工只能": "人工智能",
            "机器学刁": "机器学习",
            "深渡学习": "深度学习",
        }

    def process(self, text: str) -> str:
        text = self._fix_punctuation(text)
        text = self._fix_spacing(text)
        text = self._fix_numbers(text)
        text = self._apply_corrections(text)
        text = self._remove_repetitions(text)
        return text.strip()

    def _fix_punctuation(self, text: str) -> str:
        """修正标点问题"""
        # 移除重复标点
        text = re.sub(r'[。,、!?]{2,}', lambda m: m.group()[0], text)
        # 确保句末有标点
        if text and text[-1] not in '。!?…':
            text += '。'
        return text

    def _fix_spacing(self, text: str) -> str:
        """修正空格"""
        # 中文之间不应有空格
        text = re.sub(r'([\u4e00-\u9fff])\s+([\u4e00-\u9fff])', r'\1\2', text)
        # 中英文之间加空格
        text = re.sub(r'([\u4e00-\u9fff])([a-zA-Z])', r'\1 \2', text)
        text = re.sub(r'([a-zA-Z])([\u4e00-\u9fff])', r'\1 \2', text)
        return text

    def _fix_numbers(self, text: str) -> str:
        """数字格式化"""
        # "一 百 二 十 三" -> "一百二十三"
        text = re.sub(r'(?<=[一二三四五六七八九十百千万亿])\s+(?=[一二三四五六七八九十百千万亿])', '', text)
        return text

    def _apply_corrections(self, text: str) -> str:
        """应用错误修正"""
        for wrong, correct in self.corrections.items():
            text = text.replace(wrong, correct)
        return text

    def _remove_repetitions(self, text: str) -> str:
        """去除ASR常见的重复识别"""
        # 去除连续重复的短语
        text = re.sub(r'(.{2,10})\1{2,}', r'\1', text)
        return text

热词增强

针对特定领域的专业术语,可以通过热词机制提升识别率:

# FunASR支持热词
from funasr import AutoModel

model = AutoModel(model="paraformer-zh")

# 通过hotword参数传入热词
result = model.generate(
    input="audio.wav",
    hotword="大语言模型 Transformer 注意力机制 LoRA 微调 量化",
)

# Whisper的等效方式:通过initial_prompt
result = model.transcribe(
    "audio.wav",
    language="zh",
    initial_prompt="以下内容涉及:大语言模型、Transformer、注意力机制、LoRA微调、量化部署。",
)

9. 会议实时转写系统搭建

构建一个完整的会议实时转写系统,需要整合ASR、说话人分离、标点恢复等多个模块。

系统架构

麦克风 -> 音频采集 -> VAD分段 -> ASR识别 -> 标点恢复 -> 说话人分离 -> 前端展示
                                    |
                              WebSocket实时推送

核心实现

# meeting_transcriber.py
import asyncio
import json
import time
import numpy as np
from dataclasses import dataclass, asdict

@dataclass
class TranscriptSegment:
    speaker: str
    text: str
    start_time: float
    end_time: float
    is_final: bool

class MeetingTranscriber:
    def __init__(self):
        from faster_whisper import WhisperModel
        from funasr import AutoModel as FunASRModel
        from pyannote.audio import Pipeline as DiarizationPipeline

        self.asr_model = WhisperModel("medium", device="cuda", compute_type="float16")
        self.punc_model = FunASRModel(model="ct-punc")
        self.diarization = DiarizationPipeline.from_pretrained("pyannote/speaker-diarization-3.1")

        self.audio_buffer = np.array([], dtype=np.float32)
        self.sample_rate = 16000
        self.segment_duration = 5.0   # 每5秒处理一次
        self.listeners = []           # WebSocket监听器

    async def add_listener(self, ws):
        self.listeners.append(ws)

    async def remove_listener(self, ws):
        self.listeners.remove(ws)

    async def broadcast(self, segment: TranscriptSegment):
        data = json.dumps(asdict(segment), ensure_ascii=False)
        for ws in self.listeners[:]:
            try:
                await ws.send(data)
            except:
                self.listeners.remove(ws)

    def feed_audio(self, audio_chunk: np.ndarray):
        """喂入音频数据"""
        self.audio_buffer = np.concatenate([self.audio_buffer, audio_chunk])

        buffer_duration = len(self.audio_buffer) / self.sample_rate
        if buffer_duration >= self.segment_duration:
            asyncio.create_task(self._process_buffer())

    async def _process_buffer(self):
        """处理音频缓冲区"""
        if len(self.audio_buffer) < self.sample_rate:  # 至少1秒
            return

        audio_to_process = self.audio_buffer.copy()
        self.audio_buffer = np.array([], dtype=np.float32)

        # ASR识别
        segments, _ = self.asr_model.transcribe(
            audio_to_process,
            language="zh",
            beam_size=3,
            vad_filter=True,
        )

        raw_text = "".join(seg.text for seg in segments)
        if not raw_text.strip():
            return

        # 标点恢复
        punc_result = self.punc_model.generate(input=raw_text)
        text = punc_result[0]["text"]

        # 发送结果
        segment = TranscriptSegment(
            speaker="检测中...",
            text=text,
            start_time=time.time(),
            end_time=time.time(),
            is_final=True,
        )
        await self.broadcast(segment)

WebSocket服务

# server.py
import asyncio
import websockets
import numpy as np
from meeting_transcriber import MeetingTranscriber

transcriber = MeetingTranscriber()

async def handle_client(websocket):
    await transcriber.add_listener(websocket)
    print(f"客户端已连接,当前监听数: {len(transcriber.listeners)}")

    try:
        async for message in websocket:
            if isinstance(message, bytes):
                # 接收音频数据(Int16格式)
                audio = np.frombuffer(message, dtype=np.int16).astype(np.float32) / 32768.0
                transcriber.feed_audio(audio)
    except websockets.exceptions.ConnectionClosed:
        pass
    finally:
        await transcriber.remove_listener(websocket)
        print(f"客户端已断开,当前监听数: {len(transcriber.listeners)}")

async def main():
    async with websockets.serve(handle_client, "0.0.0.0", 8765):
        print("会议转写服务启动在 ws://0.0.0.0:8765")
        await asyncio.Future()

asyncio.run(main())

前端展示页面

<!-- meeting_ui.html -->
<!DOCTYPE html>
<html>
<head>
    <title>实时会议转写</title>
    <style>
        body { font-family: system-ui; max-width: 800px; margin: 0 auto; padding: 20px; }
        .transcript-area { height: 60vh; overflow-y: auto; border: 1px solid #ddd; border-radius: 8px; padding: 16px; }
        .segment { margin-bottom: 12px; padding: 8px 12px; border-radius: 8px; background: #f5f5f5; }
        .segment .speaker { font-weight: bold; color: #2563eb; font-size: 0.85em; }
        .segment .text { margin-top: 4px; line-height: 1.6; }
        .segment .time { font-size: 0.75em; color: #999; }
        .controls { margin-top: 16px; display: flex; gap: 8px; }
        .btn { padding: 8px 24px; border: none; border-radius: 6px; cursor: pointer; font-size: 14px; }
        .btn-primary { background: #2563eb; color: white; }
        .btn-danger { background: #dc2626; color: white; }
        .status { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 8px; }
        .status.active { background: #22c55e; }
        .status.inactive { background: #9ca3af; }
    </style>
</head>
<body>
    <h1>🎙️ 实时会议转写</h1>
    <div class="controls">
        <button class="btn btn-primary" onclick="startRecording()">开始转写</button>
        <button class="btn btn-danger" onclick="stopRecording()">停止</button>
        <span><span class="status" id="status"></span><span id="statusText">未连接</span></span>
    </div>
    <div class="transcript-area" id="transcript"></div>

    <script>
    let ws, mediaRecorder, audioContext, processor;
    const transcript = document.getElementById('transcript');

    async function startRecording() {
        const stream = await navigator.mediaDevices.getUserMedia({ audio: { sampleRate: 16000, channelCount: 1 } });
        audioContext = new AudioContext({ sampleRate: 16000 });
        const source = audioContext.createMediaStreamSource(stream);
        processor = audioContext.createScriptProcessor(4096, 1, 1);

        ws = new WebSocket('ws://localhost:8765');
        ws.onopen = () => {
            document.getElementById('status').className = 'status active';
            document.getElementById('statusText').textContent = '转写中...';
        };
        ws.onmessage = (e) => {
            const data = JSON.parse(e.data);
            const div = document.createElement('div');
            div.className = 'segment';
            div.innerHTML = `
                <div class="speaker">${data.speaker} · <span class="time">${new Date(data.start_time * 1000).toLocaleTimeString()}</span></div>
                <div class="text">${data.text}</div>
            `;
            transcript.appendChild(div);
            transcript.scrollTop = transcript.scrollHeight;
        };

        processor.onaudioprocess = (e) => {
            const audio = e.inputBuffer.getChannelData(0);
            const int16 = new Int16Array(audio.length);
            for (let i = 0; i < audio.length; i++) int16[i] = audio[i] * 32768;
            if (ws.readyState === WebSocket.OPEN) ws.send(int16.buffer);
        };

        source.connect(processor);
        processor.connect(audioContext.destination);
    }

    function stopRecording() {
        processor?.disconnect();
        audioContext?.close();
        ws?.close();
        document.getElementById('status').className = 'status inactive';
        document.getElementById('statusText').textContent = '已停止';
    }
    </script>
</body>
</html>

10. 实战案例:多语言实时字幕系统

构建一个支持中英日韩多语言的实时字幕系统,适用于直播、视频会议等场景。

字幕系统架构

# subtitle_system.py
import asyncio
import time
from enum import Enum
from dataclasses import dataclass
from typing import Callable

class Language(Enum):
    ZH = "zh"
    EN = "en"
    JA = "ja"
    KO = "ko"
    AUTO = "auto"

@dataclass
class SubtitleLine:
    text: str
    language: Language
    start_ms: int
    end_ms: int
    confidence: float
    speaker_id: str | None = None

class MultiLanguageSubtitleSystem:
    def __init__(self):
        from faster_whisper import WhisperModel
        self.model = WhisperModel("medium", device="cuda", compute_type="float16")
        self.subtitles: list[SubtitleLine] = []
        self.callbacks: list[Callable] = []
        self.target_language: Language | None = None  # None表示不翻译

    def set_target_language(self, lang: Language | None):
        """设置目标语言(None=原语言,指定语言=翻译)"""
        self.target_language = lang

    def on_subtitle(self, callback: Callable[[SubtitleLine], None]):
        self.callbacks.append(callback)

    async def process_audio(self, audio: np.ndarray, timestamp_ms: int):
        """处理一段音频,生成字幕"""
        # 自动检测语言
        segments, info = self.model.transcribe(
            audio,
            language=None,         # 自动检测
            task="transcribe",     # 不翻译
            beam_size=3,
            vad_filter=True,
        )

        detected_lang = Language(info.language) if info.language in [l.value for l in Language] else Language.AUTO

        for seg in segments:
            subtitle = SubtitleLine(
                text=seg.text,
                language=detected_lang,
                start_ms=timestamp_ms + int(seg.start * 1000),
                end_ms=timestamp_ms + int(seg.end * 1000),
                confidence=seg.avg_logprob,
            )

            # 如果需要翻译
            if self.target_language and self.target_language != detected_lang:
                subtitle.text = await self._translate(subtitle.text, detected_lang, self.target_language)
                subtitle.language = self.target_language

            self.subtitles.append(subtitle)

            for cb in self.callbacks:
                cb(subtitle)

    async def _translate(self, text: str, from_lang: Language, to_lang: Language) -> str:
        """翻译字幕(可对接翻译API或本地模型)"""
        # 示例:使用Whisper的translate任务(仅支持翻译为英文)
        if to_lang == Language.EN:
            segments, _ = self.model.transcribe(
                text,
                language=from_lang.value,
                task="translate",
            )
            return "".join(seg.text for seg in segments)

        # 其他语言可对接翻译API
        # 这里返回原文作为fallback
        return text

    def export_srt(self, output_path: str):
        """导出SRT字幕文件"""
        with open(output_path, 'w', encoding='utf-8') as f:
            for i, sub in enumerate(self.subtitles, 1):
                start = self._ms_to_srt_time(sub.start_ms)
                end = self._ms_to_srt_time(sub.end_ms)
                f.write(f"{i}\n{start} --> {end}\n{sub.text}\n\n")

    @staticmethod
    def _ms_to_srt_time(ms: int) -> str:
        h = ms // 3600000
        m = (ms % 3600000) // 60000
        s = (ms % 60000) // 1000
        ms_rem = ms % 1000
        return f"{h:02d}:{m:02d}:{s:02d},{ms_rem:03d}"

    def export_vtt(self, output_path: str):
        """导出WebVTT字幕文件(Web标准)"""
        with open(output_path, 'w', encoding='utf-8') as f:
            f.write("WEBVTT\n\n")
            for i, sub in enumerate(self.subtitles, 1):
                start = self._ms_to_vtt_time(sub.start_ms)
                end = self._ms_to_vtt_time(sub.end_ms)
                f.write(f"{i}\n{start} --> {end}\n{sub.text}\n\n")

    @staticmethod
    def _ms_to_vtt_time(ms: int) -> str:
        h = ms // 3600000
        m = (ms % 3600000) // 60000
        s = (ms % 60000) // 1000
        ms_rem = ms % 1000
        return f"{h:02d}:{m:02d}:{s:02d}.{ms_rem:03d}"

OBS直播字幕集成

# obs_subtitle_overlay.py
import asyncio
import websockets
import json

class OBSSubtitleOverlay:
    """通过WebSocket向OBS的浏览器源推送字幕"""

    def __init__(self, obs_ws_url="ws://localhost:4455"):
        self.obs_ws_url = obs_ws_url
        self.subtitle_ws = None

    async def start_server(self, port=8766):
        """启动字幕WebSocket服务器,供OBS浏览器源连接"""
        async def handler(websocket):
            self.subtitle_ws = websocket
            try:
                async for _ in websocket:
                    pass  # 只用于发送,不接收
            finally:
                self.subtitle_ws = None

        async with websockets.serve(handler, "0.0.0.0", port):
            print(f"字幕服务器启动在 ws://0.0.0.0:{port}")
            await asyncio.Future()

    async def push_subtitle(self, text: str, duration_ms: int = 5000):
        if self.subtitle_ws:
            await self.subtitle_ws.send(json.dumps({
                "text": text,
                "duration": duration_ms,
            }))

# OBS浏览器源HTML模板
OBS_BROWSER_HTML = """
<!DOCTYPE html>
<html>
<style>
    body {
        margin: 0;
        background: transparent;
        display: flex;
        justify-content: center;
        align-items: flex-end;
        height: 100vh;
        padding-bottom: 40px;
        font-family: 'Noto Sans SC', sans-serif;
    }
    #subtitle {
        background: rgba(0, 0, 0, 0.75);
        color: white;
        padding: 8px 24px;
        border-radius: 6px;
        font-size: 24px;
        max-width: 80%;
        text-align: center;
        opacity: 0;
        transition: opacity 0.3s;
    }
    #subtitle.visible { opacity: 1; }
</style>
<body>
    <div id="subtitle"></div>
    <script>
        const el = document.getElementById('subtitle');
        const ws = new WebSocket('ws://localhost:8766');
        ws.onmessage = (e) => {
            const { text, duration } = JSON.parse(e.data);
            el.textContent = text;
            el.classList.add('visible');
            setTimeout(() => el.classList.remove('visible'), duration || 5000);
        };
    </script>
</body>
</html>
"""

11. 评估指标与优化策略

评估指标详解

import jiwer
from collections import Counter

def calculate_wer(reference: str, hypothesis: str) -> dict:
    """计算WER及相关指标"""
    measures = jiwer.compute_measures(reference, hypothesis)
    return {
        "wer": measures["wer"],
        "insertions": measures["insertions"],
        "deletions": measures["deletions"],
        "substitutions": measures["substitutions"],
        "hits": measures["hits"],
    }

def calculate_cer(reference: str, hypothesis: str) -> float:
    """计算字符错误率(中文适用)"""
    # 使用编辑距离
    ref_chars = list(reference.replace(" ", ""))
    hyp_chars = list(hypothesis.replace(" ", ""))

    # 动态规划计算编辑距离
    n, m = len(ref_chars), len(hyp_chars)
    dp = [[0] * (m + 1) for _ in range(n + 1)]

    for i in range(n + 1):
        dp[i][0] = i
    for j in range(m + 1):
        dp[0][j] = j

    for i in range(1, n + 1):
        for j in range(1, m + 1):
            if ref_chars[i-1] == hyp_chars[j-1]:
                dp[i][j] = dp[i-1][j-1]
            else:
                dp[i][j] = 1 + min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1])

    return dp[n][m] / max(n, 1)

def batch_evaluate(references: list[str], hypotheses: list[str]) -> dict:
    """批量评估"""
    total_wer = 0
    total_cer = 0

    for ref, hyp in zip(references, hypotheses):
        total_wer += calculate_wer(ref, hyp)["wer"]
        total_cer += calculate_cer(ref, hyp)

    n = len(references)
    return {
        "avg_wer": total_wer / n,
        "avg_cer": total_cer / n,
        "num_samples": n,
    }

优化策略

1. 模型选择优化

# 根据场景选择最优模型
def select_model(scenario: str):
    configs = {
        "realtime_low_latency": {
            "model": "small",
            "beam_size": 1,
            "compute_type": "int8",
            "vad_filter": True,
        },
        "offline_high_accuracy": {
            "model": "large-v3",
            "beam_size": 5,
            "compute_type": "float16",
            "vad_filter": True,
        },
        "batch_processing": {
            "model": "large-v3",
            "beam_size": 5,
            "compute_type": "float16",
            "batch_size": 16,
        },
        "edge_device": {
            "model": "tiny",
            "beam_size": 1,
            "compute_type": "int8",
        },
    }
    return configs.get(scenario, configs["offline_high_accuracy"])

2. 推理加速

# CTranslate2量化加速
from ctranslate2 import WhisperModel as CT2WhisperModel

# 转换模型格式(一次性)
converter = ctranslate2.converters.OpenAIConverter()
converter.convert(
    "large-v3",
    output_dir="whisper-large-v3-ct2",
    quantization="int8_float16",  # INT8权重 + FP16计算
)

# 使用量化模型推理
model = CT2WhisperModel("whisper-large-v3-ct2", device="cuda")
result = model.transcribe("audio.wav", beam_size=5)

3. VAD预过滤

from silero_vad import load_silero_vad, get_speech_timestamps
import torch

def filter_speech(audio: np.ndarray, sr: int = 16000) -> np.ndarray:
    """只保留有语音的部分,跳过静音"""
    model = load_silero_vad()
    audio_tensor = torch.from_numpy(audio)

    speech_timestamps = get_speech_timestamps(
        audio_tensor,
        model,
        sampling_rate=sr,
        min_speech_duration_ms=250,
        min_silence_duration_ms=100,
    )

    if not speech_timestamps:
        return audio

    # 拼接所有语音段
    speech_chunks = []
    for ts in speech_timestamps:
        speech_chunks.append(audio[ts['start']:ts['end']])

    return np.concatenate(speech_chunks)

4. 长音频分段策略

def transcribe_long_audio(audio_path: str, model, segment_duration: int = 30):
    """长音频分段识别,使用重叠窗口避免截断处丢字"""
    import soundfile as sf

    audio, sr = sf.read(audio_path)
    if len(audio.shape) > 1:
        audio = np.mean(audio, axis=1)

    segment_samples = segment_duration * sr
    overlap_samples = int(2.0 * sr)  # 2秒重叠
    step_samples = segment_samples - overlap_samples

    all_segments = []
    position = 0

    while position < len(audio):
        end = min(position + segment_samples, len(audio))
        chunk = audio[position:end]

        segments, _ = model.transcribe(chunk, language="zh", beam_size=5)

        for seg in segments:
            abs_start = position / sr + seg.start
            abs_end = position / sr + seg.end

            # 跳过重叠区域中已识别的部分
            if position > 0 and seg.start < 2.0:
                continue

            all_segments.append({
                "start": abs_start,
                "end": abs_end,
                "text": seg.text,
            })

        position += step_samples

    return all_segments

5. 生产环境部署建议

# docker-compose.yml
version: '3.8'
services:
  asr-server:
    build: .
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              count: 1
              capabilities: [gpu]
    ports:
      - "8765:8765"
    environment:
      - MODEL_SIZE=medium
      - COMPUTE_TYPE=float16
      - BEAM_SIZE=3
      - MAX_CONCURRENT=10
    volumes:
      - ./models:/root/.cache
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8765/health"]
      interval: 30s
      timeout: 10s
      retries: 3

以上涵盖了从基础ASR技术到完整会议转写系统搭建的全流程。在实际项目中,需要根据具体场景(实时性要求、语言需求、部署环境)选择合适的模型和架构方案。核心优化方向是:模型选择匹配场景需求、VAD预过滤减少无效计算、量化部署降低资源消耗、合理的分段策略平衡延迟与精度。

内容声明

本文内容为AI技术学习教程,仅供学习参考。如涉及技术问题,欢迎通过 xurj005@163.com 与我们交流。

目录