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.
This commit is contained in:
Generated
+16
@@ -10,6 +10,7 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tonejs/midi": "^2.0.28",
|
"@tonejs/midi": "^2.0.28",
|
||||||
|
"lamejs": "^1.2.1",
|
||||||
"soundfont-player": "^0.12.0",
|
"soundfont-player": "^0.12.0",
|
||||||
"tone": "^15.1.22"
|
"tone": "^15.1.22"
|
||||||
},
|
},
|
||||||
@@ -2065,6 +2066,15 @@
|
|||||||
"json-buffer": "3.0.1"
|
"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": {
|
"node_modules/levn": {
|
||||||
"version": "0.4.1",
|
"version": "0.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
||||||
@@ -2740,6 +2750,12 @@
|
|||||||
"punycode": "^2.1.0"
|
"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": {
|
"node_modules/vite": {
|
||||||
"version": "5.4.21",
|
"version": "5.4.21",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tonejs/midi": "^2.0.28",
|
"@tonejs/midi": "^2.0.28",
|
||||||
|
"lamejs": "^1.2.1",
|
||||||
"soundfont-player": "^0.12.0",
|
"soundfont-player": "^0.12.0",
|
||||||
"tone": "^15.1.22"
|
"tone": "^15.1.22"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 {
|
seek(progress: number): void {
|
||||||
if (this.synthesisEngine) {
|
if (this.synthesisEngine) {
|
||||||
this.synthesisEngine.seek(progress);
|
this.synthesisEngine.seek(progress);
|
||||||
|
|||||||
@@ -19,6 +19,25 @@ export class SynthesisEngine {
|
|||||||
this.masterGain.gain.value = 0.3;
|
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 {
|
setupLayers(assignments: RoleAssignment[]): void {
|
||||||
// Clean up existing layers
|
// Clean up existing layers
|
||||||
this.cleanupLayers();
|
this.cleanupLayers();
|
||||||
|
|||||||
+116
@@ -418,6 +418,9 @@
|
|||||||
<button id="copyLinkBtn" disabled>Copy link</button>
|
<button id="copyLinkBtn" disabled>Copy link</button>
|
||||||
<button id="shareXBtn" disabled>Share on X</button>
|
<button id="shareXBtn" disabled>Share on X</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="share-row">
|
||||||
|
<button id="downloadMp3Btn" disabled>Download MP3</button>
|
||||||
|
</div>
|
||||||
<div id="shareHint" class="share-hint"></div>
|
<div id="shareHint" class="share-hint"></div>
|
||||||
|
|
||||||
<div class="volume-row">
|
<div class="volume-row">
|
||||||
@@ -460,6 +463,7 @@
|
|||||||
const stopBtn = document.getElementById('stopBtn');
|
const stopBtn = document.getElementById('stopBtn');
|
||||||
const copyLinkBtn = document.getElementById('copyLinkBtn');
|
const copyLinkBtn = document.getElementById('copyLinkBtn');
|
||||||
const shareXBtn = document.getElementById('shareXBtn');
|
const shareXBtn = document.getElementById('shareXBtn');
|
||||||
|
const downloadMp3Btn = document.getElementById('downloadMp3Btn');
|
||||||
const shareHint = document.getElementById('shareHint');
|
const shareHint = document.getElementById('shareHint');
|
||||||
const searchBtn = document.getElementById('searchBtn');
|
const searchBtn = document.getElementById('searchBtn');
|
||||||
const songInput = document.getElementById('songInput');
|
const songInput = document.getElementById('songInput');
|
||||||
@@ -615,6 +619,7 @@
|
|||||||
playBtn.disabled = false;
|
playBtn.disabled = false;
|
||||||
copyLinkBtn.disabled = false;
|
copyLinkBtn.disabled = false;
|
||||||
shareXBtn.disabled = false;
|
shareXBtn.disabled = false;
|
||||||
|
downloadMp3Btn.disabled = false;
|
||||||
setStatus('Ready to play');
|
setStatus('Ready to play');
|
||||||
shareHint.textContent = '';
|
shareHint.textContent = '';
|
||||||
|
|
||||||
@@ -657,6 +662,116 @@
|
|||||||
stopVisualizer();
|
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() {
|
async function copyShareLink() {
|
||||||
if (!currentMidiUrl) return;
|
if (!currentMidiUrl) return;
|
||||||
const url = await buildShortShareUrl();
|
const url = await buildShortShareUrl();
|
||||||
@@ -776,6 +891,7 @@
|
|||||||
stopBtn.addEventListener('click', stopPlayback);
|
stopBtn.addEventListener('click', stopPlayback);
|
||||||
copyLinkBtn.addEventListener('click', () => void copyShareLink());
|
copyLinkBtn.addEventListener('click', () => void copyShareLink());
|
||||||
shareXBtn.addEventListener('click', shareOnX);
|
shareXBtn.addEventListener('click', shareOnX);
|
||||||
|
downloadMp3Btn.addEventListener('click', () => downloadMp3());
|
||||||
|
|
||||||
// Init
|
// Init
|
||||||
createBars();
|
createBars();
|
||||||
|
|||||||
Reference in New Issue
Block a user