使用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星图镜像广场,提供丰富的预置镜像,覆盖大模型推理、图像生成、视频生成、模型微调等多个领域,支持一键部署。

Logo

邀请您加入社区

更多推荐