music-player.ts
PURPOSE
Full-featured in-game music player with weighted-shuffle queue, preload-ahead caching, crossfade transitions, and warp-driven low-pass/playback-rate modulation. Plays a 60-track MP3 library hosted in Supabase Storage during gameplay only — no menu music. Exposes a singleton MusicPlayer object with skip / heart / next / previous / pause / seek / volume controls and a getState() snapshot for HUD rendering.
OWNS
- The play queue (
_queue), play history (_history, capped at 10), and the currently-active track (_current— track, cached audio, fade gain). - The audio element cache (
_cache:Map<trackId, CachedAudio>), each entry holding anHTMLAudioElementplus its permanently-boundMediaElementAudioSourceNode. - Per-track fade-in / fade-out envelopes via a per-track
GainNode(the_fadeGain), withFADE_DURATION = 0.5 s. - Active / playing flags (
_active,_playing) for mission lifecycle and pause state. - The static
TRACKStable of 60 entries (id, display name, CDN URL) built from filename list at module load. - Hearted-track and skipped-track sets, persisted to
localStorageunder keysss_music_heartsandss_music_skips. - A one-time prefs-dump flag (
ss_music_prefs_dumped) gating the initial telemetry upload of existing preferences. - Weighted-shuffle queue construction (
_buildQueue): hearted tracks added 3× to the pool, skipped tracks excluded, Fisher-Yates shuffle, consecutive-duplicate dedupe. - Preload window policy: next 3 queued tracks preloaded, current + last 3 history tracks kept warm, everything else evicted.
- Mapping from a 0..1 warp tween to music-bus low-pass cutoff (exponential interpolation in log-Hz, 1000 Hz → 400 Hz) and current-track
playbackRate(1.0 → 0.8). - Volume-step constant (
VOLUME_STEP = 0.1, ten taps from 0 → 1).
READS FROM
./audio-context—AudioBus.getCtx(),AudioBus.getMusicBus(),AudioBus.getMusicLowpass(),AudioBus.getMusicVolume(),AudioBus.resume().@metagame/services/analytics—trackEventfor music_prefs_dump, music_skip, music_heart, music_unheart events.localStorage(HEARTS_KEY,SKIPS_KEY,PREFS_DUMPED_KEY) — heart / skip sets and the prefs-dump flag.- The static
MUSIC_CDNconstant (Supabase Storage public URL) plus theMUSIC_SPEED_VARIANT = 'lo16'subfolder selector to build per-track file URLs. AudioContext.currentTimefor all envelope scheduling (setValueAtTime,exponentialRampToValueAtTime,setTargetAtTime,cancelScheduledValues).- The browser’s
Audioelement constructor, withcrossOrigin = 'anonymous'andpreload = 'auto'.
PUSHES TO
AudioBus.setMusicVolume(v)— driven byvolumeUp/volumeDowncontrols.- The music low-pass filter retrieved via
AudioBus.getMusicLowpass()—frequency.setTargetAtTime(...)per warp-intensity update. - The music bus node retrieved via
AudioBus.getMusicBus()— every track’s per-track_fadeGainconnects into it (and into the cached source’sMediaElementSourceupstream). localStorage— writes the heart and skip sets via_saveSet, and the one-time prefs-dump flag.trackEvent(analytics) —music_prefs_dump(once),music_skip,music_heart,music_unheartpayloads.- The current
HTMLAudioElement—play(),pause(),currentTime,playbackRate,onendedhandler.
DOES NOT
- Does not own or create the
AudioContext, the music bus, the low-pass filter, the compressor, the master gain, or the destination — those live inaudio-context.tsand are read viaAudioBus. - Does not play any audio outside an active mission.
start()arms the player; beforestart()or afterstop()all controls (toggle,next,previous,heart,seek) are no-ops when_currentis null and_activeis false. - Does not perform user-gesture unlock itself — relies on the caller to invoke
start()after a user gesture and callsAudioBus.resume()defensively. - Does not fetch or stream tracks from anywhere except the hardcoded Supabase Storage
musicbucket; no fallback CDN, no local files when the speed variant is set. - Does not sync hearts / skips to a server or across devices —
localStorageonly, plus a one-time telemetry dump. - Does not retry on autoplay-blocked play. If
audio.play()rejects, it skips immediately to the next track. - Does not clear history on
stop()— history is preserved to keep the preload cache warm for the next mission. - Does not de-duplicate the queue beyond suppressing consecutive same-track runs; non-adjacent repeats from hearted-track weighting are allowed.
- Does not expose any default export, class, or constructor — only the singleton
MusicPlayerobject.
Signals
- Skipping the current track:
MusicPlayer.next()(always counts as a skip — adds to_skips, removes any heart, emitsmusic_skip, fades out, advances). - Hearting / unhearting the current track:
MusicPlayer.heart()(toggles_hearts, removes the track from_skipson heart, emitsmusic_heart/music_unheart). - Previous-track navigation:
MusicPlayer.previous()(pops from_history, puts current back at front of_queue, fades, plays previous — no skip penalty). - Pause / resume:
MusicPlayer.toggle()(flips_playingand pauses or replays the currentHTMLAudioElement). - Track auto-advance:
HTMLAudioElement.onendedtriggers_advanceToNext(false)(no skip credit). - Warp tween:
MusicPlayer.setWarpIntensity(t)consumes a 0..1 value, sweeps low-pass cutoff (1000 Hz → 400 Hz exponentially) and current-track playbackRate (1.0 → 0.8). - Volume bumps:
volumeUp()/volumeDown()step the music bus volume by 0.1, clamped to [0, 1]. - Seek:
MusicPlayer.seek(time)jumps to a clamped time on the currentHTMLAudioElement. - HUD snapshot:
getState()returns{ trackName, progress, duration, isPlaying, isHearted, volume, active }.
Entry points
MusicPlayer.start()— called when a mission begins (after user gesture). Stops any prior session, resets history, reloads heart/skip sets, fires the one-time prefs dump, builds a fresh queue, and starts the first track with fade-in.MusicPlayer.stop()— called when the mission ends. Clears_active/_playing, hard-disconnects the current track without fade, empties the queue, preserves history.MusicPlayer.toggle()— pause / resume the current track.MusicPlayer.next()— skip to next (counts as a skip).MusicPlayer.previous()— go back to previous track (no skip penalty).MusicPlayer.heart()— toggle heart on the current track.MusicPlayer.volumeUp()/MusicPlayer.volumeDown()— step the music bus volume.MusicPlayer.seek(time)— jump to a time in the current track.MusicPlayer.setWarpIntensity(t)— called per frame from bridge code withship.warpT.MusicPlayer.getState()— HUD-facing snapshot.
Pattern notes
- Singleton module — module-level
letstate (_queue,_history,_current,_playing,_active,_hearts,_skips) plus module-level constants for cache and tracks. No class, no constructor, no instances. - Per-track signal chain:
HTMLAudioElement→MediaElementSourceNode(created once per cache entry, retained) → per-track_fadeGain(created fresh per playback) →AudioBus.getMusicBus(). Downstream lowpass / compressor / masterGain belong toaudio-context.ts. - Crossfade is overlap-on-fade:
_fadeOutCurrent()schedules an exponential ramp to 0.0001 and resolves afterFADE_DURATION * 1000 ms; the next track’s_playTrackschedules its own fade-in via a new gain node on the next event loop turn. Cleanup uses an identity check (_current === currentRef) to avoid disconnecting a node that a newer playback already replaced. - Envelopes use
exponentialRampToValueAtTime(never linear), starting from0.0001rather than 0 because exponential ramps cannot target zero.setWarpIntensityusessetTargetAtTimewith a 0.05 s time constant to avoid clicks while remaining snappy against the upstream 0.25 s warp tween. - Weighted shuffle: pool is built by inserting hearted tracks 3× before Fisher-Yates, then consecutive duplicates are dropped. Non-adjacent repeats from hearted weighting are intentional.
- Preload window: when a track starts,
_preloadAhead()ensures the queue has ≥ 3 entries (rebuilds if not), preloads the next 3, keeps the last 3 history entries warm, and evicts everything else. Eviction callsaudio.pause(), blankssrc, and disconnects theMediaElementSourceNode. MediaElementSourceNodeis created on first play of a cached element and kept attached for the lifetime of the cache entry — re-creating it on the sameHTMLMediaElementthrows.localStorageaccess is wrapped in try/catch on every read and write — corrupt JSON or unavailable storage degrades silently to an empty set.- Track URLs are built once at module load via
.map. Filename casing is preserved forname(HUD display);idis lowercased;fileURL-encodes the filename +.mp3. Filenames are pre-sanitised (no apostrophes,&→ “and”). TheMUSIC_SPEED_VARIANTconstant selects a subfolder of the bucket (lo16currently); set to''to use unprocessed originals. - Autoplay-blocked plays are treated as skips —
audio.play().catch(() => _advanceToNext(false)). No retry, no UI prompt. _advanceToNextshort-circuits when_activeis false, so astop()mid-fade cannot resurrect playback._historyis capped at 10 entries viashift(). The preload window only keeps the last 3 history tracks warm.- Warp interpolation is exponential in log-frequency space (
Math.exp(logNormal + (logWarped - logNormal) * t)) because filter sweeps feel natural in log frequency — 1000 Hz → 400 Hz is roughly a 1.3-octave drop. - Hard
_disconnectCurrentis used bystop()(no fade), soft_fadeOutCurrentis used bynext()/previous()/_advanceToNext. - The prefs-dump telemetry is one-shot per device — guarded by the
PREFS_DUMPED_KEYflag set after the first dump, regardless of whether the dump payload was non-empty.