/**
* Генератор звуков «Пепельных лугов» — WAV из кода, без зависимостей.
* Запуск: node tools/audio/gen.mjs
* Выход: apps/game/assets/audio/sfx/*.wav, apps/game/assets/audio/music/*.wav
*
* Формат: моно, 22050 Гц, 16-бит PCM (хватает для пиксель-арта со звуком,
* файлы вдвое меньше). Меланхоличный пепельный саунд: глухие удары, шум, низкие пады.
*/
import { mkdirSync, writeFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
const OUT = fileURLToPath(new URL('../../apps/game/assets/audio/', import.meta.url));
const RATE = 22050;
// ---------- WAV-энкодер ----------
/** Собрать WAV-файл из Float32 сэмплов (-1..1). */
function encodeWav(samples) {
const n = samples.length;
const data = new ArrayBuffer(44 + n * 2);
const view = new DataView(data);
const str = (offset, s) => {
for (let i = 0; i < s.length; i++) view.setUint8(offset + i, s.charCodeAt(i));
};
str(0, 'RIFF');
view.setUint32(4, 36 + n * 2, true);
str(8, 'WAVE');
str(12, 'fmt ');
view.setUint32(16, 16, true); // размер fmt
view.setUint16(20, 1, true); // PCM
view.setUint16(22, 1, true); // моно
view.setUint32(24, RATE, true);
view.setUint32(28, RATE * 2, true); // байт/сек
view.setUint16(32, 2, true); // блок
view.setUint16(34, 16, true); // бит
str(36, 'data');
view.setUint32(40, n * 2, true);
for (let i = 0; i < n; i++) {
const s = Math.max(-1, Math.min(1, samples[i]));
view.setInt16(44 + i * 2, Math.round(s * 32767), true);
}
return Buffer.from(data);
}
// ---------- примитивы ----------
/** Детерминированный шум. */
function makeRng(seed) {
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) -> сэмпл. */
function synth(seconds, fn) {
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 к концу. */
function decay(t, dur, power = 4) {
return Math.pow(Math.max(0, 1 - t / dur), power);
}
/** Колокольный партиал: негармоничный (частота * растяжка), быстрый спад. */
function bellPartial(freq, stretch, dur, power) {
return (t) => Math.sin(2 * Math.PI * freq * stretch * t) * decay(t, dur, power);
}
/** Полосовой шум через однополюсные фильтры (низ + верх). */
function bandNoise(seed, lowHz, highHz, dur) {
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));
};
}
/** Нормализовать по пику. */
function normalize(samples, peak = 0.9) {
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-секунд с концом. */
function loopify(samples, fadeSeconds = 0.5) {
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);
}
// ---------- звуки ----------
/** Гулкий удар колокольчиком: 4 негармоничных партиала + шумовой клик. */
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);
}
/** Низкий резонанс: медленный гул с тремоло — «усыпляет пепел». */
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);
}
/** Шипение: сгусток просыпается. */
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);
}
/** Смерть сгустка: шум с падающим «выдохом». */
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);
}
/** Плевок: синус с падающей высотой + шипящий хвост. */
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);
}
/** Урон герою: глухой низкий удар. */
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);
}
/** Шаг по пеплу: короткий шорох. */
function step() {
const dur = 0.12;
const noise = bandNoise(53, 300, 1500, dur);
const s = synth(dur, (t) => noise() * decay(t, dur, 2) * 0.5);
return normalize(s, 0.3);
}
/** Клик UI. */
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);
}
/** Колокольчик Милы: высокий, почти хрустальный. */
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 musicMeadows() {
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 musicPonds() {
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 musicZvenets() {
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));
}
// ---------- запись ----------
const SFX = {
'sfx/bell_hit': bellHit,
'sfx/bell_low': bellLow,
'sfx/ash_hiss': ashHiss,
'sfx/ash_die': ashDie,
'sfx/spit': spit,
'sfx/hurt': hurt,
'sfx/step': step,
'sfx/ui_click': uiClick,
'sfx/chime': chime,
'music/meadows': musicMeadows,
'music/ponds': musicPonds,
'music/zvenets': musicZvenets
};
mkdirSync(`${OUT}sfx`, { recursive: true });
mkdirSync(`${OUT}music`, { 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)} КБ`);
}