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