diff --git a/docs/engine/assets-audio-save.md b/docs/engine/assets-audio-save.md index 3a64f58..2169449 100644 --- a/docs/engine/assets-audio-save.md +++ b/docs/engine/assets-audio-save.md @@ -70,7 +70,11 @@ await audio.load('bell'); await audio.play('bell', 0.8); // sfx, громкость 0.8 -await audio.play('bell', { volume: 0.8, rate: 1.1, pan: -0.4 }); // скорость + панорама +const h = await audio.play('bell', { volume: 0.8, rate: 1.1, pan: -0.4 }); // хендл голоса +h?.setVolume(0.5); // параметры в полёте +h?.setRate(1.3); +h?.setPan(0.6); // panner создастся, если его не было +h?.stop(0.05); // короткое затухание await audio.playMusic('meadows_theme', { fade: 2 }); // зациклится с нарастанием 2 сек audio.stopMusic(1); // затухание 1 сек await audio.playAmbience('meadows', { fade: 3 }); // слот амбиента — отдельно от музыки @@ -79,8 +83,15 @@ Опции `play` (`PlayOptions`): `volume` (по умолчанию 1), `rate` (0.5..2 — джиттер шагов, вариации тона), `pan` (-1..1; при |pan| < 0.01 узел -StereoPanner не создаётся). Число вместо объекта — синтаксический сахар для -`{ volume }` (старые вызовы не надо мигрировать). +StereoPanner не создаётся), `restart` (заглушить прежние играющие экземпляры +того же ключа коротким фейдом). Число вместо объекта — синтаксический сахар +для `{ volume }` (старые вызовы не надо мигрировать). + +`play` возвращает `SfxHandle | null` (null — ключ не декодировался, контекст +не готов). Хендл умеет `stop(fadeSeconds?)`, `setVolume`, `setPan`, `setRate`; +после `stop` и естественного конца звука хендл «мёртв» — все методы no-op. +Полифония ограничена `maxVoices` (третий аргумент конструктора, по умолчанию +24, 0 — без лимита): при переполнении воруется самый тихий играющий голос. Слоты лупов: `playMusic` и `playAmbience` — независимые слоты с кроссфейдом (повторный вызов вытесняет прежний трек своего слота; негодный ключ не глушит diff --git a/docs/engine/practices.md b/docs/engine/practices.md index 762a12d..ef28b5b 100644 --- a/docs/engine/practices.md +++ b/docs/engine/practices.md @@ -176,12 +176,17 @@ / `AMBIENCE_LAYER_KEYS`): BootScene префетчит оттуда, validate-тест ловит «ключ в контенте — файла нет» и «файл есть — ключа нет». 3. Как играть: разовый sfx — `audio.play(key, {volume, rate, pan})` (число = - громкость); позиционный — `worldAudio.playAt(key, pos, base, radius)` - (затухание + панорама от героя, тишина за радиусом); шаги — + громкость; вернёт `SfxHandle` — параметры в полёте и stop; `restart: true` + для «одиночного» звука вместо наслоения экземпляров); позиционный — + `worldAudio.playAt(key, pos, base, radius)` (затухание + панорама от + героя, тишина за радиусом); шаги — `worldAudio.playStep(stepKey(tile), ...)`. Событийные звуки боя/фауны — подпиской в `AudioSystem.attach()` на `combat:*` / `fauna:startle` (симуляция чиста от аудио). Лупы областей — `AreaDef.ambience`, локальные слои (вода, гул) — `AreaDef.ambienceLayers` + тик `worldAudio.setLayers`. + Полифонию ограничивает движок (`maxVoices`, воровство тихих голосов) — + пер-ключевой интервал в `AudioSystem` нужен только против «пулемёта» + одного ключа. 4. На слух в headless не проверить — смотреть фактические запуски: DEV-шпион `window.__gameAudioLog` (кольцо на 24, `t`/`volume`/`pan`) читается из страницы; образец — `apps/game/tools/checks/audio.mjs`. После действия diff --git a/packages/engine/src/audio/AudioManager.ts b/packages/engine/src/audio/AudioManager.ts index a17e929..3d01530 100644 --- a/packages/engine/src/audio/AudioManager.ts +++ b/packages/engine/src/audio/AudioManager.ts @@ -20,6 +20,14 @@ 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 { /** Относительная громкость в шине sfx (0..1+). */ @@ -28,6 +36,8 @@ rate?: number; /** Панорама -1..1; |pan| < 0.01 — узел StereoPanner не создаётся. */ pan?: number; + /** Заглушить прежние играющие экземпляры этого же ключа (короткий фейд). */ + restart?: boolean; } interface LoopOptions { @@ -43,6 +53,14 @@ gain: GainNode; } +/** Голос разового sfx: узлы + ключ (учёт для лимита полифонии и restart). */ +interface SfxVoice { + key: string; + source: AudioBufferSourceNode; + gain: GainNode; + panner: StereoPannerNode | null; +} + const DEFAULT_FADE = 1; const MIN_FADE_VOLUME = 0.0001; @@ -55,14 +73,21 @@ 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 (тесты, DEV-мост игры). */ onPlayed?: (key: string, opts: PlayOptions) => void; constructor( private resolveUrl: (key: string) => string, - private ctxFactory: () => AudioContext = () => new AudioContext() - ) {} + private ctxFactory: () => AudioContext = () => new AudioContext(), + { maxVoices = 24 }: { maxVoices?: number } = {} + ) { + this.maxVoices = maxVoices; + } /** Расблокировать аудио — вызвать из обработчика пользовательского ввода. */ async unlock(): Promise { @@ -105,19 +130,22 @@ } /** - * Проиграть sfx (если звук не загружен — тихо ничего не делает). + * Проиграть sfx и вернуть хендл играющего голоса (null — не декодировался + * или контекст не готов; хендл «мёртв» после stop и естественного конца). * opts — число (громкость, обратная совместимость) или PlayOptions. */ - async play(key: string, opts?: number | PlayOptions): 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; + if (!ctx) return null; const buf = await this.decode(key); - if (!buf) return; + if (!buf) return 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; @@ -125,15 +153,84 @@ const gain = ctx.createGain(); gain.gain.value = volume; source.connect(gain); + let panner: StereoPannerNode | null = null; if (Math.abs(pan) >= 0.01 && typeof ctx.createStereoPanner === 'function') { - const panner = ctx.createStereoPanner(); + panner = ctx.createStereoPanner(); panner.pan.value = pan; gain.connect(panner).connect(this.buses!.sfx); } else { gain.connect(this.buses!.sfx); } source.start(); + const voice: SfxVoice = { key, source, gain, panner }; + source.onended = () => this.releaseVoice(voice); + this.sfxVoices.push(voice); this.onPlayed?.(key, { volume, rate, pan }); + return this.makeSfxHandle(voice); + } + + /** Хендл над голосом: после снятия с учёта (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 { + 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); } /** diff --git a/packages/engine/src/audio/__tests__/AudioManager.test.ts b/packages/engine/src/audio/__tests__/AudioManager.test.ts index dae2eb2..8b486a4 100644 --- a/packages/engine/src/audio/__tests__/AudioManager.test.ts +++ b/packages/engine/src/audio/__tests__/AudioManager.test.ts @@ -34,10 +34,16 @@ destination: graph.destination, resume: vi.fn(async () => undefined), createGain: () => { - const node: { gain: ReturnType; dests: unknown[]; connect: (d: unknown) => unknown } = { + const node: { + gain: ReturnType; + dests: unknown[]; + connect: (d: unknown) => unknown; + disconnect: () => void; + } = { gain: makeParam(1), dests: [], - connect: () => undefined + connect: () => undefined, + disconnect: () => undefined }; node.connect = connect(node.dests); graph.gains.push(node); @@ -72,9 +78,9 @@ return { ctx, graph }; } -function makeManager() { +function makeManager(opts: { maxVoices?: number } = {}) { const { ctx, graph } = makeCtx(); - const audio = new AudioManager((key) => `url:${key}`, () => ctx as unknown as AudioContext); + const audio = new AudioManager((key) => `url:${key}`, () => ctx as unknown as AudioContext, opts); return { audio, graph }; } @@ -144,12 +150,12 @@ expect(graph.sources[1]!.playbackRate.value).toBe(1); }); - it('незагруженный ключ — тихо ничего, onPlayed не зовётся', async () => { + it('незагруженный ключ — тихо ничего (null), 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(); + await expect(audio.play('ghost')).resolves.toBeNull(); expect(played).toHaveLength(0); }); @@ -170,6 +176,95 @@ expect(graph.sources).toHaveLength(2); }); + it('play возвращает хендл: setVolume/setRate/setPan меняют узлы, stop глушит', async () => { + const { audio, graph } = makeManager(); + await audio.unlock(); + await audio.load('bell'); + + const h = await audio.play('bell', { volume: 0.5, pan: 0.3 }); + expect(h).not.toBeNull(); + + const gainNode = graph.sources[0]!.dests[0] as { gain: { value: number } }; + h!.setVolume(0.8); + expect(gainNode.gain.value).toBe(0.8); + + h!.setRate(1.4); + expect(graph.sources[0]!.playbackRate.value).toBe(1.4); + + h!.setPan(-0.5); + expect(graph.panners[0]!.pan.value).toBe(-0.5); + + h!.stop(0); + vi.advanceTimersByTime(100); + expect(graph.sources[0]!.stop).toHaveBeenCalled(); + }); + + it('setPan создаёт panner, если его не было', async () => { + const { audio, graph } = makeManager(); + await audio.unlock(); + await audio.load('bell'); + + const h = await audio.play('bell', 0.5); + expect(graph.panners).toHaveLength(0); + + h!.setPan(0.4); + expect(graph.panners).toHaveLength(1); + expect(graph.panners[0]!.pan.value).toBeCloseTo(0.4); + // Пересоединение: gain теперь ведёт и в panner + const gainNode = graph.sources[0]!.dests[0] as { dests: unknown[] }; + expect(gainNode.dests).toContain(graph.panners[0]); + }); + + it('после stop хендл мёртв: setVolume/setRate — no-op', async () => { + const { audio, graph } = makeManager(); + await audio.unlock(); + await audio.load('bell'); + + const h = await audio.play('bell', 0.5); + h!.stop(0); + h!.setVolume(0.9); + h!.setRate(1.5); + + // stop затушил громкость; setVolume мёртвого хендла её не вернул + const gainNode = graph.sources[0]!.dests[0] as { gain: { value: number } }; + expect(gainNode.gain.value).toBeCloseTo(0.0001); + expect(graph.sources[0]!.playbackRate.value).toBe(1); + vi.advanceTimersByTime(100); + expect(graph.sources[0]!.stop).toHaveBeenCalledTimes(1); + }); + + it('restart: новый экземпляр ключа глушит прежний; без restart — играют оба', 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, restart: true }); + vi.advanceTimersByTime(100); + expect(graph.sources[0]!.stop).toHaveBeenCalled(); + expect(graph.sources[1]!.stop).not.toHaveBeenCalled(); + + await audio.play('bell', 0.5); + vi.advanceTimersByTime(100); + expect(graph.sources[1]!.stop).not.toHaveBeenCalled(); // без restart живут оба + expect(graph.sources[2]!.stop).not.toHaveBeenCalled(); + }); + + it('maxVoices: при переполнении воруется самый тихий голос', async () => { + const { audio, graph } = makeManager({ maxVoices: 2 }); + await audio.unlock(); + await audio.load('bell'); + + await audio.play('bell', 0.9); + await audio.play('bell', 0.1); + await audio.play('bell', 0.5); + vi.advanceTimersByTime(100); + + expect(graph.sources[1]!.stop).toHaveBeenCalled(); // самый тихий (0.1) + expect(graph.sources[0]!.stop).not.toHaveBeenCalled(); + expect(graph.sources[2]!.stop).not.toHaveBeenCalled(); + }); + it('setVolume хендла сразу задаёт громкость слота', async () => { const { audio, graph } = makeManager(); await audio.unlock(); diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 2334061..a6d16ad 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, type BusName, type MusicHandle, type PlayOptions } from './audio/AudioManager'; +export { AudioManager, type AudioBuses, type BusName, type MusicHandle, type PlayOptions, type SfxHandle } from './audio/AudioManager'; // assets export { AssetLoader } from './assets/AssetLoader';