WebRtc语音通话前置铃声处理

目标

在H5页面拨打webrtc 通话之前播放前置铃声。

问题

在移动端静音模式、音量调到最低播放音频是没有声音。

解决方案

通过 navigator.mediaDevices.getUserMedia + AudioContext 解决。

原因:

  1. 使用audio 播放的时候,h5音频播放使用的是媒体通道,这个时候音量可能会被用户调到最低,但是由于 H5 页面无法控制系统的音量修改的。当然也可以通过与移动端原生方法进行通信,这样工作量就增加了。
  2. 在使用 webrtc 播放的时候,使用的是语音通话的通道,这样可以忽略掉移动端的静音模式及音量控制的问题,这里可以设置【audioCtx.createGain();】 initialGain 扩大音量。
  3. 如果没有经过 navigator.mediaDevices.getUserMedia 初始化,直接使用 AudioContext 去播放音频的时候,静音模式下是没有声音的。
jsx 复制代码
import { useState, useEffect, useRef, useCallback } from "react";

/**
 * @typedef {Object} EnhancedAudioControls
 * @property {() => void} play - 播放音频。
 * @property {() => void} stop - 停止当前播放的音频。
 * @property {boolean} isPlaying - 音频是否正在播放。
 * @property {boolean} isLoading - 音频数据是否正在加载。
 * @property {boolean} isMicrophoneActive - 麦克风流是否已激活(音频优先级增强)。
 * @property {number} gainValue - 当前的音频增益值。
 * @property {(value: number) => void} setGainValue - 设置音频增益值(建议 1.0 - 5.0)。
 * @property {(isLooping: boolean) => void} setLooping - 设置是否循环播放。
 * @property {boolean} isLooping - 当前是否为循环模式。
 * @property {string | null} error - 错误信息。
 */

const MAX_SAFE_GAIN = 50000000.0; // Web Audio 增益上限,防止严重削波

/**
 * 封装 WebRTC 音频激活和 Web Audio 播放,以最大化音频优先级和音量。
 * * @param {string} mp3Url - 待播放的 MP3 文件的 URL 路径。
 * @param {number} initialGain - 初始增益值 (默认为 4.5,已调大以满足用户要求)。
 * @param {boolean} initialLooping - 初始是否循环播放 (默认为 false)。
 * @returns {EnhancedAudioControls}
 */
