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();
相关推荐
ZouZou老师3 小时前
FFmpeg性能优化经典案例
性能优化·ffmpeg
aqi005 小时前
FFmpeg开发笔记(九十)采用FFmpeg套壳的音视频转码百宝箱FFBox
ffmpeg·音视频·直播·流媒体
齐齐大魔王7 小时前
FFmpeg
ffmpeg
你好音视频9 小时前
FFmpeg RTSP拉流流程深度解析
ffmpeg
IFTICing19 小时前
【环境配置】ffmpeg下载、安装、配置(Windows环境)
windows·ffmpeg
haiy201120 小时前
FFmpeg 编译
ffmpeg
aqi001 天前
FFmpeg开发笔记(八十九)基于FFmpeg的直播视频录制工具StreamCap
ffmpeg·音视频·直播·流媒体
八月的雨季 最後的冰吻1 天前
FFmepg--28- 滤镜处理 YUV 视频帧:实现上下镜像效果
ffmpeg·音视频
ganqiuye1 天前
向ffmpeg官方源码仓库提交patch
大数据·ffmpeg·video-codec
草明1 天前
ffmpeg 把 ts 转换成 mp3
ffmpeg