Newer
Older
rpg / packages / engine / src / audio / AudioManager.ts
/**
 * 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 {
    /** Относительная громкость в шине (см. bus), 0..1+. */
    volume?: number;
    /** Скорость воспроизведения 0.5..2 (джиттер шагов, вариации тона). */
    rate?: number;
    /** Панорама -1..1; |pan| < 0.01 — узел StereoPanner не создаётся. */
    pan?: number;
    /** Заглушить прежние играющие экземпляры этого же ключа (короткий фейд). */
    restart?: boolean;
    /** Шина голоса (по умолчанию sfx) — например UI-звук в master. */
    bus?: BusName;
}

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

    /** Расблокировать аудио — вызвать из обработчика пользовательского ввода. */
    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-сэмплов (для процедурных звуков в рантайме): моно
     * (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) 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);
        const bus = this.buses![o.bus ?? 'sfx'];
        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(bus);
        } else {
            gain.connect(bus);
        }
        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);
    }

    /**
     * Луп из буфера в памяти (процедурная музыка: renderMusic → буфер → стем
     * на шине). Ключ — логическое имя слота (для шпионов), не файл.
     */
    async playLoopBuffer(
        key: string,
        buffer: AudioBuffer,
        { loop = true, fade = DEFAULT_FADE, volume = 1, bus = 'music' }: LoopOptions = {}
    ): Promise<MusicHandle | null> {
        const ctx = await this.ensureContext();
        if (!ctx) return null;
        const slot = this.startSlot(key, ctx, buffer, { loop, fade, volume, bus });
        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, opts: Required<LoopOptions>): Promise<Slot | null> {
        const ctx = await this.ensureContext();
        if (!ctx) return null;
        const buf = await this.decode(key);
        if (!buf) return null;
        return this.startSlot(key, ctx, buf, opts);
    }

    /** Запуск лупа из готового буфера: источник + линейное нарастание громкости. */
    private startSlot(key: string, ctx: AudioContext, buf: AudioBuffer, { loop, fade, volume, bus }: Required<LoopOptions>): Slot {
        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 };
    }

    /** Затухание слота (экспонента — слуху линейный спад слышен ступенькой). */
    private fadeOut(slot: Slot, fadeSeconds: number): void {
        if (!this.ctx) return;
        const t = this.ctx.currentTime;
        slot.gain.gain.cancelScheduledValues(t);
        // Экспоненциальный ramp не определён от нуля — поднимаем до минимума.
        const from = Math.max(slot.gain.gain.value, MIN_FADE_VOLUME);
        slot.gain.gain.setValueAtTime(from, t);
        slot.gain.gain.exponentialRampToValueAtTime(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;
    }
}