feat: add lazy mod tracker player

Implement createModPlayer with a TDD-first ModPlayer contract
(idle/loading/playing/paused/unavailable), wired into App and
TrackerPanel. Uses chiptune3@0.8.7, loaded lazily via a deep
import (the package ships no main/exports field). Adds two Vite
fixes needed for the nested worklet asset to actually load: copy
libopenmpt.worklet.js next to the built worklet chunk, and
exclude chiptune3 from dep pre-bundling so dev mode resolves the
same sibling file.

Ships public/music/ride.mod: "11th-hour" by TDK, CC BY 4.0, from
The Mod Archive (modarchive.org, module ID 67104). Credited in
the HUD scroller text.
This commit is contained in:
L'électron rare
2026-07-09 12:05:15 +02:00
parent fc5621bd24
commit 7ca96d049a
9 changed files with 199 additions and 3 deletions
+7
View File
@@ -10,6 +10,7 @@
"dependencies": { "dependencies": {
"@react-three/drei": "^10", "@react-three/drei": "^10",
"@react-three/fiber": "^9", "@react-three/fiber": "^9",
"chiptune3": "0.8.7",
"react": "^19", "react": "^19",
"react-dom": "^19", "react-dom": "^19",
"three": "^0.177.0", "three": "^0.177.0",
@@ -1828,6 +1829,12 @@
"node": ">= 16" "node": ">= 16"
} }
}, },
"node_modules/chiptune3": {
"version": "0.8.7",
"resolved": "https://registry.npmjs.org/chiptune3/-/chiptune3-0.8.7.tgz",
"integrity": "sha512-lFK7Lk146nijMkObg1FnWClvQLq5Ih/F+sV7bA1Cqx/9zmdTmCG8YLZIm51M/HRp4a6DfI79q1m2I1peGGjiig==",
"license": "MIT"
},
"node_modules/convert-source-map": { "node_modules/convert-source-map": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+1
View File
@@ -13,6 +13,7 @@
"dependencies": { "dependencies": {
"@react-three/drei": "^10", "@react-three/drei": "^10",
"@react-three/fiber": "^9", "@react-three/fiber": "^9",
"chiptune3": "0.8.7",
"react": "^19", "react": "^19",
"react-dom": "^19", "react-dom": "^19",
"three": "^0.177.0", "three": "^0.177.0",
Binary file not shown.
+4 -1
View File
@@ -1,3 +1,4 @@
import { useMemo } from 'react'
import { Canvas } from '@react-three/fiber' import { Canvas } from '@react-three/fiber'
import { CameraRig } from './scene/CameraRig' import { CameraRig } from './scene/CameraRig'
import { World } from './scene/World' import { World } from './scene/World'
@@ -8,9 +9,11 @@ import { Stops } from './scene/stops/Stops'
import { useRideInput } from './ride/useRideInput' import { useRideInput } from './ride/useRideInput'
import { Scroller } from './hud/Scroller' import { Scroller } from './hud/Scroller'
import { TrackerPanel } from './hud/TrackerPanel' import { TrackerPanel } from './hud/TrackerPanel'
import { createModPlayer } from './audio/modPlayer'
export default function App() { export default function App() {
useRideInput() useRideInput()
const player = useMemo(() => createModPlayer(), [])
return ( return (
<div id="demo"> <div id="demo">
<Canvas <Canvas
@@ -29,7 +32,7 @@ export default function App() {
<Stops /> <Stops />
</Canvas> </Canvas>
<Scroller /> <Scroller />
<TrackerPanel player={null} /> <TrackerPanel player={player} />
</div> </div>
) )
} }
+5
View File
@@ -0,0 +1,5 @@
// chiptune3@0.8.7 ships no type declarations and its package.json has no
// "main"/"exports" field (only "browser"), so both the bare specifier and
// this deep import need help: the deep path resolves at runtime (Vite +
// Node both find the file), this ambient declaration just satisfies tsc.
declare module 'chiptune3/chiptune3.js'
+64
View File
@@ -0,0 +1,64 @@
import { describe, expect, it, vi } from 'vitest'
import { createModPlayer } from './modPlayer'
function fakeLib() {
const instance = {
play: vi.fn(),
togglePause: vi.fn(),
setVol: vi.fn(),
onInitialized: (cb: () => void) => cb(),
}
return { lib: { ChiptuneJsPlayer: vi.fn(() => instance) }, instance }
}
describe('createModPlayer', () => {
it('loads lib + module then plays on first toggle', async () => {
const { lib, instance } = fakeLib()
const player = createModPlayer({
loadLib: async () => lib,
fetchMod: async () => new ArrayBuffer(8),
})
expect(player.state).toBe('idle')
const state = await player.toggle()
expect(state).toBe('playing')
expect(instance.play).toHaveBeenCalledOnce()
})
it('toggles pause/resume without reloading', async () => {
const { lib, instance } = fakeLib()
const player = createModPlayer({
loadLib: async () => lib,
fetchMod: async () => new ArrayBuffer(8),
})
await player.toggle()
expect(await player.toggle()).toBe('paused')
expect(await player.toggle()).toBe('playing')
expect(instance.togglePause).toHaveBeenCalledTimes(2)
expect(lib.ChiptuneJsPlayer).toHaveBeenCalledOnce()
})
it('reports unavailable when the module cannot be fetched', async () => {
const { lib } = fakeLib()
const seen: string[] = []
const player = createModPlayer({
loadLib: async () => lib,
fetchMod: async () => { throw new Error('404') },
})
player.onChange((s) => seen.push(s))
expect(await player.toggle()).toBe('unavailable')
expect(seen).toContain('unavailable')
})
it('mute calls setVol 0 and restores', async () => {
const { lib, instance } = fakeLib()
const player = createModPlayer({
loadLib: async () => lib,
fetchMod: async () => new ArrayBuffer(8),
})
await player.toggle()
player.setMuted(true)
player.setMuted(false)
expect(instance.setVol).toHaveBeenCalledWith(0)
expect(instance.setVol).toHaveBeenLastCalledWith(1)
})
})
+79
View File
@@ -6,3 +6,82 @@ export interface ModPlayer {
setMuted(muted: boolean): void setMuted(muted: boolean): void
onChange(fn: (s: ModState) => void): void onChange(fn: (s: ModState) => void): void
} }
export const MOD_URL = '/music/ride.mod'
// chiptune3's real ChiptuneJsPlayer exposes far more (stop, pause, unpause,
// setPitch, onMetadata, ...) — only the subset this player actually drives
// is modeled here. Constructor takes an optional config object (defaults
// fine for our use), so `new () => ChiptuneInstance` still matches.
interface ChiptuneInstance {
play(buf: ArrayBuffer): void
togglePause(): void
setVol(v: number): void
onInitialized(cb: () => void): void
}
export interface ChiptuneLib {
ChiptuneJsPlayer: new () => ChiptuneInstance
}
export interface PlayerDeps {
loadLib?: () => Promise<ChiptuneLib>
fetchMod?: () => Promise<ArrayBuffer>
}
async function defaultLoadLib(): Promise<ChiptuneLib> {
// Lazy: the WASM lib is only pulled after first user interaction.
// Deep import: chiptune3@0.8.7's package.json has no "main"/"exports"
// field (only "browser"), so the bare specifier "chiptune3" fails to
// resolve under both plain Node and Vite's import analysis.
return (await import('chiptune3/chiptune3.js')) as unknown as ChiptuneLib
}
async function defaultFetchMod(): Promise<ArrayBuffer> {
const res = await fetch(MOD_URL)
if (!res.ok) throw new Error(`mod fetch failed: ${res.status}`)
return res.arrayBuffer()
}
export function createModPlayer(deps: PlayerDeps = {}): ModPlayer {
const loadLib = deps.loadLib ?? defaultLoadLib
const fetchMod = deps.fetchMod ?? defaultFetchMod
let instance: ChiptuneInstance | null = null
const listeners: Array<(s: ModState) => void> = []
const player: ModPlayer = {
state: 'idle',
async toggle() {
if (player.state === 'unavailable' || player.state === 'loading') return player.state
if (player.state === 'idle') {
setState('loading')
try {
const [lib, buf] = await Promise.all([loadLib(), fetchMod()])
instance = new lib.ChiptuneJsPlayer()
await new Promise<void>((res) => instance!.onInitialized(res))
instance.play(buf)
setState('playing')
} catch {
setState('unavailable')
}
return player.state
}
instance!.togglePause()
setState(player.state === 'playing' ? 'paused' : 'playing')
return player.state
},
setMuted(muted) {
instance?.setVol(muted ? 0 : 1)
},
onChange(fn) {
listeners.push(fn)
},
}
function setState(s: ModState) {
player.state = s
listeners.forEach((fn) => fn(s))
}
return player
}
+2 -1
View File
@@ -12,4 +12,5 @@ export const SCROLLER_TEXT =
'CLEMENT SAILLANT PRESENTS *** LE BUS *** UN HUB PERSO FACON DEMOSCENE *** ' + 'CLEMENT SAILLANT PRESENTS *** LE BUS *** UN HUB PERSO FACON DEMOSCENE *** ' +
'GREETINGS TO ' + GREETINGS.join(' + ').toUpperCase() + ' *** ' + 'GREETINGS TO ' + GREETINGS.join(' + ').toUpperCase() + ' *** ' +
'MONTEZ DANS LE BUS — PROCHAIN ARRET : IDENTITES *** ' + 'MONTEZ DANS LE BUS — PROCHAIN ARRET : IDENTITES *** ' +
'PAS DE COOKIES, PAS DE TRACKERS, JUSTE DES TRAMES I2C *** ' 'PAS DE COOKIES, PAS DE TRACKERS, JUSTE DES TRAMES I2C *** ' +
'MUSIC : 11TH-HOUR BY TDK (CC BY 4.0) *** '
+37 -1
View File
@@ -1,3 +1,5 @@
import { copyFileSync, existsSync } from 'node:fs'
import { resolve } from 'node:path'
import { defineConfig, type Plugin } from 'vite' import { defineConfig, type Plugin } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import { renderFallbackHtml } from './src/fallback/inject' import { renderFallbackHtml } from './src/fallback/inject'
@@ -9,6 +11,40 @@ function fallbackPlugin(): Plugin {
} }
} }
// chiptune3's chiptune3.worklet.js does `import libopenmptPromise from
// './libopenmpt.worklet.js'` — a plain relative import. Vite's `new URL(...,
// import.meta.url)` handling (used internally by chiptune3.js to load the
// worklet) copies chiptune3.worklet.js into the build output verbatim but
// does not follow that nested relative import, so libopenmpt.worklet.js is
// missing from `dist/` and AudioWorklet.addModule() fails at runtime
// ("Unable to load a worklet's module."). Copy the sibling file next to the
// emitted worklet chunk so the relative import resolves in prod too.
function chiptuneWorkletFix(): Plugin {
let outDir = 'dist'
let assetsDir = 'assets'
return {
name: 'chiptune3-worklet-fix',
configResolved(config) {
outDir = config.build.outDir
assetsDir = config.build.assetsDir
},
closeBundle() {
const src = resolve('node_modules/chiptune3/libopenmpt.worklet.js')
if (!existsSync(src)) return
const dest = resolve(outDir, assetsDir, 'libopenmpt.worklet.js')
copyFileSync(src, dest)
},
}
}
export default defineConfig({ export default defineConfig({
plugins: [react(), fallbackPlugin()], plugins: [react(), fallbackPlugin(), chiptuneWorkletFix()],
optimizeDeps: {
// In dev, esbuild's dep pre-bundling copies chiptune3.js into
// node_modules/.vite/deps/, which breaks its internal
// `new URL('./chiptune3.worklet.js', import.meta.url)` (the worklet
// sibling file isn't copied along). Excluding it keeps chiptune3
// served straight from node_modules, where the relative lookup works.
exclude: ['chiptune3'],
},
}) })