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)