diff --git a/Phase 2 Implementation v2.0.md b/Phase 2 Implementation v2.0.md new file mode 100644 index 0000000..12abd85 --- /dev/null +++ b/Phase 2 Implementation v2.0.md @@ -0,0 +1,603 @@ +# Wario Synth v2: Phase 2 Implementation Plan + +**Project:** Wario Synthesis Engine v2 +**Owner:** Andy @ birdmania +**Status:** Implementation Phase +**Target:** Game Boy-authentic MIDI conversion with enhanced polyphony +**Repository:** https://github.com/b1rdmania/motif + +--- + +## Project Overview + +Build a Game Boy-authentic synthesis engine (v2) that captures the true DMG-CPU sound chip character while preserving v1's "any MIDI works" philosophy. The v2 engine will be hosted separately and won't intrude on v1's functionality. + +**Key Architecture Decision:** 8-channel "Super Game Boy" setup (4 pulse, 2 wave, 2 noise) instead of authentic 4-channel limitation. This preserves GB sound character while handling complex MIDI files gracefully. + +--- + +## Critical Constraint: v1 Isolation + +**v1 MUST remain completely untouched and functional throughout v2 development.** + +### What stays UNTOUCHED (v1): + +- `src/synthesis/SynthesisEngine.ts` - current engine, no changes +- `src/core/MotifEngine.ts` - current orchestration, no changes +- All existing files in `src/` - completely untouched +- `index.html`, `play.html`, `embed.html` - no modifications +- Live site at wario.style continues to work exactly as today + +### What gets CREATED (v2): + +- Brand new directory: `src-v2/` - entirely separate codebase +- New entry point: `v2.html` - doesn't touch existing HTML files +- Separate Vite build config if needed +- Could become separate repo later if desired + +### What v2 CAN import (read-only): + +- `src/midi/MIDIParser.ts` - reuse existing MIDI parsing +- Shared types from `src/types/index.ts` if compatible +- Nothing else - all synthesis code is fresh + +### Benefits of this approach: + +1. Develop v2 while v1 stays live and stable +2. If v2 has issues, v1 is completely unaffected +3. A/B test with simple URL switch (`/` vs `/v2`) +4. Rollback is trivial - just don't deploy v2 code +5. Can eventually merge or keep separate forever + +--- + +## Repository Structure + +``` +/Users/andy/MOTIF/ +├── src/ # v1 (current, unchanged) +│ ├── core/MotifEngine.ts +│ ├── synthesis/SynthesisEngine.ts +│ └── ... +├── src-v2/ # v2 (NEW - separate tree) +│ ├── audio/ +│ │ ├── apu/ +│ │ │ ├── APU.ts # Main 8-channel coordinator +│ │ │ ├── PulseChannel.ts # GB pulse with duty cycles +│ │ │ ├── WaveChannel.ts # 4-bit wavetable +│ │ │ ├── NoiseChannel.ts # LFSR noise +│ │ │ └── Mixer.ts # Stereo mixing +│ │ ├── synthesis/ +│ │ │ ├── DutyCycle.ts # 4 GB duty patterns +│ │ │ ├── LFSR.ts # Noise generator +│ │ │ ├── WaveTable.ts # 4-bit quantization +│ │ │ └── FrequencyCalc.ts # GB frequency formulas +│ │ └── midi/ +│ │ ├── TrackAnalyzer.ts # Analyze MIDI structure +│ │ ├── ChannelMapper.ts # Assign tracks to 8 channels +│ │ └── Arpeggiator.ts # Convert chords to arps +│ ├── core/ +│ │ └── GameBoyPlayer.ts # Main v2 entry point +│ └── types/ +│ └── index.ts # v2-specific types +├── public/ +│ └── v2/ # v2 UI assets +└── v2.html # v2 demo page +``` + +--- + +## Phase 1: Core Sound Engine (Week 1-2) + +**Goal:** Implement authentic GB sound generation with Web Audio API + +### 1.1 Duty Cycle Implementation + +Create `src-v2/audio/synthesis/DutyCycle.ts`: + +- Define 4 GB duty patterns (12.5%, 25%, 50%, 75%) +- Convert patterns to `PeriodicWave` for Web Audio +- Pattern format: 8-step arrays `[0,0,0,0,0,0,0,1]` etc + +Create `src-v2/audio/apu/PulseChannel.ts`: + +- Pre-create all 4 duty waveforms on init +- `playNote(midiNote, duration, velocity)` method +- Use GB frequency formula (not standard MIDI) +- Simple envelope (fast attack, quick release) +- Return `{ osc, gain }` for cleanup + +**Success Criteria:** Each duty cycle sounds distinctly different when tested + +### 1.2 GB Frequency Formulas + +Create `src-v2/audio/synthesis/FrequencyCalc.ts`: + +- `calculatePulseFrequency(midiNote)` → Hz + - Formula: `131072 / (2048 - registerValue)` + - Convert MIDI → standard freq → register → GB freq +- `calculateWaveFrequency(midiNote)` → Hz + - Formula: `65536 / (2048 - registerValue)` +- `calculateNoiseFrequency(pitch, mode)` → Hz + - Formula: `524288 / divisor / 2^(shift+1)` + +**Success Criteria:** Frequencies are slightly "off" from standard tuning (GB characteristic) + +### 1.3 LFSR Noise Generator + +Create `src-v2/audio/synthesis/LFSR.ts`: + +- Implement 15-bit LFSR (default mode) +- Implement 7-bit LFSR (tonal mode) +- `clock()` method returns 0 or 1 +- XOR bits 0 and 1, shift right, set bit 14 + +Create `src-v2/audio/apu/NoiseChannel.ts`: + +- `playNoise(duration, frequency, velocity)` method +- Generate buffer with LFSR output +- Clock LFSR at calculated rate +- Apply envelope to buffer playback + +**Success Criteria:** Noise sounds crunchy/metallic, not smooth white noise + +### 1.4 Wave Channel with 4-bit Quantization + +Create `src-v2/audio/synthesis/WaveTable.ts`: + +- Store 32 samples × 4-bit (0-15 values) +- `quantize(value)` rounds to 4-bit +- Presets: `generateBass()`, `generatePad()`, `generateLead()` +- `createBuffer(audioContext)` converts to AudioBuffer + +Create `src-v2/audio/apu/WaveChannel.ts`: + +- Load wavetable on construction +- `playNote(midiNote, duration, velocity)` method +- Use `BufferSource` with looping +- Set playback rate for pitch +- Volume levels: 0, 100%, 50%, 25% (bit-shift style) + +**Success Criteria:** Wave channel has audible digital "staircase" effect + +### 1.5 Testing Phase 1 + +Create `src-v2/audio/test/soundTest.ts`: + +- Test all 4 duty cycles sequentially +- Test wave channel with bass preset +- Test both noise modes (7-bit and 15-bit) +- Play test sequence: duty sweeps, bass note, drum hits + +--- + +## Phase 2: Channel Manager & 8-Channel APU (Week 2-3) + +**Goal:** Coordinate 8 independent GB channels with mixing + +### 2.1 APU Coordinator + +Create `src-v2/audio/apu/APU.ts`: + +- Initialize 4 pulse, 2 wave, 2 noise channels +- Channel IDs: `p1-p4`, `w1-w2`, `n1-n2` +- Master gain connected to destination +- Per-channel gain nodes for mixing +- `scheduleNote(note: ChannelNote)` routes to appropriate channel +- Track which channels are busy (`channelBusy` map) +- `isChannelFree(channelId, atTime)` for voice allocation + +**Key Methods:** + +- `scheduleNote({ channel, midiNote, startTime, duration, velocity })` +- `schedulePulseNote()`, `scheduleWaveNote()`, `scheduleNoiseNote()` +- `setChannelPan(channelId, pan)` for stereo + +### 2.2 Channel Gain & Mixing + +In `src-v2/audio/apu/APU.ts`: + +- Each channel connects to individual `GainNode` +- Individual gains connect to master gain +- Master gain at ~0.7 to prevent clipping +- Per-channel volumes match role importance + +**Success Criteria:** Can play 8 simultaneous notes without clipping + +### 2.3 Integration Test + +Create `src-v2/audio/test/apuTest.ts`: + +- Schedule notes on all 8 channels simultaneously +- Verify no audio glitches or pops +- Test channel busy/free logic +- Test master volume control + +--- + +## Phase 3: MIDI Intelligence Layer (Week 3-4) + +**Goal:** Smart track analysis and channel assignment for arbitrary MIDIs + +### 3.1 Track Analyzer + +Create `src-v2/audio/midi/TrackAnalyzer.ts`: + +- `analyzeTrack(track)` returns `TrackAnalysis` +- Detect drums (channel 9 or percussive patterns) +- Calculate note range (min, max, avg pitch) +- Calculate note density (notes per second) +- Detect chords (simultaneous notes) +- Assign role: `drums`, `bass`, `lead`, `harmony`, `pad`, `fx` + +**Analysis Logic:** + +- Drums: channel 9 OR very short notes with low pitch variation +- Bass: average pitch < 48 (C3) +- Lead: high pitch (>72) with high density (>5 notes/sec) +- Pad: low density (<2 notes/sec), long notes +- Harmony: medium density with detected chords +- FX: very high density (>10 notes/sec) + +### 3.2 Arpeggiator + +Create `src-v2/audio/midi/Arpeggiator.ts`: + +- `arpeggiate(notes, speed)` converts chords to fast note sequences +- Group notes by time (10ms tolerance) +- Single notes pass through unchanged +- Chords (2+ simultaneous notes) → fast arpeggio +- Default speed: 1/64 note +- Sort chord notes low-to-high +- Cycle through chord notes for full duration + +**Success Criteria:** 3-note chord becomes smooth fast arpeggio + +### 3.3 Channel Mapper + +Create `src-v2/audio/midi/ChannelMapper.ts`: + +- `mapTracks(midiTracks)` returns array of `ChannelAssignment` +- Analyze all tracks first +- Sort by priority (drums > bass > lead > harmony) +- Assign intelligently: + - Drums → `n1`, `n2` (noise channels) + - Bass → `w1` (wave bass preset) + - Pads → `w2` (wave pad preset) + - Lead → `p1`, `p2` (pulse with sweep, 50% duty) + - Harmony → `p3`, `p4` (pulse, 25% duty, arpeggiated) +- Mark which tracks need arpeggiator +- Specify duty cycle per assignment + +**Priority Calculation:** + +- Drums +30 points +- Bass +25 points +- Lead +20 points +- Note density +up to 20 points +- Velocity +up to 10 points + +**Success Criteria:** Mario theme maps melody to pulse, no bass assigned (no bass in song) + +### 3.4 Integration Test + +Create `src-v2/audio/test/mapperTest.ts`: + +- Load test MIDI (simple melody + bass + drums) +- Run through analyzer and mapper +- Verify drum tracks → noise channels +- Verify bass → wave channel +- Verify melody → pulse channel +- Print channel assignments for inspection + +--- + +## Phase 4: Main Player & Integration (Week 4) + +**Goal:** Complete end-to-end MIDI → GB audio pipeline + +### 4.1 Game Boy Player + +Create `src-v2/core/GameBoyPlayer.ts`: + +- Main entry point for v2 engine +- `async playMIDI(midiBuffer: ArrayBuffer)` +- Parse MIDI using existing `src/midi/MIDIParser.ts` +- Analyze tracks → assign channels → convert to GB notes +- Schedule all notes in APU +- `stop()` method resets APU +- Return playback info (duration, assignments for UI) + +**Pipeline:** + +1. Parse MIDI → `NoteEvent[]` +2. Analyze tracks → `TrackAnalysis[]` +3. Map to channels → `ChannelAssignment[]` +4. Apply arpeggiator where needed +5. Convert to `ChannelNote[]` format +6. Schedule in APU + +### 4.2 V2 Types + +Create `src-v2/types/index.ts`: + +- `ChannelNote` interface +- `ChannelAssignment` interface +- `TrackAnalysis` interface +- `ArpNote` interface +- GB-specific config types + +### 4.3 Demo Page + +Create `v2.html`: + +- Simple test UI for v2 engine +- File upload input for MIDI +- Play/stop buttons +- Volume slider +- Display channel assignments +- Show which channels are active (visual) + +Create `src-v2/main.ts`: + +- Wire up UI to `GameBoyPlayer` +- Handle file uploads +- Display playback state +- Show assignment information + +### 4.4 Integration Test + +Test with reference MIDIs: + +- **Mario theme:** Simple melody (should use p1) +- **Tetris theme:** Bass + lead (should use w1 + p1) +- **Pokémon theme:** Chords (should arpeggiate to p3/p4) +- **Hotel California:** Complex (use all 8 channels) + +**Success Criteria:** + +- All test MIDIs sound recognizable +- No audio glitches or pops +- Drums sound punchy (noise) +- Bass sounds solid (wave) +- Melody is clear (pulse) +- Chords arpeggiate smoothly + +--- + +## Phase 5: Polish & Optimization (Week 5) + +### 5.1 Performance Optimization + +In `src-v2/audio/apu/APU.ts`: + +- Limit simultaneous notes to 32 total +- Implement voice stealing (oldest note first) +- Add `onended` cleanup for oscillators +- Pre-create reusable nodes where possible + +### 5.2 Browser Compatibility + +In `src-v2/core/GameBoyPlayer.ts`: + +- Add AudioContext resume on user interaction +- Handle Safari audio quirks +- Add mobile audio unlock +- Test on Chrome, Firefox, Safari + +### 5.3 Advanced Features (Nice-to-Have) + +- **Stereo Panning:** GB-style hard L/R/center per channel +- **Duty Cycle Switching:** Change duty mid-playback for variation +- **Custom Wavetables:** User-editable wave presets +- **Export to WAV:** Offline rendering to downloadable file + +--- + +## Phase 6: Deployment Strategy + +### 6.1 Alpha Testing (Week 6) + +- Deploy v2 to staging URL (e.g., `v2.wario.style` or `wario.style/beta`) +- Keep v1 at main URL unchanged +- Test with small group (5-10 people) +- Gather feedback on authenticity +- Fix critical bugs + +### 6.2 Beta Release (Week 7) + +- Deploy to production behind feature flag +- Add "Try v2 Beta" button on main site +- A/B test user preferences (v1 vs v2) +- Monitor performance metrics +- Gradual rollout: 10% → 50% → 100% + +### 6.3 Full Release (Week 8) + +- Make v2 the default engine +- Keep v1 available as "Classic Mode" +- Update README and docs +- Social media announcement +- Monitor error rates and feedback + +**Rollback Plan:** + +- Feature flag can instantly revert to v1 +- Maintain "problematic MIDI" database +- User preference saved in localStorage + +--- + +## Technical Notes + +### Web Audio Implementation + +**Key Web Audio APIs:** + +- `PeriodicWave` for duty cycles +- `OscillatorNode` for pulse channels +- `AudioBufferSourceNode` for wave/noise +- `GainNode` for volume/envelopes +- `audioContext.currentTime` for precise scheduling + +**Memory Management:** + +```typescript +source.onended = () => { + source.disconnect() + gain.disconnect() +} +``` + +**Latency Target:** <50ms from schedule to sound + +### What Matters vs What Doesn't + +**✅ CRITICAL (Implement):** + +- Exact duty cycle patterns +- LFSR noise generation +- 4-bit wave quantization +- GB frequency formulas +- Fast arpeggios +- Simple envelopes + +**❌ SKIP (Emulator minutiae):** + +- Length counter edge cases +- DIV-APU timing sync +- Wave RAM corruption bugs +- Sweep overflow quirks +- DAC pop suppression +- High-pass filter modeling + +--- + +## Testing Strategy + +Testing follows a layered approach - technical specs for implementation correctness, community feedback for authenticity. + +### Layer 1: Technical Sanity Checks (Automated, Quick) + +Quick checks that catch implementation bugs: + +- **Waveform visualization** - view duty cycles in browser dev tools or canvas oscilloscope +- **LFSR sequence verification** - first 20 values match known GB sequence +- **Frequency spot-check** - play A4 (440Hz), verify it's slightly off (~438.5Hz due to GB register rounding) + +Setup time: ~30 minutes. Run on every build. + +### Layer 2: Reference MIDI Corpus (Manual, Essential) + +Core QA loop with 5 test MIDIs: + +| MIDI | What it tests | +|------|---------------| +| Mario Bros theme | Simple melody on pulse channels | +| Tetris theme | Bass + lead separation | +| Pokemon battle music | Chord arpeggiation | +| Any pop song with drums | Noise channel percussion | +| Hotel California | Complex multi-track mapping | + +Process: Run each through v2, listen with headphones, note what sounds wrong. + +### Layer 3: Community Vibe Check (Subjective, Final) + +Post short clips to: + +- r/chiptunes subreddit +- Chiptune Café Discord + +Ask: "Does this sound like a Game Boy?" + +Real chiptune people will identify specific issues ("duty cycles wrong", "noise too clean", etc.) + +### What to Skip + +- Automated audio comparison (too complex, diminishing returns) +- Cycle-accurate timing tests (emulator territory, not our goal) +- Formal A/B studies (overkill for this project) + +### Success Metrics + +- All Layer 1 checks pass +- All 5 reference MIDIs sound recognizable +- Community feedback: "yes, sounds like GB" +- Zero audio glitches or pops +- Works on Chrome, Firefox, Safari +- <100ms latency + +--- + +## File Checklist + +### Core Sound Engine (Phase 1) + +- [ ] `src-v2/audio/synthesis/DutyCycle.ts` +- [ ] `src-v2/audio/synthesis/FrequencyCalc.ts` +- [ ] `src-v2/audio/synthesis/LFSR.ts` +- [ ] `src-v2/audio/synthesis/WaveTable.ts` +- [ ] `src-v2/audio/apu/PulseChannel.ts` +- [ ] `src-v2/audio/apu/WaveChannel.ts` +- [ ] `src-v2/audio/apu/NoiseChannel.ts` +- [ ] `src-v2/audio/test/soundTest.ts` + +### APU & Mixing (Phase 2) + +- [ ] `src-v2/audio/apu/APU.ts` +- [ ] `src-v2/audio/apu/Mixer.ts` +- [ ] `src-v2/audio/test/apuTest.ts` + +### MIDI Intelligence (Phase 3) + +- [ ] `src-v2/audio/midi/TrackAnalyzer.ts` +- [ ] `src-v2/audio/midi/Arpeggiator.ts` +- [ ] `src-v2/audio/midi/ChannelMapper.ts` +- [ ] `src-v2/audio/test/mapperTest.ts` + +### Integration (Phase 4) + +- [ ] `src-v2/core/GameBoyPlayer.ts` +- [ ] `src-v2/types/index.ts` +- [ ] `src-v2/main.ts` +- [ ] `v2.html` + +### Documentation + +- [ ] `docs/GB_SOUND_SPECS.md` (technical reference) +- [ ] `docs/V2_ARCHITECTURE.md` (system overview) +- [ ] `CHANGELOG_V2.md` (version history) + +--- + +## Future Enhancements (v3+) + +**Short Term:** + +- User-adjustable duty cycles via UI +- Custom wavetable editor +- Real-time parameter tweaking +- Oscilloscope visualizer +- MIDI file upload (not just search) + +**Medium Term:** + +- Frequency sweep on pulse channels +- Vibrato effects +- Echo/delay using note repeats +- Better envelope shaping (ADSR editor) +- Recording/export to WAV + +**Long Term:** + +- Full tracker-style sequencer +- Multiple retro chips (NES APU, C64 SID) +- VST plugin version +- Mobile app with touch controls +- Collaborative editing + +--- + +**Document Version:** 2.0 +**Last Updated:** January 2026 +**Status:** Ready for implementation diff --git a/src-v2/audio/apu/APU.ts b/src-v2/audio/apu/APU.ts new file mode 100644 index 0000000..d05336b --- /dev/null +++ b/src-v2/audio/apu/APU.ts @@ -0,0 +1,434 @@ +/** + * Game Boy APU (Audio Processing Unit) Coordinator + * + * This is the main audio engine for v2, coordinating 8 channels: + * - 4 Pulse channels (p1-p4) with duty cycle control + * - 2 Wave channels (w1-w2) with custom wavetables + * - 2 Noise channels (n1-n2) with LFSR noise + * + * This "Super Game Boy" configuration allows handling complex MIDIs + * while maintaining authentic GB sound character. + */ + +import { PulseChannel } from './PulseChannel'; +import { WaveChannel } from './WaveChannel'; +import { NoiseChannel } from './NoiseChannel'; +import { GameBoyColorizer, type ColorizerConfig } from '../effects/GameBoyColorizer'; +import { + DEFAULT_V2_CONFIG, + type ChannelId, + type ChannelNote, + type ChannelState, + type PulseChannelId, + type WaveChannelId, + type NoiseChannelId, + type V2Config, +} from '../../types'; +import type { DutyIndex } from '../synthesis/DutyCycle'; +import type { WavePreset } from '../synthesis/WaveTable'; +import type { LFSRMode } from '../synthesis/LFSR'; + +/** + * Channel configuration for the 8-channel setup + */ +const CHANNEL_CONFIG = { + pulse: [ + { id: 'p1' as const, hasSweep: true, defaultDuty: 2 as DutyIndex }, + { id: 'p2' as const, hasSweep: true, defaultDuty: 2 as DutyIndex }, + { id: 'p3' as const, hasSweep: false, defaultDuty: 1 as DutyIndex }, + { id: 'p4' as const, hasSweep: false, defaultDuty: 1 as DutyIndex }, + ], + wave: [ + { id: 'w1' as const, preset: 'bass' as WavePreset }, + { id: 'w2' as const, preset: 'pad' as WavePreset }, + ], + noise: [ + { id: 'n1' as const, mode: '7bit' as LFSRMode }, + { id: 'n2' as const, mode: '15bit' as LFSRMode }, + ], +}; + +export class GameBoyAPU { + private audioContext: AudioContext; + private config: V2Config; + + // Master output chain + private masterGain: GainNode; + private colorizer: GameBoyColorizer; + + // Individual channel instances + private pulseChannels: Map = new Map(); + private waveChannels: Map = new Map(); + private noiseChannels: Map = new Map(); + + // Per-channel gain nodes for mixing + private channelGains: Map = new Map(); + + // Channel state tracking + private channelStates: Map = new Map(); + + // Note scheduling stats (no limit - Web Audio handles scheduling) + private scheduledNoteCount = 0; + + constructor(audioContext?: AudioContext, config?: Partial) { + this.audioContext = audioContext || new AudioContext(); + this.config = { ...DEFAULT_V2_CONFIG, ...config }; + + // Create colorizer with DMG preset for authentic sound + this.colorizer = new GameBoyColorizer( + this.audioContext, + GameBoyColorizer.createPreset('dmg') + ); + + // Create master gain + this.masterGain = this.audioContext.createGain(); + this.masterGain.gain.value = this.config.masterVolume; + + // Wire: master -> colorizer -> destination + this.masterGain.connect(this.colorizer.getInput()); + this.colorizer.getOutput().connect(this.audioContext.destination); + + // Initialize all channels + this.initializeChannels(); + } + + /** + * Initialize all 8 channels with their gain nodes. + */ + private initializeChannels(): void { + // Create pulse channels + for (const config of CHANNEL_CONFIG.pulse) { + const gain = this.createChannelGain(config.id, 0.25); + const channel = new PulseChannel(this.audioContext, gain, config.hasSweep); + channel.setDutyCycle(config.defaultDuty); + this.pulseChannels.set(config.id, channel); + this.initChannelState(config.id); + } + + // Create wave channels (higher gain for bass) + for (const config of CHANNEL_CONFIG.wave) { + const gain = this.createChannelGain(config.id, 0.55); // Boosted for bass + const channel = new WaveChannel(this.audioContext, gain, config.preset); + this.waveChannels.set(config.id, channel); + this.initChannelState(config.id); + } + + // Create noise channels + for (const config of CHANNEL_CONFIG.noise) { + const gain = this.createChannelGain(config.id, 0.3); + const channel = new NoiseChannel(this.audioContext, gain, config.mode); + this.noiseChannels.set(config.id, channel); + this.initChannelState(config.id); + } + } + + /** + * Create a gain node for a channel and connect to master. + */ + private createChannelGain(id: ChannelId, defaultGain: number): GainNode { + const gain = this.audioContext.createGain(); + gain.gain.value = defaultGain; + gain.connect(this.masterGain); + this.channelGains.set(id, gain); + return gain; + } + + /** + * Initialize channel state tracking. + */ + private initChannelState(id: ChannelId): void { + this.channelStates.set(id, { + id, + isBusy: false, + busyUntil: 0, + currentGain: this.channelGains.get(id)?.gain.value || 0, + }); + } + + /** + * Get the AudioContext. + */ + getAudioContext(): AudioContext { + return this.audioContext; + } + + /** + * Resume audio context if suspended. + */ + async resume(): Promise { + if (this.audioContext.state === 'suspended') { + await this.audioContext.resume(); + } + } + + /** + * Schedule a note on a specific channel. + * + * Web Audio handles scheduling of future notes efficiently, so we don't + * limit the number of scheduled notes. The browser will automatically + * manage memory for nodes that have finished playing. + */ + scheduleNote(note: ChannelNote): void { + const { channel, midiNote, startTime, duration, velocity } = note; + + if (channel.startsWith('p')) { + this.schedulePulseNote(channel as PulseChannelId, midiNote, duration, velocity, startTime); + } else if (channel.startsWith('w')) { + this.scheduleWaveNote(channel as WaveChannelId, midiNote, duration, velocity, startTime); + } else if (channel.startsWith('n')) { + this.scheduleNoiseNote(channel as NoiseChannelId, midiNote, duration, velocity, startTime); + } + + // Update channel state + this.updateChannelBusy(channel, startTime + duration); + this.scheduledNoteCount++; + } + + /** + * Schedule a pulse channel note. + */ + private schedulePulseNote( + channelId: PulseChannelId, + midiNote: number, + duration: number, + velocity: number, + startTime: number + ): void { + const channel = this.pulseChannels.get(channelId); + if (!channel) return; + + channel.playNote(midiNote, duration, velocity, startTime); + } + + /** + * Schedule a wave channel note. + */ + private scheduleWaveNote( + channelId: WaveChannelId, + midiNote: number, + duration: number, + velocity: number, + startTime: number + ): void { + const channel = this.waveChannels.get(channelId); + if (!channel) return; + + channel.playNote(midiNote, duration, velocity, startTime); + } + + /** + * Schedule a noise channel note. + */ + private scheduleNoiseNote( + channelId: NoiseChannelId, + midiNote: number, + duration: number, + velocity: number, + startTime: number + ): void { + const channel = this.noiseChannels.get(channelId); + if (!channel) return; + + channel.playNote(midiNote, duration, velocity, startTime); + } + + /** + * Update channel busy state. + */ + private updateChannelBusy(channelId: ChannelId, busyUntil: number): void { + const state = this.channelStates.get(channelId); + if (state) { + state.isBusy = true; + state.busyUntil = Math.max(state.busyUntil, busyUntil); + } + } + + /** + * Check if a channel is free at a given time. + */ + isChannelFree(channelId: ChannelId, atTime?: number): boolean { + const time = atTime ?? this.audioContext.currentTime; + const state = this.channelStates.get(channelId); + if (!state) return false; + return time >= state.busyUntil; + } + + /** + * Find a free pulse channel. + */ + findFreePulseChannel(atTime?: number): PulseChannelId | null { + const time = atTime ?? this.audioContext.currentTime; + for (const id of ['p1', 'p2', 'p3', 'p4'] as PulseChannelId[]) { + if (this.isChannelFree(id, time)) { + return id; + } + } + return null; + } + + /** + * Find a free wave channel. + */ + findFreeWaveChannel(atTime?: number): WaveChannelId | null { + const time = atTime ?? this.audioContext.currentTime; + for (const id of ['w1', 'w2'] as WaveChannelId[]) { + if (this.isChannelFree(id, time)) { + return id; + } + } + return null; + } + + /** + * Find a free noise channel. + */ + findFreeNoiseChannel(atTime?: number): NoiseChannelId | null { + const time = atTime ?? this.audioContext.currentTime; + for (const id of ['n1', 'n2'] as NoiseChannelId[]) { + if (this.isChannelFree(id, time)) { + return id; + } + } + return null; + } + + /** + * Set duty cycle for a pulse channel. + */ + setPulseDuty(channelId: PulseChannelId, duty: DutyIndex): void { + const channel = this.pulseChannels.get(channelId); + if (channel) { + channel.setDutyCycle(duty); + } + } + + /** + * Set preset for a wave channel. + */ + setWavePreset(channelId: WaveChannelId, preset: WavePreset): void { + const channel = this.waveChannels.get(channelId); + if (channel) { + channel.loadPreset(preset); + } + } + + /** + * Set mode for a noise channel. + */ + setNoiseMode(channelId: NoiseChannelId, mode: LFSRMode): void { + const channel = this.noiseChannels.get(channelId); + if (channel) { + channel.setMode(mode); + } + } + + /** + * Set individual channel volume. + */ + setChannelVolume(channelId: ChannelId, volume: number): void { + const gain = this.channelGains.get(channelId); + if (gain) { + gain.gain.value = Math.max(0, Math.min(1, volume)); + } + } + + /** + * Set master volume. + */ + setMasterVolume(volume: number): void { + this.masterGain.gain.value = Math.max(0, Math.min(1, volume)); + this.config.masterVolume = volume; + } + + /** + * Get master volume. + */ + getMasterVolume(): number { + return this.config.masterVolume; + } + + /** + * Get a pulse channel instance. + */ + getPulseChannel(id: PulseChannelId): PulseChannel | undefined { + return this.pulseChannels.get(id); + } + + /** + * Get a wave channel instance. + */ + getWaveChannel(id: WaveChannelId): WaveChannel | undefined { + return this.waveChannels.get(id); + } + + /** + * Get a noise channel instance. + */ + getNoiseChannel(id: NoiseChannelId): NoiseChannel | undefined { + return this.noiseChannels.get(id); + } + + /** + * Get all channel states. + */ + getChannelStates(): Map { + return new Map(this.channelStates); + } + + /** + * Get current time from audio context. + */ + getCurrentTime(): number { + return this.audioContext.currentTime; + } + + /** + * Reset all channel states. + */ + reset(): void { + for (const id of this.channelStates.keys()) { + this.initChannelState(id); + } + this.scheduledNoteCount = 0; + } + + /** + * Get scheduled note count. + */ + getScheduledNoteCount(): number { + return this.scheduledNoteCount; + } + + // ===== COLORIZER CONTROLS ===== + + /** + * Get the colorizer instance. + */ + getColorizer(): GameBoyColorizer { + return this.colorizer; + } + + /** + * Set colorizer preset. + */ + setColorizerPreset(preset: 'dmg' | 'gbc' | 'gba' | 'clean'): void { + this.colorizer.setConfig(GameBoyColorizer.createPreset(preset)); + } + + /** + * Enable/disable the colorizer. + */ + setColorizerEnabled(enabled: boolean): void { + this.colorizer.setEnabled(enabled); + } + + /** + * Initialize bit crusher (call after user interaction). + */ + initializeBitCrusher(): void { + this.colorizer.initializeBitCrusher(); + } +} + +// Re-export default config for convenience +export { DEFAULT_V2_CONFIG } from '../../types'; diff --git a/src-v2/audio/apu/NoiseChannel.ts b/src-v2/audio/apu/NoiseChannel.ts new file mode 100644 index 0000000..7484dbe --- /dev/null +++ b/src-v2/audio/apu/NoiseChannel.ts @@ -0,0 +1,315 @@ +/** + * Game Boy Noise Channel + * + * Implements the noise channel with: + * - LFSR-based pseudo-random noise generation + * - 7-bit mode (tonal, metallic) and 15-bit mode (fuller noise) + * - GB-accurate frequency calculation + * - Envelope control + */ + +import { generateNoiseBuffer, type LFSRMode } from '../synthesis/LFSR'; +import { calculateNoiseFrequency, midiToNoiseParams } from '../synthesis/FrequencyCalc'; + +export interface NoiseNoteResult { + source: AudioBufferSourceNode; + gainNode: GainNode; + stopTime: number; +} + +// Cache for noise buffers to avoid regenerating +interface NoiseBufferCache { + buffer: AudioBuffer; + frequency: number; + mode: LFSRMode; + duration: number; +} + +export class NoiseChannel { + private audioContext: AudioContext; + private mode: LFSRMode; + private outputNode: GainNode; + + // Cache recently used noise buffers + private bufferCache: NoiseBufferCache[] = []; + private maxCacheSize = 8; + + constructor( + audioContext: AudioContext, + outputNode: GainNode, + mode: LFSRMode = '15bit' + ) { + this.audioContext = audioContext; + this.outputNode = outputNode; + this.mode = mode; + } + + /** + * Set the LFSR mode. + * '7bit' = more tonal, metallic sound (good for snares) + * '15bit' = fuller noise (good for hihats, white noise effects) + */ + setMode(mode: LFSRMode): void { + this.mode = mode; + } + + /** + * Get current mode. + */ + getMode(): LFSRMode { + return this.mode; + } + + /** + * Get or create a noise buffer with the given parameters. + */ + private getNoiseBuffer( + frequency: number, + duration: number, + mode: LFSRMode + ): AudioBuffer { + // Check cache first + const cached = this.bufferCache.find( + c => c.frequency === frequency && + c.mode === mode && + c.duration >= duration + ); + + if (cached) { + return cached.buffer; + } + + // Generate new buffer + const buffer = generateNoiseBuffer( + this.audioContext, + duration + 0.1, // Extra time for envelope tail + frequency, + mode + ); + + // Add to cache + this.bufferCache.push({ buffer, frequency, mode, duration }); + + // Trim cache if too large + while (this.bufferCache.length > this.maxCacheSize) { + this.bufferCache.shift(); + } + + return buffer; + } + + /** + * Play noise with raw frequency control. + * + * @param duration - Duration in seconds + * @param frequency - LFSR clock frequency in Hz + * @param velocity - Velocity (0-127) + * @param startTime - When to start + */ + playNoise( + duration: number, + frequency: number, + velocity: number = 100, + startTime?: number + ): NoiseNoteResult { + const now = startTime ?? this.audioContext.currentTime; + + // Get or generate noise buffer + const buffer = this.getNoiseBuffer(frequency, duration, this.mode); + + // Create source + const source = this.audioContext.createBufferSource(); + source.buffer = buffer; + source.loop = false; + + // Create gain for envelope + const gain = this.audioContext.createGain(); + + // Calculate gain from velocity + const maxGain = (velocity / 127) * 0.7; // Noise is loud, keep headroom + + // Noise envelope: instant attack, decay to sustain, release + const attackTime = 0.001; // Nearly instant + const decayTime = 0.05; // Quick decay + const sustainLevel = maxGain * 0.6; + const releaseTime = 0.03; + + gain.gain.setValueAtTime(0, now); + gain.gain.linearRampToValueAtTime(maxGain, now + attackTime); + gain.gain.linearRampToValueAtTime(sustainLevel, now + attackTime + decayTime); + + const releaseStart = now + Math.max(attackTime + decayTime, duration - releaseTime); + gain.gain.setValueAtTime(sustainLevel, releaseStart); + + const stopTime = now + duration + releaseTime; + gain.gain.linearRampToValueAtTime(0.001, stopTime); + + // Connect + source.connect(gain); + gain.connect(this.outputNode); + + // Play + source.start(now); + source.stop(stopTime + 0.01); + + // Auto-cleanup + source.onended = () => { + try { + source.disconnect(); + gain.disconnect(); + } catch { + // Already disconnected + } + }; + + return { source, gainNode: gain, stopTime }; + } + + /** + * Play noise mapped from a MIDI note. + * Lower notes = lower frequency noise (boomy) + * Higher notes = higher frequency noise (hissy) + * + * @param midiNote - MIDI note (affects noise frequency) + * @param duration - Duration in seconds + * @param velocity - Velocity (0-127) + * @param startTime - When to start + */ + playNote( + midiNote: number, + duration: number, + velocity: number = 100, + startTime?: number + ): NoiseNoteResult { + const { divisorCode, clockShift } = midiToNoiseParams(midiNote); + const frequency = calculateNoiseFrequency(divisorCode, clockShift); + + return this.playNoise(duration, frequency, velocity, startTime); + } + + /** + * Play a kick drum sound. + * Short, low-frequency noise burst. + */ + playKick(velocity: number = 100, startTime?: number): NoiseNoteResult { + const now = startTime ?? this.audioContext.currentTime; + + // Low frequency, short duration, 7-bit for more punch + const buffer = this.getNoiseBuffer(500, 0.15, '7bit'); + + const source = this.audioContext.createBufferSource(); + source.buffer = buffer; + + const gain = this.audioContext.createGain(); + const maxGain = (velocity / 127) * 0.9; + + // Kick envelope: instant attack, fast decay + gain.gain.setValueAtTime(maxGain, now); + gain.gain.exponentialRampToValueAtTime(0.01, now + 0.1); + + source.connect(gain); + gain.connect(this.outputNode); + + source.start(now); + source.stop(now + 0.15); + + source.onended = () => { + try { + source.disconnect(); + gain.disconnect(); + } catch {} + }; + + return { source, gainNode: gain, stopTime: now + 0.15 }; + } + + /** + * Play a snare drum sound. + * Mid-frequency noise with some sustain. + */ + playSnare(velocity: number = 100, startTime?: number): NoiseNoteResult { + const now = startTime ?? this.audioContext.currentTime; + + // Mid frequency, 7-bit for metallic character + const buffer = this.getNoiseBuffer(2000, 0.2, '7bit'); + + const source = this.audioContext.createBufferSource(); + source.buffer = buffer; + + const gain = this.audioContext.createGain(); + const maxGain = (velocity / 127) * 0.8; + + // Snare envelope: fast attack, medium decay + gain.gain.setValueAtTime(0, now); + gain.gain.linearRampToValueAtTime(maxGain, now + 0.005); + gain.gain.exponentialRampToValueAtTime(0.01, now + 0.15); + + source.connect(gain); + gain.connect(this.outputNode); + + source.start(now); + source.stop(now + 0.2); + + source.onended = () => { + try { + source.disconnect(); + gain.disconnect(); + } catch {} + }; + + return { source, gainNode: gain, stopTime: now + 0.2 }; + } + + /** + * Play a hihat sound. + * High-frequency noise, very short. + */ + playHihat( + velocity: number = 100, + open: boolean = false, + startTime?: number + ): NoiseNoteResult { + const now = startTime ?? this.audioContext.currentTime; + + // High frequency, 15-bit for fuller sound + const duration = open ? 0.3 : 0.08; + const buffer = this.getNoiseBuffer(8000, duration, '15bit'); + + const source = this.audioContext.createBufferSource(); + source.buffer = buffer; + + const gain = this.audioContext.createGain(); + const maxGain = (velocity / 127) * 0.5; // Hihats are quieter + + // Hihat envelope: instant attack, quick decay + gain.gain.setValueAtTime(maxGain, now); + + if (open) { + gain.gain.exponentialRampToValueAtTime(0.01, now + 0.25); + } else { + gain.gain.exponentialRampToValueAtTime(0.01, now + 0.05); + } + + source.connect(gain); + gain.connect(this.outputNode); + + source.start(now); + source.stop(now + duration); + + source.onended = () => { + try { + source.disconnect(); + gain.disconnect(); + } catch {} + }; + + return { source, gainNode: gain, stopTime: now + duration }; + } + + /** + * Clear the buffer cache. + */ + clearCache(): void { + this.bufferCache = []; + } +} diff --git a/src-v2/audio/apu/PulseChannel.ts b/src-v2/audio/apu/PulseChannel.ts new file mode 100644 index 0000000..325b2f8 --- /dev/null +++ b/src-v2/audio/apu/PulseChannel.ts @@ -0,0 +1,239 @@ +/** + * Game Boy Pulse Channel + * + * Implements a single pulse channel with: + * - 4 selectable duty cycles + * - GB-accurate frequency calculation + * - Simple envelope (fast attack, configurable release) + * - Optional sweep capability (for p1/p2) + */ + +import { createAllDutyWaves, type DutyIndex } from '../synthesis/DutyCycle'; +import { calculatePulseFrequency } from '../synthesis/FrequencyCalc'; + +export interface PulseNoteResult { + oscillator: OscillatorNode; + gainNode: GainNode; + stopTime: number; +} + +export class PulseChannel { + private audioContext: AudioContext; + private dutyWaves: PeriodicWave[]; + private currentDuty: DutyIndex = 2; // Default to 50% + private hasSweep: boolean; + private outputNode: GainNode; + + constructor( + audioContext: AudioContext, + outputNode: GainNode, + hasSweep: boolean = false + ) { + this.audioContext = audioContext; + this.outputNode = outputNode; + this.hasSweep = hasSweep; + + // Pre-create all duty cycle waveforms + this.dutyWaves = createAllDutyWaves(audioContext); + } + + /** + * Set the duty cycle for subsequent notes. + */ + setDutyCycle(duty: DutyIndex): void { + this.currentDuty = duty; + } + + /** + * Get current duty cycle. + */ + getDutyCycle(): DutyIndex { + return this.currentDuty; + } + + /** + * Play a note on this channel. + * + * @param midiNote - MIDI note number (0-127) + * @param duration - Note duration in seconds + * @param velocity - Note velocity (0-127) + * @param startTime - When to start (audioContext.currentTime based) + * @returns Objects for manual cleanup if needed + */ + playNote( + midiNote: number, + duration: number, + velocity: number = 100, + startTime?: number + ): PulseNoteResult { + const now = startTime ?? this.audioContext.currentTime; + + // Create oscillator with current duty cycle + const osc = this.audioContext.createOscillator(); + osc.setPeriodicWave(this.dutyWaves[this.currentDuty]); + + // Use GB frequency formula (slightly detuned from standard) + const frequency = calculatePulseFrequency(midiNote); + osc.frequency.setValueAtTime(frequency, now); + + // Create gain node for envelope + const gain = this.audioContext.createGain(); + + // Calculate gain from velocity (0-127 → 0-1) + const maxGain = (velocity / 127) * 0.8; // Leave headroom + + // GB-style envelope: fast attack, sustain, quick release + const attackTime = 0.005; // 5ms attack + const releaseTime = 0.02; // 20ms release + + // Envelope automation + gain.gain.setValueAtTime(0, now); + gain.gain.linearRampToValueAtTime(maxGain, now + attackTime); + + // Hold at max until release + const releaseStart = now + Math.max(attackTime, duration - releaseTime); + gain.gain.setValueAtTime(maxGain, releaseStart); + + // Release to near-zero (avoid exponentialRamp to 0) + const stopTime = now + duration + releaseTime; + gain.gain.linearRampToValueAtTime(0.001, stopTime); + + // Connect nodes + osc.connect(gain); + gain.connect(this.outputNode); + + // Schedule playback + osc.start(now); + osc.stop(stopTime + 0.01); // Small buffer after release + + // Auto-cleanup when oscillator ends + osc.onended = () => { + try { + osc.disconnect(); + gain.disconnect(); + } catch { + // Already disconnected + } + }; + + return { oscillator: osc, gainNode: gain, stopTime }; + } + + /** + * Play a note with a specific duty cycle (doesn't change default). + */ + playNoteWithDuty( + midiNote: number, + duration: number, + velocity: number, + duty: DutyIndex, + startTime?: number + ): PulseNoteResult { + const now = startTime ?? this.audioContext.currentTime; + + const osc = this.audioContext.createOscillator(); + osc.setPeriodicWave(this.dutyWaves[duty]); + + const frequency = calculatePulseFrequency(midiNote); + osc.frequency.setValueAtTime(frequency, now); + + const gain = this.audioContext.createGain(); + const maxGain = (velocity / 127) * 0.8; + + const attackTime = 0.005; + const releaseTime = 0.02; + + gain.gain.setValueAtTime(0, now); + gain.gain.linearRampToValueAtTime(maxGain, now + attackTime); + + const releaseStart = now + Math.max(attackTime, duration - releaseTime); + gain.gain.setValueAtTime(maxGain, releaseStart); + + const stopTime = now + duration + releaseTime; + gain.gain.linearRampToValueAtTime(0.001, stopTime); + + osc.connect(gain); + gain.connect(this.outputNode); + + osc.start(now); + osc.stop(stopTime + 0.01); + + osc.onended = () => { + try { + osc.disconnect(); + gain.disconnect(); + } catch { + // Already disconnected + } + }; + + return { oscillator: osc, gainNode: gain, stopTime }; + } + + /** + * Check if this channel has sweep capability. + */ + canSweep(): boolean { + return this.hasSweep; + } + + /** + * Play a note with pitch sweep (if sweep enabled). + * Sweep goes from startNote to endNote over the duration. + */ + playNoteWithSweep( + startNote: number, + endNote: number, + duration: number, + velocity: number = 100, + startTime?: number + ): PulseNoteResult | null { + if (!this.hasSweep) { + console.warn('PulseChannel: Sweep not available on this channel'); + return null; + } + + const now = startTime ?? this.audioContext.currentTime; + + const osc = this.audioContext.createOscillator(); + osc.setPeriodicWave(this.dutyWaves[this.currentDuty]); + + const startFreq = calculatePulseFrequency(startNote); + const endFreq = calculatePulseFrequency(endNote); + + osc.frequency.setValueAtTime(startFreq, now); + osc.frequency.linearRampToValueAtTime(endFreq, now + duration); + + const gain = this.audioContext.createGain(); + const maxGain = (velocity / 127) * 0.8; + + const attackTime = 0.005; + const releaseTime = 0.02; + + gain.gain.setValueAtTime(0, now); + gain.gain.linearRampToValueAtTime(maxGain, now + attackTime); + + const releaseStart = now + Math.max(attackTime, duration - releaseTime); + gain.gain.setValueAtTime(maxGain, releaseStart); + + const stopTime = now + duration + releaseTime; + gain.gain.linearRampToValueAtTime(0.001, stopTime); + + osc.connect(gain); + gain.connect(this.outputNode); + + osc.start(now); + osc.stop(stopTime + 0.01); + + osc.onended = () => { + try { + osc.disconnect(); + gain.disconnect(); + } catch { + // Already disconnected + } + }; + + return { oscillator: osc, gainNode: gain, stopTime }; + } +} diff --git a/src-v2/audio/apu/WaveChannel.ts b/src-v2/audio/apu/WaveChannel.ts new file mode 100644 index 0000000..38b6d45 --- /dev/null +++ b/src-v2/audio/apu/WaveChannel.ts @@ -0,0 +1,237 @@ +/** + * Game Boy Wave Channel + * + * Implements the wave channel with: + * - 32-sample × 4-bit wavetable + * - GB-accurate frequency calculation + * - 4-level volume (mute, 100%, 50%, 25%) + * - Preset waveforms (bass, pad, lead, etc.) + * + * Uses OscillatorNode with PeriodicWave for accurate pitch control + * (AudioBufferSourceNode playbackRate has issues at low frequencies). + */ + +import { + WaveTable, + WAVE_PRESETS, + VOLUME_MULTIPLIERS, + createPeriodicWaveFromTable, + type WavePreset, + type WaveVolume +} from '../synthesis/WaveTable'; +import { calculateWaveFrequency } from '../synthesis/FrequencyCalc'; + +export interface WaveNoteResult { + oscillator: OscillatorNode; + gainNode: GainNode; + stopTime: number; +} + +export class WaveChannel { + private audioContext: AudioContext; + private waveTable: WaveTable; + private periodicWave: PeriodicWave | null = null; + private volume: WaveVolume = 1; // Default to 100% + private outputNode: GainNode; + private currentPreset: WavePreset; + + constructor( + audioContext: AudioContext, + outputNode: GainNode, + preset: WavePreset = 'bass' + ) { + this.audioContext = audioContext; + this.outputNode = outputNode; + this.currentPreset = preset; + + // Initialize wavetable with preset + this.waveTable = new WaveTable(); + this.loadPreset(preset); + } + + /** + * Load a preset waveform. + */ + loadPreset(preset: WavePreset): void { + this.currentPreset = preset; + const waveform = WAVE_PRESETS[preset](); + this.waveTable.loadFromBytes(Array.from(waveform)); + + // Create PeriodicWave from the wavetable + this.periodicWave = createPeriodicWaveFromTable( + this.waveTable.getSamples(), + this.audioContext + ); + } + + /** + * Load custom waveform data (32 samples, 0-15 each). + */ + loadCustomWaveform(samples: number[]): void { + this.waveTable.loadFromBytes(samples); + this.periodicWave = createPeriodicWaveFromTable( + this.waveTable.getSamples(), + this.audioContext + ); + } + + /** + * Set volume level (GB style: 0=mute, 1=100%, 2=50%, 3=25%). + */ + setVolume(volume: WaveVolume): void { + this.volume = volume; + } + + /** + * Get current volume level. + */ + getVolume(): WaveVolume { + return this.volume; + } + + /** + * Get current preset name. + */ + getPreset(): WavePreset { + return this.currentPreset; + } + + /** + * Play a note on this channel. + * + * @param midiNote - MIDI note number + * @param duration - Note duration in seconds + * @param velocity - Note velocity (0-127) + * @param startTime - When to start (audioContext.currentTime based) + */ + playNote( + midiNote: number, + duration: number, + velocity: number = 100, + startTime?: number + ): WaveNoteResult { + const now = startTime ?? this.audioContext.currentTime; + + // Ensure we have a PeriodicWave + if (!this.periodicWave) { + this.periodicWave = createPeriodicWaveFromTable( + this.waveTable.getSamples(), + this.audioContext + ); + } + + // Create oscillator with the wavetable's PeriodicWave + const oscillator = this.audioContext.createOscillator(); + oscillator.setPeriodicWave(this.periodicWave); + + // Calculate GB frequency and set directly (no playback rate needed!) + const frequency = calculateWaveFrequency(midiNote); + oscillator.frequency.setValueAtTime(frequency, now); + + // Create gain node for volume control + const gain = this.audioContext.createGain(); + + // Calculate final gain from velocity and GB volume level + const velocityGain = (velocity / 127) * 0.8; + const volumeMultiplier = VOLUME_MULTIPLIERS[this.volume]; + const finalGain = velocityGain * volumeMultiplier; + + // Simple envelope for wave channel + const attackTime = 0.002; // Very fast attack + const releaseTime = 0.01; // Quick release + + gain.gain.setValueAtTime(0, now); + gain.gain.linearRampToValueAtTime(finalGain, now + attackTime); + + const releaseStart = now + Math.max(attackTime, duration - releaseTime); + gain.gain.setValueAtTime(finalGain, releaseStart); + + const stopTime = now + duration + releaseTime; + gain.gain.linearRampToValueAtTime(0.001, stopTime); + + // Connect nodes + oscillator.connect(gain); + gain.connect(this.outputNode); + + // Schedule playback + oscillator.start(now); + oscillator.stop(stopTime + 0.01); + + // Auto-cleanup + oscillator.onended = () => { + try { + oscillator.disconnect(); + gain.disconnect(); + } catch { + // Already disconnected + } + }; + + return { oscillator, gainNode: gain, stopTime }; + } + + /** + * Play a note with a specific preset (doesn't change default). + */ + playNoteWithPreset( + midiNote: number, + duration: number, + velocity: number, + preset: WavePreset, + startTime?: number + ): WaveNoteResult { + const now = startTime ?? this.audioContext.currentTime; + + // Create temporary PeriodicWave for this preset + const waveform = WAVE_PRESETS[preset](); + const tempWave = createPeriodicWaveFromTable(waveform, this.audioContext); + + // Create oscillator with the preset's PeriodicWave + const oscillator = this.audioContext.createOscillator(); + oscillator.setPeriodicWave(tempWave); + + const frequency = calculateWaveFrequency(midiNote); + oscillator.frequency.setValueAtTime(frequency, now); + + const gain = this.audioContext.createGain(); + const velocityGain = (velocity / 127) * 0.8; + const volumeMultiplier = VOLUME_MULTIPLIERS[this.volume]; + const finalGain = velocityGain * volumeMultiplier; + + const attackTime = 0.002; + const releaseTime = 0.01; + + gain.gain.setValueAtTime(0, now); + gain.gain.linearRampToValueAtTime(finalGain, now + attackTime); + + const releaseStart = now + Math.max(attackTime, duration - releaseTime); + gain.gain.setValueAtTime(finalGain, releaseStart); + + const stopTime = now + duration + releaseTime; + gain.gain.linearRampToValueAtTime(0.001, stopTime); + + oscillator.connect(gain); + gain.connect(this.outputNode); + + oscillator.start(now); + oscillator.stop(stopTime + 0.01); + + oscillator.onended = () => { + try { + oscillator.disconnect(); + gain.disconnect(); + } catch { + // Already disconnected + } + }; + + return { oscillator, gainNode: gain, stopTime }; + } + + /** + * Get the raw wavetable samples for visualization. + */ + getWaveformSamples(): Uint8Array { + return this.waveTable.getSamples(); + } +} diff --git a/src-v2/audio/arranger/GameBoyArranger.ts b/src-v2/audio/arranger/GameBoyArranger.ts new file mode 100644 index 0000000..1a7231e --- /dev/null +++ b/src-v2/audio/arranger/GameBoyArranger.ts @@ -0,0 +1,433 @@ +/** + * 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: true, + doubleMelody: true, + fillGaps: true, + minGapToFill: 0.5, + targetUtilization: 0.7, + hihatRate: 1, // Quarter notes (less busy than 8th notes) + bpm: 120, +}; + +export interface ArrangementResult { + notes: ChannelNote[]; + stats: { + originalNotes: number; + addedNotes: number; + channelUtilization: Record; + }; +} + +export class GameBoyArranger { + private config: ArrangerConfig; + + constructor(config: Partial = {}) { + this.config = { ...DEFAULT_CONFIG, ...config }; + } + + /** + * Set configuration. + */ + setConfig(config: Partial): 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 { + const grouped = new Map(); + + 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, + 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, + duration: number + ): Record { + const utilization: Record = {}; + + 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; + } +} diff --git a/src-v2/audio/effects/GameBoyColorizer.ts b/src-v2/audio/effects/GameBoyColorizer.ts new file mode 100644 index 0000000..506a903 --- /dev/null +++ b/src-v2/audio/effects/GameBoyColorizer.ts @@ -0,0 +1,274 @@ +/** + * Game Boy Colorizer + * + * Applies authentic Game Boy audio characteristics to the output: + * - Low-pass filter (GB has ~8kHz natural rolloff) + * - Bit-crushing (4-bit DAC simulation) + * - Sample rate reduction (mimics ~32kHz internal rate) + * - Subtle saturation (hardware non-linearity) + * - Characteristic noise floor + */ + +export interface ColorizerConfig { + /** Enable/disable the colorizer */ + enabled: boolean; + + /** Low-pass filter cutoff (Hz). Real GB is ~8-10kHz */ + lowpassFreq: number; + + /** Bit depth for crushing (4 = authentic, higher = cleaner) */ + bitDepth: number; + + /** Sample rate reduction factor (1 = none, 2 = half, etc.) */ + sampleRateReduction: number; + + /** Saturation amount (0-1) */ + saturation: number; + + /** High-pass filter to remove DC offset and sub-bass (Hz) */ + highpassFreq: number; +} + +const DEFAULT_CONFIG: ColorizerConfig = { + enabled: true, + lowpassFreq: 10000, // GB natural rolloff (slightly higher) + bitDepth: 8, // Less aggressive bit crushing (4 was too harsh) + sampleRateReduction: 1, // No sample rate reduction (was causing artifacts) + saturation: 0.2, // Subtle warmth + highpassFreq: 20, // LOW - allow bass through! +}; + +export class GameBoyColorizer { + private audioContext: AudioContext; + private config: ColorizerConfig; + + // Audio nodes + private inputGain: GainNode; + private outputGain: GainNode; + private highpassFilter: BiquadFilterNode; + private lowpassFilter: BiquadFilterNode; + private bitCrusher: AudioWorkletNode | ScriptProcessorNode | null = null; + private waveshaper: WaveShaperNode; + private limiter: DynamicsCompressorNode; + + private isInitialized = false; + + constructor(audioContext: AudioContext, config: Partial = {}) { + this.audioContext = audioContext; + this.config = { ...DEFAULT_CONFIG, ...config }; + + // Create basic nodes + this.inputGain = audioContext.createGain(); + this.outputGain = audioContext.createGain(); + + // High-pass filter (remove DC and sub-bass) + this.highpassFilter = audioContext.createBiquadFilter(); + this.highpassFilter.type = 'highpass'; + this.highpassFilter.frequency.value = this.config.highpassFreq; + this.highpassFilter.Q.value = 0.7; + + // Low-pass filter (GB characteristic rolloff) + this.lowpassFilter = audioContext.createBiquadFilter(); + this.lowpassFilter.type = 'lowpass'; + this.lowpassFilter.frequency.value = this.config.lowpassFreq; + this.lowpassFilter.Q.value = 0.7; + + // Waveshaper for saturation + this.waveshaper = audioContext.createWaveShaper(); + this.waveshaper.curve = this.createSaturationCurve(this.config.saturation); + this.waveshaper.oversample = '2x'; + + // Limiter to prevent clipping + this.limiter = audioContext.createDynamicsCompressor(); + this.limiter.threshold.value = -6; + this.limiter.knee.value = 6; + this.limiter.ratio.value = 12; + this.limiter.attack.value = 0.001; + this.limiter.release.value = 0.1; + + // Initialize chain (without bit crusher for now) + this.initializeBasicChain(); + } + + /** + * Initialize the basic audio chain without bit crusher. + */ + private initializeBasicChain(): void { + // Chain: input -> highpass -> lowpass -> waveshaper -> limiter -> output + this.inputGain.connect(this.highpassFilter); + this.highpassFilter.connect(this.lowpassFilter); + this.lowpassFilter.connect(this.waveshaper); + this.waveshaper.connect(this.limiter); + this.limiter.connect(this.outputGain); + + this.isInitialized = true; + } + + /** + * Initialize with bit crusher using ScriptProcessor (fallback). + * Call this after user interaction for iOS compatibility. + */ + initializeBitCrusher(): void { + if (this.bitCrusher) return; + + // Disconnect current chain + this.lowpassFilter.disconnect(); + + // Create bit crusher using ScriptProcessor (deprecated but widely supported) + const bufferSize = 4096; + const crusher = this.audioContext.createScriptProcessor(bufferSize, 1, 1); + + const bitDepth = this.config.bitDepth; + const sampleRateReduction = this.config.sampleRateReduction; + const levels = Math.pow(2, bitDepth); + + let lastSample = 0; + let sampleCounter = 0; + + crusher.onaudioprocess = (event) => { + const input = event.inputBuffer.getChannelData(0); + const output = event.outputBuffer.getChannelData(0); + + for (let i = 0; i < input.length; i++) { + sampleCounter++; + + // Sample rate reduction + if (sampleCounter >= sampleRateReduction) { + sampleCounter = 0; + + // Bit crushing: quantize to bitDepth levels + const sample = input[i]; + lastSample = Math.round(sample * levels) / levels; + } + + output[i] = lastSample; + } + }; + + this.bitCrusher = crusher; + + // Reconnect chain with crusher + this.lowpassFilter.connect(crusher as unknown as AudioNode); + (crusher as unknown as AudioNode).connect(this.waveshaper); + } + + /** + * Create a saturation curve for the waveshaper. + */ + private createSaturationCurve(amount: number): Float32Array { + const samples = 44100; + const curve = new Float32Array(samples); + const deg = Math.PI / 180; + + for (let i = 0; i < samples; i++) { + const x = (i * 2) / samples - 1; + + if (amount === 0) { + // No saturation - linear + curve[i] = x; + } else { + // Soft clipping curve + const k = 2 * amount / (1 - amount); + curve[i] = ((1 + k) * x) / (1 + k * Math.abs(x)); + } + } + + return curve; + } + + /** + * Get the input node (connect your audio source to this). + */ + getInput(): GainNode { + return this.inputGain; + } + + /** + * Get the output node (connect this to destination or other effects). + */ + getOutput(): GainNode { + return this.outputGain; + } + + /** + * Enable/disable the colorizer. + */ + setEnabled(enabled: boolean): void { + this.config.enabled = enabled; + // When disabled, bypass could be implemented + // For now, just set gain to 0 or 1 + this.inputGain.gain.value = enabled ? 1 : 0; + } + + /** + * Update configuration. + */ + setConfig(config: Partial): void { + this.config = { ...this.config, ...config }; + + // Update filter frequencies + this.highpassFilter.frequency.value = this.config.highpassFreq; + this.lowpassFilter.frequency.value = this.config.lowpassFreq; + + // Update saturation curve + this.waveshaper.curve = this.createSaturationCurve(this.config.saturation); + } + + /** + * Get current configuration. + */ + getConfig(): ColorizerConfig { + return { ...this.config }; + } + + /** + * Create a preset configuration. + */ + static createPreset(preset: 'dmg' | 'gbc' | 'gba' | 'clean'): Partial { + switch (preset) { + case 'dmg': + // Original Game Boy - lo-fi but with bass + return { + enabled: true, + lowpassFreq: 8000, + bitDepth: 8, // Less harsh than 4-bit + sampleRateReduction: 1, // No SR reduction (causes artifacts) + saturation: 0.3, + highpassFreq: 30, // Let bass through! + }; + + case 'gbc': + // Game Boy Color - slightly cleaner + return { + enabled: true, + lowpassFreq: 10000, + bitDepth: 8, + sampleRateReduction: 1, + saturation: 0.2, + highpassFreq: 25, + }; + + case 'gba': + // Game Boy Advance - cleaner still + return { + enabled: true, + lowpassFreq: 14000, + bitDepth: 12, + sampleRateReduction: 1, + saturation: 0.1, + highpassFreq: 20, + }; + + case 'clean': + // No processing + return { + enabled: false, + lowpassFreq: 20000, + bitDepth: 16, + sampleRateReduction: 1, + saturation: 0, + highpassFreq: 20, + }; + } + } +} diff --git a/src-v2/audio/midi/Arpeggiator.ts b/src-v2/audio/midi/Arpeggiator.ts new file mode 100644 index 0000000..8ffa0aa --- /dev/null +++ b/src-v2/audio/midi/Arpeggiator.ts @@ -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 = {}) { + this.config = { ...DEFAULT_CONFIG, ...config }; + } + + /** + * Update configuration. + */ + setConfig(config: Partial): 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; + } +} diff --git a/src-v2/audio/midi/ChannelMapper.ts b/src-v2/audio/midi/ChannelMapper.ts new file mode 100644 index 0000000..23f92e5 --- /dev/null +++ b/src-v2/audio/midi/ChannelMapper.ts @@ -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 = {}) { + this.analyzer = new TrackAnalyzer(); + this.config = { ...DEFAULT_CONFIG, ...config }; + } + + /** + * Update configuration. + */ + setConfig(config: Partial): 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(); + + // 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 + ): 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 + ): 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 + ): 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 + ): 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, + 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 + ): 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 + ): 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 + ): 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( + pool: T[], + usedChannels: Set + ): 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); + } +} diff --git a/src-v2/audio/midi/TrackAnalyzer.ts b/src-v2/audio/midi/TrackAnalyzer.ts new file mode 100644 index 0000000..9eaea8a --- /dev/null +++ b/src-v2/audio/midi/TrackAnalyzer.ts @@ -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(); + + 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(); + 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; + } +} diff --git a/src-v2/audio/synthesis/DutyCycle.ts b/src-v2/audio/synthesis/DutyCycle.ts new file mode 100644 index 0000000..fa37cae --- /dev/null +++ b/src-v2/audio/synthesis/DutyCycle.ts @@ -0,0 +1,82 @@ +/** + * Game Boy Duty Cycle Implementation + * + * The GB pulse channels support 4 duty cycle patterns. + * These exact duty ratios give the Game Boy its distinctive sound. + */ + +/** + * Duty cycle ratios for the 4 GB patterns + * 12.5% - Very thin, buzzy, laser-like sound + * 25% - Classic chiptune sound, bright and punchy + * 50% - Full square wave + * 75% - Same as 25% but inverted + */ +export const DUTY_RATIOS = [0.125, 0.25, 0.5, 0.75] as const; + +export type DutyIndex = 0 | 1 | 2 | 3; + +/** + * Creates a PeriodicWave for Web Audio from a duty cycle. + * + * Uses proper Fourier series for pulse wave: + * imag[n] = (2 / (π * n)) * sin(π * n * duty) + * + * This is the mathematically correct way to synthesize pulse waves. + */ +export function createDutyWave( + dutyIndex: DutyIndex, + audioContext: BaseAudioContext +): PeriodicWave { + const dutyRatio = DUTY_RATIOS[dutyIndex]; + + // More harmonics = sharper edges (but more CPU) + const numHarmonics = 64; + + const real = new Float32Array(numHarmonics); + const imag = new Float32Array(numHarmonics); + + // DC offset = 0 for centered waveform + real[0] = 0; + imag[0] = 0; + + // Fourier series for pulse wave + // https://en.wikipedia.org/wiki/Pulse_wave + for (let n = 1; n < numHarmonics; n++) { + // Pulse wave Fourier coefficient + const coefficient = (2 / (Math.PI * n)) * Math.sin(Math.PI * n * dutyRatio); + imag[n] = coefficient; + real[n] = 0; + } + + return audioContext.createPeriodicWave(real, imag, { + disableNormalization: false + }); +} + +/** + * Pre-creates all 4 duty cycle waveforms for efficient reuse. + */ +export function createAllDutyWaves( + audioContext: BaseAudioContext +): PeriodicWave[] { + return [ + createDutyWave(0, audioContext), + createDutyWave(1, audioContext), + createDutyWave(2, audioContext), + createDutyWave(3, audioContext), + ]; +} + +/** + * Returns a human-readable description of each duty cycle. + */ +export function getDutyDescription(dutyIndex: DutyIndex): string { + const descriptions = [ + '12.5% - Thin, buzzy', + '25% - Classic chiptune', + '50% - Full square', + '75% - Bright, punchy', + ]; + return descriptions[dutyIndex]; +} diff --git a/src-v2/audio/synthesis/FrequencyCalc.ts b/src-v2/audio/synthesis/FrequencyCalc.ts new file mode 100644 index 0000000..96f1eb3 --- /dev/null +++ b/src-v2/audio/synthesis/FrequencyCalc.ts @@ -0,0 +1,161 @@ +/** + * Game Boy Frequency Calculations + * + * The GB uses specific frequency formulas based on 11-bit period registers. + * This creates slightly "off" tuning compared to standard A440 tuning, + * which is part of the characteristic GB sound. + * + * Reference: https://gbdev.io/pandocs/Audio_details.html + */ + +/** + * GB CPU clock rate used for audio timing + */ +const GB_CLOCK = 4194304; // 4.194304 MHz + +/** + * Pulse channel base frequency divider + * Formula: freq = 131072 / (2048 - period) + */ +const PULSE_FREQ_BASE = 131072; + +/** + * Wave channel base frequency divider + * Formula: freq = 65536 / (2048 - period) + * (Half the pulse frequency, so wave plays one octave lower for same period) + */ +const WAVE_FREQ_BASE = 65536; + +/** + * Maximum period register value (11-bit) + */ +const MAX_PERIOD = 2047; + +/** + * Noise channel divisor lookup table + * Used with divisor code (r) in noise frequency calculation + */ +const NOISE_DIVISORS = [0.5, 1, 1.5, 2, 2.5, 3, 3.5, 4] as const; + +/** + * Convert MIDI note number to standard frequency (A4 = 440Hz) + */ +export function midiToStandardFrequency(midiNote: number): number { + return 440 * Math.pow(2, (midiNote - 69) / 12); +} + +/** + * Convert standard frequency to GB pulse period register value. + * Returns clamped 11-bit value (0-2047). + */ +export function frequencyToPulsePeriod(frequency: number): number { + // freq = 131072 / (2048 - period) + // period = 2048 - (131072 / freq) + const period = Math.round(2048 - (PULSE_FREQ_BASE / frequency)); + return Math.max(0, Math.min(MAX_PERIOD, period)); +} + +/** + * Convert GB pulse period register to actual output frequency. + */ +export function pulsePeriodToFrequency(period: number): number { + if (period >= 2048) return 0; + return PULSE_FREQ_BASE / (2048 - period); +} + +/** + * Calculate the actual GB frequency for a pulse channel from MIDI note. + * + * This goes: MIDI → standard freq → period register → GB freq + * The register quantization creates the characteristic slight detuning. + */ +export function calculatePulseFrequency(midiNote: number): number { + const standardFreq = midiToStandardFrequency(midiNote); + const period = frequencyToPulsePeriod(standardFreq); + return pulsePeriodToFrequency(period); +} + +/** + * Convert standard frequency to GB wave period register value. + */ +export function frequencyToWavePeriod(frequency: number): number { + // freq = 65536 / (2048 - period) + // period = 2048 - (65536 / freq) + const period = Math.round(2048 - (WAVE_FREQ_BASE / frequency)); + return Math.max(0, Math.min(MAX_PERIOD, period)); +} + +/** + * Convert GB wave period register to actual output frequency. + */ +export function wavePeriodToFrequency(period: number): number { + if (period >= 2048) return 0; + return WAVE_FREQ_BASE / (2048 - period); +} + +/** + * Calculate the actual GB frequency for a wave channel from MIDI note. + */ +export function calculateWaveFrequency(midiNote: number): number { + const standardFreq = midiToStandardFrequency(midiNote); + const period = frequencyToWavePeriod(standardFreq); + return wavePeriodToFrequency(period); +} + +/** + * Calculate noise channel frequency. + * + * @param divisorCode - Divisor code (0-7), selects from NOISE_DIVISORS + * @param clockShift - Clock shift (0-14), higher = lower frequency + * @returns Frequency in Hz + * + * Formula: freq = 524288 / divisor / 2^(shift+1) + */ +export function calculateNoiseFrequency( + divisorCode: number, + clockShift: number +): number { + const divisor = NOISE_DIVISORS[divisorCode % 8]; + const shift = Math.max(0, Math.min(14, clockShift)); + return 524288 / divisor / Math.pow(2, shift + 1); +} + +/** + * Map a MIDI note to noise parameters. + * Lower notes = lower noise frequency (more "boomy") + * Higher notes = higher noise frequency (more "hissy") + * + * This is an approximation since noise isn't truly pitched. + */ +export function midiToNoiseParams(midiNote: number): { + divisorCode: number; + clockShift: number; +} { + // Map MIDI notes 24-96 to noise parameters + // Lower notes get higher shift (lower freq) + // Higher notes get lower shift (higher freq) + + const normalized = Math.max(0, Math.min(72, midiNote - 24)); + + // Map to shift (0-14): high notes = low shift, low notes = high shift + const clockShift = Math.floor(14 - (normalized / 72) * 14); + + // Divisor code affects timbre - use middle values for most natural sound + const divisorCode = Math.floor((normalized % 8)); + + return { divisorCode, clockShift }; +} + +/** + * Calculate the frequency deviation from standard tuning. + * Useful for testing/verification. + * + * @returns Deviation in cents (100 cents = 1 semitone) + */ +export function getFrequencyDeviation(midiNote: number): number { + const standard = midiToStandardFrequency(midiNote); + const gbFreq = calculatePulseFrequency(midiNote); + + // Cents = 1200 * log2(f2/f1) + return 1200 * Math.log2(gbFreq / standard); +} diff --git a/src-v2/audio/synthesis/LFSR.ts b/src-v2/audio/synthesis/LFSR.ts new file mode 100644 index 0000000..45eaeaf --- /dev/null +++ b/src-v2/audio/synthesis/LFSR.ts @@ -0,0 +1,181 @@ +/** + * Linear Feedback Shift Register (LFSR) Noise Generator + * + * The Game Boy's noise channel uses a 15-bit LFSR to generate + * pseudo-random noise. It can also operate in 7-bit mode for + * a more tonal, metallic sound. + * + * This is what gives GB noise its characteristic "crunchy" quality + * compared to smooth white noise. + * + * Reference: https://gbdev.io/pandocs/Audio_details.html#noise-channel + */ + +export type LFSRMode = '7bit' | '15bit'; + +/** + * Initial LFSR seed value (all 1s for 15-bit register) + */ +const INITIAL_SEED = 0x7FFF; + +/** + * LFSR noise generator that matches Game Boy hardware behavior. + */ +export class LFSR { + private lfsr: number; + private mode: LFSRMode; + + constructor(mode: LFSRMode = '15bit') { + this.mode = mode; + this.lfsr = INITIAL_SEED; + } + + /** + * Clock the LFSR once and return the output bit. + * + * Algorithm: + * 1. XOR bits 0 and 1 to get new bit + * 2. Output is current bit 0 (before shift) + * 3. Shift register right by 1 + * 4. Put XOR result into bit 14 + * 5. If 7-bit mode, also put XOR result into bit 6 + * + * @returns 0 or 1 + */ + clock(): number { + // Output is bit 0 before we modify anything + const output = this.lfsr & 1; + + // XOR bits 0 and 1 + const bit0 = this.lfsr & 1; + const bit1 = (this.lfsr >> 1) & 1; + const xorResult = bit0 ^ bit1; + + // Shift right by 1 + this.lfsr >>= 1; + + // Set bit 14 to XOR result + this.lfsr |= (xorResult << 14); + + // In 7-bit mode, also set bit 6 + if (this.mode === '7bit') { + // Clear bit 6 first, then set if needed + this.lfsr &= ~(1 << 6); + this.lfsr |= (xorResult << 6); + } + + return output; + } + + /** + * Reset LFSR to initial state. + */ + reset(): void { + this.lfsr = INITIAL_SEED; + } + + /** + * Set the LFSR mode. + * 7-bit mode produces more tonal, metallic sounds. + * 15-bit mode produces fuller noise. + */ + setMode(mode: LFSRMode): void { + this.mode = mode; + } + + /** + * Get current mode. + */ + getMode(): LFSRMode { + return this.mode; + } + + /** + * Get current register value (for debugging/visualization). + */ + getValue(): number { + return this.lfsr; + } + + /** + * Generate a sequence of n output bits. + * Useful for verification against known GB sequences. + */ + generateSequence(length: number): number[] { + const sequence: number[] = []; + for (let i = 0; i < length; i++) { + sequence.push(this.clock()); + } + return sequence; + } +} + +/** + * Known first 20 values of 15-bit LFSR starting from 0x7FFF (all 1s). + * The first outputs are just the low bits shifting out. + * Used for verification that our implementation matches GB hardware. + */ +export const LFSR_15BIT_EXPECTED = [ + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 0, 0, 0, 0, 0 +]; + +/** + * Verify that our LFSR implementation produces correct output. + */ +export function verifyLFSR(): boolean { + const lfsr = new LFSR('15bit'); + const sequence = lfsr.generateSequence(20); + + for (let i = 0; i < LFSR_15BIT_EXPECTED.length; i++) { + if (sequence[i] !== LFSR_15BIT_EXPECTED[i]) { + console.error(`LFSR mismatch at index ${i}: got ${sequence[i]}, expected ${LFSR_15BIT_EXPECTED[i]}`); + return false; + } + } + + return true; +} + +/** + * Generate an audio buffer filled with LFSR noise. + * + * @param audioContext - Web Audio context + * @param duration - Duration in seconds + * @param frequency - Clock frequency of the LFSR + * @param mode - LFSR mode (7bit or 15bit) + * @returns AudioBuffer filled with noise + */ +export function generateNoiseBuffer( + audioContext: BaseAudioContext, + duration: number, + frequency: number, + mode: LFSRMode = '15bit' +): AudioBuffer { + const sampleRate = audioContext.sampleRate; + const bufferLength = Math.ceil(duration * sampleRate); + const buffer = audioContext.createBuffer(1, bufferLength, sampleRate); + const data = buffer.getChannelData(0); + + const lfsr = new LFSR(mode); + + // How many samples between LFSR clocks + const samplesPerClock = sampleRate / frequency; + + let clockAccumulator = 0; + let currentOutput = 0; + + for (let i = 0; i < bufferLength; i++) { + // Clock LFSR when accumulator reaches threshold + clockAccumulator += 1; + if (clockAccumulator >= samplesPerClock) { + currentOutput = lfsr.clock(); + clockAccumulator -= samplesPerClock; + } + + // Convert 0/1 to -1/+1 for audio + data[i] = currentOutput * 2 - 1; + } + + return buffer; +} diff --git a/src-v2/audio/synthesis/WaveTable.ts b/src-v2/audio/synthesis/WaveTable.ts new file mode 100644 index 0000000..1547fa4 --- /dev/null +++ b/src-v2/audio/synthesis/WaveTable.ts @@ -0,0 +1,306 @@ +/** + * Game Boy Wave Channel Wavetable + * + * The GB wave channel uses a 32-sample wavetable with 4-bit resolution. + * Each sample can be 0-15, giving the characteristic "digital staircase" + * sound quality. + * + * The low resolution creates audible quantization that's part of the + * GB's unique character - smoother than pulse but still distinctly digital. + * + * Reference: https://gbdev.io/pandocs/Audio_details.html#wave-channel + */ + +/** + * Number of samples in the wavetable + */ +export const WAVE_TABLE_SIZE = 32; + +/** + * Maximum sample value (4-bit = 0-15) + */ +export const MAX_SAMPLE_VALUE = 15; + +/** + * GB wave channel volume levels (bit-shift based) + * 0 = mute, 1 = 100%, 2 = 50%, 3 = 25% + */ +export type WaveVolume = 0 | 1 | 2 | 3; + +/** + * Volume multipliers matching GB behavior + * GB uses right-shift for volume: 0=mute, 1=>>0, 2=>>1, 3=>>2 + */ +export const VOLUME_MULTIPLIERS: Record = { + 0: 0, + 1: 1.0, + 2: 0.5, + 3: 0.25, +}; + +/** + * Wavetable class for the GB wave channel. + */ +export class WaveTable { + private samples: Uint8Array; + + constructor() { + this.samples = new Uint8Array(WAVE_TABLE_SIZE); + // Initialize with silence + this.samples.fill(8); // 8 = center value (no DC offset) + } + + /** + * Quantize a float value (0-1) to 4-bit (0-15). + */ + private quantize(value: number): number { + const clamped = Math.max(0, Math.min(1, value)); + return Math.floor(clamped * MAX_SAMPLE_VALUE); + } + + /** + * Load a waveform from a float array (0-1 range). + * Values are quantized to 4-bit resolution. + */ + loadFromFloats(waveform: number[]): void { + for (let i = 0; i < WAVE_TABLE_SIZE; i++) { + const value = i < waveform.length ? waveform[i] : 0.5; + this.samples[i] = this.quantize(value); + } + } + + /** + * Load raw 4-bit samples directly. + */ + loadFromBytes(samples: number[]): void { + for (let i = 0; i < WAVE_TABLE_SIZE; i++) { + const value = i < samples.length ? samples[i] : 8; + this.samples[i] = Math.max(0, Math.min(MAX_SAMPLE_VALUE, Math.floor(value))); + } + } + + /** + * Get the raw sample array. + */ + getSamples(): Uint8Array { + return this.samples; + } + + /** + * Create a Web Audio buffer from this wavetable. + * The buffer is one cycle of the waveform. + */ + createBuffer(audioContext: BaseAudioContext): AudioBuffer { + const buffer = audioContext.createBuffer(1, WAVE_TABLE_SIZE, audioContext.sampleRate); + const data = buffer.getChannelData(0); + + for (let i = 0; i < WAVE_TABLE_SIZE; i++) { + // Convert 0-15 to -1 to +1 + data[i] = (this.samples[i] / MAX_SAMPLE_VALUE) * 2 - 1; + } + + return buffer; + } + + /** + * Create an extended buffer for better audio quality. + * Repeats the waveform multiple times to avoid pitch artifacts. + */ + createExtendedBuffer( + audioContext: BaseAudioContext, + repetitions: number = 256 + ): AudioBuffer { + const totalSamples = WAVE_TABLE_SIZE * repetitions; + const buffer = audioContext.createBuffer(1, totalSamples, audioContext.sampleRate); + const data = buffer.getChannelData(0); + + for (let i = 0; i < totalSamples; i++) { + const sampleIndex = i % WAVE_TABLE_SIZE; + data[i] = (this.samples[sampleIndex] / MAX_SAMPLE_VALUE) * 2 - 1; + } + + return buffer; + } +} + +/** + * Generate a triangle wave with 4-bit quantization. + * Classic GB bass sound. + */ +export function generateTriangleWave(): Uint8Array { + const wave = new Uint8Array(WAVE_TABLE_SIZE); + + for (let i = 0; i < WAVE_TABLE_SIZE; i++) { + // Triangle: ramp up for first half, down for second half + const position = i / WAVE_TABLE_SIZE; + let value: number; + + if (position < 0.5) { + value = position * 2; // 0 to 1 + } else { + value = 2 - position * 2; // 1 to 0 + } + + wave[i] = Math.floor(value * MAX_SAMPLE_VALUE); + } + + return wave; +} + +/** + * Generate a sawtooth wave with 4-bit quantization. + * Brighter, more aggressive sound. + */ +export function generateSawtoothWave(): Uint8Array { + const wave = new Uint8Array(WAVE_TABLE_SIZE); + + for (let i = 0; i < WAVE_TABLE_SIZE; i++) { + wave[i] = Math.floor((i / (WAVE_TABLE_SIZE - 1)) * MAX_SAMPLE_VALUE); + } + + return wave; +} + +/** + * Generate a sine-ish wave with 4-bit quantization. + * Rounder, softer sound for pads. + */ +export function generateSineWave(): Uint8Array { + const wave = new Uint8Array(WAVE_TABLE_SIZE); + + for (let i = 0; i < WAVE_TABLE_SIZE; i++) { + const angle = (i / WAVE_TABLE_SIZE) * Math.PI * 2; + const sine = (Math.sin(angle) + 1) / 2; // Normalize to 0-1 + wave[i] = Math.floor(sine * MAX_SAMPLE_VALUE); + } + + return wave; +} + +/** + * Generate a square wave with 4-bit resolution. + * Sharp, bright sound. + */ +export function generateSquareWave(): Uint8Array { + const wave = new Uint8Array(WAVE_TABLE_SIZE); + + for (let i = 0; i < WAVE_TABLE_SIZE; i++) { + wave[i] = i < WAVE_TABLE_SIZE / 2 ? MAX_SAMPLE_VALUE : 0; + } + + return wave; +} + +/** + * Generate a bass-optimized waveform. + * Combination of triangle with slight harmonics. + */ +export function generateBassWave(): Uint8Array { + const wave = new Uint8Array(WAVE_TABLE_SIZE); + + for (let i = 0; i < WAVE_TABLE_SIZE; i++) { + const position = i / WAVE_TABLE_SIZE; + const angle = position * Math.PI * 2; + + // Fundamental + slight 2nd harmonic for warmth + const value = (Math.sin(angle) * 0.8 + Math.sin(angle * 2) * 0.2 + 1) / 2; + wave[i] = Math.floor(value * MAX_SAMPLE_VALUE); + } + + return wave; +} + +/** + * Generate a pad-optimized waveform. + * Softer, rounder character. + */ +export function generatePadWave(): Uint8Array { + // Use sine wave for pads - smoothest option + return generateSineWave(); +} + +/** + * Generate a lead-optimized waveform. + * Brighter with more harmonics. + */ +export function generateLeadWave(): Uint8Array { + const wave = new Uint8Array(WAVE_TABLE_SIZE); + + for (let i = 0; i < WAVE_TABLE_SIZE; i++) { + const position = i / WAVE_TABLE_SIZE; + const angle = position * Math.PI * 2; + + // Mix of saw and triangle characteristics + const saw = position; + const tri = position < 0.5 ? position * 2 : 2 - position * 2; + const value = saw * 0.6 + tri * 0.4; + + wave[i] = Math.floor(value * MAX_SAMPLE_VALUE); + } + + return wave; +} + +/** + * Preset wavetables for easy access. + */ +export const WAVE_PRESETS = { + triangle: generateTriangleWave, + sawtooth: generateSawtoothWave, + sine: generateSineWave, + square: generateSquareWave, + bass: generateBassWave, + pad: generatePadWave, + lead: generateLeadWave, +} as const; + +export type WavePreset = keyof typeof WAVE_PRESETS; + +/** + * Create a PeriodicWave from a wavetable for use with OscillatorNode. + * This is more accurate than using AudioBufferSourceNode with playback rate. + */ +export function createPeriodicWaveFromTable( + samples: Uint8Array | number[], + audioContext: BaseAudioContext +): PeriodicWave { + const n = samples.length; + + // Convert samples to normalized audio values (-1 to +1) + const normalized: number[] = []; + for (let i = 0; i < n; i++) { + const sample = typeof samples[i] === 'number' ? samples[i] : 0; + normalized.push((sample / MAX_SAMPLE_VALUE) * 2 - 1); + } + + // Number of harmonics - more harmonics = more accurate representation + const numHarmonics = 64; + + // Calculate Fourier coefficients + const real = new Float32Array(numHarmonics); + const imag = new Float32Array(numHarmonics); + + // DC offset (real[0]) should be 0 for centered waveform + real[0] = 0; + imag[0] = 0; + + // Calculate each harmonic using DFT + for (let k = 1; k < numHarmonics; k++) { + let realSum = 0; + let imagSum = 0; + + for (let i = 0; i < n; i++) { + const angle = (2 * Math.PI * k * i) / n; + realSum += normalized[i] * Math.cos(angle); + imagSum -= normalized[i] * Math.sin(angle); + } + + // Scale by 2/n for proper amplitude + real[k] = (2 * realSum) / n; + imag[k] = (2 * imagSum) / n; + } + + return audioContext.createPeriodicWave(real, imag, { + disableNormalization: false + }); +} diff --git a/src-v2/audio/test/soundTest.ts b/src-v2/audio/test/soundTest.ts new file mode 100644 index 0000000..c1f777d --- /dev/null +++ b/src-v2/audio/test/soundTest.ts @@ -0,0 +1,267 @@ +/** + * Sound Test for Wario Synth v2 + * + * Tests all individual sound generators to verify they work + * and sound authentically Game Boy-like. + */ + +import { PulseChannel } from '../apu/PulseChannel'; +import { WaveChannel } from '../apu/WaveChannel'; +import { NoiseChannel } from '../apu/NoiseChannel'; +import { verifyLFSR } from '../synthesis/LFSR'; +import { getFrequencyDeviation } from '../synthesis/FrequencyCalc'; +import type { DutyIndex } from '../synthesis/DutyCycle'; + +export interface TestResult { + name: string; + passed: boolean; + message: string; +} + +/** + * Run all verification tests (non-audio). + */ +export function runVerificationTests(): TestResult[] { + const results: TestResult[] = []; + + // Test LFSR implementation + const lfsrOk = verifyLFSR(); + results.push({ + name: 'LFSR Sequence', + passed: lfsrOk, + message: lfsrOk ? 'LFSR matches expected GB sequence' : 'LFSR sequence mismatch!' + }); + + // Test frequency deviation (should be non-zero but small) + const devA4 = getFrequencyDeviation(69); // A4 + const devOk = Math.abs(devA4) > 0.01 && Math.abs(devA4) < 10; + results.push({ + name: 'Frequency Deviation', + passed: devOk, + message: `A4 deviation: ${devA4.toFixed(2)} cents (expected small non-zero value)` + }); + + return results; +} + +/** + * Create test channels for audio testing. + */ +export function createTestChannels(audioContext: AudioContext) { + // Create master gain + const masterGain = audioContext.createGain(); + masterGain.gain.value = 0.5; + masterGain.connect(audioContext.destination); + + // Create individual channel gains + const pulseGain = audioContext.createGain(); + pulseGain.gain.value = 0.4; + pulseGain.connect(masterGain); + + const waveGain = audioContext.createGain(); + waveGain.gain.value = 0.5; + waveGain.connect(masterGain); + + const noiseGain = audioContext.createGain(); + noiseGain.gain.value = 0.4; + noiseGain.connect(masterGain); + + return { + pulse: new PulseChannel(audioContext, pulseGain, true), + wave: new WaveChannel(audioContext, waveGain, 'bass'), + noise: new NoiseChannel(audioContext, noiseGain, '15bit'), + masterGain + }; +} + +/** + * Test all 4 duty cycles on the pulse channel. + */ +export async function testDutyCycles( + pulse: PulseChannel, + audioContext: AudioContext +): Promise { + console.log('Testing duty cycles...'); + + const duties: DutyIndex[] = [0, 1, 2, 3]; + const dutyNames = ['12.5%', '25%', '50%', '75%']; + + for (let i = 0; i < duties.length; i++) { + console.log(` Playing duty cycle ${dutyNames[i]}`); + pulse.setDutyCycle(duties[i]); + + // Play a short melody + const notes = [60, 64, 67, 72]; // C major arpeggio + const now = audioContext.currentTime; + + notes.forEach((note, idx) => { + pulse.playNote(note, 0.15, 100, now + idx * 0.2); + }); + + // Wait for notes to finish + await sleep(1000); + } + + console.log('Duty cycle test complete!'); +} + +/** + * Test the wave channel with different presets. + */ +export async function testWaveChannel( + wave: WaveChannel, + audioContext: AudioContext +): Promise { + console.log('Testing wave channel...'); + + const presets = ['bass', 'pad', 'lead', 'triangle', 'sawtooth'] as const; + + for (const preset of presets) { + console.log(` Playing preset: ${preset}`); + wave.loadPreset(preset); + + // Play a bass line + const notes = [36, 36, 43, 41]; // Low C, C, G, F + const now = audioContext.currentTime; + + notes.forEach((note, idx) => { + wave.playNote(note, 0.4, 100, now + idx * 0.5); + }); + + await sleep(2200); + } + + console.log('Wave channel test complete!'); +} + +/** + * Test the noise channel with different modes. + */ +export async function testNoiseChannel( + noise: NoiseChannel, + audioContext: AudioContext +): Promise { + console.log('Testing noise channel...'); + + // Test 15-bit mode (fuller noise) + console.log(' Testing 15-bit mode (full noise)'); + noise.setMode('15bit'); + + let now = audioContext.currentTime; + noise.playHihat(80, false, now); + noise.playHihat(60, false, now + 0.25); + noise.playHihat(80, false, now + 0.5); + noise.playHihat(60, true, now + 0.75); + + await sleep(1500); + + // Test 7-bit mode (metallic) + console.log(' Testing 7-bit mode (metallic)'); + noise.setMode('7bit'); + + now = audioContext.currentTime; + noise.playKick(100, now); + noise.playSnare(90, now + 0.5); + noise.playKick(100, now + 1.0); + noise.playSnare(90, now + 1.5); + + await sleep(2200); + + // Test different frequencies + console.log(' Testing frequency range'); + now = audioContext.currentTime; + + for (let i = 0; i < 8; i++) { + noise.playNote(36 + i * 6, 0.2, 80, now + i * 0.25); + } + + await sleep(2500); + + console.log('Noise channel test complete!'); +} + +/** + * Play a simple test melody using all channels. + */ +export async function testCombined( + pulse: PulseChannel, + wave: WaveChannel, + noise: NoiseChannel, + audioContext: AudioContext +): Promise { + console.log('Testing combined playback...'); + + const bpm = 120; + const beatDuration = 60 / bpm; + const now = audioContext.currentTime; + + // Set up channels + pulse.setDutyCycle(2); // 50% + wave.loadPreset('bass'); + noise.setMode('7bit'); + + // 4-bar phrase + for (let bar = 0; bar < 4; bar++) { + const barStart = now + bar * 4 * beatDuration; + + // Bass line (wave channel) - root notes + const bassNotes = [36, 36, 43, 41]; // C, C, G, F + wave.playNote(bassNotes[bar], beatDuration * 3.5, 90, barStart); + + // Melody (pulse channel) + const melodyNotes = [ + [60, 64, 67], // Bar 1: C E G + [64, 67, 72], // Bar 2: E G C + [67, 71, 74], // Bar 3: G B D + [65, 69, 72], // Bar 4: F A C + ]; + + melodyNotes[bar].forEach((note, i) => { + pulse.playNote(note, beatDuration * 0.9, 80, barStart + i * beatDuration); + }); + + // Drums (noise channel) + noise.playKick(100, barStart); + noise.playHihat(60, false, barStart + beatDuration * 0.5); + noise.playSnare(90, barStart + beatDuration); + noise.playHihat(60, false, barStart + beatDuration * 1.5); + noise.playKick(80, barStart + beatDuration * 2); + noise.playHihat(60, false, barStart + beatDuration * 2.5); + noise.playSnare(90, barStart + beatDuration * 3); + noise.playHihat(60, true, barStart + beatDuration * 3.5); + } + + // Wait for playback to complete + await sleep(4 * 4 * beatDuration * 1000 + 500); + + console.log('Combined test complete!'); +} + +/** + * Run all audio tests sequentially. + */ +export async function runAllAudioTests(audioContext: AudioContext): Promise { + const { pulse, wave, noise } = createTestChannels(audioContext); + + console.log('=== Wario Synth v2 Audio Tests ===\n'); + + await testDutyCycles(pulse, audioContext); + await sleep(500); + + await testWaveChannel(wave, audioContext); + await sleep(500); + + await testNoiseChannel(noise, audioContext); + await sleep(500); + + await testCombined(pulse, wave, noise, audioContext); + + console.log('\n=== All Audio Tests Complete ==='); +} + +/** + * Helper: sleep for a given duration. + */ +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} diff --git a/src-v2/core/GameBoyPlayer.ts b/src-v2/core/GameBoyPlayer.ts new file mode 100644 index 0000000..9fc5605 --- /dev/null +++ b/src-v2/core/GameBoyPlayer.ts @@ -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 { + /** 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; +} + +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 = {}) { + 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 { + // 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): 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 { + 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); + } +} diff --git a/src-v2/index.ts b/src-v2/index.ts new file mode 100644 index 0000000..b2ce2e9 --- /dev/null +++ b/src-v2/index.ts @@ -0,0 +1,77 @@ +/** + * Wario Synth v2 - Main Exports + * + * Game Boy-authentic synthesis engine + */ + +// Core Player - Main entry point +export { GameBoyPlayer } from './core/GameBoyPlayer'; + +// APU and Channels +export { GameBoyAPU } from './audio/apu/APU'; +export { PulseChannel } from './audio/apu/PulseChannel'; +export { WaveChannel } from './audio/apu/WaveChannel'; +export { NoiseChannel } from './audio/apu/NoiseChannel'; + +// Synthesis primitives +export { + DUTY_PATTERNS, + createDutyWave, + createAllDutyWaves, + getDutyDescription, + type DutyIndex +} from './audio/synthesis/DutyCycle'; + +export { + calculatePulseFrequency, + calculateWaveFrequency, + calculateNoiseFrequency, + midiToStandardFrequency, + getFrequencyDeviation +} from './audio/synthesis/FrequencyCalc'; + +export { + LFSR, + generateNoiseBuffer, + verifyLFSR, + type LFSRMode +} from './audio/synthesis/LFSR'; + +export { + WaveTable, + WAVE_PRESETS, + generateTriangleWave, + generateSawtoothWave, + generateSineWave, + generateSquareWave, + generateBassWave, + generatePadWave, + generateLeadWave, + type WavePreset, + type WaveVolume +} from './audio/synthesis/WaveTable'; + +// MIDI Intelligence +export { TrackAnalyzer, type MIDINote, type MIDITrack } from './audio/midi/TrackAnalyzer'; +export { Arpeggiator, type ArpeggiatorConfig } from './audio/midi/Arpeggiator'; +export { ChannelMapper, type ChannelMapperConfig } from './audio/midi/ChannelMapper'; + +// Arranger (makes sparse MIDIs sound full like real GB music) +export { GameBoyArranger, type ArrangerConfig, type ArrangementResult } from './audio/arranger/GameBoyArranger'; + +// Effects +export { GameBoyColorizer, type ColorizerConfig } from './audio/effects/GameBoyColorizer'; + +// Types +export * from './types'; + +// Tests +export { + runVerificationTests, + createTestChannels, + testDutyCycles, + testWaveChannel, + testNoiseChannel, + testCombined, + runAllAudioTests +} from './audio/test/soundTest'; diff --git a/src-v2/types/index.ts b/src-v2/types/index.ts new file mode 100644 index 0000000..bdc567b --- /dev/null +++ b/src-v2/types/index.ts @@ -0,0 +1,151 @@ +/** + * Wario Synth v2 Type Definitions + * + * Types specific to the Game Boy-authentic synthesis engine. + */ + +import type { DutyIndex } from '../audio/synthesis/DutyCycle'; +import type { LFSRMode } from '../audio/synthesis/LFSR'; +import type { WavePreset, WaveVolume } from '../audio/synthesis/WaveTable'; + +/** + * Channel identifiers for the 8-channel "Super Game Boy" setup + */ +export type PulseChannelId = 'p1' | 'p2' | 'p3' | 'p4'; +export type WaveChannelId = 'w1' | 'w2'; +export type NoiseChannelId = 'n1' | 'n2'; +export type ChannelId = PulseChannelId | WaveChannelId | NoiseChannelId; + +/** + * A note scheduled to play on a specific channel + */ +export interface ChannelNote { + channel: ChannelId; + midiNote: number; + startTime: number; // In seconds from playback start + duration: number; // In seconds + velocity: number; // 0-127 +} + +/** + * Pulse channel configuration + */ +export interface PulseChannelConfig { + id: PulseChannelId; + hasSweep: boolean; + defaultDuty: DutyIndex; + role: 'lead' | 'fx' | 'harmony' | 'arp'; +} + +/** + * Wave channel configuration + */ +export interface WaveChannelConfig { + id: WaveChannelId; + preset: WavePreset; + role: 'bass' | 'texture'; +} + +/** + * Noise channel configuration + */ +export interface NoiseChannelConfig { + id: NoiseChannelId; + mode: LFSRMode; + role: 'percussion' | 'hihats'; +} + +/** + * Track role for MIDI analysis + */ +export type TrackRole = 'drums' | 'bass' | 'lead' | 'harmony' | 'pad' | 'fx'; + +/** + * Analysis result for a single MIDI track + */ +export interface TrackAnalysis { + trackIndex: number; + channel: number; // MIDI channel (0-15, 9 = drums) + isDrums: boolean; + isPercussive: boolean; + noteRange: { + min: number; + max: number; + avg: number; + }; + noteDensity: number; // Notes per second + complexity: number; // 0-1 score + hasChords: boolean; + avgVelocity: number; + avgDuration: number; + noteCount: number; + role: TrackRole; + priority: number; // Higher = more important +} + +/** + * Assignment of a MIDI track to a GB channel + */ +export interface ChannelAssignment { + trackIndex: number; + channelId: ChannelId; + shouldArpeggiate: boolean; + dutyCycle?: DutyIndex; // For pulse channels + wavePreset?: WavePreset; // For wave channels + noiseMode?: LFSRMode; // For noise channels +} + +/** + * Note format for arpeggiator processing + */ +export interface ArpNote { + midiNote: number; + time: number; + duration: number; + velocity: number; +} + +/** + * Grouped chord for arpeggiator + */ +export interface ArpChord { + startTime: number; + notes: ArpNote[]; +} + +/** + * v2 Engine configuration + */ +export interface V2Config { + masterVolume: number; // 0-1 + lookaheadTime: number; // Scheduling lookahead in seconds + scheduleInterval: number; // Scheduler interval in ms +} + +/** + * Default configuration values + */ +export const DEFAULT_V2_CONFIG: V2Config = { + masterVolume: 0.7, + lookaheadTime: 0.1, + scheduleInterval: 25, +}; + +/** + * Channel state for APU + */ +export interface ChannelState { + id: ChannelId; + isBusy: boolean; + busyUntil: number; // AudioContext time when channel becomes free + currentGain: number; +} + +/** + * Playback result info + */ +export interface PlaybackInfo { + duration: number; + assignments: ChannelAssignment[]; + noteCount: number; +} diff --git a/v2-diagnostic.html b/v2-diagnostic.html new file mode 100644 index 0000000..a1883a6 --- /dev/null +++ b/v2-diagnostic.html @@ -0,0 +1,528 @@ + + + + + + V2 Sound Diagnostic + + + +

