Improve MIDI search and add browser playback engines.
This commit is contained in:
Generated
+36
-1
@@ -11,7 +11,8 @@
|
||||
"cheerio": "^1.0.0-rc.12",
|
||||
"cors": "^2.8.5",
|
||||
"crypto": "^1.0.1",
|
||||
"express": "^4.18.2"
|
||||
"express": "^4.18.2",
|
||||
"midi-writer-js": "^3.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.17",
|
||||
@@ -463,6 +464,30 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@tonaljs/midi": {
|
||||
"version": "4.10.2",
|
||||
"resolved": "https://registry.npmjs.org/@tonaljs/midi/-/midi-4.10.2.tgz",
|
||||
"integrity": "sha512-MPamXhEwPL7L1udLfYMm3Ft8mLYtHr62Zi2w5zYHM2P7YwIvNoiX0+dvAN5is1Wvq4iVRa8AjFHerjOW/SZhGg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tonaljs/pitch-note": "6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tonaljs/pitch": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@tonaljs/pitch/-/pitch-5.0.2.tgz",
|
||||
"integrity": "sha512-mxaXJPPe+LIJdjzpZEl8I8Wx3dEvlzkBbsr2Ltwc2dTAdnErAZ5R0TxVq2egF27lMvQN2QPQPWI9iDPPdVUmrg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tonaljs/pitch-note": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@tonaljs/pitch-note/-/pitch-note-6.1.0.tgz",
|
||||
"integrity": "sha512-A4OSLo8DjM38u73862LnDmL4YInDDRBmg0fojXcvu4cyU3oOlqndyeHOra1OVoH/WW46uNIxNs1wJDZNPWL5KQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tonaljs/pitch": "5.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/body-parser": {
|
||||
"version": "1.19.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
||||
@@ -1346,6 +1371,16 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/midi-writer-js": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/midi-writer-js/-/midi-writer-js-3.1.1.tgz",
|
||||
"integrity": "sha512-ruRzUWtmcvD7xQcrKRk9fH+1BELv8x07QJxhpM4PS5n6+MOyPb39ifxTV/oI1hHPgKMSuhnhWw2MaGWUAvH9DA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tonaljs/midi": "^4.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mime": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
|
||||
|
||||
+6
-5
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { SearchAdapter, MIDICandidate } from '../types.js';
|
||||
import { ScoreUtils } from '../utils/ScoreUtils.js';
|
||||
|
||||
export class MockAdapter implements SearchAdapter {
|
||||
name = 'mock';
|
||||
|
||||
async search(query: string): Promise<MIDICandidate[]> {
|
||||
// Generate mock MIDI search results for testing
|
||||
const mockSongs = [
|
||||
{ title: 'Bohemian Rhapsody - Queen', url: 'synthetic:bohemian-rhapsody-queen' },
|
||||
{ title: 'Hotel California - Eagles', url: 'synthetic:hotel-california-eagles' },
|
||||
{ title: 'Sweet Child O Mine - Guns N Roses', url: 'synthetic:sweet-child-o-mine-guns-n-roses' },
|
||||
{ title: 'Stairway to Heaven - Led Zeppelin', url: 'synthetic:stairway-to-heaven-led-zeppelin' },
|
||||
{ title: 'Yesterday - The Beatles', url: 'synthetic:yesterday-the-beatles' }
|
||||
];
|
||||
|
||||
const results: MIDICandidate[] = [];
|
||||
|
||||
for (const song of mockSongs) {
|
||||
const confidence = ScoreUtils.calculateConfidence(song.title, query, this.name);
|
||||
|
||||
if (confidence > 0.1) { // Include if there's any relevance
|
||||
results.push({
|
||||
id: `mock_${Buffer.from(song.url).toString('base64').slice(0, 16)}`,
|
||||
title: song.title,
|
||||
source: 'mock',
|
||||
pageUrl: song.url,
|
||||
midiUrl: song.url,
|
||||
confidence
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
.sort((a, b) => b.confidence - a.confidence)
|
||||
.slice(0, 5);
|
||||
}
|
||||
}
|
||||
@@ -44,8 +44,8 @@ app.get('/api/midi/fetch', async (req, res) => {
|
||||
|
||||
if (result.success) {
|
||||
res.setHeader('Content-Type', 'audio/midi');
|
||||
res.setHeader('Content-Length', result.data!.length);
|
||||
res.send(result.data);
|
||||
res.setHeader('Content-Length', result.data!.byteLength);
|
||||
res.send(Buffer.from(result.data!));
|
||||
} else {
|
||||
res.status(404).json({ error: result.error });
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import crypto from 'crypto';
|
||||
import { ScoreUtils } from '../utils/ScoreUtils.js';
|
||||
import { SimpleMIDI } from '../utils/SimpleMIDI.js';
|
||||
import type { CacheEntry } from '../types.js';
|
||||
|
||||
export class MIDIFetchService {
|
||||
@@ -14,6 +15,19 @@ export class MIDIFetchService {
|
||||
|
||||
async fetch(url: string): Promise<{ success: boolean; data?: ArrayBuffer; error?: string }> {
|
||||
try {
|
||||
// Only generate synthetic MIDI for explicit synthetic URLs or when enabled
|
||||
if (url.startsWith('synthetic:') || url.includes('mock') ||
|
||||
(process.env.USE_SYNTHETIC_FETCH === '1')) {
|
||||
console.log(`Generating synthetic MIDI for: ${url}`);
|
||||
const songName = url.startsWith('synthetic:')
|
||||
? url.replace('synthetic:', '')
|
||||
: url.split('/').pop()?.replace('.mid', '') || 'test';
|
||||
|
||||
const syntheticBuffer = SimpleMIDI.generateValidMIDI(songName);
|
||||
console.log(`Generated ${syntheticBuffer.byteLength} bytes of synthetic MIDI data`);
|
||||
return { success: true, data: syntheticBuffer };
|
||||
}
|
||||
|
||||
// Check cache first
|
||||
const hash = this.hashUrl(url);
|
||||
const cached = await this.getCached(hash);
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import type { SearchAdapter, MIDICandidate } from '../types.js';
|
||||
import { BitMidiAdapter } from '../adapters/BitMidiAdapter.js';
|
||||
import { DongraysAdapter } from '../adapters/DongraysAdapter.js';
|
||||
import { MockAdapter } from '../adapters/MockAdapter.js';
|
||||
|
||||
export class MIDISearchService {
|
||||
private adapters: SearchAdapter[];
|
||||
|
||||
constructor() {
|
||||
// Real MIDI adapters only - no mock
|
||||
this.adapters = [
|
||||
new BitMidiAdapter(),
|
||||
new DongraysAdapter()
|
||||
];
|
||||
console.log('MIDISearchService initialized with real adapters:', this.adapters.map(a => a.name));
|
||||
}
|
||||
|
||||
async search(query: string): Promise<MIDICandidate[]> {
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
export interface MIDICandidate {
|
||||
id: string;
|
||||
title: string;
|
||||
source: 'bitmidi' | 'dongrays';
|
||||
source: 'bitmidi' | 'dongrays' | 'mock';
|
||||
pageUrl: string;
|
||||
midiUrl: string;
|
||||
confidence: number;
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
// Using dynamic import to handle the CommonJS module properly
|
||||
export class ProperMIDI {
|
||||
static async generateMIDI(songName: string): Promise<ArrayBuffer> {
|
||||
console.log(`Generating synthetic MIDI for: ${songName}`);
|
||||
|
||||
try {
|
||||
const MidiWriter = await import('midi-writer-js');
|
||||
|
||||
// Create a hash-based seed for deterministic generation
|
||||
const hash = this.simpleHash(songName);
|
||||
const tempo = 120 + (hash % 40); // 120-160 BPM
|
||||
|
||||
// Create a new track - try different import patterns
|
||||
const track = new (MidiWriter as any).Track();
|
||||
|
||||
// Set tempo
|
||||
track.addEvent(new (MidiWriter as any).TempoEvent({ bpm: tempo }));
|
||||
|
||||
// Create simple melody
|
||||
const notes = this.generateSimpleNotes(hash);
|
||||
|
||||
// Add notes to track
|
||||
for (const note of notes) {
|
||||
track.addEvent(new (MidiWriter as any).NoteEvent({
|
||||
pitch: note.pitch,
|
||||
duration: note.duration,
|
||||
velocity: note.velocity,
|
||||
wait: note.wait
|
||||
}));
|
||||
}
|
||||
|
||||
// Create writer and generate MIDI file
|
||||
const writer = new (MidiWriter as any).Writer(track);
|
||||
const midiData = writer.buildFile();
|
||||
|
||||
// Convert to ArrayBuffer
|
||||
const uint8Array = new Uint8Array(midiData);
|
||||
return uint8Array.buffer;
|
||||
} catch (error) {
|
||||
console.error('MIDI generation error:', error);
|
||||
// Fallback to simple MIDI generation
|
||||
return this.generateFallbackMIDI(songName);
|
||||
}
|
||||
}
|
||||
|
||||
private static getScale(key: number): number[] {
|
||||
// Major scale intervals
|
||||
const intervals = [0, 2, 4, 5, 7, 9, 11];
|
||||
const baseNote = 60 + key; // Middle C + key offset
|
||||
return intervals.map(interval => baseNote + interval);
|
||||
}
|
||||
|
||||
private static generateSimpleNotes(hash: number): Array<{
|
||||
pitch: string,
|
||||
duration: string,
|
||||
velocity: number,
|
||||
wait: string
|
||||
}> {
|
||||
const notes = [];
|
||||
const durations = ['4', '8', '8', '4']; // Quarter, eighth, eighth, quarter
|
||||
const scale = [60, 62, 64, 65, 67, 69, 71]; // C major scale starting at C4
|
||||
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const noteIndex = (hash + i * 3) % scale.length;
|
||||
const pitch = scale[noteIndex];
|
||||
|
||||
notes.push({
|
||||
pitch: this.midiNumberToNote(pitch),
|
||||
duration: durations[i % durations.length],
|
||||
velocity: 70 + ((hash + i * 5) % 30), // 70-100 velocity
|
||||
wait: i === 0 ? '0' : '0' // No wait between notes for legato
|
||||
});
|
||||
}
|
||||
|
||||
return notes;
|
||||
}
|
||||
|
||||
private static generateFallbackMIDI(songName: string): ArrayBuffer {
|
||||
console.log('Using fallback MIDI generation for:', songName);
|
||||
|
||||
// Simple MIDI file structure
|
||||
const data: number[] = [];
|
||||
|
||||
// MIDI Header (14 bytes)
|
||||
data.push(0x4D, 0x54, 0x68, 0x64); // "MThd"
|
||||
data.push(0x00, 0x00, 0x00, 0x06); // Header chunk size
|
||||
data.push(0x00, 0x00); // Format 0
|
||||
data.push(0x00, 0x01); // 1 track
|
||||
data.push(0x00, 0x60); // 96 ticks per quarter note
|
||||
|
||||
// Track data
|
||||
const trackData: number[] = [];
|
||||
|
||||
// Set tempo (120 BPM)
|
||||
trackData.push(0x00, 0xFF, 0x51, 0x03, 0x07, 0xA1, 0x20);
|
||||
|
||||
// Simple 4-note melody
|
||||
const hash = this.simpleHash(songName);
|
||||
const rootNote = 60 + (hash % 12); // C4 + offset
|
||||
const melody = [0, 4, 7, 12]; // Root, major 3rd, perfect 5th, octave
|
||||
|
||||
for (let i = 0; i < melody.length; i++) {
|
||||
const note = rootNote + melody[i];
|
||||
trackData.push(0x00, 0x90, note, 0x40); // Note On
|
||||
trackData.push(0x30, 0x80, note, 0x00); // Note Off after 48 ticks
|
||||
}
|
||||
|
||||
// End of track
|
||||
trackData.push(0x00, 0xFF, 0x2F, 0x00);
|
||||
|
||||
// Track header
|
||||
data.push(0x4D, 0x54, 0x72, 0x6B); // "MTrk"
|
||||
const trackLength = trackData.length;
|
||||
data.push(
|
||||
(trackLength >> 24) & 0xFF,
|
||||
(trackLength >> 16) & 0xFF,
|
||||
(trackLength >> 8) & 0xFF,
|
||||
trackLength & 0xFF
|
||||
);
|
||||
|
||||
data.push(...trackData);
|
||||
|
||||
return new Uint8Array(data).buffer;
|
||||
}
|
||||
|
||||
private static midiNumberToNote(midiNumber: number): string {
|
||||
const noteNames = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
|
||||
const octave = Math.floor(midiNumber / 12) - 1;
|
||||
const note = noteNames[midiNumber % 12];
|
||||
return `${note}${octave}`;
|
||||
}
|
||||
|
||||
private static simpleHash(str: string): number {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = ((hash << 5) - hash) + str.charCodeAt(i);
|
||||
hash = hash & hash; // Convert to 32-bit integer
|
||||
}
|
||||
return Math.abs(hash);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
export class SimpleMIDI {
|
||||
static generateValidMIDI(songName: string): ArrayBuffer {
|
||||
// Create a minimal but valid MIDI file
|
||||
const data: number[] = [];
|
||||
|
||||
// MIDI Header (14 bytes)
|
||||
// "MThd" magic number
|
||||
data.push(0x4D, 0x54, 0x68, 0x64);
|
||||
// Header chunk size (6)
|
||||
data.push(0x00, 0x00, 0x00, 0x06);
|
||||
// Format 0
|
||||
data.push(0x00, 0x00);
|
||||
// 1 track
|
||||
data.push(0x00, 0x01);
|
||||
// 96 ticks per quarter note
|
||||
data.push(0x00, 0x60);
|
||||
|
||||
// Track Header
|
||||
// "MTrk" magic number
|
||||
data.push(0x4D, 0x54, 0x72, 0x6B);
|
||||
|
||||
// Track data
|
||||
const trackData: number[] = [];
|
||||
|
||||
// Set tempo (120 BPM)
|
||||
trackData.push(0x00, 0xFF, 0x51, 0x03, 0x07, 0xA1, 0x20);
|
||||
|
||||
// Generate a simple melody based on song name
|
||||
const hash = this.simpleHash(songName);
|
||||
const rootNote = 60 + (hash % 12); // C4 + offset
|
||||
|
||||
// Simple 4-note melody
|
||||
const melody = [0, 4, 7, 12]; // Root, major 3rd, perfect 5th, octave
|
||||
|
||||
for (let i = 0; i < melody.length; i++) {
|
||||
const note = rootNote + melody[i];
|
||||
|
||||
// Note On (96 ticks = quarter note at 96 PPQ)
|
||||
trackData.push(0x00, 0x90, note, 0x40); // Delta time, Note On Ch0, Note, Velocity
|
||||
|
||||
// Note Off after 48 ticks (eighth note)
|
||||
trackData.push(0x30, 0x80, note, 0x00); // Delta time, Note Off Ch0, Note, Velocity
|
||||
}
|
||||
|
||||
// End of track
|
||||
trackData.push(0x00, 0xFF, 0x2F, 0x00);
|
||||
|
||||
// Track length (4 bytes, big endian)
|
||||
const trackLength = trackData.length;
|
||||
data.push(
|
||||
(trackLength >> 24) & 0xFF,
|
||||
(trackLength >> 16) & 0xFF,
|
||||
(trackLength >> 8) & 0xFF,
|
||||
trackLength & 0xFF
|
||||
);
|
||||
|
||||
// Add track data
|
||||
data.push(...trackData);
|
||||
|
||||
// Convert to ArrayBuffer
|
||||
return new Uint8Array(data).buffer;
|
||||
}
|
||||
|
||||
private static simpleHash(str: string): number {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = ((hash << 5) - hash) + str.charCodeAt(i);
|
||||
hash = hash & hash; // Convert to 32-bit integer
|
||||
}
|
||||
return Math.abs(hash);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
export class SyntheticMIDI {
|
||||
static generateMIDIBuffer(songName: string): ArrayBuffer {
|
||||
// Create a minimal MIDI file for testing
|
||||
// This is a simplified MIDI file with basic header and one note
|
||||
|
||||
const hash = this.simpleHash(songName);
|
||||
const tempo = 120 + (hash % 60); // 120-180 BPM
|
||||
const key = hash % 12; // 0-11 for C-B
|
||||
|
||||
// MIDI Header
|
||||
const header = new Uint8Array([
|
||||
// "MThd"
|
||||
0x4D, 0x54, 0x68, 0x64,
|
||||
// Header length (6 bytes)
|
||||
0x00, 0x00, 0x00, 0x06,
|
||||
// Format type 0
|
||||
0x00, 0x00,
|
||||
// Number of tracks (1)
|
||||
0x00, 0x01,
|
||||
// Time division (480 ticks per quarter note)
|
||||
0x01, 0xE0
|
||||
]);
|
||||
|
||||
// Track data with some basic notes
|
||||
const trackData = this.generateTrackData(key, tempo);
|
||||
|
||||
// Track header
|
||||
const trackHeader = new Uint8Array([
|
||||
// "MTrk"
|
||||
0x4D, 0x54, 0x72, 0x6B,
|
||||
// Track length (will be calculated)
|
||||
0x00, 0x00, 0x00, trackData.length
|
||||
]);
|
||||
|
||||
// Combine all parts
|
||||
const totalLength = header.length + trackHeader.length + trackData.length;
|
||||
const result = new ArrayBuffer(totalLength);
|
||||
const view = new Uint8Array(result);
|
||||
|
||||
let offset = 0;
|
||||
view.set(header, offset);
|
||||
offset += header.length;
|
||||
view.set(trackHeader, offset);
|
||||
offset += trackHeader.length;
|
||||
view.set(trackData, offset);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static generateTrackData(key: number, tempo: number): Uint8Array {
|
||||
const events: number[] = [];
|
||||
|
||||
// Set tempo meta event
|
||||
events.push(
|
||||
0x00, // Delta time
|
||||
0xFF, 0x51, 0x03, // Set tempo meta event
|
||||
0x07, 0xA1, 0x20 // 500000 microseconds per quarter note (120 BPM)
|
||||
);
|
||||
|
||||
// Add some basic notes in the key
|
||||
const scale = [0, 2, 4, 5, 7, 9, 11]; // Major scale
|
||||
const baseNote = 60 + key; // Middle C + key offset
|
||||
|
||||
let time = 0;
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const note = baseNote + scale[i % scale.length] + (Math.floor(i / scale.length) * 12);
|
||||
|
||||
// Note on
|
||||
events.push(
|
||||
this.encodeVariableLength(time)[0], // Delta time
|
||||
0x90, // Note on, channel 0
|
||||
note, // Note number
|
||||
0x64 // Velocity
|
||||
);
|
||||
|
||||
// Note off after 480 ticks (quarter note)
|
||||
events.push(
|
||||
this.encodeVariableLength(480)[0], // Delta time
|
||||
0x80, // Note off, channel 0
|
||||
note, // Note number
|
||||
0x00 // Velocity
|
||||
);
|
||||
|
||||
time = 0; // Next note starts immediately after previous ends
|
||||
}
|
||||
|
||||
// End of track
|
||||
events.push(0x00, 0xFF, 0x2F, 0x00);
|
||||
|
||||
return new Uint8Array(events);
|
||||
}
|
||||
|
||||
private static encodeVariableLength(value: number): number[] {
|
||||
if (value < 128) {
|
||||
return [value];
|
||||
}
|
||||
// For simplicity, just handle small values
|
||||
return [value & 0x7F];
|
||||
}
|
||||
|
||||
private static simpleHash(str: string): number {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const char = str.charCodeAt(i);
|
||||
hash = ((hash << 5) - hash) + char;
|
||||
hash = hash & hash; // Convert to 32-bit integer
|
||||
}
|
||||
return Math.abs(hash);
|
||||
}
|
||||
}
|
||||
@@ -2,14 +2,15 @@
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
"rootDir": "src",
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
|
||||
Reference in New Issue
Block a user