useTradeSound.ts 4.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153
  1. 'use client';
  2. import { useEffect, useRef, useCallback, useState } from 'react';
  3. /**
  4. * bitFlyer Lightning 스타일 효과음
  5. * sound-parlor.mp3 오디오 스프라이트 사용
  6. *
  7. * 스프라이트 구간 [startMs, durationMs]:
  8. * electricalMagicPoof [0, 761] → 호가 제거
  9. * dropHeavyPlastic [761, 574] → 호가 추가
  10. * MagicDing [1330, 1221] → 소형 매도 체결
  11. * DescendingMagic [2550, 1183] → 대형 매도 체결
  12. * coinGameWinPack1 [3740, 1140] → 소형 매수 체결
  13. * coinGameWinPack2 [4882, 1017] → 대형 매수 체결
  14. */
  15. // [startMs, durationMs]
  16. const SPRITES = {
  17. orderbookRemove: [0, 761],
  18. orderbookAdd: [761, 574],
  19. sellSmall: [1330, 1221],
  20. sellBig: [2550, 1183],
  21. buySmall: [3740, 1140],
  22. buyBig: [4882, 1017],
  23. } as const;
  24. // 대형 체결 기준 (이 수량 이상이면 Big 사운드)
  25. const BIG_TRADE_THRESHOLD = 1;
  26. // 호가 사운드 쓰로틀 간격 (ms) — 짧게 설정하여 bitFlyer처럼 연속 재생
  27. const OB_THROTTLE_ADD = 60;
  28. const OB_THROTTLE_REMOVE = 150;
  29. export default function useTradeSound() {
  30. const audioCtxRef = useRef<AudioContext | null>(null);
  31. const audioBufferRef = useRef<AudioBuffer | null>(null);
  32. const [muted, setMuted] = useState(true);
  33. const loadedRef = useRef(false);
  34. const loadingRef = useRef(false);
  35. const obThrottleRef = useRef<{ add: number; remove: number }>({ add: 0, remove: 0 });
  36. // AudioContext 초기화
  37. const ensureContext = useCallback(() => {
  38. if (!audioCtxRef.current) {
  39. audioCtxRef.current = new AudioContext();
  40. }
  41. if (audioCtxRef.current.state === 'suspended') {
  42. audioCtxRef.current.resume();
  43. }
  44. return audioCtxRef.current;
  45. }, []);
  46. // MP3 스프라이트 파일 로드
  47. const loadSound = useCallback(async () => {
  48. if (loadedRef.current || loadingRef.current) return;
  49. loadingRef.current = true;
  50. try {
  51. const ctx = ensureContext();
  52. const response = await fetch('/sounds/sound-parlor.mp3');
  53. const arrayBuffer = await response.arrayBuffer();
  54. const decoded = await ctx.decodeAudioData(arrayBuffer);
  55. audioBufferRef.current = decoded;
  56. loadedRef.current = true;
  57. } catch (err) {
  58. console.error('Failed to load trade sound:', err);
  59. } finally {
  60. loadingRef.current = false;
  61. }
  62. }, [ensureContext]);
  63. // 스프라이트 구간 재생
  64. const playSprite = useCallback((startMs: number, durationMs: number, volume: number) => {
  65. if (!audioBufferRef.current || !audioCtxRef.current) return;
  66. const ctx = audioCtxRef.current;
  67. if (ctx.state === 'suspended') {
  68. ctx.resume();
  69. }
  70. const source = ctx.createBufferSource();
  71. const gainNode = ctx.createGain();
  72. source.buffer = audioBufferRef.current;
  73. gainNode.gain.value = Math.min(Math.max(volume, 0.1), 1.0);
  74. source.connect(gainNode);
  75. gainNode.connect(ctx.destination);
  76. const startSec = startMs / 1000;
  77. const durationSec = durationMs / 1000;
  78. source.start(0, startSec, durationSec);
  79. }, []);
  80. // 체결 사운드 재생
  81. const playTradeSound = useCallback((side: 'BID' | 'ASK', tradeVolume: number = 0.5) => {
  82. if (muted || !loadedRef.current) return;
  83. const isBig = tradeVolume >= BIG_TRADE_THRESHOLD;
  84. let sprite: readonly [number, number];
  85. if (side === 'BID') {
  86. sprite = isBig ? SPRITES.buyBig : SPRITES.buySmall;
  87. } else {
  88. sprite = isBig ? SPRITES.sellBig : SPRITES.sellSmall;
  89. }
  90. // 거래량 기반 볼륨 (0.3 ~ 0.8)
  91. const vol = Math.min(0.3 + tradeVolume * 0.3, 0.8);
  92. playSprite(sprite[0], sprite[1], vol);
  93. }, [muted, playSprite]);
  94. // 호가 사운드 재생 (짧은 쓰로틀 → bitFlyer 스타일 연속 딸깍)
  95. const playOrderbookSound = useCallback((type: 'add' | 'remove') => {
  96. if (muted || !loadedRef.current) return;
  97. const now = Date.now();
  98. const throttle = type === 'add' ? OB_THROTTLE_ADD : OB_THROTTLE_REMOVE;
  99. if (now - obThrottleRef.current[type] < throttle) return;
  100. obThrottleRef.current[type] = now;
  101. // electricalMagicPoof (remove) 는 일단 비활성화
  102. if (type === 'remove') return;
  103. const sprite = SPRITES.orderbookAdd;
  104. playSprite(sprite[0], sprite[1], 0.15);
  105. }, [muted, playSprite]);
  106. // 토글 함수
  107. const toggleMute = useCallback(() => {
  108. setMuted((prev) => {
  109. if (prev) {
  110. // 음소거 해제 시 사운드 로드
  111. ensureContext();
  112. loadSound();
  113. }
  114. return !prev;
  115. });
  116. }, [ensureContext, loadSound]);
  117. // cleanup
  118. useEffect(() => {
  119. return () => {
  120. if (audioCtxRef.current) {
  121. audioCtxRef.current.close();
  122. audioCtxRef.current = null;
  123. }
  124. };
  125. }, []);
  126. return { playTradeSound, playOrderbookSound, muted, toggleMute };
  127. }