const useWebRTCEnhancedAudio = (mp3Url = "", initialGain = 10000, initialLooping = true) => {
  const [isLoading, setIsLoading] = useState(true);
  const [isMicrophoneActive, setIsMicrophoneActive] = useState(false);
  const [isPlaying, setIsPlaying] = useState(false);
  const [gainValue, setGainValue] = useState(Math.min(initialGain, MAX_SAFE_GAIN));
  const [isLooping, setLooping] = useState(initialLooping);
  const [error, setError] = useState<any>(null);

  const audioContextRef = useRef<any>(null);
  const streamRef = useRef<any>(null);
  const gainNodeRef = useRef<any>(null);
  const audioBufferRef = useRef<any>(null);
  const currentSourceNodeRef = useRef<any>(null);

  // --- 1. 停止播放功能 (Callback) ---
  const stop = useCallback(() => {
    if (currentSourceNodeRef.current) {
      try {
        // 确保在停止前断开连接,避免内存泄漏
        currentSourceNodeRef.current.stop();
        currentSourceNodeRef.current.disconnect();
        currentSourceNodeRef.current = null;
        setIsPlaying(false);
      } catch (e) {
        // 忽略 AudioBufferSourceNode 已停止的错误
        // console.warn("停止音频失败或音频已停止:", e);
      }
    }
  }, []);

  // --- 2. 播放功能 (Callback) ---
  const play = useCallback(async () => {
    if (!audioContextRef.current || !audioBufferRef.current) {
      console.warn("音频未加载或上下文未就绪");
      return;
    }

    // 播放新音频前,先停止正在播放的音频(关键:用于应用新的 loop 属性)
    stop();

    const audioCtx = audioContextRef.current;
    const gainNode = gainNodeRef.current;

    // 处理移动端自动播放限制
    if (audioCtx.state === "suspended") {
      await audioCtx.resume();
    }

    try {
      // 每次播放都必须创建一个新的 source node
      const source = audioCtx.createBufferSource();
      source.buffer = audioBufferRef.current;
      source.loop = isLooping; // **【循环设置】** 应用当前的循环状态

      source.connect(gainNode);

      currentSourceNodeRef.current = source;
      setIsPlaying(true);

      source.start(0);

      source.onended = () => {
        // 非循环模式下播放结束后清理引用和状态
        if (currentSourceNodeRef.current === source && !isLooping) {
          currentSourceNodeRef.current = null;
          setIsPlaying(false);
        }
      };
    } catch (e) {
      console.error("播放音频失败:", e);
      setIsPlaying(false);
    }
  }, [stop, isLooping]); // play 依赖 stop 和 isLooping

  // --- 3. 辅助函数:加载和解码音频 ---
  const loadAudioFile = useCallback(async (url: string, audioCtx: any) => {
    setIsLoading(true);
    try {
      const response = await fetch(url);
      if (!response.ok) {
        throw new Error(`Failed to load MP3: ${response.statusText}`);
      }
      const arrayBuffer = await response.arrayBuffer();
      const buffer = await audioCtx.decodeAudioData(arrayBuffer);
      audioBufferRef.current = buffer;
      setIsLoading(false);
    } catch (e: any) {
      console.error("加载或解码音频失败:", e);
      setError(`加载音频失败: ${e?.message}`);
      setIsLoading(false);
    }
  }, []);

  // --- 4. 主初始化逻辑 (Effect) ---
  useEffect(() => {
    if (!mp3Url) return;

    const initAudioEngine = async () => {
      try {
        // 1. 请求麦克风权限 (WebRTC 优先级增强)
        const stream = await navigator.mediaDevices.getUserMedia({
          audio: true,
          video: false,
        });
        streamRef.current = stream;
        setIsMicrophoneActive(true);

        // 2. 初始化 Web Audio Context
        const AudioContext = window.AudioContext || (window as any).webkitAudioContext;
        const audioCtx = new AudioContext();
        audioContextRef.current = audioCtx;

        // 3. 创建音频处理节点 (增益和压缩)
        const gainNode = audioCtx.createGain();
        gainNode.gain.value = Math.min(gainValue, MAX_SAFE_GAIN);
        gainNodeRef.current = gainNode;

        const compressor = audioCtx.createDynamicsCompressor(); // 提升感知响度

        // 串联: 增益 -> 压缩 -> 扬声器
        gainNode.connect(compressor);
        compressor.connect(audioCtx.destination);

        // 4. 加载 MP3 文件
        await loadAudioFile(mp3Url, audioCtx);
      } catch (err) {
        console.error("初始化音频引擎失败:", err);
        setError("无法访问麦克风或音频功能。请检查权限。");
        setIsMicrophoneActive(false);
        setIsLoading(false);
      }
    };

    initAudioEngine();

    // 清理函数:关闭所有资源
    return () => {
      stop(); // 确保组件卸载时停止播放
      if (streamRef.current) {
        streamRef.current.getTracks().forEach((track: any) => track.stop());
      }
      if (audioContextRef.current) {
        audioContextRef.current.close();
      }
    };
  }, [mp3Url, loadAudioFile]);

  // --- 5. 动态更新增益值 (Effect) ---
  useEffect(() => {
    if (gainNodeRef.current && audioContextRef.current) {
      const safeGain = Math.min(Math.max(gainValue, 0.01), MAX_SAFE_GAIN);
      // 平滑设置增益,避免播放中断
      gainNodeRef.current.gain.setValueAtTime(safeGain, audioContextRef.current.currentTime);
    }
  }, [gainValue]);

  // --- 6. 循环状态变更处理 (Effect) ---
  useEffect(() => {
    // 当循环状态改变时,如果正在播放,必须重新创建 source node 来应用新的 loop 值。
    if (isPlaying) {
      // 重新调用 play,它会先 stop 再以新的 isLooping 值开始播放
      play();
    }
  }, [isLooping]);

  return {
    play,
    stop,
    isPlaying,
    isLoading,
    isMicrophoneActive,
    gainValue,
    setGainValue,
    isLooping, // 返回当前循环状态
    setLooping,
    error,
  };
};

