写一个最便捷的 WebRTC Demo(实操篇)

news/2026/1/21 8:40:18/文章来源:https://www.cnblogs.com/gccbuaa/p/19509685

写一个最简单的 WebRTC Demo(实操篇)

本文是 WebRTC 系列专栏的第三篇,我们将动手实践,从零开始构建一个完整的 WebRTC 音视频通话 Demo。通过这个实战项目,你将深入理解 WebRTC 的工作流程。


目录

  1. 项目概述
  2. 获取摄像头与麦克风
  3. 建立 RTCPeerConnection
  4. 实现完整的 P2P 音视频通话
  5. 运行与测试
  6. 常见问题与调试
  7. 总结

1. 项目概述

1.1 我们要做什么?

我们将构建一个 1 对 1 的实时音视频通话应用,包含以下功能:

1.2 技术栈

组件技术选型
前端原生 HTML/CSS/JavaScript
信令服务器Node.js + WebSocket
WebRTC浏览器原生 API

1.3 项目结构

webrtc-demo/
├── server/
│   ├── package.json
│   └── server.js          # 信令服务器
├── client/
│   ├── index.html         # 页面结构
│   ├── style.css          # 样式
│   └── main.js            # WebRTC 逻辑
└── README.md

2. 获取摄像头与麦克风

2.1 基础 API:getUserMedia

getUserMedia 是获取媒体设备的核心 API。

// 最简单的用法
async function getLocalStream() {
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: true
});
return stream;
} catch (error) {
console.error('获取媒体设备失败:', error);
throw error;
}
}

2.2 处理权限和错误

async function getLocalStream() {
// 检查浏览器支持
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
throw new Error('浏览器不支持 getUserMedia');
}
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: true
});
return stream;
} catch (error) {
// 处理不同类型的错误
switch (error.name) {
case 'NotAllowedError':
throw new Error('用户拒绝了摄像头/麦克风权限');
case 'NotFoundError':
throw new Error('找不到摄像头或麦克风设备');
case 'NotReadableError':
throw new Error('设备被其他应用占用');
case 'OverconstrainedError':
throw new Error('设备不满足指定的约束条件');
default:
throw error;
}
}
}

2.3 高级约束配置

const constraints = {
video: {
width: { min: 640, ideal: 1280, max: 1920 },
height: { min: 480, ideal: 720, max: 1080 },
frameRate: { ideal: 30 },
facingMode: 'user'  // 前置摄像头
},
audio: {
echoCancellation: true,   // 回声消除
noiseSuppression: true,   // 噪声抑制
autoGainControl: true     // 自动增益
}
};
const stream = await navigator.mediaDevices.getUserMedia(constraints);

2.4 显示本地视频

<video id="localVideo" autoplay muted playsinline></video>
const localVideo = document.getElementById('localVideo');
const stream = await getLocalStream();
// 将媒体流绑定到 video 元素
localVideo.srcObject = stream;

⚠️ 注意:本地视频需要设置 muted 属性,否则会产生回声。


3. 建立 RTCPeerConnection

3.1 创建 PeerConnection

const configuration = {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' }
]
};
const peerConnection = new RTCPeerConnection(configuration);

3.2 添加本地媒体轨道

// 将本地媒体流的所有轨道添加到 PeerConnection
localStream.getTracks().forEach(track => {
peerConnection.addTrack(track, localStream);
});

3.3 处理远端媒体流

const remoteVideo = document.getElementById('remoteVideo');
peerConnection.ontrack = (event) => {
// 获取远端媒体流
const [remoteStream] = event.streams;
remoteVideo.srcObject = remoteStream;
};

3.4 Offer/Answer 交换

// 发起方:创建 Offer
async function createOffer() {
const offer = await peerConnection.createOffer();
await peerConnection.setLocalDescription(offer);
// 通过信令服务器发送 Offer
sendToSignalingServer({
type: 'offer',
sdp: offer.sdp
});
}
// 接收方:处理 Offer 并创建 Answer
async function handleOffer(offer) {
await peerConnection.setRemoteDescription(
new RTCSessionDescription(offer)
);
const answer = await peerConnection.createAnswer();
await peerConnection.setLocalDescription(answer);
// 通过信令服务器发送 Answer
sendToSignalingServer({
type: 'answer',
sdp: answer.sdp
});
}
// 发起方:处理 Answer
async function handleAnswer(answer) {
await peerConnection.setRemoteDescription(
new RTCSessionDescription(answer)
);
}

3.5 ICE 候选交换

