|
| 1 | +#+OPTIONS: ^:nil |
| 2 | +#+BEGIN_EXPORT html |
| 3 | +--- |
| 4 | +layout: default |
| 5 | +title: AI聊天盒子 - 集成语音转文字服务 |
| 6 | +tags: [LLM, Rust, ESP32, ESP32S3] |
| 7 | +nav_order: {{ page.date }} |
| 8 | +sync_wexin: 1 |
| 9 | +--- |
| 10 | +#+END_EXPORT |
| 11 | + |
| 12 | +* AI聊天盒子 - 集成语音转文字服务 |
| 13 | + |
| 14 | +** 前言 |
| 15 | + |
| 16 | +好久没有更新文章了,最近在做一个智能键盘的项目,以后有机会给大家介绍。前面我们已经实现了和AI聊天的功能,然而我们还需要将用户的语音转成文字发送给AI,本文介绍如何集成语音转文字服务。 |
| 17 | + |
| 18 | +本系列其他文章 |
| 19 | +1. [[https://paul356.github.io/2025/03/15/wake-word-detect.html][AI聊天盒子 - 概念设计]] |
| 20 | +2. [[https://paul356.github.io/2025/04/09/ai-box-esp-sr.html][AI聊天盒子 - 使用esp-sr和Rust语言编写语音识别程序]] |
| 21 | +3. [[https://paul356.github.io/2025/04/25/ai-box-ask-llm.html][AI聊天盒子 - 请AI帮我编写和AI聊天的Rust代码]] |
| 22 | + |
| 23 | +** 语音转文字服务 |
| 24 | + |
| 25 | +语音转文字服务有很多成熟的项目可以使用,我选择了Vosk语音识别工具包。要使用Vosk,可以通过pip安装Vosk模块,另外还需要从链接[[https://alphacephei.com/vosk/models/vosk-model-cn-0.22.zip][vosk-model-cn-0.22.zip]]下载模型文件,解压后待用。 |
| 26 | + |
| 27 | +#+begin_src txt |
| 28 | + pip install vosk flask flask-cors |
| 29 | +#+end_src |
| 30 | + |
| 31 | +结合Flask模块我们就可以快速实现一个基于Vosk的REST服务,创建一个名为vosk_server.py的文件,填入下列代码。 |
| 32 | + |
| 33 | +#+begin_src python |
| 34 | +import tempfile |
| 35 | + |
| 36 | +app = Flask(__name__) |
| 37 | +CORS(app) # 允许跨域请求(局域网内其他设备访问) |
| 38 | + |
| 39 | +# 加载Vosk模型(替换为你的模型路径) |
| 40 | +model = Model("vosk-model-cn-0.22") # 中文模型 |
| 41 | + |
| 42 | +@app.route('/transcribe', methods=['POST']) |
| 43 | +def transcribe(): |
| 44 | + if 'file' not in request.files: |
| 45 | + return jsonify({"error": "No audio file provided"}), 400 |
| 46 | + |
| 47 | + audio_file = request.files['file'] |
| 48 | + _, temp_path = tempfile.mkstemp(suffix=".wav") |
| 49 | + audio_file.save(temp_path) |
| 50 | + |
| 51 | + # 使用Vosk进行语音识别 |
| 52 | + recognizer = KaldiRecognizer(model, 16000) |
| 53 | + with open(temp_path, 'rb') as f: |
| 54 | + audio_data = f.read() |
| 55 | + recognizer.AcceptWaveform(audio_data) |
| 56 | + |
| 57 | + os.remove(temp_path) # 删除临时文件 |
| 58 | + result = recognizer.FinalResult() |
| 59 | + return result |
| 60 | + #return jsonify({"text": result[0]}) |
| 61 | + |
| 62 | +if __name__ == '__main__': |
| 63 | + app.run(host='0.0.0.0', port=5000) # 允许局域网访问 |
| 64 | +#+end_src |
| 65 | + |
| 66 | +我们到目录vosk-model-cn-0.22的上一级目录下运行 ~python vosk_server.py~ ,我们就启动了一个简单的语音转文字服务。这个语音转文字服务,接受一个wav文件,然后将语音转换成文字返回给调用者。我们可以使用下面的命令来测试这个REST服务。 |
| 67 | + |
| 68 | +#+begin_src txt |
| 69 | + curl -X POST -F " [email protected]" http://127.0.0.1:5000/transcribe |
| 70 | +#+end_src |
| 71 | + |
| 72 | +** 集成语音转文字服务 |
| 73 | + |
| 74 | +在[[https://paul356.github.io/2025/04/09/ai-box-esp-sr.html][AI聊天盒子 - 使用esp-sr和Rust语言编写语音识别程序]]中我们已经实现了一个简单的AI对话流程。这里我们先回顾一下这个流程。 |
| 75 | + |
| 76 | +[[/images/ai-chatbox-fetch-states.png]] |
| 77 | + |
| 78 | +1. 用户需要先使用唤醒词“hi, 乐鑫”唤醒程序。 |
| 79 | +2. 用户再使用命令词“我有个问题”进入录音状态。 |
| 80 | +3. 记录用户的语音。 |
| 81 | +4. 程序检测到2秒连续的静音,返回等待唤醒检测的状态。 |
| 82 | + |
| 83 | +我将第4步进行了扩展,检测到2秒连续的静音后,我们就会开始语音转换流程。然后将转换结果发送给LLM,再获取并在日志中打印LLM的回答。扩展后的流程如下。 |
| 84 | + |
| 85 | +1. 用户需要先使用唤醒词“hi, 乐鑫”唤醒程序。 |
| 86 | +2. 用户再使用命令词“我有个问题”进入录音状态。 |
| 87 | +3. 记录用户的语音。 |
| 88 | +4. 程序检测到2秒连续的静音: |
| 89 | + 1) 使用语音转文字服务将语音转换为文字。 |
| 90 | + 2) 得到文字形态而问题后,使用LLM API向DeepSeek提问。 |
| 91 | + 3) 获得回答打印到日志中。 |
| 92 | + |
| 93 | +为了实现增加的功能我创建了一个transcription_worker线程负责将语音转换成文字和向LLM提问,这样可以减少对fetch_proc的影响,保证AFE数据队列不会堵塞。 |
| 94 | + |
| 95 | +#+begin_src Rust |
| 96 | +// Worker function for the transcription thread |
| 97 | +fn transcription_worker(rx: Receiver<TranscriptionMessage>) -> anyhow::Result<()> { |
| 98 | + log::info!("Transcription worker thread started"); |
| 99 | + |
| 100 | + // Get token from environment variable at compile time |
| 101 | + let token = env!("LLM_AUTH_TOKEN", "LLM authentication token must be set at compile time"); |
| 102 | + |
| 103 | + // Create and configure the LLM helper |
| 104 | + let mut llm = match llm_intf::LlmHelper::new(token, "deepseek-chat") { |
| 105 | + helper => { |
| 106 | + log::info!("LLM helper created successfully"); |
| 107 | + helper |
| 108 | + } |
| 109 | + }; |
| 110 | + |
| 111 | + // Configure with parameters suitable for embedded device |
| 112 | + llm.configure( |
| 113 | + Some(512), // Max tokens to generate in response |
| 114 | + Some(0.7), // Temperature - balanced between deterministic and creative |
| 115 | + Some(0.9) // Top-p - slightly more focused sampling |
| 116 | + ); |
| 117 | + |
| 118 | + // Send initial system message to set context |
| 119 | + llm.send_message( |
| 120 | + "接下来的请求来自一个语音转文字服务,请小心中间可能有一些字词被识别成同音的字词。".to_string(), |
| 121 | + ChatRole::System |
| 122 | + ); |
| 123 | + |
| 124 | + log::info!("LLM helper initialized with system prompt"); |
| 125 | + |
| 126 | + loop { |
| 127 | + match rx.recv() { |
| 128 | + Ok(TranscriptionMessage::TranscribeFile { path }) => { |
| 129 | + log::info!("Received request to transcribe file: {}", path); |
| 130 | + |
| 131 | + match transcribe_audio(&path) { |
| 132 | + Ok(transcription) => { |
| 133 | + log::info!("Transcription completed: {}", transcription); |
| 134 | + |
| 135 | + // Send the transcription to the LLM |
| 136 | + log::info!("Sending transcription to LLM..."); |
| 137 | + let response = llm.send_message(transcription, ChatRole::User); |
| 138 | + |
| 139 | + if response.starts_with("Error:") { |
| 140 | + log::error!("LLM API error: {}", response); |
| 141 | + } else { |
| 142 | + log::info!("LLM response: {}", response); |
| 143 | + |
| 144 | + // Here you would send the response to a text-to-speech system |
| 145 | + // For now, we just log it |
| 146 | + } |
| 147 | + }, |
| 148 | + Err(e) => { |
| 149 | + log::error!("Failed to transcribe audio: {}", e); |
| 150 | + } |
| 151 | + } |
| 152 | + }, |
| 153 | + Ok(TranscriptionMessage::Shutdown) => { |
| 154 | + log::info!("Transcription worker received shutdown signal"); |
| 155 | + break; |
| 156 | + }, |
| 157 | + Err(e) => { |
| 158 | + log::error!("Error receiving message in transcription worker: {}", e); |
| 159 | + break; |
| 160 | + } |
| 161 | + } |
| 162 | + } |
| 163 | + |
| 164 | + log::info!("Transcription worker thread terminated"); |
| 165 | + Ok(()) |
| 166 | +} |
| 167 | +#+end_src |
| 168 | + |
| 169 | +并在inner_fetch_proc的Recording状态中通过管道给transcription_worker发送消息,触发语音转换流程。 |
| 170 | + |
| 171 | +#+begin_src Rust |
| 172 | +// Finalize WAV file |
| 173 | +if let Some(writer) = wav_writer.take() { |
| 174 | + log::info!("Finalizing WAV file after {} silent frames", silence_frames); |
| 175 | + writer.finalize()?; |
| 176 | + |
| 177 | + // Flush the filesystem to ensure all data is written |
| 178 | + if let Err(e) = flush_filesystem("/vfat") { |
| 179 | + log::warn!("Failed to flush filesystem: {}", e); |
| 180 | + } else { |
| 181 | + log::info!("Filesystem flushed successfully"); |
| 182 | + |
| 183 | + // Send a message to the transcription thread to process the file |
| 184 | + let file_path = format!("/vfat/audio{}.wav", file_idx - 1); |
| 185 | + if let Err(e) = arg.transcription_tx.send(TranscriptionMessage::TranscribeFile { path: file_path }) { |
| 186 | + log::error!("Failed to send transcription message: {}", e); |
| 187 | + } else { |
| 188 | + log::info!("Sent audio file for asynchronous transcription"); |
| 189 | + } |
| 190 | + } |
| 191 | +} |
| 192 | +#+end_src |
| 193 | + |
| 194 | +具体的代码修改可以查看代码仓[[https://github.com/paul356/ai-chatbox][ai-chatbox]]。 |
| 195 | + |
| 196 | +** 效果测试 |
| 197 | + |
| 198 | +我还是使用XIAO ESP32S3 Sense作为测试硬件。为了避免手动刷写模型文件,我把srmodels存到了SD卡上,除了代码修改中包含的配置文件skconfig.defaults的修改和将esp_srmodel_init的参数改为“/vfat”之外,还要把srmodels中的四个子目录mn7_cn、nsnet2、vadnet1_medium、wn9_hilexin整体拷贝到SD卡的根目录下。 |
| 199 | + |
| 200 | +#+begin_src shell |
| 201 | +> ls ./target/xtensa-esp32s3-espidf/debug/build/esp-idf-sys-*/out/build/srmodels |
| 202 | +fst mn7_cn nsnet2 srmodels.bin vadnet1_medium wn9_hilexin |
| 203 | +#+end_src |
| 204 | + |
| 205 | +但这个修改其实不是必须的,主要是为了刷写固件更加方便。下面运行 ~env WIFI_SSID=<ssid-name> WIFI_PASS=<wifi-password> LLM_AUTH_TOKEN=<llm-auth-token> cargo espflash flash -p /dev/ttyACM0 --flash-size 8mb~ 命令刷写好固件,再用 ~cargo espflash monitor~ 查看运行日志。下面是我测试时的运行日志,我问了DeepSeek一个问题“大模型是什么东西”。不过可能由于Vosk模型比较小,发音需要比较清楚才能准确识别。 |
| 206 | + |
| 207 | +#+begin_src txt |
| 208 | +I (61892) ai_chatbox: State transition: WakeWordDetecting -> CmdDetecting (Waiting for wake word → Detecting command): Wake word detected |
| 209 | +I (64512) ai_chatbox: Command detected: 1 |
| 210 | +I (64512) ai_chatbox: State transition: CmdDetecting -> Recording (Detecting command → Recording audio): Command detected (ID: 1) |
| 211 | +I (64522) ai_chatbox: Creating WAV file: /vfat/audio1.wav |
| 212 | +I (70822) ai_chatbox: State transition: Recording -> WakeWordDetecting (Recording audio → Waiting for wake word): Detected 66 frames of silence |
| 213 | +I (70822) ai_chatbox: Finalizing WAV file after 66 silent frames |
| 214 | +I (70852) ai_chatbox: Filesystem at /vfat flushed successfully |
| 215 | +I (70852) ai_chatbox: Filesystem flushed successfully |
| 216 | +I (70852) ai_chatbox: Received request to transcribe file: /vfat/audio1.wav |
| 217 | +I (70862) ai_chatbox: Sent audio file for asynchronous transcription |
| 218 | +I (70862) ai_chatbox: Transcribing audio file: /vfat/audio1.wav |
| 219 | +I (71132) ai_chatbox: Read 205868 bytes from WAV file |
| 220 | +I (72612) ai_chatbox: Response status: 200 |
| 221 | +I (72612) ai_chatbox: Transcription completed: { |
| 222 | + "text" : "大 模型 是 什么 东西" |
| 223 | +} |
| 224 | +I (72612) ai_chatbox: Sending transcription to LLM... |
| 225 | +I (72622) ai_chatbox::llm_intf: Sending request to DeepSeek API... |
| 226 | +I (72622) ai_chatbox::llm_intf: Initiating HTTP request to https://api.deepseek.com/chat/completions |
| 227 | +I (72772) esp-x509-crt-bundle: Certificate validated |
| 228 | +I (73462) ai_chatbox::llm_intf: HTTP request sent successfully. |
| 229 | +I (73462) ai_chatbox::llm_intf: HTTP response status: 200 |
| 230 | +I (94142) ai_chatbox::llm_intf: Response received. Tokens used: 124 (prompt) + 390 (completion) = 514 (total) |
| 231 | +I (94142) ai_chatbox: LLM response: **大模型(Large Language Model, LLM)** 是一种基于海量数据训练的**人工智能模型**,能够理解和生成人类语言(甚至代码、多模态内容等)。它的核心特点是: |
| 232 | + |
| 233 | +--- |
| 234 | + |
| 235 | +### 1. **本质是什么?** |
| 236 | + - **参数规模超大**:通常拥有百亿、千亿甚至万亿级参数(比如GPT-3有1750亿参数),通过深度学习(如Transformer架构)从文本数据中学习语言规律。 |
| 237 | + - **通用性强**:不像传统AI专精单一任务(如翻译),大模型能处理问答、写作、编程、逻辑推理等多种任务。 |
| 238 | + |
| 239 | +--- |
| 240 | + |
| 241 | +### 2. **为什么叫“大”?** |
| 242 | + - **数据大**:训练数据可达TB级(全网文本、书籍、代码等)。 |
| 243 | + - **算力大**:需要超算级GPU集群训练,成本极高(例如GPT-3训练费用超千万美元)。 |
| 244 | + - **能力“大”**:涌现出小模型不具备的复杂能力(如上下文学习、少样本推理)。 |
| 245 | + |
| 246 | +--- |
| 247 | + |
| 248 | +### 3. **常见例子** |
| 249 | + - **ChatGPT**(OpenAI)、**Gemini**(Google)、**Claude**(Anthropic)等对话式AI。 |
| 250 | + - **文心一言**(百度)、**通义千问**(阿里)等中文大模型。 |
| 251 | + |
| 252 | +--- |
| 253 | + |
| 254 | +### 4. **能干什么?** |
| 255 | + - 生成文章、代码、剧本 |
| 256 | + - 解答复杂问题(需验证) |
| 257 | + - 翻译/总结文档 |
| 258 | + - 作为智能助手(客服、教育等) |
| 259 | + |
| 260 | +--- |
| 261 | + |
| 262 | +### 5. **局限性** |
| 263 | + - 可能产生“幻觉”(编造错误信息) |
| 264 | + - 依赖训练数据,存在偏见风险 |
| 265 | + - 需大量算力,不够环保 |
| 266 | + |
| 267 | +如果需要更具体的解释(如技术原理、应用场景),可以告诉我! |
| 268 | +#+end_src |
| 269 | + |
| 270 | +** 总结 |
| 271 | + |
| 272 | +本文主要考虑如何为ai-chatbox集成语音转文字服务,这样我们就可以用语音向LLM提问。如果进一步将获得的回答转成语音播放出来,就可以完整地和LLM语音聊天了,这将是下一篇文章我们要考虑的事情。 |
| 273 | + |
| 274 | +** 链接 |
| 275 | + |
| 276 | +1. vosk-model-cn-0.22.zip - https://alphacephei.com/vosk/models/vosk-model-cn-0.22.zip |
| 277 | +2. ai-chatbox - https://github.com/paul356/ai-chatbox |
0 commit comments