实时语音AI对话系统教程

教程简介

零基础实时语音AI对话系统教程,涵盖ASR语音识别、LLM对话引擎、TTS语音合成、WebSocket实时通信、流式处理、延迟优化等核心技能,适合AI开发者系统学习。

实时语音 AI 对话系统教程

SEO 信息

  • 名称:实时语音AI对话系统教程
  • 描述:零基础实时语音AI对话系统教程,涵盖ASR语音识别、LLM对话引擎、TTS语音合成、WebSocket实时通信、流式处理、延迟优化等核心技能,适合AI开发者系统学习。
  • 关键词:实时语音AI, 语音对话系统, Whisper, TTS, WebSocket
  • 长尾关键词:实时语音AI对话系统开发教程, 语音助手开发实战, Whisper+LLM语音对话系统, WebSocket实时语音AI开发

一、实时语音 AI 技术栈概览

一个完整的实时语音 AI 对话系统,本质上是将语音识别(ASR)、**大语言模型(LLM)语音合成(TTS)**三个模块通过实时通信管道串联起来。整个链路可以概括为:

用户语音输入 → VAD 语音活动检测 → ASR 语音识别 → LLM 对话生成 → TTS 语音合成 → 播放音频响应

核心技术栈如下:

模块 开源方案 商业方案
语音识别 ASR Whisper、Paraformer、FunASR Azure Speech、Google STT
对话引擎 LLM Qwen、DeepSeek、ChatGLM GPT-4o、Claude
语音合成 TTS ChatTTS、CosyVoice、Edge TTS Azure TTS、ElevenLabs
语音活动检测 Silero VAD、WebRTC VAD
实时通信 WebSocket、WebRTC
音频处理 FFmpeg、librosa、soundfile

选择开源方案的优势在于:零成本、可本地部署、数据不出域。本教程将以开源方案为主线,构建一套完整的实时语音 AI 对话系统。


二、ASR 语音识别:Whisper 与 Paraformer

2.1 Whisper 简介

Whisper 是 OpenAI 开源的自动语音识别模型,支持多语言识别和翻译。其核心架构是 Encoder-Decoder Transformer,训练数据规模达 68 万小时。

Whisper 有多种规格:

模型 参数量 推理速度 精度
tiny 39M 极快 一般
base 74M 很快 较好
small 244M
medium 769M 中等 很好
large-v3 1.55B 较慢 最佳

对于实时对话场景,推荐使用 smallmedium 模型,在精度和速度之间取得平衡。

2.2 使用 faster-whisper 进行语音识别

faster-whisper 基于 CTranslate2 实现,推理速度比原版快 4 倍以上:

from faster_whisper import WhisperModel

# 加载模型(首次运行会自动下载)
model = WhisperModel("small", device="cpu", compute_type="int8")

def transcribe(audio_path: str) -> str:
    """将音频文件转为文字"""
    segments, info = model.transcribe(audio_path, beam_size=5, language="zh")
    text = "".join([seg.text for seg in segments])
    return text.strip()

# 使用示例
result = transcribe("recording.wav")
print(f"识别结果: {result}")

2.3 Paraformer:高精度中文识别

Paraformer 是阿里达摩院提出的非自回归语音识别模型,中文识别精度优于同参数量的 Whisper:

# 使用 funasr 库
from funasr import AutoModel

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

def transcribe_paraformer(audio_path: str) -> str:
    result = model.generate(input=audio_path)
    if result and len(result) > 0:
        return result[0]["text"]
    return ""

text = transcribe_paraformer("recording.wav")
print(f"Paraformer 识别: {text}")

2.4 流式识别策略

实时对话系统不能等用户说完一整段再识别,需要采用流式识别策略:

import numpy as np
from faster_whisper import WhisperModel

model = WhisperModel("small", device="cpu", compute_type="int8")

class StreamingASR:
    """流式语音识别器"""
    
    def __init__(self, sample_rate=16000, chunk_duration=2.0):
        self.sample_rate = sample_rate
        self.chunk_size = int(sample_rate * chunk_duration)
        self.buffer = np.array([], dtype=np.float32)
        self.result_text = ""
    
    def feed_audio(self, audio_chunk: np.ndarray) -> str:
        """喂入音频数据,返回当前已识别文本"""
        self.buffer = np.concatenate([self.buffer, audio_chunk])
        
        # 缓冲区积累到足够长度时进行识别
        if len(self.buffer) >= self.chunk_size:
            segments, _ = model.transcribe(
                self.buffer, 
                beam_size=5, 
                language="zh",
                vad_filter=True
            )
            self.result_text = "".join([seg.text for seg in segments])
            # 保留最后 0.5 秒作为重叠
            overlap = int(self.sample_rate * 0.5)
            self.buffer = self.buffer[-overlap:]
        
        return self.result_text
    
    def finalize(self) -> str:
        """结束时对剩余音频做最终识别"""
        if len(self.buffer) > 0:
            segments, _ = model.transcribe(self.buffer, beam_size=5, language="zh")
            self.result_text = "".join([seg.text for seg in segments])
        return self.result_text

三、LLM 对话引擎集成

3.1 对话管理架构

语音对话系统的 LLM 引擎需要处理几个关键问题:上下文管理、流式输出、以及与语音模块的衔接。

from dataclasses import dataclass, field

