Add v2 Game Boy sound engine (isolated from v1)

- Authentic DMG-CPU sound chip implementation:
  - 4 Pulse channels with duty cycle control (12.5%, 25%, 50%, 75%)
  - 2 Wave channels with 4-bit wavetables
  - 2 Noise channels with LFSR (7-bit and 15-bit modes)

- GameBoy Colorizer effect chain:
  - Low-pass filter (natural GB rolloff)
  - Bit-crushing (4-bit DAC simulation)
  - Sample rate reduction
  - Saturation and high-pass filter
  - Presets: DMG, GBC, GBA, Clean

- Intelligent MIDI processing:
  - Track analysis and role detection (bass, lead, drums, etc.)
  - Automatic channel mapping to GB channels
  - Chord arpeggiator for polyphony handling
  - GameBoy Arranger for fuller sound

- BitMidi search integration
- Completely isolated from v1 (no changes to src/)
This commit is contained in:
b1rdmania
2026-01-20 19:36:13 +00:00
parent 63bd71202a
commit 5e127c3a3e
21 changed files with 6789 additions and 0 deletions
+335
View File
@@ -0,0 +1,335 @@
/**
* MIDI Track Analyzer
*
* Analyzes MIDI tracks to determine their musical role and characteristics.
* This information is used by the ChannelMapper to intelligently assign
* tracks to the appropriate GB channels.
*/
import type { TrackAnalysis, TrackRole } from '../../types';
/**
* Raw note data from a MIDI track
*/
export interface MIDINote {
midi: number; // MIDI note number (0-127)
time: number; // Start time in seconds
duration: number; // Duration in seconds
velocity: number; // Velocity (0-127)
}
/**
* Parsed track data from a MIDI file
*/
export interface MIDITrack {
channel: number; // MIDI channel (0-15)
notes: MIDINote[];
name?: string;
}
export class TrackAnalyzer {
/**
* Analyze a single MIDI track and determine its characteristics.
*/
analyzeTrack(track: MIDITrack, trackIndex: number): TrackAnalysis {
const notes = track.notes;
if (notes.length === 0) {
return this.createEmptyAnalysis(trackIndex, track.channel);
}
// Calculate basic statistics
const noteRange = this.calculateNoteRange(notes);
const noteDensity = this.calculateNoteDensity(notes);
const avgVelocity = this.calculateAverageVelocity(notes);
const avgDuration = this.calculateAverageDuration(notes);
const complexity = this.calculateComplexity(notes);
const hasChords = this.detectChords(notes);
// Detect if this is a drums track
const isDrums = track.channel === 9 || this.detectDrums(notes);
const isPercussive = this.detectPercussive(notes);
// Determine the musical role
const role = this.detectRole(notes, noteRange, isDrums, noteDensity, hasChords, avgDuration);
// Calculate priority for channel assignment
const priority = this.calculatePriority(role, noteDensity, avgVelocity, notes.length);
return {
trackIndex,
channel: track.channel,
isDrums,
isPercussive,
noteRange,
noteDensity,
complexity,
hasChords,
avgVelocity,
avgDuration,
noteCount: notes.length,
role,
priority,
};
}
/**
* Analyze multiple tracks and return sorted by priority.
*/
analyzeTracks(tracks: MIDITrack[]): TrackAnalysis[] {
const analyses = tracks.map((track, index) => this.analyzeTrack(track, index));
// Sort by priority (highest first)
return analyses.sort((a, b) => b.priority - a.priority);
}
/**
* Create an empty analysis for a track with no notes.
*/
private createEmptyAnalysis(trackIndex: number, channel: number): TrackAnalysis {
return {
trackIndex,
channel,
isDrums: false,
isPercussive: false,
noteRange: { min: 0, max: 0, avg: 0 },
noteDensity: 0,
complexity: 0,
hasChords: false,
avgVelocity: 0,
avgDuration: 0,
noteCount: 0,
role: 'fx',
priority: 0,
};
}
/**
* Calculate the note range (min, max, average pitch).
*/
private calculateNoteRange(notes: MIDINote[]): { min: number; max: number; avg: number } {
if (notes.length === 0) {
return { min: 0, max: 0, avg: 0 };
}
let min = 127;
let max = 0;
let sum = 0;
for (const note of notes) {
min = Math.min(min, note.midi);
max = Math.max(max, note.midi);
sum += note.midi;
}
return {
min,
max,
avg: sum / notes.length,
};
}
/**
* Calculate note density (notes per second).
*/
private calculateNoteDensity(notes: MIDINote[]): number {
if (notes.length < 2) return 0;
const startTime = notes[0].time;
const endTime = notes[notes.length - 1].time + notes[notes.length - 1].duration;
const duration = endTime - startTime;
if (duration <= 0) return 0;
return notes.length / duration;
}
/**
* Calculate average velocity.
*/
private calculateAverageVelocity(notes: MIDINote[]): number {
if (notes.length === 0) return 0;
const sum = notes.reduce((acc, note) => acc + note.velocity, 0);
return sum / notes.length;
}
/**
* Calculate average note duration.
*/
private calculateAverageDuration(notes: MIDINote[]): number {
if (notes.length === 0) return 0;
const sum = notes.reduce((acc, note) => acc + note.duration, 0);
return sum / notes.length;
}
/**
* Calculate complexity score (0-1).
* Based on pitch variation, rhythm variation, and density.
*/
private calculateComplexity(notes: MIDINote[]): number {
if (notes.length < 2) return 0;
// Pitch variation
const range = this.calculateNoteRange(notes);
const pitchVariation = Math.min(1, (range.max - range.min) / 36); // Normalize to 3 octaves
// Rhythm variation (variance in inter-note timing)
const timeDiffs: number[] = [];
for (let i = 1; i < notes.length; i++) {
timeDiffs.push(notes[i].time - notes[i - 1].time);
}
if (timeDiffs.length === 0) return pitchVariation * 0.5;
const avgTimeDiff = timeDiffs.reduce((a, b) => a + b, 0) / timeDiffs.length;
const timeVariance = timeDiffs.reduce((acc, t) => acc + Math.pow(t - avgTimeDiff, 2), 0) / timeDiffs.length;
const rhythmVariation = Math.min(1, Math.sqrt(timeVariance) / avgTimeDiff);
return (pitchVariation * 0.6 + rhythmVariation * 0.4);
}
/**
* Detect if notes contain chords (multiple simultaneous notes).
*/
private detectChords(notes: MIDINote[]): boolean {
// Group notes by time (10ms tolerance)
const tolerance = 0.01;
const timeSlots = new Map<number, number>();
for (const note of notes) {
const slot = Math.floor(note.time / tolerance);
timeSlots.set(slot, (timeSlots.get(slot) || 0) + 1);
}
// Count how many slots have more than 2 notes
let chordSlots = 0;
for (const count of timeSlots.values()) {
if (count >= 2) chordSlots++;
}
// If more than 10% of time slots have chords, this track has chords
return chordSlots > timeSlots.size * 0.1;
}
/**
* Detect if this is a drums track (based on note patterns, not just channel).
*/
private detectDrums(notes: MIDINote[]): boolean {
if (notes.length < 4) return false;
// Drums typically have:
// 1. Short note durations
// 2. Limited pitch range (clustered around GM drum notes 35-81)
// 3. High velocity variation
const avgDuration = this.calculateAverageDuration(notes);
const range = this.calculateNoteRange(notes);
// Very short notes
const shortNotes = avgDuration < 0.1;
// Limited pitch range around drum notes
const drumPitchRange = range.min >= 35 && range.max <= 81 && (range.max - range.min) < 30;
// High repetition (same notes repeated often)
const pitchCounts = new Map<number, number>();
for (const note of notes) {
pitchCounts.set(note.midi, (pitchCounts.get(note.midi) || 0) + 1);
}
const uniquePitches = pitchCounts.size;
const highRepetition = uniquePitches < 10 && notes.length > 20;
return shortNotes && (drumPitchRange || highRepetition);
}
/**
* Detect if track is percussive (short, rhythmic).
*/
private detectPercussive(notes: MIDINote[]): boolean {
const avgDuration = this.calculateAverageDuration(notes);
return avgDuration < 0.15;
}
/**
* Determine the musical role of the track.
*/
private detectRole(
notes: MIDINote[],
range: { min: number; max: number; avg: number },
isDrums: boolean,
density: number,
hasChords: boolean,
avgDuration: number
): TrackRole {
// Drums are drums
if (isDrums) return 'drums';
// Bass: low average pitch (MIDI 52 = E3, typical bass range)
// Also consider tracks where the max note is low
if (range.avg < 52 || range.max < 55) return 'bass';
// Lead: high pitch with high density
if (range.avg > 58 && density > 2) return 'lead';
// Pad: low density, long notes
if (density < 1.5 && avgDuration > 0.5) return 'pad';
// Harmony: has chords
if (hasChords) return 'harmony';
// FX: very high density
if (density > 8) return 'fx';
// Default to lead for melodic content
return 'lead';
}
/**
* Calculate priority for channel assignment.
* Higher priority = assigned first to best channels.
*/
private calculatePriority(
role: TrackRole,
density: number,
avgVelocity: number,
noteCount: number
): number {
let priority = 50;
// Role-based priority
switch (role) {
case 'drums':
priority += 30;
break;
case 'bass':
priority += 25;
break;
case 'lead':
priority += 20;
break;
case 'harmony':
priority += 15;
break;
case 'pad':
priority += 10;
break;
case 'fx':
priority += 5;
break;
}
// Density bonus (up to 20 points)
priority += Math.min(20, density * 2);
// Velocity bonus (up to 10 points)
priority += (avgVelocity / 127) * 10;
// Note count bonus (logarithmic, up to 10 points)
priority += Math.min(10, Math.log10(noteCount + 1) * 3);
return priority;
}
}