// 收集 ICE 候选
peerConnection.onicecandidate = (event) => {
if (event.candidate) {
sendToSignalingServer({
type: 'candidate',
candidate: event.candidate
});
}
};
// 添加远端 ICE 候选
async function handleCandidate(candidate) {
await peerConnection.addIceCandidate(
new RTCIceCandidate(candidate)
);
}

4. 实现完整的 P2P 音视频通话

现在让我们把所有部分组合起来,创建一个完整的项目。

4.1 信令服务器 (server/server.js)

const WebSocket = require('ws');
const http = require('http');
// 创建 HTTP 服务器
const server = http.createServer();
// 创建 WebSocket 服务器
const wss = new WebSocket.Server({ server });
// 存储所有连接的客户端
const clients = new Map();
let clientIdCounter = 0;
wss.on('connection', (ws) => {
// 为每个客户端分配唯一 ID
const clientId = ++clientIdCounter;
clients.set(clientId, ws);
console.log(`客户端 ${clientId} 已连接,当前在线: ${clients.size}`);
// 通知客户端其 ID
ws.send(JSON.stringify({
type: 'welcome',
clientId: clientId,
clientCount: clients.size
}));
// 通知其他客户端有新用户加入
broadcastExcept(clientId, {
type: 'user-joined',
clientId: clientId,
clientCount: clients.size
});
ws.on('message', (message) => {
try {
const data = JSON.parse(message);
console.log(`收到来自客户端 ${clientId} 的消息:`, data.type);
// 转发消息给目标客户端
if (data.target) {
const targetWs = clients.get(data.target);
if (targetWs && targetWs.readyState === WebSocket.OPEN) {
targetWs.send(JSON.stringify({
...data,
from: clientId
}));
}
} else {
// 广播给所有其他客户端
broadcastExcept(clientId, {
...data,
from: clientId
});
}
} catch (error) {
console.error('消息解析错误:', error);
}
});
ws.on('close', () => {
clients.delete(clientId);
console.log(`客户端 ${clientId} 已断开,当前在线: ${clients.size}`);
// 通知其他客户端
broadcastExcept(clientId, {
type: 'user-left',
clientId: clientId,
clientCount: clients.size
});
});
ws.on('error', (error) => {
console.error(`客户端 ${clientId} 错误:`, error);
});
});
// 广播消息给除指定客户端外的所有客户端
function broadcastExcept(excludeId, message) {
clients.forEach((ws, id) => {
if (id !== excludeId && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(message));
}
});
}
const PORT = process.env.PORT || 8080;
server.listen(PORT, () => {
console.log(`信令服务器运行在 ws://localhost:${PORT}`);
});

4.2 package.json (server/package.json)

{
"name": "webrtc-signaling-server",
"version": "1.0.0",
"description": "WebRTC 信令服务器",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"ws": "^8.14.2"
}
}

4.3 HTML 页面 (client/index.html)

<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>WebRTC 视频通话 Demo</title><link rel="stylesheet" href="style.css"></head><body><div class="container"><h1>WebRTC 视频通话</h1><!-- 状态显示 --><div class="status-bar"><span id="connectionStatus">未连接</span><span id="clientInfo"></span></div><!-- 视频区域 --><div class="video-container"><div class="video-wrapper"><video id="localVideo" autoplay muted playsinline></video><span class="video-label">本地视频</span></div><div class="video-wrapper"><video id="remoteVideo" autoplay playsinline></video><span class="video-label">远端视频</span></div></div><!-- 控制按钮 --><div class="controls"><button id="startBtn" class="btn btn-primary">开启摄像头</button><button id="callBtn" class="btn btn-success" disabled>发起通话</button><button id="hangupBtn" class="btn btn-danger" disabled>挂断</button></div><!-- 媒体控制 --><div class="media-controls"><button id="toggleVideoBtn" class="btn btn-secondary" disabled>关闭视频</button><button id="toggleAudioBtn" class="btn btn-secondary" disabled>静音</button></div><!-- 日志区域 --><div class="log-container"><h3>连接日志</h3><div id="logArea"></div></div></div><script src="main.js"></script></body></html>

4.4 CSS 样式 (client/style.css)

* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
min-height: 100vh;
color: #fff;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
h1 {
text-align: center;
margin-bottom: 20px;
font-size: 2rem;
background: linear-gradient(90deg, #00d2ff, #3a7bd5);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* 状态栏 */
.status-bar {
display: flex;
justify-content: space-between;
align-items: center;
background: rgba(255, 255, 255, 0.1);
padding: 10px 20px;
border-radius: 10px;
margin-bottom: 20px;
}
#connectionStatus {
padding: 5px 15px;
border-radius: 20px;
background: #e74c3c;
font-size: 0.9rem;
}
#connectionStatus.connected {
background: #27ae60;
}
#connectionStatus.calling {
background: #f39c12;
}
/* 视频容器 */
.video-container {
display: flex;
gap: 20px;
justify-content: center;
flex-wrap: wrap;
margin-bottom: 20px;
}
.video-wrapper {
position: relative;
background: #000;
border-radius: 15px;
overflow: hidden;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
}
.video-wrapper video {
width: 480px;
height: 360px;
object-fit: cover;
display: block;
}
.video-label {
position: absolute;
bottom: 10px;
left: 10px;
background: rgba(0, 0, 0, 0.7);
padding: 5px 15px;
border-radius: 20px;
font-size: 0.85rem;
}
/* 按钮样式 */
.controls, .media-controls {
display: flex;
gap: 15px;
justify-content: center;
margin-bottom: 15px;
}
.btn {
padding: 12px 30px;
border: none;
border-radius: 25px;
font-size: 1rem;
cursor: pointer;
transition: all 0.3s ease;
font-weight: 600;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background: linear-gradient(90deg, #00d2ff, #3a7bd5);
color: #fff;
}
.btn-primary:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 5px 20px rgba(0, 210, 255, 0.4);
}
.btn-success {
background: linear-gradient(90deg, #11998e, #38ef7d);
color: #fff;
}
.btn-success:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 5px 20px rgba(56, 239, 125, 0.4);
}
.btn-danger {
background: linear-gradient(90deg, #eb3349, #f45c43);
color: #fff;
}
.btn-danger:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 5px 20px rgba(235, 51, 73, 0.4);
}
.btn-secondary {
background: rgba(255, 255, 255, 0.2);
color: #fff;
}
.btn-secondary:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.3);
}
.btn-secondary.active {
background: #e74c3c;
}
/* 日志区域 */
.log-container {
background: rgba(0, 0, 0, 0.3);
border-radius: 15px;
padding: 20px;
margin-top: 20px;
}
.log-container h3 {
margin-bottom: 15px;
font-size: 1.1rem;
color: #aaa;
}
#logArea {
height: 200px;
overflow-y: auto;
font-family: 'Monaco', 'Menlo', monospace;
font-size: 0.85rem;
line-height: 1.6;
}
#logArea .log-item {
padding: 3px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
#logArea .log-time {
color: #888;
margin-right: 10px;
}
#logArea .log-info { color: #3498db; }
#logArea .log-success { color: #27ae60; }
#logArea .log-warning { color: #f39c12; }
#logArea .log-error { color: #e74c3c; }
/* 响应式设计 */
@media (max-width: 768px) {
.video-wrapper video {
width: 100%;
height: auto;
aspect-ratio: 4/3;
}
.controls, .media-controls {
flex-wrap: wrap;
}
.btn {
flex: 1;
min-width: 120px;
}
}

4.5 JavaScript 主逻辑 (client/main.js)