@dataclass
class ConversationManager:
    """对话管理器"""
    system_prompt: str = "你是一个友好的语音助手,回答简洁明了。"
    history: list = field(default_factory=list)
    max_history: int = 20  # 最多保留的对话轮数
    
    def add_user_message(self, text: str):
        self.history.append({"role": "user", "content": text})
        self._trim_history()
    
    def add_assistant_message(self, text: str):
        self.history.append({"role": "assistant", "content": text})
        self._trim_history()
    
    def get_messages(self) -> list:
        return [{"role": "system", "content": self.system_prompt}] + self.history
    
    def _trim_history(self):
        if len(self.history) > self.max_history:
            self.history = self.history[-self.max_history:]

3.2 流式 LLM 调用

对于实时语音场景,LLM 必须采用流式输出,以便尽早将文本送入 TTS:

import httpx
import json

class StreamingLLM:
    """流式 LLM 调用器"""
    
    def __init__(self, api_base: str, model: str, api_key: str = "EMPTY"):
        self.api_base = api_base
        self.model = model
        self.api_key = api_key
    
    async def stream_chat(self, messages: list):
        """流式调用 LLM,yield 每个文本片段"""
        url = f"{self.api_base}/chat/completions"
        payload = {
            "model": self.model,
            "messages": messages,
            "stream": True,
            "temperature": 0.7,
            "max_tokens": 512,  # 语音场景控制回复长度
        }
        
        async with httpx.AsyncClient() as client:
            async with client.stream(
                "POST", url,
                json=payload,
                headers={"Authorization": f"Bearer {self.api_key}"},
                timeout=30.0
            ) as response:
                async for line in response.aiter_lines():
                    if line.startswith("data: ") and line != "data: [DONE]":
                        chunk = json.loads(line[6:])
                        delta = chunk["choices"][0].get("delta", {})
                        if "content" in delta:
                            yield delta["content"]

# 使用示例
async def demo():
    llm = StreamingLLM(
        api_base="http://localhost:8000/v1",
        model="qwen2.5-7b-instruct"
    )
    messages = [
        {"role": "system", "content": "你是语音助手"},
        {"role": "user", "content": "今天天气怎么样?"}
    ]
    async for token in llm.stream_chat(messages):
        print(token, end="", flush=True)

3.3 语音优化的 Prompt 策略

语音对话场景与文字对话不同,需要对 LLM 的输出进行约束:

VOICE_SYSTEM_PROMPT = """你是一个实时语音助手。请遵守以下规则:
1. 回答简洁,每次回复控制在 2-3 句话以内
2. 不要使用 markdown 格式、代码块、列表符号
3. 不要使用特殊符号如 **、##、- 等
4. 数字直接用文字表达,如"三十二"而非"32"
5. 避免长段落,口语化表达
6. 如果不确定,直接说不确定,不要编造长篇大论
"""

四、TTS 语音合成

4.1 Edge TTS:零成本高质量

Edge TTS 基于微软 Azure 的语音合成服务,完全免费,音质极佳:

import edge_tts
import asyncio

async def text_to_speech(text: str, output_path: str = "output.mp3"):
    """使用 Edge TTS 将文本转为语音"""
    # 中文女声
    voice = "zh-CN-XiaoxiaoNeural"
    communicate = edge_tts.Communicate(text, voice)
    await communicate.save(output_path)
    return output_path

# 使用示例
asyncio.run(text_to_speech("你好,我是你的语音助手!"))

# 查看所有可用中文语音
async def list_voices():
    voices = await edge_tts.list_voices()
    for v in voices:
        if "zh-CN" in v["Locale"]:
            print(f"{v['ShortName']} - {v['Gender']}")

asyncio.run(list_voices())

4.2 ChatTTS:开源可控语音合成

ChatTTS 是一个开源的对话式语音合成模型,支持情感控制和说话人定制:

import ChatTTS
import torch
import soundfile as sf

# 初始化 ChatTTS
chat = ChatTTS.Chat()
chat.load(compile=False)  # 设为 True 可加速但需要编译

def generate_speech(text: str, output_path: str = "chattts_output.wav"):
    """使用 ChatTTS 生成语音"""
    # 生成随机说话人嵌入
    rand_spk = chat.sample_random_speaker()
    
    params_infer = ChatTTS.Chat.InferCodeParams(
        spk_emb=rand_spk,
        temperature=0.3,   # 越低越稳定
        top_P=0.7,
        top_K=20,
    )
    
    params_refine = ChatTTS.Chat.RefineTextParams(
        prompt='[oral_2][laugh_0][break_6]',  # 口语化控制
    )
    
    wavs = chat.infer(
        [text],
        params_infer_code=params_infer,
        params_refine_text=params_refine,
    )
    
    sf.write(output_path, wavs[0], 24000)
    return output_path

generate_speech("今天天气真不错,要不要出去走走?")

4.3 CosyVoice:阿里最新语音合成

CosyVoice 支持多语言、情感控制和声音克隆:

# 需要先克隆 CosyVoice 仓库并安装依赖
from cosyvoice.cli.cosyvoice import CosyVoice

model = CosyVoice('pretrained_models/CosyVoice-300M')

# 基础语音合成
output = model.inference_sft('你好,欢迎使用实时语音助手!', '中文女')
# output 是生成的音频 tensor

# 带情感控制的合成
output = model.inference_cross_lingual(
    '今天心情真好啊!',
    prompt_speech_16k=reference_audio_tensor  # 参考音频
)

4.4 流式 TTS 策略

