diff --git a/README.md b/README.md index 8016c19..93a7be9 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,26 @@ MOTIF is a small experiment in **music-as-code**: treat MIDI as structural data, Note: if the backend isn’t deployed/configured for the demo environment, search/fetch won’t work from the hosted URL. Run locally for the full experience. +## Use on your website (embed) + +MOTIF includes an embeddable widget page at **`/embed`**. It generates audio in the user’s browser (no audio files needed). + +Example: + +```html + +``` + +### Embed parameters (v1) +- **`song`**: the query to load (e.g. `Hotel%20California`)\n+- **`volume`**: preview volume `0..1` (optional)\n+- **`motifVolume`**: motif volume `0..1` (optional) + +Notes:\n- Autoplay is best-effort; iOS/Safari requires a user gesture before sound.\n ## Features - **MIDI search** (currently BitMidi; additional sources optional) diff --git a/embed.html b/embed.html new file mode 100644 index 0000000..1251409 --- /dev/null +++ b/embed.html @@ -0,0 +1,223 @@ + + + + + + MOTIF Embed + + + +
+
+
+

MOTIF

+
Embed
+
+ +
+
+ + + +
+ +
Ready.
+ +
+

Playback

+
+
+

Preview

+

Play the original MIDI (piano).

+
+ + +
+
+ + +
+
+ +
+

Variation

+

Generate a chiptune-ish Motif.

