fix(stage-ui-live2d): close Live2D mouth after TTS finishes speaking (#2064)

This commit is contained in:
Zyr - AI
2026-07-17 17:55:31 +08:00
committed by GitHub
parent 0eb46620a5
commit f0d9f3d3bb
2 changed files with 28 additions and 1 deletions
@@ -462,6 +462,13 @@ export function useMotionUpdatePluginExpression(
*
* `nowSpeaking` (not `mouthOpenSize > 0`) is the speech boundary, so silent
* gaps between phonemes write 0 directly instead of triggering the release.
*
* After the release tail elapses, the plugin keeps forcing ParamMouthOpenY to 0
* for a short handoff hold (HANDOFF_HOLD_MS) before handing control back to
* motion/expression plugins. This reliably closes the mouth after speech even
* when an idle motion curve leaves a non-zero resting value, while still
* letting idle mouth expressions take over shortly after speech ends (rather
* than overriding them forever).
*/
export function useMotionUpdatePluginLipSync(
mouthOpenSize: Ref<number>,
@@ -469,8 +476,14 @@ export function useMotionUpdatePluginLipSync(
): MotionManagerPlugin {
// 200 ms covers a typical phoneme tail without lagging behind the next utterance.
const RELEASE_DURATION_MS = 200
// After the release tail, keep forcing the mouth shut for this long before
// handing control back to motion/expression plugins. This guarantees the
// mouth actually closes even on the first idle frame, where a non-zero
// resting motion curve would otherwise reopen it immediately.
const HANDOFF_HOLD_MS = 500
let releaseRemainingMs = 0
let handoffRemainingMs = 0
let lastForcedValue = 0
// Smoothstep: 3t^2 - 2t^3, eases in/out with zero slope at endpoints.
@@ -480,12 +493,22 @@ export function useMotionUpdatePluginLipSync(
if (nowSpeaking.value) {
lastForcedValue = mouthOpenSize.value
releaseRemainingMs = RELEASE_DURATION_MS
handoffRemainingMs = HANDOFF_HOLD_MS
ctx.model.setParameterValueById('ParamMouthOpenY', mouthOpenSize.value)
return
}
if (releaseRemainingMs <= 0)
if (releaseRemainingMs <= 0) {
if (handoffRemainingMs > 0) {
// Release tail elapsed. Keep forcing the mouth shut through the handoff
// hold so a non-zero idle motion curve cannot reopen it on the first
// idle frame. After the hold we stop owning the parameter and let
// motion/expression plugins drive it again.
handoffRemainingMs = Math.max(0, handoffRemainingMs - ctx.timeDelta * 1000)
ctx.model.setParameterValueById('ParamMouthOpenY', 0)
}
return
}
releaseRemainingMs = Math.max(0, releaseRemainingMs - ctx.timeDelta * 1000)
const blend = smoothstep(1 - releaseRemainingMs / RELEASE_DURATION_MS)
@@ -744,6 +744,10 @@ function openTtsSession(): StageTtsSession {
model: activeSpeechModel.value,
error: err,
})
// Drop the failed session so no further audio is queued, but let the
// playback manager keep draining already-queued audio and emit its own
// terminal events. Calling resetSpeakingState() here would force the
// mouth shut while audio is still playing.
clearIfActive()
},
onDone: () => {