From 5528aef7f80b4eac99122f4111020e38e5a98ce7 Mon Sep 17 00:00:00 2001 From: b1rdmania <102524336+b1rdmania@users.noreply.github.com> Date: Mon, 5 Jan 2026 13:20:42 +0000 Subject: [PATCH] Add Download MP3 button, bump to v1.5 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Download MP3 alongside Copy Link and Share to X buttons - Offline rendering for faster MP3 exports - Version history updated with v1.4 and v1.5 changes 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- index.html | 10 ++++++- src/main.ts | 84 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 1 deletion(-) diff --git a/index.html b/index.html index 3f2d19a..0214dcf 100644 --- a/index.html +++ b/index.html @@ -1141,7 +1141,7 @@
-

WARIO SYNTH v1.3

+

WARIO SYNTH v1.5

@@ -1222,6 +1222,7 @@
+
+
+ v1.5 - Download MP3 button, offline rendering for faster exports +
+
+ v1.4 - 16-bit to 8-bit audio fixes, improved synthesis quality +
v1.3 - Share to X, light Game Boy palette, iOS audio fixes, local font hosting
@@ -1294,6 +1301,7 @@
+ \ No newline at end of file diff --git a/src/main.ts b/src/main.ts index f065277..630ac93 100644 --- a/src/main.ts +++ b/src/main.ts @@ -55,6 +55,7 @@ class MotifApp { private copyLinkBtn!: HTMLButtonElement; private shareToXBtn!: HTMLButtonElement; + private downloadMp3Btn!: HTMLButtonElement; private shareFallback!: HTMLElement; private shareFallbackInput!: HTMLInputElement; @@ -129,6 +130,7 @@ class MotifApp { this.copyLinkBtn = document.getElementById('copyLinkBtn') as HTMLButtonElement; this.shareToXBtn = document.getElementById('shareToXBtn') as HTMLButtonElement; + this.downloadMp3Btn = document.getElementById('downloadMp3Btn') as HTMLButtonElement; this.shareFallback = document.getElementById('shareFallback')!; this.shareFallbackInput = document.getElementById('shareFallbackInput') as HTMLInputElement; @@ -189,6 +191,7 @@ class MotifApp { this.copyLinkBtn.addEventListener('click', () => void this.handleCopyLink()); this.shareToXBtn.addEventListener('click', () => void this.handleShareToX()); + this.downloadMp3Btn.addEventListener('click', () => void this.handleDownloadMp3()); // Embed snippet copy (may be disabled / not-live) this.copyEmbedBtn?.addEventListener('click', () => void this.copyEmbedSnippet()); @@ -817,6 +820,85 @@ class MotifApp { } } + private floatToInt16(input: Float32Array): Int16Array { + 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; + } + + private async handleDownloadMp3(): Promise { + if (!this.hasGenerated || !this.currentMIDI) return; + + try { + this.downloadMp3Btn.disabled = true; + this.updateStatus('Rendering audio…'); + + // Stop any current playback + this.handleMotifStop(); + + // Render offline + const sampleRate = 44100; + const audioBuffer = await this.motifEngine.renderOffline( + this.currentMIDI.events, + 'procedural', + sampleRate + ); + + this.updateStatus('Encoding MP3…'); + + // Use lamejs from CDN (global) + const Mp3Encoder = (window as any).lamejs?.Mp3Encoder; + if (!Mp3Encoder) throw new Error('MP3 encoder not loaded'); + + const channels = audioBuffer.numberOfChannels; + const kbps = 128; + const encoder = new Mp3Encoder(channels, sampleRate, kbps); + const mp3Chunks: BlobPart[] = []; + + // Get audio data + const leftData = audioBuffer.getChannelData(0); + const rightData = channels > 1 ? audioBuffer.getChannelData(1) : leftData; + const left = this.floatToInt16(leftData); + const right = this.floatToInt16(rightData); + + // 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); + } + + const tail = encoder.flush(); + if (tail && tail.length) mp3Chunks.push(tail); + + const blob = new Blob(mp3Chunks, { type: 'audio/mpeg' }); + const result = this.searchResults[this.selectedResultIndex]; + const safeTitle = this.cleanSongTitle(result?.title || 'wario-synth').slice(0, 80) || 'wario-synth'; + + // Download + const a = document.createElement('a'); + const url = URL.createObjectURL(blob); + a.href = url; + a.download = `${safeTitle}.mp3`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + setTimeout(() => URL.revokeObjectURL(url), 1000); + + this.updateStatus('MP3 downloaded!'); + setTimeout(() => this.updateStatus(''), 1500); + } catch (e) { + this.updateStatus(`MP3 failed: ${e instanceof Error ? e.message : 'Unknown error'}`); + } finally { + this.downloadMp3Btn.disabled = false; + } + } + private setState(state: 'idle' | 'results' | 'selected' | 'generated'): void { // results - keep visible in all states except idle so user can pick a different source const hasResults = state === 'results' || state === 'selected' || state === 'generated'; @@ -832,6 +914,8 @@ class MotifApp { this.copyLinkBtn.disabled = !(state === 'generated' && this.hasGenerated); this.shareToXBtn.style.display = state === 'generated' ? 'inline-block' : 'none'; this.shareToXBtn.disabled = !(state === 'generated' && this.hasGenerated); + this.downloadMp3Btn.style.display = state === 'generated' ? 'inline-block' : 'none'; + this.downloadMp3Btn.disabled = !(state === 'generated' && this.hasGenerated); // Hide share fallback when state changes this.shareFallback.style.display = 'none';