使用NodeJS构建Lite-Avatar实时通信服务:WebRTC技术实践
使用NodeJS构建Lite-Avatar实时通信服务:WebRTC技术实践
想象一下,你正在开发一个数字人客服系统,用户通过网页就能和虚拟形象进行实时视频对话。用户说话,数字人实时回应,表情和口型都能完美同步。听起来很酷,对吧?但当你真正开始动手,会发现一堆技术难题摆在面前:视频流怎么传输?不同网络环境怎么打通?服务器怎么协调两端连接?
这就是我们今天要解决的问题。基于Node.js和WebRTC技术,我来带你一步步搭建一个支持Lite-Avatar的实时视频通信服务。不用怕,我会用最直白的方式,把那些听起来高大上的技术概念拆解成你能理解的实际操作。
1. 为什么需要专门的通信服务?
你可能在想,现在不是有很多现成的视频会议方案吗?为什么还要自己搭?问得好,这恰恰是理解整个方案价值的关键。
Lite-Avatar这类数字人应用有个特殊需求:它不只是传输视频,还要实时驱动虚拟形象的口型和表情。这意味着音频数据需要被后端处理,生成对应的面部动画参数,再实时推送给前端。传统的视频会议方案很难满足这种定制化的数据流转需求。
我遇到过不少团队,一开始想用现成的方案凑合,结果发现延迟太高,或者数据流转不灵活,最后还得回头自己搭建。所以,不如一开始就选择正确的路径。
2. 整体架构设计:一张图看懂数据流转
在开始写代码之前,咱们先看看整个系统是怎么工作的。我把核心流程画成了下面这张图,你可以边看边理解:
用户浏览器 <--WebRTC PeerConnection--> Node.js信令服务器 <--WebRTC PeerConnection--> Lite-Avatar服务端
| | |
摄像头/麦克风 协调连接、交换信息 处理音频、生成视频
| | |
采集音视频数据 NAT穿透、会话管理 驱动数字人动画
简单来说,就是两端(用户浏览器和Lite-Avatar服务)通过WebRTC直接建立点对点连接传输音视频,而我们的Node.js服务器负责在中间牵线搭桥,告诉它们对方在哪里、怎么连接。
这种架构有个明显的好处:音视频数据不经过我们的服务器中转,大大减轻了服务器负担,也降低了延迟。我们的服务器只做协调工作,就像婚介所,介绍双方认识后,就让它们自己聊天去了。
3. 搭建基础信令服务器
信令服务器是整个系统的“大脑”,它负责协调两端建立连接。咱们先用最基础的Express框架搭个架子。
3.1 项目初始化与依赖安装
首先,创建一个新项目并安装必要的包:
# 创建项目目录
mkdir lite-avatar-signaling
cd lite-avatar-signaling
# 初始化项目
npm init -y
# 安装核心依赖
npm install express socket.io
npm install -D nodemon
# 创建基础目录结构
mkdir -p src/public
3.2 实现基础信令服务器
接下来,我们创建一个简单的信令服务器。这个服务器主要做三件事:处理HTTP请求、管理WebSocket连接、协调WebRTC信令交换。
// src/server.js
const express = require('express');
const http = require('http');
const socketIo = require('socket.io');
const path = require('path');
// 创建Express应用和HTTP服务器
const app = express();
const server = http.createServer(app);
const io = socketIo(server, {
cors: {
origin: "*", // 生产环境记得限制来源
methods: ["GET", "POST"]
}
});
// 提供静态文件(前端页面)
app.use(express.static(path.join(__dirname, 'public')));
// 存储房间和用户信息
const rooms = new Map();
// Socket.io连接处理
io.on('connection', (socket) => {
console.log(`用户连接: ${socket.id}`);
// 加入房间
socket.on('join-room', (roomId, userId) => {
console.log(`${userId} 尝试加入房间 ${roomId}`);
// 创建或获取房间
if (!rooms.has(roomId)) {
rooms.set(roomId, new Set());
}
const room = rooms.get(roomId);
// 检查房间是否已满(这里假设最多2人)
if (room.size >= 2) {
socket.emit('room-full', { roomId });
return;
}
// 加入房间
room.add(userId);
socket.join(roomId);
socket.roomId = roomId;
socket.userId = userId;
console.log(`${userId} 成功加入房间 ${roomId}, 当前人数: ${room.size}`);
// 通知房间内其他用户
socket.to(roomId).emit('user-joined', { userId });
// 如果是第一个用户,等待第二个用户
if (room.size === 1) {
socket.emit('waiting-for-peer', { roomId });
} else {
// 第二个用户加入,开始交换信令
io.to(roomId).emit('start-signaling', { roomId });
}
});
// 转发WebRTC信令
socket.on('signal', (data) => {
const { to, signal } = data;
console.log(`转发信令从 ${socket.userId} 到 ${to}`);
socket.to(to).emit('signal', {
from: socket.userId,
signal: signal
});
});
// 处理断开连接
socket.on('disconnect', () => {
console.log(`用户断开连接: ${socket.id}`);
if (socket.roomId && socket.userId) {
const room = rooms.get(socket.roomId);
if (room) {
room.delete(socket.userId);
// 通知房间内其他用户
socket.to(socket.roomId).emit('user-left', { userId: socket.userId });
// 如果房间为空,清理房间
if (room.size === 0) {
rooms.delete(socket.roomId);
console.log(`房间 ${socket.roomId} 已被清理`);
}
}
}
});
});
// 启动服务器
const PORT = process.env.PORT || 3000;
server.listen(PORT, () => {
console.log(`信令服务器运行在 http://localhost:${PORT}`);
});
这个服务器虽然简单,但已经具备了核心功能。它管理房间,转发信令消息,处理用户加入和离开。你可以看到,我们用了Socket.io来处理实时通信,这比原始的WebSocket更方便。
3.3 创建简单的前端测试页面
为了测试我们的服务器,我们需要一个简单的前端页面:
<!-- src/public/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>Lite-Avatar 信令服务器测试</title>
<style>
body { font-family: Arial, sans-serif; max-width: 800px; margin: 0 auto; padding: 20px; }
.container { display: flex; flex-direction: column; gap: 20px; }
.control-panel { background: #f5f5f5; padding: 20px; border-radius: 8px; }
.log-panel { background: #333; color: #fff; padding: 20px; border-radius: 8px; min-height: 200px; }
input, button { padding: 10px; margin: 5px; font-size: 16px; }
.log-entry { margin: 5px 0; padding: 5px; background: #444; border-radius: 3px; }
</style>
</head>
<body>
<div class="container">
<h1>Lite-Avatar 信令服务器测试</h1>
<div class="control-panel">
<h3>连接控制</h3>
<div>
<input type="text" id="roomId" placeholder="房间ID" value="test-room">
<input type="text" id="userId" placeholder="用户ID" value="user-1">
<button onclick="joinRoom()">加入房间</button>
<button onclick="leaveRoom()">离开房间</button>
</div>
<h3>测试信令</h3>
<div>
<input type="text" id="targetUser" placeholder="目标用户ID">
<input type="text" id="message" placeholder="测试消息">
<button onclick="sendSignal()">发送信令</button>
</div>
</div>
<div class="log-panel">
<h3>日志输出</h3>
<div id="logOutput"></div>
</div>
</div>
<script src="/socket.io/socket.io.js"></script>
<script>
let socket = null;
let currentRoom = null;
let currentUser = null;
function log(message) {
const logOutput = document.getElementById('logOutput');
const entry = document.createElement('div');
entry.className = 'log-entry';
entry.textContent = `[${new Date().toLocaleTimeString()}] ${message}`;
logOutput.appendChild(entry);
logOutput.scrollTop = logOutput.scrollHeight;
}
function joinRoom() {
const roomId = document.getElementById('roomId').value;
const userId = document.getElementById('userId').value;
if (!socket) {
socket = io();
socket.on('connect', () => {
log(`已连接到服务器,Socket ID: ${socket.id}`);
});
socket.on('user-joined', (data) => {
log(`用户加入: ${data.userId}`);
});
socket.on('user-left', (data) => {
log(`用户离开: ${data.userId}`);
});
socket.on('waiting-for-peer', (data) => {
log(`等待其他用户加入房间 ${data.roomId}...`);
});
socket.on('start-signaling', (data) => {
log(`可以开始交换WebRTC信令了!房间: ${data.roomId}`);
});
socket.on('signal', (data) => {
log(`收到来自 ${data.from} 的信令: ${JSON.stringify(data.signal).substring(0, 100)}...`);
});
socket.on('room-full', (data) => {
log(`房间 ${data.roomId} 已满,无法加入`);
});
socket.on('disconnect', () => {
log('与服务器断开连接');
});
}
socket.emit('join-room', roomId, userId);
currentRoom = roomId;
currentUser = userId;
log(`尝试加入房间: ${roomId}, 用户: ${userId}`);
}
function leaveRoom() {
if (socket) {
socket.disconnect();
socket = null;
currentRoom = null;
currentUser = null;
log('已离开房间并断开连接');
}
}
function sendSignal() {
if (!socket) {
log('请先加入房间');
return;
}
const targetUser = document.getElementById('targetUser').value;
const message = document.getElementById('message').value;
if (!targetUser) {
log('请输入目标用户ID');
return;
}
const signal = {
type: 'test-message',
message: message,
timestamp: Date.now()
};
socket.emit('signal', {
to: targetUser,
signal: signal
});
log(`向 ${targetUser} 发送测试信令: ${message}`);
}
// 页面加载时自动连接
window.addEventListener('load', () => {
log('页面加载完成,可以开始测试');
});
</script>
</body>
</html>
现在,运行 npm start(记得在package.json里配置启动脚本),打开浏览器访问 http://localhost:3000,你应该能看到测试页面。打开两个标签页,分别用不同的用户ID加入同一个房间,就能测试信令转发功能了。
4. 集成WebRTC:建立真正的音视频连接
信令服务器搭好了,现在我们来加入WebRTC的核心功能。WebRTC让浏览器之间可以直接传输音视频数据,不需要经过服务器中转。
4.1 扩展信令服务器支持WebRTC
我们需要修改服务器,让它能处理更复杂的WebRTC信令:
// 在现有的socket.io事件处理中添加
socket.on('offer', (data) => {
const { to, offer } = data;
console.log(`转发offer从 ${socket.userId} 到 ${to}`);
socket.to(to).emit('offer', {
from: socket.userId,
offer: offer
});
});
socket.on('answer', (data) => {
const { to, answer } = data;
console.log(`转发answer从 ${socket.userId} 到 ${to}`);
socket.to(to).emit('answer', {
from: socket.userId,
answer: answer
});
});
socket.on('ice-candidate', (data) => {
const { to, candidate } = data;
socket.to(to).emit('ice-candidate', {
from: socket.userId,
candidate: candidate
});
});
4.2 创建完整的WebRTC客户端
现在我们来创建一个更完整的前端,包含真正的WebRTC功能:
<!-- src/public/webrtc-client.html -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Lite-Avatar WebRTC客户端</title>
<style>
body { font-family: Arial, sans-serif; max-width: 1000px; margin: 0 auto; padding: 20px; }
.container { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
.video-container { background: #000; border-radius: 8px; overflow: hidden; }
video { width: 100%; height: auto; }
.control-panel { background: #f5f5f5; padding: 20px; border-radius: 8px; grid-column: span 2; }
.log-panel { background: #333; color: #fff; padding: 20px; border-radius: 8px; min-height: 200px; grid-column: span 2; }
button { padding: 10px 20px; margin: 5px; font-size: 16px; cursor: pointer; }
button:disabled { opacity: 0.5; cursor: not-allowed; }
.status { padding: 10px; margin: 10px 0; border-radius: 5px; }
.connected { background: #d4edda; color: #155724; }
.disconnected { background: #f8d7da; color: #721c24; }
</style>
</head>
<body>
<h1>Lite-Avatar WebRTC视频通话</h1>
<div class="container">
<div class="video-container">
<h3>本地视频</h3>
<video id="localVideo" autoplay muted playsinline></video>
</div>
<div class="video-container">
<h3>远程视频</h3>
<video id="remoteVideo" autoplay playsinline></video>
</div>
<div class="control-panel">
<h3>连接控制</h3>
<div>
<input type="text" id="roomId" placeholder="房间ID" value="avatar-room">
<input type="text" id="userId" placeholder="用户ID" value="user-" + Math.floor(Math.random() * 1000)">
<button id="joinBtn" onclick="joinRoom()">加入房间</button>
<button id="leaveBtn" onclick="leaveRoom()" disabled>离开房间</button>
</div>
<div>
<button id="startVideoBtn" onclick="startVideo()" disabled>开启摄像头</button>
<button id="stopVideoBtn" onclick="stopVideo()" disabled>关闭摄像头</button>
<button id="startAudioBtn" onclick="startAudio()" disabled>开启麦克风</button>
<button id="stopAudioBtn" onclick="stopAudio()" disabled>关闭麦克风</button>
</div>
<div id="connectionStatus" class="status disconnected">
状态: 未连接
</div>
</div>
<div class="log-panel">
<h3>连接日志</h3>
<div id="logOutput"></div>
</div>
</div>
<script src="/socket.io/socket.io.js"></script>
<script>
// 全局变量
let socket = null;
let peerConnection = null;
let localStream = null;
let remoteStream = null;
let currentRoom = null;
let currentUser = null;
// WebRTC配置
const configuration = {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' }
]
};
// 日志函数
function log(message) {
const logOutput = document.getElementById('logOutput');
const entry = document.createElement('div');
entry.textContent = `[${new Date().toLocaleTimeString()}] ${message}`;
logOutput.appendChild(entry);
logOutput.scrollTop = logOutput.scrollHeight;
console.log(message);
}
// 更新状态显示
function updateStatus(message, isConnected) {
const statusDiv = document.getElementById('connectionStatus');
statusDiv.textContent = `状态: ${message}`;
statusDiv.className = `status ${isConnected ? 'connected' : 'disconnected'}`;
}
// 加入房间
async function joinRoom() {
const roomId = document.getElementById('roomId').value;
const userId = document.getElementById('userId').value;
if (!roomId || !userId) {
alert('请输入房间ID和用户ID');
return;
}
// 连接Socket.io
socket = io();
currentRoom = roomId;
currentUser = userId;
// 设置Socket.io事件监听
setupSocketListeners();
// 加入房间
socket.emit('join-room', roomId, userId);
// 更新UI状态
document.getElementById('joinBtn').disabled = true;
document.getElementById('leaveBtn').disabled = false;
document.getElementById('startVideoBtn').disabled = false;
document.getElementById('startAudioBtn').disabled = false;
updateStatus(`正在加入房间 ${roomId}...`, false);
log(`尝试加入房间: ${roomId}, 用户: ${userId}`);
}
// 设置Socket.io监听器
function setupSocketListeners() {
socket.on('connect', () => {
log(`已连接到信令服务器,Socket ID: ${socket.id}`);
});
socket.on('user-joined', async (data) => {
log(`新用户加入: ${data.userId}`);
// 如果有新用户加入,且我们还没有创建PeerConnection,就创建并发送offer
if (!peerConnection && localStream) {
await createPeerConnection();
await createAndSendOffer(data.userId);
}
});
socket.on('offer', async (data) => {
log(`收到来自 ${data.from} 的offer`);
if (!peerConnection) {
await createPeerConnection();
}
try {
await peerConnection.setRemoteDescription(new RTCSessionDescription(data.offer));
const answer = await peerConnection.createAnswer();
await peerConnection.setLocalDescription(answer);
socket.emit('answer', {
to: data.from,
answer: answer
});
log(`已发送answer到 ${data.from}`);
} catch (error) {
log(`处理offer时出错: ${error}`);
}
});
socket.on('answer', async (data) => {
log(`收到来自 ${data.from} 的answer`);
if (peerConnection && peerConnection.signalingState !== 'stable') {
try {
await peerConnection.setRemoteDescription(new RTCSessionDescription(data.answer));
log(`远程描述设置完成`);
} catch (error) {
log(`设置远程描述时出错: ${error}`);
}
}
});
socket.on('ice-candidate', async (data) => {
log(`收到来自 ${data.from} 的ICE候选`);
if (peerConnection) {
try {
await peerConnection.addIceCandidate(new RTCIceCandidate(data.candidate));
} catch (error) {
log(`添加ICE候选时出错: ${error}`);
}
}
});
socket.on('user-left', (data) => {
log(`用户离开: ${data.userId}`);
// 清理PeerConnection
if (peerConnection) {
peerConnection.close();
peerConnection = null;
}
// 清理远程视频
const remoteVideo = document.getElementById('remoteVideo');
remoteVideo.srcObject = null;
updateStatus('对方已断开连接', false);
});
socket.on('start-signaling', (data) => {
log(`房间 ${data.roomId} 已就绪,可以开始WebRTC连接`);
updateStatus('等待媒体流...', false);
});
socket.on('disconnect', () => {
log('与信令服务器断开连接');
updateStatus('服务器连接断开', false);
});
}
// 创建PeerConnection
async function createPeerConnection() {
try {
peerConnection = new RTCPeerConnection(configuration);
log('创建新的PeerConnection');
// 添加本地流
if (localStream) {
localStream.getTracks().forEach(track => {
peerConnection.addTrack(track, localStream);
});
log('已添加本地媒体轨道到PeerConnection');
}
// 设置远程流处理
peerConnection.ontrack = (event) => {
log('收到远程媒体流');
const remoteVideo = document.getElementById('remoteVideo');
if (!remoteVideo.srcObject) {
remoteVideo.srcObject = event.streams[0];
updateStatus('已建立视频连接', true);
}
};
// ICE候选处理
peerConnection.onicecandidate = (event) => {
if (event.candidate) {
// 获取房间内其他用户
socket.emit('ice-candidate', {
to: getOtherUserInRoom(),
candidate: event.candidate
});
}
};
// 连接状态变化
peerConnection.onconnectionstatechange = () => {
log(`PeerConnection状态: ${peerConnection.connectionState}`);
if (peerConnection.connectionState === 'connected') {
updateStatus('WebRTC连接已建立', true);
} else if (peerConnection.connectionState === 'disconnected' ||
peerConnection.connectionState === 'failed') {
updateStatus('WebRTC连接断开', false);
}
};
return peerConnection;
} catch (error) {
log(`创建PeerConnection失败: ${error}`);
throw error;
}
}
// 创建并发送offer
async function createAndSendOffer(targetUser) {
try {
const offer = await peerConnection.createOffer();
await peerConnection.setLocalDescription(offer);
socket.emit('offer', {
to: targetUser,
offer: offer
});
log(`已发送offer到 ${targetUser}`);
} catch (error) {
log(`创建或发送offer时出错: ${error}`);
}
}
// 获取房间内其他用户(简化实现)
function getOtherUserInRoom() {
// 这里应该从服务器获取房间内其他用户
// 简化实现:假设我们知道目标用户
return currentUser === 'user-1' ? 'user-2' : 'user-1';
}
// 开启摄像头
async function startVideo() {
try {
localStream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: false
});
const localVideo = document.getElementById('localVideo');
localVideo.srcObject = localStream;
document.getElementById('startVideoBtn').disabled = true;
document.getElementById('stopVideoBtn').disabled = false;
log('摄像头已开启');
// 如果有PeerConnection,更新轨道
if (peerConnection) {
const videoTrack = localStream.getVideoTracks()[0];
const sender = peerConnection.getSenders().find(s =>
s.track && s.track.kind === 'video'
);
if (sender) {
sender.replaceTrack(videoTrack);
} else {
peerConnection.addTrack(videoTrack, localStream);
}
}
} catch (error) {
log(`开启摄像头失败: ${error}`);
}
}
// 关闭摄像头
function stopVideo() {
if (localStream) {
const videoTrack = localStream.getVideoTracks()[0];
if (videoTrack) {
videoTrack.stop();
}
const localVideo = document.getElementById('localVideo');
localVideo.srcObject = null;
document.getElementById('startVideoBtn').disabled = false;
document.getElementById('stopVideoBtn').disabled = true;
log('摄像头已关闭');
}
}
// 开启麦克风
async function startAudio() {
try {
if (!localStream) {
localStream = await navigator.mediaDevices.getUserMedia({
video: false,
audio: true
});
} else {
const audioStream = await navigator.mediaDevices.getUserMedia({
audio: true
});
audioStream.getAudioTracks().forEach(track => {
localStream.addTrack(track);
});
}
document.getElementById('startAudioBtn').disabled = true;
document.getElementById('stopAudioBtn').disabled = false;
log('麦克风已开启');
// 如果有PeerConnection,更新轨道
if (peerConnection) {
const audioTrack = localStream.getAudioTracks()[0];
const sender = peerConnection.getSenders().find(s =>
s.track && s.track.kind === 'audio'
);
if (sender) {
sender.replaceTrack(audioTrack);
} else {
peerConnection.addTrack(audioTrack, localStream);
}
}
} catch (error) {
log(`开启麦克风失败: ${error}`);
}
}
// 关闭麦克风
function stopAudio() {
if (localStream) {
const audioTracks = localStream.getAudioTracks();
audioTracks.forEach(track => track.stop());
document.getElementById('startAudioBtn').disabled = false;
document.getElementById('stopAudioBtn').disabled = true;
log('麦克风已关闭');
}
}
// 离开房间
function leaveRoom() {
// 关闭媒体流
if (localStream) {
localStream.getTracks().forEach(track => track.stop());
localStream = null;
}
// 关闭PeerConnection
if (peerConnection) {
peerConnection.close();
peerConnection = null;
}
// 断开Socket连接
if (socket) {
socket.disconnect();
socket = null;
}
// 清理视频元素
document.getElementById('localVideo').srcObject = null;
document.getElementById('remoteVideo').srcObject = null;
// 重置UI状态
document.getElementById('joinBtn').disabled = false;
document.getElementById('leaveBtn').disabled = true;
document.getElementById('startVideoBtn').disabled = true;
document.getElementById('stopVideoBtn').disabled = true;
document.getElementById('startAudioBtn').disabled = true;
document.getElementById('stopAudioBtn').disabled = true;
updateStatus('未连接', false);
log('已离开房间并清理所有连接');
currentRoom = null;
currentUser = null;
}
// 页面加载完成
window.addEventListener('load', () => {
log('WebRTC客户端已加载,可以开始测试');
// 检查浏览器支持
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
alert('您的浏览器不支持getUserMedia API');
return;
}
if (!window.RTCPeerConnection) {
alert('您的浏览器不支持WebRTC');
return;
}
});
</script>
</body>
</html>
这个客户端已经相当完整了。它包含了摄像头和麦克风的控制、WebRTC连接的建立和维护、以及完整的错误处理。你可以打开两个浏览器窗口(或者两个不同的浏览器),分别访问这个页面,用不同的用户ID加入同一个房间,就能看到视频通话的效果了。
5. 处理NAT穿透:让连接更可靠
如果你在本地测试,可能一切都很顺利。但当你把服务部署到公网,或者用户在不同的网络环境下,就会遇到NAT穿透的问题。简单说,就是防火墙和路由器会阻止直接连接。
5.1 添加TURN服务器支持
TURN服务器是一个中继服务器,当直接连接失败时,它会中转数据。虽然这会增加延迟,但能保证连接成功。
首先,我们需要修改WebRTC配置,添加TURN服务器:
// 更新configuration对象
const configuration = {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' },
{
urls: 'turn:your-turn-server.com:3478',
username: 'your-username',
credential: 'your-password'
},
{
urls: 'turns:your-turn-server.com:5349',
username: 'your-username',
credential: 'your-password'
}
],
iceTransportPolicy: 'all', // 尝试所有ICE候选
iceCandidatePoolSize: 10 // 增加候选数量
};
5.2 实现动态TURN服务器配置
在实际项目中,TURN服务器的配置可能需要动态获取。我们可以让服务器提供TURN配置:
// 在服务器端添加TURN配置接口
app.get('/api/turn-config', (req, res) => {
// 这里可以从数据库或环境变量获取TURN服务器配置
const turnConfig = {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{
urls: process.env.TURN_SERVER_URL || 'turn:localhost:3478',
username: process.env.TURN_USERNAME || 'test',
credential: process.env.TURN_PASSWORD || 'test'
}
]
};
// 可以添加一些安全措施,比如生成临时凭证
const timestamp = Date.now();
const expiry = timestamp + 3600000; // 1小时后过期
const username = `${timestamp}:${expiry}`;
const credential = generateTurnCredential(username, process.env.TURN_SECRET);
turnConfig.iceServers[1].username = username;
turnConfig.iceServers[1].credential = credential;
res.json(turnConfig);
});
// 生成TURN凭证的函数
function generateTurnCredential(username, secret) {
// 这里应该使用HMAC等安全算法
// 简化实现
const crypto = require('crypto');
const hmac = crypto.createHmac('sha1', secret);
hmac.update(username);
return hmac.digest('base64');
}
然后在客户端动态获取配置:
// 在客户端添加获取TURN配置的函数
async function getTurnConfig() {
try {
const response = await fetch('/api/turn-config');
const config = await response.json();
return config;
} catch (error) {
log(`获取TURN配置失败,使用默认配置: ${error}`);
return {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' }
]
};
}
}
// 修改joinRoom函数
async function joinRoom() {
// ... 其他代码 ...
// 获取动态配置
const config = await getTurnConfig();
configuration = config;
// ... 继续其他操作 ...
}
5.3 部署自己的TURN服务器
如果你需要部署自己的TURN服务器,可以使用Coturn,这是一个开源的TURN/STUN服务器:
# 安装Coturn
sudo apt-get update
sudo apt-get install coturn
# 配置Coturn
sudo nano /etc/turnserver.conf
在配置文件中,你需要设置:
# 监听地址和端口
listening-ip=0.0.0.0
listening-port=3478
tls-listening-port=5349
# 中继地址
relay-ip=你的服务器公网IP
external-ip=你的服务器公网IP
# 认证
lt-cred-mech
user=用户名:密码
realm=你的域名
# 日志
verbose
然后启动服务:
sudo systemctl start coturn
sudo systemctl enable coturn
6. 集成Lite-Avatar:完整的数字人服务
现在,我们有了可靠的WebRTC通信基础,可以开始集成Lite-Avatar了。Lite-Avatar服务端需要接收音频流,处理并生成数字人视频流。
6.1 创建Lite-Avatar服务端连接器
我们需要创建一个Node.js服务,作为Lite-Avatar服务端和信令服务器之间的桥梁:
// src/lite-avatar-connector.js
const socketIoClient = require('socket.io-client');
const { RTCPeerConnection, RTCSessionDescription } = require('wrtc');
class LiteAvatarConnector {
constructor(signalingServerUrl, avatarServiceUrl) {
this.signalingServerUrl = signalingServerUrl;
this.avatarServiceUrl = avatarServiceUrl;
this.socket = null;
this.peerConnection = null;
this.roomId = 'avatar-service-room';
this.userId = `avatar-service-${Date.now()}`;
}
async connect() {
console.log('连接信令服务器...');
// 连接信令服务器
this.socket = socketIoClient(this.signalingServerUrl);
// 设置事件监听
this.socket.on('connect', () => {
console.log('已连接到信令服务器');
this.joinRoom();
});
this.socket.on('user-joined', async (data) => {
console.log(`用户加入: ${data.userId}`);
// 如果有用户加入,创建PeerConnection
if (!this.peerConnection) {
await this.createPeerConnection();
// 如果是第一个用户,等待对方发起offer
// 如果是服务端主动连接,可以在这里创建offer
}
});
this.socket.on('offer', async (data) => {
console.log(`收到来自 ${data.from} 的offer`);
await this.handleOffer(data.from, data.offer);
});
this.socket.on('answer', async (data) => {
console.log(`收到来自 ${data.from} 的answer`);
await this.handleAnswer(data.answer);
});
this.socket.on('ice-candidate', async (data) => {
console.log(`收到ICE候选来自 ${data.from}`);
await this.handleIceCandidate(data.candidate);
});
this.socket.on('user-left', (data) => {
console.log(`用户离开: ${data.userId}`);
this.cleanup();
});
this.socket.on('disconnect', () => {
console.log('与信令服务器断开连接');
this.cleanup();
});
}
joinRoom() {
console.log(`加入房间: ${this.roomId}`);
this.socket.emit('join-room', this.roomId, this.userId);
}
async createPeerConnection() {
try {
console.log('创建PeerConnection...');
this.peerConnection = new RTCPeerConnection({
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' }
]
});
// 设置事件处理
this.peerConnection.onicecandidate = (event) => {
if (event.candidate) {
console.log('生成ICE候选');
this.socket.emit('ice-candidate', {
to: this.getTargetUser(),
candidate: event.candidate
});
}
};
this.peerConnection.ontrack = (event) => {
console.log('收到媒体轨道:', event.track.kind);
// 这里应该将音频流发送到Lite-Avatar服务
// 简化处理:只是记录日志
if (event.track.kind === 'audio') {
console.log('收到音频流,可以发送到Lite-Avatar处理');
this.sendToLiteAvatar(event.streams[0]);
}
};
this.peerConnection.onconnectionstatechange = () => {
console.log(`连接状态: ${this.peerConnection.connectionState}`);
};
console.log('PeerConnection创建成功');
return this.peerConnection;
} catch (error) {
console.error('创建PeerConnection失败:', error);
throw error;
}
}
async handleOffer(from, offer) {
try {
if (!this.peerConnection) {
await this.createPeerConnection();
}
await this.peerConnection.setRemoteDescription(
new RTCSessionDescription(offer)
);
// 创建answer
const answer = await this.peerConnection.createAnswer();
await this.peerConnection.setLocalDescription(answer);
// 发送answer
this.socket.emit('answer', {
to: from,
answer: answer
});
console.log(`已发送answer到 ${from}`);
} catch (error) {
console.error('处理offer失败:', error);
}
}
async handleAnswer(answer) {
try {
if (this.peerConnection) {
await this.peerConnection.setRemoteDescription(
new RTCSessionDescription(answer)
);
console.log('远程描述设置完成');
}
} catch (error) {
console.error('处理answer失败:', error);
}
}
async handleIceCandidate(candidate) {
try {
if (this.peerConnection) {
await this.peerConnection.addIceCandidate(
new RTCIceCandidate(candidate)
);
}
} catch (error) {
console.error('添加ICE候选失败:', error);
}
}
async sendToLiteAvatar(stream) {
// 这里应该实现将音频流发送到Lite-Avatar服务的逻辑
// 简化实现:记录日志
console.log('将音频流发送到Lite-Avatar服务...');
// 实际实现可能涉及:
// 1. 提取音频数据
// 2. 通过WebSocket或HTTP发送到Lite-Avatar服务
// 3. 接收处理后的视频流
// 4. 通过PeerConnection发送回客户端
}
getTargetUser() {
// 简化实现:假设我们知道目标用户
// 实际应该从房间信息中获取
return 'user-client';
}
cleanup() {
if (this.peerConnection) {
this.peerConnection.close();
this.peerConnection = null;
}
console.log('连接已清理');
}
disconnect() {
if (this.socket) {
this.socket.disconnect();
this.socket = null;
}
this.cleanup();
console.log('已断开所有连接');
}
}
// 使用示例
if (require.main === module) {
const connector = new LiteAvatarConnector(
'http://localhost:3000',
'http://localhost:8000' // Lite-Avatar服务地址
);
connector.connect();
// 处理程序退出
process.on('SIGINT', () => {
console.log('收到退出信号');
connector.disconnect();
process.exit(0);
});
}
module.exports = LiteAvatarConnector;
6.2 完整的部署脚本
为了方便部署,我们可以创建一个完整的部署脚本:
// scripts/deploy.js
const { spawn } = require('child_process');
const fs = require('fs');
const path = require('path');
class DeploymentManager {
constructor() {
this.processes = [];
}
startSignalingServer() {
console.log('启动信令服务器...');
const serverProcess = spawn('node', ['src/server.js'], {
stdio: 'inherit',
env: { ...process.env, PORT: 3000 }
});
this.processes.push({
name: 'signaling-server',
process: serverProcess
});
return new Promise((resolve) => {
setTimeout(resolve, 2000); // 等待2秒让服务器启动
});
}
startLiteAvatarConnector() {
console.log('启动Lite-Avatar连接器...');
const connectorProcess = spawn('node', ['src/lite-avatar-connector.js'], {
stdio: 'inherit'
});
this.processes.push({
name: 'lite-avatar-connector',
process: connectorProcess
});
return new Promise((resolve) => {
setTimeout(resolve, 1000);
});
}
startTurnServer() {
console.log('启动TURN服务器...');
// 这里假设Coturn已经安装并配置好
const turnProcess = spawn('turnserver', ['-c', '/etc/turnserver.conf'], {
stdio: 'inherit'
});
this.processes.push({
name: 'turn-server',
process: turnProcess
});
return new Promise((resolve) => {
setTimeout(resolve, 3000);
});
}
async deployAll() {
console.log('开始部署Lite-Avatar实时通信服务...');
try {
// 检查必要文件
this.checkRequiredFiles();
// 启动各个服务
await this.startTurnServer();
await this.startSignalingServer();
await this.startLiteAvatarConnector();
console.log('\n 所有服务已启动!');
console.log('信令服务器: http://localhost:3000');
console.log('测试客户端: http://localhost:3000/webrtc-client.html');
console.log('\n按 Ctrl+C 停止所有服务');
} catch (error) {
console.error('部署失败:', error);
this.cleanup();
}
}
checkRequiredFiles() {
const requiredFiles = [
'src/server.js',
'src/lite-avatar-connector.js',
'src/public/webrtc-client.html'
];
for (const file of requiredFiles) {
if (!fs.existsSync(file)) {
throw new Error(`缺少必要文件: ${file}`);
}
}
console.log('所有必要文件检查通过');
}
cleanup() {
console.log('\n清理所有进程...');
this.processes.forEach(({ name, process }) => {
console.log(`停止 ${name}...`);
process.kill('SIGTERM');
});
this.processes = [];
}
}
// 运行部署
if (require.main === module) {
const manager = new DeploymentManager();
// 处理退出信号
process.on('SIGINT', () => {
console.log('\n收到退出信号');
manager.cleanup();
process.exit(0);
});
process.on('SIGTERM', () => {
console.log('\n收到终止信号');
manager.cleanup();
process.exit(0);
});
// 开始部署
manager.deployAll().catch(console.error);
}
module.exports = DeploymentManager;
7. 实际应用中的优化建议
在实际项目中,你可能会遇到各种问题。根据我的经验,这里有几个重要的优化建议:
7.1 连接稳定性优化
WebRTC连接有时会不稳定,特别是在网络条件差的情况下。我们可以添加重连机制:
// 在客户端添加重连逻辑
let reconnectAttempts = 0;
const MAX_RECONNECT_ATTEMPTS = 5;
function setupReconnection() {
socket.on('disconnect', (reason) => {
log(`连接断开: ${reason}`);
if (reason === 'io server disconnect') {
// 服务器主动断开,需要手动重连
socket.connect();
} else {
// 其他原因,自动重连
if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
reconnectAttempts++;
const delay = Math.min(1000 * reconnectAttempts, 10000);
log(`将在 ${delay/1000} 秒后尝试重连 (尝试 ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})`);
setTimeout(() => {
socket.connect();
}, delay);
} else {
log('重连尝试次数过多,请手动重连');
}
}
});
socket.on('connect', () => {
log('重新连接成功');
reconnectAttempts = 0;
// 重新加入房间
if (currentRoom && currentUser) {
socket.emit('join-room', currentRoom, currentUser);
}
});
}
7.2 带宽自适应
不同的用户网络条件不同,我们需要根据网络状况调整视频质量:
// 添加带宽检测和自适应
function setupBandwidthAdaptation() {
if (!peerConnection) return;
// 定期检测带宽
setInterval(async () => {
try {
const stats = await peerConnection.getStats();
let totalBitrate = 0;
stats.forEach(report => {
if (report.type === 'outbound-rtp' && report.mediaType === 'video') {
totalBitrate += report.bytesSent * 8 / 1000; // kbps
}
});
log(`当前视频码率: ${totalBitrate.toFixed(2)} kbps`);
// 根据带宽调整视频参数
adjustVideoQuality(totalBitrate);
} catch (error) {
log(`获取带宽统计失败: ${error}`);
}
}, 5000); // 每5秒检测一次
}
function adjustVideoQuality(bitrate) {
const videoSender = peerConnection.getSenders().find(s =>
s.track && s.track.kind === 'video'
);
if (!videoSender || !videoSender.track) return;
const constraints = {
width: { ideal: 640 },
height: { ideal: 480 },
frameRate: { ideal: 24 }
};
// 根据带宽调整参数
if (bitrate < 500) {
// 低带宽
constraints.width.ideal = 320;
constraints.height.ideal = 240;
constraints.frameRate.ideal = 15;
log('网络条件较差,降低视频质量');
} else if (bitrate > 2000) {
// 高带宽
constraints.width.ideal = 1280;
constraints.height.ideal = 720;
constraints.frameRate.ideal = 30;
log('网络条件良好,提升视频质量');
}
// 应用新的约束
videoSender.track.applyConstraints(constraints).catch(error => {
log(`调整视频参数失败: ${error}`);
});
}
7.3 监控和日志
在生产环境中,监控是非常重要的:
// 添加详细的监控日志
function setupMonitoring() {
// 监控ICE连接状态
peerConnection.oniceconnectionstatechange = () => {
log(`ICE连接状态: ${peerConnection.iceConnectionState}`);
// 记录状态变化到服务器
socket.emit('ice-state-change', {
state: peerConnection.iceConnectionState,
timestamp: Date.now()
});
};
// 监控信号强度
peerConnection.onicegatheringstatechange = () => {
log(`ICE收集状态: ${peerConnection.iceGatheringState}`);
};
// 定期报告统计信息
setInterval(async () => {
try {
const stats = await peerConnection.getStats();
const report = {
timestamp: Date.now(),
connectionState: peerConnection.connectionState,
iceConnectionState: peerConnection.iceConnectionState,
stats: {}
};
// 收集关键统计信息
stats.forEach(reportItem => {
if (reportItem.type === 'candidate-pair' && reportItem.nominated) {
report.stats.candidatePair = {
availableOutgoingBitrate: reportItem.availableOutgoingBitrate,
bytesSent: reportItem.bytesSent,
bytesReceived: reportItem.bytesReceived,
currentRoundTripTime: reportItem.currentRoundTripTime
};
}
});
// 发送到服务器进行监控
socket.emit('webrtc-stats', report);
} catch (error) {
log(`收集统计信息失败: ${error}`);
}
}, 10000); // 每10秒报告一次
}
8. 总结
从头搭建一个支持Lite-Avatar的实时通信服务,确实涉及不少技术点。我们一步步走过来,从最基础的信令服务器,到完整的WebRTC实现,再到NAT穿透和性能优化。
实际用下来,这套方案在大多数场景下表现都不错。部署相对简单,扩展性也好。当然,真实项目中可能会遇到更多细节问题,比如不同浏览器的兼容性、大规模并发时的性能优化等。但有了这个基础框架,解决那些问题就有了明确的方向。
如果你正在考虑类似的实时视频应用,建议先从小规模开始,把基础功能跑通,再根据实际需求逐步优化。WebRTC技术虽然强大,但也有很多“坑”,实际动手做一遍,比只看文档要理解得深得多。
获取更多AI镜像
想探索更多AI镜像和应用场景?访问 CSDN星图镜像广场,提供丰富的预置镜像,覆盖大模型推理、图像生成、视频生成、模型微调等多个领域,支持一键部署。
更多推荐

所有评论(0)