diff --git a/index.html b/index.html index 30cc27c..b83d203 100644 --- a/index.html +++ b/index.html @@ -64,28 +64,24 @@ margin: 0 auto; } - h1 { - color: var(--color-accent-primary); + .hero { text-align: center; margin: 0 0 calc(var(--spacing-unit) * 4) 0; + } + .hero h1 { + color: var(--color-accent-primary); + margin: 0; font-size: 2em; - font-weight: 700; - letter-spacing: 0.05em; + font-weight: 800; + letter-spacing: 0.08em; text-shadow: var(--glow-primary); } - - .topbar { - display: flex; - align-items: center; - justify-content: center; - gap: calc(var(--spacing-unit) * 2); - margin-bottom: calc(var(--spacing-unit) * 2); - } - .topbar h1 { - margin: 0; + .hero .tagline { + margin: calc(var(--spacing-unit) * 1.5) 0 0 0; + color: rgba(255,255,255,0.72); + font-size: 14px; } .faq-link { - margin-left: auto; background: transparent; border: 1px solid rgba(255,255,255,0.14); color: rgba(255,255,255,0.85); @@ -230,8 +226,12 @@ color: rgba(255,255,255,0.85); } .controls { - margin: calc(var(--spacing-unit) * 3) 0; - text-align: center; + margin: calc(var(--spacing-unit) * 3) auto calc(var(--spacing-unit) * 2) auto; + display: flex; + gap: calc(var(--spacing-unit) * 1.5); + align-items: stretch; + justify-content: center; + max-width: 720px; } button { @@ -268,8 +268,8 @@ button.primary { background: var(--color-accent-primary); color: #000; - border-color: var(--color-accent-primary); - font-weight: 600; + border-color: rgba(0,255,136,0.4); + font-weight: 800; } button.primary:hover:not(:disabled) { @@ -277,6 +277,12 @@ box-shadow: var(--glow-primary), var(--shadow-md); } + /* Make Search the primary CTA */ + #searchBtn { + padding: 14px 18px; + border-radius: 12px; + } + #status { margin: calc(var(--spacing-unit) * 3) 0; padding: calc(var(--spacing-unit) * 2); @@ -289,11 +295,12 @@ } input[type="text"] { - background: var(--color-surface); + flex: 1; + background: rgba(0,0,0,0.25); color: var(--color-text); - border: 1px solid var(--color-border); - padding: calc(var(--spacing-unit) * 1.5) calc(var(--spacing-unit) * 2); - border-radius: var(--radius); + border: 1px solid rgba(255,255,255,0.14); + padding: 14px 14px; + border-radius: 12px; font-family: inherit; font-size: 16px; transition: border-color 150ms ease, box-shadow 150ms ease; @@ -301,8 +308,8 @@ input[type="text"]:focus { outline: none; - border-color: var(--color-accent-primary); - box-shadow: 0 0 0 3px rgba(0, 255, 136, 0.1); + border-color: rgba(0,255,136,0.45); + box-shadow: 0 0 0 3px rgba(0,255,136,0.18); } .results-section { @@ -312,6 +319,13 @@ .results-section.visible { display: block; } + .results-section.collapsed { + opacity: 0.55; + transition: opacity 160ms ease; + } + .results-section.collapsed:hover { + opacity: 1; + } .results-table { width: 100%; @@ -346,11 +360,11 @@ .results-table .duration-col { width: 80px; - text-align: center; + text-align: right; } - .results-table .preview-col { - width: 110px; - text-align: center; + .results-table .action-col { + width: 120px; + text-align: right; } .results-table tbody tr { @@ -394,30 +408,12 @@ width: 60px; } - .results-table .preview-col { - width: 84px; + .results-table .action-col { + width: 100px; } } - .results-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: calc(var(--spacing-unit) * 2); - flex-wrap: wrap; - margin: 0 0 calc(var(--spacing-unit) * 2) 0; - } - .results-header h3 { - margin: 0; - } - .results-header .volume-control { - margin-top: 0; - min-width: 260px; - flex: 0 1 360px; - } - .results-header .volume-control label { - font-size: 12px; - } + .results-header h3 { margin: 0; } .confidence-bar { width: 60px; @@ -431,6 +427,83 @@ background: linear-gradient(90deg, #ff4444, #ffaa44, #00ff88); transition: width 0.2s; } + + /* Selected source card */ + .selected-card { + margin-top: calc(var(--spacing-unit) * 3); + padding: calc(var(--spacing-unit) * 3); + background: var(--color-surface-elevated); + border: 1px solid rgba(255,255,255,0.10); + border-radius: 12px; + box-shadow: var(--shadow-lg); + } + .selected-title { + margin: 0; + font-size: 22px; + line-height: 1.2; + font-weight: 800; + letter-spacing: 0.01em; + color: var(--color-text); + } + .selected-meta { + margin: 10px 0 0 0; + font-size: 13px; + color: rgba(255,255,255,0.62); + } + .selected-actions { + display: flex; + gap: 12px; + margin-top: 16px; + flex-wrap: wrap; + } + .selected-actions .primary { + flex: 1; + min-width: 180px; + padding: 12px 16px; + } + .selected-actions button:not(.primary) { + flex: 1; + min-width: 160px; + } + .selected-support { + display: flex; + gap: 12px; + align-items: center; + flex-wrap: wrap; + margin-top: 12px; + } + .selected-support .volume-control { + margin-top: 0; + flex: 1; + min-width: 220px; + opacity: 0.85; + } + .engine-line { + margin-top: 14px; + color: rgba(255,255,255,0.72); + font-size: 13px; + } + details.details-toggle { + margin-top: 12px; + } + details.details-toggle summary { + cursor: pointer; + color: rgba(255,255,255,0.7); + font-size: 12px; + list-style: none; + } + details.details-toggle summary::-webkit-details-marker { display: none; } + .selected-details { + margin-top: 8px; + font-size: 12px; + color: rgba(255,255,255,0.58); + } + + .footer { + margin-top: calc(var(--spacing-unit) * 6); + display: flex; + justify-content: center; + } .player-section { margin: 30px 0; @@ -856,15 +929,14 @@
-
-

🎵 MOTIF

- +
+

MOTIF

+

Procedural music generated from MIDI structure

-

Procedural Music Synthesis from MIDI Structure

- - + +
Ready. Enter a song name to search for MIDI files.
@@ -872,18 +944,14 @@

Search Results

-
- - -
- + - - + + @@ -892,57 +960,55 @@
-
-

No MIDI selected

-

Select a MIDI file from search results to enable playback

-
+
+

No source selected

+
Search for a song, then choose a source to use.
-
-
-

Synthesis Engine

-

Run it through the Gameboy emulator.

+
+ +
-
-
-
-
-

Run the engine

-

Gameboy MIDI synthesis. Triangle, square, and sawtooth waveforms. Polyphonic 8-bit chiptune generation.

-
- -
- - - -
- - -
-
- -
-
-
- iOS Safari: tap once to enable audio, then press Generate & Play. -
- -
-
Audio: …
-
- - +
+ + +
+ + +
+
+ +
+ +
Reimagined as classic game-console music using Motif’s Wario engine.
+ +
+
+
+ iOS Safari: tap once to enable audio, then press Generate. +
+ +
+
Audio: …
+
+ + + +
+ Details +
+
+ +
- - - + + + + `; - // Make entire row clickable - row.addEventListener('click', () => this.selectResult(index)); - - const btn = row.querySelector('button.row-preview-btn') as HTMLButtonElement; - btn.addEventListener('click', (e) => { + // 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.handleRowPreview(index); + void this.selectResult(index); }); - this.previewButtons[index] = btn; this.resultsBody.appendChild(row); }); this.resultsSection.classList.add('visible'); + this.resultsSection.classList.remove('collapsed'); + this.playerSection.classList.remove('visible'); - // Auto-select first result - if (this.searchResults.length > 0) { - this.selectResult(0); - } - } - - private updatePreviewButtons(): void { - for (let i = 0; i < this.previewButtons.length; i++) { - const btn = this.previewButtons[i]; - if (!btn) continue; - btn.textContent = this.playingPreviewIndex === i ? 'Stop' : 'Play'; - } - } - - private async handleRowPreview(index: number): Promise { - if (this.playingPreviewIndex === index) { - this.stopPreview(true); - return; - } - - // Stop any existing preview first - this.stopPreview(false); - - // Ensure this MIDI is selected/loaded (also satisfies iOS user-gesture unlock path) - await this.selectResult(index); - - try { - const player = await this.ensureAudioReady(); - player.setVolume(parseFloat(this.soundfontVolumeSlider.value)); - await player.play(); - - this.playingPreviewIndex = index; - this.updatePreviewButtons(); - this.updateStatus('Previewing MIDI…'); - - // Best-effort: reset UI after playback ends (SoundfontMIDIPlayer self-stops) - const duration = player.getDuration(); - if (this.previewStopTimeout) window.clearTimeout(this.previewStopTimeout); - this.previewStopTimeout = window.setTimeout(() => { - if (this.playingPreviewIndex === index) { - this.stopPreview(false); - this.updateStatus('Preview finished.'); - } - }, Math.max(0.5, duration + 0.5) * 1000); - } catch (error) { - this.updateStatus(`Preview error: ${error instanceof Error ? error.message : 'Unknown error'}`); - this.stopPreview(false); - } - } - - private stopPreview(updateStatus: boolean): void { - if (this.previewStopTimeout) { - window.clearTimeout(this.previewStopTimeout); - this.previewStopTimeout = null; - } - this.soundfontPlayer?.stop(); - this.playingPreviewIndex = null; - this.updatePreviewButtons(); - if (updateStatus) this.updateStatus('Preview stopped.'); + // No auto-select: user should choose "Use this" } public async selectResult(index: number): Promise { @@ -390,7 +328,7 @@ class MotifApp { // Stop any playing Motif audio this.handleMotifStop(); // Stop any playing preview audio - this.stopPreview(false); + this.soundfontPlayer?.stop(); this.selectedResultIndex = index; const result = this.searchResults[index]; @@ -401,7 +339,7 @@ class MotifApp { row.classList.toggle('selected', i === index); }); - this.updateStatus('Loading MIDI file...'); + this.updateStatus('Loading…'); this.disablePlayerControls(); try { @@ -423,38 +361,61 @@ class MotifApp { this.currentMIDI = { events, metadata: { ...metadata, duration: actualDuration } }; - // Load into preview player (ensure audio unlocked on iOS) - const player = await this.ensureAudioReady(); - await player.load(events); - player.setVolume(parseFloat(this.soundfontVolumeSlider.value)); - // Update UI - this.selectedTitle.textContent = result.title; - this.selectedMeta.innerHTML = ` - Source: ${result.source} | - Duration: ${Math.round(actualDuration)}s | - Tracks: ${metadata.trackCount} | - Notes: ${events.length} | - Tempo: ${metadata.tempo}bpm - `; + const displayTitle = this.cleanSongTitle(result.title); + this.selectedTitle.textContent = displayTitle; + this.selectedMeta.textContent = `Source: ${this.formatSourceLabel(result.source)} · Length: ${this.formatDuration(actualDuration)} · Notes: ${events.length}`; + + // Optional details (hidden behind toggle) + const detailParts: string[] = []; + const trackCount = Number(metadata.trackCount); + const tempo = Number(metadata.tempo); + const duration = Number(actualDuration); + if (Number.isFinite(trackCount) && trackCount > 0) detailParts.push(`Tracks: ${trackCount}`); + if (Number.isFinite(tempo) && tempo > 0) detailParts.push(`Tempo: ${Math.round(tempo)} bpm`); + if (Number.isFinite(duration) && duration > 0) detailParts.push(`Length: ${this.formatDuration(duration)}`); + detailParts.push(`Notes: ${events.length}`); + this.selectedDetails.textContent = detailParts.join(' · '); this.updateEmbedSnippet(result.title); this.updateIOSAudioBanner(); this.playerSection.classList.add('visible'); + this.resultsSection.classList.add('collapsed'); this.enablePlayerControls(); - this.updateStatus('MIDI loaded. You can now preview or generate.'); + this.updateStatus(''); } catch (error) { this.updateStatus(`Load error: ${error instanceof Error ? error.message : 'Unknown error'}`); } } + private async handlePreviewMIDI(): Promise { + if (!this.currentMIDI) return; + try { + this.previewBtn.disabled = true; + const prevLabel = this.previewBtn.textContent || 'Preview MIDI'; + this.previewBtn.textContent = 'Previewing…'; + + const player = await this.ensureAudioReady(); + await player.load(this.currentMIDI.events); + player.setVolume(parseFloat(this.soundfontVolumeSlider.value)); + await player.play(); + + const duration = player.getDuration(); + window.setTimeout(() => { + this.previewBtn.disabled = false; + this.previewBtn.textContent = prevLabel; + }, Math.max(0.5, duration + 0.25) * 1000); + } catch { + this.previewBtn.disabled = false; + this.previewBtn.textContent = 'Preview MIDI'; + } + } + // Motif handlers private async handleMotif(): Promise { - console.log('Motif Generate & Play button clicked'); if (!this.currentMIDI) { - console.error('No MIDI loaded'); return; } @@ -463,14 +424,12 @@ class MotifApp { await unlockAudio(); this.updateIOSAudioBanner(); - this.updateStatus('Generating Motif synthesis...'); + this.updateStatus('Generating…'); this.motifBtn.disabled = true; - console.log('Calling generateFromMIDI with', this.currentMIDI.events.length, 'events'); // Generate a variation using the procedural role-mapping mode await this.motifEngine.generateFromMIDI(this.currentMIDI.events, 'procedural'); - console.log('Calling motifEngine.play()'); await this.motifEngine.play(); this.motifStopBtn.disabled = false; @@ -485,10 +444,8 @@ class MotifApp { // Start progress updates this.startMotifProgressUpdates(); - this.updateStatus('Playing Motif synthesis...'); - console.log('Motif playback started successfully'); + this.updateStatus(''); } catch (error) { - console.error('Motif error:', error); this.updateStatus(`Motif error: ${error instanceof Error ? error.message : 'Unknown error'}`); this.motifBtn.disabled = false; } @@ -499,7 +456,7 @@ class MotifApp { this.motifBtn.disabled = false; this.motifStopBtn.disabled = true; this.stopMotifProgressUpdates(); - this.updateStatus('Motif synthesis stopped.'); + this.updateStatus(''); } private handleMotifSeek(progress: number): void { @@ -536,6 +493,50 @@ class MotifApp { 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(); + return this.titleCase(cleaned || 'Unknown'); + } + + private formatDuration(secondsMaybe: any): string { + const seconds = Number(secondsMaybe); + if (!Number.isFinite(seconds) || seconds <= 0) return '—'; + const mins = Math.floor(seconds / 60); + const secs = Math.floor(seconds % 60); + return `${mins}:${secs.toString().padStart(2, '0')}`; + } + + 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 hideResults(): void { this.resultsSection.classList.remove('visible'); this.playerSection.classList.remove('visible'); @@ -543,11 +544,13 @@ class MotifApp { private enablePlayerControls(): void { this.motifBtn.disabled = false; - this.copyLinkBtn.disabled = this.searchResults.length === 0; + this.previewBtn.disabled = false; + this.copyLinkBtn.disabled = this.currentMIDI == null; } private disablePlayerControls(): void { this.motifBtn.disabled = true; + this.previewBtn.disabled = true; this.motifStopBtn.disabled = true; this.copyLinkBtn.disabled = true; } @@ -627,9 +630,13 @@ class MotifApp { try { this.copyLinkBtn.disabled = true; await this.copyToClipboard(shareUrl); - this.updateStatus('Link copied.'); + const prev = this.copyLinkBtn.textContent || 'Copy link'; + this.copyLinkBtn.textContent = 'Copied'; + window.setTimeout(() => { + this.copyLinkBtn.textContent = prev; + }, 900); } catch { - this.updateStatus('Copy failed.'); + // keep quiet; clipboard can fail in some contexts } finally { this.copyLinkBtn.disabled = false; }
TitleSong SourceDurationPreviewLengthAction
${result.title}${result.source}${result.parsed ? Math.round(result.parsed.durationSec) + 's' : '?'}${this.cleanSongTitle(result.title)}${this.formatSourceLabel(result.source)}${this.formatDuration(result.parsed?.durationSec)}