189 lines
6.8 KiB
JavaScript
189 lines
6.8 KiB
JavaScript
/**
|
||
* 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);
|
||
}
|
||
}
|