首先对live2d-py的作者表示感谢,是他让live2d模型得以通过python语言加载。

以下是live2d-py仓库:

Arkueid/live2d-py: Live2D Library for Python (C++ Wrapper): Supports model loading, lip-sync and basic face rigging, precise click test.

我个人环境的python版本是3.10,conda构建,在这个基础上,安装好了py-live2d针对py310的轮子文件,大家也可以参照我的情况安装。

以下是演示视频:

【ChatBatV2对话Demo】python中运行live2d模型与口型同步

大模型用的是DEEPSEEK,语音生成模块是fish audio。

由于py-live2d库原生只支持wav文件进行口型同步,所以我对源码进行了修改。

以下是修改后的位于utils下的lipsync.py文件,通过加入pydub和wavfile库进行了拓展,当然pydub库是借用了ffmpeg,wavfile主要是解决audition转换后的wav文件不能被读取的问题

import wave
import wavfile
import numpy as np
import time
from pydub import AudioSegment


class WavHandler:
    def __init__(self):
        # 每个通道的采样帧数
        self.numFrames: int = 0
        # 采样率,帧/秒
        self.sampleRate: int = 0
        self.sampleWidth: int = 0
        # 通道数
        self.numChannels: int = 0
        # 数据
        self.pcmData: np.ndarray = None
        # 已经读取的帧数
        self.lastOffset: int = 0
        # 当前rms值
        self.currentRms: float = 0
        # 开始读取的时间
        self.startTime: float = -1

    def Start(self, filePath: str) -> None:
        self.ReleasePcmData()
        if filePath.endswith('.wav'):
            try:
                with wave.open(filePath, "r") as wav:
                    self.numFrames = wav.getnframes()
                    self.sampleRate = wav.getframerate()
                    self.sampleWidth = wav.getsampwidth()
                    self.numChannels = wav.getnchannels()
                    byte_data = wav.readframes(self.numFrames)
                    self.pcmData = np.frombuffer(byte_data, dtype=np.int16)
                    print(self.pcmData.shape)
            except Exception as e:
                print(f'wav音频读取错误:{e}, 启用备用方案')
                self.ReleasePcmData()
                try:
                    with wavfile.open(filePath, "r") as wav_file:
                        self.numFrames = wav_file.num_frames
                        self.sampleRate = wav_file.sample_rate
                        self.sampleWidth = wav_file.bits_per_sample
                        self.numChannels = wav_file.num_channels
                        byte_data, _, _ = wavfile.read(filePath, fmt='float')
                        self.pcmData = np.array(byte_data)
                        print(self.pcmData.shape)
                except Exception as e:
                    print(f'wav音频读取错误:{e}')
                    self.ReleasePcmData()
        else:
            mp3_file = AudioSegment.from_mp3(filePath)
            self.numFrames = int(mp3_file.frame_count(ms=len(mp3_file)))
            self.sampleRate = mp3_file.frame_rate
            self.sampleWidth = mp3_file.sample_width
            self.numChannels = mp3_file.channels
            byte_data = mp3_file.raw_data
            self.pcmData = np.frombuffer(byte_data, dtype=np.int16)
            print(self.pcmData.shape)

        # 标准化
        self.pcmData = self.pcmData / np.max(np.abs(self.pcmData))
        # 拆分通道
        self.pcmData = self.pcmData.reshape(-1, self.numChannels).T

        # print(self.pcmData)

        self.startTime = time.time()
        self.lastOffset = 0

    def ReleasePcmData(self):
        if self.pcmData is not None:
            del self.pcmData
            self.pcmData = None

    def GetRms(self) -> float:
        """
        获取当前音频响度
        """
        return self.currentRms

    def Update(self) -> bool:
        """
        更新位置
        """
        # 数据未加载或者数据已经读取完毕
        if self.pcmData is None or self.lastOffset >= self.numFrames:
            return False

        currentTime = time.time() - self.startTime
        currentOffset = int(currentTime * self.sampleRate)

        # 时间太短
        if currentOffset == self.lastOffset:
            return True

        currentOffset = min(currentOffset, self.numFrames)

        dataFragment = self.pcmData[:, self.lastOffset:currentOffset].astype(np.float32)

        self.currentRms = np.sqrt(np.mean(np.square(dataFragment)))

        self.lastOffset = currentOffset
        return True

整个项目的源码并不考虑公开。

以下是参考作者提供的示例代码进行修改后的代码

