Spaces:
Runtime error
Runtime error
| 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<SoundName, Howl>() | |
| private _muted = true | |
| private _volume = 0.5 | |
| private hoverTimer: ReturnType<typeof setTimeout> | null = null | |
| private music: Howl | null = null | |
| private musicId: number | null = null | |
| private tracks: string[] | |
| private musicTrackIndex: number | |
| private musicRequested = false | |
| private musicStopTimer: ReturnType<typeof setTimeout> | 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() | |