Skip to content

WebRTC - 语音演示

在本章中,我们将构建一个客户端应用程序,使其能够在独立设备上的两名用户之间使用 WebRTC 音频流进行通信。我们的应用程序将有两个主要视图:一个用于登录,另一个用于发起音频呼叫。

为了便于理解,原教程在此处使用了一张图片,描绘了一个带有用户名输入框和“登录”按钮的简单登录页面,以及一个显示“本地音频”和“远端音频”元素、一个用于“要呼叫的用户名”的输入框、“呼叫”按钮和“挂断”按钮的呼叫页面。我们将在 HTML 部分描述这些 UI 元素。

这两个视图将通过切换 div 元素的显示来管理。用户交互将通过 JavaScript 事件监听器进行处理。

要建立 WebRTC 连接,客户端需要一种方式来交换元数据(例如 SDP 要约/应答和 ICE 候选者),然后才能形成直接的对等(peer-to-peer)连接。这个过程称为信令(Signaling)。我们将为此使用 WebSockets,它提供客户端和我们的 Node.js 信令服务器之间的双向通信通道。请确保您已安装 Node.js 和 ws 库(运行 npm install ws)。

// server.js
const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 9090 });
// 存储已连接用户
const users = {};
console.log('Signaling server started on ws://localhost:9090');
wss.on('connection', (connection) => {
console.log('User connected');
connection.on('message', (message) => {
let data;
try {
data = JSON.parse(message);
} catch (e) {
console.log('Invalid JSON received:', message);
data = {};
}
console.log('Received message:', data);
switch (data.type) {
case 'login':
handleLogin(connection, data);
break;
case 'offer':
handleOffer(connection, data);
break;
case 'answer':
handleAnswer(connection, data);
break;
case 'candidate':
handleCandidate(connection, data);
break;
case 'leave':
handleLeave(connection, data);
break;
default:
sendTo(connection, {
type: 'error',
message: `Command not found: ${data.type}`
});
break;
}
});
connection.on('close', () => {
handleDisconnect(connection);
});
connection.on('error', (error) => {
console.error('Connection error:', error);
handleDisconnect(connection); // 将错误视为断开连接
});
// 发送欢迎消息(可选)
// sendTo(connection, { type: 'info', message: 'Connected to signaling server' });
});
function sendTo(conn, message) {
try {
conn.send(JSON.stringify(message));
} catch (error) {
console.error('Error sending message:', error, 'to user:', conn.name);
}
}
function handleLogin(connection, data) {
if (users[data.name]) {
sendTo(connection, { type: 'login', success: false, message: 'Username is already taken.' });
} else {
users[data.name] = connection;
connection.name = data.name;
console.log(`User logged in: ${data.name}`);
sendTo(connection, { type: 'login', success: true });
}
}
function handleOffer(connection, data) {
const targetUser = users[data.name];
if (targetUser) {
console.log(`Sending offer from ${connection.name} to ${data.name}`);
connection.otherName = data.name; // 跟踪此用户正在尝试连接到谁
sendTo(targetUser, {
type: 'offer',
offer: data.offer,
name: connection.name
});
} else {
console.log(`User ${data.name} not found for offer.`);
sendTo(connection, { type: 'error', message: `User ${data.name} not found.` });
}
}
function handleAnswer(connection, data) {
const targetUser = users[data.name];
if (targetUser) {
console.log(`Sending answer from ${connection.name} to ${data.name}`);
connection.otherName = data.name;
sendTo(targetUser, {
type: 'answer',
answer: data.answer
});
} else {
console.log(`User ${data.name} not found for answer.`);
}
}
function handleCandidate(connection, data) {
const targetUser = users[data.name];
if (targetUser) {
// console.log(`Sending ICE candidate from ${connection.name} to ${data.name}`); // 可能非常详细
sendTo(targetUser, {
type: 'candidate',
candidate: data.candidate
});
} else {
// console.log(`User ${data.name} not found for ICE candidate.`); // 可能非常详细
}
}
function handleLeave(connection, data) {
const targetUser = users[data.name]; // 要通知的用户
console.log(`User ${connection.name || 'unknown'} is leaving call with ${data.name}`);
if (targetUser) {
targetUser.otherName = null;
sendTo(targetUser, { type: 'leave' });
}
// 如果当前用户发起挂断,也为他们进行清理
if (connection.otherName === data.name) {
connection.otherName = null;
}
}
function handleDisconnect(connection) {
console.log(`User ${connection.name || 'unknown'} disconnected`);
if (connection.name && users[connection.name]) {
delete users[connection.name];
}
// 如果对方在通话中,通知对方用户
if (connection.otherName) {
const targetUser = users[connection.otherName];
if (targetUser) {
console.log(`Notifying ${connection.otherName} about ${connection.name || 'unknown'}'s disconnect.`);
targetUser.otherName = null;
sendTo(targetUser, { type: 'leave' });
}
}
}

