Use when implementing audio features, adding new instruments, or modifying Tone.js synthesis - ensures proper resource management, prevents memory leaks, and maintains consistent audio quality
Audio in this project uses Tone.js for synthesis. Improper handling causes memory leaks, audio glitches, and browser crashes.
Core principle: Initialize once, reuse always, dispose completely.
Bass Guitar (FretboardComponent):
FMSynth → EQ3 → Compressor → Reverb → Volume → Destination
Piano (KeyboardComponent):
PolySynth(Synth) → Reverb → Volume → Destination
private synth: Tone.PolySynth | null = null;
private reverb: Tone.Reverb | null = null;
private volume: Tone.Volume | null = null;
async initAudio(): Promise<void> {
// Start audio context (REQUIRED - needs user gesture)
await Tone.start();
// Create effects chain (end to start)
this.volume = new Tone.Volume(-6).toDestination();
this.reverb = new Tone.Reverb({ decay: 2.5 }).connect(this.volume);
// Wait for reverb to generate IR
await this.reverb.ready;
// Create synth and connect
this.synth = new Tone.PolySynth(Tone.Synth).connect(this.reverb);
// Configure synth
this.synth.set({
oscillator: { type: 'sine' },
envelope: {
attack: 0.005,
decay: 0.3,
sustain: 0.2,
release: 1.5
}
});
}
ngOnDestroy(): void {
// Dispose in reverse order of creation
this.synth?.dispose();
this.reverb?.dispose();
this.volume?.dispose();
// Null out references
this.synth = null;
this.reverb = null;
this.volume = null;
}
playNote(noteName: string, duration: string = '8n'): void {
if (!this.synth) return;
// triggerAttackRelease handles timing automatically
this.synth.triggerAttackRelease(noteName, duration);
}
// Convert note value (0-11) and octave to Tone.js format
const noteName = `${CHROMATIC_SHARPS[noteValue]}${octave}`; // e.g., "C4", "F#3"
this.synth.triggerAttackRelease(noteName, '8n');
playScale(notes: string[], tempo: number = 120): void {
const now = Tone.now();
const noteDuration = 60 / tempo; // seconds per beat
notes.forEach((note, index) => {
this.synth?.triggerAttackRelease(
note,
'8n',
now + (index * noteDuration)
);
});
}
playChord(notes: string[], duration: string = '2n'): void {
if (!this.synth) return;
// PolySynth handles multiple simultaneous notes
this.synth.triggerAttackRelease(notes, duration);
}
const bassSynth = new Tone.FMSynth({
harmonicity: 0.25,
modulationIndex: 8,
oscillator: { type: 'sine' },
envelope: {
attack: 0.01,
decay: 0.2,
sustain: 0.8,
release: 0.5
},
modulation: { type: 'sine' },
modulationEnvelope: {
attack: 0.01,
decay: 0.2,
sustain: 0.5,
release: 0.5
}
});
const bassEQ = new Tone.EQ3({
low: 8, // Boost bass frequencies
mid: -2, // Slight mid cut
high: -8 // Cut highs for warmth
});
const bassCompressor = new Tone.Compressor({
ratio: 8,
threshold: -24,
attack: 0.003,
release: 0.25
});
const pianoSynth = new Tone.PolySynth(Tone.Synth, {
oscillator: { type: 'sine' },
envelope: {
attack: 0.005,
decay: 0.3,
sustain: 0.2,
release: 1.5
}
});
const pianoReverb = new Tone.Reverb({
decay: 2.5,
wet: 0.3
});
const guitarSynth = new Tone.PluckSynth({
attackNoise: 1.5,
dampening: 4000,
resonance: 0.95,
release: 1.2
});
// Audio context starts suspended - MUST have user interaction
async handleFirstInteraction(): Promise<void> {
if (Tone.context.state !== 'running') {
await Tone.start();
}
}
// Attach to click handler
<button (click)="handleFirstInteraction(); playNote('C4')">Play</button>
playNoteWithFeedback(noteIndex: number, noteName: string): void {
// Visual highlight
this.currentlyPlayingPosition = noteIndex;
// Play audio
this.synth?.triggerAttackRelease(noteName, '8n');
// Clear highlight after duration
setTimeout(() => {
this.currentlyPlayingPosition = null;
}, 300);
}
setVolume(db: number): void {
if (this.volume) {
this.volume.volume.value = db; // -60 to 0, where 0 is max
}
}
mute(): void {
if (this.volume) {
this.volume.mute = true;
}
}
| Anti-Pattern | Problem | Correct Approach |
|---|---|---|
new Tone.Synth() per note | Memory leak, audio glitches | Initialize once, reuse |
| No disposal in ngOnDestroy | Memory never freed | Always dispose all nodes |
Forgetting await Tone.start() | Silent playback | Call on first interaction |
Not awaiting reverb.ready | Reverb may not work | Always await before connecting |
Using setTimeout for timing | Timing drift | Use Tone.js Transport or Tone.now() |
| Connecting nodes out of order | No audio output | Connect from source to destination |
Tone.context.state - must be 'running'triggerRelease()// Mock Tone.js for unit tests
beforeEach(() => {
spyOn(Tone, 'start').and.returnValue(Promise.resolve());
// Create mock synth
mockSynth = jasmine.createSpyObj('PolySynth',
['triggerAttackRelease', 'dispose', 'connect', 'set']
);
spyOn(Tone, 'PolySynth').and.returnValue(mockSynth);
});
it('should play note', async () => {
await component.initAudio();
component.playNote('C4');
expect(mockSynth.triggerAttackRelease).toHaveBeenCalledWith('C4', '8n');
});
The app uses two audio systems:
alphaTab creates its own Web Audio context. When switching between pages:
// GP Viewer Component
export class GpViewerComponent implements OnInit, OnDestroy {
ngOnInit(): void {
// alphaTab will create its own audio context
// Tone.js context remains but is unused
}
ngOnDestroy(): void {
// Dispose alphaTab resources
this.alphaTabService.dispose();
// Tone.js resumes automatically when fretboard initializes
}
}
| Context | Managed By | Notes |
|---|---|---|
| Tone.js AudioContext | Tone.js library | Auto-created on first Tone.start() |
| alphaTab AudioContext | alphaTab library | Created by AlphaTabApi initialization |
api.destroy() is called