+
+ + +
+
+ + +
+
+
+ +
+ Note: autoplay is best-effort; iOS/Safari requires a tap before sound. +
+
+
+
+ + + + + diff --git a/index.html b/index.html index 25104e2..1978181 100644 --- a/index.html +++ b/index.html @@ -541,6 +541,55 @@ transition: width 0.1s linear; } + /* Use on your website */ + .embed-card { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius); + padding: calc(var(--spacing-unit) * 3); + box-shadow: var(--shadow-md); + } + .embed-row { + display: flex; + gap: calc(var(--spacing-unit) * 1.5); + align-items: flex-start; + flex-wrap: wrap; + } + .embed-row button { + flex-shrink: 0; + } + .codeblock { + flex: 1; + min-width: 260px; + margin: 0; + padding: calc(var(--spacing-unit) * 2); + border-radius: calc(var(--radius) + 2px); + background: rgba(0,0,0,0.35); + border: 1px solid rgba(255,255,255,0.08); + overflow: auto; + box-shadow: var(--shadow-sm); + } + .codeblock code { + font-size: 12px; + color: rgba(255,255,255,0.85); + white-space: pre; + } + .embed-hint { + margin-top: calc(var(--spacing-unit) * 1.5); + color: var(--color-text-dim); + font-size: 12px; + } + .embed-hint strong { + color: var(--color-text); + font-weight: 600; + } + .copy-toast { + margin-left: auto; + font-size: 12px; + color: rgba(0,255,136,0.85); + display: none; + } + .engine-selector { margin-top: 10px; font-size: 0.9em; @@ -647,6 +696,27 @@ + + diff --git a/src/embed.ts b/src/embed.ts new file mode 100644 index 0000000..86e5410 --- /dev/null +++ b/src/embed.ts @@ -0,0 +1,242 @@ +import { MIDIService } from './services/MIDIService'; +import { MIDIParser } from './midi/MIDIParser'; +import { SoundfontMIDIPlayer } from './synthesis/SoundfontMIDIPlayer'; +import { MotifEngine } from './core/MotifEngine'; +import type { NoteEvent } from './types'; + +type SearchResult = { + title: string; + midiUrl: string; + confidence: number; +}; + +function qs(id: string): T { + const el = document.getElementById(id); + if (!el) throw new Error(`Missing element #${id}`); + return el as T; +} + +function getParam(name: string): string | null { + return new URLSearchParams(window.location.search).get(name); +} + +function setStatus(msg: string): void { + qs('status').textContent = msg; +} + +function clamp01(v: number): number { + return Math.max(0, Math.min(1, v)); +} + +async function main(): Promise { + const songInput = qs('songInput'); + const loadBtn = qs('loadBtn'); + const nextBtn = qs('nextBtn'); + + const previewPlayBtn = qs('previewPlayBtn'); + const previewStopBtn = qs('previewStopBtn'); + const previewVol = qs('previewVol'); + + const motifPlayBtn = qs('motifPlayBtn'); + const motifStopBtn = qs('motifStopBtn'); + const motifVol = qs('motifVol'); + + const midiService = new MIDIService(); + const audioContext = new AudioContext(); + const previewPlayer = new SoundfontMIDIPlayer(audioContext); + const motifEngine = new MotifEngine(); + + let results: SearchResult[] = []; + let selectedIndex = 0; + let currentEvents: NoteEvent[] | null = null; + let isMotifPlaying = false; + + function disableAll(): void { + previewPlayBtn.disabled = true; + previewStopBtn.disabled = true; + motifPlayBtn.disabled = true; + motifStopBtn.disabled = true; + nextBtn.disabled = true; + } + + function stopEverything(): void { + previewPlayer.stop(); + motifEngine.stop(); + previewPlayBtn.disabled = currentEvents === null; + previewStopBtn.disabled = true; + motifStopBtn.disabled = true; + isMotifPlaying = false; + } + + async function loadIndex(index: number): Promise { + if (index < 0 || index >= results.length) return; + selectedIndex = index; + const r = results[selectedIndex]; + + setStatus(`Loading: ${r.title}`); + disableAll(); + + try { + const midiBuffer = await midiService.fetchMIDI(r.midiUrl); + if (!midiBuffer) throw new Error('Fetch failed'); + + const events = MIDIParser.parseMIDI(midiBuffer); + currentEvents = events; + + await previewPlayer.load(events); + previewPlayer.setVolume(clamp01(parseFloat(previewVol.value))); + + setStatus(`Ready: ${r.title}`); + previewPlayBtn.disabled = false; + motifPlayBtn.disabled = false; + nextBtn.disabled = results.length <= 1; + } catch (e) { + currentEvents = null; + setStatus(`Load error: ${e instanceof Error ? e.message : 'Unknown error'}`); + } + } + + async function doSearch(): Promise { + const query = songInput.value.trim(); + if (!query) return; + + stopEverything(); + disableAll(); + setStatus('Searching…'); + loadBtn.disabled = true; + + try { + const r = await midiService.search(query); + results = r.map((x: any) => ({ + title: x.title, + midiUrl: x.midiUrl, + confidence: x.confidence, + })); + + if (results.length === 0) { + setStatus('No results found.'); + return; + } + + // Pick best by confidence (backend already sorts, but keep safe) + results.sort((a, b) => (b.confidence ?? 0) - (a.confidence ?? 0)); + selectedIndex = 0; + setStatus(`Found ${results.length}. Loading best match…`); + await loadIndex(0); + } catch (e) { + setStatus(`Search error: ${e instanceof Error ? e.message : 'Unknown error'}`); + } finally { + loadBtn.disabled = false; + } + } + + loadBtn.addEventListener('click', () => void doSearch()); + songInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') void doSearch(); + }); + + nextBtn.addEventListener('click', () => { + if (results.length <= 1) return; + const next = (selectedIndex + 1) % results.length; + void loadIndex(next); + }); + + previewVol.addEventListener('input', (e) => { + const v = clamp01(parseFloat((e.target as HTMLInputElement).value)); + previewPlayer.setVolume(v); + }); + + motifVol.addEventListener('input', (e) => { + const v = clamp01(parseFloat((e.target as HTMLInputElement).value)); + motifEngine.setVolume(v); + }); + + previewPlayBtn.addEventListener('click', async () => { + if (!currentEvents) return; + try { + motifEngine.stop(); + isMotifPlaying = false; + await previewPlayer.play(); + previewPlayBtn.disabled = true; + previewStopBtn.disabled = false; + motifStopBtn.disabled = true; + setStatus('Playing preview…'); + } catch (e) { + setStatus(`Preview error: ${e instanceof Error ? e.message : 'Unknown error'}`); + } + }); + + previewStopBtn.addEventListener('click', () => { + previewPlayer.stop(); + previewPlayBtn.disabled = currentEvents === null; + previewStopBtn.disabled = true; + setStatus('Preview stopped.'); + }); + + motifPlayBtn.addEventListener('click', async () => { + if (!currentEvents) return; + try { + // Stop preview and generate variation + previewPlayer.stop(); + previewPlayBtn.disabled = false; + previewStopBtn.disabled = true; + + motifPlayBtn.disabled = true; + setStatus('Generating variation…'); + + await motifEngine.generateFromMIDI(currentEvents, 'procedural'); + motifEngine.setVolume(clamp01(parseFloat(motifVol.value))); + await motifEngine.play(); + + isMotifPlaying = true; + motifStopBtn.disabled = false; + setStatus('Playing variation…'); + } catch (e) { + motifPlayBtn.disabled = false; + setStatus(`Motif error: ${e instanceof Error ? e.message : 'Unknown error'}`); + } + }); + + motifStopBtn.addEventListener('click', () => { + motifEngine.stop(); + isMotifPlaying = false; + motifStopBtn.disabled = true; + motifPlayBtn.disabled = currentEvents === null; + setStatus('Variation stopped.'); + }); + + // Initialize from query params + const song = getParam('song'); + const volume = getParam('volume'); + const motifVolume = getParam('motifVolume'); + + if (volume) { + previewVol.value = String(clamp01(parseFloat(volume))); + previewPlayer.setVolume(parseFloat(previewVol.value)); + } + if (motifVolume) { + motifVol.value = String(clamp01(parseFloat(motifVolume))); + } + + if (song) { + songInput.value = song; + setStatus('Loading from URL…'); + // best-effort: do not autoplay audio, just load the MIDI + void doSearch(); + } else { + setStatus('Ready. Add ?song=Hotel%20California to auto-load.'); + } + + // Defensive: stop audio when the iframe/tab is hidden + document.addEventListener('visibilitychange', () => { + if (document.hidden) { + stopEverything(); + if (isMotifPlaying) { + motifPlayBtn.disabled = currentEvents === null; + } + } + }); +} + +void main(); + diff --git a/src/main.ts b/src/main.ts index fd4e4c3..787397d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -41,6 +41,12 @@ class MotifApp { private nextResultBtn!: HTMLButtonElement; + // Embed snippet UI + private embedSection: HTMLElement | null = null; + private embedCodeEl: HTMLElement | null = null; + private copyEmbedBtn: HTMLButtonElement | null = null; + private copyToast: HTMLElement | null = null; + private searchResults: any[] = []; private selectedResultIndex = 0; private currentMIDI: { events: NoteEvent[], metadata: any } | null = null; @@ -86,6 +92,12 @@ class MotifApp { this.motifDuration = document.getElementById('motifDuration')!; this.nextResultBtn = document.getElementById('nextResultBtn') as HTMLButtonElement; + + // 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'); } private setupEventListeners(): void { @@ -121,6 +133,9 @@ class MotifApp { this.motifProgressBar.addEventListener('change', seekHandler); this.nextResultBtn.addEventListener('click', () => this.handleNextResult()); + + // Embed snippet copy + this.copyEmbedBtn?.addEventListener('click', () => void this.copyEmbedSnippet()); } private async handleSearch(): Promise { @@ -236,6 +251,8 @@ class MotifApp { Notes: ${events.length} | Tempo: ${metadata.tempo}bpm `; + + this.updateEmbedSnippet(result.title); this.playerSection.classList.add('visible'); this.enablePlayerControls(); @@ -279,8 +296,8 @@ class MotifApp { this.motifBtn.disabled = true; console.log('Calling generateFromMIDI with', this.currentMIDI.events.length, 'events'); - // Use the current MIDI data directly in passthrough mode - await this.motifEngine.generateFromMIDI(this.currentMIDI.events, 'passthrough'); + // 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(); @@ -375,6 +392,52 @@ class MotifApp { private updateStatus(message: string): void { this.status.textContent = message; } + + private updateEmbedSnippet(songTitle: string): void { + if (!this.embedSection || !this.embedCodeEl) return; + + const origin = window.location.origin; + const url = `${origin}/embed?song=${encodeURIComponent(songTitle)}`; + + const snippet = ``; + + this.embedCodeEl.textContent = snippet; + this.embedSection.style.display = 'block'; + if (this.copyToast) this.copyToast.style.display = 'none'; + } + + private async copyEmbedSnippet(): Promise { + if (!this.embedCodeEl) 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 diff --git a/vercel.json b/vercel.json index 864075f..cffc83d 100644 --- a/vercel.json +++ b/vercel.json @@ -22,6 +22,10 @@ "src": "/health", "dest": "server/src/server.ts" }, + { + "src": "/embed", + "dest": "/embed.html" + }, { "handle": "filesystem" }, { "src": "/(.*)", diff --git a/vite.config.ts b/vite.config.ts index 0b22f03..1b65634 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,4 +1,5 @@ import { defineConfig } from 'vite' +import { resolve } from 'node:path' export default defineConfig({ server: { @@ -7,5 +8,11 @@ export default defineConfig({ build: { outDir: 'dist', sourcemap: true, + rollupOptions: { + input: { + main: resolve(__dirname, 'index.html'), + embed: resolve(__dirname, 'embed.html'), + }, + }, }, }) \ No newline at end of file