/**
* WebAudio с тремя шинами (master/music/sfx): громкости по шинам,
* кроссфейд музыки, кэш сэмплов. Игры подключают Settings.onChange
* и вызывают setBusVolume.
*/
export interface AudioBuses {
master: GainNode;
music: GainNode;
sfx: GainNode;
}
export interface MusicHandle {
stop(fadeSeconds?: number): void;
}
export class AudioManager {
private ctx: AudioContext | null = null;
private buffers = new Map<string, AudioBuffer>();
private urls = new Map<string, string>();
private buses: AudioBuses | null = null;
private currentMusic: { source: AudioBufferSourceNode; gain: GainNode } | null = null;
constructor(private resolveUrl: (key: string) => string) {}
/** Расблокировать аудио — вызвать из обработчика пользовательского ввода. */
async unlock(): Promise<void> {
if (!this.ctx) {
this.ctx = new AudioContext();
const master = this.ctx.createGain();
const music = this.ctx.createGain();
const sfx = this.ctx.createGain();
music.connect(master);
sfx.connect(master);
master.connect(this.ctx.destination);
this.buses = { master, music, sfx };
}
if (this.ctx.state === 'suspended') {
await this.ctx.resume();
}
}
/** Громкость шины 0..1 (без unlock просто запоминается на будущее). */
setBusVolume(bus: 'master' | 'music' | 'sfx', volume: number): void {
if (this.buses) {
this.buses[bus].gain.value = Math.max(0, Math.min(1, volume));
}
}
async load(key: string): Promise<void> {
this.urls.set(key, this.resolveUrl(key));
}
/**
* Проиграть sfx (если звук не загружен — тихо ничего не делает).
* volume — относительная громкость в шине sfx.
*/
async play(key: string, volume = 1): Promise<void> {
const ctx = await this.ensureContext();
if (!ctx) return;
const buf = await this.decode(key);
if (!buf) return;
const source = ctx.createBufferSource();
source.buffer = buf;
const gain = ctx.createGain();
gain.gain.value = volume;
source.connect(gain).connect(this.buses!.sfx);
source.start();
}
/**
* Играть музыку в цикле с кроссфейдом: текущая трек затухает,
* новый нарастает за fadeSeconds.
*/
async playMusic(key: string, { loop = true, fade = 1, volume = 1 }: { loop?: boolean; fade?: number; volume?: number } = {}): Promise<MusicHandle | null> {
const ctx = await this.ensureContext();
if (!ctx) return null;
const buf = await this.decode(key);
if (!buf) return null;
// Заглушить текущий трек
this.stopMusic(fade);
const source = ctx.createBufferSource();
source.buffer = buf;
source.loop = loop;
const gain = ctx.createGain();
gain.gain.setValueAtTime(0.0001, ctx.currentTime);
gain.gain.linearRampToValueAtTime(volume, ctx.currentTime + fade);
source.connect(gain).connect(this.buses!.music);
source.start();
this.currentMusic = { source, gain };
return {
stop: (fadeSeconds = fade) => this.stopMusic(fadeSeconds)
};
}
/** Остановить музыку (с затуханием). */
stopMusic(fadeSeconds = 0.5): void {
const current = this.currentMusic;
if (!current || !this.ctx) return;
this.currentMusic = null;
const { source, gain } = current;
const t = this.ctx!.currentTime;
gain.gain.cancelScheduledValues(t);
gain.gain.setValueAtTime(gain.gain.value, t);
gain.gain.linearRampToValueAtTime(0.0001, t + fadeSeconds);
window.setTimeout(() => {
try {
source.stop();
} catch {
// уже остановлен
}
}, fadeSeconds * 1000 + 50);
}
private async ensureContext(): Promise<AudioContext | null> {
await this.unlock();
return this.ctx;
}
private async decode(key: string): Promise<AudioBuffer | null> {
if (!this.ctx) return null;
let buf = this.buffers.get(key);
const url = this.urls.get(key);
if (!buf && url) {
try {
const res = await fetch(url);
buf = await this.ctx.decodeAudioData(await res.arrayBuffer());
this.buffers.set(key, buf);
} catch (err) {
console.warn(`[audio] не удалось декодировать ${key}:`, err);
return null;
}
}
return buf ?? null;
}
}