musical-chess / src /engines /AudioEngine.ts
Maximus Powers
final
3568151
import { Chess, Square, Move, PieceSymbol, Color } from 'chess.js'
import { PositionEvaluator } from './PositionEvaluator'
export class AudioEngine {
private audioContext: AudioContext | null = null
private masterGain: GainNode | null = null
private isActive: boolean = false
private volume: number = 0.7
private ambientVolume: number = 0.8
private gameVolume: number = 0.048
private userInterval: NodeJS.Timeout | null = null
private boardFlipped: boolean = false
private noteFrequencies = {
'A': 27.50, // A0
'B': 30.87, // B0
'Cb': 32.70, // C1♭
'C': 32.70, // C1
'D': 36.71, // D1
'Db': 34.65, // D1♭
'E': 41.20, // E1
'F': 43.65 // F1
}
private fileNotes = ['A', 'B', 'Cb', 'C', 'D', 'Db', 'E', 'F']
private fileFrequencies = [
this.noteFrequencies.A,
this.noteFrequencies.B,
this.noteFrequencies.Cb,
this.noteFrequencies.C,
this.noteFrequencies.D,
this.noteFrequencies.Db,
this.noteFrequencies.E,
this.noteFrequencies.F
]
private userInitiativeFreq = 110.0 // A2
constructor() {
this.initialize()
}
private async initialize() {
try {
this.audioContext = new (window.AudioContext || (window as any).webkitAudioContext)()
this.masterGain = this.audioContext.createGain()
this.masterGain.connect(this.audioContext.destination)
this.masterGain.gain.value = this.volume
this.isActive = true
console.log('Audio engine initialized')
} catch (error) {
console.error('Failed to initialize audio engine:', error)
this.isActive = false
}
}
setBoardFlipped(flipped: boolean) {
this.boardFlipped = flipped
}
private getSquareFrequency(square: Square): number {
const file = square.charCodeAt(0) - 97
const rank = parseInt(square[1]) - 1
const actualFile = this.boardFlipped ? 7 - file : file
const actualRank = this.boardFlipped ? 7 - rank : rank
const baseFreq = this.fileFrequencies[actualFile]
const octaveMultiplier = Math.pow(2, actualRank)
return baseFreq * octaveMultiplier
}
getFileNoteName(fileIndex: number): string {
const actualFile = this.boardFlipped ? 7 - fileIndex : fileIndex
return this.fileNotes[actualFile]
}
setVolume(volume: number) {
this.volume = Math.max(0, Math.min(1, volume))
if (this.masterGain) {
this.masterGain.gain.value = this.volume
}
if (this.volume === 0) {
this.stopAllAudio()
}
}
setAmbientVolume(volume: number) {
this.ambientVolume = Math.max(0, Math.min(1, volume))
}
setGameVolume(volume: number) {
this.gameVolume = Math.max(0, Math.min(0.1, volume * 0.1))
}
getAmbientVolume(): number {
return this.ambientVolume
}
getGameVolume(): number {
return this.gameVolume / 0.1
}
async ensureAudioContext() {
if (!this.audioContext) {
await this.initialize()
}
if (this.audioContext?.state === 'suspended') {
await this.audioContext.resume()
}
}
private createTone(frequency: number, duration: number, fadeOut: boolean = true, volume: number = 0.4): Promise<void> {
return new Promise((resolve) => {
if (!this.audioContext || !this.masterGain || this.volume === 0) {
resolve()
return
}
const oscillator = this.audioContext.createOscillator()
const gainNode = this.audioContext.createGain()
oscillator.connect(gainNode)
gainNode.connect(this.masterGain)
oscillator.frequency.value = frequency
oscillator.type = 'sine'
const now = this.audioContext.currentTime
gainNode.gain.setValueAtTime(volume, now)
if (fadeOut) {
gainNode.gain.exponentialRampToValueAtTime(0.001, now + duration)
} else {
gainNode.gain.setValueAtTime(volume, now + duration - 0.01)
gainNode.gain.exponentialRampToValueAtTime(0.001, now + duration)
}
oscillator.start(now)
oscillator.stop(now + duration)
oscillator.onended = () => resolve()
})
}
async playMoveSound(move: Move, board: Chess, capturedPiece?: any) {
if (!this.isActive || this.volume === 0) return
try {
await this.ensureAudioContext()
const originFreq = this.getSquareFrequency(move.from as Square)
const destFreq = this.getSquareFrequency(move.to as Square)
const beatDuration = 0.2
const pauseDuration = 0.08
await this.createTone(originFreq, beatDuration, false, this.gameVolume)
await new Promise(resolve => setTimeout(resolve, pauseDuration * 1000))
await this.createTone(destFreq, beatDuration, false, this.gameVolume)
if (capturedPiece) {
await new Promise(resolve => setTimeout(resolve, pauseDuration * 1000))
await this.createCaptureSound()
} else {
const canCapture = this.checkForDanger(move.to as Square, board)
if (canCapture) {
await new Promise(resolve => setTimeout(resolve, pauseDuration * 1000))
await this.createDangerSound()
}
}
} catch (error) {
console.error('Error playing move sound:', error)
}
}
private async createCaptureSound() {
if (!this.audioContext || !this.masterGain) return
const duration = 0.2
const now = this.audioContext.currentTime
const frequencies = [400, 800, 1200]
const oscillators: OscillatorNode[] = []
const gainNodes: GainNode[] = []
frequencies.forEach((freq, index) => {
const oscillator = this.audioContext!.createOscillator()
const gainNode = this.audioContext!.createGain()
oscillator.connect(gainNode)
gainNode.connect(this.masterGain!)
oscillator.frequency.value = freq
oscillator.type = 'sine'
const harmonic_volume = this.gameVolume * (1 / (index + 1))
gainNode.gain.setValueAtTime(harmonic_volume, now)
gainNode.gain.exponentialRampToValueAtTime(0.001, now + duration)
oscillator.start(now)
oscillator.stop(now + duration)
oscillators.push(oscillator)
gainNodes.push(gainNode)
})
return new Promise<void>(resolve => {
oscillators[oscillators.length - 1].onended = () => resolve()
})
}
private async createDangerSound() {
if (!this.audioContext || !this.masterGain) return
const duration = 0.15
const now = this.audioContext.currentTime
const oscillator = this.audioContext.createOscillator()
const gainNode = this.audioContext.createGain()
oscillator.connect(gainNode)
gainNode.connect(this.masterGain)
oscillator.frequency.value = 1000
oscillator.type = 'sawtooth'
gainNode.gain.setValueAtTime(this.gameVolume, now)
gainNode.gain.exponentialRampToValueAtTime(0.001, now + duration)
oscillator.start(now)
oscillator.stop(now + duration)
return new Promise<void>(resolve => {
oscillator.onended = () => resolve()
})
}
private checkForDanger(square: Square, board: Chess): boolean {
try {
const moves = board.moves({ square, verbose: true })
return moves.some((move: any) => move.captured)
} catch {
return false
}
}
async updatePositionAudio(board: Chess, userColor: Color = 'w') {
if (!this.isActive || this.volume === 0) return
try {
await this.ensureAudioContext()
this.startContinuousInitiativeAudio(board, userColor)
} catch (error) {
console.error('Error updating position audio:', error)
}
}
private startContinuousInitiativeAudio(board: Chess, userColor: Color = 'w') {
if (!this.audioContext || !this.masterGain) return
this.stopPositionAudio()
const userInitiative = PositionEvaluator.getInitiative(board, userColor)
const userBeatsPerMinute = userInitiative * 120
if (userBeatsPerMinute > 0) {
const userInterval = (60 / userBeatsPerMinute) * 1000
this.userInterval = setInterval(() => {
this.playInitiativeBeat(this.userInitiativeFreq)
}, userInterval)
}
}
private async playInitiativeBeat(frequency: number) {
if (!this.audioContext || !this.masterGain || this.volume === 0) return
const oscillator = this.audioContext.createOscillator()
const gainNode = this.audioContext.createGain()
oscillator.connect(gainNode)
gainNode.connect(this.masterGain)
oscillator.frequency.value = frequency
oscillator.type = 'sine'
const now = this.audioContext.currentTime
const duration = 0.1
const volume = this.ambientVolume
gainNode.gain.setValueAtTime(volume, now)
gainNode.gain.exponentialRampToValueAtTime(0.001, now + duration)
oscillator.start(now)
oscillator.stop(now + duration)
}
updateInitiativeVolumes(board: Chess, userColor: Color = 'w') {
this.startContinuousInitiativeAudio(board, userColor)
}
stopPositionAudio() {
if (this.userInterval) {
clearInterval(this.userInterval)
this.userInterval = null
}
}
stopAllAudio() {
this.stopPositionAudio()
if (this.audioContext) {
try {
if (this.masterGain) {
this.masterGain.disconnect()
this.masterGain = this.audioContext.createGain()
this.masterGain.connect(this.audioContext.destination)
this.masterGain.gain.value = this.volume
}
} catch (error) {
console.error('Error stopping audio:', error)
}
}
}
isPlaying(): boolean {
return this.userInterval !== null
}
cleanup() {
this.stopAllAudio()
this.isActive = false
if (this.audioContext) {
this.audioContext.close()
this.audioContext = null
}
}
}