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; rate=16000', // 根据你的音频格式调整 '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 lang="zh-CN"> <head> <meta charset="UTF-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 id="status" class="disconnected">状态:未连接</div> <button id="connectBtn">连接服务</button> <button id="startBtn" disabled>开始录音</button> <button id="stopBtn" disabled>停止录音</button> <div> <h3>识别结果:</h3> <div id="transcript"></div> </div> <script src="client.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星图镜像广场,提供丰富的预置镜像,覆盖大模型推理、图像生成、视频生成、模型微调等多个领域,支持一键部署。