Newer
Older
rpg / packages / engine / src / audio / Music.ts
/**
 * Музыкальный секвенсор движка: партитура (ноты + инструменты) → сэмплы-луп.
 * Адресат — ИИ-агент: описывает партию нотами (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));
}