diff --git a/apps/game/src/Game.ts b/apps/game/src/Game.ts index 00bef99..2cbeed0 100644 --- a/apps/game/src/Game.ts +++ b/apps/game/src/Game.ts @@ -7,11 +7,14 @@ Settings, Inventory, parseMap, + renderMusic, + type MusicHandle, type Renderer, type SceneManager, type TileMapData } from '@rpg/engine'; import { AUDIO_KEYS } from './data/audio'; +import { THEMES } from './data/music'; // Шрифт идёт с движком (OFL): Vite кладёт файл в сборку и даёт готовый URL. import fontUrl from '@rpg/engine/assets/fonts/VT323-Regular.ttf?url'; @@ -138,4 +141,40 @@ /** Текущий амбиент (защита от перезапуска того же ключа; '' — тишина). */ private ambienceKey: string | null = null; + + /** + * Тема области (спек-музыка из data/music.ts, компилируется в рантайме): + * кроссфейд при смене, повторный вызов с тем же ключом не перезапускает, + * '' — тишина. Буфер темы кэшируется — компиляция один раз на ключ. + */ + playTheme(key: string): void { + if (this.themeKey === key) return; + this.themeKey = key; + this.themeHandle?.stop(2); + this.themeHandle = null; + const spec = THEMES[key]; + if (!spec) return; + let buf = this.themeBuffers.get(key); + if (!buf) { + const created = this.audio.createBuffer(renderMusic(spec)); + if (!created) { + this.themeKey = null; // до unlock — компиляция при следующем входе + return; + } + buf = created; + this.themeBuffers.set(key, buf); + } + void this.audio.playLoopBuffer(`music/${key}`, buf, { fade: 2, volume: 0.5 }).then((handle) => { + // Тема сменилась, пока луп заводился, — гасим без хвостов. + if (this.themeKey !== key) { + handle?.stop(1); + return; + } + this.themeHandle = handle; + }); + } + + private themeKey: string | null = null; + private themeHandle: MusicHandle | null = null; + private themeBuffers = new Map(); } \ No newline at end of file diff --git a/apps/game/src/data/locations.ts b/apps/game/src/data/locations.ts index b9439f7..4c9e2ae 100644 --- a/apps/game/src/data/locations.ts +++ b/apps/game/src/data/locations.ts @@ -65,6 +65,8 @@ name: string; /** Амбиент области (кроссфейд при переходе, ключ из Game.AUDIO_KEYS); нет — тишина. */ ambience?: string; + /** Музыкальная тема области (ключ из data/music.ts, спек-музыка); нет — тишина. */ + theme?: string; /** Стартовый тайл (новая игра / респаун / фолбэк return). */ spawn: { x: number; y: number }; npcs: NpcDef[]; @@ -87,6 +89,7 @@ id: 'meadows', name: 'Выжженные луга', ambience: 'ambience/meadows', + theme: 'meadows', spawn: { x: 14, y: 14 }, npcs: [], enemies: [ diff --git a/apps/game/src/data/music.ts b/apps/game/src/data/music.ts new file mode 100644 index 0000000..e85e819 --- /dev/null +++ b/apps/game/src/data/music.ts @@ -0,0 +1,50 @@ +import { createRng, type MusicSpec, type NoteEvent } from '@rpg/engine'; + +/** + * Музыкальные темы: правило-базовый генератор (детерминированный, сиды) + + * компиляция движковым секвенсором (renderMusic → playLoopBuffer) прямо + * в рантайме — WAV-файлов для музыки нет. Тема лугов — пепельная + * ля-минорная пентатоника: пэд-дрон + редкая плак-мелодия восьмыми. + */ + +/** Ля-минорная пентатоника от A3 (A C D E G + октава). */ +const PENTATONIC = [57, 60, 62, 64, 67, 69, 72] as const; + +/** Дрон-пэд: тоника/субмедианта по половинам лупа. */ +function drone(from: number, to: number, beats: number): NoteEvent[] { + return [ + { beat: 0, dur: beats / 2, midi: from, vel: 0.5 }, + { beat: beats / 2, dur: beats / 2, midi: to, vel: 0.5 } + ]; +} + +/** Мелодия восьмыми с паузами (вероятность ноты 0.55) — сид фиксирует рисунок. */ +function melody(seed: number, beats: number): NoteEvent[] { + const rng = createRng(seed); + const notes: NoteEvent[] = []; + for (let eighth = 0; eighth < beats * 2; eighth++) { + if (!rng.chance(0.55)) continue; + notes.push({ + beat: eighth * 0.5, + dur: 0.5, + midi: PENTATONIC[Math.floor(rng.next() * PENTATONIC.length)]!, + vel: 0.35 + rng.next() * 0.25 + }); + } + return notes; +} + +/** Тема лугов: 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) } + ] +}; + +/** Реестр тем (ключ → спек); область ссылается на тему полем `theme`. */ +export const THEMES: Record = { + meadows: MEADOW_THEME +}; \ No newline at end of file diff --git a/apps/game/src/scenes/LocationScene.ts b/apps/game/src/scenes/LocationScene.ts index aa63ea1..1555b2f 100644 --- a/apps/game/src/scenes/LocationScene.ts +++ b/apps/game/src/scenes/LocationScene.ts @@ -416,6 +416,7 @@ console.log('[location]', this.area.id); // маяк для смоук-тестов // Амбиент локации (кроссфейд; тот же ключ не перезапускается, '' — тишина). this.game.playAmbience(this.area.ambience ?? ''); + this.game.playTheme(this.area.theme ?? ''); } exit(): void { diff --git a/apps/game/tools/agent.mjs b/apps/game/tools/agent.mjs index 72bc0ff..481831a 100644 --- a/apps/game/tools/agent.mjs +++ b/apps/game/tools/agent.mjs @@ -106,7 +106,8 @@ { name: 'collision', run: () => runScenario('collision', 'apps/game/tools/checks/collision.mjs') }, { name: 'ai', run: () => runScenario('ai', 'apps/game/tools/checks/ai.mjs') }, { name: 'audio', run: () => runScenario('audio', 'apps/game/tools/checks/audio.mjs') }, - { name: 'synth', run: () => runScenario('synth', 'apps/game/tools/checks/synth.mjs') } + { name: 'synth', run: () => runScenario('synth', 'apps/game/tools/checks/synth.mjs') }, + { name: 'music', run: () => runScenario('music', 'apps/game/tools/checks/music.mjs') } ]; const only = args.only ?? all.map((p) => p.name); const skip = args.skip ?? []; diff --git a/apps/game/tools/checks/music.mjs b/apps/game/tools/checks/music.mjs new file mode 100644 index 0000000..59fea4e --- /dev/null +++ b/apps/game/tools/checks/music.mjs @@ -0,0 +1,82 @@ +/** + * Сценарий music — музыкальный слой: тема лугов генерируется правилами + * (data/music.ts, сид) и компилируется движковым секвенсором в рантайме + * (playTheme → playLoopBuffer), факт запуска — в window.__gameAudioLog. + * 1) новая игра в лугах: music/meadows в логе (тема играет) + амбиент; + * 2) тема не перезапускается при возврате в ту же область (guard); + * 3) уход в Звенец (темы нет): слот темы снят (themeKey '') и + * новый music/* в логе не появился. + * Запуск: node tools/agent.mjs run tools/checks/music.mjs + */ +import { startDevServer, openGame, Checks } from '../lib.mjs'; + +const readLog = (page) => page.evaluate(() => window.__gameAudioLog ?? null); + +/** Подождать появления ключа в логе (компиляция темы занимает кадры). */ +async function waitKey({ page, agent }, key, tries = 12) { + for (let i = 0; i < tries; i++) { + if ((await readLog(page)).some((e) => e.key === key)) return true; + await agent.step(30); + } + return false; +} + +export default async function ({ pretty }) { + const c = new Checks('music'); + const server = await startDevServer(); + let ctx; + try { + await c.run('новая игра в лугах: тема скомпилирована и играет', async () => { + ctx = await openGame({ url: server.url, newGame: true }); + await ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); + c.expect( + await waitKey(ctx, 'music/meadows'), + 'темы лугов нет в аудио-логе (компиляция не удалась?)', + await readLog(ctx.page) + ); + c.expect( + (await readLog(ctx.page)).some((e) => e.key === 'ambience/meadows'), + 'амбиент лугов пропал при старте темы' + ); + return null; + }); + await c.run('возврат в ту же область: тема не перезапускается (guard)', async () => { + const count = () => + readLog(ctx.page).then((log) => log.filter((e) => e.key === 'music/meadows').length); + const before = await count(); + // Шагнуть туда-обратно мимо переходов, сцена не меняется — guard ключа. + await ctx.agent.walkTo(15, 14, { timeoutTicks: 1500 }); + await ctx.agent.walkTo(14, 14, { timeoutTicks: 1500 }); + c.expect((await count()) === before, 'тема перезапустилась без смены ключа', { + before, + after: await count() + }); + return null; + }); + await c.run('уход в Звенец: слот темы снят, новый music/* не играет', async () => { + // Пешком к тропе в Звенец (26,14) — честный step-переход. + const walked = await ctx.agent.walkTo(26, 14, { timeoutTicks: 3000 }); + c.expect(walked, 'walkTo(26,14) не дошёл до перехода'); + const themeKey = () => + ctx.page.evaluate(() => { + 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 + ); + return null; + }); + } finally { + await ctx?.browser?.close(); + server.stop(); + } + return c.finish({ pretty }).ok ? 0 : 1; +} \ No newline at end of file diff --git a/docs/engine/assets-audio-save.md b/docs/engine/assets-audio-save.md index 0eb2fc7..26e1f4a 100644 --- a/docs/engine/assets-audio-save.md +++ b/docs/engine/assets-audio-save.md @@ -156,13 +156,43 @@ (ambient `declare module` не работает: TS резолвит specifier к реальному файлу и игнорирует ambient-имя). +### Музыка: renderMusic + playLoopBuffer (тема по партитуре) + +Секвенсор движка (`audio/Music.ts`): партитура `MusicSpec` — темп (`bpm`), +длина лупа в долях (`beats`) и партии-стемы (`tracks`: инструмент + ноты +`{beat, dur, midi, vel}` + `gain`). Инструменты — осциллятор + огибающая: +`pluck` (сумма партиалов с затуханием), `bell` (негармоничные партиалы), +`pad` (расстроенная пара синусов, мягкая атака/релиз). `renderMusic(spec)` +— чистая функция (без ГПСЧ: детерминизм мелодии обеспечивают сиды +генератора нот на стороне игры/агента): ноты суммируются, луп сшивается +кроссфейдом и нормализуется к пику 0.8. + +```ts +import { renderMusic, 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 }] } + ] +}; +const buf = audio.createBuffer(renderMusic(spec)); // до unlock — null +if (buf) await audio.playLoopBuffer('music/meadows', buf, { fade: 2, volume: 0.5 }); +``` + +`playLoopBuffer(key, buffer, {loop, fade, volume, bus})` — луп из памяти +на любой шине (по умолчанию music), ключ — логическое имя слота для шпионов; +хендл тот же `MusicHandle`. Лупы-слои с независимой громкостью (спокойно/ +настороженно/бой) — это тот же `playLoop`/`playLoopBuffer` без слота + +тик-фейд поверх `MusicHandle.setVolume` (см. `playLoop` ниже). + Слоты лупов: `playMusic` и `playAmbience` — независимые слоты с кроссфейдом (повторный вызов вытесняет прежний трек своего слота; негодный ключ не глушит -текущий). `playLoop` — луп **без слота** (занимать нечего, сколько вызвали — -столько играет, `MusicHandle.stop` останавливает конкретный): база для -локальных амбиент-слоёв (вода, гул) — игра поверх амбиента области. -У `MusicHandle` есть `setVolume(v)` — мгновенная громкость для медленных -тик-фейдов поверх (сам движок фейд не тикает): +текущий). `playLoop` — луп **без слота** (сколько вызвали — столько играет, +`MusicHandle.stop` останавливает конкретный): база для локальных +амбиент-слоёв (вода, гул) — игра поверх амбиента области. ```ts const layer = await audio.playLoop('ambience/ponds_water', { bus: 'ambience', fade: 2 }); diff --git a/docs/engine/practices.md b/docs/engine/practices.md index 315f72d..87a2498 100644 --- a/docs/engine/practices.md +++ b/docs/engine/practices.md @@ -194,6 +194,10 @@ спек `SoundSpec` (kind `hit`/`chime`/`scrape`/`hum` + параметры) движок детерминированно компилирует сам — путь для ИИ-агента и быстрых прототипов (агентный доступ — op `scene:synthesize`, образец — `checks/synth.mjs`). + Музыка: спек-партитура `MusicSpec` → `renderMusic` → `createBuffer` → + `playLoopBuffer` (см. «Спек-синтез» и «Музыка» в assets-audio-save.md) — + генератор нот с сидом в `data/music.ts`, у области — поле `theme`, + у Game — `playTheme(key)` с guard-ом и кроссфейдом; WAV для музыки не нужен. 4. На слух в headless не проверить — смотреть фактические запуски: DEV-шпион `window.__gameAudioLog` (кольцо на 24, `t`/`volume`/`pan`) читается из страницы; образец — `apps/game/tools/checks/audio.mjs`. После действия diff --git a/docs/plan.md b/docs/plan.md index b5c2541..12c4690 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -349,6 +349,15 @@ лугов — первый носитель. Позже — маленькая нейронка «описание → спек/ноты» как опция, не как база. +✅ **Частично сделано (2026-09-07)**: секвенсор движка (`renderMusic`, чистый, +инструменты pluck/bell/pad) + `playLoopBuffer` (луп из памяти на любой шине); +правило-базовый генератор темы лугов в `data/music.ts` (сид, пентатоника), +компиляция в рантайме — WAV для музыки не нужен; тема области — поле +`theme` + `Game.playTheme` (guard + кроссфейд), проба `checks/music.mjs`. +Стем-миксинг уже покрыт (`playLoop` + тик-фейд, амбиент-слои). +**Осталось**: секвенция тем (бой → назад) — когда появится боевая тема; +нейронка «описание → ноты» — опционально. + --- ## Открытые решения (принять перед стартом работ) diff --git a/packages/engine/src/audio/AudioManager.ts b/packages/engine/src/audio/AudioManager.ts index f4a08d4..3bdf5da 100644 --- a/packages/engine/src/audio/AudioManager.ts +++ b/packages/engine/src/audio/AudioManager.ts @@ -327,6 +327,21 @@ return this.makeHandle(slot, fade); } + /** + * Луп из буфера в памяти (процедурная музыка: renderMusic → буфер → стем + * на шине). Ключ — логическое имя слота (для шпионов), не файл. + */ + async playLoopBuffer( + key: string, + buffer: AudioBuffer, + { loop = true, fade = DEFAULT_FADE, volume = 1, bus = 'music' }: LoopOptions = {} + ): Promise { + const ctx = await this.ensureContext(); + if (!ctx) return null; + const slot = this.startSlot(key, ctx, buffer, { loop, fade, volume, bus }); + return this.makeHandle(slot, fade); + } + /** Handle над слотом: затухание + мгновенная громкость (без борьбы с ramp). */ private makeHandle(slot: Slot, fade: number): MusicHandle { return { @@ -387,13 +402,17 @@ this.fadeOut(current, fadeSeconds); } - /** Общий запуск лупа (источник + линейное нарастание громкости). */ - private async startLoop(key: string, { loop, fade, volume, bus }: Required): Promise { + /** Общий запуск файлового лупа (после декода). */ + private async startLoop(key: string, opts: Required): Promise { const ctx = await this.ensureContext(); if (!ctx) return null; const buf = await this.decode(key); if (!buf) return null; + return this.startSlot(key, ctx, buf, opts); + } + /** Запуск лупа из готового буфера: источник + линейное нарастание громкости. */ + private startSlot(key: string, ctx: AudioContext, buf: AudioBuffer, { loop, fade, volume, bus }: Required): Slot { const source = ctx.createBufferSource(); source.buffer = buf; source.loop = loop; diff --git a/packages/engine/src/audio/Music.ts b/packages/engine/src/audio/Music.ts new file mode 100644 index 0000000..407a7b1 --- /dev/null +++ b/packages/engine/src/audio/Music.ts @@ -0,0 +1,109 @@ +/** + * Музыкальный секвенсор движка: партитура (ноты + инструменты) → сэмплы-луп. + * Адресат — ИИ-агент: описывает партию нотами (MIDI), движок детерминированно + * компилирует её в буфер (AudioManager.playLoopBuffer — стем на шине music). + * Инструменты — осциллятор + огибающая поверх примитивов synth.mjs; сам + * рендер чистый (без ГПСЧ) — детерминизм мелодии обеспечивают сиды + * генератора нот на стороне игры/агента, а не рендера. + */ +import { RATE, decay, loopify, normalize } from '@rpg/engine/tools/synth.mjs'; + +/** Инструменты: осциллятор + огибающая (набор минимальный, расширять по мере надобности). */ +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; +} + +/** Партитура лупа: темп, длина в долях, партии. */ +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); + } +} + +/** + * Партитура → моно-сэмплы-луп (RATE): ноты всех партий суммируются, луп + * сшивается кроссфейдом и нормализуется к пику 0.8. Одинаковые спеки дают + * одинаковые сэмплы (рендер чистый). + */ +export function renderMusic(spec: MusicSpec): 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); + for (const track of spec.tracks) { + const gain = track.gain ?? 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; + for (let i = 0; i < len && start + i < total; i++) { + out[start + i] += wave(i / RATE) * vel * gain; + } + } + } + return loopify(normalize(out, 0.8), Math.min(0.5, (total / RATE) * 0.25)); +} \ 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 0357aec..57d1c01 100644 --- a/packages/engine/src/audio/__tests__/AudioManager.test.ts +++ b/packages/engine/src/audio/__tests__/AudioManager.test.ts @@ -544,6 +544,26 @@ } }); + 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.load('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', { diff --git a/packages/engine/src/audio/__tests__/Music.test.ts b/packages/engine/src/audio/__tests__/Music.test.ts new file mode 100644 index 0000000..9a17a6b --- /dev/null +++ b/packages/engine/src/audio/__tests__/Music.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest'; +import { renderMusic, 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); + }); +}); \ No newline at end of file diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 99d2924..1c1ec40 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -172,6 +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 } from './audio/SoundSpec'; +export { renderMusic, type MusicSpec, type TrackSpec, type NoteEvent, type InstrumentKind } from './audio/Music'; // assets export { AssetLoader } from './assets/AssetLoader';