using System; using System.Collections.Generic; using UnityEngine; using UnityEngine.Audio; using UnityEngine.Events; namespace E7.Introloop { /// /// A component that coordinates 4 together with scheduling methods /// to achieve gap-less looping music with intro section. /// /// /// /// 2 uses scheduling methods to stitch up audio precisely, while the other 2 sources /// are there to support cross fading to a new Introloop audio. /// /// /// Potentially there is a moment when all 4 sources are playing at the same time. /// (e.g. One introlooping audio at the seam, while being tasked to cross fade into /// an another introloop audio that starts near the seam.) /// /// public class IntroloopPlayer : MonoBehaviour { /// /// This fade is inaudible, it helps removing loud pop/click when you stop song suddenly. /// This is used automatically when you . /// If you really don't want this, use with 0 second fade length. /// private const float popRemovalFadeTime = 0.055f; private static IntroloopPlayer instance; private static AudioSource singletonInstanceTemplateSource; [Tooltip("This works the same as Clips slot on the AudioSource. It is the asset to play when you call Play() " + "in the API. However, Introloop also has Play overload where you can just send an asset to play via " + "the arguments regardless of asset reference connected to this field.")] [SerializeField] private IntroloopAudio defaultIntroloopAudio; [Tooltip("Introloop spawns 4 AudioSource at runtime to manage the loops. All sources inherits settings from " + "this template AudioSource, including the output AudioMixerGroup. You can add the AudioSource to be " + "the template next to this IntroloopPlayer component then connect to this slot.\n\n" + "If it is not assigned, new AudioSource component will be added next to " + "this component on the first play with settings : Priority 0, Spatial Blend 2D. (Expected settings " + "for background music uses.) Then this field will reference that newly added AudioSource as a template.")] [SerializeField] private AudioSource templateSource; [Tooltip("Works like Play On Awake of AudioSource, play the connected \"Default Introloop Audio\" asset " + "automatically on Awake().")] [SerializeField] private bool playOnAwake; [SerializeField] private IntroloopSettings introloopSettings; private readonly float[] fadeLength = new float[2]; private readonly float[] towardsVolume = new float[2]; private readonly IntroloopTrack[] twoTracks = new IntroloopTrack[2]; private readonly bool[] willPause = new bool[2]; private readonly bool[] willStop = new bool[2]; /// /// It will change to 0 on first . /// 0 is the first track. /// private int currentTrack = 1; private bool importantChildrenCreated; private IntroloopAudio previousPlay; private float timeBeforePause; /// /// Works like property. You can set this to any /// for it to be used when you call or next. /// public IntroloopAudio DefaultIntroloopAudio { get => defaultIntroloopAudio; set => defaultIntroloopAudio = value; } /// /// Works like , play the connected /// automatically on Awake. /// public bool PlayOnAwake { get => playOnAwake; set => playOnAwake = value; } /// /// /// When it would spawn 4 for the first time on Start(), read out the /// fields from this reference and copy them to all 4. Assigning this after /// it had already spawned underlying 4 has no effect. /// /// /// To apply this template again after Start(), use . /// The argument could be this or any other . /// /// /// /// The does not need to be since it just /// need to read the fields out for copy. Also it does not need to be anywhere on the scene, /// it can come from a prefab with in your project. /// public AudioSource TemplateSource { get { if (templateSource != null) { return templateSource; } // A fallback to pickup nearby AudioSource as a template. templateSource = GetComponent(); if (templateSource != null) { return templateSource; } // If no template source, make it a high priority + 2D source by default. templateSource = gameObject.AddComponent(); SetupDefaultAudioSourceForIntroloop(templateSource); return templateSource; } set => templateSource = value; } /// /// If you wish to do something that affects all 4 that Introloop utilize at once, /// do a foreach on this property. /// /// /// You should not use this in Awake, as Introloop might still /// not yet spawn the . /// public IEnumerable InternalAudioSources { get { if (twoTracks == null) { throw new IntroloopException( "Child game objects of Introloop player is not yet initialized. " + "Please avoid accessing internal AudioSource on Awake."); } foreach (var aSource in twoTracks[0].AllAudioSources) { yield return aSource; } foreach (var aSource in twoTracks[1].AllAudioSources) { yield return aSource; } } } /// /// /// Get a convenient singleton instance of from anywhere in your code. /// It has DontDestroyOnLoad applied. /// /// /// Before calling this for the first time, call /// first to setup its from script. (It does not exist until runtime, you /// cannot setup the template ahead of time unlike non-singleton instances.) /// /// public static IntroloopPlayer Instance { get { if (instance != null) { return instance; } instance = InstantiateSingletonPlayer(singletonInstanceTemplateSource); instance.name = IntroloopSettings.singletonObjectPrefix + instance.name; return instance; } } private void Awake() { if (introloopSettings == null) { introloopSettings = new IntroloopSettings(); } CreateImportantChildren(); if (playOnAwake) { Play(); } } private void Start() { TemplateSource.enabled = false; ApplyAudioSource(TemplateSource); } private void Update() { FadeUpdate(); twoTracks[0].SchedulingUpdate(); twoTracks[1].SchedulingUpdate(); } /// /// /// This is a dirty workaround for the bug in 2019.1+ where on game minimize or /// pause, all will be lost. /// /// /// I confirmed it is not a problem in 2018.4 LTS. /// The ideal fix is to call Pause just before the game goes to minimize then /// Resume after we comeback to reschedule. /// /// /// However at this callback Pause does not work, as all audio are already on its way to pausing. /// So an another approach is that we will remember the time just before the pause, and the play again /// after coming back using that time. The Seek method can be used instead of Play here so you don't have to specify the /// previous audio. /// /// /// Please see : https://forum.unity.com/threads/introloop-easily-play-looping-music-with-intro-section-v4-0-0-2019.378370/#post-4793741 /// Track the case here : https://fogbugz.unity3d.com/default.asp?1151637_4i53coq9v07qctp1 /// /// public void OnApplicationPause(bool paused) { if (paused) { timeBeforePause = GetPlayheadTime(); } else { Seek(timeBeforePause); } } private protected static T InstantiateSingletonPlayer(AudioSource templateOfTemplateSource) where T : IntroloopPlayer { var type = typeof(T); var typeString = type.Name; var newIntroloopPlayerObject = new GameObject(typeString); var playerComponent = newIntroloopPlayerObject.AddComponent(); var templateAudioSource = newIntroloopPlayerObject.AddComponent(); playerComponent.TemplateSource = templateOfTemplateSource; SetupDefaultAudioSourceForIntroloop(templateAudioSource); if (templateOfTemplateSource != null) { CopyAudioSourceFields(templateAudioSource, templateOfTemplateSource); } DontDestroyOnLoad(newIntroloopPlayerObject); playerComponent.CreateImportantChildren(); return playerComponent; } private void CreateImportantChildren() { if (importantChildrenCreated) { return; } // These are all the components that make this plugin works. Basically 4 AudioSources with special control script // to juggle music file carefully, stop/pause/resume gracefully while Introloop-ing. var musicPlayerTransform = transform; var musicTrack1 = new GameObject(); musicTrack1.AddComponent(); musicTrack1.name = "IntroloopTrack 1"; musicTrack1.transform.parent = musicPlayerTransform; musicTrack1.transform.localPosition = Vector3.zero; twoTracks[0] = musicTrack1.GetComponent(); twoTracks[0].introloopSettings = introloopSettings; var musicTrack2 = new GameObject(); musicTrack2.AddComponent(); musicTrack2.name = "IntroloopTrack 2"; musicTrack2.transform.parent = musicPlayerTransform; musicTrack2.transform.localPosition = Vector3.zero; twoTracks[1] = musicTrack2.GetComponent(); twoTracks[1].introloopSettings = introloopSettings; importantChildrenCreated = true; } private static void SetupDefaultAudioSourceForIntroloop(AudioSource audioSource) { audioSource.spatialBlend = 0; audioSource.priority = 0; } private void FadeUpdate() { //For two main tracks for (var i = 0; i < 2; i++) { var towardsVolumeBgmVolumeApplied = towardsVolume[i]; if (!(Math.Abs(twoTracks[i].FadeVolume - towardsVolumeBgmVolumeApplied) > 0.0001f)) { continue; } // Handles the fade in/out if (fadeLength[i] == 0) { twoTracks[i].FadeVolume = towardsVolumeBgmVolumeApplied; } else { if (twoTracks[i].FadeVolume > towardsVolumeBgmVolumeApplied) { twoTracks[i].FadeVolume -= Time.unscaledDeltaTime / fadeLength[i]; if (twoTracks[i].FadeVolume <= towardsVolumeBgmVolumeApplied) { //Stop the fade twoTracks[i].FadeVolume = towardsVolumeBgmVolumeApplied; } } else { twoTracks[i].FadeVolume += Time.unscaledDeltaTime / fadeLength[i]; if (twoTracks[i].FadeVolume >= towardsVolumeBgmVolumeApplied) { //Stop the fade twoTracks[i].FadeVolume = towardsVolumeBgmVolumeApplied; } } } //Stop check if (willStop[i] && twoTracks[i].FadeVolume == 0) { willStop[i] = false; willPause[i] = false; twoTracks[i].Stop(); UnloadTrack(i); } //Pause check if (willPause[i] && twoTracks[i].FadeVolume == 0) { willStop[i] = false; willPause[i] = false; twoTracks[i].Pause(); //don't unload! } } } private void UnloadTrack(int trackNumber) { //Have to check if other track is using the music or not? //If playing the same song again, //the loading of the next song might come earlier, then got immediately unloaded by this. //Also check for when using different IntroloopAudio with the same source file. //In this case .Music will be not equal, but actually the audioClip inside is the same song. //Note that load/unloading has no effect on "Streaming" audio type. var musicEqualCurrent = twoTracks[trackNumber].Music == twoTracks[(trackNumber + 1) % 2].Music; var clipEqualCurrent = twoTracks[trackNumber].Music != null && twoTracks[(trackNumber + 1) % 2].Music != null && twoTracks[trackNumber].Music.AudioClip == twoTracks[(trackNumber + 1) % 2].Music.AudioClip; //As = AudioSource var isSameSongAsCurrent = musicEqualCurrent || clipEqualCurrent; var musicEqualNext = twoTracks[trackNumber].Music == twoTracks[(trackNumber + 1) % 2].MusicAboutToPlay; var clipEqualNext = twoTracks[trackNumber].Music != null && twoTracks[(trackNumber + 1) % 2].MusicAboutToPlay != null && twoTracks[trackNumber].Music.AudioClip == twoTracks[(trackNumber + 1) % 2].MusicAboutToPlay.AudioClip; var isSameSongAsAboutToPlay = musicEqualNext || clipEqualNext; var usingAndPlaying = twoTracks[(trackNumber + 1) % 2].IsPlaying && isSameSongAsCurrent; if (!usingAndPlaying && !isSameSongAsAboutToPlay) { //If not, it is now safe to unload it //Debug.Log("Unloading"); twoTracks[trackNumber].Unload(); } } internal void ApplyVolumeSettingToAllTracks() { twoTracks[0].ApplyVolume(); twoTracks[1].ApplyVolume(); } /// /// Play asset currently assigned to . /// /// /// /// It applies and /// to the underlying . /// /// /// If an another is playing on this player, /// it could cross-fade between the two if is provided. /// The faded out audio will be unloaded automatically once the fade is finished. /// /// /// /// Fade in/out length to use in seconds. /// /// /// If 0, it uses a small pop removal fade time. /// /// /// If negative, it is immediate. /// /// /// The audio will be unloaded only after it had fade out completely. /// /// /// /// Specify starting point in time instead of starting from the beginning. /// /// /// The time you specify here will be converted to "play head time", Introloop will make the play head /// at the point in time as if you had played for this amount of time before starting. /// /// /// Since conceptually has infinite length, any number that is over looping boundary /// will be wrapped over to the intro boundary in the calculation. (Except that if the audio is non-looping) /// /// /// The time specified here is not taking into account. /// It's an elapsed time as if is 1. /// /// /// /// Thrown when was not assigned. /// public void Play(float fadeLengthSeconds = 0, float startTime = 0) { if (defaultIntroloopAudio == null) { throw new ArgumentNullException(nameof(defaultIntroloopAudio), "Default Introloop Audio was not assigned, but you called " + "Play overload without IntroloopAudio argument."); } Play(defaultIntroloopAudio, fadeLengthSeconds, startTime); } /// /// Play any asset with the argument , /// regardless of asset assigned in . /// /// /// /// It applies and /// to the underlying . /// /// /// If an another is playing on this player, /// it could cross-fade between the two if is provided. /// The faded out audio will be unloaded automatically once the fade is finished. /// /// /// /// A reference to asset file to play. /// /// /// Fade in/out length to use in seconds. /// /// /// If 0, it uses a small pop removal fade time. /// /// /// If negative, it is immediate. /// /// /// The audio will be unloaded only after it had fade out completely. /// /// /// /// Specify starting point in time instead of starting from the beginning. /// /// /// The time you specify here will be converted to "play head time", Introloop will make the play head /// at the point in time as if you had played for this amount of time before starting. /// /// /// Since conceptually has infinite length, any number that is over looping boundary /// will be wrapped over to the intro boundary in the calculation. (Except that if the audio is non-looping) /// /// /// The time specified here is not taking into account. /// It's an elapsed time as if is 1. /// /// /// Thrown when is `null`. public void Play(IntroloopAudio introloopAudio, float fadeLengthSeconds = 0, float startTime = 0) { if (introloopAudio == null) { throw new ArgumentNullException(nameof(introloopAudio), "Played IntroloopAudio is null"); } //Auto cross fade old ones. If no fade length specified, a very very small fade will be used to avoid pops/clicks. Stop(fadeLengthSeconds); var next = (currentTrack + 1) % 2; twoTracks[next].Play(introloopAudio, fadeLengthSeconds == 0 ? false : true, startTime); towardsVolume[next] = 1; fadeLength[next] = TranslateFadeLength(fadeLengthSeconds); currentTrack = next; previousPlay = introloopAudio; } /// /// Similar to overload, but has only a single /// argument so it is able to receive calls from . /// public void Play(IntroloopAudio introloopAudio) { Play(introloopAudio, 0); } /// /// Similar to overload, but has no /// optional arguments so it is able to receive calls from . /// public void Play() { Play(0); } /// /// Move the play head of the currently playing audio to anywhere in terms of elapsed time. /// /// /// /// If it is currently playing, you can instantly move the play head position to anywhere else. /// /// /// /// /// If it is not playing, no effect. (This includes while in paused state, you cannot seek in paused state.) /// /// /// /// /// /// /// An internal implementation is not actually a seek, but a completely new /// with the previous . /// /// /// This is why you cannot seek while in pause, as it actually does a new play for you. /// It is handy because it doesn't require you to remember and specify that audio again. /// /// /// /// /// Introloop will make the play head at the point in time as if you had played for this amount /// of time before starting. /// /// /// The time you specify here will be converted to "play head time", Introloop will make the play head /// at the point in time as if you had played for this amount of time before starting. /// /// /// Since conceptually has infinite length, any number that is over looping boundary /// will be wrapped over to the intro boundary in the calculation. (Except that if the audio is non-looping) /// The time specified here is not taking into account. /// It's an elapsed time as if is 1. /// /// public void Seek(float elapsedTime) { if (!twoTracks[currentTrack].IsPlaying) { return; } twoTracks[currentTrack].Play(previousPlay, false, elapsedTime); towardsVolume[currentTrack] = 1; fadeLength[currentTrack] = 0; } /// /// Stop the currently playing immediately and unload it from memory. /// public void Stop() { willStop[currentTrack] = false; willPause[currentTrack] = false; fadeLength[currentTrack] = 0; twoTracks[currentTrack].FadeVolume = 0; twoTracks[currentTrack].Stop(); UnloadTrack(currentTrack); } /// /// Fading out to stop the currently playing , and unload it from memory /// once it is completely faded out. /// /// /// Fade out length to use in seconds. /// /// /// 0 is a special value that will still apply small pop removal fade time. /// /// /// If negative, this method works like overload. /// /// /// public void Stop(float fadeLengthSeconds) { if (fadeLengthSeconds < 0) { Stop(); } else { willStop[currentTrack] = true; willPause[currentTrack] = false; fadeLength[currentTrack] = TranslateFadeLength(fadeLengthSeconds); towardsVolume[currentTrack] = 0; } } /// /// Pause the currently playing immediately without unloading. /// Call to continue playing. /// public void Pause() { if (twoTracks[currentTrack].IsPausable()) { willStop[currentTrack] = false; willPause[currentTrack] = false; fadeLength[currentTrack] = 0; twoTracks[currentTrack].FadeVolume = 0; twoTracks[currentTrack].Pause(); } } /// /// Fading out to pause the currently playing without unloading. /// Call to continue playing. /// /// /// Fade out length to use in seconds. /// /// /// 0 is a special value that will still apply small pop removal fade time. /// /// /// If negative, this method works like overload. /// /// /// public void Pause(float fadeLengthSeconds) { if (twoTracks[currentTrack].IsPausable()) { if (fadeLengthSeconds < 0) { Pause(); } else { willPause[currentTrack] = true; willStop[currentTrack] = false; fadeLength[currentTrack] = TranslateFadeLength(fadeLengthSeconds); ; towardsVolume[currentTrack] = 0; } } } /// /// Resume playing of previously paused () . /// If currently not pausing, it does nothing. /// /// /// Note that if it is currently "fading to pause", the state is not considered paused /// yet so you can't resume in that time. /// /// /// Fade out length to use in seconds. /// /// /// If 0, it uses a small pop removal fade time. /// /// /// If negative, it resumes immediately. /// /// /// public void Resume(float fadeLengthSeconds = 0) { if (twoTracks[currentTrack].Resume()) { //Resume success willStop[currentTrack] = false; willPause[currentTrack] = false; towardsVolume[currentTrack] = 1; fadeLength[currentTrack] = TranslateFadeLength(fadeLengthSeconds); } } /// /// Zero length is a special value that equals pop removal small fade time. /// Negative length is a special value that equals (real) 0. /// private static float TranslateFadeLength(float fadeLength) { return fadeLength > 0 ? fadeLength : fadeLength < 0 ? 0 : popRemovalFadeTime; } /// /// An experimental feature in the case that you really want the audio to start /// in an instant you call . You must use the same /// that you preload in the next play. /// /// /// /// By normally using and /// it loads the audio the moment you called . /// Introloop waits for an audio to load before playing with a coroutine. /// /// /// (Only if you have in the import settings. /// Otherwise, will be a blocking call.) /// /// /// Introloop can't guarantee that the playback will be instant, /// but your game can continue while it is loading. By using this method before actually calling /// it will instead be instant. /// /// /// This function is special even songs with /// can be loaded in a blocking fashion. (You can put immediately /// in the next line expecting a fully loaded audio.) /// /// /// However be aware that memory is managed less efficiently in the following case : /// Normally Introloop immediately unloads the previous track to minimize memory. /// But if you use then did not call /// with the same afterwards, /// the loaded memory will be unmanaged. /// /// /// (Just like if you tick on your clip and have them /// in a hierarchy somewhere, then did not use it.) /// /// /// Does not work with audio loading type. /// /// public void Preload(IntroloopAudio introloopAudio) { introloopAudio.Preload(); } /// /// This interpretation of a play time could decrease when it goes over /// looping boundary back to intro boundary. Conceptually Introloop audio has infinite length, /// so this time is a bit different from normal sense. /// /// /// /// Think as it as not "elapsed time" but rather the position of the actual playhead, /// expressed in time as if the pitch is 1. /// /// /// For example with pitch enabled, the play head will move slowly, /// and so the time returned from this method respect that slower play head. /// /// /// It is usable with as a start time /// to "restore" the play from remembered time. With only 1 you can stop and /// unload previous song then continue later after reloading it. /// /// /// Common use case includes battle music which resumes the field music afterwards. /// If the battle is memory consuming unloading the field music could help. /// /// public float GetPlayheadTime() { return twoTracks[currentTrack].PlayheadPositionSeconds; } /// /// Assign a different audio mixer group to all underlying . /// public void SetMixerGroup(AudioMixerGroup audioMixerGroup) { foreach (var aSource in InternalAudioSources) { aSource.outputAudioMixerGroup = audioMixerGroup; } } /// /// Call this before the first use of to have the singleton instance /// copy fields from . /// /// /// /// Singleton instance is convenient but you cannot pre-connect like /// a regular instance because it does not exist until runtime. /// /// /// If you had already used the singleton instance before calling this, you can still call /// on the singleton instance to apply different /// settings of . /// /// public static void SetSingletonInstanceTemplateSource(AudioSource templateSource) { singletonInstanceTemplateSource = templateSource; } /// /// /// Copy fields from to all 4 underlying . /// Make it as if they had as a from /// the beginning. (Or you can think this method as a way to late-assign a .) /// /// /// /// The does not need to be since it just /// need to read the fields out for copy. Also it does not need to be anywhere on the scene, /// it can come from a prefab with in your project. /// public void ApplyAudioSource(AudioSource applyFrom) { foreach (var aSource in InternalAudioSources) { CopyAudioSourceFields(aSource, applyFrom); } } private static void CopyAudioSourceFields(AudioSource copyTo, AudioSource copyFrom) { // Pitch is NOT inherited, that could destroy scheduling! // Pitch can only be specified via the IntroloopAudio asset file. copyTo.outputAudioMixerGroup = copyFrom.outputAudioMixerGroup; copyTo.SetCustomCurve(AudioSourceCurveType.CustomRolloff, copyFrom.GetCustomCurve(AudioSourceCurveType.CustomRolloff)); copyTo.SetCustomCurve(AudioSourceCurveType.ReverbZoneMix, copyFrom.GetCustomCurve(AudioSourceCurveType.ReverbZoneMix)); copyTo.SetCustomCurve(AudioSourceCurveType.SpatialBlend, copyFrom.GetCustomCurve(AudioSourceCurveType.SpatialBlend)); copyTo.SetCustomCurve(AudioSourceCurveType.Spread, copyFrom.GetCustomCurve(AudioSourceCurveType.Spread)); // Spatial blend, reverb zone mix, and spread must NOT be copied // since it will overwrite the curve copy above. copyTo.ignoreListenerVolume = copyFrom.ignoreListenerVolume; copyTo.ignoreListenerPause = copyFrom.ignoreListenerPause; copyTo.velocityUpdateMode = copyFrom.velocityUpdateMode; copyTo.panStereo = copyFrom.panStereo; // applyTarget.spatialBlend = applyFrom.spatialBlend; copyTo.spatialize = copyFrom.spatialize; copyTo.spatializePostEffects = copyFrom.spatializePostEffects; // applyTarget.reverbZoneMix = applyFrom.reverbZoneMix; copyTo.bypassEffects = copyFrom.bypassEffects; copyTo.bypassListenerEffects = copyFrom.bypassListenerEffects; copyTo.bypassReverbZones = copyFrom.bypassReverbZones; copyTo.dopplerLevel = copyFrom.dopplerLevel; // applyTarget.spread = applyFrom.spread; copyTo.priority = copyFrom.priority; copyTo.mute = copyFrom.mute; copyTo.minDistance = copyFrom.minDistance; copyTo.maxDistance = copyFrom.maxDistance; } /// /// Each player contains 4 , this method /// returns the current information of the first pair for debugging purpose. /// public string[] GetDebugStringsTrack1() { return twoTracks[0].DebugInformation; } /// /// Each player contains 4 , this method /// returns the current information of the second pair for debugging purpose. /// public string[] GetDebugStringsTrack2() { return twoTracks[1].DebugInformation; } } }