From 2c9d06d8d7dcf264650feaf4d89e487ccd235290 Mon Sep 17 00:00:00 2001 From: b1rdmania <102524336+b1rdmania@users.noreply.github.com> Date: Thu, 18 Dec 2025 14:25:38 +0000 Subject: [PATCH] Improve MIDI search and add browser playback engines. --- .gitignore | 6 +- index.html | 55 ++++- package-lock.json | 105 ++++++++- package.json | 6 +- server/package-lock.json | 37 +++- server/package.json | 11 +- server/src/adapters/BitMidiAdapter.ts | 167 ++++++++++++--- server/src/adapters/MockAdapter.ts | 38 ++++ server/src/server.ts | 4 +- server/src/services/MIDIFetchService.ts | 14 ++ server/src/services/MIDISearchService.ts | 3 + server/src/types.ts | 2 +- server/src/utils/ProperMIDI.ts | 141 ++++++++++++ server/src/utils/SimpleMIDI.ts | 72 +++++++ server/src/utils/SyntheticMIDI.ts | 110 ++++++++++ server/tsconfig.json | 7 +- src/core/MotifEngine.ts | 8 +- src/core/RoleMapper.ts | 4 +- src/main-simple.ts | 42 ++++ src/main-working.ts | 189 +++++++++++++++++ src/main.ts | 162 +++++++++++--- src/midi/MIDIParser.ts | 3 +- src/services/MIDIService.ts | 1 - src/synthesis/EnhancedMIDIPlayer.ts | 259 +++++++++++++++++++++++ src/synthesis/SimpleMIDIPlayer.ts | 215 +++++++++++++++++++ src/synthesis/SoundfontMIDIPlayer.ts | 196 +++++++++++++++++ src/synthesis/SynthesisEngine.ts | 14 +- src/synthesis/ToneJSMIDIPlayer.ts | 174 +++++++++++++++ test.html | 21 ++ thursday-aims-progress-problems.md | 107 ++++++++++ 30 files changed, 2080 insertions(+), 93 deletions(-) create mode 100644 server/src/adapters/MockAdapter.ts create mode 100644 server/src/utils/ProperMIDI.ts create mode 100644 server/src/utils/SimpleMIDI.ts create mode 100644 server/src/utils/SyntheticMIDI.ts create mode 100644 src/main-simple.ts create mode 100644 src/main-working.ts create mode 100644 src/synthesis/EnhancedMIDIPlayer.ts create mode 100644 src/synthesis/SimpleMIDIPlayer.ts create mode 100644 src/synthesis/SoundfontMIDIPlayer.ts create mode 100644 src/synthesis/ToneJSMIDIPlayer.ts create mode 100644 test.html create mode 100644 thursday-aims-progress-problems.md diff --git a/.gitignore b/.gitignore index 9c85dcb..67e450a 100644 --- a/.gitignore +++ b/.gitignore @@ -32,4 +32,8 @@ logs/ # Runtime .cache/ -.tmp/ \ No newline at end of file +.tmp/ +.vercel + +# Backend runtime cache (downloaded MIDI files) +server/cache/ diff --git a/index.html b/index.html index 75ba016..780f327 100644 --- a/index.html +++ b/index.html @@ -132,6 +132,39 @@ color: #ffaa44; font-size: 0.9em; } + + .volume-control { + margin-top: 10px; + display: flex; + align-items: center; + gap: 10px; + } + .volume-control label { + font-size: 0.9em; + color: #aaa; + } + .volume-control input[type="range"] { + flex: 1; + margin: 0; + } + + .engine-selector { + margin-top: 10px; + font-size: 0.9em; + } + .engine-selector label { + color: #aaa; + display: block; + margin-bottom: 5px; + } + .engine-selector select { + background: #333; + color: #e0e0e0; + border: 1px solid #555; + padding: 5px 10px; + font-family: inherit; + width: 100%; + } @@ -175,15 +208,31 @@

MIDI Preview

Plays original MIDI as-is

- +
+ + +
+ +
+ + +
- +

Motif Generation

Procedural synthesis from structure

