Add Download MP3 button, bump to v1.5
- 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 <noreply@anthropic.com>
This commit is contained in:
+9
-1
@@ -1141,7 +1141,7 @@
|
||||
<div class="container">
|
||||
<div class="hero">
|
||||
<div class="hero-top">
|
||||
<h1>WARIO SYNTH <span class="version-tag">v1.3</span></h1>
|
||||
<h1>WARIO SYNTH <span class="version-tag">v1.5</span></h1>
|
||||
<button id="faqBtnTop" class="faq-link" type="button">FAQ</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1222,6 +1222,7 @@
|
||||
<div style="margin-top: 14px; display: flex; gap: 12px; flex-wrap: wrap;">
|
||||
<button id="copyLinkBtn" type="button" disabled>Copy link</button>
|
||||
<button id="shareToXBtn" type="button" disabled>Share to X</button>
|
||||
<button id="downloadMp3Btn" type="button" disabled>Download MP3</button>
|
||||
</div>
|
||||
<div id="shareFallback" style="display: none; margin-top: 12px;">
|
||||
<p style="font-size: 8px; margin: 0 0 8px 0; color: var(--gb-light);">Sorry, copy doesn't work on your device. Here's the link:</p>
|
||||
@@ -1246,6 +1247,12 @@
|
||||
<strong>How it works:</strong> The Wario Synthesis Engine analyses MIDI files to identify melody, bass, and chord tracks. It then resynthesises each part using Web Audio oscillators tuned to mimic the Game Boy's 4-channel sound chip: two pulse wave channels, one wave channel, and one noise channel. All processing runs client-side in your browser - no server-side audio generation. Built with TypeScript, Vite, and Claude Code.
|
||||
</div>
|
||||
<div class="version-divider"></div>
|
||||
<div class="version-item">
|
||||
<strong>v1.5</strong> - Download MP3 button, offline rendering for faster exports
|
||||
</div>
|
||||
<div class="version-item">
|
||||
<strong>v1.4</strong> - 16-bit to 8-bit audio fixes, improved synthesis quality
|
||||
</div>
|
||||
<div class="version-item">
|
||||
<strong>v1.3</strong> - Share to X, light Game Boy palette, iOS audio fixes, local font hosting
|
||||
</div>
|
||||
@@ -1294,6 +1301,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/lamejs@1.2.1/lame.min.js"></script>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
+84
@@ -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<void> {
|
||||
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';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user