Skip to content

Commit 233a6f3

Browse files
committed
Add bb.s3m soundtrack playback via libopenmpt
bb never compiled in sound (libmikmod is missing, per its own configure output), and there's no realistic path to changing that: WASI has no audio API at all, so even a working libmikmod couldn't play anything from inside the wasm module. Plays bb's actual bb.s3m independently, client-side, via libopenmpt's official prebuilt WebAssembly build (BSD-3-Clause/BSL-1.0) -- fetched at Docker build time alongside everything else, not vendored. Not synced to whichever scene bb.wasm is currently rendering; there's no signal from the wasm side to sync to, just a soundtrack loop toggled by a button (autoplay policy requires a user gesture regardless). This particular libopenmpt.js build doesn't expose HEAPU8/HEAPF32/ccall on the Module object -- only _malloc/_free and the _openmpt_* C functions -- so there's no supported way to read or write the bytes a _malloc'd pointer refers to. Worked around it by wrapping WebAssembly.instantiate(Streaming) in index.html before loading libopenmpt.js, capturing the real wasm instance's exports regardless of what the module chooses to expose, then finding the one export that's a WebAssembly.Memory (even export names are minified in this build, no literal "memory" key to look up). audio.js is wrapped in an IIFE: libopenmpt.js is a classic script too, and litters the shared global scope with its own same-named internals (wasmMemory, node, start all collided) -- discovered the hard way when a same-named local function got silently clobbered. Claude-Session: https://claude.ai/code/session_01F6QKUwwiAxLNkYn4dv5TfW
1 parent ea8fce3 commit 233a6f3

4 files changed

Lines changed: 163 additions & 3 deletions

File tree

web/Dockerfile

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,22 @@ RUN cp /usr/share/misc/config.guess /usr/share/misc/config.sub . \
8686
&& ./configure --host=wasm32-wasi \
8787
&& make LDFLAGS="-static"
8888

