13404089107
2 天以前 dd076cee45ea103a43d6726a21d900f5484b3729
H5/components/voiceInputPopup.vue
@@ -1,25 +1,32 @@
<template>
  <u-popup :show="show" mode="bottom" :closeOnClickOverlay="false" @close="closePopup" zIndex="10071">
  <u-popup :show="show" mode="bottom" :closeOnClickOverlay="false" @close="closePopup" :zIndex="9999" :overlay="true"
    :safeAreaInsetBottom="true">
    <view class="voice-popup">
      <view class="header">
        <text class="pl-30">语音输入</text>
        <image src="/static/Appeal/close.png" class="w-34 h-34 mr-30" mode="" @click="closePopup"></image>
      </view>
      <view class="record-anim">
        <image src="/static/Appeal/step.png" class="w-153 h-119" mode=""  @click="onPlay"></image>
        <image src="/static/Appeal/step.png" class="w-153 h-119" mode=""></image>
      </view>
      <view class="timer">{{ time }}</view>
      <view class="btns">
        <image src="/static/Appeal/start.png" class="w-153 h-153 mr-14" mode=""  @click="onPlay"></image>
        <image src="/static/Appeal/stop.png" class="w-153 h-153 mr-14" mode="" @click="onPause"></image>
        <image :src="getRecordButtonSrc" class="w-153 h-153 mr-14" mode="" @click="handlerOnCahnger"></image>
        <image src="/static/Appeal/cancel.png" class="w-153 h-153 mr-14" mode="" @click="onStop"></image>
      </view>
    </view>
    <mumu-recorder ref="mumuRecorder" @success="handlerSuccess" @error="handleError"></mumu-recorder>
  </u-popup>