export default useWebRTCEnhancedAudio;

优化

移动端设备可能存在不支持 navigator.mediaDevices.getUserMedia ,因此可以使用 audio 播放进行兜底。

jsx 复制代码
import { useRef, useCallback } from "react";

// 自定义 Hook:管理拨号铃声
export const usePlayAudio = () => {
  const audioRef = useRef<HTMLAudioElement | null>(null);

  // 初始化音频对象
  const initAudio = useCallback((mp3Url: string) => {
    if (!audioRef.current) {
      audioRef.current = new Audio(mp3Url);

      audioRef.current.loop = true; // 循环播放
    }
  }, []);

  // 开始播放铃声
  const playRingtone = useCallback(
    async (mp3Url: string) => {
      try {
        initAudio(mp3Url);
        if (audioRef.current) {
          audioRef.current.pause();
          audioRef.current.currentTime = 0;
          audioRef.current.volume = 1.0;
          await audioRef.current.play();
        }
      } catch (error) {
        console.error("播放铃声失败:", error);
      }
    },
    [initAudio],
  );

  // 停止播放铃声
  const stopRingtone = useCallback(() => {
    if (audioRef.current) {
      audioRef.current.volume = 0;
      audioRef.current.pause();
      audioRef.current.currentTime = 0;

      // 彻底清空缓冲区
      audioRef.current.src = "";
      audioRef.current.load();
    }
  }, []);

  // 清理资源
  const cleanup = useCallback(() => {
    if (audioRef.current) {
      audioRef.current.pause();
      audioRef.current.src = "";
      audioRef.current.load(); // 重新加载(清空缓冲)
      audioRef.current.currentTime = 0;
      audioRef.current = null;
    }
  }, []);

  return {
    initAudio,
    playRingtone,
    stopRingtone,
    cleanup,
  };
};

实现: useHighPriorityAudio

jsx 复制代码
import { useState, useEffect, useMemo, useCallback } from "react";
import { usePlayAudio } from "../usePlayAudio";
import useWebRTCEnhancedAudio from "../useWebRTCEnhancedAudio";

/**
 * @typedef {Object} UnifiedAudioControls
 * @property {(mp3Url: string) => void} playRingtone - 开始播放铃声 (统一 API)。
 * @property {() => void} stopRingtone - 停止播放铃声 (统一 API)。
 * @property {() => void} cleanup - 清理资源。
 * @property {string} mode - 当前使用的音频模式: 'WebRTC' 或 'HTML_AUDIO'。
 * @property {string | null} error - 错误信息。
 * @property {boolean} isWebRTCAvailable - 浏览器是否支持 WebRTC。
 */

// 能力检测函数
const checkWebRTCAvailability = () => {
  // @ts-ignore
  return !!(navigator.mediaDevices && navigator?.mediaDevices?.getUserMedia && (window.AudioContext || window?.webkitAudioContext));
};

/**
 * 兼容性 Wrapper Hook:优先使用 WebRTC 增强音频,回退到 HTML Audio。
 * @returns {UnifiedAudioControls}
 */
