你有没有想象过在本地快速搭建一个完整的语音对话系统?只需一台普通电脑,无需联网,甚至低算力设备也能流畅运行。这篇博客将手把手带你实现一个端到端的语音交互系统,从语音输入到语音回复,全链路完整展示。

ASR-LLM-TTS Onnx 项目实现一个在本地运行的端到端语音对话系统,能够完成“语音 -> 文本 -> 对话回复 -> 语音”的全过程。

项目地址:https://github.com/muggle-stack/asr-llm-tts

流程

  1. 自动语音识别(ASR):通过麦克风录制用户的语音输入,并将音频转换为文本。
  2. 大语言模型(LLM):将识别出的文本发送给本地的大语言模型,生成对用户话语的回答(文本形式)。
  3. 文本转语音合成(TTS):将LLM产生的回答文本转换为语音,通过扬声器播放输出。

不想看内部实现的,想直接体验的直接跳到最后的使用章节。

设计目的

项目的设计目标是在本地以低算力完成以上流程,因此特别注重模型拆分优化和并行处理。与将ASR、对话和TTS集成到一个“大模型”中不同,本项目将这三部分解耦为独立模块,各模块使用针对性的轻量模型,实现整体更高效率。

  • 优化的小模型组合:ASR、LLM、TTS 各自采用不同的小模型或推理引擎,以在保证精度的前提下尽量降低资源占用 。ASR模块采用精度高且模型小的语音识别模型,TTS模块采用轻量高效的语音合成模型等。
  • 模块解耦与可替换:通过Pipeline将ASR、LLM、TTS串联,每个模块相互独立,遵循统一接口。这样可以灵活替换任意一部分的实现或模型,而无需改动其他模块
  • 异步流水线并行:采用多线程/多进程 + 队列将三个阶段串联,使各阶段能够流水线并行处理
  • 资源隔离与异构计算:不同模块可使用不同的硬件资源并行运行,充分利用硬件能力,提高整体吞吐。
  • 易于扩展:由于采用节点串联的架构,功能扩展只需在Pipeline中插入新节点即可

ASR

ASR(Automatic Speech Recognition)模块负责将输入的语音信号转换为文本。本项目中ASR模块采用了阿里 sensevoice-small 模型及其优化变体来实现高精度的语音转写。sensevoice是一个基于Transformer的大规模语音识别模型,支持多语言和长音频转录,其原理是将音频转为梅尔频谱输入一个Encoder-Decoder网络,输出对应的文本序列。
实现原理上,无论哪种后端,ASR模块本质都需要先将原始音频转换为模型输入的特征,然后通过模型推理得到文本结果:

  1. 将音频信号重采样/转换为所需采样率(模型要求16kHz单声道)并归一化音量;
  2. 计算音频的梅尔频谱或Log-Mel特征作为模型输入;
  3. 模型推理输出词序列(token),再解码为可读文本。
    在将音频送入ASR模型之前,进行必要的前处理,以确保语音数据格式和质量满足模型要求。前处理需要进行的操作:

音频采集:通过麦克风录制实时音频流。项目中使用Python的音频库(例如 sounddevice 或 pyaudio)打开麦克风输入,以一定的帧率持续读取音频数据块。

import pyaudio 

self.RATE       = RATE_VAD         # 采样率 16kHz
self.FRAME_SIZE = WIN_SAMPLES      # 每帧 512 个采样点
self.FORMAT     = pyaudio.paInt16  # 16位整型
self.CHANNELS   = 1                # 单声道

self.pa     = pyaudio.PyAudio()
self.stream = self.pa.open(format=self.FORMAT,
                           channels=self.CHANNELS,
                           rate=self.RATE,
                           input=True,
                           frames_per_buffer=self.FRAME_SIZE,
                           input_device_index=device_index)

在将音频送入模型前,需要确保音频格式符合模型要求:16kHz采样率、16-bit PCM。如果麦克风输入不是16kHz,需要重采样到16kHz(代码里我注释了,可以自己改 from scipy.signal import resample 这个库就能搞),并将音频数据整理为 NumPy 数组或直接写入临时 WAV 文件供模型读取。

这里面我添加了vad的功能,自动检测说话是否停止,进而停止录音,在 VAD 推理前,音频帧会被转换为 float32 并归一化到 [-1, 1] 区间:

wav = (np.frombuffer(pcm_bytes, dtype=np.int16)
         .astype(np.float32) / 32768.0)[np.newaxis, :]  # (1,512)

将原始 PCM 数据转换为神经网络模型可接受的格式。

self.sess  = ort.InferenceSession(silero_model_path, providers=['CPUExecutionProvider'])

使用 ONNX Runtime 加载 Silero VAD 模型,无需 PyTorch,torch比较大,不适合在端侧进行部署。
推理以及后处理:

# asr/asr.py
    def generate(self, audio_file):
        if isinstance(audio_file, np.ndarray):
            audio_path = audio_file
        if isinstance(audio_file, str):
            audio_path = [audio_file]
        asr_res = self._model(audio_path, language='zh', use_itn=True)
        asr_res = asr_res[0][0].tolist()
        text = rich_transcription_postprocess(asr_res[0])
        return text

# asr/models/postprocess_utils.py
def rich_transcription_postprocess(result):
    # 分词、去除特殊符号
    text = re.sub(r"[^\w\s]", "", result["text"])
    # 时间戳处理
    ...
    return text

ASR处理完以后,得到纯文本输出,接着将文本给llm进行输出。

LLM