为了降低首字延迟,可以将 LLM 输出的文本分句后逐句合成:

import re
import asyncio
import edge_tts

class StreamingTTS:
    """流式语音合成器:按句子边界切分,逐句合成"""
    
    # 中英文句子结束标志
    SENTENCE_END = re.compile(r'[。!?.!?\n;;]')
    
    def __init__(self, voice: str = "zh-CN-XiaoxiaoNeural"):
        self.voice = voice
        self.buffer = ""
        self.audio_queue = asyncio.Queue()
    
    def feed_text(self, text: str) -> list[str]:
        """喂入文本片段,返回可合成的完整句子列表"""
        self.buffer += text
        sentences = []
        
        while True:
            match = self.SENTENCE_END.search(self.buffer)
            if match is None:
                break
            end_pos = match.end()
            sentence = self.buffer[:end_pos].strip()
            if sentence:
                sentences.append(sentence)
            self.buffer = self.buffer[end_pos:]
        
        return sentences
    
    async def synthesize(self, text: str) -> bytes:
        """合成单句语音"""
        communicate = edge_tts.Communicate(text, self.voice)
        audio_data = b""
        async for chunk in communicate.stream():
            if chunk["type"] == "audio":
                audio_data += chunk["data"]
        return audio_data

五、WebSocket 实时通信架构

5.1 为什么选择 WebSocket

实时语音对话对通信的要求:

  1. 低延迟:音频数据需要毫秒级传输
  2. 双向通信:客户端发送音频,服务端推送识别结果和合成音频
  3. 长连接:一次对话持续数分钟,不适合 HTTP 短连接
  4. 二进制传输:音频数据用二进制帧传输效率更高

WebSocket 完美满足以上所有需求。

5.2 服务端实现

import asyncio
import json
import numpy as np
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.websockets import WebSocketState

app = FastAPI()

class VoiceSession:
    """单个语音会话"""
    
    def __init__(self, websocket: WebSocket):
        self.ws = websocket
        self.asr = StreamingASR()
        self.conv = ConversationManager()
        self.tts = StreamingTTS()
        self.is_speaking = False  # AI 是否正在说话
    
    async def handle_audio(self, audio_bytes: bytes):
        """处理客户端发来的音频数据"""
        # 将 bytes 转为 float32 数组
        audio_np = np.frombuffer(audio_bytes, dtype=np.int16).astype(np.float32) / 32768.0
        
        # 送入 ASR
        partial_text = self.asr.feed_audio(audio_np)
        
        # 发送中间识别结果
        await self.ws.send_json({
            "type": "asr_partial",
            "text": partial_text
        })
    
    async def handle_end_of_speech(self):
        """用户说完了,开始生成回复"""
        # 最终识别
        final_text = self.asr.finalize()
        if not final_text.strip():
            return
        
        await self.ws.send_json({
            "type": "asr_final",
            "text": final_text
        })
        
        # 添加到对话历史
        self.conv.add_user_message(final_text)
        
        # 流式调用 LLM
        full_response = ""
        async for token in llm.stream_chat(self.conv.get_messages()):
            full_response += token
            
            # 发送文本片段
            await self.ws.send_json({
                "type": "llm_token",
                "text": token
            })
            
            # 将文本送入流式 TTS
            sentences = self.tts.feed_text(token)
            for sentence in sentences:
                audio = await self.tts.synthesize(sentence)
                await self.ws.send_bytes(audio)
        
        # 保存助手回复
        self.conv.add_assistant_message(full_response)

@app.websocket("/ws/voice")
async def voice_websocket(websocket: WebSocket):
    await websocket.accept()
    session = VoiceSession(websocket)
    
    try:
        while True:
            message = await websocket.receive()
            
            if message["type"] == "websocket.receive":
                if "bytes" in message:
                    # 二进制帧 = 音频数据
                    await session.handle_audio(message["bytes"])
                elif "text" in message:
                    # 文本帧 = 控制指令
                    data = json.loads(message["text"])
                    if data.get("action") == "end_of_speech":
                        await session.handle_end_of_speech()
                        
    except WebSocketDisconnect:
        print("客户端断开连接")

5.3 客户端实现(浏览器端)

class VoiceAssistant {
    constructor(wsUrl) {
        this.wsUrl = wsUrl;
        this.ws = null;
        this.mediaRecorder = null;
        this.audioContext = null;
        this.isRecording = false;
    }

    async connect() {
        this.ws = new WebSocket(this.wsUrl);
        this.ws.binaryType = 'arraybuffer';

        this.ws.onmessage = (event) => {
            if (typeof event.data === 'string') {
                const msg = JSON.parse(event.data);
                this.handleMessage(msg);
            } else {
                // 二进制数据 = 音频
                this.playAudio(event.data);
            }
        };

        this.ws.onopen = () => console.log('已连接到语音服务');
        this.ws.onclose = () => console.log('连接已断开');
    }

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

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

        processor.onaudioprocess = (e) => {
            if (!this.isRecording) return;
            const inputData = e.inputBuffer.getChannelData(0);
            // 转为 16-bit PCM
            const pcm = new Int16Array(inputData.length);
            for (let i = 0; i < inputData.length; i++) {
                pcm[i] = Math.max(-1, Math.min(1, inputData[i])) * 32767;
            }
            this.ws.send(pcm.buffer);
        };

