js前端实现语言识别(asr)与录音

2023-05-16

js前端实现语言识别与录音

前言

实习的时候,领导要求验证一下在web前端实现录音和语音识别,查了一下发现网上有关语音识别也就是语音转文字几乎没有任何教程。

其实有一种方案,前端先录音然后把录音传到后端,后端在请求如百度语音转文字的api进行识别,但是这种就需要再写个后端。如果直接前端请求百度api会遇到跨域问题,何况apikey等写在前端总感觉不是很安全。再一个百度的识别准确率不是很高。。

由此就有了本篇的由来,基于web原生的api实现语音识别

环境

名称版本
nodev17.1.0
npm8.1.4
@vue/cli4.5.15
vue2
vant2

适配率

在这里插入图片描述

由图可知常用的浏览器基本都支持,但是实际经过测试,谷歌浏览器由于网络原因时灵时不灵,pc上edge识别表现最好,安卓设备几乎都不可用,苹果ios上用safari完美使用

录音与语音识别

HZRecorder.js封装

这里提供一个网上找来的封装好的js

HZRecorder.js

function HZRecorder (stream, config) {
  config = config || {}
  config.sampleBits = config.sampleBits || 16 // 采样数位 8, 16
  config.sampleRate = config.sampleRate || 16000 // 采样率16khz

  let context = new (window.webkitAudioContext || window.AudioContext)()
  let audioInput = context.createMediaStreamSource(stream)
  let createScript = context.createScriptProcessor || context.createJavaScriptNode
  let recorder = createScript.apply(context, [4096, 1, 1])

  let audioData = {
    size: 0 // 录音文件长度
    , buffer: [] // 录音缓存
    , inputSampleRate: context.sampleRate // 输入采样率
    , inputSampleBits: 16 // 输入采样数位 8, 16
    , outputSampleRate: config.sampleRate // 输出采样率
    , oututSampleBits: config.sampleBits // 输出采样数位 8, 16
    , input: function (data) {
      this.buffer.push(new Float32Array(data))
      this.size += data.length
    }
    , compress: function () { // 合并压缩
      // 合并
      let data = new Float32Array(this.size)
      let offset = 0
      for (let i = 0; i < this.buffer.length; i++) {
        data.set(this.buffer[i], offset)
        offset += this.buffer[i].length
      }
      // 压缩
      let compression = parseInt(this.inputSampleRate / this.outputSampleRate)
      let length = data.length / compression
      let result = new Float32Array(length)
      // eslint-disable-next-line one-var
      let index = 0, j = 0
      while (index < length) {
        result[index] = data[j]
        j += compression
        index++
      }
      return result
    }
    , encodeWAV: function () {
      let sampleRate = Math.min(this.inputSampleRate, this.outputSampleRate)
      let sampleBits = Math.min(this.inputSampleBits, this.oututSampleBits)
      let bytes = this.compress()
      let dataLength = bytes.length * (sampleBits / 8)
      let buffer = new ArrayBuffer(44 + dataLength)
      let data = new DataView(buffer)

      let channelCount = 1// 单声道
      let offset = 0

      let writeString = function (str) {
        for (let i = 0; i < str.length; i++) {
          data.setUint8(offset + i, str.charCodeAt(i))
        }
      }

      // 资源交换文件标识符
      writeString('RIFF')
      offset += 4
      // 下个地址开始到文件尾总字节数,即文件大小-8
      data.setUint32(offset, 36 + dataLength, true)
      offset += 4
      // WAV文件标志
      writeString('WAVE')
      offset += 4
      // 波形格式标志
      writeString('fmt ')
      offset += 4
      // 过滤字节,一般为 0x10 = 16
      data.setUint32(offset, 16, true)
      offset += 4
      // 格式类别 (PCM形式采样数据)
      data.setUint16(offset, 1, true)
      offset += 2
      // 通道数
      data.setUint16(offset, channelCount, true)
      offset += 2
      // 采样率,每秒样本数,表示每个通道的播放速度
      data.setUint32(offset, sampleRate, true)
      offset += 4
      // 波形数据传输率 (每秒平均字节数) 单声道×每秒数据位数×每样本数据位/8
      data.setUint32(offset, channelCount * sampleRate * (sampleBits / 8), true)
      offset += 4
      // 快数据调整数 采样一次占用字节数 单声道×每样本的数据位数/8
      data.setUint16(offset, channelCount * (sampleBits / 8), true)
      offset += 2
      // 每样本数据位数
      data.setUint16(offset, sampleBits, true)
      offset += 2
      // 数据标识符
      writeString('data')
      offset += 4
      // 采样数据总数,即数据总大小-44
      data.setUint32(offset, dataLength, true)
      offset += 4
      // 写入采样数据
      if (sampleBits === 8) {
        for (let i = 0; i < bytes.length; i++, offset++) {
          let s = Math.max(-1, Math.min(1, bytes[i]))
          let val = s < 0 ? s * 0x8000 : s * 0x7FFF
          val = parseInt(255 / (65535 / (val + 32768)))
          data.setInt8(offset, val, true)
        }
      } else {
        for (let i = 0; i < bytes.length; i++, offset += 2) {
          let s = Math.max(-1, Math.min(1, bytes[i]))
          data.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7FFF, true)
        }
      }

      return new Blob([data], {type: 'audio/wav'})
    }
  }
  // 开始录音
  this.start = function () {
    audioInput.connect(recorder)
    recorder.connect(context.destination)
  }

  // 停止
  this.stop = function () {
    recorder.disconnect()
  }

  // 获取音频文件
  this.getBlob = function () {
    this.stop()
    console.log(audioData.encodeWAV())
    return audioData.encodeWAV()
  }

  // 回放
  this.play = function (audio) {
    let blob = this.getBlob()
    // saveAs(blob, "F:/3.wav");
    // window.open(window.URL.createObjectURL(this.getBlob()))
    audio.src = window.URL.createObjectURL(this.getBlob())
  }

  // 上传
  this.upload = function () {
    return this.getBlob()
  }

  // 音频采集
  recorder.onaudioprocess = function (e) {
    audioData.input(e.inputBuffer.getChannelData(0))
    // record(e.inputBuffer.getChannelData(0));
  }

  return this
}