// ==================== 配置 ====================
const SIGNALING_SERVER_URL = 'ws://localhost:8080';
const ICE_SERVERS = {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' },
{ urls: 'stun:stun2.l.google.com:19302' }
]
};
// ==================== 全局变量 ====================
let localStream = null;
let peerConnection = null;
let signalingSocket = null;
let myClientId = null;
let remoteClientId = null;
let isVideoEnabled = true;
let isAudioEnabled = true;
// ==================== DOM 元素 ====================
const localVideo = document.getElementById('localVideo');
const remoteVideo = document.getElementById('remoteVideo');
const startBtn = document.getElementById('startBtn');
const callBtn = document.getElementById('callBtn');
const hangupBtn = document.getElementById('hangupBtn');
const toggleVideoBtn = document.getElementById('toggleVideoBtn');
const toggleAudioBtn = document.getElementById('toggleAudioBtn');
const connectionStatus = document.getElementById('connectionStatus');
const clientInfo = document.getElementById('clientInfo');
const logArea = document.getElementById('logArea');
// ==================== 日志函数 ====================
function log(message, type = 'info') {
const time = new Date().toLocaleTimeString();
const logItem = document.createElement('div');
logItem.className = 'log-item';
logItem.innerHTML = `<span class="log-time">[${time}]</span><span class="log-${type}">${message}</span>`;
logArea.appendChild(logItem);
logArea.scrollTop = logArea.scrollHeight;
console.log(`[${type.toUpperCase()}] ${message}`);
}
// ==================== 状态更新 ====================
function updateStatus(status, className = '') {
connectionStatus.textContent = status;
connectionStatus.className = className;
}
// ==================== 信令服务器连接 ====================
function connectSignalingServer() {
log('正在连接信令服务器...');
signalingSocket = new WebSocket(SIGNALING_SERVER_URL);
signalingSocket.onopen = () => {
log('信令服务器连接成功', 'success');
updateStatus('已连接', 'connected');
};
signalingSocket.onclose = () => {
log('信令服务器连接断开', 'warning');
updateStatus('未连接');
// 尝试重连
setTimeout(connectSignalingServer, 3000);
};
signalingSocket.onerror = (error) => {
log('信令服务器连接错误', 'error');
};
signalingSocket.onmessage = async (event) => {
const message = JSON.parse(event.data);
await handleSignalingMessage(message);
};
}
// ==================== 处理信令消息 ====================
async function handleSignalingMessage(message) {
log(`收到信令消息: ${message.type}`);
switch (message.type) {
case 'welcome':
myClientId = message.clientId;
clientInfo.textContent = `我的 ID: ${myClientId} | 在线人数: ${message.clientCount}`;
log(`分配到客户端 ID: ${myClientId}`, 'success');
break;
case 'user-joined':
clientInfo.textContent = `我的 ID: ${myClientId} | 在线人数: ${message.clientCount}`;
log(`用户 ${message.clientId} 加入`, 'info');
if (localStream) {
callBtn.disabled = false;
}
break;
case 'user-left':
clientInfo.textContent = `我的 ID: ${myClientId} | 在线人数: ${message.clientCount}`;
log(`用户 ${message.clientId} 离开`, 'warning');
if (message.clientId === remoteClientId) {
hangup();
}
break;
case 'offer':
log(`收到来自用户 ${message.from} 的通话请求`, 'info');
remoteClientId = message.from;
await handleOffer(message);
break;
case 'answer':
log(`收到来自用户 ${message.from} 的应答`, 'success');
await handleAnswer(message);
break;
case 'candidate':
await handleCandidate(message);
break;
case 'hangup':
log(`用户 ${message.from} 挂断了通话`, 'warning');
hangup();
break;
}
}
// ==================== 发送信令消息 ====================
function sendSignalingMessage(message) {
if (signalingSocket && signalingSocket.readyState === WebSocket.OPEN) {
signalingSocket.send(JSON.stringify(message));
}
}
// ==================== 获取本地媒体流 ====================
async function startLocalStream() {
try {
log('正在获取摄像头和麦克风...');
localStream = await navigator.mediaDevices.getUserMedia({
video: {
width: { ideal: 1280 },
height: { ideal: 720 },
frameRate: { ideal: 30 }
},
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true
}
});
localVideo.srcObject = localStream;
log('摄像头和麦克风获取成功', 'success');
// 更新按钮状态
startBtn.disabled = true;
callBtn.disabled = false;
toggleVideoBtn.disabled = false;
toggleAudioBtn.disabled = false;
} catch (error) {
log(`获取媒体设备失败: ${error.message}`, 'error');
}
}
// ==================== 创建 PeerConnection ====================
function createPeerConnection() {
log('创建 PeerConnection...');
peerConnection = new RTCPeerConnection(ICE_SERVERS);
// 添加本地轨道
localStream.getTracks().forEach(track => {
peerConnection.addTrack(track, localStream);
log(`添加本地轨道: ${track.kind}`);
});
// ICE 候选事件
peerConnection.onicecandidate = (event) => {
if (event.candidate) {
log(`发送 ICE 候选: ${event.candidate.type || 'unknown'}`);
sendSignalingMessage({
type: 'candidate',
target: remoteClientId,
candidate: event.candidate
});
}
};
// ICE 连接状态变化
peerConnection.oniceconnectionstatechange = () => {
const state = peerConnection.iceConnectionState;
log(`ICE 连接状态: ${state}`);
switch (state) {
case 'checking':
updateStatus('正在连接...', 'calling');
break;
case 'connected':
case 'completed':
updateStatus('通话中', 'connected');
log('P2P 连接建立成功!', 'success');
break;
case 'failed':
log('连接失败', 'error');
hangup();
break;
case 'disconnected':
log('连接断开', 'warning');
break;
}
};
// 连接状态变化
peerConnection.onconnectionstatechange = () => {
log(`连接状态: ${peerConnection.connectionState}`);
};
// 收到远端轨道
peerConnection.ontrack = (event) => {
log(`收到远端轨道: ${event.track.kind}`, 'success');
const [remoteStream] = event.streams;
remoteVideo.srcObject = remoteStream;
};
return peerConnection;
}
// ==================== 发起通话 ====================
async function call() {
log('发起通话...');
createPeerConnection();
try {
const offer = await peerConnection.createOffer();
await peerConnection.setLocalDescription(offer);
log('发送 Offer...');
sendSignalingMessage({
type: 'offer',
sdp: offer.sdp
});
updateStatus('等待应答...', 'calling');
callBtn.disabled = true;
hangupBtn.disabled = false;
} catch (error) {
log(`创建 Offer 失败: ${error.message}`, 'error');
}
}
// ==================== 处理 Offer ====================
async function handleOffer(message) {
createPeerConnection();
try {
await peerConnection.setRemoteDescription(
new RTCSessionDescription({
type: 'offer',
sdp: message.sdp
})
);
const answer = await peerConnection.createAnswer();
await peerConnection.setLocalDescription(answer);
log('发送 Answer...');
sendSignalingMessage({
type: 'answer',
target: remoteClientId,
sdp: answer.sdp
});
callBtn.disabled = true;
hangupBtn.disabled = false;
} catch (error) {
log(`处理 Offer 失败: ${error.message}`, 'error');
}
}
// ==================== 处理 Answer ====================
async function handleAnswer(message) {
try {
await peerConnection.setRemoteDescription(
new RTCSessionDescription({
type: 'answer',
sdp: message.sdp
})
);
log('Answer 处理完成', 'success');
} catch (error) {
log(`处理 Answer 失败: ${error.message}`, 'error');
}
}
// ==================== 处理 ICE 候选 ====================
async function handleCandidate(message) {
try {
if (peerConnection && message.candidate) {
await peerConnection.addIceCandidate(
new RTCIceCandidate(message.candidate)
);
log('添加 ICE 候选成功');
}
} catch (error) {
log(`添加 ICE 候选失败: ${error.message}`, 'error');
}
}
// ==================== 挂断 ====================
function hangup() {
log('挂断通话');
// 通知对方
if (remoteClientId) {
sendSignalingMessage({
type: 'hangup',
target: remoteClientId
});
}
// 关闭 PeerConnection
if (peerConnection) {
peerConnection.close();
peerConnection = null;
}
// 清除远端视频
remoteVideo.srcObject = null;
remoteClientId = null;
// 更新按钮状态
callBtn.disabled = false;
hangupBtn.disabled = true;
updateStatus('已连接', 'connected');
}
// ==================== 切换视频 ====================
function toggleVideo() {
if (localStream) {
const videoTrack = localStream.getVideoTracks()[0];
if (videoTrack) {
isVideoEnabled = !isVideoEnabled;
videoTrack.enabled = isVideoEnabled;
toggleVideoBtn.textContent = isVideoEnabled ? '关闭视频' : '开启视频';
toggleVideoBtn.classList.toggle('active', !isVideoEnabled);
log(`视频已${isVideoEnabled ? '开启' : '关闭'}`);
}
}
}
// ==================== 切换音频 ====================
function toggleAudio() {
if (localStream) {
const audioTrack = localStream.getAudioTracks()[0];
if (audioTrack) {
isAudioEnabled = !isAudioEnabled;
audioTrack.enabled = isAudioEnabled;
toggleAudioBtn.textContent = isAudioEnabled ? '静音' : '取消静音';
toggleAudioBtn.classList.toggle('active', !isAudioEnabled);
log(`音频已${isAudioEnabled ? '开启' : '静音'}`);
}
}
}
// ==================== 事件绑定 ====================
startBtn.addEventListener('click', startLocalStream);
callBtn.addEventListener('click', call);
hangupBtn.addEventListener('click', hangup);
toggleVideoBtn.addEventListener('click', toggleVideo);
toggleAudioBtn.addEventListener('click', toggleAudio);
// ==================== 初始化 ====================
window.addEventListener('load', () => {
log('WebRTC Demo 初始化...');
connectSignalingServer();
});
// 页面关闭时清理
window.addEventListener('beforeunload', () => {
if (localStream) {
localStream.getTracks().forEach(track => track.stop());
}
if (peerConnection) {
peerConnection.close();
}
if (signalingSocket) {
signalingSocket.close();
}
});

