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,333 @@
|
||||
/**
|
||||
* Game Boy Player
|
||||
*
|
||||
* Main entry point for the v2 synthesis engine.
|
||||
* Orchestrates MIDI parsing, track analysis, channel mapping,
|
||||
* arpeggiator, and APU scheduling.
|
||||
*/
|
||||
|
||||
import { Midi } from '@tonejs/midi';
|
||||
import { GameBoyAPU } from '../audio/apu/APU';
|
||||
import { ChannelMapper } from '../audio/midi/ChannelMapper';
|
||||
import { Arpeggiator } from '../audio/midi/Arpeggiator';
|
||||
import { GameBoyArranger, type ArrangerConfig } from '../audio/arranger/GameBoyArranger';
|
||||
import type { MIDITrack, MIDINote } from '../audio/midi/TrackAnalyzer';
|
||||
import type {
|
||||
ChannelNote,
|
||||
ChannelAssignment,
|
||||
PlaybackInfo,
|
||||
ArpNote,
|
||||
V2Config
|
||||
} from '../types';
|
||||
|
||||
export interface GameBoyPlayerConfig extends Partial<V2Config> {
|
||||
/** Whether to auto-resume audio context on play */
|
||||
autoResume: boolean;
|
||||
|
||||
/** Default BPM if not detected from MIDI */
|
||||
defaultBPM: number;
|
||||
|
||||
/** Enable the arranger for fuller sound */
|
||||
enableArranger: boolean;
|
||||
|
||||
/** Arranger configuration */
|
||||
arrangerConfig: Partial<ArrangerConfig>;
|
||||
}
|
||||
|
||||
const DEFAULT_PLAYER_CONFIG: GameBoyPlayerConfig = {
|
||||
autoResume: true,
|
||||
defaultBPM: 120,
|
||||
masterVolume: 0.7,
|
||||
enableArranger: true, // ON by default for fuller sound
|
||||
arrangerConfig: {},
|
||||
};
|
||||
|
||||
export class GameBoyPlayer {
|
||||
private apu: GameBoyAPU;
|
||||
private mapper: ChannelMapper;
|
||||
private arpeggiator: Arpeggiator;
|
||||
private arranger: GameBoyArranger;
|
||||
private config: GameBoyPlayerConfig;
|
||||
|
||||
private isPlaying: boolean = false;
|
||||
private currentPlaybackInfo: PlaybackInfo | null = null;
|
||||
private playbackStartTime: number = 0;
|
||||
|
||||
constructor(config: Partial<GameBoyPlayerConfig> = {}) {
|
||||
this.config = { ...DEFAULT_PLAYER_CONFIG, ...config };
|
||||
this.apu = new GameBoyAPU(undefined, this.config);
|
||||
this.mapper = new ChannelMapper();
|
||||
this.arpeggiator = new Arpeggiator({ bpm: this.config.defaultBPM });
|
||||
this.arranger = new GameBoyArranger(this.config.arrangerConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the APU instance for direct channel control.
|
||||
*/
|
||||
getAPU(): GameBoyAPU {
|
||||
return this.apu;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and play a MIDI file.
|
||||
*
|
||||
* @param midiData - MIDI file as ArrayBuffer
|
||||
* @returns Playback information
|
||||
*/
|
||||
async playMIDI(midiData: ArrayBuffer): Promise<PlaybackInfo> {
|
||||
// Resume audio context if needed
|
||||
if (this.config.autoResume) {
|
||||
await this.apu.resume();
|
||||
}
|
||||
|
||||
// Stop any current playback
|
||||
if (this.isPlaying) {
|
||||
this.stop();
|
||||
}
|
||||
|
||||
// Parse MIDI file
|
||||
const midi = new Midi(midiData);
|
||||
const bpm = midi.header.tempos[0]?.bpm || this.config.defaultBPM;
|
||||
|
||||
// Update arpeggiator and arranger BPM
|
||||
this.arpeggiator.setBPM(bpm);
|
||||
this.arranger.setBPM(bpm);
|
||||
|
||||
// Convert to our track format
|
||||
const tracks = this.convertMIDITracks(midi);
|
||||
|
||||
// Analyze and map tracks to channels
|
||||
const assignments = this.mapper.mapTracks(tracks);
|
||||
|
||||
// Convert to scheduled notes
|
||||
let gbNotes = this.convertToGBNotes(tracks, assignments);
|
||||
|
||||
// Apply arranger for fuller sound (if enabled)
|
||||
let arrangerStats = null;
|
||||
if (this.config.enableArranger) {
|
||||
const result = this.arranger.arrange(gbNotes, assignments, midi.duration);
|
||||
gbNotes = result.notes;
|
||||
arrangerStats = result.stats;
|
||||
console.log(`Arranger: Added ${result.stats.addedNotes} notes (${result.stats.originalNotes} → ${gbNotes.length})`);
|
||||
}
|
||||
|
||||
// Get current time for scheduling
|
||||
const startTime = this.apu.getCurrentTime() + 0.1; // Small lookahead
|
||||
this.playbackStartTime = startTime;
|
||||
|
||||
// Schedule all notes
|
||||
for (const note of gbNotes) {
|
||||
this.apu.scheduleNote({
|
||||
...note,
|
||||
startTime: startTime + note.startTime,
|
||||
});
|
||||
}
|
||||
|
||||
this.isPlaying = true;
|
||||
|
||||
// Create playback info
|
||||
this.currentPlaybackInfo = {
|
||||
duration: midi.duration,
|
||||
assignments,
|
||||
noteCount: gbNotes.length,
|
||||
};
|
||||
|
||||
console.log(`Playing MIDI: ${gbNotes.length} notes, ${assignments.length} channels, ${midi.duration.toFixed(1)}s duration`);
|
||||
|
||||
// Schedule auto-stop
|
||||
const stopDelay = (midi.duration + 1) * 1000;
|
||||
setTimeout(() => {
|
||||
if (this.isPlaying && this.playbackStartTime === startTime) {
|
||||
this.isPlaying = false;
|
||||
}
|
||||
}, stopDelay);
|
||||
|
||||
return this.currentPlaybackInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert tonejs/midi tracks to our format.
|
||||
*/
|
||||
private convertMIDITracks(midi: InstanceType<typeof Midi>): MIDITrack[] {
|
||||
return midi.tracks.map((track, index) => ({
|
||||
channel: track.channel,
|
||||
name: track.name,
|
||||
notes: track.notes.map(note => ({
|
||||
midi: note.midi,
|
||||
time: note.time,
|
||||
duration: note.duration,
|
||||
velocity: Math.round(note.velocity * 127),
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert MIDI notes to GB channel notes using assignments.
|
||||
*/
|
||||
private convertToGBNotes(
|
||||
tracks: MIDITrack[],
|
||||
assignments: ChannelAssignment[]
|
||||
): ChannelNote[] {
|
||||
const gbNotes: ChannelNote[] = [];
|
||||
|
||||
for (const assignment of assignments) {
|
||||
const track = tracks[assignment.trackIndex];
|
||||
if (!track || track.notes.length === 0) continue;
|
||||
|
||||
// Apply channel-specific settings
|
||||
this.applyChannelSettings(assignment);
|
||||
|
||||
// Get notes from track
|
||||
let notes: ArpNote[] = track.notes.map(n => ({
|
||||
midiNote: n.midi,
|
||||
time: n.time,
|
||||
duration: n.duration,
|
||||
velocity: n.velocity,
|
||||
}));
|
||||
|
||||
// Apply arpeggiator if needed
|
||||
if (assignment.shouldArpeggiate) {
|
||||
notes = this.arpeggiator.arpeggiate(notes);
|
||||
}
|
||||
|
||||
// Convert to ChannelNote format
|
||||
for (const note of notes) {
|
||||
gbNotes.push({
|
||||
channel: assignment.channelId,
|
||||
midiNote: note.midiNote,
|
||||
startTime: note.time,
|
||||
duration: note.duration,
|
||||
velocity: note.velocity,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by start time for efficient scheduling
|
||||
return gbNotes.sort((a, b) => a.startTime - b.startTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply channel-specific settings from assignment.
|
||||
*/
|
||||
private applyChannelSettings(assignment: ChannelAssignment): void {
|
||||
const { channelId, dutyCycle, wavePreset, noiseMode } = assignment;
|
||||
|
||||
if (channelId.startsWith('p') && dutyCycle !== undefined) {
|
||||
this.apu.setPulseDuty(channelId as any, dutyCycle);
|
||||
}
|
||||
|
||||
if (channelId.startsWith('w') && wavePreset) {
|
||||
this.apu.setWavePreset(channelId as any, wavePreset);
|
||||
}
|
||||
|
||||
if (channelId.startsWith('n') && noiseMode) {
|
||||
this.apu.setNoiseMode(channelId as any, noiseMode);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop playback.
|
||||
*/
|
||||
stop(): void {
|
||||
this.isPlaying = false;
|
||||
this.apu.reset();
|
||||
console.log('Playback stopped');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if currently playing.
|
||||
*/
|
||||
getIsPlaying(): boolean {
|
||||
return this.isPlaying;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current playback info.
|
||||
*/
|
||||
getPlaybackInfo(): PlaybackInfo | null {
|
||||
return this.currentPlaybackInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get elapsed playback time in seconds.
|
||||
*/
|
||||
getElapsedTime(): number {
|
||||
if (!this.isPlaying) return 0;
|
||||
return this.apu.getCurrentTime() - this.playbackStartTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set master volume.
|
||||
*/
|
||||
setVolume(volume: number): void {
|
||||
this.apu.setMasterVolume(volume);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get master volume.
|
||||
*/
|
||||
getVolume(): number {
|
||||
return this.apu.getMasterVolume();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume audio context (required after user interaction in most browsers).
|
||||
*/
|
||||
async resume(): Promise<void> {
|
||||
await this.apu.resume();
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable or disable the arranger.
|
||||
*/
|
||||
setArrangerEnabled(enabled: boolean): void {
|
||||
this.config.enableArranger = enabled;
|
||||
console.log(`Arranger ${enabled ? 'enabled' : 'disabled'}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if arranger is enabled.
|
||||
*/
|
||||
isArrangerEnabled(): boolean {
|
||||
return this.config.enableArranger ?? true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the arranger instance for configuration.
|
||||
*/
|
||||
getArranger(): GameBoyArranger {
|
||||
return this.arranger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse MIDI without playing (for analysis/preview).
|
||||
*/
|
||||
analyzeMIDI(midiData: ArrayBuffer): {
|
||||
duration: number;
|
||||
trackCount: number;
|
||||
noteCount: number;
|
||||
bpm: number;
|
||||
assignments: ChannelAssignment[];
|
||||
} {
|
||||
const midi = new Midi(midiData);
|
||||
const tracks = this.convertMIDITracks(midi);
|
||||
const assignments = this.mapper.mapTracks(tracks);
|
||||
|
||||
return {
|
||||
duration: midi.duration,
|
||||
trackCount: midi.tracks.length,
|
||||
noteCount: midi.tracks.reduce((sum, t) => sum + t.notes.length, 0),
|
||||
bpm: midi.header.tempos[0]?.bpm || this.config.defaultBPM,
|
||||
assignments,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detailed track analysis.
|
||||
*/
|
||||
getTrackAnalysis(midiData: ArrayBuffer) {
|
||||
const midi = new Midi(midiData);
|
||||
const tracks = this.convertMIDITracks(midi);
|
||||
return this.mapper.analyzeTracks(tracks);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user