Improve MIDI search and add browser playback engines.
This commit is contained in:
@@ -33,3 +33,7 @@ logs/
|
||||
# Runtime
|
||||
.cache/
|
||||
.tmp/
|
||||
.vercel
|
||||
|
||||
# Backend runtime cache (downloaded MIDI files)
|
||||
server/cache/
|
||||
|
||||
+51
-2
@@ -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%;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -175,15 +208,31 @@
|
||||
<div>
|
||||
<h4>MIDI Preview</h4>
|
||||
<p>Plays original MIDI as-is</p>
|
||||
<button id="previewBtn" disabled>Play Original</button>
|
||||
<div class="engine-selector">
|
||||
<label for="engineSelect">Playback Engine:</label>
|
||||
<select id="engineSelect">
|
||||
<option value="tonejs">Tone.js (Sampled Piano)</option>
|
||||
<option value="soundfont">Soundfont (Piano CDN)</option>
|
||||
<option value="custom">Custom Synthesis</option>
|
||||
</select>
|
||||
</div>
|
||||
<button id="previewBtn" disabled onclick="handlePreview()">Play Original</button>
|
||||
<button id="previewStopBtn" disabled>Stop</button>
|
||||
<div class="volume-control">
|
||||
<label for="previewVolume">Volume:</label>
|
||||
<input type="range" id="previewVolume" min="0" max="1" step="0.01" value="0.6" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4>Motif Generation</h4>
|
||||
<p>Procedural synthesis from structure</p>
|
||||
<button id="motifBtn" disabled>Generate & Play</button>
|
||||
<button id="motifBtn" disabled onclick="handleMotif()">Generate & Play</button>
|
||||
<button id="motifStopBtn" disabled>Stop</button>
|
||||
<div class="volume-control">
|
||||
<label for="motifVolume">Volume:</label>
|
||||
<input type="range" id="motifVolume" min="0" max="1" step="0.01" value="0.3" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Generated
+104
-1
@@ -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",
|
||||
|
||||
+3
-1
@@ -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",
|
||||
|
||||
Generated
+36
-1
@@ -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",
|
||||
|
||||
+5
-4
@@ -10,14 +10,15 @@
|
||||
"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"
|
||||
|
||||
@@ -29,47 +29,154 @@ 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 = /<a[^>]*href="([^"]*)"[^>]*>([^<]*)</gi;
|
||||
let match;
|
||||
try {
|
||||
// Look for different possible variable names containing search data
|
||||
let initStoreStart = html.indexOf('window.initStore = ');
|
||||
if (initStoreStart === -1) {
|
||||
initStoreStart = html.indexOf('window.__INITIAL_STATE__ = ');
|
||||
if (initStoreStart === -1) {
|
||||
initStoreStart = html.indexOf('window.INITIAL_PROPS = ');
|
||||
if (initStoreStart === -1) {
|
||||
// Look for any assignment that contains the data structure we need
|
||||
// From the context, we know the pattern is: {"data":{"midis":{...
|
||||
const dataStartPattern = '"data":{"midis":';
|
||||
const dataPatternIndex = html.indexOf(dataStartPattern);
|
||||
if (dataPatternIndex !== -1) {
|
||||
// Go backwards to find the start of the containing object
|
||||
let bracketStart = dataPatternIndex;
|
||||
let bracketDepth = 0;
|
||||
while (bracketStart > 0) {
|
||||
bracketStart--;
|
||||
if (html[bracketStart] === '}') bracketDepth++;
|
||||
if (html[bracketStart] === '{') {
|
||||
if (bracketDepth === 0) break;
|
||||
bracketDepth--;
|
||||
}
|
||||
}
|
||||
initStoreStart = bracketStart;
|
||||
} else {
|
||||
|
||||
while ((match = linkRegex.exec(html)) !== null && candidates.length < 10) {
|
||||
const [, href, title] = match;
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Look for MIDI file links
|
||||
if (href.includes('/midi/') && !href.includes('.mp3') && !href.includes('.wav')) {
|
||||
const fullUrl = href.startsWith('http') ? href : `${this.baseUrl}${href}`;
|
||||
const midiUrl = this.extractMidiUrl(fullUrl);
|
||||
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}`;
|
||||
|
||||
if (midiUrl && title.trim()) {
|
||||
const confidence = ScoreUtils.calculateConfidence(title, query, this.name);
|
||||
|
||||
candidates.push({
|
||||
id: `bitmidi_${Buffer.from(fullUrl).toString('base64').slice(0, 16)}`,
|
||||
id: `bitmidi_${midiData.id}`,
|
||||
title: title.trim(),
|
||||
source: 'bitmidi',
|
||||
pageUrl: fullUrl,
|
||||
midiUrl: midiUrl,
|
||||
pageUrl: pageUrl,
|
||||
midiUrl: fullDownloadUrl,
|
||||
confidence
|
||||
});
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('BitMidi: Error parsing JSON data:', error);
|
||||
return [];
|
||||
}
|
||||
|
||||
return candidates
|
||||
.filter(c => 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`;
|
||||
async getMidiDownloadUrl(pageUrl: string): Promise<string> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<MIDICandidate[]> {
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<MIDICandidate[]> {
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
export interface MIDICandidate {
|
||||
id: string;
|
||||
title: string;
|
||||
source: 'bitmidi' | 'dongrays';
|
||||
source: 'bitmidi' | 'dongrays' | 'mock';
|
||||
pageUrl: string;
|
||||
midiUrl: string;
|
||||
confidence: number;
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
// Using dynamic import to handle the CommonJS module properly
|
||||
export class ProperMIDI {
|
||||
static async generateMIDI(songName: string): Promise<ArrayBuffer> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -76,7 +76,7 @@ export class RoleMapper {
|
||||
};
|
||||
}
|
||||
|
||||
private calculateRoleScores(features: TrackFeatures, globalFeatures: StructuralFeatures): Map<Role, number> {
|
||||
private calculateRoleScores(features: TrackFeatures, _globalFeatures: StructuralFeatures): Map<Role, number> {
|
||||
const scores = new Map<Role, number>();
|
||||
|
||||
// Bass scoring
|
||||
@@ -130,7 +130,7 @@ export class RoleMapper {
|
||||
return scores;
|
||||
}
|
||||
|
||||
private allocateRoles(roleScores: Map<number, Map<Role, number>>, trackEvents: Map<number, NoteEvent[]>): Map<number, Role> {
|
||||
private allocateRoles(roleScores: Map<number, Map<Role, number>>, _trackEvents: Map<number, NoteEvent[]>): Map<number, Role> {
|
||||
const assignments = new Map<number, Role>();
|
||||
const assignedRoles = new Set<Role>();
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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<void> {
|
||||
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 = `
|
||||
<td>${result.title}</td>
|
||||
<td>${result.source}</td>
|
||||
<td>
|
||||
<div class="confidence-bar">
|
||||
<div class="confidence-fill" style="width: ${result.confidence * 100}%"></div>
|
||||
</div>
|
||||
</td>
|
||||
<td>?</td>
|
||||
<td>?</td>
|
||||
<td></td>
|
||||
<td><button onclick="window.app.selectResult(${index})">Select</button></td>
|
||||
`;
|
||||
|
||||
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<void> {
|
||||
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 = `
|
||||
<strong>Source:</strong> ${result.source} |
|
||||
<strong>Size:</strong> ${(midiBuffer.byteLength / 1024).toFixed(1)}KB |
|
||||
<strong>Confidence:</strong> ${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<void> {
|
||||
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<void> {
|
||||
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();
|
||||
+124
-26
@@ -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<void>;
|
||||
play(): void | Promise<void>;
|
||||
stop(): void;
|
||||
setVolume(volume: number): void;
|
||||
getDuration(): number;
|
||||
getProgress(): number;
|
||||
}
|
||||
|
||||
class MotifApp {
|
||||
private motifEngine: MotifEngine;
|
||||
private midiService: MIDIService;
|
||||
private midiPlayer: MIDIPlayer;
|
||||
private audioContext: AudioContext;
|
||||
|
||||
private searchBtn: HTMLButtonElement;
|
||||
private songInput: HTMLInputElement;
|
||||
private status: HTMLElement;
|
||||
// Multiple player instances
|
||||
private toneJSPlayer: ToneJSMIDIPlayer;
|
||||
private soundfontPlayer: SoundfontMIDIPlayer;
|
||||
private customPlayer: EnhancedMIDIPlayer;
|
||||
private currentPlayer: MIDIPlayer;
|
||||
private currentPlayerType: MIDIPlayerType = 'tonejs';
|
||||
|
||||
private resultsSection: HTMLElement;
|
||||
private resultsBody: HTMLElement;
|
||||
private playerSection: HTMLElement;
|
||||
private searchBtn!: HTMLButtonElement;
|
||||
private songInput!: HTMLInputElement;
|
||||
private status!: HTMLElement;
|
||||
|
||||
private selectedTitle: HTMLElement;
|
||||
private selectedMeta: HTMLElement;
|
||||
private resultsSection!: HTMLElement;
|
||||
private resultsBody!: HTMLElement;
|
||||
private playerSection!: HTMLElement;
|
||||
|
||||
private previewBtn: HTMLButtonElement;
|
||||
private previewStopBtn: HTMLButtonElement;
|
||||
private motifBtn: HTMLButtonElement;
|
||||
private motifStopBtn: HTMLButtonElement;
|
||||
private nextResultBtn: HTMLButtonElement;
|
||||
private selectedTitle!: HTMLElement;
|
||||
private selectedMeta!: HTMLElement;
|
||||
|
||||
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,7 +58,12 @@ 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<void> {
|
||||
@@ -148,7 +196,7 @@ class MotifApp {
|
||||
}
|
||||
}
|
||||
|
||||
async selectResult(index: number): Promise<void> {
|
||||
public async selectResult(index: number): Promise<void> {
|
||||
if (index < 0 || index >= this.searchResults.length) return;
|
||||
|
||||
this.selectedResultIndex = index;
|
||||
@@ -173,16 +221,23 @@ class MotifApp {
|
||||
const events = MIDIParser.parseMIDI(midiBuffer);
|
||||
const metadata = result.parsed || MIDIParser.getMIDIInfo(midiBuffer);
|
||||
|
||||
this.currentMIDI = { events, metadata };
|
||||
// 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));
|
||||
}
|
||||
|
||||
// Load into players
|
||||
this.midiPlayer.load(events);
|
||||
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 = `
|
||||
<strong>Source:</strong> ${result.source} |
|
||||
<strong>Duration:</strong> ${Math.round(metadata.duration || 0)}s |
|
||||
<strong>Duration:</strong> ${Math.round(actualDuration)}s |
|
||||
<strong>Tracks:</strong> ${metadata.trackCount} |
|
||||
<strong>Notes:</strong> ${events.length} |
|
||||
<strong>Tempo:</strong> ${metadata.tempo}bpm
|
||||
@@ -201,28 +256,71 @@ 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<void> {
|
||||
// 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<void> {
|
||||
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;
|
||||
|
||||
|
||||
@@ -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)
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -93,4 +93,3 @@ export class MIDIService {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,6 @@ export class SynthesisEngine {
|
||||
private roleAssignments: Map<Role, RoleAssignment> = new Map();
|
||||
private isPlaying = false;
|
||||
private schedulerIntervalId: number | null = null;
|
||||
private currentTime = 0;
|
||||
private startTime = 0;
|
||||
private nextEventIndex = new Map<Role, number>();
|
||||
|
||||
@@ -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()) {
|
||||
@@ -69,6 +67,10 @@ export class SynthesisEngine {
|
||||
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);
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Test</title>
|
||||
</head>
|
||||
<body>
|
||||
<button id="testBtn">Test Button</button>
|
||||
<div id="output">Not loaded</div>
|
||||
|
||||
<script>
|
||||
console.log('JavaScript is loading...');
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
console.log('DOM loaded');
|
||||
document.getElementById('output').textContent = 'JavaScript loaded!';
|
||||
document.getElementById('testBtn').addEventListener('click', function() {
|
||||
alert('Button works!');
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user