Newer
Older
rpg / packages / engine / src / audio / Loops.ts
/**
 * Лупы (музыка/амбиент/локальные слои): запуск из буфера, кроссфейд, слоты
 * музыки и амбиента (взаимно не вытесняют). Выбор трека — AudioManager.
 */
import { MIN_FADE_VOLUME, type AudioBuses, type LoopOptions, type MusicHandle, type PlayOptions } from './types';

/** Запущенный луп: контекст + источник + огибающая громкости. */
export interface Slot {
    ctx: AudioContext;
    source: AudioBufferSourceNode;
    gain: GainNode;
}

export class LoopSlots {
    private currentMusic: Slot | null = null;
    private currentAmbience: Slot | null = null;

    /**
     * Запуск лупа из готового буфера: источник + линейное нарастание громкости.
     * Не занимает ни слот музыки, ни слот амбиента — база локальных слоёв.
     */
    startSlot(
        key: string,
        ctx: AudioContext,
        buses: AudioBuses,
        buf: AudioBuffer,
        { loop, fade, volume, bus }: Required<LoopOptions>,
        onPlayed: (key: string, opts: PlayOptions) => void
    ): 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(buses[bus]);
        source.start();
        onPlayed(key, { volume, rate: 1, pan: 0 });
        return { ctx, source, gain };
    }

    /** Handle над слотом: затухание + мгновенная громкость (без борьбы с ramp). */
    makeHandle(slot: Slot, fade: number): MusicHandle {
        return {
            stop: (fadeSeconds = fade) => this.fadeOut(slot, fadeSeconds),
            setVolume: (volume: number) => {
                const t = slot.ctx.currentTime;
                slot.gain.gain.cancelScheduledValues(t);
                slot.gain.gain.setValueAtTime(Math.max(0, volume), t);
            }
        };
    }

    /**
     * Слот музыки: новый трек вытесняет прежний (кроссфейд). Негодный ключ не
     * должен заглушить текущий трек — проверка decode до вызова.
     */
    swapMusic(
        key: string,
        ctx: AudioContext,
        buses: AudioBuses,
        buf: AudioBuffer,
        opts: Required<LoopOptions>,
        onPlayed: (key: string, opts: PlayOptions) => void
    ): Slot {
        this.stopMusic(opts.fade);
        const slot = this.startSlot(key, ctx, buses, buf, opts, onPlayed);
        this.currentMusic = slot;
        return slot;
    }

    /** Слот амбиента — отдельный от музыки: playAmbience и playMusic не конфликтуют. */
    swapAmbience(
        key: string,
        ctx: AudioContext,
        buses: AudioBuses,
        buf: AudioBuffer,
        opts: Required<LoopOptions>,
        onPlayed: (key: string, opts: PlayOptions) => void
    ): Slot {
        this.stopAmbience(opts.fade);
        const slot = this.startSlot(key, ctx, buses, buf, opts, onPlayed);
        this.currentAmbience = slot;
        return slot;
    }

    /** Остановить музыку (с затуханием). */
    stopMusic(fadeSeconds: number): void {
        const current = this.currentMusic;
        if (!current) return;
        this.currentMusic = null;
        this.fadeOut(current, fadeSeconds);
    }

    /** Остановить амбиент (с затуханием). */
    stopAmbience(fadeSeconds: number): void {
        const current = this.currentAmbience;
        if (!current) return;
        this.currentAmbience = null;
        this.fadeOut(current, fadeSeconds);
    }

    /** Затухание слота (экспонента — слуху линейный спад слышен ступенькой). */
    fadeOut(slot: Slot, fadeSeconds: number): void {
        const t = slot.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);
    }
}