chore: remove MP3 download UI
Remove the Download MP3 button and all related client-side export logic. Made-with: Cursor
This commit is contained in:
@@ -1222,7 +1222,6 @@
|
|||||||
<div style="margin-top: 14px; display: flex; gap: 12px; flex-wrap: wrap;">
|
<div style="margin-top: 14px; display: flex; gap: 12px; flex-wrap: wrap;">
|
||||||
<button id="copyLinkBtn" type="button" disabled>Copy link</button>
|
<button id="copyLinkBtn" type="button" disabled>Copy link</button>
|
||||||
<button id="shareToXBtn" type="button" disabled>Share to X</button>
|
<button id="shareToXBtn" type="button" disabled>Share to X</button>
|
||||||
<button id="downloadMp3Btn" type="button" disabled>Download MP3</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div id="shareFallback" style="display: none; margin-top: 12px;">
|
<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>
|
<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>
|
||||||
@@ -1247,9 +1246,6 @@
|
|||||||
<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.
|
<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>
|
||||||
<div class="version-divider"></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">
|
<div class="version-item">
|
||||||
<strong>v1.4</strong> - 16-bit to 8-bit audio fixes, improved synthesis quality
|
<strong>v1.4</strong> - 16-bit to 8-bit audio fixes, improved synthesis quality
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Vendored
-7
@@ -1,7 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
-149
@@ -4,7 +4,6 @@ import { MIDIParser } from './midi/MIDIParser';
|
|||||||
import { SoundfontMIDIPlayer } from './synthesis/SoundfontMIDIPlayer';
|
import { SoundfontMIDIPlayer } from './synthesis/SoundfontMIDIPlayer';
|
||||||
import { getAudioContext, isAudioReady, peekAudioContext, unlockAudio } from './utils/audioUnlock';
|
import { getAudioContext, isAudioReady, peekAudioContext, unlockAudio } from './utils/audioUnlock';
|
||||||
import type { NoteEvent } from './types';
|
import type { NoteEvent } from './types';
|
||||||
import * as lamejs from 'lamejs';
|
|
||||||
|
|
||||||
class MotifApp {
|
class MotifApp {
|
||||||
private motifEngine: MotifEngine;
|
private motifEngine: MotifEngine;
|
||||||
@@ -56,12 +55,9 @@ class MotifApp {
|
|||||||
|
|
||||||
private copyLinkBtn!: HTMLButtonElement;
|
private copyLinkBtn!: HTMLButtonElement;
|
||||||
private shareToXBtn!: HTMLButtonElement;
|
private shareToXBtn!: HTMLButtonElement;
|
||||||
private downloadMp3Btn!: HTMLButtonElement;
|
|
||||||
private shareFallback!: HTMLElement;
|
private shareFallback!: HTMLElement;
|
||||||
private shareFallbackInput!: HTMLInputElement;
|
private shareFallbackInput!: HTMLInputElement;
|
||||||
|
|
||||||
private downloadMp3BtnLabel = 'Download MP3';
|
|
||||||
|
|
||||||
// Embed snippet UI
|
// Embed snippet UI
|
||||||
private embedSection: HTMLElement | null = null;
|
private embedSection: HTMLElement | null = null;
|
||||||
private embedCodeEl: HTMLElement | null = null;
|
private embedCodeEl: HTMLElement | null = null;
|
||||||
@@ -133,8 +129,6 @@ class MotifApp {
|
|||||||
|
|
||||||
this.copyLinkBtn = document.getElementById('copyLinkBtn') as HTMLButtonElement;
|
this.copyLinkBtn = document.getElementById('copyLinkBtn') as HTMLButtonElement;
|
||||||
this.shareToXBtn = document.getElementById('shareToXBtn') 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.shareFallback = document.getElementById('shareFallback')!;
|
||||||
this.shareFallbackInput = document.getElementById('shareFallbackInput') as HTMLInputElement;
|
this.shareFallbackInput = document.getElementById('shareFallbackInput') as HTMLInputElement;
|
||||||
|
|
||||||
@@ -195,7 +189,6 @@ class MotifApp {
|
|||||||
|
|
||||||
this.copyLinkBtn.addEventListener('click', () => void this.handleCopyLink());
|
this.copyLinkBtn.addEventListener('click', () => void this.handleCopyLink());
|
||||||
this.shareToXBtn.addEventListener('click', () => void this.handleShareToX());
|
this.shareToXBtn.addEventListener('click', () => void this.handleShareToX());
|
||||||
this.downloadMp3Btn.addEventListener('click', () => void this.handleDownloadMp3());
|
|
||||||
|
|
||||||
// Embed snippet copy (may be disabled / not-live)
|
// Embed snippet copy (may be disabled / not-live)
|
||||||
this.copyEmbedBtn?.addEventListener('click', () => void this.copyEmbedSnippet());
|
this.copyEmbedBtn?.addEventListener('click', () => void this.copyEmbedSnippet());
|
||||||
@@ -824,146 +817,6 @@ 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;
|
|
||||||
|
|
||||||
const result = this.searchResults[this.selectedResultIndex];
|
|
||||||
const safeTitle = this.cleanSongTitle(result?.title || 'wario-synth').slice(0, 80) || 'wario-synth';
|
|
||||||
const fileName = `${safeTitle}.mp3`;
|
|
||||||
let fileHandle: any = null;
|
|
||||||
let popup: Window | null = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
this.downloadMp3Btn.disabled = true;
|
|
||||||
this.downloadMp3Btn.textContent = 'Rendering…';
|
|
||||||
this.updateStatus('Rendering audio…');
|
|
||||||
|
|
||||||
// Capture user activation up-front when possible.
|
|
||||||
const maybeShowSaveFilePicker = (window as any).showSaveFilePicker;
|
|
||||||
if (typeof maybeShowSaveFilePicker === 'function') {
|
|
||||||
try {
|
|
||||||
fileHandle = await maybeShowSaveFilePicker({
|
|
||||||
suggestedName: fileName,
|
|
||||||
types: [{
|
|
||||||
description: 'MP3 audio',
|
|
||||||
accept: { 'audio/mpeg': ['.mp3'] },
|
|
||||||
}],
|
|
||||||
});
|
|
||||||
} catch (e: any) {
|
|
||||||
if (e?.name === 'AbortError') {
|
|
||||||
this.updateStatus('Download cancelled.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Fallback for browsers without file picker: pre-open a window from the click gesture.
|
|
||||||
popup = window.open('', '_blank');
|
|
||||||
if (popup && !popup.closed) {
|
|
||||||
try {
|
|
||||||
popup.document.title = 'Preparing MP3';
|
|
||||||
popup.document.body.innerHTML = '<p style="font-family:sans-serif;padding:16px">Preparing MP3…</p>';
|
|
||||||
} catch {
|
|
||||||
// ignore cross-window access issues
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stop any current playback
|
|
||||||
this.handleMotifStop();
|
|
||||||
|
|
||||||
// Render offline (full song)
|
|
||||||
const sampleRate = 44100;
|
|
||||||
const audioBuffer = await this.motifEngine.renderOffline(
|
|
||||||
this.currentMIDI.events,
|
|
||||||
'procedural',
|
|
||||||
sampleRate
|
|
||||||
);
|
|
||||||
|
|
||||||
this.downloadMp3Btn.textContent = 'Encoding…';
|
|
||||||
this.updateStatus('Encoding MP3…');
|
|
||||||
|
|
||||||
const Mp3Encoder = (lamejs as any)?.Mp3Encoder;
|
|
||||||
if (!Mp3Encoder) throw new Error('MP3 encoder not loaded');
|
|
||||||
const channels = audioBuffer.numberOfChannels;
|
|
||||||
const kbps = 128;
|
|
||||||
const encoder = new (lamejs as any).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' });
|
|
||||||
if (fileHandle) {
|
|
||||||
const writable = await fileHandle.createWritable();
|
|
||||||
await writable.write(blob);
|
|
||||||
await writable.close();
|
|
||||||
this.updateStatus('MP3 downloaded!');
|
|
||||||
setTimeout(() => this.updateStatus(''), 1500);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
let attemptedDownload = false;
|
|
||||||
try {
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = fileName;
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
document.body.removeChild(a);
|
|
||||||
attemptedDownload = true;
|
|
||||||
} catch {
|
|
||||||
// fallback below
|
|
||||||
}
|
|
||||||
|
|
||||||
if (popup && !popup.closed) {
|
|
||||||
popup.location.href = url;
|
|
||||||
if (attemptedDownload) {
|
|
||||||
popup.close();
|
|
||||||
}
|
|
||||||
} else if (!attemptedDownload) {
|
|
||||||
window.open(url, '_blank');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (attemptedDownload) {
|
|
||||||
this.updateStatus('MP3 downloaded!');
|
|
||||||
setTimeout(() => this.updateStatus(''), 1500);
|
|
||||||
} else {
|
|
||||||
this.updateStatus('MP3 opened in a new tab. Save it from there.');
|
|
||||||
}
|
|
||||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
|
||||||
} catch (e) {
|
|
||||||
this.updateStatus(`MP3 failed: ${e instanceof Error ? e.message : 'Unknown error'}`);
|
|
||||||
} finally {
|
|
||||||
this.downloadMp3Btn.disabled = false;
|
|
||||||
this.downloadMp3Btn.textContent = this.downloadMp3BtnLabel;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private setState(state: 'idle' | 'results' | 'selected' | 'generated'): void {
|
private setState(state: 'idle' | 'results' | 'selected' | 'generated'): void {
|
||||||
// results - keep visible in all states except idle so user can pick a different source
|
// results - keep visible in all states except idle so user can pick a different source
|
||||||
const hasResults = state === 'results' || state === 'selected' || state === 'generated';
|
const hasResults = state === 'results' || state === 'selected' || state === 'generated';
|
||||||
@@ -979,8 +832,6 @@ class MotifApp {
|
|||||||
this.copyLinkBtn.disabled = !(state === 'generated' && this.hasGenerated);
|
this.copyLinkBtn.disabled = !(state === 'generated' && this.hasGenerated);
|
||||||
this.shareToXBtn.style.display = state === 'generated' ? 'inline-block' : 'none';
|
this.shareToXBtn.style.display = state === 'generated' ? 'inline-block' : 'none';
|
||||||
this.shareToXBtn.disabled = !(state === 'generated' && this.hasGenerated);
|
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
|
// Hide share fallback when state changes
|
||||||
this.shareFallback.style.display = 'none';
|
this.shareFallback.style.display = 'none';
|
||||||
|
|
||||||
|
|||||||
@@ -512,9 +512,6 @@
|
|||||||
<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">
|
||||||
@@ -562,7 +559,6 @@
|
|||||||
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');
|
||||||
@@ -718,7 +714,6 @@
|
|||||||
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 = '';
|
||||||
|
|
||||||
@@ -761,83 +756,6 @@
|
|||||||
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 = 'Rendering audio…';
|
|
||||||
|
|
||||||
// Stop any current playback first
|
|
||||||
stopPlayback();
|
|
||||||
|
|
||||||
// Ensure Motif engine exists
|
|
||||||
if (!motifEngine) motifEngine = new MotifEngine();
|
|
||||||
|
|
||||||
// Render offline (faster than real-time!)
|
|
||||||
const sampleRate = 44100;
|
|
||||||
const audioBuffer = await motifEngine.renderOffline(currentEvents, 'procedural', sampleRate);
|
|
||||||
|
|
||||||
shareHint.textContent = 'Encoding MP3…';
|
|
||||||
|
|
||||||
// Use lamejs from CDN (global)
|
|
||||||
const Mp3Encoder = window.lamejs?.Mp3Encoder;
|
|
||||||
if (!Mp3Encoder) throw new Error('MP3 encoder not available - lamejs not loaded');
|
|
||||||
|
|
||||||
const channels = audioBuffer.numberOfChannels;
|
|
||||||
const kbps = 128;
|
|
||||||
const encoder = new Mp3Encoder(channels, sampleRate, kbps);
|
|
||||||
const mp3Chunks = [];
|
|
||||||
|
|
||||||
// Get audio data from buffer
|
|
||||||
const leftData = audioBuffer.getChannelData(0);
|
|
||||||
const rightData = channels > 1 ? audioBuffer.getChannelData(1) : leftData;
|
|
||||||
const left = floatToInt16(leftData);
|
|
||||||
const right = 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 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'}`;
|
|
||||||
console.error('MP3 download error:', e);
|
|
||||||
} finally {
|
|
||||||
downloadMp3Btn.disabled = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function copyShareLink() {
|
async function copyShareLink() {
|
||||||
if (!currentMidiUrl) return;
|
if (!currentMidiUrl) return;
|
||||||
@@ -958,7 +876,6 @@
|
|||||||
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