/** * Note class - represents a single musical note */ export class Note { frequency: number; duration: number; constructor(str: string) { const couple = str.split(/\s+/); this.frequency = Note.getFrequency(couple[0]) || 0; this.duration = Note.getDuration(couple[1]) || 0; } /** * Convert a note name (e.g. 'A4') to a frequency (e.g. 440.00) */ static getFrequency(name: string): number { const enharmonics = 'B#-C|C#-Db|D|D#-Eb|E-Fb|E#-F|F#-Gb|G|G#-Ab|A|A#-Bb|B-Cb'; const middleC = 440 * Math.pow(Math.pow(2, 1 / 12), -9); const octaveOffset = 4; const num = /(\d+)/; const offsets: Record = {}; enharmonics.split('|').forEach((val, i) => { val.split('-').forEach((note) => { offsets[note] = i; }); }); const couple = name.split(num); const distance = offsets[couple[0]] ?? 0; const octaveDiff = parseInt(couple[1] || String(octaveOffset), 10) - octaveOffset; const freq = middleC * Math.pow(Math.pow(2, 1 / 12), distance); return freq * Math.pow(2, octaveDiff); } /** * Convert a duration string (e.g. 'q') to a number (e.g. 1) */ static getDuration(symbol: string): number { const numeric = /^[0-9.]+$/; if (numeric.test(symbol)) { return parseFloat(symbol); } return symbol .toLowerCase() .split('') .reduce((prev, curr) => { return ( prev + (curr === 'w' ? 4 : curr === 'h' ? 2 : curr === 'q' ? 1 : curr === 'e' ? 0.5 : curr === 's' ? 0.25 : 0) ); }, 0); } } /** * Sequence class - manages playback of musical sequences */ export class Sequence { ac: AudioContext; tempo: number; loop: boolean; smoothing: number; staccato: number; notes: Note[]; gain: GainNode; bass: BiquadFilterNode | null; mid: BiquadFilterNode | null; treble: BiquadFilterNode | null; waveType: OscillatorType | 'custom'; customWave?: [Float32Array, Float32Array]; osc: OscillatorNode | null; constructor(ac?: AudioContext, tempo = 120, arr?: (Note | string)[]) { this.ac = ac || new AudioContext(); this.tempo = tempo; this.loop = true; this.smoothing = 0; this.staccato = 0; this.notes = []; this.bass = null; this.mid = null; this.treble = null; this.osc = null; this.waveType = 'square'; this.gain = this.ac.createGain(); this.createFxNodes(); if (arr) { this.push(...arr); } } /** * Create gain and EQ nodes, then connect them */ createFxNodes(): void { const eq: Array<[string, number]> = [ ['bass', 100], ['mid', 1000], ['treble', 2500], ]; let prev: AudioNode = this.gain; eq.forEach((config) => { const filter = this.ac.createBiquadFilter(); filter.type = 'peaking'; filter.frequency.value = config[1]; prev.connect(filter); prev = filter; if (config[0] === 'bass') { this.bass = filter; } else if (config[0] === 'mid') { this.mid = filter; } else if (config[0] === 'treble') { this.treble = filter; } }); prev.connect(this.ac.destination); } /** * Accepts Note instances or strings (e.g. 'A4 e') */ push(...notes: (Note | string)[]): this { notes.forEach((note) => { this.notes.push(note instanceof Note ? note : new Note(note)); }); return this; } /** * Create a custom waveform */ createCustomWave(real: number[], imag?: number[]): void { if (!imag) { imag = real; } this.waveType = 'custom'; this.customWave = [new Float32Array(real), new Float32Array(imag)]; } /** * Recreate the oscillator node (happens on every play) */ createOscillator(): this { this.stop(); this.osc = this.ac.createOscillator(); if (this.customWave) { this.osc.setPeriodicWave(this.ac.createPeriodicWave(this.customWave[0], this.customWave[1])); } else { this.osc.type = this.waveType === 'custom' ? 'square' : this.waveType; } if (this.gain) { this.osc.connect(this.gain); } return this; } /** * Schedule a note to play at the given time */ scheduleNote(index: number, when: number): number { const duration = (60 / this.tempo) * this.notes[index].duration; const cutoff = duration * (1 - (this.staccato || 0)); this.setFrequency(this.notes[index].frequency, when); if (this.smoothing && this.notes[index].frequency) { this.slide(index, when, cutoff); } this.setFrequency(0, when + cutoff); return when + duration; } /** * Get the next note */ getNextNote(index: number): Note { return this.notes[index < this.notes.length - 1 ? index + 1 : 0]; } /** * How long do we wait before beginning the slide? */ getSlideStartDelay(duration: number): number { return duration - Math.min(duration, (60 / this.tempo) * this.smoothing); } /** * Slide the note at index into the next note */ slide(index: number, when: number, cutoff: number): this { const next = this.getNextNote(index); const start = this.getSlideStartDelay(cutoff); this.setFrequency(this.notes[index].frequency, when + start); this.rampFrequency(next.frequency, when + cutoff); return this; } /** * Set frequency at time */ setFrequency(freq: number, when: number): this { if (this.osc) { this.osc.frequency.setValueAtTime(freq, when); } return this; } /** * Ramp to frequency at time */ rampFrequency(freq: number, when: number): this { if (this.osc) { this.osc.frequency.linearRampToValueAtTime(freq, when); } return this; } /** * Run through all notes in the sequence and schedule them */ play(when?: number): this { const startTime = typeof when === 'number' ? when : this.ac.currentTime; this.createOscillator(); if (this.osc) { this.osc.start(startTime); let currentTime = startTime; this.notes.forEach((_note, i) => { currentTime = this.scheduleNote(i, currentTime); }); this.osc.stop(currentTime); this.osc.onended = this.loop ? () => this.play(currentTime) : null; } return this; } /** * Stop playback */ stop(): this { if (this.osc) { this.osc.onended = null; this.osc.disconnect(); this.osc = null; } return this; } }