AIdentity

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | README

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:
Mapp/components/1_chat.tsx | 63++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Mapp/components/2_draw.tsx | 2+-
Aapp/components/useAudioSequencer.ts | 212+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mapp/ctrl/page.tsx | 3---
Apublic/audio/001.wav | 0
Apublic/audio/002.wav | 0
Apublic/audio/003.wav | 0
Apublic/audio/004.wav | 0
Apublic/audio/005.wav | 0
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.