🔧 V2 Sound Diagnostic

+

Test each audio component in isolation to identify issues.

+ +

1. Baseline Tests

+ +
+

Built-in Oscillators (Should definitely work)

+

These use Web Audio's built-in waveforms. If these don't sound right, there's an audio context issue.

+ + + + +
+ +

2. Pulse Channel Tests

+ +
+

A/B Comparison: Built-in vs Our PeriodicWave

+

Click these in sequence. The 50% duty SHOULD sound identical to the built-in square.

+
+ + +
+

If they sound DIFFERENT, our PeriodicWave is broken.

+
+ +
+

All 4 Duty Cycles

+

12.5% = thin/buzzy, 25% = classic chiptune, 50% = full square, 75% = inverted 25%

+ + + + +
+ +
+

Frequency Comparison

+

Standard frequency vs GB-quantized frequency. Should be nearly identical.

+
+ + +
+
+ +

3. Noise Channel Tests

+ +
+

LFSR Noise

+

7-bit = harsher/metallic, 15-bit = smoother white noise

+ + + +
+ +

4. Wave Channel Tests

+ +
+

4-bit Wavetable

+

Should sound distinctly lo-fi compared to smooth sine.

+
+ + +
+ + +
+ +

5. Full Channel Tests (Through APU)

+ +
+

