Electron 桌面应用录音功能导致页面蓝屏问题解决方案
本文记录了一次解决 Electron + Vue3 桌面应用录音功能导致页面崩溃问题的完整过程,涉及 macOS 权限配置、Web Audio API 兼容性处理、平台差异优化等多个方面。
问题描述
在开发一个基于 Electron + Vue3 的桌面应用时,遇到了一个严重的问题:当用户点击"开始录音"按钮时,应用页面会直接蓝屏(崩溃),特别是在 macOS 和 Windows 平台上。
问题表现
- macOS: 点击开始录音后,页面直接蓝屏,控制台显示 "DevTools was disconnected from the page"
- Windows: 同样的问题,渲染进程崩溃
- 错误信息 : 服务器返回
buffer size must be a multiple of element size错误
问题原因分析
1. 麦克风权限问题(macOS)
macOS 系统对应用访问麦克风有严格的权限控制,需要在应用配置中声明权限:
- 缺少
entitlements.mac.plist文件 package.json中未配置 entitlements- 应用启动时未主动请求麦克风权限
2. ScriptProcessor API 的兼容性问题
原始代码使用了 ScriptProcessor API 来处理音频数据:
audioProcessor = audioContext.createScriptProcessor(4096, 1, 1)
audioProcessor.onaudioprocess = function (event) {
// 处理音频数据
}
问题所在:
ScriptProcessor是一个已废弃的 API- 在 Electron 环境中,特别是某些版本,会导致渲染进程崩溃
- 这是导致页面蓝屏的主要原因
3. 音频格式不匹配
尝试使用 MediaRecorder API 时,服务器期望的是 PCM 16 位格式,但 MediaRecorder 输出的是编码后的格式(WebM/OGG),导致服务器无法处理。
解决方案
方案一:配置 macOS 权限(解决权限问题)
1. 创建 entitlements.mac.plist 文件
在 build/entitlements.mac.plist 中声明麦克风权限:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- 麦克风权限 -->
<key>com.apple.security.device.microphone</key>
<true/>
<!-- 音频输入权限 -->
<key>com.apple.security.device.audio-input</key>
<true/>
<!-- 允许应用访问用户选择的文件 -->
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<!-- 网络访问权限 -->
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.network.server</key>
<true/>
</dict>
</plist>
2. 在 package.json 中配置 entitlements
{
"build": {
"mac": {
"entitlements": "build/entitlements.mac.plist",
"entitlementsInherit": "build/entitlements.mac.plist",
"hardenedRuntime": true
}
}
}
3. 在主进程中请求权限
在 electron/main.js 中添加权限请求:
const { app, systemPreferences } = require('electron')
app.whenReady().then(async () => {
// macOS 权限处理
if (process.platform === 'darwin') {
try {
const micStatus = await systemPreferences.getMediaAccessStatus(
'microphone'
)
console.log('麦克风权限状态:', micStatus)
if (micStatus !== 'granted') {
console.log('请求麦克风权限...')
const result = await systemPreferences.askForMediaAccess('microphone')
console.log('麦克风权限请求结果:', result)
}
} catch (error) {
console.error('请求麦克风权限失败:', error)
}
}
// 处理媒体权限请求
app.on('web-contents-created', (event, contents) => {
contents.session.setPermissionRequestHandler(
(webContents, permission, callback) => {
if (
permission === 'media' ||
permission === 'microphone' ||
permission === 'camera'
) {
callback(true) // 允许权限
} else {
callback(false)
}
}
)
})
})
方案二:替换 ScriptProcessor 为 AnalyserNode(解决崩溃问题)
这是最关键的解决方案。使用 AnalyserNode + 定时器替代 ScriptProcessor:
1. 使用 AnalyserNode 获取音频数据
// 创建 AnalyserNode
analyserNode = audioContext.createAnalyser()
analyserNode.fftSize = 8192 // macOS 使用较大值,Windows 使用 4096
analyserNode.smoothingTimeConstant = 0.8
source.connect(analyserNode)
// 创建静音的 GainNode 作为终点
const gainNode = audioContext.createGain()
gainNode.gain.value = 0 // 静音输出
analyserNode.connect(gainNode)
gainNode.connect(audioContext.destination)
2. 使用定时器定期读取音频数据
const readInterval = 20 // 毫秒
const bufferLength = analyserNode.frequencyBinCount
const dataArray = new Float32Array(bufferLength)
audioProcessingInterval = setInterval(() => {
// 检查状态
if (!isRecording.value || !ws || ws.readyState !== WebSocket.OPEN) {
return
}
// Windows 上需要检查 AudioContext 状态
if (audioContext.state === 'suspended') {
audioContext.resume().catch(err => {
console.warn('恢复 AudioContext 失败:', err)
})
return
}
// 获取时域数据
try {
analyserNode.getFloatTimeDomainData(dataArray)
} catch (getDataError) {
console.error('获取音频数据失败:', getDataError)
return
}
// 转换为 PCM 16位格式
const pcmData = convertToPCM(dataArray, actualSampleRate, targetSampleRate)
// 添加到缓冲区并发送
audioBuffer.push(pcmData)
if (shouldSendBuffer()) {
sendAudioData()
}
}, readInterval)
3. PCM 转换函数
function convertToPCM(audioSamples, actualSampleRate, targetSampleRate) {
if (actualSampleRate === targetSampleRate) {
// 采样率匹配,直接转换
const pcmData = new Int16Array(audioSamples.length)
for (let i = 0; i < audioSamples.length; i++) {
const sample = Math.max(-1, Math.min(1, audioSamples[i]))
pcmData[i] = Math.round(sample * 32767)
}
return pcmData
} else {
// 需要重采样(线性插值)
const ratio = targetSampleRate / actualSampleRate
const newLength = Math.floor(audioSamples.length * ratio)
const pcmData = new Int16Array(newLength)
for (let i = 0; i < newLength; i++) {
const srcIndex = i / ratio
const index = Math.floor(srcIndex)
const fraction = srcIndex - index
let value
if (index + 1 < audioSamples.length) {
value =
audioSamples[index] * (1 - fraction) +
audioSamples[index + 1] * fraction
} else {
value = audioSamples[index] || 0
}
value = Math.max(-1, Math.min(1, value))
pcmData[i] = Math.round(value * 32767)
}
return pcmData
}
}
方案三:Windows 平台特殊处理
Windows 平台需要额外的兼容性处理:
// 检测平台
const isWindows = navigator.platform.toLowerCase().includes('win')
// Windows 上使用较小的 FFT 大小
analyserNode.fftSize = isWindows ? 4096 : 8192
analyserNode.smoothingTimeConstant = isWindows ? 0.5 : 0.8
// Windows 上延迟连接 destination,避免崩溃
if (isWindows) {
setTimeout(() => {
try {
gainNode.connect(audioContext.destination)
} catch (destError) {
console.warn('连接 destination 失败(Windows):', destError)
// Windows 上如果连接失败,不连接 destination 也可以工作
}
}, 100)
} else {
gainNode.connect(audioContext.destination)
}
方案四:添加全局错误处理
防止未捕获的异常导致崩溃:
onMounted(() => {
// 添加全局错误处理
window.addEventListener('error', event => {
console.error('全局错误捕获:', event.error)
// 如果是音频相关错误,尝试清理资源
if (
event.error &&
(event.error.message?.includes('AudioContext') ||
event.error.message?.includes('audio'))
) {
console.warn('检测到音频相关错误,清理资源')
if (isRecording.value) {
stopRecording()
}
}
})
// 捕获未处理的 Promise 拒绝
window.addEventListener('unhandledrejection', event => {
console.error('未处理的 Promise 拒绝:', event.reason)
event.preventDefault()
})
})
完整代码示例
开始录音函数
const startRecording = async () => {
// 检查 WebSocket 连接
if (!ws || ws.readyState !== WebSocket.OPEN) {
await connectWebSocket()
}
// 检查是否支持 getUserMedia
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
ElMessage.error('您的浏览器不支持录音功能')
return
}
try {
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
sampleRate: 16000,
channelCount: 1,
echoCancellation: true,
noiseSuppression: true,
},
})
// 创建 AudioContext
audioContext = new (window.AudioContext || window.webkitAudioContext)()
// 检查状态
if (audioContext.state === 'suspended') {
await audioContext.resume()
}
// 创建音频源
const source = audioContext.createMediaStreamSource(stream)
// 创建 AnalyserNode
const isWindows = navigator.platform.toLowerCase().includes('win')
analyserNode = audioContext.createAnalyser()
analyserNode.fftSize = isWindows ? 4096 : 8192
analyserNode.smoothingTimeConstant = isWindows ? 0.5 : 0.8
source.connect(analyserNode)
// 创建静音的 GainNode
const gainNode = audioContext.createGain()
gainNode.gain.value = 0
analyserNode.connect(gainNode)
// Windows 上延迟连接
if (isWindows) {
setTimeout(() => {
try {
gainNode.connect(audioContext.destination)
} catch (e) {
console.warn('连接 destination 失败:', e)
}
}, 100)
} else {
gainNode.connect(audioContext.destination)
}
// 清空缓冲区
audioBuffer = []
// 设置定时器读取音频数据
const targetSampleRate = 16000
const actualSampleRate = audioContext.sampleRate
const bufferLength = analyserNode.frequencyBinCount
const dataArray = new Float32Array(bufferLength)
const readInterval = 20 // 毫秒
audioProcessingInterval = setInterval(() => {
try {
if (!isRecording.value || !ws || ws.readyState !== WebSocket.OPEN) {
return
}
// 检查 AudioContext 状态
if (audioContext.state === 'suspended') {
audioContext.resume().catch(() => {})
return
}
// 获取音频数据
analyserNode.getFloatTimeDomainData(dataArray)
// 转换为 PCM
const pcmData = convertToPCM(
dataArray,
actualSampleRate,
targetSampleRate
)
// 添加到缓冲区
audioBuffer.push(pcmData)
// 检查是否需要发送
let totalSize = 0
for (let buf of audioBuffer) {
totalSize += buf.length * 2
}
if (totalSize >= BUFFER_SIZE) {
// 合并并发送
const mergedData = mergeBuffers(audioBuffer)
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(mergedData.buffer)
}
audioBuffer = []
}
} catch (error) {
console.error('音频处理错误:', error)
}
}, readInterval)
// 保存引用
audioStream = stream
isRecording.value = true
startRecordingTimer()
console.log('🎤 录音已开始')
} catch (error) {
console.error('启动录音失败:', error)
ElMessage.error('无法访问麦克风,请检查权限设置')
isRecording.value = false
}
}
停止录音函数
const stopRecording = () => {
if (isRecording.value) {
isRecording.value = false
try {
// 清理定时器
if (audioProcessingInterval) {
clearInterval(audioProcessingInterval)
audioProcessingInterval = null
}
// 清理音频节点
if (analyserNode) {
analyserNode.disconnect()
analyserNode = null
}
if (audioContext) {
if (audioContext.state !== 'closed') {
audioContext.close()
}
audioContext = null
}
if (audioStream) {
audioStream.getTracks().forEach(track => track.stop())
audioStream = null
}
// 清空缓冲区
audioBuffer = []
// 关闭 WebSocket
if (ws && ws.readyState === WebSocket.OPEN) {
ws.close(1000, '正常关闭')
}
} catch (error) {
console.error('停止录音时发生错误:', error)
}
}
}
关键技术点总结
1. ScriptProcessor vs AnalyserNode
|--------|---------------------|------------------|
| 特性 | ScriptProcessor | AnalyserNode |
| 状态 | 已废弃 | 推荐使用 |
| 稳定性 | 在 Electron 中容易崩溃 | 稳定可靠 |
| 性能 | 回调函数可能阻塞 | 非阻塞,性能更好 |
| 使用方式 | 事件回调 | 主动读取 |
2. 平台差异处理
- macOS: 需要配置 entitlements,主动请求权限
- Windows: 需要较小的 FFT 大小,延迟连接 destination
- 通用: 都需要错误处理和状态检查
3. 音频格式转换
- 服务器需要 PCM 16 位格式
- 需要处理采样率转换(如果实际采样率与目标采样率不同)
- 确保 buffer 大小是 2 的倍数(Int16 每个元素 2 字节)
最终效果
经过以上修复:
✅ macOS : 应用启动时会自动请求麦克风权限,点击开始录音不再崩溃✅ Windows : 使用兼容性配置,避免渲染进程崩溃✅ 实时性 : 保持原有的实时识别速度✅ 准确性: 音频数据质量稳定,识别准确
经验总结
- 权限配置很重要: 特别是 macOS,必须在配置文件中声明权限
- 避免使用废弃 API: ScriptProcessor 虽然能用,但在 Electron 中不稳定
- 平台差异: 不同平台需要不同的处理策略
- 错误处理: 完善的错误处理可以避免应用崩溃
- 测试覆盖: 需要在多个平台上测试,不能只在一个平台验证