FireRedASR-AED-L语音识别模型与微信小程序开发集成指南

最近在做一个在线会议纪要的小工具,团队里有人提了个需求:能不能让用户直接在小程序里录音,然后自动转成文字,还能把可能说错的地方标出来?这想法挺实用的,毕竟手动整理会议记录太费时间了。

我研究了一圈,发现FireRedASR-AED-L这个语音识别模型挺合适。它不仅能转文字,还自带一个AED(音频错误检测)模块,能告诉你哪段音频的识别结果可能不太靠谱。这不正好符合“实时纠错”的需求嘛。

但怎么把它塞进微信小程序里呢?小程序端要录音、上传,服务端要跑模型、返回结果,还得把识别进度推送给前端,最后把可能出错的地方高亮显示出来。这一套流程下来,涉及前后端联调、长连接通信,确实有点东西。

这篇文章,我就把自己折腾这套方案的过程和踩过的坑,从头到尾捋一遍。如果你也想在小程序里加一个带智能纠错提示的语音输入功能,希望这些经验能帮你省点时间。

1. 为什么选择这个技术方案?

做语音功能,首先得选个合适的模型。市面上开源模型不少,但很多要么体积太大,部署困难;要么只做识别,没有错误检测。

FireRedASR-AED-L吸引我的地方主要有三点:

识别精度够用:在几个常见的测试集上,它的字错误率(CER)表现属于中上水平。对于会议纪要这种场景,不需要像专业速记那样一字不差,意思准确、关键信息不丢就行。

自带错误检测:这是它的核心卖点。AED模块会给每一段音频帧打一个“可信度”分数。分数低的段落,很可能识别错了。前端拿到这个分数,就能把对应的文字标黄或者加下划线,提醒用户重点检查。

部署相对友好:模型提供了WebUI界面,这意味着我们可以快速搭建一个HTTP API服务。对于小程序这种前后端分离的架构来说,用HTTP接口通信是最自然的方式。

至于微信小程序,它本身提供了完善的音频录制和管理API,生态成熟,用户也不用额外安装App,访问门槛低。两者结合,能很快做出一个可用的原型。

2. 整体架构与工作流程

先来看看整个系统是怎么跑起来的。我画了个简单的示意图,方便理解:

用户在小程序按下录音键 --> 小程序调用`wx.startRecord` API录音
    |
    V
录音结束,生成临时音频文件 --> 小程序调用`wx.uploadFile`上传至服务端
    |
    V
服务端接收文件,放入任务队列 --> 调用FireRedASR-AED-L模型进行识别
    |
    V
模型识别中... --> 服务端通过WebSocket向小程序推送识别进度(如“识别中:30%”)
    |
    V
识别完成,模型返回文本和AED分数 --> 服务端处理结果,将低分段落标记出来
    |
    V
服务端将最终结果(文本+错误标记)通过WebSocket推送给小程序
    |
    V
小程序接收结果,在界面上渲染文本,并将低置信度段落高亮显示

简单说,就是“前端录音上传 -> 后端识别分析 -> 长连接推送结果 -> 前端渲染标注”。这里面有几个关键点需要特别注意:

音频格式:微信小程序录制的默认格式是.aac.mp3,而很多语音识别模型(包括FireRedASR)更偏好.wav格式。所以服务端拿到文件后,可能需要进行一次转码。

网络通信:文件上传用普通的HTTP POST就行。但识别过程可能需要几秒到十几秒,不能让用户干等着。所以需要用WebSocket来实时推送“识别中”、“识别完成”这样的状态。

结果格式:后端返回的不能只是一段纯文本。需要把文本和AED模块输出的时间戳、置信度分数绑定在一起,形成一个结构化的数据,前端才知道哪段文字需要高亮。

3. 服务端搭建:模型部署与API设计

服务端是核心,负责承载模型并提供接口。这里假设你已经有一台带GPU的服务器(CPU也能跑,就是慢点)。

3.1 模型环境部署

首先,把FireRedASR-AED-L的代码和模型权重拉到服务器上。通常项目会提供详细的安装说明,照着做就行,主要是安装Python依赖和PyTorch。

部署完成后,启动项目自带的WebUI。这个UI本身是个基于Gradio或Streamlit的网页应用。但我们不需要它的界面,我们需要的是它背后暴露出来的函数。查看源码,找到那个执行识别的主函数,比如叫 inference(audio_path)

我们的目标是把这个函数包装成一个HTTP API。这里我用FastAPI来写,因为它轻量、异步支持好,写起来快。

# app.py
from fastapi import FastAPI, File, UploadFile, WebSocket, WebSocketDisconnect
from fastapi.responses import JSONResponse
import asyncio
import uuid
import os
from pathlib import Path
from your_model_module import inference  # 导入模型识别函数

app = FastAPI()

# 存储任务状态和结果的字典
tasks = {}

@app.post("/api/recognize")
async def recognize_audio(file: UploadFile = File(...)):
    """接收音频文件,创建识别任务"""
    # 生成唯一任务ID
    task_id = str(uuid.uuid4())
    
    # 保存上传的音频文件
    save_dir = Path("./uploads")
    save_dir.mkdir(exist_ok=True)
    file_path = save_dir / f"{task_id}_{file.filename}"
    
    with open(file_path, "wb") as f:
        content = await file.read()
        f.write(content)
    
    # 初始化任务状态
    tasks[task_id] = {
        "status": "pending",
        "progress": 0,
        "result": None,
        "file_path": str(file_path)
    }
    
    # 这里不直接处理,而是返回任务ID,让客户端通过WebSocket查询进度
    return JSONResponse({"task_id": task_id, "message": "任务已创建,请连接WebSocket获取进度"})

@app.websocket("/ws/progress/{task_id}")
async def websocket_progress(websocket: WebSocket, task_id: str):
    """WebSocket连接,用于推送任务进度和结果"""
    await websocket.accept()
    
    if task_id not in tasks:
        await websocket.send_json({"error": "任务不存在"})
        await websocket.close()
        return
    
    try:
        # 模拟进度推送 (在实际中,这里应调用模型并实时获取进度)
        for i in range(10):
            await asyncio.sleep(0.5)  # 模拟处理时间
            progress = (i + 1) * 10
            tasks[task_id]["progress"] = progress
            await websocket.send_json({"task_id": task_id, "progress": progress, "status": "processing"})
        
        # 模拟识别完成,调用模型
        file_path = tasks[task_id]["file_path"]
        # 这里是关键:调用模型函数,获取识别文本和AED分数
        # raw_result 应是一个字典,例如:{'text': '识别出的文本', 'segments': [{'start':0, 'end':1, 'text':'...', 'confidence':0.95}, ...]}
        raw_result = inference(file_path)
        
        # 处理结果,标记低置信度部分(假设置信度<0.7为低)
        processed_text = ""
        for seg in raw_result['segments']:
            if seg['confidence'] < 0.7:
                # 用特殊标记包裹低置信度文本,前端据此高亮。这里用`[[...]]`示例。
                processed_text += f"[[{seg['text']}]]"
            else:
                processed_text += seg['text']
        
        final_result = {
            "text": processed_text,
            "raw_segments": raw_result['segments']  # 也可以把原始数据返回,供前端灵活处理
        }
        
        # 更新任务状态并推送最终结果
        tasks[task_id].update({
            "status": "completed",
            "progress": 100,
            "result": final_result
        })
        
        await websocket.send_json({
            "task_id": task_id,
            "progress": 100,
            "status": "completed",
            "result": final_result
        })
        
    except WebSocketDisconnect:
        print(f"客户端断开连接: {task_id}")
    except Exception as e:
        await websocket.send_json({"error": str(e)})
    finally:
        await websocket.close()

if __name__ == "__main__":
    import uvicorn
    uvicorn.run(app, host="0.0.0.0", port=8000)

这段代码做了两件事:一是提供了/api/recognize接口用来上传文件并创建任务;二是提供了一个WebSocket端点/ws/progress/{task_id},客户端连接后,可以实时收到这个任务的进度和最终识别结果。

注意:上面的inference函数和结果格式需要你根据实际模型代码进行调整。关键是拿到带有时间戳和置信度分数的分段文本。

3.2 关键点:错误检测结果的处理

模型返回的AED置信度分数是核心。我们需要定一个阈值(比如0.7),低于这个分数的段落,就认为是“可疑”的。

在上面的代码里,我把低置信度段落用双中括号[[...]]包裹了起来。这只是其中一种方式。你也可以返回更结构化的数据,比如:

{
  "text": "今天我们讨论一下下一季度的项目规划",
  "highlights": [
    {"start": 4, "end": 8, "confidence": 0.65, "reason": "背景噪音干扰"}
  ]
}

这样前端可以根据highlights数组里的信息,精确地对某段文字进行样式渲染。具体用哪种,取决于前端开发的方便程度。

4. 微信小程序端开发

小程序端主要负责三件事:录音、上传、接收并展示结果。

4.1 音频录制与上传

微信小程序的wx.getRecorderManager()提供了完整的录音功能。我们需要在页面的onLoad中初始化管理器,并监听录音事件。

// pages/meeting/meeting.js
Page({
  data: {
    recording: false,
    recordTime: 0,
    taskId: null,
    socketConnected: false,
    resultText: '',
    highlightText: '' // 用于渲染带高亮标记的文本
  },

  onLoad: function () {
    this.recorderManager = wx.getRecorderManager();
    this.initRecorderEvents();
  },

  initRecorderEvents: function () {
    const that = this;
    this.recorderManager.onStart(() => {
      console.log('录音开始');
      that.setData({ recording: true });
    });

    this.recorderManager.onStop((res) => {
      console.log('录音结束', res);
      that.setData({ recording: false });
      // 录音结束后自动上传
      that.uploadAudioFile(res.tempFilePath);
    });

    this.recorderManager.onError((err) => {
      console.error('录音失败', err);
      wx.showToast({ title: '录音失败', icon: 'none' });
    });
  },

  startRecording: function () {
    this.recorderManager.start({
      duration: 600000, // 最长10分钟
      sampleRate: 16000, // 采样率,与模型匹配
      numberOfChannels: 1, // 单声道
      encodeBitRate: 48000,
      format: 'aac' // 格式,后端可能需要转换
    });
    // 开始计时
    this.startRecordTime = Date.now();
    this.timer = setInterval(() => {
      this.setData({
        recordTime: Math.floor((Date.now() - this.startRecordTime) / 1000)
      });
    }, 1000);
  },

  stopRecording: function () {
    this.recorderManager.stop();
    clearInterval(this.timer);
  },

录音结束后,调用uploadAudioFile函数将临时文件上传到我们刚写好的服务端。

  uploadAudioFile: function (tempFilePath) {
    const that = this;
    wx.showLoading({ title: '上传中...' });
    
    wx.uploadFile({
      url: 'https://your-server.com/api/recognize', // 你的服务端地址
      filePath: tempFilePath,
      name: 'file',
      formData: { 'user': 'test' },
      success(res) {
        wx.hideLoading();
        const data = JSON.parse(res.data);
        if (data.task_id) {
          that.setData({ taskId: data.task_id });
          wx.showToast({ title: '上传成功,开始识别', icon: 'success' });
          // 上传成功后,连接WebSocket获取进度
          that.connectWebSocket(data.task_id);
        } else {
          wx.showToast({ title: '上传失败', icon: 'none' });
        }
      },
      fail(err) {
        wx.hideLoading();
        wx.showToast({ title: '上传失败', icon: 'none' });
        console.error(err);
      }
    });
  },

4.2 WebSocket连接与结果接收

上传成功拿到task_id后,就要连接WebSocket来获取实时进度和最终结果了。

  connectWebSocket: function (taskId) {
    const that = this;
    // 连接WebSocket
    const socketUrl = `wss://your-server.com/ws/progress/${taskId}`; // 注意是wss
    this.socket = wx.connectSocket({
      url: socketUrl,
      success: () => {
        console.log('WebSocket连接成功');
        that.setData({ socketConnected: true });
      },
    });

    // 监听WebSocket消息
    wx.onSocketMessage((res) => {
      const data = JSON.parse(res.data);
      console.log('收到消息:', data);
      
      if (data.progress) {
        // 更新进度条UI
        that.setData({ progress: data.progress });
      }
      
      if (data.status === 'completed' && data.result) {
        // 识别完成,处理结果
        const result = data.result;
        that.setData({ resultText: result.text });
        // 调用函数,将带标记的文本渲染成带高亮的富文本
        that.renderHighlightText(result.text);
        wx.showToast({ title: '识别完成', icon: 'success' });
        // 可以主动关闭Socket
        wx.closeSocket();
      }
      
      if (data.error) {
        wx.showToast({ title: `识别错误: ${data.error}`, icon: 'none' });
        wx.closeSocket();
      }
    });

    wx.onSocketError((err) => {
      console.error('WebSocket错误:', err);
      wx.showToast({ title: '连接异常', icon: 'none' });
    });

    wx.onSocketClose(() => {
      console.log('WebSocket连接关闭');
      that.setData({ socketConnected: false });
    });
  },

4.3 高亮文本的可视化渲染

服务端返回的文本里包含了[[...]]标记。我们需要在小程序里把它渲染成带样式的文字。小程序里可以用<rich-text>组件,但更灵活的方式是用多个<text>组件拼接。

这里提供一个简单的渲染思路:

  renderHighlightText: function (markedText) {
    // 假设标记是 [[可疑文本]]
    const regex = /\[\[(.*?)\]\]/g;
    const parts = [];
    let lastIndex = 0;
    let match;
    
    while ((match = regex.exec(markedText)) !== null) {
      // 匹配到普通文本
      if (match.index > lastIndex) {
        parts.push({
          type: 'normal',
          text: markedText.substring(lastIndex, match.index)
        });
      }
      // 匹配到高亮文本
      parts.push({
        type: 'highlight',
        text: match[1] // 取出括号内的内容
      });
      lastIndex = match.index + match[0].length;
    }
    // 处理剩余文本
    if (lastIndex < markedText.length) {
      parts.push({
        type: 'normal',
        text: markedText.substring(lastIndex)
      });
    }
    
    // 现在 parts 是一个数组,包含普通文本和高亮文本对象
    // 你可以在WXML中遍历这个数组,用<text>组件渲染,并对高亮类型应用不同的样式类
    this.setData({ textParts: parts });
  },

然后在WXML中:

<!-- pages/meeting/meeting.wxml -->
<view class="result-container">
  <text>识别结果:</text>
  <view>
    <block wx:for="{{textParts}}" wx:key="index">
      <text class="{{item.type === 'highlight' ? 'highlight-text' : 'normal-text'}}">
        {{item.text}}
      </text>
    </block>
  </view>
</view>

最后在WXSS中定义高亮样式:

/* pages/meeting/meeting.wxss */
.highlight-text {
  background-color: #fffbe6; /* 浅黄色背景 */
  border-bottom: 2px dashed #faad14; /* 橙色虚线边框 */
  padding: 2rpx 4rpx;
  border-radius: 4rpx;
}

这样,所有被[[ ]]包裹的文字都会以浅黄色背景和虚线边框显示,非常醒目,提醒用户这里可能需要手动核对修改。

5. 实际应用中的优化点

按照上面的流程跑通之后,基本功能就有了。但在实际用的时候,我发现还有一些地方可以优化,让体验更好。

音频预处理:如果会议室环境嘈杂,识别准确率会下降。可以在上传前,或者服务端处理时,加入一个简单的降噪环节。有很多轻量级的音频处理库可以用。

识别加速:对于长音频,全段识别耗时可能比较久。可以考虑在服务端引入流式识别,或者将长音频切成短片段并行处理,然后再拼接结果和置信度分数。

前端交互优化:当用户点击高亮文本时,可以播放对应时间戳的录音片段,让用户能“听音辨字”,修改起来更准确。这需要服务端在返回分段文本时,也返回准确的时间偏移量。

错误反馈闭环:可以加一个按钮,让用户对高亮部分进行“确认无误”或“修正”的操作。这些修正后的数据,可以匿名收集起来,作为未来优化模型的数据集。

6. 写在最后

把FireRedASR-AED-L集成到微信小程序里,实现带纠错提示的语音输入,整个过程就像搭积木。核心是理解模型能给你什么(带置信度的分段文本),以及小程序需要什么(实时进度、结构化结果)。

这套方案跑起来之后,确实能大大节省会议纪要的整理时间。虽然模型偶尔还是会错判,把一些正确的句子标黄,但作为一个“辅助提示”工具,它的价值已经很大了。至少它能帮你快速定位到那些可能需要反复听录音才能确认的部分。

开发过程中,最花时间的其实是前后端的联调和数据格式的约定。建议在动手写代码前,先把接口文档(哪怕只是简单的字段说明)定下来,两边对照着开发,效率会高很多。

如果你对实时性的要求更高,可以研究一下Web端直接部署轻量级模型,或者用小程序云开发搭配云函数来跑模型。不过那就是另一个话题了。目前这个基于HTTP API和WebSocket的方案,对于大多数需要“录音-转写-校对”场景的小程序来说,应该够用了。


获取更多AI镜像

想探索更多AI镜像和应用场景?访问 CSDN星图镜像广场,提供丰富的预置镜像,覆盖大模型推理、图像生成、视频生成、模型微调等多个领域,支持一键部署。

Logo

腾讯云面向开发者汇聚海量精品云计算使用和开发经验,营造开放的云计算技术生态圈。

更多推荐