Skip to content

Commit f9b5f8b

Browse files
feat: reimplement sound management
1 parent 80979d5 commit f9b5f8b

12 files changed

Lines changed: 195 additions & 172 deletions

File tree

src/components/Board/GameBoard.tsx

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/* eslint-disable @typescript-eslint/no-explicit-any */
22
import { Chess } from 'chess.ts'
3-
import { chessSoundManager } from 'src/lib/sound'
3+
import { useSound } from 'src/hooks/useSound'
44
import { defaults } from 'chessground/state'
55
import type { Key } from 'chessground/types'
66
import Chessground from '@react-chess/chessground'
@@ -35,6 +35,7 @@ export const GameBoard: React.FC<Props> = ({
3535
setCurrentSquare,
3636
onSelectSquare,
3737
}: Props) => {
38+
const { playMoveSound } = useSound()
3839
const after = useCallback(
3940
(from: string, to: string) => {
4041
if (onPlayerMakeMove) onPlayerMakeMove([from, to])
@@ -57,7 +58,7 @@ export const GameBoard: React.FC<Props> = ({
5758
) {
5859
const moveAttempt = chess.move({ from: from, to: to })
5960
if (moveAttempt) {
60-
chessSoundManager.playMoveSound(isCapture)
61+
playMoveSound(isCapture)
6162

6263
const newFen = chess.fen()
6364
const moveString = from + to
@@ -76,13 +77,21 @@ export const GameBoard: React.FC<Props> = ({
7677
}
7778
}
7879
} else {
79-
chessSoundManager.playMoveSound(isCapture)
80+
playMoveSound(isCapture)
8081
}
8182
} else {
82-
chessSoundManager.playMoveSound(false)
83+
playMoveSound(false)
8384
}
8485
},
85-
[game, gameTree, goToNode, currentNode, onPlayerMakeMove, setCurrentSquare],
86+
[
87+
game,
88+
gameTree,
89+
goToNode,
90+
currentNode,
91+
onPlayerMakeMove,
92+
setCurrentSquare,
93+
playMoveSound,
94+
],
8695
)
8796

8897
const boardConfig = useMemo(() => {

src/components/Settings/SoundSettings.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import React from 'react'
22
import { useSettings } from 'src/contexts/SettingsContext'
3-
import { useChessSoundManager } from 'src/lib/sound'
3+
import { useSound } from 'src/hooks/useSound'
44

55
export const SoundSettings: React.FC = () => {
66
const { settings, updateSetting } = useSettings()
7-
const { playMoveSound } = useChessSoundManager()
7+
const { playMoveSound } = useSound()
88

99
const handleToggleSound = () => {
1010
const newValue = !settings.soundEnabled

src/contexts/SoundContext.tsx

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import React, {
2+
createContext,
3+
useCallback,
4+
useEffect,
5+
useMemo,
6+
useRef,
7+
useState,
8+
} from 'react'
9+
import { useSettings } from 'src/contexts/SettingsContext'
10+
11+
type SoundType = 'move' | 'capture'
12+
13+
export interface SoundContextValue {
14+
playMoveSound: (isCapture?: boolean) => void
15+
ready: boolean
16+
}
17+
18+
export const SoundContext = createContext<SoundContextValue | undefined>(
19+
undefined,
20+
)
21+
22+
interface Props {
23+
children: React.ReactNode
24+
}
25+
26+
export const SoundProvider: React.FC<Props> = ({ children }) => {
27+
const { settings } = useSettings()
28+
29+
const ctxRef = useRef<AudioContext | null>(null)
30+
const gainRef = useRef<GainNode | null>(null)
31+
const buffersRef = useRef<Map<SoundType, AudioBuffer>>(new Map())
32+
const lastTypeRef = useRef<SoundType | null>(null)
33+
const lastPlayedAtMsRef = useRef(0)
34+
const initializedRef = useRef(false)
35+
36+
const [ready, setReady] = useState(false)
37+
38+
useEffect(() => {
39+
if (initializedRef.current || typeof window === 'undefined') return
40+
initializedRef.current = true
41+
42+
let canceled = false
43+
44+
const init = async () => {
45+
try {
46+
const AudioCtx: typeof AudioContext | undefined =
47+
(window as any).AudioContext || (window as any).webkitAudioContext
48+
if (!AudioCtx) {
49+
console.warn('Web Audio API not supported; sounds disabled')
50+
setReady(false)
51+
return
52+
}
53+
54+
const ctx = new AudioCtx()
55+
const gain = ctx.createGain()
56+
gain.connect(ctx.destination)
57+
58+
ctxRef.current = ctx
59+
gainRef.current = gain
60+
61+
const loadBuffer = async (url: string): Promise<AudioBuffer | null> => {
62+
try {
63+
const res = await fetch(url)
64+
const arr = await res.arrayBuffer()
65+
const buf = await ctx.decodeAudioData(arr)
66+
return buf
67+
} catch (e) {
68+
console.warn('Failed to load sound:', url, e)
69+
return null
70+
}
71+
}
72+
73+
const [moveBuf, captureBuf] = await Promise.all([
74+
loadBuffer('/assets/sound/move.mp3'),
75+
loadBuffer('/assets/sound/capture.mp3'),
76+
])
77+
78+
if (moveBuf) buffersRef.current.set('move', moveBuf)
79+
if (captureBuf) buffersRef.current.set('capture', captureBuf)
80+
81+
if (!canceled) setReady(buffersRef.current.size > 0)
82+
} catch (error) {
83+
console.warn('Failed to initialize sound:', error)
84+
if (!canceled) setReady(false)
85+
}
86+
}
87+
88+
void init()
89+
90+
return () => {
91+
canceled = true
92+
const gain = gainRef.current
93+
const ctx = ctxRef.current
94+
try {
95+
if (gain) gain.disconnect()
96+
} catch {
97+
/* noop */
98+
}
99+
gainRef.current = null
100+
101+
try {
102+
if (ctx) void ctx.close()
103+
} catch {
104+
/* noop */
105+
}
106+
ctxRef.current = null
107+
buffersRef.current.clear()
108+
setReady(false)
109+
initializedRef.current = false
110+
}
111+
}, [])
112+
113+
const playMoveSound = useCallback(
114+
(isCapture = false) => {
115+
if (!settings.soundEnabled) return
116+
const ctx = ctxRef.current
117+
const gain = gainRef.current
118+
if (!ctx || !gain) return
119+
120+
if (ctx.state !== 'running') {
121+
void ctx.resume()
122+
}
123+
124+
const type: SoundType = isCapture ? 'capture' : 'move'
125+
const buffer = buffersRef.current.get(type)
126+
if (!buffer) return
127+
128+
const nowMs = ctx.currentTime * 1000
129+
if (
130+
lastTypeRef.current === type &&
131+
nowMs - lastPlayedAtMsRef.current < 30
132+
) {
133+
return
134+
}
135+
lastTypeRef.current = type
136+
lastPlayedAtMsRef.current = nowMs
137+
138+
try {
139+
const src = ctx.createBufferSource()
140+
src.buffer = buffer
141+
src.connect(gain)
142+
src.start(0)
143+
} catch (e) {
144+
console.warn('Failed to play sound:', type, e)
145+
}
146+
},
147+
[settings.soundEnabled],
148+
)
149+
150+
const value = useMemo<SoundContextValue>(
151+
() => ({ playMoveSound, ready }),
152+
[playMoveSound, ready],
153+
)
154+
155+
return <SoundContext.Provider value={value}>{children}</SoundContext.Provider>
156+
}

src/hooks/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
export * from './useAnalysisController'
2-
export * from './useChessSound'
2+
export * from './useSound'
33
export * from './useLocalStorage'
44
export * from './useOpeningDrillController'
55
export * from './usePlayController'

src/hooks/useChessSound.ts

Lines changed: 0 additions & 13 deletions
This file was deleted.

src/hooks/useOpeningDrillController/useOpeningDrillController.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import {
1616
} from 'src/types/openings'
1717
import { MAIA_MODELS } from 'src/constants/common'
1818
import { MIN_STOCKFISH_DEPTH } from 'src/constants/analysis'
19-
import { chessSoundManager } from 'src/lib/sound'
19+
import { useSound } from 'src/hooks/useSound'
2020

2121
const parsePgnToTree = (pgn: string, gameTree: GameTree): GameNode | null => {
2222
if (!pgn || pgn.trim() === '') return gameTree.getRoot()
@@ -61,6 +61,7 @@ const parsePgnToTree = (pgn: string, gameTree: GameTree): GameNode | null => {
6161
export const useOpeningDrillController = (
6262
configuration: DrillConfiguration,
6363
) => {
64+
const { playMoveSound } = useSound()
6465
const [currentDrillIndex, setCurrentDrillIndex] = useState(0)
6566
const [currentDrillGame, setCurrentDrillGame] =
6667
useState<OpeningDrillGame | null>(null)
@@ -752,7 +753,7 @@ export const useOpeningDrillController = (
752753
const tempChess = new Chess(fromNode.fen)
753754
const tempMoveObj = tempChess.move(maiaMove, { sloppy: true })
754755
const isCapture = tempMoveObj?.captured !== undefined
755-
chessSoundManager.playMoveSound(isCapture)
756+
playMoveSound(isCapture)
756757

757758
// Update the moves array by getting all moves after the opening
758759
const mainLine = gameTree.getMainLine()

src/hooks/usePlayController/useHandBrainController.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { PlayGameConfig } from 'src/types'
88
import { useStats } from 'src/hooks/useStats'
99
import { usePlayController } from './usePlayController'
1010
import { fetchGameMove, logGameMove, fetchPlayPlayerStats } from 'src/api'
11-
import { chessSoundManager } from 'src/lib/sound'
11+
import { useSound } from 'src/hooks/useSound'
1212
import { safeUpdateRating } from 'src/lib/ratingUtils'
1313

1414
const brainStatsLoader = async () => {
@@ -36,6 +36,7 @@ export const useHandBrainController = (
3636
) => {
3737
const isBrain = playGameConfig.isBrain
3838
const controller = usePlayController(id, playGameConfig)
39+
const { playMoveSound } = useSound()
3940

4041
const [selectedPiece, setSelectedPiece] = useState<PieceSymbol | undefined>(
4142
undefined,
@@ -121,9 +122,9 @@ export const useHandBrainController = (
121122
controller.addMoveWithTime(moveUci, moveTime)
122123
setSelectedPiece(undefined)
123124

124-
chessSoundManager.playMoveSound(isCapture)
125+
playMoveSound(isCapture)
125126
},
126-
[controller],
127+
[controller, playMoveSound],
127128
)
128129

129130
useEffect(() => {
@@ -168,7 +169,7 @@ export const useHandBrainController = (
168169
const isCapture = !!chess.get(destinationSquare)
169170

170171
controller.addMoveWithTime(nextMove, moveTime)
171-
chessSoundManager.playMoveSound(isCapture)
172+
playMoveSound(isCapture)
172173
},
173174
simulateMaiaTime ? moveDelay * 1000 : 0,
174175
)

src/hooks/usePlayController/useVsMaiaController.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { backOff } from 'exponential-backoff'
55
import { useStats } from 'src/hooks/useStats'
66
import { usePlayController } from 'src/hooks/usePlayController'
77
import { fetchGameMove, logGameMove, fetchPlayPlayerStats } from 'src/api'
8-
import { chessSoundManager } from 'src/lib/sound'
8+
import { useSound } from 'src/hooks/useSound'
99
import { safeUpdateRating } from 'src/lib/ratingUtils'
1010

1111
const playStatsLoader = async () => {
@@ -24,6 +24,7 @@ export const useVsMaiaPlayController = (
2424
) => {
2525
const controller = usePlayController(id, playGameConfig)
2626
const [stats, incrementStats, updateRating] = useStats(playStatsLoader)
27+
const { playMoveSound } = useSound()
2728

2829
const makePlayerMove = async (moveUci: string) => {
2930
const moveTime = controller.updateClock()
@@ -77,7 +78,7 @@ export const useVsMaiaPlayController = (
7778
const isCapture = !!chess.get(destinationSquare)
7879

7980
controller.addMoveWithTime(nextMove, moveTime)
80-
chessSoundManager.playMoveSound(isCapture)
81+
playMoveSound(isCapture)
8182
}, moveDelay * 1000)
8283
} else {
8384
const moveTime = controller.updateClock()
@@ -87,7 +88,7 @@ export const useVsMaiaPlayController = (
8788
const isCapture = !!chess.get(destinationSquare)
8889

8990
controller.addMoveWithTime(nextMove, moveTime)
90-
chessSoundManager.playMoveSound(isCapture)
91+
playMoveSound(isCapture)
9192
}
9293
}
9394
}

src/hooks/useSound.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { useContext } from 'react'
2+
import { SoundContext, type SoundContextValue } from 'src/contexts/SoundContext'
3+
4+
export const useSound = (): SoundContextValue => {
5+
const ctx = useContext(SoundContext)
6+
if (!ctx) throw new Error('useSound must be used within SoundProvider')
7+
return ctx
8+
}

src/lib/index.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,3 @@ export * from './lichess'
66
export * from './puzzle'
77
export * from './ratingUtils'
88
export * from './settings'
9-
export * from './sound'

0 commit comments

Comments
 (0)