        source.connect(processor);
        processor.connect(this.audioContext.destination);
        this.isRecording = true;
    }

    stopRecording() {
        this.isRecording = false;
        // 通知服务端用户说完了
        this.ws.send(JSON.stringify({ action: 'end_of_speech' }));
    }

    handleMessage(msg) {
        switch (msg.type) {
            case 'asr_partial':
                document.getElementById('asr-text').textContent = msg.text;
                break;
            case 'asr_final':
                document.getElementById('user-text').textContent = msg.text;
                break;
            case 'llm_token':
                document.getElementById('ai-text').textContent += msg.text;
                break;
        }
    }

    async playAudio(arrayBuffer) {
        const audioContext = new AudioContext();
        const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
        const source = audioContext.createBufferSource();
        source.buffer = audioBuffer;
        source.connect(audioContext.destination);
        source.start();
    }
}

六、流式音频处理

6.1 音频格式与采样率

实时语音系统中,音频格式的选择直接影响延迟和质量:

import numpy as np

class AudioProcessor:
    """音频处理器:负责格式转换和预处理"""
    
    @staticmethod
    def pcm16_to_float32(pcm_bytes: bytes) -> np.ndarray:
        """16-bit PCM 转 float32"""
        return np.frombuffer(pcm_bytes, dtype=np.int16).astype(np.float32) / 32768.0
    
    @staticmethod
    def float32_to_pcm16(audio: np.ndarray) -> bytes:
        """float32 转 16-bit PCM"""
        clipped = np.clip(audio, -1.0, 1.0)
        return (clipped * 32767).astype(np.int16).tobytes()
    
    @staticmethod
    def resample(audio: np.ndarray, orig_sr: int, target_sr: int) -> np.ndarray:
        """重采样"""
        if orig_sr == target_sr:
            return audio
        # 简单线性插值(生产环境建议用 librosa 或 soxr)
        duration = len(audio) / orig_sr
        target_len = int(duration * target_sr)
        indices = np.linspace(0, len(audio) - 1, target_len)
        return np.interp(indices, np.arange(len(audio)), audio)
    
    @staticmethod
    def normalize_volume(audio: np.ndarray, target_db: float = -20.0) -> np.ndarray:
        """音量归一化"""
        if len(audio) == 0:
            return audio
        rms = np.sqrt(np.mean(audio ** 2))
        if rms < 1e-8:
            return audio
        target_rms = 10 ** (target_db / 20)
        return audio * (target_rms / rms)

6.2 环形缓冲区

实时音频处理需要高效的缓冲区管理:

import numpy as np

class RingBuffer:
    """环形缓冲区:用于实时音频流"""
    
    def __init__(self, size: int):
        self.buffer = np.zeros(size, dtype=np.float32)
        self.size = size
        self.write_pos = 0
        self.read_pos = 0
        self.count = 0
    
    def write(self, data: np.ndarray) -> int:
        """写入数据,返回实际写入的样本数"""
        available = self.size - self.count
        to_write = min(len(data), available)
        
        if to_write <= 0:
            return 0
        
        end_pos = self.write_pos + to_write
        if end_pos <= self.size:
            self.buffer[self.write_pos:end_pos] = data[:to_write]
        else:
            first_part = self.size - self.write_pos
            self.buffer[self.write_pos:] = data[:first_part]
            self.buffer[:to_write - first_part] = data[first_part:to_write]
        
        self.write_pos = end_pos % self.size
        self.count += to_write
        return to_write
    
    def read(self, num_samples: int) -> np.ndarray:
        """读取指定数量的样本"""
        to_read = min(num_samples, self.count)
        if to_read <= 0:
            return np.array([], dtype=np.float32)
        
        end_pos = self.read_pos + to_read
        if end_pos <= self.size:
            result = self.buffer[self.read_pos:end_pos].copy()
        else:
            first_part = self.size - self.read_pos
            result = np.concatenate([
                self.buffer[self.read_pos:],
                self.buffer[:to_read - first_part]
            ])
        
        self.read_pos = end_pos % self.size
        self.count -= to_read
        return result

七、语音活动检测(VAD)

7.1 VAD 的作用

语音活动检测(Voice Activity Detection)用于判断音频中是否包含人声。在实时语音对话中,VAD 的核心任务是:

  1. 检测用户何时开始说话(减少无意义的静音传输)
  2. 检测用户何时说完(触发 LLM 生成回复)
  3. 过滤背景噪音(避免误触发识别)

7.2 使用 Silero VAD

Silero VAD 是目前最优秀的轻量级 VAD 模型:

import torch
import numpy as np

