Files
2026-09-24 17:57:54 +02:00

189 lines
6.8 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<string, Promise<ArrayBuffer|null>>} Rohdaten je Datei (vor dem Entsperren). */
this.raw = new Map();
/** @type {Map<string, AudioBuffer>} Dekodierte Klänge je Datei. */
this.buffers = new Map();
/** @type {Map<string, number>} 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<AudioManager['gains']>} */ (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);
}
}