Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

录音、上传、播放音频微信小程序实践 #30

Open
Godiswill opened this issue Jul 9, 2022 · 0 comments
Open

录音、上传、播放音频微信小程序实践 #30

Godiswill opened this issue Jul 9, 2022 · 0 comments

Comments

@Godiswill
Copy link
Owner

Godiswill commented Jul 9, 2022

录音、上传、播放音频微信小程序实践

最近上线了一款智能外呼机器人产品,需要开发一款录音、上传、播放音频功能的
微信小程序给录音师配置外呼话术真人录音。

代码已开源,数据均已本地化处理。适合新手参考学习的完整原生微信小程序小项目。

约束

磨刀不误砍柴工,良好的约束是一个团队协作开发质量保障的基础。

开启 ts、less

最新的小程序已支持,less、ts 文件,无需在配置转换插件。

project.config.json 中配置开启支持编译

{
  "setting": {
    "useCompilerPlugins": [
      "typescript",
      "less"
    ]
  }
}

ts 声明

  1. ts 声明文件统一放置 typings/ 文件夹中,.d.ts 命名
  2. 大项目可以多用 namespace 来隔离,文件中不能使用 import、export,业务中直接使用声明即可,很方便
  3. .d.ts 之间引用,使用 /// <reference path="./wx/index.d.ts" />
  4. interface 变量可以 I 开头,同理 type T 开头,namespace N 开头

环境隔离

注意:提交审核时,审核人员在测试你的应用时,相当于 develop 环境。
如果公司做了测试环境内网隔离,需要小心,审核人员无法正常访问。
本地开发完时需要手动改成线上环境,比较繁琐。

const { miniProgram: { envVersion } } = wx.getAccountInfoSync();
const getConfig = () => {
  switch (envVersion) {
    case 'develop':// 开发版
      return {
        env: envVersion,
        host: prodHost,
      }
    case 'trial':// 体验版
      return {
        env: envVersion,
        host: testHost,
      }
    case 'release': // 正式版
      return {
        env: envVersion,
        host: prodHost,
      }
  }
}

export const envConfig = getConfig();

模拟 cookie

小程序不支持 cookie,用户登陆状态需要自己存储。

思路:存储在 Storage 中,请求时在 header 中设置 cookie 字段,
处理响应时存储返回的 cookie

注意:开发、体验、线上小程序使用的同一个 Storage,存储时记得做隔离。例如:

const { miniProgram: { envVersion } } = wx.getAccountInfoSync();

export const StorageMap = {
  Cookie: `${envVersion}_cookie`,
};

实践分析

依赖接口

主要使用以下 api

  1. wx.getRecorderManager :获取全局唯一的录音管理器 RecorderManager
  2. wx.createInnerAudioContext : 创建内部 audio 上下文 InnerAudioContext 对象

PS.

  1. 默认 audio 组件样式不符合需求,目前只需播放进度条,InnerAudioContext 配合 process 组件实现
  2. InnerAudioContext 退出小程序自动停止播放,需要退出小程序依然可播放请使用背景音频 BackgroundAudioManager 代替

为什么要声明全局变量:

  1. 录音本身就是唯一全局
  2. 语音播放,如果每次离开、进入页面动态生成、销毁(好像有 bug),会有多条音频同时播放,为避免这个问题,使用全局唯一对象管理
const recorderManager: WechatMiniprogram.RecorderManager = wx.getRecorderManager();
const innerAudioContext: WechatMiniprogram.InnerAudioContext = wx.createInnerAudioContext();

录音

  • 录音开始配置
const recordOptions = {
  duration: 10 * 60 * 1000, // 最多录音时长 10 分钟
  sampleRate: 8000, // 采样率
  numberOfChannels: 1, // 1 个录音通道即可
  format: 'wav', // 服务端指定格式
};
recorderManager.start(recordOptions)
  • 初始状态

录音

  • 录音检测是否收到声音,本想利用 RecorderManager.onFrameRecorded 来感知是否收到声音,
    展示波形图,但该事件不支持 wav 格式文件。目前监听到开始事件即显示录音计时。
// 监听已录制完指定帧大小的文件事件。如果设置了 frameSize,则会回调此事件。
recorderManager.onFrameRecorded(({ frameBuffer, isLastFrame }) => {
  console.log('frameBuffer.byteLength: ', frameBuffer.byteLength)
  console.log('isLastFrame: ', isLastFrame);
});
  • 监听录音开始事件,设置录音进行中状态,并展示录音计时器
recorderManager.onStart(() => {
  console.log('recorder start');
  this.startClock();
  this.setData({
    ...recordingData,
  });
});

录音中

  • 停止录音事件,可以接收到本地录音文件地址、录音时长信息。一般上传文件至 CDN ,然后把地址存储到业务服务器,接着试听播放。
