'use client'; import { useEffect, useRef, useCallback, useState } from 'react'; /** * bitFlyer Lightning 스타일 효과음 * sound-parlor.mp3 오디오 스프라이트 사용 * * 스프라이트 구간 [startMs, durationMs]: * electricalMagicPoof [0, 761] → 호가 제거 * dropHeavyPlastic [761, 574] → 호가 추가 * MagicDing [1330, 1221] → 소형 매도 체결 * DescendingMagic [2550, 1183] → 대형 매도 체결 * coinGameWinPack1 [3740, 1140] → 소형 매수 체결 * coinGameWinPack2 [4882, 1017] → 대형 매수 체결 */ // [startMs, durationMs] const SPRITES = { orderbookRemove: [0, 761], orderbookAdd: [761, 574], sellSmall: [1330, 1221], sellBig: [2550, 1183], buySmall: [3740, 1140], buyBig: [4882, 1017], } as const; // 대형 체결 기준 (이 수량 이상이면 Big 사운드) const BIG_TRADE_THRESHOLD = 1; // 호가 사운드 쓰로틀 간격 (ms) — 짧게 설정하여 bitFlyer처럼 연속 재생 const OB_THROTTLE_ADD = 60; const OB_THROTTLE_REMOVE = 150; export default function useTradeSound() { const audioCtxRef = useRef(null); const audioBufferRef = useRef(null); const [muted, setMuted] = useState(true); const loadedRef = useRef(false); const loadingRef = useRef(false); const obThrottleRef = useRef<{ add: number; remove: number }>({ add: 0, remove: 0 }); // AudioContext 초기화 const ensureContext = useCallback(() => { if (!audioCtxRef.current) { audioCtxRef.current = new AudioContext(); } if (audioCtxRef.current.state === 'suspended') { audioCtxRef.current.resume(); } return audioCtxRef.current; }, []); // MP3 스프라이트 파일 로드 const loadSound = useCallback(async () => { if (loadedRef.current || loadingRef.current) return; loadingRef.current = true; try { const ctx = ensureContext(); const response = await fetch('/sounds/sound-parlor.mp3'); const arrayBuffer = await response.arrayBuffer(); const decoded = await ctx.decodeAudioData(arrayBuffer); audioBufferRef.current = decoded; loadedRef.current = true; } catch (err) { console.error('Failed to load trade sound:', err); } finally { loadingRef.current = false; } }, [ensureContext]); // 스프라이트 구간 재생 const playSprite = useCallback((startMs: number, durationMs: number, volume: number) => { if (!audioBufferRef.current || !audioCtxRef.current) return; const ctx = audioCtxRef.current; if (ctx.state === 'suspended') { ctx.resume(); } const source = ctx.createBufferSource(); const gainNode = ctx.createGain(); source.buffer = audioBufferRef.current; gainNode.gain.value = Math.min(Math.max(volume, 0.1), 1.0); source.connect(gainNode); gainNode.connect(ctx.destination); const startSec = startMs / 1000; const durationSec = durationMs / 1000; source.start(0, startSec, durationSec); }, []); // 체결 사운드 재생 const playTradeSound = useCallback((side: 'BID' | 'ASK', tradeVolume: number = 0.5) => { if (muted || !loadedRef.current) return; const isBig = tradeVolume >= BIG_TRADE_THRESHOLD; let sprite: readonly [number, number]; if (side === 'BID') { sprite = isBig ? SPRITES.buyBig : SPRITES.buySmall; } else { sprite = isBig ? SPRITES.sellBig : SPRITES.sellSmall; } // 거래량 기반 볼륨 (0.3 ~ 0.8) const vol = Math.min(0.3 + tradeVolume * 0.3, 0.8); playSprite(sprite[0], sprite[1], vol); }, [muted, playSprite]); // 호가 사운드 재생 (짧은 쓰로틀 → bitFlyer 스타일 연속 딸깍) const playOrderbookSound = useCallback((type: 'add' | 'remove') => { if (muted || !loadedRef.current) return; const now = Date.now(); const throttle = type === 'add' ? OB_THROTTLE_ADD : OB_THROTTLE_REMOVE; if (now - obThrottleRef.current[type] < throttle) return; obThrottleRef.current[type] = now; // electricalMagicPoof (remove) 는 일단 비활성화 if (type === 'remove') return; const sprite = SPRITES.orderbookAdd; playSprite(sprite[0], sprite[1], 0.15); }, [muted, playSprite]); // 토글 함수 const toggleMute = useCallback(() => { setMuted((prev) => { if (prev) { // 음소거 해제 시 사운드 로드 ensureContext(); loadSound(); } return !prev; }); }, [ensureContext, loadSound]); // cleanup useEffect(() => { return () => { if (audioCtxRef.current) { audioCtxRef.current.close(); audioCtxRef.current = null; } }; }, []); return { playTradeSound, playOrderbookSound, muted, toggleMute }; }