Newer
Older
rpg / apps / game / src / data / music.ts
import { createRng, type MusicSpec, type NoteEvent } from '@rpg/engine';

/**
 * Музыкальные темы: правило-базовый генератор (детерминированный, сиды) +
 * компиляция движковым секвенсором (renderMusicStereo → playLoopBuffer) прямо
 * в рантайме — WAV-файлов для музыки нет. Тема лугов — пепельная
 * ля-минорная пентатоника: пэд-дрон + редкая плак-мелодия восьмыми.
 */

/** Ля-минорная пентатоника от A3 (A C D E G + октава). */
const PENTATONIC = [57, 60, 62, 64, 67, 69, 72] as const;

/** Дрон-пэд: тоника/субмедианта по половинам лупа. */
function drone(from: number, to: number, beats: number): NoteEvent[] {
    return [
        { beat: 0, dur: beats / 2, midi: from, vel: 0.5 },
        { beat: beats / 2, dur: beats / 2, midi: to, vel: 0.5 }
    ];
}

/** Мелодия восьмыми с паузами (вероятность ноты 0.55) — сид фиксирует рисунок. */
function melody(seed: number, beats: number): NoteEvent[] {
    const rng = createRng(seed);
    const notes: NoteEvent[] = [];
    for (let eighth = 0; eighth < beats * 2; eighth++) {
        if (!rng.chance(0.55)) continue;
        notes.push({
            beat: eighth * 0.5,
            dur: 0.5,
            midi: PENTATONIC[Math.floor(rng.next() * PENTATONIC.length)]!,
            vel: 0.35 + rng.next() * 0.25
        });
    }
    return notes;
}

/** Тема лугов: 16 долей на 70 bpm, дрон A2→G2 + плак по пентатонике (стерео). */
export const MEADOW_THEME: MusicSpec = {
    bpm: 70,
    beats: 16,
    tracks: [
        { instrument: 'pad', notes: drone(45, 43, 16), gain: 0.9, pan: -0.3 },
        { instrument: 'pluck', notes: melody(20260907, 16), pan: 0.3 }
    ]
};

/** Ритмичный рифф боя: восьмые с пропусками, низкая пентатоника. */
function riff(seed: number, beats: number): NoteEvent[] {
    const rng = createRng(seed);
    const low = [45, 48, 50, 52, 55] as const; // A2-минорная низь
    const notes: NoteEvent[] = [];
    for (let eighth = 0; eighth < beats * 2; eighth++) {
        if (!rng.chance(0.7)) continue;
        notes.push({
            beat: eighth * 0.5,
            dur: 0.5,
            midi: low[Math.floor(rng.next() * low.length)]!,
            vel: 0.5 + rng.next() * 0.3
        });
    }
    return notes;
}

/** Редкие высокие «тревожные» ноты колокола для tension-стема. */
function sparseBells(seed: number, beats: number): NoteEvent[] {
    const rng = createRng(seed);
    const notes: NoteEvent[] = [];
    for (let beat = 0; beat < beats; beat++) {
        if (!rng.chance(0.15)) continue;
        notes.push({ beat, dur: 2, midi: rng.chance(0.5) ? 81 : 84, vel: 0.4 + rng.next() * 0.2 });
    }
    return notes;
}

/** Тема боя: 112 bpm, глухой дрон A1 + настойчивый низкий рифф. */
export const COMBAT_THEME: MusicSpec = {
    bpm: 112,
    beats: 8,
    tracks: [
        { instrument: 'pad', notes: drone(33, 33, 8), gain: 1, pan: 0 },
        { instrument: 'pluck', notes: riff(20260908, 8), pan: -0.2 }
    ]
};

/**
 * Tension-стем: тихий луп «настороженности» (враг wary) — низкий дрон
 * A1 + редкие высокие колокола. Играется на шине music ПОД темой области,
 * громкость ведёт threat-тик (см. LocationScene.updateMusic).
 */
export const TENSION_STEM: MusicSpec = {
    bpm: 70,
    beats: 16,
    tracks: [
        { instrument: 'pad', notes: [{ beat: 0, dur: 16, midi: 33, vel: 0.7 }], gain: 0.9, pan: 0.2 },
        { instrument: 'bell', notes: sparseBells(20260909, 16), gain: 0.5, pan: -0.4 }
    ]
};

/** Реестр тем (ключ → спек); область ссылается на тему полем `theme`. */
export const THEMES: Record<string, MusicSpec> = {
    meadows: MEADOW_THEME,
    combat: COMBAT_THEME
};