class VoiceActivityDetector:
    """基于 Silero VAD 的语音活动检测器"""
    
    def __init__(self, threshold: float = 0.5, sample_rate: int = 16000):
        self.model, _ = torch.hub.load(
            repo_or_dir='snakers4/silero-vad',
            model='silero_vad',
            force_reload=False
        )
        self.threshold = threshold
        self.sample_rate = sample_rate
        self.is_speech = False
        self.silence_duration = 0  # 持续静音时长(秒)
        self.speech_duration = 0   # 持续说话时长(秒)
        
        # 可调参数
        self.silence_timeout = 1.5   # 静音多久算说完(秒)
        self.min_speech_duration = 0.3  # 最短语音时长(秒)
    
    def process_chunk(self, audio_chunk: np.ndarray) -> dict:
        """
        处理一个音频块,返回状态信息
        返回: {"event": "start"|"speaking"|"end"|"silence", "is_speech": bool}
        """
        tensor = torch.from_numpy(audio_chunk).float()
        prob = self.model(tensor, self.sample_rate).item()
        
        current_speech = prob > self.threshold
        chunk_duration = len(audio_chunk) / self.sample_rate
        
        if current_speech:
            self.speech_duration += chunk_duration
            self.silence_duration = 0
            
            if not self.is_speech:
                if self.speech_duration >= 0.1:  # 连续说话超过 100ms 才算开始
                    self.is_speech = True
                    return {"event": "start", "is_speech": True}
            
            return {"event": "speaking", "is_speech": True}
        else:
            if self.is_speech:
                self.silence_duration += chunk_duration
                
                if self.silence_duration >= self.silence_timeout:
                    # 确认说完
                    if self.speech_duration >= self.min_speech_duration:
                        self.is_speech = False
                        self.speech_duration = 0
                        self.silence_duration = 0
                        return {"event": "end", "is_speech": False}
                
                return {"event": "speaking", "is_speech": True}
            
            self.speech_duration = 0
            return {"event": "silence", "is_speech": False}

7.3 VAD 集成到 WebSocket 流

class VoiceWebSocketHandler:
    def __init__(self):
        self.vad = VoiceActivityDetector(threshold=0.5)
        self.asr = StreamingASR()
        self.audio_buffer = []  # 用于缓冲待识别的音频
    
    async def on_audio_data(self, audio_bytes: bytes, websocket: WebSocket):
        audio = AudioProcessor.pcm16_to_float32(audio_bytes)
        
        # 分块处理(每 512 样本一块,约 32ms @ 16kHz)
        chunk_size = 512
        for i in range(0, len(audio), chunk_size):
            chunk = audio[i:i + chunk_size]
            if len(chunk) < chunk_size:
                chunk = np.pad(chunk, (0, chunk_size - len(chunk)))
            
            vad_result = self.vad.process_chunk(chunk)
            
            if vad_result["event"] == "start":
                # 用户开始说话,清空缓冲区
                self.audio_buffer = []
                await websocket.send_json({"type": "user_started_speaking"})
            
            elif vad_result["is_speech"]:
                # 正在说话,累积音频
                self.audio_buffer.append(chunk)
            
            elif vad_result["event"] == "end":
                # 用户说完了
                full_audio = np.concatenate(self.audio_buffer)
                final_text = self.asr.feed_audio(full_audio)
                
                await websocket.send_json({
                    "type": "speech_end",
                    "text": final_text
                })
                
                # 触发 LLM 回复...
                self.audio_buffer = []

八、端到端延迟优化

8.1 延迟来源分析

一个典型的语音 AI 对话的延迟分布:

用户说完 → [网络传输 50ms] → [VAD 检测 100ms] → [ASR 识别 500ms]
→ [LLM 首 token 200ms] → [TTS 合成首句 300ms] → [网络回传 50ms] → 用户听到
总延迟: ~1.2 秒

8.2 优化策略

策略一:流水线并行

import asyncio

async def optimized_pipeline(audio: np.ndarray, session: VoiceSession):
    """优化后的处理流水线"""
    
    # 1. ASR 识别(可以与 VAD 的尾部重叠执行)
    asr_task = asyncio.create_task(
        asyncio.to_thread(session.asr.finalize)
    )
    
    # 2. 等待 ASR 完成
    text = await asr_task
    session.conv.add_user_message(text)
    
    # 3. LLM 流式生成 + TTS 流式合成并行
    tts_buffer = ""
    tts_tasks = []
    
    async for token in llm.stream_chat(session.conv.get_messages()):
        tts_buffer += token
        
        # 按标点切分,每积累一句就发起 TTS
        if token in "。!?.!?\n":
            sentence = tts_buffer.strip()
            if sentence:
                task = asyncio.create_task(session.tts.synthesize(sentence))
                tts_tasks.append(task)
            tts_buffer = ""
    
    # 4. 收集 TTS 结果并按顺序发送
    for task in tts_tasks:
        audio_data = await task
        await session.ws.send_bytes(audio_data)

策略二:ASR 模型选择优化

# 使用 INT8 量化加速推理
model = WhisperModel("small", device="cuda", compute_type="int8_float16")

# 使用 VAD 预过滤静音段
segments, info = model.transcribe(
    audio,
    vad_filter=True,
    vad_parameters=dict(
        min_silence_duration_ms=500,
        speech_pad_ms=200
    )
)

策略三:首包 TTS 快速响应

class PriorityTTS:
    """优先级 TTS:第一句用快速引擎,后续用高质量引擎"""
    
    def __init__(self):
        self.fast_engine = edge_tts  # 快速
        self.high_quality_engine = None  # 高质量(如 CosyVoice)
    
    async def synthesize_first(self, text: str) -> bytes:
        """第一句:快速合成,降低首字延迟"""
        communicate = edge_tts.Communicate(text, "zh-CN-YunxiNeural")  # 男声,速度快
        audio = b""
        async for chunk in communicate.stream():
            if chunk["type"] == "audio":
                audio += chunk["data"]
        return audio
    
    async def synthesize_normal(self, text: str) -> bytes:
        """后续句子:高质量合成"""
        return await self.synthesize_first(text)  # 或使用高质量引擎

8.3 延迟监控

import time

