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:
Generated
+7
@@ -10,6 +10,7 @@
|
||||
"dependencies": {
|
||||
"@react-three/drei": "^10",
|
||||
"@react-three/fiber": "^9",
|
||||
"chiptune3": "0.8.7",
|
||||
"react": "^19",
|
||||
"react-dom": "^19",
|
||||
"three": "^0.177.0",
|
||||
@@ -1828,6 +1829,12 @@
|
||||
"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": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"dependencies": {
|
||||
"@react-three/drei": "^10",
|
||||
"@react-three/fiber": "^9",
|
||||
"chiptune3": "0.8.7",
|
||||
"react": "^19",
|
||||
"react-dom": "^19",
|
||||
"three": "^0.177.0",
|
||||
|
||||
Binary file not shown.
+4
-1
@@ -1,3 +1,4 @@
|
||||
import { useMemo } from 'react'
|
||||
import { Canvas } from '@react-three/fiber'
|
||||
import { CameraRig } from './scene/CameraRig'
|
||||
import { World } from './scene/World'
|
||||
@@ -8,9 +9,11 @@ import { Stops } from './scene/stops/Stops'
|
||||
import { useRideInput } from './ride/useRideInput'
|
||||
import { Scroller } from './hud/Scroller'
|
||||
import { TrackerPanel } from './hud/TrackerPanel'
|
||||
import { createModPlayer } from './audio/modPlayer'
|
||||
|
||||
export default function App() {
|
||||
useRideInput()
|
||||
const player = useMemo(() => createModPlayer(), [])
|
||||
return (
|
||||
<div id="demo">
|
||||
<Canvas
|
||||
@@ -29,7 +32,7 @@ export default function App() {
|
||||
<Stops />
|
||||
</Canvas>
|
||||
<Scroller />
|
||||
<TrackerPanel player={null} />
|
||||
<TrackerPanel player={player} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Vendored
+5
@@ -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'
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -6,3 +6,82 @@ export interface ModPlayer {
|
||||
setMuted(muted: boolean): 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
|
||||
}
|
||||
|
||||
@@ -12,4 +12,5 @@ export const SCROLLER_TEXT =
|
||||
'CLEMENT SAILLANT PRESENTS *** LE BUS *** UN HUB PERSO FACON DEMOSCENE *** ' +
|
||||
'GREETINGS TO ' + GREETINGS.join(' + ').toUpperCase() + ' *** ' +
|
||||
'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
@@ -1,3 +1,5 @@
|
||||
import { copyFileSync, existsSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { defineConfig, type Plugin } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
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({
|
||||
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'],
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user