甲方说想要一个数字人,喔哦,这不,我的专长领域来咯。

之前我有做过这么一个Python项目,DigPeopleCore, 把RAG、TTS、ASR、LLM、口型集合在一起,纯本地化,给虚幻引擎的AI数字人提供服务,但是存在这样一些问题:

  • 响应速度慢,用户输入 -> ASR -> RAG -> LLM -> TTS,响应速度太慢了。

  • 硬件成本高,毕竟纯本地化方案。

需求

轻量级的使用场景,一般只在一台设备上使用,不公开服务。

除了能够实时语音交流,还要有一个数字人形象。

能接知识库,喂信息。

解决方案

火山的实时语音对话解决方案

实时对话,火山提供了现成的解决方案和Demo工程

火山官方文档

  1. 浏览器通过WebRTC连接到火山的实时音视频中转服务器。(浏览器加入一个房间)

  2. 火山的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拼拼拼半天)

https://blog.ffeng123.win/archives/854dad30-bc8f-4ee1-91c1-231f9d0e3568

用上祖传的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。

  1. 修改项目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_noMountdigpeople_mount

  1. 编译项目B,得到 html、js、css。html只是个模板里面肯定没有业务相关的东西(如有请反思为什么用框架会在Html里面写东西[狗头])。

  2. 将编译得到的js、css放入项目A的Public

  3. 让项目A加载js和css。可以写在html里面,也可以用脚本加载。但应该保证项目A的入口代码优先于项目B的代码执行。

  4. window.digpeople_noMount赋值。告诉项目B,它不是一个独立项目。

  5. 适时调用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} />;
};

总结

于是完成了低开发成本、低运营成本、但用户体验很好的实时语音数字人~

凡事还是要站在巨人肩膀上呢。

感觉微前端技术越来越有必要了。

我能想到的,最大的成功就是无愧于自己的心。