Implement complete search results + dual player UI

Backend Enhancements:
- Add ParsedMIDIInfo with track analysis and quality issues
- New /api/midi/parse endpoint for MIDI metadata extraction
- Enhanced MIDIService with parseMIDI method

MIDI Preview Player:
- Complete MIDIPlayer class with polyphonic playback
- Basic oscillator mapping (bass=square, mid=triangle, high=sine)
- Lookahead scheduling with proper cleanup
- Automatic stopping when MIDI ends

New UI Flow:
1. Search → Results table with metadata
2. Select MIDI → Shows parsed info (duration, tracks, tempo)
3. Dual transport: Preview Original vs Generate Motif
4. Try Next Result button for easy A/B testing

Search Results Table:
- Title, source, confidence bar, duration, track count
- Quality issues display (short duration, few notes, etc.)
- Auto-select best result, manual selection available
- Visual confidence bars and issue warnings

This makes the project feel "real" - you can now:
- See exactly what MIDI was found
- Verify it's the right song via preview
- Compare original vs procedural synthesis
- Easily try different results

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
b1rdmania
2025-12-17 21:04:29 +00:00
parent 2d4c748d4c
commit 177e8fbed3
8 changed files with 731 additions and 36 deletions
+16
View File
@@ -27,6 +27,22 @@ export class MotifEngine {
this.roleMapper = new RoleMapper();
}
async generateFromMIDI(events: NoteEvent[]): Promise<void> {
// Process events directly (bypass search/fetch)
const features = this.midiProcessor.extractFeatures(events);
const roleAssignments = this.roleMapper.assignRoles(features, events);
this.currentFeatures = features;
// Initialize audio context and synthesis engine
if (!this.audioContext) {
this.audioContext = new AudioContext();
}
this.synthesisEngine = new SynthesisEngine(this.audioContext, this.config);
this.synthesisEngine.setupLayers(roleAssignments);
}
async generateFromSong(songName: string): Promise<void> {
let events: NoteEvent[];
+233 -32
View File
@@ -1,73 +1,272 @@
import { MotifEngine } from './core/MotifEngine';
import { MIDIService } from './services/MIDIService';
import { MIDIParser } from './midi/MIDIParser';
import { MIDIPlayer } from './synthesis/MIDIPlayer';
import type { NoteEvent } from './types';
class MotifApp {
private engine: MotifEngine;
private generateBtn: HTMLButtonElement;
private playBtn: HTMLButtonElement;
private stopBtn: HTMLButtonElement;
private motifEngine: MotifEngine;
private midiService: MIDIService;
private midiPlayer: MIDIPlayer;
private audioContext: AudioContext;
private searchBtn: HTMLButtonElement;
private songInput: HTMLInputElement;
private status: HTMLElement;
private resultsSection: HTMLElement;
private resultsBody: HTMLElement;
private playerSection: HTMLElement;
private selectedTitle: HTMLElement;
private selectedMeta: HTMLElement;
private previewBtn: HTMLButtonElement;
private previewStopBtn: HTMLButtonElement;
private motifBtn: HTMLButtonElement;
private motifStopBtn: HTMLButtonElement;
private nextResultBtn: HTMLButtonElement;
private searchResults: any[] = [];
private selectedResultIndex = 0;
private currentMIDI: { events: NoteEvent[], metadata: any } | null = null;
constructor() {
this.engine = new MotifEngine();
this.audioContext = new AudioContext();
this.motifEngine = new MotifEngine();
this.midiService = new MIDIService();
this.midiPlayer = new MIDIPlayer(this.audioContext);
this.initializeUI();
this.setupEventListeners();
}
private initializeUI(): void {
this.generateBtn = document.getElementById('generateBtn') as HTMLButtonElement;
this.playBtn = document.getElementById('playBtn') as HTMLButtonElement;
this.stopBtn = document.getElementById('stopBtn') as HTMLButtonElement;
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.selectedTitle = document.getElementById('selectedTitle')!;
this.selectedMeta = document.getElementById('selectedMeta')!;
this.previewBtn = document.getElementById('previewBtn') as HTMLButtonElement;
this.previewStopBtn = document.getElementById('previewStopBtn') as HTMLButtonElement;
this.motifBtn = document.getElementById('motifBtn') as HTMLButtonElement;
this.motifStopBtn = document.getElementById('motifStopBtn') as HTMLButtonElement;
this.nextResultBtn = document.getElementById('nextResultBtn') as HTMLButtonElement;
}
private setupEventListeners(): void {
this.generateBtn.addEventListener('click', () => this.handleGenerate());
this.playBtn.addEventListener('click', () => this.handlePlay());
this.stopBtn.addEventListener('click', () => this.handleStop());
this.searchBtn.addEventListener('click', () => this.handleSearch());
this.songInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
this.handleGenerate();
this.handleSearch();
}
});
this.previewBtn.addEventListener('click', () => this.handlePreview());
this.previewStopBtn.addEventListener('click', () => this.handlePreviewStop());
this.motifBtn.addEventListener('click', () => this.handleMotif());
this.motifStopBtn.addEventListener('click', () => this.handleMotifStop());
this.nextResultBtn.addEventListener('click', () => this.handleNextResult());
}
private async handleGenerate(): Promise<void> {
private async handleSearch(): Promise<void> {
const songName = this.songInput.value.trim();
if (!songName) return;
this.updateStatus('Generating structure...');
this.generateBtn.disabled = true;
this.updateStatus('Searching for MIDI files...');
this.searchBtn.disabled = true;
this.hideResults();
try {
await this.engine.generateFromSong(songName);
this.updateStatus(`Generated: ${songName} - Ready to play`);
this.playBtn.disabled = false;
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.updateStatus(`Found ${results.length} MIDI files. Select one to play.`);
} catch (error) {
this.updateStatus(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
this.updateStatus(`Search error: ${error instanceof Error ? error.message : 'Unknown error'}`);
} finally {
this.generateBtn.disabled = false;
this.searchBtn.disabled = false;
}
}
private async handlePlay(): Promise<void> {
private displayResults(): void {
this.resultsBody.innerHTML = '';
this.searchResults.forEach((result, index) => {
const row = document.createElement('tr');
if (index === this.selectedResultIndex) {
row.classList.add('selected');
}
row.innerHTML = `
<td>${result.title}</td>
<td>${result.source}</td>
<td>
<div class="confidence-bar">
<div class="confidence-fill" style="width: ${result.confidence * 100}%"></div>
</div>
</td>
<td>${result.parsed ? Math.round(result.parsed.durationSec) + 's' : '?'}</td>
<td>${result.parsed ? result.parsed.tracks.length : '?'}</td>
<td class="issues">${result.parsed?.issues.join(', ') || ''}</td>
<td><button onclick="window.app.selectResult(${index})">Select</button></td>
`;
this.resultsBody.appendChild(row);
});
this.resultsSection.classList.add('visible');
// Auto-select first result
if (this.searchResults.length > 0) {
this.selectResult(0);
}
}
async selectResult(index: number): Promise<void> {
if (index < 0 || index >= this.searchResults.length) return;
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 MIDI file...');
this.disablePlayerControls();
try {
await this.engine.play();
this.updateStatus('Playing...');
this.playBtn.disabled = true;
this.stopBtn.disabled = false;
// 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);
this.currentMIDI = { events, metadata };
// Load into players
this.midiPlayer.load(events);
// Update UI
this.selectedTitle.textContent = result.title;
this.selectedMeta.innerHTML = `
<strong>Source:</strong> ${result.source} |
<strong>Duration:</strong> ${Math.round(metadata.duration || 0)}s |
<strong>Tracks:</strong> ${metadata.trackCount} |
<strong>Notes:</strong> ${events.length} |
<strong>Tempo:</strong> ${metadata.tempo}bpm
`;
this.playerSection.classList.add('visible');
this.enablePlayerControls();
this.updateStatus('MIDI loaded. You can now preview or generate.');
} catch (error) {
this.updateStatus(`Playback error: ${error instanceof Error ? error.message : 'Unknown error'}`);
this.updateStatus(`Load error: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
private handleStop(): void {
this.engine.stop();
this.updateStatus('Stopped');
this.playBtn.disabled = false;
this.stopBtn.disabled = true;
private async handlePreview(): Promise<void> {
if (!this.currentMIDI) return;
try {
await this.midiPlayer.play();
this.previewBtn.disabled = true;
this.previewStopBtn.disabled = false;
this.updateStatus('Playing MIDI preview...');
} catch (error) {
this.updateStatus(`Preview error: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
private handlePreviewStop(): void {
this.midiPlayer.stop();
this.previewBtn.disabled = false;
this.previewStopBtn.disabled = true;
this.updateStatus('Preview stopped.');
}
private async handleMotif(): Promise<void> {
if (!this.currentMIDI) return;
const result = this.searchResults[this.selectedResultIndex];
try {
this.updateStatus('Generating Motif synthesis...');
this.motifBtn.disabled = true;
// Use the current MIDI data directly
await this.motifEngine.generateFromMIDI(this.currentMIDI.events);
await this.motifEngine.play();
this.motifStopBtn.disabled = false;
this.updateStatus('Playing Motif synthesis...');
} catch (error) {
this.updateStatus(`Motif error: ${error instanceof Error ? error.message : 'Unknown error'}`);
this.motifBtn.disabled = false;
}
}
private handleMotifStop(): void {
this.motifEngine.stop();
this.motifBtn.disabled = false;
this.motifStopBtn.disabled = true;
this.updateStatus('Motif synthesis stopped.');
}
private handleNextResult(): void {
const nextIndex = (this.selectedResultIndex + 1) % this.searchResults.length;
this.selectResult(nextIndex);
}
private hideResults(): void {
this.resultsSection.classList.remove('visible');
this.playerSection.classList.remove('visible');
}
private enablePlayerControls(): void {
this.previewBtn.disabled = false;
this.motifBtn.disabled = false;
this.nextResultBtn.disabled = this.searchResults.length <= 1;
}
private disablePlayerControls(): void {
this.previewBtn.disabled = true;
this.previewStopBtn.disabled = true;
this.motifBtn.disabled = true;
this.motifStopBtn.disabled = true;
this.nextResultBtn.disabled = true;
}
private updateStatus(message: string): void {
@@ -75,4 +274,6 @@ class MotifApp {
}
}
new MotifApp();
// Make app globally available for onclick handlers
const app = new MotifApp();
(window as any).app = app;
+35
View File
@@ -5,6 +5,25 @@ interface MIDISearchResult {
pageUrl: string;
midiUrl: string;
confidence: number;
parsed?: ParsedMIDIInfo;
}
interface ParsedMIDIInfo {
durationSec: number;
tempoBpm: number;
timeSig?: { num: number; den: number };
tracks: TrackInfo[];
noteCount: number;
issues: string[];
}
interface TrackInfo {
id: number;
name?: string;
program?: number;
noteCount: number;
channel?: number;
register: 'low' | 'mid' | 'high';
}
interface MIDISearchResponse {
@@ -50,6 +69,21 @@ export class MIDIService {
}
}
async parseMIDI(url: string): Promise<ParsedMIDIInfo | null> {
try {
const response = await fetch(`${this.baseUrl}/api/midi/parse?u=${encodeURIComponent(url)}`);
if (!response.ok) {
throw new Error(`Parse failed: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('MIDI parse error:', error);
return null;
}
}
async checkHealth(): Promise<boolean> {
try {
const response = await fetch(`${this.baseUrl}/health`);
@@ -58,4 +92,5 @@ export class MIDIService {
return false;
}
}
}
}
+196
View File
@@ -0,0 +1,196 @@
import type { NoteEvent } from '../types';
export class MIDIPlayer {
private audioContext: AudioContext;
private masterGain: GainNode;
private isPlaying = false;
private schedulerIntervalId: number | null = null;
private startTime = 0;
private events: NoteEvent[] = [];
private currentEventIndex = 0;
constructor(audioContext: AudioContext) {
this.audioContext = audioContext;
this.masterGain = audioContext.createGain();
this.masterGain.connect(audioContext.destination);
this.masterGain.gain.value = 0.3;
}
load(events: NoteEvent[]): void {
this.events = [...events].sort((a, b) => a.time - b.time);
this.currentEventIndex = 0;
console.log(`Loaded ${this.events.length} MIDI events for preview`);
}
async play(): Promise<void> {
if (this.isPlaying) return;
if (this.events.length === 0) {
throw new Error('No MIDI events loaded');
}
if (this.audioContext.state === 'suspended') {
await this.audioContext.resume();
}
this.isPlaying = true;
this.startTime = this.audioContext.currentTime;
this.currentEventIndex = 0;
// Schedule events with lookahead
this.schedulerIntervalId = window.setInterval(() => {
this.scheduleEvents();
}, 25); // 25ms lookahead scheduling
console.log('MIDI preview started');
}
stop(): void {
if (!this.isPlaying) return;
this.isPlaying = false;
if (this.schedulerIntervalId) {
clearInterval(this.schedulerIntervalId);
this.schedulerIntervalId = null;
}
// Fade out
this.masterGain.gain.linearRampToValueAtTime(0, this.audioContext.currentTime + 0.1);
// Reset volume after fade
setTimeout(() => {
if (!this.isPlaying) {
this.masterGain.gain.value = 0.3;
}
}, 150);
console.log('MIDI preview stopped');
}
private scheduleEvents(): void {
if (!this.isPlaying) return;
const currentTime = this.audioContext.currentTime;
const lookahead = 0.1; // 100ms lookahead
const scheduleUntil = currentTime + lookahead;
while (this.currentEventIndex < this.events.length) {
const event = this.events[this.currentEventIndex];
const eventTime = this.startTime + event.time;
// Stop if we're past the lookahead window
if (eventTime > scheduleUntil) break;
// Schedule if the event hasn't been played yet
if (eventTime >= currentTime) {
this.scheduleNote(event, eventTime);
}
this.currentEventIndex++;
}
// Stop when all events are done
if (this.currentEventIndex >= this.events.length) {
// Check if we're past the last event's end time
const lastEvent = this.events[this.events.length - 1];
const lastEventEnd = this.startTime + lastEvent.time + lastEvent.duration;
if (currentTime > lastEventEnd + 1.0) { // 1 second grace period
this.stop();
}
}
}
private scheduleNote(event: NoteEvent, when: number): void {
const osc = this.audioContext.createOscillator();
const envelope = this.audioContext.createGain();
const filter = this.audioContext.createBiquadFilter();
// Basic frequency conversion
const frequency = 440 * Math.pow(2, (event.pitch - 69) / 12);
osc.frequency.value = frequency;
// Simple timbre based on track/register
if (event.pitch < 48) {
// Bass register
osc.type = 'square';
filter.type = 'lowpass';
filter.frequency.value = 400;
} else if (event.pitch > 84) {
// High register
osc.type = 'sine';
filter.type = 'highpass';
filter.frequency.value = 800;
} else {
// Mid register
osc.type = 'triangle';
filter.type = 'bandpass';
filter.frequency.value = 1000;
}
// Channel 10 (drums) handling
if (event.track === 9) { // Track 9 = MIDI channel 10 (drums)
osc.type = 'sawtooth';
filter.type = 'highpass';
filter.frequency.value = 2000;
}
// Connect audio graph
osc.connect(filter);
filter.connect(envelope);
envelope.connect(this.masterGain);
// Envelope
const gainValue = (event.velocity * 0.4) / Math.max(this.getPolyphonyAtTime(event.time), 1);
const attackTime = Math.min(0.02, event.duration * 0.1);
const releaseTime = Math.min(0.05, event.duration * 0.2);
envelope.gain.setValueAtTime(0, when);
envelope.gain.linearRampToValueAtTime(gainValue, when + attackTime);
envelope.gain.linearRampToValueAtTime(gainValue * 0.7, when + event.duration - releaseTime);
envelope.gain.exponentialRampToValueAtTime(0.001, when + event.duration);
osc.start(when);
osc.stop(when + event.duration);
// Cleanup
setTimeout(() => {
try {
osc.disconnect();
filter.disconnect();
envelope.disconnect();
} catch (e) {
// Already disconnected
}
}, (event.duration + 0.1) * 1000);
}
private getPolyphonyAtTime(time: number): number {
// Count overlapping notes for volume scaling
let count = 0;
const tolerance = 0.05; // 50ms tolerance
for (const event of this.events) {
if (event.time <= time + tolerance &&
event.time + event.duration >= time - tolerance) {
count++;
}
}
return Math.max(count, 1);
}
getProgress(): number {
if (this.events.length === 0) return 0;
const currentTime = this.audioContext.currentTime - this.startTime;
const totalDuration = Math.max(...this.events.map(e => e.time + e.duration));
return Math.max(0, Math.min(1, currentTime / totalDuration));
}
getDuration(): number {
if (this.events.length === 0) return 0;
return Math.max(...this.events.map(e => e.time + e.duration));
}
}