WebRTC - 语音演示
WebRTC - 现代语音演示
Section titled “WebRTC - 现代语音演示”在本章中,我们将构建一个客户端应用程序,使其能够在独立设备上的两名用户之间使用 WebRTC 音频流进行通信。我们的应用程序将有两个主要视图:一个用于登录,另一个用于发起音频呼叫。
为了便于理解,原教程在此处使用了一张图片,描绘了一个带有用户名输入框和“登录”按钮的简单登录页面,以及一个显示“本地音频”和“远端音频”元素、一个用于“要呼叫的用户名”的输入框、“呼叫”按钮和“挂断”按钮的呼叫页面。我们将在 HTML 部分描述这些 UI 元素。
这两个视图将通过切换 div 元素的显示来管理。用户交互将通过 JavaScript 事件监听器进行处理。
信令服务器 (Node.js + ws)
Section titled “信令服务器 (Node.js + ws)”要建立 WebRTC 连接,客户端需要一种方式来交换元数据(例如 SDP 要约/应答和 ICE 候选者),然后才能形成直接的对等(peer-to-peer)连接。这个过程称为信令(Signaling)。我们将为此使用 WebSockets,它提供客户端和我们的 Node.js 信令服务器之间的双向通信通道。请确保您已安装 Node.js 和 ws 库(运行 npm install ws)。
// server.jsconst 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 函数在连接关闭或发生错误时清理用户状态。
客户端应用 (HTML & JavaScript)
Section titled “客户端应用 (HTML & JavaScript)”要测试此应用程序,您通常会打开两个浏览器标签页(或同一网络上允许进行 STUN/TURN 设置的不同浏览器/设备),并尝试在它们之间进行音频呼叫。
首先,创建一个 index.html 文件。我们将使用 Bootstrap 的 CDN 来简化样式。创建一个文件夹(例如,webrtc-voice-app),并将 index.html 和 client.js(接下来创建)放入其中。
<!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.jslet 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,以确保后续呼叫处于干净状态。
运行演示:
- 保存
server.js、index.html和client.js。 - 打开终端,导航到包含
server.js的文件夹,然后运行node server.js。 - 在两个不同的浏览器标签页中打开
index.html(如果您的 STUN/TURN 设置允许,也可以在同一网络上的不同浏览器/设备上打开)。 - 使用唯一的用户名在每个标签页中登录。
- 在“要呼叫的用户名”字段中输入另一个标签页的用户名,然后点击“呼叫”。
您应该能够建立音频呼叫。浏览器的开发者控制台将显示来自客户端和服务器的日志,这有助于故障排除。例如,原教程显示了一张图片,其中包含确认已连接到信令服务器的控制台输出。现在通过 console.log 来实现这一点。
原教程中的另一张图片显示了登录后本地音频流正在播放。现在由 localAudio 元素播放您麦克风的输入来表示这一点。
进一步的图片描绘了两个标签页成功登录(例如,“userA”和“userB”),然后是它们之间的活动通话,接着是挂断。此功能正是更新后的代码旨在利用现代 API 复制的。
此示例专注于核心 WebRTC 音频聊天功能。对于生产应用程序,请考虑更健壮的错误处理、UI/UX 改进,以及可能使用 TURN 服务器来处理限制性 NAT/防火墙。关于 STUN/TURN 的更多信息可以在讨论 NAT 穿越的章节中找到。