基于 Vue 3 的屏幕音频捕获实现:从原理到实践

前言

在日常开发中,我们经常会遇到需要录制音频的场景,比如在线会议、语音笔记、教学录制等。传统的音频录制通常只能捕获麦克风输入,但在某些场景下,我们可能需要录制系统音频,也就是电脑正在播放的声音。

今天,我们就来实现一个基于 Vue 3 Composition API 的屏幕音频捕获 Hook,它能够录制系统音频并生成音频文件。

技术原理

在开始编码之前,我们先了解几个关键的技术概念:

1. MediaDevices.getDisplayMedia()

这是实现屏幕共享和系统音频捕获的核心 API。它允许用户选择并授权捕获屏幕内容,同时可以选择捕获系统音频。

2. MediaRecorder

Web API 提供的媒体录制接口,可以将 MediaStream 录制为指定格式的文件。

核心实现

让我们逐步分析这个音频捕获 Hook 的实现:

1. 状态管理

javascript 复制代码
const inCapture = ref(false);
const screenStream = ref(null);
const recorder = ref(null);
let timer = null;

我们使用 Vue 的 ref 来管理捕获状态、屏幕流和录制器实例,确保状态的响应式更新。

2. 媒体流获取

javascript 复制代码
function invokeGetDisplayMedia({success, fail}) {
    var constraints = {
        video: true,
        audio: true
    };
    
    // 兼容不同浏览器的 API
    if(navigator?.mediaDevices?.getDisplayMedia) {
        navigator.mediaDevices.getDisplayMedia(constraints).then(success).catch(fail);
    } else if(navigator.getDisplayMedia) {
        navigator.getDisplayMedia(constraints).then(success).catch(fail);
    } else {
        fail("当前环境不支持该操作");
    }
}

这里我们做了浏览器兼容性处理,确保在不同环境下都能正常调用 API。

3. 音频捕获核心逻辑

javascript 复制代码
function captureScreen() {
    return new Promise((resolve, reject) => {
        invokeGetDisplayMedia({
            success: (screen) => {
                inCapture.value = true;
                screenStream.value = screen;
                
                // 监听流结束事件
                screenStream.value.onended = () => {
                    reset();
                }
                screenStream.value.oninactive = () => {
                    stopCapture();
                }
                
                // 检查音频轨道
                const audioTracks = screenStream.value.getAudioTracks();
                if (audioTracks.length === 0) {
                    console.error("没有检测到音频,请检查分享音频选项是否开启");
                    reset();
                    reject("没有检测到音频,请检查分享音频选项是否开启");
                    return;
                }
                
                // 创建纯音频流
                const audioStream = new MediaStream(audioTracks);

                nextTick(() => {
                    // 配置录制参数
                    const options = {
                        mimeType: 'audio/webm;codecs=opus',
                        audioBitsPerSecond: 128000
                    };
                    
                    // 格式兼容性回退
                    if (!MediaRecorder.isTypeSupported(options.mimeType)) {
                        options.mimeType = 'audio/webm';
                    }
                    
                    // 创建录制器
                    recorder.value = new MediaRecorder(audioStream, options);
                    recorder.value.start();
                    
                    // 处理录制数据
                    recorder.value.ondataavailable = (event) => {
                        if (event?.data?.size > 0) {
                            const file = new File([event.data], 
                                `audio_capture_${new Date().getTime()}.opus`, 
                                { type: 'audio/opus' });
                            reset();
                            resolve(file);
                        } else {
                            console.error("录制失败");
                            reset();
                            reject("录制失败");
                        }
                    }
                    
                    // 设置最大录制时间
                    timer = setTimeout(() => {
                        stopCapture();
                    }, maxTime);
                })
            },
            fail: (err) => {
                reject(err);
            }
        })
    })
}

4. 资源清理

javascript 复制代码
function reset() {
    screenStream.value?.getTracks()?.forEach(track => track.stop());
    screenStream.value = null;
    inCapture.value = false;
    recorder.value = null;
    clearTimeout(timer);
    timer = null;
}

onUnmounted(() => {
    reset();
})

确保在组件卸载时正确清理所有资源,避免内存泄漏。

5. 完整代码

javascript 复制代码
import { nextTick, onUnmounted, ref } from "vue";

export function useAudioCapture({maxTime = 60 * 1000}) {
    const inCapture = ref(false);
    const screenStream = ref(null);
    const recorder = ref(null);
    let timer = null
  
    function stopCapture() {
        recorder.value?.stop();
    }
  
    function reset() {
        screenStream.value?.getTracks()?.forEach(track => track.stop());
        screenStream.value = null;
        inCapture.value = false;
        recorder.value = null;
        clearTimeout(timer);
        timer = null;
    }

    onUnmounted(() => {
        reset();
    })

  
    function captureScreen() {
        return new Promise((resolve, reject) => {
            invokeGetDisplayMedia({
                success: (screen) => {
                    inCapture.value = true;
                    screenStream.value = screen;
                    
                    screenStream.value.onended = () => {
                        reset();
                    }
                    screenStream.value.oninactive = () => {
                        stopCapture();
                    }
                   
                    const audioTracks = screenStream.value.getAudioTracks();
                    if (audioTracks.length === 0) {
                        console.error("没有检测到音频,请检查分享音频选项是否开启");
                        reset();
                        reject("没有检测到音频,请检查分享音频选项是否开启");
                        return;
                    }
                    const audioStream = new MediaStream(audioTracks);
   
                    nextTick(() => {
                        const options = {
                            mimeType: 'audio/webm;codecs=opus',
                            audioBitsPerSecond: 128000
                        };
                        if (!MediaRecorder.isTypeSupported(options.mimeType)) {
                            options.mimeType = 'audio/webm';
                        }
                        recorder.value = new MediaRecorder(audioStream, options);
                        recorder.value.start();
                        recorder.value.ondataavailable = (event) => {
                            if (event?.data?.size > 0) {
                                const file = new File([event.data], `audio_capture_${new Date().getTime()}.opus`, { type: 'audio/opus' });
                                reset();
                                resolve(file);
                            } else {
                                console.error("录制失败");
                                reset();
                                reject("录制失败");
                            }
                        }
                        timer = setTimeout(() => {
                            stopCapture();
                        }, maxTime);
                    })
                },
                fail: (err) => {
                    reject(err);
                }
            })
        })
    }
   
    function invokeGetDisplayMedia({success, fail}) {
        var constraints = {
            video : true,
            audio : true
        };
        if(navigator?.mediaDevices?.getDisplayMedia) {
            navigator.mediaDevices.getDisplayMedia(constraints).then(success).catch(fail);
        } else if(navigator.getDisplayMedia) {
            navigator.getDisplayMedia(constraints).then(success).catch(fail);
        } else {
            fail("当前环境不支持该操作");
        }
    }

    return {
        reset,
        captureScreen,
        stopCapture,
        get InCapture(){ return inCapture.value},
    }
}