此服务器监听端口 9090 上的 WebSocket 连接。它处理不同类型的消息,用于登录、WebRTC 信令(要约、应答、ICE 候选者)以及断开连接(leave)。

  • 它将连接的用户存储在一个简单的 users 对象中,以用户名作为键。
  • 登录检查用户名是否唯一。
  • 要约(Offer)、应答(Answer)和候选者(Candidate)消息会被中继到指定的目标用户。
  • leave 消息和断开连接会通知对方进行 WebRTC 连接清理。

sendTo 函数是一个辅助函数,用于向特定连接发送 JSON 消息。handleDisconnect 函数在连接关闭或发生错误时清理用户状态。

要测试此应用程序,您通常会打开两个浏览器标签页(或同一网络上允许进行 STUN/TURN 设置的不同浏览器/设备),并尝试在它们之间进行音频呼叫。

首先,创建一个 index.html 文件。我们将使用 Bootstrap 的 CDN 来简化样式。创建一个文件夹(例如,webrtc-voice-app),并将 index.html 和 client.js(接下来创建)放入其中。

index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WebRTC Voice Demo</title>
<link href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css" rel="stylesheet">
<style>
body { background: #f0f0f0; padding: 20px; }
.container { max-width: 600px; margin: auto; background: white; padding: 20px; border-radius: 8px; box-shadow: 0 0 10px rgba(0,0,0,0.1); }
#loginPage, #callPage { margin-top: 20px; }
audio { width: 100%; margin-top: 10px; }
</style>
</head>
<body>
<div class="container">
<div id="loginPage">
<h2 class="text-center">WebRTC Voice Demo</h2>
<p class="text-center">Please sign in</p>
<div class="form-group">
<input type="text" id="usernameInput" class="form-control" placeholder="Enter your username" autofocus required>
</div>
<button id="loginBtn" class="btn btn-primary btn-block">Sign In</button>
</div>
<div id="callPage" style="display: none;">
<h3 class="text-center">Hi, <span id="currentUser"></span>!</h3>
<div class="row">
<div class="col-md-6">
<h4>Local Audio</h4>
<audio id="localAudio" controls autoplay muted></audio> <!-- 静音以防止回声 -->
</div>
<div class="col-md-6">
<h4>Remote Audio</h4>
<audio id="remoteAudio" controls autoplay></audio>
</div>
</div>
<hr>
<div class="form-group">
<input type="text" id="callToUsernameInput" class="form-control" placeholder="Username to call">
</div>
<button id="callBtn" class="btn btn-success">Call</button>
<button id="hangUpBtn" class="btn btn-danger">Hang Up</button>
</div>
</div>
<script src="client.js"></script>
</body>
</html>

此 HTML 设置了两个主要的 div 部分:loginPage 和 callPage。它包含用于用户名、目标用户名的输入字段和操作按钮。提供了两个 <audio> 元素用于本地和远端音频流。我们链接到 client.js,它将包含我们的 WebRTC 逻辑。

现在,在同一文件夹中创建 client.js:

// client.js
let name;
let connectedUser;
let localStream;
let peerConnection;
const wsURL = 'ws://localhost:9090';
const connection = new WebSocket(wsURL);
// DOM 元素
const loginPage = document.querySelector('#loginPage');
const usernameInput = document.querySelector('#usernameInput');
const loginBtn = document.querySelector('#loginBtn');
const callPage = document.querySelector('#callPage');
const currentUserDisplay = document.querySelector('#currentUser');
const callToUsernameInput = document.querySelector('#callToUsernameInput');
const callBtn = document.querySelector('#callBtn');
const hangUpBtn = document.querySelector('#hangUpBtn');
const localAudio = document.querySelector('#localAudio');
const remoteAudio = document.querySelector('#remoteAudio');
// ICE 服务器配置
const configuration = {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
// 如果需要,您可以在此处添加更多 STUN/TURN 服务器
// { urls: 'turn:your.turn.server:port', username: 'user', credential: 'password' }
]
};
connection.onopen = () => {
console.log('Connected to signaling server');
};
connection.onmessage = async (msg) => {
console.log('Got message', msg.data);
const data = JSON.parse(msg.data);
switch (data.type) {
case 'login':
handleLogin(data.success, data.message);
break;
case 'offer':
await handleOffer(data.offer, data.name);
break;
case 'answer':
await handleAnswer(data.answer);
break;
case 'candidate':
await handleCandidate(data.candidate);
break;
case 'leave':
handleLeave();
break;
case 'error':
alert(`Error: ${data.message}`);
break;
default:
break;
}
};
connection.onerror = (err) => {
console.error('WebSocket error:', err);
alert('Error connecting to signaling server. Please ensure it is running and accessible.');
};
function send(message) {
if (connectedUser) {
message.name = connectedUser; // 信令消息的目标用户
}
try {
connection.send(JSON.stringify(message));
} catch (error) {
console.error('Error sending WebSocket message:', error);
}
}
// UI 事件监听器
loginBtn.addEventListener('click', () => {
name = usernameInput.value;
if (name.length > 0) {
send({ type: 'login', name: name });
} else {
alert('Please enter a username.');
}
});
async function handleLogin(success, message) {
if (success === false) {
alert(`Login failed: ${message || 'Try a different username.'}`);
} else {
loginPage.style.display = 'none';
callPage.style.display = 'block';
currentUserDisplay.textContent = name;
try {
// 获取本地音频流
localStream = await navigator.mediaDevices.getUserMedia({ video: false, audio: true });
localAudio.srcObject = localStream;
// 初始化 RTCPeerConnection
initializePeerConnection();
} catch (error) {
console.error('Error accessing media devices or initializing PeerConnection:', error);
alert('Error setting up media: ' + error.message);
// 返回登录页面或适当处理错误
loginPage.style.display = 'block';
callPage.style.display = 'none';
}
}
}
function initializePeerConnection() {
peerConnection = new RTCPeerConnection(configuration);
// 将本地流的轨道添加到对等连接
localStream.getTracks().forEach(track => peerConnection.addTrack(track, localStream));
// 处理传入的远端轨道
peerConnection.ontrack = (event) => {
console.log('Remote track received');
if (event.streams && event.streams[0]) {
remoteAudio.srcObject = event.streams[0];
} else {
// 针对旧浏览器或特定轨道事件场景的备用处理
let inboundStream = new MediaStream();
inboundStream.addTrack(event.track);
remoteAudio.srcObject = inboundStream;
}
};
// 处理 ICE 候选者
peerConnection.onicecandidate = (event) => {
if (event.candidate) {
send({ type: 'candidate', candidate: event.candidate });
}
};
// 可选:记录连接状态变化
peerConnection.oniceconnectionstatechange = () => {
console.log(`ICE connection state: ${peerConnection.iceConnectionState}`);
if (peerConnection.iceConnectionState === 'failed' ||
peerConnection.iceConnectionState === 'disconnected' ||
peerConnection.iceConnectionState === 'closed') {
// 可在此处处理连接失败,例如提示用户或尝试重新连接
// 对于此演示,我们主要依赖 'leave' 信令或手动挂断
}
};
}
callBtn.addEventListener('click', async () => {
const callToUsername = callToUsernameInput.value;
if (callToUsername.length === 0) {
alert('Please enter a username to call.');
return;
}
if (callToUsername === name) {
alert('You cannot call yourself.');
return;
}
connectedUser = callToUsername;
console.log(`Initiating call to ${connectedUser}`);
if (!peerConnection) initializePeerConnection(); // 确保 peerConnection 已初始化
try {
const offer = await peerConnection.createOffer();
await peerConnection.setLocalDescription(offer);
send({ type: 'offer', offer: offer });
} catch (error) {
console.error('Error creating offer:', error);
alert('Error creating call offer: ' + error.message);
}
});
async function handleOffer(offer, fromName) {
if (!peerConnection) initializePeerConnection(); // 确保 PC 已准备好接收远端要约
connectedUser = fromName;
console.log(`Received offer from ${connectedUser}`);
try {
await peerConnection.setRemoteDescription(new RTCSessionDescription(offer));
const answer = await peerConnection.createAnswer();
await peerConnection.setLocalDescription(answer);
send({ type: 'answer', answer: answer });
} catch (error) {
console.error('Error handling offer:', error);
alert('Error handling incoming call: ' + error.message);
}
}
async function handleAnswer(answer) {
console.log('Received answer');
try {
await peerConnection.setRemoteDescription(new RTCSessionDescription(answer));
} catch (error) {
console.error('Error handling answer:', error);
}
}
async function handleCandidate(candidate) {
try {
if (candidate) {
await peerConnection.addIceCandidate(new RTCIceCandidate(candidate));
}
} catch (error) {
console.error('Error adding ICE candidate:', error);
}
}
hangUpBtn.addEventListener('click', () => {
send({ type: 'leave' });
handleLeave();
});
function handleLeave() {
console.log('Call ended or other user left.');
connectedUser = null;
if (remoteAudio) remoteAudio.srcObject = null;
if (peerConnection) {
peerConnection.close();
peerConnection.onicecandidate = null;
peerConnection.ontrack = null;
peerConnection.oniceconnectionstatechange = null;
// 最好重新初始化 peerConnection,如果要发起新的呼叫
// 为简单起见,我们可以在此处或下次尝试呼叫之前重新初始化它
// initializePeerConnection(); // 或置空并在下次呼叫时重新创建
peerConnection = null; // 置空以便下次呼叫时重新创建
}
callToUsernameInput.value = ''; // 清空输入字段
// 可选地,为新呼叫重新初始化 peerConnection
// if (localStream) initializePeerConnection(); // 如果 localStream 仍然活跃
}
// 在窗口关闭时进行清理
window.addEventListener('beforeunload', () => {
if (name) { // 如果已登录
send({ type: 'leave' }); // 通知对方用户
// 如果连接仍然活跃,进行额外清理
if (connection && connection.readyState === WebSocket.OPEN) {
// 如果您的服务器处理特定断开连接消息,则发送该消息
// 对于此示例,'leave' 已涵盖,或者服务器处理 'close' 事件。
}
}
if (peerConnection) {
peerConnection.close();
}
if (connection) {
connection.close();
}
});

