/**
* Музыкальный секвенсор движка: партитура (ноты + инструменты) → сэмплы-луп.
* Адресат — ИИ-агент: описывает партию нотами (MIDI), движок детерминированно
* компилирует её в буфер (AudioManager.playLoopBuffer — стем на шине music).
* Инструменты — осциллятор + огибающая поверх примитивов synth.mjs; сам
* рендер чистый (без ГПСЧ) — детерминизм мелодии обеспечивают сиды
* генератора нот на стороне игры/агента, а не рендера.
*/
import { RATE, decay, loopify, normalize } from '@rpg/engine/tools/synth.mjs';
/** Инструменты: осциллятор + огибающая (набор минимальный, расширять по мере надобности). */
export type InstrumentKind = 'pluck' | 'bell' | 'pad';
/** Нота: старт и длина в долях (beats), высота в MIDI (69 = A4 = 440 Гц). */
export interface NoteEvent {
beat: number;
dur: number;
midi: number;
/** Громкость ноты 0..1 (по умолчанию 0.8). */
vel?: number;
}
/** Партия = трек = стем: инструмент + ноты + громкость трека. */
export interface TrackSpec {
instrument: InstrumentKind;
notes: NoteEvent[];
/** Громкость трека (по умолчанию 1). */
gain?: number;
}
/** Партитура лупа: темп, длина в долях, партии. */
export interface MusicSpec {
/** Темп, долей в минуту. */
bpm: number;
/** Длина лупа в долях (ноты, вылезшие за край, обрежаются). */
beats: number;
tracks: TrackSpec[];
}
const midiHz = (midi: number): number => 440 * Math.pow(2, (midi - 69) / 12);
/** Партиалы [множитель частоты, доля амплитуды] для мелодических инструментов. */
const PLUCK_PARTIALS: [number, number][] = [
[1, 0.6],
[2, 0.25],
[3, 0.15]
];
const BELL_PARTIALS: [number, number][] = [
[1, 0.5],
[1.52, 0.3],
[2.38, 0.2]
];
/** Сумма партиалов с экспоненциальным затуханием — pluck/bell. */
function partialVoice(partials: [number, number][], f: number, ndur: number, power: number): (t: number) => number {
return (t) => {
let v = 0;
for (const [k, amp] of partials) {
v += Math.sin(2 * Math.PI * f * k * t) * amp * decay(t, ndur, power);
}
return v;
};
}
/** Пэд: sine + слегка расстроенная копия, мягкая атака/релиз вместо щелчка. */
function padVoice(f: number, ndur: number): (t: number) => number {
const attack = Math.min(0.3, ndur * 0.25);
const release = Math.min(0.5, ndur * 0.35);
return (t) => {
const env = Math.min(1, t / attack) * Math.min(1, Math.max(0, (ndur - t) / release));
return (Math.sin(2 * Math.PI * f * t) + Math.sin(2 * Math.PI * f * 1.004 * t)) * 0.5 * env;
};
}
/** Голос ноты: (t сек от начала ноты) → значение волны. */
function voice(kind: InstrumentKind, f: number, ndur: number): (t: number) => number {
switch (kind) {
case 'pluck':
return partialVoice(PLUCK_PARTIALS, f, ndur, 3);
case 'bell':
return partialVoice(BELL_PARTIALS, f, ndur, 4);
case 'pad':
return padVoice(f, ndur);
}
}
/**
* Партитура → моно-сэмплы-луп (RATE): ноты всех партий суммируются, луп
* сшивается кроссфейдом и нормализуется к пику 0.8. Одинаковые спеки дают
* одинаковые сэмплы (рендер чистый).
*/
export function renderMusic(spec: MusicSpec): Float32Array {
const spb = 60 / Math.max(1, spec.bpm); // сек на долю
const total = Math.max(1, Math.round(spec.beats * spb * RATE));
const out = new Float32Array(total);
for (const track of spec.tracks) {
const gain = track.gain ?? 1;
for (const n of track.notes) {
const start = Math.round(n.beat * spb * RATE);
const len = Math.max(1, Math.round(n.dur * spb * RATE));
const wave = voice(track.instrument, midiHz(n.midi), len / RATE);
const vel = n.vel ?? 0.8;
for (let i = 0; i < len && start + i < total; i++) {
out[start + i] += wave(i / RATE) * vel * gain;
}
}
}
return loopify(normalize(out, 0.8), Math.min(0.5, (total / RATE) * 0.25));
}