diff --git a/osu.Framework.Tests/Audio/AudioManagerDeviceEnumerationTest.cs b/osu.Framework.Tests/Audio/AudioManagerDeviceEnumerationTest.cs index b705314e0..471d473ee 100644 --- a/osu.Framework.Tests/Audio/AudioManagerDeviceEnumerationTest.cs +++ b/osu.Framework.Tests/Audio/AudioManagerDeviceEnumerationTest.cs @@ -12,39 +12,6 @@ namespace osu.Framework.Tests.Audio [TestFixture] public class AudioManagerDeviceEnumerationTest { - [Test] - public void TestAudioManagerEnumeratesAsioDevices() - { - AudioThread.PreloadBass(); - - var audioThread = new AudioThread(); - var trackStore = new ResourceStore(new DllResourceStore(typeof(AudioManagerDeviceEnumerationTest).Assembly)); - var sampleStore = new ResourceStore(new DllResourceStore(typeof(AudioManagerDeviceEnumerationTest).Assembly)); - - using (var audioManager = new AudioManager(audioThread, trackStore, sampleStore, null)) - { - var deviceNames = audioManager.AudioDeviceNames.ToList(); - - // Check that we have base devices - Assert.That(deviceNames.Count, Is.GreaterThan(0), "Should have at least one audio device"); - - // Check for ASIO devices if on Windows - if (RuntimeInfo.OS == RuntimeInfo.Platform.Windows) - { - var asioDevices = deviceNames.Where(name => name.Contains("(ASIO)")).ToList(); - - // ASIO may not be available in test environment, so we just log what we found - TestContext.WriteLine($"Found {asioDevices.Count} ASIO devices"); - - // Log the devices for debugging - foreach (string? device in deviceNames) - TestContext.WriteLine($"Device: {device}"); - - // Don't assert on ASIO device count since it may not be available in test environment - } - } - } - [Test] public void TestAsioDeviceSupportedSampleRates() { @@ -65,24 +32,24 @@ namespace osu.Framework.Tests.Audio TestContext.WriteLine($"Found {asioDevices.Count} ASIO devices"); - foreach (string? device in asioDevices) + foreach (var device in asioDevices) { string deviceName = device.Replace(" (ASIO)", ""); TestContext.WriteLine($"Testing ASIO device: {deviceName}"); try { - // var rates = audioManager.GetAsioDeviceSupportedSampleRates(deviceName); - // TestContext.WriteLine($" Supported rates: {(rates != null ? string.Join(", ", rates) : "null")}"); - // - // if (rates != null && rates.Length > 0) - // { - // TestContext.WriteLine($" Rate count: {rates.Length}"); - // } - // else - // { - // TestContext.WriteLine(" No supported rates found!"); - // } + var rates = audioManager.GetAsioDeviceSupportedSampleRates(deviceName); + TestContext.WriteLine($" Supported rates: {(rates != null ? string.Join(", ", rates) : "null")}"); + + if (rates != null && rates.Length > 0) + { + TestContext.WriteLine($" Rate count: {rates.Length}"); + } + else + { + TestContext.WriteLine(" No supported rates found!"); + } } catch (System.Exception ex) { diff --git a/osu.Framework/Audio/EzLatency/EzLatencyCore.cs b/osu.Framework/Audio/EzLatency/EzLatencyCore.cs index 6fe15ec72..f36034a30 100644 --- a/osu.Framework/Audio/EzLatency/EzLatencyCore.cs +++ b/osu.Framework/Audio/EzLatency/EzLatencyCore.cs @@ -18,10 +18,11 @@ namespace osu.Framework.Audio.EzLatency public struct EzLatencyInputData { public double InputTime; - public object? KeyValue; + public object KeyValue; public double JudgeTime; public double PlaybackTime; - public bool IsValid => InputTime > 0 && JudgeTime > 0 && PlaybackTime > 0; + // Consider input+playback or input+judge as valid for best-effort measurements. + public bool IsValid => InputTime > 0 && (PlaybackTime > 0 || JudgeTime > 0); } public struct EzLatencyHardwareData @@ -67,7 +68,7 @@ namespace osu.Framework.Audio.EzLatency { private readonly Stopwatch stopwatch; public bool Enabled { get; set; } = false; - public event Action? OnNewRecord; + public event Action OnNewRecord; private EzLatencyInputData currentInputData; private EzLatencyHardwareData currentHardwareData; @@ -79,7 +80,7 @@ namespace osu.Framework.Audio.EzLatency stopwatch = Stopwatch.StartNew(); } - public void RecordInputData(double inputTime, object? keyValue = null) + public void RecordInputData(double inputTime, object keyValue = null) { if (!Enabled) return; if (currentInputData.InputTime > 0) @@ -123,12 +124,13 @@ namespace osu.Framework.Audio.EzLatency private void tryGenerateCompleteRecord() { - if (!currentInputData.IsValid || !currentHardwareData.IsValid) + if (!currentInputData.IsValid) { checkTimeout(); return; } + // If we have hardware data, emit a full record. Otherwise emit a best-effort record without hw. var record = new EzLatencyRecord { Timestamp = DateTimeOffset.Now, @@ -140,21 +142,24 @@ namespace osu.Framework.Audio.EzLatency InputHardwareTime = currentHardwareData.InputHardwareTime, LatencyDifference = currentHardwareData.LatencyDifference, MeasuredMs = currentInputData.PlaybackTime - currentInputData.InputTime, - Note = "complete-latency-measurement", + Note = currentHardwareData.IsValid ? "complete-latency-measurement" : "best-effort-no-hw", 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); + OnNewRecord?.Invoke(record); + EzLatencyService.Instance.PushRecord(record); + if (currentHardwareData.IsValid) + Logger.Log($"EzLatency 完整记录已生成: Input→Playback={record.PlaybackTime - record.InputTime:F2}ms", LoggingTarget.Runtime, LogLevel.Debug); + else + Logger.Log($"EzLatency 最佳尝试记录(无硬件时间戳): Input→Playback={record.PlaybackTime - record.InputTime:F2}ms", LoggingTarget.Runtime, LogLevel.Debug); } + catch (Exception ex) + { + Logger.Log($"EzLatencyAnalyzer: tryGenerateCompleteRecord failed: {ex.Message}", LoggingTarget.Runtime, LogLevel.Error); + } ClearCurrentData(); } @@ -168,7 +173,7 @@ namespace osu.Framework.Audio.EzLatency double elapsed = stopwatch.Elapsed.TotalMilliseconds - recordStartTime; if (elapsed > timeout_ms) { - Logger.Log(string.Format("EzLatency 数据收集超时 ({0:F0}ms),清除旧数据", elapsed), LoggingTarget.Runtime, LogLevel.Debug); + Logger.Log($"EzLatency 数据收集超时 ({elapsed:F0}ms),清除旧数据", LoggingTarget.Runtime, LogLevel.Debug); ClearCurrentData(); } } @@ -192,7 +197,14 @@ namespace osu.Framework.Audio.EzLatency public void PushRecord(EzLatencyRecord record) { - try { OnMeasurement?.Invoke(record); } catch (Exception ex) { Logger.Log($"EzLatencyService.PushRecord 异常: {ex.Message}", LoggingTarget.Runtime, LogLevel.Error); } + try + { + OnMeasurement?.Invoke(record); + } + catch (Exception ex) + { + Logger.Log($"EzLatencyService.PushRecord 异常: {ex.Message}", LoggingTarget.Runtime, LogLevel.Error); + } } public event Action OnMeasurement; @@ -210,29 +222,78 @@ namespace osu.Framework.Audio.EzLatency 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)) + + this.scheduler = scheduler as Scheduler; { - 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); } + 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); + fileWriter = null; + } } } 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 { } + try + { + if (scheduler != null) + scheduler.Add(() => OnRecord?.Invoke(record)); + else + OnRecord?.Invoke(record); + } + catch (Exception ex) + { + Logger.Log($"EzLoggerAdapter: OnRecord handler threw: {ex.Message}", LoggingTarget.Runtime, LogLevel.Error); + } + + try + { + if (fileWriter != null) + { + string line = $"[{record.Timestamp:O}] {record.MeasuredMs} ms - {record.Note}"; + fileWriter.WriteLine(line); + } + } + catch (Exception ex) + { + Logger.Log($"EzLoggerAdapter: failed to write to file: {ex.Message}", LoggingTarget.Runtime, LogLevel.Error); + } } - public void Flush() { try { fileWriter?.Flush(); } catch { } } - public void Dispose() { try { fileWriter?.Dispose(); } catch { } } + public void Flush() + { + try + { + fileWriter?.Flush(); + } + catch (Exception ex) + { + Logger.Log($"EzLoggerAdapter: flush failed: {ex.Message}", LoggingTarget.Runtime, LogLevel.Error); + } + } + + public void Dispose() + { + try + { + fileWriter?.Dispose(); + } + catch (Exception ex) + { + Logger.Log($"EzLoggerAdapter: dispose failed: {ex.Message}", LoggingTarget.Runtime, LogLevel.Error); + } + } } // --- Interfaces & basic tracker ------------------------------------------ @@ -303,7 +364,8 @@ namespace osu.Framework.Audio.EzLatency public void AddRecord(EzLatencyRecord record) { - if (!record.IsComplete) + // Accept records if input data is valid (best-effort), even if hardware data isn't available. + if (!record.InputData.IsValid) return; lock (lockObject) @@ -327,10 +389,10 @@ namespace osu.Framework.Audio.EzLatency 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(); + var inputToJudge = records.Select(r => r.JudgeTime - r.InputTime).ToList(); + var inputToPlayback = records.Select(r => r.PlaybackTime - r.InputTime).ToList(); + var playbackToJudge = records.Select(r => r.JudgeTime - r.PlaybackTime).ToList(); + var hardwareLatency = records.Select(r => r.OutputHardwareTime).Where(h => h > 0).ToList(); return new EzLatencyStatistics { diff --git a/osu.Framework/Audio/EzLatency/EzLatencyManager.cs b/osu.Framework/Audio/EzLatency/EzLatencyManager.cs index b84f54f69..93a973604 100644 --- a/osu.Framework/Audio/EzLatency/EzLatencyManager.cs +++ b/osu.Framework/Audio/EzLatency/EzLatencyManager.cs @@ -30,31 +30,25 @@ namespace osu.Framework.Audio.EzLatency private readonly EzLatencyAnalyzer analyzer; private readonly EzLatencyCollector collector = new EzLatencyCollector(); + private readonly Action serviceHandler; public EzLatencyManager() { analyzer = new EzLatencyAnalyzer(); - // 将启用状态与分析器同步 Enabled.BindValueChanged(v => { analyzer.Enabled = v.NewValue; }, true); - // 将分析器的记录事件转发给外部 - analyzer.OnNewRecord += record => + // 统一通过 EzLatencyService 的事件通道接收所有记录(包括来自其它分析器/线程的) + serviceHandler = record => { - // add to collector for statistics - try - { - collector.AddRecord(record); - } - catch - { - } - + collector.AddRecord(record); OnNewRecord?.Invoke(record); }; + + EzLatencyService.Instance.OnMeasurement += serviceHandler; } /// @@ -126,6 +120,21 @@ namespace osu.Framework.Audio.EzLatency /// public void ClearStatistics() => collector.Clear(); + /// + /// Create a simple file logger for latency records. Convenience factory to make EzLoggerAdapter discoverable. + /// + public IEzLatencyLogger CreateLogger(string filePath = null) => new EzLoggerAdapter(null, filePath); + + /// + /// Create a basic in-process tracker which emits measurements into the global pipeline. + /// + public IEzLatencyTracker CreateBasicTracker() + { + var t = new BasicEzLatencyTracker(); + t.OnMeasurement += r => EzLatencyService.Instance.PushRecord(r); + return t; + } + /// /// 已收集的完整记录数量 /// @@ -155,7 +164,7 @@ namespace osu.Framework.Audio.EzLatency public void Dispose() { Enabled.UnbindAll(); - analyzer.OnNewRecord -= OnNewRecord; + EzLatencyService.Instance.OnMeasurement -= serviceHandler; } } } diff --git a/osu.Framework/Threading/AudioThread.cs b/osu.Framework/Threading/AudioThread.cs index 776033e08..5d5ff14f2 100644 --- a/osu.Framework/Threading/AudioThread.cs +++ b/osu.Framework/Threading/AudioThread.cs @@ -48,6 +48,24 @@ namespace osu.Framework.Threading // 初始化EzLatency模块 LatencyAnalyzer = new EzLatencyAnalyzer(); + + // Forward any analyzer records from this audio thread into the global EzLatencyService + try + { + LatencyAnalyzer.OnNewRecord += r => + { + try + { + EzLatencyService.Instance.PushRecord(r); + } + catch + { + } + }; + } + catch + { + } } public override bool IsCurrent => ThreadSafety.IsAudioThread;