甲方说想要一个数字人,喔哦,这不,我的专长领域来咯。
之前我有做过这么一个Python项目,DigPeopleCore, 把RAG、TTS、ASR、LLM、口型集合在一起,纯本地化,给虚幻引擎的AI数字人提供服务,但是存在这样一些问题:
响应速度慢,用户输入 -> ASR -> RAG -> LLM -> TTS,响应速度太慢了。
硬件成本高,毕竟纯本地化方案。
需求
轻量级的使用场景,一般只在一台设备上使用,不公开服务。
除了能够实时语音交流,还要有一个数字人形象。
能接知识库,喂信息。
解决方案
火山的实时语音对话解决方案
实时对话,火山提供了现成的解决方案和Demo工程。
浏览器通过WebRTC连接到火山的实时音视频中转服务器。(浏览器加入一个房间)
火山的AI服务器作为一个用户加入到实时音视频房间中。(AI进房间陪聊)
这个AI服务器的行为是高度可自定义的,它是三种服务的结合:
ASR,语音转文字,只支持火山自己的两种服务,基础的语音转文字和有上下文的语音转文字大模型。
LLM,大语言模型,支持火山直接的若干大模型服务,同时支持第三方。
TTS,语音合成,只支持火山自己的两种服务,基础的和大模型。
LLM支持的第三方,是OpenAI API格式,文档。
(又又又被AI骗了,他说火山支持的格式不是OpenAI格式,还贴心给我写了代理)
知识库
如果没有知识库的需求,可以直接根据官方的教程开通服务,忽略下面的内容,使用官方的大模型接口。
知识库,火山有提供服务,但是不免费,于是这一块我打算在本地部署(需要一台服务器)。
本地部署一个任何一个平台,MaxKB、Dify之类的都可以。
我使用MaxKB,创建一个最简单的对话机器人,接到火山免费的大模型服务上,加一个知识库,创建一个API KEY,完工。
不仅免费,还能查看用户的对话记录。
我部署时的测试,MaxKB似乎有BUG,即便我把会话ID正确传过去,也无法有对话上下文,在消息记录里面明明看到是同一个会话,但是详细信息却显示推理的时候历史对话是空。
火山调第三方API不是很灵活,需要考虑在火山和大模型服务之间加一层代理。(被AI欺骗的我企图通过代理将OpenAI风格API转换成火山风格API,但传递会话ID保留对话上下文需要代理修改请求数据)
数字人形象
咱是技术人员,不是画师,做数字人形象?得加钱
想快速做3D的就算了吧,到目前为止,虚幻引擎表情绑定和口型匹配对我来说还是个难题,而且做了也不好放在页面里。
Live2D,YYDS!
在页面上使用 pixi-live2d-display 库渲染即可~
于是,技术就通了。
制作数字人形象
我用ComfyUI呼呼呼画了几百张,从中挑了一张,数字人就画好啦~
(虽然似乎没有写过ComfyUI的Blog,但我确实研究过一段时间嘟)
(其实这里有小插曲,我死活画不出来全身的形象,于是找了个只有上半身的让ChatGPT补出来了下半身,用PS拼拼拼半天)
用上祖传的Live2D技术,做出来了眼睛会眨、嘴巴会动的数字人。
制作动画
给眼睛、嘴巴创建了轨道是不够的,不知道是我不会看文档还是库没提供,pixi-live2d-display 是不能设置这种轨道的值的。
必须将眼睛眨、嘴巴张做成动画,播放动画才可以。
Live2D的动画挺神奇的,此处说的动画并不是集成在模型文件里面的动画,而是创建一个动画文件,把模型拖进去制作动画,既,模型属于动画的一部分,而不是动画属于模型的一部分。
此处我做了两个动画,不说话的时候的闲置动画、说话时候的说话动画,两个动画是一个动画文件中的两个场景。于是得到了两个文件。
导出
因为有两个文件,所以要分两次导出。
导出模型moc3我使用了SDK5.0(因为强迫症,最新的才舒服)
模型会导出一堆文件和一个纹理文件夹,这些文件需要保持层级结构(需要放在前端页面的Public里面某个子目录)
每个动画场景动画会导出单个json文件,需要将动画和模型组合起来,不然运行时不会读动画json。
导出模型会有一个叫 XXX.model3.json 的文件,需要把导出的动画放到确定的位置,并在这个文件内写动画的相对位置。
我将动画放到了XXX.model3.json 旁边的motions 文件夹中,于是XXX.model3.json 里面写:
{
"Version": 3,
"FileReferences": {
"Moc": "people.moc3",
"Textures": [
"people.512/texture_00.png"
],
"DisplayInfo": "people.cdi3.json",
"Motions": {
"Idle": [
{
"Name": "Idle",
"File": "motions/Idle.motion3.json",
"Weight": 1
}
],
"Speak": [
{
"Name": "Speak",
"File": "motions/speak.motion3.json",
"Weight": 1
}
]
}
},
"Groups": [
{
"Target": "Parameter",
"Name": "LipSync",
"Ids": []
},
{
"Target": "Parameter",
"Name": "EyeBlink",
"Ids": []
}
]
}重点在Motions那儿。
显示数字人形象
需要使用pixi-live2d-display 库,这个库依赖pixi.js 而且需要手动装,但在我使用时,pixi.js 需要手动指定低版本,如果你在使用时遇到问题,试试降低pixi.js 版本。
而且可能是由于版权的问题,pixi-live2d-display 并不是装上就能用的,需要根据官方文档引入一个JS。
这个库的动画切换会有过渡,而且如果切换前后是同一个动画,那么就不进行切换。于是有了下面的不优雅但能用的React代码:
useEffect(() => {
let closed = false
const f = () => {
if (modelRef.current) {
if (speaking) {
modelRef.current.motion("Speak", undefined, MotionPriority.FORCE)
} else {
modelRef.current.motion("Idle", undefined, MotionPriority.FORCE)
}
}
if (closed) return
requestAnimationFrame(f)
}
f()
return () => {
closed = true
}
}, [speaking])
接入AI
官方的Demo已经做的很好啦。
前端魔改一下,删删东西,把数字人放进去当背景就好啦。
支持多客户端
但,官方的Demo并不支持多客户端,如果同时两个客户端访问,似乎只有一个能用,而且一旦有一个客户端点了结束,那么所有客户端都会结束。
这是由于在Demo服务器上,房间ID、AI会话的ID都是在第一次请求时就固定下来不变了。
客户端点击结束时,服务器会直接结束保存的AI会话ID。
解决方法也很简单,服务器不要存ID,每次请求给个新的。
在rtc-aigc-demo/Server/app.js:104附近,看到如下代码:
RTCConfig.RoomId = VoiceChat.RoomId = RoomId || uuid.v4();
RTCConfig.UserId = VoiceChat.AgentConfig.TargetUserId[0] = UserId || uuid.v4();RoomID、UserID是先看有没有,再生成,直接改成
RTCConfig.RoomId = VoiceChat.RoomId = uuid.v4();
RTCConfig.UserId = VoiceChat.AgentConfig.TargetUserId[0] = uuid.v4();在rtc-aigc-demo/Server/app.js:53附近,看到如下代码:
body = VoiceChat;改成:
body = {
...VoiceChat,
TaskId: uuid.v4(),
};同时带来一个问题——结束时无法确定要结束哪个会话。
我选择,不解决。
因为挂断按钮并不总是在关闭的时候会被用户点击,所以结束请求不是必要的,即便不结束,在用户退出之后,火山也会自动结束。
一种简单的另类前端集成方法
做的这个数字人是打算放在数据大屏里面,本来计划用Iframe来嵌入,但是做完后发现Iframe无法实现透明背景。
这里我想到了一种快速、支持Vue或React、但不优雅不易维护的方法,如果你赶时间,可以试一试这种方法。
需要集成进的项目称为项目A,被集成的项目称为项目B。此次数据大屏是项目A,数字人是项目B。
修改项目B入口文件。
以React为例,入口文件一般只做一件事情:
const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement)
root.render(
<Provider store={store}>
<App />
</Provider>
);修改它,加一些判断,并导出东西到window上:
const window_ = window as any;
function Mount(re?: HTMLElement) {
const root = ReactDOM.createRoot(re ?? document.getElementById('root') as HTMLElement)
root.render(
<Provider store={store}>
<App />
</Provider>
);
return root;
}
window_.digpeople_mount = Mount;
if (!window_.digpeople_noMount) {
Mount();
}这里因为我是希望项目B能独立运行,如果项目B不需要独立运行,可以省几行:
const window_ = window as any;
function Mount(re: HTMLElement) {
const root = ReactDOM.createRoot(re)
root.render(
<Provider store={store}>
<App />
</Provider>
);
return root;
}
window_.digpeople_mount = Mount;修改后的入口文件导出了 digpeople_noMount和digpeople_mount 。
编译项目B,得到 html、js、css。html只是个模板里面肯定没有业务相关的东西(如有请反思为什么用框架会在Html里面写东西[狗头])。
将编译得到的js、css放入项目A的Public。
让项目A加载js和css。可以写在html里面,也可以用脚本加载。但应该保证项目A的入口代码优先于项目B的代码执行。
给
window.digpeople_noMount赋值。告诉项目B,它不是一个独立项目。适时调用
window.digpeople_mount。例如创建一个像下面这样子的组件,把项目B变成组件。
const DigPeopleWrapper: React.FC = () => {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const mount = (window as any).digpeople_mount;
const container = containerRef.current;
if (typeof mount === 'function' && container) {
const root = mount(container);
return () => {
if (root?.unmount) {
root.unmount();
}
};
}
}, []);
return <div ref={containerRef} />;
};总结
于是完成了低开发成本、低运营成本、但用户体验很好的实时语音数字人~
凡事还是要站在巨人肩膀上呢。
感觉微前端技术越来越有必要了。