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();