Newer
Older
rpg / packages / engine / src / audio / SoundSpec.ts
import {
    RATE,
    bandNoise,
    bellPartial,
    decay,
    loopify,
    makeRng,
    normalize,
    synth
} from '@rpg/engine/tools/synth.mjs';

/**
 * Спек-синтез: детерминированная компиляция описания звука в сэмплы.
 * Адресат — ИИ-агент (движок агент-первый): описывает звук параметрами,
 * без слуха и без библиотек сэмплов; движок компилирует спек в буфер
 * (AudioManager.playSpec), факт запуска наблюдаем через шпион onPlayed.
 * Формулы — те же примитивы, что у файлового генератора игры
 * (tools/synth.mjs), поэтому спек-звук и WAV-звук звучат в одной палитре.
 */

export type SoundKind = 'hit' | 'chime' | 'scrape' | 'hum';

export interface SoundSpec {
    kind: SoundKind;
    /** Длительность, сек (0.05..4; hum — до 8). */
    dur: number;
    /** Полоса шума, Гц (hit/scrape; дефолты по kind). */
    low?: number;
    high?: number;
    /** Низкий тон, Гц: подложка удара/шороха или основной тон гула. */
    tone?: number;
    /** Частота звона, Гц (chime; дефолт 520). */
    freq?: number;
    /** Крутизна затухания (дефолты по kind). */
    power?: number;
    /** Сид шума/клика — детерминизм (дефолт 1). */
    seed?: number;
    /** Пик нормализации 0..1 (дефолт 0.5). */
    peak?: number;
    /** Огибающая «0 → 1 → 0» вместо спада с самого громкого старта (воздух, свуш). */
    swell?: boolean;
    /** Зациклить (кроссфейд краёв; для hum — по умолчанию). */
    loop?: boolean;
}

/** Клэмп длительности: снизу — против нулевых буферов, сверху — по kind. */
const clampDur = (dur: number, max: number): number => Math.max(0.05, Math.min(max, dur));

/** Огибающая: спад с самого громкого старта или «0 → 1 → 0» (swell). */
function envelope(spec: SoundSpec, dur: number, power: number): (t: number) => number {
    return spec.swell
        ? (t) => Math.sin(Math.PI * Math.min(1, t / dur))
        : (t) => decay(t, dur, power);
}

/** Шумовой удар: полоса шума с резким спадом; tone — низкая подложка. */
function renderHit(spec: SoundSpec, dur: number): Float32Array {
    const noise = bandNoise(spec.seed ?? 1, spec.low ?? 600, spec.high ?? 3000, dur);
    const env = envelope(spec, dur, spec.power ?? 2);
    const tone = spec.tone ?? 0;
    return normalize(
        synth(dur, (t) => {
            let v = noise() * env(t);
            if (tone > 0) v += Math.sin(2 * Math.PI * tone * t) * decay(t, dur, (spec.power ?? 2) * 0.8) * 0.4;
            return v;
        }),
        spec.peak ?? 0.5
    );
}

/** Скрип/шорох: низкая полоса, медленный спад (ветка/куст/дверь); swell — воздух. */
function renderScrape(spec: SoundSpec, dur: number): Float32Array {
    const noise = bandNoise(spec.seed ?? 1, spec.low ?? 100, spec.high ?? 900, dur);
    const env = envelope(spec, dur, spec.power ?? 1.5);
    return normalize(synth(dur, (t) => noise() * env(t)), spec.peak ?? 0.5);
}

/** Звон: негармоничные партиалы одной частоты + короткий клик. */
function renderChime(spec: SoundSpec, dur: number): Float32Array {
    const f = spec.freq ?? 520;
    const power = spec.power ?? 5;
    // Клик — спад с одной случайной амплитудой (как у файлового генератора:
    // формулы совпадают дословно — мигрированные звуки звучат один в один).
    const clickAmp = makeRng(spec.seed ?? 1)() * 2 - 1;
    return normalize(
        synth(dur, (t) => {
            const body =
                bellPartial(f, 1.0, dur, power)(t) * 0.5 +
                bellPartial(f, 1.52, dur, power + 1)(t) * 0.3 +
                bellPartial(f, 2.38, dur * 0.6, power + 2)(t) * 0.2 +
                bellPartial(f, 3.91, dur * 0.4, power + 3)(t) * 0.12;
            const click = t < 0.008 ? clickAmp * (1 - t / 0.008) * 0.4 : 0;
            return body * 0.8 + click;
        }),
        spec.peak ?? 0.5
    );
}

/** Гул: два тона с биением (tone и tone×1.5) на «дыхании», луп по умолчанию. */
function renderHum(spec: SoundSpec, dur: number): Float32Array {
    const tone = spec.tone ?? 55;
    const breath = spec.power ?? 2; // число периодов «дыхания» на луп
    return normalize(
        synth(dur, (t) => {
            const swell = 0.6 + 0.4 * Math.sin(2 * Math.PI * (t / dur) * breath + 0.5);
            const body =
                Math.sin(2 * Math.PI * tone * t) * 0.5 +
                Math.sin(2 * Math.PI * tone * 1.5 * t) * 0.3;
            return body * swell * 0.35;
        }),
        spec.peak ?? 0.5
    );
}

/**
 * Спек → сэмплы (моно, RATE). Одинаковые спеки дают одинаковые сэмплы
 * (детерминизм: сиды фикс, ГПСЧ детерминирован).
 */
export function renderSpec(spec: SoundSpec): Float32Array {
    const dur = clampDur(spec.dur, spec.kind === 'hum' ? 8 : 4);
    const samples =
        spec.kind === 'hit'
            ? renderHit(spec, dur)
            : spec.kind === 'chime'
              ? renderChime(spec, dur)
              : spec.kind === 'scrape'
                ? renderScrape(spec, dur)
                : renderHum(spec, dur);
    if (spec.loop ?? spec.kind === 'hum') {
        return loopify(samples, Math.min(0.5, dur / 4));
    }
    return samples;
}

export { RATE };