From b48fb4b7ded7964e1e3bf4d41fac81feba1b2d7b Mon Sep 17 00:00:00 2001
From: b1rdmania <102524336+b1rdmania@users.noreply.github.com>
Date: Mon, 29 Dec 2025 09:59:56 +0000
Subject: [PATCH] Add MP3 download to UX test page.
Installs lamejs and adds an MP3 downloader that records the Motif synth output in real time via an output tap, then encodes and downloads as .mp3.
---
package-lock.json | 16 +++++
package.json | 1 +
src/core/MotifEngine.ts | 19 +++++
src/synthesis/SynthesisEngine.ts | 19 +++++
ux-test.html | 116 +++++++++++++++++++++++++++++++
5 files changed, 171 insertions(+)
diff --git a/package-lock.json b/package-lock.json
index 4bc9c3e..90ec076 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,6 +10,7 @@
"license": "MIT",
"dependencies": {
"@tonejs/midi": "^2.0.28",
+ "lamejs": "^1.2.1",
"soundfont-player": "^0.12.0",
"tone": "^15.1.22"
},
@@ -2065,6 +2066,15 @@
"json-buffer": "3.0.1"
}
},
+ "node_modules/lamejs": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/lamejs/-/lamejs-1.2.1.tgz",
+ "integrity": "sha512-s7bxvjvYthw6oPLCm5pFxvA84wUROODB8jEO2+CE1adhKgrIvVOlmMgY8zyugxGrvRaDHNJanOiS21/emty6dQ==",
+ "license": "LGPL-3.0",
+ "dependencies": {
+ "use-strict": "1.0.1"
+ }
+ },
"node_modules/levn": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@@ -2740,6 +2750,12 @@
"punycode": "^2.1.0"
}
},
+ "node_modules/use-strict": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/use-strict/-/use-strict-1.0.1.tgz",
+ "integrity": "sha512-IeiWvvEXfW5ltKVMkxq6FvNf2LojMKvB2OCeja6+ct24S1XOmQw2dGr2JyndwACWAGJva9B7yPHwAmeA9QCqAQ==",
+ "license": "ISC"
+ },
"node_modules/vite": {
"version": "5.4.21",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
diff --git a/package.json b/package.json
index 9b9c120..9f8e1ca 100644
--- a/package.json
+++ b/package.json
@@ -23,6 +23,7 @@
},
"dependencies": {
"@tonejs/midi": "^2.0.28",
+ "lamejs": "^1.2.1",
"soundfont-player": "^0.12.0",
"tone": "^15.1.22"
},
diff --git a/src/core/MotifEngine.ts b/src/core/MotifEngine.ts
index 4df9739..1347a25 100644
--- a/src/core/MotifEngine.ts
+++ b/src/core/MotifEngine.ts
@@ -138,6 +138,25 @@ export class MotifEngine {
}
}
+ /**
+ * Connect an additional output tap node to the synthesis output.
+ * Useful for recording/export without changing the audible output.
+ */
+ connectOutput(node: AudioNode): void {
+ if (this.synthesisEngine) {
+ this.synthesisEngine.connectOutput(node);
+ }
+ }
+
+ /**
+ * Disconnect a previously connected output tap node.
+ */
+ disconnectOutput(node: AudioNode): void {
+ if (this.synthesisEngine) {
+ this.synthesisEngine.disconnectOutput(node);
+ }
+ }
+
seek(progress: number): void {
if (this.synthesisEngine) {
this.synthesisEngine.seek(progress);
diff --git a/src/synthesis/SynthesisEngine.ts b/src/synthesis/SynthesisEngine.ts
index d4eeb5f..38cfab1 100644
--- a/src/synthesis/SynthesisEngine.ts
+++ b/src/synthesis/SynthesisEngine.ts
@@ -19,6 +19,25 @@ export class SynthesisEngine {
this.masterGain.gain.value = 0.3;
}
+ /**
+ * Connect the master output to an additional node (e.g. recorder tap).
+ * This does not change the default destination routing.
+ */
+ connectOutput(node: AudioNode): void {
+ this.masterGain.connect(node);
+ }
+
+ /**
+ * Disconnect a previously connected output tap.
+ */
+ disconnectOutput(node: AudioNode): void {
+ try {
+ this.masterGain.disconnect(node);
+ } catch {
+ // ignore (node might not be connected)
+ }
+ }
+
setupLayers(assignments: RoleAssignment[]): void {
// Clean up existing layers
this.cleanupLayers();
diff --git a/ux-test.html b/ux-test.html
index 094d62b..3fec2f7 100644
--- a/ux-test.html
+++ b/ux-test.html
@@ -418,6 +418,9 @@
+
+
+
@@ -460,6 +463,7 @@
const stopBtn = document.getElementById('stopBtn');
const copyLinkBtn = document.getElementById('copyLinkBtn');
const shareXBtn = document.getElementById('shareXBtn');
+ const downloadMp3Btn = document.getElementById('downloadMp3Btn');
const shareHint = document.getElementById('shareHint');
const searchBtn = document.getElementById('searchBtn');
const songInput = document.getElementById('songInput');
@@ -615,6 +619,7 @@
playBtn.disabled = false;
copyLinkBtn.disabled = false;
shareXBtn.disabled = false;
+ downloadMp3Btn.disabled = false;
setStatus('Ready to play');
shareHint.textContent = '';
@@ -657,6 +662,116 @@
stopVisualizer();
}
+ function downloadBlob(blob, filename) {
+ const a = document.createElement('a');
+ const url = URL.createObjectURL(blob);
+ a.href = url;
+ a.download = filename;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
+ }
+
+ function floatToInt16(input) {
+ const out = new Int16Array(input.length);
+ for (let i = 0; i < input.length; i++) {
+ const s = Math.max(-1, Math.min(1, input[i]));
+ out[i] = s < 0 ? s * 0x8000 : s * 0x7fff;
+ }
+ return out;
+ }
+
+ async function downloadMp3() {
+ if (!currentEvents) return;
+ try {
+ downloadMp3Btn.disabled = true;
+ shareHint.textContent = 'Preparing MP3…';
+
+ // Ensure audio unlocked (user gesture)
+ const audioContext = await unlockAudio();
+
+ // Stop any current playback first
+ stopPlayback();
+
+ // Ensure Motif engine exists
+ if (!motifEngine) motifEngine = new MotifEngine();
+
+ // Use synth output (the "Gameboy" render)
+ await motifEngine.generateFromMIDI(currentEvents, 'procedural');
+ motifEngine.setVolume(1);
+
+ // Lazy-load MP3 encoder
+ const lameMod = await import('lamejs');
+ const Mp3Encoder = lameMod.Mp3Encoder || (lameMod.default && lameMod.default.Mp3Encoder);
+ if (!Mp3Encoder) throw new Error('MP3 encoder not available');
+
+ const channels = 2;
+ const sampleRate = audioContext.sampleRate;
+ const kbps = 128;
+ const encoder = new Mp3Encoder(channels, sampleRate, kbps);
+ const mp3Chunks = [];
+
+ // Tap audio via ScriptProcessorNode
+ const bufferSize = 4096;
+ const processor = audioContext.createScriptProcessor(bufferSize, channels, channels);
+ const sink = audioContext.createGain();
+ sink.gain.value = 0; // avoid doubling audible output
+ processor.connect(sink);
+ sink.connect(audioContext.destination);
+
+ let recording = true;
+ processor.onaudioprocess = (e) => {
+ if (!recording) return;
+ const ib = e.inputBuffer;
+ const left = floatToInt16(ib.getChannelData(0));
+ const right = ib.numberOfChannels > 1 ? floatToInt16(ib.getChannelData(1)) : left;
+
+ // Encode in 1152-sample frames
+ const frameSize = 1152;
+ for (let i = 0; i < left.length; i += frameSize) {
+ const l = left.subarray(i, i + frameSize);
+ const r = right.subarray(i, i + frameSize);
+ const buf = encoder.encodeBuffer(l, r);
+ if (buf && buf.length) mp3Chunks.push(buf);
+ }
+ };
+
+ motifEngine.connectOutput(processor);
+
+ // Start playback (audible) while we capture/encode
+ isPlaying = true;
+ await motifEngine.play();
+ updateVisualizer();
+
+ const durationSec = Math.max(1, motifEngine.getDuration());
+ shareHint.textContent = `Recording… (${Math.round(durationSec)}s)`;
+
+ await new Promise((resolve) => setTimeout(resolve, (durationSec + 0.25) * 1000));
+
+ recording = false;
+ try { motifEngine.disconnectOutput(processor); } catch {}
+ try { processor.disconnect(); } catch {}
+ try { sink.disconnect(); } catch {}
+
+ // Stop playback after capture
+ stopPlayback();
+
+ const tail = encoder.flush();
+ if (tail && tail.length) mp3Chunks.push(tail);
+
+ const blob = new Blob(mp3Chunks, { type: 'audio/mpeg' });
+ const safeTitle = cleanTitleForShare(currentTitle || 'motif').slice(0, 80) || 'motif';
+ downloadBlob(blob, `${safeTitle}.mp3`);
+ shareHint.textContent = 'MP3 downloaded.';
+ window.setTimeout(() => (shareHint.textContent = ''), 1400);
+ } catch (e) {
+ shareHint.textContent = `MP3 failed: ${e && e.message ? e.message : 'Unknown error'}`;
+ } finally {
+ downloadMp3Btn.disabled = false;
+ }
+ }
+
async function copyShareLink() {
if (!currentMidiUrl) return;
const url = await buildShortShareUrl();
@@ -776,6 +891,7 @@
stopBtn.addEventListener('click', stopPlayback);
copyLinkBtn.addEventListener('click', () => void copyShareLink());
shareXBtn.addEventListener('click', shareOnX);
+ downloadMp3Btn.addEventListener('click', () => downloadMp3());
// Init
createBars();