WebRtc语音通话前置铃声处理
目标
在H5页面拨打webrtc 通话之前播放前置铃声。
问题
在移动端静音模式、音量调到最低播放音频是没有声音。
解决方案
通过 navigator.mediaDevices.getUserMedia + AudioContext 解决。
原因:
- 使用audio 播放的时候,h5音频播放使用的是媒体通道,这个时候音量可能会被用户调到最低,但是由于 H5 页面无法控制系统的音量修改的。当然也可以通过与移动端原生方法进行通信,这样工作量就增加了。
- 在使用 webrtc 播放的时候,使用的是语音通话的通道,这样可以忽略掉移动端的静音模式及音量控制的问题,这里可以设置【audioCtx.createGain();】 initialGain 扩大音量。
- 如果没有经过 navigator.mediaDevices.getUserMedia 初始化,直接使用 AudioContext 去播放音频的时候,静音模式下是没有声音的。
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 nodeconst 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 Contextconst 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 播放进行兜底。
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
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-ignorereturn !!(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_AUDIOuseEffect(() => {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 中已经加载,直接调用 playwebRtcPlay();} 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,};
};
使用:
const { playRingtone, stopRingtone, cleanup, mode, isWebRTCAvailable, error } = useHighPriorityAudio();
