Newer
Older
rpg / apps / game / tools / audio / gen.mjs
/**
 * Генератор амбиента «Пепельных лугов» — 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);
}

/** Амбиент лугов: настоящий ветер — воздушная полоса, порывы шумом, зациклен. */
/**
 * Циклический value-noise: плавная случайная кривая, замкнутая на границе
 * лупа (u = t/dur). Сумма двух-трёх кривых с несоизмеримыми шагами —
 * огибающая ветра без слышимого периода.
 */
function slowNoise(seed, steps, phase = 0) {
    const r = makeRng(seed);
    const v = [];
    for (let i = 0; i < steps; i++) v.push(r());
    return (u) => {
        const x = (u * steps + phase) % steps;
        const i = Math.floor(x);
        const k = x - i;
        const a = v[i];
        const b = v[(i + 1) % steps];
        const c = (1 - Math.cos(Math.PI * k)) / 2;
        return a + (b - a) * c;
    };
}

/**
 * Фактура ветра лугов: широкий «шёпот» воздуха (150–1200 Гц) с плавной
 * непериодической огибающей (value-noise), плюс «толща» (60–300 Гц) и тихий
 * свист листвы (1.8–3.6 кГц), приходящие только внутри порывов — свист с
 * задержкой, как у настоящего шквала. Возвращает замыкание t → вклад ветра.
 */
function meadowsWind(seed, dur, { gustCount, gustAmp, baseAmp }) {
    const air = bandNoise(seed, 150, 1200, dur);
    const low = bandNoise(seed + 1, 60, 300, dur);
    const hiss = bandNoise(seed + 2, 1800, 3600, dur);
    const n1 = slowNoise(seed + 3, 4);
    const n2 = slowNoise(seed + 4, 9, 3.2);
    const r = makeRng(seed * 31 + 5);
    const gusts = [];
    for (let i = 0; i < gustCount; i++) {
        gusts.push({ at: r() * dur, len: 1.5 + r() * 2.5, amp: (0.4 + r() * 0.6) * gustAmp });
    }
    return (t) => {
        const u = t / dur;
        const base = 0.35 + 0.45 * n1(u) + 0.2 * n2(u);
        let g = 0;
        let hg = 0;
        for (const gu of gusts) {
            const d = t - gu.at + (t < gu.at ? dur : 0); // зациклить порывы
            if (d >= 0 && d < gu.len) {
                g += gu.amp * Math.pow(Math.sin(Math.PI * (d / gu.len)), 1.4);
                const d2 = d - 0.5; // свист догоняет порыв
                if (d2 > 0) hg += gu.amp * Math.pow(Math.sin(Math.PI * Math.min(1, d2 / (gu.len - 0.5))), 1.4);
            }
        }
        return air() * base * baseAmp + low() * g * 0.5 + hiss() * hg * 0.06;
    };
}

/** Шуршание сухой травы: короткие всплески широкой полосы 1.2–4 кГц. */
function grassRustle(seed, dur, count, amp) {
    const rustle = bandNoise(seed, 1200, 4000, dur);
    const r = makeRng(seed * 17 + 3);
    const bursts = [];
    for (let i = 0; i < count; i++) bursts.push({ at: r() * dur, len: 0.08 + r() * 0.2, amp: 0.5 + r() * 0.5 });
    return (t) => {
        let v = 0;
        for (const b of bursts) {
            const d = t - b.at + (t < b.at ? dur : 0);
            if (d >= 0 && d < b.len) v += rustle() * amp * b.amp * decay(d, b.len, 2.5);
        }
        return v;
    };
}

/** Скрип дерева/столба: низкий тон с изгибом частоты, короткий. */
function woodCreak(at, len, f0, amp) {
    return (t) => {
        const d = t - at;
        if (d < 0 || d >= len) return 0;
        // Изгиб частоты: два медленных колебания поверх базовой — «скрип-скрип».
        const f = f0 + 0.35 * f0 * Math.sin((2 * Math.PI * d) / 0.22);
        return Math.sin(2 * Math.PI * f * d) * amp * decay(d, len, 2.2);
    };
}

/**
 * Амбиент лугов: настоящий ветер — широкий воздух с непериодической
 * огибающей, «толща» и свист только в порывах; шуршание сухой травы и редкие
 * события (скрип, далёкий звон Звенца). Луп 12 с — повторы не слышны.
 */
function ambienceMeadows() {
    const dur = 12;
    const wind = meadowsWind(62, dur, { gustCount: 4, gustAmp: 0.5, baseAmp: 0.5 });
    const rustle = grassRustle(63, dur, 6, 0.08);
    const creaks = [woodCreak(2.1, 0.5, 120, 0.06), woodCreak(8.7, 0.6, 90, 0.05)];
    const s = synth(dur, (t) => {
        let v = wind(t) + rustle(t);
        for (const creak of creaks) v += creak(t);
        // Далёкий звон Звенца (111 Гц), один за цикл, глухо — сквозь ветер.
        const db = t - 7.2;
        if (db >= 0 && db < 1.6)
            v += (Math.sin(2 * Math.PI * 111 * db) * 0.04 + Math.sin(2 * Math.PI * 111 * 2.76 * db) * 0.012) * decay(db, 1.6, 3);
        return v;
    });
    return loopify(normalize(s, 0.28));
}