export const useHighPriorityAudio = (audioUrl: string) => {
  // 检查浏览器能力,只需要运行一次
  const [isWebRTCAvailable, setIsWebRTCAvailable] = useState(checkWebRTCAvailability());
  // const [audioUrl, setAudioUrl] = useState(fileUrl || "");
  const [hasError, setHasError] = useState(false);

  // 实例化 WebRTC Hook (即使不用,也需要实例化以保证 Hook 规则)
  // 由于 WebRTC Hook 复杂,我们需要将它的初始化逻辑移到 Wrapper Hook 内部
  // 我们不能在 Hook 内部有条件地调用 Hook,所以我们总是调用两个 Hook,并根据条件使用它们的返回值。

  // --- 1. WebRTC Enhanced Hook ---
  const {
    play: webRtcPlay, // 重命名,避免冲突
    stop: webRtcStop,
    isMicrophoneActive,
    error: webRtcError,
    // WebRTC Hook 通常需要在初始化时加载音频,所以我们在这里保持 Hook 简洁
  } = useWebRTCEnhancedAudio(audioUrl, 5000, true);

  // --- 2. HTML Audio Hook ---
  const { playRingtone: htmlPlay, stopRingtone: htmlStop, cleanup: htmlCleanup } = usePlayAudio();

  // 确定当前模式
  const mode = useMemo(() => {
    // 如果 WebRTC 可用,且没有发生权限错误(即麦克风已激活),则使用 WebRTC
    // 注意:WebRTC Hook 在内部会尝试激活麦克风。如果失败,它会返回一个错误。
    if (isWebRTCAvailable && isMicrophoneActive) {
      console.log("WebRTC 可支持");
      return "WebRTC";
    }
    return "HTML_AUDIO";
  }, [isWebRTCAvailable, isMicrophoneActive]);

  // 监听 WebRTC 错误,如果 WebRTC 失败,我们退化到 HTML_AUDIO
  useEffect(() => {
    if (webRtcError) {
      console.warn("WebRTC 初始化失败或权限被拒,降级到 HTML Audio。", webRtcError);
      setIsWebRTCAvailable(false); // 视为不可用,强制切换到 HTML Audio 模式
      setHasError(true);
    }
  }, [webRtcError]);

  // --- 统一 API 实现 ---

  const playRingtone = useCallback(
    async (mp3Url: string) => {
      // setAudioUrl(mp3Url); // 更新 URL,触发 WebRTC 内部加载

      if (mode === "WebRTC") {
        console.log("使用 WebRTC/Web Audio (高优先级) 播放...");
        // WebRTC 模式需要先加载音频,在 useEffect 中已经加载,直接调用 play
        webRtcPlay();
      } else {
        console.log("使用 HTML Audio (标准) 播放...");
        // HTML Audio 模式,调用其播放逻辑
        htmlPlay(mp3Url);
      }
    },
    [mode, webRtcPlay, htmlPlay],
  );

  const stopRingtone = useCallback(() => {
    if (mode === "WebRTC") {
      webRtcStop();
    } else {
      htmlStop();
    }
  }, [mode, webRtcStop, htmlStop]);

  const cleanup = useCallback(() => {
    webRtcStop(); // 停止 WebRTC 播放
    htmlCleanup(); // 清理 HTML Audio 资源
    // WebRTC Hook 的清理逻辑在它自己的 useEffect return 中
  }, [webRtcStop, htmlCleanup]);

  return {
    playRingtone,
    stopRingtone,
    cleanup,
    mode,
    isWebRTCAvailable,
    error: hasError ? "WebRTC 权限被拒,已切换到标准模式。" : null,
  };
};

使用:

jsx 复制代码
const { playRingtone, stopRingtone, cleanup, mode, isWebRTCAvailable, error } = useHighPriorityAudio();
相关推荐
撬动未来的支点8 小时前
解读ffmpeg控制台输出
ffmpeg
huangql52012 小时前
WebRTC技术详解:构建实时音视频应用实践
webrtc·实时音视频
赖small强12 小时前
【ZeroRange WebRTC】TWCC 在 WebRTC 中的角色与工作原理(深入指南)
webrtc·rtp·twcc·remb
戴草帽的大z12 小时前
使用V4L2工具验证RK3588平台视频设备节点数据有效性
ffmpeg·音视频·rk3588·nv12·v4l2-ctl
戴草帽的大z12 小时前
rk3588上用rk_mpi_vi_test与ffmpeg实战
ffmpeg·rk3588·mpi·rk_mpi_vi
笑虾12 小时前
bat 批处理实现 FFmpeg 命令压缩 MP4
ffmpeg
PenTablet12 小时前
用ffmpeg来压缩视频文件
ffmpeg
赖small强13 小时前
【ZeroRange WebRTC 】STUN 在 WebRTC 中的角色与工作原理(深入指南)
webrtc·nat·stun·ice
赖small强14 小时前
【ZeroRange WebRTC】WebRTC 信令安全:实现原理与应用(深入指南)
webrtc·信令安全·tls/wss 传输加密·身份鉴权与授权·sdp/ice 的完整性保障