Files
Ez2Lazer/osu.Game/LAsEzExtensions/Audio/EzPreviewTrackManager.cs
LA 0b9f9f70d6 主要为代码质量更新
1. 匹配新版按钮控件的自动宽度写法

2. 统一Ez日志写入方向

3.移除历史修改:缓存启用mod列表,切换mod时保持通用mod开启状态

4.代码格式化、

5.修改文件名称表意,更直观
2026-03-12 19:29:55 +08:00

864 lines
32 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Audio;
using osu.Framework.Audio.Sample;
using osu.Framework.Audio.Track;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Logging;
using osu.Framework.Threading;
using osu.Game.Audio;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Objects;
using osu.Game.Skinning;
using osu.Game.Storyboards;
namespace osu.Game.LAsEzExtensions.Audio
{
/// <summary>
/// <para>一个增强的预览音轨管理器支持在预览时播放note音效和故事板背景音。</para>
/// <para>主要有两个用途:</para>
/// 1. 在选歌界面实现最完整的游戏音轨预览。
/// <para>2. 提供拓展支持自定义预览时间、循环次数和间隔、关联游戏时钟、开关note音效等。</para>
/// </summary>
public partial class EzPreviewTrackManager : CompositeDrawable
{
// 单例/实例都可用,但我们使用实例级 Bindable 以便在 `SongSelect` 中直接 BindTo。
public BindableBool EnabledBindable { get; } = new BindableBool();
// 预览时非重复音效必须大于此值才会激活hitsound预览否则退回外部预览。
private const int hitsound_threshold = 10;
private const double preview_window_length = 20000; // 20s
private const double scheduler_interval = 16; // ~60fps
private const double trigger_tolerance = 15; // ms 容差
private const double max_dynamic_preview_length = 60000; // 动态扩展最长 ms
private readonly SampleSchedulerState sampleScheduler = new SampleSchedulerState();
private readonly PlaybackState playback = new PlaybackState();
public readonly Bindable<IBeatmap> Beatmap = new Bindable<IBeatmap>();
private Track? currentTrack;
private IWorkingBeatmap? currentBeatmap;
private ScheduledDelegate? updateDelegate;
private Container audioContainer = null!;
private ISampleStore sampleStore = null!;
[Resolved]
protected AudioManager AudioManager { get; private set; } = null!;
[Resolved]
private ISkinSource skinSource { get; set; } = null!;
/// <summary>
/// 覆盖预览起点时间(毫秒)。
/// 若为 null则使用谱面元数据的预览时间PreviewTime
/// </summary>
public double? OverridePreviewStartTime { get; set; }
/// <summary>
/// 重置循环状态,用于在开始新预览时清除之前的循环进度。
/// </summary>
public void ResetLoopState()
{
playback.ResetPlaybackProgress();
}
private bool ownsCurrentTrack;
#region Disposal
protected override void Dispose(bool isDisposing)
{
StopPreview();
base.Dispose(isDisposing);
}
#endregion
/// <summary>
/// 为指定谱面启动预览。
/// 若命中音效数量低于阈值,会自动回退到“仅 BGM”的标准预览。
/// </summary>
/// <param name="beatmap">要预览的谱面</param>
/// <param name="forceEnhanced">是否强制使用增强预览(忽略命中音效数量阈值)</param>
public bool StartPreview(IWorkingBeatmap beatmap, bool forceEnhanced = false)
{
if (!EnabledBindable.Value)
return false;
StopPreview();
currentBeatmap = beatmap;
currentTrack = CreateTrack(beatmap, out ownsCurrentTrack);
if (currentTrack == null)
Logger.Log("EzPreviewTrackManager: currentTrack is null (falling back?)");
playback.ResetPlaybackProgress();
if (!forceEnhanced && !fastCheckShouldUseEnhanced(beatmap, hitsound_threshold)) return false;
startEnhancedPreview(beatmap);
return true;
}
public void StopPreview()
{
StopPreviewInternal();
}
protected void StopPreviewInternal()
{
playback.IsPlaying = false;
updateDelegate?.Cancel();
updateDelegate = null;
if (currentTrack != null)
{
currentTrack.Volume.Value = 1f;
currentTrack.Stop();
if (ownsCurrentTrack)
currentTrack.Dispose();
}
clearEnhancedElements();
currentBeatmap = null;
currentTrack = null;
playback.ResetPlaybackProgress();
}
[BackgroundDependencyLoader]
private void load()
{
sampleStore = AudioManager.Samples;
InternalChild = audioContainer = new Container
{
RelativeSizeAxes = Axes.Both
};
// EnabledBindable.BindValueChanged(_ =>
// {
// // 在主调度线程执行 Stop/Start 操作以保证线程安全。
// Schedule(() =>
// {
// if (currentBeatmap != null)
// {
// StopPreviewInternal();
// StartPreview(currentBeatmap);
// }
// });
// }, false);
}
// 快速判定:遍历命中对象直到达到阈值即返回 true。
// 统计谱面中命中对象所使用的采样音频的唯一文件名数量(使用 HitSampleInfo.LookupNames 的首选值)。
private bool fastCheckShouldUseEnhanced(IWorkingBeatmap beatmap, int threshold)
{
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var obj in beatmap.Beatmap.HitObjects)
{
var stack = new Stack<HitObject>();
stack.Push(obj);
while (stack.Count > 0)
{
var ho = stack.Pop();
foreach (var sm in ho.Samples)
{
string? first = sm.LookupNames.FirstOrDefault();
if (first != null && set.Add(first) && set.Count >= threshold)
return true;
}
foreach (var n in ho.NestedHitObjects)
stack.Push(n);
}
}
return set.Count >= threshold;
}
/// <summary>
/// 启动增强预览BGM + 命中音效 + 故事板音效)。
/// </summary>
private void startEnhancedPreview(IWorkingBeatmap beatmap)
{
double longestHitTime = 0;
double longestStoryboardTime = 0;
void collectLongest(HitObject ho)
{
longestHitTime = Math.Max(longestHitTime, ho.StartTime);
foreach (var n in ho.NestedHitObjects) collectLongest(n);
}
try
{
var playableBeatmap = beatmap.GetPlayableBeatmap(beatmap.BeatmapInfo.Ruleset);
beatmap.PrepareTrackForPreview(true);
playback.PreviewStartTime = OverridePreviewStartTime ?? beatmap.BeatmapInfo.Metadata.PreviewTime;
if (playback.PreviewStartTime < 0 || playback.PreviewStartTime > (currentTrack?.Length ?? 0))
playback.PreviewStartTime = (currentTrack?.Length ?? 0) * 0.4;
foreach (var ho in playableBeatmap.HitObjects)
collectLongest(ho);
if (beatmap.Storyboard?.Layers != null)
{
foreach (var layer in beatmap.Storyboard.Layers)
{
foreach (var element in layer.Elements)
{
if (element is StoryboardSampleInfo s)
longestStoryboardTime = Math.Max(longestStoryboardTime, s.StartTime);
}
}
}
double longestEventTime = Math.Max(longestHitTime, longestStoryboardTime);
double defaultEnd = playback.PreviewStartTime + preview_window_length;
double dynamicEnd = defaultEnd;
if (currentTrack != null)
{
double segmentAfterStart = Math.Max(1, currentTrack.Length - playback.PreviewStartTime);
if (segmentAfterStart < preview_window_length * 0.6 && longestEventTime > defaultEnd)
dynamicEnd = Math.Min(playback.PreviewStartTime + max_dynamic_preview_length, longestEventTime);
}
double segmentLength = dynamicEnd - playback.PreviewStartTime;
playback.LoopSegmentLength = Math.Max(1, segmentLength);
// 播放至谱面事件结束或音轨结尾(取较小者),作为一次循环的结束点。
playback.PreviewEndTime = Math.Min(currentTrack?.Length ?? double.MaxValue, playback.PreviewStartTime + playback.LoopSegmentLength);
playback.LastTrackTime = playback.PreviewStartTime;
playback.LegacyLoopCount = 0;
playback.LegacyLogicalOffset = 0;
// external-clock / manual looping behavior has been removed from this manager.
playback.ShortBgmOneShotMode = false;
playback.ShortBgmMutedAfterFirstLoop = false;
// 判定一次性短BGM模式
// 若主音频非常短(<10s则将其视为短 BGM在循环时在第一次循环后静音从而实现“播放一次可听后续不再循环”的效果
// 同时保留命中音效与故事板音效的触发逻辑。
if (currentTrack != null)
{
if (currentTrack.Length < 10000 || longestEventTime > (currentTrack.Length + 5000))
playback.ShortBgmOneShotMode = true;
}
prepareHitSounds(playableBeatmap, playback.PreviewEndTime);
prepareStoryboardSamples(beatmap.Storyboard, playback.PreviewEndTime);
// preloadSamples();
if (sampleScheduler.ScheduledHitSounds.Count == 0 && sampleScheduler.ScheduledStoryboardSamples.Count == 0)
{
clearEnhancedElements();
return;
}
sampleScheduler.ResetIndices();
currentTrack?.Seek(playback.PreviewStartTime);
if (currentTrack != null)
{
// 在已简化的 manager 中,交由底层 track 的 Looping 决定是否循环;我们尽量保持默认可循环体验。
currentTrack.Looping = true;
currentTrack.RestartPoint = playback.PreviewStartTime;
}
currentTrack?.Start();
playback.IsPlaying = true;
// 保持样本调度逻辑以触发命中音效与故事板样本。
updateDelegate = Scheduler.AddDelayed(updateSamples, scheduler_interval, true);
updateSamples();
}
catch (Exception ex)
{
Logger.Log($"EzPreviewTrackManager: startEnhancedPreview error: {ex}");
clearEnhancedElements();
}
}
private void prepareHitSounds(IBeatmap beatmap, double previewEndTime)
{
sampleScheduler.ScheduledHitSounds.Clear();
foreach (var ho in beatmap.HitObjects)
schedule(ho, previewEndTime);
sampleScheduler.ScheduledHitSounds.Sort((a, b) => a.Time.CompareTo(b.Time));
void schedule(HitObject ho, double end)
{
if (ho.StartTime >= playback.PreviewStartTime && ho.StartTime <= end && ho.Samples.Any())
{
sampleScheduler.ScheduledHitSounds.Add(new ScheduledHitSound
{
Time = ho.StartTime,
Samples = ho.Samples.ToArray(),
HasTriggered = false
});
}
foreach (var n in ho.NestedHitObjects) schedule(n, end);
}
}
private void prepareStoryboardSamples(Storyboard? storyboard, double previewEndTime)
{
sampleScheduler.ScheduledStoryboardSamples.Clear();
if (storyboard?.Layers == null) return;
foreach (var layer in storyboard.Layers)
{
foreach (var element in layer.Elements)
{
if (element is StoryboardSampleInfo s && s.StartTime >= playback.PreviewStartTime && s.StartTime <= previewEndTime)
{
sampleScheduler.ScheduledStoryboardSamples.Add(new ScheduledStoryboardSample
{
Time = s.StartTime,
Sample = s,
HasTriggered = false
});
}
}
}
sampleScheduler.ScheduledStoryboardSamples.Sort((a, b) => a.Time.CompareTo(b.Time));
}
// 样本预加载:去重后调用一次 GetChannel() 以确保缓存 / 文件读取
private void preloadSamples()
{
try
{
var uniqueHitInfos = new HashSet<string?>();
foreach (var s in sampleScheduler.ScheduledHitSounds.SelectMany(h => h.Samples))
{
foreach (var sample in fetchSamplesForInfo(s, true))
{
string? key = sample?.ToString();
if (key != null && uniqueHitInfos.Add(key))
{
var ch = sample?.GetChannel();
if (ch != null)
{
try
{
ch.Stop();
}
finally
{
if (!ch.IsDisposed && !ch.ManualFree)
ch.Dispose();
}
}
}
}
}
var uniqueStoryboard = new HashSet<string>();
foreach (var sb in sampleScheduler.ScheduledStoryboardSamples)
{
// 通过统一的 fetchStoryboardSample 进行预热
var fetched = fetchStoryboardSample(sb.Sample, true);
if (fetched.sample != null && uniqueStoryboard.Add(fetched.chosenKey))
{
var ch = fetched.sample.GetChannel();
try
{
ch.Stop();
}
finally
{
if (!ch.IsDisposed && !ch.ManualFree)
ch.Dispose();
}
}
}
}
catch (Exception ex)
{
Logger.Log($"EzPreviewTrackManager: Preload error {ex.Message}");
}
}
// 调度函数基于索引推进
private void updateSamples()
{
if (!playback.IsPlaying || currentTrack == null) return;
// 简化:使用底层 track 的 CurrentTime 作为逻辑时间来驱动样本触发。
double physicalTime = currentTrack.CurrentTime;
// 回绕检测:若物理时间回绕(小于上一次记录的时间超出阈值),视为一次循环已发生,重置样本触发状态。
if (playback.LastTrackTime != 0 && physicalTime + 200 < playback.LastTrackTime)
{
// 重置命中音效触发标记
for (int i = 0; i < sampleScheduler.ScheduledHitSounds.Count; i++)
{
var s = sampleScheduler.ScheduledHitSounds[i];
s.HasTriggered = false;
sampleScheduler.ScheduledHitSounds[i] = s;
}
// 重置 storyboard 样本触发标记
for (int i = 0; i < sampleScheduler.ScheduledStoryboardSamples.Count; i++)
{
var sb = sampleScheduler.ScheduledStoryboardSamples[i];
sb.HasTriggered = false;
sampleScheduler.ScheduledStoryboardSamples[i] = sb;
}
// 重新定位下一触发索引(从当前物理时间附近开始查找)
sampleScheduler.NextHitSoundIndex = findNextValidIndex(sampleScheduler.ScheduledHitSounds, 0, physicalTime - trigger_tolerance);
sampleScheduler.NextStoryboardSampleIndex = findNextValidIndex(sampleScheduler.ScheduledStoryboardSamples, 0, physicalTime - trigger_tolerance);
// 清理已停止的活动通道并归还资源
cleanupInactiveChannels();
}
double logicalTime = physicalTime;
if (!currentTrack.IsRunning)
{
if (currentTrack.IsDisposed)
{
StopPreview();
return;
}
currentTrack.Start();
}
double logicalTimeForEvents = logicalTime;
bool withinWindow = logicalTimeForEvents <= playback.PreviewEndTime + trigger_tolerance;
sampleScheduler.NextHitSoundIndex = findNextValidIndex(sampleScheduler.ScheduledHitSounds, sampleScheduler.NextHitSoundIndex, logicalTimeForEvents - trigger_tolerance);
while (withinWindow && sampleScheduler.NextHitSoundIndex < sampleScheduler.ScheduledHitSounds.Count)
{
var hs = sampleScheduler.ScheduledHitSounds[sampleScheduler.NextHitSoundIndex];
if (hs.HasTriggered)
{
sampleScheduler.NextHitSoundIndex++;
continue;
}
if (hs.Time > logicalTime + trigger_tolerance) break;
if (Math.Abs(hs.Time - logicalTime) <= trigger_tolerance)
{
triggerHitSound(hs.Samples);
hs.HasTriggered = true;
sampleScheduler.ScheduledHitSounds[sampleScheduler.NextHitSoundIndex] = hs;
sampleScheduler.NextHitSoundIndex++;
}
else if (hs.Time < logicalTime - trigger_tolerance)
{
// 已错过(比如用户 Seek
hs.HasTriggered = true;
sampleScheduler.ScheduledHitSounds[sampleScheduler.NextHitSoundIndex] = hs;
sampleScheduler.NextHitSoundIndex++;
}
else break;
}
// 同样优化 storyboard samples
sampleScheduler.NextStoryboardSampleIndex = findNextValidIndex(sampleScheduler.ScheduledStoryboardSamples, sampleScheduler.NextStoryboardSampleIndex,
logicalTimeForEvents - trigger_tolerance);
while (withinWindow && sampleScheduler.NextStoryboardSampleIndex < sampleScheduler.ScheduledStoryboardSamples.Count)
{
var sb = sampleScheduler.ScheduledStoryboardSamples[sampleScheduler.NextStoryboardSampleIndex];
if (sb.HasTriggered)
{
sampleScheduler.NextStoryboardSampleIndex++;
continue;
}
if (sb.Time > logicalTime + trigger_tolerance) break;
if (Math.Abs(sb.Time - logicalTime) <= trigger_tolerance)
{
triggerStoryboardSample(sb.Sample);
sb.HasTriggered = true;
sampleScheduler.ScheduledStoryboardSamples[sampleScheduler.NextStoryboardSampleIndex] = sb;
sampleScheduler.NextStoryboardSampleIndex++;
}
else if (sb.Time < logicalTime - trigger_tolerance)
{
sb.HasTriggered = true;
sampleScheduler.ScheduledStoryboardSamples[sampleScheduler.NextStoryboardSampleIndex] = sb;
sampleScheduler.NextStoryboardSampleIndex++;
}
else break;
}
cleanupInactiveChannels();
playback.LastTrackTime = logicalTimeForEvents;
}
private void cleanupInactiveChannels()
{
for (int i = sampleScheduler.ActiveChannels.Count - 1; i >= 0; i--)
{
var channel = sampleScheduler.ActiveChannels[i];
if (channel.Playing)
continue;
try
{
if (!channel.IsDisposed && !channel.ManualFree)
channel.Dispose();
}
catch
{
// Ignore disposal errors.
}
sampleScheduler.ActiveChannels.RemoveAt(i);
}
}
private void triggerHitSound(HitSampleInfo[] samples)
{
if (samples.Length == 0) return;
try
{
foreach (var info in samples)
{
// bool playedAny = false;
foreach (var sample in fetchSamplesForInfo(info))
{
if (sample == null) continue;
var channelInner = sample.GetChannel();
// 同上:仅当命中对象样本显式给出音量 (>0) 时才应用;否则保持默认以跟随系统设置。
if (info.Volume > 0)
{
double volInner = Math.Clamp(info.Volume / 100.0, 0, 1);
channelInner.Volume.Value = (float)volInner;
}
channelInner.Play();
sampleScheduler.ActiveChannels.Add(channelInner);
break; // 只需播放命中链中的首个可用样本
}
}
}
catch (Exception ex)
{
Logger.Log($"EzPreviewTrackManager: triggerHitSound error: {ex}");
}
}
// 多级检索:谱面 skin -> 全局 skinSource -> sampleStore (LookupNames) -> Gameplay/ 回退
private IEnumerable<ISample?> fetchSamplesForInfo(HitSampleInfo info, bool preloadOnly = false)
{
// 1. 谱面皮肤
var s = currentBeatmap?.Skin.GetSample(info);
if (s != null) yield return s;
// 2. 全局皮肤源
var global = skinSource.GetSample(info);
if (global != null) yield return global;
// 3. LookupNames 走样本库
foreach (string name in info.LookupNames)
{
// LookupNames 通常包含 Gameplay/ 前缀;若没有尝试补全
ISample? storeSample = sampleStore.Get(name) ?? sampleStore.Get($"Gameplay/{name}");
if (storeSample != null) yield return storeSample;
}
// 4. 兜底(兼容 legacy 组合)
yield return sampleStore.Get($"Gameplay/{info.Bank}-{info.Name}");
}
private void triggerStoryboardSample(StoryboardSampleInfo sampleInfo)
{
try
{
var (sample, _, tried) = fetchStoryboardSample(sampleInfo);
if (sample == null)
{
// Logger.Log($"EzPreviewTrackManager: Miss storyboard sample {sampleInfo.Path} (tried: {string.Join("|", tried)})", LoggingTarget.Runtime);
return;
}
var channel = sample.GetChannel();
// 仅在谱面 Storyboard 显式指定音量 (>0) 时应用相对缩放;否则保持默认,完全跟随系统全局音量/效果音量设置。
if (sampleInfo.Volume > 0)
{
double vol = Math.Clamp(sampleInfo.Volume / 100.0, 0, 1);
channel.Volume.Value = (float)vol;
}
channel.Play();
sampleScheduler.ActiveChannels.Add(channel);
// Logger.Log($"EzPreviewTrackManager: Played storyboard sample {sampleInfo.Path} <- {chosenKey}");
}
catch (Exception ex)
{
Logger.Log($"EzPreviewTrackManager: triggerStoryboardSample error: {ex}");
}
}
/// <summary>
/// 统一 storyboard 样本获取逻辑。返回 (sample, 命中的key, 尝试列表)
/// 顺序:缓存 -> beatmap skin -> 全局 skin -> sampleStore.LookupNames -> sampleStore 原Path 变体
/// </summary>
private (ISample? sample, string chosenKey, List<string> tried) fetchStoryboardSample(StoryboardSampleInfo info, bool preload = false)
{
var tried = new List<string>();
string normalizedPath = info.Path.Replace('\\', '/');
// 1. 缓存
if (sampleScheduler.StoryboardSampleCache.TryGetValue(normalizedPath, out var cached) && cached != null)
return (cached, normalizedPath + "(cache)", tried);
ISample? selected = null;
string chosenKey = string.Empty;
void consider(ISample? s, string key)
{
if (s != null && selected == null)
{
selected = s;
chosenKey = key;
}
tried.Add(key);
}
// 2. beatmap skin
// StoryboardSampleInfo 实现 ISampleInfo直接走 Skin.GetSample 会使用其 LookupNames
var beatmapSkinSample = currentBeatmap?.Skin.GetSample(info);
consider(beatmapSkinSample, "beatmapSkin:" + normalizedPath);
// 3. 全局皮肤
var globalSkinSample = skinSource.GetSample(info);
consider(globalSkinSample, "globalSkin:" + normalizedPath);
// 4. LookupNames in sampleStore
foreach (string name in info.LookupNames)
{
string key = name.Replace('\\', '/');
var s = sampleStore.Get(key);
consider(s, "store:" + key);
if (selected != null) break;
}
// 5. 额外尝试:去扩展名或补 wav/mp3 (有些 beatmap 在 LookupNames 第二项无扩展)
if (selected == null)
{
string withoutExt = Path.ChangeExtension(normalizedPath, null);
foreach (string ext in new[] { ".wav", ".ogg", ".mp3" })
{
var s = sampleStore.Get(withoutExt + ext);
consider(s, "store-extra:" + withoutExt + ext);
if (selected != null) break;
}
}
// 6. 写缓存(即使 null 也缓存,避免重复磁盘尝试;预加载阶段写入,触发阶段复用)
sampleScheduler.StoryboardSampleCache[normalizedPath] = selected;
return (selected, chosenKey, tried);
}
private void clearEnhancedElements()
{
// 停止并释放所有仍在播放的样本通道,避免依赖最终化器来回收短期通道
foreach (var channel in sampleScheduler.ActiveChannels)
{
try
{
channel.Stop();
}
catch
{
}
try
{
if (!channel.IsDisposed && !channel.ManualFree)
channel.Dispose();
}
catch
{
}
}
sampleScheduler.Reset();
playback.ShortBgmOneShotMode = false;
playback.ShortBgmMutedAfterFirstLoop = false;
// 避免在非更新线程直接操作 InternalChildren 导致 InvalidThreadForMutationException
Schedule(() => audioContainer.Clear());
}
private struct ScheduledHitSound
{
public double Time;
public HitSampleInfo[] Samples;
public bool HasTriggered;
}
private struct ScheduledStoryboardSample
{
public double Time;
public StoryboardSampleInfo Sample;
public bool HasTriggered;
}
// 二分查找辅助方法:找到第一个 Time >= minTime 的索引
private static int findNextValidIndex(List<ScheduledHitSound> list, int startIndex, double minTime)
{
int low = startIndex, high = list.Count - 1;
while (low <= high)
{
int mid = (low + high) / 2;
double time = list[mid].Time;
if (time < minTime)
low = mid + 1;
else
high = mid - 1;
}
return low;
}
private static int findNextValidIndex(List<ScheduledStoryboardSample> list, int startIndex, double minTime)
{
int low = startIndex, high = list.Count - 1;
while (low <= high)
{
int mid = (low + high) / 2;
double time = list[mid].Time;
if (time < minTime)
low = mid + 1;
else
high = mid - 1;
}
return low;
}
private bool legacyTrackLogicalTime(out double logicalTime, out bool inBreak)
{
double physicalTime = currentTrack?.CurrentTime ?? 0;
// 回绕检测阈值200ms避免因 Seek 导致的误判
if (physicalTime + 200 < playback.LastTrackTime)
{
playback.LegacyLoopCount++;
playback.LegacyLogicalOffset = playback.LegacyLoopCount * playback.LoopSegmentLength;
if (playback.ShortBgmOneShotMode && !playback.ShortBgmMutedAfterFirstLoop && currentTrack != null)
{
currentTrack.Volume.Value = 0f;
playback.ShortBgmMutedAfterFirstLoop = true;
}
}
logicalTime = physicalTime + playback.LegacyLogicalOffset;
inBreak = false;
return true;
}
private sealed class PlaybackState
{
public bool IsPlaying;
public double PreviewStartTime;
public double PreviewEndTime;
public double LastTrackTime;
public double LoopSegmentLength;
public int LegacyLoopCount;
public double LegacyLogicalOffset;
public bool ShortBgmOneShotMode;
public bool ShortBgmMutedAfterFirstLoop;
public void ResetPlaybackProgress()
{
LastTrackTime = 0;
LegacyLoopCount = 0;
LegacyLogicalOffset = 0;
}
}
private sealed class SampleSchedulerState
{
public readonly List<ScheduledHitSound> ScheduledHitSounds = new List<ScheduledHitSound>();
public readonly List<ScheduledStoryboardSample> ScheduledStoryboardSamples = new List<ScheduledStoryboardSample>();
public readonly Dictionary<string, ISample?> StoryboardSampleCache = new Dictionary<string, ISample?>();
public readonly List<SampleChannel> ActiveChannels = new List<SampleChannel>();
public int NextHitSoundIndex;
public int NextStoryboardSampleIndex;
public void ResetIndices()
{
NextHitSoundIndex = 0;
NextStoryboardSampleIndex = 0;
}
public void Reset()
{
ActiveChannels.Clear();
ScheduledHitSounds.Clear();
ScheduledStoryboardSamples.Clear();
StoryboardSampleCache.Clear();
ResetIndices();
}
}
protected virtual Track? CreateTrack(IWorkingBeatmap beatmap, out bool ownsTrack)
{
ownsTrack = false;
return beatmap.Track;
}
}
}