Electron 桌面应用打开录音功能导致页面蓝屏问题解决方案

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 : 使用兼容性配置,避免渲染进程崩溃✅ 实时性 : 保持原有的实时识别速度✅ 准确性: 音频数据质量稳定,识别准确

经验总结

  1. 权限配置很重要: 特别是 macOS,必须在配置文件中声明权限
  2. 避免使用废弃 API: ScriptProcessor 虽然能用,但在 Electron 中不稳定
  3. 平台差异: 不同平台需要不同的处理策略
  4. 错误处理: 完善的错误处理可以避免应用崩溃
  5. 测试覆盖: 需要在多个平台上测试,不能只在一个平台验证

参考资源

相关推荐
编程猪猪侠2 小时前
手写js轮播图效果参考
开发语言·javascript·ecmascript
Swift社区2 小时前
Vue Router 越写越乱,如何架构设计?
前端·javascript·vue.js
白兰地空瓶2 小时前
JavaScript 列表转树(List to Tree)详解:前端面试中如何从递归 O(n²) 优化到一次遍历 O(n)
javascript·算法·面试
大布布将军2 小时前
⚡️ 后端工程师的护甲:TypeScript 进阶与数据建模
前端·javascript·程序人生·typescript·前端框架·node.js·改行学it
chilavert3183 小时前
技术演进中的开发沉思-260 Ajax:核心动画
开发语言·javascript·ajax
xiaoxue..3 小时前
列表转树结构:从扁平列表到层级森林
前端·javascript·算法·面试
小oo呆3 小时前
【自然语言处理与大模型】LangChainV1.0入门指南:核心组件Agent
前端·javascript·easyui
BD_Marathon3 小时前
关于JS和TS选择的问题
开发语言·javascript·ecmascript
dly_blog3 小时前
Vite 原理与 Vue 项目实践
前端·javascript·vue.js