From d0be58296e8f89dda5d4f4b0396836ddf6ecdce2 Mon Sep 17 00:00:00 2001
From: b1rdmania <102524336+b1rdmania@users.noreply.github.com>
Date: Thu, 18 Dec 2025 20:58:35 +0000
Subject: [PATCH] Add embeddable /embed widget and copy-paste snippet.
---
README.md | 20 ++++
embed.html | 223 +++++++++++++++++++++++++++++++++++++++++++++
index.html | 70 ++++++++++++++
src/embed.ts | 242 +++++++++++++++++++++++++++++++++++++++++++++++++
src/main.ts | 67 +++++++++++++-
vercel.json | 4 +
vite.config.ts | 7 ++
7 files changed, 631 insertions(+), 2 deletions(-)
create mode 100644 embed.html
create mode 100644 src/embed.ts
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
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Ready.
+
+
+
Playback
+
+
+
Preview
+
Play the original MIDI (piano).
+
+
+
+
+
+
+
+
+
+
+
+
Variation
+
Generate a chiptune-ish Motif.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
<iframe src="..."></iframe>
+
+
+ Copied
+
+
+
+ Tip: iOS/Safari requires a tap before audio can start. Add allow="autoplay" and expect a user gesture.
+
+
+
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