| | |
| | | <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, |
| | |
| | | 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 // 当前录音片段 |
| | | } |
| | | }, |
| | | methods: { |
| | | closePopup() { |
| | | this.$emit('update:show', false) |
| | | computed: { |
| | | getRecordButtonSrc() { |
| | | if (this.isPaused) { |
| | | return '/static/Appeal/start.png' |
| | | } |
| | | return this.isRecording ? '/static/Appeal/stop.png' : '/static/Appeal/start.png' |
| | | } |
| | | }, |
| | | onPlay() {}, |
| | | onPause() {}, |
| | | onStop() {}, |
| | | mounted() { |
| | | }, |
| | | methods: { |
| | | handlerSave() { |
| | | let tag = document.createElement('a') |
| | | tag.href = this.recorder.localUrl |
| | | tag.download = '录音' |
| | | tag.click() |
| | | }, |
| | | 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> |
| | |
| | | background: #fff; |
| | | border-radius: 24rpx 24rpx 0 0; |
| | | padding: 40rpx 0 30rpx 0; |
| | | position: relative; |
| | | z-index: 9999; |
| | | |
| | | .header { |
| | | display: flex; |
| | | justify-content: center; |
| | |
| | | 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; |
| | |
| | | } |
| | | } |
| | | } |
| | | |
| | | .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; |
| | | } |
| | | } |
| | | |
| | | ::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> |