diff --git a/docs/engine/assets-audio-save.md b/docs/engine/assets-audio-save.md index 065f3a0..0eb2fc7 100644 --- a/docs/engine/assets-audio-save.md +++ b/docs/engine/assets-audio-save.md @@ -84,8 +84,10 @@ Опции `play` (`PlayOptions`): `volume` (по умолчанию 1), `rate` (0.5..2 — джиттер шагов, вариации тона), `pan` (-1..1; при |pan| < 0.01 узел StereoPanner не создаётся), `restart` (заглушить прежние играющие экземпляры -того же ключа коротким фейдом). Число вместо объекта — синтаксический сахар -для `{ volume }` (старые вызовы не надо мигрировать). +того же ключа коротким фейдом), `bus` (шина голоса, по умолчанию sfx — +например UI-звук можно направить в master, минуя гейны sfx). Число вместо +объекта — синтаксический сахар для `{ volume }` (старые вызовы не надо +мигрировать). `play` возвращает `SfxHandle | null` (null — ключ не декодировался, контекст не готов). Хендл умеет `stop(fadeSeconds?)`, `setVolume`, `setPan`, `setRate`; @@ -93,6 +95,15 @@ Полифония ограничена `maxVoices` (третий аргумент конструктора, по умолчанию 24, 0 — без лимита): при переполнении воруется самый тихий играющий голос. +Затухания лупов (`stopMusic`, `stopAmbience`, `MusicHandle.stop`, вор и +кроссфейд) — экспоненциальные: слуху линейный спад слышен «ступенькой». +Нарастание при старте лупа остаётся линейным (экспонента не определена от +нуля). + +Таб ушёл в фон — звук сам замолкает (`ctx.suspend`), вернулся — сам +возобновляется; опция конструктора `pauseOnHide` (по умолчанию true) +это отключает. В средах без DOM (тесты движка) listener не ставится. + `playBuffer(key, buffer, opts?)` — тот же sfx, но из **буфера в памяти** (процедурные звуки, синтезированные в рантайме): ключ — логическое имя голоса (дедуп, restart, полифония, шпион `onPlayed`), не файл. Пара к нему — diff --git a/docs/plan.md b/docs/plan.md index 6457e6f..b5c2541 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -334,10 +334,10 @@ ### Работа F — инфраструктура аудио (хвосты D) -- mute/suspend при `visibilitychange` (свернул таб — тишина). -- `bus` в `PlayOptions` разового `play` (сейчас разовый — только шина sfx). -- exponential-фейд вместо линейного на затуханиях (на громких слышна - «ступенька»). +✅ **Сделана (2026-09-07)**: mute/suspend при `visibilitychange` (опция +`pauseOnHide`, по умолчанию включена); `bus` в `PlayOptions` разового `play` +(по умолчанию sfx); exponential-фейд на затуханиях (нарастание — линейное: +экспонента не определена от нуля). ### Работа G — музыкальный слой diff --git a/packages/engine/src/audio/AudioManager.ts b/packages/engine/src/audio/AudioManager.ts index bb0c7ff..f4a08d4 100644 --- a/packages/engine/src/audio/AudioManager.ts +++ b/packages/engine/src/audio/AudioManager.ts @@ -31,7 +31,7 @@ /** Опции разового sfx. */ export interface PlayOptions { - /** Относительная громкость в шине sfx (0..1+). */ + /** Относительная громкость в шине (см. bus), 0..1+. */ volume?: number; /** Скорость воспроизведения 0.5..2 (джиттер шагов, вариации тона). */ rate?: number; @@ -39,6 +39,8 @@ pan?: number; /** Заглушить прежние играющие экземпляры этого же ключа (короткий фейд). */ restart?: boolean; + /** Шина голоса (по умолчанию sfx) — например UI-звук в master. */ + bus?: BusName; } interface LoopOptions { @@ -96,9 +98,20 @@ constructor( private resolveUrl: (key: string) => string, private ctxFactory: () => AudioContext = () => new AudioContext(), - { maxVoices = 24 }: { maxVoices?: number } = {} + { 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(); + } + }); + } } /** Расблокировать аудио — вызвать из обработчика пользовательского ввода. */ @@ -211,13 +224,14 @@ 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(this.buses!.sfx); + gain.connect(panner).connect(bus); } else { - gain.connect(this.buses!.sfx); + gain.connect(bus); } source.start(); const voice: SfxVoice = { key, source, gain, panner, killed: false }; @@ -392,13 +406,15 @@ 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); + // Экспоненциальный 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(); diff --git a/packages/engine/src/audio/__tests__/AudioManager.test.ts b/packages/engine/src/audio/__tests__/AudioManager.test.ts index 886e481..0357aec 100644 --- a/packages/engine/src/audio/__tests__/AudioManager.test.ts +++ b/packages/engine/src/audio/__tests__/AudioManager.test.ts @@ -11,6 +11,9 @@ linearRampToValueAtTime: vi.fn((v: number) => { p.value = v; }), + exponentialRampToValueAtTime: vi.fn((v: number) => { + p.value = v; + }), cancelScheduledValues: vi.fn() }; return p; @@ -33,6 +36,9 @@ state: 'running', destination: graph.destination, resume: vi.fn(async () => undefined), + suspend: vi.fn(async () => { + ctx.state = 'suspended'; + }), createGain: () => { const node: { gain: ReturnType; @@ -90,10 +96,19 @@ return { ctx, graph }; } -function makeManager(opts: { maxVoices?: number } = {}) { +function makeManager(opts: { maxVoices?: number; pauseOnHide?: boolean } = {}) { const { ctx, graph } = makeCtx(); const audio = new AudioManager((key) => `url:${key}`, () => ctx as unknown as AudioContext, opts); - return { audio, graph, ctx: ctx as unknown as { createBuffer: ReturnType } }; + return { + audio, + graph, + ctx: ctx as unknown as { + createBuffer: ReturnType; + suspend: ReturnType; + resume: ReturnType; + state: string; + } + }; } describe('AudioManager', () => { @@ -472,4 +487,77 @@ expect(graph.sources[0]!.stop).not.toHaveBeenCalled(); expect(graph.sources[1]!.stop).toHaveBeenCalled(); }); + + it('bus в PlayOptions: разовый голос уходит в заданную шину', async () => { + const { audio, graph } = makeManager(); + await audio.unlock(); + await audio.load('bell'); + + // graph.gains: master, music, sfx, ambience + await audio.play('bell', { volume: 0.5, bus: 'music' }); + const gainNode = graph.sources[0]!.dests[0] as { dests: unknown[] }; + expect(gainNode.dests).toContain(graph.gains[1]); // шина music + expect(gainNode.dests).not.toContain(graph.gains[2]); + + await audio.play('bell'); // без bus — по-прежнему sfx + const plain = graph.sources[1]!.dests[0] as { dests: unknown[] }; + expect(plain.dests).toContain(graph.gains[2]); + }); + + it('fadeOut: экспоненциальный спад от нуля поднимается до минимума', async () => { + const { audio, graph } = makeManager(); + await audio.unlock(); + await audio.load('m'); + + const layer = await audio.playLoop('m', { fade: 0, volume: 0 }); + layer!.stop(0.5); + const slotGain = graph.sources[0]!.dests[0] as { gain: ReturnType }; + // from = max(0, MIN) → setValueAtTime(MIN), затем exponential- ramp + expect(slotGain.gain.setValueAtTime).toHaveBeenCalledWith(0.0001, 0); + expect(slotGain.gain.exponentialRampToValueAtTime).toHaveBeenCalledWith(0.0001, 0.5); + }); + + it('visibilitychange: скрыли таб — suspend, вернули — resume', async () => { + const listeners: Record void)[]> = {}; + const doc = { + hidden: false, + addEventListener: (type: string, fn: () => void) => { + (listeners[type] ??= []).push(fn); + } + }; + vi.stubGlobal('document', doc); + try { + const { audio, ctx } = makeManager(); + await audio.unlock(); + expect(ctx.suspend).not.toHaveBeenCalled(); + + doc.hidden = true; + listeners.visibilitychange![0]!(); + expect(ctx.suspend).toHaveBeenCalledTimes(1); + + doc.hidden = false; + ctx.state = 'suspended'; + listeners.visibilitychange![0]!(); + expect(ctx.resume).toHaveBeenCalledTimes(1); + } finally { + vi.unstubAllGlobals(); + } + }); + + it('pauseOnHide: false — visibilitychange игнорируется', async () => { + const listeners: Record void)[]> = {}; + vi.stubGlobal('document', { + hidden: true, + addEventListener: (type: string, fn: () => void) => { + (listeners[type] ??= []).push(fn); + } + }); + try { + const { ctx } = makeManager({ pauseOnHide: false }); + expect(listeners.visibilitychange).toBeUndefined(); // listener не ставился + expect(ctx.suspend).not.toHaveBeenCalled(); + } finally { + vi.unstubAllGlobals(); + } + }); }); \ No newline at end of file