From b649e8dc4457c1ea8369f1d742ff167467e14f2f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Mar 2026 05:49:17 +0000 Subject: [PATCH 1/5] Initial plan From 6ad6ce06d6b8e4c1016e8ee467cb34d17408779b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Mar 2026 06:18:02 +0000 Subject: [PATCH 2/5] Fix SRT/ASS subtitles playback rate handling When the editor's playback rate was not 1x, SRT/ASS subtitles appeared at "normal" 1x timing because ResolveCueTiming() called jukebox.SecondsToBeats(seconds) without accounting for the AudioSource pitch (which controls the editor's playback rate). Fix: Add GetJukeboxPlaybackRate() helper that reads AudioSource.pitch from the jukebox's component and divide the subtitle seconds by this rate before calling SecondsToBeats(). At 0.5x playback, SecondsToBeats (10 / 0.5) = SecondsToBeats(20) correctly returns the beat that CurrentBeat reaches at real-time second 20 (when audio-content second 10 plays at 0.5x speed). Also fix a secondary bug where karaoke segment beats from SRT/ASS were not recomputed on session restart (stale from previous session): remove the !segment.Beat.HasValue guard so seconds-based segments always get their beat recomputed. JSON-based segments (Beat set, Seconds unset) are unaffected. Co-authored-by: Brollyy <12004018+Brollyy@users.noreply.github.com> --- .../Core/SubtitleRuntimeController.cs | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/BopSubtitleReader/Core/SubtitleRuntimeController.cs b/BopSubtitleReader/Core/SubtitleRuntimeController.cs index fbf06da..7367001 100644 --- a/BopSubtitleReader/Core/SubtitleRuntimeController.cs +++ b/BopSubtitleReader/Core/SubtitleRuntimeController.cs @@ -252,19 +252,20 @@ private static string BuildStyleKey(SubtitleCueStyle? style) private static void ResolveCueTiming(SubtitleTrack track, JukeboxScript jukebox) { + var playbackRate = GetJukeboxPlaybackRate(jukebox); foreach (var cue in track.Cues) { if (cue.UsesSecondsTiming && cue is { StartSeconds: not null, EndSeconds: not null }) { - cue.StartBeat = jukebox.SecondsToBeats(cue.StartSeconds.Value); - cue.EndBeat = jukebox.SecondsToBeats(cue.EndSeconds.Value); + cue.StartBeat = jukebox.SecondsToBeats(cue.StartSeconds.Value / playbackRate); + cue.EndBeat = jukebox.SecondsToBeats(cue.EndSeconds.Value / playbackRate); } foreach (var segment in cue.KaraokeSegments) { - if (!segment.Beat.HasValue && segment.Seconds.HasValue) + if (segment.Seconds.HasValue) { - segment.Beat = jukebox.SecondsToBeats(segment.Seconds.Value); + segment.Beat = jukebox.SecondsToBeats(segment.Seconds.Value / playbackRate); } } } @@ -272,6 +273,17 @@ private static void ResolveCueTiming(SubtitleTrack track, JukeboxScript jukebox) track.Cues.Sort((a, b) => a.StartBeat.CompareTo(b.StartBeat)); } + private static float GetJukeboxPlaybackRate(JukeboxScript jukebox) + { + var audioSource = jukebox.GetComponent(); + if (!audioSource) + { + return 1f; + } + + return Mathf.Max(Mathf.Abs(audioSource.pitch), 0.01f); + } + private void RefreshSubtitleCamera(bool force = false) { if (_loader is null || !_loader) From c69c8b5441cda4ef05cf8a0ffaa09fb744fa364d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Mar 2026 06:34:53 +0000 Subject: [PATCH 3/5] Fix dynamic playback rate: re-resolve timing on rate change The previous fix read the playback rate once at session start, which was wrong because the editor allows changing the rate mid-session. Add _resolvedPlaybackRate field to track which rate was used for the last ResolveCueTiming() call. In Update(), read the current AudioSource.pitch each frame; if it differs from _resolvedPlaybackRate, re-run ResolveCueTiming() with the new rate and reset _activeCue and the karaoke cache so the display is immediately re-evaluated. ResolveCueTiming now accepts the pre-read playbackRate parameter to avoid reading AudioSource.pitch twice per frame. Co-authored-by: Brollyy <12004018+Brollyy@users.noreply.github.com> --- .../Core/SubtitleRuntimeController.cs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/BopSubtitleReader/Core/SubtitleRuntimeController.cs b/BopSubtitleReader/Core/SubtitleRuntimeController.cs index 7367001..5ec01ab 100644 --- a/BopSubtitleReader/Core/SubtitleRuntimeController.cs +++ b/BopSubtitleReader/Core/SubtitleRuntimeController.cs @@ -30,6 +30,7 @@ public sealed class SubtitleRuntimeController : MonoBehaviour private MixtapeLoaderCustom? _loader; private SubtitleConfig? _config; private bool _timingResolved; + private float _resolvedPlaybackRate = 1f; private string _displayText = string.Empty; private string _displayStyleKey = string.Empty; @@ -67,6 +68,7 @@ public void StartSession(MixtapeLoaderCustom loader, SubtitleTrack track, Subtit _displayText = string.Empty; _displayStyleKey = string.Empty; _timingResolved = false; + _resolvedPlaybackRate = 1f; _cachedKaraokeCue = null; _cachedActiveSegmentIndex = -1; _cachedKaraokeBeat = float.NaN; @@ -86,6 +88,7 @@ public void StopSession() _displayText = string.Empty; _displayStyleKey = string.Empty; _timingResolved = false; + _resolvedPlaybackRate = 1f; _cachedKaraokeCue = null; _cachedActiveSegmentIndex = -1; _cachedKaraokeBeat = float.NaN; @@ -118,10 +121,15 @@ private void Update() RefreshSubtitleCamera(); - if (!_timingResolved) + var playbackRate = GetJukeboxPlaybackRate(jukebox); + if (!_timingResolved || !Mathf.Approximately(playbackRate, _resolvedPlaybackRate)) { - ResolveCueTiming(_track, jukebox); + ResolveCueTiming(_track, jukebox, playbackRate); + _resolvedPlaybackRate = playbackRate; _timingResolved = true; + _activeCue = null; + _cachedKaraokeCue = null; + _cachedActiveSegmentIndex = -1; } var beat = jukebox.CurrentBeat; @@ -250,9 +258,8 @@ private static string BuildStyleKey(SubtitleCueStyle? style) $"{style.FontName}|{fontSize}|{style.ColorHexRgba}|{style.SecondaryColorHexRgba}|{style.OutlineColorHexRgba}|{style.OutlineWidth}|{style.Bold}|{style.Italic}|{style.Alignment}"; } - private static void ResolveCueTiming(SubtitleTrack track, JukeboxScript jukebox) + private static void ResolveCueTiming(SubtitleTrack track, JukeboxScript jukebox, float playbackRate) { - var playbackRate = GetJukeboxPlaybackRate(jukebox); foreach (var cue in track.Cues) { if (cue.UsesSecondsTiming && cue is { StartSeconds: not null, EndSeconds: not null }) From 023f1a67b951bee4b8515ba58ce8b521f126abd2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Mar 2026 06:54:43 +0000 Subject: [PATCH 4/5] Fix subtitle sync: derive beat from AudioSource.time instead of CurrentBeat JukeboxScript.CurrentBeat advances based on wall-clock time and does not account for AudioSource.pitch, causing subtitles to drift from the audio at non-1x playback rates. Replace CurrentBeat with GetContentBeat() which reads AudioSource.time (the true content position regardless of pitch) and converts to beats via SecondsToBeats(). Falls back to CurrentBeat when no AudioSource is available. This removes all previous rate-division hacks and keeps cues as simple beat values converted once at session start. Co-authored-by: Brollyy <12004018+Brollyy@users.noreply.github.com> --- .../Core/SubtitleRuntimeController.cs | 40 +++++++++---------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/BopSubtitleReader/Core/SubtitleRuntimeController.cs b/BopSubtitleReader/Core/SubtitleRuntimeController.cs index 5ec01ab..fe129c6 100644 --- a/BopSubtitleReader/Core/SubtitleRuntimeController.cs +++ b/BopSubtitleReader/Core/SubtitleRuntimeController.cs @@ -30,7 +30,6 @@ public sealed class SubtitleRuntimeController : MonoBehaviour private MixtapeLoaderCustom? _loader; private SubtitleConfig? _config; private bool _timingResolved; - private float _resolvedPlaybackRate = 1f; private string _displayText = string.Empty; private string _displayStyleKey = string.Empty; @@ -68,7 +67,6 @@ public void StartSession(MixtapeLoaderCustom loader, SubtitleTrack track, Subtit _displayText = string.Empty; _displayStyleKey = string.Empty; _timingResolved = false; - _resolvedPlaybackRate = 1f; _cachedKaraokeCue = null; _cachedActiveSegmentIndex = -1; _cachedKaraokeBeat = float.NaN; @@ -88,7 +86,6 @@ public void StopSession() _displayText = string.Empty; _displayStyleKey = string.Empty; _timingResolved = false; - _resolvedPlaybackRate = 1f; _cachedKaraokeCue = null; _cachedActiveSegmentIndex = -1; _cachedKaraokeBeat = float.NaN; @@ -121,18 +118,13 @@ private void Update() RefreshSubtitleCamera(); - var playbackRate = GetJukeboxPlaybackRate(jukebox); - if (!_timingResolved || !Mathf.Approximately(playbackRate, _resolvedPlaybackRate)) + if (!_timingResolved) { - ResolveCueTiming(_track, jukebox, playbackRate); - _resolvedPlaybackRate = playbackRate; + ResolveCueTiming(_track, jukebox); _timingResolved = true; - _activeCue = null; - _cachedKaraokeCue = null; - _cachedActiveSegmentIndex = -1; } - var beat = jukebox.CurrentBeat; + var beat = GetContentBeat(jukebox); var cue = FindActiveCue(_track.Cues, beat); if (cue == _activeCue) { @@ -258,21 +250,21 @@ private static string BuildStyleKey(SubtitleCueStyle? style) $"{style.FontName}|{fontSize}|{style.ColorHexRgba}|{style.SecondaryColorHexRgba}|{style.OutlineColorHexRgba}|{style.OutlineWidth}|{style.Bold}|{style.Italic}|{style.Alignment}"; } - private static void ResolveCueTiming(SubtitleTrack track, JukeboxScript jukebox, float playbackRate) + private static void ResolveCueTiming(SubtitleTrack track, JukeboxScript jukebox) { foreach (var cue in track.Cues) { if (cue.UsesSecondsTiming && cue is { StartSeconds: not null, EndSeconds: not null }) { - cue.StartBeat = jukebox.SecondsToBeats(cue.StartSeconds.Value / playbackRate); - cue.EndBeat = jukebox.SecondsToBeats(cue.EndSeconds.Value / playbackRate); + cue.StartBeat = jukebox.SecondsToBeats(cue.StartSeconds.Value); + cue.EndBeat = jukebox.SecondsToBeats(cue.EndSeconds.Value); } foreach (var segment in cue.KaraokeSegments) { - if (segment.Seconds.HasValue) + if (!segment.Beat.HasValue && segment.Seconds.HasValue) { - segment.Beat = jukebox.SecondsToBeats(segment.Seconds.Value / playbackRate); + segment.Beat = jukebox.SecondsToBeats(segment.Seconds.Value); } } } @@ -280,15 +272,23 @@ private static void ResolveCueTiming(SubtitleTrack track, JukeboxScript jukebox, track.Cues.Sort((a, b) => a.StartBeat.CompareTo(b.StartBeat)); } - private static float GetJukeboxPlaybackRate(JukeboxScript jukebox) + /// + /// Returns the current beat corresponding to the actual audio content position. + /// JukeboxScript.CurrentBeat advances based on wall-clock time and does not + /// account for AudioSource.pitch, so at non-1× playback rates it drifts from + /// the audio. Deriving the beat from AudioSource.time (which always reflects + /// the true content position regardless of pitch) keeps subtitles synchronised at + /// any editor playback rate. + /// + private static float GetContentBeat(JukeboxScript jukebox) { var audioSource = jukebox.GetComponent(); - if (!audioSource) + if (audioSource && audioSource.clip) { - return 1f; + return jukebox.SecondsToBeats(audioSource.time); } - return Mathf.Max(Mathf.Abs(audioSource.pitch), 0.01f); + return jukebox.CurrentBeat; } private void RefreshSubtitleCamera(bool force = false) From 6fa7b717b415b69cb70e317431a47ccc0ef7e91f Mon Sep 17 00:00:00 2001 From: Brollyy Date: Wed, 25 Mar 2026 20:38:09 +0100 Subject: [PATCH 5/5] Fixed the logic for timing subtitles when editor playback rate is applied. --- .../Core/SubtitleRuntimeController.cs | 102 ++++++++++++++---- 1 file changed, 83 insertions(+), 19 deletions(-) diff --git a/BopSubtitleReader/Core/SubtitleRuntimeController.cs b/BopSubtitleReader/Core/SubtitleRuntimeController.cs index 3332c38..c1c8688 100644 --- a/BopSubtitleReader/Core/SubtitleRuntimeController.cs +++ b/BopSubtitleReader/Core/SubtitleRuntimeController.cs @@ -17,6 +17,12 @@ public sealed class SubtitleRuntimeController : MonoBehaviour AccessTools.FieldRefAccess("sceneKeys"); private static readonly AccessTools.FieldRef> ScriptsRef = AccessTools.FieldRefAccess>("scripts"); + private static readonly AccessTools.FieldRef RiqLoaderRef = + AccessTools.FieldRefAccess("riqLoader"); + private static readonly AccessTools.FieldRef RiqLoaderMixtapeRef = + AccessTools.FieldRefAccess("mixtape"); + private static readonly AccessTools.FieldRef MixtapePropertiesEventRef = + AccessTools.FieldRefAccess("mixtapePropertiesEvent"); // Sentinel stored in _displayText while the karaoke overlay is actively rendering. private const string KaraokeActiveSentinel = "__KARAOKE_ACTIVE__"; @@ -123,11 +129,11 @@ private void Update() if (!_timingResolved) { - ResolveCueTiming(_track, jukebox); + ResolveCueTiming(_track, _loader, jukebox); _timingResolved = true; } - var beat = GetContentBeat(jukebox); + var beat = jukebox.CurrentBeat; var cue = FindActiveCue(_track.Cues, beat); if (cue == _activeCue) { @@ -253,21 +259,25 @@ private static string BuildStyleKey(SubtitleCueStyle? style) $"{style.FontName}|{fontSize}|{style.ColorHexRgba}|{style.SecondaryColorHexRgba}|{style.OutlineColorHexRgba}|{style.OutlineWidth}|{style.Bold}|{style.Italic}|{style.Alignment}"; } - private static void ResolveCueTiming(SubtitleTrack track, JukeboxScript jukebox) + private static void ResolveCueTiming(SubtitleTrack track, MixtapeLoaderCustom loader, JukeboxScript jukebox) { + var playbackRate = ResolvePlaybackRate(loader, jukebox); + var secondsBeatScale = Mathf.Approximately(playbackRate, 0f) ? 1f : 1f / playbackRate; + Log.Info( + $"Resolving cue timing for mixtape using {jukebox.Bpm} BPM (playbackSpeed={playbackRate.ToString("F3", CultureInfo.InvariantCulture)})"); foreach (var cue in track.Cues) { if (cue.UsesSecondsTiming && cue is { StartSeconds: not null, EndSeconds: not null }) { - cue.StartBeat = jukebox.SecondsToBeats(cue.StartSeconds.Value); - cue.EndBeat = jukebox.SecondsToBeats(cue.EndSeconds.Value); + cue.StartBeat = ResolveSecondsToBeat(jukebox, cue.StartSeconds.Value, secondsBeatScale); + cue.EndBeat = ResolveSecondsToBeat(jukebox, cue.EndSeconds.Value, secondsBeatScale); } foreach (var segment in cue.KaraokeSegments) { if (!segment.Beat.HasValue && segment.Seconds.HasValue) { - segment.Beat = jukebox.SecondsToBeats(segment.Seconds.Value); + segment.Beat = ResolveSecondsToBeat(jukebox, segment.Seconds.Value, secondsBeatScale); } } } @@ -275,23 +285,77 @@ private static void ResolveCueTiming(SubtitleTrack track, JukeboxScript jukebox) track.Cues.Sort((a, b) => a.StartBeat.CompareTo(b.StartBeat)); } - /// - /// Returns the current beat corresponding to the actual audio content position. - /// JukeboxScript.CurrentBeat advances based on wall-clock time and does not - /// account for AudioSource.pitch, so at non-1× playback rates it drifts from - /// the audio. Deriving the beat from AudioSource.time (which always reflects - /// the true content position regardless of pitch) keeps subtitles synchronised at - /// any editor playback rate. - /// - private static float GetContentBeat(JukeboxScript jukebox) + private static float ResolveSecondsToBeat(JukeboxScript jukebox, double seconds, float secondsBeatScale) { - var audioSource = jukebox.GetComponent(); - if (audioSource && audioSource.clip) + return jukebox.SecondsToBeats(seconds) * secondsBeatScale; + } + + private static float ResolvePlaybackRate(MixtapeLoaderCustom? loader, JukeboxScript jukebox) + { + if (!TryGetBaseMixtapeBpm(loader, out var baseBpm, out var source)) + { + Log.Info("ResolvePlaybackRate: base mixtape BPM unavailable; defaulting to 1.000"); + return 1f; + } + + var playbackRate = jukebox.bpm / baseBpm; + Log.Info( + $"ResolvePlaybackRate: {jukebox.bpm.ToString("F3", CultureInfo.InvariantCulture)} / {baseBpm.ToString("F3", CultureInfo.InvariantCulture)} ({source})"); + return playbackRate > 0f ? playbackRate : 1f; + } + + private static bool TryGetBaseMixtapeBpm(MixtapeLoaderCustom? loader, out float baseBpm, out string source) + { + baseBpm = 0f; + source = "none"; + + if (loader is not null && loader) + { + var riqLoader = RiqLoaderRef(loader); + if (riqLoader is not null && riqLoader) + { + var loadedMixtape = RiqLoaderMixtapeRef(riqLoader); + if (loadedMixtape is not null && loadedMixtape.bpm > 0f) + { + baseBpm = loadedMixtape.bpm; + source = "riqLoader.mixtape"; + return true; + } + } + } + + if (!MixtapeEditorScript.IsInEditor) { - return jukebox.SecondsToBeats(audioSource.time); + return false; + } + + var editor = FindObjectOfType(); + if (editor is null || !editor) + { + return false; + } + + var mixtapePropertiesEvent = MixtapePropertiesEventRef(editor); + if (mixtapePropertiesEvent is null || !mixtapePropertiesEvent) + { + return false; + } + + var entity = mixtapePropertiesEvent.Entity; + if (entity is null) + { + return false; + } + + var editorBpm = entity.GetFloat("bpm"); + if (editorBpm <= 0f) + { + return false; } - return jukebox.CurrentBeat; + baseBpm = editorBpm; + source = "MixtapeEditorScript.mixtapePropertiesEvent"; + return true; } private void RefreshSubtitleCamera(bool force = false)