Complete V2 Pipeline

+

Tests the full path through APU and Colorizer.

+ + + +
+ +

Log

+
+ + + + diff --git a/v2-test.html b/v2-test.html new file mode 100644 index 0000000..e9fab7c --- /dev/null +++ b/v2-test.html @@ -0,0 +1,406 @@ + + + + + + Wario Synth v2 - Sound Test + + + +
+

Wario Synth v2

+

Game Boy Sound Engine Test Suite

+ +
+ ⚠️ This is the v2 test page. The main site (v1) is unaffected. + Click any button to start audio (required by browsers). +
+ +
+

Verification Tests

+
+ +
+
+
+ +
+

Audio Tests Ready

+
+ + + +
+
+ + +
+
+ +
+

Quick Sound Check

+
+ + + + + + + +
+
+ +
+

Console Log

+
+
+
+ + + + diff --git a/v2.html b/v2.html new file mode 100644 index 0000000..b02c956 --- /dev/null +++ b/v2.html @@ -0,0 +1,834 @@ + + + + + + Wario Synth v2 - Game Boy Sound Engine + + + + + + +
+
+

🎮 WARIO SYNTH

+

Game Boy Sound Engine

+ v2.0 BETA +
+ +
+

SEARCH BITMIDI

+ +
+ +
OR
+ +
+
🎵
+

Drop a MIDI file here or click to browse

+ + +
+
+ +
+

PLAYBACK

+
+ + +
+ VOL: + +
+ READY +
+ +
+
+
DURATION
+
--:--
+
+
+
NOTES
+
---
+
+
+
TEMPO
+
--- BPM
+
+
+
TRACKS
+
---
+
+
+
+ +
+

CHANNELS

+
+
P1
-
+
P2
-
+
P3
-
+
P4
-
+
W1
-
+
W2
-
+
N1
-
+
N2
-
+
+ +
+ Quick test: + + + + +
+ +
+ Sound mode: + + + + +
+ +
+ Arranger: + + Makes sparse MIDIs sound full +
+
+ +
+

LOG

+
+
+ + +
+ + + +