client.js 中的关键更新:

  • 使用基于 Promise 的 navigator.mediaDevices.getUserMedia,而非已弃用的基于回调的 navigator.getUserMedia。
  • 使用 localAudio.srcObject = stream,而非 URL.createObjectURL。
  • 使用标准的 RTCPeerConnection 构造函数。
  • 将 addStream 替换为 addTrack(通过迭代 localStream.getTracks())。
  • 将 onaddstream 替换为 ontrack 来处理远端媒体。
  • 对 createOffer、setLocalDescription 等 WebRTC 操作使用 async/await,以获得更清晰的异步代码。
  • 包含基本的错误处理和控制台日志记录,以便更好地进行调试。
  • initializePeerConnection 函数封装了 RTCPeerConnection 及其事件处理程序的设置。此函数在成功登录和获取媒体后调用。
  • handleLeave 函数现在更彻底地清理了 peerConnection 对象,将其事件处理程序设置为 null 并关闭它。重要的是重新初始化或置空 peerConnection,以确保后续呼叫处于干净状态。

运行演示:

  1. 保存 server.js、index.html 和 client.js。
  2. 打开终端,导航到包含 server.js 的文件夹,然后运行 node server.js。
  3. 在两个不同的浏览器标签页中打开 index.html(如果您的 STUN/TURN 设置允许,也可以在同一网络上的不同浏览器/设备上打开)。
  4. 使用唯一的用户名在每个标签页中登录。
  5. 在“要呼叫的用户名”字段中输入另一个标签页的用户名,然后点击“呼叫”。

您应该能够建立音频呼叫。浏览器的开发者控制台将显示来自客户端和服务器的日志,这有助于故障排除。例如,原教程显示了一张图片,其中包含确认已连接到信令服务器的控制台输出。现在通过 console.log 来实现这一点。

原教程中的另一张图片显示了登录后本地音频流正在播放。现在由 localAudio 元素播放您麦克风的输入来表示这一点。

进一步的图片描绘了两个标签页成功登录(例如,“userA”和“userB”),然后是它们之间的活动通话,接着是挂断。此功能正是更新后的代码旨在利用现代 API 复制的。

此示例专注于核心 WebRTC 音频聊天功能。对于生产应用程序,请考虑更健壮的错误处理、UI/UX 改进,以及可能使用 TURN 服务器来处理限制性 NAT/防火墙。关于 STUN/TURN 的更多信息可以在讨论 NAT 穿越的章节中找到。