FireRedASR Pro与Node.js集成:构建实时语音转文字WebSocket服务

张开发
2026/4/17 5:08:12 15 分钟阅读

分享文章

FireRedASR Pro与Node.js集成:构建实时语音转文字WebSocket服务
FireRedASR Pro与Node.js集成构建实时语音转文字WebSocket服务你有没有想过怎么让在线会议自动生成字幕或者让语音聊天室里的每句话都实时变成文字以前做这种实时语音识别要么延迟高得让人着急要么搭建起来特别复杂。现在事情变得简单多了。最近我在一个项目里需要给一个在线协作平台加上实时字幕功能。试了好几种方案要么是延迟太大别人话都说完了字幕才出来要么就是服务不稳定动不动就断掉。后来用FireRedASR Pro配合Node.js和WebSocket搭了一套服务效果出乎意料的好延迟基本控制在几百毫秒而且搭建过程也没想象中那么难。这篇文章我就来跟你分享一下怎么从零开始把FireRedASR Pro这个语音识别引擎通过Node.js和WebSocket变成一个能实时处理语音流并返回文字的服务。不管你是想给产品加个实时字幕还是做个语音输入的智能助手这套思路都能直接用上。1. 为什么选择这个技术组合在做实时语音转文字的时候我们通常会遇到几个头疼的问题。第一是延迟用户说完话如果字幕要等两三秒才出来那体验就太差了。第二是稳定性语音流不能断识别服务也不能挂。第三是开发复杂度最好别弄得太复杂不然后期维护都是坑。FireRedASR Pro、Node.js加上WebSocket这个组合刚好能比较优雅地解决这些问题。FireRedASR Pro本身支持流式识别这意味着你不用等用户说完一整段话再识别而是可以一边接收音频数据一边就出文字结果这是低延迟的基石。它的识别准确率在中文场景下表现不错尤其是针对一些常用词汇和口语化表达这对于会议、聊天这类场景很重要。Node.js呢它处理I/O密集型任务特别是这种网络流数据天生就有优势。事件驱动的非阻塞模型让它能轻松应对大量并发的WebSocket连接每个连接都在持续不断地收发音频片段和识别结果而不会把服务器卡死。WebSocket协议就更不用说了它是为双向实时通信而生的。相比传统的HTTP请求-响应模式WebSocket建立一次连接之后就可以随时双向收发数据完美契合“一边发送音频流一边接收文字流”的需求。你不用再自己折腾长轮询或者短轮询那些效率低下的方案了。简单来说这个组合就是让专业的识别引擎FireRedASR Pro干专业的活让擅长高并发的运行时Node.js来调度和通信再用最适合的协议WebSocket把它们和前端连接起来。接下来我们就看看具体怎么把它们拼装到一起。2. 搭建服务端Node.js与WebSocket核心服务端是整个系统的大脑它要负责建立WebSocket连接、接收音频流、调用识别API再把结果推回去。我们先来把这块搭起来。首先你需要一个安装了Node.js的环境版本建议在16以上。新建一个项目目录初始化并安装我们需要的核心依赖mkdir realtime-asr-server cd realtime-asr-server npm init -y npm install ws axios这里ws是一个简单好用的WebSocket库我们将用它来创建WebSocket服务器。axios则用来向FireRedASR Pro的API发送HTTP请求。2.1 创建WebSocket服务器我们先创建一个最简单的WebSocket服务器监听客户端的连接。新建一个server.js文件const WebSocket require(ws); const axios require(axios); // 配置信息 const ASR_API_URL https://your-fireredasr-api-endpoint.com/v1/recognize/stream; // 替换为实际的流式识别API地址 const ASR_API_KEY your-api-key-here; // 替换为你的API密钥 const WS_PORT 8080; // 创建WebSocket服务器 const wss new WebSocket.Server({ port: WS_PORT }); console.log(WebSocket 服务器已启动监听端口: ${WS_PORT}); // 存储每个连接对应的识别会话状态 const sessionMap new Map(); wss.on(connection, function connection(ws) { console.log(新的客户端连接); // 为这个连接初始化一个会话状态 const sessionId Date.now().toString(); sessionMap.set(sessionId, { ws: ws, asrBuffer: [], // 用于暂存待发送的音频数据块 isSending: false // 防止向API并发发送过多请求 }); // 监听客户端发来的消息音频数据 ws.on(message, async function incoming(message) { const session sessionMap.get(sessionId); if (!session) return; // 假设前端发送的是ArrayBuffer格式的音频数据 if (message instanceof Buffer) { session.asrBuffer.push(message); // 触发处理函数将缓冲区的数据发送给识别API processAudioBuffer(sessionId); } else if (typeof message string) { // 处理控制指令例如开始、结束识别 handleControlMessage(sessionId, message); } }); // 连接关闭时清理资源 ws.on(close, () { console.log(客户端断开连接: ${sessionId}); // 发送识别结束信号给API sendEndOfStream(sessionId); sessionMap.delete(sessionId); }); // 发送欢迎消息或连接确认 ws.send(JSON.stringify({ type: connected, sessionId })); });这段代码建立了一个WebSocket服务器。每个新连接都会获得一个唯一的sessionId并且我们会用一个Map来管理所有活跃连接的状态。客户端发来的音频数据Buffer格式会被暂存到缓冲区内。2.2 实现音频流处理与识别调用核心难点在于如何将源源不断的音频流合理地分片发送给FireRedASR Pro的流式识别接口。我们不能来一个数据块就发一次请求那样效率太低也不能等太久否则延迟会变大。通常的策略是设置一个时间窗口或数据量阈值。接下来我们实现processAudioBuffer和调用API的函数async function processAudioBuffer(sessionId) { const session sessionMap.get(sessionId); if (!session || session.isSending || session.asrBuffer.length 0) { return; // 没有数据或正在发送则跳过 } session.isSending true; // 从缓冲区取出所有累积的数据这里简单处理实际可根据时间或大小分片 const audioChunks session.asrBuffer.splice(0, session.asrBuffer.length); // 将多个Buffer合并 const audioData Buffer.concat(audioChunks); try { // 调用FireRedASR Pro的流式识别接口 const response await axios.post(ASR_API_URL, audioData, { headers: { Authorization: Bearer ${ASR_API_KEY}, Content-Type: audio/pcm; rate16000, // 根据你的音频格式调整 X-Session-Id: sessionId // 传递会话ID帮助服务端关联上下文 }, // 注意流式识别接口可能期望特定的数据格式和参数请查阅官方文档 }); // 假设API返回JSON包含识别文本和是否结束等信息 const result response.data; if (result.text) { // 将识别结果通过WebSocket实时推送给对应的客户端 session.ws.send(JSON.stringify({ type: transcript, text: result.text, isFinal: result.is_final || false // 是否为最终结果 })); } } catch (error) { console.error(识别API调用失败 (Session: ${sessionId}):, error.message); // 可以选择将错误信息通知前端 session.ws.send(JSON.stringify({ type: error, message: 语音识别服务暂时不可用 })); } finally { session.isSending false; // 如果缓冲区又有新数据了继续处理 if (session.asrBuffer.length 0) { setImmediate(() processAudioBuffer(sessionId)); } } } function handleControlMessage(sessionId, message) { const session sessionMap.get(sessionId); try { const command JSON.parse(message); switch (command.type) { case start: console.log(会话 ${sessionId} 开始识别); // 可以在这里初始化API的流式会话 break; case stop: console.log(会话 ${sessionId} 停止识别); sendEndOfStream(sessionId); break; default: console.log(未知控制指令: ${command.type}); } } catch (e) { console.log(无效的控制消息: ${message}); } } async function sendEndOfStream(sessionId) { // 向识别API发送一个结束标记通知其当前流已结束 // 具体实现取决于FireRedASR Pro API的设计可能是一个特殊的空数据包或另一个API调用 console.log(通知API结束流: ${sessionId}); // 示例发送一个结束请求 // await axios.post(${ASR_API_URL}/end, {...}, {headers: {...}}); }这里的关键是processAudioBuffer函数。它负责将累积的音频数据打包调用识别API并将返回的文字结果通过WebSocket发回给发起请求的特定客户端。我们用了简单的锁isSending来防止并发调用API确保顺序处理。3. 构建前端采集音频并建立连接服务端准备好了现在需要有一个前端页面来采集用户的麦克风声音并通过WebSocket发送出去。我们将使用WebRTC的getUserMediaAPI来获取音频流并使用AudioContext进行必要的处理。创建一个简单的index.html和client.js。3.1 HTML页面结构!DOCTYPE html html langzh-CN head meta charsetUTF-8 title实时语音转文字测试/title style body { font-family: sans-serif; max-width: 800px; margin: 20px auto; padding: 20px; } button { padding: 10px 15px; margin: 5px; font-size: 16px; cursor: pointer; } #status { padding: 10px; margin: 10px 0; border-radius: 5px; } .connected { background-color: #d4edda; color: #155724; } .disconnected { background-color: #f8d7da; color: #721c24; } #transcript { border: 1px solid #ccc; padding: 15px; min-height: 200px; margin-top: 20px; white-space: pre-wrap; background-color: #f8f9fa; } /style /head body h1实时语音转文字演示/h1 div idstatus classdisconnected状态未连接/div button idconnectBtn连接服务/button button idstartBtn disabled开始录音/button button idstopBtn disabled停止录音/button div h3识别结果/h3 div idtranscript/div /div script srcclient.js/script /body /html页面很简单有几个控制按钮和一个显示识别结果的区域。3.2 JavaScript客户端逻辑这是前端的核心client.js文件class RealtimeASRClient { constructor() { this.ws null; this.mediaRecorder null; this.audioChunks []; this.isRecording false; this.audioContext null; this.scriptProcessor null; this.stream null; this.connectBtn document.getElementById(connectBtn); this.startBtn document.getElementById(startBtn); this.stopBtn document.getElementById(stopBtn); this.statusDiv document.getElementById(status); this.transcriptDiv document.getElementById(transcript); this.bindEvents(); } bindEvents() { this.connectBtn.addEventListener(click, () this.connect()); this.startBtn.addEventListener(click, () this.startRecording()); this.stopBtn.addEventListener(click, () this.stopRecording()); } connect() { if (this.ws this.ws.readyState WebSocket.OPEN) { alert(已经连接了); return; } // 连接到我们的Node.js WebSocket服务器 this.ws new WebSocket(ws://localhost:8080); // 地址根据你的服务器调整 this.ws.onopen () { this.updateStatus(connected, 已连接到服务器); this.startBtn.disabled false; this.connectBtn.disabled true; console.log(WebSocket连接已打开); }; this.ws.onmessage (event) { try { const data JSON.parse(event.data); switch (data.type) { case connected: console.log(服务器确认连接会话ID:, data.sessionId); break; case transcript: this.appendTranscript(data.text, data.isFinal); break; case error: console.error(服务器错误:, data.message); alert(识别服务出错: data.message); break; } } catch (e) { console.error(解析服务器消息失败:, e); } }; this.ws.onclose () { this.updateStatus(disconnected, 连接已断开); this.startBtn.disabled true; this.stopBtn.disabled true; this.connectBtn.disabled false; console.log(WebSocket连接关闭); this.stopRecording(); // 连接断开时也停止录音 }; this.ws.onerror (error) { console.error(WebSocket错误:, error); this.updateStatus(disconnected, 连接发生错误); }; } updateStatus(state, message) { this.statusDiv.textContent 状态${message}; this.statusDiv.className state connected ? status connected : status disconnected; } async startRecording() { if (this.isRecording) return; try { // 1. 获取麦克风权限和音频流 this.stream await navigator.mediaDevices.getUserMedia({ audio: true }); this.isRecording true; this.startBtn.disabled true; this.stopBtn.disabled false; this.transcriptDiv.textContent ; // 清空之前的结果 // 2. 创建音频上下文和处理节点用于处理原始音频数据 this.audioContext new (window.AudioContext || window.webkitAudioContext)(); const source this.audioContext.createMediaStreamSource(this.stream); // 采样率转换浏览器麦克风通常是44.1kHz或48kHz识别引擎可能需要16kHz // 这里使用简单的ScriptProcessorNode进行降采样和PCM格式转换简化示例 // 实际生产环境建议使用Worklet或成熟的音频处理库如libsamplerate.js this.scriptProcessor this.audioContext.createScriptProcessor(4096, 1, 1); source.connect(this.scriptProcessor); this.scriptProcessor.connect(this.audioContext.destination); // 3. 处理音频数据 this.scriptProcessor.onaudioprocess (audioProcessingEvent) { if (!this.isRecording || !this.ws || this.ws.readyState ! WebSocket.OPEN) return; const inputBuffer audioProcessingEvent.inputBuffer; const inputData inputBuffer.getChannelData(0); // 获取单声道数据 // 简化处理这里直接将Float32Array的音频数据转换为16位PCM格式 // 注意这是关键步骤需要根据FireRedASR Pro API要求的音频格式编码、采样率、位深、声道进行精确转换 const pcmData this.floatTo16BitPCM(inputData); // 将PCM数据通过WebSocket发送给服务器 this.ws.send(pcmData); }; // 通知服务器开始识别如果需要 this.ws.send(JSON.stringify({ type: start })); console.log(开始录音并发送音频数据...); } catch (err) { console.error(无法访问麦克风或初始化音频失败:, err); alert(无法启动录音请检查麦克风权限。); this.isRecording false; this.startBtn.disabled false; } } // 一个简单的Float32到Int16 PCM的转换函数仅供参考实际需要更严谨的处理 floatTo16BitPCM(float32Array) { const buffer new ArrayBuffer(float32Array.length * 2); // 16位 2字节 const view new DataView(buffer); let offset 0; for (let i 0; i float32Array.length; i, offset 2) { let s Math.max(-1, Math.min(1, float32Array[i])); // 钳制到[-1, 1] s s 0 ? s * 0x8000 : s * 0x7FFF; // 缩放到16位整数范围 view.setInt16(offset, s, true); // true 表示小端字节序 } return buffer; } stopRecording() { if (!this.isRecording) return; this.isRecording false; this.startBtn.disabled false; this.stopBtn.disabled true; // 断开音频处理节点 if (this.scriptProcessor) { this.scriptProcessor.disconnect(); this.scriptProcessor.onaudioprocess null; this.scriptProcessor null; } // 关闭音频上下文 if (this.audioContext this.audioContext.state ! closed) { this.audioContext.close(); this.audioContext null; } // 停止所有音频轨道 if (this.stream) { this.stream.getTracks().forEach(track track.stop()); this.stream null; } // 通知服务器识别结束 if (this.ws this.ws.readyState WebSocket.OPEN) { this.ws.send(JSON.stringify({ type: stop })); } console.log(录音已停止); } appendTranscript(text, isFinal) { // 简单的结果显示逻辑 this.transcriptDiv.textContent text; if (isFinal) { this.transcriptDiv.textContent \n; // 最终结果换行 } // 滚动到底部 this.transcriptDiv.scrollTop this.transcriptDiv.scrollHeight; } } // 页面加载后初始化客户端 window.addEventListener(DOMContentLoaded, () { new RealtimeASRClient(); });前端代码的核心是RealtimeASRClient类。它做了几件事建立WebSocket连接、通过WebRTC获取麦克风音频流、使用AudioContext和ScriptProcessorNode处理原始音频数据这里包含了关键的采样率转换和PCM编码最后将处理后的音频数据块通过WebSocket实时发送给我们的Node.js服务器。特别注意floatTo16BitPCM函数是一个高度简化的示例。在实际项目中音频格式转换采样率、位深、声道数、编码是保证识别准确性的关键你可能需要使用OfflineAudioContext进行重采样或者使用专门的音频处理库。4. 实际运行与效果调优把前后端代码都准备好之后就可以跑起来看看效果了。启动服务端在终端进入项目目录运行node server.js。你应该看到“WebSocket服务器已启动”的日志。打开前端页面你可以用任何静态文件服务器比如npx serve .来运行index.html或者直接用浏览器打开文件注意部分浏览器可能因安全限制不允许本地文件使用麦克风最好通过localhost访问。测试流程点击“连接服务”再点击“开始录音”然后对着麦克风说话。你说的内容应该会近乎实时地显示在页面的“识别结果”区域。跑通基本流程后你会发现一些需要优化和注意的地方音频格式与质量这是影响识别准确率的最大因素。确保前端发送的音频格式采样率、位深、编码与FireRedASR Pro API的要求完全一致。不一致会导致识别失败或准确率骤降。网络延迟与抖动WebSocket虽然实时但网络不稳定会导致数据包延迟或乱序。可以考虑在前端增加一个小的音频缓冲区平滑发送节奏并在服务端实现简单的重排序或超时重传逻辑对于实时性要求极高的场景可能需要更复杂的抗抖动算法。服务端性能我们的示例服务端是单线程的。当并发用户数增多时processAudioBuffer函数可能成为瓶颈。可以考虑使用Node.js集群Cluster模式或者将识别任务放入消息队列如Redis由多个工作进程来消费实现横向扩展。错误处理与重连网络中断、识别服务临时不可用等情况需要妥善处理。前端需要监听WebSocket的断开事件并实现自动重连机制。服务端也需要优雅地处理API调用失败避免一个会话的失败影响其他会话。会话管理我们用了简单的Map在内存中管理会话。如果服务器重启所有状态都会丢失。对于生产环境可能需要将会话状态如API调用上下文持久化到Redis等外部存储中。5. 总结走完这一趟你会发现用FireRedASR Pro、Node.js和WebSocket来搭建一个实时语音转文字服务核心思路其实很清晰前端采集并预处理音频流通过WebSocket管道源源不断地送到Node.js服务端服务端负责会话管理、数据缓冲和与识别引擎的对接再把识别出的文字通过原路返回给前端。整个过程里最需要花心思打磨的就是音频数据处理那一环格式对不对、质量高不高直接决定了最后出来的文字准不准。另外怎么让服务在面对很多人同时使用时还能保持稳定和快速也需要根据实际情况做一些架构上的调整。这套方案的好处是模块分明每个部分都可以独立优化。比如你觉得FireRedASR Pro的识别效果想进一步提升可以单独去研究它的参数调优觉得WebSocket通信不够稳可以引入心跳机制和重连逻辑。它给实时语音应用提供了一个挺扎实的起点。如果你正在做在线教育、视频会议或者任何需要实时语音交互的产品希望这个分享能给你带来一些直接的帮助。从一个小demo开始慢慢把它变得更强壮、更可靠这个过程本身就挺有意思的。获取更多AI镜像想探索更多AI镜像和应用场景访问 CSDN星图镜像广场提供丰富的预置镜像覆盖大模型推理、图像生成、视频生成、模型微调等多个领域支持一键部署。

更多文章