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;
/** Зациклить (кроссфейд краёв; для hum — по умолчанию). */
loop?: boolean;
}
/** Клэмп длительности: снизу — против нулевых буферов, сверху — по kind. */
const clampDur = (dur: number, max: number): number => Math.max(0.05, Math.min(max, dur));
/** Шумовой удар: полоса шума с резким спадом; tone — низкая подложка. */
function renderHit(spec: SoundSpec, dur: number): Float32Array {
const noise = bandNoise(spec.seed ?? 1, spec.low ?? 600, spec.high ?? 3000, dur);
const power = spec.power ?? 2;
const tone = spec.tone ?? 0;
return normalize(
synth(dur, (t) => {
let v = noise() * decay(t, dur, power);
if (tone > 0) v += Math.sin(2 * Math.PI * tone * t) * decay(t, dur, power * 0.8) * 0.4;
return v;
}),
spec.peak ?? 0.5
);
}
/** Скрип/шорох: низкая полоса, медленный спад (ветка/куст/дверь). */
function renderScrape(spec: SoundSpec, dur: number): Float32Array {
const noise = bandNoise(spec.seed ?? 1, spec.low ?? 100, spec.high ?? 900, dur);
const power = spec.power ?? 1.5;
return normalize(synth(dur, (t) => noise() * decay(t, dur, power)), spec.peak ?? 0.5);
}
/** Звон: негармоничные партиалы одной частоты + короткий клик. */
function renderChime(spec: SoundSpec, dur: number): Float32Array {
const f = spec.freq ?? 520;
const power = spec.power ?? 5;
const rng = makeRng((spec.seed ?? 1) + 17);
const clickDur = 0.008;
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;
const click = t < clickDur ? (rng() * 2 - 1) * (1 - t / clickDur) * 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 };