/**
* Полифония разовых sfx: учёт играющих голосов, лимит с воровством самого
* тихого, restart по ключу, панорама в полёте. Шины и контекст даёт AudioManager.
*/
import { clamp, MIN_FADE_VOLUME, type AudioBuses, type BusName, type PlayOptions, type SfxHandle } from './types';
/** Голос разового sfx: узлы + ключ (учёт для лимита полифонии и restart). */
interface SfxVoice {
key: string;
/** Контекст и шины запуска (фейки тестов не дают source.context). */
ctx: AudioContext;
buses: AudioBuses;
source: AudioBufferSourceNode;
gain: GainNode;
panner: StereoPannerNode | null;
/** Шина голоса (pan в полёте переподключается в ней же, не в sfx). */
bus: BusName;
/** Заглушен принудительно (stop/вор/новый restart) — onEnded не зовётся. */
killed: boolean;
}
export class SfxVoices {
private voices: SfxVoice[] = [];
/** Звук доиграл до конца (stop/вор/новый restart — не считаются). */
onEnded?: (key: string) => void;
/** @param maxVoices лимит одновременных голосов (0 — без лимита). */
constructor(private maxVoices: number) {}
/**
* Общий запуск голоса (play — после декода, playBuffer — как есть).
* onPlayed зовётся после фактического source.start с нормализованными опциями.
*/
start(
ctx: AudioContext,
buses: AudioBuses,
key: string,
buf: AudioBuffer,
o: PlayOptions,
onPlayed: (key: string, opts: PlayOptions) => void
): SfxHandle | null {
const volume = o.volume ?? 1;
const rate = clamp(o.rate ?? 1, 0.5, 2);
const pan = clamp(o.pan ?? 0, -1, 1);
const bus = o.bus ?? 'sfx';
if (o.restart) this.stopKey(key);
this.stealIfNeeded();
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(buses[bus]);
} else {
gain.connect(buses[bus]);
}
source.start();
const voice: SfxVoice = { key, ctx, buses, source, gain, panner, bus, killed: false };
source.onended = () => {
this.release(voice);
if (!voice.killed) this.onEnded?.(key);
};
this.voices.push(voice);
onPlayed(key, { volume, rate, pan });
return this.makeHandle(voice);
}
/** Остановить все играющие голоса ключа (короткий фейд). */
stopKey(key: string, fadeSeconds = 0.05): void {
for (const voice of [...this.voices]) {
if (voice.key === key) this.kill(voice, fadeSeconds);
}
}
/** Погасить все голоса (dispose менеджера). */
killAll(): void {
for (const voice of [...this.voices]) this.kill(voice, 0.05);
}
/** Хендл над голосом: после снятия с учёта (stop/конец) — no-op. */
private makeHandle(voice: SfxVoice): SfxHandle {
const alive = () => this.voices.includes(voice);
return {
stop: (fadeSeconds = 0.05) => {
if (alive()) this.kill(voice, fadeSeconds);
},
setVolume: (v: number) => {
if (alive()) voice.gain.gain.value = Math.max(0, v);
},
setPan: (p: number) => {
if (alive()) this.setPan(voice, clamp(p, -1, 1));
},
setRate: (r: number) => {
if (alive()) voice.source.playbackRate.value = clamp(r, 0.5, 2);
}
};
}
/** Панорама голоса: узел создаётся при первом ненулевом значении — в шине голоса. */
private setPan(voice: SfxVoice, pan: number): void {
if (voice.panner) {
voice.panner.pan.value = pan;
return;
}
if (Math.abs(pan) < 0.01 || typeof voice.ctx.createStereoPanner !== 'function') return;
const panner = voice.ctx.createStereoPanner();
panner.pan.value = pan;
voice.gain.disconnect();
voice.gain.connect(panner).connect(voice.buses[voice.bus]);
voice.panner = panner;
}
/** Лимит голосов: при переполнении воруется самый тихий (ничья — самый старый). */
private stealIfNeeded(): void {
while (this.maxVoices > 0 && this.voices.length >= this.maxVoices) {
let victim = this.voices[0]!;
for (const v of this.voices) {
if (v.gain.gain.value < victim.gain.gain.value) victim = v;
}
this.kill(victim, 0.05);
}
}
/** Затухание голоса + снятие с учёта (handle сразу «мёртв»). */
private kill(voice: SfxVoice, fadeSeconds: number): void {
voice.killed = true;
const t = voice.ctx.currentTime;
voice.gain.gain.cancelScheduledValues(t);
const from = Math.max(voice.gain.gain.value, MIN_FADE_VOLUME);
voice.gain.gain.setValueAtTime(from, t);
voice.gain.gain.exponentialRampToValueAtTime(MIN_FADE_VOLUME, t + fadeSeconds);
setTimeout(() => {
try {
voice.source.stop();
} catch {
// уже остановлен
}
}, fadeSeconds * 1000 + 50);
this.release(voice);
}
/** Снять голос с учёта (idempotent — зовётся и из onended, и из kill). */
private release(voice: SfxVoice): void {
const i = this.voices.indexOf(voice);
if (i >= 0) this.voices.splice(i, 1);
}
}