大范围重构

This commit is contained in:
LA
2026-01-16 18:26:00 +08:00
parent 160593ddc9
commit b981b5b56d
15 changed files with 513 additions and 510 deletions

View File

@@ -1,83 +0,0 @@
namespace osu.Framework.Audio.EzLatency
{
#nullable disable
using System;
/// <summary>
/// Basic tracker that exposes an API to record measurements.
/// This implementation does not perform audio operations; it merely collects and forwards measurements.
/// </summary>
public class BasicEzLatencyTracker : IEzLatencyTracker
{
public event Action<EzLatencyRecord> 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);
}
/// <summary>
/// 记录输入数据
/// </summary>
/// <param name="inputTime">输入时间</param>
/// <param name="keyValue">按键值</param>
public void RecordInputData(double inputTime, object keyValue = null)
{
analyzer.RecordInputData(inputTime, keyValue);
}
/// <summary>
/// 记录判定数据
/// </summary>
/// <param name="judgeTime">判定时间</param>
public void RecordJudgeData(double judgeTime)
{
analyzer.RecordJudgeData(judgeTime);
}
/// <summary>
/// 记录播放数据
/// </summary>
/// <param name="playbackTime">播放时间</param>
public void RecordPlaybackData(double playbackTime)
{
analyzer.RecordPlaybackData(playbackTime);
}
/// <summary>
/// 记录硬件数据
/// </summary>
/// <param name="driverTime">驱动时间</param>
/// <param name="outputHardwareTime">输出硬件时间</param>
/// <param name="inputHardwareTime">输入硬件时间</param>
/// <param name="latencyDifference">延迟差异</param>
public void RecordHardwareData(double driverTime, double outputHardwareTime, double inputHardwareTime, double latencyDifference)
{
analyzer.RecordHardwareData(driverTime, outputHardwareTime, inputHardwareTime, latencyDifference);
}
}
}

View File

@@ -1,162 +0,0 @@
// 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.Diagnostics;
using osu.Framework.Logging;
namespace osu.Framework.Audio.EzLatency
{
/// <summary>
/// 延迟检测管理器,负责协调输入和硬件数据的收集
/// </summary>
public class EzLatencyAnalyzer
{
private readonly Stopwatch stopwatch;
/// <summary>
/// 是否启用延迟测量
/// </summary>
public bool Enabled { get; set; } = false;
/// <summary>
/// 当有新的延迟记录时触发
/// </summary>
public event Action<EzLatencyRecord>? OnNewRecord;
// 当前收集的数据
private EzLatencyInputData currentInputData;
private EzLatencyHardwareData currentHardwareData;
public EzLatencyAnalyzer()
{
stopwatch = Stopwatch.StartNew();
}
/// <summary>
/// 记录输入数据
/// </summary>
/// <param name="inputTime">输入时间</param>
/// <param name="keyValue">按键值</param>
public void RecordInputData(double inputTime, object? keyValue = null)
{
if (!Enabled) return;
currentInputData = new EzLatencyInputData
{
InputTime = inputTime,
KeyValue = keyValue,
JudgeTime = currentInputData.JudgeTime, // 保持已有的值
PlaybackTime = currentInputData.PlaybackTime // 保持已有的值
};
}
/// <summary>
/// 记录判定数据
/// </summary>
/// <param name="judgeTime">判定时间</param>
public void RecordJudgeData(double judgeTime)
{
if (!Enabled) return;
currentInputData = new EzLatencyInputData
{
InputTime = currentInputData.InputTime,
KeyValue = currentInputData.KeyValue,
JudgeTime = judgeTime,
PlaybackTime = currentInputData.PlaybackTime
};
}
/// <summary>
/// 记录播放数据
/// </summary>
/// <param name="playbackTime">播放时间</param>
public void RecordPlaybackData(double playbackTime)
{
if (!Enabled) return;
currentInputData = new EzLatencyInputData
{
InputTime = currentInputData.InputTime,
KeyValue = currentInputData.KeyValue,
JudgeTime = currentInputData.JudgeTime,
PlaybackTime = playbackTime
};
// 尝试生成完整记录
TryGenerateCompleteRecord();
}
/// <summary>
/// 记录硬件数据
/// </summary>
/// <param name="driverTime">驱动时间</param>
/// <param name="outputHardwareTime">输出硬件时间</param>
/// <param name="inputHardwareTime">输入硬件时间</param>
/// <param name="latencyDifference">延迟差异</param>
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();
}
/// <summary>
/// 尝试生成完整的延迟记录(当输入数据和硬件数据都有效时)
/// </summary>
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;
}
}
/// <summary>
/// 获取当前时间戳(毫秒)
/// </summary>
/// <returns>当前时间戳</returns>
public double GetCurrentTimestamp() => stopwatch.Elapsed.TotalMilliseconds;
}
}

