/**
* Лупы (музыка/амбиент/локальные слои): запуск из буфера, кроссфейд, слоты
* музыки и амбиента (взаимно не вытесняют). Выбор трека — 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);
}
}