export {
  HZRecorder
}

VueJs

<template>
  <div id="page">
    <div class="content">
      <div>
        <div style="display: block;align-items: center;text-align: center;">
          <label>识别结果: {{ result }}</label>
        </div>
        <div style="display: block;align-items: center;text-align: center;margin: 20px 0 20px 0">
          <label>识别结果2: {{ result2 }}</label>
        </div>
        <audio ref="audiodiv" type="audio/wav" controls />
      </div>
      <div style="display: inline-flex;margin: 20px 0 20px 0">
        <van-button
          type="warning"
          @click="speakClick"
          square
        >识别点击说话
        </van-button>
        <van-button
          type="warning"
          @click="speakEndClick"
          square
        >识别结束说话
        </van-button>
      </div>
      <div>
        <van-button
          type="warning"
          @click="speakClick2"
          square
        >录音点击说话
        </van-button>
        <van-button
          type="warning"
          @click="speakEndClick2"
          square
        >录音关闭说话
        </van-button>
      </div>
    </div>
  </div>
</template>

<script>
import {HZRecorder} from '../js/HZRecorder'
import {Toast} from 'vant'

export default {
  name: 'home',
  data () {
    return {
      recorder: '',
      recognition: '',
      audioSrc: '',
      result: '',
      result2: ''
    }
  },
  created () {
    const vue = this

    if (navigator.mediaDevices.getUserMedia || navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia) {
      this.getUserMedia({ video: false, audio: true }) // 调用用户媒体设备,访问摄像头、录音
    } else {
      console.log('你的浏览器不支持访问用户媒体设备')
    }

  },
  methods: {
    speakClick () {
      const vue = this
      vue.result2 = ''
      vue.result = ''
      console.log('start识别')

      let SpeechRecognition = window.SpeechRecognition || window.mozSpeechRecognition || window.webkitSpeechRecognition || window.msSpeechRecognition || window.oSpeechRecognition
      if (SpeechRecognition) {
        vue.recognition = new SpeechRecognition()
        vue.recognition.continuous = true
        vue.recognition.interimResults = true
        vue.recognition.lang = 'cmn-Hans-CN' // 普通话 (中国大陆)
      }

      vue.recognition.start()
      vue.recognition.onstart = function () {
        console.log('识别开始...')
      }
      // eslint-disable-next-line one-var
      let final_transcript = '', interim_transcript = ''
      vue.recognition.onerror = function (event) {
        console.log('识别出错')
        console.log(event)
        if (event.error == 'no-speech') {
          console.log('no-speech')
        }
        if (event.error == 'audio-capture') {
          console.log('audio-capture')
        }
        if (event.error == 'not-allowed') {
          console.log('not-allowed')
        }
      }
      vue.recognition.onresult = function (event) {
        console.log('识别成功')
        if (typeof (event.results) == 'undefined') {
          console.log('识别结果undefined')
          vue.recognition.onend = null
          vue.recognition.stop()
        } else {
          console.log(event.results)
          for (let i = event.resultIndex; i < event.results.length; ++i) {
            if (event.results[i].isFinal) {
              final_transcript += event.results[i][0].transcript
            } else {
              interim_transcript += event.results[i][0].transcript
            }
          }
          final_transcript = capitalize(final_transcript)
          console.log('final_transcript: ' + final_transcript)
          console.log('interim_transcript: ' + interim_transcript)
          if (final_transcript != 'undefined') {
            vue.result = final_transcript
          }
          if (interim_transcript != 'undefined') {
            vue.result2 = interim_transcript
          }
        }
      }
      var two_line = /\n\n/g
      var one_line = /\n/g

      function linebreak (s) {
        return s.replace(two_line, '<p></p>').replace(one_line, '<br>')
      }

      let first_char = /\S/

      function capitalize (s) {
        return s.replace(first_char,
          function (m) {
            return m.toUpperCase()
          })
      }
    },
    speakEndClick () {
      const vue = this
      console.log('end识别')
      vue.recognition.stop() // 识别停止
      vue.recognition.onend = function () {
		console.log('识别结束')
      }
    },
    speakClick2 () {
      const vue = this
      console.log('start')
      vue.recorder.start() // 录音
    },
    speakEndClick2 () {
      const vue = this
      console.log('end')
      let audioData = new FormData()
      audioData.append('speechFile', vue.recorder.getBlob())
      vue.recorder.play(this.$refs.audiodiv)
    },
    getUserMedia (constrains) {
      let that = this
      if (navigator.mediaDevices.getUserMedia) {
        // 最新标准API
        navigator.mediaDevices.getUserMedia(constrains).then(stream => {
          that.success(stream)
          that.recorder = new HZRecorder(stream)
          console.log('录音初始化准备完成')
        }).catch(err => { that.error(err) })
      } else if (navigator.webkitGetUserMedia) {
        // webkit内核浏览器
        navigator.webkitGetUserMedia(constrains).then(stream => {
          that.success(stream)
          that.recorder = new HZRecorder(stream)
          console.log('录音初始化准备完成')
        }).catch(err => { that.error(err) })
      } else if (navigator.mozGetUserMedia) {
        // Firefox浏览器
        navigator.mozGetUserMedia(constrains).then(stream => {
          that.success(stream)
          that.recorder = new HZRecorder(stream)
          console.log('录音初始化准备完成')
        }).catch(err => { that.error(err) })
      } else if (navigator.getUserMedia) {
        // 旧版API
        navigator.getUserMedia(constrains).then(stream => {
          that.success(stream)
          that.recorder = new HZRecorder(stream)
          console.log('录音初始化准备完成')
        }).catch(err => { that.error(err) })
      }
    },
    // 成功的回调函数
    success (stream) {
      console.log('已点击允许,开启成功')
    },
    // 异常的回调函数
    error (error) {
      console.log('访问用户媒体设备失败:', error.name, error.message)
    }
  }
}

