/**
* WebAudio с четырьмя шинами (master/music/sfx/ambience): громкости по шинам,
* кроссфейд лупов (музыка и амбиент — независимые слоты), опции sfx
* (громкость/скорость/панорама), кэш сэмплов. Игры подключают Settings.onChange
* и вызывают setBusVolume.
*/
import { renderSpec, RATE, type SoundSpec } from './SoundSpec';
export interface AudioBuses {
master: GainNode;
music: GainNode;
sfx: GainNode;
ambience: GainNode;
}
export type BusName = 'master' | 'music' | 'sfx' | 'ambience';
export interface MusicHandle {
stop(fadeSeconds?: number): void;
/** Задать громкость сразу (для медленных тик-фейдов поверх — локальные слои). */
setVolume(volume: number): void;
}
/** Хендл играющего разового sfx: параметры в полёте + остановка. */
export interface SfxHandle {
stop(fadeSeconds?: number): void;
setVolume(volume: number): void;
setPan(pan: number): void;
setRate(rate: number): void;
}
/** Опции разового sfx. */
export interface PlayOptions {
/** Относительная громкость в шине sfx (0..1+). */
volume?: number;
/** Скорость воспроизведения 0.5..2 (джиттер шагов, вариации тона). */
rate?: number;
/** Панорама -1..1; |pan| < 0.01 — узел StereoPanner не создаётся. */
pan?: number;
/** Заглушить прежние играющие экземпляры этого же ключа (короткий фейд). */
restart?: boolean;
}
interface LoopOptions {
loop?: boolean;
fade?: number;
volume?: number;
bus?: BusName;
}
/** Запущенный луп: источник + его огибающая громкости. */
interface Slot {
source: AudioBufferSourceNode;
gain: GainNode;
}
/** Голос разового sfx: узлы + ключ (учёт для лимита полифонии и restart). */
interface SfxVoice {
key: string;
source: AudioBufferSourceNode;
gain: GainNode;
panner: StereoPannerNode | null;
/** Заглушен принудительно (stop/вор/новый restart) — onEnded не зовётся. */
killed: boolean;
}
const DEFAULT_FADE = 1;
const MIN_FADE_VOLUME = 0.0001;
const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v));
export class AudioManager {
private ctx: AudioContext | null = null;
private buffers = new Map<string, AudioBuffer>();
/** Буферы спек-синтеза (playSpec): компиляция один раз на ключ. */
private specBuffers = new Map<string, AudioBuffer>();
private urls = new Map<string, string>();
private buses: AudioBuses | null = null;
private currentMusic: Slot | null = null;
private currentAmbience: Slot | null = null;
/** Играющие голоса sfx (учёт полифонии и restart). */
private sfxVoices: SfxVoice[] = [];
/** Лимит одновременных sfx-голосов (0 — без лимита); избыток — воруется. */
private maxVoices: number;
/**
* Шпионский хук: вызывается при каждом фактическом запуске звука —
* и разового sfx, и лупов (музыка/амбиент/слои; rate 1, pan 0).
* Для тестов и DEV-лога игры.
*/
onPlayed?: (key: string, opts: PlayOptions) => void;
/** Звук доиграл до конца (stop/вор/новый restart — не считаются). */
onEnded?: (key: string) => void;
constructor(
private resolveUrl: (key: string) => string,
private ctxFactory: () => AudioContext = () => new AudioContext(),
{ maxVoices = 24 }: { maxVoices?: number } = {}
) {
this.maxVoices = maxVoices;
}
/** Расблокировать аудио — вызвать из обработчика пользовательского ввода. */
async unlock(): Promise<void> {
if (!this.ctx) {
this.ctx = this.ctxFactory();
const master = this.ctx.createGain();
const music = this.ctx.createGain();
const sfx = this.ctx.createGain();
const ambience = this.ctx.createGain();
music.connect(master);
sfx.connect(master);
ambience.connect(master);
master.connect(this.ctx.destination);
this.buses = { master, music, sfx, ambience };
}
if (this.ctx.state === 'suspended') {
await this.ctx.resume();
}
}
/** Громкость шины 0..1 (до unlock шин ещё нет — вызов игнорируется). */
setBusVolume(bus: BusName, 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));
}
/**
* Декодировать заранее (после unlock, например в сцене загрузки),
* чтобы первый play не тратил время на fetch+decode.
*/
async preload(key: string): Promise<void> {
const ctx = await this.ensureContext();
if (!ctx) return;
await this.decode(key);
}
/**
* Проиграть sfx и вернуть хендл играющего голоса (null — не декодировался
* или контекст не готов; хендл «мёртв» после stop и естественного конца).
* opts — число (громкость, обратная совместимость) или PlayOptions.
*/
async play(key: string, opts?: number | PlayOptions): Promise<SfxHandle | null> {
const o: PlayOptions = typeof opts === 'number' ? { volume: opts } : (opts ?? {});
const ctx = await this.ensureContext();
if (!ctx) return null;
const buf = await this.decode(key);
if (!buf) return null;
return this.startSfx(ctx, key, buf, o);
}
/**
* Проиграть буфер из памяти как sfx (процедурные звуки в рантайме). Ключ —
* логическое имя голоса (для restart, полифонии и шпионов), не файл.
*/
async playBuffer(key: string, buffer: AudioBuffer, opts?: number | PlayOptions): Promise<SfxHandle | null> {
const o: PlayOptions = typeof opts === 'number' ? { volume: opts } : (opts ?? {});
const ctx = await this.ensureContext();
if (!ctx) return null;
return this.startSfx(ctx, key, buffer, o);
}
/**
* Буфер из моно-FLOAT сэмплов (для процедурных звуков в рантайме).
* До unlock контекста нет — null; создать буфер по ключу не получится,
* вызывать после первого play/unlock.
*/
createBuffer(data: Float32Array, sampleRate = 22050): AudioBuffer | null {
if (!this.ctx) return null;
const buf = this.ctx.createBuffer(1, data.length, sampleRate);
// DOM-тип требует Float32Array<ArrayBuffer> — сэмплы из генераторов не обязаны.
buf.copyToChannel(data as Float32Array<ArrayBuffer>, 0);
return buf;
}
/**
* Синтез по спеку (см. SoundSpec): детерминированно компилирует описание
* звука в буфер (кэш по ключу) и играет как sfx. Для ИИ-агента: описывает
* звук параметрами, без файлов; факт запуска виден в шпионе onPlayed.
*/
async playSpec(key: string, spec: SoundSpec, opts?: number | PlayOptions): Promise<SfxHandle | null> {
const ctx = await this.ensureContext();
if (!ctx) return null;
let buf = this.specBuffers.get(key);
if (!buf) {
const created = this.createBuffer(renderSpec(spec), RATE);
if (!created) return null;
buf = created;
this.specBuffers.set(key, buf);
}
return this.startSfx(ctx, key, buf, typeof opts === 'number' ? { volume: opts } : (opts ?? {}));
}
/** Общий запуск голоса sfx (play — после декода, playBuffer — как есть). */
private startSfx(ctx: AudioContext, key: string, buf: AudioBuffer, o: PlayOptions): SfxHandle | null {
const volume = o.volume ?? 1;
const rate = clamp(o.rate ?? 1, 0.5, 2);
const pan = clamp(o.pan ?? 0, -1, 1);
if (o.restart) this.stopKey(key);
this.stealVoiceIfNeeded();
const source = ctx.createBufferSource();
source.buffer = buf;
source.playbackRate.value = rate;
const gain = ctx.createGain();
gain.gain.value = volume;
source.connect(gain);
let panner: StereoPannerNode | null = null;
if (Math.abs(pan) >= 0.01 && typeof ctx.createStereoPanner === 'function') {
panner = ctx.createStereoPanner();
panner.pan.value = pan;
gain.connect(panner).connect(this.buses!.sfx);
} else {
gain.connect(this.buses!.sfx);
}
source.start();
const voice: SfxVoice = { key, source, gain, panner, killed: false };
source.onended = () => {
this.releaseVoice(voice);
if (!voice.killed) this.onEnded?.(key);
};
this.sfxVoices.push(voice);
this.onPlayed?.(key, { volume, rate, pan });
return this.makeSfxHandle(voice);
}
/**
* Длительность буфера в секундах (после preload/декода); не декодирован —
* null. Для катсцен и генераторов, синхронизирующихся со звуком.
*/
duration(key: string): number | null {
return this.buffers.get(key)?.duration ?? null;
}
/** Хендл над голосом: после снятия с учёта (stop/конец) — no-op. */
private makeSfxHandle(voice: SfxVoice): SfxHandle {
const alive = () => this.sfxVoices.includes(voice);
return {
stop: (fadeSeconds = 0.05) => {
if (alive()) this.killVoice(voice, fadeSeconds);
},
setVolume: (v: number) => {
if (alive()) voice.gain.gain.value = Math.max(0, v);
},
setPan: (p: number) => {
if (alive()) this.setVoicePan(voice, clamp(p, -1, 1));
},
setRate: (r: number) => {
if (alive()) voice.source.playbackRate.value = clamp(r, 0.5, 2);
}
};
}
/** Панорама голоса: узел создаётся при первом ненулевом значении. */
private setVoicePan(voice: SfxVoice, pan: number): void {
if (!this.ctx) return;
if (voice.panner) {
voice.panner.pan.value = pan;
return;
}
if (Math.abs(pan) < 0.01 || typeof this.ctx.createStereoPanner !== 'function') return;
const panner = this.ctx.createStereoPanner();
panner.pan.value = pan;
voice.gain.disconnect();
voice.gain.connect(panner).connect(this.buses!.sfx);
voice.panner = panner;
}
/** Остановить все играющие голоса ключа (короткий фейд). */
private stopKey(key: string, fadeSeconds = 0.05): void {
for (const voice of [...this.sfxVoices]) {
if (voice.key === key) this.killVoice(voice, fadeSeconds);
}
}
/** Лимит голосов: при переполнении воруется самый тихий (ничья — самый старый). */
private stealVoiceIfNeeded(): void {
while (this.maxVoices > 0 && this.sfxVoices.length >= this.maxVoices) {
let victim = this.sfxVoices[0]!;
for (const v of this.sfxVoices) {
if (v.gain.gain.value < victim.gain.gain.value) victim = v;
}
this.killVoice(victim, 0.05);
}
}
/** Затухание голоса + снятие с учёта (handle сразу «мёртв»). */
private killVoice(voice: SfxVoice, fadeSeconds: number): void {
voice.killed = true;
this.fadeOut(voice, fadeSeconds);
this.releaseVoice(voice);
}
/** Снять голос с учёта (idempotent — зовётся и из onended, и из killVoice). */
private releaseVoice(voice: SfxVoice): void {
const i = this.sfxVoices.indexOf(voice);
if (i >= 0) this.sfxVoices.splice(i, 1);
}
/**
* Играть луп в заданной шине; НЕ занимает ни слот музыки, ни слот амбиента —
* база для локальных амбиент-слоёв (несколько параллельных лупов).
*/
async playLoop(key: string, { loop = true, fade = DEFAULT_FADE, volume = 1, bus = 'music' }: LoopOptions = {}): Promise<MusicHandle | null> {
const slot = await this.startLoop(key, { loop, fade, volume, bus });
if (!slot) return null;
return this.makeHandle(slot, fade);
}
/** Handle над слотом: затухание + мгновенная громкость (без борьбы с ramp). */
private makeHandle(slot: Slot, fade: number): MusicHandle {
return {
stop: (fadeSeconds = fade) => this.fadeOut(slot, fadeSeconds),
setVolume: (volume: number) => {
if (!this.ctx) return;
const t = this.ctx.currentTime;
slot.gain.gain.cancelScheduledValues(t);
slot.gain.gain.setValueAtTime(Math.max(0, volume), t);
}
};
}
/**
* Играть музыку с кроссфейдом: текущий трек затухает, новый нарастает
* за fadeSeconds. Слот один — повторный вызов вытесняет прежний трек.
*/
async playMusic(key: string, { loop = true, fade = DEFAULT_FADE, volume = 1 }: Omit<LoopOptions, 'bus'> = {}): Promise<MusicHandle | null> {
const ctx = await this.ensureContext();
if (!ctx) return null;
// Сначала проверяем ключ: негодный не должен заглушить текущий трек.
if (!(await this.decode(key))) return null;
this.stopMusic(fade);
const slot = await this.startLoop(key, { loop, fade, volume, bus: 'music' });
if (!slot) return null;
this.currentMusic = slot;
return this.makeHandle(slot, fade);
}
/** Остановить музыку (с затуханием). */
stopMusic(fadeSeconds = 0.5): void {
const current = this.currentMusic;
if (!current) return;
this.currentMusic = null;
this.fadeOut(current, fadeSeconds);
}
/**
* Играть амбиент с кроссфейдом — слот отдельный от музыки:
* playAmbience и playMusic не вытесняют друг друга.
*/
async playAmbience(key: string, { fade = DEFAULT_FADE, volume = 1 }: { fade?: number; volume?: number } = {}): Promise<MusicHandle | null> {
const ctx = await this.ensureContext();
if (!ctx) return null;
if (!(await this.decode(key))) return null;
this.stopAmbience(fade);
const slot = await this.startLoop(key, { loop: true, fade, volume, bus: 'ambience' });
if (!slot) return null;
this.currentAmbience = slot;
return this.makeHandle(slot, fade);
}
/** Остановить амбиент (с затуханием). */
stopAmbience(fadeSeconds = 0.5): void {
const current = this.currentAmbience;
if (!current) return;
this.currentAmbience = null;
this.fadeOut(current, fadeSeconds);
}
/** Общий запуск лупа (источник + линейное нарастание громкости). */
private async startLoop(key: string, { loop, fade, volume, bus }: Required<LoopOptions>): Promise<Slot | null> {
const ctx = await this.ensureContext();
if (!ctx) return null;
const buf = await this.decode(key);
if (!buf) return null;
const source = ctx.createBufferSource();
source.buffer = buf;
source.loop = loop;
const gain = ctx.createGain();
gain.gain.setValueAtTime(MIN_FADE_VOLUME, ctx.currentTime);
gain.gain.linearRampToValueAtTime(volume, ctx.currentTime + fade);
source.connect(gain).connect(this.buses![bus]);
source.start();
this.onPlayed?.(key, { volume, rate: 1, pan: 0 });
return { source, gain };
}
/** Затухание слота и остановка источника после fadeSeconds. */
private fadeOut(slot: Slot, fadeSeconds: number): void {
if (!this.ctx) return;
const t = this.ctx.currentTime;
slot.gain.gain.cancelScheduledValues(t);
slot.gain.gain.setValueAtTime(slot.gain.gain.value, t);
slot.gain.gain.linearRampToValueAtTime(MIN_FADE_VOLUME, t + fadeSeconds);
setTimeout(() => {
try {
slot.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;
}
}