llm模块比较简单,用到 ollama 的 python 库去获取本地ollama的API,这里我用的模型是本地的qwen3-0.6b可以自行修改:

    def get_chat_stream(self, text, messages):
        messages.append({"role": "user", "content": text})
        stream = chat(
            model=self._model_path,
            messages=messages,
            stream=self._stream
        )
        return stream

    # 处理函数调用的主逻辑
    def generate(self, text):
        self.messages = [
            {
                "role": "system",
                "content": (
                    "你是一个问答助手,正常回答用户问题。\n"
                )
            }
        ] 
        
        # 获取聊天流
        stream = self.get_chat_stream(text, self.messages)

TTS

TTS部分采用的是meloTTS。TTS的完整实现链路如下:

				┌──────────────────┐
				│   原始文本 text   │
				└────────┬─────────┘
				         │ split_utils.split_sentence
				         ▼
				┌──────────────────┐
				│    句子序列 s_i   │
				└────────┬─────────┘
				         │ chinese_mix.clean_text
				         │   ├─ 数字 / 标点标准化  utils.*
				         │   ├─ 中文 lazy_pinyin + ToneSandhi
				         │   └─ 英文 g2p_en
				         ▼
				┌──────────────────┐
				│  phone / tone    │
				│  lang_id 序列     │
				└────────┬─────────┘
				         │ melotts_onnx.get_text_for_tts_infer
				         │   ├─ intersperse(blank)  # CTC
				         │   └─ word2ph
				         ▼
				┌──────────────────┐
				│    numpy int32   │
				│   (phones,tones, │
				│    langs,word2ph)│
				└────────┬─────────┘
				         │ ONNX Encoder
				         │ (TTSModel._run_encoder)
				         ▼
				┌──────────────────┐
				│    latent z_p    │
				│    pronoun_lens  │
				└────────┬─────────┘
				         │ calc_word2pronoun
				         │ generate_slices(dec_len)
				         ▼
				┌──────────────────┐
				│    z_p 切片队列   │
				└────────┬─────────┘
				         │ ONNX Decoder
				         │ (TTSModel._run_decoder)
				         ▼
				┌──────────────────┐
				│   sub-audio PCM  │
				└────────┬─────────┘
				         │ merge_sub_audio
				         ▼
				┌──────────────────┐
				│   句级音频 wav_i  │
				└────────┬─────────┘
				         │ audio_numpy_concat (+50 ms 静音)
				         ▼
				┌──────────────────┐
				│   全句 WAV/PCM    │
				└──────────────────┘
  1. 文本前处理与分句
    • 文本标准化(去除杂乱标点,统一数字表达等)
    • 根据标点对长文本进行分句,提高推理效率
def split_sentence(text, min_len=10, language_str='ZH'):
    # 根据中文或英文正则表达式进行分句
    sentences = split_sentences_zh(text, min_len) if language_str == 'ZH' else split_sentences_latin(text, min_len)
    return sentences
  1. 文本到音素转换与声调处理(G2P)
    • 中英文混合文本分别进行音素转换
    • 处理中文拼音声调规则
def clean_text(text, language_str='ZH'):
    # 文本标准化和G2P
    # 对于中文调用 lazy_pinyin 和 ToneSandhi 完成音素转换
    # 对于英文调用 g2p_en 实现音素转换
    norm_text, phones, tones, word2ph = chinese_mix.clean_text(text, language_str)
    return norm_text, phones, tones, word2ph
  1. 编码器输入准备
    • 将音素序列处理成带有CTC空白符的序列
    • 生成编码器可接受的numpy数组输入
def get_text_for_tts_infer(text, language_str='ZH', symbol_to_id=None):
    phones, tones, langs, norm_text, word2ph = clean_text(...)
    phones = intersperse(phones, 0).astype(np.int32) # 插入CTC空白符
    tones  = intersperse(tones, 0).astype(np.int32)
    langs  = intersperse(langs, 0).astype(np.int32)
    return phones, tones, langs, norm_text, word2ph
  1. 编码器推理 (ONNX Encoder)
    • 将音素输入转化为 latent 特征表示
z_p, pronoun_lens, audio_len = sess_enc.run(None, input_feed)
  1. latent 特征切片
    • 将过长的 latent 序列按照解码器长度限制进行切分,并带有一定重叠,方便后续拼接
def generate_slices(word2pronoun, dec_len):
    return pn_slices, zp_slices
  1. 解码器推理(ONNX Decoder)
    • 将每个latent切片解码为对应的音频片段
audio_segment = sess_dec.run(None, {'z_p': zp_slice, 'g': g})[0]
  1. 音频片段拼接(Overlap-Add)
    • 将解码后的音频片段平滑拼接成一句完整的音频
def merge_sub_audio(sub_audio_list, pad_size, audio_len):
    # 重叠相加,拼接音频
    merged_audio = ...
    return merged_audio

以上是模型需要的前后处理的具体实现,代码内部均已实现。代码已经封装成一个完整的pipeline,如果要移植代码,只需要在主程序导入类TTSModel,使用ort_predict推理方法即可得到输出音频。

使用

克隆代码

git clone https://github.com/muggle-stack/asr-llm-tts.git
cd asr-llm-tts

安装虚拟环境、依赖

sudo apt install venv
python -m venv .venv
source ~/.venv/bin/activate
pip install -r requirements.txt

安装ollama

# Linux
curl -fsSL https://ollama.com/install.sh | sh

Windows以及macOS需要去ollama官网手动下载https://ollama.com/download/windows
下载模型

ollama pull qwen3:0.6b

使用

python asr_llm_tts.py
Logo

火山引擎开发者社区是火山引擎打造的AI技术生态平台,聚焦Agent与大模型开发,提供豆包系列模型(图像/视频/视觉)、智能分析与会话工具,并配套评测集、动手实验室及行业案例库。社区通过技术沙龙、挑战赛等活动促进开发者成长,新用户可领50万Tokens权益,助力构建智能应用。

更多推荐