diff --git a/docs/engine/README.md b/docs/engine/README.md index 2aea41f..140930b 100644 --- a/docs/engine/README.md +++ b/docs/engine/README.md @@ -72,7 +72,7 @@ cutscene/ CutsceneRunner (кат-сцены из шагов, view-агностик) ui/ DialogueBox, Panel, Button, MenuList, PixelText anim/ FrameAnimation - audio/ AudioManager (шины master/music/sfx, кроссфейд) + audio/ AudioManager (шины master/music/sfx/ambience, лупы, pan/rate) assets/ AssetLoader (текстуры, атласы) save/ SaveManager (JSON-слоты) agent/ EngineAgent, SceneAgent, инварианты (агентный мост) diff --git a/docs/engine/assets-audio-save.md b/docs/engine/assets-audio-save.md index 6f4ca6d..d3e1b10 100644 --- a/docs/engine/assets-audio-save.md +++ b/docs/engine/assets-audio-save.md @@ -56,23 +56,47 @@ ## AudioManager -Три шины (master/music/sfx), кроссфейд музыки, кэш сэмплов: +Четыре шины (master/music/sfx/ambience), кроссфейд лупов, опции sfx +(громкость/скорость/панорама), кэш сэмплов. Фабрика AudioContext инъецируется +(в тестах — фейк), как `StorageLike` у `SaveManager`: ```ts import { AudioManager } from '@rpg/engine'; -const audio = new AudioManager((key) => `audio/${key}.mp3`); +const audio = new AudioManager((key) => `audio/${key}.wav`); // Расблокировать из обработчика пользовательского ввода (требование браузеров): uiButton.onSelect = () => void audio.unlock(); await audio.load('bell'); -await audio.play('bell', 0.8); // sfx -await audio.playMusic('meadows_theme', { fade: 2 }); // зациклится с нарастанием 2 сек -audio.stopMusic(1); // затухание 1 сек -audio.setBusVolume('music', 0.6); // громкость шины +await audio.play('bell', 0.8); // sfx, громкость 0.8 +await audio.play('bell', { volume: 0.8, rate: 1.1, pan: -0.4 }); // скорость + панорама +await audio.playMusic('meadows_theme', { fade: 2 }); // зациклится с нарастанием 2 сек +audio.stopMusic(1); // затухание 1 сек +await audio.playAmbience('meadows', { fade: 3 }); // слот амбиента — отдельно от музыки +audio.setBusVolume('music', 0.6); // громкость шины ``` +Опции `play` (`PlayOptions`): `volume` (по умолчанию 1), `rate` (0.5..2 — +джиттер шагов, вариации тона), `pan` (-1..1; при |pan| < 0.01 узел +StereoPanner не создаётся). Число вместо объекта — синтаксический сахар для +`{ volume }` (старые вызовы не надо мигрировать). + +Слоты лупов: `playMusic` и `playAmbience` — независимые слоты с кроссфейдом +(повторный вызов вытесняет прежний трек своего слота; негодный ключ не глушит +текущий). `playLoop` — луп **без слота** (занимать нечего, сколько вызвали — +столько играет, `MusicHandle.stop` останавливает конкретный): база для +локальных амбиент-слоёв (вода, гул) — игра поверх амбиента области: + +```ts +const layer = await audio.playLoop('ambience/ponds_water', { bus: 'ambience', fade: 2 }); +// ... +layer?.stop(2); +``` + +Шпионский хук `onPlayed?: (key, opts) => void` вызывается при каждом +фактическом запуске sfx — для тестов и DEV-лога игры. + Связка с Settings (громкости применяются при изменении): ```ts @@ -80,11 +104,13 @@ audio.setBusVolume('master', s.master); audio.setBusVolume('music', s.music); audio.setBusVolume('sfx', s.sfx); + audio.setBusVolume('ambience', s.ambience); }); ``` -`play`/`playMusic` не падают, если звук не загружен или AudioContext не разблокирован — -тихо ничего не делают. +`play`/`playMusic`/`playAmbience` не падают, если звук не загружен или +AudioContext не разблокирован — тихо ничего не делают. До `unlock` шин ещё +нет — `setBusVolume` игнорируется, громкости применяются после него. ## SaveManager diff --git a/packages/engine/src/audio/AudioManager.ts b/packages/engine/src/audio/AudioManager.ts index 868d7b0..27db6bb 100644 --- a/packages/engine/src/audio/AudioManager.ts +++ b/packages/engine/src/audio/AudioManager.ts @@ -1,6 +1,7 @@ /** - * WebAudio с тремя шинами (master/music/sfx): громкости по шинам, - * кроссфейд музыки, кэш сэмплов. Игры подключают Settings.onChange + * WebAudio с четырьмя шинами (master/music/sfx/ambience): громкости по шинам, + * кроссфейд лупов (музыка и амбиент — независимые слоты), опции sfx + * (громкость/скорость/панорама), кэш сэмплов. Игры подключают Settings.onChange * и вызывают setBusVolume. */ @@ -8,40 +9,80 @@ master: GainNode; music: GainNode; sfx: GainNode; + ambience: GainNode; } +export type BusName = 'master' | 'music' | 'sfx' | 'ambience'; + export interface MusicHandle { stop(fadeSeconds?: number): void; } +/** Опции разового sfx. */ +export interface PlayOptions { + /** Относительная громкость в шине sfx (0..1+). */ + volume?: number; + /** Скорость воспроизведения 0.5..2 (джиттер шагов, вариации тона). */ + rate?: number; + /** Панорама -1..1; |pan| < 0.01 — узел StereoPanner не создаётся. */ + pan?: number; +} + +interface LoopOptions { + loop?: boolean; + fade?: number; + volume?: number; + bus?: BusName; +} + +/** Запущенный луп: источник + его огибающая громкости. */ +interface Slot { + source: AudioBufferSourceNode; + gain: GainNode; +} + +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(); private urls = new Map(); private buses: AudioBuses | null = null; - private currentMusic: { source: AudioBufferSourceNode; gain: GainNode } | null = null; + private currentMusic: Slot | null = null; + private currentAmbience: Slot | null = null; - constructor(private resolveUrl: (key: string) => string) {} + /** Шпионский хук: вызывается при каждом фактическом запуске sfx (тесты, DEV-мост игры). */ + onPlayed?: (key: string, opts: PlayOptions) => void; + + constructor( + private resolveUrl: (key: string) => string, + private ctxFactory: () => AudioContext = () => new AudioContext() + ) {} /** Расблокировать аудио — вызвать из обработчика пользовательского ввода. */ async unlock(): Promise { if (!this.ctx) { - this.ctx = new AudioContext(); + 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 }; + this.buses = { master, music, sfx, ambience }; } if (this.ctx.state === 'suspended') { await this.ctx.resume(); } } - /** Громкость шины 0..1 (без unlock просто запоминается на будущее). */ - setBusVolume(bus: 'master' | 'music' | 'sfx', volume: number): void { + /** Громкость шины 0..1 (до unlock шин ещё нет — вызов игнорируется). */ + setBusVolume(bus: BusName, volume: number): void { if (this.buses) { this.buses[bus].gain.value = Math.max(0, Math.min(1, volume)); } @@ -63,63 +104,121 @@ /** * Проиграть sfx (если звук не загружен — тихо ничего не делает). - * volume — относительная громкость в шине sfx. + * opts — число (громкость, обратная совместимость) или PlayOptions. */ - async play(key: string, volume = 1): Promise { + async play(key: string, opts?: number | PlayOptions): Promise { + const o: PlayOptions = typeof opts === 'number' ? { volume: opts } : (opts ?? {}); const ctx = await this.ensureContext(); if (!ctx) return; const buf = await this.decode(key); if (!buf) return; + const volume = o.volume ?? 1; + const rate = clamp(o.rate ?? 1, 0.5, 2); + const pan = clamp(o.pan ?? 0, -1, 1); + const source = ctx.createBufferSource(); source.buffer = buf; + source.playbackRate.value = rate; const gain = ctx.createGain(); gain.gain.value = volume; - source.connect(gain).connect(this.buses!.sfx); + source.connect(gain); + if (Math.abs(pan) >= 0.01 && typeof ctx.createStereoPanner === 'function') { + const panner = ctx.createStereoPanner(); + panner.pan.value = pan; + gain.connect(panner).connect(this.buses!.sfx); + } else { + gain.connect(this.buses!.sfx); + } source.start(); + this.onPlayed?.(key, { volume, rate, pan }); } /** - * Играть музыку в цикле с кроссфейдом: текущая трек затухает, - * новый нарастает за fadeSeconds. + * Играть луп в заданной шине; НЕ занимает ни слот музыки, ни слот амбиента — + * база для локальных амбиент-слоёв (несколько параллельных лупов). */ - async playMusic(key: string, { loop = true, fade = 1, volume = 1 }: { loop?: boolean; fade?: number; volume?: number } = {}): Promise { + async playLoop(key: string, { loop = true, fade = DEFAULT_FADE, volume = 1, bus = 'music' }: LoopOptions = {}): Promise { + const slot = await this.startLoop(key, { loop, fade, volume, bus }); + if (!slot) return null; + return { stop: (fadeSeconds = fade) => this.fadeOut(slot, fadeSeconds) }; + } + + /** + * Играть музыку с кроссфейдом: текущий трек затухает, новый нарастает + * за fadeSeconds. Слот один — повторный вызов вытесняет прежний трек. + */ + async playMusic(key: string, { loop = true, fade = DEFAULT_FADE, volume = 1 }: Omit = {}): Promise { const ctx = await this.ensureContext(); if (!ctx) return null; - const buf = await this.decode(key); - if (!buf) return null; - - // Заглушить текущий трек + // Сначала проверяем ключ: негодный не должен заглушить текущий трек. + if (!(await this.decode(key))) return null; this.stopMusic(fade); - - const source = ctx.createBufferSource(); - source.buffer = buf; - source.loop = loop; - const gain = ctx.createGain(); - gain.gain.setValueAtTime(0.0001, ctx.currentTime); - gain.gain.linearRampToValueAtTime(volume, ctx.currentTime + fade); - source.connect(gain).connect(this.buses!.music); - source.start(); - - this.currentMusic = { source, gain }; - return { - stop: (fadeSeconds = fade) => this.stopMusic(fadeSeconds) - }; + const slot = await this.startLoop(key, { loop, fade, volume, bus: 'music' }); + if (!slot) return null; + this.currentMusic = slot; + return { stop: (fadeSeconds = fade) => this.stopMusic(fadeSeconds) }; } /** Остановить музыку (с затуханием). */ stopMusic(fadeSeconds = 0.5): void { const current = this.currentMusic; - if (!current || !this.ctx) return; + if (!current) return; this.currentMusic = null; - const { source, gain } = current; - const t = this.ctx!.currentTime; - gain.gain.cancelScheduledValues(t); - gain.gain.setValueAtTime(gain.gain.value, t); - gain.gain.linearRampToValueAtTime(0.0001, t + fadeSeconds); - window.setTimeout(() => { + this.fadeOut(current, fadeSeconds); + } + + /** + * Играть амбиент с кроссфейдом — слот отдельный от музыки: + * playAmbience и playMusic не вытесняют друг друга. + */ + async playAmbience(key: string, { fade = DEFAULT_FADE, volume = 1 }: { fade?: number; volume?: number } = {}): Promise { + 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 { stop: (fadeSeconds = fade) => this.stopAmbience(fadeSeconds) }; + } + + /** Остановить амбиент (с затуханием). */ + stopAmbience(fadeSeconds = 0.5): void { + const current = this.currentAmbience; + if (!current) return; + this.currentAmbience = null; + this.fadeOut(current, fadeSeconds); + } + + /** Общий запуск лупа (источник + линейное нарастание громкости). */ + private async startLoop(key: string, { loop, fade, volume, bus }: Required): Promise { + const ctx = await this.ensureContext(); + if (!ctx) return null; + const buf = await this.decode(key); + if (!buf) return null; + + 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(); + return { source, gain }; + } + + /** Затухание слота и остановка источника после fadeSeconds. */ + private fadeOut(slot: Slot, fadeSeconds: number): void { + if (!this.ctx) return; + const t = this.ctx.currentTime; + slot.gain.gain.cancelScheduledValues(t); + slot.gain.gain.setValueAtTime(slot.gain.gain.value, t); + slot.gain.gain.linearRampToValueAtTime(MIN_FADE_VOLUME, t + fadeSeconds); + setTimeout(() => { try { - source.stop(); + slot.source.stop(); } catch { // уже остановлен } diff --git a/packages/engine/src/audio/__tests__/AudioManager.test.ts b/packages/engine/src/audio/__tests__/AudioManager.test.ts new file mode 100644 index 0000000..dbcb2f3 --- /dev/null +++ b/packages/engine/src/audio/__tests__/AudioManager.test.ts @@ -0,0 +1,232 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { AudioManager, type PlayOptions } from '../AudioManager'; + +/** Параметр WebAudio: значение + планировщик огибающей. */ +function makeParam(value = 1) { + return { + value, + setValueAtTime: vi.fn(), + linearRampToValueAtTime: vi.fn(), + cancelScheduledValues: vi.fn() + }; +} + +/** Минимальный граф WebAudio: считает узлы, запоминает соединения. */ +function makeCtx() { + const graph = { + gains: [] as { gain: ReturnType; dests: unknown[] }[], + sources: [] as { buffer: unknown; loop: boolean; playbackRate: ReturnType; start: ReturnType; stop: ReturnType; dests: unknown[] }[], + panners: [] as { pan: ReturnType; dests: unknown[] }[], + destination: { destination: true } + }; + const connect = (dests: unknown[]) => (dest: unknown) => { + dests.push(dest); + return dest; + }; + const ctx = { + currentTime: 0, + state: 'running', + destination: graph.destination, + resume: vi.fn(async () => undefined), + createGain: () => { + const node: { gain: ReturnType; dests: unknown[]; connect: (d: unknown) => unknown } = { + gain: makeParam(1), + dests: [], + connect: () => undefined + }; + node.connect = connect(node.dests); + graph.gains.push(node); + return node; + }, + createBufferSource: () => { + const node: { buffer: unknown; loop: boolean; playbackRate: ReturnType; start: ReturnType; stop: ReturnType; dests: unknown[]; connect: (d: unknown) => unknown } = { + buffer: null, + loop: false, + playbackRate: makeParam(1), + start: vi.fn(), + stop: vi.fn(), + dests: [], + connect: () => undefined + }; + node.connect = connect(node.dests); + graph.sources.push(node); + return node; + }, + createStereoPanner: () => { + const node: { pan: ReturnType; dests: unknown[]; connect: (d: unknown) => unknown } = { + pan: makeParam(0), + dests: [], + connect: () => undefined + }; + node.connect = connect(node.dests); + graph.panners.push(node); + return node; + }, + decodeAudioData: vi.fn(async () => ({ decoded: true })) + }; + return { ctx, graph }; +} + +function makeManager() { + const { ctx, graph } = makeCtx(); + const audio = new AudioManager((key) => `url:${key}`, () => ctx as unknown as AudioContext); + return { audio, graph }; +} + +describe('AudioManager', () => { + beforeEach(() => { + vi.stubGlobal('fetch', vi.fn(async () => ({ arrayBuffer: async () => new ArrayBuffer(8) }))); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + it('unlock строит 4 шины: music/sfx/ambience ведут в master, master — в выход', async () => { + const { audio, graph } = makeManager(); + await audio.unlock(); + + expect(graph.gains).toHaveLength(4); + const [master, music, sfx, ambience] = graph.gains; + expect(master!.dests).toContain(graph.destination); + for (const bus of [music, sfx, ambience]) { + expect(bus!.dests).toContain(master); + } + }); + + it('setBusVolume: до unlock — без броска, после unlock — меняет громкость шины', async () => { + const { audio, graph } = makeManager(); + expect(() => audio.setBusVolume('sfx', 0.3)).not.toThrow(); + expect(graph.gains).toHaveLength(0); + + await audio.unlock(); + audio.setBusVolume('sfx', 0.3); + audio.setBusVolume('ambience', 0.4); + expect(graph.gains[2]!.gain.value).toBe(0.3); + expect(graph.gains[3]!.gain.value).toBe(0.4); + }); + + it('play(key, 0.5) ≡ play(key, {volume: 0.5}) — источник в sfx через gain', async () => { + const { audio, graph } = makeManager(); + await audio.unlock(); + await audio.load('bell'); + + await audio.play('bell', 0.5); + await audio.play('bell', { volume: 0.5 }); + + const sfxBus = graph.gains[2]!; + for (const src of graph.sources) { + const gainNode = src.dests[0] as { gain: ReturnType; dests: unknown[] }; + expect(gainNode.gain.value).toBe(0.5); + expect(gainNode.dests).toContain(sfxBus); + } + }); + + it('rate и pan доезжают до узлов; pan ≈ 0 — StereoPanner не создаётся', async () => { + const { audio, graph } = makeManager(); + await audio.unlock(); + await audio.load('bell'); + + await audio.play('bell', { rate: 1.5, pan: 0.7 }); + expect(graph.sources[0]!.playbackRate.value).toBe(1.5); + expect(graph.panners).toHaveLength(1); + expect(graph.panners[0]!.pan.value).toBe(0.7); + + await audio.play('bell'); + expect(graph.panners).toHaveLength(1); // второй sfx без панорамы + expect(graph.sources[1]!.playbackRate.value).toBe(1); + }); + + it('незагруженный ключ — тихо ничего, onPlayed не зовётся', async () => { + const { audio } = makeManager(); + const played: { key: string; opts: PlayOptions }[] = []; + audio.onPlayed = (key, opts) => played.push({ key, opts }); + + await expect(audio.play('ghost')).resolves.toBeUndefined(); + expect(played).toHaveLength(0); + }); + + it('onPlayed зовётся после фактического запуска с нормализованными опциями', async () => { + const { audio, graph } = makeManager(); + const played: { key: string; opts: PlayOptions }[] = []; + audio.onPlayed = (key, opts) => played.push({ key, opts }); + await audio.unlock(); + await audio.load('bell'); + + await audio.play('bell', { volume: 0.6, rate: 1.2, pan: -0.4 }); + await audio.play('bell', 0.5); + + expect(played).toEqual([ + { key: 'bell', opts: { volume: 0.6, rate: 1.2, pan: -0.4 } }, + { key: 'bell', opts: { volume: 0.5, rate: 1, pan: 0 } } + ]); + expect(graph.sources).toHaveLength(2); + }); + + it('playMusic и playAmbience независимы: остановка музыки не глушит амбиент', async () => { + const { audio, graph } = makeManager(); + await audio.unlock(); + await audio.load('m'); + await audio.load('a'); + + const music = await audio.playMusic('m', { fade: 0 }); + const amb = await audio.playAmbience('a', { fade: 0 }); + expect(music).not.toBeNull(); + expect(amb).not.toBeNull(); + expect(graph.sources).toHaveLength(2); + + music!.stop(0); + vi.advanceTimersByTime(100); + expect(graph.sources[0]!.stop).toHaveBeenCalled(); + expect(graph.sources[1]!.stop).not.toHaveBeenCalled(); + }); + + it('playAmbience дважды — прежний слот остановлен', async () => { + const { audio, graph } = makeManager(); + await audio.unlock(); + await audio.load('a'); + + const first = await audio.playAmbience('a', { fade: 0 }); + await audio.playAmbience('a', { fade: 0 }); + vi.advanceTimersByTime(100); + + expect(first).not.toBeNull(); + expect(graph.sources[0]!.stop).toHaveBeenCalled(); + expect(graph.sources[1]!.stop).not.toHaveBeenCalled(); // новый играет + }); + + it('playMusic с негодным ключом не глушит текущий трек', async () => { + const { audio, graph } = makeManager(); + await audio.unlock(); + await audio.load('m'); + + await audio.playMusic('m', { fade: 0 }); + const bad = await audio.playMusic('ghost', { fade: 0 }); + expect(bad).toBeNull(); + expect(graph.sources[0]!.stop).not.toHaveBeenCalled(); + }); + + it('playLoop играет в заданную шину и не занимает слот музыки', async () => { + const { audio, graph } = makeManager(); + await audio.unlock(); + await audio.load('m'); + + const layer = await audio.playLoop('m', { bus: 'sfx', fade: 0 }); + expect(layer).not.toBeNull(); + const sfxBus = graph.gains[2]!; + expect(sfxBus.dests).not.toContain(graph.destination); + // gain лупа ведёт в sfx + const gainNode = graph.sources[0]!.dests[0] as { dests: unknown[] }; + expect(gainNode.dests).toContain(sfxBus); + + // музыка стартует отдельно, луп не заглушается + await audio.playMusic('m', { fade: 0 }); + expect(graph.sources).toHaveLength(2); + audio.stopMusic(0); + vi.advanceTimersByTime(100); + expect(graph.sources[0]!.stop).not.toHaveBeenCalled(); + expect(graph.sources[1]!.stop).toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/packages/engine/src/core/Settings.ts b/packages/engine/src/core/Settings.ts index 8166ead..d9344ec 100644 --- a/packages/engine/src/core/Settings.ts +++ b/packages/engine/src/core/Settings.ts @@ -10,6 +10,8 @@ master: number; music: number; sfx: number; + /** Громкость амбиента мира (фоновые лупы областей и локальные слои). */ + ambience: number; /** Язык локализации (BCP-47, например 'ru'). */ lang: string; /** Прочие настройки игры (произвольные пары). */ @@ -20,6 +22,7 @@ master: 1, music: 1, sfx: 1, + ambience: 1, lang: 'ru', extra: {} }; diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 93e6619..2334061 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -169,7 +169,7 @@ export { MenuList, type MenuListOptions } from './ui/MenuList'; // audio -export { AudioManager, type AudioBuses } from './audio/AudioManager'; +export { AudioManager, type AudioBuses, type BusName, type MusicHandle, type PlayOptions } from './audio/AudioManager'; // assets export { AssetLoader } from './assets/AssetLoader';