Newer
Older
rpg / packages / engine / src / audio / AudioManager.ts
/**
 * WebAudio с четырьмя шинами (master/music/sfx/ambience): публичный фасад.
 * Полифония sfx — Voices, лупы/кроссфейд — Loops, кэш буферов — BufferCache.
 * Игры подключают Settings.onChange и вызывают setBusVolume.
 */
import { BufferCache } from './decode';
import { LoopSlots } from './Loops';
import { renderSpec, RATE, type SoundSpec } from './SoundSpec';
import { SfxVoices } from './Voices';
import {
    DEFAULT_FADE,
    type AudioBuses,
    type BusName,
    type LoopOptions,
    type MusicHandle,
    type PlayOptions,
    type SfxHandle
} from './types';

export { type AudioBuses, type BusName, type MusicHandle, type PlayOptions, type SfxHandle } from './types';

export class AudioManager {
    private ctx: AudioContext | null = null;
    private buses: AudioBuses | null = null;
    /** Буферы спек-синтеза (playSpec): компиляция один раз на ключ. */
    private specBuffers = new Map<string, AudioBuffer>();
    private cache = new BufferCache();
    private voices: SfxVoices;
    private loops = new LoopSlots();
    /** Слушатель visibility (снимается в dispose). */
    private onVisibility: (() => void) | null = null;

