<template>
|
<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=""></image>
|
</view>
|
<view class="timer">{{ time }}</view>
|
<view class="btns">
|
<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,
|
default: false
|
}
|
},
|
data() {
|
return {
|
time: '00:00:00',
|
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: {
|
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>
|
|
<style scoped lang="scss">
|
.voice-popup {
|
background: #fff;
|
border-radius: 24rpx 24rpx 0 0;
|
padding: 40rpx 0 30rpx 0;
|
position: relative;
|
z-index: 9999;
|
|
.header {
|
display: flex;
|
justify-content: center;
|
align-items: center;
|
position: relative;
|
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;
|
background: linear-gradient(180deg, #ff4948 0%, #fc8d55 100%);
|
border-radius: 6rpx;
|
transition: height 0.3s;
|
}
|
}
|
}
|
|
.timer {
|
text-align: center;
|
font-size: 28rpx;
|
color: #888;
|
}
|
|
.btns {
|
display: flex;
|
justify-content: space-around;
|
align-items: center;
|
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>
|