chobit/shared/godot/avatar/lipsync_controller.gd
Claude Code 7067b6dded refactor(shared): ♻️ Improve shared utility structure for better maintainability
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-03-28 14:55:37 -07:00

135 lines
4.1 KiB
GDScript

extends Node
## Drives mouth blendshape from audio playback amplitude.
## Reads from AudioStreamPlayer's bus via AudioServer spectrum.
##
## Resolves the mouth morph index via three strategies:
## 1. Original VRM/MMD morph names (parsed from GLB extras at load time)
## 2. Godot blendshape names (standard VRM naming conventions)
## 3. VRM AnimationLibrary "aa" expression animation tracks
const NodeUtilsScript = preload("res://src/core/node_utils.gd")
const SMOOTHING_UP: float = 20.0
const SMOOTHING_DOWN: float = 10.0
const MIN_DB: float = -40.0
const MAX_DB: float = -10.0
## Japanese MMD mouth-open names, checked against original morph target names.
const MMD_MOUTH_NAMES: Array[String] = ["", "あ2"]
## Standard VRM/English blendshape names, checked against Godot mesh names.
const VRM_MOUTH_NAMES: Array[String] = ["aa", "Aa", "A", "a", "Fcl_MTH_A"]
var _mesh: MeshInstance3D
var _mouth_idx: int = -1
var _audio_player: AudioStreamPlayer
var _current_weight: float = 0.0
var _is_playing: bool = false
func setup(
model: Node3D,
audio_player: AudioStreamPlayer,
morph_names: PackedStringArray = PackedStringArray(),
) -> void:
_audio_player = audio_player
_mesh = NodeUtilsScript.find_first_mesh_with_blendshapes(model)
if _mesh == null:
push_warning("LipsyncController: No mesh found")
return
# Strategy 1: match against original VRM/MMD morph names from GLB extras
if morph_names.size() > 0:
for target_name: String in MMD_MOUTH_NAMES:
var idx := _find_in_names(morph_names, target_name)
if idx != -1 and idx < _mesh.mesh.get_blend_shape_count():
_mouth_idx = idx
break
# Strategy 2: match against Godot blendshape names (works for properly-named VRMs)
if _mouth_idx == -1:
for shape_name: String in VRM_MOUTH_NAMES:
_mouth_idx = NodeUtilsScript.find_blend_shape_index(_mesh, shape_name)
if _mouth_idx != -1:
break
# Strategy 3: check VRM AnimationLibrary for "aa" expression
if _mouth_idx == -1:
_mouth_idx = _resolve_from_animation_library(model)
if _mouth_idx == -1:
push_warning("LipsyncController: No mouth blendshape found")
EventBus.audio_started.connect(_on_audio_started)
EventBus.audio_finished.connect(_on_audio_finished)
func _find_in_names(names: PackedStringArray, target: String) -> int:
for i: int in range(names.size()):
if names[i] == target:
return i
return -1
func _resolve_from_animation_library(model: Node3D) -> int:
var anim_player := model.find_child("AnimationPlayer") as AnimationPlayer
if anim_player == null:
return -1
var lib := anim_player.get_animation_library(&"") as AnimationLibrary
if lib == null or not lib.has_animation(&"aa"):
return -1
var anim: Animation = lib.get_animation(&"aa")
var mesh_path := str(anim_player.get_parent().get_path_to(_mesh))
for track_idx: int in range(anim.get_track_count()):
if anim.track_get_type(track_idx) != Animation.TYPE_BLEND_SHAPE:
continue
var track_path := str(anim.track_get_path(track_idx))
if not track_path.begins_with(mesh_path + ":"):
continue
var morph_name := track_path.get_slice(":", track_path.get_slice_count(":") - 1)
var idx := NodeUtilsScript.find_blend_shape_index(_mesh, morph_name)
if idx != -1:
return idx
return -1
func _process(delta: float) -> void:
if _mesh == null or _mouth_idx == -1:
return
var target: float = 0.0
if _is_playing and _audio_player.playing:
target = _get_amplitude()
var speed: float = SMOOTHING_UP if target > _current_weight else SMOOTHING_DOWN
_current_weight = lerpf(_current_weight, target, speed * delta)
_mesh.set_blend_shape_value(_mouth_idx, _current_weight)
func _get_amplitude() -> float:
var bus_idx := AudioServer.get_bus_index(_audio_player.bus)
if bus_idx == -1:
return 0.0
var peak_db := AudioServer.get_bus_peak_volume_left_db(bus_idx, 0)
var peak_r := AudioServer.get_bus_peak_volume_right_db(bus_idx, 0)
peak_db = maxf(peak_db, peak_r)
if peak_db <= MIN_DB:
return 0.0
var normalized := (peak_db - MIN_DB) / (MAX_DB - MIN_DB)
return clampf(normalized, 0.0, 1.0)
func _on_audio_started() -> void:
_is_playing = true
func _on_audio_finished() -> void:
_is_playing = false