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/.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/Alarm/AlertRuntimeService.cs b/Alarm/AlertRuntimeService.cs new file mode 100644 index 0000000..7f11912 --- /dev/null +++ b/Alarm/AlertRuntimeService.cs @@ -0,0 +1,216 @@ +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.Models; +using Decibel_Monitor.Services; +using Microsoft.Extensions.Hosting; + +namespace Decibel_Monitor.Alarm; + +/// +/// 提醒运行时编排服务:按固定节拍采样 → 构造判定上下文 → 交由仲裁模块裁定 → +/// 裁定提醒时接入通知提供方,并进入 3 秒防自激(暂停分贝采样判定)。 +/// +/// +/// +/// 采样独立于组件视觉树:组件只读取本服务的最新状态用于显示,提醒链路不依赖组件是否显示。 +/// 设置(各判定源的启用/阈值/冷却/专属参数、平均窗口、优先级顺序)在每个节拍从插件全局设置同步到判定源, +/// 设置页改动下一拍即生效。 +/// 通知的唯一出口是仲裁模块的 事件; +/// 本服务把它接到现有 ,强调通知与语音由宿主通知系统负责, +/// 不新增通知/语音开关。 +/// +/// +public sealed class AlertRuntimeService : IHostedService, IDisposable +{ + /// 采样与判定节拍。 + private static readonly TimeSpan SampleInterval = TimeSpan.FromMilliseconds(200); + + /// 防自激时长:执行提醒后暂停分贝采样判定。 + private static readonly TimeSpan AntiSelfKickDelay = TimeSpan.FromSeconds(3); + + /// 平均窗口允许的样本数上限(防止窗口时长配置过大时无界占用内存)。 + 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 string[]? _appliedPriorityOrder; + private bool _disposed; + + /// 最新一次采样的分贝映射值(显示刻度 0..150)。 + public double CurrentDecibel { get; private set; } + + /// 是否已取得过至少一次采样(未取得时组件显示占位文本)。 + public bool HasSample { get; private set; } + + /// + /// 当前是否应点亮提醒状态点(红):任一判定源处于超阈值、筛选窗口开启或冷却期。 + /// 绿点表示上述条件均不成立。 + /// + public bool IsAlertStateActive { get; private set; } + + /// 是否至少启用了一个判定源(决定组件是否显示提醒状态点)。 + public bool IsAnySourceEnabled => _autoSource.IsEnabled || _hotkeySource.IsEnabled; + + /// 热键判定源所处的筛选窗口是否开启(组件据此把状态点显示为红绿闪动)。 + public bool IsHotkeyWindowOpen => _hotkeySource.IsWindowOpen; + + /// 共享的麦克风峰值采样服务。 + /// 插件全局设置服务(可空;缺省时使用判定源内置默认参数)。 + /// 仲裁模块(自持判定源集合与优先级配置)。 + /// 自动提醒判定源(同步其设置)。 + /// 热键确认判定源(同步其设置,并暴露筛选窗口状态)。 + public AlertRuntimeService( + AudioPeakMeter audioPeakMeter, + DecibelMonitorSettingsService? settingsService, + 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 = SampleInterval }; + _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) + { + DecibelNotificationProvider.Instance?.Notify(); + } + + // 异步 Tick:全程保持在 DispatcherTimer 绑定的 UI 线程上下文(不就地切线程), + // 与热键确认封送回 UI 线程的处理保持一致,避免判定源状态被并发修改。 + private async void Timer_Tick(object? sender, EventArgs e) + { + if (_disposed) return; + + try + { + ApplySettings(); + + // 防自激:执行提醒后 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); + while (_window.Count > capacity) _window.RemoveAt(0); + var average = AverageVolumeAlertEvaluator.CalculateAverage(_window); + + var decision = _coordinator.Decide(new AlertContext(mapped, average), DateTime.UtcNow); + + // 提醒状态点:超阈值、筛选窗口开启、以及提醒后的冷却期都点亮(红点),其余为绿点 + var nowUtc = DateTime.UtcNow; + IsAlertStateActive = _autoSource.IsTriggerActive + || _hotkeySource.IsTriggerActive + || _hotkeySource.IsWindowOpen + || _autoSource.IsCoolingDown(nowUtc) + || _hotkeySource.IsCoolingDown(nowUtc); + + if (!decision.ShouldAlert) return; + + // 提醒已执行(Decide 内部经 NotifyRequested 发出)→ 进入防自激, + // 并清空样本窗口,避免残留高值在恢复后立即再次触发。 + _pauseUntilUtc = DateTime.UtcNow.Add(AntiSelfKickDelay); + _window.Clear(); + } + catch + { + // 采样/判定任一步骤失败都不应抛出到定时器循环 + } + } + + /// 把插件全局设置同步到各判定源与仲裁模块(设置页改动下一拍即生效)。 + 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() + { + 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..619e1c4 --- /dev/null +++ b/Alarm/HotkeyWindowMonitor.cs @@ -0,0 +1,145 @@ +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 readonly HotkeyConfirmDecisionSource _source; + private readonly object _gate = new(); + private Timer? _retryTimer; + private IKeyboardCaptureService? _capture; + private bool _disposed; + + /// 热键判定源(由 DI 以单例注入)。 + public HotkeyWindowMonitor(HotkeyConfirmDecisionSource source) + { + _source = source ?? throw new ArgumentNullException(nameof(source)); + } + + /// + public Task StartAsync(CancellationToken cancellationToken) + { + TryResolveAndSubscribe(); + return Task.CompletedTask; + } + + /// + public Task StopAsync(CancellationToken cancellationToken) + { + Unsubscribe(); + 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 Unsubscribe() + { + lock (_gate) + { + _retryTimer?.Dispose(); + _retryTimer = null; + if (_capture is not null) + { + _capture.KeyDown -= OnKeyDown; + _capture = null; + } + } + } + + /// + public void Dispose() + { + if (_disposed) return; + _disposed = true; + Unsubscribe(); + } +} \ No newline at end of file diff --git a/Alerting/AlertDecisionCoordinator.cs b/Alerting/AlertDecisionCoordinator.cs new file mode 100644 index 0000000..cbf6a03 --- /dev/null +++ b/Alerting/AlertDecisionCoordinator.cs @@ -0,0 +1,113 @@ +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 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("至少需要一个判定源。"); + + _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 matched = sources.FirstOrDefault(s => s.Id == id); + if (matched is not null && ordered.All(o => o.Id != matched.Id)) + { + ordered.Add(matched); + } + } + + // 仅追加未被优先级顺序覆盖的判定源(保持稳定顺序) + foreach (var s in sources) + { + if (ordered.All(o => o.Id != s.Id)) + { + ordered.Add(s); + } + } + + return ordered.ToArray(); + } + + /// + /// 依据当前上下文仲裁是否提醒:调用所有启用判定源(各自推进内部状态机),取优先级最高的触发结果。 + /// 若需提醒,触发 事件并返回对应结果。 + /// + 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..1988eae --- /dev/null +++ b/Alerting/AutoThresholdDecisionSource.cs @@ -0,0 +1,76 @@ +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 string Description => "平均分贝超过阈值时立即提醒。"; + + /// + 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 bool IsCoolingDown(DateTime nowUtc) => + IsEnabled && _nextAlertTimeUtc != DateTime.MinValue && nowUtc < _nextAlertTimeUtc; + + /// + 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/DecibelCalculator.cs b/Alerting/DecibelCalculator.cs new file mode 100644 index 0000000..a49dd5c --- /dev/null +++ b/Alerting/DecibelCalculator.cs @@ -0,0 +1,62 @@ +using System; + +namespace Decibel_Monitor.Alerting; + +/// +/// 分贝换算纯函数工具类(与 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/Alerting/DecibelNotificationProvider.cs b/Alerting/DecibelNotificationProvider.cs new file mode 100644 index 0000000..da3ad66 --- /dev/null +++ b/Alerting/DecibelNotificationProvider.cs @@ -0,0 +1,97 @@ +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.Alerting; + +/// +/// 分贝提醒通知提供方。 +/// 负责发出 ClassIsland 醒目提醒(强调通知与语音均由宿主通知系统负责), +/// 文案来自设置中的 。 +/// +/// +/// 边界说明:本提供方只负责"发什么内容",不做阈值/冷却/启停判定—— +/// 那部分收敛到插件设置页的判定源()与 +/// 仲裁模块(),避免双重判定门槛。 +/// 宿主接入仍走 以获取自动持久化的设置, +/// 并经 渠道发送,与 ClassIsland 内置提供方一致。 +/// +[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; + + /// + /// 当前通知提供方实例(宿主启动后由 DI 以单例方式创建)。 + /// + public static DecibelNotificationProvider? Instance => _instance; + + public DecibelNotificationProvider(ILogger? logger = null) + { + _logger = logger; + _instance = this; + } + + /// + /// 发出一次"超过分贝阈值"提醒,文案取设置中的提醒正文。 + /// 调用方(仲裁模块 / 运行时编排)负责判定"是否该提醒"。 + /// + /// 提醒正文;为空时回退到设置中的提醒正文。 + public void Notify(string? text = null) + { + try + { + NotifyOverThreshold(string.IsNullOrWhiteSpace(text) ? Settings.AlertText : text); + } + catch (Exception ex) + { + // 通知失败不应影响主功能 + _logger?.LogWarning(ex, "发送分贝提醒失败。"); + } + } + + /// + /// 发送"超过分贝阈值"提醒。 + /// + /// 提醒正文。 + private void NotifyOverThreshold(string text) + { + 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); + } + + /// + /// 释放时清理静态实例引用,避免插件重载或宿主重启后残留已失效的引用。 + /// + public void Dispose() + { + if (ReferenceEquals(_instance, this)) + { + _instance = null; + } + } +} \ No newline at end of file diff --git a/Alerting/HotkeyConfirmDecisionSource.cs b/Alerting/HotkeyConfirmDecisionSource.cs new file mode 100644 index 0000000..17af910 --- /dev/null +++ b/Alerting/HotkeyConfirmDecisionSource.cs @@ -0,0 +1,152 @@ +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 string Description => "超过阈值时开启筛选窗口,窗口内按下热键才提醒。"; + + /// + 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 IsCoolingDown(DateTime nowUtc) => + IsEnabled && _nextAlertTimeUtc != DateTime.MinValue && nowUtc < _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..4c60d18 --- /dev/null +++ b/Alerting/IAlertDecisionSource.cs @@ -0,0 +1,61 @@ +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; } + + /// 判定源简要说明(设置页优先级列表展示)。 + string Description { get; } + + /// 是否启用该判定源。 + bool IsEnabled { get; } + + /// 该判定源自身的提醒冷却时间(两次提醒之间的最小间隔)。 + TimeSpan Cooldown { get; } + + /// + /// 当前是否处于冷却期(上次提醒后尚未超过 ,不具备再次提醒的资格)。 + /// 未启用的判定源恒为 false。 + /// + /// 当前时间(UTC)。 + bool IsCoolingDown(DateTime nowUtc); + + /// + /// 依据当前上下文判定是否提醒。未启用时应返回"不提醒"并清空内部状态。 + /// + /// 当前判定上下文。 + /// 当前时间(UTC)。 + AlertDecision Decide(AlertContext context, DateTime nowUtc); +} diff --git a/ClassIsland.Desktop/PluginLoadContext.cs b/ClassIsland.Desktop/PluginLoadContext.cs deleted file mode 100644 index e69de29..0000000 diff --git a/Controls/ComponentSettings/DecibelComponentSettingsControl.axaml b/Controls/ComponentSettings/DecibelComponentSettingsControl.axaml index 6e15a48..69694dd 100644 --- a/Controls/ComponentSettings/DecibelComponentSettingsControl.axaml +++ b/Controls/ComponentSettings/DecibelComponentSettingsControl.axaml @@ -1,17 +1,3 @@ - - - + - - - - - + + - -