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