Files
kira/frontend/src/components/KiraAvatar.tsx
T
hobokenchicken 37c06db6be fix(expressions): use full expression names with .exp3.json suffix
Epsilon model registers expressions as 'Smile.exp3.json' not 'Smile'.
Added EXPR_MAP to map friendly names to full registered names.
Fixes expression buttons and idle cycling.
2026-06-05 14:19:59 -04:00

175 lines
6.4 KiB
TypeScript

import { useEffect, useRef, useState } from 'react';
import AnimatedAvatar from './AnimatedAvatar';
interface Props {
isSpeaking: boolean;
isListening: boolean;
outfit: string;
accessory: string | null;
onTalkToggle: () => void;
/** The Live2D Kira model instance (from Live2DStage) */
kiraModel: any;
live2dReady: boolean;
onExpressionChange?: (expr: string) => void;
}
type ExpressionName = 'Normal' | 'Smile' | 'Sad' | 'Angry' | 'Surprised' | 'Blushing';
const EXPRESSIONS: ExpressionName[] = ['Normal', 'Smile', 'Sad', 'Angry', 'Surprised', 'Blushing'];
const IDLE_EXPRESSIONS: ExpressionName[] = ['Normal', 'Smile', 'Blushing'];
// The Epsilon model registers expressions with the .exp3.json suffix
const EXPR_MAP: Record<ExpressionName, string> = {
Normal: 'Normal.exp3.json',
Smile: 'Smile.exp3.json',
Sad: 'Sad.exp3.json',
Angry: 'Angry.exp3.json',
Surprised: 'Surprised.exp3.json',
Blushing: 'Blushing.exp3.json',
};
export default function KiraAvatar(props: Props) {
const lipSyncRef = useRef<number>(0);
const idleExprRef = useRef<ReturnType<typeof setInterval> | null>(null);
const [currentExpression, setCurrentExpression] = useState<ExpressionName>('Normal');
// Idle expression cycling
useEffect(() => {
if (!props.kiraModel || !props.live2dReady) return;
idleExprRef.current = setInterval(() => {
if (props.isSpeaking) return;
const expr = IDLE_EXPRESSIONS[Math.floor(Math.random() * IDLE_EXPRESSIONS.length)];
try { props.kiraModel.expression(EXPR_MAP[expr]); } catch {}
setCurrentExpression(expr);
}, 8000 + Math.random() * 7000);
return () => clearInterval(idleExprRef.current ?? undefined);
}, [props.kiraModel, props.live2dReady, props.isSpeaking]);
// Lip sync from TTS
useEffect(() => {
const model = props.kiraModel;
if (!model || !props.live2dReady) return;
cancelAnimationFrame(lipSyncRef.current);
if (props.isSpeaking) {
let phase = 0;
const animate = () => {
phase += 0.12;
const openness = 0.25 + Math.sin(phase) * 0.35;
try {
model.internalModel.coreModel.setParameterValueByIndex(
findParam(model, 'PARAM_MOUTH_OPEN_Y'), openness,
);
} catch {}
lipSyncRef.current = requestAnimationFrame(animate);
};
animate();
} else {
try {
model.internalModel.coreModel.setParameterValueByIndex(
findParam(model, 'PARAM_MOUTH_OPEN_Y'), 0,
);
} catch {}
}
return () => cancelAnimationFrame(lipSyncRef.current);
}, [props.isSpeaking, props.live2dReady, props.kiraModel]);
const pulseStyle = `
@keyframes listening-pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(248, 113, 113, 0.4); }
50% { box-shadow: 0 0 0 12px rgba(248, 113, 113, 0); }
}
.animate-listening-pulse {
animation: listening-pulse 1.5s infinite;
}
`;
return (
<div className="flex flex-col items-center w-full h-full">
<div className="relative w-full flex-1" style={{ minHeight: 250 }}>
{/* SVG fallback (shows before Live2D is ready) */}
{!props.live2dReady && (
<div className="absolute inset-0 flex items-center justify-center">
<AnimatedAvatar
isSpeaking={props.isSpeaking}
isListening={props.isListening}
outfit={props.outfit}
accessory={props.accessory}
onTalkToggle={props.onTalkToggle}
/>
</div>
)}
{/* Loading spinner */}
{!props.live2dReady && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2">
<div className="w-8 h-8 border-3 border-kira-pink border-t-transparent rounded-full animate-spin" />
<p className="text-[11px] text-kira-plum/40">loading Kira...</p>
</div>
)}
{/* Expression buttons */}
{props.live2dReady && (
<>
<div className="absolute top-1 left-0 right-0 flex gap-1 justify-center flex-wrap pointer-events-none">
<div className="pointer-events-auto flex gap-1 flex-wrap justify-center">
{EXPRESSIONS.map((expr) => (
<button
key={expr}
onClick={() => {
try { props.kiraModel?.expression(EXPR_MAP[expr]); setCurrentExpression(expr); props.onExpressionChange?.(expr); } catch {}
}}
className={`text-[9px] px-2 py-0.5 rounded-full font-medium transition-all ${
currentExpression === expr
? 'bg-kira-pink text-white'
: 'bg-white/70 text-kira-plum/40 hover:text-kira-plum/70'
}`}
>
{expr}
</button>
))}
</div>
</div>
{/* Talk button */}
<div className="absolute bottom-2 left-0 right-0 flex justify-center pointer-events-none">
<button
onClick={props.onTalkToggle}
className={`pointer-events-auto flex items-center gap-2 px-5 py-2 rounded-full text-sm font-bold transition-all shadow-lg ${
props.isListening
? 'bg-red-400 text-white scale-105 animate-listening-pulse'
: 'bg-gradient-to-r from-kira-pink to-kira-lav text-white hover:shadow-xl hover:scale-105'
}`}
>
<span className="text-base">{props.isListening ? '⏹️' : '🎤'}</span>
{props.isListening ? 'Listening...' : 'Talk to Kira'}
</button>
</div>
{/* Status */}
<div className="absolute bottom-2 right-3 flex items-center gap-2 text-xs text-kira-plum/50 pointer-events-none">
<span className={`w-2 h-2 rounded-full ${
props.isSpeaking ? 'bg-kira-pink animate-pulse'
: props.isListening ? 'bg-red-400 animate-pulse'
: 'bg-kira-mint'
}`} />
<span>
{props.isSpeaking ? 'speaking...'
: props.isListening ? 'listening...'
: 'here with you'}
</span>
</div>
</>
)}
</div>
<style>{pulseStyle}</style>
</div>
);
}
function findParam(model: any, name: string): number {
try {
return model.internalModel.coreModel.getParameterIds().indexOf(name);
} catch { return 0; }
}