/** Амбиент прудов: ниже, влажнее, с редкой капелью. */
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 ambienceMeadowsNight() {
    const dur = 12;
    const wind = meadowsWind(72, dur, { gustCount: 2, gustAmp: 0.3, baseAmp: 0.32 });
    const rustle = grassRustle(73, dur, 3, 0.05);
    const hum = bandNoise(74, 40, 160, dur);
    const creaks = [woodCreak(5.4, 0.6, 110, 0.04)];
    const s = synth(dur, (t) => {
        let v = wind(t) + rustle(t);
        for (const creak of creaks) v += creak(t);
        // Дальний гул машины: 55 Гц с медленным тремоло, как будто земля дышит.
        v += (Math.sin(2 * Math.PI * 55 * t) * 0.07 + hum() * 0.09) * (0.7 + 0.3 * Math.sin(2 * Math.PI * t / 3));
        // Далёкий колокол: глухой удар, приходящий сквозь ветер.
        const dt = t - 9.8;
        if (dt >= 0 && dt < 1.6)
            v += (Math.sin(2 * Math.PI * 111 * dt) * 0.06 + Math.sin(2 * Math.PI * 111 * 2.76 * dt) * 0.02) * decay(dt, 1.6, 3);
        return v;
    });
    return loopify(normalize(s, 0.26));
}

/** Ночь у прудов: ветер почти стих, вода слышна громче, капли чаще. */
function ambiencePondsNight() {
    const dur = 8;
    const wind = bandNoise(83, 50, 250, dur);
    const lap = bandNoise(84, 200, 900, dur);
    const r = makeRng(19);
    const drops = [];
    for (let i = 0; i < 7; i++) drops.push({ at: r() * dur, f: 700 + r() * 500 });
    const s = synth(dur, (t) => {
        let v = wind() * 0.22 * (0.6 + 0.4 * Math.sin(2 * Math.PI * t / dur + 1));
        // Ночью вода «дышит» ровнее и ближе.
        v += lap() * 0.14 * (0.6 + 0.4 * 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.09 * decay(dt, 0.15, 2);
        }
        return v;
    });
    return loopify(normalize(s, 0.5));
}

/**
 * Ночь в Звенце: ветер ниже, колокол бьёт реже и глуше, под ним — низкий
 * гул башни, не смолкающий и ночью.
 */
function ambienceZvenetsNight() {
    const dur = 8;
    const wind = bandNoise(93, 60, 350, dur);
    const strikes = [
        { at: 2.0, f: 111 },
        { at: 6.0, f: 98 }
    ];
    const s = synth(dur, (t) => {
        let v = wind() * 0.24 * (0.65 + 0.35 * Math.sin(2 * Math.PI * t / dur + 2.5));
        // Башня не спит: тихий двойной гул под всем прочим.
        v += (Math.sin(2 * Math.PI * 55 * t) * 0.08 + Math.sin(2 * Math.PI * 82.5 * t) * 0.05) * (0.6 + 0.4 * Math.sin(2 * Math.PI * t / dur * 2));
        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.2 +
                        Math.sin(2 * Math.PI * s.f * 2.76 * dt) * 0.07 +
                        Math.sin(2 * Math.PI * s.f * 5.4 * dt) * 0.03) *
                    decay(dt, 2.5, 3);
        }
        return v;
    });
    return loopify(normalize(s, 0.55));
}

// ---------- локальные амбиент-слои ----------

/** Слой «зона наката»: низкий шелест пепла + глухой гул — пепел течёт. */
function layerAshflow() {
    const dur = 8;
    const rustle = bandNoise(181, 100, 500, dur);
    const r = makeRng(53);
    const waves = [];
    for (let i = 0; i < 3; i++) waves.push({ at: r() * dur, len: 1.5 + r() });
    const s = synth(dur, (t) => {
        // Гул: два расстроенных низа с медленным биением.
        let v = (Math.sin(2 * Math.PI * 45 * t) * 0.12 + Math.sin(2 * Math.PI * 47.5 * t) * 0.1) * (0.7 + 0.3 * Math.sin(2 * Math.PI * t / dur));
        // Шелест волнами, как будто пепел перекатывается.
        for (const w of waves) {
            const dt = t - w.at + (t < w.at ? dur : 0);
            if (dt >= 0 && dt < w.len) v += rustle() * 0.2 * decay(dt, w.len, 1.2);
        }
        return v;
    });
    return loopify(normalize(s, 0.5));
}

/** Слой «вода прудов»: тихий плеск + редкие капли. */
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));
}

/** Слой «ветер» (глобальный): свистящая высокая полоса с редкими порывами. */
function layerWind() {
    const dur = 8;
    const air = bandNoise(175, 300, 1400, dur);
    const r = makeRng(47);
    const gusts = [];
    for (let i = 0; i < 2; i++) gusts.push({ at: r() * dur, len: 2 + r() });
    const s = synth(dur, (t) => {
        let v = air() * 0.18 * (0.55 + 0.45 * Math.sin(2 * Math.PI * t / dur + 1.5));
        for (const g of gusts) {
            const dt = t - g.at + (t < g.at ? dur : 0); // зациклить порывы
            if (dt >= 0 && dt < g.len) v += air() * 0.22 * decay(dt, g.len, 1.5);
        }
        return v;
    });
    return loopify(normalize(s, 0.5));
}

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

const AMBIENCE = {
    'ambience/meadows': ambienceMeadows,
    'ambience/ponds': ambiencePonds,
    'ambience/zvenets': ambienceZvenets,
    // Ночные варианты: ниже, плотнее, с дальними механизмами (кроссфейд по часам).
    'ambience/meadows_night': ambienceMeadowsNight,
    'ambience/ponds_night': ambiencePondsNight,
    'ambience/zvenets_night': ambienceZvenetsNight,
    'ambience/ponds_water': layerPondsWater,
    'ambience/tower_hum': layerTowerHum,
    'ambience/houses': layerHouses,
    'ambience/wind': layerWind,
    'ambience/ashflow': layerAshflow
};
// Записываем 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)} КБ`);
    }
}