This commit is contained in:
2026-07-04 16:57:40 -07:00
parent 16e396841e
commit c6b7890212
47 changed files with 1713 additions and 209 deletions
@@ -0,0 +1,285 @@
using ProjectM.Simulation;
using Unity.Entities;
using Unity.NetCode;
using UnityEngine;
namespace ProjectM.Client
{
/// <summary>
/// Client-only PROCEDURAL MUSIC — four synchronized loop layers (bass / chord pad / arpeggio / percussion
/// pulse), all generated over the SAME 4-bar buffer (100 BPM, AmFCG) so they stay phase-locked forever:
/// every AudioSource starts the same frame with loop=true and identical clip length, and per-bar amplitude
/// envelopes reach ~zero at each bar boundary so both the chord changes and the loop point are click-free
/// (the <c>AmbientAudioSystem.Snap</c> trick generalized to enveloped segments). The MIX is the state
/// machine: layer volumes ease toward targets chosen from replicated state only — <see cref="RunInfo"/>
/// lifecycle (staging / combat / boss / reward lull), <see cref="CycleState"/> siege, and the terminal
/// <see cref="RunOutcome"/> (one-shot victory/defeat sting + aftermath bed). Observe-only presentation
/// system: no sim writes, no determinism surface; asset-free per the project convention. Sits under SFX at
/// <see cref="MasterVolume"/> × <see cref="GameVolume.Music"/>; the low <c>AmbientAudioSystem</c> drone
/// (vol 0.10) remains as texture beneath it.
/// </summary>
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
[UpdateInGroup(typeof(PresentationSystemGroup))]
public partial class MusicSystem : SystemBase
{
const float MasterVolume = 0.45f;
const float FadePerSecond = 0.30f;
// ---- shared musical grid: 100 BPM, 4/4, one chord per bar, 4 bars ----
const int SampleRate = 44100;
const float BarSeconds = 2.4f; // 4 beats @ 0.6 s
const int Bars = 4;
const float LoopSeconds = BarSeconds * Bars; // 9.6 s
// Am F C G (natural-minor loop; index = bar): root/third/fifth per chord.
static readonly float[][] ChordHz =
{
new[] { 110.00f, 130.81f, 164.81f }, // Am: A2 C3 E3
new[] { 87.31f, 110.00f, 130.81f }, // F: F2 A2 C3
new[] { 130.81f, 164.81f, 196.00f }, // C: C3 E3 G3
new[] { 98.00f, 123.47f, 146.83f }, // G: G2 B2 D3
};
GameObject _root;
AudioSource _bass, _pad, _arp, _pulse;
float _vBass, _vPad, _vArp, _vPulse; // current smoothed volumes (pre-master)
byte _outcomePlayed; // which terminal outcome's sting has fired (0 = none)
protected override void OnStartRunning()
{
if (_root != null) return;
_root = new GameObject("~Music");
_bass = MakeSource(BuildBassLoop());
_pad = MakeSource(BuildPadLoop());
_arp = MakeSource(BuildArpLoop());
_pulse = MakeSource(BuildPulseLoop());
// Same frame, same length, loop=true -> phase-locked for the whole session.
_bass.Play(); _pad.Play(); _arp.Play(); _pulse.Play();
}
protected override void OnDestroy()
{
if (_root != null) Object.Destroy(_root);
}
AudioSource MakeSource(AudioClip clip)
{
var src = _root.AddComponent<AudioSource>();
src.clip = clip;
src.loop = true;
src.playOnAwake = false;
src.spatialBlend = 0f;
src.volume = 0f;
return src;
}
protected override void OnUpdate()
{
if (_bass == null) return;
// ---- pick the mix from replicated state (defaults = quiet staging bed) ----
float tBass = 0.50f, tPad = 0.55f, tArp = 0.12f, tPulse = 0f;
bool haveRun = SystemAPI.TryGetSingleton<RunInfo>(out var run);
SystemAPI.TryGetSingleton<CycleState>(out var cyc);
bool siege = cyc.Phase == CyclePhase.Siege;
bool finalSiege = siege && SystemAPI.TryGetSingleton<GoalProgress>(out var goal)
&& goal.Target > 0 && goal.Charge >= goal.Target;
byte outcome = SystemAPI.TryGetSingleton<RunOutcome>(out var oc) ? oc.Value : RunOutcomeId.InProgress;
if (outcome != RunOutcomeId.InProgress)
{
if (_outcomePlayed != outcome)
{
_outcomePlayed = outcome;
PlayOutcomeSting(outcome == RunOutcomeId.Victory);
}
tBass = 0.20f; tPad = 0.45f; tArp = 0f; tPulse = 0f; // aftermath bed under the banner
}
else if (siege)
{
tBass = 0.70f; tPad = 0.40f; tArp = 0.60f; tPulse = finalSiege ? 0.90f : 0.65f;
}
else if (haveRun)
{
switch (run.Lifecycle)
{
case RunLifecycle.Launching:
tBass = 0.55f; tPad = 0.50f; tArp = 0.35f; tPulse = 0.20f; // anticipation swell
break;
case RunLifecycle.InRoom:
bool boss = run.CurrentRoomType == RoomTypeId.Boss;
tBass = boss ? 0.75f : 0.65f;
tPad = 0.40f;
tArp = boss ? 0.80f : 0.65f;
tPulse = boss ? 0.75f : 0.35f;
break;
case RunLifecycle.RoomReward:
case RunLifecycle.RouteSelect:
tBass = 0.45f; tPad = 0.55f; tArp = 0.25f; tPulse = 0.08f; // between-rooms lull
break;
case RunLifecycle.Returning:
tBass = 0.45f; tPad = 0.60f; tArp = 0.15f; tPulse = 0f; // resolution
break;
// Staging keeps the defaults.
}
}
float dt = SystemAPI.Time.DeltaTime * FadePerSecond;
_vBass = Mathf.MoveTowards(_vBass, tBass, dt);
_vPad = Mathf.MoveTowards(_vPad, tPad, dt);
_vArp = Mathf.MoveTowards(_vArp, tArp, dt);
_vPulse = Mathf.MoveTowards(_vPulse, tPulse, dt);
float master = MasterVolume * GameVolume.Music;
_bass.volume = _vBass * master;
_pad.volume = _vPad * master;
_arp.volume = _vArp * master;
_pulse.volume = _vPulse * master;
}
void PlayOutcomeSting(bool victory)
{
// Victory: rising A-minor->major-feel arpeggio; defeat: falling minor third crawl.
float[] notes = victory
? new[] { 220f, 277.18f, 329.63f, 440f } // A3 C#4 E4 A4 (picardy lift)
: new[] { 220f, 207.65f, 174.61f, 146.83f }; // A3 Ab3 F3 D3
var clip = BuildStingClip(notes, victory ? 0.16f : 0.28f, victory ? 0.45f : 0.40f);
if (_pad != null) _pad.PlayOneShot(clip, 0.9f * GameVolume.Music);
}
// ================= clip builders (deterministic, asset-free) =================
static AudioClip NewLoopClip(string name, out float[] data)
{
int len = (int)(LoopSeconds * SampleRate);
data = new float[len];
return AudioClip.Create(name, len, 1, SampleRate, false);
}
/// <summary>Bar-local envelope that is ~0 at both bar edges (click-free chord changes + loop point).</summary>
static float BarEnv(float tInBar, float attack, float release)
{
float up = Mathf.Clamp01(tInBar / attack);
float down = Mathf.Clamp01((BarSeconds - tInBar) / release);
return up * up * down;
}
static AudioClip BuildBassLoop()
{
var clip = NewLoopClip("music_bass", out var data);
for (int i = 0; i < data.Length; i++)
{
float t = i / (float)SampleRate;
int bar = Mathf.Min(Bars - 1, (int)(t / BarSeconds));
float tb = t - bar * BarSeconds;
float f = ChordHz[bar][0] * 0.5f; // root, an octave down
float s = Mathf.Sin(2f * Mathf.PI * f * t) * 0.8f
+ Mathf.Sin(2f * Mathf.PI * f * 2f * t) * 0.2f;
data[i] = s * BarEnv(tb, 0.03f, 0.12f) * 0.50f;
}
clip.SetData(data, 0);
return clip;
}
static AudioClip BuildPadLoop()
{
var clip = NewLoopClip("music_pad", out var data);
for (int i = 0; i < data.Length; i++)
{
float t = i / (float)SampleRate;
int bar = Mathf.Min(Bars - 1, (int)(t / BarSeconds));
float tb = t - bar * BarSeconds;
var chord = ChordHz[bar];
float s = 0f;
for (int n = 0; n < chord.Length; n++)
s += Mathf.Sin(2f * Mathf.PI * chord[n] * t + n * 1.7f);
float trem = 0.85f + 0.15f * Mathf.Sin(2f * Mathf.PI * 0.8f * t);
data[i] = s / 3f * BarEnv(tb, 0.45f, 0.50f) * trem * 0.34f;
}
clip.SetData(data, 0);
return clip;
}
static AudioClip BuildArpLoop()
{
var clip = NewLoopClip("music_arp", out var data);
const float noteLen = BarSeconds / 8f; // 8th notes
for (int i = 0; i < data.Length; i++)
{
float t = i / (float)SampleRate;
int bar = Mathf.Min(Bars - 1, (int)(t / BarSeconds));
float tb = t - bar * BarSeconds;
int step = Mathf.Min(7, (int)(tb / noteLen));
float tn = tb - step * noteLen;
var chord = ChordHz[bar];
// up-and-over pattern: root, fifth, octave, tenth, back down
float f = step switch
{
0 => chord[0] * 2f,
1 => chord[2] * 2f,
2 => chord[0] * 4f,
3 => chord[1] * 4f,
4 => chord[2] * 4f,
5 => chord[0] * 4f,
6 => chord[2] * 2f,
_ => chord[1] * 2f,
};
float env = Mathf.Exp(-9f * tn);
data[i] = Mathf.Sin(2f * Mathf.PI * f * tn) * env * 0.32f;
}
clip.SetData(data, 0);
return clip;
}
static AudioClip BuildPulseLoop()
{
var clip = NewLoopClip("music_pulse", out var data);
const float beat = BarSeconds / 4f;
uint rng = 0x5EED5EEDu; // deterministic hat noise
for (int i = 0; i < data.Length; i++)
{
float t = i / (float)SampleRate;
float tBeat = t % beat;
// Low thump on every beat: 55->38 Hz sweep, tight decay.
float thump = 0f;
if (tBeat < 0.14f)
{
float f = Mathf.Lerp(55f, 38f, tBeat / 0.14f);
thump = Mathf.Sin(2f * Mathf.PI * f * tBeat) * Mathf.Exp(-22f * tBeat) * 0.85f;
}
// Off-beat hat: a 30 ms noise tick halfway through each beat.
float tHat = (t + beat * 0.5f) % beat;
float hat = 0f;
if (tHat < 0.03f)
{
rng ^= rng << 13; rng ^= rng >> 17; rng ^= rng << 5; // xorshift
float n = (rng & 0xFFFF) / 32768f - 1f;
hat = n * Mathf.Exp(-120f * tHat) * 0.22f;
}
data[i] = thump + hat;
}
clip.SetData(data, 0);
return clip;
}
static AudioClip BuildStingClip(float[] notes, float noteLen, float vol)
{
int len = (int)(notes.Length * noteLen * SampleRate) + SampleRate / 2; // + half-second tail
var clip = AudioClip.Create("music_sting", len, 1, SampleRate, false);
var data = new float[len];
for (int n = 0; n < notes.Length; n++)
{
int start = (int)(n * noteLen * SampleRate);
int dur = (int)(SampleRate * (noteLen + (n == notes.Length - 1 ? 0.5f : 0.05f)));
for (int i = 0; i < dur && start + i < len; i++)
{
float tn = i / (float)SampleRate;
data[start + i] += Mathf.Sin(2f * Mathf.PI * notes[n] * tn) * Mathf.Exp(-4.5f * tn) * vol;
}
}
clip.SetData(data, 0);
return clip;
}
}
}