# -*- coding: utf-8 -*-
# -*- file: live2d_wrapper.py -*-

import math
import pygame
from pygame.locals import *
from OpenGL.GL import *
import live2d.v3 as live2d
from live2d.utils.lipsync import WavHandler
from live2d.utils import log
from live2d.v3 import StandardParams
from live2d.v3 import MotionPriority


class Live2DModel:
    """封装 Live2D 模型的加载、渲染和交互逻辑。"""

    def __init__(self, model_path: str, canvas_width: int = 1200, canvas_height: int = 800):
        """
        初始化 Live2D 模型。

        Args:
            model_path: Live2D 模型文件的路径(.model3.json)。
            canvas_width: 画布宽度。
            canvas_height: 画布高度。
        """
        self.model = live2d.LAppModel()
        self.model.LoadModelJson(model_path)
        self.model.Resize(canvas_width, canvas_height)
        self.model.SetAutoBlinkEnable(True)
        self.model.SetAutoBreathEnable(True)
        self.model.StartRandomMotion()

        self.canvas_width = canvas_width
        self.canvas_height = canvas_height
        self.dx = 0.0
        self.dy = 0.0
        self.scale = 1.0
        self.wav_handler = WavHandler()
        self.lip_sync_n = 3
        self.audio_played = False

    def start_callback(self, group, no):
        audio_path = 'haru_normal_01.wav'
        log.Info("start motion: [%s_%d]" % (group, no))
        pygame.mixer.music.load(audio_path)
        pygame.mixer.music.play()
        log.Info("start lipSync")
        self.wav_handler.Start(audio_path)


    @staticmethod
    def on_finish_motion_callback():
        log.Info("motion finished")

    def update(self, delta_time: float):
        """更新模型状态(如旋转、位置、缩放等)。"""
        progress = delta_time * math.pi * 10 / 1000 * 0.5
        deg = math.sin(progress) * 5  # 最大旋转角度为5度
        self.model.Rotate(deg)
        self.model.Update()

    def update_setoff(self):
        self.set_offset(self.dx, self.dy)
        self.set_scale(self.scale)
        live2d.clearBuffer()

    def draw(self):
        """绘制模型到当前 OpenGL 上下文。"""
        self.model.Draw()

    def handle_event(self, event):
        """处理用户输入事件(鼠标、键盘)。"""
        if event.type == pygame.MOUSEBUTTONDOWN:
            x, y = pygame.mouse.get_pos()
            self.model.SetRandomExpression()
            self.model.StartRandomMotion(priority=3, onFinishMotionHandler=self.on_finish_motion_callback)

        elif event.type == pygame.KEYDOWN:
            if event.key == pygame.K_LEFT:
                self.dx -= 0.1
            elif event.key == pygame.K_RIGHT:
                self.dx += 0.1
            elif event.key == pygame.K_UP:
                self.dy += 0.1
            elif event.key == pygame.K_DOWN:
                self.dy -= 0.1
            elif event.key == pygame.K_i:
                self.scale += 0.1
            elif event.key == pygame.K_u:
                self.scale -= 0.1
            elif event.key == pygame.K_r:
                self.model.StopAllMotions()
                self.model.ResetPose()
            elif event.key == pygame.K_e:
                self.model.ResetExpression()

        elif event.type == pygame.MOUSEMOTION:
            self.model.Drag(*pygame.mouse.get_pos())

    def set_offset(self, dx: float, dy: float):
        """设置模型偏移量。"""
        self.dx = dx
        self.dy = dy
        self.model.SetOffset(dx, dy)

    def set_scale(self, scale: float):
        """设置模型缩放比例。"""
        self.scale = scale
        self.model.SetScale(scale)


