// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. #nullable disable using System; using System.Diagnostics; using System.Linq; using osu.Framework.Audio.Track; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Transforms; using osu.Framework.Timing; using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; namespace osu.Game.Screens.Edit { /// /// A decoupled clock which adds editor-specific functionality, such as snapping to a user-defined beat divisor. /// public partial class EditorClock : CompositeComponent, IFrameBasedClock, IAdjustableClock, ISourceChangeableClock { public IBindable Track => track; private readonly Bindable track = new Bindable(); public double TrackLength => track.Value?.IsLoaded == true ? track.Value.Length : 60000; public ControlPointInfo ControlPointInfo => Beatmap.ControlPointInfo; public IBeatmap Beatmap { get; set; } private readonly BindableBeatDivisor beatDivisor; private readonly FramedBeatmapClock underlyingClock; private bool playbackFinished; public IBindable SeekingOrStopped => seekingOrStopped; private readonly Bindable seekingOrStopped = new Bindable(true); /// /// Whether a seek is currently in progress. True for the duration of a seek performed via . /// public bool IsSeeking { get; private set; } public EditorClock(IBeatmap beatmap = null, BindableBeatDivisor beatDivisor = null) { Beatmap = beatmap ?? new Beatmap(); this.beatDivisor = beatDivisor ?? new BindableBeatDivisor(); underlyingClock = new FramedBeatmapClock(applyOffsets: true, requireDecoupling: true); AddInternal(underlyingClock); } /// /// Seek to the closest snappable beat from a time. /// /// The raw position which should be seeked around. /// Whether the seek could be performed. public bool SeekSnapped(double position) { var timingPoint = ControlPointInfo.TimingPointAt(position); double beatSnapLength = timingPoint.BeatLength / beatDivisor.Value; // We will be snapping to beats within the timing point position -= timingPoint.Time; // Determine the index from the current timing point of the closest beat to position int closestBeat = (int)Math.Round(position / beatSnapLength); position = timingPoint.Time + closestBeat * beatSnapLength; // Depending on beatSnapLength, we may snap to a beat that is beyond timingPoint's end time, but we want to instead snap to // the next timing point's start time var nextTimingPoint = ControlPointInfo.TimingPoints.FirstOrDefault(t => t.Time > timingPoint.Time); if (position > nextTimingPoint?.Time) position = nextTimingPoint.Time; return Seek(position); } /// /// Seeks backwards by one beat length. /// /// Whether to snap to the closest beat after seeking. /// The relative amount (magnitude) which should be seeked. public void SeekBackward(bool snapped = false, double amount = 1) => seek(-1, snapped, amount + (IsRunning ? 1.5 : 0)); /// /// Seeks forwards by one beat length. /// /// Whether to snap to the closest beat after seeking. /// The relative amount (magnitude) which should be seeked. public void SeekForward(bool snapped = false, double amount = 1) => seek(1, snapped, amount); private void seek(int direction, bool snapped, double amount = 1) { double current = CurrentTimeAccurate; if (amount <= 0) throw new ArgumentException("Value should be greater than zero", nameof(amount)); var timingPoint = ControlPointInfo.TimingPointAt(current); if (direction < 0 && timingPoint.Time == current) // When going backwards and we're at the boundary of two timing points, we compute the seek distance with the timing point which we are seeking into timingPoint = ControlPointInfo.TimingPointAt(current - 1); double seekAmount = timingPoint.BeatLength / beatDivisor.Value * amount; double seekTime = current + seekAmount * direction; if (!snapped || ControlPointInfo.TimingPoints.Count == 0) { SeekSmoothlyTo(seekTime); return; } // We will be snapping to beats within timingPoint seekTime -= timingPoint.Time; // Determine the index from timingPoint of the closest beat to seekTime, accounting for scrolling direction int closestBeat; if (direction > 0) closestBeat = (int)Math.Floor(seekTime / seekAmount); else closestBeat = (int)Math.Ceiling(seekTime / seekAmount); seekTime = timingPoint.Time + closestBeat * seekAmount; // limit forward seeking to only up to the next timing point's start time. var nextTimingPoint = ControlPointInfo.TimingPointAfter(timingPoint.Time); if (seekTime > nextTimingPoint?.Time) seekTime = nextTimingPoint.Time; // Due to the rounding above, we may end up on the current beat. This will effectively cause 0 seeking to happen, but we don't want this. // Instead, we'll go to the next beat in the direction when this is the case if (Precision.AlmostEquals(current, seekTime, 0.5f)) { closestBeat += direction > 0 ? 1 : -1; seekTime = timingPoint.Time + closestBeat * seekAmount; } if (seekTime < timingPoint.Time && !ReferenceEquals(timingPoint, ControlPointInfo.TimingPoints.First())) seekTime = timingPoint.Time; SeekSmoothlyTo(seekTime); } /// /// The current time of this clock, include any active transform seeks performed via . /// public double CurrentTimeAccurate => Transforms.OfType().LastOrDefault()?.EndValue ?? CurrentTime; public double CurrentTime => underlyingClock.CurrentTime; public void Reset() { ClearTransforms(); underlyingClock.Reset(); } public void Start() { ClearTransforms(); if (playbackFinished) underlyingClock.Seek(0); underlyingClock.Start(); } public void Stop() { seekingOrStopped.Value = true; underlyingClock.Stop(); } public bool Seek(double position) { seekingOrStopped.Value = IsSeeking = true; ClearTransforms(); // Ensure the sought point is within the boundaries position = Math.Clamp(position, 0, TrackLength); return underlyingClock.Seek(position); } /// /// Seek smoothly to the provided destination. /// Use to perform an immediate seek. /// /// public void SeekSmoothlyTo(double seekDestination) { seekingOrStopped.Value = true; if (IsRunning) Seek(seekDestination); else { transformSeekTo(seekDestination, transform_time, Easing.OutQuint); } } public void ResetSpeedAdjustments() => underlyingClock.ResetSpeedAdjustments(); double IAdjustableClock.Rate { get => underlyingClock.Rate; set => underlyingClock.Rate = value; } double IClock.Rate => underlyingClock.Rate; public bool IsRunning => underlyingClock.IsRunning; public void ProcessFrame() { // Noop to ensure an external consumer doesn't process the internal clock an extra time. } public double ElapsedFrameTime => underlyingClock.ElapsedFrameTime; public double FramesPerSecond => underlyingClock.FramesPerSecond; public void ChangeSource(IClock source) { track.Value = source as Track; underlyingClock.ChangeSource(source); } public IClock Source => underlyingClock.Source; private const double transform_time = 300; protected override void Update() { base.Update(); // EditorClock wasn't being added in many places. This gives us more certainty that it is. Debug.Assert(underlyingClock.LoadState > LoadState.NotLoaded); playbackFinished = CurrentTime >= TrackLength; if (playbackFinished) { if (IsRunning) underlyingClock.Stop(); if (CurrentTime > TrackLength) underlyingClock.Seek(TrackLength); } updateSeekingState(); } private void updateSeekingState() { if (seekingOrStopped.Value) { IsSeeking &= Transforms.Any(); if (!IsRunning) { // seeking in the editor can happen while the track isn't running. // in this case we always want to expose ourselves as seeking (to avoid sample playback). return; } // we are either running a seek tween or doing an immediate seek. // in the case of an immediate seek the seeking bool will be set to false after one update. // this allows for silencing hit sounds and the likes. seekingOrStopped.Value = IsSeeking; } } private void transformSeekTo(double seek, double duration = 0, Easing easing = Easing.None) => this.TransformTo(this.PopulateTransform(new TransformSeek(), Math.Clamp(seek, 0, TrackLength), duration, easing)); private double currentTime { get => underlyingClock.CurrentTime; set => underlyingClock.Seek(value); } private class TransformSeek : Transform { public override string TargetMember => nameof(currentTime); protected override void Apply(EditorClock clock, double time) => clock.currentTime = valueAt(time); private double valueAt(double time) { if (time < StartTime) return StartValue; if (time >= EndTime) return EndValue; return Interpolation.ValueAt(time, StartValue, EndValue, StartTime, EndTime, Easing); } protected override void ReadIntoStartValue(EditorClock clock) => StartValue = clock.currentTime; } } }