微信小程序实现长按录音,点击播放等功能,CSS实现语音录制动画效果

有一个需求需要在微信小程序上实现一个长按时进行语音录制,录制时间最大为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);
}
相关推荐
豪宇刘几秒前
JavaScript 延迟加载的方法
开发语言·javascript
顾尘眠34 分钟前
http常用状态码(204,304, 404, 504,502)含义
前端
摇光931 小时前
js迭代器模式
开发语言·javascript·迭代器模式
王先生技术栈2 小时前
思维导图,Android版本实现
java·前端
悠悠:)3 小时前
前端 动图方案
前端
anyup_前端梦工厂3 小时前
了解 ES6 的变量特性:Var、Let、Const
开发语言·javascript·ecmascript
星陈~3 小时前
检测electron打包文件 app.asar
前端·vue.js·electron
Aatroox3 小时前
基于 Nuxt3 + Obsidian 搭建个人博客
前端·node.js
说私域3 小时前
优质内容在个人IP运营中的重要性:以开源AI智能名片商城小程序为应用实例的深度探讨
人工智能·tcp/ip·小程序
每天都要进步哦3 小时前
Node.js中的fs模块:文件与目录操作(写入、读取、复制、移动、删除、重命名等)
前端·javascript·node.js