5. 运行与测试

5.1 启动信令服务器

# 进入 server 目录
cd server
# 安装依赖
npm install
# 启动服务器
npm start

输出:

信令服务器运行在 ws://localhost:8080

5.2 启动客户端

由于需要访问摄像头,浏览器要求使用 HTTPS 或 localhost。我们可以使用简单的 HTTP 服务器:

# 进入 client 目录
cd client
# 使用 Python 启动 HTTP 服务器
python3 -m http.server 3000
# 或使用 Node.js 的 http-server
npx http-server -p 3000

5.3 测试步骤

  1. 打开两个浏览器窗口(或两台设备)
  2. 访问 http://localhost:3000
  3. 在两个窗口中分别点击「开启摄像头」
  4. 在其中一个窗口点击「发起通话」
  5. 观察连接建立过程和视频通话效果

5.4 测试检查清单

检查项预期结果
本地视频显示✅ 能看到自己的摄像头画面
信令连接✅ 状态显示「已连接」
发起通话✅ 状态变为「等待应答」
连接建立✅ 状态变为「通话中」
远端视频✅ 能看到对方的视频
音频通话✅ 能听到对方的声音
挂断功能✅ 能正常挂断并重新通话

6. 常见问题与调试

6.1 调试工具

Chrome WebRTC Internals

