fix: make MP3 downloads reliable again

Use bundled lamejs instead of a CDN global, and avoid browser user-gesture download blocking by preparing the MP3 first and downloading on a follow-up click.

Made-with: Cursor
This commit is contained in:
b1rdmania
2026-02-27 15:41:07 +00:00
parent 5bd2493d90
commit 9937f5a02a
4 changed files with 74 additions and 15 deletions
+1 -2
View File
@@ -1301,7 +1301,6 @@
</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>
</html>
+8
View File
@@ -0,0 +1,8 @@
declare module 'lamejs' {
// `lamejs` ships without TypeScript types.
// We only need `Mp3Encoder` for client-side MP3 export.
export const Mp3Encoder: any;
const _default: any;
export default _default;
}
+65 -12
View File
@@ -4,6 +4,7 @@ import { MIDIParser } from './midi/MIDIParser';
import { SoundfontMIDIPlayer } from './synthesis/SoundfontMIDIPlayer';
import { getAudioContext, isAudioReady, peekAudioContext, unlockAudio } from './utils/audioUnlock';
import type { NoteEvent } from './types';
import * as lamejs from 'lamejs';
class MotifApp {
private motifEngine: MotifEngine;
@@ -59,6 +60,10 @@ class MotifApp {
private shareFallback!: HTMLElement;
private shareFallbackInput!: HTMLInputElement;
private downloadMp3BtnLabel = 'Download MP3';
private preparedMp3: { url: string; filename: string } | null = null;
private preparedMp3RevokeTimeout: number | null = null;
// Embed snippet UI
private embedSection: HTMLElement | null = null;
private embedCodeEl: HTMLElement | null = null;
@@ -131,6 +136,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.downloadMp3BtnLabel = (this.downloadMp3Btn.textContent || '').trim() || 'Download MP3';
this.shareFallback = document.getElementById('shareFallback')!;
this.shareFallbackInput = document.getElementById('shareFallbackInput') as HTMLInputElement;
@@ -227,6 +233,7 @@ class MotifApp {
// Stop all audio and return to the search workbench.
this.stopPreview();
this.handleMotifStop();
this.clearPreparedMp3();
this.hasGenerated = false;
this.isMotifPlaying = false;
this.motifResumeProgress = 0;
@@ -406,6 +413,7 @@ class MotifApp {
// Stop any playing preview audio
this.soundfontPlayer?.stop();
this.stopPreview();
this.clearPreparedMp3();
this.hasGenerated = false;
this.selectedResultIndex = index;
@@ -508,6 +516,7 @@ class MotifApp {
try {
// Audio exclusivity
this.stopPreview();
this.clearPreparedMp3();
// Best-effort: ensure iOS audio is unlocked from this user gesture.
await unlockAudio();
this.updateIOSAudioBanner();
@@ -829,11 +838,54 @@ class MotifApp {
return out;
}
private clearPreparedMp3(): void {
if (this.preparedMp3RevokeTimeout !== null) {
window.clearTimeout(this.preparedMp3RevokeTimeout);
this.preparedMp3RevokeTimeout = null;
}
if (this.preparedMp3) {
try {
URL.revokeObjectURL(this.preparedMp3.url);
} catch {
// ignore
}
this.preparedMp3 = null;
}
if (this.downloadMp3Btn) {
this.downloadMp3Btn.textContent = this.downloadMp3BtnLabel;
}
}
private startBlobDownload(url: string, filename: string): void {
// This MUST run inside a user gesture to avoid modern download blocking.
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.rel = 'noopener';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
private async handleDownloadMp3(): Promise<void> {
if (!this.hasGenerated || !this.currentMIDI) return;
// If we already prepared an MP3, do the *actual* download synchronously in the click handler.
// Many browsers block downloads that occur after an async chain (user activation is lost).
if (this.preparedMp3) {
try {
this.startBlobDownload(this.preparedMp3.url, this.preparedMp3.filename);
this.updateStatus('Downloading…');
window.setTimeout(() => this.updateStatus(''), 800);
} catch (e) {
this.updateStatus(`Download blocked: ${e instanceof Error ? e.message : 'Unknown error'}`);
}
return;
}
try {
this.downloadMp3Btn.disabled = true;
this.downloadMp3Btn.textContent = 'Preparing…';
this.updateStatus('Rendering audio…');
// Stop any current playback
@@ -849,8 +901,7 @@ class MotifApp {
this.updateStatus('Encoding MP3…');
// Use lamejs from CDN (global)
const Mp3Encoder = (window as any).lamejs?.Mp3Encoder;
const Mp3Encoder = (lamejs as any)?.Mp3Encoder;
if (!Mp3Encoder) throw new Error('MP3 encoder not loaded');
const channels = audioBuffer.numberOfChannels;
@@ -880,22 +931,24 @@ class MotifApp {
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);
const filename = `${safeTitle}.mp3`;
this.updateStatus('MP3 downloaded!');
setTimeout(() => this.updateStatus(''), 1500);
// Store prepared download and let the user click once more to save.
this.preparedMp3 = { url, filename };
this.downloadMp3Btn.textContent = 'Tap to download';
this.updateStatus('MP3 ready. Tap Download MP3 again to save.');
// Auto-cleanup to avoid leaking blob URLs.
this.preparedMp3RevokeTimeout = window.setTimeout(() => {
this.clearPreparedMp3();
}, 5 * 60 * 1000);
} catch (e) {
this.updateStatus(`MP3 failed: ${e instanceof Error ? e.message : 'Unknown error'}`);
this.clearPreparedMp3();
} finally {
this.downloadMp3Btn.disabled = false;
if (!this.preparedMp3) this.downloadMp3Btn.textContent = this.downloadMp3BtnLabel;
}
}
-1
View File
@@ -469,7 +469,6 @@
}
}
</style>
<script src="https://cdn.jsdelivr.net/npm/lamejs@1.2.1/lame.min.js"></script>
</head>
<body>
<div class="container">