// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Track; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Audio; using osu.Framework.Graphics.Containers; using osu.Framework.Logging; using osu.Framework.Threading; using osu.Framework.Utils; using osu.Game.Audio.Effects; using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.Database; using osu.Game.Rulesets.Mods; namespace osu.Game.Overlays { /// /// Handles playback of the global music track. /// public partial class MusicController : CompositeDrawable { [Resolved] private BeatmapManager beatmaps { get; set; } = null!; /// /// Point in time after which the current track will be restarted on triggering a "previous track" action. /// private const double restart_cutoff_point = 5000; /// /// Whether the user has requested the track to be paused. Use to determine whether the track is still playing. /// public bool UserPauseRequested { get; private set; } /// /// Whether user control of the global track should be allowed. /// public readonly BindableBool AllowTrackControl = new BindableBool(true); public readonly BindableBool Shuffle = new BindableBool(true); /// /// Fired when the global has changed. /// Includes direction information for display purposes. /// public event Action? TrackChanged; [Resolved] private IBindable beatmap { get; set; } = null!; [Resolved] private IBindable> mods { get; set; } = null!; public DrawableTrack CurrentTrack { get; private set; } = new DrawableTrack(new TrackVirtual(1000)); [Resolved] private RealmAccess realm { get; set; } = null!; private BindableNumber sampleVolume = null!; private readonly BindableDouble audioDuckVolume = new BindableDouble(1); private AudioFilter audioDuckFilter = null!; private readonly Bindable randomSelectAlgorithm = new Bindable(); private readonly List previousRandomSets = new List(); private int randomHistoryDirection; [BackgroundDependencyLoader] private void load(AudioManager audio, OsuConfigManager configManager) { AddInternal(audioDuckFilter = new AudioFilter(audio.TrackMixer)); audio.Tracks.AddAdjustment(AdjustableProperty.Volume, audioDuckVolume); sampleVolume = audio.VolumeSample.GetBoundCopy(); configManager.BindWith(OsuSetting.RandomSelectAlgorithm, randomSelectAlgorithm); } protected override void LoadComplete() { base.LoadComplete(); beatmap.BindValueChanged(b => { if (b.NewValue != null) changeBeatmap(b.NewValue); }, true); mods.BindValueChanged(_ => ResetTrackAdjustments(), true); } /// /// Forcefully reload the current 's track from disk. /// public void ReloadCurrentTrack() { if (current == null) return; changeTrack(); TrackChanged?.Invoke(current, TrackChangeDirection.None); } /// /// Returns whether the beatmap track is playing. /// public bool IsPlaying => CurrentTrack.IsRunning; /// /// Returns whether the beatmap track is loaded. /// public bool TrackLoaded => CurrentTrack.TrackLoaded; private ScheduledDelegate? seekDelegate; public void SeekTo(double position) { seekDelegate?.Cancel(); seekDelegate = Schedule(() => { if (!AllowTrackControl.Value) return; CurrentTrack.Seek(position); }); } /// /// Ensures music is playing, no matter what, unless the user has explicitly paused. /// This means that if the current beatmap has a virtual track (see ) a new beatmap will be selected. /// public void EnsurePlayingSomething() { if (UserPauseRequested) return; if (CurrentTrack.IsDummyDevice || beatmap.Value.BeatmapSetInfo.DeletePending) { if (beatmap.Disabled || !AllowTrackControl.Value) return; Logger.Log($"{nameof(MusicController)} skipping next track to {nameof(EnsurePlayingSomething)}"); NextTrack(allowProtectedTracks: true); } else if (!IsPlaying) { Logger.Log($"{nameof(MusicController)} starting playback to {nameof(EnsurePlayingSomething)}"); Play(); } } /// /// Start playing the current track (if not already playing). /// /// Whether to restart the track from the beginning. /// /// Whether the request to play was issued by the user rather than internally. /// Specifying true will ensure that other methods like /// will resume music playback going forward. /// /// Whether the operation was successful. public bool Play(bool restart = false, bool requestedByUser = false) { if (requestedByUser && !AllowTrackControl.Value) return false; if (requestedByUser) UserPauseRequested = false; if (restart) CurrentTrack.RestartAsync(); else if (!IsPlaying) CurrentTrack.StartAsync(); return true; } /// /// Stop playing the current track and pause at the current position. /// /// /// Whether the request to stop was issued by the user rather than internally. /// Specifying true will ensure that other methods like /// will not resume music playback until the next explicit call to . /// public void Stop(bool requestedByUser = false) { if (requestedByUser && !AllowTrackControl.Value) return; UserPauseRequested |= requestedByUser; if (CurrentTrack.IsRunning) CurrentTrack.StopAsync(); } /// /// Toggle pause / play. /// /// Whether the operation was successful. public bool TogglePause() { if (!AllowTrackControl.Value) return false; if (CurrentTrack.IsRunning) Stop(true); else Play(requestedByUser: true); return true; } /// /// Play the previous track or restart the current track if it's current time below . /// /// Invoked when the operation has been performed successfully. /// Whether to include beatmap sets when navigating. public void PreviousTrack(Action? onSuccess = null, bool allowProtectedTracks = false) => Schedule(() => { PreviousTrackResult res = prev(allowProtectedTracks); if (res != PreviousTrackResult.None) onSuccess?.Invoke(res); }); /// /// Play the previous track or restart the current track if it's current time below . /// /// Whether to include beatmap sets when navigating. /// The that indicate the decided action. private PreviousTrackResult prev(bool allowProtectedTracks) { if (beatmap.Disabled || !AllowTrackControl.Value) return PreviousTrackResult.None; double currentTrackPosition = CurrentTrack.CurrentTime; if (currentTrackPosition >= restart_cutoff_point) { SeekTo(0); return PreviousTrackResult.Restart; } queuedDirection = TrackChangeDirection.Prev; BeatmapSetInfo? playableSet; if (Shuffle.Value) playableSet = getNextRandom(-1, allowProtectedTracks); else { playableSet = getBeatmapSets().AsEnumerable().TakeWhile(i => !i.Equals(current?.BeatmapSetInfo)).LastOrDefault(s => !s.Protected || allowProtectedTracks) ?? getBeatmapSets().AsEnumerable().LastOrDefault(s => !s.Protected || allowProtectedTracks); } if (playableSet != null) { changeBeatmap(beatmaps.GetWorkingBeatmap(playableSet.Beatmaps.First())); restartTrack(); return PreviousTrackResult.Previous; } return PreviousTrackResult.None; } /// /// Play the next random or playlist track. /// /// Invoked when the operation has been performed successfully. /// Whether to include beatmap sets when navigating. /// A of the operation. public void NextTrack(Action? onSuccess = null, bool allowProtectedTracks = false) => Schedule(() => { bool res = next(allowProtectedTracks); if (res) onSuccess?.Invoke(); }); private readonly List duckOperations = new List(); /// /// Applies ducking, attenuating the volume and/or low-pass cutoff of the currently playing track to make headroom for effects (or just to apply an effect). /// /// A which will restore the duck operation when disposed. public IDisposable Duck(DuckParameters? parameters = null) { // Don't duck if samples have no volume, it sounds weird. if (sampleVolume.Value == 0) return new InvokeOnDisposal(() => { }); parameters ??= new DuckParameters(); duckOperations.Add(parameters); DuckParameters volumeOperation = duckOperations.MinBy(p => p.DuckVolumeTo)!; DuckParameters lowPassOperation = duckOperations.MinBy(p => p.DuckCutoffTo)!; audioDuckFilter.CutoffTo(lowPassOperation.DuckCutoffTo, lowPassOperation.DuckDuration, lowPassOperation.DuckEasing); this.TransformBindableTo(audioDuckVolume, volumeOperation.DuckVolumeTo, volumeOperation.DuckDuration, volumeOperation.DuckEasing); return new InvokeOnDisposal(restoreDucking); void restoreDucking() => Schedule(() => { if (!duckOperations.Remove(parameters)) return; DuckParameters? restoreVolumeOperation = duckOperations.MinBy(p => p.DuckVolumeTo); DuckParameters? restoreLowPassOperation = duckOperations.MinBy(p => p.DuckCutoffTo); // If another duck operation is in the list, restore ducking to its level, else reset back to defaults. audioDuckFilter.CutoffTo(restoreLowPassOperation?.DuckCutoffTo ?? AudioFilter.MAX_LOWPASS_CUTOFF, parameters.RestoreDuration, parameters.RestoreEasing); this.TransformBindableTo(audioDuckVolume, restoreVolumeOperation?.DuckVolumeTo ?? 1, parameters.RestoreDuration, parameters.RestoreEasing); }); } /// /// A convenience method that ducks the currently playing track, then after a delay, restores automatically. /// /// A delay in milliseconds which defines how long to delay restoration after ducking completes. /// Parameters defining the ducking operation. public void DuckMomentarily(double delayUntilRestore, DuckParameters? parameters = null) { // Don't duck if samples have no volume, it sounds weird. if (sampleVolume.Value == 0) return; parameters ??= new DuckParameters(); IDisposable duckOperation = Duck(parameters); Scheduler.AddDelayed(() => duckOperation.Dispose(), delayUntilRestore); } private bool next(bool allowProtectedTracks) { if (beatmap.Disabled || !AllowTrackControl.Value) return false; queuedDirection = TrackChangeDirection.Next; BeatmapSetInfo? playableSet; if (Shuffle.Value) playableSet = getNextRandom(1, allowProtectedTracks); else { playableSet = getBeatmapSets().AsEnumerable().SkipWhile(i => !i.Equals(current?.BeatmapSetInfo)) .Where(i => !i.Protected || allowProtectedTracks) .ElementAtOrDefault(1) ?? getBeatmapSets().AsEnumerable().FirstOrDefault(i => !i.Protected || allowProtectedTracks); } var playableBeatmap = playableSet?.Beatmaps.FirstOrDefault(); if (playableBeatmap != null) { changeBeatmap(beatmaps.GetWorkingBeatmap(playableBeatmap)); restartTrack(); return true; } return false; } private BeatmapSetInfo? getNextRandom(int direction, bool allowProtectedTracks) { BeatmapSetInfo result; var possibleSets = getBeatmapSets().AsEnumerable().Where(s => !s.Protected || allowProtectedTracks).ToArray(); if (possibleSets.Length == 0) return null; // condition below checks if the signs of `randomHistoryDirection` and `direction` are opposite and not zero. // if that is the case, it means that the user had previously chosen next track `randomHistoryDirection` times and wants to go back, // or that the user had previously chosen previous track `randomHistoryDirection` times and wants to go forward. // in both cases, it means that we have a history of previous random selections that we can rewind. if (randomHistoryDirection * direction < 0) { Debug.Assert(Math.Abs(randomHistoryDirection) == previousRandomSets.Count); result = previousRandomSets[^1]; previousRandomSets.RemoveAt(previousRandomSets.Count - 1); randomHistoryDirection += direction; return result; } // if the early-return above didn't cover it, it means that we have no history to fall back on // and need to actually choose something random. switch (randomSelectAlgorithm.Value) { case RandomSelectAlgorithm.Random: result = possibleSets[RNG.Next(possibleSets.Length)]; break; case RandomSelectAlgorithm.RandomPermutation: var notYetPlayedSets = possibleSets.Except(previousRandomSets).ToArray(); if (notYetPlayedSets.Length == 0) { notYetPlayedSets = possibleSets; previousRandomSets.Clear(); randomHistoryDirection = 0; } result = notYetPlayedSets[RNG.Next(notYetPlayedSets.Length)]; break; default: throw new ArgumentOutOfRangeException(nameof(randomSelectAlgorithm), randomSelectAlgorithm.Value, "Unsupported random select algorithm"); } previousRandomSets.Add(result); randomHistoryDirection += direction; return result; } private void restartTrack() { // if not scheduled, the previously track will be stopped one frame later (see ScheduleAfterChildren logic in GameBase). // we probably want to move this to a central method for switching to a new working beatmap in the future. Schedule(() => CurrentTrack.RestartAsync()); } private WorkingBeatmap? current; private TrackChangeDirection? queuedDirection; private IQueryable getBeatmapSets() => realm.Realm.All().Where(s => !s.DeletePending); private void changeBeatmap(WorkingBeatmap newWorking) { // This method can potentially be triggered multiple times as it is eagerly fired in next() / prev() to ensure correct execution order // (changeBeatmap must be called before consumers receive the bindable changed event, which is not the case when the local beatmap bindable is updated directly). if (newWorking == current) return; var lastWorking = current; TrackChangeDirection direction = TrackChangeDirection.None; bool audioEquals = newWorking.BeatmapInfo?.AudioEquals(current?.BeatmapInfo) == true; if (current != null) { if (audioEquals) direction = TrackChangeDirection.None; else if (queuedDirection.HasValue) { direction = queuedDirection.Value; queuedDirection = null; } else { // figure out the best direction based on order in playlist. int last = getBeatmapSets().AsEnumerable().TakeWhile(b => !b.Equals(current.BeatmapSetInfo)).Count(); int next = getBeatmapSets().AsEnumerable().TakeWhile(b => !b.Equals(newWorking.BeatmapSetInfo)).Count(); direction = last > next ? TrackChangeDirection.Prev : TrackChangeDirection.Next; } } current = newWorking; if (lastWorking == null || !lastWorking.TryTransferTrack(current)) changeTrack(); TrackChanged?.Invoke(current, direction); ResetTrackAdjustments(); queuedDirection = null; // this will be a noop if coming from the beatmapChanged event. // the exception is local operations like next/prev, where we want to complete loading the track before sending out a change. if (beatmap.Value != current && beatmap is Bindable working) working.Value = current; } private void changeTrack() { var queuedTrack = getQueuedTrack(); var lastTrack = CurrentTrack; lastTrack.Completed -= onTrackCompleted; CurrentTrack = queuedTrack; // At this point we may potentially be in an async context from tests. This is extremely dangerous but we have to make do for now. // CurrentTrack is immediately updated above for situations where a immediate knowledge about the new track is required, // but the mutation of the hierarchy is scheduled to avoid exceptions. Schedule(() => { lastTrack.VolumeTo(0, 500, Easing.Out).Expire(); if (queuedTrack == CurrentTrack) { AddInternal(queuedTrack); queuedTrack.VolumeTo(0).Then().VolumeTo(1, 300, Easing.Out); } else { // If the track has changed since the call to changeTrack, it is safe to dispose the // queued track rather than consume it. queuedTrack.Dispose(); } }); } private DrawableTrack getQueuedTrack() { // Important to keep this in its own method to avoid inadvertently capturing unnecessary variables in the callback. // Can lead to leaks. var queuedTrack = new DrawableTrack(current!.LoadTrack()); queuedTrack.Completed += onTrackCompleted; return queuedTrack; } private void onTrackCompleted() { if (!CurrentTrack.Looping && !beatmap.Disabled && AllowTrackControl.Value) NextTrack(allowProtectedTracks: true); } private bool applyModTrackAdjustments; /// /// Whether mod track adjustments are allowed to be applied. /// public bool ApplyModTrackAdjustments { get => applyModTrackAdjustments; set { if (applyModTrackAdjustments == value) return; applyModTrackAdjustments = value; ResetTrackAdjustments(); } } private AudioAdjustments? modTrackAdjustments; /// /// Resets the adjustments currently applied on and applies the mod adjustments if is true. /// /// /// Does not reset any adjustments applied directly to the beatmap track. /// public void ResetTrackAdjustments() { // todo: we probably want a helper method rather than this. CurrentTrack.RemoveAllAdjustments(AdjustableProperty.Balance); CurrentTrack.RemoveAllAdjustments(AdjustableProperty.Frequency); CurrentTrack.RemoveAllAdjustments(AdjustableProperty.Tempo); CurrentTrack.RemoveAllAdjustments(AdjustableProperty.Volume); if (applyModTrackAdjustments) { CurrentTrack.BindAdjustments(modTrackAdjustments = new AudioAdjustments()); foreach (var mod in mods.Value.OfType()) mod.ApplyToTrack(modTrackAdjustments); } } } public class DuckParameters { /// /// The duration of the ducking transition in milliseconds. /// Defaults to 100 ms. /// public double DuckDuration = 100; /// /// The final volume which should be reached during ducking, when 0 is silent and 1 is original volume. /// Defaults to 25%. /// public double DuckVolumeTo = 0.25; /// /// The low-pass cutoff frequency which should be reached during ducking. If not required, set to . /// Defaults to 300 Hz. /// public int DuckCutoffTo = 300; /// /// The easing curve to be applied during ducking. /// Defaults to . /// public Easing DuckEasing = Easing.Out; /// /// The duration of the restoration transition in milliseconds. /// Defaults to 500 ms. /// public double RestoreDuration = 500; /// /// The easing curve to be applied during restoration. /// Defaults to . /// public Easing RestoreEasing = Easing.In; } public enum TrackChangeDirection { None, Next, Prev } public enum PreviousTrackResult { None, Restart, Previous } }