139 lines
7.3 KiB
JavaScript
139 lines
7.3 KiB
JavaScript
/**
|
||
* Synthetische Klänge über die Web Audio API – ohne eine einzige Audiodatei.
|
||
*
|
||
* Dient als Ersatz, wenn Klangdateien fehlen oder der Browser OGG nicht dekodiert,
|
||
* und liefert Effekte, für die es kein passendes Sample gibt (Rolle, Mörserpfeifen).
|
||
* @module systems/Synth
|
||
*/
|
||
|
||
/**
|
||
* Erzeugt eine Sekunde weißes Rauschen (wird für Rausch-Effekte wiederverwendet).
|
||
* @param {BaseAudioContext} ctx
|
||
* @returns {AudioBuffer}
|
||
*/
|
||
export function createNoiseBuffer(ctx) {
|
||
const buffer = ctx.createBuffer(1, ctx.sampleRate, ctx.sampleRate);
|
||
const data = buffer.getChannelData(0);
|
||
for (let i = 0; i < data.length; i += 1) data[i] = Math.random() * 2 - 1;
|
||
return buffer;
|
||
}
|
||
|
||
/**
|
||
* Hüllkurve: schneller Anstieg, exponentielles Abklingen. Exponentiell klingt
|
||
* natürlicher als linear, darf aber nie genau 0 erreichen (daher 0.0001).
|
||
* @param {GainNode} gain
|
||
* @param {number} start
|
||
* @param {number} peak
|
||
* @param {number} attack
|
||
* @param {number} duration
|
||
*/
|
||
function envelope(gain, start, peak, attack, duration) {
|
||
gain.gain.setValueAtTime(0.0001, start);
|
||
gain.gain.exponentialRampToValueAtTime(Math.max(0.0002, peak), start + attack);
|
||
gain.gain.exponentialRampToValueAtTime(0.0001, start + duration);
|
||
}
|
||
|
||
/**
|
||
* Einzelner Ton mit optionalem Tonhöhenverlauf.
|
||
* @param {AudioContext} ctx
|
||
* @param {AudioNode} destination
|
||
* @param {{type?: OscillatorType, freq: number, freqEnd?: number, duration: number, volume: number, attack?: number, delay?: number}} p
|
||
*/
|
||
export function tone(ctx, destination, p) {
|
||
const start = ctx.currentTime + (p.delay ?? 0);
|
||
const osc = ctx.createOscillator();
|
||
const gain = ctx.createGain();
|
||
osc.type = p.type ?? 'sine';
|
||
osc.frequency.setValueAtTime(p.freq, start);
|
||
if (p.freqEnd) osc.frequency.exponentialRampToValueAtTime(p.freqEnd, start + p.duration);
|
||
envelope(gain, start, p.volume, p.attack ?? 0.005, p.duration);
|
||
osc.connect(gain).connect(destination);
|
||
osc.start(start);
|
||
osc.stop(start + p.duration + 0.05);
|
||
}
|
||
|
||
/**
|
||
* Gefiltertes Rauschen (Knall, Zischen, Wusch).
|
||
* @param {AudioContext} ctx
|
||
* @param {AudioNode} destination
|
||
* @param {AudioBuffer} noiseBuffer
|
||
* @param {{filter?: BiquadFilterType, freq: number, freqEnd?: number, q?: number, duration: number, volume: number, attack?: number}} p
|
||
*/
|
||
export function noise(ctx, destination, noiseBuffer, p) {
|
||
const start = ctx.currentTime;
|
||
const source = ctx.createBufferSource();
|
||
source.buffer = noiseBuffer;
|
||
const filter = ctx.createBiquadFilter();
|
||
filter.type = p.filter ?? 'lowpass';
|
||
filter.Q.value = p.q ?? 1;
|
||
filter.frequency.setValueAtTime(p.freq, start);
|
||
if (p.freqEnd) filter.frequency.exponentialRampToValueAtTime(p.freqEnd, start + p.duration);
|
||
const gain = ctx.createGain();
|
||
envelope(gain, start, p.volume, p.attack ?? 0.004, p.duration);
|
||
source.connect(filter).connect(gain).connect(destination);
|
||
source.start(start, Math.random() * 0.5);
|
||
source.stop(start + p.duration + 0.05);
|
||
}
|
||
|
||
/** Tonfolge (Fanfaren, Hinweise). */
|
||
function arpeggio(ctx, dest, freqs, step, type, volume) {
|
||
freqs.forEach((freq, i) => tone(ctx, dest, { type, freq, duration: step * 1.6, volume, delay: i * step }));
|
||
}
|
||
|
||
/**
|
||
* Rezepte je Effektname. `v` ist die Lautstärke (0..1) des Effekts.
|
||
* @type {Record<string, (ctx: AudioContext, dest: AudioNode, noiseBuffer: AudioBuffer, v: number) => void>}
|
||
*/
|
||
export const SYNTH_RECIPES = {
|
||
'shot-carbine': (c, d, n, v) => {
|
||
tone(c, d, { type: 'square', freq: 1400, freqEnd: 300, duration: 0.08, volume: 0.25 * v });
|
||
noise(c, d, n, { filter: 'highpass', freq: 2500, duration: 0.06, volume: 0.2 * v });
|
||
},
|
||
'shot-shotgun': (c, d, n, v) => noise(c, d, n, { freq: 3000, freqEnd: 300, duration: 0.25, volume: 0.6 * v }),
|
||
'shot-pistol': (c, d, _n, v) => tone(c, d, { type: 'square', freq: 900, freqEnd: 200, duration: 0.1, volume: 0.3 * v }),
|
||
'turret-shot': (c, d, _n, v) => tone(c, d, { type: 'triangle', freq: 1800, freqEnd: 600, duration: 0.06, volume: 0.25 * v }),
|
||
'enemy-shot': (c, d, _n, v) => tone(c, d, { type: 'sawtooth', freq: 500, freqEnd: 150, duration: 0.12, volume: 0.15 * v }),
|
||
'hit-flesh': (c, d, n, v) => noise(c, d, n, { filter: 'bandpass', freq: 700, q: 2, duration: 0.07, volume: 0.5 * v }),
|
||
'hit-metal': (c, d, _n, v) => tone(c, d, { type: 'triangle', freq: 2200, freqEnd: 1600, duration: 0.09, volume: 0.25 * v }),
|
||
'death-swarm': (c, d, n, v) => noise(c, d, n, { filter: 'bandpass', freq: 900, freqEnd: 200, q: 3, duration: 0.3, volume: 0.6 * v }),
|
||
'death-legion': (c, d, n, v) => noise(c, d, n, { freq: 1800, freqEnd: 200, duration: 0.35, volume: 0.6 * v }),
|
||
'explosion-small': (c, d, n, v) => {
|
||
noise(c, d, n, { freq: 1200, freqEnd: 120, duration: 0.6, volume: 0.8 * v });
|
||
tone(c, d, { freq: 110, freqEnd: 40, duration: 0.35, volume: 0.6 * v });
|
||
},
|
||
'explosion-big': (c, d, n, v) => {
|
||
noise(c, d, n, { freq: 900, freqEnd: 60, duration: 1.4, volume: 1 * v });
|
||
tone(c, d, { freq: 80, freqEnd: 30, duration: 0.9, volume: 0.9 * v });
|
||
},
|
||
'player-hit': (c, d, n, v) => {
|
||
tone(c, d, { type: 'square', freq: 220, freqEnd: 90, duration: 0.15, volume: 0.35 * v });
|
||
noise(c, d, n, { filter: 'bandpass', freq: 500, duration: 0.1, volume: 0.4 * v });
|
||
},
|
||
'player-death': (c, d, n, v) => {
|
||
noise(c, d, n, { freq: 700, freqEnd: 50, duration: 1.8, volume: 1 * v });
|
||
tone(c, d, { type: 'sawtooth', freq: 300, freqEnd: 40, duration: 1.6, volume: 0.3 * v });
|
||
},
|
||
reload: (c, d, n, v) => noise(c, d, n, { filter: 'bandpass', freq: 2500, q: 4, duration: 0.08, volume: 0.5 * v }),
|
||
'reload-done': (c, d, _n, v) => tone(c, d, { type: 'square', freq: 1200, duration: 0.04, volume: 0.2 * v }),
|
||
switch: (c, d, _n, v) => tone(c, d, { type: 'triangle', freq: 700, duration: 0.05, volume: 0.3 * v }),
|
||
'combo-step': (c, d, _n, v) => tone(c, d, { type: 'sine', freq: 880, duration: 0.05, volume: 0.35 * v }),
|
||
'combo-fail': (c, d, _n, v) => tone(c, d, { type: 'square', freq: 180, freqEnd: 120, duration: 0.18, volume: 0.25 * v }),
|
||
'orbital-call': (c, d, _n, v) => arpeggio(c, d, [523, 659, 784, 1047], 0.06, 'triangle', 0.3 * v),
|
||
pickup: (c, d, _n, v) => arpeggio(c, d, [659, 784, 988, 1319], 0.05, 'sine', 0.35 * v),
|
||
brood: (c, d, n, v) => noise(c, d, n, { filter: 'bandpass', freq: 400, freqEnd: 900, q: 5, duration: 0.4, volume: 0.5 * v }),
|
||
'ui-click': (c, d, _n, v) => tone(c, d, { type: 'sine', freq: 1000, duration: 0.03, volume: 0.3 * v }),
|
||
'ui-confirm': (c, d, _n, v) => arpeggio(c, d, [784, 1047], 0.06, 'triangle', 0.3 * v),
|
||
'wave-start': (c, d, _n, v) => arpeggio(c, d, [392, 523, 659, 784], 0.12, 'sawtooth', 0.12 * v),
|
||
'wave-cleared': (c, d, _n, v) => arpeggio(c, d, [523, 659, 784, 1047, 1319], 0.1, 'square', 0.1 * v),
|
||
alarm: (c, d, _n, v) => {
|
||
for (let i = 0; i < 3; i += 1) {
|
||
tone(c, d, { type: 'square', freq: 660, duration: 0.14, volume: 0.15 * v, delay: i * 0.3 });
|
||
tone(c, d, { type: 'square', freq: 880, duration: 0.14, volume: 0.15 * v, delay: i * 0.3 + 0.15 });
|
||
}
|
||
},
|
||
'game-over': (c, d, _n, v) => arpeggio(c, d, [392, 349, 311, 262], 0.22, 'triangle', 0.3 * v),
|
||
dodge: (c, d, n, v) => noise(c, d, n, { filter: 'bandpass', freq: 400, freqEnd: 2400, q: 1.5, duration: 0.25, volume: 0.5 * v, attack: 0.05 }),
|
||
// Pfeifen passend zur Mörser-Vorwarnzeit: von hoch nach tief.
|
||
mortar: (c, d, _n, v) => tone(c, d, { type: 'sine', freq: 1500, freqEnd: 450, duration: 1.3, volume: 0.2 * v, attack: 0.1 }),
|
||
};
|