// 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.Diagnostics; using osu.Framework; using osu.Framework.Allocation; using osu.Framework.Audio.Track; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Timing; using osu.Game.Configuration; using osu.Game.Database; using osu.Game.Screens.Play; namespace osu.Game.Beatmaps { /// /// A clock intended to be the single source-of-truth for beatmap timing. /// /// It provides some functionality: /// - Optionally applies (and tracks changes of) beatmap, user, and platform offsets (see ctor argument applyOffsets). /// - Adjusts operations to account for any applied offsets, seeking in raw "beatmap" time values. /// - Exposes track length. /// - Allows changing the source to a new track (for cases like editor track updating). /// public partial class FramedBeatmapClock : Component, IFrameBasedClock, IAdjustableClock, ISourceChangeableClock { private readonly bool applyOffsets; /// /// The length of the underlying beatmap track. Will default to 60 seconds if unavailable. /// public double TrackLength => Track.Length; /// /// The underlying beatmap track, if available. /// public Track Track { get; private set; } = new TrackVirtual(60000); /// /// The total frequency adjustment from pause transforms. Should eventually be handled in a better way. /// public readonly BindableDouble ExternalPauseFrequencyAdjust = new BindableDouble(1); private readonly OffsetCorrectionClock? userGlobalOffsetClock; private readonly OffsetCorrectionClock? platformOffsetClock; private readonly OffsetCorrectionClock? userBeatmapOffsetClock; private readonly IFrameBasedClock finalClockSource; private Bindable? userAudioOffset; private IDisposable? beatmapOffsetSubscription; private readonly DecoupleableInterpolatingFramedClock decoupledClock; [Resolved] private OsuConfigManager config { get; set; } = null!; [Resolved] private RealmAccess realm { get; set; } = null!; [Resolved] private IBindable beatmap { get; set; } = null!; public bool IsRewinding { get; private set; } public bool IsCoupled { get => decoupledClock.IsCoupled; set => decoupledClock.IsCoupled = value; } public FramedBeatmapClock(bool applyOffsets = false) { this.applyOffsets = applyOffsets; // A decoupled clock is used to ensure precise time values even when the host audio subsystem is not reporting // high precision times (on windows there's generally only 5-10ms reporting intervals, as an example). decoupledClock = new DecoupleableInterpolatingFramedClock { IsCoupled = true }; if (applyOffsets) { // Audio timings in general with newer BASS versions don't match stable. // This only seems to be required on windows. We need to eventually figure out why, with a bit of luck. platformOffsetClock = new OffsetCorrectionClock(decoupledClock, ExternalPauseFrequencyAdjust) { Offset = RuntimeInfo.OS == RuntimeInfo.Platform.Windows ? 15 : 0 }; // User global offset (set in settings) should also be applied. userGlobalOffsetClock = new OffsetCorrectionClock(platformOffsetClock, ExternalPauseFrequencyAdjust); // User per-beatmap offset will be applied to this final clock. finalClockSource = userBeatmapOffsetClock = new OffsetCorrectionClock(userGlobalOffsetClock, ExternalPauseFrequencyAdjust); } else { finalClockSource = decoupledClock; } } protected override void LoadComplete() { base.LoadComplete(); if (applyOffsets) { Debug.Assert(userBeatmapOffsetClock != null); Debug.Assert(userGlobalOffsetClock != null); userAudioOffset = config.GetBindable(OsuSetting.AudioOffset); userAudioOffset.BindValueChanged(offset => userGlobalOffsetClock.Offset = offset.NewValue, true); beatmapOffsetSubscription = realm.SubscribeToPropertyChanged( r => r.Find(beatmap.Value.BeatmapInfo.ID)?.UserSettings, settings => settings.Offset, val => { userBeatmapOffsetClock.Offset = val; }); } } protected override void Update() { base.Update(); if (Source != null && Source is not IAdjustableClock && Source.CurrentTime < decoupledClock.CurrentTime) { // InterpolatingFramedClock won't interpolate backwards unless its source has an ElapsedFrameTime. // See https://github.com/ppy/osu-framework/blob/ba1385330cc501f34937e08257e586c84e35d772/osu.Framework/Timing/InterpolatingFramedClock.cs#L91-L93 // This is not always the case here when doing large seeks. // (Of note, this is not an issue if the source is adjustable, as the source is seeked to be in time by DecoupleableInterpolatingFramedClock). // Rather than trying to get around this by fixing the framework clock stack, let's work around it for now. Seek(Source.CurrentTime); } else finalClockSource.ProcessFrame(); if (Clock.ElapsedFrameTime != 0) IsRewinding = Clock.ElapsedFrameTime < 0; } public double TotalAppliedOffset { get { if (!applyOffsets) return 0; Debug.Assert(userGlobalOffsetClock != null); Debug.Assert(userBeatmapOffsetClock != null); Debug.Assert(platformOffsetClock != null); return userGlobalOffsetClock.RateAdjustedOffset + userBeatmapOffsetClock.RateAdjustedOffset + platformOffsetClock.RateAdjustedOffset; } } #region Delegation of IAdjustableClock / ISourceChangeableClock to decoupled clock. public void ChangeSource(IClock? source) { Track = source as Track ?? new TrackVirtual(60000); decoupledClock.ChangeSource(source); } public IClock? Source => decoupledClock.Source; public void Reset() { decoupledClock.Reset(); finalClockSource.ProcessFrame(); } public void Start() { decoupledClock.Start(); finalClockSource.ProcessFrame(); } public void Stop() { decoupledClock.Stop(); finalClockSource.ProcessFrame(); } public bool Seek(double position) { bool success = decoupledClock.Seek(position - TotalAppliedOffset); finalClockSource.ProcessFrame(); return success; } public void ResetSpeedAdjustments() => decoupledClock.ResetSpeedAdjustments(); public double Rate { get => decoupledClock.Rate; set => decoupledClock.Rate = value; } #endregion #region Delegation of IFrameBasedClock to clock with all offsets applied public double CurrentTime => finalClockSource.CurrentTime; public bool IsRunning => finalClockSource.IsRunning; public void ProcessFrame() { // Noop to ensure an external consumer doesn't process the internal clock an extra time. } public double ElapsedFrameTime => finalClockSource.ElapsedFrameTime; public double FramesPerSecond => finalClockSource.FramesPerSecond; public FrameTimeInfo TimeInfo => finalClockSource.TimeInfo; #endregion protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); beatmapOffsetSubscription?.Dispose(); } } }