diff --git a/docs/plan.md b/docs/plan.md index be566f9..560c3e2 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -54,6 +54,7 @@ | 32 | v2 трек 11: сохранение — SaveManager (JSON-слоты, StorageLike инъекцией, мета в параллельном ключе), сериализация состояния демки (герой/флаги/переменные/сумка), мост saveTo/loadFrom/saveList/saveDelete + гейт | ✅ (2026-09-09). `save/saveManager.ts` — перенос SaveManager v1 1:1: `save(slot, data, meta?)`/`load` (битый JSON → null)/`slotMeta`/`list`/`delete`/`has`/`listSlots`; хранилище — `StorageLike` инъекцией (демка — window.localStorage, тесты — Map-заглушка), мета — в параллельном ключе `meta:` (старые сейвы читаемы). Демка: `DemoSave {version, heroPos, heroYaw, heroPhase, flags, vars, inv}` — `serializeSave`/`applySave` (версия формата проверяется; активный диалог прерывается; HUD-подсказка обновляется сразу — урок трека 9); мост `saveTo(slot, title)`/`loadFrom(slot)`/`saveList()`/`saveDelete(slot)`. Гейт `save.mjs` 9 проверок: слотов нет на старте, saveTo пишет слот+мету, состояние ломается (отдача цветка + уезд героя), loadFrom восстанавливает позицию/флаги (без flowerGivenAway)/сумку (цветок ×1), **кадр после загрузки попиксельно равен кадру сохранения (diff 0)**, saveDelete чистит. 154 юнит-теста. Уроки: **в ручном режиме кадр рендерится только в stepTicks** — после loadFrom-мостов нужен тик перед скриншотом (иначе скриншот захватывает протухший канвас с прежней камерой); **applySave меняет состояние вне тика — обновлять HUD сразу** (тот же урок, что у диалогов). | | 33 | v2 трек 12: сцены/переходы — стек SceneManager (меню поверх игры), фейд-переходы out→swap→in, параллельные переходы глотаются; демка: DOM-меню (старт/продолжить), автосейв при уходе в меню, Esc + гейт | ✅ (2026-09-09). `core/scene.ts` — перенос схемы v1 без привязки к бэкенду: `Scene` {enter/exit/update/render}, стек push/pop/replace, переход {duration} фазами **затухание → смена сцены → проявление** (каждая фаза длится duration — полный переход 2×duration); `FadeOverlay {setAlpha}` инъекцией (демка — DOM-слой `#fade`, тесты — заглушка); **параллельные переходы глотаются** (pending-проверка в begin), **enter новой сцены не ожидается** (void), update тикает текущую сцену и во время перехода, render рисует весь стек (нижние сцены видны). Демка: `gameScene` (tick+рендер) внизу стека, `menuScene` (DOM-панель) сверху — мир живёт под меню с самого старта; Esc/мост `gotoMenu` — из игры автосохранение в слот `auto` + push меню, из меню — возврат; «Продолжить» грузит `auto` (кнопка disabled без сейва); мост sceneInfo/gotoMenu/startGame. Гейт `scenes.mjs` 11 проверок: старт в меню без перехода, startGame — переход идёт (сцена ещё меню), **фейд в конце фазы out — кадр чёрный**, переход завершён (меню скрыто), кадры меню/игры различаются, gotoMenu пишет автосейв, возврат в меню, «Продолжить» вернул героя на место сохранения, параллельный переход глотается, повторный переход работает. 164 юнит-теста. Уроки: **CSS `display:flex` перебивает атрибут `hidden`** — нужен `#menu[hidden]{display:none}` (гейт save поймал: меню «скрыто», но видно на каждом кадре, испортив попиксельное сравнение); **полный переход — это out+in (2×duration = 48 тиков при 0.4 с), а не 24** — в ручном режиме гейты обязаны пройти все 48 тиков, иначе фейд висит частично затемняя каждый кадр (ловится только попиксельным сравнением); **все гейты демки теперь входят в игру через startGame(false)+60 тиков** — демка стартует в меню, старые гейты снимали кадры поверх панели меню. | | 34 | v2 трек 13: бой — перенос боевой системы v1 на воксельный движок: PlayerCombat (конус «гулкий звон» будит пепел / заряженный резонанс усыпляет), EnemyBrain (стейты dormant/rise/chase/hurt/flee/sleeping, слух по уровням шума, LOS с кэшем), CombatWorld (спавн, снаряды плевуна, трупы, толкучка-сепарация); демка: сгустки-кучки на лугу, атака на Space, HUD сердец, мост combatSpawn/combatClear/combatInfo/combatStats/attackInput + гейт | ✅ (2026-09-09). `combat/` движка: `shapes.ts`/`los.ts`/`stats.ts`/`enemies.ts`/`playerCombat.ts`/`brain.ts` (StateMachine + Cooldown) + `world.ts` — CombatWorld с хуками {onEnemyHurt/onEnemyKilled/onEnemyAwake/onAttack/onSpit} вместо EventBus v1; deps {getPlayerPos, damagePlayer, solid, opaque} — мир инъекцией предикатами. 16 юнит-тестов (217 всего). **Масштаб: 1 боевой юнит = 3 вокселя** — боевые числа v1 в юнитах (герой ≈ 1), конвертация только на границе демки (позиции /3, отброс ×3), CombatWorld живёт в чистом юнит-пространстве. Демка: спавны (патруль (8,10)→(10,12), спящие плевун (19,20) и тяжёлый (22,13)), кучки — приплюснутые эллипсоиды из вокселей (спящий — нижний ряд, «блин»; hurt — вспышка 0.12 с), шум шагов 0.35, HUD `#hp` сердечками (скрывается вне игровой сцены — isBlack-проверка scenes-гейта сканирует весь кадр). Гейт `combat.mjs` 14 проверок: 3 сгустка/2 спят, кучка рисуется (diff ~1100), звон будит без урона (rise), погоня до равновесия 0.55, конус 3→1→труп→исчез, смерть героя 4,3,2,1,5 + респаун (40,60), плевун 5→4 + HUD-зона, резонанс усыпляет обоих. Уроки: **dormant глух к близости героя** — спящий вплотную не просыпается (только шум ≥ 0.7 или урон), гейт будит звоном издали; **равновесие сепарации = сумма радиусов (0.55) > attackRange ползуна (0.4)** — ползун «липнет», но стоящего героя не бьёт (v1-faithful; контактные удары в гейте — телепортом в упор, тяжёлый 0.65 ≤ 0.8 бьёт); **толкучка вталкивает врага в strike-радиус** — два сгустка с разных сторон дают удар без атаки, спавнить с одной стороны; **combatClear обязан чистить и снаряды** — снаряд плевуна летит после удаления врагов и «призраком» наносит урон; **боевые точки проверять прогоном** — у старта героя (40,60) пусто, у (14,84) препятствие (равновесие погони ровно 0.55 — маркер открытости). | +| 35 | v2 трек 14: аудио — перенос аудио-подсистемы v1 на воксельный движок: AudioManager (4 шины, спек-синтез sfx без WAV-пайплайна), Voices/Loops/BufferCache/SoundSpec/Music, synth-примитивы в src движка; демка: позиционный звук боя (затухание + изо-панорама), шаги с вариациями, DEV-шпион + гейт | ✅ (2026-09-09). `audio/` движка: `types.ts` (шины/хендлы/опции), `decode.ts` (BufferCache: in-flight дедуп декода), `Voices.ts` (полифония 24, вор самого тихого, restart по ключу, панорама в полёте), `Loops.ts` (слоты музыки/амбиента с кроссфейдом, независимые), `SoundSpec.ts`/`Music.ts` (спек-синтез: hit/chime/scrape/hum/tone + треки bpm/beats/tracks), `synth.ts` (примитивы v1 tools/synth.mjs в TS — рантайм и тулзы делят одни формулы), `AudioManager.ts` (фасад: prepare() синхронно заводит контекст без resume, unlock, playSpec кэширует буфер по ключу, шпион onPlayed). 44 юнит-теста (261 всего, 31 файл), тесты v1 перенесены 1:1 (фейковый граф AudioContext). Демка: `src/audio.ts` — спеки боя/урона/шагов (формулы v1 sfxSpecs), `DemoAudio` (playAt: (1−d/radius)^1.5 + панорама ∝ изо-(dx−dz)·16/240, дедуп 0.08 с; StepVariants: 3 варианта шага, сид = база + i·10), обвязка в main.ts — хуки боя (cone→bell_hit, resonance→bell_low, spit, awake→ash_hiss, kill→ash_die, playerHit→hurt), шаг на границе половины walk-цикла, unlock на первом жесте, prepare() на старте (буферы вариантов компилируются сразу); мост `audioUnlock`/`audioInfo` (кольцевой буфер 24 записей фактических запусков). Гейт `audio.mjs` 8 проверок: тишина на старте (амбиента нет, герой не двигался), шаги играют, атака→bell_hit, плевок→spit (позиционный, в радиусе слышимости 12), резонанс→bell_low, лог ограничен 24. Уроки: **кулдаун конуса 0.45 с не сбрасывается телепортом/очисткой** — атака-пробуждение через ~9 тиков после атаки-звонка не выходит (release глотает), между боевыми секциями гейта ждать ≥30 тиков; **звук в headless работает без жестов** (agent-lib уже передаёт `--autoplay-policy=no-user-gesture-required`), prepare() даёт компиляцию буферов до первого resume. | Отложено: **IsoLayout как параметр игры** — `DEFAULT_ISO` в `math/iso.ts` остаётся единственной точкой входа; прокидывание конфига игры через ~15 мест движка не даёт @@ -597,5 +598,7 @@ SceneManager — стек + фейд, параллельные переходы глотаются), ~~бой~~ (трек 13, строка 34: PlayerCombat/EnemyBrain/CombatWorld — перенос v1, хуки вместо EventBus, 1 боевой юнит = 3 вокселя, конвертация на границе -демки). Остаётся: перенос аудио (по мере движения к воспроизведению -демки v1 на v2). +демки), ~~аудио~~ (трек 14, строка 35: AudioManager/Voices/Loops + +спек-синтез без WAV, позиционный звук и шаги в демке, DEV-шпион). +Все 14 треков переноса подсистем v1 на v2 закрыты — дальше: воспроизведение +демки v1 («Пепельные луга») на v2 по мере движения. diff --git a/v2/CLAUDE.md b/v2/CLAUDE.md index 2d90fdf..8515f43 100644 --- a/v2/CLAUDE.md +++ b/v2/CLAUDE.md @@ -48,6 +48,7 @@ node apps/demo/tools/checks/save.mjs # скриншот-гейт сохранений (/tmp/v2_save_*.png) node apps/demo/tools/checks/scenes.mjs # скриншот-гейт сцен/меню (/tmp/v2_scenes_*.png) node apps/demo/tools/checks/combat.mjs # скриншот-гейт боя (/tmp/v2_combat_*.png) +node apps/demo/tools/checks/audio.mjs # гейт аудио (DEV-шпион, /tmp/v2_audio_*.png) npx vite --port 5299 # демка (в apps/demo), http://localhost:5299 ``` @@ -165,6 +166,22 @@ вталкивание в solid-колонку откатывается), ProjectileSystem — полёт, opaque-колонка или срок жизни убивает; moveCircleHor/circleHitsSolid — круг против bbox-клеток, ось-раздельный зажим). + - `src/audio/` — аудио-подсистема (перенос v1 1:1, без WAV-пайплайна — + всё на спек-синтезе): `types.ts` (шины master/music/sfx/ambience, хендлы, + PlayOptions/LoopOptions), `decode.ts` (BufferCache: fetch+decodeAudioData, + in-flight дедуп), `Voices.ts` (полифония разовых sfx: лимит с воровством + самого тихого, restart по ключу, панорама StereoPanner в полёте), `Loops.ts` + (слоты музыки и амбиента с кроссфейдом — независимые, playLoop — база + локальных слоёв), `SoundSpec.ts`/`Music.ts` (спек-синтез: hit/chime/scrape/ + hum/tone детерминированно; треки bpm/beats/tracks, normalize, loopify), + `synth.ts` (примитивы v1 tools/synth.mjs в TS: makeRng/synth/decay/ + bellPartial/bandNoise/normalize/loopify — рантайм и будущие тулзы делят + одни формулы), `AudioManager.ts` (фасад: 4 шины, `prepare()` синхронно + заводит контекст без resume (буферы компилируются до первого жеста), + `unlock()` из ввода, `playSpec` кэширует буфер по ключу, шпион `onPlayed`, + `dispose`); демка: `apps/demo/src/audio.ts` — спеки (формулы v1 + sfxSpecs.ts), `DemoAudio` (playAt: затухание (1−d/r)^1.5 + панорама по + изо-экрану, дедуп 0.08 с; StepVariants — 3 варианта шага на поверхность). - `tools/agent-lib.mjs` — браузерный каркас проверок (launchBrowser с опциональным viewport/ startDevServer/openGame/AgentClient/Checks), swiftshader в headless; @@ -276,6 +293,15 @@ (Space агентом: press — заряд, release — конус/резонанс). Все боевые проверки — на чистом ряду z=60 у старта героя (40,60); сцены демки (спавны) проверяются до первого combatClear. +- `apps/demo/tools/checks/audio.mjs` — гейт аудио (8 проверок, без + скриншотов — на слух в headless не проверить, читаем DEV-шпион): + вход в игру, тишина на старте (шпион подключён, амбиента в демке нет), + шаги героя играют (варианты step_ash — рантайм-синтез по спеку), + атака → bell_hit, плевок плевуна → spit (позиционный звук: сгусток в + радиусе слышимости 12, но не в конусе — звон будит, плевун стреляет), + резонанс → bell_low, лог ограничен 24 записями. Мост: `audioUnlock()` + (разблокировка контекста гейтом без жестов), `audioInfo()` (кольцевой + буфер 24 записей {key, volume, pan, rate} фактических запусков звука). ## Известные грабли (уже собранные) @@ -371,6 +397,14 @@ - **Боевые точки выбирать прогоном, не по глазам**: у старта героя (40,60) чисто (равновесие погони ровно 0.55 — маркер открытости), у (14,84) стоит препятствие — сгусток в chase топчется на месте. +- **Кулдаун конуса (0.45 с) не сбрасывается телепортом/очисткой боя**: + атака-пробуждение через ~9 тиков после атаки-звонка не выходит (release + глотает молча) — между боевыми секциями гейта ждать ≥ 30 тиков (0.5 с). +- **Звук в headless работает без жестов**: agent-lib уже передаёт + `--autoplay-policy=no-user-gesture-required`, а `audio.prepare()` заводит + контекст синхронно — варианты шагов компилируются до первого resume; + факт запуска звука проверяется только DEV-шпионом `audioInfo()` (на слух + в headless не проверить). - **`AgentClient.call` вариадический**: `call('combatSpawn', 'crawler', 15, 20)`, а не `call('combatSpawn', ['crawler', 15, 20])` — массив уходит ОДНИМ аргументом (ENEMY_KINDS[Array] → undefined). diff --git a/v2/apps/demo/src/audio.ts b/v2/apps/demo/src/audio.ts new file mode 100644 index 0000000..af085db --- /dev/null +++ b/v2/apps/demo/src/audio.ts @@ -0,0 +1,149 @@ +/** + * Аудио демки (перенос обвязки v1): sfx на спек-синтезе движка, позиционный + * звук мира (затухание по дистанции + панорама по изо-экрану) и шаги с + * вариациями. Спеки — формулы v1 (apps/game/src/data/sfxSpecs.ts), только + * нужные демке ключи; WAV-пайплайна в v2 нет — всё синтезируется в рантайме. + */ +import { renderSpec, type AudioManager, type PlayOptions, type SoundSpec } from '@rpg/engine'; +import type { Hor } from '@rpg/engine'; + +/** Спек-звуки демки: бой, урон героя и шаги (пепел/трава). */ +export const SPEC_SFX: Record = { + // Звон удара конусом: 1 партиал + клик, normalize 0.7 (v1 bell_hit) + 'sfx/bell_hit': { kind: 'chime', dur: 0.35, freq: 520, power: 5, seed: 1, peak: 0.7 }, + // Низкий звон резонанса: три тона с тремоло 6 Гц (v1 bell_low) + '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 }], + }, + // Плевок плевуна: тон со свипом 700→350 Гц + шипящий хвост (v1 spit) + '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 }], + }, + // Пробуждение сгустка: шипение с нарастанием 0.08 с (v1 ash_hiss) + 'sfx/ash_hiss': { kind: 'scrape', dur: 0.4, low: 900, high: 4200, seed: 11, power: 2, attack: 0.08, peak: 0.55 }, + // Развеивание сгустка: шум, громкость спадает вдвое к концу (v1 ash_die) + 'sfx/ash_die': { kind: 'scrape', dur: 0.6, low: 300, high: 2400, seed: 23, power: 3, fadeTo: 0.5, peak: 0.6 }, + // Урон герою: глухой удар 90 Гц + низкий шум (v1 hurt) + '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 }], + }, + // Шаги: до unlock играет базовый спек, варианты — сид + i·10 (StepVariants) + 'sfx/step_ash': { kind: 'hit', dur: 0.14, low: 200, high: 1100, seed: 254, power: 2.5, peak: 0.3 }, + 'sfx/step_grass': { kind: 'hit', dur: 0.12, low: 400, high: 2000, seed: 253, power: 2, peak: 0.3 }, +}; + +/** Единая точка sfx по ключу (спек-звук компилирует движок, буфер кэшируется). */ +export function playSfx(audio: AudioManager, key: string, opts?: number | PlayOptions): void { + const spec = SPEC_SFX[key]; + if (spec) void audio.playSpec(key, spec, opts); + else void audio.play(key, opts); +} + +/** Поверхности шагов демки. */ +export type StepSurface = 'grass' | 'ash'; + +/** + * Вариативные шаги (перенос v1): 3 варианта на поверхность, сид варианта = + * базовый + i·10 — звучат похоже, но не идентично. Буферы синтезируются при + * первом успешном шаге (до unlock контекста нет — играет базовый спек). + */ +const STEP_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 { + playSfx(this.audio, key, { volume }); + } + } + + /** Синтезировать варианты поверхности (нет контекста — пустой список). */ + private ensure(surface: StepSurface): AudioBuffer[] { + const existing = this.cache.get(surface); + if (existing) return existing; + const base = SPEC_SFX[`sfx/step_${surface}`]; + if (!base) return []; + const list: AudioBuffer[] = []; + for (let i = 0; i < STEP_VARIANTS; i++) { + const buf = this.audio.createBuffer(renderSpec({ ...base, seed: (base.seed ?? 1) + i * 10 })); + if (!buf) return []; // контекста нет — попробуем на следующем шаге + list.push(buf); + } + this.cache.set(surface, list); + return list; + } +} + +/** Минимальный интервал между запусками одного ключа (полифония, v1). */ +const MIN_INTERVAL = 0.08; + +/** Панорама источника: изо-экранный сдвиг ∝ (dx − dz), половина кадра 240 px. */ +function panOf(source: Hor, listener: Hor): number { + const dx = (source.x - listener.x) * 16; // 16 px/юнит (тайл v1 32/2) + return Math.max(-0.8, Math.min(0.8, dx / 240)); +} + +/** + * Позиционный звук мира демки: затухание (1 − d/радиус)^1.5, за радиусом — + * тишина; панорама по разности изо-экранных X источника и слушателя. Шаги — + * дедуп по поверхности. Слушатель — герой. + */ +export class DemoAudio { + private clock = 0; + private lastAt = new Map(); + private steps: StepVariants; + + constructor( + private audio: AudioManager, + /** Позиция слушателя (герой) в боевых юнитах. */ + private getListener: () => Hor + ) { + this.steps = new StepVariants(audio); + } + + /** Часы полифонии (тик демки). */ + update(dt: number): void { + this.clock += dt; + } + + /** Звук в точке мира (юниты): затухание по дистанции + панорама. */ + playAt(key: string, pos: Hor, base = 1, radius = 12): void { + const l = this.getListener(); + const d = Math.hypot(pos.x - l.x, pos.z - l.z); + const falloff = Math.max(0, 1 - d / radius); + const volume = base * Math.pow(falloff, 1.5); + if (volume < 0.02) return; + this.play(key, { volume, pan: panOf(pos, l) }); + } + + /** Шаг героя по поверхности: слышен только вблизи слушателя. */ + playStep(surface: StepSurface, pos: Hor, base = 1): void { + const l = this.getListener(); + const d = Math.hypot(pos.x - l.x, pos.z - l.z); + const volume = base * Math.max(0, 1 - d / 4); + if (volume < 0.02) return; + if ((this.lastAt.get(`step:${surface}`) ?? -1) + MIN_INTERVAL > this.clock) return; + this.lastAt.set(`step:${surface}`, this.clock); + this.steps.play(surface, volume); + } + + /** Разовый sfx с дедупом по ключу (боевые подписки демки). */ + play(key: string, opts: { volume: number; rate?: number; pan?: number }): void { + if ((this.lastAt.get(key) ?? -1) + MIN_INTERVAL > this.clock) return; + this.lastAt.set(key, this.clock); + playSfx(this.audio, key, opts); + } +} \ No newline at end of file diff --git a/v2/apps/demo/src/main.ts b/v2/apps/demo/src/main.ts index 2c5f450..bad7ac7 100644 --- a/v2/apps/demo/src/main.ts +++ b/v2/apps/demo/src/main.ts @@ -12,7 +12,9 @@ DialogueRunner, validateDialogue, nearestInteractable, Inventory, SaveManager, SceneManager, PlayerCombat, CombatWorld, moveCircleHor, PLAYER_COMBAT, + AudioManager, } from '@rpg/engine'; +import { DemoAudio } from './audio'; import type { VoxelCloud, DialogueGraph, DialogueState, DialogueView, Interactable } from '@rpg/engine'; import type { InventoryData, Scene, FadeOverlay, EnemyKindId, EnemyComp, Entity, Hor } from '@rpg/engine'; import * as THREE from 'three'; @@ -195,6 +197,19 @@ /** Игровое время (сек): таймеры вьюх — вспышки урона. */ let gameT = 0; +// ---- Аудио: спек-синтез (WAV-пайплайна в v2 нет), позиционный звук мира ---- +/** Контекст заводится синхронно (prepare) — шаги-варианты компилируются сразу. */ +const audio = new AudioManager((key) => key); +audio.prepare(); +/** DEV-шпион для гейта: кольцевой буфер фактических запусков звука (24 записи). */ +const audioLog: { key: string; volume: number; pan: number; rate: number }[] = []; +audio.onPlayed = (key, opts) => { + audioLog.push({ key, volume: opts.volume ?? 1, pan: opts.pan ?? 0, rate: opts.rate ?? 1 }); + if (audioLog.length > 24) audioLog.shift(); +}; +/** Позиционная обвязка: затухание + панорама, слушатель — герой (юниты v1). */ +const worldAudio = new DemoAudio(audio, heroPosU); + const combat = new CombatWorld({ getPlayerPos: heroPosU, /** Урон герою: неуязвимость/кламп — в PlayerCombat; отброс и отклик — здесь. */ @@ -207,6 +222,7 @@ hero.body.pos[0] = p.x * U; hero.body.pos[2] = p.z * U; hero.body.vel = [0, 0, 0]; + worldAudio.play('sfx/hurt', { volume: 0.8 }); // урон герою — без позиции renderHp(); }, solid: solidU, @@ -216,7 +232,16 @@ const v = enemyViews.get(e); if (v) v.flashUntil = gameT + 0.12; }, - onEnemyKilled: () => toasts.push('Сгусток развеялся'), + onEnemyKilled: (_kind, pos) => { + toasts.push('Сгусток развеялся'); + worldAudio.playAt('sfx/ash_die', pos, 0.8); + }, + onEnemyAwake: (_kind, pos) => worldAudio.playAt('sfx/ash_hiss', pos, 0.5), + onAttack: (mode, origin) => { + // Конус — звон удара, резонанс — низкий звон (v1 AudioSystem.attach) + worldAudio.playAt(mode === 'cone' ? 'sfx/bell_hit' : 'sfx/bell_low', origin, mode === 'cone' ? 0.8 : 0.9); + }, + onSpit: (pos) => worldAudio.playAt('sfx/spit', pos, 0.6), }, }); @@ -470,6 +495,7 @@ ArrowUp: [0, -1], ArrowDown: [0, 1], ArrowLeft: [-1, 0], ArrowRight: [1, 0], }; window.addEventListener('keydown', (e) => { + void audio.unlock(); // разблокировка аудио — жест пользователя if (KEYMAP[e.code]) { heldKeys.add(e.code); e.preventDefault(); @@ -508,6 +534,7 @@ let followHero = true; /** Один шаг логики: ввод → герой → бой → облака → камера (шаг пиксельной сетки). */ +let lastStepIdx = -1; // половина walk-цикла = один шаг (для звука шагов) function tick(dt: number): void { gameT += dt; const move = agentInput ?? keyboardMove(); @@ -517,6 +544,13 @@ playerCombat.update(dt); // шаги героя — тихий шум (0.35): бодрые сгустки в hearRadius настораживаются if (Math.hypot(hero.body.vel[0], hero.body.vel[2]) > 0.1) combat.noise(heroPosU(), 0.35); + // звук шага — на границе половины цикла (два шага на walk-цикл) + const stepIdx = Math.floor(hero.phase * 2); + if (stepIdx !== lastStepIdx && Math.hypot(hero.body.vel[0], hero.body.vel[2]) > 0.1) { + lastStepIdx = stepIdx; + worldAudio.playStep('ash', heroPosU()); + } + worldAudio.update(dt); combat.update(dt); syncEnemies(); if (playerCombat.dead) { @@ -765,6 +799,11 @@ stepTicks(n: number): number { return loop.stepTick(n, true); }, + /** Аудио: разблокировать контекст (гейт без жестов) и снять DEV-лог. */ + audioUnlock(): void { void audio.unlock(); }, + audioInfo() { + return { log: [...audioLog] }; + }, /** Ручной/автоматический режим цикла. */ setManual(on: boolean): void { loop.setManual(on); diff --git a/v2/apps/demo/tools/checks/audio.mjs b/v2/apps/demo/tools/checks/audio.mjs new file mode 100644 index 0000000..3a81542 --- /dev/null +++ b/v2/apps/demo/tools/checks/audio.mjs @@ -0,0 +1,108 @@ +/** + * Гейт аудио v2 (трек «аудио»): на слух в headless не проверить — читаем + * DEV-шпион audioInfo() (кольцевой буфер фактических запусков, 24 записи). + * 1) вход в игру; 2) тишина на старте (нет амбиента/боевых звуков); + * 3) шаги героя играют (варианты step_ash); 4) атака — bell_hit; + * 5) плевок плевуна — spit (позиционный, с затуханием); 6) резонанс — + * bell_low; 7) лог ограничен 24 записями. + * + * Запуск: node apps/demo/tools/checks/audio.mjs [--port 5313] + */ +import { fileURLToPath } from 'node:url'; +import { launchBrowser, startDevServer, openGame, parseArgs } from '@rpg/engine/tools/agent-lib.mjs'; + +const args = parseArgs(process.argv.slice(2)); +const port = Number(args.port ?? 5313); +const DEMO_DIR = fileURLToPath(new URL('../../', import.meta.url)); + +const server = startDevServer({ port, cwd: DEMO_DIR }); +const report = { name: 'audio', ok: false, checks: [] }; +const check = (title, ok, info = null) => { + report.checks.push({ title, ok, info }); + console.error(`${ok ? '✓' : '✗'} ${title}${info ? ' — ' + JSON.stringify(info) : ''}`); +}; + +/** Ключи в DEV-логе (фактические запуски звука). */ +const keys = (client) => client.call('audioInfo').then((i) => i.log.map((e) => e.key)); + +/** Чистая поляна у точки старта: герой (40,60) вокс = (13.33,20) юнит. */ +const H = { x: 40, z: 60 }; + +try { + await server.waitReady; + const browser = await launchBrowser(); + try { + const client = await openGame(browser, { url: `http://localhost:${port}/` }); + const errs = client.errors(); + check('нет pageerror при загрузке', errs.length === 0, errs.slice(0, 3)); + + await client.call('setManual', true); + // Демка стартует в меню — входим в игру и проходим фейд (48 тиков) + запас + await client.call('startGame', false); + await client.call('stepTicks', 60); + const si = await client.call('sceneInfo'); + check('вошли в игру', si.current === 'game' && !si.transitioning, si); + + // На лугах тишина: амбиента в демке нет, герой не двигался, боя не было + const log0 = await keys(client); + check('на старте тишина (шпион подключён)', Array.isArray(log0) && log0.length === 0, log0); + + // Шаги героя: движение → step_ash в логе (варианты #0/#1/#2 — буферы + // рантайм-синтеза; до unlock играл бы базовый спек) + await client.call('setInput', 1, 1); + await client.call('stepTicks', 30); + await client.call('setInput', null, null); + await client.call('stepTicks', 2); + const log1 = await keys(client); + check('шаги героя играют (step_ash)', log1.some((k) => k.startsWith('sfx/step_')), log1); + + // Атака конусом у ног героя: звон удара (volume с позиции d=0 → base) + await client.call('attackInput', true); + await client.call('attackInput', false); + await client.call('stepTicks', 5); + const log2 = await keys(client); + check('атака сыграла bell_hit', log2.some((k) => k === 'sfx/bell_hit'), log2); + + // Плевок плевуна: позиционный звук — сгусток в радиусе слышимости (12), + // но не в конусе (2.17 юнита > 1.0): звон будит, плевун стреляет (~2.2 с) + // Кулдаун конуса 0.45 с — ждём, иначе атака-пробуждение не выйдет + await client.call('stepTicks', 30); + await client.call('combatClear'); + await client.call('heroTeleport', H.x, H.z); + await client.call('stepTicks', 2); + await client.call('combatSpawn', 'spitter', 15.5, 20); + await client.call('attackInput', true); + await client.call('attackInput', false); // звон-пробуждение без урона + await client.call('stepTicks', 240); + const log3 = await keys(client); + check('плевок сыграл spit', log3.some((k) => k === 'sfx/spit'), log3); + + // Резонанс: заряд ≥ 0.6 с → низкий звон (мимо сгустков — тишина боя) + await client.call('combatClear'); + await client.call('stepTicks', 2); + await client.call('attackInput', true); + await client.call('stepTicks', 40); // 0.67 с — заряд накоплен + await client.call('attackInput', false); + await client.call('stepTicks', 5); + const log4 = await keys(client); + check('резонанс сыграл bell_low', log4.some((k) => k === 'sfx/bell_low'), log4); + + // Кольцевой буфер: долгая прогулка — шагов больше 24, лог обрезается + await client.call('setInput', 1, 1); + await client.call('stepTicks', 600); // ~25 шагов при 2.5 шага/с + await client.call('setInput', null, null); + await client.call('stepTicks', 2); + const info = await client.call('audioInfo'); + check('лог ограничен 24 записями', info.log.length <= 24 && info.log.length > 0, info.log.length); + + await client.call('combatClear'); + report.ok = report.checks.every((c) => c.ok); + } finally { + await browser.close(); + } +} catch (err) { + check('гейт упал', false, String(err)); +} finally { + server.stop(); + console.log(JSON.stringify(report)); +} \ No newline at end of file diff --git a/v2/packages/engine/src/audio/AudioManager.ts b/v2/packages/engine/src/audio/AudioManager.ts new file mode 100644 index 0000000..031e585 --- /dev/null +++ b/v2/packages/engine/src/audio/AudioManager.ts @@ -0,0 +1,292 @@ +/** + * WebAudio с четырьмя шинами (master/music/sfx/ambience): публичный фасад. + * Полифония sfx — Voices, лупы/кроссфейд — Loops, кэш буферов — BufferCache. + * Игры подключают Settings.onChange и вызывают setBusVolume. + */ +import { BufferCache } from './decode'; +import { LoopSlots } from './Loops'; +import { renderSpec, RATE, type SoundSpec } from './SoundSpec'; +import { SfxVoices } from './Voices'; +import { + DEFAULT_FADE, + type AudioBuses, + type BusName, + type LoopOptions, + type MusicHandle, + type PlayOptions, + type SfxHandle +} from './types'; + +export { type AudioBuses, type BusName, type MusicHandle, type PlayOptions, type SfxHandle } from './types'; + +export class AudioManager { + private ctx: AudioContext | null = null; + private buses: AudioBuses | null = null; + /** Буферы спек-синтеза (playSpec): компиляция один раз на ключ. */ + private specBuffers = new Map(); + private cache = new BufferCache(); + private voices: SfxVoices; + private loops = new LoopSlots(); + /** Слушатель visibility (снимается в dispose). */ + private onVisibility: (() => void) | null = null; + + /** + * Шпионский хук: вызывается при каждом фактическом запуске звука — + * и разового sfx, и лупов (музыка/амбиент/слои; rate 1, pan 0). + * Для тестов и DEV-лога игры. + */ + onPlayed?: (key: string, opts: PlayOptions) => void; + + /** Звук доиграл до конца (stop/вор/новый restart — не считаются). */ + onEnded?: (key: string) => void; + + constructor( + private resolveUrl: (key: string) => string, + private ctxFactory: () => AudioContext = () => new AudioContext(), + { maxVoices = 24, pauseOnHide = true }: { maxVoices?: number; pauseOnHide?: boolean } = {} + ) { + this.voices = new SfxVoices(maxVoices); + this.voices.onEnded = (key) => this.onEnded?.(key); + // Свернул таб — тишина, вернулся — звук сам (сверху не нужен жест, мы + // уже разблокированы); движку без DOM (тесты) listener не ставится. + if (pauseOnHide && typeof document !== 'undefined') { + this.onVisibility = () => { + if (document.hidden) { + void this.ctx?.suspend(); + } else if (this.ctx?.state === 'suspended') { + void this.ctx.resume(); + } + }; + document.addEventListener('visibilitychange', this.onVisibility); + } + } + + /** Создать контекст и шины синхронно (без resume) — для prepare(). */ + private createCtx(): void { + if (this.ctx) return; + this.ctx = this.ctxFactory(); + const master = this.ctx.createGain(); + const music = this.ctx.createGain(); + const sfx = this.ctx.createGain(); + const ambience = this.ctx.createGain(); + music.connect(master); + sfx.connect(master); + ambience.connect(master); + master.connect(this.ctx.destination); + this.buses = { master, music, sfx, ambience }; + } + + /** + * Синхронно завести контекст без resume: после него createBuffer работает, + * а первый play доведёт разблокировку до конца (resume — асинхронный). + * Нужно генеративным лупам (темы), которые компилируются вне async-цепочки. + */ + prepare(): void { + this.createCtx(); + } + + /** Расблокировать аудио — вызвать из обработчика пользовательского ввода. */ + async unlock(): Promise { + this.createCtx(); + if (this.ctx?.state === 'suspended') { + await this.ctx.resume(); + } + } + + /** Громкость шины 0..1 (до unlock шин ещё нет — вызов игнорируется). */ + setBusVolume(bus: BusName, volume: number): void { + if (this.buses) { + this.buses[bus].gain.value = Math.max(0, Math.min(1, volume)); + } + } + + /** Зарегистрировать URL ключа (файл читается при play/preload). */ + register(key: string): void { + this.cache.register(key, this.resolveUrl(key)); + } + + /** + * Декодировать заранее (после unlock, например в сцене загрузки), + * чтобы первый play не тратил время на fetch+decode. + */ + async preload(key: string): Promise { + const ctx = await this.ensureContext(); + if (!ctx) return; + await this.cache.decode(key, ctx); + } + + /** + * Проиграть sfx и вернуть хендл играющего голоса (null — не декодировался + * или контекст не готов; хендл «мёртв» после stop и естественного конца). + * opts — число (громкость, обратная совместимость) или PlayOptions. + */ + async play(key: string, opts?: number | PlayOptions): Promise { + const ctx = await this.ensureContext(); + if (!ctx || !this.buses) return null; + const buf = await this.cache.decode(key, ctx); + if (!buf) return null; + return this.startSfx(ctx, key, buf, opts); + } + + /** + * Проиграть буфер из памяти как sfx (процедурные звуки в рантайме). Ключ — + * логическое имя голоса (для restart, полифонии и шпионов), не файл. + */ + async playBuffer(key: string, buffer: AudioBuffer, opts?: number | PlayOptions): Promise { + const ctx = await this.ensureContext(); + if (!ctx || !this.buses) return null; + return this.startSfx(ctx, key, buffer, opts); + } + + /** + * Буфер из FLOAT-сэмплов (для процедурных звуков в рантайме): моно + * (channels 1) или interleaved L,R (channels 2 — стерео-рендеры движка). + * До unlock контекста нет — null; создать буфер по ключу не получится, + * вызывать после первого play/unlock. + */ + createBuffer(data: Float32Array, sampleRate = 22050, channels: 1 | 2 = 1): AudioBuffer | null { + if (!this.ctx) return null; + const frames = Math.floor(data.length / channels); + const buf = this.ctx.createBuffer(channels, frames, sampleRate); + // DOM-тип требует Float32Array — сэмплы из генераторов не обязаны. + if (channels === 1) { + buf.copyToChannel(data as Float32Array, 0); + } else { + const l = new Float32Array(frames); + const r = new Float32Array(frames); + for (let i = 0; i < frames; i++) { + l[i] = data[i * 2]!; + r[i] = data[i * 2 + 1]!; + } + buf.copyToChannel(l as Float32Array, 0); + buf.copyToChannel(r as Float32Array, 1); + } + return buf; + } + + /** + * Синтез по спеку (см. SoundSpec): детерминированно компилирует описание + * звука в буфер (кэш по ключу) и играет как sfx. Для ИИ-агента: описывает + * звук параметрами, без файлов; факт запуска виден в шпионе onPlayed. + */ + async playSpec(key: string, spec: SoundSpec, opts?: number | PlayOptions): Promise { + const ctx = await this.ensureContext(); + if (!ctx || !this.buses) 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, opts); + } + + /** + * Длительность буфера в секундах (после preload/декода); не декодирован — + * null. Для катсцен и генераторов, синхронизирующихся со звуком. + */ + duration(key: string): number | null { + return this.cache.cached(key)?.duration ?? null; + } + + /** + * Играть луп в заданной шине; НЕ занимает ни слот музыки, ни слот амбиента — + * база для локальных амбиент-слоёв (несколько параллельных лупов). + */ + async playLoop(key: string, opts: LoopOptions = {}): Promise { + return this.playLoopFrom(key, opts); + } + + /** + * Луп из буфера в памяти (процедурная музыка: renderMusic → буфер → стем + * на шине). Ключ — логическое имя слота (для шпионов), не файл. + */ + async playLoopBuffer(key: string, buffer: AudioBuffer, opts: LoopOptions = {}): Promise { + const ctx = await this.ensureContext(); + if (!ctx || !this.buses) return null; + const full = this.loopOpts(opts); + const slot = this.loops.startSlot(key, ctx, this.buses, buffer, full, this.played()); + return this.loops.makeHandle(slot, full.fade); + } + + /** + * Играть музыку с кроссфейдом: текущий трек затухает, новый нарастает + * за fadeSeconds. Слот один — повторный вызов вытесняет прежний трек. + */ + async playMusic(key: string, opts: Omit = {}): Promise { + const ctx = await this.ensureContext(); + if (!ctx || !this.buses) return null; + const buf = await this.cache.decode(key, ctx); + if (!buf) return null; // негодный ключ не должен заглушить текущий трек + const full = this.loopOpts(opts); + const slot = this.loops.swapMusic(key, ctx, this.buses, buf, full, this.played()); + return this.loops.makeHandle(slot, full.fade); + } + + /** Остановить музыку (с затуханием). */ + stopMusic(fadeSeconds = 0.5): void { + this.loops.stopMusic(fadeSeconds); + } + + /** + * Играть амбиент с кроссфейдом — слот отдельный от музыки: + * playAmbience и playMusic не вытесняют друг друга. + */ + async playAmbience(key: string, { fade = DEFAULT_FADE, volume = 1 }: { fade?: number; volume?: number } = {}): Promise { + const ctx = await this.ensureContext(); + if (!ctx || !this.buses) return null; + const buf = await this.cache.decode(key, ctx); + if (!buf) return null; // негодный ключ не должен заглушить текущий амбиент + const slot = this.loops.swapAmbience(key, ctx, this.buses, buf, { loop: true, fade, volume, bus: 'ambience' }, this.played()); + return this.loops.makeHandle(slot, fade); + } + + /** Остановить амбиент (с затуханием). */ + stopAmbience(fadeSeconds = 0.5): void { + this.loops.stopAmbience(fadeSeconds); + } + + /** Погасить всё и снять слушатели (пересоздание аудио/HMR — без утечек). */ + dispose(): void { + if (this.onVisibility) { + document.removeEventListener('visibilitychange', this.onVisibility); + this.onVisibility = null; + } + this.voices.killAll(); + this.loops.stopMusic(0.05); + this.loops.stopAmbience(0.05); + } + + /** Общий запуск голоса sfx (нормализация opts-числа — здесь). */ + private startSfx(ctx: AudioContext, key: string, buf: AudioBuffer, opts?: number | PlayOptions): SfxHandle | null { + const o: PlayOptions = typeof opts === 'number' ? { volume: opts } : (opts ?? {}); + return this.voices.start(ctx, this.buses!, key, buf, o, (k, n) => this.onPlayed?.(k, n)); + } + + /** Нормализованные опции лупа. */ + private loopOpts(opts: LoopOptions): Required { + return { loop: opts.loop ?? true, fade: opts.fade ?? DEFAULT_FADE, volume: opts.volume ?? 1, bus: opts.bus ?? 'music' }; + } + + /** Шпионский колбэк для Loops (startSlot/swap* зовут на каждый запуск). */ + private played(): (key: string, opts: PlayOptions) => void { + return (key, opts) => this.onPlayed?.(key, opts); + } + + /** Файловый луп без слота (playLoop): декод + независимый запуск. */ + private async playLoopFrom(key: string, opts: LoopOptions): Promise { + const ctx = await this.ensureContext(); + if (!ctx || !this.buses) return null; + const buf = await this.cache.decode(key, ctx); + if (!buf) return null; + const full = this.loopOpts(opts); + const slot = this.loops.startSlot(key, ctx, this.buses, buf, full, this.played()); + return this.loops.makeHandle(slot, full.fade); + } + + private async ensureContext(): Promise { + await this.unlock(); + return this.ctx; + } +} \ No newline at end of file diff --git a/v2/packages/engine/src/audio/Loops.ts b/v2/packages/engine/src/audio/Loops.ts new file mode 100644 index 0000000..1cd29c7 --- /dev/null +++ b/v2/packages/engine/src/audio/Loops.ts @@ -0,0 +1,120 @@ +/** + * Лупы (музыка/амбиент/локальные слои) — перенос v1: запуск из буфера, + * кроссфейд, слоты музыки и амбиента (взаимно не вытесняют). Выбор трека — + * AudioManager. + */ +import { MIN_FADE_VOLUME, type AudioBuses, type LoopOptions, type MusicHandle, type PlayOptions } from './types'; + +/** Запущенный луп: контекст + источник + огибающая громкости. */ +export interface Slot { + ctx: AudioContext; + source: AudioBufferSourceNode; + gain: GainNode; +} + +export class LoopSlots { + private currentMusic: Slot | null = null; + private currentAmbience: Slot | null = null; + + /** + * Запуск лупа из готового буфера: источник + линейное нарастание громкости. + * Не занимает ни слот музыки, ни слот амбиента — база локальных слоёв. + */ + startSlot( + key: string, + ctx: AudioContext, + buses: AudioBuses, + buf: AudioBuffer, + { loop, fade, volume, bus }: Required, + onPlayed: (key: string, opts: PlayOptions) => void + ): Slot { + const source = ctx.createBufferSource(); + source.buffer = buf; + source.loop = loop; + const gain = ctx.createGain(); + gain.gain.setValueAtTime(MIN_FADE_VOLUME, ctx.currentTime); + gain.gain.linearRampToValueAtTime(volume, ctx.currentTime + fade); + source.connect(gain).connect(buses[bus]); + source.start(); + onPlayed(key, { volume, rate: 1, pan: 0 }); + return { ctx, source, gain }; + } + + /** Handle над слотом: затухание + мгновенная громкость (без борьбы с ramp). */ + makeHandle(slot: Slot, fade: number): MusicHandle { + return { + stop: (fadeSeconds = fade) => this.fadeOut(slot, fadeSeconds), + setVolume: (volume: number) => { + const t = slot.ctx.currentTime; + slot.gain.gain.cancelScheduledValues(t); + slot.gain.gain.setValueAtTime(Math.max(0, volume), t); + } + }; + } + + /** + * Слот музыки: новый трек вытесняет прежний (кроссфейд). Негодный ключ не + * должен заглушить текущий трек — проверка decode до вызова. + */ + swapMusic( + key: string, + ctx: AudioContext, + buses: AudioBuses, + buf: AudioBuffer, + opts: Required, + onPlayed: (key: string, opts: PlayOptions) => void + ): Slot { + this.stopMusic(opts.fade); + const slot = this.startSlot(key, ctx, buses, buf, opts, onPlayed); + this.currentMusic = slot; + return slot; + } + + /** Слот амбиента — отдельный от музыки: playAmbience и playMusic не конфликтуют. */ + swapAmbience( + key: string, + ctx: AudioContext, + buses: AudioBuses, + buf: AudioBuffer, + opts: Required, + onPlayed: (key: string, opts: PlayOptions) => void + ): Slot { + this.stopAmbience(opts.fade); + const slot = this.startSlot(key, ctx, buses, buf, opts, onPlayed); + this.currentAmbience = slot; + return slot; + } + + /** Остановить музыку (с затуханием). */ + stopMusic(fadeSeconds: number): void { + const current = this.currentMusic; + if (!current) return; + this.currentMusic = null; + this.fadeOut(current, fadeSeconds); + } + + /** Остановить амбиент (с затуханием). */ + stopAmbience(fadeSeconds: number): void { + const current = this.currentAmbience; + if (!current) return; + this.currentAmbience = null; + this.fadeOut(current, fadeSeconds); + } + + /** Затухание слота (экспонента — слуху линейный спад слышен ступенькой). */ + private fadeOut(slot: Slot, fadeSeconds: number): void { + const t = slot.ctx.currentTime; + slot.gain.gain.cancelScheduledValues(t); + // Экспоненциальный ramp не определён от нуля — поднимаем до минимума. + const from = Math.max(slot.gain.gain.value, MIN_FADE_VOLUME); + slot.gain.gain.setValueAtTime(from, t); + slot.gain.gain.exponentialRampToValueAtTime(MIN_FADE_VOLUME, t + fadeSeconds); + setTimeout(() => { + try { + slot.source.stop(); + } catch { + // уже остановлен + } + }, fadeSeconds * 1000 + 50); + } +} \ No newline at end of file diff --git a/v2/packages/engine/src/audio/Music.ts b/v2/packages/engine/src/audio/Music.ts new file mode 100644 index 0000000..35d5c8d --- /dev/null +++ b/v2/packages/engine/src/audio/Music.ts @@ -0,0 +1,167 @@ +/** + * Музыкальный секвенсор движка: партитура (ноты + инструменты) → сэмплы-луп. + * Адресат — ИИ-агент: описывает партию нотами (MIDI), движок детерминированно + * компилирует её в буфер (AudioManager.playLoopBuffer — стем на шине music). + * Инструменты — осциллятор + огибающая поверх примитивов synth.mjs; сам + * рендер чистый (без ГПСЧ) — детерминизм мелодии обеспечивают сиды + * генератора нот на стороне игры/агента, а не рендера. + */ +import { RATE, decay, loopify, normalize } from './synth'; + +/** Инструменты: осциллятор + огибающая (набор минимальный, расширять по мере надобности). */ +export type InstrumentKind = 'pluck' | 'bell' | 'pad'; + +/** Нота: старт и длина в долях (beats), высота в MIDI (69 = A4 = 440 Гц). */ +export interface NoteEvent { + beat: number; + dur: number; + midi: number; + /** Громкость ноты 0..1 (по умолчанию 0.8). */ + vel?: number; +} + +/** Партия = трек = стем: инструмент + ноты + громкость трека. */ +export interface TrackSpec { + instrument: InstrumentKind; + notes: NoteEvent[]; + /** Громкость трека (по умолчанию 1). */ + gain?: number; + /** Панорама -1..1 (только в стерео-рендере; по умолчанию 0 — центр). */ + pan?: number; +} + +/** Партитура лупа: темп, длина в долях, партии. */ +export interface MusicSpec { + /** Темп, долей в минуту. */ + bpm: number; + /** Длина лупа в долях (ноты, вылезшие за край, обрежаются). */ + beats: number; + tracks: TrackSpec[]; +} + +const midiHz = (midi: number): number => 440 * Math.pow(2, (midi - 69) / 12); + +/** Партиалы [множитель частоты, доля амплитуды] для мелодических инструментов. */ +const PLUCK_PARTIALS: [number, number][] = [ + [1, 0.6], + [2, 0.25], + [3, 0.15] +]; +const BELL_PARTIALS: [number, number][] = [ + [1, 0.5], + [1.52, 0.3], + [2.38, 0.2] +]; + +/** Сумма партиалов с экспоненциальным затуханием — pluck/bell. */ +function partialVoice(partials: [number, number][], f: number, ndur: number, power: number): (t: number) => number { + return (t) => { + let v = 0; + for (const [k, amp] of partials) { + v += Math.sin(2 * Math.PI * f * k * t) * amp * decay(t, ndur, power); + } + return v; + }; +} + +/** Пэд: sine + слегка расстроенная копия, мягкая атака/релиз вместо щелчка. */ +function padVoice(f: number, ndur: number): (t: number) => number { + const attack = Math.min(0.3, ndur * 0.25); + const release = Math.min(0.5, ndur * 0.35); + return (t) => { + const env = Math.min(1, t / attack) * Math.min(1, Math.max(0, (ndur - t) / release)); + return (Math.sin(2 * Math.PI * f * t) + Math.sin(2 * Math.PI * f * 1.004 * t)) * 0.5 * env; + }; +} + +/** Голос ноты: (t сек от начала ноты) → значение волны. */ +function voice(kind: InstrumentKind, f: number, ndur: number): (t: number) => number { + switch (kind) { + case 'pluck': + return partialVoice(PLUCK_PARTIALS, f, ndur, 3); + case 'bell': + return partialVoice(BELL_PARTIALS, f, ndur, 4); + case 'pad': + return padVoice(f, ndur); + } +} + +/** + * Суммирование нот всех партий в два канала. Моно-рендер кладёт всё в левый + * канал с весами 1 (pan не слышен); стерео — раскладывает по equal-power + * панораме трека. + */ +function mixTracks( + spec: MusicSpec, + total: number, + stereo: boolean +): [Float32Array, Float32Array] { + const spb = 60 / Math.max(1, spec.bpm); // сек на долю + const left = new Float32Array(total); + const right = new Float32Array(total); + for (const track of spec.tracks) { + const gain = track.gain ?? 1; + // Equal-power панорама: 0 → центр (обе 0.707), -1 → лево, 1 → право. + const a = ((Math.max(-1, Math.min(1, track.pan ?? 0)) + 1) * Math.PI) / 4; + const gl = stereo ? Math.cos(a) : 1; + const gr = stereo ? Math.sin(a) : 1; + for (const n of track.notes) { + const start = Math.round(n.beat * spb * RATE); + const len = Math.max(1, Math.round(n.dur * spb * RATE)); + const wave = voice(track.instrument, midiHz(n.midi), len / RATE); + const vel = (n.vel ?? 0.8) * gain; + for (let i = 0; i < len && start + i < total; i++) { + const v = wave(i / RATE) * vel; + left[start + i] += v * gl; + right[start + i] += v * gr; + } + } + } + return [left, right]; +} + +/** Нормализация к пику 0.8 по общему пику каналов + кроссфейд краёв лупа. */ +function finalizeLoop(left: Float32Array, right: Float32Array): void { + let peakMax = 0; + for (let i = 0; i < left.length; i++) + peakMax = Math.max(peakMax, Math.abs(left[i]!), Math.abs(right[i]!)); + const k = peakMax > 0 ? 0.8 / peakMax : 0; + if (k !== 1) { + for (let i = 0; i < left.length; i++) { + left[i] = left[i]! * k; + right[i] = right[i]! * k; + } + } + const fade = Math.min(0.5, (left.length / RATE) * 0.25); + loopify(left, fade); + loopify(right, fade); +} + +/** + * Партитура → моно-сэмплы-луп (RATE): ноты всех партий суммируются, луп + * сшивается кроссфейдом и нормализуется к пику 0.8. Одинаковые спеки дают + * одинаковые сэмплы (рендер чистый); pan треков не слышен. + */ +export function renderMusic(spec: MusicSpec): Float32Array { + const total = Math.max(1, Math.round(spec.beats * (60 / Math.max(1, spec.bpm)) * RATE)); + const [left] = mixTracks(spec, total, false); + return loopify(normalize(left, 0.8), Math.min(0.5, (total / RATE) * 0.25)); +} + +/** + * Партитура → стерео-сэмплы-луп (RATE, interleaved L,R): треки раскладываются + * по панораме `pan` (equal-power), нормализация к пику 0.8 — по общему пику + * каналов (баланс не искажается), кроссфейд краёв синхронный. Проигрывается + * как обычный AudioBuffer: `createBuffer(data, RATE, 2)`. + */ +export function renderMusicStereo(spec: MusicSpec): Float32Array { + const total = Math.max(1, Math.round(spec.beats * (60 / Math.max(1, spec.bpm)) * RATE)); + const [left, right] = mixTracks(spec, total, true); + finalizeLoop(left, right); + const out = new Float32Array(total * 2); + for (let i = 0; i < total; i++) { + out[i * 2] = left[i]!; + out[i * 2 + 1] = right[i]!; + } + return out; +} \ No newline at end of file diff --git a/v2/packages/engine/src/audio/SoundSpec.ts b/v2/packages/engine/src/audio/SoundSpec.ts new file mode 100644 index 0000000..422cc38 --- /dev/null +++ b/v2/packages/engine/src/audio/SoundSpec.ts @@ -0,0 +1,235 @@ +import { + RATE, + bandNoise, + bellPartial, + decay, + loopify, + makeRng, + normalize, + synth +} from './synth'; + +/** + * Спек-синтез: детерминированная компиляция описания звука в сэмплы. + * Адресат — ИИ-агент (движок агент-первый): описывает звук параметрами, + * без слуха и без библиотек сэмплов; движок компилирует спек в буфер + * (AudioManager.playSpec), факт запуска наблюдаем через шпион onPlayed. + * Формулы — те же примитивы, что у файлового генератора игры + * (tools/synth.mjs), поэтому спек-звук и WAV-звук звучат в одной палитре. + */ + +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; + /** Длительность, сек (0.05..4; hum — до 8). */ + dur: number; + /** Полоса шума, Гц (hit/scrape; дефолты по kind). */ + low?: number; + high?: number; + /** Низкий тон, Гц: подложка удара/шороха или основной тон гула. */ + tone?: number; + /** Частота тона/звона, Гц (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). */ + peak?: number; + /** Огибающая «0 → 1 → 0» вместо спада с самого громкого старта (воздух, свуш). */ + swell?: boolean; + /** Зациклить (кроссфейд краёв; для hum — по умолчанию). */ + loop?: boolean; +} + +/** Клэмп длительности: снизу — против нулевых буферов, сверху — по kind. */ +const clampDur = (dur: number, max: number): number => Math.max(0.05, Math.min(max, dur)); + +/** Огибающая: спад с самого громкого старта или «0 → 1 → 0» (swell). */ +function envelope(spec: SoundSpec, dur: number, power: number): (t: number) => number { + return spec.swell + ? (t) => Math.sin(Math.PI * Math.min(1, t / dur)) + : (t) => decay(t, dur, power); +} + +/** Нарастание: линейное от старта до полной громкости за 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 = 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 + ); +} + +/** Голос слоя: шум (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 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 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 (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 humBase(spec: SoundSpec, dur: number): (t: number) => number { + const tone = spec.tone ?? 55; + const breath = spec.power ?? 2; // число периодов «дыхания» на луп + 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; + }; +} + +/** + * Спек → сэмплы (моно, RATE). Одинаковые спеки дают одинаковые сэмплы + * (детерминизм: сиды фикс, ГПСЧ детерминирован). + */ +export function renderSpec(spec: SoundSpec): Float32Array { + const dur = clampDur(spec.dur, spec.kind === 'hum' ? 8 : 4); + const env = envelope(spec, dur, spec.power ?? 2); + const base = + spec.kind === 'hit' + ? hitBase(spec, dur, env) + : spec.kind === 'chime' + ? chimeBase(spec, dur) + : spec.kind === 'scrape' + ? 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)); + } + return samples; +} + +export { RATE }; \ No newline at end of file diff --git a/v2/packages/engine/src/audio/Voices.ts b/v2/packages/engine/src/audio/Voices.ts new file mode 100644 index 0000000..f4239f9 --- /dev/null +++ b/v2/packages/engine/src/audio/Voices.ts @@ -0,0 +1,154 @@ +/** + * Полифония разовых sfx (перенос v1): учёт играющих голосов, лимит с + * воровством самого тихого, restart по ключу, панорама в полёте. Шины и + * контекст даёт AudioManager. + */ +import { clamp, MIN_FADE_VOLUME, type AudioBuses, type BusName, type PlayOptions, type SfxHandle } from './types'; + +/** Голос разового sfx: узлы + ключ (учёт для лимита полифонии и restart). */ +interface SfxVoice { + key: string; + /** Контекст и шины запуска (фейки тестов не дают source.context). */ + ctx: AudioContext; + buses: AudioBuses; + source: AudioBufferSourceNode; + gain: GainNode; + panner: StereoPannerNode | null; + /** Шина голоса (pan в полёте переподключается в ней же, не в sfx). */ + bus: BusName; + /** Заглушен принудительно (stop/вор/новый restart) — onEnded не зовётся. */ + killed: boolean; +} + +export class SfxVoices { + private voices: SfxVoice[] = []; + /** Звук доиграл до конца (stop/вор/новый restart — не считаются). */ + onEnded?: (key: string) => void; + + /** @param maxVoices лимит одновременных голосов (0 — без лимита). */ + constructor(private maxVoices: number) {} + + /** + * Общий запуск голоса (play — после декода, playBuffer — как есть). + * onPlayed зовётся после фактического source.start с нормализованными опциями. + */ + start( + ctx: AudioContext, + buses: AudioBuses, + key: string, + buf: AudioBuffer, + o: PlayOptions, + onPlayed: (key: string, opts: PlayOptions) => void + ): SfxHandle | null { + const volume = o.volume ?? 1; + const rate = clamp(o.rate ?? 1, 0.5, 2); + const pan = clamp(o.pan ?? 0, -1, 1); + const bus = o.bus ?? 'sfx'; + if (o.restart) this.stopKey(key); + this.stealIfNeeded(); + + const source = ctx.createBufferSource(); + source.buffer = buf; + source.playbackRate.value = rate; + const gain = ctx.createGain(); + gain.gain.value = volume; + source.connect(gain); + let panner: StereoPannerNode | null = null; + if (Math.abs(pan) >= 0.01 && typeof ctx.createStereoPanner === 'function') { + panner = ctx.createStereoPanner(); + panner.pan.value = pan; + gain.connect(panner).connect(buses[bus]); + } else { + gain.connect(buses[bus]); + } + source.start(); + const voice: SfxVoice = { key, ctx, buses, source, gain, panner, bus, killed: false }; + source.onended = () => { + this.release(voice); + if (!voice.killed) this.onEnded?.(key); + }; + this.voices.push(voice); + onPlayed(key, { volume, rate, pan }); + return this.makeHandle(voice); + } + + /** Остановить все играющие голоса ключа (короткий фейд). */ + stopKey(key: string, fadeSeconds = 0.05): void { + for (const voice of [...this.voices]) { + if (voice.key === key) this.kill(voice, fadeSeconds); + } + } + + /** Погасить все голоса (dispose менеджера). */ + killAll(): void { + for (const voice of [...this.voices]) this.kill(voice, 0.05); + } + + /** Хендл над голосом: после снятия с учёта (stop/конец) — no-op. */ + private makeHandle(voice: SfxVoice): SfxHandle { + const alive = () => this.voices.includes(voice); + return { + stop: (fadeSeconds = 0.05) => { + if (alive()) this.kill(voice, fadeSeconds); + }, + setVolume: (v: number) => { + if (alive()) voice.gain.gain.value = Math.max(0, v); + }, + setPan: (p: number) => { + if (alive()) this.setPan(voice, clamp(p, -1, 1)); + }, + setRate: (r: number) => { + if (alive()) voice.source.playbackRate.value = clamp(r, 0.5, 2); + } + }; + } + + /** Панорама голоса: узел создаётся при первом ненулевом значении — в шине голоса. */ + private setPan(voice: SfxVoice, pan: number): void { + if (voice.panner) { + voice.panner.pan.value = pan; + return; + } + if (Math.abs(pan) < 0.01 || typeof voice.ctx.createStereoPanner !== 'function') return; + const panner = voice.ctx.createStereoPanner(); + panner.pan.value = pan; + voice.gain.disconnect(); + voice.gain.connect(panner).connect(voice.buses[voice.bus]); + voice.panner = panner; + } + + /** Лимит голосов: при переполнении воруется самый тихий (ничья — самый старый). */ + private stealIfNeeded(): void { + while (this.maxVoices > 0 && this.voices.length >= this.maxVoices) { + let victim = this.voices[0]!; + for (const v of this.voices) { + if (v.gain.gain.value < victim.gain.gain.value) victim = v; + } + this.kill(victim, 0.05); + } + } + + /** Затухание голоса + снятие с учёта (handle сразу «мёртв»). */ + private kill(voice: SfxVoice, fadeSeconds: number): void { + voice.killed = true; + const t = voice.ctx.currentTime; + voice.gain.gain.cancelScheduledValues(t); + const from = Math.max(voice.gain.gain.value, MIN_FADE_VOLUME); + voice.gain.gain.setValueAtTime(from, t); + voice.gain.gain.exponentialRampToValueAtTime(MIN_FADE_VOLUME, t + fadeSeconds); + setTimeout(() => { + try { + voice.source.stop(); + } catch { + // уже остановлен + } + }, fadeSeconds * 1000 + 50); + this.release(voice); + } + + /** Снять голос с учёта (idempotent — зовётся и из onended, и из kill). */ + private release(voice: SfxVoice): void { + const i = this.voices.indexOf(voice); + if (i >= 0) this.voices.splice(i, 1); + } +} \ No newline at end of file diff --git a/v2/packages/engine/src/audio/__tests__/AudioManager.test.ts b/v2/packages/engine/src/audio/__tests__/AudioManager.test.ts new file mode 100644 index 0000000..9ea1207 --- /dev/null +++ b/v2/packages/engine/src/audio/__tests__/AudioManager.test.ts @@ -0,0 +1,603 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { AudioManager, type PlayOptions } from '../AudioManager'; + +/** Параметр WebAudio: значение + планировщик огибающей (мгновенно применяет). */ +function makeParam(value = 1) { + const p = { + value, + setValueAtTime: vi.fn((v: number) => { + p.value = v; + }), + linearRampToValueAtTime: vi.fn((v: number) => { + p.value = v; + }), + exponentialRampToValueAtTime: vi.fn((v: number) => { + p.value = v; + }), + cancelScheduledValues: vi.fn() + }; + return p; +} + +/** Минимальный граф WebAudio: считает узлы, запоминает соединения. */ +function makeCtx() { + const graph = { + gains: [] as { gain: ReturnType; dests: unknown[] }[], + sources: [] as { buffer: unknown; loop: boolean; playbackRate: ReturnType; start: ReturnType; stop: ReturnType; dests: unknown[] }[], + panners: [] as { pan: ReturnType; dests: unknown[] }[], + destination: { destination: true } + }; + const connect = (dests: unknown[]) => (dest: unknown) => { + dests.push(dest); + return dest; + }; + const ctx = { + currentTime: 0, + state: 'running', + destination: graph.destination, + resume: vi.fn(async () => undefined), + suspend: vi.fn(async () => { + ctx.state = 'suspended'; + }), + createGain: () => { + const node: { + gain: ReturnType; + dests: unknown[]; + connect: (d: unknown) => unknown; + disconnect: () => void; + } = { + gain: makeParam(1), + dests: [], + connect: () => undefined, + disconnect: () => undefined + }; + node.connect = connect(node.dests); + graph.gains.push(node); + return node; + }, + createBufferSource: () => { + const node: { buffer: unknown; loop: boolean; playbackRate: ReturnType; start: ReturnType; stop: ReturnType; dests: unknown[]; connect: (d: unknown) => unknown } = { + buffer: null, + loop: false, + playbackRate: makeParam(1), + start: vi.fn(), + stop: vi.fn(), + dests: [], + connect: () => undefined + }; + node.connect = connect(node.dests); + graph.sources.push(node); + return node; + }, + createStereoPanner: () => { + const node: { pan: ReturnType; dests: unknown[]; connect: (d: unknown) => unknown } = { + pan: makeParam(0), + dests: [], + connect: () => undefined + }; + node.connect = connect(node.dests); + graph.panners.push(node); + return node; + }, + 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 }; +} + +function makeManager(opts: { maxVoices?: number; pauseOnHide?: boolean } = {}) { + const { ctx, graph } = makeCtx(); + const audio = new AudioManager((key) => `url:${key}`, () => ctx as unknown as AudioContext, opts); + return { + audio, + graph, + ctx: ctx as unknown as { + createBuffer: ReturnType; + suspend: ReturnType; + resume: ReturnType; + state: string; + } + }; +} + +describe('AudioManager', () => { + beforeEach(() => { + vi.stubGlobal('fetch', vi.fn(async () => ({ arrayBuffer: async () => new ArrayBuffer(8) }))); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + it('unlock строит 4 шины: music/sfx/ambience ведут в master, master — в выход', async () => { + const { audio, graph } = makeManager(); + await audio.unlock(); + + expect(graph.gains).toHaveLength(4); + const [master, music, sfx, ambience] = graph.gains; + expect(master!.dests).toContain(graph.destination); + for (const bus of [music, sfx, ambience]) { + expect(bus!.dests).toContain(master); + } + }); + + it('setBusVolume: до unlock — без броска, после unlock — меняет громкость шины', async () => { + const { audio, graph } = makeManager(); + expect(() => audio.setBusVolume('sfx', 0.3)).not.toThrow(); + expect(graph.gains).toHaveLength(0); + + await audio.unlock(); + audio.setBusVolume('sfx', 0.3); + audio.setBusVolume('ambience', 0.4); + expect(graph.gains[2]!.gain.value).toBe(0.3); + expect(graph.gains[3]!.gain.value).toBe(0.4); + }); + + it('play(key, 0.5) ≡ play(key, {volume: 0.5}) — источник в sfx через gain', async () => { + const { audio, graph } = makeManager(); + await audio.unlock(); + await audio.register('bell'); + + await audio.play('bell', 0.5); + await audio.play('bell', { volume: 0.5 }); + + const sfxBus = graph.gains[2]!; + for (const src of graph.sources) { + const gainNode = src.dests[0] as { gain: ReturnType; dests: unknown[] }; + expect(gainNode.gain.value).toBe(0.5); + expect(gainNode.dests).toContain(sfxBus); + } + }); + + it('rate и pan доезжают до узлов; pan ≈ 0 — StereoPanner не создаётся', async () => { + const { audio, graph } = makeManager(); + await audio.unlock(); + await audio.register('bell'); + + await audio.play('bell', { rate: 1.5, pan: 0.7 }); + expect(graph.sources[0]!.playbackRate.value).toBe(1.5); + expect(graph.panners).toHaveLength(1); + expect(graph.panners[0]!.pan.value).toBe(0.7); + + await audio.play('bell'); + expect(graph.panners).toHaveLength(1); // второй sfx без панорамы + expect(graph.sources[1]!.playbackRate.value).toBe(1); + }); + + it('незагруженный ключ — тихо ничего (null), onPlayed не зовётся', async () => { + const { audio } = makeManager(); + const played: { key: string; opts: PlayOptions }[] = []; + audio.onPlayed = (key, opts) => played.push({ key, opts }); + + await expect(audio.play('ghost')).resolves.toBeNull(); + expect(played).toHaveLength(0); + }); + + it('onPlayed зовётся после фактического запуска с нормализованными опциями', async () => { + const { audio, graph } = makeManager(); + const played: { key: string; opts: PlayOptions }[] = []; + audio.onPlayed = (key, opts) => played.push({ key, opts }); + await audio.unlock(); + await audio.register('bell'); + + await audio.play('bell', { volume: 0.6, rate: 1.2, pan: -0.4 }); + await audio.play('bell', 0.5); + + expect(played).toEqual([ + { key: 'bell', opts: { volume: 0.6, rate: 1.2, pan: -0.4 } }, + { key: 'bell', opts: { volume: 0.5, rate: 1, pan: 0 } } + ]); + expect(graph.sources).toHaveLength(2); + }); + + it('play возвращает хендл: setVolume/setRate/setPan меняют узлы, stop глушит', async () => { + const { audio, graph } = makeManager(); + await audio.unlock(); + await audio.register('bell'); + + const h = await audio.play('bell', { volume: 0.5, pan: 0.3 }); + expect(h).not.toBeNull(); + + const gainNode = graph.sources[0]!.dests[0] as { gain: { value: number } }; + h!.setVolume(0.8); + expect(gainNode.gain.value).toBe(0.8); + + h!.setRate(1.4); + expect(graph.sources[0]!.playbackRate.value).toBe(1.4); + + h!.setPan(-0.5); + expect(graph.panners[0]!.pan.value).toBe(-0.5); + + h!.stop(0); + vi.advanceTimersByTime(100); + expect(graph.sources[0]!.stop).toHaveBeenCalled(); + }); + + it('setPan создаёт panner, если его не было', async () => { + const { audio, graph } = makeManager(); + await audio.unlock(); + await audio.register('bell'); + + const h = await audio.play('bell', 0.5); + expect(graph.panners).toHaveLength(0); + + h!.setPan(0.4); + expect(graph.panners).toHaveLength(1); + expect(graph.panners[0]!.pan.value).toBeCloseTo(0.4); + // Пересоединение: gain теперь ведёт и в panner + const gainNode = graph.sources[0]!.dests[0] as { dests: unknown[] }; + expect(gainNode.dests).toContain(graph.panners[0]); + }); + + it('после stop хендл мёртв: setVolume/setRate — no-op', async () => { + const { audio, graph } = makeManager(); + await audio.unlock(); + await audio.register('bell'); + + const h = await audio.play('bell', 0.5); + h!.stop(0); + h!.setVolume(0.9); + h!.setRate(1.5); + + // stop затушил громкость; setVolume мёртвого хендла её не вернул + const gainNode = graph.sources[0]!.dests[0] as { gain: { value: number } }; + expect(gainNode.gain.value).toBeCloseTo(0.0001); + expect(graph.sources[0]!.playbackRate.value).toBe(1); + vi.advanceTimersByTime(100); + expect(graph.sources[0]!.stop).toHaveBeenCalledTimes(1); + }); + + it('restart: новый экземпляр ключа глушит прежний; без restart — играют оба', async () => { + const { audio, graph } = makeManager(); + await audio.unlock(); + await audio.register('bell'); + + await audio.play('bell', 0.5); + await audio.play('bell', { volume: 0.5, restart: true }); + vi.advanceTimersByTime(100); + expect(graph.sources[0]!.stop).toHaveBeenCalled(); + expect(graph.sources[1]!.stop).not.toHaveBeenCalled(); + + await audio.play('bell', 0.5); + vi.advanceTimersByTime(100); + expect(graph.sources[1]!.stop).not.toHaveBeenCalled(); // без restart живут оба + expect(graph.sources[2]!.stop).not.toHaveBeenCalled(); + }); + + it('maxVoices: при переполнении воруется самый тихий голос', async () => { + const { audio, graph } = makeManager({ maxVoices: 2 }); + await audio.unlock(); + await audio.register('bell'); + + await audio.play('bell', 0.9); + await audio.play('bell', 0.1); + await audio.play('bell', 0.5); + vi.advanceTimersByTime(100); + + expect(graph.sources[1]!.stop).toHaveBeenCalled(); // самый тихий (0.1) + expect(graph.sources[0]!.stop).not.toHaveBeenCalled(); + expect(graph.sources[2]!.stop).not.toHaveBeenCalled(); + }); + + it('onPlayed зовётся и для лупов (музыка/амбиент), и для sfx', async () => { + const { audio } = makeManager(); + const played: { key: string; opts: PlayOptions }[] = []; + audio.onPlayed = (key, opts) => played.push({ key, opts }); + await audio.unlock(); + await audio.register('m'); + await audio.register('a'); + await audio.register('bell'); + + await audio.playMusic('m', { volume: 0.6, fade: 0 }); + await audio.playAmbience('a', { fade: 0 }); + await audio.play('bell', 0.5); + + expect(played).toEqual([ + { key: 'm', opts: { volume: 0.6, rate: 1, pan: 0 } }, + { key: 'a', opts: { volume: 1, rate: 1, pan: 0 } }, + { key: 'bell', opts: { volume: 0.5, rate: 1, pan: 0 } } + ]); + }); + + it('onEnded зовётся на естественном конце звука', async () => { + const { audio, graph } = makeManager(); + const ended: string[] = []; + audio.onEnded = (key) => ended.push(key); + await audio.unlock(); + await audio.register('bell'); + + await audio.play('bell'); + expect(ended).toHaveLength(0); + + // Фейковый источник не завершается сам — дёргаем onended руками. + (graph.sources[0] as unknown as { onended?: () => void }).onended?.(); + expect(ended).toEqual(['bell']); + }); + + it('заглушенный голос (stop) onEnded не зовёт', async () => { + const { audio, graph } = makeManager(); + const ended: string[] = []; + audio.onEnded = (key) => ended.push(key); + await audio.unlock(); + await audio.register('bell'); + + const h = await audio.play('bell'); + h!.stop(0); + // Источник «остановился» после затухания — голос убит, событие не для него. + (graph.sources[0] as unknown as { onended?: () => void }).onended?.(); + expect(ended).toEqual([]); + }); + + it('duration: после load+preload — секунды буфера, иначе null', async () => { + const { audio } = makeManager(); + await audio.unlock(); + + expect(audio.duration('m')).toBeNull(); + await audio.register('m'); + await audio.preload('m'); + 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('createBuffer: channels 2 — interleaved L,R деинтерливится в два канала', async () => { + const { audio, ctx } = makeManager(); + await audio.unlock(); + const copied: { ch: number; data: Float32Array }[] = []; + const data = new Float32Array([0.1, 0.9, -0.2, -0.8]); + const buf = audio.createBuffer(data, 22050, 2) as unknown as { + copyToChannel: (d: Float32Array, ch: number) => void; + }; + // Собираем вызовы по каналам: фейк хранит только последний, читаем аргументы. + (ctx.createBuffer as ReturnType).mock.results.at(-1)!.value.copyToChannel.mock.calls.forEach( + ([d, ch]: [Float32Array, number]) => copied.push({ ch, data: d }) + ); + expect(ctx.createBuffer).toHaveBeenCalledWith(2, 2, 22050); + expect(copied).toEqual([ + { ch: 0, data: new Float32Array([0.1, -0.2]) }, + { ch: 1, data: new Float32Array([0.9, -0.8]) } + ]); + void buf; + }); + + 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('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(); + await audio.register('m'); + + const handle = await audio.playLoop('m', { fade: 0, volume: 1 }); + handle!.setVolume(0.3); + const slotGain = (graph.sources[0]!.dests[0] as { gain: { value: number } }).gain; + expect(slotGain.value).toBeCloseTo(0.3); + }); + + it('playMusic и playAmbience независимы: остановка музыки не глушит амбиент', async () => { + const { audio, graph } = makeManager(); + await audio.unlock(); + await audio.register('m'); + await audio.register('a'); + + const music = await audio.playMusic('m', { fade: 0 }); + const amb = await audio.playAmbience('a', { fade: 0 }); + expect(music).not.toBeNull(); + expect(amb).not.toBeNull(); + expect(graph.sources).toHaveLength(2); + + music!.stop(0); + vi.advanceTimersByTime(100); + expect(graph.sources[0]!.stop).toHaveBeenCalled(); + expect(graph.sources[1]!.stop).not.toHaveBeenCalled(); + }); + + it('playAmbience дважды — прежний слот остановлен', async () => { + const { audio, graph } = makeManager(); + await audio.unlock(); + await audio.register('a'); + + const first = await audio.playAmbience('a', { fade: 0 }); + await audio.playAmbience('a', { fade: 0 }); + vi.advanceTimersByTime(100); + + expect(first).not.toBeNull(); + expect(graph.sources[0]!.stop).toHaveBeenCalled(); + expect(graph.sources[1]!.stop).not.toHaveBeenCalled(); // новый играет + }); + + it('playMusic с негодным ключом не глушит текущий трек', async () => { + const { audio, graph } = makeManager(); + await audio.unlock(); + await audio.register('m'); + + await audio.playMusic('m', { fade: 0 }); + const bad = await audio.playMusic('ghost', { fade: 0 }); + expect(bad).toBeNull(); + expect(graph.sources[0]!.stop).not.toHaveBeenCalled(); + }); + + it('playLoop играет в заданную шину и не занимает слот музыки', async () => { + const { audio, graph } = makeManager(); + await audio.unlock(); + await audio.register('m'); + + const layer = await audio.playLoop('m', { bus: 'sfx', fade: 0 }); + expect(layer).not.toBeNull(); + const sfxBus = graph.gains[2]!; + expect(sfxBus.dests).not.toContain(graph.destination); + // gain лупа ведёт в sfx + const gainNode = graph.sources[0]!.dests[0] as { dests: unknown[] }; + expect(gainNode.dests).toContain(sfxBus); + + // музыка стартует отдельно, луп не заглушается + await audio.playMusic('m', { fade: 0 }); + expect(graph.sources).toHaveLength(2); + audio.stopMusic(0); + vi.advanceTimersByTime(100); + expect(graph.sources[0]!.stop).not.toHaveBeenCalled(); + expect(graph.sources[1]!.stop).toHaveBeenCalled(); + }); + + it('bus в PlayOptions: разовый голос уходит в заданную шину', async () => { + const { audio, graph } = makeManager(); + await audio.unlock(); + await audio.register('bell'); + + // graph.gains: master, music, sfx, ambience + await audio.play('bell', { volume: 0.5, bus: 'music' }); + const gainNode = graph.sources[0]!.dests[0] as { dests: unknown[] }; + expect(gainNode.dests).toContain(graph.gains[1]); // шина music + expect(gainNode.dests).not.toContain(graph.gains[2]); + + await audio.play('bell'); // без bus — по-прежнему sfx + const plain = graph.sources[1]!.dests[0] as { dests: unknown[] }; + expect(plain.dests).toContain(graph.gains[2]); + }); + + it('fadeOut: экспоненциальный спад от нуля поднимается до минимума', async () => { + const { audio, graph } = makeManager(); + await audio.unlock(); + await audio.register('m'); + + const layer = await audio.playLoop('m', { fade: 0, volume: 0 }); + layer!.stop(0.5); + const slotGain = graph.sources[0]!.dests[0] as { gain: ReturnType }; + // from = max(0, MIN) → setValueAtTime(MIN), затем exponential- ramp + expect(slotGain.gain.setValueAtTime).toHaveBeenCalledWith(0.0001, 0); + expect(slotGain.gain.exponentialRampToValueAtTime).toHaveBeenCalledWith(0.0001, 0.5); + }); + + it('visibilitychange: скрыли таб — suspend, вернули — resume', async () => { + const listeners: Record void)[]> = {}; + const doc = { + hidden: false, + addEventListener: (type: string, fn: () => void) => { + (listeners[type] ??= []).push(fn); + } + }; + vi.stubGlobal('document', doc); + try { + const { audio, ctx } = makeManager(); + await audio.unlock(); + expect(ctx.suspend).not.toHaveBeenCalled(); + + doc.hidden = true; + listeners.visibilitychange![0]!(); + expect(ctx.suspend).toHaveBeenCalledTimes(1); + + doc.hidden = false; + ctx.state = 'suspended'; + listeners.visibilitychange![0]!(); + expect(ctx.resume).toHaveBeenCalledTimes(1); + } finally { + vi.unstubAllGlobals(); + } + }); + + it('playLoopBuffer: луп из памяти на заданной шине, хендл стопит', async () => { + const { audio, graph } = makeManager(); + const played: { key: string; opts: PlayOptions }[] = []; + audio.onPlayed = (key, opts) => played.push({ key, opts }); + await audio.unlock(); + await audio.register('m'); // createBuffer фейка требует unlock — берём его буфер + + const buf = audio.createBuffer(new Float32Array(22050).fill(0.2))!; + const handle = await audio.playLoopBuffer('music/meadows', buf, { fade: 0, volume: 0.7 }); + expect(handle).not.toBeNull(); + 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[1]); // шина music по умолчанию + expect(played).toEqual([{ key: 'music/meadows', opts: { volume: 0.7, rate: 1, pan: 0 } }]); + + handle!.stop(0); + vi.advanceTimersByTime(100); + expect(graph.sources[0]!.stop).toHaveBeenCalled(); + }); + + it('pauseOnHide: false — visibilitychange игнорируется', async () => { + const listeners: Record void)[]> = {}; + vi.stubGlobal('document', { + hidden: true, + addEventListener: (type: string, fn: () => void) => { + (listeners[type] ??= []).push(fn); + } + }); + try { + const { ctx } = makeManager({ pauseOnHide: false }); + expect(listeners.visibilitychange).toBeUndefined(); // listener не ставился + expect(ctx.suspend).not.toHaveBeenCalled(); + } finally { + vi.unstubAllGlobals(); + } + }); +}); \ No newline at end of file diff --git a/v2/packages/engine/src/audio/__tests__/Music.test.ts b/v2/packages/engine/src/audio/__tests__/Music.test.ts new file mode 100644 index 0000000..221f11e --- /dev/null +++ b/v2/packages/engine/src/audio/__tests__/Music.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest'; +import { renderMusic, renderMusicStereo, type MusicSpec } from '../Music'; + +const RATE = 22050; +const spb = (bpm: number): number => 60 / bpm; + +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)); + +/** Одна нота на долю — простейшая партитура. */ +const one = (over: Partial = {}): MusicSpec => ({ + bpm: 120, + beats: 4, + tracks: [{ instrument: 'pluck', notes: [{ beat: 0, dur: 1, midi: 69 }] }], + ...over +}); + +describe('renderMusic', () => { + it('детерминизм: одинаковые спеки → одинаковые сэмплы', () => { + expect(same(renderMusic(one()), renderMusic(one()))).toBe(true); + }); + + it('длина лупа — сырая минус кроссфейд (фейд = min(0.5с, 25% длины))', () => { + const t1 = 4 * spb(120) * RATE; // 2 с + expect(renderMusic(one({ bpm: 120, beats: 4 })).length).toBe(t1 - 0.5 * RATE); + const t2 = 8 * spb(60) * RATE; // 8 с — фейд упёрся в 0.5 с + expect(renderMusic(one({ bpm: 60, beats: 8 })).length).toBe(t2 - 0.5 * RATE); + }); + + it('высота ноты доезжает: другой midi — другие сэмплы', () => { + const a = renderMusic(one()); + const b = renderMusic(one({ tracks: [{ instrument: 'pluck', notes: [{ beat: 0, dur: 1, midi: 71 }] }] })); + expect(same(a, b)).toBe(false); + }); + + it('vel и gain меняют микс (пик после нормализации — 0.8 вне кроссфейд-зоны)', () => { + const beat1 = one({ tracks: [{ instrument: 'pluck', notes: [{ beat: 1, dur: 1, midi: 69 }] }] }); + expect(peak(renderMusic(beat1))).toBeCloseTo(0.8, 2); // нормализация + + // gain одной партии при опорной второй стереть нормализацией нельзя: + const mix = (g: number): MusicSpec => ({ + bpm: 120, + beats: 4, + tracks: [ + { instrument: 'pad', notes: [{ beat: 0, dur: 4, midi: 57 }] }, + { instrument: 'pluck', notes: [{ beat: 1, dur: 1, midi: 69 }], gain: g } + ] + }); + expect(same(renderMusic(mix(1)), renderMusic(mix(0.3)))).toBe(false); + }); + + it('ноты за краем лупа обрезаются, пустой трек — тишина нормализована в 0', () => { + const empty = renderMusic(one({ tracks: [{ instrument: 'pad', notes: [] }] })); + expect(peak(empty)).toBe(0); + + const clipped = renderMusic(one({ beats: 1, tracks: [{ instrument: 'pluck', notes: [{ beat: 0.5, dur: 2, midi: 69 }] }] })); + expect(clipped.length).toBeLessThanOrEqual(1 * spb(120) * RATE); + expect(peak(clipped)).toBeGreaterThan(0); + }); + + it('стерео: interleaved L,R; pan 0 — каналы равны, pan -1 — громче слева', () => { + const frames = 4 * spb(120) * RATE; + const spec = one({ tracks: [{ instrument: 'pluck', notes: [{ beat: 0, dur: 1, midi: 69 }], pan: -1 }] }); + const s = renderMusicStereo(spec); + expect(s.length).toBe(frames * 2); + + // Пики каналов (вне кроссфейд-зоны начала). + let lPeak = 0; + let rPeak = 0; + for (let i = 0; i < frames; i++) { + lPeak = Math.max(lPeak, Math.abs(s[i * 2]!)); + rPeak = Math.max(rPeak, Math.abs(s[i * 2 + 1]!)); + } + expect(lPeak).toBeGreaterThan(rPeak * 2); // панорама влево + + const centered = renderMusicStereo(one()); + let equal = true; + for (let i = 0; i < centered.length; i += 2) { + if (centered[i] !== centered[i + 1]) equal = false; + } + expect(equal).toBe(true); // pan 0 — моно-копия в обоих каналах + }); + + it('стерео: нормализация по общему пику каналов — баланс pan не искажается', () => { + // Тихий левый + громкий правый: пик пары равен 0.8 (нота вне кроссфейд-зоны). + const spec = one({ + tracks: [ + { instrument: 'pluck', notes: [{ beat: 1, dur: 1, midi: 69 }], pan: -1 }, + { instrument: 'pluck', notes: [{ beat: 1, dur: 1, midi: 69 }], pan: 1, gain: 2 } + ] + }); + const s = renderMusicStereo(spec); + expect(peak(s)).toBeCloseTo(0.8, 2); + }); +}); \ No newline at end of file diff --git a/v2/packages/engine/src/audio/__tests__/SoundSpec.test.ts b/v2/packages/engine/src/audio/__tests__/SoundSpec.test.ts new file mode 100644 index 0000000..91299d9 --- /dev/null +++ b/v2/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/v2/packages/engine/src/audio/decode.ts b/v2/packages/engine/src/audio/decode.ts new file mode 100644 index 0000000..b1fc2dd --- /dev/null +++ b/v2/packages/engine/src/audio/decode.ts @@ -0,0 +1,46 @@ +/** + * Кэш декодированных аудио-буферов (перенос v1): реестр URL + decodeAudioData. + * Параллельные вызовы decode по одному ключу делят один in-flight промис — + * двойной fetch+decode при старте сцены исключён. + */ +export class BufferCache { + private urls = new Map(); + private buffers = new Map(); + private pending = new Map>(); + + /** Зарегистрировать URL ключа (без загрузки — файл читается в decode). */ + register(key: string, url: string): void { + this.urls.set(key, url); + } + + /** Уже декодированный буфер (без загрузки); не декодирован — null. */ + cached(key: string): AudioBuffer | null { + return this.buffers.get(key) ?? null; + } + + /** Декодировать (fetch + decodeAudioData); сбой — null с warn в консоль. */ + async decode(key: string, ctx: AudioContext): Promise { + const cached = this.buffers.get(key); + if (cached) return cached; + let flight = this.pending.get(key); + if (!flight) { + const url = this.urls.get(key); + if (!url) return null; + flight = this.fetchDecode(key, url, ctx).finally(() => this.pending.delete(key)); + this.pending.set(key, flight); + } + return flight; + } + + private async fetchDecode(key: string, url: string, ctx: AudioContext): Promise { + try { + const res = await fetch(url); + const buf = await ctx.decodeAudioData(await res.arrayBuffer()); + this.buffers.set(key, buf); + return buf; + } catch (err) { + console.warn(`[audio] не удалось декодировать ${key}:`, err); + return null; + } + } +} \ No newline at end of file diff --git a/v2/packages/engine/src/audio/synth.ts b/v2/packages/engine/src/audio/synth.ts new file mode 100644 index 0000000..c2355a2 --- /dev/null +++ b/v2/packages/engine/src/audio/synth.ts @@ -0,0 +1,73 @@ +/** + * Чистые примитивы синтеза (перенос v1 tools/synth.mjs): детерминированный + * шум, полосовой фильтр, огибающие, нормализация, кроссфейд-луп. В v2 живёт + * в src движка (TS) — рантайм (SoundSpec/Music) и будущие тулзы делят одни + * формулы, поэтому спек-звук и WAV-звук звучат в одной палитре. + * Частота по умолчанию — RATE Гц (моно). + */ + +/** Частота дискретизации процедурного звука. */ +export const RATE = 22050; + +/** Детерминированный ГПСЧ (mulberry32). */ +export function makeRng(seed: number): () => number { + 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: number, fn: (t: number, i: number) => number, rate = RATE): Float32Array { + 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: number, dur: number, power = 4): number { + return Math.pow(Math.max(0, 1 - t / dur), power); +} + +/** Колокольный партиал: негармоничный (частота × растяжка), быстрый спад. */ +export function bellPartial(freq: number, stretch: number, dur: number, power: number): (t: number) => number { + return (t) => Math.sin(2 * Math.PI * freq * stretch * t) * decay(t, dur, power); +} + +/** Полосовой шум через однополюсные фильтры (низ + верх). */ +export function bandNoise(seed: number, lowHz: number, highHz: number, _dur = 0): () => number { + 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: Float32Array, peak = 0.9): Float32Array { + 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: Float32Array, fadeSeconds = 0.5): Float32Array { + 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/v2/packages/engine/src/audio/types.ts b/v2/packages/engine/src/audio/types.ts new file mode 100644 index 0000000..efbb0fc --- /dev/null +++ b/v2/packages/engine/src/audio/types.ts @@ -0,0 +1,53 @@ +/** + * Общие типы и константы аудио-подсистемы (перенос v1): шины, хендлы, опции. + * Реализации — в AudioManager (фасад), Voices (полифония sfx), Loops (лупы). + */ + +export interface AudioBuses { + master: GainNode; + music: GainNode; + sfx: GainNode; + ambience: GainNode; +} + +export type BusName = 'master' | 'music' | 'sfx' | 'ambience'; + +export interface MusicHandle { + stop(fadeSeconds?: number): void; + /** Задать громкость сразу (для медленных тик-фейдов поверх — локальные слои). */ + setVolume(volume: number): void; +} + +/** Хендл играющего разового sfx: параметры в полёте + остановка. */ +export interface SfxHandle { + stop(fadeSeconds?: number): void; + setVolume(volume: number): void; + setPan(pan: number): void; + setRate(rate: number): void; +} + +/** Опции разового sfx. */ +export interface PlayOptions { + /** Относительная громкость в шине (см. bus), 0..1+. */ + volume?: number; + /** Скорость воспроизведения 0.5..2 (джиттер шагов, вариации тона). */ + rate?: number; + /** Панорама -1..1; |pan| < 0.01 — узел StereoPanner не создаётся. */ + pan?: number; + /** Заглушить прежние играющие экземпляры этого же ключа (короткий фейд). */ + restart?: boolean; + /** Шина голоса (по умолчанию sfx) — например UI-звук в master. */ + bus?: BusName; +} + +export interface LoopOptions { + loop?: boolean; + fade?: number; + volume?: number; + bus?: BusName; +} + +export const DEFAULT_FADE = 1; +export const MIN_FADE_VOLUME = 0.0001; + +export const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v)); \ No newline at end of file diff --git a/v2/packages/engine/src/index.ts b/v2/packages/engine/src/index.ts index 87d3326..765a8b4 100644 --- a/v2/packages/engine/src/index.ts +++ b/v2/packages/engine/src/index.ts @@ -82,4 +82,10 @@ export type { EnemyIntent, EnemySenses } from './combat/brain'; export { PlayerCombat } from './combat/playerCombat'; export { CombatWorld, circleHitsSolid, moveCircleHor } from './combat/world'; -export type { CombatDeps, CombatHooks, EnemyComp, ProjectileComp } from './combat/world'; \ No newline at end of file +export type { CombatDeps, CombatHooks, EnemyComp, ProjectileComp } from './combat/world';export { AudioManager } from './audio/AudioManager'; +export type { AudioBuses, BusName, MusicHandle, PlayOptions, SfxHandle } from './audio/AudioManager'; +export { renderSpec, RATE } from './audio/SoundSpec'; +export type { SoundSpec, SoundKind, SpecVoice } from './audio/SoundSpec'; +export { renderMusic, renderMusicStereo } from './audio/Music'; +export type { MusicSpec, TrackSpec, NoteEvent, InstrumentKind } from './audio/Music'; +export { BufferCache } from './audio/decode';