View File

@@ -0,0 +1,375 @@
// 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.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<EzLatencyRecord>? 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<EzLatencyService> lazy = new Lazy<EzLatencyService>(() => 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<EzLatencyRecord> OnMeasurement;
}
// --- Logger types ---------------------------------------------------------
public interface IEzLatencyLogger : IDisposable
{
void Log(EzLatencyRecord record);
void Flush();
event Action<EzLatencyRecord> OnRecord;
}
public class EzLoggerAdapter : IEzLatencyLogger
{
private readonly Scheduler scheduler;
private readonly string filePath;
private StreamWriter fileWriter;
public event Action<EzLatencyRecord> 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<EzLatencyRecord> OnMeasurement;
void SetSampleRate(int sampleRate);
}
public interface IEzLatencyPlayback : IDisposable
{
void PlayTestTone();
void StopTestTone();
void SetSampleRate(int sampleRate);
}
public class BasicEzLatencyTracker : IEzLatencyTracker
{
public event Action<EzLatencyRecord> OnMeasurement;
private readonly EzLatencyAnalyzer analyzer;
private readonly Action<EzLatencyRecord> 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<EzLatencyRecord> records = new List<EzLatencyRecord>();
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;
}
}
}

View File

@@ -11,12 +11,25 @@ namespace osu.Framework.Audio.EzLatency
/// </summary>
public class EzLatencyManager
{
/// <summary>
/// Pluggable hardware timestamp provider. Defaults to a null provider which indicates no hardware timestamps available.
/// Override this with a platform-specific provider if desired.
/// </summary>
public static IHwTimestampProvider HwProvider = new NullHwTimestampProvider();
/// <summary>
/// Global lightweight manager instance for best-effort framework instrumentation.
/// Use this to record input/playback events from low-level framework code without resolving instances.
/// </summary>
public static readonly EzLatencyManager GLOBAL = new EzLatencyManager();
/// <summary>
/// 延迟测试启用状态的绑定值,可与程序设置关联
/// </summary>
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);
};
}
/// <summary>
@@ -91,6 +116,23 @@ namespace osu.Framework.Audio.EzLatency
/// <returns>当前时间戳(毫秒)</returns>
public double GetCurrentTimestamp() => analyzer.GetCurrentTimestamp();
/// <summary>
/// 获取聚合的延迟统计数据best-effort
/// </summary>
public EzLatencyStatistics GetStatistics() => collector.GetStatistics();
/// <summary>
/// 清空统计收集器
/// </summary>
public void ClearStatistics() => collector.Clear();
/// <summary>
/// 已收集的完整记录数量
/// </summary>
public int RecordCount => collector.Count;
// (Statistics, collector, and hardware provider types are defined in EzLatencyCore.cs)
/// <summary>
/// 在gameplay开始时启用延迟测试
/// </summary>

View File

@@ -1,34 +0,0 @@
// 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;
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; }
/// <summary>
/// 检查记录是否包含完整的输入和硬件数据
/// </summary>
public bool IsComplete => InputData.IsValid && HardwareData.IsValid;
}
}

