/** * 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 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 }), };