- + +
+ + +
diff --git a/package-lock.json b/package-lock.json index eab4288..4bc9c3e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,9 @@ "version": "0.1.0", "license": "MIT", "dependencies": { - "@tonejs/midi": "^2.0.28" + "@tonejs/midi": "^2.0.28", + "soundfont-player": "^0.12.0", + "tone": "^15.1.22" }, "devDependencies": { "@types/node": "^20.0.0", @@ -20,6 +22,15 @@ "vite": "^5.0.0" } }, + "node_modules/@babel/runtime": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", @@ -1175,6 +1186,12 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/adsr": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/adsr/-/adsr-1.0.1.tgz", + "integrity": "sha512-thr9LK4jxApOzBA33IWOA83bXJFbyfbeozpHXyrMQOIhUni198uRxXqDhobW0S/51iokqty2Yz2WbLZbE6tntQ==", + "license": "ISC" + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -1241,6 +1258,25 @@ "node": ">=8" } }, + "node_modules/audio-loader": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/audio-loader/-/audio-loader-0.5.0.tgz", + "integrity": "sha512-mEoYRjZhqkBSen/X9i2PNosqvafEsur8bI5MNoPr0wsJu9Nzlul3Yv1elYeMPsXxTxYhXLY8AZlScBvaK4mydg==", + "license": "MIT" + }, + "node_modules/automation-events": { + "version": "7.1.13", + "resolved": "https://registry.npmjs.org/automation-events/-/automation-events-7.1.13.tgz", + "integrity": "sha512-1Hay5TQPzxsskSqPTH3YXyzE9Iirz82zZDse2vr3+kOR7Sc7om17qIEPsESchlNX0EgKxANwR40i2g/O3GM1Tw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=18.2.0" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -2096,6 +2132,12 @@ "integrity": "sha512-B5SnBC6i2bwJIXTY9MElIydJwAmnKx+r5eJ1jknTLetzLflEl0GWveuBB6ACrQpecSRkOB6fhTx1PwXk2BVxnA==", "license": "MIT" }, + "node_modules/midimessage": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/midimessage/-/midimessage-1.0.5.tgz", + "integrity": "sha512-MPJ2tDupFOfZB5/PLp8fri1IS4fd9hPj0Bio//FBhWRQ+TsJA7/49CF1aJyraDxa0Jq8zMHAwrwXl2GINvLvgw==", + "license": "MIT" + }, "node_modules/minimatch": { "version": "9.0.3", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", @@ -2145,6 +2187,12 @@ "dev": true, "license": "MIT" }, + "node_modules/note-parser": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/note-parser/-/note-parser-2.0.1.tgz", + "integrity": "sha512-w9o6Fv46y3NsFxeezTZSmftBtUM/ypme6iZWVrTJvvsD5RN+w0XNDePWtfreNrZFL3jSjBFhadPoXb+pJO4UdA==", + "license": "MIT" + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -2452,6 +2500,23 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/sample-player": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/sample-player/-/sample-player-0.5.5.tgz", + "integrity": "sha512-VQ9pXPJ1m/eTH8QK6OQ8Dn/HSVToNyY9w9vnv+y/yjkJeRm87tJ/gBEm66jItfSLhKe6VG1DfX8+oT+Mg7QUpg==", + "license": "MIT", + "dependencies": { + "adsr": "^1.0.0", + "midimessage": "^1.0.5", + "note-parser": "^1.1.0" + } + }, + "node_modules/sample-player/node_modules/note-parser": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/note-parser/-/note-parser-1.1.0.tgz", + "integrity": "sha512-YTqWQBsRp40EFrEznnkGtmx68gcgOQ8CdoBspqGBA3G1/4mJwIYbDe/vuNpX3oGX2DhP7b1dBgTmj7p3Zr0P1Q==", + "license": "MIT" + }, "node_modules/semver": { "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", @@ -2498,6 +2563,17 @@ "node": ">=8" } }, + "node_modules/soundfont-player": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/soundfont-player/-/soundfont-player-0.12.0.tgz", + "integrity": "sha512-8BJIsAt7h1PK3thSZDgF6zecgGhYkK74JnZO8WRZi3h34qG6H/DYlnv7cpRvL7Q9C8N6qld4Qwj7nJsX1gYjEA==", + "license": "MIT", + "dependencies": { + "audio-loader": "^0.5.0", + "note-parser": "^2.0.0", + "sample-player": "^0.5.5" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2508,6 +2584,17 @@ "node": ">=0.10.0" } }, + "node_modules/standardized-audio-context": { + "version": "25.3.77", + "resolved": "https://registry.npmjs.org/standardized-audio-context/-/standardized-audio-context-25.3.77.tgz", + "integrity": "sha512-Ki9zNz6pKcC5Pi+QPjPyVsD9GwJIJWgryji0XL9cAJXMGyn+dPOf6Qik1AHei0+UNVcc4BOCa0hWLBzlwqsW/A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.6", + "automation-events": "^7.0.9", + "tslib": "^2.7.0" + } + }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -2567,6 +2654,16 @@ "node": ">=8.0" } }, + "node_modules/tone": { + "version": "15.1.22", + "resolved": "https://registry.npmjs.org/tone/-/tone-15.1.22.tgz", + "integrity": "sha512-TCScAGD4sLsama5DjvTUXlLDXSqPealhL64nsdV1hhr6frPWve0DeSo63AKnSJwgfg55fhvxj0iPPRwPN5o0ag==", + "license": "MIT", + "dependencies": { + "standardized-audio-context": "^25.3.70", + "tslib": "^2.3.1" + } + }, "node_modules/ts-api-utils": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", @@ -2580,6 +2677,12 @@ "typescript": ">=4.2.0" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/package.json b/package.json index c38628e..9b9c120 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,9 @@ "vite": "^5.0.0" }, "dependencies": { - "@tonejs/midi": "^2.0.28" + "@tonejs/midi": "^2.0.28", + "soundfont-player": "^0.12.0", + "tone": "^15.1.22" }, "keywords": [ "web-audio", @@ -32,4 +34,4 @@ ], "author": "", "license": "MIT" -} \ No newline at end of file +} diff --git a/server/package-lock.json b/server/package-lock.json index 351dec9..44b333f 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -11,7 +11,8 @@ "cheerio": "^1.0.0-rc.12", "cors": "^2.8.5", "crypto": "^1.0.1", - "express": "^4.18.2" + "express": "^4.18.2", + "midi-writer-js": "^3.1.1" }, "devDependencies": { "@types/cors": "^2.8.17", @@ -463,6 +464,30 @@ "node": ">=18" } }, + "node_modules/@tonaljs/midi": { + "version": "4.10.2", + "resolved": "https://registry.npmjs.org/@tonaljs/midi/-/midi-4.10.2.tgz", + "integrity": "sha512-MPamXhEwPL7L1udLfYMm3Ft8mLYtHr62Zi2w5zYHM2P7YwIvNoiX0+dvAN5is1Wvq4iVRa8AjFHerjOW/SZhGg==", + "license": "MIT", + "dependencies": { + "@tonaljs/pitch-note": "6.1.0" + } + }, + "node_modules/@tonaljs/pitch": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@tonaljs/pitch/-/pitch-5.0.2.tgz", + "integrity": "sha512-mxaXJPPe+LIJdjzpZEl8I8Wx3dEvlzkBbsr2Ltwc2dTAdnErAZ5R0TxVq2egF27lMvQN2QPQPWI9iDPPdVUmrg==", + "license": "MIT" + }, + "node_modules/@tonaljs/pitch-note": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@tonaljs/pitch-note/-/pitch-note-6.1.0.tgz", + "integrity": "sha512-A4OSLo8DjM38u73862LnDmL4YInDDRBmg0fojXcvu4cyU3oOlqndyeHOra1OVoH/WW46uNIxNs1wJDZNPWL5KQ==", + "license": "MIT", + "dependencies": { + "@tonaljs/pitch": "5.0.2" + } + }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -1346,6 +1371,16 @@ "node": ">= 0.6" } }, + "node_modules/midi-writer-js": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/midi-writer-js/-/midi-writer-js-3.1.1.tgz", + "integrity": "sha512-ruRzUWtmcvD7xQcrKRk9fH+1BELv8x07QJxhpM4PS5n6+MOyPb39ifxTV/oI1hHPgKMSuhnhWw2MaGWUAvH9DA==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@tonaljs/midi": "^4.9.0" + } + }, "node_modules/mime": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", diff --git a/server/package.json b/server/package.json index 02e5c43..5b57b23 100644 --- a/server/package.json +++ b/server/package.json @@ -10,16 +10,17 @@ "start": "node dist/server.js" }, "dependencies": { - "express": "^4.18.2", - "cors": "^2.8.5", "cheerio": "^1.0.0-rc.12", - "crypto": "^1.0.1" + "cors": "^2.8.5", + "crypto": "^1.0.1", + "express": "^4.18.2", + "midi-writer-js": "^3.1.1" }, "devDependencies": { - "@types/express": "^4.17.21", "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", "@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 index 3f67276..e381938 100644 --- a/server/src/adapters/BitMidiAdapter.ts +++ b/server/src/adapters/BitMidiAdapter.ts @@ -29,31 +29,108 @@ export class BitMidiAdapter implements SearchAdapter { private parseSearchResults(html: string, query: string): MIDICandidate[] { const candidates: MIDICandidate[] = []; - // Simple regex-based parsing for MVP (would use cheerio in production) - const linkRegex = /]*href="([^"]*)"[^>]*>([^<]*) 0) { + bracketStart--; + if (html[bracketStart] === '}') bracketDepth++; + if (html[bracketStart] === '{') { + if (bracketDepth === 0) break; + bracketDepth--; + } + } + initStoreStart = bracketStart; + } else { + + return []; + } + } } } + + let jsonStart = initStoreStart; + // If initStoreStart is not already at a brace, find the next one + if (html[initStoreStart] !== '{') { + jsonStart = html.indexOf('{', initStoreStart); + if (jsonStart === -1) { + console.log('BitMidi: Could not find JSON start'); + return []; + } + } + + // Find the matching closing brace + let braceCount = 0; + let jsonEnd = -1; + for (let i = jsonStart; i < html.length; i++) { + if (html[i] === '{') braceCount++; + else if (html[i] === '}') { + braceCount--; + if (braceCount === 0) { + jsonEnd = i + 1; + break; + } + } + } + + if (jsonEnd === -1) { + console.log('BitMidi: Could not find JSON end'); + return []; + } + + const jsonString = html.slice(jsonStart, jsonEnd); + const jsonData = JSON.parse(jsonString); + + // Extract search results from the JSON structure + if (jsonData.data && jsonData.data.midis) { + const midisData = jsonData.data.midis; + const midiKeys = Object.keys(midisData); + + for (let i = 0; i < Math.min(midiKeys.length, 10); i++) { + const midiKey = midiKeys[i]; + const midiData = midisData[midiKey]; + + if (midiData && midiData.name) { + const title = midiData.name; + const slug = midiData.slug; + const downloadUrl = midiData.downloadUrl || `/uploads/${midiData.id}.mid`; + + const pageUrl = `${this.baseUrl}/${slug}`; + const fullDownloadUrl = downloadUrl.startsWith('http') + ? downloadUrl + : `${this.baseUrl}${downloadUrl}`; + + const confidence = ScoreUtils.calculateConfidence(title, query, this.name); + + candidates.push({ + id: `bitmidi_${midiData.id}`, + title: title.trim(), + source: 'bitmidi', + pageUrl: pageUrl, + midiUrl: fullDownloadUrl, + confidence + }); + + } + } + } + + } catch (error) { + console.error('BitMidi: Error parsing JSON data:', error); + return []; } return candidates @@ -62,14 +139,44 @@ export class BitMidiAdapter implements SearchAdapter { .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`; + async getMidiDownloadUrl(pageUrl: string): Promise { + try { + const response = await fetch(pageUrl, { + headers: { + 'User-Agent': 'Mozilla/5.0 (compatible; MotifBot/1.0)' + } + }); + + if (!response.ok) { + throw new Error(`Failed to fetch MIDI page: ${response.status}`); + } + + const html = await response.text(); + + // Extract the actual download URL from the page + // Pattern: href="/uploads/12345.mid" or similar + const downloadMatch = html.match(/href="(\/uploads\/[^"]*\.mid)"/); + if (downloadMatch) { + return `${this.baseUrl}${downloadMatch[1]}`; + } + + // Fallback: look for any .mid download link + const midiMatch = html.match(/href="([^"]*\.mid)"/); + if (midiMatch) { + const url = midiMatch[1]; + return url.startsWith('http') ? url : `${this.baseUrl}${url}`; + } + + throw new Error('Could not find MIDI download URL'); + } catch (error) { + console.error('Error extracting MIDI URL:', error); + return pageUrl; // Fallback to page URL } + } + + private extractMidiUrl(pageUrl: string): string { + // This method is deprecated in favor of getMidiDownloadUrl + // but keeping for backward compatibility return pageUrl; } } \ No newline at end of file diff --git a/server/src/adapters/MockAdapter.ts b/server/src/adapters/MockAdapter.ts new file mode 100644 index 0000000..67b5fc8 --- /dev/null +++ b/server/src/adapters/MockAdapter.ts @@ -0,0 +1,38 @@ +import type { SearchAdapter, MIDICandidate } from '../types.js'; +import { ScoreUtils } from '../utils/ScoreUtils.js'; + +export class MockAdapter implements SearchAdapter { + name = 'mock'; + + async search(query: string): Promise { + // Generate mock MIDI search results for testing + const mockSongs = [ + { title: 'Bohemian Rhapsody - Queen', url: 'synthetic:bohemian-rhapsody-queen' }, + { title: 'Hotel California - Eagles', url: 'synthetic:hotel-california-eagles' }, + { title: 'Sweet Child O Mine - Guns N Roses', url: 'synthetic:sweet-child-o-mine-guns-n-roses' }, + { title: 'Stairway to Heaven - Led Zeppelin', url: 'synthetic:stairway-to-heaven-led-zeppelin' }, + { title: 'Yesterday - The Beatles', url: 'synthetic:yesterday-the-beatles' } + ]; + + const results: MIDICandidate[] = []; + + for (const song of mockSongs) { + const confidence = ScoreUtils.calculateConfidence(song.title, query, this.name); + + if (confidence > 0.1) { // Include if there's any relevance + results.push({ + id: `mock_${Buffer.from(song.url).toString('base64').slice(0, 16)}`, + title: song.title, + source: 'mock', + pageUrl: song.url, + midiUrl: song.url, + confidence + }); + } + } + + return results + .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 index eb23ca3..6ba9125 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -44,8 +44,8 @@ app.get('/api/midi/fetch', async (req, res) => { if (result.success) { res.setHeader('Content-Type', 'audio/midi'); - res.setHeader('Content-Length', result.data!.length); - res.send(result.data); + res.setHeader('Content-Length', result.data!.byteLength); + res.send(Buffer.from(result.data!)); } else { res.status(404).json({ error: result.error }); } diff --git a/server/src/services/MIDIFetchService.ts b/server/src/services/MIDIFetchService.ts index afa6200..feef594 100644 --- a/server/src/services/MIDIFetchService.ts +++ b/server/src/services/MIDIFetchService.ts @@ -2,6 +2,7 @@ import fs from 'fs/promises'; import path from 'path'; import crypto from 'crypto'; import { ScoreUtils } from '../utils/ScoreUtils.js'; +import { SimpleMIDI } from '../utils/SimpleMIDI.js'; import type { CacheEntry } from '../types.js'; export class MIDIFetchService { @@ -14,6 +15,19 @@ export class MIDIFetchService { async fetch(url: string): Promise<{ success: boolean; data?: ArrayBuffer; error?: string }> { try { + // Only generate synthetic MIDI for explicit synthetic URLs or when enabled + if (url.startsWith('synthetic:') || url.includes('mock') || + (process.env.USE_SYNTHETIC_FETCH === '1')) { + console.log(`Generating synthetic MIDI for: ${url}`); + const songName = url.startsWith('synthetic:') + ? url.replace('synthetic:', '') + : url.split('/').pop()?.replace('.mid', '') || 'test'; + + const syntheticBuffer = SimpleMIDI.generateValidMIDI(songName); + console.log(`Generated ${syntheticBuffer.byteLength} bytes of synthetic MIDI data`); + return { success: true, data: syntheticBuffer }; + } + // Check cache first const hash = this.hashUrl(url); const cached = await this.getCached(hash); diff --git a/server/src/services/MIDISearchService.ts b/server/src/services/MIDISearchService.ts index 744400d..bb8d401 100644 --- a/server/src/services/MIDISearchService.ts +++ b/server/src/services/MIDISearchService.ts @@ -1,15 +1,18 @@ import type { SearchAdapter, MIDICandidate } from '../types.js'; import { BitMidiAdapter } from '../adapters/BitMidiAdapter.js'; import { DongraysAdapter } from '../adapters/DongraysAdapter.js'; +import { MockAdapter } from '../adapters/MockAdapter.js'; export class MIDISearchService { private adapters: SearchAdapter[]; constructor() { + // Real MIDI adapters only - no mock this.adapters = [ new BitMidiAdapter(), new DongraysAdapter() ]; + console.log('MIDISearchService initialized with real adapters:', this.adapters.map(a => a.name)); } async search(query: string): Promise { diff --git a/server/src/types.ts b/server/src/types.ts index 95e2b43..61ba767 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -1,7 +1,7 @@ export interface MIDICandidate { id: string; title: string; - source: 'bitmidi' | 'dongrays'; + source: 'bitmidi' | 'dongrays' | 'mock'; pageUrl: string; midiUrl: string; confidence: number; diff --git a/server/src/utils/ProperMIDI.ts b/server/src/utils/ProperMIDI.ts new file mode 100644 index 0000000..0f2874b --- /dev/null +++ b/server/src/utils/ProperMIDI.ts @@ -0,0 +1,141 @@ +// Using dynamic import to handle the CommonJS module properly +export class ProperMIDI { + static async generateMIDI(songName: string): Promise { + console.log(`Generating synthetic MIDI for: ${songName}`); + + try { + const MidiWriter = await import('midi-writer-js'); + + // Create a hash-based seed for deterministic generation + const hash = this.simpleHash(songName); + const tempo = 120 + (hash % 40); // 120-160 BPM + + // Create a new track - try different import patterns + const track = new (MidiWriter as any).Track(); + + // Set tempo + track.addEvent(new (MidiWriter as any).TempoEvent({ bpm: tempo })); + + // Create simple melody + const notes = this.generateSimpleNotes(hash); + + // Add notes to track + for (const note of notes) { + track.addEvent(new (MidiWriter as any).NoteEvent({ + pitch: note.pitch, + duration: note.duration, + velocity: note.velocity, + wait: note.wait + })); + } + + // Create writer and generate MIDI file + const writer = new (MidiWriter as any).Writer(track); + const midiData = writer.buildFile(); + + // Convert to ArrayBuffer + const uint8Array = new Uint8Array(midiData); + return uint8Array.buffer; + } catch (error) { + console.error('MIDI generation error:', error); + // Fallback to simple MIDI generation + return this.generateFallbackMIDI(songName); + } + } + + private static getScale(key: number): number[] { + // Major scale intervals + const intervals = [0, 2, 4, 5, 7, 9, 11]; + const baseNote = 60 + key; // Middle C + key offset + return intervals.map(interval => baseNote + interval); + } + + private static generateSimpleNotes(hash: number): Array<{ + pitch: string, + duration: string, + velocity: number, + wait: string + }> { + const notes = []; + const durations = ['4', '8', '8', '4']; // Quarter, eighth, eighth, quarter + const scale = [60, 62, 64, 65, 67, 69, 71]; // C major scale starting at C4 + + for (let i = 0; i < 8; i++) { + const noteIndex = (hash + i * 3) % scale.length; + const pitch = scale[noteIndex]; + + notes.push({ + pitch: this.midiNumberToNote(pitch), + duration: durations[i % durations.length], + velocity: 70 + ((hash + i * 5) % 30), // 70-100 velocity + wait: i === 0 ? '0' : '0' // No wait between notes for legato + }); + } + + return notes; + } + + private static generateFallbackMIDI(songName: string): ArrayBuffer { + console.log('Using fallback MIDI generation for:', songName); + + // Simple MIDI file structure + const data: number[] = []; + + // MIDI Header (14 bytes) + data.push(0x4D, 0x54, 0x68, 0x64); // "MThd" + data.push(0x00, 0x00, 0x00, 0x06); // Header chunk size + data.push(0x00, 0x00); // Format 0 + data.push(0x00, 0x01); // 1 track + data.push(0x00, 0x60); // 96 ticks per quarter note + + // Track data + const trackData: number[] = []; + + // Set tempo (120 BPM) + trackData.push(0x00, 0xFF, 0x51, 0x03, 0x07, 0xA1, 0x20); + + // Simple 4-note melody + const hash = this.simpleHash(songName); + const rootNote = 60 + (hash % 12); // C4 + offset + const melody = [0, 4, 7, 12]; // Root, major 3rd, perfect 5th, octave + + for (let i = 0; i < melody.length; i++) { + const note = rootNote + melody[i]; + trackData.push(0x00, 0x90, note, 0x40); // Note On + trackData.push(0x30, 0x80, note, 0x00); // Note Off after 48 ticks + } + + // End of track + trackData.push(0x00, 0xFF, 0x2F, 0x00); + + // Track header + data.push(0x4D, 0x54, 0x72, 0x6B); // "MTrk" + const trackLength = trackData.length; + data.push( + (trackLength >> 24) & 0xFF, + (trackLength >> 16) & 0xFF, + (trackLength >> 8) & 0xFF, + trackLength & 0xFF + ); + + data.push(...trackData); + + return new Uint8Array(data).buffer; + } + + private static midiNumberToNote(midiNumber: number): string { + const noteNames = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']; + const octave = Math.floor(midiNumber / 12) - 1; + const note = noteNames[midiNumber % 12]; + return `${note}${octave}`; + } + + private static simpleHash(str: string): number { + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = ((hash << 5) - hash) + str.charCodeAt(i); + hash = hash & hash; // Convert to 32-bit integer + } + return Math.abs(hash); + } +} \ No newline at end of file diff --git a/server/src/utils/SimpleMIDI.ts b/server/src/utils/SimpleMIDI.ts new file mode 100644 index 0000000..9b2fb85 --- /dev/null +++ b/server/src/utils/SimpleMIDI.ts @@ -0,0 +1,72 @@ +export class SimpleMIDI { + static generateValidMIDI(songName: string): ArrayBuffer { + // Create a minimal but valid MIDI file + const data: number[] = []; + + // MIDI Header (14 bytes) + // "MThd" magic number + data.push(0x4D, 0x54, 0x68, 0x64); + // Header chunk size (6) + data.push(0x00, 0x00, 0x00, 0x06); + // Format 0 + data.push(0x00, 0x00); + // 1 track + data.push(0x00, 0x01); + // 96 ticks per quarter note + data.push(0x00, 0x60); + + // Track Header + // "MTrk" magic number + data.push(0x4D, 0x54, 0x72, 0x6B); + + // Track data + const trackData: number[] = []; + + // Set tempo (120 BPM) + trackData.push(0x00, 0xFF, 0x51, 0x03, 0x07, 0xA1, 0x20); + + // Generate a simple melody based on song name + const hash = this.simpleHash(songName); + const rootNote = 60 + (hash % 12); // C4 + offset + + // Simple 4-note melody + const melody = [0, 4, 7, 12]; // Root, major 3rd, perfect 5th, octave + + for (let i = 0; i < melody.length; i++) { + const note = rootNote + melody[i]; + + // Note On (96 ticks = quarter note at 96 PPQ) + trackData.push(0x00, 0x90, note, 0x40); // Delta time, Note On Ch0, Note, Velocity + + // Note Off after 48 ticks (eighth note) + trackData.push(0x30, 0x80, note, 0x00); // Delta time, Note Off Ch0, Note, Velocity + } + + // End of track + trackData.push(0x00, 0xFF, 0x2F, 0x00); + + // Track length (4 bytes, big endian) + const trackLength = trackData.length; + data.push( + (trackLength >> 24) & 0xFF, + (trackLength >> 16) & 0xFF, + (trackLength >> 8) & 0xFF, + trackLength & 0xFF + ); + + // Add track data + data.push(...trackData); + + // Convert to ArrayBuffer + return new Uint8Array(data).buffer; + } + + private static simpleHash(str: string): number { + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = ((hash << 5) - hash) + str.charCodeAt(i); + hash = hash & hash; // Convert to 32-bit integer + } + return Math.abs(hash); + } +} \ No newline at end of file diff --git a/server/src/utils/SyntheticMIDI.ts b/server/src/utils/SyntheticMIDI.ts new file mode 100644 index 0000000..ad87c3a --- /dev/null +++ b/server/src/utils/SyntheticMIDI.ts @@ -0,0 +1,110 @@ +export class SyntheticMIDI { + static generateMIDIBuffer(songName: string): ArrayBuffer { + // Create a minimal MIDI file for testing + // This is a simplified MIDI file with basic header and one note + + const hash = this.simpleHash(songName); + const tempo = 120 + (hash % 60); // 120-180 BPM + const key = hash % 12; // 0-11 for C-B + + // MIDI Header + const header = new Uint8Array([ + // "MThd" + 0x4D, 0x54, 0x68, 0x64, + // Header length (6 bytes) + 0x00, 0x00, 0x00, 0x06, + // Format type 0 + 0x00, 0x00, + // Number of tracks (1) + 0x00, 0x01, + // Time division (480 ticks per quarter note) + 0x01, 0xE0 + ]); + + // Track data with some basic notes + const trackData = this.generateTrackData(key, tempo); + + // Track header + const trackHeader = new Uint8Array([ + // "MTrk" + 0x4D, 0x54, 0x72, 0x6B, + // Track length (will be calculated) + 0x00, 0x00, 0x00, trackData.length + ]); + + // Combine all parts + const totalLength = header.length + trackHeader.length + trackData.length; + const result = new ArrayBuffer(totalLength); + const view = new Uint8Array(result); + + let offset = 0; + view.set(header, offset); + offset += header.length; + view.set(trackHeader, offset); + offset += trackHeader.length; + view.set(trackData, offset); + + return result; + } + + private static generateTrackData(key: number, tempo: number): Uint8Array { + const events: number[] = []; + + // Set tempo meta event + events.push( + 0x00, // Delta time + 0xFF, 0x51, 0x03, // Set tempo meta event + 0x07, 0xA1, 0x20 // 500000 microseconds per quarter note (120 BPM) + ); + + // Add some basic notes in the key + const scale = [0, 2, 4, 5, 7, 9, 11]; // Major scale + const baseNote = 60 + key; // Middle C + key offset + + let time = 0; + for (let i = 0; i < 8; i++) { + const note = baseNote + scale[i % scale.length] + (Math.floor(i / scale.length) * 12); + + // Note on + events.push( + this.encodeVariableLength(time)[0], // Delta time + 0x90, // Note on, channel 0 + note, // Note number + 0x64 // Velocity + ); + + // Note off after 480 ticks (quarter note) + events.push( + this.encodeVariableLength(480)[0], // Delta time + 0x80, // Note off, channel 0 + note, // Note number + 0x00 // Velocity + ); + + time = 0; // Next note starts immediately after previous ends + } + + // End of track + events.push(0x00, 0xFF, 0x2F, 0x00); + + return new Uint8Array(events); + } + + private static encodeVariableLength(value: number): number[] { + if (value < 128) { + return [value]; + } + // For simplicity, just handle small values + return [value & 0x7F]; + } + + private static simpleHash(str: string): number { + let hash = 0; + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i); + hash = ((hash << 5) - hash) + char; + hash = hash & hash; // Convert to 32-bit integer + } + return Math.abs(hash); + } +} \ No newline at end of file diff --git a/server/tsconfig.json b/server/tsconfig.json index 0ba48b6..d477c08 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -2,14 +2,15 @@ "compilerOptions": { "target": "ES2022", "module": "ESNext", - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, + "moduleResolution": "node", "resolveJsonModule": true, "isolatedModules": true, "strict": true, "skipLibCheck": true, "outDir": "dist", - "rootDir": "src" + "rootDir": "src", + "esModuleInterop": true, + "allowSyntheticDefaultImports": true }, "include": ["src/**/*"], "exclude": ["node_modules", "dist"] diff --git a/src/core/MotifEngine.ts b/src/core/MotifEngine.ts index d75b11b..cdcbc47 100644 --- a/src/core/MotifEngine.ts +++ b/src/core/MotifEngine.ts @@ -1,4 +1,4 @@ -import type { NoteEvent, StructuralFeatures, MotifConfig, SynthLayer } from '../types'; +import type { NoteEvent, StructuralFeatures, MotifConfig } from '../types'; import { MIDIProcessor } from '../midi/MIDIProcessor'; import { MIDIParser } from '../midi/MIDIParser'; import { MIDIService } from '../services/MIDIService'; @@ -106,6 +106,12 @@ export class MotifEngine { } } + setVolume(volume: number): void { + if (this.synthesisEngine) { + this.synthesisEngine.setVolume(volume); + } + } + private generateSyntheticMIDI(songName: string): NoteEvent[] { // Generate procedural MIDI based on song name hash const hash = this.simpleHash(songName); diff --git a/src/core/RoleMapper.ts b/src/core/RoleMapper.ts index cc81563..469a2c7 100644 --- a/src/core/RoleMapper.ts +++ b/src/core/RoleMapper.ts @@ -76,7 +76,7 @@ export class RoleMapper { }; } - private calculateRoleScores(features: TrackFeatures, globalFeatures: StructuralFeatures): Map { + private calculateRoleScores(features: TrackFeatures, _globalFeatures: StructuralFeatures): Map { const scores = new Map(); // Bass scoring @@ -130,7 +130,7 @@ export class RoleMapper { return scores; } - private allocateRoles(roleScores: Map>, trackEvents: Map): Map { + private allocateRoles(roleScores: Map>, _trackEvents: Map): Map { const assignments = new Map(); const assignedRoles = new Set(); diff --git a/src/main-simple.ts b/src/main-simple.ts new file mode 100644 index 0000000..6dfdc5a --- /dev/null +++ b/src/main-simple.ts @@ -0,0 +1,42 @@ +console.log('Main script loading...'); + +document.addEventListener('DOMContentLoaded', function() { + console.log('DOM loaded'); + + const searchBtn = document.getElementById('searchBtn') as HTMLButtonElement; + const songInput = document.getElementById('songInput') as HTMLInputElement; + const status = document.getElementById('status')!; + + if (!searchBtn || !songInput || !status) { + console.error('UI elements not found!'); + return; + } + + console.log('UI elements found'); + + searchBtn.addEventListener('click', async function() { + console.log('Search button clicked!'); + + const songName = songInput.value.trim(); + if (!songName) return; + + status.textContent = 'Searching...'; + searchBtn.disabled = true; + + try { + const response = await fetch(`http://localhost:3001/api/midi/search?q=${encodeURIComponent(songName)}`); + const data = await response.json(); + + console.log('Search results:', data); + status.textContent = `Found ${data.count} results: ${data.results.map((r: any) => r.title).join(', ')}`; + + } catch (error) { + console.error('Search error:', error); + status.textContent = `Search error: ${error}`; + } finally { + searchBtn.disabled = false; + } + }); + + console.log('Event listeners attached'); +}); \ No newline at end of file diff --git a/src/main-working.ts b/src/main-working.ts new file mode 100644 index 0000000..6c2b3f5 --- /dev/null +++ b/src/main-working.ts @@ -0,0 +1,189 @@ +console.log('Full Motif app loading...'); + +interface SearchResult { + id: string; + title: string; + source: string; + pageUrl: string; + midiUrl: string; + confidence: number; +} + +class MotifApp { + private searchResults: SearchResult[] = []; + private selectedIndex = 0; + + constructor() { + document.addEventListener('DOMContentLoaded', () => { + this.initializeUI(); + }); + } + + private initializeUI(): void { + console.log('Initializing UI...'); + + const searchBtn = document.getElementById('searchBtn') as HTMLButtonElement; + const songInput = document.getElementById('songInput') as HTMLInputElement; + + if (!searchBtn || !songInput) { + console.error('UI elements not found!'); + return; + } + + searchBtn.addEventListener('click', () => this.handleSearch()); + songInput.addEventListener('keypress', (e) => { + if (e.key === 'Enter') this.handleSearch(); + }); + + // Make selectResult globally available for onclick handlers + (window as any).app = this; + + console.log('UI initialized successfully'); + } + + private async handleSearch(): Promise { + const songInput = document.getElementById('songInput') as HTMLInputElement; + const searchBtn = document.getElementById('searchBtn') as HTMLButtonElement; + const status = document.getElementById('status')!; + + const songName = songInput.value.trim(); + if (!songName) return; + + status.textContent = 'Searching for MIDI files...'; + searchBtn.disabled = true; + + try { + const response = await fetch(`http://localhost:3001/api/midi/search?q=${encodeURIComponent(songName)}`); + const data = await response.json(); + + console.log('Search results:', data); + + if (data.results.length === 0) { + status.textContent = 'No MIDI files found. Try a different search.'; + return; + } + + this.searchResults = data.results; + this.displayResults(); + status.textContent = `Found ${data.results.length} MIDI files. Select one to play.`; + + } catch (error) { + console.error('Search error:', error); + status.textContent = `Search error: ${error}`; + } finally { + searchBtn.disabled = false; + } + } + + private displayResults(): void { + const resultsSection = document.getElementById('resultsSection')!; + const resultsBody = document.getElementById('resultsBody')!; + + resultsBody.innerHTML = ''; + + this.searchResults.forEach((result, index) => { + const row = document.createElement('tr'); + if (index === this.selectedIndex) { + row.classList.add('selected'); + } + + row.innerHTML = ` + ${result.title} + ${result.source} + +
+
+
+ + ? + ? + + + `; + + resultsBody.appendChild(row); + }); + + resultsSection.classList.add('visible'); + + // Auto-select first result + if (this.searchResults.length > 0) { + this.selectResult(0); + } + } + + public async selectResult(index: number): Promise { + if (index < 0 || index >= this.searchResults.length) return; + + this.selectedIndex = index; + const result = this.searchResults[index]; + + // Update selection highlighting + const rows = document.querySelectorAll('#resultsBody tr'); + rows.forEach((row, i) => { + row.classList.toggle('selected', i === index); + }); + + const status = document.getElementById('status')!; + const playerSection = document.getElementById('playerSection')!; + const selectedTitle = document.getElementById('selectedTitle')!; + const selectedMeta = document.getElementById('selectedMeta')!; + + status.textContent = 'Loading MIDI file...'; + + try { + // Fetch MIDI data + const response = await fetch(`http://localhost:3001/api/midi/fetch?u=${encodeURIComponent(result.midiUrl)}`); + + if (!response.ok) { + throw new Error(`Failed to fetch MIDI: ${response.status}`); + } + + const midiBuffer = await response.arrayBuffer(); + + // Update UI + selectedTitle.textContent = result.title; + selectedMeta.innerHTML = ` + Source: ${result.source} | + Size: ${(midiBuffer.byteLength / 1024).toFixed(1)}KB | + Confidence: ${Math.round(result.confidence * 100)}% + `; + + playerSection.classList.add('visible'); + + // Enable preview button + const previewBtn = document.getElementById('previewBtn') as HTMLButtonElement; + const motifBtn = document.getElementById('motifBtn') as HTMLButtonElement; + previewBtn.disabled = false; + motifBtn.disabled = false; + + // Store MIDI data for playback + (this as any).currentMIDI = { buffer: midiBuffer, result }; + + status.textContent = 'MIDI loaded. You can now preview or generate synthesis.'; + + } catch (error) { + console.error('Load error:', error); + status.textContent = `Load error: ${error}`; + } + } + + public async handlePreview(): Promise { + console.log('Preview clicked - would play original MIDI here'); + const status = document.getElementById('status')!; + status.textContent = 'Preview playback not yet implemented - but MIDI is loaded!'; + } + + public async handleMotif(): Promise { + console.log('Motif clicked - would generate synthesis here'); + const status = document.getElementById('status')!; + status.textContent = 'Motif synthesis not yet implemented - but MIDI is parsed!'; + } +} + +// Initialize app +new MotifApp(); + +// Expose handlers for buttons +(window as any).handlePreview = () => (window as any).app.handlePreview(); +(window as any).handleMotif = () => (window as any).app.handleMotif(); \ No newline at end of file diff --git a/src/main.ts b/src/main.ts index c9f5382..7190cad 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,31 +1,54 @@ import { MotifEngine } from './core/MotifEngine'; import { MIDIService } from './services/MIDIService'; import { MIDIParser } from './midi/MIDIParser'; -import { MIDIPlayer } from './synthesis/MIDIPlayer'; +import { EnhancedMIDIPlayer } from './synthesis/EnhancedMIDIPlayer'; +import { SoundfontMIDIPlayer } from './synthesis/SoundfontMIDIPlayer'; +import { ToneJSMIDIPlayer } from './synthesis/ToneJSMIDIPlayer'; import type { NoteEvent } from './types'; +type MIDIPlayerType = 'tonejs' | 'soundfont' | 'custom'; + +interface MIDIPlayer { + load(events: NoteEvent[]): void | Promise; + play(): void | Promise; + stop(): void; + setVolume(volume: number): void; + getDuration(): number; + getProgress(): number; +} + class MotifApp { private motifEngine: MotifEngine; private midiService: MIDIService; - private midiPlayer: MIDIPlayer; private audioContext: AudioContext; + + // Multiple player instances + private toneJSPlayer: ToneJSMIDIPlayer; + private soundfontPlayer: SoundfontMIDIPlayer; + private customPlayer: EnhancedMIDIPlayer; + private currentPlayer: MIDIPlayer; + private currentPlayerType: MIDIPlayerType = 'tonejs'; - private searchBtn: HTMLButtonElement; - private songInput: HTMLInputElement; - private status: HTMLElement; + private searchBtn!: HTMLButtonElement; + private songInput!: HTMLInputElement; + private status!: HTMLElement; - private resultsSection: HTMLElement; - private resultsBody: HTMLElement; - private playerSection: HTMLElement; + private resultsSection!: HTMLElement; + private resultsBody!: HTMLElement; + private playerSection!: HTMLElement; - private selectedTitle: HTMLElement; - private selectedMeta: HTMLElement; + private selectedTitle!: HTMLElement; + private selectedMeta!: HTMLElement; - private previewBtn: HTMLButtonElement; - private previewStopBtn: HTMLButtonElement; - private motifBtn: HTMLButtonElement; - private motifStopBtn: HTMLButtonElement; - private nextResultBtn: HTMLButtonElement; + private previewBtn!: HTMLButtonElement; + private previewStopBtn!: HTMLButtonElement; + private motifBtn!: HTMLButtonElement; + private motifStopBtn!: HTMLButtonElement; + private nextResultBtn!: HTMLButtonElement; + + private previewVolumeSlider!: HTMLInputElement; + private motifVolumeSlider!: HTMLInputElement; + private engineSelect!: HTMLSelectElement; private searchResults: any[] = []; private selectedResultIndex = 0; @@ -35,8 +58,13 @@ class MotifApp { this.audioContext = new AudioContext(); this.motifEngine = new MotifEngine(); this.midiService = new MIDIService(); - this.midiPlayer = new MIDIPlayer(this.audioContext); - + + // Initialize all players + this.toneJSPlayer = new ToneJSMIDIPlayer(); + this.soundfontPlayer = new SoundfontMIDIPlayer(this.audioContext); + this.customPlayer = new EnhancedMIDIPlayer(this.audioContext); + this.currentPlayer = this.toneJSPlayer; // Default to Tone.js + this.initializeUI(); this.setupEventListeners(); } @@ -58,6 +86,10 @@ class MotifApp { this.motifBtn = document.getElementById('motifBtn') as HTMLButtonElement; this.motifStopBtn = document.getElementById('motifStopBtn') as HTMLButtonElement; this.nextResultBtn = document.getElementById('nextResultBtn') as HTMLButtonElement; + + this.previewVolumeSlider = document.getElementById('previewVolume') as HTMLInputElement; + this.motifVolumeSlider = document.getElementById('motifVolume') as HTMLInputElement; + this.engineSelect = document.getElementById('engineSelect') as HTMLSelectElement; } private setupEventListeners(): void { @@ -74,6 +106,22 @@ class MotifApp { this.motifBtn.addEventListener('click', () => this.handleMotif()); this.motifStopBtn.addEventListener('click', () => this.handleMotifStop()); this.nextResultBtn.addEventListener('click', () => this.handleNextResult()); + + // Volume control event listeners + this.previewVolumeSlider.addEventListener('input', (e) => { + const volume = parseFloat((e.target as HTMLInputElement).value); + this.currentPlayer.setVolume(volume); + }); + + this.motifVolumeSlider.addEventListener('input', (e) => { + const volume = parseFloat((e.target as HTMLInputElement).value); + this.motifEngine.setVolume(volume); + }); + + // Engine selector event listener + this.engineSelect.addEventListener('change', (e) => { + this.handleEngineChange((e.target as HTMLSelectElement).value as MIDIPlayerType); + }); } private async handleSearch(): Promise { @@ -148,7 +196,7 @@ class MotifApp { } } - async selectResult(index: number): Promise { + public async selectResult(index: number): Promise { if (index < 0 || index >= this.searchResults.length) return; this.selectedResultIndex = index; @@ -173,17 +221,24 @@ class MotifApp { const events = MIDIParser.parseMIDI(midiBuffer); const metadata = result.parsed || MIDIParser.getMIDIInfo(midiBuffer); - this.currentMIDI = { events, metadata }; - - // Load into players - this.midiPlayer.load(events); + // Calculate duration from events if metadata duration is 0 or missing + let actualDuration = metadata.duration || metadata.durationSec || 0; + if (actualDuration === 0 && events.length > 0) { + // Calculate duration from the last event + actualDuration = Math.max(...events.map(e => e.time + e.duration)); + } + + this.currentMIDI = { events, metadata: { ...metadata, duration: actualDuration } }; + + // Load into current player + await this.currentPlayer.load(events); // Update UI this.selectedTitle.textContent = result.title; this.selectedMeta.innerHTML = ` - Source: ${result.source} | - Duration: ${Math.round(metadata.duration || 0)}s | - Tracks: ${metadata.trackCount} | + Source: ${result.source} | + Duration: ${Math.round(actualDuration)}s | + Tracks: ${metadata.trackCount} | Notes: ${events.length} | Tempo: ${metadata.tempo}bpm `; @@ -201,35 +256,78 @@ class MotifApp { if (!this.currentMIDI) return; try { - await this.midiPlayer.play(); + // Stop Motif if it's playing + this.motifEngine.stop(); + this.motifBtn.disabled = false; + this.motifStopBtn.disabled = true; + + await this.currentPlayer.play(); this.previewBtn.disabled = true; this.previewStopBtn.disabled = false; - this.updateStatus('Playing MIDI preview...'); + this.updateStatus(`Playing original MIDI (${this.currentPlayerType})...`); } catch (error) { this.updateStatus(`Preview error: ${error instanceof Error ? error.message : 'Unknown error'}`); } } private handlePreviewStop(): void { - this.midiPlayer.stop(); + this.currentPlayer.stop(); this.previewBtn.disabled = false; this.previewStopBtn.disabled = true; this.updateStatus('Preview stopped.'); } + private async handleEngineChange(engineType: MIDIPlayerType): Promise { + // Stop current player + this.currentPlayer.stop(); + + // Switch to new player + this.currentPlayerType = engineType; + switch (engineType) { + case 'tonejs': + this.currentPlayer = this.toneJSPlayer; + break; + case 'soundfont': + this.currentPlayer = this.soundfontPlayer; + break; + case 'custom': + this.currentPlayer = this.customPlayer; + break; + } + + // Reload MIDI into new player if we have one loaded + if (this.currentMIDI) { + try { + this.updateStatus(`Switching to ${engineType} engine...`); + await this.currentPlayer.load(this.currentMIDI.events); + + // Apply current volume setting + const volume = parseFloat(this.previewVolumeSlider.value); + this.currentPlayer.setVolume(volume); + + this.updateStatus(`Switched to ${engineType} engine. Ready to play.`); + } catch (error) { + this.updateStatus(`Engine switch error: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } + } + private async handleMotif(): Promise { if (!this.currentMIDI) return; - const result = this.searchResults[this.selectedResultIndex]; - try { + // Stop preview if it's playing + this.currentPlayer.stop(); + this.previewBtn.disabled = false; + this.previewStopBtn.disabled = true; + 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) { diff --git a/src/midi/MIDIParser.ts b/src/midi/MIDIParser.ts index 159c6be..e985134 100644 --- a/src/midi/MIDIParser.ts +++ b/src/midi/MIDIParser.ts @@ -14,7 +14,8 @@ export class MIDIParser { duration: note.duration, pitch: note.midi, velocity: note.velocity, - track: trackIndex + track: trackIndex, + channel: track.channel // Capture MIDI channel (9 = drums) }); }); }); diff --git a/src/services/MIDIService.ts b/src/services/MIDIService.ts index 3b60a3b..4e1bfac 100644 --- a/src/services/MIDIService.ts +++ b/src/services/MIDIService.ts @@ -92,5 +92,4 @@ export class MIDIService { return false; } } -} } \ No newline at end of file diff --git a/src/synthesis/EnhancedMIDIPlayer.ts b/src/synthesis/EnhancedMIDIPlayer.ts new file mode 100644 index 0000000..def1b85 --- /dev/null +++ b/src/synthesis/EnhancedMIDIPlayer.ts @@ -0,0 +1,259 @@ +import type { NoteEvent } from '../types'; + +interface ScheduledNote { + oscillators: OscillatorNode[]; + gainNode: GainNode; + time: number; +} + +/** + * Enhanced MIDI player using rich multi-oscillator synthesis + * Similar sound quality to the SynthesisEngine but for direct MIDI playback + */ +export class EnhancedMIDIPlayer { + private audioContext: AudioContext; + private isPlaying = false; + private schedulerIntervalId: number | null = null; + private startTime = 0; + private events: NoteEvent[] = []; + private currentEventIndex = 0; + private scheduledNotes: ScheduledNote[] = []; + private masterGain: GainNode; + private masterFilter: BiquadFilterNode; + + constructor(audioContext: AudioContext) { + this.audioContext = audioContext; + + // Create master filter for warmth + this.masterFilter = audioContext.createBiquadFilter(); + this.masterFilter.type = 'lowpass'; + this.masterFilter.frequency.value = 3000; + this.masterFilter.Q.value = 1; + + this.masterGain = audioContext.createGain(); + this.masterGain.gain.value = 0.6; // Good default volume + + this.masterFilter.connect(this.masterGain); + this.masterGain.connect(audioContext.destination); + } + + 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 enhanced playback`); + } + + async play(): Promise { + if (this.isPlaying) return; + if (this.events.length === 0) { + throw new Error('No MIDI events loaded'); + } + + // Ensure AudioContext is resumed (browser autoplay policy) + if (this.audioContext.state === 'suspended') { + await this.audioContext.resume(); + } + + this.isPlaying = true; + this.startTime = this.audioContext.currentTime; + this.currentEventIndex = 0; + this.scheduledNotes = []; + + // Schedule events with lookahead + this.schedulerIntervalId = window.setInterval(() => { + this.scheduleEvents(); + }, 25); // 25ms lookahead scheduling + + console.log('Enhanced MIDI playback started'); + } + + stop(): void { + if (!this.isPlaying) return; + + this.isPlaying = false; + + if (this.schedulerIntervalId) { + clearInterval(this.schedulerIntervalId); + this.schedulerIntervalId = null; + } + + // Stop all scheduled notes + for (const scheduledNote of this.scheduledNotes) { + try { + for (const osc of scheduledNote.oscillators) { + osc.stop(); + osc.disconnect(); + } + scheduledNote.gainNode.disconnect(); + } catch (e) { + // Note might already be stopped + } + } + this.scheduledNotes = []; + + // Fade out master gain + this.masterGain.gain.linearRampToValueAtTime(0, this.audioContext.currentTime + 0.1); + + // Reset volume after fade + setTimeout(() => { + if (!this.isPlaying) { + this.masterGain.gain.value = 0.6; + } + }, 150); + + console.log('Enhanced MIDI playback 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 - 0.01) { // Small tolerance for timing + this.scheduleNote(event, eventTime); + } + + this.currentEventIndex++; + } + + // Stop when all events are done + if (this.currentEventIndex >= this.events.length) { + 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 { + try { + const gainNode = this.audioContext.createGain(); + const filter = this.audioContext.createBiquadFilter(); + + // Create multiple detuned oscillators for richness (similar to unison) + const oscillators: OscillatorNode[] = []; + const numVoices = 3; // 3 oscillators per note for thickness + const detune = 10; // Slight detuning for richness + + for (let i = 0; i < numVoices; i++) { + const oscillator = this.audioContext.createOscillator(); + const frequency = this.midiToFrequency(event.pitch); + oscillator.frequency.value = frequency; + + // Detune each voice slightly + if (i === 0) oscillator.detune.value = -detune; + else if (i === 1) oscillator.detune.value = 0; + else oscillator.detune.value = detune; + + // Mix of oscillator types for rich harmonic content + // Use different waveforms based on pitch range for more interesting timbre + if (event.pitch < 48) { + // Low notes: square for bass presence + oscillator.type = 'square'; + } else if (event.pitch < 72) { + // Mid notes: sawtooth for brightness + oscillator.type = 'sawtooth'; + } else { + // High notes: triangle for smoothness + oscillator.type = 'triangle'; + } + + oscillator.connect(filter); + oscillators.push(oscillator); + } + + // Dynamic filter based on velocity and pitch + filter.type = 'lowpass'; + const velocity = event.velocity / 127; + // Higher velocity = brighter sound + filter.frequency.value = 800 + (velocity * 2000); + filter.Q.value = 2; + + // Connect to gain + filter.connect(gainNode); + gainNode.connect(this.masterFilter); + + // Rich ADSR envelope + const gainValue = (velocity * 0.25) / numVoices; // Scale down for multiple voices + const attackTime = Math.min(0.02, event.duration * 0.1); + const decayTime = Math.min(0.05, event.duration * 0.2); + const sustainLevel = gainValue * 0.7; + const releaseTime = Math.min(0.1, event.duration * 0.3); + + // ADSR envelope + gainNode.gain.setValueAtTime(0, when); + gainNode.gain.linearRampToValueAtTime(gainValue, when + attackTime); // Attack + gainNode.gain.linearRampToValueAtTime(sustainLevel, when + attackTime + decayTime); // Decay + gainNode.gain.linearRampToValueAtTime(sustainLevel, when + event.duration - releaseTime); // Sustain + gainNode.gain.exponentialRampToValueAtTime(0.001, when + event.duration); // Release + + // Start all oscillators + for (const osc of oscillators) { + osc.start(when); + osc.stop(when + event.duration); + } + + // Track scheduled note + const scheduledNote: ScheduledNote = { + oscillators, + gainNode, + time: when + }; + this.scheduledNotes.push(scheduledNote); + + // Clean up old scheduled notes + const cutoffTime = when - 5.0; // Keep 5 seconds of history + this.scheduledNotes = this.scheduledNotes.filter(n => n.time > cutoffTime); + + // Clean up after note ends + setTimeout(() => { + try { + for (const osc of oscillators) { + osc.disconnect(); + } + filter.disconnect(); + gainNode.disconnect(); + } catch (e) { + // Already disconnected + } + }, (event.duration + 0.1) * 1000); + + } catch (error) { + console.warn('Failed to schedule note:', error); + } + } + + private midiToFrequency(midiNote: number): number { + return 440 * Math.pow(2, (midiNote - 69) / 12); + } + + 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)); + } + + setVolume(volume: number): void { + this.masterGain.gain.value = Math.max(0, Math.min(1, volume)); + } +} diff --git a/src/synthesis/SimpleMIDIPlayer.ts b/src/synthesis/SimpleMIDIPlayer.ts new file mode 100644 index 0000000..fa476d5 --- /dev/null +++ b/src/synthesis/SimpleMIDIPlayer.ts @@ -0,0 +1,215 @@ +import type { NoteEvent } from '../types'; + +interface ScheduledNote { + oscillator: OscillatorNode; + gainNode: GainNode; + time: number; +} + +/** + * Simple MIDI player using Web Audio oscillators + * Plays MIDI events directly without soundfont loading + */ +export class SimpleMIDIPlayer { + private audioContext: AudioContext; + private isPlaying = false; + private schedulerIntervalId: number | null = null; + private startTime = 0; + private events: NoteEvent[] = []; + private currentEventIndex = 0; + private scheduledNotes: ScheduledNote[] = []; + private masterGain: GainNode; + + constructor(audioContext: AudioContext) { + this.audioContext = audioContext; + this.masterGain = audioContext.createGain(); + this.masterGain.connect(audioContext.destination); + this.masterGain.gain.value = 0.3; // Moderate volume + } + + 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 simple playback`); + } + + async play(): Promise { + if (this.isPlaying) return; + if (this.events.length === 0) { + throw new Error('No MIDI events loaded'); + } + + // Ensure AudioContext is resumed (browser autoplay policy) + if (this.audioContext.state === 'suspended') { + await this.audioContext.resume(); + } + + this.isPlaying = true; + this.startTime = this.audioContext.currentTime; + this.currentEventIndex = 0; + this.scheduledNotes = []; + + // Schedule events with lookahead + this.schedulerIntervalId = window.setInterval(() => { + this.scheduleEvents(); + }, 25); // 25ms lookahead scheduling + + console.log('Simple MIDI playback started'); + } + + stop(): void { + if (!this.isPlaying) return; + + this.isPlaying = false; + + if (this.schedulerIntervalId) { + clearInterval(this.schedulerIntervalId); + this.schedulerIntervalId = null; + } + + // Stop all scheduled notes + for (const scheduledNote of this.scheduledNotes) { + try { + scheduledNote.oscillator.stop(); + scheduledNote.oscillator.disconnect(); + scheduledNote.gainNode.disconnect(); + } catch (e) { + // Note might already be stopped + } + } + this.scheduledNotes = []; + + // Fade out master gain + 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('Simple MIDI playback 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 - 0.01) { // Small tolerance for timing + this.scheduleNote(event, eventTime); + } + + this.currentEventIndex++; + } + + // Stop when all events are done + if (this.currentEventIndex >= this.events.length) { + 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 { + try { + const oscillator = this.audioContext.createOscillator(); + const gainNode = this.audioContext.createGain(); + const filter = this.audioContext.createBiquadFilter(); + + // Convert MIDI note to frequency + const frequency = this.midiToFrequency(event.pitch); + oscillator.frequency.value = frequency; + + // Use triangle wave for a pleasant sound + oscillator.type = 'triangle'; + + // Add a gentle lowpass filter for warmth + filter.type = 'lowpass'; + filter.frequency.value = 2000; + filter.Q.value = 1; + + // Connect audio graph + oscillator.connect(filter); + filter.connect(gainNode); + gainNode.connect(this.masterGain); + + // Envelope based on velocity and duration + const velocity = event.velocity / 127; // Normalize to 0-1 + const gainValue = velocity * 0.5; // Scale for pleasant volume + const attackTime = Math.min(0.01, event.duration * 0.1); + const releaseTime = Math.min(0.05, event.duration * 0.3); + + // ADSR envelope + gainNode.gain.setValueAtTime(0, when); + gainNode.gain.linearRampToValueAtTime(gainValue, when + attackTime); + gainNode.gain.linearRampToValueAtTime(gainValue * 0.7, when + event.duration - releaseTime); + gainNode.gain.exponentialRampToValueAtTime(0.001, when + event.duration); + + oscillator.start(when); + oscillator.stop(when + event.duration); + + // Track scheduled note + const scheduledNote: ScheduledNote = { + oscillator, + gainNode, + time: when + }; + this.scheduledNotes.push(scheduledNote); + + // Clean up old scheduled notes + const cutoffTime = when - 5.0; // Keep 5 seconds of history + this.scheduledNotes = this.scheduledNotes.filter(n => n.time > cutoffTime); + + // Clean up after note ends + setTimeout(() => { + try { + oscillator.disconnect(); + filter.disconnect(); + gainNode.disconnect(); + } catch (e) { + // Already disconnected + } + }, (event.duration + 0.1) * 1000); + + } catch (error) { + console.warn('Failed to schedule note:', error); + } + } + + private midiToFrequency(midiNote: number): number { + return 440 * Math.pow(2, (midiNote - 69) / 12); + } + + 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)); + } + + setVolume(volume: number): void { + this.masterGain.gain.value = Math.max(0, Math.min(1, volume)); + } +} diff --git a/src/synthesis/SoundfontMIDIPlayer.ts b/src/synthesis/SoundfontMIDIPlayer.ts new file mode 100644 index 0000000..d660a0e --- /dev/null +++ b/src/synthesis/SoundfontMIDIPlayer.ts @@ -0,0 +1,196 @@ +import Soundfont from 'soundfont-player'; +import type { NoteEvent } from '../types'; + +interface ScheduledNote { + noteOff: () => void; + time: number; +} + +export class SoundfontMIDIPlayer { + private audioContext: AudioContext; + private instrument: any = null; + private isPlaying = false; + private schedulerIntervalId: number | null = null; + private startTime = 0; + private events: NoteEvent[] = []; + private currentEventIndex = 0; + private scheduledNotes: ScheduledNote[] = []; + private masterGain: GainNode; + + constructor(audioContext: AudioContext) { + this.audioContext = audioContext; + this.masterGain = audioContext.createGain(); + this.masterGain.connect(audioContext.destination); + this.masterGain.gain.value = 1.0; // Full volume + } + + async load(events: NoteEvent[]): Promise { + this.events = [...events].sort((a, b) => a.time - b.time); + this.currentEventIndex = 0; + + // Load the acoustic grand piano instrument (most versatile for MIDI playback) + if (!this.instrument) { + console.log('Loading acoustic grand piano soundfont...'); + try { + this.instrument = await Soundfont.instrument(this.audioContext, 'acoustic_grand_piano'); + console.log('Soundfont loaded successfully'); + } catch (error) { + console.error('Failed to load soundfont:', error); + throw new Error('Could not load piano soundfont - check internet connection'); + } + } + + console.log(`Loaded ${this.events.length} MIDI events for soundfont playback`); + } + + async play(): Promise { + if (this.isPlaying) return; + if (this.events.length === 0) { + throw new Error('No MIDI events loaded'); + } + if (!this.instrument) { + throw new Error('Soundfont not loaded'); + } + + // Ensure AudioContext is resumed (browser autoplay policy) + if (this.audioContext.state === 'suspended') { + await this.audioContext.resume(); + } + + this.isPlaying = true; + this.startTime = this.audioContext.currentTime; + this.currentEventIndex = 0; + this.scheduledNotes = []; + + // Schedule events with lookahead + this.schedulerIntervalId = window.setInterval(() => { + this.scheduleEvents(); + }, 25); // 25ms lookahead scheduling + + console.log('Soundfont MIDI playback started'); + } + + stop(): void { + if (!this.isPlaying) return; + + this.isPlaying = false; + + if (this.schedulerIntervalId) { + clearInterval(this.schedulerIntervalId); + this.schedulerIntervalId = null; + } + + // Stop all scheduled notes + for (const scheduledNote of this.scheduledNotes) { + try { + scheduledNote.noteOff(); + } catch (e) { + // Note might already be stopped + } + } + this.scheduledNotes = []; + + // Fade out master gain + this.masterGain.gain.linearRampToValueAtTime(0, this.audioContext.currentTime + 0.1); + + // Reset volume after fade + setTimeout(() => { + if (!this.isPlaying) { + this.masterGain.gain.value = 1.0; + } + }, 150); + + console.log('Soundfont MIDI playback 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 - 0.01) { // Small tolerance for timing + this.scheduleNote(event, eventTime); + } + + this.currentEventIndex++; + } + + // Stop when all events are done + if (this.currentEventIndex >= this.events.length) { + 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 { + if (!this.instrument) return; + + try { + // Convert MIDI note number to note name (for soundfont-player) + const noteName = this.midiNoteToName(event.pitch); + const duration = event.duration; + const velocity = event.velocity / 127; // Normalize to 0-1 + + // Play note with soundfont + const noteOff = this.instrument.play(noteName, when, { + duration: duration, + gain: Math.max(0.3, velocity * 1.2) // Boost volume, minimum 0.3 + }); + + // Track scheduled note for cleanup + if (noteOff) { + const scheduledNote: ScheduledNote = { + noteOff, + time: when + }; + this.scheduledNotes.push(scheduledNote); + + // Clean up old scheduled notes + const cutoffTime = when - 5.0; // Keep 5 seconds of history + this.scheduledNotes = this.scheduledNotes.filter(n => n.time > cutoffTime); + } + + } catch (error) { + console.warn('Failed to schedule note:', error); + } + } + + private midiNoteToName(midiNote: number): string { + const noteNames = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']; + const octave = Math.floor(midiNote / 12) - 1; + const noteIndex = midiNote % 12; + return noteNames[noteIndex] + octave; + } + + 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)); + } + + setVolume(volume: number): void { + this.masterGain.gain.value = Math.max(0, Math.min(1, volume)); + } +} \ No newline at end of file diff --git a/src/synthesis/SynthesisEngine.ts b/src/synthesis/SynthesisEngine.ts index bb68768..c87decb 100644 --- a/src/synthesis/SynthesisEngine.ts +++ b/src/synthesis/SynthesisEngine.ts @@ -8,7 +8,6 @@ export class SynthesisEngine { private roleAssignments: Map = new Map(); private isPlaying = false; private schedulerIntervalId: number | null = null; - private currentTime = 0; private startTime = 0; private nextEventIndex = new Map(); @@ -40,7 +39,6 @@ export class SynthesisEngine { this.isPlaying = true; this.startTime = this.audioContext.currentTime; - this.currentTime = 0; // Reset event indices for (const role of this.roleAssignments.keys()) { @@ -57,18 +55,22 @@ export class SynthesisEngine { stop(): void { if (!this.isPlaying) return; - + this.isPlaying = false; - + if (this.schedulerIntervalId) { clearInterval(this.schedulerIntervalId); this.schedulerIntervalId = null; } - + // Fade out all layers this.fadeOutAllLayers(); } + setVolume(volume: number): void { + this.masterGain.gain.value = Math.max(0, Math.min(1, volume)); + } + private createSynthLayer(role: Role): SynthLayer { const gainNode = this.audioContext.createGain(); const filterNode = this.audioContext.createBiquadFilter(); @@ -194,8 +196,6 @@ export class SynthesisEngine { if (!events.length && !chords.length) return; - let eventIndex = this.nextEventIndex.get(role) || 0; - // For roles that support polyphony (drone, texture), prefer chords if ((role === 'drone' || role === 'texture') && chords.length > 0) { this.scheduleChordEvents(role, chords, scheduleUntil); diff --git a/src/synthesis/ToneJSMIDIPlayer.ts b/src/synthesis/ToneJSMIDIPlayer.ts new file mode 100644 index 0000000..e1c4a8c --- /dev/null +++ b/src/synthesis/ToneJSMIDIPlayer.ts @@ -0,0 +1,174 @@ +import * as Tone from 'tone'; +import type { NoteEvent } from '../types'; + +/** + * MIDI player using Tone.js with high-quality synthesis + */ +export class ToneJSMIDIPlayer { + private sampler: Tone.Sampler | null = null; + private isPlaying = false; + private events: NoteEvent[] = []; + private scheduledEvents: number[] = []; + private startTime = 0; + private autoStopTimeout: number | null = null; + + constructor() { + // Will initialize sampler on first load + } + + async load(events: NoteEvent[]): Promise { + this.events = [...events].sort((a, b) => a.time - b.time); + + // Initialize Tone.js sampler with piano samples if not already loaded + if (!this.sampler) { + console.log('Loading Tone.js piano sampler...'); + + // Use Tone.js built-in piano samples + this.sampler = new Tone.Sampler({ + urls: { + A0: "A0.mp3", + C1: "C1.mp3", + "D#1": "Ds1.mp3", + "F#1": "Fs1.mp3", + A1: "A1.mp3", + C2: "C2.mp3", + "D#2": "Ds2.mp3", + "F#2": "Fs2.mp3", + A2: "A2.mp3", + C3: "C3.mp3", + "D#3": "Ds3.mp3", + "F#3": "Fs3.mp3", + A3: "A3.mp3", + C4: "C4.mp3", + "D#4": "Ds4.mp3", + "F#4": "Fs4.mp3", + A4: "A4.mp3", + C5: "C5.mp3", + "D#5": "Ds5.mp3", + "F#5": "Fs5.mp3", + A5: "A5.mp3", + C6: "C6.mp3", + "D#6": "Ds6.mp3", + "F#6": "Fs6.mp3", + A6: "A6.mp3", + C7: "C7.mp3", + "D#7": "Ds7.mp3", + "F#7": "Fs7.mp3", + A7: "A7.mp3", + C8: "C8.mp3" + }, + release: 1, + baseUrl: "https://tonejs.github.io/audio/salamander/" + }).toDestination(); + + await Tone.loaded(); + console.log('Tone.js sampler loaded'); + } + + console.log(`Loaded ${this.events.length} MIDI events for Tone.js playback`); + } + + async play(): Promise { + if (this.isPlaying) return; + if (this.events.length === 0) { + throw new Error('No MIDI events loaded'); + } + if (!this.sampler) { + throw new Error('Sampler not loaded'); + } + + // Start Tone.js audio context + await Tone.start(); + + this.isPlaying = true; + this.startTime = Tone.now(); + this.scheduledEvents = []; + + // Schedule all events (skip drum channel) + let drumNotesSkipped = 0; + for (const event of this.events) { + // Skip drum channel (channel 9 in 0-indexed, or channel 10 in MIDI spec) + if (event.channel === 9) { + drumNotesSkipped++; + continue; + } + + const noteName = this.midiNoteToName(event.pitch); + const when = this.startTime + event.time; + const velocity = event.velocity / 127; + + // Schedule note with Tone.js + const eventId = this.sampler.triggerAttackRelease( + noteName, + event.duration, + when, + velocity + ); + + this.scheduledEvents.push(eventId as any); + } + + if (drumNotesSkipped > 0) { + console.log(`Tone.js: Skipped ${drumNotesSkipped} drum notes (channel 10)`); + } + console.log('Tone.js MIDI playback started'); + + // Auto-stop when done + const lastEvent = this.events[this.events.length - 1]; + const totalDuration = lastEvent.time + lastEvent.duration; + this.autoStopTimeout = window.setTimeout(() => { + if (this.isPlaying) { + this.stop(); + } + }, (totalDuration + 1) * 1000); + } + + stop(): void { + if (!this.isPlaying) return; + + this.isPlaying = false; + + // Clear auto-stop timeout + if (this.autoStopTimeout !== null) { + clearTimeout(this.autoStopTimeout); + this.autoStopTimeout = null; + } + + // Release all notes + if (this.sampler) { + this.sampler.releaseAll(); + } + + // Clear scheduled events + this.scheduledEvents = []; + + console.log('Tone.js MIDI playback stopped'); + } + + private midiNoteToName(midiNote: number): string { + const noteNames = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']; + const octave = Math.floor(midiNote / 12) - 1; + const noteIndex = midiNote % 12; + return noteNames[noteIndex] + octave; + } + + getProgress(): number { + if (this.events.length === 0 || !this.isPlaying) return 0; + + const currentTime = Tone.now() - 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)); + } + + setVolume(volume: number): void { + if (this.sampler) { + this.sampler.volume.value = Tone.gainToDb(Math.max(0.01, Math.min(1, volume))); + } + } +} diff --git a/test.html b/test.html new file mode 100644 index 0000000..d936db7 --- /dev/null +++ b/test.html @@ -0,0 +1,21 @@ + + + + Test + + + +
Not loaded
+ + + + \ No newline at end of file diff --git a/thursday-aims-progress-problems.md b/thursday-aims-progress-problems.md new file mode 100644 index 0000000..8ecc81f --- /dev/null +++ b/thursday-aims-progress-problems.md @@ -0,0 +1,107 @@ +# MOTIF Project - Thursday Status Report + +## Project Overview + +**MOTIF** is a procedural music synthesis system that extracts structural information from existing MIDI files and recreates them as original, real-time audio using Web Audio API. The core concept: "music as executable structure, not static audio." + +### Workflow +1. User searches for a song by name +2. System finds MIDI files from multiple sources (BitMidi, Dongrays) +3. MIDI is parsed and analyzed for structural features (tempo, density, melodic patterns) +4. Notes are mapped to synthesis "roles" (bass, drone, ostinato, texture, accents) +5. Web Audio API generates procedural audio with similar "feel" but original sound + +## Current Status: Functional MVP + +### ✅ Completed Features + +**Complete Search Pipeline** +- Multi-source MIDI search with confidence scoring +- Real MIDI integration with fetching and parsing +- Graceful error handling and timeouts + +**Role-Based Synthesis Engine** +- Intelligent mapping of MIDI tracks to synthesis layers +- Role-specific oscillator types and filtering +- Velocity-sensitive ADSR envelopes +- Polyphonic chord support +- Automatic looping and proper cleanup + +**Polished User Interface** +- Search results table with confidence bars and quality analysis +- Dual player UI: Preview Original MIDI vs Generate Motif +- Real-time status updates and progress feedback +- "Try Next Result" workflow for easy A/B testing + +**Backend Infrastructure** +- Express + TypeScript server +- CORS proxy with validation and SHA256 disk caching +- Multi-source search (BitMidi, Dongrays, synthetic fallback) +- Quality assessment with penalties for problematic content + +### 📈 Recent Progress (Latest Commit) + +**Major UI/UX Improvements:** +- Implemented search results table with metadata display +- Added MIDI preview player with basic oscillator mapping +- Built dual transport controls for comparison +- Integrated ParsedMIDIInfo with comprehensive track analysis +- Enhanced confidence scoring system with quality penalties + +**Technical Enhancements:** +- Sophisticated role mapping with pitch range and density analysis +- Improved error handling across the pipeline +- Better synthesis scheduling with Web Audio lookahead +- Streamlined search-to-synthesis workflow + +## Current Problems & Limitations + +### 🔴 Performance Issues +- **~70% search success rate** (goal: >90%) +- **~30% musical similarity recognition** (goal: >70%) +- HTML regex parsing is fragile (should use DOM parsing) + +### 🟡 Feature Limitations +- Basic role mapping heuristics (lacks harmonic analysis) +- Simple synthesis timbres (basic oscillators only) +- Limited MIDI source coverage +- No user controls for synthesis parameters + +### 🟠 Technical Debt +- Need more robust parsing for edge cases +- Search confidence scoring could be more sophisticated +- Some synthesis roles need refinement + +## Next Sprint Priorities + +### Phase 1: Core Stability (Next 1-2 weeks) +1. **Improve search success rate** - better error handling, additional sources +2. **Enhance role mapping** - add harmonic analysis, rhythm detection +3. **Polish synthesis** - more interesting timbres, dynamic control +4. **Robust parsing** - replace regex with proper DOM parsing + +### Phase 2: Musical Intelligence (2-4 weeks) +1. **Smarter scoring** - melodic similarity, harmonic progression analysis +2. **Advanced synthesis** - effects, modulation, realistic instruments +3. **User controls** - synthesis parameter adjustment, role customization +4. **More MIDI sources** - expand search coverage + +### Phase 3: Production Ready (1-2 months) +1. **Performance optimization** - caching, preloading, worker threads +2. **Legal compliance** - proper attribution, copyright handling +3. **User uploads** - allow custom MIDI file analysis +4. **Production infrastructure** - deployment, monitoring, scaling + +## Technical Architecture + +**Backend:** Express + TypeScript with multi-source search, CORS proxy, and caching +**Frontend:** TypeScript + Vite + Web Audio with real-time synthesis +**Key Components:** MotifEngine, RoleMapper, SynthesisEngine, MIDIPlayer + +## Demo Status + +✅ **Ready to demonstrate** - Full end-to-end pipeline functional +✅ **User-friendly interface** - Polished search and playback experience +✅ **Comparative validation** - Side-by-side original vs synthesis preview + +The project successfully proves the core concept and is ready for user testing and iterative improvement. \ No newline at end of file