import { Howl, Howler } from 'howler' import { binAssetUrl } from '../../binAssets' import { debug } from '../../debug.ts' type SoundName = 'placement' | 'hover' | 'win' | 'click' const DEFAULT_SOUNDTRACK_TRACKS = [ 'bamboo_breath_32.opus', 'bamboo_forest_32.opus', 'bamboo_green_32.opus', 'bamboo_rainfall_32.opus', 'bamboo_rattle_32.opus', 'bamboo_silence_32.opus', ] const HF_SOUNDTRACK_BASE = 'https://huggingface.co/datasets/pedroc/caro5-game-assets/resolve/main/music/soundtrack' const VICTORY_TRACKS = [ 'firework_rookmate_48.ogg', 'imperial_checkmate2_48.ogg', 'imperial_firecrash_victory_48.ogg', 'imperial_firecrash_victory2_48.ogg', ] const DEFEAT_TRACKS = [ 'chessfall_disappointment_48.ogg', 'chessfall_glory_48.ogg', 'judgment_gong_48.ogg', ] const HF_VICTORY_BASE = 'https://huggingface.co/datasets/pedroc/caro5-game-assets/resolve/main/music/victory' const HF_DEFEAT_BASE = 'https://huggingface.co/datasets/pedroc/caro5-game-assets/resolve/main/music/defeat' export class AudioManager { private sounds = new Map() private _muted = true private _volume = 0.5 private hoverTimer: ReturnType | null = null private music: Howl | null = null private musicId: number | null = null private tracks: string[] private musicTrackIndex: number private musicRequested = false private musicStopTimer: ReturnType | null = null private victoryDefeatMusic: Howl | null = null private victoryPreloaded: Howl | null = null private defeatPreloaded: Howl | null = null constructor() { debug('AudioManager', 'constructor') this.tracks = [...DEFAULT_SOUNDTRACK_TRACKS] this.musicTrackIndex = Math.floor(Math.random() * this.tracks.length) this.load('placement', binAssetUrl('sounds/placement.wav'), { volume: 1.0 }) this.load('hover', binAssetUrl('sounds/hover.wav'), { volume: 0.4 }) this.load('win', binAssetUrl('sounds/win.wav'), { volume: 0.8 }) this.load('click', binAssetUrl('sounds/placement.wav'), { volume: 0.7 }) this.preloadNextVictory() this.preloadNextDefeat() this.applyVolume() } get muted(): boolean { return this._muted } get volume(): number { return this._volume } private load(name: SoundName, src: string, opts: { volume?: number; loop?: boolean } = {}): void { const howl = new Howl({ src: [src], preload: true, ...opts }) this.sounds.set(name, howl) } private removeTrack(failedTrack: string): void { const idx = this.tracks.indexOf(failedTrack) if (idx === -1) return debug('AudioManager', 'removing failed track', failedTrack) this.tracks.splice(idx, 1) if (this.musicTrackIndex >= idx && this.musicTrackIndex > 0) { this.musicTrackIndex-- } } private createMusic(track: string): Howl { const src = binAssetUrl(`music/soundtrack/${track}`) const hfSrc = `${HF_SOUNDTRACK_BASE}/${track}` const onError = () => { debug('AudioManager', 'track error:', track) this.removeTrack(track) this.playNextTrack() } return new Howl({ src: src === hfSrc ? [src] : [src, hfSrc], html5: true, preload: true, volume: 0.35, onend: () => this.playNextTrack(), onloaderror: onError, onplayerror: onError, }) } private createOutcomeMusic(track: string, baseUrl: string, hfBase: string): Howl { const src = binAssetUrl(`${baseUrl}/${track}`) const hfSrc = `${hfBase}/${track}` debug('AudioManager', 'createOutcomeMusic', { track, src, hfSrc }) return new Howl({ src: src === hfSrc ? [src] : [src, hfSrc], preload: true, volume: 0.5, onload: () => { debug('AudioManager', 'outcome music loaded:', track) }, onloaderror: (_id: number, err: unknown) => { debug('AudioManager', 'outcome music LOAD error:', track, err) }, onplayerror: (_id: number, err: unknown) => { debug('AudioManager', 'outcome music PLAY error:', track, err) }, }) } private preloadNextVictory(): void { const track = VICTORY_TRACKS[Math.floor(Math.random() * VICTORY_TRACKS.length)] debug('AudioManager', 'preloadNextVictory', { track }) this.victoryPreloaded = this.createOutcomeMusic(track, 'music/victory', HF_VICTORY_BASE) } private preloadNextDefeat(): void { const track = DEFEAT_TRACKS[Math.floor(Math.random() * DEFEAT_TRACKS.length)] debug('AudioManager', 'preloadNextDefeat', { track }) this.defeatPreloaded = this.createOutcomeMusic(track, 'music/defeat', HF_DEFEAT_BASE) } playVictoryMusic(): void { debug('AudioManager', 'playVictoryMusic', { hasPreloaded: !!this.victoryPreloaded, hasCurrent: !!this.victoryDefeatMusic, muted: this._muted, howlerVolume: (Howler as any).volume(), }) this.stopOutcomeMusic() this.victoryDefeatMusic = this.victoryPreloaded this.victoryPreloaded = null if (this.victoryDefeatMusic) { const id = this.victoryDefeatMusic.play() debug('AudioManager', 'playVictoryMusic play() returned', { id }) } else { debug('AudioManager', 'playVictoryMusic: no preloaded Howl') } this.preloadNextVictory() } playDefeatMusic(): void { debug('AudioManager', 'playDefeatMusic', { hasPreloaded: !!this.defeatPreloaded, hasCurrent: !!this.victoryDefeatMusic, muted: this._muted, howlerVolume: (Howler as any).volume(), }) this.stopOutcomeMusic() this.victoryDefeatMusic = this.defeatPreloaded this.defeatPreloaded = null if (this.victoryDefeatMusic) { const id = this.victoryDefeatMusic.play() debug('AudioManager', 'playDefeatMusic play() returned', { id }) } else { debug('AudioManager', 'playDefeatMusic: no preloaded Howl') } this.preloadNextDefeat() } private stopOutcomeMusic(): void { if (this.victoryDefeatMusic) { this.victoryDefeatMusic.unload() this.victoryDefeatMusic = null } } private playNextTrack(): void { if (this.musicStopTimer) { clearTimeout(this.musicStopTimer) this.musicStopTimer = null } this.musicId = null this.music?.unload() this.music = null if (this.tracks.length === 0) { debug('AudioManager', 'no tracks left, stopping music') this.musicRequested = false return } this.musicTrackIndex = (this.musicTrackIndex + 1) % this.tracks.length if (this.musicRequested && !this._muted) this.startMusic() } private applyVolume(): void { const v = this._muted ? 0 : this._volume Howler.volume(v) } setVolume(v: number): void { this._volume = Math.max(0, Math.min(1, v)) this.applyVolume() } mute(): void { this._muted = true this.applyVolume() this.pauseMusic() } unmute(): void { this._muted = false this.applyVolume() if (this.musicRequested) this.startMusic() } toggleMute(): boolean { this._muted ? this.unmute() : this.mute() return !this._muted } play(name: SoundName): void { debug('AudioManager', 'play', name) const howl = this.sounds.get(name) if (!howl) { debug('AudioManager', 'sound not found:', name); return } howl.play() } stop(name: SoundName): void { const howl = this.sounds.get(name) if (!howl) return howl.stop() } playHover(): void { if (this.hoverTimer) return this.hoverTimer = setTimeout(() => { this.hoverTimer = null this.play('hover') }, 100) } startMusic(): void { debug('AudioManager', 'startMusic', { muted: this._muted, hasMusic: !!this.music, musicId: this.musicId }) this.musicRequested = true if (this.musicStopTimer) { clearTimeout(this.musicStopTimer) this.musicStopTimer = null } if (this._muted || this.musicId !== null) return if (!this.music) { if (this.musicTrackIndex >= this.tracks.length) { this.musicTrackIndex = 0 } this.music = this.createMusic(this.tracks[this.musicTrackIndex]) } try { this.musicId = this.music.play() } catch { debug('AudioManager', 'startMusic: play failed, next track') this.playNextTrack() } } pauseMusic(): void { debug('AudioManager', 'pauseMusic', { musicId: this.musicId }) if (this.musicId === null) return this.music?.pause(this.musicId) this.musicId = null } stopMusic(): void { debug('AudioManager', 'stopMusic') this.musicRequested = false if (this.musicId !== null) { const id = this.musicId this.music?.fade(0.35, 0, 450, id) if (this.musicStopTimer) clearTimeout(this.musicStopTimer) this.musicStopTimer = setTimeout(() => { this.music?.stop(id) this.musicStopTimer = null }, 450) } else { this.music?.stop() } this.musicId = null } stopAll(): void { debug('AudioManager', 'stopAll') this.musicRequested = false if (this.musicStopTimer) clearTimeout(this.musicStopTimer) this.music?.stop() this.music = null this.musicId = null this.musicStopTimer = null this.stopOutcomeMusic() this.victoryPreloaded?.unload() this.victoryPreloaded = null this.defeatPreloaded?.unload() this.defeatPreloaded = null this.preloadNextVictory() this.preloadNextDefeat() } destroy(): void { debug('AudioManager', 'destroy') if (this.hoverTimer) clearTimeout(this.hoverTimer) if (this.musicStopTimer) clearTimeout(this.musicStopTimer) for (const howl of this.sounds.values()) howl.unload() this.sounds.clear() this.music?.unload() this.music = null this.musicId = null this.musicRequested = false this.musicStopTimer = null this.stopOutcomeMusic() this.victoryPreloaded?.unload() this.victoryPreloaded = null this.defeatPreloaded?.unload() this.defeatPreloaded = null } } export const audioManager = new AudioManager()