class Live2DRenderer:
    """封装 OpenGL 和 Pygame 的渲染逻辑。"""

    @staticmethod
    def render_texture(surface: pygame.Surface) -> int:
        """将 Pygame Surface 转换为 OpenGL 纹理并返回纹理 ID。"""
        texture_data = pygame.image.tobytes(surface, "RGBA", True)
        texture_id = glGenTextures(1)
        glBindTexture(GL_TEXTURE_2D, texture_id)
        glTexImage2D(
            GL_TEXTURE_2D,
            0,
            GL_RGBA,
            surface.get_width(),
            surface.get_height(),
            0,
            GL_RGBA,
            GL_UNSIGNED_BYTE,
            texture_data
        )
        glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR)
        print(f'=========={texture_id}==============')
        return texture_id

    @staticmethod
    def draw_texture(texture_id: object, *args: tuple[tuple[3], tuple[3], tuple[3], tuple[3]]) -> None:
        """绘制 OpenGL 纹理。"""
        glEnable(GL_TEXTURE_2D)
        glBindTexture(GL_TEXTURE_2D, texture_id)
        glBegin(GL_QUADS)
        glTexCoord2f(0, 0)  # 左上角纹理坐标
        glVertex3f(*args[3])  # 对应左下角顶点
        glTexCoord2f(1, 0)  # 右上角纹理坐标
        glVertex3f(*args[2])  # 对应右下角顶点
        glTexCoord2f(1, 1)  # 右下角纹理坐标
        glVertex3f(*args[1])  # 对应右上角顶点
        glTexCoord2f(0, 1)  # 左下角纹理坐标
        glVertex3f(*args[0])  # 对应左上角顶点
        glEnd()


    @staticmethod
    def draw_black_box():
        glDisable(GL_TEXTURE_2D)  # 禁用纹理
        glColor3f(0, 0, 0)  # 设置黑色
        glLineWidth(2)  # 边框宽度

        # 定义方框的标准化坐标(OpenGL 坐标系范围为 [-1, 1])
        x_min = -0.5
        x_max = 0.5
        y_min = -0.5
        y_max = 0.5

        # 绘制方框
        glBegin(GL_LINE_LOOP)
        glVertex2f(x_min, y_max)  # 左上角
        glVertex2f(x_max, y_max)  # 右上角
        glVertex2f(x_max, y_min)  # 右下角
        glVertex2f(x_min, y_min)  # 左下角
        glEnd()

        glEnable(GL_TEXTURE_2D)  # 恢复纹理


# 动作开始播放前调用该函数
def onStartCallback(group: str, no: int):
    print(f"touched and motion [{group}_{no}] is started")

# 动作播放结束后会调用该函数
def onFinishCallback():
    print("motion finished")



def main():
    """主函数:初始化并运行 Live2D 演示。"""
    # 初始化 Pygame 和 OpenGL
    pygame.init()
    live2d.init()
    live2d.setLogEnable(True)
    screen = pygame.display.set_mode((1200, 800), DOUBLEBUF | OPENGL, vsync=1)

    if live2d.LIVE2D_VERSION == 3:
        print('Live2D V3 模式')
        live2d.glInit()

    # 加载背景和模型
    img_alpha = pygame.image.load("山_川沿いA.png").convert_alpha()
    background = Live2DRenderer.render_texture(img_alpha)
    background_vertices =  (
    (-1.0, 1.0, 0),  # 左上
    (1.0, 1.0, 0),   # 右上
    (1.0, -1.0, 0),  # 右下
    (-1.0, -1.0, 0)  # 左下
)

    model = Live2DModel(r"Resources\BRAIN\BRAIN.model3.json")

    clock = pygame.time.Clock()
    running = True

    while running:
        delta_time = clock.tick(60) / 1000.0  # 转换为秒

        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                running = False
            if event.type == pygame.MOUSEWHEEL:
                model.model.StartMotion("Flick", 0, MotionPriority.FORCE, onStartCallback, onFinishCallback)

            model.handle_event(event)

        if not model.wav_handler.Update():
            # 更新模型状态
            model.update(delta_time)


        if model.wav_handler.Update():

            # 利用 wav 响度更新 嘴部张合
            model.model.SetParameterValue(
                StandardParams.ParamMouthOpenY, model.wav_handler.GetRms() * model.lip_sync_n
            )

        if not model.audio_played:
            # 播放一个不存在的动作
            model.model.StartMotion(
                "",
                0,
                live2d.MotionPriority.FORCE,
                model.start_callback,
                model.on_finish_motion_callback,
            )
            model.audio_played = True

        model.update_setoff()

        # 清除缓冲区
        glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)
        # 渲染
        Live2DRenderer.draw_texture(background, *background_vertices)
        # Live2DRenderer.draw_black_box()

        model.draw()

        pygame.display.flip()

    # 清理资源
    live2d.dispose()
    pygame.quit()


if __name__ == "__main__":
    main()

Logo

中国智能体开发者社区,聚焦智能体与大模型开发,提供前沿资讯、实用工具链、开源项目及行业案例。通过技术沙龙、开发者大赛等活动,促进经验交流与协作,助力开发者快速构建创新智能应用。

更多推荐