diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..2b9df0e --- /dev/null +++ b/.editorconfig @@ -0,0 +1,5 @@ +[*.cs] + +# IDE0290: Use primary constructor +# Primary constructors are far from perfect: they can't have readonly fields, while fields can be used anywhere in the class body. +csharp_style_prefer_primary_constructors = false diff --git a/CHANGELOG.md b/CHANGELOG.md index d1a2291..445b78a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## MuzikaGromche 1337.420.69 - Fix harmless but annoying errors in BepInEx console output. +- Improve smoothness of color animations. ## MuzikaGromche 1337.69.420 - It's All Connected Edition diff --git a/MuzikaGromche/Plugin.cs b/MuzikaGromche/Plugin.cs index 03572a6..2302079 100644 --- a/MuzikaGromche/Plugin.cs +++ b/MuzikaGromche/Plugin.cs @@ -830,11 +830,15 @@ namespace MuzikaGromche // Beat relative to the popup. Always less than LoopBeats. When not IsLooping, can be unbounded negative. public readonly float Beat; - public BeatTimestamp(int loopBeats, bool isLooping, float beat) + // Additional metadata describing whether this timestamp is based on extrapolated source data. + public readonly bool IsExtrapolated; + + public BeatTimestamp(int loopBeats, bool isLooping, float beat, bool isExtrapolated) { LoopBeats = loopBeats; IsLooping = isLooping || beat >= HalfLoopBeats; Beat = isLooping || beat >= LoopBeats ? Mod.Positive(beat, LoopBeats) : beat; + IsExtrapolated = isExtrapolated; } public static BeatTimestamp operator +(BeatTimestamp self, float delta) @@ -847,7 +851,7 @@ namespace MuzikaGromche // Shouldn't be needed though, as deltas are usually short enough. // But don't try to chain many short negative deltas! } - return new BeatTimestamp(self.LoopBeats, self.IsLooping, self.Beat + delta); + return new BeatTimestamp(self.LoopBeats, self.IsLooping, self.Beat + delta, self.IsExtrapolated); } public static BeatTimestamp operator -(BeatTimestamp self, float delta) @@ -859,12 +863,12 @@ namespace MuzikaGromche { // There is no way it wraps or affects IsLooping state var beat = Mathf.Floor(Beat); - return new BeatTimestamp(LoopBeats, IsLooping, beat); + return new BeatTimestamp(LoopBeats, IsLooping, beat, IsExtrapolated); } public readonly override string ToString() { - return $"{nameof(BeatTimestamp)}({(IsLooping ? 'Y' : 'n')} {Beat:N4}/{LoopBeats})"; + return $"{nameof(BeatTimestamp)}({(IsLooping ? 'Y' : 'n')}{(IsExtrapolated ? 'E' : '_')} {Beat:N4}/{LoopBeats})"; } } @@ -877,13 +881,16 @@ namespace MuzikaGromche public readonly float BeatFromExclusive; // Closed upper bound public readonly float BeatToInclusive; + // Additional metadata describing whether this timestamp is based on extrapolated source data. + public readonly bool IsExtrapolated; - public BeatTimeSpan(int loopBeats, bool isLooping, float beatFromExclusive, float beatToInclusive) + public BeatTimeSpan(int loopBeats, bool isLooping, float beatFromExclusive, float beatToInclusive, bool isExtrapolated) { LoopBeats = loopBeats; IsLooping = isLooping || beatToInclusive >= HalfLoopBeats; BeatFromExclusive = wrap(beatFromExclusive); BeatToInclusive = wrap(beatToInclusive); + IsExtrapolated = isExtrapolated; float wrap(float beat) { @@ -893,20 +900,20 @@ namespace MuzikaGromche public static BeatTimeSpan Between(BeatTimestamp timestampFromExclusive, BeatTimestamp timestampToInclusive) { - return new BeatTimeSpan(timestampToInclusive.LoopBeats, timestampToInclusive.IsLooping, timestampFromExclusive.Beat, timestampToInclusive.Beat); + var isExtrapolated = timestampFromExclusive.IsExtrapolated || timestampToInclusive.IsExtrapolated; + return new BeatTimeSpan(timestampToInclusive.LoopBeats, timestampToInclusive.IsLooping, timestampFromExclusive.Beat, timestampToInclusive.Beat, isExtrapolated); } - public static BeatTimeSpan Between(float beatFromExclusive, BeatTimestamp timestampToInclusive) { - return new BeatTimeSpan(timestampToInclusive.LoopBeats, timestampToInclusive.IsLooping, beatFromExclusive, timestampToInclusive.Beat); + return new BeatTimeSpan(timestampToInclusive.LoopBeats, timestampToInclusive.IsLooping, beatFromExclusive, timestampToInclusive.Beat, timestampToInclusive.IsExtrapolated); } public static BeatTimeSpan Empty = new(); public readonly BeatTimestamp ToTimestamp() { - return new(LoopBeats, IsLooping, BeatToInclusive); + return new(LoopBeats, IsLooping, BeatToInclusive, IsExtrapolated); } // The beat will not be wrapped. @@ -928,7 +935,7 @@ namespace MuzikaGromche // before wrapping (happens earlier) and after wrapping (happens later). // Check the "happens later" part first. - var laterSpan = new BeatTimeSpan(LoopBeats, isLooping: false, beatFromExclusive: /* epsilon to make zero inclusive */ -0.001f, beatToInclusive: BeatToInclusive); + var laterSpan = new BeatTimeSpan(LoopBeats, isLooping: false, beatFromExclusive: /* epsilon to make zero inclusive */ -0.001f, beatToInclusive: BeatToInclusive, IsExtrapolated); var laterIndex = laterSpan.GetLastIndex(timeSeries); if (laterIndex != null) { @@ -1014,94 +1021,144 @@ namespace MuzikaGromche public readonly override string ToString() { - return $"{nameof(BeatTimeSpan)}({(IsLooping ? 'Y' : 'n')}, {BeatFromExclusive:N4}..{BeatToInclusive:N4}/{LoopBeats}{(IsEmpty() ? " Empty!" : "")})"; + return $"{nameof(BeatTimeSpan)}({(IsLooping ? 'Y' : 'n')}{(IsExtrapolated ? 'E' : '_')}, {BeatFromExclusive:N4}..{BeatToInclusive:N4}/{LoopBeats}{(IsEmpty() ? " Empty!" : "")})"; } } - class BeatTimeState + class ExtrapolatedAudioSourceState { - // The object is newly created, the Start audio began to play but its time hasn't adjanced from 0.0f yet. - private bool hasStarted = false; + // AudioSource.isPlaying + public bool IsPlaying { get; private set; } - // The time span after Zero state (when the Start audio has advanced from 0.0f) but before the WindUpTimer+Loop/2. - private bool windUpOffsetIsLooping = false; + // AudioSource.time, possibly extrapolated + public float Time => ExtrapolatedTime; - // The time span after Zero state (when the Start audio has advanced from 0.0f) but before the WindUpTimer+LoopOffset+Loop/2. - private bool loopOffsetIsLooping = false; + // The object is newly created, the AudioSource began to play (possibly delayed) but its time hasn't advanced from 0.0f yet. + // Time can not be extrapolated when HasStarted is false. + public bool HasStarted { get; private set; } = false; - private bool windUpZeroBeatEventTriggered = false; + public bool IsExtrapolated => LastKnownNonExtrapolatedTime != ExtrapolatedTime; - private readonly Track track; + private float ExtrapolatedTime = 0f; - private float loopOffsetBeat = float.NegativeInfinity; + private float LastKnownNonExtrapolatedTime = 0f; - private static System.Random lyricsRandom = null!; + // Any wall clock based measurements of when this state was recorded + private float LastKnownRealtime = 0f; - private int lyricsRandomPerLoop; + private const float MaxExtrapolationInterval = 0.5f; - public BeatTimeState(Track track) + public void Update(AudioSource audioSource, float realtime) { - if (lyricsRandom == null) + IsPlaying = audioSource.isPlaying; + HasStarted |= audioSource.time != 0f; + + if (LastKnownNonExtrapolatedTime != audioSource.time) { - lyricsRandom = new System.Random(RoundManager.Instance.playersManager.randomMapSeed + 1337); - lyricsRandomPerLoop = lyricsRandom.Next(); + LastKnownRealtime = realtime; + LastKnownNonExtrapolatedTime = ExtrapolatedTime = audioSource.time; } - this.track = track; - } - - public List Update(AudioSource start, AudioSource loop) - { - hasStarted |= start.time != 0; - if (hasStarted) + // Frames are rendering faster than AudioSource updates its playback time state + else if (IsPlaying && HasStarted && Config.ExtrapolateTime) { - var loopTimestamp = UpdateStateForLoopOffset(start, loop); - var loopOffsetSpan = BeatTimeSpan.Between(loopOffsetBeat, loopTimestamp); - - // Do not go back in time - if (!loopOffsetSpan.IsEmpty()) - { - if (loopOffsetSpan.BeatFromExclusive > loopOffsetSpan.BeatToInclusive) - { - lyricsRandomPerLoop = lyricsRandom.Next(); - } - - var windUpOffsetTimestamp = UpdateStateForWindUpOffset(start, loop); - loopOffsetBeat = loopTimestamp.Beat; - var events = GetEvents(loopOffsetSpan, windUpOffsetTimestamp); #if DEBUG - Debug.Log($"{nameof(MuzikaGromche)} looping? {(loopOffsetIsLooping ? 'X' : '_')}{(windUpOffsetIsLooping ? 'X' : '_')} Loop={loopOffsetSpan} WindUp={windUpOffsetTimestamp} events={string.Join(",", events)}"); + Debug.Assert(LastKnownNonExtrapolatedTime == audioSource.time); // implied #endif - return events; + var deltaTime = realtime - LastKnownRealtime; + if (0 < deltaTime && deltaTime < MaxExtrapolationInterval) + { + ExtrapolatedTime = LastKnownNonExtrapolatedTime + deltaTime; } } - - return []; } - // Events other than colors start rotating at 0=WindUpTimer+LoopOffset. - private BeatTimestamp UpdateStateForLoopOffset(AudioSource start, AudioSource loop) + public void Finish() { - var offset = BaseOffset() + track.LoopOffsetInSeconds; - var timestamp = GetTimestampRelativeToGivenOffset(start, loop, offset, loopOffsetIsLooping); - loopOffsetIsLooping |= timestamp.IsLooping; - return timestamp; + IsPlaying = false; } - // Colors start rotating at 0=WindUpTimer - private BeatTimestamp UpdateStateForWindUpOffset(AudioSource start, AudioSource loop) + public override string ToString() { - var offset = BaseOffset(); - var timestamp = GetTimestampRelativeToGivenOffset(start, loop, offset, windUpOffsetIsLooping); - windUpOffsetIsLooping |= timestamp.IsLooping; - return timestamp; + return $"{nameof(ExtrapolatedAudioSourceState)}({(IsPlaying ? 'P' : '_')}{(HasStarted ? 'S' : '0')} " + + (IsExtrapolated + ? $"{LastKnownRealtime:N4}, {LastKnownNonExtrapolatedTime:N4} => {ExtrapolatedTime:N4}" + : $"{LastKnownRealtime:N4}, {LastKnownNonExtrapolatedTime:N4}" + ) + ")"; } + } - private float BaseOffset() + class JesterAudioSourcesState + { + private readonly float StartClipLength; + + // Neither start.isPlaying or loop.isPlaying are reliable indicators of which track is actually playing right now: + // start.isPlaying would be true during the loop when Jester chases a player, + // loop.isPlaying would be true when it is played delyaed but hasn't actually started playing yet. + private readonly ExtrapolatedAudioSourceState Start = new(); + + private readonly ExtrapolatedAudioSourceState Loop = new(); + + // If true, use Start state as a reference, otherwise use Loop. + private bool ReferenceIsStart = true; + + public bool HasStarted => Start.HasStarted; + + public bool IsExtrapolated => ReferenceIsStart ? Start.IsExtrapolated : Loop.IsExtrapolated; + + // Time from the start of the start clip. It wraps when the loop AudioSource loops: + // [...start...][...loop...] + // ^ | + // `----------' + public float Time => ReferenceIsStart + ? Start.Time + : StartClipLength + Loop.Time; + + public JesterAudioSourcesState(float startClipLength) { - return Config.AudioOffset.Value + track.BeatsOffsetInSeconds + track.WindUpTimer; + StartClipLength = startClipLength; } - BeatTimestamp GetTimestampRelativeToGivenOffset(AudioSource start, AudioSource loop, float offset, bool isLooping) + public void Update(AudioSource start, AudioSource loop, float realtime) + { + // It doesn't make sense to update start state after loop has started (because start.isPlaying occasionally becomes true). + // But always makes sense to update loop, so we can check if it has actually started. + Loop.Update(loop, realtime); + + if (!Loop.HasStarted) + { +#if DEBUG + Debug.Assert(ReferenceIsStart); +#endif + Start.Update(start, realtime); + } + else + { + ReferenceIsStart = false; + } + } + } + + // This class tracks looping state of the playback, so that the timestamps can be correctly wrapped only when needed. + // [... ...time... ...] + // ^ | + // `---|---' loop + // ^ IsLooping becomes true and stays true forever. + class AudioLoopingState + { + public bool IsLooping { get; private set; } = false; + + private readonly float StartOfLoop; + private readonly float LoopLength; + private readonly int Beats; + + public AudioLoopingState(float startOfLoop, float loopLength, int beats) + { + StartOfLoop = startOfLoop; + LoopLength = loopLength; + Beats = beats; + } + + public BeatTimestamp Update(float time, bool isExtrapolated, float additionalOffset) { // If popped, calculate which beat the music is currently at. // In order to do that we should choose one of two strategies: @@ -1114,42 +1171,114 @@ namespace MuzikaGromche // NOTE 2: There is a weird state when Jester has popped and chases a player: // Start/farAudio isPlaying is true but stays exactly at zero time, so we need to ignore that. - var timeFromTheVeryStart = start.isPlaying && start.time != 0f - // [1] Start source is still playing - ? start.time - // [2] Start source has finished - : track.LoadedStart.length + loop.time; + var offset = StartOfLoop + additionalOffset; - float adjustedTimeFromOffset = timeFromTheVeryStart - offset; + float timeSinceStartOfLoop = time - offset; - var adjustedTimeNormalized = adjustedTimeFromOffset / track.LoadedLoop.length; + var adjustedTimeNormalized = timeSinceStartOfLoop / LoopLength; - var beat = adjustedTimeNormalized * track.Beats; + var beat = adjustedTimeNormalized * Beats; // Let it infer the isLooping flag from the beat - var timestamp = new BeatTimestamp(track.Beats, isLooping, beat); + var timestamp = new BeatTimestamp(Beats, IsLooping, beat, isExtrapolated); + + IsLooping |= timestamp.IsLooping; #if DEBUG && false - var color = ColorFromPaletteAtTimestamp(timestamp); - Debug.LogFormat("{0} t={1,10:N4} d={2,7:N4} Start[{3}{4,8:N4} zero? {5}] Loop[{6}{7,8:N4}] norm={8,6:N4} beat={9,7:N4} color={10}", + Debug.LogFormat("{0} t={1,10:N4} d={2,7:N4} {3} Time={4:N4} norm={5,6:N4} beat={6,7:N4}", nameof(MuzikaGromche), Time.realtimeSinceStartup, Time.deltaTime, - (start.isPlaying ? '+' : ' '), start.time, (start.time == 0f ? 'Y' : 'n'), - (loop.isPlaying ? '+' : ' '), loop.time, - adjustedTimeNormalized, beat, color); + isExtrapolated ? 'E' : '_', time, + adjustedTimeNormalized, beat); #endif return timestamp; } + } + + class BeatTimeState + { + private readonly Track track; + + private readonly JesterAudioSourcesState AudioState; + + // Colors wrap from WindUpTimer + private readonly AudioLoopingState WindUpLoopingState; + + // Events other than colors wrap from WindUpTimer+LoopOffset. + private readonly AudioLoopingState LoopLoopingState; + + private float LastKnownLoopOffsetBeat = float.NegativeInfinity; + + private static System.Random LyricsRandom = null!; + + private int LyricsRandomPerLoop; + + private bool WindUpZeroBeatEventTriggered = false; + + public BeatTimeState(Track track) + { + if (LyricsRandom == null) + { + LyricsRandom = new System.Random(RoundManager.Instance.playersManager.randomMapSeed + 1337); + LyricsRandomPerLoop = LyricsRandom.Next(); + } + this.track = track; + AudioState = new(track.LoadedStart.length); + WindUpLoopingState = new(track.WindUpTimer, track.LoadedLoop.length, track.Beats); + LoopLoopingState = new(track.WindUpTimer + track.LoopOffsetInSeconds, track.LoadedLoop.length, track.Beats); + } + + public List Update(AudioSource start, AudioSource loop) + { + var time = Time.realtimeSinceStartup; + AudioState.Update(start, loop, time); + + if (AudioState.HasStarted) + { + var loopTimestamp = Update(LoopLoopingState); + var loopOffsetSpan = BeatTimeSpan.Between(LastKnownLoopOffsetBeat, loopTimestamp); + + // Do not go back in time + if (!loopOffsetSpan.IsEmpty()) + { + if (loopOffsetSpan.BeatFromExclusive > loopOffsetSpan.BeatToInclusive) + { + LyricsRandomPerLoop = LyricsRandom.Next(); + } + + var windUpOffsetTimestamp = Update(WindUpLoopingState); + LastKnownLoopOffsetBeat = loopTimestamp.Beat; + var events = GetEvents(loopOffsetSpan, windUpOffsetTimestamp); +#if DEBUG + Debug.Log($"{nameof(MuzikaGromche)} looping? {(LoopLoopingState.IsLooping ? 'X' : '_')}{(WindUpLoopingState.IsLooping ? 'X' : '_')} Loop={loopOffsetSpan} WindUp={windUpOffsetTimestamp} Time={Time.realtimeSinceStartup:N4} events={string.Join(",", events)}"); +#endif + return events; + } + } + + return []; + } + + private BeatTimestamp Update(AudioLoopingState loopingState) + { + return loopingState.Update(AudioState.Time, AudioState.IsExtrapolated, AdditionalOffset()); + } + + // Timings that may be changes through config + private float AdditionalOffset() + { + return Config.AudioOffset.Value + track.BeatsOffsetInSeconds; + } private List GetEvents(BeatTimeSpan loopOffsetSpan, BeatTimestamp windUpOffsetTimestamp) { List events = []; - if (windUpOffsetTimestamp.Beat >= 0f && !windUpZeroBeatEventTriggered) + if (windUpOffsetTimestamp.Beat >= 0f && !WindUpZeroBeatEventTriggered) { events.Add(new WindUpZeroBeatEvent()); - windUpZeroBeatEventTriggered = true; + WindUpZeroBeatEventTriggered = true; } if (GetColorEvent(loopOffsetSpan, windUpOffsetTimestamp) is { } colorEvent) @@ -1171,7 +1300,7 @@ namespace MuzikaGromche { var line = track.LyricsLines[i]; var alternatives = line.Split('\t'); - var randomIndex = lyricsRandomPerLoop % alternatives.Length; + var randomIndex = LyricsRandomPerLoop % alternatives.Length; var alternative = alternatives[randomIndex]; if (alternative != "") { @@ -1466,6 +1595,7 @@ namespace MuzikaGromche public static ConfigEntry OverrideSpawnRates { get; private set; } = null!; + public static bool ExtrapolateTime { get; private set; } = true; public static bool ShouldSkipWindingPhase { get; private set; } = false; public static Palette? PaletteOverride { get; private set; } = null; @@ -1502,6 +1632,7 @@ namespace MuzikaGromche LethalConfigManager.AddConfigItem(new BoolCheckBoxConfigItem(OverrideSpawnRates, Default(new BoolCheckBoxOptions()))); #if DEBUG + SetupEntriesForExtrapolation(configFile); SetupEntriesToSkipWinding(configFile); SetupEntriesForPaletteOverride(configFile); SetupEntriesForTimingsOverride(configFile); @@ -1600,6 +1731,22 @@ namespace MuzikaGromche } #if DEBUG + private void SetupEntriesForExtrapolation(ConfigFile configFile) + { + var syncedEntry = configFile.BindSyncedEntry("General", "Extrapolate Audio Playback Time", true, + new ConfigDescription("AudioSource only updates its playback position about 20 times per second.\n\nUse extrapolation technique to predict playback time between updates for smoother color animations.")); + LethalConfigManager.AddConfigItem(new BoolCheckBoxConfigItem(syncedEntry.Entry, Default(new BoolCheckBoxOptions()))); + CSyncHackAddSyncedEntry(syncedEntry); + syncedEntry.Changed += (sender, args) => apply(); + syncedEntry.SyncHostToLocal(); + apply(); + + void apply() + { + ExtrapolateTime = syncedEntry.Value; + } + } + private void SetupEntriesToSkipWinding(ConfigFile configFile) { var syncedEntry = configFile.BindSyncedEntry("General", "Skip Winding Phase", false, @@ -2015,9 +2162,9 @@ namespace MuzikaGromche } // Manage the timeline: switch color of the lights according to the current playback/beat position. - if ((__instance.previousState == 1 || __instance.previousState == 2) && Plugin.BeatTimeState != null) + if ((__instance.previousState == 1 || __instance.previousState == 2) && Plugin.BeatTimeState is { } beatTimeState) { - var events = Plugin.BeatTimeState.Update(start: __instance.farAudio, loop: __instance.creatureVoice); + var events = beatTimeState.Update(start: __instance.farAudio, loop: __instance.creatureVoice); foreach (var ev in events) { switch (ev)