Newer
Older
rpg / packages / engine / tools / wav.mjs
/**
 * WAV-тулкит без зависимостей: энкодер и примитивы синтеза (детерминированный
 * шум, полосовой фильтр, огибающие, нормализация, кроссфейд-луп).
 * Формат: моно, RATE Гц, 16-бит PCM.
 */
import { writeFileSync } from 'node:fs';

export const RATE = 22050;

/** Собрать WAV-файл из Float32 сэмплов (-1..1). */
export function encodeWav(samples, rate = RATE) {
    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);
}

/** Записать WAV на диск (хелпер типового пайплайна «синтез -> файл»). */
export function writeWav(path, samples, rate = RATE) {
    writeFileSync(path, encodeWav(samples, rate));
}

// ---------- примитивы ----------

/** Детерминированный шум. */
export 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) -> сэмпл. */
export function synth(seconds, fn, rate = RATE) {
    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 к концу. */
export function decay(t, dur, power = 4) {
    return Math.pow(Math.max(0, 1 - t / dur), power);
}

/** Колокольный партиал: негармоничный (частота * растяжка), быстрый спад. */
export function bellPartial(freq, stretch, dur, power) {
    return (t) => Math.sin(2 * Math.PI * freq * stretch * t) * decay(t, dur, power);
}

/** Полосовой шум через однополюсные фильтры (низ + верх). */
export 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));
    };
}

/** Нормализовать по пику. */
export 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-секунд с концом. */
export 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);
}