在 Chrome 浏览器中访问:

chrome://webrtc-internals

可以查看:

  • PeerConnection 状态
  • ICE 候选收集情况
  • SDP 内容
  • 媒体统计信息
获取连接统计
async function getStats() {
if (peerConnection) {
const stats = await peerConnection.getStats();
stats.forEach(report => {
if (report.type === 'inbound-rtp' && report.kind === 'video') {
console.log('视频接收统计:', {
packetsReceived: report.packetsReceived,
bytesReceived: report.bytesReceived,
packetsLost: report.packetsLost,
framesDecoded: report.framesDecoded
});
}
});
}
}

6.2 常见问题

问题 1:摄像头权限被拒绝

现象:点击「开启摄像头」后报错

解决方案

  1. 检查浏览器地址栏的权限图标
  2. 确保使用 localhostHTTPS
  3. 在浏览器设置中重置摄像头权限
问题 2:ICE 连接失败

现象:状态一直显示「正在连接」

可能原因

  • 防火墙阻止 UDP 流量
  • NAT 类型不兼容
  • STUN 服务器不可用

解决方案

// 添加 TURN 服务器作为备选
const ICE_SERVERS = {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{
urls: 'turn:your-turn-server.com:3478',
username: 'user',
credential: 'password'
}
]
};
问题 3:只有单向视频

现象:一方能看到对方,但对方看不到自己

可能原因

  • 轨道未正确添加
  • ontrack 事件未触发

调试方法

// 检查轨道状态
console.log('发送器:', peerConnection.getSenders());
console.log('接收器:', peerConnection.getReceivers());
问题 4:音频有回声

现象:通话时听到自己的声音

解决方案

  1. 确保本地视频设置了 muted 属性
  2. 使用耳机进行测试
  3. 检查 echoCancellation 是否启用
<video id="localVideo" autoplay muted playsinline></video>

6.3 网络调试

// 监控 ICE 候选收集
peerConnection.onicegatheringstatechange = () => {
console.log('ICE 收集状态:', peerConnection.iceGatheringState);
};
// 打印所有收集到的候选
peerConnection.onicecandidate = (event) => {
if (event.candidate) {
console.log('ICE 候选:', {
type: event.candidate.type,
protocol: event.candidate.protocol,
address: event.candidate.address,
port: event.candidate.port
});
} else {
console.log('ICE 候选收集完成');
}
};

7. 总结

本文要点回顾

步骤关键 API
获取媒体流navigator.mediaDevices.getUserMedia()
创建连接new RTCPeerConnection(config)
添加轨道pc.addTrack(track, stream)
创建 Offerpc.createOffer()
设置描述pc.setLocalDescription() / pc.setRemoteDescription()
ICE 候选pc.onicecandidate / pc.addIceCandidate()
接收媒体pc.ontrack

完整流程图

┌─────────────────────────────────────────────────────────────────┐
│                        WebRTC 通话流程                           │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  1. getUserMedia()     获取本地媒体流                            │
│          ↓                                                      │
│  2. new RTCPeerConnection()  创建连接对象                        │
│          ↓                                                      │
│  3. addTrack()         添加本地轨道                              │
│          ↓                                                      │
│  4. createOffer()      创建 Offer                               │
│          ↓                                                      │
│  5. setLocalDescription()  设置本地描述                          │
│          ↓                                                      │
│  6. 信令服务器          交换 Offer/Answer/ICE                    │
│          ↓                                                      │
│  7. setRemoteDescription()  设置远端描述                         │
│          ↓                                                      │
│  8. addIceCandidate()  添加 ICE 候选                            │
│          ↓                                                      │
│  9. ontrack            接收远端媒体                              │
│          ↓                                                      │
│  10. 通话建立!                                                  │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

