引言:为什么需要数字人口播
电商直播有三个绕不开的痛点:主播成本高、排班困难、无法 7×24 在线。一个成熟主播的月均成本在 2-5 万,而直播间日均在线时长超过 12 小时才能覆盖流量高峰与长尾时段。MCN 机构同时运营多个直播间时,人力调度更加紧张,主播状态波动直接影响转化率。
数字人口播解决了这些问题。预置形象与音色组合可以稳定输出,无需休息,不受情绪影响。一个数字人可以同时跑在多个直播间,边际成本趋近于零。对于”内容批量生产”场景(商品讲解录播、活动促销循环播报),数字人更是天然匹配:写好话术文本,数字人自动播报,无需真人一遍遍重复。
这篇教程基于一个完整的 Web 端数字人口播 Demo,使用 ZEGO数字人 API搭建,覆盖从创建数字人视频流任务到前端实时播放的全部流程。代码从实际项目中提取,可以直接运行。
技术方案介绍:服务端驱动 + RTC 推流
ZEGO 数字人口播方案的核心架构是服务端驱动 + RTC 推流,与纯客户端 SDK 方案有本质区别。
纯客户端方案(如 Unity/UE 集成数字人渲染 SDK)需要在本地运行渲染引擎,对终端 GPU 有硬性要求,同时渲染与推流逻辑耦合在客户端,难以做集中化调度。这种方式适合单机互动场景,不适合多直播间并发播报。
ZEGO 的方案把渲染放在云端:服务端调用 CreateDigitalHumanStreamTask 创建任务,ZEGO 云端完成数字人渲染和音频合成,生成的音视频流通过 RTC 协议推送到房间。客户端只需用 ZEGO Express SDK 登录房间并拉流,不需要任何渲染能力。文本驱动也走服务端 API(DriveByText),编排端可以随时注入新话术。
两种方案的对比:
| 维度 | 纯客户端渲染 | ZEGO 服务端驱动 |
|---|---|---|
| 终端要求 | 需要 GPU,最低 4GB 显存 | 无 GPU 要求,浏览器即可 |
| 渲染位置 | 本地设备 | ZEGO 云端 |
| 并发能力 | 受本地资源限制 | 云端弹性扩展 |
| 调度方式 | 客户端各自独立 | 服务端集中控制 |
| 延迟 | 渲染延迟低,约 100ms | 端到端约 200-400ms |
| 适用场景 | 单机互动、高质量渲染 | 多直播间并发、无人值守播报 |
对于电商/MCN 的”无人值守直播”需求,服务端驱动方案是更务实的选择:运维成本低、扩展容易、客户端零渲染依赖。
核心概念
在开始编码之前,需要理解四个核心概念。
数字人形象(Digital Human):在 ZEGO 控制台上传或选择的虚拟人形象,每个形象有唯一的 DigitalHumanId。形象包含外观、表情基和动作基,渲染时由云端驱动。
音色(Timbre):数字人使用的语音风格,由 TTS 引擎合成。音色分为公共音色和私有音色:公共音色所有项目可用,私有音色绑定到特定数字人形象。每个音色有唯一的 TimbreId,TTS 参数(语速、音调、音量)可以在驱动时动态调整。
视频流任务(Stream Task):调用 CreateDigitalHumanStreamTask 后,ZEGO 云端为指定数字人创建一个渲染任务,产出一路音视频流推送到 RTC 房间。任务有唯一的 TaskId,对应一个 RoomId 和 StreamId。
文本驱动(DriveByText):向运行中的视频流任务注入文本,ZEGO 云端将文本经 TTS 合成为语音,同时驱动数字人口型和表情同步。支持设置 InterruptMode 控制新文本是否打断当前播报。
架构
整个系统分为四层:编排端(Next.js 页面)、服务端(API Routes)、ZEGO 云端、播放端(Express SDK)。
graph TB
subgraph 编排端
A[page.jsx] -->|选择形象/音色/话术| B[控制面板]
A -->|播放音视频流| C[视频区域]
end
subgraph 服务端 - API Routes
D[/api/broadcast] -->|创建/停止任务| E[播报管理]
F[/api/drive] -->|文本驱动| G[DriveByText]
H[/api/token] -->|生成Token04| I[鉴权]
J[/api/digital-humans] -->|获取形象列表| K[GetDigitalHumanList]
L[/api/timbres] -->|获取音色列表| M[GetTimbreList]
end
subgraph ZEGO云端
N[Digital Human API] -->|渲染+TTS| O[数字人引擎]
O -->|推流| P[RTC 房间]
end
B -->|POST /api/broadcast| D
B -->|POST /api/drive| F
B -->|GET /api/token| H
A -->|GET /api/digital-humans| J
A -->|GET /api/timbres| L
E -->|CreateStreamTask| N
G -->|DriveByText| N
E -->|StopStreamTask| N
C -->|loginRoom + startPlayingStream| P
核心交互流程如下:
sequenceDiagram
participant U as 编排端
participant S as 服务端
participant Z as ZEGO Digital Human API
participant R as ZEGO RTC 云端
Note over U,R: 阶段1: 页面加载与选择
U->>S: GET /api/digital-humans
S->>Z: GetDigitalHumanList
Z-->>S: 形象列表
S-->>U: 渲染选择面板
U->>S: GET /api/timbres(公共+私有)
S->>Z: GetTimbreList
Z-->>S: 音色列表
S-->>U: 渲染音色选择
Note over U,R: 阶段2: 开始播报
U->>S: POST /api/broadcast(digitalHumanId, timbreId, text...)
S->>Z: CreateDigitalHumanStreamTask
Z-->>S: TaskId
S->>Z: DriveByText(taskId, text, TTS参数)
Z->>R: 渲染数字人并推流
S-->>U: {taskId, roomId, streamId}
U->>S: GET /api/token?userId=xxx
S-->>U: Token04
U->>R: loginRoom(roomId, token)
U->>R: startPlayingStream(streamId)
R-->>U: 远端音视频流
Note over U,R: 阶段3: 追加话术
U->>S: POST /api/drive(taskId, text, TTS参数)
S->>Z: DriveByText
Z->>R: 更新数字人流
Note over U,R: 阶段4: 停止播报
U->>S: DELETE /api/broadcast?index=0
S->>Z: StopDigitalHumanStreamTask
U->>R: stopPlayingStream + logoutRoom
关键设计决策:所有对 ZEGO Digital Human API 的调用都走服务端 API Routes,而非直接从浏览器调用。原因有两点:第一,API 签名需要 ServerSecret,不能暴露给客户端;第二,服务端可以做任务状态管理,为多直播间调度留出扩展空间。
前置准备
- Node.js >= 18
- ZEGO 账号,已在 ZEGO 控制台 创建项目
- AppID 和 ServerSecret(32 位字符串),从控制台”项目信息”页面获取
- Digital Human API 权限,需联系 ZEGO 技术支持开通
CreateDigitalHumanStreamTask、DriveByText、StopDigitalHumanStreamTask、GetDigitalHumanList、GetTimbreList五个接口 - 数字人形象和音色,在 ZEGO 控制台上传或选用预置形象,确保至少有一个形象和一个公共音色可用
- 项目环境变量配置(
.env文件):
# .env.example
# ZEGO App ID(数字类型,从控制台获取)
APP_ID=your_app_id
# Server Secret(32 位字符串,用于 API 签名和 Token 生成)
# 注意:此密钥仅在服务端使用,不可暴露给浏览器
SERVER_SECRET=your_server_secret
# Token 有效期(秒),默认 3600
TOKEN_EXPIRE_SECONDS=3600
# 客户端 App ID(NEXT_PUBLIC_ 前缀会暴露给浏览器,必须与 APP_ID 一致)
NEXT_PUBLIC_APP_ID=your_app_id
分步实现教程
项目基于 Next.js 16 + ZEGO Express WebRTC SDK 3.11,采用单项目架构:前端页面和服务端 API Routes 在同一个 Next.js 应用中。
步骤 1:项目初始化
创建 Next.js 项目并安装 ZEGO Express WebRTC SDK,这是数字人音视频流在浏览器端播放的必要依赖。
npx create-next-app@latest digital-human-broadcasting
cd digital-human-broadcasting
npm install zego-express-engine-webrtc@^3.11.0
项目依赖说明:
| 依赖 | 版本 | 用途 |
|---|---|---|
| next | ^16.1.5 | 应用框架,提供 API Routes |
| react | ^19.2.4 | 前端 UI 渲染 |
| zego-express-engine-webrtc | ^3.11.0 | RTC 拉流,播放数字人音视频 |
| tailwindcss | ^4.1.18 | 样式(可替换为任意 CSS 方案) |
步骤 2:API 签名封装
ZEGO Digital Human API 的每次请求都需要 MD5 签名鉴权。签名算法固定:MD5(AppId + SignatureNonce + ServerSecret + Timestamp)。将签名逻辑封装为 buildCommonParams 和 post 两个函数,所有 API Route 共用。
buildCommonParams 负责生成签名和公共参数:
// app/api/broadcast/route.js
import crypto from "crypto";
const buildCommonParams = (action) => {
const appId = process.env.APP_ID || process.env.ZEGO_APPID || "";
const serverSecret = process.env.SERVER_SECRET || process.env.ZEGO_SERVER_SECRET || "";
const signatureNonce = crypto.randomBytes(8).toString("hex");
const timestamp = Math.floor(Date.now() / 1000);
const signature = crypto
.createHash("md5")
.update(`${appId}${signatureNonce}${serverSecret}${timestamp}`)
.digest("hex");
return new URLSearchParams({
Action: action,
AppId: appId.toString(),
SignatureNonce: signatureNonce,
Timestamp: timestamp.toString(),
Signature: signature,
SignatureVersion: "2.0",
});
};
post 函数将签名参数拼接到 URL,发送 POST 请求并校验返回码:
const post = async (action, body) => {
const params = buildCommonParams(action);
const url = `https://aigc-digitalhuman-api.zegotech.cn/?${params.toString()}`;
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const data = await response.json();
if (data.Code !== 0) {
throw new Error(`Digital Human API failed: ${data.Code} ${data.Message}`);
}
return data.Data;
};
注意几点:签名参数通过 URL Query 传递,请求体放业务参数;SignatureNonce 每次请求随机生成,防止重放攻击;AppId 和 ServerSecret 支持双环境变量名(APP_ID / ZEGO_APPID),方便不同部署环境使用。
步骤 3:获取数字人列表和音色列表
页面加载时需要拉取可用的数字人形象和音色,供用户选择。这两个接口调用逻辑简单,关键在于区分公共音色和私有音色。
获取数字人列表:
// app/api/digital-humans/route.js
const getDigitalHumanList = async (params) => {
const body = {};
if (params.fetchMode !== undefined) {
body.FetchMode = params.fetchMode;
}
if (params.offset !== undefined) {
body.Offset = params.offset;
}
if (params.limit !== undefined) {
body.Limit = params.limit;
}
const data = await post("GetDigitalHumanList", body);
return data;
};
获取音色列表:不带 digitalHumanId 返回公共音色,带 digitalHumanId 返回该形象的私有音色。
// app/api/timbres/route.js
const getTimbreList = async (params) => {
const body = {};
if (params.digitalHumanId) {
body.DigitalHumanId = params.digitalHumanId;
}
if (params.offset !== undefined) {
body.Offset = params.offset;
}
if (params.limit !== undefined) {
body.Limit = params.limit;
}
const data = await post("GetTimbreList", body);
return data;
};
前端页面在挂载时调用这两个接口,同时监听数字人选择变化来拉取私有音色,两者合并后去重展示:
// app/page.jsx
const allTimbres = useMemo(() => {
const timbreMap = new Map();
publicTimbres.forEach((t) => timbreMap.set(t.TimbreId, t));
privateTimbres.forEach((t) => timbreMap.set(t.TimbreId, t));
return Array.from(timbreMap.values());
}, [publicTimbres, privateTimbres]);
用 Map 按 TimbreId 去重,确保私有音色覆盖同 ID 的公共音色。这是实际场景中常见的处理方式:部分形象有专属音色,优先级高于公共音色。
步骤 4:创建视频流任务
这一步是整个流程的核心:调用 CreateDigitalHumanStreamTask 让 ZEGO 云端开始渲染数字人,并将音视频流推送到指定的 RTC 房间。
// app/api/broadcast/route.js
const createStreamTask = async (params) => {
const data = await post("CreateDigitalHumanStreamTask", {
DigitalHumanConfig: { DigitalHumanId: params.digitalHumanId },
RTCConfig: { RoomId: params.roomId, StreamId: params.streamId },
});
return data.TaskId;
};
请求体包含两个配置块:DigitalHumanConfig 指定用哪个数字人形象,RTCConfig 指定音视频流推送的目标房间和流 ID。返回的 TaskId 是后续文本驱动和停止任务的唯一标识。
播报管理逻辑将创建任务和首次驱动封装在一起:
const startBroadcast = async (options) => {
const { digitalHumanId, timbreId, roomId, streamId, text,
speechRate, pitchRate, volume, broadcastIndex } = options;
// 如果同 index 已有任务,先停止
if (globalState.__DH_BROADCASTS__[broadcastIndex]) {
await stopBroadcast(broadcastIndex);
}
// 创建流任务
const taskId = await createStreamTask({ digitalHumanId, roomId, streamId });
// 首次文本驱动
if (text) {
try {
await driveByText({
taskId, text, timbreId, speechRate, pitchRate, volume,
interruptMode: 1,
});
} catch (error) {
console.log("Initial drive failed (task may need time to initialize):", error.message);
}
}
// 记录任务状态
globalState.__DH_BROADCASTS__[broadcastIndex] = {
taskId, roomId, streamId, digitalHumanId, timbreId, speechRate, pitchRate, volume,
};
};
这里有两个细节值得注意。第一,首次驱动可能失败,因为流任务初始化需要时间,catch 中只记录日志不中断流程,后续可以通过 Drive Text 按钮再次驱动。第二,任务状态存在 globalThis 上,这是 Demo 的简化方案,生产环境应替换为数据库或缓存。
步骤 5:文本驱动数字人
流任务创建后,通过 DriveByText 接口注入文本,ZEGO 云端完成 TTS 合成和口型驱动。
// app/api/broadcast/route.js
const driveByText = async (params) => {
const result = await post("DriveByText", {
TaskId: params.taskId,
Text: params.text,
InterruptMode: params.interruptMode ?? 1,
TTSConfig: {
TimbreId: params.timbreId,
SpeechRate: params.speechRate ?? 0,
PitchRate: params.pitchRate ?? 0,
Volume: params.volume ?? 50,
},
});
return result;
};
InterruptMode 设为 1 表示新文本打断当前播报,立即生效。TTS 参数含义:
| 参数 | 范围 | 默认值 | 说明 |
|---|---|---|---|
| SpeechRate | -500 ~ 500 | 0 | 语速,正值加快,负值减慢 |
| PitchRate | -500 ~ 500 | 0 | 音调,正值升高,负值降低 |
| Volume | 1 ~ 100 | 50 | 音量 |
独立的 /api/drive 路由处理播报过程中的追加话术,与创建任务时的首次驱动共享同一签名和调用逻辑:
// app/api/drive/route.js
export const POST = async (request) => {
try {
const body = await request.json();
const { taskId, text, timbreId, speechRate, pitchRate, volume } = body;
if (!taskId) {
return NextResponse.json({ success: false, error: "taskId is required" }, { status: 400 });
}
const result = await post("DriveByText", {
TaskId: taskId,
Text: text,
InterruptMode: 1,
TTSConfig: {
TimbreId: timbreId,
SpeechRate: speechRate ?? 0,
PitchRate: pitchRate ?? 0,
Volume: volume ?? 50,
},
});
return NextResponse.json({ success: true, driveId: result?.DriveId });
} catch (error) {
return NextResponse.json({ success: false, error: error.message }, { status: 400 });
}
};
前端触发驱动的逻辑:用户在播报进行中输入新文本,点击 “Drive Text” 按钮,将当前 taskId 和新文本发送到服务端。
步骤 6:Token 生成和 RTC 房间登录
客户端要拉取数字人音视频流,需要先用 Token 登录 RTC 房间。Token 生成使用 ZEGO Token04 算法,基于 AES-CBC 加密,必须在服务端完成。
Token04 的生成逻辑:
// app/api/token/route.js
import { createCipheriv } from "crypto";
const generateToken04 = (appId, userId, secret, effectiveTimeInSeconds, payload = "") => {
if (!appId || typeof appId !== "number") {
throw new Error("Invalid appId");
}
if (!secret || secret.length !== 32) {
throw new Error("ServerSecret must be a 32-character string");
}
const createTime = Math.floor(Date.now() / 1000);
const tokenInfo = {
app_id: appId,
user_id: userId,
nonce: makeNonce(),
ctime: createTime,
expire: createTime + effectiveTimeInSeconds,
payload,
};
const plainText = JSON.stringify(tokenInfo);
const iv = makeRandomIv();
const encryptBuf = aesEncrypt(plainText, secret, iv);
// 二进制拼接: expire(8) + ivLen(2) + iv + encryptLen(2) + encryptBuf
const b1 = new Uint8Array(8);
const b2 = new Uint8Array(2);
const b3 = new Uint8Array(2);
new DataView(b1.buffer).setBigInt64(0, BigInt(tokenInfo.expire), false);
new DataView(b2.buffer).setUint16(0, iv.length, false);
new DataView(b3.buffer).setUint16(0, encryptBuf.byteLength, false);
const buf = Buffer.concat([
Buffer.from(b1),
Buffer.from(b2),
Buffer.from(iv),
Buffer.from(b3),
Buffer.from(encryptBuf),
]);
return `04${Buffer.from(buf).toString("base64")}`;
};
AES 密钥长度决定加密算法:16 字节用 AES-128-CBC,24 字节用 AES-192-CBC,32 字节用 AES-256-CBC。ZEGO 的 ServerSecret 固定 32 字节,因此实际使用 AES-256-CBC。
API Route 暴露为 GET 接口,接收 userId 参数:
// app/api/token/route.js
export const GET = async (request) => {
const appId = Number(process.env.APP_ID || process.env.ZEGO_APPID || 0);
const serverSecret = process.env.SERVER_SECRET || process.env.ZEGO_SERVER_SECRET || "";
const tokenExpireSeconds = Number(process.env.TOKEN_EXPIRE_SECONDS) || 3600;
const { searchParams } = new URL(request.url);
const userId = searchParams.get("userId");
if (!userId) {
return NextResponse.json({ error: "Missing userId parameter" }, { status: 400 });
}
try {
const token = generateToken04(appId, userId, serverSecret, tokenExpireSeconds, "");
return NextResponse.json({ token });
} catch (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
};
步骤 7:前端播放数字人音视频流
前端用 ZEGO Express WebRTC SDK 登录房间并拉流播放,核心代码集中在 page.jsx 的 handleStartBroadcast 函数中。
完整流程分为六步:创建播报任务、获取任务信息、获取 Token、初始化 Express SDK、登录房间、拉流播放。
// app/page.jsx
const handleStartBroadcast = async () => {
// 步骤 1: 创建播报任务
const newRoomId = `room_dh_${Date.now()}`;
const newStreamId = `stream_dh_${Date.now()}`;
const broadcastRes = await fetch("/api/broadcast", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
broadcastIndex: 0,
digitalHumanId: selectedDigitalHumanId,
timbreId: selectedTimbreId,
roomId: newRoomId,
streamId: newStreamId,
text: broadcastingText.trim(),
speechRate, pitchRate, volume,
}),
});
const broadcastData = await broadcastRes.json();
// 步骤 2: 获取任务信息
const infoRes = await fetch("/api/broadcast");
const infoData = await infoRes.json();
const task = Object.values(infoData.broadcastList)[0];
setTaskId(task.taskId);
setRoomId(task.roomId);
setStreamId(task.streamId);
// 步骤 3: 获取 Token
const userId = `user_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
const tokenRes = await fetch(`/api/token?userId=${userId}`);
const tokenData = await tokenRes.json();
// 步骤 4: 初始化 ZEGO Express SDK
const { ZegoExpressEngine } = await import("zego-express-engine-webrtc");
const appId = Number(process.env.NEXT_PUBLIC_APP_ID || process.env.ZEGO_APPID || 0);
const engine = new ZegoExpressEngine(appId, "");
engineRef.current = engine;
// 步骤 5: 登录房间
await engine.loginRoom(task.roomId, tokenData.token, {
userID: userId,
userName: userId,
});
// 步骤 6: 拉流播放
const remoteStream = await engine.startPlayingStream(task.streamId);
const remoteView = engine.createRemoteStreamView(remoteStream);
remoteView.play("remote-video");
};
remoteView.play("remote-video") 将音视频流渲染到页面中 id="remote-video" 的 DOM 元素。这个元素同时作为播放容器和占位区域:
<div id="remote-video" className="w-full aspect-video bg-black flex items-center justify-center">
{!isRunning && (
<p className="text-gray-500 text-sm">
Digital human video will appear here after broadcasting starts
</p>
)}
</div>
停止播报时需要清理资源:停止拉流、退出房间、销毁引擎实例。错误处理路径中同样需要清理,避免残留连接:
const handleStopBroadcast = async () => {
await fetch("/api/broadcast?index=0", { method: "DELETE" });
if (engineRef.current && currentRoomRef.current) {
engineRef.current.stopPlayingStream(currentRoomRef.current.streamId);
engineRef.current.logoutRoom(currentRoomRef.current.roomId);
engineRef.current.destroyEngine();
engineRef.current = null;
currentRoomRef.current = null;
}
setIsRunning(false);
setTaskId("");
setRoomId("");
setStreamId("");
};
组件卸载时也需清理,通过 useEffect 的返回函数处理:
useEffect(() => {
return () => {
if (engineRef.current && currentRoomRef.current) {
try {
engineRef.current.stopPlayingStream(currentRoomRef.current.streamId);
engineRef.current.logoutRoom(currentRoomRef.current.roomId);
engineRef.current.destroyEngine();
} catch (_) {}
}
};
}, []);
运行效果
页面初始状态:左侧为黑色视频占位区域,右侧为状态面板(显示 “Idle”)。下方控制面板包含四个区域:数字人形象选择网格、音色选择网格、话术文本输入框、TTS 参数滑块(语速/音调/音量),以及三个操作按钮。
播报进行中:视频区域显示数字人实时画面,数字人口型与播报文本同步。状态面板显示 “Playing”,同时展示当前 Room ID、Stream ID 和 Task ID。
追加话术:播报进行中,在文本框输入新话术,点击 “Drive Text” 按钮,数字人切换为新话术播报。
停止播报:点击 “Stop Broadcasting” 后,视频区域恢复黑色占位,状态归位。
进阶方向
对接直播平台推流:当前 Demo 在浏览器端播放数字人流。生产环境中,需要将 RTC 流推送到抖音、快手、淘宝等直播平台。方案是使用 ZEGO 的旁路推流功能,将 RTC 流转为 RTMP 推送到平台推流地址,实现数字人画面直接进入直播间。
AI 大模型驱动对话:将 DriveByText 的输入源从人工文本替换为大模型输出。接入 ChatGPT、通义千问等 LLM,根据直播间弹幕或预设话术生成回复文本,实时驱动数字人播报。InterruptMode: 1 配置下,新文本会打断当前播报,适合互动场景。
多语言多音色切换:DriveByText 每次调用都可以指定不同的 TimbreId,实现同一数字人在不同场景下切换音色。配合多语言 TTS,可以构建跨境电商的多语言直播矩阵,一个形象覆盖多个语种。
多直播间并发调度:当前 Demo 用 globalThis 存储单个任务状态。生产环境中,将任务状态迁移到 Redis 或数据库,配合任务队列管理多个直播间的并发播报,实现真正的”一人多播”。
总结
数字人口播系统解决的核心问题是:用服务端渲染 + RTC 推流替代真人主播,实现稳定的 7×24 直播输出。本教程覆盖了从 API 签名、形象/音色选择、流任务创建、文本驱动到前端拉流播放的完整链路,所有代码基于实际运行的 Demo 项目提取。对于电商和 MCN 团队,下一步是将这个 Demo 扩展为生产级方案:加入旁路推流对接直播平台、接入大模型实现智能对话、构建多直播间调度系统。
原创文章,作者:ZEGO即构科技,如若转载,请注明出处:https://market-blogs.zego.im/reports-technique/3568/