class LatencyTracker:
    """端到端延迟追踪器"""
    
    def __init__(self):
        self.milestones = {}
    
    def mark(self, name: str):
        self.milestones[name] = time.perf_counter()
    
    def report(self) -> dict:
        if len(self.milestones) < 2:
            return {}
        
        names = list(self.milestones.keys())
        report = {}
        for i in range(1, len(names)):
            delta = (self.milestones[names[i]] - self.milestones[names[i-1]]) * 1000
            report[f"{names[i-1]} → {names[i]}"] = f"{delta:.0f}ms"
        
        total = (self.milestones[names[-1]] - self.milestones[names[0]]) * 1000
        report["总延迟"] = f"{total:.0f}ms"
        
        return report

# 使用示例
tracker = LatencyTracker()
tracker.mark("vad_end")
tracker.mark("asr_done")
tracker.mark("llm_first_token")
tracker.mark("tts_first_audio")
tracker.mark("audio_sent")
print(tracker.report())
# {'vad_end → asr_done': '480ms', 'asr_done → llm_first_token': '150ms', ...}

九、多语言支持

9.1 多语言 ASR

Whisper 本身支持 99 种语言,可以自动检测语言:

def multilingual_transcribe(audio_path: str) -> tuple[str, str]:
    """多语言识别,返回 (语言, 文字)"""
    segments, info = model.transcribe(audio_path, beam_size=5)
    language = info.language
    text = "".join([seg.text for seg in segments])
    return language, text

# 手动指定语言可以提升精度
segments, _ = model.transcribe(audio, language="ja")  # 日语
segments, _ = model.transcribe(audio, language="en")  # 英语

9.2 多语言 TTS

Edge TTS 支持超过 300 种语音,覆盖 70+ 语言:

MULTILINGUAL_VOICES = {
    "zh": "zh-CN-XiaoxiaoNeural",
    "en": "en-US-JennyNeural",
    "ja": "ja-JP-NanamiNeural",
    "ko": "ko-KR-SunHiNeural",
    "fr": "fr-FR-DeniseNeural",
    "de": "de-DE-KatjaNeural",
    "es": "es-ES-ElviraNeural",
}

async def multilingual_tts(text: str, language: str, output_path: str):
    voice = MULTILINGUAL_VOICES.get(language, "en-US-JennyNeural")
    communicate = edge_tts.Communicate(text, voice)
    await communicate.save(output_path)

9.3 自动语言路由

class MultilingualRouter:
    """多语言路由:自动选择 ASR 语言和 TTS 语音"""
    
    def __init__(self):
        self.detected_language = "zh"
    
    def on_asr_result(self, language: str):
        """ASR 检测到语言后更新路由"""
        self.detected_language = language
    
    def get_tts_voice(self) -> str:
        """根据检测到的语言选择 TTS 语音"""
        return MULTILINGUAL_VOICES.get(self.detected_language, "en-US-JennyNeural")
    
    def get_llm_prompt(self) -> str:
        """根据语言切换 LLM 回复语言"""
        lang_prompts = {
            "zh": "请用中文回答。",
            "en": "Please respond in English.",
            "ja": "日本語で回答してください。",
        }
        return lang_prompts.get(self.detected_language, "Please respond in English.")

十、实战:构建实时语音 AI 助手

10.1 项目结构

voice-ai-assistant/
├── server/
│   ├── main.py            # FastAPI 主入口
│   ├── asr.py             # 语音识别模块
│   ├── llm.py             # LLM 对话模块
│   ├── tts.py             # 语音合成模块
│   ├── vad.py             # 语音活动检测
│   ├── session.py         # 会话管理
│   └── config.py          # 配置文件
├── web/
│   ├── index.html         # 前端页面
│   ├── voice.js           # 语音交互逻辑
│   └── style.css          # 样式
├── requirements.txt
└── README.md

10.2 完整服务端代码

# server/main.py
import asyncio
import json
import numpy as np
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
from dataclasses import dataclass, field

app = FastAPI()

# ==================== 配置 ====================
@dataclass
class Config:
    asr_model: str = "small"
    llm_api_base: str = "http://localhost:8000/v1"
    llm_model: str = "qwen2.5-7b-instruct"
    tts_voice: str = "zh-CN-XiaoxiaoNeural"
    vad_threshold: float = 0.5
    silence_timeout: float = 1.5
    sample_rate: int = 16000

config = Config()

# ==================== ASR ====================
from faster_whisper import WhisperModel
asr_model = WhisperModel(config.asr_model, device="cpu", compute_type="int8")

# ==================== TTS ====================
import edge_tts

async def synthesize_speech(text: str, voice: str) -> bytes:
    communicate = edge_tts.Communicate(text, voice)
    audio = b""
    async for chunk in communicate.stream():
        if chunk["type"] == "audio":
            audio += chunk["data"]
    return audio

# ==================== LLM ====================
import httpx

async def stream_llm(messages: list):
    url = f"{config.llm_api_base}/chat/completions"
    payload = {
        "model": config.llm_model,
        "messages": messages,
        "stream": True,
        "temperature": 0.7,
        "max_tokens": 300,
    }
    async with httpx.AsyncClient() as client:
        async with client.stream("POST", url, json=payload, timeout=30) as resp:
            async for line in resp.aiter_lines():
                if line.startswith("data: ") and line != "data: [DONE]":
                    chunk = json.loads(line[6:])
                    delta = chunk["choices"][0].get("delta", {})
                    if "content" in delta:
                        yield delta["content"]

# ==================== VAD ====================
import torch
vad_model, _ = torch.hub.load('snakers4/silero-vad', 'silero_vad', force_reload=False)

