diff --git a/package-lock.json b/package-lock.json
index f028890..c66cb54 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/package.json b/package.json
index dc55d60..1869a51 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/public/music/ride.mod b/public/music/ride.mod
new file mode 100644
index 0000000..0bd1146
Binary files /dev/null and b/public/music/ride.mod differ
diff --git a/src/App.tsx b/src/App.tsx
index 14054e4..b605667 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -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 (
-
+
)
}
diff --git a/src/audio/chiptune3.d.ts b/src/audio/chiptune3.d.ts
new file mode 100644
index 0000000..7f9bc1b
--- /dev/null
+++ b/src/audio/chiptune3.d.ts
@@ -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'
diff --git a/src/audio/modPlayer.test.ts b/src/audio/modPlayer.test.ts
new file mode 100644
index 0000000..533f219
--- /dev/null
+++ b/src/audio/modPlayer.test.ts
@@ -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)
+ })
+})
diff --git a/src/audio/modPlayer.ts b/src/audio/modPlayer.ts
index 32fbd58..c1c18f3 100644
--- a/src/audio/modPlayer.ts
+++ b/src/audio/modPlayer.ts
@@ -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
+ fetchMod?: () => Promise
+}
+
+async function defaultLoadLib(): Promise {
+ // 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 {
+ 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((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
+}
diff --git a/src/content/greetings.ts b/src/content/greetings.ts
index 0ee143f..6ccb67f 100644
--- a/src/content/greetings.ts
+++ b/src/content/greetings.ts
@@ -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) *** '
diff --git a/vite.config.ts b/vite.config.ts
index def65cb..9b2cbfe 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -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'],
+ },
})