基于 Vue3 + Vite5,使用科大讯飞 ASR 实现语音转文字

基于 Vue3 + Vite5,使用科大讯飞 ASR 实现语音转文字

语音听写流式接口,用于1分钟内的即时语音转文字技术,支持实时返回识别结果,达到一边上传音频一边获得识别文本的效果。

官方文档

环境准备

  • 开发环境:Node.js v20.10.0 + npm v10.2.3
  • 开发工具:WebStorm 2024.1.7
  • 源代码管理:Git
  • npm镜像:https://registry.npmmirror.com/

系统环境

一、快速开始

使用 npm 包管理工具快速创建一个项目

npm create vue@latest

项目名称为 xf-asr,其它功能暂时都不需要,可以直接回车。进入项目目录,执行 npm install 安装依赖,执行 npm run dev 启动项目。

执行 git init 初始化 git 仓库(个人习惯)方便管理,并执行 git add . ,在执行 git commit -m 'init'

二、下载 SDK

在官方文档中找到语音听写流式API demo js语言
点击下载,里面包含了我们需要用到的录音管理器,以及语音识别的代码。

解压刚才下载好的 iat-js-demo.zip 文件,将里面的 dist 文件夹复制到项目的 public 目录下,并改名为 asr-sdk

在终端执行 npm install crypto-js 安装crypto-js包,用于加密。

三、引入 SDK

修改 index.html 文件,其它位置不用修改

<!DOCTYPE html>
<html lang="">
<head>
    <meta charset="UTF-8">
    <link rel="icon" href="/favicon.ico">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Vite App</title>
    <script src="/asr-sdk/index.umd.js"></script> <!-- 引入 SDK -->
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

四、封装 useXfAsr 语音识别插件

在项目 src 目录下创建一个 hooks 目录,在该目录下创建一个 useXfAsr.js 文件,并添加以下代码:

import {computed, ref} from "vue";
import CryptoJS from "crypto-js";

// TODO 自己去讯飞官网获获取 apiKey、apiSecret、app_id 等信息

const apiKey = "xxx";
const apiSecret = "xxx";
const app_id = "xxx";

/**
 * 获取websocket url
 * 该接口需要后端提供,这里为了方便前端处理
 */
function getWebSocketUrl() {
    const url = "wss://iat-api.xfyun.cn/v2/iat";
    const host = "iat-api.xfyun.cn";
    const date = new Date().toUTCString();
    const signatureOrigin = `host: ${host}\ndate: ${date}\nGET /v2/iat HTTP/1.1`;
    const signatureSha = CryptoJS.HmacSHA256(signatureOrigin, apiSecret);
    const signature = CryptoJS.enc.Base64.stringify(signatureSha);
    const authorizationOrigin = `api_key="${apiKey}", algorithm="hmac-sha256", headers="host date request-line", signature="${signature}"`;
    const authorization = btoa(authorizationOrigin);
    return `${url}?authorization=${authorization}&date=${date}&host=${host}`;
}

/**
 * 将音频二进制数据转换为base64编码
 * @param buffer
 * @returns {string}
 */
function bufferToBase64(buffer) {
    let binary = "";
    const bytes = new Uint8Array(buffer);
    const len = bytes.byteLength;
    for (let i = 0; i < len; i ++) {
        binary += String.fromCharCode(bytes[i]);
    }
    return window.btoa(binary);
}

