效果图
recorder-core插件可以在网页中进行录音。录音文件(blob)并可以自定义上传,可以下载录音文件到本地,本文录音过程中会显示可视化波形,插件兼容PC端、Android、和iOS,目前只测试pc端
一、开启游览器录音权限
http://localhost:5173/ 网址为例:
第一步:在浏览器地址栏输入:
edge游览器输入:edge://flags/#unsafely-treat-insecure-origin-as-secure
谷歌游览器输入: chrome://flags/#unsafely-treat-insecure-origin-as-secure
第二步:在 Insecure origins treated as secure 输入栏中输入需要获取麦克风权限的白名单网址。
第三步:将右侧 已禁用 状态改成 已启用。
第四步:点击浏览器右下角 重启 按钮重启浏览器。
二、前端代码
下载插件
npm install recorder-core
使用的版本号:
“recorder-core”: “^1.3.25011100”,
前端代码使用了antdv UI库,只在button 和switch 上使用了,可以自行删除前缀(a-)
<template>
<div style="padding: 10px">
<div style="display: flex; align-items: center">
<!-- 按钮 -->
<a-button @click="recOpen">打开录音,请求权限</a-button>
<a-button @click="recStart">开始录音</a-button>
<a-button @click="recStop">结束录音</a-button>
<a-button @click="recPlay">本地试听</a-button>
<div style="display: flex; align-items: center; margin: 0 10px">
<span>下载文件到本地</span>
<a-switch v-model:checked="downloadShow" checked-children="开" un-checked-children="关" />
</div>
<div style="display: flex; align-items: center; margin: 0 10px">
<span>录音完成上传识别</span>
<a-switch v-model:checked="uploadShow" checked-children="开" un-checked-children="关" />
</div>
</div>
<div style="margin-top: 10px">
<div> 波形绘制区域 </div>
<div style="padding-top: 5px">
<div style="border: 1px solid #ccc; display: inline-block; vertical-align: bottom">
<div style="height: 100px; width: 300px" ref="recwave"></div>
</div>
</div>
</div>
<div style="margin: 10px 0">
<div class="file">
<i class="ico-plus"></i>上传图片
<input
type="file"
id="avatar"
name="avatar"
accept="wav/*"
multiple
required
@change="changeFile"
/>
</div>
</div>
<div>
识别结果:
<span style="color: aqua">
{{ recordingResult }}
</span>
</div>
</div>
</template>
<script lang="ts" setup>
import { onMounted, ref } from 'vue';
//必须引入的核心
import Recorder from 'recorder-core';
//引入mp3格式支持文件;如果需要多个格式支持,把这些格式的编码引擎js文件放到后面统统引入进来即可
import 'recorder-core/src/engine/mp3';
import 'recorder-core/src/engine/mp3-engine';
//录制wav格式的用这一句就行
import 'recorder-core/src/engine/wav';
//可选的插件支持项,这个是波形可视化插件
import 'recorder-core/src/extensions/waveview';
import { upWav } from '/@/api/service';
let rec: any;
let recBlob: any;
let wave: any;
const recwave = ref(null);
let recordingResult = ref('');
let downloadShow = ref(false);
let uploadShow = ref(true);
onMounted(() => {
recOpen();
});
// 打开录音
async function recOpen() {
return new Promise<void>((resolve, reject) => {
rec = Recorder({
type: 'wav',
sampleRate: 16000,
bitRate: 16,
onProcess: (
buffers: any,
powerLevel: any,
bufferDuration: any,
bufferSampleRate: any,
newBufferIdx: any,
asyncEnd: any,
) => {
if (wave) {
wave.input(buffers[buffers.length - 1], powerLevel, bufferSampleRate);
}
},
});
rec.open(
() => {
console.log('录音已打开');
if (recwave.value) {
wave = Recorder.WaveView({ elem: recwave.value });
}
resolve(); // 录音打开成功,返回 Promise 的 resolve
},
(msg: any, isUserNotAllow: any) => {
console.log((isUserNotAllow ? 'UserNotAllow,' : '') + '无法录音:' + msg);
reject(new Error((isUserNotAllow ? 'UserNotAllow,' : '') + '无法录音:' + msg)); // 录音打开失败,返回 Promise 的 reject
},
);
});
}
// 开始录音
async function recStart() {
try {
await recOpen(); // 等待录音设备打开
rec?.start(); // 开始录音
console.log('录音已开始');
} catch (error) {
console.error('录音启动失败:', error);
}
}
// 结束录音并保存到本地
function recStop() {
if (!rec) {
console.error('未打开录音');
return;
}
rec.stop(
(blob: any, duration: any) => {
//blob就是我们要的录音文件对象,可以上传,或者本地播放
recBlob = blob;
//简单利用URL生成本地文件地址,此地址只能本地使用,比如赋值给audio.src进行播放,赋值给a.href然后a.click()进行下载(a需提供download="xxx.mp3"属性)
const localUrl = (window.URL || window.webkitURL).createObjectURL(blob);
console.log('录音成功', blob, localUrl, '时长:' + duration + 'ms');
if (downloadShow.value) {
download(blob);
}
if (uploadShow.value) {
upload(blob); //把blob文件上传到服务器
}
//关闭录音,释放录音资源,当然可以不释放,后面可以连续调用start
rec.close();
rec = null;
},
(err: any) => {
console.error('结束录音出错:' + err);
rec.close();
rec = null;
},
);
}
// 本地试听(保持原样)
function recPlay() {
const localUrl = URL.createObjectURL(recBlob);
const audio = document.createElement('audio');
audio.controls = true;
document.body.appendChild(audio);
audio.src = localUrl;
audio.play();
setTimeout(() => {
URL.revokeObjectURL(audio.src);
}, 5000);
}
/**下载录音文件到本地*/
function download(blob) {
// 创建下载链接
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.style.display = 'none';
a.href = url;
a.download = `recording_${new Date().toISOString().slice(0, 19).replace(/:/g, '-')}.wav`; // 使用时间戳生成唯一文件名
document.body.appendChild(a);
a.click();
// 清理资源
setTimeout(() => {
URL.revokeObjectURL(url);
document.body.removeChild(a);
}, 100);
}
/**上传录音*/
function upload(blob: any) {
//使用FormData用multipart/form-data表单上传文件
//或者将blob文件用FileReader转成base64纯文本编码,使用普通application/x-www-form-urlencoded表单上传
// const form = new FormData();
// form.append('upfile', blob, 'recorder.mp3'); // 和普通form表单并无二致,后端接收到upfile参数的文件,文件名为recorder.mp3
// form.append('key', 'value'); // 其他参数
// var xhr = new XMLHttpRequest();
// xhr.open('POST', '/upload/xxxx');
// xhr.onreadystatechange = () => {
// if (xhr.readyState == 4) {
// if (xhr.status == 200) {
// console.log('上传成功');
// } else {
// console.error('上传失败' + xhr.status);
// }
// }
// };
// xhr.send(form);
// 自己的上传函数
uploadService(blob);
}
/**手动选择文件上传*/
async function changeFile(e: any) {
// 确保事件对象有效
if (!e || !e.target || !e.target.files) {
console.error('Invalid event or no files selected');
return;
}
// 获取文件列表
const files = e.target.files;
console.log('e::::', e);
console.log('files::::', files);
// 检查文件列表是否为空
if (files.length === 0) {
console.error('No files selected');
return;
}
// 获取第一个文件
const file = files[0];
console.log('file::::', file);
// 调用 OCR 函数
try {
let res = await upWav({ file: file });
console.log('🚀 ~ changeFile ~ res:', res);
} catch (error) {
console.error('Error during file processing:', error);
}
}
/**录音完毕,自动上传识别*/
async function uploadService(blob) {
try {
let res: any = await upWav({ file: blob });
if (res.data.code == 1) {
recordingResult.value = res.data.data;
} else {
recordingResult.value = res.data.msg;
}
} catch (error) {
console.error('Error during file processing:', error);
}
}
</script>
三、Go代码,上传到讯飞识别录音返回到前端
使用讯飞的录音文件转写产品
获取前端上传的文件
package ocr
import (
"context"
"fmt"
"io"
"io/ioutil"
"net/http"
"ocrtext/internal/logic/xfyun"
"ocrtext/internal/svc"
"ocrtext/internal/types"
"os"
"path/filepath"
"github.com/zeromicro/go-zero/core/logx"
)
type UpWavBySpeechRecognitionLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
r *http.Request
}
func NewUpWavBySpeechRecognitionLogic(ctx context.Context, svcCtx *svc.ServiceContext, r *http.Request) *UpWavBySpeechRecognitionLogic {
return &UpWavBySpeechRecognitionLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
r: r,
}
}
func (l *UpWavBySpeechRecognitionLogic) UpWavBySpeechRecognition() (resp *types.OcrListResp, err error) {
// 获取上传的文件
file, handler, err := l.r.FormFile("file")
if err != nil {
fmt.Println("检索文件错误")
return
}
defer file.Close()
// 打印文件信息:handler.Filename
//fmt.Printf("Uploaded File: %+v\n", handler.Filename)
//fmt.Printf("File Size: %+v\n", handler.Size)
//fmt.Printf("MIME Header: %+v\n", handler.Header)
// 创建一个临时文件来保存上传的文件内容
tempFile, err := ioutil.TempFile(os.TempDir(), "upload-*"+filepath.Ext(handler.Filename))
if err != nil {
fmt.Println("创建临时文件出错")
return
}
defer tempFile.Close()
// 将上传的文件内容复制到临时文件中
_, err = io.Copy(tempFile, file)
if err != nil {
fmt.Println("将文件复制到临时文件错误")
return
}
// 新增语音识别调用
api := xfyun.NewRequestApi(tempFile.Name()) // 使用临时文件路径
result, err := api.GetResult()
if err != nil {
return &types.OcrListResp{
BaseDataInfo: types.BaseDataInfo{
Code: 500,
Msg: "语音识别失败: " + err.Error(),
},
}, nil
}
// 解析识别结果(根据实际API响应结构调整)
return &types.OcrListResp{BaseDataInfo: types.BaseDataInfo{
Code: 1,
},
Data: result.Result,
}, nil
}
上传到讯飞,识别录音返回到前端
讯飞后台地址:https://console.xfyun.cn/services/lfasr
package xfyun
import (
"crypto/hmac"
"crypto/md5"
"crypto/sha1"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"time"
)
const (
lfasrHost = "https://raasr.xfyun.cn/v2/api"
apiUpload = "/upload"
apiGetResult = "/getResult"
appid = "" // 自行填写
secretKey = "" // 自行填写
)
type RequestApi struct {
AppID string
SecretKey string
UploadFilePath string
ts string
signa string
}
type UploadResponse struct {
Code string `json:"code"`
DescInfo string `json:"descInfo"`
Content struct {
OrderID string `json:"orderId"`
TaskEstimateTime int `json:"taskEstimateTime"`
} `json:"content"`
}
type OrderInfo struct {
OrderId string `json:"orderId"`
failType int `json:"failType"`
Status int `json:"status"`
OriginalDuration int `json:"originalDuration"`
RealDuration int `json:"realDuration"`
}
type GetResultResponse struct {
Code string `json:"code"`
DescInfo string `json:"descInfo"`
Content struct {
OrderInfo OrderInfo `json:"orderInfo"`
// 根据实际API响应添加字段
OrderResult string `json:"orderResult"`
ResultText string `json:"resultText"`
TaskEstimateTime int `json:"taskEstimateTime"`
} `json:"content"`
}
type XFYunResp struct {
Result string `json:"result"`
}
// 基础结构体定义
type Cw struct {
W string `json:"w"`
}
type Ws struct {
Cw []Cw `json:"cw"`
}
type Rt struct {
Ws []Ws `json:"ws"`
}
type St struct {
Rt []Rt `json:"rt"`
}
type JSON1Best struct {
St St `json:"st"`
}
// Lattice项(需要二次解析)
type LatticeItem struct {
JSON1Best string `json:"json_1best"`
}
// Lattice2项(直接包含结构)
type Lattice2Item struct {
JSON1Best JSON1Best `json:"json_1best"`
}
type OrderResult struct {
Lattice []LatticeItem `json:"lattice"`
Lattice2 []Lattice2Item `json:"lattice2"`
}
func NewRequestApi(uploadFilePath string) *RequestApi {
ts := fmt.Sprintf("%d", time.Now().Unix())
return &RequestApi{
AppID: appid,
SecretKey: secretKey,
UploadFilePath: uploadFilePath,
ts: ts,
signa: "",
}
}
// getSigna 获取接口鉴权
func (r *RequestApi) getSigna() string {
data := r.AppID + r.ts
hasher := md5.New()
hasher.Write([]byte(data))
md5Str := hex.EncodeToString(hasher.Sum(nil))
mac := hmac.New(sha1.New, []byte(r.SecretKey))
mac.Write([]byte(md5Str))
signa := base64.StdEncoding.EncodeToString(mac.Sum(nil))
return signa
}
// Upload 上传文件函数
func (r *RequestApi) Upload() (*UploadResponse, error) {
r.signa = r.getSigna()
fileInfo, err := os.Stat(r.UploadFilePath)
if err != nil {
return nil, err
}
params := url.Values{}
params.Add("appId", r.AppID)
params.Add("signa", r.signa)
params.Add("ts", r.ts)
params.Add("fileSize", fmt.Sprintf("%d", fileInfo.Size()))
params.Add("fileName", fileInfo.Name())
params.Add("duration", "200")
file, err := os.Open(r.UploadFilePath)
if err != nil {
return nil, err
}
defer file.Close()
reqURL := lfasrHost + apiUpload + "?" + params.Encode()
resp, err := http.Post(reqURL, "application/octet-stream", file)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var uploadResp UploadResponse
if err := json.Unmarshal(body, &uploadResp); err != nil {
return nil, err
}
return &uploadResp, nil
}
// GetResult 获取结果函数
func (r *RequestApi) GetResult() (*XFYunResp, error) {
uploadResp, err := r.Upload()
if err != nil {
return nil, err
}
params := url.Values{}
params.Add("appId", r.AppID)
params.Add("signa", r.signa)
params.Add("ts", r.ts)
params.Add("orderId", uploadResp.Content.OrderID)
params.Add("resultType", "transfer,predict")
client := &http.Client{}
var resultResp GetResultResponse
/**get,post都可*/
// 创建请求体缓冲区
//var requestBody bytes.Buffer
//writer := multipart.NewWriter(&requestBody)
添加表单字段
//_ = writer.WriteField("appId", r.AppID)
//_ = writer.WriteField("signa", r.signa)
//_ = writer.WriteField("ts", r.ts)
//_ = writer.WriteField("orderId", uploadResp.Content.OrderID)
//_ = writer.WriteField("resultType", "transfer,predict")
//
关闭writer以生成结尾边界
//writer.Close()
for {
fmt.Println("重新请求")
reqURL := lfasrHost + apiGetResult + "?" + params.Encode()
resp, err := client.Get(reqURL)
// 创建HTTP请求
//reqURL := lfasrHost + apiGetResult
//req, err := http.NewRequest("POST", reqURL, &requestBody)
//if err != nil {
// // 处理错误
//}
设置Content-Type头(必须包含boundary参数)
//req.Header.Set("Content-Type", writer.FormDataContentType())
//
发送请求
//client := &http.Client{}
//resp, err := client.Do(req)
//if err != nil {
// return nil, err
//}
//defer resp.Body.Close()
if err != nil {
return nil, err
}
body, err := io.ReadAll(resp.Body)
err = resp.Body.Close()
if err != nil {
return nil, err
}
if err := json.Unmarshal(body, &resultResp); err != nil {
return nil, err
}
fmt.Printf("resultResp: %+v\n", resultResp)
if resultResp.Code == "000000" && (resultResp.Content.OrderInfo.Status == 4 || resultResp.Content.OrderInfo.Status == -1) {
break
}
if resultResp.Content.OrderInfo.Status == 4 { // 假设4表示完成
break
}
time.Sleep(1 * time.Second)
}
/**提取文字内容*/
ParseOrderResultResp, err := ParseOrderResult(resultResp.Content.OrderResult)
if err != nil {
return nil, err
}
return &XFYunResp{
Result: ParseOrderResultResp,
}, nil
}
// ParseOrderResult 解析入口函数
func ParseOrderResult(strData string) (string, error) {
var jsonData = []byte(strData)
var result OrderResult
if err := json.Unmarshal(jsonData, &result); err != nil {
return "", err
}
var words []string
// 统一处理两种数据结构
processJSON1Best := func(content *JSON1Best) {
for _, rt := range content.St.Rt {
for _, ws := range rt.Ws {
for _, cw := range ws.Cw {
if cw.W != "" {
words = append(words, cw.W)
}
}
}
}
}
// 解析Lattice
for _, item := range result.Lattice {
var content JSON1Best
if err := json.Unmarshal([]byte(item.JSON1Best), &content); err == nil {
processJSON1Best(&content)
}
}
// 解析Lattice2
//for _, item := range result.Lattice2 {
// processJSON1Best(&item.JSON1Best)
//}
return strings.Join(words, ""), nil
}
五、感谢
感谢这位博主的分享
努力挣钱的小鑫