有一个需求需要在微信小程序上实现一个长按时进行语音录制,录制时间最大为60秒,录制完成后,可点击播放,播放时再次点击停止播放,可以反复录制,新录制的语音把之前的语音覆盖掉,也可以主动长按删除
javascript
// 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 - currentTime
warningShown: false, // 是否已显示50秒提示
minDuration: 2, // 录音最小时长秒数
animationArray: Array.from({ length: 15 }, (_, index) => {
// length 这个名字就不再需要,因为我们已经在这里写死了 15
const centerIndex = Math.floor((15 - 1) / 2) // 7
const distance = Math.abs(index - centerIndex)
// 中心延迟为 0,向外越来越大
const delay = distance * 0.2
return { 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.time
if (duration >= this.data.minDuration) {
this.setData(
{
recordedDuration: duration,
tempFilePath: newTempFilePath
},
() => {
console.log('录音已停止,时长:', this.data.recordedDuration, '秒')
}
)
} else {
// 录音时长过短,提示用户
wx.showToast({
title: '录音时间太短',
icon: 'none'
})
// 不覆盖之前的 tempFilePath,保留旧的录音
// 仅重置 time
this.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.dataset
this.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, // 在开始录音前重置 time
warningShown: false // 重置警告提示
})
this.startCountdown(this.data.duration / 1000) // 录音倒计时60秒
recorderManager.stop() // 确保之前的录音已停止
this.startRecordingTimer()
const options = {
duration: this.data.duration * 1000, // 指定录音的时长,单位 ms
sampleRate: 16000, // 采样率
numberOfChannels: 1, // 录音通道数
encodeBitRate: 96000, // 编码码率
format: 'mp3', // 音频格式,有效值 aac/mp3
frameSize: 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 => {
// 将新录音的文件路径传递给 stopRecordingTimer
this.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 - current
this.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()
},
})
html
// index.wxml
<view wx:else class="voice-input">
<view wx:if="{{tempFilePath !== ''}}" class="voice-msg" bind:tap="playBtn">
<image
src="{{ 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>
<view
class="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">
<view
class="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>
css
/* 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);
}