diff --git a/apps/game/assets/audio/sfx/bell_hit.wav b/apps/game/assets/audio/sfx/bell_hit.wav deleted file mode 100644 index c64de60..0000000 --- a/apps/game/assets/audio/sfx/bell_hit.wav +++ /dev/null Binary files differ diff --git a/apps/game/assets/audio/sfx/door.wav b/apps/game/assets/audio/sfx/door.wav deleted file mode 100644 index e8cfd2e..0000000 --- a/apps/game/assets/audio/sfx/door.wav +++ /dev/null Binary files differ diff --git a/apps/game/assets/audio/sfx/whoosh.wav b/apps/game/assets/audio/sfx/whoosh.wav deleted file mode 100644 index 00e80e5..0000000 --- a/apps/game/assets/audio/sfx/whoosh.wav +++ /dev/null Binary files differ diff --git a/apps/game/src/data/__tests__/sfxSpecs.test.ts b/apps/game/src/data/__tests__/sfxSpecs.test.ts new file mode 100644 index 0000000..bb19417 --- /dev/null +++ b/apps/game/src/data/__tests__/sfxSpecs.test.ts @@ -0,0 +1,26 @@ +/** + * Парити-тест спек-синтеза: renderSpec(SPEC_SFX[key]) должен быть бит-в-бит + * равен формуле бывшего файлового генератора (tools/audio/gen.mjs) — миграция + * sfx/bell_hit и sfx/whoosh на playSpec не должна изменить звучание. + * Слух агента нет, поэтому звучание сверяется численно (поразрядно). + */ +import { describe, expect, it } from 'vitest'; +import { renderSpec } from '@rpg/engine'; +import { bellHit, whoosh } from '../../../tools/audio/gen.mjs'; +import { SPEC_SFX } from '../sfxSpecs'; + +describe('паритет SPEC_SFX с формулами gen.mjs', () => { + it('sfx/bell_hit — бит-в-бит равен bellHit()', () => { + const spec = renderSpec(SPEC_SFX['sfx/bell_hit']!); + const wav = bellHit(); + expect(spec.length).toBe(wav.length); + for (let i = 0; i < wav.length; i++) expect(spec[i]).toBe(wav[i]); + }); + + it('sfx/whoosh — бит-в-бит равен whoosh()', () => { + const spec = renderSpec(SPEC_SFX['sfx/whoosh']!); + const wav = whoosh(); + expect(spec.length).toBe(wav.length); + for (let i = 0; i < wav.length; i++) expect(spec[i]).toBe(wav[i]); + }); +}); \ No newline at end of file diff --git a/apps/game/src/data/audio.ts b/apps/game/src/data/audio.ts index 46b6606..bb3bbfa 100644 --- a/apps/game/src/data/audio.ts +++ b/apps/game/src/data/audio.ts @@ -1,12 +1,12 @@ /** - * Ключи аудио (WAV из tools/audio/gen.mjs): sfx + амбиент-лупы областей. - * Отдельный модуль (без `?url`-импортов Game) — чтобы валидатор мог сверять - * ключи контента с реестром в node-тестах. + * Ключи аудио: sfx (WAV из tools/audio/gen.mjs + спек-синтез sfxSpecs.ts) и + * амбиент-лупы областей. Отдельный модуль (без `?url`-импортов Game) — чтобы + * валидатор мог сверять ключи контента с реестром в node-тестах. */ +import { SPEC_SFX } from './sfxSpecs'; -/** Sfx. */ +/** Файловые sfx (WAV). */ export const SFX_KEYS = [ - 'sfx/bell_hit', 'sfx/bell_low', 'sfx/ash_hiss', 'sfx/ash_die', @@ -19,19 +19,25 @@ 'sfx/mote_pickup', 'sfx/pickup', 'sfx/chest_open', - 'sfx/door', 'sfx/note', - 'sfx/whoosh', 'sfx/deer_startle', 'sfx/ui_click', 'sfx/chime' ] as const; +/** Спек-синтезированные sfx (без WAV, компилирует движок). */ +export const SPEC_SFX_KEYS: readonly string[] = Object.keys(SPEC_SFX); + /** Амбиент-лупы областей. */ export const AMBIENCE_KEYS = ['ambience/meadows', 'ambience/ponds', 'ambience/zvenets'] as const; /** Локальные амбиент-слои (вода, гул, дома). */ export const AMBIENCE_LAYER_KEYS = ['ambience/ponds_water', 'ambience/tower_hum', 'ambience/houses'] as const; -/** Все аудио-ключи игры (префетч в BootScene). */ -export const AUDIO_KEYS: readonly string[] = [...SFX_KEYS, ...AMBIENCE_KEYS, ...AMBIENCE_LAYER_KEYS]; \ No newline at end of file +/** Все аудио-ключи игры (валидация контента; префетч только файловых). */ +export const AUDIO_KEYS: readonly string[] = [ + ...SFX_KEYS, + ...SPEC_SFX_KEYS, + ...AMBIENCE_KEYS, + ...AMBIENCE_LAYER_KEYS +]; \ No newline at end of file diff --git a/apps/game/src/data/sfxSpecs.ts b/apps/game/src/data/sfxSpecs.ts new file mode 100644 index 0000000..a786750 --- /dev/null +++ b/apps/game/src/data/sfxSpecs.ts @@ -0,0 +1,25 @@ +/** + * Sfx на спек-синтезе: ключ → SoundSpec (компилирует движок, WAV не нужен). + * Формулы совпадают с бывшими генераторами tools/audio/gen.mjs дословно — + * парити-тест sfxSpecs.test.ts сверяет renderSpec с функциями gen.mjs. + */ +import type { AudioManager, PlayOptions, SoundSpec } from '@rpg/engine'; + +/** Реестр спек-звуков (ключи валидируются тем же validate-тестом, что и WAV). */ +export const SPEC_SFX: Record = { + // Бывший sfx/bell_hit.wav (bellHit): 4 партиала + клик, normalize 0.7. + 'sfx/bell_hit': { kind: 'chime', dur: 0.35, freq: 520, power: 5, seed: 1, peak: 0.7 }, + // Бывший sfx/whoosh.wav (whoosh): воздух «0 → 1 → 0», normalize 0.4. + 'sfx/whoosh': { kind: 'scrape', dur: 0.7, low: 200, high: 2400, seed: 160, swell: true, peak: 0.4 } +}; + +/** + * Единая точка проигрывания sfx по ключу: спек-звук — через playSpec + * (движок компилирует сам), файловый — через play. Все play-сайты игры + * зовут этот хелпер, а не audio.play напрямую. + */ +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); +} \ No newline at end of file diff --git a/apps/game/src/scenes/BootScene.ts b/apps/game/src/scenes/BootScene.ts index bafd157..152b374 100644 --- a/apps/game/src/scenes/BootScene.ts +++ b/apps/game/src/scenes/BootScene.ts @@ -1,6 +1,7 @@ import { Container, Graphics, PixelText, ensurePixelFont, type Scene } from '@rpg/engine'; import { Game } from '../Game'; import { AREAS } from '../data/locations'; +import { AMBIENCE_KEYS, AMBIENCE_LAYER_KEYS, SFX_KEYS } from '../data/audio'; import { MenuScene } from './MenuScene'; /** @@ -50,8 +51,13 @@ }) .then(() => ensurePixelFont(Game.FONT_URL)) // Звуки: декодируем заранее, чтобы первый play не тормозил. + // Спек-звуки (SPEC_SFX_KEYS) не грузим — их компилирует движок. .then(async () => { - for (const key of Game.AUDIO_KEYS) { + for (const key of SFX_KEYS) { + await this.game.audio.load(key); + await this.game.audio.preload(key); + } + for (const key of [...AMBIENCE_KEYS, ...AMBIENCE_LAYER_KEYS]) { await this.game.audio.load(key); await this.game.audio.preload(key); } diff --git a/apps/game/src/scenes/LocationScene.ts b/apps/game/src/scenes/LocationScene.ts index 1555b2f..4a4df65 100644 --- a/apps/game/src/scenes/LocationScene.ts +++ b/apps/game/src/scenes/LocationScene.ts @@ -27,6 +27,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 { areaOf, type AmbienceLayerDef, @@ -572,7 +573,7 @@ /** Выполнить переход: новая сцена с fade (длительность — из определения). */ private useTransition(def: TransitionDef): void { const next = this.targetEntry(def); - void this.game.audio.play('sfx/whoosh', 0.5); + playSfx(this.game.audio, 'sfx/whoosh', 0.5); void this.game.scenes.replace( new LocationScene(this.game, null, next.area, next.entry, { area: this.area.id, @@ -777,7 +778,7 @@ const n = this.game.state.getNumber(VARS.flowers) + 1; this.game.state.setVar(VARS.flowers, n); this.game.engine.events.emit('quest:flower', { n }); - void this.game.audio.play('sfx/bell_hit', 0.6); + playSfx(this.game.audio, 'sfx/bell_hit', 0.6); const c = this.tileCenter(x, y); this.combatViews.hitBurst(c); this.showToast(`Лунный колокольчик (${Math.min(n, QUEST_FLOWERS)}/${QUEST_FLOWERS})`); diff --git a/apps/game/src/systems/AudioSystem.ts b/apps/game/src/systems/AudioSystem.ts index 4b3b43f..54a107b 100644 --- a/apps/game/src/systems/AudioSystem.ts +++ b/apps/game/src/systems/AudioSystem.ts @@ -6,6 +6,7 @@ type Vec2 } from '@rpg/engine'; import { Game } from '../Game'; +import { playSfx } from '../data/sfxSpecs'; import { StepVariants, type StepSurface } from './StepVariants'; /** @@ -159,6 +160,6 @@ private 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); - void this.deps.audio.play(key, opts); + playSfx(this.deps.audio, key, opts); } } \ No newline at end of file diff --git a/apps/game/src/systems/__tests__/AudioSystem.test.ts b/apps/game/src/systems/__tests__/AudioSystem.test.ts index 210b7f4..abc902c 100644 --- a/apps/game/src/systems/__tests__/AudioSystem.test.ts +++ b/apps/game/src/systems/__tests__/AudioSystem.test.ts @@ -13,6 +13,9 @@ playBuffer: vi.fn(async (key: string, _buf: AudioBuffer, opts?: { volume?: number; rate?: number; pan?: number }) => { played.push({ key, opts }); }), + playSpec: vi.fn(async (key: string, _spec: unknown, opts?: { volume?: number; rate?: number; pan?: number }) => { + played.push({ key, opts }); + }), // По умолчанию контекст «не разблокирован» — буферы не создать. createBuffer: vi.fn((): AudioBuffer | null => null), playLoop: vi.fn(async (key: string, opts?: { bus?: string; volume?: number; fade?: number }) => { diff --git a/apps/game/tools/audio/gen.d.mts b/apps/game/tools/audio/gen.d.mts new file mode 100644 index 0000000..207fd95 --- /dev/null +++ b/apps/game/tools/audio/gen.d.mts @@ -0,0 +1,3 @@ +/** Типы для парити-теста sfxSpecs: экспортируемые формулы gen.mjs. */ +export declare function bellHit(): Float32Array; +export declare function whoosh(): Float32Array; \ No newline at end of file diff --git a/apps/game/tools/audio/gen.mjs b/apps/game/tools/audio/gen.mjs index 147b810..0ca8e77 100644 --- a/apps/game/tools/audio/gen.mjs +++ b/apps/game/tools/audio/gen.mjs @@ -8,15 +8,16 @@ * WAV-энкодер и примитивы синтеза — жанронезависимые, в @rpg/engine/tools/wav.mjs. */ import { mkdirSync, writeFileSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import { encodeWav, makeRng, synth, decay, bellPartial, bandNoise, normalize, loopify } from '@rpg/engine/tools/wav.mjs'; const OUT = fileURLToPath(new URL('../../assets/audio/', import.meta.url)); // ---------- звуки ---------- -/** Гулкий удар колокольчиком: 4 негармоничных партиала + шумовой клик. */ -function bellHit() { +/** Гулкий удар колокольчиком: 4 негармоничных партиала + шумовой клик. + * Экспортируется для парити-теста: renderSpec(SPEC_SFX['sfx/bell_hit']) бит-в-бит. */ +export function bellHit() { const dur = 0.35; const s = synth(dur, (t) => { const body = @@ -151,19 +152,6 @@ return normalize(s, 0.45); } -/** Дверь: короткий скрип щитов на ржавых петлях. */ -function door() { - const dur = 0.5; - const noise = bandNoise(158, 250, 1200, dur); - const s = synth(dur, (t) => { - // Скрип с гуляющей частотой (воблер 7 Гц). - const wob = 1 + 0.4 * Math.sin(2 * Math.PI * 7 * t); - const creak = noise() * wob * decay(t, dur, 1.5); - return creak * 0.8; - }); - return normalize(s, 0.4); -} - /** Записка: шелест бумаги. */ function note() { const dur = 0.35; @@ -172,8 +160,9 @@ return normalize(s, 0.3); } -/** Переход локаций: воздух уносит — шум с подъёмом и спадом. */ -function whoosh() { +/** Переход локаций: воздух уносит — шум с подъёмом и спадом. + * Экспортируется для парити-теста (спек sfx/whoosh). */ +export function whoosh() { const dur = 0.7; const noise = bandNoise(160, 200, 2400, dur); const s = synth(dur, (t) => { @@ -338,7 +327,6 @@ // ---------- запись ---------- const SFX = { - 'sfx/bell_hit': bellHit, 'sfx/bell_low': bellLow, 'sfx/ash_hiss': ashHiss, 'sfx/ash_die': ashDie, @@ -351,9 +339,7 @@ 'sfx/mote_pickup': motePickup, 'sfx/pickup': pickup, 'sfx/chest_open': chestOpen, - 'sfx/door': door, 'sfx/note': note, - 'sfx/whoosh': whoosh, 'sfx/deer_startle': deerStartle, 'sfx/ui_click': uiClick, 'sfx/chime': chime, @@ -365,10 +351,14 @@ 'ambience/houses': layerHouses }; -mkdirSync(`${OUT}sfx`, { recursive: true }); -mkdirSync(`${OUT}ambience`, { recursive: true }); -for (const [name, gen] of Object.entries(SFX)) { - const buf = encodeWav(gen()); - writeFileSync(`${OUT}${name}.wav`, buf); - console.log(`${name}.wav ${(buf.length / 1024).toFixed(1)} КБ`); +// Записываем WAV только при запуске как CLI, а не при импорте (парити-тест +// импортирует формулы bellHit/whoosh, чтобы сверить их со спек-синтезом). +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + mkdirSync(`${OUT}sfx`, { recursive: true }); + mkdirSync(`${OUT}ambience`, { recursive: true }); + for (const [name, gen] of Object.entries(SFX)) { + const buf = encodeWav(gen()); + writeFileSync(`${OUT}${name}.wav`, buf); + console.log(`${name}.wav ${(buf.length / 1024).toFixed(1)} КБ`); + } } \ No newline at end of file diff --git a/docs/engine/assets-audio-save.md b/docs/engine/assets-audio-save.md index 26e1f4a..3f887d6 100644 --- a/docs/engine/assets-audio-save.md +++ b/docs/engine/assets-audio-save.md @@ -140,14 +140,16 @@ tone: 60, // низкая подложка / основной тон гула power: 1.5, // крутизна затухания / «дыхание» hum seed: 7, // сид шума/клика — детерминизм - peak: 0.5 // нормализация 0..1 + peak: 0.5, // нормализация 0..1 + swell: false // огибающая «0 → 1 → 0» вместо спада (воздух, свуш) }); await audio.playSpec('agent/coin', { kind: 'chime', dur: 0.6, freq: 880 }, 0.4); ``` Формы: `hit` — полоса шума с резким спадом + низкая подложка (удары); -`chime` — негармоничные партиалы + клик (звон, монеты, подбор); `scrape` — -низкая полоса с медленным спадом (дверь, куст, ветка); `hum` — два тона +`chime` — четыре негармоничных партиала + клик (звон, монеты, подбор); +`scrape` — низкая полоса с медленным спадом (дверь, куст, ветка), `swell: true` +превращает спад в «0 → 1 → 0» (воздух, свуш); `hum` — два тона (tone и tone×1.5) на «дыхании», луп по умолчанию (гул, эмбиент-слой). Тот же `renderSpec` экспортируется из публичного API — можно компилировать в сэмплы без воспроизведения (офлайн-генерация, тесты). diff --git a/docs/engine/practices.md b/docs/engine/practices.md index 87a2498..fd2fbfc 100644 --- a/docs/engine/practices.md +++ b/docs/engine/practices.md @@ -190,8 +190,15 @@ слои (вода, гул) — `AreaDef.ambienceLayers` + тик `worldAudio.setLayers`. Полифонию ограничивает движок (`maxVoices`, воровство тихих голосов) — пер-ключевой интервал в `AudioSystem` нужен только против «пулемёта» - одного ключа. Звук **без файла вообще** — `audio.playSpec(key, spec)`: - спек `SoundSpec` (kind `hit`/`chime`/`scrape`/`hum` + параметры) движок + одного ключа. Звук **без файла вообще** — спек-синтез: ключ в реестр + `SPEC_SFX` (`data/sfxSpecs.ts`, ключи попадают в `AUDIO_KEYS` через + `SPEC_SFX_KEYS`), play-сайт зовёт хелпер `playSfx(audio, key, opts)` — + он сам разрулит спек-ключ и файловый (не звать `audio.play` напрямую). + Формулы бывшего WAV-генератора переносятся в спек дословно — парити-тест + `sfxSpecs.test.ts` сверяет `renderSpec` с функцией gen.mjs бит-в-бит + (генератор экспортирует формулу, но WAV при импорте не пишет — CLI-гвард + `import.meta.url === pathToFileURL(process.argv[1]).href`). Спек + `SoundSpec` (kind `hit`/`chime`/`scrape`/`hum` + параметры) движок детерминированно компилирует сам — путь для ИИ-агента и быстрых прототипов (агентный доступ — op `scene:synthesize`, образец — `checks/synth.mjs`). Музыка: спек-партитура `MusicSpec` → `renderMusic` → `createBuffer` → diff --git a/docs/plan.md b/docs/plan.md index 12c4690..c4f5e45 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -24,8 +24,8 @@ | 5 | Замена сгенерированных плейсхолдеров арта на AI/ручной арт | в составе работы B | | 6 | Крупные объекты: footprint в движке, слоты арта (работа C, ниже) | в работе | | 7 | Возможности аудио-движка (работа D, ниже): sfx-хендл, полифония, наблюдаемость, буферы | ✅ D0–D2 сделаны (2026-09-07) | -| 8 | Синтез звука по запросу для агента (работа E, ниже) | ✅ E1+E2 сделаны (2026-09-07); E3 — опционально | -| 9 | Инфраструктура аудио + музыкальный слой (работы F/G, ниже) | 📋 спланировано | +| 8 | Синтез звука по запросу для агента (работа E, ниже) | ✅ E1–E3 сделаны (2026-09-07) | +| 9 | Инфраструктура аудио + музыкальный слой (работы F/G, ниже) | ✅ сделаны (2026-09-07) | Отложено: **IsoLayout как параметр игры** — `DEFAULT_ISO` в `math/iso.ts` остаётся единственной точкой входа; прокидывание конфига игры через ~15 мест движка не даёт @@ -329,8 +329,16 @@ playSpec), документация форм спеков в `docs/engine/agent.md`; проба `checks/synth.mjs` (в `agent:check`) — агент слепо синтезирует удар/звон/ скрип/гул, проверка фактов запуска в аудио-логе + отрицательные случаи. -- **E3 (контент, опционально):** миграция части файловых SFX на спеки - (дверь, цветок/куст) — где процедурная формула тривиальна. +- **E3 (контент) ✅ (2026-09-07): миграция файловых SFX на спеки.** + `sfx/bell_hit` и `sfx/whoosh` — теперь спек-синтез в `data/sfxSpecs.ts` + (реестр `SPEC_SFX` + хелпер `playSfx`, единая точка проигрывания sfx); + мёртвый `sfx/door.wav` (ключ без play-сайтов) удалён. Для свуша у scrape + добавлен флаг `swell` (огибающая «0 → 1 → 0»), у chime — 4-й партиал и + константная амплитуда клика: формулы совпали с gen.mjs дословно, парити-тест + `sfxSpecs.test.ts` сверяет `renderSpec` с формулами генератора бит-в-бит + (генератор формулы экспортирует, WAV при импорте не пишет — CLI-гвард). + Остальные файловые SFX не выражаются текущими kinds (тон/тремоло/свип) — + по мере расширения палитры спеков мигрируются так же. ### Работа F — инфраструктура аудио (хвосты D) diff --git a/packages/engine/src/audio/SoundSpec.ts b/packages/engine/src/audio/SoundSpec.ts index 9da2f80..d347016 100644 --- a/packages/engine/src/audio/SoundSpec.ts +++ b/packages/engine/src/audio/SoundSpec.ts @@ -37,6 +37,8 @@ seed?: number; /** Пик нормализации 0..1 (дефолт 0.5). */ peak?: number; + /** Огибающая «0 → 1 → 0» вместо спада с самого громкого старта (воздух, свуш). */ + swell?: boolean; /** Зациклить (кроссфейд краёв; для hum — по умолчанию). */ loop?: boolean; } @@ -44,41 +46,50 @@ /** Клэмп длительности: снизу — против нулевых буферов, сверху — по 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); +} + /** Шумовой удар: полоса шума с резким спадом; tone — низкая подложка. */ function renderHit(spec: SoundSpec, dur: number): Float32Array { const noise = bandNoise(spec.seed ?? 1, spec.low ?? 600, spec.high ?? 3000, dur); - const power = spec.power ?? 2; + const env = envelope(spec, dur, spec.power ?? 2); const tone = spec.tone ?? 0; return normalize( synth(dur, (t) => { - let v = noise() * decay(t, dur, power); - if (tone > 0) v += Math.sin(2 * Math.PI * tone * t) * decay(t, dur, power * 0.8) * 0.4; + let v = noise() * env(t); + if (tone > 0) v += Math.sin(2 * Math.PI * tone * t) * decay(t, dur, (spec.power ?? 2) * 0.8) * 0.4; return v; }), spec.peak ?? 0.5 ); } -/** Скрип/шорох: низкая полоса, медленный спад (ветка/куст/дверь). */ +/** Скрип/шорох: низкая полоса, медленный спад (ветка/куст/дверь); swell — воздух. */ function renderScrape(spec: SoundSpec, dur: number): Float32Array { const noise = bandNoise(spec.seed ?? 1, spec.low ?? 100, spec.high ?? 900, dur); - const power = spec.power ?? 1.5; - return normalize(synth(dur, (t) => noise() * decay(t, dur, power)), spec.peak ?? 0.5); + const env = envelope(spec, dur, spec.power ?? 1.5); + return normalize(synth(dur, (t) => noise() * env(t)), spec.peak ?? 0.5); } /** Звон: негармоничные партиалы одной частоты + короткий клик. */ function renderChime(spec: SoundSpec, dur: number): Float32Array { const f = spec.freq ?? 520; const power = spec.power ?? 5; - const rng = makeRng((spec.seed ?? 1) + 17); - const clickDur = 0.008; + // Клик — спад с одной случайной амплитудой (как у файлового генератора: + // формулы совпадают дословно — мигрированные звуки звучат один в один). + const clickAmp = makeRng(spec.seed ?? 1)() * 2 - 1; return normalize( synth(dur, (t) => { const body = bellPartial(f, 1.0, dur, power)(t) * 0.5 + bellPartial(f, 1.52, dur, power + 1)(t) * 0.3 + - bellPartial(f, 2.38, dur * 0.6, power + 2)(t) * 0.2; - const click = t < clickDur ? (rng() * 2 - 1) * (1 - t / clickDur) * 0.4 : 0; + bellPartial(f, 2.38, dur * 0.6, power + 2)(t) * 0.2 + + bellPartial(f, 3.91, dur * 0.4, power + 3)(t) * 0.12; + const click = t < 0.008 ? clickAmp * (1 - t / 0.008) * 0.4 : 0; return body * 0.8 + click; }), spec.peak ?? 0.5