Newer
Older
rpg / apps / game / tools / audio / gen.mjs
/**
 * Генератор звуков «Пепельных лугов» — WAV из кода, без зависимостей.
 * Запуск: node tools/audio/gen.mjs
 * Выход:  apps/game/assets/audio/sfx/*.wav, apps/game/assets/audio/ambience/*.wav
 *
 * Формат: моно, 22050 Гц, 16-бит PCM (хватает для пиксель-арта со звуком,
 * файлы вдвое меньше). Меланхоличный пепельный саунд: глухие удары, шум, низкие пады.
 * WAV-энкодер и примитивы синтеза — жанронезависимые, в @rpg/engine/tools/wav.mjs.
 */
import { mkdirSync, writeFileSync } from 'node:fs';
import { fileURLToPath } 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() {
    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 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));
}

// ---------- запись ----------

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,
    'ambience/meadows': ambienceMeadows,
    'ambience/ponds': ambiencePonds,
    'ambience/zvenets': ambienceZvenets
};

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)} КБ`);
}