export function useXfAsr() {
    const resultText = ref(); // 识别结果
    let resultTextTemp = "";
    let iatWS = null; // websocket
    let countdownInterval = null; // 倒计时
    const nextTime = ref(60); // 录音时长(最大60秒)
    const recorder = new RecorderManager("/asr-sdk");
    const recordStatus = ref("CLOSED"); //  CONNECTING | OPEN | CLOSING | CLOSED
    const recordText = computed(() => {
        if (recordStatus.value === "CONNECTING") {
            return "建立连接中";
        } else if (recordStatus.value === "OPEN") {
            return `录音中(${nextTime.value})`;
        } else if (recordStatus.value === "CLOSING") {
            return "关闭连接中";
        } else if (recordStatus.value === "CLOSED") {
            return "开始录音";
        }
    });
    /**
     * 录音开始事件
     */
    recorder.onStart = () => {
        updateStatus("OPEN");
    };
    /**
     * 监听已录制完指定帧大小的文件事件。如果设置了 frameSize,则会回调此事件。
     * @param isLastFrame 当前帧是否正常录音结束前的最后一帧
     * @param frameBuffer 录音分片数据
     */
    recorder.onFrameRecorded = ({ isLastFrame, frameBuffer }) => {
        if (iatWS.readyState === iatWS.OPEN) {
            const data = {
                data: {
                    // 0 :第一帧音频、1 :中间的音频、2 :最后一帧音频,最后一帧必须要发送
                    status: isLastFrame ? 2 : 1,
                    format: "audio/L16;rate=16000",
                    encoding: "raw",
                    audio: bufferToBase64(frameBuffer),
                },
            };
            iatWS.send(JSON.stringify(data));
            if (isLastFrame) {
                updateStatus("CLOSING");
            }
        }
    };
    /**
     * 录音结束事件
     */
    recorder.onStop = () => {
        clearInterval(countdownInterval);
    };

    /**
     * 倒计时
     */
    function countdown() {
        nextTime.value = 60;
        countdownInterval = setInterval(() => {
            nextTime.value --;
            if (nextTime.value <= 0) {
                clearInterval(countdownInterval);
                recorder.stop();
            }
        }, 1000);
    }

    /**
     * 更新状态
     * @param status CONNECTING | OPEN | CLOSING | CLOSED
     */
    function updateStatus(status) {
        recordStatus.value = status;
        if (status === "OPEN") {
            countdown();
        } else if (status === "CONNECTING") {
            resultText.value = "";
            resultTextTemp = "";
        }
    }

    /**
     * 渲染识别结果
     * @param resultData
     */
    function renderResult(resultData) {
        let jsonData = JSON.parse(resultData);
        console.log("识别结果:", jsonData);
        if (jsonData.data && jsonData.data.result) {
            let data = jsonData.data.result;
            let str = "";
            let ws = data.ws;
            for (let i = 0; i < ws.length; i ++) {
                str = str + ws[i].cw[0].w;
            }
            // 开启 wpgs 会有此字段(前提:在控制台开通动态修正功能)
            // 取值为 "apd"时表示该片结果是追加到前面的最终结果;取值为"rpl" 时表示替换前面的部分结果,替换范围为rg字段
            if (data.pgs) {
                if (data.pgs === "apd") {
                    // 将resultTextTemp同步给resultText
                    resultText.value = resultTextTemp;
                }
                // 将结果存储在resultTextTemp中
                resultTextTemp = resultText.value + str;
            } else {
                resultText.value = resultText.value + str;
            }
        }
        if (jsonData.code === 0 && jsonData.data.status === 2) {
            iatWS.close();
        }
        if (jsonData.code !== 0) {
            iatWS.close();
            console.error(jsonData);
        }
    }

    /**
     * 开始录音
     */
    function startRecording() {
        if (recordStatus.value !== "CLOSED") return;
        const url = getWebSocketUrl();
        if ("WebSocket" in window) {
            iatWS = new WebSocket(url);
        } else if ("MozWebSocket" in window) {
            iatWS = new MozWebSocket(url);
        } else {
            console.error(new Error("浏览器不支持WebSocket"));
            return;
        }
        updateStatus("CONNECTING");
        iatWS.onopen = (e) => {
            recorder.start({ sampleRate: 16000, frameSize: 1280 });
            const params = {
                common: { app_id },
                business: { language: "zh_cn", domain: "iat", accent: "mandarin", vad_eos: 5000, dwa: "wpgs" },
                data: { status: 0, format: "audio/L16;rate=16000", encoding: "raw" },
            };
            iatWS.send(JSON.stringify(params));
        };
        iatWS.onmessage = (e) => {
            renderResult(e.data);
        };
        iatWS.onerror = (e) => {
            recorder.stop();
            updateStatus("CLOSED");
        };
        iatWS.onclose = (e) => {
            recorder.stop();
            updateStatus("CLOSED");
        };
    }

    /**
     * 停止录音
     */
    function stopRecording() {
        recorder.stop();
    }

    return {
        resultText,
        recordText,
        startRecording,
        stopRecording,
    };
}

五、插件使用

修改 src/App.vue 文件,添加如下代码:


<script setup>
  import {useXfAsr} from "@/hooks/useXfAsr.js";

  const { startRecording, stopRecording, recordText, resultText } = useXfAsr();
</script>

<template>
  <div class="app-container">
    <h1>识别结果:{{ resultText }}</h1>
    <button @click="startRecording">{{ recordText }}</button>
    <button @click="stopRecording">停止录音</button>
  </div>
</template>

运行效果

六、心得体会

在这里,作者用最简洁的代码调用讯飞语音识别,通过封装 useXfAsr 函数,可以很方便的项目的任何地方进行调用。

由于讯飞 SDK 的要求,这里的语音时长最大为 60 秒,所以,如果用户在录音过程中,超过 60 秒,则需要重新开始录音。

通过接口密钥基于 hmac-sha256 计算签名,向服务器端发送 Websocket 协议握手请求。握手成功后,客户端通过 Websocket 连接同时上传和接收数据。数据上传完毕,客户端需要上传一次数据结束标识。接收到服务器端的结果全部返回标识后断开 Websocket 连接。

具体的细节请参考官方文档及示例。

七、项目原码请前往 xf-asr

各位小伙伴,欢迎大家使用,有什么问题,欢迎留言,一起交流。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值