</script>

<style scoped>
#page{
  position: absolute;
  display: flex;
  width: 100%;
  height: 100%;
  align-items: center;
  text-align: center;
  vertical-align: middle;
}
.content{
  width: 30%;
  height: 30%;
  margin: 0 auto;
}
</style>

识别效果

Chrome
在这里插入图片描述

Safari

ios Safari

重要

在localhost下可以申请并开启麦克风权限,其它环境下需要配置https才可以申请并开启麦克风权限

参考文档

Web APIs | MDN

原文载于本人博客whitemoon.top

本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系:hwhale#tublm.com(使用前将#替换为@)

js前端实现语言识别(asr)与录音 的相关文章

  • java.lang.NoSuchMethodException异常

    在Struts2中 xff0c 有时候会出现java lang NoSuchMethodException异常 xff0c 有可能是三种情况导致的运行异常 xff1a 第一种 xff1a Action 类的方法被定义成 private 类型
  • java.lang.IllegalArgumentException异常解决

    在maven项目中测试代码的时候 xff0c 碰到java lang IllegalArgumentException 异常 xff1a 严重 Servlet service for servlet e3 manager in contex
  • 在idea中创建一个普通工程

    第一步 xff1a File gt new gt Project 第二步 xff1a 点击next 点击 finish 即可 xff01 xff01 xff01 运行结果
  • java:获取当月最后一天

    设置时间格式 SimpleDateFormat format 61 new SimpleDateFormat 34 yyyy MM dd 34 获得实体类 Calendar ca 61 Calendar getInstance 设置最后一天
  • idea自动生成UUID和解决办法

    正常情况下 xff0c 鼠标点击类名 xff0c Alt 43 Insert键就会出现生成UUID选项 xff0c 即 xff1a 有时候Alt 43 Insert没有UUID选项 xff0c 解决办法 第一种情况 xff1a Settin
  • 页面<div>位置调整

    调整页面 lt div gt 样式 给 lt div gt lt select gt 分别起名字 xff1a div2 xff0c s1 lt div gt 代码 xff1a lt div class 61 34 div2 34 style
  • 马士兵_JAVA自学之路(为那些目标模糊的码农们)

    转载自 xff1a https blog csdn net anlidengshiwei article details 42264301 JAVA自学之路 一 学会选择 为了就业 xff0c 不少同学参加各种各样的培训 决心做软件的 xf
  • 在深度学习中Softmax交叉熵损失函数的公式求导

    以下部分基本介绍转载于点击打开链接 在深度学习NN中的output层通常是一个分类输出 xff0c 对于多分类问题我们可以采用k 二元分类器来实现 xff0c 这里我们介绍softmax softmax回归中 xff0c 我们解决的是多分类
  • 1-基于ArUco码的标记与检测

    1 简介 姿态估计 xff08 Pose estimation xff09 在 计算机视觉领域扮演着十分重要的角色 xff1a 机器人导航 增强现实以及其它 这一过程的基础是找到现实世界和图像投影之间的对应点 这通常是很困难的一步 xff0
  • 4-基于ArUco相机姿态评估

    1 简介 基于ArUco评估相机姿态 xff0c 可以使用OPENCV的外部库 xff08 opencv contrib xff09 中的aruco模块 xff0c 可以参考安装目录 xff08 库目录 xff09 xff1a opencv
  • MySQL--40道基础概念选择题及答案

    一 单选题 xff08 题数 xff1a 40 xff0c 共 40 0 分 xff09 1 在计算机系统中能够实现对数据库资源进行统一管理和控制的是 xff08 A xff09 A DBMS B DBA C DBS D DBAS 2 数据
  • 抽象类方法——子类定义getDescription方法返回对一个人的简单描述

    Person与子类的关系图 每一个 人都有一些诸如名字这样的属性 xff0c 学生与雇员都有名字属性 xff0c 因此可以将getName方法放在位于继承关系较高层的通用超类 xff08 父类 xff09 中 xff0c 现在增加一个get
  • Exynos4412 Uboot 移植(一)—— Uboot 编译流程分析

    Uboot 所用版本 u boot 2013 01 u boot 2013 01 中有上千文件 xff0c 要想了解对于某款开发板 xff0c 使用哪些文件 哪些文件首先执行 可执行文件占用内存的情况 xff0c 最好的方法就是阅读它的Ma
  • Linux USB 驱动开发(五)—— USB驱动程序开发过程简单总结

    设备驱动程序是操作系统内核和机器硬件之间的接口 xff0c 由一组函数和一些私有数据组成 xff0c 是应用程序和硬件设备之间的桥梁 在应用程序看来 xff0c 硬件设备只是一个设备文件 xff0c 应用程序可以像操作普通文件一样对硬件设备
  • 路由器开发(一)—— 路由器硬件结构及软件体系

    一 路由器的硬件构成 路由器主要由以下几个部分组成 xff1a 输入 输出接口部分 包转发或交换结构部分 xff08 switching fabric xff09 路由计算或处理部分 如图所示 图1 路由器的基本组成 输入端口是物理链路和输
  • Linux 设备驱动开发思想 —— 驱动分层与驱动分离

    前面我们学习I2C USB SD驱动时 xff0c 有没有发现一个共性 xff0c 就是在驱动开发时 xff0c 每个驱动都分层三部分 xff0c 由上到下分别是 xff1a 1 XXX 设备驱动 2 XXX 核心层 3 XXX 主机控制器
  • C++ 学习基础篇(一)—— C++与C 的区别

    编程的学习学无止境 xff0c 只掌握一门语言是远远不够的 xff0c 现在我们开始C 43 43 的学习之路 xff0c 下面先看下C 43 43 与C 的区别 一 C 43 43 概述 1 发展历史 1980年 xff0c Bjarne
  • Linux 网络协议栈开发基础篇(七)—— 网桥br0

    一 桥接的概念 简单来说 xff0c 桥接就是把一台机器上的若干个网络接口 连接 起来 其结果是 xff0c 其中一个网口收到的报文会被复制给其他网口并发送出去 以使得网口之间的报文能够互相转发 交换机就是这样一个设备 xff0c 它有若干
  • 常用的18个免费论文文献网站,分享给大家

    1 掌桥科研 掌桥科研文献资源库涵盖中英文期刊 xff0c 会议 xff0c 报告等多种资源 xff0c 拥有1 2多亿文献资源 xff0c 值得一提的是 xff0c 它整合了目前国际上主流的英文文献数据库 xff0c 涵盖了诸如Sprin
  • 必备外文文献网站,有外文文献翻译功能

    国内好多同学面对外文文献论文都有一个共同的槽点 xff0c 那就是翻译的问题 xff0c 好不容易找到了自己想要的外文文献 xff0c 结果那长篇大论的专业术语看不懂 xff0c 还需另找软件翻译 xff0c 这确实太麻烦了 图片来自于网络

