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
+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",
+6 -5
View File
@@ -10,16 +10,17 @@
"start": "node dist/server.js"
},
"dependencies": {
"express": "^4.18.2",
"cors": "^2.8.5",
"cheerio": "^1.0.0-rc.12",
"crypto": "^1.0.1"
"cors": "^2.8.5",
"crypto": "^1.0.1",
"express": "^4.18.2",
"midi-writer-js": "^3.1.1"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/node": "^20.0.0",
"tsx": "^4.0.0",
"typescript": "^5.0.0"
}
}
}
+137 -30
View File
@@ -29,31 +29,108 @@ export class BitMidiAdapter implements SearchAdapter {
private parseSearchResults(html: string, query: string): MIDICandidate[] {
const candidates: MIDICandidate[] = [];
// Simple regex-based parsing for MVP (would use cheerio in production)
const linkRegex = /<a[^>]*href="([^"]*)"[^>]*>([^<]*)</gi;
let match;
while ((match = linkRegex.exec(html)) !== null && candidates.length < 10) {
const [, href, title] = match;
// 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);
if (midiUrl && title.trim()) {
const confidence = ScoreUtils.calculateConfidence(title, query, this.name);
candidates.push({
id: `bitmidi_${Buffer.from(fullUrl).toString('base64').slice(0, 16)}`,
title: title.trim(),
source: 'bitmidi',
pageUrl: fullUrl,
midiUrl: midiUrl,
confidence
});
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 {
return [];
}
}
}
}
let jsonStart = initStoreStart;
// If initStoreStart is not already at a brace, find the next one
if (html[initStoreStart] !== '{') {
jsonStart = html.indexOf('{', initStoreStart);
if (jsonStart === -1) {
console.log('BitMidi: Could not find JSON start');
return [];
}
}
// Find the matching closing brace
let braceCount = 0;
let jsonEnd = -1;
for (let i = jsonStart; i < html.length; i++) {
if (html[i] === '{') braceCount++;
else if (html[i] === '}') {
braceCount--;
if (braceCount === 0) {
jsonEnd = i + 1;
break;
}
}
}
if (jsonEnd === -1) {
console.log('BitMidi: Could not find JSON end');
return [];
}
const jsonString = html.slice(jsonStart, jsonEnd);
const jsonData = JSON.parse(jsonString);
// Extract search results from the JSON structure
if (jsonData.data && jsonData.data.midis) {
const midisData = jsonData.data.midis;
const midiKeys = Object.keys(midisData);
for (let i = 0; i < Math.min(midiKeys.length, 10); i++) {
const midiKey = midiKeys[i];
const midiData = midisData[midiKey];
if (midiData && midiData.name) {
const title = midiData.name;
const slug = midiData.slug;
const downloadUrl = midiData.downloadUrl || `/uploads/${midiData.id}.mid`;
const pageUrl = `${this.baseUrl}/${slug}`;
const fullDownloadUrl = downloadUrl.startsWith('http')
? downloadUrl
: `${this.baseUrl}${downloadUrl}`;
const confidence = ScoreUtils.calculateConfidence(title, query, this.name);
candidates.push({
id: `bitmidi_${midiData.id}`,
title: title.trim(),
source: 'bitmidi',
pageUrl: pageUrl,
midiUrl: fullDownloadUrl,
confidence
});
}
}
}
} catch (error) {
console.error('BitMidi: Error parsing JSON data:', error);
return [];
}
return candidates
@@ -62,14 +139,44 @@ export class BitMidiAdapter implements SearchAdapter {
.slice(0, 5); // Top 5 results
}
private extractMidiUrl(pageUrl: string): string {
// For BitMidi, the MIDI download is typically at the same path with .mid extension
// or through a download endpoint
if (pageUrl.includes('/midi/')) {
// Try direct .mid file first
const basePath = pageUrl.replace(/\/$/, '');
return `${basePath}.mid`;
async getMidiDownloadUrl(pageUrl: string): Promise<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"]