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 an HTMLAudioElement plus its permanently-bound MediaElementAudioSourceNode.
  • Per-track fade-in / fade-out envelopes via a per-track GainNode (the _fadeGain), with FADE_DURATION = 0.5 s.
  • Active / playing flags (_active, _playing) for mission lifecycle and pause state.
  • The static TRACKS table of 60 entries (id, display name, CDN URL) built from filename list at module load.
  • Hearted-track and skipped-track sets, persisted to localStorage under keys ss_music_hearts and ss_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-contextAudioBus.getCtx(), AudioBus.getMusicBus(), AudioBus.getMusicLowpass(), AudioBus.getMusicVolume(), AudioBus.resume().
  • @metagame/services/analyticstrackEvent for 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_CDN constant (Supabase Storage public URL) plus the MUSIC_SPEED_VARIANT = 'lo16' subfolder selector to build per-track file URLs.
  • AudioContext.currentTime for all envelope scheduling (setValueAtTime, exponentialRampToValueAtTime, setTargetAtTime, cancelScheduledValues).
  • The browser’s Audio element constructor, with crossOrigin = 'anonymous' and preload = 'auto'.

PUSHES TO

  • AudioBus.setMusicVolume(v) — driven by volumeUp / volumeDown controls.
  • 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 _fadeGain connects into it (and into the cached source’s MediaElementSource upstream).
  • 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_unheart payloads.
  • The current HTMLAudioElementplay(), pause(), currentTime, playbackRate, onended handler.

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 in audio-context.ts and are read via AudioBus.
  • Does not play any audio outside an active mission. start() arms the player; before start() or after stop() all controls (toggle, next, previous, heart, seek) are no-ops when _current is null and _active is false.
  • Does not perform user-gesture unlock itself — relies on the caller to invoke start() after a user gesture and calls AudioBus.resume() defensively.
  • Does not fetch or stream tracks from anywhere except the hardcoded Supabase Storage music bucket; no fallback CDN, no local files when the speed variant is set.
  • Does not sync hearts / skips to a server or across devices — localStorage only, 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 MusicPlayer object.

Signals

  • Skipping the current track: MusicPlayer.next() (always counts as a skip — adds to _skips, removes any heart, emits music_skip, fades out, advances).
  • Hearting / unhearting the current track: MusicPlayer.heart() (toggles _hearts, removes the track from _skips on heart, emits music_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 _playing and pauses or replays the current HTMLAudioElement).
  • Track auto-advance: HTMLAudioElement.onended triggers _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 current HTMLAudioElement.
  • 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 with ship.warpT.
  • MusicPlayer.getState() — HUD-facing snapshot.

Pattern notes

  • Singleton module — module-level let state (_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: HTMLAudioElementMediaElementSourceNode (created once per cache entry, retained) → per-track _fadeGain (created fresh per playback) → AudioBus.getMusicBus(). Downstream lowpass / compressor / masterGain belong to audio-context.ts.
  • Crossfade is overlap-on-fade: _fadeOutCurrent() schedules an exponential ramp to 0.0001 and resolves after FADE_DURATION * 1000 ms; the next track’s _playTrack schedules 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 from 0.0001 rather than 0 because exponential ramps cannot target zero. setWarpIntensity uses setTargetAtTime with 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 calls audio.pause(), blanks src, and disconnects the MediaElementSourceNode.
  • MediaElementSourceNode is created on first play of a cached element and kept attached for the lifetime of the cache entry — re-creating it on the same HTMLMediaElement throws.
  • localStorage access 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 for name (HUD display); id is lowercased; file URL-encodes the filename + .mp3. Filenames are pre-sanitised (no apostrophes, & → “and”). The MUSIC_SPEED_VARIANT constant selects a subfolder of the bucket (lo16 currently); set to '' to use unprocessed originals.
  • Autoplay-blocked plays are treated as skips — audio.play().catch(() => _advanceToNext(false)). No retry, no UI prompt.
  • _advanceToNext short-circuits when _active is false, so a stop() mid-fade cannot resurrect playback.
  • _history is capped at 10 entries via shift(). 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 _disconnectCurrent is used by stop() (no fade), soft _fadeOutCurrent is used by next() / previous() / _advanceToNext.
  • The prefs-dump telemetry is one-shot per device — guarded by the PREFS_DUMPED_KEY flag set after the first dump, regardless of whether the dump payload was non-empty.