Add embeddable /embed widget and copy-paste snippet.

This commit is contained in:
b1rdmania
2025-12-18 20:58:35 +00:00
parent e33cd26238
commit d0be58296e
7 changed files with 631 additions and 2 deletions
+242
View File
@@ -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<T extends HTMLElement>(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<HTMLElement>('status').textContent = msg;
}
function clamp01(v: number): number {
return Math.max(0, Math.min(1, v));
}
async function main(): Promise<void> {
const songInput = qs<HTMLInputElement>('songInput');
const loadBtn = qs<HTMLButtonElement>('loadBtn');
const nextBtn = qs<HTMLButtonElement>('nextBtn');
const previewPlayBtn = qs<HTMLButtonElement>('previewPlayBtn');
const previewStopBtn = qs<HTMLButtonElement>('previewStopBtn');
const previewVol = qs<HTMLInputElement>('previewVol');
const motifPlayBtn = qs<HTMLButtonElement>('motifPlayBtn');
const motifStopBtn = qs<HTMLButtonElement>('motifStopBtn');
const motifVol = qs<HTMLInputElement>('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<void> {
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<void> {
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();
+65 -2
View File
@@ -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<void> {
@@ -236,6 +251,8 @@ class MotifApp {
<strong>Notes:</strong> ${events.length} |
<strong>Tempo:</strong> ${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 = `<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;
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