From 692f9df3db95fad419de9b05aa60031b1c90b9f9 Mon Sep 17 00:00:00 2001 From: Starsky16 <163619709+Starsky16@users.noreply.github.com> Date: Tue, 15 Sep 2026 01:58:44 +0800 Subject: [PATCH 01/25] =?UTF-8?q?refactor:=20=E6=8A=BD=E5=8F=96=E9=87=87?= =?UTF-8?q?=E6=A0=B7=E4=B8=8E=E8=AE=A1=E7=AE=97=E6=A0=B8=E5=BF=83=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E5=B9=B6=E8=A1=A5=E5=85=85=E5=8D=95=E5=85=83=E6=B5=8B?= =?UTF-8?q?=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 Services:AudioPeakMeter(门面)、AudioDeviceEnumerator(设备枚举与缓存)、ContinuousPeakCapture(常驻静默捕获流)、OneShotPeakCapture(一次性三级回退采样)、DecibelCalculator(线性峰值与显示刻度换算)、PeakSample(采样结果纯数据)。 - 新增插件全局设置 DecibelMonitorGlobalSettings 与持久化服务 DecibelMonitorSettingsService(整体反序列化,新增设置项无需同步维护)。 - Plugin.Initialize 注册全局设置服务与共享采样服务;组件将在后续 PR 切换为该服务。 - 新增 Tests/Decibel_Monitor.Tests(xunit),覆盖计算换算与采样数据纯逻辑,共 28 条用例全绿。 由 VS Code 中的 Cline 辅助生成。 --- Decibel_Monitor.csproj | 7 + Models/DecibelMonitorGlobalSettings.cs | 37 ++++ Plugin.cs | 7 + Services/AudioDeviceEnumerator.cs | 110 ++++++++++ Services/AudioPeakMeter.cs | 154 ++++++++++++++ Services/ContinuousPeakCapture.cs | 194 ++++++++++++++++++ Services/DecibelCalculator.cs | 62 ++++++ Services/DecibelMonitorSettingsService.cs | 95 +++++++++ Services/OneShotPeakCapture.cs | 183 +++++++++++++++++ Services/PeakSample.cs | 50 +++++ .../DecibelCalculatorTests.cs | 127 ++++++++++++ .../Decibel_Monitor.Tests.csproj | 27 +++ .../Decibel_Monitor.Tests/PeakSampleTests.cs | 86 ++++++++ 13 files changed, 1139 insertions(+) create mode 100644 Models/DecibelMonitorGlobalSettings.cs create mode 100644 Services/AudioDeviceEnumerator.cs create mode 100644 Services/AudioPeakMeter.cs create mode 100644 Services/ContinuousPeakCapture.cs create mode 100644 Services/DecibelCalculator.cs create mode 100644 Services/DecibelMonitorSettingsService.cs create mode 100644 Services/OneShotPeakCapture.cs create mode 100644 Services/PeakSample.cs create mode 100644 Tests/Decibel_Monitor.Tests/DecibelCalculatorTests.cs create mode 100644 Tests/Decibel_Monitor.Tests/Decibel_Monitor.Tests.csproj create mode 100644 Tests/Decibel_Monitor.Tests/PeakSampleTests.cs diff --git a/Decibel_Monitor.csproj b/Decibel_Monitor.csproj index 9afaa78..b9dda6d 100644 --- a/Decibel_Monitor.csproj +++ b/Decibel_Monitor.csproj @@ -20,6 +20,13 @@ + + + + + + + DecibelComponentSettingsControl.axaml diff --git a/Models/DecibelMonitorGlobalSettings.cs b/Models/DecibelMonitorGlobalSettings.cs new file mode 100644 index 0000000..fe5ce58 --- /dev/null +++ b/Models/DecibelMonitorGlobalSettings.cs @@ -0,0 +1,37 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace Decibel_Monitor.Models; + +/// +/// Decibel_Monitor 插件的全局设置(存放于插件配置目录,与组件/通知设置相互独立)。 +/// +public partial class DecibelMonitorGlobalSettings : ObservableObject +{ + /// + /// 校准参考(显示刻度 dB 0..150,换算为 dBFS 时为其减去 150),默认 70(对应 -80 dBFS)。 + /// 同一麦克风的测量/校准为全局一致,故作为插件级全局设置。 + /// + [ObservableProperty] private double _referenceDecibel = 70.0; + + /// + /// 全局放大倍数:由校准计算后写入,组件显示分贝时统一使用该值,避免各组件数值不一致。 + /// + [ObservableProperty] private double _magnification = 1.0; + + /// + /// 是否启用"常驻静默捕获流",默认 true。 + /// 开启后保持一条低开销的麦克风捕获流以持续获取峰值(Windows 会显示"麦克风正在使用",但图标常亮、不会闪烁); + /// 关闭后仅使用系统实时计量(多数设备在无活跃录音会话时该值为 0,组件可能长期显示 0)。 + /// + [ObservableProperty] private bool _enableContinuousMonitoring = true; + + /// + /// 显式采样(如校准)的单次录音时长(毫秒)。较长时间可捕获更低频率/更弱信号。 + /// + [ObservableProperty] private int _fallbackCaptureMs = 400; + + /// + /// 线性峰值信号检测阈值:超过该值视为"检测到有效信号"(可在采样异常时调低)。 + /// + [ObservableProperty] private double _signalThreshold = 0.0001; +} diff --git a/Plugin.cs b/Plugin.cs index 58bd1a1..e43c1b7 100644 --- a/Plugin.cs +++ b/Plugin.cs @@ -17,6 +17,13 @@ public override void Initialize(HostBuilderContext context, IServiceCollection s // 可选欢迎提示 //CommonTaskDialogs.ShowDialog("Hello world!", "Hello from Decibel_Monitor!"); + // 插件全局设置(保存于插件配置目录,供采样服务与设置页共享) + var globalSettingsService = new Services.DecibelMonitorSettingsService(PluginConfigFolder); + services.AddSingleton(globalSettingsService); + + // 共享的麦克风峰值采样服务(单例,构造时读取全局采样偏好) + services.AddSingleton(); + // 一次性注册组件与其设置控件(不要重复注册) services.AddComponent(); } diff --git a/Services/AudioDeviceEnumerator.cs b/Services/AudioDeviceEnumerator.cs new file mode 100644 index 0000000..1aea70e --- /dev/null +++ b/Services/AudioDeviceEnumerator.cs @@ -0,0 +1,110 @@ +using System; +using System.Linq; +using NAudio.CoreAudioApi; + +namespace Decibel_Monitor.Services; + +/// +/// 捕获设备枚举与缓存(默认捕获设备 / 设备描述列表,枚举结果带 5 秒缓存)。 +/// 由 拆分而来,对外行为保持一致。 +/// +internal sealed class AudioDeviceEnumerator : IDisposable +{ + /// 设备列表缓存刷新间隔(毫秒)。 + private const int DeviceRefreshIntervalMs = 5000; + + private readonly object _cacheLock = new(); + private readonly MMDeviceEnumerator _enumerator = new(); + + private MMDevice[]? _cachedCaptureDevices; + private MMDevice? _cachedDefaultDevice; + private DateTime _deviceCacheTimeUtc = DateTime.MinValue; + + /// + /// 获取默认捕获设备(设备列表带 5 秒缓存)。 + /// + public MMDevice? GetDefaultCaptureDevice() + { + try + { + EnsureCache(); + lock (_cacheLock) + { + if (_cachedDefaultDevice is null) return null; + if (_cachedCaptureDevices is null) return _cachedDefaultDevice; + return _cachedCaptureDevices.FirstOrDefault(c => c.ID == _cachedDefaultDevice.ID) ?? _cachedDefaultDevice; + } + } + catch + { + return null; + } + } + + /// + /// 判断指定设备是否为默认捕获设备(null 表示默认设备)。 + /// + public bool IsDefaultDevice(MMDevice? device) + { + if (device is null) return true; + var def = _cachedDefaultDevice; + return def is null || def.ID == device.ID; + } + + /// + /// 获取当前可用捕获设备的描述列表(用于诊断显示)。 + /// + public string[] GetCaptureDeviceDescriptions() + { + try + { + EnsureCache(); + lock (_cacheLock) + { + return _cachedCaptureDevices?.Select(d => $"{d.FriendlyName} ({d.ID})").ToArray() ?? Array.Empty(); + } + } + catch + { + return Array.Empty(); + } + } + + /// + /// 获取默认捕获设备的描述(用于诊断显示)。 + /// + public string? GetDefaultCaptureDeviceDescription() + { + try + { + var d = GetDefaultCaptureDevice(); + return d is null ? null : $"{d.FriendlyName} ({d.ID})"; + } + catch + { + return null; + } + } + + /// + /// 刷新设备缓存(超过缓存时间或尚未初始化时重新枚举)。 + /// + private void EnsureCache() + { + lock (_cacheLock) + { + var now = DateTime.UtcNow; + if (_cachedCaptureDevices is null || (now - _deviceCacheTimeUtc).TotalMilliseconds >= DeviceRefreshIntervalMs) + { + _cachedCaptureDevices = _enumerator.EnumerateAudioEndPoints(DataFlow.Capture, DeviceState.Active).ToArray(); + _cachedDefaultDevice = _enumerator.GetDefaultAudioEndpoint(DataFlow.Capture, Role.Console); + _deviceCacheTimeUtc = now; + } + } + } + + public void Dispose() + { + _enumerator.Dispose(); + } +} diff --git a/Services/AudioPeakMeter.cs b/Services/AudioPeakMeter.cs new file mode 100644 index 0000000..f761424 --- /dev/null +++ b/Services/AudioPeakMeter.cs @@ -0,0 +1,154 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Decibel_Monitor.Services; + +/// +/// 麦克风峰值采样服务。 +/// 通过一条"常驻静默捕获流"(WASAPI 共享模式,仅计算峰值、不保存音频)持续读取默认麦克风峰值, +/// 供组件高频刷新直接读取——数值稳定、无周期性打开/关闭录音导致的占用图标闪烁。 +/// 常驻流不可用时回退到系统实时计量(AudioMeterInformation); +/// 显式操作(校准等)可调用 临时采样一次。 +/// 设备枚举结果带缓存,采样会话(临时采样)受信号量保护。 +/// +/// +/// 实现已拆分为:(设备枚举与缓存)、 +/// (常驻捕获流)、(显式一次性采样)。 +/// 本类只负责持有配置并转发调用,公开 API 保持不变。 +/// +public sealed class AudioPeakMeter : IDisposable +{ + private readonly AudioDeviceEnumerator _devices = new(); + private readonly ContinuousPeakCapture _continuousCapture; + private readonly OneShotPeakCapture _oneShotCapture; + + /// 临时(显式)采样单次录音时长(毫秒),来自插件全局设置,默认 400。 + private readonly int _fallbackCaptureMs = 400; + + /// 线性峰值超过该阈值视为"检测到有效信号",来自插件全局设置,默认 0.0001。 + private readonly float _signalThreshold = 0.0001f; + + /// + /// 是否启用"常驻静默捕获流"。默认 true:保持一条低开销捕获流以持续获取峰值 + /// (Windows 会显示麦克风正在使用且图标常亮,但不会闪烁)。 + /// 关闭后仅使用系统实时计量(多数设备在无活跃录音会话时该值为 0)。 + /// + private readonly bool _enableContinuousMonitoring; + + private volatile bool _disposed; + + /// 当前服务是否已释放。 + public bool IsDisposed => _disposed; + + /// + /// 当前默认的临时采样时长(毫秒,来自插件全局设置;未注入时 400)。 + /// + public int DefaultCaptureMs => _fallbackCaptureMs; + + /// + /// 是否启用"常驻静默捕获流"。 + /// + public bool EnableContinuousMonitoring => _enableContinuousMonitoring; + + /// + /// 初始化采样服务。 + /// + /// 插件全局设置服务(可空;缺省时使用内置默认采样参数)。 + public AudioPeakMeter(DecibelMonitorSettingsService? settingsService = null) + { + if (settingsService is not null) + { + _fallbackCaptureMs = Math.Clamp(settingsService.Settings.FallbackCaptureMs, 100, 5000); + _signalThreshold = (float)Math.Clamp(settingsService.Settings.SignalThreshold, 0.000001, 1.0); + _enableContinuousMonitoring = settingsService.Settings.EnableContinuousMonitoring; + } + + _continuousCapture = new ContinuousPeakCapture(_devices, _enableContinuousMonitoring); + _oneShotCapture = new OneShotPeakCapture(_devices, _signalThreshold); + } + + /// + /// 快速读取默认捕获设备的实时线性峰值(0..1)。 + /// 仅读取 AudioMeterInformation,不做耗时采样,适合高频 UI 刷新。 + /// 若设备不支持实时计量或读取失败,返回 0。 + /// + public float GetDefaultDevicePeakLinearFast() + { + if (_disposed) return 0f; + try + { + var device = _devices.GetDefaultCaptureDevice(); + return device?.AudioMeterInformation?.MasterPeakValue ?? 0f; + } + catch + { + return 0f; + } + } + + /// + /// 获取默认捕获设备的线性峰值(0..1),供组件周期刷新使用。 + /// 启用"常驻静默捕获流"时,返回常驻流在当前峰值窗口内的最大值(稳定、无周期性开/关录音); + /// 同时优先返回实时计量(AudioMeterInformation)读到的更大瞬时值以提高响应灵敏度。 + /// 未启用常驻流时仅使用系统实时计量(多数设备在无活跃录音会话时该值为 0)。 + /// + public Task GetDefaultDevicePeakLinearAsync(CancellationToken cancellationToken = default) + { + if (_disposed) return Task.FromResult(0f); + + var fast = GetDefaultDevicePeakLinearFast(); + if (fast > _signalThreshold) + { + return Task.FromResult(fast); + } + + if (_enableContinuousMonitoring) + { + _continuousCapture.EnsureStarted(); + return Task.FromResult(_continuousCapture.CurrentPeak); + } + + return Task.FromResult(0f); + } + + /// + /// 显式采样一次默认捕获设备的线性峰值(0..1)——用户主动触发(如校准、诊断)时使用。 + /// 会按 AudioMeterInformation → WasapiCapture → WaveInEvent 顺序尝试, + /// 期间会短暂打开录音通道(受信号量保护,同一时间仅一个采样会话)。 + /// + /// 录音采样时长(毫秒),限制在 100..5000 之间。 + public async Task CaptureSamplePeakAsync(int captureMs, CancellationToken cancellationToken = default) + { + if (_disposed) return 0f; + return await _oneShotCapture.CaptureAsync(captureMs, cancellationToken).ConfigureAwait(false); + } + + /// + /// 获取当前可用捕获设备的描述列表(用于诊断显示)。 + /// + public string[] GetCaptureDeviceDescriptions() + { + if (_disposed) return Array.Empty(); + return _devices.GetCaptureDeviceDescriptions(); + } + + /// + /// 获取默认捕获设备的描述(用于诊断显示)。 + /// + public string? GetDefaultCaptureDeviceDescription() + { + if (_disposed) return null; + return _devices.GetDefaultCaptureDeviceDescription(); + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + + _continuousCapture.Dispose(); + _oneShotCapture.Dispose(); + _devices.Dispose(); + } +} diff --git a/Services/ContinuousPeakCapture.cs b/Services/ContinuousPeakCapture.cs new file mode 100644 index 0000000..c3aaba1 --- /dev/null +++ b/Services/ContinuousPeakCapture.cs @@ -0,0 +1,194 @@ +using System; +using NAudio.CoreAudioApi; +using NAudio.Wave; + +namespace Decibel_Monitor.Services; + +/// +/// 常驻静默捕获流:以 WASAPI 共享模式持续读取默认麦克风峰值(仅计算峰值、不保存音频)。 +/// 默认设备变化或捕获意外停止时自动重启(失败带退避);流不可用时由调用方回退到系统实时计量。 +/// 由 拆分而来,对外行为保持一致。 +/// +internal sealed class ContinuousPeakCapture : IDisposable +{ + /// 常驻捕获流启动失败后的重试退避间隔(毫秒)。 + private const int CaptureRestartBackoffMs = 5000; + + /// 常驻捕获流的峰值窗口(毫秒):超过该窗口后重新开始累计最大值。 + private static readonly TimeSpan ContinuousPeakWindow = TimeSpan.FromMilliseconds(400); + + private readonly object _captureLock = new(); + private readonly AudioDeviceEnumerator _devices; + private readonly bool _enabled; + + private WasapiCapture? _continuousCapture; + private string? _continuousCaptureDeviceId; + private DateTime _continuousWindowStartUtc; + private DateTime _nextCaptureStartAttemptUtc = DateTime.MinValue; + private volatile bool _captureRunning; + private volatile float _continuousPeak; + private volatile bool _disposed; + + /// + /// 初始化常驻捕获流。 + /// + /// 设备枚举器(提供默认捕获设备)。 + /// 是否启用常驻捕获流。 + public ContinuousPeakCapture(AudioDeviceEnumerator devices, bool enabled) + { + _devices = devices; + _enabled = enabled; + } + + /// 当前峰值窗口内的最大线性峰值(0..1)。 + public float CurrentPeak => _continuousPeak; + + /// + /// 确保"常驻静默捕获流"已启动。捕获流仅计算峰值、不保存音频; + /// 若默认设备变化或捕获意外停止则自动重启(带退避)。 + /// + public void EnsureStarted() + { + if (_disposed || !_enabled) return; + + // 捕获运行中:仅检查默认设备是否变化,变化则重启 + if (_captureRunning) + { + if (_continuousCaptureDeviceId != _devices.GetDefaultCaptureDevice()?.ID) + { + Restart(); + } + return; + } + + if (DateTime.UtcNow < _nextCaptureStartAttemptUtc) return; // 失败退避中 + + lock (_captureLock) + { + if (_disposed || _captureRunning) return; + if (DateTime.UtcNow < _nextCaptureStartAttemptUtc) return; + + try + { + var device = _devices.GetDefaultCaptureDevice(); + if (device is null) + { + _nextCaptureStartAttemptUtc = DateTime.UtcNow.AddMilliseconds(CaptureRestartBackoffMs); + return; + } + + StopLocked(); // 清理可能残留的对象 + + var capture = new WasapiCapture(device); + capture.DataAvailable += OnData; + capture.RecordingStopped += OnStopped; + capture.StartRecording(); + + _continuousCapture = capture; + _continuousCaptureDeviceId = device.ID; + _continuousWindowStartUtc = DateTime.MinValue; + _continuousPeak = 0f; + _captureRunning = true; + } + catch + { + // 启动失败:清理并退避,避免高频重试 + StopLocked(); + _nextCaptureStartAttemptUtc = DateTime.UtcNow.AddMilliseconds(CaptureRestartBackoffMs); + } + } + } + + /// + /// 重启常驻捕获流(默认设备变化等场景)。 + /// + private void Restart() + { + lock (_captureLock) + { + StopLocked(); + _nextCaptureStartAttemptUtc = DateTime.MinValue; + } + // 让 EnsureStarted 的加锁路径重新启动 + EnsureStarted(); + } + + /// + /// 停止并释放当前常驻捕获流。须在持有 时调用。 + /// + private void StopLocked() + { + var capture = _continuousCapture; + _continuousCapture = null; + _continuousCaptureDeviceId = null; + _captureRunning = false; + _continuousPeak = 0f; + + if (capture is null) return; + try + { + capture.DataAvailable -= OnData; + capture.RecordingStopped -= OnStopped; + capture.StopRecording(); + } + catch { /* 忽略停止时的异常 */ } + capture.Dispose(); + } + + /// + /// 常驻捕获流数据回调:在每个数据块中取峰值,并在 + /// 窗口内保留最大值(供读取线程经 获取)。 + /// + private void OnData(object? sender, WaveInEventArgs e) + { + try + { + if (sender is not WasapiCapture capture) return; + + var wf = capture.WaveFormat; + float blockPeak = PeakSample.ComputePeak( + e.Buffer, e.BytesRecorded, + wf.Encoding == WaveFormatEncoding.IeeeFloat, wf.BitsPerSample); + + var now = DateTime.UtcNow; + if (_continuousWindowStartUtc == DateTime.MinValue || + now - _continuousWindowStartUtc > ContinuousPeakWindow) + { + _continuousWindowStartUtc = now; + _continuousPeak = blockPeak; + } + else if (blockPeak > _continuousPeak) + { + _continuousPeak = blockPeak; + } + } + catch { /* 忽略单个数据块解析错误 */ } + } + + /// + /// 常驻捕获流停止回调(设备被拔出、被独占或异常等)。 + /// + private void OnStopped(object? sender, StoppedEventArgs e) + { + // 仅处理当前流的事件,忽略重启过程中旧流延迟到达的回调 + if (!ReferenceEquals(sender, _continuousCapture)) return; + + _captureRunning = false; + _continuousPeak = 0f; + if (e.Exception is not null) + { + _nextCaptureStartAttemptUtc = DateTime.UtcNow.AddMilliseconds(CaptureRestartBackoffMs); + } + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + + lock (_captureLock) + { + StopLocked(); + } + } +} diff --git a/Services/DecibelCalculator.cs b/Services/DecibelCalculator.cs new file mode 100644 index 0000000..b991646 --- /dev/null +++ b/Services/DecibelCalculator.cs @@ -0,0 +1,62 @@ +using System; + +namespace Decibel_Monitor.Services; + +/// +/// 分贝换算纯函数工具类(与 UI / 音频设备无关,便于单元测试)。 +/// +/// +/// 显示刻度(0..150)与 dBFS 的换算关系: +/// displayDb = 150 + dbfs,即 0 dBFS(满幅)对应 150,-150 dBFS 对应 0。 +/// +public static class DecibelCalculator +{ + /// + /// 显示刻度上限。 + /// + public const double DisplayScaleMax = 150.0; + + /// + /// 将线性峰值(0..1)按放大倍数放大后映射为显示刻度(0..150)。 + /// + /// 线性峰值,范围 0..1。 + /// 校准放大倍数。 + /// 映射后的显示分贝值(0..150)。 + public static double LinearToDisplayDb(double linear, double magnification) + { + var scaled = Math.Clamp(linear * magnification, 0.0, 1.0); + if (scaled <= 0.0) return 0.0; + var dbfs = 20.0 * Math.Log10(scaled); + return Math.Clamp(DisplayScaleMax + dbfs, 0.0, DisplayScaleMax); + } + + /// + /// 将显示刻度(0..150)转换为 dBFS。 + /// + public static double DisplayDbToDbFs(double displayDb) + { + return displayDb - DisplayScaleMax; + } + + /// + /// 将 dBFS 转换为线性值(0..1)。 + /// + public static double DbFsToLinear(double dbfs) + { + return Math.Pow(10.0, dbfs / 20.0); + } + + /// + /// 根据目标显示分贝与测量到的线性峰值计算放大倍数,并做上下限保护。 + /// + /// 目标显示分贝(0..150)。 + /// 测量得到的线性峰值(0..1)。 + /// 放大倍数上限。 + /// 放大倍数。当测量值为 0(无法测量)时返回上限,避免极端错误值。 + public static double CalculateMagnification(double targetDisplayDb, double measuredLinear, double maxMagnification = 1024.0) + { + if (measuredLinear <= 0.0) return maxMagnification; + var targetLinear = DbFsToLinear(DisplayDbToDbFs(targetDisplayDb)); + return Math.Clamp(targetLinear / measuredLinear, 0.0, maxMagnification); + } +} diff --git a/Services/DecibelMonitorSettingsService.cs b/Services/DecibelMonitorSettingsService.cs new file mode 100644 index 0000000..a913dde --- /dev/null +++ b/Services/DecibelMonitorSettingsService.cs @@ -0,0 +1,95 @@ +using System; +using System.ComponentModel; +using System.IO; +using System.Text.Json; +using System.Threading; +using Decibel_Monitor.Models; + +namespace Decibel_Monitor.Services; + +/// +/// 插件全局设置服务:在插件配置目录中以 JSON 保存 +/// ,并在修改后自动保存。 +/// +public sealed class DecibelMonitorSettingsService : IDisposable +{ + private readonly object _saveLock = new(); + private readonly string? _filePath; + private Timer? _saveTimer; + private bool _disposed; + + /// + /// 当前全局设置(属性变化自动触发延迟保存)。 + /// + public DecibelMonitorGlobalSettings Settings { get; } + + /// 插件配置目录(PluginBase.PluginConfigFolder);为空时仅内存保存。 + public DecibelMonitorSettingsService(string? configFolder) + { + if (!string.IsNullOrWhiteSpace(configFolder)) + { + _filePath = Path.Combine(configFolder, "plugin-settings.json"); + } + + // 整体反序列化得到完整实例:新增设置项无需在此处同步维护,避免漏拷贝 + Settings = LoadSettings() ?? new DecibelMonitorGlobalSettings(); + Settings.PropertyChanged += OnSettingsPropertyChanged; + } + + private void OnSettingsPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (_disposed) return; + + // 去抖后写盘,避免滑块拖动等高频变更频繁落盘 + _saveTimer?.Dispose(); + _saveTimer = new Timer(_ => Save(), null, 400, Timeout.Infinite); + } + + /// + /// 从磁盘读取全局设置;文件不存在或解析失败时返回 null(调用方回退到默认值)。 + /// + private DecibelMonitorGlobalSettings? LoadSettings() + { + if (_filePath is null || !File.Exists(_filePath)) return null; + try + { + var json = File.ReadAllText(_filePath); + return JsonSerializer.Deserialize(json); + } + catch + { + // 读取失败按默认值继续 + return null; + } + } + + /// + /// 立即将全局设置写入磁盘。 + /// + public void Save() + { + if (_filePath is null || _disposed) return; + try + { + lock (_saveLock) + { + var dir = Path.GetDirectoryName(_filePath); + if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); + File.WriteAllText(_filePath, JsonSerializer.Serialize(Settings)); + } + } + catch + { + // 写盘失败不应影响运行 + } + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + Settings.PropertyChanged -= OnSettingsPropertyChanged; + _saveTimer?.Dispose(); + Save(); + } +} diff --git a/Services/OneShotPeakCapture.cs b/Services/OneShotPeakCapture.cs new file mode 100644 index 0000000..4e76e68 --- /dev/null +++ b/Services/OneShotPeakCapture.cs @@ -0,0 +1,183 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using NAudio.CoreAudioApi; +using NAudio.Wave; + +namespace Decibel_Monitor.Services; + +/// +/// 显式(一次性)采样:按 AudioMeterInformation → WasapiCapture → WaveInEvent 顺序回退, +/// 采样会话受信号量保护(同一时间仅一个会话),期间会短暂打开录音通道。 +/// 由 拆分而来,对外行为保持一致。 +/// +internal sealed class OneShotPeakCapture : IDisposable +{ + private readonly SemaphoreSlim _samplingGate = new(1, 1); + private readonly AudioDeviceEnumerator _devices; + private readonly float _signalThreshold; + private volatile bool _disposed; + + /// + /// 初始化一次性采样器。 + /// + /// 设备枚举器(提供默认捕获设备)。 + /// 线性峰值超过该值视为“检测到有效信号”。 + public OneShotPeakCapture(AudioDeviceEnumerator devices, float signalThreshold) + { + _devices = devices; + _signalThreshold = signalThreshold; + } + + /// + /// 显式采样一次默认捕获设备的线性峰值(0..1)——用户主动触发(如校准、诊断)时使用。 + /// + /// 录音采样时长(毫秒),限制在 100..5000 之间。 + public async Task CaptureAsync(int captureMs, CancellationToken cancellationToken = default) + { + if (_disposed) return 0f; + + await _samplingGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + cancellationToken.ThrowIfCancellationRequested(); + + var device = _devices.GetDefaultCaptureDevice(); + return await GetPeakLinearAsync(device, Math.Clamp(captureMs, 100, 5000), cancellationToken).ConfigureAwait(false); + } + finally + { + _samplingGate.Release(); + } + } + + /// + /// 获取指定设备(可空表示默认设备)的线性峰值(0..1),按 AudioMeterInformation → WasapiCapture → WaveInEvent 顺序回退。 + /// + private async Task GetPeakLinearAsync(MMDevice? device, int captureMs, CancellationToken cancellationToken) + { + // 1) AudioMeterInformation(低成本实时计量) + var meterValue = device?.AudioMeterInformation?.MasterPeakValue ?? 0f; + if (meterValue > _signalThreshold) return meterValue; + + // 2) WasapiCapture(指定设备) + try + { + var v = await GetPeakByWasapiCaptureAsync(device, captureMs, cancellationToken).ConfigureAwait(false); + if (v > _signalThreshold) return v; + } + catch (OperationCanceledException) { throw; } + catch { /* 忽略,继续回退 */ } + + // 3) WasapiCapture(默认设备):仅在指定设备并非默认设备时才需要再次尝试, + // 避免对同一设备重复录音造成不必要的开销。 + if (!_devices.IsDefaultDevice(device)) + { + try + { + var v = await GetPeakByWasapiCaptureAsync(null, captureMs, cancellationToken).ConfigureAwait(false); + if (v > _signalThreshold) return v; + } + catch (OperationCanceledException) { throw; } + catch { /* 忽略 */ } + } + + // 4) WaveInEvent(旧 API,兼容性更好) + try + { + var v = await GetPeakByWaveInAsync(captureMs, cancellationToken).ConfigureAwait(false); + if (v > _signalThreshold) return v; + } + catch (OperationCanceledException) { throw; } + catch { /* 忽略 */ } + + return 0f; + } + + /// + /// 使用 WasapiCapture 短时录音并计算最大样本绝对值(0..1)。 + /// 检测到首个有效信号即提前返回,避免等满整个录音时长。 + /// + private async Task GetPeakByWasapiCaptureAsync(MMDevice? device, int captureMs, CancellationToken cancellationToken) + { + float maxSample = 0f; + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + using var capture = device is null ? new WasapiCapture() : new WasapiCapture(device); + capture.DataAvailable += (s, e) => + { + try + { + var wf = capture.WaveFormat; + float peak = PeakSample.ComputePeak( + e.Buffer, e.BytesRecorded, + wf.Encoding == WaveFormatEncoding.IeeeFloat, wf.BitsPerSample); + + if (peak > maxSample) maxSample = peak; + // 检测到有效信号即可提前完成采样,避免等满整个录音时长 + if (maxSample > _signalThreshold) tcs.TrySetResult(maxSample); + } + catch { /* 忽略单个数据块解析错误 */ } + }; + capture.RecordingStopped += (s, e) => tcs.TrySetResult(maxSample); + + capture.StartRecording(); + try + { + // 首个有效信号或录音时长到点即返回 + await Task.WhenAny(tcs.Task, Task.Delay(captureMs, cancellationToken)).ConfigureAwait(false); + } + finally + { + capture.StopRecording(); + } + + // 采样结果经 TCS 传递,保证回调线程写入对当前线程可见 + var peak = tcs.Task.IsCompleted ? await tcs.Task.ConfigureAwait(false) : maxSample; + return Math.Clamp(peak, 0f, 1f); + } + + /// + /// 使用 WaveInEvent(旧 API)短时录音并计算最大样本绝对值(0..1)。 + /// 检测到首个有效信号即提前返回,避免等满整个录音时长。 + /// + private async Task GetPeakByWaveInAsync(int captureMs, CancellationToken cancellationToken) + { + float maxSample = 0f; + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + using var waveIn = new WaveInEvent(); + waveIn.WaveFormat = new WaveFormat(16000, 16, 1); // 单声道 16k 16bit + waveIn.DataAvailable += (s, e) => + { + try + { + float peak = PeakSample.ComputePeak(e.Buffer, e.BytesRecorded, false, 16); + if (peak > maxSample) maxSample = peak; + if (maxSample > _signalThreshold) tcs.TrySetResult(maxSample); + } + catch { } + }; + waveIn.RecordingStopped += (s, e) => tcs.TrySetResult(maxSample); + + waveIn.StartRecording(); + try + { + await Task.WhenAny(tcs.Task, Task.Delay(captureMs, cancellationToken)).ConfigureAwait(false); + } + finally + { + waveIn.StopRecording(); + } + + var peak = tcs.Task.IsCompleted ? await tcs.Task.ConfigureAwait(false) : maxSample; + return Math.Clamp(peak, 0f, 1f); + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + _samplingGate.Dispose(); + } +} diff --git a/Services/PeakSample.cs b/Services/PeakSample.cs new file mode 100644 index 0000000..07185af --- /dev/null +++ b/Services/PeakSample.cs @@ -0,0 +1,50 @@ +using System; + +namespace Decibel_Monitor.Services; + +/// +/// 音频峰值解析纯函数工具类(不依赖 NAudio 具体类型,便于单元测试)。 +/// +public static class PeakSample +{ + /// + /// 从 PCM/浮点字节缓冲区中计算最大样本绝对值(线性峰值,0..1)。 + /// + /// 音频字节缓冲区。 + /// 有效字节数。 + /// 是否为 IEEE 32 位浮点编码。 + /// PCM 采样位深(16 或 32)。 + /// 线性峰值,范围 0..1。 + public static float ComputePeak(byte[] buffer, int bytesRecorded, bool isIeeeFloat, int bitsPerSample) + { + if (buffer is null || bytesRecorded <= 0) return 0f; + + float max = 0f; + if (isIeeeFloat) + { + for (int n = 0; n + 4 <= bytesRecorded; n += 4) + { + float sample = Math.Abs(BitConverter.ToSingle(buffer, n)); + if (sample > max) max = sample; + } + } + else if (bitsPerSample == 16) + { + for (int n = 0; n + 2 <= bytesRecorded; n += 2) + { + float sample = Math.Abs(BitConverter.ToInt16(buffer, n) / 32768f); + if (sample > max) max = sample; + } + } + else if (bitsPerSample == 32) + { + for (int n = 0; n + 4 <= bytesRecorded; n += 4) + { + float sample = Math.Abs(BitConverter.ToInt32(buffer, n) / (float)int.MaxValue); + if (sample > max) max = sample; + } + } + + return Math.Clamp(max, 0f, 1f); + } +} diff --git a/Tests/Decibel_Monitor.Tests/DecibelCalculatorTests.cs b/Tests/Decibel_Monitor.Tests/DecibelCalculatorTests.cs new file mode 100644 index 0000000..099b2b9 --- /dev/null +++ b/Tests/Decibel_Monitor.Tests/DecibelCalculatorTests.cs @@ -0,0 +1,127 @@ +using Decibel_Monitor.Services; + +namespace Decibel_Monitor.Tests; + +public class DecibelCalculatorTests +{ + // ---- LinearToDisplayDb ---- + + [Theory] + [InlineData(1.0, 1.0, 150.0)] // 0 dBFS = 满幅 + [InlineData(0.0, 1.0, 0.0)] // 静音 + [InlineData(0.5, 1.0, 143.9794)] // -6.02 dBFS + [InlineData(0.1, 100.0, 150.0)] // 放大后超限,clamp 到上限 + [InlineData(0.001, 1.0, 90.0)] // -60 dBFS -> 90 + public void LinearToDisplayDb_Should_MapWithinScale(double linear, double magnification, double expected) + { + var result = DecibelCalculator.LinearToDisplayDb(linear, magnification); + Assert.Equal(expected, result, 3); + } + + [Fact] + public void LinearToDisplayDb_Should_ClampToZeroForNonPositiveInput() + { + Assert.Equal(0.0, DecibelCalculator.LinearToDisplayDb(-0.5, 1.0), 6); + Assert.Equal(0.0, DecibelCalculator.LinearToDisplayDb(0.0, 1000.0), 6); + } + + [Fact] + public void LinearToDisplayDb_Should_RespectMagnificationZero() + { + // 放大倍数为 0 时任何输入都应映射为 0 + Assert.Equal(0.0, DecibelCalculator.LinearToDisplayDb(1.0, 0.0), 6); + } + + // ---- DisplayDbToDbFs ---- + + [Theory] + [InlineData(150.0, 0.0)] + [InlineData(70.0, -80.0)] + [InlineData(0.0, -150.0)] + public void DisplayDbToDbFs_Should_ConvertToDbFs(double displayDb, double expectedDbFs) + { + Assert.Equal(expectedDbFs, DecibelCalculator.DisplayDbToDbFs(displayDb), 6); + } + + // ---- DbFsToLinear ---- + + [Theory] + [InlineData(0.0, 1.0)] + [InlineData(-80.0, 0.0001)] + [InlineData(-60.0, 0.001)] + public void DbFsToLinear_Should_ConvertToLinear(double dbfs, double expectedLinear) + { + Assert.Equal(expectedLinear, DecibelCalculator.DbFsToLinear(dbfs), 6); + } + + // ---- CalculateMagnification ---- + + [Fact] + public void CalculateMagnification_Should_ReturnOne_WhenMeasuredMatchesTarget() + { + // 目标 70 dB(-80 dBFS)对应的线性值为 0.0001,若测量值恰好为该值则倍数为 1 + var result = DecibelCalculator.CalculateMagnification(70.0, 0.0001); + Assert.Equal(1.0, result, 6); + } + + [Fact] + public void CalculateMagnification_Should_ClampToMax_WhenMeasuredIsZero() + { + Assert.Equal(1024.0, DecibelCalculator.CalculateMagnification(70.0, 0.0)); + } + + [Fact] + public void CalculateMagnification_Should_ReturnReasonableValue() + { + // 目标 120 dB(-30 dBFS)对应线性值约 0.031623;测量值 0.001 时需要放大约 31.6 倍 + var result = DecibelCalculator.CalculateMagnification(120.0, 0.001); + Assert.Equal(31.6228, result, 3); + } + + [Fact] + public void CalculateMagnification_Should_ClampToZero_WhenMeasuredExceedsTarget() + { + // 测量值远超目标时,放大倍数应趋近 0(不会出现负数) + var result = DecibelCalculator.CalculateMagnification(70.0, 0.5); + Assert.True(result >= 0.0); + Assert.True(result < 0.001); + } + + // ---- 边界与防御性行为 ---- + + [Fact] + public void LinearToDisplayDb_Should_ClampToScaleMax_WhenAmplifiedBeyondFullScale() + { + // 输入超满幅(含放大后超幅)一律 clamp 到刻度上限 150 + Assert.Equal(150.0, DecibelCalculator.LinearToDisplayDb(2.0, 1.0), 6); + Assert.Equal(150.0, DecibelCalculator.LinearToDisplayDb(1.0, 1024.0), 6); + } + + [Fact] + public void LinearToDisplayDb_Should_ClampInfinitiesToScaleRange() + { + Assert.Equal(150.0, DecibelCalculator.LinearToDisplayDb(double.PositiveInfinity, 1.0), 6); + Assert.Equal(0.0, DecibelCalculator.LinearToDisplayDb(double.NegativeInfinity, 1.0), 6); + } + + [Fact] + public void LinearToDisplayDb_Should_MapSignalThresholdTo70() + { + // 线性阈值 0.0001(-80 dBFS)映射到 70,是"检测到有效信号"的基准点 + Assert.Equal(70.0, DecibelCalculator.LinearToDisplayDb(0.0001, 1.0), 6); + } + + [Fact] + public void CalculateMagnification_Should_RespectCustomMaxMagnification() + { + Assert.Equal(1.0, DecibelCalculator.CalculateMagnification(70.0, 0.0001, 64.0), 6); + Assert.Equal(64.0, DecibelCalculator.CalculateMagnification(150.0, 0.0001, 64.0), 6); + } + + [Fact] + public void CalculateMagnification_Should_ClampToMax_WhenMeasuredIsNegative() + { + // 负的测量值视为"无法测量",与 0 一样返回上限 + Assert.Equal(1024.0, DecibelCalculator.CalculateMagnification(70.0, -1.0)); + } +} diff --git a/Tests/Decibel_Monitor.Tests/Decibel_Monitor.Tests.csproj b/Tests/Decibel_Monitor.Tests/Decibel_Monitor.Tests.csproj new file mode 100644 index 0000000..e69ca78 --- /dev/null +++ b/Tests/Decibel_Monitor.Tests/Decibel_Monitor.Tests.csproj @@ -0,0 +1,27 @@ + + + + net8.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + diff --git a/Tests/Decibel_Monitor.Tests/PeakSampleTests.cs b/Tests/Decibel_Monitor.Tests/PeakSampleTests.cs new file mode 100644 index 0000000..18b3438 --- /dev/null +++ b/Tests/Decibel_Monitor.Tests/PeakSampleTests.cs @@ -0,0 +1,86 @@ +using System; +using Decibel_Monitor.Services; + +namespace Decibel_Monitor.Tests; + +public class PeakSampleTests +{ + // ---- 16-bit PCM ---- + + [Fact] + public void ComputePeak_16BitPcm_Should_FindMaximumAbsoluteSample() + { + var buffer = new byte[6]; + // 两个 16-bit 采样:0x1000 (4096) 与 -0x4000 (-16384) + WriteInt16(buffer, 0, 4096); + WriteInt16(buffer, 2, -16384); + WriteInt16(buffer, 4, 0); + + var peak = PeakSample.ComputePeak(buffer, buffer.Length, false, 16); + + Assert.Equal(0.5f, peak, 5); // 16384 / 32768 + } + + [Fact] + public void ComputePeak_16BitPcm_Should_HandleFullScale() + { + var buffer = new byte[2]; + WriteInt16(buffer, 0, short.MinValue); // -32768 + + var peak = PeakSample.ComputePeak(buffer, buffer.Length, false, 16); + + Assert.Equal(1.0f, peak, 5); + } + + // ---- 32-bit float ---- + + [Fact] + public void ComputePeak_IeeeFloat_Should_FindMaximumAbsoluteSample() + { + var buffer = new byte[8]; + BitConverter.GetBytes(-0.25f).CopyTo(buffer, 0); + BitConverter.GetBytes(1.0f).CopyTo(buffer, 4); + + var peak = PeakSample.ComputePeak(buffer, buffer.Length, true, 32); + + Assert.Equal(1.0f, peak, 5); + } + + // ---- 32-bit PCM ---- + + [Fact] + public void ComputePeak_32BitPcm_Should_FindMaximumAbsoluteSample() + { + var buffer = new byte[8]; + BitConverter.GetBytes(0x20000000).CopyTo(buffer, 0); // 536870912 + BitConverter.GetBytes(unchecked((int)0x80000000)).CopyTo(buffer, 4); // int.MinValue + + var peak = PeakSample.ComputePeak(buffer, buffer.Length, false, 32); + + Assert.Equal(1.0f, peak, 5); + } + + // ---- 边界 ---- + + [Fact] + public void ComputePeak_EmptyOrNull_Should_ReturnZero() + { + Assert.Equal(0f, PeakSample.ComputePeak(Array.Empty(), 0, false, 16)); + Assert.Equal(0f, PeakSample.ComputePeak(new byte[] { 1, 2 }, 0, false, 16)); + } + + [Fact] + public void ComputePeak_UnknownFormat_Should_ReturnZero() + { + // 24-bit 等未支持格式不应抛异常 + var buffer = new byte[3]; + var peak = PeakSample.ComputePeak(buffer, buffer.Length, false, 24); + Assert.Equal(0f, peak); + } + + private static void WriteInt16(byte[] buffer, int offset, short value) + { + buffer[offset] = (byte)(value & 0xFF); + buffer[offset + 1] = (byte)((value >> 8) & 0xFF); + } +} From 902060604047cfcfdba638a7d116c92bc4fcdbb3 Mon Sep 17 00:00:00 2001 From: Starsky16 <163619709+Starsky16@users.noreply.github.com> Date: Tue, 15 Sep 2026 02:01:25 +0800 Subject: [PATCH 02/25] =?UTF-8?q?refactor:=20=E7=BB=84=E4=BB=B6=E6=94=B9?= =?UTF-8?q?=E7=94=A8=E5=85=B1=E4=BA=AB=E9=87=87=E6=A0=B7=E6=9C=8D=E5=8A=A1?= =?UTF-8?q?=E5=B9=B6=E7=B2=BE=E7=AE=80=E7=BB=84=E4=BB=B6=E8=AE=BE=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DecibelComponent 从宿主 DI 获取 AudioPeakMeter 与 DecibelMonitorSettingsService,改用 DecibelCalculator 做线性峰值到显示刻度的换算,不再自行枚举设备并三级回退采样。 - 数值改用 AvaloniaProperty(StyledProperty)绑定,移除手写的 INotifyPropertyChanged;采样 Tick 增加节流,避免异步任务堆积;放大倍数取自全局设置,同一麦克风所有组件数值一致。 - 组件设置控件精简为显示侧偏好(分贝前缀、更新频率);测量/校准与提醒设置分别移至插件设置页与通知提供方设置。 - Plugin 移除重复的 GetVoicePeakValue()。 - 编译警告由 3 个降为 0 个(原 CS0108/CS4014 随旧实现一并移除)。 由 VS Code 中的 Cline 辅助生成。 --- .../DecibelComponentSettingsControl.axaml | 49 ++-- .../DecibelComponentSettingsControl.axaml.cs | 249 +----------------- Controls/Components/DecibelComponent.axaml | 52 +--- Controls/Components/DecibelComponent.axaml.cs | 229 ++++------------ .../DecibelComponentSettings.cs | 17 +- Plugin.cs | 12 +- 6 files changed, 104 insertions(+), 504 deletions(-) diff --git a/Controls/ComponentSettings/DecibelComponentSettingsControl.axaml b/Controls/ComponentSettings/DecibelComponentSettingsControl.axaml index 6e15a48..d6b6341 100644 --- a/Controls/ComponentSettings/DecibelComponentSettingsControl.axaml +++ b/Controls/ComponentSettings/DecibelComponentSettingsControl.axaml @@ -1,17 +1,3 @@ - - - + - - - - - + + - -