commit 7e5ab38ab5710cec87ecf47f6faba4bcaff457d4
parent a76446155c8b43528a7c03e9f0c16be557c9f8a4
Author: minerva-jupiter <ryouturn@gmail.com>
Date: Fri, 24 Oct 2025 21:24:03 +0900
feat(bgm): Add dynamic background music on chat component
Diffstat:
9 files changed, 269 insertions(+), 11 deletions(-)
diff --git a/app/components/1_chat.tsx b/app/components/1_chat.tsx
@@ -3,6 +3,7 @@
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(
@@ -10,9 +11,24 @@ const FirstChat: React.FC<StageProps> = ({ 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;
@@ -90,6 +106,7 @@ const MessageInput: React.FC<{ onSend: (text: string) => void }> = ({ onSend })
fontWeight: 'bold',
};
+
return (
<div style={{ padding: '15px', backgroundColor: '#f9fafb', display: 'flex', alignItems: 'center' }}>
<input
@@ -119,6 +136,12 @@ function ChatPage({onComplete}:StageProps) {
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' });
@@ -136,9 +159,7 @@ function ChatPage({onComplete}:StageProps) {
init();
}, []);
- let talktimes = 1;
-
- const handleSendMessage = useCallback((text: string) => {
+ const handleSendMessage = useCallback(async (text: string) => {
if (text.trim() === '') return;
const newUserMessage: Message = {
@@ -154,11 +175,18 @@ function ChatPage({onComplete}:StageProps) {
sender: 'ai',
};
setMessages((prev) => [...prev, aiResponse]);
- talktimes += 1;
- if(talktimes >= 5){
+
+ talktimesRef.current += 1;
+
+ // termination condition
+ if(talktimesRef.current >= 4){
+ await endLoopAndAwaitCompletion();
onComplete();
+ return;
}
- }, [dict, onComplete]);
+ setCurrentTalktimes(talktimesRef.current);
+
+ }, [dict, onComplete, endLoopAndAwaitCompletion]);
const pageContainerStyle: React.CSSProperties = {
display: 'flex',
@@ -185,6 +213,27 @@ function ChatPage({onComplete}:StageProps) {
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();
+ setCurrentTalktimes(1);
+ }, []);
+
+ // function ChatPage's return
+
return (
<div style={pageContainerStyle}>
<div style={headerStyle}>
diff --git a/app/components/2_draw.tsx b/app/components/2_draw.tsx
@@ -170,7 +170,7 @@ function DrawingApp({onComplete}: StageProps) {
const snapping_distance_viewbox = SNAPPING_DISTANCE_PIXELS * (viewBoxSize / effectiveSize);
// ⚠️ WASM呼び出し
- const nearest_viewbox: NearestPointResult | null = find_nearest_point_on_path(
+ const nearest_viewbox: NearestPointResult | undefined = find_nearest_point_on_path(
backgroundPathD,
p_viewbox.x,
p_viewbox.y,
diff --git a/app/components/useAudioSequencer.ts b/app/components/useAudioSequencer.ts
@@ -0,0 +1,212 @@
+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 || (globalThis as any).webkitAudioContext)();
+ }
+ }, []);
+
+ // 音源をロードし、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
@@ -31,7 +31,6 @@ export default function Chat() {
}, []);
let StageComponent: React.ReactNode;
- /*
switch (stage) {
case 1:
StageComponent = <FirstChat onComplete={handleStageComplete}/>;
@@ -42,8 +41,6 @@ export default function Chat() {
default:
StageComponent = <Error onComplete={handleStageComplete}/>;
}
- */
- StageComponent = <SecondDraw onComplete={handleStageComplete}/>;
const checkFullscreen = () => {
const isTargetFullscreen = document.fullscreenElement === pageRef.current;
setIsFullscreen(isTargetFullscreen);
diff --git a/public/audio/001.wav b/public/audio/001.wav
Binary files differ.
diff --git a/public/audio/002.wav b/public/audio/002.wav
Binary files differ.
diff --git a/public/audio/003.wav b/public/audio/003.wav
Binary files differ.
diff --git a/public/audio/004.wav b/public/audio/004.wav
Binary files differ.
diff --git a/public/audio/005.wav b/public/audio/005.wav
Binary files differ.