using System; using System.Collections.Generic; using UnityEngine; using SimpleJSON; using MacGruber; using MVRScript_Extensions; // BeatIt v1.5 // Modified by HAL9001, // based on hamzhox VAMAudioAnalyzer, // using MacGruber_Utils // // Allows precise control of triggers based on audio frequencies bands. // Does not care about audio source overall volume as it auto-adjusts each band. // // v1.0 (var 2) - 2023/10/16 - Initial Version // v1.1 (var 3) - 2024/04/28 - Modified AudioSource acquisition - properly finds Person HeadAudio now as well // v1.2 (var 4) - 2024/04/30 - A bit more detail in the debug output when not finding an AudioSource // v1.3 (var 5/6) - 2024/05/22 - Added Enable Band toggle and registered band parameters. Replaced scrollable band selector by a fixed one. // v1.4 (var 7) - 2025/01/26 - Honouring plugin enabled flag (Thanks, @Ghostwalden). Added MacGruber Utils. Refactored and updated coding conventions. // v1.5 (var 8) - 2025/08/18 - Adding a min/max level slider per band (Thanks, @LaDark) // namespace HAL9001 { public class BeatIt : MVRScript { public static readonly string PLUGIN_NAME = nameof(BeatIt) + " v1.5"; public static readonly string PLUGIN_DESCRIPTION = "Control scene elements based on sound and music."; // ******************************************************************** #region Init, Enable, Disable, Destroy public override void Init() { try { Utils.OnInitUI(CreateUIElement); SuperController.singleton.onAtomUIDRenameHandlers += OnAtomRename; SimpleTriggerHandler.LoadAssets(); this.CreatePluginTitle(PLUGIN_NAME, PLUGIN_DESCRIPTION); // Grabbing audio source m_audioSource = containingAtom.GetComponentInChildren(typeof(AudioSource)) as AudioSource; if (m_audioSource == null) { m_audioSource = containingAtom.reParentObject.GetComponentInChildren(typeof(AudioSource)) as AudioSource; } if (m_audioSource == null) { this.LogInfo($"Could not find direct AudioSource for Atom type {containingAtom.type} - Using scene AudioListener"); } m_hasSignal = false; m_spectrum = new float[FFT_BUFFER_SIZE]; m_bands = new List(); for (int i = 0; i < NUM_BANDS; i++) { FrequencyBand b = new FrequencyBand(this, i + 1, DEFAULT_FREQUENCIES[i]); m_bands.Add(b); } // Left Side this.CreateHeader1("Parameters", false); m_jsonRiseSpeed = new JSONStorableFloat("Rise Ratio", 1f, 0f, 1f); RegisterFloat(m_jsonRiseSpeed); UIDynamicSlider sliderRS = CreateSlider(m_jsonRiseSpeed); sliderRS.valueFormat = "F4"; m_jsonFallSpeed = new JSONStorableFloat("Fall Speed", 0.1f, 0.0001f, 1f); RegisterFloat(m_jsonFallSpeed); UIDynamicSlider sliderFS = CreateSlider(m_jsonFallSpeed); sliderFS.valueFormat = "F4"; m_jsonFallAcceleration = new JSONStorableFloat("Fall Acceleration", 1.2f, 1.001f, 2f); RegisterFloat(m_jsonFallAcceleration); UIDynamicSlider sliderFA = CreateSlider(m_jsonFallAcceleration); sliderFA.valueFormat = "F3"; if (m_audioSource != null) { m_jsonVolumeCompensation = new JSONStorableFloat("Volume Compensation", 1f, 0f, 1f); RegisterFloat(m_jsonVolumeCompensation); var slider = CreateSlider(m_jsonVolumeCompensation); } CreateSpacer().height = 20; List bandChoices = new List() { "None" }; foreach (FrequencyBand b in m_bands) { bandChoices.Add(b.GetName()); } m_jsonBandToEdit = new JSONStorableStringChooser("Band to Edit", bandChoices, bandChoices[0], "Band to Edit"); m_jsonBandToEdit.setCallbackFunction += BandToEditChanged; UIDynamicPopup chooserBtE = CreateScrollablePopup(m_jsonBandToEdit, false); // Right Side this.CreateHeader1("Frequency Bands", true); foreach (FrequencyBand b in m_bands) { b.CreateUI(); } } catch (Exception e) { this.LogError("Exception caught: " + e); } } public void OnDestroy() { foreach (FrequencyBand b in m_bands) { b.m_trigger.Remove(); } } #endregion // ******************************************************************** #region Update public void Update() { if (SuperController.singleton.freezeAnimation) return; // Updating the spectrum itself GetAudioSpectrum(); m_hasSignal = false; foreach (FrequencyBand b in m_bands) { m_hasSignal |= b.ProcessSpectrum(m_spectrum); } if ((m_audioSource != null) && (!m_audioSource.isPlaying)) { m_hasSignal = false; } if (!m_hasSignal) { foreach (FrequencyBand b in m_bands) { b.ResetCalibration(); } } if (enabledJSON.val) { // only animate when plugin is enabled foreach (FrequencyBand b in m_bands) { b.Animate(); } } } #endregion // ******************************************************************** #region Methods protected void BandToEditChanged(string _) { foreach (FrequencyBand b in m_bands) { b.DestroyUI(); } foreach (FrequencyBand b in m_bands) { b.CreateUI(); } } protected void GetAudioSpectrum() { if (m_audioSource != null) { m_audioSource.GetSpectrumData(m_spectrum, 0, FFTWindow.Blackman); } else { AudioListener.GetSpectrumData(m_spectrum, 0, FFTWindow.Blackman); } } #endregion // ******************************************************************** #region Save, Load, Rename public override JSONClass GetJSON(bool includePhysical = true, bool includeAppearance = true, bool forceStore = false) { JSONClass jc = base.GetJSON(includePhysical, includeAppearance, forceStore); if (includePhysical || forceStore) { needsStore = true; foreach (FrequencyBand b in m_bands) { jc[b.m_trigger.Name] = b.m_trigger.GetJSON(base.subScenePrefix); } } return jc; } public override void LateRestoreFromJSON(JSONClass jc, bool restorePhysical = true, bool restoreAppearance = true, bool setMissingToDefault = true) { base.LateRestoreFromJSON(jc, restorePhysical, restoreAppearance, setMissingToDefault); if (!base.physicalLocked && restorePhysical && !IsCustomPhysicalParamLocked("trigger")) { foreach (FrequencyBand b in m_bands) { b.m_trigger.RestoreFromJSON(jc, base.subScenePrefix, base.mergeRestore, setMissingToDefault); } } } protected void OnAtomRename(string _oldUID, string _newUID) { foreach (FrequencyBand b in m_bands) { b.m_trigger.SyncAtomNames(); } } #endregion // ******************************************************************** #region Inner Classes protected class FrequencyBand { protected BeatIt m_script; protected int m_bandIdx; protected string m_baseName; protected JSONStorableString m_jsonBandName; protected JSONStorableFloat m_jsonCenterFrequency; protected JSONStorableFloat m_jsonBandwidth; protected JSONStorableFloat m_jsonMinLevel; protected JSONStorableFloat m_jsonMaxLevel; protected JSONStorableBool m_jsonBandEnabled; protected JSONStorableFloat m_jsonCurrentLevel; public FloatTrigger m_trigger; protected UIDynamicButton m_btnTrigger; protected UIDynamic m_spacer1, m_spacer2; protected float m_level, m_maxLevel, m_animatedLevel, m_levelFall; public FrequencyBand(BeatIt _script, int _bandIdx, float _defaultFrequency) { m_script = _script; m_bandIdx = _bandIdx; m_baseName = "Band " + m_bandIdx; m_jsonBandName = new JSONStorableString(m_baseName + " Name", m_baseName); m_jsonBandEnabled = new JSONStorableBool( m_baseName + " Enabled", true); m_jsonCenterFrequency = new JSONStorableFloat( m_baseName + " Frequency", _defaultFrequency, (float val) => {}, 20f, 20000f, true, true); m_jsonBandwidth = new JSONStorableFloat( m_baseName + " Bandwidth", _defaultFrequency/2, (float val) => {}, 10f, 10000f, true, true); m_jsonCurrentLevel = new JSONStorableFloat(m_baseName + " Level", 0f, (float val) => {}, 0f, 1f); m_jsonCurrentLevel.isStorable = false; m_jsonCurrentLevel.isRestorable = false; m_jsonMinLevel = new JSONStorableFloat(m_baseName + " Min Level", 0f, (float val) => {}, 0f, 1f, true, true); m_jsonMaxLevel = new JSONStorableFloat(m_baseName + " Max Level", 1f, (float val) => {}, 0f, 1f, true, true); m_trigger = new FloatTrigger(m_script, m_baseName + " Actions"); m_trigger.active = true; m_script.RegisterBool( m_jsonBandEnabled); m_script.RegisterFloat(m_jsonCenterFrequency); m_script.RegisterFloat(m_jsonBandwidth); m_script.RegisterFloat(m_jsonMinLevel); m_script.RegisterFloat(m_jsonMaxLevel); m_level = 0; m_maxLevel = 0; m_animatedLevel = 0; m_levelFall = 0; } public string GetName() { return m_jsonBandName.val; } public void CreateUI() { UIDynamicSlider slider; if (GetName() == m_script.m_jsonBandToEdit.val) { // when editing this band, expand the UI if (m_bandIdx > 1) { m_spacer1 = m_script.CreateSpacer(true); m_spacer1.height = 20; } slider = m_script.CreateSlider(m_jsonCurrentLevel, true); // outer.CreateTextField(bandName, true); m_script.CreateToggle(m_jsonBandEnabled, true); m_btnTrigger = m_script.CreateButton(m_baseName + " Triggers", true); m_btnTrigger.button.onClick.AddListener( () => { m_trigger.OpenPanel(); }); var sldF = m_script.CreateSlider(m_jsonCenterFrequency, true); sldF.ConfigureQuickButtons(-1, -10, -100, -1000, 1, 10, 100, 1000); var sldBW = m_script.CreateSlider(m_jsonBandwidth, true); sldBW.ConfigureQuickButtons(-1, -10, -100, -1000, 1, 10, 100, 1000); var sldMin = m_script.CreateSlider(m_jsonMinLevel, true); sldMin.ConfigureQuickButtons(-0.001f, -0.01f, -0.1f, -1f, 0.001f, 0.01f, 0.1f, 1f); var sldMax = m_script.CreateSlider(m_jsonMaxLevel, true); sldMax.ConfigureQuickButtons(-0.001f, -0.01f, -0.1f, -1f, 0.001f, 0.01f, 0.1f, 1f); if (m_bandIdx < NUM_BANDS) { m_spacer2 = m_script.CreateSpacer(true); m_spacer2.height = 20; } } else { slider = m_script.CreateSlider(m_jsonCurrentLevel, true); slider.labelText.text = String.Format("{0} ({1} ± {2})", m_jsonBandName.val, FrequencyToString(m_jsonCenterFrequency.val), FrequencyToString(m_jsonBandwidth.val) ); } slider.quickButtonsEnabled = false; slider.defaultButtonEnabled = false; slider.rangeAdjustEnabled = false; slider.sliderControl.slider.interactable = false; } public void DestroyUI() { m_script.RemoveSpacer(m_spacer1); m_script.RemoveSlider(m_jsonCurrentLevel); m_script.RemoveToggle(m_jsonBandEnabled); m_script.RemoveButton(m_btnTrigger); m_script.RemoveSlider(m_jsonCenterFrequency); m_script.RemoveSlider(m_jsonBandwidth); m_script.RemoveSlider(m_jsonMinLevel); m_script.RemoveSlider(m_jsonMaxLevel); m_script.RemoveSpacer(m_spacer2); } public bool ProcessSpectrum(float[] _spectrum) { m_level = 0; if (m_jsonBandEnabled.val) { int startIdx = GetIndexFromFrequency(m_jsonCenterFrequency.val - m_jsonBandwidth.val / 2f); int endIdx = GetIndexFromFrequency(m_jsonCenterFrequency.val + m_jsonBandwidth.val / 2f); for (int i = startIdx; i <= endIdx; i++) { m_level += _spectrum[i]; } m_level /= (endIdx - startIdx + 1); if (m_script.m_audioSource != null) { // compensate by source volume m_level /= Mathf.Max(0.01f, m_script.m_audioSource.volume); // 0.01: avoid divide by zero } } return m_level > 0.0001f; } public void Animate() { if (m_jsonBandEnabled.val) { // adjust maximum level m_maxLevel = Mathf.Max(m_level, m_maxLevel); // determine current level float level = m_script.m_hasSignal ? (m_level / m_maxLevel) : 0; if (m_script.m_audioSource != null) { level *= 1f - ((1f - m_script.m_jsonVolumeCompensation.val) * (1f - m_script.m_audioSource.volume)); } // apply min/max float span = m_jsonMaxLevel.val - m_jsonMinLevel.val; if (Mathf.Abs(span) < 0.0001f) span = 0.0001f * Mathf.Sign(span); // avoid division by 0 level = (level - m_jsonMinLevel.val) / span; // keep between 0 and 1 level = Mathf.Clamp01(level); // animate if ( level > m_animatedLevel ) { m_animatedLevel += m_script.m_jsonRiseSpeed.val * (level - m_animatedLevel); m_levelFall = m_script.m_jsonFallSpeed.val; } else { m_animatedLevel = Mathf.Max(0f, m_animatedLevel - m_levelFall * Time.deltaTime); m_levelFall *= m_script.m_jsonFallAcceleration.val; } m_jsonCurrentLevel.val = m_animatedLevel; m_trigger.Trigger(m_animatedLevel); m_trigger.Update(); } else if (m_animatedLevel > 0) { // when disabling, still animate the level down to 0 m_animatedLevel = Mathf.Max(0f, m_animatedLevel - m_levelFall * Time.deltaTime); m_levelFall *= m_script.m_jsonFallAcceleration.val; m_jsonCurrentLevel.val = m_animatedLevel; m_trigger.Trigger(m_animatedLevel); m_trigger.Update(); } } public void ResetCalibration() { m_maxLevel = 0.001f; } protected int GetIndexFromFrequency(float _frequency) { int bin = (int) (_frequency / ((AUDIO_SAMPLE_RATE / 2f) / FFT_BUFFER_SIZE)); if (bin < 0) { bin = 0; } if (bin >= FFT_BUFFER_SIZE) { bin = FFT_BUFFER_SIZE-1; } return bin; } protected string FrequencyToString(float _frequency) { if (_frequency < 1000.0f) { return String.Format("{0:F0}Hz", _frequency); } else { return String.Format("{0:F0}kHz", _frequency/1000.0f); } } } #endregion // ******************************************************************** #region JSON Storables public JSONStorableFloat m_jsonRiseSpeed; public JSONStorableFloat m_jsonFallSpeed; public JSONStorableFloat m_jsonFallAcceleration; public JSONStorableFloat m_jsonVolumeCompensation; public JSONStorableFloat m_jsonMinLevel; public JSONStorableFloat m_jsonMaxLevel; public JSONStorableStringChooser m_jsonBandToEdit; #endregion // ******************************************************************** #region Variables protected AudioSource m_audioSource; protected bool m_hasSignal; protected float[] m_spectrum; protected List m_bands; #endregion // ******************************************************************** #region Constants static readonly int NUM_BANDS = 8; static readonly float AUDIO_SAMPLE_RATE = 48000f; static readonly int FFT_BUFFER_SIZE = 1024; // should capture about 1/40h of a second @ 48000 Hz samplerate static readonly float[] DEFAULT_FREQUENCIES = new[] { 50f, 100f, 250f, 500f, 1000f, 2500f, 5000f, 10000f }; #endregion } }