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 };