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 */}
-
-
-
-
-
-
-
-
- );
+ return (
+
+
+ {/* Preload VRM asset to shorten first render time */}
+
+
+
+
+
+
+
+
+ );
}
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 */}
-
-
- {/* 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 */}
+
+
+ {/* AI Avatar */}
+
+
+ {adviceAvailable && (
+
+
+ {showAdvicePanel && (
+
+ )}
+
+
+
+ )}
+
+
+ {/* User Video */}
+
+
+
+ {/* Recording Status Indicator */}
+
+
+ {/* Error Messages */}
+
+
+ {/* Conversation History Panel */}
+
+
+
+ {/* Control Panel */}
+ setShowControls((prev) => !prev)}
+ />
+
+ )}
+
+ );
}