From 694cb8daedf2d8e5757c7b00e5ec6d5276cbbbcc Mon Sep 17 00:00:00 2001 From: polson <302882+polson@users.noreply.github.com> Date: Sat, 11 Jul 2026 09:21:48 -0500 Subject: [PATCH 01/31] Improve audio synchronization --- Assets/Script/Audio/Bass/BassHelpers.cs | 15 +++++++ .../Script/Audio/Bass/BassLatencyProvider.cs | 44 +++++++++++++++++++ .../Audio/Bass/BassLatencyProvider.cs.meta | 2 + Assets/Script/Audio/Bass/BassStemMixer.cs | 16 ++++--- Assets/Script/Playback/SongRunner.cs | 4 +- YARG.Core | 2 +- 6 files changed, 74 insertions(+), 9 deletions(-) create mode 100644 Assets/Script/Audio/Bass/BassLatencyProvider.cs create mode 100644 Assets/Script/Audio/Bass/BassLatencyProvider.cs.meta diff --git a/Assets/Script/Audio/Bass/BassHelpers.cs b/Assets/Script/Audio/Bass/BassHelpers.cs index fe9722d02d..82fe63ad38 100644 --- a/Assets/Script/Audio/Bass/BassHelpers.cs +++ b/Assets/Script/Audio/Bass/BassHelpers.cs @@ -5,6 +5,7 @@ using UnityEngine; using YARG.Core.Audio; using YARG.Core.Logging; +using YARG.Settings; namespace YARG.Audio.BASS { @@ -17,6 +18,9 @@ public static class BassHelpers public const int FADE_TIME_MILLISECONDS = 1000; + public static int ConfiguredPlaybackBufferLength => ClampPlaybackBufferLength( + SettingsManager.Settings?.PlaybackBufferLength.Value ?? 0); + public const int REVERB_SLIDE_IN_MILLISECONDS = 300; public const int REVERB_SLIDE_OUT_MILLISECONDS = 500; @@ -65,6 +69,17 @@ public static class BassHelpers fDryMix = 0.5f, fWetMix = 1.0f, fRoomSize = 0.8f, fDamp = 0.5f, fWidth = 1.0f, lMode = 0 }; + public static int ClampPlaybackBufferLength(int length) + { + int minimumLength = GlobalAudioHandler.MinimumBufferLength; + if (length > 0 && minimumLength > 0 && length < minimumLength) + { + return minimumLength; + } + + return length; + } + public static int FXAddParameters(int streamHandle, EffectType type, IEffectParameter parameters, int priority = 0) { diff --git a/Assets/Script/Audio/Bass/BassLatencyProvider.cs b/Assets/Script/Audio/Bass/BassLatencyProvider.cs new file mode 100644 index 0000000000..87504f3cf7 --- /dev/null +++ b/Assets/Script/Audio/Bass/BassLatencyProvider.cs @@ -0,0 +1,44 @@ +using System; +using ManagedBass; + +namespace YARG.Audio.BASS +{ + /// + /// Provides estimated BASS tempo stream latency in seconds. + /// + internal static class BassLatencyProvider + { + // Default BASS_FX tempo latency: sequence (82 ms) + seek window (14 ms) + overlap (12 ms). + private const double TEMPO_FX_LATENCY_SECONDS = 0.108; + + // Half the update period is the best estimate for latency whose exact position in the period is unknown. + private static double CommandLatency => Math.Max(0, Bass.UpdatePeriod) / 2000.0; + + /// + /// Gets estimated tempo stream latency, including buffered audio, BASS command latency, + /// and BASS_FX tempo processing latency. + /// + public static double GetTempoStreamLatency(int tempoStreamHandle) + { + return GetOutputBufferLatency(tempoStreamHandle) + CommandLatency + TEMPO_FX_LATENCY_SECONDS; + } + + private static double GetOutputBufferLatency(int tempoStreamHandle) + { + double configuredBufferLatency = BassHelpers.ConfiguredPlaybackBufferLength / 1000.0; + if (configuredBufferLatency <= 0) + { + return 0; + } + + int availableBytes = Bass.ChannelGetData(tempoStreamHandle, IntPtr.Zero, (int) DataFlags.Available); + if (availableBytes < 0) + { + return configuredBufferLatency; + } + + double bufferLatency = Bass.ChannelBytes2Seconds(tempoStreamHandle, availableBytes); + return bufferLatency >= 0 ? bufferLatency : configuredBufferLatency; + } + } +} diff --git a/Assets/Script/Audio/Bass/BassLatencyProvider.cs.meta b/Assets/Script/Audio/Bass/BassLatencyProvider.cs.meta new file mode 100644 index 0000000000..b1840f994e --- /dev/null +++ b/Assets/Script/Audio/Bass/BassLatencyProvider.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c87148e4969c4594a8581774f99c3c6e diff --git a/Assets/Script/Audio/Bass/BassStemMixer.cs b/Assets/Script/Audio/Bass/BassStemMixer.cs index 22e154a8e3..e9fe5fa5f5 100644 --- a/Assets/Script/Audio/Bass/BassStemMixer.cs +++ b/Assets/Script/Audio/Bass/BassStemMixer.cs @@ -206,6 +206,11 @@ protected override double GetPosition_Internal() return _songPositionTracker.GetSongPosition(); } + protected override double GetTempoStreamLatency_Internal() + { + return BassLatencyProvider.GetTempoStreamLatency(_tempoStreamHandle); + } + protected override double GetVolume_Internal() { if (!Bass.ChannelGetAttribute(_tempoStreamHandle, ChannelAttribute.Volume, out float volume)) @@ -568,14 +573,11 @@ protected override void SetBufferLength_Internal(int length) private void _BufferSetter(int length) { - // 0 is a special value in BASS that disables buffering. - // Any positive buffer length must be at least the minimum supported limit to prevent errors. - if (length > 0 && length < GlobalAudioHandler.MinimumBufferLength) - { - length = GlobalAudioHandler.MinimumBufferLength; - } + // 0 disables buffering. Positive values must meet BASS minimum buffer requirements. + length = BassHelpers.ClampPlaybackBufferLength(length); + float lengthInSeconds = length / 1000f; - if (!Bass.ChannelSetAttribute(_tempoStreamHandle, ChannelAttribute.Buffer, length)) + if (!Bass.ChannelSetAttribute(_tempoStreamHandle, ChannelAttribute.Buffer, lengthInSeconds)) { YargLogger.LogFormatError("Failed to set playback buffer: {0}!", Bass.LastError); } diff --git a/Assets/Script/Playback/SongRunner.cs b/Assets/Script/Playback/SongRunner.cs index d6846081c4..fee6935873 100644 --- a/Assets/Script/Playback/SongRunner.cs +++ b/Assets/Script/Playback/SongRunner.cs @@ -389,7 +389,9 @@ private void SyncThread() { double audioOffset = SongOffset - (AudioCalibration * SongSpeed); - SyncAudioTime = _mixer.GetPosition(); + double tempoStreamPosition = _mixer.GetPosition(); + double tempoStreamLatency = _mixer.GetTempoStreamLatency(); + SyncAudioTime = tempoStreamPosition - (tempoStreamLatency * RealSongSpeed); SyncVisualTime = GetRelativeInputTime(InputManager.CurrentInputTime) - audioOffset; if (Paused || SyncVisualTime < 0 || SyncVisualTime >= _mixer.Length) diff --git a/YARG.Core b/YARG.Core index 76fb4f264c..1f83dcf331 160000 --- a/YARG.Core +++ b/YARG.Core @@ -1 +1 @@ -Subproject commit 76fb4f264c107b2da1725a2c220fc81af365e075 +Subproject commit 1f83dcf3310d0469754cf76f4c6c616b45311b34 From 010f50e8602e59a0e74fcf800d18f70555eb9db4 Mon Sep 17 00:00:00 2001 From: polson <302882+polson@users.noreply.github.com> Date: Sat, 11 Jul 2026 10:19:38 -0500 Subject: [PATCH 02/31] Refine audio synchronization --- .../Script/Audio/Bass/BassLatencyProvider.cs | 8 +- Assets/Script/Gameplay/GameManager.Debug.cs | 2 +- Assets/Script/Gameplay/GameManager.cs | 30 +++- Assets/Script/Playback/SongRunner.cs | 167 ++++++++++++------ .../Playback/SyncCorrectionCalculator.cs | 152 ++++++++++++++++ .../Playback/SyncCorrectionCalculator.cs.meta | 2 + 6 files changed, 296 insertions(+), 65 deletions(-) create mode 100644 Assets/Script/Playback/SyncCorrectionCalculator.cs create mode 100644 Assets/Script/Playback/SyncCorrectionCalculator.cs.meta diff --git a/Assets/Script/Audio/Bass/BassLatencyProvider.cs b/Assets/Script/Audio/Bass/BassLatencyProvider.cs index 87504f3cf7..3fb9d5e6f4 100644 --- a/Assets/Script/Audio/Bass/BassLatencyProvider.cs +++ b/Assets/Script/Audio/Bass/BassLatencyProvider.cs @@ -8,19 +8,15 @@ namespace YARG.Audio.BASS /// internal static class BassLatencyProvider { - // Default BASS_FX tempo latency: sequence (82 ms) + seek window (14 ms) + overlap (12 ms). - private const double TEMPO_FX_LATENCY_SECONDS = 0.108; - // Half the update period is the best estimate for latency whose exact position in the period is unknown. private static double CommandLatency => Math.Max(0, Bass.UpdatePeriod) / 2000.0; /// - /// Gets estimated tempo stream latency, including buffered audio, BASS command latency, - /// and BASS_FX tempo processing latency. + /// Gets estimated tempo stream latency, including buffered audio and BASS command latency. /// public static double GetTempoStreamLatency(int tempoStreamHandle) { - return GetOutputBufferLatency(tempoStreamHandle) + CommandLatency + TEMPO_FX_LATENCY_SECONDS; + return GetOutputBufferLatency(tempoStreamHandle) + CommandLatency; } private static double GetOutputBufferLatency(int tempoStreamHandle) diff --git a/Assets/Script/Gameplay/GameManager.Debug.cs b/Assets/Script/Gameplay/GameManager.Debug.cs index 424c305f8d..e408cbb0fe 100644 --- a/Assets/Script/Gameplay/GameManager.Debug.cs +++ b/Assets/Script/Gameplay/GameManager.Debug.cs @@ -558,7 +558,7 @@ private void TimingDebug() { using var text = ZString.CreateStringBuilder(true); - text.AppendFormat("Audio/visual difference: {0:0.000000}\n", _songRunner.SyncDelta); + text.AppendFormat("Audio/visual difference: {0:0.000} ms\n", _songRunner.SyncDelta * 1000.0); text.AppendFormat("Resync start delta: {0:0.000000}\n", _songRunner.SyncStartDelta); text.AppendFormat("Resync worst delta: {0:0.000000}\n", _songRunner.SyncWorstDelta); text.AppendFormat("Speed adjustment: {0:0.00}\n", _songRunner.SyncSpeedAdjustment); diff --git a/Assets/Script/Gameplay/GameManager.cs b/Assets/Script/Gameplay/GameManager.cs index 2101c717f0..6df79b2f80 100644 --- a/Assets/Script/Gameplay/GameManager.cs +++ b/Assets/Script/Gameplay/GameManager.cs @@ -84,6 +84,7 @@ public partial class GameManager : MonoBehaviour public bool IsSongStarted { get; private set; } = false; private SongRunner _songRunner; + private float _appliedSongSpeed = float.NaN; /// /// This is not initialized on awake, but rather, in @@ -291,6 +292,7 @@ private void Update() // Update handlers _songRunner.Update(); + ApplySongSpeed(); BeatEventHandler.Update(_songRunner.SongTime, _songRunner.VisualTime); CrowdEventHandler.Update(_songRunner.SongTime); @@ -326,6 +328,7 @@ private void Update() public void SetSongTime(double time, double delayTime = SONG_START_DELAY) { _songRunner.SetSongTime(time, delayTime); + ApplySongSpeed(); BeatEventHandler.Reset(); BackgroundManager.SetTime(_songRunner.SongTime + Song.SongOffsetSeconds); @@ -345,8 +348,7 @@ public void SetSongTime(double time, double delayTime = SONG_START_DELAY) public void SetSongSpeed(float speed) { _songRunner.SetSongSpeed(speed); - - BackgroundManager.SetSpeed(_songRunner.SongSpeed); + ApplySongSpeed(); } public int GetMixerFFTData(float[] buffer, int fftSize, bool complex) @@ -363,18 +365,30 @@ public void AdjustSongSpeed(float deltaSpeed) { _songRunner.AdjustSongSpeed(deltaSpeed); - // Only scale the player speed in practice - if (IsPractice && _songRunner.SongSpeed >= 1) + ApplySongSpeed(); + } + + private void ApplySongSpeed() + { + float speed = _songRunner.SongSpeed; + if (Mathf.Approximately(speed, _appliedSongSpeed)) + { + return; + } + + _appliedSongSpeed = speed; + + // Only scale the player speed in practice. + if (IsPractice && _players != null) { - // Scale only if the speed is greater than 1 - var speed = _songRunner.SongSpeed >= 1 ? _songRunner.SongSpeed : 1; + float engineSpeed = speed >= 1 ? speed : 1; foreach (var player in _players) { - player.BaseEngine.SetSpeed(speed); + player.BaseEngine.SetSpeed(engineSpeed); } } - BackgroundManager.SetSpeed(_songRunner.SongSpeed); + BackgroundManager.SetSpeed(speed); } public void Pause(bool showMenu = true) diff --git a/Assets/Script/Playback/SongRunner.cs b/Assets/Script/Playback/SongRunner.cs index fee6935873..f3a3a57c07 100644 --- a/Assets/Script/Playback/SongRunner.cs +++ b/Assets/Script/Playback/SongRunner.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Threading; using Cysharp.Threading.Tasks; using DG.Tweening; @@ -158,10 +159,15 @@ public class SongRunner : IDisposable #region Other state /// - /// The set playback speed of the song. + /// The currently effective playback speed of the song. /// public float SongSpeed { get; private set; } + /// + /// The requested playback speed. The effective speed catches up after tempo-stream latency. + /// + private float _requestedSongSpeed; + /// /// The actual current playback speed of the song. /// @@ -192,6 +198,8 @@ public class SongRunner : IDisposable private bool _pausedForFrameDebugger; private double _forceStartTime = double.NaN; + + private readonly Queue<(double EffectiveTime, float Speed)> _scheduledSpeedChanges = new(); #endregion #region Rewind State @@ -209,6 +217,9 @@ public class SongRunner : IDisposable private volatile float _syncStartDelta; private volatile float _syncWorstDelta; + private readonly SyncCorrectionCalculator _syncCorrection = new(); + private double _syncCorrectionSuppressedUntil = double.NegativeInfinity; + private readonly StemMixer _mixer; public float SyncSpeedAdjustment => _syncSpeedAdjustment; @@ -276,7 +287,8 @@ double songOffset ) { _mixer = mixer; - SongSpeed = songSpeed; + SongSpeed = ClampSongSpeed(songSpeed); + _requestedSongSpeed = SongSpeed; SongOffset = -songOffset; _syncThread = new Thread(SyncThread) { IsBackground = true }; @@ -360,6 +372,11 @@ public void Update() } } + lock (_syncThread) + { + ApplyScheduledSpeedChanges(InputManager.InputUpdateTime); + } + if (Paused) return; @@ -379,23 +396,36 @@ public void Update() private void SyncThread() { - const double INITIAL_SYNC_THRESH = 0.015; - const double ADJUST_SYNC_THRESH = 0.005; - const float SPEED_ADJUSTMENT = 0.05f; + const float SPEED_UPDATE_THRESHOLD = 0.0005f; + double lastSampleTime = InputManager.CurrentInputTime; for (; !_disposed; Thread.Sleep(1)) { lock (_syncThread) { + double currentTime = InputManager.CurrentInputTime; + double elapsedMs = (currentTime - lastSampleTime) * 1000.0; + lastSampleTime = currentTime; + if (elapsedMs <= 0.0) + { + elapsedMs = 1.0; + } + else + { + elapsedMs = Math.Min(elapsedMs, 100.0); + } + double audioOffset = SongOffset - (AudioCalibration * SongSpeed); double tempoStreamPosition = _mixer.GetPosition(); double tempoStreamLatency = _mixer.GetTempoStreamLatency(); + double tempoStreamLatencyMs = Math.Max(1.0, tempoStreamLatency * 1000.0); SyncAudioTime = tempoStreamPosition - (tempoStreamLatency * RealSongSpeed); - SyncVisualTime = GetRelativeInputTime(InputManager.CurrentInputTime) - audioOffset; + SyncVisualTime = GetRelativeInputTime(currentTime) - audioOffset; if (Paused || SyncVisualTime < 0 || SyncVisualTime >= _mixer.Length) { + _syncCorrection.SuppressAdjustment(elapsedMs, tempoStreamLatencyMs, _syncSpeedAdjustment); continue; } @@ -406,30 +436,21 @@ private void SyncThread() if (SyncAudioTime >= _mixer.Length) { + _syncCorrection.SuppressAdjustment(elapsedMs, tempoStreamLatencyMs, _syncSpeedAdjustment); continue; } - // Account for song speed - double initialThreshold = INITIAL_SYNC_THRESH * SongSpeed; - double adjustThreshold = ADJUST_SYNC_THRESH * SongSpeed; - - // Check the difference between visual and audio times double delta = SyncVisualTime - SyncAudioTime; - double deltaAbs = Math.Abs(delta); - // Don't sync if below the initial sync threshold, and we haven't adjusted the speed - if (_syncSpeedMultiplier == 0 && deltaAbs < initialThreshold) - continue; - - // We're now syncing, determine how much to adjust the song speed by - int speedMultiplier = (int) Math.Round(delta / INITIAL_SYNC_THRESH); - if (speedMultiplier == 0) - speedMultiplier = delta > 0 ? 1 : -1; + float adjustment = currentTime < _syncCorrectionSuppressedUntil + ? _syncCorrection.SuppressAdjustment(elapsedMs, tempoStreamLatencyMs, _syncSpeedAdjustment) + : _syncCorrection.CalculateAdjustment(delta, elapsedMs, tempoStreamLatencyMs, + _syncSpeedAdjustment); - // Only change speed when the multiplier changes - if (_syncSpeedMultiplier != speedMultiplier) + int speedMultiplier = Math.Sign(adjustment); + if (speedMultiplier != _syncSpeedMultiplier) { - if (_syncSpeedMultiplier == 0) + if (_syncSpeedMultiplier == 0 && speedMultiplier != 0) { _syncStartDelta = (float) delta; _syncWorstDelta = _syncStartDelta; @@ -440,22 +461,15 @@ private void SyncThread() } _syncSpeedMultiplier = speedMultiplier; - - float adjustment = SPEED_ADJUSTMENT * speedMultiplier; - if (!Mathf.Approximately(adjustment, _syncSpeedAdjustment)) - { - _syncSpeedAdjustment = adjustment; - _mixer.SetSpeed(RealSongSpeed, false); - } } - // No change in speed, check if we're below the threshold - if (deltaAbs < adjustThreshold || - // Also check if we overshot and passed 0 - (delta > 0.0 && _syncStartDelta < 0.0) || - (delta < 0.0 && _syncStartDelta > 0.0)) + bool shouldUpdateSpeed = adjustment == 0f + ? !Mathf.Approximately(adjustment, _syncSpeedAdjustment) + : Math.Abs(adjustment - _syncSpeedAdjustment) >= SPEED_UPDATE_THRESHOLD; + if (shouldUpdateSpeed) { - ResetSync(); + _syncSpeedAdjustment = adjustment; + _mixer.SetSpeed(RealSongSpeed, false); } } } @@ -463,10 +477,7 @@ private void SyncThread() private void ResetSync() { - // Don't reset so that they're easier to see in real time - // _syncStartDelta = 0; - // _syncWorstDelta = 0; - + _syncCorrection.Reset(); _syncSpeedMultiplier = 0; _syncSpeedAdjustment = 0f; _mixer.SetSpeed(RealSongSpeed, true); @@ -477,6 +488,23 @@ public double GetRelativeInputTime(double timeFromInputSystem) return (timeFromInputSystem - InputTimeOffset) * SongSpeed; } + private void ApplyScheduledSpeedChanges(double nowInputSystemTime) + { + while (_scheduledSpeedChanges.Count > 0 && + _scheduledSpeedChanges.Peek().EffectiveTime <= nowInputSystemTime) + { + var change = _scheduledSpeedChanges.Dequeue(); + + // Keep the timeline continuous at the predicted activation time. If the frame + // arrived late, advance from that activation point using the new speed. + double inputTimeAtActivation = GetRelativeInputTime(change.EffectiveTime); + double inputTime = inputTimeAtActivation + + (nowInputSystemTime - change.EffectiveTime) * change.Speed; + SongSpeed = change.Speed; + SetInputBaseAt(inputTime, nowInputSystemTime); + } + } + private void UpdateTimes() { InputTime = GetRelativeInputTime(InputManager.InputUpdateTime); @@ -487,13 +515,18 @@ private void UpdateTimes() } private void SetInputBase(double songTime) + { + SetInputBaseAt(songTime, InputManager.InputUpdateTime); + } + + private void SetInputBaseAt(double songTime, double inputSystemTime) { double previousOffset = InputTimeOffset; double previousInputTime = InputTime; double previousSongTime = SongTime; double previousVisualTime = VisualTime; - InputTimeOffset = InputManager.InputUpdateTime - (songTime / SongSpeed); + InputTimeOffset = inputSystemTime - (songTime / SongSpeed); // Update input times UpdateTimes(); @@ -549,6 +582,12 @@ public void SetSongTime(double time, double delayTime = SONG_START_DELAY) { lock (_syncThread) { + // Seeking flushes the tempo stream, so pending tempo changes no longer have a + // meaningful activation time. Apply the latest requested speed immediately. + _scheduledSpeedChanges.Clear(); + SongSpeed = _requestedSongSpeed; + _syncCorrectionSuppressedUntil = double.NegativeInfinity; + // Set input/song time InitializeSongTime(time, delayTime); @@ -580,17 +619,45 @@ public void SetSongSpeed(float speed) lock (_syncThread) { speed = ClampSongSpeed(speed); + if (Mathf.Approximately(speed, _requestedSongSpeed)) + { + return; + } + + _requestedSongSpeed = speed; + + if (Started && !Paused) + { + double now = InputManager.CurrentInputTime; + double tempoStreamLatency = _mixer.GetTempoStreamLatency(); + double effectiveTime = now + Math.Max(0.0, tempoStreamLatency); + + _scheduledSpeedChanges.Enqueue((effectiveTime, speed)); + _syncCorrection.Reset(); + _syncSpeedMultiplier = 0; + _syncSpeedAdjustment = 0f; + _syncCorrectionSuppressedUntil = Math.Max( + _syncCorrectionSuppressedUntil, + effectiveTime + ); + + // The mixer receives the tempo command now. Only the gameplay/reference + // timeline waits for the measured tempo-stream delay. + _mixer.SetSpeed(speed, true); + return; + } - // Set speed; save old for input offset compensation + double inputTime = InputTime; SongSpeed = speed; + _scheduledSpeedChanges.Clear(); + _syncCorrection.Reset(); + _syncSpeedMultiplier = 0; + _syncSpeedAdjustment = 0f; + _syncCorrectionSuppressedUntil = double.NegativeInfinity; - // Set based on the actual song speed, so as to not break resyncing + // There is no audible tempo-stream delay while inactive or paused. _mixer.SetSpeed(RealSongSpeed, true); - - // Adjust input offset, otherwise input time will desync - // TODO: Pressing and holding left or right in practice will - // cause time to progress much slower than it should - SetInputBaseChecked(InputTime); + SetInputBaseChecked(inputTime); } YargLogger.LogFormatDebug("Set song speed to {0:0.00}.\n" @@ -598,7 +665,7 @@ public void SetSongSpeed(float speed) SongTime, VisualTime, InputTime); } - public void AdjustSongSpeed(float deltaSpeed) => SetSongSpeed(SongSpeed + deltaSpeed); + public void AdjustSongSpeed(float deltaSpeed) => SetSongSpeed(_requestedSongSpeed + deltaSpeed); public void UpdateCalibration() { @@ -770,4 +837,4 @@ public static float ClampSongSpeed(float speed) return Math.Clamp(speed, 10 / 100f, 5000 / 100f); } } -} \ No newline at end of file +} diff --git a/Assets/Script/Playback/SyncCorrectionCalculator.cs b/Assets/Script/Playback/SyncCorrectionCalculator.cs new file mode 100644 index 0000000000..c8d9163abe --- /dev/null +++ b/Assets/Script/Playback/SyncCorrectionCalculator.cs @@ -0,0 +1,152 @@ +using System; + +namespace YARG.Playback +{ + // Speed corrections are delayed by the tempo stream. Keep the corrections sent during + // that delay in the error calculation so the controller does not react to its own stale work. + internal sealed class SyncCorrectionCalculator + { + private const double SYNC_DEADBAND_SECONDS = 0.0015; + + // Correct a larger fraction of the error as the stream delay grows, without making + // short-delay corrections unnecessarily aggressive. + private const float GAIN_DELAY_MS = 100f; + private const float MIN_GAIN = 0.4f; + private const float MAX_GAIN = 0.85f; + + // Limit the correction represented by one latency window. + private const float MAX_SLEW_MS_PER_SEC = 2500f; + private const float SYNC_CLAMP = 0.50f; + + private readonly SyncHistoryBuffer _syncHistory = new(); + + public float CalculateAdjustment(double syncDeltaSeconds, double elapsedMs, double streamDelayMs, + float appliedAdjustment) + { + streamDelayMs = Math.Max(1.0, streamDelayMs); + elapsedMs = Math.Clamp(elapsedMs, 1.0, 100.0); + + RecordAdjustment(elapsedMs, appliedAdjustment, streamDelayMs); + + double errorMs = syncDeltaSeconds * 1000.0 - _syncHistory.RunningContributionMs; + return CalculateRateAdjustment(errorMs, streamDelayMs); + } + + public float SuppressAdjustment(double elapsedMs, double streamDelayMs, float appliedAdjustment) + { + streamDelayMs = Math.Max(1.0, streamDelayMs); + elapsedMs = Math.Clamp(elapsedMs, 1.0, 100.0); + + RecordAdjustment(elapsedMs, appliedAdjustment, streamDelayMs); + return 0f; + } + + public void Reset() + { + _syncHistory.Clear(); + } + + private static float CalculateRateAdjustment(double errorMs, double streamDelayMs) + { + if (Math.Abs(errorMs) < SYNC_DEADBAND_SECONDS * 1000.0) + { + return 0f; + } + + float gain = Math.Clamp((float) streamDelayMs / GAIN_DELAY_MS, MIN_GAIN, MAX_GAIN); + float correctionMs = (float) errorMs * gain; + float maxCorrectionMs = MAX_SLEW_MS_PER_SEC * (float) streamDelayMs / 1000f; + correctionMs = Math.Clamp(correctionMs, -maxCorrectionMs, maxCorrectionMs); + + return Math.Clamp(correctionMs / (float) streamDelayMs, -SYNC_CLAMP, SYNC_CLAMP); + } + + private void RecordAdjustment(double elapsedMs, float adjustment, double streamDelayMs) + { + _syncHistory.Add(elapsedMs, adjustment * elapsedMs); + _syncHistory.TrimToDuration(streamDelayMs); + } + + private sealed class SyncHistoryBuffer + { + private const int CAPACITY = 4096; + + private readonly Entry[] _entries = new Entry[CAPACITY]; + private int _start; + private int _count; + private double _runningDurationMs; + private double _runningContributionMs; + + public double RunningContributionMs => _runningContributionMs; + + public void Clear() + { + _start = 0; + _count = 0; + _runningDurationMs = 0.0; + _runningContributionMs = 0.0; + } + + public void Add(double durationMs, double contributionMs) + { + if (_count == _entries.Length) + { + RemoveOldest(); + } + + _entries[GetIndex(_count)] = new Entry(durationMs, contributionMs); + _count++; + _runningDurationMs += durationMs; + _runningContributionMs += contributionMs; + } + + public void TrimToDuration(double targetDurationMs) + { + targetDurationMs = Math.Max(1.0, targetDurationMs); + + while (_count > 0 && _runningDurationMs > targetDurationMs) + { + double excessDurationMs = _runningDurationMs - targetDurationMs; + ref Entry oldest = ref _entries[_start]; + if (oldest.DurationMs <= excessDurationMs) + { + RemoveOldest(); + continue; + } + + double retainedDurationMs = oldest.DurationMs - excessDurationMs; + double removedRatio = excessDurationMs / oldest.DurationMs; + double removedContributionMs = oldest.ContributionMs * removedRatio; + + oldest.DurationMs = retainedDurationMs; + oldest.ContributionMs -= removedContributionMs; + _runningDurationMs = targetDurationMs; + _runningContributionMs -= removedContributionMs; + } + } + + private void RemoveOldest() + { + Entry oldest = _entries[_start]; + _start = GetIndex(1); + _count--; + _runningDurationMs -= oldest.DurationMs; + _runningContributionMs -= oldest.ContributionMs; + } + + private int GetIndex(int offset) => (_start + offset) % _entries.Length; + + private struct Entry + { + public double DurationMs; + public double ContributionMs; + + public Entry(double durationMs, double contributionMs) + { + DurationMs = durationMs; + ContributionMs = contributionMs; + } + } + } + } +} diff --git a/Assets/Script/Playback/SyncCorrectionCalculator.cs.meta b/Assets/Script/Playback/SyncCorrectionCalculator.cs.meta new file mode 100644 index 0000000000..91920d70e7 --- /dev/null +++ b/Assets/Script/Playback/SyncCorrectionCalculator.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3b4c933ee79546ba84803b45d81c3c8e From 9bb1976e6187633d2918cf6f75e5d300b4ba1930 Mon Sep 17 00:00:00 2001 From: polson <302882+polson@users.noreply.github.com> Date: Sat, 11 Jul 2026 10:55:55 -0500 Subject: [PATCH 03/31] Fix transient sync delta after resume --- Assets/Script/Playback/SongRunner.cs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/Assets/Script/Playback/SongRunner.cs b/Assets/Script/Playback/SongRunner.cs index f3a3a57c07..0dd510f8b2 100644 --- a/Assets/Script/Playback/SongRunner.cs +++ b/Assets/Script/Playback/SongRunner.cs @@ -724,9 +724,14 @@ public void Resume() return; - Paused = false; - UpdateCalibration(); - SetInputBaseChecked(InputTime); + // Rebase the timeline before exposing the resumed state to the sync thread. Otherwise, + // it can sample the input clock with the pre-pause base and treat the pause duration as desync. + lock (_syncThread) + { + UpdateCalibration(); + SetInputBaseChecked(InputTime); + Paused = false; + } YargLogger.LogFormatDebug( "Resumed at song time {0:0.000000}, visual time {1:0.000000}, input time {2:0.000000}.", From 16356efdeb706e9ecb424555a8dae8ed9b4d9c65 Mon Sep 17 00:00:00 2001 From: polson <302882+polson@users.noreply.github.com> Date: Sat, 11 Jul 2026 13:48:11 -0500 Subject: [PATCH 04/31] Improve audio synchronization on resume and fix teardown hangs --- .../Script/Audio/Bass/BassLatencyProvider.cs | 10 ++- Assets/Script/Audio/Bass/BassNormalizer.cs | 57 +++++++++----- Assets/Script/Audio/Bass/BassStemMixer.cs | 12 +++ Assets/Script/Gameplay/GameManager.Debug.cs | 7 +- Assets/Script/Gameplay/GameManager.cs | 37 +++++++-- .../Gameplay/HUD/Pause/PauseMenuManager.cs | 18 ++++- Assets/Script/Playback/SongRunner.cs | 75 ++++++++++++++++--- YARG.Core | 2 +- 8 files changed, 176 insertions(+), 42 deletions(-) diff --git a/Assets/Script/Audio/Bass/BassLatencyProvider.cs b/Assets/Script/Audio/Bass/BassLatencyProvider.cs index 3fb9d5e6f4..2eedf51ba7 100644 --- a/Assets/Script/Audio/Bass/BassLatencyProvider.cs +++ b/Assets/Script/Audio/Bass/BassLatencyProvider.cs @@ -8,7 +8,7 @@ namespace YARG.Audio.BASS /// internal static class BassLatencyProvider { - // Half the update period is the best estimate for latency whose exact position in the period is unknown. + // Estimate command timing at midpoint of BASS's update period. private static double CommandLatency => Math.Max(0, Bass.UpdatePeriod) / 2000.0; /// @@ -19,6 +19,14 @@ public static double GetTempoStreamLatency(int tempoStreamHandle) return GetOutputBufferLatency(tempoStreamHandle) + CommandLatency; } + /// + /// Gets time needed for newly started audio to cross BASS and device output buffers. + /// + public static double GetOutputTransitionLatency() + { + return Math.Max(0, Bass.Info.Latency + Bass.DeviceBufferLength) / 1000.0; + } + private static double GetOutputBufferLatency(int tempoStreamHandle) { double configuredBufferLatency = BassHelpers.ConfiguredPlaybackBufferLength / 1000.0; diff --git a/Assets/Script/Audio/Bass/BassNormalizer.cs b/Assets/Script/Audio/Bass/BassNormalizer.cs index 650563737c..b1adc41de2 100644 --- a/Assets/Script/Audio/Bass/BassNormalizer.cs +++ b/Assets/Script/Audio/Bass/BassNormalizer.cs @@ -39,6 +39,7 @@ public class BassNormalizer : IDisposable // Undocumented BASS attribute to set max processing threads for a mixer private const int MAX_THREADS_ATTRIB = 86017; + private const int GAIN_CALC_SHUTDOWN_TIMEOUT_MS = 1000; private int _mixer; private readonly List _streams = new(); @@ -55,7 +56,11 @@ public class BassNormalizer : IDisposable /// public bool AddStream(Stream stream, params StemMixer.StemInfo[] stemInfos) { - StopGainCalculation(); + if (!StopGainCalculation()) + { + YargLogger.LogError("Previous gain calculation did not stop; refusing to start another one."); + return false; + } if (_mixer == 0) { @@ -172,39 +177,54 @@ private void StartGainCalculation() _gainCalcCts = new CancellationTokenSource(); var token = _gainCalcCts.Token; - var progress = new Progress(gain => + Action progress = gain => { OnGainAdjusted?.Invoke((float) gain); - }); + }; _gainCalcTask = Task.Run(() => CalculateRms(progress, token), token); } - private void StopGainCalculation() + private bool StopGainCalculation() { if (_gainCalcCts == null) { - return; + return true; } - _gainCalcCts.Cancel(); + var cts = _gainCalcCts; + var task = _gainCalcTask; + cts.Cancel(); + + bool stopped = false; try { - _gainCalcTask.GetAwaiter().GetResult(); + stopped = task.Wait(GAIN_CALC_SHUTDOWN_TIMEOUT_MS); } - catch (OperationCanceledException) + catch (AggregateException) { - // Cancellation before the worker starts is expected. + // The worker has stopped; its exception must not abort Unity teardown. + stopped = true; } finally { - _gainCalcCts.Dispose(); - _gainCalcCts = null; - _gainCalcTask = Task.CompletedTask; + if (stopped || task.IsCompleted) + { + cts.Dispose(); + _gainCalcCts = null; + _gainCalcTask = Task.CompletedTask; + } } + + if (!stopped) + { + YargLogger.LogError("Gain calculation did not stop during audio teardown; leaving its BASS handles intact."); + } + + return stopped; } - private void CalculateRms(IProgress progress, CancellationToken token) + private void CalculateRms(Action progress, CancellationToken token) { double cumulativeSumSquares = 0.0; long totalSamples = 0; @@ -240,15 +260,18 @@ private void CalculateRms(IProgress progress, CancellationToken token) float targetGain = (float) Math.Min(MAX_GAIN, TARGET_RMS / rms); float delta = Math.Clamp(targetGain - Gain, -MAX_GAIN_STEP, MAX_GAIN_STEP); Gain += delta; - progress?.Report(Gain); + progress?.Invoke(Gain); } } } public void Dispose() { - // BASS calls cannot be interrupted mid-call. Wait before freeing handles used by the worker. - StopGainCalculation(); + // BASS calls cannot be interrupted mid-call. Do not free handles while the worker is still using them. + if (!StopGainCalculation()) + { + return; + } foreach (var handle in _handles) { @@ -270,4 +293,4 @@ public void Dispose() _handles.Clear(); } } -} \ No newline at end of file +} diff --git a/Assets/Script/Audio/Bass/BassStemMixer.cs b/Assets/Script/Audio/Bass/BassStemMixer.cs index e9fe5fa5f5..c92f586050 100644 --- a/Assets/Script/Audio/Bass/BassStemMixer.cs +++ b/Assets/Script/Audio/Bass/BassStemMixer.cs @@ -143,6 +143,7 @@ protected override int Play_Internal() { return (int) Bass.LastError; } + Bass.ChannelUpdate(_tempoStreamHandle, 0); _didSeek = false; } @@ -211,6 +212,11 @@ protected override double GetTempoStreamLatency_Internal() return BassLatencyProvider.GetTempoStreamLatency(_tempoStreamHandle); } + protected override double GetPlaybackStartLatency_Internal() + { + return BassLatencyProvider.GetOutputTransitionLatency() + _songPositionTracker.AlignmentDelay; + } + protected override double GetVolume_Internal() { if (!Bass.ChannelGetAttribute(_tempoStreamHandle, ChannelAttribute.Volume, out float volume)) @@ -234,6 +240,10 @@ protected override void SetPosition_Internal(double position) } _didSeek = true; _songPositionTracker.Reset(position, delay); + if (!Bass.ChannelSetPosition(_tempoStreamHandle, 0)) + { + YargLogger.LogFormatError("Failed to reset tempo stream position: {0}!", Bass.LastError); + } } if (wasPlaying) @@ -678,6 +688,8 @@ private sealed class SongPositionTracker private double _songStart; private double _delay; + public double AlignmentDelay => _delay; + public SongPositionTracker(int tempoStreamHandle) { _tempoStreamHandle = tempoStreamHandle; diff --git a/Assets/Script/Gameplay/GameManager.Debug.cs b/Assets/Script/Gameplay/GameManager.Debug.cs index e408cbb0fe..eecf193828 100644 --- a/Assets/Script/Gameplay/GameManager.Debug.cs +++ b/Assets/Script/Gameplay/GameManager.Debug.cs @@ -558,11 +558,10 @@ private void TimingDebug() { using var text = ZString.CreateStringBuilder(true); - text.AppendFormat("Audio/visual difference: {0:0.000} ms\n", _songRunner.SyncDelta * 1000.0); - text.AppendFormat("Resync start delta: {0:0.000000}\n", _songRunner.SyncStartDelta); - text.AppendFormat("Resync worst delta: {0:0.000000}\n", _songRunner.SyncWorstDelta); + text.AppendFormat("Audio sync error: {0:0.000} ms\n", _songRunner.SyncDelta * 1000.0); + text.AppendFormat("Resync start delta: {0:0.000} ms\n", _songRunner.SyncStartDelta * 1000f); + text.AppendFormat("Resync worst delta: {0:0.000} ms\n", _songRunner.SyncWorstDelta * 1000f); text.AppendFormat("Speed adjustment: {0:0.00}\n", _songRunner.SyncSpeedAdjustment); - text.AppendFormat("Speed multiplier: {0}\n", _songRunner.SyncSpeedMultiplier); GUILayout.Label(text.AsSpan().TrimEnd('\n').ToString()); } diff --git a/Assets/Script/Gameplay/GameManager.cs b/Assets/Script/Gameplay/GameManager.cs index 6df79b2f80..8af450980d 100644 --- a/Assets/Script/Gameplay/GameManager.cs +++ b/Assets/Script/Gameplay/GameManager.cs @@ -240,19 +240,42 @@ private void OnDestroy() EngineManager.OnCodaEnd -= EndCoda; EngineManager.OnUnisonPhraseSuccess -= OnUnisonPhraseSuccess; - //Restore stem volumes to their original state - foreach (var (stem, state) in _stemStates) + // Stop the audio worker before any teardown callback can touch the mixer or UI. + _songRunner?.Dispose(); + + bool canDisposeMixer = _songRunner == null || _songRunner.SyncThreadStopped; + + // Restore stem volumes to their original state while the mixer is still valid. + if (canDisposeMixer) + { + foreach (var (stem, state) in _stemStates) + { + GlobalAudioHandler.SetVolumeSetting(stem, state.Volume); + } + } + else { - GlobalAudioHandler.SetVolumeSetting(stem, state.Volume); + YargLogger.LogError("Skipping mixer-dependent teardown because the audio sync thread is still running."); } DisposeDebug(); - _pauseMenu.PopAllMenus(); - _mixer?.Dispose(); - _songRunner?.Dispose(); - BackgroundManager.Dispose(); + + // Scene teardown can destroy this object before GameManager.OnDestroy runs. + if (_pauseMenu != null) + { + _pauseMenu.PopAllMenus(); + } + + // Crowd teardown stops SFX through GlobalAudioHandler, so it must happen while audio is initialized. CrowdEventHandler.Dispose(); + if (canDisposeMixer) + { + _mixer?.Dispose(); + } + + BackgroundManager.Dispose(); + // Reset the time scale back, as it would be 0 at this point (because of pausing) Time.timeScale = 1f; diff --git a/Assets/Script/Gameplay/HUD/Pause/PauseMenuManager.cs b/Assets/Script/Gameplay/HUD/Pause/PauseMenuManager.cs index 0681efc811..47d70c611a 100644 --- a/Assets/Script/Gameplay/HUD/Pause/PauseMenuManager.cs +++ b/Assets/Script/Gameplay/HUD/Pause/PauseMenuManager.cs @@ -138,13 +138,25 @@ public void PopAllMenusWithResume() public void PopAllMenus() { + if (this == null) + { + return; + } + foreach (var menu in _openMenus) { - _menus[menu].gameObject.SetActive(false); + if (_menus.TryGetValue(menu, out var menuObject) && menuObject != null) + { + menuObject.gameObject.SetActive(false); + } } _openMenus.Clear(); - gameObject.SetActive(false); + + if (this != null) + { + gameObject.SetActive(false); + } } public void Quit() @@ -185,4 +197,4 @@ public void Skip() GlobalVariables.Instance.LoadScene(SceneIndex.Gameplay); } } -} \ No newline at end of file +} diff --git a/Assets/Script/Playback/SongRunner.cs b/Assets/Script/Playback/SongRunner.cs index 0dd510f8b2..345f1ae337 100644 --- a/Assets/Script/Playback/SongRunner.cs +++ b/Assets/Script/Playback/SongRunner.cs @@ -208,14 +208,19 @@ public class SongRunner : IDisposable #endregion #region Audio syncing + private const int SYNC_THREAD_SHUTDOWN_TIMEOUT_MS = 1000; + private Thread _syncThread; - private bool _disposed; + private volatile bool _disposed; + + public bool SyncThreadStopped { get; private set; } private volatile float _syncSpeedAdjustment; private volatile int _syncSpeedMultiplier; private volatile float _syncStartDelta; private volatile float _syncWorstDelta; + private bool _logNextResumeSyncSample; private readonly SyncCorrectionCalculator _syncCorrection = new(); private double _syncCorrectionSuppressedUntil = double.NegativeInfinity; @@ -315,11 +320,21 @@ private void Dispose(bool disposing) _disposed = true; if (disposing) { - if (_syncThread.IsAlive) + _rewindSource?.Cancel(); + _rewindTween?.Kill(); + + SyncThreadStopped = !_syncThread.IsAlive || + _syncThread.Join(SYNC_THREAD_SHUTDOWN_TIMEOUT_MS); + if (!SyncThreadStopped) { - _syncThread.Join(); + YargLogger.LogError("Audio sync thread did not stop during song teardown."); + return; } + _syncThread = null; + _rewindSource?.Dispose(); + _rewindSource = null; + _rewindTween = null; } } } @@ -441,6 +456,15 @@ private void SyncThread() } double delta = SyncVisualTime - SyncAudioTime; + if (_logNextResumeSyncSample) + { + _logNextResumeSyncSample = false; + YargLogger.LogFormatInfo( + "First resume sync sample: mixer position={0:0.000000}, tempo latency={1:0.000000}, " + + "sync audio={2:0.000000}, sync visual={3:0.000000}, delta={4:+0.000000;-0.000000;0.000000}.", + tempoStreamPosition, tempoStreamLatency, SyncAudioTime, SyncVisualTime, delta + ); + } float adjustment = currentTime < _syncCorrectionSuppressedUntil ? _syncCorrection.SuppressAdjustment(elapsedMs, tempoStreamLatencyMs, _syncSpeedAdjustment) @@ -480,6 +504,8 @@ private void ResetSync() _syncCorrection.Reset(); _syncSpeedMultiplier = 0; _syncSpeedAdjustment = 0f; + _syncStartDelta = 0f; + _syncWorstDelta = 0f; _mixer.SetSpeed(RealSongSpeed, true); } @@ -528,8 +554,10 @@ private void SetInputBaseAt(double songTime, double inputSystemTime) InputTimeOffset = inputSystemTime - (songTime / SongSpeed); - // Update input times - UpdateTimes(); + InputTime = GetRelativeInputTime(inputSystemTime); + SongTime = InputTime + (AudioCalibration * SongSpeed); + VisualTime = InputTime + (VideoCalibration * SongSpeed); + AudioPlaybackTime = _mixer.GetPosition(); YargLogger.LogFormatDebug( "Set input time base.\n" + @@ -596,7 +624,8 @@ public void SetSongTime(double time, double delayTime = SONG_START_DELAY) _mixer.Pause(); // Audio seeking; cannot go negative - double seekTime = time - (delayTime - AudioCalibration) * SongSpeed - SongOffset; + double playbackPreRoll = _mixer.GetPlaybackStartLatency() * SongSpeed; + double seekTime = time - (delayTime - AudioCalibration) * SongSpeed - SongOffset + playbackPreRoll; if (seekTime < 0) { seekTime = 0; @@ -724,13 +753,41 @@ public void Resume() return; - // Rebase the timeline before exposing the resumed state to the sync thread. Otherwise, - // it can sample the input clock with the pre-pause base and treat the pause duration as desync. lock (_syncThread) { UpdateCalibration(); - SetInputBaseChecked(InputTime); + double resumeInputTime = InputTime; + double playbackPreRoll = _mixer.GetPlaybackStartLatency() * SongSpeed; + double targetAudioPosition = resumeInputTime + (AudioCalibration * SongSpeed) - SongOffset; + double seekPosition = Math.Clamp(targetAudioPosition + playbackPreRoll, 0, _mixer.Length); + + double mixerPositionBeforeSeek = _mixer.GetPosition(); + _mixer.SetPosition(seekPosition); + double mixerPositionAfterSeek = _mixer.GetPosition(); + ResetSync(); + Paused = false; + + if (targetAudioPosition >= -playbackPreRoll && targetAudioPosition < _mixer.Length) + { + double mixerPositionBeforePlay = _mixer.GetPosition(); + _mixer.Play(); + double mixerPositionAfterPlay = _mixer.GetPosition(); + _logNextResumeSyncSample = true; + + YargLogger.LogFormatInfo( + "Resume audio setup: target={0:0.000000}, pre-roll={1:0.000000}, requested seek={2:0.000000}, " + + "position before seek={3:0.000000}, after seek={4:0.000000}, before play={5:0.000000}, " + + "after play={6:0.000000}, achieved pre-roll after play={7:+0.000000;-0.000000;0.000000}.", + targetAudioPosition, playbackPreRoll, seekPosition, mixerPositionBeforeSeek, + mixerPositionAfterSeek, mixerPositionBeforePlay, mixerPositionAfterPlay, + mixerPositionAfterPlay - targetAudioPosition + ); + } + + // Seeking and starting playback can take several milliseconds. Anchor after both + // complete so that work does not advance the resumed timeline. + SetInputBaseAt(resumeInputTime, InputManager.CurrentInputTime); } YargLogger.LogFormatDebug( diff --git a/YARG.Core b/YARG.Core index 1f83dcf331..263c4e143e 160000 --- a/YARG.Core +++ b/YARG.Core @@ -1 +1 @@ -Subproject commit 1f83dcf3310d0469754cf76f4c6c616b45311b34 +Subproject commit 263c4e143e1419f274678254b5d3629101c0e5a8 From ebe46f70aa45e68a49844a590f06dc57b75c6ff2 Mon Sep 17 00:00:00 2001 From: polson <302882+polson@users.noreply.github.com> Date: Sat, 11 Jul 2026 14:31:33 -0500 Subject: [PATCH 05/31] Fix audio teardown and latency compensation --- Assets/Script/Gameplay/HUD/Pause/PauseMenuManager.cs | 2 +- Assets/Script/Playback/SongRunner.cs | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/Assets/Script/Gameplay/HUD/Pause/PauseMenuManager.cs b/Assets/Script/Gameplay/HUD/Pause/PauseMenuManager.cs index 47d70c611a..60180d4c42 100644 --- a/Assets/Script/Gameplay/HUD/Pause/PauseMenuManager.cs +++ b/Assets/Script/Gameplay/HUD/Pause/PauseMenuManager.cs @@ -1,4 +1,4 @@ -using Cysharp.Threading.Tasks; +using Cysharp.Threading.Tasks; using System; using System.Collections.Generic; using System.Linq; diff --git a/Assets/Script/Playback/SongRunner.cs b/Assets/Script/Playback/SongRunner.cs index 345f1ae337..bcfd42f980 100644 --- a/Assets/Script/Playback/SongRunner.cs +++ b/Assets/Script/Playback/SongRunner.cs @@ -432,10 +432,12 @@ private void SyncThread() double audioOffset = SongOffset - (AudioCalibration * SongSpeed); - double tempoStreamPosition = _mixer.GetPosition(); double tempoStreamLatency = _mixer.GetTempoStreamLatency(); double tempoStreamLatencyMs = Math.Max(1.0, tempoStreamLatency * 1000.0); - SyncAudioTime = tempoStreamPosition - (tempoStreamLatency * RealSongSpeed); + + // AudioCalibration already accounts for output-device latency. Mixer position must + // stay on the same timeline as dev or latency gets compensated a second time. + SyncAudioTime = _mixer.GetPosition(); SyncVisualTime = GetRelativeInputTime(currentTime) - audioOffset; if (Paused || SyncVisualTime < 0 || SyncVisualTime >= _mixer.Length) From 1610a33b6b2f7c52288358d7cdd4b1bc285f251c Mon Sep 17 00:00:00 2001 From: polson <302882+polson@users.noreply.github.com> Date: Sat, 11 Jul 2026 16:53:45 -0500 Subject: [PATCH 06/31] More improvements --- Assets/Script/Audio/Bass/BassAudioManager.cs | 9 ++++++++- .../Script/Audio/Bass/BassLatencyProvider.cs | 20 +++++++++++++++++-- Assets/Script/Playback/SongRunner.cs | 9 +++++++-- 3 files changed, 33 insertions(+), 5 deletions(-) diff --git a/Assets/Script/Audio/Bass/BassAudioManager.cs b/Assets/Script/Audio/Bass/BassAudioManager.cs index a3a904a556..e06f937371 100644 --- a/Assets/Script/Audio/Bass/BassAudioManager.cs +++ b/Assets/Script/Audio/Bass/BassAudioManager.cs @@ -168,7 +168,7 @@ public BassAudioManager() } var info = Bass.Info; - PlaybackLatency = info.Latency + Bass.DeviceBufferLength + devPeriod; + UpdatePlaybackLatency(); MinimumBufferLength = info.MinBufferLength + Bass.UpdatePeriod; MaximumBufferLength = 5000; @@ -182,6 +182,12 @@ public BassAudioManager() Application.quitting += OnApplicationQuitting; } + private void UpdatePlaybackLatency() + { + double playbackLatency = BassLatencyProvider.GetPlaybackStreamLatency(); + PlaybackLatency = (int) Math.Round(playbackLatency * 1000.0); + } + protected override bool SetOutputDevice(string name) { int currentDevice = Bass.CurrentDevice; @@ -198,6 +204,7 @@ protected override bool SetOutputDevice(string name) _currentDevice?.Dispose(); _currentDevice = bassDevice.Use(); + UpdatePlaybackLatency(); YargLogger.LogFormatInfo("Current BASS Device: {0}", Bass.GetDeviceInfo(Bass.CurrentDevice).Name); diff --git a/Assets/Script/Audio/Bass/BassLatencyProvider.cs b/Assets/Script/Audio/Bass/BassLatencyProvider.cs index 2eedf51ba7..a446e8dec4 100644 --- a/Assets/Script/Audio/Bass/BassLatencyProvider.cs +++ b/Assets/Script/Audio/Bass/BassLatencyProvider.cs @@ -4,13 +4,29 @@ namespace YARG.Audio.BASS { /// - /// Provides estimated BASS tempo stream latency in seconds. + /// Provides estimated BASS playback and tempo stream latencies in seconds. /// internal static class BassLatencyProvider { + private static double DeviceOutputLatency => Math.Max(0, Bass.Info.Latency) / 1000.0; + // Estimate command timing at midpoint of BASS's update period. private static double CommandLatency => Math.Max(0, Bass.UpdatePeriod) / 2000.0; + /// + /// Gets estimated playback stream output latency. + /// + public static double GetPlaybackStreamLatency() + { +#if UNITY_STANDALONE_OSX || UNITY_EDITOR_OSX + // CoreAudio is pull-based; info.Latency already encapsulates the full hardware pipeline. + return DeviceOutputLatency; +#else + double deviceBufferLatency = Math.Max(0, Bass.DeviceBufferLength) / 1000.0; + return DeviceOutputLatency + deviceBufferLatency; +#endif + } + /// /// Gets estimated tempo stream latency, including buffered audio and BASS command latency. /// @@ -24,7 +40,7 @@ public static double GetTempoStreamLatency(int tempoStreamHandle) /// public static double GetOutputTransitionLatency() { - return Math.Max(0, Bass.Info.Latency + Bass.DeviceBufferLength) / 1000.0; + return GetPlaybackStreamLatency(); } private static double GetOutputBufferLatency(int tempoStreamHandle) diff --git a/Assets/Script/Playback/SongRunner.cs b/Assets/Script/Playback/SongRunner.cs index bcfd42f980..62af5df04f 100644 --- a/Assets/Script/Playback/SongRunner.cs +++ b/Assets/Script/Playback/SongRunner.cs @@ -464,7 +464,7 @@ private void SyncThread() YargLogger.LogFormatInfo( "First resume sync sample: mixer position={0:0.000000}, tempo latency={1:0.000000}, " + "sync audio={2:0.000000}, sync visual={3:0.000000}, delta={4:+0.000000;-0.000000;0.000000}.", - tempoStreamPosition, tempoStreamLatency, SyncAudioTime, SyncVisualTime, delta + _mixer.GetPosition(), tempoStreamLatency, SyncAudioTime, SyncVisualTime, delta ); } @@ -620,6 +620,7 @@ public void SetSongTime(double time, double delayTime = SONG_START_DELAY) // Set input/song time InitializeSongTime(time, delayTime); + double seekInputTime = InputTime; // Reset syncing before seeking to prevent speed adjustments from causing issues ResetSync(); @@ -637,10 +638,14 @@ public void SetSongTime(double time, double delayTime = SONG_START_DELAY) { _mixer.SetPosition(seekTime); if (!Paused) + { _mixer.Play(); + } } - UpdateTimes(); + // Seeking and restarting playback can take several milliseconds. Anchor after both + // complete so that command execution does not advance the gameplay timeline alone. + SetInputBaseAt(seekInputTime, InputManager.CurrentInputTime); _seeked = true; } } From ab63ebf87e0726b967ba8bf8f6202acfa10f46f6 Mon Sep 17 00:00:00 2001 From: polson <302882+polson@users.noreply.github.com> Date: Sat, 11 Jul 2026 17:49:27 -0500 Subject: [PATCH 07/31] Remove resume audio debug logs --- Assets/Script/Playback/SongRunner.cs | 25 ------------------------- 1 file changed, 25 deletions(-) diff --git a/Assets/Script/Playback/SongRunner.cs b/Assets/Script/Playback/SongRunner.cs index 62af5df04f..0957a7dabb 100644 --- a/Assets/Script/Playback/SongRunner.cs +++ b/Assets/Script/Playback/SongRunner.cs @@ -220,7 +220,6 @@ public class SongRunner : IDisposable private volatile int _syncSpeedMultiplier; private volatile float _syncStartDelta; private volatile float _syncWorstDelta; - private bool _logNextResumeSyncSample; private readonly SyncCorrectionCalculator _syncCorrection = new(); private double _syncCorrectionSuppressedUntil = double.NegativeInfinity; @@ -458,16 +457,6 @@ private void SyncThread() } double delta = SyncVisualTime - SyncAudioTime; - if (_logNextResumeSyncSample) - { - _logNextResumeSyncSample = false; - YargLogger.LogFormatInfo( - "First resume sync sample: mixer position={0:0.000000}, tempo latency={1:0.000000}, " + - "sync audio={2:0.000000}, sync visual={3:0.000000}, delta={4:+0.000000;-0.000000;0.000000}.", - _mixer.GetPosition(), tempoStreamLatency, SyncAudioTime, SyncVisualTime, delta - ); - } - float adjustment = currentTime < _syncCorrectionSuppressedUntil ? _syncCorrection.SuppressAdjustment(elapsedMs, tempoStreamLatencyMs, _syncSpeedAdjustment) : _syncCorrection.CalculateAdjustment(delta, elapsedMs, tempoStreamLatencyMs, @@ -768,28 +757,14 @@ public void Resume() double targetAudioPosition = resumeInputTime + (AudioCalibration * SongSpeed) - SongOffset; double seekPosition = Math.Clamp(targetAudioPosition + playbackPreRoll, 0, _mixer.Length); - double mixerPositionBeforeSeek = _mixer.GetPosition(); _mixer.SetPosition(seekPosition); - double mixerPositionAfterSeek = _mixer.GetPosition(); ResetSync(); Paused = false; if (targetAudioPosition >= -playbackPreRoll && targetAudioPosition < _mixer.Length) { - double mixerPositionBeforePlay = _mixer.GetPosition(); _mixer.Play(); - double mixerPositionAfterPlay = _mixer.GetPosition(); - _logNextResumeSyncSample = true; - - YargLogger.LogFormatInfo( - "Resume audio setup: target={0:0.000000}, pre-roll={1:0.000000}, requested seek={2:0.000000}, " + - "position before seek={3:0.000000}, after seek={4:0.000000}, before play={5:0.000000}, " + - "after play={6:0.000000}, achieved pre-roll after play={7:+0.000000;-0.000000;0.000000}.", - targetAudioPosition, playbackPreRoll, seekPosition, mixerPositionBeforeSeek, - mixerPositionAfterSeek, mixerPositionBeforePlay, mixerPositionAfterPlay, - mixerPositionAfterPlay - targetAudioPosition - ); } // Seeking and starting playback can take several milliseconds. Anchor after both From 0559bda4d20e9f210062616bab753ac820baa83a Mon Sep 17 00:00:00 2001 From: polson <302882+polson@users.noreply.github.com> Date: Sat, 11 Jul 2026 18:02:39 -0500 Subject: [PATCH 08/31] Document sync correction behavior --- .../Playback/SyncCorrectionCalculator.cs | 58 +++++++++++++++---- 1 file changed, 48 insertions(+), 10 deletions(-) diff --git a/Assets/Script/Playback/SyncCorrectionCalculator.cs b/Assets/Script/Playback/SyncCorrectionCalculator.cs index c8d9163abe..06221da27c 100644 --- a/Assets/Script/Playback/SyncCorrectionCalculator.cs +++ b/Assets/Script/Playback/SyncCorrectionCalculator.cs @@ -2,24 +2,53 @@ namespace YARG.Playback { - // Speed corrections are delayed by the tempo stream. Keep the corrections sent during - // that delay in the error calculation so the controller does not react to its own stale work. + // Keeps playback synchronized by turning the difference between the expected and actual song + // position into a temporary playback-speed adjustment. Speed changes do not take effect + // immediately: they must first pass through the buffered tempo stream. The stream delay is the + // time between sending a speed change and that change reaching playback. Each adjustment is + // therefore recorded for that length of time, along with how much timing error it should + // correct. That pending correction is subtracted from the latest measured error so the + // controller does not repeatedly react to work that is already in progress. + // + // In simplified terms: + // pending correction = sum(applied speed adjustment * elapsed time) + // remaining error = measured timing error - pending correction + // new speed adjustment = remaining error * gain / stream delay + // For example, running 10% faster for 20 ms contributes 2 ms of pending correction. + // + // Once pending work is removed, errors below the deadband are ignored to prevent constant + // speed changes from tiny clock fluctuations. Larger errors are multiplied by a gain based on + // stream delay, converted into a playback-rate adjustment, and capped by SYNC_CLAMP. Callers + // feed the adjustment applied during each elapsed frame back into the next calculation, which + // keeps the pending-correction history aligned with what playback actually received. internal sealed class SyncCorrectionCalculator { + // Ignore sync errors smaller than 1.5 ms to avoid reacting to tiny clock fluctuations. private const double SYNC_DEADBAND_SECONDS = 0.0015; - // Correct a larger fraction of the error as the stream delay grows, without making - // short-delay corrections unnecessarily aggressive. - private const float GAIN_DELAY_MS = 100f; + // Number of milliseconds over which we aim to recover from a timing error. + // For example, recovering 10 ms over 100 ms requires running 10% faster. + private const float CORRECTION_TIME_MS = 100f; + + // Correct at least 40% of the remaining error, even with a short stream delay. private const float MIN_GAIN = 0.4f; + + // Correct at most 85% of the remaining error to reduce overshoot. private const float MAX_GAIN = 0.85f; - // Limit the correction represented by one latency window. - private const float MAX_SLEW_MS_PER_SEC = 2500f; + // Never adjust playback speed by more than 50% in either direction. private const float SYNC_CLAMP = 0.50f; private readonly SyncHistoryBuffer _syncHistory = new(); + /// + /// Calculates the playback-speed change needed to reduce the current sync error. + /// + /// How far playback is behind or ahead, in seconds. + /// Time since the previous calculation, in milliseconds. + /// Time before a speed change reaches audio output, in milliseconds. + /// Speed adjustment used during the elapsed time. + /// Speed adjustment to apply, where 0.1 means 10% faster. public float CalculateAdjustment(double syncDeltaSeconds, double elapsedMs, double streamDelayMs, float appliedAdjustment) { @@ -32,6 +61,13 @@ public float CalculateAdjustment(double syncDeltaSeconds, double elapsedMs, doub return CalculateRateAdjustment(errorMs, streamDelayMs); } + /// + /// Records the current speed adjustment but requests no new sync correction. + /// + /// Time since the previous calculation, in milliseconds. + /// Time before a speed change reaches audio output, in milliseconds. + /// Speed adjustment used during the elapsed time. + /// Zero, to disable sync correction. public float SuppressAdjustment(double elapsedMs, double streamDelayMs, float appliedAdjustment) { streamDelayMs = Math.Max(1.0, streamDelayMs); @@ -41,6 +77,10 @@ public float SuppressAdjustment(double elapsedMs, double streamDelayMs, float ap return 0f; } + /// + /// Clears remembered speed corrections. Call when playback restarts, seeks, or changes speed + /// so corrections from the previous playback state do not affect the new one. + /// public void Reset() { _syncHistory.Clear(); @@ -53,10 +93,8 @@ private static float CalculateRateAdjustment(double errorMs, double streamDelayM return 0f; } - float gain = Math.Clamp((float) streamDelayMs / GAIN_DELAY_MS, MIN_GAIN, MAX_GAIN); + float gain = Math.Clamp((float) streamDelayMs / CORRECTION_TIME_MS, MIN_GAIN, MAX_GAIN); float correctionMs = (float) errorMs * gain; - float maxCorrectionMs = MAX_SLEW_MS_PER_SEC * (float) streamDelayMs / 1000f; - correctionMs = Math.Clamp(correctionMs, -maxCorrectionMs, maxCorrectionMs); return Math.Clamp(correctionMs / (float) streamDelayMs, -SYNC_CLAMP, SYNC_CLAMP); } From 555513d6981b6f2ad401e8234646d648c04c84ec Mon Sep 17 00:00:00 2001 From: polson <302882+polson@users.noreply.github.com> Date: Sat, 11 Jul 2026 18:16:34 -0500 Subject: [PATCH 09/31] Keep sync correction state aligned with playback --- Assets/Script/Playback/SongRunner.cs | 15 ++--- .../Playback/SyncCorrectionCalculator.cs | 58 ++++++++++--------- 2 files changed, 36 insertions(+), 37 deletions(-) diff --git a/Assets/Script/Playback/SongRunner.cs b/Assets/Script/Playback/SongRunner.cs index 0957a7dabb..3b73855051 100644 --- a/Assets/Script/Playback/SongRunner.cs +++ b/Assets/Script/Playback/SongRunner.cs @@ -410,7 +410,6 @@ public void Update() private void SyncThread() { - const float SPEED_UPDATE_THRESHOLD = 0.0005f; double lastSampleTime = InputManager.CurrentInputTime; for (; !_disposed; Thread.Sleep(1)) @@ -441,7 +440,7 @@ private void SyncThread() if (Paused || SyncVisualTime < 0 || SyncVisualTime >= _mixer.Length) { - _syncCorrection.SuppressAdjustment(elapsedMs, tempoStreamLatencyMs, _syncSpeedAdjustment); + _syncCorrection.SuppressAdjustment(elapsedMs, tempoStreamLatencyMs); continue; } @@ -452,15 +451,14 @@ private void SyncThread() if (SyncAudioTime >= _mixer.Length) { - _syncCorrection.SuppressAdjustment(elapsedMs, tempoStreamLatencyMs, _syncSpeedAdjustment); + _syncCorrection.SuppressAdjustment(elapsedMs, tempoStreamLatencyMs); continue; } double delta = SyncVisualTime - SyncAudioTime; float adjustment = currentTime < _syncCorrectionSuppressedUntil - ? _syncCorrection.SuppressAdjustment(elapsedMs, tempoStreamLatencyMs, _syncSpeedAdjustment) - : _syncCorrection.CalculateAdjustment(delta, elapsedMs, tempoStreamLatencyMs, - _syncSpeedAdjustment); + ? _syncCorrection.SuppressAdjustment(elapsedMs, tempoStreamLatencyMs) + : _syncCorrection.CalculateAdjustment(delta, elapsedMs, tempoStreamLatencyMs); int speedMultiplier = Math.Sign(adjustment); if (speedMultiplier != _syncSpeedMultiplier) @@ -478,10 +476,7 @@ private void SyncThread() _syncSpeedMultiplier = speedMultiplier; } - bool shouldUpdateSpeed = adjustment == 0f - ? !Mathf.Approximately(adjustment, _syncSpeedAdjustment) - : Math.Abs(adjustment - _syncSpeedAdjustment) >= SPEED_UPDATE_THRESHOLD; - if (shouldUpdateSpeed) + if (adjustment != _syncSpeedAdjustment) { _syncSpeedAdjustment = adjustment; _mixer.SetSpeed(RealSongSpeed, false); diff --git a/Assets/Script/Playback/SyncCorrectionCalculator.cs b/Assets/Script/Playback/SyncCorrectionCalculator.cs index 06221da27c..be6eb51f28 100644 --- a/Assets/Script/Playback/SyncCorrectionCalculator.cs +++ b/Assets/Script/Playback/SyncCorrectionCalculator.cs @@ -18,9 +18,10 @@ namespace YARG.Playback // // Once pending work is removed, errors below the deadband are ignored to prevent constant // speed changes from tiny clock fluctuations. Larger errors are multiplied by a gain based on - // stream delay, converted into a playback-rate adjustment, and capped by SYNC_CLAMP. Callers - // feed the adjustment applied during each elapsed frame back into the next calculation, which - // keeps the pending-correction history aligned with what playback actually received. + // stream delay, converted into a playback-rate adjustment, and capped by SYNC_CLAMP. Tiny + // changes to the requested adjustment are retained until they cross the update threshold, + // avoiding needless tempo-stream updates while keeping pending-correction history aligned with + // the adjustment requested from playback. internal sealed class SyncCorrectionCalculator { // Ignore sync errors smaller than 1.5 ms to avoid reacting to tiny clock fluctuations. @@ -39,7 +40,10 @@ internal sealed class SyncCorrectionCalculator // Never adjust playback speed by more than 50% in either direction. private const float SYNC_CLAMP = 0.50f; + private const float SPEED_UPDATE_THRESHOLD = 0.0005f; + private readonly SyncHistoryBuffer _syncHistory = new(); + private float _currentAdjustment; /// /// Calculates the playback-speed change needed to reduce the current sync error. @@ -47,18 +51,30 @@ internal sealed class SyncCorrectionCalculator /// How far playback is behind or ahead, in seconds. /// Time since the previous calculation, in milliseconds. /// Time before a speed change reaches audio output, in milliseconds. - /// Speed adjustment used during the elapsed time. /// Speed adjustment to apply, where 0.1 means 10% faster. - public float CalculateAdjustment(double syncDeltaSeconds, double elapsedMs, double streamDelayMs, - float appliedAdjustment) + public float CalculateAdjustment(double syncDeltaSeconds, double elapsedMs, double streamDelayMs) { streamDelayMs = Math.Max(1.0, streamDelayMs); elapsedMs = Math.Clamp(elapsedMs, 1.0, 100.0); - RecordAdjustment(elapsedMs, appliedAdjustment, streamDelayMs); + RecordAdjustment(elapsedMs, streamDelayMs); double errorMs = syncDeltaSeconds * 1000.0 - _syncHistory.RunningContributionMs; - return CalculateRateAdjustment(errorMs, streamDelayMs); + float adjustment = 0f; + if (Math.Abs(errorMs) >= SYNC_DEADBAND_SECONDS * 1000.0) + { + float gain = Math.Clamp((float) streamDelayMs / CORRECTION_TIME_MS, MIN_GAIN, MAX_GAIN); + float correctionMs = (float) errorMs * gain; + adjustment = Math.Clamp(correctionMs / (float) streamDelayMs, -SYNC_CLAMP, SYNC_CLAMP); + } + + if (adjustment != 0f && Math.Abs(adjustment - _currentAdjustment) < SPEED_UPDATE_THRESHOLD) + { + return _currentAdjustment; + } + + _currentAdjustment = adjustment; + return _currentAdjustment; } /// @@ -66,15 +82,15 @@ public float CalculateAdjustment(double syncDeltaSeconds, double elapsedMs, doub /// /// Time since the previous calculation, in milliseconds. /// Time before a speed change reaches audio output, in milliseconds. - /// Speed adjustment used during the elapsed time. /// Zero, to disable sync correction. - public float SuppressAdjustment(double elapsedMs, double streamDelayMs, float appliedAdjustment) + public float SuppressAdjustment(double elapsedMs, double streamDelayMs) { streamDelayMs = Math.Max(1.0, streamDelayMs); elapsedMs = Math.Clamp(elapsedMs, 1.0, 100.0); - RecordAdjustment(elapsedMs, appliedAdjustment, streamDelayMs); - return 0f; + RecordAdjustment(elapsedMs, streamDelayMs); + _currentAdjustment = 0f; + return _currentAdjustment; } /// @@ -84,24 +100,12 @@ public float SuppressAdjustment(double elapsedMs, double streamDelayMs, float ap public void Reset() { _syncHistory.Clear(); + _currentAdjustment = 0f; } - private static float CalculateRateAdjustment(double errorMs, double streamDelayMs) - { - if (Math.Abs(errorMs) < SYNC_DEADBAND_SECONDS * 1000.0) - { - return 0f; - } - - float gain = Math.Clamp((float) streamDelayMs / CORRECTION_TIME_MS, MIN_GAIN, MAX_GAIN); - float correctionMs = (float) errorMs * gain; - - return Math.Clamp(correctionMs / (float) streamDelayMs, -SYNC_CLAMP, SYNC_CLAMP); - } - - private void RecordAdjustment(double elapsedMs, float adjustment, double streamDelayMs) + private void RecordAdjustment(double elapsedMs, double streamDelayMs) { - _syncHistory.Add(elapsedMs, adjustment * elapsedMs); + _syncHistory.Add(elapsedMs, _currentAdjustment * elapsedMs); _syncHistory.TrimToDuration(streamDelayMs); } From 2c746f5ca8c3e8b2a70b887474f3db240a44f813 Mon Sep 17 00:00:00 2001 From: polson <302882+polson@users.noreply.github.com> Date: Sat, 11 Jul 2026 18:24:30 -0500 Subject: [PATCH 10/31] Reduce playback sync polling overhead --- Assets/Script/Playback/SongRunner.cs | 3 +- .../Playback/SyncCorrectionCalculator.cs | 30 ++++++------------- 2 files changed, 11 insertions(+), 22 deletions(-) diff --git a/Assets/Script/Playback/SongRunner.cs b/Assets/Script/Playback/SongRunner.cs index 3b73855051..7ee489da38 100644 --- a/Assets/Script/Playback/SongRunner.cs +++ b/Assets/Script/Playback/SongRunner.cs @@ -410,9 +410,10 @@ public void Update() private void SyncThread() { + const int SYNC_INTERVAL_MS = 10; double lastSampleTime = InputManager.CurrentInputTime; - for (; !_disposed; Thread.Sleep(1)) + for (; !_disposed; Thread.Sleep(SYNC_INTERVAL_MS)) { lock (_syncThread) { diff --git a/Assets/Script/Playback/SyncCorrectionCalculator.cs b/Assets/Script/Playback/SyncCorrectionCalculator.cs index be6eb51f28..c6e3a7d965 100644 --- a/Assets/Script/Playback/SyncCorrectionCalculator.cs +++ b/Assets/Script/Playback/SyncCorrectionCalculator.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; namespace YARG.Playback { @@ -111,11 +112,7 @@ private void RecordAdjustment(double elapsedMs, double streamDelayMs) private sealed class SyncHistoryBuffer { - private const int CAPACITY = 4096; - - private readonly Entry[] _entries = new Entry[CAPACITY]; - private int _start; - private int _count; + private readonly List _entries = new(512); private double _runningDurationMs; private double _runningContributionMs; @@ -123,21 +120,14 @@ private sealed class SyncHistoryBuffer public void Clear() { - _start = 0; - _count = 0; + _entries.Clear(); _runningDurationMs = 0.0; _runningContributionMs = 0.0; } public void Add(double durationMs, double contributionMs) { - if (_count == _entries.Length) - { - RemoveOldest(); - } - - _entries[GetIndex(_count)] = new Entry(durationMs, contributionMs); - _count++; + _entries.Add(new Entry(durationMs, contributionMs)); _runningDurationMs += durationMs; _runningContributionMs += contributionMs; } @@ -146,10 +136,10 @@ public void TrimToDuration(double targetDurationMs) { targetDurationMs = Math.Max(1.0, targetDurationMs); - while (_count > 0 && _runningDurationMs > targetDurationMs) + while (_entries.Count > 0 && _runningDurationMs > targetDurationMs) { double excessDurationMs = _runningDurationMs - targetDurationMs; - ref Entry oldest = ref _entries[_start]; + var oldest = _entries[0]; if (oldest.DurationMs <= excessDurationMs) { RemoveOldest(); @@ -164,20 +154,18 @@ public void TrimToDuration(double targetDurationMs) oldest.ContributionMs -= removedContributionMs; _runningDurationMs = targetDurationMs; _runningContributionMs -= removedContributionMs; + _entries[0] = oldest; } } private void RemoveOldest() { - Entry oldest = _entries[_start]; - _start = GetIndex(1); - _count--; + var oldest = _entries[0]; + _entries.RemoveAt(0); _runningDurationMs -= oldest.DurationMs; _runningContributionMs -= oldest.ContributionMs; } - private int GetIndex(int offset) => (_start + offset) % _entries.Length; - private struct Entry { public double DurationMs; From e7f8a1f7dd8d7b1e2bde3d6af95b3f09ea601388 Mon Sep 17 00:00:00 2001 From: polson <302882+polson@users.noreply.github.com> Date: Sun, 12 Jul 2026 13:39:08 -0500 Subject: [PATCH 11/31] Use ring buffer for sync correction history --- Assets/Script/Helpers/RingBuffer.cs | 70 +++++++++++++++++++ Assets/Script/Helpers/RingBuffer.cs.meta | 3 + .../Playback/SyncCorrectionCalculator.cs | 55 ++++++++------- 3 files changed, 102 insertions(+), 26 deletions(-) create mode 100644 Assets/Script/Helpers/RingBuffer.cs create mode 100644 Assets/Script/Helpers/RingBuffer.cs.meta diff --git a/Assets/Script/Helpers/RingBuffer.cs b/Assets/Script/Helpers/RingBuffer.cs new file mode 100644 index 0000000000..74f377945e --- /dev/null +++ b/Assets/Script/Helpers/RingBuffer.cs @@ -0,0 +1,70 @@ +using System; + +namespace YARG.Helpers +{ + internal sealed class RingBuffer + { + private readonly T[] _items; + private int _start; + + public int Count { get; private set; } + + public T this[int index] + { + get => _items[GetIndex(index)]; + set => _items[GetIndex(index)] = value; + } + + public RingBuffer(int capacity) + { + if (capacity <= 0) + { + throw new ArgumentOutOfRangeException(nameof(capacity)); + } + + _items = new T[capacity]; + } + + public void Add(T item) + { + if (Count == _items.Length) + { + throw new InvalidOperationException("Ring buffer is full."); + } + + _items[(_start + Count) % _items.Length] = item; + Count++; + } + + public T RemoveOldest() + { + if (Count == 0) + { + throw new InvalidOperationException("Ring buffer is empty."); + } + + var item = _items[_start]; + _items[_start] = default; + _start = (_start + 1) % _items.Length; + Count--; + return item; + } + + public void Clear() + { + Array.Clear(_items, 0, _items.Length); + _start = 0; + Count = 0; + } + + private int GetIndex(int index) + { + if (index < 0 || index >= Count) + { + throw new ArgumentOutOfRangeException(nameof(index)); + } + + return (_start + index) % _items.Length; + } + } +} diff --git a/Assets/Script/Helpers/RingBuffer.cs.meta b/Assets/Script/Helpers/RingBuffer.cs.meta new file mode 100644 index 0000000000..2b21c8ccfe --- /dev/null +++ b/Assets/Script/Helpers/RingBuffer.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 2569035e795a44f8acb593ecf7018349 +timeCreated: 1783785600 diff --git a/Assets/Script/Playback/SyncCorrectionCalculator.cs b/Assets/Script/Playback/SyncCorrectionCalculator.cs index c6e3a7d965..e777f0626f 100644 --- a/Assets/Script/Playback/SyncCorrectionCalculator.cs +++ b/Assets/Script/Playback/SyncCorrectionCalculator.cs @@ -1,5 +1,5 @@ using System; -using System.Collections.Generic; +using YARG.Helpers; namespace YARG.Playback { @@ -110,26 +110,34 @@ private void RecordAdjustment(double elapsedMs, double streamDelayMs) _syncHistory.TrimToDuration(streamDelayMs); } + /// + /// Remembers recent playback-speed adjustments while they pass through the buffered audio + /// stream. Each entry records how long an adjustment was active and how much timing error it + /// should correct. The running total represents correction already requested but not yet + /// reflected in playback, preventing the sync controller from correcting the same error + /// again. History older than the current stream delay is removed, including part of an entry + /// when the cutoff falls between updates. + /// private sealed class SyncHistoryBuffer { - private readonly List _entries = new(512); + // 500 entries cover the maximum 5000 ms delay at the 10 ms update cadence. + private readonly RingBuffer _entries = new(512); private double _runningDurationMs; - private double _runningContributionMs; - public double RunningContributionMs => _runningContributionMs; + public double RunningContributionMs { get; private set; } public void Clear() { _entries.Clear(); _runningDurationMs = 0.0; - _runningContributionMs = 0.0; + RunningContributionMs = 0.0; } public void Add(double durationMs, double contributionMs) { _entries.Add(new Entry(durationMs, contributionMs)); _runningDurationMs += durationMs; - _runningContributionMs += contributionMs; + RunningContributionMs += contributionMs; } public void TrimToDuration(double targetDurationMs) @@ -138,38 +146,33 @@ public void TrimToDuration(double targetDurationMs) while (_entries.Count > 0 && _runningDurationMs > targetDurationMs) { - double excessDurationMs = _runningDurationMs - targetDurationMs; + double excessMs = _runningDurationMs - targetDurationMs; var oldest = _entries[0]; - if (oldest.DurationMs <= excessDurationMs) + + if (oldest.DurationMs <= excessMs) { - RemoveOldest(); + _entries.RemoveOldest(); + _runningDurationMs -= oldest.DurationMs; + RunningContributionMs -= oldest.ContributionMs; continue; } - double retainedDurationMs = oldest.DurationMs - excessDurationMs; - double removedRatio = excessDurationMs / oldest.DurationMs; + double removedRatio = excessMs / oldest.DurationMs; double removedContributionMs = oldest.ContributionMs * removedRatio; - oldest.DurationMs = retainedDurationMs; - oldest.ContributionMs -= removedContributionMs; + _entries[0] = new Entry( + oldest.DurationMs - excessMs, + oldest.ContributionMs - removedContributionMs); + _runningDurationMs = targetDurationMs; - _runningContributionMs -= removedContributionMs; - _entries[0] = oldest; + RunningContributionMs -= removedContributionMs; } } - private void RemoveOldest() - { - var oldest = _entries[0]; - _entries.RemoveAt(0); - _runningDurationMs -= oldest.DurationMs; - _runningContributionMs -= oldest.ContributionMs; - } - - private struct Entry + private readonly struct Entry { - public double DurationMs; - public double ContributionMs; + public readonly double DurationMs; + public readonly double ContributionMs; public Entry(double durationMs, double contributionMs) { From 660dad81f5b3155a200327374129a100a3ea2cee Mon Sep 17 00:00:00 2001 From: polson <302882+polson@users.noreply.github.com> Date: Sun, 12 Jul 2026 15:58:45 -0500 Subject: [PATCH 12/31] Improve audio sync and calibration timing --- Assets/Script/Gameplay/GameManager.Debug.cs | 5 +- Assets/Script/Gameplay/GameManager.cs | 4 +- .../HUD/Dragging/DraggableHudManager.cs | 4 +- Assets/Script/Gameplay/Player/TrackPlayer.cs | 2 +- Assets/Script/Helpers/AutoCalibrator.cs | 6 +- Assets/Script/Menu/Calibrator/Calibrator.cs | 55 +++--------------- Assets/Script/Playback/SongRunner.cs | 51 +++++++++++++++- .../Playback/SyncCorrectionCalculator.cs | 12 +++- test_cowbell.wav | Bin 0 -> 11068 bytes 9 files changed, 77 insertions(+), 62 deletions(-) create mode 100644 test_cowbell.wav diff --git a/Assets/Script/Gameplay/GameManager.Debug.cs b/Assets/Script/Gameplay/GameManager.Debug.cs index eecf193828..4e44729f7d 100644 --- a/Assets/Script/Gameplay/GameManager.Debug.cs +++ b/Assets/Script/Gameplay/GameManager.Debug.cs @@ -561,7 +561,8 @@ private void TimingDebug() text.AppendFormat("Audio sync error: {0:0.000} ms\n", _songRunner.SyncDelta * 1000.0); text.AppendFormat("Resync start delta: {0:0.000} ms\n", _songRunner.SyncStartDelta * 1000f); text.AppendFormat("Resync worst delta: {0:0.000} ms\n", _songRunner.SyncWorstDelta * 1000f); - text.AppendFormat("Speed adjustment: {0:0.00}\n", _songRunner.SyncSpeedAdjustment); + text.AppendFormat("Effective speed adjustment: {0:0.000}\n", + _songRunner.EffectiveSyncSpeedAdjustment); GUILayout.Label(text.AsSpan().TrimEnd('\n').ToString()); } @@ -825,4 +826,4 @@ private void VenueDebug() } } } -} \ No newline at end of file +} diff --git a/Assets/Script/Gameplay/GameManager.cs b/Assets/Script/Gameplay/GameManager.cs index 8af450980d..32e28f201a 100644 --- a/Assets/Script/Gameplay/GameManager.cs +++ b/Assets/Script/Gameplay/GameManager.cs @@ -267,7 +267,7 @@ private void OnDestroy() } // Crowd teardown stops SFX through GlobalAudioHandler, so it must happen while audio is initialized. - CrowdEventHandler.Dispose(); + CrowdEventHandler?.Dispose(); if (canDisposeMixer) { @@ -628,7 +628,7 @@ public double GetRelativeInputTime(double timeFromInputSystem) private bool EndSong() { // Dispose the crowd handler - CrowdEventHandler.Dispose(); + CrowdEventHandler?.Dispose(); if (IsPractice) { diff --git a/Assets/Script/Gameplay/HUD/Dragging/DraggableHudManager.cs b/Assets/Script/Gameplay/HUD/Dragging/DraggableHudManager.cs index faa43dd9c6..76accef1e2 100644 --- a/Assets/Script/Gameplay/HUD/Dragging/DraggableHudManager.cs +++ b/Assets/Script/Gameplay/HUD/Dragging/DraggableHudManager.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; using UnityEngine; using UnityEngine.EventSystems; @@ -60,7 +60,7 @@ private void Start() public void RemoveDraggableElement(DraggableHudElement elem) { - _draggableElements.Remove(elem); + _draggableElements?.Remove(elem); } public void SetEditHUD(bool on) diff --git a/Assets/Script/Gameplay/Player/TrackPlayer.cs b/Assets/Script/Gameplay/Player/TrackPlayer.cs index 15a9f623aa..6a2d07214d 100644 --- a/Assets/Script/Gameplay/Player/TrackPlayer.cs +++ b/Assets/Script/Gameplay/Player/TrackPlayer.cs @@ -1036,7 +1036,7 @@ protected virtual void OnNoteHit(int index, TNote note) { if (!Player.Profile.IsBot) { - _autoCalibrator.RecordAccuracy(note.Time); + _autoCalibrator.RecordAccuracy(Engine.CurrentTime, note.Time); } if (!GameManager.IsSeekingReplay) diff --git a/Assets/Script/Helpers/AutoCalibrator.cs b/Assets/Script/Helpers/AutoCalibrator.cs index dab3cb07fa..4d48600fbb 100644 --- a/Assets/Script/Helpers/AutoCalibrator.cs +++ b/Assets/Script/Helpers/AutoCalibrator.cs @@ -93,14 +93,14 @@ private void Reset() } } - public void RecordAccuracy(double noteTime) - { + public void RecordAccuracy(double hitTime, double noteTime) + { if (CalibrationMode == CalibrationType.DISABLED) { return; } - double accuracy = (_gameManager.InputTime - noteTime) * 1000; + double accuracy = (hitTime - noteTime) * 1000; _accuracyList.Add(accuracy); if (_accuracyList.Count < SAMPLE_SIZE) diff --git a/Assets/Script/Menu/Calibrator/Calibrator.cs b/Assets/Script/Menu/Calibrator/Calibrator.cs index 48046e8050..0b867cebae 100644 --- a/Assets/Script/Menu/Calibrator/Calibrator.cs +++ b/Assets/Script/Menu/Calibrator/Calibrator.cs @@ -47,17 +47,10 @@ private enum State private YargPlayer _player; #nullable enable private StemMixer? _mixer; - private double _time; #nullable disable - private bool _wasWhammyEnabled; private bool _hasNavigationScheme; - private void Awake() - { - _wasWhammyEnabled = SettingsManager.Settings.UseWhammyFx.Value; - } - private void Start() { UpdateForState(); @@ -95,7 +88,10 @@ private void OnMenuInput(YargPlayer player, ref GameInput input) _audioCalibrateText.color = Color.green; _audioCalibrateText.text = Localize.Key("Menu.Calibrator.Detected"); - _calibrationTimes.Add(Time.realtimeSinceStartupAsDouble - _time); + // GetPosition() is sampled when this callback runs. Move it back to when the + // input event occurred so input processing time does not affect calibration. + double inputAge = InputManager.CurrentInputTime - input.Time; + _calibrationTimes.Add(_mixer.GetPosition() - inputAge); break; } } @@ -148,19 +144,12 @@ private void UpdateForState() const double VOLUME = 1.0; var file = Path.Combine(Application.streamingAssetsPath, "calibration_music.ogg"); - //Temporarily disable whammy so we don't have to deal with pitch shift delay - SettingsManager.Settings.UseWhammyFx.Value = false; - _mixer = GlobalAudioHandler.LoadCustomFile(file, SPEED, VOLUME); _mixer.SongEnd += OnAudioEnd; _mixer.Play(); - _time = Time.realtimeSinceStartupAsDouble; StartCoroutine(AudioCalibrateCoroutine()); break; case State.AudioDone: - //Restore whammy settings - SettingsManager.Settings.UseWhammyFx.Value = _wasWhammyEnabled; - _audioCalibrateContainer.SetActive(true); CalculateAudioLatency(); SetBackNavigation(); @@ -234,42 +223,12 @@ private void CalculateAudioLatency() return; } - // Get the deviations + // Get each input's signed deviation from the nearest beat. var diffs = new List(); for (int i = 0; i < _calibrationTimes.Count; i++) { - // Our goal is to get as close to 0 as possible - double diff = Math.Abs(_calibrationTimes[i] - SECONDS_PER_BEAT * i); - - // Look forwards - for (int j = 1;; j++) - { - double newDiff = Math.Abs(_calibrationTimes[i] - SECONDS_PER_BEAT * (i + j)); - if (newDiff < diff) - { - diff = newDiff; - } - else - { - break; - } - } - - // Look backwards - for (int j = 1;; j++) - { - double newDiff = Math.Abs(_calibrationTimes[i] - SECONDS_PER_BEAT * (i - j)); - if (newDiff < diff) - { - diff = newDiff; - } - else - { - break; - } - } - - diffs.Add(diff); + double nearestBeat = Math.Round(_calibrationTimes[i] / SECONDS_PER_BEAT) * SECONDS_PER_BEAT; + diffs.Add(_calibrationTimes[i] - nearestBeat); } // Get the median diff --git a/Assets/Script/Playback/SongRunner.cs b/Assets/Script/Playback/SongRunner.cs index 7ee489da38..5cec38d741 100644 --- a/Assets/Script/Playback/SongRunner.cs +++ b/Assets/Script/Playback/SongRunner.cs @@ -217,6 +217,7 @@ public class SongRunner : IDisposable public bool SyncThreadStopped { get; private set; } private volatile float _syncSpeedAdjustment; + private volatile float _effectiveSyncSpeedAdjustment; private volatile int _syncSpeedMultiplier; private volatile float _syncStartDelta; private volatile float _syncWorstDelta; @@ -227,6 +228,7 @@ public class SongRunner : IDisposable private readonly StemMixer _mixer; public float SyncSpeedAdjustment => _syncSpeedAdjustment; + public float EffectiveSyncSpeedAdjustment => _effectiveSyncSpeedAdjustment; public int SyncSpeedMultiplier => _syncSpeedMultiplier; public float SyncStartDelta => _syncStartDelta; public float SyncWorstDelta => _syncWorstDelta; @@ -252,6 +254,7 @@ public class SongRunner : IDisposable #region Seek debugging private bool _seeked; private double _previousInputTime = double.MinValue; + private double _inputSystemTimeFloor = double.NegativeInfinity; #endregion /// @@ -410,7 +413,7 @@ public void Update() private void SyncThread() { - const int SYNC_INTERVAL_MS = 10; + const int SYNC_INTERVAL_MS = 1; double lastSampleTime = InputManager.CurrentInputTime; for (; !_disposed; Thread.Sleep(SYNC_INTERVAL_MS)) @@ -439,9 +442,12 @@ private void SyncThread() SyncAudioTime = _mixer.GetPosition(); SyncVisualTime = GetRelativeInputTime(currentTime) - audioOffset; + StartAudioBeforeSongBegins(currentTime); + if (Paused || SyncVisualTime < 0 || SyncVisualTime >= _mixer.Length) { _syncCorrection.SuppressAdjustment(elapsedMs, tempoStreamLatencyMs); + _effectiveSyncSpeedAdjustment = _syncCorrection.EffectiveAdjustment; continue; } @@ -453,6 +459,7 @@ private void SyncThread() if (SyncAudioTime >= _mixer.Length) { _syncCorrection.SuppressAdjustment(elapsedMs, tempoStreamLatencyMs); + _effectiveSyncSpeedAdjustment = _syncCorrection.EffectiveAdjustment; continue; } @@ -460,6 +467,7 @@ private void SyncThread() float adjustment = currentTime < _syncCorrectionSuppressedUntil ? _syncCorrection.SuppressAdjustment(elapsedMs, tempoStreamLatencyMs) : _syncCorrection.CalculateAdjustment(delta, elapsedMs, tempoStreamLatencyMs); + _effectiveSyncSpeedAdjustment = _syncCorrection.EffectiveAdjustment; int speedMultiplier = Math.Sign(adjustment); if (speedMultiplier != _syncSpeedMultiplier) @@ -486,11 +494,37 @@ private void SyncThread() } } + private void StartAudioBeforeSongBegins(double currentTime) + { + if (Paused || !_mixer.IsPaused || SyncVisualTime >= 0) + { + return; + } + + double playbackStartLatency = _mixer.GetPlaybackStartLatency(); + double playbackPreRoll = playbackStartLatency * SongSpeed; + if (SyncVisualTime < -playbackPreRoll) + { + return; + } + + _mixer.Play(); + + // Starting at audio position zero cannot seek ahead to compensate for output latency. + // Start during the countdown instead, then ignore mixer position until that audio can + // reach output. + _syncCorrectionSuppressedUntil = Math.Max( + _syncCorrectionSuppressedUntil, + currentTime + playbackStartLatency + ); + } + private void ResetSync() { _syncCorrection.Reset(); _syncSpeedMultiplier = 0; _syncSpeedAdjustment = 0f; + _effectiveSyncSpeedAdjustment = 0f; _syncStartDelta = 0f; _syncWorstDelta = 0f; _mixer.SetSpeed(RealSongSpeed, true); @@ -520,7 +554,10 @@ private void ApplyScheduledSpeedChanges(double nowInputSystemTime) private void UpdateTimes() { - InputTime = GetRelativeInputTime(InputManager.InputUpdateTime); + // Re-anchoring can use a newer on-demand input timestamp than this frame's cached + // timestamp. Hold the timeline at that anchor until the frame clock catches up. + double inputSystemTime = Math.Max(InputManager.InputUpdateTime, _inputSystemTimeFloor); + InputTime = GetRelativeInputTime(inputSystemTime); SongTime = InputTime + (AudioCalibration * SongSpeed); VisualTime = InputTime + (VideoCalibration * SongSpeed); @@ -540,6 +577,7 @@ private void SetInputBaseAt(double songTime, double inputSystemTime) double previousVisualTime = VisualTime; InputTimeOffset = inputSystemTime - (songTime / SongSpeed); + _inputSystemTimeFloor = Math.Max(_inputSystemTimeFloor, inputSystemTime); InputTime = GetRelativeInputTime(inputSystemTime); SongTime = InputTime + (AudioCalibration * SongSpeed); @@ -749,7 +787,8 @@ public void Resume() { UpdateCalibration(); double resumeInputTime = InputTime; - double playbackPreRoll = _mixer.GetPlaybackStartLatency() * SongSpeed; + double playbackStartLatency = _mixer.GetPlaybackStartLatency(); + double playbackPreRoll = playbackStartLatency * SongSpeed; double targetAudioPosition = resumeInputTime + (AudioCalibration * SongSpeed) - SongOffset; double seekPosition = Math.Clamp(targetAudioPosition + playbackPreRoll, 0, _mixer.Length); @@ -757,10 +796,16 @@ public void Resume() ResetSync(); Paused = false; + _syncCorrectionSuppressedUntil = double.NegativeInfinity; if (targetAudioPosition >= -playbackPreRoll && targetAudioPosition < _mixer.Length) { _mixer.Play(); + + // Audio is intentionally pre-rolled so it reaches output at the resumed song + // position. Until then, mixer position is ahead by the pre-roll and is not a + // valid synchronization error. + _syncCorrectionSuppressedUntil = InputManager.CurrentInputTime + playbackStartLatency; } // Seeking and starting playback can take several milliseconds. Anchor after both diff --git a/Assets/Script/Playback/SyncCorrectionCalculator.cs b/Assets/Script/Playback/SyncCorrectionCalculator.cs index e777f0626f..eb1efd4d12 100644 --- a/Assets/Script/Playback/SyncCorrectionCalculator.cs +++ b/Assets/Script/Playback/SyncCorrectionCalculator.cs @@ -46,6 +46,8 @@ internal sealed class SyncCorrectionCalculator private readonly SyncHistoryBuffer _syncHistory = new(); private float _currentAdjustment; + public float EffectiveAdjustment => _syncHistory.EffectiveAdjustment; + /// /// Calculates the playback-speed change needed to reduce the current sync error. /// @@ -121,16 +123,18 @@ private void RecordAdjustment(double elapsedMs, double streamDelayMs) private sealed class SyncHistoryBuffer { // 500 entries cover the maximum 5000 ms delay at the 10 ms update cadence. - private readonly RingBuffer _entries = new(512); + private readonly RingBuffer _entries = new(5012); private double _runningDurationMs; public double RunningContributionMs { get; private set; } + public float EffectiveAdjustment { get; private set; } public void Clear() { _entries.Clear(); _runningDurationMs = 0.0; RunningContributionMs = 0.0; + EffectiveAdjustment = 0f; } public void Add(double durationMs, double contributionMs) @@ -167,6 +171,12 @@ public void TrimToDuration(double targetDurationMs) _runningDurationMs = targetDurationMs; RunningContributionMs -= removedContributionMs; } + + // Oldest retained adjustment is reaching output now. Until history spans the + // stream delay, requested changes have not reached output yet. + EffectiveAdjustment = _entries.Count > 0 && _runningDurationMs >= targetDurationMs + ? (float) (_entries[0].ContributionMs / _entries[0].DurationMs) + : 0f; } private readonly struct Entry diff --git a/test_cowbell.wav b/test_cowbell.wav new file mode 100644 index 0000000000000000000000000000000000000000..e36f6f9a418674f4b84c129b91e7e1ad432b3205 GIT binary patch literal 11068 zcmY+p1$Y$K8#R9K%&cd3<3nR~wjzyJSvzVDeQ+a#Sk@44qa=e&19zpkA+HL63%;C6$O#!s0Y=}ics zxS|IU((x!E0`VqeN6jAPjeG2p*3v0pMd<19%!notj%rhaEB!uLwi!l9%lQ|M+=AfD zgx3w8>z_?|9{+5|lYl4fQqR07d^_|*$BMo}7oRQRsWso#y;}c3eOq1MxD8PyA=iDn zS*97!(j0f+%EZ!rg-7$7=0_GJ7S$=eS8>b!&b?STV%ZxU99dGMPOYKQ>w=DZjT9o? zeJYw14ar{lvfh)vDgQo*dZeT#zs|@yTX?#1uu@-|X+0Po<#gT76&OPM=NQ!;KDp-BG=)d%>8T@$Z|y|MSB)dCiKhl1qi;jAH|76WcGNLneXKj> z9~l$bU~kJ`+JDyZRlAs$w;L{xuNg7L*Pz>?EUEO$k50S#pZws^tp?Z3S6}_3y;=C+ z_VcHi{fhT`zBMlkYZo`=Gqq8*CSi@ft$!_UedPB+4SW}S2N*N?OUJOX{RK~R-oL;2 zJ|-t4Z)cIUJfrF#cYooM**nM_aX4ao$XUOM<{(`dP4={N{#)6VuqIdQ!ViP}JeSQ47`l}nXZqT%TS$t*mvWTC;x`fF7 zL%l|kTlVtegxo)}+Ge=Y*JK`kuja*;T(eDcEu}eQ98M(zhN%hQb+w#=a)vh(YdKCgVLwAb&l)bjqryPEec z%Wbco#&xZ1s|ev_vA+xZ>lu0DZ2jFZv)E1;$z3uK32C? z-LK{}`d-rAh4;rlp8TRLy<2`}MGenyqGb6w zWPEh5xIT4$sWTDAF`0$^MlGOWqb-%1g+z=EoJ*FCXT9Mq3y< zS?~Jo3h5X5ZH=g!OJdWbcSh6+vHMQ)vS~i>HF9F*_Pjf3eV=xJv@NCZ;p_i0Urx@* z&AU`S+C7nWH+2cT6Y*P(E4BK@?Wp-A_HFEcG5W~D&^-UCmO#w~H!bg)e=~bg#_06+ z885T$c5At3LO+U-pAMLgmxn>RjB)y@*^L1r|o%u zICXVu$+JnXS7w~gonPA9aYtP$z4VF-j0(RHRUGpnW^s+3v1@B=i3$o|8Pvn)g|Uo& z&#ObYUbs+JQ9?@UF#iX@NvOcnQwmQuQufqntu8$PY zqg+F5a!F{>`GO|Jg00ZGRlO|4==K|Hn``(q2%Hg85PCT@IHW`1YTrhdUk!@1hxCv) z*~G%nvtrWjzcjww_o~_3FSCBnn_s%pK1kUtlm$%!tZ~ITQ0^)YI^c z;BY^O*EA_f-d1s@@NQ0Kc7E2`Y<b1u3RBM)e`9xQ#?NMn+aY#v@iZ}MD@_lwrFlhJd=Ns>vTlmHWYz_Q1aCktFpWb`A z*B1RT=`!o?tSn8+rJ0|pc+LZJ&*`fIjOMk9# zj`5B03A9f4`r6P$E0dP;FIDlS6Y}n6_sUGmXqi>>L&yAPCHri3Tuam;!ZPhUlhbEZ zpii}{)vHGji#{AVJp5_!bid!s$8|cPP|mllE%xMf%o+T_D-Cb{0vMBOS|ZbxWrgEY9tgb2?{P-n_!3(jB%$=M|62 z{}nT}5vFkOB>^R&Z6cOe&x<$|78tV5f2>t89FShB)f|t?suxA&r{vPSE%|MVY{e%_ zhL>HhI8=4W-GUgjuZ-VY%Dm%zTlx0%Sz!$_H#Jt%ImJZY(jDeFY=3TF=Q!<5aKDxJ zDPx(QETAWZFEn)wCa)s%6U#kIrFpv7F@22a&5pTa9h)j!mQO3LRXC{NUBSkpgwpvH z4ei11BIR4MO-$2mx6BO~7TP>KKB8Z^BXmqqxo?&Emi~+=@z(BVwycsRg(vez!$Y%)S?W=6FxFyfo$LQ#4nFZ2pCsNYqam4rF@Co#A;t-sO5#u~F6gQ>x9G3J z>xJJHW*2NN99?WJ8*Vc=Ubrp1rDn2mx+T%)xUcM6-*=(6k0s9J(4CUj)4y30rKSAH zRp#8`yyqO}8s`2{uJDXjAF^NREX`*9Yh#kv8?OeY1G+%z966yzdt%+m&b9W_wxaTc z^0DPJDiSKcv3s1S+Rz! z)|5JncNMEekwwdk)Z!s!Lu}XWgWMfiHOXQq^Eza0=(E#D@>y>kW4>y9sJksS6!+5m zY>+ZSE_Yd77oAzod9Ly9ZL-btrFx4^p=OOo7j2wxI%)LL_t0zWVw*K}bE}gQNoYE+U7UmJw$=)-(H(CqLCrtr{)jF!H zsZ9~vl2G+KdA7^n`Gup6q%#6i0*))#4t|(lXknbT{uDPY@ym$ z=_s#v?y%3R3aa|1YOwv9<9pY)@^a-8zbDq!$;JxH9v{8m=YE5H*LoW)CewZ00;w0B z!W^FeT&?ZLZ1%EtB{@Y8i=G!hEL~i2v?|>7tEY$sh_`f$Of@YRt!=!YST|V?c(pfP z(OdPBZlmNT*Hx`2+cnC$%F*4i%Q4lt)8#Lp0+$iwgt%XutV`5&)5dCEiwlKVn#)tz z2epy9*E8IG!Fka!%(2nY-Z|WL!#&s&%5KnHX`-&bak_byb(D8c?}64m<`JfU^yQjJ zaUKa`fyzwxXh%flo3gZ$ppw}oDWzrQhb#YeaCbwsJx$a6W*FmD$KtfSvJ{yIdbKg; z>O1K>>fD+cqJw8C{pFD^U*|;BeD1jEG`Tm)jg)ulV)9&Et2v`tqY2PVkT!}tggx{q zdB8P%EX!3c%7Jc;YlU;IQ|HQc`Fnb>mh_hRNYg`aHRX6cGQTlLnUhWVh6}ncG%n#J z+0XKnHS#~sD^;f|Qp$v~wq>Ks+u9CSO>z#Bo2%u#pBSMFG{$-LHSaK2H##vzC++aU@1b203xqX&>yS=AljjM4qp7Vg;mu7$hB%a-~<&Dv3ya#1+Ed z^cAs?Ni1CHDl4vLF1NFR>#6IU{JYwn92Ry+{#v&##<@<(7NOyvo(`1GcoP$z^(^9V)UZ+jnO^r-H82cEm z>WVcX;yOM_dE+i}9&~s)ydAq8`<&dhRqmz`wucAMzCvfQQtU5HmO4n;;vn&eP(tg_ zsU(M8R=9k{?Q|V=rMeo*+m!+)(o~^?)JtWU-6{kzAD^&!z>7wtv;cw-;a64NVFe=i@ex~7{3Md#GK z5i7|`b(cKUmF!5j@3nJ#hNGtIoO_0+xmt_)@qb8yFhHy=9hK6hTR@sF))v1KR@1Yj z7T2kLJZ8D0`;t4!lcOwU0vSwa3f;ssu~-_Z?XA0_`&-vUH(uLc6Cm9XrqOz&2dn2P zaxSpvRo1O+QOPR%+k>4o-9wQJd--CTDo)Xk)cJ2|%UExcP3wv=(oM;tF=(zzO{Ep$IpGyWCa_*g zq3m+kmnV6Wm1s7T?<0rkw}M5Ckk)IKYMIugJEhfVgEWeGU-*fh<6o)MJ)PZpr0j#L zf2#)BXEd24DBqcq30Cw2Grg@!MUpBcY1^wmE{u6!l_jyH+R$_`oQ7F-)J zb)sAvccNS>xANTaEL2L=GJcLGiUXuz&0@`3O=rz@Nhj47KNphdzx&tn{rIAjFa>OjWvhX1fzz-#dpp&pQ{meg?*2 z%1gBq-%8HX2GGe3?RNdY=yz9b zrP)+3R!K$LPI}3ZV0faZz&KNrFAfkMlCSyS>c5`Oa##0V*A-WgyCbfB?!IzMPcOxz z1`#{`UOXzz*1XcZ(`?o#QX{Fe*jeaEAMhotLfxW{PzS4X)w8OF&0~dZB!9x25G#6~ zD83RKOFg6-QYSH$P9vZ3DArv)t-SNRmRrf994r4Kck;w4k#J~j9t!?L1wnFZzSK3; zFVrXLckA|Q$7ob>wGc}0@@UpW$&_>5Biu>u4en#^gKovWL!RTAtE^MQxsDzY++w~o zS94Ia45>a@`c*tEoS=>Ad2*Xqvd-*|I!Embq)XNNYHhZiS^0AQHQ7%O2xH)MtF%F! zB@CjS$pF5R<)}=__tf#+l`qR~d7h`1;>Ogt&Gz%DWD^}Bw$}Wvou#{{JE)7$wb4dv z-ifP(#?;NT*lJa$6v<8HLbu0V=DzD@?jPhNPa~z3+MC_uYpEc{N=4Ej%|uOt=D4Ja zwZzuKKXf0}QqBW-e|AkBuGUs#)o$u$wL%@sGT8%eq=7=NFbB!HTX;>av=`aM4SWl0 z#dfLpl;%pgC*M;|S)v%!W9kUz&;RAOiCI`Ex+O{byEa+7TI z4`cU`RZY|e>L7K8>QN`NTsDfgAj4@_!6O*OwL%pgK+ljcvXw{kdu%<^u^wuQvRYZB z>{p!1BzSHki{}w!2puLY5l>2sHMyEnIKZmuBn=h^3ccw-GMf)zW)-vFb3^u#@47SH z-DO#R=gCmYRnCTyeB@+x>9Qn3kH@8WX`ZMPp9?<-{=(v~E&smeVynVsSp zq><1;)JWe-JEbLX!FI8&7%HX+BZYLTkW`$C>}r3t5Ew5j#Y%fPs3m*APVoQ8EczQg zK)2I*v?JBfYh)hzjFiLwP546KT(1sM`>4~@B4cbku+5rBUO|3 zilW#%o{P!>^*6SHe}Oz%BYZ7#v4_-O3X@KX zQ^ek)R{Tu}6q-8=X^=dUWLwTtvN*DE{TAy8D9eDtGOhRcd`Zb+GhtuZNL{rID zGKhqb_k1G1fQpaQU)8nhZuNyajSb=TNFiB99dxbGNZcvj6)%W$M1#0Q$f7OiHp2K^ zewl4mCn{S!zMec;@0sk;D7Tay>LRR-ArZ8(U>5g?6(W(IiT{e%#SLNt&KbVKRJxRm z;@_|XYN&cgIf9N0>IC(JI)h34CmuxBlOocPjslZ&=m^@3O7t!=v@=BwmDGlghQj45|PQGK^)Nx9RXQ^kg=aMHu zxvMNzJ1`@^%V&~Kw1Mzm7$R;KH(`&L#oginF~HB_B>6l2|&HuA)EE<#aSnq;_(OOd&=5bAEs|Wu?e0Bm09j>{;fu^9qa>hvHrXO*_Te5(3x}# zl(`0IKchBsjEpA*ye&V&dcfzAY#rll7SHA#$##-PjPxt|n1%?ga773o=r(lwHJM8? zc?|E(W~tABuCFp)IjRJ!d!YVwwwU{npHQWg&JpejvEov6eVv#D&-DS1!E^yR%^$G< zXtJ^Dt%j&w)!$W#EoOIF4zoZ-R`Ltc(qX`O4A)lr4Nw-7y<`Y^$6G>|YuPdOj)m}H zaD6%tCf&(wa)&ghn`kn9O`n3*CBRrl{vg5dcLr<0HbYry$`hp$8Gb;G!TRR>I`VEm zNuYOWGvT;UQyeW$6kCY}!d#quhtg!ykSqZszHFyDRBaC)*Q#$-9~RGsVD5xKE8|Hq z8ASKchx847K=;#0G?u;~D~Lo!^EYfCJI~%gQ|e`1gldTw!}zW6X`Ys5MUgS*An1e@ z)PsHBDNEkMLKVkXX_Y-pVJ5@Y}a=+-NZQmh>i%IiALEvgyp5y-=^Kk5o5$y_uD= zzWfw7kuT7JnMkldFy)w#53K!!Osq~PUy(f2I>j2XboGRKNKMAdFjj~4XB*fHR+I1J zZOL7zWhL<4r#CR8rqa4J3kpgiD!d-S}Sa;BCkvWW#ZIXT5M%IE~LFAs2fjbPbep z8WoPQBqp%;YL1$&K321E8tTUmFp-bt`FsV@1KAmLI}1JEOUKb_^gdF(7Rlhd`BdJG zH{x~pXS@v`0u68H|G+_UWG#7(4m72mXc)agCJ@urj zjrn}6VK{fKB>~WrMd&Gv6NU(N;Oq4?j{ZXi6DRuQWE+7pocS{eeRsj#LxE9YgZVQ) zk>rvtbOTUcqbKNE%y4gdo9rMzlHo*4E}=qKUW*5EKOO!?z~VY+B%bDxYsktSWF%0Y=d;o2 z2yWtf?hi)0^BGumo14jGa*5c9LY|X@P=0&N#ay^}AD_l+a~Dfvsc@W)1;BH&`62G+ zBgr`uNEd*M_cWjWNB>0L1=Cw(28klC_-{a2#{Onsvl{3*N0Rpd<}B8X|HyNAFLH~- z&m3-JCXvFv!InbV4(-E$-SXAUoe`)zrm!s3b%eq4q@dvvI`82B5g=G z=3+K~z%Sydn4mZxnz#?nRIT}Fz80tKx?~}_g0ve*SJT~eCsJV;jf87{f#*!*2@-A? z_rl3$Jp2=h^y$HVV0Ty`=JQ2fo%~EHk$V&9Li!#2`Z+LWlT>nnY=c%fKMA+A0h4Cb z42I(RK$pAueRN)FfbVpEalQWIFUY8Yny9oEJ`v1gDnCNUozu1v`+cIN3ZR zf06I8E)M@1{gxj_#u1$Ecd{QbXU?(==(!)X^O)j0 ziPR*|_y#@z*!({!tOn-!Sp4QZPvKeIKoUqdxN9nWGYbet;#sZG^#D=^9c@H<4aME- z_!<5dGaw58uOIGNKz5V!EHd0b0FUSGZZHqkr8%(s|p?LSv!wU&u=c6VsAowO zK9-Pgv8$n&M)i;hPvG(;d;-?4#}oeJHtr2Av?e{tM5uT_Cgdrw_$%~04jAKz8_(Oo z2k~&E%nU}*m&I3J0O_2)VyNeD0w#uNTRk}GgsPh`kpl1ws)hv=w)8TS-t zwI9ep?64f_`W8BD0-x5wbnFdozQ?TG3h$)Ab#+j43iwzDepZ6Z@nE(Y_9pXt{5aO0 z;OFp;`4FrZplc%j9vK9rJ#pV$Ovy#~{2J%2Mnr_$&hRxzq2|bhSR`0G{v|N}i5w~A z7V@#qB65VhAx?Ch;MA20jLVQe9~pTBJQFc#4mHDEnhQoY zkqu-y{5uLNO+@ac0oz5W;u8LQ4{T)d66Co^%p?YBH4Ms_1s12lcb!Q!QUyhvg^E_7 zYXf*sHQEh5K(Er%3<&j-M`7F}#X{7D67%U0ycAmHqR>>G${ z1d?+Vy7&;Q6T!n=-2F3pw*jZ~Z{YXO!K8=30n^D)+SQM5>u~66IJ^R#(~!_lJs*X8 zr+~-)(69+7uKzGOkMV7ot5dkc7d$H6sW zsP!N6W-<610-Rq$Gb8YXdC>SlxULX?cj*8w76Rp_PpLi~=dljxKm^Vk9&qp+O#F-L zS1~W|qTV}ny^0%=03GnYHww-k1~)|!9r7Rz)9MWWm9OLrq57q$u?s)nM-QA>Q48u@ zf%$tCxpM=_z7u*J4U7>;uMfbo1KOAhzf8o}7<|sb9(SXUFSrMP`RV~S7NOs3ppd!P z+eb~-1&UCtF2Fr^fbasYE8sN+)93?Mg@Tu^(9-~PzCP~M;S`jQoO*`TKMu!j2iBe7 z;Vi!e&lPYPr;|qL*<$3uK`3)SIQ;=R(H&jzAujCWCe*tgI-CL~hw$P2YkdC;=D=fa z=TT79I5Ho5Tn&u#;D>%tNdhp2e8Tt={9VI-E&}OI=;I^C3UoXgbD%p^))sn+LLC9; zph8Ukx0n)Ffb$R#9>EN{hBa@|ZxJh60OdS5bStLJCS=}Z?6Cz_>q#ZJIEUH40Uh`T zJdT4tmqIxw!Ac%dUH;FhJxCX%S|d^&DD!ZxxQ3qn4Yi#F(wk6E1{@?p zwarL7(h`i;!S?}}QUq+4@k~7TD$pHAE}Vs;@1cGPx?KZ0?gzImggcj`bJLJqU9iJY zOaU8qmx48WFh3Sx*3Up5twc9YfT1k7H4wG>fxj8>^-T2r3;fgz?6yP~Lx_Ua4^i_J zFdoC_WjN>=e&^!;s2T^p3FbBbbL<={FM3?{=3x3Jfejr<#wlYnrpyu`T@N1}N8jFHtqA`&gR+JL z-#B=61g>Gg*#X_ILA;P(uTbYa?mvo-f9&1^)Xc;Cj)1%jLWLMG84I-kKTaKR+OfA6 z=)fJUeF((mnEPhzBN~`m!*9KCb%W!YK`9Y<9}qE9%Yf$!Q2v6BZ{$BC7mkCSXK}Kexd8YUp1P?n?&B6IgQ!{<#Te(~(T2=%4{991fH@18?6u27lo%b1)CjkwE@O@SQQK|KK45U+@FTSe}@+T z;0N%1GHT}{y|k!P1M8Box4y`+VK`q5K>u6APmxfW4bFT3{?3EN^HA9>_~spW%EitZ z+z<$4HGnY=U;i)4OeE|h+?@vAZCphqE8G4h681PsJrwy32SYbte*>LCo zdUg^$JB^AT`S_S4GW6_?jwN7U9f5KnW=KEu`Xi6gc%B9mIRhQL2@Wsg>jqr#7@l|m zHgmv_fT$M100$RVaQZVO=gzX2D~5=(g<>N{Z(hP_G(x6Ng>J18p=s;e&2h zV%Hf!`Ej~kMdy-H=^1`g2=x=J^1*#^NQVw^TW@&iV@5W_9z(G2|Gn>|0Oj>hPE7%? zshA)iJ6?g+R#dEkdI`AdpytOc^1(A1)Q}IHd6+fD_`V96rQm)M{{O%I7W1jwIwV2` z?$3fJA48dU@bwTF--3xUWE#a2{ZOX~yw(+3>>dxB4e6Wz+WY}N>R&(pWQ(E@e~yRULxV5s88KC ceVPKLK$(diJwcU^TK?FrkN$MxG^r*32li<~cK`qY literal 0 HcmV?d00001 From a7d00d87771a9127f78d89e1f5675a854d28f2d0 Mon Sep 17 00:00:00 2001 From: polson <302882+polson@users.noreply.github.com> Date: Sun, 12 Jul 2026 16:39:58 -0500 Subject: [PATCH 13/31] Unify audio playback preparation --- Assets/Script/Playback/SongRunner.cs | 114 ++++++++++++--------------- 1 file changed, 49 insertions(+), 65 deletions(-) diff --git a/Assets/Script/Playback/SongRunner.cs b/Assets/Script/Playback/SongRunner.cs index 5cec38d741..5d16328bee 100644 --- a/Assets/Script/Playback/SongRunner.cs +++ b/Assets/Script/Playback/SongRunner.cs @@ -224,6 +224,8 @@ public class SongRunner : IDisposable private readonly SyncCorrectionCalculator _syncCorrection = new(); private double _syncCorrectionSuppressedUntil = double.NegativeInfinity; + private double _playbackStartLatency; + private double _preparedAudioStartTime = double.PositiveInfinity; private readonly StemMixer _mixer; @@ -233,11 +235,16 @@ public class SongRunner : IDisposable public float SyncStartDelta => _syncStartDelta; public float SyncWorstDelta => _syncWorstDelta; + /// + /// Estimated song time physically coming out of the speakers. + /// + public double AudibleSongTime { get; private set; } + /// /// The audio time used by audio synchronization.
/// Accounts for song speed, audio calibration, and song offset. ///
- public double SyncAudioTime { get; private set; } + public double SyncAudioTime => AudibleSongTime; /// /// The visual time used by audio synchronization.
@@ -347,6 +354,7 @@ private void Start() // Re-initialize song times to avoid lag issues InitializeSongTime(InputTime, 0); + PrepareAudioPlayback(); _syncThread.Start(); Started = true; @@ -437,12 +445,12 @@ private void SyncThread() double tempoStreamLatency = _mixer.GetTempoStreamLatency(); double tempoStreamLatencyMs = Math.Max(1.0, tempoStreamLatency * 1000.0); - // AudioCalibration already accounts for output-device latency. Mixer position must - // stay on the same timeline as dev or latency gets compensated a second time. - SyncAudioTime = _mixer.GetPosition(); + // Do not subtract output latency from the mixer position here. AudioCalibration + // shifts SyncVisualTime below, so doing both would apply that latency twice. + AudibleSongTime = _mixer.GetPosition(); SyncVisualTime = GetRelativeInputTime(currentTime) - audioOffset; - StartAudioBeforeSongBegins(currentTime); + StartPreparedAudio(currentTime); if (Paused || SyncVisualTime < 0 || SyncVisualTime >= _mixer.Length) { @@ -451,11 +459,6 @@ private void SyncThread() continue; } - if (_mixer.IsPaused) - { - _mixer.Play(); - } - if (SyncAudioTime >= _mixer.Length) { _syncCorrection.SuppressAdjustment(elapsedMs, tempoStreamLatencyMs); @@ -494,28 +497,46 @@ private void SyncThread() } } - private void StartAudioBeforeSongBegins(double currentTime) + private double CurrentAudioTimelineTime => + InputTime + (AudioCalibration * SongSpeed) - SongOffset; + + /// + /// Realigns audio after any gameplay timeline discontinuity: playback start, seek, or unpause. + /// Pauses and seeks the mixer to the current gameplay position while accounting for playback-start + /// latency. The sync thread starts playback when needed to keep the audio and gameplay synchronized. + /// + private void PrepareAudioPlayback() { - if (Paused || !_mixer.IsPaused || SyncVisualTime >= 0) - { - return; - } + _mixer.Pause(); + ResetSync(); + + _playbackStartLatency = _mixer.GetPlaybackStartLatency(); + double playbackPreRoll = _playbackStartLatency * SongSpeed; + double audioTime = CurrentAudioTimelineTime; + double seekPosition = Math.Clamp(audioTime + playbackPreRoll, 0, _mixer.Length); + _mixer.SetPosition(seekPosition); + + // When pre-roll would seek before the file, wait until starting position zero will + // reach the speakers at the correct song time. Otherwise playback is ready now. + _preparedAudioStartTime = seekPosition == 0 ? -playbackPreRoll : audioTime; + _syncCorrectionSuppressedUntil = double.NegativeInfinity; + } - double playbackStartLatency = _mixer.GetPlaybackStartLatency(); - double playbackPreRoll = playbackStartLatency * SongSpeed; - if (SyncVisualTime < -playbackPreRoll) + private void StartPreparedAudio(double currentTime) + { + bool reachedAudioStart = SyncVisualTime >= _preparedAudioStartTime; + bool beforeAudioEnd = SyncVisualTime < _mixer.Length; + if (Paused || !_mixer.IsPaused || !reachedAudioStart || !beforeAudioEnd) { return; } _mixer.Play(); - // Starting at audio position zero cannot seek ahead to compensate for output latency. - // Start during the countdown instead, then ignore mixer position until that audio can - // reach output. + // Mixer position is intentionally ahead until the prepared audio reaches the speakers. _syncCorrectionSuppressedUntil = Math.Max( _syncCorrectionSuppressedUntil, - currentTime + playbackStartLatency + currentTime + _playbackStartLatency ); } @@ -645,29 +666,10 @@ public void SetSongTime(double time, double delayTime = SONG_START_DELAY) InitializeSongTime(time, delayTime); double seekInputTime = InputTime; - // Reset syncing before seeking to prevent speed adjustments from causing issues - ResetSync(); + PrepareAudioPlayback(); - _mixer.Pause(); - // Audio seeking; cannot go negative - double playbackPreRoll = _mixer.GetPlaybackStartLatency() * SongSpeed; - double seekTime = time - (delayTime - AudioCalibration) * SongSpeed - SongOffset + playbackPreRoll; - if (seekTime < 0) - { - seekTime = 0; - _mixer.SetPosition(seekTime); - } - else - { - _mixer.SetPosition(seekTime); - if (!Paused) - { - _mixer.Play(); - } - } - - // Seeking and restarting playback can take several milliseconds. Anchor after both - // complete so that command execution does not advance the gameplay timeline alone. + // Seeking can take several milliseconds. Anchor after it completes so that command + // execution does not advance the gameplay timeline alone. SetInputBaseAt(seekInputTime, InputManager.CurrentInputTime); _seeked = true; } @@ -787,29 +789,11 @@ public void Resume() { UpdateCalibration(); double resumeInputTime = InputTime; - double playbackStartLatency = _mixer.GetPlaybackStartLatency(); - double playbackPreRoll = playbackStartLatency * SongSpeed; - double targetAudioPosition = resumeInputTime + (AudioCalibration * SongSpeed) - SongOffset; - double seekPosition = Math.Clamp(targetAudioPosition + playbackPreRoll, 0, _mixer.Length); - - _mixer.SetPosition(seekPosition); - ResetSync(); - + PrepareAudioPlayback(); Paused = false; - _syncCorrectionSuppressedUntil = double.NegativeInfinity; - - if (targetAudioPosition >= -playbackPreRoll && targetAudioPosition < _mixer.Length) - { - _mixer.Play(); - - // Audio is intentionally pre-rolled so it reaches output at the resumed song - // position. Until then, mixer position is ahead by the pre-roll and is not a - // valid synchronization error. - _syncCorrectionSuppressedUntil = InputManager.CurrentInputTime + playbackStartLatency; - } - // Seeking and starting playback can take several milliseconds. Anchor after both - // complete so that work does not advance the resumed timeline. + // Seeking can take several milliseconds. Anchor after it completes so that work + // does not advance the resumed timeline. SetInputBaseAt(resumeInputTime, InputManager.CurrentInputTime); } From 4543010a73ad6f34f29cb049fb51c677b9e0bcc7 Mon Sep 17 00:00:00 2001 From: polson <302882+polson@users.noreply.github.com> Date: Sun, 12 Jul 2026 18:10:42 -0500 Subject: [PATCH 14/31] Improve audio sync correction --- Assets/Script/Gameplay/GameManager.Debug.cs | 8 +- Assets/Script/Gameplay/GameManager.cs | 3 - Assets/Script/Playback/SongRunner.cs | 134 +++++----------- .../Playback/SyncCorrectionCalculator.cs | 147 +++++++++--------- 4 files changed, 118 insertions(+), 174 deletions(-) diff --git a/Assets/Script/Gameplay/GameManager.Debug.cs b/Assets/Script/Gameplay/GameManager.Debug.cs index 4e44729f7d..cb2cd69bba 100644 --- a/Assets/Script/Gameplay/GameManager.Debug.cs +++ b/Assets/Script/Gameplay/GameManager.Debug.cs @@ -558,11 +558,11 @@ private void TimingDebug() { using var text = ZString.CreateStringBuilder(true); - text.AppendFormat("Audio sync error: {0:0.000} ms\n", _songRunner.SyncDelta * 1000.0); - text.AppendFormat("Resync start delta: {0:0.000} ms\n", _songRunner.SyncStartDelta * 1000f); - text.AppendFormat("Resync worst delta: {0:0.000} ms\n", _songRunner.SyncWorstDelta * 1000f); + text.AppendFormat("Audio sync error: {0:0.000} ms\n", _songRunner.SyncError * 1000.0); + text.AppendFormat("Resync start delta: {0:0.000} ms\n", _songRunner.DebugSyncStartDelta * 1000f); + text.AppendFormat("Resync worst delta: {0:0.000} ms\n", _songRunner.DebugSyncWorstDelta * 1000f); text.AppendFormat("Effective speed adjustment: {0:0.000}\n", - _songRunner.EffectiveSyncSpeedAdjustment); + _songRunner.DebugEffectiveSyncSpeedAdjustment); GUILayout.Label(text.AsSpan().TrimEnd('\n').ToString()); } diff --git a/Assets/Script/Gameplay/GameManager.cs b/Assets/Script/Gameplay/GameManager.cs index 32e28f201a..7d634c6dc0 100644 --- a/Assets/Script/Gameplay/GameManager.cs +++ b/Assets/Script/Gameplay/GameManager.cs @@ -107,9 +107,6 @@ public partial class GameManager : MonoBehaviour /// public double SongTime => _songRunner.SongTime; - /// - public double AudioTime => _songRunner.AudioTime; - /// public double VisualTime => _songRunner.VisualTime; diff --git a/Assets/Script/Playback/SongRunner.cs b/Assets/Script/Playback/SongRunner.cs index 5d16328bee..45ec4aa602 100644 --- a/Assets/Script/Playback/SongRunner.cs +++ b/Assets/Script/Playback/SongRunner.cs @@ -217,10 +217,9 @@ public class SongRunner : IDisposable public bool SyncThreadStopped { get; private set; } private volatile float _syncSpeedAdjustment; - private volatile float _effectiveSyncSpeedAdjustment; - private volatile int _syncSpeedMultiplier; - private volatile float _syncStartDelta; - private volatile float _syncWorstDelta; + private volatile float _debugEffectiveSyncSpeedAdjustment; + private volatile float _debugSyncStartDelta; + private volatile float _debugSyncWorstDelta; private readonly SyncCorrectionCalculator _syncCorrection = new(); private double _syncCorrectionSuppressedUntil = double.NegativeInfinity; @@ -229,33 +228,14 @@ public class SongRunner : IDisposable private readonly StemMixer _mixer; - public float SyncSpeedAdjustment => _syncSpeedAdjustment; - public float EffectiveSyncSpeedAdjustment => _effectiveSyncSpeedAdjustment; - public int SyncSpeedMultiplier => _syncSpeedMultiplier; - public float SyncStartDelta => _syncStartDelta; - public float SyncWorstDelta => _syncWorstDelta; + public float DebugEffectiveSyncSpeedAdjustment => _debugEffectiveSyncSpeedAdjustment; + public float DebugSyncStartDelta => _debugSyncStartDelta; + public float DebugSyncWorstDelta => _debugSyncWorstDelta; /// - /// Estimated song time physically coming out of the speakers. + /// The latest sampled difference between the target and actual audio synchronization times. /// - public double AudibleSongTime { get; private set; } - - /// - /// The audio time used by audio synchronization.
- /// Accounts for song speed, audio calibration, and song offset. - ///
- public double SyncAudioTime => AudibleSongTime; - - /// - /// The visual time used by audio synchronization.
- /// Accounts for song speed, but not video calibration. - ///
- public double SyncVisualTime { get; private set; } - - /// - /// The difference between the visual and audio times used by audio synchronization. - /// - public double SyncDelta => SyncVisualTime - SyncAudioTime; + public double SyncError { get; private set; } #endregion #region Seek debugging @@ -422,70 +402,46 @@ public void Update() private void SyncThread() { const int SYNC_INTERVAL_MS = 1; - double lastSampleTime = InputManager.CurrentInputTime; for (; !_disposed; Thread.Sleep(SYNC_INTERVAL_MS)) { lock (_syncThread) { double currentTime = InputManager.CurrentInputTime; - double elapsedMs = (currentTime - lastSampleTime) * 1000.0; - lastSampleTime = currentTime; - if (elapsedMs <= 0.0) - { - elapsedMs = 1.0; - } - else - { - elapsedMs = Math.Min(elapsedMs, 100.0); - } - double audioOffset = SongOffset - (AudioCalibration * SongSpeed); - - double tempoStreamLatency = _mixer.GetTempoStreamLatency(); - double tempoStreamLatencyMs = Math.Max(1.0, tempoStreamLatency * 1000.0); - - // Do not subtract output latency from the mixer position here. AudioCalibration - // shifts SyncVisualTime below, so doing both would apply that latency twice. - AudibleSongTime = _mixer.GetPosition(); - SyncVisualTime = GetRelativeInputTime(currentTime) - audioOffset; - - StartPreparedAudio(currentTime); - - if (Paused || SyncVisualTime < 0 || SyncVisualTime >= _mixer.Length) + double tempoStreamLatencyMs = _mixer.GetTempoStreamLatency() * 1000.0; + double syncActualTime = _mixer.GetPosition(); + double syncTargetTime = GetRelativeInputTime(currentTime) - audioOffset; + double syncError = syncTargetTime - syncActualTime; + SyncError = syncError; + + StartPreparedAudio(currentTime, syncTargetTime); + + bool isWithinSongBounds = + !Paused && + syncTargetTime >= 0 && + syncTargetTime < _mixer.Length && + syncActualTime < _mixer.Length; + bool isSuppressing = currentTime < _syncCorrectionSuppressedUntil; + bool allowCorrection = isWithinSongBounds && !isSuppressing; + + float adjustment = _syncCorrection.Update( + syncError, currentTime, tempoStreamLatencyMs, allowCorrection); + _debugEffectiveSyncSpeedAdjustment = _syncCorrection.EffectiveAdjustment; + if (!isWithinSongBounds) { - _syncCorrection.SuppressAdjustment(elapsedMs, tempoStreamLatencyMs); - _effectiveSyncSpeedAdjustment = _syncCorrection.EffectiveAdjustment; continue; } - if (SyncAudioTime >= _mixer.Length) + bool correctionStarting = _syncSpeedAdjustment == 0f && adjustment != 0f; + if (correctionStarting) { - _syncCorrection.SuppressAdjustment(elapsedMs, tempoStreamLatencyMs); - _effectiveSyncSpeedAdjustment = _syncCorrection.EffectiveAdjustment; - continue; + _debugSyncStartDelta = (float) syncError; + _debugSyncWorstDelta = _debugSyncStartDelta; } - - double delta = SyncVisualTime - SyncAudioTime; - float adjustment = currentTime < _syncCorrectionSuppressedUntil - ? _syncCorrection.SuppressAdjustment(elapsedMs, tempoStreamLatencyMs) - : _syncCorrection.CalculateAdjustment(delta, elapsedMs, tempoStreamLatencyMs); - _effectiveSyncSpeedAdjustment = _syncCorrection.EffectiveAdjustment; - - int speedMultiplier = Math.Sign(adjustment); - if (speedMultiplier != _syncSpeedMultiplier) + else if (adjustment != 0f && Math.Abs(syncError) > Math.Abs(_debugSyncWorstDelta)) { - if (_syncSpeedMultiplier == 0 && speedMultiplier != 0) - { - _syncStartDelta = (float) delta; - _syncWorstDelta = _syncStartDelta; - } - else if (Math.Abs(delta) > Math.Abs(_syncWorstDelta)) - { - _syncWorstDelta = (float) delta; - } - - _syncSpeedMultiplier = speedMultiplier; + _debugSyncWorstDelta = (float) syncError; } if (adjustment != _syncSpeedAdjustment) @@ -497,9 +453,6 @@ private void SyncThread() } } - private double CurrentAudioTimelineTime => - InputTime + (AudioCalibration * SongSpeed) - SongOffset; - /// /// Realigns audio after any gameplay timeline discontinuity: playback start, seek, or unpause. /// Pauses and seeks the mixer to the current gameplay position while accounting for playback-start @@ -512,7 +465,9 @@ private void PrepareAudioPlayback() _playbackStartLatency = _mixer.GetPlaybackStartLatency(); double playbackPreRoll = _playbackStartLatency * SongSpeed; - double audioTime = CurrentAudioTimelineTime; + + // Audio-file position corresponding to the current gameplay timeline. + double audioTime = InputTime + (AudioCalibration * SongSpeed) - SongOffset; double seekPosition = Math.Clamp(audioTime + playbackPreRoll, 0, _mixer.Length); _mixer.SetPosition(seekPosition); @@ -522,10 +477,10 @@ private void PrepareAudioPlayback() _syncCorrectionSuppressedUntil = double.NegativeInfinity; } - private void StartPreparedAudio(double currentTime) + private void StartPreparedAudio(double currentTime, double syncTargetTime) { - bool reachedAudioStart = SyncVisualTime >= _preparedAudioStartTime; - bool beforeAudioEnd = SyncVisualTime < _mixer.Length; + bool reachedAudioStart = syncTargetTime >= _preparedAudioStartTime; + bool beforeAudioEnd = syncTargetTime < _mixer.Length; if (Paused || !_mixer.IsPaused || !reachedAudioStart || !beforeAudioEnd) { return; @@ -543,11 +498,10 @@ private void StartPreparedAudio(double currentTime) private void ResetSync() { _syncCorrection.Reset(); - _syncSpeedMultiplier = 0; _syncSpeedAdjustment = 0f; - _effectiveSyncSpeedAdjustment = 0f; - _syncStartDelta = 0f; - _syncWorstDelta = 0f; + _debugEffectiveSyncSpeedAdjustment = 0f; + _debugSyncStartDelta = 0f; + _debugSyncWorstDelta = 0f; _mixer.SetSpeed(RealSongSpeed, true); } @@ -695,7 +649,6 @@ public void SetSongSpeed(float speed) _scheduledSpeedChanges.Enqueue((effectiveTime, speed)); _syncCorrection.Reset(); - _syncSpeedMultiplier = 0; _syncSpeedAdjustment = 0f; _syncCorrectionSuppressedUntil = Math.Max( _syncCorrectionSuppressedUntil, @@ -712,7 +665,6 @@ public void SetSongSpeed(float speed) SongSpeed = speed; _scheduledSpeedChanges.Clear(); _syncCorrection.Reset(); - _syncSpeedMultiplier = 0; _syncSpeedAdjustment = 0f; _syncCorrectionSuppressedUntil = double.NegativeInfinity; diff --git a/Assets/Script/Playback/SyncCorrectionCalculator.cs b/Assets/Script/Playback/SyncCorrectionCalculator.cs index eb1efd4d12..f882308773 100644 --- a/Assets/Script/Playback/SyncCorrectionCalculator.cs +++ b/Assets/Script/Playback/SyncCorrectionCalculator.cs @@ -49,51 +49,41 @@ internal sealed class SyncCorrectionCalculator public float EffectiveAdjustment => _syncHistory.EffectiveAdjustment; /// - /// Calculates the playback-speed change needed to reduce the current sync error. + /// Updates buffered correction history and returns the playback-speed change to request. /// /// How far playback is behind or ahead, in seconds. - /// Time since the previous calculation, in milliseconds. + /// Current input-clock time, in seconds. /// Time before a speed change reaches audio output, in milliseconds. + /// Whether a new sync correction may be requested. /// Speed adjustment to apply, where 0.1 means 10% faster. - public float CalculateAdjustment(double syncDeltaSeconds, double elapsedMs, double streamDelayMs) + public float Update(double syncDeltaSeconds, double currentTime, double streamDelayMs, bool allowCorrection) { streamDelayMs = Math.Max(1.0, streamDelayMs); - elapsedMs = Math.Clamp(elapsedMs, 1.0, 100.0); + _syncHistory.Update(currentTime, streamDelayMs, _currentAdjustment); - RecordAdjustment(elapsedMs, streamDelayMs); - - double errorMs = syncDeltaSeconds * 1000.0 - _syncHistory.RunningContributionMs; - float adjustment = 0f; - if (Math.Abs(errorMs) >= SYNC_DEADBAND_SECONDS * 1000.0) - { - float gain = Math.Clamp((float) streamDelayMs / CORRECTION_TIME_MS, MIN_GAIN, MAX_GAIN); - float correctionMs = (float) errorMs * gain; - adjustment = Math.Clamp(correctionMs / (float) streamDelayMs, -SYNC_CLAMP, SYNC_CLAMP); - } - - if (adjustment != 0f && Math.Abs(adjustment - _currentAdjustment) < SPEED_UPDATE_THRESHOLD) + float adjustment = allowCorrection ? CalculateAdjustment(syncDeltaSeconds, streamDelayMs) : 0f; + if (allowCorrection && adjustment != 0f && + Math.Abs(adjustment - _currentAdjustment) < SPEED_UPDATE_THRESHOLD) { return _currentAdjustment; } _currentAdjustment = adjustment; + _syncHistory.RecordChange(currentTime, _currentAdjustment); return _currentAdjustment; } - /// - /// Records the current speed adjustment but requests no new sync correction. - /// - /// Time since the previous calculation, in milliseconds. - /// Time before a speed change reaches audio output, in milliseconds. - /// Zero, to disable sync correction. - public float SuppressAdjustment(double elapsedMs, double streamDelayMs) + private float CalculateAdjustment(double syncDeltaSeconds, double streamDelayMs) { - streamDelayMs = Math.Max(1.0, streamDelayMs); - elapsedMs = Math.Clamp(elapsedMs, 1.0, 100.0); + double errorMs = syncDeltaSeconds * 1000.0 - _syncHistory.RunningContributionMs; + if (Math.Abs(errorMs) < SYNC_DEADBAND_SECONDS * 1000.0) + { + return 0f; + } - RecordAdjustment(elapsedMs, streamDelayMs); - _currentAdjustment = 0f; - return _currentAdjustment; + float gain = Math.Clamp((float) streamDelayMs / CORRECTION_TIME_MS, MIN_GAIN, MAX_GAIN); + float correctionMs = (float) errorMs * gain; + return Math.Clamp(correctionMs / (float) streamDelayMs, -SYNC_CLAMP, SYNC_CLAMP); } /// @@ -106,25 +96,16 @@ public void Reset() _currentAdjustment = 0f; } - private void RecordAdjustment(double elapsedMs, double streamDelayMs) - { - _syncHistory.Add(elapsedMs, _currentAdjustment * elapsedMs); - _syncHistory.TrimToDuration(streamDelayMs); - } - /// - /// Remembers recent playback-speed adjustments while they pass through the buffered audio - /// stream. Each entry records how long an adjustment was active and how much timing error it - /// should correct. The running total represents correction already requested but not yet - /// reflected in playback, preventing the sync controller from correcting the same error - /// again. History older than the current stream delay is removed, including part of an entry - /// when the cutoff falls between updates. + /// Remembers when requested playback-speed adjustments change while they pass through the + /// buffered audio stream. Pending correction is the integral of those adjustments over the + /// stream-delay window. Absolute timestamps make history independent of update cadence. /// private sealed class SyncHistoryBuffer { - // 500 entries cover the maximum 5000 ms delay at the 10 ms update cadence. + // Covers five seconds even if the requested adjustment changes every millisecond. private readonly RingBuffer _entries = new(5012); - private double _runningDurationMs; + private double _historyStartTime = double.NaN; public double RunningContributionMs { get; private set; } public float EffectiveAdjustment { get; private set; } @@ -132,62 +113,76 @@ private sealed class SyncHistoryBuffer public void Clear() { _entries.Clear(); - _runningDurationMs = 0.0; + _historyStartTime = double.NaN; RunningContributionMs = 0.0; EffectiveAdjustment = 0f; } - public void Add(double durationMs, double contributionMs) + public void RecordChange(double timestamp, float adjustment) { - _entries.Add(new Entry(durationMs, contributionMs)); - _runningDurationMs += durationMs; - RunningContributionMs += contributionMs; + if (_entries.Count == 0) + { + _historyStartTime = timestamp; + _entries.Add(new Entry(timestamp, adjustment)); + return; + } + + int latestIndex = _entries.Count - 1; + var latest = _entries[latestIndex]; + if (latest.Timestamp == timestamp) + { + _entries[latestIndex] = new Entry(timestamp, adjustment); + } + else if (latest.Adjustment != adjustment) + { + _entries.Add(new Entry(timestamp, adjustment)); + } } - public void TrimToDuration(double targetDurationMs) + public void Update(double currentTime, double streamDelayMs, float currentAdjustment) { - targetDurationMs = Math.Max(1.0, targetDurationMs); + if (_entries.Count == 0) + { + RecordChange(currentTime, currentAdjustment); + } - while (_entries.Count > 0 && _runningDurationMs > targetDurationMs) + double cutoffTime = currentTime - streamDelayMs / 1000.0; + while (_entries.Count > 1 && _entries[1].Timestamp <= cutoffTime) { - double excessMs = _runningDurationMs - targetDurationMs; - var oldest = _entries[0]; + _entries.RemoveOldest(); + } + + bool historyReachesCutoff = _historyStartTime <= cutoffTime; + EffectiveAdjustment = historyReachesCutoff ? _entries[0].Adjustment : 0f; - if (oldest.DurationMs <= excessMs) + double intervalStart = Math.Max(cutoffTime, _historyStartTime); + float adjustment = _entries[0].Adjustment; + double contributionSeconds = 0.0; + for (int i = 1; i < _entries.Count; i++) + { + var entry = _entries[i]; + if (entry.Timestamp > intervalStart) { - _entries.RemoveOldest(); - _runningDurationMs -= oldest.DurationMs; - RunningContributionMs -= oldest.ContributionMs; - continue; + contributionSeconds += adjustment * (entry.Timestamp - intervalStart); } - double removedRatio = excessMs / oldest.DurationMs; - double removedContributionMs = oldest.ContributionMs * removedRatio; - - _entries[0] = new Entry( - oldest.DurationMs - excessMs, - oldest.ContributionMs - removedContributionMs); - - _runningDurationMs = targetDurationMs; - RunningContributionMs -= removedContributionMs; + intervalStart = Math.Max(intervalStart, entry.Timestamp); + adjustment = entry.Adjustment; } - // Oldest retained adjustment is reaching output now. Until history spans the - // stream delay, requested changes have not reached output yet. - EffectiveAdjustment = _entries.Count > 0 && _runningDurationMs >= targetDurationMs - ? (float) (_entries[0].ContributionMs / _entries[0].DurationMs) - : 0f; + contributionSeconds += adjustment * (currentTime - intervalStart); + RunningContributionMs = contributionSeconds * 1000.0; } private readonly struct Entry { - public readonly double DurationMs; - public readonly double ContributionMs; + public readonly double Timestamp; + public readonly float Adjustment; - public Entry(double durationMs, double contributionMs) + public Entry(double timestamp, float adjustment) { - DurationMs = durationMs; - ContributionMs = contributionMs; + Timestamp = timestamp; + Adjustment = adjustment; } } } From 01c8ce9074121963197c93726bacda8b33aca888 Mon Sep 17 00:00:00 2001 From: polson <302882+polson@users.noreply.github.com> Date: Mon, 13 Jul 2026 08:23:07 -0500 Subject: [PATCH 15/31] More cleanup --- Assets/Script/Gameplay/GameManager.Debug.cs | 2 +- Assets/Script/Playback/SongRunner.cs | 102 ++++++++---------- .../Playback/SyncCorrectionCalculator.cs | 38 +++---- 3 files changed, 68 insertions(+), 74 deletions(-) diff --git a/Assets/Script/Gameplay/GameManager.Debug.cs b/Assets/Script/Gameplay/GameManager.Debug.cs index cb2cd69bba..4ab54d2e3a 100644 --- a/Assets/Script/Gameplay/GameManager.Debug.cs +++ b/Assets/Script/Gameplay/GameManager.Debug.cs @@ -562,7 +562,7 @@ private void TimingDebug() text.AppendFormat("Resync start delta: {0:0.000} ms\n", _songRunner.DebugSyncStartDelta * 1000f); text.AppendFormat("Resync worst delta: {0:0.000} ms\n", _songRunner.DebugSyncWorstDelta * 1000f); text.AppendFormat("Effective speed adjustment: {0:0.000}\n", - _songRunner.DebugEffectiveSyncSpeedAdjustment); + _songRunner.DebugEffectiveSyncAdjustment); GUILayout.Label(text.AsSpan().TrimEnd('\n').ToString()); } diff --git a/Assets/Script/Playback/SongRunner.cs b/Assets/Script/Playback/SongRunner.cs index 45ec4aa602..59b8cb26c0 100644 --- a/Assets/Script/Playback/SongRunner.cs +++ b/Assets/Script/Playback/SongRunner.cs @@ -217,18 +217,18 @@ public class SongRunner : IDisposable public bool SyncThreadStopped { get; private set; } private volatile float _syncSpeedAdjustment; - private volatile float _debugEffectiveSyncSpeedAdjustment; + private volatile float _debugEffectiveSyncAdjustment; private volatile float _debugSyncStartDelta; private volatile float _debugSyncWorstDelta; private readonly SyncCorrectionCalculator _syncCorrection = new(); - private double _syncCorrectionSuppressedUntil = double.NegativeInfinity; + private double _suppressSyncUntil = double.NegativeInfinity; private double _playbackStartLatency; - private double _preparedAudioStartTime = double.PositiveInfinity; + private double _scheduledAudioStartTime = double.PositiveInfinity; private readonly StemMixer _mixer; - public float DebugEffectiveSyncSpeedAdjustment => _debugEffectiveSyncSpeedAdjustment; + public float DebugEffectiveSyncAdjustment => _debugEffectiveSyncAdjustment; public float DebugSyncStartDelta => _debugSyncStartDelta; public float DebugSyncWorstDelta => _debugSyncWorstDelta; @@ -284,9 +284,7 @@ double songOffset SongSpeed = ClampSongSpeed(songSpeed); _requestedSongSpeed = SongSpeed; SongOffset = -songOffset; - _syncThread = new Thread(SyncThread) { IsBackground = true }; - InitializeSongTime(startTime + SongOffset, startDelay); UpdateCalibration(); } @@ -401,15 +399,13 @@ public void Update() private void SyncThread() { - const int SYNC_INTERVAL_MS = 1; - - for (; !_disposed; Thread.Sleep(SYNC_INTERVAL_MS)) + const int syncIntervalMs = 1; + for (; !_disposed; Thread.Sleep(syncIntervalMs)) { lock (_syncThread) { double currentTime = InputManager.CurrentInputTime; double audioOffset = SongOffset - (AudioCalibration * SongSpeed); - double tempoStreamLatencyMs = _mixer.GetTempoStreamLatency() * 1000.0; double syncActualTime = _mixer.GetPosition(); double syncTargetTime = GetRelativeInputTime(currentTime) - audioOffset; double syncError = syncTargetTime - syncActualTime; @@ -422,12 +418,15 @@ private void SyncThread() syncTargetTime >= 0 && syncTargetTime < _mixer.Length && syncActualTime < _mixer.Length; - bool isSuppressing = currentTime < _syncCorrectionSuppressedUntil; + bool isSuppressing = currentTime < _suppressSyncUntil; bool allowCorrection = isWithinSongBounds && !isSuppressing; float adjustment = _syncCorrection.Update( - syncError, currentTime, tempoStreamLatencyMs, allowCorrection); - _debugEffectiveSyncSpeedAdjustment = _syncCorrection.EffectiveAdjustment; + syncDeltaSeconds: syncError, + currentTime: currentTime, + latency: _mixer.GetTempoStreamLatency(), + allowCorrection: allowCorrection); + _debugEffectiveSyncAdjustment = _syncCorrection.EffectiveAdjustment; if (!isWithinSongBounds) { continue; @@ -456,7 +455,7 @@ private void SyncThread() /// /// Realigns audio after any gameplay timeline discontinuity: playback start, seek, or unpause. /// Pauses and seeks the mixer to the current gameplay position while accounting for playback-start - /// latency. The sync thread starts playback when needed to keep the audio and gameplay synchronized. + /// latency. The sync thread will resume the mixer at the requested time. /// private void PrepareAudioPlayback() { @@ -466,20 +465,20 @@ private void PrepareAudioPlayback() _playbackStartLatency = _mixer.GetPlaybackStartLatency(); double playbackPreRoll = _playbackStartLatency * SongSpeed; - // Audio-file position corresponding to the current gameplay timeline. + // Audio-file position corresponding to the current paused gameplay timeline. double audioTime = InputTime + (AudioCalibration * SongSpeed) - SongOffset; double seekPosition = Math.Clamp(audioTime + playbackPreRoll, 0, _mixer.Length); _mixer.SetPosition(seekPosition); // When pre-roll would seek before the file, wait until starting position zero will // reach the speakers at the correct song time. Otherwise playback is ready now. - _preparedAudioStartTime = seekPosition == 0 ? -playbackPreRoll : audioTime; - _syncCorrectionSuppressedUntil = double.NegativeInfinity; + _scheduledAudioStartTime = seekPosition == 0 ? -playbackPreRoll : audioTime; + _suppressSyncUntil = double.NegativeInfinity; } private void StartPreparedAudio(double currentTime, double syncTargetTime) { - bool reachedAudioStart = syncTargetTime >= _preparedAudioStartTime; + bool reachedAudioStart = syncTargetTime >= _scheduledAudioStartTime; bool beforeAudioEnd = syncTargetTime < _mixer.Length; if (Paused || !_mixer.IsPaused || !reachedAudioStart || !beforeAudioEnd) { @@ -489,17 +488,14 @@ private void StartPreparedAudio(double currentTime, double syncTargetTime) _mixer.Play(); // Mixer position is intentionally ahead until the prepared audio reaches the speakers. - _syncCorrectionSuppressedUntil = Math.Max( - _syncCorrectionSuppressedUntil, - currentTime + _playbackStartLatency - ); + _suppressSyncUntil = currentTime + _playbackStartLatency; } private void ResetSync() { _syncCorrection.Reset(); _syncSpeedAdjustment = 0f; - _debugEffectiveSyncSpeedAdjustment = 0f; + _debugEffectiveSyncAdjustment = 0f; _debugSyncStartDelta = 0f; _debugSyncWorstDelta = 0f; _mixer.SetSpeed(RealSongSpeed, true); @@ -614,7 +610,7 @@ public void SetSongTime(double time, double delayTime = SONG_START_DELAY) // meaningful activation time. Apply the latest requested speed immediately. _scheduledSpeedChanges.Clear(); SongSpeed = _requestedSongSpeed; - _syncCorrectionSuppressedUntil = double.NegativeInfinity; + _suppressSyncUntil = double.NegativeInfinity; // Set input/song time InitializeSongTime(time, delayTime); @@ -631,51 +627,47 @@ public void SetSongTime(double time, double delayTime = SONG_START_DELAY) public void SetSongSpeed(float speed) { + speed = ClampSongSpeed(speed); lock (_syncThread) { - speed = ClampSongSpeed(speed); if (Mathf.Approximately(speed, _requestedSongSpeed)) { return; } _requestedSongSpeed = speed; - if (Started && !Paused) { - double now = InputManager.CurrentInputTime; - double tempoStreamLatency = _mixer.GetTempoStreamLatency(); - double effectiveTime = now + Math.Max(0.0, tempoStreamLatency); - - _scheduledSpeedChanges.Enqueue((effectiveTime, speed)); - _syncCorrection.Reset(); - _syncSpeedAdjustment = 0f; - _syncCorrectionSuppressedUntil = Math.Max( - _syncCorrectionSuppressedUntil, - effectiveTime - ); - - // The mixer receives the tempo command now. Only the gameplay/reference - // timeline waits for the measured tempo-stream delay. - _mixer.SetSpeed(speed, true); - return; + ScheduleSpeedChange(speed); + } + else + { + ApplyImmediateSpeedChange(speed); } - - double inputTime = InputTime; - SongSpeed = speed; - _scheduledSpeedChanges.Clear(); - _syncCorrection.Reset(); - _syncSpeedAdjustment = 0f; - _syncCorrectionSuppressedUntil = double.NegativeInfinity; - - // There is no audible tempo-stream delay while inactive or paused. - _mixer.SetSpeed(RealSongSpeed, true); - SetInputBaseChecked(inputTime); } YargLogger.LogFormatDebug("Set song speed to {0:0.00}.\n" - + "Song time: {1:0.000000}, visual time: {2:0.000000}, input time: {3:0.000000}", speed, - SongTime, VisualTime, InputTime); + + "Song time: {1:0.000000}, visual time: {2:0.000000}, input time: {3:0.000000}", + speed, SongTime, VisualTime, InputTime); + } + + private void ScheduleSpeedChange(float speed) + { + double effectiveTime = InputManager.CurrentInputTime + _mixer.GetTempoStreamLatency(); + _scheduledSpeedChanges.Enqueue((effectiveTime, speed)); + ResetSync(); + _suppressSyncUntil = Math.Max(_suppressSyncUntil, effectiveTime); + _mixer.SetSpeed(speed, true); + } + + private void ApplyImmediateSpeedChange(float speed) + { + double inputTime = InputTime; + SongSpeed = speed; + _scheduledSpeedChanges.Clear(); + ResetSync(); + _suppressSyncUntil = double.NegativeInfinity; + SetInputBaseChecked(inputTime); } public void AdjustSongSpeed(float deltaSpeed) => SetSongSpeed(_requestedSongSpeed + deltaSpeed); diff --git a/Assets/Script/Playback/SyncCorrectionCalculator.cs b/Assets/Script/Playback/SyncCorrectionCalculator.cs index f882308773..8d977391a7 100644 --- a/Assets/Script/Playback/SyncCorrectionCalculator.cs +++ b/Assets/Script/Playback/SyncCorrectionCalculator.cs @@ -28,9 +28,11 @@ internal sealed class SyncCorrectionCalculator // Ignore sync errors smaller than 1.5 ms to avoid reacting to tiny clock fluctuations. private const double SYNC_DEADBAND_SECONDS = 0.0015; - // Number of milliseconds over which we aim to recover from a timing error. + // Number of seconds over which we aim to recover from a timing error. // For example, recovering 10 ms over 100 ms requires running 10% faster. - private const float CORRECTION_TIME_MS = 100f; + private const float CORRECTION_TIME_SECONDS = 0.1f; + + private const double MIN_LATENCY_SECONDS = 0.001; // Correct at least 40% of the remaining error, even with a short stream delay. private const float MIN_GAIN = 0.4f; @@ -53,15 +55,15 @@ internal sealed class SyncCorrectionCalculator /// /// How far playback is behind or ahead, in seconds. /// Current input-clock time, in seconds. - /// Time before a speed change reaches audio output, in milliseconds. + /// Time before a speed change reaches audio output, in seconds. /// Whether a new sync correction may be requested. /// Speed adjustment to apply, where 0.1 means 10% faster. - public float Update(double syncDeltaSeconds, double currentTime, double streamDelayMs, bool allowCorrection) + public float Update(double syncDeltaSeconds, double currentTime, double latency, bool allowCorrection) { - streamDelayMs = Math.Max(1.0, streamDelayMs); - _syncHistory.Update(currentTime, streamDelayMs, _currentAdjustment); + latency = Math.Max(MIN_LATENCY_SECONDS, latency); + _syncHistory.Update(currentTime, latency, _currentAdjustment); - float adjustment = allowCorrection ? CalculateAdjustment(syncDeltaSeconds, streamDelayMs) : 0f; + float adjustment = allowCorrection ? CalculateAdjustment(syncDeltaSeconds, latency) : 0f; if (allowCorrection && adjustment != 0f && Math.Abs(adjustment - _currentAdjustment) < SPEED_UPDATE_THRESHOLD) { @@ -73,17 +75,17 @@ public float Update(double syncDeltaSeconds, double currentTime, double streamDe return _currentAdjustment; } - private float CalculateAdjustment(double syncDeltaSeconds, double streamDelayMs) + private float CalculateAdjustment(double syncDeltaSeconds, double latency) { - double errorMs = syncDeltaSeconds * 1000.0 - _syncHistory.RunningContributionMs; - if (Math.Abs(errorMs) < SYNC_DEADBAND_SECONDS * 1000.0) + double errorSeconds = syncDeltaSeconds - _syncHistory.RunningContributionSeconds; + if (Math.Abs(errorSeconds) < SYNC_DEADBAND_SECONDS) { return 0f; } - float gain = Math.Clamp((float) streamDelayMs / CORRECTION_TIME_MS, MIN_GAIN, MAX_GAIN); - float correctionMs = (float) errorMs * gain; - return Math.Clamp(correctionMs / (float) streamDelayMs, -SYNC_CLAMP, SYNC_CLAMP); + float gain = Math.Clamp((float) latency / CORRECTION_TIME_SECONDS, MIN_GAIN, MAX_GAIN); + float correctionSeconds = (float) errorSeconds * gain; + return Math.Clamp(correctionSeconds / (float) latency, -SYNC_CLAMP, SYNC_CLAMP); } /// @@ -107,14 +109,14 @@ private sealed class SyncHistoryBuffer private readonly RingBuffer _entries = new(5012); private double _historyStartTime = double.NaN; - public double RunningContributionMs { get; private set; } + public double RunningContributionSeconds { get; private set; } public float EffectiveAdjustment { get; private set; } public void Clear() { _entries.Clear(); _historyStartTime = double.NaN; - RunningContributionMs = 0.0; + RunningContributionSeconds = 0.0; EffectiveAdjustment = 0f; } @@ -139,14 +141,14 @@ public void RecordChange(double timestamp, float adjustment) } } - public void Update(double currentTime, double streamDelayMs, float currentAdjustment) + public void Update(double currentTime, double latency, float currentAdjustment) { if (_entries.Count == 0) { RecordChange(currentTime, currentAdjustment); } - double cutoffTime = currentTime - streamDelayMs / 1000.0; + double cutoffTime = currentTime - latency; while (_entries.Count > 1 && _entries[1].Timestamp <= cutoffTime) { _entries.RemoveOldest(); @@ -171,7 +173,7 @@ public void Update(double currentTime, double streamDelayMs, float currentAdjust } contributionSeconds += adjustment * (currentTime - intervalStart); - RunningContributionMs = contributionSeconds * 1000.0; + RunningContributionSeconds = contributionSeconds; } private readonly struct Entry From b4baee46d90509276b6f1a982325b6341b452449 Mon Sep 17 00:00:00 2001 From: polson <302882+polson@users.noreply.github.com> Date: Mon, 13 Jul 2026 13:27:56 -0500 Subject: [PATCH 16/31] Handle playback latency for metronome sample channel --- Assets/Script/Gameplay/Player/TrackPlayer.cs | 9 +++- Assets/Script/Menu/Calibrator/Calibrator.cs | 9 ++-- Assets/Script/Playback/SongRunner.cs | 53 +++++++++++++++++--- 3 files changed, 58 insertions(+), 13 deletions(-) diff --git a/Assets/Script/Gameplay/Player/TrackPlayer.cs b/Assets/Script/Gameplay/Player/TrackPlayer.cs index 6a2d07214d..ecea727053 100644 --- a/Assets/Script/Gameplay/Player/TrackPlayer.cs +++ b/Assets/Script/Gameplay/Player/TrackPlayer.cs @@ -256,8 +256,13 @@ public override void Initialize(int index, YargPlayer player, SongChart chart, T Engine.SetSpeed(GameManager.SongSpeed); } - GameManager.BeatEventHandler.Audio.Subscribe(MetronomeTick, BeatEventType.Measure); - GameManager.BeatEventHandler.Audio.Subscribe(MetronomeTock, BeatEventType.QuarterNote); + // Samples enter the output buffer when played, while song audio is already buffered. + // Trigger them early so both reach the output device on the authored beat. + double metronomeOffset = -GlobalAudioHandler.PlaybackLatency / 1000.0 * GameManager.SongSpeed; + GameManager.BeatEventHandler.Audio.Subscribe(MetronomeTick, BeatEventType.Measure, + offset: metronomeOffset); + GameManager.BeatEventHandler.Audio.Subscribe(MetronomeTock, BeatEventType.QuarterNote, + offset: metronomeOffset); GameManager.BeatEventHandler.Visual.Subscribe(SunburstEffects.PulseSunburst, BeatEventType.StrongBeat); InitializeTrackEffects(); InitializeCodaEvents(); diff --git a/Assets/Script/Menu/Calibrator/Calibrator.cs b/Assets/Script/Menu/Calibrator/Calibrator.cs index 0b867cebae..f151809a0e 100644 --- a/Assets/Script/Menu/Calibrator/Calibrator.cs +++ b/Assets/Script/Menu/Calibrator/Calibrator.cs @@ -47,6 +47,7 @@ private enum State private YargPlayer _player; #nullable enable private StemMixer? _mixer; + private double _audioStartTime; #nullable disable private bool _hasNavigationScheme; @@ -88,10 +89,7 @@ private void OnMenuInput(YargPlayer player, ref GameInput input) _audioCalibrateText.color = Color.green; _audioCalibrateText.text = Localize.Key("Menu.Calibrator.Detected"); - // GetPosition() is sampled when this callback runs. Move it back to when the - // input event occurred so input processing time does not affect calibration. - double inputAge = InputManager.CurrentInputTime - input.Time; - _calibrationTimes.Add(_mixer.GetPosition() - inputAge); + _calibrationTimes.Add(input.Time - _audioStartTime); break; } } @@ -146,7 +144,10 @@ private void UpdateForState() _mixer = GlobalAudioHandler.LoadCustomFile(file, SPEED, VOLUME); _mixer.SongEnd += OnAudioEnd; + double startCallTime = InputManager.CurrentInputTime; _mixer.Play(); + double endCallTime = InputManager.CurrentInputTime; + _audioStartTime = (startCallTime + endCallTime) / 2; StartCoroutine(AudioCalibrateCoroutine()); break; case State.AudioDone: diff --git a/Assets/Script/Playback/SongRunner.cs b/Assets/Script/Playback/SongRunner.cs index 59b8cb26c0..e639206761 100644 --- a/Assets/Script/Playback/SongRunner.cs +++ b/Assets/Script/Playback/SongRunner.cs @@ -224,8 +224,12 @@ public class SongRunner : IDisposable private readonly SyncCorrectionCalculator _syncCorrection = new(); private double _suppressSyncUntil = double.NegativeInfinity; private double _playbackStartLatency; + private double _expectedPlaybackLead; private double _scheduledAudioStartTime = double.PositiveInfinity; + private double _lastFrameInputTime; + private long _lastFrameTimestamp; + private readonly StemMixer _mixer; public float DebugEffectiveSyncAdjustment => _debugEffectiveSyncAdjustment; @@ -304,18 +308,36 @@ private void Dispose(bool disposing) { if (!_disposed) { - _disposed = true; + if (_syncThread != null) + { + lock (_syncThread) + { + _disposed = true; + } + } + else + { + _disposed = true; + } + if (disposing) { _rewindSource?.Cancel(); _rewindTween?.Kill(); - SyncThreadStopped = !_syncThread.IsAlive || - _syncThread.Join(SYNC_THREAD_SHUTDOWN_TIMEOUT_MS); - if (!SyncThreadStopped) + if (_syncThread != null) + { + SyncThreadStopped = !_syncThread.IsAlive || + _syncThread.Join(SYNC_THREAD_SHUTDOWN_TIMEOUT_MS); + if (!SyncThreadStopped) + { + YargLogger.LogError("Audio sync thread did not stop during song teardown."); + return; + } + } + else { - YargLogger.LogError("Audio sync thread did not stop during song teardown."); - return; + SyncThreadStopped = true; } _syncThread = null; @@ -334,12 +356,21 @@ private void Start() InitializeSongTime(InputTime, 0); PrepareAudioPlayback(); + _lastFrameInputTime = InputManager.CurrentInputTime; + _lastFrameTimestamp = System.Diagnostics.Stopwatch.GetTimestamp(); + _syncThread.Start(); Started = true; } public void Update() { + lock (_syncThread) + { + _lastFrameInputTime = InputManager.CurrentInputTime; + _lastFrameTimestamp = System.Diagnostics.Stopwatch.GetTimestamp(); + } + // Runner is lazy-started to avoid timing issues with lag if (!Started) { @@ -404,7 +435,15 @@ private void SyncThread() { lock (_syncThread) { - double currentTime = InputManager.CurrentInputTime; + if (_disposed) + { + break; + } + + long currentTimestamp = System.Diagnostics.Stopwatch.GetTimestamp(); + double elapsedSeconds = (double) (currentTimestamp - _lastFrameTimestamp) / System.Diagnostics.Stopwatch.Frequency; + double currentTime = _lastFrameInputTime + elapsedSeconds; + double audioOffset = SongOffset - (AudioCalibration * SongSpeed); double syncActualTime = _mixer.GetPosition(); double syncTargetTime = GetRelativeInputTime(currentTime) - audioOffset; From 1e8da020c1bae1656051dc3efa3c8caccca5aabf Mon Sep 17 00:00:00 2001 From: polson <302882+polson@users.noreply.github.com> Date: Mon, 13 Jul 2026 13:55:00 -0500 Subject: [PATCH 17/31] Improved scheduling of metronome hits --- Assets/Script/Audio/Bass/BassAudioManager.cs | 12 -- .../Audio/Bass/BassMetronomeSampleChannel.cs | 26 ++++- Assets/Script/Audio/Bass/BassStemMixer.cs | 65 +++++++++++ Assets/Script/Gameplay/GameManager.Loading.cs | 3 + Assets/Script/Gameplay/GameManager.cs | 2 + Assets/Script/Gameplay/MetronomeScheduler.cs | 108 ++++++++++++++++++ .../Gameplay/MetronomeScheduler.cs.meta | 3 + Assets/Script/Gameplay/Player/TrackPlayer.cs | 19 --- Assets/Script/Playback/SongRunner.cs | 6 + 9 files changed, 212 insertions(+), 32 deletions(-) create mode 100644 Assets/Script/Gameplay/MetronomeScheduler.cs create mode 100644 Assets/Script/Gameplay/MetronomeScheduler.cs.meta diff --git a/Assets/Script/Audio/Bass/BassAudioManager.cs b/Assets/Script/Audio/Bass/BassAudioManager.cs index e06f937371..05c27d805d 100644 --- a/Assets/Script/Audio/Bass/BassAudioManager.cs +++ b/Assets/Script/Audio/Bass/BassAudioManager.cs @@ -178,8 +178,6 @@ public BassAudioManager() Bass.UpdatePeriod, Bass.DeviceBufferLength, Bass.PlaybackBufferLength, PlaybackLatency); YargLogger.LogFormatInfo("Current Device: {0}", Bass.GetDeviceInfo(Bass.CurrentDevice).Name); - - Application.quitting += OnApplicationQuitting; } private void UpdatePlaybackLatency() @@ -594,21 +592,11 @@ protected override void SetBufferLength_Internal(int length) protected override void DisposeUnmanagedResources() { - Application.quitting -= OnApplicationQuitting; - YargLogger.LogInfo("Unloading BASS plugins"); Bass.PluginFree(0); Bass.Free(); } - private void OnApplicationQuitting() - { - Application.quitting -= OnApplicationQuitting; - YargLogger.LogInfo("Application quitting: starting early BASS shutdown"); - GlobalAudioHandler.Close(); - YargLogger.LogInfo("Application quitting: early BASS shutdown complete"); - } - private static string GetBassDirectory() { string pluginDirectory = Path.Combine(Application.dataPath, "Plugins"); diff --git a/Assets/Script/Audio/Bass/BassMetronomeSampleChannel.cs b/Assets/Script/Audio/Bass/BassMetronomeSampleChannel.cs index 2d2a1c0459..6205c9b997 100644 --- a/Assets/Script/Audio/Bass/BassMetronomeSampleChannel.cs +++ b/Assets/Script/Audio/Bass/BassMetronomeSampleChannel.cs @@ -82,6 +82,30 @@ protected override void PlayLo_Internal() } } + protected override int CreateStream_Internal(MetronomePitch pitch) + { + int sampleHandle = pitch == MetronomePitch.Hi ? _hiHandle : _loHandle; + int stream = Bass.SampleGetChannel(sampleHandle, + BassFlags.SampleChannelStream | BassFlags.SampleChannelNew | BassFlags.Decode); + if (stream == 0) + { + YargLogger.LogFormatError("Failed to create {0} {1} stream: {2}!", Sample, pitch, + Bass.LastError); + return 0; + } + + double volume = _volumeSetting * AudioHelpers.MetronomeSamples[(int) Sample].Volume; + if (!Bass.ChannelSetAttribute(stream, ChannelAttribute.Volume, volume)) + { + YargLogger.LogFormatError("Failed to set {0} {1} stream volume: {2}!", Sample, pitch, + Bass.LastError); + Bass.StreamFree(stream); + return 0; + } + + return stream; + } + protected override void SetVolume_Internal(double volume) { _volumeSetting = volume; @@ -112,4 +136,4 @@ protected override void DisposeUnmanagedResources() Bass.SampleFree(_loHandle); } } -} \ No newline at end of file +} diff --git a/Assets/Script/Audio/Bass/BassStemMixer.cs b/Assets/Script/Audio/Bass/BassStemMixer.cs index c92f586050..0b8b8597fe 100644 --- a/Assets/Script/Audio/Bass/BassStemMixer.cs +++ b/Assets/Script/Audio/Bass/BassStemMixer.cs @@ -51,6 +51,7 @@ public StemData(SongStem stem, float[,]? volumeMatrix, StreamHandle streamHandle private float _speed = 1.0f; private Timer _whammySyncTimer; private readonly List _stemDatas = new(); + private readonly HashSet _scheduledOneShots = new(); private int _longestHandle; private readonly BassNormalizer _normalizer = new(); @@ -58,6 +59,68 @@ public StemData(SongStem stem, float[,]? volumeMatrix, StreamHandle streamHandle private int _gainDspHandle; private float _gain = 1.0f; + /// + /// Schedules an owned one-shot stream at an audio-file position. Scheduling consumes the + /// stream handle whether it succeeds or fails. + /// + public override bool ScheduleOneShot(int streamHandle, double songTime) + { + lock (this) + { + if (streamHandle == 0) + { + return false; + } + + long streamLength = Bass.ChannelGetLength(streamHandle); + double lengthSeconds = Bass.ChannelBytes2Seconds(streamHandle, streamLength); + long mixerPosition = Bass.ChannelGetPosition(_mixerHandle); + double startSeconds = songTime - _songPositionTracker.SongStart + + _songPositionTracker.AlignmentDelay; + long start = Bass.ChannelSeconds2Bytes(_mixerHandle, startSeconds); + long length = Bass.ChannelSeconds2Bytes(_mixerHandle, lengthSeconds); + + if (streamLength < 0 || lengthSeconds < 0 || mixerPosition < 0 || start < mixerPosition || + length <= 0) + { + Bass.StreamFree(streamHandle); + return false; + } + + BassFlags flags = BassFlags.MixerChanNoRampin | BassFlags.AutoFree; + if (!BassMix.MixerAddChannel(_mixerHandle, streamHandle, flags, start, length)) + { + YargLogger.LogFormatError("Failed to schedule one-shot stream: {0}!", Bass.LastError); + Bass.StreamFree(streamHandle); + return false; + } + + _scheduledOneShots.Add(streamHandle); + Bass.ChannelSetSync(streamHandle, SyncFlags.Free, 0, + (_, channel, _, _) => + { + lock (this) + { + _scheduledOneShots.Remove(channel); + } + }); + + return true; + } + } + + public override void ClearScheduledOneShots() + { + lock (this) + { + foreach (int streamHandle in _scheduledOneShots.ToArray()) + { + BassMix.MixerRemoveChannel(streamHandle); + } + _scheduledOneShots.Clear(); + } + } + public override event Action SongEnd { add @@ -432,6 +495,7 @@ private void RemoveChannelsFromMixer() YargLogger.LogDebug("Failed to remove channel from mixer"); } } + _scheduledOneShots.Clear(); } private static bool BuildStemData(int sourceStream, IEnumerable stemInfos, @@ -689,6 +753,7 @@ private sealed class SongPositionTracker private double _delay; public double AlignmentDelay => _delay; + public double SongStart => _songStart; public SongPositionTracker(int tempoStreamHandle) { diff --git a/Assets/Script/Gameplay/GameManager.Loading.cs b/Assets/Script/Gameplay/GameManager.Loading.cs index 3c1c1f7c91..91c06a0280 100644 --- a/Assets/Script/Gameplay/GameManager.Loading.cs +++ b/Assets/Script/Gameplay/GameManager.Loading.cs @@ -198,6 +198,9 @@ private async void Start() GlobalVariables.State.SongSpeed, Song.SongOffsetSeconds); + _metronomeScheduler = new MetronomeScheduler(_mixer, _songRunner, Chart.SyncTrack, + SongLength, Song.SongOffsetSeconds); + // Spawn players CreatePlayers(); YargLogger.LogFormatDebug("Calculating star cutoffs for {0} players", _players.Count); diff --git a/Assets/Script/Gameplay/GameManager.cs b/Assets/Script/Gameplay/GameManager.cs index 7d634c6dc0..5c6956a656 100644 --- a/Assets/Script/Gameplay/GameManager.cs +++ b/Assets/Script/Gameplay/GameManager.cs @@ -166,6 +166,7 @@ public int BandCombo private bool _breBoxActive; private StemMixer _mixer; + private MetronomeScheduler _metronomeScheduler; private List _frameTimes; @@ -238,6 +239,7 @@ private void OnDestroy() EngineManager.OnUnisonPhraseSuccess -= OnUnisonPhraseSuccess; // Stop the audio worker before any teardown callback can touch the mixer or UI. + _metronomeScheduler?.Dispose(); _songRunner?.Dispose(); bool canDisposeMixer = _songRunner == null || _songRunner.SyncThreadStopped; diff --git a/Assets/Script/Gameplay/MetronomeScheduler.cs b/Assets/Script/Gameplay/MetronomeScheduler.cs new file mode 100644 index 0000000000..d8d4cb26b8 --- /dev/null +++ b/Assets/Script/Gameplay/MetronomeScheduler.cs @@ -0,0 +1,108 @@ +using System; +using System.Collections.Generic; +using YARG.Core.Audio; +using YARG.Core.Chart; +using YARG.Playback; +using YARG.Settings; + +namespace YARG.Gameplay +{ + /// + /// Owns gameplay metronome scheduling. Hits enter the song mixer and therefore share its + /// buffering, tempo processing, and synchronization corrections. + /// + public sealed class MetronomeScheduler : IDisposable + { + private readonly struct Hit + { + public readonly double Time; + public readonly MetronomePitch Pitch; + + public Hit(double time, MetronomePitch pitch) + { + Time = time; + Pitch = pitch; + } + } + + private readonly StemMixer _mixer; + private readonly SongRunner _songRunner; + private readonly List _hits = new(); + private readonly double _songOffset; + + public MetronomeScheduler(StemMixer mixer, SongRunner songRunner, SyncTrack sync, + double songLength, double songOffset) + { + _mixer = mixer; + _songRunner = songRunner; + _songOffset = songOffset; + + foreach (Beatline beatline in sync.Beatlines) + { + if (beatline.Type == BeatlineType.Measure && beatline.Time <= songLength) + { + _hits.Add(new Hit(beatline.Time, MetronomePitch.Hi)); + } + } + + for (uint tick = 0; ; tick += sync.Resolution) + { + double time = sync.TickToTime(tick); + if (time > songLength) + { + break; + } + + _hits.Add(new Hit(time, MetronomePitch.Lo)); + if (uint.MaxValue - tick < sync.Resolution) + { + break; + } + } + + _hits.Sort((left, right) => left.Time.CompareTo(right.Time)); + _songRunner.AudioPrepared += Schedule; + SettingsManager.Settings.MetronomeSound.OnChange += OnSoundChanged; + SettingsManager.Settings.MetronomeVolume.OnChange += OnVolumeChanged; + } + + private void Schedule(double fromAudioTime) + { + MetronomeSample sample = SettingsManager.Settings.MetronomeSound.Value; + foreach (Hit hit in _hits) + { + double audioTime = hit.Time + _songOffset; + if (audioTime < fromAudioTime) + { + continue; + } + + int stream = GlobalAudioHandler.CreateMetronomeStream(sample, hit.Pitch); + _mixer.ScheduleOneShot(stream, audioTime); + } + } + + private void OnSoundChanged(MetronomeSample _) + { + Reschedule(); + } + + private void OnVolumeChanged(float _) + { + Reschedule(); + } + + private void Reschedule() + { + _mixer.ClearScheduledOneShots(); + Schedule(_mixer.GetPosition()); + } + + public void Dispose() + { + _songRunner.AudioPrepared -= Schedule; + SettingsManager.Settings.MetronomeSound.OnChange -= OnSoundChanged; + SettingsManager.Settings.MetronomeVolume.OnChange -= OnVolumeChanged; + } + } +} diff --git a/Assets/Script/Gameplay/MetronomeScheduler.cs.meta b/Assets/Script/Gameplay/MetronomeScheduler.cs.meta new file mode 100644 index 0000000000..44cdd3b23b --- /dev/null +++ b/Assets/Script/Gameplay/MetronomeScheduler.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: f5f65b8e4e364cbe9f8f5f1c04989ff6 +timeCreated: 1783966538 diff --git a/Assets/Script/Gameplay/Player/TrackPlayer.cs b/Assets/Script/Gameplay/Player/TrackPlayer.cs index ecea727053..778e7f3a83 100644 --- a/Assets/Script/Gameplay/Player/TrackPlayer.cs +++ b/Assets/Script/Gameplay/Player/TrackPlayer.cs @@ -256,13 +256,6 @@ public override void Initialize(int index, YargPlayer player, SongChart chart, T Engine.SetSpeed(GameManager.SongSpeed); } - // Samples enter the output buffer when played, while song audio is already buffered. - // Trigger them early so both reach the output device on the authored beat. - double metronomeOffset = -GlobalAudioHandler.PlaybackLatency / 1000.0 * GameManager.SongSpeed; - GameManager.BeatEventHandler.Audio.Subscribe(MetronomeTick, BeatEventType.Measure, - offset: metronomeOffset); - GameManager.BeatEventHandler.Audio.Subscribe(MetronomeTock, BeatEventType.QuarterNote, - offset: metronomeOffset); GameManager.BeatEventHandler.Visual.Subscribe(SunburstEffects.PulseSunburst, BeatEventType.StrongBeat); InitializeTrackEffects(); InitializeCodaEvents(); @@ -279,8 +272,6 @@ public override void Initialize(int index, YargPlayer player, SongChart chart, T protected override void FinishDestruction() { - GameManager.BeatEventHandler.Audio.Unsubscribe(MetronomeTick); - GameManager.BeatEventHandler.Audio.Unsubscribe(MetronomeTock); GameManager.BeatEventHandler.Visual.Unsubscribe(SunburstEffects.PulseSunburst); _autoCalibrator?.Dispose(); @@ -1235,16 +1226,6 @@ public override void GameplayUpdate() } } - public void MetronomeTick() - { - GlobalAudioHandler.PlayMetronomeSoundEffect(SettingsManager.Settings.MetronomeSound.Value, MetronomePitch.Hi); - } - - public void MetronomeTock() - { - GlobalAudioHandler.PlayMetronomeSoundEffect(SettingsManager.Settings.MetronomeSound.Value, MetronomePitch.Lo); - } - protected override void GameplayDestroy() { GameManager.EngineManager.OnPlayerFailed -= OnPlayerFailed; diff --git a/Assets/Script/Playback/SongRunner.cs b/Assets/Script/Playback/SongRunner.cs index e639206761..f9cc3341c9 100644 --- a/Assets/Script/Playback/SongRunner.cs +++ b/Assets/Script/Playback/SongRunner.cs @@ -69,6 +69,11 @@ namespace YARG.Playback public class SongRunner : IDisposable { + /// + /// Invoked synchronously after the mixer has been rebuilt and positioned, before playback resumes. + /// + public event Action AudioPrepared; + #region Times public const double SONG_START_DELAY = 2; @@ -508,6 +513,7 @@ private void PrepareAudioPlayback() double audioTime = InputTime + (AudioCalibration * SongSpeed) - SongOffset; double seekPosition = Math.Clamp(audioTime + playbackPreRoll, 0, _mixer.Length); _mixer.SetPosition(seekPosition); + AudioPrepared?.Invoke(seekPosition); // When pre-roll would seek before the file, wait until starting position zero will // reach the speakers at the correct song time. Otherwise playback is ready now. From 83a4fc12ad6947b14ddfc353bed0e7ebbc852fba Mon Sep 17 00:00:00 2001 From: polson <302882+polson@users.noreply.github.com> Date: Mon, 13 Jul 2026 15:56:51 -0500 Subject: [PATCH 18/31] More oneshot refactoring --- .../Audio/Bass/BassMetronomeSampleChannel.cs | 11 - Assets/Script/Audio/Bass/BassStemMixer.cs | 216 +++++++++++++----- Assets/Script/Gameplay/MetronomeScheduler.cs | 17 +- 3 files changed, 172 insertions(+), 72 deletions(-) diff --git a/Assets/Script/Audio/Bass/BassMetronomeSampleChannel.cs b/Assets/Script/Audio/Bass/BassMetronomeSampleChannel.cs index 6205c9b997..7bf388dac5 100644 --- a/Assets/Script/Audio/Bass/BassMetronomeSampleChannel.cs +++ b/Assets/Script/Audio/Bass/BassMetronomeSampleChannel.cs @@ -50,7 +50,6 @@ public sealed class BassMetronomeSampleChannel : MetronomeSampleChannel private readonly int _hiChannel; private readonly int _loHandle; private readonly int _loChannel; - private double _volumeSetting = 1; #nullable enable private BassMetronomeSampleChannel(MetronomeSample sample, int hiHandle, int hiChannel, string hiPath, int loHandle, int loChannel, string loPath, @@ -94,21 +93,11 @@ protected override int CreateStream_Internal(MetronomePitch pitch) return 0; } - double volume = _volumeSetting * AudioHelpers.MetronomeSamples[(int) Sample].Volume; - if (!Bass.ChannelSetAttribute(stream, ChannelAttribute.Volume, volume)) - { - YargLogger.LogFormatError("Failed to set {0} {1} stream volume: {2}!", Sample, pitch, - Bass.LastError); - Bass.StreamFree(stream); - return 0; - } - return stream; } protected override void SetVolume_Internal(double volume) { - _volumeSetting = volume; volume *= AudioHelpers.MetronomeSamples[(int) Sample].Volume; if (!Bass.ChannelSetAttribute(_hiChannel, ChannelAttribute.Volume, volume)) diff --git a/Assets/Script/Audio/Bass/BassStemMixer.cs b/Assets/Script/Audio/Bass/BassStemMixer.cs index 0b8b8597fe..8f32e8adf8 100644 --- a/Assets/Script/Audio/Bass/BassStemMixer.cs +++ b/Assets/Script/Audio/Bass/BassStemMixer.cs @@ -51,7 +51,7 @@ public StemData(SongStem stem, float[,]? volumeMatrix, StreamHandle streamHandle private float _speed = 1.0f; private Timer _whammySyncTimer; private readonly List _stemDatas = new(); - private readonly HashSet _scheduledOneShots = new(); + private readonly HashSet _oneShotSchedules = new(); private int _longestHandle; private readonly BassNormalizer _normalizer = new(); @@ -59,65 +59,13 @@ public StemData(SongStem stem, float[,]? volumeMatrix, StreamHandle streamHandle private int _gainDspHandle; private float _gain = 1.0f; - /// - /// Schedules an owned one-shot stream at an audio-file position. Scheduling consumes the - /// stream handle whether it succeeds or fails. - /// - public override bool ScheduleOneShot(int streamHandle, double songTime) + public override OneShotSchedule CreateOneShotSchedule() { lock (this) { - if (streamHandle == 0) - { - return false; - } - - long streamLength = Bass.ChannelGetLength(streamHandle); - double lengthSeconds = Bass.ChannelBytes2Seconds(streamHandle, streamLength); - long mixerPosition = Bass.ChannelGetPosition(_mixerHandle); - double startSeconds = songTime - _songPositionTracker.SongStart + - _songPositionTracker.AlignmentDelay; - long start = Bass.ChannelSeconds2Bytes(_mixerHandle, startSeconds); - long length = Bass.ChannelSeconds2Bytes(_mixerHandle, lengthSeconds); - - if (streamLength < 0 || lengthSeconds < 0 || mixerPosition < 0 || start < mixerPosition || - length <= 0) - { - Bass.StreamFree(streamHandle); - return false; - } - - BassFlags flags = BassFlags.MixerChanNoRampin | BassFlags.AutoFree; - if (!BassMix.MixerAddChannel(_mixerHandle, streamHandle, flags, start, length)) - { - YargLogger.LogFormatError("Failed to schedule one-shot stream: {0}!", Bass.LastError); - Bass.StreamFree(streamHandle); - return false; - } - - _scheduledOneShots.Add(streamHandle); - Bass.ChannelSetSync(streamHandle, SyncFlags.Free, 0, - (_, channel, _, _) => - { - lock (this) - { - _scheduledOneShots.Remove(channel); - } - }); - - return true; - } - } - - public override void ClearScheduledOneShots() - { - lock (this) - { - foreach (int streamHandle in _scheduledOneShots.ToArray()) - { - BassMix.MixerRemoveChannel(streamHandle); - } - _scheduledOneShots.Clear(); + var schedule = new BassOneShot(this); + _oneShotSchedules.Add(schedule); + return schedule; } } @@ -495,7 +443,10 @@ private void RemoveChannelsFromMixer() YargLogger.LogDebug("Failed to remove channel from mixer"); } } - _scheduledOneShots.Clear(); + foreach (var schedule in _oneShotSchedules) + { + schedule.MixerCleared(); + } } private static bool BuildStemData(int sourceStream, IEnumerable stemInfos, @@ -691,6 +642,12 @@ protected override void DisposeManagedResources() protected override void DisposeUnmanagedResources() { + foreach (var schedule in _oneShotSchedules) + { + schedule.MixerDisposed(); + } + _oneShotSchedules.Clear(); + if (_mixerHandle != 0) { if (!Bass.StreamFree(_mixerHandle)) @@ -708,6 +665,149 @@ protected override void DisposeUnmanagedResources() } } + private sealed class BassOneShot : OneShotSchedule + { + private readonly BassStemMixer _mixer; + private readonly HashSet _streams = new(); + private double _volume = 1; + private bool _disposed; + + public BassOneShot(BassStemMixer mixer) + { + _mixer = mixer; + } + + /// + /// Schedules an owned one-shot stream at an audio-file position. Scheduling consumes + /// the stream handle whether it succeeds or fails. + /// + public override bool Schedule(int streamHandle, double songTime) + { + lock (_mixer) + { + if (_disposed || streamHandle == 0) + { + Bass.StreamFree(streamHandle); + return false; + } + + if (!Bass.ChannelSetAttribute(streamHandle, ChannelAttribute.Volume, _volume)) + { + YargLogger.LogFormatError("Failed to set one-shot stream volume: {0}!", Bass.LastError); + Bass.StreamFree(streamHandle); + return false; + } + + long streamLength = Bass.ChannelGetLength(streamHandle); + double lengthSeconds = Bass.ChannelBytes2Seconds(streamHandle, streamLength); + long mixerPosition = Bass.ChannelGetPosition(_mixer._mixerHandle); + double startSeconds = songTime - _mixer._songPositionTracker.SongStart + + _mixer._songPositionTracker.AlignmentDelay; + long start = Bass.ChannelSeconds2Bytes(_mixer._mixerHandle, startSeconds); + long length = Bass.ChannelSeconds2Bytes(_mixer._mixerHandle, lengthSeconds); + + if (streamLength < 0 || lengthSeconds < 0 || mixerPosition < 0 || + start < mixerPosition || length <= 0) + { + Bass.StreamFree(streamHandle); + return false; + } + + BassFlags flags = BassFlags.MixerChanNoRampin | BassFlags.AutoFree; + if (!BassMix.MixerAddChannel(_mixer._mixerHandle, streamHandle, flags, start, length)) + { + YargLogger.LogFormatError("Failed to schedule one-shot stream: {0}!", Bass.LastError); + Bass.StreamFree(streamHandle); + return false; + } + + _streams.Add(streamHandle); + int freeSync = Bass.ChannelSetSync(streamHandle, SyncFlags.Free, 0, + (_, channel, _, _) => + { + lock (_mixer) + { + _streams.Remove(channel); + } + }); + if (freeSync == 0) + { + YargLogger.LogFormatError("Failed to track one-shot stream: {0}!", Bass.LastError); + _streams.Remove(streamHandle); + BassMix.MixerRemoveChannel(streamHandle); + return false; + } + return true; + } + } + + public override void SetVolume(double volume) + { + lock (_mixer) + { + if (_disposed) + { + return; + } + + _volume = volume; + foreach (int stream in _streams) + { + if (!Bass.ChannelSetAttribute(stream, ChannelAttribute.Volume, volume)) + { + YargLogger.LogFormatError("Failed to set one-shot stream volume: {0}!", Bass.LastError); + } + } + } + } + + public override void Clear() + { + lock (_mixer) + { + if (!_disposed) + { + ClearStreams(); + } + } + } + + public override void Dispose() + { + lock (_mixer) + { + if (_disposed) + { + return; + } + + ClearStreams(); + _mixer._oneShotSchedules.Remove(this); + _disposed = true; + } + } + + public void MixerCleared() + { + _streams.Clear(); + } + + public void MixerDisposed() + { + _streams.Clear(); + _disposed = true; + } + + private void ClearStreams() + { + foreach (int stream in _streams.ToArray()) + { + BassMix.MixerRemoveChannel(stream); + } + _streams.Clear(); + } + } + private void CreateChannel(SongStem stem, int sourceHandle, StreamHandle streamHandles, StreamHandle reverbHandles) { var pitchparams = BassAudioManager.SetPitchParams(stem, _speed, streamHandles, reverbHandles); diff --git a/Assets/Script/Gameplay/MetronomeScheduler.cs b/Assets/Script/Gameplay/MetronomeScheduler.cs index d8d4cb26b8..5b5bfae213 100644 --- a/Assets/Script/Gameplay/MetronomeScheduler.cs +++ b/Assets/Script/Gameplay/MetronomeScheduler.cs @@ -26,6 +26,7 @@ public Hit(double time, MetronomePitch pitch) } private readonly StemMixer _mixer; + private readonly OneShotSchedule _schedule; private readonly SongRunner _songRunner; private readonly List _hits = new(); private readonly double _songOffset; @@ -34,6 +35,7 @@ public MetronomeScheduler(StemMixer mixer, SongRunner songRunner, SyncTrack sync double songLength, double songOffset) { _mixer = mixer; + _schedule = mixer.CreateOneShotSchedule(); _songRunner = songRunner; _songOffset = songOffset; @@ -69,6 +71,7 @@ public MetronomeScheduler(StemMixer mixer, SongRunner songRunner, SyncTrack sync private void Schedule(double fromAudioTime) { MetronomeSample sample = SettingsManager.Settings.MetronomeSound.Value; + SetVolume(sample); foreach (Hit hit in _hits) { double audioTime = hit.Time + _songOffset; @@ -78,7 +81,7 @@ private void Schedule(double fromAudioTime) } int stream = GlobalAudioHandler.CreateMetronomeStream(sample, hit.Pitch); - _mixer.ScheduleOneShot(stream, audioTime); + _schedule.Schedule(stream, audioTime); } } @@ -89,20 +92,28 @@ private void OnSoundChanged(MetronomeSample _) private void OnVolumeChanged(float _) { - Reschedule(); + SetVolume(SettingsManager.Settings.MetronomeSound.Value); } private void Reschedule() { - _mixer.ClearScheduledOneShots(); + _schedule.Clear(); Schedule(_mixer.GetPosition()); } + private void SetVolume(MetronomeSample sample) + { + double volume = GlobalAudioHandler.GetTrueVolume(SongStem.Metronome) * + AudioHelpers.MetronomeSamples[(int) sample].Volume; + _schedule.SetVolume(volume); + } + public void Dispose() { _songRunner.AudioPrepared -= Schedule; SettingsManager.Settings.MetronomeSound.OnChange -= OnSoundChanged; SettingsManager.Settings.MetronomeVolume.OnChange -= OnVolumeChanged; + _schedule.Dispose(); } } } From b9d94950e7be3bc6391050ffb5d41aebf34f1060 Mon Sep 17 00:00:00 2001 From: polson <302882+polson@users.noreply.github.com> Date: Mon, 13 Jul 2026 16:08:48 -0500 Subject: [PATCH 19/31] Refactor position handling to simplify song runner --- Assets/Script/Audio/Bass/BassStemMixer.cs | 37 ++- .../Audio/Bass/BufferedPlaybackTimeline.cs | 256 ++++++++++++++++++ .../Bass/BufferedPlaybackTimeline.cs.meta | 2 + Assets/Script/Playback/SongRunner.cs | 89 ++---- .../Playback/SyncCorrectionCalculator.cs | 192 ------------- .../Playback/SyncCorrectionCalculator.cs.meta | 2 - 6 files changed, 315 insertions(+), 263 deletions(-) create mode 100644 Assets/Script/Audio/Bass/BufferedPlaybackTimeline.cs create mode 100644 Assets/Script/Audio/Bass/BufferedPlaybackTimeline.cs.meta delete mode 100644 Assets/Script/Playback/SyncCorrectionCalculator.cs delete mode 100644 Assets/Script/Playback/SyncCorrectionCalculator.cs.meta diff --git a/Assets/Script/Audio/Bass/BassStemMixer.cs b/Assets/Script/Audio/Bass/BassStemMixer.cs index 8f32e8adf8..1c24358627 100644 --- a/Assets/Script/Audio/Bass/BassStemMixer.cs +++ b/Assets/Script/Audio/Bass/BassStemMixer.cs @@ -46,6 +46,7 @@ public StemData(SongStem stem, float[,]? volumeMatrix, StreamHandle streamHandle private readonly List _sourceHandles = new(); private readonly int _tempoStreamHandle; private readonly SongPositionTracker _songPositionTracker; + private readonly BufferedPlaybackTimeline _playbackTimeline; private bool _didSeek; private int _songEndHandle; private float _speed = 1.0f; @@ -103,6 +104,7 @@ internal BassStemMixer(string name, BassAudioManager manager, float speed, doubl { _tempoStreamHandle = BassFx.TempoCreate(handle, BassFlags.SampleOverrideLowestVolume); _songPositionTracker = new SongPositionTracker(_tempoStreamHandle); + _playbackTimeline = new BufferedPlaybackTimeline(speed); if (_tempoStreamHandle == 0) { YargLogger.LogFormatError("Failed to create tempo stream: {0}", Bass.LastError); @@ -156,6 +158,7 @@ protected override int Play_Internal() } Bass.ChannelUpdate(_tempoStreamHandle, 0); _didSeek = false; + _playbackTimeline.Play(); } if (IsWhammyEnabled) @@ -210,6 +213,8 @@ protected override int Pause_Internal() return (int) Bass.LastError; } + _playbackTimeline.Pause(); + return 0; } @@ -218,6 +223,13 @@ protected override double GetPosition_Internal() return _songPositionTracker.GetSongPosition(); } + protected override PlaybackPosition GetPlaybackPosition_Internal() + { + double rawPosition = _songPositionTracker.GetSongPosition(); + double tempoLatency = BassLatencyProvider.GetTempoStreamLatency(_tempoStreamHandle); + return _playbackTimeline.GetPosition(rawPosition, tempoLatency); + } + protected override double GetTempoStreamLatency_Internal() { return BassLatencyProvider.GetTempoStreamLatency(_tempoStreamHandle); @@ -225,7 +237,7 @@ protected override double GetTempoStreamLatency_Internal() protected override double GetPlaybackStartLatency_Internal() { - return BassLatencyProvider.GetOutputTransitionLatency() + _songPositionTracker.AlignmentDelay; + return Math.Max(0, _playbackTimeline.OutputLatency) + _songPositionTracker.AlignmentDelay; } protected override double GetVolume_Internal() @@ -255,6 +267,7 @@ protected override void SetPosition_Internal(double position) { YargLogger.LogFormatError("Failed to reset tempo stream position: {0}!", Bass.LastError); } + _playbackTimeline.Reset(_songPositionTracker.GetSongPosition()); } if (wasPlaying) @@ -332,14 +345,26 @@ protected override int GetLevel_Internal(float[] level) protected override void SetSpeed_Internal(float speed, bool shiftPitch) { - speed = (float) Math.Clamp(speed, 0.05, 50); - if (_speed == speed) + SetPlaybackSpeed_Internal(speed, 0f, shiftPitch); + } + + protected override void SetPlaybackSpeed_Internal(float songSpeed, float syncAdjustment, bool shiftPitch) + { + float speed = (float) Math.Clamp(songSpeed + syncAdjustment, 0.05, 50); + syncAdjustment = speed - songSpeed; + if (_speed != speed) { - return; + _speed = speed; + BassAudioManager.SetSpeed(speed, _tempoStreamHandle, shiftPitch); } - _speed = speed; - BassAudioManager.SetSpeed(speed, _tempoStreamHandle, shiftPitch); + double tempoLatency = BassLatencyProvider.GetTempoStreamLatency(_tempoStreamHandle); + _playbackTimeline.SetSpeed(songSpeed, syncAdjustment, tempoLatency); + } + + protected override void SetOutputLatency_Internal(double latency) + { + _playbackTimeline.SetOutputLatency(latency); } protected override bool AddChannels_Internal(Stream stream, params StemInfo[] stemInfos) diff --git a/Assets/Script/Audio/Bass/BufferedPlaybackTimeline.cs b/Assets/Script/Audio/Bass/BufferedPlaybackTimeline.cs new file mode 100644 index 0000000000..4b470f656e --- /dev/null +++ b/Assets/Script/Audio/Bass/BufferedPlaybackTimeline.cs @@ -0,0 +1,256 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using YARG.Core.Audio; + +namespace YARG.Audio.BASS +{ + /// + /// Models mixer playback without exposing tempo and output-buffer delay to callers. + /// + internal sealed class BufferedPlaybackTimeline + { + private readonly Func _getTime; + private readonly PositionModel _immediate = new(); + private readonly PositionModel _tempoOutput = new(); + + private double _retainedHistorySeconds; + private double _outputLatency; + private float _songSpeed; + private float _syncAdjustment; + private bool _isPlaying; + + public double OutputLatency => _outputLatency; + + public BufferedPlaybackTimeline(float speed) + : this(speed, GetCurrentTime) + { + } + + internal BufferedPlaybackTimeline(float speed, Func getTime) + { + _songSpeed = speed; + _getTime = getTime; + Reset(0); + } + + public PlaybackPosition GetPosition(double rawPosition, double tempoLatency) + { + double now = _getTime(); + tempoLatency = Math.Max(0, tempoLatency); + _retainedHistorySeconds = Math.Max( + _retainedHistorySeconds, + tempoLatency + Math.Abs(_outputLatency)); + + double modeledTempoPosition = _tempoOutput.GetPosition(now); + double modeledHeardPosition = _tempoOutput.GetPosition(now - _outputLatency); + + // Correct modeled output with observed BASS position. Same correction is applied to + // delay-free model, which is Smith-predictor feedback for model/clock drift. + double modelError = rawPosition - modeledTempoPosition; + double heardPosition = modeledHeardPosition + modelError; + double controlPosition = _immediate.GetPosition(now) + modelError; + + double cutoff = now - _retainedHistorySeconds - 1.0; + _immediate.PruneBefore(cutoff); + _tempoOutput.PruneBefore(cutoff); + return new PlaybackPosition(heardPosition, controlPosition); + } + + public void SetOutputLatency(double latency) + { + double latencyChange = latency - _outputLatency; + if (latencyChange != 0) + { + _immediate.Shift(-latencyChange * CurrentRate); + } + + _outputLatency = latency; + _retainedHistorySeconds = Math.Max(_retainedHistorySeconds, Math.Abs(_outputLatency)); + } + + public void SetSpeed(float songSpeed, float syncAdjustment, double tempoLatency) + { + if (_songSpeed == songSpeed && _syncAdjustment == syncAdjustment) + { + return; + } + + _songSpeed = songSpeed; + _syncAdjustment = syncAdjustment; + if (!_isPlaying) + { + return; + } + + double now = _getTime(); + float rate = CurrentRate; + _immediate.SetRate(now, rate); + _tempoOutput.SetRate(now + Math.Max(0, tempoLatency), rate); + } + + public void Play() + { + if (_isPlaying) + { + return; + } + + _isPlaying = true; + double now = _getTime(); + _immediate.SetRate(now, CurrentRate); + _tempoOutput.SetRate(now, CurrentRate); + } + + public void Pause() + { + if (!_isPlaying) + { + return; + } + + _isPlaying = false; + double now = _getTime(); + _immediate.Stop(now); + _tempoOutput.Stop(now); + } + + public void Reset(double songPosition) + { + double now = _getTime(); + float rate = _isPlaying ? CurrentRate : 0f; + double controlPosition = songPosition - _outputLatency * CurrentRate; + _immediate.Reset(now, controlPosition, rate); + _tempoOutput.Reset(now, songPosition, rate); + } + + private float CurrentRate => _songSpeed + _syncAdjustment; + + private static double GetCurrentTime() + { + return (double) Stopwatch.GetTimestamp() / Stopwatch.Frequency; + } + + private sealed class PositionModel + { + private readonly List _changes = new(); + private double _startPosition; + + public void Reset(double timestamp, double position, float rate) + { + _startPosition = position; + _changes.Clear(); + _changes.Add(new RateChange(timestamp, rate)); + } + + public void Shift(double positionDelta) + { + _startPosition += positionDelta; + } + + public void Stop(double timestamp) + { + RemoveChangesAfter(timestamp); + SetRate(timestamp, 0f); + } + + public void SetRate(double timestamp, float rate) + { + for (int i = 0; i < _changes.Count; i++) + { + if (_changes[i].Timestamp == timestamp) + { + _changes[i] = new RateChange(timestamp, rate); + return; + } + + if (_changes[i].Timestamp > timestamp) + { + _changes.Insert(i, new RateChange(timestamp, rate)); + return; + } + } + + _changes.Add(new RateChange(timestamp, rate)); + } + + public double GetPosition(double timestamp) + { + RateChange first = _changes[0]; + if (timestamp <= first.Timestamp) + { + return _startPosition; + } + + double position = _startPosition; + double intervalStart = first.Timestamp; + float rate = first.Rate; + for (int i = 1; i < _changes.Count; i++) + { + RateChange change = _changes[i]; + if (change.Timestamp > timestamp) + { + break; + } + + position += rate * (change.Timestamp - intervalStart); + intervalStart = change.Timestamp; + rate = change.Rate; + } + + return position + rate * (timestamp - intervalStart); + } + + public void PruneBefore(double timestamp) + { + if (_changes.Count < 2 || _changes[1].Timestamp > timestamp) + { + return; + } + + double position = GetPosition(timestamp); + float rate = GetRate(timestamp); + int removeCount = 0; + while (removeCount < _changes.Count && _changes[removeCount].Timestamp <= timestamp) + { + removeCount++; + } + + _changes.RemoveRange(0, removeCount); + _changes.Insert(0, new RateChange(timestamp, rate)); + _startPosition = position; + } + + private float GetRate(double timestamp) + { + float rate = _changes[0].Rate; + for (int i = 1; i < _changes.Count && _changes[i].Timestamp <= timestamp; i++) + { + rate = _changes[i].Rate; + } + return rate; + } + + private void RemoveChangesAfter(double timestamp) + { + int index = _changes.FindIndex(change => change.Timestamp > timestamp); + if (index >= 0) + { + _changes.RemoveRange(index, _changes.Count - index); + } + } + + private readonly struct RateChange + { + public readonly double Timestamp; + public readonly float Rate; + + public RateChange(double timestamp, float rate) + { + Timestamp = timestamp; + Rate = rate; + } + } + } + } +} diff --git a/Assets/Script/Audio/Bass/BufferedPlaybackTimeline.cs.meta b/Assets/Script/Audio/Bass/BufferedPlaybackTimeline.cs.meta new file mode 100644 index 0000000000..ee881df5a4 --- /dev/null +++ b/Assets/Script/Audio/Bass/BufferedPlaybackTimeline.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4cf0dc30c74c4933b361db6f947a90bb diff --git a/Assets/Script/Playback/SongRunner.cs b/Assets/Script/Playback/SongRunner.cs index f9cc3341c9..c70274c5a7 100644 --- a/Assets/Script/Playback/SongRunner.cs +++ b/Assets/Script/Playback/SongRunner.cs @@ -204,7 +204,6 @@ public class SongRunner : IDisposable private double _forceStartTime = double.NaN; - private readonly Queue<(double EffectiveTime, float Speed)> _scheduledSpeedChanges = new(); #endregion #region Rewind State @@ -226,7 +225,9 @@ public class SongRunner : IDisposable private volatile float _debugSyncStartDelta; private volatile float _debugSyncWorstDelta; - private readonly SyncCorrectionCalculator _syncCorrection = new(); + private const double SYNC_DEADBAND_SECONDS = 0.0015; + private const float CORRECTION_TIME_SECONDS = 0.1f; + private const float SYNC_CLAMP = 0.50f; private double _suppressSyncUntil = double.NegativeInfinity; private double _playbackStartLatency; private double _expectedPlaybackLead; @@ -411,11 +412,6 @@ public void Update() } } - lock (_syncThread) - { - ApplyScheduledSpeedChanges(InputManager.InputUpdateTime); - } - if (Paused) return; @@ -449,10 +445,9 @@ private void SyncThread() double elapsedSeconds = (double) (currentTimestamp - _lastFrameTimestamp) / System.Diagnostics.Stopwatch.Frequency; double currentTime = _lastFrameInputTime + elapsedSeconds; - double audioOffset = SongOffset - (AudioCalibration * SongSpeed); - double syncActualTime = _mixer.GetPosition(); - double syncTargetTime = GetRelativeInputTime(currentTime) - audioOffset; - double syncError = syncTargetTime - syncActualTime; + double syncTargetTime = GetRelativeInputTime(currentTime) - SongOffset; + PlaybackPosition playbackPosition = _mixer.GetPlaybackPosition(); + double syncError = syncTargetTime - playbackPosition.Control; SyncError = syncError; StartPreparedAudio(currentTime, syncTargetTime); @@ -461,16 +456,21 @@ private void SyncThread() !Paused && syncTargetTime >= 0 && syncTargetTime < _mixer.Length && - syncActualTime < _mixer.Length; + playbackPosition.Heard < _mixer.Length; bool isSuppressing = currentTime < _suppressSyncUntil; bool allowCorrection = isWithinSongBounds && !isSuppressing; - float adjustment = _syncCorrection.Update( - syncDeltaSeconds: syncError, - currentTime: currentTime, - latency: _mixer.GetTempoStreamLatency(), - allowCorrection: allowCorrection); - _debugEffectiveSyncAdjustment = _syncCorrection.EffectiveAdjustment; + // Converts delay-free playback error into a temporary speed adjustment. + // Audio pipeline delay is modeled by the mixer and does not belong in this controller. + float adjustment = 0f; + if (allowCorrection && Math.Abs(syncError) >= SYNC_DEADBAND_SECONDS) + { + adjustment = Math.Clamp( + (float) syncError / CORRECTION_TIME_SECONDS, + -SYNC_CLAMP, + SYNC_CLAMP); + } + _debugEffectiveSyncAdjustment = adjustment; if (!isWithinSongBounds) { continue; @@ -490,7 +490,7 @@ private void SyncThread() if (adjustment != _syncSpeedAdjustment) { _syncSpeedAdjustment = adjustment; - _mixer.SetSpeed(RealSongSpeed, false); + _mixer.SetPlaybackSpeed(SongSpeed, adjustment, false); } } } @@ -510,7 +510,7 @@ private void PrepareAudioPlayback() double playbackPreRoll = _playbackStartLatency * SongSpeed; // Audio-file position corresponding to the current paused gameplay timeline. - double audioTime = InputTime + (AudioCalibration * SongSpeed) - SongOffset; + double audioTime = InputTime - SongOffset; double seekPosition = Math.Clamp(audioTime + playbackPreRoll, 0, _mixer.Length); _mixer.SetPosition(seekPosition); AudioPrepared?.Invoke(seekPosition); @@ -538,12 +538,11 @@ private void StartPreparedAudio(double currentTime, double syncTargetTime) private void ResetSync() { - _syncCorrection.Reset(); _syncSpeedAdjustment = 0f; _debugEffectiveSyncAdjustment = 0f; _debugSyncStartDelta = 0f; _debugSyncWorstDelta = 0f; - _mixer.SetSpeed(RealSongSpeed, true); + _mixer.SetPlaybackSpeed(SongSpeed, 0f, true); } public double GetRelativeInputTime(double timeFromInputSystem) @@ -551,23 +550,6 @@ public double GetRelativeInputTime(double timeFromInputSystem) return (timeFromInputSystem - InputTimeOffset) * SongSpeed; } - private void ApplyScheduledSpeedChanges(double nowInputSystemTime) - { - while (_scheduledSpeedChanges.Count > 0 && - _scheduledSpeedChanges.Peek().EffectiveTime <= nowInputSystemTime) - { - var change = _scheduledSpeedChanges.Dequeue(); - - // Keep the timeline continuous at the predicted activation time. If the frame - // arrived late, advance from that activation point using the new speed. - double inputTimeAtActivation = GetRelativeInputTime(change.EffectiveTime); - double inputTime = inputTimeAtActivation + - (nowInputSystemTime - change.EffectiveTime) * change.Speed; - SongSpeed = change.Speed; - SetInputBaseAt(inputTime, nowInputSystemTime); - } - } - private void UpdateTimes() { // Re-anchoring can use a newer on-demand input timestamp than this frame's cached @@ -577,7 +559,7 @@ private void UpdateTimes() SongTime = InputTime + (AudioCalibration * SongSpeed); VisualTime = InputTime + (VideoCalibration * SongSpeed); - AudioPlaybackTime = _mixer.GetPosition(); + AudioPlaybackTime = _mixer.GetPlaybackPosition().Heard; } private void SetInputBase(double songTime) @@ -598,7 +580,7 @@ private void SetInputBaseAt(double songTime, double inputSystemTime) InputTime = GetRelativeInputTime(inputSystemTime); SongTime = InputTime + (AudioCalibration * SongSpeed); VisualTime = InputTime + (VideoCalibration * SongSpeed); - AudioPlaybackTime = _mixer.GetPosition(); + AudioPlaybackTime = _mixer.GetPlaybackPosition().Heard; YargLogger.LogFormatDebug( "Set input time base.\n" + @@ -651,9 +633,6 @@ public void SetSongTime(double time, double delayTime = SONG_START_DELAY) { lock (_syncThread) { - // Seeking flushes the tempo stream, so pending tempo changes no longer have a - // meaningful activation time. Apply the latest requested speed immediately. - _scheduledSpeedChanges.Clear(); SongSpeed = _requestedSongSpeed; _suppressSyncUntil = double.NegativeInfinity; @@ -681,14 +660,7 @@ public void SetSongSpeed(float speed) } _requestedSongSpeed = speed; - if (Started && !Paused) - { - ScheduleSpeedChange(speed); - } - else - { - ApplyImmediateSpeedChange(speed); - } + ApplySpeedChange(speed); } YargLogger.LogFormatDebug("Set song speed to {0:0.00}.\n" @@ -696,20 +668,10 @@ public void SetSongSpeed(float speed) speed, SongTime, VisualTime, InputTime); } - private void ScheduleSpeedChange(float speed) - { - double effectiveTime = InputManager.CurrentInputTime + _mixer.GetTempoStreamLatency(); - _scheduledSpeedChanges.Enqueue((effectiveTime, speed)); - ResetSync(); - _suppressSyncUntil = Math.Max(_suppressSyncUntil, effectiveTime); - _mixer.SetSpeed(speed, true); - } - - private void ApplyImmediateSpeedChange(float speed) + private void ApplySpeedChange(float speed) { double inputTime = InputTime; SongSpeed = speed; - _scheduledSpeedChanges.Clear(); ResetSync(); _suppressSyncUntil = double.NegativeInfinity; SetInputBaseChecked(inputTime); @@ -728,6 +690,7 @@ public void UpdateCalibration() AudioCalibration = audioCalibrationMs / 1000.0; VideoCalibration = videoCalibrationMs / 1000.0; + _mixer.SetOutputLatency(AudioCalibration); SetInputBase(InputTime); } diff --git a/Assets/Script/Playback/SyncCorrectionCalculator.cs b/Assets/Script/Playback/SyncCorrectionCalculator.cs deleted file mode 100644 index 8d977391a7..0000000000 --- a/Assets/Script/Playback/SyncCorrectionCalculator.cs +++ /dev/null @@ -1,192 +0,0 @@ -using System; -using YARG.Helpers; - -namespace YARG.Playback -{ - // Keeps playback synchronized by turning the difference between the expected and actual song - // position into a temporary playback-speed adjustment. Speed changes do not take effect - // immediately: they must first pass through the buffered tempo stream. The stream delay is the - // time between sending a speed change and that change reaching playback. Each adjustment is - // therefore recorded for that length of time, along with how much timing error it should - // correct. That pending correction is subtracted from the latest measured error so the - // controller does not repeatedly react to work that is already in progress. - // - // In simplified terms: - // pending correction = sum(applied speed adjustment * elapsed time) - // remaining error = measured timing error - pending correction - // new speed adjustment = remaining error * gain / stream delay - // For example, running 10% faster for 20 ms contributes 2 ms of pending correction. - // - // Once pending work is removed, errors below the deadband are ignored to prevent constant - // speed changes from tiny clock fluctuations. Larger errors are multiplied by a gain based on - // stream delay, converted into a playback-rate adjustment, and capped by SYNC_CLAMP. Tiny - // changes to the requested adjustment are retained until they cross the update threshold, - // avoiding needless tempo-stream updates while keeping pending-correction history aligned with - // the adjustment requested from playback. - internal sealed class SyncCorrectionCalculator - { - // Ignore sync errors smaller than 1.5 ms to avoid reacting to tiny clock fluctuations. - private const double SYNC_DEADBAND_SECONDS = 0.0015; - - // Number of seconds over which we aim to recover from a timing error. - // For example, recovering 10 ms over 100 ms requires running 10% faster. - private const float CORRECTION_TIME_SECONDS = 0.1f; - - private const double MIN_LATENCY_SECONDS = 0.001; - - // Correct at least 40% of the remaining error, even with a short stream delay. - private const float MIN_GAIN = 0.4f; - - // Correct at most 85% of the remaining error to reduce overshoot. - private const float MAX_GAIN = 0.85f; - - // Never adjust playback speed by more than 50% in either direction. - private const float SYNC_CLAMP = 0.50f; - - private const float SPEED_UPDATE_THRESHOLD = 0.0005f; - - private readonly SyncHistoryBuffer _syncHistory = new(); - private float _currentAdjustment; - - public float EffectiveAdjustment => _syncHistory.EffectiveAdjustment; - - /// - /// Updates buffered correction history and returns the playback-speed change to request. - /// - /// How far playback is behind or ahead, in seconds. - /// Current input-clock time, in seconds. - /// Time before a speed change reaches audio output, in seconds. - /// Whether a new sync correction may be requested. - /// Speed adjustment to apply, where 0.1 means 10% faster. - public float Update(double syncDeltaSeconds, double currentTime, double latency, bool allowCorrection) - { - latency = Math.Max(MIN_LATENCY_SECONDS, latency); - _syncHistory.Update(currentTime, latency, _currentAdjustment); - - float adjustment = allowCorrection ? CalculateAdjustment(syncDeltaSeconds, latency) : 0f; - if (allowCorrection && adjustment != 0f && - Math.Abs(adjustment - _currentAdjustment) < SPEED_UPDATE_THRESHOLD) - { - return _currentAdjustment; - } - - _currentAdjustment = adjustment; - _syncHistory.RecordChange(currentTime, _currentAdjustment); - return _currentAdjustment; - } - - private float CalculateAdjustment(double syncDeltaSeconds, double latency) - { - double errorSeconds = syncDeltaSeconds - _syncHistory.RunningContributionSeconds; - if (Math.Abs(errorSeconds) < SYNC_DEADBAND_SECONDS) - { - return 0f; - } - - float gain = Math.Clamp((float) latency / CORRECTION_TIME_SECONDS, MIN_GAIN, MAX_GAIN); - float correctionSeconds = (float) errorSeconds * gain; - return Math.Clamp(correctionSeconds / (float) latency, -SYNC_CLAMP, SYNC_CLAMP); - } - - /// - /// Clears remembered speed corrections. Call when playback restarts, seeks, or changes speed - /// so corrections from the previous playback state do not affect the new one. - /// - public void Reset() - { - _syncHistory.Clear(); - _currentAdjustment = 0f; - } - - /// - /// Remembers when requested playback-speed adjustments change while they pass through the - /// buffered audio stream. Pending correction is the integral of those adjustments over the - /// stream-delay window. Absolute timestamps make history independent of update cadence. - /// - private sealed class SyncHistoryBuffer - { - // Covers five seconds even if the requested adjustment changes every millisecond. - private readonly RingBuffer _entries = new(5012); - private double _historyStartTime = double.NaN; - - public double RunningContributionSeconds { get; private set; } - public float EffectiveAdjustment { get; private set; } - - public void Clear() - { - _entries.Clear(); - _historyStartTime = double.NaN; - RunningContributionSeconds = 0.0; - EffectiveAdjustment = 0f; - } - - public void RecordChange(double timestamp, float adjustment) - { - if (_entries.Count == 0) - { - _historyStartTime = timestamp; - _entries.Add(new Entry(timestamp, adjustment)); - return; - } - - int latestIndex = _entries.Count - 1; - var latest = _entries[latestIndex]; - if (latest.Timestamp == timestamp) - { - _entries[latestIndex] = new Entry(timestamp, adjustment); - } - else if (latest.Adjustment != adjustment) - { - _entries.Add(new Entry(timestamp, adjustment)); - } - } - - public void Update(double currentTime, double latency, float currentAdjustment) - { - if (_entries.Count == 0) - { - RecordChange(currentTime, currentAdjustment); - } - - double cutoffTime = currentTime - latency; - while (_entries.Count > 1 && _entries[1].Timestamp <= cutoffTime) - { - _entries.RemoveOldest(); - } - - bool historyReachesCutoff = _historyStartTime <= cutoffTime; - EffectiveAdjustment = historyReachesCutoff ? _entries[0].Adjustment : 0f; - - double intervalStart = Math.Max(cutoffTime, _historyStartTime); - float adjustment = _entries[0].Adjustment; - double contributionSeconds = 0.0; - for (int i = 1; i < _entries.Count; i++) - { - var entry = _entries[i]; - if (entry.Timestamp > intervalStart) - { - contributionSeconds += adjustment * (entry.Timestamp - intervalStart); - } - - intervalStart = Math.Max(intervalStart, entry.Timestamp); - adjustment = entry.Adjustment; - } - - contributionSeconds += adjustment * (currentTime - intervalStart); - RunningContributionSeconds = contributionSeconds; - } - - private readonly struct Entry - { - public readonly double Timestamp; - public readonly float Adjustment; - - public Entry(double timestamp, float adjustment) - { - Timestamp = timestamp; - Adjustment = adjustment; - } - } - } - } -} diff --git a/Assets/Script/Playback/SyncCorrectionCalculator.cs.meta b/Assets/Script/Playback/SyncCorrectionCalculator.cs.meta deleted file mode 100644 index 91920d70e7..0000000000 --- a/Assets/Script/Playback/SyncCorrectionCalculator.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 3b4c933ee79546ba84803b45d81c3c8e From c3ac846b4fc4408e4ac066e0e10fc730a4b2d299 Mon Sep 17 00:00:00 2001 From: polson <302882+polson@users.noreply.github.com> Date: Mon, 13 Jul 2026 18:05:25 -0500 Subject: [PATCH 20/31] Simplify handling playback/seek latency --- Assets/Script/Audio/Bass/BassStemMixer.cs | 56 ++++++++++----- .../Audio/Bass/BufferedPlaybackTimeline.cs | 12 +++- Assets/Script/Playback/SongRunner.cs | 70 +++++++------------ 3 files changed, 73 insertions(+), 65 deletions(-) diff --git a/Assets/Script/Audio/Bass/BassStemMixer.cs b/Assets/Script/Audio/Bass/BassStemMixer.cs index 1c24358627..4455d555d6 100644 --- a/Assets/Script/Audio/Bass/BassStemMixer.cs +++ b/Assets/Script/Audio/Bass/BassStemMixer.cs @@ -48,7 +48,9 @@ public StemData(SongStem stem, float[,]? volumeMatrix, StreamHandle streamHandle private readonly SongPositionTracker _songPositionTracker; private readonly BufferedPlaybackTimeline _playbackTimeline; private bool _didSeek; + private bool _preparedBeyondEnd; private int _songEndHandle; + private float _songSpeed = 1.0f; private float _speed = 1.0f; private Timer _whammySyncTimer; private readonly List _stemDatas = new(); @@ -152,13 +154,17 @@ protected override int Play_Internal() if (!IsPlaying) { + // Start the logical transport before BASS. ChannelPlay/ChannelUpdate can block while + // priming output; that time is real playback time and must not become sync error. + double startupLatency = BassLatencyProvider.GetOutputTransitionLatency(); + _playbackTimeline.Play(startupLatency); if (!Bass.ChannelPlay(_tempoStreamHandle, Restart: _didSeek)) { + _playbackTimeline.Pause(); return (int) Bass.LastError; } Bass.ChannelUpdate(_tempoStreamHandle, 0); _didSeek = false; - _playbackTimeline.Play(); } if (IsWhammyEnabled) @@ -205,6 +211,7 @@ protected override int Pause_Internal() { if (!IsPlaying) { + _playbackTimeline.Pause(); return 0; } @@ -235,7 +242,7 @@ protected override double GetTempoStreamLatency_Internal() return BassLatencyProvider.GetTempoStreamLatency(_tempoStreamHandle); } - protected override double GetPlaybackStartLatency_Internal() + private double GetPlaybackStartLatency() { return Math.Max(0, _playbackTimeline.OutputLatency) + _songPositionTracker.AlignmentDelay; } @@ -254,20 +261,26 @@ protected override void SetPosition_Internal(double position) var wasPlaying = IsPlaying; Pause_Internal(); + double playbackPreRoll = GetPlaybackStartLatency() * _songSpeed; + double preparedPosition = position + playbackPreRoll; + double seekPosition = Math.Clamp(preparedPosition, 0, _length); + double playbackDelay = Math.Max(0, -preparedPosition); + _preparedBeyondEnd = position >= _length; + RemoveChannelsFromMixer(); - if (AddChannelsToMixer(_stemDatas, out double delay)) + if (AddChannelsToMixer(_stemDatas, playbackDelay, out double alignmentDelay)) { foreach (var channel in _channels) { - channel.SetPosition(position); + channel.SetPosition(seekPosition); } _didSeek = true; - _songPositionTracker.Reset(position, delay); + _songPositionTracker.Reset(seekPosition, alignmentDelay, playbackDelay); if (!Bass.ChannelSetPosition(_tempoStreamHandle, 0)) { YargLogger.LogFormatError("Failed to reset tempo stream position: {0}!", Bass.LastError); } - _playbackTimeline.Reset(_songPositionTracker.GetSongPosition()); + _playbackTimeline.PreparePlayback(_songPositionTracker.GetSongPosition(), position); } if (wasPlaying) @@ -352,6 +365,7 @@ protected override void SetPlaybackSpeed_Internal(float songSpeed, float syncAdj { float speed = (float) Math.Clamp(songSpeed + syncAdjustment, 0.05, 50); syncAdjustment = speed - songSpeed; + _songSpeed = songSpeed; if (_speed != speed) { _speed = speed; @@ -393,12 +407,12 @@ protected override bool AddChannels_Internal(Stream stream, params StemInfo[] st _stemDatas.AddRange(stemDatas); RemoveChannelsFromMixer(); - if (!AddChannelsToMixer(_stemDatas, out double delay)) + if (!AddChannelsToMixer(_stemDatas, 0, out double delay)) { _stemDatas.RemoveAll(stemDatas.Contains); return false; } - _songPositionTracker.SetDelay(delay); + _songPositionTracker.SetAlignmentDelay(delay); foreach (var stemStreamData in stemDatas) { @@ -523,12 +537,13 @@ private static bool BuildStemData(int sourceStream, IEnumerable stemIn return false; } - private bool AddChannelsToMixer(IEnumerable stemStreamDataList, out double delay) + private bool AddChannelsToMixer(IEnumerable stemStreamDataList, double playbackDelay, + out double alignmentDelay) { var stemData = stemStreamDataList.ToArray(); // Align every stem with the largest pitch fx latency. - delay = stemData.Max(data => data.PitchFxDelay); + alignmentDelay = stemData.Max(data => data.PitchFxDelay); foreach (var data in stemData) { @@ -539,7 +554,7 @@ private bool AddChannelsToMixer(IEnumerable stemStreamDataList, out do // Each stem already incurs its own processing delay. Add the difference from the maximum so every // stem has the same total delay. - double addedDelay = delay - data.PitchFxDelay; + double addedDelay = playbackDelay + alignmentDelay - data.PitchFxDelay; long delayBytes = Bass.ChannelSeconds2Bytes(_mixerHandle, addedDelay); var flags = volumeMatrix != null ? BassFlags.MixerChanMatrix : BassFlags.Default; @@ -727,7 +742,7 @@ public override bool Schedule(int streamHandle, double songTime) double lengthSeconds = Bass.ChannelBytes2Seconds(streamHandle, streamLength); long mixerPosition = Bass.ChannelGetPosition(_mixer._mixerHandle); double startSeconds = songTime - _mixer._songPositionTracker.SongStart + - _mixer._songPositionTracker.AlignmentDelay; + _mixer._songPositionTracker.TotalDelay; long start = Bass.ChannelSeconds2Bytes(_mixer._mixerHandle, startSeconds); long length = Bass.ChannelSeconds2Bytes(_mixer._mixerHandle, lengthSeconds); @@ -875,9 +890,11 @@ private sealed class SongPositionTracker { private readonly int _tempoStreamHandle; private double _songStart; - private double _delay; + private double _alignmentDelay; + private double _playbackDelay; - public double AlignmentDelay => _delay; + public double AlignmentDelay => _alignmentDelay; + public double TotalDelay => _alignmentDelay + _playbackDelay; public double SongStart => _songStart; public SongPositionTracker(int tempoStreamHandle) @@ -895,20 +912,21 @@ public double GetSongPosition() { return 0; } - return position - _delay + _songStart; + return position - TotalDelay + _songStart; } /// /// Starts tracking from the requested song position after a seek /// - public void Reset(double songStart, double delay) + public void Reset(double songStart, double alignmentDelay, double playbackDelay) { _songStart = songStart; - _delay = delay; + _alignmentDelay = alignmentDelay; + _playbackDelay = playbackDelay; } - public void SetDelay(double delay) + public void SetAlignmentDelay(double delay) { - _delay = delay; + _alignmentDelay = delay; } private double GetTempoStreamPosition() diff --git a/Assets/Script/Audio/Bass/BufferedPlaybackTimeline.cs b/Assets/Script/Audio/Bass/BufferedPlaybackTimeline.cs index 4b470f656e..d99a005443 100644 --- a/Assets/Script/Audio/Bass/BufferedPlaybackTimeline.cs +++ b/Assets/Script/Audio/Bass/BufferedPlaybackTimeline.cs @@ -89,7 +89,7 @@ public void SetSpeed(float songSpeed, float syncAdjustment, double tempoLatency) _tempoOutput.SetRate(now + Math.Max(0, tempoLatency), rate); } - public void Play() + public void Play(double startupLatency) { if (_isPlaying) { @@ -99,7 +99,7 @@ public void Play() _isPlaying = true; double now = _getTime(); _immediate.SetRate(now, CurrentRate); - _tempoOutput.SetRate(now, CurrentRate); + _tempoOutput.SetRate(now + Math.Max(0, startupLatency), CurrentRate); } public void Pause() @@ -124,6 +124,14 @@ public void Reset(double songPosition) _tempoOutput.Reset(now, songPosition, rate); } + public void PreparePlayback(double observedPosition, double requestedPosition) + { + double now = _getTime(); + float rate = _isPlaying ? CurrentRate : 0f; + _immediate.Reset(now, requestedPosition, rate); + _tempoOutput.Reset(now, observedPosition, rate); + } + private float CurrentRate => _songSpeed + _syncAdjustment; private static double GetCurrentTime() diff --git a/Assets/Script/Playback/SongRunner.cs b/Assets/Script/Playback/SongRunner.cs index c70274c5a7..6a52c99cd6 100644 --- a/Assets/Script/Playback/SongRunner.cs +++ b/Assets/Script/Playback/SongRunner.cs @@ -71,6 +71,7 @@ public class SongRunner : IDisposable { /// /// Invoked synchronously after the mixer has been rebuilt and positioned, before playback resumes. + /// Event argument is logical audio position requested from mixer. /// public event Action AudioPrepared; @@ -228,10 +229,6 @@ public class SongRunner : IDisposable private const double SYNC_DEADBAND_SECONDS = 0.0015; private const float CORRECTION_TIME_SECONDS = 0.1f; private const float SYNC_CLAMP = 0.50f; - private double _suppressSyncUntil = double.NegativeInfinity; - private double _playbackStartLatency; - private double _expectedPlaybackLead; - private double _scheduledAudioStartTime = double.PositiveInfinity; private double _lastFrameInputTime; private long _lastFrameTimestamp; @@ -297,6 +294,8 @@ double songOffset _syncThread = new Thread(SyncThread) { IsBackground = true }; InitializeSongTime(startTime + SongOffset, startDelay); UpdateCalibration(); + + Application.quitting += OnApplicationQuitting; } ~SongRunner() @@ -314,6 +313,8 @@ private void Dispose(bool disposing) { if (!_disposed) { + Application.quitting -= OnApplicationQuitting; + if (_syncThread != null) { lock (_syncThread) @@ -354,17 +355,26 @@ private void Dispose(bool disposing) } } + private void OnApplicationQuitting() + { + Dispose(); + } + private void Start() { YargLogger.LogDebug("Starting song runner"); // Re-initialize song times to avoid lag issues InitializeSongTime(InputTime, 0); + double startInputTime = InputTime; PrepareAudioPlayback(); + // Mixer preparation can take several milliseconds. Start both timelines from now. + SetInputBaseAt(startInputTime, InputManager.CurrentInputTime); + _mixer.Play(); + _lastFrameInputTime = InputManager.CurrentInputTime; _lastFrameTimestamp = System.Diagnostics.Stopwatch.GetTimestamp(); - _syncThread.Start(); Started = true; } @@ -444,26 +454,21 @@ private void SyncThread() long currentTimestamp = System.Diagnostics.Stopwatch.GetTimestamp(); double elapsedSeconds = (double) (currentTimestamp - _lastFrameTimestamp) / System.Diagnostics.Stopwatch.Frequency; double currentTime = _lastFrameInputTime + elapsedSeconds; - double syncTargetTime = GetRelativeInputTime(currentTime) - SongOffset; - PlaybackPosition playbackPosition = _mixer.GetPlaybackPosition(); + var playbackPosition = _mixer.GetPlaybackPosition(); double syncError = syncTargetTime - playbackPosition.Control; SyncError = syncError; - StartPreparedAudio(currentTime, syncTargetTime); - bool isWithinSongBounds = !Paused && syncTargetTime >= 0 && syncTargetTime < _mixer.Length && playbackPosition.Heard < _mixer.Length; - bool isSuppressing = currentTime < _suppressSyncUntil; - bool allowCorrection = isWithinSongBounds && !isSuppressing; // Converts delay-free playback error into a temporary speed adjustment. // Audio pipeline delay is modeled by the mixer and does not belong in this controller. float adjustment = 0f; - if (allowCorrection && Math.Abs(syncError) >= SYNC_DEADBAND_SECONDS) + if (isWithinSongBounds && Math.Abs(syncError) >= SYNC_DEADBAND_SECONDS) { adjustment = Math.Clamp( (float) syncError / CORRECTION_TIME_SECONDS, @@ -497,43 +502,17 @@ private void SyncThread() } /// - /// Realigns audio after any gameplay timeline discontinuity: playback start, seek, or unpause. - /// Pauses and seeks the mixer to the current gameplay position while accounting for playback-start - /// latency. The sync thread will resume the mixer at the requested time. + /// Prepares audio after any gameplay timeline discontinuity: playback start, seek, or unpause. + /// Mixer owns physical seeking, playback latency, and channel scheduling. /// private void PrepareAudioPlayback() { _mixer.Pause(); ResetSync(); - _playbackStartLatency = _mixer.GetPlaybackStartLatency(); - double playbackPreRoll = _playbackStartLatency * SongSpeed; - - // Audio-file position corresponding to the current paused gameplay timeline. double audioTime = InputTime - SongOffset; - double seekPosition = Math.Clamp(audioTime + playbackPreRoll, 0, _mixer.Length); - _mixer.SetPosition(seekPosition); - AudioPrepared?.Invoke(seekPosition); - - // When pre-roll would seek before the file, wait until starting position zero will - // reach the speakers at the correct song time. Otherwise playback is ready now. - _scheduledAudioStartTime = seekPosition == 0 ? -playbackPreRoll : audioTime; - _suppressSyncUntil = double.NegativeInfinity; - } - - private void StartPreparedAudio(double currentTime, double syncTargetTime) - { - bool reachedAudioStart = syncTargetTime >= _scheduledAudioStartTime; - bool beforeAudioEnd = syncTargetTime < _mixer.Length; - if (Paused || !_mixer.IsPaused || !reachedAudioStart || !beforeAudioEnd) - { - return; - } - - _mixer.Play(); - - // Mixer position is intentionally ahead until the prepared audio reaches the speakers. - _suppressSyncUntil = currentTime + _playbackStartLatency; + _mixer.SetPosition(audioTime); + AudioPrepared?.Invoke(audioTime); } private void ResetSync() @@ -634,7 +613,6 @@ public void SetSongTime(double time, double delayTime = SONG_START_DELAY) lock (_syncThread) { SongSpeed = _requestedSongSpeed; - _suppressSyncUntil = double.NegativeInfinity; // Set input/song time InitializeSongTime(time, delayTime); @@ -645,6 +623,10 @@ public void SetSongTime(double time, double delayTime = SONG_START_DELAY) // Seeking can take several milliseconds. Anchor after it completes so that command // execution does not advance the gameplay timeline alone. SetInputBaseAt(seekInputTime, InputManager.CurrentInputTime); + if (!Paused) + { + _mixer.Play(); + } _seeked = true; } } @@ -673,7 +655,6 @@ private void ApplySpeedChange(float speed) double inputTime = InputTime; SongSpeed = speed; ResetSync(); - _suppressSyncUntil = double.NegativeInfinity; SetInputBaseChecked(inputTime); } @@ -747,6 +728,7 @@ public void Resume() // Seeking can take several milliseconds. Anchor after it completes so that work // does not advance the resumed timeline. SetInputBaseAt(resumeInputTime, InputManager.CurrentInputTime); + _mixer.Play(); } YargLogger.LogFormatDebug( From 2f5e7701b0a2a4206ebbcac834ba83122a95b76c Mon Sep 17 00:00:00 2001 From: polson <302882+polson@users.noreply.github.com> Date: Mon, 13 Jul 2026 18:29:16 -0500 Subject: [PATCH 21/31] Fix calibration input boxes for negatives --- .../HUD/Pause/Settings/IntPauseSetting.cs | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/Assets/Script/Gameplay/HUD/Pause/Settings/IntPauseSetting.cs b/Assets/Script/Gameplay/HUD/Pause/Settings/IntPauseSetting.cs index 61e735b6a0..5c11651039 100644 --- a/Assets/Script/Gameplay/HUD/Pause/Settings/IntPauseSetting.cs +++ b/Assets/Script/Gameplay/HUD/Pause/Settings/IntPauseSetting.cs @@ -19,6 +19,7 @@ public override void Initialize(string settingName, IntSetting setting) _inputField.text = setting.Value.ToString(CultureInfo.InvariantCulture); _inputField.onValueChanged.AddListener(OnValueChange); + _inputField.onEndEdit.AddListener(OnEndEdit); } protected override NavigationScheme GetNavigationScheme() @@ -41,17 +42,18 @@ protected override NavigationScheme GetNavigationScheme() private void OnValueChange(string text) { - try + // Keep incomplete input such as a leading minus sign while the user is typing. + if (!int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out int value)) { - int value = int.Parse(text, CultureInfo.InvariantCulture); - value = Mathf.Clamp(value, Setting.Min, Setting.Max); - Setting.Value = value; - } - catch - { - // Ignore error + return; } + Setting.Value = value; + } + + private void OnEndEdit(string _) + { + // Restore invalid input and show any clamping performed by the setting. RefreshVisual(); } @@ -60,4 +62,4 @@ private void RefreshVisual() _inputField.text = Setting.Value.ToString(CultureInfo.InvariantCulture); } } -} \ No newline at end of file +} From 33063e9af8fc7fb12d4a3ad8b6fa21cdb59a2db2 Mon Sep 17 00:00:00 2001 From: polson <302882+polson@users.noreply.github.com> Date: Mon, 13 Jul 2026 18:39:12 -0500 Subject: [PATCH 22/31] Fix calibration issue --- Assets/Script/Audio/Bass/BassStemMixer.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Assets/Script/Audio/Bass/BassStemMixer.cs b/Assets/Script/Audio/Bass/BassStemMixer.cs index 4455d555d6..d36041b292 100644 --- a/Assets/Script/Audio/Bass/BassStemMixer.cs +++ b/Assets/Script/Audio/Bass/BassStemMixer.cs @@ -242,9 +242,9 @@ protected override double GetTempoStreamLatency_Internal() return BassLatencyProvider.GetTempoStreamLatency(_tempoStreamHandle); } - private double GetPlaybackStartLatency() + private double GetPlaybackStartOffset() { - return Math.Max(0, _playbackTimeline.OutputLatency) + _songPositionTracker.AlignmentDelay; + return _playbackTimeline.OutputLatency + _songPositionTracker.AlignmentDelay; } protected override double GetVolume_Internal() @@ -261,8 +261,8 @@ protected override void SetPosition_Internal(double position) var wasPlaying = IsPlaying; Pause_Internal(); - double playbackPreRoll = GetPlaybackStartLatency() * _songSpeed; - double preparedPosition = position + playbackPreRoll; + double playbackOffset = GetPlaybackStartOffset() * _songSpeed; + double preparedPosition = position + playbackOffset; double seekPosition = Math.Clamp(preparedPosition, 0, _length); double playbackDelay = Math.Max(0, -preparedPosition); _preparedBeyondEnd = position >= _length; From 4ba5c65491e4c6d48d865f203794548696097a1e Mon Sep 17 00:00:00 2001 From: polson <302882+polson@users.noreply.github.com> Date: Mon, 13 Jul 2026 20:55:15 -0500 Subject: [PATCH 23/31] First implementation of DSP backed metronome --- Assets/Script/Audio/Bass/BassStemMixer.cs | 365 ++++++++++++++----- Assets/Script/Gameplay/MetronomeScheduler.cs | 60 +-- Assets/Script/Playback/SongRunner.cs | 2 +- 3 files changed, 314 insertions(+), 113 deletions(-) diff --git a/Assets/Script/Audio/Bass/BassStemMixer.cs b/Assets/Script/Audio/Bass/BassStemMixer.cs index d36041b292..f3d07a4607 100644 --- a/Assets/Script/Audio/Bass/BassStemMixer.cs +++ b/Assets/Script/Audio/Bass/BassStemMixer.cs @@ -11,6 +11,7 @@ using YARG.Core.Song; using YARG.Helpers; using YARG.Settings; +using Volatile = System.Threading.Volatile; namespace YARG.Audio.BASS { @@ -54,7 +55,7 @@ public StemData(SongStem stem, float[,]? volumeMatrix, StreamHandle streamHandle private float _speed = 1.0f; private Timer _whammySyncTimer; private readonly List _stemDatas = new(); - private readonly HashSet _oneShotSchedules = new(); + private readonly HashSet _timedSampleSchedules = new(); private int _longestHandle; private readonly BassNormalizer _normalizer = new(); @@ -62,12 +63,12 @@ public StemData(SongStem stem, float[,]? volumeMatrix, StreamHandle streamHandle private int _gainDspHandle; private float _gain = 1.0f; - public override OneShotSchedule CreateOneShotSchedule() + public override TimedSampleSchedule CreateTimedSampleSchedule() { lock (this) { - var schedule = new BassOneShot(this); - _oneShotSchedules.Add(schedule); + var schedule = new BassTimedSampleSchedule(this); + _timedSampleSchedules.Add(schedule); return schedule; } } @@ -280,6 +281,10 @@ protected override void SetPosition_Internal(double position) { YargLogger.LogFormatError("Failed to reset tempo stream position: {0}!", Bass.LastError); } + foreach (var schedule in _timedSampleSchedules) + { + schedule.TransportReset(); + } _playbackTimeline.PreparePlayback(_songPositionTracker.GetSongPosition(), position); } @@ -482,9 +487,9 @@ private void RemoveChannelsFromMixer() YargLogger.LogDebug("Failed to remove channel from mixer"); } } - foreach (var schedule in _oneShotSchedules) + foreach (var schedule in _timedSampleSchedules) { - schedule.MixerCleared(); + schedule.TransportReset(); } } @@ -682,11 +687,11 @@ protected override void DisposeManagedResources() protected override void DisposeUnmanagedResources() { - foreach (var schedule in _oneShotSchedules) + foreach (var schedule in _timedSampleSchedules) { schedule.MixerDisposed(); } - _oneShotSchedules.Clear(); + _timedSampleSchedules.Clear(); if (_mixerHandle != 0) { @@ -705,111 +710,293 @@ protected override void DisposeUnmanagedResources() } } - private sealed class BassOneShot : OneShotSchedule + private sealed class BassTimedSampleSchedule : TimedSampleSchedule { + private const int CHANNEL_COUNT = 2; + private const int MAX_ACTIVE_SAMPLES = 64; + + private readonly struct TimedSample + { + public readonly int SampleId; + public readonly double SongTime; + + public TimedSample(int sampleId, double songTime) + { + SampleId = sampleId; + SongTime = songTime; + } + } + + private struct ActiveSample + { + public float[] Data; + public int Frame; + } + + private sealed class ScheduleState + { + public static readonly ScheduleState Empty = new(Array.Empty(), + new Dictionary()); + + public readonly TimedSample[] Events; + public readonly Dictionary Samples; + + public ScheduleState(TimedSample[] events, Dictionary samples) + { + Events = events; + Samples = samples; + } + } + private readonly BassStemMixer _mixer; - private readonly HashSet _streams = new(); - private double _volume = 1; + private readonly DSPProcedure _callback; + private readonly int _dspHandle; + private readonly List _pendingEvents = new(); + private readonly Dictionary _pendingSamples = new(); + private readonly ActiveSample[] _activeSamples = new ActiveSample[MAX_ACTIVE_SAMPLES]; + + private ScheduleState _state = ScheduleState.Empty; + private ScheduleState _activeState; + private int _transportGeneration; + private int _activeTransportGeneration = -1; + private long _previousEndPosition; + private int _nextEvent; + private int _activeSampleCount; + private float _volume = 1; private bool _disposed; - public BassOneShot(BassStemMixer mixer) + public BassTimedSampleSchedule(BassStemMixer mixer) { _mixer = mixer; + _callback = MixSamples; + _dspHandle = Bass.ChannelSetDSP(mixer._tempoStreamHandle, _callback); + if (_dspHandle == 0) + { + YargLogger.LogFormatError("Failed to attach timed sample DSP: {0}!", Bass.LastError); + } } - /// - /// Schedules an owned one-shot stream at an audio-file position. Scheduling consumes - /// the stream handle whether it succeeds or fails. - /// - public override bool Schedule(int streamHandle, double songTime) + public override bool SetSample(int sampleId, int streamHandle) { - lock (_mixer) + if (_disposed || streamHandle == 0) { - if (_disposed || streamHandle == 0) - { - Bass.StreamFree(streamHandle); - return false; - } + Bass.StreamFree(streamHandle); + return false; + } - if (!Bass.ChannelSetAttribute(streamHandle, ChannelAttribute.Volume, _volume)) - { - YargLogger.LogFormatError("Failed to set one-shot stream volume: {0}!", Bass.LastError); - Bass.StreamFree(streamHandle); - return false; - } + float[] sample = DecodeSample(streamHandle); + if (sample == null) + { + return false; + } + + _pendingSamples[sampleId] = sample; + return true; + } + + public override bool Schedule(int sampleId, double songTime) + { + if (_disposed || !_pendingSamples.ContainsKey(sampleId)) + { + return false; + } + + _pendingEvents.Add(new TimedSample(sampleId, songTime)); + return true; + } + + public override void Commit() + { + if (_disposed) + { + return; + } + + _pendingEvents.Sort((left, right) => left.SongTime.CompareTo(right.SongTime)); + var state = new ScheduleState(_pendingEvents.ToArray(), + new Dictionary(_pendingSamples)); + Volatile.Write(ref _state, state); + } + + public override void SetVolume(double volume) + { + Volatile.Write(ref _volume, (float) volume); + } + + public override void Clear() + { + _pendingEvents.Clear(); + } + + public void TransportReset() + { + System.Threading.Interlocked.Increment(ref _transportGeneration); + } + + private unsafe void MixSamples(int _, int channel, IntPtr buffer, int length, IntPtr __) + { + int frameCount = length / (sizeof(float) * CHANNEL_COUNT); + if (frameCount <= 0) + { + return; + } + + long endPosition = Bass.ChannelGetPosition(channel, PositionFlags.Decode); + if (endPosition < 0) + { + return; + } + + int transportGeneration = Volatile.Read(ref _transportGeneration); + if (_activeTransportGeneration != transportGeneration) + { + _activeTransportGeneration = transportGeneration; + _previousEndPosition = 0; + _activeState = null; + _activeSampleCount = 0; + } + + long startPosition = _previousEndPosition; + _previousEndPosition = endPosition; + if (endPosition <= startPosition) + { + return; + } + + double songStart = _mixer._songPositionTracker.GetSongPosition(startPosition); + double songEnd = _mixer._songPositionTracker.GetSongPosition(endPosition); + if (songEnd <= songStart) + { + return; + } + + ScheduleState state = Volatile.Read(ref _state); + if (!ReferenceEquals(state, _activeState)) + { + _activeState = state; + _activeSampleCount = 0; + _nextEvent = FindFirstEvent(state.Events, songStart); + } - long streamLength = Bass.ChannelGetLength(streamHandle); - double lengthSeconds = Bass.ChannelBytes2Seconds(streamHandle, streamLength); - long mixerPosition = Bass.ChannelGetPosition(_mixer._mixerHandle); - double startSeconds = songTime - _mixer._songPositionTracker.SongStart + - _mixer._songPositionTracker.TotalDelay; - long start = Bass.ChannelSeconds2Bytes(_mixer._mixerHandle, startSeconds); - long length = Bass.ChannelSeconds2Bytes(_mixer._mixerHandle, lengthSeconds); + float* output = (float*) buffer; + float volume = Volatile.Read(ref _volume); + MixActiveSamples(output, frameCount, volume); - if (streamLength < 0 || lengthSeconds < 0 || mixerPosition < 0 || - start < mixerPosition || length <= 0) + while (_nextEvent < state.Events.Length) + { + TimedSample timedSample = state.Events[_nextEvent]; + if (timedSample.SongTime >= songEnd) { - Bass.StreamFree(streamHandle); - return false; + break; } + _nextEvent++; - BassFlags flags = BassFlags.MixerChanNoRampin | BassFlags.AutoFree; - if (!BassMix.MixerAddChannel(_mixer._mixerHandle, streamHandle, flags, start, length)) + if (timedSample.SongTime < songStart || + !state.Samples.TryGetValue(timedSample.SampleId, out float[] sample)) { - YargLogger.LogFormatError("Failed to schedule one-shot stream: {0}!", Bass.LastError); - Bass.StreamFree(streamHandle); - return false; + continue; } - _streams.Add(streamHandle); - int freeSync = Bass.ChannelSetSync(streamHandle, SyncFlags.Free, 0, - (_, channel, _, _) => - { - lock (_mixer) - { - _streams.Remove(channel); - } - }); - if (freeSync == 0) + double progress = (timedSample.SongTime - songStart) / (songEnd - songStart); + int startFrame = Math.Clamp((int) Math.Round(progress * frameCount), 0, frameCount - 1); + var activeSample = new ActiveSample { Data = sample }; + MixSample(output, frameCount, startFrame, volume, ref activeSample); + if (activeSample.Frame * CHANNEL_COUNT < sample.Length && + _activeSampleCount < _activeSamples.Length) { - YargLogger.LogFormatError("Failed to track one-shot stream: {0}!", Bass.LastError); - _streams.Remove(streamHandle); - BassMix.MixerRemoveChannel(streamHandle); - return false; + _activeSamples[_activeSampleCount++] = activeSample; } - return true; } } - public override void SetVolume(double volume) + private unsafe void MixActiveSamples(float* output, int frameCount, float volume) { - lock (_mixer) + int writeIndex = 0; + for (int i = 0; i < _activeSampleCount; i++) { - if (_disposed) + ActiveSample activeSample = _activeSamples[i]; + MixSample(output, frameCount, 0, volume, ref activeSample); + if (activeSample.Frame * CHANNEL_COUNT < activeSample.Data.Length) { - return; + _activeSamples[writeIndex++] = activeSample; } + } + _activeSampleCount = writeIndex; + } + + private static unsafe void MixSample(float* output, int outputFrames, int startFrame, + float volume, ref ActiveSample activeSample) + { + int sampleFrames = activeSample.Data.Length / CHANNEL_COUNT; + int framesToMix = Math.Min(outputFrames - startFrame, sampleFrames - activeSample.Frame); + int source = activeSample.Frame * CHANNEL_COUNT; + int destination = startFrame * CHANNEL_COUNT; + for (int frame = 0; frame < framesToMix; frame++) + { + output[destination++] += activeSample.Data[source++] * volume; + output[destination++] += activeSample.Data[source++] * volume; + } + activeSample.Frame += framesToMix; + } - _volume = volume; - foreach (int stream in _streams) + private static int FindFirstEvent(TimedSample[] events, double songTime) + { + int low = 0; + int high = events.Length; + while (low < high) + { + int middle = low + (high - low) / 2; + if (events[middle].SongTime < songTime) { - if (!Bass.ChannelSetAttribute(stream, ChannelAttribute.Volume, volume)) - { - YargLogger.LogFormatError("Failed to set one-shot stream volume: {0}!", Bass.LastError); - } + low = middle + 1; + } + else + { + high = middle; } } + return low; } - public override void Clear() + private static float[] DecodeSample(int streamHandle) { - lock (_mixer) + int converter = BassMix.CreateMixerStream(44100, CHANNEL_COUNT, + BassFlags.Float | BassFlags.Decode | BassFlags.MixerEnd); + if (converter == 0) { - if (!_disposed) + YargLogger.LogFormatError("Failed to create timed sample converter: {0}!", Bass.LastError); + Bass.StreamFree(streamHandle); + return null; + } + + if (!BassMix.MixerAddChannel(converter, streamHandle, BassFlags.MixerChanNoRampin)) + { + YargLogger.LogFormatError("Failed to add timed sample to converter: {0}!", Bass.LastError); + Bass.StreamFree(converter); + Bass.StreamFree(streamHandle); + return null; + } + + var samples = new List(); + var buffer = new float[4096]; + int bytesRead; + while ((bytesRead = Bass.ChannelGetData(converter, buffer, buffer.Length * sizeof(float))) > 0) + { + int sampleCount = bytesRead / sizeof(float); + for (int i = 0; i < sampleCount; i++) { - ClearStreams(); + samples.Add(buffer[i]); } } + + if (bytesRead < 0 && Bass.LastError != Errors.Ended) + { + YargLogger.LogFormatError("Failed to decode timed sample: {0}!", Bass.LastError); + } + + Bass.StreamFree(converter); + Bass.StreamFree(streamHandle); + return samples.Count > 0 ? samples.ToArray() : null; } public override void Dispose() @@ -821,31 +1008,21 @@ public override void Dispose() return; } - ClearStreams(); - _mixer._oneShotSchedules.Remove(this); + if (_dspHandle != 0 && + !Bass.ChannelRemoveDSP(_mixer._tempoStreamHandle, _dspHandle) && + Bass.LastError != Errors.Handle) + { + YargLogger.LogFormatError("Failed to remove timed sample DSP: {0}!", Bass.LastError); + } + _mixer._timedSampleSchedules.Remove(this); _disposed = true; } } - public void MixerCleared() - { - _streams.Clear(); - } - public void MixerDisposed() { - _streams.Clear(); _disposed = true; } - - private void ClearStreams() - { - foreach (int stream in _streams.ToArray()) - { - BassMix.MixerRemoveChannel(stream); - } - _streams.Clear(); - } } private void CreateChannel(SongStem stem, int sourceHandle, StreamHandle streamHandles, StreamHandle reverbHandles) @@ -914,6 +1091,12 @@ public double GetSongPosition() } return position - TotalDelay + _songStart; } + + public double GetSongPosition(long tempoStreamPosition) + { + double position = Bass.ChannelBytes2Seconds(_tempoStreamHandle, tempoStreamPosition); + return position - TotalDelay + _songStart; + } /// /// Starts tracking from the requested song position after a seek /// diff --git a/Assets/Script/Gameplay/MetronomeScheduler.cs b/Assets/Script/Gameplay/MetronomeScheduler.cs index 5b5bfae213..c82ad9f7e8 100644 --- a/Assets/Script/Gameplay/MetronomeScheduler.cs +++ b/Assets/Script/Gameplay/MetronomeScheduler.cs @@ -1,18 +1,23 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using YARG.Core.Audio; using YARG.Core.Chart; +using YARG.Core.Logging; using YARG.Playback; using YARG.Settings; namespace YARG.Gameplay { /// - /// Owns gameplay metronome scheduling. Hits enter the song mixer and therefore share its - /// buffering, tempo processing, and synchronization corrections. + /// Builds metronome events on the song timeline. Timed samples are mixed after tempo processing, + /// keeping their original pitch and duration at every song speed. /// public sealed class MetronomeScheduler : IDisposable { + private const int HI_SAMPLE = 0; + private const int LO_SAMPLE = 1; + private readonly struct Hit { public readonly double Time; @@ -26,7 +31,7 @@ public Hit(double time, MetronomePitch pitch) } private readonly StemMixer _mixer; - private readonly OneShotSchedule _schedule; + private readonly TimedSampleSchedule _schedule; private readonly SongRunner _songRunner; private readonly List _hits = new(); private readonly double _songOffset; @@ -35,7 +40,7 @@ public MetronomeScheduler(StemMixer mixer, SongRunner songRunner, SyncTrack sync double songLength, double songOffset) { _mixer = mixer; - _schedule = mixer.CreateOneShotSchedule(); + _schedule = mixer.CreateTimedSampleSchedule(); _songRunner = songRunner; _songOffset = songOffset; @@ -63,31 +68,42 @@ public MetronomeScheduler(StemMixer mixer, SongRunner songRunner, SyncTrack sync } _hits.Sort((left, right) => left.Time.CompareTo(right.Time)); - _songRunner.AudioPrepared += Schedule; + SetSamples(SettingsManager.Settings.MetronomeSound.Value); + _songRunner.AudioPrepared += Prepare; SettingsManager.Settings.MetronomeSound.OnChange += OnSoundChanged; SettingsManager.Settings.MetronomeVolume.OnChange += OnVolumeChanged; } - private void Schedule(double fromAudioTime) + private void Prepare(double fromAudioTime) { - MetronomeSample sample = SettingsManager.Settings.MetronomeSound.Value; - SetVolume(sample); + var stopwatch = Stopwatch.StartNew(); + _schedule.Clear(); + SetVolume(SettingsManager.Settings.MetronomeSound.Value); + int scheduledHits = 0; foreach (Hit hit in _hits) { - double audioTime = hit.Time + _songOffset; - if (audioTime < fromAudioTime) + double hitAudioTime = hit.Time + _songOffset; + if (hitAudioTime < fromAudioTime) { continue; } - int stream = GlobalAudioHandler.CreateMetronomeStream(sample, hit.Pitch); - _schedule.Schedule(stream, audioTime); + int sampleId = hit.Pitch == MetronomePitch.Hi ? HI_SAMPLE : LO_SAMPLE; + if (_schedule.Schedule(sampleId, hitAudioTime)) + { + scheduledHits++; + } } + _schedule.Commit(); + stopwatch.Stop(); + YargLogger.LogFormatDebug("Scheduled {0} DSP metronome hits in {1:0.00} ms", + scheduledHits, stopwatch.Elapsed.TotalMilliseconds); } - private void OnSoundChanged(MetronomeSample _) + private void OnSoundChanged(MetronomeSample sample) { - Reschedule(); + SetSamples(sample); + Prepare(_mixer.GetPosition()); } private void OnVolumeChanged(float _) @@ -95,12 +111,6 @@ private void OnVolumeChanged(float _) SetVolume(SettingsManager.Settings.MetronomeSound.Value); } - private void Reschedule() - { - _schedule.Clear(); - Schedule(_mixer.GetPosition()); - } - private void SetVolume(MetronomeSample sample) { double volume = GlobalAudioHandler.GetTrueVolume(SongStem.Metronome) * @@ -108,9 +118,17 @@ private void SetVolume(MetronomeSample sample) _schedule.SetVolume(volume); } + private void SetSamples(MetronomeSample sample) + { + _schedule.SetSample(HI_SAMPLE, + GlobalAudioHandler.CreateMetronomeStream(sample, MetronomePitch.Hi)); + _schedule.SetSample(LO_SAMPLE, + GlobalAudioHandler.CreateMetronomeStream(sample, MetronomePitch.Lo)); + } + public void Dispose() { - _songRunner.AudioPrepared -= Schedule; + _songRunner.AudioPrepared -= Prepare; SettingsManager.Settings.MetronomeSound.OnChange -= OnSoundChanged; SettingsManager.Settings.MetronomeVolume.OnChange -= OnVolumeChanged; _schedule.Dispose(); diff --git a/Assets/Script/Playback/SongRunner.cs b/Assets/Script/Playback/SongRunner.cs index 6a52c99cd6..a30c5a593b 100644 --- a/Assets/Script/Playback/SongRunner.cs +++ b/Assets/Script/Playback/SongRunner.cs @@ -441,7 +441,7 @@ public void Update() private void SyncThread() { - const int syncIntervalMs = 1; + const int syncIntervalMs = 10; for (; !_disposed; Thread.Sleep(syncIntervalMs)) { lock (_syncThread) From 7a4aadecb83a2295b2f43d649de65f269e468369 Mon Sep 17 00:00:00 2001 From: polson <302882+polson@users.noreply.github.com> Date: Mon, 13 Jul 2026 21:28:08 -0500 Subject: [PATCH 24/31] Improved oneshot design --- .../Script/Audio/Bass/BassOneShotChannel.cs | 386 ++++++++++++++++++ .../Audio/Bass/BassOneShotChannel.cs.meta | 11 + Assets/Script/Audio/Bass/BassStemMixer.cs | 350 +--------------- Assets/Script/Gameplay/GameManager.Loading.cs | 2 +- Assets/Script/Gameplay/MetronomeScheduler.cs | 67 ++- 5 files changed, 445 insertions(+), 371 deletions(-) create mode 100644 Assets/Script/Audio/Bass/BassOneShotChannel.cs create mode 100644 Assets/Script/Audio/Bass/BassOneShotChannel.cs.meta diff --git a/Assets/Script/Audio/Bass/BassOneShotChannel.cs b/Assets/Script/Audio/Bass/BassOneShotChannel.cs new file mode 100644 index 0000000000..75acee1a12 --- /dev/null +++ b/Assets/Script/Audio/Bass/BassOneShotChannel.cs @@ -0,0 +1,386 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Threading; +using ManagedBass; +using ManagedBass.Mix; +using YARG.Core.Audio; +using YARG.Core.Logging; + +namespace YARG.Audio.BASS +{ + /// + /// Mixes immediate and scheduled instances of one sample into a float playback stream. + /// + internal sealed class BassOneShotChannel : OneShotChannel + { + private const int MAX_ACTIVE_SAMPLES = 64; + + private struct ActiveSample + { + public float[] Data; + public int Frame; + } + + private sealed class ScheduleState + { + public readonly double[] Events; + public int Count; + + public ScheduleState(int capacity) + { + Events = new double[capacity]; + } + } + + private readonly int _playbackStreamHandle; + private readonly int _sampleRate; + private readonly int _channelCount; + private readonly Func _getPlaybackTime; + private readonly float[] _sample; + private readonly DSPProcedure _callback; + private readonly int _dspHandle; + private readonly ConcurrentQueue _immediatePlays = new(); + private readonly ActiveSample[] _activeSamples = new ActiveSample[MAX_ACTIVE_SAMPLES]; + + private ScheduleState _state = new(64); + private ScheduleState _activeState; + private int _transportGeneration; + private int _activeTransportGeneration = -1; + private long _previousEndPosition; + private int _nextEvent; + private int _activeSampleCount; + private float _volume = 1; + private bool _disposed; + + internal event Action Disposed; + + /// + /// Attaches to a stream using its own position in seconds as scheduling time. + /// + public BassOneShotChannel(int playbackStreamHandle, int sampleStream) + : this(playbackStreamHandle, sampleStream, + position => Bass.ChannelBytes2Seconds(playbackStreamHandle, position)) + { + } + + /// + /// Attaches to a stream using a function that maps stream positions to scheduling time. + /// + public BassOneShotChannel(int playbackStreamHandle, int sampleStream, + Func getPlaybackTime) + { + _playbackStreamHandle = playbackStreamHandle; + _getPlaybackTime = getPlaybackTime ?? throw new ArgumentNullException(nameof(getPlaybackTime)); + + ChannelInfo info = Bass.ChannelGetInfo(playbackStreamHandle); + if ((info.Flags & BassFlags.Float) == 0) + { + Bass.StreamFree(sampleStream); + throw new ArgumentException("Playback stream must use float sample data.", + nameof(playbackStreamHandle)); + } + + _sampleRate = info.Frequency; + _channelCount = info.Channels; + _sample = DecodeSample(sampleStream) ?? Array.Empty(); + if (_sample.Length == 0) + { + return; + } + + _callback = MixSamples; + _previousEndPosition = Math.Max(0, + Bass.ChannelGetPosition(playbackStreamHandle, PositionFlags.Decode)); + _dspHandle = Bass.ChannelSetDSP(playbackStreamHandle, _callback); + if (_dspHandle == 0) + { + YargLogger.LogFormatError("Failed to attach one-shot DSP: {0}!", Bass.LastError); + } + } + + public override void Play() + { + lock (this) + { + if (!_disposed && _dspHandle != 0) + { + _immediatePlays.Enqueue(0); + } + } + } + + public override void Schedule(double songTime) + { + lock (this) + { + if (_disposed || _dspHandle == 0) + { + return; + } + + ScheduleState state = Volatile.Read(ref _state); + int count = Volatile.Read(ref state.Count); + if (count < state.Events.Length && + (count == 0 || songTime >= state.Events[count - 1])) + { + state.Events[count] = songTime; + Volatile.Write(ref state.Count, count + 1); + return; + } + + int insertionIndex = Array.BinarySearch(state.Events, 0, count, songTime); + if (insertionIndex < 0) + { + insertionIndex = ~insertionIndex; + } + var newState = new ScheduleState(Math.Max(state.Events.Length * 2, count + 1)); + Array.Copy(state.Events, 0, newState.Events, 0, insertionIndex); + newState.Events[insertionIndex] = songTime; + Array.Copy(state.Events, insertionIndex, newState.Events, insertionIndex + 1, + count - insertionIndex); + newState.Count = count + 1; + Volatile.Write(ref _state, newState); + } + } + + public override void SetVolume(double volume) + { + Volatile.Write(ref _volume, (float) volume); + } + + public override void ClearSchedule() + { + lock (this) + { + if (!_disposed) + { + Volatile.Write(ref _state, new ScheduleState(64)); + } + } + } + + public void ResetTransport() + { + Interlocked.Increment(ref _transportGeneration); + } + + private unsafe void MixSamples(int _, int channel, IntPtr buffer, int length, IntPtr __) + { + int frameCount = length / (sizeof(float) * _channelCount); + if (frameCount <= 0) + { + return; + } + + long endPosition = Bass.ChannelGetPosition(channel, PositionFlags.Decode); + if (endPosition < 0) + { + return; + } + + int transportGeneration = Volatile.Read(ref _transportGeneration); + if (_activeTransportGeneration != transportGeneration) + { + _activeTransportGeneration = transportGeneration; + _previousEndPosition = 0; + _activeState = null; + _activeSampleCount = 0; + } + + long startPosition = _previousEndPosition; + _previousEndPosition = endPosition; + if (endPosition <= startPosition) + { + return; + } + + double playbackStart = _getPlaybackTime(startPosition); + double playbackEnd = _getPlaybackTime(endPosition); + if (playbackEnd <= playbackStart) + { + return; + } + + ScheduleState state = Volatile.Read(ref _state); + if (!ReferenceEquals(state, _activeState)) + { + _activeState = state; + _nextEvent = FindFirstEvent(state.Events, + Volatile.Read(ref state.Count), playbackStart); + } + + float* output = (float*) buffer; + float volume = Volatile.Read(ref _volume); + MixActiveSamples(output, frameCount, volume); + MixImmediateSamples(output, frameCount, volume); + + int eventCount = Volatile.Read(ref state.Count); + while (_nextEvent < eventCount) + { + double eventTime = state.Events[_nextEvent]; + if (eventTime >= playbackEnd) + { + break; + } + _nextEvent++; + + if (eventTime < playbackStart) + { + continue; + } + + double progress = (eventTime - playbackStart) / + (playbackEnd - playbackStart); + int startFrame = Math.Clamp((int) Math.Round(progress * frameCount), 0, + frameCount - 1); + StartSample(output, frameCount, startFrame, volume); + } + } + + private unsafe void MixActiveSamples(float* output, int frameCount, float volume) + { + int writeIndex = 0; + for (int i = 0; i < _activeSampleCount; i++) + { + ActiveSample activeSample = _activeSamples[i]; + MixSample(output, frameCount, 0, volume, ref activeSample); + if (activeSample.Frame * _channelCount < activeSample.Data.Length) + { + _activeSamples[writeIndex++] = activeSample; + } + } + _activeSampleCount = writeIndex; + } + + private unsafe void MixImmediateSamples(float* output, int frameCount, float volume) + { + for (int i = 0; i < MAX_ACTIVE_SAMPLES && _immediatePlays.TryDequeue(out _); i++) + { + StartSample(output, frameCount, 0, volume); + } + } + + private unsafe void StartSample(float* output, int frameCount, int startFrame, float volume) + { + var activeSample = new ActiveSample { Data = _sample }; + MixSample(output, frameCount, startFrame, volume, ref activeSample); + if (activeSample.Frame * _channelCount < _sample.Length && + _activeSampleCount < _activeSamples.Length) + { + _activeSamples[_activeSampleCount++] = activeSample; + } + } + + private unsafe void MixSample(float* output, int outputFrames, int startFrame, + float volume, ref ActiveSample activeSample) + { + int sampleFrames = activeSample.Data.Length / _channelCount; + int framesToMix = Math.Min(outputFrames - startFrame, sampleFrames - activeSample.Frame); + int source = activeSample.Frame * _channelCount; + int destination = startFrame * _channelCount; + int valuesToMix = framesToMix * _channelCount; + for (int i = 0; i < valuesToMix; i++) + { + output[destination++] += activeSample.Data[source++] * volume; + } + activeSample.Frame += framesToMix; + } + + private static int FindFirstEvent(double[] events, int eventCount, double playbackTime) + { + int low = 0; + int high = eventCount; + while (low < high) + { + int middle = low + (high - low) / 2; + if (events[middle] < playbackTime) + { + low = middle + 1; + } + else + { + high = middle; + } + } + return low; + } + + private float[] DecodeSample(int streamHandle) + { + int converter = BassMix.CreateMixerStream(_sampleRate, _channelCount, + BassFlags.Float | BassFlags.Decode | BassFlags.MixerEnd); + if (converter == 0) + { + YargLogger.LogFormatError("Failed to create one-shot sample converter: {0}!", + Bass.LastError); + Bass.StreamFree(streamHandle); + return null; + } + + if (!BassMix.MixerAddChannel(converter, streamHandle, BassFlags.MixerChanNoRampin)) + { + YargLogger.LogFormatError("Failed to add one-shot sample to converter: {0}!", + Bass.LastError); + Bass.StreamFree(converter); + Bass.StreamFree(streamHandle); + return null; + } + + var samples = new List(); + var buffer = new float[4096]; + int bytesRead; + while ((bytesRead = Bass.ChannelGetData(converter, buffer, + buffer.Length * sizeof(float))) > 0) + { + int sampleCount = bytesRead / sizeof(float); + for (int i = 0; i < sampleCount; i++) + { + samples.Add(buffer[i]); + } + } + + if (bytesRead < 0 && Bass.LastError != Errors.Ended) + { + YargLogger.LogFormatError("Failed to decode one-shot sample: {0}!", Bass.LastError); + } + + Bass.StreamFree(converter); + Bass.StreamFree(streamHandle); + return samples.Count > 0 ? samples.ToArray() : null; + } + + public override void Dispose() + { + Action disposed; + lock (this) + { + if (_disposed) + { + return; + } + + if (_dspHandle != 0 && + !Bass.ChannelRemoveDSP(_playbackStreamHandle, _dspHandle) && + Bass.LastError != Errors.Handle) + { + YargLogger.LogFormatError("Failed to remove one-shot DSP: {0}!", Bass.LastError); + } + _disposed = true; + disposed = Disposed; + Disposed = null; + } + + disposed?.Invoke(this); + } + + internal void PlaybackStreamDisposed() + { + lock (this) + { + _disposed = true; + Disposed = null; + } + } + } +} diff --git a/Assets/Script/Audio/Bass/BassOneShotChannel.cs.meta b/Assets/Script/Audio/Bass/BassOneShotChannel.cs.meta new file mode 100644 index 0000000000..7141716926 --- /dev/null +++ b/Assets/Script/Audio/Bass/BassOneShotChannel.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 10a34c4ddb66420e920737a5dbf2f920 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Script/Audio/Bass/BassStemMixer.cs b/Assets/Script/Audio/Bass/BassStemMixer.cs index f3d07a4607..9d09c14d8d 100644 --- a/Assets/Script/Audio/Bass/BassStemMixer.cs +++ b/Assets/Script/Audio/Bass/BassStemMixer.cs @@ -11,7 +11,6 @@ using YARG.Core.Song; using YARG.Helpers; using YARG.Settings; -using Volatile = System.Threading.Volatile; namespace YARG.Audio.BASS { @@ -55,7 +54,7 @@ public StemData(SongStem stem, float[,]? volumeMatrix, StreamHandle streamHandle private float _speed = 1.0f; private Timer _whammySyncTimer; private readonly List _stemDatas = new(); - private readonly HashSet _timedSampleSchedules = new(); + private readonly HashSet _oneShotChannels = new(); private int _longestHandle; private readonly BassNormalizer _normalizer = new(); @@ -63,13 +62,23 @@ public StemData(SongStem stem, float[,]? volumeMatrix, StreamHandle streamHandle private int _gainDspHandle; private float _gain = 1.0f; - public override TimedSampleSchedule CreateTimedSampleSchedule() + public override OneShotChannel CreateOneShotChannel(int sampleStream) { lock (this) { - var schedule = new BassTimedSampleSchedule(this); - _timedSampleSchedules.Add(schedule); - return schedule; + var channel = new BassOneShotChannel(_tempoStreamHandle, sampleStream, + _songPositionTracker.GetSongPosition); + channel.Disposed += OnOneShotChannelDisposed; + _oneShotChannels.Add(channel); + return channel; + } + } + + private void OnOneShotChannelDisposed(BassOneShotChannel channel) + { + lock (this) + { + _oneShotChannels.Remove(channel); } } @@ -281,9 +290,9 @@ protected override void SetPosition_Internal(double position) { YargLogger.LogFormatError("Failed to reset tempo stream position: {0}!", Bass.LastError); } - foreach (var schedule in _timedSampleSchedules) + foreach (var channel in _oneShotChannels) { - schedule.TransportReset(); + channel.ResetTransport(); } _playbackTimeline.PreparePlayback(_songPositionTracker.GetSongPosition(), position); } @@ -487,9 +496,9 @@ private void RemoveChannelsFromMixer() YargLogger.LogDebug("Failed to remove channel from mixer"); } } - foreach (var schedule in _timedSampleSchedules) + foreach (var channel in _oneShotChannels) { - schedule.TransportReset(); + channel.ResetTransport(); } } @@ -687,11 +696,11 @@ protected override void DisposeManagedResources() protected override void DisposeUnmanagedResources() { - foreach (var schedule in _timedSampleSchedules) + foreach (var channel in _oneShotChannels) { - schedule.MixerDisposed(); + channel.PlaybackStreamDisposed(); } - _timedSampleSchedules.Clear(); + _oneShotChannels.Clear(); if (_mixerHandle != 0) { @@ -710,321 +719,6 @@ protected override void DisposeUnmanagedResources() } } - private sealed class BassTimedSampleSchedule : TimedSampleSchedule - { - private const int CHANNEL_COUNT = 2; - private const int MAX_ACTIVE_SAMPLES = 64; - - private readonly struct TimedSample - { - public readonly int SampleId; - public readonly double SongTime; - - public TimedSample(int sampleId, double songTime) - { - SampleId = sampleId; - SongTime = songTime; - } - } - - private struct ActiveSample - { - public float[] Data; - public int Frame; - } - - private sealed class ScheduleState - { - public static readonly ScheduleState Empty = new(Array.Empty(), - new Dictionary()); - - public readonly TimedSample[] Events; - public readonly Dictionary Samples; - - public ScheduleState(TimedSample[] events, Dictionary samples) - { - Events = events; - Samples = samples; - } - } - - private readonly BassStemMixer _mixer; - private readonly DSPProcedure _callback; - private readonly int _dspHandle; - private readonly List _pendingEvents = new(); - private readonly Dictionary _pendingSamples = new(); - private readonly ActiveSample[] _activeSamples = new ActiveSample[MAX_ACTIVE_SAMPLES]; - - private ScheduleState _state = ScheduleState.Empty; - private ScheduleState _activeState; - private int _transportGeneration; - private int _activeTransportGeneration = -1; - private long _previousEndPosition; - private int _nextEvent; - private int _activeSampleCount; - private float _volume = 1; - private bool _disposed; - - public BassTimedSampleSchedule(BassStemMixer mixer) - { - _mixer = mixer; - _callback = MixSamples; - _dspHandle = Bass.ChannelSetDSP(mixer._tempoStreamHandle, _callback); - if (_dspHandle == 0) - { - YargLogger.LogFormatError("Failed to attach timed sample DSP: {0}!", Bass.LastError); - } - } - - public override bool SetSample(int sampleId, int streamHandle) - { - if (_disposed || streamHandle == 0) - { - Bass.StreamFree(streamHandle); - return false; - } - - float[] sample = DecodeSample(streamHandle); - if (sample == null) - { - return false; - } - - _pendingSamples[sampleId] = sample; - return true; - } - - public override bool Schedule(int sampleId, double songTime) - { - if (_disposed || !_pendingSamples.ContainsKey(sampleId)) - { - return false; - } - - _pendingEvents.Add(new TimedSample(sampleId, songTime)); - return true; - } - - public override void Commit() - { - if (_disposed) - { - return; - } - - _pendingEvents.Sort((left, right) => left.SongTime.CompareTo(right.SongTime)); - var state = new ScheduleState(_pendingEvents.ToArray(), - new Dictionary(_pendingSamples)); - Volatile.Write(ref _state, state); - } - - public override void SetVolume(double volume) - { - Volatile.Write(ref _volume, (float) volume); - } - - public override void Clear() - { - _pendingEvents.Clear(); - } - - public void TransportReset() - { - System.Threading.Interlocked.Increment(ref _transportGeneration); - } - - private unsafe void MixSamples(int _, int channel, IntPtr buffer, int length, IntPtr __) - { - int frameCount = length / (sizeof(float) * CHANNEL_COUNT); - if (frameCount <= 0) - { - return; - } - - long endPosition = Bass.ChannelGetPosition(channel, PositionFlags.Decode); - if (endPosition < 0) - { - return; - } - - int transportGeneration = Volatile.Read(ref _transportGeneration); - if (_activeTransportGeneration != transportGeneration) - { - _activeTransportGeneration = transportGeneration; - _previousEndPosition = 0; - _activeState = null; - _activeSampleCount = 0; - } - - long startPosition = _previousEndPosition; - _previousEndPosition = endPosition; - if (endPosition <= startPosition) - { - return; - } - - double songStart = _mixer._songPositionTracker.GetSongPosition(startPosition); - double songEnd = _mixer._songPositionTracker.GetSongPosition(endPosition); - if (songEnd <= songStart) - { - return; - } - - ScheduleState state = Volatile.Read(ref _state); - if (!ReferenceEquals(state, _activeState)) - { - _activeState = state; - _activeSampleCount = 0; - _nextEvent = FindFirstEvent(state.Events, songStart); - } - - float* output = (float*) buffer; - float volume = Volatile.Read(ref _volume); - MixActiveSamples(output, frameCount, volume); - - while (_nextEvent < state.Events.Length) - { - TimedSample timedSample = state.Events[_nextEvent]; - if (timedSample.SongTime >= songEnd) - { - break; - } - _nextEvent++; - - if (timedSample.SongTime < songStart || - !state.Samples.TryGetValue(timedSample.SampleId, out float[] sample)) - { - continue; - } - - double progress = (timedSample.SongTime - songStart) / (songEnd - songStart); - int startFrame = Math.Clamp((int) Math.Round(progress * frameCount), 0, frameCount - 1); - var activeSample = new ActiveSample { Data = sample }; - MixSample(output, frameCount, startFrame, volume, ref activeSample); - if (activeSample.Frame * CHANNEL_COUNT < sample.Length && - _activeSampleCount < _activeSamples.Length) - { - _activeSamples[_activeSampleCount++] = activeSample; - } - } - } - - private unsafe void MixActiveSamples(float* output, int frameCount, float volume) - { - int writeIndex = 0; - for (int i = 0; i < _activeSampleCount; i++) - { - ActiveSample activeSample = _activeSamples[i]; - MixSample(output, frameCount, 0, volume, ref activeSample); - if (activeSample.Frame * CHANNEL_COUNT < activeSample.Data.Length) - { - _activeSamples[writeIndex++] = activeSample; - } - } - _activeSampleCount = writeIndex; - } - - private static unsafe void MixSample(float* output, int outputFrames, int startFrame, - float volume, ref ActiveSample activeSample) - { - int sampleFrames = activeSample.Data.Length / CHANNEL_COUNT; - int framesToMix = Math.Min(outputFrames - startFrame, sampleFrames - activeSample.Frame); - int source = activeSample.Frame * CHANNEL_COUNT; - int destination = startFrame * CHANNEL_COUNT; - for (int frame = 0; frame < framesToMix; frame++) - { - output[destination++] += activeSample.Data[source++] * volume; - output[destination++] += activeSample.Data[source++] * volume; - } - activeSample.Frame += framesToMix; - } - - private static int FindFirstEvent(TimedSample[] events, double songTime) - { - int low = 0; - int high = events.Length; - while (low < high) - { - int middle = low + (high - low) / 2; - if (events[middle].SongTime < songTime) - { - low = middle + 1; - } - else - { - high = middle; - } - } - return low; - } - - private static float[] DecodeSample(int streamHandle) - { - int converter = BassMix.CreateMixerStream(44100, CHANNEL_COUNT, - BassFlags.Float | BassFlags.Decode | BassFlags.MixerEnd); - if (converter == 0) - { - YargLogger.LogFormatError("Failed to create timed sample converter: {0}!", Bass.LastError); - Bass.StreamFree(streamHandle); - return null; - } - - if (!BassMix.MixerAddChannel(converter, streamHandle, BassFlags.MixerChanNoRampin)) - { - YargLogger.LogFormatError("Failed to add timed sample to converter: {0}!", Bass.LastError); - Bass.StreamFree(converter); - Bass.StreamFree(streamHandle); - return null; - } - - var samples = new List(); - var buffer = new float[4096]; - int bytesRead; - while ((bytesRead = Bass.ChannelGetData(converter, buffer, buffer.Length * sizeof(float))) > 0) - { - int sampleCount = bytesRead / sizeof(float); - for (int i = 0; i < sampleCount; i++) - { - samples.Add(buffer[i]); - } - } - - if (bytesRead < 0 && Bass.LastError != Errors.Ended) - { - YargLogger.LogFormatError("Failed to decode timed sample: {0}!", Bass.LastError); - } - - Bass.StreamFree(converter); - Bass.StreamFree(streamHandle); - return samples.Count > 0 ? samples.ToArray() : null; - } - - public override void Dispose() - { - lock (_mixer) - { - if (_disposed) - { - return; - } - - if (_dspHandle != 0 && - !Bass.ChannelRemoveDSP(_mixer._tempoStreamHandle, _dspHandle) && - Bass.LastError != Errors.Handle) - { - YargLogger.LogFormatError("Failed to remove timed sample DSP: {0}!", Bass.LastError); - } - _mixer._timedSampleSchedules.Remove(this); - _disposed = true; - } - } - - public void MixerDisposed() - { - _disposed = true; - } - } - private void CreateChannel(SongStem stem, int sourceHandle, StreamHandle streamHandles, StreamHandle reverbHandles) { var pitchparams = BassAudioManager.SetPitchParams(stem, _speed, streamHandles, reverbHandles); diff --git a/Assets/Script/Gameplay/GameManager.Loading.cs b/Assets/Script/Gameplay/GameManager.Loading.cs index 91c06a0280..13c9431d0b 100644 --- a/Assets/Script/Gameplay/GameManager.Loading.cs +++ b/Assets/Script/Gameplay/GameManager.Loading.cs @@ -198,7 +198,7 @@ private async void Start() GlobalVariables.State.SongSpeed, Song.SongOffsetSeconds); - _metronomeScheduler = new MetronomeScheduler(_mixer, _songRunner, Chart.SyncTrack, + _metronomeScheduler = new MetronomeScheduler(_mixer, Chart.SyncTrack, SongLength, Song.SongOffsetSeconds); // Spawn players diff --git a/Assets/Script/Gameplay/MetronomeScheduler.cs b/Assets/Script/Gameplay/MetronomeScheduler.cs index c82ad9f7e8..e3c323c526 100644 --- a/Assets/Script/Gameplay/MetronomeScheduler.cs +++ b/Assets/Script/Gameplay/MetronomeScheduler.cs @@ -4,7 +4,6 @@ using YARG.Core.Audio; using YARG.Core.Chart; using YARG.Core.Logging; -using YARG.Playback; using YARG.Settings; namespace YARG.Gameplay @@ -15,9 +14,6 @@ namespace YARG.Gameplay /// public sealed class MetronomeScheduler : IDisposable { - private const int HI_SAMPLE = 0; - private const int LO_SAMPLE = 1; - private readonly struct Hit { public readonly double Time; @@ -31,17 +27,15 @@ public Hit(double time, MetronomePitch pitch) } private readonly StemMixer _mixer; - private readonly TimedSampleSchedule _schedule; - private readonly SongRunner _songRunner; private readonly List _hits = new(); private readonly double _songOffset; + private OneShotChannel _hiChannel; + private OneShotChannel _loChannel; - public MetronomeScheduler(StemMixer mixer, SongRunner songRunner, SyncTrack sync, - double songLength, double songOffset) + public MetronomeScheduler(StemMixer mixer, SyncTrack sync, double songLength, + double songOffset) { _mixer = mixer; - _schedule = mixer.CreateTimedSampleSchedule(); - _songRunner = songRunner; _songOffset = songOffset; foreach (Beatline beatline in sync.Beatlines) @@ -68,42 +62,38 @@ public MetronomeScheduler(StemMixer mixer, SongRunner songRunner, SyncTrack sync } _hits.Sort((left, right) => left.Time.CompareTo(right.Time)); - SetSamples(SettingsManager.Settings.MetronomeSound.Value); - _songRunner.AudioPrepared += Prepare; + CreateChannels(SettingsManager.Settings.MetronomeSound.Value); SettingsManager.Settings.MetronomeSound.OnChange += OnSoundChanged; SettingsManager.Settings.MetronomeVolume.OnChange += OnVolumeChanged; } - private void Prepare(double fromAudioTime) + private void CreateChannels(MetronomeSample sample) { + _hiChannel?.Dispose(); + _loChannel?.Dispose(); + + _hiChannel = _mixer.CreateOneShotChannel( + GlobalAudioHandler.CreateMetronomeStream(sample, MetronomePitch.Hi)); + _loChannel = _mixer.CreateOneShotChannel( + GlobalAudioHandler.CreateMetronomeStream(sample, MetronomePitch.Lo)); + var stopwatch = Stopwatch.StartNew(); - _schedule.Clear(); - SetVolume(SettingsManager.Settings.MetronomeSound.Value); - int scheduledHits = 0; foreach (Hit hit in _hits) { - double hitAudioTime = hit.Time + _songOffset; - if (hitAudioTime < fromAudioTime) - { - continue; - } - - int sampleId = hit.Pitch == MetronomePitch.Hi ? HI_SAMPLE : LO_SAMPLE; - if (_schedule.Schedule(sampleId, hitAudioTime)) - { - scheduledHits++; - } + OneShotChannel channel = hit.Pitch == MetronomePitch.Hi + ? _hiChannel + : _loChannel; + channel.Schedule(hit.Time + _songOffset); } - _schedule.Commit(); + SetVolume(sample); stopwatch.Stop(); YargLogger.LogFormatDebug("Scheduled {0} DSP metronome hits in {1:0.00} ms", - scheduledHits, stopwatch.Elapsed.TotalMilliseconds); + _hits.Count, stopwatch.Elapsed.TotalMilliseconds); } private void OnSoundChanged(MetronomeSample sample) { - SetSamples(sample); - Prepare(_mixer.GetPosition()); + CreateChannels(sample); } private void OnVolumeChanged(float _) @@ -115,23 +105,16 @@ private void SetVolume(MetronomeSample sample) { double volume = GlobalAudioHandler.GetTrueVolume(SongStem.Metronome) * AudioHelpers.MetronomeSamples[(int) sample].Volume; - _schedule.SetVolume(volume); - } - - private void SetSamples(MetronomeSample sample) - { - _schedule.SetSample(HI_SAMPLE, - GlobalAudioHandler.CreateMetronomeStream(sample, MetronomePitch.Hi)); - _schedule.SetSample(LO_SAMPLE, - GlobalAudioHandler.CreateMetronomeStream(sample, MetronomePitch.Lo)); + _hiChannel.SetVolume(volume); + _loChannel.SetVolume(volume); } public void Dispose() { - _songRunner.AudioPrepared -= Prepare; SettingsManager.Settings.MetronomeSound.OnChange -= OnSoundChanged; SettingsManager.Settings.MetronomeVolume.OnChange -= OnVolumeChanged; - _schedule.Dispose(); + _hiChannel.Dispose(); + _loChannel.Dispose(); } } } From 370f066e08d9f45b02bae54be6f39f222f79634f Mon Sep 17 00:00:00 2001 From: polson <302882+polson@users.noreply.github.com> Date: Mon, 13 Jul 2026 21:29:22 -0500 Subject: [PATCH 25/31] Update YARG.Core playback sync API --- YARG.Core | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/YARG.Core b/YARG.Core index 263c4e143e..89ec79f33b 160000 --- a/YARG.Core +++ b/YARG.Core @@ -1 +1 @@ -Subproject commit 263c4e143e1419f274678254b5d3629101c0e5a8 +Subproject commit 89ec79f33b57b63b7eaebe8c347fe64d4eb5784b From 757ff37a44d869d13f0266ddf6d161b1e1c77afc Mon Sep 17 00:00:00 2001 From: polson <302882+polson@users.noreply.github.com> Date: Mon, 13 Jul 2026 21:30:06 -0500 Subject: [PATCH 26/31] Remove test cowbell asset --- test_cowbell.wav | Bin 11068 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 test_cowbell.wav diff --git a/test_cowbell.wav b/test_cowbell.wav deleted file mode 100644 index e36f6f9a418674f4b84c129b91e7e1ad432b3205..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 11068 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 From 186e9780cb0e8012ad7abd29ef4cff54245e497d Mon Sep 17 00:00:00 2001 From: polson <302882+polson@users.noreply.github.com> Date: Tue, 14 Jul 2026 11:26:10 -0500 Subject: [PATCH 27/31] More cleanup --- .../Script/Audio/Bass/BassLatencyProvider.cs | 8 - .../Script/Audio/Bass/BassOneShotChannel.cs | 523 ++++++++++++------ Assets/Script/Audio/Bass/BassStemMixer.cs | 117 ++-- Assets/Script/Gameplay/GameManager.Loading.cs | 4 +- Assets/Script/Gameplay/MetronomeScheduler.cs | 54 +- Assets/Script/Playback/SongRunner.cs | 27 +- 6 files changed, 449 insertions(+), 284 deletions(-) diff --git a/Assets/Script/Audio/Bass/BassLatencyProvider.cs b/Assets/Script/Audio/Bass/BassLatencyProvider.cs index a446e8dec4..461f9f04a5 100644 --- a/Assets/Script/Audio/Bass/BassLatencyProvider.cs +++ b/Assets/Script/Audio/Bass/BassLatencyProvider.cs @@ -35,14 +35,6 @@ public static double GetTempoStreamLatency(int tempoStreamHandle) return GetOutputBufferLatency(tempoStreamHandle) + CommandLatency; } - /// - /// Gets time needed for newly started audio to cross BASS and device output buffers. - /// - public static double GetOutputTransitionLatency() - { - return GetPlaybackStreamLatency(); - } - private static double GetOutputBufferLatency(int tempoStreamHandle) { double configuredBufferLatency = BassHelpers.ConfiguredPlaybackBufferLength / 1000.0; diff --git a/Assets/Script/Audio/Bass/BassOneShotChannel.cs b/Assets/Script/Audio/Bass/BassOneShotChannel.cs index 75acee1a12..d0bb1d8e33 100644 --- a/Assets/Script/Audio/Bass/BassOneShotChannel.cs +++ b/Assets/Script/Audio/Bass/BassOneShotChannel.cs @@ -9,101 +9,105 @@ namespace YARG.Audio.BASS { - /// - /// Mixes immediate and scheduled instances of one sample into a float playback stream. - /// internal sealed class BassOneShotChannel : OneShotChannel { private const int MAX_ACTIVE_SAMPLES = 64; + private const int MAX_SCHEDULES = 64; + private const int DECODE_BUFFER_SIZE = 4096; - private struct ActiveSample - { - public float[] Data; - public int Frame; - } - - private sealed class ScheduleState - { - public readonly double[] Events; - public int Count; - - public ScheduleState(int capacity) - { - Events = new double[capacity]; - } - } + private readonly object _stateLock = new(); private readonly int _playbackStreamHandle; private readonly int _sampleRate; private readonly int _channelCount; + private readonly int _sampleFrameCount; + private readonly Func _getPlaybackTime; - private readonly float[] _sample; - private readonly DSPProcedure _callback; - private readonly int _dspHandle; + private readonly float[] _sample; + private readonly int _dspHandle; + private readonly ConcurrentQueue _immediatePlays = new(); - private readonly ActiveSample[] _activeSamples = new ActiveSample[MAX_ACTIVE_SAMPLES]; + private readonly int[] _activeSampleFrames = new int[MAX_ACTIVE_SAMPLES]; + + private ScheduleState _schedule = + new(MAX_SCHEDULES); + + private ScheduleState _callbackSchedule; + private int _nextScheduledEvent; - private ScheduleState _state = new(64); - private ScheduleState _activeState; private int _transportGeneration; - private int _activeTransportGeneration = -1; + private int _callbackTransportGeneration = -1; private long _previousEndPosition; - private int _nextEvent; + private int _activeSampleCount; private float _volume = 1; private bool _disposed; internal event Action Disposed; - /// - /// Attaches to a stream using its own position in seconds as scheduling time. - /// public BassOneShotChannel(int playbackStreamHandle, int sampleStream) - : this(playbackStreamHandle, sampleStream, - position => Bass.ChannelBytes2Seconds(playbackStreamHandle, position)) + : this( + playbackStreamHandle, + sampleStream, + position => Bass.ChannelBytes2Seconds( + playbackStreamHandle, + position)) { } - /// - /// Attaches to a stream using a function that maps stream positions to scheduling time. - /// - public BassOneShotChannel(int playbackStreamHandle, int sampleStream, + public BassOneShotChannel( + int playbackStreamHandle, + int sampleStream, Func getPlaybackTime) { _playbackStreamHandle = playbackStreamHandle; - _getPlaybackTime = getPlaybackTime ?? throw new ArgumentNullException(nameof(getPlaybackTime)); + _getPlaybackTime = getPlaybackTime ?? + throw new ArgumentNullException(nameof(getPlaybackTime)); - ChannelInfo info = Bass.ChannelGetInfo(playbackStreamHandle); + var info = Bass.ChannelGetInfo(playbackStreamHandle); if ((info.Flags & BassFlags.Float) == 0) { Bass.StreamFree(sampleStream); - throw new ArgumentException("Playback stream must use float sample data.", + + throw new ArgumentException( + "Playback stream must use float sample data.", nameof(playbackStreamHandle)); } _sampleRate = info.Frequency; _channelCount = info.Channels; _sample = DecodeSample(sampleStream) ?? Array.Empty(); - if (_sample.Length == 0) + _sampleFrameCount = _sample.Length / _channelCount; + + if (_sampleFrameCount == 0) { return; } - _callback = MixSamples; - _previousEndPosition = Math.Max(0, - Bass.ChannelGetPosition(playbackStreamHandle, PositionFlags.Decode)); - _dspHandle = Bass.ChannelSetDSP(playbackStreamHandle, _callback); + DSPProcedure callback = MixSamples; + _previousEndPosition = Math.Max( + 0, + Bass.ChannelGetPosition( + playbackStreamHandle, + PositionFlags.Decode)); + + _dspHandle = Bass.ChannelSetDSP( + playbackStreamHandle, + callback); + if (_dspHandle == 0) { - YargLogger.LogFormatError("Failed to attach one-shot DSP: {0}!", Bass.LastError); + LogBassError("Failed to attach one-shot DSP: {0}!"); } } + private bool IsAvailable => !_disposed && _dspHandle != 0; + public override void Play() { - lock (this) + lock (_stateLock) { - if (!_disposed && _dspHandle != 0) + if (IsAvailable) { _immediatePlays.Enqueue(0); } @@ -112,35 +116,15 @@ public override void Play() public override void Schedule(double songTime) { - lock (this) + lock (_stateLock) { - if (_disposed || _dspHandle == 0) - { - return; - } - - ScheduleState state = Volatile.Read(ref _state); - int count = Volatile.Read(ref state.Count); - if (count < state.Events.Length && - (count == 0 || songTime >= state.Events[count - 1])) + if (!IsAvailable) { - state.Events[count] = songTime; - Volatile.Write(ref state.Count, count + 1); return; } - int insertionIndex = Array.BinarySearch(state.Events, 0, count, songTime); - if (insertionIndex < 0) - { - insertionIndex = ~insertionIndex; - } - var newState = new ScheduleState(Math.Max(state.Events.Length * 2, count + 1)); - Array.Copy(state.Events, 0, newState.Events, 0, insertionIndex); - newState.Events[insertionIndex] = songTime; - Array.Copy(state.Events, insertionIndex, newState.Events, insertionIndex + 1, - count - insertionIndex); - newState.Count = count + 1; - Volatile.Write(ref _state, newState); + var schedule = Volatile.Read(ref _schedule).Add(songTime); + Volatile.Write(ref _schedule, schedule); } } @@ -151,11 +135,13 @@ public override void SetVolume(double volume) public override void ClearSchedule() { - lock (this) + lock (_stateLock) { if (!_disposed) { - Volatile.Write(ref _state, new ScheduleState(64)); + Volatile.Write( + ref _schedule, + new ScheduleState(MAX_SCHEDULES)); } } } @@ -165,135 +151,246 @@ public void ResetTransport() Interlocked.Increment(ref _transportGeneration); } - private unsafe void MixSamples(int _, int channel, IntPtr buffer, int length, IntPtr __) + private unsafe void MixSamples( + int _, + int channel, + IntPtr buffer, + int length, + IntPtr __) { - int frameCount = length / (sizeof(float) * _channelCount); - if (frameCount <= 0) + int frameCount = + length / (sizeof(float) * _channelCount); + + if (frameCount <= 0 || + !TryGetPlaybackWindow( + channel, + out double startTime, + out double endTime)) { return; } - long endPosition = Bass.ChannelGetPosition(channel, PositionFlags.Decode); + float* output = (float*) buffer; + float volume = Volatile.Read(ref _volume); + + MixActiveSamples(output, frameCount, volume); + MixImmediateSamples(output, frameCount, volume); + MixScheduledSamples( + output, + frameCount, + volume, + GetCallbackSchedule(startTime), + startTime, + endTime); + } + + private bool TryGetPlaybackWindow( + int channel, + out double startTime, + out double endTime) + { + startTime = 0; + endTime = 0; + + long endPosition = + Bass.ChannelGetPosition(channel, PositionFlags.Decode); + if (endPosition < 0) { - return; + return false; } - int transportGeneration = Volatile.Read(ref _transportGeneration); - if (_activeTransportGeneration != transportGeneration) + int generation = Volatile.Read(ref _transportGeneration); + if (_callbackTransportGeneration != generation) { - _activeTransportGeneration = transportGeneration; - _previousEndPosition = 0; - _activeState = null; - _activeSampleCount = 0; + ResetCallbackState(generation); } long startPosition = _previousEndPosition; _previousEndPosition = endPosition; + if (endPosition <= startPosition) { - return; + return false; } - double playbackStart = _getPlaybackTime(startPosition); - double playbackEnd = _getPlaybackTime(endPosition); - if (playbackEnd <= playbackStart) - { - return; - } + startTime = _getPlaybackTime(startPosition); + endTime = _getPlaybackTime(endPosition); + + return endTime > startTime; + } + + private void ResetCallbackState(int generation) + { + _callbackTransportGeneration = generation; + _previousEndPosition = 0; + _callbackSchedule = null; + _nextScheduledEvent = 0; + _activeSampleCount = 0; + } + + private ScheduleState GetCallbackSchedule(double playbackStart) + { + ScheduleState schedule = Volatile.Read(ref _schedule); - ScheduleState state = Volatile.Read(ref _state); - if (!ReferenceEquals(state, _activeState)) + if (!ReferenceEquals(schedule, _callbackSchedule)) { - _activeState = state; - _nextEvent = FindFirstEvent(state.Events, - Volatile.Read(ref state.Count), playbackStart); + _callbackSchedule = schedule; + _nextScheduledEvent = FindFirstEvent( + schedule.Events, + Volatile.Read(ref schedule.Count), + playbackStart); } - float* output = (float*) buffer; - float volume = Volatile.Read(ref _volume); - MixActiveSamples(output, frameCount, volume); - MixImmediateSamples(output, frameCount, volume); + return schedule; + } + + private unsafe void MixScheduledSamples( + float* output, + int frameCount, + float volume, + ScheduleState schedule, + double playbackStart, + double playbackEnd) + { + int eventCount = Volatile.Read(ref schedule.Count); + double duration = playbackEnd - playbackStart; - int eventCount = Volatile.Read(ref state.Count); - while (_nextEvent < eventCount) + while (_nextScheduledEvent < eventCount) { - double eventTime = state.Events[_nextEvent]; + double eventTime = + schedule.Events[_nextScheduledEvent]; + if (eventTime >= playbackEnd) { - break; + return; } - _nextEvent++; + + _nextScheduledEvent++; if (eventTime < playbackStart) { continue; } - double progress = (eventTime - playbackStart) / - (playbackEnd - playbackStart); - int startFrame = Math.Clamp((int) Math.Round(progress * frameCount), 0, + double progress = + (eventTime - playbackStart) / duration; + + int startFrame = Math.Clamp( + (int) Math.Round(progress * frameCount), + 0, frameCount - 1); - StartSample(output, frameCount, startFrame, volume); + + StartSample( + output, + frameCount, + startFrame, + volume); } } - private unsafe void MixActiveSamples(float* output, int frameCount, float volume) + private unsafe void MixActiveSamples( + float* output, + int frameCount, + float volume) { int writeIndex = 0; + for (int i = 0; i < _activeSampleCount; i++) { - ActiveSample activeSample = _activeSamples[i]; - MixSample(output, frameCount, 0, volume, ref activeSample); - if (activeSample.Frame * _channelCount < activeSample.Data.Length) + int sampleFrame = _activeSampleFrames[i]; + + MixSample( + output, + frameCount, + startFrame: 0, + volume, + ref sampleFrame); + + if (sampleFrame < _sampleFrameCount) { - _activeSamples[writeIndex++] = activeSample; + _activeSampleFrames[writeIndex++] = sampleFrame; } } + _activeSampleCount = writeIndex; } - private unsafe void MixImmediateSamples(float* output, int frameCount, float volume) + private unsafe void MixImmediateSamples( + float* output, + int frameCount, + float volume) { - for (int i = 0; i < MAX_ACTIVE_SAMPLES && _immediatePlays.TryDequeue(out _); i++) + for (int i = 0; + i < MAX_ACTIVE_SAMPLES && + _immediatePlays.TryDequeue(out _); + i++) { - StartSample(output, frameCount, 0, volume); + StartSample( + output, + frameCount, + startFrame: 0, + volume); } } - private unsafe void StartSample(float* output, int frameCount, int startFrame, float volume) + private unsafe void StartSample( + float* output, + int frameCount, + int startFrame, + float volume) { - var activeSample = new ActiveSample { Data = _sample }; - MixSample(output, frameCount, startFrame, volume, ref activeSample); - if (activeSample.Frame * _channelCount < _sample.Length && - _activeSampleCount < _activeSamples.Length) + int sampleFrame = 0; + + MixSample( + output, + frameCount, + startFrame, + volume, + ref sampleFrame); + + if (sampleFrame < _sampleFrameCount && + _activeSampleCount < MAX_ACTIVE_SAMPLES) { - _activeSamples[_activeSampleCount++] = activeSample; + _activeSampleFrames[_activeSampleCount++] = sampleFrame; } } - private unsafe void MixSample(float* output, int outputFrames, int startFrame, - float volume, ref ActiveSample activeSample) + private unsafe void MixSample( + float* output, + int outputFrames, + int startFrame, + float volume, + ref int sampleFrame) { - int sampleFrames = activeSample.Data.Length / _channelCount; - int framesToMix = Math.Min(outputFrames - startFrame, sampleFrames - activeSample.Frame); - int source = activeSample.Frame * _channelCount; + int framesToMix = Math.Min( + outputFrames - startFrame, + _sampleFrameCount - sampleFrame); + + int source = sampleFrame * _channelCount; int destination = startFrame * _channelCount; - int valuesToMix = framesToMix * _channelCount; - for (int i = 0; i < valuesToMix; i++) + int valuesRemaining = framesToMix * _channelCount; + + while (valuesRemaining-- > 0) { - output[destination++] += activeSample.Data[source++] * volume; + output[destination++] += _sample[source++] * volume; } - activeSample.Frame += framesToMix; + + sampleFrame += framesToMix; } - private static int FindFirstEvent(double[] events, int eventCount, double playbackTime) + private static int FindFirstEvent( + double[] events, + int eventCount, + double playbackTime) { int low = 0; int high = eventCount; + while (low < high) { int middle = low + (high - low) / 2; + if (events[middle] < playbackTime) { low = middle + 1; @@ -303,69 +400,88 @@ private static int FindFirstEvent(double[] events, int eventCount, double playba high = middle; } } + return low; } private float[] DecodeSample(int streamHandle) { - int converter = BassMix.CreateMixerStream(_sampleRate, _channelCount, - BassFlags.Float | BassFlags.Decode | BassFlags.MixerEnd); + int converter = BassMix.CreateMixerStream( + _sampleRate, + _channelCount, + BassFlags.Float | + BassFlags.Decode | + BassFlags.MixerEnd); + if (converter == 0) { - YargLogger.LogFormatError("Failed to create one-shot sample converter: {0}!", - Bass.LastError); - Bass.StreamFree(streamHandle); - return null; - } + LogBassError( + "Failed to create one-shot sample converter: {0}!"); - if (!BassMix.MixerAddChannel(converter, streamHandle, BassFlags.MixerChanNoRampin)) - { - YargLogger.LogFormatError("Failed to add one-shot sample to converter: {0}!", - Bass.LastError); - Bass.StreamFree(converter); Bass.StreamFree(streamHandle); return null; } - var samples = new List(); - var buffer = new float[4096]; - int bytesRead; - while ((bytesRead = Bass.ChannelGetData(converter, buffer, - buffer.Length * sizeof(float))) > 0) + try { - int sampleCount = bytesRead / sizeof(float); - for (int i = 0; i < sampleCount; i++) + if (!BassMix.MixerAddChannel( + converter, + streamHandle, + BassFlags.MixerChanNoRampin)) { - samples.Add(buffer[i]); + LogBassError( + "Failed to add one-shot sample to converter: {0}!"); + + return null; } - } - if (bytesRead < 0 && Bass.LastError != Errors.Ended) + var samples = new List(); + var buffer = new float[DECODE_BUFFER_SIZE]; + + int bytesRead; + while ((bytesRead = Bass.ChannelGetData( + converter, + buffer, + buffer.Length * sizeof(float))) > 0) + { + int sampleCount = bytesRead / sizeof(float); + + for (int i = 0; i < sampleCount; i++) + { + samples.Add(buffer[i]); + } + } + + if (bytesRead < 0 && Bass.LastError != Errors.Ended) + { + LogBassError( + "Failed to decode one-shot sample: {0}!"); + } + + return samples.Count == 0 + ? null + : samples.ToArray(); + } + finally { - YargLogger.LogFormatError("Failed to decode one-shot sample: {0}!", Bass.LastError); + Bass.StreamFree(converter); + Bass.StreamFree(streamHandle); } - - Bass.StreamFree(converter); - Bass.StreamFree(streamHandle); - return samples.Count > 0 ? samples.ToArray() : null; } public override void Dispose() { Action disposed; - lock (this) + + lock (_stateLock) { if (_disposed) { return; } - if (_dspHandle != 0 && - !Bass.ChannelRemoveDSP(_playbackStreamHandle, _dspHandle) && - Bass.LastError != Errors.Handle) - { - YargLogger.LogFormatError("Failed to remove one-shot DSP: {0}!", Bass.LastError); - } + RemoveDsp(); + _disposed = true; disposed = Disposed; Disposed = null; @@ -374,13 +490,82 @@ public override void Dispose() disposed?.Invoke(this); } + private void RemoveDsp() + { + if (_dspHandle == 0 || + Bass.ChannelRemoveDSP( + _playbackStreamHandle, + _dspHandle) || + Bass.LastError == Errors.Handle) + { + return; + } + + LogBassError("Failed to remove one-shot DSP: {0}!"); + } + internal void PlaybackStreamDisposed() { - lock (this) + lock (_stateLock) { _disposed = true; Disposed = null; } } + + private static void LogBassError(string format) + { + YargLogger.LogFormatError(format, Bass.LastError); + } + + private sealed class ScheduleState + { + public readonly double[] Events; + public int Count; + + public ScheduleState(int capacity) + { + Events = new double[capacity]; + } + + public ScheduleState Add(double songTime) + { + int count = Volatile.Read(ref Count); + + if (CanAppend(songTime, count)) + { + Events[count] = songTime; + Volatile.Write(ref Count, count + 1); + return this; + } + + int index = Array.BinarySearch(Events, 0, count, songTime); + if (index < 0) + { + index = ~index; + } + + var replacement = new ScheduleState( + Math.Max(Events.Length * 2, count + 1)); + + Array.Copy(Events, 0, replacement.Events, 0, index); + replacement.Events[index] = songTime; + Array.Copy( + Events, + index, + replacement.Events, + index + 1, + count - index); + + replacement.Count = count + 1; + return replacement; + } + + private bool CanAppend(double songTime, int count) + { + return count < Events.Length && + (count == 0 || songTime >= Events[count - 1]); + } + } } -} +} \ No newline at end of file diff --git a/Assets/Script/Audio/Bass/BassStemMixer.cs b/Assets/Script/Audio/Bass/BassStemMixer.cs index 9d09c14d8d..4c557dfb4d 100644 --- a/Assets/Script/Audio/Bass/BassStemMixer.cs +++ b/Assets/Script/Audio/Bass/BassStemMixer.cs @@ -42,46 +42,25 @@ public StemData(SongStem stem, float[,]? volumeMatrix, StreamHandle streamHandle private static bool IsWhammyEnabled => SettingsManager.Settings.UseWhammyFx.Value; private bool IsPlaying => Bass.ChannelIsActive(_tempoStreamHandle) == PlaybackState.Playing; - private readonly int _mixerHandle; - private readonly List _sourceHandles = new(); - private readonly int _tempoStreamHandle; - private readonly SongPositionTracker _songPositionTracker; - private readonly BufferedPlaybackTimeline _playbackTimeline; - private bool _didSeek; - private bool _preparedBeyondEnd; - private int _songEndHandle; - private float _songSpeed = 1.0f; - private float _speed = 1.0f; - private Timer _whammySyncTimer; - private readonly List _stemDatas = new(); + private readonly int _mixerHandle; + private readonly List _sourceHandles = new(); + private readonly int _tempoStreamHandle; + private readonly SongPositionTracker _songPositionTracker; + private readonly BufferedPlaybackTimeline _playbackTimeline; + private bool _didSeek; + private int _songEndHandle; + private float _songSpeed = 1.0f; + private float _speed = 1.0f; + private Timer _whammySyncTimer; + private readonly List _stemDatas = new(); private readonly HashSet _oneShotChannels = new(); - private int _longestHandle; + private int _longestHandle; private readonly BassNormalizer _normalizer = new(); private bool _shouldNormalize; private int _gainDspHandle; private float _gain = 1.0f; - public override OneShotChannel CreateOneShotChannel(int sampleStream) - { - lock (this) - { - var channel = new BassOneShotChannel(_tempoStreamHandle, sampleStream, - _songPositionTracker.GetSongPosition); - channel.Disposed += OnOneShotChannelDisposed; - _oneShotChannels.Add(channel); - return channel; - } - } - - private void OnOneShotChannelDisposed(BassOneShotChannel channel) - { - lock (this) - { - _oneShotChannels.Remove(channel); - } - } - public override event Action SongEnd { add @@ -133,12 +112,10 @@ internal BassStemMixer(string name, BassAudioManager manager, float speed, doubl _whammySyncTimer = new Timer(); SetOutputChannel_Internal(outputChannel); SetVolume_Internal(volume); - SetSpeed_Internal(speed, true); - + SetPlaybackSpeed_Internal(speed, 0f, true); _BufferSetter(SettingsManager.Settings.PlaybackBufferLength.Value); } - private void AddGainDSP() { _gainDspHandle = Bass.ChannelSetDSP(_mixerHandle, (handle, channel, buffer, length, user) => @@ -164,15 +141,17 @@ protected override int Play_Internal() if (!IsPlaying) { - // Start the logical transport before BASS. ChannelPlay/ChannelUpdate can block while - // priming output; that time is real playback time and must not become sync error. - double startupLatency = BassLatencyProvider.GetOutputTransitionLatency(); + double startupLatency = BassLatencyProvider.GetPlaybackStreamLatency(); _playbackTimeline.Play(startupLatency); + + // Restart on the tempostream resets position to 0, position here represents time since last play call if (!Bass.ChannelPlay(_tempoStreamHandle, Restart: _didSeek)) { _playbackTimeline.Pause(); return (int) Bass.LastError; } + + //Force immediate update to start the playback precisely Bass.ChannelUpdate(_tempoStreamHandle, 0); _didSeek = false; } @@ -247,11 +226,7 @@ protected override PlaybackPosition GetPlaybackPosition_Internal() return _playbackTimeline.GetPosition(rawPosition, tempoLatency); } - protected override double GetTempoStreamLatency_Internal() - { - return BassLatencyProvider.GetTempoStreamLatency(_tempoStreamHandle); - } - + // The total delay between playback command and when audio is heard private double GetPlaybackStartOffset() { return _playbackTimeline.OutputLatency + _songPositionTracker.AlignmentDelay; @@ -275,7 +250,6 @@ protected override void SetPosition_Internal(double position) double preparedPosition = position + playbackOffset; double seekPosition = Math.Clamp(preparedPosition, 0, _length); double playbackDelay = Math.Max(0, -preparedPosition); - _preparedBeyondEnd = position >= _length; RemoveChannelsFromMixer(); if (AddChannelsToMixer(_stemDatas, playbackDelay, out double alignmentDelay)) @@ -290,10 +264,7 @@ protected override void SetPosition_Internal(double position) { YargLogger.LogFormatError("Failed to reset tempo stream position: {0}!", Bass.LastError); } - foreach (var channel in _oneShotChannels) - { - channel.ResetTransport(); - } + _playbackTimeline.PreparePlayback(_songPositionTracker.GetSongPosition(), position); } @@ -370,11 +341,6 @@ protected override int GetLevel_Internal(float[] level) return (int) Errors.OK; } - protected override void SetSpeed_Internal(float speed, bool shiftPitch) - { - SetPlaybackSpeed_Internal(speed, 0f, shiftPitch); - } - protected override void SetPlaybackSpeed_Internal(float songSpeed, float syncAdjustment, bool shiftPitch) { float speed = (float) Math.Clamp(songSpeed + syncAdjustment, 0.05, 50); @@ -556,7 +522,7 @@ private bool AddChannelsToMixer(IEnumerable stemStreamDataList, double { var stemData = stemStreamDataList.ToArray(); - // Align every stem with the largest pitch fx latency. + // Align every stem with the largest pitch fx latency. Latencies per stem can differ due to sample rate alignmentDelay = stemData.Max(data => data.PitchFxDelay); foreach (var data in stemData) @@ -655,7 +621,6 @@ private void _BufferSetter(int length) // 0 disables buffering. Positive values must meet BASS minimum buffer requirements. length = BassHelpers.ClampPlaybackBufferLength(length); float lengthInSeconds = length / 1000f; - if (!Bass.ChannelSetAttribute(_tempoStreamHandle, ChannelAttribute.Buffer, lengthInSeconds)) { YargLogger.LogFormatError("Failed to set playback buffer: {0}!", Bass.LastError); @@ -746,6 +711,29 @@ private void UpdateThreading() } } + public override OneShotChannel CreateOneShotChannel(int sampleStream) + { + lock (this) + { + var channel = new BassOneShotChannel( + _tempoStreamHandle, + sampleStream, + _songPositionTracker.GetSongPosition + ); + channel.Disposed += OnOneShotChannelDisposed; + _oneShotChannels.Add(channel); + return channel; + } + } + + private void OnOneShotChannelDisposed(BassOneShotChannel channel) + { + lock (this) + { + _oneShotChannels.Remove(channel); + } + } + /// /// Gets actual song position from tempo stream. /// @@ -759,14 +747,13 @@ private void UpdateThreading() /// private sealed class SongPositionTracker { - private readonly int _tempoStreamHandle; - private double _songStart; - private double _alignmentDelay; - private double _playbackDelay; + private readonly int _tempoStreamHandle; + private double _songStart; + private double _playbackDelay; + + public double AlignmentDelay { get; private set; } - public double AlignmentDelay => _alignmentDelay; - public double TotalDelay => _alignmentDelay + _playbackDelay; - public double SongStart => _songStart; + private double TotalDelay => AlignmentDelay + _playbackDelay; public SongPositionTracker(int tempoStreamHandle) { @@ -797,13 +784,13 @@ public double GetSongPosition(long tempoStreamPosition) public void Reset(double songStart, double alignmentDelay, double playbackDelay) { _songStart = songStart; - _alignmentDelay = alignmentDelay; + AlignmentDelay = alignmentDelay; _playbackDelay = playbackDelay; } public void SetAlignmentDelay(double delay) { - _alignmentDelay = delay; + AlignmentDelay = delay; } private double GetTempoStreamPosition() diff --git a/Assets/Script/Gameplay/GameManager.Loading.cs b/Assets/Script/Gameplay/GameManager.Loading.cs index 13c9431d0b..792e656e48 100644 --- a/Assets/Script/Gameplay/GameManager.Loading.cs +++ b/Assets/Script/Gameplay/GameManager.Loading.cs @@ -198,8 +198,8 @@ private async void Start() GlobalVariables.State.SongSpeed, Song.SongOffsetSeconds); - _metronomeScheduler = new MetronomeScheduler(_mixer, Chart.SyncTrack, - SongLength, Song.SongOffsetSeconds); + _metronomeScheduler = new MetronomeScheduler( + _mixer, _songRunner, Chart.SyncTrack, SongLength); // Spawn players CreatePlayers(); diff --git a/Assets/Script/Gameplay/MetronomeScheduler.cs b/Assets/Script/Gameplay/MetronomeScheduler.cs index e3c323c526..167ad0a73b 100644 --- a/Assets/Script/Gameplay/MetronomeScheduler.cs +++ b/Assets/Script/Gameplay/MetronomeScheduler.cs @@ -1,9 +1,8 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using YARG.Core.Audio; using YARG.Core.Chart; -using YARG.Core.Logging; +using YARG.Playback; using YARG.Settings; namespace YARG.Gameplay @@ -28,40 +27,28 @@ public Hit(double time, MetronomePitch pitch) private readonly StemMixer _mixer; private readonly List _hits = new(); - private readonly double _songOffset; private OneShotChannel _hiChannel; private OneShotChannel _loChannel; - public MetronomeScheduler(StemMixer mixer, SyncTrack sync, double songLength, - double songOffset) + public MetronomeScheduler(StemMixer mixer, SongRunner songRunner, SyncTrack sync, + double songLength) { _mixer = mixer; - _songOffset = songOffset; - foreach (Beatline beatline in sync.Beatlines) + foreach (var beatline in sync.Beatlines) { - if (beatline.Type == BeatlineType.Measure && beatline.Time <= songLength) - { - _hits.Add(new Hit(beatline.Time, MetronomePitch.Hi)); - } - } - - for (uint tick = 0; ; tick += sync.Resolution) - { - double time = sync.TickToTime(tick); - if (time > songLength) + if (beatline.Time > songLength) { break; } - _hits.Add(new Hit(time, MetronomePitch.Lo)); - if (uint.MaxValue - tick < sync.Resolution) - { - break; - } + var pitch = beatline.Type == BeatlineType.Measure + ? MetronomePitch.Hi + : MetronomePitch.Lo; + double audioTime = songRunner.SongTimeToAudioPlaybackTime(beatline.Time); + _hits.Add(new Hit(audioTime, pitch)); } - _hits.Sort((left, right) => left.Time.CompareTo(right.Time)); CreateChannels(SettingsManager.Settings.MetronomeSound.Value); SettingsManager.Settings.MetronomeSound.OnChange += OnSoundChanged; SettingsManager.Settings.MetronomeVolume.OnChange += OnVolumeChanged; @@ -72,23 +59,16 @@ private void CreateChannels(MetronomeSample sample) _hiChannel?.Dispose(); _loChannel?.Dispose(); - _hiChannel = _mixer.CreateOneShotChannel( - GlobalAudioHandler.CreateMetronomeStream(sample, MetronomePitch.Hi)); - _loChannel = _mixer.CreateOneShotChannel( - GlobalAudioHandler.CreateMetronomeStream(sample, MetronomePitch.Lo)); - - var stopwatch = Stopwatch.StartNew(); - foreach (Hit hit in _hits) + var hiStream = GlobalAudioHandler.CreateMetronomeStream(sample, MetronomePitch.Hi); + var loStream = GlobalAudioHandler.CreateMetronomeStream(sample, MetronomePitch.Lo); + _hiChannel = _mixer.CreateOneShotChannel(hiStream); + _loChannel = _mixer.CreateOneShotChannel(loStream); + foreach (var hit in _hits) { - OneShotChannel channel = hit.Pitch == MetronomePitch.Hi - ? _hiChannel - : _loChannel; - channel.Schedule(hit.Time + _songOffset); + var channel = hit.Pitch == MetronomePitch.Hi ? _hiChannel : _loChannel; + channel.Schedule(hit.Time); } SetVolume(sample); - stopwatch.Stop(); - YargLogger.LogFormatDebug("Scheduled {0} DSP metronome hits in {1:0.00} ms", - _hits.Count, stopwatch.Elapsed.TotalMilliseconds); } private void OnSoundChanged(MetronomeSample sample) diff --git a/Assets/Script/Playback/SongRunner.cs b/Assets/Script/Playback/SongRunner.cs index a30c5a593b..c1204494a4 100644 --- a/Assets/Script/Playback/SongRunner.cs +++ b/Assets/Script/Playback/SongRunner.cs @@ -529,6 +529,11 @@ public double GetRelativeInputTime(double timeFromInputSystem) return (timeFromInputSystem - InputTimeOffset) * SongSpeed; } + public double SongTimeToAudioPlaybackTime(double songTime) + { + return songTime - SongOffset; + } + private void UpdateTimes() { // Re-anchoring can use a newer on-demand input timestamp than this frame's cached @@ -633,9 +638,9 @@ public void SetSongTime(double time, double delayTime = SONG_START_DELAY) public void SetSongSpeed(float speed) { - speed = ClampSongSpeed(speed); lock (_syncThread) { + speed = ClampSongSpeed(speed); if (Mathf.Approximately(speed, _requestedSongSpeed)) { return; @@ -654,8 +659,24 @@ private void ApplySpeedChange(float speed) { double inputTime = InputTime; SongSpeed = speed; - ResetSync(); - SetInputBaseChecked(inputTime); + + if (!Started || Paused) + { + ResetSync(); + SetInputBaseChecked(inputTime); + return; + } + + // BASS applies tempo changes after samples already buffered in the tempo stream. + // Rebuild at the current position so gameplay and audible playback start the new + // speed together. The control position predicts through that delay, so sync error + // alone cannot detect the audible offset left by changing speed in place. + SetInputBaseAt(inputTime, InputManager.CurrentInputTime); + PrepareAudioPlayback(); + + // Mixer preparation takes time. Keep gameplay fixed at the position being prepared. + SetInputBaseAt(inputTime, InputManager.CurrentInputTime); + _mixer.Play(); } public void AdjustSongSpeed(float deltaSpeed) => SetSongSpeed(_requestedSongSpeed + deltaSpeed); From e9d54aa4d45fc2cb7e0e72b8e97db44a71c78cd9 Mon Sep 17 00:00:00 2001 From: polson <302882+polson@users.noreply.github.com> Date: Tue, 14 Jul 2026 12:23:08 -0500 Subject: [PATCH 28/31] Much simplified song runner --- Assets/Script/Gameplay/GameManager.cs | 22 +- Assets/Script/Playback/SongRunner.cs | 407 ++++++++++---------------- 2 files changed, 167 insertions(+), 262 deletions(-) diff --git a/Assets/Script/Gameplay/GameManager.cs b/Assets/Script/Gameplay/GameManager.cs index 5c6956a656..45598a0c19 100644 --- a/Assets/Script/Gameplay/GameManager.cs +++ b/Assets/Script/Gameplay/GameManager.cs @@ -238,23 +238,14 @@ private void OnDestroy() EngineManager.OnCodaEnd -= EndCoda; EngineManager.OnUnisonPhraseSuccess -= OnUnisonPhraseSuccess; - // Stop the audio worker before any teardown callback can touch the mixer or UI. + // Stop playback-owned work before teardown callbacks touch the mixer or UI. _metronomeScheduler?.Dispose(); _songRunner?.Dispose(); - bool canDisposeMixer = _songRunner == null || _songRunner.SyncThreadStopped; - // Restore stem volumes to their original state while the mixer is still valid. - if (canDisposeMixer) - { - foreach (var (stem, state) in _stemStates) - { - GlobalAudioHandler.SetVolumeSetting(stem, state.Volume); - } - } - else + foreach (var (stem, state) in _stemStates) { - YargLogger.LogError("Skipping mixer-dependent teardown because the audio sync thread is still running."); + GlobalAudioHandler.SetVolumeSetting(stem, state.Volume); } DisposeDebug(); @@ -268,10 +259,7 @@ private void OnDestroy() // Crowd teardown stops SFX through GlobalAudioHandler, so it must happen while audio is initialized. CrowdEventHandler?.Dispose(); - if (canDisposeMixer) - { - _mixer?.Dispose(); - } + _mixer?.Dispose(); BackgroundManager.Dispose(); @@ -284,6 +272,8 @@ private void OnDestroy() private void Update() { + + // Pause/unpause if (Keyboard.current.escapeKey.wasPressedThisFrame) { diff --git a/Assets/Script/Playback/SongRunner.cs b/Assets/Script/Playback/SongRunner.cs index c1204494a4..406da3daf1 100644 --- a/Assets/Script/Playback/SongRunner.cs +++ b/Assets/Script/Playback/SongRunner.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Generic; using System.Threading; using Cysharp.Threading.Tasks; using DG.Tweening; @@ -66,15 +65,12 @@ namespace YARG.Playback // in line. This produces little to no audible effect in BASS, its time stretching is well-suited // for this purpose. Seeking has also been considered for large desyncs, but is not implemented // currently. + // + // SongRunner and its audio synchronizer run on Unity's main thread. This gives mixer transport + // one owner, so synchronization cannot interleave with pause, seek, or speed-change operations. public class SongRunner : IDisposable { - /// - /// Invoked synchronously after the mixer has been rebuilt and positioned, before playback resumes. - /// Event argument is logical audio position requested from mixer. - /// - public event Action AudioPrepared; - #region Times public const double SONG_START_DELAY = 2; @@ -174,15 +170,6 @@ public class SongRunner : IDisposable /// private float _requestedSongSpeed; - /// - /// The actual current playback speed of the song. - /// - /// - /// The audio may be sped up or slowed down in order to re-synchronize. - /// This value takes that speed adjustment into account. - /// - public float RealSongSpeed => SongSpeed + _syncSpeedAdjustment; - /// /// Whether or not the runner has been started. /// @@ -196,7 +183,7 @@ public class SongRunner : IDisposable /// /// Whether or not the song's pause state is currently overridden. /// - public bool PauseOverridden => _pauseOverrides > 0; + private bool PauseOverridden => _pauseOverrides > 0; private int _pauseOverrides; private bool _resumeAfterOverride; @@ -204,6 +191,7 @@ public class SongRunner : IDisposable private bool _pausedForFrameDebugger; private double _forceStartTime = double.NaN; + private bool _disposed; #endregion @@ -213,36 +201,17 @@ public class SongRunner : IDisposable #endregion #region Audio syncing - private const int SYNC_THREAD_SHUTDOWN_TIMEOUT_MS = 1000; - - private Thread _syncThread; - - private volatile bool _disposed; - - public bool SyncThreadStopped { get; private set; } - - private volatile float _syncSpeedAdjustment; - private volatile float _debugEffectiveSyncAdjustment; - private volatile float _debugSyncStartDelta; - private volatile float _debugSyncWorstDelta; - - private const double SYNC_DEADBAND_SECONDS = 0.0015; - private const float CORRECTION_TIME_SECONDS = 0.1f; - private const float SYNC_CLAMP = 0.50f; - - private double _lastFrameInputTime; - private long _lastFrameTimestamp; - private readonly StemMixer _mixer; + private readonly AudioSynchronizer _audioSynchronizer; - public float DebugEffectiveSyncAdjustment => _debugEffectiveSyncAdjustment; - public float DebugSyncStartDelta => _debugSyncStartDelta; - public float DebugSyncWorstDelta => _debugSyncWorstDelta; + public float DebugEffectiveSyncAdjustment => _audioSynchronizer.EffectiveAdjustment; + public float DebugSyncStartDelta => _audioSynchronizer.StartDelta; + public float DebugSyncWorstDelta => _audioSynchronizer.WorstDelta; /// /// The latest sampled difference between the target and actual audio synchronization times. /// - public double SyncError { get; private set; } + public double SyncError => _audioSynchronizer.Error; #endregion #region Seek debugging @@ -252,7 +221,7 @@ public class SongRunner : IDisposable #endregion /// - /// Creates a new song runner with the given speed and calibration values. + /// Creates a song runner at the given position and speed. /// /// /// The created song runner will be in an unstarted state. Upon calling , @@ -263,21 +232,14 @@ public class SongRunner : IDisposable /// Since the runner starts paused, anything that might potentially interact with it before /// starting must respect the paused state, otherwise incorrect behavior may happen. /// - /// - /// The percentage song speed, where 1f == 100%. - /// - /// - /// The audio calibration, in milliseconds.
- /// This value is negated and normalized to seconds for more intuitive usage in other code. - /// is also applied to keep things visually synced. - /// - /// - /// The video calibration, in milliseconds.
- /// This value is negated and normalized to seconds for more intuitive usage in other code. + /// The mixer used for song playback. + /// The initial gameplay time, in seconds. + /// + /// The delay before , in seconds of playback time. /// + /// The song speed, where 1f is 100%. /// - /// The song offset, in seconds.
- /// This value is negated for more intuitive usage in other code. + /// The chart's audio offset, in seconds. This value is negated for internal use. /// public SongRunner( StemMixer mixer, @@ -288,76 +250,28 @@ double songOffset ) { _mixer = mixer; + _audioSynchronizer = new AudioSynchronizer(mixer); SongSpeed = ClampSongSpeed(songSpeed); _requestedSongSpeed = SongSpeed; SongOffset = -songOffset; - _syncThread = new Thread(SyncThread) { IsBackground = true }; InitializeSongTime(startTime + SongOffset, startDelay); UpdateCalibration(); - - Application.quitting += OnApplicationQuitting; - } - - ~SongRunner() - { - Dispose(false); } public void Dispose() { - Dispose(true); - GC.SuppressFinalize(this); - } - - private void Dispose(bool disposing) - { - if (!_disposed) + if (_disposed) { - Application.quitting -= OnApplicationQuitting; - - if (_syncThread != null) - { - lock (_syncThread) - { - _disposed = true; - } - } - else - { - _disposed = true; - } - - if (disposing) - { - _rewindSource?.Cancel(); - _rewindTween?.Kill(); - - if (_syncThread != null) - { - SyncThreadStopped = !_syncThread.IsAlive || - _syncThread.Join(SYNC_THREAD_SHUTDOWN_TIMEOUT_MS); - if (!SyncThreadStopped) - { - YargLogger.LogError("Audio sync thread did not stop during song teardown."); - return; - } - } - else - { - SyncThreadStopped = true; - } - - _syncThread = null; - _rewindSource?.Dispose(); - _rewindSource = null; - _rewindTween = null; - } + return; } - } - private void OnApplicationQuitting() - { - Dispose(); + _disposed = true; + + _rewindSource?.Cancel(); + _rewindTween?.Kill(); + _rewindSource?.Dispose(); + _rewindSource = null; + _rewindTween = null; } private void Start() @@ -373,20 +287,11 @@ private void Start() SetInputBaseAt(startInputTime, InputManager.CurrentInputTime); _mixer.Play(); - _lastFrameInputTime = InputManager.CurrentInputTime; - _lastFrameTimestamp = System.Diagnostics.Stopwatch.GetTimestamp(); - _syncThread.Start(); Started = true; } public void Update() { - lock (_syncThread) - { - _lastFrameInputTime = InputManager.CurrentInputTime; - _lastFrameTimestamp = System.Diagnostics.Stopwatch.GetTimestamp(); - } - // Runner is lazy-started to avoid timing issues with lag if (!Started) { @@ -439,67 +344,7 @@ public void Update() _seeked = false; } - private void SyncThread() - { - const int syncIntervalMs = 10; - for (; !_disposed; Thread.Sleep(syncIntervalMs)) - { - lock (_syncThread) - { - if (_disposed) - { - break; - } - - long currentTimestamp = System.Diagnostics.Stopwatch.GetTimestamp(); - double elapsedSeconds = (double) (currentTimestamp - _lastFrameTimestamp) / System.Diagnostics.Stopwatch.Frequency; - double currentTime = _lastFrameInputTime + elapsedSeconds; - double syncTargetTime = GetRelativeInputTime(currentTime) - SongOffset; - var playbackPosition = _mixer.GetPlaybackPosition(); - double syncError = syncTargetTime - playbackPosition.Control; - SyncError = syncError; - - bool isWithinSongBounds = - !Paused && - syncTargetTime >= 0 && - syncTargetTime < _mixer.Length && - playbackPosition.Heard < _mixer.Length; - - // Converts delay-free playback error into a temporary speed adjustment. - // Audio pipeline delay is modeled by the mixer and does not belong in this controller. - float adjustment = 0f; - if (isWithinSongBounds && Math.Abs(syncError) >= SYNC_DEADBAND_SECONDS) - { - adjustment = Math.Clamp( - (float) syncError / CORRECTION_TIME_SECONDS, - -SYNC_CLAMP, - SYNC_CLAMP); - } - _debugEffectiveSyncAdjustment = adjustment; - if (!isWithinSongBounds) - { - continue; - } - - bool correctionStarting = _syncSpeedAdjustment == 0f && adjustment != 0f; - if (correctionStarting) - { - _debugSyncStartDelta = (float) syncError; - _debugSyncWorstDelta = _debugSyncStartDelta; - } - else if (adjustment != 0f && Math.Abs(syncError) > Math.Abs(_debugSyncWorstDelta)) - { - _debugSyncWorstDelta = (float) syncError; - } - - if (adjustment != _syncSpeedAdjustment) - { - _syncSpeedAdjustment = adjustment; - _mixer.SetPlaybackSpeed(SongSpeed, adjustment, false); - } - } - } - } + /// /// Prepares audio after any gameplay timeline discontinuity: playback start, seek, or unpause. @@ -508,20 +353,10 @@ private void SyncThread() private void PrepareAudioPlayback() { _mixer.Pause(); - ResetSync(); + _audioSynchronizer.Reset(SongSpeed); double audioTime = InputTime - SongOffset; _mixer.SetPosition(audioTime); - AudioPrepared?.Invoke(audioTime); - } - - private void ResetSync() - { - _syncSpeedAdjustment = 0f; - _debugEffectiveSyncAdjustment = 0f; - _debugSyncStartDelta = 0f; - _debugSyncWorstDelta = 0f; - _mixer.SetPlaybackSpeed(SongSpeed, 0f, true); } public double GetRelativeInputTime(double timeFromInputSystem) @@ -543,7 +378,8 @@ private void UpdateTimes() SongTime = InputTime + (AudioCalibration * SongSpeed); VisualTime = InputTime + (VideoCalibration * SongSpeed); - AudioPlaybackTime = _mixer.GetPlaybackPosition().Heard; + double audioTargetTime = InputTime - SongOffset; + AudioPlaybackTime = _audioSynchronizer.Synchronize(audioTargetTime, SongSpeed); } private void SetInputBase(double songTime) @@ -564,7 +400,7 @@ private void SetInputBaseAt(double songTime, double inputSystemTime) InputTime = GetRelativeInputTime(inputSystemTime); SongTime = InputTime + (AudioCalibration * SongSpeed); VisualTime = InputTime + (VideoCalibration * SongSpeed); - AudioPlaybackTime = _mixer.GetPlaybackPosition().Heard; + AudioPlaybackTime = _audioSynchronizer.SampleHeardTime(); YargLogger.LogFormatDebug( "Set input time base.\n" + @@ -615,41 +451,35 @@ private void InitializeSongTime(double time, double delayTime) public void SetSongTime(double time, double delayTime = SONG_START_DELAY) { - lock (_syncThread) - { - SongSpeed = _requestedSongSpeed; + SongSpeed = _requestedSongSpeed; - // Set input/song time - InitializeSongTime(time, delayTime); - double seekInputTime = InputTime; + // Set input/song time + InitializeSongTime(time, delayTime); + double seekInputTime = InputTime; - PrepareAudioPlayback(); + PrepareAudioPlayback(); - // Seeking can take several milliseconds. Anchor after it completes so that command - // execution does not advance the gameplay timeline alone. - SetInputBaseAt(seekInputTime, InputManager.CurrentInputTime); - if (!Paused) - { - _mixer.Play(); - } - _seeked = true; + // Seeking can take several milliseconds. Anchor after it completes so that command + // execution does not advance the gameplay timeline alone. + SetInputBaseAt(seekInputTime, InputManager.CurrentInputTime); + if (!Paused) + { + _mixer.Play(); } + _seeked = true; } public void SetSongSpeed(float speed) { - lock (_syncThread) + speed = ClampSongSpeed(speed); + if (Mathf.Approximately(speed, _requestedSongSpeed)) { - speed = ClampSongSpeed(speed); - if (Mathf.Approximately(speed, _requestedSongSpeed)) - { - return; - } - - _requestedSongSpeed = speed; - ApplySpeedChange(speed); + return; } + _requestedSongSpeed = speed; + ApplySpeedChange(speed); + YargLogger.LogFormatDebug("Set song speed to {0:0.00}.\n" + "Song time: {1:0.000000}, visual time: {2:0.000000}, input time: {3:0.000000}", speed, SongTime, VisualTime, InputTime); @@ -662,7 +492,7 @@ private void ApplySpeedChange(float speed) if (!Started || Paused) { - ResetSync(); + _audioSynchronizer.Reset(SongSpeed); SetInputBaseChecked(inputTime); return; } @@ -715,6 +545,7 @@ public void Pause() if (Paused) return; + _audioSynchronizer.Suspend(SongSpeed); Paused = true; _mixer.Pause(); @@ -738,19 +569,15 @@ public void Resume() if (!Paused) return; + UpdateCalibration(); + double resumeInputTime = InputTime; + PrepareAudioPlayback(); + Paused = false; - lock (_syncThread) - { - UpdateCalibration(); - double resumeInputTime = InputTime; - PrepareAudioPlayback(); - Paused = false; - - // Seeking can take several milliseconds. Anchor after it completes so that work - // does not advance the resumed timeline. - SetInputBaseAt(resumeInputTime, InputManager.CurrentInputTime); - _mixer.Play(); - } + // Seeking can take several milliseconds. Anchor after it completes so that work + // does not advance the resumed timeline. + SetInputBaseAt(resumeInputTime, InputManager.CurrentInputTime); + _mixer.Play(); YargLogger.LogFormatDebug( "Resumed at song time {0:0.000000}, visual time {1:0.000000}, input time {2:0.000000}.", @@ -758,18 +585,6 @@ public void Resume() ); } - public void SetPaused(bool paused) - { - if (paused) - { - Pause(); - } - else - { - Resume(); - } - } - /// /// Forces the song to be paused until is called, /// for long-running operations that must be completed before resuming. @@ -853,7 +668,7 @@ public async UniTask RewindAndResume(double seconds, double? overrideTarge return false; } - public static float ClampSongSpeed(float speed) + private static float ClampSongSpeed(float speed) { // 10% - 5000%, we reserve 5% at the bottom so that audio syncing can still function. // BASS can go up to 5100%, but we round down since 5000% looks nicer (and it gives us a @@ -861,4 +676,104 @@ public static float ClampSongSpeed(float speed) return Math.Clamp(speed, 10 / 100f, 5000 / 100f); } } + + /// + /// Keeps mixer playback aligned with gameplay time + /// + internal sealed class AudioSynchronizer + { + private const double SYNC_DEADBAND_SECONDS = 0.0015; + private const float CORRECTION_TIME_SECONDS = 0.1f; + private const float SYNC_CLAMP = 0.50f; + + private readonly StemMixer _mixer; + + public float Adjustment { get; private set; } + public float EffectiveAdjustment { get; private set; } + public float StartDelta { get; private set; } + public float WorstDelta { get; private set; } + public double Error { get; private set; } + + public AudioSynchronizer(StemMixer mixer) + { + _mixer = mixer; + } + + public double Synchronize(double targetTime, float songSpeed) + { + var position = _mixer.GetPlaybackPosition(); + Error = targetTime - position.Control; + + bool isWithinSongBounds = + targetTime >= 0 && + targetTime < _mixer.Length && + position.Heard < _mixer.Length; + + float adjustment = 0f; + if (isWithinSongBounds && Math.Abs(Error) >= SYNC_DEADBAND_SECONDS) + { + adjustment = Math.Clamp( + (float) Error / CORRECTION_TIME_SECONDS, + -SYNC_CLAMP, + SYNC_CLAMP); + } + + EffectiveAdjustment = adjustment; + RecordCorrection(adjustment); + ApplyAdjustment(songSpeed, adjustment); + return position.Heard; + } + + public double SampleHeardTime() + { + return _mixer.GetPlaybackPosition().Heard; + } + + /// + /// Restores requested speed and rebuilds mixer speed state after a timeline discontinuity. + /// + public void Reset(float songSpeed) + { + Adjustment = 0f; + EffectiveAdjustment = 0f; + StartDelta = 0f; + WorstDelta = 0f; + Error = 0; + _mixer.SetPlaybackSpeed(songSpeed); + } + + /// + /// Removes active correction while playback synchronization is suspended. + /// + public void Suspend(float songSpeed) + { + EffectiveAdjustment = 0f; + ApplyAdjustment(songSpeed, 0f); + } + + private void RecordCorrection(float adjustment) + { + bool correctionStarting = Adjustment == 0f && adjustment != 0f; + if (correctionStarting) + { + StartDelta = (float) Error; + WorstDelta = StartDelta; + } + else if (adjustment != 0f && Math.Abs(Error) > Math.Abs(WorstDelta)) + { + WorstDelta = (float) Error; + } + } + + private void ApplyAdjustment(float songSpeed, float adjustment) + { + if (Mathf.Approximately(adjustment, Adjustment)) + { + return; + } + + Adjustment = adjustment; + _mixer.SetPlaybackSpeed(songSpeed, adjustment, false); + } + } } From ae40c1c5c92e244dc5b0f2b6c01c2f2132e2f4d6 Mon Sep 17 00:00:00 2001 From: polson <302882+polson@users.noreply.github.com> Date: Tue, 14 Jul 2026 15:36:26 -0500 Subject: [PATCH 29/31] More cleanup --- Assets/Script/Audio/Bass/BassStemMixer.cs | 41 ++- .../Audio/Bass/BufferedPlaybackTimeline.cs | 173 +++++++----- Assets/Script/Gameplay/GameManager.Debug.cs | 6 +- Assets/Script/Gameplay/GameManager.cs | 4 +- Assets/Script/Gameplay/MetronomeScheduler.cs | 37 +-- Assets/Script/Gameplay/Player/BasePlayer.cs | 2 +- Assets/Script/Playback/SongRunner.cs | 246 ++++++++++-------- 7 files changed, 295 insertions(+), 214 deletions(-) diff --git a/Assets/Script/Audio/Bass/BassStemMixer.cs b/Assets/Script/Audio/Bass/BassStemMixer.cs index 4c557dfb4d..2f0b1dca37 100644 --- a/Assets/Script/Audio/Bass/BassStemMixer.cs +++ b/Assets/Script/Audio/Bass/BassStemMixer.cs @@ -38,6 +38,8 @@ public StemData(SongStem stem, float[,]? volumeMatrix, StreamHandle streamHandle #nullable disable private const float WHAMMY_SYNC_INTERVAL_SECONDS = 1f; + private const float MIN_PLAYBACK_SPEED = 0.05f; + private const float MAX_PLAYBACK_SPEED = 51f; private static bool IsWhammyEnabled => SettingsManager.Settings.UseWhammyFx.Value; private bool IsPlaying => Bass.ChannelIsActive(_tempoStreamHandle) == PlaybackState.Playing; @@ -219,11 +221,10 @@ protected override double GetPosition_Internal() return _songPositionTracker.GetSongPosition(); } - protected override PlaybackPosition GetPlaybackPosition_Internal() + protected override double GetControlPosition_Internal() { - double rawPosition = _songPositionTracker.GetSongPosition(); - double tempoLatency = BassLatencyProvider.GetTempoStreamLatency(_tempoStreamHandle); - return _playbackTimeline.GetPosition(rawPosition, tempoLatency); + double bassPosition = _songPositionTracker.GetSongPosition(); + return _playbackTimeline.GetControlPosition(bassPosition); } // The total delay between playback command and when audio is heard @@ -265,7 +266,7 @@ protected override void SetPosition_Internal(double position) YargLogger.LogFormatError("Failed to reset tempo stream position: {0}!", Bass.LastError); } - _playbackTimeline.PreparePlayback(_songPositionTracker.GetSongPosition(), position); + _playbackTimeline.ResetAfterSeek(_songPositionTracker.GetSongPosition(), position); } if (wasPlaying) @@ -343,17 +344,29 @@ protected override int GetLevel_Internal(float[] level) protected override void SetPlaybackSpeed_Internal(float songSpeed, float syncAdjustment, bool shiftPitch) { - float speed = (float) Math.Clamp(songSpeed + syncAdjustment, 0.05, 50); - syncAdjustment = speed - songSpeed; + // SongRunner clamps requested song speed, but the temporary synchronization adjustment can + // push the effective speed outside BASS_FX's supported 5%-5100% tempo range. + float effectiveSpeed = Math.Clamp( + songSpeed + syncAdjustment, + MIN_PLAYBACK_SPEED, + MAX_PLAYBACK_SPEED + ); + + // Model the speed BASS actually receives. This can differ from the requested adjustment + // when the effective speed reaches one of the limits above. + float appliedAdjustment = effectiveSpeed - songSpeed; _songSpeed = songSpeed; - if (_speed != speed) + + // Exact comparison is intentional. If BASS receives a new float value, the playback model + // must record the same value; an approximate comparison could let the two drift apart. + if (_speed != effectiveSpeed) { - _speed = speed; - BassAudioManager.SetSpeed(speed, _tempoStreamHandle, shiftPitch); + _speed = effectiveSpeed; + BassAudioManager.SetSpeed(effectiveSpeed, _tempoStreamHandle, shiftPitch); } double tempoLatency = BassLatencyProvider.GetTempoStreamLatency(_tempoStreamHandle); - _playbackTimeline.SetSpeed(songSpeed, syncAdjustment, tempoLatency); + _playbackTimeline.SetSpeed(songSpeed, appliedAdjustment, tempoLatency); } protected override void SetOutputLatency_Internal(double latency) @@ -380,12 +393,16 @@ protected override bool AddChannels_Internal(Stream stream, params StemInfo[] st _sourceHandles.Add(sourceStream); - if (!BuildStemData(sourceStream, stemInfos, out List stemDatas)) + if (!BuildStemData(sourceStream, stemInfos, out var stemDatas)) { return false; } _stemDatas.AddRange(stemDatas); + + // Every stem is padded to match the largest pitch-effect delay in the mixer. A new stem can + // increase that delay, so rebuild all mixer channels to keep every stem aligned. Rebuilding + // also prevents the existing streams from being added a second time below. RemoveChannelsFromMixer(); if (!AddChannelsToMixer(_stemDatas, 0, out double delay)) { diff --git a/Assets/Script/Audio/Bass/BufferedPlaybackTimeline.cs b/Assets/Script/Audio/Bass/BufferedPlaybackTimeline.cs index d99a005443..5566672ff2 100644 --- a/Assets/Script/Audio/Bass/BufferedPlaybackTimeline.cs +++ b/Assets/Script/Audio/Bass/BufferedPlaybackTimeline.cs @@ -1,74 +1,106 @@ using System; using System.Collections.Generic; using System.Diagnostics; -using YARG.Core.Audio; namespace YARG.Audio.BASS { /// - /// Models mixer playback without exposing tempo and output-buffer delay to callers. + /// Converts current BASS position into delay-free control position used for gameplay synchronization. /// + /// + /// BASS position is the source of truth. Rate histories track remaining BASS buffer time so + /// synchronization can account for commands that have not taken effect yet. + /// + /// Two histories represent BASS command buffering: + /// + /// applies commands immediately. + /// applies commands after their measured BASS latency. + /// + /// Differences between these histories reveal song progress hidden by the remaining BASS buffer. + /// internal sealed class BufferedPlaybackTimeline { - private readonly Func _getTime; - private readonly PositionModel _immediate = new(); - private readonly PositionModel _tempoOutput = new(); + private const double HISTORY_MARGIN_SECONDS = 1.0; + private readonly PlaybackRateHistory _commandedRateHistory = new(); + private readonly PlaybackRateHistory _bufferedRateHistory = new(); - private double _retainedHistorySeconds; - private double _outputLatency; - private float _songSpeed; - private float _syncAdjustment; - private bool _isPlaying; + private float _songSpeed; + private float _syncAdjustment; + private bool _isPlaying; - public double OutputLatency => _outputLatency; + public double OutputLatency { get; private set; } public BufferedPlaybackTimeline(float speed) - : this(speed, GetCurrentTime) - { - } - - internal BufferedPlaybackTimeline(float speed, Func getTime) { _songSpeed = speed; - _getTime = getTime; - Reset(0); + double now = GetCurrentTime(); + _commandedRateHistory.Reset(now, 0, 0f); + _bufferedRateHistory.Reset(now, 0, 0f); } - public PlaybackPosition GetPosition(double rawPosition, double tempoLatency) + /// + /// Converts current BASS position into delay-free control position used to synchronize playback. + /// + /// Current song position read from BASS. + /// Position with pending BASS command-buffer progress applied. + /// + /// + /// Mathematical relationships: + /// Control = Raw BASS Position + (Commanded Integral - Buffered Integral) + /// + /// + /// Playback speed changes do not take effect immediately. Control position accounts for that delay, + /// allowing synchronization to calculate error as: + /// error = target time - Control + /// + /// + /// We can then use that error to predict a speed adjustment in AudioSynchronizer.Synchronize. + /// + /// + /// BASS position remains the source of truth. Rate histories only account for commands still + /// pending in the BASS buffer. + /// + /// + public double GetControlPosition(double bassPosition) { - double now = _getTime(); - tempoLatency = Math.Max(0, tempoLatency); - _retainedHistorySeconds = Math.Max( - _retainedHistorySeconds, - tempoLatency + Math.Abs(_outputLatency)); - - double modeledTempoPosition = _tempoOutput.GetPosition(now); - double modeledHeardPosition = _tempoOutput.GetPosition(now - _outputLatency); - - // Correct modeled output with observed BASS position. Same correction is applied to - // delay-free model, which is Smith-predictor feedback for model/clock drift. - double modelError = rawPosition - modeledTempoPosition; - double heardPosition = modeledHeardPosition + modelError; - double controlPosition = _immediate.GetPosition(now) + modelError; - - double cutoff = now - _retainedHistorySeconds - 1.0; - _immediate.PruneBefore(cutoff); - _tempoOutput.PruneBefore(cutoff); - return new PlaybackPosition(heardPosition, controlPosition); + double now = GetCurrentTime(); + + // Command history changes rate immediately; buffered history changes rate when the remaining + // BASS buffer reaches command. Their difference removes that buffer delay from the + // synchronization position. + double commandBufferingOffset = + _commandedRateHistory.GetPositionAt(now) - _bufferedRateHistory.GetPositionAt(now); + double controlPosition = bassPosition + commandBufferingOffset; + + // Re-anchor old history periodically. Pruning preserves all positions from the cutoff onward. + double cutoff = now - HISTORY_MARGIN_SECONDS; + _commandedRateHistory.PruneBefore(cutoff); + _bufferedRateHistory.PruneBefore(cutoff); + return controlPosition; } + /// + /// Sets calibrated delay between BASS tempo output and audio heard by player. + /// + /// + /// Moving the command history by the same amount makes synchronization move playback toward the + /// newly calibrated target instead of treating calibration as a reporting-only change. + /// public void SetOutputLatency(double latency) { - double latencyChange = latency - _outputLatency; + double latencyChange = latency - OutputLatency; if (latencyChange != 0) { - _immediate.Shift(-latencyChange * CurrentRate); + _commandedRateHistory.Shift(-latencyChange * CurrentRate); } - _outputLatency = latency; - _retainedHistorySeconds = Math.Max(_retainedHistorySeconds, Math.Abs(_outputLatency)); + OutputLatency = latency; } + /// + /// Records a speed command. Synchronization uses the new speed immediately, while BASS tempo + /// output does not reflect it until audio already buffered by the tempo stream has played. + /// public void SetSpeed(float songSpeed, float syncAdjustment, double tempoLatency) { if (_songSpeed == songSpeed && _syncAdjustment == syncAdjustment) @@ -83,12 +115,16 @@ public void SetSpeed(float songSpeed, float syncAdjustment, double tempoLatency) return; } - double now = _getTime(); + double now = GetCurrentTime(); float rate = CurrentRate; - _immediate.SetRate(now, rate); - _tempoOutput.SetRate(now + Math.Max(0, tempoLatency), rate); + _commandedRateHistory.SetRate(now, rate); + _bufferedRateHistory.SetRate(now + Math.Max(0, tempoLatency), rate); } + /// + /// Starts position advancement. Commanded playback starts now; BASS output starts after its + /// measured startup latency. + /// public void Play(double startupLatency) { if (_isPlaying) @@ -97,11 +133,15 @@ public void Play(double startupLatency) } _isPlaying = true; - double now = _getTime(); - _immediate.SetRate(now, CurrentRate); - _tempoOutput.SetRate(now + Math.Max(0, startupLatency), CurrentRate); + double now = GetCurrentTime(); + _commandedRateHistory.SetRate(now, CurrentRate); + _bufferedRateHistory.SetRate(now + Math.Max(0, startupLatency), CurrentRate); } + /// + /// Stops both position histories at the current time and removes commands that had not yet + /// reached buffered output. + /// public void Pause() { if (!_isPlaying) @@ -110,26 +150,22 @@ public void Pause() } _isPlaying = false; - double now = _getTime(); - _immediate.Stop(now); - _tempoOutput.Stop(now); - } - - public void Reset(double songPosition) - { - double now = _getTime(); - float rate = _isPlaying ? CurrentRate : 0f; - double controlPosition = songPosition - _outputLatency * CurrentRate; - _immediate.Reset(now, controlPosition, rate); - _tempoOutput.Reset(now, songPosition, rate); + double now = GetCurrentTime(); + _commandedRateHistory.Stop(now); + _bufferedRateHistory.Stop(now); } - public void PreparePlayback(double observedPosition, double requestedPosition) + /// + /// Re-anchors both histories after the mixer has prepared a seek. + /// + /// Position now reported by the prepared BASS streams. + /// Position requested by the playback caller. + public void ResetAfterSeek(double observedPosition, double requestedPosition) { - double now = _getTime(); + double now = GetCurrentTime(); float rate = _isPlaying ? CurrentRate : 0f; - _immediate.Reset(now, requestedPosition, rate); - _tempoOutput.Reset(now, observedPosition, rate); + _commandedRateHistory.Reset(now, requestedPosition, rate); + _bufferedRateHistory.Reset(now, observedPosition, rate); } private float CurrentRate => _songSpeed + _syncAdjustment; @@ -139,7 +175,10 @@ private static double GetCurrentTime() return (double) Stopwatch.GetTimestamp() / Stopwatch.Frequency; } - private sealed class PositionModel + /// + /// Playback-rate history used to calculate song progress between timestamps. + /// + private sealed class PlaybackRateHistory { private readonly List _changes = new(); private double _startPosition; @@ -182,7 +221,7 @@ public void SetRate(double timestamp, float rate) _changes.Add(new RateChange(timestamp, rate)); } - public double GetPosition(double timestamp) + public double GetPositionAt(double timestamp) { RateChange first = _changes[0]; if (timestamp <= first.Timestamp) @@ -216,7 +255,7 @@ public void PruneBefore(double timestamp) return; } - double position = GetPosition(timestamp); + double position = GetPositionAt(timestamp); float rate = GetRate(timestamp); int removeCount = 0; while (removeCount < _changes.Count && _changes[removeCount].Timestamp <= timestamp) diff --git a/Assets/Script/Gameplay/GameManager.Debug.cs b/Assets/Script/Gameplay/GameManager.Debug.cs index 4ab54d2e3a..64de156478 100644 --- a/Assets/Script/Gameplay/GameManager.Debug.cs +++ b/Assets/Script/Gameplay/GameManager.Debug.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using Cysharp.Text; @@ -561,8 +561,8 @@ private void TimingDebug() text.AppendFormat("Audio sync error: {0:0.000} ms\n", _songRunner.SyncError * 1000.0); text.AppendFormat("Resync start delta: {0:0.000} ms\n", _songRunner.DebugSyncStartDelta * 1000f); text.AppendFormat("Resync worst delta: {0:0.000} ms\n", _songRunner.DebugSyncWorstDelta * 1000f); - text.AppendFormat("Effective speed adjustment: {0:0.000}\n", - _songRunner.DebugEffectiveSyncAdjustment); + text.AppendFormat("Speed adjustment: {0:0.000}\n", + _songRunner.DebugSyncAdjustment); GUILayout.Label(text.AsSpan().TrimEnd('\n').ToString()); } diff --git a/Assets/Script/Gameplay/GameManager.cs b/Assets/Script/Gameplay/GameManager.cs index 45598a0c19..b0463bac62 100644 --- a/Assets/Script/Gameplay/GameManager.cs +++ b/Assets/Script/Gameplay/GameManager.cs @@ -611,8 +611,8 @@ public bool OverrideResume() return resumed; } - public double GetRelativeInputTime(double timeFromInputSystem) - => _songRunner.GetRelativeInputTime(timeFromInputSystem); + public double GetInputTime(double inputSystemTime) + => _songRunner.GetInputTime(inputSystemTime); private bool EndSong() { diff --git a/Assets/Script/Gameplay/MetronomeScheduler.cs b/Assets/Script/Gameplay/MetronomeScheduler.cs index 167ad0a73b..c5fc0786be 100644 --- a/Assets/Script/Gameplay/MetronomeScheduler.cs +++ b/Assets/Script/Gameplay/MetronomeScheduler.cs @@ -26,7 +26,7 @@ public Hit(double time, MetronomePitch pitch) } private readonly StemMixer _mixer; - private readonly List _hits = new(); + private readonly List _hits; private OneShotChannel _hiChannel; private OneShotChannel _loChannel; @@ -34,7 +34,16 @@ public MetronomeScheduler(StemMixer mixer, SongRunner songRunner, SyncTrack sync double songLength) { _mixer = mixer; + _hits = CreateHits(songRunner, sync, songLength); + CreateChannels(SettingsManager.Settings.MetronomeSound.Value); + SettingsManager.Settings.MetronomeSound.OnChange += OnSoundChanged; + SettingsManager.Settings.MetronomeVolume.OnChange += OnVolumeChanged; + } + + private static List CreateHits(SongRunner songRunner, SyncTrack sync, double songLength) + { + var hits = new List(); foreach (var beatline in sync.Beatlines) { if (beatline.Time > songLength) @@ -42,23 +51,16 @@ public MetronomeScheduler(StemMixer mixer, SongRunner songRunner, SyncTrack sync break; } - var pitch = beatline.Type == BeatlineType.Measure - ? MetronomePitch.Hi - : MetronomePitch.Lo; - double audioTime = songRunner.SongTimeToAudioPlaybackTime(beatline.Time); - _hits.Add(new Hit(audioTime, pitch)); + var pitch = beatline.Type == BeatlineType.Measure ? MetronomePitch.Hi : MetronomePitch.Lo; + double audioTime = songRunner.GetAudioPlaybackTime(beatline.Time); + hits.Add(new Hit(audioTime, pitch)); } - - CreateChannels(SettingsManager.Settings.MetronomeSound.Value); - SettingsManager.Settings.MetronomeSound.OnChange += OnSoundChanged; - SettingsManager.Settings.MetronomeVolume.OnChange += OnVolumeChanged; + return hits; } private void CreateChannels(MetronomeSample sample) { - _hiChannel?.Dispose(); - _loChannel?.Dispose(); - + DisposeChannels(); var hiStream = GlobalAudioHandler.CreateMetronomeStream(sample, MetronomePitch.Hi); var loStream = GlobalAudioHandler.CreateMetronomeStream(sample, MetronomePitch.Lo); _hiChannel = _mixer.CreateOneShotChannel(hiStream); @@ -93,8 +95,13 @@ public void Dispose() { SettingsManager.Settings.MetronomeSound.OnChange -= OnSoundChanged; SettingsManager.Settings.MetronomeVolume.OnChange -= OnVolumeChanged; - _hiChannel.Dispose(); - _loChannel.Dispose(); + DisposeChannels(); + } + + private void DisposeChannels() + { + _hiChannel?.Dispose(); + _loChannel?.Dispose(); } } } diff --git a/Assets/Script/Gameplay/Player/BasePlayer.cs b/Assets/Script/Gameplay/Player/BasePlayer.cs index f8bb93d595..757a96f6aa 100644 --- a/Assets/Script/Gameplay/Player/BasePlayer.cs +++ b/Assets/Script/Gameplay/Player/BasePlayer.cs @@ -355,7 +355,7 @@ protected void OnGameInput(ref GameInput input) LastInputs[input.Action] = input; - double adjustedTime = GameManager.GetRelativeInputTime(input.Time); + double adjustedTime = GameManager.GetInputTime(input.Time); // Apply input offset adjustedTime += InputCalibration; input = new(adjustedTime, input.Action, input.Integer); diff --git a/Assets/Script/Playback/SongRunner.cs b/Assets/Script/Playback/SongRunner.cs index 406da3daf1..3efd629c6d 100644 --- a/Assets/Script/Playback/SongRunner.cs +++ b/Assets/Script/Playback/SongRunner.cs @@ -73,6 +73,7 @@ public class SongRunner : IDisposable { #region Times public const double SONG_START_DELAY = 2; + private const double MAX_START_FRAME_LENGTH = 0.1; /// /// The time into the song, accounting for song speed and audio calibration.
@@ -81,7 +82,7 @@ public class SongRunner : IDisposable /// /// This value should be used for all interactions that are relative to the audio. /// Note that this is driven by input time, rather than audio time. - /// Use if the actual audio time is required. + /// Use if actual audio time is required. /// public double SongTime { get; private set; } @@ -102,26 +103,14 @@ public class SongRunner : IDisposable public double InputTime { get; private set; } /// - /// The playback position of the audio relative to gameplay.
- /// This is updated every frame while not paused. + /// Current heard playback position relative to gameplay. ///
/// /// This value is for scenarios that must be tied to audio playback time, /// as opposed to input/visual time. /// In general, should be used instead where possible. /// - public double AudioTime => AudioPlaybackTime + SongOffset; - - /// - /// The playback position of the audio relative to the audio file only.
- /// This is updated every frame while not paused. - ///
- /// - /// This value is for scenarios that must know the position into the audio file, - /// as opposed to the gameplay song position. - /// In general, should be used instead where possible. - /// - public double AudioPlaybackTime { get; private set; } + public double AudioTime => _mixer.GetPosition() - AudioCalibration + SongOffset; #endregion #region Offsets @@ -204,7 +193,7 @@ public class SongRunner : IDisposable private readonly StemMixer _mixer; private readonly AudioSynchronizer _audioSynchronizer; - public float DebugEffectiveSyncAdjustment => _audioSynchronizer.EffectiveAdjustment; + public float DebugSyncAdjustment => _audioSynchronizer.EffectiveAdjustment; public float DebugSyncStartDelta => _audioSynchronizer.StartDelta; public float DebugSyncWorstDelta => _audioSynchronizer.WorstDelta; @@ -281,10 +270,7 @@ private void Start() // Re-initialize song times to avoid lag issues InitializeSongTime(InputTime, 0); double startInputTime = InputTime; - PrepareAudioPlayback(); - - // Mixer preparation can take several milliseconds. Start both timelines from now. - SetInputBaseAt(startInputTime, InputManager.CurrentInputTime); + PrepareAudioAt(startInputTime); _mixer.Play(); Started = true; @@ -295,112 +281,139 @@ public void Update() // Runner is lazy-started to avoid timing issues with lag if (!Started) { - // Hack: delay if the starting frame lagged - - // Only delay a maximum of one second - if (double.IsNaN(_forceStartTime)) - { - _forceStartTime = InputManager.CurrentInputTime + 1; - } - - double currentTime = InputManager.CurrentInputTime; - double currentFrameLength = currentTime - InputManager.InputUpdateTime; - if (currentFrameLength >= 0.1f && currentTime < _forceStartTime) + if (ShouldDelayStart()) { return; } - Start(); } + UpdateFrameDebuggerPause(); + if (!Paused) + { + UpdatePlayback(); + ValidateInputTime(); + } + } + + private bool ShouldDelayStart() + { + double currentTime = InputManager.CurrentInputTime; - // Hack: don't update while in the frame debugger - if (_pausedForFrameDebugger != FrameDebugger.enabled) + // Delay after a lagged starting frame, but only for one second at most. + if (double.IsNaN(_forceStartTime)) { - _pausedForFrameDebugger = FrameDebugger.enabled; - if (_pausedForFrameDebugger) - { - OverridePause(); - } - else - { - OverrideResume(); - } + _forceStartTime = currentTime + 1; } - if (Paused) + double frameLength = currentTime - InputManager.InputUpdateTime; + + return frameLength >= MAX_START_FRAME_LENGTH && currentTime < _forceStartTime; + } + + private void UpdateFrameDebuggerPause() + { + if (_pausedForFrameDebugger == FrameDebugger.enabled) + { return; + } - // Update times - UpdateTimes(); + _pausedForFrameDebugger = FrameDebugger.enabled; - // Check for unexpected backwards time jumps + if (_pausedForFrameDebugger) + { + OverridePause(); + } + else + { + OverrideResume(); + } + } + + // Detect unexpected clock regressions without flagging deliberate seeks. + private void ValidateInputTime() + { YargLogger.AssertFormat( InputTime >= _previousInputTime || _seeked, "Unexpected time seek backwards! Went from {0} to {1} (delta: {2})", - _previousInputTime, InputTime, InputTime - _previousInputTime + _previousInputTime, + InputTime, + InputTime - _previousInputTime ); - _previousInputTime = InputTime; + _previousInputTime = InputTime; _seeked = false; } - - /// - /// Prepares audio after any gameplay timeline discontinuity: playback start, seek, or unpause. - /// Mixer owns physical seeking, playback latency, and channel scheduling. + /// Rebuilds mixer playback at the given gameplay time and anchors the gameplay clock after + /// the seek completes. /// - private void PrepareAudioPlayback() + private void PrepareAudioAt(double inputTime) { _mixer.Pause(); _audioSynchronizer.Reset(SongSpeed); - - double audioTime = InputTime - SongOffset; + double audioTime = inputTime - SongOffset; _mixer.SetPosition(audioTime); + + // Mixer preparation can take several milliseconds. Keep gameplay fixed at the + // position being prepared. + AnchorTimeline(inputTime, InputManager.CurrentInputTime); } - public double GetRelativeInputTime(double timeFromInputSystem) + /// + /// Converts an absolute input-system timestamp to gameplay input time. + /// + public double GetInputTime(double inputSystemTime) { - return (timeFromInputSystem - InputTimeOffset) * SongSpeed; + return (inputSystemTime - InputTimeOffset) * SongSpeed; } - public double SongTimeToAudioPlaybackTime(double songTime) + /// + /// Converts gameplay song time to a position in the audio file. + /// + public double GetAudioPlaybackTime(double songTime) { return songTime - SongOffset; } - private void UpdateTimes() + // Advance gameplay clocks from input-system time, then synchronize audio to them. + private void UpdatePlayback() { // Re-anchoring can use a newer on-demand input timestamp than this frame's cached // timestamp. Hold the timeline at that anchor until the frame clock catches up. double inputSystemTime = Math.Max(InputManager.InputUpdateTime, _inputSystemTimeFloor); - InputTime = GetRelativeInputTime(inputSystemTime); + InputTime = GetInputTime(inputSystemTime); SongTime = InputTime + (AudioCalibration * SongSpeed); VisualTime = InputTime + (VideoCalibration * SongSpeed); double audioTargetTime = InputTime - SongOffset; - AudioPlaybackTime = _audioSynchronizer.Synchronize(audioTargetTime, SongSpeed); + _audioSynchronizer.Synchronize(audioTargetTime, SongSpeed); } - private void SetInputBase(double songTime) + /// + /// Anchors gameplay time to the input timestamp captured for the current frame. + /// + private void AnchorTimeline(double inputTime) { - SetInputBaseAt(songTime, InputManager.InputUpdateTime); + AnchorTimeline(inputTime, InputManager.InputUpdateTime); } - private void SetInputBaseAt(double songTime, double inputSystemTime) + /// + /// Anchors gameplay time to an absolute input-system timestamp and refreshes all timeline samples. + /// + private void AnchorTimeline(double inputTime, double inputSystemTime) { double previousOffset = InputTimeOffset; double previousInputTime = InputTime; double previousSongTime = SongTime; double previousVisualTime = VisualTime; - InputTimeOffset = inputSystemTime - (songTime / SongSpeed); + InputTimeOffset = inputSystemTime - (inputTime / SongSpeed); _inputSystemTimeFloor = Math.Max(_inputSystemTimeFloor, inputSystemTime); - InputTime = GetRelativeInputTime(inputSystemTime); + InputTime = GetInputTime(inputSystemTime); SongTime = InputTime + (AudioCalibration * SongSpeed); VisualTime = InputTime + (VideoCalibration * SongSpeed); - AudioPlaybackTime = _audioSynchronizer.SampleHeardTime(); YargLogger.LogFormatDebug( "Set input time base.\n" + @@ -415,12 +428,12 @@ private void SetInputBaseAt(double songTime, double inputSystemTime) ); } - private void SetInputBaseChecked(double inputBase) + private void AnchorTimelineChecked(double inputTime) { double previousVisualTime = VisualTime; double previousInputTime = InputTime; - SetInputBase(inputBase); + AnchorTimeline(inputTime); // Speeds above 200% or so can cause inaccuracies greater than 1 ms double threshold = Math.Max(0.001 * SongSpeed, 0.0005); @@ -432,21 +445,21 @@ private void SetInputBaseChecked(double inputBase) previousInputTime, InputTime, threshold); } - private void InitializeSongTime(double time, double delayTime) + private void InitializeSongTime(double time, double songStartDelay) { // Account for song speed - delayTime *= SongSpeed; + songStartDelay *= SongSpeed; // Seek time // Doesn't account for audio calibration for better audio syncing // since seeking is slightly delayed - double seekTime = time - delayTime; + double seekTime = time - songStartDelay; // Set input offsets - SetInputBase(seekTime); + AnchorTimeline(seekTime); YargLogger.LogFormatDebug("Set song time to {0:0.000000} (delay: {1:0.000000}).\n" + - "Seek time: {2:0.000000}, resulting song time: {3:0.000000}", time, delayTime, seekTime, SongTime); + "Seek time: {2:0.000000}, resulting song time: {3:0.000000}", time, songStartDelay, seekTime, SongTime); } public void SetSongTime(double time, double delayTime = SONG_START_DELAY) @@ -457,11 +470,7 @@ public void SetSongTime(double time, double delayTime = SONG_START_DELAY) InitializeSongTime(time, delayTime); double seekInputTime = InputTime; - PrepareAudioPlayback(); - - // Seeking can take several milliseconds. Anchor after it completes so that command - // execution does not advance the gameplay timeline alone. - SetInputBaseAt(seekInputTime, InputManager.CurrentInputTime); + PrepareAudioAt(seekInputTime); if (!Paused) { _mixer.Play(); @@ -477,7 +486,6 @@ public void SetSongSpeed(float speed) return; } - _requestedSongSpeed = speed; ApplySpeedChange(speed); YargLogger.LogFormatDebug("Set song speed to {0:0.00}.\n" @@ -488,12 +496,13 @@ public void SetSongSpeed(float speed) private void ApplySpeedChange(float speed) { double inputTime = InputTime; + _requestedSongSpeed = speed; SongSpeed = speed; if (!Started || Paused) { _audioSynchronizer.Reset(SongSpeed); - SetInputBaseChecked(inputTime); + AnchorTimelineChecked(inputTime); return; } @@ -501,16 +510,18 @@ private void ApplySpeedChange(float speed) // Rebuild at the current position so gameplay and audible playback start the new // speed together. The control position predicts through that delay, so sync error // alone cannot detect the audible offset left by changing speed in place. - SetInputBaseAt(inputTime, InputManager.CurrentInputTime); - PrepareAudioPlayback(); - - // Mixer preparation takes time. Keep gameplay fixed at the position being prepared. - SetInputBaseAt(inputTime, InputManager.CurrentInputTime); + PrepareAudioAt(inputTime); _mixer.Play(); } + /// + /// Changes requested playback speed by the given amount, where 1f is 100%. + /// public void AdjustSongSpeed(float deltaSpeed) => SetSongSpeed(_requestedSongSpeed + deltaSpeed); + /// + /// Reloads audio and video calibration settings while preserving current gameplay time. + /// public void UpdateCalibration() { int videoCalibrationMs = SettingsManager.Settings.VideoCalibration.Value; @@ -522,8 +533,10 @@ public void UpdateCalibration() AudioCalibration = audioCalibrationMs / 1000.0; VideoCalibration = videoCalibrationMs / 1000.0; + + // Tell the mixer which modeled output position should represent heard audio. _mixer.SetOutputLatency(AudioCalibration); - SetInputBase(InputTime); + AnchorTimeline(InputTime); } /// @@ -543,7 +556,9 @@ public void Pause() } if (Paused) + { return; + } _audioSynchronizer.Suspend(SongSpeed); Paused = true; @@ -567,16 +582,16 @@ public void Resume() } if (!Paused) + { return; + } + // Settings can change from the pause menu. UpdateCalibration(); double resumeInputTime = InputTime; - PrepareAudioPlayback(); + PrepareAudioAt(resumeInputTime); Paused = false; - // Seeking can take several milliseconds. Anchor after it completes so that work - // does not advance the resumed timeline. - SetInputBaseAt(resumeInputTime, InputManager.CurrentInputTime); _mixer.Play(); YargLogger.LogFormatDebug( @@ -616,7 +631,9 @@ public bool OverrideResume() } if (_resumeAfterOverride) + { Resume(); + } return !Paused; } @@ -678,7 +695,8 @@ private static float ClampSongSpeed(float speed) } /// - /// Keeps mixer playback aligned with gameplay time + /// Keeps mixer playback aligned with gameplay time by applying bounded speed corrections. + /// Corrections stop near song boundaries and while error remains within a small deadband. /// internal sealed class AudioSynchronizer { @@ -688,45 +706,45 @@ internal sealed class AudioSynchronizer private readonly StemMixer _mixer; - public float Adjustment { get; private set; } - public float EffectiveAdjustment { get; private set; } - public float StartDelta { get; private set; } - public float WorstDelta { get; private set; } - public double Error { get; private set; } + private float Adjustment { get; set; } + public float EffectiveAdjustment { get; private set; } + public float StartDelta { get; private set; } + public float WorstDelta { get; private set; } + public double Error { get; private set; } public AudioSynchronizer(StemMixer mixer) { _mixer = mixer; } - public double Synchronize(double targetTime, float songSpeed) + /// + /// Samples mixer position and corrects its control-time error. + /// + /// Required audio-file position on the gameplay clock. + /// Requested playback speed before synchronization correction. + public void Synchronize(double targetTime, float songSpeed) { - var position = _mixer.GetPlaybackPosition(); - Error = targetTime - position.Control; + double controlPosition = _mixer.GetControlPosition(); + Error = targetTime - controlPosition; + + float adjustment = 0f; bool isWithinSongBounds = targetTime >= 0 && targetTime < _mixer.Length && - position.Heard < _mixer.Length; + controlPosition < _mixer.Length; - float adjustment = 0f; - if (isWithinSongBounds && Math.Abs(Error) >= SYNC_DEADBAND_SECONDS) + bool needsAdjustment = Math.Abs(Error) >= SYNC_DEADBAND_SECONDS; + + if (isWithinSongBounds && needsAdjustment) { - adjustment = Math.Clamp( - (float) Error / CORRECTION_TIME_SECONDS, - -SYNC_CLAMP, - SYNC_CLAMP); + adjustment = (float) Error / CORRECTION_TIME_SECONDS; + adjustment = Math.Clamp(adjustment, -SYNC_CLAMP, SYNC_CLAMP); } EffectiveAdjustment = adjustment; RecordCorrection(adjustment); ApplyAdjustment(songSpeed, adjustment); - return position.Heard; - } - - public double SampleHeardTime() - { - return _mixer.GetPlaybackPosition().Heard; } /// From 2d38146f6c635ff301eb3ea77be64eec814780f2 Mon Sep 17 00:00:00 2001 From: polson <302882+polson@users.noreply.github.com> Date: Tue, 14 Jul 2026 16:53:15 -0500 Subject: [PATCH 30/31] More cleanup --- .../Script/Audio/Bass/BassOneShotChannel.cs | 327 ++++-------------- Assets/Script/Audio/Bass/BassStemMixer.cs | 28 +- Assets/Script/Gameplay/MetronomeScheduler.cs | 2 +- 3 files changed, 85 insertions(+), 272 deletions(-) diff --git a/Assets/Script/Audio/Bass/BassOneShotChannel.cs b/Assets/Script/Audio/Bass/BassOneShotChannel.cs index d0bb1d8e33..fe0b7db0d6 100644 --- a/Assets/Script/Audio/Bass/BassOneShotChannel.cs +++ b/Assets/Script/Audio/Bass/BassOneShotChannel.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Concurrent; using System.Collections.Generic; using System.Threading; using ManagedBass; @@ -9,123 +8,86 @@ namespace YARG.Audio.BASS { + /// + /// Mixes scheduled instances of one sample into a float playback stream. + /// Control methods must be called from the Unity thread. + /// internal sealed class BassOneShotChannel : OneShotChannel { private const int MAX_ACTIVE_SAMPLES = 64; - private const int MAX_SCHEDULES = 64; + private const int MAX_SCHEDULES = 64; private const int DECODE_BUFFER_SIZE = 4096; - private readonly object _stateLock = new(); - private readonly int _playbackStreamHandle; - private readonly int _sampleRate; private readonly int _channelCount; private readonly int _sampleFrameCount; - private readonly Func _getPlaybackTime; - private readonly float[] _sample; - private readonly int _dspHandle; + private readonly PlaybackTimeResolver _getPlaybackTime; + private readonly float[] _sample; + private readonly int _dspHandle; - private readonly ConcurrentQueue _immediatePlays = new(); private readonly int[] _activeSampleFrames = new int[MAX_ACTIVE_SAMPLES]; private ScheduleState _schedule = new(MAX_SCHEDULES); private ScheduleState _callbackSchedule; - private int _nextScheduledEvent; + private int _nextScheduledEvent; - private int _transportGeneration; - private int _callbackTransportGeneration = -1; + private int _seekGeneration; + private int _callbackSeekGeneration = -1; private long _previousEndPosition; - private int _activeSampleCount; + private int _activeSampleCount; private float _volume = 1; - private bool _disposed; - + private bool _disposed; internal event Action Disposed; - - public BassOneShotChannel(int playbackStreamHandle, int sampleStream) - : this( - playbackStreamHandle, - sampleStream, - position => Bass.ChannelBytes2Seconds( - playbackStreamHandle, - position)) - { - } + internal delegate double PlaybackTimeResolver(long streamPosition); + private bool IsAvailable => !_disposed && _dspHandle != 0; public BassOneShotChannel( int playbackStreamHandle, int sampleStream, - Func getPlaybackTime) + PlaybackTimeResolver getPlaybackTime) { _playbackStreamHandle = playbackStreamHandle; - _getPlaybackTime = getPlaybackTime ?? - throw new ArgumentNullException(nameof(getPlaybackTime)); + _getPlaybackTime = getPlaybackTime ?? throw new ArgumentNullException(nameof(getPlaybackTime)); var info = Bass.ChannelGetInfo(playbackStreamHandle); - if ((info.Flags & BassFlags.Float) == 0) + bool usesFloatSamples = (info.Flags & BassFlags.Float) != 0; + if (!usesFloatSamples) { Bass.StreamFree(sampleStream); - - throw new ArgumentException( - "Playback stream must use float sample data.", + throw new ArgumentException("Playback stream must use float sample data.", nameof(playbackStreamHandle)); } - _sampleRate = info.Frequency; _channelCount = info.Channels; - _sample = DecodeSample(sampleStream) ?? Array.Empty(); + _sample = DecodeSample(sampleStream, info.Frequency, info.Channels) ?? Array.Empty(); _sampleFrameCount = _sample.Length / _channelCount; - if (_sampleFrameCount == 0) { return; } - DSPProcedure callback = MixSamples; - _previousEndPosition = Math.Max( - 0, - Bass.ChannelGetPosition( - playbackStreamHandle, - PositionFlags.Decode)); - - _dspHandle = Bass.ChannelSetDSP( - playbackStreamHandle, - callback); - + DSPProcedure dspCallback = ProcessAudio; + _previousEndPosition = Math.Max(0, Bass.ChannelGetPosition(playbackStreamHandle, PositionFlags.Decode)); + _dspHandle = Bass.ChannelSetDSP(playbackStreamHandle, dspCallback); if (_dspHandle == 0) { LogBassError("Failed to attach one-shot DSP: {0}!"); } } - private bool IsAvailable => !_disposed && _dspHandle != 0; - - public override void Play() + public override void AddScheduledPlay(double songTime) { - lock (_stateLock) + if (!IsAvailable) { - if (IsAvailable) - { - _immediatePlays.Enqueue(0); - } + return; } - } - public override void Schedule(double songTime) - { - lock (_stateLock) - { - if (!IsAvailable) - { - return; - } - - var schedule = Volatile.Read(ref _schedule).Add(songTime); - Volatile.Write(ref _schedule, schedule); - } + var schedule = Volatile.Read(ref _schedule).Add(songTime); + Volatile.Write(ref _schedule, schedule); } public override void SetVolume(double volume) @@ -133,96 +95,56 @@ public override void SetVolume(double volume) Volatile.Write(ref _volume, (float) volume); } - public override void ClearSchedule() - { - lock (_stateLock) - { - if (!_disposed) - { - Volatile.Write( - ref _schedule, - new ScheduleState(MAX_SCHEDULES)); - } - } - } - - public void ResetTransport() + internal void ResetAfterSeek() { - Interlocked.Increment(ref _transportGeneration); + Interlocked.Increment(ref _seekGeneration); } - private unsafe void MixSamples( - int _, - int channel, - IntPtr buffer, - int length, - IntPtr __) + private unsafe void ProcessAudio(int _, int channel, IntPtr buffer, int length, IntPtr __) { - int frameCount = - length / (sizeof(float) * _channelCount); - - if (frameCount <= 0 || - !TryGetPlaybackWindow( - channel, - out double startTime, - out double endTime)) + int frameCount = length / (sizeof(float) * _channelCount); + if (frameCount <= 0 || !GetPlaybackWindow(channel, out double startTime, out double endTime)) { return; } - float* output = (float*) buffer; float volume = Volatile.Read(ref _volume); - MixActiveSamples(output, frameCount, volume); - MixImmediateSamples(output, frameCount, volume); - MixScheduledSamples( - output, - frameCount, - volume, - GetCallbackSchedule(startTime), - startTime, - endTime); + MixScheduledSamples(output, frameCount, volume, GetCallbackSchedule(startTime), startTime, endTime); } - private bool TryGetPlaybackWindow( + private bool GetPlaybackWindow( int channel, out double startTime, out double endTime) { startTime = 0; endTime = 0; - long endPosition = Bass.ChannelGetPosition(channel, PositionFlags.Decode); - if (endPosition < 0) { return false; } - - int generation = Volatile.Read(ref _transportGeneration); - if (_callbackTransportGeneration != generation) + int generation = Volatile.Read(ref _seekGeneration); + if (_callbackSeekGeneration != generation) { ResetCallbackState(generation); } - long startPosition = _previousEndPosition; _previousEndPosition = endPosition; - if (endPosition <= startPosition) { return false; } - startTime = _getPlaybackTime(startPosition); endTime = _getPlaybackTime(endPosition); - return endTime > startTime; } private void ResetCallbackState(int generation) { - _callbackTransportGeneration = generation; + _callbackSeekGeneration = generation; _previousEndPosition = 0; _callbackSchedule = null; _nextScheduledEvent = 0; @@ -231,8 +153,7 @@ private void ResetCallbackState(int generation) private ScheduleState GetCallbackSchedule(double playbackStart) { - ScheduleState schedule = Volatile.Read(ref _schedule); - + var schedule = Volatile.Read(ref _schedule); if (!ReferenceEquals(schedule, _callbackSchedule)) { _callbackSchedule = schedule; @@ -255,17 +176,13 @@ private unsafe void MixScheduledSamples( { int eventCount = Volatile.Read(ref schedule.Count); double duration = playbackEnd - playbackStart; - while (_nextScheduledEvent < eventCount) { - double eventTime = - schedule.Events[_nextScheduledEvent]; - + double eventTime = schedule.Events[_nextScheduledEvent]; if (eventTime >= playbackEnd) { return; } - _nextScheduledEvent++; if (eventTime < playbackStart) @@ -273,40 +190,19 @@ private unsafe void MixScheduledSamples( continue; } - double progress = - (eventTime - playbackStart) / duration; - - int startFrame = Math.Clamp( - (int) Math.Round(progress * frameCount), - 0, - frameCount - 1); - - StartSample( - output, - frameCount, - startFrame, - volume); + double progress = (eventTime - playbackStart) / duration; + int startFrame = Math.Clamp((int) Math.Round(progress * frameCount), 0, frameCount - 1); + StartSample(output, frameCount, startFrame, volume); } } - private unsafe void MixActiveSamples( - float* output, - int frameCount, - float volume) + private unsafe void MixActiveSamples(float* output, int frameCount, float volume) { int writeIndex = 0; - for (int i = 0; i < _activeSampleCount; i++) { int sampleFrame = _activeSampleFrames[i]; - - MixSample( - output, - frameCount, - startFrame: 0, - volume, - ref sampleFrame); - + MixSample(output, frameCount, startFrame: 0, volume, ref sampleFrame); if (sampleFrame < _sampleFrameCount) { _activeSampleFrames[writeIndex++] = sampleFrame; @@ -316,61 +212,23 @@ private unsafe void MixActiveSamples( _activeSampleCount = writeIndex; } - private unsafe void MixImmediateSamples( - float* output, - int frameCount, - float volume) - { - for (int i = 0; - i < MAX_ACTIVE_SAMPLES && - _immediatePlays.TryDequeue(out _); - i++) - { - StartSample( - output, - frameCount, - startFrame: 0, - volume); - } - } - - private unsafe void StartSample( - float* output, - int frameCount, - int startFrame, - float volume) + private unsafe void StartSample(float* output, int frameCount, int startFrame, float volume) { int sampleFrame = 0; - - MixSample( - output, - frameCount, - startFrame, - volume, - ref sampleFrame); - - if (sampleFrame < _sampleFrameCount && - _activeSampleCount < MAX_ACTIVE_SAMPLES) + MixSample(output, frameCount, startFrame, volume, ref sampleFrame); + if (sampleFrame < _sampleFrameCount && _activeSampleCount < MAX_ACTIVE_SAMPLES) { _activeSampleFrames[_activeSampleCount++] = sampleFrame; } } - private unsafe void MixSample( - float* output, - int outputFrames, - int startFrame, - float volume, + private unsafe void MixSample(float* output, int outputFrames, int startFrame, float volume, ref int sampleFrame) { - int framesToMix = Math.Min( - outputFrames - startFrame, - _sampleFrameCount - sampleFrame); - + int framesToMix = Math.Min(outputFrames - startFrame, _sampleFrameCount - sampleFrame); int source = sampleFrame * _channelCount; int destination = startFrame * _channelCount; int valuesRemaining = framesToMix * _channelCount; - while (valuesRemaining-- > 0) { output[destination++] += _sample[source++] * volume; @@ -379,14 +237,10 @@ private unsafe void MixSample( sampleFrame += framesToMix; } - private static int FindFirstEvent( - double[] events, - int eventCount, - double playbackTime) + private static int FindFirstEvent(double[] events, int eventCount, double playbackTime) { int low = 0; int high = eventCount; - while (low < high) { int middle = low + (high - low) / 2; @@ -404,45 +258,32 @@ private static int FindFirstEvent( return low; } - private float[] DecodeSample(int streamHandle) + private static float[] DecodeSample(int streamHandle, int sampleRate, int channelCount) { - int converter = BassMix.CreateMixerStream( - _sampleRate, - _channelCount, - BassFlags.Float | - BassFlags.Decode | - BassFlags.MixerEnd); + int converter = BassMix.CreateMixerStream(sampleRate, channelCount, + BassFlags.Float | BassFlags.Decode | BassFlags.MixerEnd); if (converter == 0) { - LogBassError( - "Failed to create one-shot sample converter: {0}!"); - + LogBassError("Failed to create one-shot sample converter: {0}!"); Bass.StreamFree(streamHandle); return null; } try { - if (!BassMix.MixerAddChannel( - converter, - streamHandle, - BassFlags.MixerChanNoRampin)) + if (!BassMix.MixerAddChannel(converter, streamHandle, BassFlags.MixerChanNoRampin)) { LogBassError( "Failed to add one-shot sample to converter: {0}!"); - return null; } var samples = new List(); var buffer = new float[DECODE_BUFFER_SIZE]; - int bytesRead; - while ((bytesRead = Bass.ChannelGetData( - converter, - buffer, - buffer.Length * sizeof(float))) > 0) + + while ((bytesRead = Bass.ChannelGetData(converter, buffer, buffer.Length * sizeof(float))) > 0) { int sampleCount = bytesRead / sizeof(float); @@ -454,13 +295,10 @@ private float[] DecodeSample(int streamHandle) if (bytesRead < 0 && Bass.LastError != Errors.Ended) { - LogBassError( - "Failed to decode one-shot sample: {0}!"); + LogBassError("Failed to decode one-shot sample: {0}!"); } - return samples.Count == 0 - ? null - : samples.ToArray(); + return samples.Count == 0 ? null : samples.ToArray(); } finally { @@ -471,31 +309,22 @@ private float[] DecodeSample(int streamHandle) public override void Dispose() { - Action disposed; - - lock (_stateLock) + if (_disposed) { - if (_disposed) - { - return; - } - - RemoveDsp(); - - _disposed = true; - disposed = Disposed; - Disposed = null; + return; } + RemoveDsp(); + _disposed = true; + var disposed = Disposed; + Disposed = null; disposed?.Invoke(this); } private void RemoveDsp() { if (_dspHandle == 0 || - Bass.ChannelRemoveDSP( - _playbackStreamHandle, - _dspHandle) || + Bass.ChannelRemoveDSP(_playbackStreamHandle, _dspHandle) || Bass.LastError == Errors.Handle) { return; @@ -506,11 +335,8 @@ private void RemoveDsp() internal void PlaybackStreamDisposed() { - lock (_stateLock) - { - _disposed = true; - Disposed = null; - } + _disposed = true; + Disposed = null; } private static void LogBassError(string format) @@ -545,18 +371,11 @@ public ScheduleState Add(double songTime) index = ~index; } - var replacement = new ScheduleState( - Math.Max(Events.Length * 2, count + 1)); + var replacement = new ScheduleState(Math.Max(Events.Length * 2, count + 1)); Array.Copy(Events, 0, replacement.Events, 0, index); replacement.Events[index] = songTime; - Array.Copy( - Events, - index, - replacement.Events, - index + 1, - count - index); - + Array.Copy(Events, index, replacement.Events, index + 1, count - index); replacement.Count = count + 1; return replacement; } @@ -568,4 +387,4 @@ private bool CanAppend(double songTime, int count) } } } -} \ No newline at end of file +} diff --git a/Assets/Script/Audio/Bass/BassStemMixer.cs b/Assets/Script/Audio/Bass/BassStemMixer.cs index 2f0b1dca37..938311d520 100644 --- a/Assets/Script/Audio/Bass/BassStemMixer.cs +++ b/Assets/Script/Audio/Bass/BassStemMixer.cs @@ -481,7 +481,7 @@ private void RemoveChannelsFromMixer() } foreach (var channel in _oneShotChannels) { - channel.ResetTransport(); + channel.ResetAfterSeek(); } } @@ -730,25 +730,19 @@ private void UpdateThreading() public override OneShotChannel CreateOneShotChannel(int sampleStream) { - lock (this) - { - var channel = new BassOneShotChannel( - _tempoStreamHandle, - sampleStream, - _songPositionTracker.GetSongPosition - ); - channel.Disposed += OnOneShotChannelDisposed; - _oneShotChannels.Add(channel); - return channel; - } + var channel = new BassOneShotChannel( + _tempoStreamHandle, + sampleStream, + _songPositionTracker.GetSongPosition + ); + channel.Disposed += OnOneShotDisposed; + _oneShotChannels.Add(channel); + return channel; } - private void OnOneShotChannelDisposed(BassOneShotChannel channel) + private void OnOneShotDisposed(BassOneShotChannel channel) { - lock (this) - { - _oneShotChannels.Remove(channel); - } + _oneShotChannels.Remove(channel); } /// diff --git a/Assets/Script/Gameplay/MetronomeScheduler.cs b/Assets/Script/Gameplay/MetronomeScheduler.cs index c5fc0786be..3a163bb07c 100644 --- a/Assets/Script/Gameplay/MetronomeScheduler.cs +++ b/Assets/Script/Gameplay/MetronomeScheduler.cs @@ -68,7 +68,7 @@ private void CreateChannels(MetronomeSample sample) foreach (var hit in _hits) { var channel = hit.Pitch == MetronomePitch.Hi ? _hiChannel : _loChannel; - channel.Schedule(hit.Time); + channel.AddScheduledPlay(hit.Time); } SetVolume(sample); } From eee5dd3ebd194843cf5b82f49525d97bee7ace0f Mon Sep 17 00:00:00 2001 From: polson <302882+polson@users.noreply.github.com> Date: Tue, 14 Jul 2026 17:26:55 -0500 Subject: [PATCH 31/31] More cleanup --- .../Script/Audio/Bass/BassOneShotChannel.cs | 214 ++++++++++-------- Assets/Script/Audio/Bass/BassStemMixer.cs | 4 +- Assets/Script/Gameplay/GameManager.Loading.cs | 4 +- Assets/Script/Gameplay/MetronomeScheduler.cs | 70 +++--- Assets/Script/Helpers/RingBuffer.cs | 70 ------ Assets/Script/Helpers/RingBuffer.cs.meta | 3 - 6 files changed, 165 insertions(+), 200 deletions(-) delete mode 100644 Assets/Script/Helpers/RingBuffer.cs delete mode 100644 Assets/Script/Helpers/RingBuffer.cs.meta diff --git a/Assets/Script/Audio/Bass/BassOneShotChannel.cs b/Assets/Script/Audio/Bass/BassOneShotChannel.cs index fe0b7db0d6..f99147c480 100644 --- a/Assets/Script/Audio/Bass/BassOneShotChannel.cs +++ b/Assets/Script/Audio/Bass/BassOneShotChannel.cs @@ -9,13 +9,22 @@ namespace YARG.Audio.BASS { /// - /// Mixes scheduled instances of one sample into a float playback stream. - /// Control methods must be called from the Unity thread. + /// Mixes scheduled instances of a decoded sample directly into a BASS float playback stream using a DSP. + /// + /// With this class we can play sfx with sample-accurate timing. This is useful for metronome, claps, + /// or anything that should play to the beat of the song. /// + /// + /// The complete schedule is copied during construction, before the DSP callback is attached. + /// + /// Full disclosure: This class was written with assistance from LLMs and has a lot of low level DSP manipulation, + /// which is an area that LLMs excel at. The trade-off is that some of this class is a bit complex. + /// However the stakes are low, and this provides a cost-efficient way to get sample-accurate + /// playback of oneshot sfx. + /// internal sealed class BassOneShotChannel : OneShotChannel { private const int MAX_ACTIVE_SAMPLES = 64; - private const int MAX_SCHEDULES = 64; private const int DECODE_BUFFER_SIZE = 4096; private readonly int _playbackStreamHandle; @@ -24,15 +33,14 @@ internal sealed class BassOneShotChannel : OneShotChannel private readonly PlaybackTimeResolver _getPlaybackTime; private readonly float[] _sample; + private readonly double[] _scheduledPlays; private readonly int _dspHandle; private readonly int[] _activeSampleFrames = new int[MAX_ACTIVE_SAMPLES]; - private ScheduleState _schedule = - new(MAX_SCHEDULES); - - private ScheduleState _callbackSchedule; - private int _nextScheduledEvent; + // A negative index means the schedule must be positioned from the next callback's + // playback time. This is also the initial state because channels may be created mid-song. + private int _nextScheduledEvent = -1; private int _seekGeneration; private int _callbackSeekGeneration = -1; @@ -43,15 +51,29 @@ internal sealed class BassOneShotChannel : OneShotChannel private bool _disposed; internal event Action Disposed; internal delegate double PlaybackTimeResolver(long streamPosition); - private bool IsAvailable => !_disposed && _dspHandle != 0; + /// + /// Creates a channel that decodes and mixes one sample at the supplied playback times. + /// + /// BASS float stream that receives the mixed sample. + /// + /// Owned BASS stream containing the sample. The channel frees it after decoding. + /// + /// + /// Playback times in seconds. The values are copied and sorted before playback begins. + /// + /// + /// Resolves a byte position in the playback stream to its song time. + /// public BassOneShotChannel( int playbackStreamHandle, int sampleStream, + IReadOnlyList scheduledPlays, PlaybackTimeResolver getPlaybackTime) { _playbackStreamHandle = playbackStreamHandle; _getPlaybackTime = getPlaybackTime ?? throw new ArgumentNullException(nameof(getPlaybackTime)); + _scheduledPlays = CopyAndSort(scheduledPlays); var info = Bass.ChannelGetInfo(playbackStreamHandle); bool usesFloatSamples = (info.Flags & BassFlags.Float) != 0; @@ -79,17 +101,10 @@ public BassOneShotChannel( } } - public override void AddScheduledPlay(double songTime) - { - if (!IsAvailable) - { - return; - } - - var schedule = Volatile.Read(ref _schedule).Add(songTime); - Volatile.Write(ref _schedule, schedule); - } - + /// + /// Sets volume applied to future mixing, including samples already in progress. + /// + /// Linear volume multiplier. public override void SetVolume(double volume) { Volatile.Write(ref _volume, (float) volume); @@ -100,6 +115,13 @@ internal void ResetAfterSeek() Interlocked.Increment(ref _seekGeneration); } + /// + /// Mixes active and newly scheduled sample instances into one DSP output buffer. + /// + /// + /// BASS invokes this method on its audio thread. Callback-owned playback state must only be + /// changed here, except for seek and volume signals published through atomic operations. + /// private unsafe void ProcessAudio(int _, int channel, IntPtr buffer, int length, IntPtr __) { int frameCount = length / (sizeof(float) * _channelCount); @@ -110,9 +132,18 @@ private unsafe void ProcessAudio(int _, int channel, IntPtr buffer, int length, float* output = (float*) buffer; float volume = Volatile.Read(ref _volume); MixActiveSamples(output, frameCount, volume); - MixScheduledSamples(output, frameCount, volume, GetCallbackSchedule(startTime), startTime, endTime); + PositionSchedule(startTime); + MixScheduledSamples(output, frameCount, volume, startTime, endTime); } + /// + /// Resolves the song-time interval represented by the current callback and applies pending + /// seek resets before exposing that interval to the mixer. + /// + /// + /// Stream positions delimit adjacent callback buffers. A non-advancing position is ignored + /// because it cannot describe a valid forward playback interval. + /// private bool GetPlaybackWindow( int channel, out double startTime, @@ -142,43 +173,70 @@ private bool GetPlaybackWindow( return endTime > startTime; } + /// + /// Clears callback-owned progress after a seek so schedule and active samples can be rebuilt + /// from the new playback position. + /// private void ResetCallbackState(int generation) { _callbackSeekGeneration = generation; _previousEndPosition = 0; - _callbackSchedule = null; - _nextScheduledEvent = 0; + _nextScheduledEvent = -1; _activeSampleCount = 0; } - private ScheduleState GetCallbackSchedule(double playbackStart) + /// + /// Positions the schedule at its first event at or after the current playback time. + /// + private void PositionSchedule(double playbackStart) { - var schedule = Volatile.Read(ref _schedule); - if (!ReferenceEquals(schedule, _callbackSchedule)) + if (_nextScheduledEvent >= 0) { - _callbackSchedule = schedule; - _nextScheduledEvent = FindFirstEvent( - schedule.Events, - Volatile.Read(ref schedule.Count), - playbackStart); + return; } - return schedule; + _nextScheduledEvent = FindFirstScheduledPlay(playbackStart); } + /// + /// Finds the first scheduled play at or after the supplied playback time. Unlike + /// , this preserves duplicate events. + /// + private int FindFirstScheduledPlay(double playbackTime) + { + int start = 0; + int end = _scheduledPlays.Length; + while (start < end) + { + int middle = start + (end - start) / 2; + if (_scheduledPlays[middle] < playbackTime) + { + start = middle + 1; + } + else + { + end = middle; + } + } + + return start; + } + + /// + /// Starts each event in the current playback interval at its corresponding output frame. + /// Samples extending beyond this buffer are retained for subsequent callbacks. + /// private unsafe void MixScheduledSamples( float* output, int frameCount, float volume, - ScheduleState schedule, double playbackStart, double playbackEnd) { - int eventCount = Volatile.Read(ref schedule.Count); double duration = playbackEnd - playbackStart; - while (_nextScheduledEvent < eventCount) + while (_nextScheduledEvent < _scheduledPlays.Length) { - double eventTime = schedule.Events[_nextScheduledEvent]; + double eventTime = _scheduledPlays[_nextScheduledEvent]; if (eventTime >= playbackEnd) { return; @@ -196,6 +254,10 @@ private unsafe void MixScheduledSamples( } } + /// + /// Continues sample instances started by earlier callbacks and compacts unfinished instances + /// in place. + /// private unsafe void MixActiveSamples(float* output, int frameCount, float volume) { int writeIndex = 0; @@ -237,27 +299,29 @@ private unsafe void MixSample(float* output, int outputFrames, int startFrame, f sampleFrame += framesToMix; } - private static int FindFirstEvent(double[] events, int eventCount, double playbackTime) + private static double[] CopyAndSort(IReadOnlyList scheduledPlays) { - int low = 0; - int high = eventCount; - while (low < high) + if (scheduledPlays == null) { - int middle = low + (high - low) / 2; - - if (events[middle] < playbackTime) - { - low = middle + 1; - } - else - { - high = middle; - } + throw new ArgumentNullException(nameof(scheduledPlays)); } - return low; + var copy = new double[scheduledPlays.Count]; + for (int i = 0; i < copy.Length; i++) + { + copy[i] = scheduledPlays[i]; + } + Array.Sort(copy); + return copy; } + /// + /// Decodes and converts an owned sample stream to float data matching the playback stream. + /// + /// + /// Both the source stream and temporary mixer are released before this method returns, + /// including all failure paths. + /// private static float[] DecodeSample(int streamHandle, int sampleRate, int channelCount) { int converter = BassMix.CreateMixerStream(sampleRate, channelCount, @@ -282,11 +346,9 @@ private static float[] DecodeSample(int streamHandle, int sampleRate, int channe var samples = new List(); var buffer = new float[DECODE_BUFFER_SIZE]; int bytesRead; - while ((bytesRead = Bass.ChannelGetData(converter, buffer, buffer.Length * sizeof(float))) > 0) { int sampleCount = bytesRead / sizeof(float); - for (int i = 0; i < sampleCount; i++) { samples.Add(buffer[i]); @@ -297,7 +359,6 @@ private static float[] DecodeSample(int streamHandle, int sampleRate, int channe { LogBassError("Failed to decode one-shot sample: {0}!"); } - return samples.Count == 0 ? null : samples.ToArray(); } finally @@ -307,6 +368,9 @@ private static float[] DecodeSample(int streamHandle, int sampleRate, int channe } } + /// + /// Detaches DSP callback and releases this channel. + /// public override void Dispose() { if (_disposed) @@ -344,47 +408,5 @@ private static void LogBassError(string format) YargLogger.LogFormatError(format, Bass.LastError); } - private sealed class ScheduleState - { - public readonly double[] Events; - public int Count; - - public ScheduleState(int capacity) - { - Events = new double[capacity]; - } - - public ScheduleState Add(double songTime) - { - int count = Volatile.Read(ref Count); - - if (CanAppend(songTime, count)) - { - Events[count] = songTime; - Volatile.Write(ref Count, count + 1); - return this; - } - - int index = Array.BinarySearch(Events, 0, count, songTime); - if (index < 0) - { - index = ~index; - } - - var replacement = new ScheduleState(Math.Max(Events.Length * 2, count + 1)); - - Array.Copy(Events, 0, replacement.Events, 0, index); - replacement.Events[index] = songTime; - Array.Copy(Events, index, replacement.Events, index + 1, count - index); - replacement.Count = count + 1; - return replacement; - } - - private bool CanAppend(double songTime, int count) - { - return count < Events.Length && - (count == 0 || songTime >= Events[count - 1]); - } - } } } diff --git a/Assets/Script/Audio/Bass/BassStemMixer.cs b/Assets/Script/Audio/Bass/BassStemMixer.cs index 938311d520..e7f16045b8 100644 --- a/Assets/Script/Audio/Bass/BassStemMixer.cs +++ b/Assets/Script/Audio/Bass/BassStemMixer.cs @@ -728,11 +728,13 @@ private void UpdateThreading() } } - public override OneShotChannel CreateOneShotChannel(int sampleStream) + public override OneShotChannel CreateOneShotChannel(int sampleStream, + IReadOnlyList scheduledPlays) { var channel = new BassOneShotChannel( _tempoStreamHandle, sampleStream, + scheduledPlays, _songPositionTracker.GetSongPosition ); channel.Disposed += OnOneShotDisposed; diff --git a/Assets/Script/Gameplay/GameManager.Loading.cs b/Assets/Script/Gameplay/GameManager.Loading.cs index 792e656e48..e1ca76b8ad 100644 --- a/Assets/Script/Gameplay/GameManager.Loading.cs +++ b/Assets/Script/Gameplay/GameManager.Loading.cs @@ -198,8 +198,8 @@ private async void Start() GlobalVariables.State.SongSpeed, Song.SongOffsetSeconds); - _metronomeScheduler = new MetronomeScheduler( - _mixer, _songRunner, Chart.SyncTrack, SongLength); + _metronomeScheduler = new MetronomeScheduler(_mixer); + _metronomeScheduler.Schedule(_songRunner, Chart.SyncTrack, SongLength); // Spawn players CreatePlayers(); diff --git a/Assets/Script/Gameplay/MetronomeScheduler.cs b/Assets/Script/Gameplay/MetronomeScheduler.cs index 3a163bb07c..6a5ef874a0 100644 --- a/Assets/Script/Gameplay/MetronomeScheduler.cs +++ b/Assets/Script/Gameplay/MetronomeScheduler.cs @@ -13,37 +13,48 @@ namespace YARG.Gameplay /// public sealed class MetronomeScheduler : IDisposable { - private readonly struct Hit - { - public readonly double Time; - public readonly MetronomePitch Pitch; - - public Hit(double time, MetronomePitch pitch) - { - Time = time; - Pitch = pitch; - } - } - private readonly StemMixer _mixer; - private readonly List _hits; + private double[] _hiHits = Array.Empty(); + private double[] _loHits = Array.Empty(); private OneShotChannel _hiChannel; private OneShotChannel _loChannel; + private bool _scheduled; + private bool _disposed; - public MetronomeScheduler(StemMixer mixer, SongRunner songRunner, SyncTrack sync, - double songLength) + /// + /// Creates a metronome scheduler for the supplied mixer. + /// + public MetronomeScheduler(StemMixer mixer) { _mixer = mixer; - _hits = CreateHits(songRunner, sync, songLength); + } + /// + /// Schedules metronome hits for the song and begins responding to metronome settings. + /// + public void Schedule(SongRunner songRunner, SyncTrack sync, double songLength) + { + if (_disposed) + { + throw new ObjectDisposedException(nameof(MetronomeScheduler)); + } + if (_scheduled) + { + throw new InvalidOperationException("Metronome has already been scheduled."); + } + + CreateSchedule(songRunner, sync, songLength, out _hiHits, out _loHits); CreateChannels(SettingsManager.Settings.MetronomeSound.Value); SettingsManager.Settings.MetronomeSound.OnChange += OnSoundChanged; SettingsManager.Settings.MetronomeVolume.OnChange += OnVolumeChanged; + _scheduled = true; } - private static List CreateHits(SongRunner songRunner, SyncTrack sync, double songLength) + private static void CreateSchedule(SongRunner songRunner, SyncTrack sync, double songLength, + out double[] hiHits, out double[] loHits) { - var hits = new List(); + var hi = new List(); + var lo = new List(); foreach (var beatline in sync.Beatlines) { if (beatline.Time > songLength) @@ -51,11 +62,13 @@ private static List CreateHits(SongRunner songRunner, SyncTrack sync, doubl break; } - var pitch = beatline.Type == BeatlineType.Measure ? MetronomePitch.Hi : MetronomePitch.Lo; double audioTime = songRunner.GetAudioPlaybackTime(beatline.Time); - hits.Add(new Hit(audioTime, pitch)); + var hits = beatline.Type == BeatlineType.Measure ? hi : lo; + hits.Add(audioTime); } - return hits; + + hiHits = hi.ToArray(); + loHits = lo.ToArray(); } private void CreateChannels(MetronomeSample sample) @@ -63,13 +76,8 @@ private void CreateChannels(MetronomeSample sample) DisposeChannels(); var hiStream = GlobalAudioHandler.CreateMetronomeStream(sample, MetronomePitch.Hi); var loStream = GlobalAudioHandler.CreateMetronomeStream(sample, MetronomePitch.Lo); - _hiChannel = _mixer.CreateOneShotChannel(hiStream); - _loChannel = _mixer.CreateOneShotChannel(loStream); - foreach (var hit in _hits) - { - var channel = hit.Pitch == MetronomePitch.Hi ? _hiChannel : _loChannel; - channel.AddScheduledPlay(hit.Time); - } + _hiChannel = _mixer.CreateOneShotChannel(hiStream, _hiHits); + _loChannel = _mixer.CreateOneShotChannel(loStream, _loHits); SetVolume(sample); } @@ -93,9 +101,15 @@ private void SetVolume(MetronomeSample sample) public void Dispose() { + if (_disposed) + { + return; + } + SettingsManager.Settings.MetronomeSound.OnChange -= OnSoundChanged; SettingsManager.Settings.MetronomeVolume.OnChange -= OnVolumeChanged; DisposeChannels(); + _disposed = true; } private void DisposeChannels() diff --git a/Assets/Script/Helpers/RingBuffer.cs b/Assets/Script/Helpers/RingBuffer.cs deleted file mode 100644 index 74f377945e..0000000000 --- a/Assets/Script/Helpers/RingBuffer.cs +++ /dev/null @@ -1,70 +0,0 @@ -using System; - -namespace YARG.Helpers -{ - internal sealed class RingBuffer - { - private readonly T[] _items; - private int _start; - - public int Count { get; private set; } - - public T this[int index] - { - get => _items[GetIndex(index)]; - set => _items[GetIndex(index)] = value; - } - - public RingBuffer(int capacity) - { - if (capacity <= 0) - { - throw new ArgumentOutOfRangeException(nameof(capacity)); - } - - _items = new T[capacity]; - } - - public void Add(T item) - { - if (Count == _items.Length) - { - throw new InvalidOperationException("Ring buffer is full."); - } - - _items[(_start + Count) % _items.Length] = item; - Count++; - } - - public T RemoveOldest() - { - if (Count == 0) - { - throw new InvalidOperationException("Ring buffer is empty."); - } - - var item = _items[_start]; - _items[_start] = default; - _start = (_start + 1) % _items.Length; - Count--; - return item; - } - - public void Clear() - { - Array.Clear(_items, 0, _items.Length); - _start = 0; - Count = 0; - } - - private int GetIndex(int index) - { - if (index < 0 || index >= Count) - { - throw new ArgumentOutOfRangeException(nameof(index)); - } - - return (_start + index) % _items.Length; - } - } -} diff --git a/Assets/Script/Helpers/RingBuffer.cs.meta b/Assets/Script/Helpers/RingBuffer.cs.meta deleted file mode 100644 index 2b21c8ccfe..0000000000 --- a/Assets/Script/Helpers/RingBuffer.cs.meta +++ /dev/null @@ -1,3 +0,0 @@ -fileFormatVersion: 2 -guid: 2569035e795a44f8acb593ecf7018349 -timeCreated: 1783785600