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'], + }, })