/**
* Музыкальный секвенсор движка: партитура (ноты + инструменты) → сэмплы-луп.
* Адресат — ИИ-агент: описывает партию нотами (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;
/** Панорама -1..1 (только в стерео-рендере; по умолчанию 0 — центр). */
pan?: 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);
}
}
/**
* Суммирование нот всех партий в два канала. Моно-рендер кладёт всё в левый
* канал с весами 1 (pan не слышен); стерео — раскладывает по equal-power
* панораме трека.
*/
function mixTracks(
spec: MusicSpec,
total: number,
stereo: boolean
): [Float32Array, Float32Array] {
const spb = 60 / Math.max(1, spec.bpm); // сек на долю
const left = new Float32Array(total);
const right = new Float32Array(total);
for (const track of spec.tracks) {
const gain = track.gain ?? 1;
// Equal-power панорама: 0 → центр (обе 0.707), -1 → лево, 1 → право.
const a = ((Math.max(-1, Math.min(1, track.pan ?? 0)) + 1) * Math.PI) / 4;
const gl = stereo ? Math.cos(a) : 1;
const gr = stereo ? Math.sin(a) : 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) * gain;
for (let i = 0; i < len && start + i < total; i++) {
const v = wave(i / RATE) * vel;
left[start + i] += v * gl;
right[start + i] += v * gr;
}
}
}
return [left, right];
}
/** Нормализация к пику 0.8 по общему пику каналов + кроссфейд краёв лупа. */
function finalizeLoop(left: Float32Array, right: Float32Array): void {
let peakMax = 0;
for (let i = 0; i < left.length; i++)
peakMax = Math.max(peakMax, Math.abs(left[i]!), Math.abs(right[i]!));
const k = peakMax > 0 ? 0.8 / peakMax : 0;
if (k !== 1) {
for (let i = 0; i < left.length; i++) {
left[i] = left[i]! * k;
right[i] = right[i]! * k;
}
}
const fade = Math.min(0.5, (left.length / RATE) * 0.25);
loopify(left, fade);
loopify(right, fade);
}
/**
* Партитура → моно-сэмплы-луп (RATE): ноты всех партий суммируются, луп
* сшивается кроссфейдом и нормализуется к пику 0.8. Одинаковые спеки дают
* одинаковые сэмплы (рендер чистый); pan треков не слышен.
*/
export function renderMusic(spec: MusicSpec): Float32Array {
const total = Math.max(1, Math.round(spec.beats * (60 / Math.max(1, spec.bpm)) * RATE));
const [left] = mixTracks(spec, total, false);
return loopify(normalize(left, 0.8), Math.min(0.5, (total / RATE) * 0.25));
}
/**
* Партитура → стерео-сэмплы-луп (RATE, interleaved L,R): треки раскладываются
* по панораме `pan` (equal-power), нормализация к пику 0.8 — по общему пику
* каналов (баланс не искажается), кроссфейд краёв синхронный. Проигрывается
* как обычный AudioBuffer: `createBuffer(data, RATE, 2)`.
*/
export function renderMusicStereo(spec: MusicSpec): Float32Array {
const total = Math.max(1, Math.round(spec.beats * (60 / Math.max(1, spec.bpm)) * RATE));
const [left, right] = mixTracks(spec, total, true);
finalizeLoop(left, right);
const out = new Float32Array(total * 2);
for (let i = 0; i < total; i++) {
out[i * 2] = left[i]!;
out[i * 2 + 1] = right[i]!;
}
return out;
}