Improve MIDI search and add browser playback engines.

This commit is contained in:
b1rdmania
2025-12-18 14:25:38 +00:00
parent 4fdc6984bc
commit 2c9d06d8d7
30 changed files with 2080 additions and 93 deletions
+4
View File
@@ -33,3 +33,7 @@ logs/
# Runtime
.cache/
.tmp/
.vercel
# Backend runtime cache (downloaded MIDI files)
server/cache/
+51 -2
View File
@@ -132,6 +132,39 @@
color: #ffaa44;
font-size: 0.9em;
}
.volume-control {
margin-top: 10px;
display: flex;
align-items: center;
gap: 10px;
}
.volume-control label {
font-size: 0.9em;
color: #aaa;
}
.volume-control input[type="range"] {
flex: 1;
margin: 0;
}
.engine-selector {
margin-top: 10px;
font-size: 0.9em;
}
.engine-selector label {
color: #aaa;
display: block;
margin-bottom: 5px;
}
.engine-selector select {
background: #333;
color: #e0e0e0;
border: 1px solid #555;
padding: 5px 10px;
font-family: inherit;
width: 100%;
}
</style>
</head>
<body>
@@ -175,15 +208,31 @@
<div>
<h4>MIDI Preview</h4>
<p>Plays original MIDI as-is</p>
<button id="previewBtn" disabled>Play Original</button>
<div class="engine-selector">
<label for="engineSelect">Playback Engine:</label>
<select id="engineSelect">
<option value="tonejs">Tone.js (Sampled Piano)</option>
<option value="soundfont">Soundfont (Piano CDN)</option>
<option value="custom">Custom Synthesis</option>
</select>
</div>
<button id="previewBtn" disabled onclick="handlePreview()">Play Original</button>
<button id="previewStopBtn" disabled>Stop</button>
<div class="volume-control">
<label for="previewVolume">Volume:</label>
<input type="range" id="previewVolume" min="0" max="1" step="0.01" value="0.6" />
</div>
</div>
<div>
<h4>Motif Generation</h4>
<p>Procedural synthesis from structure</p>
<button id="motifBtn" disabled>Generate & Play</button>
<button id="motifBtn" disabled onclick="handleMotif()">Generate & Play</button>
<button id="motifStopBtn" disabled>Stop</button>
<div class="volume-control">
<label for="motifVolume">Volume:</label>
<input type="range" id="motifVolume" min="0" max="1" step="0.01" value="0.3" />
</div>
</div>
</div>
+104 -1
View File
@@ -9,7 +9,9 @@
"version": "0.1.0",
"license": "MIT",
"dependencies": {
"@tonejs/midi": "^2.0.28"
"@tonejs/midi": "^2.0.28",
"soundfont-player": "^0.12.0",
"tone": "^15.1.22"
},
"devDependencies": {
"@types/node": "^20.0.0",
@@ -20,6 +22,15 @@
"vite": "^5.0.0"
}
},
"node_modules/@babel/runtime": {
"version": "7.28.4",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
"integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
@@ -1175,6 +1186,12 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/adsr": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/adsr/-/adsr-1.0.1.tgz",
"integrity": "sha512-thr9LK4jxApOzBA33IWOA83bXJFbyfbeozpHXyrMQOIhUni198uRxXqDhobW0S/51iokqty2Yz2WbLZbE6tntQ==",
"license": "ISC"
},
"node_modules/ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
@@ -1241,6 +1258,25 @@
"node": ">=8"
}
},
"node_modules/audio-loader": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/audio-loader/-/audio-loader-0.5.0.tgz",
"integrity": "sha512-mEoYRjZhqkBSen/X9i2PNosqvafEsur8bI5MNoPr0wsJu9Nzlul3Yv1elYeMPsXxTxYhXLY8AZlScBvaK4mydg==",
"license": "MIT"
},
"node_modules/automation-events": {
"version": "7.1.13",
"resolved": "https://registry.npmjs.org/automation-events/-/automation-events-7.1.13.tgz",
"integrity": "sha512-1Hay5TQPzxsskSqPTH3YXyzE9Iirz82zZDse2vr3+kOR7Sc7om17qIEPsESchlNX0EgKxANwR40i2g/O3GM1Tw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.4",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=18.2.0"
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -2096,6 +2132,12 @@
"integrity": "sha512-B5SnBC6i2bwJIXTY9MElIydJwAmnKx+r5eJ1jknTLetzLflEl0GWveuBB6ACrQpecSRkOB6fhTx1PwXk2BVxnA==",
"license": "MIT"
},
"node_modules/midimessage": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/midimessage/-/midimessage-1.0.5.tgz",
"integrity": "sha512-MPJ2tDupFOfZB5/PLp8fri1IS4fd9hPj0Bio//FBhWRQ+TsJA7/49CF1aJyraDxa0Jq8zMHAwrwXl2GINvLvgw==",
"license": "MIT"
},
"node_modules/minimatch": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
@@ -2145,6 +2187,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/note-parser": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/note-parser/-/note-parser-2.0.1.tgz",
"integrity": "sha512-w9o6Fv46y3NsFxeezTZSmftBtUM/ypme6iZWVrTJvvsD5RN+w0XNDePWtfreNrZFL3jSjBFhadPoXb+pJO4UdA==",
"license": "MIT"
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
@@ -2452,6 +2500,23 @@
"queue-microtask": "^1.2.2"
}
},
"node_modules/sample-player": {
"version": "0.5.5",
"resolved": "https://registry.npmjs.org/sample-player/-/sample-player-0.5.5.tgz",
"integrity": "sha512-VQ9pXPJ1m/eTH8QK6OQ8Dn/HSVToNyY9w9vnv+y/yjkJeRm87tJ/gBEm66jItfSLhKe6VG1DfX8+oT+Mg7QUpg==",
"license": "MIT",
"dependencies": {
"adsr": "^1.0.0",
"midimessage": "^1.0.5",
"note-parser": "^1.1.0"
}
},
"node_modules/sample-player/node_modules/note-parser": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/note-parser/-/note-parser-1.1.0.tgz",
"integrity": "sha512-YTqWQBsRp40EFrEznnkGtmx68gcgOQ8CdoBspqGBA3G1/4mJwIYbDe/vuNpX3oGX2DhP7b1dBgTmj7p3Zr0P1Q==",
"license": "MIT"
},
"node_modules/semver": {
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
@@ -2498,6 +2563,17 @@
"node": ">=8"
}
},
"node_modules/soundfont-player": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/soundfont-player/-/soundfont-player-0.12.0.tgz",
"integrity": "sha512-8BJIsAt7h1PK3thSZDgF6zecgGhYkK74JnZO8WRZi3h34qG6H/DYlnv7cpRvL7Q9C8N6qld4Qwj7nJsX1gYjEA==",
"license": "MIT",
"dependencies": {
"audio-loader": "^0.5.0",
"note-parser": "^2.0.0",
"sample-player": "^0.5.5"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -2508,6 +2584,17 @@
"node": ">=0.10.0"
}
},
"node_modules/standardized-audio-context": {
"version": "25.3.77",
"resolved": "https://registry.npmjs.org/standardized-audio-context/-/standardized-audio-context-25.3.77.tgz",
"integrity": "sha512-Ki9zNz6pKcC5Pi+QPjPyVsD9GwJIJWgryji0XL9cAJXMGyn+dPOf6Qik1AHei0+UNVcc4BOCa0hWLBzlwqsW/A==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.25.6",
"automation-events": "^7.0.9",
"tslib": "^2.7.0"
}
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
@@ -2567,6 +2654,16 @@
"node": ">=8.0"
}
},
"node_modules/tone": {
"version": "15.1.22",
"resolved": "https://registry.npmjs.org/tone/-/tone-15.1.22.tgz",
"integrity": "sha512-TCScAGD4sLsama5DjvTUXlLDXSqPealhL64nsdV1hhr6frPWve0DeSo63AKnSJwgfg55fhvxj0iPPRwPN5o0ag==",
"license": "MIT",
"dependencies": {
"standardized-audio-context": "^25.3.70",
"tslib": "^2.3.1"
}
},
"node_modules/ts-api-utils": {
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz",
@@ -2580,6 +2677,12 @@
"typescript": ">=4.2.0"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
+3 -1
View File
@@ -22,7 +22,9 @@
"vite": "^5.0.0"
},
"dependencies": {
"@tonejs/midi": "^2.0.28"
"@tonejs/midi": "^2.0.28",
"soundfont-player": "^0.12.0",
"tone": "^15.1.22"
},
"keywords": [
"web-audio",
+36 -1
View File
@@ -11,7 +11,8 @@
"cheerio": "^1.0.0-rc.12",
"cors": "^2.8.5",
"crypto": "^1.0.1",
"express": "^4.18.2"
"express": "^4.18.2",
"midi-writer-js": "^3.1.1"
},
"devDependencies": {
"@types/cors": "^2.8.17",
@@ -463,6 +464,30 @@
"node": ">=18"
}
},
"node_modules/@tonaljs/midi": {
"version": "4.10.2",
"resolved": "https://registry.npmjs.org/@tonaljs/midi/-/midi-4.10.2.tgz",
"integrity": "sha512-MPamXhEwPL7L1udLfYMm3Ft8mLYtHr62Zi2w5zYHM2P7YwIvNoiX0+dvAN5is1Wvq4iVRa8AjFHerjOW/SZhGg==",
"license": "MIT",
"dependencies": {
"@tonaljs/pitch-note": "6.1.0"
}
},
"node_modules/@tonaljs/pitch": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/@tonaljs/pitch/-/pitch-5.0.2.tgz",
"integrity": "sha512-mxaXJPPe+LIJdjzpZEl8I8Wx3dEvlzkBbsr2Ltwc2dTAdnErAZ5R0TxVq2egF27lMvQN2QPQPWI9iDPPdVUmrg==",
"license": "MIT"
},
"node_modules/@tonaljs/pitch-note": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/@tonaljs/pitch-note/-/pitch-note-6.1.0.tgz",
"integrity": "sha512-A4OSLo8DjM38u73862LnDmL4YInDDRBmg0fojXcvu4cyU3oOlqndyeHOra1OVoH/WW46uNIxNs1wJDZNPWL5KQ==",
"license": "MIT",
"dependencies": {
"@tonaljs/pitch": "5.0.2"
}
},
"node_modules/@types/body-parser": {
"version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
@@ -1346,6 +1371,16 @@
"node": ">= 0.6"
}
},
"node_modules/midi-writer-js": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/midi-writer-js/-/midi-writer-js-3.1.1.tgz",
"integrity": "sha512-ruRzUWtmcvD7xQcrKRk9fH+1BELv8x07QJxhpM4PS5n6+MOyPb39ifxTV/oI1hHPgKMSuhnhWw2MaGWUAvH9DA==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"@tonaljs/midi": "^4.9.0"
}
},
"node_modules/mime": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
+5 -4
View File
@@ -10,14 +10,15 @@
"start": "node dist/server.js"
},
"dependencies": {
"express": "^4.18.2",
"cors": "^2.8.5",
"cheerio": "^1.0.0-rc.12",
"crypto": "^1.0.1"
"cors": "^2.8.5",
"crypto": "^1.0.1",
"express": "^4.18.2",
"midi-writer-js": "^3.1.1"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/node": "^20.0.0",
"tsx": "^4.0.0",
"typescript": "^5.0.0"
+127 -20
View File
@@ -29,47 +29,154 @@ export class BitMidiAdapter implements SearchAdapter {
private parseSearchResults(html: string, query: string): MIDICandidate[] {
const candidates: MIDICandidate[] = [];
// Simple regex-based parsing for MVP (would use cheerio in production)
const linkRegex = /<a[^>]*href="([^"]*)"[^>]*>([^<]*)</gi;
let match;
try {
// Look for different possible variable names containing search data
let initStoreStart = html.indexOf('window.initStore = ');
if (initStoreStart === -1) {
initStoreStart = html.indexOf('window.__INITIAL_STATE__ = ');
if (initStoreStart === -1) {
initStoreStart = html.indexOf('window.INITIAL_PROPS = ');
if (initStoreStart === -1) {
// Look for any assignment that contains the data structure we need
// From the context, we know the pattern is: {"data":{"midis":{...
const dataStartPattern = '"data":{"midis":';
const dataPatternIndex = html.indexOf(dataStartPattern);
if (dataPatternIndex !== -1) {
// Go backwards to find the start of the containing object
let bracketStart = dataPatternIndex;
let bracketDepth = 0;
while (bracketStart > 0) {
bracketStart--;
if (html[bracketStart] === '}') bracketDepth++;
if (html[bracketStart] === '{') {
if (bracketDepth === 0) break;
bracketDepth--;
}
}
initStoreStart = bracketStart;
} else {
while ((match = linkRegex.exec(html)) !== null && candidates.length < 10) {
const [, href, title] = match;
return [];
}
}
}
}
// Look for MIDI file links
if (href.includes('/midi/') && !href.includes('.mp3') && !href.includes('.wav')) {
const fullUrl = href.startsWith('http') ? href : `${this.baseUrl}${href}`;
const midiUrl = this.extractMidiUrl(fullUrl);
let jsonStart = initStoreStart;
// If initStoreStart is not already at a brace, find the next one
if (html[initStoreStart] !== '{') {
jsonStart = html.indexOf('{', initStoreStart);
if (jsonStart === -1) {
console.log('BitMidi: Could not find JSON start');
return [];
}
}
// Find the matching closing brace
let braceCount = 0;
let jsonEnd = -1;
for (let i = jsonStart; i < html.length; i++) {
if (html[i] === '{') braceCount++;
else if (html[i] === '}') {
braceCount--;
if (braceCount === 0) {
jsonEnd = i + 1;
break;
}
}
}
if (jsonEnd === -1) {
console.log('BitMidi: Could not find JSON end');
return [];
}
const jsonString = html.slice(jsonStart, jsonEnd);
const jsonData = JSON.parse(jsonString);
// Extract search results from the JSON structure
if (jsonData.data && jsonData.data.midis) {
const midisData = jsonData.data.midis;
const midiKeys = Object.keys(midisData);
for (let i = 0; i < Math.min(midiKeys.length, 10); i++) {
const midiKey = midiKeys[i];
const midiData = midisData[midiKey];
if (midiData && midiData.name) {
const title = midiData.name;
const slug = midiData.slug;
const downloadUrl = midiData.downloadUrl || `/uploads/${midiData.id}.mid`;
const pageUrl = `${this.baseUrl}/${slug}`;
const fullDownloadUrl = downloadUrl.startsWith('http')
? downloadUrl
: `${this.baseUrl}${downloadUrl}`;
if (midiUrl && title.trim()) {
const confidence = ScoreUtils.calculateConfidence(title, query, this.name);
candidates.push({
id: `bitmidi_${Buffer.from(fullUrl).toString('base64').slice(0, 16)}`,
id: `bitmidi_${midiData.id}`,
title: title.trim(),
source: 'bitmidi',
pageUrl: fullUrl,
midiUrl: midiUrl,
pageUrl: pageUrl,
midiUrl: fullDownloadUrl,
confidence
});
}
}
}
} catch (error) {
console.error('BitMidi: Error parsing JSON data:', error);
return [];
}
return candidates
.filter(c => c.confidence > 0.3) // Filter low confidence matches
.sort((a, b) => b.confidence - a.confidence)
.slice(0, 5); // Top 5 results
}
private extractMidiUrl(pageUrl: string): string {
// For BitMidi, the MIDI download is typically at the same path with .mid extension
// or through a download endpoint
if (pageUrl.includes('/midi/')) {
// Try direct .mid file first
const basePath = pageUrl.replace(/\/$/, '');
return `${basePath}.mid`;
async getMidiDownloadUrl(pageUrl: string): Promise<string> {
try {
const response = await fetch(pageUrl, {
headers: {
'User-Agent': 'Mozilla/5.0 (compatible; MotifBot/1.0)'
}
});
if (!response.ok) {
throw new Error(`Failed to fetch MIDI page: ${response.status}`);
}
const html = await response.text();
// Extract the actual download URL from the page
// Pattern: href="/uploads/12345.mid" or similar
const downloadMatch = html.match(/href="(\/uploads\/[^"]*\.mid)"/);
if (downloadMatch) {
return `${this.baseUrl}${downloadMatch[1]}`;
}
// Fallback: look for any .mid download link
const midiMatch = html.match(/href="([^"]*\.mid)"/);
if (midiMatch) {
const url = midiMatch[1];
return url.startsWith('http') ? url : `${this.baseUrl}${url}`;
}
throw new Error('Could not find MIDI download URL');
} catch (error) {
console.error('Error extracting MIDI URL:', error);
return pageUrl; // Fallback to page URL
}
}
private extractMidiUrl(pageUrl: string): string {
// This method is deprecated in favor of getMidiDownloadUrl
// but keeping for backward compatibility
return pageUrl;
}
}
+38
View File
@@ -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);
}
}
+2 -2
View File
@@ -44,8 +44,8 @@ app.get('/api/midi/fetch', async (req, res) => {
if (result.success) {
res.setHeader('Content-Type', 'audio/midi');
res.setHeader('Content-Length', result.data!.length);
res.send(result.data);
res.setHeader('Content-Length', result.data!.byteLength);
res.send(Buffer.from(result.data!));
} else {
res.status(404).json({ error: result.error });
}
+14
View File
@@ -2,6 +2,7 @@ import fs from 'fs/promises';
import path from 'path';
import crypto from 'crypto';
import { ScoreUtils } from '../utils/ScoreUtils.js';
import { SimpleMIDI } from '../utils/SimpleMIDI.js';
import type { CacheEntry } from '../types.js';
export class MIDIFetchService {
@@ -14,6 +15,19 @@ export class MIDIFetchService {
async fetch(url: string): Promise<{ success: boolean; data?: ArrayBuffer; error?: string }> {
try {
// Only generate synthetic MIDI for explicit synthetic URLs or when enabled
if (url.startsWith('synthetic:') || url.includes('mock') ||
(process.env.USE_SYNTHETIC_FETCH === '1')) {
console.log(`Generating synthetic MIDI for: ${url}`);
const songName = url.startsWith('synthetic:')
? url.replace('synthetic:', '')
: url.split('/').pop()?.replace('.mid', '') || 'test';
const syntheticBuffer = SimpleMIDI.generateValidMIDI(songName);
console.log(`Generated ${syntheticBuffer.byteLength} bytes of synthetic MIDI data`);
return { success: true, data: syntheticBuffer };
}
// Check cache first
const hash = this.hashUrl(url);
const cached = await this.getCached(hash);
+3
View File
@@ -1,15 +1,18 @@
import type { SearchAdapter, MIDICandidate } from '../types.js';
import { BitMidiAdapter } from '../adapters/BitMidiAdapter.js';
import { DongraysAdapter } from '../adapters/DongraysAdapter.js';
import { MockAdapter } from '../adapters/MockAdapter.js';
export class MIDISearchService {
private adapters: SearchAdapter[];
constructor() {
// Real MIDI adapters only - no mock
this.adapters = [
new BitMidiAdapter(),
new DongraysAdapter()
];
console.log('MIDISearchService initialized with real adapters:', this.adapters.map(a => a.name));
}
async search(query: string): Promise<MIDICandidate[]> {
+1 -1
View File
@@ -1,7 +1,7 @@
export interface MIDICandidate {
id: string;
title: string;
source: 'bitmidi' | 'dongrays';
source: 'bitmidi' | 'dongrays' | 'mock';
pageUrl: string;
midiUrl: string;
confidence: number;
+141
View File
@@ -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);
}
}
+72
View File
@@ -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);
}
}
+110
View File
@@ -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);
}
}
+4 -3
View File
@@ -2,14 +2,15 @@
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"strict": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": "src"
"rootDir": "src",
"esModuleInterop": true,
"allowSyntheticDefaultImports": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
+7 -1
View File
@@ -1,4 +1,4 @@
import type { NoteEvent, StructuralFeatures, MotifConfig, SynthLayer } from '../types';
import type { NoteEvent, StructuralFeatures, MotifConfig } from '../types';
import { MIDIProcessor } from '../midi/MIDIProcessor';
import { MIDIParser } from '../midi/MIDIParser';
import { MIDIService } from '../services/MIDIService';
@@ -106,6 +106,12 @@ export class MotifEngine {
}
}
setVolume(volume: number): void {
if (this.synthesisEngine) {
this.synthesisEngine.setVolume(volume);
}
}
private generateSyntheticMIDI(songName: string): NoteEvent[] {
// Generate procedural MIDI based on song name hash
const hash = this.simpleHash(songName);
+2 -2
View File
@@ -76,7 +76,7 @@ export class RoleMapper {
};
}
private calculateRoleScores(features: TrackFeatures, globalFeatures: StructuralFeatures): Map<Role, number> {
private calculateRoleScores(features: TrackFeatures, _globalFeatures: StructuralFeatures): Map<Role, number> {
const scores = new Map<Role, number>();
// Bass scoring
@@ -130,7 +130,7 @@ export class RoleMapper {
return scores;
}
private allocateRoles(roleScores: Map<number, Map<Role, number>>, trackEvents: Map<number, NoteEvent[]>): Map<number, Role> {
private allocateRoles(roleScores: Map<number, Map<Role, number>>, _trackEvents: Map<number, NoteEvent[]>): Map<number, Role> {
const assignments = new Map<number, Role>();
const assignedRoles = new Set<Role>();
+42
View File
@@ -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');
});
+189
View File
@@ -0,0 +1,189 @@
console.log('Full Motif app loading...');
interface SearchResult {
id: string;
title: string;
source: string;
pageUrl: string;
midiUrl: string;
confidence: number;
}
class MotifApp {
private searchResults: SearchResult[] = [];
private selectedIndex = 0;
constructor() {
document.addEventListener('DOMContentLoaded', () => {
this.initializeUI();
});
}
private initializeUI(): void {
console.log('Initializing UI...');
const searchBtn = document.getElementById('searchBtn') as HTMLButtonElement;
const songInput = document.getElementById('songInput') as HTMLInputElement;
if (!searchBtn || !songInput) {
console.error('UI elements not found!');
return;
}
searchBtn.addEventListener('click', () => this.handleSearch());
songInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') this.handleSearch();
});
// Make selectResult globally available for onclick handlers
(window as any).app = this;
console.log('UI initialized successfully');
}
private async handleSearch(): Promise<void> {
const songInput = document.getElementById('songInput') as HTMLInputElement;
const searchBtn = document.getElementById('searchBtn') as HTMLButtonElement;
const status = document.getElementById('status')!;
const songName = songInput.value.trim();
if (!songName) return;
status.textContent = 'Searching for MIDI files...';
searchBtn.disabled = true;
try {
const response = await fetch(`http://localhost:3001/api/midi/search?q=${encodeURIComponent(songName)}`);
const data = await response.json();
console.log('Search results:', data);
if (data.results.length === 0) {
status.textContent = 'No MIDI files found. Try a different search.';
return;
}
this.searchResults = data.results;
this.displayResults();
status.textContent = `Found ${data.results.length} MIDI files. Select one to play.`;
} catch (error) {
console.error('Search error:', error);
status.textContent = `Search error: ${error}`;
} finally {
searchBtn.disabled = false;
}
}
private displayResults(): void {
const resultsSection = document.getElementById('resultsSection')!;
const resultsBody = document.getElementById('resultsBody')!;
resultsBody.innerHTML = '';
this.searchResults.forEach((result, index) => {
const row = document.createElement('tr');
if (index === this.selectedIndex) {
row.classList.add('selected');
}
row.innerHTML = `
<td>${result.title}</td>
<td>${result.source}</td>
<td>
<div class="confidence-bar">
<div class="confidence-fill" style="width: ${result.confidence * 100}%"></div>
</div>
</td>
<td>?</td>
<td>?</td>
<td></td>
<td><button onclick="window.app.selectResult(${index})">Select</button></td>
`;
resultsBody.appendChild(row);
});
resultsSection.classList.add('visible');
// Auto-select first result
if (this.searchResults.length > 0) {
this.selectResult(0);
}
}
public async selectResult(index: number): Promise<void> {
if (index < 0 || index >= this.searchResults.length) return;
this.selectedIndex = index;
const result = this.searchResults[index];
// Update selection highlighting
const rows = document.querySelectorAll('#resultsBody tr');
rows.forEach((row, i) => {
row.classList.toggle('selected', i === index);
});
const status = document.getElementById('status')!;
const playerSection = document.getElementById('playerSection')!;
const selectedTitle = document.getElementById('selectedTitle')!;
const selectedMeta = document.getElementById('selectedMeta')!;
status.textContent = 'Loading MIDI file...';
try {
// Fetch MIDI data
const response = await fetch(`http://localhost:3001/api/midi/fetch?u=${encodeURIComponent(result.midiUrl)}`);
if (!response.ok) {
throw new Error(`Failed to fetch MIDI: ${response.status}`);
}
const midiBuffer = await response.arrayBuffer();
// Update UI
selectedTitle.textContent = result.title;
selectedMeta.innerHTML = `
<strong>Source:</strong> ${result.source} |
<strong>Size:</strong> ${(midiBuffer.byteLength / 1024).toFixed(1)}KB |
<strong>Confidence:</strong> ${Math.round(result.confidence * 100)}%
`;
playerSection.classList.add('visible');
// Enable preview button
const previewBtn = document.getElementById('previewBtn') as HTMLButtonElement;
const motifBtn = document.getElementById('motifBtn') as HTMLButtonElement;
previewBtn.disabled = false;
motifBtn.disabled = false;
// Store MIDI data for playback
(this as any).currentMIDI = { buffer: midiBuffer, result };
status.textContent = 'MIDI loaded. You can now preview or generate synthesis.';
} catch (error) {
console.error('Load error:', error);
status.textContent = `Load error: ${error}`;
}
}
public async handlePreview(): Promise<void> {
console.log('Preview clicked - would play original MIDI here');
const status = document.getElementById('status')!;
status.textContent = 'Preview playback not yet implemented - but MIDI is loaded!';
}
public async handleMotif(): Promise<void> {
console.log('Motif clicked - would generate synthesis here');
const status = document.getElementById('status')!;
status.textContent = 'Motif synthesis not yet implemented - but MIDI is parsed!';
}
}
// Initialize app
new MotifApp();
// Expose handlers for buttons
(window as any).handlePreview = () => (window as any).app.handlePreview();
(window as any).handleMotif = () => (window as any).app.handleMotif();
+124 -26
View File
@@ -1,31 +1,54 @@
import { MotifEngine } from './core/MotifEngine';
import { MIDIService } from './services/MIDIService';
import { MIDIParser } from './midi/MIDIParser';
import { MIDIPlayer } from './synthesis/MIDIPlayer';
import { EnhancedMIDIPlayer } from './synthesis/EnhancedMIDIPlayer';
import { SoundfontMIDIPlayer } from './synthesis/SoundfontMIDIPlayer';
import { ToneJSMIDIPlayer } from './synthesis/ToneJSMIDIPlayer';
import type { NoteEvent } from './types';
type MIDIPlayerType = 'tonejs' | 'soundfont' | 'custom';
interface MIDIPlayer {
load(events: NoteEvent[]): void | Promise<void>;
play(): void | Promise<void>;
stop(): void;
setVolume(volume: number): void;
getDuration(): number;
getProgress(): number;
}
class MotifApp {
private motifEngine: MotifEngine;
private midiService: MIDIService;
private midiPlayer: MIDIPlayer;
private audioContext: AudioContext;
private searchBtn: HTMLButtonElement;
private songInput: HTMLInputElement;
private status: HTMLElement;
// Multiple player instances
private toneJSPlayer: ToneJSMIDIPlayer;
private soundfontPlayer: SoundfontMIDIPlayer;
private customPlayer: EnhancedMIDIPlayer;
private currentPlayer: MIDIPlayer;
private currentPlayerType: MIDIPlayerType = 'tonejs';
private resultsSection: HTMLElement;
private resultsBody: HTMLElement;
private playerSection: HTMLElement;
private searchBtn!: HTMLButtonElement;
private songInput!: HTMLInputElement;
private status!: HTMLElement;
private selectedTitle: HTMLElement;
private selectedMeta: HTMLElement;
private resultsSection!: HTMLElement;
private resultsBody!: HTMLElement;
private playerSection!: HTMLElement;
private previewBtn: HTMLButtonElement;
private previewStopBtn: HTMLButtonElement;
private motifBtn: HTMLButtonElement;
private motifStopBtn: HTMLButtonElement;
private nextResultBtn: HTMLButtonElement;
private selectedTitle!: HTMLElement;
private selectedMeta!: HTMLElement;
private previewBtn!: HTMLButtonElement;
private previewStopBtn!: HTMLButtonElement;
private motifBtn!: HTMLButtonElement;
private motifStopBtn!: HTMLButtonElement;
private nextResultBtn!: HTMLButtonElement;
private previewVolumeSlider!: HTMLInputElement;
private motifVolumeSlider!: HTMLInputElement;
private engineSelect!: HTMLSelectElement;
private searchResults: any[] = [];
private selectedResultIndex = 0;
@@ -35,7 +58,12 @@ class MotifApp {
this.audioContext = new AudioContext();
this.motifEngine = new MotifEngine();
this.midiService = new MIDIService();
this.midiPlayer = new MIDIPlayer(this.audioContext);
// Initialize all players
this.toneJSPlayer = new ToneJSMIDIPlayer();
this.soundfontPlayer = new SoundfontMIDIPlayer(this.audioContext);
this.customPlayer = new EnhancedMIDIPlayer(this.audioContext);
this.currentPlayer = this.toneJSPlayer; // Default to Tone.js
this.initializeUI();
this.setupEventListeners();
@@ -58,6 +86,10 @@ class MotifApp {
this.motifBtn = document.getElementById('motifBtn') as HTMLButtonElement;
this.motifStopBtn = document.getElementById('motifStopBtn') as HTMLButtonElement;
this.nextResultBtn = document.getElementById('nextResultBtn') as HTMLButtonElement;
this.previewVolumeSlider = document.getElementById('previewVolume') as HTMLInputElement;
this.motifVolumeSlider = document.getElementById('motifVolume') as HTMLInputElement;
this.engineSelect = document.getElementById('engineSelect') as HTMLSelectElement;
}
private setupEventListeners(): void {
@@ -74,6 +106,22 @@ class MotifApp {
this.motifBtn.addEventListener('click', () => this.handleMotif());
this.motifStopBtn.addEventListener('click', () => this.handleMotifStop());
this.nextResultBtn.addEventListener('click', () => this.handleNextResult());
// Volume control event listeners
this.previewVolumeSlider.addEventListener('input', (e) => {
const volume = parseFloat((e.target as HTMLInputElement).value);
this.currentPlayer.setVolume(volume);
});
this.motifVolumeSlider.addEventListener('input', (e) => {
const volume = parseFloat((e.target as HTMLInputElement).value);
this.motifEngine.setVolume(volume);
});
// Engine selector event listener
this.engineSelect.addEventListener('change', (e) => {
this.handleEngineChange((e.target as HTMLSelectElement).value as MIDIPlayerType);
});
}
private async handleSearch(): Promise<void> {
@@ -148,7 +196,7 @@ class MotifApp {
}
}
async selectResult(index: number): Promise<void> {
public async selectResult(index: number): Promise<void> {
if (index < 0 || index >= this.searchResults.length) return;
this.selectedResultIndex = index;
@@ -173,16 +221,23 @@ class MotifApp {
const events = MIDIParser.parseMIDI(midiBuffer);
const metadata = result.parsed || MIDIParser.getMIDIInfo(midiBuffer);
this.currentMIDI = { events, metadata };
// Calculate duration from events if metadata duration is 0 or missing
let actualDuration = metadata.duration || metadata.durationSec || 0;
if (actualDuration === 0 && events.length > 0) {
// Calculate duration from the last event
actualDuration = Math.max(...events.map(e => e.time + e.duration));
}
// Load into players
this.midiPlayer.load(events);
this.currentMIDI = { events, metadata: { ...metadata, duration: actualDuration } };
// Load into current player
await this.currentPlayer.load(events);
// Update UI
this.selectedTitle.textContent = result.title;
this.selectedMeta.innerHTML = `
<strong>Source:</strong> ${result.source} |
<strong>Duration:</strong> ${Math.round(metadata.duration || 0)}s |
<strong>Duration:</strong> ${Math.round(actualDuration)}s |
<strong>Tracks:</strong> ${metadata.trackCount} |
<strong>Notes:</strong> ${events.length} |
<strong>Tempo:</strong> ${metadata.tempo}bpm
@@ -201,28 +256,71 @@ class MotifApp {
if (!this.currentMIDI) return;
try {
await this.midiPlayer.play();
// Stop Motif if it's playing
this.motifEngine.stop();
this.motifBtn.disabled = false;
this.motifStopBtn.disabled = true;
await this.currentPlayer.play();
this.previewBtn.disabled = true;
this.previewStopBtn.disabled = false;
this.updateStatus('Playing MIDI preview...');
this.updateStatus(`Playing original MIDI (${this.currentPlayerType})...`);
} catch (error) {
this.updateStatus(`Preview error: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
private handlePreviewStop(): void {
this.midiPlayer.stop();
this.currentPlayer.stop();
this.previewBtn.disabled = false;
this.previewStopBtn.disabled = true;
this.updateStatus('Preview stopped.');
}
private async handleEngineChange(engineType: MIDIPlayerType): Promise<void> {
// Stop current player
this.currentPlayer.stop();
// Switch to new player
this.currentPlayerType = engineType;
switch (engineType) {
case 'tonejs':
this.currentPlayer = this.toneJSPlayer;
break;
case 'soundfont':
this.currentPlayer = this.soundfontPlayer;
break;
case 'custom':
this.currentPlayer = this.customPlayer;
break;
}
// Reload MIDI into new player if we have one loaded
if (this.currentMIDI) {
try {
this.updateStatus(`Switching to ${engineType} engine...`);
await this.currentPlayer.load(this.currentMIDI.events);
// Apply current volume setting
const volume = parseFloat(this.previewVolumeSlider.value);
this.currentPlayer.setVolume(volume);
this.updateStatus(`Switched to ${engineType} engine. Ready to play.`);
} catch (error) {
this.updateStatus(`Engine switch error: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
}
private async handleMotif(): Promise<void> {
if (!this.currentMIDI) return;
const result = this.searchResults[this.selectedResultIndex];
try {
// Stop preview if it's playing
this.currentPlayer.stop();
this.previewBtn.disabled = false;
this.previewStopBtn.disabled = true;
this.updateStatus('Generating Motif synthesis...');
this.motifBtn.disabled = true;
+2 -1
View File
@@ -14,7 +14,8 @@ export class MIDIParser {
duration: note.duration,
pitch: note.midi,
velocity: note.velocity,
track: trackIndex
track: trackIndex,
channel: track.channel // Capture MIDI channel (9 = drums)
});
});
});
-1
View File
@@ -93,4 +93,3 @@ export class MIDIService {
}
}
}
}
+259
View File
@@ -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));
}
}
+215
View File
@@ -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));
}
}
+196
View File
@@ -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));
}
}
+4 -4
View File
@@ -8,7 +8,6 @@ export class SynthesisEngine {
private roleAssignments: Map<Role, RoleAssignment> = new Map();
private isPlaying = false;
private schedulerIntervalId: number | null = null;
private currentTime = 0;
private startTime = 0;
private nextEventIndex = new Map<Role, number>();
@@ -40,7 +39,6 @@ export class SynthesisEngine {
this.isPlaying = true;
this.startTime = this.audioContext.currentTime;
this.currentTime = 0;
// Reset event indices
for (const role of this.roleAssignments.keys()) {
@@ -69,6 +67,10 @@ export class SynthesisEngine {
this.fadeOutAllLayers();
}
setVolume(volume: number): void {
this.masterGain.gain.value = Math.max(0, Math.min(1, volume));
}
private createSynthLayer(role: Role): SynthLayer {
const gainNode = this.audioContext.createGain();
const filterNode = this.audioContext.createBiquadFilter();
@@ -194,8 +196,6 @@ export class SynthesisEngine {
if (!events.length && !chords.length) return;
let eventIndex = this.nextEventIndex.get(role) || 0;
// For roles that support polyphony (drone, texture), prefer chords
if ((role === 'drone' || role === 'texture') && chords.length > 0) {
this.scheduleChordEvents(role, chords, scheduleUntil);
+174
View File
@@ -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)));
}
}
}
+21
View File
@@ -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>
+107
View File
@@ -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.