解决视频通话无声问题:如何检测基于浏览器的应用程序中的音频路由问题

有没有过这样的经历:在视频通话中,尽管音频效果很好,但对方却听不到你的声音?我在构建基于浏览器的视频 KYC(Know Your Customer)解决方案时就遇到过这样的难题,正确的音频路由对于实现无缝验证体验至关重要。

解决视频通话无声问题:如何检测基于浏览器的应用程序中的音频路由问题

问题:音频消失的情况

我们发现许多客户无法完成 KYC。用户会加入视频通话,尽管我们的系统显示音频电平正常,但他们报告说听不到坐席的声音。经过大量调试,我们发现了罪魁祸首:音频被路由到移动设备上的听筒而不是扬声器。

这对我们的视频 KYC 应用程序来说尤其棘手。用户需要在一定距离内拿着手机展示身份证件和脸部,但由于音频是通过听筒播放的,因此无法听到说明,而听筒的设计是贴着耳朵的。

与本地应用程序不同,基于浏览器的解决方案对设备音频路由的控制有限。没有简单的 JavaScript API 调用来检测或更改音频是来自听筒还是扬声器。我们需要一个创造性的解决方案。

方法:音频指纹识别

在研究了各种方案后,我找到了一个巧妙的解决方法:使用设备的麦克风检测音频是否通过扬声器播放。这个概念很简单:

  • 如果我们通过扬声器播放音频,设备的麦克风应该能够接收到它
  • 如果音频是通过听筒播放的,麦克风将无法检测到(除非将手机放在耳边)
  • 通过比较我们发送到音频输出端的内容和麦克风拾取到的内容,我们就能确定音频被传送到了哪里。

这类似于语音助手如何知道自己听到的是自己的输出还是用户的新命令。

技术解决方案

该实现需要使用两个强大的浏览器 API:

  1. Web Audio API:用于分析音频模式和频率
  2. MediaDevices API:用于访问设备的麦克风

以下是我构建解决方案的方法.

步骤 1:设置两个音频分析仪

// 创建音频上下文
const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)();
// 为输出和麦克风输入创建分析器
const outputAnalyser = audioContext.createAnalyser();
const micAnalyser = audioContext.createAnalyser();
outputAnalyser.fftSize = 256;
micAnalyser.fftSize = 256;

第一个分析器监听发送到输出设备的音频,而第二个分析器监听麦克风输入。

步骤 2:点击输出音频流

// 将远程音频连接到分析器
const source = audioContext.createMediaElementSource ( remoteAudioRef.current ) ; 
source.connect (outputAnalyser) ;
 outputAnalyser.connect ( audioContext.destination ) ;

这样就能非破坏性地摄取到输出设备的音频流,使我们能够在不影响用户体验的情况下对其进行分析。

步骤 3:接入麦克风

// 使用特定配置请求麦克风访问
const micStream = await navigator.mediaDevices.getUserMedia({ 
  audio: { 
    echoCancellation: false, // 禁用回声消除
    noiseSuppression: false  // 禁用噪音抑制
  } 
});
// 创建话筒信号源并连接至分析器
const micSource = audioContext.createMediaStreamSource(micStream);
micSource.connect(micAnalyser);
// 重要提示:请勿将 micSource 连接到目的地以避免反馈

我特意禁用了回声消除和噪声抑制,因为它们可能会过滤掉我们想要检测的音频模式。

步骤 4:比较音频模式

const checkAudioRoute = (): void => { 
  // 从输出获取频率数据
  const outputData = new  Uint8Array (outputAnalyser. frequencyBinCount ); 
  outputAnalyser. getByteFrequencyData (outputData); 
  
  // 计算输出音量
  const outputVolume = calculateAverage (outputData); 
  
  // 从麦克风获取频率数据
  const micData = new  Uint8Array (micAnalyser. frequencyBinCount ); 
  micAnalyser. getByteFrequencyData (micData); 
  
  // 比较模式以检测相关性
  const correlation = calculateCorrelation (outputData, micData); 
  
  // 进行确定
  if (outputVolume > 15 && correlation < 5 ) { 
    // 音频正在播放但未被麦克风检测到 = 可能进入耳机
    setAudioRoutingIssue ( true ); 
  } else { 
    setAudioRoutingIssue ( false ); 
  } 
  
  // 继续检查
  requestAnimationFrame (checkAudioRoute); 
};

该功能持续运行,比较发送到输出端和麦克风摄取到的频率模式。

步骤 5:提供用户反馈

return (
  <div className="video-call-container">
    <video ref={remoteAudioRef} autoPlay playsInline />
    
    {audioRoutingIssue && (
      <div className="warning-banner">
        <p>检测到音频可能传到了听筒而不是扬声器。</p>
        <p>为获得最佳体验,请启用扬声器或使用听筒。</p>
        <button onClick={attemptSwitchToSpeaker}>切换到扬声器</button>
      </div>
    )}
  </div>
);

