/**
* Генератор амбиента «Пепельных лугов» — WAV из кода, без зависимостей.
* Запуск: node tools/audio/gen.mjs
* Выход: apps/game/assets/audio/ambience/*.wav
*
* Все sfx переехали на спек-синтез (data/sfxSpecs.ts, компилирует движок) —
* здесь остались только многослойные амбиент-лупы. Формулы бывших sfx
* экспортированы ниже: их сверяет с рендером спеков парити-тест
* data/__tests__/sfxSpecs.test.ts (звучание мигрированных звуков не менялось).
*
* Формат: моно, 22050 Гц, 16-бит PCM. Меланхоличный пепельный саунд.
* WAV-энкодер и примитивы синтеза — жанронезависимые, в @rpg/engine/tools/wav.mjs.
*/
import { mkdirSync, writeFileSync } from 'node:fs';
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 негармоничных партиала + шумовой клик.
* Экспортируется для парити-теста: renderSpec(SPEC_SFX['sfx/bell_hit']) бит-в-бит. */
export function bellHit() {
const dur = 0.35;
const s = synth(dur, (t) => {
const body =
bellPartial(520, 1.0, dur, 5)(t) * 0.5 +
bellPartial(520, 1.52, dur, 6)(t) * 0.3 +
bellPartial(520, 2.38, dur * 0.6, 7)(t) * 0.2 +
bellPartial(520, 3.91, dur * 0.4, 8)(t) * 0.12;
const click = t < 0.008 ? (makeRng(1)() * 2 - 1) * (1 - t / 0.008) * 0.4 : 0;
return body * 0.8 + click;
});
return normalize(s, 0.7);
}
/** Низкий резонанс: медленный гул с тремоло — «усыпляет пепел». */
export function bellLow() {
const dur = 0.9;
const s = synth(dur, (t) => {
const trem = 1 + 0.3 * Math.sin(2 * Math.PI * 6 * t);
const body =
Math.sin(2 * Math.PI * 110 * t) * 0.5 +
Math.sin(2 * Math.PI * 165 * t) * 0.25 +
Math.sin(2 * Math.PI * 220.5 * t) * 0.12;
return body * decay(t, dur, 2) * trem;
});
return normalize(s, 0.6);
}
/** Шипение: сгусток просыпается. */
export function ashHiss() {
const dur = 0.4;
const noise = bandNoise(11, 900, 4200, dur);
const s = synth(dur, (t) => noise() * Math.min(1, t / 0.08) * decay(t, dur, 2));
return normalize(s, 0.55);
}
/** Смерть сгустка: шум с падающим «выдохом». */
export function ashDie() {
const dur = 0.6;
const noise = bandNoise(23, 300, 2400, dur);
const s = synth(dur, (t) => {
const pitch = 1 - 0.7 * (t / dur); // темнеет к концу
return noise() * decay(t, dur, 3) * (0.4 + pitch);
});
return normalize(s, 0.6);
}
/** Плевок: синус с падающей высотой + шипящий хвост. */
export function spit() {
const dur = 0.2;
const noise = bandNoise(37, 2000, 6000, dur);
const s = synth(dur, (t) => {
const f = 700 - 350 * (t / dur);
return (Math.sin(2 * Math.PI * f * t) * 0.6 + noise() * 0.4) * decay(t, dur, 3);
});
return normalize(s, 0.5);
}
/** Урон герою: глухой низкий удар. */
export function hurt() {
const dur = 0.25;
const noise = bandNoise(41, 80, 400, dur);
const s = synth(dur, (t) => {
const thump = Math.sin(2 * Math.PI * 90 * t) * decay(t, dur, 3);
return thump * 0.8 + noise() * 0.3 * decay(t, dur * 0.5, 2);
});
return normalize(s, 0.65);
}
/** Мот подобран: мерцающий двойной звон (тёплая искра в серой земле). */
export function motePickup() {
const dur = 0.45;
const s = synth(dur, (t) => {
const body =
Math.sin(2 * Math.PI * 1240 * t) * 0.4 +
Math.sin(2 * Math.PI * 1860 * t) * 0.2 * decay(t, dur, 4);
return body * decay(t, dur, 2.5);
});
return normalize(s, 0.4);
}
/** Предмет в сумку: мягкий «пок». */
export function pickup() {
const dur = 0.14;
const s = synth(dur, (t) => {
const f = 520 + 220 * (t / dur); // чуть взлетает
return Math.sin(2 * Math.PI * f * t) * decay(t, dur, 2.5);
});
return normalize(s, 0.4);
}
/** Сундук открылся: низкий удар крышки + скрип петель. */
export function chestOpen() {
const dur = 0.55;
const noise = bandNoise(157, 300, 1400, dur);
const s = synth(dur, (t) => {
const thump = Math.sin(2 * Math.PI * 110 * t) * decay(t, 0.12, 2) * 0.7;
const creak = noise() * Math.min(1, t / 0.1) * decay(t, dur, 1.5) * 0.35;
return thump + creak;
});
return normalize(s, 0.45);
}
/** Записка: шелест бумаги. */
export function note() {
const dur = 0.35;
const noise = bandNoise(159, 1800, 6000, dur);
const s = synth(dur, (t) => noise() * (0.4 + 0.6 * Math.sin(2 * Math.PI * 3.2 * t + 1)) * decay(t, dur, 1.5));
return normalize(s, 0.3);
}
/** Переход локаций: воздух уносит — шум с подъёмом и спадом.
* Экспортируется для парити-теста (спек sfx/whoosh). */
export function whoosh() {
const dur = 0.7;
const noise = bandNoise(160, 200, 2400, dur);
const s = synth(dur, (t) => {
const swell = Math.sin(Math.PI * Math.min(1, t / dur)); // 0 → 1 → 0
return noise() * swell;
});
return normalize(s, 0.4);
}
/** Олень вспугнут: глухой выдох + стук копыт. */
export function deerStartle() {
const dur = 0.4;
const noise = bandNoise(161, 150, 900, dur);
const s = synth(dur, (t) => {
const breath = noise() * Math.min(1, t / 0.05) * decay(t, dur, 2);
const hoof = t > 0.2 && t < 0.28 ? Math.sin(2 * Math.PI * 90 * (t - 0.2)) * decay(t - 0.2, 0.08, 2) * 0.6 : 0;
return breath * 0.7 + hoof;
});
return normalize(s, 0.4);
}
/** Клик UI. */
export function uiClick() {
const dur = 0.06;
const s = synth(dur, (t) => Math.sin(2 * Math.PI * 880 * t) * decay(t, dur, 3) * 0.5);
return normalize(s, 0.4);
}
/** Колокольчик Милы: высокий, почти хрустальный. */
export function chime() {
const dur = 0.5;
const s = synth(dur, (t) => {
const body =
Math.sin(2 * Math.PI * 1560 * t) * 0.5 +
Math.sin(2 * Math.PI * 1560 * 1.61 * t) * 0.25;
return body * decay(t, dur, 3);
});
return normalize(s, 0.45);
}
/** Амбиент лугов: низкий ветер + редкий гул, зациклен. */
function ambienceMeadows() {
const dur = 8;
const wind = bandNoise(61, 100, 600, dur);
const r = makeRng(7);
const gusts = [];
for (let i = 0; i < 3; i++) gusts.push({ at: r() * dur, len: 1.5 + r() });
const s = synth(dur, (t) => {
let v = wind() * 0.25;
// медленное «дыхание» ветра
v *= 0.6 + 0.4 * Math.sin(2 * Math.PI * t / dur * 2 + 1);
for (const g of gusts) {
const d = t - g.at + (t < g.at ? dur : 0); // зациклить порывы
if (d >= 0 && d < g.len) v += Math.sin(2 * Math.PI * 82 * d) * 0.1 * decay(d, g.len, 2);
}
return v;
});
return loopify(normalize(s, 0.5));
}
/** Амбиент прудов: ниже, влажнее, с редкой капелью. */
function ambiencePonds() {
const dur = 8;
const wind = bandNoise(71, 60, 400, dur);
const r = makeRng(13);
const drops = [];
for (let i = 0; i < 4; i++) drops.push({ at: r() * dur, f: 900 + r() * 600 });
const s = synth(dur, (t) => {
let v = wind() * 0.3 * (0.7 + 0.3 * Math.sin(2 * Math.PI * t / dur + 3));
for (const d of drops) {
const dt = t - d.at + (t < d.at ? dur : 0);
if (dt >= 0 && dt < 0.15) v += Math.sin(2 * Math.PI * d.f * dt * (1 - dt * 3)) * 0.08 * decay(dt, 0.15, 2);
}
return v;
});
return loopify(normalize(s, 0.5));
}
/**
* Амбиент Звенца: низкий колокольный тон (два удара за цикл) над тихим
* ветром. Колокол башни отлит из рельсов — голос ниже лугового гула.
*/
function ambienceZvenets() {
const dur = 8;
const wind = bandNoise(81, 80, 500, dur);
const strikes = [
{ at: 1.0, f: 148 },
{ at: 5.0, f: 111 }
];
const s = synth(dur, (t) => {
let v = wind() * 0.2 * (0.7 + 0.3 * Math.sin(2 * Math.PI * t / dur + 0.5));
for (const s of strikes) {
const dt = t - s.at + (t < s.at ? dur : 0); // зациклить удары
if (dt >= 0 && dt < 2.5) {
v +=
(Math.sin(2 * Math.PI * s.f * dt) * 0.22 +
Math.sin(2 * Math.PI * s.f * 2.76 * dt) * 0.08 +
Math.sin(2 * Math.PI * s.f * 5.4 * dt) * 0.04) *
decay(dt, 2.5, 3);
}
}
return v;
});
return loopify(normalize(s, 0.55));
}
// ---------- локальные амбиент-слои ----------
/** Слой «вода прудов»: тихий плеск + редкие капли. */
function layerPondsWater() {
const dur = 8;
const lap = bandNoise(172, 300, 1500, dur);
const r = makeRng(27);
const drops = [];
for (let i = 0; i < 6; i++) drops.push({ at: r() * dur, f: 800 + r() * 700 });
const s = synth(dur, (t) => {
// Плебск «дышит» медленной волной.
let v = lap() * 0.12 * (0.5 + 0.5 * Math.sin(2 * Math.PI * t / dur + 2));
for (const d of drops) {
const dt = t - d.at + (t < d.at ? dur : 0); // зациклить капли
if (dt >= 0 && dt < 0.12) v += Math.sin(2 * Math.PI * d.f * dt) * 0.1 * decay(dt, 0.12, 2);
}
return v;
});
return loopify(normalize(s, 0.5));
}
/** Слой «башня Звенца»: низкий гул колокола (55+82.5 Гц) с биением. */
function layerTowerHum() {
const dur = 8;
const s = synth(dur, (t) => {
const breath = 0.6 + 0.4 * Math.sin(2 * Math.PI * t / dur * 2 + 0.5);
const body =
Math.sin(2 * Math.PI * 55 * t) * 0.5 +
Math.sin(2 * Math.PI * 82.5 * t) * 0.3; // биение 27.5 Гц
return body * breath * 0.35;
});
return loopify(normalize(s, 0.5));
}
/** Слой «дома Звенца»: редкие скрипы вагонных щитов. */
function layerHouses() {
const dur = 8;
const noise = bandNoise(174, 200, 1000, dur);
const r = makeRng(41);
const creaks = [];
for (let i = 0; i < 4; i++) creaks.push({ at: r() * dur, len: 0.5 + r() * 0.6 });
const s = synth(dur, (t) => {
let v = 0;
for (const c of creaks) {
const dt = t - c.at + (t < c.at ? dur : 0); // зациклить скрипы
if (dt >= 0 && dt < c.len) {
const wob = 1 + 0.5 * Math.sin(2 * Math.PI * 5 * dt);
v += noise() * wob * decay(dt, c.len, 1.5) * 0.25;
}
}
return v;
});
return loopify(normalize(s, 0.5));
}
// ---------- запись ----------
const AMBIENCE = {
'ambience/meadows': ambienceMeadows,
'ambience/ponds': ambiencePonds,
'ambience/zvenets': ambienceZvenets,
'ambience/ponds_water': layerPondsWater,
'ambience/tower_hum': layerTowerHum,
'ambience/houses': layerHouses
};
// Записываем WAV только при запуске как CLI, а не при импорте (парити-тест
// импортирует формулы bellHit/whoosh, чтобы сверить их со спек-синтезом).
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
mkdirSync(`${OUT}ambience`, { recursive: true });
for (const [name, gen] of Object.entries(AMBIENCE)) {
const buf = encodeWav(gen());
writeFileSync(`${OUT}${name}.wav`, buf);
console.log(`${name}.wav ${(buf.length / 1024).toFixed(1)} КБ`);
}
}