89+
# bb never compiled in sound (libmikmod is missing, per its own configure
90+
# output), and there's no realistic path to changing that: WASI has no
91+
# audio API at all, so even a working libmikmod couldn't play anything
92+
# from inside the wasm module. Playing bb's actual soundtrack means doing
93+
# it independently, client-side, with an existing tracker-module player --
94+
# libopenmpt (BSD-3-Clause/BSL-1.0) is the mature, actively maintained one,
95+
# with an official prebuilt WebAssembly build for exactly this.
96+
FROM --platform=$BUILDPLATFORM debian:trixie-slim AS openmpt
97+
RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \
98+
&& rm -rf /var/lib/apt/lists/*
99+
ARG LIBOPENMPT_VERSION=0.8.7
100+
RUN curl -fL "https://lib.openmpt.org/files/libopenmpt/dev/libopenmpt-${LIBOPENMPT_VERSION}+release.dev.js.tar.gz" | tar -xz -C /opt \
101+
&& mv "/opt/libopenmpt-${LIBOPENMPT_VERSION}+release/bin/wasm" /opt/libopenmpt
102+
89103
FROM scratch AS export
90104
COPY --from=build /bb-1.3.0/bb /bb.wasm
91-
COPY web/index.html web/wasi-shim.js web/worker.js web/app.js web/bb-logo.jpg /
105+
COPY --from=build /bb-1.3.0/bb.s3m /bb.s3m
106+
COPY --from=openmpt /opt/libopenmpt/libopenmpt.js /opt/libopenmpt/libopenmpt.wasm /
107+
COPY web/index.html web/wasi-shim.js web/worker.js web/app.js web/audio.js web/bb-logo.jpg /

web/app.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ function startWorker(size) {
6363
}
6464

6565
function fitFont() {
66-
const reserved = ["logo", "sizes", "status"]
66+
const reserved = ["logo", "sizes", "music", "status"]
6767
.map((id) => document.getElementById(id))
6868
.reduce((sum, el) => sum + (el && el.isConnected ? el.getBoundingClientRect().height + 12 : 0), 0);
6969
const footer = document.querySelector("footer").getBoundingClientRect().height;

web/audio.js

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
// bb never compiled in sound (libmikmod is missing, and WASI has no audio
2+
// API regardless -- there's no path to the wasm module playing anything
3+
// itself). This plays bb's actual bb.s3m soundtrack independently,
4+
// client-side, via libopenmpt (loaded as a classic script by index.html,
5+
// which sets the global `libopenmpt`/`Module` this depends on). Not
6+
// synced to whichever scene bb.wasm is currently rendering -- there's no
7+
// signal from the wasm side to sync to -- just a soundtrack loop.
8+
//
9+
// Wrapped in an IIFE: libopenmpt.js is also a classic (non-module) script
10+
// and litters the shared global scope with its own internal `var`s (e.g.
11+
// wasmMemory, node, start all collide with obvious names -- discovered
12+
// the hard way when `function wasmMemory(){}` here got silently
13+
// clobbered by its same-named global).
14+
(function () {
15+
const BUFFER_FRAMES = 4096;
16+
17+
let audioCtx = null;
18+
let audioNode = null;
19+
let modPtr = 0;
20+
let outPtr = 0;
21+
let playing = false;
22+
23+
// This build of libopenmpt.js doesn't expose memory-access helpers on
24+
// the Module object -- index.html captures the real wasm instance
25+
// exports by wrapping WebAssembly.instantiate(Streaming) instead. Even
26+
// export names are minified in this build (no "memory" key to look
27+
// up), so find the one export that's actually a WebAssembly.Memory
28+
// instead. Fetch fresh views each time rather than caching: growing
29+
// memory detaches old ArrayBuffers.
30+
function findWasmMemory() {
31+
for (const value of Object.values(window.__wasmExports)) {
32+
if (value instanceof WebAssembly.Memory) return value;
33+
}
34+
throw new Error("no WebAssembly.Memory export found on libopenmpt.wasm");
35+
}
36+
const memU8 = () => new Uint8Array(findWasmMemory().buffer);
37+
const memF32 = () => new Float32Array(findWasmMemory().buffer);
38+
39+
function whenReady(cb) {
40+
const Module = window.libopenmpt;
41+
if (Module.calledRun) cb(Module);
42+
else {
43+
const prev = Module.onRuntimeInitialized;
44+
Module.onRuntimeInitialized = () => {
45+
prev?.();
46+
cb(Module);
47+
};
48+
}
49+
}
50+
51+
async function startMusic(button) {
52+
button.disabled = true;
53+
button.textContent = "Loading…";
54+
const Module = await new Promise((resolve) => whenReady(resolve));
55+
56+
const data = new Uint8Array(await (await fetch("bb.s3m")).arrayBuffer());
57+
const dataPtr = Module._malloc(data.length);
58+
memU8().set(data, dataPtr);
59+
modPtr = Module._openmpt_module_create_from_memory(dataPtr, data.length, 0, 0, 0);
60+
Module._free(dataPtr);
61+
if (!modPtr) {
62+
button.textContent = "Failed to load bb.s3m";
63+
return;
64+
}
65+
Module._openmpt_module_set_repeat_count(modPtr, -1); // loop forever
66+
67+
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
68+
outPtr = Module._malloc(BUFFER_FRAMES * 2 * 4); // interleaved stereo float32
69+
audioNode = audioCtx.createScriptProcessor(BUFFER_FRAMES, 0, 2);
70+
audioNode.onaudioprocess = (event) => {
71+
const rendered = Module._openmpt_module_read_interleaved_float_stereo(
72+
modPtr,
73+
audioCtx.sampleRate,
74+
BUFFER_FRAMES,
75+
outPtr,
76+
);
77+
const interleaved = memF32().subarray(outPtr / 4, outPtr / 4 + rendered * 2);
78+
const left = event.outputBuffer.getChannelData(0);
79+
const right = event.outputBuffer.getChannelData(1);
80+
for (let i = 0; i < rendered; i++) {
81+
left[i] = interleaved[i * 2];
82+
right[i] = interleaved[i * 2 + 1];
83+
}
84+
for (let i = rendered; i < BUFFER_FRAMES; i++) left[i] = right[i] = 0;
85+
};
86+
audioNode.connect(audioCtx.destination);
87+
88+
playing = true;
89+
button.disabled = false;
90+
button.textContent = "🔊 Pause music";
91+
}
92+
93+
function stopMusic(button) {
94+
audioCtx.suspend();
95+
playing = false;
96+
button.textContent = "🔇 Play music";
97+
}
98+
99+
function resumeMusic(button) {
100+
audioCtx.resume();
101+
playing = true;
102+
button.textContent = "🔊 Pause music";
103+
}
104+
105+
const button = document.getElementById("music");
106+
button.onclick = () => {
107+
if (!audioCtx) startMusic(button);
108+
else if (playing) stopMusic(button);
109+
else resumeMusic(button);
110+
};
111+
})();

web/index.html

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,16 @@
5555
}
5656
#sizes button:hover { border-color: #666; color: #ccc; }
5757
#sizes button[aria-pressed="true"] { color: #0f0; border-color: #0f0; }
58+
#music {
59+
background: #111;
60+
color: #999;
61+
border: 1px solid #333;
62+
font: inherit;
63+
font-size: 0.8rem;
64+
padding: 0.2rem 0.6rem;
65+
cursor: pointer;
66+
}
67+
#music:hover { border-color: #666; color: #ccc; }
5868
footer {
5969
color: #444;
6070
font-size: 0.75rem;
@@ -73,6 +83,7 @@
7383
<body>
7484
<img id="logo" src="bb-logo.jpg" alt="bb logo">
7585
<div id="sizes"></div>
86+
<button id="music">🔇 Play music</button>
7687
<pre id="screen"></pre>
7788
<div id="status">Loading bb.wasm&hellip;</div>
7889
<footer>
@@ -82,7 +93,8 @@
8293
Original: <a href="https://aa-project.sourceforge.net/aalib/">aa-lib</a>
8394
&middot;
8495
<a href="https://aa-project.sourceforge.net/bb/">bb</a>
85-
(last modified Wed Mar 26 1997)
96+
(last modified Wed Mar 26 1997).
97+
Music playback: <a href="https://lib.openmpt.org/libopenmpt/">libopenmpt</a>
8698
</div>
8799
<div class="blurb">
88100
&ldquo;This demo requires computer at least as fast as 486/33 with coprocesor.
@@ -96,5 +108,26 @@
96108
</div>
97109
</footer>
98110
<script type="module" src="app.js"></script>
111+
<script>
112+
// This build of libopenmpt.js doesn't expose HEAPU8/HEAPF32/ccall/etc on
113+
// the Module object (only _malloc/_free and the _openmpt_* functions),
114+
// so there's no supported way to read or write the bytes _malloc'd
115+
// pointers refer to. Capture the wasm instance's own exports -- the
116+
// memory it holds is real regardless of what the module chooses to
117+
// expose -- by wrapping the WebAssembly APIs it instantiates itself
118+
// with, and build our own typed array views directly over its buffer.
119+
window.libopenmpt = {};
120+
for (const name of ["instantiateStreaming", "instantiate"]) {
121+
const orig = WebAssembly[name];
122+
WebAssembly[name] = async function (...args) {
123+
const result = await orig.apply(WebAssembly, args);
124+
const instance = result.instance || result;
125+
window.__wasmExports = instance.exports;
126+
return result;
127+
};
128+
}
129+
</script>
130+
<script src="libopenmpt.js"></script>
131+
<script src="audio.js"></script>
99132
</body>
100133
</html>

0 commit comments

Comments
 (0)