using ProjectM.Simulation; using Unity.Entities; using Unity.NetCode; using UnityEngine; namespace ProjectM.Client { /// /// Client-only PROCEDURAL MUSIC — four synchronized loop layers (bass / chord pad / arpeggio / percussion /// pulse), all generated over the SAME 4-bar buffer (100 BPM, Am–F–C–G) 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 AmbientAudioSystem.Snap trick generalized to enveloped segments). The MIX is the state /// machine: layer volumes ease toward targets chosen from replicated state only — the /// lifecycle (staging / launch / combat / boss / reward lull / return). Observe-only presentation /// system: no sim writes, no determinism surface; asset-free per the project convention. Sits under SFX at /// × ; the low AmbientAudioSystem drone /// (vol 0.10) remains as texture beneath it. /// [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) 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(); 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; // 2026-08-07 audit purge: the mix used to key off RunInfo.Lifecycle (staging / launching / in-room / // boss / returning). With the run FSM deleted the bed holds its defaults; re-key it off LANTERN's // descent state when Phase 2 lands. 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; } // ================= 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); } /// Bar-local envelope that is ~0 at both bar edges (click-free chord changes + loop point). 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; } } }