diff --git a/apps/game/src/scenes/LocationScene.ts b/apps/game/src/scenes/LocationScene.ts index 6ca93c8..aa63ea1 100644 --- a/apps/game/src/scenes/LocationScene.ts +++ b/apps/game/src/scenes/LocationScene.ts @@ -36,6 +36,7 @@ type TransitionDef } from '../data/locations'; import { AudioSystem, type AudioLayerDef } from '../systems/AudioSystem'; +import type { StepSurface } from '../systems/StepVariants'; import { InteractionRouter } from '../systems/ClickRouting'; import { SceneAgentView } from '../agent/SceneAgentView'; import type { NpcDef } from '../data/npcs'; @@ -161,7 +162,7 @@ }); this.audioOff = this.worldAudio.attach(); this.player = new PlayerController(this.map, this.heroTextures(), startTile, () => { - this.worldAudio.playStep(this.stepKey(this.player.currentTile()), this.player.position, 0.35); + this.worldAudio.playStep(this.stepSurface(this.player.currentTile()), this.player.position, 0.35); // Шаги — тихий шум (0.35): бодрых сгустков настораживает, спящих не будит. this.combat.noise(this.player.position, 0.35); }); @@ -731,13 +732,13 @@ return this.map.data.tiles[y * this.map.data.width + x]; } - /** Ключ шага по поверхности под ногами (трава/пепел/вода/пол). */ - private stepKey(tile: Vec2): string { + /** Поверхность под ногами (трава/пепел/вода/пол) — для вариативных шагов. */ + private stepSurface(tile: Vec2): StepSurface { const id = this.tileId(tile.x, tile.y); - if (id === TILES.WATER) return 'sfx/step_water'; - if (id === TILES.ASH || id === TILES.HOUSE || id === TILES.TOWER) return 'sfx/step_ash'; - if (id === TILES.FLOOR || id === TILES.PATH) return 'sfx/step_floor'; - return 'sfx/step_grass'; + if (id === TILES.WATER) return 'water'; + if (id === TILES.ASH || id === TILES.HOUSE || id === TILES.TOWER) return 'ash'; + if (id === TILES.FLOOR || id === TILES.PATH) return 'floor'; + return 'grass'; } /** diff --git a/apps/game/src/systems/AudioSystem.ts b/apps/game/src/systems/AudioSystem.ts index e24b775..4b3b43f 100644 --- a/apps/game/src/systems/AudioSystem.ts +++ b/apps/game/src/systems/AudioSystem.ts @@ -1,5 +1,4 @@ import { - createRng, worldToScreen, EventBus, type AudioManager, @@ -7,6 +6,7 @@ type Vec2 } from '@rpg/engine'; import { Game } from '../Game'; +import { StepVariants, type StepSurface } from './StepVariants'; /** * Позиционный звук мира: громкость и панорама от расстояния до героя, @@ -39,9 +39,12 @@ private layers = new Map(); /** Ключи запрошенных слоёв (и до асинхронного старта лупов). */ private wanted = new Set(); - private rng = createRng(20260905); + /** Вариативные шаги (процедурные буферы; до unlock — файловый шаг). */ + private steps: StepVariants; - constructor(private deps: AudioSystemDeps) {} + constructor(private deps: AudioSystemDeps) { + this.steps = new StepVariants(deps.audio); + } /** Тик: часы полифонии + доводка громкости слоёв к целям (fade сек до target). */ update(dt: number): void { @@ -75,13 +78,18 @@ else this.play(key, { volume }); } - /** Шаг: позиционный звук с лёгким джиттером скорости (поверхность живая). */ - playStep(key: string, pos: Vec2, base = 1): void { + /** + * Шаг по поверхности ('grass'|'ash'|'water'|'floor'): процедурный вариант + * из StepVariants (до unlock — файловый шаг), дедуп по поверхности. + */ + playStep(surface: StepSurface, pos: Vec2, base = 1): void { const listener = this.deps.getListener(); const d = Math.hypot(pos.x - listener.x, pos.y - listener.y); const volume = base * Math.max(0, 1 - d / 4); if (volume < 0.02) return; - this.play(key, { volume, rate: 0.92 + this.rng.next() * 0.16 }); + if ((this.lastAt.get(`step:${surface}`) ?? -1) + MIN_INTERVAL > this.clock) return; + this.lastAt.set(`step:${surface}`, this.clock); + this.steps.play(surface, volume); } /** Подписки на события боя и фауны; возвращает off() для exit() сцены. */ diff --git a/apps/game/src/systems/StepVariants.ts b/apps/game/src/systems/StepVariants.ts new file mode 100644 index 0000000..4ffa703 --- /dev/null +++ b/apps/game/src/systems/StepVariants.ts @@ -0,0 +1,66 @@ +import type { AudioManager } from '@rpg/engine'; +import { bandNoise, decay, normalize, synth } from '@rpg/engine/tools/synth.mjs'; + +/** + * Вариативные шаги: 3 процедурных варианта на поверхность, синтезируются + * в рантайме из примитивов wav.mjs (те же полосы/огибающие, что в + * tools/audio/gen.mjs, сиды смещены — звучат похоже, но не идентично WAV). + * Выбор варианта — по кругу. До unlock буферы не создать (createBuffer → + * null) — играем файловый шаг, варианты синтезируются заново при следующих + * шагах, пока не получится (обычно вторая попытка, после первого play). + */ + +export type StepSurface = 'grass' | 'ash' | 'water' | 'floor'; + +/** Параметры поверхности — как в gen.mjs (шум + низкий тон). */ +const SURFACES: Record = { + grass: { seed: 253, low: 400, high: 2000, dur: 0.12, power: 2, noise: 1, hum: 0, humGain: 0 }, + ash: { seed: 254, low: 200, high: 1100, dur: 0.14, power: 2.5, noise: 1, hum: 0, humGain: 0 }, + water: { seed: 255, low: 600, high: 3200, dur: 0.18, power: 2, noise: 0.6, hum: 300, humGain: 0.2 }, + floor: { seed: 256, low: 150, high: 800, dur: 0.1, power: 3, noise: 0.4, hum: 140, humGain: 0.5 } +}; + +const VARIANTS = 3; + +export class StepVariants { + /** Буферы по поверхности (синтезируются при первом успешном шаге). */ + private cache = new Map(); + private seq = 0; + + constructor(private audio: AudioManager) {} + + /** Играть очередной вариант шага (до готовности буферов — файловый шаг). */ + play(surface: StepSurface, volume: number): void { + const key = `sfx/step_${surface}`; + const list = this.ensure(surface); + if (list.length > 0) { + const i = this.seq++ % list.length; + void this.audio.playBuffer(`${key}#${i}`, list[i]!, { volume }); + } else { + void this.audio.play(key, { volume }); + } + } + + /** Синтезировать варианты поверхности (если контекст ещё не готов — пустой список). */ + private ensure(surface: StepSurface): AudioBuffer[] { + const existing = this.cache.get(surface); + if (existing) return existing; + const s = SURFACES[surface]; + const list: AudioBuffer[] = []; + for (let i = 0; i < VARIANTS; i++) { + const noise = bandNoise(s.seed + i * 10, s.low, s.high, s.dur); + const samples = normalize( + synth( + s.dur, + (t) => (s.noise * noise() + s.humGain * Math.sin(2 * Math.PI * s.hum * t)) * decay(t, s.dur, s.power) + ), + 0.3 + ); + const buf = this.audio.createBuffer(samples, 22050); + if (!buf) return []; // контекста нет — попробуем на следующем шаге + list.push(buf); + } + this.cache.set(surface, list); + return list; + } +} \ No newline at end of file diff --git a/apps/game/src/systems/__tests__/AudioSystem.test.ts b/apps/game/src/systems/__tests__/AudioSystem.test.ts index 86886dd..210b7f4 100644 --- a/apps/game/src/systems/__tests__/AudioSystem.test.ts +++ b/apps/game/src/systems/__tests__/AudioSystem.test.ts @@ -2,7 +2,7 @@ import { EventBus, type AudioManager, type MusicHandle } from '@rpg/engine'; import { AudioSystem, type AudioSystemDeps } from '../AudioSystem'; -/** Фейковый AudioManager: записывает вызовы play/playLoop. */ +/** Фейковый AudioManager: записывает вызовы play/playBuffer/playLoop. */ function fakeAudio() { const played: { key: string; opts?: { volume?: number; rate?: number; pan?: number } }[] = []; const loops: { key: string; opts?: { bus?: string; volume?: number; fade?: number } }[] = []; @@ -10,6 +10,11 @@ play: vi.fn(async (key: string, opts?: { volume?: number; rate?: number; pan?: number }) => { played.push({ key, opts }); }), + playBuffer: vi.fn(async (key: string, _buf: AudioBuffer, opts?: { volume?: number; rate?: number; pan?: number }) => { + played.push({ key, opts }); + }), + // По умолчанию контекст «не разблокирован» — буферы не создать. + createBuffer: vi.fn((): AudioBuffer | null => null), playLoop: vi.fn(async (key: string, opts?: { bus?: string; volume?: number; fade?: number }) => { loops.push({ key, opts }); return { stop: vi.fn(), setVolume: vi.fn() } as unknown as MusicHandle; @@ -73,30 +78,53 @@ }); }); -describe('AudioSystem.playStep — джиттер и полифония', () => { - it('шагу задаётся rate 0.92..1.08', () => { +describe('AudioSystem.playStep — поверхности и варианты', () => { + it('без буферов (контекст не разблокирован) — файловый шаг по поверхности', () => { const { deps, calls } = makeDeps(); const sys = new AudioSystem(deps); - sys.playStep('sfx/step', { x: 10, y: 10 }, 0.35); + sys.playStep('grass', { x: 10, y: 10 }, 0.35); - const rate = calls.played[0]!.opts!.rate!; - expect(rate).toBeGreaterThanOrEqual(0.92); - expect(rate).toBeLessThanOrEqual(1.08); + expect(calls.played).toHaveLength(1); + expect(calls.played[0]!.key).toBe('sfx/step_grass'); + expect(calls.played[0]!.opts!.volume).toBeCloseTo(0.35); }); - it('один ключ чаще 0.08 с не запускается, другой — проходит', () => { + it('буферы готовы — playBuffer вариантов по кругу', () => { + const { deps, calls } = makeDeps(); + vi.mocked(calls.audio.createBuffer).mockImplementation(() => ({ duration: 0.12 }) as AudioBuffer); + const sys = new AudioSystem(deps); + + sys.playStep('ash', { x: 10, y: 10 }, 0.35); + sys.update(0.1); + sys.playStep('ash', { x: 10, y: 10 }, 0.35); + sys.update(0.1); + sys.playStep('ash', { x: 10, y: 10 }, 0.35); + + expect(calls.played.map((p) => p.key)).toEqual(['sfx/step_ash#0', 'sfx/step_ash#1', 'sfx/step_ash#2']); + }); + + it('дедуп: одна поверхность чаще 0.08 с не звучит', () => { const { deps, calls } = makeDeps(); const sys = new AudioSystem(deps); - sys.playAt('sfx/spit', { x: 10, y: 10 }); - sys.playAt('sfx/spit', { x: 10, y: 10 }); // слишком рано — глушится + sys.playStep('grass', { x: 10, y: 10 }, 0.35); + sys.playStep('grass', { x: 10, y: 10 }, 0.35); // слишком рано — глушится sys.update(0.05); - sys.playAt('sfx/spit', { x: 10, y: 10 }); // всё ещё рано + sys.playStep('grass', { x: 10, y: 10 }, 0.35); // всё ещё рано sys.update(0.05); - sys.playAt('sfx/bell_hit', { x: 10, y: 10 }); // другой ключ — ок + sys.playStep('water', { x: 10, y: 10 }, 0.35); // другая поверхность — ок - expect(calls.played.map((p) => p.key)).toEqual(['sfx/spit', 'sfx/bell_hit']); + expect(calls.played.map((p) => p.key)).toEqual(['sfx/step_grass', 'sfx/step_water']); + }); + + it('далеко от слушателя — тишина', () => { + const { deps, calls } = makeDeps(); + const sys = new AudioSystem(deps); + + sys.playStep('floor', { x: 20, y: 20 }, 0.35); + + expect(calls.played).toHaveLength(0); }); }); diff --git a/apps/game/src/wav-tools.d.ts b/apps/game/src/wav-tools.d.ts new file mode 100644 index 0000000..49d5eeb --- /dev/null +++ b/apps/game/src/wav-tools.d.ts @@ -0,0 +1,19 @@ +/** + * Чистые примитивы синтеза движка (тулза synth.mjs без типов) для + * рантайм-синтеза: вариативные шаги в systems/StepVariants.ts. WAV-энкодер + * (wav.mjs) нужен только тулзам игры при сборке — здесь его не декларируем. + */ +declare module '@rpg/engine/tools/synth.mjs' { + /** Частота дискретизации (Гц). */ + export const RATE: number; + /** Детерминированный ГПСЧ в [0, 1). */ + export function makeRng(seed: number): () => number; + /** Синтез: fn(t-сек) на каждый сэмпл, RATE по умолчанию. */ + export function synth(seconds: number, fn: (t: number) => number, rate?: number): Float32Array; + /** Огибающая затухания к концу dur. */ + export function decay(t: number, dur: number, power?: number): number; + /** Полосный шум (детерминирован сидом): генератор сэмплов в [-1, 1]. */ + export function bandNoise(seed: number, lowHz: number, highHz: number, dur: number): () => number; + /** Нормировать по пику. */ + export function normalize(samples: Float32Array, peak?: number): Float32Array; +} \ No newline at end of file diff --git a/docs/engine/assets-audio-save.md b/docs/engine/assets-audio-save.md index fd808ea..0c97320 100644 --- a/docs/engine/assets-audio-save.md +++ b/docs/engine/assets-audio-save.md @@ -93,6 +93,24 @@ Полифония ограничена `maxVoices` (третий аргумент конструктора, по умолчанию 24, 0 — без лимита): при переполнении воруется самый тихий играющий голос. +`playBuffer(key, buffer, opts?)` — тот же sfx, но из **буфера в памяти** +(процедурные звуки, синтезированные в рантайме): ключ — логическое имя голоса +(дедуп, restart, полифония, шпион `onPlayed`), не файл. Пара к нему — +`createBuffer(data: Float32Array, sampleRate = 22050)`: моно-сэмплы → +`AudioBuffer` (null, пока контекст не разблокирован — после первого +`play`/`unlock` работает). Чистые примитивы синтеза (bandNoise, synth, decay, +normalize, makeRng) идут с движком в `@rpg/engine/tools/synth.mjs` — без +node-импортов, готовы для браузера: + +```ts +import { bandNoise, decay, normalize, synth } from '@rpg/engine/tools/synth.mjs'; + +const samples = normalize(synth(0.12, (t) => noise() * decay(t, 0.12, 2)), 0.3); +const buf = audio.createBuffer(samples); +if (buf) await audio.playBuffer('sfx/step_grass#0', buf, { volume }); +// до unlock буфер не создать — fallback на файловый audio.play('sfx/step_grass') +``` + Слоты лупов: `playMusic` и `playAmbience` — независимые слоты с кроссфейдом (повторный вызов вытесняет прежний трек своего слота; негодный ключ не глушит текущий). `playLoop` — луп **без слота** (занимать нечего, сколько вызвали — diff --git a/docs/engine/practices.md b/docs/engine/practices.md index ef28b5b..f79449f 100644 --- a/docs/engine/practices.md +++ b/docs/engine/practices.md @@ -138,7 +138,7 @@ 1. Правила границы проверяет гвард: `npm run guard` (`apps/game/tools/guards/boundary.mjs`), он же проба `guard:boundary` в `agent check`. Провал = импорт мимо публичного API движка - (`@rpg/engine` / `@rpg/engine/assets/*`), относительный импорт, + (`@rpg/engine` / `@rpg/engine/assets/*` / `@rpg/engine/tools/*`), относительный импорт, пересекающий границу пакета, или импорт игры из движка. Чинить импорт, а не гвард. 2. Движок не знает ничего об RPG-контенте; если движку нужна точка расширения @@ -169,9 +169,10 @@ ## Ситуация: добавляю звук 1. Генерация: функция-синтез в `apps/game/tools/audio/gen.mjs` (примитивы - движкового `@rpg/engine/tools/wav.mjs` — bandNoise, bellPartial, synth, - decay, loopify; сиды фиксированы — звук детерминирован) + запись в словарь - `SFX`; `npm run audio` перегенерирует WAV в `apps/game/assets/audio/`. + движкового `@rpg/engine/tools/synth.mjs` — bandNoise, bellPartial, synth, + decay, loopify; сиды фиксированы — звук детерминирован; WAV-энкодер и + запись — `wav.mjs` поверх них) + запись в словарь `SFX`; `npm run audio` + перегенерирует WAV в `apps/game/assets/audio/`. 2. Ключ — в реестр `apps/game/src/data/audio.ts` (`SFX_KEYS` / `AMBIENCE_KEYS` / `AMBIENCE_LAYER_KEYS`): BootScene префетчит оттуда, validate-тест ловит «ключ в контенте — файла нет» и «файл есть — ключа нет». @@ -180,7 +181,10 @@ для «одиночного» звука вместо наслоения экземпляров); позиционный — `worldAudio.playAt(key, pos, base, radius)` (затухание + панорама от героя, тишина за радиусом); шаги — - `worldAudio.playStep(stepKey(tile), ...)`. Событийные звуки боя/фауны — + `worldAudio.playStep(surface, ...)`, где surface — `'grass'|'ash'|'water'| + 'floor'` (вариативные процедурные шаги из `systems/StepVariants.ts` — + 3 буфера на поверхность, до unlock движок играет файловый шаг). + Событийные звуки боя/фауны — подпиской в `AudioSystem.attach()` на `combat:*` / `fauna:startle` (симуляция чиста от аудио). Лупы областей — `AreaDef.ambience`, локальные слои (вода, гул) — `AreaDef.ambienceLayers` + тик `worldAudio.setLayers`. @@ -257,6 +261,9 @@ параметр `quantize`/`Canvas`, маяк загрузки — параметр `openGame({beacon})`, корни пакетов гварда — опции `checkBoundary`. - `Assets.load` без `Assets.init()` висит навсегда — грузи только через `AssetLoader`. +- **`wav.mjs` тянет `node:fs` — в рантайме браузера не соберётся** (маяк загрузки + молчит, страница мертва). Рантайм-синтез — из `@rpg/engine/tools/synth.mjs` + (чистые примитивы, без node-импортов); wav.mjs — только в тулзах при сборке. - Headless Chromium: `--autoplay-policy=no-user-gesture-required` обязателен; `ctx.resume()` без него не резолвится. - Маяки консоли: `[boot] ассеты загружены` (BootScene), `[location] ` diff --git a/docs/plan.md b/docs/plan.md index 1dea11b..671a220 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -23,7 +23,7 @@ | 4 | Акт 2: новые локации/сюжет по `docs/world.md` | очередь после акта 1+AI-ассетов | | 5 | Замена сгенерированных плейсхолдеров арта на AI/ручной арт | в составе работы B | | 6 | Крупные объекты: footprint в движке, слоты арта (работа C, ниже) | в работе | -| 7 | Возможности аудио-движка (работа D, ниже): sfx-хендл, полифония, наблюдаемость, буферы | 📋 спланировано (D0–D2) | +| 7 | Возможности аудио-движка (работа D, ниже): sfx-хендл, полифония, наблюдаемость, буферы | ✅ D0–D2 сделаны (2026-09-07) | Отложено: **IsoLayout как параметр игры** — `DEFAULT_ISO` в `math/iso.ts` остаётся единственной точкой входа; прокидывание конфига игры через ~15 мест движка не даёт @@ -294,18 +294,18 @@ ### Этапы -- **D0 (движок): sfx-хендл + полифония.** `SfxHandle { stop(fadeSeconds?), +- ✅ **D0 (движок): sfx-хендл + полифония.** `SfxHandle { stop(fadeSeconds?), setVolume(v), setPan(v), setRate(v) }` — по образцу `MusicHandle`; опция `restart`/«один экземпляр на ключ»; `maxVoices` с воровством голосов. - Тесты — расширение `AudioManager.test.ts` (фейковый ctx уже умеет). -- **D1 (движок): наблюдаемость.** `onPlayed` на все запуски (sfx + лупы, - опции нормализованы); `onEnded(key)`; `duration(key)`. Игра: убрать ручное - логирование амбиента из `Game.playAmbience` (лог целиком из шпиона). -- **D2 (движок + игра): буферы из памяти.** `playBuffer(buf, opts)` в шину - sfx/луп; игра — вариативные шаги: генерация 3–4 вариаций шага в рантайме - из примитивов `wav.mjs` (сиды фикс.) вместо джиттера `rate`. -- **Проверки каждого этапа**: `npm run check:fast`; агентная проба - `checks/audio.mjs` не деградирует (лог шпиона остаётся источником). +- ✅ **D1 (движок): наблюдаемость.** `onPlayed` на все запуски (sfx + лупы, + опции нормализованы); `onEnded(key)`; `duration(key)`. Игра: ручное + логирование амбиента убрано (лог целиком из шпиона). +- ✅ **D2 (движок + игра): буферы из памяти.** `playBuffer(key, buffer, opts)` + + `createBuffer(data)`; примитивы синтеза вынесены в чистый + `@rpg/engine/tools/synth.mjs` (без node-импортов — wav.mjs в браузере не + соберётся); игра — `systems/StepVariants.ts`: 3 процедурных варианта шага + на поверхность (сид ~253–256 + сдвиг, полосы как в gen.mjs), до unlock — + файловый шаг. Гвард: `@rpg/engine/tools/*` — публичное API. ### Дальше (после D — закрываем «по инструментарию») diff --git a/packages/engine/src/audio/AudioManager.ts b/packages/engine/src/audio/AudioManager.ts index d906a48..821de3e 100644 --- a/packages/engine/src/audio/AudioManager.ts +++ b/packages/engine/src/audio/AudioManager.ts @@ -149,7 +149,35 @@ if (!ctx) return null; const buf = await this.decode(key); if (!buf) return null; + return this.startSfx(ctx, key, buf, o); + } + /** + * Проиграть буфер из памяти как sfx (процедурные звуки в рантайме). Ключ — + * логическое имя голоса (для restart, полифонии и шпионов), не файл. + */ + async playBuffer(key: string, buffer: AudioBuffer, opts?: number | PlayOptions): Promise { + const o: PlayOptions = typeof opts === 'number' ? { volume: opts } : (opts ?? {}); + const ctx = await this.ensureContext(); + if (!ctx) return null; + return this.startSfx(ctx, key, buffer, o); + } + + /** + * Буфер из моно-FLOAT сэмплов (для процедурных звуков в рантайме). + * До unlock контекста нет — null; создать буфер по ключу не получится, + * вызывать после первого play/unlock. + */ + createBuffer(data: Float32Array, sampleRate = 22050): AudioBuffer | null { + if (!this.ctx) return null; + const buf = this.ctx.createBuffer(1, data.length, sampleRate); + // DOM-тип требует Float32Array — сэмплы из генераторов не обязаны. + buf.copyToChannel(data as Float32Array, 0); + return buf; + } + + /** Общий запуск голоса sfx (play — после декода, playBuffer — как есть). */ + private startSfx(ctx: AudioContext, key: string, buf: AudioBuffer, o: PlayOptions): SfxHandle | null { const volume = o.volume ?? 1; const rate = clamp(o.rate ?? 1, 0.5, 2); const pan = clamp(o.pan ?? 0, -1, 1); diff --git a/packages/engine/src/audio/__tests__/AudioManager.test.ts b/packages/engine/src/audio/__tests__/AudioManager.test.ts index ab3211b..56b3118 100644 --- a/packages/engine/src/audio/__tests__/AudioManager.test.ts +++ b/packages/engine/src/audio/__tests__/AudioManager.test.ts @@ -73,7 +73,19 @@ graph.panners.push(node); return node; }, - decodeAudioData: vi.fn(async () => ({ decoded: true, duration: 1.25 })) + decodeAudioData: vi.fn(async () => ({ decoded: true, duration: 1.25 })), + createBuffer: vi.fn((channels: number, length: number, rate: number) => { + const buf: { channels: number; length: number; sampleRate: number; data: Float32Array | null; copyToChannel: ReturnType } = { + channels, + length, + sampleRate: rate, + data: null, + copyToChannel: vi.fn((data: Float32Array) => { + buf.data = data; + }) + }; + return buf; + }) }; return { ctx, graph }; } @@ -81,7 +93,7 @@ function makeManager(opts: { maxVoices?: number } = {}) { const { ctx, graph } = makeCtx(); const audio = new AudioManager((key) => `url:${key}`, () => ctx as unknown as AudioContext, opts); - return { audio, graph }; + return { audio, graph, ctx: ctx as unknown as { createBuffer: ReturnType } }; } describe('AudioManager', () => { @@ -324,6 +336,38 @@ expect(audio.duration('m')).toBeCloseTo(1.25); }); + it('createBuffer: до unlock — null, после — буфер с сэмплами', async () => { + const { audio, ctx } = makeManager(); + const data = new Float32Array([0, 0.5, -0.5]); + expect(audio.createBuffer(data)).toBeNull(); // контекста ещё нет + + await audio.unlock(); + const buf = audio.createBuffer(data, 44100) as unknown as { data: Float32Array | null; copyToChannel: (d: Float32Array, ch: number) => void }; + expect(ctx.createBuffer).toHaveBeenCalledWith(1, 3, 44100); + expect(buf.data).toBe(data); + expect(buf!.copyToChannel).toHaveBeenCalledWith(data, 0); + }); + + it('playBuffer играет буфер в sfx с заданным ключом и возвращает хендл', async () => { + const { audio, graph } = makeManager(); + const played: { key: string; opts: PlayOptions }[] = []; + audio.onPlayed = (key, opts) => played.push({ key, opts }); + await audio.unlock(); + + const buf = audio.createBuffer(new Float32Array(100).fill(0.3))!; + const h = await audio.playBuffer('sfx/step_grass#0', buf, { volume: 0.4 }); + + expect(h).not.toBeNull(); + const gainNode = graph.sources[0]!.dests[0] as { gain: { value: number }; dests: unknown[] }; + expect(gainNode.gain.value).toBe(0.4); + expect(gainNode.dests).toContain(graph.gains[2]); // шина sfx + expect(played).toEqual([{ key: 'sfx/step_grass#0', opts: { volume: 0.4, rate: 1, pan: 0 } }]); + + h!.stop(0); + vi.advanceTimersByTime(100); + expect(graph.sources[0]!.stop).toHaveBeenCalled(); + }); + it('setVolume хендла сразу задаёт громкость слота', async () => { const { audio, graph } = makeManager(); await audio.unlock(); diff --git a/packages/engine/tools/__tests__/boundary.test.mjs b/packages/engine/tools/__tests__/boundary.test.mjs index 8634366..d1a2a44 100644 --- a/packages/engine/tools/__tests__/boundary.test.mjs +++ b/packages/engine/tools/__tests__/boundary.test.mjs @@ -51,9 +51,10 @@ expect(v[0]).toMatchObject({ rule: 'game-engine-public-api', severity: 'error', spec: '@rpg/engine/math/iso' }); }); - it('публичное API разрешено: сам пакет и assets/*', () => { + it('публичное API разрешено: сам пакет, assets/* и tools/*', () => { const v = checkBoundary(files([ - ['apps/game/src/main.ts', "import { Game } from '@rpg/engine';\nimport fontUrl from '@rpg/engine/assets/fonts/VT323-Regular.ttf?url';"] + ['apps/game/src/main.ts', "import { Game } from '@rpg/engine';\nimport fontUrl from '@rpg/engine/assets/fonts/VT323-Regular.ttf?url';"], + ['apps/game/src/systems/steps.ts', "import { bandNoise } from '@rpg/engine/tools/wav.mjs';"] ])); expect(v).toEqual([]); }); diff --git a/packages/engine/tools/boundary.mjs b/packages/engine/tools/boundary.mjs index bc4d286..e5cc679 100644 --- a/packages/engine/tools/boundary.mjs +++ b/packages/engine/tools/boundary.mjs @@ -45,7 +45,7 @@ */ function resolveOpts({ root = DEFAULT_ROOT, engineDir = join(root, 'packages', 'engine'), gameDir = join(root, 'apps', 'game'), enginePkg = '@rpg/engine', - publicApi = [enginePkg, `${enginePkg}/assets/`] } = {}) { + publicApi = [enginePkg, `${enginePkg}/assets/`, `${enginePkg}/tools/`] } = {}) { return { root, engineDir, gameDir, enginePkg, publicApi }; } diff --git a/packages/engine/tools/synth.mjs b/packages/engine/tools/synth.mjs new file mode 100644 index 0000000..ad584a2 --- /dev/null +++ b/packages/engine/tools/synth.mjs @@ -0,0 +1,72 @@ +/** + * Чистые примитивы синтеза (детерминированный шум, полосовой фильтр, + * огибающие, нормализация, кроссфейд-луп) — без node-импортов, чтобы + * работать и в тулзах (wav.mjs, gen.mjs), и в рантайме браузера + * (вариативные шаги игры поверх AudioManager.createBuffer). + * Частота по умолчанию — RATE Гц (моно). + */ + +export const RATE = 22050; + +/** Детерминированный шум. */ +export function makeRng(seed) { + let s = seed >>> 0; + return () => { + s = (s + 0x6d2b79f5) >>> 0; + let t = Math.imul(s ^ (s >>> 15), 1 | s); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +/** Синтез: секунды -> Float32[]. fn(t, i) -> сэмпл. */ +export function synth(seconds, fn, rate = RATE) { + const n = Math.floor(seconds * rate); + const out = new Float32Array(n); + for (let i = 0; i < n; i++) out[i] = fn(i / rate, i); + return out; +} + +/** Экспоненциальный спад: полный на старте, ~0 к концу. */ +export function decay(t, dur, power = 4) { + return Math.pow(Math.max(0, 1 - t / dur), power); +} + +/** Колокольный партиал: негармоничный (частота * растяжка), быстрый спад. */ +export function bellPartial(freq, stretch, dur, power) { + return (t) => Math.sin(2 * Math.PI * freq * stretch * t) * decay(t, dur, power); +} + +/** Полосовой шум через однополюсные фильтры (низ + верх). */ +export function bandNoise(seed, lowHz, highHz, dur) { + const r = makeRng(seed); + let l1 = 0; + let l2 = 0; + const aLow = 1 - Math.exp(-2 * Math.PI * lowHz / RATE); + const aHigh = 1 - Math.exp(-2 * Math.PI * highHz / RATE); + return () => { + const white = r() * 2 - 1; + l1 += aHigh * (white - l1); // фильтр верхов: выше highHz глушим + l2 += aLow * (l1 - l2); // фильтр низов: ниже lowHz глушим + return l2 - l1 < -1 ? -1 : Math.max(-1, Math.min(1, l1 - l2)); + }; +} + +/** Нормализовать по пику. */ +export function normalize(samples, peak = 0.9) { + let max = 0; + for (const s of samples) max = Math.max(max, Math.abs(s)); + if (max === 0) return samples; + const k = peak / max; + return samples.map((s) => s * k); +} + +/** Сшить луп: кроссфейд первых fade-секунд с концом. */ +export function loopify(samples, fadeSeconds = 0.5) { + const n = Math.floor(fadeSeconds * RATE); + for (let i = 0; i < n; i++) { + const k = i / n; + samples[i] = samples[i] * k + samples[samples.length - n + i] * (1 - k); + } + return samples.slice(0, samples.length - n); +} \ No newline at end of file diff --git a/packages/engine/tools/wav.mjs b/packages/engine/tools/wav.mjs index 13137c5..e1d22b2 100644 --- a/packages/engine/tools/wav.mjs +++ b/packages/engine/tools/wav.mjs @@ -1,11 +1,21 @@ /** - * WAV-тулкит без зависимостей: энкодер и примитивы синтеза (детерминированный - * шум, полосовой фильтр, огибающие, нормализация, кроссфейд-луп). + * WAV-тулкит без зависимостей: энкодер поверх чистых примитивов синтеза + * (сами примитивы — в synth.mjs, они же используются в рантайме браузера). * Формат: моно, RATE Гц, 16-бит PCM. */ import { writeFileSync } from 'node:fs'; +import { + RATE, + bandNoise, + bellPartial, + decay, + loopify, + makeRng, + normalize, + synth +} from './synth.mjs'; -export const RATE = 22050; +export { RATE, bandNoise, bellPartial, decay, loopify, makeRng, normalize, synth }; /** Собрать WAV-файл из Float32 сэмплов (-1..1). */ export function encodeWav(samples, rate = RATE) { @@ -39,68 +49,3 @@ export function writeWav(path, samples, rate = RATE) { writeFileSync(path, encodeWav(samples, rate)); } - -// ---------- примитивы ---------- - -/** Детерминированный шум. */ -export function makeRng(seed) { - let s = seed >>> 0; - return () => { - s = (s + 0x6d2b79f5) >>> 0; - let t = Math.imul(s ^ (s >>> 15), 1 | s); - t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; - return ((t ^ (t >>> 14)) >>> 0) / 4294967296; - }; -} - -/** Синтез: секунды -> Float32[]. fn(t, i) -> сэмпл. */ -export function synth(seconds, fn, rate = RATE) { - const n = Math.floor(seconds * rate); - const out = new Float32Array(n); - for (let i = 0; i < n; i++) out[i] = fn(i / rate, i); - return out; -} - -/** Экспоненциальный спад: полный на старте, ~0 к концу. */ -export function decay(t, dur, power = 4) { - return Math.pow(Math.max(0, 1 - t / dur), power); -} - -/** Колокольный партиал: негармоничный (частота * растяжка), быстрый спад. */ -export function bellPartial(freq, stretch, dur, power) { - return (t) => Math.sin(2 * Math.PI * freq * stretch * t) * decay(t, dur, power); -} - -/** Полосовой шум через однополюсные фильтры (низ + верх). */ -export function bandNoise(seed, lowHz, highHz, dur) { - const r = makeRng(seed); - let l1 = 0; - let l2 = 0; - const aLow = 1 - Math.exp(-2 * Math.PI * lowHz / RATE); - const aHigh = 1 - Math.exp(-2 * Math.PI * highHz / RATE); - return () => { - const white = r() * 2 - 1; - l1 += aHigh * (white - l1); // фильтр верхов: выше highHz глушим - l2 += aLow * (l1 - l2); // фильтр низов: ниже lowHz глушим - return l2 - l1 < -1 ? -1 : Math.max(-1, Math.min(1, l1 - l2)); - }; -} - -/** Нормализовать по пику. */ -export function normalize(samples, peak = 0.9) { - let max = 0; - for (const s of samples) max = Math.max(max, Math.abs(s)); - if (max === 0) return samples; - const k = peak / max; - return samples.map((s) => s * k); -} - -/** Сшить луп: кроссфейд первых fade-секунд с концом. */ -export function loopify(samples, fadeSeconds = 0.5) { - const n = Math.floor(fadeSeconds * RATE); - for (let i = 0; i < n; i++) { - const k = i / n; - samples[i] = samples[i] * k + samples[samples.length - n + i] * (1 - k); - } - return samples.slice(0, samples.length - n); -} \ No newline at end of file