diff --git a/MuzikaGromche/Plugin.cs b/MuzikaGromche/Plugin.cs index 02f8e19..4709a28 100644 --- a/MuzikaGromche/Plugin.cs +++ b/MuzikaGromche/Plugin.cs @@ -139,6 +139,33 @@ namespace MuzikaGromche public static readonly Language RUSSIAN = new("RU", "Russian"); } + public readonly record struct Easing(string Name, Func Eval) + { + public static Easing Linear = new("Linear", static x => x); + public static Easing OutCubic = new("OutCubic", static x => 1 - Mathf.Pow(1f - x, 3f)); + public static Easing InCubic = new("InCubic", static x => x * x * x); + public static Easing InOutCubic = new("InOutCubic", static x => x < 0.5f ? 4f * x * x * x : 1 - Mathf.Pow(-2f * x + 2f, 3f) / 2f); + public static Easing InExpo = new("InExpo", static x => x == 0f ? 0f : Mathf.Pow(2f, 10f * x - 10f)); + public static Easing OutExpo = new("OutExpo", static x => x == 1f ? 1f : 1f - Mathf.Pow(2f, -10f * x)); + public static Easing InOutExpo = new("InOutExpo", static x => + x == 0f + ? 0f + : x == 1f + ? 1f + : x < 0.5f + ? Mathf.Pow(2f, 20f * x - 10f) / 2f + : (2f - Mathf.Pow(2f, -20f * x + 10f)) / 2f); + + public static Easing[] All = [Linear, InCubic, OutCubic, InOutCubic, InExpo, OutExpo, InOutExpo]; + + public static string[] AllNames => [.. All.Select(easing => easing.Name)]; + + public static Easing FindByName(string Name) + { + return All.Where(easing => easing.Name == Name).DefaultIfEmpty(Linear).First(); + } + } + public record Palette(Color[] Colors) { public static Palette DEFAULT = new([Color.magenta, Color.cyan, Color.green, Color.yellow]); @@ -203,6 +230,39 @@ namespace MuzikaGromche _ => "", }; + // Offset of beats. Bigger offset => colors will change later. + public float _BeatsOffset = 0f; + public float BeatsOffset + { + get => Config.BeatsOffsetOverride ?? _BeatsOffset; + set => _BeatsOffset = value; + } + + // Duration of color transition, measured in beats. + public float _ColorTransitionIn = 0.25f; + public float ColorTransitionIn + { + get => Config.ColorTransitionInOverride ?? _ColorTransitionIn; + set => _ColorTransitionIn = value; + } + + public float _ColorTransitionOut = 0.25f; + public float ColorTransitionOut + { + get => Config.ColorTransitionOutOverride ?? _ColorTransitionOut; + set => _ColorTransitionOut = value; + } + + // Easing function for color transitions. + public Easing _ColorTransitionEasing = Easing.OutExpo; + public Easing ColorTransitionEasing + { + get => Config.ColorTransitionEasingOverride != null + ? Easing.FindByName(Config.ColorTransitionEasingOverride) + : _ColorTransitionEasing; + set => _ColorTransitionEasing = value; + } + public float CalculateBeat(AudioSource start, AudioSource loop) { // If popped, calculate which beat the music is currently at. @@ -227,15 +287,16 @@ namespace MuzikaGromche var normalized = Mod.Positive(elapsed / LoadedLoop.length, 1f); var beat = normalized * (float)Beats; + var offset = Mod.Positive(beat - BeatsOffset, (float)Beats); #if DEBUG var color = ColorAtBeat(beat); - Debug.LogFormat("MuzikaGromche t={0,10:N4} d={1,7:N4} Start[{2}{3,8:N4} ==0f? {4}] Loop[{5}{6,8:N4}] norm={7,6:N4} beat={8,7:N4} color={9}", + Debug.LogFormat("MuzikaGromche t={0,10:N4} d={1,7:N4} Start[{2}{3,8:N4} ==0f? {4}] Loop[{5}{6,8:N4}] norm={7,6:N4} beat={8,7:N4} {9,7:N4} color={10}", Time.realtimeSinceStartup, Time.deltaTime, (start.isPlaying ? '+' : ' '), start.time, (start.time == 0f ? 'Y' : 'n'), (loop.isPlaying ? '+' : ' '), loop.time, - normalized, beat, color); + normalized, beat, offset, color); #endif - return beat; + return offset; } public Palette _Palette = Palette.DEFAULT; @@ -247,8 +308,64 @@ namespace MuzikaGromche public Color ColorAtBeat(float beat) { - int beatIndex = Mod.Positive(Mathf.FloorToInt(beat), Beats); - return Mod.Index(Palette.Colors, beatIndex); + // Imagine the timeline as a sequence of clips without gaps where each clip is a whole beat long. + // Transition is when two adjacent clips need to be combined with some blend function(t) + // where t is a factor in range 0..1 expressed as (time - Transition.Start) / Transition.Length; + // + // How to find a transition at a given time? + // First, we need to find the current clip's start and length. + // - Length is always 1 beat, and + // - start is just time rounded down. + // + // If time interval from the start of the clip is less than Transition.Out + // then blend between previous and current clips. + // + // Else if time interval to the end of the clip is less than Transition.In + // then blend between current and next clips. + // + // Otherwise there is no transition running at this time. + const float currentClipLength = 1f; + var currentClipStart = Mathf.Floor(beat); + var currentClipEnd = currentClipStart + currentClipLength; + + float transitionLength = ColorTransitionIn + ColorTransitionOut; + + if (Config.EnableColorAnimations.Value) + { + if (transitionLength > /* epsilon */ 0.01) + { + if (beat - currentClipStart < ColorTransitionOut) + { + return ColorTransition(currentClipStart); + } + else if (currentClipEnd - beat < ColorTransitionIn) + { + return ColorTransition(currentClipEnd); + } + } + } + // default + return ColorAtWholeBeat(beat); + + Color ColorTransition(float clipsBoundary) + { + var transitionStart = clipsBoundary - ColorTransitionIn; + var transitionEnd = clipsBoundary + ColorTransitionOut; + var x = (beat - transitionStart) / transitionLength; + var t = Mathf.Clamp(ColorTransitionEasing.Eval(x), 0f, 1f); + if (ColorTransitionIn == 0.0f) + { + // Subtract an epsilon, so we don't use the same beat twice + transitionStart -= 0.01f; + } + return Color.Lerp(ColorAtWholeBeat(transitionStart), ColorAtWholeBeat(transitionEnd), t); + } + + Color ColorAtWholeBeat(float beat) + { + int beatIndex = Mod.Positive(Mathf.FloorToInt(beat), Beats); + return Mod.Index(Palette.Colors, beatIndex); + } } } @@ -376,14 +493,25 @@ namespace MuzikaGromche public class Config : SyncedConfig2 { + public static ConfigEntry EnableColorAnimations { get; private set; } + public static ConfigEntry AudioOffset { get; private set; } public static bool ShouldSkipWindingPhase { get; private set; } = false; public static Palette PaletteOverride { get; private set; } = null; + public static float? BeatsOffsetOverride { get; private set; } = null; + public static float? ColorTransitionInOverride { get; private set; } = null; + public static float? ColorTransitionOutOverride { get; private set; } = null; + public static string ColorTransitionEasingOverride { get; private set; } = null; + public Config(ConfigFile configFile) : base(PluginInfo.PLUGIN_GUID) { + EnableColorAnimations = configFile.Bind("General", "Enable Color Animations", true, + new ConfigDescription("Smooth light color transitions are known to cause performance issues on some setups.\n\nTurn them off if you experience lag spikes.")); + LethalConfigManager.AddConfigItem(new BoolCheckBoxConfigItem(EnableColorAnimations, requiresRestart: false)); + AudioOffset = configFile.Bind("General", "Audio Offset", 0f, new ConfigDescription( "Adjust audio offset (in seconds).\n\nIf you are playing with Bluetooth headphones and experiencing a visual desync, try setting this to about negative 0.2.\n\nIf your video output has high latency (like a long HDMI cable etc.), try positive values instead.", new AcceptableValueRange(-0.5f, 0.5f))); @@ -392,6 +520,7 @@ namespace MuzikaGromche #if DEBUG SetupEntriesToSkipWinding(configFile); SetupEntriesForPaletteOverride(configFile); + SetupEntriesForTimingsOverride(configFile); #endif var chanceRange = new AcceptableValueRange(0, 100); @@ -579,6 +708,110 @@ namespace MuzikaGromche } } } + + private void SetupEntriesForTimingsOverride(ConfigFile configFile) + { + const string section = "Timings"; + var colorTransitionRange = new AcceptableValueRange(0f, 1f); + // Declare and initialize early to avoid "Use of unassigned local variable" + SyncedEntry overrideTimingsSyncedEntry = null; + SyncedEntry beatsOffsetSyncedEntry = null; + SyncedEntry colorTransitionInSyncedEntry = null; + SyncedEntry colorTransitionOutSyncedEntry = null; + SyncedEntry colorTransitionEasingSyncedEntry = null; + + var loadButton = new GenericButtonConfigItem(section, "Load Timings from the Current Track", + "Override custom timings with the built-in timings of the current track.", "Load", load); + loadButton.ButtonOptions.CanModifyCallback = CanModifyIfHost; + LethalConfigManager.AddConfigItem(loadButton); + + overrideTimingsSyncedEntry = configFile.BindSyncedEntry(section, "Override Timings", false, + new ConfigDescription("If checked, custom timings override track's own built-in timings.")); + LethalConfigManager.AddConfigItem(new BoolCheckBoxConfigItem(overrideTimingsSyncedEntry.Entry, new BoolCheckBoxOptions + { + RequiresRestart = false, + CanModifyCallback = CanModifyIfHost, + })); + CSyncHackAddSyncedEntry(overrideTimingsSyncedEntry); + overrideTimingsSyncedEntry.Changed += (sender, args) => apply(); + overrideTimingsSyncedEntry.SyncHostToLocal(); + + beatsOffsetSyncedEntry = configFile.BindSyncedEntry(section, "Beats Offset", 0f, + new ConfigDescription("How much to offset the whole beat. More is later", new AcceptableValueRange(-0.5f, 0.5f))); + colorTransitionInSyncedEntry = configFile.BindSyncedEntry(section, "Color Transition In", 0.25f, + new ConfigDescription("Fraction of a beat *before* the whole beat when the color transition should start.", colorTransitionRange)); + colorTransitionOutSyncedEntry = configFile.BindSyncedEntry(section, "Color Transition Out", 0.25f, + new ConfigDescription("Fraction of a beat *after* the whole beat when the color transition should end.", colorTransitionRange)); + colorTransitionEasingSyncedEntry = configFile.BindSyncedEntry(section, "Color Transition Easing", Easing.Linear.Name, + new ConfigDescription("Interpolation/easing method to use for color transitions", new AcceptableValueList(Easing.AllNames))); + + var floatSliderOptions = new FloatSliderOptions + { + RequiresRestart = false, + CanModifyCallback = CanModifyIfHost, + }; + LethalConfigManager.AddConfigItem(new FloatSliderConfigItem(beatsOffsetSyncedEntry.Entry, floatSliderOptions)); + LethalConfigManager.AddConfigItem(new FloatSliderConfigItem(colorTransitionInSyncedEntry.Entry, floatSliderOptions)); + LethalConfigManager.AddConfigItem(new FloatSliderConfigItem(colorTransitionOutSyncedEntry.Entry, floatSliderOptions)); + LethalConfigManager.AddConfigItem(new TextDropDownConfigItem(colorTransitionEasingSyncedEntry.Entry, new TextDropDownOptions + { + RequiresRestart = false, + CanModifyCallback = CanModifyIfHost, + })); + + CSyncHackAddSyncedEntry(beatsOffsetSyncedEntry); + CSyncHackAddSyncedEntry(colorTransitionInSyncedEntry); + CSyncHackAddSyncedEntry(colorTransitionOutSyncedEntry); + CSyncHackAddSyncedEntry(colorTransitionEasingSyncedEntry); + + beatsOffsetSyncedEntry.SyncHostToLocal(); + colorTransitionInSyncedEntry.SyncHostToLocal(); + colorTransitionOutSyncedEntry.SyncHostToLocal(); + colorTransitionEasingSyncedEntry.SyncHostToLocal(); + + beatsOffsetSyncedEntry.Changed += (sender, args) => apply(); + colorTransitionInSyncedEntry.Changed += (sender, args) => apply(); + colorTransitionOutSyncedEntry.Changed += (sender, args) => apply(); + colorTransitionEasingSyncedEntry.Changed += (sender, args) => apply(); + + void load() + { + // if track is null, set everything to defaults + var track = Plugin.CurrentTrack; + if (track == null) + { + beatsOffsetSyncedEntry.LocalValue = 0f; + colorTransitionInSyncedEntry.LocalValue = 0f; + colorTransitionOutSyncedEntry.LocalValue = 0f; + colorTransitionEasingSyncedEntry.LocalValue = Easing.Linear.Name; + } + else + { + beatsOffsetSyncedEntry.LocalValue = track._BeatsOffset; + colorTransitionInSyncedEntry.LocalValue = track._ColorTransitionIn; + colorTransitionOutSyncedEntry.LocalValue = track._ColorTransitionOut; + colorTransitionEasingSyncedEntry.LocalValue = track._ColorTransitionEasing.Name; + } + } + + void apply() + { + if (!overrideTimingsSyncedEntry.Value) + { + BeatsOffsetOverride = null; + ColorTransitionInOverride = null; + ColorTransitionOutOverride = null; + ColorTransitionEasingOverride = null; + } + else + { + BeatsOffsetOverride = beatsOffsetSyncedEntry.Value; + ColorTransitionInOverride = colorTransitionInSyncedEntry.Value; + ColorTransitionOutOverride = colorTransitionOutSyncedEntry.Value; + ColorTransitionEasingOverride = colorTransitionEasingSyncedEntry.Value; + } + } + } } // farAudio is during windup, Start overrides popGoesTheWeaselTheme