场景:
实现web端录音,调用无人机上的外接喇叭喊话。
整个流程:
1、web端录音,将文件已blob格式传给后端,上传到minio。
2、前端页面点击喊话按钮,调用后端接口,后端通过mqtt给c++传递消息。
3、c++通过蓝牙连接喇叭,通过无人机上的边缘盒子给无人机下指令,调用喇叭播放前端录音,实现喊话功能。(我做的是前端,c++这一块儿我不是很清楚)。
这里分享下,vue3实现的喊话组件:
html
<template>
<div @click="handleClick">
<slot></slot>
</div>
<el-dialog v-model="dialogVisible" title="远程喊话" width="300" :before-close="handleClose"
:close-on-click-modal="false" top="30vh">
<!-- 录音控制 -->
<div class="recording-control" v-if="audioFiles.length <= 0">
<div v-if="!isRecording">
<img src="../assets/imgs/luyin.png" alt="shout image" style="width: 50px;height: 50px;cursor: pointer;"
@click="toggleRecording" />
</div>
<div v-else class="is-recording">
<!-- <RecordingAnimation></RecordingAnimation> -->
<!-- 音量可视化柱体 -->
<div class="volume-visualizer">
<div
v-for="(height, index) in volumeBars"
:key="index"
class="volume-bar"
:style="{ height: `${height}px`, opacity: height > 0 ? 1 : 0 }"
></div>
</div>
<span class="recording-time">{{ recordingTimeFormat }}</span>
<span class="end-recording" @click="toggleRecording">结束录音</span>
</div>
</div>
<!-- 已录音内容 -->
<!-- {{ isRecording }}, {{ audioFiles.length }} -->
<div v-if="!isRecording && audioFiles.length > 0" class="hanhua">
<div class="top">
<div class="audio-player" @click="togglePlay">
<svg v-if="!isPlaying" class="audio-icon" viewBox="0 0 24 24" fill="none"
xmlns="http://www.w3.org/2000/svg">
<path d="M8 5V19L19 12L8 5Z" fill="#333" />
</svg>
<svg v-else class="audio-icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="8" y="8" width="4" height="8" fill="#333" />
<rect x="13" y="10" width="4" height="4" fill="#333" />
</svg>
<span class="audio-duration">{{ timeFormat2 }}</span>
<audio ref="audioRef" :src="audioFiles[audioFiles.length - 1].url"
@ended="isPlaying = false"></audio>
</div>
<div>
<img v-if="poopMode" src="../assets/imgs/xunhuan.png" alt="shout image"
style="width: 35px;height: 35px;cursor: pointer;" title="循环喊话" @click="toggleMode" />
<img v-else src="../assets/imgs/xunhuan-b.png" alt="shout image"
style="width: 35px;height: 35px;cursor: pointer;" title="单次喊话" @click="toggleMode" />
</div>
<div>
<img v-if="!isHanhua" src="../assets/imgs/hanhua.png" alt="shout image"
style="width: 28px;height: 28px;cursor: pointer;" title="喊话" @click="startShout" />
<img v-else src="../assets/imgs/hanhua-is.png" alt="shout image"
style="width: 28px;height: 28px;cursor: pointer;" title="喊话" @click="endShout" />
</div>
</div>
<div class="bottom">
<span @click="handleReset">重录</span>
</div>
</div>
</el-dialog>
<!-- 自定义弹窗 -->
<div v-if="showCustomModal" class="custom-modal">
<div class="modal-content">
<h3>提示</h3>
<p>您有未保存的数据,确定要离开吗?</p>
<button @click="confirmClose">确定离开</button>
<button @click="cancelClose">取消</button>
</div>
</div>
</template>
<script setup>
import { onMounted, onUnmounted, ref, watch } from 'vue'
import { audioUpload, beginShout, stopShout } from '@/api/hanhua'
import { ElMessage, ElMessageBox } from 'element-plus'
import RecordingAnimation from '@/components/RecordingAnimation.vue'
const props = defineProps(['deviceId']);
const dialogVisible = ref(false);
const handleClick = () => {
dialogVisible.value = true;
}
// 音量可视化相关
const volumeBars = ref(new Array(20).fill(0)); // 12个柱体
const audioContext = ref(null);
const analyser = ref(null);
const volumeInterval = ref(null);
const flag = ref(false);
const handleClose = (done) => {
if (isRecording.value) {
flag.value = true;
stopRecording();
stopVolumeMonitoring();
audioFiles.value = [];
isRecording.value = false;
recordingTime.value = 0;
recordingTimeFormat.value = '00:00';
clearInterval(timer.value);
volumeBars.value = volumeBars.value.map(() => 0);
}
done();
};
// 录音计时
const recordingTime = ref(0);
const recordingTimeFormat = ref("00:00");
const timeFormat2 = ref('');
const timer = ref(null);
const formatSecondsToMinSec = (totalSeconds) => {
if (isNaN(totalSeconds) || totalSeconds < 0) return '0"';
const seconds = Math.floor(totalSeconds);
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return minutes > 0
? `${minutes}'${remainingSeconds.toString().padStart(2, '0')}''`
: `${remainingSeconds}''`;
}
const startRecordingTime = () => {
timer.value = setInterval(() => {
if (isRecording.value) {
recordingTime.value++;
const minutes = Math.floor(recordingTime.value / 60);
const seconds = recordingTime.value % 60;
recordingTimeFormat.value = `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
}
}, 1000)
}
const stopRecordingTime = () => {
flag.value = false;
clearInterval(timer.value);
timeFormat2.value = formatSecondsToMinSec(recordingTime.value);
recordingTimeFormat.value = '00:00';
}
// 录音核心逻辑
const isRecording = ref(false);
const mediaRecorder = ref(null);
const audioChunks = ref([]);
const audioFiles = ref([]);
const audioSrc = ref('');
// 初始化音量监测
const initVolumeMonitoring = (stream) => {
try {
audioContext.value = new (window.AudioContext || window.webkitAudioContext)();
analyser.value = audioContext.value.createAnalyser();
analyser.value.fftSize = 256; // 分析精度
const source = audioContext.value.createMediaStreamSource(stream);
source.connect(analyser.value);
// 实时更新音量柱体
const bufferLength = analyser.value.frequencyBinCount;
const dataArray = new Uint8Array(bufferLength);
volumeInterval.value = setInterval(() => {
analyser.value.getByteFrequencyData(dataArray);
// 计算12个柱体的高度(将频率数据分组)
const groupSize = Math.floor(bufferLength / volumeBars.value.length);
volumeBars.value = volumeBars.value.map((_, index) => {
const start = index * groupSize;
const end = start + groupSize;
const group = dataArray.slice(start, end);
const avg = group.reduce((sum, val) => sum + val, 0) / group.length;
return Math.min(avg * 1.2, 30); // 限制最大高度30px
});
}, 50); // 50ms更新一次
} catch (error) {
console.error("音量监测初始化失败:", error);
}
};
// 停止音量监测
const stopVolumeMonitoring = () => {
if (volumeInterval.value) {
clearInterval(volumeInterval.value);
volumeInterval.value = null;
}
if (audioContext.value) {
audioContext.value.close();
audioContext.value = null;
}
volumeBars.value = volumeBars.value.map(() => 0);
};
// 上传录音文件
const uploadAudio = async (audioBlob) => {
const formData = new FormData();
formData.append('file', audioBlob);
try {
const res = await audioUpload(formData);
audioSrc.value = res.data;
} catch (error) {
ElMessage.error('录音上传失败');
}
};
// 开始录音
const startRecording = async () => {
if (isRecording.value) return;
try {
audioChunks.value = [];
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
sampleRate: { ideal: 16000 },
channelCount: { ideal: 1 },
echoCancellation: true,
noiseSuppression: true
}
});
// 初始化音量监测
initVolumeMonitoring(stream);
mediaRecorder.value = new MediaRecorder(stream, { mimeType: 'audio/webm' });
mediaRecorder.value.ondataavailable = (e) => {
audioChunks.value.push(e.data);
};
mediaRecorder.value.onstop = () => {
stopVolumeMonitoring();
if (!flag.value) {
const audioBlob = new Blob(audioChunks.value, { type: "audio/webm" });
uploadAudio(audioBlob);
const audioURL = URL.createObjectURL(audioBlob);
audioFiles.value.push({
name: `recording_${Date.now()}.${getFileExtension(audioBlob.type)}`,
url: audioURL,
});
}
audioChunks.value = [];
};
mediaRecorder.value.start();
isRecording.value = true;
} catch (error) {
console.error("录音错误:", error);
ElMessage.error('录音初始化失败,请检查麦克风权限');
}
};
// 停止录音
const stopRecording = () => {
isRecording.value = false;
if (mediaRecorder.value) {
mediaRecorder.value.stop();
mediaRecorder.value.stream.getTracks().forEach(track => track.stop());
mediaRecorder.value = null;
}
};
const getFileExtension = (mimeType) => {
const extensions = {
"audio/webm": "webm",
"audio/ogg": "ogg",
"audio/mp4": "mp4",
"audio/mpeg": "mp3",
"audio/wav": "wav",
};
return extensions[mimeType] || "webm";
};
const toggleRecording = () => {
if (!isRecording.value) {
startRecording();
startRecordingTime();
} else {
if (mediaRecorder.value) {
mediaRecorder.value.stop();
mediaRecorder.value.stream.getTracks().forEach((track) => track.stop());
}
stopRecordingTime();
isPlaying.value = false;
}
isRecording.value = !isRecording.value;
};
// 播放相关
const audioRef = ref(null);
const isPlaying = ref(false);
const poopMode = ref(false);
const isHanhua = ref(false);
let curTaskId;
const togglePlay = () => {
if (isPlaying.value) {
audioRef.value.pause();
} else {
audioRef.value.currentTime = 0;
audioRef.value.play().catch(err => console.error('播放失败:', err));
}
isPlaying.value = !isPlaying.value;
};
const toggleMode = () => {
if (isHanhua.value) return;
poopMode.value = !poopMode.value;
};
const startShout = () => {
if (!audioSrc.value) {
ElMessage.warning('请先完成录音上传');
return;
}
const params = {
audioData: audioSrc.value,
durationNum: poopMode.value ? 0 : 1,
interval: 0,
deviceId: props.deviceId,
};
beginShout(params).then(res => {
isHanhua.value = true;
curTaskId = res.data.taskId;
if (!poopMode.value) {
setTimeout(() => {
isHanhua.value = false;
}, recordingTime.value * 1000);
}
}).catch(err => {
ElMessage.warning('喊话失败请重试!');
isHanhua.value = false;
});
};
const endShout = () => {
const params = {
taskId: curTaskId,
deviceId: props.deviceId
};
stopShout(params).then(res => {
isHanhua.value = false;
}).catch(err => {
ElMessage.warning('停止喊话失败,请重试!');
isHanhua.value = false;
});
};
const handleReset = () => {
if (isHanhua.value) {
ElMessage.warning('正在喊话中,请喊话结束后再重新尝试!');
return;
}
audioFiles.value = [];
audioSrc.value = '';
isRecording.value = false;
recordingTime.value = 0;
recordingTimeFormat.value = '00:00';
volumeBars.value = volumeBars.value.map(() => 0);
};
// 组件卸载时清理资源
onUnmounted(() => {
stopRecording();
stopVolumeMonitoring();
clearInterval(timer.value);
});
</script>
<style lang="scss" scoped>
.recording-control {
width: 100%;
height: 100px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
.is-recording {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: relative;
.recording-time {
position: absolute;
top: -20px;
left: 50%;
transform: translateX(-50%);
font-size: 18px;
margin-bottom: 10px;
}
.end-recording {
color: #2ca3dc;
cursor: pointer;
margin-top: 10px;
}
}
}
// 音量可视化样式
.volume-visualizer {
display: flex;
justify-content: center;
align-items: flex-end;
// align-items: center;
height: 60px;
gap: 3px;
// margin: 10px 0;
// padding: 10px 0;
}
.volume-bar {
width: 6px;
background: linear-gradient(to top, #2ca3dc, #4fc3f7);
border-radius: 3px;
transition: height 0.05s ease, opacity 0.2s ease;
min-height: 2px;
}
.hanhua {
.top {
display: flex;
justify-content: center;
align-items: center;
gap: 10px;
margin-bottom: 15px;
}
.bottom {
display: flex;
justify-content: center;
align-items: center;
span {
color: #2ca3dc;
cursor: pointer;
}
}
}
.audio-player {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border: 1px solid #ddd;
border-radius: 4px;
cursor: pointer;
width: fit-content;
min-width: 100px;
}
.audio-icon {
width: 20px;
height: 20px;
}
.audio-duration {
font-size: 14px;
color: #666;
}
.custom-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
.modal-content {
background: white;
padding: 20px;
border-radius: 8px;
text-align: center;
button {
margin: 0 10px;
padding: 5px 15px;
}
}
}
</style>
注意:
因为浏览器录音需要获取麦克风的权限,仅在开发环境下生效(访问localhost或者127.0.0.1),如果部署到服务器,会有安全性检查。
解决方案有两种:
A、浏览器配置:(不是很推荐,也很麻烦,用户不知道)
1.Chrome: 在地址栏中输入 chrome://flags/
2.输入unsafely-treat-insecure-origin-as-secure,然后添加你要信任的URL(如 http://192.168.1.7:8010),点启用
3.最后重启浏览器后该站点将被视为安全,可以正常识别