From 692f9df3db95fad419de9b05aa60031b1c90b9f9 Mon Sep 17 00:00:00 2001
From: Starsky16 <163619709+Starsky16@users.noreply.github.com>
Date: Tue, 15 Sep 2026 01:58:44 +0800
Subject: [PATCH 01/25] =?UTF-8?q?refactor:=20=E6=8A=BD=E5=8F=96=E9=87=87?=
=?UTF-8?q?=E6=A0=B7=E4=B8=8E=E8=AE=A1=E7=AE=97=E6=A0=B8=E5=BF=83=E6=9C=8D?=
=?UTF-8?q?=E5=8A=A1=E5=B9=B6=E8=A1=A5=E5=85=85=E5=8D=95=E5=85=83=E6=B5=8B?=
=?UTF-8?q?=E8=AF=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 新增 Services:AudioPeakMeter(门面)、AudioDeviceEnumerator(设备枚举与缓存)、ContinuousPeakCapture(常驻静默捕获流)、OneShotPeakCapture(一次性三级回退采样)、DecibelCalculator(线性峰值与显示刻度换算)、PeakSample(采样结果纯数据)。
- 新增插件全局设置 DecibelMonitorGlobalSettings 与持久化服务 DecibelMonitorSettingsService(整体反序列化,新增设置项无需同步维护)。
- Plugin.Initialize 注册全局设置服务与共享采样服务;组件将在后续 PR 切换为该服务。
- 新增 Tests/Decibel_Monitor.Tests(xunit),覆盖计算换算与采样数据纯逻辑,共 28 条用例全绿。
由 VS Code 中的 Cline 辅助生成。
---
Decibel_Monitor.csproj | 7 +
Models/DecibelMonitorGlobalSettings.cs | 37 ++++
Plugin.cs | 7 +
Services/AudioDeviceEnumerator.cs | 110 ++++++++++
Services/AudioPeakMeter.cs | 154 ++++++++++++++
Services/ContinuousPeakCapture.cs | 194 ++++++++++++++++++
Services/DecibelCalculator.cs | 62 ++++++
Services/DecibelMonitorSettingsService.cs | 95 +++++++++
Services/OneShotPeakCapture.cs | 183 +++++++++++++++++
Services/PeakSample.cs | 50 +++++
.../DecibelCalculatorTests.cs | 127 ++++++++++++
.../Decibel_Monitor.Tests.csproj | 27 +++
.../Decibel_Monitor.Tests/PeakSampleTests.cs | 86 ++++++++
13 files changed, 1139 insertions(+)
create mode 100644 Models/DecibelMonitorGlobalSettings.cs
create mode 100644 Services/AudioDeviceEnumerator.cs
create mode 100644 Services/AudioPeakMeter.cs
create mode 100644 Services/ContinuousPeakCapture.cs
create mode 100644 Services/DecibelCalculator.cs
create mode 100644 Services/DecibelMonitorSettingsService.cs
create mode 100644 Services/OneShotPeakCapture.cs
create mode 100644 Services/PeakSample.cs
create mode 100644 Tests/Decibel_Monitor.Tests/DecibelCalculatorTests.cs
create mode 100644 Tests/Decibel_Monitor.Tests/Decibel_Monitor.Tests.csproj
create mode 100644 Tests/Decibel_Monitor.Tests/PeakSampleTests.cs
diff --git a/Decibel_Monitor.csproj b/Decibel_Monitor.csproj
index 9afaa78..b9dda6d 100644
--- a/Decibel_Monitor.csproj
+++ b/Decibel_Monitor.csproj
@@ -20,6 +20,13 @@
+
+
+
+
+
+
+
DecibelComponentSettingsControl.axaml
diff --git a/Models/DecibelMonitorGlobalSettings.cs b/Models/DecibelMonitorGlobalSettings.cs
new file mode 100644
index 0000000..fe5ce58
--- /dev/null
+++ b/Models/DecibelMonitorGlobalSettings.cs
@@ -0,0 +1,37 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+
+namespace Decibel_Monitor.Models;
+
+///
+/// Decibel_Monitor 插件的全局设置(存放于插件配置目录,与组件/通知设置相互独立)。
+///
+public partial class DecibelMonitorGlobalSettings : ObservableObject
+{
+ ///
+ /// 校准参考(显示刻度 dB 0..150,换算为 dBFS 时为其减去 150),默认 70(对应 -80 dBFS)。
+ /// 同一麦克风的测量/校准为全局一致,故作为插件级全局设置。
+ ///
+ [ObservableProperty] private double _referenceDecibel = 70.0;
+
+ ///
+ /// 全局放大倍数:由校准计算后写入,组件显示分贝时统一使用该值,避免各组件数值不一致。
+ ///
+ [ObservableProperty] private double _magnification = 1.0;
+
+ ///
+ /// 是否启用"常驻静默捕获流",默认 true。
+ /// 开启后保持一条低开销的麦克风捕获流以持续获取峰值(Windows 会显示"麦克风正在使用",但图标常亮、不会闪烁);
+ /// 关闭后仅使用系统实时计量(多数设备在无活跃录音会话时该值为 0,组件可能长期显示 0)。
+ ///
+ [ObservableProperty] private bool _enableContinuousMonitoring = true;
+
+ ///
+ /// 显式采样(如校准)的单次录音时长(毫秒)。较长时间可捕获更低频率/更弱信号。
+ ///
+ [ObservableProperty] private int _fallbackCaptureMs = 400;
+
+ ///
+ /// 线性峰值信号检测阈值:超过该值视为"检测到有效信号"(可在采样异常时调低)。
+ ///
+ [ObservableProperty] private double _signalThreshold = 0.0001;
+}
diff --git a/Plugin.cs b/Plugin.cs
index 58bd1a1..e43c1b7 100644
--- a/Plugin.cs
+++ b/Plugin.cs
@@ -17,6 +17,13 @@ public override void Initialize(HostBuilderContext context, IServiceCollection s
// 可选欢迎提示
//CommonTaskDialogs.ShowDialog("Hello world!", "Hello from Decibel_Monitor!");
+ // 插件全局设置(保存于插件配置目录,供采样服务与设置页共享)
+ var globalSettingsService = new Services.DecibelMonitorSettingsService(PluginConfigFolder);
+ services.AddSingleton(globalSettingsService);
+
+ // 共享的麦克风峰值采样服务(单例,构造时读取全局采样偏好)
+ services.AddSingleton();
+
// 一次性注册组件与其设置控件(不要重复注册)
services.AddComponent();
}
diff --git a/Services/AudioDeviceEnumerator.cs b/Services/AudioDeviceEnumerator.cs
new file mode 100644
index 0000000..1aea70e
--- /dev/null
+++ b/Services/AudioDeviceEnumerator.cs
@@ -0,0 +1,110 @@
+using System;
+using System.Linq;
+using NAudio.CoreAudioApi;
+
+namespace Decibel_Monitor.Services;
+
+///
+/// 捕获设备枚举与缓存(默认捕获设备 / 设备描述列表,枚举结果带 5 秒缓存)。
+/// 由 拆分而来,对外行为保持一致。
+///
+internal sealed class AudioDeviceEnumerator : IDisposable
+{
+ /// 设备列表缓存刷新间隔(毫秒)。
+ private const int DeviceRefreshIntervalMs = 5000;
+
+ private readonly object _cacheLock = new();
+ private readonly MMDeviceEnumerator _enumerator = new();
+
+ private MMDevice[]? _cachedCaptureDevices;
+ private MMDevice? _cachedDefaultDevice;
+ private DateTime _deviceCacheTimeUtc = DateTime.MinValue;
+
+ ///
+ /// 获取默认捕获设备(设备列表带 5 秒缓存)。
+ ///
+ public MMDevice? GetDefaultCaptureDevice()
+ {
+ try
+ {
+ EnsureCache();
+ lock (_cacheLock)
+ {
+ if (_cachedDefaultDevice is null) return null;
+ if (_cachedCaptureDevices is null) return _cachedDefaultDevice;
+ return _cachedCaptureDevices.FirstOrDefault(c => c.ID == _cachedDefaultDevice.ID) ?? _cachedDefaultDevice;
+ }
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ ///
+ /// 判断指定设备是否为默认捕获设备(null 表示默认设备)。
+ ///
+ public bool IsDefaultDevice(MMDevice? device)
+ {
+ if (device is null) return true;
+ var def = _cachedDefaultDevice;
+ return def is null || def.ID == device.ID;
+ }
+
+ ///
+ /// 获取当前可用捕获设备的描述列表(用于诊断显示)。
+ ///
+ public string[] GetCaptureDeviceDescriptions()
+ {
+ try
+ {
+ EnsureCache();
+ lock (_cacheLock)
+ {
+ return _cachedCaptureDevices?.Select(d => $"{d.FriendlyName} ({d.ID})").ToArray() ?? Array.Empty();
+ }
+ }
+ catch
+ {
+ return Array.Empty();
+ }
+ }
+
+ ///
+ /// 获取默认捕获设备的描述(用于诊断显示)。
+ ///
+ public string? GetDefaultCaptureDeviceDescription()
+ {
+ try
+ {
+ var d = GetDefaultCaptureDevice();
+ return d is null ? null : $"{d.FriendlyName} ({d.ID})";
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ ///
+ /// 刷新设备缓存(超过缓存时间或尚未初始化时重新枚举)。
+ ///
+ private void EnsureCache()
+ {
+ lock (_cacheLock)
+ {
+ var now = DateTime.UtcNow;
+ if (_cachedCaptureDevices is null || (now - _deviceCacheTimeUtc).TotalMilliseconds >= DeviceRefreshIntervalMs)
+ {
+ _cachedCaptureDevices = _enumerator.EnumerateAudioEndPoints(DataFlow.Capture, DeviceState.Active).ToArray();
+ _cachedDefaultDevice = _enumerator.GetDefaultAudioEndpoint(DataFlow.Capture, Role.Console);
+ _deviceCacheTimeUtc = now;
+ }
+ }
+ }
+
+ public void Dispose()
+ {
+ _enumerator.Dispose();
+ }
+}
diff --git a/Services/AudioPeakMeter.cs b/Services/AudioPeakMeter.cs
new file mode 100644
index 0000000..f761424
--- /dev/null
+++ b/Services/AudioPeakMeter.cs
@@ -0,0 +1,154 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Decibel_Monitor.Services;
+
+///
+/// 麦克风峰值采样服务。
+/// 通过一条"常驻静默捕获流"(WASAPI 共享模式,仅计算峰值、不保存音频)持续读取默认麦克风峰值,
+/// 供组件高频刷新直接读取——数值稳定、无周期性打开/关闭录音导致的占用图标闪烁。
+/// 常驻流不可用时回退到系统实时计量(AudioMeterInformation);
+/// 显式操作(校准等)可调用 临时采样一次。
+/// 设备枚举结果带缓存,采样会话(临时采样)受信号量保护。
+///
+///
+/// 实现已拆分为:(设备枚举与缓存)、
+/// (常驻捕获流)、(显式一次性采样)。
+/// 本类只负责持有配置并转发调用,公开 API 保持不变。
+///
+public sealed class AudioPeakMeter : IDisposable
+{
+ private readonly AudioDeviceEnumerator _devices = new();
+ private readonly ContinuousPeakCapture _continuousCapture;
+ private readonly OneShotPeakCapture _oneShotCapture;
+
+ /// 临时(显式)采样单次录音时长(毫秒),来自插件全局设置,默认 400。
+ private readonly int _fallbackCaptureMs = 400;
+
+ /// 线性峰值超过该阈值视为"检测到有效信号",来自插件全局设置,默认 0.0001。
+ private readonly float _signalThreshold = 0.0001f;
+
+ ///
+ /// 是否启用"常驻静默捕获流"。默认 true:保持一条低开销捕获流以持续获取峰值
+ /// (Windows 会显示麦克风正在使用且图标常亮,但不会闪烁)。
+ /// 关闭后仅使用系统实时计量(多数设备在无活跃录音会话时该值为 0)。
+ ///
+ private readonly bool _enableContinuousMonitoring;
+
+ private volatile bool _disposed;
+
+ /// 当前服务是否已释放。
+ public bool IsDisposed => _disposed;
+
+ ///
+ /// 当前默认的临时采样时长(毫秒,来自插件全局设置;未注入时 400)。
+ ///
+ public int DefaultCaptureMs => _fallbackCaptureMs;
+
+ ///
+ /// 是否启用"常驻静默捕获流"。
+ ///
+ public bool EnableContinuousMonitoring => _enableContinuousMonitoring;
+
+ ///
+ /// 初始化采样服务。
+ ///
+ /// 插件全局设置服务(可空;缺省时使用内置默认采样参数)。
+ public AudioPeakMeter(DecibelMonitorSettingsService? settingsService = null)
+ {
+ if (settingsService is not null)
+ {
+ _fallbackCaptureMs = Math.Clamp(settingsService.Settings.FallbackCaptureMs, 100, 5000);
+ _signalThreshold = (float)Math.Clamp(settingsService.Settings.SignalThreshold, 0.000001, 1.0);
+ _enableContinuousMonitoring = settingsService.Settings.EnableContinuousMonitoring;
+ }
+
+ _continuousCapture = new ContinuousPeakCapture(_devices, _enableContinuousMonitoring);
+ _oneShotCapture = new OneShotPeakCapture(_devices, _signalThreshold);
+ }
+
+ ///
+ /// 快速读取默认捕获设备的实时线性峰值(0..1)。
+ /// 仅读取 AudioMeterInformation,不做耗时采样,适合高频 UI 刷新。
+ /// 若设备不支持实时计量或读取失败,返回 0。
+ ///
+ public float GetDefaultDevicePeakLinearFast()
+ {
+ if (_disposed) return 0f;
+ try
+ {
+ var device = _devices.GetDefaultCaptureDevice();
+ return device?.AudioMeterInformation?.MasterPeakValue ?? 0f;
+ }
+ catch
+ {
+ return 0f;
+ }
+ }
+
+ ///
+ /// 获取默认捕获设备的线性峰值(0..1),供组件周期刷新使用。
+ /// 启用"常驻静默捕获流"时,返回常驻流在当前峰值窗口内的最大值(稳定、无周期性开/关录音);
+ /// 同时优先返回实时计量(AudioMeterInformation)读到的更大瞬时值以提高响应灵敏度。
+ /// 未启用常驻流时仅使用系统实时计量(多数设备在无活跃录音会话时该值为 0)。
+ ///
+ public Task GetDefaultDevicePeakLinearAsync(CancellationToken cancellationToken = default)
+ {
+ if (_disposed) return Task.FromResult(0f);
+
+ var fast = GetDefaultDevicePeakLinearFast();
+ if (fast > _signalThreshold)
+ {
+ return Task.FromResult(fast);
+ }
+
+ if (_enableContinuousMonitoring)
+ {
+ _continuousCapture.EnsureStarted();
+ return Task.FromResult(_continuousCapture.CurrentPeak);
+ }
+
+ return Task.FromResult(0f);
+ }
+
+ ///
+ /// 显式采样一次默认捕获设备的线性峰值(0..1)——用户主动触发(如校准、诊断)时使用。
+ /// 会按 AudioMeterInformation → WasapiCapture → WaveInEvent 顺序尝试,
+ /// 期间会短暂打开录音通道(受信号量保护,同一时间仅一个采样会话)。
+ ///
+ /// 录音采样时长(毫秒),限制在 100..5000 之间。
+ public async Task CaptureSamplePeakAsync(int captureMs, CancellationToken cancellationToken = default)
+ {
+ if (_disposed) return 0f;
+ return await _oneShotCapture.CaptureAsync(captureMs, cancellationToken).ConfigureAwait(false);
+ }
+
+ ///
+ /// 获取当前可用捕获设备的描述列表(用于诊断显示)。
+ ///
+ public string[] GetCaptureDeviceDescriptions()
+ {
+ if (_disposed) return Array.Empty();
+ return _devices.GetCaptureDeviceDescriptions();
+ }
+
+ ///
+ /// 获取默认捕获设备的描述(用于诊断显示)。
+ ///
+ public string? GetDefaultCaptureDeviceDescription()
+ {
+ if (_disposed) return null;
+ return _devices.GetDefaultCaptureDeviceDescription();
+ }
+
+ public void Dispose()
+ {
+ if (_disposed) return;
+ _disposed = true;
+
+ _continuousCapture.Dispose();
+ _oneShotCapture.Dispose();
+ _devices.Dispose();
+ }
+}
diff --git a/Services/ContinuousPeakCapture.cs b/Services/ContinuousPeakCapture.cs
new file mode 100644
index 0000000..c3aaba1
--- /dev/null
+++ b/Services/ContinuousPeakCapture.cs
@@ -0,0 +1,194 @@
+using System;
+using NAudio.CoreAudioApi;
+using NAudio.Wave;
+
+namespace Decibel_Monitor.Services;
+
+///
+/// 常驻静默捕获流:以 WASAPI 共享模式持续读取默认麦克风峰值(仅计算峰值、不保存音频)。
+/// 默认设备变化或捕获意外停止时自动重启(失败带退避);流不可用时由调用方回退到系统实时计量。
+/// 由 拆分而来,对外行为保持一致。
+///
+internal sealed class ContinuousPeakCapture : IDisposable
+{
+ /// 常驻捕获流启动失败后的重试退避间隔(毫秒)。
+ private const int CaptureRestartBackoffMs = 5000;
+
+ /// 常驻捕获流的峰值窗口(毫秒):超过该窗口后重新开始累计最大值。
+ private static readonly TimeSpan ContinuousPeakWindow = TimeSpan.FromMilliseconds(400);
+
+ private readonly object _captureLock = new();
+ private readonly AudioDeviceEnumerator _devices;
+ private readonly bool _enabled;
+
+ private WasapiCapture? _continuousCapture;
+ private string? _continuousCaptureDeviceId;
+ private DateTime _continuousWindowStartUtc;
+ private DateTime _nextCaptureStartAttemptUtc = DateTime.MinValue;
+ private volatile bool _captureRunning;
+ private volatile float _continuousPeak;
+ private volatile bool _disposed;
+
+ ///
+ /// 初始化常驻捕获流。
+ ///
+ /// 设备枚举器(提供默认捕获设备)。
+ /// 是否启用常驻捕获流。
+ public ContinuousPeakCapture(AudioDeviceEnumerator devices, bool enabled)
+ {
+ _devices = devices;
+ _enabled = enabled;
+ }
+
+ /// 当前峰值窗口内的最大线性峰值(0..1)。
+ public float CurrentPeak => _continuousPeak;
+
+ ///
+ /// 确保"常驻静默捕获流"已启动。捕获流仅计算峰值、不保存音频;
+ /// 若默认设备变化或捕获意外停止则自动重启(带退避)。
+ ///
+ public void EnsureStarted()
+ {
+ if (_disposed || !_enabled) return;
+
+ // 捕获运行中:仅检查默认设备是否变化,变化则重启
+ if (_captureRunning)
+ {
+ if (_continuousCaptureDeviceId != _devices.GetDefaultCaptureDevice()?.ID)
+ {
+ Restart();
+ }
+ return;
+ }
+
+ if (DateTime.UtcNow < _nextCaptureStartAttemptUtc) return; // 失败退避中
+
+ lock (_captureLock)
+ {
+ if (_disposed || _captureRunning) return;
+ if (DateTime.UtcNow < _nextCaptureStartAttemptUtc) return;
+
+ try
+ {
+ var device = _devices.GetDefaultCaptureDevice();
+ if (device is null)
+ {
+ _nextCaptureStartAttemptUtc = DateTime.UtcNow.AddMilliseconds(CaptureRestartBackoffMs);
+ return;
+ }
+
+ StopLocked(); // 清理可能残留的对象
+
+ var capture = new WasapiCapture(device);
+ capture.DataAvailable += OnData;
+ capture.RecordingStopped += OnStopped;
+ capture.StartRecording();
+
+ _continuousCapture = capture;
+ _continuousCaptureDeviceId = device.ID;
+ _continuousWindowStartUtc = DateTime.MinValue;
+ _continuousPeak = 0f;
+ _captureRunning = true;
+ }
+ catch
+ {
+ // 启动失败:清理并退避,避免高频重试
+ StopLocked();
+ _nextCaptureStartAttemptUtc = DateTime.UtcNow.AddMilliseconds(CaptureRestartBackoffMs);
+ }
+ }
+ }
+
+ ///
+ /// 重启常驻捕获流(默认设备变化等场景)。
+ ///
+ private void Restart()
+ {
+ lock (_captureLock)
+ {
+ StopLocked();
+ _nextCaptureStartAttemptUtc = DateTime.MinValue;
+ }
+ // 让 EnsureStarted 的加锁路径重新启动
+ EnsureStarted();
+ }
+
+ ///
+ /// 停止并释放当前常驻捕获流。须在持有 时调用。
+ ///
+ private void StopLocked()
+ {
+ var capture = _continuousCapture;
+ _continuousCapture = null;
+ _continuousCaptureDeviceId = null;
+ _captureRunning = false;
+ _continuousPeak = 0f;
+
+ if (capture is null) return;
+ try
+ {
+ capture.DataAvailable -= OnData;
+ capture.RecordingStopped -= OnStopped;
+ capture.StopRecording();
+ }
+ catch { /* 忽略停止时的异常 */ }
+ capture.Dispose();
+ }
+
+ ///
+ /// 常驻捕获流数据回调:在每个数据块中取峰值,并在
+ /// 窗口内保留最大值(供读取线程经 获取)。
+ ///
+ private void OnData(object? sender, WaveInEventArgs e)
+ {
+ try
+ {
+ if (sender is not WasapiCapture capture) return;
+
+ var wf = capture.WaveFormat;
+ float blockPeak = PeakSample.ComputePeak(
+ e.Buffer, e.BytesRecorded,
+ wf.Encoding == WaveFormatEncoding.IeeeFloat, wf.BitsPerSample);
+
+ var now = DateTime.UtcNow;
+ if (_continuousWindowStartUtc == DateTime.MinValue ||
+ now - _continuousWindowStartUtc > ContinuousPeakWindow)
+ {
+ _continuousWindowStartUtc = now;
+ _continuousPeak = blockPeak;
+ }
+ else if (blockPeak > _continuousPeak)
+ {
+ _continuousPeak = blockPeak;
+ }
+ }
+ catch { /* 忽略单个数据块解析错误 */ }
+ }
+
+ ///
+ /// 常驻捕获流停止回调(设备被拔出、被独占或异常等)。
+ ///
+ private void OnStopped(object? sender, StoppedEventArgs e)
+ {
+ // 仅处理当前流的事件,忽略重启过程中旧流延迟到达的回调
+ if (!ReferenceEquals(sender, _continuousCapture)) return;
+
+ _captureRunning = false;
+ _continuousPeak = 0f;
+ if (e.Exception is not null)
+ {
+ _nextCaptureStartAttemptUtc = DateTime.UtcNow.AddMilliseconds(CaptureRestartBackoffMs);
+ }
+ }
+
+ public void Dispose()
+ {
+ if (_disposed) return;
+ _disposed = true;
+
+ lock (_captureLock)
+ {
+ StopLocked();
+ }
+ }
+}
diff --git a/Services/DecibelCalculator.cs b/Services/DecibelCalculator.cs
new file mode 100644
index 0000000..b991646
--- /dev/null
+++ b/Services/DecibelCalculator.cs
@@ -0,0 +1,62 @@
+using System;
+
+namespace Decibel_Monitor.Services;
+
+///
+/// 分贝换算纯函数工具类(与 UI / 音频设备无关,便于单元测试)。
+///
+///
+/// 显示刻度(0..150)与 dBFS 的换算关系:
+/// displayDb = 150 + dbfs,即 0 dBFS(满幅)对应 150,-150 dBFS 对应 0。
+///
+public static class DecibelCalculator
+{
+ ///
+ /// 显示刻度上限。
+ ///
+ public const double DisplayScaleMax = 150.0;
+
+ ///
+ /// 将线性峰值(0..1)按放大倍数放大后映射为显示刻度(0..150)。
+ ///
+ /// 线性峰值,范围 0..1。
+ /// 校准放大倍数。
+ /// 映射后的显示分贝值(0..150)。
+ public static double LinearToDisplayDb(double linear, double magnification)
+ {
+ var scaled = Math.Clamp(linear * magnification, 0.0, 1.0);
+ if (scaled <= 0.0) return 0.0;
+ var dbfs = 20.0 * Math.Log10(scaled);
+ return Math.Clamp(DisplayScaleMax + dbfs, 0.0, DisplayScaleMax);
+ }
+
+ ///
+ /// 将显示刻度(0..150)转换为 dBFS。
+ ///
+ public static double DisplayDbToDbFs(double displayDb)
+ {
+ return displayDb - DisplayScaleMax;
+ }
+
+ ///
+ /// 将 dBFS 转换为线性值(0..1)。
+ ///
+ public static double DbFsToLinear(double dbfs)
+ {
+ return Math.Pow(10.0, dbfs / 20.0);
+ }
+
+ ///
+ /// 根据目标显示分贝与测量到的线性峰值计算放大倍数,并做上下限保护。
+ ///
+ /// 目标显示分贝(0..150)。
+ /// 测量得到的线性峰值(0..1)。
+ /// 放大倍数上限。
+ /// 放大倍数。当测量值为 0(无法测量)时返回上限,避免极端错误值。
+ public static double CalculateMagnification(double targetDisplayDb, double measuredLinear, double maxMagnification = 1024.0)
+ {
+ if (measuredLinear <= 0.0) return maxMagnification;
+ var targetLinear = DbFsToLinear(DisplayDbToDbFs(targetDisplayDb));
+ return Math.Clamp(targetLinear / measuredLinear, 0.0, maxMagnification);
+ }
+}
diff --git a/Services/DecibelMonitorSettingsService.cs b/Services/DecibelMonitorSettingsService.cs
new file mode 100644
index 0000000..a913dde
--- /dev/null
+++ b/Services/DecibelMonitorSettingsService.cs
@@ -0,0 +1,95 @@
+using System;
+using System.ComponentModel;
+using System.IO;
+using System.Text.Json;
+using System.Threading;
+using Decibel_Monitor.Models;
+
+namespace Decibel_Monitor.Services;
+
+///
+/// 插件全局设置服务:在插件配置目录中以 JSON 保存
+/// ,并在修改后自动保存。
+///
+public sealed class DecibelMonitorSettingsService : IDisposable
+{
+ private readonly object _saveLock = new();
+ private readonly string? _filePath;
+ private Timer? _saveTimer;
+ private bool _disposed;
+
+ ///
+ /// 当前全局设置(属性变化自动触发延迟保存)。
+ ///
+ public DecibelMonitorGlobalSettings Settings { get; }
+
+ /// 插件配置目录(PluginBase.PluginConfigFolder);为空时仅内存保存。
+ public DecibelMonitorSettingsService(string? configFolder)
+ {
+ if (!string.IsNullOrWhiteSpace(configFolder))
+ {
+ _filePath = Path.Combine(configFolder, "plugin-settings.json");
+ }
+
+ // 整体反序列化得到完整实例:新增设置项无需在此处同步维护,避免漏拷贝
+ Settings = LoadSettings() ?? new DecibelMonitorGlobalSettings();
+ Settings.PropertyChanged += OnSettingsPropertyChanged;
+ }
+
+ private void OnSettingsPropertyChanged(object? sender, PropertyChangedEventArgs e)
+ {
+ if (_disposed) return;
+
+ // 去抖后写盘,避免滑块拖动等高频变更频繁落盘
+ _saveTimer?.Dispose();
+ _saveTimer = new Timer(_ => Save(), null, 400, Timeout.Infinite);
+ }
+
+ ///
+ /// 从磁盘读取全局设置;文件不存在或解析失败时返回 null(调用方回退到默认值)。
+ ///
+ private DecibelMonitorGlobalSettings? LoadSettings()
+ {
+ if (_filePath is null || !File.Exists(_filePath)) return null;
+ try
+ {
+ var json = File.ReadAllText(_filePath);
+ return JsonSerializer.Deserialize(json);
+ }
+ catch
+ {
+ // 读取失败按默认值继续
+ return null;
+ }
+ }
+
+ ///
+ /// 立即将全局设置写入磁盘。
+ ///
+ public void Save()
+ {
+ if (_filePath is null || _disposed) return;
+ try
+ {
+ lock (_saveLock)
+ {
+ var dir = Path.GetDirectoryName(_filePath);
+ if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
+ File.WriteAllText(_filePath, JsonSerializer.Serialize(Settings));
+ }
+ }
+ catch
+ {
+ // 写盘失败不应影响运行
+ }
+ }
+
+ public void Dispose()
+ {
+ if (_disposed) return;
+ _disposed = true;
+ Settings.PropertyChanged -= OnSettingsPropertyChanged;
+ _saveTimer?.Dispose();
+ Save();
+ }
+}
diff --git a/Services/OneShotPeakCapture.cs b/Services/OneShotPeakCapture.cs
new file mode 100644
index 0000000..4e76e68
--- /dev/null
+++ b/Services/OneShotPeakCapture.cs
@@ -0,0 +1,183 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using NAudio.CoreAudioApi;
+using NAudio.Wave;
+
+namespace Decibel_Monitor.Services;
+
+///
+/// 显式(一次性)采样:按 AudioMeterInformation → WasapiCapture → WaveInEvent 顺序回退,
+/// 采样会话受信号量保护(同一时间仅一个会话),期间会短暂打开录音通道。
+/// 由 拆分而来,对外行为保持一致。
+///
+internal sealed class OneShotPeakCapture : IDisposable
+{
+ private readonly SemaphoreSlim _samplingGate = new(1, 1);
+ private readonly AudioDeviceEnumerator _devices;
+ private readonly float _signalThreshold;
+ private volatile bool _disposed;
+
+ ///
+ /// 初始化一次性采样器。
+ ///
+ /// 设备枚举器(提供默认捕获设备)。
+ /// 线性峰值超过该值视为“检测到有效信号”。
+ public OneShotPeakCapture(AudioDeviceEnumerator devices, float signalThreshold)
+ {
+ _devices = devices;
+ _signalThreshold = signalThreshold;
+ }
+
+ ///
+ /// 显式采样一次默认捕获设备的线性峰值(0..1)——用户主动触发(如校准、诊断)时使用。
+ ///
+ /// 录音采样时长(毫秒),限制在 100..5000 之间。
+ public async Task CaptureAsync(int captureMs, CancellationToken cancellationToken = default)
+ {
+ if (_disposed) return 0f;
+
+ await _samplingGate.WaitAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var device = _devices.GetDefaultCaptureDevice();
+ return await GetPeakLinearAsync(device, Math.Clamp(captureMs, 100, 5000), cancellationToken).ConfigureAwait(false);
+ }
+ finally
+ {
+ _samplingGate.Release();
+ }
+ }
+
+ ///
+ /// 获取指定设备(可空表示默认设备)的线性峰值(0..1),按 AudioMeterInformation → WasapiCapture → WaveInEvent 顺序回退。
+ ///
+ private async Task GetPeakLinearAsync(MMDevice? device, int captureMs, CancellationToken cancellationToken)
+ {
+ // 1) AudioMeterInformation(低成本实时计量)
+ var meterValue = device?.AudioMeterInformation?.MasterPeakValue ?? 0f;
+ if (meterValue > _signalThreshold) return meterValue;
+
+ // 2) WasapiCapture(指定设备)
+ try
+ {
+ var v = await GetPeakByWasapiCaptureAsync(device, captureMs, cancellationToken).ConfigureAwait(false);
+ if (v > _signalThreshold) return v;
+ }
+ catch (OperationCanceledException) { throw; }
+ catch { /* 忽略,继续回退 */ }
+
+ // 3) WasapiCapture(默认设备):仅在指定设备并非默认设备时才需要再次尝试,
+ // 避免对同一设备重复录音造成不必要的开销。
+ if (!_devices.IsDefaultDevice(device))
+ {
+ try
+ {
+ var v = await GetPeakByWasapiCaptureAsync(null, captureMs, cancellationToken).ConfigureAwait(false);
+ if (v > _signalThreshold) return v;
+ }
+ catch (OperationCanceledException) { throw; }
+ catch { /* 忽略 */ }
+ }
+
+ // 4) WaveInEvent(旧 API,兼容性更好)
+ try
+ {
+ var v = await GetPeakByWaveInAsync(captureMs, cancellationToken).ConfigureAwait(false);
+ if (v > _signalThreshold) return v;
+ }
+ catch (OperationCanceledException) { throw; }
+ catch { /* 忽略 */ }
+
+ return 0f;
+ }
+
+ ///
+ /// 使用 WasapiCapture 短时录音并计算最大样本绝对值(0..1)。
+ /// 检测到首个有效信号即提前返回,避免等满整个录音时长。
+ ///
+ private async Task GetPeakByWasapiCaptureAsync(MMDevice? device, int captureMs, CancellationToken cancellationToken)
+ {
+ float maxSample = 0f;
+ var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ using var capture = device is null ? new WasapiCapture() : new WasapiCapture(device);
+ capture.DataAvailable += (s, e) =>
+ {
+ try
+ {
+ var wf = capture.WaveFormat;
+ float peak = PeakSample.ComputePeak(
+ e.Buffer, e.BytesRecorded,
+ wf.Encoding == WaveFormatEncoding.IeeeFloat, wf.BitsPerSample);
+
+ if (peak > maxSample) maxSample = peak;
+ // 检测到有效信号即可提前完成采样,避免等满整个录音时长
+ if (maxSample > _signalThreshold) tcs.TrySetResult(maxSample);
+ }
+ catch { /* 忽略单个数据块解析错误 */ }
+ };
+ capture.RecordingStopped += (s, e) => tcs.TrySetResult(maxSample);
+
+ capture.StartRecording();
+ try
+ {
+ // 首个有效信号或录音时长到点即返回
+ await Task.WhenAny(tcs.Task, Task.Delay(captureMs, cancellationToken)).ConfigureAwait(false);
+ }
+ finally
+ {
+ capture.StopRecording();
+ }
+
+ // 采样结果经 TCS 传递,保证回调线程写入对当前线程可见
+ var peak = tcs.Task.IsCompleted ? await tcs.Task.ConfigureAwait(false) : maxSample;
+ return Math.Clamp(peak, 0f, 1f);
+ }
+
+ ///
+ /// 使用 WaveInEvent(旧 API)短时录音并计算最大样本绝对值(0..1)。
+ /// 检测到首个有效信号即提前返回,避免等满整个录音时长。
+ ///
+ private async Task GetPeakByWaveInAsync(int captureMs, CancellationToken cancellationToken)
+ {
+ float maxSample = 0f;
+ var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ using var waveIn = new WaveInEvent();
+ waveIn.WaveFormat = new WaveFormat(16000, 16, 1); // 单声道 16k 16bit
+ waveIn.DataAvailable += (s, e) =>
+ {
+ try
+ {
+ float peak = PeakSample.ComputePeak(e.Buffer, e.BytesRecorded, false, 16);
+ if (peak > maxSample) maxSample = peak;
+ if (maxSample > _signalThreshold) tcs.TrySetResult(maxSample);
+ }
+ catch { }
+ };
+ waveIn.RecordingStopped += (s, e) => tcs.TrySetResult(maxSample);
+
+ waveIn.StartRecording();
+ try
+ {
+ await Task.WhenAny(tcs.Task, Task.Delay(captureMs, cancellationToken)).ConfigureAwait(false);
+ }
+ finally
+ {
+ waveIn.StopRecording();
+ }
+
+ var peak = tcs.Task.IsCompleted ? await tcs.Task.ConfigureAwait(false) : maxSample;
+ return Math.Clamp(peak, 0f, 1f);
+ }
+
+ public void Dispose()
+ {
+ if (_disposed) return;
+ _disposed = true;
+ _samplingGate.Dispose();
+ }
+}
diff --git a/Services/PeakSample.cs b/Services/PeakSample.cs
new file mode 100644
index 0000000..07185af
--- /dev/null
+++ b/Services/PeakSample.cs
@@ -0,0 +1,50 @@
+using System;
+
+namespace Decibel_Monitor.Services;
+
+///
+/// 音频峰值解析纯函数工具类(不依赖 NAudio 具体类型,便于单元测试)。
+///
+public static class PeakSample
+{
+ ///
+ /// 从 PCM/浮点字节缓冲区中计算最大样本绝对值(线性峰值,0..1)。
+ ///
+ /// 音频字节缓冲区。
+ /// 有效字节数。
+ /// 是否为 IEEE 32 位浮点编码。
+ /// PCM 采样位深(16 或 32)。
+ /// 线性峰值,范围 0..1。
+ public static float ComputePeak(byte[] buffer, int bytesRecorded, bool isIeeeFloat, int bitsPerSample)
+ {
+ if (buffer is null || bytesRecorded <= 0) return 0f;
+
+ float max = 0f;
+ if (isIeeeFloat)
+ {
+ for (int n = 0; n + 4 <= bytesRecorded; n += 4)
+ {
+ float sample = Math.Abs(BitConverter.ToSingle(buffer, n));
+ if (sample > max) max = sample;
+ }
+ }
+ else if (bitsPerSample == 16)
+ {
+ for (int n = 0; n + 2 <= bytesRecorded; n += 2)
+ {
+ float sample = Math.Abs(BitConverter.ToInt16(buffer, n) / 32768f);
+ if (sample > max) max = sample;
+ }
+ }
+ else if (bitsPerSample == 32)
+ {
+ for (int n = 0; n + 4 <= bytesRecorded; n += 4)
+ {
+ float sample = Math.Abs(BitConverter.ToInt32(buffer, n) / (float)int.MaxValue);
+ if (sample > max) max = sample;
+ }
+ }
+
+ return Math.Clamp(max, 0f, 1f);
+ }
+}
diff --git a/Tests/Decibel_Monitor.Tests/DecibelCalculatorTests.cs b/Tests/Decibel_Monitor.Tests/DecibelCalculatorTests.cs
new file mode 100644
index 0000000..099b2b9
--- /dev/null
+++ b/Tests/Decibel_Monitor.Tests/DecibelCalculatorTests.cs
@@ -0,0 +1,127 @@
+using Decibel_Monitor.Services;
+
+namespace Decibel_Monitor.Tests;
+
+public class DecibelCalculatorTests
+{
+ // ---- LinearToDisplayDb ----
+
+ [Theory]
+ [InlineData(1.0, 1.0, 150.0)] // 0 dBFS = 满幅
+ [InlineData(0.0, 1.0, 0.0)] // 静音
+ [InlineData(0.5, 1.0, 143.9794)] // -6.02 dBFS
+ [InlineData(0.1, 100.0, 150.0)] // 放大后超限,clamp 到上限
+ [InlineData(0.001, 1.0, 90.0)] // -60 dBFS -> 90
+ public void LinearToDisplayDb_Should_MapWithinScale(double linear, double magnification, double expected)
+ {
+ var result = DecibelCalculator.LinearToDisplayDb(linear, magnification);
+ Assert.Equal(expected, result, 3);
+ }
+
+ [Fact]
+ public void LinearToDisplayDb_Should_ClampToZeroForNonPositiveInput()
+ {
+ Assert.Equal(0.0, DecibelCalculator.LinearToDisplayDb(-0.5, 1.0), 6);
+ Assert.Equal(0.0, DecibelCalculator.LinearToDisplayDb(0.0, 1000.0), 6);
+ }
+
+ [Fact]
+ public void LinearToDisplayDb_Should_RespectMagnificationZero()
+ {
+ // 放大倍数为 0 时任何输入都应映射为 0
+ Assert.Equal(0.0, DecibelCalculator.LinearToDisplayDb(1.0, 0.0), 6);
+ }
+
+ // ---- DisplayDbToDbFs ----
+
+ [Theory]
+ [InlineData(150.0, 0.0)]
+ [InlineData(70.0, -80.0)]
+ [InlineData(0.0, -150.0)]
+ public void DisplayDbToDbFs_Should_ConvertToDbFs(double displayDb, double expectedDbFs)
+ {
+ Assert.Equal(expectedDbFs, DecibelCalculator.DisplayDbToDbFs(displayDb), 6);
+ }
+
+ // ---- DbFsToLinear ----
+
+ [Theory]
+ [InlineData(0.0, 1.0)]
+ [InlineData(-80.0, 0.0001)]
+ [InlineData(-60.0, 0.001)]
+ public void DbFsToLinear_Should_ConvertToLinear(double dbfs, double expectedLinear)
+ {
+ Assert.Equal(expectedLinear, DecibelCalculator.DbFsToLinear(dbfs), 6);
+ }
+
+ // ---- CalculateMagnification ----
+
+ [Fact]
+ public void CalculateMagnification_Should_ReturnOne_WhenMeasuredMatchesTarget()
+ {
+ // 目标 70 dB(-80 dBFS)对应的线性值为 0.0001,若测量值恰好为该值则倍数为 1
+ var result = DecibelCalculator.CalculateMagnification(70.0, 0.0001);
+ Assert.Equal(1.0, result, 6);
+ }
+
+ [Fact]
+ public void CalculateMagnification_Should_ClampToMax_WhenMeasuredIsZero()
+ {
+ Assert.Equal(1024.0, DecibelCalculator.CalculateMagnification(70.0, 0.0));
+ }
+
+ [Fact]
+ public void CalculateMagnification_Should_ReturnReasonableValue()
+ {
+ // 目标 120 dB(-30 dBFS)对应线性值约 0.031623;测量值 0.001 时需要放大约 31.6 倍
+ var result = DecibelCalculator.CalculateMagnification(120.0, 0.001);
+ Assert.Equal(31.6228, result, 3);
+ }
+
+ [Fact]
+ public void CalculateMagnification_Should_ClampToZero_WhenMeasuredExceedsTarget()
+ {
+ // 测量值远超目标时,放大倍数应趋近 0(不会出现负数)
+ var result = DecibelCalculator.CalculateMagnification(70.0, 0.5);
+ Assert.True(result >= 0.0);
+ Assert.True(result < 0.001);
+ }
+
+ // ---- 边界与防御性行为 ----
+
+ [Fact]
+ public void LinearToDisplayDb_Should_ClampToScaleMax_WhenAmplifiedBeyondFullScale()
+ {
+ // 输入超满幅(含放大后超幅)一律 clamp 到刻度上限 150
+ Assert.Equal(150.0, DecibelCalculator.LinearToDisplayDb(2.0, 1.0), 6);
+ Assert.Equal(150.0, DecibelCalculator.LinearToDisplayDb(1.0, 1024.0), 6);
+ }
+
+ [Fact]
+ public void LinearToDisplayDb_Should_ClampInfinitiesToScaleRange()
+ {
+ Assert.Equal(150.0, DecibelCalculator.LinearToDisplayDb(double.PositiveInfinity, 1.0), 6);
+ Assert.Equal(0.0, DecibelCalculator.LinearToDisplayDb(double.NegativeInfinity, 1.0), 6);
+ }
+
+ [Fact]
+ public void LinearToDisplayDb_Should_MapSignalThresholdTo70()
+ {
+ // 线性阈值 0.0001(-80 dBFS)映射到 70,是"检测到有效信号"的基准点
+ Assert.Equal(70.0, DecibelCalculator.LinearToDisplayDb(0.0001, 1.0), 6);
+ }
+
+ [Fact]
+ public void CalculateMagnification_Should_RespectCustomMaxMagnification()
+ {
+ Assert.Equal(1.0, DecibelCalculator.CalculateMagnification(70.0, 0.0001, 64.0), 6);
+ Assert.Equal(64.0, DecibelCalculator.CalculateMagnification(150.0, 0.0001, 64.0), 6);
+ }
+
+ [Fact]
+ public void CalculateMagnification_Should_ClampToMax_WhenMeasuredIsNegative()
+ {
+ // 负的测量值视为"无法测量",与 0 一样返回上限
+ Assert.Equal(1024.0, DecibelCalculator.CalculateMagnification(70.0, -1.0));
+ }
+}
diff --git a/Tests/Decibel_Monitor.Tests/Decibel_Monitor.Tests.csproj b/Tests/Decibel_Monitor.Tests/Decibel_Monitor.Tests.csproj
new file mode 100644
index 0000000..e69ca78
--- /dev/null
+++ b/Tests/Decibel_Monitor.Tests/Decibel_Monitor.Tests.csproj
@@ -0,0 +1,27 @@
+
+
+
+ net8.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Tests/Decibel_Monitor.Tests/PeakSampleTests.cs b/Tests/Decibel_Monitor.Tests/PeakSampleTests.cs
new file mode 100644
index 0000000..18b3438
--- /dev/null
+++ b/Tests/Decibel_Monitor.Tests/PeakSampleTests.cs
@@ -0,0 +1,86 @@
+using System;
+using Decibel_Monitor.Services;
+
+namespace Decibel_Monitor.Tests;
+
+public class PeakSampleTests
+{
+ // ---- 16-bit PCM ----
+
+ [Fact]
+ public void ComputePeak_16BitPcm_Should_FindMaximumAbsoluteSample()
+ {
+ var buffer = new byte[6];
+ // 两个 16-bit 采样:0x1000 (4096) 与 -0x4000 (-16384)
+ WriteInt16(buffer, 0, 4096);
+ WriteInt16(buffer, 2, -16384);
+ WriteInt16(buffer, 4, 0);
+
+ var peak = PeakSample.ComputePeak(buffer, buffer.Length, false, 16);
+
+ Assert.Equal(0.5f, peak, 5); // 16384 / 32768
+ }
+
+ [Fact]
+ public void ComputePeak_16BitPcm_Should_HandleFullScale()
+ {
+ var buffer = new byte[2];
+ WriteInt16(buffer, 0, short.MinValue); // -32768
+
+ var peak = PeakSample.ComputePeak(buffer, buffer.Length, false, 16);
+
+ Assert.Equal(1.0f, peak, 5);
+ }
+
+ // ---- 32-bit float ----
+
+ [Fact]
+ public void ComputePeak_IeeeFloat_Should_FindMaximumAbsoluteSample()
+ {
+ var buffer = new byte[8];
+ BitConverter.GetBytes(-0.25f).CopyTo(buffer, 0);
+ BitConverter.GetBytes(1.0f).CopyTo(buffer, 4);
+
+ var peak = PeakSample.ComputePeak(buffer, buffer.Length, true, 32);
+
+ Assert.Equal(1.0f, peak, 5);
+ }
+
+ // ---- 32-bit PCM ----
+
+ [Fact]
+ public void ComputePeak_32BitPcm_Should_FindMaximumAbsoluteSample()
+ {
+ var buffer = new byte[8];
+ BitConverter.GetBytes(0x20000000).CopyTo(buffer, 0); // 536870912
+ BitConverter.GetBytes(unchecked((int)0x80000000)).CopyTo(buffer, 4); // int.MinValue
+
+ var peak = PeakSample.ComputePeak(buffer, buffer.Length, false, 32);
+
+ Assert.Equal(1.0f, peak, 5);
+ }
+
+ // ---- 边界 ----
+
+ [Fact]
+ public void ComputePeak_EmptyOrNull_Should_ReturnZero()
+ {
+ Assert.Equal(0f, PeakSample.ComputePeak(Array.Empty(), 0, false, 16));
+ Assert.Equal(0f, PeakSample.ComputePeak(new byte[] { 1, 2 }, 0, false, 16));
+ }
+
+ [Fact]
+ public void ComputePeak_UnknownFormat_Should_ReturnZero()
+ {
+ // 24-bit 等未支持格式不应抛异常
+ var buffer = new byte[3];
+ var peak = PeakSample.ComputePeak(buffer, buffer.Length, false, 24);
+ Assert.Equal(0f, peak);
+ }
+
+ private static void WriteInt16(byte[] buffer, int offset, short value)
+ {
+ buffer[offset] = (byte)(value & 0xFF);
+ buffer[offset + 1] = (byte)((value >> 8) & 0xFF);
+ }
+}
From 902060604047cfcfdba638a7d116c92bc4fcdbb3 Mon Sep 17 00:00:00 2001
From: Starsky16 <163619709+Starsky16@users.noreply.github.com>
Date: Tue, 15 Sep 2026 02:01:25 +0800
Subject: [PATCH 02/25] =?UTF-8?q?refactor:=20=E7=BB=84=E4=BB=B6=E6=94=B9?=
=?UTF-8?q?=E7=94=A8=E5=85=B1=E4=BA=AB=E9=87=87=E6=A0=B7=E6=9C=8D=E5=8A=A1?=
=?UTF-8?q?=E5=B9=B6=E7=B2=BE=E7=AE=80=E7=BB=84=E4=BB=B6=E8=AE=BE=E7=BD=AE?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- DecibelComponent 从宿主 DI 获取 AudioPeakMeter 与 DecibelMonitorSettingsService,改用 DecibelCalculator 做线性峰值到显示刻度的换算,不再自行枚举设备并三级回退采样。
- 数值改用 AvaloniaProperty(StyledProperty)绑定,移除手写的 INotifyPropertyChanged;采样 Tick 增加节流,避免异步任务堆积;放大倍数取自全局设置,同一麦克风所有组件数值一致。
- 组件设置控件精简为显示侧偏好(分贝前缀、更新频率);测量/校准与提醒设置分别移至插件设置页与通知提供方设置。
- Plugin 移除重复的 GetVoicePeakValue()。
- 编译警告由 3 个降为 0 个(原 CS0108/CS4014 随旧实现一并移除)。
由 VS Code 中的 Cline 辅助生成。
---
.../DecibelComponentSettingsControl.axaml | 49 ++--
.../DecibelComponentSettingsControl.axaml.cs | 249 +-----------------
Controls/Components/DecibelComponent.axaml | 52 +---
Controls/Components/DecibelComponent.axaml.cs | 229 ++++------------
.../DecibelComponentSettings.cs | 17 +-
Plugin.cs | 12 +-
6 files changed, 104 insertions(+), 504 deletions(-)
diff --git a/Controls/ComponentSettings/DecibelComponentSettingsControl.axaml b/Controls/ComponentSettings/DecibelComponentSettingsControl.axaml
index 6e15a48..d6b6341 100644
--- a/Controls/ComponentSettings/DecibelComponentSettingsControl.axaml
+++ b/Controls/ComponentSettings/DecibelComponentSettingsControl.axaml
@@ -1,17 +1,3 @@
-
-
-
+
-
-
-
-
-
+
+
-
-
-
-
+
+
+
+
-
+
+
+
-
\ 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/25] =?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/25] =?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/25] =?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/25] =?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/25] =?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/25] =?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/25] =?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/25] =?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/25] =?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/25] =?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/25] =?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/25] =?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="窗口开启后等待按键确认的时间,超时则关闭窗口且不提醒;窗口开启期间组件状态点红绿闪动。">
Date: Sat, 26 Sep 2026 17:56:01 +0800
Subject: [PATCH 15/25] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=8F=90?=
=?UTF-8?q?=E9=86=92=E7=8A=B6=E6=80=81=E7=82=B9=E5=90=88=E5=B9=B6=E4=B8=8E?=
=?UTF-8?q?=E6=BB=9E=E5=9B=9E=E7=BA=AF=E9=80=BB=E8=BE=91=E8=A7=A3=E6=9E=90?=
=?UTF-8?q?=E5=99=A8=E5=8F=8A=E5=8D=95=E6=B5=8B?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
把散落在运行时服务(布尔 OR 压平)与组件(自行解释布尔)两处的提醒状态判断
收敛为一个枚举与一个纯函数式合并器,为后续「形状 + 填充 + 呼吸 + 数字颜色」
四通道显示改造提供唯一判定依据。
- 合并规则按「是否需要用户动手」排序:窗口 > 超阈值 > 冷却 > 正常 > 无数据,
窗口压制冷却,避免两源同时启用时「该按键」被长冷却盖掉
- 阈值迟滞(施密特触发):只作用于数字颜色判据,迟滞带 2.0 显示刻度单位
(1 单位 = 1 dB,等价 20*log10(0.8) 约 -1.9 dB),不影响判定源的提醒行为
- 时间滞回:升级立即生效、降级需新态持续 1 秒,计时用真实经过时间而非采样次数
- 冷却期内数字判据改用 1 秒短窗口平均;输出 Changed 标志与显式 Reset()
- 新增 32 条单测覆盖合并组合、优先级压制、两种滞回、判据切换与呼吸时机
---
Alerting/IndicatorStateResolver.cs | 292 +++++++++++++++
.../Decibel_Monitor.Tests.csproj | 1 +
.../IndicatorStateResolverTests.cs | 341 ++++++++++++++++++
3 files changed, 634 insertions(+)
create mode 100644 Alerting/IndicatorStateResolver.cs
create mode 100644 Tests/Decibel_Monitor.Tests/IndicatorStateResolverTests.cs
diff --git a/Alerting/IndicatorStateResolver.cs b/Alerting/IndicatorStateResolver.cs
new file mode 100644
index 0000000..dba152b
--- /dev/null
+++ b/Alerting/IndicatorStateResolver.cs
@@ -0,0 +1,292 @@
+using System;
+
+namespace Decibel_Monitor.Alerting;
+
+///
+/// 提醒状态点的离散状态(组件据此选择形状与填充,不再自行解释布尔条件)。
+///
+public enum IndicatorState
+{
+ /// 无采样,或未启用任何判定源:不显示任何形状。
+ NoData,
+
+ /// 有采样且各判定条件均不成立。
+ Normal,
+
+ /// 任一判定源当前处于"超过阈值"状态(提醒已由仲裁模块发出)。
+ Alerting,
+
+ /// 任一判定源处于提醒后的冷却期(提醒已经发生过)。
+ CoolingDown,
+
+ /// 热键筛选窗口开启,等待按键确认——唯一需要用户动手的状态。
+ AwaitingHotkey
+}
+
+///
+/// 数字颜色的判据结果:当前值是否已满足告警条件(与 相互独立)。
+///
+public enum IndicatorValueState
+{
+ /// 无采样 / 未启用任何判定源:数字使用默认色。
+ Unknown,
+
+ /// 判据不成立:数字为绿。
+ Below,
+
+ /// 判据成立:数字为红。
+ Above
+}
+
+///
+/// 一次状态解析的输入(由运行时服务在每个采样周期构造)。
+///
+///
+/// 输入均为"已压平"的布尔量与数值,不携带判定源身份——状态点不表达源身份,
+/// 两个源同时启用时按"是否需要用户动手"取最高者。
+///
+public readonly record struct IndicatorInputs
+{
+ /// 是否已取得过至少一次采样。
+ public bool HasSample { get; init; }
+
+ /// 是否至少启用了一个判定源。
+ public bool AnySourceEnabled { get; init; }
+
+ /// 任一判定源的热键筛选窗口是否开启。
+ public bool AnyWindowOpen { get; init; }
+
+ /// 任一判定源是否处于"超过阈值"状态。
+ public bool AnyTriggerActive { get; init; }
+
+ /// 任一判定源是否处于提醒后的冷却期。
+ public bool AnyCoolingDown { get; init; }
+
+ /// 判定用的滑动窗口平均分贝(显示刻度 0..150)。
+ public double AverageDecibel { get; init; }
+
+ /// 1 秒短窗口平均分贝(显示刻度 0..150),冷却期内作为数字颜色判据。
+ public double ShortWindowAverageDecibel { get; init; }
+
+ ///
+ /// 数字颜色判据的阈值(显示刻度 0..150)。多个判定源同时启用时应传入其中最小者:
+ /// 各源判据共用同一个数值,因此"任一源超阈"等价于"数值超过最小阈值"。
+ ///
+ public double Threshold { get; init; }
+
+ /// 热键筛选窗口的剩余时间(无窗口时为 )。
+ public TimeSpan HotkeyWindowRemaining { get; init; }
+
+ /// 热键筛选窗口的开启时长(用于换算呼吸提前量)。
+ public TimeSpan HotkeyWindowTimeout { get; init; }
+}
+
+///
+/// 一次状态解析的结果。
+///
+/// 合并后的提醒状态(已套用时间滞回)。
+/// 数字颜色的判据结果(已套用阈值迟滞)。
+/// 状态与上一次解析结果相比是否发生变化。
+/// 状态或数字判据任一发生变化(组件据此跳过无变化的视觉重设)。
+/// 当前是否应呼吸(仅窗口即将超时时为真)。
+public readonly record struct IndicatorSnapshot(
+ IndicatorState State,
+ IndicatorValueState ValueState,
+ bool StateChanged,
+ bool Changed,
+ bool IsBreathing);
+
+///
+/// 提醒状态点解析器:把各判定源的压平状态合并为单一 ,
+/// 并给出数字颜色判据与呼吸时机。
+///
+///
+/// 设计约束(纯逻辑,不依赖 Avalonia / NAudio / Windows,可在任意平台单测):
+///
+/// - 合并规则按"是否需要用户动手"排序:窗口 > 超阈值 > 冷却 > 正常 > 无数据。
+/// 窗口必须压制冷却——两个源同时启用时自动源的冷却可达 1800 秒,若按严重度排序会把"该按键"盖掉。
+/// - 状态不携带源身份:单点无法表达 2 源 × 4 态,不做并排图标。
+/// - 阈值迟滞(施密特触发)只作用于数字颜色判据,用于消除判据在阈值附近的抖动引起的颜色闪变;
+/// 判定源自身的触发逻辑保持不变,因此不影响提醒行为。
+/// - 时间滞回:升级立即生效,降级需新态持续 。
+/// 用于消除"两个源交替报警"造成的合并结果抖动。
+/// - 时间基准:全部计时使用传入的 UTC 时间戳相减(真实经过时间),
+/// 不按采样次数累加——采样间隔可配,按次数计会让"1 秒"的语义随配置漂移。
+///
+///
+public sealed class IndicatorStateResolver
+{
+ ///
+ /// 降级保持时长:切换到更低优先级的态需新态持续该时长才生效(升级不受限制,立即生效)。
+ ///
+ public static readonly TimeSpan DowngradeHoldDuration = TimeSpan.FromSeconds(1);
+
+ ///
+ /// 数字颜色判据的迟滞带宽(显示刻度单位,1 单位 = 1 dB)。
+ ///
+ ///
+ /// 参照 BigScreenClock 的 HysteresisFactor = 0.8(RMS 线性域,等价 20·log10(0.8) ≈ -1.9 dB)。
+ /// 本插件的阈值以显示刻度存储(displayDb = 150 + dBFS,与 dB 为 1:1 的绝对量),
+ /// 因此换算为绝对差:退出阈值 = 进入阈值 − 2,不能直接搬用 0.8 的乘法系数。
+ ///
+ public const double HysteresisBandDb = 2.0;
+
+ /// 呼吸提前量的上限:剩余时间进入该值后才开始呼吸。
+ public static readonly TimeSpan MaxBreathLeadTime = TimeSpan.FromSeconds(3);
+
+ private readonly double _hysteresisBandDb;
+
+ private IndicatorState _state = IndicatorState.NoData;
+ private IndicatorValueState _valueState = IndicatorValueState.Unknown;
+ private bool _valueAboveThreshold;
+ private bool _hasSnapshot;
+ private IndicatorState _pendingState = IndicatorState.NoData;
+ private DateTime _pendingSinceUtc = DateTime.MinValue;
+
+ /// 数字判据的迟滞带宽(显示刻度单位);为负值或 0 时表示不迟滞。
+ public IndicatorStateResolver(double hysteresisBandDb = HysteresisBandDb)
+ {
+ _hysteresisBandDb = hysteresisBandDb > 0.0 ? hysteresisBandDb : 0.0;
+ }
+
+ /// 当前状态(最近一次解析的结果)。
+ public IndicatorState State => _state;
+
+ ///
+ /// 合并规则本体:按"是否需要用户动手"排序取最高者,不含任何滞回。
+ ///
+ ///
+ /// 顺序:① 任一源窗口开启 → ;
+ /// ② 任一源超阈值 → ;
+ /// ③ 任一源冷却中 → ;
+ /// ④ 有采样且至少启用一个判定源 → ;
+ /// ⑤ 否则 → (无采样或未启用任何判定源时都不显示形状)。
+ ///
+ public static IndicatorState ResolveCandidate(in IndicatorInputs inputs)
+ {
+ if (inputs.AnyWindowOpen) return IndicatorState.AwaitingHotkey;
+ if (inputs.AnyTriggerActive) return IndicatorState.Alerting;
+ if (inputs.AnyCoolingDown) return IndicatorState.CoolingDown;
+ if (inputs.HasSample && inputs.AnySourceEnabled) return IndicatorState.Normal;
+ return IndicatorState.NoData;
+ }
+
+ ///
+ /// 是否应呼吸:仅窗口开启且剩余时间进入 min(3 秒, 窗口时长 / 4) 时为真。
+ /// 窗口开启期间其余时间静置,低阈值用法下窗口长期开启也不会持续闪烁。
+ ///
+ /// 当前状态。
+ /// 窗口剩余时间。
+ /// 窗口开启时长。
+ public static bool ShouldBreathe(IndicatorState state, TimeSpan windowRemaining, TimeSpan windowTimeout)
+ {
+ if (state != IndicatorState.AwaitingHotkey) return false;
+
+ var fraction = windowTimeout > TimeSpan.Zero
+ ? TimeSpan.FromTicks(windowTimeout.Ticks / 4)
+ : TimeSpan.Zero;
+ var lead = fraction < MaxBreathLeadTime ? fraction : MaxBreathLeadTime;
+ var remaining = windowRemaining > TimeSpan.Zero ? windowRemaining : TimeSpan.Zero;
+ return remaining <= lead;
+ }
+
+ ///
+ /// 用当前输入解析一次状态。
+ ///
+ /// 当前输入。
+ /// 当前时间(UTC),用于时间滞回计时。
+ public IndicatorSnapshot Update(in IndicatorInputs inputs, DateTime nowUtc)
+ {
+ var candidate = ResolveCandidate(inputs);
+ var state = ApplyDowngradeHold(candidate, nowUtc);
+
+ // 数字颜色判据:冷却期内改用 1 秒短窗口平均(冷却期不会再触发提醒,不存在"红了却不提醒"的错位;
+ // 继续用判定用的长平均窗口会因滞后而在已经安静后仍显示红色);其余情形与触发判定同源。
+ var value = state == IndicatorState.CoolingDown ? inputs.ShortWindowAverageDecibel : inputs.AverageDecibel;
+ var valueState = ResolveValueState(inputs.HasSample && inputs.AnySourceEnabled, value, inputs.Threshold);
+
+ var stateChanged = !_hasSnapshot || state != _state;
+ var valueChanged = !_hasSnapshot || valueState != _valueState;
+ var changed = stateChanged || valueChanged;
+
+ _hasSnapshot = true;
+ _state = state;
+ _valueState = valueState;
+
+ var breathing = ShouldBreathe(state, inputs.HotkeyWindowRemaining, inputs.HotkeyWindowTimeout);
+ return new IndicatorSnapshot(state, valueState, stateChanged, changed, breathing);
+ }
+
+ ///
+ /// 清空全部内部状态(设备切换、采样重启时调用,避免残留上一次的红色与迟滞标记)。
+ ///
+ public void Reset()
+ {
+ _state = IndicatorState.NoData;
+ _valueState = IndicatorValueState.Unknown;
+ _valueAboveThreshold = false;
+ _hasSnapshot = false;
+ _pendingState = IndicatorState.NoData;
+ _pendingSinceUtc = DateTime.MinValue;
+ }
+
+ /// 数字判据:进入用名义阈值,退出用"名义阈值 − 迟滞带宽"(施密特触发)。
+ private IndicatorValueState ResolveValueState(bool hasValue, double value, double threshold)
+ {
+ if (!hasValue) return IndicatorValueState.Unknown;
+
+ var exitThreshold = threshold - _hysteresisBandDb;
+
+ if (_valueAboveThreshold)
+ {
+ // 与进入时的严格比较对称:必须低于退出阈值才回落
+ if (value < exitThreshold) _valueAboveThreshold = false;
+ }
+ else if (value > threshold)
+ {
+ _valueAboveThreshold = true;
+ }
+
+ return _valueAboveThreshold ? IndicatorValueState.Above : IndicatorValueState.Below;
+ }
+
+ ///
+ /// 时间滞回:升级(含同级)立即生效;降级需新态持续 ,
+ /// 期间若新态又变回更高的态则取消待决。
+ ///
+ private IndicatorState ApplyDowngradeHold(IndicatorState candidate, DateTime nowUtc)
+ {
+ if (Priority(candidate) >= Priority(_state))
+ {
+ ClearPending();
+ return candidate;
+ }
+
+ if (_pendingState != candidate)
+ {
+ _pendingState = candidate;
+ _pendingSinceUtc = nowUtc;
+ }
+
+ if (nowUtc - _pendingSinceUtc < DowngradeHoldDuration) return _state;
+
+ ClearPending();
+ return candidate;
+ }
+
+ private void ClearPending()
+ {
+ _pendingState = IndicatorState.NoData;
+ _pendingSinceUtc = DateTime.MinValue;
+ }
+
+ /// 状态优先级:数值越大越"需要用户动手"。
+ private static int Priority(IndicatorState state) => state switch
+ {
+ IndicatorState.AwaitingHotkey => 4,
+ IndicatorState.Alerting => 3,
+ IndicatorState.CoolingDown => 2,
+ IndicatorState.Normal => 1,
+ _ => 0,
+ };
+}
\ No newline at end of file
diff --git a/Tests/Decibel_Monitor.Tests/Decibel_Monitor.Tests.csproj b/Tests/Decibel_Monitor.Tests/Decibel_Monitor.Tests.csproj
index 8e534d6..77cf643 100644
--- a/Tests/Decibel_Monitor.Tests/Decibel_Monitor.Tests.csproj
+++ b/Tests/Decibel_Monitor.Tests/Decibel_Monitor.Tests.csproj
@@ -27,6 +27,7 @@
+
diff --git a/Tests/Decibel_Monitor.Tests/IndicatorStateResolverTests.cs b/Tests/Decibel_Monitor.Tests/IndicatorStateResolverTests.cs
new file mode 100644
index 0000000..b9a8510
--- /dev/null
+++ b/Tests/Decibel_Monitor.Tests/IndicatorStateResolverTests.cs
@@ -0,0 +1,341 @@
+using System;
+using Decibel_Monitor.Alerting;
+
+namespace Decibel_Monitor.Tests;
+
+public class IndicatorStateResolverTests
+{
+ private const double Threshold = 120.0;
+
+ /// 构造一次解析输入(默认:有采样、已启用判定源、条件均不成立、数值低于阈值)。
+ private static IndicatorInputs Inputs(
+ bool hasSample = true,
+ bool anySourceEnabled = true,
+ bool windowOpen = false,
+ bool triggerActive = false,
+ bool coolingDown = false,
+ double average = 100.0,
+ double shortAverage = 100.0,
+ double threshold = Threshold,
+ TimeSpan? windowRemaining = null,
+ TimeSpan? windowTimeout = null) => new()
+ {
+ HasSample = hasSample,
+ AnySourceEnabled = anySourceEnabled,
+ AnyWindowOpen = windowOpen,
+ AnyTriggerActive = triggerActive,
+ AnyCoolingDown = coolingDown,
+ AverageDecibel = average,
+ ShortWindowAverageDecibel = shortAverage,
+ Threshold = threshold,
+ HotkeyWindowRemaining = windowRemaining ?? TimeSpan.Zero,
+ HotkeyWindowTimeout = windowTimeout ?? TimeSpan.Zero,
+ };
+
+ // ---- 合并规则:两源全开的全部组合 ----
+
+ [Theory]
+ [InlineData(true, false, false)] // 窗口开 → 最高优先级
+ [InlineData(true, true, false)]
+ [InlineData(true, false, true)]
+ [InlineData(true, true, true)] // 窗口开 + 另一源冷却中:窗口必须压制冷却
+ public void ResolveCandidate_Should_ReturnAwaitingHotkey_When_WindowOpen(bool windowOpen, bool trigger, bool cooling)
+ {
+ var state = IndicatorStateResolver.ResolveCandidate(Inputs(windowOpen: windowOpen, triggerActive: trigger, coolingDown: cooling));
+
+ Assert.Equal(IndicatorState.AwaitingHotkey, state);
+ }
+
+ [Fact]
+ public void ResolveCandidate_Should_ReturnAlerting_When_TriggerActiveWithoutWindow()
+ {
+ var state = IndicatorStateResolver.ResolveCandidate(Inputs(triggerActive: true, coolingDown: true));
+
+ Assert.Equal(IndicatorState.Alerting, state);
+ }
+
+ [Fact]
+ public void ResolveCandidate_Should_ReturnCoolingDown_When_OnlyCooling()
+ {
+ var state = IndicatorStateResolver.ResolveCandidate(Inputs(coolingDown: true));
+
+ Assert.Equal(IndicatorState.CoolingDown, state);
+ }
+
+ [Fact]
+ public void ResolveCandidate_Should_ReturnNormal_When_NothingActive()
+ {
+ var state = IndicatorStateResolver.ResolveCandidate(Inputs());
+
+ Assert.Equal(IndicatorState.Normal, state);
+ }
+
+ [Fact]
+ public void ResolveCandidate_Should_ReturnNoData_When_NoSample()
+ {
+ var state = IndicatorStateResolver.ResolveCandidate(Inputs(hasSample: false));
+
+ Assert.Equal(IndicatorState.NoData, state);
+ }
+
+ [Fact]
+ public void ResolveCandidate_Should_ReturnNoData_When_NoSourceEnabled()
+ {
+ // 未启用任何判定源时不显示形状(与现状一致),即使采样正常
+ var state = IndicatorStateResolver.ResolveCandidate(Inputs(anySourceEnabled: false));
+
+ Assert.Equal(IndicatorState.NoData, state);
+ }
+
+ // ---- 时间滞回 ----
+
+ [Fact]
+ public void Update_Should_ApplyUpgradeImmediately()
+ {
+ var resolver = new IndicatorStateResolver();
+ var t0 = new DateTime(2026, 9, 26, 0, 0, 0, DateTimeKind.Utc);
+
+ var snapshot = resolver.Update(Inputs(triggerActive: true), t0);
+
+ Assert.Equal(IndicatorState.Alerting, snapshot.State);
+ Assert.True(snapshot.StateChanged);
+ }
+
+ [Fact]
+ public void Update_Should_HoldDowngrade_UntilNewStatePersistsOneSecond()
+ {
+ var resolver = new IndicatorStateResolver();
+ var t0 = new DateTime(2026, 9, 26, 0, 0, 0, DateTimeKind.Utc);
+ resolver.Update(Inputs(triggerActive: true), t0);
+
+ var held = resolver.Update(Inputs(), t0.AddMilliseconds(300));
+ Assert.Equal(IndicatorState.Alerting, held.State);
+ Assert.False(held.StateChanged);
+
+ var stillHeld = resolver.Update(Inputs(), t0.AddMilliseconds(1200));
+ Assert.Equal(IndicatorState.Alerting, stillHeld.State);
+
+ var released = resolver.Update(Inputs(), t0.AddMilliseconds(1400));
+ Assert.Equal(IndicatorState.Normal, released.State);
+ Assert.True(released.StateChanged);
+ }
+
+ [Fact]
+ public void Update_Should_CancelPendingDowngrade_When_HigherStateReturns()
+ {
+ var resolver = new IndicatorStateResolver();
+ var t0 = new DateTime(2026, 9, 26, 0, 0, 0, DateTimeKind.Utc);
+ resolver.Update(Inputs(triggerActive: true), t0);
+
+ // 第一次回落:起算待决
+ resolver.Update(Inputs(), t0.AddMilliseconds(300));
+
+ // 又回到高优先级态:待决必须被取消
+ var backToAlerting = resolver.Update(Inputs(triggerActive: true), t0.AddMilliseconds(500));
+ Assert.Equal(IndicatorState.Alerting, backToAlerting.State);
+
+ // 再次回落:从此刻重新起算,1 秒不足以降级
+ var held = resolver.Update(Inputs(), t0.AddMilliseconds(1600));
+ Assert.Equal(IndicatorState.Alerting, held.State);
+
+ var released = resolver.Update(Inputs(), t0.AddMilliseconds(2700));
+ Assert.Equal(IndicatorState.Normal, released.State);
+ }
+
+ [Fact]
+ public void Update_Should_ReleaseDowngradeByRealElapsedTime_NotByCallCount()
+ {
+ // 采样间隔可配:调用次数多但真实经过时间不足时不得降级
+ var resolver = new IndicatorStateResolver();
+ var t0 = new DateTime(2026, 9, 26, 0, 0, 0, DateTimeKind.Utc);
+ resolver.Update(Inputs(triggerActive: true), t0);
+
+ for (var i = 0; i < 20; i++)
+ {
+ resolver.Update(Inputs(), t0.AddMilliseconds(10 * (i + 1)));
+ }
+
+ Assert.Equal(IndicatorState.Alerting, resolver.State);
+ Assert.Equal(IndicatorState.Normal, resolver.Update(Inputs(), t0.AddMilliseconds(1050)).State);
+ }
+
+ // ---- 数字颜色判据:阈值迟滞 ----
+
+ [Fact]
+ public void Update_Should_NotReportAbove_When_ValueEqualsThreshold()
+ {
+ var resolver = new IndicatorStateResolver();
+
+ var snapshot = resolver.Update(Inputs(average: Threshold), DateTime.UtcNow);
+
+ Assert.Equal(IndicatorValueState.Below, snapshot.ValueState);
+ }
+
+ [Fact]
+ public void Update_Should_TurnValueStateAbove_When_ValueExceedsThreshold()
+ {
+ var resolver = new IndicatorStateResolver();
+
+ var snapshot = resolver.Update(Inputs(average: Threshold + 1), DateTime.UtcNow);
+
+ Assert.Equal(IndicatorValueState.Above, snapshot.ValueState);
+ }
+
+ [Fact]
+ public void Update_Should_KeepAbove_Until_ValueDropsBelowExitThreshold()
+ {
+ var resolver = new IndicatorStateResolver();
+ var now = DateTime.UtcNow;
+
+ resolver.Update(Inputs(average: Threshold + 1), now);
+
+ // 迟滞带内(退出阈值 = 120 - 2 = 118)不回落
+ Assert.Equal(IndicatorValueState.Above, resolver.Update(Inputs(average: Threshold - 0.5), now).ValueState);
+ Assert.Equal(IndicatorValueState.Above, resolver.Update(Inputs(average: Threshold - 2), now).ValueState);
+
+ // 越过 118 才回落
+ Assert.Equal(IndicatorValueState.Below, resolver.Update(Inputs(average: Threshold - 2.1), now).ValueState);
+ }
+
+ [Fact]
+ public void Update_Should_NotHysteresis_When_BandDisabled()
+ {
+ var resolver = new IndicatorStateResolver(0.0);
+ var now = DateTime.UtcNow;
+
+ resolver.Update(Inputs(average: Threshold + 1), now);
+
+ Assert.Equal(IndicatorValueState.Below, resolver.Update(Inputs(average: Threshold - 0.5), now).ValueState);
+ }
+
+ // ---- 数字颜色判据:冷却期改用 1 秒短窗口平均 ----
+
+ [Fact]
+ public void Update_Should_UseShortWindowAverage_When_CoolingDown()
+ {
+ var resolver = new IndicatorStateResolver();
+
+ // 冷却期:长平均仍高但短窗口已回落 → 绿
+ var snapshot = resolver.Update(
+ Inputs(coolingDown: true, average: 140.0, shortAverage: 100.0),
+ DateTime.UtcNow);
+
+ Assert.Equal(IndicatorState.CoolingDown, snapshot.State);
+ Assert.Equal(IndicatorValueState.Below, snapshot.ValueState);
+ }
+
+ [Fact]
+ public void Update_Should_ReportAbove_When_CoolingDownAndShortWindowStillHigh()
+ {
+ var resolver = new IndicatorStateResolver();
+
+ var snapshot = resolver.Update(
+ Inputs(coolingDown: true, average: 100.0, shortAverage: 140.0),
+ DateTime.UtcNow);
+
+ Assert.Equal(IndicatorState.CoolingDown, snapshot.State);
+ Assert.Equal(IndicatorValueState.Above, snapshot.ValueState);
+ }
+
+ [Fact]
+ public void Update_Should_UseLongAverage_When_NotCoolingDown()
+ {
+ var resolver = new IndicatorStateResolver();
+
+ // 非冷却期:只用长平均,短窗口值不参与
+ var snapshot = resolver.Update(
+ Inputs(triggerActive: true, average: 130.0, shortAverage: 100.0),
+ DateTime.UtcNow);
+
+ Assert.Equal(IndicatorValueState.Above, snapshot.ValueState);
+ }
+
+ [Fact]
+ public void Update_Should_ReturnUnknownValueState_When_NoSample()
+ {
+ var resolver = new IndicatorStateResolver();
+
+ var snapshot = resolver.Update(Inputs(hasSample: false, average: 140.0), DateTime.UtcNow);
+
+ Assert.Equal(IndicatorState.NoData, snapshot.State);
+ Assert.Equal(IndicatorValueState.Unknown, snapshot.ValueState);
+ }
+
+ // ---- 呼吸时机 ----
+
+ [Theory]
+ [InlineData(10, 3.0, false)] // 窗口 10 秒 → 提前量 min(3, 2.5) = 2.5 秒
+ [InlineData(10, 2.5, true)]
+ [InlineData(10, 2.0, true)]
+ [InlineData(20, 3.0, true)] // 窗口 20 秒 → 提前量 min(3, 5) = 3 秒
+ [InlineData(20, 3.1, false)]
+ [InlineData(40, 3.0, true)]
+ [InlineData(40, 3.1, false)]
+ public void ShouldBreathe_Should_DependOnRemainingTime(int windowSeconds, double remainingSeconds, bool expected)
+ {
+ var breathing = IndicatorStateResolver.ShouldBreathe(
+ IndicatorState.AwaitingHotkey,
+ TimeSpan.FromSeconds(remainingSeconds),
+ TimeSpan.FromSeconds(windowSeconds));
+
+ Assert.Equal(expected, breathing);
+ }
+
+ [Fact]
+ public void ShouldBreathe_Should_ReturnFalse_When_StateIsNotAwaitingHotkey()
+ {
+ Assert.False(IndicatorStateResolver.ShouldBreathe(IndicatorState.Normal, TimeSpan.Zero, TimeSpan.FromSeconds(10)));
+ Assert.False(IndicatorStateResolver.ShouldBreathe(IndicatorState.Alerting, TimeSpan.Zero, TimeSpan.FromSeconds(10)));
+ Assert.False(IndicatorStateResolver.ShouldBreathe(IndicatorState.CoolingDown, TimeSpan.Zero, TimeSpan.FromSeconds(10)));
+ }
+
+ // ---- Changed 标志与 Reset ----
+
+ [Fact]
+ public void Update_Should_ReportChanged_When_ValueStateChangesOnly()
+ {
+ var resolver = new IndicatorStateResolver();
+ var now = DateTime.UtcNow;
+ var steady = resolver.Update(Inputs(average: 100.0), now);
+ Assert.True(steady.Changed);
+
+ // 状态不变、数字判据变红 → Changed 仍为真
+ var red = resolver.Update(Inputs(average: 130.0), now);
+ Assert.False(red.StateChanged);
+ Assert.True(red.Changed);
+
+ // 状态与判据都不变 → Changed 为假
+ var same = resolver.Update(Inputs(average: 130.0), now);
+ Assert.False(same.Changed);
+ }
+
+ [Fact]
+ public void Reset_Should_ClearStateAndHysteresis()
+ {
+ var resolver = new IndicatorStateResolver();
+ var now = DateTime.UtcNow;
+ resolver.Update(Inputs(triggerActive: true, average: 130.0), now);
+
+ resolver.Reset();
+ Assert.Equal(IndicatorState.NoData, resolver.State);
+
+ // Reset 后下一次解析必须报 Changed(组件需强制刷新视觉),且迟滞标记已清空
+ var snapshot = resolver.Update(Inputs(average: 100.0), now);
+ Assert.Equal(IndicatorState.Normal, snapshot.State);
+ Assert.True(snapshot.Changed);
+ Assert.Equal(IndicatorValueState.Below, snapshot.ValueState);
+ }
+
+ [Fact]
+ public void Reset_Should_NotHoldDowngrade()
+ {
+ // 设备切换后残留的红色必须立即消失,不经过 1 秒降级保持
+ var resolver = new IndicatorStateResolver();
+ var t0 = new DateTime(2026, 9, 26, 0, 0, 0, DateTimeKind.Utc);
+ resolver.Update(Inputs(triggerActive: true), t0);
+
+ resolver.Reset();
+
+ Assert.Equal(IndicatorState.Normal, resolver.Update(Inputs(), t0.AddMilliseconds(200)).State);
+ }
+}
\ No newline at end of file
From 7f4b3a9092e291371138611cb112a03b9019a3f8 Mon Sep 17 00:00:00 2001
From: Starsky16 <163619709+Starsky16@users.noreply.github.com>
Date: Sat, 26 Sep 2026 18:00:46 +0800
Subject: [PATCH 16/25] =?UTF-8?q?feat:=20=E6=8F=90=E9=86=92=E8=BF=90?=
=?UTF-8?q?=E8=A1=8C=E6=97=B6=E6=8E=A5=E5=85=A5=E7=8A=B6=E6=80=81=E7=82=B9?=
=?UTF-8?q?=E8=A7=A3=E6=9E=90=E5=99=A8=E5=B9=B6=E6=8C=89=E8=AE=BE=E5=A4=87?=
=?UTF-8?q?=E5=8F=AF=E7=94=A8=E6=80=A7=E5=88=A4=E5=AE=9A=E6=97=A0=E9=87=87?=
=?UTF-8?q?=E6=A0=B7?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
用 IndicatorStateResolver 替换运行时服务里布尔 OR 拼装的 IsAlertStateActive,
状态含义与滞回规则统一归解析器,服务只负责压平各源状态与维护两类窗口。
- 暴露 IndicatorState / IndicatorValueState / IsIndicatorBreathing / IndicatorStateChanged
供组件读取,不再暴露含义被压平的布尔量
- 新增 1 秒短窗口平均(独立于判定用的平均窗口),按真实时间戳剔除过期样本,
供冷却期数字判据使用
- 数字判据阈值取已启用判定源中的最小者(各源共用同一数值)
- HotkeyConfirmDecisionSource 新增 WindowOpenUntilUtc,供计算窗口剩余时间与呼吸时机
- 默认捕获设备变化(含被拔出/禁用后为 null)时 Reset 解析器与短窗口,避免残留红色;
无可用捕获设备时视为无采样,状态切 NoData、数值显示 N/A,不再伪装成绿色正常
- 组件仅为保持编译通过做最小适配,视觉行为与改动前一致(视觉改造在下一阶段)
---
Alarm/AlertRuntimeService.cs | 127 +++++++++++++++---
Alerting/HotkeyConfirmDecisionSource.cs | 6 +
Controls/Components/DecibelComponent.axaml.cs | 12 +-
.../HotkeyConfirmDecisionSourceTests.cs | 15 +++
4 files changed, 139 insertions(+), 21 deletions(-)
diff --git a/Alarm/AlertRuntimeService.cs b/Alarm/AlertRuntimeService.cs
index 7f11912..aa3c962 100644
--- a/Alarm/AlertRuntimeService.cs
+++ b/Alarm/AlertRuntimeService.cs
@@ -24,6 +24,8 @@ namespace Decibel_Monitor.Alarm;
/// - 通知的唯一出口是仲裁模块的 事件;
/// 本服务把它接到现有 ,强调通知与语音由宿主通知系统负责,
/// 不新增通知/语音开关。
+/// - 提醒状态点由 解析:本服务只负责压平各判定源状态、
+/// 维护判定用的平均窗口与冷却期判据所用的 1 秒短窗口,状态含义与滞回规则全部归解析器(纯逻辑、可单测)。
///
///
public sealed class AlertRuntimeService : IHostedService, IDisposable
@@ -37,35 +39,44 @@ public sealed class AlertRuntimeService : IHostedService, IDisposable
/// 平均窗口允许的样本数上限(防止窗口时长配置过大时无界占用内存)。
private const int MaxWindowSamples = 2000;
+ /// 冷却期数字判据所用的短窗口时长(固定 1 秒,与判定用的平均窗口相互独立)。
+ private static readonly TimeSpan ShortWindowDuration = TimeSpan.FromSeconds(1);
+
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 Queue<(DateTime TimestampUtc, float Value)> _shortWindow = new();
+ private readonly IndicatorStateResolver _indicatorResolver = new();
private readonly DispatcherTimer _timer;
private DateTime _pauseUntilUtc = DateTime.MinValue;
private string[]? _appliedPriorityOrder;
+ private string? _lastDeviceDescription;
private bool _disposed;
/// 最新一次采样的分贝映射值(显示刻度 0..150)。
public double CurrentDecibel { get; private set; }
- /// 是否已取得过至少一次采样(未取得时组件显示占位文本)。
+ /// 当前是否存在可用的默认捕获设备并已取得采样(否则组件显示占位文本与无形状状态)。
public bool HasSample { get; private set; }
- ///
- /// 当前是否应点亮提醒状态点(红):任一判定源处于超阈值、筛选窗口开启或冷却期。
- /// 绿点表示上述条件均不成立。
- ///
- public bool IsAlertStateActive { get; private set; }
+ /// 提醒状态点的当前状态(组件据此选择形状与填充)。
+ public IndicatorState IndicatorState { get; private set; } = IndicatorState.NoData;
+
+ /// 数字颜色的判据结果:当前值是否已满足告警条件。
+ public IndicatorValueState IndicatorValueState { get; private set; } = IndicatorValueState.Unknown;
+
+ /// 筛选窗口剩余时间进入呼吸阈值后为真(组件据此让三角呼吸)。
+ public bool IsIndicatorBreathing { get; private set; }
+
+ /// 本次解析中状态或数字判据是否发生变化(组件据此跳过无变化的视觉重设)。
+ public bool IndicatorStateChanged { get; private set; }
/// 是否至少启用了一个判定源(决定组件是否显示提醒状态点)。
public bool IsAnySourceEnabled => _autoSource.IsEnabled || _hotkeySource.IsEnabled;
- /// 热键判定源所处的筛选窗口是否开启(组件据此把状态点显示为红绿闪动)。
- public bool IsHotkeyWindowOpen => _hotkeySource.IsWindowOpen;
-
/// 共享的麦克风峰值采样服务。
/// 插件全局设置服务(可空;缺省时使用判定源内置默认参数)。
/// 仲裁模块(自持判定源集合与优先级配置)。
@@ -125,6 +136,27 @@ private async void Timer_Tick(object? sender, EventArgs e)
// 防自激:执行提醒后 3 秒内暂停采样与判定
if (DateTime.UtcNow < _pauseUntilUtc) return;
+ var nowUtc = DateTime.UtcNow;
+
+ // 默认捕获设备发生变化(含被拔出/禁用后为 null)时清空解析器状态与短窗口,
+ // 避免残留上一次的红色与迟滞标记
+ var deviceDescription = _audioPeakMeter.GetDefaultCaptureDeviceDescription();
+ if (deviceDescription != _lastDeviceDescription)
+ {
+ _lastDeviceDescription = deviceDescription;
+ _indicatorResolver.Reset();
+ _shortWindow.Clear();
+ }
+
+ // 无默认捕获设备时视为无采样:状态点不显示任何形状、数字显示占位文本,
+ // 不再伪装成"绿色正常"(仍需解析一次,否则组件读到的是上一次的状态)
+ if (deviceDescription is null)
+ {
+ HasSample = false;
+ UpdateIndicator(nowUtc, average: 0.0, shortAverage: 0.0);
+ return;
+ }
+
var linear = await _audioPeakMeter.GetDefaultDevicePeakLinearAsync();
var magnification = _settingsService?.Settings.Magnification ?? 1.0;
var mapped = DecibelCalculator.LinearToDisplayDb(linear, magnification);
@@ -137,15 +169,15 @@ private async void Timer_Tick(object? sender, EventArgs e)
while (_window.Count > capacity) _window.RemoveAt(0);
var average = AverageVolumeAlertEvaluator.CalculateAverage(_window);
- var decision = _coordinator.Decide(new AlertContext(mapped, average), DateTime.UtcNow);
+ // 维护 1 秒短窗口平均(供冷却期数字判据使用,与判定用的平均窗口相互独立):
+ // 先入队当前样本,再按真实时间戳剔除 1 秒之前的样本
+ _shortWindow.Enqueue((nowUtc, (float)mapped));
+ var shortAverage = GetShortWindowAverage(nowUtc, mapped);
- // 提醒状态点:超阈值、筛选窗口开启、以及提醒后的冷却期都点亮(红点),其余为绿点
- var nowUtc = DateTime.UtcNow;
- IsAlertStateActive = _autoSource.IsTriggerActive
- || _hotkeySource.IsTriggerActive
- || _hotkeySource.IsWindowOpen
- || _autoSource.IsCoolingDown(nowUtc)
- || _hotkeySource.IsCoolingDown(nowUtc);
+ var decision = _coordinator.Decide(new AlertContext(mapped, average), nowUtc);
+
+ // 提醒状态点:由纯逻辑解析器把各判定源状态合并为单一枚举(取"是否需要用户动手"最高者)
+ UpdateIndicator(nowUtc, average, shortAverage);
if (!decision.ShouldAlert) return;
@@ -160,6 +192,67 @@ private async void Timer_Tick(object? sender, EventArgs e)
}
}
+ ///
+ /// 解析提醒状态并更新组件读取的显示属性。
+ ///
+ /// 当前时间(UTC)。
+ /// 判定用的滑动窗口平均分贝。
+ /// 冷却期数字判据所用的 1 秒短窗口平均分贝。
+ private void UpdateIndicator(DateTime nowUtc, double average, double shortAverage)
+ {
+ var windowOpen = _hotkeySource.IsWindowOpen;
+ var windowUntilUtc = _hotkeySource.WindowOpenUntilUtc;
+ var remaining = windowOpen && windowUntilUtc > nowUtc ? windowUntilUtc - nowUtc : TimeSpan.Zero;
+
+ var snapshot = _indicatorResolver.Update(new IndicatorInputs
+ {
+ HasSample = HasSample,
+ AnySourceEnabled = IsAnySourceEnabled,
+ AnyWindowOpen = windowOpen,
+ AnyTriggerActive = _autoSource.IsTriggerActive || _hotkeySource.IsTriggerActive,
+ AnyCoolingDown = _autoSource.IsCoolingDown(nowUtc) || _hotkeySource.IsCoolingDown(nowUtc),
+ AverageDecibel = average,
+ ShortWindowAverageDecibel = shortAverage,
+ Threshold = GetIndicatorThreshold(),
+ HotkeyWindowRemaining = remaining,
+ HotkeyWindowTimeout = _hotkeySource.WindowTimeout,
+ }, nowUtc);
+
+ IndicatorState = snapshot.State;
+ IndicatorValueState = snapshot.ValueState;
+ IsIndicatorBreathing = snapshot.IsBreathing;
+ IndicatorStateChanged = snapshot.Changed;
+ }
+
+ ///
+ /// 数字颜色判据的阈值:取已启用判定源中的最小阈值。
+ /// 各源的判据共用同一数值,因此"任一源超阈"等价于"数值超过最小阈值";未启用任何源时取 0(该情形下判据为未知)。
+ ///
+ private double GetIndicatorThreshold()
+ {
+ var threshold = double.MaxValue;
+ if (_autoSource.IsEnabled) threshold = Math.Min(threshold, _autoSource.Threshold);
+ if (_hotkeySource.IsEnabled) threshold = Math.Min(threshold, _hotkeySource.Threshold);
+ return threshold == double.MaxValue ? 0.0 : threshold;
+ }
+
+ ///
+ /// 剔除 1 秒之前的样本并返回短窗口平均;窗口内无样本时返回 (最近一次采样值)。
+ ///
+ private double GetShortWindowAverage(DateTime nowUtc, double fallback)
+ {
+ while (_shortWindow.Count > 0 && nowUtc - _shortWindow.Peek().TimestampUtc > ShortWindowDuration)
+ {
+ _shortWindow.Dequeue();
+ }
+
+ if (_shortWindow.Count == 0) return fallback;
+
+ double sum = 0.0;
+ foreach (var sample in _shortWindow) sum += sample.Value;
+ return sum / _shortWindow.Count;
+ }
+
/// 把插件全局设置同步到各判定源与仲裁模块(设置页改动下一拍即生效)。
private void ApplySettings()
{
diff --git a/Alerting/HotkeyConfirmDecisionSource.cs b/Alerting/HotkeyConfirmDecisionSource.cs
index 17af910..7bee91e 100644
--- a/Alerting/HotkeyConfirmDecisionSource.cs
+++ b/Alerting/HotkeyConfirmDecisionSource.cs
@@ -67,6 +67,12 @@ public HotkeyConfirmDecisionSource(
/// 当前筛选窗口是否开启(供组件显示提醒状态)。
public bool IsWindowOpen { get; private set; }
+ ///
+ /// 筛选窗口的截止时间(UTC)。窗口未开启时为
+ /// (供运行时服务计算剩余时间与呼吸时机)。
+ ///
+ public DateTime WindowOpenUntilUtc => IsWindowOpen ? _windowOpenUntilUtc : DateTime.MinValue;
+
/// 当前是否处于"超过阈值"状态(供组件显示提醒状态)。
public bool IsTriggerActive { get; private set; }
diff --git a/Controls/Components/DecibelComponent.axaml.cs b/Controls/Components/DecibelComponent.axaml.cs
index 09d7c36..695abfb 100644
--- a/Controls/Components/DecibelComponent.axaml.cs
+++ b/Controls/Components/DecibelComponent.axaml.cs
@@ -5,6 +5,7 @@
using ClassIsland.Core.Attributes;
using ClassIsland.Shared;
using Decibel_Monitor.Alarm;
+using Decibel_Monitor.Alerting;
using DecibelComponentSettings = Decibel_Monitor.Models.ComponentSettings.DecibelComponentSettings;
namespace Decibel_Monitor.Controls.Components;
@@ -118,7 +119,7 @@ private void UpdateTimer_Tick(object? sender, EventArgs e)
// 应用设置的更新频率(设置变化后下一拍即生效);
// 筛选窗口开启时收紧到闪烁半周期,保证红绿交替看得见
int intervalMs = Math.Clamp(Settings?.UpdateIntervalMs ?? 200, 100, 5000);
- if (_runtimeService?.IsHotkeyWindowOpen == true) intervalMs = Math.Min(intervalMs, BlinkHalfPeriodMs);
+ if (_runtimeService?.IndicatorState == IndicatorState.AwaitingHotkey) intervalMs = Math.Min(intervalMs, BlinkHalfPeriodMs);
if (Math.Abs(_updateTimer.Interval.TotalMilliseconds - intervalMs) > 0.5)
{
_updateTimer.Interval = TimeSpan.FromMilliseconds(intervalMs);
@@ -142,9 +143,12 @@ private void UpdateTimer_Tick(object? sender, EventArgs e)
// 单一状态点:红=提醒触发中(超阈值/冷却期),绿=正常;
// 筛选窗口开启时红绿交替闪动,与静态状态区分开
IsIndicatorVisible = showIndicator && _runtimeService.IsAnySourceEnabled;
- IsIndicatorAlert = _runtimeService.IsHotkeyWindowOpen
- ? Environment.TickCount64 / BlinkHalfPeriodMs % 2 == 0
- : _runtimeService.IsAlertStateActive;
+ IsIndicatorAlert = _runtimeService.IndicatorState switch
+ {
+ IndicatorState.AwaitingHotkey => Environment.TickCount64 / BlinkHalfPeriodMs % 2 == 0,
+ IndicatorState.Alerting or IndicatorState.CoolingDown => true,
+ _ => false,
+ };
}
catch (Exception)
{
diff --git a/Tests/Decibel_Monitor.Tests/HotkeyConfirmDecisionSourceTests.cs b/Tests/Decibel_Monitor.Tests/HotkeyConfirmDecisionSourceTests.cs
index e6de3e5..86b924f 100644
--- a/Tests/Decibel_Monitor.Tests/HotkeyConfirmDecisionSourceTests.cs
+++ b/Tests/Decibel_Monitor.Tests/HotkeyConfirmDecisionSourceTests.cs
@@ -122,6 +122,21 @@ public void HandleKeyPress_Should_ReturnFalse_WhenWindowNotOpen()
// ---- 窗口超时 ----
+ [Fact]
+ public void WindowOpenUntilUtc_Should_ExposeDeadlineOnlyWhileWindowOpen()
+ {
+ // 供运行时服务计算窗口剩余时间与呼吸时机:窗口关闭后不得残留过期截止时间
+ var source = CreateSource();
+ Assert.Equal(DateTime.MinValue, source.WindowOpenUntilUtc);
+
+ source.Decide(Context(120.0), Now);
+ Assert.Equal(Now + Window, source.WindowOpenUntilUtc);
+
+ source.Decide(Context(120.0), Now + Window + TimeSpan.FromMilliseconds(1));
+ Assert.False(source.IsWindowOpen);
+ Assert.Equal(DateTime.MinValue, source.WindowOpenUntilUtc);
+ }
+
[Fact]
public void Decide_Should_CloseWindowWithoutAlerting_WhenWindowTimedOut()
{
From 75d0a38e7018d44c9b8fdd074b561de45c7c9680 Mon Sep 17 00:00:00 2001
From: Starsky16 <163619709+Starsky16@users.noreply.github.com>
Date: Sat, 26 Sep 2026 18:10:52 +0800
Subject: [PATCH 17/25] =?UTF-8?q?feat:=20=E6=8F=90=E9=86=92=E7=8A=B6?=
=?UTF-8?q?=E6=80=81=E7=82=B9=E6=94=B9=E7=94=A8=E5=BD=A2=E7=8A=B6=E4=B8=8E?=
=?UTF-8?q?=E5=91=BC=E5=90=B8=E8=A1=A8=E8=BE=BE=EF=BC=8C=E6=95=B0=E5=AD=97?=
=?UTF-8?q?=E9=A2=9C=E8=89=B2=E9=9A=8F=E5=88=A4=E6=8D=AE=E5=8F=98=E5=8C=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
状态点由"单点红绿闪动"改为四通道编码,不再用红绿交替造成最紧急状态有一半时间
显示成正常:
- 形状 12px 互斥显示:常态低不透明度绿圆 / 超标红实心方 / 冷却期红空心方 /
待热键确认橙三角;无采样或未启用判定源时不显示任何形状
- 呼吸 Animation 驱动三角不透明度,触发点为窗口剩余时间 <= min(3s, WindowTimeout/4),
窗口开启期间静置
- 数字 Foreground 绑定判据颜色(冷却期用 1 秒短窗口平均),改用宿主主题语义画刷
SystemFillColorCriticalBrush / SystemFillColorSuccessBrush / SystemFillColorCautionBrush
- 删除 TickCount64 取模闪烁与窗口期 400ms 强制刷新;保留视觉树生命周期管理
- 形状与颜色只在 Changed 为真时重设,并用 _visualsApplied 在首次附着时强制应用一次
(状态未变化时 Changed 为假,否则新实例不会显示形状),分离时复位
---
Controls/Components/DecibelComponent.axaml | 56 +++++++-
Controls/Components/DecibelComponent.axaml.cs | 126 ++++++++++++++----
2 files changed, 150 insertions(+), 32 deletions(-)
diff --git a/Controls/Components/DecibelComponent.axaml b/Controls/Components/DecibelComponent.axaml
index 66c82da..bbead7a 100644
--- a/Controls/Components/DecibelComponent.axaml
+++ b/Controls/Components/DecibelComponent.axaml
@@ -10,20 +10,64 @@
+
+
+
+
+
+
+
-
+ Margin="5,0,0,0"
+ Classes.value-alert="{Binding IsValueAboveThreshold, Mode=OneWay}"
+ Classes.value-normal="{Binding IsValueWithinThreshold, Mode=OneWay}"/>
+
-
-
+
+
+
+
+
+
+
+
diff --git a/Controls/Components/DecibelComponent.axaml.cs b/Controls/Components/DecibelComponent.axaml.cs
index 695abfb..51292e5 100644
--- a/Controls/Components/DecibelComponent.axaml.cs
+++ b/Controls/Components/DecibelComponent.axaml.cs
@@ -22,22 +22,46 @@ public partial class DecibelComponent : ComponentBase
public static readonly StyledProperty CurrentDecibelValueProperty =
AvaloniaProperty.Register(nameof(CurrentDecibelValue), "N/A");
- /// 是否显示提醒状态点(任一判定源已启用且组件设置未关闭时显示)。
+ /// 是否显示提醒状态点(组件设置未关闭且存在有效采样时显示)。
public static readonly StyledProperty IsIndicatorVisibleProperty =
AvaloniaProperty.Register(nameof(IsIndicatorVisible));
- /// 提醒状态点是否为红色:红=提醒触发中(含冷却期),绿=正常。
- public static readonly StyledProperty IsIndicatorAlertProperty =
- AvaloniaProperty.Register(nameof(IsIndicatorAlert));
+ /// 常态是否显示绿圆。
+ public static readonly StyledProperty IsCircleVisibleProperty =
+ AvaloniaProperty.Register(nameof(IsCircleVisible));
- /// 筛选窗口开启时状态点的闪烁半周期(毫秒):红灯、绿灯各持续这么久。
- private const int BlinkHalfPeriodMs = 400;
+ /// 正在超阈值时是否显示红实心方。
+ public static readonly StyledProperty IsSquareFilledVisibleProperty =
+ AvaloniaProperty.Register(nameof(IsSquareFilledVisible));
+
+ /// 提醒后的冷却期是否显示红空心方。
+ public static readonly StyledProperty IsSquareHollowVisibleProperty =
+ AvaloniaProperty.Register(nameof(IsSquareHollowVisible));
+
+ /// 待热键确认时是否显示橙三角。
+ public static readonly StyledProperty IsTriangleVisibleProperty =
+ AvaloniaProperty.Register(nameof(IsTriangleVisible));
+
+ /// 三角是否呼吸(筛选窗口剩余时间已进入收紧区间)。
+ public static readonly StyledProperty IsBreathingProperty =
+ AvaloniaProperty.Register(nameof(IsBreathing));
+
+ /// 数字是否使用"判据成立"颜色(红)。
+ public static readonly StyledProperty IsValueAboveThresholdProperty =
+ AvaloniaProperty.Register(nameof(IsValueAboveThreshold));
+
+ /// 数字是否使用"判据不成立"颜色(绿)。
+ public static readonly StyledProperty IsValueWithinThresholdProperty =
+ AvaloniaProperty.Register(nameof(IsValueWithinThreshold));
private readonly DispatcherTimer _updateTimer;
private readonly AlertRuntimeService? _runtimeService;
private volatile bool _isActive;
private volatile bool _isUpdating;
+ /// 本实例是否已把视觉属性应用到控件上(首次附着时 Change 标志为假,需要强制应用一次)。
+ private bool _visualsApplied;
+
///
/// 当前显示的分贝值文本(Avalonia 属性,绑定自动响应变化)。
///
@@ -48,7 +72,7 @@ public string CurrentDecibelValue
}
///
- /// 是否显示提醒状态点(组件设置关闭或没有启用任何判定源时为 false)。
+ /// 是否显示提醒状态点(组件设置关闭或没有有效采样时为 false)。
///
public bool IsIndicatorVisible
{
@@ -56,14 +80,53 @@ public bool IsIndicatorVisible
private set => SetValue(IsIndicatorVisibleProperty, value);
}
- ///
- /// 提醒状态点是否为红色:红=提醒触发中(含冷却期),绿=正常;
- /// 筛选窗口开启时在红绿之间交替,形成闪动。
- ///
- public bool IsIndicatorAlert
+ /// 常态是否显示绿圆。
+ public bool IsCircleVisible
+ {
+ get => GetValue(IsCircleVisibleProperty);
+ private set => SetValue(IsCircleVisibleProperty, value);
+ }
+
+ /// 正在超阈值时是否显示红实心方。
+ public bool IsSquareFilledVisible
+ {
+ get => GetValue(IsSquareFilledVisibleProperty);
+ private set => SetValue(IsSquareFilledVisibleProperty, value);
+ }
+
+ /// 提醒后的冷却期是否显示红空心方。
+ public bool IsSquareHollowVisible
+ {
+ get => GetValue(IsSquareHollowVisibleProperty);
+ private set => SetValue(IsSquareHollowVisibleProperty, value);
+ }
+
+ /// 待热键确认时是否显示橙三角。
+ public bool IsTriangleVisible
+ {
+ get => GetValue(IsTriangleVisibleProperty);
+ private set => SetValue(IsTriangleVisibleProperty, value);
+ }
+
+ /// 三角是否呼吸(筛选窗口即将超时)。
+ public bool IsBreathing
+ {
+ get => GetValue(IsBreathingProperty);
+ private set => SetValue(IsBreathingProperty, value);
+ }
+
+ /// 数字是否使用"判据成立"颜色(红)。
+ public bool IsValueAboveThreshold
+ {
+ get => GetValue(IsValueAboveThresholdProperty);
+ private set => SetValue(IsValueAboveThresholdProperty, value);
+ }
+
+ /// 数字是否使用"判据不成立"颜色(绿)。
+ public bool IsValueWithinThreshold
{
- get => GetValue(IsIndicatorAlertProperty);
- private set => SetValue(IsIndicatorAlertProperty, value);
+ get => GetValue(IsValueWithinThresholdProperty);
+ private set => SetValue(IsValueWithinThresholdProperty, value);
}
public DecibelComponent()
@@ -103,6 +166,9 @@ protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e
{
base.OnDetachedFromVisualTree(e);
+ // 重新附着时状态未必发生变化(Change 标志为假),需要再次强制应用一次视觉属性
+ _visualsApplied = false;
+
if (!_isActive) return;
_isActive = false;
_updateTimer.Tick -= UpdateTimer_Tick;
@@ -116,10 +182,8 @@ private void UpdateTimer_Tick(object? sender, EventArgs e)
_isUpdating = true;
try
{
- // 应用设置的更新频率(设置变化后下一拍即生效);
- // 筛选窗口开启时收紧到闪烁半周期,保证红绿交替看得见
+ // 应用设置的更新频率(设置变化后下一拍即生效)
int intervalMs = Math.Clamp(Settings?.UpdateIntervalMs ?? 200, 100, 5000);
- if (_runtimeService?.IndicatorState == IndicatorState.AwaitingHotkey) intervalMs = Math.Min(intervalMs, BlinkHalfPeriodMs);
if (Math.Abs(_updateTimer.Interval.TotalMilliseconds - intervalMs) > 0.5)
{
_updateTimer.Interval = TimeSpan.FromMilliseconds(intervalMs);
@@ -140,15 +204,25 @@ private void UpdateTimer_Tick(object? sender, EventArgs e)
? (showPrefix ? $"分贝: {_runtimeService.CurrentDecibel:F1}" : $"{_runtimeService.CurrentDecibel:F1}")
: "N/A";
- // 单一状态点:红=提醒触发中(超阈值/冷却期),绿=正常;
- // 筛选窗口开启时红绿交替闪动,与静态状态区分开
- IsIndicatorVisible = showIndicator && _runtimeService.IsAnySourceEnabled;
- IsIndicatorAlert = _runtimeService.IndicatorState switch
- {
- IndicatorState.AwaitingHotkey => Environment.TickCount64 / BlinkHalfPeriodMs % 2 == 0,
- IndicatorState.Alerting or IndicatorState.CoolingDown => true,
- _ => false,
- };
+ var state = _runtimeService.IndicatorState;
+
+ // 无采样或未启用任何判定源时(解析结果 NoData)不显示任何形状
+ IsIndicatorVisible = showIndicator && state != IndicatorState.NoData;
+
+ // 呼吸的起止由时间推进而非状态变化触发,不在 Change 标志内,必须每拍读取
+ IsBreathing = _runtimeService.IsIndicatorBreathing;
+
+ // 形状与数字颜色是"离散状态",只在状态或判据变化时重设;
+ // 新实例首次附着时 Change 标志必为假,用 _visualsApplied 兜底强制应用一次
+ if (_visualsApplied && !_runtimeService.IndicatorStateChanged) return;
+
+ IsCircleVisible = state == IndicatorState.Normal;
+ IsSquareFilledVisible = state == IndicatorState.Alerting;
+ IsSquareHollowVisible = state == IndicatorState.CoolingDown;
+ IsTriangleVisible = state == IndicatorState.AwaitingHotkey;
+ IsValueAboveThreshold = _runtimeService.IndicatorValueState == IndicatorValueState.Above;
+ IsValueWithinThreshold = _runtimeService.IndicatorValueState == IndicatorValueState.Below;
+ _visualsApplied = true;
}
catch (Exception)
{
From b4ca1ebaab7efbd51434125b374fab1643a9ecaa Mon Sep 17 00:00:00 2001
From: Starsky16 <163619709+Starsky16@users.noreply.github.com>
Date: Sat, 26 Sep 2026 18:16:39 +0800
Subject: [PATCH 18/25] =?UTF-8?q?docs:=20=E6=9B=B4=E6=96=B0=E7=8A=B6?=
=?UTF-8?q?=E6=80=81=E7=82=B9=E5=BD=A2=E7=8A=B6=E5=AF=B9=E7=85=A7=E8=A1=A8?=
=?UTF-8?q?=E4=B8=8E=E7=9B=B8=E5=85=B3=E6=96=87=E6=A1=88?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
状态点由单点红绿闪动改为四形状体系后,README 与设置页文案仍停留在旧描述:
- README 新增「提醒状态点」小节:形状对照表(半透明绿圆=正常 / 红实心方=正在
超阈值 / 红空心方=冷却期 / 橙三角=待热键确认且窗口即将超时时呼吸)与数字颜色
判据说明(非冷却期用滑动平均值、冷却期用 1 秒短窗口平均、2 dB 迟滞、无采样不设色)
- 设置页「判定源②」与「筛选窗口时长」两处 Description 由红绿闪动改为橙色三角与呼吸提示
- 组件设置页说明与 ShowStatusIndicator 的 XML 注释同步为四形状语义
---
.../DecibelComponentSettingsControl.axaml | 2 +-
.../DecibelComponentSettings.cs | 4 +++-
README.md | 20 ++++++++++++++++++-
.../DecibelMonitorSettingsPage.axaml | 4 ++--
4 files changed, 25 insertions(+), 5 deletions(-)
diff --git a/Controls/ComponentSettings/DecibelComponentSettingsControl.axaml b/Controls/ComponentSettings/DecibelComponentSettingsControl.axaml
index 69694dd..afd7929 100644
--- a/Controls/ComponentSettings/DecibelComponentSettingsControl.axaml
+++ b/Controls/ComponentSettings/DecibelComponentSettingsControl.axaml
@@ -41,7 +41,7 @@
TextWrapping="Wrap"/>
/// 是否在组件上显示提醒状态点(默认 true)。
- /// 绿点=正常;红点=提醒触发中(超过阈值、筛选窗口开启或处于冷却期)。
+ /// 形状表达状态:绿圆=正常;红实心方=正在超阈值;红空心方=提醒后的冷却期;
+ /// 橙色三角=筛选窗口开启、等待热键确认(窗口即将超时时三角呼吸)。
+ /// 无采样或未启用任何判定源时不显示形状。
/// 关闭后仍会正常发出系统通知,只是不在组件上显示状态点。
///
[ObservableProperty] private bool _showStatusIndicator = true;
diff --git a/README.md b/README.md
index b4a0bef..4bc21e5 100644
--- a/README.md
+++ b/README.md
@@ -40,10 +40,28 @@
1. 打开 **设置 → 通知 → 分贝提醒**。
2. 开启“启用提醒”,设置阈值(dB)、提醒文字与冷却时间(分钟)。
3. 当分贝值超过阈值时:
- - 组件上显示红色提示文字;
+ - 组件上的提醒状态点变为红色(含义见下方“提醒状态点”);
- 通过 ClassIsland 通知系统弹出醒目提醒;
- 冷却时间内不会重复提醒(默认 10 分钟)。
+### 提醒状态点(组件上的形状)
+
+组件在分贝值右侧显示一个状态形状。形状表示“当前处于哪一类状态”,填充与呼吸表示附加信息;**无采样或未启用任何判定源时不显示任何形状**(分贝值显示 `N/A`)。
+
+| 形状 | 含义 | 说明 |
+| --- | --- | --- |
+| 绿色圆(半透明) | 正常 | 有采样、判定源已启用且未超过阈值 |
+| 红色实心方 | 正在超阈值 | 判定条件当前成立 |
+| 红色空心方 | 冷却期 | 提醒已发出,冷却期内不再重复提醒(空心表示“提醒已发生过”而非“正在触发”) |
+| 橙色三角 | 待热键确认 | 判定源②的筛选窗口已开启,需在窗口内按下热键;窗口剩余时间进入 `min(3 秒, 窗口时长 / 4)` 后三角会呼吸,提示尽快按键(窗口其余时间静置) |
+
+数字颜色与形状相互独立,只反映“当前值是否满足告警条件”:
+
+- **非冷却期**:判据取与触发判定同源的**滑动平均值**(平均窗口由判定源设置决定)。因此显示的数字瞬时值不高,也可能因窗口平均值超阈而变红。
+- **冷却期**:判据改用**最近 1 秒的短窗口平均**,反映“现在是否还在吵”,而不是整段滑动窗口的旧平均值;此时形状恒为空心方。
+- 判据带 2 dB 的迟滞,避免数值在阈值附近反复跳色(进入需超过阈值,退出需低于“阈值 − 2”)。
+- 无采样或未启用任何判定源时不设置颜色,沿用宿主默认文字色。
+
### 校准公式
- 测量得到的线性峰值:`measuredLinear`(0..1)
diff --git a/Views/SettingsPages/DecibelMonitorSettingsPage.axaml b/Views/SettingsPages/DecibelMonitorSettingsPage.axaml
index 43de2c8..6ac28ca 100644
--- a/Views/SettingsPages/DecibelMonitorSettingsPage.axaml
+++ b/Views/SettingsPages/DecibelMonitorSettingsPage.axaml
@@ -126,7 +126,7 @@
@@ -152,7 +152,7 @@
+ Description="窗口开启后等待按键确认的时间,超时则关闭窗口且不提醒;窗口开启期间组件状态点为橙色三角,窗口即将超时时三角会呼吸提示。">
Date: Sat, 26 Sep 2026 18:39:43 +0800
Subject: [PATCH 19/25] =?UTF-8?q?docs:=20=E4=BF=AE=E6=AD=A3=20README=20?=
=?UTF-8?q?=E4=B8=8E=E6=B3=A8=E9=87=8A=E4=B8=AD=E5=B7=B2=E5=A4=B1=E6=95=88?=
=?UTF-8?q?=E7=9A=84=E8=AE=BE=E7=BD=AE=E9=A1=B9=E4=B8=8E=E9=85=8D=E7=BD=AE?=
=?UTF-8?q?=E4=BD=8D=E7=BD=AE?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
提醒判定迁到插件设置页的"判定源"、通知提供方改为只负责提醒正文之后,文档与
注释仍指向旧位置与已删除的设置项:
- README:`ShowAlertTextOnComponent` 改为 `ShowStatusIndicator`;通知提供方设置只
剩 `AlertText`(删去 IsAlertEnabled / AlertThreshold / AlertCooldownMinutes);
"设置位置一览"与"阈值提醒"小节改为指向插件设置页的判定源;补全判定源①/②字段、
平均窗口与优先级;故障排查补上"判定源默认关闭"与判定源②需 KeyboardCapture
- 组件设置页 XAML 注释:提醒相关设置的指向同步为"判定源在插件设置页、正文与语音在
通知提供方"
---
.../DecibelComponentSettingsControl.axaml | 3 +-
README.md | 36 ++++++++++---------
2 files changed, 22 insertions(+), 17 deletions(-)
diff --git a/Controls/ComponentSettings/DecibelComponentSettingsControl.axaml b/Controls/ComponentSettings/DecibelComponentSettingsControl.axaml
index afd7929..99c8a8d 100644
--- a/Controls/ComponentSettings/DecibelComponentSettingsControl.axaml
+++ b/Controls/ComponentSettings/DecibelComponentSettingsControl.axaml
@@ -12,7 +12,8 @@
+ 提醒判定(何时提醒)在 设置 → Decibel_Monitor 页 → 提醒判定,
+ 提醒正文与语音在 设置 → 通知 → 分贝提醒。 -->
diff --git a/README.md b/README.md
index 4bc21e5..fa52348 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@
- 实时显示麦克风分贝值(映射到 0–150 范围以便在 UI 中直观显示)。默认通过**一条常驻的低开销捕获流**持续计算峰值——数值稳定、无周期性开/关录音造成的闪烁(Windows 麦克风占用图标会常亮而非闪烁)。
- 校准功能:根据目标 dBFS 自动计算并保存**全局**放大倍数(插件设置页 → 全局校准)。校准时才会进行一次显式采样。
-- 阈值提醒:超过指定分贝值时,在组件上显示提示文字,并通过 ClassIsland 通知系统弹出醒目提醒(带冷却时间防刷屏)。
+- 阈值提醒:由**判定源**决定“何时提醒”——判定源①超过阈值即提醒,判定源②超过阈值后开筛选窗口、窗口内按下确认热键才提醒;触发时通过 ClassIsland 通知系统弹出醒目提醒(各判定源自带冷却时间防刷屏)。组件上的提醒状态点同时以形状与颜色表达当前状态。
## 设置位置一览
@@ -14,9 +14,9 @@
| 位置 | 内容 |
| --- | --- |
-| ① 组件设置(右键组件 → 设置) | 显示偏好:是否在组件上显示“超过阈值”的提示文字(`ShowAlertTextOnComponent`) |
-| ② 通知设置(设置 → 通知 → 分贝提醒) | 启用提醒、阈值(dB)、提醒文字、冷却时间(分钟) |
-| ③ 插件设置页(设置 → Decibel_Monitor) | **全局校准**(参考 dB、放大倍数、校准按钮)、测量参数(持续监听、采样时长)、设备诊断 |
+| ① 组件设置(右键组件 → 设置) | 显示偏好:提醒状态点(`ShowStatusIndicator`)、“分贝:”前缀(`ShowDecibelPrefix`)、更新频率(`UpdateIntervalMs`) |
+| ② 通知设置(设置 → 通知 → 分贝提醒) | 提醒正文(`AlertText`) |
+| ③ 插件设置页(设置 → Decibel_Monitor) | **全局校准**(参考 dB、放大倍数、校准按钮)、测量参数(持续监听、采样时长)、**提醒判定**(平均窗口、判定源优先级、判定源①/②的启用与阈值/冷却/热键)、诊断 |
## 依赖
@@ -35,14 +35,16 @@
- 点击“校准”按钮。插件采样一次并计算全局放大倍数,保存后所有组件立即生效。
- 采样或静音表现异常时,可在同一页面使用“捕获设备列表”诊断,或调整测量参数。
-### 阈值提醒(通知设置)
+### 阈值提醒(判定源)
-1. 打开 **设置 → 通知 → 分贝提醒**。
-2. 开启“启用提醒”,设置阈值(dB)、提醒文字与冷却时间(分钟)。
-3. 当分贝值超过阈值时:
+1. 打开 **设置 → Decibel_Monitor 页 → 提醒判定**,设置平均窗口时长与判定源优先级。
+2. 判定源①“自动提醒”:开启后,窗口平均分贝超过阈值即提醒(阈值默认 120,冷却时间默认 600 秒)。
+3. 判定源②“热键确认”:开启后,窗口平均分贝超过阈值时先开启筛选窗口(默认 10 秒),窗口内按下确认热键才提醒,超时未按键则不提醒;该判定源需要宿主安装并启用 KeyboardCapture 插件。
+4. 提醒正文与语音在 **设置 → 通知 → 分贝提醒** 及宿主的提醒设置中配置;本页只决定“何时提醒”。
+5. 触发提醒时:
- 组件上的提醒状态点变为红色(含义见下方“提醒状态点”);
- 通过 ClassIsland 通知系统弹出醒目提醒;
- - 冷却时间内不会重复提醒(默认 10 分钟)。
+ - 该判定源的冷却时间内不会重复提醒。
### 提醒状态点(组件上的形状)
@@ -74,16 +76,13 @@
### ① 组件设置(`DecibelComponentSettings`,每组件独立)
-- `ShowAlertTextOnComponent` (bool):是否在组件上显示“超过阈值”的提示文字,默认 true;关闭后仍会发送系统通知,但不在组件上显示提示文字。
+- `ShowStatusIndicator` (bool):是否在组件上显示提醒状态点,默认 true;关闭后仍会正常发出系统通知,只是不在组件上显示状态点(形状含义见“提醒状态点”)。
- `ShowDecibelPrefix` (bool):数值前是否显示“分贝:”前缀(如“分贝: 70.0”),默认 true。
-- `UpdateIntervalMs` (int):数值更新频率(毫秒,100..1000,可配置范围 100..5000),默认 200。
+- `UpdateIntervalMs` (int):数值更新频率(毫秒,运行时会夹取到 100..5000),默认 200。
### ② 通知提供方设置(`DecibelNotificationProviderSettings`,全局)
-- `IsAlertEnabled` (bool):是否启用阈值提醒,默认 false。
-- `AlertThreshold` (double):提醒阈值(显示刻度 0..150),默认 120。
-- `AlertText` (string):提醒文字(通知横幅与组件提示),默认“请保持安静”。
-- `AlertCooldownMinutes` (int):触发提醒后的冷却时间(分钟),默认 10。
+- `AlertText` (string):提醒正文(通知横幅显示的文字),默认“请保持安静”。启用开关、阈值与冷却**不在此处**,由插件设置页的判定源负责(避免“判定源已裁定提醒、提供方却因自身阈值不发通知”的双重门槛)。
### ③ 插件全局设置(`DecibelMonitorGlobalSettings`)
@@ -92,11 +91,16 @@
- `EnableContinuousMonitoring` (bool):是否启用“持续监听麦克风”(常驻静默捕获流),默认 true。关闭后仅使用系统实时计量,多数设备会一直显示 0。
- `FallbackCaptureMs` (int):显式采样(如校准)的单次录音时长(毫秒),默认 400。
- `SignalThreshold` (double):信号检测阈值(存在配置文件中,可在采样异常时调低)。
+- `AverageWindowSeconds` (double):判定源比较的平均窗口时长(秒),默认 3。
+- `SourcePriorityOrder` (`List`):判定源优先级顺序,默认“自动提醒”在前。
+- 判定源①(自动提醒):`AutoSourceEnabled` (bool)、`AutoSourceThreshold` (double,默认 120)、`AutoSourceCooldownSeconds` (int,默认 600)。
+- 判定源②(热键确认):`HotkeySourceEnabled` (bool)、`HotkeySourceThreshold` (double,默认 120)、`HotkeySourceCooldownSeconds` (int,默认 600)、`HotkeyWindowSeconds` (int,默认 10)、`HotkeyKey` (string,默认 `F5`) 与 `HotkeyCtrl` / `HotkeyAlt` / `HotkeyShift` / `HotkeyMeta` (bool)。
## 故障排查
- 提醒一直没有触发:
- - 确认 ClassIsland 全局通知开关与 **设置 → 通知 → 分贝提醒** 的开关均已开启。
+ - 确认 ClassIsland 的全局通知/语音开关已开启,且 **设置 → Decibel_Monitor 页** 中至少启用了一个判定源(两个判定源默认都是关闭的)。
+ - 若启用的是判定源②,确认已安装并启用 KeyboardCapture 插件,且在筛选窗口内按下了配置的确认热键(超时未按键不会提醒)。
- 确认分贝值确实超过了设定阈值(长期显示 0 请参考下一条)。
- 若宿主日志中无“注册提醒渠道:…(超过分贝阈值)”,说明加载的是旧版本插件,请更新插件并重启。
- 分贝值长期显示 `0.0`:
From 086daea3ca40010a428e38208dd18349ed04fc2f Mon Sep 17 00:00:00 2001
From: Starsky16 <163619709+Starsky16@users.noreply.github.com>
Date: Sat, 26 Sep 2026 20:01:43 +0800
Subject: [PATCH 20/25] =?UTF-8?q?fix:=20=E5=86=B7=E5=8D=B4=E6=9C=9F?=
=?UTF-8?q?=E5=86=85=E5=88=A4=E5=AE=9A=E6=BA=90=E4=B8=8D=E5=86=8D=E8=AE=A1?=
=?UTF-8?q?=E5=85=A5=E6=AD=A3=E5=9C=A8=E8=B6=85=E9=98=88=E5=80=BC=EF=BC=8C?=
=?UTF-8?q?=E7=8A=B6=E6=80=81=E7=82=B9=E4=BF=9D=E6=8C=81=E7=A9=BA=E5=BF=83?=
=?UTF-8?q?=E6=96=B9?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
实机验证发现冷却期形状与设计 3.2 节不符:判定源的 IsTriggerActive 不受冷却影响,
低阈值用法下 600 秒冷却期内几乎全程显示实心方,"刚提醒过"这一事实在形状上丢失,
且数字颜色的"冷却期改用 1 秒短窗口平均"分支走不到。
改为按判定源分别计算"超阈值且未冷却"后再取或,使冷却期形状恒为空心方、
数字判据切换生效。同步 README 形状对照表与设置页文案,并修正 README 中
"未启用任何判定源时数字显示 N/A"的不准确表述。
副作用:提醒与冷却在同一拍写入,因此 Alerting(红实心方)在冷却时间非 0 时
实际不可见,仅在冷却时间为 0 时出现;已在 README、组件设置页与 resolver 注释中
按此精确说明。
---
Alarm/AlertRuntimeService.cs | 12 ++++++++++--
Alerting/IndicatorStateResolver.cs | 17 +++++++++++++----
.../DecibelComponentSettingsControl.axaml | 2 +-
README.md | 6 +++---
.../IndicatorStateResolverTests.cs | 2 ++
.../DecibelMonitorSettingsPage.axaml | 4 ++--
6 files changed, 31 insertions(+), 12 deletions(-)
diff --git a/Alarm/AlertRuntimeService.cs b/Alarm/AlertRuntimeService.cs
index aa3c962..972d5e6 100644
--- a/Alarm/AlertRuntimeService.cs
+++ b/Alarm/AlertRuntimeService.cs
@@ -204,13 +204,21 @@ private void UpdateIndicator(DateTime nowUtc, double average, double shortAverag
var windowUntilUtc = _hotkeySource.WindowOpenUntilUtc;
var remaining = windowOpen && windowUntilUtc > nowUtc ? windowUntilUtc - nowUtc : TimeSpan.Zero;
+ var autoCoolingDown = _autoSource.IsCoolingDown(nowUtc);
+ var hotkeyCoolingDown = _hotkeySource.IsCoolingDown(nowUtc);
+
var snapshot = _indicatorResolver.Update(new IndicatorInputs
{
HasSample = HasSample,
AnySourceEnabled = IsAnySourceEnabled,
AnyWindowOpen = windowOpen,
- AnyTriggerActive = _autoSource.IsTriggerActive || _hotkeySource.IsTriggerActive,
- AnyCoolingDown = _autoSource.IsCoolingDown(nowUtc) || _hotkeySource.IsCoolingDown(nowUtc),
+ // "正在超阈值"只在判定源未处于冷却期时成立:提醒一旦发出即进入冷却,
+ // 此后由冷却态表达形状(空心方,表示"提醒已发生过"),
+ // "现在是否还在吵"交给数字颜色(冷却期改用 1 秒短窗口平均)。
+ AnyTriggerActive =
+ (_autoSource.IsTriggerActive && !autoCoolingDown) ||
+ (_hotkeySource.IsTriggerActive && !hotkeyCoolingDown),
+ AnyCoolingDown = autoCoolingDown || hotkeyCoolingDown,
AverageDecibel = average,
ShortWindowAverageDecibel = shortAverage,
Threshold = GetIndicatorThreshold(),
diff --git a/Alerting/IndicatorStateResolver.cs b/Alerting/IndicatorStateResolver.cs
index dba152b..8537007 100644
--- a/Alerting/IndicatorStateResolver.cs
+++ b/Alerting/IndicatorStateResolver.cs
@@ -13,7 +13,10 @@ public enum IndicatorState
/// 有采样且各判定条件均不成立。
Normal,
- /// 任一判定源当前处于"超过阈值"状态(提醒已由仲裁模块发出)。
+ ///
+ /// 任一判定源当前处于"超过阈值且未冷却"状态(提醒正由仲裁模块发出)。
+ /// 提醒与冷却在同一拍写入,因此该态实际只在判定源冷却时间为 0 时可见。
+ ///
Alerting,
/// 任一判定源处于提醒后的冷却期(提醒已经发生过)。
@@ -56,7 +59,10 @@ public readonly record struct IndicatorInputs
/// 任一判定源的热键筛选窗口是否开启。
public bool AnyWindowOpen { get; init; }
- /// 任一判定源是否处于"超过阈值"状态。
+ ///
+ /// 任一判定源是否处于"超过阈值且未处于冷却期"状态(判定条件成立、提醒即将或正在发出)。
+ /// 已进入冷却的源不算数——否则低阈值用法下冷却期内会一直显示"正在超阈值","刚提醒过"这一事实会被盖掉。
+ ///
public bool AnyTriggerActive { get; init; }
/// 任一判定源是否处于提醒后的冷却期。
@@ -104,7 +110,9 @@ public readonly record struct IndicatorSnapshot(
/// 设计约束(纯逻辑,不依赖 Avalonia / NAudio / Windows,可在任意平台单测):
///
/// - 合并规则按"是否需要用户动手"排序:窗口 > 超阈值 > 冷却 > 正常 > 无数据。
-/// 窗口必须压制冷却——两个源同时启用时自动源的冷却可达 1800 秒,若按严重度排序会把"该按键"盖掉。
+/// 窗口必须压制冷却——两个源同时启用时自动源的冷却可达 1800 秒,若按严重度排序会把"该按键"盖掉。
+/// 的输入已排除处于冷却期的源,因此冷却期的形状恒为空心方:
+/// 超阈值当拍即发出提醒并进入冷却,此后形状只表达"提醒已发生过","现在是否还在吵"交给数字颜色。
/// - 状态不携带源身份:单点无法表达 2 源 × 4 态,不做并排图标。
/// - 阈值迟滞(施密特触发)只作用于数字颜色判据,用于消除判据在阈值附近的抖动引起的颜色闪变;
/// 判定源自身的触发逻辑保持不变,因此不影响提醒行为。
@@ -157,7 +165,8 @@ public IndicatorStateResolver(double hysteresisBandDb = HysteresisBandDb)
///
///
/// 顺序:① 任一源窗口开启 → ;
- /// ② 任一源超阈值 → ;
+ /// ② 任一源超阈值且未冷却 →
+ /// (判定源超阈值当拍即由仲裁模块发出提醒并写入冷却,因此该态只在冷却时间为 0 时可见);
/// ③ 任一源冷却中 → ;
/// ④ 有采样且至少启用一个判定源 → ;
/// ⑤ 否则 → (无采样或未启用任何判定源时都不显示形状)。
diff --git a/Controls/ComponentSettings/DecibelComponentSettingsControl.axaml b/Controls/ComponentSettings/DecibelComponentSettingsControl.axaml
index 99c8a8d..50c3617 100644
--- a/Controls/ComponentSettings/DecibelComponentSettingsControl.axaml
+++ b/Controls/ComponentSettings/DecibelComponentSettingsControl.axaml
@@ -42,7 +42,7 @@
TextWrapping="Wrap"/>
+ Description="两次提醒之间的最小间隔;冷却期内状态点为红色空心方(表示提醒已发生)但不重复提醒。">
+ Description="两次提醒之间的最小间隔;冷却期内不再开启筛选窗口,状态点保持红色空心方。">
Date: Sat, 26 Sep 2026 20:41:18 +0800
Subject: [PATCH 21/25] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=97=B6?=
=?UTF-8?q?=E6=AE=B5=E9=97=B8=E9=97=A8=EF=BC=8C=E8=AF=BE=E9=97=B4=E4=B8=8E?=
=?UTF-8?q?=E4=B8=8A=E8=AF=BE=E5=88=9D=E6=9C=9F=E6=8A=91=E5=88=B6=E6=8F=90?=
=?UTF-8?q?=E9=86=92?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
课间休息与每节课开头若干分钟内的超阈值不再发出通知,避免课间交谈、上课起立
等声音被当成吵闹造成误报。
关键点:抑制时直接跳过仲裁模块的 Decide 调用,而不是只在通知出口拦截。
Decide 一旦被调用就会写入冷却时间,课间的一次误报会吃掉整段冷却,导致进入
上课后该提醒而不提醒,反而比不抑制更严重。抑制期间状态点改由"当前窗口平均
是否超阈值"直接驱动,仍照常显示噪音(超阈值红实心方、否则绿圆)。
- 新增 Alerting/ScheduleGate.cs:纯逻辑时段闸门,上课起点取 OnClass 上升沿并用
真实时间计时;课表未启用或未加载时整体旁路。自定义 SchedulePhase 枚举以避免
测试项目引用宿主类型,新增 14 条单测
- AlertRuntimeService 惰性解析 IAppHost.TryGetService()(零新增依赖)
- 新增设置项 SuppressAlertDuringBreak(默认开)与 ClassStartProtectionMinutes
(默认 3,0 表示关闭),设置页新增「时段闸门」分组
---
Alarm/AlertRuntimeService.cs | 105 +++++++++--
Alerting/ScheduleGate.cs | 84 +++++++++
Models/DecibelMonitorGlobalSettings.cs | 16 ++
README.md | 14 +-
.../Decibel_Monitor.Tests.csproj | 1 +
.../ScheduleGateTests.cs | 172 ++++++++++++++++++
.../DecibelMonitorSettingsPage.axaml | 21 +++
7 files changed, 393 insertions(+), 20 deletions(-)
create mode 100644 Alerting/ScheduleGate.cs
create mode 100644 Tests/Decibel_Monitor.Tests/ScheduleGateTests.cs
diff --git a/Alarm/AlertRuntimeService.cs b/Alarm/AlertRuntimeService.cs
index 972d5e6..9b0b068 100644
--- a/Alarm/AlertRuntimeService.cs
+++ b/Alarm/AlertRuntimeService.cs
@@ -4,6 +4,9 @@
using System.Threading;
using System.Threading.Tasks;
using Avalonia.Threading;
+using ClassIsland.Core.Abstractions.Services;
+using ClassIsland.Shared;
+using ClassIsland.Shared.Enums;
using Decibel_Monitor.Alerting;
using Decibel_Monitor.Measurement;
using Decibel_Monitor.Models;
@@ -26,6 +29,8 @@ namespace Decibel_Monitor.Alarm;
/// 不新增通知/语音开关。
/// - 提醒状态点由 解析:本服务只负责压平各判定源状态、
/// 维护判定用的平均窗口与冷却期判据所用的 1 秒短窗口,状态含义与滞回规则全部归解析器(纯逻辑、可单测)。
+/// - 时段闸门:课间休息与每节课开头若干分钟内不推进判定源(,
+/// 依赖宿主课表状态)。这些时段的误报既不发出通知、也不占用冷却;状态点仍照常显示当前是否超阈值。
///
///
public sealed class AlertRuntimeService : IHostedService, IDisposable
@@ -50,10 +55,12 @@ public sealed class AlertRuntimeService : IHostedService, IDisposable
private readonly List _window = new();
private readonly Queue<(DateTime TimestampUtc, float Value)> _shortWindow = new();
private readonly IndicatorStateResolver _indicatorResolver = new();
+ private readonly ScheduleGate _scheduleGate = new();
private readonly DispatcherTimer _timer;
private DateTime _pauseUntilUtc = DateTime.MinValue;
private string[]? _appliedPriorityOrder;
private string? _lastDeviceDescription;
+ private ILessonsService? _lessonsService;
private bool _disposed;
/// 最新一次采样的分贝映射值(显示刻度 0..150)。
@@ -153,7 +160,7 @@ private async void Timer_Tick(object? sender, EventArgs e)
if (deviceDescription is null)
{
HasSample = false;
- UpdateIndicator(nowUtc, average: 0.0, shortAverage: 0.0);
+ UpdateIndicator(nowUtc, average: 0.0, shortAverage: 0.0, scheduleGateSuppressed: false);
return;
}
@@ -174,10 +181,19 @@ private async void Timer_Tick(object? sender, EventArgs e)
_shortWindow.Enqueue((nowUtc, (float)mapped));
var shortAverage = GetShortWindowAverage(nowUtc, mapped);
- var decision = _coordinator.Decide(new AlertContext(mapped, average), nowUtc);
+ // 时段闸门:课间与上课初期抑制提醒。闸门生效时必须跳过 Decide 调用——
+ // Decide 一旦被调用就会写入冷却时间,课间的一次误报会吃掉整段冷却,
+ // 导致进入上课后该提醒而不提醒(比不提醒更严重)。
+ var scheduleGateSuppressed = EvaluateScheduleGate(nowUtc);
+
+ var decision = default(CoordinatorDecision);
+ if (!scheduleGateSuppressed)
+ {
+ decision = _coordinator.Decide(new AlertContext(mapped, average), nowUtc);
+ }
// 提醒状态点:由纯逻辑解析器把各判定源状态合并为单一枚举(取"是否需要用户动手"最高者)
- UpdateIndicator(nowUtc, average, shortAverage);
+ UpdateIndicator(nowUtc, average, shortAverage, scheduleGateSuppressed);
if (!decision.ShouldAlert) return;
@@ -198,30 +214,55 @@ private async void Timer_Tick(object? sender, EventArgs e)
/// 当前时间(UTC)。
/// 判定用的滑动窗口平均分贝。
/// 冷却期数字判据所用的 1 秒短窗口平均分贝。
- private void UpdateIndicator(DateTime nowUtc, double average, double shortAverage)
+ /// 本拍是否被时段闸门抑制(抑制时判定源未被推进,其状态不可用于显示)。
+ private void UpdateIndicator(DateTime nowUtc, double average, double shortAverage, bool scheduleGateSuppressed)
{
- var windowOpen = _hotkeySource.IsWindowOpen;
- var windowUntilUtc = _hotkeySource.WindowOpenUntilUtc;
- var remaining = windowOpen && windowUntilUtc > nowUtc ? windowUntilUtc - nowUtc : TimeSpan.Zero;
+ var threshold = GetIndicatorThreshold();
- var autoCoolingDown = _autoSource.IsCoolingDown(nowUtc);
- var hotkeyCoolingDown = _hotkeySource.IsCoolingDown(nowUtc);
+ bool windowOpen;
+ bool triggerActive;
+ bool coolingDown;
+ TimeSpan remaining;
- var snapshot = _indicatorResolver.Update(new IndicatorInputs
+ if (scheduleGateSuppressed)
{
- HasSample = HasSample,
- AnySourceEnabled = IsAnySourceEnabled,
- AnyWindowOpen = windowOpen,
+ // 闸门生效期间判定源本拍未被推进,其内部状态(超阈值 / 冷却 / 筛选窗口)停留在闸门开始前的旧值,
+ // 因此不参与显示。状态点改由"当前窗口平均是否超阈值"直接驱动,
+ // 只回答"现在吵不吵"——与"课间只停通知、照常显示噪音"的约定一致。
+ windowOpen = false;
+ triggerActive = IsAnySourceEnabled && average > threshold;
+ coolingDown = false;
+ remaining = TimeSpan.Zero;
+ }
+ else
+ {
+ windowOpen = _hotkeySource.IsWindowOpen;
+ var autoCoolingDown = _autoSource.IsCoolingDown(nowUtc);
+ var hotkeyCoolingDown = _hotkeySource.IsCoolingDown(nowUtc);
+
+ remaining = windowOpen && _hotkeySource.WindowOpenUntilUtc > nowUtc
+ ? _hotkeySource.WindowOpenUntilUtc - nowUtc
+ : TimeSpan.Zero;
+
// "正在超阈值"只在判定源未处于冷却期时成立:提醒一旦发出即进入冷却,
// 此后由冷却态表达形状(空心方,表示"提醒已发生过"),
// "现在是否还在吵"交给数字颜色(冷却期改用 1 秒短窗口平均)。
- AnyTriggerActive =
+ triggerActive =
(_autoSource.IsTriggerActive && !autoCoolingDown) ||
- (_hotkeySource.IsTriggerActive && !hotkeyCoolingDown),
- AnyCoolingDown = autoCoolingDown || hotkeyCoolingDown,
+ (_hotkeySource.IsTriggerActive && !hotkeyCoolingDown);
+ coolingDown = autoCoolingDown || hotkeyCoolingDown;
+ }
+
+ var snapshot = _indicatorResolver.Update(new IndicatorInputs
+ {
+ HasSample = HasSample,
+ AnySourceEnabled = IsAnySourceEnabled,
+ AnyWindowOpen = windowOpen,
+ AnyTriggerActive = triggerActive,
+ AnyCoolingDown = coolingDown,
AverageDecibel = average,
ShortWindowAverageDecibel = shortAverage,
- Threshold = GetIndicatorThreshold(),
+ Threshold = threshold,
HotkeyWindowRemaining = remaining,
HotkeyWindowTimeout = _hotkeySource.WindowTimeout,
}, nowUtc);
@@ -232,6 +273,36 @@ private void UpdateIndicator(DateTime nowUtc, double average, double shortAverag
IndicatorStateChanged = snapshot.Changed;
}
+ ///
+ /// 推进时段闸门,返回本拍是否应抑制提醒。
+ ///
+ ///
+ /// 宿主课程服务不可用时按"不抑制"处理,行为回到未接入闸门之前;课表未启用或未加载时由闸门内部旁路。
+ /// 服务惰性解析并缓存(宿主服务在本插件加载时通常已就绪,解析失败则下一拍重试)。
+ ///
+ private bool EvaluateScheduleGate(DateTime nowUtc)
+ {
+ var lessons = _lessonsService ??= IAppHost.TryGetService();
+ if (lessons is null) return false;
+
+ var settings = _settingsService?.Settings;
+ return _scheduleGate.Update(
+ ToSchedulePhase(lessons.CurrentState),
+ lessons.IsClassPlanEnabled,
+ lessons.IsClassPlanLoaded,
+ settings?.SuppressAlertDuringBreak ?? true,
+ settings?.ClassStartProtectionMinutes ?? 3,
+ nowUtc);
+ }
+
+ /// 把宿主的 映射为闸门关心的时段类别(准备上课、放学等一律不抑制)。
+ private static SchedulePhase ToSchedulePhase(TimeState state) => state switch
+ {
+ TimeState.OnClass => SchedulePhase.OnClass,
+ TimeState.Breaking => SchedulePhase.Breaking,
+ _ => SchedulePhase.Other,
+ };
+
///
/// 数字颜色判据的阈值:取已启用判定源中的最小阈值。
/// 各源的判据共用同一数值,因此"任一源超阈"等价于"数值超过最小阈值";未启用任何源时取 0(该情形下判据为未知)。
diff --git a/Alerting/ScheduleGate.cs b/Alerting/ScheduleGate.cs
new file mode 100644
index 0000000..f1246bd
--- /dev/null
+++ b/Alerting/ScheduleGate.cs
@@ -0,0 +1,84 @@
+using System;
+
+namespace Decibel_Monitor.Alerting;
+
+///
+/// 闸门关心的时段类别,由宿主 TimeState 映射而来(只保留需要区分的情形)。
+///
+public enum SchedulePhase
+{
+ /// 其它时段(无时间点、准备上课、放学等),不抑制。
+ Other,
+
+ /// 上课。
+ OnClass,
+
+ /// 课间休息。
+ Breaking,
+}
+
+///
+/// 时段闸门:依据课表时段(课间休息、上课初期)判定本拍是否应抑制提醒。
+///
+///
+///
+/// - 纯逻辑、可单测:由运行时服务在每个采样节拍推进,不自持定时器、不订阅宿主事件,
+/// 也不引用宿主类型(宿主时段由调用方映射为 )。
+/// - 抑制只决定"要不要推进判定源"。调用方在抑制时须跳过 Decide 调用,
+/// 否则判定源内部仍会写入冷却时间——课间的一次误报会吃掉整段冷却,反而让上课时该提醒而不提醒。
+/// - 上课起点取 的上升沿(以插件首次观察到进入上课状态为准,
+/// 而非课表里的真实开课时间),并用真实时间相减计时(采样节拍可配,不能按采样次数累加)。
+/// 因此插件在课中启动/重启时会从该时刻重新计时,偏保守。
+/// - 课表未启用或未加载时整体旁路,行为回到未接入闸门之前。
+///
+///
+public sealed class ScheduleGate
+{
+ /// 上课初期保护时长的上限(分钟)。
+ public const int MaxClassStartProtectionMinutes = 10;
+
+ private SchedulePhase _lastPhase = SchedulePhase.Other;
+ private DateTime _classStartUtc = DateTime.MinValue;
+
+ ///
+ /// 推进一次判定并返回本拍是否应抑制提醒。
+ ///
+ /// 当前时段类别。
+ /// 宿主是否启用课表。
+ /// 宿主是否已加载课表。
+ /// 课间休息期间是否抑制提醒。
+ /// 上课初期保护时长(分钟),按 0.. 夹取,0 表示关闭。
+ /// 当前时间(UTC)。
+ /// 本拍是否应抑制提醒。
+ public bool Update(
+ SchedulePhase phase,
+ bool isClassPlanEnabled,
+ bool isClassPlanLoaded,
+ bool suppressDuringBreak,
+ int classStartProtectionMinutes,
+ DateTime nowUtc)
+ {
+ // 上课上升沿:每次进入 OnClass 都重新计时(课间 → 上课的转换会重置保护起点)
+ if (phase == SchedulePhase.OnClass && _lastPhase != SchedulePhase.OnClass)
+ {
+ _classStartUtc = nowUtc;
+ }
+
+ _lastPhase = phase;
+
+ // 课表未启用或未加载:时段信息不可信,闸门整体旁路
+ if (!isClassPlanEnabled || !isClassPlanLoaded) return false;
+
+ if (suppressDuringBreak && phase == SchedulePhase.Breaking) return true;
+
+ var protectionMinutes = Math.Clamp(classStartProtectionMinutes, 0, MaxClassStartProtectionMinutes);
+ if (protectionMinutes > 0 &&
+ phase == SchedulePhase.OnClass &&
+ nowUtc - _classStartUtc < TimeSpan.FromMinutes(protectionMinutes))
+ {
+ return true;
+ }
+
+ return false;
+ }
+}
\ No newline at end of file
diff --git a/Models/DecibelMonitorGlobalSettings.cs b/Models/DecibelMonitorGlobalSettings.cs
index 25a7053..36b4556 100644
--- a/Models/DecibelMonitorGlobalSettings.cs
+++ b/Models/DecibelMonitorGlobalSettings.cs
@@ -61,6 +61,22 @@ public partial class DecibelMonitorGlobalSettings : ObservableObject
Alerting.HotkeyConfirmDecisionSource.SourceId,
};
+ // ── 时段闸门(对全部判定源生效)────────────────────────────────────
+
+ ///
+ /// 课间休息期间是否抑制提醒,默认 true。
+ /// 抑制时只停止发出通知,状态点仍照常显示当前是否超阈值;
+ /// 且抑制期间不推进判定源,因此不会占用冷却时间(课间误报不会影响上课时的提醒)。
+ /// 课表未启用或未加载时本项不生效。
+ ///
+ [ObservableProperty] private bool _suppressAlertDuringBreak = true;
+
+ ///
+ /// 每节课开始后的保护时长(分钟),默认 3,取值按 0..10 夹取(消费端处理);0 表示关闭该保护。
+ /// 保护期内抑制提醒,用于避开上课起立、问好等固定声音造成的误报;同样不占用冷却时间。
+ ///
+ [ObservableProperty] private int _classStartProtectionMinutes = 3;
+
// ── 判定源①:到阈值自动提醒 ─────────────────────────────────────────
/// 是否启用"自动提醒"判定源(超过阈值立即提醒)。
diff --git a/README.md b/README.md
index ab53d18..92ba2e1 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@
- 实时显示麦克风分贝值(映射到 0–150 范围以便在 UI 中直观显示)。默认通过**一条常驻的低开销捕获流**持续计算峰值——数值稳定、无周期性开/关录音造成的闪烁(Windows 麦克风占用图标会常亮而非闪烁)。
- 校准功能:根据目标 dBFS 自动计算并保存**全局**放大倍数(插件设置页 → 全局校准)。校准时才会进行一次显式采样。
-- 阈值提醒:由**判定源**决定“何时提醒”——判定源①超过阈值即提醒,判定源②超过阈值后开筛选窗口、窗口内按下确认热键才提醒;触发时通过 ClassIsland 通知系统弹出醒目提醒(各判定源自带冷却时间防刷屏)。组件上的提醒状态点同时以形状与颜色表达当前状态。
+- 阈值提醒:由**判定源**决定“何时提醒”——判定源①超过阈值即提醒,判定源②超过阈值后开筛选窗口、窗口内按下确认热键才提醒;触发时通过 ClassIsland 通知系统弹出醒目提醒(各判定源自带冷却时间防刷屏)。组件上的提醒状态点同时以形状与颜色表达当前状态。另有**时段闸门**按课表时段(课间休息、每节课开头若干分钟)抑制提醒,避免课间交谈、上课起立等声音被当成吵闹。
## 设置位置一览
@@ -16,7 +16,7 @@
| --- | --- |
| ① 组件设置(右键组件 → 设置) | 显示偏好:提醒状态点(`ShowStatusIndicator`)、“分贝:”前缀(`ShowDecibelPrefix`)、更新频率(`UpdateIntervalMs`) |
| ② 通知设置(设置 → 通知 → 分贝提醒) | 提醒正文(`AlertText`) |
-| ③ 插件设置页(设置 → Decibel_Monitor) | **全局校准**(参考 dB、放大倍数、校准按钮)、测量参数(持续监听、采样时长)、**提醒判定**(平均窗口、判定源优先级、判定源①/②的启用与阈值/冷却/热键)、诊断 |
+| ③ 插件设置页(设置 → Decibel_Monitor) | **全局校准**(参考 dB、放大倍数、校准按钮)、测量参数(持续监听、采样时长)、**提醒判定**(平均窗口、判定源优先级、**时段闸门**、判定源①/②的启用与阈值/冷却/热键)、诊断 |
## 依赖
@@ -45,6 +45,12 @@
- 组件上的提醒状态点变为红色(含义见下方“提醒状态点”);
- 通过 ClassIsland 通知系统弹出醒目提醒;
- 该判定源的冷却时间内不会重复提醒。
+6. **时段闸门**(默认开启,对两个判定源都生效):课间休息期间、以及每节课开始后的若干分钟内**不发出通知**,用于避开课间交谈、上课起立问好等固定声音造成的误报。
+ - 抑制期间**只停通知**:组件上的状态点仍照常显示当前是否超阈值(超阈值显示红色实心方,否则绿色圆)。
+ - 抑制期间**不推进判定源**,因此不会占用冷却时间——课间的一次超阈值不会让上课时的提醒失效(这是本功能与“只屏蔽通知”写法的关键区别)。
+ - 课表未启用或未加载时,本闸门整体不生效,行为与未启用时一致。
+ - 上课初期保护时长设为 `0` 即关闭该保护;课间抑制可单独关闭。
+ - 保护起点以插件**首次观察到进入上课状态**为准(而非课表里的真实开课时间),因此插件在课中启动或重启时会从那一刻重新计时,偏保守(最多少提醒一次)。
### 提醒状态点(组件上的形状)
@@ -53,7 +59,7 @@
| 形状 | 含义 | 说明 |
| --- | --- | --- |
| 绿色圆(半透明) | 正常 | 有采样、判定源已启用且未超过阈值 |
-| 红色实心方 | 正在超阈值 | 判定条件成立且尚未进入冷却。注意:提醒在同一拍即发出并写入冷却,因此**只有把该判定源的冷却时间设为 0 时才可能看到实心方**;设了冷却时间(默认 600 秒)时,超阈值会直接进入冷却,显示为空心方 |
+| 红色实心方 | 正在超阈值 | 判定条件成立且尚未进入冷却。注意:提醒在同一拍即发出并写入冷却,因此**只有把该判定源的冷却时间设为 0 时才可能看到实心方**;设了冷却时间(默认 600 秒)时,超阈值会直接进入冷却,显示为空心方。**例外**:时段闸门抑制期间(见第 6 条)不写冷却,此时平均分贝超阈值就会显示实心方,表示“现在确实很吵,但本时段不提醒” |
| 红色空心方 | 冷却期 | 提醒已发出,冷却期内不再重复提醒;**冷却期内即使仍超过阈值也显示空心方**(空心表示“提醒已发生过”而非“正在触发”) |
| 橙色三角 | 待热键确认 | 判定源②的筛选窗口已开启,需在窗口内按下热键;窗口剩余时间进入 `min(3 秒, 窗口时长 / 4)` 后三角会呼吸,提示尽快按键(窗口其余时间静置) |
@@ -93,6 +99,7 @@
- `SignalThreshold` (double):信号检测阈值(存在配置文件中,可在采样异常时调低)。
- `AverageWindowSeconds` (double):判定源比较的平均窗口时长(秒),默认 3。
- `SourcePriorityOrder` (`List`):判定源优先级顺序,默认“自动提醒”在前。
+- 时段闸门(对两个判定源生效):`SuppressAlertDuringBreak` (bool,默认 true)、`ClassStartProtectionMinutes` (int,默认 3,运行时夹取到 0..10,0 表示关闭该保护)。
- 判定源①(自动提醒):`AutoSourceEnabled` (bool)、`AutoSourceThreshold` (double,默认 120)、`AutoSourceCooldownSeconds` (int,默认 600)。
- 判定源②(热键确认):`HotkeySourceEnabled` (bool)、`HotkeySourceThreshold` (double,默认 120)、`HotkeySourceCooldownSeconds` (int,默认 600)、`HotkeyWindowSeconds` (int,默认 10)、`HotkeyKey` (string,默认 `F5`) 与 `HotkeyCtrl` / `HotkeyAlt` / `HotkeyShift` / `HotkeyMeta` (bool)。
@@ -103,6 +110,7 @@
- 若启用的是判定源②,确认已安装并启用 KeyboardCapture 插件,且在筛选窗口内按下了配置的确认热键(超时未按键不会提醒)。
- 确认分贝值确实超过了设定阈值(长期显示 0 请参考下一条)。
- 若宿主日志中无“注册提醒渠道:…(超过分贝阈值)”,说明加载的是旧版本插件,请更新插件并重启。
+ - 课间或刚上课时本就不提醒(**时段闸门**,默认开启);此时状态点仍会正常变红。如需在这些时段也提醒,可在 **设置 → Decibel_Monitor 页 → 时段闸门** 关闭“课间期间不提醒”并把“上课初期保护时长”设为 0。
- 分贝值长期显示 `0.0`:
- 若关闭了“持续监听麦克风”,则只使用系统实时计量,多数设备会一直为 0;请到 **设置 → Decibel_Monitor 页** 确认该开关已开启(开启后麦克风占用图标会常亮)。
- 确认宿主应用有麦克风权限(Windows 隐私设置)。
diff --git a/Tests/Decibel_Monitor.Tests/Decibel_Monitor.Tests.csproj b/Tests/Decibel_Monitor.Tests/Decibel_Monitor.Tests.csproj
index 77cf643..8195dc4 100644
--- a/Tests/Decibel_Monitor.Tests/Decibel_Monitor.Tests.csproj
+++ b/Tests/Decibel_Monitor.Tests/Decibel_Monitor.Tests.csproj
@@ -28,6 +28,7 @@
+
diff --git a/Tests/Decibel_Monitor.Tests/ScheduleGateTests.cs b/Tests/Decibel_Monitor.Tests/ScheduleGateTests.cs
new file mode 100644
index 0000000..bf43987
--- /dev/null
+++ b/Tests/Decibel_Monitor.Tests/ScheduleGateTests.cs
@@ -0,0 +1,172 @@
+using System;
+using Decibel_Monitor.Alerting;
+
+namespace Decibel_Monitor.Tests;
+
+public class ScheduleGateTests
+{
+ /// 测试基准时间。
+ private static readonly DateTime Start = new(2026, 9, 26, 8, 0, 0, DateTimeKind.Utc);
+
+ /// 按默认参数(课表已启用并加载、课间抑制开、保护 3 分钟)推进一次判定。
+ private static bool Advance(
+ ScheduleGate gate,
+ SchedulePhase phase,
+ DateTime nowUtc,
+ bool suppressDuringBreak = true,
+ int protectionMinutes = 3,
+ bool isClassPlanEnabled = true,
+ bool isClassPlanLoaded = true) =>
+ gate.Update(phase, isClassPlanEnabled, isClassPlanLoaded, suppressDuringBreak, protectionMinutes, nowUtc);
+
+ // ---- 课表状态旁路 ----
+
+ [Fact]
+ public void Update_Should_NotSuppress_When_ClassPlanDisabled()
+ {
+ var gate = new ScheduleGate();
+
+ // 即使处于课间,课表未启用时时段信息不可信 → 不抑制
+ Assert.False(Advance(gate, SchedulePhase.Breaking, Start, isClassPlanEnabled: false));
+ }
+
+ [Fact]
+ public void Update_Should_NotSuppress_When_ClassPlanNotLoaded()
+ {
+ var gate = new ScheduleGate();
+
+ Assert.False(Advance(gate, SchedulePhase.Breaking, Start, isClassPlanLoaded: false));
+ }
+
+ [Fact]
+ public void Update_Should_NotSuppress_When_PlanDisabledDuringClassStart()
+ {
+ var gate = new ScheduleGate();
+
+ Assert.False(Advance(gate, SchedulePhase.OnClass, Start, isClassPlanEnabled: false));
+ }
+
+ // ---- 课间 ----
+
+ [Fact]
+ public void Update_Should_Suppress_When_BreakingAndSwitchOn()
+ {
+ var gate = new ScheduleGate();
+
+ Assert.True(Advance(gate, SchedulePhase.Breaking, Start));
+ }
+
+ [Fact]
+ public void Update_Should_NotSuppress_When_BreakingButSwitchOff()
+ {
+ var gate = new ScheduleGate();
+
+ Assert.False(Advance(gate, SchedulePhase.Breaking, Start, suppressDuringBreak: false));
+ }
+
+ // ---- 上课初期保护 ----
+
+ [Fact]
+ public void Update_Should_Suppress_WithinClassStartProtection()
+ {
+ var gate = new ScheduleGate();
+
+ // 上升沿建立起点,随后第 1 分钟仍处于 3 分钟保护内
+ Assert.True(Advance(gate, SchedulePhase.OnClass, Start));
+ Assert.True(Advance(gate, SchedulePhase.OnClass, Start.AddMinutes(1)));
+ }
+
+ [Fact]
+ public void Update_Should_NotSuppress_AfterClassStartProtection()
+ {
+ var gate = new ScheduleGate();
+
+ Advance(gate, SchedulePhase.OnClass, Start);
+
+ Assert.False(Advance(gate, SchedulePhase.OnClass, Start.AddMinutes(4)));
+ }
+
+ [Fact]
+ public void Update_Should_NotSuppress_At_ProtectionBoundary()
+ {
+ var gate = new ScheduleGate();
+
+ Advance(gate, SchedulePhase.OnClass, Start);
+
+ // 严格小于:正好 3 分钟时保护已结束
+ Assert.False(Advance(gate, SchedulePhase.OnClass, Start.AddMinutes(3)));
+ }
+
+ [Fact]
+ public void Update_Should_NotSuppress_When_ProtectionDisabled()
+ {
+ var gate = new ScheduleGate();
+
+ // 保护时长 0 = 关闭该保护,上课第 1 分钟就应正常判定
+ Assert.False(Advance(gate, SchedulePhase.OnClass, Start, protectionMinutes: 0));
+ }
+
+ [Fact]
+ public void Update_Should_Clamp_ProtectionMinutes_ToUpperBound()
+ {
+ var gate = new ScheduleGate();
+
+ // 传入超过上限的值按 10 分钟处理:第 9 分钟仍保护、第 11 分钟不再保护
+ Advance(gate, SchedulePhase.OnClass, Start, protectionMinutes: 99);
+
+ Assert.True(Advance(gate, SchedulePhase.OnClass, Start.AddMinutes(9), protectionMinutes: 99));
+ Assert.False(Advance(gate, SchedulePhase.OnClass, Start.AddMinutes(11), protectionMinutes: 99));
+ }
+
+ [Fact]
+ public void Update_Should_TreatNegativeProtectionMinutes_AsDisabled()
+ {
+ var gate = new ScheduleGate();
+
+ Assert.False(Advance(gate, SchedulePhase.OnClass, Start, protectionMinutes: -5));
+ }
+
+ // ---- 上升沿重新计时 ----
+
+ [Fact]
+ public void Update_Should_RestartProtection_On_RisingEdgeOfClass()
+ {
+ var gate = new ScheduleGate();
+
+ // 第一节:保护已过
+ Advance(gate, SchedulePhase.OnClass, Start);
+ Assert.False(Advance(gate, SchedulePhase.OnClass, Start.AddMinutes(10)));
+
+ // 课间:抑制,但不影响下一节课的起点
+ var breakStart = Start.AddMinutes(45);
+ Assert.True(Advance(gate, SchedulePhase.Breaking, breakStart));
+
+ // 第二节上课:上升沿重新计时 → 保护重新生效
+ var secondClass = breakStart.AddMinutes(10);
+ Assert.True(Advance(gate, SchedulePhase.OnClass, secondClass));
+ Assert.False(Advance(gate, SchedulePhase.OnClass, secondClass.AddMinutes(3)));
+ }
+
+ [Fact]
+ public void Update_Should_NotRestartProtection_While_StayingOnClass()
+ {
+ var gate = new ScheduleGate();
+
+ // 连续的上课 tick 不应把起点往后推:起点始终是上升沿那一刻
+ Advance(gate, SchedulePhase.OnClass, Start);
+
+ for (var i = 1; i <= 5; i++) Advance(gate, SchedulePhase.OnClass, Start.AddSeconds(i));
+
+ Assert.False(Advance(gate, SchedulePhase.OnClass, Start.AddMinutes(3)));
+ }
+
+ // ---- 其它时段 ----
+
+ [Fact]
+ public void Update_Should_NotSuppress_When_OtherPhase()
+ {
+ var gate = new ScheduleGate();
+
+ Assert.False(Advance(gate, SchedulePhase.Other, Start));
+ }
+}
\ No newline at end of file
diff --git a/Views/SettingsPages/DecibelMonitorSettingsPage.axaml b/Views/SettingsPages/DecibelMonitorSettingsPage.axaml
index a8d1d55..8fbcaf8 100644
--- a/Views/SettingsPages/DecibelMonitorSettingsPage.axaml
+++ b/Views/SettingsPages/DecibelMonitorSettingsPage.axaml
@@ -96,6 +96,27 @@
Text="同一次判定中多个判定源同时触发时,靠前者被记为触发提醒的判定源。"
TextWrapping="Wrap"/>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Date: Sun, 27 Sep 2026 13:34:47 +0800
Subject: [PATCH 22/25] =?UTF-8?q?fix:=20=E7=BB=84=E4=BB=B6=E7=8A=B6?=
=?UTF-8?q?=E6=80=81=E7=82=B9=E6=94=B9=E4=B8=BA=E8=87=AA=E6=8C=81=E6=9C=80?=
=?UTF-8?q?=E8=BF=91=E4=B8=80=E6=AC=A1=E5=BA=94=E7=94=A8=E5=80=BC=E6=AF=94?=
=?UTF-8?q?=E5=AF=B9=EF=BC=8C=E4=BF=AE=E5=A4=8D=E8=84=89=E5=86=B2=E6=BC=8F?=
=?UTF-8?q?=E8=AF=BB=E5=AF=BC=E8=87=B4=E5=BD=A2=E7=8A=B6=E5=86=BB=E7=BB=93?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
IndicatorStateChanged 是宽度仅一个采样拍(200 ms)的脉冲,组件节拍(默认 500 ms)
可能整拍落在脉冲之外;一旦漏读,形状会一直冻结在旧值(表现为热键筛选窗口已开却
仍显示空心方,而此时按键其实仍能提醒)。改为由组件自持最近一次已应用的状态与数字
判据做比对,最坏只滞后一个组件节拍,不会长期冻结。
---
Controls/Components/DecibelComponent.axaml.cs | 27 +++++++++++--------
1 file changed, 16 insertions(+), 11 deletions(-)
diff --git a/Controls/Components/DecibelComponent.axaml.cs b/Controls/Components/DecibelComponent.axaml.cs
index 51292e5..7107af6 100644
--- a/Controls/Components/DecibelComponent.axaml.cs
+++ b/Controls/Components/DecibelComponent.axaml.cs
@@ -59,8 +59,11 @@ public partial class DecibelComponent : ComponentBase
private volatile bool _isActive;
private volatile bool _isUpdating;
- /// 本实例是否已把视觉属性应用到控件上(首次附着时 Change 标志为假,需要强制应用一次)。
- private bool _visualsApplied;
+ /// 本实例最近一次写入视觉属性的形状状态(自持比对用,见 )。
+ private IndicatorState _appliedState = IndicatorState.NoData;
+
+ /// 本实例最近一次写入视觉属性的数字颜色判据。
+ private IndicatorValueState _appliedValueState = IndicatorValueState.Unknown;
///
/// 当前显示的分贝值文本(Avalonia 属性,绑定自动响应变化)。
@@ -166,9 +169,6 @@ protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e
{
base.OnDetachedFromVisualTree(e);
- // 重新附着时状态未必发生变化(Change 标志为假),需要再次强制应用一次视觉属性
- _visualsApplied = false;
-
if (!_isActive) return;
_isActive = false;
_updateTimer.Tick -= UpdateTimer_Tick;
@@ -205,6 +205,7 @@ private void UpdateTimer_Tick(object? sender, EventArgs e)
: "N/A";
var state = _runtimeService.IndicatorState;
+ var valueState = _runtimeService.IndicatorValueState;
// 无采样或未启用任何判定源时(解析结果 NoData)不显示任何形状
IsIndicatorVisible = showIndicator && state != IndicatorState.NoData;
@@ -212,17 +213,21 @@ private void UpdateTimer_Tick(object? sender, EventArgs e)
// 呼吸的起止由时间推进而非状态变化触发,不在 Change 标志内,必须每拍读取
IsBreathing = _runtimeService.IsIndicatorBreathing;
- // 形状与数字颜色是"离散状态",只在状态或判据变化时重设;
- // 新实例首次附着时 Change 标志必为假,用 _visualsApplied 兜底强制应用一次
- if (_visualsApplied && !_runtimeService.IndicatorStateChanged) return;
+ // 形状与数字颜色是"离散状态":只在与本实例已应用的组合不同时才重设。
+ // 这里不能依赖服务侧的 IndicatorStateChanged —— 它是宽度只有一个采样拍(200 ms)的脉冲,
+ // 组件节拍(默认 500 ms)可能整拍落在脉冲之外;一旦漏读,形状会一直冻结在旧值,
+ // 直到下一次被捕获的脉冲为止(表现为"热键窗口已开却仍显示空心方",且此时按键仍能提醒)。
+ // 改为自持最近一次应用值比对后,最坏情况只是滞后一个组件节拍,不会长期冻结。
+ if (state == _appliedState && valueState == _appliedValueState) return;
IsCircleVisible = state == IndicatorState.Normal;
IsSquareFilledVisible = state == IndicatorState.Alerting;
IsSquareHollowVisible = state == IndicatorState.CoolingDown;
IsTriangleVisible = state == IndicatorState.AwaitingHotkey;
- IsValueAboveThreshold = _runtimeService.IndicatorValueState == IndicatorValueState.Above;
- IsValueWithinThreshold = _runtimeService.IndicatorValueState == IndicatorValueState.Below;
- _visualsApplied = true;
+ IsValueAboveThreshold = valueState == IndicatorValueState.Above;
+ IsValueWithinThreshold = valueState == IndicatorValueState.Below;
+ _appliedState = state;
+ _appliedValueState = valueState;
}
catch (Exception)
{
From aae301a5e5677aa2e261dfccdbe193c6627b5cde Mon Sep 17 00:00:00 2001
From: Starsky16 <163619709+Starsky16@users.noreply.github.com>
Date: Sun, 27 Sep 2026 13:34:55 +0800
Subject: [PATCH 23/25] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E7=83=AD?=
=?UTF-8?q?=E9=94=AE=E7=A1=AE=E8=AE=A4=E8=A2=AB=E8=B6=85=E6=97=B6=E4=B8=A2?=
=?UTF-8?q?=E5=BC=83=EF=BC=8C=E8=B6=85=E6=97=B6=E6=94=B9=E8=BF=9B=E5=85=A5?=
=?UTF-8?q?=E5=86=B7=E5=8D=B4=E5=B9=B6=E4=BD=9C=E5=BA=9F=E9=97=B8=E9=97=A8?=
=?UTF-8?q?=E9=81=97=E7=95=99=E7=AA=97=E5=8F=A3?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 消费已确认按键的分支提到超时判定之前:HandleKeyPress 接受按键时已校验未超时,
若先判超时,落在窗口最后一个采样拍里的确认会被当成"超时未确认"清掉,用户按了键
却既不提醒又白进一次冷却。
- 窗口超时未按键同样写入冷却(视为"已经给过一次机会"),否则下一拍立即重新开窗,
低阈值用法下三角会长期常亮且永远看不到冷却态。
- 新增 CancelWindow(),供时段闸门抑制期间作废遗留窗口(含其中已命中的确认),
不写冷却;否则会出现"课间按的键,上课后才响"。
- HotkeyWindowMonitor 补日志:未解析到 KeyboardCapture 服务首次告警,接入成功打
Information,窗口期内未命中的按键降为 Debug(避免打字污染日志),命中仍打
Information。该链路此前全程静默,是判断"断在哪一环"的唯一依据。
---
Alarm/AlertRuntimeService.cs | 8 ++
Alarm/HotkeyWindowMonitor.cs | 34 ++++-
Alerting/HotkeyConfirmDecisionSource.cs | 53 ++++++--
.../HotkeyConfirmDecisionSourceTests.cs | 118 +++++++++++++++++-
4 files changed, 194 insertions(+), 19 deletions(-)
diff --git a/Alarm/AlertRuntimeService.cs b/Alarm/AlertRuntimeService.cs
index 9b0b068..6eda78d 100644
--- a/Alarm/AlertRuntimeService.cs
+++ b/Alarm/AlertRuntimeService.cs
@@ -191,6 +191,14 @@ private async void Timer_Tick(object? sender, EventArgs e)
{
decision = _coordinator.Decide(new AlertContext(mapped, average), nowUtc);
}
+ else
+ {
+ // 闸门期间不发通知,且状态点不显示三角(用户没有被要求按键),
+ // 因此闸门开始前遗留的筛选窗口要连同其中已命中的确认一起作废——
+ // 否则闸门解除后会用一段早已过期的确认发提醒(课间按的键,上课后才响)。
+ // 不写冷却,维持"抑制期间不占用冷却"的约定。
+ _hotkeySource.CancelWindow();
+ }
// 提醒状态点:由纯逻辑解析器把各判定源状态合并为单一枚举(取"是否需要用户动手"最高者)
UpdateIndicator(nowUtc, average, shortAverage, scheduleGateSuppressed);
diff --git a/Alarm/HotkeyWindowMonitor.cs b/Alarm/HotkeyWindowMonitor.cs
index 619e1c4..8ae4f94 100644
--- a/Alarm/HotkeyWindowMonitor.cs
+++ b/Alarm/HotkeyWindowMonitor.cs
@@ -6,6 +6,7 @@
using Decibel_Monitor.Alerting;
using KeyboardCapture.Abstractions;
using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
namespace Decibel_Monitor.Alarm;
@@ -30,15 +31,19 @@ public sealed class HotkeyWindowMonitor : IHostedService, IDisposable
private static readonly TimeSpan ResolveRetryInterval = TimeSpan.FromSeconds(2);
private readonly HotkeyConfirmDecisionSource _source;
+ private readonly ILogger? _logger;
private readonly object _gate = new();
private Timer? _retryTimer;
private IKeyboardCaptureService? _capture;
+ private bool _resolveFailureLogged;
private bool _disposed;
/// 热键判定源(由 DI 以单例注入)。
- public HotkeyWindowMonitor(HotkeyConfirmDecisionSource source)
+ /// 可选的日志记录器:本链路异常时全程静默,日志是判断"断在哪一环"的唯一依据。
+ public HotkeyWindowMonitor(HotkeyConfirmDecisionSource source, ILogger? logger = null)
{
_source = source ?? throw new ArgumentNullException(nameof(source));
+ _logger = logger;
}
///
@@ -63,6 +68,15 @@ private void TryResolveAndSubscribe()
var service = IAppHost.TryGetService();
if (service is null)
{
+ if (!_resolveFailureLogged)
+ {
+ _resolveFailureLogged = true;
+ _logger?.LogWarning(
+ "未解析到 KeyboardCapture 的 IKeyboardCaptureService,热键确认暂不可用(每 {Interval} 秒重试一次)。" +
+ "请确认宿主已安装并启用 Starsky16.KeyboardCapture 插件。",
+ ResolveRetryInterval.TotalSeconds);
+ }
+
// KeyboardCapture 插件可能晚于本插件加载,定时重试直到解析成功
lock (_gate)
{
@@ -85,6 +99,9 @@ private void TryResolveAndSubscribe()
_capture = service;
service.KeyDown += OnKeyDown;
}
+
+ _resolveFailureLogged = false;
+ _logger?.LogInformation("已接入 KeyboardCapture 全局键盘服务,热键确认可用。");
}
///
@@ -95,13 +112,26 @@ private void OnKeyDown(object? sender, KeyboardKeyEventArgs e)
// 未开窗或未启用时不消费按键,直接返回避免无谓封送
if (!_source.IsEnabled || !_source.IsWindowOpen) return;
+ var keyName = e.Key.Name;
+ var modifiers = ToHotkeyModifiers(e.Modifiers);
+
// 封送到 UI 线程,与采样编排(判定源状态推进)同线程,避免竞态
Dispatcher.UIThread.Post(() =>
{
if (_disposed) return;
try
{
- _source.HandleKeyPress(e.Key.Name, ToHotkeyModifiers(e.Modifiers), DateTime.UtcNow);
+ if (_source.HandleKeyPress(keyName, modifiers, DateTime.UtcNow))
+ {
+ _logger?.LogInformation("筛选窗口内命中确认热键:{Key}({Modifiers}),下一拍发出提醒。", keyName, modifiers);
+ }
+ else
+ {
+ // 未命中通常是键名或修饰键与配置不一致(如多按了 Shift);也可能是封送期间窗口刚好超时关闭。
+ // 只在 Debug 级别记录:窗口内每按一键都会触发一次(含用户打字),
+ // 升到 Information 会污染宿主日志;排查"按了没反应"时再调高日志级别。
+ _logger?.LogDebug("筛选窗口内收到未命中的按键:{Key}({Modifiers})。", keyName, modifiers);
+ }
}
catch
{
diff --git a/Alerting/HotkeyConfirmDecisionSource.cs b/Alerting/HotkeyConfirmDecisionSource.cs
index 7bee91e..c37e468 100644
--- a/Alerting/HotkeyConfirmDecisionSource.cs
+++ b/Alerting/HotkeyConfirmDecisionSource.cs
@@ -10,8 +10,12 @@ namespace Decibel_Monitor.Alerting;
///
/// - 窗口未开启时:平均分贝超过阈值且自身冷却已过 → 打开筛选窗口(窗口期内暂不提醒)。
/// - 窗口开启期间:外部注入按键事件();命中配置热键 → 置为"已确认"。
-/// - 后续采样判定:窗口开启且未超时并已确认 → 触发提醒并关闭窗口、进入冷却。
-/// - 窗口开启但超时未确认 → 不提醒,关闭窗口(不进入冷却,下次超阈值可再次开窗)。
+/// - 后续采样判定:窗口开启期间已确认 → 触发提醒并关闭窗口、进入冷却(不要求消费该确认的那一拍仍在窗口内,
+/// 否则落在窗口最后一个采样拍里的按键会被当成超时丢掉)。
+/// - 窗口开启但超时未确认 → 不提醒,关闭窗口并同样进入冷却(视为"已经给过一次机会");
+/// 冷却结束后若仍超阈值才会再次开窗。
+/// - 时段闸门抑制期间()→ 窗口连同其中已命中的确认一起作废,
+/// 不写冷却;闸门解除后若仍超阈值,重新开一个完整时长的窗口。
///
/// 热键输入由外部注入(便于单元测试),本类只做纯逻辑判定,不接触键盘钩子。
///
@@ -101,6 +105,22 @@ public bool HandleKeyPress(string? keyName, HotkeyModifiers modifiers, DateTime
return false;
}
+ ///
+ /// 作废当前筛选窗口(含其中已命中的确认),不写入冷却。未开窗时为空操作。
+ ///
+ ///
+ /// 供时段闸门抑制期间调用。闸门期间不发通知,且状态点也不显示三角(用户没有被要求按键),
+ /// 因此此时开着的窗口(闸门开始前遗留的)必须作废,否则闸门解除后会用一段早已过期的确认去发提醒
+ /// ——表现为"课间按的键,上课后才响"。
+ /// 与"抑制期间不推进判定源"的约定一致:不写冷却,闸门结束后仍超阈值即重新开窗。
+ ///
+ public void CancelWindow()
+ {
+ IsWindowOpen = false;
+ _confirmed = false;
+ _windowOpenUntilUtc = DateTime.MinValue;
+ }
+
///
public AlertDecision Decide(AlertContext context, DateTime nowUtc)
{
@@ -129,24 +149,35 @@ public AlertDecision Decide(AlertContext context, DateTime nowUtc)
return new AlertDecision(false, isActive);
}
- // 窗口已开但超时 → 关窗(本次不提醒,不进入冷却)
+ // 已命中热键 → 提醒。本分支必须先于超时判定:
+ // HandleKeyPress 接受按键时已经校验过"未超时",因此 _confirmed 为真即代表按键确实落在窗口内。
+ // 若先判超时,落在窗口最后一个采样拍里的确认会被当成"超时未确认"清掉:
+ // 用户明明按了键却既不提醒、又白进一次冷却。
+ if (_confirmed)
+ {
+ IsWindowOpen = false;
+ _confirmed = false;
+ _nextAlertTimeUtc = nowUtc + EffectiveCooldown();
+ return new AlertDecision(true, isActive);
+ }
+
+ // 窗口已开但超时未确认 → 关窗并进入冷却。
+ // 不按键同样算一次"已经给过一次机会":若只关窗不写冷却,下一拍就会立即重新开窗,
+ // 低阈值用法下三角会长期常亮,并永远看不到冷却态与正常态。
if (nowUtc > _windowOpenUntilUtc)
{
IsWindowOpen = false;
_confirmed = false;
+ _nextAlertTimeUtc = nowUtc + EffectiveCooldown();
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);
+ return new AlertDecision(false, isActive);
}
+ /// 生效冷却时间:负值按无冷却处理。
+ private TimeSpan EffectiveCooldown() => Cooldown > TimeSpan.Zero ? Cooldown : TimeSpan.Zero;
+
private void Reset()
{
IsWindowOpen = false;
diff --git a/Tests/Decibel_Monitor.Tests/HotkeyConfirmDecisionSourceTests.cs b/Tests/Decibel_Monitor.Tests/HotkeyConfirmDecisionSourceTests.cs
index 86b924f..4f2c8a8 100644
--- a/Tests/Decibel_Monitor.Tests/HotkeyConfirmDecisionSourceTests.cs
+++ b/Tests/Decibel_Monitor.Tests/HotkeyConfirmDecisionSourceTests.cs
@@ -84,6 +84,82 @@ public void Decide_Should_Alert_WhenHotkeyHitInsideWindow()
Assert.False(source.IsWindowOpen);
}
+ [Fact]
+ public void Decide_Should_Alert_WhenConfirmedKeyConsumedAfterDeadline()
+ {
+ // 采样拍与窗口截止时间不对齐:按键本身落在窗口内(HandleKeyPress 放行,_confirmed 置真),
+ // 但消费这次确认的那一拍已经越过截止时间。
+ // 这类确认必须兑现:否则用户明明按了键,却既不提醒、又白进一次冷却。
+ var source = CreateSource(cooldown: TimeSpan.FromMinutes(5));
+ source.Decide(Context(120.0), Now);
+
+ var hit = source.HandleKeyPress(KeyName, HotkeyModifiers.None, Now + Window - TimeSpan.FromMilliseconds(1));
+ var decisionAt = Now + Window + TimeSpan.FromMilliseconds(400);
+ var decision = source.Decide(Context(120.0), decisionAt);
+
+ Assert.True(hit);
+ Assert.True(decision.ShouldAlert);
+ Assert.False(source.IsWindowOpen);
+ Assert.Equal(decisionAt.AddMinutes(5), source.NextAlertTimeUtc);
+ }
+
+ // ---- 闸门抑制:窗口作废 ----
+
+ [Fact]
+ public void CancelWindow_Should_DropWindowAndConfirmation_WithoutWritingCooldown()
+ {
+ var source = CreateSource(cooldown: TimeSpan.FromMinutes(5));
+ source.Decide(Context(120.0), Now);
+ source.HandleKeyPress(KeyName, HotkeyModifiers.None, Now + TimeSpan.FromSeconds(1));
+
+ source.CancelWindow();
+
+ Assert.False(source.IsWindowOpen);
+ Assert.Equal(DateTime.MinValue, source.WindowOpenUntilUtc);
+ Assert.Equal(DateTime.MinValue, source.NextAlertTimeUtc);
+ Assert.False(source.IsCoolingDown(Now + TimeSpan.FromSeconds(2)));
+ }
+
+ [Fact]
+ public void CancelWindow_Should_BeNoOp_WhenWindowClosed()
+ {
+ var source = CreateSource(cooldown: TimeSpan.FromMinutes(5));
+
+ source.CancelWindow();
+
+ Assert.False(source.IsWindowOpen);
+ Assert.Equal(DateTime.MinValue, source.NextAlertTimeUtc);
+ }
+
+ [Fact]
+ public void Decide_Should_NotAlert_AfterCancelledConfirmation()
+ {
+ // 闸门期间作废掉的确认不能在闸门解除后被兑现,否则"课间按的键,上课后才响"
+ var source = CreateSource(cooldown: TimeSpan.FromMinutes(5));
+ source.Decide(Context(120.0), Now);
+ source.HandleKeyPress(KeyName, HotkeyModifiers.None, Now + TimeSpan.FromSeconds(1));
+ source.CancelWindow();
+
+ var decision = source.Decide(Context(120.0), Now + TimeSpan.FromSeconds(2));
+
+ Assert.False(decision.ShouldAlert);
+ }
+
+ [Fact]
+ public void Decide_Should_ReopenFullWindow_AfterGateLifted()
+ {
+ // 作废不写冷却:闸门解除后仍超阈值 → 立刻重开一个完整时长的窗口
+ var source = CreateSource(cooldown: TimeSpan.FromMinutes(5));
+ source.Decide(Context(120.0), Now);
+ source.CancelWindow();
+
+ var reopenAt = Now + TimeSpan.FromSeconds(30);
+ source.Decide(Context(120.0), reopenAt);
+
+ Assert.True(source.IsWindowOpen);
+ Assert.Equal(reopenAt + Window, source.WindowOpenUntilUtc);
+ }
+
[Fact]
public void Decide_Should_NotAlert_WhenOtherKeyPressedInsideWindow()
{
@@ -161,15 +237,44 @@ public void Decide_Should_IgnoreKeyPress_WhenWindowTimedOut()
}
[Fact]
- public void Decide_Should_OpenWindowAgain_AfterTimeout()
+ public void Decide_Should_EnterCooldown_WhenWindowTimedOut()
{
- // 超时未确认不进入冷却,仍在超阈值时可再次开窗
+ // 超时未确认同样视为"已经给过一次机会":关窗并写入冷却
+ var source = CreateSource(cooldown: TimeSpan.FromMinutes(5));
+ source.Decide(Context(120.0), Now);
+
+ var timeoutAt = Now + Window + TimeSpan.FromSeconds(1);
+ var timedOut = source.Decide(Context(120.0), timeoutAt);
+
+ Assert.False(timedOut.ShouldAlert);
+ Assert.False(source.IsWindowOpen);
+ Assert.Equal(timeoutAt.AddMinutes(5), source.NextAlertTimeUtc);
+ Assert.True(source.IsCoolingDown(timeoutAt.AddSeconds(1)));
+ }
+
+ [Fact]
+ public void Decide_Should_NotOpenWindowAgain_WhileTimeoutCooldownActive()
+ {
+ // 超时后的冷却期内即使仍超阈值也不开窗,否则三角会立即重新常亮
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.False(source.IsWindowOpen);
+ }
+
+ [Fact]
+ public void Decide_Should_OpenWindowAgain_AfterTimeoutCooldownElapsed()
+ {
+ var source = CreateSource(cooldown: TimeSpan.FromMinutes(5));
+ source.Decide(Context(120.0), Now);
+ var timeoutAt = Now + Window + TimeSpan.FromSeconds(1);
+ source.Decide(Context(120.0), timeoutAt);
+
+ source.Decide(Context(120.0), timeoutAt.AddMinutes(5));
+
Assert.True(source.IsWindowOpen);
}
@@ -235,15 +340,16 @@ public void IsCoolingDown_Should_BeTrue_AfterAlert_UntilCooldownElapsed()
}
[Fact]
- public void IsCoolingDown_Should_BeFalse_WhenWindowClosedWithoutConfirming()
+ public void IsCoolingDown_Should_BeTrue_WhenWindowClosedWithoutConfirming()
{
- // 窗口超时未确认不进入冷却,状态点不应因超时保持点亮
+ // 超时未确认同样进入冷却:状态点在超时后转为冷却空心方,而不是一直停在三角
var source = CreateSource(cooldown: TimeSpan.FromMinutes(5));
source.Decide(Context(120.0), Now);
- source.Decide(Context(120.0), Now.Add(Window).AddSeconds(1));
+ var timeoutAt = Now.Add(Window).AddSeconds(1);
+ source.Decide(Context(120.0), timeoutAt);
- Assert.False(source.IsCoolingDown(Now.Add(Window).AddSeconds(1)));
+ Assert.True(source.IsCoolingDown(timeoutAt));
}
// ---- 热键匹配 ----
From 94289f0ba591d4b0ae0f7adbad31017058ea3a3e Mon Sep 17 00:00:00 2001
From: Starsky16 <163619709+Starsky16@users.noreply.github.com>
Date: Sun, 27 Sep 2026 13:36:14 +0800
Subject: [PATCH 24/25] =?UTF-8?q?feat:=20=E6=8F=90=E9=86=92=E6=AD=A3?=
=?UTF-8?q?=E6=96=87=E6=97=B6=E9=95=BF=E6=94=B9=E4=B8=BA=E5=9C=A8=E9=80=9A?=
=?UTF-8?q?=E7=9F=A5=E6=8F=90=E4=BE=9B=E6=96=B9=E9=85=8D=E7=BD=AE=EF=BC=8C?=
=?UTF-8?q?=E5=86=B7=E5=8D=B4=E6=97=B6=E9=95=BF=E4=B8=8B=E9=99=90=E6=8F=90?=
=?UTF-8?q?=E5=8D=87=E8=87=B3=2060=20=E7=A7=92?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 通知提供方新增 OverlayDurationSeconds(默认 30,面板与运行时均夹取 1..600),
用于设置提醒正文(第二段)的显示时长;遮罩沿用宿主默认 5 秒,因此一次提醒的总
时长 = 5 秒 + 本值。此前正文时长硬编码,缩短提醒只能改代码重新发布。
- 两个判定源的冷却时间下限由 0 提升至 60 秒:设置页滑块与运行时夹取双保险,兜住
存量配置里的小值。一次提醒本身会盖住状态点约 35 秒,比它更短的冷却期会被整段
遮住,冷却态根本看不见。
- README 同步:判定源②超时进冷却、状态点对照表两行、通知提供方新增设置项。
---
Alarm/AlertRuntimeService.cs | 12 ++++++++++--
Alerting/DecibelNotificationProvider.cs | 5 ++++-
.../DecibelNotificationProviderSettingsControl.axaml | 11 ++++++++++-
.../DecibelNotificationProviderSettings.cs | 10 ++++++++++
README.md | 11 ++++++-----
Views/SettingsPages/DecibelMonitorSettingsPage.axaml | 12 ++++++------
6 files changed, 46 insertions(+), 15 deletions(-)
diff --git a/Alarm/AlertRuntimeService.cs b/Alarm/AlertRuntimeService.cs
index 6eda78d..c8b947f 100644
--- a/Alarm/AlertRuntimeService.cs
+++ b/Alarm/AlertRuntimeService.cs
@@ -44,6 +44,14 @@ public sealed class AlertRuntimeService : IHostedService, IDisposable
/// 平均窗口允许的样本数上限(防止窗口时长配置过大时无界占用内存)。
private const int MaxWindowSamples = 2000;
+ /// 冷却时间的最小值(秒),与设置页冷却滑块下限一致。
+ ///
+ /// 一次提醒本身(遮罩 5 秒 + 正文)会盖住状态点约 35 秒,比它更短的冷却期会被提醒整段遮住、
+ /// 看不到冷却态。设置页已把滑块下限设为 60 秒,这里再夹一次是为了兜住存量配置里的小值
+ /// (旧版本允许设 0,语义为"无冷却")。
+ ///
+ private const int MinCooldownSeconds = 60;
+
/// 冷却期数字判据所用的短窗口时长(固定 1 秒,与判定用的平均窗口相互独立)。
private static readonly TimeSpan ShortWindowDuration = TimeSpan.FromSeconds(1);
@@ -348,11 +356,11 @@ private void ApplySettings()
_autoSource.IsEnabled = settings.AutoSourceEnabled;
_autoSource.Threshold = settings.AutoSourceThreshold;
- _autoSource.Cooldown = TimeSpan.FromSeconds(Math.Max(0, settings.AutoSourceCooldownSeconds));
+ _autoSource.Cooldown = TimeSpan.FromSeconds(Math.Max(MinCooldownSeconds, settings.AutoSourceCooldownSeconds));
_hotkeySource.IsEnabled = settings.HotkeySourceEnabled;
_hotkeySource.Threshold = settings.HotkeySourceThreshold;
- _hotkeySource.Cooldown = TimeSpan.FromSeconds(Math.Max(0, settings.HotkeySourceCooldownSeconds));
+ _hotkeySource.Cooldown = TimeSpan.FromSeconds(Math.Max(MinCooldownSeconds, settings.HotkeySourceCooldownSeconds));
_hotkeySource.WindowTimeout = TimeSpan.FromSeconds(Math.Clamp(settings.HotkeyWindowSeconds, 1, 600));
_hotkeySource.Hotkey = new HotkeyDefinition(settings.HotkeyKey ?? string.Empty, ComposeModifiers(settings));
diff --git a/Alerting/DecibelNotificationProvider.cs b/Alerting/DecibelNotificationProvider.cs
index da3ad66..479ee90 100644
--- a/Alerting/DecibelNotificationProvider.cs
+++ b/Alerting/DecibelNotificationProvider.cs
@@ -75,11 +75,14 @@ public void Notify(string? text = null)
/// 提醒正文。
private void NotifyOverThreshold(string text)
{
+ // 正文时长由通知提供方设置决定(见 DecibelNotificationProviderSettings.OverlayDurationSeconds),
+ // 遮罩沿用宿主 NotificationContent 的默认时长(5 秒)。两者文案相同,总时长 = 5 秒 + 本值。
+ var overlaySeconds = Math.Clamp(Settings.OverlayDurationSeconds, 1, 600);
var request = new NotificationRequest
{
ChannelId = Guid.Parse(OverThresholdChannelId),
MaskContent = NotificationContent.CreateSimpleTextContent(text),
- OverlayContent = NotificationContent.CreateSimpleTextContent(text, x => x.Duration = TimeSpan.FromSeconds(30)),
+ OverlayContent = NotificationContent.CreateSimpleTextContent(text, x => x.Duration = TimeSpan.FromSeconds(overlaySeconds)),
};
Channel(OverThresholdChannelId).ShowNotification(request);
}
diff --git a/Controls/NotificationProviders/DecibelNotificationProviderSettingsControl.axaml b/Controls/NotificationProviders/DecibelNotificationProviderSettingsControl.axaml
index c12af16..8183e02 100644
--- a/Controls/NotificationProviders/DecibelNotificationProviderSettingsControl.axaml
+++ b/Controls/NotificationProviders/DecibelNotificationProviderSettingsControl.axaml
@@ -20,8 +20,17 @@
+
+
+
+
+
+
diff --git a/Models/NotificationProviderSettings/DecibelNotificationProviderSettings.cs b/Models/NotificationProviderSettings/DecibelNotificationProviderSettings.cs
index 712d001..f4d9f14 100644
--- a/Models/NotificationProviderSettings/DecibelNotificationProviderSettings.cs
+++ b/Models/NotificationProviderSettings/DecibelNotificationProviderSettings.cs
@@ -17,4 +17,14 @@ public partial class DecibelNotificationProviderSettings : ObservableObject
/// 提醒正文(通知横幅显示的文字)。
///
[ObservableProperty] private string _alertText = "请保持安静";
+
+ ///
+ /// 提醒正文(第二段)的显示时长(秒),取值 1~600。
+ ///
+ ///
+ /// 一次提醒由两段拼成:遮罩固定为宿主
+ /// 的默认时长(5 秒),正文时长由本项决定,因此一次提醒的总时长 = 5 秒 + 本值。
+ /// 两段文案相同,肉眼看上去像一段;总时长短于判定源的冷却时间时,才能在提醒结束后看到冷却期的状态点。
+ ///
+ [ObservableProperty] private int _overlayDurationSeconds = 30;
}
diff --git a/README.md b/README.md
index 92ba2e1..477372b 100644
--- a/README.md
+++ b/README.md
@@ -39,8 +39,8 @@
1. 打开 **设置 → Decibel_Monitor 页 → 提醒判定**,设置平均窗口时长与判定源优先级。
2. 判定源①“自动提醒”:开启后,窗口平均分贝超过阈值即提醒(阈值默认 120,冷却时间默认 600 秒)。
-3. 判定源②“热键确认”:开启后,窗口平均分贝超过阈值时先开启筛选窗口(默认 10 秒),窗口内按下确认热键才提醒,超时未按键则不提醒;该判定源需要宿主安装并启用 KeyboardCapture 插件。
-4. 提醒正文与语音在 **设置 → 通知 → 分贝提醒** 及宿主的提醒设置中配置;本页只决定“何时提醒”。
+3. 判定源②“热键确认”:开启后,窗口平均分贝超过阈值时先开启筛选窗口(默认 10 秒),窗口内按下确认热键才提醒;**窗口超时未按键同样进入该判定源的冷却**(视为“已经给过一次机会”),因此三角不会长期常亮。该判定源需要宿主安装并启用 KeyboardCapture 插件。
+4. 提醒正文、正文显示时长与语音在 **设置 → 通知 → 分贝提醒** 及宿主的提醒设置中配置;本页只决定“何时提醒”。
5. 触发提醒时:
- 组件上的提醒状态点变为红色(含义见下方“提醒状态点”);
- 通过 ClassIsland 通知系统弹出醒目提醒;
@@ -59,8 +59,8 @@
| 形状 | 含义 | 说明 |
| --- | --- | --- |
| 绿色圆(半透明) | 正常 | 有采样、判定源已启用且未超过阈值 |
-| 红色实心方 | 正在超阈值 | 判定条件成立且尚未进入冷却。注意:提醒在同一拍即发出并写入冷却,因此**只有把该判定源的冷却时间设为 0 时才可能看到实心方**;设了冷却时间(默认 600 秒)时,超阈值会直接进入冷却,显示为空心方。**例外**:时段闸门抑制期间(见第 6 条)不写冷却,此时平均分贝超阈值就会显示实心方,表示“现在确实很吵,但本时段不提醒” |
-| 红色空心方 | 冷却期 | 提醒已发出,冷却期内不再重复提醒;**冷却期内即使仍超过阈值也显示空心方**(空心表示“提醒已发生过”而非“正在触发”) |
+| 红色实心方 | 正在超阈值 | 判定条件成立且尚未进入冷却。注意:提醒在同一拍即发出并写入冷却,而冷却时间的最小可设值是 60 秒,因此**实际使用中看不到实心方**(会直接进入冷却、显示为空心方)。**例外**:时段闸门抑制期间(见第 6 条)不写冷却,此时平均分贝超阈值就会显示实心方,表示“现在确实很吵,但本时段不提醒” |
+| 红色空心方 | 冷却期 | 提醒已发出(含判定源②窗口超时未按键的情形),冷却期内不再重复提醒;**冷却期内即使仍超过阈值也显示空心方**(空心表示“提醒已发生过”而非“正在触发”)。冷却时间最小 60 秒,是为了让冷却期在一次提醒(遮罩 5 秒 + 正文,见下)结束之后仍有一段能被看见 |
| 橙色三角 | 待热键确认 | 判定源②的筛选窗口已开启,需在窗口内按下热键;窗口剩余时间进入 `min(3 秒, 窗口时长 / 4)` 后三角会呼吸,提示尽快按键(窗口其余时间静置) |
数字颜色与形状相互独立,只反映“当前值是否满足告警条件”:
@@ -88,7 +88,8 @@
### ② 通知提供方设置(`DecibelNotificationProviderSettings`,全局)
-- `AlertText` (string):提醒正文(通知横幅显示的文字),默认“请保持安静”。启用开关、阈值与冷却**不在此处**,由插件设置页的判定源负责(避免“判定源已裁定提醒、提供方却因自身阈值不发通知”的双重门槛)。
+- `AlertText` (string):提醒正文(通知横幅显示的文字),默认“请保持安静”。
+- `OverlayDurationSeconds` (int):提醒正文(第二段)的显示时长(秒,面板范围 1..600,运行时夹取 1..600),默认 30。一次提醒由遮罩(固定 5 秒,宿主 `NotificationContent` 默认值)与正文两段拼成且文案相同,故**一次提醒的总时长 = 5 秒 + 本值**;总时长比判定源的冷却时间短时,提醒结束后才能看到冷却期的状态点。启用开关、阈值与冷却**不在此处**,由插件设置页的判定源负责(避免“判定源已裁定提醒、提供方却因自身阈值不发通知”的双重门槛)。
### ③ 插件全局设置(`DecibelMonitorGlobalSettings`)
diff --git a/Views/SettingsPages/DecibelMonitorSettingsPage.axaml b/Views/SettingsPages/DecibelMonitorSettingsPage.axaml
index 8fbcaf8..24cf9a6 100644
--- a/Views/SettingsPages/DecibelMonitorSettingsPage.axaml
+++ b/Views/SettingsPages/DecibelMonitorSettingsPage.axaml
@@ -134,9 +134,9 @@
+ Description="两次提醒之间的最小间隔;冷却期内状态点为红色空心方(表示提醒已发生)但不重复提醒。最小值 60 秒:一次提醒本身约 35 秒会盖住状态点,更短的冷却期会被提醒整段遮住、看不到。">
-
@@ -163,9 +163,9 @@
+ Description="两次提醒之间的最小间隔;窗口超时未按键也会进入冷却;冷却期内不再开启筛选窗口,状态点保持红色空心方。最小值 60 秒:一次提醒本身约 35 秒会盖住状态点,更短的冷却期会被提醒整段遮住、看不到。">
-
+ Description="窗口开启后等待按键确认的时间,超时则关闭窗口且不提醒,并按判定源②的冷却时间进入冷却;窗口开启期间组件状态点为橙色三角,窗口即将超时时三角会呼吸提示。">
Date: Sun, 27 Sep 2026 13:50:30 +0800
Subject: [PATCH 25/25] =?UTF-8?q?refactor:=20=E5=88=A0=E9=99=A4=E5=B7=B2?=
=?UTF-8?q?=E6=97=A0=E8=AF=BB=E5=8F=96=E6=96=B9=E7=9A=84=20IndicatorStateC?=
=?UTF-8?q?hanged=20=E5=8F=8A=E5=85=B6=E5=A4=B1=E6=95=88=E6=B3=A8=E9=87=8A?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
组件改为自持"最近一次已应用值"比对后,运行时服务上的 IndicatorStateChanged 不再有
任何读取方,其注释"组件据此跳过无变化的视觉重设"也与事实不符,留着会误导后续维护。
删掉属性、其赋值,以及组件注释里对该属性的指名引用(该注释保留,它是自持比对的原因说明)。
不涉及 IndicatorStateResolver 快照里的 StateChanged/Changed:那是解析器纯逻辑模块的
输出契约,有独立单测覆盖,不在本次清理范围。
---
Alarm/AlertRuntimeService.cs | 4 ----
Controls/Components/DecibelComponent.axaml.cs | 4 ++--
2 files changed, 2 insertions(+), 6 deletions(-)
diff --git a/Alarm/AlertRuntimeService.cs b/Alarm/AlertRuntimeService.cs
index c8b947f..cee5e1e 100644
--- a/Alarm/AlertRuntimeService.cs
+++ b/Alarm/AlertRuntimeService.cs
@@ -86,9 +86,6 @@ public sealed class AlertRuntimeService : IHostedService, IDisposable
/// 筛选窗口剩余时间进入呼吸阈值后为真(组件据此让三角呼吸)。
public bool IsIndicatorBreathing { get; private set; }
- /// 本次解析中状态或数字判据是否发生变化(组件据此跳过无变化的视觉重设)。
- public bool IndicatorStateChanged { get; private set; }
-
/// 是否至少启用了一个判定源(决定组件是否显示提醒状态点)。
public bool IsAnySourceEnabled => _autoSource.IsEnabled || _hotkeySource.IsEnabled;
@@ -286,7 +283,6 @@ private void UpdateIndicator(DateTime nowUtc, double average, double shortAverag
IndicatorState = snapshot.State;
IndicatorValueState = snapshot.ValueState;
IsIndicatorBreathing = snapshot.IsBreathing;
- IndicatorStateChanged = snapshot.Changed;
}
///
diff --git a/Controls/Components/DecibelComponent.axaml.cs b/Controls/Components/DecibelComponent.axaml.cs
index 7107af6..621608e 100644
--- a/Controls/Components/DecibelComponent.axaml.cs
+++ b/Controls/Components/DecibelComponent.axaml.cs
@@ -210,11 +210,11 @@ private void UpdateTimer_Tick(object? sender, EventArgs e)
// 无采样或未启用任何判定源时(解析结果 NoData)不显示任何形状
IsIndicatorVisible = showIndicator && state != IndicatorState.NoData;
- // 呼吸的起止由时间推进而非状态变化触发,不在 Change 标志内,必须每拍读取
+ // 呼吸的起止由时间推进而非状态变化触发,不参与下方的自持比对,必须每拍读取
IsBreathing = _runtimeService.IsIndicatorBreathing;
// 形状与数字颜色是"离散状态":只在与本实例已应用的组合不同时才重设。
- // 这里不能依赖服务侧的 IndicatorStateChanged —— 它是宽度只有一个采样拍(200 ms)的脉冲,
+ // 这里不能依赖服务侧的"状态已变化"标志 —— 它由采样判定逐拍产出,宽度只有一个采样拍(200 ms),
// 组件节拍(默认 500 ms)可能整拍落在脉冲之外;一旦漏读,形状会一直冻结在旧值,
// 直到下一次被捕获的脉冲为止(表现为"热键窗口已开却仍显示空心方",且此时按键仍能提醒)。
// 改为自持最近一次应用值比对后,最坏情况只是滞后一个组件节拍,不会长期冻结。