diff --git a/apps/game/src/Game.ts b/apps/game/src/Game.ts index 2cbeed0..be584d4 100644 --- a/apps/game/src/Game.ts +++ b/apps/game/src/Game.ts @@ -7,7 +7,8 @@ Settings, Inventory, parseMap, - renderMusic, + RATE, + renderMusicStereo, type MusicHandle, type Renderer, type SceneManager, @@ -156,7 +157,7 @@ if (!spec) return; let buf = this.themeBuffers.get(key); if (!buf) { - const created = this.audio.createBuffer(renderMusic(spec)); + const created = this.audio.createBuffer(renderMusicStereo(spec), RATE, 2); if (!created) { this.themeKey = null; // до unlock — компиляция при следующем входе return; diff --git a/apps/game/src/data/__tests__/validate.test.ts b/apps/game/src/data/__tests__/validate.test.ts index e451375..4d61396 100644 --- a/apps/game/src/data/__tests__/validate.test.ts +++ b/apps/game/src/data/__tests__/validate.test.ts @@ -4,6 +4,7 @@ import { AREAS } from '../locations'; import { INTERACTABLES } from '../interactables'; import { AUDIO_KEYS } from '../audio'; +import { ROOM_TONES } from '../sfxSpecs'; import { NPCS } from '../npcs'; import { DIALOGUES } from '../dialogues'; import { FLAGS } from '../ids'; @@ -139,4 +140,10 @@ } } }); + + it('room tone интерьеров — ключи ROOM_TONES есть среди областей (id → спек)', () => { + for (const key of Object.keys(ROOM_TONES)) { + expect(AREAS[key as keyof typeof AREAS], `room tone ${key} — нет такой области`).toBeTruthy(); + } + }); }); diff --git a/apps/game/src/data/music.ts b/apps/game/src/data/music.ts index e85e819..922cbf0 100644 --- a/apps/game/src/data/music.ts +++ b/apps/game/src/data/music.ts @@ -2,7 +2,7 @@ /** * Музыкальные темы: правило-базовый генератор (детерминированный, сиды) + - * компиляция движковым секвенсором (renderMusic → playLoopBuffer) прямо + * компиляция движковым секвенсором (renderMusicStereo → playLoopBuffer) прямо * в рантайме — WAV-файлов для музыки нет. Тема лугов — пепельная * ля-минорная пентатоника: пэд-дрон + редкая плак-мелодия восьмыми. */ @@ -34,17 +34,70 @@ return notes; } -/** Тема лугов: 16 долей на 70 bpm, дрон A2→G2 + плак по пентатонике. */ +/** Тема лугов: 16 долей на 70 bpm, дрон A2→G2 + плак по пентатонике (стерео). */ export const MEADOW_THEME: MusicSpec = { bpm: 70, beats: 16, tracks: [ - { instrument: 'pad', notes: drone(45, 43, 16), gain: 0.9 }, - { instrument: 'pluck', notes: melody(20260907, 16) } + { instrument: 'pad', notes: drone(45, 43, 16), gain: 0.9, pan: -0.3 }, + { instrument: 'pluck', notes: melody(20260907, 16), pan: 0.3 } + ] +}; + +/** Ритмичный рифф боя: восьмые с пропусками, низкая пентатоника. */ +function riff(seed: number, beats: number): NoteEvent[] { + const rng = createRng(seed); + const low = [45, 48, 50, 52, 55] as const; // A2-минорная низь + const notes: NoteEvent[] = []; + for (let eighth = 0; eighth < beats * 2; eighth++) { + if (!rng.chance(0.7)) continue; + notes.push({ + beat: eighth * 0.5, + dur: 0.5, + midi: low[Math.floor(rng.next() * low.length)]!, + vel: 0.5 + rng.next() * 0.3 + }); + } + return notes; +} + +/** Редкие высокие «тревожные» ноты колокола для tension-стема. */ +function sparseBells(seed: number, beats: number): NoteEvent[] { + const rng = createRng(seed); + const notes: NoteEvent[] = []; + for (let beat = 0; beat < beats; beat++) { + if (!rng.chance(0.15)) continue; + notes.push({ beat, dur: 2, midi: rng.chance(0.5) ? 81 : 84, vel: 0.4 + rng.next() * 0.2 }); + } + return notes; +} + +/** Тема боя: 112 bpm, глухой дрон A1 + настойчивый низкий рифф. */ +export const COMBAT_THEME: MusicSpec = { + bpm: 112, + beats: 8, + tracks: [ + { instrument: 'pad', notes: drone(33, 33, 8), gain: 1, pan: 0 }, + { instrument: 'pluck', notes: riff(20260908, 8), pan: -0.2 } + ] +}; + +/** + * Tension-стем: тихий луп «настороженности» (враг wary) — низкий дрон + * A1 + редкие высокие колокола. Играется на шине music ПОД темой области, + * громкость ведёт threat-тик (см. LocationScene.updateMusic). + */ +export const TENSION_STEM: MusicSpec = { + bpm: 70, + beats: 16, + tracks: [ + { instrument: 'pad', notes: [{ beat: 0, dur: 16, midi: 33, vel: 0.7 }], gain: 0.9, pan: 0.2 }, + { instrument: 'bell', notes: sparseBells(20260909, 16), gain: 0.5, pan: -0.4 } ] }; /** Реестр тем (ключ → спек); область ссылается на тему полем `theme`. */ export const THEMES: Record = { - meadows: MEADOW_THEME + meadows: MEADOW_THEME, + combat: COMBAT_THEME }; \ No newline at end of file diff --git a/apps/game/src/data/sfxSpecs.ts b/apps/game/src/data/sfxSpecs.ts index eb78016..0406e63 100644 --- a/apps/game/src/data/sfxSpecs.ts +++ b/apps/game/src/data/sfxSpecs.ts @@ -77,6 +77,19 @@ }; /** + * Room tone интерьеров: тихий луп-гул «внутри помещения» (шина ambience). + * Ключи — строковые id областей (см. locations.ts), БЕЗ типа AreaId — иначе + * цикл импортов audio.ts → sfxSpecs → locations → interactables → audio.ts. + * Проверка «ключи ⊆ AREAS» — в validate-тестах. + */ +export const ROOM_TONES: Record = { + // Дом Ирвина: очаг + скрип половиц — низкий гул с медленным «дыханием». + house_elder: { kind: 'hum', tone: 52, dur: 8, power: 2, peak: 0.3 }, + // Лавка Милы: чуть выше и с лёгким тремоло (колокольчики на витрине). + shop: { kind: 'hum', tone: 66, dur: 8, power: 3, peak: 0.28, wobbleHz: 0.4, wobbleDepth: 0.15 } +}; + +/** * Единая точка проигрывания sfx по ключу: спек-звук — через playSpec * (движок компилирует сам), файловый — через play. Все play-сайты игры * зовут этот хелпер, а не audio.play напрямую. diff --git a/apps/game/src/scenes/LocationScene.ts b/apps/game/src/scenes/LocationScene.ts index 0025721..ac339ce 100644 --- a/apps/game/src/scenes/LocationScene.ts +++ b/apps/game/src/scenes/LocationScene.ts @@ -9,6 +9,9 @@ SpriteMotion, Texture, IsometricTileMap, + RATE, + renderSpec, + renderMusicStereo, worldToScreen, worldNorm, tileToWorld, @@ -18,6 +21,7 @@ type Camera, type Entity, type Invariant, + type MusicHandle, type JsonValue, type Scene, type SnapshotLayer, @@ -27,7 +31,7 @@ import { MenuScene, SAVE_VERSION, type SaveData } from './MenuScene'; import { InventoryScene } from './InventoryScene'; import { TILES, DECOR } from '../data/map'; -import { playSfx } from '../data/sfxSpecs'; +import { playSfx, ROOM_TONES } from '../data/sfxSpecs'; import { areaOf, type AmbienceLayerDef, @@ -43,6 +47,7 @@ import type { NpcDef } from '../data/npcs'; import { DIALOGUES } from '../data/dialogues'; import { QUEST_FLOWERS, questDialogueFor, questEffectFor } from '../data/quests'; +import { TENSION_STEM } from '../data/music'; import { VARS } from '../data/ids'; import type { EnemyKindId } from '../data/enemies'; import { PlayerController, type HeroTextures } from '../systems/PlayerController'; @@ -100,6 +105,14 @@ private worldAudio: AudioSystem; /** off() подписок worldAudio (снять в exit). */ private audioOff: () => void; + /** Tension-стем (враги wary): буфер + хендл + сглаженная громкость. */ + private stemHandle: MusicHandle | null = null; + private stemBuf: AudioBuffer | null = null; + private stemLevel = 0; + /** Стем заводится (промис не вернулся) — не заводить второй. */ + private stemPending = false; + /** Room tone интерьера (тихий гул на шине ambience). */ + private roomtoneHandle: MusicHandle | null = null; /** Кат-сцены (раннер шагов; на время сцены геймплей на паузе). */ private cutscene = new CutsceneRunner(); /** Зона наката, в которой герой был в прошлом кадре (для тоста при входе). */ @@ -418,11 +431,30 @@ // Амбиент локации (кроссфейд; тот же ключ не перезапускается, '' — тишина). this.game.playAmbience(this.area.ambience ?? ''); this.game.playTheme(this.area.theme ?? ''); + this.startRoomTone(); + } + + /** Room tone интерьера (см. ROOM_TONES): тихий луп на шине ambience. */ + private startRoomTone(): void { + const spec = ROOM_TONES[this.area.id]; + if (!spec) return; + const buf = this.game.audio.createBuffer(renderSpec(spec)); + if (!buf) return; // до unlock контекста нет — тон появится при следующем входе + void this.game.audio + .playLoopBuffer(`roomtone/${this.area.id}`, buf, { bus: 'ambience', fade: 2, volume: 0.6 }) + .then((h) => { + this.roomtoneHandle = h; + }); } exit(): void { this.audioOff(); - this.worldAudio.setLayers([]); + this.roomtoneHandle?.stop(1); + this.roomtoneHandle = null; + this.stemHandle?.stop(1); + this.stemHandle = null; + this.stemBuf = null; + this.stemPending = false; this.fauna.exit(); this.combatViews.exit(); this.ash?.clear(); @@ -501,6 +533,7 @@ this.combat.update(dt); this.combatViews.sync(dt); this.playerCombat.update(dt); + this.updateMusic(dt); this.router.updateTarget(dt); this.updateChargeRing(); this.healthBar.setHp(this.playerCombat.hp); @@ -771,6 +804,43 @@ return best; } + /** + * Динамическая музыка по состояниям врагов: погоня (chase/бой) — боевая + * тема вместо темы области; настороженность (wary) — тихий tension-стем + * под темой (громкость по угрозе, сглаженная); спокойствие — тема области. + */ + private updateMusic(dt: number): void { + let threat = 0; + for (const en of this.combat.enemies.values()) { + const st = en.brain.state; + if (st === 'chase' || st === 'windup' || st === 'attack' || st === 'hurt') threat = 1; + else if (st === 'wary') threat = Math.max(threat, 0.4); + } + // Боевая тема вытесняет тему области; обратно — как только погоня кончилась. + this.game.playTheme(threat >= 1 ? 'combat' : (this.area.theme ?? '')); + + // Стем: тихо в бою (тема боя сама громкая), громче при wary, ноль в тишине. + const target = threat >= 1 ? 0.15 : threat > 0 ? 0.6 * threat : 0; + if (!this.stemHandle && !this.stemPending && target > 0) { + this.stemBuf ??= this.game.audio.createBuffer(renderMusicStereo(TENSION_STEM), RATE, 2); + if (this.stemBuf) { + this.stemPending = true; + void this.game.audio + .playLoopBuffer('music/tension', this.stemBuf, { fade: 1, volume: 0 }) + .then((h) => { + this.stemPending = false; + if (!h) return; + // Сцена сменилась, пока луп заводился, — гасим без хвостов. + if (!this.stemBuf) h.stop(1); + else this.stemHandle = h; + }); + } + } + // Громкость ведём плавно (dt-подход), stop в exit. + this.stemLevel += (target - this.stemLevel) * Math.min(1, dt * 2); + this.stemHandle?.setVolume(this.stemLevel * 0.8); + } + /** Сбор лунного колокольчика: тайл зеленеет, цветок — в сумку, прогресс — в vars. */ private collectFlower(x: number, y: number): void { this.map.setTile(x, y, TILES.GRASS); diff --git a/apps/game/tools/checks/music.mjs b/apps/game/tools/checks/music.mjs index 59fea4e..b923737 100644 --- a/apps/game/tools/checks/music.mjs +++ b/apps/game/tools/checks/music.mjs @@ -4,8 +4,9 @@ * (playTheme → playLoopBuffer), факт запуска — в window.__gameAudioLog. * 1) новая игра в лугах: music/meadows в логе (тема играет) + амбиент; * 2) тема не перезапускается при возврате в ту же область (guard); - * 3) уход в Звенец (темы нет): слот темы снят (themeKey '') и - * новый music/* в логе не появился. + * 3) уход в Звенец (темы нет): слот темы снят (themeKey '') и тема лугов + * не перезапускается. Динамическая музыка разрешена: стем (настороженность) + * и тема боя (погоня тяжеловеса у тропы — его аггро-радиус на пути). * Запуск: node tools/agent.mjs run tools/checks/music.mjs */ import { startDevServer, openGame, Checks } from '../lib.mjs'; @@ -53,7 +54,10 @@ }); return null; }); - await c.run('уход в Звенец: слот темы снят, новый music/* не играет', async () => { + await c.run('уход в Звенец: слот темы снят, тема лугов не перезапускается', async () => { + const meadowsCount = () => + readLog(ctx.page).then((log) => log.filter((e) => e.key === 'music/meadows').length); + const before = await meadowsCount(); // Пешком к тропе в Звенец (26,14) — честный step-переход. const walked = await ctx.agent.walkTo(26, 14, { timeoutTicks: 3000 }); c.expect(walked, 'walkTo(26,14) не дошёл до перехода'); @@ -62,15 +66,16 @@ const g = window.__game; return g ? g.themeKey : null; }); + // Слот темы освобождается (тема Звенца пуста; тема боя погони — + // временный ключ, ждём его снятия после деаггро). for (let i = 0; i < 12 && (await themeKey()) !== ''; i++) { await ctx.agent.step(30); } c.expect((await themeKey()) === '', 'слот темы не снят при уходе из лугов'); - const music = (await readLog(ctx.page)).filter((e) => e.key.startsWith('music/')); c.expect( - music.every((e) => e.key === 'music/meadows'), - 'в Звенце играет музыка, которой быть не должно', - music + (await meadowsCount()) === before, + 'тема лугов перезапустилась (guard ключа сломан)', + { before, after: await meadowsCount() } ); return null; }); diff --git a/docs/engine/assets-audio-save.md b/docs/engine/assets-audio-save.md index fb928c0..33f00f5 100644 --- a/docs/engine/assets-audio-save.md +++ b/docs/engine/assets-audio-save.md @@ -167,29 +167,32 @@ (ambient `declare module` не работает: TS резолвит specifier к реальному файлу и игнорирует ambient-имя). -### Музыка: renderMusic + playLoopBuffer (тема по партитуре) +### Музыка: renderMusic/renderMusicStereo + playLoopBuffer (тема по партитуре) Секвенсор движка (`audio/Music.ts`): партитура `MusicSpec` — темп (`bpm`), длина лупа в долях (`beats`) и партии-стемы (`tracks`: инструмент + ноты -`{beat, dur, midi, vel}` + `gain`). Инструменты — осциллятор + огибающая: -`pluck` (сумма партиалов с затуханием), `bell` (негармоничные партиалы), -`pad` (расстроенная пара синусов, мягкая атака/релиз). `renderMusic(spec)` -— чистая функция (без ГПСЧ: детерминизм мелодии обеспечивают сиды -генератора нот на стороне игры/агента): ноты суммируются, луп сшивается -кроссфейдом и нормализуется к пику 0.8. +`{beat, dur, midi, vel}` + `gain` + `pan`). Инструменты — осциллятор + +огибающая: `pluck` (сумма партиалов с затуханием), `bell` (негармоничные +партиалы), `pad` (расстроенная пара синусов, мягкая атака/релиз). +`renderMusic(spec)` — чистая функция (без ГПСЧ: детерминизм мелодии +обеспечивают сиды генератора нот на стороне игры/агента): ноты суммируются, +луп сшивается кроссфейдом и нормализуется к пику 0.8. Стерео-вариант +`renderMusicStereo(spec)` возвращает interleaved L,R (моно-сэмплы прежние): +`pan` трека (-1..1) раскладывается по equal-power панораме, нормализация — +по общему пику каналов (баланс не искажается). ```ts -import { renderMusic, type MusicSpec } from '@rpg/engine'; +import { RATE, renderMusicStereo, type MusicSpec } from '@rpg/engine'; const spec: MusicSpec = { bpm: 70, beats: 16, tracks: [ - { instrument: 'pad', notes: [{ beat: 0, dur: 8, midi: 45, vel: 0.5 }] }, - { instrument: 'pluck', notes: [{ beat: 4, dur: 0.5, midi: 69, vel: 0.4 }] } + { instrument: 'pad', notes: [{ beat: 0, dur: 8, midi: 45, vel: 0.5 }], pan: -0.3 }, + { instrument: 'pluck', notes: [{ beat: 4, dur: 0.5, midi: 69, vel: 0.4 }], pan: 0.3 } ] }; -const buf = audio.createBuffer(renderMusic(spec)); // до unlock — null +const buf = audio.createBuffer(renderMusicStereo(spec), RATE, 2); // до unlock — null if (buf) await audio.playLoopBuffer('music/meadows', buf, { fade: 2, volume: 0.5 }); ``` diff --git a/docs/engine/practices.md b/docs/engine/practices.md index 3101a2f..ad1f492 100644 --- a/docs/engine/practices.md +++ b/docs/engine/practices.md @@ -205,10 +205,21 @@ пер-ключевой интервал в `AudioSystem` нужен только против «пулемёта» одного ключа. Агентный доступ — op `scene:synthesize`, образец — `checks/synth.mjs`. - Музыка: спек-партитура `MusicSpec` → `renderMusic` → `createBuffer` → + Музыка: спек-партитура `MusicSpec` → `renderMusicStereo` (стерео, `pan` + треков; до стерео был `renderMusic`) → `createBuffer(data, RATE, 2)` → `playLoopBuffer` (см. «Спек-синтез» и «Музыка» в assets-audio-save.md) — генератор нот с сидом в `data/music.ts`, у области — поле `theme`, у Game — `playTheme(key)` с guard-ом и кроссфейдом; WAV для музыки не нужен. + Динамическая музыка — тик по состояниям врагов (`LocationScene.updateMusic`): + chase → `playTheme('combat')`, wary → тихий стем `playLoopBuffer` с + `setVolume` поверх темы области; темы боя живут в реестре `THEMES` наравне + с темами областей. Луп, заводимый из тика, — **один** запуск: флаг + «промис не вернулся» (`stemPending`), иначе каждый кадр до резолва промиса + заводит новый луп и заливает аудио-лог (пробы audio/music краснеют). + Room tone интерьера — hum-спек в `ROOM_TONES` (`data/sfxSpecs.ts`, ключи — + строковые id областей, БЕЗ типа AreaId — иначе цикл импортов), запуск в + `LocationScene.enter` на шине ambience, стоп в exit; validate-тест: ключи + `ROOM_TONES` ⊆ `AREAS`. 5. На слух в headless не проверить — смотреть фактические запуски: DEV-шпион `window.__gameAudioLog` (кольцо на 24, `t`/`volume`/`pan`) читается из страницы; образец — `apps/game/tools/checks/audio.mjs`. После действия diff --git a/docs/plan.md b/docs/plan.md index 1cfaac4..ea8d68c 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -373,8 +373,13 @@ компиляция в рантайме — WAV для музыки не нужен; тема области — поле `theme` + `Game.playTheme` (guard + кроссфейд), проба `checks/music.mjs`. Стем-миксинг уже покрыт (`playLoop` + тик-фейд, амбиент-слои). -**Осталось**: секвенция тем (бой → назад) — когда появится боевая тема; -нейронка «описание → ноты» — опционально. +✅ **Динамическая музыка (2026-09-07)**: стерео-рендер `renderMusicStereo` +(interleaved L,R, `pan` треков equal-power, нормализация по общему пику +каналов — `createBuffer(data, RATE, 2)`); тема боя + tension-стем в +`data/music.ts`; threat-тик в `LocationScene` (состояния врагов: chase → +тема боя, wary → тихий стем под темой области, деаггро → тема назад); +room tone интерьеров (`ROOM_TONES` — hum-спеки, шина ambience). +**Осталось**: нейронка «описание → ноты» — опционально. --- diff --git a/packages/engine/src/audio/AudioManager.ts b/packages/engine/src/audio/AudioManager.ts index 3bdf5da..1ac22fe 100644 --- a/packages/engine/src/audio/AudioManager.ts +++ b/packages/engine/src/audio/AudioManager.ts @@ -180,15 +180,28 @@ } /** - * Буфер из моно-FLOAT сэмплов (для процедурных звуков в рантайме). + * Буфер из FLOAT-сэмплов (для процедурных звуков в рантайме): моно + * (channels 1) или interleaved L,R (channels 2 — стерео-рендеры движка). * До unlock контекста нет — null; создать буфер по ключу не получится, * вызывать после первого play/unlock. */ - createBuffer(data: Float32Array, sampleRate = 22050): AudioBuffer | null { + createBuffer(data: Float32Array, sampleRate = 22050, channels: 1 | 2 = 1): AudioBuffer | null { if (!this.ctx) return null; - const buf = this.ctx.createBuffer(1, data.length, sampleRate); + const frames = Math.floor(data.length / channels); + const buf = this.ctx.createBuffer(channels, frames, sampleRate); // DOM-тип требует Float32Array — сэмплы из генераторов не обязаны. - buf.copyToChannel(data as Float32Array, 0); + 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; } diff --git a/packages/engine/src/audio/Music.ts b/packages/engine/src/audio/Music.ts index 407a7b1..56d509d 100644 --- a/packages/engine/src/audio/Music.ts +++ b/packages/engine/src/audio/Music.ts @@ -26,6 +26,8 @@ notes: NoteEvent[]; /** Громкость трека (по умолчанию 1). */ gain?: number; + /** Панорама -1..1 (только в стерео-рендере; по умолчанию 0 — центр). */ + pan?: number; } /** Партитура лупа: темп, длина в долях, партии. */ @@ -85,25 +87,81 @@ } /** - * Партитура → моно-сэмплы-луп (RATE): ноты всех партий суммируются, луп - * сшивается кроссфейдом и нормализуется к пику 0.8. Одинаковые спеки дают - * одинаковые сэмплы (рендер чистый). + * Суммирование нот всех партий в два канала. Моно-рендер кладёт всё в левый + * канал с весами 1 (pan не слышен); стерео — раскладывает по equal-power + * панораме трека. */ -export function renderMusic(spec: MusicSpec): Float32Array { +function mixTracks( + spec: MusicSpec, + total: number, + stereo: boolean +): [Float32Array, Float32Array] { const spb = 60 / Math.max(1, spec.bpm); // сек на долю - const total = Math.max(1, Math.round(spec.beats * spb * RATE)); - const out = new Float32Array(total); + 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; + const vel = (n.vel ?? 0.8) * gain; for (let i = 0; i < len && start + i < total; i++) { - out[start + i] += wave(i / RATE) * vel * gain; + const v = wave(i / RATE) * vel; + left[start + i] += v * gl; + right[start + i] += v * gr; } } } - return loopify(normalize(out, 0.8), Math.min(0.5, (total / RATE) * 0.25)); + 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/packages/engine/src/audio/__tests__/AudioManager.test.ts b/packages/engine/src/audio/__tests__/AudioManager.test.ts index 57d1c01..5376b4e 100644 --- a/packages/engine/src/audio/__tests__/AudioManager.test.ts +++ b/packages/engine/src/audio/__tests__/AudioManager.test.ts @@ -363,6 +363,26 @@ 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 }[] = []; diff --git a/packages/engine/src/audio/__tests__/Music.test.ts b/packages/engine/src/audio/__tests__/Music.test.ts index 9a17a6b..221f11e 100644 --- a/packages/engine/src/audio/__tests__/Music.test.ts +++ b/packages/engine/src/audio/__tests__/Music.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { renderMusic, type MusicSpec } from '../Music'; +import { renderMusic, renderMusicStereo, type MusicSpec } from '../Music'; const RATE = 22050; const spb = (bpm: number): number => 60 / bpm; @@ -59,4 +59,39 @@ 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/packages/engine/src/index.ts b/packages/engine/src/index.ts index af3f06e..239df47 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -172,7 +172,7 @@ // audio export { AudioManager, type AudioBuses, type BusName, type MusicHandle, type PlayOptions, type SfxHandle } from './audio/AudioManager'; export { renderSpec, RATE, type SoundSpec, type SoundKind, type SpecVoice } from './audio/SoundSpec'; -export { renderMusic, type MusicSpec, type TrackSpec, type NoteEvent, type InstrumentKind } from './audio/Music'; +export { renderMusic, renderMusicStereo, type MusicSpec, type TrackSpec, type NoteEvent, type InstrumentKind } from './audio/Music'; // assets export { AssetLoader } from './assets/AssetLoader';