最近的项目开发中做了一个微信小程序录音的功能,遇到了一些问题,是如何解决的?

本文会从两种解决方案(微信小程序录音API/H5录音嵌入webview中),从优点、难点、缺陷、实现等进行讲解,并附有代码。

RecorderManager 微信小程序 API

创建全局的唯一录音器,可以在当前页面或者小程序内其他页面操作,不影响录音运行。

缺陷

  1. 熄屏锁屏后会停止录音
  2. 切换 APP 也会停止录音
  3. 小程序进入后台并被「挂起」后,如果很长时间(目前是 30 分钟)都未再次进入前台,小程序会被销毁;也会停止录音

难点

  1. 录音切换页面、小程序后台运行、微信后台运行,会暂停录音,如何保持联系
  2. 录音时长最大支持10分钟,解决方法

实现

创建全局的录音管理器,通过start开始录音,stop结束录音;并在onStop监听结束录音获取到录音文件并做处理。

API 使用

  • RecorderManager 全局唯一的录音管理器
  • RecorderManager.start 开始录音
  • RecorderManager.stop 停止录音
  • RecorderManager.resume 继续录音
  • RecorderManager.onPause 监听录音暂停;如非手动暂停,可在此继续录音
  • RecorderManager.onStop 监听录音结束,会返回录音的临时文件 tempFilePath

录音流程

代码示例

本示例是通过 react + Taro 自定义 hook,可直接获取使用。

 1import Taro, {
 2  getSetting,
 3  authorize,
 4  openSetting,
 5  showModal,
 6} from '@tarojs/taro'
 7import { useEffect, useRef } from 'react'
 8
 9// 录音最大时长
10const maxDuration = 1000 * 60 * 10
11// 录音默认配置
12const defaultOptions = {
13  duration: maxDuration,
14  format: 'mp3', // 文件格式
15  numberOfChannels: 1,
16}
17// 全局的录音管理器
18const recorderManager = Taro.getRecorderManager()
19
20/**
21 * 拜访打卡任务录音
22 * @param {*} options 音频配置,见小程序文档
23 * @param {*} onUploadSuccess 自定义上传回调
24 * @param {*} isContinueRecord 超过10分钟是否继续录制,暂未实现
25 * @returns
26 */
27const useRecord = ({ options = {}, onUploadSuccess, isContinueRecord = false } = {}) => {
28  // 是否已开始录音
29  let isRecording = false
30  // 是否手动结束
31  let isManualEnd = false
32  // 继续录音失败
33  let resuumeFial = false
34
35  /**
36   * 开启/关闭屏幕常亮,防止录音时熄屏后录音失败
37   */
38  const setKeepScreenOn = (state = false) => {
39    Taro.setKeepScreenOn({ keepScreenOn: state })
40  }
41
42  /**
43   * 初始化全局录音组件
44   */
45  const initRecorderManager = () => {
46    // 录音开始
47    recorderManager.onStart(() => {
48      resuumeFial = false
49      // 开始录音后,设置屏幕常亮;防止熄屏后录音失败
50      setKeepScreenOn(true)
51    })
52
53    // 录音失败
54    recorderManager.onError((res) => {
55      // 锁屏或者息屏后,继续录音失败
56      if (res.errMsg.includes('resume') && res.errMsg.includes('fail')) {
57        resuumeFial = true
58        // 手动停止
59        recorderManager.stop()
60      }
61      setKeepScreenOn(false)
62      isRecording = false
63      isManualEnd = false
64    })
65
66    // 录音结束
67    recorderManager.onStop((res) => {
68      // 关闭屏幕常亮设置
69      setKeepScreenOn(false)
70      // 录音时间小于1秒不做处理。
71      if (res.duration < 1000) {
72        Taro.showToast('录音时长需大于1秒')
73        isRecording = false
74        return
75      }
76      /** 手动停止录音 || 录音时间超过 10 分钟,触发上传文件 */
77      if (isContinueRecord && res.duration >= maxDuration && !isManualEnd) {
78        // 超过10分钟录音,再次开始录音
79        continueRecord()
80      } else if (!resuumeFial) {
81        // 手动停止录音
82        onUploadSuccess(res)
83        isManualEnd = false
84      }
85    })
86
87    // 录音暂停
88    recorderManager.onPause(() => {
89      // 录音暂停后,继续录音
90      if (isRecording) recorderManager.resume()
91    })
92
93    // 录音继续
94    recorderManager.onResume(() => {
95      resuumeFial = false
96    })
97
98    // 中断结束事件
99    recorderManager.onInterruptionEnd(() => {
100      // 继续录音
101      recorderManager.resume()
102    })
103  }
104
105  /**
106   * 开始录音
107   */
108  const startRecord = () => {
109    if (isRecording) return
110    isRecording = true
111    initRecorderManager()
112    recorderManager.start({
113      ...defaultOptions,
114      ...options,
115    })
116  }
117
118  /**
119   * 再次录音,用于超过10分钟后再次触发录音
120   */
121  const continueRecord = () => {
122    recorderManager.start({
123      ...defaultOptions,
124      ...options,
125    })
126  }
127
128  /**
129   * 结束录音,录音上传
130   */
131  const stopRecord = () => {
132    isManualEnd = true
133    recorderManager.stop()
134  }
135
136  /**
137   * 获取录音权限
138   */
139  function getRecordAuth() {
140    return new Promise((resolve, reject) => {
141      getSetting({
142        success: (res) => {
143          resolve(!!res.authSetting['scope.record'])
144        },
145        fail: (err) => {
146          reject(err)
147        },
148      })
149    })
150  }
151
152  /**
153   * 打开设置,授权录音权限
154   */
155  function openRecordAuth() {
156    authorize({
157      scope: 'scope.record',
158      success: () => {},
159      fail: () => {
160        showModal({
161          title: '录音需开启麦克风权限',
162          confirmText: '前往开启',
163          success: (data) => {
164            if (data.confirm) {
165              openSetting()
166            } else if (data.cancel) {
167              Taro.showToast('授权失败')
168            }
169          },
170        })
171      },
172    })
173  }
174
175  return {
176    startRecord,
177    stopRecord,
178    getRecordAuth,
179    openRecordAuth,
180  }
181}
182
183export default useRecord
184