当检测到问题时,我们会向用户显示明确的警告并指导如何解决问题。

完整的解决方案

以下是将所有内容联系在一起的完整 TypeScript 实现:

import React, { useEffect, useRef, useState } from 'react';
interface VideoCallProps {
  // 添加组件所需的任何道具
}
const VideoCallWithAudioCheck: React.FC<VideoCallProps> = () => {
  // UI 和检测结果的状态
  const [isAudioDetected, setIsAudioDetected] = useState<boolean>(false);
  const [audioRoutingIssue, setAudioRoutingIssue] = useState<boolean>(false);
  const [volumeLevel, setVolumeLevel] = useState<number>(0);
  
  // 音频元素和处理的Refs
  const remoteAudioRef = useRef<HTMLVideoElement | null>(null);
  const micStreamRef = useRef<MediaStream | null>(null);
  const audioContextRef = useRef<AudioContext | null>(null);
  const outputAnalyserRef = useRef<AnalyserNode | null>(null);
  const micAnalyserRef = useRef<AnalyserNode | null>(null);
  
  useEffect(() => {
    // 设置 WebRTC 和音频监控
    setupCall();
    
    // 清理函数
    return () => {
      cleanup();
    };
  }, []);
  
  const setupCall = async (): Promise<void> => {
    try {
      // 1. 设置 WebRTC 连接
      // ... WebRTC 设置代码将放在这里 ... 
      
      //2. 创建音频上下文
      audioContextRef.current = new (window.AudioContext || (window as any).webkitAudioContext)();
      
      // 3. 设置输出音频监控
      setupOutputMonitoring();
      
      // 4. 请求麦克风访问以进行输入监控
      await setupMicrophoneMonitoring();
      
      // 5. 启动定期音频路由检查
      startAudioRouteChecking();
    } catch (error) {
      console.error("Error setting up call:", error);
    }
  };
  
  const setupOutputMonitoring = (): void => {
    if (!remoteAudioRef.current || !audioContextRef.current) return;
    
    // 监控发送到扬声器/听筒的输出音量
    const source = audioContextRef.current.createMediaElementSource(remoteAudioRef.current);
    outputAnalyserRef.current = audioContextRef.current.createAnalyser();
    outputAnalyserRef.current.fftSize = 256;
    
    source.connect(outputAnalyserRef.current);
    outputAnalyserRef.current.connect(audioContextRef.current.destination);
  };
  
  const setupMicrophoneMonitoring = async (): Promise<boolean> => {
    try {
      // 请求麦克风访问
      micStreamRef.current = await navigator.mediaDevices.getUserMedia({ 
        audio: { 
          echoCancellation: false, // 禁用回声消除以更好地检测扬声器输出
          noiseSuppression: false  // 禁用噪音抑制以便更好地检测
        } 
      });
      
      if (!audioContextRef.current) return false;
      
      // 创建麦克风分析器
      const micSource = audioContextRef.current.createMediaStreamSource(micStreamRef.current);
      micAnalyserRef.current = audioContextRef.current.createAnalyser();
      micAnalyserRef.current.fftSize = 256;
      micSource.connect(micAnalyserRef.current);
      
      return true;
    } catch (error) {
      console.error("Error accessing microphone:", error);
      return false;
    }
  };
  
  const startAudioRouteChecking = (): void => {
    if (!outputAnalyserRef.current || !micAnalyserRef.current) return;
    
    const outputBufferLength = outputAnalyserRef.current.frequencyBinCount;
    const outputDataArray = new Uint8Array(outputBufferLength);
    
    const micBufferLength = micAnalyserRef.current.frequencyBinCount;
    const micDataArray = new Uint8Array(micBufferLength);
    
    const checkAudioRoute = (): void => {
      if (!outputAnalyserRef.current || !micAnalyserRef.current) return;
      
      // 1. 检查输出音量
      outputAnalyserRef.current.getByteFrequencyData(outputDataArray);
      let outputSum = 0;
      for (let i = 0; i < outputBufferLength; i++) {
        outputSum += outputDataArray[i];
      }
      const outputVolume = outputSum / outputBufferLength;
      setVolumeLevel(outputVolume);
      
      // 2. 检查是否可以在麦克风中检测到类似的音频模式
      micAnalyserRef.current.getByteFrequencyData(micDataArray);
      
      // 3. 比较频率模式以查看是否在麦克风输入中检测到输出音频
      // 这是一种简化的方法 - 实际实现将使用更复杂的
      // 相关方法来匹配音频模式
      let correlation = 0;
      for (let i = 0; i < Math.min(outputBufferLength, micBufferLength); i++) {
        // Simple correlation - will need refinement for production use
        if (outputDataArray[i] > 30 && micDataArray[i] > 20) {
          correlation++;
        }
      }
      
      // 如果存在显著的相关性并且输出合理音量
      const isDetected = correlation > 5 && outputVolume > 15;
      setIsAudioDetected(isDetected);
      
      // 如果输出音量良好但未在麦克风中检测到,则可能转到听筒
      if (outputVolume > 15 && !isDetected) {
        setAudioRoutingIssue(true);
      } else {
        setAudioRoutingIssue(false);
      }
      
      // 继续检查
      requestAnimationFrame(checkAudioRoute);
    };
    
    checkAudioRoute();
  };
  
  const cleanup = (): void => {
    if (micStreamRef.current) {
      micStreamRef.current.getTracks().forEach(track => track.stop());
    }
    
    if (audioContextRef.current) {
      audioContextRef.current.close();
    }
  };
  
  const switchToSpeaker = async (): Promise<void> => {
    // 这在浏览器中受到限制,但如果可用,我们可以尝试使用 setSinkId 
    if (remoteAudioRef.current && 'setSinkId' in remoteAudioRef.current) {
      try {
        // TypeScript 默认无法识别 setSinkId,因此我们使用类型断言
        await (remoteAudioRef.current as any).setSinkId('');  // 默认设备(通常是扬声器)
      } catch (err) {
        console.error("Error switching to speaker:", err);
      }
    }
  };
  
  return (
    <div className="video-call-container">
      <video ref={remoteAudioRef} autoPlay playsInline />
      
      <div className="audio-status">
        <div 
          className="volume-meter" 
          style={{ width: `${Math.min(volumeLevel, 100)}%` }}
        ></div>
        <div>Volume level: {Math.round(volumeLevel)}</div>
        
        {audioRoutingIssue && (
          <div className="warning">
            <p>音频可能路由到听筒而不是扬声器。</p>
            <button onClick={switchToSpeaker}>切换到扬声器</button>
          </div>
        )}
      </div>
    </div>
  );
};
export default VideoCallWithAudioCheck;

