diff --git a/apps/game/src/wav-tools.d.ts b/apps/game/src/wav-tools.d.ts deleted file mode 100644 index 49d5eeb..0000000 --- a/apps/game/src/wav-tools.d.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * Чистые примитивы синтеза движка (тулза 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 0c97320..065f3a0 100644 --- a/docs/engine/assets-audio-save.md +++ b/docs/engine/assets-audio-save.md @@ -111,6 +111,40 @@ // до unlock буфер не создать — fallback на файловый audio.play('sfx/step_grass') ``` +### Спек-синтез: playSpec (звук по описанию) + +`playSpec(key, spec, opts?)` — sfx из **описания звука** (`SoundSpec` из +`audio/SoundSpec.ts`, `renderSpec` детерминированно компилирует спек в сэмплы). +Адресат — ИИ-агент: описывает звук параметрами (без слуха и без библиотек +сэмплов), движок компилирует и играет; буфер кэшируется по ключу (компиляция +один раз), факт запуска виден в шпионе `onPlayed`. Примитивы те же, что у +файлового генератора, — спек-звук звучит в одной палитре с WAV-звуком. + +```ts +// kind — форма звука; остальное — параметры с дефолтами по kind +await audio.playSpec('agent/door', { + kind: 'scrape', // hit | chime | scrape | hum + 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 +}); +await audio.playSpec('agent/coin', { kind: 'chime', dur: 0.6, freq: 880 }, 0.4); +``` + +Формы: `hit` — полоса шума с резким спадом + низкая подложка (удары); +`chime` — негармоничные партиалы + клик (звон, монеты, подбор); `scrape` — +низкая полоса с медленным спадом (дверь, куст, ветка); `hum` — два тона +(tone и tone×1.5) на «дыхании», луп по умолчанию (гул, эмбиент-слой). +Тот же `renderSpec` экспортируется из публичного API — можно компилировать +в сэмплы без воспроизведения (офлайн-генерация, тесты). + +Типы для `synth.mjs` лежат рядом в `synth.d.mts` — декларация **рядом с .mjs** +(ambient `declare module` не работает: TS резолвит specifier к реальному +файлу и игнорирует ambient-имя). + Слоты лупов: `playMusic` и `playAmbience` — независимые слоты с кроссфейдом (повторный вызов вытесняет прежний трек своего слота; негодный ключ не глушит текущий). `playLoop` — луп **без слота** (занимать нечего, сколько вызвали — diff --git a/docs/engine/practices.md b/docs/engine/practices.md index f79449f..f1fbd1d 100644 --- a/docs/engine/practices.md +++ b/docs/engine/practices.md @@ -190,7 +190,9 @@ слои (вода, гул) — `AreaDef.ambienceLayers` + тик `worldAudio.setLayers`. Полифонию ограничивает движок (`maxVoices`, воровство тихих голосов) — пер-ключевой интервал в `AudioSystem` нужен только против «пулемёта» - одного ключа. + одного ключа. Звук **без файла вообще** — `audio.playSpec(key, spec)`: + спек `SoundSpec` (kind `hit`/`chime`/`scrape`/`hum` + параметры) движок + детерминированно компилирует сам — путь для ИИ-агента и быстрых прототипов. 4. На слух в headless не проверить — смотреть фактические запуски: DEV-шпион `window.__gameAudioLog` (кольцо на 24, `t`/`volume`/`pan`) читается из страницы; образец — `apps/game/tools/checks/audio.mjs`. После действия diff --git a/docs/plan.md b/docs/plan.md index 671a220..5e9ae77 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -24,6 +24,8 @@ | 5 | Замена сгенерированных плейсхолдеров арта на AI/ручной арт | в составе работы B | | 6 | Крупные объекты: footprint в движке, слоты арта (работа C, ниже) | в работе | | 7 | Возможности аудио-движка (работа D, ниже): sfx-хендл, полифония, наблюдаемость, буферы | ✅ D0–D2 сделаны (2026-09-07) | +| 8 | Синтез звука по запросу для агента (работа E, ниже) | 📋 спланировано | +| 9 | Инфраструктура аудио + музыкальный слой (работы F/G, ниже) | 📋 спланировано | Отложено: **IsoLayout как параметр игры** — `DEFAULT_ISO` в `math/iso.ts` остаётся единственной точкой входа; прокидывание конфига игры через ~15 мест движка не даёт @@ -307,15 +309,45 @@ на поверхность (сид ~253–256 + сдвиг, полосы как в gen.mjs), до unlock — файловый шаг. Гвард: `@rpg/engine/tools/*` — публичное API. -### Дальше (после D — закрываем «по инструментарию») +### Работа E — синтез звука по запросу (агент без слуха) -- **Инфраструктура**: mute/suspend при `visibilitychange` (свернул таб — - тишина); `bus` в опциях разового `play`; exponential-фейд (линейный на - громких затуханиях слышен как «ступенька»). -- **Музыкальный слой**: плейлист/секвенция тем («тема лугов → тема боя → - назад»), стем-миксинг — параллельные лупы-слои одной темы с независимой +Движок строится агент-первым: у агента нет слуха, и искать готовые сэмплы +в библиотеках он не может. «Генерация по запросу» для агента — параметрический +синтез: агент описывает звук **спеком** (форма, полоса шума, тон, огибающая, +сид), движок детерминированно компилирует спек в буфер (база — D2: +`createBuffer`/`playBuffer` + `tools/synth.mjs`), факт запуска наблюдаем через +`__gameAudioLog`. Нейросеть позже — опциональный «описание → спек» маппер +поверх того же API; сэмпл-библиотеки как основной путь не используются. + +- **E1 (движок): компилятор спеков.** `audio.playSpec(key, spec, opts?)` — + kinds `hit`/`chime`/`scrape`/`hum`, параметры: `dur` (обяз.), `low`/`high` + (полоса шума), `tone` (низкая подложка/основной тон гула), `freq` (звон), + `power` (крутизна затухания), `seed` (детерминизм), `peak`, `loop` + (кроссфейд краёв). Кэш буферов по ключу. Тесты: детерминизм (одинаковые + спеки/сиды → одинаковые сэмплы), kinds, кэш. +- **E2 (мост): агентный доступ.** Op `scene:synthesize` (спек → playSpec), + документация форм спеков в `docs/engine/agent.md`; проба + `checks/synth.mjs` — агент слепо синтезирует «удар» и «звон», проверка + фактов запуска в аудио-логе. +- **E3 (контент, опционально):** миграция части файловых SFX на спеки + (дверь, цветок/куст) — где процедурная формула тривиальна. + +### Работа F — инфраструктура аудио (хвосты D) + +- mute/suspend при `visibilitychange` (свернул таб — тишина). +- `bus` в `PlayOptions` разового `play` (сейчас разовый — только шина sfx). +- exponential-фейд вместо линейного на затуханиях (на громких слышна + «ступенька»). + +### Работа G — музыкальный слой + +- Движок: секвенсор (ноты → инструменты поверх `synth.mjs`: осциллятор + + огибающая, партия = трек = стем), плейлист/секвенция тем («тема лугов → + тема боя → назад»), стем-миксинг — параллельные лупы-слои с независимой громкостью (спокойно/настороженно/бой; ложится на существующий `playLoop`). - Контент закрывается инструментарием Работы A (этапы A2–A3), API — здесь. +- Тулзы: правило-базовый генератор тем офлайн (детерминированный, сиды), тема + лугов — первый носитель. Позже — маленькая нейронка «описание → спек/ноты» + как опция, не как база. --- diff --git a/packages/engine/src/audio/AudioManager.ts b/packages/engine/src/audio/AudioManager.ts index 821de3e..bb0c7ff 100644 --- a/packages/engine/src/audio/AudioManager.ts +++ b/packages/engine/src/audio/AudioManager.ts @@ -4,6 +4,7 @@ * (громкость/скорость/панорама), кэш сэмплов. Игры подключают Settings.onChange * и вызывают setBusVolume. */ +import { renderSpec, RATE, type SoundSpec } from './SoundSpec'; export interface AudioBuses { master: GainNode; @@ -71,6 +72,8 @@ export class AudioManager { private ctx: AudioContext | null = null; private buffers = new Map(); + /** Буферы спек-синтеза (playSpec): компиляция один раз на ключ. */ + private specBuffers = new Map(); private urls = new Map(); private buses: AudioBuses | null = null; private currentMusic: Slot | null = null; @@ -176,6 +179,24 @@ return buf; } + /** + * Синтез по спеку (см. SoundSpec): детерминированно компилирует описание + * звука в буфер (кэш по ключу) и играет как sfx. Для ИИ-агента: описывает + * звук параметрами, без файлов; факт запуска виден в шпионе onPlayed. + */ + async playSpec(key: string, spec: SoundSpec, opts?: number | PlayOptions): Promise { + const ctx = await this.ensureContext(); + if (!ctx) return null; + let buf = this.specBuffers.get(key); + if (!buf) { + const created = this.createBuffer(renderSpec(spec), RATE); + if (!created) return null; + buf = created; + this.specBuffers.set(key, buf); + } + return this.startSfx(ctx, key, buf, typeof opts === 'number' ? { volume: opts } : (opts ?? {})); + } + /** Общий запуск голоса sfx (play — после декода, playBuffer — как есть). */ private startSfx(ctx: AudioContext, key: string, buf: AudioBuffer, o: PlayOptions): SfxHandle | null { const volume = o.volume ?? 1; diff --git a/packages/engine/src/audio/SoundSpec.ts b/packages/engine/src/audio/SoundSpec.ts new file mode 100644 index 0000000..9da2f80 --- /dev/null +++ b/packages/engine/src/audio/SoundSpec.ts @@ -0,0 +1,124 @@ +import { + RATE, + bandNoise, + bellPartial, + decay, + loopify, + makeRng, + normalize, + synth +} from '@rpg/engine/tools/synth.mjs'; + +/** + * Спек-синтез: детерминированная компиляция описания звука в сэмплы. + * Адресат — ИИ-агент (движок агент-первый): описывает звук параметрами, + * без слуха и без библиотек сэмплов; движок компилирует спек в буфер + * (AudioManager.playSpec), факт запуска наблюдаем через шпион onPlayed. + * Формулы — те же примитивы, что у файлового генератора игры + * (tools/synth.mjs), поэтому спек-звук и WAV-звук звучат в одной палитре. + */ + +export type SoundKind = 'hit' | 'chime' | 'scrape' | 'hum'; + +export interface SoundSpec { + kind: SoundKind; + /** Длительность, сек (0.05..4; hum — до 8). */ + dur: number; + /** Полоса шума, Гц (hit/scrape; дефолты по kind). */ + low?: number; + high?: number; + /** Низкий тон, Гц: подложка удара/шороха или основной тон гула. */ + tone?: number; + /** Частота звона, Гц (chime; дефолт 520). */ + freq?: number; + /** Крутизна затухания (дефолты по kind). */ + power?: number; + /** Сид шума/клика — детерминизм (дефолт 1). */ + seed?: number; + /** Пик нормализации 0..1 (дефолт 0.5). */ + peak?: number; + /** Зациклить (кроссфейд краёв; для hum — по умолчанию). */ + loop?: boolean; +} + +/** Клэмп длительности: снизу — против нулевых буферов, сверху — по kind. */ +const clampDur = (dur: number, max: number): number => Math.max(0.05, Math.min(max, dur)); + +/** Шумовой удар: полоса шума с резким спадом; tone — низкая подложка. */ +function renderHit(spec: SoundSpec, dur: number): Float32Array { + const noise = bandNoise(spec.seed ?? 1, spec.low ?? 600, spec.high ?? 3000, dur); + const power = spec.power ?? 2; + const tone = spec.tone ?? 0; + return normalize( + synth(dur, (t) => { + let v = noise() * decay(t, dur, power); + if (tone > 0) v += Math.sin(2 * Math.PI * tone * t) * decay(t, dur, power * 0.8) * 0.4; + return v; + }), + spec.peak ?? 0.5 + ); +} + +/** Скрип/шорох: низкая полоса, медленный спад (ветка/куст/дверь). */ +function renderScrape(spec: SoundSpec, dur: number): Float32Array { + const noise = bandNoise(spec.seed ?? 1, spec.low ?? 100, spec.high ?? 900, dur); + const power = spec.power ?? 1.5; + return normalize(synth(dur, (t) => noise() * decay(t, dur, power)), spec.peak ?? 0.5); +} + +/** Звон: негармоничные партиалы одной частоты + короткий клик. */ +function renderChime(spec: SoundSpec, dur: number): Float32Array { + const f = spec.freq ?? 520; + const power = spec.power ?? 5; + const rng = makeRng((spec.seed ?? 1) + 17); + const clickDur = 0.008; + 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; + const click = t < clickDur ? (rng() * 2 - 1) * (1 - t / clickDur) * 0.4 : 0; + return body * 0.8 + click; + }), + spec.peak ?? 0.5 + ); +} + +/** Гул: два тона с биением (tone и tone×1.5) на «дыхании», луп по умолчанию. */ +function renderHum(spec: SoundSpec, dur: number): Float32Array { + 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 + ); +} + +/** + * Спек → сэмплы (моно, RATE). Одинаковые спеки дают одинаковые сэмплы + * (детерминизм: сиды фикс, ГПСЧ детерминирован). + */ +export function renderSpec(spec: SoundSpec): Float32Array { + const dur = clampDur(spec.dur, spec.kind === 'hum' ? 8 : 4); + const samples = + spec.kind === 'hit' + ? renderHit(spec, dur) + : spec.kind === 'chime' + ? renderChime(spec, dur) + : spec.kind === 'scrape' + ? renderScrape(spec, dur) + : renderHum(spec, dur); + if (spec.loop ?? spec.kind === 'hum') { + return loopify(samples, Math.min(0.5, dur / 4)); + } + return samples; +} + +export { RATE }; \ No newline at end of file diff --git a/packages/engine/src/audio/__tests__/AudioManager.test.ts b/packages/engine/src/audio/__tests__/AudioManager.test.ts index 56b3118..886e481 100644 --- a/packages/engine/src/audio/__tests__/AudioManager.test.ts +++ b/packages/engine/src/audio/__tests__/AudioManager.test.ts @@ -368,6 +368,35 @@ expect(graph.sources[0]!.stop).toHaveBeenCalled(); }); + it('playSpec: спек компилируется в буфер и кэшируется по ключу', async () => { + const { audio, graph, ctx } = makeManager(); + const played: { key: string; opts: PlayOptions }[] = []; + audio.onPlayed = (key, opts) => played.push({ key, opts }); + await audio.unlock(); + + const spec = { kind: 'chime', dur: 0.1, freq: 660 } as const; + const h1 = await audio.playSpec('agent/door', spec, 0.7); + await audio.playSpec('agent/door', spec, 0.7); // из кэша + + expect(graph.sources).toHaveLength(2); + expect(ctx.createBuffer).toHaveBeenCalledTimes(1); // компиляция один раз + const gainNode = graph.sources[0]!.dests[0] as { gain: { value: number }; dests: unknown[] }; + expect(gainNode.gain.value).toBe(0.7); + expect(gainNode.dests).toContain(graph.gains[2]); // шина sfx + expect(played.map((p) => p.key)).toEqual(['agent/door', 'agent/door']); + expect(h1).not.toBeNull(); + + // Другой ключ — своя компиляция + await audio.playSpec('agent/bush', spec, 0.5); + expect(ctx.createBuffer).toHaveBeenCalledTimes(2); + }); + + it('playSpec: спек рендерится в буфер с длиной dur×RATE (моно, 22050)', async () => { + const { audio, ctx } = makeManager(); + await audio.playSpec('x', { kind: 'hit', dur: 0.1 }); + expect(ctx.createBuffer).toHaveBeenCalledWith(1, 2205, 22050); + }); + it('setVolume хендла сразу задаёт громкость слота', async () => { const { audio, graph } = makeManager(); await audio.unlock(); diff --git a/packages/engine/src/audio/__tests__/SoundSpec.test.ts b/packages/engine/src/audio/__tests__/SoundSpec.test.ts new file mode 100644 index 0000000..91299d9 --- /dev/null +++ b/packages/engine/src/audio/__tests__/SoundSpec.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest'; +import { renderSpec, type SoundSpec } from '../SoundSpec'; + +const same = (a: Float32Array, b: Float32Array): boolean => + a.length === b.length && a.every((v, i) => v === b[i]); + +const peak = (samples: Float32Array): number => Math.max(...[...samples].map(Math.abs)); + +describe('renderSpec — детерминизм', () => { + it('одинаковые спеки → одинаковые сэмплы', () => { + const spec: SoundSpec = { kind: 'hit', dur: 0.2, seed: 42 }; + expect(same(renderSpec(spec), renderSpec(spec))).toBe(true); + }); + + it('другой сид → другой шум (но та же форма затухания)', () => { + const a = renderSpec({ kind: 'hit', dur: 0.2, seed: 1 }); + const b = renderSpec({ kind: 'hit', dur: 0.2, seed: 2 }); + expect(same(a, b)).toBe(false); + expect(peak(a)).toBeCloseTo(peak(b), 5); // нормализация по одному peak + }); +}); + +describe('renderSpec — kinds', () => { + it('hit/chime/scrape нормализуются к peak, немые буферы исключены', () => { + for (const kind of ['hit', 'chime', 'scrape'] as const) { + const samples = renderSpec({ kind, dur: 0.3, peak: 0.6 }); + expect(peak(samples)).toBeCloseTo(0.6, 2); + expect(peak(samples)).toBeGreaterThan(0); + } + }); + + it('длительности: без лупа — ровно dur×RATE; луп сшит (короче на фейд)', () => { + const hit = renderSpec({ kind: 'hit', dur: 0.4 }); + expect(hit.length).toBe(8820); // 0.4 с × 22050 + + const hum = renderSpec({ kind: 'hum', dur: 2 }); // луп по умолчанию + expect(hum.length).toBeLessThan(44100); // срезан кроссфейд-хвост + expect(hum.length).toBeGreaterThanOrEqual(44100 - 11025); // фейд ≤ 0.5 с + }); + + it('hum: заданный loop=false не сшивает; hit с loop=true сшивает', () => { + const humRaw = renderSpec({ kind: 'hum', dur: 1, loop: false }); + expect(humRaw.length).toBe(22050); + const hitLoop = renderSpec({ kind: 'hit', dur: 1, loop: true }); + expect(hitLoop.length).toBeLessThan(22050); + }); + + it('клэмп длительности: 0.01 → мин 0.05 с, hum 100 → макс 8 с', () => { + expect(renderSpec({ kind: 'hit', dur: 0.01 }).length).toBe(1102); // 0.05 с + expect(renderSpec({ kind: 'hum', dur: 100, loop: false }).length).toBe(176400); // 8 с + }); + + it('параметры доезжают: freq меняет тембр звона, tone — подложку удара', () => { + const low = renderSpec({ kind: 'chime', dur: 0.3, freq: 220, loop: false }); + const high = renderSpec({ kind: 'chime', dur: 0.3, freq: 880, loop: false }); + expect(same(low, high)).toBe(false); + + const plain = renderSpec({ kind: 'hit', dur: 0.3, seed: 5, loop: false }); + const toned = renderSpec({ kind: 'hit', dur: 0.3, seed: 5, tone: 120, loop: false }); + expect(same(plain, toned)).toBe(false); + }); +}); \ No newline at end of file diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 413eb6d..99d2924 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -171,6 +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'; // assets export { AssetLoader } from './assets/AssetLoader'; diff --git a/packages/engine/tools/synth.d.mts b/packages/engine/tools/synth.d.mts new file mode 100644 index 0000000..d445fb8 --- /dev/null +++ b/packages/engine/tools/synth.d.mts @@ -0,0 +1,29 @@ +/** + * Типы для synth.mjs: чистые примитивы синтеза без node-зависимостей + * (их можно импортировать и в браузере — в отличие от wav.mjs с node:fs). + * Сам .mjs без JSDoc-типов, декларация рядом покрывает оба tsconfig-проекта. + */ + +/** Частота дискретизации синтеза, Гц. */ +export declare const RATE: number; + +/** Детерминированный ГПСЧ (mulberry32): (seed) → функция () => [0..1). */ +export declare function makeRng(seed: number): () => number; + +/** Сэмплы длительностью seconds: fn(t секунды, i индекс) → значение волны. */ +export declare function synth(seconds: number, fn: (t: number, i: number) => number, rate?: number): Float32Array; + +/** Огибающая затухания: 1 на старте → 0 к концу (кривая t^power). */ +export declare function decay(t: number, dur: number, power?: number): number; + +/** Партиал колокола: sin(2π·freq·stretch·t) с затуханием decay. */ +export declare function bellPartial(freq: number, stretch: number, dur: number, power?: number): (t: number) => number; + +/** Полосовой шум: детерминированная последовательность сэмплов в полосе. */ +export declare function bandNoise(seed: number, lowHz: number, highHz: number, dur: number): () => number; + +/** Нормализация к пику (по модулю), дефолт 0.9. */ +export declare function normalize(samples: Float32Array, peak?: number): Float32Array; + +/** Сшить края в луп коротким кроссфейдом (возвращает копию без хвоста фейда). */ +export declare function loopify(samples: Float32Array, fadeSeconds?: number): Float32Array; \ No newline at end of file