recorderManager.stop();
// 停止录音事件
recorderManager.onStop(async (res) => {
  console.log('recorder stop', res)
  // 停止后立即更新状态,以免异常
  this.stopClock();
  this.setData({
    ...initRData,
  });
  if (isError) {
    isError = false;
    return;
  }
  const { tempFilePath, duration } = res;
  console.log('tempFilePath', tempFilePath);
  const url = await uploadFile({ filePath: tempFilePath });

  // 快速开始时,获取的都是未录音,会冲掉当前上传试听,这里手动设置一下
  if (innerAudioContext.currentTime) {
    innerAudioContext.stop();
  }
  innerAudioContext.src = url;
  this.setData({
    ...initPlayData,
    ...initRData,
    detail: {
      ...this.data.detail,
      url,
      duration: Math.ceil(duration / 1000),
    },
    duration: formatClock(duration, true),
  });
  // await this.getDetail('CUR');
});
  • 监听录音异常、中断,录音异常千奇百怪,且无文档具体说明。
    比如电话会打断录音,触发暂停事件。拒绝授权会出发错误事件。这里都设置异常变量为 true,在 onStop 事件中不进行上传逻辑,而是恢复到录音初始状态。
// 监听录音错误事件
recorderManager.onError((err) => {
  this.noEffectStopRecorder();

  showErrMsg(msgMap[err.errMsg] || err.errMsg || '小程序错误');
  console.log('recorderManager.onError', err);
});

// 监听录音暂停事件
recorderManager.onPause(() => {
  console.log('recorder pause');
  // 立马停止,重新开始,没有恢复机制
  this.noEffectStopRecorder();
});
  • 记录异常不进行业务处理并调用终止录音。这里注意录音不像播放调用 stop 是无副作用的。未开始或暂停录音调用 stop 会抛出异常。小心导致死循环。
noEffectStopRecorder() {
  if (this.data.isRecording) {
    isError = true;
    recorderManager.stop();
  }
}

上传

  • 需小程序后台配置相关业务域名
export function uploadFile({ fileName, filePath }: {
  fileName?: string;
  filePath: string;
}) {
  return new Promise<string>((resolve) => {
    wx.showLoading({
      title: '上传中...',
    });
    const name = fileName || filePath;
    // 获取 CDN token
    getNosToken({ fileName: name }).then((data) => {
      console.log('uploadToken: ', data);
      wx.hideLoading();
      wx.uploadFile({
        url: 'https://nos.com/',
        name: 'file', // 服务器获取流的参数名
        filePath,
        formData: {
          Object: data.objectName,
          'x-nos-token': data.token,
        },
        success(res) {
          console.log('上传成功回调', res);
          wx.hideLoading();
          const url = `https://cdn.com/${data.objectName}`
          console.log(url);
          resolve(url);
        },
        fail(err) {
          wx.hideLoading();
          wx.showToast({
            title: err.errMsg,
            icon: 'none',
          });
          reject(err);
        },
      })
    });
  });
}

播放

  • 未播放状态

播放

  • 监听播放开始事件,设置播放状态,且展示播放进度条
// 监听音频播放事件
innerAudioContext.onPlay(() => {
  console.log('开始播放');
  this.setData({
    ...playingData,
  });
});
  • 监听音频播放进度更新事件,更新 process 百分比
// 监听音频播放进度更新事件
innerAudioContext.onTimeUpdate(() => {
  console.log('监听音频播放进度更新事件');

  let playPercent = 0;
  const duration = this.data.detail.duration || innerAudioContext.duration;
  try {
    playPercent = Math.ceil(((innerAudioContext.currentTime * 1000) / (duration * 1000)) * 100) || 0;
  } catch (e) {
    playPercent = 0;
  }
  playPercent = playPercent && playPercent > 100 ? 100 : playPercent;
  const currentTime = formatClock(innerAudioContext.currentTime * 1000, true);
  console.log('当前播放时间:', currentTime);
  console.log('微信暴露时间:', innerAudioContext.duration);
  console.log('后端返回时间:', duration);
  console.log('当前播放进度:', playPercent);
  this.setData({
    currentTime,
    playPercent,
  });
});

播放中

  • 需求不需要暂停或拖拽进度条。监听音频正常、异常停止或暂停时,都恢复到初始状态。需要恢复或拖拽进度能力,可自行相应事件中处理
// 监听音频自然播放至结束的事件
innerAudioContext.onEnded(() => {
  console.log('监听音频自然播放至结束的事件');
  this.setData({
    ...initPlayData
  });
});

// 监听音频播放错误事件
innerAudioContext.onError((res) => {
  /**
   * 10001	系统错误
   * 10002	网络错误
   * 10003	文件错误
   * 10004	格式错误
   * -1	    未知错误
   */
  console.log(res.errCode, res.errMsg);
  this.setData({
    ...initPlayData
  });
});

// 监听音频暂停事件
innerAudioContext.onPause(() => {
  console.log('监听音频暂停事件');
  this.setData({
    ...initPlayData
  });
});

// 监听音频停止事件
innerAudioContext.onStop(() => {
  console.log('监听音频停止事件');
  this.setData({
    ...initPlayData,
  });
});

Page 事件

  1. 页面初次渲染完成,初始化音频录音、播放事件
  2. 页面每次重新进入加载最新业务数据
  3. 页面离开当前页面或退出小程序,停止录音、播放
/**
   * 生命周期函数--监听页面初次渲染完成
   */
onReady() {
  this.initRecorder();
  this.initAudioPlayer();
},

/**
 * 生命周期函数--监听页面显示
 */
onShow() {
  this.getDetail('CUR');
},

/**
 * 生命周期函数--监听页面卸载
 */
onUnload() {
  console.log('切换页面停止录音或播放');
  innerAudioContext.stop();

  this.noEffectStopRecorder();
},

参考

  1. 原文地址
  2. Github 地址
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant