From b981b5b56db3bceaf327b1905e98a0bb06f0ffdc Mon Sep 17 00:00:00 2001
From: LA <1245661240@qq.com>
Date: Fri, 16 Jan 2026 18:26:00 +0800
Subject: [PATCH] =?UTF-8?q?=E5=A4=A7=E8=8C=83=E5=9B=B4=E9=87=8D=E6=9E=84?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../Audio/EzLatency/BasicEzLatencyTracker.cs | 83 ----
.../Audio/EzLatency/EzLatencyAnalyzer.cs | 162 --------
.../Audio/EzLatency/EzLatencyCore.cs | 375 ++++++++++++++++++
.../Audio/EzLatency/EzLatencyManager.cs | 44 +-
.../Audio/EzLatency/EzLatencyRecord.cs | 34 --
.../Audio/EzLatency/EzLatencyService.cs | 46 ---
.../Audio/EzLatency/EzLatencyStruct.cs | 45 ---
.../Audio/EzLatency/EzLoggerAdapter.cs | 83 ----
.../Audio/EzLatency/IEzLatencyLogger.cs | 16 -
.../Audio/EzLatency/IEzLatencyPlayback.cs | 12 -
.../Audio/EzLatency/IEzLatencyTracker.cs | 13 -
.../Audio/Mixing/Bass/BassAudioMixer.cs | 42 +-
osu.Framework/Audio/Sample/SampleChannel.cs | 9 +
osu.Framework/Audio/Track/TrackBass.cs | 13 +
.../Input/PassThroughInputManager.cs | 46 ++-
15 files changed, 513 insertions(+), 510 deletions(-)
delete mode 100644 osu.Framework/Audio/EzLatency/BasicEzLatencyTracker.cs
delete mode 100644 osu.Framework/Audio/EzLatency/EzLatencyAnalyzer.cs
create mode 100644 osu.Framework/Audio/EzLatency/EzLatencyCore.cs
delete mode 100644 osu.Framework/Audio/EzLatency/EzLatencyRecord.cs
delete mode 100644 osu.Framework/Audio/EzLatency/EzLatencyService.cs
delete mode 100644 osu.Framework/Audio/EzLatency/EzLatencyStruct.cs
delete mode 100644 osu.Framework/Audio/EzLatency/EzLoggerAdapter.cs
delete mode 100644 osu.Framework/Audio/EzLatency/IEzLatencyLogger.cs
delete mode 100644 osu.Framework/Audio/EzLatency/IEzLatencyPlayback.cs
delete mode 100644 osu.Framework/Audio/EzLatency/IEzLatencyTracker.cs
diff --git a/osu.Framework/Audio/EzLatency/BasicEzLatencyTracker.cs b/osu.Framework/Audio/EzLatency/BasicEzLatencyTracker.cs
deleted file mode 100644
index ee75955de..000000000
--- a/osu.Framework/Audio/EzLatency/BasicEzLatencyTracker.cs
+++ /dev/null
@@ -1,83 +0,0 @@
-namespace osu.Framework.Audio.EzLatency
-{
- #nullable disable
- using System;
-
- ///
- /// Basic tracker that exposes an API to record measurements.
- /// This implementation does not perform audio operations; it merely collects and forwards measurements.
- ///
- public class BasicEzLatencyTracker : IEzLatencyTracker
- {
- public event Action OnMeasurement;
-
- private int sampleRate = 48000;
- private readonly EzLatencyAnalyzer analyzer;
-
- public BasicEzLatencyTracker()
- {
- analyzer = new EzLatencyAnalyzer();
- analyzer.OnNewRecord += record => OnMeasurement?.Invoke(record);
- }
-
- public void Start()
- {
- analyzer.Enabled = true;
- }
-
- public void Stop()
- {
- analyzer.Enabled = false;
- }
-
- public void SetSampleRate(int sampleRate) => this.sampleRate = sampleRate;
-
- public void PushMeasurement(EzLatencyRecord record) => OnMeasurement?.Invoke(record);
-
- public void Dispose()
- {
- analyzer.Enabled = false;
- analyzer.OnNewRecord -= record => OnMeasurement?.Invoke(record);
- }
-
- ///
- /// 记录输入数据
- ///
- /// 输入时间
- /// 按键值
- public void RecordInputData(double inputTime, object keyValue = null)
- {
- analyzer.RecordInputData(inputTime, keyValue);
- }
-
- ///
- /// 记录判定数据
- ///
- /// 判定时间
- public void RecordJudgeData(double judgeTime)
- {
- analyzer.RecordJudgeData(judgeTime);
- }
-
- ///
- /// 记录播放数据
- ///
- /// 播放时间
- public void RecordPlaybackData(double playbackTime)
- {
- analyzer.RecordPlaybackData(playbackTime);
- }
-
- ///
- /// 记录硬件数据
- ///
- /// 驱动时间
- /// 输出硬件时间
- /// 输入硬件时间
- /// 延迟差异
- public void RecordHardwareData(double driverTime, double outputHardwareTime, double inputHardwareTime, double latencyDifference)
- {
- analyzer.RecordHardwareData(driverTime, outputHardwareTime, inputHardwareTime, latencyDifference);
- }
- }
-}
\ No newline at end of file
diff --git a/osu.Framework/Audio/EzLatency/EzLatencyAnalyzer.cs b/osu.Framework/Audio/EzLatency/EzLatencyAnalyzer.cs
deleted file mode 100644
index 34cdea6e8..000000000
--- a/osu.Framework/Audio/EzLatency/EzLatencyAnalyzer.cs
+++ /dev/null
@@ -1,162 +0,0 @@
-// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
-// See the LICENCE file in the repository root for full licence text.
-
-using System;
-using System.Diagnostics;
-using osu.Framework.Logging;
-
-namespace osu.Framework.Audio.EzLatency
-{
- ///
- /// 延迟检测管理器,负责协调输入和硬件数据的收集
- ///
- public class EzLatencyAnalyzer
- {
- private readonly Stopwatch stopwatch;
-
- ///
- /// 是否启用延迟测量
- ///
- public bool Enabled { get; set; } = false;
-
- ///
- /// 当有新的延迟记录时触发
- ///
- public event Action? OnNewRecord;
-
- // 当前收集的数据
- private EzLatencyInputData currentInputData;
- private EzLatencyHardwareData currentHardwareData;
-
- public EzLatencyAnalyzer()
- {
- stopwatch = Stopwatch.StartNew();
- }
-
- ///
- /// 记录输入数据
- ///
- /// 输入时间
- /// 按键值
- public void RecordInputData(double inputTime, object? keyValue = null)
- {
- if (!Enabled) return;
-
- currentInputData = new EzLatencyInputData
- {
- InputTime = inputTime,
- KeyValue = keyValue,
- JudgeTime = currentInputData.JudgeTime, // 保持已有的值
- PlaybackTime = currentInputData.PlaybackTime // 保持已有的值
- };
- }
-
- ///
- /// 记录判定数据
- ///
- /// 判定时间
- public void RecordJudgeData(double judgeTime)
- {
- if (!Enabled) return;
-
- currentInputData = new EzLatencyInputData
- {
- InputTime = currentInputData.InputTime,
- KeyValue = currentInputData.KeyValue,
- JudgeTime = judgeTime,
- PlaybackTime = currentInputData.PlaybackTime
- };
- }
-
- ///
- /// 记录播放数据
- ///
- /// 播放时间
- public void RecordPlaybackData(double playbackTime)
- {
- if (!Enabled) return;
-
- currentInputData = new EzLatencyInputData
- {
- InputTime = currentInputData.InputTime,
- KeyValue = currentInputData.KeyValue,
- JudgeTime = currentInputData.JudgeTime,
- PlaybackTime = playbackTime
- };
-
- // 尝试生成完整记录
- TryGenerateCompleteRecord();
- }
-
- ///
- /// 记录硬件数据
- ///
- /// 驱动时间
- /// 输出硬件时间
- /// 输入硬件时间
- /// 延迟差异
- public void RecordHardwareData(double driverTime, double outputHardwareTime, double inputHardwareTime, double latencyDifference)
- {
- if (!Enabled) return;
-
- currentHardwareData = new EzLatencyHardwareData
- {
- DriverTime = driverTime,
- OutputHardwareTime = outputHardwareTime,
- InputHardwareTime = inputHardwareTime,
- LatencyDifference = latencyDifference
- };
-
- // 尝试生成完整记录
- TryGenerateCompleteRecord();
- }
-
- ///
- /// 尝试生成完整的延迟记录(当输入数据和硬件数据都有效时)
- ///
- private void TryGenerateCompleteRecord()
- {
- if (currentInputData.IsValid && currentHardwareData.IsValid)
- {
- var record = new EzLatencyRecord
- {
- Timestamp = DateTimeOffset.Now,
- InputTime = currentInputData.InputTime,
- JudgeTime = currentInputData.JudgeTime,
- PlaybackTime = currentInputData.PlaybackTime,
- DriverTime = currentHardwareData.DriverTime,
- OutputHardwareTime = currentHardwareData.OutputHardwareTime,
- InputHardwareTime = currentHardwareData.InputHardwareTime,
- LatencyDifference = currentHardwareData.LatencyDifference,
- MeasuredMs = currentInputData.PlaybackTime - currentInputData.InputTime,
- Note = "complete-latency-measurement",
- InputData = currentInputData,
- HardwareData = currentHardwareData
- };
-
- // 触发事件
- try
- {
- OnNewRecord?.Invoke(record);
-
- // 同时推送到中央服务
- EzLatencyService.Instance.PushRecord(record);
- }
- catch (Exception ex)
- {
- Logger.Log($"延迟记录事件处理出错: {ex.Message}", LoggingTarget.Runtime, LogLevel.Error);
- }
-
- // 清空已使用的数据,等待下一轮收集
- currentInputData = default;
- currentHardwareData = default;
- }
- }
-
- ///
- /// 获取当前时间戳(毫秒)
- ///
- /// 当前时间戳
- public double GetCurrentTimestamp() => stopwatch.Elapsed.TotalMilliseconds;
- }
-}
\ No newline at end of file
diff --git a/osu.Framework/Audio/EzLatency/EzLatencyCore.cs b/osu.Framework/Audio/EzLatency/EzLatencyCore.cs
new file mode 100644
index 000000000..6fe15ec72
--- /dev/null
+++ b/osu.Framework/Audio/EzLatency/EzLatencyCore.cs
@@ -0,0 +1,375 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System;
+using System.IO;
+using System.Linq;
+using System.Collections.Generic;
+using System.Diagnostics;
+using osu.Framework.Logging;
+using osu.Framework.Threading;
+
+namespace osu.Framework.Audio.EzLatency
+{
+ #nullable disable
+
+ // --- Structures and Record -------------------------------------------------
+
+ public struct EzLatencyInputData
+ {
+ public double InputTime;
+ public object? KeyValue;
+ public double JudgeTime;
+ public double PlaybackTime;
+ public bool IsValid => InputTime > 0 && JudgeTime > 0 && PlaybackTime > 0;
+ }
+
+ public struct EzLatencyHardwareData
+ {
+ public double DriverTime;
+ public double OutputHardwareTime;
+ public double InputHardwareTime;
+ public double LatencyDifference;
+ public bool IsValid => OutputHardwareTime > 0 && DriverTime > 0;
+ }
+
+ public class EzLatencyRecord
+ {
+ public DateTimeOffset Timestamp { get; set; }
+ public double MeasuredMs { get; set; }
+ public string Note { get; set; }
+
+ public double InputTime { get; set; }
+ public double JudgeTime { get; set; }
+ public double PlaybackTime { get; set; }
+ public double DriverTime { get; set; }
+ public double OutputHardwareTime { get; set; }
+ public double InputHardwareTime { get; set; }
+ public double LatencyDifference { get; set; }
+
+ public EzLatencyInputData InputData { get; set; }
+ public EzLatencyHardwareData HardwareData { get; set; }
+
+ public bool IsComplete => InputData.IsValid && HardwareData.IsValid;
+ }
+
+ public enum AudioOutputMode
+ {
+ Default,
+ WasapiShared,
+ WasapiExclusive,
+ Asio,
+ }
+
+ // --- Analyzer --------------------------------------------------------------
+
+ public class EzLatencyAnalyzer
+ {
+ private readonly Stopwatch stopwatch;
+ public bool Enabled { get; set; } = false;
+ public event Action? OnNewRecord;
+
+ private EzLatencyInputData currentInputData;
+ private EzLatencyHardwareData currentHardwareData;
+ private double recordStartTime;
+ private const double timeout_ms = 5000;
+
+ public EzLatencyAnalyzer()
+ {
+ stopwatch = Stopwatch.StartNew();
+ }
+
+ public void RecordInputData(double inputTime, object? keyValue = null)
+ {
+ if (!Enabled) return;
+ if (currentInputData.InputTime > 0)
+ {
+ currentInputData = default;
+ currentHardwareData = default;
+ }
+
+ currentInputData.InputTime = inputTime;
+ currentInputData.KeyValue = keyValue;
+ recordStartTime = stopwatch.Elapsed.TotalMilliseconds;
+ }
+
+ public void RecordJudgeData(double judgeTime)
+ {
+ if (!Enabled) return;
+ currentInputData.JudgeTime = judgeTime;
+ checkTimeout();
+ }
+
+ public void RecordPlaybackData(double playbackTime)
+ {
+ if (!Enabled) return;
+ currentInputData.PlaybackTime = playbackTime;
+ tryGenerateCompleteRecord();
+ }
+
+ public void RecordHardwareData(double driverTime, double outputHardwareTime, double inputHardwareTime, double latencyDifference)
+ {
+ if (!Enabled) return;
+ currentHardwareData = new EzLatencyHardwareData
+ {
+ DriverTime = driverTime,
+ OutputHardwareTime = outputHardwareTime,
+ InputHardwareTime = inputHardwareTime,
+ LatencyDifference = latencyDifference
+ };
+
+ tryGenerateCompleteRecord();
+ }
+
+ private void tryGenerateCompleteRecord()
+ {
+ if (!currentInputData.IsValid || !currentHardwareData.IsValid)
+ {
+ checkTimeout();
+ return;
+ }
+
+ var record = new EzLatencyRecord
+ {
+ Timestamp = DateTimeOffset.Now,
+ InputTime = currentInputData.InputTime,
+ JudgeTime = currentInputData.JudgeTime,
+ PlaybackTime = currentInputData.PlaybackTime,
+ DriverTime = currentHardwareData.DriverTime,
+ OutputHardwareTime = currentHardwareData.OutputHardwareTime,
+ InputHardwareTime = currentHardwareData.InputHardwareTime,
+ LatencyDifference = currentHardwareData.LatencyDifference,
+ MeasuredMs = currentInputData.PlaybackTime - currentInputData.InputTime,
+ Note = "complete-latency-measurement",
+ InputData = currentInputData,
+ HardwareData = currentHardwareData
+ };
+
+ try
+ {
+ OnNewRecord?.Invoke(record);
+ EzLatencyService.Instance.PushRecord(record);
+ Logger.Log(string.Format("EzLatency 完整记录已生成: Input→Playback={0:F2}ms", record.PlaybackTime - record.InputTime), LoggingTarget.Runtime, LogLevel.Debug);
+ }
+ catch (Exception ex)
+ {
+ Logger.Log(string.Format("延迟记录事件处理出错: {0}", ex.Message), LoggingTarget.Runtime, LogLevel.Error);
+ }
+
+ ClearCurrentData();
+ }
+
+ public double GetCurrentTimestamp() => stopwatch.Elapsed.TotalMilliseconds;
+
+ private void checkTimeout()
+ {
+ if (recordStartTime > 0)
+ {
+ double elapsed = stopwatch.Elapsed.TotalMilliseconds - recordStartTime;
+ if (elapsed > timeout_ms)
+ {
+ Logger.Log(string.Format("EzLatency 数据收集超时 ({0:F0}ms),清除旧数据", elapsed), LoggingTarget.Runtime, LogLevel.Debug);
+ ClearCurrentData();
+ }
+ }
+ }
+
+ public void ClearCurrentData()
+ {
+ currentInputData = default;
+ currentHardwareData = default;
+ recordStartTime = 0;
+ }
+ }
+
+ // --- Service ---------------------------------------------------------------
+
+ public class EzLatencyService
+ {
+ private static readonly Lazy lazy = new Lazy(() => new EzLatencyService());
+ public static EzLatencyService Instance => lazy.Value;
+ private EzLatencyService() { }
+
+ public void PushRecord(EzLatencyRecord record)
+ {
+ try { OnMeasurement?.Invoke(record); } catch (Exception ex) { Logger.Log($"EzLatencyService.PushRecord 异常: {ex.Message}", LoggingTarget.Runtime, LogLevel.Error); }
+ }
+
+ public event Action OnMeasurement;
+ }
+
+ // --- Logger types ---------------------------------------------------------
+
+ public interface IEzLatencyLogger : IDisposable
+ {
+ void Log(EzLatencyRecord record);
+ void Flush();
+ event Action OnRecord;
+ }
+
+ public class EzLoggerAdapter : IEzLatencyLogger
+ {
+ private readonly Scheduler scheduler;
+ private readonly string filePath;
+ private StreamWriter fileWriter;
+ public event Action OnRecord;
+
+ public EzLoggerAdapter(Scheduler scheduler = null, string filePath = null)
+ {
+ this.scheduler = scheduler;
+ this.filePath = filePath;
+ if (!string.IsNullOrEmpty(filePath))
+ {
+ try { fileWriter = new StreamWriter(File.Open(filePath, FileMode.Append, FileAccess.Write, FileShare.Read)) { AutoFlush = true }; }
+ catch (Exception ex) { Logger.Log($"EzLoggerAdapter: failed to open file {filePath}: {ex.Message}", LoggingTarget.Runtime, LogLevel.Error); }
+ }
+ }
+
+ public void Log(EzLatencyRecord record)
+ {
+ try { if (scheduler != null) scheduler.Add(() => OnRecord?.Invoke(record)); else OnRecord?.Invoke(record); } catch { }
+ try { string line = $"[{record.Timestamp:O}] {record.MeasuredMs} ms - {record.Note}"; fileWriter?.WriteLine(line); } catch { }
+ }
+
+ public void Flush() { try { fileWriter?.Flush(); } catch { } }
+ public void Dispose() { try { fileWriter?.Dispose(); } catch { } }
+ }
+
+ // --- Interfaces & basic tracker ------------------------------------------
+
+ public interface IEzLatencyTracker : IDisposable
+ {
+ void Start();
+ void Stop();
+ event Action OnMeasurement;
+ void SetSampleRate(int sampleRate);
+ }
+
+ public interface IEzLatencyPlayback : IDisposable
+ {
+ void PlayTestTone();
+ void StopTestTone();
+ void SetSampleRate(int sampleRate);
+ }
+
+ public class BasicEzLatencyTracker : IEzLatencyTracker
+ {
+ public event Action OnMeasurement;
+ private readonly EzLatencyAnalyzer analyzer;
+ private readonly Action recordHandler;
+
+ public BasicEzLatencyTracker()
+ {
+ analyzer = new EzLatencyAnalyzer();
+ recordHandler = record => OnMeasurement?.Invoke(record);
+ analyzer.OnNewRecord += recordHandler;
+ }
+
+ public void Start() { analyzer.Enabled = true; }
+ public void Stop() { analyzer.Enabled = false; }
+ public void SetSampleRate(int sampleRate) { }
+ public void PushMeasurement(EzLatencyRecord record) => OnMeasurement?.Invoke(record);
+
+ public void Dispose()
+ {
+ analyzer.Enabled = false;
+ if (recordHandler != null) analyzer.OnNewRecord -= recordHandler;
+ }
+
+ public void RecordInputData(double inputTime, object keyValue = null) => analyzer.RecordInputData(inputTime, keyValue);
+ public void RecordJudgeData(double judgeTime) => analyzer.RecordJudgeData(judgeTime);
+ public void RecordPlaybackData(double playbackTime) => analyzer.RecordPlaybackData(playbackTime);
+ public void RecordHardwareData(double driverTime, double outputHardwareTime, double inputHardwareTime, double latencyDifference) => analyzer.RecordHardwareData(driverTime, outputHardwareTime, inputHardwareTime, latencyDifference);
+ }
+
+ // --- Statistics & Collector ----------------------------------------------
+
+ public class EzLatencyStatistics
+ {
+ public bool HasData => RecordCount > 0;
+ public int RecordCount { get; set; }
+ public double AvgInputToJudge { get; set; }
+ public double AvgInputToPlayback { get; set; }
+ public double AvgPlaybackToJudge { get; set; }
+ public double MinInputToJudge { get; set; }
+ public double MaxInputToJudge { get; set; }
+ public double AvgHardwareLatency { get; set; }
+ }
+
+ internal class EzLatencyCollector
+ {
+ private readonly List records = new List();
+ private readonly object lockObject = new object();
+
+ public void AddRecord(EzLatencyRecord record)
+ {
+ if (!record.IsComplete)
+ return;
+
+ lock (lockObject)
+ {
+ records.Add(record);
+ }
+ }
+
+ public void Clear()
+ {
+ lock (lockObject)
+ {
+ records.Clear();
+ }
+ }
+
+ public EzLatencyStatistics GetStatistics()
+ {
+ lock (lockObject)
+ {
+ if (records.Count == 0)
+ return new EzLatencyStatistics { RecordCount = 0 };
+
+ var inputToJudge = records.Select(r => (double)(r.JudgeTime - r.InputTime)).ToList();
+ var inputToPlayback = records.Select(r => (double)(r.PlaybackTime - r.InputTime)).ToList();
+ var playbackToJudge = records.Select(r => (double)(r.JudgeTime - r.PlaybackTime)).ToList();
+ var hardwareLatency = records.Select(r => (double)r.OutputHardwareTime).Where(h => h > 0).ToList();
+
+ return new EzLatencyStatistics
+ {
+ RecordCount = records.Count,
+ AvgInputToJudge = inputToJudge.Average(),
+ AvgInputToPlayback = inputToPlayback.Average(),
+ AvgPlaybackToJudge = playbackToJudge.Average(),
+ MinInputToJudge = inputToJudge.Min(),
+ MaxInputToJudge = inputToJudge.Max(),
+ AvgHardwareLatency = hardwareLatency.Count > 0 ? hardwareLatency.Average() : 0
+ };
+ }
+ }
+
+ public int Count
+ {
+ get
+ {
+ lock (lockObject)
+ {
+ return records.Count;
+ }
+ }
+ }
+ }
+
+ // --- Hardware timestamp provider (pluggable) -----------------------------
+
+ public interface IHwTimestampProvider
+ {
+ bool TryGetHardwareTimestamps(int channelHandle, out double driverTimeMs, out double outputHardwareTimeMs, out double inputHardwareTimeMs, out double latencyDifferenceMs);
+ }
+
+ public class NullHwTimestampProvider : IHwTimestampProvider
+ {
+ public bool TryGetHardwareTimestamps(int channelHandle, out double driverTimeMs, out double outputHardwareTimeMs, out double inputHardwareTimeMs, out double latencyDifferenceMs)
+ {
+ driverTimeMs = outputHardwareTimeMs = inputHardwareTimeMs = latencyDifferenceMs = 0;
+ return false;
+ }
+ }
+}
diff --git a/osu.Framework/Audio/EzLatency/EzLatencyManager.cs b/osu.Framework/Audio/EzLatency/EzLatencyManager.cs
index 845b55f4e..b84f54f69 100644
--- a/osu.Framework/Audio/EzLatency/EzLatencyManager.cs
+++ b/osu.Framework/Audio/EzLatency/EzLatencyManager.cs
@@ -11,12 +11,25 @@ namespace osu.Framework.Audio.EzLatency
///
public class EzLatencyManager
{
+ ///
+ /// Pluggable hardware timestamp provider. Defaults to a null provider which indicates no hardware timestamps available.
+ /// Override this with a platform-specific provider if desired.
+ ///
+ public static IHwTimestampProvider HwProvider = new NullHwTimestampProvider();
+
+ ///
+ /// Global lightweight manager instance for best-effort framework instrumentation.
+ /// Use this to record input/playback events from low-level framework code without resolving instances.
+ ///
+ public static readonly EzLatencyManager GLOBAL = new EzLatencyManager();
+
///
/// 延迟测试启用状态的绑定值,可与程序设置关联
///
public readonly BindableBool Enabled = new BindableBool(false);
private readonly EzLatencyAnalyzer analyzer;
+ private readonly EzLatencyCollector collector = new EzLatencyCollector();
public EzLatencyManager()
{
@@ -29,7 +42,19 @@ namespace osu.Framework.Audio.EzLatency
}, true);
// 将分析器的记录事件转发给外部
- analyzer.OnNewRecord += record => OnNewRecord?.Invoke(record);
+ analyzer.OnNewRecord += record =>
+ {
+ // add to collector for statistics
+ try
+ {
+ collector.AddRecord(record);
+ }
+ catch
+ {
+ }
+
+ OnNewRecord?.Invoke(record);
+ };
}
///
@@ -91,6 +116,23 @@ namespace osu.Framework.Audio.EzLatency
/// 当前时间戳(毫秒)
public double GetCurrentTimestamp() => analyzer.GetCurrentTimestamp();
+ ///
+ /// 获取聚合的延迟统计数据(best-effort)。
+ ///
+ public EzLatencyStatistics GetStatistics() => collector.GetStatistics();
+
+ ///
+ /// 清空统计收集器
+ ///
+ public void ClearStatistics() => collector.Clear();
+
+ ///
+ /// 已收集的完整记录数量
+ ///
+ public int RecordCount => collector.Count;
+
+ // (Statistics, collector, and hardware provider types are defined in EzLatencyCore.cs)
+
///
/// 在gameplay开始时启用延迟测试
///
diff --git a/osu.Framework/Audio/EzLatency/EzLatencyRecord.cs b/osu.Framework/Audio/EzLatency/EzLatencyRecord.cs
deleted file mode 100644
index a3ac9a347..000000000
--- a/osu.Framework/Audio/EzLatency/EzLatencyRecord.cs
+++ /dev/null
@@ -1,34 +0,0 @@
-// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
-// See the LICENCE file in the repository root for full licence text.
-
-using System;
-
-namespace osu.Framework.Audio.EzLatency
-{
-#nullable disable
- public class EzLatencyRecord
- {
- // 高级字段
- public DateTimeOffset Timestamp { get; set; }
- public double MeasuredMs { get; set; }
- public string Note { get; set; }
-
- // 低级字段
- public double InputTime { get; set; }
- public double JudgeTime { get; set; }
- public double PlaybackTime { get; set; }
- public double DriverTime { get; set; }
- public double OutputHardwareTime { get; set; }
- public double InputHardwareTime { get; set; }
- public double LatencyDifference { get; set; }
-
- // 可选的低级结构体,包含完整的输入和硬件数据
- public EzLatencyInputData InputData { get; set; }
- public EzLatencyHardwareData HardwareData { get; set; }
-
- ///
- /// 检查记录是否包含完整的输入和硬件数据
- ///
- public bool IsComplete => InputData.IsValid && HardwareData.IsValid;
- }
-}
\ No newline at end of file
diff --git a/osu.Framework/Audio/EzLatency/EzLatencyService.cs b/osu.Framework/Audio/EzLatency/EzLatencyService.cs
deleted file mode 100644
index 5804a6000..000000000
--- a/osu.Framework/Audio/EzLatency/EzLatencyService.cs
+++ /dev/null
@@ -1,46 +0,0 @@
-// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
-// See the LICENCE file in the repository root for full licence text.
-
-using System;
-
-namespace osu.Framework.Audio.EzLatency
-{
-#nullable disable
- ///
- /// Standalone singleton service which collects low-level framework latency records
- /// and exposes higher-level EzLatencyRecord events for consumers in the osu layer.
- /// This keeps AudioManager untouched and centralizes latency collection in one place.
- ///
- public class EzLatencyService
- {
- private static readonly Lazy lazy = new Lazy(() => new EzLatencyService());
-
- public static EzLatencyService Instance => lazy.Value;
-
- private EzLatencyService()
- {
- }
-
- ///
- /// Allows producers (framework playback module) to push a fully-populated record
- /// into the service for forwarding to osu layer subscribers.
- ///
- public void PushRecord(EzLatencyRecord record)
- {
- try
- {
- OnMeasurement?.Invoke(record);
- }
- catch (Exception)
- {
- // 记录异常但不抛出,以避免影响主程序流程
- }
- }
-
- ///
- /// Fired when a new high-level latency record is available.
- /// osu! layer should subscribe to this event and decide how to output logs.
- ///
- public event Action OnMeasurement;
- }
-}
\ No newline at end of file
diff --git a/osu.Framework/Audio/EzLatency/EzLatencyStruct.cs b/osu.Framework/Audio/EzLatency/EzLatencyStruct.cs
deleted file mode 100644
index 7b60f5456..000000000
--- a/osu.Framework/Audio/EzLatency/EzLatencyStruct.cs
+++ /dev/null
@@ -1,45 +0,0 @@
-// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
-// See the LICENCE file in the repository root for full licence text.
-
-namespace osu.Framework.Audio.EzLatency
-{
- ///
- /// EzOsuLatency 输入数据结构体
- ///
- public struct EzLatencyInputData
- {
- public double InputTime;
- public object KeyValue;
- public double JudgeTime;
- public double PlaybackTime;
-
- ///
- /// 检查输入数据是否完整
- ///
- public bool IsValid => InputTime > 0;
- }
-
- ///
- /// EzOsuLatency 硬件数据结构体
- ///
- public struct EzLatencyHardwareData
- {
- public double DriverTime;
- public double OutputHardwareTime;
- public double InputHardwareTime;
- public double LatencyDifference;
-
- ///
- /// 检查硬件数据是否完整
- ///
- public bool IsValid => OutputHardwareTime > 0 && DriverTime > 0;
- }
-
- public enum AudioOutputMode
- {
- Default,
- WasapiShared,
- WasapiExclusive,
- Asio,
- }
-}
\ No newline at end of file
diff --git a/osu.Framework/Audio/EzLatency/EzLoggerAdapter.cs b/osu.Framework/Audio/EzLatency/EzLoggerAdapter.cs
deleted file mode 100644
index e0d56572c..000000000
--- a/osu.Framework/Audio/EzLatency/EzLoggerAdapter.cs
+++ /dev/null
@@ -1,83 +0,0 @@
-// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
-// See the LICENCE file in the repository root for full licence text.
-
-using System;
-using System.IO;
-using osu.Framework.Logging;
-using osu.Framework.Threading;
-
-namespace osu.Framework.Audio.EzLatency
-{
-#nullable disable
- public class EzLoggerAdapter : IEzLatencyLogger
- {
- private readonly Scheduler scheduler;
- private readonly string filePath;
- private StreamWriter fileWriter;
-
- public event Action OnRecord;
-
- public EzLoggerAdapter(Scheduler scheduler = null, string filePath = null)
- {
- this.scheduler = scheduler;
- this.filePath = filePath;
-
- if (!string.IsNullOrEmpty(filePath))
- {
- try
- {
- fileWriter = new StreamWriter(File.Open(filePath, FileMode.Append, FileAccess.Write, FileShare.Read)) { AutoFlush = true };
- }
- catch (Exception ex)
- {
- Logger.Log($"EzLoggerAdapter: failed to open file {filePath}: {ex.Message}", LoggingTarget.Runtime, LogLevel.Error);
- }
- }
- }
-
- public void Log(EzLatencyRecord record)
- {
- // Raise OnRecord for consumers to handle (osu! will subscribe and use Logger there)
- try
- {
- if (scheduler != null)
- scheduler.Add(() => OnRecord?.Invoke(record));
- else
- OnRecord?.Invoke(record);
- }
- catch
- {
- }
-
- // Also write to file if enabled (diagnostic only)
- try
- {
- string line = $"[{record.Timestamp:O}] {record.MeasuredMs} ms - {record.Note}";
- fileWriter?.WriteLine(line);
- }
- catch
- {
- }
- }
-
- public void Flush()
- {
- try
- {
- fileWriter?.Flush();
- }
- catch
- {
- }
- }
-
- public void Dispose()
- {
- try
- {
- fileWriter?.Dispose();
- }
- catch { }
- }
- }
-}
diff --git a/osu.Framework/Audio/EzLatency/IEzLatencyLogger.cs b/osu.Framework/Audio/EzLatency/IEzLatencyLogger.cs
deleted file mode 100644
index 78a7434b9..000000000
--- a/osu.Framework/Audio/EzLatency/IEzLatencyLogger.cs
+++ /dev/null
@@ -1,16 +0,0 @@
-using System;
-
-namespace osu.Framework.Audio.EzLatency
-{
- public interface IEzLatencyLogger : IDisposable
- {
- void Log(EzLatencyRecord record);
- void Flush();
-
- ///
- /// Fired whenever a new latency record is logged.
- /// Consumers (e.g. osu! layer) should subscribe to this to perform application-level logging/output.
- ///
- event Action OnRecord;
- }
-}
diff --git a/osu.Framework/Audio/EzLatency/IEzLatencyPlayback.cs b/osu.Framework/Audio/EzLatency/IEzLatencyPlayback.cs
deleted file mode 100644
index 48fbf857c..000000000
--- a/osu.Framework/Audio/EzLatency/IEzLatencyPlayback.cs
+++ /dev/null
@@ -1,12 +0,0 @@
-namespace osu.Framework.Audio.EzLatency
-{
- #nullable disable
- using System;
-
- public interface IEzLatencyPlayback : IDisposable
- {
- void PlayTestTone();
- void StopTestTone();
- void SetSampleRate(int sampleRate);
- }
-}
diff --git a/osu.Framework/Audio/EzLatency/IEzLatencyTracker.cs b/osu.Framework/Audio/EzLatency/IEzLatencyTracker.cs
deleted file mode 100644
index f06320a25..000000000
--- a/osu.Framework/Audio/EzLatency/IEzLatencyTracker.cs
+++ /dev/null
@@ -1,13 +0,0 @@
-namespace osu.Framework.Audio.EzLatency
-{
-#nullable disable
- using System;
-
- public interface IEzLatencyTracker : IDisposable
- {
- void Start();
- void Stop();
- event Action OnMeasurement;
- void SetSampleRate(int sampleRate);
- }
-}
diff --git a/osu.Framework/Audio/Mixing/Bass/BassAudioMixer.cs b/osu.Framework/Audio/Mixing/Bass/BassAudioMixer.cs
index a36778f49..e98d21ea0 100644
--- a/osu.Framework/Audio/Mixing/Bass/BassAudioMixer.cs
+++ b/osu.Framework/Audio/Mixing/Bass/BassAudioMixer.cs
@@ -8,6 +8,7 @@ using System.Runtime.InteropServices;
using ManagedBass;
using ManagedBass.Mix;
using osu.Framework.Extensions.EnumExtensions;
+using osu.Framework.Audio.EzLatency;
using osu.Framework.Statistics;
namespace osu.Framework.Audio.Mixing.Bass
@@ -175,7 +176,9 @@ namespace osu.Framework.Audio.Mixing.Bass
/// If successful, the position is returned.
///
public long ChannelGetPosition(IBassAudioChannel channel, PositionFlags mode = PositionFlags.Bytes)
- => BassMix.ChannelGetPosition(channel.Handle, mode);
+ {
+ return BassMix.ChannelGetPosition(channel.Handle, mode);
+ }
///
/// Sets the playback position of a channel.
@@ -209,7 +212,9 @@ namespace osu.Framework.Audio.Mixing.Bass
/// What levels to retrieve.
/// true if successful, false otherwise.
public bool ChannelGetLevel(IBassAudioChannel channel, [In, Out] float[] levels, float length, LevelRetrievalFlags flags)
- => BassMix.ChannelGetLevel(channel.Handle, levels, length, flags) != -1;
+ {
+ return BassMix.ChannelGetLevel(channel.Handle, levels, length, flags) != -1;
+ }
///
/// Retrieves the immediate sample data (or an FFT representation of it) of a channel.
@@ -224,7 +229,9 @@ namespace osu.Framework.Audio.Mixing.Bass
/// When using the flag, the number of bytes in the channel's buffer is returned.
///
public int ChannelGetData(IBassAudioChannel channel, float[] buffer, int length)
- => BassMix.ChannelGetData(channel.Handle, buffer, length);
+ {
+ return BassMix.ChannelGetData(channel.Handle, buffer, length);
+ }
///
/// Sets up a synchroniser on a mixer source channel.
@@ -237,7 +244,9 @@ namespace osu.Framework.Audio.Mixing.Bass
/// User instance data to pass to the callback function.
/// If successful, then the new synchroniser's handle is returned, else 0 is returned. Use to get the error code.
public int ChannelSetSync(IBassAudioChannel channel, SyncFlags type, long parameter, SyncProcedure procedure, IntPtr user = default)
- => BassMix.ChannelSetSync(channel.Handle, type, parameter, procedure, user);
+ {
+ return BassMix.ChannelSetSync(channel.Handle, type, parameter, procedure, user);
+ }
///
/// Removes a synchroniser from a mixer source channel.
@@ -246,7 +255,9 @@ namespace osu.Framework.Audio.Mixing.Bass
/// Handle of the synchroniser to remove (return value of a previous call).
/// If successful, is returned, else is returned. Use to get the error code.
public bool ChannelRemoveSync(IBassAudioChannel channel, int sync)
- => BassMix.ChannelRemoveSync(channel.Handle, sync);
+ {
+ return BassMix.ChannelRemoveSync(channel.Handle, sync);
+ }
///
/// Frees a channel's resources.
@@ -330,7 +341,28 @@ namespace osu.Framework.Audio.Mixing.Bass
flags |= BassFlags.MixerChanPause;
if (BassMix.MixerAddChannel(Handle, channel.Handle, flags))
+ {
activeChannels.Add(channel);
+
+ // Best-effort: ask the pluggable hardware timestamp provider for timestamps for this channel
+ try
+ {
+ if (EzLatencyManager.HwProvider.TryGetHardwareTimestamps(channel.Handle, out double driverTimeMs, out double outputHardwareTimeMs, out double inputHardwareTimeMs, out double latencyDiff))
+ {
+ // Push the hardware data into the global manager (non-blocking and swallow any errors)
+ try
+ {
+ EzLatencyManager.GLOBAL.RecordHardwareData(driverTimeMs, outputHardwareTimeMs, inputHardwareTimeMs, latencyDiff);
+ }
+ catch
+ {
+ }
+ }
+ }
+ catch
+ {
+ }
+ }
}
///
diff --git a/osu.Framework/Audio/Sample/SampleChannel.cs b/osu.Framework/Audio/Sample/SampleChannel.cs
index 410c490f7..41a86b535 100644
--- a/osu.Framework/Audio/Sample/SampleChannel.cs
+++ b/osu.Framework/Audio/Sample/SampleChannel.cs
@@ -25,6 +25,15 @@ namespace osu.Framework.Audio.Sample
ObjectDisposedException.ThrowIf(IsDisposed, this);
Played = true;
+ try
+ {
+ // Best-effort: notify global latency manager that a sample playback was requested.
+ osu.Framework.Audio.EzLatency.EzLatencyManager.GLOBAL.RecordPlaybackEvent();
+ }
+ catch
+ {
+ }
+
OnPlay?.Invoke(this);
}
diff --git a/osu.Framework/Audio/Track/TrackBass.cs b/osu.Framework/Audio/Track/TrackBass.cs
index e7daa1abe..22999710c 100644
--- a/osu.Framework/Audio/Track/TrackBass.cs
+++ b/osu.Framework/Audio/Track/TrackBass.cs
@@ -10,6 +10,7 @@ using ManagedBass.Fx;
using osu.Framework.IO;
using System.Threading.Tasks;
using osu.Framework.Audio.Callbacks;
+using osu.Framework.Audio.EzLatency;
using osu.Framework.Audio.Mixing;
using osu.Framework.Audio.Mixing.Bass;
using osu.Framework.Extensions;
@@ -267,7 +268,19 @@ namespace osu.Framework.Audio.Track
public override Task StartAsync() => EnqueueAction(() =>
{
if (startInternal())
+ {
isRunning = isPlayed = true;
+
+ // Record playback event for latency measurement (best-effort, non-blocking)
+ try
+ {
+ EzLatencyManager.GLOBAL.RecordPlaybackEvent();
+ }
+ catch
+ {
+ // swallow to avoid impacting playback
+ }
+ }
});
private bool startInternal()
diff --git a/osu.Framework/Input/PassThroughInputManager.cs b/osu.Framework/Input/PassThroughInputManager.cs
index ea31dd815..f650ca759 100644
--- a/osu.Framework/Input/PassThroughInputManager.cs
+++ b/osu.Framework/Input/PassThroughInputManager.cs
@@ -12,6 +12,7 @@ using osu.Framework.Input.States;
using osuTK;
using osuTK.Input;
using JoystickState = osu.Framework.Input.States.JoystickState;
+using osu.Framework.Audio.EzLatency;
namespace osu.Framework.Input
{
@@ -149,8 +150,20 @@ namespace osu.Framework.Input
case KeyDownEvent keyDown:
if (keyDown.Repeat)
return false;
-
+ // Apply framework input state update
new KeyboardKeyInput(keyDown.Key, true).Apply(CurrentState, this);
+
+ // Record raw input timestamp at the earliest point possible for latency measurement.
+ try
+ {
+ // Use global manager to avoid needing to resolve instances here.
+ EzLatencyManager.GLOBAL.RecordInputEvent(keyDown.Key);
+ }
+ catch
+ {
+ // Swallow any errors to avoid affecting input handling.
+ }
+
break;
case KeyUpEvent keyUp:
@@ -233,20 +246,33 @@ namespace osu.Framework.Input
return;
var parentState = parentInputManager.CurrentState;
- var mouseDiff = (parentState?.Mouse?.Buttons ?? new ButtonStates()).EnumerateDifference(CurrentState.Mouse.Buttons);
- var keyDiff = (parentState?.Keyboard.Keys ?? new ButtonStates()).EnumerateDifference(CurrentState.Keyboard.Keys);
- var touchDiff = (parentState?.Touch ?? new TouchState()).EnumerateDifference(CurrentState.Touch);
- var joyButtonDiff = (parentState?.Joystick?.Buttons ?? new ButtonStates()).EnumerateDifference(CurrentState.Joystick.Buttons);
- var midiDiff = (parentState?.Midi?.Keys ?? new ButtonStates()).EnumerateDifference(CurrentState.Midi.Keys);
- var tabletPenDiff = (parentState?.Tablet?.PenButtons ?? new ButtonStates()).EnumerateDifference(CurrentState.Tablet.PenButtons);
- var tabletAuxiliaryDiff = (parentState?.Tablet?.AuxiliaryButtons ?? new ButtonStates()).EnumerateDifference(CurrentState.Tablet.AuxiliaryButtons);
+ var mouseButtons = parentState?.Mouse?.Buttons ?? new ButtonStates();
+ var mouseDiff = mouseButtons.EnumerateDifference(CurrentState.Mouse.Buttons);
+
+ var keyboardKeys = parentState?.Keyboard.Keys ?? new ButtonStates();
+ var keyDiff = keyboardKeys.EnumerateDifference(CurrentState.Keyboard.Keys);
+
+ var touches = parentState?.Touch ?? new TouchState();
+ var (touchesDeactivated, _) = touches.EnumerateDifference(CurrentState.Touch);
+
+ var joystickButtons = parentState?.Joystick?.Buttons ?? new ButtonStates();
+ var joyButtonDiff = joystickButtons.EnumerateDifference(CurrentState.Joystick.Buttons);
+
+ var midiKeys = parentState?.Midi?.Keys ?? new ButtonStates();
+ var midiDiff = midiKeys.EnumerateDifference(CurrentState.Midi.Keys);
+
+ var penButtons = parentState?.Tablet?.PenButtons ?? new ButtonStates();
+ var tabletPenDiff = penButtons.EnumerateDifference(CurrentState.Tablet.PenButtons);
+
+ var auxButtons = parentState?.Tablet?.AuxiliaryButtons ?? new ButtonStates();
+ var tabletAuxiliaryDiff = auxButtons.EnumerateDifference(CurrentState.Tablet.AuxiliaryButtons);
if (mouseDiff.Released.Length > 0)
new MouseButtonInput(mouseDiff.Released.Select(button => new ButtonInputEntry(button, false))).Apply(CurrentState, this);
foreach (var key in keyDiff.Released)
new KeyboardKeyInput(key, false).Apply(CurrentState, this);
- if (touchDiff.deactivated.Length > 0)
- new TouchInput(touchDiff.deactivated, false).Apply(CurrentState, this);
+ if (touchesDeactivated.Length > 0)
+ new TouchInput(touchesDeactivated, false).Apply(CurrentState, this);
foreach (var button in joyButtonDiff.Released)
new JoystickButtonInput(button, false).Apply(CurrentState, this);
foreach (var key in midiDiff.Released)