View File

@@ -1,46 +0,0 @@
// 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;
namespace osu.Framework.Audio.EzLatency
{
#nullable disable
/// <summary>
/// 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.
/// </summary>
public class EzLatencyService
{
private static readonly Lazy<EzLatencyService> lazy = new Lazy<EzLatencyService>(() => new EzLatencyService());
public static EzLatencyService Instance => lazy.Value;
private EzLatencyService()
{
}
/// <summary>
/// Allows producers (framework playback module) to push a fully-populated record
/// into the service for forwarding to osu layer subscribers.
/// </summary>
public void PushRecord(EzLatencyRecord record)
{
try
{
OnMeasurement?.Invoke(record);
}
catch (Exception)
{
// 记录异常但不抛出,以避免影响主程序流程
}
}
/// <summary>
/// Fired when a new high-level latency record is available.
/// osu! layer should subscribe to this event and decide how to output logs.
/// </summary>
public event Action<EzLatencyRecord> OnMeasurement;
}
}

View File

@@ -1,45 +0,0 @@
// 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.
namespace osu.Framework.Audio.EzLatency
{
/// <summary>
/// EzOsuLatency 输入数据结构体
/// </summary>
public struct EzLatencyInputData
{
public double InputTime;
public object KeyValue;
public double JudgeTime;
public double PlaybackTime;
/// <summary>
/// 检查输入数据是否完整
/// </summary>
public bool IsValid => InputTime > 0;
}
/// <summary>
/// EzOsuLatency 硬件数据结构体
/// </summary>
public struct EzLatencyHardwareData
{
public double DriverTime;
public double OutputHardwareTime;
public double InputHardwareTime;
public double LatencyDifference;
/// <summary>
/// 检查硬件数据是否完整
/// </summary>
public bool IsValid => OutputHardwareTime > 0 && DriverTime > 0;
}
public enum AudioOutputMode
{
Default,
WasapiShared,
WasapiExclusive,
Asio,
}
}

View File

@@ -1,83 +0,0 @@
// 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.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<EzLatencyRecord> 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 { }
}
}
}

View File

@@ -1,16 +0,0 @@
using System;
namespace osu.Framework.Audio.EzLatency
{
public interface IEzLatencyLogger : IDisposable
{
void Log(EzLatencyRecord record);
void Flush();
/// <summary>
/// Fired whenever a new latency record is logged.
/// Consumers (e.g. osu! layer) should subscribe to this to perform application-level logging/output.
/// </summary>
event Action<EzLatencyRecord> OnRecord;
}
}

View File

@@ -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);
}
}

View File

@@ -1,13 +0,0 @@
namespace osu.Framework.Audio.EzLatency
{
#nullable disable
using System;
public interface IEzLatencyTracker : IDisposable
{
void Start();
void Stop();
event Action<EzLatencyRecord> OnMeasurement;
void SetSampleRate(int sampleRate);
}
}

View File

