135 lines
4.1 KiB
GDScript
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
|