Files
motif/src/main.ts
T
b1rdmania 456db201e5 fix: make MP3 save work on iOS and allow full export
Offer full-song export via prompt and improve the fallback save path by using Share Sheet when available or opening the MP3 in a new tab on iOS, instead of relying on unreliable blob downloads.

Made-with: Cursor
2026-03-03 12:07:49 +00:00

1256 lines
44 KiB
TypeScript

import { MotifEngine } from './core/MotifEngine';
import { MIDIService } from './services/MIDIService';
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;
private midiService: MIDIService;
// Preview player (lazily created for iOS compatibility)
private soundfontPlayer: SoundfontMIDIPlayer | null = null;
private previewStopTimeout: number | null = null;
private searchBtn!: HTMLButtonElement;
private songInput!: HTMLInputElement;
private status!: HTMLElement;
private resultsSection!: HTMLElement;
private resultsBody!: HTMLElement;
private playerSection!: HTMLElement;
private chooseHelper!: HTMLElement;
private selectedTitle!: HTMLElement;
private selectedMeta!: HTMLElement;
private preGenActions!: HTMLElement;
private preGenSupport!: HTMLElement;
private generatedBlock!: HTMLElement;
private playPauseBtn!: HTMLButtonElement;
private previewBtn!: HTMLButtonElement;
private previewStopBtn!: HTMLButtonElement;
private previewState!: HTMLElement;
private isPreviewPlaying = false;
private hasGenerated = false;
private isMotifPlaying = false;
private motifResumeProgress = 0;
// Preview player (no UI volume)
// Motif controls
private motifBtn!: HTMLButtonElement;
private motifProgressContainer!: HTMLElement;
private motifProgressBar!: HTMLInputElement;
private motifProgressFill!: HTMLElement;
private motifCurrentTime!: HTMLElement;
private motifDuration!: HTMLElement;
private motifProgressInterval: number | null = null;
// iOS audio unlock UI (Motif only)
private iosAudioBanner!: HTMLElement;
private enableAudioBtn!: HTMLButtonElement;
private iosAudioState!: HTMLElement;
private copyLinkBtn!: HTMLButtonElement;
private shareToXBtn!: HTMLButtonElement;
private downloadMp3Btn!: HTMLButtonElement;
private shareFallback!: HTMLElement;
private shareFallbackInput!: HTMLInputElement;
private downloadMp3BtnLabel = 'Download MP3';
private preparedMp3: { url: string; filename: string; blob: Blob } | null = null;
private preparedMp3RevokeTimeout: number | null = null;
private isMp3Preparing = false;
// Embed snippet UI
private embedSection: HTMLElement | null = null;
private embedCodeEl: HTMLElement | null = null;
private copyEmbedBtn: HTMLButtonElement | null = null;
private copyToast: HTMLElement | null = null;
// FAQ modal
private faqBtnTop: HTMLButtonElement | null = null;
private newSearchBtn: HTMLButtonElement | null = null;
private faqBackdrop!: HTMLElement;
private faqCloseBtn!: HTMLButtonElement;
private searchResults: any[] = [];
private selectedResultIndex = 0;
private currentMIDI: { events: NoteEvent[], metadata: any } | null = null;
constructor() {
this.motifEngine = new MotifEngine();
this.midiService = new MIDIService();
// soundfontPlayer created lazily on first play for iOS compatibility
this.initializeUI();
this.setupEventListeners();
// Always play at 100%
this.motifEngine.setVolume(1);
}
/**
* Ensure audio is unlocked and soundfontPlayer is ready.
* Must be called from a user gesture context.
*/
private async ensureAudioReady(): Promise<SoundfontMIDIPlayer> {
const audioContext = await unlockAudio();
if (!this.soundfontPlayer) {
this.soundfontPlayer = new SoundfontMIDIPlayer(audioContext);
this.soundfontPlayer.setVolume(1);
}
return this.soundfontPlayer;
}
private initializeUI(): void {
this.searchBtn = document.getElementById('searchBtn') as HTMLButtonElement;
this.songInput = document.getElementById('songInput') as HTMLInputElement;
this.status = document.getElementById('status')!;
this.resultsSection = document.getElementById('resultsSection')!;
this.resultsBody = document.getElementById('resultsBody')!;
this.playerSection = document.getElementById('playerSection')!;
this.chooseHelper = document.getElementById('chooseHelper')!;
this.selectedTitle = document.getElementById('selectedTitle')!;
this.selectedMeta = document.getElementById('selectedMeta')!;
this.preGenActions = document.getElementById('preGenActions')!;
this.preGenSupport = document.getElementById('preGenSupport')!;
this.generatedBlock = document.getElementById('generatedBlock')!;
this.playPauseBtn = document.getElementById('playPauseBtn') as HTMLButtonElement;
this.previewBtn = document.getElementById('previewBtn') as HTMLButtonElement;
this.previewStopBtn = document.getElementById('previewStopBtn') as HTMLButtonElement;
this.previewState = document.getElementById('previewState')!;
// Motif controls
this.motifBtn = document.getElementById('motifBtn') as HTMLButtonElement;
this.motifProgressContainer = document.getElementById('motifProgressContainer')!;
this.motifProgressBar = document.getElementById('motifProgressBar') as HTMLInputElement;
this.motifProgressFill = document.getElementById('motifProgressFill')!;
this.motifCurrentTime = document.getElementById('motifCurrentTime')!;
this.motifDuration = document.getElementById('motifDuration')!;
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;
// iOS audio unlock UI (Motif)
this.iosAudioBanner = document.getElementById('iosAudioBanner')!;
this.enableAudioBtn = document.getElementById('enableAudioBtn') as HTMLButtonElement;
this.iosAudioState = document.getElementById('iosAudioState')!;
// Optional embed UI (only present on main page)
this.embedSection = document.getElementById('embedSection');
this.embedCodeEl = document.getElementById('embedCode');
this.copyEmbedBtn = document.getElementById('copyEmbedBtn') as HTMLButtonElement | null;
this.copyToast = document.getElementById('copyToast');
// FAQ modal
this.faqBtnTop = document.getElementById('faqBtnTop') as HTMLButtonElement | null;
this.newSearchBtn = document.getElementById('newSearchBtn') as HTMLButtonElement | null;
this.faqBackdrop = document.getElementById('faqModalBackdrop')!;
this.faqCloseBtn = document.getElementById('faqCloseBtn') as HTMLButtonElement;
}
private setupEventListeners(): void {
const doSearch = () => {
console.log('[MotifApp] Search triggered');
// Immediately show feedback so user knows click registered
this.status.textContent = 'Starting search...';
this.handleSearch().catch(err => {
console.error('[MotifApp] Search error:', err);
this.updateStatus(`Error: ${err instanceof Error ? err.message : 'Unknown error'}`);
});
};
// Use both click and touchend for iOS compatibility
this.searchBtn.addEventListener('click', doSearch);
this.songInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
doSearch();
}
});
// Preview MIDI (verification only) — controlled from results table rows
// Motif
this.motifBtn.addEventListener('click', () => this.handleMotif());
this.playPauseBtn.addEventListener('click', () => void this.handlePlayPause());
// Preview inside selected source only
this.previewBtn.addEventListener('click', () => void this.handlePreviewToggle());
this.previewStopBtn.addEventListener('click', () => this.stopPreview());
// Use both input and change for iOS compatibility
const seekHandler = (e: Event) => {
const progress = parseFloat((e.target as HTMLInputElement).value) / 100;
this.handleMotifSeek(progress);
};
this.motifProgressBar.addEventListener('input', seekHandler);
this.motifProgressBar.addEventListener('change', seekHandler);
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());
// iOS audio unlock CTA — must be a user gesture
const enable = () => void this.handleEnableAudio();
this.enableAudioBtn.addEventListener('click', enable);
this.enableAudioBtn.addEventListener('touchend', enable, { passive: true });
// FAQ
this.faqBtnTop?.addEventListener('click', () => this.openFaq());
this.newSearchBtn?.addEventListener('click', () => this.resetToNewSearch());
this.faqCloseBtn.addEventListener('click', () => this.closeFaq());
this.faqBackdrop.addEventListener('click', (e) => {
if (e.target === this.faqBackdrop) this.closeFaq();
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') this.closeFaq();
});
}
private openFaq(): void {
this.faqBackdrop.classList.add('open');
// focus close for keyboard users
this.faqCloseBtn.focus();
}
private closeFaq(): void {
this.faqBackdrop.classList.remove('open');
}
private resetToNewSearch(): void {
// Stop all audio and return to the search workbench.
this.stopPreview();
this.handleMotifStop();
this.clearPreparedMp3();
this.hasGenerated = false;
this.isMotifPlaying = false;
this.motifResumeProgress = 0;
this.currentMIDI = null;
this.setState('idle');
this.updateStatus('Ready. Search any song to make a Game Boy version.');
try {
window.scrollTo({ top: 0, behavior: 'smooth' });
} catch {
// ignore
}
// Focus search field for immediate next query
window.setTimeout(() => {
try {
this.songInput.focus();
this.songInput.select();
} catch {
// ignore
}
}, 150);
}
private isIOSLike(): boolean {
const ua = navigator.userAgent || '';
const iOS = /iPad|iPhone|iPod/.test(ua);
const iPadOS13Plus = /Macintosh/.test(ua) && (navigator as any).maxTouchPoints > 1;
return iOS || iPadOS13Plus;
}
private updateIOSAudioBanner(): void {
// Only show this UX on iOS-like browsers, and only until audio is running.
if (!this.isIOSLike()) {
this.iosAudioBanner.style.display = 'none';
return;
}
const ready = isAudioReady();
this.iosAudioBanner.style.display = ready ? 'none' : 'block';
// Optional tiny state readout (helps support debugging)
const ctx = peekAudioContext();
if (!ready && ctx) {
this.iosAudioState.style.display = 'block';
this.iosAudioState.textContent = `Audio: ${ctx.state} @ ${ctx.sampleRate}Hz`;
} else {
this.iosAudioState.style.display = 'none';
this.iosAudioState.textContent = '';
}
}
private async handleEnableAudio(): Promise<void> {
// Must run in a user gesture context.
try {
this.enableAudioBtn.disabled = true;
this.iosAudioState.style.display = 'block';
this.iosAudioState.textContent = 'Audio: enabling…';
await unlockAudio();
// Update banner state
const ctx = getAudioContext();
if (ctx.state !== 'running') {
this.enableAudioBtn.disabled = false;
this.iosAudioState.textContent = 'Audio still locked. Tap Enable Audio again.';
return;
}
this.iosAudioState.textContent = `Audio: running @ ${ctx.sampleRate}Hz`;
// Hide after a short beat to reduce flicker
window.setTimeout(() => this.updateIOSAudioBanner(), 250);
} catch {
this.enableAudioBtn.disabled = false;
this.iosAudioState.style.display = 'block';
this.iosAudioState.textContent = 'Audio enable failed. Tap again, or disable Silent Mode.';
} finally {
this.enableAudioBtn.disabled = false;
}
}
private async handleSearch(): Promise<void> {
console.log('[MotifApp] handleSearch called');
const songName = this.songInput.value.trim();
console.log('[MotifApp] songName:', songName);
if (!songName) {
this.updateStatus('Enter a song name to search.');
this.songInput.focus();
return;
}
// Stop any playing Motif audio
this.handleMotifStop();
this.updateStatus('Searching…');
console.log('[MotifApp] Starting search for:', songName);
this.searchBtn.disabled = true;
this.setState('idle');
try {
const results = await this.midiService.search(songName);
if (results.length === 0) {
this.updateStatus('No MIDI files found. Try a different search.');
return;
}
this.searchResults = results;
this.selectedResultIndex = 0;
// Parse metadata for results
this.updateStatus('Analyzing MIDI files...');
for (let i = 0; i < Math.min(results.length, 3); i++) {
const metadata = await this.midiService.parseMIDI(results[i].midiUrl);
if (metadata) {
results[i].parsed = metadata;
}
}
this.displayResults();
this.setState('results');
this.updateStatus('Pick a midi source. Try another if it sounds bad, some work better than others.');
this.updateIOSAudioBanner();
} catch (error) {
this.updateStatus(`Search error: ${error instanceof Error ? error.message : 'Unknown error'}`);
} finally {
this.searchBtn.disabled = false;
}
}
private displayResults(): void {
this.resultsBody.innerHTML = '';
this.searchResults.forEach((result, index) => {
const row = document.createElement('tr');
row.innerHTML = `
<td>${this.cleanSongTitle(result.title)}</td>
<td class="source-col">${this.formatSourceLabel(result.source)}</td>
<td class="action-col">
<div style="display:flex; gap: 10px; justify-content: flex-end; align-items:center;">
<button type="button" class="row-use-btn">Use</button>
</div>
</td>
`;
// Use explicit action button to reduce accidental selection
const useBtn = row.querySelector('button.row-use-btn') as HTMLButtonElement;
useBtn.addEventListener('click', (e) => {
e.stopPropagation();
void this.selectResult(index);
});
this.resultsBody.appendChild(row);
});
// No auto-select: user should choose "Use this"
}
private stopPreview(): void {
if (this.previewStopTimeout) {
window.clearTimeout(this.previewStopTimeout);
this.previewStopTimeout = null;
}
this.soundfontPlayer?.stop();
this.isPreviewPlaying = false;
this.previewState.style.display = 'none';
this.previewStopBtn.style.display = 'none';
this.previewBtn.disabled = this.currentMIDI == null;
}
public async selectResult(index: number): Promise<void> {
if (index < 0 || index >= this.searchResults.length) return;
// Stop any playing Motif audio
this.handleMotifStop();
// Stop any playing preview audio
this.soundfontPlayer?.stop();
this.stopPreview();
this.clearPreparedMp3();
this.hasGenerated = false;
this.selectedResultIndex = index;
const result = this.searchResults[index];
// Update selection highlighting
const rows = this.resultsBody.querySelectorAll('tr');
rows.forEach((row, i) => {
row.classList.toggle('selected', i === index);
});
this.updateStatus('Loading…');
this.disablePlayerControls();
try {
// Fetch and parse MIDI
const midiBuffer = await this.midiService.fetchMIDI(result.midiUrl);
if (!midiBuffer) {
throw new Error('Failed to fetch MIDI file');
}
const events = MIDIParser.parseMIDI(midiBuffer);
const metadata = result.parsed || MIDIParser.getMIDIInfo(midiBuffer);
// Calculate duration from events if metadata duration is 0 or missing
let actualDuration = metadata.duration || metadata.durationSec || 0;
if (actualDuration === 0 && events.length > 0) {
// Calculate duration from the last event
actualDuration = Math.max(...events.map(e => e.time + e.duration));
}
this.currentMIDI = { events, metadata: { ...metadata, duration: actualDuration } };
// Update UI
const displayTitle = this.cleanSongTitle(result.title);
this.selectedTitle.textContent = displayTitle;
this.selectedMeta.textContent = `Source: ${this.formatSourceLabel(result.source)}`;
this.updateEmbedSnippet(result.title);
this.updateIOSAudioBanner();
this.playerSection.classList.add('visible');
this.resultsSection.classList.add('collapsed');
this.enablePlayerControls();
this.updateStatus('');
this.setState('selected');
// Intent: after selecting "Use this", bring the workbench controls into view.
// Do this after the card is visible and MIDI is loaded.
try {
this.playerSection.scrollIntoView({ behavior: 'smooth', block: 'start' });
} catch {
// ignore
}
} catch (error) {
this.updateStatus(`Load error: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
private async handlePreviewToggle(): Promise<void> {
if (!this.currentMIDI) return;
if (this.isPreviewPlaying) {
this.stopPreview();
return;
}
// Audio exclusivity
this.handleMotifStop();
try {
this.previewBtn.disabled = true;
const player = await this.ensureAudioReady();
await player.load(this.currentMIDI.events);
// conservative fixed preview level
player.setVolume(0.8);
await player.play();
this.isPreviewPlaying = true;
this.previewState.style.display = 'inline';
this.previewStopBtn.style.display = 'inline';
this.previewBtn.disabled = false;
const duration = player.getDuration();
if (this.previewStopTimeout) window.clearTimeout(this.previewStopTimeout);
this.previewStopTimeout = window.setTimeout(() => {
this.stopPreview();
}, Math.max(0.5, duration + 0.5) * 1000);
} catch {
this.stopPreview();
}
}
// Motif handlers
private async handleMotif(): Promise<void> {
if (!this.currentMIDI) {
return;
}
try {
// Audio exclusivity
this.stopPreview();
this.clearPreparedMp3();
// Best-effort: ensure iOS audio is unlocked from this user gesture.
await unlockAudio();
this.updateIOSAudioBanner();
// Preserve scroll position during state change
const scrollY = window.scrollY;
// Once generation starts: lock into generated experience mode.
this.setState('generated');
window.scrollTo(0, scrollY);
this.updateStatus('Generating…');
this.motifBtn.disabled = true;
this.playPauseBtn.disabled = true;
// Generate a variation using the procedural role-mapping mode
await this.motifEngine.generateFromMIDI(this.currentMIDI.events, 'procedural');
this.motifEngine.setVolume(0.8);
await this.motifEngine.play();
this.isMotifPlaying = true;
this.playPauseBtn.disabled = false;
this.playPauseBtn.textContent = 'Pause';
// Show progress bar and set duration
this.motifProgressContainer.style.display = 'block';
const duration = this.motifEngine.getDuration();
this.motifDuration.textContent = this.formatTime(duration);
this.motifProgressBar.value = '0';
this.motifProgressFill.style.width = '0%';
// Start progress updates
this.startMotifProgressUpdates();
this.updateStatus('');
this.hasGenerated = true;
// Refresh generated state now that generation succeeded (enables Copy link, etc.)
const scrollYEnd = window.scrollY;
this.setState('generated');
window.scrollTo(0, scrollYEnd);
} catch (error) {
this.updateStatus(`Motif error: ${error instanceof Error ? error.message : 'Unknown error'}`);
this.motifBtn.disabled = false;
this.playPauseBtn.disabled = false;
// If generation fails, return to selected state
this.setState('selected');
}
}
private handleMotifStop(): void {
this.motifEngine.stop();
this.isMotifPlaying = false;
this.playPauseBtn.textContent = 'Play';
this.stopMotifProgressUpdates();
this.updateStatus('');
}
private handleMotifSeek(progress: number): void {
this.motifResumeProgress = progress;
if (this.isMotifPlaying) {
this.motifEngine.seek(progress);
this.updateMotifProgress();
return;
}
const duration = this.motifEngine.getDuration();
this.motifProgressBar.value = (progress * 100).toString();
this.motifProgressFill.style.width = `${progress * 100}%`;
this.motifCurrentTime.textContent = this.formatTime(progress * duration);
}
private startMotifProgressUpdates(): void {
this.stopMotifProgressUpdates();
this.motifProgressInterval = window.setInterval(() => {
this.updateMotifProgress();
}, 100); // Update 10 times per second
}
private stopMotifProgressUpdates(): void {
if (this.motifProgressInterval !== null) {
clearInterval(this.motifProgressInterval);
this.motifProgressInterval = null;
}
}
private updateMotifProgress(): void {
const progress = this.motifEngine.getProgress();
const currentTime = this.motifEngine.getCurrentTime();
this.motifProgressBar.value = (progress * 100).toString();
this.motifProgressFill.style.width = `${progress * 100}%`;
this.motifCurrentTime.textContent = this.formatTime(currentTime);
// Auto-pause at end
const duration = this.motifEngine.getDuration();
if (duration > 0 && progress >= 0.999) {
this.motifResumeProgress = 0;
this.handleMotifStop();
this.motifCurrentTime.textContent = this.formatTime(duration);
this.motifProgressBar.value = '100';
this.motifProgressFill.style.width = '100%';
}
}
private formatTime(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
private titleCase(input: string): string {
const small = new Set([
'a', 'an', 'and', 'as', 'at', 'but', 'by', 'for', 'from', 'in', 'into',
'nor', 'of', 'on', 'or', 'per', 'the', 'to', 'via', 'with',
]);
const words = (input || '')
.split(/\s+/g)
.filter(Boolean)
.map((w) => w.trim());
return words
.map((word, idx) => {
if (/^[A-Z0-9]+$/.test(word)) return word;
const lower = word.toLowerCase();
if (idx !== 0 && small.has(lower)) return lower;
return lower.charAt(0).toUpperCase() + lower.slice(1);
})
.join(' ');
}
private cleanSongTitle(raw: string): string {
const cleaned = (raw || '')
.replace(/\.mid$/i, '')
.replace(/[_]+/g, ' ')
.replace(/[.]+/g, ' ')
.replace(/\s+/g, ' ')
.trim()
// Strip standalone "mid" or "midi" words (case insensitive)
.replace(/\b(midi?)\b/gi, '')
.replace(/\s+/g, ' ')
.trim();
return this.titleCase(cleaned || 'Unknown');
}
private formatSourceLabel(source: string): string {
const s = String(source || '').toLowerCase();
if (s === 'bitmidi') return 'BitMidi';
if (s === 'dongrays') return 'Dongrays';
return this.titleCase(s);
}
private enablePlayerControls(): void {
this.motifBtn.disabled = false;
this.copyLinkBtn.disabled = this.currentMIDI == null;
this.previewBtn.disabled = this.currentMIDI == null;
}
private disablePlayerControls(): void {
this.motifBtn.disabled = true;
this.copyLinkBtn.disabled = true;
this.previewBtn.disabled = true;
}
private updateStatus(message: string): void {
this.status.textContent = message;
}
private async handleCopyLink(): Promise<void> {
if (!this.hasGenerated) return;
const result = this.searchResults[this.selectedResultIndex];
if (!result?.midiUrl) return;
const title = result.title || '';
// Try to get a short link with dynamic OG tags
let shareUrl: string | null = null;
try {
let payload: any = null;
if (result.source === 'bitmidi') {
const m = String(result.midiUrl).match(/\/uploads\/(\d+)\.mid/i);
if (m?.[1]) payload = { src: 'bitmidi', id: m[1], title };
}
if (!payload) payload = { u: result.midiUrl, title };
const resp = await fetch('/api/share', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (resp.ok) {
const data = await resp.json();
if (data?.url) shareUrl = `${window.location.origin}${data.url}`;
}
} catch {
// Fall through to long URL
}
// Fallback: direct /play link
if (!shareUrl) {
const encodedTitle = encodeURIComponent(title);
if (result.source === 'bitmidi') {
const m = String(result.midiUrl).match(/\/uploads\/(\d+)\.mid/i);
if (m?.[1]) {
shareUrl = `${window.location.origin}/play?src=bitmidi&id=${encodeURIComponent(m[1])}&title=${encodedTitle}`;
} else {
shareUrl = `${window.location.origin}/play?u=${encodeURIComponent(result.midiUrl)}&title=${encodedTitle}`;
}
} else {
shareUrl = `${window.location.origin}/play?u=${encodeURIComponent(result.midiUrl)}&title=${encodedTitle}`;
}
}
// Try to copy to clipboard
let copied = false;
// Method 1: Modern Clipboard API
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(shareUrl);
copied = true;
}
} catch {
// Clipboard API failed, try fallback
}
// Method 2: execCommand fallback (works better on iOS Safari)
if (!copied) {
try {
const textarea = document.createElement('textarea');
textarea.value = shareUrl;
textarea.style.position = 'fixed';
textarea.style.left = '-9999px';
textarea.style.top = '0';
textarea.setAttribute('readonly', ''); // Prevent zoom on iOS
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
// iOS needs setSelectionRange
textarea.setSelectionRange(0, shareUrl.length);
copied = document.execCommand('copy');
document.body.removeChild(textarea);
} catch {
// execCommand failed too
}
}
// Show feedback
if (copied) {
const originalText = this.copyLinkBtn.textContent;
this.copyLinkBtn.textContent = 'Copied!';
this.shareFallback.style.display = 'none';
setTimeout(() => {
this.copyLinkBtn.textContent = originalText;
}, 1500);
} else {
// Show fallback with the link for manual copying
this.shareFallbackInput.value = shareUrl;
this.shareFallback.style.display = 'block';
// Select the text so user can easily copy
this.shareFallbackInput.focus();
this.shareFallbackInput.select();
}
}
private async handleShareToX(): Promise<void> {
if (!this.hasGenerated) return;
const result = this.searchResults[this.selectedResultIndex];
if (!result?.midiUrl) return;
const title = this.cleanSongTitle(result.title || 'a song');
// Open window immediately (must be in user gesture context for mobile)
// We'll navigate it after getting the share URL
const popup = window.open('about:blank', '_blank');
// Get share URL (try short link first)
let shareUrl: string | null = null;
try {
let payload: any = null;
if (result.source === 'bitmidi') {
const m = String(result.midiUrl).match(/\/uploads\/(\d+)\.mid/i);
if (m?.[1]) payload = { src: 'bitmidi', id: m[1], title: result.title };
}
if (!payload) payload = { u: result.midiUrl, title: result.title };
const resp = await fetch('/api/share', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (resp.ok) {
const data = await resp.json();
if (data?.url) shareUrl = `${window.location.origin}${data.url}`;
}
} catch {
// Fall through
}
if (!shareUrl) {
shareUrl = window.location.href;
}
// Compose tweet text
const tweetText = `I made a Game Boy version of ${title}\n\nUsing @b1rdmania's Wario Synthesis Midi Engine\n\nCheck it out here`;
const twitterUrl = `https://twitter.com/intent/tweet?text=${encodeURIComponent(tweetText)}&url=${encodeURIComponent(shareUrl)}`;
// Navigate the already-opened window
if (popup) {
popup.location.href = twitterUrl;
} else {
// Fallback if popup was blocked
window.location.href = twitterUrl;
}
}
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 getEventsDurationSec(events: NoteEvent[]): number {
let maxEnd = 0;
for (const e of events) {
const end = (e.time || 0) + (e.duration || 0);
if (end > maxEnd) maxEnd = end;
}
return maxEnd;
}
private trimEventsForDuration(events: NoteEvent[], maxSec: number): NoteEvent[] {
if (!Number.isFinite(maxSec) || maxSec <= 0) return [];
const out: NoteEvent[] = [];
for (const e of events) {
const t = e.time || 0;
if (t >= maxSec) continue;
const maxDur = maxSec - t;
const dur = Math.max(0, Math.min(e.duration || 0, maxDur));
if (dur <= 0) continue;
out.push({ ...e, duration: dur });
}
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 async trySavePreparedMp3(prepared: { url: string; filename: string; blob: Blob }): Promise<void> {
// Best effort ordering:
// 1) Share sheet (iOS Safari often can't "download" blob URLs reliably)
// 2) Open in a new tab (lets user use the browser's share/save UI)
// 3) Regular anchor download
const file = new File([prepared.blob], prepared.filename, { type: 'audio/mpeg' });
const canShareFiles =
typeof navigator !== 'undefined' &&
typeof (navigator as any).share === 'function' &&
typeof (navigator as any).canShare === 'function' &&
(navigator as any).canShare({ files: [file] });
if (canShareFiles) {
await (navigator as any).share({
files: [file],
title: prepared.filename,
});
return;
}
// iOS-like browsers: open blob in a new tab/window (download attr is unreliable)
if (this.isIOSLike()) {
const opened = window.open(prepared.url, '_blank', 'noopener,noreferrer');
if (!opened) {
// popup blocked: navigate current tab
window.location.href = prepared.url;
}
return;
}
// Default: anchor download (must run inside a user gesture)
const a = document.createElement('a');
a.href = prepared.url;
a.download = prepared.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 (this.isMp3Preparing) {
this.updateStatus('Still preparing MP3…');
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 {
await this.trySavePreparedMp3(this.preparedMp3);
this.updateStatus(this.isIOSLike() ? 'Opened MP3. Use Share/Save.' : 'Downloading…');
window.setTimeout(() => this.updateStatus(''), 1200);
// Reset after a save attempt so the button doesn't feel “dead”.
this.clearPreparedMp3();
} catch (e) {
this.updateStatus(`Download blocked: ${e instanceof Error ? e.message : 'Unknown error'}`);
}
return;
}
try {
this.isMp3Preparing = true;
this.downloadMp3Btn.disabled = true;
this.downloadMp3Btn.textContent = 'Preparing MP3…';
// Stop any current playback
this.handleMotifStop();
const result = this.searchResults[this.selectedResultIndex];
const safeTitle = this.cleanSongTitle(result?.title || 'wario-synth').slice(0, 80) || 'wario-synth';
const filename = `${safeTitle}.mp3`;
// Guard against huge exports that feel like a “stall”.
const durationSec = this.getEventsDurationSec(this.currentMIDI.events);
const MAX_EXPORT_SEC = 180; // 3 minutes max by default (keeps export fast/reliable)
let exportSec = durationSec;
if (durationSec > MAX_EXPORT_SEC) {
const promptVal = window.prompt(
`This track is about ${Math.round(durationSec)}s long.\n\nEnter seconds to export (e.g. ${MAX_EXPORT_SEC}), or leave blank for full song:`,
String(MAX_EXPORT_SEC)
);
if (promptVal === null) {
this.updateStatus('MP3 export cancelled.');
return;
}
const trimmed = promptVal.trim();
if (trimmed.length === 0) {
exportSec = durationSec; // full
} else {
const n = Number(trimmed);
if (!Number.isFinite(n) || n <= 0) {
this.updateStatus('Invalid export duration.');
return;
}
exportSec = Math.min(durationSec, n);
}
}
const exportEvents = this.trimEventsForDuration(this.currentMIDI.events, exportSec);
if (exportEvents.length === 0) {
throw new Error('Nothing to export (empty clip)');
}
// Prefer File System Access API when available (single click, no download blocking).
const picker = (window as any).showSaveFilePicker as
| undefined
| ((opts: any) => Promise<any>);
let writable: any = null;
if (typeof picker === 'function') {
try {
this.downloadMp3Btn.textContent = 'Choose save location…';
this.updateStatus('Choose where to save the MP3…');
const handle = await picker({
suggestedName: filename,
types: [
{
description: 'MP3 Audio',
accept: { 'audio/mpeg': ['.mp3'] },
},
],
});
writable = await handle.createWritable();
} catch (e: any) {
if (e?.name === 'AbortError') {
this.updateStatus('MP3 export cancelled.');
return;
}
throw e;
}
}
this.downloadMp3Btn.textContent = 'Rendering…';
this.updateStatus('Rendering audio…');
// Render offline
const sampleRate = 44100;
const audioBuffer = await this.motifEngine.renderOffline(
exportEvents,
'procedural',
sampleRate
);
this.downloadMp3Btn.textContent = 'Encoding… 0%';
this.updateStatus('Encoding MP3… 0%');
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 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;
const totalFrames = Math.max(1, Math.ceil(left.length / frameSize));
let framesDone = 0;
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) {
if (writable) {
await writable.write(new Uint8Array(buf));
} else {
mp3Chunks.push(buf);
}
}
framesDone++;
if (framesDone % 64 === 0 || framesDone === totalFrames) {
const pct = Math.min(99, Math.floor((framesDone / totalFrames) * 100));
this.downloadMp3Btn.textContent = `Encoding… ${pct}%`;
this.updateStatus(`Encoding MP3… ${pct}%`);
// Yield to the UI thread so the page doesn't feel frozen.
// eslint-disable-next-line no-await-in-loop
await new Promise<void>((resolve) => setTimeout(resolve, 0));
}
}
const tail = encoder.flush();
if (tail && tail.length) {
if (writable) {
await writable.write(new Uint8Array(tail));
} else {
mp3Chunks.push(tail);
}
}
if (writable) {
this.downloadMp3Btn.textContent = 'Saving…';
this.updateStatus('Saving MP3…');
await writable.close();
this.updateStatus('MP3 saved!');
window.setTimeout(() => this.updateStatus(''), 1200);
} else {
// Fallback: create a Blob URL and require a second click to save (avoids download blocking).
const blob = new Blob(mp3Chunks, { type: 'audio/mpeg' });
const url = URL.createObjectURL(blob);
this.preparedMp3 = { url, filename, blob };
this.downloadMp3Btn.textContent = this.isIOSLike() ? 'Save MP3 (ready)' : 'Download MP3 (ready)';
this.updateStatus(this.isIOSLike() ? 'MP3 ready. Tap Save MP3 to open/share.' : 'MP3 ready. Click 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.isMp3Preparing = false;
this.downloadMp3Btn.disabled = false;
if (!this.preparedMp3) this.downloadMp3Btn.textContent = this.downloadMp3BtnLabel;
}
}
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';
this.resultsSection.classList.toggle('visible', hasResults);
this.chooseHelper.style.display = 'none';
// selected card
const hasSelection = state === 'selected' || state === 'generated';
this.playerSection.classList.toggle('visible', hasSelection);
// share buttons only after generation
this.copyLinkBtn.style.display = state === 'generated' ? 'inline-block' : 'none';
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';
// New search button only after generation
if (this.newSearchBtn) {
this.newSearchBtn.style.display = state === 'generated' ? 'inline-block' : 'none';
}
// Dim results when selected/generated, but keep them interactive
this.resultsSection.classList.toggle('collapsed', state === 'selected' || state === 'generated');
// Never lock search - users should always be able to search or pick different results
// Selected pre-generation content
const showPreGen = state === 'selected';
this.preGenActions.style.display = showPreGen ? 'block' : 'none';
this.preGenSupport.style.display = showPreGen ? 'flex' : 'none';
// Generated artifact content
const showGenerated = state === 'generated';
this.generatedBlock.style.display = showGenerated ? 'block' : 'none';
this.playPauseBtn.disabled = !showGenerated || !this.hasGenerated;
this.playPauseBtn.textContent = this.isMotifPlaying ? 'Pause' : 'Play';
// iOS banner only before generation
if (showPreGen) {
this.updateIOSAudioBanner();
} else {
this.iosAudioBanner.style.display = 'none';
}
}
private async handlePlayPause(): Promise<void> {
if (!this.hasGenerated) return;
if (this.isMotifPlaying) {
this.motifResumeProgress = this.motifEngine.getProgress();
this.motifEngine.stop();
this.isMotifPlaying = false;
this.stopMotifProgressUpdates();
this.playPauseBtn.textContent = 'Play';
return;
}
try {
// Audio exclusivity
this.stopPreview();
await unlockAudio();
this.motifEngine.setVolume(0.8);
await this.motifEngine.play();
if (this.motifResumeProgress > 0) this.motifEngine.seek(this.motifResumeProgress);
this.isMotifPlaying = true;
this.playPauseBtn.textContent = 'Pause';
this.startMotifProgressUpdates();
} catch {
// ignore
}
}
private updateEmbedSnippet(songTitle: string): void {
if (!this.embedSection || !this.embedCodeEl) return;
const notLive = this.embedSection.getAttribute('data-not-live') === 'true';
const base = notLive ? 'https://YOUR_DOMAIN' : window.location.origin;
const url = `${base}/embed?song=${encodeURIComponent(songTitle)}`;
const snippet = `<iframe\n src=\"${url}\"\n width=\"420\"\n height=\"260\"\n style=\"border:0;border-radius:12px;overflow:hidden\"\n allow=\"autoplay\"\n></iframe>`;
this.embedCodeEl.textContent = snippet;
this.embedSection.style.display = 'block';
if (this.copyToast) this.copyToast.style.display = 'none';
}
private async copyEmbedSnippet(): Promise<void> {
if (!this.embedCodeEl) return;
if (this.embedSection?.getAttribute('data-not-live') === 'true') {
this.updateStatus('Embed is coming soon.');
return;
}
const text = this.embedCodeEl.textContent || '';
if (!text.trim()) return;
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
} else {
// Fallback
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.left = '-9999px';
document.body.appendChild(ta);
ta.focus();
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
}
if (this.copyToast) {
this.copyToast.style.display = 'inline';
window.setTimeout(() => {
if (this.copyToast) this.copyToast.style.display = 'none';
}, 1200);
}
} catch {
this.updateStatus('Copy failed. Select the snippet and copy manually.');
}
}
}
// Make app globally available for onclick handlers
try {
const app = new MotifApp();
(window as any).app = app;
console.log('[MotifApp] Initialized successfully');
} catch (err) {
console.error('[MotifApp] Failed to initialize:', err);
// Show error in UI so users can report it
const status = document.getElementById('status');
if (status) {
status.textContent = `App error: ${err instanceof Error ? err.message : 'Unknown error'}. Please refresh.`;
status.style.color = '#ff6b6b';
}
}