随机推荐

  • 国内常用的5个中文期刊论文网站,5个外文文献网站

    作为一名科研汪 xff0c 日常工作就是找资料 xff0c 查文献 xff0c 做实验 xff0c 现在我给大家分享10个中外文献论文网站 xff0c 助同僚们在日常中能节省一些时间 xff0c 能更快有效地找到自己需要的资料文献 5个中文
  • 能查阅国外文献的8个论文网站(最新整理)

    这几天又新发现了几个论文网站 xff0c 有用的话请拿走 xff01 1 CALIS公共目录检索系统 这里是 传送门 2 掌桥科研一站式服务平台 这里是 传送门 3 NSTL文献检索 这里是 传送门 4 CASHL目录系统 这里是 传送门
  • java里的自动装箱和自动拆箱

    所有的基本类型都有与之对应的类 xff0c 例如 xff1a int Integer byte Byte short Short long Long float Float double Double char Char boolean B
  • 热门文献|陈国生:实证化中医基础理论依据及应用

    题名 xff1a 实证化中医基础理论依据及应用 作者 xff1a 陈国生 摘要 xff1a 中医基础理论在日地月天体运行图中的反映以成不争的事实 xff0c 然而笼统地概念对经络名称的划分 对称的机制 手足经络的区别 还需要加以澄清 xff
  • 全球IEEE期刊大全(综合整理,附原文论文下载地址)

    本文整理了来自全球的IEEE期刊 xff0c 一共有67种 xff0c 共计305236篇论文 期刊类别 xff1a 1 Industrial Electronics IEEE Transactions on 2 IEEE transact
  • 论文怎么添加引用参考文献(附word添加引用标注教程)

    第一步 xff1a 登录 掌桥科研 xff0c 掌桥科研是专业检索下载论文的网站 xff0c 能找到各个学科专业的中外学术期刊和论文 xff08 1 3亿多篇 xff09 地址 xff1a zhangqiaokeyan com LSDN 2
  • 2020年经济学专业论文选题参考(20个选题+部分参考文献)

    2020年经济学专业论文选题参考 xff08 20个选题 43 部分参考文献 xff09 1 一带一路 沿线主要区域集团人口及社会经济分布特征 2 房价 金融发展对技术创新的影响 3 共享经济背景下资源有效利用研究 4 基于新农村建设的农业
  • 自动化技术、计算机技术核心期刊整理及介绍

    本文由掌桥科研整理 xff0c 平台提供中外文献检索获取 xff0c 拥有1 3亿 43 篇 xff0c 中外专利1 4亿 43 条 xff0c 月更新百万篇 xff0c 是科研人员与硕博研究生必备平台之一 内容参考网站 xff1a 掌桥科
  • intel cpu 分类 i7、i5、i3、T系列、P系列

    现在市场的CPU有T系列 P系列 E系列 还有i3 i5 i7 T系列 xff0c 是intel 双核 xff0c 主要应用于笔记本 包括奔腾双核和酷睿双核 xff0c 2以下的 xff0c 比如T2140 xff0c 是奔腾双核 2以上
  • 2021年计算机保研面试题

    准备计算机保研面试题 注意点 大家都是第一次 没有保研经验 xff0c 所以担心会被问专业课知识相关的东西 但是结合博主自己的经历 xff0c 本人双非保到某985 xff0c 过程中问的最多的是项目相关问题 xff0c 并不会设计太多专业
  • 阿里云源码编译内核并替换

    1 介绍 阿里云新机器 xff1a 系统Ubuntu 16 04内存16G4核CPU 源码编译Linux最新stable版本内核 xff0c 并替换现有内核使用新内核 2 编译 2 1 安装依赖 apt update apt apt get
  • 记录一次wordpress站点迁移过程

    迁移和备份还原的区别是针对不同的install而言的 xff0c 使用上的区别可能是访问的IP会变 几乎所有系统的备份还原都主要涉及下面两个方面 xff0c wordpress也不例外 xff1a 数据库 xff1a mysqldump x
  • ubuntu16.04桌面美化

    先晒一张桌面图 xff1a 电脑是笔记本 xff0c 尺寸13 3 1080P 主要修改如下 xff1a 桌面壁纸 主题 缩放Unity面板左上角 34 Ubuntu Desktop 34 下部类似MacOS中的启动栏 桌面壁纸 主题 缩放
  • Java Math类的函数计算方法汇总

    java lang Math类中包含基本的数字操作 xff0c 如指数 对数 平方根和三角函数 java math是一个包 xff0c 提供用于执行任意精度整数 BigInteger 算法和任意精度小数 BigDecimal 算法的类 ja
  • Ubuntu截图快捷键

    系统设置 键盘 截图查看截图键的设置 xff1a 总结下 xff1a 对整个屏幕截图 xff1a Prt Sc xff08 PrintScreen xff0c 打印按钮 xff09 当前当前窗口截图 xff1a Alt 43 Prt Sc自
  • Ubuntu翻译任何选中的文字

    1 问题 Google Chrome浏览器可以集成Google Translator插件 xff0c 实现浏览器页面文字的翻译 xff0c 但是除了浏览器 xff0c PDF LibreOffice等软件上面的文字也经常需要翻译 Ubunt
  • 关于字符集和编码你应该知道的

    1 Introduction 大部分程序员都会认为 xff1a plain text 61 ascii 61 character xff0c 如我们使用的A字符 xff0c 就是一个字节 8bits Unicode字符集占用2个字节 xff
  • 2019 年 吉林大学 软件学硕967 回忆题

    2019年吉林大学软件工程专业硕士967回忆 一简单题 1给了一个中缀表达式转化为后缀表达式 2给了一组数字 xff0c 用快速排序进行排序 xff0c 写出每一趟的过程 3给了一组11个元素的有序表 xff0c 进行二分查找33 xff0
  • 南京工业大学校园网(智慧南工)自动登录

    前言 南京工业大学校园网 智慧南工 Njtech Home宿舍网自动登录 多平台可用 目前实现windows xff0c macos xff0c openwrt xff0c ios平台自动登录 由于gitee所有项目私有 xff0c 公开需
  • js前端实现语言识别(asr)与录音

    js前端实现语言识别与录音 前言 实习的时候 xff0c 领导要求验证一下在web前端实现录音和语音识别 xff0c 查了一下发现网上有关语音识别也就是语音转文字几乎没有任何教程 其实有一种方案 xff0c 前端先录音然后把录音传到后端 x