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:
@@ -0,0 +1,163 @@
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import crypto from 'crypto';
|
||||
import { ScoreUtils } from '../utils/ScoreUtils.js';
|
||||
import type { CacheEntry } from '../types.js';
|
||||
|
||||
export class MIDIFetchService {
|
||||
private cacheDir = path.join(process.cwd(), 'cache');
|
||||
private cacheIndex = new Map<string, CacheEntry>();
|
||||
|
||||
constructor() {
|
||||
this.initializeCache();
|
||||
}
|
||||
|
||||
async fetch(url: string): Promise<{ success: boolean; data?: ArrayBuffer; error?: string }> {
|
||||
try {
|
||||
// Check cache first
|
||||
const hash = this.hashUrl(url);
|
||||
const cached = await this.getCached(hash);
|
||||
if (cached) {
|
||||
console.log(`Cache hit for ${url}`);
|
||||
return { success: true, data: cached };
|
||||
}
|
||||
|
||||
// Fetch from network
|
||||
console.log(`Fetching from network: ${url}`);
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (compatible; MotifBot/1.0)',
|
||||
},
|
||||
signal: AbortSignal.timeout(10000) // 10 second timeout
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return { success: false, error: `HTTP ${response.status}` };
|
||||
}
|
||||
|
||||
const buffer = await response.arrayBuffer();
|
||||
|
||||
// Validate file
|
||||
const validation = this.validateMIDI(buffer);
|
||||
if (!validation.valid) {
|
||||
return { success: false, error: validation.error };
|
||||
}
|
||||
|
||||
// Quality check
|
||||
const quality = ScoreUtils.assessQuality(buffer);
|
||||
if (quality.score < 0.3) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Poor quality: ${quality.issues.join(', ')}`
|
||||
};
|
||||
}
|
||||
|
||||
// Cache the file
|
||||
await this.cacheFile(hash, buffer, url);
|
||||
|
||||
return { success: true, data: buffer };
|
||||
|
||||
} catch (error) {
|
||||
console.error('Fetch error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private validateMIDI(buffer: ArrayBuffer): { valid: boolean; error?: string } {
|
||||
if (buffer.byteLength < 14) {
|
||||
return { valid: false, error: 'File too small to be valid MIDI' };
|
||||
}
|
||||
|
||||
// Check MIDI header
|
||||
const view = new Uint8Array(buffer);
|
||||
const header = String.fromCharCode(...view.slice(0, 4));
|
||||
|
||||
if (header !== 'MThd') {
|
||||
return { valid: false, error: 'Invalid MIDI header' };
|
||||
}
|
||||
|
||||
// Size limits
|
||||
if (buffer.byteLength > 10_000_000) { // 10MB max
|
||||
return { valid: false, error: 'File too large' };
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
private hashUrl(url: string): string {
|
||||
return crypto.createHash('sha256').update(url).digest('hex').slice(0, 16);
|
||||
}
|
||||
|
||||
private async initializeCache(): Promise<void> {
|
||||
try {
|
||||
await fs.mkdir(this.cacheDir, { recursive: true });
|
||||
|
||||
// Load cache index if it exists
|
||||
const indexPath = path.join(this.cacheDir, 'index.json');
|
||||
try {
|
||||
const indexData = await fs.readFile(indexPath, 'utf-8');
|
||||
const entries = JSON.parse(indexData) as CacheEntry[];
|
||||
for (const entry of entries) {
|
||||
this.cacheIndex.set(entry.hash, entry);
|
||||
}
|
||||
console.log(`Loaded ${entries.length} cache entries`);
|
||||
} catch {
|
||||
// Index doesn't exist yet, that's fine
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Cache initialization failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private async getCached(hash: string): Promise<ArrayBuffer | null> {
|
||||
const entry = this.cacheIndex.get(hash);
|
||||
if (!entry) return null;
|
||||
|
||||
// Check if file still exists
|
||||
const filePath = path.join(this.cacheDir, entry.filename);
|
||||
try {
|
||||
const buffer = await fs.readFile(filePath);
|
||||
return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength);
|
||||
} catch {
|
||||
// File doesn't exist, remove from index
|
||||
this.cacheIndex.delete(hash);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async cacheFile(hash: string, buffer: ArrayBuffer, originalUrl: string): Promise<void> {
|
||||
try {
|
||||
const filename = `${hash}.mid`;
|
||||
const filePath = path.join(this.cacheDir, filename);
|
||||
|
||||
await fs.writeFile(filePath, new Uint8Array(buffer));
|
||||
|
||||
const entry: CacheEntry = {
|
||||
hash,
|
||||
filename,
|
||||
size: buffer.byteLength,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
this.cacheIndex.set(hash, entry);
|
||||
await this.saveIndex();
|
||||
|
||||
console.log(`Cached ${originalUrl} as ${filename} (${buffer.byteLength} bytes)`);
|
||||
} catch (error) {
|
||||
console.error('Cache write failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private async saveIndex(): Promise<void> {
|
||||
try {
|
||||
const indexPath = path.join(this.cacheDir, 'index.json');
|
||||
const entries = Array.from(this.cacheIndex.values());
|
||||
await fs.writeFile(indexPath, JSON.stringify(entries, null, 2));
|
||||
} catch (error) {
|
||||
console.error('Cache index save failed:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { SearchAdapter, MIDICandidate } from '../types.js';
|
||||
import { BitMidiAdapter } from '../adapters/BitMidiAdapter.js';
|
||||
import { DongraysAdapter } from '../adapters/DongraysAdapter.js';
|
||||
|
||||
export class MIDISearchService {
|
||||
private adapters: SearchAdapter[];
|
||||
|
||||
constructor() {
|
||||
this.adapters = [
|
||||
new BitMidiAdapter(),
|
||||
new DongraysAdapter()
|
||||
];
|
||||
}
|
||||
|
||||
async search(query: string): Promise<MIDICandidate[]> {
|
||||
const allResults: MIDICandidate[] = [];
|
||||
|
||||
// Search all adapters in parallel
|
||||
const searchPromises = this.adapters.map(async adapter => {
|
||||
try {
|
||||
const results = await adapter.search(query);
|
||||
console.log(`${adapter.name}: Found ${results.length} results`);
|
||||
return results;
|
||||
} catch (error) {
|
||||
console.error(`${adapter.name} search failed:`, error);
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
const results = await Promise.all(searchPromises);
|
||||
|
||||
// Combine and deduplicate results
|
||||
for (const adapterResults of results) {
|
||||
allResults.push(...adapterResults);
|
||||
}
|
||||
|
||||
// Remove duplicates (same MIDI URL)
|
||||
const uniqueResults = this.deduplicateResults(allResults);
|
||||
|
||||
// Sort by confidence score
|
||||
uniqueResults.sort((a, b) => b.confidence - a.confidence);
|
||||
|
||||
// Return top 10 results
|
||||
return uniqueResults.slice(0, 10);
|
||||
}
|
||||
|
||||
private deduplicateResults(results: MIDICandidate[]): MIDICandidate[] {
|
||||
const seen = new Set<string>();
|
||||
const unique: MIDICandidate[] = [];
|
||||
|
||||
for (const result of results) {
|
||||
// Create dedup key from MIDI URL or title+source
|
||||
const key = result.midiUrl || `${result.title}_${result.source}`;
|
||||
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
unique.push(result);
|
||||
}
|
||||
}
|
||||
|
||||
return unique;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user