    /**
     * Шпионский хук: вызывается при каждом фактическом запуске звука —
     * и разового 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, pauseOnHide = true }: { maxVoices?: number; pauseOnHide?: boolean } = {}
    ) {
        this.voices = new SfxVoices(maxVoices);
        this.voices.onEnded = (key) => this.onEnded?.(key);
        // Свернул таб — тишина, вернулся — звук сам (сверху не нужен жест, мы
        // уже разблокированы); движку без DOM (тесты) listener не ставится.
        if (pauseOnHide && typeof document !== 'undefined') {
            this.onVisibility = () => {
                if (document.hidden) {
                    void this.ctx?.suspend();
                } else if (this.ctx?.state === 'suspended') {
                    void this.ctx.resume();
                }
            };
            document.addEventListener('visibilitychange', this.onVisibility);
        }
    }

    /** Расблокировать аудио — вызвать из обработчика пользовательского ввода. */
    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));
        }
    }

    /** Зарегистрировать URL ключа (файл читается при play/preload). */
    register(key: string): void {
        this.cache.register(key, this.resolveUrl(key));
    }

    /**
     * Декодировать заранее (после unlock, например в сцене загрузки),
     * чтобы первый play не тратил время на fetch+decode.
     */
    async preload(key: string): Promise<void> {
        const ctx = await this.ensureContext();
        if (!ctx) return;
        await this.cache.decode(key, ctx);
    }

    /**
     * Проиграть sfx и вернуть хендл играющего голоса (null — не декодировался
     * или контекст не готов; хендл «мёртв» после stop и естественного конца).
     * opts — число (громкость, обратная совместимость) или PlayOptions.
     */
    async play(key: string, opts?: number | PlayOptions): Promise<SfxHandle | null> {
        const ctx = await this.ensureContext();
        if (!ctx || !this.buses) return null;
        const buf = await this.cache.decode(key, ctx);
        if (!buf) return null;
        return this.startSfx(ctx, key, buf, opts);
    }

    /**
     * Проиграть буфер из памяти как sfx (процедурные звуки в рантайме). Ключ —
     * логическое имя голоса (для restart, полифонии и шпионов), не файл.
     */
    async playBuffer(key: string, buffer: AudioBuffer, opts?: number | PlayOptions): Promise<SfxHandle | null> {
        const ctx = await this.ensureContext();
        if (!ctx || !this.buses) return null;
        return this.startSfx(ctx, key, buffer, opts);
    }

    /**
     * Буфер из FLOAT-сэмплов (для процедурных звуков в рантайме): моно
     * (channels 1) или interleaved L,R (channels 2 — стерео-рендеры движка).
     * До unlock контекста нет — null; создать буфер по ключу не получится,
     * вызывать после первого play/unlock.
     */
    createBuffer(data: Float32Array, sampleRate = 22050, channels: 1 | 2 = 1): AudioBuffer | null {
        if (!this.ctx) return null;
        const frames = Math.floor(data.length / channels);
        const buf = this.ctx.createBuffer(channels, frames, sampleRate);
        // DOM-тип требует Float32Array<ArrayBuffer> — сэмплы из генераторов не обязаны.
        if (channels === 1) {
            buf.copyToChannel(data as Float32Array<ArrayBuffer>, 0);
        } else {
            const l = new Float32Array(frames);
            const r = new Float32Array(frames);
            for (let i = 0; i < frames; i++) {
                l[i] = data[i * 2]!;
                r[i] = data[i * 2 + 1]!;
            }
            buf.copyToChannel(l as Float32Array<ArrayBuffer>, 0);
            buf.copyToChannel(r as Float32Array<ArrayBuffer>, 1);
        }
        return buf;
    }

    /**
     * Синтез по спеку (см. SoundSpec): детерминированно компилирует описание
     * звука в буфер (кэш по ключу) и играет как sfx. Для ИИ-агента: описывает
     * звук параметрами, без файлов; факт запуска виден в шпионе onPlayed.
     */
    async playSpec(key: string, spec: SoundSpec, opts?: number | PlayOptions): Promise<SfxHandle | null> {
        const ctx = await this.ensureContext();
        if (!ctx || !this.buses) 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, opts);
    }

    /**
     * Длительность буфера в секундах (после preload/декода); не декодирован —
     * null. Для катсцен и генераторов, синхронизирующихся со звуком.
     */
    duration(key: string): number | null {
        return this.cache.cached(key)?.duration ?? null;
    }

    /**
     * Играть луп в заданной шине; НЕ занимает ни слот музыки, ни слот амбиента —
     * база для локальных амбиент-слоёв (несколько параллельных лупов).
     */
    async playLoop(key: string, opts: LoopOptions = {}): Promise<MusicHandle | null> {
        return this.playLoopFrom(key, opts);
    }

    /**
     * Луп из буфера в памяти (процедурная музыка: renderMusic → буфер → стем
     * на шине). Ключ — логическое имя слота (для шпионов), не файл.
     */
    async playLoopBuffer(key: string, buffer: AudioBuffer, opts: LoopOptions = {}): Promise<MusicHandle | null> {
        const ctx = await this.ensureContext();
        if (!ctx || !this.buses) return null;
        const full = this.loopOpts(opts);
        const slot = this.loops.startSlot(key, ctx, this.buses, buffer, full, this.played());
        return this.loops.makeHandle(slot, full.fade);
    }

    /**
     * Играть музыку с кроссфейдом: текущий трек затухает, новый нарастает
     * за fadeSeconds. Слот один — повторный вызов вытесняет прежний трек.
     */
    async playMusic(key: string, opts: Omit<LoopOptions, 'bus'> = {}): Promise<MusicHandle | null> {
        const ctx = await this.ensureContext();
        if (!ctx || !this.buses) return null;
        const buf = await this.cache.decode(key, ctx);
        if (!buf) return null; // негодный ключ не должен заглушить текущий трек
        const full = this.loopOpts(opts);
        const slot = this.loops.swapMusic(key, ctx, this.buses, buf, full, this.played());
        return this.loops.makeHandle(slot, full.fade);
    }

    /** Остановить музыку (с затуханием). */
    stopMusic(fadeSeconds = 0.5): void {
        this.loops.stopMusic(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 || !this.buses) return null;
        const buf = await this.cache.decode(key, ctx);
        if (!buf) return null; // негодный ключ не должен заглушить текущий амбиент
        const slot = this.loops.swapAmbience(key, ctx, this.buses, buf, { loop: true, fade, volume, bus: 'ambience' }, this.played());
        return this.loops.makeHandle(slot, fade);
    }

    /** Остановить амбиент (с затуханием). */
    stopAmbience(fadeSeconds = 0.5): void {
        this.loops.stopAmbience(fadeSeconds);
    }

    /** Погасить всё и снять слушатели (пересоздание аудио/HMR — без утечек). */
    dispose(): void {
        if (this.onVisibility) {
            document.removeEventListener('visibilitychange', this.onVisibility);
            this.onVisibility = null;
        }
        this.voices.killAll();
        this.loops.stopMusic(0.05);
        this.loops.stopAmbience(0.05);
    }

    /** Общий запуск голоса sfx (нормализация opts-числа — здесь). */
    private startSfx(ctx: AudioContext, key: string, buf: AudioBuffer, opts?: number | PlayOptions): SfxHandle | null {
        const o: PlayOptions = typeof opts === 'number' ? { volume: opts } : (opts ?? {});
        return this.voices.start(ctx, this.buses!, key, buf, o, (k, n) => this.onPlayed?.(k, n));
    }

    /** Нормализованные опции лупа. */
    private loopOpts(opts: LoopOptions): Required<LoopOptions> {
        return { loop: opts.loop ?? true, fade: opts.fade ?? DEFAULT_FADE, volume: opts.volume ?? 1, bus: opts.bus ?? 'music' };
    }

    /** Шпионский колбэк для Loops (startSlot/swap* зовут на каждый запуск). */
    private played(): (key: string, opts: PlayOptions) => void {
        return (key, opts) => this.onPlayed?.(key, opts);
    }

    /** Файловый луп без слота (playLoop): декод + независимый запуск. */
    private async playLoopFrom(key: string, opts: LoopOptions): Promise<MusicHandle | null> {
        const ctx = await this.ensureContext();
        if (!ctx || !this.buses) return null;
        const buf = await this.cache.decode(key, ctx);
        if (!buf) return null;
        const full = this.loopOpts(opts);
        const slot = this.loops.startSlot(key, ctx, this.buses, buf, full, this.played());
        return this.loops.makeHandle(slot, full.fade);
    }

    private async ensureContext(): Promise<AudioContext | null> {
        await this.unlock();
        return this.ctx;
    }
}