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:
b1rdmania
2026-01-20 19:36:13 +00:00
parent 63bd71202a
commit 5e127c3a3e
21 changed files with 6789 additions and 0 deletions
+603
View File
@@ -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
+434
View File
@@ -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';
+315
View File
@@ -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 = [];
}
}
+239
View File
@@ -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 };
}
}
+237
View File
@@ -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();
}
}
+433
View File
@@ -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>;
}
}
+274
View File
@@ -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,
};
}
}
}
+229
View File
@@ -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;
}
}
+364
View File
@@ -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);
}
}
+335
View File
@@ -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;
}
}
+82
View File
@@ -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];
}
+161
View File
@@ -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);
}
+181
View File
@@ -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;
}
+306
View File
@@ -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
});
}
+267
View File
@@ -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));
}
+333
View File
@@ -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);
}
}
+77
View File
@@ -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';
+151
View File
@@ -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;
}
+528
View File
@@ -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
View File
@@ -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>
+834
View File
@@ -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>