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..9b0b068 --- /dev/null +++ b/Alarm/AlertRuntimeService.cs @@ -0,0 +1,388 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Avalonia.Threading; +using ClassIsland.Core.Abstractions.Services; +using ClassIsland.Shared; +using ClassIsland.Shared.Enums; +using Decibel_Monitor.Alerting; +using Decibel_Monitor.Measurement; +using Decibel_Monitor.Models; +using Decibel_Monitor.Services; +using Microsoft.Extensions.Hosting; + +namespace Decibel_Monitor.Alarm; + +/// +/// 提醒运行时编排服务:按固定节拍采样 → 构造判定上下文 → 交由仲裁模块裁定 → +/// 裁定提醒时接入通知提供方,并进入 3 秒防自激(暂停分贝采样判定)。 +/// +/// +/// +/// 采样独立于组件视觉树:组件只读取本服务的最新状态用于显示,提醒链路不依赖组件是否显示。 +/// 设置(各判定源的启用/阈值/冷却/专属参数、平均窗口、优先级顺序)在每个节拍从插件全局设置同步到判定源, +/// 设置页改动下一拍即生效。 +/// 通知的唯一出口是仲裁模块的 事件; +/// 本服务把它接到现有 ,强调通知与语音由宿主通知系统负责, +/// 不新增通知/语音开关。 +/// 提醒状态点由 解析:本服务只负责压平各判定源状态、 +/// 维护判定用的平均窗口与冷却期判据所用的 1 秒短窗口,状态含义与滞回规则全部归解析器(纯逻辑、可单测)。 +/// 时段闸门:课间休息与每节课开头若干分钟内不推进判定源(, +/// 依赖宿主课表状态)。这些时段的误报既不发出通知、也不占用冷却;状态点仍照常显示当前是否超阈值。 +/// +/// +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; + + /// 冷却期数字判据所用的短窗口时长(固定 1 秒,与判定用的平均窗口相互独立)。 + private static readonly TimeSpan ShortWindowDuration = TimeSpan.FromSeconds(1); + + private readonly AudioPeakMeter _audioPeakMeter; + private readonly DecibelMonitorSettingsService? _settingsService; + private readonly AlertDecisionCoordinator _coordinator; + private readonly AutoThresholdDecisionSource _autoSource; + private readonly HotkeyConfirmDecisionSource _hotkeySource; + private readonly List _window = new(); + private readonly Queue<(DateTime TimestampUtc, float Value)> _shortWindow = new(); + private readonly IndicatorStateResolver _indicatorResolver = new(); + private readonly ScheduleGate _scheduleGate = new(); + private readonly DispatcherTimer _timer; + private DateTime _pauseUntilUtc = DateTime.MinValue; + private string[]? _appliedPriorityOrder; + private string? _lastDeviceDescription; + private ILessonsService? _lessonsService; + private bool _disposed; + + /// 最新一次采样的分贝映射值(显示刻度 0..150)。 + public double CurrentDecibel { get; private set; } + + /// 当前是否存在可用的默认捕获设备并已取得采样(否则组件显示占位文本与无形状状态)。 + public bool HasSample { get; private set; } + + /// 提醒状态点的当前状态(组件据此选择形状与填充)。 + public IndicatorState IndicatorState { get; private set; } = IndicatorState.NoData; + + /// 数字颜色的判据结果:当前值是否已满足告警条件。 + public IndicatorValueState IndicatorValueState { get; private set; } = IndicatorValueState.Unknown; + + /// 筛选窗口剩余时间进入呼吸阈值后为真(组件据此让三角呼吸)。 + public bool IsIndicatorBreathing { get; private set; } + + /// 本次解析中状态或数字判据是否发生变化(组件据此跳过无变化的视觉重设)。 + public bool IndicatorStateChanged { get; private set; } + + /// 是否至少启用了一个判定源(决定组件是否显示提醒状态点)。 + public bool IsAnySourceEnabled => _autoSource.IsEnabled || _hotkeySource.IsEnabled; + + /// 共享的麦克风峰值采样服务。 + /// 插件全局设置服务(可空;缺省时使用判定源内置默认参数)。 + /// 仲裁模块(自持判定源集合与优先级配置)。 + /// 自动提醒判定源(同步其设置)。 + /// 热键确认判定源(同步其设置,并暴露筛选窗口状态)。 + public 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 nowUtc = DateTime.UtcNow; + + // 默认捕获设备发生变化(含被拔出/禁用后为 null)时清空解析器状态与短窗口, + // 避免残留上一次的红色与迟滞标记 + var deviceDescription = _audioPeakMeter.GetDefaultCaptureDeviceDescription(); + if (deviceDescription != _lastDeviceDescription) + { + _lastDeviceDescription = deviceDescription; + _indicatorResolver.Reset(); + _shortWindow.Clear(); + } + + // 无默认捕获设备时视为无采样:状态点不显示任何形状、数字显示占位文本, + // 不再伪装成"绿色正常"(仍需解析一次,否则组件读到的是上一次的状态) + if (deviceDescription is null) + { + HasSample = false; + UpdateIndicator(nowUtc, average: 0.0, shortAverage: 0.0, scheduleGateSuppressed: false); + 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); + + // 维护 1 秒短窗口平均(供冷却期数字判据使用,与判定用的平均窗口相互独立): + // 先入队当前样本,再按真实时间戳剔除 1 秒之前的样本 + _shortWindow.Enqueue((nowUtc, (float)mapped)); + var shortAverage = GetShortWindowAverage(nowUtc, mapped); + + // 时段闸门:课间与上课初期抑制提醒。闸门生效时必须跳过 Decide 调用—— + // Decide 一旦被调用就会写入冷却时间,课间的一次误报会吃掉整段冷却, + // 导致进入上课后该提醒而不提醒(比不提醒更严重)。 + var scheduleGateSuppressed = EvaluateScheduleGate(nowUtc); + + var decision = default(CoordinatorDecision); + if (!scheduleGateSuppressed) + { + decision = _coordinator.Decide(new AlertContext(mapped, average), nowUtc); + } + + // 提醒状态点:由纯逻辑解析器把各判定源状态合并为单一枚举(取"是否需要用户动手"最高者) + UpdateIndicator(nowUtc, average, shortAverage, scheduleGateSuppressed); + + if (!decision.ShouldAlert) return; + + // 提醒已执行(Decide 内部经 NotifyRequested 发出)→ 进入防自激, + // 并清空样本窗口,避免残留高值在恢复后立即再次触发。 + _pauseUntilUtc = DateTime.UtcNow.Add(AntiSelfKickDelay); + _window.Clear(); + } + catch + { + // 采样/判定任一步骤失败都不应抛出到定时器循环 + } + } + + /// + /// 解析提醒状态并更新组件读取的显示属性。 + /// + /// 当前时间(UTC)。 + /// 判定用的滑动窗口平均分贝。 + /// 冷却期数字判据所用的 1 秒短窗口平均分贝。 + /// 本拍是否被时段闸门抑制(抑制时判定源未被推进,其状态不可用于显示)。 + private void UpdateIndicator(DateTime nowUtc, double average, double shortAverage, bool scheduleGateSuppressed) + { + var threshold = GetIndicatorThreshold(); + + bool windowOpen; + bool triggerActive; + bool coolingDown; + TimeSpan remaining; + + if (scheduleGateSuppressed) + { + // 闸门生效期间判定源本拍未被推进,其内部状态(超阈值 / 冷却 / 筛选窗口)停留在闸门开始前的旧值, + // 因此不参与显示。状态点改由"当前窗口平均是否超阈值"直接驱动, + // 只回答"现在吵不吵"——与"课间只停通知、照常显示噪音"的约定一致。 + windowOpen = false; + triggerActive = IsAnySourceEnabled && average > threshold; + coolingDown = false; + remaining = TimeSpan.Zero; + } + else + { + windowOpen = _hotkeySource.IsWindowOpen; + var autoCoolingDown = _autoSource.IsCoolingDown(nowUtc); + var hotkeyCoolingDown = _hotkeySource.IsCoolingDown(nowUtc); + + remaining = windowOpen && _hotkeySource.WindowOpenUntilUtc > nowUtc + ? _hotkeySource.WindowOpenUntilUtc - nowUtc + : TimeSpan.Zero; + + // "正在超阈值"只在判定源未处于冷却期时成立:提醒一旦发出即进入冷却, + // 此后由冷却态表达形状(空心方,表示"提醒已发生过"), + // "现在是否还在吵"交给数字颜色(冷却期改用 1 秒短窗口平均)。 + triggerActive = + (_autoSource.IsTriggerActive && !autoCoolingDown) || + (_hotkeySource.IsTriggerActive && !hotkeyCoolingDown); + coolingDown = autoCoolingDown || hotkeyCoolingDown; + } + + var snapshot = _indicatorResolver.Update(new IndicatorInputs + { + HasSample = HasSample, + AnySourceEnabled = IsAnySourceEnabled, + AnyWindowOpen = windowOpen, + AnyTriggerActive = triggerActive, + AnyCoolingDown = coolingDown, + AverageDecibel = average, + ShortWindowAverageDecibel = shortAverage, + Threshold = threshold, + HotkeyWindowRemaining = remaining, + HotkeyWindowTimeout = _hotkeySource.WindowTimeout, + }, nowUtc); + + IndicatorState = snapshot.State; + IndicatorValueState = snapshot.ValueState; + IsIndicatorBreathing = snapshot.IsBreathing; + IndicatorStateChanged = snapshot.Changed; + } + + /// + /// 推进时段闸门,返回本拍是否应抑制提醒。 + /// + /// + /// 宿主课程服务不可用时按"不抑制"处理,行为回到未接入闸门之前;课表未启用或未加载时由闸门内部旁路。 + /// 服务惰性解析并缓存(宿主服务在本插件加载时通常已就绪,解析失败则下一拍重试)。 + /// + private bool EvaluateScheduleGate(DateTime nowUtc) + { + var lessons = _lessonsService ??= IAppHost.TryGetService(); + if (lessons is null) return false; + + var settings = _settingsService?.Settings; + return _scheduleGate.Update( + ToSchedulePhase(lessons.CurrentState), + lessons.IsClassPlanEnabled, + lessons.IsClassPlanLoaded, + settings?.SuppressAlertDuringBreak ?? true, + settings?.ClassStartProtectionMinutes ?? 3, + nowUtc); + } + + /// 把宿主的 映射为闸门关心的时段类别(准备上课、放学等一律不抑制)。 + private static SchedulePhase ToSchedulePhase(TimeState state) => state switch + { + TimeState.OnClass => SchedulePhase.OnClass, + TimeState.Breaking => SchedulePhase.Breaking, + _ => SchedulePhase.Other, + }; + + /// + /// 数字颜色判据的阈值:取已启用判定源中的最小阈值。 + /// 各源的判据共用同一数值,因此"任一源超阈"等价于"数值超过最小阈值";未启用任何源时取 0(该情形下判据为未知)。 + /// + private double GetIndicatorThreshold() + { + var threshold = double.MaxValue; + if (_autoSource.IsEnabled) threshold = Math.Min(threshold, _autoSource.Threshold); + if (_hotkeySource.IsEnabled) threshold = Math.Min(threshold, _hotkeySource.Threshold); + return threshold == double.MaxValue ? 0.0 : threshold; + } + + /// + /// 剔除 1 秒之前的样本并返回短窗口平均;窗口内无样本时返回 (最近一次采样值)。 + /// + private double GetShortWindowAverage(DateTime nowUtc, double fallback) + { + while (_shortWindow.Count > 0 && nowUtc - _shortWindow.Peek().TimestampUtc > ShortWindowDuration) + { + _shortWindow.Dequeue(); + } + + if (_shortWindow.Count == 0) return fallback; + + double sum = 0.0; + foreach (var sample in _shortWindow) sum += sample.Value; + return sum / _shortWindow.Count; + } + + /// 把插件全局设置同步到各判定源与仲裁模块(设置页改动下一拍即生效)。 + private void ApplySettings() + { + 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..7bee91e --- /dev/null +++ b/Alerting/HotkeyConfirmDecisionSource.cs @@ -0,0 +1,158 @@ +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; } + + /// + /// 筛选窗口的截止时间(UTC)。窗口未开启时为 + /// (供运行时服务计算剩余时间与呼吸时机)。 + /// + public DateTime WindowOpenUntilUtc => IsWindowOpen ? _windowOpenUntilUtc : DateTime.MinValue; + + /// 当前是否处于"超过阈值"状态(供组件显示提醒状态)。 + 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/Alerting/IndicatorStateResolver.cs b/Alerting/IndicatorStateResolver.cs new file mode 100644 index 0000000..8537007 --- /dev/null +++ b/Alerting/IndicatorStateResolver.cs @@ -0,0 +1,301 @@ +using System; + +namespace Decibel_Monitor.Alerting; + +/// +/// 提醒状态点的离散状态(组件据此选择形状与填充,不再自行解释布尔条件)。 +/// +public enum IndicatorState +{ + /// 无采样,或未启用任何判定源:不显示任何形状。 + NoData, + + /// 有采样且各判定条件均不成立。 + Normal, + + /// + /// 任一判定源当前处于"超过阈值且未冷却"状态(提醒正由仲裁模块发出)。 + /// 提醒与冷却在同一拍写入,因此该态实际只在判定源冷却时间为 0 时可见。 + /// + Alerting, + + /// 任一判定源处于提醒后的冷却期(提醒已经发生过)。 + CoolingDown, + + /// 热键筛选窗口开启,等待按键确认——唯一需要用户动手的状态。 + AwaitingHotkey +} + +/// +/// 数字颜色的判据结果:当前值是否已满足告警条件(与 相互独立)。 +/// +public enum IndicatorValueState +{ + /// 无采样 / 未启用任何判定源:数字使用默认色。 + Unknown, + + /// 判据不成立:数字为绿。 + Below, + + /// 判据成立:数字为红。 + Above +} + +/// +/// 一次状态解析的输入(由运行时服务在每个采样周期构造)。 +/// +/// +/// 输入均为"已压平"的布尔量与数值,不携带判定源身份——状态点不表达源身份, +/// 两个源同时启用时按"是否需要用户动手"取最高者。 +/// +public readonly record struct IndicatorInputs +{ + /// 是否已取得过至少一次采样。 + public bool HasSample { get; init; } + + /// 是否至少启用了一个判定源。 + public bool AnySourceEnabled { get; init; } + + /// 任一判定源的热键筛选窗口是否开启。 + public bool AnyWindowOpen { get; init; } + + /// + /// 任一判定源是否处于"超过阈值且未处于冷却期"状态(判定条件成立、提醒即将或正在发出)。 + /// 已进入冷却的源不算数——否则低阈值用法下冷却期内会一直显示"正在超阈值","刚提醒过"这一事实会被盖掉。 + /// + public bool AnyTriggerActive { get; init; } + + /// 任一判定源是否处于提醒后的冷却期。 + public bool AnyCoolingDown { get; init; } + + /// 判定用的滑动窗口平均分贝(显示刻度 0..150)。 + public double AverageDecibel { get; init; } + + /// 1 秒短窗口平均分贝(显示刻度 0..150),冷却期内作为数字颜色判据。 + public double ShortWindowAverageDecibel { get; init; } + + /// + /// 数字颜色判据的阈值(显示刻度 0..150)。多个判定源同时启用时应传入其中最小者: + /// 各源判据共用同一个数值,因此"任一源超阈"等价于"数值超过最小阈值"。 + /// + public double Threshold { get; init; } + + /// 热键筛选窗口的剩余时间(无窗口时为 )。 + public TimeSpan HotkeyWindowRemaining { get; init; } + + /// 热键筛选窗口的开启时长(用于换算呼吸提前量)。 + public TimeSpan HotkeyWindowTimeout { get; init; } +} + +/// +/// 一次状态解析的结果。 +/// +/// 合并后的提醒状态(已套用时间滞回)。 +/// 数字颜色的判据结果(已套用阈值迟滞)。 +/// 状态与上一次解析结果相比是否发生变化。 +/// 状态或数字判据任一发生变化(组件据此跳过无变化的视觉重设)。 +/// 当前是否应呼吸(仅窗口即将超时时为真)。 +public readonly record struct IndicatorSnapshot( + IndicatorState State, + IndicatorValueState ValueState, + bool StateChanged, + bool Changed, + bool IsBreathing); + +/// +/// 提醒状态点解析器:把各判定源的压平状态合并为单一 , +/// 并给出数字颜色判据与呼吸时机。 +/// +/// +/// 设计约束(纯逻辑,不依赖 Avalonia / NAudio / Windows,可在任意平台单测): +/// +/// 合并规则按"是否需要用户动手"排序:窗口 > 超阈值 > 冷却 > 正常 > 无数据。 +/// 窗口必须压制冷却——两个源同时启用时自动源的冷却可达 1800 秒,若按严重度排序会把"该按键"盖掉。 +/// 的输入已排除处于冷却期的源,因此冷却期的形状恒为空心方: +/// 超阈值当拍即发出提醒并进入冷却,此后形状只表达"提醒已发生过","现在是否还在吵"交给数字颜色。 +/// 状态不携带源身份:单点无法表达 2 源 × 4 态,不做并排图标。 +/// 阈值迟滞(施密特触发)只作用于数字颜色判据,用于消除判据在阈值附近的抖动引起的颜色闪变; +/// 判定源自身的触发逻辑保持不变,因此不影响提醒行为。 +/// 时间滞回:升级立即生效,降级需新态持续 。 +/// 用于消除"两个源交替报警"造成的合并结果抖动。 +/// 时间基准:全部计时使用传入的 UTC 时间戳相减(真实经过时间), +/// 不按采样次数累加——采样间隔可配,按次数计会让"1 秒"的语义随配置漂移。 +/// +/// +public sealed class IndicatorStateResolver +{ + /// + /// 降级保持时长:切换到更低优先级的态需新态持续该时长才生效(升级不受限制,立即生效)。 + /// + public static readonly TimeSpan DowngradeHoldDuration = TimeSpan.FromSeconds(1); + + /// + /// 数字颜色判据的迟滞带宽(显示刻度单位,1 单位 = 1 dB)。 + /// + /// + /// 参照 BigScreenClock 的 HysteresisFactor = 0.8(RMS 线性域,等价 20·log10(0.8) ≈ -1.9 dB)。 + /// 本插件的阈值以显示刻度存储(displayDb = 150 + dBFS,与 dB 为 1:1 的绝对量), + /// 因此换算为绝对差:退出阈值 = 进入阈值 − 2,不能直接搬用 0.8 的乘法系数。 + /// + public const double HysteresisBandDb = 2.0; + + /// 呼吸提前量的上限:剩余时间进入该值后才开始呼吸。 + public static readonly TimeSpan MaxBreathLeadTime = TimeSpan.FromSeconds(3); + + private readonly double _hysteresisBandDb; + + private IndicatorState _state = IndicatorState.NoData; + private IndicatorValueState _valueState = IndicatorValueState.Unknown; + private bool _valueAboveThreshold; + private bool _hasSnapshot; + private IndicatorState _pendingState = IndicatorState.NoData; + private DateTime _pendingSinceUtc = DateTime.MinValue; + + /// 数字判据的迟滞带宽(显示刻度单位);为负值或 0 时表示不迟滞。 + public IndicatorStateResolver(double hysteresisBandDb = HysteresisBandDb) + { + _hysteresisBandDb = hysteresisBandDb > 0.0 ? hysteresisBandDb : 0.0; + } + + /// 当前状态(最近一次解析的结果)。 + public IndicatorState State => _state; + + /// + /// 合并规则本体:按"是否需要用户动手"排序取最高者,不含任何滞回。 + /// + /// + /// 顺序:① 任一源窗口开启 → ; + /// ② 任一源超阈值且未冷却 → + /// (判定源超阈值当拍即由仲裁模块发出提醒并写入冷却,因此该态只在冷却时间为 0 时可见); + /// ③ 任一源冷却中 → ; + /// ④ 有采样且至少启用一个判定源 → ; + /// ⑤ 否则 → (无采样或未启用任何判定源时都不显示形状)。 + /// + public static IndicatorState ResolveCandidate(in IndicatorInputs inputs) + { + if (inputs.AnyWindowOpen) return IndicatorState.AwaitingHotkey; + if (inputs.AnyTriggerActive) return IndicatorState.Alerting; + if (inputs.AnyCoolingDown) return IndicatorState.CoolingDown; + if (inputs.HasSample && inputs.AnySourceEnabled) return IndicatorState.Normal; + return IndicatorState.NoData; + } + + /// + /// 是否应呼吸:仅窗口开启且剩余时间进入 min(3 秒, 窗口时长 / 4) 时为真。 + /// 窗口开启期间其余时间静置,低阈值用法下窗口长期开启也不会持续闪烁。 + /// + /// 当前状态。 + /// 窗口剩余时间。 + /// 窗口开启时长。 + public static bool ShouldBreathe(IndicatorState state, TimeSpan windowRemaining, TimeSpan windowTimeout) + { + if (state != IndicatorState.AwaitingHotkey) return false; + + var fraction = windowTimeout > TimeSpan.Zero + ? TimeSpan.FromTicks(windowTimeout.Ticks / 4) + : TimeSpan.Zero; + var lead = fraction < MaxBreathLeadTime ? fraction : MaxBreathLeadTime; + var remaining = windowRemaining > TimeSpan.Zero ? windowRemaining : TimeSpan.Zero; + return remaining <= lead; + } + + /// + /// 用当前输入解析一次状态。 + /// + /// 当前输入。 + /// 当前时间(UTC),用于时间滞回计时。 + public IndicatorSnapshot Update(in IndicatorInputs inputs, DateTime nowUtc) + { + var candidate = ResolveCandidate(inputs); + var state = ApplyDowngradeHold(candidate, nowUtc); + + // 数字颜色判据:冷却期内改用 1 秒短窗口平均(冷却期不会再触发提醒,不存在"红了却不提醒"的错位; + // 继续用判定用的长平均窗口会因滞后而在已经安静后仍显示红色);其余情形与触发判定同源。 + var value = state == IndicatorState.CoolingDown ? inputs.ShortWindowAverageDecibel : inputs.AverageDecibel; + var valueState = ResolveValueState(inputs.HasSample && inputs.AnySourceEnabled, value, inputs.Threshold); + + var stateChanged = !_hasSnapshot || state != _state; + var valueChanged = !_hasSnapshot || valueState != _valueState; + var changed = stateChanged || valueChanged; + + _hasSnapshot = true; + _state = state; + _valueState = valueState; + + var breathing = ShouldBreathe(state, inputs.HotkeyWindowRemaining, inputs.HotkeyWindowTimeout); + return new IndicatorSnapshot(state, valueState, stateChanged, changed, breathing); + } + + /// + /// 清空全部内部状态(设备切换、采样重启时调用,避免残留上一次的红色与迟滞标记)。 + /// + public void Reset() + { + _state = IndicatorState.NoData; + _valueState = IndicatorValueState.Unknown; + _valueAboveThreshold = false; + _hasSnapshot = false; + _pendingState = IndicatorState.NoData; + _pendingSinceUtc = DateTime.MinValue; + } + + /// 数字判据:进入用名义阈值,退出用"名义阈值 − 迟滞带宽"(施密特触发)。 + private IndicatorValueState ResolveValueState(bool hasValue, double value, double threshold) + { + if (!hasValue) return IndicatorValueState.Unknown; + + var exitThreshold = threshold - _hysteresisBandDb; + + if (_valueAboveThreshold) + { + // 与进入时的严格比较对称:必须低于退出阈值才回落 + if (value < exitThreshold) _valueAboveThreshold = false; + } + else if (value > threshold) + { + _valueAboveThreshold = true; + } + + return _valueAboveThreshold ? IndicatorValueState.Above : IndicatorValueState.Below; + } + + /// + /// 时间滞回:升级(含同级)立即生效;降级需新态持续 , + /// 期间若新态又变回更高的态则取消待决。 + /// + private IndicatorState ApplyDowngradeHold(IndicatorState candidate, DateTime nowUtc) + { + if (Priority(candidate) >= Priority(_state)) + { + ClearPending(); + return candidate; + } + + if (_pendingState != candidate) + { + _pendingState = candidate; + _pendingSinceUtc = nowUtc; + } + + if (nowUtc - _pendingSinceUtc < DowngradeHoldDuration) return _state; + + ClearPending(); + return candidate; + } + + private void ClearPending() + { + _pendingState = IndicatorState.NoData; + _pendingSinceUtc = DateTime.MinValue; + } + + /// 状态优先级:数值越大越"需要用户动手"。 + private static int Priority(IndicatorState state) => state switch + { + IndicatorState.AwaitingHotkey => 4, + IndicatorState.Alerting => 3, + IndicatorState.CoolingDown => 2, + IndicatorState.Normal => 1, + _ => 0, + }; +} \ No newline at end of file diff --git a/Alerting/ScheduleGate.cs b/Alerting/ScheduleGate.cs new file mode 100644 index 0000000..f1246bd --- /dev/null +++ b/Alerting/ScheduleGate.cs @@ -0,0 +1,84 @@ +using System; + +namespace Decibel_Monitor.Alerting; + +/// +/// 闸门关心的时段类别,由宿主 TimeState 映射而来(只保留需要区分的情形)。 +/// +public enum SchedulePhase +{ + /// 其它时段(无时间点、准备上课、放学等),不抑制。 + Other, + + /// 上课。 + OnClass, + + /// 课间休息。 + Breaking, +} + +/// +/// 时段闸门:依据课表时段(课间休息、上课初期)判定本拍是否应抑制提醒。 +/// +/// +/// +/// 纯逻辑、可单测:由运行时服务在每个采样节拍推进,不自持定时器、不订阅宿主事件, +/// 也不引用宿主类型(宿主时段由调用方映射为 )。 +/// 抑制只决定"要不要推进判定源"。调用方在抑制时须跳过 Decide 调用, +/// 否则判定源内部仍会写入冷却时间——课间的一次误报会吃掉整段冷却,反而让上课时该提醒而不提醒。 +/// 上课起点取 的上升沿(以插件首次观察到进入上课状态为准, +/// 而非课表里的真实开课时间),并用真实时间相减计时(采样节拍可配,不能按采样次数累加)。 +/// 因此插件在课中启动/重启时会从该时刻重新计时,偏保守。 +/// 课表未启用或未加载时整体旁路,行为回到未接入闸门之前。 +/// +/// +public sealed class ScheduleGate +{ + /// 上课初期保护时长的上限(分钟)。 + public const int MaxClassStartProtectionMinutes = 10; + + private SchedulePhase _lastPhase = SchedulePhase.Other; + private DateTime _classStartUtc = DateTime.MinValue; + + /// + /// 推进一次判定并返回本拍是否应抑制提醒。 + /// + /// 当前时段类别。 + /// 宿主是否启用课表。 + /// 宿主是否已加载课表。 + /// 课间休息期间是否抑制提醒。 + /// 上课初期保护时长(分钟),按 0.. 夹取,0 表示关闭。 + /// 当前时间(UTC)。 + /// 本拍是否应抑制提醒。 + public bool Update( + SchedulePhase phase, + bool isClassPlanEnabled, + bool isClassPlanLoaded, + bool suppressDuringBreak, + int classStartProtectionMinutes, + DateTime nowUtc) + { + // 上课上升沿:每次进入 OnClass 都重新计时(课间 → 上课的转换会重置保护起点) + if (phase == SchedulePhase.OnClass && _lastPhase != SchedulePhase.OnClass) + { + _classStartUtc = nowUtc; + } + + _lastPhase = phase; + + // 课表未启用或未加载:时段信息不可信,闸门整体旁路 + if (!isClassPlanEnabled || !isClassPlanLoaded) return false; + + if (suppressDuringBreak && phase == SchedulePhase.Breaking) return true; + + var protectionMinutes = Math.Clamp(classStartProtectionMinutes, 0, MaxClassStartProtectionMinutes); + if (protectionMinutes > 0 && + phase == SchedulePhase.OnClass && + nowUtc - _classStartUtc < TimeSpan.FromMinutes(protectionMinutes)) + { + return true; + } + + return false; + } +} \ No newline at end of file diff --git a/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..50c3617 100644 --- a/Controls/ComponentSettings/DecibelComponentSettingsControl.axaml +++ b/Controls/ComponentSettings/DecibelComponentSettingsControl.axaml @@ -1,17 +1,3 @@ - - - + - - - - - + + - -