diff --git a/README.md b/README.md new file mode 100644 index 0000000..e09f0a5 --- /dev/null +++ b/README.md @@ -0,0 +1,59 @@ +# MOTIF + +**Procedural Music Synthesis from MIDI Structure** + +Motif extracts structural information from existing music and re-instantiates it as original, real-time audio using the Web Audio API. Music as executable structure, not static audio. + +## Quick Start + +```bash +# Install dependencies for both frontend and backend +npm install +cd server && npm install && cd .. + +# Run both frontend and backend +npm run dev:all + +# Or run separately: +npm run dev:backend # Backend on :3001 +npm run dev # Frontend on :3000 +``` + +## Architecture + +- **Frontend**: TypeScript + Vite + Web Audio API +- **Backend**: Express API for MIDI search and fetching +- **MIDI Sources**: BitMidi, Dongrays (with synthetic fallback) +- **Synthesis**: Pure procedural Web Audio (no samples) + +## How it works + +1. Search for MIDI by song name +2. Extract structural features (tempo, density, roles) +3. Map to synthesis layers (bass, drone, ostinato, texture) +4. Generate original audio with similar "feel" + +## Project Structure + +``` +src/ +├── core/ # MotifEngine, RoleMapper +├── synthesis/ # Web Audio synthesis engine +├── midi/ # MIDI parsing and processing +├── services/ # Backend API client +└── types/ # TypeScript interfaces + +server/ +├── src/ +│ ├── adapters/ # MIDI search adapters +│ ├── services/ # Search and fetch logic +│ └── utils/ # Scoring and validation +└── cache/ # MIDI file cache +``` + +## API + +- `GET /api/midi/search?q=song` - Search for MIDI files +- `GET /api/midi/fetch?u=url` - Fetch and validate MIDI + +Built as a technical experiment in procedural audio and Web Audio capabilities. \ No newline at end of file diff --git a/package.json b/package.json index 254856a..c38628e 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,10 @@ "type": "module", "scripts": { "dev": "vite", + "dev:backend": "cd server && npm run dev", + "dev:all": "npm run dev:backend & npm run dev", "build": "tsc && vite build", + "build:backend": "cd server && npm run build", "preview": "vite preview", "lint": "eslint src --ext .ts,.tsx", "typecheck": "tsc --noEmit" @@ -19,7 +22,7 @@ "vite": "^5.0.0" }, "dependencies": { - "midi-parser-js": "^4.0.4" + "@tonejs/midi": "^2.0.28" }, "keywords": [ "web-audio", diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..02e5c43 --- /dev/null +++ b/server/package.json @@ -0,0 +1,25 @@ +{ + "name": "motif-backend", + "version": "0.1.0", + "description": "MIDI search and proxy backend for Motif", + "type": "module", + "main": "dist/server.js", + "scripts": { + "dev": "tsx watch src/server.ts", + "build": "tsc", + "start": "node dist/server.js" + }, + "dependencies": { + "express": "^4.18.2", + "cors": "^2.8.5", + "cheerio": "^1.0.0-rc.12", + "crypto": "^1.0.1" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/cors": "^2.8.17", + "@types/node": "^20.0.0", + "tsx": "^4.0.0", + "typescript": "^5.0.0" + } +} \ No newline at end of file diff --git a/server/src/adapters/BitMidiAdapter.ts b/server/src/adapters/BitMidiAdapter.ts new file mode 100644 index 0000000..3f67276 --- /dev/null +++ b/server/src/adapters/BitMidiAdapter.ts @@ -0,0 +1,75 @@ +import type { SearchAdapter, MIDICandidate } from '../types.js'; +import { ScoreUtils } from '../utils/ScoreUtils.js'; + +export class BitMidiAdapter implements SearchAdapter { + name = 'bitmidi'; + private baseUrl = 'https://bitmidi.com'; + + async search(query: string): Promise { + try { + const searchUrl = `${this.baseUrl}/search?q=${encodeURIComponent(query)}`; + const response = await fetch(searchUrl, { + headers: { + 'User-Agent': 'Mozilla/5.0 (compatible; MotifBot/1.0)' + } + }); + + if (!response.ok) { + throw new Error(`BitMidi search failed: ${response.status}`); + } + + const html = await response.text(); + return this.parseSearchResults(html, query); + } catch (error) { + console.error('BitMidi search error:', error); + return []; + } + } + + private parseSearchResults(html: string, query: string): MIDICandidate[] { + const candidates: MIDICandidate[] = []; + + // Simple regex-based parsing for MVP (would use cheerio in production) + const linkRegex = /]*href="([^"]*)"[^>]*>([^<]*) c.confidence > 0.3) // Filter low confidence matches + .sort((a, b) => b.confidence - a.confidence) + .slice(0, 5); // Top 5 results + } + + private extractMidiUrl(pageUrl: string): string { + // For BitMidi, the MIDI download is typically at the same path with .mid extension + // or through a download endpoint + if (pageUrl.includes('/midi/')) { + // Try direct .mid file first + const basePath = pageUrl.replace(/\/$/, ''); + return `${basePath}.mid`; + } + return pageUrl; + } +} \ No newline at end of file diff --git a/server/src/adapters/DongraysAdapter.ts b/server/src/adapters/DongraysAdapter.ts new file mode 100644 index 0000000..1674bf0 --- /dev/null +++ b/server/src/adapters/DongraysAdapter.ts @@ -0,0 +1,80 @@ +import type { SearchAdapter, MIDICandidate } from '../types.js'; +import { ScoreUtils } from '../utils/ScoreUtils.js'; + +export class DongraysAdapter implements SearchAdapter { + name = 'dongrays'; + private baseUrl = 'https://www.dongrays.net'; + + async search(query: string): Promise { + try { + // Dongrays search endpoint (simplified for MVP) + const searchUrl = `${this.baseUrl}/search?q=${encodeURIComponent(query)}`; + const response = await fetch(searchUrl, { + headers: { + 'User-Agent': 'Mozilla/5.0 (compatible; MotifBot/1.0)' + } + }); + + if (!response.ok) { + throw new Error(`Dongrays search failed: ${response.status}`); + } + + const html = await response.text(); + return this.parseSearchResults(html, query); + } catch (error) { + console.error('Dongrays search error:', error); + return []; + } + } + + private parseSearchResults(html: string, query: string): MIDICandidate[] { + const candidates: MIDICandidate[] = []; + + // Look for .mid file links in the HTML + const midiRegex = /]*href="([^"]*\.mid)"[^>]*>([^<]*)]*href="([^"]*download[^"]*)"[^>]*>([^<]*mid[^<]*) c.confidence > 0.3) + .sort((a, b) => b.confidence - a.confidence) + .slice(0, 5); + } +} \ No newline at end of file diff --git a/server/src/server.ts b/server/src/server.ts new file mode 100644 index 0000000..02b0a87 --- /dev/null +++ b/server/src/server.ts @@ -0,0 +1,63 @@ +import express from 'express'; +import cors from 'cors'; +import { MIDISearchService } from './services/MIDISearchService.js'; +import { MIDIFetchService } from './services/MIDIFetchService.js'; + +const app = express(); +const port = process.env.PORT || 3001; + +app.use(cors()); +app.use(express.json()); + +const searchService = new MIDISearchService(); +const fetchService = new MIDIFetchService(); + +// Search for MIDI files +app.get('/api/midi/search', async (req, res) => { + try { + const query = req.query.q as string; + if (!query) { + return res.status(400).json({ error: 'Query parameter "q" is required' }); + } + + console.log(`Searching for: ${query}`); + const results = await searchService.search(query); + res.json({ results, count: results.length }); + } catch (error) { + console.error('Search error:', error); + res.status(500).json({ error: 'Search failed' }); + } +}); + +// Fetch and proxy MIDI file +app.get('/api/midi/fetch', async (req, res) => { + try { + const url = req.query.u as string; + if (!url) { + return res.status(400).json({ error: 'URL parameter "u" is required' }); + } + + console.log(`Fetching: ${url}`); + const result = await fetchService.fetch(url); + + if (result.success) { + res.setHeader('Content-Type', 'audio/midi'); + res.setHeader('Content-Length', result.data!.length); + res.send(result.data); + } else { + res.status(404).json({ error: result.error }); + } + } catch (error) { + console.error('Fetch error:', error); + res.status(500).json({ error: 'Fetch failed' }); + } +}); + +// Health check +app.get('/health', (req, res) => { + res.json({ status: 'ok', timestamp: new Date().toISOString() }); +}); + +app.listen(port, () => { + console.log(`🎵 Motif backend running on port ${port}`); +}); \ No newline at end of file diff --git a/server/src/services/MIDIFetchService.ts b/server/src/services/MIDIFetchService.ts new file mode 100644 index 0000000..afa6200 --- /dev/null +++ b/server/src/services/MIDIFetchService.ts @@ -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(); + + 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 { + 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 { + 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 { + 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 { + 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); + } + } +} \ No newline at end of file diff --git a/server/src/services/MIDISearchService.ts b/server/src/services/MIDISearchService.ts new file mode 100644 index 0000000..744400d --- /dev/null +++ b/server/src/services/MIDISearchService.ts @@ -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 { + 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(); + 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; + } +} \ No newline at end of file diff --git a/server/src/types.ts b/server/src/types.ts new file mode 100644 index 0000000..fd13189 --- /dev/null +++ b/server/src/types.ts @@ -0,0 +1,22 @@ +export interface MIDICandidate { + id: string; + title: string; + source: 'bitmidi' | 'dongrays'; + pageUrl: string; + midiUrl: string; + confidence: number; + fileSize?: number; + duration?: number; +} + +export interface SearchAdapter { + name: string; + search(query: string): Promise; +} + +export interface CacheEntry { + hash: string; + filename: string; + size: number; + timestamp: number; +} \ No newline at end of file diff --git a/server/src/utils/ScoreUtils.ts b/server/src/utils/ScoreUtils.ts new file mode 100644 index 0000000..980963f --- /dev/null +++ b/server/src/utils/ScoreUtils.ts @@ -0,0 +1,109 @@ +export class ScoreUtils { + static calculateConfidence(title: string, query: string, source: string): number { + const titleLower = title.toLowerCase(); + const queryLower = query.toLowerCase(); + + let score = 0; + + // Token matching - split and check individual words + const titleTokens = this.tokenize(titleLower); + const queryTokens = this.tokenize(queryLower); + + // Exact title match gets high score + if (titleLower.includes(queryLower)) { + score += 0.8; + } + + // Token overlap scoring + const matchingTokens = queryTokens.filter(token => + titleTokens.some(titleToken => + titleToken.includes(token) || token.includes(titleToken) + ) + ); + + const tokenMatchRatio = matchingTokens.length / queryTokens.length; + score += tokenMatchRatio * 0.6; + + // Penalty for low-quality indicators + const penalties = [ + { pattern: /karaoke|kar|midkar/, penalty: 0.3 }, + { pattern: /vocal|lyrics/, penalty: 0.2 }, + { pattern: /demo|test|sample/, penalty: 0.2 }, + { pattern: /incomplete|broken/, penalty: 0.5 }, + ]; + + for (const { pattern, penalty } of penalties) { + if (pattern.test(titleLower)) { + score -= penalty; + } + } + + // Bonus for direct .mid links + if (title.includes('.mid')) { + score += 0.1; + } + + // Source-specific scoring adjustments + if (source === 'bitmidi') { + score += 0.1; // Slight preference for BitMidi (more curated) + } + + // Ensure score is between 0 and 1 + return Math.max(0, Math.min(1, score)); + } + + static assessQuality(buffer: ArrayBuffer): { + score: number; + duration?: number; + trackCount?: number; + issues: string[] + } { + const issues: string[] = []; + let score = 0.5; // Base score + + // File size checks + const size = buffer.byteLength; + if (size < 1000) { + issues.push('File too small'); + score -= 0.3; + } else if (size > 5_000_000) { + issues.push('File very large'); + score -= 0.1; + } else { + score += 0.1; // Good size range + } + + // Basic MIDI header validation + const view = new Uint8Array(buffer); + const header = String.fromCharCode(...view.slice(0, 4)); + + if (header !== 'MThd') { + issues.push('Invalid MIDI header'); + score -= 0.5; + } else { + score += 0.2; + } + + // TODO: More sophisticated parsing for duration, tempo changes, track count + // For MVP, we'll do basic heuristics + + return { + score: Math.max(0, Math.min(1, score)), + issues + }; + } + + private static tokenize(text: string): string[] { + return text + .toLowerCase() + .replace(/[^\w\s]/g, ' ') // Remove punctuation + .split(/\s+/) + .filter(token => token.length > 1) // Remove single characters + .filter(token => !this.isStopWord(token)); + } + + private static isStopWord(word: string): boolean { + const stopWords = ['the', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for', 'of', 'with', 'by']; + return stopWords.includes(word); + } +} \ No newline at end of file diff --git a/server/tsconfig.json b/server/tsconfig.json new file mode 100644 index 0000000..0ba48b6 --- /dev/null +++ b/server/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "strict": true, + "skipLibCheck": true, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} \ No newline at end of file diff --git a/src/core/MotifEngine.ts b/src/core/MotifEngine.ts index d999c1f..e3eeb96 100644 --- a/src/core/MotifEngine.ts +++ b/src/core/MotifEngine.ts @@ -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 { - // 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; diff --git a/src/midi/MIDIParser.ts b/src/midi/MIDIParser.ts new file mode 100644 index 0000000..159c6be --- /dev/null +++ b/src/midi/MIDIParser.ts @@ -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 + }; + } + } +} \ No newline at end of file diff --git a/src/services/MIDIService.ts b/src/services/MIDIService.ts new file mode 100644 index 0000000..f75021f --- /dev/null +++ b/src/services/MIDIService.ts @@ -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 { + 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 { + 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 { + try { + const response = await fetch(`${this.baseUrl}/health`); + return response.ok; + } catch { + return false; + } + } +} \ No newline at end of file