MediaRecorder MediaStream Recording API

  • 通过开发录音的 H5 并嵌入 webview 可以解决熄屏锁屏切换 APP的录音暂停问题。
  • 不仅可以解决上述小程序的缺陷;而且还可以解决不同端的录音器 API 差异或没有支持录音 API。

缺陷

  1. 小程序 “返回上一页” 等销毁 webview 页面的操作,录音也会被停止

难点

  1. H5 的与小程序 webview 通信
  2. H5 录音,拒绝授权后的引导处理

实现

  • 通过 MediaRecorder 实现录音的H5页面。
  • 需要在小程序中使用,使用 webview 嵌入H5,然后通过 postMessage 进行通信。

兼容性

MediaRecorder API,目前的主流浏览器都已经支持,包括微信浏览器。

API

  • const stream = awwit navigator.mediaDevices.getUserMedia({ audio: true }) 打开浏览器麦克风录音
  • new MediaRecorder(stream, { mimeTyp: 'audio/mp3', }) 创建录音器
  • ondataavailable 用于获取录制的媒体资源
  • start 开始录音
  • stop 结束录音

代码

本示例是通过 react 自定义 hook,可直接获取使用。

 1import { useEffect, useRef } from 'react'
 2
 3/**
 4 * useRecord
 5 * @param {*} isStart 是否初始化完成就开始录音
 6 * @param {*} maxRecordTime 录音最大时长,单位秒,默认10分钟
 7 * @param {*} onUploadSuccess 录音结束回调
 8 * @param {*} timeChange 录音时长
 9 * @returns
10 */
11const useRecord = ({ isStart, maxRecordTime = 10 * 60, onUploadSuccess, timeChange }) => {
12  // 录音器
13  const mediaRecorder = useRef(null)
14  // 录音去加载完成,开始开始录音
15  let isStartRecord = isStart || false
16  // 录音时长
17  let recordTime = 0
18  // 计时器
19  let timer = null
20
21  /**
22   * 记录录音时长
23   * @returns
24   */
25  const handleRecordTime = (type) => {
26    if (type === 'stop') {
27      timeChange?.({ time: recordTime, state: mediaRecorder.current?.state })
28      return clearInterval(timer)
29    }
30    timer = setInterval(() => {
31      if (recordTime >= maxRecordTime) {
32        onStop()
33        timeChange?.({ time: recordTime, state: 'inactive' })
34        clearInterval(timer)
35      } else {
36        recordTime += 1
37        timeChange?.({ time: recordTime, state: mediaRecorder.current?.state })
38      }
39    }, 1000)
40  }
41
42  // 录音初始化
43  const initMediaRecorder = async () => {
44    if (!navigator?.mediaDevices) {
45      alert('该浏览器不支持录音(mediaDevices)')
46      return
47    }
48    navigator.mediaDevices
49      .getUserMedia({ audio: true })
50      .then((stream) => {
51        mediaRecorder.current = new MediaRecorder(stream, {
52          mimeTyp: 'audio/mp3',
53        })
54        // 开始录音
55        mediaRecorder.current.onstart = function (e) {
56          recordTime = 0
57          handleRecordTime('start')
58        }
59        // 录音暂停
60        mediaRecorder.current.onpause = function (e) {
61          handleRecordTime('stop')
62        }
63        // 录音继续
64        mediaRecorder.current.onresume = function (e) {
65          handleRecordTime('start')
66        }
67        // 录音结束
68        mediaRecorder.current.onstop = function (e) {
69          handleRecordTime('stop')
70        }
71        // 录音错误
72        mediaRecorder.current.onerror = function (e) {
73          handleRecordTime('stop')
74        }
75        // 录制的资源,录音结束才会触发
76        mediaRecorder.current.ondataavailable = function (e) {
77          onUploadSuccess(e)
78        }
79
80        // 进入页面,录音器加载完后就开始录音
81        if (isStartRecord) {
82          onStart()
83          isStartRecord = false
84        }
85      })
86      .catch((err) => {
87      if (
88          err.toString().includes('denied') &&
89          (err.toString().includes('Permission') || err.toString().includes('permission'))
90        ) {
91          alert('录音授权失败,请清除缓存后再操作')
92          return
93        }
94        alert(err)
95      })
96  }
97
98  // 开始录音
99  const onStart = async () => {
100    // state: inactive -未开始, recording - 录音中,paused - 录音暂停
101    if (mediaRecorder?.current?.state === 'inactive') mediaRecorder.current?.start()
102  }
103
104  // 结束录音
105  const onStop = () => {
106    if (mediaRecorder.current && mediaRecorder.current?.state !== 'inactive') mediaRecorder.current.stop()
107  }
108
109  // 继续录音
110  // isload 页面初次加载完成后开始录音
111  const onContinue = async (isload) => {
112    if (mediaRecorder?.current?.state === 'paused' && recordTime) mediaRecorder.current.resume()
113    else if (!mediaRecorder?.current && isload && !recordTime) isStartRecord = true
114    else if (!recordTime) onStart()
115  }
116
117  // 暂停录音
118  const onPause = () => {
119    if (mediaRecorder?.current?.state === 'recording') mediaRecorder.current.pause()
120  }
121
122  // 录音兼容问题处理
123  const initUserMedia = () => {
124    // eslint-disable-next-line no-undef
125    if (navigator.mediaDevices === undefined) {
126      // eslint-disable-next-line no-undef
127      navigator.mediaDevices = {}
128    }
129
130    // eslint-disable-next-line no-undef
131    if (navigator.mediaDevices.getUserMedia === undefined) {
132      // eslint-disable-next-line no-undef
133      navigator.mediaDevices.getUserMedia = function (constraints) {
134        // eslint-disable-next-line no-undef
135        const getUserMedia = navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia
136
137        if (!getUserMedia) {
138          return Promise.reject(new Error('浏览器不支持 getUserMedia !'))
139        }
140
141        return new Promise((resolve, reject) => {
142          // eslint-disable-next-line no-undef
143          getUserMedia.call(navigator, constraints, resolve, reject)
144        })
145      }
146    }
147  }
148
149  /**
150   * init
151   */
152  const initRecord = async () => {
153    await initUserMedia()
154    initMediaRecorder()
155  }
156  
157  useEffect(() => {
158    initRecord()
159  }, [])
160
161  return {
162    onContinue,
163    onStart,
164    onStop,
165    onPause,
166  }
167}
168
169export default useRecord

注意事项

  • 录音的 H5 网络协议得是 https 协议
  • 录音最好设置最大时长;时长过大,录音文件大小会比较大,上传速度会有影响
  • 嵌入到小程序中,需配置 webview 域名
  • webview 进行通信,在 onMessage 获取数据;返回的数据是多次 postMessage 的数据组成的数组
  • 在 H5 中通过 wxjsddk 进行操作,postMessage 进行数据通信,只会在小程序后退、组件销毁、分享、复制链接后触发
  • 更多请查看API:developers.weixin.qq.com/miniprogram…

65417ff4acff4902c7404ee344ecd8a2.png

65417ff4acff4902c7404ee344ecd8a2.png

 

个人笔记记录 2021 ~ 2025