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:
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* Arpeggiator
|
||||
*
|
||||
* Converts chords (simultaneous notes) into fast arpeggios.
|
||||
* This is a classic Game Boy technique to simulate polyphony
|
||||
* on limited channels.
|
||||
*/
|
||||
|
||||
import type { ArpNote, ArpChord } from '../../types';
|
||||
|
||||
export interface ArpeggiatorConfig {
|
||||
/** Speed of arpeggiation in beats (1/64 = 64th note, 1/32 = 32nd note) */
|
||||
speed: number;
|
||||
|
||||
/** BPM for calculating actual timing */
|
||||
bpm: number;
|
||||
|
||||
/** Pattern: 'up', 'down', 'updown', 'random' */
|
||||
pattern: 'up' | 'down' | 'updown' | 'random';
|
||||
|
||||
/** Minimum number of notes to trigger arpeggiation (2 = any chord) */
|
||||
minNotes: number;
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: ArpeggiatorConfig = {
|
||||
speed: 1 / 32, // 32nd notes
|
||||
bpm: 120,
|
||||
pattern: 'up',
|
||||
minNotes: 2,
|
||||
};
|
||||
|
||||
export class Arpeggiator {
|
||||
private config: ArpeggiatorConfig;
|
||||
|
||||
constructor(config: Partial<ArpeggiatorConfig> = {}) {
|
||||
this.config = { ...DEFAULT_CONFIG, ...config };
|
||||
}
|
||||
|
||||
/**
|
||||
* Update configuration.
|
||||
*/
|
||||
setConfig(config: Partial<ArpeggiatorConfig>): void {
|
||||
this.config = { ...this.config, ...config };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current configuration.
|
||||
*/
|
||||
getConfig(): ArpeggiatorConfig {
|
||||
return { ...this.config };
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the duration of one arp step in seconds.
|
||||
*/
|
||||
private getStepDuration(): number {
|
||||
// One beat = 60 / BPM seconds
|
||||
// speed is in beats (e.g., 1/32 = 32nd note = 1/8 of a beat)
|
||||
const beatDuration = 60 / this.config.bpm;
|
||||
return beatDuration * this.config.speed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an array of notes to arpeggiated output.
|
||||
* Single notes pass through unchanged.
|
||||
* Chords are converted to fast arpeggios.
|
||||
*/
|
||||
arpeggiate(notes: ArpNote[]): ArpNote[] {
|
||||
if (notes.length === 0) return [];
|
||||
|
||||
// Group notes by time (detect chords)
|
||||
const chords = this.groupIntoChords(notes);
|
||||
|
||||
// Process each chord
|
||||
const result: ArpNote[] = [];
|
||||
|
||||
for (const chord of chords) {
|
||||
if (chord.notes.length < this.config.minNotes) {
|
||||
// Not enough notes for a chord, pass through
|
||||
result.push(...chord.notes);
|
||||
} else {
|
||||
// Arpeggiate the chord
|
||||
result.push(...this.arpeggiateChord(chord));
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by time
|
||||
return result.sort((a, b) => a.time - b.time);
|
||||
}
|
||||
|
||||
/**
|
||||
* Group notes into chords based on timing.
|
||||
*/
|
||||
private groupIntoChords(notes: ArpNote[]): ArpChord[] {
|
||||
const tolerance = 0.02; // 20ms tolerance
|
||||
const sorted = [...notes].sort((a, b) => a.time - b.time);
|
||||
|
||||
const chords: ArpChord[] = [];
|
||||
let currentChord: ArpChord | null = null;
|
||||
|
||||
for (const note of sorted) {
|
||||
if (!currentChord || note.time - currentChord.startTime > tolerance) {
|
||||
// Start a new chord
|
||||
currentChord = {
|
||||
startTime: note.time,
|
||||
notes: [note],
|
||||
};
|
||||
chords.push(currentChord);
|
||||
} else {
|
||||
// Add to current chord
|
||||
currentChord.notes.push(note);
|
||||
}
|
||||
}
|
||||
|
||||
return chords;
|
||||
}
|
||||
|
||||
/**
|
||||
* Arpeggiate a single chord.
|
||||
*/
|
||||
private arpeggiateChord(chord: ArpChord): ArpNote[] {
|
||||
const stepDuration = this.getStepDuration();
|
||||
|
||||
// Sort notes by pitch based on pattern
|
||||
const sortedNotes = this.sortNotesForPattern(chord.notes);
|
||||
|
||||
// Calculate how long the original chord should last
|
||||
const maxDuration = Math.max(...chord.notes.map(n => n.duration));
|
||||
|
||||
// Calculate how many complete cycles we can fit
|
||||
const cycleLength = sortedNotes.length * stepDuration;
|
||||
const numCycles = Math.max(1, Math.floor(maxDuration / cycleLength));
|
||||
|
||||
const result: ArpNote[] = [];
|
||||
let noteIndex = 0;
|
||||
let direction = 1; // For updown pattern
|
||||
|
||||
// Generate arpeggiated notes
|
||||
for (let cycle = 0; cycle < numCycles; cycle++) {
|
||||
for (let i = 0; i < sortedNotes.length; i++) {
|
||||
const originalNote = sortedNotes[noteIndex];
|
||||
const time = chord.startTime + (cycle * sortedNotes.length + i) * stepDuration;
|
||||
|
||||
// Only add if within original duration
|
||||
if (time < chord.startTime + maxDuration) {
|
||||
result.push({
|
||||
midiNote: originalNote.midiNote,
|
||||
time,
|
||||
duration: stepDuration * 0.9, // Slight gap between notes
|
||||
velocity: originalNote.velocity,
|
||||
});
|
||||
}
|
||||
|
||||
// Update index based on pattern
|
||||
if (this.config.pattern === 'updown') {
|
||||
noteIndex += direction;
|
||||
if (noteIndex >= sortedNotes.length - 1) {
|
||||
direction = -1;
|
||||
noteIndex = sortedNotes.length - 1;
|
||||
} else if (noteIndex <= 0) {
|
||||
direction = 1;
|
||||
noteIndex = 0;
|
||||
}
|
||||
} else if (this.config.pattern === 'random') {
|
||||
noteIndex = Math.floor(Math.random() * sortedNotes.length);
|
||||
} else {
|
||||
noteIndex = (noteIndex + 1) % sortedNotes.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort notes based on the arpeggio pattern.
|
||||
*/
|
||||
private sortNotesForPattern(notes: ArpNote[]): ArpNote[] {
|
||||
const sorted = [...notes];
|
||||
|
||||
switch (this.config.pattern) {
|
||||
case 'up':
|
||||
case 'updown':
|
||||
sorted.sort((a, b) => a.midiNote - b.midiNote);
|
||||
break;
|
||||
case 'down':
|
||||
sorted.sort((a, b) => b.midiNote - a.midiNote);
|
||||
break;
|
||||
case 'random':
|
||||
// Shuffle
|
||||
for (let i = sorted.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[sorted[i], sorted[j]] = [sorted[j], sorted[i]];
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return sorted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a set of notes would be arpeggiated.
|
||||
*/
|
||||
wouldArpeggiate(notes: ArpNote[]): boolean {
|
||||
const chords = this.groupIntoChords(notes);
|
||||
return chords.some(chord => chord.notes.length >= this.config.minNotes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set BPM (updates timing calculations).
|
||||
*/
|
||||
setBPM(bpm: number): void {
|
||||
this.config.bpm = Math.max(20, Math.min(300, bpm));
|
||||
}
|
||||
|
||||
/**
|
||||
* Set arpeggio speed.
|
||||
*/
|
||||
setSpeed(speed: number): void {
|
||||
this.config.speed = speed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set arpeggio pattern.
|
||||
*/
|
||||
setPattern(pattern: ArpeggiatorConfig['pattern']): void {
|
||||
this.config.pattern = pattern;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
/**
|
||||
* Channel Mapper
|
||||
*
|
||||
* Intelligently assigns MIDI tracks to the 8 GB channels based on
|
||||
* track analysis results. Prioritizes the most important tracks
|
||||
* and assigns them to the most appropriate channel types.
|
||||
*/
|
||||
|
||||
import { TrackAnalyzer, type MIDITrack } from './TrackAnalyzer';
|
||||
import type {
|
||||
ChannelAssignment,
|
||||
TrackAnalysis,
|
||||
ChannelId,
|
||||
PulseChannelId,
|
||||
WaveChannelId,
|
||||
NoiseChannelId,
|
||||
TrackRole
|
||||
} from '../../types';
|
||||
import type { DutyIndex } from '../synthesis/DutyCycle';
|
||||
import type { WavePreset } from '../synthesis/WaveTable';
|
||||
import type { LFSRMode } from '../synthesis/LFSR';
|
||||
|
||||
/**
|
||||
* Channel mapping configuration
|
||||
*/
|
||||
export interface ChannelMapperConfig {
|
||||
/** Maximum tracks to assign (limits complexity) */
|
||||
maxTracks: number;
|
||||
|
||||
/** Whether to arpeggiate harmony tracks */
|
||||
arpeggiateHarmony: boolean;
|
||||
|
||||
/** Default duty cycle for lead channels */
|
||||
leadDuty: DutyIndex;
|
||||
|
||||
/** Default duty cycle for harmony channels */
|
||||
harmonyDuty: DutyIndex;
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: ChannelMapperConfig = {
|
||||
maxTracks: 8,
|
||||
arpeggiateHarmony: true,
|
||||
leadDuty: 2, // 50% for full sound
|
||||
harmonyDuty: 1, // 25% for thinner, less intrusive sound
|
||||
};
|
||||
|
||||
/**
|
||||
* Available channel pools by type
|
||||
*/
|
||||
const CHANNEL_POOLS = {
|
||||
pulse: ['p1', 'p2', 'p3', 'p4'] as PulseChannelId[],
|
||||
wave: ['w1', 'w2'] as WaveChannelId[],
|
||||
noise: ['n1', 'n2'] as NoiseChannelId[],
|
||||
};
|
||||
|
||||
export class ChannelMapper {
|
||||
private analyzer: TrackAnalyzer;
|
||||
private config: ChannelMapperConfig;
|
||||
|
||||
constructor(config: Partial<ChannelMapperConfig> = {}) {
|
||||
this.analyzer = new TrackAnalyzer();
|
||||
this.config = { ...DEFAULT_CONFIG, ...config };
|
||||
}
|
||||
|
||||
/**
|
||||
* Update configuration.
|
||||
*/
|
||||
setConfig(config: Partial<ChannelMapperConfig>): void {
|
||||
this.config = { ...this.config, ...config };
|
||||
}
|
||||
|
||||
/**
|
||||
* Map MIDI tracks to GB channels.
|
||||
* Returns an array of channel assignments.
|
||||
*/
|
||||
mapTracks(tracks: MIDITrack[]): ChannelAssignment[] {
|
||||
// Analyze all tracks
|
||||
const analyses = this.analyzer.analyzeTracks(tracks);
|
||||
|
||||
// Filter out empty tracks
|
||||
const nonEmptyAnalyses = analyses.filter(a => a.noteCount > 0);
|
||||
|
||||
// Track used channels
|
||||
const usedChannels = new Set<ChannelId>();
|
||||
|
||||
// Assign channels in priority order
|
||||
const assignments: ChannelAssignment[] = [];
|
||||
|
||||
for (const analysis of nonEmptyAnalyses) {
|
||||
if (assignments.length >= this.config.maxTracks) break;
|
||||
|
||||
const assignment = this.assignChannel(analysis, usedChannels);
|
||||
if (assignment) {
|
||||
assignments.push(assignment);
|
||||
usedChannels.add(assignment.channelId);
|
||||
}
|
||||
}
|
||||
|
||||
return assignments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign a single track to a channel.
|
||||
*/
|
||||
private assignChannel(
|
||||
analysis: TrackAnalysis,
|
||||
usedChannels: Set<ChannelId>
|
||||
): ChannelAssignment | null {
|
||||
const { role, hasChords } = analysis;
|
||||
|
||||
// Route to appropriate channel type based on role
|
||||
switch (role) {
|
||||
case 'drums':
|
||||
return this.assignDrums(analysis, usedChannels);
|
||||
|
||||
case 'bass':
|
||||
return this.assignBass(analysis, usedChannels);
|
||||
|
||||
case 'lead':
|
||||
return this.assignLead(analysis, usedChannels);
|
||||
|
||||
case 'harmony':
|
||||
return this.assignHarmony(analysis, usedChannels, hasChords);
|
||||
|
||||
case 'pad':
|
||||
return this.assignPad(analysis, usedChannels);
|
||||
|
||||
case 'fx':
|
||||
return this.assignFX(analysis, usedChannels);
|
||||
|
||||
default:
|
||||
// Fallback to any available pulse channel
|
||||
return this.assignToAnyPulse(analysis, usedChannels);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign drums to noise channels.
|
||||
*/
|
||||
private assignDrums(
|
||||
analysis: TrackAnalysis,
|
||||
usedChannels: Set<ChannelId>
|
||||
): ChannelAssignment | null {
|
||||
// Try noise channels first
|
||||
const channel = this.findFreeChannel(CHANNEL_POOLS.noise, usedChannels);
|
||||
|
||||
if (!channel) return null;
|
||||
|
||||
// Use 7-bit for kick/snare, 15-bit for hihats
|
||||
// Default to 7-bit as it's punchier
|
||||
const noiseMode: LFSRMode = '7bit';
|
||||
|
||||
return {
|
||||
trackIndex: analysis.trackIndex,
|
||||
channelId: channel,
|
||||
shouldArpeggiate: false,
|
||||
noiseMode,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign bass to wave channel.
|
||||
*/
|
||||
private assignBass(
|
||||
analysis: TrackAnalysis,
|
||||
usedChannels: Set<ChannelId>
|
||||
): ChannelAssignment | null {
|
||||
// Prefer w1 for bass
|
||||
if (!usedChannels.has('w1')) {
|
||||
return {
|
||||
trackIndex: analysis.trackIndex,
|
||||
channelId: 'w1',
|
||||
shouldArpeggiate: false,
|
||||
wavePreset: 'bass' as WavePreset,
|
||||
};
|
||||
}
|
||||
|
||||
// Fall back to w2
|
||||
if (!usedChannels.has('w2')) {
|
||||
return {
|
||||
trackIndex: analysis.trackIndex,
|
||||
channelId: 'w2',
|
||||
shouldArpeggiate: false,
|
||||
wavePreset: 'bass' as WavePreset,
|
||||
};
|
||||
}
|
||||
|
||||
// No wave channels available, try pulse with low duty
|
||||
const pulseChannel = this.findFreeChannel(CHANNEL_POOLS.pulse, usedChannels);
|
||||
if (pulseChannel) {
|
||||
return {
|
||||
trackIndex: analysis.trackIndex,
|
||||
channelId: pulseChannel,
|
||||
shouldArpeggiate: false,
|
||||
dutyCycle: 2 as DutyIndex, // 50% for fuller bass
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign lead melody to pulse channels.
|
||||
*/
|
||||
private assignLead(
|
||||
analysis: TrackAnalysis,
|
||||
usedChannels: Set<ChannelId>
|
||||
): ChannelAssignment | null {
|
||||
// Prefer p1 or p2 (sweep-capable) for lead
|
||||
for (const channelId of ['p1', 'p2'] as PulseChannelId[]) {
|
||||
if (!usedChannels.has(channelId)) {
|
||||
return {
|
||||
trackIndex: analysis.trackIndex,
|
||||
channelId,
|
||||
shouldArpeggiate: false,
|
||||
dutyCycle: this.config.leadDuty,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to p3/p4
|
||||
const channel = this.findFreeChannel(['p3', 'p4'] as PulseChannelId[], usedChannels);
|
||||
if (channel) {
|
||||
return {
|
||||
trackIndex: analysis.trackIndex,
|
||||
channelId: channel,
|
||||
shouldArpeggiate: false,
|
||||
dutyCycle: this.config.leadDuty,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign harmony to pulse channels (with optional arpeggio).
|
||||
*/
|
||||
private assignHarmony(
|
||||
analysis: TrackAnalysis,
|
||||
usedChannels: Set<ChannelId>,
|
||||
hasChords: boolean
|
||||
): ChannelAssignment | null {
|
||||
// Use p3/p4 for harmony (thinner sound, no sweep)
|
||||
const channel = this.findFreeChannel(['p3', 'p4', 'p1', 'p2'] as PulseChannelId[], usedChannels);
|
||||
|
||||
if (!channel) return null;
|
||||
|
||||
return {
|
||||
trackIndex: analysis.trackIndex,
|
||||
channelId: channel,
|
||||
shouldArpeggiate: this.config.arpeggiateHarmony && hasChords,
|
||||
dutyCycle: this.config.harmonyDuty,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign pad to wave channel.
|
||||
*/
|
||||
private assignPad(
|
||||
analysis: TrackAnalysis,
|
||||
usedChannels: Set<ChannelId>
|
||||
): ChannelAssignment | null {
|
||||
// Prefer w2 for pads
|
||||
if (!usedChannels.has('w2')) {
|
||||
return {
|
||||
trackIndex: analysis.trackIndex,
|
||||
channelId: 'w2',
|
||||
shouldArpeggiate: false,
|
||||
wavePreset: 'pad' as WavePreset,
|
||||
};
|
||||
}
|
||||
|
||||
// Fall back to w1
|
||||
if (!usedChannels.has('w1')) {
|
||||
return {
|
||||
trackIndex: analysis.trackIndex,
|
||||
channelId: 'w1',
|
||||
shouldArpeggiate: false,
|
||||
wavePreset: 'pad' as WavePreset,
|
||||
};
|
||||
}
|
||||
|
||||
// Fall back to pulse
|
||||
const pulseChannel = this.findFreeChannel(CHANNEL_POOLS.pulse, usedChannels);
|
||||
if (pulseChannel) {
|
||||
return {
|
||||
trackIndex: analysis.trackIndex,
|
||||
channelId: pulseChannel,
|
||||
shouldArpeggiate: false,
|
||||
dutyCycle: 2 as DutyIndex,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign FX/incidental to any available pulse channel.
|
||||
*/
|
||||
private assignFX(
|
||||
analysis: TrackAnalysis,
|
||||
usedChannels: Set<ChannelId>
|
||||
): ChannelAssignment | null {
|
||||
// FX goes to any available pulse channel
|
||||
const channel = this.findFreeChannel(CHANNEL_POOLS.pulse, usedChannels);
|
||||
|
||||
if (!channel) return null;
|
||||
|
||||
return {
|
||||
trackIndex: analysis.trackIndex,
|
||||
channelId: channel,
|
||||
shouldArpeggiate: false,
|
||||
dutyCycle: 0 as DutyIndex, // 12.5% for thin, effects-like sound
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign to any available pulse channel.
|
||||
*/
|
||||
private assignToAnyPulse(
|
||||
analysis: TrackAnalysis,
|
||||
usedChannels: Set<ChannelId>
|
||||
): ChannelAssignment | null {
|
||||
const channel = this.findFreeChannel(CHANNEL_POOLS.pulse, usedChannels);
|
||||
|
||||
if (!channel) return null;
|
||||
|
||||
return {
|
||||
trackIndex: analysis.trackIndex,
|
||||
channelId: channel,
|
||||
shouldArpeggiate: false,
|
||||
dutyCycle: 2 as DutyIndex,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the first free channel from a pool.
|
||||
*/
|
||||
private findFreeChannel<T extends ChannelId>(
|
||||
pool: T[],
|
||||
usedChannels: Set<ChannelId>
|
||||
): T | null {
|
||||
for (const channel of pool) {
|
||||
if (!usedChannels.has(channel)) {
|
||||
return channel;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the analyzer for external use.
|
||||
*/
|
||||
getAnalyzer(): TrackAnalyzer {
|
||||
return this.analyzer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze tracks without mapping (useful for UI display).
|
||||
*/
|
||||
analyzeTracks(tracks: MIDITrack[]): TrackAnalysis[] {
|
||||
return this.analyzer.analyzeTracks(tracks);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user