在组件中使用

下面是一个使用示例,展示如何在 Vue 组件中集成这个 Hook:

vue 复制代码
<template>
  <div class="audio-recorder">
    <button 
      @click="startRecording" 
      :disabled="inCapture"
      class="record-button"
    >
      {{ inCapture ? '录制中...' : '开始录制' }}
    </button>
    
    <button 
      @click="stopRecording" 
      :disabled="!inCapture"
      class="stop-button"
    >
      停止录制
    </button>
    
    <div v-if="lastRecordedFile" class="recording-info">
      <p>录制完成!文件: {{ lastRecordedFile.name }}</p>
      <audio :src="audioUrl" controls class="audio-player" />
    </div>
  </div>
</template>

<script setup>
import { ref } from 'vue';
import { useAudioCapture } from './useAudioCapture';

const { captureScreen, stopCapture, get InCapture() { return inCapture.value } } = 
  useAudioCapture({ maxTime: 5 * 60 * 1000 }); // 5分钟限制

const inCapture = ref(false);
const lastRecordedFile = ref(null);
const audioUrl = ref('');

const startRecording = async () => {
  try {
    const file = await captureScreen();
    lastRecordedFile.value = file;
    audioUrl.value = URL.createObjectURL(file);
  } catch (error) {
    console.error('录制失败:', error);
    alert(`录制失败: ${error}`);
  }
};

const stopRecording = () => {
  stopCapture();
};
</script>

<style scoped>
.audio-recorder {
  padding: 20px;
  text-align: center;
}

.record-button, .stop-button {
  margin: 0 10px;
  padding: 10px 20px;
  border: none;
  border-radius: 5px;
  cursor: pointer;
}

.record-button {
  background-color: #4CAF50;
  color: white;
}

.record-button:disabled {
  background-color: #cccccc;
}

.stop-button {
  background-color: #f44336;
  color: white;
}

.audio-player {
  margin-top: 20px;
  width: 100%;
  max-width: 400px;
}
</style>

关键特性与优化

1. 音频格式选择

我们优先选择 audio/webm;codecs=opus 格式,因为:

  • Opus 编码:提供更好的音质和压缩比
  • WebM 容器:现代浏览器支持良好
  • 兼容性回退:在不支持时回退到基础格式

2. 错误处理

javascript 复制代码
// 检查音频轨道是否存在
const audioTracks = screenStream.value.getAudioTracks();
if (audioTracks.length === 0) {
    console.error("没有检测到音频,请检查分享音频选项是否开启");
    reset();
    reject("没有检测到音频,请检查分享音频选项是否开启");
    return;
}

3. 自动超时保护

通过 maxTime 参数防止无限期录制,保护系统资源。

浏览器兼容性说明

浏览器 支持情况 注意事项
Chrome 74+ ✅ 完全支持 推荐使用
Edge 79+ ✅ 完全支持 基于 Chromium
Firefox 66+ ✅ 基本支持 可能需要配置
Safari ⚠️ 部分支持 有限的功能

总结

通过这个 Vue 3 Hook,我们实现了一个功能完整、易于使用的屏幕音频捕获解决方案。它不仅提供了基础的录制功能,还考虑了错误处理、资源管理和用户体验等多个方面。

这种基于 Composition API 的实现方式,体现了 Vue 3 在逻辑复用和代码组织方面的优势,可以作为其他媒体处理功能的参考实现。

希望这篇文章能够帮助你在实际项目中更好地处理音频录制需求,如果有任何问题或建议,欢迎在评论区讨论!

相关推荐
孟祥_成都25 分钟前
深入 Nestjs 底层概念(1):依赖注入和面向切面编程 AOP
前端·node.js·nestjs
let_code26 分钟前
CopilotKit-丝滑连接agent和应用-理论篇
前端·agent·ai编程
Apifox1 小时前
Apifox 11 月更新|AI 生成测试用例能力持续升级、JSON Body 自动补全、支持为响应组件添加描述和 Header
前端·后端·测试
木易士心1 小时前
深入剖析:按下 F5 后,浏览器前端究竟发生了什么?
前端·javascript
在掘金801101 小时前
vue3中使用medium-zoom
前端·vue.js
xump1 小时前
如何在DevTools选中调试一个实时交互才能显示的元素样式
前端·javascript·css
折翅嘀皇虫1 小时前
fastdds.type_propagation 详解
java·服务器·前端
Front_Yue1 小时前
深入探究跨域请求及其解决方案
前端·javascript
wordbaby1 小时前
React Native 进阶实战:基于 Server-Driven UI 的动态表单架构设计
前端·react native·react.js