下一篇预告

在下一篇文章中,我们将深入探讨 WebRTC 的三个关键技术

  • NAT 穿透原理与 ICE 框架
  • 音视频实时传输协议(RTP/RTCP/SRTP)
  • 回声消除、抗抖动与带宽控制

参考资料

  1. MDN - WebRTC API
  2. WebRTC Samples
  3. Getting Started with WebRTC
  4. WebRTC for the Curious

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/1193496.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

2026年GEO服务商推荐:技术驱动与垂直深耕排名,直击算法迭代与数据安全痛点

摘要 在生成式人工智能(AIGC)深度重塑信息分发与商业决策流程的当下,企业品牌在AI对话答案中的“可见性”与“权威性”已成为决定其未来增长潜力的关键战略资产。对于企业的决策者与市场战略负责人而言,如何在纷繁…

保姆级教程:手把手教你部署Fun-ASR语音系统

保姆级教程&#xff1a;手把手教你部署Fun-ASR语音系统 你是否正为会议录音、客服对话或课程内容的转写效率低下而烦恼&#xff1f;人工听写耗时费力&#xff0c;商业API按分钟计费成本高昂&#xff0c;数据外传又存在隐私风险。有没有一种方案&#xff0c;既能本地化运行保障…

Paraformer-large电商客服应用:售后录音自动归档系统搭建

Paraformer-large电商客服应用&#xff1a;售后录音自动归档系统搭建 1. 项目背景与核心价值 你有没有遇到过这样的问题&#xff1f;每天成百上千通售后电话&#xff0c;光靠人工听录音、记要点&#xff0c;不仅效率低&#xff0c;还容易遗漏关键信息。更头疼的是&#xff0c…

YOLOE三种提示模式对比:文本/视觉/无提示哪个强

YOLOE三种提示模式对比&#xff1a;文本/视觉/无提示哪个强 在开放词汇表目标检测与分割领域&#xff0c;YOLOE&#xff08;You Only Look Once for Everything&#xff09;正迅速成为开发者和研究者的首选方案。它不仅继承了YOLO系列的高效推理能力&#xff0c;更通过统一架构…

音乐解锁工具:专业音频格式转换解决方案

音乐解锁工具&#xff1a;专业音频格式转换解决方案 【免费下载链接】unlock-music 在浏览器中解锁加密的音乐文件。原仓库&#xff1a; 1. https://github.com/unlock-music/unlock-music &#xff1b;2. https://git.unlock-music.dev/um/web 项目地址: https://gitcode.co…

Docker Desktop启动失败?揭秘WSL 2安装不完整的真实原因与3步修复法

第一章&#xff1a;Docker Desktop启动失败&#xff1f;揭秘WSL 2安装不完整的真实原因与3步修复法Docker Desktop 在 Windows 系统中依赖 WSL 2&#xff08;Windows Subsystem for Linux 2&#xff09;作为底层运行环境。若 WSL 2 安装不完整或未正确配置&#xff0c;将直接导…

Qwen3-Embedding-0.6B内存占用高?量化压缩部署实战优化案例

Qwen3-Embedding-0.6B内存占用高&#xff1f;量化压缩部署实战优化案例 1. Qwen3-Embedding-0.6B 模型特性与挑战 Qwen3 Embedding 模型系列是 Qwen 家族的最新专有模型&#xff0c;专门设计用于文本嵌入和排序任务。基于 Qwen3 系列的密集基础模型&#xff0c;它提供了多种规…

Applera1n:iOS设备激活锁专业解除方案

Applera1n&#xff1a;iOS设备激活锁专业解除方案 【免费下载链接】applera1n icloud bypass for ios 15-16 项目地址: https://gitcode.com/gh_mirrors/ap/applera1n 面对二手设备或遗忘密码导致的iOS激活锁困境&#xff0c;Applera1n提供了一套高效的本地化解决方案。…

HS2增强补丁:技术优化与游戏体验全面升级方案

HS2增强补丁&#xff1a;技术优化与游戏体验全面升级方案 【免费下载链接】HS2-HF_Patch Automatically translate, uncensor and update HoneySelect2! 项目地址: https://gitcode.com/gh_mirrors/hs/HS2-HF_Patch HS2增强补丁作为专业级游戏优化工具&#xff0c;通过集…

图像修复效率翻倍!fft npainting lama调优实践

图像修复效率翻倍&#xff01;fft npainting lama调优实践 1. 引言&#xff1a;图像修复的痛点与新方案 你有没有遇到过这样的情况&#xff1a;一张重要的老照片上有划痕&#xff0c;或者截图里带着不想保留的水印&#xff0c;又或者产品图中有个碍眼的物体怎么都拍不掉&…

