commit 2e10db4c6ef9b0d32eaf356da22118a3285e0b4d
parent 0189e9951b9408c97d4a48dd782122fd7c0b7650
Author: minerva-jupiter <ryouturn@gmail.com>
Date: Sun, 26 Oct 2025 15:54:23 +0900
refactor(audio): Decouple audio playback from global sequencer
Diffstat:
5 files changed, 657 insertions(+), 693 deletions(-)
diff --git a/app/components/1_chat.tsx b/app/components/1_chat.tsx
@@ -3,266 +3,355 @@
import React, { useState, useRef, useEffect, KeyboardEvent, useCallback } from 'react';
import { StageProps } from '../ctrl/page.tsx';
import init, { chat } from '../../rust-wasm/pkg/rust_wasm.js';
-import useAudioSequencer from './useAudioSequencer.ts';
-
-const FirstChat: React.FC<StageProps> = ({ onComplete }) => {
- return(
- <ChatPage onComplete={onComplete}/>
- )
-};
-
-export default FirstChat;
-
-const BGM_MAP: {[key:number]:string } = {
- 1: '/audio/001.wav',
- 2: '/audio/002.wav',
- 3: '/audio/003.wav',
-}
-
-const getBgmUrl = (times: number): string | null => {
- const stages = Object.keys(BGM_MAP).map(Number).sort((a,b) => b - a);
- for (const stage of stages) {
- if (times >= stage) {
- return BGM_MAP[stage];
- }
- }
- return null;
-}
-
-interface Message {
- id: number;
- text: string;
- sender: 'user' | 'ai';
-}
-
-const initialMessages: Message[] = [
- { id: 1, text: 'そろそろどうするか決めないとだよ?', sender: 'ai' },
- { id: 2, text: 'はやく話して。', sender:'ai'},
-];
-
-const MessageBubble: React.FC<{ message: Message }> = ({ message }) => {
- const isUser = message.sender === 'user';
-
- const bubbleStyle: React.CSSProperties = {
- padding: '10px 15px',
- borderRadius: '15px',
- maxWidth: '70%',
- wordBreak: 'break-word',
- fontSize: '16px',
- backgroundColor: isUser ? '#3b82f6' : '#e5e7eb', // blue-500 or gray-200
- color: isUser ? 'white' : '#1f2937', // white or gray-800
- marginLeft: isUser ? 'auto' : '0',
- marginRight: isUser ? '0' : 'auto',
- };
- const containerStyle: React.CSSProperties = {
- display: 'flex',
- marginBottom: '10px',
- justifyContent: isUser ? 'flex-end' : 'flex-start',
+// useWebAudioControllerの代替となるカスタムフックを定義
+const useAudioPlayback = (
+ initialTalktime: number,
+ audioSources: Record<number, string>,
+ onAudioEnd: () => void,
+) => {
+ // Audioオブジェクトの参照を保持
+ const audioRef = useRef<HTMLAudioElement | null>(null);
+ // 現在のトークタイムを保持し、変更を監視するためのState
+ const talktimesRef = useRef(initialTalktime);
+ const [currentAudioUrl, setCurrentAudioUrl] = useState(audioSources[initialTalktime] || '');
+
+ // 外部から再生を停止するための関数
+ const stop = useCallback(() => {
+ if (audioRef.current) {
+ audioRef.current.pause();
+ audioRef.current.currentTime = 0; // 最初に戻す
+ }
+ }, []);
+
+ // 外部からループ設定を変更するための関数(AudioRefのcurrentが更新されるたびに適用される)
+ const setLoop = useCallback((isLooping: boolean) => {
+ // audioRef.currentが存在する場合にのみ設定を試みる
+ if (audioRef.current) {
+ audioRef.current.loop = isLooping;
+ }
+ }, []); // 依存配列は空でOK
+
+ // 1. Audioオブジェクトの初期化、クリーンアップ、および終了イベント処理
+ useEffect(() => {
+ // 古い音源を停止
+ stop();
+
+ const audio = new Audio(currentAudioUrl);
+ audioRef.current = audio;
+ audio.volume = 0.5; // 必要に応じて音量を設定
+
+ // 常に最新のtalktimesRef.currentに基づいてループ設定
+ const isLooping = talktimesRef.current < 4;
+ audio.loop = isLooping;
+ setLoop(isLooping); // 念のため
+
+ // 自動再生の試行 (ユーザーのインタラクションが必要なため、失敗する可能性あり)
+ audio.play().catch(e => {
+ console.error("Audio playback error on URL change/init:", e);
+ // ユーザーのインタラクションがない場合は再生できないため、ここではエラーを無視するか、ユーザーに操作を促す
+ });
+
+
+ const handleEnded = () => {
+ const currentTalktime = talktimesRef.current;
+
+ // 終了条件を満たしている場合は停止し、onAudioEndを実行
+ if (currentTalktime >= 4) {
+ console.log("最終音源の再生が終了しました。onAudioEndを実行します。");
+ stop();
+ onAudioEnd();
+ return;
+ }
+
+ // ループがtrueの場合はonendedは呼ばれないはずだが、フォールバックとして再再生を試みる
+ if (!audio.loop) {
+ console.log(`音源 ${currentTalktime} の再生が終了しました。ループ再生を再開します。`);
+ audio.play().catch(e => console.error("Audio playback error on loop restart:", e));
+ }
+ };
+
+ audio.addEventListener('ended', handleEnded);
+
+ // コンポーネントがアンマウントされる際のクリーンアップ
+ return () => {
+ audio.removeEventListener('ended', handleEnded);
+ audio.pause();
+ // audioRef.current = null; // Audioオブジェクトが再生成されるため、ここではnullにしない
+ };
+ }, [currentAudioUrl, onAudioEnd, stop, setLoop]); // currentAudioUrlが変わるとAudioオブジェクトが再生成される
+
+ // 2. talktimesRef.currentの変更を監視し、音源の切り替えを行う
+ useEffect(() => {
+ const currentTalktime = talktimesRef.current;
+ const newAudioUrl = audioSources[currentTalktime];
+
+ console.log("talktimesRef is ", currentTalktime);
+
+ if (newAudioUrl && newAudioUrl !== currentAudioUrl) {
+ // URLが変わったら、Audioオブジェクトを再生成するためにstateを更新
+ // 新しいcurrentAudioUrlで上のuseEffectがトリガーされる
+ setCurrentAudioUrl(newAudioUrl);
+
+ // 新しい音源に対するループ設定を即座に更新
+ const isLooping = currentTalktime < 4;
+ setLoop(isLooping);
+ } else if (currentTalktime === initialTalktime && audioRef.current) {
+ // 初回ロード時のみ、初期音源を再生(ブラウザの制限のため、ユーザー操作後の初回にのみ有効)
+ audioRef.current.play().catch(e => console.error("Initial audio playback error:", e));
+ }
+
+ }, [talktimesRef.current]);
+
+ // 外部からの更新用にtalktimesRefと制御関数を返す
+ return { talktimesRef, stop, setLoop, currentAudioUrl };
};
- return (
- <div style={containerStyle}>
- <div style={bubbleStyle}>
- {message.text}
- </div>
- </div>
- );
-};
-
-const MessageInput: React.FC<{ onSend: (text: string) => void }> = ({ onSend }) => {
- const [input, setInput] = useState('');
-
- const handleSend = () => {
- if (input.trim() === '') return;
- onSend(input);
- setInput('');
+ const FirstChat: React.FC<StageProps> = ({ onComplete }) => {
+ return(
+ <ChatPage onComplete={onComplete}/>
+ )
};
- const handleKeyPress = (e: KeyboardEvent<HTMLInputElement>) => {
- if (e.key === 'Enter') {
- e.preventDefault();
- handleSend();
- }
- };
+ export default FirstChat;
- const inputStyle: React.CSSProperties = {
- flexGrow: 1,
- padding: '12px',
- border: '1px solid #d1d5db', // gray-300
- borderRadius: '8px',
- marginRight: '10px',
- fontSize: '16px',
- outline: 'none',
+ const AUDIO_SOURCES: Record<number, string> = {
+ 1: '/audio/001.wav',
+ 2: '/audio/002.wav',
+ 3: '/audio/003.wav',
+ 4: '/audio/004.wav',
+ // 5以降は終了条件を満たすため、再生する音源は設定不要
};
- const buttonStyle: React.CSSProperties = {
- backgroundColor: '#3b82f6', // blue-500
- color: 'white',
- border: 'none',
- padding: '12px 20px',
- borderRadius: '8px',
- cursor: 'pointer',
- fontWeight: 'bold',
- };
+ interface Message {
+ id: number;
+ text: string;
+ sender: 'user' | 'ai';
+ }
+ const initialMessages: Message[] = [
+ { id: 1, text: 'そろそろどうするか決めないとだよ?', sender: 'ai' },
+ { id: 2, text: 'はやく話して。', sender:'ai'},
+ ];
+
+ const MessageBubble: React.FC<{ message: Message }> = ({ message }) => {
+ const isUser = message.sender === 'user';
+
+ const bubbleStyle: React.CSSProperties = {
+ padding: '10px 15px',
+ borderRadius: '15px',
+ maxWidth: '70%',
+ wordBreak: 'break-word',
+ fontSize: '16px',
+ backgroundColor: isUser ? '#3b82f6' : '#e5e7eb', // blue-500 or gray-200
+ color: isUser ? 'white' : '#1f2937', // white or gray-800
+ marginLeft: isUser ? 'auto' : '0',
+ marginRight: isUser ? '0' : 'auto',
+ };
- return (
- <div style={{ padding: '15px', backgroundColor: '#f9fafb', display: 'flex', alignItems: 'center' }}>
- <input
- type="text"
- style={inputStyle}
- placeholder="メッセージを入力してください..."
- value={input}
- onChange={(e) => setInput(e.target.value)}
- onKeyDown={handleKeyPress}
- />
- <button
- type='submit'
- style={buttonStyle}
- onClick={handleSend}
- disabled={input.trim() === ''}
- >
- 送信
- </button>
- </div>
- );
-};
-
-
-
-function ChatPage({onComplete}:StageProps) {
- const [messages, setMessages] = useState<Message[]>(initialMessages);
- const [ dict, setDict ] = useState<Uint8Array|undefined>(undefined);
- const messagesEndRef = useRef<HTMLDivElement>(null);
- const dictPath = '/system.dic.zst';
-
- // for audio
- const talktimesRef = useRef(1);
- const [currentTalktimes, setCurrentTalktimes] = useState(1);
- const currentBgmUrl = getBgmUrl(currentTalktimes);
- const { isPlaying, isSwitching, endLoopAndAwaitCompletion } = useAudioSequencer(currentBgmUrl);
-
- useEffect(() => {
- messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
- }, [messages]);
-
- useEffect(() => {
- const loadData = async () => {
- const loadedDict = await LoadDict(dictPath);
- setDict(loadedDict);
- }
- loadData();
- }, []);
+ const containerStyle: React.CSSProperties = {
+ display: 'flex',
+ marginBottom: '10px',
+ justifyContent: isUser ? 'flex-end' : 'flex-start',
+ };
- useEffect(() => {
- init();
- }, []);
+ return (
+ <div style={containerStyle}>
+ <div style={bubbleStyle}>
+ {message.text}
+ </div>
+ </div>
+ );
+ };
- const handleSendMessage = useCallback(async (text: string) => {
- if (text.trim() === '') return;
+ const MessageInput: React.FC<{ onSend: (text: string) => void }> = ({ onSend }) => {
+ const [input, setInput] = useState('');
- const newUserMessage: Message = {
- id: Date.now(),
- text,
- sender: 'user',
+ const handleSend = () => {
+ if (input.trim() === '') return;
+ onSend(input);
+ setInput('');
};
- setMessages((prev) => [...prev, newUserMessage]);
- const ans = dict == undefined ? 'もっとまともなことを言いなさい。' : chat(dict, text);
- const aiResponse: Message = {
- id: Date.now() + 1,
- text: ans,
- sender: 'ai',
+
+ const handleKeyPress = (e: KeyboardEvent<HTMLInputElement>) => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ handleSend();
+ }
};
- setMessages((prev) => [...prev, aiResponse]);
- talktimesRef.current += 1;
+ const inputStyle: React.CSSProperties = {
+ flexGrow: 1,
+ padding: '12px',
+ border: '1px solid #d1d5db', // gray-300
+ borderRadius: '8px',
+ marginRight: '10px',
+ fontSize: '16px',
+ outline: 'none',
+ };
- // termination condition
- if(talktimesRef.current >= 4){
- await endLoopAndAwaitCompletion();
- onComplete();
- return;
- }
- setCurrentTalktimes(talktimesRef.current);
-
- }, [dict, onComplete, endLoopAndAwaitCompletion]);
-
- const pageContainerStyle: React.CSSProperties = {
- display: 'flex',
- flexDirection: 'column',
- height: '100vh', // 全画面の高さ
- maxWidth: '800px', // 最大幅を制限して中央に寄せる
- margin: '0 auto',
- backgroundColor: 'white',
- boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1)',
- };
+ const buttonStyle: React.CSSProperties = {
+ backgroundColor: '#3b82f6', // blue-500
+ color: 'white',
+ border: 'none',
+ padding: '12px 20px',
+ borderRadius: '8px',
+ cursor: 'pointer',
+ fontWeight: 'bold',
+ };
- const headerStyle: React.CSSProperties = {
- padding: '15px',
- backgroundColor: '#3b82f6', // blue-500
- color: 'white',
- textAlign: 'center',
- fontWeight: 'bold',
- fontSize: '20px',
- };
- const messageListStyle: React.CSSProperties = {
- flexGrow: 1, // 残りのスペースをすべて占める
- padding: '15px',
- overflowY: 'auto', // スクロール可能にする
+ return (
+ <div style={{ padding: '15px', backgroundColor: '#f9fafb', display: 'flex', alignItems: 'center' }}>
+ <input
+ type="text"
+ style={inputStyle}
+ placeholder="メッセージを入力してください..."
+ value={input}
+ onChange={(e) => setInput(e.target.value)}
+ onKeyDown={handleKeyPress}
+ />
+ <button
+ type='submit'
+ style={buttonStyle}
+ onClick={handleSend}
+ disabled={input.trim() === ''}
+ >
+ 送信
+ </button>
+ </div>
+ );
};
- // for audio
-
- useEffect(() => {
- messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
- }, [messages]);
+ function ChatPage({onComplete}:StageProps) {
+ const [messages, setMessages] = useState<Message[]>(initialMessages);
+ const [ dict, setDict ] = useState<Uint8Array|undefined>(undefined);
+ const messagesEndRef = useRef<HTMLDivElement>(null);
+ const dictPath = '/system.dic.zst';
+
+ // for audio
+ const { talktimesRef } = useAudioPlayback(
+ 1, // initialTalktime
+ AUDIO_SOURCES,
+ onComplete
+ );
+
+ useEffect(() => {
+ messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
+ }, [messages]);
+
+ useEffect(() => {
+ const loadData = async () => {
+ const loadedDict = await LoadDict(dictPath);
+ setDict(loadedDict);
+ }
+ loadData();
+ }, []);
+
+ useEffect(() => {
+ init();
+ }, []);
+
+ const handleSendMessage = useCallback(async (text: string) => {
+ if (text.trim() === '') return;
+
+ const newUserMessage: Message = {
+ id: Date.now(),
+ text,
+ sender: 'user',
+ };
+ setMessages((prev) => [...prev, newUserMessage]);
+ const ans = dict == undefined ? 'もっとまともなことを言いなさい。' : chat(dict, text);
+ const aiResponse: Message = {
+ id: Date.now() + 1,
+ text: ans,
+ sender: 'ai',
+ };
+ setMessages((prev) => [...prev, aiResponse]);
+
+ talktimesRef.current += 1;
+
+ // termination condition
+ if(talktimesRef.current >= 4){
+ return;
+ }
+
+ }, [dict, talktimesRef ]);
+
+ const pageContainerStyle: React.CSSProperties = {
+ display: 'flex',
+ flexDirection: 'column',
+ height: '100vh', // 全画面の高さ
+ maxWidth: '800px', // 最大幅を制限して中央に寄せる
+ margin: '0 auto',
+ backgroundColor: 'white',
+ boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1)',
+ };
- useEffect(() => {
- const loadData = async () => {
- const loadedDict = await LoadDict(dictPath);
- setDict(loadedDict);
- }
- loadData();
- }, []);
-
- useEffect(() => {
- init();
- setCurrentTalktimes(1);
- }, []);
-
- // function ChatPage's return
-
- return (
- <div style={pageContainerStyle}>
- <div style={headerStyle}>
- チャット
- </div>
-
- <div style={messageListStyle}>
- {messages.map((msg) => (
- <MessageBubble key={msg.id} message={msg} />
- ))}
- <div ref={messagesEndRef} />
- </div>
-
- <MessageInput onSend={handleSendMessage} />
- </div>
- );
-}
-
-async function LoadDict(dictPath: string): Promise<Uint8Array> {
- try{
- const response = await fetch(dictPath);
- if(!response.ok){
- throw new Error(`fail to fetch file: ${response.statusText}`);
+ const headerStyle: React.CSSProperties = {
+ padding: '15px',
+ backgroundColor: '#3b82f6', // blue-500
+ color: 'white',
+ textAlign: 'center',
+ fontWeight: 'bold',
+ fontSize: '20px',
+ };
+
+ const messageListStyle: React.CSSProperties = {
+ flexGrow: 1, // 残りのスペースをすべて占める
+ padding: '15px',
+ overflowY: 'auto', // スクロール可能にする
+ };
+
+ // for audio
+
+ useEffect(() => {
+ messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
+ }, [messages]);
+
+ useEffect(() => {
+ const loadData = async () => {
+ const loadedDict = await LoadDict(dictPath);
+ setDict(loadedDict);
+ }
+ loadData();
+ }, []);
+
+ useEffect(() => {
+ init();
+ }, []);
+
+ // function ChatPage's return
+
+ return (
+ <div style={pageContainerStyle}>
+ <div style={headerStyle}>
+ チャット
+ </div>
+
+ <div style={messageListStyle}>
+ {messages.map((msg) => (
+ <MessageBubble key={msg.id} message={msg} />
+ ))}
+ <div ref={messagesEndRef} />
+ </div>
+
+ <MessageInput onSend={handleSendMessage} />
+ </div>
+ );
+ }
+
+ async function LoadDict(dictPath: string): Promise<Uint8Array> {
+ try{
+ const response = await fetch(dictPath);
+ if(!response.ok){
+ throw new Error(`fail to fetch file: ${response.statusText}`);
+ }
+ const arrayBuffer = await response.arrayBuffer();
+ const byte = new Uint8Array(arrayBuffer);
+ return byte;
+ } catch (error) {
+ console.log("error occur",error);
}
- const arrayBuffer = await response.arrayBuffer();
- const byte = new Uint8Array(arrayBuffer);
- return byte;
- } catch (error) {
- console.log("error occur",error);
+ return new Uint8Array(0);
}
- return new Uint8Array(0);
-}
diff --git a/app/components/2_draw.tsx b/app/components/2_draw.tsx
@@ -3,14 +3,13 @@
import React, { useState, useRef, useCallback, useEffect } from 'react';
import init, { find_nearest_point_on_path, NearestPointResult } from '../../rust-wasm/pkg/rust_wasm.js';
import { StageProps } from '../ctrl/page.tsx';
-import useAudioSequencer from './useAudioSequencer.ts';
type Tool = 'pen';
type Point = { x: number; y: number; };
interface LineData {
- id: number;
- tool: Tool;
+ id: number;
+ tool: Tool;
points: Point[];
targetPoints: Point[];
}
@@ -19,45 +18,26 @@ const BACKGROUND_SVG_PATH_D_DEFAULT = "";
const VIEWSBOX_SIZE = 500;
const SNAPPING_DISTANCE_PIXELS = 30;
-const BGM_MAP: {[key:number]:string } = {
- 1: '/audio/001.wav',
- 2: '/audio/002.wav',
- 3: '/audio/003.wav',
-}
-
-const getBgmUrl = (times: number): string | null => {
- const stages = Object.keys(BGM_MAP).map(Number).sort((a,b) => b - a);
- for (const stage of stages) {
- if (times >= stage) {
- return BGM_MAP[stage];
- }
- }
- return null;
-}
+const AUDIO_SOURCE = '/audio/001.wav';
-function DrawingApp({onComplete}: StageProps) {
+export default function DrawingApp({onComplete}: StageProps) {
const [isClient, setIsClient] = useState(false);
- const lineIdCounter = useRef(0);
+ const lineIdCounter = useRef(0);
const [lines, setLines] = useState<LineData[]>([]);
const [currentLines, setCurrentLines] = useState<LineData[]>([]);
- const [stageWidth, setStageWidth] = useState(0);
- const [stageHeight, setStageHeight] = useState(0);
- const [backgroundPathD, setBackgroundPathD] = useState(BACKGROUND_SVG_PATH_D_DEFAULT);
- const [viewBoxSize, setViewBoxSize] = useState(VIEWSBOX_SIZE);
-
+ const [stageWidth, setStageWidth] = useState(0);
+ const [stageHeight, setStageHeight] = useState(0);
+ const [backgroundPathD, setBackgroundPathD] = useState(BACKGROUND_SVG_PATH_D_DEFAULT);
+ const [viewBoxSize, setViewBoxSize] = useState(VIEWSBOX_SIZE);
+
const isDrawing = useRef(false);
-
+
const animationRef = useRef<number>(0);
const startTimeRef = useRef<number|undefined>(undefined);
const stageRef = useRef<HTMLDivElement>(null);
- // for audio
- const currentTalktimes = useState<number>(1);
- const currentBgmUrl = getBgmUrl(1);
- const { isPlaying, isSwitching, endLoopAndAwaitCompletion } = useAudioSequencer(currentBgmUrl);
-
useEffect(() => {
init();
},[])
@@ -65,7 +45,7 @@ function DrawingApp({onComplete}: StageProps) {
// --- 1. サイズ計算とステージ設定 (画面全体を使用) ---
useEffect(() => {
setIsClient(true);
-
+
const handleResize = () => {
if (typeof globalThis !== 'undefined') {
setStageWidth(globalThis.innerWidth);
@@ -80,153 +60,153 @@ function DrawingApp({onComplete}: StageProps) {
globalThis.removeEventListener('resize', handleResize);
};
}, []);
-
- // --- 2. 座標変換ユーティリティ ---
-
- const scaleToViewBox = useCallback((p: Point): Point => {
- if (stageWidth === 0 || stageHeight === 0) return p;
-
- const effectiveSize = Math.min(stageWidth, stageHeight);
- const scale = viewBoxSize / effectiveSize;
-
- const offsetX = (stageWidth - effectiveSize) / 2;
- const offsetY = (stageHeight - effectiveSize) / 2;
-
- return {
- x: (p.x - offsetX) * scale,
- y: (p.y - offsetY) * scale,
- };
- }, [stageWidth, stageHeight, viewBoxSize]);
-
- const scaleToScreen = useCallback((p: Point): Point => {
- if (stageWidth === 0 || stageHeight === 0) return p;
-
- const effectiveSize = Math.min(stageWidth, stageHeight);
- const scale = effectiveSize / viewBoxSize;
-
- const offsetX = (stageWidth - effectiveSize) / 2;
- const offsetY = (stageHeight - effectiveSize) / 2;
-
- return {
- x: p.x * scale + offsetX,
- y: p.y * scale + offsetY,
- };
- }, [stageWidth, stageHeight, viewBoxSize]);
-
- const getPointerPosition = useCallback((e: React.MouseEvent | React.TouchEvent | MouseEvent | TouchEvent): Point | null => {
+
+ // --- 2. 座標変換ユーティリティ ---
+
+ const scaleToViewBox = useCallback((p: Point): Point => {
+ if (stageWidth === 0 || stageHeight === 0) return p;
+
+ const effectiveSize = Math.min(stageWidth, stageHeight);
+ const scale = viewBoxSize / effectiveSize;
+
+ const offsetX = (stageWidth - effectiveSize) / 2;
+ const offsetY = (stageHeight - effectiveSize) / 2;
+
+ return {
+ x: (p.x - offsetX) * scale,
+ y: (p.y - offsetY) * scale,
+ };
+ }, [stageWidth, stageHeight, viewBoxSize]);
+
+ const scaleToScreen = useCallback((p: Point): Point => {
+ if (stageWidth === 0 || stageHeight === 0) return p;
+
+ const effectiveSize = Math.min(stageWidth, stageHeight);
+ const scale = effectiveSize / viewBoxSize;
+
+ const offsetX = (stageWidth - effectiveSize) / 2;
+ const offsetY = (stageHeight - effectiveSize) / 2;
+
+ return {
+ x: p.x * scale + offsetX,
+ y: p.y * scale + offsetY,
+ };
+ }, [stageWidth, stageHeight, viewBoxSize]);
+
+ const getPointerPosition = useCallback((e: React.MouseEvent | React.TouchEvent | MouseEvent | TouchEvent): Point | null => {
if (!stageRef.current) return null;
const rect = stageRef.current.getBoundingClientRect();
-
- let clientX: number, clientY: number;
- if ('touches' in e) {
- if (e.touches.length === 0) return null;
- clientX = e.touches[0].clientX;
- clientY = e.touches[0].clientY;
- } else {
- clientX = e.clientX;
- clientY = e.clientY;
- }
+
+ let clientX: number, clientY: number;
+ if ('touches' in e) {
+ if (e.touches.length === 0) return null;
+ clientX = e.touches[0].clientX;
+ clientY = e.touches[0].clientY;
+ } else {
+ clientX = e.clientX;
+ clientY = e.clientY;
+ }
return { x: clientX - rect.left, y: clientY - rect.top };
}, []);
-
- // --- 3. SVGコンテンツの取得ロジック (DOMParser) ---
- useEffect(() => {
- const fetchAndParseSvg = async () => {
- try {
- const response = await fetch('/whiteperson.svg');
- const svgText = await response.text();
-
- const parser = new DOMParser();
- const doc = parser.parseFromString(svgText, "image/svg+xml");
-
- const svgElement = doc.querySelector('svg');
- if (svgElement) {
- const viewBoxAttr = svgElement.getAttribute('viewBox');
- if (viewBoxAttr) {
- const parts = viewBoxAttr.trim().split(/\s+/);
- if (parts.length === 4) {
- const size = Math.max(parseFloat(parts[2]), parseFloat(parts[3]));
- if (!isNaN(size) && size > 0) {
- setViewBoxSize(size);
- }
- }
- }
- }
-
- const pathElements = doc.querySelectorAll('path');
- let foundDValue = null;
-
- for (const element of Array.from(pathElements)) {
- const dValue = element.getAttribute('d');
- if (dValue && dValue.trim().length > 0) {
- foundDValue = dValue;
- break;
- }
- }
-
- if (foundDValue) {
- setBackgroundPathD(foundDValue);
- return;
- }
-
- console.error("Error: <path> element or 'd' attribute not found in whiteperson.svg.");
-
- } catch (error) {
- console.error("Failed to load or parse whiteperson.svg:", error);
- }
- };
-
- if (isClient && backgroundPathD === "") {
- fetchAndParseSvg();
- }
- }, [isClient, backgroundPathD]);
-
-
- // --- 4. 吸着ロジック (WASM呼び出し) ---
- const calculateNearestTargetPoint = useCallback((p: Point): Point => {
- const p_viewbox = scaleToViewBox(p);
-
- const effectiveSize = Math.min(stageWidth, stageHeight);
- const snapping_distance_viewbox = SNAPPING_DISTANCE_PIXELS * (viewBoxSize / effectiveSize);
-
- // ⚠️ WASM呼び出し
- const nearest_viewbox: NearestPointResult | undefined = find_nearest_point_on_path(
- backgroundPathD,
- p_viewbox.x,
- p_viewbox.y,
- snapping_distance_viewbox
- );
+
+ // --- 3. SVGコンテンツの取得ロジック (DOMParser) ---
+ useEffect(() => {
+ const fetchAndParseSvg = async () => {
+ try {
+ const response = await fetch('/whiteperson.svg');
+ const svgText = await response.text();
+
+ const parser = new DOMParser();
+ const doc = parser.parseFromString(svgText, "image/svg+xml");
+
+ const svgElement = doc.querySelector('svg');
+ if (svgElement) {
+ const viewBoxAttr = svgElement.getAttribute('viewBox');
+ if (viewBoxAttr) {
+ const parts = viewBoxAttr.trim().split(/\s+/);
+ if (parts.length === 4) {
+ const size = Math.max(parseFloat(parts[2]), parseFloat(parts[3]));
+ if (!isNaN(size) && size > 0) {
+ setViewBoxSize(size);
+ }
+ }
+ }
+ }
+
+ const pathElements = doc.querySelectorAll('path');
+ let foundDValue = null;
+
+ for (const element of Array.from(pathElements)) {
+ const dValue = element.getAttribute('d');
+ if (dValue && dValue.trim().length > 0) {
+ foundDValue = dValue;
+ break;
+ }
+ }
+
+ if (foundDValue) {
+ setBackgroundPathD(foundDValue);
+ return;
+ }
+
+ console.error("Error: <path> element or 'd' attribute not found in whiteperson.svg.");
+
+ } catch (error) {
+ console.error("Failed to load or parse whiteperson.svg:", error);
+ }
+ };
+
+ if (isClient && backgroundPathD === "") {
+ fetchAndParseSvg();
+ }
+ }, [isClient, backgroundPathD]);
+
+
+ // --- 4. 吸着ロジック (WASM呼び出し) ---
+ const calculateNearestTargetPoint = useCallback((p: Point): Point => {
+ const p_viewbox = scaleToViewBox(p);
+
+ const effectiveSize = Math.min(stageWidth, stageHeight);
+ const snapping_distance_viewbox = SNAPPING_DISTANCE_PIXELS * (viewBoxSize / effectiveSize);
+
+ // ⚠️ WASM呼び出し
+ const nearest_viewbox: NearestPointResult | undefined = find_nearest_point_on_path(
+ backgroundPathD,
+ p_viewbox.x,
+ p_viewbox.y,
+ snapping_distance_viewbox
+ );
console.log("nearest_viewbox is ",nearest_viewbox);
- if (nearest_viewbox) {
- return scaleToScreen(nearest_viewbox);
- }
- return p;
- }, [scaleToViewBox, scaleToScreen, stageWidth, stageHeight, viewBoxSize, backgroundPathD]);
+ if (nearest_viewbox) {
+ return scaleToScreen(nearest_viewbox);
+ }
+ return p;
+ }, [scaleToViewBox, scaleToScreen, stageWidth, stageHeight, viewBoxSize, backgroundPathD]);
const calculateTargetPoints = useCallback((points: Point[]): Point[] => {
- return points.map(p => calculateNearestTargetPoint(p));
+ return points.map(p => calculateNearestTargetPoint(p));
}, [calculateNearestTargetPoint]);
- // --- 5. イベントハンドラ (吹き飛んでいた部分を再定義) ---
+ // --- 5. イベントハンドラ (吹き飛んでいた部分を再定義) ---
- /**
+ /**
* マウス・タッチ開始時の処理
*/
- const handleMouseDown = useCallback((e: React.MouseEvent | React.TouchEvent) => {
+ const handleMouseDown = useCallback((e: React.MouseEvent | React.TouchEvent) => {
isDrawing.current = true;
const pos = getPointerPosition(e);
if (pos) {
- lineIdCounter.current += 1;
- const newLine: LineData = { id: lineIdCounter.current, tool: 'pen', points: [pos], targetPoints: [] };
+ lineIdCounter.current += 1;
+ const newLine: LineData = { id: lineIdCounter.current, tool: 'pen', points: [pos], targetPoints: [] };
setLines(prev => [...prev, newLine]);
- setCurrentLines(prev => [...prev, newLine]);
+ setCurrentLines(prev => [...prev, newLine]);
}
}, [getPointerPosition]);
- /**
+ /**
* マウス・タッチ移動時の処理
*/
const handleMouseMove = useCallback((e: React.MouseEvent | React.TouchEvent) => {
@@ -241,13 +221,13 @@ function DrawingApp({onComplete}: StageProps) {
const newPoints: Point[] = [...lastLine.points, point];
return [...prevLines.slice(0, lastLineIndex), { ...lastLine, points: newPoints }];
};
-
+
setLines(updateLines);
- setCurrentLines(updateLines);
+ setCurrentLines(updateLines);
}, [getPointerPosition]);
- /**
+ /**
* マウス・タッチ終了時の処理
*/
const handleMouseUp = useCallback(() => {
@@ -257,7 +237,7 @@ function DrawingApp({onComplete}: StageProps) {
const lastLineIndex = prevLines.length - 1;
if (lastLineIndex < 0) return prevLines;
const lastLine = prevLines[lastLineIndex];
-
+
const targetPoints = calculateTargetPoints(lastLine.points);
const newLine: LineData = {
@@ -267,7 +247,7 @@ function DrawingApp({onComplete}: StageProps) {
return [...prevLines.slice(0, lastLineIndex), newLine];
});
}, [calculateTargetPoints]);
-
+
// --- 6. アニメーションロジック (変更なし) ---
const animateLines = useCallback((timestamp: number) => {
@@ -280,11 +260,11 @@ function DrawingApp({onComplete}: StageProps) {
if (line.targetPoints.length === 0) return line;
const newPoints: Point[] = line.points.map((originalP, index) => {
- const targetP = line.targetPoints[index];
+ const targetP = line.targetPoints[index];
return {
- x: originalP.x + (targetP.x - originalP.x) * progress,
- y: originalP.y + (targetP.y - originalP.y) * progress,
- };
+ x: originalP.x + (targetP.x - originalP.x) * progress,
+ y: originalP.y + (targetP.y - originalP.y) * progress,
+ };
});
return { ...line, points: newPoints };
@@ -297,10 +277,10 @@ function DrawingApp({onComplete}: StageProps) {
} else {
startTimeRef.current = undefined;
setLines(newCurrentLines.map(line => ({
- ...line,
- points: line.targetPoints,
- targetPoints: []
- })));
+ ...line,
+ points: line.targetPoints,
+ targetPoints: []
+ })));
}
}, [lines]);
@@ -319,24 +299,46 @@ function DrawingApp({onComplete}: StageProps) {
}, [lines, animateLines]);
- const pointsToSvgString = (points: Point[]): string => {
- return points.map(p => `${p.x},${p.y}`).join(' ');
- };
+ const pointsToSvgString = (points: Point[]): string => {
+ return points.map(p => `${p.x},${p.y}`).join(' ');
+ };
// for audio
+ const audioRef = useRef<HTMLAudioElement | null>(null);
useEffect(() => {
- const handleAudioSequence = async () => {
+ init();
+ },[])
- await endLoopAndAwaitCompletion();
+ useEffect(() => {
+ const audio = new Audio(AUDIO_SOURCE);
+ audioRef.current = audio;
+ const handleAudioEnded = () => {
+ console.log("Audio playback finished. Calling onComplete.");
onComplete();
};
- handleAudioSequence();
- return
+ audio.addEventListener('ended', handleAudioEnded);
- }, [currentBgmUrl, onComplete, endLoopAndAwaitCompletion]);
+ // ユーザーインタラクションの直後に再生を開始
+ // コンポーネントがロードされただけではブラウザの制限で再生できないため、
+ // ユーザーの最初の描画操作をトリガーとして再生を開始するのがより安全ですが、
+ // 今回はシンプルにロード時に再生を試みます。
+ const playAudio = () => {
+ audio.play().catch(e => console.log("Audio playback failed (may require user interaction):", e));
+ };
+
+ // ロード完了を待って再生を試みる
+ audio.oncanplaythrough = playAudio;
+
+ // アンマウント時のクリーンアップ
+ return () => {
+ audio.pause();
+ audio.removeEventListener('ended', handleAudioEnded);
+ audioRef.current = null;
+ };
+ }, [onComplete]);
return (
<div
@@ -405,8 +407,3 @@ function DrawingApp({onComplete}: StageProps) {
);
}
-const SecondDraw: React.FC<StageProps> = (props) => {
- return <DrawingApp {...props} />;
-};
-
-export default SecondDraw;
diff --git a/app/components/3_vr.tsx b/app/components/3_vr.tsx
@@ -2,13 +2,134 @@
import { Canvas, useFrame } from '@react-three/fiber';
import { OrbitControls, Sky, ContactShadows, Cloud, Clouds } from '@react-three/drei';
-import BenchScene from '../components/BenchScene';
-import { useState, useMemo } from 'react';
+import BenchScene from './BenchScene.tsx';
+import { useState, useMemo, useRef, useEffect } from 'react';
import * as THREE from 'three';
+import { StageProps } from '../ctrl/page.tsx';
+
+// --- オーディオ関連の定数とカスタムフック ---
+const AUDIO_SOURCES: Record<number, string> = {
+ 1: '/audio/001.wav',
+ 2: '/audio/002.wav',
+};
+
+// シーケンシャルな音声再生と状態遷移を制御するカスタムフック (修正版)
+const useSequentialAudio = (initialState: number, audioSources: Record<number, string>, onComplete: () => void) => {
+ const [sceneState, setSceneState] = useState(initialState);
+ const audioRef = useRef<HTMLAudioElement | null>(null);
+
+ // 現在の状態に対応する音源のURLを取得
+ const currentAudioUrl = audioSources[sceneState];
+
+ // 音声再生のロジック
+ useEffect(() => {
+ // --- 1. 終了条件のチェック ---
+ if (sceneState > Math.max(...Object.keys(audioSources).map(Number))) {
+ // 定義された全ての音源が再生された後、onCompleteを実行
+ console.log("All audio finished. Calling onComplete.");
+ onComplete();
+ return;
+ }
+
+ // 現在の状態に対応する音源がない場合、ここで処理を停止
+ if (!currentAudioUrl) {
+ return;
+ }
+
+ console.log(`Starting setup for state ${sceneState}: ${currentAudioUrl}`);
+
+ // --- 2. 古いAudioオブジェクトのクリーンアップ ---
+ const existingAudio = audioRef.current;
+ if (existingAudio) {
+ existingAudio.pause();
+ // イベントリスナーの削除 (次のステップで新しいAudioオブジェクトにリスナーを設定するため)
+ // このクリーンアップが重要です。古いAudioオブジェクトが'ended'イベントをトリガーするのを防ぎます。
+ // `oncanplaythrough`リスナーはここでは不要ですが、もしあれば削除すべきです。
+ // Audioオブジェクトのライフサイクルを明確にするため、毎回新しいインスタンスを生成します。
+ }
+
+ // --- 3. 新しいAudioオブジェクトの作成と設定 ---
+ const audio = new Audio(currentAudioUrl);
+ audioRef.current = audio;
+
+ // 再生が終了したときのハンドラを定義
+ const handleAudioEnded = () => {
+ console.log(`Audio ${sceneState} finished. Transitioning state.`);
+ // 次の状態へ遷移 (例: 1 -> 2, 2 -> 3)
+ setSceneState(prev => prev + 1);
+ };
+
+ audio.addEventListener('ended', handleAudioEnded);
+
+ // --- 4. 再生の開始 ---
+ // ロードやイベントを待たずに、すぐに再生を試みる
+ audio.play().catch(e => {
+ console.warn(`Audio playback failed for state ${sceneState} (requires user interaction):`, e);
+ // ユーザーに最初のクリックを促すメッセージなどを表示すると良い
+ });
+
+
+ // --- 5. クリーンアップ関数 ---
+ return () => {
+ // アンマウント/状態遷移時に現在のAudioオブジェクトを確実に停止し、リスナーを削除
+ if (audio === audioRef.current) { // 現在設定したAudioオブジェクトであることを確認
+ audio.pause();
+ audio.removeEventListener('ended', handleAudioEnded);
+ }
+ };
+
+ }, [sceneState, audioSources, onComplete]); // sceneStateとcurrentAudioUrlは基本的に連動するため、sceneStateを依存配列に含める
+
+ return sceneState;
+};
+
+// 雲のランダム生成のための型
+interface RandomCloudsProps {
+ count: number;
+ radius: number;
+ height: number;
+}
+
+
+// 状態に応じてライトと霧を制御するコンポーネント
+function SceneController({ sceneState }: { sceneState: number }) {
+
+ const isFoggy = sceneState === 2;
+ // 状態 1 (晴れ): 強さ 2 / 状態 2 (霧): 強さ 0.3
+ const lightIntensity = isFoggy ? 0.3 : 2;
+
+ // 霧の制御ロジック
+ useFrame(({ scene }) => {
+ if (isFoggy) {
+ // 💡 状態 2: 暗く濃い霧 (色: 灰色、Near 5, Far 50)
+ scene.fog = new THREE.Fog(0x909090, 5, 50);
+ } else {
+ // 状態 1: 霧を削除(今まで通り)
+ scene.fog = null;
+ }
+ });
+
+ return (
+ <>
+ <ambientLight intensity={0.5} />
+ <directionalLight
+ position={[5, 10, 5]}
+ intensity={lightIntensity} // 状態によって明るさを変更
+ castShadow
+ shadow-mapSize-width={2048}
+ shadow-mapSize-height={2048}
+ shadow-camera-far={50}
+ shadow-camera-left={-10}
+ shadow-camera-right={10}
+ shadow-camera-top={10}
+ shadow-camera-bottom={-10}
+ />
+ </>
+ );
+}
function AnimatedSky() {
- // このコンポーネントはそのまま維持し、空のグラデーションと太陽をアニメーションさせます。
const [sunPosition, setSunPosition] = useState<[number, number, number]>([0, 100, 0]);
useFrame(({ clock }) => {
@@ -32,42 +153,27 @@ function AnimatedSky() {
);
}
-// 必要なプロパティ: 雲の数、空の範囲の半径、雲の高さ
-interface RandomCloudsProps {
- count: number;
- radius: number;
- height: number;
-}
function RandomClouds({ count, radius, height }: RandomCloudsProps) {
const cloudConfigs = useMemo(() => {
return Array.from({ length: count }).map((_, i) => {
- // 💡 修正点: 極座標 (角度と距離) を使って円形に均一に分布させる
-
- // 1. 角度 (0 から 2π) をランダムに決定
const angle = Math.random() * Math.PI * 2;
-
- // 2. 距離 (0 から radius) をランダムに決定 (二乗根で遠くへ均一に分布)
- // Math.sqrt() を使うことで、原点付近への集中を避け、円内での分布を均一にします
const distance = Math.sqrt(Math.random()) * radius;
- // 3. 極座標を直交座標 (X, Z) に変換
const x = Math.cos(angle) * distance;
const z = Math.sin(angle) * distance;
return {
- // 新しいランダムなX, Z座標
position: [
x,
height + (Math.random() - 0.5) * 5,
z,
] as [number, number, number],
- // ... (bounds などの他の設定はそのまま)
bounds: [
- Math.random() * 10 + 10,
- Math.random() * 2 + 1,
- Math.random() * 10 + 10,
+ Math.random() * 10 + 10,
+ Math.random() * 2 + 1,
+ Math.random() * 10 + 10,
] as [number, number, number],
seed: Math.floor(Math.random() * 1000) + i,
volume: Math.random() * 20 + 5,
@@ -85,40 +191,32 @@ function RandomClouds({ count, radius, height }: RandomCloudsProps) {
);
}
-export default function ThreeVR() {
+
+export default function ThreeVR({onComplete}:StageProps) {
+ // 💡 状態定義: number型
+ const sceneState = useSequentialAudio(1, AUDIO_SOURCES, onComplete);
+
return (
<Canvas
+ key="r3f-main-canvas"
camera={{ position: [5, 5, 5], fov: 60 }}
shadows
+ // 霧の状態によってクリアカラー(背景色)を変える
+ style={{ backgroundColor: sceneState === 2 ? '#a0aa90' : '#d0e0ff' }}
>
- {/* 太陽と空 */}
<AnimatedSky />
-
- <Clouds material={THREE.MeshLambertMaterial} limit={400}> {/* limitで最大雲数を設定 */}
- <RandomClouds count={100} radius={80} height={15} /> {/* 100個の雲を半径80の範囲、高さ15に生成 */}
- </Clouds>
-
-
- {/* ライトとモデル */}
- <ambientLight intensity={0.5} />
- <directionalLight
- position={[5, 10, 5]}
- intensity={2}
- castShadow
- shadow-mapSize-width={2048}
- shadow-mapSize-height={2048}
- shadow-camera-far={50}
- shadow-camera-left={-10}
- shadow-camera-right={10}
- shadow-camera-top={10}
- shadow-camera-bottom={-10}
- />
-
- <BenchScene />
- <ContactShadows position={[0, -0.5, 0]} opacity={0.7} scale={10} blur={1} far={10} />
- <OrbitControls enableZoom={true} enablePan={true} enableRotate={true} />
-
- </Canvas>
- );
+ <Clouds material={THREE.MeshLambertMaterial} limit={200}>
+ <RandomClouds count={100} radius={80} height={15} />
+ </Clouds>
+
+ {/* 💡 SceneControllerを配置し、状態を渡す */}
+ <SceneController sceneState={sceneState} />
+
+ <BenchScene />
+ <ContactShadows position={[0, -0.5, 0]} opacity={0.7} scale={10} blur={1} far={10} />
+ <OrbitControls enableZoom={true} enablePan={true} enableRotate={true} />
+
+ </Canvas>
+ );
}
diff --git a/app/components/useAudioSequencer.ts b/app/components/useAudioSequencer.ts
@@ -1,212 +0,0 @@
-import { useState, useEffect, useRef, useCallback } from 'react';
-
-interface AudioCacheEntry {
- buffer: AudioBuffer;
- url: string; // 元のURLを保持
-}
-
-// Web Audio APIのインスタンスを管理するフック
-const useAudioSequencer = (currentAudioUrl: string | null) => {
- // AudioContext、再生中のノード、ロード済みのバッファを保持
- const contextRef = useRef<AudioContext | null>(null);
- const sourceRef = useRef<AudioBufferSourceNode | null>(null);
- const bufferCache = useRef<Map<string, AudioCacheEntry>>(new Map());
-
- const [isPlaying, setIsPlaying] = useState(false);
- const [isSwitching, setIsSwitching] = useState(false);
-
- // AudioContextの初期化
- useEffect(() => {
- if (typeof globalThis !== 'undefined' && !contextRef.current) {
- contextRef.current = new (globalThis.AudioContext)();
- }
- }, []);
-
- // 音源をロードし、AudioBufferにデコードする関数
- const loadAudio = useCallback(async (url: string): Promise<AudioBuffer> => {
- if (bufferCache.current.has(url)) {
- return bufferCache.current.get(url)!.buffer;
- }
-
- const response = await fetch(url);
- const arrayBuffer = await response.arrayBuffer();
- const buffer = await contextRef.current!.decodeAudioData(arrayBuffer);
-
- bufferCache.current.set(url, { buffer, url });
- return buffer;
- }, []);
-
- // 再生を開始・切り替えするメインロジック
- const playSource = (
- buffer: AudioBuffer,
- context: AudioContext,
- onEndCallback: () => void // 再生終了時に呼び出すコールバック
- ) => {
- const source = context.createBufferSource();
- source.buffer = buffer;
- // source.loop = true; // ❌ 標準のループ機能を無効化して手動で実装。
-
- source.connect(context.destination);
-
- // onended イベントをフックして、手動ループまたはシーケンス制御を行う
- source.onended = onEndCallback;
-
- source.start(0);
- return source;
- };
-
- // ループを解除し、現在の再生が終了するのを待つ
- const endLoopAndAwaitCompletion = useCallback((): Promise<void> => {
- const source = sourceRef.current;
- if (!source || !isPlaying) {
- return Promise.resolve();
- }
-
- // ループを無効化
- source.loop = false;
-
- return new Promise((resolve) => {
- // onended が発火するのを待つ
- source.onended = () => {
- sourceRef.current = null;
- setIsPlaying(false);
- resolve();
- };
- });
- }, [isPlaying]);
-
- // currentAudioUrl の変更監視
- // useAudioSequencer.ts の useEffect の修正
-
- useEffect(() => {
- let active = true;
- let isLooping = true; // 💡 手動ループの状態管理用フラグ
-
- const context = contextRef.current;
- if (!context) return;
-
- // 💡 関数: 現在の音源が終了したときに呼ばれるコールバック
- const handleSourceEnded = () => {
- // 現在のノードをクリア
- sourceRef.current = null;
-
- // 1. ループ継続が許可されている場合、即座に同じ音源を再再生(手動ループ)
- if (isLooping && currentAudioUrl) {
- console.log("-> 手動ループ: 同じ音源を再再生");
- sequencePlayback(currentAudioUrl, true);
- } else {
- // 2. ループが解除されている場合、外部の待機処理(Promise)を完了させる
- // このとき、Promiseの解決は外部のendLoopAndAwaitCompletionで処理されます。
- setIsPlaying(false);
- // 何もしないことで、onendedイベントが外部のPromiseを解決するのを待つ
- }
- };
-
- // 💡 関数: 再生シーケンスの開始
- const sequencePlayback = async (url: string, isLoopRestart: boolean = false) => {
- if (!active || !context) return;
-
- try {
- const buffer = await loadAudio(url);
-
- // 💡 停止中の場合は、古いノードを破棄してから新しいノードを作成
- if (!isLoopRestart && sourceRef.current) {
- // 新しいURLが来たら、古いノードを停止
- sourceRef.current.stop(context.currentTime);
- }
-
- // 新しい音源の再生開始
- const newSource = playSource(buffer, context, handleSourceEnded);
- sourceRef.current = newSource;
- setIsPlaying(true);
- setIsSwitching(false);
-
- } catch (error) {
- console.error('Audio playback failed:', error);
- setIsPlaying(false);
- setIsSwitching(false);
- }
- };
-
- // 💡 関数: ループを解除し、現在の再生が終了するのを待つ (Promiseを返す)
- const endLoopAndAwaitCompletion = (): Promise<void> => {
- if (!sourceRef.current || !isPlaying) {
- return Promise.resolve();
- }
-
- // 💡 重要な修正: ループフラグを false に設定することで、handleSourceEndedで再ループが起こるのを防ぐ
- isLooping = false;
-
- return new Promise((resolve) => {
- // 現在再生中のノードが終了した時に resolve するための処理
- // handleSourceEndedが呼ばれ、その中で isLooping=false の処理が走るのを待つ。
- // 💡 ここで onended を直接上書きするのではなく、元の handleSourceEnded の動作に依存させる
- sourceRef.current!.onended = () => {
- sourceRef.current = null;
- setIsPlaying(false);
- resolve();
- };
- });
- };
-
- const getCurrentAudioUrl = (buffer: AudioBuffer): string | undefined => {
- for (const [url, entry] of bufferCache.current.entries()) {
- if (entry.buffer === buffer) {
- return url;
- }
- }
- return undefined;
- };
-
- // 💡 メイン処理: currentAudioUrlの変更を監視
- const mainSequence = async () => {
- if (!currentAudioUrl) {
- sourceRef.current?.stop();
- sourceRef.current = null;
- setIsPlaying(false);
- return;
- }
-
- setIsSwitching(true);
-
- // 1. URLが変わった場合、古い音源の終了を待つ
- if (sourceRef.current && sourceRef.current.buffer) {
-
- // 💡 修正されたチェックロジック
- const currentUrl = getCurrentAudioUrl(sourceRef.current.buffer);
-
- // 現在のURLが新しいURLと異なる場合
- if (currentUrl && currentUrl !== currentAudioUrl) {
- // ループを切り、終了を待機
- isLooping = false; // ループを停止
- await endLoopAndAwaitCompletion();
- }
- }
-
- if (!active) return;
-
- // 2. 新しい音源の再生開始
- isLooping = true;
- sequencePlayback(currentAudioUrl);
- setIsSwitching(false);
- };
-
- if (currentAudioUrl) {
- mainSequence();
- }
-
- return () => {
- active = false;
- // ... (クリーンアップ)
- };
- }, [currentAudioUrl, loadAudio]); // 依存配列はシンプルに保つ
-
- return {
- isPlaying,
- isSwitching,
- stop: () => sourceRef.current?.stop(),
- endLoopAndAwaitCompletion,
- };
-};
-
-export default useAudioSequencer;
diff --git a/app/ctrl/page.tsx b/app/ctrl/page.tsx
@@ -33,8 +33,8 @@ export default function Chat() {
setStage(stage + 1);
}, []);
- /*
let StageComponent: React.ReactNode;
+ console.log("now, stage is ",stage);
switch (stage) {
case 1:
StageComponent = <FirstChat onComplete={handleStageComplete}/>;
@@ -48,20 +48,12 @@ export default function Chat() {
default:
StageComponent = <Error onComplete={handleStageComplete}/>;
}
- */
- //for debug
- const StageComponent: React.ReactNode = <ThreeVR onComplete={handleStageComplete}/>;
-
const checkFullscreen = () => {
const isTargetFullscreen = document.fullscreenElement === pageRef.current;
setIsFullscreen(isTargetFullscreen);
};
-// const checkStage = () => {
-// const stage;
-// };
-
useEffect(() => {
// for fullscreen
const element = pageRef.current;