Add v2 Game Boy sound engine (isolated from v1)
- Authentic DMG-CPU sound chip implementation: - 4 Pulse channels with duty cycle control (12.5%, 25%, 50%, 75%) - 2 Wave channels with 4-bit wavetables - 2 Noise channels with LFSR (7-bit and 15-bit modes) - GameBoy Colorizer effect chain: - Low-pass filter (natural GB rolloff) - Bit-crushing (4-bit DAC simulation) - Sample rate reduction - Saturation and high-pass filter - Presets: DMG, GBC, GBA, Clean - Intelligent MIDI processing: - Track analysis and role detection (bass, lead, drums, etc.) - Automatic channel mapping to GB channels - Chord arpeggiator for polyphony handling - GameBoy Arranger for fuller sound - BitMidi search integration - Completely isolated from v1 (no changes to src/)
This commit is contained in:
@@ -0,0 +1,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
|
||||
@@ -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<PulseChannelId, PulseChannel> = new Map();
|
||||
private waveChannels: Map<WaveChannelId, WaveChannel> = new Map();
|
||||
private noiseChannels: Map<NoiseChannelId, NoiseChannel> = new Map();
|
||||
|
||||
// Per-channel gain nodes for mixing
|
||||
private channelGains: Map<ChannelId, GainNode> = new Map();
|
||||
|
||||
// Channel state tracking
|
||||
private channelStates: Map<ChannelId, ChannelState> = new Map();
|
||||
|
||||
// Note scheduling stats (no limit - Web Audio handles scheduling)
|
||||
private scheduledNoteCount = 0;
|
||||
|
||||
constructor(audioContext?: AudioContext, config?: Partial<V2Config>) {
|
||||
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<void> {
|
||||
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<ChannelId, ChannelState> {
|
||||
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';
|
||||
@@ -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 = [];
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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<ChannelId, number>;
|
||||
};
|
||||
}
|
||||
|
||||
export class GameBoyArranger {
|
||||
private config: ArrangerConfig;
|
||||
|
||||
constructor(config: Partial<ArrangerConfig> = {}) {
|
||||
this.config = { ...DEFAULT_CONFIG, ...config };
|
||||
}
|
||||
|
||||
/**
|
||||
* Set configuration.
|
||||
*/
|
||||
setConfig(config: Partial<ArrangerConfig>): void {
|
||||
this.config = { ...this.config, ...config };
|
||||
}
|
||||
|
||||
/**
|
||||
* Set BPM for timing calculations.
|
||||
*/
|
||||
setBPM(bpm: number): void {
|
||||
this.config.bpm = bpm;
|
||||
}
|
||||
|
||||
/**
|
||||
* Arrange and enhance notes for fuller GB sound.
|
||||
*/
|
||||
arrange(
|
||||
notes: ChannelNote[],
|
||||
assignments: ChannelAssignment[],
|
||||
duration: number
|
||||
): ArrangementResult {
|
||||
const originalCount = notes.length;
|
||||
let enhanced = [...notes];
|
||||
|
||||
// Group notes by channel
|
||||
const byChannel = this.groupByChannel(enhanced);
|
||||
|
||||
// 1. Enhance bass
|
||||
if (this.config.enhanceBass) {
|
||||
const bassChannels = ['w1', 'w2'] as ChannelId[];
|
||||
for (const channelId of bassChannels) {
|
||||
if (byChannel.has(channelId)) {
|
||||
const bassNotes = byChannel.get(channelId)!;
|
||||
const enhancedBass = this.enhanceBass(bassNotes, duration);
|
||||
byChannel.set(channelId, enhancedBass);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Enhance drums
|
||||
if (this.config.enhanceDrums) {
|
||||
const noiseChannels = ['n1', 'n2'] as ChannelId[];
|
||||
for (const channelId of noiseChannels) {
|
||||
const drumNotes = byChannel.get(channelId) || [];
|
||||
const enhancedDrums = this.enhanceDrums(drumNotes, duration, channelId);
|
||||
byChannel.set(channelId, enhancedDrums);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Double melody if spare pulse channel available
|
||||
if (this.config.doubleMelody) {
|
||||
const pulseChannels = ['p1', 'p2', 'p3', 'p4'] as ChannelId[];
|
||||
const usedPulse = pulseChannels.filter(c =>
|
||||
byChannel.has(c) && byChannel.get(c)!.length > 0
|
||||
);
|
||||
const sparePulse = pulseChannels.filter(c => !usedPulse.includes(c));
|
||||
|
||||
if (sparePulse.length > 0 && usedPulse.length > 0) {
|
||||
// Find the lead channel (most notes, highest pitch)
|
||||
const leadChannel = this.findLeadChannel(byChannel, usedPulse);
|
||||
if (leadChannel && byChannel.get(leadChannel)) {
|
||||
const doubled = this.doubleMelody(
|
||||
byChannel.get(leadChannel)!,
|
||||
sparePulse[0]
|
||||
);
|
||||
byChannel.set(sparePulse[0], doubled);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Fill gaps in all channels
|
||||
if (this.config.fillGaps) {
|
||||
for (const [channelId, channelNotes] of byChannel) {
|
||||
const filled = this.fillGaps(channelNotes, duration, channelId);
|
||||
byChannel.set(channelId, filled);
|
||||
}
|
||||
}
|
||||
|
||||
// Flatten back to array
|
||||
enhanced = [];
|
||||
for (const channelNotes of byChannel.values()) {
|
||||
enhanced.push(...channelNotes);
|
||||
}
|
||||
|
||||
// Sort by time
|
||||
enhanced.sort((a, b) => a.startTime - b.startTime);
|
||||
|
||||
// Calculate utilization stats
|
||||
const utilization = this.calculateUtilization(byChannel, duration);
|
||||
|
||||
return {
|
||||
notes: enhanced,
|
||||
stats: {
|
||||
originalNotes: originalCount,
|
||||
addedNotes: enhanced.length - originalCount,
|
||||
channelUtilization: utilization,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Group notes by channel.
|
||||
*/
|
||||
private groupByChannel(notes: ChannelNote[]): Map<ChannelId, ChannelNote[]> {
|
||||
const grouped = new Map<ChannelId, ChannelNote[]>();
|
||||
|
||||
for (const note of notes) {
|
||||
if (!grouped.has(note.channel)) {
|
||||
grouped.set(note.channel, []);
|
||||
}
|
||||
grouped.get(note.channel)!.push(note);
|
||||
}
|
||||
|
||||
// Sort each channel by time
|
||||
for (const channelNotes of grouped.values()) {
|
||||
channelNotes.sort((a, b) => a.startTime - b.startTime);
|
||||
}
|
||||
|
||||
return grouped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhance bass to be more rhythmically active.
|
||||
* GB bass doesn't just play root notes - it has rhythmic variation.
|
||||
*/
|
||||
private enhanceBass(notes: ChannelNote[], duration: number): ChannelNote[] {
|
||||
if (notes.length === 0) return notes;
|
||||
|
||||
const enhanced: ChannelNote[] = [];
|
||||
const beatDuration = 60 / this.config.bpm;
|
||||
|
||||
for (const note of notes) {
|
||||
// Keep original note
|
||||
enhanced.push(note);
|
||||
|
||||
// If note is long, add rhythmic subdivisions
|
||||
if (note.duration > beatDuration * 1.5) {
|
||||
// Add a "bounce" note halfway through
|
||||
const bounceTime = note.startTime + note.duration / 2;
|
||||
enhanced.push({
|
||||
...note,
|
||||
startTime: bounceTime,
|
||||
duration: Math.min(beatDuration * 0.5, note.duration / 4),
|
||||
velocity: note.velocity * 0.7,
|
||||
});
|
||||
}
|
||||
|
||||
// Add octave jump on strong beats occasionally
|
||||
if (note.duration > beatDuration * 2 && Math.random() > 0.6) {
|
||||
enhanced.push({
|
||||
...note,
|
||||
midiNote: note.midiNote + 12, // Octave up
|
||||
startTime: note.startTime + beatDuration,
|
||||
duration: beatDuration * 0.4,
|
||||
velocity: note.velocity * 0.6,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return enhanced.sort((a, b) => a.startTime - b.startTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhance drums with hi-hats and fills.
|
||||
* GB drums are BUSY - hi-hats on every 8th note.
|
||||
*/
|
||||
private enhanceDrums(
|
||||
notes: ChannelNote[],
|
||||
duration: number,
|
||||
channelId: ChannelId
|
||||
): ChannelNote[] {
|
||||
const enhanced = [...notes];
|
||||
const beatDuration = 60 / this.config.bpm;
|
||||
const subdivisionDuration = beatDuration / this.config.hihatRate;
|
||||
|
||||
// n2 is for hi-hats (15-bit noise = more "tsss")
|
||||
// n1 is for kick/snare (7-bit noise = more punchy)
|
||||
if (channelId === 'n2') {
|
||||
// Add hi-hats on every subdivision if not already occupied
|
||||
for (let time = 0; time < duration; time += subdivisionDuration) {
|
||||
const hasNote = notes.some(n =>
|
||||
Math.abs(n.startTime - time) < subdivisionDuration * 0.3
|
||||
);
|
||||
|
||||
if (!hasNote) {
|
||||
enhanced.push({
|
||||
channel: channelId,
|
||||
midiNote: 66, // High pitch = high frequency noise
|
||||
startTime: time,
|
||||
duration: subdivisionDuration * 0.4,
|
||||
velocity: 40 + Math.random() * 15, // Quieter, sits back in mix
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (channelId === 'n1') {
|
||||
// Ensure kick and snare on basic beats if sparse
|
||||
const kickTimes = this.getKickTimes(notes);
|
||||
const snareTimes = this.getSnareTimes(notes);
|
||||
|
||||
// Add kick on beat 1 and 3 if missing
|
||||
for (let beat = 0; beat < duration / beatDuration; beat++) {
|
||||
const beatTime = beat * beatDuration;
|
||||
|
||||
if (beat % 4 === 0 || beat % 4 === 2) { // Beats 1 and 3
|
||||
if (!kickTimes.some(t => Math.abs(t - beatTime) < beatDuration * 0.2)) {
|
||||
enhanced.push({
|
||||
channel: channelId,
|
||||
midiNote: 36, // Low pitch = low frequency noise (kick)
|
||||
startTime: beatTime,
|
||||
duration: 0.15,
|
||||
velocity: 100,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (beat % 4 === 1 || beat % 4 === 3) { // Beats 2 and 4
|
||||
if (!snareTimes.some(t => Math.abs(t - beatTime) < beatDuration * 0.2)) {
|
||||
enhanced.push({
|
||||
channel: channelId,
|
||||
midiNote: 48, // Mid pitch (snare)
|
||||
startTime: beatTime,
|
||||
duration: 0.1,
|
||||
velocity: 90,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return enhanced.sort((a, b) => a.startTime - b.startTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get times of kick-like notes.
|
||||
*/
|
||||
private getKickTimes(notes: ChannelNote[]): number[] {
|
||||
return notes
|
||||
.filter(n => n.midiNote < 45 && n.velocity > 80)
|
||||
.map(n => n.startTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get times of snare-like notes.
|
||||
*/
|
||||
private getSnareTimes(notes: ChannelNote[]): number[] {
|
||||
return notes
|
||||
.filter(n => n.midiNote >= 45 && n.midiNote < 55 && n.velocity > 70)
|
||||
.map(n => n.startTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Double the melody an octave up on a spare channel.
|
||||
*/
|
||||
private doubleMelody(
|
||||
leadNotes: ChannelNote[],
|
||||
targetChannel: ChannelId
|
||||
): ChannelNote[] {
|
||||
return leadNotes.map(note => ({
|
||||
...note,
|
||||
channel: targetChannel,
|
||||
midiNote: note.midiNote + 12, // Octave up
|
||||
velocity: Math.round(note.velocity * 0.5), // Quieter
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the lead channel (highest average pitch, most notes).
|
||||
*/
|
||||
private findLeadChannel(
|
||||
byChannel: Map<ChannelId, ChannelNote[]>,
|
||||
candidates: ChannelId[]
|
||||
): ChannelId | null {
|
||||
let bestChannel: ChannelId | null = null;
|
||||
let bestScore = 0;
|
||||
|
||||
for (const channelId of candidates) {
|
||||
const notes = byChannel.get(channelId);
|
||||
if (!notes || notes.length === 0) continue;
|
||||
|
||||
const avgPitch = notes.reduce((sum, n) => sum + n.midiNote, 0) / notes.length;
|
||||
const noteCount = notes.length;
|
||||
|
||||
// Score = high pitch + many notes
|
||||
const score = avgPitch / 127 * 0.5 + Math.min(1, noteCount / 100) * 0.5;
|
||||
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
bestChannel = channelId;
|
||||
}
|
||||
}
|
||||
|
||||
return bestChannel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill gaps in a channel with sustain notes or echoes.
|
||||
*/
|
||||
private fillGaps(
|
||||
notes: ChannelNote[],
|
||||
duration: number,
|
||||
channelId: ChannelId
|
||||
): ChannelNote[] {
|
||||
if (notes.length === 0) return notes;
|
||||
|
||||
const enhanced = [...notes];
|
||||
const minGap = this.config.minGapToFill;
|
||||
|
||||
// Find gaps
|
||||
for (let i = 0; i < notes.length - 1; i++) {
|
||||
const current = notes[i];
|
||||
const next = notes[i + 1];
|
||||
const gapStart = current.startTime + current.duration;
|
||||
const gapDuration = next.startTime - gapStart;
|
||||
|
||||
if (gapDuration > minGap) {
|
||||
// Add an echo/sustain note in the gap
|
||||
enhanced.push({
|
||||
channel: channelId,
|
||||
midiNote: current.midiNote,
|
||||
startTime: gapStart + 0.1,
|
||||
duration: Math.min(gapDuration - 0.2, 0.3),
|
||||
velocity: Math.round(current.velocity * 0.4), // Quiet echo
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check gap at the end
|
||||
const lastNote = notes[notes.length - 1];
|
||||
const endGap = duration - (lastNote.startTime + lastNote.duration);
|
||||
if (endGap > minGap * 2) {
|
||||
enhanced.push({
|
||||
channel: channelId,
|
||||
midiNote: lastNote.midiNote,
|
||||
startTime: lastNote.startTime + lastNote.duration + 0.1,
|
||||
duration: 0.3,
|
||||
velocity: Math.round(lastNote.velocity * 0.3),
|
||||
});
|
||||
}
|
||||
|
||||
return enhanced.sort((a, b) => a.startTime - b.startTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate channel utilization (0-1 for each channel).
|
||||
*/
|
||||
private calculateUtilization(
|
||||
byChannel: Map<ChannelId, ChannelNote[]>,
|
||||
duration: number
|
||||
): Record<ChannelId, number> {
|
||||
const utilization: Record<string, number> = {};
|
||||
|
||||
for (const [channelId, notes] of byChannel) {
|
||||
const totalNoteTime = notes.reduce((sum, n) => sum + n.duration, 0);
|
||||
utilization[channelId] = Math.min(1, totalNoteTime / duration);
|
||||
}
|
||||
|
||||
return utilization as Record<ChannelId, number>;
|
||||
}
|
||||
}
|
||||
@@ -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<ColorizerConfig> = {}) {
|
||||
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<ColorizerConfig>): 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<ColorizerConfig> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* Arpeggiator
|
||||
*
|
||||
* Converts chords (simultaneous notes) into fast arpeggios.
|
||||
* This is a classic Game Boy technique to simulate polyphony
|
||||
* on limited channels.
|
||||
*/
|
||||
|
||||
import type { ArpNote, ArpChord } from '../../types';
|
||||
|
||||
export interface ArpeggiatorConfig {
|
||||
/** Speed of arpeggiation in beats (1/64 = 64th note, 1/32 = 32nd note) */
|
||||
speed: number;
|
||||
|
||||
/** BPM for calculating actual timing */
|
||||
bpm: number;
|
||||
|
||||
/** Pattern: 'up', 'down', 'updown', 'random' */
|
||||
pattern: 'up' | 'down' | 'updown' | 'random';
|
||||
|
||||
/** Minimum number of notes to trigger arpeggiation (2 = any chord) */
|
||||
minNotes: number;
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: ArpeggiatorConfig = {
|
||||
speed: 1 / 32, // 32nd notes
|
||||
bpm: 120,
|
||||
pattern: 'up',
|
||||
minNotes: 2,
|
||||
};
|
||||
|
||||
export class Arpeggiator {
|
||||
private config: ArpeggiatorConfig;
|
||||
|
||||
constructor(config: Partial<ArpeggiatorConfig> = {}) {
|
||||
this.config = { ...DEFAULT_CONFIG, ...config };
|
||||
}
|
||||
|
||||
/**
|
||||
* Update configuration.
|
||||
*/
|
||||
setConfig(config: Partial<ArpeggiatorConfig>): void {
|
||||
this.config = { ...this.config, ...config };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current configuration.
|
||||
*/
|
||||
getConfig(): ArpeggiatorConfig {
|
||||
return { ...this.config };
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the duration of one arp step in seconds.
|
||||
*/
|
||||
private getStepDuration(): number {
|
||||
// One beat = 60 / BPM seconds
|
||||
// speed is in beats (e.g., 1/32 = 32nd note = 1/8 of a beat)
|
||||
const beatDuration = 60 / this.config.bpm;
|
||||
return beatDuration * this.config.speed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an array of notes to arpeggiated output.
|
||||
* Single notes pass through unchanged.
|
||||
* Chords are converted to fast arpeggios.
|
||||
*/
|
||||
arpeggiate(notes: ArpNote[]): ArpNote[] {
|
||||
if (notes.length === 0) return [];
|
||||
|
||||
// Group notes by time (detect chords)
|
||||
const chords = this.groupIntoChords(notes);
|
||||
|
||||
// Process each chord
|
||||
const result: ArpNote[] = [];
|
||||
|
||||
for (const chord of chords) {
|
||||
if (chord.notes.length < this.config.minNotes) {
|
||||
// Not enough notes for a chord, pass through
|
||||
result.push(...chord.notes);
|
||||
} else {
|
||||
// Arpeggiate the chord
|
||||
result.push(...this.arpeggiateChord(chord));
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by time
|
||||
return result.sort((a, b) => a.time - b.time);
|
||||
}
|
||||
|
||||
/**
|
||||
* Group notes into chords based on timing.
|
||||
*/
|
||||
private groupIntoChords(notes: ArpNote[]): ArpChord[] {
|
||||
const tolerance = 0.02; // 20ms tolerance
|
||||
const sorted = [...notes].sort((a, b) => a.time - b.time);
|
||||
|
||||
const chords: ArpChord[] = [];
|
||||
let currentChord: ArpChord | null = null;
|
||||
|
||||
for (const note of sorted) {
|
||||
if (!currentChord || note.time - currentChord.startTime > tolerance) {
|
||||
// Start a new chord
|
||||
currentChord = {
|
||||
startTime: note.time,
|
||||
notes: [note],
|
||||
};
|
||||
chords.push(currentChord);
|
||||
} else {
|
||||
// Add to current chord
|
||||
currentChord.notes.push(note);
|
||||
}
|
||||
}
|
||||
|
||||
return chords;
|
||||
}
|
||||
|
||||
/**
|
||||
* Arpeggiate a single chord.
|
||||
*/
|
||||
private arpeggiateChord(chord: ArpChord): ArpNote[] {
|
||||
const stepDuration = this.getStepDuration();
|
||||
|
||||
// Sort notes by pitch based on pattern
|
||||
const sortedNotes = this.sortNotesForPattern(chord.notes);
|
||||
|
||||
// Calculate how long the original chord should last
|
||||
const maxDuration = Math.max(...chord.notes.map(n => n.duration));
|
||||
|
||||
// Calculate how many complete cycles we can fit
|
||||
const cycleLength = sortedNotes.length * stepDuration;
|
||||
const numCycles = Math.max(1, Math.floor(maxDuration / cycleLength));
|
||||
|
||||
const result: ArpNote[] = [];
|
||||
let noteIndex = 0;
|
||||
let direction = 1; // For updown pattern
|
||||
|
||||
// Generate arpeggiated notes
|
||||
for (let cycle = 0; cycle < numCycles; cycle++) {
|
||||
for (let i = 0; i < sortedNotes.length; i++) {
|
||||
const originalNote = sortedNotes[noteIndex];
|
||||
const time = chord.startTime + (cycle * sortedNotes.length + i) * stepDuration;
|
||||
|
||||
// Only add if within original duration
|
||||
if (time < chord.startTime + maxDuration) {
|
||||
result.push({
|
||||
midiNote: originalNote.midiNote,
|
||||
time,
|
||||
duration: stepDuration * 0.9, // Slight gap between notes
|
||||
velocity: originalNote.velocity,
|
||||
});
|
||||
}
|
||||
|
||||
// Update index based on pattern
|
||||
if (this.config.pattern === 'updown') {
|
||||
noteIndex += direction;
|
||||
if (noteIndex >= sortedNotes.length - 1) {
|
||||
direction = -1;
|
||||
noteIndex = sortedNotes.length - 1;
|
||||
} else if (noteIndex <= 0) {
|
||||
direction = 1;
|
||||
noteIndex = 0;
|
||||
}
|
||||
} else if (this.config.pattern === 'random') {
|
||||
noteIndex = Math.floor(Math.random() * sortedNotes.length);
|
||||
} else {
|
||||
noteIndex = (noteIndex + 1) % sortedNotes.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort notes based on the arpeggio pattern.
|
||||
*/
|
||||
private sortNotesForPattern(notes: ArpNote[]): ArpNote[] {
|
||||
const sorted = [...notes];
|
||||
|
||||
switch (this.config.pattern) {
|
||||
case 'up':
|
||||
case 'updown':
|
||||
sorted.sort((a, b) => a.midiNote - b.midiNote);
|
||||
break;
|
||||
case 'down':
|
||||
sorted.sort((a, b) => b.midiNote - a.midiNote);
|
||||
break;
|
||||
case 'random':
|
||||
// Shuffle
|
||||
for (let i = sorted.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[sorted[i], sorted[j]] = [sorted[j], sorted[i]];
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return sorted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a set of notes would be arpeggiated.
|
||||
*/
|
||||
wouldArpeggiate(notes: ArpNote[]): boolean {
|
||||
const chords = this.groupIntoChords(notes);
|
||||
return chords.some(chord => chord.notes.length >= this.config.minNotes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set BPM (updates timing calculations).
|
||||
*/
|
||||
setBPM(bpm: number): void {
|
||||
this.config.bpm = Math.max(20, Math.min(300, bpm));
|
||||
}
|
||||
|
||||
/**
|
||||
* Set arpeggio speed.
|
||||
*/
|
||||
setSpeed(speed: number): void {
|
||||
this.config.speed = speed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set arpeggio pattern.
|
||||
*/
|
||||
setPattern(pattern: ArpeggiatorConfig['pattern']): void {
|
||||
this.config.pattern = pattern;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
/**
|
||||
* Channel Mapper
|
||||
*
|
||||
* Intelligently assigns MIDI tracks to the 8 GB channels based on
|
||||
* track analysis results. Prioritizes the most important tracks
|
||||
* and assigns them to the most appropriate channel types.
|
||||
*/
|
||||
|
||||
import { TrackAnalyzer, type MIDITrack } from './TrackAnalyzer';
|
||||
import type {
|
||||
ChannelAssignment,
|
||||
TrackAnalysis,
|
||||
ChannelId,
|
||||
PulseChannelId,
|
||||
WaveChannelId,
|
||||
NoiseChannelId,
|
||||
TrackRole
|
||||
} from '../../types';
|
||||
import type { DutyIndex } from '../synthesis/DutyCycle';
|
||||
import type { WavePreset } from '../synthesis/WaveTable';
|
||||
import type { LFSRMode } from '../synthesis/LFSR';
|
||||
|
||||
/**
|
||||
* Channel mapping configuration
|
||||
*/
|
||||
export interface ChannelMapperConfig {
|
||||
/** Maximum tracks to assign (limits complexity) */
|
||||
maxTracks: number;
|
||||
|
||||
/** Whether to arpeggiate harmony tracks */
|
||||
arpeggiateHarmony: boolean;
|
||||
|
||||
/** Default duty cycle for lead channels */
|
||||
leadDuty: DutyIndex;
|
||||
|
||||
/** Default duty cycle for harmony channels */
|
||||
harmonyDuty: DutyIndex;
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: ChannelMapperConfig = {
|
||||
maxTracks: 8,
|
||||
arpeggiateHarmony: true,
|
||||
leadDuty: 2, // 50% for full sound
|
||||
harmonyDuty: 1, // 25% for thinner, less intrusive sound
|
||||
};
|
||||
|
||||
/**
|
||||
* Available channel pools by type
|
||||
*/
|
||||
const CHANNEL_POOLS = {
|
||||
pulse: ['p1', 'p2', 'p3', 'p4'] as PulseChannelId[],
|
||||
wave: ['w1', 'w2'] as WaveChannelId[],
|
||||
noise: ['n1', 'n2'] as NoiseChannelId[],
|
||||
};
|
||||
|
||||
export class ChannelMapper {
|
||||
private analyzer: TrackAnalyzer;
|
||||
private config: ChannelMapperConfig;
|
||||
|
||||
constructor(config: Partial<ChannelMapperConfig> = {}) {
|
||||
this.analyzer = new TrackAnalyzer();
|
||||
this.config = { ...DEFAULT_CONFIG, ...config };
|
||||
}
|
||||
|
||||
/**
|
||||
* Update configuration.
|
||||
*/
|
||||
setConfig(config: Partial<ChannelMapperConfig>): void {
|
||||
this.config = { ...this.config, ...config };
|
||||
}
|
||||
|
||||
/**
|
||||
* Map MIDI tracks to GB channels.
|
||||
* Returns an array of channel assignments.
|
||||
*/
|
||||
mapTracks(tracks: MIDITrack[]): ChannelAssignment[] {
|
||||
// Analyze all tracks
|
||||
const analyses = this.analyzer.analyzeTracks(tracks);
|
||||
|
||||
// Filter out empty tracks
|
||||
const nonEmptyAnalyses = analyses.filter(a => a.noteCount > 0);
|
||||
|
||||
// Track used channels
|
||||
const usedChannels = new Set<ChannelId>();
|
||||
|
||||
// Assign channels in priority order
|
||||
const assignments: ChannelAssignment[] = [];
|
||||
|
||||
for (const analysis of nonEmptyAnalyses) {
|
||||
if (assignments.length >= this.config.maxTracks) break;
|
||||
|
||||
const assignment = this.assignChannel(analysis, usedChannels);
|
||||
if (assignment) {
|
||||
assignments.push(assignment);
|
||||
usedChannels.add(assignment.channelId);
|
||||
}
|
||||
}
|
||||
|
||||
return assignments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign a single track to a channel.
|
||||
*/
|
||||
private assignChannel(
|
||||
analysis: TrackAnalysis,
|
||||
usedChannels: Set<ChannelId>
|
||||
): ChannelAssignment | null {
|
||||
const { role, hasChords } = analysis;
|
||||
|
||||
// Route to appropriate channel type based on role
|
||||
switch (role) {
|
||||
case 'drums':
|
||||
return this.assignDrums(analysis, usedChannels);
|
||||
|
||||
case 'bass':
|
||||
return this.assignBass(analysis, usedChannels);
|
||||
|
||||
case 'lead':
|
||||
return this.assignLead(analysis, usedChannels);
|
||||
|
||||
case 'harmony':
|
||||
return this.assignHarmony(analysis, usedChannels, hasChords);
|
||||
|
||||
case 'pad':
|
||||
return this.assignPad(analysis, usedChannels);
|
||||
|
||||
case 'fx':
|
||||
return this.assignFX(analysis, usedChannels);
|
||||
|
||||
default:
|
||||
// Fallback to any available pulse channel
|
||||
return this.assignToAnyPulse(analysis, usedChannels);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign drums to noise channels.
|
||||
*/
|
||||
private assignDrums(
|
||||
analysis: TrackAnalysis,
|
||||
usedChannels: Set<ChannelId>
|
||||
): ChannelAssignment | null {
|
||||
// Try noise channels first
|
||||
const channel = this.findFreeChannel(CHANNEL_POOLS.noise, usedChannels);
|
||||
|
||||
if (!channel) return null;
|
||||
|
||||
// Use 7-bit for kick/snare, 15-bit for hihats
|
||||
// Default to 7-bit as it's punchier
|
||||
const noiseMode: LFSRMode = '7bit';
|
||||
|
||||
return {
|
||||
trackIndex: analysis.trackIndex,
|
||||
channelId: channel,
|
||||
shouldArpeggiate: false,
|
||||
noiseMode,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign bass to wave channel.
|
||||
*/
|
||||
private assignBass(
|
||||
analysis: TrackAnalysis,
|
||||
usedChannels: Set<ChannelId>
|
||||
): ChannelAssignment | null {
|
||||
// Prefer w1 for bass
|
||||
if (!usedChannels.has('w1')) {
|
||||
return {
|
||||
trackIndex: analysis.trackIndex,
|
||||
channelId: 'w1',
|
||||
shouldArpeggiate: false,
|
||||
wavePreset: 'bass' as WavePreset,
|
||||
};
|
||||
}
|
||||
|
||||
// Fall back to w2
|
||||
if (!usedChannels.has('w2')) {
|
||||
return {
|
||||
trackIndex: analysis.trackIndex,
|
||||
channelId: 'w2',
|
||||
shouldArpeggiate: false,
|
||||
wavePreset: 'bass' as WavePreset,
|
||||
};
|
||||
}
|
||||
|
||||
// No wave channels available, try pulse with low duty
|
||||
const pulseChannel = this.findFreeChannel(CHANNEL_POOLS.pulse, usedChannels);
|
||||
if (pulseChannel) {
|
||||
return {
|
||||
trackIndex: analysis.trackIndex,
|
||||
channelId: pulseChannel,
|
||||
shouldArpeggiate: false,
|
||||
dutyCycle: 2 as DutyIndex, // 50% for fuller bass
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign lead melody to pulse channels.
|
||||
*/
|
||||
private assignLead(
|
||||
analysis: TrackAnalysis,
|
||||
usedChannels: Set<ChannelId>
|
||||
): ChannelAssignment | null {
|
||||
// Prefer p1 or p2 (sweep-capable) for lead
|
||||
for (const channelId of ['p1', 'p2'] as PulseChannelId[]) {
|
||||
if (!usedChannels.has(channelId)) {
|
||||
return {
|
||||
trackIndex: analysis.trackIndex,
|
||||
channelId,
|
||||
shouldArpeggiate: false,
|
||||
dutyCycle: this.config.leadDuty,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to p3/p4
|
||||
const channel = this.findFreeChannel(['p3', 'p4'] as PulseChannelId[], usedChannels);
|
||||
if (channel) {
|
||||
return {
|
||||
trackIndex: analysis.trackIndex,
|
||||
channelId: channel,
|
||||
shouldArpeggiate: false,
|
||||
dutyCycle: this.config.leadDuty,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign harmony to pulse channels (with optional arpeggio).
|
||||
*/
|
||||
private assignHarmony(
|
||||
analysis: TrackAnalysis,
|
||||
usedChannels: Set<ChannelId>,
|
||||
hasChords: boolean
|
||||
): ChannelAssignment | null {
|
||||
// Use p3/p4 for harmony (thinner sound, no sweep)
|
||||
const channel = this.findFreeChannel(['p3', 'p4', 'p1', 'p2'] as PulseChannelId[], usedChannels);
|
||||
|
||||
if (!channel) return null;
|
||||
|
||||
return {
|
||||
trackIndex: analysis.trackIndex,
|
||||
channelId: channel,
|
||||
shouldArpeggiate: this.config.arpeggiateHarmony && hasChords,
|
||||
dutyCycle: this.config.harmonyDuty,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign pad to wave channel.
|
||||
*/
|
||||
private assignPad(
|
||||
analysis: TrackAnalysis,
|
||||
usedChannels: Set<ChannelId>
|
||||
): ChannelAssignment | null {
|
||||
// Prefer w2 for pads
|
||||
if (!usedChannels.has('w2')) {
|
||||
return {
|
||||
trackIndex: analysis.trackIndex,
|
||||
channelId: 'w2',
|
||||
shouldArpeggiate: false,
|
||||
wavePreset: 'pad' as WavePreset,
|
||||
};
|
||||
}
|
||||
|
||||
// Fall back to w1
|
||||
if (!usedChannels.has('w1')) {
|
||||
return {
|
||||
trackIndex: analysis.trackIndex,
|
||||
channelId: 'w1',
|
||||
shouldArpeggiate: false,
|
||||
wavePreset: 'pad' as WavePreset,
|
||||
};
|
||||
}
|
||||
|
||||
// Fall back to pulse
|
||||
const pulseChannel = this.findFreeChannel(CHANNEL_POOLS.pulse, usedChannels);
|
||||
if (pulseChannel) {
|
||||
return {
|
||||
trackIndex: analysis.trackIndex,
|
||||
channelId: pulseChannel,
|
||||
shouldArpeggiate: false,
|
||||
dutyCycle: 2 as DutyIndex,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign FX/incidental to any available pulse channel.
|
||||
*/
|
||||
private assignFX(
|
||||
analysis: TrackAnalysis,
|
||||
usedChannels: Set<ChannelId>
|
||||
): ChannelAssignment | null {
|
||||
// FX goes to any available pulse channel
|
||||
const channel = this.findFreeChannel(CHANNEL_POOLS.pulse, usedChannels);
|
||||
|
||||
if (!channel) return null;
|
||||
|
||||
return {
|
||||
trackIndex: analysis.trackIndex,
|
||||
channelId: channel,
|
||||
shouldArpeggiate: false,
|
||||
dutyCycle: 0 as DutyIndex, // 12.5% for thin, effects-like sound
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign to any available pulse channel.
|
||||
*/
|
||||
private assignToAnyPulse(
|
||||
analysis: TrackAnalysis,
|
||||
usedChannels: Set<ChannelId>
|
||||
): ChannelAssignment | null {
|
||||
const channel = this.findFreeChannel(CHANNEL_POOLS.pulse, usedChannels);
|
||||
|
||||
if (!channel) return null;
|
||||
|
||||
return {
|
||||
trackIndex: analysis.trackIndex,
|
||||
channelId: channel,
|
||||
shouldArpeggiate: false,
|
||||
dutyCycle: 2 as DutyIndex,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the first free channel from a pool.
|
||||
*/
|
||||
private findFreeChannel<T extends ChannelId>(
|
||||
pool: T[],
|
||||
usedChannels: Set<ChannelId>
|
||||
): T | null {
|
||||
for (const channel of pool) {
|
||||
if (!usedChannels.has(channel)) {
|
||||
return channel;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the analyzer for external use.
|
||||
*/
|
||||
getAnalyzer(): TrackAnalyzer {
|
||||
return this.analyzer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze tracks without mapping (useful for UI display).
|
||||
*/
|
||||
analyzeTracks(tracks: MIDITrack[]): TrackAnalysis[] {
|
||||
return this.analyzer.analyzeTracks(tracks);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
/**
|
||||
* MIDI Track Analyzer
|
||||
*
|
||||
* Analyzes MIDI tracks to determine their musical role and characteristics.
|
||||
* This information is used by the ChannelMapper to intelligently assign
|
||||
* tracks to the appropriate GB channels.
|
||||
*/
|
||||
|
||||
import type { TrackAnalysis, TrackRole } from '../../types';
|
||||
|
||||
/**
|
||||
* Raw note data from a MIDI track
|
||||
*/
|
||||
export interface MIDINote {
|
||||
midi: number; // MIDI note number (0-127)
|
||||
time: number; // Start time in seconds
|
||||
duration: number; // Duration in seconds
|
||||
velocity: number; // Velocity (0-127)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsed track data from a MIDI file
|
||||
*/
|
||||
export interface MIDITrack {
|
||||
channel: number; // MIDI channel (0-15)
|
||||
notes: MIDINote[];
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export class TrackAnalyzer {
|
||||
|
||||
/**
|
||||
* Analyze a single MIDI track and determine its characteristics.
|
||||
*/
|
||||
analyzeTrack(track: MIDITrack, trackIndex: number): TrackAnalysis {
|
||||
const notes = track.notes;
|
||||
|
||||
if (notes.length === 0) {
|
||||
return this.createEmptyAnalysis(trackIndex, track.channel);
|
||||
}
|
||||
|
||||
// Calculate basic statistics
|
||||
const noteRange = this.calculateNoteRange(notes);
|
||||
const noteDensity = this.calculateNoteDensity(notes);
|
||||
const avgVelocity = this.calculateAverageVelocity(notes);
|
||||
const avgDuration = this.calculateAverageDuration(notes);
|
||||
const complexity = this.calculateComplexity(notes);
|
||||
const hasChords = this.detectChords(notes);
|
||||
|
||||
// Detect if this is a drums track
|
||||
const isDrums = track.channel === 9 || this.detectDrums(notes);
|
||||
const isPercussive = this.detectPercussive(notes);
|
||||
|
||||
// Determine the musical role
|
||||
const role = this.detectRole(notes, noteRange, isDrums, noteDensity, hasChords, avgDuration);
|
||||
|
||||
// Calculate priority for channel assignment
|
||||
const priority = this.calculatePriority(role, noteDensity, avgVelocity, notes.length);
|
||||
|
||||
return {
|
||||
trackIndex,
|
||||
channel: track.channel,
|
||||
isDrums,
|
||||
isPercussive,
|
||||
noteRange,
|
||||
noteDensity,
|
||||
complexity,
|
||||
hasChords,
|
||||
avgVelocity,
|
||||
avgDuration,
|
||||
noteCount: notes.length,
|
||||
role,
|
||||
priority,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze multiple tracks and return sorted by priority.
|
||||
*/
|
||||
analyzeTracks(tracks: MIDITrack[]): TrackAnalysis[] {
|
||||
const analyses = tracks.map((track, index) => this.analyzeTrack(track, index));
|
||||
|
||||
// Sort by priority (highest first)
|
||||
return analyses.sort((a, b) => b.priority - a.priority);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an empty analysis for a track with no notes.
|
||||
*/
|
||||
private createEmptyAnalysis(trackIndex: number, channel: number): TrackAnalysis {
|
||||
return {
|
||||
trackIndex,
|
||||
channel,
|
||||
isDrums: false,
|
||||
isPercussive: false,
|
||||
noteRange: { min: 0, max: 0, avg: 0 },
|
||||
noteDensity: 0,
|
||||
complexity: 0,
|
||||
hasChords: false,
|
||||
avgVelocity: 0,
|
||||
avgDuration: 0,
|
||||
noteCount: 0,
|
||||
role: 'fx',
|
||||
priority: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the note range (min, max, average pitch).
|
||||
*/
|
||||
private calculateNoteRange(notes: MIDINote[]): { min: number; max: number; avg: number } {
|
||||
if (notes.length === 0) {
|
||||
return { min: 0, max: 0, avg: 0 };
|
||||
}
|
||||
|
||||
let min = 127;
|
||||
let max = 0;
|
||||
let sum = 0;
|
||||
|
||||
for (const note of notes) {
|
||||
min = Math.min(min, note.midi);
|
||||
max = Math.max(max, note.midi);
|
||||
sum += note.midi;
|
||||
}
|
||||
|
||||
return {
|
||||
min,
|
||||
max,
|
||||
avg: sum / notes.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate note density (notes per second).
|
||||
*/
|
||||
private calculateNoteDensity(notes: MIDINote[]): number {
|
||||
if (notes.length < 2) return 0;
|
||||
|
||||
const startTime = notes[0].time;
|
||||
const endTime = notes[notes.length - 1].time + notes[notes.length - 1].duration;
|
||||
const duration = endTime - startTime;
|
||||
|
||||
if (duration <= 0) return 0;
|
||||
|
||||
return notes.length / duration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate average velocity.
|
||||
*/
|
||||
private calculateAverageVelocity(notes: MIDINote[]): number {
|
||||
if (notes.length === 0) return 0;
|
||||
|
||||
const sum = notes.reduce((acc, note) => acc + note.velocity, 0);
|
||||
return sum / notes.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate average note duration.
|
||||
*/
|
||||
private calculateAverageDuration(notes: MIDINote[]): number {
|
||||
if (notes.length === 0) return 0;
|
||||
|
||||
const sum = notes.reduce((acc, note) => acc + note.duration, 0);
|
||||
return sum / notes.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate complexity score (0-1).
|
||||
* Based on pitch variation, rhythm variation, and density.
|
||||
*/
|
||||
private calculateComplexity(notes: MIDINote[]): number {
|
||||
if (notes.length < 2) return 0;
|
||||
|
||||
// Pitch variation
|
||||
const range = this.calculateNoteRange(notes);
|
||||
const pitchVariation = Math.min(1, (range.max - range.min) / 36); // Normalize to 3 octaves
|
||||
|
||||
// Rhythm variation (variance in inter-note timing)
|
||||
const timeDiffs: number[] = [];
|
||||
for (let i = 1; i < notes.length; i++) {
|
||||
timeDiffs.push(notes[i].time - notes[i - 1].time);
|
||||
}
|
||||
|
||||
if (timeDiffs.length === 0) return pitchVariation * 0.5;
|
||||
|
||||
const avgTimeDiff = timeDiffs.reduce((a, b) => a + b, 0) / timeDiffs.length;
|
||||
const timeVariance = timeDiffs.reduce((acc, t) => acc + Math.pow(t - avgTimeDiff, 2), 0) / timeDiffs.length;
|
||||
const rhythmVariation = Math.min(1, Math.sqrt(timeVariance) / avgTimeDiff);
|
||||
|
||||
return (pitchVariation * 0.6 + rhythmVariation * 0.4);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if notes contain chords (multiple simultaneous notes).
|
||||
*/
|
||||
private detectChords(notes: MIDINote[]): boolean {
|
||||
// Group notes by time (10ms tolerance)
|
||||
const tolerance = 0.01;
|
||||
const timeSlots = new Map<number, number>();
|
||||
|
||||
for (const note of notes) {
|
||||
const slot = Math.floor(note.time / tolerance);
|
||||
timeSlots.set(slot, (timeSlots.get(slot) || 0) + 1);
|
||||
}
|
||||
|
||||
// Count how many slots have more than 2 notes
|
||||
let chordSlots = 0;
|
||||
for (const count of timeSlots.values()) {
|
||||
if (count >= 2) chordSlots++;
|
||||
}
|
||||
|
||||
// If more than 10% of time slots have chords, this track has chords
|
||||
return chordSlots > timeSlots.size * 0.1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if this is a drums track (based on note patterns, not just channel).
|
||||
*/
|
||||
private detectDrums(notes: MIDINote[]): boolean {
|
||||
if (notes.length < 4) return false;
|
||||
|
||||
// Drums typically have:
|
||||
// 1. Short note durations
|
||||
// 2. Limited pitch range (clustered around GM drum notes 35-81)
|
||||
// 3. High velocity variation
|
||||
|
||||
const avgDuration = this.calculateAverageDuration(notes);
|
||||
const range = this.calculateNoteRange(notes);
|
||||
|
||||
// Very short notes
|
||||
const shortNotes = avgDuration < 0.1;
|
||||
|
||||
// Limited pitch range around drum notes
|
||||
const drumPitchRange = range.min >= 35 && range.max <= 81 && (range.max - range.min) < 30;
|
||||
|
||||
// High repetition (same notes repeated often)
|
||||
const pitchCounts = new Map<number, number>();
|
||||
for (const note of notes) {
|
||||
pitchCounts.set(note.midi, (pitchCounts.get(note.midi) || 0) + 1);
|
||||
}
|
||||
const uniquePitches = pitchCounts.size;
|
||||
const highRepetition = uniquePitches < 10 && notes.length > 20;
|
||||
|
||||
return shortNotes && (drumPitchRange || highRepetition);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if track is percussive (short, rhythmic).
|
||||
*/
|
||||
private detectPercussive(notes: MIDINote[]): boolean {
|
||||
const avgDuration = this.calculateAverageDuration(notes);
|
||||
return avgDuration < 0.15;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the musical role of the track.
|
||||
*/
|
||||
private detectRole(
|
||||
notes: MIDINote[],
|
||||
range: { min: number; max: number; avg: number },
|
||||
isDrums: boolean,
|
||||
density: number,
|
||||
hasChords: boolean,
|
||||
avgDuration: number
|
||||
): TrackRole {
|
||||
// Drums are drums
|
||||
if (isDrums) return 'drums';
|
||||
|
||||
// Bass: low average pitch (MIDI 52 = E3, typical bass range)
|
||||
// Also consider tracks where the max note is low
|
||||
if (range.avg < 52 || range.max < 55) return 'bass';
|
||||
|
||||
// Lead: high pitch with high density
|
||||
if (range.avg > 58 && density > 2) return 'lead';
|
||||
|
||||
// Pad: low density, long notes
|
||||
if (density < 1.5 && avgDuration > 0.5) return 'pad';
|
||||
|
||||
// Harmony: has chords
|
||||
if (hasChords) return 'harmony';
|
||||
|
||||
// FX: very high density
|
||||
if (density > 8) return 'fx';
|
||||
|
||||
// Default to lead for melodic content
|
||||
return 'lead';
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate priority for channel assignment.
|
||||
* Higher priority = assigned first to best channels.
|
||||
*/
|
||||
private calculatePriority(
|
||||
role: TrackRole,
|
||||
density: number,
|
||||
avgVelocity: number,
|
||||
noteCount: number
|
||||
): number {
|
||||
let priority = 50;
|
||||
|
||||
// Role-based priority
|
||||
switch (role) {
|
||||
case 'drums':
|
||||
priority += 30;
|
||||
break;
|
||||
case 'bass':
|
||||
priority += 25;
|
||||
break;
|
||||
case 'lead':
|
||||
priority += 20;
|
||||
break;
|
||||
case 'harmony':
|
||||
priority += 15;
|
||||
break;
|
||||
case 'pad':
|
||||
priority += 10;
|
||||
break;
|
||||
case 'fx':
|
||||
priority += 5;
|
||||
break;
|
||||
}
|
||||
|
||||
// Density bonus (up to 20 points)
|
||||
priority += Math.min(20, density * 2);
|
||||
|
||||
// Velocity bonus (up to 10 points)
|
||||
priority += (avgVelocity / 127) * 10;
|
||||
|
||||
// Note count bonus (logarithmic, up to 10 points)
|
||||
priority += Math.min(10, Math.log10(noteCount + 1) * 3);
|
||||
|
||||
return priority;
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<WaveVolume, number> = {
|
||||
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
|
||||
});
|
||||
}
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
/**
|
||||
* Game Boy Player
|
||||
*
|
||||
* Main entry point for the v2 synthesis engine.
|
||||
* Orchestrates MIDI parsing, track analysis, channel mapping,
|
||||
* arpeggiator, and APU scheduling.
|
||||
*/
|
||||
|
||||
import { Midi } from '@tonejs/midi';
|
||||
import { GameBoyAPU } from '../audio/apu/APU';
|
||||
import { ChannelMapper } from '../audio/midi/ChannelMapper';
|
||||
import { Arpeggiator } from '../audio/midi/Arpeggiator';
|
||||
import { GameBoyArranger, type ArrangerConfig } from '../audio/arranger/GameBoyArranger';
|
||||
import type { MIDITrack, MIDINote } from '../audio/midi/TrackAnalyzer';
|
||||
import type {
|
||||
ChannelNote,
|
||||
ChannelAssignment,
|
||||
PlaybackInfo,
|
||||
ArpNote,
|
||||
V2Config
|
||||
} from '../types';
|
||||
|
||||
export interface GameBoyPlayerConfig extends Partial<V2Config> {
|
||||
/** Whether to auto-resume audio context on play */
|
||||
autoResume: boolean;
|
||||
|
||||
/** Default BPM if not detected from MIDI */
|
||||
defaultBPM: number;
|
||||
|
||||
/** Enable the arranger for fuller sound */
|
||||
enableArranger: boolean;
|
||||
|
||||
/** Arranger configuration */
|
||||
arrangerConfig: Partial<ArrangerConfig>;
|
||||
}
|
||||
|
||||
const DEFAULT_PLAYER_CONFIG: GameBoyPlayerConfig = {
|
||||
autoResume: true,
|
||||
defaultBPM: 120,
|
||||
masterVolume: 0.7,
|
||||
enableArranger: true, // ON by default for fuller sound
|
||||
arrangerConfig: {},
|
||||
};
|
||||
|
||||
export class GameBoyPlayer {
|
||||
private apu: GameBoyAPU;
|
||||
private mapper: ChannelMapper;
|
||||
private arpeggiator: Arpeggiator;
|
||||
private arranger: GameBoyArranger;
|
||||
private config: GameBoyPlayerConfig;
|
||||
|
||||
private isPlaying: boolean = false;
|
||||
private currentPlaybackInfo: PlaybackInfo | null = null;
|
||||
private playbackStartTime: number = 0;
|
||||
|
||||
constructor(config: Partial<GameBoyPlayerConfig> = {}) {
|
||||
this.config = { ...DEFAULT_PLAYER_CONFIG, ...config };
|
||||
this.apu = new GameBoyAPU(undefined, this.config);
|
||||
this.mapper = new ChannelMapper();
|
||||
this.arpeggiator = new Arpeggiator({ bpm: this.config.defaultBPM });
|
||||
this.arranger = new GameBoyArranger(this.config.arrangerConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the APU instance for direct channel control.
|
||||
*/
|
||||
getAPU(): GameBoyAPU {
|
||||
return this.apu;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and play a MIDI file.
|
||||
*
|
||||
* @param midiData - MIDI file as ArrayBuffer
|
||||
* @returns Playback information
|
||||
*/
|
||||
async playMIDI(midiData: ArrayBuffer): Promise<PlaybackInfo> {
|
||||
// Resume audio context if needed
|
||||
if (this.config.autoResume) {
|
||||
await this.apu.resume();
|
||||
}
|
||||
|
||||
// Stop any current playback
|
||||
if (this.isPlaying) {
|
||||
this.stop();
|
||||
}
|
||||
|
||||
// Parse MIDI file
|
||||
const midi = new Midi(midiData);
|
||||
const bpm = midi.header.tempos[0]?.bpm || this.config.defaultBPM;
|
||||
|
||||
// Update arpeggiator and arranger BPM
|
||||
this.arpeggiator.setBPM(bpm);
|
||||
this.arranger.setBPM(bpm);
|
||||
|
||||
// Convert to our track format
|
||||
const tracks = this.convertMIDITracks(midi);
|
||||
|
||||
// Analyze and map tracks to channels
|
||||
const assignments = this.mapper.mapTracks(tracks);
|
||||
|
||||
// Convert to scheduled notes
|
||||
let gbNotes = this.convertToGBNotes(tracks, assignments);
|
||||
|
||||
// Apply arranger for fuller sound (if enabled)
|
||||
let arrangerStats = null;
|
||||
if (this.config.enableArranger) {
|
||||
const result = this.arranger.arrange(gbNotes, assignments, midi.duration);
|
||||
gbNotes = result.notes;
|
||||
arrangerStats = result.stats;
|
||||
console.log(`Arranger: Added ${result.stats.addedNotes} notes (${result.stats.originalNotes} → ${gbNotes.length})`);
|
||||
}
|
||||
|
||||
// Get current time for scheduling
|
||||
const startTime = this.apu.getCurrentTime() + 0.1; // Small lookahead
|
||||
this.playbackStartTime = startTime;
|
||||
|
||||
// Schedule all notes
|
||||
for (const note of gbNotes) {
|
||||
this.apu.scheduleNote({
|
||||
...note,
|
||||
startTime: startTime + note.startTime,
|
||||
});
|
||||
}
|
||||
|
||||
this.isPlaying = true;
|
||||
|
||||
// Create playback info
|
||||
this.currentPlaybackInfo = {
|
||||
duration: midi.duration,
|
||||
assignments,
|
||||
noteCount: gbNotes.length,
|
||||
};
|
||||
|
||||
console.log(`Playing MIDI: ${gbNotes.length} notes, ${assignments.length} channels, ${midi.duration.toFixed(1)}s duration`);
|
||||
|
||||
// Schedule auto-stop
|
||||
const stopDelay = (midi.duration + 1) * 1000;
|
||||
setTimeout(() => {
|
||||
if (this.isPlaying && this.playbackStartTime === startTime) {
|
||||
this.isPlaying = false;
|
||||
}
|
||||
}, stopDelay);
|
||||
|
||||
return this.currentPlaybackInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert tonejs/midi tracks to our format.
|
||||
*/
|
||||
private convertMIDITracks(midi: InstanceType<typeof Midi>): MIDITrack[] {
|
||||
return midi.tracks.map((track, index) => ({
|
||||
channel: track.channel,
|
||||
name: track.name,
|
||||
notes: track.notes.map(note => ({
|
||||
midi: note.midi,
|
||||
time: note.time,
|
||||
duration: note.duration,
|
||||
velocity: Math.round(note.velocity * 127),
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert MIDI notes to GB channel notes using assignments.
|
||||
*/
|
||||
private convertToGBNotes(
|
||||
tracks: MIDITrack[],
|
||||
assignments: ChannelAssignment[]
|
||||
): ChannelNote[] {
|
||||
const gbNotes: ChannelNote[] = [];
|
||||
|
||||
for (const assignment of assignments) {
|
||||
const track = tracks[assignment.trackIndex];
|
||||
if (!track || track.notes.length === 0) continue;
|
||||
|
||||
// Apply channel-specific settings
|
||||
this.applyChannelSettings(assignment);
|
||||
|
||||
// Get notes from track
|
||||
let notes: ArpNote[] = track.notes.map(n => ({
|
||||
midiNote: n.midi,
|
||||
time: n.time,
|
||||
duration: n.duration,
|
||||
velocity: n.velocity,
|
||||
}));
|
||||
|
||||
// Apply arpeggiator if needed
|
||||
if (assignment.shouldArpeggiate) {
|
||||
notes = this.arpeggiator.arpeggiate(notes);
|
||||
}
|
||||
|
||||
// Convert to ChannelNote format
|
||||
for (const note of notes) {
|
||||
gbNotes.push({
|
||||
channel: assignment.channelId,
|
||||
midiNote: note.midiNote,
|
||||
startTime: note.time,
|
||||
duration: note.duration,
|
||||
velocity: note.velocity,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by start time for efficient scheduling
|
||||
return gbNotes.sort((a, b) => a.startTime - b.startTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply channel-specific settings from assignment.
|
||||
*/
|
||||
private applyChannelSettings(assignment: ChannelAssignment): void {
|
||||
const { channelId, dutyCycle, wavePreset, noiseMode } = assignment;
|
||||
|
||||
if (channelId.startsWith('p') && dutyCycle !== undefined) {
|
||||
this.apu.setPulseDuty(channelId as any, dutyCycle);
|
||||
}
|
||||
|
||||
if (channelId.startsWith('w') && wavePreset) {
|
||||
this.apu.setWavePreset(channelId as any, wavePreset);
|
||||
}
|
||||
|
||||
if (channelId.startsWith('n') && noiseMode) {
|
||||
this.apu.setNoiseMode(channelId as any, noiseMode);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop playback.
|
||||
*/
|
||||
stop(): void {
|
||||
this.isPlaying = false;
|
||||
this.apu.reset();
|
||||
console.log('Playback stopped');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if currently playing.
|
||||
*/
|
||||
getIsPlaying(): boolean {
|
||||
return this.isPlaying;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current playback info.
|
||||
*/
|
||||
getPlaybackInfo(): PlaybackInfo | null {
|
||||
return this.currentPlaybackInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get elapsed playback time in seconds.
|
||||
*/
|
||||
getElapsedTime(): number {
|
||||
if (!this.isPlaying) return 0;
|
||||
return this.apu.getCurrentTime() - this.playbackStartTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set master volume.
|
||||
*/
|
||||
setVolume(volume: number): void {
|
||||
this.apu.setMasterVolume(volume);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get master volume.
|
||||
*/
|
||||
getVolume(): number {
|
||||
return this.apu.getMasterVolume();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume audio context (required after user interaction in most browsers).
|
||||
*/
|
||||
async resume(): Promise<void> {
|
||||
await this.apu.resume();
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable or disable the arranger.
|
||||
*/
|
||||
setArrangerEnabled(enabled: boolean): void {
|
||||
this.config.enableArranger = enabled;
|
||||
console.log(`Arranger ${enabled ? 'enabled' : 'disabled'}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if arranger is enabled.
|
||||
*/
|
||||
isArrangerEnabled(): boolean {
|
||||
return this.config.enableArranger ?? true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the arranger instance for configuration.
|
||||
*/
|
||||
getArranger(): GameBoyArranger {
|
||||
return this.arranger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse MIDI without playing (for analysis/preview).
|
||||
*/
|
||||
analyzeMIDI(midiData: ArrayBuffer): {
|
||||
duration: number;
|
||||
trackCount: number;
|
||||
noteCount: number;
|
||||
bpm: number;
|
||||
assignments: ChannelAssignment[];
|
||||
} {
|
||||
const midi = new Midi(midiData);
|
||||
const tracks = this.convertMIDITracks(midi);
|
||||
const assignments = this.mapper.mapTracks(tracks);
|
||||
|
||||
return {
|
||||
duration: midi.duration,
|
||||
trackCount: midi.tracks.length,
|
||||
noteCount: midi.tracks.reduce((sum, t) => sum + t.notes.length, 0),
|
||||
bpm: midi.header.tempos[0]?.bpm || this.config.defaultBPM,
|
||||
assignments,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detailed track analysis.
|
||||
*/
|
||||
getTrackAnalysis(midiData: ArrayBuffer) {
|
||||
const midi = new Midi(midiData);
|
||||
const tracks = this.convertMIDITracks(midi);
|
||||
return this.mapper.analyzeTracks(tracks);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,528 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>V2 Sound Diagnostic</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: monospace;
|
||||
background: #1a1a2e;
|
||||
color: #eee;
|
||||
padding: 2rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
h1 { color: #9bbc0f; margin-bottom: 1rem; }
|
||||
h2 { color: #8bac0f; margin: 2rem 0 1rem; border-bottom: 1px solid #333; padding-bottom: 0.5rem; }
|
||||
|
||||
.test-section {
|
||||
background: #16213e;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.test-section h3 {
|
||||
color: #00ff88;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.test-section p {
|
||||
color: #888;
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
button {
|
||||
background: #0f3460;
|
||||
color: #eee;
|
||||
border: 2px solid #00ff88;
|
||||
padding: 0.5rem 1rem;
|
||||
margin: 0.25rem;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 0.9rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
button:hover { background: #1a4f7a; }
|
||||
button:active { background: #00ff88; color: #000; }
|
||||
|
||||
.log {
|
||||
background: #000;
|
||||
padding: 1rem;
|
||||
margin-top: 1rem;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
font-size: 0.8rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.log-entry { margin-bottom: 0.25rem; }
|
||||
.log-entry.info { color: #888; }
|
||||
.log-entry.success { color: #00ff88; }
|
||||
.log-entry.error { color: #ff4444; }
|
||||
.log-entry.test { color: #ffaa00; }
|
||||
|
||||
.comparison {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.comparison button {
|
||||
flex: 1;
|
||||
min-width: 150px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>🔧 V2 Sound Diagnostic</h1>
|
||||
<p>Test each audio component in isolation to identify issues.</p>
|
||||
|
||||
<h2>1. Baseline Tests</h2>
|
||||
|
||||
<div class="test-section">
|
||||
<h3>Built-in Oscillators (Should definitely work)</h3>
|
||||
<p>These use Web Audio's built-in waveforms. If these don't sound right, there's an audio context issue.</p>
|
||||
<button onclick="testBuiltinSquare()">Square Wave</button>
|
||||
<button onclick="testBuiltinTriangle()">Triangle Wave</button>
|
||||
<button onclick="testBuiltinSawtooth()">Sawtooth Wave</button>
|
||||
<button onclick="testBuiltinSine()">Sine Wave</button>
|
||||
</div>
|
||||
|
||||
<h2>2. Pulse Channel Tests</h2>
|
||||
|
||||
<div class="test-section">
|
||||
<h3>A/B Comparison: Built-in vs Our PeriodicWave</h3>
|
||||
<p>Click these in sequence. The 50% duty SHOULD sound identical to the built-in square.</p>
|
||||
<div class="comparison">
|
||||
<button onclick="testBuiltinSquare()">Built-in Square (Reference)</button>
|
||||
<button onclick="testDuty50()">Our 50% Duty Cycle</button>
|
||||
</div>
|
||||
<p style="margin-top: 1rem;">If they sound DIFFERENT, our PeriodicWave is broken.</p>
|
||||
</div>
|
||||
|
||||
<div class="test-section">
|
||||
<h3>All 4 Duty Cycles</h3>
|
||||
<p>12.5% = thin/buzzy, 25% = classic chiptune, 50% = full square, 75% = inverted 25%</p>
|
||||
<button onclick="testDuty(0)">12.5%</button>
|
||||
<button onclick="testDuty(1)">25%</button>
|
||||
<button onclick="testDuty(2)">50%</button>
|
||||
<button onclick="testDuty(3)">75%</button>
|
||||
</div>
|
||||
|
||||
<div class="test-section">
|
||||
<h3>Frequency Comparison</h3>
|
||||
<p>Standard frequency vs GB-quantized frequency. Should be nearly identical.</p>
|
||||
<div class="comparison">
|
||||
<button onclick="testFreqStandard()">Standard A4 (440Hz)</button>
|
||||
<button onclick="testFreqGB()">GB Quantized A4</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>3. Noise Channel Tests</h2>
|
||||
|
||||
<div class="test-section">
|
||||
<h3>LFSR Noise</h3>
|
||||
<p>7-bit = harsher/metallic, 15-bit = smoother white noise</p>
|
||||
<button onclick="testNoise7bit()">7-bit Noise</button>
|
||||
<button onclick="testNoise15bit()">15-bit Noise</button>
|
||||
<button onclick="testNoiseWhite()">Pure White Noise (Reference)</button>
|
||||
</div>
|
||||
|
||||
<h2>4. Wave Channel Tests</h2>
|
||||
|
||||
<div class="test-section">
|
||||
<h3>4-bit Wavetable</h3>
|
||||
<p>Should sound distinctly lo-fi compared to smooth sine.</p>
|
||||
<div class="comparison">
|
||||
<button onclick="testBuiltinSine()">Pure Sine (Reference)</button>
|
||||
<button onclick="testWaveSine()">4-bit Quantized Sine</button>
|
||||
</div>
|
||||
<button onclick="testWaveTriangle()">4-bit Triangle</button>
|
||||
<button onclick="testWaveSawtooth()">4-bit Sawtooth</button>
|
||||
</div>
|
||||
|
||||
<h2>5. Full Channel Tests (Through APU)</h2>
|
||||
|
||||
<div class="test-section">
|
||||
<h3>Complete V2 Pipeline</h3>
|
||||
<p>Tests the full path through APU and Colorizer.</p>
|
||||
<button onclick="testFullPulse()">V2 Pulse (P1)</button>
|
||||
<button onclick="testFullWave()">V2 Wave (W1)</button>
|
||||
<button onclick="testFullNoise()">V2 Noise (N1)</button>
|
||||
</div>
|
||||
|
||||
<h2>Log</h2>
|
||||
<div id="log" class="log"></div>
|
||||
|
||||
<script type="module">
|
||||
// ===== LOGGING =====
|
||||
const logEl = document.getElementById('log');
|
||||
|
||||
function log(message, type = 'info') {
|
||||
const entry = document.createElement('div');
|
||||
entry.className = `log-entry ${type}`;
|
||||
entry.textContent = `[${new Date().toISOString().slice(11,19)}] ${message}`;
|
||||
logEl.appendChild(entry);
|
||||
logEl.scrollTop = logEl.scrollHeight;
|
||||
console.log(message);
|
||||
}
|
||||
|
||||
window.log = log;
|
||||
|
||||
// ===== AUDIO CONTEXT =====
|
||||
let ctx = null;
|
||||
|
||||
function getContext() {
|
||||
if (!ctx || ctx.state === 'closed') {
|
||||
ctx = new AudioContext();
|
||||
log('Created AudioContext', 'success');
|
||||
}
|
||||
if (ctx.state === 'suspended') {
|
||||
ctx.resume();
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
window.getContext = getContext;
|
||||
|
||||
// ===== 1. BASELINE TESTS =====
|
||||
|
||||
window.testBuiltinSquare = function() {
|
||||
const ctx = getContext();
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
|
||||
osc.type = 'square';
|
||||
osc.frequency.value = 440;
|
||||
|
||||
gain.gain.value = 0.3;
|
||||
|
||||
osc.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
|
||||
osc.start();
|
||||
osc.stop(ctx.currentTime + 0.5);
|
||||
|
||||
log('Playing built-in SQUARE wave at 440Hz', 'test');
|
||||
};
|
||||
|
||||
window.testBuiltinTriangle = function() {
|
||||
const ctx = getContext();
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
|
||||
osc.type = 'triangle';
|
||||
osc.frequency.value = 440;
|
||||
gain.gain.value = 0.3;
|
||||
|
||||
osc.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
|
||||
osc.start();
|
||||
osc.stop(ctx.currentTime + 0.5);
|
||||
|
||||
log('Playing built-in TRIANGLE wave at 440Hz', 'test');
|
||||
};
|
||||
|
||||
window.testBuiltinSawtooth = function() {
|
||||
const ctx = getContext();
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
|
||||
osc.type = 'sawtooth';
|
||||
osc.frequency.value = 440;
|
||||
gain.gain.value = 0.3;
|
||||
|
||||
osc.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
|
||||
osc.start();
|
||||
osc.stop(ctx.currentTime + 0.5);
|
||||
|
||||
log('Playing built-in SAWTOOTH wave at 440Hz', 'test');
|
||||
};
|
||||
|
||||
window.testBuiltinSine = function() {
|
||||
const ctx = getContext();
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
|
||||
osc.type = 'sine';
|
||||
osc.frequency.value = 440;
|
||||
gain.gain.value = 0.3;
|
||||
|
||||
osc.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
|
||||
osc.start();
|
||||
osc.stop(ctx.currentTime + 0.5);
|
||||
|
||||
log('Playing built-in SINE wave at 440Hz', 'test');
|
||||
};
|
||||
|
||||
// ===== 2. DUTY CYCLE TESTS =====
|
||||
|
||||
// Import our duty cycle function
|
||||
import { createDutyWave, DUTY_RATIOS } from './src-v2/audio/synthesis/DutyCycle.ts';
|
||||
|
||||
window.testDuty = function(dutyIndex) {
|
||||
const ctx = getContext();
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
|
||||
try {
|
||||
const wave = createDutyWave(dutyIndex, ctx);
|
||||
osc.setPeriodicWave(wave);
|
||||
osc.frequency.value = 440;
|
||||
|
||||
gain.gain.value = 0.3;
|
||||
|
||||
osc.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
|
||||
osc.start();
|
||||
osc.stop(ctx.currentTime + 0.5);
|
||||
|
||||
log(`Playing ${DUTY_RATIOS[dutyIndex] * 100}% duty cycle at 440Hz`, 'test');
|
||||
} catch (e) {
|
||||
log(`ERROR: ${e.message}`, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
window.testDuty50 = function() {
|
||||
testDuty(2); // 50% duty
|
||||
};
|
||||
|
||||
// ===== FREQUENCY COMPARISON =====
|
||||
|
||||
import { calculatePulseFrequency } from './src-v2/audio/synthesis/FrequencyCalc.ts';
|
||||
|
||||
window.testFreqStandard = function() {
|
||||
const ctx = getContext();
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
|
||||
osc.type = 'square';
|
||||
osc.frequency.value = 440; // Exact A4
|
||||
gain.gain.value = 0.3;
|
||||
|
||||
osc.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
|
||||
osc.start();
|
||||
osc.stop(ctx.currentTime + 0.5);
|
||||
|
||||
log(`Playing standard frequency: 440Hz exactly`, 'test');
|
||||
};
|
||||
|
||||
window.testFreqGB = function() {
|
||||
const ctx = getContext();
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
|
||||
osc.type = 'square';
|
||||
const gbFreq = calculatePulseFrequency(69); // MIDI note 69 = A4
|
||||
osc.frequency.value = gbFreq;
|
||||
gain.gain.value = 0.3;
|
||||
|
||||
osc.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
|
||||
osc.start();
|
||||
osc.stop(ctx.currentTime + 0.5);
|
||||
|
||||
log(`Playing GB-quantized frequency: ${gbFreq.toFixed(2)}Hz (expected ~440Hz)`, 'test');
|
||||
};
|
||||
|
||||
// ===== 3. NOISE TESTS =====
|
||||
|
||||
import { LFSR } from './src-v2/audio/synthesis/LFSR.ts';
|
||||
|
||||
function generateLFSRBuffer(ctx, mode, duration, frequency) {
|
||||
const sampleRate = ctx.sampleRate;
|
||||
const bufferLength = Math.ceil(duration * sampleRate);
|
||||
const buffer = ctx.createBuffer(1, bufferLength, sampleRate);
|
||||
const data = buffer.getChannelData(0);
|
||||
|
||||
const lfsr = new LFSR(mode);
|
||||
const samplesPerClock = sampleRate / frequency;
|
||||
|
||||
let clockCounter = 0;
|
||||
let output = 0;
|
||||
|
||||
for (let i = 0; i < bufferLength; i++) {
|
||||
if (clockCounter >= samplesPerClock) {
|
||||
output = lfsr.clock();
|
||||
clockCounter = 0;
|
||||
}
|
||||
data[i] = output ? 0.3 : -0.3;
|
||||
clockCounter++;
|
||||
}
|
||||
|
||||
return buffer;
|
||||
}
|
||||
|
||||
window.testNoise7bit = function() {
|
||||
const ctx = getContext();
|
||||
|
||||
try {
|
||||
const buffer = generateLFSRBuffer(ctx, '7bit', 0.5, 20000);
|
||||
const source = ctx.createBufferSource();
|
||||
source.buffer = buffer;
|
||||
source.connect(ctx.destination);
|
||||
source.start();
|
||||
|
||||
log('Playing 7-bit LFSR noise (should sound harsh/metallic)', 'test');
|
||||
} catch (e) {
|
||||
log(`ERROR: ${e.message}`, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
window.testNoise15bit = function() {
|
||||
const ctx = getContext();
|
||||
|
||||
try {
|
||||
const buffer = generateLFSRBuffer(ctx, '15bit', 0.5, 20000);
|
||||
const source = ctx.createBufferSource();
|
||||
source.buffer = buffer;
|
||||
source.connect(ctx.destination);
|
||||
source.start();
|
||||
|
||||
log('Playing 15-bit LFSR noise (should sound more like white noise)', 'test');
|
||||
} catch (e) {
|
||||
log(`ERROR: ${e.message}`, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
window.testNoiseWhite = function() {
|
||||
const ctx = getContext();
|
||||
const sampleRate = ctx.sampleRate;
|
||||
const duration = 0.5;
|
||||
const bufferLength = Math.ceil(duration * sampleRate);
|
||||
const buffer = ctx.createBuffer(1, bufferLength, sampleRate);
|
||||
const data = buffer.getChannelData(0);
|
||||
|
||||
// Pure random white noise
|
||||
for (let i = 0; i < bufferLength; i++) {
|
||||
data[i] = (Math.random() * 2 - 1) * 0.3;
|
||||
}
|
||||
|
||||
const source = ctx.createBufferSource();
|
||||
source.buffer = buffer;
|
||||
source.connect(ctx.destination);
|
||||
source.start();
|
||||
|
||||
log('Playing pure white noise (reference)', 'test');
|
||||
};
|
||||
|
||||
// ===== 4. WAVE TESTS =====
|
||||
|
||||
function generate4bitWavetable(waveformFn) {
|
||||
const samples = new Uint8Array(32);
|
||||
for (let i = 0; i < 32; i++) {
|
||||
const t = i / 32;
|
||||
const value = waveformFn(t); // Returns 0-1
|
||||
samples[i] = Math.floor(value * 15); // Quantize to 4-bit (0-15)
|
||||
}
|
||||
return samples;
|
||||
}
|
||||
|
||||
function playWavetable(samples, frequency) {
|
||||
const ctx = getContext();
|
||||
const sampleRate = ctx.sampleRate;
|
||||
const duration = 0.5;
|
||||
const bufferLength = Math.ceil(duration * sampleRate);
|
||||
const buffer = ctx.createBuffer(1, bufferLength, sampleRate);
|
||||
const data = buffer.getChannelData(0);
|
||||
|
||||
const samplesPerCycle = sampleRate / frequency;
|
||||
|
||||
for (let i = 0; i < bufferLength; i++) {
|
||||
const phase = (i / samplesPerCycle) % 1;
|
||||
const sampleIndex = Math.floor(phase * 32) % 32;
|
||||
// Convert 4-bit (0-15) to audio range (-1 to 1)
|
||||
data[i] = ((samples[sampleIndex] / 15) * 2 - 1) * 0.3;
|
||||
}
|
||||
|
||||
const source = ctx.createBufferSource();
|
||||
source.buffer = buffer;
|
||||
source.connect(ctx.destination);
|
||||
source.start();
|
||||
}
|
||||
|
||||
window.testWaveSine = function() {
|
||||
const samples = generate4bitWavetable(t => (Math.sin(t * Math.PI * 2) + 1) / 2);
|
||||
playWavetable(samples, 440);
|
||||
log(`Playing 4-bit quantized sine. Samples: [${Array.from(samples).join(',')}]`, 'test');
|
||||
};
|
||||
|
||||
window.testWaveTriangle = function() {
|
||||
const samples = generate4bitWavetable(t => t < 0.5 ? t * 2 : 2 - t * 2);
|
||||
playWavetable(samples, 440);
|
||||
log(`Playing 4-bit triangle. Samples: [${Array.from(samples).join(',')}]`, 'test');
|
||||
};
|
||||
|
||||
window.testWaveSawtooth = function() {
|
||||
const samples = generate4bitWavetable(t => t);
|
||||
playWavetable(samples, 440);
|
||||
log(`Playing 4-bit sawtooth. Samples: [${Array.from(samples).join(',')}]`, 'test');
|
||||
};
|
||||
|
||||
// ===== 5. FULL V2 TESTS =====
|
||||
|
||||
import { GameBoyAPU } from './src-v2/audio/apu/APU.ts';
|
||||
|
||||
let apu = null;
|
||||
|
||||
function getAPU() {
|
||||
if (!apu) {
|
||||
apu = new GameBoyAPU(getContext());
|
||||
log('Created GameBoyAPU', 'success');
|
||||
}
|
||||
return apu;
|
||||
}
|
||||
|
||||
window.testFullPulse = function() {
|
||||
const apu = getAPU();
|
||||
const channel = apu.getPulseChannel('p1');
|
||||
if (channel) {
|
||||
channel.playNote(69, 0.5, 100); // A4
|
||||
log('Playing through full V2 pipeline: Pulse channel P1, MIDI note 69 (A4)', 'test');
|
||||
} else {
|
||||
log('ERROR: Could not get pulse channel P1', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
window.testFullWave = function() {
|
||||
const apu = getAPU();
|
||||
const channel = apu.getWaveChannel('w1');
|
||||
if (channel) {
|
||||
channel.playNote(57, 0.5, 100); // A3 (lower)
|
||||
log('Playing through full V2 pipeline: Wave channel W1, MIDI note 57 (A3)', 'test');
|
||||
} else {
|
||||
log('ERROR: Could not get wave channel W1', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
window.testFullNoise = function() {
|
||||
const apu = getAPU();
|
||||
const channel = apu.getNoiseChannel('n1');
|
||||
if (channel) {
|
||||
channel.playKick(100);
|
||||
log('Playing through full V2 pipeline: Noise channel N1 (kick)', 'test');
|
||||
} else {
|
||||
log('ERROR: Could not get noise channel N1', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// ===== INIT =====
|
||||
log('Diagnostic page ready. Click buttons to test.', 'success');
|
||||
log('Compare "Built-in Square" with "Our 50% Duty" - they SHOULD sound the same.', 'info');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+406
@@ -0,0 +1,406 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Wario Synth v2 - Sound Test</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg-dark: #0a0a0a;
|
||||
--bg-panel: #1a1a1a;
|
||||
--gb-green: #9bbc0f;
|
||||
--gb-dark-green: #0f380f;
|
||||
--gb-light-green: #8bac0f;
|
||||
--text: #e0e0e0;
|
||||
--text-dim: #888;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Courier New', monospace;
|
||||
background: var(--bg-dark);
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: var(--gb-green);
|
||||
text-align: center;
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
text-align: center;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 2rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--bg-panel);
|
||||
border: 2px solid var(--gb-dark-green);
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.panel h2 {
|
||||
color: var(--gb-light-green);
|
||||
font-size: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
border-bottom: 1px solid var(--gb-dark-green);
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
button {
|
||||
background: var(--gb-dark-green);
|
||||
color: var(--gb-green);
|
||||
border: 2px solid var(--gb-green);
|
||||
padding: 0.75rem 1.25rem;
|
||||
font-family: inherit;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: all 0.15s;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: var(--gb-green);
|
||||
color: var(--gb-dark-green);
|
||||
}
|
||||
|
||||
button:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background: var(--gb-green);
|
||||
color: var(--gb-dark-green);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
button.primary:hover {
|
||||
background: var(--gb-light-green);
|
||||
}
|
||||
|
||||
.log {
|
||||
background: #000;
|
||||
border: 1px solid var(--gb-dark-green);
|
||||
border-radius: 4px;
|
||||
padding: 1rem;
|
||||
height: 200px;
|
||||
overflow-y: auto;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.log-entry {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.log-entry.success {
|
||||
color: var(--gb-green);
|
||||
}
|
||||
|
||||
.log-entry.error {
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
.log-entry.info {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.status {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.status.ready {
|
||||
background: var(--gb-dark-green);
|
||||
color: var(--gb-green);
|
||||
}
|
||||
|
||||
.status.playing {
|
||||
background: var(--gb-green);
|
||||
color: var(--bg-dark);
|
||||
animation: pulse 1s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.7; }
|
||||
}
|
||||
|
||||
.verification-results {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.result-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.5rem;
|
||||
border-bottom: 1px solid var(--bg-dark);
|
||||
}
|
||||
|
||||
.result-item .icon {
|
||||
width: 20px;
|
||||
margin-right: 0.75rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.result-item.pass .icon {
|
||||
color: var(--gb-green);
|
||||
}
|
||||
|
||||
.result-item.fail .icon {
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
.result-item .name {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.result-item .message {
|
||||
color: var(--text-dim);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.warning {
|
||||
background: #332200;
|
||||
border: 1px solid #665500;
|
||||
color: #ffcc00;
|
||||
padding: 1rem;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Wario Synth v2</h1>
|
||||
<p class="subtitle">Game Boy Sound Engine Test Suite</p>
|
||||
|
||||
<div class="warning">
|
||||
⚠️ This is the v2 test page. The main site (v1) is unaffected.
|
||||
Click any button to start audio (required by browsers).
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h2>Verification Tests</h2>
|
||||
<div class="btn-row">
|
||||
<button id="btn-verify" class="primary">Run Verification</button>
|
||||
</div>
|
||||
<div id="verification-results" class="verification-results"></div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h2>Audio Tests <span id="status" class="status ready">Ready</span></h2>
|
||||
<div class="btn-row">
|
||||
<button id="btn-duty">Test Duty Cycles</button>
|
||||
<button id="btn-wave">Test Wave Channel</button>
|
||||
<button id="btn-noise">Test Noise Channel</button>
|
||||
</div>
|
||||
<div class="btn-row">
|
||||
<button id="btn-combined" class="primary">Test Combined</button>
|
||||
<button id="btn-all">Run All Tests</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h2>Quick Sound Check</h2>
|
||||
<div class="btn-row">
|
||||
<button id="btn-note-c">Play C4</button>
|
||||
<button id="btn-note-e">Play E4</button>
|
||||
<button id="btn-note-g">Play G4</button>
|
||||
<button id="btn-bass">Play Bass</button>
|
||||
<button id="btn-kick">Kick</button>
|
||||
<button id="btn-snare">Snare</button>
|
||||
<button id="btn-hihat">HiHat</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h2>Console Log</h2>
|
||||
<div id="log" class="log"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
import {
|
||||
runVerificationTests,
|
||||
createTestChannels,
|
||||
testDutyCycles,
|
||||
testWaveChannel,
|
||||
testNoiseChannel,
|
||||
testCombined,
|
||||
runAllAudioTests
|
||||
} from './src-v2/audio/test/soundTest.ts';
|
||||
|
||||
let audioContext = null;
|
||||
let channels = null;
|
||||
|
||||
const logEl = document.getElementById('log');
|
||||
const statusEl = document.getElementById('status');
|
||||
const verificationEl = document.getElementById('verification-results');
|
||||
|
||||
// Override console.log to show in UI
|
||||
const originalLog = console.log;
|
||||
console.log = (...args) => {
|
||||
originalLog(...args);
|
||||
addLog(args.join(' '), 'info');
|
||||
};
|
||||
|
||||
function addLog(message, type = 'info') {
|
||||
const entry = document.createElement('div');
|
||||
entry.className = `log-entry ${type}`;
|
||||
entry.textContent = `> ${message}`;
|
||||
logEl.appendChild(entry);
|
||||
logEl.scrollTop = logEl.scrollHeight;
|
||||
}
|
||||
|
||||
function setStatus(status) {
|
||||
statusEl.textContent = status;
|
||||
statusEl.className = `status ${status.toLowerCase()}`;
|
||||
}
|
||||
|
||||
async function ensureAudioContext() {
|
||||
if (!audioContext) {
|
||||
audioContext = new AudioContext();
|
||||
channels = createTestChannels(audioContext);
|
||||
addLog('Audio context initialized', 'success');
|
||||
}
|
||||
if (audioContext.state === 'suspended') {
|
||||
await audioContext.resume();
|
||||
}
|
||||
}
|
||||
|
||||
// Verification tests
|
||||
document.getElementById('btn-verify').addEventListener('click', () => {
|
||||
const results = runVerificationTests();
|
||||
|
||||
verificationEl.innerHTML = results.map(r => `
|
||||
<div class="result-item ${r.passed ? 'pass' : 'fail'}">
|
||||
<span class="icon">${r.passed ? '✓' : '✗'}</span>
|
||||
<span class="name">${r.name}</span>
|
||||
<span class="message">${r.message}</span>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
results.forEach(r => {
|
||||
addLog(`${r.passed ? '✓' : '✗'} ${r.name}: ${r.message}`, r.passed ? 'success' : 'error');
|
||||
});
|
||||
});
|
||||
|
||||
// Audio tests
|
||||
document.getElementById('btn-duty').addEventListener('click', async () => {
|
||||
await ensureAudioContext();
|
||||
setStatus('Playing');
|
||||
await testDutyCycles(channels.pulse, audioContext);
|
||||
setStatus('Ready');
|
||||
});
|
||||
|
||||
document.getElementById('btn-wave').addEventListener('click', async () => {
|
||||
await ensureAudioContext();
|
||||
setStatus('Playing');
|
||||
await testWaveChannel(channels.wave, audioContext);
|
||||
setStatus('Ready');
|
||||
});
|
||||
|
||||
document.getElementById('btn-noise').addEventListener('click', async () => {
|
||||
await ensureAudioContext();
|
||||
setStatus('Playing');
|
||||
await testNoiseChannel(channels.noise, audioContext);
|
||||
setStatus('Ready');
|
||||
});
|
||||
|
||||
document.getElementById('btn-combined').addEventListener('click', async () => {
|
||||
await ensureAudioContext();
|
||||
setStatus('Playing');
|
||||
await testCombined(channels.pulse, channels.wave, channels.noise, audioContext);
|
||||
setStatus('Ready');
|
||||
});
|
||||
|
||||
document.getElementById('btn-all').addEventListener('click', async () => {
|
||||
await ensureAudioContext();
|
||||
setStatus('Playing');
|
||||
await runAllAudioTests(audioContext);
|
||||
setStatus('Ready');
|
||||
});
|
||||
|
||||
// Quick sound checks
|
||||
document.getElementById('btn-note-c').addEventListener('click', async () => {
|
||||
await ensureAudioContext();
|
||||
channels.pulse.setDutyCycle(2);
|
||||
channels.pulse.playNote(60, 0.3, 100);
|
||||
});
|
||||
|
||||
document.getElementById('btn-note-e').addEventListener('click', async () => {
|
||||
await ensureAudioContext();
|
||||
channels.pulse.setDutyCycle(1);
|
||||
channels.pulse.playNote(64, 0.3, 100);
|
||||
});
|
||||
|
||||
document.getElementById('btn-note-g').addEventListener('click', async () => {
|
||||
await ensureAudioContext();
|
||||
channels.pulse.setDutyCycle(0);
|
||||
channels.pulse.playNote(67, 0.3, 100);
|
||||
});
|
||||
|
||||
document.getElementById('btn-bass').addEventListener('click', async () => {
|
||||
await ensureAudioContext();
|
||||
channels.wave.loadPreset('bass');
|
||||
channels.wave.playNote(36, 0.5, 100);
|
||||
});
|
||||
|
||||
document.getElementById('btn-kick').addEventListener('click', async () => {
|
||||
await ensureAudioContext();
|
||||
channels.noise.playKick(100);
|
||||
});
|
||||
|
||||
document.getElementById('btn-snare').addEventListener('click', async () => {
|
||||
await ensureAudioContext();
|
||||
channels.noise.playSnare(100);
|
||||
});
|
||||
|
||||
document.getElementById('btn-hihat').addEventListener('click', async () => {
|
||||
await ensureAudioContext();
|
||||
channels.noise.playHihat(80, false);
|
||||
});
|
||||
|
||||
addLog('Wario Synth v2 Test Page loaded', 'success');
|
||||
addLog('Click any button to initialize audio', 'info');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,834 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Wario Synth v2 - Game Boy Sound Engine</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--bg-dark: #0f380f;
|
||||
--bg-panel: #306230;
|
||||
--gb-green: #9bbc0f;
|
||||
--gb-dark-green: #0f380f;
|
||||
--gb-light-green: #8bac0f;
|
||||
--gb-cream: #e0f8d0;
|
||||
--text: #9bbc0f;
|
||||
--text-dim: #306230;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Press Start 2P', monospace;
|
||||
background: var(--bg-dark);
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
padding: 1rem;
|
||||
font-size: 10px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
header {
|
||||
text-align: center;
|
||||
padding: 2rem 0;
|
||||
border-bottom: 4px solid var(--gb-light-green);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.4rem;
|
||||
margin-bottom: 0.5rem;
|
||||
text-shadow: 2px 2px 0 #0a280a;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 0.7rem;
|
||||
color: var(--gb-light-green);
|
||||
}
|
||||
|
||||
.version-badge {
|
||||
display: inline-block;
|
||||
background: var(--gb-green);
|
||||
color: var(--bg-dark);
|
||||
padding: 0.3rem 0.6rem;
|
||||
font-size: 0.6rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--bg-panel);
|
||||
border: 4px solid var(--gb-light-green);
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.panel h2 {
|
||||
font-size: 0.8rem;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 2px solid var(--gb-light-green);
|
||||
}
|
||||
|
||||
.upload-zone {
|
||||
border: 3px dashed var(--gb-light-green);
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.upload-zone:hover {
|
||||
background: rgba(155, 188, 15, 0.1);
|
||||
}
|
||||
|
||||
.upload-zone.dragover {
|
||||
background: rgba(155, 188, 15, 0.2);
|
||||
border-color: var(--gb-green);
|
||||
}
|
||||
|
||||
.upload-zone p {
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.upload-zone .icon {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
input[type="file"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
button {
|
||||
background: var(--bg-dark);
|
||||
color: var(--gb-green);
|
||||
border: 3px solid var(--gb-green);
|
||||
padding: 0.75rem 1.5rem;
|
||||
font-family: inherit;
|
||||
font-size: 0.7rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
button:hover:not(:disabled) {
|
||||
background: var(--gb-green);
|
||||
color: var(--bg-dark);
|
||||
}
|
||||
|
||||
button:active:not(:disabled) {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background: var(--gb-green);
|
||||
color: var(--bg-dark);
|
||||
}
|
||||
|
||||
button.primary:hover:not(:disabled) {
|
||||
background: var(--gb-cream);
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.volume-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.volume-control input[type="range"] {
|
||||
width: 100px;
|
||||
accent-color: var(--gb-green);
|
||||
}
|
||||
|
||||
.status {
|
||||
padding: 0.3rem 0.6rem;
|
||||
font-size: 0.6rem;
|
||||
background: var(--bg-dark);
|
||||
border: 2px solid var(--gb-light-green);
|
||||
}
|
||||
|
||||
.status.playing {
|
||||
background: var(--gb-green);
|
||||
color: var(--bg-dark);
|
||||
animation: blink 1s infinite;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.7; }
|
||||
}
|
||||
|
||||
.channels {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.channel {
|
||||
background: var(--bg-dark);
|
||||
border: 2px solid var(--gb-light-green);
|
||||
padding: 0.5rem;
|
||||
text-align: center;
|
||||
font-size: 0.6rem;
|
||||
}
|
||||
|
||||
.channel.active {
|
||||
border-color: var(--gb-green);
|
||||
background: rgba(155, 188, 15, 0.2);
|
||||
}
|
||||
|
||||
.channel .id {
|
||||
font-size: 0.7rem;
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
.channel .role {
|
||||
color: var(--gb-light-green);
|
||||
font-size: 0.5rem;
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
background: var(--bg-dark);
|
||||
padding: 0.75rem;
|
||||
border: 2px solid var(--gb-light-green);
|
||||
}
|
||||
|
||||
.info-item .label {
|
||||
font-size: 0.55rem;
|
||||
color: var(--gb-light-green);
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
.info-item .value {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.log {
|
||||
background: #000;
|
||||
border: 2px solid var(--gb-light-green);
|
||||
padding: 0.75rem;
|
||||
height: 150px;
|
||||
overflow-y: auto;
|
||||
font-family: monospace;
|
||||
font-size: 0.65rem;
|
||||
color: var(--gb-green);
|
||||
}
|
||||
|
||||
.log-entry {
|
||||
margin-bottom: 0.25rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.log-entry.error { color: #ff6b6b; }
|
||||
.log-entry.success { color: var(--gb-cream); }
|
||||
|
||||
footer {
|
||||
text-align: center;
|
||||
padding: 2rem 0;
|
||||
font-size: 0.6rem;
|
||||
color: var(--gb-light-green);
|
||||
}
|
||||
|
||||
footer a {
|
||||
color: var(--gb-green);
|
||||
}
|
||||
|
||||
.quick-test {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.quick-test button {
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.6rem;
|
||||
}
|
||||
|
||||
.quick-test button.active {
|
||||
background: var(--gb-green);
|
||||
color: var(--bg-dark);
|
||||
}
|
||||
|
||||
.search-box {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.search-box input[type="text"] {
|
||||
flex: 1;
|
||||
background: var(--bg-dark);
|
||||
border: 3px solid var(--gb-green);
|
||||
color: var(--gb-green);
|
||||
padding: 0.75rem;
|
||||
font-family: inherit;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.search-box input[type="text"]::placeholder {
|
||||
color: var(--gb-light-green);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.search-box input[type="text"]:focus {
|
||||
outline: none;
|
||||
background: rgba(155, 188, 15, 0.1);
|
||||
}
|
||||
|
||||
.results-list {
|
||||
margin-top: 1rem;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.result-item {
|
||||
background: var(--bg-dark);
|
||||
border: 2px solid var(--gb-light-green);
|
||||
padding: 0.5rem 0.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.result-item:hover {
|
||||
background: rgba(155, 188, 15, 0.15);
|
||||
border-color: var(--gb-green);
|
||||
}
|
||||
|
||||
.result-item.selected {
|
||||
background: rgba(155, 188, 15, 0.25);
|
||||
border-color: var(--gb-green);
|
||||
}
|
||||
|
||||
.result-item .title {
|
||||
font-size: 0.65rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 70%;
|
||||
}
|
||||
|
||||
.result-item .confidence {
|
||||
font-size: 0.5rem;
|
||||
color: var(--gb-light-green);
|
||||
}
|
||||
|
||||
.divider {
|
||||
text-align: center;
|
||||
font-size: 0.6rem;
|
||||
color: var(--gb-light-green);
|
||||
margin: 1rem 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.divider::before,
|
||||
.divider::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 40%;
|
||||
height: 2px;
|
||||
background: var(--gb-light-green);
|
||||
}
|
||||
|
||||
.divider::before { left: 0; }
|
||||
.divider::after { right: 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1>🎮 WARIO SYNTH</h1>
|
||||
<p class="subtitle">Game Boy Sound Engine</p>
|
||||
<span class="version-badge">v2.0 BETA</span>
|
||||
</header>
|
||||
|
||||
<div class="panel">
|
||||
<h2>SEARCH BITMIDI</h2>
|
||||
<div class="search-box">
|
||||
<input type="text" id="search-input" placeholder="Search for a song...">
|
||||
<button id="btn-search">SEARCH</button>
|
||||
</div>
|
||||
<div id="results-list" class="results-list"></div>
|
||||
|
||||
<div class="divider">OR</div>
|
||||
|
||||
<div class="upload-zone" id="upload-zone">
|
||||
<div class="icon">🎵</div>
|
||||
<p>Drop a MIDI file here or click to browse</p>
|
||||
<button>Choose File</button>
|
||||
<input type="file" id="file-input" accept=".mid,.midi">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h2>PLAYBACK</h2>
|
||||
<div class="controls">
|
||||
<button id="btn-play" class="primary" disabled>▶ PLAY</button>
|
||||
<button id="btn-stop" disabled>■ STOP</button>
|
||||
<div class="volume-control">
|
||||
<span>VOL:</span>
|
||||
<input type="range" id="volume" min="0" max="100" value="70">
|
||||
</div>
|
||||
<span id="status" class="status">READY</span>
|
||||
</div>
|
||||
|
||||
<div class="info-grid">
|
||||
<div class="info-item">
|
||||
<div class="label">DURATION</div>
|
||||
<div class="value" id="duration">--:--</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="label">NOTES</div>
|
||||
<div class="value" id="notes">---</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="label">TEMPO</div>
|
||||
<div class="value" id="tempo">--- BPM</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="label">TRACKS</div>
|
||||
<div class="value" id="tracks">---</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h2>CHANNELS</h2>
|
||||
<div class="channels">
|
||||
<div class="channel" id="ch-p1"><div class="id">P1</div><div class="role">-</div></div>
|
||||
<div class="channel" id="ch-p2"><div class="id">P2</div><div class="role">-</div></div>
|
||||
<div class="channel" id="ch-p3"><div class="id">P3</div><div class="role">-</div></div>
|
||||
<div class="channel" id="ch-p4"><div class="id">P4</div><div class="role">-</div></div>
|
||||
<div class="channel" id="ch-w1"><div class="id">W1</div><div class="role">-</div></div>
|
||||
<div class="channel" id="ch-w2"><div class="id">W2</div><div class="role">-</div></div>
|
||||
<div class="channel" id="ch-n1"><div class="id">N1</div><div class="role">-</div></div>
|
||||
<div class="channel" id="ch-n2"><div class="id">N2</div><div class="role">-</div></div>
|
||||
</div>
|
||||
|
||||
<div class="quick-test">
|
||||
<span style="font-size: 0.6rem; opacity: 0.7;">Quick test:</span>
|
||||
<button id="btn-c4">C4</button>
|
||||
<button id="btn-chord">Chord</button>
|
||||
<button id="btn-bass">Bass</button>
|
||||
<button id="btn-drum">Drum</button>
|
||||
</div>
|
||||
|
||||
<div class="quick-test" style="margin-top: 0.5rem;">
|
||||
<span style="font-size: 0.6rem; opacity: 0.7;">Sound mode:</span>
|
||||
<button id="btn-dmg" class="active">DMG</button>
|
||||
<button id="btn-gbc">GBC</button>
|
||||
<button id="btn-gba">GBA</button>
|
||||
<button id="btn-clean">Clean</button>
|
||||
</div>
|
||||
|
||||
<div class="quick-test" style="margin-top: 0.5rem;">
|
||||
<span style="font-size: 0.6rem; opacity: 0.7;">Arranger:</span>
|
||||
<button id="btn-arranger" class="active" style="background: var(--gb-green); color: var(--bg-dark);">ON</button>
|
||||
<span style="font-size: 0.5rem; opacity: 0.6; margin-left: 0.5rem;">Makes sparse MIDIs sound full</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h2>LOG</h2>
|
||||
<div id="log" class="log"></div>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<p>Wario Synth v2 - Authentic Game Boy Sound</p>
|
||||
<p><a href="/">Back to v1</a> | <a href="/v2-test.html">Sound Tests</a></p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
import { GameBoyPlayer } from './src-v2/core/GameBoyPlayer.ts';
|
||||
import { MIDIService } from './src/services/MIDIService.ts';
|
||||
|
||||
// Initialize player and services
|
||||
const player = new GameBoyPlayer();
|
||||
const midiService = new MIDIService();
|
||||
let currentMIDI = null;
|
||||
let searchResults = [];
|
||||
let selectedResultIndex = -1;
|
||||
|
||||
// DOM elements
|
||||
const searchInput = document.getElementById('search-input');
|
||||
const btnSearch = document.getElementById('btn-search');
|
||||
const resultsList = document.getElementById('results-list');
|
||||
const uploadZone = document.getElementById('upload-zone');
|
||||
const fileInput = document.getElementById('file-input');
|
||||
const btnPlay = document.getElementById('btn-play');
|
||||
const btnStop = document.getElementById('btn-stop');
|
||||
const volumeSlider = document.getElementById('volume');
|
||||
const statusEl = document.getElementById('status');
|
||||
const logEl = document.getElementById('log');
|
||||
|
||||
// Info elements
|
||||
const durationEl = document.getElementById('duration');
|
||||
const notesEl = document.getElementById('notes');
|
||||
const tempoEl = document.getElementById('tempo');
|
||||
const tracksEl = document.getElementById('tracks');
|
||||
|
||||
// Channel elements
|
||||
const channelEls = {
|
||||
p1: document.getElementById('ch-p1'),
|
||||
p2: document.getElementById('ch-p2'),
|
||||
p3: document.getElementById('ch-p3'),
|
||||
p4: document.getElementById('ch-p4'),
|
||||
w1: document.getElementById('ch-w1'),
|
||||
w2: document.getElementById('ch-w2'),
|
||||
n1: document.getElementById('ch-n1'),
|
||||
n2: document.getElementById('ch-n2'),
|
||||
};
|
||||
|
||||
function log(message, type = 'info') {
|
||||
const entry = document.createElement('div');
|
||||
entry.className = `log-entry ${type}`;
|
||||
entry.textContent = `> ${message}`;
|
||||
logEl.appendChild(entry);
|
||||
logEl.scrollTop = logEl.scrollHeight;
|
||||
console.log(message);
|
||||
}
|
||||
|
||||
function setStatus(status, playing = false) {
|
||||
statusEl.textContent = status;
|
||||
statusEl.className = `status ${playing ? 'playing' : ''}`;
|
||||
}
|
||||
|
||||
function formatTime(seconds) {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function updateInfo(info) {
|
||||
durationEl.textContent = formatTime(info.duration);
|
||||
notesEl.textContent = info.noteCount.toString();
|
||||
tempoEl.textContent = `${Math.round(info.bpm)} BPM`;
|
||||
tracksEl.textContent = info.trackCount.toString();
|
||||
}
|
||||
|
||||
function updateChannels(assignments) {
|
||||
// Reset all
|
||||
Object.values(channelEls).forEach(el => {
|
||||
el.classList.remove('active');
|
||||
el.querySelector('.role').textContent = '-';
|
||||
});
|
||||
|
||||
// Highlight assigned channels
|
||||
for (const assignment of assignments) {
|
||||
const el = channelEls[assignment.channelId];
|
||||
if (el) {
|
||||
el.classList.add('active');
|
||||
// Get role from track analysis (simplified)
|
||||
let role = assignment.channelId.startsWith('p') ? 'PULSE' :
|
||||
assignment.channelId.startsWith('w') ? 'WAVE' : 'NOISE';
|
||||
if (assignment.shouldArpeggiate) role = 'ARP';
|
||||
el.querySelector('.role').textContent = role;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// File upload handling
|
||||
uploadZone.addEventListener('click', () => fileInput.click());
|
||||
|
||||
uploadZone.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
uploadZone.classList.add('dragover');
|
||||
});
|
||||
|
||||
uploadZone.addEventListener('dragleave', () => {
|
||||
uploadZone.classList.remove('dragover');
|
||||
});
|
||||
|
||||
uploadZone.addEventListener('drop', async (e) => {
|
||||
e.preventDefault();
|
||||
uploadZone.classList.remove('dragover');
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file) await loadMIDI(file);
|
||||
});
|
||||
|
||||
fileInput.addEventListener('change', async (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (file) await loadMIDI(file);
|
||||
});
|
||||
|
||||
async function loadMIDI(file) {
|
||||
try {
|
||||
log(`Loading: ${file.name}`);
|
||||
const buffer = await file.arrayBuffer();
|
||||
currentMIDI = buffer;
|
||||
|
||||
// Analyze without playing
|
||||
const info = player.analyzeMIDI(buffer);
|
||||
updateInfo(info);
|
||||
updateChannels(info.assignments);
|
||||
|
||||
log(`Loaded: ${info.noteCount} notes, ${info.trackCount} tracks`, 'success');
|
||||
|
||||
btnPlay.disabled = false;
|
||||
setStatus('LOADED');
|
||||
} catch (error) {
|
||||
log(`Error: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Playback controls
|
||||
btnPlay.addEventListener('click', async () => {
|
||||
if (!currentMIDI) return;
|
||||
|
||||
try {
|
||||
await player.resume();
|
||||
const info = await player.playMIDI(currentMIDI);
|
||||
|
||||
setStatus('PLAYING', true);
|
||||
btnPlay.disabled = true;
|
||||
btnStop.disabled = false;
|
||||
|
||||
log('Playback started', 'success');
|
||||
|
||||
// Auto-reset when done
|
||||
setTimeout(() => {
|
||||
if (!player.getIsPlaying()) {
|
||||
setStatus('FINISHED');
|
||||
btnPlay.disabled = false;
|
||||
btnStop.disabled = true;
|
||||
}
|
||||
}, (info.duration + 1) * 1000);
|
||||
} catch (error) {
|
||||
log(`Playback error: ${error.message}`, 'error');
|
||||
}
|
||||
});
|
||||
|
||||
btnStop.addEventListener('click', () => {
|
||||
player.stop();
|
||||
setStatus('STOPPED');
|
||||
btnPlay.disabled = false;
|
||||
btnStop.disabled = true;
|
||||
log('Playback stopped');
|
||||
});
|
||||
|
||||
// Volume control
|
||||
volumeSlider.addEventListener('input', (e) => {
|
||||
player.setVolume(e.target.value / 100);
|
||||
});
|
||||
|
||||
// Quick test buttons
|
||||
document.getElementById('btn-c4').addEventListener('click', async () => {
|
||||
await player.resume();
|
||||
const apu = player.getAPU();
|
||||
apu.getPulseChannel('p1')?.playNote(60, 0.3, 100);
|
||||
});
|
||||
|
||||
document.getElementById('btn-chord').addEventListener('click', async () => {
|
||||
await player.resume();
|
||||
const apu = player.getAPU();
|
||||
const now = apu.getCurrentTime();
|
||||
apu.getPulseChannel('p1')?.playNote(60, 0.4, 80, now);
|
||||
apu.getPulseChannel('p2')?.playNote(64, 0.4, 80, now);
|
||||
apu.getPulseChannel('p3')?.playNote(67, 0.4, 80, now);
|
||||
});
|
||||
|
||||
document.getElementById('btn-bass').addEventListener('click', async () => {
|
||||
await player.resume();
|
||||
const apu = player.getAPU();
|
||||
apu.getWaveChannel('w1')?.playNote(36, 0.5, 100);
|
||||
});
|
||||
|
||||
document.getElementById('btn-drum').addEventListener('click', async () => {
|
||||
await player.resume();
|
||||
const apu = player.getAPU();
|
||||
apu.getNoiseChannel('n1')?.playKick(100);
|
||||
});
|
||||
|
||||
// Colorizer preset buttons
|
||||
const presetButtons = {
|
||||
dmg: document.getElementById('btn-dmg'),
|
||||
gbc: document.getElementById('btn-gbc'),
|
||||
gba: document.getElementById('btn-gba'),
|
||||
clean: document.getElementById('btn-clean'),
|
||||
};
|
||||
|
||||
function setColorizerPreset(preset) {
|
||||
log(`Sound mode: ${preset.toUpperCase()}`);
|
||||
player.getAPU().setColorizerPreset(preset);
|
||||
|
||||
// Update button states
|
||||
Object.entries(presetButtons).forEach(([key, btn]) => {
|
||||
btn.classList.toggle('active', key === preset);
|
||||
});
|
||||
}
|
||||
|
||||
presetButtons.dmg.addEventListener('click', () => setColorizerPreset('dmg'));
|
||||
presetButtons.gbc.addEventListener('click', () => setColorizerPreset('gbc'));
|
||||
presetButtons.gba.addEventListener('click', () => setColorizerPreset('gba'));
|
||||
presetButtons.clean.addEventListener('click', () => setColorizerPreset('clean'));
|
||||
|
||||
// ===== ARRANGER TOGGLE =====
|
||||
|
||||
const arrangerBtn = document.getElementById('btn-arranger');
|
||||
let arrangerEnabled = true;
|
||||
|
||||
arrangerBtn.addEventListener('click', () => {
|
||||
arrangerEnabled = !arrangerEnabled;
|
||||
player.setArrangerEnabled(arrangerEnabled);
|
||||
|
||||
if (arrangerEnabled) {
|
||||
arrangerBtn.textContent = 'ON';
|
||||
arrangerBtn.style.background = 'var(--gb-green)';
|
||||
arrangerBtn.style.color = 'var(--bg-dark)';
|
||||
log('Arranger ON - sparse MIDIs will be enhanced');
|
||||
} else {
|
||||
arrangerBtn.textContent = 'OFF';
|
||||
arrangerBtn.style.background = 'transparent';
|
||||
arrangerBtn.style.color = 'var(--gb-green)';
|
||||
log('Arranger OFF - playing raw MIDI data');
|
||||
}
|
||||
});
|
||||
|
||||
// ===== BITMIDI SEARCH =====
|
||||
|
||||
async function handleSearch() {
|
||||
const query = searchInput.value.trim();
|
||||
if (!query) {
|
||||
log('Enter a song name to search');
|
||||
return;
|
||||
}
|
||||
|
||||
log(`Searching: "${query}"...`);
|
||||
setStatus('SEARCHING');
|
||||
btnSearch.disabled = true;
|
||||
resultsList.innerHTML = '';
|
||||
|
||||
try {
|
||||
const results = await midiService.search(query);
|
||||
searchResults = results;
|
||||
|
||||
if (results.length === 0) {
|
||||
log('No MIDI files found. Try a different search.', 'error');
|
||||
setStatus('NO RESULTS');
|
||||
return;
|
||||
}
|
||||
|
||||
log(`Found ${results.length} results`, 'success');
|
||||
setStatus('SELECT MIDI');
|
||||
displayResults();
|
||||
|
||||
} catch (error) {
|
||||
log(`Search error: ${error.message}`, 'error');
|
||||
setStatus('ERROR');
|
||||
} finally {
|
||||
btnSearch.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function displayResults() {
|
||||
resultsList.innerHTML = '';
|
||||
|
||||
for (let i = 0; i < Math.min(searchResults.length, 10); i++) {
|
||||
const result = searchResults[i];
|
||||
const item = document.createElement('div');
|
||||
item.className = 'result-item';
|
||||
item.setAttribute('role', 'button');
|
||||
item.setAttribute('tabindex', '0');
|
||||
item.innerHTML = `
|
||||
<span class="title">${result.title}</span>
|
||||
<span class="confidence">${Math.round((result.confidence || 0) * 100)}%</span>
|
||||
`;
|
||||
item.addEventListener('click', () => selectResult(i));
|
||||
item.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
selectResult(i);
|
||||
}
|
||||
});
|
||||
resultsList.appendChild(item);
|
||||
}
|
||||
}
|
||||
|
||||
async function selectResult(index) {
|
||||
if (index < 0 || index >= searchResults.length) return;
|
||||
|
||||
selectedResultIndex = index;
|
||||
const result = searchResults[index];
|
||||
|
||||
// Update UI to show selection
|
||||
document.querySelectorAll('.result-item').forEach((el, i) => {
|
||||
el.classList.toggle('selected', i === index);
|
||||
});
|
||||
|
||||
log(`Loading: ${result.title}...`);
|
||||
setStatus('LOADING');
|
||||
|
||||
try {
|
||||
const midiBuffer = await midiService.fetchMIDI(result.midiUrl);
|
||||
|
||||
if (!midiBuffer) {
|
||||
throw new Error('Failed to fetch MIDI');
|
||||
}
|
||||
|
||||
currentMIDI = midiBuffer;
|
||||
|
||||
// Analyze without playing
|
||||
const info = player.analyzeMIDI(midiBuffer);
|
||||
updateInfo(info);
|
||||
updateChannels(info.assignments);
|
||||
|
||||
log(`Loaded: ${info.noteCount} notes, ${info.trackCount} tracks`, 'success');
|
||||
|
||||
btnPlay.disabled = false;
|
||||
setStatus('READY');
|
||||
|
||||
} catch (error) {
|
||||
log(`Load error: ${error.message}`, 'error');
|
||||
setStatus('ERROR');
|
||||
}
|
||||
}
|
||||
|
||||
// Search event listeners
|
||||
btnSearch.addEventListener('click', handleSearch);
|
||||
searchInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') handleSearch();
|
||||
});
|
||||
|
||||
// Initialize
|
||||
log('Wario Synth v2 initialized', 'success');
|
||||
log('Search BitMidi or drop a MIDI file');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user