有一个需求需要在微信小程序上实现一个长按时进行语音录制,录制时间最大为60秒,录制完成后,可点击播放,播放时再次点击停止播放,可以反复录制,新录制的语音把之前的语音覆盖掉,也可以主动长按删除
// index.js
const recorderManager = wx.getRecorderManager()
const innerAudioContext = wx.createInnerAudioContext()
let recordingTimerInterval = null // 录音时长计时器
let countdownTimerInterval = null // 倒计时计时器
let playbackCountdownInterval = null // 播放倒计时计时器Page({
/*** 页面的初始数据*/data: {// 语音输入部分inputType: 'input',count: null, // 录制倒计时longPress: '1', // 1显示 按住说话 2显示 说话中delShow: false, // 删除提示框显示隐藏time: 0, // 录音时长recordedDuration: 0, // 已录制音频的时长duration: 60000, // 录音最大值ms 60000/1分钟tempFilePath: '', //音频路径playStatus: 0, //录音播放状态 0:未播放 1:正在播放currentTime: 0, // 当前播放进度(秒)remain: 0, // 当前剩余时长(秒) = duration - currentTimewarningShown: false, // 是否已显示50秒提示minDuration: 2, // 录音最小时长秒数animationArray: Array.from({ length: 15 }, (_, index) => {// length 这个名字就不再需要,因为我们已经在这里写死了 15const centerIndex = Math.floor((15 - 1) / 2) // 7const distance = Math.abs(index - centerIndex)// 中心延迟为 0,向外越来越大const delay = distance * 0.2return { delay }})},/*** 开始录音倒计时* @param {number} val - 倒计时秒数*/startCountdown(val) {this.setData({count: Number(val)})countdownTimerInterval = setInterval(() => {if (this.data.count > 0) {this.setData({count: this.data.count - 1})} else {this.setData({longPress: '1'})clearInterval(countdownTimerInterval)countdownTimerInterval = null}}, 1000)},/*** 开始录音时长计时*/startRecordingTimer() {if (recordingTimerInterval) return // 防止重复启动计时器recordingTimerInterval = setInterval(() => {this.setData({time: this.data.time + 1})// 当录音时长达到50秒且未显示提示时,显示提示if (this.data.time === 50 && !this.data.warningShown) {wx.showToast({title: '录音即将结束',icon: 'none',duration: 2000})this.setData({warningShown: true})}// 如果录音时长达到最大值,自动停止录音if (this.data.time >= this.data.duration / 1000) {wx.showToast({title: '录音已达到最大时长',icon: 'none'})this.touchendBtn()}}, 1000)},/*** 停止录音时长计时* @param {string} newTempFilePath - 新录音的文件路径*/stopRecordingTimer(newTempFilePath) {if (recordingTimerInterval) {clearInterval(recordingTimerInterval)recordingTimerInterval = null}const duration = this.data.timeif (duration >= this.data.minDuration) {this.setData({recordedDuration: duration,tempFilePath: newTempFilePath},() => {console.log('录音已停止,时长:', this.data.recordedDuration, '秒')})} else {// 录音时长过短,提示用户wx.showToast({title: '录音时间太短',icon: 'none'})// 不覆盖之前的 tempFilePath,保留旧的录音// 仅重置 timethis.setData({time: 0},() => {console.log('录音时间太短,不保存此次录音。')})}},/*** 开始播放倒计时* @param {number} val - 播放倒计时秒数*/startPlaybackCountdown(val) {// 先停止可能存在的旧计时器if (playbackCountdownInterval) {clearInterval(playbackCountdownInterval)playbackCountdownInterval = null}this.setData({count: Number(val)})playbackCountdownInterval = setInterval(() => {if (this.data.count > 0) {this.setData({count: this.data.count - 1})} else {// 播放结束this.setData({playStatus: 0,count: null})innerAudioContext.stop()clearInterval(playbackCountdownInterval)playbackCountdownInterval = null}}, 1000)},/*** 停止播放倒计时*/stopPlaybackCountdown() {if (playbackCountdownInterval) {clearInterval(playbackCountdownInterval)playbackCountdownInterval = null}this.setData({count: null})},/*** 清除所有计时器*/clearAllTimers() {if (recordingTimerInterval) {clearInterval(recordingTimerInterval)recordingTimerInterval = null}if (countdownTimerInterval) {clearInterval(countdownTimerInterval)countdownTimerInterval = null}if (playbackCountdownInterval) {clearInterval(playbackCountdownInterval)playbackCountdownInterval = null}},/*** 重置录音状态*/resetRecordingState() {this.setData({longPress: '1',time: 0,recordedDuration: 0,count: null,warningShown: false // 重置警告提示})this.stopRecordingTimer()this.stopCountdownTimer()},/*** 处理输入类型变化* @param {object} e - 事件对象*/handleChangeInputType(e) {const { type } = e.currentTarget.datasetthis.setData({inputType: type})},/*** 检查录音权限*/checkRecordPermission() {wx.getSetting({success: res => {if (!res.authSetting['scope.record']) {// 没有录音权限,尝试授权wx.authorize({scope: 'scope.record',success: () => {// 授权成功,可以开始录音this.startRecording()},fail: () => {// 授权失败,提示用户前往设置授权wx.showModal({title: '授权提示',content: '录音权限未授权,请前往设置授权',success: res => {if (res.confirm) {wx.openSetting()}}})}})} else {// 已经授权,可以开始录音this.startRecording()}},fail: () => {// 获取设置失败,提示用户wx.showToast({title: '获取权限失败,请重试',icon: 'none'})}})},/*** 开始录音的封装函数*/startRecording() {this.setData({longPress: '2',time: 0, // 在开始录音前重置 timewarningShown: false // 重置警告提示})this.startCountdown(this.data.duration / 1000) // 录音倒计时60秒recorderManager.stop() // 确保之前的录音已停止this.startRecordingTimer()const options = {duration: this.data.duration * 1000, // 指定录音的时长,单位 mssampleRate: 16000, // 采样率numberOfChannels: 1, // 录音通道数encodeBitRate: 96000, // 编码码率format: 'mp3', // 音频格式,有效值 aac/mp3frameSize: 10 // 指定帧大小,单位 KB}recorderManager.start(options)},/*** 长按录音事件*/longpressBtn() {this.checkRecordPermission()},/*** 长按松开录音事件*/touchendBtn() {this.setData({longPress: '1'})recorderManager.stop()this.stopCountdownTimer()},/*** 停止倒计时计时器*/stopCountdownTimer() {if (countdownTimerInterval) {clearInterval(countdownTimerInterval)countdownTimerInterval = null}this.setData({count: null})},/*** 播放录音*/playBtn() {if (!this.data.tempFilePath) {wx.showToast({title: '没有录音文件',icon: 'none'})return}// 如果已经在播放,就先停止if (this.data.playStatus === 1) {innerAudioContext.stop()// 重置状态this.setData({playStatus: 0,currentTime: 0,remain: 0})} else {// 重新开始播放console.log('开始播放', this.data.tempFilePath)innerAudioContext.src = this.data.tempFilePath// 在 iOS 下,即使系统静音,也能播放音频innerAudioContext.obeyMuteSwitch = false// 播放innerAudioContext.play()// playStatus 会在 onPlay 中置为 1// 如果想在点击之后就立即把 playStatus 置为 1 也行}},/*** 生命周期函数--监听页面加载*/onLoad(options) {// 绑定录音停止事件recorderManager.onStop(res => {// 将新录音的文件路径传递给 stopRecordingTimerthis.stopRecordingTimer(res.tempFilePath)console.log('录音已停止,文件路径:', res.tempFilePath)console.log('录音时长:', this.data.recordedDuration, '秒')})// 绑定录音开始事件recorderManager.onStart(res => {console.log('录音开始', res)})// 绑定录音错误事件recorderManager.onError(err => {console.error('录音错误:', err)wx.showToast({title: '录音失败,请重试',icon: 'none'})this.resetRecordingState()})// 当音频真正开始播放时innerAudioContext.onPlay(() => {console.log('onPlay 音频开始播放')// 设置为播放状态this.setData({playStatus: 1})})// 绑定音频播放结束事件innerAudioContext.onEnded(() => {console.log('onEnded 音频播放结束')// 停止播放并重置this.setData({playStatus: 0,currentTime: 0,remain: 0})// 如果想让界面上回到音频的总时长也可以手动 set remain = recordedDuration// 但通常播放结束,就显示 0 或不显示都行})innerAudioContext.onTimeUpdate(() => {const current = Math.floor(innerAudioContext.currentTime) // 取整或保留小数都可const total = Math.floor(innerAudioContext.duration)// 若 total 不准确(部分手机可能最初获取到是 0),可做一些保护if (total > 0) {const remain = total - currentthis.setData({currentTime: current,remain: remain > 0 ? remain : 0})}})// 绑定音频播放错误事件innerAudioContext.onError(err => {console.error('播放错误:', err)wx.showToast({title: '播放失败,请重试',icon: 'none'})this.setData({playStatus: 0,currentTime: 0,remain: 0})})},/*** 生命周期函数--监听页面卸载*/onUnload() {this.clearAllTimers()recorderManager.stop()innerAudioContext.stop()innerAudioContext.destroy()},
})
// index.wxml
<view wx:else class="voice-input"><view wx:if="{{tempFilePath !== ''}}" class="voice-msg" bind:tap="playBtn"><imagesrc="{{ playStatus === 0 ? '/sendingaudio.png' : '/voice.gif' }}"mode="aspectFill"style="transform: rotate(180deg); width: 22rpx; height: 32rpx"/><text class="voice-msg-text">{{ playStatus === 1 ? (remain + "''") : (recordedDuration + "''") }}</text></view><viewclass="voice-input-btn {{longPress == '1' ? '' : 'record-btn-2'}}"bind:longpress="longpressBtn"bind:touchend="touchendBtn"><!-- 语音音阶动画 --><view class="prompt-layer prompt-layer-1" wx:if="{{longPress == '2'}}"><!-- <view class="prompt-layer prompt-layer-1" wx:if="{{longPress == '2'}}"> --><view class="prompt-loader"><viewclass="em"wx:for="{{animationArray}}"wx:key="index"style="--delay: {{item.delay}}s;"></view></view><text class="p">{{'剩余:' + count + 's' + (warningShown ? ',即将结束录音' : '')}}</text><text class="span">松手结束录音</text></view><text class="voice-input-btn-text">{{longPress == '1' ? '按住 说话' : '说话中...'}}</text></view></view>
/* index.wxss */
.voice-btn {box-sizing: border-box;padding: 6rpx 16rpx;background: #2197ee;border-radius: 28rpx;display: flex;align-items: center;justify-content: center;gap: 10rpx;
}.voice-text {line-height: 42rpx;color: #ffffff;font-size: 30rpx;
}.voice-input {box-sizing: border-box;display: flex;flex-direction: column;padding: 30rpx 76rpx;
}.voice-msg {width: 100%;height: 56rpx;background: #95ec69;border-radius: 10rpx;box-shadow: 0 3rpx 6rpx rgba(0, 0, 0, 0.13);margin-bottom: 26rpx;box-sizing: border-box;padding: 0 20rpx;display: flex;align-items: center;gap: 16rpx;
}.voice-msg-text {color: #000000;font-size: 30rpx;line-height: 56rpx;
}.voice-input-btn {width: 100%;box-sizing: border-box;padding: 12rpx 0;background: #ffffff;border: 2rpx solid;border-color: #1f75e3;border-radius: 8rpx;box-sizing: border-box;text-align: center;position: relative;
}.voice-input-btn-text {color: #1f75e3;font-size: 36rpx;line-height: 50rpx;
}/* 提示小弹窗 */
.prompt-layer {border-radius: 16rpx;background: #2197ee;padding: 16rpx 32rpx;box-sizing: border-box;position: absolute;left: 50%;transform: translateX(-50%);
}.prompt-layer::after {content: '';display: block;border: 12rpx solid rgba(0, 0, 0, 0);border-top-color: #2197ee;position: absolute;bottom: -20rpx;left: 50%;transform: translateX(-50%);
}.prompt-layer-1 {font-size: 32rpx;width: 80%;text-align: center;display: flex;flex-direction: column;align-items: center;justify-content: center;top: -178rpx;
}
.prompt-layer-1 .p {color: #ffffff;
}
.prompt-layer-1 .span {color: rgba(255, 255, 255, 0.6);
}/* 语音音阶------------- */
/* 容器样式 */
.prompt-loader {width: 250rpx;height: 40rpx;display: flex;align-items: center; /* 对齐到容器底部 */justify-content: space-between;margin-bottom: 12rpx;
}/* 音阶条样式 */
.prompt-loader .em {background: #ffffff;width: 6rpx;border-radius: 6rpx;height: 40rpx;margin-right: 5rpx;/* 通用动画属性 */animation: load 2.5s infinite linear;animation-delay: var(--delay);will-change: transform;transform-origin: center
}/* 移除最后一个元素的右边距 */
.prompt-loader .em:last-child {margin-right: 0;
}/* 动画关键帧 */
@keyframes load {0% {transform: scaleY(1);}50% {transform: scaleY(0.1);}100% {transform: scaleY(1);}
}
.record-btn-2 {background-color: rgba(33, 151, 238, 0.2);
}