目标
在H5页面拨打webrtc 通话之前播放前置铃声。
问题
在移动端静音模式、音量调到最低播放音频是没有声音。
解决方案
通过 navigator.mediaDevices.getUserMedia + AudioContext 解决。
原因:
- 使用audio 播放的时候,h5音频播放使用的是媒体通道,这个时候音量可能会被用户调到最低,但是由于 H5 页面无法控制系统的音量修改的。当然也可以通过与移动端原生方法进行通信,这样工作量就增加了。
- 在使用 webrtc 播放的时候,使用的是语音通话的通道,这样可以忽略掉移动端的静音模式及音量控制的问题,这里可以设置【audioCtx.createGain();】 initialGain 扩大音量。
- 如果没有经过 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();