From c1103f676da55bd36ca68b87e0ef0e0072eb254d Mon Sep 17 00:00:00 2001 From: just-live28 Date: Mon, 21 Jul 2025 23:13:22 +0900 Subject: [PATCH] =?UTF-8?q?Feat:=20=ED=82=A4=EC=8B=B1=EC=9C=A0=20=EB=85=B9?= =?UTF-8?q?=EC=9D=8C=20=EC=9E=AC=EC=83=9D=20=EC=8B=9C=20=EC=97=90=EC=BD=94?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80=20#232?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/KeysingyouGameRoom.tsx | 26 +++++++++++++------- components/ui/AudioVisualizer.tsx | 1 + lib/applyEcho.ts | 40 +++++++++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 9 deletions(-) create mode 100644 lib/applyEcho.ts diff --git a/components/KeysingyouGameRoom.tsx b/components/KeysingyouGameRoom.tsx index 0e7cea6..b12a740 100644 --- a/components/KeysingyouGameRoom.tsx +++ b/components/KeysingyouGameRoom.tsx @@ -16,6 +16,7 @@ import { TimerCircle } from "./ui/TimerCircle"; import { Progress } from "./ui/Progress"; import AudioVisualizer from "./ui/AudioVisualizer"; import { getSharedAudioCtx } from "@/lib/sharedAudioCtx"; +import { attachEcho } from "@/lib/applyEcho"; interface GameRoomProps { user: any; @@ -154,17 +155,24 @@ const KeysingyouGameRoom = ({ user, room, onBack }: GameRoomProps) => { useEffect(() => { - if (phase === "listen") { - setAnalysisStep("분석중"); - const timer1 = setTimeout(() => setAnalysisStep("대조중"), 4000); - const timer2 = setTimeout(() => setAnalysisStep("추출중"), 7000); - return () => { - clearTimeout(timer1); - clearTimeout(timer2); - }; - } + if (phase !== "listen") return; + + setAnalysisStep("분석중"); + const timer1 = setTimeout(() => setAnalysisStep("대조중"), 4000); + const timer2 = setTimeout(() => setAnalysisStep("추출중"), 7000); + return () => { + clearTimeout(timer1); + clearTimeout(timer2); + }; }, [phase, audioSrc]); + useEffect(() => { + if (phase !== "listen" || !audioRef.current) return; + + getSharedAudioCtx().resume().catch(() => { }); + attachEcho(audioRef.current); + }, [phase]); + // 2. 키워드 phase 진입 시 슬롯머신 애니메이션 useEffect(() => { if (phase === "keyword" && keyword) { diff --git a/components/ui/AudioVisualizer.tsx b/components/ui/AudioVisualizer.tsx index fd03edb..3372070 100644 --- a/components/ui/AudioVisualizer.tsx +++ b/components/ui/AudioVisualizer.tsx @@ -33,6 +33,7 @@ const AudioVisualizer: React.FC = ({ audioRef }) => { if (!sourceRef.current) { try { sourceRef.current = audioCtx.createMediaElementSource(audioEl); + audioEl._srcNode = sourceRef.current; sourceRef.current.connect(analyser); analyser.connect(audioCtx.destination); } catch (error) { diff --git a/lib/applyEcho.ts b/lib/applyEcho.ts new file mode 100644 index 0000000..80881b0 --- /dev/null +++ b/lib/applyEcho.ts @@ -0,0 +1,40 @@ +import { getSharedAudioCtx } from "@/lib/sharedAudioCtx"; + +declare global { + interface HTMLAudioElement { + _srcNode?: MediaElementAudioSourceNode; + _echoAttached?: boolean; + } +} + +export function attachEcho(audioEl: HTMLAudioElement) { + const ctx = getSharedAudioCtx(); + if (audioEl._echoAttached) return; // 이미 체인 붙었으면 종료 + + /** ✦ 핵심: SourceNode 재사용 또는 신규 생성 */ + const src = + audioEl._srcNode ?? (audioEl._srcNode = ctx.createMediaElementSource(audioEl)); + + // ───── echo chain once ───── + const delay = ctx.createDelay(5); + delay.delayTime.value = 0.33; + + const feedback = ctx.createGain(); + feedback.gain.value = 0.4; + + const lpf = ctx.createBiquadFilter(); + lpf.type = "lowpass"; + lpf.frequency.value = 3500; + + const wet = ctx.createGain(); + wet.gain.value = 0.6; + + const dry = ctx.createGain(); + dry.gain.value = 1; + + delay.connect(feedback).connect(lpf).connect(delay); + src.connect(dry).connect(ctx.destination); // dry + src.connect(delay).connect(wet).connect(ctx.destination); // wet + + audioEl._echoAttached = true; // 플래그 마킹 +}