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 1/6] =?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 2/6] =?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 3/6] =?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 4/6] =?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 5/6] =?UTF-8?q?ci:=20=E6=96=B0=E5=A2=9E=E9=9B=86=E6=88=90?=
=?UTF-8?q?=E6=B5=8B=E8=AF=95=E5=B7=A5=E4=BD=9C=E6=B5=81=E5=B9=B6=E8=B0=83?=
=?UTF-8?q?=E6=95=B4=E6=9E=84=E5=BB=BA/=E6=B5=8B=E8=AF=95=E5=B7=A5?=
=?UTF-8?q?=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 6/6] =?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