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/14] =?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/14] =?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 @@
-
-
-
+
-
-
-
-
-
+
+
-
-
-
-
+
+
+
+
-
+
+
+
-
\ No newline at end of file
+
+
+
diff --git a/Controls/ComponentSettings/DecibelComponentSettingsControl.axaml.cs b/Controls/ComponentSettings/DecibelComponentSettingsControl.axaml.cs
index 9a1a1bb..8c6e300 100644
--- a/Controls/ComponentSettings/DecibelComponentSettingsControl.axaml.cs
+++ b/Controls/ComponentSettings/DecibelComponentSettingsControl.axaml.cs
@@ -1,258 +1,27 @@
-using System;
-using System.ComponentModel;
-using System.Linq;
-using System.Threading.Tasks;
-using Avalonia.Controls;
-using Avalonia.Interactivity;
-using Avalonia.Platform.Storage;
-using Avalonia.Threading;
+using System;
using ClassIsland.Core.Abstractions.Controls;
-using ClassIsland.Core.Controls;
using Decibel_Monitor.Models.ComponentSettings;
-using NAudio.CoreAudioApi;
-using NAudio.Wave;
namespace Decibel_Monitor.Controls.ComponentSettings;
-//
-// 分贝组件设置控件类,用于管理分贝监测组件的相关配置界面
-// 继承自ComponentBase基类,使用DecibelComponentSettings作为泛型参数
-//
-public partial class DecibelComponentSettingsControl : ComponentBase, INotifyPropertyChanged, IDisposable
+///
+/// 分贝组件设置控件("小组件显示侧")。
+/// 仅包含该组件的显示偏好;测量/校准(全局)与提醒设置分别位于插件设置页与通知提供方设置。
+///
+public partial class DecibelComponentSettingsControl : ComponentBase, IDisposable
{
- private readonly MMDeviceEnumerator _enumerator;
private bool _disposed;
- // 默认改为 -80 dBFS 对应显示 dB 为 70(因为 dB = dBFS + 150),但这里保留为 -80 dBFS 的语义先前使用的默认
- // 当前 ReferenceDecibel 表示 UI 上的 dB(0..150)
- private double _referenceDecibel = 70.0; // 显示层面的默认 dB(可调整为合适值)
- public event PropertyChangedEventHandler? PropertyChanged;
-
- public double ReferenceDecibel
- {
- get => _referenceDecibel;
- set
- {
- if (Math.Abs(_referenceDecibel - value) < 0.0001) return;
- _referenceDecibel = value;
- PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ReferenceDecibel)));
- }
- }
-
public DecibelComponentSettingsControl()
{
InitializeComponent();
- _enumerator = new MMDeviceEnumerator();
- }
-
- // 可选:列出系统捕获设备用于诊断(调试时调用)
- private void ShowAvailableCaptureDevices()
- {
- try
- {
- var devices = _enumerator.EnumerateAudioEndPoints(DataFlow.Capture, DeviceState.Active)
- .Select(d => $"{d.FriendlyName} ({d.ID})");
- var text = string.Join("\n", devices);
- CommonTaskDialogs.ShowDialog("捕获设备列表", text);
- }
- catch
- {
- /* 忽略诊断失败 */
- }
- }
-
- // 异步版:返回当前默认音频输入设备的线性峰值(0..1)
- // 增加多重回退:MMDevice.AudioMeterInformation -> WasapiCapture(device) -> WasapiCapture() -> WaveInEvent
- public async Task GetVoicePeakLinearAsync()
- {
- try
- {
- var captureDevices = _enumerator.EnumerateAudioEndPoints(DataFlow.Capture, DeviceState.Active).ToArray();
- var defaultDevice = _enumerator.GetDefaultAudioEndpoint(DataFlow.Capture, Role.Console);
- var selected = captureDevices.FirstOrDefault(c => c.ID == defaultDevice.ID) ?? defaultDevice;
-
- // 1) 先读 AudioMeterInformation(低成本)
- var linearValue = selected?.AudioMeterInformation?.MasterPeakValue ?? 0f;
- if (linearValue > 0.0001f)
- {
- return linearValue;
- }
-
- // 2) 尝试 WasapiCapture 指定设备
- try
- {
- var v = await GetVoicePeakLinearByWasapiCaptureAsync(selected, 600).ConfigureAwait(false);
- if (v > 0.0001f) return v;
- }
- catch { /* 忽略,继续回退 */ }
-
- // 3) 尝试 WasapiCapture 默认设备(无 device 参数)
- try
- {
- var v = await GetVoicePeakLinearByWasapiCaptureAsync(null, 600).ConfigureAwait(false);
- if (v > 0.0001f) return v;
- }
- catch { /* 忽略 */ }
-
- // 4) 尝试 WaveInEvent(旧 API,兼容性更好)
- try
- {
- var v = await GetVoicePeakLinearByWaveInAsync(600).ConfigureAwait(false);
- if (v > 0.0001f) return v;
- }
- catch { /* 忽略 */ }
-
- return 0f;
- }
- catch
- {
- return 0f;
- }
- }
-
- // 使用 WasapiCapture(可传入 MMDevice 或 null 表示默认设备)
- private async Task GetVoicePeakLinearByWasapiCaptureAsync(MMDevice? device, int captureMs = 600)
- {
- try
- {
- 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;
- if (wf.Encoding == WaveFormatEncoding.IeeeFloat)
- {
- for (int n = 0; n + 4 <= e.BytesRecorded; n += 4)
- {
- float sample = BitConverter.ToSingle(e.Buffer, n);
- maxSample = Math.Max(maxSample, Math.Abs(sample));
- }
- }
- else if (wf.BitsPerSample == 16)
- {
- for (int n = 0; n + 2 <= e.BytesRecorded; n += 2)
- {
- short s16 = BitConverter.ToInt16(e.Buffer, n);
- float sample = Math.Abs(s16 / 32768f);
- maxSample = Math.Max(maxSample, sample);
- }
- }
- else if (wf.BitsPerSample == 32)
- {
- for (int n = 0; n + 4 <= e.BytesRecorded; n += 4)
- {
- int i32 = BitConverter.ToInt32(e.Buffer, n);
- float sample = Math.Abs(i32 / (float)int.MaxValue);
- maxSample = Math.Max(maxSample, sample);
- }
- }
- }
- catch
- {
- }
- };
-
- capture.RecordingStopped += (s, e) => tcs.TrySetResult(true);
-
- capture.StartRecording();
- await Task.Delay(captureMs).ConfigureAwait(false);
- capture.StopRecording();
- await Task.WhenAny(tcs.Task, Task.Delay(1000)).ConfigureAwait(false);
-
- return Math.Clamp(maxSample, 0f, 1f);
- }
- catch
- {
- return 0f;
- }
- }
-
- // 使用 WaveInEvent 作为最后回退(与系统默认设备交互良好)
- private async Task GetVoicePeakLinearByWaveInAsync(int captureMs = 600)
- {
- try
- {
- 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
- {
- for (int i = 0; i + 2 <= e.BytesRecorded; i += 2)
- {
- short s16 = BitConverter.ToInt16(e.Buffer, i);
- float sample = Math.Abs(s16 / 32768f);
- maxSample = Math.Max(maxSample, sample);
- }
- }
- catch { }
- };
- waveIn.RecordingStopped += (s, e) => tcs.TrySetResult(true);
-
- waveIn.StartRecording();
- await Task.Delay(captureMs).ConfigureAwait(false);
- waveIn.StopRecording();
- await Task.WhenAny(tcs.Task, Task.Delay(1000)).ConfigureAwait(false);
-
- return Math.Clamp(maxSample, 0f, 1f);
- }
- catch
- {
- return 0f;
- }
}
- // 异步事件处理:避免阻塞 UI 线程
- public async void On_Calibrate(object? sender, RoutedEventArgs e)
- {
- try
- {
- float measuredLinear = await GetVoicePeakLinearAsync().ConfigureAwait(false);
-
- await Dispatcher.UIThread.InvokeAsync(() =>
- {
- if (measuredLinear <= 0f)
- {
- try { CommonTaskDialogs.ShowDialog("校准结果", "未检测到有效输入,无法完成校准。请确认麦克风已启用并有声源。"); } catch { }
- return;
- }
-
- // ReferenceDecibel 为 UI 上的 dB(0..150)
- double targetDb = ReferenceDecibel;
- // 将显示 dB 转换为 dBFS(与组件显示一致):dBFS = dB - 150
- double targetDbFS = targetDb - 150.0;
-
- double targetLinear = Math.Pow(10.0, targetDbFS / 20.0);
- double magnification = targetLinear / measuredLinear;
- // 限制放大倍数上限为 1024,下限 0
- magnification = Math.Clamp(magnification, 0.0, 1024.0);
-
- double adjustedLinear = measuredLinear * magnification;
- double adjustedDb = adjustedLinear > 0 ? 20.0 * Math.Log10(adjustedLinear) : double.NegativeInfinity;
- double measuredDb = measuredLinear > 0 ? 20.0 * Math.Log10(measuredLinear) : double.NegativeInfinity;
-
- if (Settings is not null)
- {
- Settings.Magnification = magnification;
- try { CommonTaskDialogs.ShowDialog("校准结果", $"测量线性值: {measuredLinear:F4}\n" + $"测量 dBFS: {measuredDb:F1} dB\n" + $"目标 dB (显示): {targetDb:F1} dB\n" + $"目标 dBFS: {targetDbFS:F1} dBFS\n" + $"放大倍数: {magnification:F6}×\n" + $"放大后 dBFS (验证): {adjustedDb:F1} dB"); } catch { }
- }
- else { try { CommonTaskDialogs.ShowDialog("校准完成(未保存)", $"测量线性值: {measuredLinear:F4}\n" + $"测量 dBFS: {measuredDb:F1} dB\n" + $"目标 dB (显示): {targetDb:F1} dB\n" + $"目标 dBFS: {targetDbFS:F1} dBFS\n" + $"建议放大倍数: {magnification:F6}×\n" + $"放大后 dBFS (验证): {adjustedDb:F1} dB\n\n" + "当前控件未绑定到组件实例,无法直接保存设置。"); } catch { } }
- });
- }
- catch (Exception ex) { try { CommonTaskDialogs.ShowDialog("校准失败", $"发生异常:{ex.Message}"); } catch { } }
- }
public void Dispose()
{
if (_disposed) return;
_disposed = true;
- _enumerator.Dispose();
}
-}
\ No newline at end of file
+}
+
+
diff --git a/Controls/Components/DecibelComponent.axaml b/Controls/Components/DecibelComponent.axaml
index 29ada91..4fb46e7 100644
--- a/Controls/Components/DecibelComponent.axaml
+++ b/Controls/Components/DecibelComponent.axaml
@@ -1,21 +1,3 @@
-
-
-
-
+
-
-
-
-
+
-
\ No newline at end of file
+
diff --git a/Controls/Components/DecibelComponent.axaml.cs b/Controls/Components/DecibelComponent.axaml.cs
index f648911..80e848d 100644
--- a/Controls/Components/DecibelComponent.axaml.cs
+++ b/Controls/Components/DecibelComponent.axaml.cs
@@ -1,47 +1,48 @@
-using System;
-using System.ComponentModel;
-using System.Linq;
-using System.Threading.Tasks;
+using System;
+using Avalonia;
using Avalonia.Threading;
using ClassIsland.Core.Abstractions.Controls;
using ClassIsland.Core.Attributes;
+using ClassIsland.Shared;
+using Decibel_Monitor.Services;
using DecibelComponentSettings = Decibel_Monitor.Models.ComponentSettings.DecibelComponentSettings;
-using NAudio.CoreAudioApi;
-using NAudio.Wave;
namespace Decibel_Monitor.Controls.Components;
[ComponentInfo(
- "FFFFFFFF-EEEE-DDDD-CCCC-BBBBBBBBBBBB",
+ "3540a8df-963c-45e9-a42d-8a55824994d5",
"分贝值",
"\uEB88",
"在主界面上显示麦克风分贝值。"
)]
-public partial class DecibelComponent : ComponentBase, INotifyPropertyChanged, IDisposable
+public partial class DecibelComponent : ComponentBase, IDisposable
{
- private readonly DispatcherTimer _updateTimer;
- private readonly MMDeviceEnumerator _enumerator;
- private bool _disposed;
- private string _currentDecibelValue = "N/A";
-
- public event PropertyChangedEventHandler? PropertyChanged;
+ /// 当前显示的分贝值文本。
+ public static readonly StyledProperty CurrentDecibelValueProperty =
+ AvaloniaProperty.Register(nameof(CurrentDecibelValue), "N/A");
+ private readonly DispatcherTimer _updateTimer;
+ private readonly AudioPeakMeter? _audioPeakMeter;
+ private readonly DecibelMonitorSettingsService? _settingsService;
+ private volatile bool _disposed;
+ private volatile bool _isUpdating;
+
+ ///
+ /// 当前显示的分贝值文本(Avalonia 属性,绑定自动响应变化)。
+ ///
public string CurrentDecibelValue
{
- get => _currentDecibelValue;
- private set
- {
- if (_currentDecibelValue == value) return;
- _currentDecibelValue = value;
- PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(CurrentDecibelValue)));
- }
+ get => GetValue(CurrentDecibelValueProperty);
+ private set => SetValue(CurrentDecibelValueProperty, value);
}
public DecibelComponent()
{
InitializeComponent();
- _enumerator = new MMDeviceEnumerator();
+ // 从宿主 DI 容器获取共享的峰值采样服务(单例,含并发保护与缓存)与全局设置
+ _audioPeakMeter = IAppHost.Host?.Services.GetService(typeof(AudioPeakMeter)) as AudioPeakMeter;
+ _settingsService = IAppHost.Host?.Services.GetService(typeof(DecibelMonitorSettingsService)) as DecibelMonitorSettingsService;
_updateTimer = new DispatcherTimer
{
@@ -54,178 +55,52 @@ public DecibelComponent()
// 异步 Tick,内部会异步采样(不会阻塞 UI)
private async void UpdateTimer_Tick(object? sender, EventArgs e)
{
+ // 节流:上一次采样尚未结束时跳过本次 Tick,避免耗时采样导致异步任务堆积
+ if (_isUpdating) return;
+ _isUpdating = true;
try
{
- var captureDevices = _enumerator.EnumerateAudioEndPoints(DataFlow.Capture, DeviceState.Active).ToArray();
- var defaultDevice = _enumerator.GetDefaultAudioEndpoint(DataFlow.Capture, Role.Console);
- var selectedDevice = captureDevices.FirstOrDefault(c => c.ID == defaultDevice.ID) ?? defaultDevice;
-
- if (selectedDevice?.AudioMeterInformation is null)
+ // 应用设置的更新频率(设置变化后下一拍即生效)
+ int intervalMs = Math.Clamp(Settings?.UpdateIntervalMs ?? 200, 100, 5000);
+ if (Math.Abs(_updateTimer.Interval.TotalMilliseconds - intervalMs) > 0.5)
{
- CurrentDecibelValue = "无设备";
- return;
+ _updateTimer.Interval = TimeSpan.FromMilliseconds(intervalMs);
}
- // 获取线性峰值(0..1),内部含多重回退采样
- float linear = await GetVoicePeakLinearAsync(selectedDevice).ConfigureAwait(false);
-
- // 应用放大倍数(保护 Settings 为空)
- double magnification = Settings?.Magnification ?? 1.0;
- linear = (float)(linear * magnification);
-
- if (linear <= 0f)
+ if (_audioPeakMeter is null)
{
- CurrentDecibelValue = "0.0";
+ CurrentDecibelValue = "无采样服务";
return;
}
- // 计算 dBFS(通常为负值)
- double dbfs = 20.0 * Math.Log10(linear);
-
- // 映射为 0..150:使用 150 + dBFS,再 clamp 到 0..150
- double mapped = Math.Clamp(150.0 + dbfs, 0.0, 150.0);
-
- CurrentDecibelValue = $"{mapped:F1}";
- }
- catch (Exception)
- {
- CurrentDecibelValue = "读取失败";
- }
- }
-
- // 与设置控件类似的多重回退采样函数(优先 AudioMeterInformation -> WasapiCapture -> WaveIn)
- private async Task GetVoicePeakLinearAsync(MMDevice selected)
- {
- try
- {
- // 1) 尝试 AudioMeterInformation
- var val = selected?.AudioMeterInformation?.MasterPeakValue ?? 0f;
- if (val > 0.0001f) return val;
-
- // 2) 尝试 WasapiCapture 指定设备
- try
- {
- var v = await GetVoicePeakLinearByWasapiCaptureAsync(selected, 300).ConfigureAwait(false);
- if (v > 0.0001f) return v;
- }
- catch { }
+ // 优先走实时计量;不可用时自动使用短时录音回退采样(带缓存与并发保护)
+ float linear = await _audioPeakMeter.GetDefaultDevicePeakLinearAsync().ConfigureAwait(false);
- // 3) 尝试 WasapiCapture 默认设备
- try
- {
- var v = await GetVoicePeakLinearByWasapiCaptureAsync(null, 300).ConfigureAwait(false);
- if (v > 0.0001f) return v;
- }
- catch { }
+ // 放大倍数为全局校准结果(同一麦克风所有组件显示一致)
+ double magnification = _settingsService?.Settings.Magnification ?? 1.0;
+ double mapped = DecibelCalculator.LinearToDisplayDb(linear, magnification);
- // 4) 尝试 WaveInEvent 回退
- try
+ // 绑定属性变更放回 UI 线程执行
+ Dispatcher.UIThread.Post(() =>
{
- var v = await GetVoicePeakLinearByWaveInAsync(300).ConfigureAwait(false);
- if (v > 0.0001f) return v;
- }
- catch { }
+ if (_disposed) return;
- return 0f;
- }
- catch
- {
- return 0f;
- }
- }
-
- private async Task GetVoicePeakLinearByWasapiCaptureAsync(MMDevice? device, int captureMs = 300)
- {
- try
- {
- float maxSample = 0f;
- var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ bool showPrefix = Settings?.ShowDecibelPrefix ?? true;
- using var capture = device is null ? new WasapiCapture() : new WasapiCapture(device);
- capture.DataAvailable += (s, e) =>
- {
- try
- {
- var wf = capture.WaveFormat;
- if (wf.Encoding == WaveFormatEncoding.IeeeFloat)
- {
- for (int n = 0; n + 4 <= e.BytesRecorded; n += 4)
- {
- float sample = BitConverter.ToSingle(e.Buffer, n);
- maxSample = Math.Max(maxSample, Math.Abs(sample));
- }
- }
- else if (wf.BitsPerSample == 16)
- {
- for (int n = 0; n + 2 <= e.BytesRecorded; n += 2)
- {
- short s16 = BitConverter.ToInt16(e.Buffer, n);
- float sample = Math.Abs(s16 / 32768f);
- maxSample = Math.Max(maxSample, sample);
- }
- }
- else if (wf.BitsPerSample == 32)
- {
- for (int n = 0; n + 4 <= e.BytesRecorded; n += 4)
- {
- int i32 = BitConverter.ToInt32(e.Buffer, n);
- float sample = Math.Abs(i32 / (float)int.MaxValue);
- maxSample = Math.Max(maxSample, sample);
- }
- }
- }
- catch { }
- };
-
- capture.RecordingStopped += (s, e) => tcs.TrySetResult(true);
-
- capture.StartRecording();
- await Task.Delay(captureMs).ConfigureAwait(false);
- capture.StopRecording();
- await Task.WhenAny(tcs.Task, Task.Delay(1000)).ConfigureAwait(false);
-
- return Math.Clamp(maxSample, 0f, 1f);
- }
- catch
- {
- return 0f;
+ CurrentDecibelValue = showPrefix ? $"分贝: {mapped:F1}" : $"{mapped:F1}";
+ });
}
- }
-
- private async Task GetVoicePeakLinearByWaveInAsync(int captureMs = 300)
- {
- try
+ catch (Exception)
{
- float maxSample = 0f;
- var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
-
- using var waveIn = new WaveInEvent();
- waveIn.WaveFormat = new WaveFormat(16000, 16, 1);
- waveIn.DataAvailable += (s, e) =>
+ Dispatcher.UIThread.Post(() =>
{
- try
- {
- for (int i = 0; i + 2 <= e.BytesRecorded; i += 2)
- {
- short s16 = BitConverter.ToInt16(e.Buffer, i);
- float sample = Math.Abs(s16 / 32768f);
- maxSample = Math.Max(maxSample, sample);
- }
- }
- catch { }
- };
- waveIn.RecordingStopped += (s, e) => tcs.TrySetResult(true);
-
- waveIn.StartRecording();
- await Task.Delay(captureMs).ConfigureAwait(false);
- waveIn.StopRecording();
- await Task.WhenAny(tcs.Task, Task.Delay(1000)).ConfigureAwait(false);
-
- return Math.Clamp(maxSample, 0f, 1f);
+ if (_disposed) return;
+ CurrentDecibelValue = "读取失败";
+ });
}
- catch
+ finally
{
- return 0f;
+ _isUpdating = false;
}
}
@@ -236,7 +111,5 @@ public void Dispose()
_updateTimer.Tick -= UpdateTimer_Tick;
_updateTimer.Stop();
-
- _enumerator.Dispose();
}
-}
\ No newline at end of file
+}
diff --git a/Models/ComponentSettings/DecibelComponentSettings.cs b/Models/ComponentSettings/DecibelComponentSettings.cs
index 17ee58d..4e04c1a 100644
--- a/Models/ComponentSettings/DecibelComponentSettings.cs
+++ b/Models/ComponentSettings/DecibelComponentSettings.cs
@@ -2,8 +2,21 @@
namespace Decibel_Monitor.Models.ComponentSettings;
+///
+/// 分贝组件的设置。每个组件实例独立持久化,仅保留与"小组件显示侧"相关的显示偏好。
+/// 测量/校准(参考 dB、放大倍数)为全局设置,位于插件设置页(DecibelMonitorGlobalSettings);
+/// 提醒相关设置在通知提供方设置(DecibelNotificationProviderSettings)。
+///
public partial class DecibelComponentSettings : ObservableObject
{
- // 放大倍数,校准后保存到此属性
- [ObservableProperty] private double _magnification = 1.0;
+ ///
+ /// 是否在数值前显示“分贝:”前缀(如“分贝: 70.0”),默认 true。
+ ///
+ [ObservableProperty] private bool _showDecibelPrefix = true;
+
+ ///
+ /// 数值更新频率(毫秒,100..5000),默认 200。
+ /// 越小响应越快但刷新开销越高;常驻捕获流读取下一般 100..500 即可。
+ ///
+ [ObservableProperty] private int _updateIntervalMs = 200;
}
diff --git a/Plugin.cs b/Plugin.cs
index e43c1b7..65bd6dd 100644
--- a/Plugin.cs
+++ b/Plugin.cs
@@ -5,7 +5,6 @@
using ClassIsland.Core.Extensions.Registry;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
-using NAudio.CoreAudioApi;
namespace Decibel_Monitor
{
@@ -27,14 +26,5 @@ public override void Initialize(HostBuilderContext context, IServiceCollection s
// 一次性注册组件与其设置控件(不要重复注册)
services.AddComponent();
}
-
- public float GetVoicePeakValue()
- {
- var enumerator = new MMDeviceEnumerator();
- var CaptureDevices = enumerator.EnumerateAudioEndPoints(DataFlow.Capture, DeviceState.Active).ToArray();
- var defaultDevice = enumerator.GetDefaultAudioEndpoint(DataFlow.Capture, Role.Console);
- var selectedDevice = CaptureDevices.FirstOrDefault(c => c.ID == defaultDevice.ID);
- return selectedDevice?.AudioMeterInformation.MasterPeakValue ?? 0f;
- }
}
-}
\ No newline at end of file
+}
From 4efa976d0d538d343913e97f561bf323d63d749a Mon Sep 17 00:00:00 2001
From: Starsky16 <163619709+Starsky16@users.noreply.github.com>
Date: Tue, 15 Sep 2026 02:03:09 +0800
Subject: [PATCH 03/14] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E5=88=86?=
=?UTF-8?q?=E8=B4=9D=E9=98=88=E5=80=BC=E6=8F=90=E9=86=92=EF=BC=88=E9=80=9A?=
=?UTF-8?q?=E7=9F=A5=E6=8F=90=E4=BE=9B=E6=96=B9=20+=20=E6=B8=A0=E9=81=93?=
=?UTF-8?q?=20+=20=E7=BB=84=E4=BB=B6=E6=8F=90=E7=A4=BA=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 新增 DecibelNotificationProvider:继承 NotificationProviderBase
以获得宿主自动持久化的通知侧设置,注册「超过分贝阈值」渠道并通过 Channel(...) 发送,与内置通知提供方保持一致。
- 提醒判定抽为纯函数 DecibelAlertEvaluator.Evaluate(...):严格大于阈值才触发、冷却期内不重复通知;
配套 9 条边界单元测试(等于阈值不触发、冷却中不通知、冷却刚过可通知、冷却下限 1 分钟等),单元测试总数 37 条全绿。
- 新增通知侧设置控件:启用提醒、阈值(dB)、提醒文字、冷却时间(分钟)。
- 组件展示评估结果:超阈值时显示红色提示文字,可用组件设置里的「组件内提示」关闭(关闭后仍照常发送通知);
提供方实例动态读取,避免组件先于 IHostedService 创建导致提醒永久失效。
- README 补充阈值提醒的使用方式、设置项与排查说明。
由 VS Code 中的 Cline 辅助生成。
---
.../DecibelComponentSettingsControl.axaml | 9 ++
Controls/Components/DecibelComponent.axaml | 8 ++
Controls/Components/DecibelComponent.axaml.cs | 38 +++++-
...lNotificationProviderSettingsControl.axaml | 50 +++++++
...tificationProviderSettingsControl.axaml.cs | 15 ++
Decibel_Monitor.csproj | 3 +
.../DecibelComponentSettings.cs | 6 +
.../DecibelNotificationProviderSettings.cs | 30 ++++
Plugin.cs | 10 +-
README.md | 24 +++-
Services/DecibelAlertEvaluator.cs | 41 ++++++
Services/DecibelAlertState.cs | 8 ++
Services/DecibelNotificationProvider.cs | 128 ++++++++++++++++++
.../DecibelAlertEvaluatorTests.cs | 108 +++++++++++++++
.../Decibel_Monitor.Tests.csproj | 1 +
15 files changed, 471 insertions(+), 8 deletions(-)
create mode 100644 Controls/NotificationProviders/DecibelNotificationProviderSettingsControl.axaml
create mode 100644 Controls/NotificationProviders/DecibelNotificationProviderSettingsControl.axaml.cs
create mode 100644 Models/NotificationProviderSettings/DecibelNotificationProviderSettings.cs
create mode 100644 Services/DecibelAlertEvaluator.cs
create mode 100644 Services/DecibelAlertState.cs
create mode 100644 Services/DecibelNotificationProvider.cs
create mode 100644 Tests/Decibel_Monitor.Tests/DecibelAlertEvaluatorTests.cs
diff --git a/Controls/ComponentSettings/DecibelComponentSettingsControl.axaml b/Controls/ComponentSettings/DecibelComponentSettingsControl.axaml
index d6b6341..90c0456 100644
--- a/Controls/ComponentSettings/DecibelComponentSettingsControl.axaml
+++ b/Controls/ComponentSettings/DecibelComponentSettingsControl.axaml
@@ -17,6 +17,11 @@
DataContext="{Binding RelativeSource={RelativeSource Mode=FindAncestor, AncestorType={x:Type local:DecibelComponentSettingsControl}}}">
+
+
+
+
+
@@ -35,6 +40,10 @@
Text="显示设置:开启“分贝: 前缀”时数值显示为“分贝: 70.0”形式;“更新频率”越小数值响应越快(建议 100–500 ms)。"
TextWrapping="Wrap"/>
+
+
diff --git a/Controls/Components/DecibelComponent.axaml b/Controls/Components/DecibelComponent.axaml
index 4fb46e7..c539c74 100644
--- a/Controls/Components/DecibelComponent.axaml
+++ b/Controls/Components/DecibelComponent.axaml
@@ -16,6 +16,14 @@
FontSize="16"
VerticalAlignment="Center"
Margin="5,0,0,0"/>
+
+
diff --git a/Controls/Components/DecibelComponent.axaml.cs b/Controls/Components/DecibelComponent.axaml.cs
index 80e848d..610265c 100644
--- a/Controls/Components/DecibelComponent.axaml.cs
+++ b/Controls/Components/DecibelComponent.axaml.cs
@@ -21,6 +21,14 @@ public partial class DecibelComponent : ComponentBase,
public static readonly StyledProperty CurrentDecibelValueProperty =
AvaloniaProperty.Register(nameof(CurrentDecibelValue), "N/A");
+ /// 当前是否处于"超过阈值"提醒状态(显示提示文字)。
+ public static readonly StyledProperty IsAlertActiveProperty =
+ AvaloniaProperty.Register(nameof(IsAlertActive));
+
+ /// 超过阈值时显示的提示文字。
+ public static readonly StyledProperty AlertDisplayTextProperty =
+ AvaloniaProperty.Register(nameof(AlertDisplayText), "请保持安静");
+
private readonly DispatcherTimer _updateTimer;
private readonly AudioPeakMeter? _audioPeakMeter;
private readonly DecibelMonitorSettingsService? _settingsService;
@@ -36,6 +44,24 @@ public string CurrentDecibelValue
private set => SetValue(CurrentDecibelValueProperty, value);
}
+ ///
+ /// 当前是否处于"超过阈值"提醒状态(用于显示提示文字)。
+ ///
+ public bool IsAlertActive
+ {
+ get => GetValue(IsAlertActiveProperty);
+ private set => SetValue(IsAlertActiveProperty, value);
+ }
+
+ ///
+ /// 超过阈值时显示的提示文字(取通知设置中的自定义文字)。
+ ///
+ public string AlertDisplayText
+ {
+ get => GetValue(AlertDisplayTextProperty);
+ private set => SetValue(AlertDisplayTextProperty, value);
+ }
+
public DecibelComponent()
{
InitializeComponent();
@@ -80,14 +106,23 @@ private async void UpdateTimer_Tick(object? sender, EventArgs e)
double magnification = _settingsService?.Settings.Magnification ?? 1.0;
double mapped = DecibelCalculator.LinearToDisplayDb(linear, magnification);
- // 绑定属性变更放回 UI 线程执行
+ // 绑定属性变更与提醒评估(含通知触发)都放回 UI 线程执行。
+ // 提醒提供方实例采用"动态读取"而非构造时缓存:插件加载早期组件可能先于
+ // 通知提供方(IHostedService)创建,缓存为 null 将导致提醒永久失效。
Dispatcher.UIThread.Post(() =>
{
if (_disposed) return;
+ var state = DecibelNotificationProvider.Instance?.Evaluate(mapped)
+ ?? new DecibelAlertState(false, "请保持安静");
+
+ // "组件内提示"开关只影响组件上是否显示提示文字,不影响 Evaluate 触发的系统通知
+ bool showAlertText = Settings?.ShowAlertTextOnComponent ?? true;
bool showPrefix = Settings?.ShowDecibelPrefix ?? true;
CurrentDecibelValue = showPrefix ? $"分贝: {mapped:F1}" : $"{mapped:F1}";
+ IsAlertActive = showAlertText && state.IsActive;
+ AlertDisplayText = state.Text;
});
}
catch (Exception)
@@ -113,3 +148,4 @@ public void Dispose()
_updateTimer.Stop();
}
}
+
diff --git a/Controls/NotificationProviders/DecibelNotificationProviderSettingsControl.axaml b/Controls/NotificationProviders/DecibelNotificationProviderSettingsControl.axaml
new file mode 100644
index 0000000..e78ca74
--- /dev/null
+++ b/Controls/NotificationProviders/DecibelNotificationProviderSettingsControl.axaml
@@ -0,0 +1,50 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Controls/NotificationProviders/DecibelNotificationProviderSettingsControl.axaml.cs b/Controls/NotificationProviders/DecibelNotificationProviderSettingsControl.axaml.cs
new file mode 100644
index 0000000..a165359
--- /dev/null
+++ b/Controls/NotificationProviders/DecibelNotificationProviderSettingsControl.axaml.cs
@@ -0,0 +1,15 @@
+using ClassIsland.Core.Abstractions.Controls;
+using Decibel_Monitor.Models.NotificationProviderSettings;
+
+namespace Decibel_Monitor.Controls.NotificationProviders;
+
+///
+/// 分贝提醒通知提供方的设置控件("强调通知侧")。
+///
+public partial class DecibelNotificationProviderSettingsControl : NotificationProviderControlBase
+{
+ public DecibelNotificationProviderSettingsControl()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/Decibel_Monitor.csproj b/Decibel_Monitor.csproj
index b9dda6d..febbce4 100644
--- a/Decibel_Monitor.csproj
+++ b/Decibel_Monitor.csproj
@@ -34,6 +34,9 @@
DecibelComponent.axaml
+
+ DecibelNotificationProviderSettingsControl.axaml
+
diff --git a/Models/ComponentSettings/DecibelComponentSettings.cs b/Models/ComponentSettings/DecibelComponentSettings.cs
index 4e04c1a..e5de823 100644
--- a/Models/ComponentSettings/DecibelComponentSettings.cs
+++ b/Models/ComponentSettings/DecibelComponentSettings.cs
@@ -9,6 +9,12 @@ namespace Decibel_Monitor.Models.ComponentSettings;
///
public partial class DecibelComponentSettings : ObservableObject
{
+ ///
+ /// 是否在组件上显示"超过阈值"的提示文字(如“请保持安静”),默认 true。
+ /// 关闭后超阈值仍会发送系统通知,但不在组件上显示红色提示文字。
+ ///
+ [ObservableProperty] private bool _showAlertTextOnComponent = true;
+
///
/// 是否在数值前显示“分贝:”前缀(如“分贝: 70.0”),默认 true。
///
diff --git a/Models/NotificationProviderSettings/DecibelNotificationProviderSettings.cs b/Models/NotificationProviderSettings/DecibelNotificationProviderSettings.cs
new file mode 100644
index 0000000..691c2e8
--- /dev/null
+++ b/Models/NotificationProviderSettings/DecibelNotificationProviderSettings.cs
@@ -0,0 +1,30 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+
+namespace Decibel_Monitor.Models.NotificationProviderSettings;
+
+///
+/// 分贝提醒(通知提供方)的设置。由 ClassIsland 宿主负责持久化。
+/// 对应"强调通知侧"的设置项:提醒开关、阈值、正文与冷却时间。
+///
+public partial class DecibelNotificationProviderSettings : ObservableObject
+{
+ ///
+ /// 是否启用分贝阈值提醒。
+ ///
+ [ObservableProperty] private bool _isAlertEnabled = false;
+
+ ///
+ /// 提醒阈值(显示刻度 0..150),超过该值触发提醒。
+ ///
+ [ObservableProperty] private double _alertThreshold = 120.0;
+
+ ///
+ /// 提醒正文(通知横幅显示的文字)。
+ ///
+ [ObservableProperty] private string _alertText = "请保持安静";
+
+ ///
+ /// 触发提醒后的冷却时间(分钟),避免频繁提醒,默认 10 分钟。
+ ///
+ [ObservableProperty] private int _alertCooldownMinutes = 10;
+}
diff --git a/Plugin.cs b/Plugin.cs
index 65bd6dd..fa15740 100644
--- a/Plugin.cs
+++ b/Plugin.cs
@@ -1,7 +1,6 @@
using System;
using ClassIsland.Core.Abstractions;
using ClassIsland.Core.Attributes;
-using ClassIsland.Core.Controls;
using ClassIsland.Core.Extensions.Registry;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
@@ -13,10 +12,7 @@ public class Plugin : PluginBase
{
public override void Initialize(HostBuilderContext context, IServiceCollection services)
{
- // 可选欢迎提示
- //CommonTaskDialogs.ShowDialog("Hello world!", "Hello from Decibel_Monitor!");
-
- // 插件全局设置(保存于插件配置目录,供采样服务与设置页共享)
+ // 插件全局设置(保存于插件配置目录,供设置页与采样服务共享)
var globalSettingsService = new Services.DecibelMonitorSettingsService(PluginConfigFolder);
services.AddSingleton(globalSettingsService);
@@ -25,6 +21,10 @@ public override void Initialize(HostBuilderContext context, IServiceCollection s
// 一次性注册组件与其设置控件(不要重复注册)
services.AddComponent();
+
+ // 分贝提醒通知提供方(含"强调通知侧"设置控件)
+ services.AddNotificationProvider();
}
}
}
diff --git a/README.md b/README.md
index af425c0..5e2255d 100644
--- a/README.md
+++ b/README.md
@@ -7,8 +7,7 @@
- 实时显示麦克风分贝值(映射到 0–150 范围以便在 UI 中直观显示)。
- 校准功能:根据目标 dBFS 自动计算并保存放大倍数(Settings.Magnification)。
- 多重回退采样:当设备不提供实时计量时,自动使用短时录音回退采样以获取峰值。
-- 超出指定值自动显示提示文字(未实现)
-- 超出指定值提醒(未实现)
+- 阈值提醒:超过指定分贝值时,在组件上显示提示文字,并通过 ClassIsland 通知系统弹出醒目提醒(带冷却时间防刷屏)。
## 依赖
@@ -26,6 +25,15 @@
- 将参考滑块设置为目标 dBFS(默认 -80,范围 -150..0)。
- 点击“校准”按钮。插件采样后计算放大倍数并保存到 `Settings.Magnification`(若设置控件未绑定则会提示并显示建议倍数)。
+### 阈值提醒(通知设置)
+
+1. 打开 **设置 → 通知 → 分贝提醒**。
+2. 开启“启用提醒”,设置阈值(dB)、提醒文字与冷却时间(分钟)。
+3. 当分贝值超过阈值时:
+ - 组件上显示提示文字(可在组件设置中关闭“组件内提示”);
+ - 通过 ClassIsland 通知系统弹出醒目提醒;
+ - 冷却时间内不会重复提醒(默认 10 分钟)。
+
### 校准公式
- 测量得到的线性峰值:`measuredLinear`(0..1)
@@ -37,10 +45,21 @@
## 设置项(DecibelComponentSettings)
- `Magnification` (double):放大倍数,由校准或手动设置,默认 1.0。
+- `ShowAlertTextOnComponent` (bool):是否在组件上显示“超过阈值”的提示文字,默认 true;关闭后仍会发送系统通知,但不在组件上显示提示文字。
- 参考 dBFS 在设置面板中通过滑块调整。
+## 阈值提醒设置项(DecibelNotificationProviderSettings)
+
+- `IsAlertEnabled` (bool):是否启用阈值提醒,默认 false。
+- `AlertThreshold` (double):提醒阈值(显示刻度 0..150),默认 120。
+- `AlertText` (string):提醒文字(通知横幅与组件提示),默认“请保持安静”。
+- `AlertCooldownMinutes` (int):触发提醒后的冷却时间(分钟),默认 10。
+
## 故障排查
+- 提醒一直没有触发:
+ - 确认 ClassIsland 全局通知开关与 **设置 → 通知 → 分贝提醒** 的开关均已开启。
+ - 确认分贝值确实超过了设定阈值(长期显示 0 请参考下一条)。
- 分贝值长期显示 `0.0`:
- 确认宿主应用有麦克风权限(Windows 隐私设置)。
- 确认默认捕获设备已启用并未被独占。
@@ -57,6 +76,7 @@
## 开发与贡献
- 欢迎提交 issue 或 PR。请遵循仓库中的贡献指南(若存在 CONTRIBUTING.md)提交风格一致的修改。
+- 单元测试:`Tests/Decibel_Monitor.Tests` 覆盖 `DecibelCalculator` 与 `PeakSample` 纯函数(Windows 环境运行 `dotnet test`)。
- Idea来自:[HAHAHAHAHAYINING](https://github.com/HAHAHAHAHAYINING),[讨论#561](https://github.com/ClassIsland/ClassIsland/discussions/561)
- 主要开发者:[Yeson38](https://github.com/Yeson38)
- 参考代码:[CIImage](https://github.com/lrsgzs/CIImage)
diff --git a/Services/DecibelAlertEvaluator.cs b/Services/DecibelAlertEvaluator.cs
new file mode 100644
index 0000000..30b6bf0
--- /dev/null
+++ b/Services/DecibelAlertEvaluator.cs
@@ -0,0 +1,41 @@
+using System;
+
+namespace Decibel_Monitor.Services;
+
+///
+/// 分贝提醒评估结果。
+///
+/// 是否处于“超过阈值”提醒状态(用于组件显示提示文字)。
+/// 本次评估是否需要发送提醒(超阈值且冷却已过)。
+/// 下一次允许发送提醒的时间(UTC)。
+public readonly record struct DecibelAlertDecision(bool IsActive, bool ShouldNotify, DateTime NextAlertTimeUtc);
+
+///
+/// 分贝提醒评估纯函数(与 UI / 宿主无关,便于单元测试)。
+///
+public static class DecibelAlertEvaluator
+{
+ ///
+ /// 评估当前分贝映射值是否触发提醒。
+ ///
+ /// 当前分贝映射值(显示刻度 0..150)。
+ /// 提醒阈值(显示刻度)。
+ /// 是否启用提醒。
+ /// 当前时间(UTC)。
+ /// 上一次记录的下一次可提醒时间(UTC)。
+ /// 冷却时间(分钟),最小按 1 分钟处理。
+ /// 阈值比较为严格大于:等于阈值不触发。
+ public static DecibelAlertDecision Evaluate(
+ double mappedDb,
+ double threshold,
+ bool enabled,
+ DateTime nowUtc,
+ DateTime nextAlertTimeUtc,
+ int cooldownMinutes)
+ {
+ var isActive = enabled && mappedDb > threshold;
+ var shouldNotify = isActive && nowUtc >= nextAlertTimeUtc;
+ var next = shouldNotify ? nowUtc.AddMinutes(Math.Max(1, cooldownMinutes)) : nextAlertTimeUtc;
+ return new DecibelAlertDecision(isActive, shouldNotify, next);
+ }
+}
diff --git a/Services/DecibelAlertState.cs b/Services/DecibelAlertState.cs
new file mode 100644
index 0000000..e7084b3
--- /dev/null
+++ b/Services/DecibelAlertState.cs
@@ -0,0 +1,8 @@
+namespace Decibel_Monitor.Services;
+
+///
+/// 分贝提醒提供方对一次分贝值的评估结果。
+///
+/// 当前是否处于"超过阈值"提醒状态(供组件显示提示文字)。
+/// 提醒文字(来自通知设置)。
+public sealed record DecibelAlertState(bool IsActive, string Text);
diff --git a/Services/DecibelNotificationProvider.cs b/Services/DecibelNotificationProvider.cs
new file mode 100644
index 0000000..e9b3326
--- /dev/null
+++ b/Services/DecibelNotificationProvider.cs
@@ -0,0 +1,128 @@
+using System;
+using ClassIsland.Core.Abstractions.Services.NotificationProviders;
+using ClassIsland.Core.Attributes;
+using ClassIsland.Core.Models.Notification;
+using Decibel_Monitor.Models.NotificationProviderSettings;
+using Microsoft.Extensions.Logging;
+
+namespace Decibel_Monitor.Services;
+
+///
+/// 分贝提醒通知提供方。
+/// 负责集中评估"当前分贝值是否超过阈值"并触发 ClassIsland 提醒(带冷却防刷屏),
+/// 同时向组件返回评估状态用于显示提示文字。
+///
+///
+/// 修复说明:
+/// 1. 由非泛型 改为泛型
+/// ,从而获得宿主自动持久化的设置
+/// ("强调通知侧"的设置项),并声明 。
+/// 2. 注册 渠道,并通过 Channel(...) 发送,
+/// 与 ClassIsland 内置通知提供方保持一致,避免使用空渠道 GUID。
+///
+[NotificationProviderInfo(
+ "54f1b836-efa4-4755-bbac-7460f697cbb0",
+ "分贝提醒",
+ "\uE02A",
+ "当麦克风分贝值超出设定阈值时发出醒目提醒。"
+)]
+[NotificationChannelInfo(
+ OverThresholdChannelId,
+ "超过分贝阈值",
+ "\uE02A",
+ description: "当麦克风分贝值超过设定阈值时发出的提醒。"
+)]
+public sealed class DecibelNotificationProvider : NotificationProviderBase, IDisposable
+{
+ /// "超过分贝阈值"提醒渠道 GUID。
+ public const string OverThresholdChannelId = "39d7bd60-b8a1-4b2f-a099-71c576761064";
+
+ private static DecibelNotificationProvider? _instance;
+
+ private readonly ILogger? _logger;
+
+ private DateTime _nextAlertTimeUtc = DateTime.MinValue;
+
+ ///
+ /// 当前通知提供方实例(宿主启动后由 DI 以单例方式创建)。
+ ///
+ public static DecibelNotificationProvider? Instance => _instance;
+
+ public DecibelNotificationProvider(ILogger? logger = null)
+ {
+ _logger = logger;
+ _instance = this;
+ }
+
+ ///
+ /// 评估当前分贝映射值是否超阈值;需要时(超阈值且冷却已过)发送提醒。
+ /// 供组件在每个刷新周期调用,避免组件自己维护提醒状态。
+ ///
+ /// 当前分贝映射值(显示刻度 0..150)。
+ /// 评估状态:是否处于"超过阈值"提醒状态及提醒文字。
+ public DecibelAlertState Evaluate(double mappedDb)
+ {
+ try
+ {
+ var text = Settings.AlertText;
+
+ // 判定逻辑位于 DecibelAlertEvaluator(纯函数,便于单元测试)
+ var decision = DecibelAlertEvaluator.Evaluate(
+ mappedDb,
+ Settings.AlertThreshold,
+ Settings.IsAlertEnabled,
+ DateTime.UtcNow,
+ _nextAlertTimeUtc,
+ Settings.AlertCooldownMinutes);
+
+ if (decision.ShouldNotify)
+ {
+ _nextAlertTimeUtc = decision.NextAlertTimeUtc;
+ NotifyOverThreshold(text);
+ }
+
+ return new DecibelAlertState(decision.IsActive, text);
+ }
+ catch (Exception ex)
+ {
+ // 评估失败不应影响组件主功能,仅记录日志以便诊断
+ _logger?.LogWarning(ex, "评估分贝提醒状态失败。");
+ return new DecibelAlertState(false, Settings.AlertText);
+ }
+ }
+
+ ///
+ /// 发送"超过分贝阈值"提醒。
+ ///
+ /// 提醒正文。
+ private void NotifyOverThreshold(string text)
+ {
+ try
+ {
+ var request = new NotificationRequest
+ {
+ ChannelId = Guid.Parse(OverThresholdChannelId),
+ MaskContent = NotificationContent.CreateSimpleTextContent(text),
+ OverlayContent = NotificationContent.CreateSimpleTextContent(text, x => x.Duration = TimeSpan.FromSeconds(30)),
+ };
+ Channel(OverThresholdChannelId).ShowNotification(request);
+ }
+ catch (Exception ex)
+ {
+ // 通知失败不应影响组件的主功能
+ _logger?.LogWarning(ex, "发送分贝提醒失败。");
+ }
+ }
+
+ ///
+ /// 释放时清理静态实例引用,避免插件重载或宿主重启后残留已失效的引用。
+ ///
+ public void Dispose()
+ {
+ if (ReferenceEquals(_instance, this))
+ {
+ _instance = null;
+ }
+ }
+}
+
diff --git a/Tests/Decibel_Monitor.Tests/DecibelAlertEvaluatorTests.cs b/Tests/Decibel_Monitor.Tests/DecibelAlertEvaluatorTests.cs
new file mode 100644
index 0000000..b226826
--- /dev/null
+++ b/Tests/Decibel_Monitor.Tests/DecibelAlertEvaluatorTests.cs
@@ -0,0 +1,108 @@
+using System;
+using Decibel_Monitor.Services;
+
+namespace Decibel_Monitor.Tests;
+
+public class DecibelAlertEvaluatorTests
+{
+ private const double Threshold = 100.0;
+
+ // ---- 激活判定 ----
+
+ [Fact]
+ public void Evaluate_Should_NotActivate_WhenDisabled()
+ {
+ var decision = DecibelAlertEvaluator.Evaluate(120.0, Threshold, false, DateTime.UtcNow, DateTime.MinValue, 5);
+
+ Assert.False(decision.IsActive);
+ Assert.False(decision.ShouldNotify);
+ Assert.Equal(DateTime.MinValue, decision.NextAlertTimeUtc);
+ }
+
+ [Fact]
+ public void Evaluate_Should_NotActivate_WhenBelowThreshold()
+ {
+ var now = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
+ var decision = DecibelAlertEvaluator.Evaluate(99.9, Threshold, true, now, DateTime.MinValue, 5);
+
+ Assert.False(decision.IsActive);
+ Assert.False(decision.ShouldNotify);
+ }
+
+ [Fact]
+ public void Evaluate_Should_NotActivate_WhenEqualToThreshold()
+ {
+ // 边界:严格大于才算超阈值
+ var now = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
+ var decision = DecibelAlertEvaluator.Evaluate(Threshold, Threshold, true, now, DateTime.MinValue, 5);
+
+ Assert.False(decision.IsActive);
+ Assert.False(decision.ShouldNotify);
+ }
+
+ // ---- 冷却控制 ----
+
+ [Fact]
+ public void Evaluate_Should_Notify_OnFirstOverThreshold()
+ {
+ var now = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
+ var decision = DecibelAlertEvaluator.Evaluate(120.0, Threshold, true, now, DateTime.MinValue, 5);
+
+ Assert.True(decision.IsActive);
+ Assert.True(decision.ShouldNotify);
+ }
+
+ [Fact]
+ public void Evaluate_Should_NotNotify_WhileCoolingDown()
+ {
+ var now = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
+ var next = now.AddMinutes(5);
+ var decision = DecibelAlertEvaluator.Evaluate(120.0, Threshold, true, now, next, 5);
+
+ Assert.True(decision.IsActive); // 仍处于超阈值状态(组件继续显示提示文字)
+ Assert.False(decision.ShouldNotify); // 但冷却未过,不重复提醒
+ Assert.Equal(next, decision.NextAlertTimeUtc);
+ }
+
+ [Fact]
+ public void Evaluate_Should_Notify_WhenCooldownJustElapsed()
+ {
+ var next = new DateTime(2026, 1, 1, 0, 5, 0, DateTimeKind.Utc);
+ var decision = DecibelAlertEvaluator.Evaluate(120.0, Threshold, true, next, next, 5);
+
+ Assert.True(decision.IsActive);
+ Assert.True(decision.ShouldNotify);
+ }
+
+ [Fact]
+ public void Evaluate_Should_SetNextAlertTime_ByCooldown()
+ {
+ var now = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
+ var decision = DecibelAlertEvaluator.Evaluate(120.0, Threshold, true, now, DateTime.MinValue, 3);
+
+ Assert.Equal(now.AddMinutes(3), decision.NextAlertTimeUtc);
+ }
+
+ [Fact]
+ public void Evaluate_Should_UseMinimumOneMinuteCooldown()
+ {
+ var now = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
+
+ var zero = DecibelAlertEvaluator.Evaluate(120.0, Threshold, true, now, DateTime.MinValue, 0);
+ var negative = DecibelAlertEvaluator.Evaluate(120.0, Threshold, true, now, DateTime.MinValue, -10);
+
+ Assert.Equal(now.AddMinutes(1), zero.NextAlertTimeUtc);
+ Assert.Equal(now.AddMinutes(1), negative.NextAlertTimeUtc);
+ }
+
+ [Fact]
+ public void Evaluate_Should_KeepNextAlertTime_WhenNotActive()
+ {
+ var now = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
+ var next = now.AddMinutes(5);
+ var decision = DecibelAlertEvaluator.Evaluate(50.0, Threshold, true, now, next, 5);
+
+ Assert.False(decision.IsActive);
+ Assert.Equal(next, decision.NextAlertTimeUtc);
+ }
+}
diff --git a/Tests/Decibel_Monitor.Tests/Decibel_Monitor.Tests.csproj b/Tests/Decibel_Monitor.Tests/Decibel_Monitor.Tests.csproj
index e69ca78..e2f5710 100644
--- a/Tests/Decibel_Monitor.Tests/Decibel_Monitor.Tests.csproj
+++ b/Tests/Decibel_Monitor.Tests/Decibel_Monitor.Tests.csproj
@@ -22,6 +22,7 @@
+
From b9dbc9227bed563b529575e417bb6efa0d9659c0 Mon Sep 17 00:00:00 2001
From: Starsky16 <163619709+Starsky16@users.noreply.github.com>
Date: Tue, 15 Sep 2026 02:04:03 +0800
Subject: [PATCH 04/14] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=8F=92?=
=?UTF-8?q?=E4=BB=B6=E8=AE=BE=E7=BD=AE=E9=A1=B5=E5=B9=B6=E5=B0=86=E6=A0=A1?=
=?UTF-8?q?=E5=87=86=E4=B8=8A=E6=94=B6=E4=B8=BA=E5=85=A8=E5=B1=80=E8=AE=BE?=
=?UTF-8?q?=E7=BD=AE?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 新增插件设置页(设置 → Decibel_Monitor,External 分类):全局校准(参考 dB 0..150、放大倍数、校准按钮)、
持续监听麦克风与采样参数、默认捕获设备信息与捕获设备列表诊断。
- 校准改为调用 AudioPeakMeter.CaptureSamplePeakAsync 显式采样一次,结果写入全局放大倍数,
插件内所有组件立即生效(同一麦克风各组件数值一致)。
- Plugin 注册设置页;Decibel_Monitor.csproj 补充设置页 XAML 与代码后置的 DependentUpon。
- README 更新:设置分布(组件设置 / 通知设置 / 插件设置页三处)、校准位置由组件设置改为插件设置页、
补充全局设置项与故障排查说明。
由 VS Code 中的 Cline 辅助生成。
---
Decibel_Monitor.csproj | 3 +
Plugin.cs | 3 +
README.md | 52 ++++--
.../DecibelMonitorSettingsPage.axaml | 74 ++++++++
.../DecibelMonitorSettingsPage.axaml.cs | 165 ++++++++++++++++++
5 files changed, 283 insertions(+), 14 deletions(-)
create mode 100644 Views/SettingsPages/DecibelMonitorSettingsPage.axaml
create mode 100644 Views/SettingsPages/DecibelMonitorSettingsPage.axaml.cs
diff --git a/Decibel_Monitor.csproj b/Decibel_Monitor.csproj
index febbce4..063603e 100644
--- a/Decibel_Monitor.csproj
+++ b/Decibel_Monitor.csproj
@@ -37,6 +37,9 @@
DecibelNotificationProviderSettingsControl.axaml
+
+ DecibelMonitorSettingsPage.axaml
+
diff --git a/Plugin.cs b/Plugin.cs
index fa15740..657e32b 100644
--- a/Plugin.cs
+++ b/Plugin.cs
@@ -25,6 +25,9 @@ public override void Initialize(HostBuilderContext context, IServiceCollection s
// 分贝提醒通知提供方(含"强调通知侧"设置控件)
services.AddNotificationProvider();
+
+ // 插件设置页(与其他设置项同层级)
+ services.AddSettingsPage();
}
}
}
diff --git a/README.md b/README.md
index 5e2255d..b4a0bef 100644
--- a/README.md
+++ b/README.md
@@ -1,14 +1,23 @@
# Decibel_Monitor
-一个用于 ClassIsland 的分贝监测插件,实时读取系统麦克风峰值并在主界面显示分贝映射值。支持校准(将数字音频幅度映射到目标 dBFS),并提供多重回退的采样方式以提高兼容性(AudioMeterInformation、WasapiCapture、WaveInEvent)。
+一个用于 ClassIsland 的分贝监测插件,通过一条常驻的低开销捕获流实时读取麦克风峰值并在主界面显示分贝映射值。支持全局校准(将数字音频幅度映射到目标 dBFS),并可通过系统实时计量作为补充。
## 主要功能
-- 实时显示麦克风分贝值(映射到 0–150 范围以便在 UI 中直观显示)。
-- 校准功能:根据目标 dBFS 自动计算并保存放大倍数(Settings.Magnification)。
-- 多重回退采样:当设备不提供实时计量时,自动使用短时录音回退采样以获取峰值。
+- 实时显示麦克风分贝值(映射到 0–150 范围以便在 UI 中直观显示)。默认通过**一条常驻的低开销捕获流**持续计算峰值——数值稳定、无周期性开/关录音造成的闪烁(Windows 麦克风占用图标会常亮而非闪烁)。
+- 校准功能:根据目标 dBFS 自动计算并保存**全局**放大倍数(插件设置页 → 全局校准)。校准时才会进行一次显式采样。
- 阈值提醒:超过指定分贝值时,在组件上显示提示文字,并通过 ClassIsland 通知系统弹出醒目提醒(带冷却时间防刷屏)。
+## 设置位置一览
+
+本插件的设置分布在 ClassIsland 的三个位置:
+
+| 位置 | 内容 |
+| --- | --- |
+| ① 组件设置(右键组件 → 设置) | 显示偏好:是否在组件上显示“超过阈值”的提示文字(`ShowAlertTextOnComponent`) |
+| ② 通知设置(设置 → 通知 → 分贝提醒) | 启用提醒、阈值(dB)、提醒文字、冷却时间(分钟) |
+| ③ 插件设置页(设置 → Decibel_Monitor) | **全局校准**(参考 dB、放大倍数、校准按钮)、测量参数(持续监听、采样时长)、设备诊断 |
+
## 依赖
- .NET 8
@@ -19,18 +28,19 @@
## 使用说明
1. 在宿主中将本组件加入布局(或通过插件管理启用)。
-2. 主界面会显示当前分贝映射值(0–150)。
-3. 打开组件设置面板进行校准:
+2. 主界面会显示当前分贝映射值(0–150),数值由全局校准决定(同一麦克风所有组件一致)。
+3. 进行校准(**设置 → Decibel_Monitor 页**):
- 将麦克风对准参考声源或播放测试音。
- - 将参考滑块设置为目标 dBFS(默认 -80,范围 -150..0)。
- - 点击“校准”按钮。插件采样后计算放大倍数并保存到 `Settings.Magnification`(若设置控件未绑定则会提示并显示建议倍数)。
+ - 将参考滑块设置为目标显示 dB(默认 70,范围 0..150,对应 dBFS -80)。
+ - 点击“校准”按钮。插件采样一次并计算全局放大倍数,保存后所有组件立即生效。
+ - 采样或静音表现异常时,可在同一页面使用“捕获设备列表”诊断,或调整测量参数。
### 阈值提醒(通知设置)
1. 打开 **设置 → 通知 → 分贝提醒**。
2. 开启“启用提醒”,设置阈值(dB)、提醒文字与冷却时间(分钟)。
3. 当分贝值超过阈值时:
- - 组件上显示提示文字(可在组件设置中关闭“组件内提示”);
+ - 组件上显示红色提示文字;
- 通过 ClassIsland 通知系统弹出醒目提醒;
- 冷却时间内不会重复提醒(默认 10 分钟)。
@@ -42,28 +52,41 @@
插件会对计算结果做上限保护(默认上限 1024;下限可为 0),以避免极端错误值。
-## 设置项(DecibelComponentSettings)
+## 设置项
+
+### ① 组件设置(`DecibelComponentSettings`,每组件独立)
-- `Magnification` (double):放大倍数,由校准或手动设置,默认 1.0。
- `ShowAlertTextOnComponent` (bool):是否在组件上显示“超过阈值”的提示文字,默认 true;关闭后仍会发送系统通知,但不在组件上显示提示文字。
-- 参考 dBFS 在设置面板中通过滑块调整。
+- `ShowDecibelPrefix` (bool):数值前是否显示“分贝:”前缀(如“分贝: 70.0”),默认 true。
+- `UpdateIntervalMs` (int):数值更新频率(毫秒,100..1000,可配置范围 100..5000),默认 200。
-## 阈值提醒设置项(DecibelNotificationProviderSettings)
+### ② 通知提供方设置(`DecibelNotificationProviderSettings`,全局)
- `IsAlertEnabled` (bool):是否启用阈值提醒,默认 false。
- `AlertThreshold` (double):提醒阈值(显示刻度 0..150),默认 120。
- `AlertText` (string):提醒文字(通知横幅与组件提示),默认“请保持安静”。
- `AlertCooldownMinutes` (int):触发提醒后的冷却时间(分钟),默认 10。
+### ③ 插件全局设置(`DecibelMonitorGlobalSettings`)
+
+- `ReferenceDecibel` (double):校准参考(显示刻度 dB 0..150),默认 70(对应 -80 dBFS)。
+- `Magnification` (double):全局放大倍数,校准后保存,所有组件统一使用。
+- `EnableContinuousMonitoring` (bool):是否启用“持续监听麦克风”(常驻静默捕获流),默认 true。关闭后仅使用系统实时计量,多数设备会一直显示 0。
+- `FallbackCaptureMs` (int):显式采样(如校准)的单次录音时长(毫秒),默认 400。
+- `SignalThreshold` (double):信号检测阈值(存在配置文件中,可在采样异常时调低)。
+
## 故障排查
- 提醒一直没有触发:
- 确认 ClassIsland 全局通知开关与 **设置 → 通知 → 分贝提醒** 的开关均已开启。
- 确认分贝值确实超过了设定阈值(长期显示 0 请参考下一条)。
+ - 若宿主日志中无“注册提醒渠道:…(超过分贝阈值)”,说明加载的是旧版本插件,请更新插件并重启。
- 分贝值长期显示 `0.0`:
+ - 若关闭了“持续监听麦克风”,则只使用系统实时计量,多数设备会一直为 0;请到 **设置 → Decibel_Monitor 页** 确认该开关已开启(开启后麦克风占用图标会常亮)。
- 确认宿主应用有麦克风权限(Windows 隐私设置)。
- 确认默认捕获设备已启用并未被独占。
- - 在设置面板使用“捕获设备列表”检查系统设备;增加音量或靠近声源再测。
+ - 在 **设置 → Decibel_Monitor 页** 使用“捕获设备列表”检查系统设备;增加音量或靠近声源再测。
+- 出现“麦克风正在使用”提示且图标**闪烁**:说明加载的是旧版本插件(旧版周期性开/关录音)。请更新插件;当前版本为常驻监听,图标只会常亮不会闪烁。如不希望常亮,可关闭“持续监听麦克风”。
- 校准结果异常:
- 确认目标单位为 dBFS(数字音频相对量),而非声压级 dB SPL。
- 若测量值接近 0,会导致放大倍数非常大,请提高测试音量或选择更高的目标 dBFS。
@@ -80,3 +103,4 @@
- Idea来自:[HAHAHAHAHAYINING](https://github.com/HAHAHAHAHAYINING),[讨论#561](https://github.com/ClassIsland/ClassIsland/discussions/561)
- 主要开发者:[Yeson38](https://github.com/Yeson38)
- 参考代码:[CIImage](https://github.com/lrsgzs/CIImage)
+
diff --git a/Views/SettingsPages/DecibelMonitorSettingsPage.axaml b/Views/SettingsPages/DecibelMonitorSettingsPage.axaml
new file mode 100644
index 0000000..b9f15f6
--- /dev/null
+++ b/Views/SettingsPages/DecibelMonitorSettingsPage.axaml
@@ -0,0 +1,74 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Views/SettingsPages/DecibelMonitorSettingsPage.axaml.cs b/Views/SettingsPages/DecibelMonitorSettingsPage.axaml.cs
new file mode 100644
index 0000000..cade346
--- /dev/null
+++ b/Views/SettingsPages/DecibelMonitorSettingsPage.axaml.cs
@@ -0,0 +1,165 @@
+using System;
+using System.Linq;
+using Avalonia.Interactivity;
+using ClassIsland.Core.Abstractions.Controls;
+using ClassIsland.Core.Attributes;
+using ClassIsland.Core.Controls;
+using ClassIsland.Core.Enums.SettingsWindow;
+using Decibel_Monitor.Models;
+using Decibel_Monitor.Services;
+
+namespace Decibel_Monitor.Views.SettingsPages;
+
+///
+/// Decibel_Monitor 插件设置页(与其他设置项同层级)。
+/// 承载插件全局配置与设备诊断。
+///
+[SettingsPageInfo(
+ "Decibel_Monitor.PluginSettings",
+ "Decibel_Monitor",
+ "\uEB88",
+ "\uEB88",
+ category: SettingsPageCategory.External)]
+public partial class DecibelMonitorSettingsPage : SettingsPageBase
+{
+ private readonly AudioPeakMeter? _audioPeakMeter;
+
+ ///
+ /// 插件全局设置(用于绑定采样偏好)。
+ ///
+ public DecibelMonitorGlobalSettings GlobalSettings { get; }
+
+ ///
+ /// 供 XAML 资源加载器 / 设计器使用的默认构造。
+ ///
+ public DecibelMonitorSettingsPage() : this(new DecibelMonitorSettingsService(null), null)
+ {
+ }
+
+ public DecibelMonitorSettingsPage(DecibelMonitorSettingsService settingsService, AudioPeakMeter? audioPeakMeter = null)
+ {
+ InitializeComponent();
+ GlobalSettings = settingsService.Settings;
+ _audioPeakMeter = audioPeakMeter;
+ DataContext = this;
+ RefreshDefaultDeviceText();
+ RefreshMagnificationText();
+ }
+
+ ///
+ /// 刷新默认捕获设备描述文本。
+ ///
+ private void RefreshDefaultDeviceText()
+ {
+ try
+ {
+ var desc = _audioPeakMeter?.GetDefaultCaptureDeviceDescription();
+ TextDefaultDevice.Text = string.IsNullOrWhiteSpace(desc)
+ ? "(未发现可用的默认捕获设备)"
+ : $"当前默认捕获设备:{desc}";
+ }
+ catch
+ {
+ TextDefaultDevice.Text = "(读取默认捕获设备失败)";
+ }
+ }
+
+ ///
+ /// 刷新当前全局放大倍数文本。
+ ///
+ private void RefreshMagnificationText()
+ {
+ TextMagnification.Text = $"{GlobalSettings.Magnification:F3}×";
+ }
+
+ ///
+ /// 校准按钮:短暂采样一次默认麦克风,按全局参考 dB 计算并保存全局放大倍数。
+ ///
+ public async void On_Calibrate(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
+ {
+ try
+ {
+ if (_audioPeakMeter is null)
+ {
+ _ = CommonTaskDialogs.ShowDialog("校准失败", "采样服务不可用。");
+ return;
+ }
+
+ float measuredLinear = await _audioPeakMeter
+ .CaptureSamplePeakAsync(_audioPeakMeter.DefaultCaptureMs).ConfigureAwait(false);
+
+ await Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(() =>
+ {
+ if (measuredLinear <= 0f)
+ {
+ try
+ {
+ _ = CommonTaskDialogs.ShowDialog("校准结果",
+ "未检测到有效输入,无法完成校准。请确认麦克风已启用并有声源。");
+ }
+ catch { }
+ return;
+ }
+
+ double targetDb = GlobalSettings.ReferenceDecibel;
+ double magnification = DecibelCalculator.CalculateMagnification(targetDb, measuredLinear);
+ double adjustedLinear = measuredLinear * magnification;
+ double adjustedDb = adjustedLinear > 0 ? 20.0 * Math.Log10(adjustedLinear) : double.NegativeInfinity;
+ double measuredDb = measuredLinear > 0 ? 20.0 * Math.Log10(measuredLinear) : double.NegativeInfinity;
+
+ GlobalSettings.Magnification = magnification;
+ RefreshMagnificationText();
+
+ try
+ {
+ _ = CommonTaskDialogs.ShowDialog("校准结果",
+ $"测量线性值: {measuredLinear:F4}\n" +
+ $"测量 dBFS: {measuredDb:F1} dB\n" +
+ $"目标 dB (显示): {targetDb:F1} dB\n" +
+ $"目标 dBFS: {DecibelCalculator.DisplayDbToDbFs(targetDb):F1} dBFS\n" +
+ $"全局放大倍数: {magnification:F6}×\n" +
+ $"放大后 dBFS(验证): {adjustedDb:F1} dBFS\n\n" +
+ "该校准对插件内所有组件生效。");
+ }
+ catch { }
+ });
+ }
+ catch (Exception ex)
+ {
+ try { _ = CommonTaskDialogs.ShowDialog("校准失败", $"发生异常:{ex.Message}"); } catch { }
+ }
+ }
+
+ ///
+ /// "刷新设备"按钮点击事件。
+ ///
+ public void On_RefreshDevices(object? sender, RoutedEventArgs e)
+ {
+ RefreshDefaultDeviceText();
+ }
+
+ ///
+ /// "捕获设备列表"按钮点击事件。
+ ///
+ public void On_ShowDevices(object? sender, RoutedEventArgs e)
+ {
+ try
+ {
+ if (_audioPeakMeter is null)
+ {
+ _ = CommonTaskDialogs.ShowDialog("捕获设备列表", "采样服务不可用。");
+ return;
+ }
+
+ var devices = _audioPeakMeter.GetCaptureDeviceDescriptions();
+ var text = string.Join("\n", devices);
+ _ = CommonTaskDialogs.ShowDialog("捕获设备列表",
+ string.IsNullOrWhiteSpace(text) ? "(未发现可用捕获设备)" : text);
+ }
+ catch
+ {
+ /* 忽略诊断失败 */
+ }
+ }
+}
+
From 9813e0caa501b8c458aaee1af0b1c8dcdfae0a55 Mon Sep 17 00:00:00 2001
From: Starsky16 <163619709+Starsky16@users.noreply.github.com>
Date: Tue, 15 Sep 2026 02:04:54 +0800
Subject: [PATCH 05/14] =?UTF-8?q?ci:=20=E6=96=B0=E5=A2=9E=E9=9B=86?=
=?UTF-8?q?=E6=88=90=E6=B5=8B=E8=AF=95=E5=B7=A5=E4=BD=9C=E6=B5=81=E5=B9=B6?=
=?UTF-8?q?=E8=B0=83=E6=95=B4=E6=9E=84=E5=BB=BA/=E6=B5=8B=E8=AF=95?=
=?UTF-8?q?=E5=B7=A5=E4=BD=9C=E6=B5=81?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- IntegrationTest.yml:下载官方 ClassIsland 2.1.0.1 folder 包,用 -epp 加载本插件并启动,
轮询等待插件写入 loaded.marker 以确认 Initialize 完整执行(注册组件与提醒提供方无异常);
超时或进程提前退出即失败,并在失败时打印 AppRoot 目录便于排查。
- Plugin:设置 DECIBEL_MONITOR_CI_MARKER=1 时向插件配置目录写入 loaded.marker,供上述工作流判定加载成功;
未设置该环境变量时行为与原来完全一致。
- Build.yml / Test.yml:构建与单元测试步骤对齐现有工程(tools/publish.ps1、Tests/Decibel_Monitor.Tests)。
由 VS Code 中的 Cline 辅助生成。
---
.github/workflows/Build.yml | 16 +++---
.github/workflows/IntegrationTest.yml | 77 +++++++++++++++++++++++++++
.github/workflows/Test.yml | 34 +++---------
Plugin.cs | 18 +++++++
4 files changed, 112 insertions(+), 33 deletions(-)
create mode 100644 .github/workflows/IntegrationTest.yml
diff --git a/.github/workflows/Build.yml b/.github/workflows/Build.yml
index 860ad12..e54f542 100644
--- a/.github/workflows/Build.yml
+++ b/.github/workflows/Build.yml
@@ -4,8 +4,7 @@ on:
push:
workflow_dispatch:
pull_request:
- types: [closed]
-
+
jobs:
build_app:
runs-on: windows-latest
@@ -13,7 +12,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
-
+
- name: Install .NET Core
uses: actions/setup-dotnet@v4
with:
@@ -26,13 +25,16 @@ jobs:
key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }}
restore-keys: |
${{ runner.os }}-nuget-
-
+
- name: Build
run: pwsh -ep bypass ./tools/publish.ps1
+ - name: Test
+ run: dotnet test Tests/Decibel_Monitor.Tests/Decibel_Monitor.Tests.csproj -c Release
+
- name: Md5Checksum
run: pwsh -ep bypass ./tools/generate-md5.ps1 cipx/
-
+
- name: Upload APP to release
uses: ncipollo/release-action@v1
if: ${{ startsWith(github.ref, 'refs/tags/') }}
@@ -40,9 +42,9 @@ jobs:
artifacts: "./cipx/*.cipx"
draft: true
generateReleaseNotes: true
- body: ./cipx/checksums.md
+ bodyFile: ./cipx/checksums.md
token: ${{ secrets.GITHUB_TOKEN }}
-
+
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
diff --git a/.github/workflows/IntegrationTest.yml b/.github/workflows/IntegrationTest.yml
new file mode 100644
index 0000000..f643295
--- /dev/null
+++ b/.github/workflows/IntegrationTest.yml
@@ -0,0 +1,77 @@
+name: Integration Test
+
+# 集成冒烟测试:用官方 Release 版 ClassIsland 启动并加载本插件,
+# 验证插件入口 Initialize 能完整执行(注册组件/提醒提供方无异常)。
+# 运行时功能(麦克风采样、UI 交互)在无头 CI 环境中无法验证。
+
+on:
+ push:
+ workflow_dispatch:
+
+jobs:
+ integration-test:
+ runs-on: windows-latest
+ name: Launch in ClassIsland
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup .NET 8
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: 8.x
+
+ - name: Publish plugin
+ shell: pwsh
+ run: dotnet publish Decibel_Monitor.csproj -c Release -o "$env:RUNNER_TEMP\plugin"
+
+ - name: Download & extract ClassIsland (official release 2.1.0.1)
+ shell: pwsh
+ run: |
+ $zip = Join-Path $env:RUNNER_TEMP "classisland.zip"
+ $dest = Join-Path $env:RUNNER_TEMP "classisland"
+ Invoke-WebRequest -Uri "https://github.com/ClassIsland/ClassIsland/releases/download/2.1.0.1/ClassIsland_app_windows_x64_full_folder.zip" -OutFile $zip
+ Expand-Archive -Path $zip -DestinationPath $dest -Force
+ $exe = Get-ChildItem -Path $dest -Recurse -Filter "ClassIsland.Desktop.exe" | Select-Object -First 1
+ if (-not $exe) { throw "未在解压目录中找到 ClassIsland.Desktop.exe" }
+ Write-Host "ClassIsland.Desktop.exe: $($exe.FullName)"
+ Add-Content -Path $env:GITHUB_ENV -Value "CI_CLASSISLAND_EXE=$($exe.FullName)"
+
+ - name: Launch ClassIsland with plugin & verify load marker
+ shell: pwsh
+ env:
+ DECIBEL_MONITOR_CI_MARKER: "1"
+ run: |
+ $exe = $env:CI_CLASSISLAND_EXE
+ $exeDir = Split-Path $exe
+ # folder 打包:AppPackageRoot = exeDir/.., AppRoot = AppPackageRoot/data,
+ # 插件配置目录 = AppRoot/Config/Plugins/
+ $appRoot = (Resolve-Path (Join-Path $exeDir "..")).Path
+ $marker = Join-Path $appRoot "data\Config\Plugins\Decibel_Monitor\loaded.marker"
+ $pluginDir = Join-Path $env:RUNNER_TEMP "plugin"
+ Write-Host "插件目录: $pluginDir"
+ Write-Host "期望标记文件: $marker"
+
+ $proc = Start-Process -FilePath $exe -ArgumentList @("-epp", $pluginDir, "--skip-oobe", "--quiet") -PassThru -WindowStyle Hidden
+ try {
+ $deadline = (Get-Date).AddSeconds(150)
+ $found = $false
+ while ((Get-Date) -lt $deadline) {
+ if (Test-Path $marker) { $found = $true; break }
+ if ($proc.HasExited) {
+ Write-Host "ClassIsland 进程已退出, ExitCode=$($proc.ExitCode)"
+ break
+ }
+ Start-Sleep -Seconds 5
+ }
+ if (-not $found) {
+ Write-Host "ClassIsland 进程是否存活: $(-not $proc.HasExited)"
+ Write-Host "AppRoot 下内容:"
+ if (Test-Path $appRoot) { Get-ChildItem $appRoot -Recurse -Depth 3 | Select-Object -ExpandProperty FullName | Out-String | Write-Host }
+ throw "插件未能在 ClassIsland 中成功加载(标记文件不存在: $marker)"
+ }
+ Write-Host "✅ 插件加载成功,标记文件: $marker"
+ Get-Content $marker
+ } finally {
+ if (-not $proc.HasExited) { Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue }
+ }
diff --git a/.github/workflows/Test.yml b/.github/workflows/Test.yml
index 73b380f..91f8057 100644
--- a/.github/workflows/Test.yml
+++ b/.github/workflows/Test.yml
@@ -4,16 +4,15 @@ on:
push:
workflow_dispatch:
pull_request:
- types: [closed]
-
+
jobs:
- build_app:
+ test:
runs-on: windows-latest
- name: Build
+ name: Test
steps:
- name: Checkout
uses: actions/checkout@v4
-
+
- name: Install .NET Core
uses: actions/setup-dotnet@v4
with:
@@ -26,26 +25,9 @@ jobs:
key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }}
restore-keys: |
${{ runner.os }}-nuget-
-
- - name: Build
- run: pwsh -ep bypass ./tools/publish.ps1
- - name: Checksum
- run: pwsh -ep bypass ./tools/generate-md5.ps1 cipx/
+ - name: Build main project
+ run: dotnet build Decibel_Monitor.csproj -c Release
- - name: Upload artifact
- uses: actions/upload-artifact@v4
- with:
- path: |
- ./cipx/*.cipx
- ./cipx/checksums.md
- name: Decibel_Monitor-Actions
-
- - name: Upload APP to release
- uses: ncipollo/release-action@v1
- if: ${{ startsWith(github.ref, 'refs/tags/') }}
- with:
- artifacts: "./cipx/*.cipx"
- draft: true
- bodyFile: ./cipx/checksums.md
- token: ${{ secrets.GITHUB_TOKEN }}
+ - name: Run unit tests
+ run: dotnet test Tests/Decibel_Monitor.Tests/Decibel_Monitor.Tests.csproj -c Release
diff --git a/Plugin.cs b/Plugin.cs
index 657e32b..f6b968d 100644
--- a/Plugin.cs
+++ b/Plugin.cs
@@ -1,4 +1,5 @@
using System;
+using System.IO;
using ClassIsland.Core.Abstractions;
using ClassIsland.Core.Attributes;
using ClassIsland.Core.Extensions.Registry;
@@ -28,6 +29,23 @@ public override void Initialize(HostBuilderContext context, IServiceCollection s
// 插件设置页(与其他设置项同层级)
services.AddSettingsPage();
+
+ // CI 集成测试标记:仅当显式开启环境变量时写入,
+ // 供 GitHub Actions 在启动 ClassIsland 后确认插件已成功加载(Initialize 完整执行、注册无异常)。
+ if (Environment.GetEnvironmentVariable("DECIBEL_MONITOR_CI_MARKER") == "1")
+ {
+ try
+ {
+ Directory.CreateDirectory(PluginConfigFolder);
+ File.WriteAllText(
+ Path.Combine(PluginConfigFolder, "loaded.marker"),
+ $"loaded at {DateTime.UtcNow:O}");
+ }
+ catch
+ {
+ // 标记写入失败不应影响插件正常初始化
+ }
+ }
}
}
}
From 737592e911f2e729f37f65477113a2bfa6fe91dc Mon Sep 17 00:00:00 2001
From: Starsky16 <163619709+Starsky16@users.noreply.github.com>
Date: Fri, 18 Sep 2026 00:56:19 +0800
Subject: [PATCH 06/14] =?UTF-8?q?chore:=20=E5=BF=BD=E7=95=A5=20cipx/=20?=
=?UTF-8?q?=E6=89=93=E5=8C=85=E4=BA=A7=E7=89=A9=E5=B9=B6=E7=A7=BB=E9=99=A4?=
=?UTF-8?q?=200=20=E5=AD=97=E8=8A=82=E5=8D=A0=E4=BD=8D=E6=96=87=E4=BB=B6?=
=?UTF-8?q?=EF=BC=88=E4=B8=8E=20dev=20=E5=AF=B9=E9=BD=90=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.gitignore | 2 ++
ClassIsland.Desktop/PluginLoadContext.cs | 0
2 files changed, 2 insertions(+)
delete mode 100644 ClassIsland.Desktop/PluginLoadContext.cs
diff --git a/.gitignore b/.gitignore
index 9491a2f..a388ece 100644
--- a/.gitignore
+++ b/.gitignore
@@ -29,6 +29,8 @@ x86/
bld/
[Bb]in/
[Oo]bj/
+# ClassIsland 插件打包产物
+cipx/
[Oo]ut/
[Ll]og/
[Ll]ogs/
diff --git a/ClassIsland.Desktop/PluginLoadContext.cs b/ClassIsland.Desktop/PluginLoadContext.cs
deleted file mode 100644
index e69de29..0000000
From 5a6e0abf7b6d58dddc3949cf28778d57eecbe688 Mon Sep 17 00:00:00 2001
From: Starsky16 <163619709+Starsky16@users.noreply.github.com>
Date: Fri, 18 Sep 2026 02:31:11 +0800
Subject: [PATCH 07/14] =?UTF-8?q?fix:=20=E7=BB=84=E4=BB=B6=E5=AE=9A?=
=?UTF-8?q?=E6=97=B6=E5=99=A8=E6=94=B9=E9=9A=8F=E8=A7=86=E8=A7=89=E6=A0=91?=
=?UTF-8?q?=E5=90=AF=E5=81=9C=E5=B9=B6=E7=A7=BB=E9=99=A4=20IDisposable?=
=?UTF-8?q?=EF=BC=8C=E4=BF=AE=E5=A4=8D=E7=BB=84=E4=BB=B6=E9=87=8D=E5=BB=BA?=
=?UTF-8?q?=E5=90=8E=E6=97=A0=E6=B3=95=E5=9B=9E=E6=94=B6=E7=9A=84=E5=86=85?=
=?UTF-8?q?=E5=AD=98=E6=B3=84=E6=BC=8F?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
组件由宿主从 DI 根容器以瞬态方式解析且从不调用释放方法,原实现在构造函数中启动 DispatcherTimer:已启动的定时器被 Dispatcher 强引用,Tick 闭包会永久钉住组件实例,导致组件重建后旧实例与定时器都无法回收。改为随视觉树附着/分离启停定时器,并移除不再需要的 IDisposable。
已知行为变化:组件不在视觉树上时不再采样,依赖 Tick 的阈值提醒在组件离树期间不会触发。
---
.../DecibelComponentSettingsControl.axaml.cs | 10 +----
Controls/Components/DecibelComponent.axaml.cs | 43 +++++++++++++------
2 files changed, 31 insertions(+), 22 deletions(-)
diff --git a/Controls/ComponentSettings/DecibelComponentSettingsControl.axaml.cs b/Controls/ComponentSettings/DecibelComponentSettingsControl.axaml.cs
index 8c6e300..2f007b4 100644
--- a/Controls/ComponentSettings/DecibelComponentSettingsControl.axaml.cs
+++ b/Controls/ComponentSettings/DecibelComponentSettingsControl.axaml.cs
@@ -8,20 +8,12 @@ namespace Decibel_Monitor.Controls.ComponentSettings;
/// 分贝组件设置控件("小组件显示侧")。
/// 仅包含该组件的显示偏好;测量/校准(全局)与提醒设置分别位于插件设置页与通知提供方设置。
///
-public partial class DecibelComponentSettingsControl : ComponentBase, IDisposable
+public partial class DecibelComponentSettingsControl : ComponentBase
{
- private bool _disposed;
-
public DecibelComponentSettingsControl()
{
InitializeComponent();
}
-
- public void Dispose()
- {
- if (_disposed) return;
- _disposed = true;
- }
}
diff --git a/Controls/Components/DecibelComponent.axaml.cs b/Controls/Components/DecibelComponent.axaml.cs
index 610265c..8152b3d 100644
--- a/Controls/Components/DecibelComponent.axaml.cs
+++ b/Controls/Components/DecibelComponent.axaml.cs
@@ -15,7 +15,7 @@ namespace Decibel_Monitor.Controls.Components;
"\uEB88",
"在主界面上显示麦克风分贝值。"
)]
-public partial class DecibelComponent : ComponentBase, IDisposable
+public partial class DecibelComponent : ComponentBase
{
/// 当前显示的分贝值文本。
public static readonly StyledProperty CurrentDecibelValueProperty =
@@ -32,7 +32,7 @@ public partial class DecibelComponent : ComponentBase,
private readonly DispatcherTimer _updateTimer;
private readonly AudioPeakMeter? _audioPeakMeter;
private readonly DecibelMonitorSettingsService? _settingsService;
- private volatile bool _disposed;
+ private volatile bool _isActive;
private volatile bool _isUpdating;
///
@@ -74,10 +74,36 @@ public DecibelComponent()
{
Interval = TimeSpan.FromMilliseconds(200)
};
+ // 定时器不在这里启动:组件生命周期改由视觉树决定(见 OnAttachedToVisualTree /
+ // OnDetachedFromVisualTree)。宿主通过 DI 根容器解析瞬态组件且从不调用释放方法,
+ // 若在构造期启动定时器,已启动的 DispatcherTimer 会被 Dispatcher 强引用,
+ // Tick 订阅闭包会永久钉住组件实例,导致组件重建后旧实例无法回收。
+ }
+
+ // 定时器随视觉树的附着/分离启停:宿主从 DI 根容器解析瞬态组件且从不调用释放方法,
+ // 不能依赖释放方法兜底,改为跟随视觉树生命周期,组件离开视觉树后即退订
+ // Tick 并停止定时器,避免已启动的 DispatcherTimer 通过 Tick 闭包钉住实例。
+ protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
+ {
+ base.OnAttachedToVisualTree(e);
+
+ // 可重入守卫:附着/分离可能成对多次发生,重复 Start 会导致重复计时
+ if (_isActive) return;
+ _isActive = true;
_updateTimer.Tick += UpdateTimer_Tick;
_updateTimer.Start();
}
+ protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
+ {
+ base.OnDetachedFromVisualTree(e);
+
+ if (!_isActive) return;
+ _isActive = false;
+ _updateTimer.Tick -= UpdateTimer_Tick;
+ _updateTimer.Stop();
+ }
+
// 异步 Tick,内部会异步采样(不会阻塞 UI)
private async void UpdateTimer_Tick(object? sender, EventArgs e)
{
@@ -111,7 +137,7 @@ private async void UpdateTimer_Tick(object? sender, EventArgs e)
// 通知提供方(IHostedService)创建,缓存为 null 将导致提醒永久失效。
Dispatcher.UIThread.Post(() =>
{
- if (_disposed) return;
+ if (!_isActive) return;
var state = DecibelNotificationProvider.Instance?.Evaluate(mapped)
?? new DecibelAlertState(false, "请保持安静");
@@ -129,7 +155,7 @@ private async void UpdateTimer_Tick(object? sender, EventArgs e)
{
Dispatcher.UIThread.Post(() =>
{
- if (_disposed) return;
+ if (!_isActive) return;
CurrentDecibelValue = "读取失败";
});
}
@@ -138,14 +164,5 @@ private async void UpdateTimer_Tick(object? sender, EventArgs e)
_isUpdating = false;
}
}
-
- public void Dispose()
- {
- if (_disposed) return;
- _disposed = true;
-
- _updateTimer.Tick -= UpdateTimer_Tick;
- _updateTimer.Stop();
- }
}
From 9dc0277aae29f42b7fea96e583427831b9a90bcc Mon Sep 17 00:00:00 2001
From: Starsky16 <163619709+Starsky16@users.noreply.github.com>
Date: Sat, 19 Sep 2026 15:11:37 +0800
Subject: [PATCH 08/14] =?UTF-8?q?feat:=20=E6=8E=A5=E5=85=A5=20KeyboardCapt?=
=?UTF-8?q?ure=20=E9=94=AE=E7=9B=98=E4=BA=8B=E4=BB=B6=E5=A5=91=E7=BA=A6?=
=?UTF-8?q?=E5=8C=85?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
Decibel_Monitor.csproj | 5 ++++-
manifest.yml | 6 +++++-
2 files changed, 9 insertions(+), 2 deletions(-)
diff --git a/Decibel_Monitor.csproj b/Decibel_Monitor.csproj
index 063603e..7c03eb9 100644
--- a/Decibel_Monitor.csproj
+++ b/Decibel_Monitor.csproj
@@ -1,4 +1,4 @@
-
+
net8.0-windows
@@ -18,6 +18,9 @@
+
+
diff --git a/manifest.yml b/manifest.yml
index d66037a..f4528b3 100644
--- a/manifest.yml
+++ b/manifest.yml
@@ -10,4 +10,8 @@ entranceAssembly: "Decibel_Monitor.dll"
url: https://github.com/Yeson38/Decibel_Monitor/
version: 0.0.0.1
apiVersion: 2.0.0.0
-author: Yeson
\ No newline at end of file
+author: Yeson
+# 可选依赖:安装后可用"热键判定源"(按键确认提醒);未安装时插件其余功能不受影响。
+dependencies:
+ - id: Starsky16.KeyboardCapture
+ isRequired: false
\ No newline at end of file
From e660196a86c5972b0a15d15b55a5f63c9edded92 Mon Sep 17 00:00:00 2001
From: Starsky16 <163619709+Starsky16@users.noreply.github.com>
Date: Sat, 19 Sep 2026 15:11:44 +0800
Subject: [PATCH 09/14] =?UTF-8?q?refactor:=20Services=20=E6=8C=89=E6=B5=8B?=
=?UTF-8?q?=E9=87=8F/=E6=8F=90=E9=86=92=E8=81=8C=E8=B4=A3=E6=8B=86?=
=?UTF-8?q?=E5=88=86=E7=9B=AE=E5=BD=95=E4=B8=8E=E5=91=BD=E5=90=8D=E7=A9=BA?=
=?UTF-8?q?=E9=97=B4?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
{Services => Alerting}/DecibelAlertEvaluator.cs | 2 +-
{Services => Alerting}/DecibelAlertState.cs | 2 +-
{Services => Alerting}/DecibelCalculator.cs | 2 +-
{Services => Alerting}/DecibelNotificationProvider.cs | 2 +-
Controls/Components/DecibelComponent.axaml.cs | 2 ++
{Services => Measurement}/AudioDeviceEnumerator.cs | 2 +-
{Services => Measurement}/AudioPeakMeter.cs | 3 ++-
{Services => Measurement}/ContinuousPeakCapture.cs | 2 +-
{Services => Measurement}/OneShotPeakCapture.cs | 2 +-
{Services => Measurement}/PeakSample.cs | 2 +-
Plugin.cs | 6 +++---
Tests/Decibel_Monitor.Tests/DecibelAlertEvaluatorTests.cs | 2 +-
Tests/Decibel_Monitor.Tests/DecibelCalculatorTests.cs | 2 +-
Tests/Decibel_Monitor.Tests/Decibel_Monitor.Tests.csproj | 6 +++---
Tests/Decibel_Monitor.Tests/PeakSampleTests.cs | 2 +-
Views/SettingsPages/DecibelMonitorSettingsPage.axaml.cs | 2 ++
16 files changed, 23 insertions(+), 18 deletions(-)
rename {Services => Alerting}/DecibelAlertEvaluator.cs (98%)
rename {Services => Alerting}/DecibelAlertState.cs (90%)
rename {Services => Alerting}/DecibelCalculator.cs (98%)
rename {Services => Alerting}/DecibelNotificationProvider.cs (99%)
rename {Services => Measurement}/AudioDeviceEnumerator.cs (98%)
rename {Services => Measurement}/AudioPeakMeter.cs (98%)
rename {Services => Measurement}/ContinuousPeakCapture.cs (99%)
rename {Services => Measurement}/OneShotPeakCapture.cs (99%)
rename {Services => Measurement}/PeakSample.cs (97%)
diff --git a/Services/DecibelAlertEvaluator.cs b/Alerting/DecibelAlertEvaluator.cs
similarity index 98%
rename from Services/DecibelAlertEvaluator.cs
rename to Alerting/DecibelAlertEvaluator.cs
index 30b6bf0..61d9893 100644
--- a/Services/DecibelAlertEvaluator.cs
+++ b/Alerting/DecibelAlertEvaluator.cs
@@ -1,6 +1,6 @@
using System;
-namespace Decibel_Monitor.Services;
+namespace Decibel_Monitor.Alerting;
///
/// 分贝提醒评估结果。
diff --git a/Services/DecibelAlertState.cs b/Alerting/DecibelAlertState.cs
similarity index 90%
rename from Services/DecibelAlertState.cs
rename to Alerting/DecibelAlertState.cs
index e7084b3..da863e1 100644
--- a/Services/DecibelAlertState.cs
+++ b/Alerting/DecibelAlertState.cs
@@ -1,4 +1,4 @@
-namespace Decibel_Monitor.Services;
+namespace Decibel_Monitor.Alerting;
///
/// 分贝提醒提供方对一次分贝值的评估结果。
diff --git a/Services/DecibelCalculator.cs b/Alerting/DecibelCalculator.cs
similarity index 98%
rename from Services/DecibelCalculator.cs
rename to Alerting/DecibelCalculator.cs
index b991646..a49dd5c 100644
--- a/Services/DecibelCalculator.cs
+++ b/Alerting/DecibelCalculator.cs
@@ -1,6 +1,6 @@
using System;
-namespace Decibel_Monitor.Services;
+namespace Decibel_Monitor.Alerting;
///
/// 分贝换算纯函数工具类(与 UI / 音频设备无关,便于单元测试)。
diff --git a/Services/DecibelNotificationProvider.cs b/Alerting/DecibelNotificationProvider.cs
similarity index 99%
rename from Services/DecibelNotificationProvider.cs
rename to Alerting/DecibelNotificationProvider.cs
index e9b3326..a5f9933 100644
--- a/Services/DecibelNotificationProvider.cs
+++ b/Alerting/DecibelNotificationProvider.cs
@@ -5,7 +5,7 @@
using Decibel_Monitor.Models.NotificationProviderSettings;
using Microsoft.Extensions.Logging;
-namespace Decibel_Monitor.Services;
+namespace Decibel_Monitor.Alerting;
///
/// 分贝提醒通知提供方。
diff --git a/Controls/Components/DecibelComponent.axaml.cs b/Controls/Components/DecibelComponent.axaml.cs
index 8152b3d..e4e1874 100644
--- a/Controls/Components/DecibelComponent.axaml.cs
+++ b/Controls/Components/DecibelComponent.axaml.cs
@@ -4,6 +4,8 @@
using ClassIsland.Core.Abstractions.Controls;
using ClassIsland.Core.Attributes;
using ClassIsland.Shared;
+using Decibel_Monitor.Alerting;
+using Decibel_Monitor.Measurement;
using Decibel_Monitor.Services;
using DecibelComponentSettings = Decibel_Monitor.Models.ComponentSettings.DecibelComponentSettings;
diff --git a/Services/AudioDeviceEnumerator.cs b/Measurement/AudioDeviceEnumerator.cs
similarity index 98%
rename from Services/AudioDeviceEnumerator.cs
rename to Measurement/AudioDeviceEnumerator.cs
index 1aea70e..91fc019 100644
--- a/Services/AudioDeviceEnumerator.cs
+++ b/Measurement/AudioDeviceEnumerator.cs
@@ -2,7 +2,7 @@
using System.Linq;
using NAudio.CoreAudioApi;
-namespace Decibel_Monitor.Services;
+namespace Decibel_Monitor.Measurement;
///
/// 捕获设备枚举与缓存(默认捕获设备 / 设备描述列表,枚举结果带 5 秒缓存)。
diff --git a/Services/AudioPeakMeter.cs b/Measurement/AudioPeakMeter.cs
similarity index 98%
rename from Services/AudioPeakMeter.cs
rename to Measurement/AudioPeakMeter.cs
index f761424..6621d06 100644
--- a/Services/AudioPeakMeter.cs
+++ b/Measurement/AudioPeakMeter.cs
@@ -1,8 +1,9 @@
using System;
using System.Threading;
using System.Threading.Tasks;
+using Decibel_Monitor.Services;
-namespace Decibel_Monitor.Services;
+namespace Decibel_Monitor.Measurement;
///
/// 麦克风峰值采样服务。
diff --git a/Services/ContinuousPeakCapture.cs b/Measurement/ContinuousPeakCapture.cs
similarity index 99%
rename from Services/ContinuousPeakCapture.cs
rename to Measurement/ContinuousPeakCapture.cs
index c3aaba1..2e54095 100644
--- a/Services/ContinuousPeakCapture.cs
+++ b/Measurement/ContinuousPeakCapture.cs
@@ -2,7 +2,7 @@
using NAudio.CoreAudioApi;
using NAudio.Wave;
-namespace Decibel_Monitor.Services;
+namespace Decibel_Monitor.Measurement;
///
/// 常驻静默捕获流:以 WASAPI 共享模式持续读取默认麦克风峰值(仅计算峰值、不保存音频)。
diff --git a/Services/OneShotPeakCapture.cs b/Measurement/OneShotPeakCapture.cs
similarity index 99%
rename from Services/OneShotPeakCapture.cs
rename to Measurement/OneShotPeakCapture.cs
index 4e76e68..f8b7415 100644
--- a/Services/OneShotPeakCapture.cs
+++ b/Measurement/OneShotPeakCapture.cs
@@ -4,7 +4,7 @@
using NAudio.CoreAudioApi;
using NAudio.Wave;
-namespace Decibel_Monitor.Services;
+namespace Decibel_Monitor.Measurement;
///
/// 显式(一次性)采样:按 AudioMeterInformation → WasapiCapture → WaveInEvent 顺序回退,
diff --git a/Services/PeakSample.cs b/Measurement/PeakSample.cs
similarity index 97%
rename from Services/PeakSample.cs
rename to Measurement/PeakSample.cs
index 07185af..b123e96 100644
--- a/Services/PeakSample.cs
+++ b/Measurement/PeakSample.cs
@@ -1,6 +1,6 @@
using System;
-namespace Decibel_Monitor.Services;
+namespace Decibel_Monitor.Measurement;
///
/// 音频峰值解析纯函数工具类(不依赖 NAudio 具体类型,便于单元测试)。
diff --git a/Plugin.cs b/Plugin.cs
index f6b968d..332e3e9 100644
--- a/Plugin.cs
+++ b/Plugin.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.IO;
using ClassIsland.Core.Abstractions;
using ClassIsland.Core.Attributes;
@@ -18,13 +18,13 @@ public override void Initialize(HostBuilderContext context, IServiceCollection s
services.AddSingleton(globalSettingsService);
// 共享的麦克风峰值采样服务(单例,构造时读取全局采样偏好)
- services.AddSingleton();
+ services.AddSingleton();
// 一次性注册组件与其设置控件(不要重复注册)
services.AddComponent();
// 分贝提醒通知提供方(含"强调通知侧"设置控件)
- services.AddNotificationProvider();
// 插件设置页(与其他设置项同层级)
diff --git a/Tests/Decibel_Monitor.Tests/DecibelAlertEvaluatorTests.cs b/Tests/Decibel_Monitor.Tests/DecibelAlertEvaluatorTests.cs
index b226826..81f4896 100644
--- a/Tests/Decibel_Monitor.Tests/DecibelAlertEvaluatorTests.cs
+++ b/Tests/Decibel_Monitor.Tests/DecibelAlertEvaluatorTests.cs
@@ -1,5 +1,5 @@
using System;
-using Decibel_Monitor.Services;
+using Decibel_Monitor.Alerting;
namespace Decibel_Monitor.Tests;
diff --git a/Tests/Decibel_Monitor.Tests/DecibelCalculatorTests.cs b/Tests/Decibel_Monitor.Tests/DecibelCalculatorTests.cs
index 099b2b9..c3ec5f5 100644
--- a/Tests/Decibel_Monitor.Tests/DecibelCalculatorTests.cs
+++ b/Tests/Decibel_Monitor.Tests/DecibelCalculatorTests.cs
@@ -1,4 +1,4 @@
-using Decibel_Monitor.Services;
+using Decibel_Monitor.Alerting;
namespace Decibel_Monitor.Tests;
diff --git a/Tests/Decibel_Monitor.Tests/Decibel_Monitor.Tests.csproj b/Tests/Decibel_Monitor.Tests/Decibel_Monitor.Tests.csproj
index e2f5710..91ddd84 100644
--- a/Tests/Decibel_Monitor.Tests/Decibel_Monitor.Tests.csproj
+++ b/Tests/Decibel_Monitor.Tests/Decibel_Monitor.Tests.csproj
@@ -20,9 +20,9 @@
-
-
-
+
+
+
diff --git a/Tests/Decibel_Monitor.Tests/PeakSampleTests.cs b/Tests/Decibel_Monitor.Tests/PeakSampleTests.cs
index 18b3438..9c02845 100644
--- a/Tests/Decibel_Monitor.Tests/PeakSampleTests.cs
+++ b/Tests/Decibel_Monitor.Tests/PeakSampleTests.cs
@@ -1,5 +1,5 @@
using System;
-using Decibel_Monitor.Services;
+using Decibel_Monitor.Measurement;
namespace Decibel_Monitor.Tests;
diff --git a/Views/SettingsPages/DecibelMonitorSettingsPage.axaml.cs b/Views/SettingsPages/DecibelMonitorSettingsPage.axaml.cs
index cade346..19f1408 100644
--- a/Views/SettingsPages/DecibelMonitorSettingsPage.axaml.cs
+++ b/Views/SettingsPages/DecibelMonitorSettingsPage.axaml.cs
@@ -5,6 +5,8 @@
using ClassIsland.Core.Attributes;
using ClassIsland.Core.Controls;
using ClassIsland.Core.Enums.SettingsWindow;
+using Decibel_Monitor.Alerting;
+using Decibel_Monitor.Measurement;
using Decibel_Monitor.Models;
using Decibel_Monitor.Services;
From 2752de1767be43d6ebda4c6ba28df8d7372e436f Mon Sep 17 00:00:00 2001
From: Starsky16 <163619709+Starsky16@users.noreply.github.com>
Date: Sat, 19 Sep 2026 15:17:46 +0800
Subject: [PATCH 10/14] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E5=B9=B3?=
=?UTF-8?q?=E5=9D=87=E9=9F=B3=E9=87=8F=E5=88=A4=E5=AE=9A=E4=B8=8E=E5=88=A4?=
=?UTF-8?q?=E5=AE=9A=E6=BA=90=E6=A1=86=E6=9E=B6=E5=8F=8A=E5=8D=95=E6=B5=8B?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 新增 AverageVolumeAlertEvaluator(滑动窗口平均判定,严格大于阈值)
- 新增判定源抽象 IAlertDecisionSource 与 AlertContext/AlertDecision 契约
- 新增判定源①AutoThresholdDecisionSource:超阈值且冷却已过即提醒
- 新增判定源②HotkeyConfirmDecisionSource:超阈值开筛选窗口,窗口内命中热键才提醒
- 新增仲裁模块 AlertDecisionCoordinator:自持优先级配置、统一归口发出通知事件
- 测试工程链接上述纯逻辑源文件,新增 43 条用例(共 80 条全绿)
---
Alerting/AlertDecisionCoordinator.cs | 98 ++++++++
Alerting/AutoThresholdDecisionSource.cs | 69 ++++++
Alerting/AverageVolumeAlertEvaluator.cs | 39 +++
Alerting/HotkeyConfirmDecisionSource.cs | 145 +++++++++++
Alerting/HotkeyDefinition.cs | 53 ++++
Alerting/IAlertDecisionSource.cs | 51 ++++
.../AlertDecisionCoordinatorTests.cs | 151 ++++++++++++
.../AutoThresholdDecisionSourceTests.cs | 122 +++++++++
.../AverageVolumeAlertEvaluatorTests.cs | 68 +++++
.../Decibel_Monitor.Tests.csproj | 6 +
.../HotkeyConfirmDecisionSourceTests.cs | 232 ++++++++++++++++++
11 files changed, 1034 insertions(+)
create mode 100644 Alerting/AlertDecisionCoordinator.cs
create mode 100644 Alerting/AutoThresholdDecisionSource.cs
create mode 100644 Alerting/AverageVolumeAlertEvaluator.cs
create mode 100644 Alerting/HotkeyConfirmDecisionSource.cs
create mode 100644 Alerting/HotkeyDefinition.cs
create mode 100644 Alerting/IAlertDecisionSource.cs
create mode 100644 Tests/Decibel_Monitor.Tests/AlertDecisionCoordinatorTests.cs
create mode 100644 Tests/Decibel_Monitor.Tests/AutoThresholdDecisionSourceTests.cs
create mode 100644 Tests/Decibel_Monitor.Tests/AverageVolumeAlertEvaluatorTests.cs
create mode 100644 Tests/Decibel_Monitor.Tests/HotkeyConfirmDecisionSourceTests.cs
diff --git a/Alerting/AlertDecisionCoordinator.cs b/Alerting/AlertDecisionCoordinator.cs
new file mode 100644
index 0000000..d82b400
--- /dev/null
+++ b/Alerting/AlertDecisionCoordinator.cs
@@ -0,0 +1,98 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace Decibel_Monitor.Alerting;
+
+///
+/// 仲裁结果:本次是否提醒,以及是由哪个判定源触发的。
+///
+/// 是否发出提醒。
+/// 触发提醒的判定源标识; 为 false 时可为 null。
+public readonly record struct CoordinatorDecision(bool ShouldAlert, string? TriggerSourceId);
+
+///
+/// 仲裁模块:收集所有启用判定源的判定结果,按优先级仲裁,并归口发出提醒通知。
+///
+///
+/// 职责与边界:
+///
+/// - 持有判定源集合与优先级配置(),优先级只在此配置,判定源自身不带优先级。
+/// - 每个采样周期调用所有启用判定源的 ,取优先级最高的触发结果。
+/// - 裁定需要提醒时,通过 事件唯一出口发出通知——判定源不接触通知。
+///
+/// 构造时未提供任何判定源。
+///
+public sealed class AlertDecisionCoordinator
+{
+ private readonly IAlertDecisionSource[] _sources;
+
+ ///
+ /// 需要发出提醒时触发。参数为触发提醒的判定源标识(供宿主映射到具体通知内容)。
+ /// 判定源本身不订阅此事件,由宿主运行时服务接入真实通知提供方。
+ ///
+ public event Action? NotifyRequested;
+
+ /// 全部判定源(含未启用的,启用状态在判定时读取)。
+ /// 判定源优先级顺序(按 Id 排序,靠前的优先级更高)。为空时按传入顺序。
+ public AlertDecisionCoordinator(IEnumerable sources, IReadOnlyList? sourcePriorityOrder = null)
+ {
+ var list = (sources ?? throw new ArgumentNullException(nameof(sources))).ToArray();
+ if (list.Length == 0) throw new InvalidOperationException("至少需要一个判定源。");
+
+ if (sourcePriorityOrder is { Count: > 0 })
+ {
+ var ordered = new List(list.Length);
+ foreach (var id in sourcePriorityOrder)
+ {
+ var matched = list.FirstOrDefault(s => s.Id == id);
+ if (matched is not null)
+ {
+ ordered.Add(matched);
+ }
+ }
+
+ // 仅追加未被优先级顺序覆盖的判定源(保持稳定顺序):缺失条目不代表要禁用
+ foreach (var s in list)
+ {
+ if (ordered.All(o => o.Id != s.Id))
+ {
+ ordered.Add(s);
+ }
+ }
+
+ _sources = ordered.ToArray();
+ }
+ else
+ {
+ _sources = list;
+ }
+ }
+
+ /// 按优先级排序后的全部判定源(含未启用的)。
+ public IReadOnlyList Sources => _sources;
+
+ ///
+ /// 依据当前上下文仲裁是否提醒:调用所有启用判定源(各自推进内部状态机),取优先级最高的触发结果。
+ /// 若需提醒,触发 事件并返回对应结果。
+ ///
+ public CoordinatorDecision Decide(AlertContext context, DateTime nowUtc)
+ {
+ // 必须先让所有判定源各自完成一次判定:判定源内部有冷却/筛选窗口等状态机,
+ // 提前返回会让低优先级判定源错过本周期,导致其窗口无法按时关闭。
+ string? triggerSourceId = null;
+ foreach (var source in _sources)
+ {
+ var decision = source.Decide(context, nowUtc);
+ if (decision.ShouldAlert && triggerSourceId is null)
+ {
+ triggerSourceId = source.Id;
+ }
+ }
+
+ if (triggerSourceId is null) return new CoordinatorDecision(false, null);
+
+ NotifyRequested?.Invoke(triggerSourceId);
+ return new CoordinatorDecision(true, triggerSourceId);
+ }
+}
\ No newline at end of file
diff --git a/Alerting/AutoThresholdDecisionSource.cs b/Alerting/AutoThresholdDecisionSource.cs
new file mode 100644
index 0000000..428424e
--- /dev/null
+++ b/Alerting/AutoThresholdDecisionSource.cs
@@ -0,0 +1,69 @@
+using System;
+
+namespace Decibel_Monitor.Alerting;
+
+///
+/// 判定源①:到阈值自动提醒。
+/// 当前(窗口平均)分贝值超过阈值且自身冷却已过时,直接判定为需要提醒。
+///
+public sealed class AutoThresholdDecisionSource : IAlertDecisionSource
+{
+ /// 判定源标识(用于仲裁模块的优先级排序与配置映射)。
+ public const string SourceId = "auto-threshold";
+
+ private DateTime _nextAlertTimeUtc = DateTime.MinValue;
+
+ /// 提醒阈值(显示刻度 0..150)。
+ /// 提醒冷却时间;为 null 时表示无冷却。
+ public AutoThresholdDecisionSource(double threshold = 120.0, TimeSpan? cooldown = null)
+ {
+ Threshold = threshold;
+ Cooldown = cooldown ?? TimeSpan.Zero;
+ }
+
+ ///
+ public string Id => SourceId;
+
+ ///
+ public string DisplayName => "自动提醒";
+
+ ///
+ public bool IsEnabled { get; set; }
+
+ /// 提醒阈值(显示刻度 0..150)。
+ public double Threshold { get; set; }
+
+ ///
+ public TimeSpan Cooldown { get; set; }
+
+ /// 下一次允许提醒的时间(UTC); 表示从未提醒过。
+ public DateTime NextAlertTimeUtc => _nextAlertTimeUtc;
+
+ /// 当前是否处于"超过阈值"状态(供组件显示提示文字)。
+ public bool IsTriggerActive { get; private set; }
+
+ ///
+ public AlertDecision Decide(AlertContext context, DateTime nowUtc)
+ {
+ if (!IsEnabled)
+ {
+ // 未启用时清空状态:再次启用后不会沿用旧冷却
+ IsTriggerActive = false;
+ _nextAlertTimeUtc = DateTime.MinValue;
+ return default;
+ }
+
+ // 阈值比较为严格大于:等于阈值不算超阈值
+ var isActive = context.AverageDecibel > Threshold;
+ IsTriggerActive = isActive;
+ if (!isActive) return new AlertDecision(false, false);
+
+ if (nowUtc < _nextAlertTimeUtc) return new AlertDecision(false, true);
+
+ _nextAlertTimeUtc = nowUtc + EffectiveCooldown();
+ return new AlertDecision(true, true);
+ }
+
+ /// 生效冷却时间:负值按无冷却处理。
+ private TimeSpan EffectiveCooldown() => Cooldown > TimeSpan.Zero ? Cooldown : TimeSpan.Zero;
+}
diff --git a/Alerting/AverageVolumeAlertEvaluator.cs b/Alerting/AverageVolumeAlertEvaluator.cs
new file mode 100644
index 0000000..f72506b
--- /dev/null
+++ b/Alerting/AverageVolumeAlertEvaluator.cs
@@ -0,0 +1,39 @@
+using System.Collections.Generic;
+
+namespace Decibel_Monitor.Alerting;
+
+///
+/// 平均音量判定纯函数(与 UI / 宿主无关,便于单元测试)。
+/// 用于把一段时间内的瞬时值平滑成平均值,避免单次尖峰误触发提醒。
+///
+public static class AverageVolumeAlertEvaluator
+{
+ ///
+ /// 计算窗口内样本的平均值;窗口为空时返回 0。
+ ///
+ /// 窗口内样本(显示刻度 0..150)。
+ public static double CalculateAverage(IReadOnlyList window)
+ {
+ if (window is null || window.Count == 0) return 0.0;
+
+ double sum = 0.0;
+ for (var i = 0; i < window.Count; i++)
+ {
+ sum += window[i];
+ }
+
+ return sum / window.Count;
+ }
+
+ ///
+ /// 判定窗口平均值是否超过阈值。
+ ///
+ /// 窗口内样本(显示刻度 0..150)。
+ /// 阈值(显示刻度)。
+ /// 与 一致,阈值比较为严格大于:等于阈值不触发;窗口为空不触发。
+ public static bool Evaluate(IReadOnlyList window, double threshold)
+ {
+ if (window is null || window.Count == 0) return false;
+ return CalculateAverage(window) > threshold;
+ }
+}
diff --git a/Alerting/HotkeyConfirmDecisionSource.cs b/Alerting/HotkeyConfirmDecisionSource.cs
new file mode 100644
index 0000000..d831db9
--- /dev/null
+++ b/Alerting/HotkeyConfirmDecisionSource.cs
@@ -0,0 +1,145 @@
+using System;
+
+namespace Decibel_Monitor.Alerting;
+
+///
+/// 判定源②:到阈值开筛选窗口,窗口内命中指定热键才提醒(无输入不提醒)。
+///
+///
+/// 判定流程:
+///
+/// - 窗口未开启时:平均分贝超过阈值且自身冷却已过 → 打开筛选窗口(窗口期内暂不提醒)。
+/// - 窗口开启期间:外部注入按键事件();命中配置热键 → 置为"已确认"。
+/// - 后续采样判定:窗口开启且未超时并已确认 → 触发提醒并关闭窗口、进入冷却。
+/// - 窗口开启但超时未确认 → 不提醒,关闭窗口(不进入冷却,下次超阈值可再次开窗)。
+///
+/// 热键输入由外部注入(便于单元测试),本类只做纯逻辑判定,不接触键盘钩子。
+///
+public sealed class HotkeyConfirmDecisionSource : IAlertDecisionSource
+{
+ /// 判定源标识(用于仲裁模块的优先级排序与配置映射)。
+ public const string SourceId = "hotkey-confirm";
+
+ private DateTime _windowOpenUntilUtc = DateTime.MinValue;
+ private DateTime _nextAlertTimeUtc = DateTime.MinValue;
+ private bool _confirmed;
+
+ /// 开启筛选窗口的分贝阈值(显示刻度 0..150)。
+ /// 筛选窗口内要求命中的热键。
+ /// 筛选窗口的开启时长;为 null 时使用默认 10 秒。
+ /// 提醒冷却时间;为 null 时无冷却。
+ public HotkeyConfirmDecisionSource(
+ double threshold = 120.0,
+ HotkeyDefinition hotkey = default,
+ TimeSpan? windowTimeout = null,
+ TimeSpan? cooldown = null)
+ {
+ Threshold = threshold;
+ Hotkey = hotkey;
+ WindowTimeout = windowTimeout ?? TimeSpan.FromSeconds(10);
+ Cooldown = cooldown ?? TimeSpan.Zero;
+ }
+
+ ///
+ public string Id => SourceId;
+
+ ///
+ public string DisplayName => "热键确认";
+
+ ///
+ public bool IsEnabled { get; set; }
+
+ /// 开启筛选窗口的分贝阈值(显示刻度 0..150)。
+ public double Threshold { get; set; }
+
+ /// 筛选窗口内要求命中的热键。未配置时为无效热键,窗口内永远无法命中。
+ public HotkeyDefinition Hotkey { get; set; }
+
+ /// 筛选窗口的开启时长。
+ public TimeSpan WindowTimeout { get; set; }
+
+ ///
+ public TimeSpan Cooldown { get; set; }
+
+ /// 当前筛选窗口是否开启(供组件红/绿点显示)。
+ public bool IsWindowOpen { get; private set; }
+
+ /// 当前是否处于"超过阈值"状态(供组件显示提示文字)。
+ public bool IsTriggerActive { get; private set; }
+
+ /// 下一次允许提醒的时间(UTC)。
+ public DateTime NextAlertTimeUtc => _nextAlertTimeUtc;
+
+ ///
+ /// 外部注入一次按键事件(后台钩子或测试直接调用)。
+ /// 仅当筛选窗口开启且命中配置热键时置为"已确认"。
+ ///
+ /// 本次按键是否命中并确认了当前窗口。
+ public bool HandleKeyPress(string? keyName, HotkeyModifiers modifiers, DateTime nowUtc)
+ {
+ if (!IsEnabled || !IsWindowOpen || nowUtc > _windowOpenUntilUtc) return false;
+
+ if (Hotkey.Matches(keyName, modifiers))
+ {
+ _confirmed = true;
+ return true;
+ }
+
+ return false;
+ }
+
+ ///
+ public AlertDecision Decide(AlertContext context, DateTime nowUtc)
+ {
+ if (!IsEnabled)
+ {
+ // 未启用时清空内部状态
+ Reset();
+ return default;
+ }
+
+ var isActive = context.AverageDecibel > Threshold;
+ IsTriggerActive = isActive;
+
+ var windowOpen = IsWindowOpen;
+
+ // 窗口未开 && 超阈值 && 冷却已过 → 开窗(不立即提醒,等待热键确认)
+ if (!windowOpen)
+ {
+ if (isActive && nowUtc >= _nextAlertTimeUtc && Hotkey.IsValid)
+ {
+ IsWindowOpen = true;
+ _confirmed = false;
+ _windowOpenUntilUtc = nowUtc + (WindowTimeout > TimeSpan.Zero ? WindowTimeout : TimeSpan.Zero);
+ }
+
+ return new AlertDecision(false, isActive);
+ }
+
+ // 窗口已开但超时 → 关窗(本次不提醒,不进入冷却)
+ if (nowUtc > _windowOpenUntilUtc)
+ {
+ IsWindowOpen = false;
+ _confirmed = false;
+ return new AlertDecision(false, isActive);
+ }
+
+ // 窗口开启且未超时:只有已命中热键才提醒
+ if (!_confirmed) return new AlertDecision(false, isActive);
+
+ // 命中确认 → 触发提醒,关窗并进入冷却
+ IsWindowOpen = false;
+ _confirmed = false;
+ _nextAlertTimeUtc = nowUtc + (Cooldown > TimeSpan.Zero ? Cooldown : TimeSpan.Zero);
+ return new AlertDecision(true, isActive);
+ }
+
+ private void Reset()
+ {
+ IsWindowOpen = false;
+ IsTriggerActive = false;
+ _confirmed = false;
+ _windowOpenUntilUtc = DateTime.MinValue;
+ _nextAlertTimeUtc = DateTime.MinValue;
+ }
+}
\ No newline at end of file
diff --git a/Alerting/HotkeyDefinition.cs b/Alerting/HotkeyDefinition.cs
new file mode 100644
index 0000000..2d11fe2
--- /dev/null
+++ b/Alerting/HotkeyDefinition.cs
@@ -0,0 +1,53 @@
+using System;
+
+namespace Decibel_Monitor.Alerting;
+
+///
+/// 修饰键状态(与 KeyboardCapture 的 KeyModifiers 语义一致,此处独立定义以保持纯逻辑可单测)。
+///
+[Flags]
+public enum HotkeyModifiers
+{
+ /// 无修饰键。
+ None = 0,
+
+ /// Ctrl 键(左或右)。
+ Ctrl = 1,
+
+ /// Alt 键(左或右)。
+ Alt = 2,
+
+ /// Shift 键(左或右)。
+ Shift = 4,
+
+ /// 系统/徽标键(Windows 上为 Win 键)。
+ Meta = 8,
+
+ /// 全部修饰键的掩码。
+ All = Ctrl | Alt | Shift | Meta,
+}
+
+///
+/// 一个热键定义(键名 + 修饰键组合)。
+///
+/// 键名,与 KeyboardCapture 的 KeyboardKey.Name 语义一致(如 "A"、"F5"、"Space")。
+/// 要求的修饰键组合(须完全一致才算命中)。
+public readonly record struct HotkeyDefinition(string Key, HotkeyModifiers Modifiers)
+{
+ /// 未配置的热键(不会命中任何按键)。
+ public static HotkeyDefinition None => new(string.Empty, HotkeyModifiers.None);
+
+ /// 是否已配置有效的键名。
+ public bool IsValid => !string.IsNullOrWhiteSpace(Key);
+
+ ///
+ /// 判定一次按键事件是否命中本热键(键名忽略大小写,修饰键须完全一致)。
+ ///
+ public bool Matches(string? keyName, HotkeyModifiers modifiers)
+ {
+ if (!IsValid || string.IsNullOrWhiteSpace(keyName)) return false;
+
+ return string.Equals(Key, keyName, StringComparison.OrdinalIgnoreCase)
+ && (modifiers & HotkeyModifiers.All) == (Modifiers & HotkeyModifiers.All);
+ }
+}
diff --git a/Alerting/IAlertDecisionSource.cs b/Alerting/IAlertDecisionSource.cs
new file mode 100644
index 0000000..a44aafb
--- /dev/null
+++ b/Alerting/IAlertDecisionSource.cs
@@ -0,0 +1,51 @@
+using System;
+
+namespace Decibel_Monitor.Alerting;
+
+///
+/// 一次判定的输入上下文(由运行时编排服务在每个采样周期构造)。
+///
+/// 当前瞬时映射分贝值(显示刻度 0..150)。
+/// 滑动窗口平均映射分贝值(显示刻度 0..150),窗口内无样本时等于当前值。
+public readonly record struct AlertContext(double CurrentDecibel, double AverageDecibel);
+
+///
+/// 单个判定源的判定结果。
+///
+/// 本次判定是否需要发出提醒。
+/// 当前是否处于"触发条件成立"状态(供组件显示提示文字)。
+public readonly record struct AlertDecision(bool ShouldAlert, bool IsTriggerActive);
+
+///
+/// 判定源:一种"判断方式"。
+///
+///
+/// 设计约束:
+///
+/// - 判定源只负责"判断何时该提醒",不接触通知——通知由
+/// 统一发出。
+/// - 判定源自带状态(冷却、筛选窗口等),实现需保证 可在每个采样周期被反复调用。
+/// - 判定源设置项只有:启用开关、冷却时间、专属参数;不含优先级(优先级归仲裁模块)。
+///
+///
+public interface IAlertDecisionSource
+{
+ /// 判定源标识(用于优先级排序与配置映射,须稳定且唯一)。
+ string Id { get; }
+
+ /// 判定源显示名(设置页展示)。
+ string DisplayName { get; }
+
+ /// 是否启用该判定源。
+ bool IsEnabled { get; }
+
+ /// 该判定源自身的提醒冷却时间(两次提醒之间的最小间隔)。
+ TimeSpan Cooldown { get; }
+
+ ///
+ /// 依据当前上下文判定是否提醒。未启用时应返回"不提醒"并清空内部状态。
+ ///
+ /// 当前判定上下文。
+ /// 当前时间(UTC)。
+ AlertDecision Decide(AlertContext context, DateTime nowUtc);
+}
diff --git a/Tests/Decibel_Monitor.Tests/AlertDecisionCoordinatorTests.cs b/Tests/Decibel_Monitor.Tests/AlertDecisionCoordinatorTests.cs
new file mode 100644
index 0000000..e477110
--- /dev/null
+++ b/Tests/Decibel_Monitor.Tests/AlertDecisionCoordinatorTests.cs
@@ -0,0 +1,151 @@
+using System;
+using System.Collections.Generic;
+using Decibel_Monitor.Alerting;
+
+namespace Decibel_Monitor.Tests;
+
+public class AlertDecisionCoordinatorTests
+{
+ private static readonly DateTime Now = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
+
+ private static readonly AlertContext Context = new(120.0, 120.0);
+
+ private sealed class FakeSource : IAlertDecisionSource
+ {
+ public FakeSource(string id, bool shouldAlert, bool enabled = true)
+ {
+ Id = id;
+ ShouldAlert = shouldAlert;
+ IsEnabled = enabled;
+ }
+
+ public string Id { get; }
+
+ public string DisplayName => Id;
+
+ public bool IsEnabled { get; set; }
+
+ public bool ShouldAlert { get; set; }
+
+ public TimeSpan Cooldown => TimeSpan.Zero;
+
+ public int DecideCount { get; private set; }
+
+ public AlertDecision Decide(AlertContext context, DateTime nowUtc)
+ {
+ DecideCount++;
+ return IsEnabled ? new AlertDecision(ShouldAlert, ShouldAlert) : default;
+ }
+ }
+
+ [Fact]
+ public void Constructor_Should_Throw_WhenNoSourceProvided()
+ {
+ Assert.Throws(() => new AlertDecisionCoordinator(Array.Empty()));
+ }
+
+ [Fact]
+ public void Decide_Should_Alert_ByPriorityOrder()
+ {
+ var low = new FakeSource("low", shouldAlert: true);
+ var high = new FakeSource("high", shouldAlert: true);
+ var coordinator = new AlertDecisionCoordinator(new[] { low, high }, new[] { "high", "low" });
+
+ var decision = coordinator.Decide(Context, Now);
+
+ Assert.True(decision.ShouldAlert);
+ Assert.Equal("high", decision.TriggerSourceId);
+ }
+
+ [Fact]
+ public void Decide_Should_FallBack_ToLowerPriority_WhenHigherDoesNotAlert()
+ {
+ var low = new FakeSource("low", shouldAlert: true);
+ var high = new FakeSource("high", shouldAlert: false);
+ var coordinator = new AlertDecisionCoordinator(new[] { low, high }, new[] { "high", "low" });
+
+ var decision = coordinator.Decide(Context, Now);
+
+ Assert.True(decision.ShouldAlert);
+ Assert.Equal("low", decision.TriggerSourceId);
+ }
+
+ [Fact]
+ public void Decide_Should_EvaluateAllSources_EvenAfterOneAlerts()
+ {
+ // 低优先级判定源也必须在每个周期完成判定,否则其内部窗口/冷却状态机无法推进
+ var low = new FakeSource("low", shouldAlert: false);
+ var high = new FakeSource("high", shouldAlert: true);
+ var coordinator = new AlertDecisionCoordinator(new[] { low, high }, new[] { "high", "low" });
+
+ coordinator.Decide(Context, Now);
+
+ Assert.Equal(1, high.DecideCount);
+ Assert.Equal(1, low.DecideCount);
+ }
+
+ [Fact]
+ public void Decide_Should_NotAlert_WhenNoSourceTriggers()
+ {
+ var coordinator = new AlertDecisionCoordinator(new[] { new FakeSource("a", shouldAlert: false) });
+
+ var decision = coordinator.Decide(Context, Now);
+
+ Assert.False(decision.ShouldAlert);
+ Assert.Null(decision.TriggerSourceId);
+ }
+
+ [Fact]
+ public void Decide_Should_IgnoreDisabledSource()
+ {
+ var coordinator = new AlertDecisionCoordinator(new[] { new FakeSource("a", shouldAlert: true, enabled: false) });
+
+ var decision = coordinator.Decide(Context, Now);
+
+ Assert.False(decision.ShouldAlert);
+ }
+
+ [Fact]
+ public void Decide_Should_RaiseNotifyRequested_OnlyWhenAlerting()
+ {
+ var coordinator = new AlertDecisionCoordinator(new[] { new FakeSource("a", shouldAlert: true) });
+ var notified = new List();
+ coordinator.NotifyRequested += notified.Add;
+
+ coordinator.Decide(Context, Now);
+ Assert.Equal(new[] { "a" }, notified);
+ }
+
+ [Fact]
+ public void Decide_Should_NotRaiseNotifyRequested_WhenNotAlerting()
+ {
+ var coordinator = new AlertDecisionCoordinator(new[] { new FakeSource("a", shouldAlert: false) });
+ var notified = new List();
+ coordinator.NotifyRequested += notified.Add;
+
+ coordinator.Decide(Context, Now);
+
+ Assert.Empty(notified);
+ }
+
+ [Fact]
+ public void Sources_Should_AppendUnlistedSources_AfterPriorityOrder()
+ {
+ var a = new FakeSource("a", shouldAlert: false);
+ var b = new FakeSource("b", shouldAlert: false);
+ var c = new FakeSource("c", shouldAlert: false);
+ var coordinator = new AlertDecisionCoordinator(new[] { a, b, c }, new[] { "c", "a" });
+
+ Assert.Equal(new[] { "c", "a", "b" }, new[] { coordinator.Sources[0].Id, coordinator.Sources[1].Id, coordinator.Sources[2].Id });
+ }
+
+ [Fact]
+ public void Sources_Should_KeepGivenOrder_WhenNoPriorityConfigured()
+ {
+ var a = new FakeSource("a", shouldAlert: false);
+ var b = new FakeSource("b", shouldAlert: false);
+ var coordinator = new AlertDecisionCoordinator(new[] { a, b });
+
+ Assert.Equal(new[] { "a", "b" }, new[] { coordinator.Sources[0].Id, coordinator.Sources[1].Id });
+ }
+}
diff --git a/Tests/Decibel_Monitor.Tests/AutoThresholdDecisionSourceTests.cs b/Tests/Decibel_Monitor.Tests/AutoThresholdDecisionSourceTests.cs
new file mode 100644
index 0000000..c1e0e49
--- /dev/null
+++ b/Tests/Decibel_Monitor.Tests/AutoThresholdDecisionSourceTests.cs
@@ -0,0 +1,122 @@
+using System;
+using Decibel_Monitor.Alerting;
+
+namespace Decibel_Monitor.Tests;
+
+public class AutoThresholdDecisionSourceTests
+{
+ private const double Threshold = 100.0;
+
+ private static readonly DateTime Now = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
+
+ private static AlertContext Context(double averageDb, double currentDb = 0.0) => new(currentDb, averageDb);
+
+ private static AutoThresholdDecisionSource CreateSource(TimeSpan? cooldown = null, bool enabled = true)
+ => new(Threshold, cooldown) { IsEnabled = enabled };
+
+ [Fact]
+ public void Decide_Should_NotAlert_WhenDisabled()
+ {
+ var source = CreateSource(enabled: false);
+
+ var decision = source.Decide(Context(150.0), Now);
+
+ Assert.False(decision.ShouldAlert);
+ Assert.False(decision.IsTriggerActive);
+ Assert.False(source.IsTriggerActive);
+ }
+
+ [Fact]
+ public void Decide_Should_Alert_OnFirstOverThreshold()
+ {
+ var source = CreateSource();
+
+ var decision = source.Decide(Context(120.0), Now);
+
+ Assert.True(decision.ShouldAlert);
+ Assert.True(decision.IsTriggerActive);
+ }
+
+ [Fact]
+ public void Decide_Should_NotAlert_WhenEqualToThreshold()
+ {
+ var source = CreateSource();
+
+ var decision = source.Decide(Context(Threshold), Now);
+
+ Assert.False(decision.ShouldAlert);
+ Assert.False(decision.IsTriggerActive);
+ }
+
+ [Fact]
+ public void Decide_Should_CompareAverage_NotCurrentValue()
+ {
+ // 瞬时值超阈值但窗口平均未超:不提醒(判定以平均值为准)
+ var source = CreateSource();
+
+ var decision = source.Decide(Context(90.0, 150.0), Now);
+
+ Assert.False(decision.ShouldAlert);
+ }
+
+ [Fact]
+ public void Decide_Should_NotAlert_WhileCoolingDown_ButStayActive()
+ {
+ var source = CreateSource(TimeSpan.FromMinutes(5));
+ var first = source.Decide(Context(120.0), Now);
+ Assert.True(first.ShouldAlert);
+
+ var cooling = source.Decide(Context(120.0), Now.AddMinutes(1));
+
+ Assert.False(cooling.ShouldAlert);
+ Assert.True(cooling.IsTriggerActive);
+ Assert.Equal(Now.AddMinutes(5), source.NextAlertTimeUtc);
+ }
+
+ [Fact]
+ public void Decide_Should_AlertAgain_WhenCooldownElapsed()
+ {
+ var source = CreateSource(TimeSpan.FromMinutes(5));
+ source.Decide(Context(120.0), Now);
+
+ var again = source.Decide(Context(120.0), Now.AddMinutes(5));
+
+ Assert.True(again.ShouldAlert);
+ Assert.Equal(Now.AddMinutes(10), source.NextAlertTimeUtc);
+ }
+
+ [Fact]
+ public void Decide_Should_AlertEveryTick_WhenCooldownIsZero()
+ {
+ var source = CreateSource(TimeSpan.Zero);
+
+ Assert.True(source.Decide(Context(120.0), Now).ShouldAlert);
+ Assert.True(source.Decide(Context(120.0), Now).ShouldAlert);
+ }
+
+ [Fact]
+ public void Decide_Should_ClearCooldown_WhenDisabledThenReEnabled()
+ {
+ var source = CreateSource(TimeSpan.FromMinutes(10));
+ source.Decide(Context(120.0), Now);
+
+ source.IsEnabled = false;
+ source.Decide(Context(120.0), Now);
+ source.IsEnabled = true;
+
+ var decision = source.Decide(Context(120.0), Now.AddMinutes(1));
+
+ Assert.True(decision.ShouldAlert);
+ }
+
+ [Fact]
+ public void Decide_Should_NotAlert_WhenBelowThreshold()
+ {
+ var source = CreateSource();
+
+ var decision = source.Decide(Context(50.0), Now);
+
+ Assert.False(decision.ShouldAlert);
+ Assert.False(decision.IsTriggerActive);
+ }
+}
diff --git a/Tests/Decibel_Monitor.Tests/AverageVolumeAlertEvaluatorTests.cs b/Tests/Decibel_Monitor.Tests/AverageVolumeAlertEvaluatorTests.cs
new file mode 100644
index 0000000..cd109dc
--- /dev/null
+++ b/Tests/Decibel_Monitor.Tests/AverageVolumeAlertEvaluatorTests.cs
@@ -0,0 +1,68 @@
+using System.Collections.Generic;
+using Decibel_Monitor.Alerting;
+
+namespace Decibel_Monitor.Tests;
+
+public class AverageVolumeAlertEvaluatorTests
+{
+ private const double Threshold = 100.0;
+
+ // ---- 平均值计算 ----
+
+ [Fact]
+ public void CalculateAverage_Should_ReturnZero_WhenWindowEmpty()
+ {
+ Assert.Equal(0.0, AverageVolumeAlertEvaluator.CalculateAverage(new List()));
+ }
+
+ [Fact]
+ public void CalculateAverage_Should_ReturnMean_WhenWindowHasSamples()
+ {
+ var window = new List { 80f, 100f, 120f };
+
+ Assert.Equal(100.0, AverageVolumeAlertEvaluator.CalculateAverage(window), 6);
+ }
+
+ // ---- 超阈值判定 ----
+
+ [Fact]
+ public void Evaluate_Should_ReturnFalse_WhenWindowEmpty()
+ {
+ Assert.False(AverageVolumeAlertEvaluator.Evaluate(new List(), Threshold));
+ }
+
+ [Fact]
+ public void Evaluate_Should_ReturnFalse_WhenBelowThreshold()
+ {
+ var window = new List { 80f, 90f, 99f };
+
+ Assert.False(AverageVolumeAlertEvaluator.Evaluate(window, Threshold));
+ }
+
+ [Fact]
+ public void Evaluate_Should_ReturnFalse_WhenAverageEqualsThreshold()
+ {
+ // 边界:严格大于才算超阈值
+ var window = new List { 90f, 100f, 110f };
+
+ Assert.False(AverageVolumeAlertEvaluator.Evaluate(window, Threshold));
+ }
+
+ [Fact]
+ public void Evaluate_Should_ReturnTrue_WhenAverageAboveThreshold()
+ {
+ var window = new List { 100f, 120f, 130f };
+
+ Assert.True(AverageVolumeAlertEvaluator.Evaluate(window, Threshold));
+ }
+
+ [Fact]
+ public void Evaluate_Should_SmoothSinglePeak_ByWindowAverage()
+ {
+ // 单次尖峰经窗口平均后不应超阈值:这是平均判定相对瞬时判定的意义所在
+ var window = new List { 60f, 60f, 60f, 150f };
+
+ Assert.Equal(82.5, AverageVolumeAlertEvaluator.CalculateAverage(window), 6);
+ Assert.False(AverageVolumeAlertEvaluator.Evaluate(window, Threshold));
+ }
+}
diff --git a/Tests/Decibel_Monitor.Tests/Decibel_Monitor.Tests.csproj b/Tests/Decibel_Monitor.Tests/Decibel_Monitor.Tests.csproj
index 91ddd84..e63a889 100644
--- a/Tests/Decibel_Monitor.Tests/Decibel_Monitor.Tests.csproj
+++ b/Tests/Decibel_Monitor.Tests/Decibel_Monitor.Tests.csproj
@@ -22,6 +22,12 @@
+
+
+
+
+
+
diff --git a/Tests/Decibel_Monitor.Tests/HotkeyConfirmDecisionSourceTests.cs b/Tests/Decibel_Monitor.Tests/HotkeyConfirmDecisionSourceTests.cs
new file mode 100644
index 0000000..fcc445c
--- /dev/null
+++ b/Tests/Decibel_Monitor.Tests/HotkeyConfirmDecisionSourceTests.cs
@@ -0,0 +1,232 @@
+using System;
+using Decibel_Monitor.Alerting;
+
+namespace Decibel_Monitor.Tests;
+
+public class HotkeyConfirmDecisionSourceTests
+{
+ private const double Threshold = 100.0;
+ private const string KeyName = "F5";
+
+ private static readonly DateTime Now = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
+
+ private static readonly TimeSpan Window = TimeSpan.FromSeconds(10);
+
+ private static AlertContext Context(double averageDb) => new(averageDb, averageDb);
+
+ private static HotkeyConfirmDecisionSource CreateSource(
+ bool enabled = true,
+ TimeSpan? cooldown = null,
+ HotkeyModifiers modifiers = HotkeyModifiers.None)
+ => new(Threshold, new HotkeyDefinition(KeyName, modifiers), Window, cooldown) { IsEnabled = enabled };
+
+ // ---- 窗口开闭 ----
+
+ [Fact]
+ public void Decide_Should_NotOpenWindow_WhenDisabled()
+ {
+ var source = CreateSource(enabled: false);
+
+ var decision = source.Decide(Context(150.0), Now);
+
+ Assert.False(decision.ShouldAlert);
+ Assert.False(source.IsWindowOpen);
+ }
+
+ [Fact]
+ public void Decide_Should_NotOpenWindow_WhenBelowThreshold()
+ {
+ var source = CreateSource();
+
+ var decision = source.Decide(Context(50.0), Now);
+
+ Assert.False(decision.ShouldAlert);
+ Assert.False(decision.IsTriggerActive);
+ Assert.False(source.IsWindowOpen);
+ }
+
+ [Fact]
+ public void Decide_Should_OpenWindow_WithoutAlerting_WhenOverThreshold()
+ {
+ var source = CreateSource();
+
+ var decision = source.Decide(Context(120.0), Now);
+
+ Assert.False(decision.ShouldAlert);
+ Assert.True(decision.IsTriggerActive);
+ Assert.True(source.IsWindowOpen);
+ }
+
+ [Fact]
+ public void Decide_Should_NotOpenWindow_WhenHotkeyNotConfigured()
+ {
+ var source = new HotkeyConfirmDecisionSource(Threshold, HotkeyDefinition.None, Window) { IsEnabled = true };
+
+ var decision = source.Decide(Context(120.0), Now);
+
+ Assert.False(decision.ShouldAlert);
+ Assert.False(source.IsWindowOpen);
+ }
+
+ // ---- 热键确认 ----
+
+ [Fact]
+ public void Decide_Should_Alert_WhenHotkeyHitInsideWindow()
+ {
+ var source = CreateSource();
+ source.Decide(Context(120.0), Now);
+
+ var hit = source.HandleKeyPress(KeyName, HotkeyModifiers.None, Now.AddSeconds(1));
+ var decision = source.Decide(Context(120.0), Now.AddSeconds(1));
+
+ Assert.True(hit);
+ Assert.True(decision.ShouldAlert);
+ Assert.False(source.IsWindowOpen);
+ }
+
+ [Fact]
+ public void Decide_Should_NotAlert_WhenOtherKeyPressedInsideWindow()
+ {
+ var source = CreateSource();
+ source.Decide(Context(120.0), Now);
+
+ var hit = source.HandleKeyPress("F6", HotkeyModifiers.None, Now.AddSeconds(1));
+ var decision = source.Decide(Context(120.0), Now.AddSeconds(1));
+
+ Assert.False(hit);
+ Assert.False(decision.ShouldAlert);
+ Assert.True(source.IsWindowOpen);
+ }
+
+ [Fact]
+ public void Decide_Should_NotAlert_WhenModifiersMismatch()
+ {
+ var source = CreateSource(modifiers: HotkeyModifiers.Ctrl);
+ source.Decide(Context(120.0), Now);
+
+ var hit = source.HandleKeyPress(KeyName, HotkeyModifiers.None, Now.AddSeconds(1));
+ var decision = source.Decide(Context(120.0), Now.AddSeconds(1));
+
+ Assert.False(hit);
+ Assert.False(decision.ShouldAlert);
+ Assert.True(source.IsWindowOpen);
+ }
+
+ [Fact]
+ public void HandleKeyPress_Should_ReturnFalse_WhenWindowNotOpen()
+ {
+ var source = CreateSource();
+
+ Assert.False(source.HandleKeyPress(KeyName, HotkeyModifiers.None, Now));
+ }
+
+ // ---- 窗口超时 ----
+
+ [Fact]
+ public void Decide_Should_CloseWindowWithoutAlerting_WhenWindowTimedOut()
+ {
+ var source = CreateSource();
+ source.Decide(Context(120.0), Now);
+
+ var timedOut = source.Decide(Context(120.0), Now + Window + TimeSpan.FromMilliseconds(1));
+
+ Assert.False(timedOut.ShouldAlert);
+ Assert.False(source.IsWindowOpen);
+ }
+
+ [Fact]
+ public void Decide_Should_IgnoreKeyPress_WhenWindowTimedOut()
+ {
+ var source = CreateSource();
+ source.Decide(Context(120.0), Now);
+
+ var hit = source.HandleKeyPress(KeyName, HotkeyModifiers.None, Now + Window + TimeSpan.FromSeconds(1));
+
+ Assert.False(hit);
+ }
+
+ [Fact]
+ public void Decide_Should_OpenWindowAgain_AfterTimeout()
+ {
+ // 超时未确认不进入冷却,仍在超阈值时可再次开窗
+ var source = CreateSource(cooldown: TimeSpan.FromMinutes(5));
+ source.Decide(Context(120.0), Now);
+ source.Decide(Context(120.0), Now + Window + TimeSpan.FromSeconds(1));
+
+ source.Decide(Context(120.0), Now + Window + TimeSpan.FromSeconds(2));
+
+ Assert.True(source.IsWindowOpen);
+ }
+
+ // ---- 冷却 ----
+
+ [Fact]
+ public void Decide_Should_NotOpenWindow_WhileCoolingDown()
+ {
+ var source = CreateSource(cooldown: TimeSpan.FromMinutes(5));
+ source.Decide(Context(120.0), Now);
+ source.HandleKeyPress(KeyName, HotkeyModifiers.None, Now.AddSeconds(1));
+ Assert.True(source.Decide(Context(120.0), Now.AddSeconds(1)).ShouldAlert);
+
+ var cooling = source.Decide(Context(120.0), Now.AddSeconds(2));
+
+ Assert.False(cooling.ShouldAlert);
+ Assert.False(source.IsWindowOpen);
+ Assert.Equal(Now.AddSeconds(1).AddMinutes(5), source.NextAlertTimeUtc);
+ }
+
+ [Fact]
+ public void Decide_Should_OpenWindowAgain_AfterCooldownElapsed()
+ {
+ var source = CreateSource(cooldown: TimeSpan.FromMinutes(5));
+ source.Decide(Context(120.0), Now);
+ source.HandleKeyPress(KeyName, HotkeyModifiers.None, Now);
+ source.Decide(Context(120.0), Now);
+
+ var afterCooldown = Now.AddMinutes(5);
+ source.Decide(Context(120.0), afterCooldown);
+
+ Assert.True(source.IsWindowOpen);
+ }
+
+ [Fact]
+ public void Decide_Should_ResetWindow_WhenDisabled()
+ {
+ var source = CreateSource();
+ source.Decide(Context(120.0), Now);
+ Assert.True(source.IsWindowOpen);
+
+ source.IsEnabled = false;
+ source.Decide(Context(120.0), Now.AddSeconds(1));
+
+ Assert.False(source.IsWindowOpen);
+ Assert.False(source.IsTriggerActive);
+ }
+
+ // ---- 热键匹配 ----
+
+ [Fact]
+ public void Hotkey_Should_MatchKeyName_IgnoringCase()
+ {
+ var hotkey = new HotkeyDefinition("f5", HotkeyModifiers.None);
+
+ Assert.True(hotkey.Matches("F5", HotkeyModifiers.None));
+ }
+
+ [Fact]
+ public void Hotkey_Should_RequireExactModifiers()
+ {
+ var hotkey = new HotkeyDefinition(KeyName, HotkeyModifiers.Ctrl | HotkeyModifiers.Shift);
+
+ Assert.True(hotkey.Matches(KeyName, HotkeyModifiers.Ctrl | HotkeyModifiers.Shift));
+ Assert.False(hotkey.Matches(KeyName, HotkeyModifiers.Ctrl));
+ Assert.False(hotkey.Matches(KeyName, HotkeyModifiers.Ctrl | HotkeyModifiers.Shift | HotkeyModifiers.Alt));
+ }
+
+ [Fact]
+ public void Hotkey_Should_NotMatch_WhenKeyNameEmpty()
+ {
+ Assert.False(HotkeyDefinition.None.Matches(KeyName, HotkeyModifiers.None));
+ Assert.False(new HotkeyDefinition(KeyName, HotkeyModifiers.None).Matches(null, HotkeyModifiers.None));
+ }
+}
From c35973244186f644999c233ea62782517ed4798a Mon Sep 17 00:00:00 2001
From: Starsky16 <163619709+Starsky16@users.noreply.github.com>
Date: Sat, 19 Sep 2026 15:28:26 +0800
Subject: [PATCH 11/14] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E7=83=AD?=
=?UTF-8?q?=E9=94=AE=E7=AD=9B=E9=80=89=E7=AA=97=E5=8F=A3=E4=B8=8E=E6=8F=90?=
=?UTF-8?q?=E9=86=92=E8=BF=90=E8=A1=8C=E6=97=B6=E7=BC=96=E6=8E=92=E6=9C=8D?=
=?UTF-8?q?=E5=8A=A1?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Alarm/HotkeyWindowMonitor:经 IAppHost 动态解析 KeyboardCapture 全局键盘服务
(非必需依赖,未解析到则定时重试降级为空转),把筛选窗口期间的 KeyDown
封送回 UI 线程并映射修饰键后转发给热键判定源,暴露窗口开关状态事件供组件红绿点。
- Alarm/AlertRuntimeService:周期采样构造判定上下文交由仲裁模块裁定,
经 NotifyRequested 统一接入现有通知提供方发出强调通知(语音由宿主负责),
执行提醒后进入 3 秒防自激暂停分贝采样判定并清空样本窗口。
- Plugin.cs:注册自动/热键两个判定源、仲裁模块及两个宿主服务(默认均未启用)。
---
Alarm/AlertRuntimeService.cs | 148 +++++++++++++++++++++++++++++
Alarm/HotkeyWindowMonitor.cs | 177 +++++++++++++++++++++++++++++++++++
Plugin.cs | 22 +++++
3 files changed, 347 insertions(+)
create mode 100644 Alarm/AlertRuntimeService.cs
create mode 100644 Alarm/HotkeyWindowMonitor.cs
diff --git a/Alarm/AlertRuntimeService.cs b/Alarm/AlertRuntimeService.cs
new file mode 100644
index 0000000..c921d5b
--- /dev/null
+++ b/Alarm/AlertRuntimeService.cs
@@ -0,0 +1,148 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Avalonia.Threading;
+using Decibel_Monitor.Alerting;
+using Decibel_Monitor.Measurement;
+using Decibel_Monitor.Services;
+using Microsoft.Extensions.Hosting;
+
+namespace Decibel_Monitor.Alarm;
+
+///
+/// 提醒运行时编排服务:周期采样 → 构造判定上下文 → 交由仲裁模块裁定 →
+/// 裁定提醒时接入通知提供方并进入 3 秒防自激(暂停分贝采样判定)。
+///
+///
+///
+/// - 判定源与仲裁模块由 DI 以单例注入;本服务只做「采样 → 判定 → 执行 → 防自激」编排,
+/// 不接触具体判定逻辑。
+/// - 通知的唯一出口是仲裁模块的 事件;
+/// 本服务把它接到现有 ,由宿主通知系统负责强调通知与语音,
+/// 不新增通知/语音开关。
+/// - 无任何启用判定源时不采样,避免无效的音频访问开销。
+///
+///
+public sealed class AlertRuntimeService : IHostedService, IDisposable
+{
+ /// 采样与判定周期。
+ private static readonly TimeSpan TickInterval = TimeSpan.FromSeconds(1);
+
+ /// 防自激时长:执行提醒后暂停分贝采样判定。
+ private static readonly TimeSpan AntiSelfKickDelay = TimeSpan.FromSeconds(3);
+
+ /// 平均分贝滑动窗口的样本容量(1 秒一个样本)。
+ private const int AverageWindowCount = 20;
+
+ private readonly AudioPeakMeter _audioPeakMeter;
+ private readonly DecibelMonitorSettingsService? _settingsService;
+ private readonly AlertDecisionCoordinator _coordinator;
+ private readonly List _window = new();
+ private readonly DispatcherTimer _timer;
+ private DateTime _pauseUntilUtc = DateTime.MinValue;
+ private double _lastAverage;
+ private bool _disposed;
+
+ ///
+ /// 是否处于防自激暂停期(执行提醒后 3 秒内)。暂停期内不采样、不判定。
+ ///
+ public bool IsSamplingPaused => DateTime.UtcNow < _pauseUntilUtc;
+
+ /// 共享的麦克风峰值采样服务。
+ /// 插件全局设置服务(可空;用于读取放大倍数)。
+ /// 仲裁模块(自持判定源集合与优先级配置)。
+ public AlertRuntimeService(
+ AudioPeakMeter audioPeakMeter,
+ DecibelMonitorSettingsService? settingsService,
+ AlertDecisionCoordinator coordinator)
+ {
+ _audioPeakMeter = audioPeakMeter ?? throw new ArgumentNullException(nameof(audioPeakMeter));
+ _settingsService = settingsService;
+ _coordinator = coordinator ?? throw new ArgumentNullException(nameof(coordinator));
+
+ _coordinator.NotifyRequested += OnNotifyRequested;
+
+ _timer = new DispatcherTimer { Interval = TickInterval };
+ _timer.Tick += Timer_Tick;
+ }
+
+ ///
+ public Task StartAsync(CancellationToken cancellationToken)
+ {
+ _timer.Start();
+ return Task.CompletedTask;
+ }
+
+ ///
+ public Task StopAsync(CancellationToken cancellationToken)
+ {
+ _timer.Stop();
+ return Task.CompletedTask;
+ }
+
+ ///
+ /// 仲裁模块裁定需要提醒时的唯一出口:接入现有通知提供方发出强调通知。
+ ///
+ private void OnNotifyRequested(string sourceId)
+ {
+ try
+ {
+ // 复用现有通知提供方:强调通知与语音均由宿主通知系统负责
+ DecibelNotificationProvider.Instance?.Evaluate(_lastAverage);
+ }
+ catch
+ {
+ // 通知失败不应中断编排
+ }
+ }
+
+ // 异步 Tick:全程保持在 DispatcherTimer 绑定的 UI 线程上下文(不就地切线程),
+ // 与热键确认封送回 UI 线程的处理保持一致,避免判定源状态被并发修改。
+ private async void Timer_Tick(object? sender, EventArgs e)
+ {
+ if (_disposed) return;
+
+ try
+ {
+ // 防自激:执行提醒后 3 秒内暂停采样判定
+ if (IsSamplingPaused) return;
+
+ // 无启用判定源时跳过采样,避免无效的音频访问
+ if (!_coordinator.Sources.Any(s => s.IsEnabled)) return;
+
+ var linear = await _audioPeakMeter.GetDefaultDevicePeakLinearAsync();
+ var magnification = _settingsService?.Settings.Magnification ?? 1.0;
+ var mapped = DecibelCalculator.LinearToDisplayDb(linear, magnification);
+
+ // 维护平均分贝滑动窗口(容量固定,超出即丢弃最旧样本)
+ _window.Add((float)mapped);
+ if (_window.Count > AverageWindowCount) _window.RemoveAt(0);
+ var average = AverageVolumeAlertEvaluator.CalculateAverage(_window);
+ _lastAverage = average;
+
+ var decision = _coordinator.Decide(new AlertContext(mapped, average), DateTime.UtcNow);
+ if (!decision.ShouldAlert) return;
+
+ // 提醒已执行(Decide 内部经 NotifyRequested 发出)→ 进入防自激,
+ // 并清空样本窗口,避免残留高值在恢复后立即再次触发。
+ _pauseUntilUtc = DateTime.UtcNow.Add(AntiSelfKickDelay);
+ _window.Clear();
+ }
+ catch
+ {
+ // 采样/判定任一步骤失败都不应抛出到定时器循环
+ }
+ }
+
+ ///
+ public void Dispose()
+ {
+ if (_disposed) return;
+ _disposed = true;
+ _coordinator.NotifyRequested -= OnNotifyRequested;
+ _timer.Stop();
+ _timer.Tick -= Timer_Tick;
+ }
+}
\ No newline at end of file
diff --git a/Alarm/HotkeyWindowMonitor.cs b/Alarm/HotkeyWindowMonitor.cs
new file mode 100644
index 0000000..a388766
--- /dev/null
+++ b/Alarm/HotkeyWindowMonitor.cs
@@ -0,0 +1,177 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Avalonia.Threading;
+using ClassIsland.Shared;
+using Decibel_Monitor.Alerting;
+using KeyboardCapture.Abstractions;
+using Microsoft.Extensions.Hosting;
+
+namespace Decibel_Monitor.Alarm;
+
+///
+/// 热键判定源的宿主接入:解析 KeyboardCapture 插件的全局键盘服务,把筛选窗口期间的
+/// 事件转发给
+/// ,并暴露窗口开关状态变化供组件显示红/绿点。
+///
+///
+///
+/// - KeyboardCapture 为非必需依赖:服务未解析到时降级为空转,
+/// 本插件其余功能不受影响;解析前定时重试(KeyboardCapture 插件加载顺序不保证)。
+/// - 键盘事件在后台线程触发,而判定源状态由 UI 线程的采样编排推进,
+/// 为避免竞态,按键处理统一封送到 后执行。
+///
+///
+public sealed class HotkeyWindowMonitor : IHostedService, IDisposable
+{
+ /// 解析 KeyboardCapture 服务失败后的重试间隔。
+ private static readonly TimeSpan ResolveRetryInterval = TimeSpan.FromSeconds(2);
+
+ /// 筛选窗口状态轮询间隔(用于触发 )。
+ private static readonly TimeSpan WindowPollInterval = TimeSpan.FromMilliseconds(200);
+
+ private readonly HotkeyConfirmDecisionSource _source;
+ private readonly DispatcherTimer _stateTimer;
+ private readonly object _gate = new();
+ private Timer? _retryTimer;
+ private IKeyboardCaptureService? _capture;
+ private bool _lastWindowOpen;
+ private bool _disposed;
+
+ ///
+ /// 筛选窗口开关状态变化事件。参数为当前是否开启( 开窗,供组件显示红/绿点)。
+ /// 在 UI 线程触发。
+ ///
+ public event EventHandler? WindowStateChanged;
+
+ /// 热键判定源(由 DI 以单例注入)。
+ public HotkeyWindowMonitor(HotkeyConfirmDecisionSource source)
+ {
+ _source = source ?? throw new ArgumentNullException(nameof(source));
+
+ // Avalonia 的 DispatcherTimer 固定绑定 UI 线程,Tick 与事件回调都在 UI 线程触发。
+ _stateTimer = new DispatcherTimer { Interval = WindowPollInterval };
+ _stateTimer.Tick += StateTimer_Tick;
+ }
+
+ /// KeyboardCapture 服务当前是否已解析成功。
+ public bool IsKeyboardCaptureAvailable => _capture is not null;
+
+ ///
+ public Task StartAsync(CancellationToken cancellationToken)
+ {
+ // 窗口状态轮询独立于 KeyboardCapture 是否可用:窗口开关由判定源决定。
+ _lastWindowOpen = _source.IsWindowOpen;
+ _stateTimer.Start();
+ TryResolveAndSubscribe();
+ return Task.CompletedTask;
+ }
+
+ ///
+ public Task StopAsync(CancellationToken cancellationToken)
+ {
+ StopSubscriptions();
+ return Task.CompletedTask;
+ }
+
+ /// 尝试解析 KeyboardCapture 服务;未成功则安排定时重试,成功后订阅按键事件。
+ private void TryResolveAndSubscribe()
+ {
+ if (_disposed || _capture is not null) return;
+
+ var service = IAppHost.TryGetService();
+ if (service is null)
+ {
+ // KeyboardCapture 插件可能晚于本插件加载,定时重试直到解析成功。
+ lock (_gate)
+ {
+ if (_disposed) return;
+ _retryTimer?.Dispose();
+ _retryTimer = new Timer(
+ _ => Dispatcher.UIThread.Post(TryResolveAndSubscribe),
+ null,
+ (int)ResolveRetryInterval.TotalMilliseconds,
+ (int)ResolveRetryInterval.TotalMilliseconds);
+ }
+ return;
+ }
+
+ lock (_gate)
+ {
+ if (_disposed) return;
+ _retryTimer?.Dispose();
+ _retryTimer = null;
+ _capture = service;
+ service.KeyDown += OnKeyDown;
+ }
+ }
+
+ ///
+ /// 全局按键按下事件(后台线程触发)。仅在筛选窗口开启期间转发给判定源,命中热键即确认。
+ ///
+ private void OnKeyDown(object? sender, KeyboardKeyEventArgs e)
+ {
+ // 未开窗或未启用时不消费按键,直接返回避免无谓封送。
+ if (!_source.IsEnabled || !_source.IsWindowOpen) return;
+
+ // 封送到 UI 线程,与采样编排(判定源状态推进)同线程,避免竞态。
+ Dispatcher.UIThread.Post(() =>
+ {
+ if (_disposed) return;
+ try
+ {
+ _source.HandleKeyPress(e.Key.Name, ToHotkeyModifiers(e.Modifiers), DateTime.UtcNow);
+ }
+ catch
+ {
+ // 按键处理失败不影响全局钩子分发
+ }
+ });
+ }
+
+ /// 把 KeyboardCapture 的修饰键位标志映射为本插件判定源使用的修饰键位标志。
+ private static HotkeyModifiers ToHotkeyModifiers(KeyboardCapture.Abstractions.KeyModifiers modifiers)
+ {
+ var result = HotkeyModifiers.None;
+ if (modifiers.HasFlag(KeyboardCapture.Abstractions.KeyModifiers.Ctrl)) result |= HotkeyModifiers.Ctrl;
+ if (modifiers.HasFlag(KeyboardCapture.Abstractions.KeyModifiers.Alt)) result |= HotkeyModifiers.Alt;
+ if (modifiers.HasFlag(KeyboardCapture.Abstractions.KeyModifiers.Shift)) result |= HotkeyModifiers.Shift;
+ if (modifiers.HasFlag(KeyboardCapture.Abstractions.KeyModifiers.Meta)) result |= HotkeyModifiers.Meta;
+ return result;
+ }
+
+ /// 轮询判定源窗口状态,变化时触发 。
+ private void StateTimer_Tick(object? sender, EventArgs e)
+ {
+ var open = _source.IsWindowOpen;
+ if (open == _lastWindowOpen) return;
+
+ _lastWindowOpen = open;
+ WindowStateChanged?.Invoke(this, open);
+ }
+
+ private void StopSubscriptions()
+ {
+ lock (_gate)
+ {
+ _retryTimer?.Dispose();
+ _retryTimer = null;
+ if (_capture is not null)
+ {
+ _capture.KeyDown -= OnKeyDown;
+ _capture = null;
+ }
+ }
+
+ _stateTimer.Stop();
+ }
+
+ ///
+ public void Dispose()
+ {
+ if (_disposed) return;
+ _disposed = true;
+ StopSubscriptions();
+ _stateTimer.Tick -= StateTimer_Tick;
+ }
+}
\ No newline at end of file
diff --git a/Plugin.cs b/Plugin.cs
index 332e3e9..dbb1118 100644
--- a/Plugin.cs
+++ b/Plugin.cs
@@ -20,6 +20,28 @@ public override void Initialize(HostBuilderContext context, IServiceCollection s
// 共享的麦克风峰值采样服务(单例,构造时读取全局采样偏好)
services.AddSingleton();
+ // ── 提醒链路:判定源 → 仲裁模块 → 运行时编排 ──
+ // 判定源各自独立启用(启用状态与参数由设置页控制),默认均未启用。
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton(
+ sp => sp.GetRequiredService());
+ services.AddSingleton(
+ sp => sp.GetRequiredService());
+
+ // 仲裁模块:自持判定源集合与优先级配置,并归口发出提醒通知
+ services.AddSingleton(sp => new Alerting.AlertDecisionCoordinator(
+ sp.GetServices(),
+ sourcePriorityOrder: null));
+
+ // 热键筛选窗口监视:KeyboardCapture 为非必需依赖,未安装时降级为空转
+ services.AddSingleton();
+ services.AddHostedService(sp => sp.GetRequiredService());
+
+ // 提醒运行时编排:采样 → 判定 → 执行提醒 → 防自激
+ services.AddSingleton();
+ services.AddHostedService(sp => sp.GetRequiredService());
+
// 一次性注册组件与其设置控件(不要重复注册)
services.AddComponent();
From f513235f5a4472b0f6a8b6826404a28af23e2d79 Mon Sep 17 00:00:00 2001
From: Starsky16 <163619709+Starsky16@users.noreply.github.com>
Date: Sat, 19 Sep 2026 16:12:50 +0800
Subject: [PATCH 12/14] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E5=88=A4?=
=?UTF-8?q?=E5=AE=9A=E6=BA=90=E8=AE=BE=E7=BD=AE=E9=A1=B5=E4=B8=8E=E7=BB=84?=
=?UTF-8?q?=E4=BB=B6=E7=AD=9B=E9=80=89=E7=AA=97=E5=8F=A3=E6=8C=87=E7=A4=BA?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 通知提供方设置只保留提醒文字,阈值与冷却统一由判定源负责,消除双重门槛
- 插件设置页新增“提醒判定”分区:平均窗口时长、判定源优先级排序、自动提醒与热键确认设置
- 分贝组件改为纯显示侧,新增热键筛选窗口红/绿点指示,只读取提醒运行时服务状态
- 仲裁模块支持运行时应用判定源优先级顺序
---
Alarm/AlertRuntimeService.cs | 131 +++++++++++++-----
Alarm/HotkeyWindowMonitor.cs | 50 ++-----
Alerting/AlertDecisionCoordinator.cs | 63 +++++----
Alerting/DecibelAlertEvaluator.cs | 41 ------
Alerting/DecibelAlertState.cs | 8 --
Alerting/DecibelNotificationProvider.cs | 73 +++-------
Controls/Components/DecibelComponent.axaml | 9 ++
Controls/Components/DecibelComponent.axaml.cs | 89 ++++++------
...lNotificationProviderSettingsControl.axaml | 26 +---
.../DecibelComponentSettings.cs | 7 +-
Models/DecibelMonitorGlobalSettings.cs | 66 +++++++++
.../DecibelNotificationProviderSettings.cs | 22 +--
.../AlertDecisionCoordinatorTests.cs | 39 ++++++
.../DecibelAlertEvaluatorTests.cs | 108 ---------------
.../Decibel_Monitor.Tests.csproj | 1 -
.../DecibelMonitorSettingsPage.axaml | 119 +++++++++++++++-
.../DecibelMonitorSettingsPage.axaml.cs | 86 +++++++++++-
17 files changed, 536 insertions(+), 402 deletions(-)
delete mode 100644 Alerting/DecibelAlertEvaluator.cs
delete mode 100644 Alerting/DecibelAlertState.cs
delete mode 100644 Tests/Decibel_Monitor.Tests/DecibelAlertEvaluatorTests.cs
diff --git a/Alarm/AlertRuntimeService.cs b/Alarm/AlertRuntimeService.cs
index c921d5b..dfbe12a 100644
--- a/Alarm/AlertRuntimeService.cs
+++ b/Alarm/AlertRuntimeService.cs
@@ -6,65 +6,87 @@
using Avalonia.Threading;
using Decibel_Monitor.Alerting;
using Decibel_Monitor.Measurement;
+using Decibel_Monitor.Models;
using Decibel_Monitor.Services;
using Microsoft.Extensions.Hosting;
namespace Decibel_Monitor.Alarm;
///
-/// 提醒运行时编排服务:周期采样 → 构造判定上下文 → 交由仲裁模块裁定 →
-/// 裁定提醒时接入通知提供方并进入 3 秒防自激(暂停分贝采样判定)。
+/// 提醒运行时编排服务:按固定节拍采样 → 构造判定上下文 → 交由仲裁模块裁定 →
+/// 裁定提醒时接入通知提供方,并进入 3 秒防自激(暂停分贝采样判定)。
///
///
///
-/// - 判定源与仲裁模块由 DI 以单例注入;本服务只做「采样 → 判定 → 执行 → 防自激」编排,
-/// 不接触具体判定逻辑。
+/// - 采样独立于组件视觉树:组件只读取本服务的最新状态用于显示,提醒链路不依赖组件是否显示。
+/// - 设置(各判定源的启用/阈值/冷却/专属参数、平均窗口、优先级顺序)在每个节拍从插件全局设置同步到判定源,
+/// 设置页改动下一拍即生效。
/// - 通知的唯一出口是仲裁模块的 事件;
-/// 本服务把它接到现有 ,由宿主通知系统负责强调通知与语音,
+/// 本服务把它接到现有 ,强调通知与语音由宿主通知系统负责,
/// 不新增通知/语音开关。
-/// - 无任何启用判定源时不采样,避免无效的音频访问开销。
///
///
public sealed class AlertRuntimeService : IHostedService, IDisposable
{
- /// 采样与判定周期。
- private static readonly TimeSpan TickInterval = TimeSpan.FromSeconds(1);
+ /// 采样与判定节拍。
+ private static readonly TimeSpan SampleInterval = TimeSpan.FromMilliseconds(200);
/// 防自激时长:执行提醒后暂停分贝采样判定。
private static readonly TimeSpan AntiSelfKickDelay = TimeSpan.FromSeconds(3);
- /// 平均分贝滑动窗口的样本容量(1 秒一个样本)。
- private const int AverageWindowCount = 20;
+ /// 平均窗口允许的样本数上限(防止窗口时长配置过大时无界占用内存)。
+ private const int MaxWindowSamples = 2000;
private readonly AudioPeakMeter _audioPeakMeter;
private readonly DecibelMonitorSettingsService? _settingsService;
private readonly AlertDecisionCoordinator _coordinator;
+ private readonly AutoThresholdDecisionSource _autoSource;
+ private readonly HotkeyConfirmDecisionSource _hotkeySource;
private readonly List _window = new();
private readonly DispatcherTimer _timer;
private DateTime _pauseUntilUtc = DateTime.MinValue;
- private double _lastAverage;
+ private string[]? _appliedPriorityOrder;
private bool _disposed;
- ///
- /// 是否处于防自激暂停期(执行提醒后 3 秒内)。暂停期内不采样、不判定。
- ///
- public bool IsSamplingPaused => DateTime.UtcNow < _pauseUntilUtc;
+ /// 最新一次采样的分贝映射值(显示刻度 0..150)。
+ public double CurrentDecibel { get; private set; }
+
+ /// 是否已取得过至少一次采样(未取得时组件显示占位文本)。
+ public bool HasSample { get; private set; }
+
+ /// 当前是否有任一判定源处于"超过阈值"状态(供组件显示提示文字)。
+ public bool IsTriggerActive { get; private set; }
+
+ /// 当前热键筛选窗口是否开启(供组件显示红/绿点)。
+ public bool IsHotkeyWindowOpen => _hotkeySource.IsWindowOpen;
+
+ /// 热键判定源是否已启用(决定组件是否显示红/绿点)。
+ public bool IsHotkeySourceEnabled => _hotkeySource.IsEnabled;
+
+ /// 提醒正文(取自通知提供方设置,供组件显示提示文字)。
+ public string AlertText => DecibelNotificationProvider.Instance?.Settings.AlertText ?? "请保持安静";
/// 共享的麦克风峰值采样服务。
- /// 插件全局设置服务(可空;用于读取放大倍数)。
+ /// 插件全局设置服务(可空;缺省时使用判定源内置默认参数)。
/// 仲裁模块(自持判定源集合与优先级配置)。
+ /// 自动提醒判定源(同步其设置)。
+ /// 热键确认判定源(同步其设置,并暴露筛选窗口状态)。
public AlertRuntimeService(
AudioPeakMeter audioPeakMeter,
DecibelMonitorSettingsService? settingsService,
- AlertDecisionCoordinator coordinator)
+ AlertDecisionCoordinator coordinator,
+ AutoThresholdDecisionSource autoSource,
+ HotkeyConfirmDecisionSource hotkeySource)
{
_audioPeakMeter = audioPeakMeter ?? throw new ArgumentNullException(nameof(audioPeakMeter));
_settingsService = settingsService;
_coordinator = coordinator ?? throw new ArgumentNullException(nameof(coordinator));
+ _autoSource = autoSource ?? throw new ArgumentNullException(nameof(autoSource));
+ _hotkeySource = hotkeySource ?? throw new ArgumentNullException(nameof(hotkeySource));
_coordinator.NotifyRequested += OnNotifyRequested;
- _timer = new DispatcherTimer { Interval = TickInterval };
+ _timer = new DispatcherTimer { Interval = SampleInterval };
_timer.Tick += Timer_Tick;
}
@@ -87,15 +109,7 @@ public Task StopAsync(CancellationToken cancellationToken)
///
private void OnNotifyRequested(string sourceId)
{
- try
- {
- // 复用现有通知提供方:强调通知与语音均由宿主通知系统负责
- DecibelNotificationProvider.Instance?.Evaluate(_lastAverage);
- }
- catch
- {
- // 通知失败不应中断编排
- }
+ DecibelNotificationProvider.Instance?.Notify();
}
// 异步 Tick:全程保持在 DispatcherTimer 绑定的 UI 线程上下文(不就地切线程),
@@ -106,23 +120,28 @@ private async void Timer_Tick(object? sender, EventArgs e)
try
{
- // 防自激:执行提醒后 3 秒内暂停采样判定
- if (IsSamplingPaused) return;
+ ApplySettings();
- // 无启用判定源时跳过采样,避免无效的音频访问
- if (!_coordinator.Sources.Any(s => s.IsEnabled)) return;
+ // 防自激:执行提醒后 3 秒内暂停采样与判定
+ if (DateTime.UtcNow < _pauseUntilUtc) return;
var linear = await _audioPeakMeter.GetDefaultDevicePeakLinearAsync();
var magnification = _settingsService?.Settings.Magnification ?? 1.0;
var mapped = DecibelCalculator.LinearToDisplayDb(linear, magnification);
+ CurrentDecibel = mapped;
+ HasSample = true;
- // 维护平均分贝滑动窗口(容量固定,超出即丢弃最旧样本)
+ // 维护平均分贝滑动窗口(容量由平均窗口时长与采样节拍换算,超出即丢弃最旧样本)
+ var capacity = GetWindowCapacity();
_window.Add((float)mapped);
- if (_window.Count > AverageWindowCount) _window.RemoveAt(0);
+ while (_window.Count > capacity) _window.RemoveAt(0);
var average = AverageVolumeAlertEvaluator.CalculateAverage(_window);
- _lastAverage = average;
var decision = _coordinator.Decide(new AlertContext(mapped, average), DateTime.UtcNow);
+
+ // 任一判定源处于超阈值状态即视为"提醒中"(供组件显示提示文字)
+ IsTriggerActive = _autoSource.IsTriggerActive || _hotkeySource.IsTriggerActive;
+
if (!decision.ShouldAlert) return;
// 提醒已执行(Decide 内部经 NotifyRequested 发出)→ 进入防自激,
@@ -136,6 +155,50 @@ private async void Timer_Tick(object? sender, EventArgs e)
}
}
+ /// 把插件全局设置同步到各判定源与仲裁模块(设置页改动下一拍即生效)。
+ private void ApplySettings()
+ {
+ var settings = _settingsService?.Settings;
+ if (settings is null) return;
+
+ _autoSource.IsEnabled = settings.AutoSourceEnabled;
+ _autoSource.Threshold = settings.AutoSourceThreshold;
+ _autoSource.Cooldown = TimeSpan.FromSeconds(Math.Max(0, settings.AutoSourceCooldownSeconds));
+
+ _hotkeySource.IsEnabled = settings.HotkeySourceEnabled;
+ _hotkeySource.Threshold = settings.HotkeySourceThreshold;
+ _hotkeySource.Cooldown = TimeSpan.FromSeconds(Math.Max(0, settings.HotkeySourceCooldownSeconds));
+ _hotkeySource.WindowTimeout = TimeSpan.FromSeconds(Math.Clamp(settings.HotkeyWindowSeconds, 1, 600));
+ _hotkeySource.Hotkey = new HotkeyDefinition(settings.HotkeyKey ?? string.Empty, ComposeModifiers(settings));
+
+ // 优先级顺序变化时才重排(避免每拍重复排序)
+ var order = settings.SourcePriorityOrder;
+ if (_appliedPriorityOrder is null || !order.SequenceEqual(_appliedPriorityOrder))
+ {
+ _coordinator.ApplyPriorityOrder(order);
+ _appliedPriorityOrder = order.ToArray();
+ }
+ }
+
+ /// 把设置中的四个修饰键开关组合为热键修饰键位标志。
+ private static HotkeyModifiers ComposeModifiers(DecibelMonitorGlobalSettings settings)
+ {
+ var modifiers = HotkeyModifiers.None;
+ if (settings.HotkeyCtrl) modifiers |= HotkeyModifiers.Ctrl;
+ if (settings.HotkeyAlt) modifiers |= HotkeyModifiers.Alt;
+ if (settings.HotkeyShift) modifiers |= HotkeyModifiers.Shift;
+ if (settings.HotkeyMeta) modifiers |= HotkeyModifiers.Meta;
+ return modifiers;
+ }
+
+ /// 按平均窗口时长与采样节拍换算窗口样本容量(至少 1,最多 )。
+ private int GetWindowCapacity()
+ {
+ var seconds = _settingsService?.Settings.AverageWindowSeconds ?? 3.0;
+ var count = (int)Math.Round(seconds * 1000.0 / SampleInterval.TotalMilliseconds);
+ return Math.Clamp(count, 1, MaxWindowSamples);
+ }
+
///
public void Dispose()
{
diff --git a/Alarm/HotkeyWindowMonitor.cs b/Alarm/HotkeyWindowMonitor.cs
index a388766..619e1c4 100644
--- a/Alarm/HotkeyWindowMonitor.cs
+++ b/Alarm/HotkeyWindowMonitor.cs
@@ -12,7 +12,7 @@ namespace Decibel_Monitor.Alarm;
///
/// 热键判定源的宿主接入:解析 KeyboardCapture 插件的全局键盘服务,把筛选窗口期间的
/// 事件转发给
-/// ,并暴露窗口开关状态变化供组件显示红/绿点。
+/// 。
///
///
///
@@ -20,6 +20,8 @@ namespace Decibel_Monitor.Alarm;
/// 本插件其余功能不受影响;解析前定时重试(KeyboardCapture 插件加载顺序不保证)。
/// - 键盘事件在后台线程触发,而判定源状态由 UI 线程的采样编排推进,
/// 为避免竞态,按键处理统一封送到 后执行。
+/// - 筛选窗口的开/关状态由 自身维护,
+/// 组件经 读取,本服务不做状态轮询。
///
///
public sealed class HotkeyWindowMonitor : IHostedService, IDisposable
@@ -27,42 +29,21 @@ public sealed class HotkeyWindowMonitor : IHostedService, IDisposable
/// 解析 KeyboardCapture 服务失败后的重试间隔。
private static readonly TimeSpan ResolveRetryInterval = TimeSpan.FromSeconds(2);
- /// 筛选窗口状态轮询间隔(用于触发 )。
- private static readonly TimeSpan WindowPollInterval = TimeSpan.FromMilliseconds(200);
-
private readonly HotkeyConfirmDecisionSource _source;
- private readonly DispatcherTimer _stateTimer;
private readonly object _gate = new();
private Timer? _retryTimer;
private IKeyboardCaptureService? _capture;
- private bool _lastWindowOpen;
private bool _disposed;
- ///
- /// 筛选窗口开关状态变化事件。参数为当前是否开启( 开窗,供组件显示红/绿点)。
- /// 在 UI 线程触发。
- ///
- public event EventHandler? WindowStateChanged;
-
/// 热键判定源(由 DI 以单例注入)。
public HotkeyWindowMonitor(HotkeyConfirmDecisionSource source)
{
_source = source ?? throw new ArgumentNullException(nameof(source));
-
- // Avalonia 的 DispatcherTimer 固定绑定 UI 线程,Tick 与事件回调都在 UI 线程触发。
- _stateTimer = new DispatcherTimer { Interval = WindowPollInterval };
- _stateTimer.Tick += StateTimer_Tick;
}
- /// KeyboardCapture 服务当前是否已解析成功。
- public bool IsKeyboardCaptureAvailable => _capture is not null;
-
///
public Task StartAsync(CancellationToken cancellationToken)
{
- // 窗口状态轮询独立于 KeyboardCapture 是否可用:窗口开关由判定源决定。
- _lastWindowOpen = _source.IsWindowOpen;
- _stateTimer.Start();
TryResolveAndSubscribe();
return Task.CompletedTask;
}
@@ -70,7 +51,7 @@ public Task StartAsync(CancellationToken cancellationToken)
///
public Task StopAsync(CancellationToken cancellationToken)
{
- StopSubscriptions();
+ Unsubscribe();
return Task.CompletedTask;
}
@@ -82,7 +63,7 @@ private void TryResolveAndSubscribe()
var service = IAppHost.TryGetService();
if (service is null)
{
- // KeyboardCapture 插件可能晚于本插件加载,定时重试直到解析成功。
+ // KeyboardCapture 插件可能晚于本插件加载,定时重试直到解析成功
lock (_gate)
{
if (_disposed) return;
@@ -111,10 +92,10 @@ private void TryResolveAndSubscribe()
///
private void OnKeyDown(object? sender, KeyboardKeyEventArgs e)
{
- // 未开窗或未启用时不消费按键,直接返回避免无谓封送。
+ // 未开窗或未启用时不消费按键,直接返回避免无谓封送
if (!_source.IsEnabled || !_source.IsWindowOpen) return;
- // 封送到 UI 线程,与采样编排(判定源状态推进)同线程,避免竞态。
+ // 封送到 UI 线程,与采样编排(判定源状态推进)同线程,避免竞态
Dispatcher.UIThread.Post(() =>
{
if (_disposed) return;
@@ -140,17 +121,7 @@ private static HotkeyModifiers ToHotkeyModifiers(KeyboardCapture.Abstractions.Ke
return result;
}
- /// 轮询判定源窗口状态,变化时触发 。
- private void StateTimer_Tick(object? sender, EventArgs e)
- {
- var open = _source.IsWindowOpen;
- if (open == _lastWindowOpen) return;
-
- _lastWindowOpen = open;
- WindowStateChanged?.Invoke(this, open);
- }
-
- private void StopSubscriptions()
+ private void Unsubscribe()
{
lock (_gate)
{
@@ -162,8 +133,6 @@ private void StopSubscriptions()
_capture = null;
}
}
-
- _stateTimer.Stop();
}
///
@@ -171,7 +140,6 @@ public void Dispose()
{
if (_disposed) return;
_disposed = true;
- StopSubscriptions();
- _stateTimer.Tick -= StateTimer_Tick;
+ Unsubscribe();
}
}
\ No newline at end of file
diff --git a/Alerting/AlertDecisionCoordinator.cs b/Alerting/AlertDecisionCoordinator.cs
index d82b400..cbf6a03 100644
--- a/Alerting/AlertDecisionCoordinator.cs
+++ b/Alerting/AlertDecisionCoordinator.cs
@@ -25,7 +25,7 @@ namespace Decibel_Monitor.Alerting;
///
public sealed class AlertDecisionCoordinator
{
- private readonly IAlertDecisionSource[] _sources;
+ private IAlertDecisionSource[] _sources;
///
/// 需要发出提醒时触发。参数为触发提醒的判定源标识(供宿主映射到具体通知内容)。
@@ -40,37 +40,52 @@ public AlertDecisionCoordinator(IEnumerable sources, IRead
var list = (sources ?? throw new ArgumentNullException(nameof(sources))).ToArray();
if (list.Length == 0) throw new InvalidOperationException("至少需要一个判定源。");
- if (sourcePriorityOrder is { Count: > 0 })
+ _sources = Sort(list, sourcePriorityOrder);
+ }
+
+ /// 按优先级排序后的全部判定源(含未启用的)。
+ public IReadOnlyList Sources => _sources;
+
+ ///
+ /// 更新判定源优先级顺序(供设置页调整后生效)。未在顺序中出现的判定源按原顺序追加到末尾。
+ ///
+ /// 判定源优先级顺序(按 Id 排序,靠前的优先级更高)。为空时保持原顺序。
+ public void ApplyPriorityOrder(IReadOnlyList? sourcePriorityOrder)
+ {
+ if (sourcePriorityOrder is not { Count: > 0 }) return;
+ _sources = Sort(_sources, sourcePriorityOrder);
+ }
+
+ ///
+ /// 按优先级顺序重排判定源;缺失条目不代表要禁用,仅按原顺序追加到末尾。
+ ///
+ private static IAlertDecisionSource[] Sort(
+ IReadOnlyList sources,
+ IReadOnlyList? sourcePriorityOrder)
+ {
+ if (sourcePriorityOrder is not { Count: > 0 }) return sources.ToArray();
+
+ var ordered = new List(sources.Count);
+ foreach (var id in sourcePriorityOrder)
{
- var ordered = new List(list.Length);
- foreach (var id in sourcePriorityOrder)
+ var matched = sources.FirstOrDefault(s => s.Id == id);
+ if (matched is not null && ordered.All(o => o.Id != matched.Id))
{
- var matched = list.FirstOrDefault(s => s.Id == id);
- if (matched is not null)
- {
- ordered.Add(matched);
- }
+ ordered.Add(matched);
}
+ }
- // 仅追加未被优先级顺序覆盖的判定源(保持稳定顺序):缺失条目不代表要禁用
- foreach (var s in list)
+ // 仅追加未被优先级顺序覆盖的判定源(保持稳定顺序)
+ foreach (var s in sources)
+ {
+ if (ordered.All(o => o.Id != s.Id))
{
- if (ordered.All(o => o.Id != s.Id))
- {
- ordered.Add(s);
- }
+ ordered.Add(s);
}
-
- _sources = ordered.ToArray();
}
- else
- {
- _sources = list;
- }
- }
- /// 按优先级排序后的全部判定源(含未启用的)。
- public IReadOnlyList Sources => _sources;
+ return ordered.ToArray();
+ }
///
/// 依据当前上下文仲裁是否提醒:调用所有启用判定源(各自推进内部状态机),取优先级最高的触发结果。
diff --git a/Alerting/DecibelAlertEvaluator.cs b/Alerting/DecibelAlertEvaluator.cs
deleted file mode 100644
index 61d9893..0000000
--- a/Alerting/DecibelAlertEvaluator.cs
+++ /dev/null
@@ -1,41 +0,0 @@
-using System;
-
-namespace Decibel_Monitor.Alerting;
-
-///
-/// 分贝提醒评估结果。
-///
-/// 是否处于“超过阈值”提醒状态(用于组件显示提示文字)。
-/// 本次评估是否需要发送提醒(超阈值且冷却已过)。
-/// 下一次允许发送提醒的时间(UTC)。
-public readonly record struct DecibelAlertDecision(bool IsActive, bool ShouldNotify, DateTime NextAlertTimeUtc);
-
-///
-/// 分贝提醒评估纯函数(与 UI / 宿主无关,便于单元测试)。
-///
-public static class DecibelAlertEvaluator
-{
- ///
- /// 评估当前分贝映射值是否触发提醒。
- ///
- /// 当前分贝映射值(显示刻度 0..150)。
- /// 提醒阈值(显示刻度)。
- /// 是否启用提醒。
- /// 当前时间(UTC)。
- /// 上一次记录的下一次可提醒时间(UTC)。
- /// 冷却时间(分钟),最小按 1 分钟处理。
- /// 阈值比较为严格大于:等于阈值不触发。
- public static DecibelAlertDecision Evaluate(
- double mappedDb,
- double threshold,
- bool enabled,
- DateTime nowUtc,
- DateTime nextAlertTimeUtc,
- int cooldownMinutes)
- {
- var isActive = enabled && mappedDb > threshold;
- var shouldNotify = isActive && nowUtc >= nextAlertTimeUtc;
- var next = shouldNotify ? nowUtc.AddMinutes(Math.Max(1, cooldownMinutes)) : nextAlertTimeUtc;
- return new DecibelAlertDecision(isActive, shouldNotify, next);
- }
-}
diff --git a/Alerting/DecibelAlertState.cs b/Alerting/DecibelAlertState.cs
deleted file mode 100644
index da863e1..0000000
--- a/Alerting/DecibelAlertState.cs
+++ /dev/null
@@ -1,8 +0,0 @@
-namespace Decibel_Monitor.Alerting;
-
-///
-/// 分贝提醒提供方对一次分贝值的评估结果。
-///
-/// 当前是否处于"超过阈值"提醒状态(供组件显示提示文字)。
-/// 提醒文字(来自通知设置)。
-public sealed record DecibelAlertState(bool IsActive, string Text);
diff --git a/Alerting/DecibelNotificationProvider.cs b/Alerting/DecibelNotificationProvider.cs
index a5f9933..da3ad66 100644
--- a/Alerting/DecibelNotificationProvider.cs
+++ b/Alerting/DecibelNotificationProvider.cs
@@ -9,16 +9,15 @@ namespace Decibel_Monitor.Alerting;
///
/// 分贝提醒通知提供方。
-/// 负责集中评估"当前分贝值是否超过阈值"并触发 ClassIsland 提醒(带冷却防刷屏),
-/// 同时向组件返回评估状态用于显示提示文字。
+/// 负责发出 ClassIsland 醒目提醒(强调通知与语音均由宿主通知系统负责),
+/// 文案来自设置中的 。
///
///
-/// 修复说明:
-/// 1. 由非泛型 改为泛型
-/// ,从而获得宿主自动持久化的设置
-/// ("强调通知侧"的设置项),并声明 。
-/// 2. 注册 渠道,并通过 Channel(...) 发送,
-/// 与 ClassIsland 内置通知提供方保持一致,避免使用空渠道 GUID。
+/// 边界说明:本提供方只负责"发什么内容",不做阈值/冷却/启停判定——
+/// 那部分收敛到插件设置页的判定源()与
+/// 仲裁模块(),避免双重判定门槛。
+/// 宿主接入仍走 以获取自动持久化的设置,
+/// 并经 渠道发送,与 ClassIsland 内置提供方一致。
///
[NotificationProviderInfo(
"54f1b836-efa4-4755-bbac-7460f697cbb0",
@@ -41,8 +40,6 @@ public sealed class DecibelNotificationProvider : NotificationProviderBase? _logger;
- private DateTime _nextAlertTimeUtc = DateTime.MinValue;
-
///
/// 当前通知提供方实例(宿主启动后由 DI 以单例方式创建)。
///
@@ -55,39 +52,20 @@ public DecibelNotificationProvider(ILogger? logger
}
///
- /// 评估当前分贝映射值是否超阈值;需要时(超阈值且冷却已过)发送提醒。
- /// 供组件在每个刷新周期调用,避免组件自己维护提醒状态。
+ /// 发出一次"超过分贝阈值"提醒,文案取设置中的提醒正文。
+ /// 调用方(仲裁模块 / 运行时编排)负责判定"是否该提醒"。
///
- /// 当前分贝映射值(显示刻度 0..150)。
- /// 评估状态:是否处于"超过阈值"提醒状态及提醒文字。
- public DecibelAlertState Evaluate(double mappedDb)
+ /// 提醒正文;为空时回退到设置中的提醒正文。
+ public void Notify(string? text = null)
{
try
{
- var text = Settings.AlertText;
-
- // 判定逻辑位于 DecibelAlertEvaluator(纯函数,便于单元测试)
- var decision = DecibelAlertEvaluator.Evaluate(
- mappedDb,
- Settings.AlertThreshold,
- Settings.IsAlertEnabled,
- DateTime.UtcNow,
- _nextAlertTimeUtc,
- Settings.AlertCooldownMinutes);
-
- if (decision.ShouldNotify)
- {
- _nextAlertTimeUtc = decision.NextAlertTimeUtc;
- NotifyOverThreshold(text);
- }
-
- return new DecibelAlertState(decision.IsActive, text);
+ NotifyOverThreshold(string.IsNullOrWhiteSpace(text) ? Settings.AlertText : text);
}
catch (Exception ex)
{
- // 评估失败不应影响组件主功能,仅记录日志以便诊断
- _logger?.LogWarning(ex, "评估分贝提醒状态失败。");
- return new DecibelAlertState(false, Settings.AlertText);
+ // 通知失败不应影响主功能
+ _logger?.LogWarning(ex, "发送分贝提醒失败。");
}
}
@@ -97,21 +75,13 @@ public DecibelAlertState Evaluate(double mappedDb)
/// 提醒正文。
private void NotifyOverThreshold(string text)
{
- try
+ var request = new NotificationRequest
{
- var request = new NotificationRequest
- {
- ChannelId = Guid.Parse(OverThresholdChannelId),
- MaskContent = NotificationContent.CreateSimpleTextContent(text),
- OverlayContent = NotificationContent.CreateSimpleTextContent(text, x => x.Duration = TimeSpan.FromSeconds(30)),
- };
- Channel(OverThresholdChannelId).ShowNotification(request);
- }
- catch (Exception ex)
- {
- // 通知失败不应影响组件的主功能
- _logger?.LogWarning(ex, "发送分贝提醒失败。");
- }
+ ChannelId = Guid.Parse(OverThresholdChannelId),
+ MaskContent = NotificationContent.CreateSimpleTextContent(text),
+ OverlayContent = NotificationContent.CreateSimpleTextContent(text, x => x.Duration = TimeSpan.FromSeconds(30)),
+ };
+ Channel(OverThresholdChannelId).ShowNotification(request);
}
///
@@ -124,5 +94,4 @@ public void Dispose()
_instance = null;
}
}
-}
-
+}
\ No newline at end of file
diff --git a/Controls/Components/DecibelComponent.axaml b/Controls/Components/DecibelComponent.axaml
index c539c74..8751700 100644
--- a/Controls/Components/DecibelComponent.axaml
+++ b/Controls/Components/DecibelComponent.axaml
@@ -24,6 +24,15 @@
Foreground="#FFD32F2F"
VerticalAlignment="Center"
Margin="6,0,0,0"/>
+
+
+
+
+
diff --git a/Controls/Components/DecibelComponent.axaml.cs b/Controls/Components/DecibelComponent.axaml.cs
index e4e1874..3206b1c 100644
--- a/Controls/Components/DecibelComponent.axaml.cs
+++ b/Controls/Components/DecibelComponent.axaml.cs
@@ -4,9 +4,7 @@
using ClassIsland.Core.Abstractions.Controls;
using ClassIsland.Core.Attributes;
using ClassIsland.Shared;
-using Decibel_Monitor.Alerting;
-using Decibel_Monitor.Measurement;
-using Decibel_Monitor.Services;
+using Decibel_Monitor.Alarm;
using DecibelComponentSettings = Decibel_Monitor.Models.ComponentSettings.DecibelComponentSettings;
namespace Decibel_Monitor.Controls.Components;
@@ -31,9 +29,16 @@ public partial class DecibelComponent : ComponentBase
public static readonly StyledProperty AlertDisplayTextProperty =
AvaloniaProperty.Register(nameof(AlertDisplayText), "请保持安静");
+ /// 热键判定源所处的筛选窗口是否开启(红点)。
+ public static readonly StyledProperty IsHotkeyWindowOpenProperty =
+ AvaloniaProperty.Register(nameof(IsHotkeyWindowOpen));
+
+ /// 热键判定源是否已启用(决定是否显示红/绿点)。
+ public static readonly StyledProperty IsHotkeyIndicatorVisibleProperty =
+ AvaloniaProperty.Register(nameof(IsHotkeyIndicatorVisible));
+
private readonly DispatcherTimer _updateTimer;
- private readonly AudioPeakMeter? _audioPeakMeter;
- private readonly DecibelMonitorSettingsService? _settingsService;
+ private readonly AlertRuntimeService? _runtimeService;
private volatile bool _isActive;
private volatile bool _isUpdating;
@@ -56,7 +61,7 @@ public bool IsAlertActive
}
///
- /// 超过阈值时显示的提示文字(取通知设置中的自定义文字)。
+ /// 超过阈值时显示的提示文字(取通知提供方设置中的自定义文字)。
///
public string AlertDisplayText
{
@@ -64,13 +69,28 @@ public string AlertDisplayText
private set => SetValue(AlertDisplayTextProperty, value);
}
+ /// 热键判定源所处的筛选窗口是否开启(红/绿点)。
+ public bool IsHotkeyWindowOpen
+ {
+ get => GetValue(IsHotkeyWindowOpenProperty);
+ private set => SetValue(IsHotkeyWindowOpenProperty, value);
+ }
+
+ /// 热键判定源是否已启用(决定是否显示红/绿点)。
+ public bool IsHotkeyIndicatorVisible
+ {
+ get => GetValue(IsHotkeyIndicatorVisibleProperty);
+ private set => SetValue(IsHotkeyIndicatorVisibleProperty, value);
+ }
+
public DecibelComponent()
{
InitializeComponent();
- // 从宿主 DI 容器获取共享的峰值采样服务(单例,含并发保护与缓存)与全局设置
- _audioPeakMeter = IAppHost.Host?.Services.GetService(typeof(AudioPeakMeter)) as AudioPeakMeter;
- _settingsService = IAppHost.Host?.Services.GetService(typeof(DecibelMonitorSettingsService)) as DecibelMonitorSettingsService;
+ // 从宿主 DI 容器获取提醒运行时服务(单例):采样与判定都在该服务的节拍中完成,
+ // 组件只读取其状态用于显示。动态解析而非构造期缓存:插件加载早期组件可能先于
+ // 通知提供方(IHostedService)创建。
+ _runtimeService = IAppHost.Host?.Services.GetService(typeof(AlertRuntimeService)) as AlertRuntimeService;
_updateTimer = new DispatcherTimer
{
@@ -106,10 +126,9 @@ protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e
_updateTimer.Stop();
}
- // 异步 Tick,内部会异步采样(不会阻塞 UI)
- private async void UpdateTimer_Tick(object? sender, EventArgs e)
+ private void UpdateTimer_Tick(object? sender, EventArgs e)
{
- // 节流:上一次采样尚未结束时跳过本次 Tick,避免耗时采样导致异步任务堆积
+ // 节流守卫:附着/分离的边界情形下可能重入
if (_isUpdating) return;
_isUpdating = true;
try
@@ -121,50 +140,34 @@ private async void UpdateTimer_Tick(object? sender, EventArgs e)
_updateTimer.Interval = TimeSpan.FromMilliseconds(intervalMs);
}
- if (_audioPeakMeter is null)
+ if (_runtimeService is null)
{
CurrentDecibelValue = "无采样服务";
+ IsHotkeyIndicatorVisible = false;
return;
}
- // 优先走实时计量;不可用时自动使用短时录音回退采样(带缓存与并发保护)
- float linear = await _audioPeakMeter.GetDefaultDevicePeakLinearAsync().ConfigureAwait(false);
-
- // 放大倍数为全局校准结果(同一麦克风所有组件显示一致)
- double magnification = _settingsService?.Settings.Magnification ?? 1.0;
- double mapped = DecibelCalculator.LinearToDisplayDb(linear, magnification);
-
- // 绑定属性变更与提醒评估(含通知触发)都放回 UI 线程执行。
- // 提醒提供方实例采用"动态读取"而非构造时缓存:插件加载早期组件可能先于
- // 通知提供方(IHostedService)创建,缓存为 null 将导致提醒永久失效。
- Dispatcher.UIThread.Post(() =>
- {
- if (!_isActive) return;
+ // "组件内提示"开关只影响组件上是否显示提示文字,不影响提醒链路
+ bool showAlertText = Settings?.ShowAlertTextOnComponent ?? true;
+ bool showPrefix = Settings?.ShowDecibelPrefix ?? true;
- var state = DecibelNotificationProvider.Instance?.Evaluate(mapped)
- ?? new DecibelAlertState(false, "请保持安静");
+ CurrentDecibelValue = _runtimeService.HasSample
+ ? (showPrefix ? $"分贝: {_runtimeService.CurrentDecibel:F1}" : $"{_runtimeService.CurrentDecibel:F1}")
+ : "N/A";
+ IsAlertActive = showAlertText && _runtimeService.IsTriggerActive;
+ AlertDisplayText = _runtimeService.AlertText;
- // "组件内提示"开关只影响组件上是否显示提示文字,不影响 Evaluate 触发的系统通知
- bool showAlertText = Settings?.ShowAlertTextOnComponent ?? true;
- bool showPrefix = Settings?.ShowDecibelPrefix ?? true;
-
- CurrentDecibelValue = showPrefix ? $"分贝: {mapped:F1}" : $"{mapped:F1}";
- IsAlertActive = showAlertText && state.IsActive;
- AlertDisplayText = state.Text;
- });
+ // 热键筛选窗口红/绿点:仅热键判定源启用时显示
+ IsHotkeyIndicatorVisible = _runtimeService.IsHotkeySourceEnabled;
+ IsHotkeyWindowOpen = _runtimeService.IsHotkeyWindowOpen;
}
catch (Exception)
{
- Dispatcher.UIThread.Post(() =>
- {
- if (!_isActive) return;
- CurrentDecibelValue = "读取失败";
- });
+ CurrentDecibelValue = "读取失败";
}
finally
{
_isUpdating = false;
}
}
-}
-
+}
\ No newline at end of file
diff --git a/Controls/NotificationProviders/DecibelNotificationProviderSettingsControl.axaml b/Controls/NotificationProviders/DecibelNotificationProviderSettingsControl.axaml
index e78ca74..c12af16 100644
--- a/Controls/NotificationProviders/DecibelNotificationProviderSettingsControl.axaml
+++ b/Controls/NotificationProviders/DecibelNotificationProviderSettingsControl.axaml
@@ -11,39 +11,17 @@
mc:Ignorable="d"
d:DesignHeight="450" d:DesignWidth="800">
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/Models/ComponentSettings/DecibelComponentSettings.cs b/Models/ComponentSettings/DecibelComponentSettings.cs
index e5de823..db33e2a 100644
--- a/Models/ComponentSettings/DecibelComponentSettings.cs
+++ b/Models/ComponentSettings/DecibelComponentSettings.cs
@@ -1,11 +1,12 @@
-using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.ComponentModel;
namespace Decibel_Monitor.Models.ComponentSettings;
///
/// 分贝组件的设置。每个组件实例独立持久化,仅保留与"小组件显示侧"相关的显示偏好。
-/// 测量/校准(参考 dB、放大倍数)为全局设置,位于插件设置页(DecibelMonitorGlobalSettings);
-/// 提醒相关设置在通知提供方设置(DecibelNotificationProviderSettings)。
+/// 测量/校准(参考 dB、放大倍数)与提醒判定(各判定源的启用/阈值/冷却等)为全局设置,
+/// 位于插件设置页(DecibelMonitorGlobalSettings);提醒正文在通知提供方设置
+/// (DecibelNotificationProviderSettings)。
///
public partial class DecibelComponentSettings : ObservableObject
{
diff --git a/Models/DecibelMonitorGlobalSettings.cs b/Models/DecibelMonitorGlobalSettings.cs
index fe5ce58..25a7053 100644
--- a/Models/DecibelMonitorGlobalSettings.cs
+++ b/Models/DecibelMonitorGlobalSettings.cs
@@ -1,3 +1,4 @@
+using System.Collections.Generic;
using CommunityToolkit.Mvvm.ComponentModel;
namespace Decibel_Monitor.Models;
@@ -5,6 +6,11 @@ namespace Decibel_Monitor.Models;
///
/// Decibel_Monitor 插件的全局设置(存放于插件配置目录,与组件/通知设置相互独立)。
///
+///
+/// 提醒相关设置按判定源分组:每种"判断方式"一个判定源,各自带启用开关、
+/// 冷却时间与专属参数;判定源的优先级顺序归仲裁模块(AlertDecisionCoordinator)。
+/// 不含通知/语音开关——强调通知与语音由 ClassIsland 通知提供方设置统一控制。
+///
public partial class DecibelMonitorGlobalSettings : ObservableObject
{
///
@@ -34,4 +40,64 @@ public partial class DecibelMonitorGlobalSettings : ObservableObject
/// 线性峰值信号检测阈值:超过该值视为"检测到有效信号"(可在采样异常时调低)。
///
[ObservableProperty] private double _signalThreshold = 0.0001;
+
+ // ── 平均音量判定(各判定源共用的输入平滑参数)────────────────────────
+
+ ///
+ /// 平均音量判定的滑动窗口时长(秒),默认 3 秒。
+ /// 判定源比较的是窗口内的平均分贝而非瞬时值,避免单次尖峰误触发;窗口越长越平滑、响应越慢。
+ ///
+ [ObservableProperty] private double _averageWindowSeconds = 3.0;
+
+ // ── 仲裁模块:判定源优先级顺序 ──────────────────────────────────────
+
+ ///
+ /// 判定源优先级顺序(按判定源 Id 排列,靠前者优先)。
+ /// 同一次判定中多个判定源同时触发时,由靠前的判定源被记为触发者。默认自动提醒优先。
+ ///
+ [ObservableProperty] private List _sourcePriorityOrder = new()
+ {
+ Alerting.AutoThresholdDecisionSource.SourceId,
+ Alerting.HotkeyConfirmDecisionSource.SourceId,
+ };
+
+ // ── 判定源①:到阈值自动提醒 ─────────────────────────────────────────
+
+ /// 是否启用"自动提醒"判定源(超过阈值立即提醒)。
+ [ObservableProperty] private bool _autoSourceEnabled;
+
+ /// "自动提醒"判定源的阈值(显示刻度 0..150),默认 120。
+ [ObservableProperty] private double _autoSourceThreshold = 120.0;
+
+ /// "自动提醒"判定源的冷却时间(秒),默认 600 秒(10 分钟)。
+ [ObservableProperty] private int _autoSourceCooldownSeconds = 600;
+
+ // ── 判定源②:到阈值开筛选窗口,窗口内命中热键才提醒 ─────────────────
+
+ /// 是否启用"热键确认"判定源(需要 KeyboardCapture 插件提供按键事件)。
+ [ObservableProperty] private bool _hotkeySourceEnabled;
+
+ /// "热键确认"判定源的阈值(显示刻度 0..150),默认 120。
+ [ObservableProperty] private double _hotkeySourceThreshold = 120.0;
+
+ /// "热键确认"判定源的冷却时间(秒),默认 600 秒(10 分钟)。
+ [ObservableProperty] private int _hotkeySourceCooldownSeconds = 600;
+
+ /// "热键确认"判定源筛选窗口的开启时长(秒),默认 10 秒。
+ [ObservableProperty] private int _hotkeyWindowSeconds = 10;
+
+ /// 筛选窗口内要求命中的键名(与 KeyboardCapture 的键名一致,如 F5、Space、A)。
+ [ObservableProperty] private string _hotkeyKey = "F5";
+
+ /// 筛选窗口内要求命中的热键是否包含 Ctrl 修饰键。
+ [ObservableProperty] private bool _hotkeyCtrl;
+
+ /// 筛选窗口内要求命中的热键是否包含 Alt 修饰键。
+ [ObservableProperty] private bool _hotkeyAlt;
+
+ /// 筛选窗口内要求命中的热键是否包含 Shift 修饰键。
+ [ObservableProperty] private bool _hotkeyShift;
+
+ /// 筛选窗口内要求命中的热键是否包含 Win(Meta)修饰键。
+ [ObservableProperty] private bool _hotkeyMeta;
}
diff --git a/Models/NotificationProviderSettings/DecibelNotificationProviderSettings.cs b/Models/NotificationProviderSettings/DecibelNotificationProviderSettings.cs
index 691c2e8..712d001 100644
--- a/Models/NotificationProviderSettings/DecibelNotificationProviderSettings.cs
+++ b/Models/NotificationProviderSettings/DecibelNotificationProviderSettings.cs
@@ -4,27 +4,17 @@ namespace Decibel_Monitor.Models.NotificationProviderSettings;
///
/// 分贝提醒(通知提供方)的设置。由 ClassIsland 宿主负责持久化。
-/// 对应"强调通知侧"的设置项:提醒开关、阈值、正文与冷却时间。
+/// 对应"强调通知侧"的设置项:提醒正文。
///
+///
+/// 阈值、冷却与启用开关由插件设置页中的"判定源"负责(见 DecibelMonitorGlobalSettings):
+/// 判定源决定"何时提醒",本通知提供方只负责"发什么内容",不重复做阈值判定,
+/// 避免出现"判定源已裁定提醒、提供方却因自身阈值/冷却不发通知"的双重门槛。
+///
public partial class DecibelNotificationProviderSettings : ObservableObject
{
- ///
- /// 是否启用分贝阈值提醒。
- ///
- [ObservableProperty] private bool _isAlertEnabled = false;
-
- ///
- /// 提醒阈值(显示刻度 0..150),超过该值触发提醒。
- ///
- [ObservableProperty] private double _alertThreshold = 120.0;
-
///
/// 提醒正文(通知横幅显示的文字)。
///
[ObservableProperty] private string _alertText = "请保持安静";
-
- ///
- /// 触发提醒后的冷却时间(分钟),避免频繁提醒,默认 10 分钟。
- ///
- [ObservableProperty] private int _alertCooldownMinutes = 10;
}
diff --git a/Tests/Decibel_Monitor.Tests/AlertDecisionCoordinatorTests.cs b/Tests/Decibel_Monitor.Tests/AlertDecisionCoordinatorTests.cs
index e477110..b9ec466 100644
--- a/Tests/Decibel_Monitor.Tests/AlertDecisionCoordinatorTests.cs
+++ b/Tests/Decibel_Monitor.Tests/AlertDecisionCoordinatorTests.cs
@@ -148,4 +148,43 @@ public void Sources_Should_KeepGivenOrder_WhenNoPriorityConfigured()
Assert.Equal(new[] { "a", "b" }, new[] { coordinator.Sources[0].Id, coordinator.Sources[1].Id });
}
+
+ [Fact]
+ public void ApplyPriorityOrder_Should_ReorderSources_AndTakeEffectOnNextDecide()
+ {
+ var low = new FakeSource("low", shouldAlert: true);
+ var high = new FakeSource("high", shouldAlert: true);
+ var coordinator = new AlertDecisionCoordinator(new[] { low, high }, new[] { "high", "low" });
+
+ coordinator.ApplyPriorityOrder(new[] { "low", "high" });
+
+ Assert.Equal(new[] { "low", "high" }, new[] { coordinator.Sources[0].Id, coordinator.Sources[1].Id });
+ Assert.Equal("low", coordinator.Decide(Context, Now).TriggerSourceId);
+ }
+
+ [Fact]
+ public void ApplyPriorityOrder_Should_IgnoreEmptyOrder()
+ {
+ var a = new FakeSource("a", shouldAlert: false);
+ var b = new FakeSource("b", shouldAlert: false);
+ var coordinator = new AlertDecisionCoordinator(new[] { a, b }, new[] { "b", "a" });
+
+ coordinator.ApplyPriorityOrder(Array.Empty());
+ coordinator.ApplyPriorityOrder(null);
+
+ Assert.Equal(new[] { "b", "a" }, new[] { coordinator.Sources[0].Id, coordinator.Sources[1].Id });
+ }
+
+ [Fact]
+ public void ApplyPriorityOrder_Should_AppendUnlistedSources()
+ {
+ var a = new FakeSource("a", shouldAlert: false);
+ var b = new FakeSource("b", shouldAlert: false);
+ var c = new FakeSource("c", shouldAlert: false);
+ var coordinator = new AlertDecisionCoordinator(new[] { a, b, c });
+
+ coordinator.ApplyPriorityOrder(new[] { "b" });
+
+ Assert.Equal(new[] { "b", "a", "c" }, new[] { coordinator.Sources[0].Id, coordinator.Sources[1].Id, coordinator.Sources[2].Id });
+ }
}
diff --git a/Tests/Decibel_Monitor.Tests/DecibelAlertEvaluatorTests.cs b/Tests/Decibel_Monitor.Tests/DecibelAlertEvaluatorTests.cs
deleted file mode 100644
index 81f4896..0000000
--- a/Tests/Decibel_Monitor.Tests/DecibelAlertEvaluatorTests.cs
+++ /dev/null
@@ -1,108 +0,0 @@
-using System;
-using Decibel_Monitor.Alerting;
-
-namespace Decibel_Monitor.Tests;
-
-public class DecibelAlertEvaluatorTests
-{
- private const double Threshold = 100.0;
-
- // ---- 激活判定 ----
-
- [Fact]
- public void Evaluate_Should_NotActivate_WhenDisabled()
- {
- var decision = DecibelAlertEvaluator.Evaluate(120.0, Threshold, false, DateTime.UtcNow, DateTime.MinValue, 5);
-
- Assert.False(decision.IsActive);
- Assert.False(decision.ShouldNotify);
- Assert.Equal(DateTime.MinValue, decision.NextAlertTimeUtc);
- }
-
- [Fact]
- public void Evaluate_Should_NotActivate_WhenBelowThreshold()
- {
- var now = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
- var decision = DecibelAlertEvaluator.Evaluate(99.9, Threshold, true, now, DateTime.MinValue, 5);
-
- Assert.False(decision.IsActive);
- Assert.False(decision.ShouldNotify);
- }
-
- [Fact]
- public void Evaluate_Should_NotActivate_WhenEqualToThreshold()
- {
- // 边界:严格大于才算超阈值
- var now = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
- var decision = DecibelAlertEvaluator.Evaluate(Threshold, Threshold, true, now, DateTime.MinValue, 5);
-
- Assert.False(decision.IsActive);
- Assert.False(decision.ShouldNotify);
- }
-
- // ---- 冷却控制 ----
-
- [Fact]
- public void Evaluate_Should_Notify_OnFirstOverThreshold()
- {
- var now = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
- var decision = DecibelAlertEvaluator.Evaluate(120.0, Threshold, true, now, DateTime.MinValue, 5);
-
- Assert.True(decision.IsActive);
- Assert.True(decision.ShouldNotify);
- }
-
- [Fact]
- public void Evaluate_Should_NotNotify_WhileCoolingDown()
- {
- var now = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
- var next = now.AddMinutes(5);
- var decision = DecibelAlertEvaluator.Evaluate(120.0, Threshold, true, now, next, 5);
-
- Assert.True(decision.IsActive); // 仍处于超阈值状态(组件继续显示提示文字)
- Assert.False(decision.ShouldNotify); // 但冷却未过,不重复提醒
- Assert.Equal(next, decision.NextAlertTimeUtc);
- }
-
- [Fact]
- public void Evaluate_Should_Notify_WhenCooldownJustElapsed()
- {
- var next = new DateTime(2026, 1, 1, 0, 5, 0, DateTimeKind.Utc);
- var decision = DecibelAlertEvaluator.Evaluate(120.0, Threshold, true, next, next, 5);
-
- Assert.True(decision.IsActive);
- Assert.True(decision.ShouldNotify);
- }
-
- [Fact]
- public void Evaluate_Should_SetNextAlertTime_ByCooldown()
- {
- var now = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
- var decision = DecibelAlertEvaluator.Evaluate(120.0, Threshold, true, now, DateTime.MinValue, 3);
-
- Assert.Equal(now.AddMinutes(3), decision.NextAlertTimeUtc);
- }
-
- [Fact]
- public void Evaluate_Should_UseMinimumOneMinuteCooldown()
- {
- var now = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
-
- var zero = DecibelAlertEvaluator.Evaluate(120.0, Threshold, true, now, DateTime.MinValue, 0);
- var negative = DecibelAlertEvaluator.Evaluate(120.0, Threshold, true, now, DateTime.MinValue, -10);
-
- Assert.Equal(now.AddMinutes(1), zero.NextAlertTimeUtc);
- Assert.Equal(now.AddMinutes(1), negative.NextAlertTimeUtc);
- }
-
- [Fact]
- public void Evaluate_Should_KeepNextAlertTime_WhenNotActive()
- {
- var now = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
- var next = now.AddMinutes(5);
- var decision = DecibelAlertEvaluator.Evaluate(50.0, Threshold, true, now, next, 5);
-
- Assert.False(decision.IsActive);
- Assert.Equal(next, decision.NextAlertTimeUtc);
- }
-}
diff --git a/Tests/Decibel_Monitor.Tests/Decibel_Monitor.Tests.csproj b/Tests/Decibel_Monitor.Tests/Decibel_Monitor.Tests.csproj
index e63a889..8e534d6 100644
--- a/Tests/Decibel_Monitor.Tests/Decibel_Monitor.Tests.csproj
+++ b/Tests/Decibel_Monitor.Tests/Decibel_Monitor.Tests.csproj
@@ -21,7 +21,6 @@
-
diff --git a/Views/SettingsPages/DecibelMonitorSettingsPage.axaml b/Views/SettingsPages/DecibelMonitorSettingsPage.axaml
index b9f15f6..97a7508 100644
--- a/Views/SettingsPages/DecibelMonitorSettingsPage.axaml
+++ b/Views/SettingsPages/DecibelMonitorSettingsPage.axaml
@@ -4,10 +4,11 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:ci="http://classisland.tech/schemas/xaml/core"
+ xmlns:fa="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia"
mc:Ignorable="d"
d:DesignHeight="600" d:DesignWidth="800">
-
+
@@ -55,6 +56,120 @@
Text="单次采样时长:仅在点击“校准”等显式采样时使用,时长越长越容易捕获弱信号。"
TextWrapping="Wrap"/>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -67,7 +182,7 @@
diff --git a/Views/SettingsPages/DecibelMonitorSettingsPage.axaml.cs b/Views/SettingsPages/DecibelMonitorSettingsPage.axaml.cs
index 19f1408..4ec5d7d 100644
--- a/Views/SettingsPages/DecibelMonitorSettingsPage.axaml.cs
+++ b/Views/SettingsPages/DecibelMonitorSettingsPage.axaml.cs
@@ -1,20 +1,29 @@
using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
using System.Linq;
using Avalonia.Interactivity;
using ClassIsland.Core.Abstractions.Controls;
using ClassIsland.Core.Attributes;
using ClassIsland.Core.Controls;
using ClassIsland.Core.Enums.SettingsWindow;
+using ClassIsland.Shared;
using Decibel_Monitor.Alerting;
using Decibel_Monitor.Measurement;
using Decibel_Monitor.Models;
using Decibel_Monitor.Services;
+using KeyboardCapture.Abstractions;
namespace Decibel_Monitor.Views.SettingsPages;
+/// 判定源优先级列表中的一项(视图模型,仅供设置页列表展示与排序)。
+/// 判定源标识(写回设置用)。
+/// 判定源显示名。
+public sealed record AlertSourcePriorityItem(string Id, string DisplayName);
+
///
/// Decibel_Monitor 插件设置页(与其他设置项同层级)。
-/// 承载插件全局配置与设备诊断。
+/// 承载插件全局配置、提醒判定源设置与设备诊断。
///
[SettingsPageInfo(
"Decibel_Monitor.PluginSettings",
@@ -25,29 +34,97 @@ namespace Decibel_Monitor.Views.SettingsPages;
public partial class DecibelMonitorSettingsPage : SettingsPageBase
{
private readonly AudioPeakMeter? _audioPeakMeter;
+ private readonly AlertDecisionCoordinator? _coordinator;
///
- /// 插件全局设置(用于绑定采样偏好)。
+ /// 插件全局设置(用于绑定采样偏好与各判定源设置)。
///
public DecibelMonitorGlobalSettings GlobalSettings { get; }
+ ///
+ /// 判定源优先级列表(按设置中的顺序排列,可经“上移/下移”调整)。
+ ///
+ public ObservableCollection PriorityItems { get; } = new();
+
+ ///
+ /// KeyboardCapture 插件是否可用(不可用时禁用“热键确认”判定源的全部设置项)。
+ ///
+ public bool KeyboardCaptureAvailable { get; }
+
///
/// 供 XAML 资源加载器 / 设计器使用的默认构造。
///
- public DecibelMonitorSettingsPage() : this(new DecibelMonitorSettingsService(null), null)
+ public DecibelMonitorSettingsPage() : this(new DecibelMonitorSettingsService(null), null, null)
{
}
- public DecibelMonitorSettingsPage(DecibelMonitorSettingsService settingsService, AudioPeakMeter? audioPeakMeter = null)
+ public DecibelMonitorSettingsPage(
+ DecibelMonitorSettingsService settingsService,
+ AudioPeakMeter? audioPeakMeter = null,
+ AlertDecisionCoordinator? coordinator = null)
{
InitializeComponent();
GlobalSettings = settingsService.Settings;
_audioPeakMeter = audioPeakMeter;
+ _coordinator = coordinator;
+ KeyboardCaptureAvailable = IAppHost.TryGetService() is not null;
+
DataContext = this;
+ RebuildPriorityItems();
RefreshDefaultDeviceText();
RefreshMagnificationText();
}
+ ///
+ /// 按设置中的优先级顺序重建列表;设置中未列出的判定源按仲裁模块的顺序追加到末尾。
+ ///
+ private void RebuildPriorityItems()
+ {
+ PriorityItems.Clear();
+ if (_coordinator is null) return;
+
+ var sources = _coordinator.Sources;
+ var ordered = new List();
+ foreach (var id in GlobalSettings.SourcePriorityOrder ?? new List())
+ {
+ var matched = sources.FirstOrDefault(s => s.Id == id);
+ if (matched is not null && ordered.All(o => o.Id != matched.Id)) ordered.Add(matched);
+ }
+
+ foreach (var source in sources)
+ {
+ if (ordered.All(o => o.Id != source.Id)) ordered.Add(source);
+ }
+
+ foreach (var source in ordered)
+ {
+ PriorityItems.Add(new AlertSourcePriorityItem(source.Id, source.DisplayName));
+ }
+ }
+
+ /// 把列表当前顺序写回设置(运行时编排服务会在下一拍应用到仲裁模块)。
+ private void SavePriorityOrder()
+ {
+ GlobalSettings.SourcePriorityOrder = PriorityItems.Select(i => i.Id).ToList();
+ }
+
+ /// "上移"按钮:把选中判定源的优先级提高一位。
+ public void On_MovePriorityUp(object? sender, RoutedEventArgs e) => MovePriority(-1);
+
+ /// "下移"按钮:把选中判定源的优先级降低一位。
+ public void On_MovePriorityDown(object? sender, RoutedEventArgs e) => MovePriority(1);
+
+ private void MovePriority(int offset)
+ {
+ var index = ListPriorityOrder.SelectedIndex;
+ var target = index + offset;
+ if (index < 0 || target < 0 || target >= PriorityItems.Count) return;
+
+ PriorityItems.Move(index, target);
+ ListPriorityOrder.SelectedIndex = target;
+ SavePriorityOrder();
+ }
+
///
/// 刷新默认捕获设备描述文本。
///
@@ -164,4 +241,3 @@ public void On_ShowDevices(object? sender, RoutedEventArgs e)
}
}
}
-
From 582e09b39713f8371a87b35b691a84fbcf1adc5f Mon Sep 17 00:00:00 2001
From: Starsky16 <163619709+Starsky16@users.noreply.github.com>
Date: Mon, 21 Sep 2026 00:34:27 +0800
Subject: [PATCH 13/14] =?UTF-8?q?feat:=20=E5=88=86=E8=B4=9D=E7=BB=84?=
=?UTF-8?q?=E4=BB=B6=E6=94=B9=E7=94=A8=E5=8D=95=E4=B8=80=E6=8F=90=E9=86=92?=
=?UTF-8?q?=E7=8A=B6=E6=80=81=E7=82=B9=E5=B9=B6=E8=A1=A5=E5=85=A8=E5=88=A4?=
=?UTF-8?q?=E5=AE=9A=E6=BA=90=E8=AE=BE=E7=BD=AE=E9=A1=B9?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 组件不再显示提醒文字,统一为单一状态点:绿=正常,红=提醒触发中(含筛选窗口开启与冷却期)
- 判定源接口新增 Description 与 IsCoolingDown,供设置页展示与状态点判定
- 设置页补全判定源分组名(判定源①/②)、阈值与冷却/窗口时长滑块的数值显示、优先级列表说明
- 组件设置项 ShowAlertTextOnComponent 改为 ShowStatusIndicator(组件上不再有提醒文字)
---
Alarm/AlertRuntimeService.cs | 26 ++++----
Alerting/AutoThresholdDecisionSource.cs | 9 ++-
Alerting/HotkeyConfirmDecisionSource.cs | 11 +++-
Alerting/IAlertDecisionSource.cs | 12 +++-
.../DecibelComponentSettingsControl.axaml | 6 +-
Controls/Components/DecibelComponent.axaml | 16 ++---
Controls/Components/DecibelComponent.axaml.cs | 64 ++++++-------------
.../DecibelComponentSettings.cs | 7 +-
.../AlertDecisionCoordinatorTests.cs | 6 ++
.../AutoThresholdDecisionSourceTests.cs | 35 ++++++++++
.../HotkeyConfirmDecisionSourceTests.cs | 28 ++++++++
.../DecibelMonitorSettingsPage.axaml | 55 ++++++++++------
.../DecibelMonitorSettingsPage.axaml.cs | 5 +-
13 files changed, 181 insertions(+), 99 deletions(-)
diff --git a/Alarm/AlertRuntimeService.cs b/Alarm/AlertRuntimeService.cs
index dfbe12a..4125de8 100644
--- a/Alarm/AlertRuntimeService.cs
+++ b/Alarm/AlertRuntimeService.cs
@@ -54,17 +54,14 @@ public sealed class AlertRuntimeService : IHostedService, IDisposable
/// 是否已取得过至少一次采样(未取得时组件显示占位文本)。
public bool HasSample { get; private set; }
- /// 当前是否有任一判定源处于"超过阈值"状态(供组件显示提示文字)。
- public bool IsTriggerActive { get; private set; }
-
- /// 当前热键筛选窗口是否开启(供组件显示红/绿点)。
- public bool IsHotkeyWindowOpen => _hotkeySource.IsWindowOpen;
-
- /// 热键判定源是否已启用(决定组件是否显示红/绿点)。
- public bool IsHotkeySourceEnabled => _hotkeySource.IsEnabled;
+ ///
+ /// 当前是否应点亮提醒状态点(红):任一判定源处于超阈值、筛选窗口开启或冷却期。
+ /// 绿点表示上述条件均不成立。
+ ///
+ public bool IsAlertStateActive { get; private set; }
- /// 提醒正文(取自通知提供方设置,供组件显示提示文字)。
- public string AlertText => DecibelNotificationProvider.Instance?.Settings.AlertText ?? "请保持安静";
+ /// 是否至少启用了一个判定源(决定组件是否显示提醒状态点)。
+ public bool IsAnySourceEnabled => _autoSource.IsEnabled || _hotkeySource.IsEnabled;
/// 共享的麦克风峰值采样服务。
/// 插件全局设置服务(可空;缺省时使用判定源内置默认参数)。
@@ -139,8 +136,13 @@ private async void Timer_Tick(object? sender, EventArgs e)
var decision = _coordinator.Decide(new AlertContext(mapped, average), DateTime.UtcNow);
- // 任一判定源处于超阈值状态即视为"提醒中"(供组件显示提示文字)
- IsTriggerActive = _autoSource.IsTriggerActive || _hotkeySource.IsTriggerActive;
+ // 提醒状态点:超阈值、筛选窗口开启、以及提醒后的冷却期都点亮(红点),其余为绿点
+ var nowUtc = DateTime.UtcNow;
+ IsAlertStateActive = _autoSource.IsTriggerActive
+ || _hotkeySource.IsTriggerActive
+ || _hotkeySource.IsWindowOpen
+ || _autoSource.IsCoolingDown(nowUtc)
+ || _hotkeySource.IsCoolingDown(nowUtc);
if (!decision.ShouldAlert) return;
diff --git a/Alerting/AutoThresholdDecisionSource.cs b/Alerting/AutoThresholdDecisionSource.cs
index 428424e..1988eae 100644
--- a/Alerting/AutoThresholdDecisionSource.cs
+++ b/Alerting/AutoThresholdDecisionSource.cs
@@ -27,6 +27,9 @@ public AutoThresholdDecisionSource(double threshold = 120.0, TimeSpan? cooldown
///
public string DisplayName => "自动提醒";
+ ///
+ public string Description => "平均分贝超过阈值时立即提醒。";
+
///
public bool IsEnabled { get; set; }
@@ -39,9 +42,13 @@ public AutoThresholdDecisionSource(double threshold = 120.0, TimeSpan? cooldown
/// 下一次允许提醒的时间(UTC); 表示从未提醒过。
public DateTime NextAlertTimeUtc => _nextAlertTimeUtc;
- /// 当前是否处于"超过阈值"状态(供组件显示提示文字)。
+ /// 当前是否处于"超过阈值"状态(供组件显示提醒状态)。
public bool IsTriggerActive { get; private set; }
+ ///
+ public bool IsCoolingDown(DateTime nowUtc) =>
+ IsEnabled && _nextAlertTimeUtc != DateTime.MinValue && nowUtc < _nextAlertTimeUtc;
+
///
public AlertDecision Decide(AlertContext context, DateTime nowUtc)
{
diff --git a/Alerting/HotkeyConfirmDecisionSource.cs b/Alerting/HotkeyConfirmDecisionSource.cs
index d831db9..17af910 100644
--- a/Alerting/HotkeyConfirmDecisionSource.cs
+++ b/Alerting/HotkeyConfirmDecisionSource.cs
@@ -46,6 +46,9 @@ public HotkeyConfirmDecisionSource(
///
public string DisplayName => "热键确认";
+ ///
+ public string Description => "超过阈值时开启筛选窗口,窗口内按下热键才提醒。";
+
///
public bool IsEnabled { get; set; }
@@ -61,15 +64,19 @@ public HotkeyConfirmDecisionSource(
///
public TimeSpan Cooldown { get; set; }
- /// 当前筛选窗口是否开启(供组件红/绿点显示)。
+ /// 当前筛选窗口是否开启(供组件显示提醒状态)。
public bool IsWindowOpen { get; private set; }
- /// 当前是否处于"超过阈值"状态(供组件显示提示文字)。
+ /// 当前是否处于"超过阈值"状态(供组件显示提醒状态)。
public bool IsTriggerActive { get; private set; }
/// 下一次允许提醒的时间(UTC)。
public DateTime NextAlertTimeUtc => _nextAlertTimeUtc;
+ ///
+ public bool IsCoolingDown(DateTime nowUtc) =>
+ IsEnabled && _nextAlertTimeUtc != DateTime.MinValue && nowUtc < _nextAlertTimeUtc;
+
///
/// 外部注入一次按键事件(后台钩子或测试直接调用)。
/// 仅当筛选窗口开启且命中配置热键时置为"已确认"。
diff --git a/Alerting/IAlertDecisionSource.cs b/Alerting/IAlertDecisionSource.cs
index a44aafb..4c60d18 100644
--- a/Alerting/IAlertDecisionSource.cs
+++ b/Alerting/IAlertDecisionSource.cs
@@ -13,7 +13,7 @@ namespace Decibel_Monitor.Alerting;
/// 单个判定源的判定结果。
///
/// 本次判定是否需要发出提醒。
-/// 当前是否处于"触发条件成立"状态(供组件显示提示文字)。
+/// 当前是否处于"触发条件成立"状态(供组件显示提醒状态)。
public readonly record struct AlertDecision(bool ShouldAlert, bool IsTriggerActive);
///
@@ -36,12 +36,22 @@ public interface IAlertDecisionSource
/// 判定源显示名(设置页展示)。
string DisplayName { get; }
+ /// 判定源简要说明(设置页优先级列表展示)。
+ string Description { get; }
+
/// 是否启用该判定源。
bool IsEnabled { get; }
/// 该判定源自身的提醒冷却时间(两次提醒之间的最小间隔)。
TimeSpan Cooldown { get; }
+ ///
+ /// 当前是否处于冷却期(上次提醒后尚未超过 ,不具备再次提醒的资格)。
+ /// 未启用的判定源恒为 false。
+ ///
+ /// 当前时间(UTC)。
+ bool IsCoolingDown(DateTime nowUtc);
+
///
/// 依据当前上下文判定是否提醒。未启用时应返回"不提醒"并清空内部状态。
///
diff --git a/Controls/ComponentSettings/DecibelComponentSettingsControl.axaml b/Controls/ComponentSettings/DecibelComponentSettingsControl.axaml
index 90c0456..4bde202 100644
--- a/Controls/ComponentSettings/DecibelComponentSettingsControl.axaml
+++ b/Controls/ComponentSettings/DecibelComponentSettingsControl.axaml
@@ -18,8 +18,8 @@
-
-
+
+
@@ -41,7 +41,7 @@
TextWrapping="Wrap"/>
-
-
-
-
+
+ IsVisible="{Binding !IsIndicatorAlert, Mode=OneWay}"/>
+ IsVisible="{Binding IsIndicatorAlert, Mode=OneWay}"/>
diff --git a/Controls/Components/DecibelComponent.axaml.cs b/Controls/Components/DecibelComponent.axaml.cs
index 3206b1c..f95c009 100644
--- a/Controls/Components/DecibelComponent.axaml.cs
+++ b/Controls/Components/DecibelComponent.axaml.cs
@@ -21,21 +21,13 @@ public partial class DecibelComponent : ComponentBase
public static readonly StyledProperty CurrentDecibelValueProperty =
AvaloniaProperty.Register(nameof(CurrentDecibelValue), "N/A");
- /// 当前是否处于"超过阈值"提醒状态(显示提示文字)。
- public static readonly StyledProperty IsAlertActiveProperty =
- AvaloniaProperty.Register(nameof(IsAlertActive));
+ /// 是否显示提醒状态点(任一判定源已启用且组件设置未关闭时显示)。
+ public static readonly StyledProperty IsIndicatorVisibleProperty =
+ AvaloniaProperty.Register(nameof(IsIndicatorVisible));
- /// 超过阈值时显示的提示文字。
- public static readonly StyledProperty AlertDisplayTextProperty =
- AvaloniaProperty.Register(nameof(AlertDisplayText), "请保持安静");
-
- /// 热键判定源所处的筛选窗口是否开启(红点)。
- public static readonly StyledProperty IsHotkeyWindowOpenProperty =
- AvaloniaProperty.Register(nameof(IsHotkeyWindowOpen));
-
- /// 热键判定源是否已启用(决定是否显示红/绿点)。
- public static readonly StyledProperty IsHotkeyIndicatorVisibleProperty =
- AvaloniaProperty.Register(nameof(IsHotkeyIndicatorVisible));
+ /// 提醒状态点是否为红色(提醒触发中、筛选窗口开启或处于冷却期)。
+ public static readonly StyledProperty IsIndicatorAlertProperty =
+ AvaloniaProperty.Register(nameof(IsIndicatorAlert));
private readonly DispatcherTimer _updateTimer;
private readonly AlertRuntimeService? _runtimeService;
@@ -52,35 +44,21 @@ public string CurrentDecibelValue
}
///
- /// 当前是否处于"超过阈值"提醒状态(用于显示提示文字)。
+ /// 是否显示提醒状态点(组件设置关闭或没有启用任何判定源时为 false)。
///
- public bool IsAlertActive
+ public bool IsIndicatorVisible
{
- get => GetValue(IsAlertActiveProperty);
- private set => SetValue(IsAlertActiveProperty, value);
+ get => GetValue(IsIndicatorVisibleProperty);
+ private set => SetValue(IsIndicatorVisibleProperty, value);
}
///
- /// 超过阈值时显示的提示文字(取通知提供方设置中的自定义文字)。
+ /// 提醒状态点是否为红色:红=提醒触发中(含筛选窗口开启与冷却期),绿=正常。
///
- public string AlertDisplayText
- {
- get => GetValue(AlertDisplayTextProperty);
- private set => SetValue(AlertDisplayTextProperty, value);
- }
-
- /// 热键判定源所处的筛选窗口是否开启(红/绿点)。
- public bool IsHotkeyWindowOpen
- {
- get => GetValue(IsHotkeyWindowOpenProperty);
- private set => SetValue(IsHotkeyWindowOpenProperty, value);
- }
-
- /// 热键判定源是否已启用(决定是否显示红/绿点)。
- public bool IsHotkeyIndicatorVisible
+ public bool IsIndicatorAlert
{
- get => GetValue(IsHotkeyIndicatorVisibleProperty);
- private set => SetValue(IsHotkeyIndicatorVisibleProperty, value);
+ get => GetValue(IsIndicatorAlertProperty);
+ private set => SetValue(IsIndicatorAlertProperty, value);
}
public DecibelComponent()
@@ -143,23 +121,21 @@ private void UpdateTimer_Tick(object? sender, EventArgs e)
if (_runtimeService is null)
{
CurrentDecibelValue = "无采样服务";
- IsHotkeyIndicatorVisible = false;
+ IsIndicatorVisible = false;
return;
}
- // "组件内提示"开关只影响组件上是否显示提示文字,不影响提醒链路
- bool showAlertText = Settings?.ShowAlertTextOnComponent ?? true;
+ // "提醒状态点"开关只影响组件上是否显示状态点,不影响提醒链路
+ bool showIndicator = Settings?.ShowStatusIndicator ?? true;
bool showPrefix = Settings?.ShowDecibelPrefix ?? true;
CurrentDecibelValue = _runtimeService.HasSample
? (showPrefix ? $"分贝: {_runtimeService.CurrentDecibel:F1}" : $"{_runtimeService.CurrentDecibel:F1}")
: "N/A";
- IsAlertActive = showAlertText && _runtimeService.IsTriggerActive;
- AlertDisplayText = _runtimeService.AlertText;
- // 热键筛选窗口红/绿点:仅热键判定源启用时显示
- IsHotkeyIndicatorVisible = _runtimeService.IsHotkeySourceEnabled;
- IsHotkeyWindowOpen = _runtimeService.IsHotkeyWindowOpen;
+ // 单一状态点:红=提醒触发中(超阈值/筛选窗口开启/冷却期),绿=正常
+ IsIndicatorVisible = showIndicator && _runtimeService.IsAnySourceEnabled;
+ IsIndicatorAlert = _runtimeService.IsAlertStateActive;
}
catch (Exception)
{
diff --git a/Models/ComponentSettings/DecibelComponentSettings.cs b/Models/ComponentSettings/DecibelComponentSettings.cs
index db33e2a..de48abc 100644
--- a/Models/ComponentSettings/DecibelComponentSettings.cs
+++ b/Models/ComponentSettings/DecibelComponentSettings.cs
@@ -11,10 +11,11 @@ namespace Decibel_Monitor.Models.ComponentSettings;
public partial class DecibelComponentSettings : ObservableObject
{
///
- /// 是否在组件上显示"超过阈值"的提示文字(如“请保持安静”),默认 true。
- /// 关闭后超阈值仍会发送系统通知,但不在组件上显示红色提示文字。
+ /// 是否在组件上显示提醒状态点(默认 true)。
+ /// 绿点=正常;红点=提醒触发中(超过阈值、筛选窗口开启或处于冷却期)。
+ /// 关闭后仍会正常发出系统通知,只是不在组件上显示状态点。
///
- [ObservableProperty] private bool _showAlertTextOnComponent = true;
+ [ObservableProperty] private bool _showStatusIndicator = true;
///
/// 是否在数值前显示“分贝:”前缀(如“分贝: 70.0”),默认 true。
diff --git a/Tests/Decibel_Monitor.Tests/AlertDecisionCoordinatorTests.cs b/Tests/Decibel_Monitor.Tests/AlertDecisionCoordinatorTests.cs
index b9ec466..17f2fcd 100644
--- a/Tests/Decibel_Monitor.Tests/AlertDecisionCoordinatorTests.cs
+++ b/Tests/Decibel_Monitor.Tests/AlertDecisionCoordinatorTests.cs
@@ -23,14 +23,20 @@ public FakeSource(string id, bool shouldAlert, bool enabled = true)
public string DisplayName => Id;
+ public string Description => Id;
+
public bool IsEnabled { get; set; }
public bool ShouldAlert { get; set; }
+ public bool IsCoolingDownValue { get; set; }
+
public TimeSpan Cooldown => TimeSpan.Zero;
public int DecideCount { get; private set; }
+ public bool IsCoolingDown(DateTime nowUtc) => IsCoolingDownValue;
+
public AlertDecision Decide(AlertContext context, DateTime nowUtc)
{
DecideCount++;
diff --git a/Tests/Decibel_Monitor.Tests/AutoThresholdDecisionSourceTests.cs b/Tests/Decibel_Monitor.Tests/AutoThresholdDecisionSourceTests.cs
index c1e0e49..2c1795a 100644
--- a/Tests/Decibel_Monitor.Tests/AutoThresholdDecisionSourceTests.cs
+++ b/Tests/Decibel_Monitor.Tests/AutoThresholdDecisionSourceTests.cs
@@ -119,4 +119,39 @@ public void Decide_Should_NotAlert_WhenBelowThreshold()
Assert.False(decision.ShouldAlert);
Assert.False(decision.IsTriggerActive);
}
+
+ [Fact]
+ public void IsCoolingDown_Should_BeTrue_AfterAlert_UntilCooldownElapsed()
+ {
+ var source = CreateSource(TimeSpan.FromMinutes(5));
+ Assert.False(source.IsCoolingDown(Now));
+
+ source.Decide(Context(120.0), Now);
+
+ Assert.True(source.IsCoolingDown(Now));
+ Assert.True(source.IsCoolingDown(Now.AddMinutes(4)));
+ Assert.False(source.IsCoolingDown(Now.AddMinutes(5)));
+ }
+
+ [Fact]
+ public void IsCoolingDown_Should_BeFalse_WhenDisabled()
+ {
+ var source = CreateSource(TimeSpan.FromMinutes(5));
+ source.Decide(Context(120.0), Now);
+
+ source.IsEnabled = false;
+ source.Decide(Context(120.0), Now);
+
+ Assert.False(source.IsCoolingDown(Now.AddMinutes(1)));
+ }
+
+ [Fact]
+ public void IsCoolingDown_Should_BeFalse_WhenCooldownIsZero()
+ {
+ var source = CreateSource(TimeSpan.Zero);
+
+ source.Decide(Context(120.0), Now);
+
+ Assert.False(source.IsCoolingDown(Now));
+ }
}
diff --git a/Tests/Decibel_Monitor.Tests/HotkeyConfirmDecisionSourceTests.cs b/Tests/Decibel_Monitor.Tests/HotkeyConfirmDecisionSourceTests.cs
index fcc445c..e6de3e5 100644
--- a/Tests/Decibel_Monitor.Tests/HotkeyConfirmDecisionSourceTests.cs
+++ b/Tests/Decibel_Monitor.Tests/HotkeyConfirmDecisionSourceTests.cs
@@ -203,6 +203,34 @@ public void Decide_Should_ResetWindow_WhenDisabled()
Assert.False(source.IsTriggerActive);
}
+ [Fact]
+ public void IsCoolingDown_Should_BeTrue_AfterAlert_UntilCooldownElapsed()
+ {
+ var source = CreateSource(cooldown: TimeSpan.FromMinutes(5));
+ Assert.False(source.IsCoolingDown(Now));
+
+ source.Decide(Context(120.0), Now);
+ Assert.False(source.IsCoolingDown(Now));
+
+ source.HandleKeyPress(KeyName, HotkeyModifiers.None, Now);
+ source.Decide(Context(120.0), Now);
+
+ Assert.True(source.IsCoolingDown(Now.AddMinutes(1)));
+ Assert.False(source.IsCoolingDown(Now.AddMinutes(5)));
+ }
+
+ [Fact]
+ public void IsCoolingDown_Should_BeFalse_WhenWindowClosedWithoutConfirming()
+ {
+ // 窗口超时未确认不进入冷却,状态点不应因超时保持点亮
+ var source = CreateSource(cooldown: TimeSpan.FromMinutes(5));
+ source.Decide(Context(120.0), Now);
+
+ source.Decide(Context(120.0), Now.Add(Window).AddSeconds(1));
+
+ Assert.False(source.IsCoolingDown(Now.Add(Window).AddSeconds(1)));
+ }
+
// ---- 热键匹配 ----
[Fact]
diff --git a/Views/SettingsPages/DecibelMonitorSettingsPage.axaml b/Views/SettingsPages/DecibelMonitorSettingsPage.axaml
index 97a7508..737bd0e 100644
--- a/Views/SettingsPages/DecibelMonitorSettingsPage.axaml
+++ b/Views/SettingsPages/DecibelMonitorSettingsPage.axaml
@@ -73,11 +73,15 @@
-
-
+
+
+
+
@@ -93,57 +97,69 @@
TextWrapping="Wrap"/>
-
-
-
+
+
-
+
+
-
+ Description="两次提醒之间的最小间隔;冷却期内状态点为红色但不重复提醒。">
+
-
+
+
-
-
-
+
+
-
+
+
-
+ Description="两次提醒之间的最小间隔;冷却期内不再开启筛选窗口,状态点保持红色。">
+
-
+
+
-
+
-
+
+
@@ -152,7 +168,8 @@
Text="{Binding GlobalSettings.HotkeyKey, Mode=TwoWay}" />
-
+
diff --git a/Views/SettingsPages/DecibelMonitorSettingsPage.axaml.cs b/Views/SettingsPages/DecibelMonitorSettingsPage.axaml.cs
index 4ec5d7d..8480985 100644
--- a/Views/SettingsPages/DecibelMonitorSettingsPage.axaml.cs
+++ b/Views/SettingsPages/DecibelMonitorSettingsPage.axaml.cs
@@ -19,7 +19,8 @@ namespace Decibel_Monitor.Views.SettingsPages;
/// 判定源优先级列表中的一项(视图模型,仅供设置页列表展示与排序)。
/// 判定源标识(写回设置用)。
/// 判定源显示名。
-public sealed record AlertSourcePriorityItem(string Id, string DisplayName);
+/// 判定源简要说明。
+public sealed record AlertSourcePriorityItem(string Id, string DisplayName, string Description);
///
/// Decibel_Monitor 插件设置页(与其他设置项同层级)。
@@ -98,7 +99,7 @@ private void RebuildPriorityItems()
foreach (var source in ordered)
{
- PriorityItems.Add(new AlertSourcePriorityItem(source.Id, source.DisplayName));
+ PriorityItems.Add(new AlertSourcePriorityItem(source.Id, source.DisplayName, source.Description));
}
}
From 3e96eb0cb9faf4dbd2b14b3bea5088e2377463a4 Mon Sep 17 00:00:00 2001
From: Starsky16 <163619709+Starsky16@users.noreply.github.com>
Date: Mon, 21 Sep 2026 01:01:14 +0800
Subject: [PATCH 14/14] =?UTF-8?q?feat:=20=E7=83=AD=E9=94=AE=E7=AD=9B?=
=?UTF-8?q?=E9=80=89=E7=AA=97=E5=8F=A3=E5=BC=80=E5=90=AF=E6=97=B6=E7=BB=84?=
=?UTF-8?q?=E4=BB=B6=E7=8A=B6=E6=80=81=E7=82=B9=E6=94=B9=E4=B8=BA=E7=BA=A2?=
=?UTF-8?q?=E7=BB=BF=E9=97=AA=E5=8A=A8?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 窗口开启期间状态点在红绿之间交替(400ms 半周期),与静态红/绿区分开
- 窗口开启时组件更新节拍收紧到闪烁半周期,避免更新频率设置高时看不出闪动
- 组件设置说明与设置页描述同步补充闪动含义
---
Alarm/AlertRuntimeService.cs | 3 +++
.../DecibelComponentSettingsControl.axaml | 2 +-
Controls/Components/DecibelComponent.axaml | 2 +-
Controls/Components/DecibelComponent.axaml.cs | 19 ++++++++++++++-----
.../DecibelMonitorSettingsPage.axaml | 4 ++--
5 files changed, 21 insertions(+), 9 deletions(-)
diff --git a/Alarm/AlertRuntimeService.cs b/Alarm/AlertRuntimeService.cs
index 4125de8..7f11912 100644
--- a/Alarm/AlertRuntimeService.cs
+++ b/Alarm/AlertRuntimeService.cs
@@ -63,6 +63,9 @@ public sealed class AlertRuntimeService : IHostedService, IDisposable
/// 是否至少启用了一个判定源(决定组件是否显示提醒状态点)。
public bool IsAnySourceEnabled => _autoSource.IsEnabled || _hotkeySource.IsEnabled;
+ /// 热键判定源所处的筛选窗口是否开启(组件据此把状态点显示为红绿闪动)。
+ public bool IsHotkeyWindowOpen => _hotkeySource.IsWindowOpen;
+
/// 共享的麦克风峰值采样服务。
/// 插件全局设置服务(可空;缺省时使用判定源内置默认参数)。
/// 仲裁模块(自持判定源集合与优先级配置)。
diff --git a/Controls/ComponentSettings/DecibelComponentSettingsControl.axaml b/Controls/ComponentSettings/DecibelComponentSettingsControl.axaml
index 4bde202..69694dd 100644
--- a/Controls/ComponentSettings/DecibelComponentSettingsControl.axaml
+++ b/Controls/ComponentSettings/DecibelComponentSettingsControl.axaml
@@ -41,7 +41,7 @@
TextWrapping="Wrap"/>
-
+
diff --git a/Controls/Components/DecibelComponent.axaml.cs b/Controls/Components/DecibelComponent.axaml.cs
index f95c009..09d7c36 100644
--- a/Controls/Components/DecibelComponent.axaml.cs
+++ b/Controls/Components/DecibelComponent.axaml.cs
@@ -25,10 +25,13 @@ public partial class DecibelComponent : ComponentBase
public static readonly StyledProperty IsIndicatorVisibleProperty =
AvaloniaProperty.Register(nameof(IsIndicatorVisible));
- /// 提醒状态点是否为红色(提醒触发中、筛选窗口开启或处于冷却期)。
+ /// 提醒状态点是否为红色:红=提醒触发中(含冷却期),绿=正常。
public static readonly StyledProperty IsIndicatorAlertProperty =
AvaloniaProperty.Register(nameof(IsIndicatorAlert));
+ /// 筛选窗口开启时状态点的闪烁半周期(毫秒):红灯、绿灯各持续这么久。
+ private const int BlinkHalfPeriodMs = 400;
+
private readonly DispatcherTimer _updateTimer;
private readonly AlertRuntimeService? _runtimeService;
private volatile bool _isActive;
@@ -53,7 +56,8 @@ public bool IsIndicatorVisible
}
///
- /// 提醒状态点是否为红色:红=提醒触发中(含筛选窗口开启与冷却期),绿=正常。
+ /// 提醒状态点是否为红色:红=提醒触发中(含冷却期),绿=正常;
+ /// 筛选窗口开启时在红绿之间交替,形成闪动。
///
public bool IsIndicatorAlert
{
@@ -111,8 +115,10 @@ private void UpdateTimer_Tick(object? sender, EventArgs e)
_isUpdating = true;
try
{
- // 应用设置的更新频率(设置变化后下一拍即生效)
+ // 应用设置的更新频率(设置变化后下一拍即生效);
+ // 筛选窗口开启时收紧到闪烁半周期,保证红绿交替看得见
int intervalMs = Math.Clamp(Settings?.UpdateIntervalMs ?? 200, 100, 5000);
+ if (_runtimeService?.IsHotkeyWindowOpen == true) intervalMs = Math.Min(intervalMs, BlinkHalfPeriodMs);
if (Math.Abs(_updateTimer.Interval.TotalMilliseconds - intervalMs) > 0.5)
{
_updateTimer.Interval = TimeSpan.FromMilliseconds(intervalMs);
@@ -133,9 +139,12 @@ private void UpdateTimer_Tick(object? sender, EventArgs e)
? (showPrefix ? $"分贝: {_runtimeService.CurrentDecibel:F1}" : $"{_runtimeService.CurrentDecibel:F1}")
: "N/A";
- // 单一状态点:红=提醒触发中(超阈值/筛选窗口开启/冷却期),绿=正常
+ // 单一状态点:红=提醒触发中(超阈值/冷却期),绿=正常;
+ // 筛选窗口开启时红绿交替闪动,与静态状态区分开
IsIndicatorVisible = showIndicator && _runtimeService.IsAnySourceEnabled;
- IsIndicatorAlert = _runtimeService.IsAlertStateActive;
+ IsIndicatorAlert = _runtimeService.IsHotkeyWindowOpen
+ ? Environment.TickCount64 / BlinkHalfPeriodMs % 2 == 0
+ : _runtimeService.IsAlertStateActive;
}
catch (Exception)
{
diff --git a/Views/SettingsPages/DecibelMonitorSettingsPage.axaml b/Views/SettingsPages/DecibelMonitorSettingsPage.axaml
index 737bd0e..43de2c8 100644
--- a/Views/SettingsPages/DecibelMonitorSettingsPage.axaml
+++ b/Views/SettingsPages/DecibelMonitorSettingsPage.axaml
@@ -126,7 +126,7 @@
@@ -152,7 +152,7 @@
+ Description="窗口开启后等待按键确认的时间,超时则关闭窗口且不提醒;窗口开启期间组件状态点红绿闪动。">