主要挑战和解决方案

实施该解决方案并非没有挑战:

1. 权限挑战

最明显的障碍是请求麦克风访问权限。用户已经为视频通话本身授予了摄像头和麦克风访问权限,但我们需要解释为什么即使他们没有说话,我们也需要继续访问麦克风。

解决方案:我们添加了清晰的信息,解释需要麦克风访问“以确保您的音频正常工作”并且没有记录任何对话。

2. 音频模式识别

最初,我们的相关性检测过于简单,导致了假阳性和假阴性。

解决方案:我们改进了算法,以寻找音频指纹中的特定模式,而不仅仅是比较原始值。我们还添加了一个校准步骤,该步骤会在通话开始时播放短暂、听不见的音调以建立基线。

3. 环境因素

背景噪音有时会干扰我们的检测。

解决方案:我们根据环境噪声水平添加了阈值调整,并专注于检测人类语音典型频率范围内的模式。

4. 浏览器限制

切换音频输出的setSinkId()方法并未得到普遍支持。

解决方案:我们实施了一种后备方法,如果我们的自动解决方案失败,它将清楚地指导用户如何手动切换到扬声器。

结果

实施此解决方案后,音频相关支持工单减少了 78%。用户无需联系支持人员即可快速识别和解决音频路由问题。

我们的视频 KYC 完成率提高了 23%,因为因音频问题而放弃该流程的用户减少了。

经验教训

  1. 浏览器 API 功能强大但有限:虽然我们使用现有 API 找到了创造性的解决方案,但基于浏览器的应用程序与原生应用程序相比仍然存在很大的限制。
  2. 用户体验就是预测问题:通过主动检测和解决潜在问题,我们创造了更流畅、更精致的体验。
  3. 清晰的沟通至关重要:在实施需要特殊权限的解决方案时,清楚地沟通为什么需要这些权限可以建立信任。
  4. 在真实设备上测试:这个问题主要影响某些移动设备,如果我们仅在桌面或模拟器上测试,就会被忽略。

最后的想法

基于浏览器的视频应用中的音频路由问题是一个常见但经常被忽视的问题。通过实施此检测系统,我们能够显著改善视频 KYC 解决方案的用户体验,而无需对底层 WebRTC 实施进行任何更改。

该解决方案并不完美,浏览器限制意味着我们不能总是以编程方式切换音频输出 – 但通过检测问题并提供明确的指导,我们使用户能够自己解决问题。

如果您正在构建任何类型的基于浏览器的音频或视频应用程序,尤其是针对移动设备的应用程序,我强烈建议您实施类似的检测机制,以确保您的用户不会疑惑为什么他们听不到任何声音。

作者:Parth Maheshwari
源自:https://medium.com/@parthmaheshwari/ever-been-on-a-video-call-where-the-other-person-cant-hear-you-despite-your-audio-working-d85e562cb3c2

本文来自作者投稿,版权归原作者所有。如需转载,请注明出处:https://www.nxrte.com/jishu/56804.html

(0)

相关推荐

发表回复

登录后才能评论