Newer
Older
rpg / packages / engine / src / audio / Voices.ts
/**
 * Полифония разовых 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);
    }
}