feat: implement Music and SoundEffects systems for enhanced audio management, including background music and sound effects playback
All checks were successful
Build and Publish Docker Image / Build and Validate (pull_request) Successful in 10s
All checks were successful
Build and Publish Docker Image / Build and Validate (pull_request) Successful in 10s
This commit is contained in:
parent
143072f0a0
commit
2213f64e60
16 changed files with 739 additions and 14 deletions
269
src/core/Music.ts
Normal file
269
src/core/Music.ts
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
/**
|
||||
* 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<string, number> = {};
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue