vue3 中使用 Recorder 实现录音并上传,并用Go语言调取讯飞识别录音(Go语言)

发布于:2025-03-14 ⋅ 阅读:(23) ⋅ 点赞:(0)

效果图

在这里插入图片描述

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
}

五、感谢
感谢这位博主的分享
努力挣钱的小鑫


网站公告

今日签到

点亮在社区的每一天
去签到