# ==================== 会话 ====================
@dataclass
class Session:
    ws: WebSocket
    history: list = field(default_factory=list)
    audio_buffer: list = field(default_factory=list)
    is_speaking: bool = False
    silence_counter: int = 0

SYSTEM_PROMPT = """你是一个实时语音助手。回答简洁口语化,每次 1-3 句话。
不用 markdown、不用列表符号、数字用文字表达。"""

# ==================== WebSocket ====================
@app.websocket("/ws")
async def websocket_endpoint(ws: WebSocket):
    await ws.accept()
    session = Session(ws=ws)
    
    try:
        while True:
            msg = await ws.receive()
            
            if "bytes" in msg:
                audio = np.frombuffer(msg["bytes"], dtype=np.int16).astype(np.float32) / 32768.0
                
                # VAD 检测
                chunk_size = 512
                for i in range(0, len(audio), chunk_size):
                    chunk = audio[i:i+chunk_size]
                    if len(chunk) < chunk_size:
                        chunk = np.pad(chunk, (0, chunk_size - len(chunk)))
                    
                    tensor = torch.from_numpy(chunk).float()
                    prob = vad_model(tensor, config.sample_rate).item()
                    
                    if prob > config.vad_threshold:
                        if not session.is_speaking:
                            session.is_speaking = True
                            session.audio_buffer = []
                            await ws.send_json({"type": "status", "text": "listening"})
                        session.audio_buffer.append(chunk)
                        session.silence_counter = 0
                    else:
                        if session.is_speaking:
                            session.silence_counter += 1
                            session.audio_buffer.append(chunk)
                            
                            # 静音超过阈值 → 用户说完了
                            if session.silence_counter > int(config.silence_timeout * config.sample_rate / chunk_size):
                                session.is_speaking = False
                                await handle_speech_end(session)
            
            elif "text" in msg:
                data = json.loads(msg["text"])
                if data.get("action") == "stop":
                    break
                    
    except WebSocketDisconnect:
        pass

async def handle_speech_end(session: Session):
    """用户说完后处理"""
    # ASR 识别
    full_audio = np.concatenate(session.audio_buffer)
    segments, info = asr_model.transcribe(full_audio, beam_size=5, language="zh")
    user_text = "".join([s.text for s in segments]).strip()
    
    if not user_text:
        return
    
    await session.ws.send_json({"type": "asr_result", "text": user_text})
    
    # 构建对话
    session.history.append({"role": "user", "content": user_text})
    messages = [{"role": "system", "content": SYSTEM_PROMPT}] + session.history[-10:]
    
    # 流式生成 + 逐句合成
    full_response = ""
    sentence_buffer = ""
    
    await session.ws.send_json({"type": "status", "text": "thinking"})
    
    async for token in stream_llm(messages):
        full_response += token
        sentence_buffer += token
        await session.ws.send_json({"type": "llm_token", "text": token})
        
        # 按标点分句合成
        if any(p in token for p in "。!?.!?\n"):
            sentence = sentence_buffer.strip()
            if sentence:
                await session.ws.send_json({"type": "status", "text": "speaking"})
                audio = await synthesize_speech(sentence, config.tts_voice)
                await session.ws.send_bytes(audio)
            sentence_buffer = ""
    
    # 处理剩余文本
    if sentence_buffer.strip():
        audio = await synthesize_speech(sentence_buffer.strip(), config.tts_voice)
        await session.ws.send_bytes(audio)
    
    session.history.append({"role": "assistant", "content": full_response})

# 静态文件服务
app.mount("/static", StaticFiles(directory="web"), name="static")

@app.get("/")
async def index():
    return FileResponse("web/index.html")

10.3 前端页面

