434 lines
12 KiB
TypeScript
434 lines
12 KiB
TypeScript
/**
|
|
* Game Boy Arranger
|
|
*
|
|
* This is the "secret sauce" that makes arbitrary MIDI files sound like
|
|
* actual Game Boy music. Professional GB composers used specific techniques
|
|
* to make 4 channels sound full - this module applies those techniques
|
|
* automatically to sparse MIDI arrangements.
|
|
*
|
|
* Techniques applied:
|
|
* 1. Bass Enhancement - Make bass lines rhythmically active
|
|
* 2. Drum Enhancement - Add hi-hats and fill percussion gaps
|
|
* 3. Melody Doubling - Add octave harmonies on spare channels
|
|
* 4. Counter-Melody Generation - Create interweaving parts
|
|
* 5. Gap Filling - Ensure channels stay busy
|
|
* 6. Arpeggio Insertion - Turn static chords into motion
|
|
*/
|
|
|
|
import type { ChannelNote, ChannelId, ChannelAssignment, TrackAnalysis } from '../../types';
|
|
import type { ArpNote } from '../../types';
|
|
|
|
export interface ArrangerConfig {
|
|
/** Enable bass enhancement */
|
|
enhanceBass: boolean;
|
|
|
|
/** Enable drum/percussion enhancement */
|
|
enhanceDrums: boolean;
|
|
|
|
/** Enable melody doubling */
|
|
doubleMelody: boolean;
|
|
|
|
/** Enable gap filling */
|
|
fillGaps: boolean;
|
|
|
|
/** Minimum gap duration to fill (seconds) */
|
|
minGapToFill: number;
|
|
|
|
/** Target channel utilization (0-1) */
|
|
targetUtilization: number;
|
|
|
|
/** Hi-hat rate (notes per beat) */
|
|
hihatRate: number;
|
|
|
|
/** BPM for timing calculations */
|
|
bpm: number;
|
|
}
|
|
|
|
const DEFAULT_CONFIG: ArrangerConfig = {
|
|
enhanceBass: true,
|
|
enhanceDrums: false, // Disabled - no phantom drums/hi-hats
|
|
doubleMelody: true,
|
|
fillGaps: true,
|
|
minGapToFill: 0.5,
|
|
targetUtilization: 0.7,
|
|
hihatRate: 1,
|
|
bpm: 120,
|
|
};
|
|
|
|
export interface ArrangementResult {
|
|
notes: ChannelNote[];
|
|
stats: {
|
|
originalNotes: number;
|
|
addedNotes: number;
|
|
channelUtilization: Record<ChannelId, number>;
|
|
};
|
|
}
|
|
|
|
export class GameBoyArranger {
|
|
private config: ArrangerConfig;
|
|
|
|
constructor(config: Partial<ArrangerConfig> = {}) {
|
|
this.config = { ...DEFAULT_CONFIG, ...config };
|
|
}
|
|
|
|
/**
|
|
* Set configuration.
|
|
*/
|
|
setConfig(config: Partial<ArrangerConfig>): void {
|
|
this.config = { ...this.config, ...config };
|
|
}
|
|
|
|
/**
|
|
* Set BPM for timing calculations.
|
|
*/
|
|
setBPM(bpm: number): void {
|
|
this.config.bpm = bpm;
|
|
}
|
|
|
|
/**
|
|
* Arrange and enhance notes for fuller GB sound.
|
|
*/
|
|
arrange(
|
|
notes: ChannelNote[],
|
|
assignments: ChannelAssignment[],
|
|
duration: number
|
|
): ArrangementResult {
|
|
const originalCount = notes.length;
|
|
let enhanced = [...notes];
|
|
|
|
// Group notes by channel
|
|
const byChannel = this.groupByChannel(enhanced);
|
|
|
|
// 1. Enhance bass
|
|
if (this.config.enhanceBass) {
|
|
const bassChannels = ['w1', 'w2'] as ChannelId[];
|
|
for (const channelId of bassChannels) {
|
|
if (byChannel.has(channelId)) {
|
|
const bassNotes = byChannel.get(channelId)!;
|
|
const enhancedBass = this.enhanceBass(bassNotes, duration);
|
|
byChannel.set(channelId, enhancedBass);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 2. Enhance drums
|
|
if (this.config.enhanceDrums) {
|
|
const noiseChannels = ['n1', 'n2'] as ChannelId[];
|
|
for (const channelId of noiseChannels) {
|
|
const drumNotes = byChannel.get(channelId) || [];
|
|
const enhancedDrums = this.enhanceDrums(drumNotes, duration, channelId);
|
|
byChannel.set(channelId, enhancedDrums);
|
|
}
|
|
}
|
|
|
|
// 3. Double melody if spare pulse channel available
|
|
if (this.config.doubleMelody) {
|
|
const pulseChannels = ['p1', 'p2', 'p3', 'p4'] as ChannelId[];
|
|
const usedPulse = pulseChannels.filter(c =>
|
|
byChannel.has(c) && byChannel.get(c)!.length > 0
|
|
);
|
|
const sparePulse = pulseChannels.filter(c => !usedPulse.includes(c));
|
|
|
|
if (sparePulse.length > 0 && usedPulse.length > 0) {
|
|
// Find the lead channel (most notes, highest pitch)
|
|
const leadChannel = this.findLeadChannel(byChannel, usedPulse);
|
|
if (leadChannel && byChannel.get(leadChannel)) {
|
|
const doubled = this.doubleMelody(
|
|
byChannel.get(leadChannel)!,
|
|
sparePulse[0]
|
|
);
|
|
byChannel.set(sparePulse[0], doubled);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 4. Fill gaps in all channels
|
|
if (this.config.fillGaps) {
|
|
for (const [channelId, channelNotes] of byChannel) {
|
|
const filled = this.fillGaps(channelNotes, duration, channelId);
|
|
byChannel.set(channelId, filled);
|
|
}
|
|
}
|
|
|
|
// Flatten back to array
|
|
enhanced = [];
|
|
for (const channelNotes of byChannel.values()) {
|
|
enhanced.push(...channelNotes);
|
|
}
|
|
|
|
// Sort by time
|
|
enhanced.sort((a, b) => a.startTime - b.startTime);
|
|
|
|
// Calculate utilization stats
|
|
const utilization = this.calculateUtilization(byChannel, duration);
|
|
|
|
return {
|
|
notes: enhanced,
|
|
stats: {
|
|
originalNotes: originalCount,
|
|
addedNotes: enhanced.length - originalCount,
|
|
channelUtilization: utilization,
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Group notes by channel.
|
|
*/
|
|
private groupByChannel(notes: ChannelNote[]): Map<ChannelId, ChannelNote[]> {
|
|
const grouped = new Map<ChannelId, ChannelNote[]>();
|
|
|
|
for (const note of notes) {
|
|
if (!grouped.has(note.channel)) {
|
|
grouped.set(note.channel, []);
|
|
}
|
|
grouped.get(note.channel)!.push(note);
|
|
}
|
|
|
|
// Sort each channel by time
|
|
for (const channelNotes of grouped.values()) {
|
|
channelNotes.sort((a, b) => a.startTime - b.startTime);
|
|
}
|
|
|
|
return grouped;
|
|
}
|
|
|
|
/**
|
|
* Enhance bass to be more rhythmically active.
|
|
* GB bass doesn't just play root notes - it has rhythmic variation.
|
|
*/
|
|
private enhanceBass(notes: ChannelNote[], duration: number): ChannelNote[] {
|
|
if (notes.length === 0) return notes;
|
|
|
|
const enhanced: ChannelNote[] = [];
|
|
const beatDuration = 60 / this.config.bpm;
|
|
|
|
for (const note of notes) {
|
|
// Keep original note
|
|
enhanced.push(note);
|
|
|
|
// If note is long, add rhythmic subdivisions
|
|
if (note.duration > beatDuration * 1.5) {
|
|
// Add a "bounce" note halfway through
|
|
const bounceTime = note.startTime + note.duration / 2;
|
|
enhanced.push({
|
|
...note,
|
|
startTime: bounceTime,
|
|
duration: Math.min(beatDuration * 0.5, note.duration / 4),
|
|
velocity: note.velocity * 0.7,
|
|
});
|
|
}
|
|
|
|
// Add octave jump on strong beats occasionally
|
|
if (note.duration > beatDuration * 2 && Math.random() > 0.6) {
|
|
enhanced.push({
|
|
...note,
|
|
midiNote: note.midiNote + 12, // Octave up
|
|
startTime: note.startTime + beatDuration,
|
|
duration: beatDuration * 0.4,
|
|
velocity: note.velocity * 0.6,
|
|
});
|
|
}
|
|
}
|
|
|
|
return enhanced.sort((a, b) => a.startTime - b.startTime);
|
|
}
|
|
|
|
/**
|
|
* Enhance drums with hi-hats and fills.
|
|
* GB drums are BUSY - hi-hats on every 8th note.
|
|
*/
|
|
private enhanceDrums(
|
|
notes: ChannelNote[],
|
|
duration: number,
|
|
channelId: ChannelId
|
|
): ChannelNote[] {
|
|
const enhanced = [...notes];
|
|
const beatDuration = 60 / this.config.bpm;
|
|
const subdivisionDuration = beatDuration / this.config.hihatRate;
|
|
|
|
// n2 is for hi-hats (15-bit noise = more "tsss")
|
|
// n1 is for kick/snare (7-bit noise = more punchy)
|
|
if (channelId === 'n2') {
|
|
// Add hi-hats on every subdivision if not already occupied
|
|
for (let time = 0; time < duration; time += subdivisionDuration) {
|
|
const hasNote = notes.some(n =>
|
|
Math.abs(n.startTime - time) < subdivisionDuration * 0.3
|
|
);
|
|
|
|
if (!hasNote) {
|
|
enhanced.push({
|
|
channel: channelId,
|
|
midiNote: 66, // High pitch = high frequency noise
|
|
startTime: time,
|
|
duration: subdivisionDuration * 0.4,
|
|
velocity: 40 + Math.random() * 15, // Quieter, sits back in mix
|
|
});
|
|
}
|
|
}
|
|
} else if (channelId === 'n1') {
|
|
// Ensure kick and snare on basic beats if sparse
|
|
const kickTimes = this.getKickTimes(notes);
|
|
const snareTimes = this.getSnareTimes(notes);
|
|
|
|
// Add kick on beat 1 and 3 if missing
|
|
for (let beat = 0; beat < duration / beatDuration; beat++) {
|
|
const beatTime = beat * beatDuration;
|
|
|
|
if (beat % 4 === 0 || beat % 4 === 2) { // Beats 1 and 3
|
|
if (!kickTimes.some(t => Math.abs(t - beatTime) < beatDuration * 0.2)) {
|
|
enhanced.push({
|
|
channel: channelId,
|
|
midiNote: 36, // Low pitch = low frequency noise (kick)
|
|
startTime: beatTime,
|
|
duration: 0.15,
|
|
velocity: 100,
|
|
});
|
|
}
|
|
}
|
|
|
|
if (beat % 4 === 1 || beat % 4 === 3) { // Beats 2 and 4
|
|
if (!snareTimes.some(t => Math.abs(t - beatTime) < beatDuration * 0.2)) {
|
|
enhanced.push({
|
|
channel: channelId,
|
|
midiNote: 48, // Mid pitch (snare)
|
|
startTime: beatTime,
|
|
duration: 0.1,
|
|
velocity: 90,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return enhanced.sort((a, b) => a.startTime - b.startTime);
|
|
}
|
|
|
|
/**
|
|
* Get times of kick-like notes.
|
|
*/
|
|
private getKickTimes(notes: ChannelNote[]): number[] {
|
|
return notes
|
|
.filter(n => n.midiNote < 45 && n.velocity > 80)
|
|
.map(n => n.startTime);
|
|
}
|
|
|
|
/**
|
|
* Get times of snare-like notes.
|
|
*/
|
|
private getSnareTimes(notes: ChannelNote[]): number[] {
|
|
return notes
|
|
.filter(n => n.midiNote >= 45 && n.midiNote < 55 && n.velocity > 70)
|
|
.map(n => n.startTime);
|
|
}
|
|
|
|
/**
|
|
* Double the melody an octave up on a spare channel.
|
|
*/
|
|
private doubleMelody(
|
|
leadNotes: ChannelNote[],
|
|
targetChannel: ChannelId
|
|
): ChannelNote[] {
|
|
return leadNotes.map(note => ({
|
|
...note,
|
|
channel: targetChannel,
|
|
midiNote: note.midiNote + 12, // Octave up
|
|
velocity: Math.round(note.velocity * 0.5), // Quieter
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Find the lead channel (highest average pitch, most notes).
|
|
*/
|
|
private findLeadChannel(
|
|
byChannel: Map<ChannelId, ChannelNote[]>,
|
|
candidates: ChannelId[]
|
|
): ChannelId | null {
|
|
let bestChannel: ChannelId | null = null;
|
|
let bestScore = 0;
|
|
|
|
for (const channelId of candidates) {
|
|
const notes = byChannel.get(channelId);
|
|
if (!notes || notes.length === 0) continue;
|
|
|
|
const avgPitch = notes.reduce((sum, n) => sum + n.midiNote, 0) / notes.length;
|
|
const noteCount = notes.length;
|
|
|
|
// Score = high pitch + many notes
|
|
const score = avgPitch / 127 * 0.5 + Math.min(1, noteCount / 100) * 0.5;
|
|
|
|
if (score > bestScore) {
|
|
bestScore = score;
|
|
bestChannel = channelId;
|
|
}
|
|
}
|
|
|
|
return bestChannel;
|
|
}
|
|
|
|
/**
|
|
* Fill gaps in a channel with sustain notes or echoes.
|
|
*/
|
|
private fillGaps(
|
|
notes: ChannelNote[],
|
|
duration: number,
|
|
channelId: ChannelId
|
|
): ChannelNote[] {
|
|
if (notes.length === 0) return notes;
|
|
|
|
const enhanced = [...notes];
|
|
const minGap = this.config.minGapToFill;
|
|
|
|
// Find gaps
|
|
for (let i = 0; i < notes.length - 1; i++) {
|
|
const current = notes[i];
|
|
const next = notes[i + 1];
|
|
const gapStart = current.startTime + current.duration;
|
|
const gapDuration = next.startTime - gapStart;
|
|
|
|
if (gapDuration > minGap) {
|
|
// Add an echo/sustain note in the gap
|
|
enhanced.push({
|
|
channel: channelId,
|
|
midiNote: current.midiNote,
|
|
startTime: gapStart + 0.1,
|
|
duration: Math.min(gapDuration - 0.2, 0.3),
|
|
velocity: Math.round(current.velocity * 0.4), // Quiet echo
|
|
});
|
|
}
|
|
}
|
|
|
|
// Check gap at the end
|
|
const lastNote = notes[notes.length - 1];
|
|
const endGap = duration - (lastNote.startTime + lastNote.duration);
|
|
if (endGap > minGap * 2) {
|
|
enhanced.push({
|
|
channel: channelId,
|
|
midiNote: lastNote.midiNote,
|
|
startTime: lastNote.startTime + lastNote.duration + 0.1,
|
|
duration: 0.3,
|
|
velocity: Math.round(lastNote.velocity * 0.3),
|
|
});
|
|
}
|
|
|
|
return enhanced.sort((a, b) => a.startTime - b.startTime);
|
|
}
|
|
|
|
/**
|
|
* Calculate channel utilization (0-1 for each channel).
|
|
*/
|
|
private calculateUtilization(
|
|
byChannel: Map<ChannelId, ChannelNote[]>,
|
|
duration: number
|
|
): Record<ChannelId, number> {
|
|
const utilization: Record<string, number> = {};
|
|
|
|
for (const [channelId, notes] of byChannel) {
|
|
const totalNoteTime = notes.reduce((sum, n) => sum + n.duration, 0);
|
|
utilization[channelId] = Math.min(1, totalNoteTime / duration);
|
|
}
|
|
|
|
return utilization as Record<ChannelId, number>;
|
|
}
|
|
}
|