Improve MIDI search and add browser playback engines.
This commit is contained in:
+5
-1
@@ -32,4 +32,8 @@ logs/
|
|||||||
|
|
||||||
# Runtime
|
# Runtime
|
||||||
.cache/
|
.cache/
|
||||||
.tmp/
|
.tmp/
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# Backend runtime cache (downloaded MIDI files)
|
||||||
|
server/cache/
|
||||||
|
|||||||
+52
-3
@@ -132,6 +132,39 @@
|
|||||||
color: #ffaa44;
|
color: #ffaa44;
|
||||||
font-size: 0.9em;
|
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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -175,15 +208,31 @@
|
|||||||
<div>
|
<div>
|
||||||
<h4>MIDI Preview</h4>
|
<h4>MIDI Preview</h4>
|
||||||
<p>Plays original MIDI as-is</p>
|
<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>
|
<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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h4>Motif Generation</h4>
|
<h4>Motif Generation</h4>
|
||||||
<p>Procedural synthesis from structure</p>
|
<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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Generated
+104
-1
@@ -9,7 +9,9 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tonejs/midi": "^2.0.28"
|
"@tonejs/midi": "^2.0.28",
|
||||||
|
"soundfont-player": "^0.12.0",
|
||||||
|
"tone": "^15.1.22"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20.0.0",
|
"@types/node": "^20.0.0",
|
||||||
@@ -20,6 +22,15 @@
|
|||||||
"vite": "^5.0.0"
|
"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": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.21.5",
|
"version": "0.21.5",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
|
"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"
|
"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": {
|
"node_modules/ajv": {
|
||||||
"version": "6.12.6",
|
"version": "6.12.6",
|
||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
||||||
@@ -1241,6 +1258,25 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/balanced-match": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||||
@@ -2096,6 +2132,12 @@
|
|||||||
"integrity": "sha512-B5SnBC6i2bwJIXTY9MElIydJwAmnKx+r5eJ1jknTLetzLflEl0GWveuBB6ACrQpecSRkOB6fhTx1PwXk2BVxnA==",
|
"integrity": "sha512-B5SnBC6i2bwJIXTY9MElIydJwAmnKx+r5eJ1jknTLetzLflEl0GWveuBB6ACrQpecSRkOB6fhTx1PwXk2BVxnA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/minimatch": {
|
||||||
"version": "9.0.3",
|
"version": "9.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
|
||||||
@@ -2145,6 +2187,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/once": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||||
@@ -2452,6 +2500,23 @@
|
|||||||
"queue-microtask": "^1.2.2"
|
"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": {
|
"node_modules/semver": {
|
||||||
"version": "7.7.3",
|
"version": "7.7.3",
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
|
||||||
@@ -2498,6 +2563,17 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
@@ -2508,6 +2584,17 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/strip-ansi": {
|
||||||
"version": "6.0.1",
|
"version": "6.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||||
@@ -2567,6 +2654,16 @@
|
|||||||
"node": ">=8.0"
|
"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": {
|
"node_modules/ts-api-utils": {
|
||||||
"version": "1.4.3",
|
"version": "1.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz",
|
||||||
@@ -2580,6 +2677,12 @@
|
|||||||
"typescript": ">=4.2.0"
|
"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": {
|
"node_modules/type-check": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||||
|
|||||||
+4
-2
@@ -22,7 +22,9 @@
|
|||||||
"vite": "^5.0.0"
|
"vite": "^5.0.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tonejs/midi": "^2.0.28"
|
"@tonejs/midi": "^2.0.28",
|
||||||
|
"soundfont-player": "^0.12.0",
|
||||||
|
"tone": "^15.1.22"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"web-audio",
|
"web-audio",
|
||||||
@@ -32,4 +34,4 @@
|
|||||||
],
|
],
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+36
-1
@@ -11,7 +11,8 @@
|
|||||||
"cheerio": "^1.0.0-rc.12",
|
"cheerio": "^1.0.0-rc.12",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"crypto": "^1.0.1",
|
"crypto": "^1.0.1",
|
||||||
"express": "^4.18.2"
|
"express": "^4.18.2",
|
||||||
|
"midi-writer-js": "^3.1.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.17",
|
||||||
@@ -463,6 +464,30 @@
|
|||||||
"node": ">=18"
|
"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": {
|
"node_modules/@types/body-parser": {
|
||||||
"version": "1.19.6",
|
"version": "1.19.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
||||||
@@ -1346,6 +1371,16 @@
|
|||||||
"node": ">= 0.6"
|
"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": {
|
"node_modules/mime": {
|
||||||
"version": "1.6.0",
|
"version": "1.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
|
||||||
|
|||||||
+6
-5
@@ -10,16 +10,17 @@
|
|||||||
"start": "node dist/server.js"
|
"start": "node dist/server.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"express": "^4.18.2",
|
|
||||||
"cors": "^2.8.5",
|
|
||||||
"cheerio": "^1.0.0-rc.12",
|
"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": {
|
"devDependencies": {
|
||||||
"@types/express": "^4.17.21",
|
|
||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.17",
|
||||||
|
"@types/express": "^4.17.21",
|
||||||
"@types/node": "^20.0.0",
|
"@types/node": "^20.0.0",
|
||||||
"tsx": "^4.0.0",
|
"tsx": "^4.0.0",
|
||||||
"typescript": "^5.0.0"
|
"typescript": "^5.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,31 +29,108 @@ export class BitMidiAdapter implements SearchAdapter {
|
|||||||
private parseSearchResults(html: string, query: string): MIDICandidate[] {
|
private parseSearchResults(html: string, query: string): MIDICandidate[] {
|
||||||
const candidates: MIDICandidate[] = [];
|
const candidates: MIDICandidate[] = [];
|
||||||
|
|
||||||
// Simple regex-based parsing for MVP (would use cheerio in production)
|
try {
|
||||||
const linkRegex = /<a[^>]*href="([^"]*)"[^>]*>([^<]*)</gi;
|
// Look for different possible variable names containing search data
|
||||||
let match;
|
let initStoreStart = html.indexOf('window.initStore = ');
|
||||||
|
if (initStoreStart === -1) {
|
||||||
while ((match = linkRegex.exec(html)) !== null && candidates.length < 10) {
|
initStoreStart = html.indexOf('window.__INITIAL_STATE__ = ');
|
||||||
const [, href, title] = match;
|
if (initStoreStart === -1) {
|
||||||
|
initStoreStart = html.indexOf('window.INITIAL_PROPS = ');
|
||||||
// Look for MIDI file links
|
if (initStoreStart === -1) {
|
||||||
if (href.includes('/midi/') && !href.includes('.mp3') && !href.includes('.wav')) {
|
// Look for any assignment that contains the data structure we need
|
||||||
const fullUrl = href.startsWith('http') ? href : `${this.baseUrl}${href}`;
|
// From the context, we know the pattern is: {"data":{"midis":{...
|
||||||
const midiUrl = this.extractMidiUrl(fullUrl);
|
const dataStartPattern = '"data":{"midis":';
|
||||||
|
const dataPatternIndex = html.indexOf(dataStartPattern);
|
||||||
if (midiUrl && title.trim()) {
|
if (dataPatternIndex !== -1) {
|
||||||
const confidence = ScoreUtils.calculateConfidence(title, query, this.name);
|
// Go backwards to find the start of the containing object
|
||||||
|
let bracketStart = dataPatternIndex;
|
||||||
candidates.push({
|
let bracketDepth = 0;
|
||||||
id: `bitmidi_${Buffer.from(fullUrl).toString('base64').slice(0, 16)}`,
|
while (bracketStart > 0) {
|
||||||
title: title.trim(),
|
bracketStart--;
|
||||||
source: 'bitmidi',
|
if (html[bracketStart] === '}') bracketDepth++;
|
||||||
pageUrl: fullUrl,
|
if (html[bracketStart] === '{') {
|
||||||
midiUrl: midiUrl,
|
if (bracketDepth === 0) break;
|
||||||
confidence
|
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
|
return candidates
|
||||||
@@ -62,14 +139,44 @@ export class BitMidiAdapter implements SearchAdapter {
|
|||||||
.slice(0, 5); // Top 5 results
|
.slice(0, 5); // Top 5 results
|
||||||
}
|
}
|
||||||
|
|
||||||
private extractMidiUrl(pageUrl: string): string {
|
async getMidiDownloadUrl(pageUrl: string): Promise<string> {
|
||||||
// For BitMidi, the MIDI download is typically at the same path with .mid extension
|
try {
|
||||||
// or through a download endpoint
|
const response = await fetch(pageUrl, {
|
||||||
if (pageUrl.includes('/midi/')) {
|
headers: {
|
||||||
// Try direct .mid file first
|
'User-Agent': 'Mozilla/5.0 (compatible; MotifBot/1.0)'
|
||||||
const basePath = pageUrl.replace(/\/$/, '');
|
}
|
||||||
return `${basePath}.mid`;
|
});
|
||||||
|
|
||||||
|
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;
|
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) {
|
if (result.success) {
|
||||||
res.setHeader('Content-Type', 'audio/midi');
|
res.setHeader('Content-Type', 'audio/midi');
|
||||||
res.setHeader('Content-Length', result.data!.length);
|
res.setHeader('Content-Length', result.data!.byteLength);
|
||||||
res.send(result.data);
|
res.send(Buffer.from(result.data!));
|
||||||
} else {
|
} else {
|
||||||
res.status(404).json({ error: result.error });
|
res.status(404).json({ error: result.error });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import fs from 'fs/promises';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import { ScoreUtils } from '../utils/ScoreUtils.js';
|
import { ScoreUtils } from '../utils/ScoreUtils.js';
|
||||||
|
import { SimpleMIDI } from '../utils/SimpleMIDI.js';
|
||||||
import type { CacheEntry } from '../types.js';
|
import type { CacheEntry } from '../types.js';
|
||||||
|
|
||||||
export class MIDIFetchService {
|
export class MIDIFetchService {
|
||||||
@@ -14,6 +15,19 @@ export class MIDIFetchService {
|
|||||||
|
|
||||||
async fetch(url: string): Promise<{ success: boolean; data?: ArrayBuffer; error?: string }> {
|
async fetch(url: string): Promise<{ success: boolean; data?: ArrayBuffer; error?: string }> {
|
||||||
try {
|
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
|
// Check cache first
|
||||||
const hash = this.hashUrl(url);
|
const hash = this.hashUrl(url);
|
||||||
const cached = await this.getCached(hash);
|
const cached = await this.getCached(hash);
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
import type { SearchAdapter, MIDICandidate } from '../types.js';
|
import type { SearchAdapter, MIDICandidate } from '../types.js';
|
||||||
import { BitMidiAdapter } from '../adapters/BitMidiAdapter.js';
|
import { BitMidiAdapter } from '../adapters/BitMidiAdapter.js';
|
||||||
import { DongraysAdapter } from '../adapters/DongraysAdapter.js';
|
import { DongraysAdapter } from '../adapters/DongraysAdapter.js';
|
||||||
|
import { MockAdapter } from '../adapters/MockAdapter.js';
|
||||||
|
|
||||||
export class MIDISearchService {
|
export class MIDISearchService {
|
||||||
private adapters: SearchAdapter[];
|
private adapters: SearchAdapter[];
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
|
// Real MIDI adapters only - no mock
|
||||||
this.adapters = [
|
this.adapters = [
|
||||||
new BitMidiAdapter(),
|
new BitMidiAdapter(),
|
||||||
new DongraysAdapter()
|
new DongraysAdapter()
|
||||||
];
|
];
|
||||||
|
console.log('MIDISearchService initialized with real adapters:', this.adapters.map(a => a.name));
|
||||||
}
|
}
|
||||||
|
|
||||||
async search(query: string): Promise<MIDICandidate[]> {
|
async search(query: string): Promise<MIDICandidate[]> {
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
export interface MIDICandidate {
|
export interface MIDICandidate {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
source: 'bitmidi' | 'dongrays';
|
source: 'bitmidi' | 'dongrays' | 'mock';
|
||||||
pageUrl: string;
|
pageUrl: string;
|
||||||
midiUrl: string;
|
midiUrl: string;
|
||||||
confidence: number;
|
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": {
|
"compilerOptions": {
|
||||||
"target": "ES2022",
|
"target": "ES2022",
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "node",
|
||||||
"allowImportingTsExtensions": true,
|
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"outDir": "dist",
|
"outDir": "dist",
|
||||||
"rootDir": "src"
|
"rootDir": "src",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"],
|
"include": ["src/**/*"],
|
||||||
"exclude": ["node_modules", "dist"]
|
"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 { MIDIProcessor } from '../midi/MIDIProcessor';
|
||||||
import { MIDIParser } from '../midi/MIDIParser';
|
import { MIDIParser } from '../midi/MIDIParser';
|
||||||
import { MIDIService } from '../services/MIDIService';
|
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[] {
|
private generateSyntheticMIDI(songName: string): NoteEvent[] {
|
||||||
// Generate procedural MIDI based on song name hash
|
// Generate procedural MIDI based on song name hash
|
||||||
const hash = this.simpleHash(songName);
|
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>();
|
const scores = new Map<Role, number>();
|
||||||
|
|
||||||
// Bass scoring
|
// Bass scoring
|
||||||
@@ -130,7 +130,7 @@ export class RoleMapper {
|
|||||||
return scores;
|
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 assignments = new Map<number, Role>();
|
||||||
const assignedRoles = new Set<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();
|
||||||
+130
-32
@@ -1,31 +1,54 @@
|
|||||||
import { MotifEngine } from './core/MotifEngine';
|
import { MotifEngine } from './core/MotifEngine';
|
||||||
import { MIDIService } from './services/MIDIService';
|
import { MIDIService } from './services/MIDIService';
|
||||||
import { MIDIParser } from './midi/MIDIParser';
|
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';
|
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 {
|
class MotifApp {
|
||||||
private motifEngine: MotifEngine;
|
private motifEngine: MotifEngine;
|
||||||
private midiService: MIDIService;
|
private midiService: MIDIService;
|
||||||
private midiPlayer: MIDIPlayer;
|
|
||||||
private audioContext: AudioContext;
|
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 searchBtn!: HTMLButtonElement;
|
||||||
private songInput: HTMLInputElement;
|
private songInput!: HTMLInputElement;
|
||||||
private status: HTMLElement;
|
private status!: HTMLElement;
|
||||||
|
|
||||||
private resultsSection: HTMLElement;
|
private resultsSection!: HTMLElement;
|
||||||
private resultsBody: HTMLElement;
|
private resultsBody!: HTMLElement;
|
||||||
private playerSection: HTMLElement;
|
private playerSection!: HTMLElement;
|
||||||
|
|
||||||
private selectedTitle: HTMLElement;
|
private selectedTitle!: HTMLElement;
|
||||||
private selectedMeta: HTMLElement;
|
private selectedMeta!: HTMLElement;
|
||||||
|
|
||||||
private previewBtn: HTMLButtonElement;
|
private previewBtn!: HTMLButtonElement;
|
||||||
private previewStopBtn: HTMLButtonElement;
|
private previewStopBtn!: HTMLButtonElement;
|
||||||
private motifBtn: HTMLButtonElement;
|
private motifBtn!: HTMLButtonElement;
|
||||||
private motifStopBtn: HTMLButtonElement;
|
private motifStopBtn!: HTMLButtonElement;
|
||||||
private nextResultBtn: HTMLButtonElement;
|
private nextResultBtn!: HTMLButtonElement;
|
||||||
|
|
||||||
|
private previewVolumeSlider!: HTMLInputElement;
|
||||||
|
private motifVolumeSlider!: HTMLInputElement;
|
||||||
|
private engineSelect!: HTMLSelectElement;
|
||||||
|
|
||||||
private searchResults: any[] = [];
|
private searchResults: any[] = [];
|
||||||
private selectedResultIndex = 0;
|
private selectedResultIndex = 0;
|
||||||
@@ -35,8 +58,13 @@ class MotifApp {
|
|||||||
this.audioContext = new AudioContext();
|
this.audioContext = new AudioContext();
|
||||||
this.motifEngine = new MotifEngine();
|
this.motifEngine = new MotifEngine();
|
||||||
this.midiService = new MIDIService();
|
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.initializeUI();
|
||||||
this.setupEventListeners();
|
this.setupEventListeners();
|
||||||
}
|
}
|
||||||
@@ -58,6 +86,10 @@ class MotifApp {
|
|||||||
this.motifBtn = document.getElementById('motifBtn') as HTMLButtonElement;
|
this.motifBtn = document.getElementById('motifBtn') as HTMLButtonElement;
|
||||||
this.motifStopBtn = document.getElementById('motifStopBtn') as HTMLButtonElement;
|
this.motifStopBtn = document.getElementById('motifStopBtn') as HTMLButtonElement;
|
||||||
this.nextResultBtn = document.getElementById('nextResultBtn') 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 {
|
private setupEventListeners(): void {
|
||||||
@@ -74,6 +106,22 @@ class MotifApp {
|
|||||||
this.motifBtn.addEventListener('click', () => this.handleMotif());
|
this.motifBtn.addEventListener('click', () => this.handleMotif());
|
||||||
this.motifStopBtn.addEventListener('click', () => this.handleMotifStop());
|
this.motifStopBtn.addEventListener('click', () => this.handleMotifStop());
|
||||||
this.nextResultBtn.addEventListener('click', () => this.handleNextResult());
|
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> {
|
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;
|
if (index < 0 || index >= this.searchResults.length) return;
|
||||||
|
|
||||||
this.selectedResultIndex = index;
|
this.selectedResultIndex = index;
|
||||||
@@ -173,17 +221,24 @@ class MotifApp {
|
|||||||
const events = MIDIParser.parseMIDI(midiBuffer);
|
const events = MIDIParser.parseMIDI(midiBuffer);
|
||||||
const metadata = result.parsed || MIDIParser.getMIDIInfo(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;
|
||||||
// Load into players
|
if (actualDuration === 0 && events.length > 0) {
|
||||||
this.midiPlayer.load(events);
|
// 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
|
// Update UI
|
||||||
this.selectedTitle.textContent = result.title;
|
this.selectedTitle.textContent = result.title;
|
||||||
this.selectedMeta.innerHTML = `
|
this.selectedMeta.innerHTML = `
|
||||||
<strong>Source:</strong> ${result.source} |
|
<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>Tracks:</strong> ${metadata.trackCount} |
|
||||||
<strong>Notes:</strong> ${events.length} |
|
<strong>Notes:</strong> ${events.length} |
|
||||||
<strong>Tempo:</strong> ${metadata.tempo}bpm
|
<strong>Tempo:</strong> ${metadata.tempo}bpm
|
||||||
`;
|
`;
|
||||||
@@ -201,35 +256,78 @@ class MotifApp {
|
|||||||
if (!this.currentMIDI) return;
|
if (!this.currentMIDI) return;
|
||||||
|
|
||||||
try {
|
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.previewBtn.disabled = true;
|
||||||
this.previewStopBtn.disabled = false;
|
this.previewStopBtn.disabled = false;
|
||||||
this.updateStatus('Playing MIDI preview...');
|
this.updateStatus(`Playing original MIDI (${this.currentPlayerType})...`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.updateStatus(`Preview error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
this.updateStatus(`Preview error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private handlePreviewStop(): void {
|
private handlePreviewStop(): void {
|
||||||
this.midiPlayer.stop();
|
this.currentPlayer.stop();
|
||||||
this.previewBtn.disabled = false;
|
this.previewBtn.disabled = false;
|
||||||
this.previewStopBtn.disabled = true;
|
this.previewStopBtn.disabled = true;
|
||||||
this.updateStatus('Preview stopped.');
|
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> {
|
private async handleMotif(): Promise<void> {
|
||||||
if (!this.currentMIDI) return;
|
if (!this.currentMIDI) return;
|
||||||
|
|
||||||
const result = this.searchResults[this.selectedResultIndex];
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Stop preview if it's playing
|
||||||
|
this.currentPlayer.stop();
|
||||||
|
this.previewBtn.disabled = false;
|
||||||
|
this.previewStopBtn.disabled = true;
|
||||||
|
|
||||||
this.updateStatus('Generating Motif synthesis...');
|
this.updateStatus('Generating Motif synthesis...');
|
||||||
this.motifBtn.disabled = true;
|
this.motifBtn.disabled = true;
|
||||||
|
|
||||||
// Use the current MIDI data directly
|
// Use the current MIDI data directly
|
||||||
await this.motifEngine.generateFromMIDI(this.currentMIDI.events);
|
await this.motifEngine.generateFromMIDI(this.currentMIDI.events);
|
||||||
await this.motifEngine.play();
|
await this.motifEngine.play();
|
||||||
|
|
||||||
this.motifStopBtn.disabled = false;
|
this.motifStopBtn.disabled = false;
|
||||||
this.updateStatus('Playing Motif synthesis...');
|
this.updateStatus('Playing Motif synthesis...');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ export class MIDIParser {
|
|||||||
duration: note.duration,
|
duration: note.duration,
|
||||||
pitch: note.midi,
|
pitch: note.midi,
|
||||||
velocity: note.velocity,
|
velocity: note.velocity,
|
||||||
track: trackIndex
|
track: trackIndex,
|
||||||
|
channel: track.channel // Capture MIDI channel (9 = drums)
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -92,5 +92,4 @@ export class MIDIService {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -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 roleAssignments: Map<Role, RoleAssignment> = new Map();
|
||||||
private isPlaying = false;
|
private isPlaying = false;
|
||||||
private schedulerIntervalId: number | null = null;
|
private schedulerIntervalId: number | null = null;
|
||||||
private currentTime = 0;
|
|
||||||
private startTime = 0;
|
private startTime = 0;
|
||||||
private nextEventIndex = new Map<Role, number>();
|
private nextEventIndex = new Map<Role, number>();
|
||||||
|
|
||||||
@@ -40,7 +39,6 @@ export class SynthesisEngine {
|
|||||||
|
|
||||||
this.isPlaying = true;
|
this.isPlaying = true;
|
||||||
this.startTime = this.audioContext.currentTime;
|
this.startTime = this.audioContext.currentTime;
|
||||||
this.currentTime = 0;
|
|
||||||
|
|
||||||
// Reset event indices
|
// Reset event indices
|
||||||
for (const role of this.roleAssignments.keys()) {
|
for (const role of this.roleAssignments.keys()) {
|
||||||
@@ -57,18 +55,22 @@ export class SynthesisEngine {
|
|||||||
|
|
||||||
stop(): void {
|
stop(): void {
|
||||||
if (!this.isPlaying) return;
|
if (!this.isPlaying) return;
|
||||||
|
|
||||||
this.isPlaying = false;
|
this.isPlaying = false;
|
||||||
|
|
||||||
if (this.schedulerIntervalId) {
|
if (this.schedulerIntervalId) {
|
||||||
clearInterval(this.schedulerIntervalId);
|
clearInterval(this.schedulerIntervalId);
|
||||||
this.schedulerIntervalId = null;
|
this.schedulerIntervalId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fade out all layers
|
// Fade out all layers
|
||||||
this.fadeOutAllLayers();
|
this.fadeOutAllLayers();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setVolume(volume: number): void {
|
||||||
|
this.masterGain.gain.value = Math.max(0, Math.min(1, volume));
|
||||||
|
}
|
||||||
|
|
||||||
private createSynthLayer(role: Role): SynthLayer {
|
private createSynthLayer(role: Role): SynthLayer {
|
||||||
const gainNode = this.audioContext.createGain();
|
const gainNode = this.audioContext.createGain();
|
||||||
const filterNode = this.audioContext.createBiquadFilter();
|
const filterNode = this.audioContext.createBiquadFilter();
|
||||||
@@ -194,8 +196,6 @@ export class SynthesisEngine {
|
|||||||
|
|
||||||
if (!events.length && !chords.length) return;
|
if (!events.length && !chords.length) return;
|
||||||
|
|
||||||
let eventIndex = this.nextEventIndex.get(role) || 0;
|
|
||||||
|
|
||||||
// For roles that support polyphony (drone, texture), prefer chords
|
// For roles that support polyphony (drone, texture), prefer chords
|
||||||
if ((role === 'drone' || role === 'texture') && chords.length > 0) {
|
if ((role === 'drone' || role === 'texture') && chords.length > 0) {
|
||||||
this.scheduleChordEvents(role, chords, scheduleUntil);
|
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