%o{P!>^*6SHe}Oz%BYZ7#v4_-O3X@KX
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
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