diff --git a/frontend/public/models/maki-bee.vrm b/frontend/public/models/hachisannomaki.vrm similarity index 100% rename from frontend/public/models/maki-bee.vrm rename to frontend/public/models/hachisannomaki.vrm diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index df355cc..ca5cc4e 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -4,54 +4,54 @@ import "./globals.css"; import Header from "@/components/Header"; const geistSans = Geist({ - variable: "--font-geist-sans", - subsets: ["latin"], + variable: "--font-geist-sans", + subsets: ["latin"], }); const geistMono = Geist_Mono({ - variable: "--font-geist-mono", - subsets: ["latin"], + variable: "--font-geist-mono", + subsets: ["latin"], }); export const metadata: Metadata = { - title: "恋AI", - description: - "バーチャル女子大生「まき」とのリアルタイム会話で、きみのコミュ力爆上げしちゃおう!", - // icons: { - // icon: "/2.png", - // shortcut: "/2.png", //テストに引っかかってるらしい - // apple: "/2.png", - // }, + title: "恋AI", + description: + "バーチャル女子大生「まき」とのリアルタイム会話で、きみのコミュ力爆上げしちゃおう!", + // icons: { + // icon: "/2.png", + // shortcut: "/2.png", //テストに引っかかってるらしい + // apple: "/2.png", + // }, }; export default function RootLayout({ - children, + children, }: Readonly<{ - children: React.ReactNode; + children: React.ReactNode; }>) { - return ( - - - {/* Preload VRM asset to shorten first render time */} - - - - - -
-
-
- {children} -
-
- - - ); + return ( + + + {/* Preload VRM asset to shorten first render time */} + + + + + +
+
+
+ {children} +
+
+ + + ); } diff --git a/frontend/src/app/simulation/page.tsx b/frontend/src/app/simulation/page.tsx index fd7189e..a8984bd 100644 --- a/frontend/src/app/simulation/page.tsx +++ b/frontend/src/app/simulation/page.tsx @@ -4,12 +4,12 @@ import { Heart, Lightbulb, Video } from "lucide-react"; import Image from "next/image"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { - AvatarDisplay, - ConversationControls, - ConversationHistoryPanel, - ErrorDisplay, - RecordingStatus, - UserVideoDisplay, + AvatarDisplay, + ConversationControls, + ConversationHistoryPanel, + ErrorDisplay, + RecordingStatus, + UserVideoDisplay, } from "@/components/simulation"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; @@ -33,718 +33,718 @@ type GestureType = "idle" | "thinking" | "talking" | "peace" | "nodding"; type BackgroundKey = "library" | "classroom" | "xmas"; const BACKGROUNDS: Record< - BackgroundKey, - { src: string; label: string; scenario: string } + BackgroundKey, + { src: string; label: string; scenario: string } > = { - library: { - src: "/springschool.jpg", - label: "入学式", - scenario: - "入学式の校庭。初対面らしく丁寧に挨拶しつつ、学校生活や授業の話題で盛り上げよう。", - }, - classroom: { - src: "/kokuban.png", - label: "教室", - scenario: - "放課後の教室。AIはクラスメイト。授業や課題、サークル、週末の予定など身近な話題を話そう。", - }, - xmas: { - src: "/christmas-back033.jpg", - label: "クリスマス", - scenario: - "最近の出来事やプレゼント、冬の予定など明るい話題で盛り上がりやすいです。いいムードを維持しよう。", - }, + library: { + src: "/springschool.jpg", + label: "入学式", + scenario: + "入学式の校庭。初対面らしく丁寧に挨拶しつつ、学校生活や授業の話題で盛り上げよう。", + }, + classroom: { + src: "/kokuban.png", + label: "教室", + scenario: + "放課後の教室。AIはクラスメイト。授業や課題、サークル、週末の予定など身近な話題を話そう。", + }, + xmas: { + src: "/christmas-back033.jpg", + label: "クリスマス", + scenario: + "最近の出来事やプレゼント、冬の予定など明るい話題で盛り上がりやすいです。いいムードを維持しよう。", + }, }; export default function SimulationPage() { - const [conversationStarted, setConversationStarted] = useState(false); - const [videoEnabled, setVideoEnabled] = useState(true); - const [lipSyncValue, setLipSyncValue] = useState(0); - const [showHistory, setShowHistory] = useState(false); - const [showControls, setShowControls] = useState(true); - const [avatarEmotion, setAvatarEmotion] = useState< - "neutral" | "happy" | "sad" | "surprised" | "angry" | "bashful" - >("bashful"); - const [avatarGesture, setAvatarGesture] = useState("idle"); - const [selectedAvatar, setSelectedAvatar] = useState< - "female" | "male" | "neutral" - >("female"); - const [selectedBackground, setSelectedBackground] = - useState("library"); - const [assistMode, setAssistMode] = useState(true); - const [adviceCompleted, setAdviceCompleted] = useState< - Record - >({}); - const [showAdvicePanel, setShowAdvicePanel] = useState(false); - - const videoStreamRef = useRef(null); - const hasSentBatchRef = useRef(false); - const avatarModelUrl = useMemo(() => { - if (selectedAvatar === "male") return "/models/rento.vrm"; - if (selectedAvatar === "neutral") return "/models/kouta.vrm"; - return "/models/maki-bee.vrm"; // female - }, [selectedAvatar]); - - // 背景の保存/復元s - useEffect(() => { - try { - const saved = localStorage.getItem( - "selectedBackground", - ) as BackgroundKey | null; - if (saved === "library" || saved === "classroom" || saved === "xmas") { - setSelectedBackground(saved); - } - const savedAssist = localStorage.getItem("assistMode"); - if (savedAssist === "true" || savedAssist === "false") { - setAssistMode(savedAssist === "true"); - } - } catch { - // ignore - } - }, []); - useEffect(() => { - try { - localStorage.setItem("selectedBackground", selectedBackground); - localStorage.setItem("assistMode", String(assistMode)); - } catch { - // ignore - } - }, [selectedBackground, assistMode]); - const avatarName = useMemo(() => { - const parts = avatarModelUrl.split("/"); - const file = parts[parts.length - 1] || ""; - const base = file.replace(/\.vrm$/i, ""); - return romajiToHiragana(base) || base; - }, [avatarModelUrl]); - const selectedVoiceId = useMemo(() => { - const femaleId = config.tts.voices?.female || config.tts.voiceId || ""; - const maleId = config.tts.voices?.male || ""; - const neutralId = config.tts.voices?.neutral || femaleId; - if (selectedAvatar === "male") return maleId || config.tts.voiceId || ""; - if (selectedAvatar === "neutral") - return neutralId || config.tts.voiceId || ""; - return femaleId; - }, [selectedAvatar]); - - const adviceItems = useMemo( - () => - BACKGROUND_ADVICE[selectedBackground].map((item) => ({ - id: item.id, - label: item.label, - checked: !!adviceCompleted[item.id], - })), - [selectedBackground, adviceCompleted], - ); - const adviceAvailable = assistMode && adviceItems.length > 0; - - // auth handled by shared Header; no local auth state here - - // Persist selected avatar for use on feedback page (e.g., to choose voice) - useEffect(() => { - try { - localStorage.setItem("selectedAvatar", selectedAvatar); - } catch { - // ignore - } - }, [selectedAvatar]); - - // Media devices (camera/mic) - const { - stream, - error: mediaError, - startStream, - stopStream, - } = useMediaDevices(); - - // Audio recording - const { - isRecording, - audioURL, - audioBlobs, - analysisResult, - error: recorderError, - startRecording, - stopRecording, - clearRecording, - } = useAudioRecorder(); - - // Facial analysis - const { - metrics: facialMetrics, - error: facialError, - startAnalysis, - stopAnalysis, - } = useFacialAnalysis(); - - // Gesture tracking - const { reset: resetGestures, getMetrics: getGestureMetrics } = - useGestureTracking(facialMetrics); - - // Lip sync update callback - const handleLipSyncUpdate = useCallback((value: number) => { - setLipSyncValue(value); - }, []); - - // Conversation management - const { - session, - messages, - isProcessing, - error: conversationError, - startSession, - endSession, - sendAudio, - } = useConversation({ - onLipSyncUpdate: handleLipSyncUpdate, - ttsVoiceId: selectedVoiceId || undefined, - onEmotionUpdate: setAvatarEmotion, - avatarId: - selectedAvatar === "female" - ? "maki" - : selectedAvatar === "male" - ? "rento" - : "kouta", - }); - // 最新ユーザー発話でアドバイス達成判定(state反映遅延による未達表示不安定性を解消) - useEffect(() => { - if (!messages.length) return; - const lastUser = [...messages].reverse().find((m) => m.role === "user"); - if (!lastUser) return; - const text = lastUser.content; - const adviceList = BACKGROUND_ADVICE[selectedBackground]; - const next = { ...adviceCompleted }; - let changed = false; - for (const item of adviceList) { - if (next[item.id]) continue; - if (item.patterns.some((re) => re.test(text))) { - next[item.id] = true; - changed = true; - } - } - if (changed) { - setAdviceCompleted(next); - try { - if (session?.id) { - localStorage.setItem( - `adviceCompleted:${session.id}`, - JSON.stringify(next), - ); - } - } catch {} - } - }, [messages, selectedBackground, adviceCompleted, session?.id]); - - // セッション開始時に過去の達成状況があれば復元 - useEffect(() => { - if (!session?.id) return; - try { - const raw = localStorage.getItem(`adviceCompleted:${session.id}`); - if (raw) { - const parsed = JSON.parse(raw) as Record; - setAdviceCompleted(parsed); - } - } catch {} - }, [session?.id]); - - // 背景変更時にアドバイス進捗リセット - useEffect(() => { - setAdviceCompleted({}); - void selectedBackground; - }, [selectedBackground]); - - useEffect(() => { - if (!adviceAvailable) { - setShowAdvicePanel(false); - } - }, [adviceAvailable]); - - // Preload VRM model when selected avatar changes - useEffect(() => { - logMediaRecorderSupport(); - preloadVRM(avatarModelUrl).catch(() => { - // Non-critical, ignore - }); - // フィードバック画面でも使えるようにモデルURLを永続化 - try { - localStorage.setItem("selectedAvatarModelUrl", avatarModelUrl); - } catch { - // ignore - } - }, [avatarModelUrl]); - - // Video ready handler - const handleVideoReady = useCallback( - (videoElement: HTMLVideoElement) => { - console.log("ビデオ準備完了、表情分析を開始します"); - startAnalysis(videoElement, { x: 0.25, y: 0.5 }); - }, - [startAnalysis], - ); - - // End conversation handler - async function handleEndConversation() { - if (isRecording) { - stopRecording(); - } - - stopAnalysis(); - - // Save gesture metrics - if (session?.id) { - const gestureMetrics = getGestureMetrics(); - if (gestureMetrics) { - try { - await gestureApi.saveMetrics(session.id, gestureMetrics); - } catch (error) { - console.error("Failed to save gesture metrics:", error); - } - } - } - - await endSession(); - stopStream(); - - // Redirect to feedback page - if (session?.id) { - window.location.href = `/feedback?sessionId=${session.id}`; - } else { - window.location.href = "/feedback"; - } - } - - // Start conversation handler - const handleStartConversation = useCallback(async () => { - try { - resetGestures(); - - await startStream({ - video: { - width: { ideal: 640 }, - height: { ideal: 480 }, - frameRate: { ideal: 24 }, - facingMode: "user", - }, - audio: { - echoCancellation: true, - noiseSuppression: true, - autoGainControl: true, - }, - }); - - await startSession(); - setConversationStarted(true); - } catch (error) { - console.error("Failed to start conversation:", error); - } - }, [startStream, startSession, resetGestures]); - - // Toggle recording - const toggleRecording = useCallback(() => { - if (!stream) return; - - if (!isRecording) { - startRecording(stream); - } else { - stopRecording(); - } - }, [stream, isRecording, startRecording, stopRecording]); - - // Send recorded audio when recording stops - useEffect(() => { - if ( - audioBlobs.length === 0 || - isRecording || - !session || - !analysisResult || - hasSentBatchRef.current - ) - return; - - const sendRecordedAudio = async () => { - hasSentBatchRef.current = true; // guard duplicate sends for this batch - console.log("Sending recorded audio..."); - const audioBlob = new Blob(audioBlobs, { - type: audioBlobs[0]?.type || "audio/webm", - }); - appendVoiceAnalysis(session.id, analysisResult); - await sendAudio(audioBlob, analysisResult); - clearRecording(); - // reset guard for next recording batch after clearing - setTimeout(() => { - hasSentBatchRef.current = false; - }, 0); - }; - - sendRecordedAudio(); - }, [ - audioBlobs, - isRecording, - session, - sendAudio, - clearRecording, - analysisResult, - ]); - - // When a new recording starts, ensure guard is cleared - useEffect(() => { - if (isRecording) { - hasSentBatchRef.current = false; - } - }, [isRecording]); - - // 会話内容に基づくemotion更新は useConversation の onEmotionUpdate から反映 - - // Gesture changes based on recording/processing state - useEffect(() => { - if (isRecording) { - setAvatarGesture("nodding"); - } else if (isProcessing) { - setAvatarGesture("thinking"); - } else if (lipSyncValue > 0.1) { - setAvatarGesture("talking"); - } else { - const gestures: GestureType[] = ["idle", "idle", "idle"]; - const randomGesture = - gestures[Math.floor(Math.random() * gestures.length)]; - setAvatarGesture(randomGesture); - } - }, [isRecording, isProcessing, lipSyncValue]); - - // Toggle video - const toggleVideo = useCallback(() => { - if (stream) { - const videoTrack = stream.getVideoTracks()[0]; - if (videoTrack) { - videoTrack.enabled = !videoTrack.enabled; - setVideoEnabled(videoTrack.enabled); - } - } - }, [stream]); - - return ( -
- {/* Header is provided by shared Header component in layout */} - - {!conversationStarted ? ( - /* Initial State - Full Screen Welcome */ -
-
-
-
- -
-

- 会話シミュレーション -

-

- {avatarName}と会話の練習をしましょう -

-
- - {(mediaError || conversationError) && ( - -

- ⚠️ - エラーが発生しました -

-

- {mediaError?.message || conversationError?.message} -

- {mediaError?.message.includes("拒否") && ( -
-

- 💡 - ブラウザのアドレスバー横のカメラアイコンをクリックして、アクセスを許可してください -

-
- )} -
- )} - - -
-

- Are you ready? -

-

- カメラとマイクへのアクセスを許可して -
- 会話を始めよう! -

-
- {/* Avatar Selection (Image Buttons) */} -
- - - - - -
- {/* Background Selection (Image Buttons) */} -
-

- 背景を選択 -

-
- {(Object.keys(BACKGROUNDS) as Array).map( - (key) => { - const bg = BACKGROUNDS[key]; - const selected = selectedBackground === key; - return ( - - ); - }, - )} -
- {/* Situation Hint */} -
-

- シチュエーション -

-

{BACKGROUNDS[selectedBackground].scenario}

-
- {/* Assist Mode Toggle (下段表示) */} -
-

- モード選択 -

-

- アシストモードでは会話中に高得点のコツ(アドバイス)を表示します。 -

-
- -
-
-
- -
-
-
- ) : ( - /* Conversation State - Split Screen Layout */ -
-
- {/* Background image + overlay */} -
- {`${BACKGROUNDS[selectedBackground].label}の背景`} -
-
-
- {/* AI Avatar */} -
- - {adviceAvailable && ( -
-
- {showAdvicePanel && ( - - )} - -
-
- )} -
- - {/* User Video */} - -
- - {/* Recording Status Indicator */} - - - {/* Error Messages */} - - - {/* Conversation History Panel */} - -
- - {/* Control Panel */} - setShowControls((prev) => !prev)} - /> -
- )} -
- ); + const [conversationStarted, setConversationStarted] = useState(false); + const [videoEnabled, setVideoEnabled] = useState(true); + const [lipSyncValue, setLipSyncValue] = useState(0); + const [showHistory, setShowHistory] = useState(false); + const [showControls, setShowControls] = useState(true); + const [avatarEmotion, setAvatarEmotion] = useState< + "neutral" | "happy" | "sad" | "surprised" | "angry" | "bashful" + >("bashful"); + const [avatarGesture, setAvatarGesture] = useState("idle"); + const [selectedAvatar, setSelectedAvatar] = useState< + "female" | "male" | "neutral" + >("female"); + const [selectedBackground, setSelectedBackground] = + useState("library"); + const [assistMode, setAssistMode] = useState(true); + const [adviceCompleted, setAdviceCompleted] = useState< + Record + >({}); + const [showAdvicePanel, setShowAdvicePanel] = useState(false); + + const videoStreamRef = useRef(null); + const hasSentBatchRef = useRef(false); + const avatarModelUrl = useMemo(() => { + if (selectedAvatar === "male") return "/models/rento.vrm"; + if (selectedAvatar === "neutral") return "/models/kouta.vrm"; + return "/models/hachisannomaki.vrm"; // female + }, [selectedAvatar]); + + // 背景の保存/復元s + useEffect(() => { + try { + const saved = localStorage.getItem( + "selectedBackground" + ) as BackgroundKey | null; + if (saved === "library" || saved === "classroom" || saved === "xmas") { + setSelectedBackground(saved); + } + const savedAssist = localStorage.getItem("assistMode"); + if (savedAssist === "true" || savedAssist === "false") { + setAssistMode(savedAssist === "true"); + } + } catch { + // ignore + } + }, []); + useEffect(() => { + try { + localStorage.setItem("selectedBackground", selectedBackground); + localStorage.setItem("assistMode", String(assistMode)); + } catch { + // ignore + } + }, [selectedBackground, assistMode]); + const avatarName = useMemo(() => { + const parts = avatarModelUrl.split("/"); + const file = parts[parts.length - 1] || ""; + const base = file.replace(/\.vrm$/i, ""); + return romajiToHiragana(base) || base; + }, [avatarModelUrl]); + const selectedVoiceId = useMemo(() => { + const femaleId = config.tts.voices?.female || config.tts.voiceId || ""; + const maleId = config.tts.voices?.male || ""; + const neutralId = config.tts.voices?.neutral || femaleId; + if (selectedAvatar === "male") return maleId || config.tts.voiceId || ""; + if (selectedAvatar === "neutral") + return neutralId || config.tts.voiceId || ""; + return femaleId; + }, [selectedAvatar]); + + const adviceItems = useMemo( + () => + BACKGROUND_ADVICE[selectedBackground].map((item) => ({ + id: item.id, + label: item.label, + checked: !!adviceCompleted[item.id], + })), + [selectedBackground, adviceCompleted] + ); + const adviceAvailable = assistMode && adviceItems.length > 0; + + // auth handled by shared Header; no local auth state here + + // Persist selected avatar for use on feedback page (e.g., to choose voice) + useEffect(() => { + try { + localStorage.setItem("selectedAvatar", selectedAvatar); + } catch { + // ignore + } + }, [selectedAvatar]); + + // Media devices (camera/mic) + const { + stream, + error: mediaError, + startStream, + stopStream, + } = useMediaDevices(); + + // Audio recording + const { + isRecording, + audioURL, + audioBlobs, + analysisResult, + error: recorderError, + startRecording, + stopRecording, + clearRecording, + } = useAudioRecorder(); + + // Facial analysis + const { + metrics: facialMetrics, + error: facialError, + startAnalysis, + stopAnalysis, + } = useFacialAnalysis(); + + // Gesture tracking + const { reset: resetGestures, getMetrics: getGestureMetrics } = + useGestureTracking(facialMetrics); + + // Lip sync update callback + const handleLipSyncUpdate = useCallback((value: number) => { + setLipSyncValue(value); + }, []); + + // Conversation management + const { + session, + messages, + isProcessing, + error: conversationError, + startSession, + endSession, + sendAudio, + } = useConversation({ + onLipSyncUpdate: handleLipSyncUpdate, + ttsVoiceId: selectedVoiceId || undefined, + onEmotionUpdate: setAvatarEmotion, + avatarId: + selectedAvatar === "female" + ? "maki" + : selectedAvatar === "male" + ? "rento" + : "kouta", + }); + // 最新ユーザー発話でアドバイス達成判定(state反映遅延による未達表示不安定性を解消) + useEffect(() => { + if (!messages.length) return; + const lastUser = [...messages].reverse().find((m) => m.role === "user"); + if (!lastUser) return; + const text = lastUser.content; + const adviceList = BACKGROUND_ADVICE[selectedBackground]; + const next = { ...adviceCompleted }; + let changed = false; + for (const item of adviceList) { + if (next[item.id]) continue; + if (item.patterns.some((re) => re.test(text))) { + next[item.id] = true; + changed = true; + } + } + if (changed) { + setAdviceCompleted(next); + try { + if (session?.id) { + localStorage.setItem( + `adviceCompleted:${session.id}`, + JSON.stringify(next) + ); + } + } catch {} + } + }, [messages, selectedBackground, adviceCompleted, session?.id]); + + // セッション開始時に過去の達成状況があれば復元 + useEffect(() => { + if (!session?.id) return; + try { + const raw = localStorage.getItem(`adviceCompleted:${session.id}`); + if (raw) { + const parsed = JSON.parse(raw) as Record; + setAdviceCompleted(parsed); + } + } catch {} + }, [session?.id]); + + // 背景変更時にアドバイス進捗リセット + useEffect(() => { + setAdviceCompleted({}); + void selectedBackground; + }, [selectedBackground]); + + useEffect(() => { + if (!adviceAvailable) { + setShowAdvicePanel(false); + } + }, [adviceAvailable]); + + // Preload VRM model when selected avatar changes + useEffect(() => { + logMediaRecorderSupport(); + preloadVRM(avatarModelUrl).catch(() => { + // Non-critical, ignore + }); + // フィードバック画面でも使えるようにモデルURLを永続化 + try { + localStorage.setItem("selectedAvatarModelUrl", avatarModelUrl); + } catch { + // ignore + } + }, [avatarModelUrl]); + + // Video ready handler + const handleVideoReady = useCallback( + (videoElement: HTMLVideoElement) => { + console.log("ビデオ準備完了、表情分析を開始します"); + startAnalysis(videoElement, { x: 0.25, y: 0.5 }); + }, + [startAnalysis] + ); + + // End conversation handler + async function handleEndConversation() { + if (isRecording) { + stopRecording(); + } + + stopAnalysis(); + + // Save gesture metrics + if (session?.id) { + const gestureMetrics = getGestureMetrics(); + if (gestureMetrics) { + try { + await gestureApi.saveMetrics(session.id, gestureMetrics); + } catch (error) { + console.error("Failed to save gesture metrics:", error); + } + } + } + + await endSession(); + stopStream(); + + // Redirect to feedback page + if (session?.id) { + window.location.href = `/feedback?sessionId=${session.id}`; + } else { + window.location.href = "/feedback"; + } + } + + // Start conversation handler + const handleStartConversation = useCallback(async () => { + try { + resetGestures(); + + await startStream({ + video: { + width: { ideal: 640 }, + height: { ideal: 480 }, + frameRate: { ideal: 24 }, + facingMode: "user", + }, + audio: { + echoCancellation: true, + noiseSuppression: true, + autoGainControl: true, + }, + }); + + await startSession(); + setConversationStarted(true); + } catch (error) { + console.error("Failed to start conversation:", error); + } + }, [startStream, startSession, resetGestures]); + + // Toggle recording + const toggleRecording = useCallback(() => { + if (!stream) return; + + if (!isRecording) { + startRecording(stream); + } else { + stopRecording(); + } + }, [stream, isRecording, startRecording, stopRecording]); + + // Send recorded audio when recording stops + useEffect(() => { + if ( + audioBlobs.length === 0 || + isRecording || + !session || + !analysisResult || + hasSentBatchRef.current + ) + return; + + const sendRecordedAudio = async () => { + hasSentBatchRef.current = true; // guard duplicate sends for this batch + console.log("Sending recorded audio..."); + const audioBlob = new Blob(audioBlobs, { + type: audioBlobs[0]?.type || "audio/webm", + }); + appendVoiceAnalysis(session.id, analysisResult); + await sendAudio(audioBlob, analysisResult); + clearRecording(); + // reset guard for next recording batch after clearing + setTimeout(() => { + hasSentBatchRef.current = false; + }, 0); + }; + + sendRecordedAudio(); + }, [ + audioBlobs, + isRecording, + session, + sendAudio, + clearRecording, + analysisResult, + ]); + + // When a new recording starts, ensure guard is cleared + useEffect(() => { + if (isRecording) { + hasSentBatchRef.current = false; + } + }, [isRecording]); + + // 会話内容に基づくemotion更新は useConversation の onEmotionUpdate から反映 + + // Gesture changes based on recording/processing state + useEffect(() => { + if (isRecording) { + setAvatarGesture("nodding"); + } else if (isProcessing) { + setAvatarGesture("thinking"); + } else if (lipSyncValue > 0.1) { + setAvatarGesture("talking"); + } else { + const gestures: GestureType[] = ["idle", "idle", "idle"]; + const randomGesture = + gestures[Math.floor(Math.random() * gestures.length)]; + setAvatarGesture(randomGesture); + } + }, [isRecording, isProcessing, lipSyncValue]); + + // Toggle video + const toggleVideo = useCallback(() => { + if (stream) { + const videoTrack = stream.getVideoTracks()[0]; + if (videoTrack) { + videoTrack.enabled = !videoTrack.enabled; + setVideoEnabled(videoTrack.enabled); + } + } + }, [stream]); + + return ( +
+ {/* Header is provided by shared Header component in layout */} + + {!conversationStarted ? ( + /* Initial State - Full Screen Welcome */ +
+
+
+
+ +
+

+ 会話シミュレーション +

+

+ {avatarName}と会話の練習をしましょう +

+
+ + {(mediaError || conversationError) && ( + +

+ ⚠️ + エラーが発生しました +

+

+ {mediaError?.message || conversationError?.message} +

+ {mediaError?.message.includes("拒否") && ( +
+

+ 💡 + ブラウザのアドレスバー横のカメラアイコンをクリックして、アクセスを許可してください +

+
+ )} +
+ )} + + +
+

+ Are you ready? +

+

+ カメラとマイクへのアクセスを許可して +
+ 会話を始めよう! +

+
+ {/* Avatar Selection (Image Buttons) */} +
+ + + + + +
+ {/* Background Selection (Image Buttons) */} +
+

+ 背景を選択 +

+
+ {(Object.keys(BACKGROUNDS) as Array).map( + (key) => { + const bg = BACKGROUNDS[key]; + const selected = selectedBackground === key; + return ( + + ); + } + )} +
+ {/* Situation Hint */} +
+

+ シチュエーション +

+

{BACKGROUNDS[selectedBackground].scenario}

+
+ {/* Assist Mode Toggle (下段表示) */} +
+

+ モード選択 +

+

+ アシストモードでは会話中に高得点のコツ(アドバイス)を表示します。 +

+
+ +
+
+
+ +
+
+
+ ) : ( + /* Conversation State - Split Screen Layout */ +
+
+ {/* Background image + overlay */} +
+ {`${BACKGROUNDS[selectedBackground].label}の背景`} +
+
+
+ {/* AI Avatar */} +
+ + {adviceAvailable && ( +
+
+ {showAdvicePanel && ( + + )} + +
+
+ )} +
+ + {/* User Video */} + +
+ + {/* Recording Status Indicator */} + + + {/* Error Messages */} + + + {/* Conversation History Panel */} + +
+ + {/* Control Panel */} + setShowControls((prev) => !prev)} + /> +
+ )} +
+ ); }