前端实现录音,获取流分析音量大小,设置相应的动画

场景:

实现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.最后重启浏览器后该站点将被视为安全,可以正常识别

B、将前端页面部署为https服务

相关推荐
阿民不加班42 分钟前
【React】使用browser-image-compression在上传前压缩图片、react上传图片压缩
前端·javascript·react.js
虎子_layor1 小时前
小程序登录到底是怎么工作的?一次请求背后的三方信任链
前端·后端
草字1 小时前
css 父节点设置display: flex; align-items: center;,子节点如何跟随其他子节点撑高的高度
前端·javascript·css
我命由我123451 小时前
微信小程序 - 页面跳转并传递参数(使用路由参数、使用全局变量、使用本地存储、使用路由参数结合本地存储)
开发语言·前端·javascript·微信小程序·小程序·前端框架·js
DJ斯特拉1 小时前
日志技术Logback
java·前端·logback
HIT_Weston1 小时前
49、【Ubuntu】【Gitlab】拉出内网 Web 服务:http.server 单/多线程分析(一)
前端·ubuntu·gitlab
涔溪1 小时前
Vue3 中ref和reactive的核心区别是什么?
前端·vue.js·typescript
天意__1 小时前
Flutter开发,scroll_to_index适配flutter_list_view
前端·flutter
吉星9527ABC1 小时前
表示离散量的echarts图型示例
前端·arcgis·echarts·离散量web展示