Add complete MIDI search and synthesis pipeline

Backend (MVP):
- Express API with /search and /fetch endpoints
- BitMidi and Dongrays search adapters with scoring heuristics
- CORS proxy with disk caching and validation
- Quality assessment and file size limits

Frontend Integration:
- Switch to @tonejs/midi parser for reliable MIDI parsing
- MIDIService for backend API communication
- MotifEngine updated to try real MIDI, fallback to synthetic
- Enhanced error handling and logging

Development:
- Concurrent frontend/backend npm scripts
- Basic project documentation

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
b1rdmania
2025-12-17 20:45:40 +00:00
parent 4b7836b925
commit 11c894bd91
14 changed files with 834 additions and 6 deletions
+36 -5
View File
@@ -1,5 +1,7 @@
import type { NoteEvent, StructuralFeatures, MotifConfig, SynthLayer } from '../types';
import { MIDIProcessor } from '../midi/MIDIProcessor';
import { MIDIParser } from '../midi/MIDIParser';
import { MIDIService } from '../services/MIDIService';
import { RoleMapper } from './RoleMapper';
import { SynthesisEngine } from '../synthesis/SynthesisEngine';
@@ -7,6 +9,7 @@ export class MotifEngine {
private audioContext: AudioContext | null = null;
private config: MotifConfig;
private midiProcessor: MIDIProcessor;
private midiService: MIDIService;
private roleMapper: RoleMapper;
private synthesisEngine: SynthesisEngine | null = null;
private currentFeatures: StructuralFeatures | null = null;
@@ -20,15 +23,43 @@ export class MotifEngine {
};
this.midiProcessor = new MIDIProcessor();
this.midiService = new MIDIService();
this.roleMapper = new RoleMapper();
}
async generateFromSong(songName: string): Promise<void> {
// For now, generate synthetic structure based on song name
// TODO: Implement MIDI search and fetching
const mockEvents = this.generateSyntheticMIDI(songName);
const features = this.midiProcessor.extractFeatures(mockEvents);
const roleAssignments = this.roleMapper.assignRoles(features, mockEvents);
let events: NoteEvent[];
// Try to find real MIDI first
try {
console.log(`Searching for MIDI: ${songName}`);
const searchResults = await this.midiService.search(songName);
if (searchResults.length > 0) {
// Try to fetch the best result
const bestResult = searchResults[0];
console.log(`Attempting to fetch: ${bestResult.title} (${bestResult.confidence})`);
const midiBuffer = await this.midiService.fetchMIDI(bestResult.midiUrl);
if (midiBuffer) {
// Parse real MIDI
events = MIDIParser.parseMIDI(midiBuffer);
console.log(`Successfully parsed MIDI with ${events.length} events`);
} else {
throw new Error('Failed to fetch MIDI');
}
} else {
throw new Error('No MIDI results found');
}
} catch (error) {
console.warn(`MIDI search/fetch failed: ${error}. Falling back to synthetic.`);
events = this.generateSyntheticMIDI(songName);
}
// Process events into structure
const features = this.midiProcessor.extractFeatures(events);
const roleAssignments = this.roleMapper.assignRoles(features, events);
this.currentFeatures = features;
+58
View File
@@ -0,0 +1,58 @@
import { Midi } from '@tonejs/midi';
import type { NoteEvent } from '../types';
export class MIDIParser {
static parseMIDI(arrayBuffer: ArrayBuffer): NoteEvent[] {
try {
const midi = new Midi(arrayBuffer);
const events: NoteEvent[] = [];
midi.tracks.forEach((track, trackIndex) => {
track.notes.forEach(note => {
events.push({
time: note.time,
duration: note.duration,
pitch: note.midi,
velocity: note.velocity,
track: trackIndex
});
});
});
// Sort events by time
events.sort((a, b) => a.time - b.time);
console.log(`Parsed ${events.length} notes from ${midi.tracks.length} tracks`);
return events;
} catch (error) {
console.error('MIDI parsing error:', error);
throw new Error(`Failed to parse MIDI: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
static getMIDIInfo(arrayBuffer: ArrayBuffer): {
duration: number;
trackCount: number;
noteCount: number;
tempo: number;
} {
try {
const midi = new Midi(arrayBuffer);
return {
duration: midi.duration,
trackCount: midi.tracks.length,
noteCount: midi.tracks.reduce((total, track) => total + track.notes.length, 0),
tempo: midi.header.tempos[0]?.bpm || 120
};
} catch (error) {
console.error('MIDI info extraction error:', error);
return {
duration: 0,
trackCount: 0,
noteCount: 0,
tempo: 120
};
}
}
}
+61
View File
@@ -0,0 +1,61 @@
interface MIDISearchResult {
id: string;
title: string;
source: string;
pageUrl: string;
midiUrl: string;
confidence: number;
}
interface MIDISearchResponse {
results: MIDISearchResult[];
count: number;
}
export class MIDIService {
private baseUrl: string;
constructor(baseUrl = 'http://localhost:3001') {
this.baseUrl = baseUrl;
}
async search(query: string): Promise<MIDISearchResult[]> {
try {
const response = await fetch(`${this.baseUrl}/api/midi/search?q=${encodeURIComponent(query)}`);
if (!response.ok) {
throw new Error(`Search failed: ${response.status}`);
}
const data: MIDISearchResponse = await response.json();
return data.results;
} catch (error) {
console.error('MIDI search error:', error);
return [];
}
}
async fetchMIDI(url: string): Promise<ArrayBuffer | null> {
try {
const response = await fetch(`${this.baseUrl}/api/midi/fetch?u=${encodeURIComponent(url)}`);
if (!response.ok) {
throw new Error(`Fetch failed: ${response.status}`);
}
return await response.arrayBuffer();
} catch (error) {
console.error('MIDI fetch error:', error);
return null;
}
}
async checkHealth(): Promise<boolean> {
try {
const response = await fetch(`${this.baseUrl}/health`);
return response.ok;
} catch {
return false;
}
}
}