</template>
<script>
import { getSignature } from '../pages/index/service'
import MumuRecorder from '@/uni_modules/mumu-recorder/components/mumu-recorder/mumu-recorder.vue'
export default {
  name: 'VoiceInputPopup',
  components: {
    MumuRecorder
  },
  props: {
    show: {
      type: Boolean,
@@ -29,16 +36,317 @@
  data() {
    return {
      time: '00:00:00',
      barHeights: [20, 30, 40, 30, 20], // 可做动画
      isRecording: false,
      isPaused: false,
      tempFilePath: '',
      timer: null,
      seconds: 0,
      minutes: 0,
      hours: 0,
      recordSegments: [], // 存储录音片段
      status: false,
      recorder: null,
      currentSegment: null // 当前录音片段
    }
  },
  computed: {
    getRecordButtonSrc() {
      if (this.isPaused) {
        return '/static/Appeal/start.png'
      }
      return this.isRecording ? '/static/Appeal/stop.png' : '/static/Appeal/start.png'
    }
  },
  mounted() {
  },
  methods: {
    closePopup() {
      this.$emit('update:show', false)
    handlerSave() {
      let tag = document.createElement('a')
      tag.href = this.recorder.localUrl
      tag.download = '录音'
      tag.click()
    },
    onPlay() {},
    onPause() {},
    onStop() {},
    handlerOnCahnger() {
      if (!this.isRecording && !this.isPaused) {
        // 开始新的录音
        this.startNewRecording()
      } else if (this.isRecording) {
        // 暂停当前录音
        this.pauseRecording()
      } else if (this.isPaused) {
        // 继续录音
        this.resumeRecording()
      }
    },
    startNewRecording() {
      console.log('开始新的录音')
      this.isRecording = true
      this.isPaused = false
      this.startTimer()
      this.$refs.mumuRecorder.start()
    },
    pauseRecording() {
      console.log('暂停录音')
      this.isRecording = false
      this.isPaused = true
      this.stopTimer()
      this.$refs.mumuRecorder.stop()
    },
    resumeRecording() {
      console.log('继续录音')
      this.isRecording = true
      this.isPaused = false
      this.startTimer()
      this.$refs.mumuRecorder.start()
    },
    handlerSuccess(res) {
      console.log('录音成功回调:', res)
      this.recorder = res
      // 保存当前录音片段
      if (res.localUrl) {
        console.log('当前录音片段URL:', res.localUrl)
        console.log('当前已存储的片段数:', this.recordSegments.length)
        console.log('当前片段:', this.currentSegment)
        // 只有当当前片段与上一个不同时才添加
        if (!this.currentSegment || this.currentSegment.url !== res.localUrl) {
          console.log('添加新的录音片段')
          this.currentSegment = {
            url: res.localUrl,
            data: res.data,
            duration: this.seconds + this.minutes * 60 + this.hours * 3600
          }
          this.recordSegments.push(this.currentSegment)
          console.log('更新后的片段列表:', this.recordSegments)
        } else {
          console.log('跳过重复的录音片段')
        }
      }
    },
    handlerError(code) {
      switch (code) {
        case '101':
          uni.showModal({
            content: '当前浏览器版本较低,请更换浏览器使用,推荐在微信中打开。'
          })
          break;
        case '201':
          uni.showModal({
            content: '麦克风权限被拒绝,请刷新页面后授权麦克风权限。'
          })
          break
        default:
          uni.showModal({
            content: '未知错误,请刷新页面重试'
          })
          break
      }
    },
    closePopup() {
      // 清空录音片段
      this.recordSegments = []
      this.currentSegment = null
      // 重置计时器
      this.seconds = 0
      this.minutes = 0
      this.hours = 0
      this.updateTimeDisplay()
      // 关闭弹窗
      this.$emit('close')
    },
    startTimer() {
      this.stopTimer();
      this.timer = setInterval(() => {
        this.seconds++;
        if (this.seconds >= 60) {
          this.seconds = 0;
          this.minutes++;
          if (this.minutes >= 60) {
            this.minutes = 0;
            this.hours++;
          }
        }
        this.updateTimeDisplay();
      }, 1000);
    },
    stopTimer() {
      if (this.timer) {
        clearInterval(this.timer);
        this.timer = null;
      }
    },
    updateTimeDisplay() {
      this.time = `${String(this.hours).padStart(2, '0')}:${String(this.minutes).padStart(2, '0')}:${String(this.seconds).padStart(2, '0')}`;
    },
    // 合并音频文件
    async mergeAudioFiles(audioUrls) {
      try {
        const audioContext = new (window.AudioContext || window.webkitAudioContext)();
        const audioBuffers = [];
        // 加载所有音频文件
        for (const url of audioUrls) {
          const response = await fetch(url);
          const arrayBuffer = await response.arrayBuffer();
          const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
          audioBuffers.push(audioBuffer);
        }
        // 计算总时长
        const totalLength = audioBuffers.reduce((acc, buffer) => acc + buffer.length, 0);
        // 创建新的音频缓冲区
        const mergedBuffer = audioContext.createBuffer(
          audioBuffers[0].numberOfChannels,
          totalLength,
          audioBuffers[0].sampleRate
        );
        // 合并音频数据
        let offset = 0;
        for (const buffer of audioBuffers) {
          for (let channel = 0; channel < buffer.numberOfChannels; channel++) {
            const channelData = buffer.getChannelData(channel);
            mergedBuffer.copyToChannel(channelData, channel, offset);
          }
          offset += buffer.length;
        }
        // 将合并后的音频转换为 Blob
        const wavBlob = await this.audioBufferToWav(mergedBuffer);
        const mergedUrl = URL.createObjectURL(wavBlob);
        return mergedUrl;
      } catch (error) {
        console.error('合并音频失败:', error);
        throw error;
      }
    },
    // 将 AudioBuffer 转换为 WAV 格式
    audioBufferToWav(buffer) {
      const numOfChan = buffer.numberOfChannels;
      const length = buffer.length * numOfChan * 2;
      const buffer2 = new ArrayBuffer(44 + length);
      const view = new DataView(buffer2);
      const channels = [];
      let sample;
      let offset = 0;
      let pos = 0;
      // 写入 WAV 文件头
      setUint32(0x46464952);                         // "RIFF"
      setUint32(36 + length);                        // 文件长度
      setUint32(0x45564157);                         // "WAVE"
      setUint32(0x20746d66);                         // "fmt " chunk
      setUint32(16);                                 // 长度 = 16
      setUint16(1);                                  // PCM (uncompressed)
      setUint16(numOfChan);
      setUint32(buffer.sampleRate);
      setUint32(buffer.sampleRate * 2 * numOfChan);  // avg. bytes/sec
      setUint16(numOfChan * 2);                      // block-align
      setUint16(16);                                 // 16-bit
      setUint32(0x61746164);                         // "data" - chunk
      setUint32(length);                             // chunk length
      // 写入音频数据
      for (let i = 0; i < buffer.numberOfChannels; i++) {
        channels.push(buffer.getChannelData(i));
      }
      while (pos < buffer.length) {
        for (let i = 0; i < numOfChan; i++) {
          sample = Math.max(-1, Math.min(1, channels[i][pos]));
          sample = (0.5 + sample < 0 ? sample * 32768 : sample * 32767) | 0;
          view.setInt16(44 + offset, sample, true);
          offset += 2;
        }
        pos++;
      }
      return new Blob([buffer2], { type: 'audio/wav' });
      function setUint16(data) {
        view.setUint16(pos, data, true);
        pos += 2;
      }
      function setUint32(data) {
        view.setUint32(pos, data, true);
        pos += 4;
      }
    },
    // 修改 onStop 方法
    async onStop() {
      console.log('停止录音')
      // 如果正在录音,先停止当前录音
      if (this.isRecording) {
        this.$refs.mumuRecorder.stop()
      }
      // 停止计时
      this.stopTimer()
      // 重置状态
      this.isRecording = false
      this.isPaused = false
      this.currentSegment = null
      // 处理录音片段
      if (this.recordSegments.length > 0) {
        try {
          uni.showLoading({
            title: '正在处理音频...'
          })
          // 获取所有录音片段的URL
          const audioUrls = this.recordSegments.map(segment => segment.url)
          console.log('准备合并的音频片段列表:', audioUrls)
          console.log('音频片段数量:', audioUrls.length)
          // 合并音频文件
          const mergedUrl = await this.mergeAudioFiles(audioUrls)
          // 创建合并后的音频数据对象
          const mergedAudioData = {
            url: this.recordSegments[this.recordSegments.length - 1].url,
            data: this.recordSegments[this.recordSegments.length - 1].data,
            duration: this.seconds + this.minutes * 60 + this.hours * 3600
          }
          uni.showToast({
            title: '录制成功',
            icon: 'success',
            duration: 2000
          })
          setTimeout(() => {
            this.$emit('submit', mergedAudioData)
          }, 2000)
          uni.hideLoading()
          this.closePopup()
        } catch (error) {
          console.error('音频合并失败:', error)
          uni.hideLoading()
          uni.showToast({
            title: '音频合并失败,请重试',
            icon: 'none',
            duration: 2000
          })
        }
      } else {
        uni.showToast({
          title: '未检测到录音文件',
          icon: 'none',
          duration: 2000
        })
      }
    }
  },
  beforeDestroy() {
    this.stopTimer();
  }
}
</script>
@@ -48,6 +356,9 @@
  background: #fff;
  border-radius: 24rpx 24rpx 0 0;
  padding: 40rpx 0 30rpx 0;
  position: relative;
  z-index: 9999;
  .header {
    display: flex;
    justify-content: center;
@@ -56,25 +367,30 @@
    font-size: 32rpx;
    font-weight: bold;
    margin-bottom: 80rpx;
    text {
      flex: 1;
      text-align: center;
    }
    .u-icon {
      position: absolute;
      right: 30rpx;
      top: 0;
    }
  }
  .record-anim {
    display: flex;
    justify-content: center;
    align-items: center;
    margin-bottom: 57rpx;
    .bars {
      display: flex;
      align-items: flex-end;
      height: 60rpx;
      .bar {
        width: 10rpx;
        margin: 0 4rpx;
@@ -84,26 +400,34 @@
      }
    }
  }
  .timer {
    text-align: center;
    font-size: 28rpx;
    color: #888;
    // margin-bottom: 30rpx;
  }
  .btns {
    display: flex;
    justify-content: space-around;
    align-items: center;
    padding: 40rpx 67rpx 76rpx 67rpx;
    // .u-button {
    //   width: 100rpx;
    //   height: 100rpx;
    //   border-radius: 50%;
    //   display: flex;
    //   justify-content: center;
    //   align-items: center;
    //   margin: 0 10rpx;
    // }
    padding: 40rpx 67rpx 6rpx 67rpx;
  }
}
</style>
::v-deep .uni-toast {
  z-index: 10099 !important;
}
.uni-sample-toast {
  z-index: 10099 !important;
}
::v-deep .uni-toast .uni-sample-toast {
  z-index: 10099 !important;
}
/deep/ .u-transition.u-fade-enter-to.u-fade-enter-active {
  z-index: 997 !important;
}
</style>