<!-- web/index.html -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>实时语音 AI 助手</title>
    <style>
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body {
            font-family: -apple-system, BlinkMacSystemFont, sans-serif;
            background: #0f0f23;
            color: #e0e0e0;
            display: flex;
            justify-content: center;
            align-items: center;
            min-height: 100vh;
        }
        .container {
            width: 480px;
            text-align: center;
        }
        h1 { font-size: 1.5rem; margin-bottom: 2rem; color: #7eb8da; }
        .mic-btn {
            width: 120px; height: 120px;
            border-radius: 50%;
            border: 3px solid #4a9eff;
            background: #1a1a3e;
            cursor: pointer;
            font-size: 3rem;
            transition: all 0.3s;
            display: flex;
            align-items: center;
            justify-content: center;
            margin: 0 auto 2rem;
        }
        .mic-btn.recording {
            border-color: #ff4a4a;
            background: #3e1a1a;
            animation: pulse 1.5s infinite;
        }
        @keyframes pulse {
            0%, 100% { box-shadow: 0 0 0 0 rgba(255,74,74,0.4); }
            50% { box-shadow: 0 0 0 20px rgba(255,74,74,0); }
        }
        .chat-box {
            background: #1a1a2e;
            border-radius: 12px;
            padding: 1.5rem;
            max-height: 400px;
            overflow-y: auto;
            text-align: left;
        }
        .msg { margin-bottom: 1rem; line-height: 1.6; }
        .msg.user { color: #7eb8da; }
        .msg.ai { color: #a8e6a1; }
        .msg .label { font-size: 0.8rem; opacity: 0.7; }
        .status { color: #aaa; font-size: 0.9rem; margin-top: 1rem; }
    </style>
</head>
<body>
    <div class="container">
        <h1>🎤 实时语音 AI 助手</h1>
        <button class="mic-btn" id="micBtn">🎙️</button>
        <div class="status" id="status">点击麦克风开始对话</div>
        <div class="chat-box" id="chatBox"></div>
    </div>

    <script>
        let ws, audioContext, processor, isRecording = false;
        const micBtn = document.getElementById('micBtn');
        const chatBox = document.getElementById('chatBox');
        const statusEl = document.getElementById('status');

        micBtn.addEventListener('click', toggleRecording);

        async function toggleRecording() {
            if (isRecording) {
                stopRecording();
            } else {
                await startRecording();
            }
        }

        async function startRecording() {
            ws = new WebSocket(`ws://${location.host}/ws`);
            ws.binaryType = 'arraybuffer';
            
            ws.onmessage = (e) => {
                if (typeof e.data === 'string') {
                    const msg = JSON.parse(e.data);
                    if (msg.type === 'asr_result') {
                        addMessage('user', '👤 你: ' + msg.text);
                    } else if (msg.type === 'llm_token') {
                        appendAIMessage(msg.text);
                    } else if (msg.type === 'status') {
                        const labels = { listening: '🎤 正在听...', thinking: '🤔 思考中...', speaking: '🔊 回复中...' };
                        statusEl.textContent = labels[msg.text] || '';
                    }
                } else {
                    playAudio(e.data);
                }
            };

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

            audioContext = new AudioContext({ sampleRate: 16000 });
            const source = audioContext.createMediaStreamSource(stream);
            processor = audioContext.createScriptProcessor(4096, 1, 1);
            
            processor.onaudioprocess = (e) => {
                if (!isRecording) return;
                const data = e.inputBuffer.getChannelData(0);
                const pcm = new Int16Array(data.length);
                for (let i = 0; i < data.length; i++) {
                    pcm[i] = Math.max(-1, Math.min(1, data[i])) * 32767;
                }
                ws.send(pcm.buffer);
            };

            source.connect(processor);
            processor.connect(audioContext.destination);
            isRecording = true;
            micBtn.classList.add('recording');
            statusEl.textContent = '🎤 正在听...';
        }

        function stopRecording() {
            isRecording = false;
            micBtn.classList.remove('recording');
            if (processor) processor.disconnect();
            if (audioContext) audioContext.close();
            if (ws) ws.close();
            statusEl.textContent = '点击麦克风开始对话';
        }

        let currentAIMsg = null;
        function appendAIMessage(text) {
            if (!currentAIMsg) {
                currentAIMsg = document.createElement('div');
                currentAIMsg.className = 'msg ai';
                currentAIMsg.innerHTML = '<div class="label">🤖 AI:</div><span></span>';
                chatBox.appendChild(currentAIMsg);
            }
            currentAIMsg.querySelector('span').textContent += text;
            chatBox.scrollTop = chatBox.scrollHeight;
        }

        function addMessage(role, text) {
            currentAIMsg = null;  // 新一轮对话
            const div = document.createElement('div');
            div.className = `msg ${role}`;
            div.textContent = text;
            chatBox.appendChild(div);
            chatBox.scrollTop = chatBox.scrollHeight;
        }

        async function playAudio(buffer) {
            const ctx = new AudioContext();
            const audioBuffer = await ctx.decodeAudioData(buffer);
            const source = ctx.createBufferSource();
            source.buffer = audioBuffer;
            source.connect(ctx.destination);
            source.start();
        }
    </script>
</body>
</html>

10.4 启动与运行

# 安装依赖
pip install faster-whisper edge-tts fastapi uvicorn httpx torch torchaudio

# 启动 LLM 服务(以 vLLM 为例)
python -m vllm.entrypoints.openai.api_server \
    --model Qwen/Qwen2.5-7B-Instruct \
    --port 8000

# 启动语音助手服务
cd voice-ai-assistant
uvicorn server.main:app --host 0.0.0.0 --port 8080

# 浏览器访问 http://localhost:8080

10.5 性能基准

在不同硬件上的延迟表现(端到端,从用户说完到听到回复):

硬件配置 ASR 延迟 LLM 首 token TTS 首句 总延迟
CPU (i7-12700) 800ms 300ms 400ms ~1.8s
GPU (RTX 3060) 200ms 100ms 400ms ~1.0s
GPU (RTX 4090) 100ms 50ms 400ms ~0.7s

优化建议:

  • ASR 使用 GPU 加速 + INT8 量化可大幅降低延迟
  • LLM 使用 vLLM 或 TensorRT-LLM 推理加速
  • TTS 使用 Edge TTS 几乎无额外开销(网络延迟约 200-400ms)

十一、总结与进阶方向

本教程从零开始构建了一个完整的实时语音 AI 对话系统,涵盖了从语音识别到语音合成的全链路技术。以下是进阶方向:

  1. WebRTC 替代 WebSocket:获得更好的 NAT 穿透和音频回声消除
  2. 多轮打断:支持用户在 AI 说话时打断
  3. 情感识别:从语音中识别用户情绪,调整回复策略
  4. 声纹识别:区分不同说话人,支持多人对话
  5. 本地化部署:全套模型本地运行,无需外网

语音 AI 是大模型落地的最重要场景之一。掌握这套技术栈,你将能够构建媲美商业产品的语音对话体验。


本教程内容原创,仅供学习交流使用。

内容声明

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

目录