@@ -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.
/// </returns>
public long ChannelGetPosition(IBassAudioChannel channel, PositionFlags mode = PositionFlags.Bytes)
=> BassMix.ChannelGetPosition(channel.Handle, mode);
{
return BassMix.ChannelGetPosition(channel.Handle, mode);
}
/// <summary>
/// Sets the playback position of a channel.
@@ -209,7 +212,9 @@ namespace osu.Framework.Audio.Mixing.Bass
/// <param name="flags">What levels to retrieve.</param>
/// <returns><c>true</c> if successful, false otherwise.</returns>
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;
}
/// <summary>
/// Retrieves the immediate sample data (or an FFT representation of it) of a channel.
@@ -224,7 +229,9 @@ namespace osu.Framework.Audio.Mixing.Bass
/// <para>When using the <see cref="DataFlags.Available"/> flag, the number of bytes in the channel's buffer is returned.</para>
/// </returns>
public int ChannelGetData(IBassAudioChannel channel, float[] buffer, int length)
=> BassMix.ChannelGetData(channel.Handle, buffer, length);
{
return BassMix.ChannelGetData(channel.Handle, buffer, length);
}
/// <summary>
/// Sets up a synchroniser on a mixer source channel.
@@ -237,7 +244,9 @@ namespace osu.Framework.Audio.Mixing.Bass
/// <param name="user">User instance data to pass to the callback function.</param>
/// <returns>If successful, then the new synchroniser's handle is returned, else 0 is returned. Use <see cref="ManagedBass.Bass.LastError" /> to get the error code.</returns>
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);
}
/// <summary>
/// Removes a synchroniser from a mixer source channel.
@@ -246,7 +255,9 @@ namespace osu.Framework.Audio.Mixing.Bass
/// <param name="sync">Handle of the synchroniser to remove (return value of a previous <see cref="BassMix.ChannelSetSync(int,SyncFlags,long,SyncProcedure,IntPtr)" /> call).</param>
/// <returns>If successful, <see langword="true" /> is returned, else <see langword="false" /> is returned. Use <see cref="ManagedBass.Bass.LastError" /> to get the error code.</returns>
public bool ChannelRemoveSync(IBassAudioChannel channel, int sync)
=> BassMix.ChannelRemoveSync(channel.Handle, sync);
{
return BassMix.ChannelRemoveSync(channel.Handle, sync);
}
/// <summary>
/// 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
{
}
}
}
/// <summary>

View File

@@ -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);
}

View File

@@ -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()

View File

@@ -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<MouseButton>()).EnumerateDifference(CurrentState.Mouse.Buttons);
var keyDiff = (parentState?.Keyboard.Keys ?? new ButtonStates<Key>()).EnumerateDifference(CurrentState.Keyboard.Keys);
var touchDiff = (parentState?.Touch ?? new TouchState()).EnumerateDifference(CurrentState.Touch);
var joyButtonDiff = (parentState?.Joystick?.Buttons ?? new ButtonStates<JoystickButton>()).EnumerateDifference(CurrentState.Joystick.Buttons);
var midiDiff = (parentState?.Midi?.Keys ?? new ButtonStates<MidiKey>()).EnumerateDifference(CurrentState.Midi.Keys);
var tabletPenDiff = (parentState?.Tablet?.PenButtons ?? new ButtonStates<TabletPenButton>()).EnumerateDifference(CurrentState.Tablet.PenButtons);
var tabletAuxiliaryDiff = (parentState?.Tablet?.AuxiliaryButtons ?? new ButtonStates<TabletAuxiliaryButton>()).EnumerateDifference(CurrentState.Tablet.AuxiliaryButtons);
var mouseButtons = parentState?.Mouse?.Buttons ?? new ButtonStates<MouseButton>();
var mouseDiff = mouseButtons.EnumerateDifference(CurrentState.Mouse.Buttons);
var keyboardKeys = parentState?.Keyboard.Keys ?? new ButtonStates<Key>();
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<JoystickButton>();
var joyButtonDiff = joystickButtons.EnumerateDifference(CurrentState.Joystick.Buttons);
var midiKeys = parentState?.Midi?.Keys ?? new ButtonStates<MidiKey>();
var midiDiff = midiKeys.EnumerateDifference(CurrentState.Midi.Keys);
var penButtons = parentState?.Tablet?.PenButtons ?? new ButtonStates<TabletPenButton>();
var tabletPenDiff = penButtons.EnumerateDifference(CurrentState.Tablet.PenButtons);
var auxButtons = parentState?.Tablet?.AuxiliaryButtons ?? new ButtonStates<TabletAuxiliaryButton>();
var tabletAuxiliaryDiff = auxButtons.EnumerateDifference(CurrentState.Tablet.AuxiliaryButtons);
if (mouseDiff.Released.Length > 0)
new MouseButtonInput(mouseDiff.Released.Select(button => new ButtonInputEntry<MouseButton>(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)