Docker镜像构建失败率飙升37%?——强制更新失效缓存的4个权威命令+1个生产环境禁用黑名单(附实测perf数据)

第一章&#xff1a;Docker镜像构建缓存失效的根源剖析Docker 构建缓存是加速镜像构建的核心机制&#xff0c;其本质是按 Dockerfile 指令顺序逐层比对前一层的文件系统快照与当前指令的输入状态&#xff08;如上下文文件哈希、指令内容、基础镜像ID等&#xff09;。一旦某层缓存…

小说下载神器完整教程:从零开始掌握批量下载技巧

小说下载神器完整教程&#xff1a;从零开始掌握批量下载技巧 【免费下载链接】fanqienovel-downloader 下载番茄小说 项目地址: https://gitcode.com/gh_mirrors/fa/fanqienovel-downloader 在数字阅读时代&#xff0c;拥有一个可靠的小说下载工具能够极大提升阅读体验。…

3分钟解锁B站缓存视频:m4s转MP4的终极解决方案

3分钟解锁B站缓存视频&#xff1a;m4s转MP4的终极解决方案 【免费下载链接】m4s-converter 将bilibili缓存的m4s转成mp4(读PC端缓存目录) 项目地址: https://gitcode.com/gh_mirrors/m4/m4s-converter 你是否曾为B站缓存视频无法正常播放而烦恼&#xff1f;那些珍贵的教…

fft npainting lama国际化支持:多语言界面切换功能开发计划

fft npainting lama国际化支持&#xff1a;多语言界面切换功能开发计划 1. 项目背景与目标 1.1 当前系统现状 fft npainting lama 是一款基于深度学习的图像修复工具&#xff0c;由开发者“科哥”进行二次开发并构建了直观易用的WebUI界面。该系统能够实现图片重绘、瑕疵修复…

终极SQL美化方案:3分钟掌握专业级代码格式化技巧

终极SQL美化方案&#xff1a;3分钟掌握专业级代码格式化技巧 【免费下载链接】sql-beautify VS Code extension that beautifies SQL(HQL). 项目地址: https://gitcode.com/gh_mirrors/sq/sql-beautify SQL Beautify是一款专为Visual Studio Code设计的智能代码格式化工…

告别繁琐配置!用gpt-oss-20b-WEBUI镜像快速体验MoE架构

告别繁琐配置&#xff01;用gpt-oss-20b-WEBUI镜像快速体验MoE架构 1. 为什么你需要关注这个镜像&#xff1f; 你是不是也经历过为了跑一个大模型&#xff0c;折腾一整天&#xff1a;装驱动、配环境、下依赖、调参数……最后显存不够&#xff0c;功亏一篑&#xff1f;如果你受…

超越仿真:用形式化验证为你的VHDL设计戴上“数学安全帽”

当传统仿真测试在千万个测试向量中苦苦搜寻漏洞时,有一种方法能用数学证明你的设计万无一失——这就是形式化验证的力量。 在数字电路设计中,每个工程师都面临一个共同挑战:如何确保设计完全正确?传统仿真方法就像在黑夜中打手电筒寻找丢失的钥匙,光束覆盖的区域有限,而形…

浏览器直接预览Markdown文件的完整解决方案

浏览器直接预览Markdown文件的完整解决方案 【免费下载链接】markdown-viewer Markdown Viewer / Browser Extension 项目地址: https://gitcode.com/gh_mirrors/ma/markdown-viewer 还在为每次查看Markdown文件都要下载到本地而烦恼吗&#xff1f;是否经常遇到在线文档…

Honey Select 2终极汉化教程:5步打造完美中文游戏体验

Honey Select 2终极汉化教程&#xff1a;5步打造完美中文游戏体验 【免费下载链接】HS2-HF_Patch Automatically translate, uncensor and update HoneySelect2! 项目地址: https://gitcode.com/gh_mirrors/hs/HS2-HF_Patch 还在为Honey Select 2的日文界面而苦恼吗&…

Honey Select 2汉化革命:从语言障碍到沉浸体验的蜕变之旅

Honey Select 2汉化革命&#xff1a;从语言障碍到沉浸体验的蜕变之旅 【免费下载链接】HS2-HF_Patch Automatically translate, uncensor and update HoneySelect2! 项目地址: https://gitcode.com/gh_mirrors/hs/HS2-HF_Patch 你是否曾因语言隔阂而错过游戏中的精彩剧情…