diff --git a/apps/game/assets/audio/sfx/ash_die.wav b/apps/game/assets/audio/sfx/ash_die.wav deleted file mode 100644 index fe7f847..0000000 --- a/apps/game/assets/audio/sfx/ash_die.wav +++ /dev/null Binary files differ diff --git a/apps/game/assets/audio/sfx/ash_hiss.wav b/apps/game/assets/audio/sfx/ash_hiss.wav deleted file mode 100644 index f0cf5cf..0000000 --- a/apps/game/assets/audio/sfx/ash_hiss.wav +++ /dev/null Binary files differ diff --git a/apps/game/assets/audio/sfx/bell_low.wav b/apps/game/assets/audio/sfx/bell_low.wav deleted file mode 100644 index 12728b3..0000000 --- a/apps/game/assets/audio/sfx/bell_low.wav +++ /dev/null Binary files differ diff --git a/apps/game/assets/audio/sfx/chest_open.wav b/apps/game/assets/audio/sfx/chest_open.wav deleted file mode 100644 index 635a913..0000000 --- a/apps/game/assets/audio/sfx/chest_open.wav +++ /dev/null Binary files differ diff --git a/apps/game/assets/audio/sfx/chime.wav b/apps/game/assets/audio/sfx/chime.wav deleted file mode 100644 index d988c38..0000000 --- a/apps/game/assets/audio/sfx/chime.wav +++ /dev/null Binary files differ diff --git a/apps/game/assets/audio/sfx/deer_startle.wav b/apps/game/assets/audio/sfx/deer_startle.wav deleted file mode 100644 index 7a86c17..0000000 --- a/apps/game/assets/audio/sfx/deer_startle.wav +++ /dev/null Binary files differ diff --git a/apps/game/assets/audio/sfx/hurt.wav b/apps/game/assets/audio/sfx/hurt.wav deleted file mode 100644 index a25aaf7..0000000 --- a/apps/game/assets/audio/sfx/hurt.wav +++ /dev/null Binary files differ diff --git a/apps/game/assets/audio/sfx/mote_pickup.wav b/apps/game/assets/audio/sfx/mote_pickup.wav deleted file mode 100644 index 5b6671b..0000000 --- a/apps/game/assets/audio/sfx/mote_pickup.wav +++ /dev/null Binary files differ diff --git a/apps/game/assets/audio/sfx/note.wav b/apps/game/assets/audio/sfx/note.wav deleted file mode 100644 index 293bb5f..0000000 --- a/apps/game/assets/audio/sfx/note.wav +++ /dev/null Binary files differ diff --git a/apps/game/assets/audio/sfx/pickup.wav b/apps/game/assets/audio/sfx/pickup.wav deleted file mode 100644 index 53e99f6..0000000 --- a/apps/game/assets/audio/sfx/pickup.wav +++ /dev/null Binary files differ diff --git a/apps/game/assets/audio/sfx/spit.wav b/apps/game/assets/audio/sfx/spit.wav deleted file mode 100644 index 1a7a6ee..0000000 --- a/apps/game/assets/audio/sfx/spit.wav +++ /dev/null Binary files differ diff --git a/apps/game/assets/audio/sfx/step_ash.wav b/apps/game/assets/audio/sfx/step_ash.wav deleted file mode 100644 index 1579df1..0000000 --- a/apps/game/assets/audio/sfx/step_ash.wav +++ /dev/null Binary files differ diff --git a/apps/game/assets/audio/sfx/step_floor.wav b/apps/game/assets/audio/sfx/step_floor.wav deleted file mode 100644 index b5b7100..0000000 --- a/apps/game/assets/audio/sfx/step_floor.wav +++ /dev/null Binary files differ diff --git a/apps/game/assets/audio/sfx/step_grass.wav b/apps/game/assets/audio/sfx/step_grass.wav deleted file mode 100644 index 5860506..0000000 --- a/apps/game/assets/audio/sfx/step_grass.wav +++ /dev/null Binary files differ diff --git a/apps/game/assets/audio/sfx/step_water.wav b/apps/game/assets/audio/sfx/step_water.wav deleted file mode 100644 index f96f331..0000000 --- a/apps/game/assets/audio/sfx/step_water.wav +++ /dev/null Binary files differ diff --git a/apps/game/assets/audio/sfx/ui_click.wav b/apps/game/assets/audio/sfx/ui_click.wav deleted file mode 100644 index 1638053..0000000 --- a/apps/game/assets/audio/sfx/ui_click.wav +++ /dev/null Binary files differ diff --git a/apps/game/src/agent/SceneAgentView.ts b/apps/game/src/agent/SceneAgentView.ts index 087c1d1..3de1af1 100644 --- a/apps/game/src/agent/SceneAgentView.ts +++ b/apps/game/src/agent/SceneAgentView.ts @@ -37,7 +37,7 @@ import { hasLineOfSight } from '../systems/combat/los'; /** Допустимые kind спек-синтеза (как в движке, строками — спек идёт по мосту). */ -const SOUND_KINDS: readonly string[] = ['hit', 'chime', 'scrape', 'hum']; +const SOUND_KINDS: readonly string[] = ['hit', 'chime', 'scrape', 'hum', 'tone']; /** * Агентный фасад сцены локации: слой снапшота, инварианты, whitelist-команды. diff --git a/apps/game/src/data/__tests__/sfxSpecs.test.ts b/apps/game/src/data/__tests__/sfxSpecs.test.ts index bb19417..a611a5c 100644 --- a/apps/game/src/data/__tests__/sfxSpecs.test.ts +++ b/apps/game/src/data/__tests__/sfxSpecs.test.ts @@ -1,14 +1,40 @@ /** - * Парити-тест спек-синтеза: renderSpec(SPEC_SFX[key]) должен быть бит-в-бит - * равен формуле бывшего файлового генератора (tools/audio/gen.mjs) — миграция - * sfx/bell_hit и sfx/whoosh на playSpec не должна изменить звучание. - * Слух агента нет, поэтому звучание сверяется численно (поразрядно). + * Парити-тест спек-синтеза: renderSpec(SPEC_SFX[key]) должен совпадать с + * формулой бывшего файлового генератора (tools/audio/gen.mjs) — миграция + * на playSpec не изменила звучание. Агент без слуха, поэтому сверяем + * численно: bell_hit/whoosh бит-в-бит (формулы совпали дословно), остальные + * — с допуском 1e-5 (перегруппировка умножений даёт расхождение ~1e-15, + * допуск ловит любую реальную ошибку формулы: частоту, вес, огибающую). */ import { describe, expect, it } from 'vitest'; import { renderSpec } from '@rpg/engine'; -import { bellHit, whoosh } from '../../../tools/audio/gen.mjs'; +import { bandNoise, decay, normalize, synth } from '@rpg/engine/tools/synth.mjs'; +import { + ashDie, + ashHiss, + bellHit, + bellLow, + chestOpen, + chime, + deerStartle, + hurt, + motePickup, + note, + pickup, + spit, + uiClick, + whoosh +} from '../../../tools/audio/gen.mjs'; import { SPEC_SFX } from '../sfxSpecs'; +/** Максимум модуля разности двух сэмпл-массивов. */ +function maxDiff(a: Float32Array, b: Float32Array): number { + expect(a.length).toBe(b.length); + let max = 0; + for (let i = 0; i < a.length; i++) max = Math.max(max, Math.abs(a[i]! - b[i]!)); + return max; +} + describe('паритет SPEC_SFX с формулами gen.mjs', () => { it('sfx/bell_hit — бит-в-бит равен bellHit()', () => { const spec = renderSpec(SPEC_SFX['sfx/bell_hit']!); @@ -23,4 +49,39 @@ expect(spec.length).toBe(wav.length); for (let i = 0; i < wav.length; i++) expect(spec[i]).toBe(wav[i]); }); + + const EPS = 1e-5; + const CASES: [string, () => Float32Array][] = [ + ['sfx/bell_low', bellLow], + ['sfx/ash_hiss', ashHiss], + ['sfx/ash_die', ashDie], + ['sfx/spit', spit], + ['sfx/hurt', hurt], + ['sfx/mote_pickup', motePickup], + ['sfx/pickup', pickup], + ['sfx/chest_open', chestOpen], + ['sfx/note', note], + ['sfx/deer_startle', deerStartle], + ['sfx/ui_click', uiClick], + ['sfx/chime', chime] + ]; + for (const [key, gen] of CASES) { + it(`${key} — совпадает с формулой gen.mjs (eps ${EPS})`, () => { + expect(maxDiff(renderSpec(SPEC_SFX[key]!), gen())).toBeLessThan(EPS); + }); + } + + it('sfx/step_water — совпадает с формулой всплеска (eps)', () => { + // Формула бывшего sfx/step_water.wav (seed 255 в gen.mjs); у спека + // сид 255+... — нет, базовый спек шагов взял сиды StepVariants + // (253-256), поэтому здесь сверяем саму формулу с сидом 255. + const gen = (): Float32Array => { + const dur = 0.18; + const noise = bandNoise(155, 600, 3200, dur); + const s = synth(dur, (t) => (noise() * 0.6 + Math.sin(2 * Math.PI * 300 * t) * 0.2) * decay(t, dur, 2)); + return normalize(s, 0.3); + }; + const spec: typeof SPEC_SFX[string] = { ...SPEC_SFX['sfx/step_water']!, seed: 155 }; + expect(maxDiff(renderSpec(spec), gen())).toBeLessThan(EPS); + }); }); \ No newline at end of file diff --git a/apps/game/src/data/audio.ts b/apps/game/src/data/audio.ts index bb3bbfa..8c12a8c 100644 --- a/apps/game/src/data/audio.ts +++ b/apps/game/src/data/audio.ts @@ -1,33 +1,16 @@ /** - * Ключи аудио: sfx (WAV из tools/audio/gen.mjs + спек-синтез sfxSpecs.ts) и - * амбиент-лупы областей. Отдельный модуль (без `?url`-импортов Game) — чтобы - * валидатор мог сверять ключи контента с реестром в node-тестах. + * Ключи аудио: спек-синтез sfx (sfxSpecs.ts, WAV не нужен) и файловые лупы + * (ambience из tools/audio/gen.mjs). Отдельный модуль (без `?url`-импортов + * Game) — чтобы валидатор мог сверять ключи контента с реестром в node-тестах. */ import { SPEC_SFX } from './sfxSpecs'; -/** Файловые sfx (WAV). */ -export const SFX_KEYS = [ - 'sfx/bell_low', - 'sfx/ash_hiss', - 'sfx/ash_die', - 'sfx/spit', - 'sfx/hurt', - 'sfx/step_grass', - 'sfx/step_ash', - 'sfx/step_water', - 'sfx/step_floor', - 'sfx/mote_pickup', - 'sfx/pickup', - 'sfx/chest_open', - 'sfx/note', - 'sfx/deer_startle', - 'sfx/ui_click', - 'sfx/chime' -] as const; - /** Спек-синтезированные sfx (без WAV, компилирует движок). */ export const SPEC_SFX_KEYS: readonly string[] = Object.keys(SPEC_SFX); +/** Файловые sfx (WAV) — пусто: все sfx переведены на спек-синтез. */ +export const SFX_KEYS: readonly string[] = []; + /** Амбиент-лупы областей. */ export const AMBIENCE_KEYS = ['ambience/meadows', 'ambience/ponds', 'ambience/zvenets'] as const; diff --git a/apps/game/src/data/sfxSpecs.ts b/apps/game/src/data/sfxSpecs.ts index a786750..eb78016 100644 --- a/apps/game/src/data/sfxSpecs.ts +++ b/apps/game/src/data/sfxSpecs.ts @@ -1,7 +1,8 @@ /** * Sfx на спек-синтезе: ключ → SoundSpec (компилирует движок, WAV не нужен). - * Формулы совпадают с бывшими генераторами tools/audio/gen.mjs дословно — - * парити-тест sfxSpecs.test.ts сверяет renderSpec с функциями gen.mjs. + * Формулы — бывшие генераторы tools/audio/gen.mjs (функции экспортированы + * там же); парити-тест sfxSpecs.test.ts сверяет renderSpec с ними численно, + * поэтому миграция не изменила звучание. */ import type { AudioManager, PlayOptions, SoundSpec } from '@rpg/engine'; @@ -10,7 +11,69 @@ // Бывший sfx/bell_hit.wav (bellHit): 4 партиала + клик, normalize 0.7. 'sfx/bell_hit': { kind: 'chime', dur: 0.35, freq: 520, power: 5, seed: 1, peak: 0.7 }, // Бывший sfx/whoosh.wav (whoosh): воздух «0 → 1 → 0», normalize 0.4. - 'sfx/whoosh': { kind: 'scrape', dur: 0.7, low: 200, high: 2400, seed: 160, swell: true, peak: 0.4 } + 'sfx/whoosh': { kind: 'scrape', dur: 0.7, low: 200, high: 2400, seed: 160, swell: true, peak: 0.4 }, + // Бывший sfx/bell_low.wav (bellLow): три тона с тремоло 6 Гц. + 'sfx/bell_low': { + kind: 'tone', dur: 0.9, freq: 110, w: 0.5, power: 2, peak: 0.6, + wobbleHz: 6, wobbleDepth: 0.3, + layers: [{ freq: 165, w: 0.25 }, { freq: 220.5, w: 0.12 }] + }, + // Бывший sfx/ash_hiss.wav (ashHiss): шипение с нарастанием 0.08 с. + 'sfx/ash_hiss': { kind: 'scrape', dur: 0.4, low: 900, high: 4200, seed: 11, power: 2, attack: 0.08, peak: 0.55 }, + // Бывший sfx/ash_die.wav (ashDie): шум, громкость спадает вдвое к концу. + 'sfx/ash_die': { kind: 'scrape', dur: 0.6, low: 300, high: 2400, seed: 23, power: 3, fadeTo: 0.5, peak: 0.6 }, + // Бывший sfx/spit.wav (spit): тон со свипом 700→350 Гц + шипящий хвост. + 'sfx/spit': { + kind: 'tone', dur: 0.2, freq: 700, freqTo: 350, w: 0.6, power: 3, peak: 0.5, + layers: [{ low: 2000, high: 6000, seed: 37, w: 0.4 }] + }, + // Бывший sfx/hurt.wav (hurt): глухой удар 90 Гц + низкий шум. + 'sfx/hurt': { + kind: 'tone', dur: 0.25, freq: 90, w: 0.8, power: 3, peak: 0.65, + layers: [{ low: 80, high: 400, seed: 41, w: 0.3, dur: 0.125, power: 2 }] + }, + // Бывший sfx/mote_pickup.wav (motePickup): двойной звон 1240+1860 Гц + // (у верхнего партиала суммарная крутизна 4+2.5 — два затухания gen.mjs). + 'sfx/mote_pickup': { + kind: 'tone', dur: 0.45, freq: 1240, w: 0.4, power: 2.5, peak: 0.4, + layers: [{ freq: 1860, w: 0.2, power: 6.5 }] + }, + // Бывший sfx/pickup.wav (pickup): «пок» со взлётом 520→740 Гц. + 'sfx/pickup': { kind: 'tone', dur: 0.14, freq: 520, freqTo: 740, power: 2.5, peak: 0.4 }, + // Бывший sfx/chest_open.wav (chestOpen): скрип петель + удар крышки 110 Гц. + 'sfx/chest_open': { + kind: 'scrape', dur: 0.55, low: 300, high: 1400, seed: 157, power: 1.5, w: 0.35, attack: 0.1, peak: 0.45, + layers: [{ freq: 110, w: 0.7, dur: 0.12, power: 2 }] + }, + // Бывший sfx/note.wav (note): шелест с гулким «воблером» 3.2 Гц. + 'sfx/note': { + kind: 'scrape', dur: 0.35, low: 1800, high: 6000, seed: 159, power: 1.5, peak: 0.3, + wobbleHz: 3.2, wobbleDepth: 0.6, wobbleBase: 0.4, wobblePhase: 1 + }, + // Бывший sfx/deer_startle.wav (deerStartle): выдох + стук копыт (блип 90 Гц). + 'sfx/deer_startle': { + kind: 'scrape', dur: 0.4, low: 150, high: 900, seed: 161, power: 2, w: 0.7, attack: 0.05, peak: 0.4, + layers: [{ freq: 90, w: 0.6, dur: 0.08, delay: 0.2 }] + }, + // Бывший sfx/ui_click.wav (uiClick): короткий чистый тон 880 Гц. + 'sfx/ui_click': { kind: 'tone', dur: 0.06, freq: 880, power: 3, peak: 0.4 }, + // Бывший sfx/chime.wav (chime): колокольчик Милы, 1560 Гц + партиал 1.61×. + 'sfx/chime': { + kind: 'tone', dur: 0.5, freq: 1560, w: 0.5, power: 3, peak: 0.45, + layers: [{ freq: 1560 * 1.61, w: 0.25 }] + }, + // Шаги (бывшие sfx/step_*.wav): до unlock играет спек, варианты — + // renderSpec с сидами seed+i·10 (см. systems/StepVariants.ts). + 'sfx/step_grass': { kind: 'hit', dur: 0.12, low: 400, high: 2000, seed: 253, power: 2, peak: 0.3 }, + 'sfx/step_ash': { kind: 'hit', dur: 0.14, low: 200, high: 1100, seed: 254, power: 2.5, peak: 0.3 }, + 'sfx/step_water': { + kind: 'hit', dur: 0.18, low: 600, high: 3200, seed: 255, power: 2, w: 0.6, peak: 0.3, + layers: [{ freq: 300, w: 0.2 }] + }, + 'sfx/step_floor': { + kind: 'hit', dur: 0.1, low: 150, high: 800, seed: 256, power: 3, w: 0.4, peak: 0.3, + layers: [{ freq: 140, w: 0.5 }] + } }; /** diff --git a/apps/game/src/scenes/BootScene.ts b/apps/game/src/scenes/BootScene.ts index 152b374..9776e29 100644 --- a/apps/game/src/scenes/BootScene.ts +++ b/apps/game/src/scenes/BootScene.ts @@ -1,7 +1,7 @@ import { Container, Graphics, PixelText, ensurePixelFont, type Scene } from '@rpg/engine'; import { Game } from '../Game'; import { AREAS } from '../data/locations'; -import { AMBIENCE_KEYS, AMBIENCE_LAYER_KEYS, SFX_KEYS } from '../data/audio'; +import { AMBIENCE_KEYS, AMBIENCE_LAYER_KEYS } from '../data/audio'; import { MenuScene } from './MenuScene'; /** @@ -51,12 +51,8 @@ }) .then(() => ensurePixelFont(Game.FONT_URL)) // Звуки: декодируем заранее, чтобы первый play не тормозил. - // Спек-звуки (SPEC_SFX_KEYS) не грузим — их компилирует движок. + // Sfx на спек-синтезе не грузим — их компилирует движок. .then(async () => { - for (const key of SFX_KEYS) { - await this.game.audio.load(key); - await this.game.audio.preload(key); - } for (const key of [...AMBIENCE_KEYS, ...AMBIENCE_LAYER_KEYS]) { await this.game.audio.load(key); await this.game.audio.preload(key); diff --git a/apps/game/src/scenes/InventoryScene.ts b/apps/game/src/scenes/InventoryScene.ts index be34ebf..17a26d4 100644 --- a/apps/game/src/scenes/InventoryScene.ts +++ b/apps/game/src/scenes/InventoryScene.ts @@ -1,3 +1,4 @@ +import { playSfx } from '../data/sfxSpecs'; import { Container, Panel, PixelText, type Scene } from '@rpg/engine'; import { Game } from '../Game'; import { inventoryItems, questLog } from '../data/quests'; @@ -62,7 +63,7 @@ if (this.game.scenes.transitioning) return; const input = this.game.engine.input; if (input.isActionJustPressed('menu') || input.isActionJustPressed('inventory')) { - void this.game.audio.play('sfx/ui_click'); + playSfx(this.game.audio, 'sfx/ui_click'); this.onBack(); } } diff --git a/apps/game/src/scenes/LocationScene.ts b/apps/game/src/scenes/LocationScene.ts index 4a4df65..0025721 100644 --- a/apps/game/src/scenes/LocationScene.ts +++ b/apps/game/src/scenes/LocationScene.ts @@ -289,7 +289,7 @@ worldRootOffset: () => this.game.renderer.worldRoot.position, callbacks: { showToast: (t) => this.showToast(t), - playUiClick: () => void this.game.audio.play('sfx/ui_click', 0.4), + playUiClick: () => playSfx(this.game.audio, 'sfx/ui_click', 0.4), talkTo: (d) => this.talkTo(d), collectFlower: (x, y) => this.collectFlower(x, y), useTransition: (d) => this.useTransition(d), @@ -464,7 +464,7 @@ } if (input.isActionJustPressed('inventory')) { - void this.game.audio.play('sfx/ui_click'); + playSfx(this.game.audio, 'sfx/ui_click'); void this.game.scenes.push( new InventoryScene(this.game, () => void this.game.scenes.pop()), { duration: 0.2 } diff --git a/apps/game/src/scenes/MenuScene.ts b/apps/game/src/scenes/MenuScene.ts index d0a6eb5..94ecfa1 100644 --- a/apps/game/src/scenes/MenuScene.ts +++ b/apps/game/src/scenes/MenuScene.ts @@ -1,3 +1,4 @@ +import { playSfx } from '../data/sfxSpecs'; import { Container, MenuList, @@ -48,14 +49,14 @@ label: 'Новая игра', // Звук не блокирует навигацию: в suspended-контексте play может не дойти. onSelect: () => { - void this.game.audio.play('sfx/ui_click'); + playSfx(this.game.audio, 'sfx/ui_click'); this.startNewGame(); } }, { label: 'Сейвы', onSelect: () => { - void this.game.audio.play('sfx/ui_click'); + playSfx(this.game.audio, 'sfx/ui_click'); void this.game.scenes.push( new SaveSlotsScene( this.game, @@ -68,7 +69,7 @@ { label: 'Настройки', onSelect: () => { - void this.game.audio.play('sfx/ui_click'); + playSfx(this.game.audio, 'sfx/ui_click'); void this.game.scenes.push( new SettingsScene(this.game, () => void this.game.scenes.pop()) ); @@ -79,7 +80,7 @@ items.unshift({ label: 'Продолжить', onSelect: () => { - void this.game.audio.play('sfx/ui_click'); + playSfx(this.game.audio, 'sfx/ui_click'); const save = this.game.saves.load('autosave'); this.start(save); } diff --git a/apps/game/src/scenes/SaveSlotsScene.ts b/apps/game/src/scenes/SaveSlotsScene.ts index 074978c..3cd1738 100644 --- a/apps/game/src/scenes/SaveSlotsScene.ts +++ b/apps/game/src/scenes/SaveSlotsScene.ts @@ -1,3 +1,4 @@ +import { playSfx } from '../data/sfxSpecs'; import { Container, MenuList, Panel, PixelText, type Scene } from '@rpg/engine'; import { Game } from '../Game'; import type { SaveData } from './MenuScene'; @@ -72,7 +73,7 @@ items.push({ label: `${this.slotTitle(slot)} ${loc} ${when}`, onSelect: () => { - void this.game.audio.play('sfx/ui_click'); + playSfx(this.game.audio, 'sfx/ui_click'); this.onPick(save); } }); @@ -86,7 +87,7 @@ const slot = ['autosave', 'slot1', 'slot2', 'slot3'][index]; if (!slot || slot === 'autosave' || !this.game.saves.listSlots().includes(slot)) return; this.game.saves.delete(slot); - void this.game.audio.play('sfx/ui_click'); + playSfx(this.game.audio, 'sfx/ui_click'); this.rebuild(); } @@ -96,7 +97,7 @@ } private back(): void { - void this.game.audio.play('sfx/ui_click'); + playSfx(this.game.audio, 'sfx/ui_click'); this.onBack(); } } \ No newline at end of file diff --git a/apps/game/src/scenes/SettingsScene.ts b/apps/game/src/scenes/SettingsScene.ts index e2a77fa..f7a35aa 100644 --- a/apps/game/src/scenes/SettingsScene.ts +++ b/apps/game/src/scenes/SettingsScene.ts @@ -1,3 +1,4 @@ +import { playSfx } from '../data/sfxSpecs'; import { Container, MenuList, @@ -86,12 +87,12 @@ const key = (['master', 'ambience', 'sfx'] as const)[index]; const value = Math.round(Math.min(1, Math.max(0, s[key] + delta)) * 10) / 10; this.game.settings.update({ [key]: value }); - if (delta > 0) void this.game.audio.play('sfx/ui_click'); + if (delta > 0) playSfx(this.game.audio, 'sfx/ui_click'); this.rebuild(); } private back(): void { - void this.game.audio.play('sfx/ui_click'); + playSfx(this.game.audio, 'sfx/ui_click'); this.onBack(); } } diff --git a/apps/game/src/systems/StepVariants.ts b/apps/game/src/systems/StepVariants.ts index 4ffa703..911dc18 100644 --- a/apps/game/src/systems/StepVariants.ts +++ b/apps/game/src/systems/StepVariants.ts @@ -1,25 +1,18 @@ +import { renderSpec } from '@rpg/engine'; import type { AudioManager } from '@rpg/engine'; -import { bandNoise, decay, normalize, synth } from '@rpg/engine/tools/synth.mjs'; +import { SPEC_SFX, playSfx } from '../data/sfxSpecs'; /** - * Вариативные шаги: 3 процедурных варианта на поверхность, синтезируются - * в рантайме из примитивов wav.mjs (те же полосы/огибающие, что в - * tools/audio/gen.mjs, сиды смещены — звучат похоже, но не идентично WAV). - * Выбор варианта — по кругу. До unlock буферы не создать (createBuffer → - * null) — играем файловый шаг, варианты синтезируются заново при следующих - * шагах, пока не получится (обычно вторая попытка, после первого play). + * Вариативные шаги: 3 варианта на поверхность, компилируются в рантайме + * из спеков sfxSpecs.ts (сид варианта = базовый + i·10 — звучат похоже, + * но не идентично). Выбор варианта — по кругу. До 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 { @@ -29,7 +22,7 @@ constructor(private audio: AudioManager) {} - /** Играть очередной вариант шага (до готовности буферов — файловый шаг). */ + /** Играть очередной вариант шага (до готовности буферов — базовый спек). */ play(surface: StepSurface, volume: number): void { const key = `sfx/step_${surface}`; const list = this.ensure(surface); @@ -37,7 +30,7 @@ const i = this.seq++ % list.length; void this.audio.playBuffer(`${key}#${i}`, list[i]!, { volume }); } else { - void this.audio.play(key, { volume }); + playSfx(this.audio, key, { volume }); } } @@ -45,18 +38,11 @@ private ensure(surface: StepSurface): AudioBuffer[] { const existing = this.cache.get(surface); if (existing) return existing; - const s = SURFACES[surface]; + const base = SPEC_SFX[`sfx/step_${surface}`]; + if (!base) return []; 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); + const buf = this.audio.createBuffer(renderSpec({ ...base, seed: (base.seed ?? 1) + i * 10 }), 22050); if (!buf) return []; // контекста нет — попробуем на следующем шаге list.push(buf); } diff --git a/apps/game/tools/audio/gen.d.mts b/apps/game/tools/audio/gen.d.mts index 207fd95..4315d44 100644 --- a/apps/game/tools/audio/gen.d.mts +++ b/apps/game/tools/audio/gen.d.mts @@ -1,3 +1,15 @@ /** Типы для парити-теста sfxSpecs: экспортируемые формулы gen.mjs. */ export declare function bellHit(): Float32Array; -export declare function whoosh(): Float32Array; \ No newline at end of file +export declare function bellLow(): Float32Array; +export declare function ashHiss(): Float32Array; +export declare function ashDie(): Float32Array; +export declare function spit(): Float32Array; +export declare function hurt(): Float32Array; +export declare function motePickup(): Float32Array; +export declare function pickup(): Float32Array; +export declare function chestOpen(): Float32Array; +export declare function note(): Float32Array; +export declare function whoosh(): Float32Array; +export declare function deerStartle(): Float32Array; +export declare function uiClick(): Float32Array; +export declare function chime(): Float32Array; diff --git a/apps/game/tools/audio/gen.mjs b/apps/game/tools/audio/gen.mjs index 0ca8e77..d2efb31 100644 --- a/apps/game/tools/audio/gen.mjs +++ b/apps/game/tools/audio/gen.mjs @@ -1,10 +1,14 @@ /** - * Генератор звуков «Пепельных лугов» — WAV из кода, без зависимостей. + * Генератор амбиента «Пепельных лугов» — WAV из кода, без зависимостей. * Запуск: node tools/audio/gen.mjs - * Выход: apps/game/assets/audio/sfx/*.wav, apps/game/assets/audio/ambience/*.wav + * Выход: apps/game/assets/audio/ambience/*.wav * - * Формат: моно, 22050 Гц, 16-бит PCM (хватает для пиксель-арта со звуком, - * файлы вдвое меньше). Меланхоличный пепельный саунд: глухие удары, шум, низкие пады. + * Все sfx переехали на спек-синтез (data/sfxSpecs.ts, компилирует движок) — + * здесь остались только многослойные амбиент-лупы. Формулы бывших sfx + * экспортированы ниже: их сверяет с рендером спеков парити-тест + * data/__tests__/sfxSpecs.test.ts (звучание мигрированных звуков не менялось). + * + * Формат: моно, 22050 Гц, 16-бит PCM. Меланхоличный пепельный саунд. * WAV-энкодер и примитивы синтеза — жанронезависимые, в @rpg/engine/tools/wav.mjs. */ import { mkdirSync, writeFileSync } from 'node:fs'; @@ -32,7 +36,7 @@ } /** Низкий резонанс: медленный гул с тремоло — «усыпляет пепел». */ -function bellLow() { +export function bellLow() { const dur = 0.9; const s = synth(dur, (t) => { const trem = 1 + 0.3 * Math.sin(2 * Math.PI * 6 * t); @@ -46,7 +50,7 @@ } /** Шипение: сгусток просыпается. */ -function ashHiss() { +export function ashHiss() { const dur = 0.4; const noise = bandNoise(11, 900, 4200, dur); const s = synth(dur, (t) => noise() * Math.min(1, t / 0.08) * decay(t, dur, 2)); @@ -54,7 +58,7 @@ } /** Смерть сгустка: шум с падающим «выдохом». */ -function ashDie() { +export function ashDie() { const dur = 0.6; const noise = bandNoise(23, 300, 2400, dur); const s = synth(dur, (t) => { @@ -65,7 +69,7 @@ } /** Плевок: синус с падающей высотой + шипящий хвост. */ -function spit() { +export function spit() { const dur = 0.2; const noise = bandNoise(37, 2000, 6000, dur); const s = synth(dur, (t) => { @@ -76,7 +80,7 @@ } /** Урон герою: глухой низкий удар. */ -function hurt() { +export function hurt() { const dur = 0.25; const noise = bandNoise(41, 80, 400, dur); const s = synth(dur, (t) => { @@ -86,40 +90,8 @@ return normalize(s, 0.65); } -/** Шаг по траве: глухой шорох стеблей. */ -function stepGrass() { - const dur = 0.12; - const noise = bandNoise(153, 400, 2000, dur); - const s = synth(dur, (t) => noise() * decay(t, dur, 2) * 0.5); - return normalize(s, 0.3); -} - -/** Шаг по пеплу: низкое шуршание, чуть длиннее травы. */ -function stepAsh() { - const dur = 0.14; - const noise = bandNoise(154, 200, 1100, dur); - const s = synth(dur, (t) => noise() * decay(t, dur, 2.5) * 0.5); - return normalize(s, 0.3); -} - -/** Шаг по воде: короткий всплеск. */ -function stepWater() { - const dur = 0.18; - const noise = bandNoise(155, 600, 3200, dur); - const s = synth(dur, (t) => (noise() * 0.6 + Math.sin(2 * Math.PI * 300 * t) * 0.2) * decay(t, dur, 2)); - return normalize(s, 0.3); -} - -/** Шаг по полу/тропе: глухой стук. */ -function stepFloor() { - const dur = 0.1; - const noise = bandNoise(156, 150, 800, dur); - const s = synth(dur, (t) => (noise() * 0.4 + Math.sin(2 * Math.PI * 140 * t) * 0.5) * decay(t, dur, 3)); - return normalize(s, 0.3); -} - /** Мот подобран: мерцающий двойной звон (тёплая искра в серой земле). */ -function motePickup() { +export function motePickup() { const dur = 0.45; const s = synth(dur, (t) => { const body = @@ -131,7 +103,7 @@ } /** Предмет в сумку: мягкий «пок». */ -function pickup() { +export function pickup() { const dur = 0.14; const s = synth(dur, (t) => { const f = 520 + 220 * (t / dur); // чуть взлетает @@ -141,7 +113,7 @@ } /** Сундук открылся: низкий удар крышки + скрип петель. */ -function chestOpen() { +export function chestOpen() { const dur = 0.55; const noise = bandNoise(157, 300, 1400, dur); const s = synth(dur, (t) => { @@ -153,7 +125,7 @@ } /** Записка: шелест бумаги. */ -function note() { +export function note() { const dur = 0.35; const noise = bandNoise(159, 1800, 6000, dur); const s = synth(dur, (t) => noise() * (0.4 + 0.6 * Math.sin(2 * Math.PI * 3.2 * t + 1)) * decay(t, dur, 1.5)); @@ -173,7 +145,7 @@ } /** Олень вспугнут: глухой выдох + стук копыт. */ -function deerStartle() { +export function deerStartle() { const dur = 0.4; const noise = bandNoise(161, 150, 900, dur); const s = synth(dur, (t) => { @@ -185,14 +157,14 @@ } /** Клик UI. */ -function uiClick() { +export function uiClick() { const dur = 0.06; const s = synth(dur, (t) => Math.sin(2 * Math.PI * 880 * t) * decay(t, dur, 3) * 0.5); return normalize(s, 0.4); } /** Колокольчик Милы: высокий, почти хрустальный. */ -function chime() { +export function chime() { const dur = 0.5; const s = synth(dur, (t) => { const body = @@ -326,23 +298,7 @@ // ---------- запись ---------- -const SFX = { - 'sfx/bell_low': bellLow, - 'sfx/ash_hiss': ashHiss, - 'sfx/ash_die': ashDie, - 'sfx/spit': spit, - 'sfx/hurt': hurt, - 'sfx/step_grass': stepGrass, - 'sfx/step_ash': stepAsh, - 'sfx/step_water': stepWater, - 'sfx/step_floor': stepFloor, - 'sfx/mote_pickup': motePickup, - 'sfx/pickup': pickup, - 'sfx/chest_open': chestOpen, - 'sfx/note': note, - 'sfx/deer_startle': deerStartle, - 'sfx/ui_click': uiClick, - 'sfx/chime': chime, +const AMBIENCE = { 'ambience/meadows': ambienceMeadows, 'ambience/ponds': ambiencePonds, 'ambience/zvenets': ambienceZvenets, @@ -350,13 +306,11 @@ 'ambience/tower_hum': layerTowerHum, 'ambience/houses': layerHouses }; - // Записываем WAV только при запуске как CLI, а не при импорте (парити-тест // импортирует формулы bellHit/whoosh, чтобы сверить их со спек-синтезом). if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { - mkdirSync(`${OUT}sfx`, { recursive: true }); mkdirSync(`${OUT}ambience`, { recursive: true }); - for (const [name, gen] of Object.entries(SFX)) { + for (const [name, gen] of Object.entries(AMBIENCE)) { const buf = encodeWav(gen()); writeFileSync(`${OUT}${name}.wav`, buf); console.log(`${name}.wav ${(buf.length / 1024).toFixed(1)} КБ`); diff --git a/docs/engine/agent.md b/docs/engine/agent.md index 4f86a29..63516fd 100644 --- a/docs/engine/agent.md +++ b/docs/engine/agent.md @@ -62,12 +62,16 @@ { "kind": "chime", "dur": 0.6, "freq": 880 } { "kind": "scrape", "dur": 0.5, "low": 80, "high": 400, "tone": 60 } { "kind": "hum", "dur": 8, "tone": 55, "power": 2 } +{ "kind": "tone", "dur": 0.15, "freq": 700, "freqTo": 350, "power": 3 } ``` `kind` — форма звука (`hit` удар / `chime` звон / `scrape` скрип, дверь-куст / -`hum` гул-луп), `dur` в секундах — обязательны; остальное — параметры с -дефолтами (`low`/`high` полоса шума, `tone` низкая подложка, `freq` частота -звона, `power` крутизна спада, `seed` — детерминизм, `peak`, `loop`). Ключ +`hum` гул-луп / `tone` чистый тон, со свипом `freqTo`), `dur` в секундах — +обязательны; остальное — параметры с дефолтами (`low`/`high` полоса шума, +`tone` низкая подложка, `freq` частота тона/звона, `power` крутизна спада, +`attack` нарастание, `fadeTo` линейный спад громкости, `wobbleHz`/`wobbleDepth` +тремоло, `layers` доп. голоса `[{freq|low/high, w, dur, delay}]`, `seed` — +детерминизм, `peak`, `loop`). Полный набор — `assets-audio-save.md`, «Спек-синтез». Ключ голоса по умолчанию `agent/synth` — факт запуска ищется в DEV-логе `window.__gameAudioLog`. Спек с неизвестным `kind` или без `dur` → команда вернёт `null`. Команда вне whitelist возвращает `null` — расширять осознанно. diff --git a/docs/engine/assets-audio-save.md b/docs/engine/assets-audio-save.md index 3f887d6..fb928c0 100644 --- a/docs/engine/assets-audio-save.md +++ b/docs/engine/assets-audio-save.md @@ -119,7 +119,7 @@ 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') +// до unlock буфер не создать — fallback на базовый спек audio.playSpec('sfx/step_grass') ``` ### Спек-синтез: playSpec (звук по описанию) @@ -134,25 +134,34 @@ ```ts // kind — форма звука; остальное — параметры с дефолтами по kind await audio.playSpec('agent/door', { - kind: 'scrape', // hit | chime | scrape | hum + kind: 'scrape', // hit | chime | scrape | hum | tone dur: 0.5, // сек (клэмп 0.05..4; hum до 8) low: 80, high: 400, // полоса шума (hit/scrape) tone: 60, // низкая подложка / основной тон гула power: 1.5, // крутизна затухания / «дыхание» hum seed: 7, // сид шума/клика — детерминизм peak: 0.5, // нормализация 0..1 - swell: false // огибающая «0 → 1 → 0» вместо спада (воздух, свуш) + swell: false, // огибающая «0 → 1 → 0» вместо спада (воздух, свуш) + attack: 0.08, // нарастание, сек (шипение, скрип) + fadeTo: 0.5, // линейный спад громкости к этому множителю к концу + wobbleHz: 6, // тремоло: амплитуда гуляет wobbleDepth вокруг wobbleBase + wobbleDepth: 0.3, + layers: [{ freq: 165, w: 0.25 }] // доп. голоса: тон (freq) или шум (low/high) }); await audio.playSpec('agent/coin', { kind: 'chime', dur: 0.6, freq: 880 }, 0.4); +await audio.playSpec('agent/pew', { kind: 'tone', dur: 0.2, freq: 700, freqTo: 350, power: 3 }); ``` Формы: `hit` — полоса шума с резким спадом + низкая подложка (удары); `chime` — четыре негармоничных партиала + клик (звон, монеты, подбор); `scrape` — низкая полоса с медленным спадом (дверь, куст, ветка), `swell: true` превращает спад в «0 → 1 → 0» (воздух, свуш); `hum` — два тона -(tone и tone×1.5) на «дыхании», луп по умолчанию (гул, эмбиент-слой). -Тот же `renderSpec` экспортируется из публичного API — можно компилировать -в сэмплы без воспроизведения (офлайн-генерация, тесты). +(tone и tone×1.5) на «дыхании», луп по умолчанию (гул, эмбиент-слой); +`tone` — чистый синус `freq` (со свипом `freqTo` — «пью», «пок») без шума. +Голос слоя (`SpecVoice`): `freq` или полоса `low`/`high`, вес `w`, своя +`power`/`dur`, `attack`, `delay` (блип в конце — стук копыт). Вес основного +тела — `w` спека. Тот же `renderSpec` экспортируется из публичного API — +можно компилировать в сэмплы без воспроизведения (офлайн-генерация, тесты). Типы для `synth.mjs` лежат рядом в `synth.d.mts` — декларация **рядом с .mjs** (ambient `declare module` не работает: TS резолвит specifier к реальному diff --git a/docs/engine/practices.md b/docs/engine/practices.md index fd2fbfc..3101a2f 100644 --- a/docs/engine/practices.md +++ b/docs/engine/practices.md @@ -168,49 +168,53 @@ ## Ситуация: добавляю звук -1. Генерация: функция-синтез в `apps/game/tools/audio/gen.mjs` (примитивы - движкового `@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-тест ловит - «ключ в контенте — файла нет» и «файл есть — ключа нет». -3. Как играть: разовый sfx — `audio.play(key, {volume, rate, pan})` (число = - громкость; вернёт `SfxHandle` — параметры в полёте и stop; `restart: true` - для «одиночного» звука вместо наслоения экземпляров); позиционный — +1. **sfx — спек-синтез (файлов больше нет)**: ключ + `SoundSpec` в реестр + `SPEC_SFX` (`data/sfxSpecs.ts`), play-сайт зовёт хелпер + `playSfx(audio, key, opts)` (не `audio.play` напрямую). Спек компилирует + движок (`renderSpec`, kind `hit`/`chime`/`scrape`/`hum`/`tone` + параметры + `freqTo`/`w`/`attack`/`fadeTo`/`wobble*`/`layers` — см. «Спек-синтез» в + assets-audio-save.md); ключи попадают в `AUDIO_KEYS` через + `SPEC_SFX_KEYS` — validate-тест ловит ключ контента вне реестра. Новый + звук по формуле бывшего WAV (или «как соседний») — формулы бывшего + генератора экспортированы в `tools/audio/gen.mjs`, парити-тест + `sfxSpecs.test.ts` сверяет `renderSpec` с формулой численно (бит-в-бит + или eps 1e-5): генератор при импорте WAV не пишет (CLI-гвард + `import.meta.url === pathToFileURL(process.argv[1]).href`). +2. **Файловые лупы (амбиент)**: функция-синтез в + `apps/game/tools/audio/gen.mjs` (примитивы движкового + `@rpg/engine/tools/synth.mjs` — bandNoise, bellPartial, synth, decay, + loopify; сиды фиксированы — звук детерминирован) + запись в словарь + `AMBIENCE`; `npm run audio` перегенерирует WAV в + `apps/game/assets/audio/ambience/`. +3. Ключ лупа — в реестр `apps/game/src/data/audio.ts` (`AMBIENCE_KEYS` / + `AMBIENCE_LAYER_KEYS`): BootScene префетчит оттуда (спек-ключи не + префетчатся — их компилирует движок на месте). +4. Как играть: разовый sfx — `playSfx(audio, key, {volume, rate, pan})` + (число = громкость; `restart: true` для «одиночного» звука вместо + наслоения экземпляров); позиционный — `worldAudio.playAt(key, pos, base, radius)` (затухание + панорама от героя, тишина за радиусом); шаги — `worldAudio.playStep(surface, ...)`, где surface — `'grass'|'ash'|'water'| - 'floor'` (вариативные процедурные шаги из `systems/StepVariants.ts` — - 3 буфера на поверхность, до unlock движок играет файловый шаг). + 'floor'` (варианты — `renderSpec` с сидами base+i·10 в + `systems/StepVariants.ts`, до unlock играет базовый спек). Событийные звуки боя/фауны — подпиской в `AudioSystem.attach()` на `combat:*` / `fauna:startle` (симуляция чиста от аудио). Лупы областей — `AreaDef.ambience`, локальные слои (вода, гул) — `AreaDef.ambienceLayers` + тик `worldAudio.setLayers`. Полифонию ограничивает движок (`maxVoices`, воровство тихих голосов) — пер-ключевой интервал в `AudioSystem` нужен только против «пулемёта» - одного ключа. Звук **без файла вообще** — спек-синтез: ключ в реестр - `SPEC_SFX` (`data/sfxSpecs.ts`, ключи попадают в `AUDIO_KEYS` через - `SPEC_SFX_KEYS`), play-сайт зовёт хелпер `playSfx(audio, key, opts)` — - он сам разрулит спек-ключ и файловый (не звать `audio.play` напрямую). - Формулы бывшего WAV-генератора переносятся в спек дословно — парити-тест - `sfxSpecs.test.ts` сверяет `renderSpec` с функцией gen.mjs бит-в-бит - (генератор экспортирует формулу, но WAV при импорте не пишет — CLI-гвард - `import.meta.url === pathToFileURL(process.argv[1]).href`). Спек - `SoundSpec` (kind `hit`/`chime`/`scrape`/`hum` + параметры) движок - детерминированно компилирует сам — путь для ИИ-агента и быстрых прототипов - (агентный доступ — op `scene:synthesize`, образец — `checks/synth.mjs`). + одного ключа. Агентный доступ — op `scene:synthesize`, образец — + `checks/synth.mjs`. Музыка: спек-партитура `MusicSpec` → `renderMusic` → `createBuffer` → `playLoopBuffer` (см. «Спек-синтез» и «Музыка» в assets-audio-save.md) — генератор нот с сидом в `data/music.ts`, у области — поле `theme`, у Game — `playTheme(key)` с guard-ом и кроссфейдом; WAV для музыки не нужен. -4. На слух в headless не проверить — смотреть фактические запуски: DEV-шпион +5. На слух в headless не проверить — смотреть фактические запуски: DEV-шпион `window.__gameAudioLog` (кольцо на 24, `t`/`volume`/`pan`) читается из страницы; образец — `apps/game/tools/checks/audio.mjs`. После действия - опрашивай лог с паузами (`waitAudio`): декод WAV занимает заметное время, - шаг(60) может не хватить. -5. Грабли: `ctx.resume()` без флага `--autoplay-policy=no-user-gesture-required` + опрашивай лог с паузами (`waitAudio`): компиляция спека/декод WAV + занимает заметное время, шаг(60) может не хватить. +6. Грабли: `ctx.resume()` без флага `--autoplay-policy=no-user-gesture-required` в headless не резолвится (см. «Грабли среды»); амбиент не должен перезапускаться на том же ключе — guard в `Game.playAmbience`. diff --git a/docs/plan.md b/docs/plan.md index c4f5e45..1cfaac4 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -24,7 +24,7 @@ | 5 | Замена сгенерированных плейсхолдеров арта на AI/ручной арт | в составе работы B | | 6 | Крупные объекты: footprint в движке, слоты арта (работа C, ниже) | в работе | | 7 | Возможности аудио-движка (работа D, ниже): sfx-хендл, полифония, наблюдаемость, буферы | ✅ D0–D2 сделаны (2026-09-07) | -| 8 | Синтез звука по запросу для агента (работа E, ниже) | ✅ E1–E3 сделаны (2026-09-07) | +| 8 | Синтез звука по запросу для агента (работа E, ниже) | ✅ E1–E4 сделаны (2026-09-07) | | 9 | Инфраструктура аудио + музыкальный слой (работы F/G, ниже) | ✅ сделаны (2026-09-07) | Отложено: **IsoLayout как параметр игры** — `DEFAULT_ISO` в `math/iso.ts` остаётся @@ -329,6 +329,16 @@ playSpec), документация форм спеков в `docs/engine/agent.md`; проба `checks/synth.mjs` (в `agent:check`) — агент слепо синтезирует удар/звон/ скрип/гул, проверка фактов запуска в аудио-логе + отрицательные случаи. +- **E4 (палитра спеков + полная миграция) ✅ (2026-09-07):** новый kind + `tone` (чистый тон, свип `freqTo`) и общие параметры `w`/`attack`/`fadeTo`/ + `wobbleHz|Depth|Base|Phase` (тремоло) / `layers` (доп. голоса `SpecVoice` с + `delay`). **Все файловые SFX переведены на спеки** (16 ключей в + `data/sfxSpecs.ts`, включая шаги — `StepVariants` рендерит варианты + `renderSpec` с сидами base+i·10); `tools/audio/gen.mjs` остался только + амбиенту, каталог `assets/audio/sfx/` удалён. Парити-тест `sfxSpecs.test.ts` + сверяет каждый спек с формулой бывшего генератора (bit-в-бит или eps 1e-5) — + звучание игры не изменилось. Ауди-тулза игры больше не нужна для новых + sfx: ключ + спек в `SPEC_SFX` + `playSfx` на play-сайте. - **E3 (контент) ✅ (2026-09-07): миграция файловых SFX на спеки.** `sfx/bell_hit` и `sfx/whoosh` — теперь спек-синтез в `data/sfxSpecs.ts` (реестр `SPEC_SFX` + хелпер `playSfx`, единая точка проигрывания sfx); diff --git a/packages/engine/src/audio/SoundSpec.ts b/packages/engine/src/audio/SoundSpec.ts index d347016..bcd5141 100644 --- a/packages/engine/src/audio/SoundSpec.ts +++ b/packages/engine/src/audio/SoundSpec.ts @@ -18,7 +18,31 @@ * (tools/synth.mjs), поэтому спек-звук и WAV-звук звучат в одной палитре. */ -export type SoundKind = 'hit' | 'chime' | 'scrape' | 'hum'; +export type SoundKind = 'hit' | 'chime' | 'scrape' | 'hum' | 'tone'; + +/** + * Дополнительный голос смеси: шум (low/high) или тон (freq) со своей + * огибающей. Параметры не заданы — наследуются от спека. + */ +export interface SpecVoice { + /** Тон голоса, Гц (чистый синус; вместе с delay — «блип» в конце). */ + freq?: number; + /** Полоса шума, Гц (шумовой голос). */ + low?: number; + high?: number; + /** Вес в смеси (дефолт 1). */ + w?: number; + /** Своя крутизна затухания (по умолчанию — power спека). */ + power?: number; + /** Своя длительность, сек (по умолчанию — dur спека). */ + dur?: number; + /** Нарастание, сек (min(1, t/attack) от старта голоса). */ + attack?: number; + /** Задержка старта, сек — до неё голос молчит. */ + delay?: number; + /** Свой сид шума (по умолчанию — seed спека). */ + seed?: number; +} export interface SoundSpec { kind: SoundKind; @@ -29,10 +53,27 @@ high?: number; /** Низкий тон, Гц: подложка удара/шороха или основной тон гула. */ tone?: number; - /** Частота звона, Гц (chime; дефолт 520). */ + /** Частота тона/звона, Гц (tone/chime; дефолты по kind). */ freq?: number; + /** Конечная частота линейного свипа (tone): f = freq + (freqTo−freq)·t/dur. */ + freqTo?: number; + /** Вес основного тела в смеси (дефолт 1). */ + w?: number; /** Крутизна затухания (дефолты по kind). */ power?: number; + /** Нарастание основного тела, сек (min(1, t/attack)) — шипение, скрип. */ + attack?: number; + /** Линейный спад амплитуды к этому множителю к концу (1 → fadeTo). */ + fadeTo?: number; + /** Тремоло, Гц: множитель wobbleBase + wobbleDepth·sin(2π·wobbleHz·t + фаза). */ + wobbleHz?: number; + wobbleDepth?: number; + /** База тремоло (дефолт 1 — амплитуда гуляет вокруг единицы). */ + wobbleBase?: number; + /** Фаза тремоло, рад (дефолт 0). */ + wobblePhase?: number; + /** Дополнительные голоса поверх основного тела (см. SpecVoice). */ + layers?: SpecVoice[]; /** Сид шума/клика — детерминизм (дефолт 1). */ seed?: number; /** Пик нормализации 0..1 (дефолт 0.5). */ @@ -53,63 +94,118 @@ : (t) => decay(t, dur, power); } -/** Шумовой удар: полоса шума с резким спадом; tone — низкая подложка. */ -function renderHit(spec: SoundSpec, dur: number): Float32Array { - const noise = bandNoise(spec.seed ?? 1, spec.low ?? 600, spec.high ?? 3000, dur); - const env = envelope(spec, dur, spec.power ?? 2); - const tone = spec.tone ?? 0; +/** Нарастание: линейное от старта до полной громкости за attack секунд. */ +const attackEnv = (t: number, attack: number | undefined): number => + attack ? Math.min(1, t / attack) : 1; + +/** + * Микс: основное тело + доп. голоса (layers), поверх — тремоло и линейный + * спад. Без модификаторов совпадает с normalize(synth(dur, base), peak) + * бит-в-бит (множители не применяются вовсе). + */ +function renderMix(spec: SoundSpec, dur: number, base: (t: number) => number): Float32Array { + const voices = (spec.layers ?? []).map((l) => makeVoice(l, spec, dur)); + const wobbleHz = spec.wobbleHz; + const wobbleAmp = spec.wobbleDepth ?? 0; + const wobbleBase = spec.wobbleBase ?? 1; + const wobblePhase = spec.wobblePhase ?? 0; + const fadeTo = spec.fadeTo; return normalize( synth(dur, (t) => { - let v = noise() * env(t); - if (tone > 0) v += Math.sin(2 * Math.PI * tone * t) * decay(t, dur, (spec.power ?? 2) * 0.8) * 0.4; + let v = base(t); + for (const voice of voices) v += voice(t); + if (wobbleHz !== undefined) + v *= wobbleBase + wobbleAmp * Math.sin(2 * Math.PI * wobbleHz * t + wobblePhase); + if (fadeTo !== undefined) v *= 1 + (fadeTo - 1) * (t / dur); return v; }), spec.peak ?? 0.5 ); } -/** Скрип/шорох: низкая полоса, медленный спад (ветка/куст/дверь); swell — воздух. */ -function renderScrape(spec: SoundSpec, dur: number): Float32Array { +/** Голос слоя: шум (low/high) или тон (freq), своя огибающая и задержка. */ +function makeVoice(l: SpecVoice, spec: SoundSpec, dur: number): (t: number) => number { + const vd = clampDur(l.dur ?? dur, 8); + const delay = l.delay ?? 0; + const w = l.w ?? 1; + const power = l.power ?? spec.power ?? 2; + if (l.low !== undefined || l.high !== undefined) { + const noise = bandNoise(l.seed ?? spec.seed ?? 1, l.low ?? 100, l.high ?? 900, vd); + return (t) => { + const lt = t - delay; + if (lt < 0) return 0; + return noise() * w * attackEnv(lt, l.attack) * decay(lt, vd, power); + }; + } + const f = l.freq ?? 440; + return (t) => { + const lt = t - delay; + if (lt < 0) return 0; + return Math.sin(2 * Math.PI * f * lt) * w * attackEnv(lt, l.attack) * decay(lt, vd, power); + }; +} + +/** Шумовой удар: полоса шума с резким спадом; tone — низкая подложка. */ +function hitBase(spec: SoundSpec, dur: number, env: (t: number) => number): (t: number) => number { + const noise = bandNoise(spec.seed ?? 1, spec.low ?? 600, spec.high ?? 3000, dur); + const tone = spec.tone ?? 0; + const w = spec.w ?? 1; + return (t) => { + let v = noise() * env(t) * w; + if (tone > 0) v += Math.sin(2 * Math.PI * tone * t) * decay(t, dur, (spec.power ?? 2) * 0.8) * 0.4; + return v; + }; +} + +/** Скрип/шорох: низкая полоса, медленный спад; swell — воздух; attack — нарастание. */ +function scrapeBase(spec: SoundSpec, dur: number, env: (t: number) => number): (t: number) => number { const noise = bandNoise(spec.seed ?? 1, spec.low ?? 100, spec.high ?? 900, dur); - const env = envelope(spec, dur, spec.power ?? 1.5); - return normalize(synth(dur, (t) => noise() * env(t)), spec.peak ?? 0.5); + const w = spec.w ?? 1; + const attack = spec.attack; + return (t) => noise() * w * attackEnv(t, attack) * env(t); +} + +/** Чистый тон: синус freq (со свипом к freqTo), огибающая decay/swell. */ +function toneBase(spec: SoundSpec, dur: number, env: (t: number) => number): (t: number) => number { + const f0 = spec.freq ?? 440; + const f1 = spec.freqTo; + const w = spec.w ?? 1; + const attack = spec.attack; + return (t) => { + const f = f1 === undefined ? f0 : f0 + (f1 - f0) * (t / dur); + return Math.sin(2 * Math.PI * f * t) * w * attackEnv(t, attack) * env(t); + }; } /** Звон: негармоничные партиалы одной частоты + короткий клик. */ -function renderChime(spec: SoundSpec, dur: number): Float32Array { +function chimeBase(spec: SoundSpec, dur: number): (t: number) => number { const f = spec.freq ?? 520; const power = spec.power ?? 5; // Клик — спад с одной случайной амплитудой (как у файлового генератора: // формулы совпадают дословно — мигрированные звуки звучат один в один). const clickAmp = makeRng(spec.seed ?? 1)() * 2 - 1; - return normalize( - synth(dur, (t) => { - const body = - bellPartial(f, 1.0, dur, power)(t) * 0.5 + - bellPartial(f, 1.52, dur, power + 1)(t) * 0.3 + - bellPartial(f, 2.38, dur * 0.6, power + 2)(t) * 0.2 + - bellPartial(f, 3.91, dur * 0.4, power + 3)(t) * 0.12; - const click = t < 0.008 ? clickAmp * (1 - t / 0.008) * 0.4 : 0; - return body * 0.8 + click; - }), - spec.peak ?? 0.5 - ); + return (t) => { + const body = + bellPartial(f, 1.0, dur, power)(t) * 0.5 + + bellPartial(f, 1.52, dur, power + 1)(t) * 0.3 + + bellPartial(f, 2.38, dur * 0.6, power + 2)(t) * 0.2 + + bellPartial(f, 3.91, dur * 0.4, power + 3)(t) * 0.12; + const click = t < 0.008 ? clickAmp * (1 - t / 0.008) * 0.4 : 0; + return body * 0.8 + click; + }; } /** Гул: два тона с биением (tone и tone×1.5) на «дыхании», луп по умолчанию. */ -function renderHum(spec: SoundSpec, dur: number): Float32Array { +function humBase(spec: SoundSpec, dur: number): (t: number) => number { const tone = spec.tone ?? 55; const breath = spec.power ?? 2; // число периодов «дыхания» на луп - return normalize( - synth(dur, (t) => { - const swell = 0.6 + 0.4 * Math.sin(2 * Math.PI * (t / dur) * breath + 0.5); - const body = - Math.sin(2 * Math.PI * tone * t) * 0.5 + - Math.sin(2 * Math.PI * tone * 1.5 * t) * 0.3; - return body * swell * 0.35; - }), - spec.peak ?? 0.5 - ); + return (t) => { + const swell = 0.6 + 0.4 * Math.sin(2 * Math.PI * (t / dur) * breath + 0.5); + const body = + Math.sin(2 * Math.PI * tone * t) * 0.5 + + Math.sin(2 * Math.PI * tone * 1.5 * t) * 0.3; + return body * swell * 0.35; + }; } /** @@ -118,14 +214,18 @@ */ export function renderSpec(spec: SoundSpec): Float32Array { const dur = clampDur(spec.dur, spec.kind === 'hum' ? 8 : 4); - const samples = + const env = envelope(spec, dur, spec.power ?? 2); + const base = spec.kind === 'hit' - ? renderHit(spec, dur) + ? hitBase(spec, dur, env) : spec.kind === 'chime' - ? renderChime(spec, dur) + ? chimeBase(spec, dur) : spec.kind === 'scrape' - ? renderScrape(spec, dur) - : renderHum(spec, dur); + ? scrapeBase(spec, dur, env) + : spec.kind === 'tone' + ? toneBase(spec, dur, env) + : humBase(spec, dur); + const samples = renderMix(spec, dur, base); if (spec.loop ?? spec.kind === 'hum') { return loopify(samples, Math.min(0.5, dur / 4)); } diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 1c1ec40..af3f06e 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -171,7 +171,7 @@ // audio export { AudioManager, type AudioBuses, type BusName, type MusicHandle, type PlayOptions, type SfxHandle } from './audio/AudioManager'; -export { renderSpec, RATE, type SoundSpec, type SoundKind } from './audio/SoundSpec'; +export { renderSpec, RATE, type SoundSpec, type SoundKind, type SpecVoice } from './audio/SoundSpec'; export { renderMusic, type MusicSpec, type TrackSpec, type NoteEvent, type InstrumentKind } from './audio/Music'; // assets