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' | 'tone';
/**
* Дополнительный голос смеси: шум (low/high) или тон (freq) со своей
* огибающей. Параметры не заданы — наследуются от спека.
*/
export interface SpecVoice {
/** Тон голоса, Гц (чистый синус; вместе с delay — «блип» в конце). */
freq?: number;
/** Полоса шума, Гц (шумовой голос). */
low?: number;
high?: number;
/** Вес в смеси (дефолт 1). */
w?: number;
/** Своя крутизна затухания (по умолчанию — power спека). */
power?: number;
/** Своя длительность, сек (по умолчанию — dur спека). */
dur?: number;
/** Нарастание, сек (min(1, t/attack) от старта голоса). */
attack?: number;
/** Задержка старта, сек — до неё голос молчит. */
delay?: number;
/** Свой сид шума (по умолчанию — seed спека). */
seed?: number;
}
export interface SoundSpec {
kind: SoundKind;
/** Длительность, сек (0.05..4; hum — до 8). */
dur: number;
/** Полоса шума, Гц (hit/scrape; дефолты по kind). */
low?: number;
high?: number;
/** Низкий тон, Гц: подложка удара/шороха или основной тон гула. */
tone?: number;
/** Частота тона/звона, Гц (tone/chime; дефолты по kind). */
freq?: number;
/** Конечная частота линейного свипа (tone): f = freq + (freqTo−freq)·t/dur. */
freqTo?: number;
/** Вес основного тела в смеси (дефолт 1). */
w?: number;
/** Крутизна затухания (дефолты по kind). */
power?: number;
/** Нарастание основного тела, сек (min(1, t/attack)) — шипение, скрип. */
attack?: number;
/** Линейный спад амплитуды к этому множителю к концу (1 → fadeTo). */
fadeTo?: number;
/** Тремоло, Гц: множитель wobbleBase + wobbleDepth·sin(2π·wobbleHz·t + фаза). */
wobbleHz?: number;
wobbleDepth?: number;
/** База тремоло (дефолт 1 — амплитуда гуляет вокруг единицы). */
wobbleBase?: number;
/** Фаза тремоло, рад (дефолт 0). */
wobblePhase?: number;
/** Дополнительные голоса поверх основного тела (см. SpecVoice). */
layers?: SpecVoice[];
/** Сид шума/клика — детерминизм (дефолт 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);
}
/** Нарастание: линейное от старта до полной громкости за attack секунд. */
const attackEnv = (t: number, attack: number | undefined): number =>
attack ? Math.min(1, t / attack) : 1;
/**
* Микс: основное тело + доп. голоса (layers), поверх — тремоло и линейный
* спад. Без модификаторов совпадает с normalize(synth(dur, base), peak)
* бит-в-бит (множители не применяются вовсе).
*/
function renderMix(spec: SoundSpec, dur: number, base: (t: number) => number): Float32Array {
const voices = (spec.layers ?? []).map((l) => makeVoice(l, spec, dur));
const wobbleHz = spec.wobbleHz;
const wobbleAmp = spec.wobbleDepth ?? 0;
const wobbleBase = spec.wobbleBase ?? 1;
const wobblePhase = spec.wobblePhase ?? 0;
const fadeTo = spec.fadeTo;
return normalize(
synth(dur, (t) => {
let v = base(t);
for (const voice of voices) v += voice(t);
if (wobbleHz !== undefined)
v *= wobbleBase + wobbleAmp * Math.sin(2 * Math.PI * wobbleHz * t + wobblePhase);
if (fadeTo !== undefined) v *= 1 + (fadeTo - 1) * (t / dur);
return v;
}),
spec.peak ?? 0.5
);
}
/** Голос слоя: шум (low/high) или тон (freq), своя огибающая и задержка. */
function makeVoice(l: SpecVoice, spec: SoundSpec, dur: number): (t: number) => number {
const vd = clampDur(l.dur ?? dur, 8);
const delay = l.delay ?? 0;
const w = l.w ?? 1;
const power = l.power ?? spec.power ?? 2;
if (l.low !== undefined || l.high !== undefined) {
const noise = bandNoise(l.seed ?? spec.seed ?? 1, l.low ?? 100, l.high ?? 900, vd);
return (t) => {
const lt = t - delay;
if (lt < 0) return 0;
return noise() * w * attackEnv(lt, l.attack) * decay(lt, vd, power);
};
}
const f = l.freq ?? 440;
return (t) => {
const lt = t - delay;
if (lt < 0) return 0;
return Math.sin(2 * Math.PI * f * lt) * w * attackEnv(lt, l.attack) * decay(lt, vd, power);
};
}
/** Шумовой удар: полоса шума с резким спадом; tone — низкая подложка. */
function hitBase(spec: SoundSpec, dur: number, env: (t: number) => number): (t: number) => number {
const noise = bandNoise(spec.seed ?? 1, spec.low ?? 600, spec.high ?? 3000, dur);
const tone = spec.tone ?? 0;
const w = spec.w ?? 1;
return (t) => {
let v = noise() * env(t) * w;
if (tone > 0) v += Math.sin(2 * Math.PI * tone * t) * decay(t, dur, (spec.power ?? 2) * 0.8) * 0.4;
return v;
};
}
/** Скрип/шорох: низкая полоса, медленный спад; swell — воздух; attack — нарастание. */
function scrapeBase(spec: SoundSpec, dur: number, env: (t: number) => number): (t: number) => number {
const noise = bandNoise(spec.seed ?? 1, spec.low ?? 100, spec.high ?? 900, dur);
const w = spec.w ?? 1;
const attack = spec.attack;
return (t) => noise() * w * attackEnv(t, attack) * env(t);
}
/** Чистый тон: синус freq (со свипом к freqTo), огибающая decay/swell. */
function toneBase(spec: SoundSpec, dur: number, env: (t: number) => number): (t: number) => number {
const f0 = spec.freq ?? 440;
const f1 = spec.freqTo;
const w = spec.w ?? 1;
const attack = spec.attack;
return (t) => {
const f = f1 === undefined ? f0 : f0 + (f1 - f0) * (t / dur);
return Math.sin(2 * Math.PI * f * t) * w * attackEnv(t, attack) * env(t);
};
}
/** Звон: негармоничные партиалы одной частоты + короткий клик. */
function chimeBase(spec: SoundSpec, dur: number): (t: number) => number {
const f = spec.freq ?? 520;
const power = spec.power ?? 5;
// Клик — спад с одной случайной амплитудой (как у файлового генератора:
// формулы совпадают дословно — мигрированные звуки звучат один в один).
const clickAmp = makeRng(spec.seed ?? 1)() * 2 - 1;
return (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;
};
}
/** Гул: два тона с биением (tone и tone×1.5) на «дыхании», луп по умолчанию. */
function humBase(spec: SoundSpec, dur: number): (t: number) => number {
const tone = spec.tone ?? 55;
const breath = spec.power ?? 2; // число периодов «дыхания» на луп
return (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;
};
}
/**
* Спек → сэмплы (моно, RATE). Одинаковые спеки дают одинаковые сэмплы
* (детерминизм: сиды фикс, ГПСЧ детерминирован).
*/
export function renderSpec(spec: SoundSpec): Float32Array {
const dur = clampDur(spec.dur, spec.kind === 'hum' ? 8 : 4);
const env = envelope(spec, dur, spec.power ?? 2);
const base =
spec.kind === 'hit'
? hitBase(spec, dur, env)
: spec.kind === 'chime'
? chimeBase(spec, dur)
: spec.kind === 'scrape'
? scrapeBase(spec, dur, env)
: spec.kind === 'tone'
? toneBase(spec, dur, env)
: humBase(spec, dur);
const samples = renderMix(spec, dur, base);
if (spec.loop ?? spec.kind === 'hum') {
return loopify(samples, Math.min(0.5, dur / 4));
}
return samples;
}
export { RATE };