/** * Audio: Lautstärkekanäle (Gesamt, Musik, Effekte), Klangdateien, synthetischer * Ersatz und Hintergrundmusik – alles über die Web Audio API. * * Browser erlauben Ton erst nach einer Nutzerinteraktion. Die Dateien werden deshalb * vorab nur als Bytes geladen und erst in `unlock()` (erster Klick/Tastendruck) * dekodiert. Ohne Web Audio bleibt das Spiel stumm, aber spielbar. * @module systems/AudioManager */ import { AUDIO } from '../config.js'; import { allSoundFiles, AUDIO_PATH, SOUND_FILES, SOUND_OPTIONS } from './AudioAssets.js'; import { MarchMusic } from './Music.js'; import { createNoiseBuffer, SYNTH_RECIPES } from './Synth.js'; /** Obergrenze gleichzeitig klingender Effekte – schützt vor Übersteuern und CPU-Spitzen. */ const MAX_VOICES = 24; /** Lautstärke der Musik während der Pause (Anteil). */ const PAUSE_DUCK = 0.35; export class AudioManager { /** * @param {import('./Settings.js').Settings} settings * @param {{staticBase?: string, fetchImpl?: typeof fetch}} [options] */ constructor(settings, options = {}) { this.settings = settings; this.baseUrl = `${options.staticBase ?? '/static/'}assets/${AUDIO_PATH}`; this.fetchImpl = options.fetchImpl ?? globalThis.fetch?.bind(globalThis); /** @type {AudioContext|null} */ this.context = null; /** @type {{master: GainNode, music: GainNode, sfx: GainNode, duck: GainNode}|null} */ this.gains = null; /** @type {Map>} Rohdaten je Datei (vor dem Entsperren). */ this.raw = new Map(); /** @type {Map} Dekodierte Klänge je Datei. */ this.buffers = new Map(); /** @type {Map} Letzte Abspielzeit je Effekt (gegen Übersteuern). */ this.lastPlayed = new Map(); this.activeVoices = 0; /** @type {MarchMusic|null} */ this.music = null; this.musicWanted = false; this.noiseBuffer = null; settings.onVolumeChange(() => this.applyVolumes()); } /** Lädt die Klangdateien als Bytes vor (ohne AudioContext, also vor jeder Interaktion möglich). */ preload() { if (!this.fetchImpl) return; for (const file of allSoundFiles()) { if (this.raw.has(file)) continue; const request = this.fetchImpl(this.baseUrl + file) .then((response) => (response.ok ? response.arrayBuffer() : null)) .catch(() => null); this.raw.set(file, request); } } /** Erzeugt den AudioContext nach der ersten Nutzerinteraktion und dekodiert die Klänge. */ unlock() { if (this.context) { if (this.context.state === 'suspended') this.context.resume().catch(() => {}); return; } const Ctor = globalThis.AudioContext ?? globalThis.webkitAudioContext; if (!Ctor) return; try { this.context = new Ctor(); } catch { this.context = null; return; } const ctx = this.context; const master = ctx.createGain(); const duck = ctx.createGain(); const music = ctx.createGain(); const sfx = ctx.createGain(); music.connect(duck).connect(master); sfx.connect(master); master.connect(ctx.destination); this.gains = { master, music, sfx, duck }; this.noiseBuffer = createNoiseBuffer(ctx); this.applyVolumes(); this.decodeAll(); if (this.musicWanted) this.startMusic(); } /** @private */ decodeAll() { for (const [file, request] of this.raw) { request.then((bytes) => { if (!bytes || !this.context) return; // decodeAudioData übernimmt den Puffer – eine Kopie hält die Rohdaten wiederverwendbar. this.context .decodeAudioData(bytes.slice(0)) .then((buffer) => this.buffers.set(file, buffer)) .catch(() => { // Format nicht unterstützt (z. B. OGG in alten Safari-Versionen): Synthesizer übernimmt. }); }); } } /** Überträgt die Einstellungen auf die Gain-Knoten. */ applyVolumes() { if (!this.gains || !this.context) return; const t = this.context.currentTime; this.gains.master.gain.setTargetAtTime(this.settings.getVolume('master'), t, 0.02); this.gains.music.gain.setTargetAtTime(this.settings.getVolume('music'), t, 0.02); this.gains.sfx.gain.setTargetAtTime(this.settings.getVolume('sfx'), t, 0.02); } /** * Prüft Mindestabstand und Stimmenlimit. * @param {string} name * @returns {boolean} */ shouldPlay(name) { if (!this.context || !this.gains || this.context.state !== 'running') return false; if (this.activeVoices >= MAX_VOICES) return false; const now = performance.now(); const last = this.lastPlayed.get(name) ?? -Infinity; if (now - last < AUDIO.sfxMinIntervalMs) return false; this.lastPlayed.set(name, now); return true; } /** * Spielt einen Effekt: Klangdatei, falls dekodiert, sonst synthetischer Ersatz. * @param {string} name Schlüssel aus SOUND_FILES. */ play(name) { if (!this.shouldPlay(name)) return; const ctx = /** @type {AudioContext} */ (this.context); const gains = /** @type {NonNullable} */ (this.gains); const options = SOUND_OPTIONS[name] ?? {}; const destination = options.channel === 'music' ? gains.music : gains.sfx; const volume = options.volume ?? 1; const variants = (SOUND_FILES[name] ?? []).map((file) => this.buffers.get(file)).filter(Boolean); this.activeVoices += 1; const release = () => { this.activeVoices = Math.max(0, this.activeVoices - 1); }; if (variants.length > 0) { const source = ctx.createBufferSource(); source.buffer = variants[Math.floor(Math.random() * variants.length)]; // Leichte Tonhöhenstreuung: derselbe Effekt klingt nie exakt gleich. const spread = options.spread ?? 0; source.playbackRate.value = (options.rate ?? 1) * (1 + (Math.random() * 2 - 1) * spread); const gain = ctx.createGain(); gain.gain.value = volume; source.connect(gain).connect(destination); source.onended = release; source.start(); return; } const recipe = SYNTH_RECIPES[name]; if (recipe && this.noiseBuffer) recipe(ctx, destination, this.noiseBuffer, volume); // Synthetische Klänge sind kurz; die Stimme wird nach einer Sekunde freigegeben. setTimeout(release, 1000); } /** Startet die Marschmusik (oder merkt sie vor, bis Audio entsperrt ist). */ startMusic() { this.musicWanted = true; if (!this.context || !this.gains || !this.noiseBuffer || this.music) return; this.music = new MarchMusic(this.context, this.gains.music, this.noiseBuffer); this.music.start(); } stopMusic() { this.musicWanted = false; this.music?.stop(); this.music = null; } /** * Senkt die Musik während der Pause ab. * @param {boolean} ducked */ duck(ducked) { if (!this.gains || !this.context) return; this.gains.duck.gain.setTargetAtTime(ducked ? PAUSE_DUCK : 1, this.context.currentTime, 0.1); } }