diff --git a/LanMountainDesktop/App.axaml b/LanMountainDesktop/App.axaml
index b8408e9..c098505 100644
--- a/LanMountainDesktop/App.axaml
+++ b/LanMountainDesktop/App.axaml
@@ -2,6 +2,7 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:sty="using:FluentAvalonia.Styling"
xmlns:fi="using:FluentIcons.Avalonia"
+ xmlns:mi="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia"
x:Class="LanMountainDesktop.App"
xmlns:local="using:LanMountainDesktop"
RequestedThemeVariant="Default">
@@ -17,6 +18,7 @@
+
@@ -58,5 +60,13 @@
+
+
diff --git a/LanMountainDesktop/ComponentSystem/BuiltInComponentIds.cs b/LanMountainDesktop/ComponentSystem/BuiltInComponentIds.cs
index 7c8ff75..a1fd5f2 100644
--- a/LanMountainDesktop/ComponentSystem/BuiltInComponentIds.cs
+++ b/LanMountainDesktop/ComponentSystem/BuiltInComponentIds.cs
@@ -19,6 +19,7 @@ public static class BuiltInComponentIds
public const string DesktopStudyScoreOverview = "DesktopStudyScoreOverview";
public const string DesktopStudyDeductionReasons = "DesktopStudyDeductionReasons";
public const string DesktopStudyInterruptDensity = "DesktopStudyInterruptDensity";
+ public const string DesktopStudySessionControl = "DesktopStudySessionControl";
public const string Blank2x4 = "Blank2x4";
public const string Date = "Date";
public const string MonthCalendar = "MonthCalendar";
diff --git a/LanMountainDesktop/ComponentSystem/ComponentRegistry.cs b/LanMountainDesktop/ComponentSystem/ComponentRegistry.cs
index 37bbbbf..cd26dc5 100644
--- a/LanMountainDesktop/ComponentSystem/ComponentRegistry.cs
+++ b/LanMountainDesktop/ComponentSystem/ComponentRegistry.cs
@@ -131,6 +131,15 @@ public sealed class ComponentRegistry
AllowStatusBarPlacement: false,
AllowDesktopPlacement: true,
ResizeMode: DesktopComponentResizeMode.Free),
+ new DesktopComponentDefinition(
+ BuiltInComponentIds.DesktopStudySessionControl,
+ "Study Session",
+ "Play",
+ "Study",
+ MinWidthCells: 2,
+ MinHeightCells: 1,
+ AllowStatusBarPlacement: false,
+ AllowDesktopPlacement: true),
new DesktopComponentDefinition(
BuiltInComponentIds.DesktopStudyNoiseCurve,
"Noise Curve",
diff --git a/LanMountainDesktop/LanMountainDesktop.csproj b/LanMountainDesktop/LanMountainDesktop.csproj
index 95b8c86..0d2fa66 100644
--- a/LanMountainDesktop/LanMountainDesktop.csproj
+++ b/LanMountainDesktop/LanMountainDesktop.csproj
@@ -52,6 +52,7 @@
+
diff --git a/LanMountainDesktop/Localization/en-US.json b/LanMountainDesktop/Localization/en-US.json
index e074b71..348f1ae 100644
--- a/LanMountainDesktop/Localization/en-US.json
+++ b/LanMountainDesktop/Localization/en-US.json
@@ -234,6 +234,7 @@
"component.browser": "Browser",
"component.holiday_calendar": "Holiday Calendar",
"component.study_environment": "Environment",
+ "component.study_session_control": "Study Session Control",
"component.study_noise_curve": "Noise Curve",
"component.study_noise_distribution": "Noise Distribution",
"component.study_score_overview": "Study Score Overview",
@@ -290,6 +291,15 @@
"study.environment.settings.show_display_db": "Show display dB",
"study.environment.settings.show_dbfs": "Show dBFS",
"study.environment.settings.hint": "At least one display mode must stay enabled.",
+ "study.session_control.action.start": "Start Study Session",
+ "study.session_control.action.stop": "Stop Study Session",
+ "study.session_control.idle_hint": "Tap the right button to start",
+ "study.session_control.report_preview": "Preview Report",
+ "study.session_control.report_confirm_hint": "Tap right button to confirm",
+ "study.session_control.running_elapsed_format": "Elapsed {0}",
+ "study.session_control.last_session_format": "Last {0}",
+ "study.session_control.start_failed": "Unable to start session",
+ "study.session_control.stop_failed": "Unable to stop session",
"study.noise_curve.value_format": "{0:F1} dB",
"study.noise_curve.axis.now": "Now",
"study.noise_distribution.title": "Noise Level Distribution",
diff --git a/LanMountainDesktop/Localization/zh-CN.json b/LanMountainDesktop/Localization/zh-CN.json
index 30eaa21..7613248 100644
--- a/LanMountainDesktop/Localization/zh-CN.json
+++ b/LanMountainDesktop/Localization/zh-CN.json
@@ -234,6 +234,7 @@
"component.browser": "浏览器",
"component.holiday_calendar": "节假日日历",
"component.study_environment": "环境",
+ "component.study_session_control": "自习时段控制",
"component.study_noise_curve": "噪音曲线",
"component.study_noise_distribution": "噪音等级分布",
"component.study_score_overview": "自习评分总览",
@@ -290,6 +291,15 @@
"study.environment.settings.show_display_db": "显示 display dB",
"study.environment.settings.show_dbfs": "显示 dBFS",
"study.environment.settings.hint": "至少启用一种显示方式。",
+ "study.session_control.action.start": "开始自习时段",
+ "study.session_control.action.stop": "结束自习时段",
+ "study.session_control.idle_hint": "点击右侧按钮开始",
+ "study.session_control.report_preview": "预览报告",
+ "study.session_control.report_confirm_hint": "点击右侧确定结束查看",
+ "study.session_control.running_elapsed_format": "已进行 {0}",
+ "study.session_control.last_session_format": "上次时段 {0}",
+ "study.session_control.start_failed": "启动失败",
+ "study.session_control.stop_failed": "结束失败",
"study.noise_curve.value_format": "{0:F1} dB",
"study.noise_curve.axis.now": "现在",
"study.noise_distribution.title": "噪音等级分布",
diff --git a/LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs b/LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs
index 2a50cb4..885e051 100644
--- a/LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs
+++ b/LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs
@@ -179,6 +179,11 @@ public sealed class DesktopComponentRuntimeRegistry
"component.study_environment",
() => new StudyEnvironmentWidget(),
cellSize => Math.Clamp(cellSize * 0.36, 12, 26)),
+ new DesktopComponentRuntimeRegistration(
+ BuiltInComponentIds.DesktopStudySessionControl,
+ "component.study_session_control",
+ () => new StudySessionControlWidget(),
+ cellSize => Math.Clamp(cellSize * 0.36, 10, 24)),
new DesktopComponentRuntimeRegistration(
BuiltInComponentIds.DesktopStudyNoiseCurve,
"component.study_noise_curve",
diff --git a/LanMountainDesktop/Views/Components/StudyDeductionReasonsWidget.axaml.cs b/LanMountainDesktop/Views/Components/StudyDeductionReasonsWidget.axaml.cs
index 084c644..815544c 100644
--- a/LanMountainDesktop/Views/Components/StudyDeductionReasonsWidget.axaml.cs
+++ b/LanMountainDesktop/Views/Components/StudyDeductionReasonsWidget.axaml.cs
@@ -141,14 +141,18 @@ public partial class StudyDeductionReasonsWidget : UserControl, IDesktopComponen
ApplyTypographyByBackground(panelColor);
var isSessionRunning = snapshot.Session.State == StudySessionRuntimeState.Running;
- ModeTextBlock.Text = isSessionRunning
+ var isSessionReport = snapshot.DataMode == StudyDataMode.SessionReport && snapshot.LastSessionReport is not null;
+ var isSessionView = isSessionRunning || isSessionReport;
+ ModeTextBlock.Text = isSessionView
? L("study.deduction.mode.session", "Session")
: L("study.deduction.mode.realtime", "Realtime");
- ApplyModeBadgeColor(panelColor, isSessionRunning ? Color.Parse("#FF0F6B49") : Color.Parse("#FF2F5DA8"));
+ ApplyModeBadgeColor(panelColor, isSessionView ? Color.Parse("#FF0F6B49") : Color.Parse("#FF2F5DA8"));
ApplyLocalizedLabels();
- var metrics = ComputeRealtimeDeduction(snapshot);
+ var metrics = isSessionReport && snapshot.LastSessionReport is not null
+ ? ComputeReportDeduction(snapshot.LastSessionReport, snapshot.Config)
+ : ComputeRealtimeDeduction(snapshot);
if (metrics is null)
{
ApplyUnavailableMetrics();
@@ -407,6 +411,24 @@ public partial class StudyDeductionReasonsWidget : UserControl, IDesktopComponen
SegmentsPerMin: Math.Round(segmentsPerMin, 3));
}
+ private static DeductionMetrics? ComputeReportDeduction(StudySessionReport report, StudyAnalyticsConfig config)
+ {
+ if (!StudySessionReportProjection.TryAggregate(report, config, out var aggregate))
+ {
+ return null;
+ }
+
+ return new DeductionMetrics(
+ SustainedPenalty: aggregate.SustainedPenalty,
+ TimePenalty: aggregate.TimePenalty,
+ SegmentPenalty: aggregate.SegmentPenalty,
+ TotalPenalty: aggregate.TotalPenalty,
+ Score: aggregate.Score,
+ P50Dbfs: aggregate.P50Dbfs,
+ OverRatio: aggregate.OverRatio,
+ SegmentsPerMin: aggregate.SegmentsPerMin);
+ }
+
private static double Percentile(double[] sortedValues, double percentile)
{
if (sortedValues.Length == 0)
diff --git a/LanMountainDesktop/Views/Components/StudyEnvironmentWidget.axaml.cs b/LanMountainDesktop/Views/Components/StudyEnvironmentWidget.axaml.cs
index c0ef07a..24637c8 100644
--- a/LanMountainDesktop/Views/Components/StudyEnvironmentWidget.axaml.cs
+++ b/LanMountainDesktop/Views/Components/StudyEnvironmentWidget.axaml.cs
@@ -140,8 +140,46 @@ public partial class StudyEnvironmentWidget : UserControl, IDesktopComponentWidg
private void RefreshVisual()
{
var snapshot = _studyAnalyticsService.GetSnapshot();
+ var isSessionReport = snapshot.DataMode == StudyDataMode.SessionReport && snapshot.LastSessionReport is not null;
StatusTitleTextBlock.Text = L("study.environment.status_label", "Environment");
+
+ if (isSessionReport && snapshot.LastSessionReport is not null)
+ {
+ StatusValueTextBlock.Text = L("study.score_overview.mode.session", "Session");
+ StatusValueTextBlock.Foreground = TryResolveThemeBrush("AdaptiveTextPrimaryBrush", "#FFEFF3FF");
+
+ if (!StudySessionReportProjection.TryAggregate(snapshot.LastSessionReport, snapshot.Config, out var aggregate))
+ {
+ NoiseValueTextBlock.Text = L("study.environment.value.unavailable", "--");
+ NoiseSubValueTextBlock.IsVisible = false;
+ UpdateAdaptiveLayout();
+ return;
+ }
+
+ var reportShowDisplay = _showDisplayDb;
+ var reportShowDbfs = _showDbfs;
+ if (!reportShowDisplay && !reportShowDbfs)
+ {
+ reportShowDisplay = true;
+ }
+
+ if (reportShowDisplay && reportShowDbfs)
+ {
+ NoiseValueTextBlock.Text = FormatDisplayDb(aggregate.AverageDisplayDb);
+ NoiseSubValueTextBlock.Text = FormatDbfs(aggregate.AverageDbfs);
+ NoiseSubValueTextBlock.IsVisible = true;
+ return;
+ }
+
+ NoiseValueTextBlock.Text = reportShowDisplay
+ ? FormatDisplayDb(aggregate.AverageDisplayDb)
+ : FormatDbfs(aggregate.AverageDbfs);
+ NoiseSubValueTextBlock.IsVisible = false;
+ UpdateAdaptiveLayout();
+ return;
+ }
+
StatusValueTextBlock.Text = ResolveStatusText(snapshot);
StatusValueTextBlock.Foreground = ResolveStatusBrush(snapshot);
diff --git a/LanMountainDesktop/Views/Components/StudyInterruptDensityWidget.axaml.cs b/LanMountainDesktop/Views/Components/StudyInterruptDensityWidget.axaml.cs
index 7722aaa..3b5b8ea 100644
--- a/LanMountainDesktop/Views/Components/StudyInterruptDensityWidget.axaml.cs
+++ b/LanMountainDesktop/Views/Components/StudyInterruptDensityWidget.axaml.cs
@@ -164,14 +164,24 @@ public partial class StudyInterruptDensityWidget : UserControl, IDesktopComponen
ApplyLocalizedLabels();
var isSessionRunning = snapshot.Session.State == StudySessionRuntimeState.Running;
- ModeTextBlock.Text = isSessionRunning
+ var isSessionReport = snapshot.DataMode == StudyDataMode.SessionReport && snapshot.LastSessionReport is not null;
+ var isSessionView = isSessionRunning || isSessionReport;
+ ModeTextBlock.Text = isSessionView
? L("study.interrupt_density.mode.session", "Session")
: L("study.interrupt_density.mode.realtime", "Realtime");
- ApplyModeBadgeColor(panelColor, isSessionRunning ? Color.Parse("#FF0F6B49") : Color.Parse("#FF2F5DA8"));
+ ApplyModeBadgeColor(panelColor, isSessionView ? Color.Parse("#FF0F6B49") : Color.Parse("#FF2F5DA8"));
- var metrics = isSessionRunning
- ? ComputeSessionDensity(snapshot)
- : ComputeRealtimeDensity(snapshot);
+ InterruptDensityMetrics? metrics;
+ if (isSessionReport && snapshot.LastSessionReport is not null)
+ {
+ metrics = ComputeReportDensity(snapshot.LastSessionReport, snapshot.Config);
+ }
+ else
+ {
+ metrics = isSessionRunning
+ ? ComputeSessionDensity(snapshot)
+ : ComputeRealtimeDensity(snapshot);
+ }
if (metrics is null)
{
@@ -429,6 +439,23 @@ public partial class StudyInterruptDensityWidget : UserControl, IDesktopComponen
LevelKind: levelKind);
}
+ private static InterruptDensityMetrics? ComputeReportDensity(StudySessionReport report, StudyAnalyticsConfig config)
+ {
+ if (!StudySessionReportProjection.TryAggregate(report, config, out var aggregate))
+ {
+ return null;
+ }
+
+ var threshold = Math.Max(1, config.MaxSegmentsPerMin);
+ var levelKind = ResolveLevelKind(aggregate.SegmentsPerMin, threshold);
+ return new InterruptDensityMetrics(
+ DensityPerMin: Math.Round(aggregate.SegmentsPerMin, 2),
+ SegmentCount: aggregate.SegmentCount,
+ Duration: aggregate.Duration,
+ ThresholdPerMin: threshold,
+ LevelKind: levelKind);
+ }
+
private static DensityLevelKind ResolveLevelKind(double densityPerMin, double thresholdPerMin)
{
var ratio = densityPerMin / Math.Max(1, thresholdPerMin);
diff --git a/LanMountainDesktop/Views/Components/StudyNoiseCurveWidget.axaml.cs b/LanMountainDesktop/Views/Components/StudyNoiseCurveWidget.axaml.cs
index 39c4f30..90d0140 100644
--- a/LanMountainDesktop/Views/Components/StudyNoiseCurveWidget.axaml.cs
+++ b/LanMountainDesktop/Views/Components/StudyNoiseCurveWidget.axaml.cs
@@ -247,6 +247,31 @@ public partial class StudyNoiseCurveWidget : UserControl, IDesktopComponentWidge
var panelColor = ResolvePanelBackgroundColor();
ApplyTypographyByBackground(panelColor);
+ var isSessionReport = snapshot.DataMode == StudyDataMode.SessionReport && snapshot.LastSessionReport is not null;
+ if (isSessionReport && snapshot.LastSessionReport is not null)
+ {
+ StatusTextBlock.Text = L("study.score_overview.mode.session", "Session");
+ ApplyStatusBadgeStyle(StatusVisualKind.Quiet, panelColor);
+
+ var reportPoints = StudySessionReportProjection.BuildSyntheticRealtimePoints(snapshot.LastSessionReport, snapshot.Config);
+ ChartControl.UpdateSeries(reportPoints);
+ UpdateXAxisLabels(reportPoints);
+
+ if (StudySessionReportProjection.TryAggregate(snapshot.LastSessionReport, snapshot.Config, out var aggregate))
+ {
+ RealtimeValueTextBlock.Text = string.Format(
+ CultureInfo.InvariantCulture,
+ L("study.noise_curve.value_format", "{0:F1} dB"),
+ aggregate.AverageDisplayDb);
+ }
+ else
+ {
+ RealtimeValueTextBlock.Text = L("study.environment.value.unavailable", "--");
+ }
+
+ return;
+ }
+
var statusKind = ResolveStatusVisualKind(snapshot);
StatusTextBlock.Text = ResolveStatusText(snapshot);
ApplyStatusBadgeStyle(statusKind, panelColor);
@@ -264,7 +289,7 @@ public partial class StudyNoiseCurveWidget : UserControl, IDesktopComponentWidge
}
ChartControl.UpdateSeries(snapshot.RealtimeBuffer);
- UpdateXAxisLabels(snapshot);
+ UpdateXAxisLabels(snapshot.RealtimeBuffer);
}
private void ApplyTypographyByBackground(Color panelColor)
@@ -454,9 +479,8 @@ public partial class StudyNoiseCurveWidget : UserControl, IDesktopComponentWidge
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
- private void UpdateXAxisLabels(StudyAnalyticsSnapshot snapshot)
+ private void UpdateXAxisLabels(IReadOnlyList buffer)
{
- var buffer = snapshot.RealtimeBuffer;
if (buffer.Count < 2)
{
ApplyDefaultXAxisLabels();
diff --git a/LanMountainDesktop/Views/Components/StudyNoiseDistributionWidget.axaml.cs b/LanMountainDesktop/Views/Components/StudyNoiseDistributionWidget.axaml.cs
index 95a5480..a2bf6a8 100644
--- a/LanMountainDesktop/Views/Components/StudyNoiseDistributionWidget.axaml.cs
+++ b/LanMountainDesktop/Views/Components/StudyNoiseDistributionWidget.axaml.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
@@ -163,15 +163,21 @@ public partial class StudyNoiseDistributionWidget : UserControl, IDesktopCompone
ApplyLocalizedAxisLabels();
var isSessionRunning = snapshot.Session.State == StudySessionRuntimeState.Running;
- ModeTextBlock.Text = isSessionRunning
+ var isSessionReport = snapshot.DataMode == StudyDataMode.SessionReport && snapshot.LastSessionReport is not null;
+ var isSessionView = isSessionRunning || isSessionReport;
+ ModeTextBlock.Text = isSessionView
? L("study.noise_distribution.mode.session", "Session")
: L("study.noise_distribution.mode.realtime", "Realtime");
- ApplyModeBadgeColor(panelColor, isSessionRunning ? Color.Parse("#FF0F6B49") : Color.Parse("#FF2F5DA8"));
+ ApplyModeBadgeColor(panelColor, isSessionView ? Color.Parse("#FF0F6B49") : Color.Parse("#FF2F5DA8"));
- ChartControl.UpdateSeries(snapshot.RealtimeBuffer, snapshot.Config.BaselineDb);
- UpdateXAxisLabels(snapshot);
+ var points = isSessionReport && snapshot.LastSessionReport is not null
+ ? StudySessionReportProjection.BuildSyntheticRealtimePoints(snapshot.LastSessionReport, snapshot.Config)
+ : snapshot.RealtimeBuffer;
- var stats = ComputeDistributionStats(snapshot.RealtimeBuffer, snapshot.Config.BaselineDb);
+ ChartControl.UpdateSeries(points, snapshot.Config.BaselineDb);
+ UpdateXAxisLabels(points);
+
+ var stats = ComputeDistributionStats(points, snapshot.Config.BaselineDb);
if (stats is null)
{
SummaryTextBlock.Text = string.Format(
@@ -497,9 +503,8 @@ public partial class StudyNoiseDistributionWidget : UserControl, IDesktopCompone
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
- private void UpdateXAxisLabels(StudyAnalyticsSnapshot snapshot)
+ private void UpdateXAxisLabels(IReadOnlyList buffer)
{
- var buffer = snapshot.RealtimeBuffer;
if (buffer.Count < 2)
{
ApplyDefaultXAxisLabels();
@@ -600,3 +605,5 @@ public partial class StudyNoiseDistributionWidget : UserControl, IDesktopCompone
return _localizationService.GetString(_languageCode, key, fallback);
}
}
+
+
diff --git a/LanMountainDesktop/Views/Components/StudyScoreOverviewWidget.axaml.cs b/LanMountainDesktop/Views/Components/StudyScoreOverviewWidget.axaml.cs
index efc3a0c..05b8ef9 100644
--- a/LanMountainDesktop/Views/Components/StudyScoreOverviewWidget.axaml.cs
+++ b/LanMountainDesktop/Views/Components/StudyScoreOverviewWidget.axaml.cs
@@ -154,7 +154,7 @@ public partial class StudyScoreOverviewWidget : UserControl, IDesktopComponentWi
ApplyTypographyByBackground(panelColor);
var realtimeScore = ComputeRealtimeScore(snapshot);
- if (realtimeScore is { } score)
+ if (snapshot.DataMode == StudyDataMode.Realtime && realtimeScore is { } score)
{
PushRealtimeScore(score, DateTimeOffset.UtcNow);
}
@@ -166,6 +166,12 @@ public partial class StudyScoreOverviewWidget : UserControl, IDesktopComponentWi
return;
}
+ if (snapshot.DataMode == StudyDataMode.SessionReport && snapshot.LastSessionReport is not null)
+ {
+ ApplySessionReportMode(snapshot, panelColor);
+ return;
+ }
+
ApplyRealtimeMode(snapshot, realtimeScore, panelColor);
}
@@ -199,6 +205,24 @@ public partial class StudyScoreOverviewWidget : UserControl, IDesktopComponentWi
MaximumValueTextBlock.Text = FormatScoreOrUnavailable(historyStats.Maximum);
}
+ private void ApplySessionReportMode(StudyAnalyticsSnapshot snapshot, Color panelColor)
+ {
+ var report = snapshot.LastSessionReport;
+ if (report is null)
+ {
+ ApplyRealtimeMode(snapshot, realtimeScore: null, panelColor);
+ return;
+ }
+
+ ModeTextBlock.Text = L("study.score_overview.mode.session", "Session");
+ ApplyModeBadgeColor(panelColor, Color.Parse("#FF0F6B49"));
+
+ CurrentScoreTextBlock.Text = FormatScoreOrUnavailable(report.Metrics.CurrentScore);
+ AverageValueTextBlock.Text = FormatScoreOrUnavailable(report.Metrics.AvgScore);
+ MinimumValueTextBlock.Text = FormatScoreOrUnavailable(report.Metrics.MinScore);
+ MaximumValueTextBlock.Text = FormatScoreOrUnavailable(report.Metrics.MaxScore);
+ }
+
private void ApplyLocalizedLabels()
{
TitleTextBlock.Text = L("study.score_overview.title", "Study Score");
diff --git a/LanMountainDesktop/Views/Components/StudySessionControlWidget.axaml b/LanMountainDesktop/Views/Components/StudySessionControlWidget.axaml
new file mode 100644
index 0000000..1333ef6
--- /dev/null
+++ b/LanMountainDesktop/Views/Components/StudySessionControlWidget.axaml
@@ -0,0 +1,58 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/LanMountainDesktop/Views/Components/StudySessionControlWidget.axaml.cs b/LanMountainDesktop/Views/Components/StudySessionControlWidget.axaml.cs
new file mode 100644
index 0000000..9a49223
--- /dev/null
+++ b/LanMountainDesktop/Views/Components/StudySessionControlWidget.axaml.cs
@@ -0,0 +1,471 @@
+using System;
+using System.Collections.Generic;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Media;
+using Avalonia.Threading;
+using LanMountainDesktop.Models;
+using LanMountainDesktop.Services;
+using LanMountainDesktop.Theme;
+using Material.Icons;
+
+namespace LanMountainDesktop.Views.Components;
+
+public partial class StudySessionControlWidget : UserControl, IDesktopComponentWidget, IDesktopPageVisibilityAwareComponentWidget
+{
+ private static readonly Color[] PrimaryColorCandidates =
+ {
+ Color.Parse("#FFEAF5FF"),
+ Color.Parse("#FFDDEEFF"),
+ Color.Parse("#FFCEE3FA"),
+ Color.Parse("#FF1B2E45"),
+ Color.Parse("#FF233A54"),
+ Color.Parse("#FFFFFFFF"),
+ Color.Parse("#FF101C2A")
+ };
+
+ private static readonly Color[] SecondaryColorCandidates =
+ {
+ Color.Parse("#FFC7D9EC"),
+ Color.Parse("#FFBAD0E8"),
+ Color.Parse("#FFD9E8F6"),
+ Color.Parse("#FF2F4763"),
+ Color.Parse("#FF385673"),
+ Color.Parse("#FFEAF3FA"),
+ Color.Parse("#FF1A2C40")
+ };
+
+ private static readonly Color[] WarningColorCandidates =
+ {
+ Color.Parse("#FFFFD4D4"),
+ Color.Parse("#FFFEE2E2"),
+ Color.Parse("#FF7F1D1D"),
+ Color.Parse("#FF991B1B"),
+ Color.Parse("#FFFFFFFF"),
+ Color.Parse("#FF111827")
+ };
+
+ private static readonly Color DarkSubstrate = Color.Parse("#FF0B1220");
+ private static readonly Color LightSubstrate = Color.Parse("#FFF1F5FA");
+
+ private readonly IStudyAnalyticsService _studyAnalyticsService = StudyAnalyticsServiceFactory.CreateDefault();
+ private readonly StudyAnalyticsMonitoringLeaseCoordinator _monitoringLeaseCoordinator = StudyAnalyticsMonitoringLeaseCoordinatorFactory.CreateDefault();
+ private readonly AppSettingsService _settingsService = new();
+ private readonly LocalizationService _localizationService = new();
+ private readonly DispatcherTimer _uiTimer = new()
+ {
+ Interval = TimeSpan.FromMilliseconds(250)
+ };
+
+ private double _currentCellSize = 48;
+ private string _languageCode = "zh-CN";
+ private bool _isAttached;
+ private bool _isOnActivePage = true;
+ private bool _isCompactMode;
+ private bool _isUltraCompactMode;
+ private IDisposable? _monitoringLease;
+ private string? _transientMessage;
+ private DateTimeOffset _transientMessageExpireAt;
+
+ public StudySessionControlWidget()
+ {
+ InitializeComponent();
+
+ _uiTimer.Tick += OnUiTimerTick;
+ AttachedToVisualTree += OnAttachedToVisualTree;
+ DetachedFromVisualTree += OnDetachedFromVisualTree;
+ SizeChanged += OnSizeChanged;
+
+ ReloadLanguageCode();
+ ApplyCellSize(_currentCellSize);
+ RefreshVisual();
+ }
+
+ public void ApplyCellSize(double cellSize)
+ {
+ _currentCellSize = Math.Max(1, cellSize);
+ UpdateAdaptiveLayout();
+ }
+
+ public void SetDesktopPageContext(bool isOnActivePage, bool isEditMode)
+ {
+ _ = isEditMode;
+ _isOnActivePage = isOnActivePage;
+ UpdateMonitoringLeaseState();
+ UpdateTimerState();
+ }
+
+ private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
+ {
+ _isAttached = true;
+ ReloadLanguageCode();
+ UpdateMonitoringLeaseState();
+ UpdateTimerState();
+ RefreshVisual();
+ }
+
+ private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
+ {
+ _isAttached = false;
+ _monitoringLease?.Dispose();
+ _monitoringLease = null;
+ _uiTimer.Stop();
+ }
+
+ private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
+ {
+ UpdateAdaptiveLayout();
+ ApplyTypographyByBackground(ResolvePanelBackgroundColor());
+ }
+
+ private void OnUiTimerTick(object? sender, EventArgs e)
+ {
+ RefreshVisual();
+ }
+
+ private void UpdateTimerState()
+ {
+ if (_isAttached && _isOnActivePage)
+ {
+ if (!_uiTimer.IsEnabled)
+ {
+ _uiTimer.Start();
+ }
+
+ return;
+ }
+
+ _uiTimer.Stop();
+ }
+
+ private void UpdateMonitoringLeaseState()
+ {
+ var shouldMonitor = _isAttached && _isOnActivePage;
+ if (shouldMonitor)
+ {
+ _monitoringLease ??= _monitoringLeaseCoordinator.AcquireLease();
+ return;
+ }
+
+ _monitoringLease?.Dispose();
+ _monitoringLease = null;
+ }
+
+ private void OnActionButtonClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
+ {
+ var snapshot = _studyAnalyticsService.GetSnapshot();
+ var isReportViewing = snapshot.DataMode == StudyDataMode.SessionReport && snapshot.LastSessionReport is not null;
+ if (isReportViewing)
+ {
+ _studyAnalyticsService.ClearLastSessionReport();
+ _transientMessage = null;
+ RefreshVisual();
+ return;
+ }
+
+ var isRunning = snapshot.Session.State == StudySessionRuntimeState.Running;
+
+ var success = isRunning
+ ? _studyAnalyticsService.StopStudySession()
+ : _studyAnalyticsService.StartStudySession();
+
+ if (!success)
+ {
+ _transientMessage = isRunning
+ ? L("study.session_control.stop_failed", "Unable to stop session")
+ : L("study.session_control.start_failed", "Unable to start session");
+ _transientMessageExpireAt = DateTimeOffset.UtcNow.AddSeconds(2.2);
+ }
+ else
+ {
+ _transientMessage = null;
+ }
+
+ RefreshVisual();
+ }
+
+ private void RefreshVisual()
+ {
+ var snapshot = _studyAnalyticsService.GetSnapshot();
+ var now = DateTimeOffset.UtcNow;
+ var panelColor = ResolvePanelBackgroundColor();
+ ApplyTypographyByBackground(panelColor);
+
+ if (_transientMessage is not null && now > _transientMessageExpireAt)
+ {
+ _transientMessage = null;
+ }
+
+ var isReportViewing = snapshot.DataMode == StudyDataMode.SessionReport && snapshot.LastSessionReport is not null;
+ if (isReportViewing)
+ {
+ PrimaryTextBlock.Text = L("study.session_control.report_preview", "Preview Report");
+ SecondaryTextBlock.Text = _transientMessage ?? L("study.session_control.report_confirm_hint", "Tap right button to confirm");
+ ActionIcon.Kind = MaterialIconKind.Check;
+ ApplyActionBadgeStyle(panelColor, Color.Parse("#FF34D399"));
+ ApplyTransientWarningTintIfNeeded(panelColor);
+ return;
+ }
+
+ var isRunning = snapshot.Session.State == StudySessionRuntimeState.Running;
+ if (isRunning)
+ {
+ PrimaryTextBlock.Text = L("study.session_control.action.stop", "Stop Study Session");
+ SecondaryTextBlock.Text = _transientMessage ?? string.Format(
+ L("study.session_control.running_elapsed_format", "Elapsed {0}"),
+ FormatElapsed(snapshot.Session.Elapsed));
+ ActionIcon.Kind = MaterialIconKind.Stop;
+ ApplyActionBadgeStyle(panelColor, Color.Parse("#FFF97373"));
+ ApplyTransientWarningTintIfNeeded(panelColor);
+ return;
+ }
+
+ PrimaryTextBlock.Text = L("study.session_control.action.start", "Start Study Session");
+ SecondaryTextBlock.Text = _transientMessage ?? ResolveIdleHint(snapshot);
+ ActionIcon.Kind = MaterialIconKind.Play;
+ ApplyActionBadgeStyle(panelColor, Color.Parse("#FF60A5FA"));
+ ApplyTransientWarningTintIfNeeded(panelColor);
+ }
+
+ private string ResolveIdleHint(StudyAnalyticsSnapshot snapshot)
+ {
+ if (snapshot.State == StudyAnalyticsRuntimeState.Unsupported)
+ {
+ return L("study.environment.status.unsupported", "Unsupported");
+ }
+
+ if (snapshot.State == StudyAnalyticsRuntimeState.Error || snapshot.StreamStatus == NoiseStreamStatus.Error)
+ {
+ return L("study.environment.status.error", "Error");
+ }
+
+ if (snapshot.Session.State == StudySessionRuntimeState.Completed && snapshot.LastSessionReport is not null)
+ {
+ return string.Format(
+ L("study.session_control.last_session_format", "Last {0}"),
+ FormatElapsed(snapshot.LastSessionReport.Duration));
+ }
+
+ return L("study.session_control.idle_hint", "Tap the right button to start");
+ }
+
+ private void UpdateAdaptiveLayout()
+ {
+ var cellScale = Math.Clamp(_currentCellSize / 48d, 0.78, 2.4);
+ var widthScale = Bounds.Width > 1 ? Bounds.Width / 280d : cellScale;
+ var heightScale = Bounds.Height > 1 ? Bounds.Height / 140d : cellScale;
+ var boundsScale = Math.Clamp(Math.Min(widthScale, heightScale), 0.56, 2.2);
+ var scale = Math.Clamp(Math.Min(cellScale, boundsScale * 1.05), 0.56, 2.2);
+
+ _isCompactMode = scale < 0.92 || (Bounds.Width > 1 && Bounds.Width < 220) || (Bounds.Height > 1 && Bounds.Height < 92);
+ _isUltraCompactMode = scale < 0.74 || (Bounds.Width > 1 && Bounds.Width < 180) || (Bounds.Height > 1 && Bounds.Height < 76);
+
+ var compactMultiplier = _isUltraCompactMode ? 0.78 : _isCompactMode ? 0.90 : 1.0;
+ RootBorder.CornerRadius = new CornerRadius(Math.Clamp(_currentCellSize * 0.34, 10, 28));
+ RootBorder.Padding = new Thickness(
+ Math.Clamp(14 * scale * compactMultiplier, 7, 22),
+ Math.Clamp(10 * scale * compactMultiplier, 5, 16));
+
+ LayoutGrid.ColumnSpacing = _isUltraCompactMode
+ ? Math.Clamp(6 * scale, 3, 8)
+ : _isCompactMode
+ ? Math.Clamp(8 * scale, 4, 10)
+ : Math.Clamp(10 * scale, 6, 14);
+
+ PrimaryTextBlock.FontSize = Math.Clamp(17 * scale, 10, 30);
+ SecondaryTextBlock.FontSize = Math.Clamp(11 * scale, 8, 18);
+ LeftTextStack.Spacing = _isUltraCompactMode ? 0 : Math.Clamp(2 * scale, 1, 4);
+
+ var buttonSize = Math.Clamp(48 * scale * compactMultiplier, 28, 72);
+ ActionButton.Width = buttonSize;
+ ActionButton.Height = buttonSize;
+ ActionIconBorder.Width = buttonSize;
+ ActionIconBorder.Height = buttonSize;
+ ActionIconBorder.CornerRadius = new CornerRadius(buttonSize / 2d);
+ ActionIcon.Width = Math.Clamp(buttonSize * 0.44, 14, 30);
+ ActionIcon.Height = Math.Clamp(buttonSize * 0.44, 14, 30);
+
+ SecondaryTextBlock.IsVisible = !_isUltraCompactMode;
+ }
+
+ private void ApplyTypographyByBackground(Color panelColor)
+ {
+ var samples = BuildPanelBackgroundSamples(panelColor);
+ var primary = CreateAdaptiveBrush(samples, PrimaryColorCandidates, minContrast: 4.5);
+ var secondary = CreateAdaptiveBrush(samples, SecondaryColorCandidates, minContrast: 4.5);
+
+ PrimaryTextBlock.Foreground = primary;
+ SecondaryTextBlock.Foreground = secondary;
+ }
+
+ private void ApplyTransientWarningTintIfNeeded(Color panelColor)
+ {
+ if (string.IsNullOrWhiteSpace(_transientMessage))
+ {
+ return;
+ }
+
+ var samples = BuildPanelBackgroundSamples(panelColor);
+ SecondaryTextBlock.Foreground = CreateAdaptiveBrush(samples, WarningColorCandidates, minContrast: 4.5);
+ }
+
+ private void ApplyActionBadgeStyle(Color panelColor, Color baseColor)
+ {
+ var panelLuminance = RelativeLuminance(ToOpaqueAgainst(panelColor, DarkSubstrate));
+ var badgeAlpha = panelLuminance > 0.58
+ ? (byte)0xE2
+ : panelLuminance > 0.46
+ ? (byte)0xD8
+ : (byte)0xC8;
+
+ var badgeColor = Color.FromArgb(badgeAlpha, baseColor.R, baseColor.G, baseColor.B);
+ var badgeComposite = ToOpaqueAgainst(badgeColor, ToOpaqueAgainst(panelColor, DarkSubstrate));
+
+ ActionIconBorder.Background = new SolidColorBrush(badgeColor);
+ ActionIconBorder.BorderBrush = new SolidColorBrush(Color.FromArgb(0x96, 0xFF, 0xFF, 0xFF));
+ ActionIcon.Foreground = CreateAdaptiveBrush(new[] { badgeComposite }, PrimaryColorCandidates, minContrast: 4.5);
+ }
+
+ private Color ResolvePanelBackgroundColor()
+ {
+ if (RootBorder.Background is ISolidColorBrush solidBackground)
+ {
+ return solidBackground.Color;
+ }
+
+ if (Resources.TryGetResource("AdaptiveGlassStrongBackgroundBrush", ActualThemeVariant, out var resource) &&
+ resource is ISolidColorBrush solidBrush)
+ {
+ return solidBrush.Color;
+ }
+
+ return Color.Parse("#FF1E293B");
+ }
+
+ private static IReadOnlyList BuildPanelBackgroundSamples(Color panelColor)
+ {
+ var opaqueOnDark = ToOpaqueAgainst(panelColor, DarkSubstrate);
+ var opaqueOnLight = ToOpaqueAgainst(panelColor, LightSubstrate);
+
+ return
+ [
+ opaqueOnDark,
+ opaqueOnLight,
+ ColorMath.Blend(opaqueOnDark, DarkSubstrate, 0.28),
+ ColorMath.Blend(opaqueOnDark, Color.Parse("#FFFFFFFF"), 0.16),
+ ColorMath.Blend(opaqueOnLight, Color.Parse("#FFFFFFFF"), 0.08),
+ ColorMath.Blend(opaqueOnLight, DarkSubstrate, 0.18)
+ ];
+ }
+
+ private static SolidColorBrush CreateAdaptiveBrush(
+ IReadOnlyList backgroundSamples,
+ IReadOnlyList colorCandidates,
+ double minContrast)
+ {
+ if (colorCandidates.Count == 0)
+ {
+ return new SolidColorBrush(Color.Parse("#FFFFFFFF"));
+ }
+
+ for (var i = 0; i < colorCandidates.Count; i++)
+ {
+ var candidate = colorCandidates[i];
+ if (MinContrastRatio(candidate, backgroundSamples) >= minContrast)
+ {
+ return new SolidColorBrush(candidate);
+ }
+ }
+
+ var best = colorCandidates[0];
+ var bestContrast = MinContrastRatio(best, backgroundSamples);
+ for (var i = 1; i < colorCandidates.Count; i++)
+ {
+ var candidate = colorCandidates[i];
+ var contrast = MinContrastRatio(candidate, backgroundSamples);
+ if (contrast > bestContrast)
+ {
+ best = candidate;
+ bestContrast = contrast;
+ }
+ }
+
+ return new SolidColorBrush(best);
+ }
+
+ private static double MinContrastRatio(Color foreground, IReadOnlyList backgrounds)
+ {
+ if (backgrounds.Count == 0)
+ {
+ return 21;
+ }
+
+ var minimum = double.MaxValue;
+ for (var i = 0; i < backgrounds.Count; i++)
+ {
+ var background = backgrounds[i];
+ var visibleForeground = foreground.A >= 0xFF
+ ? Color.FromArgb(0xFF, foreground.R, foreground.G, foreground.B)
+ : ToOpaqueAgainst(foreground, background);
+
+ var ratio = ColorMath.ContrastRatio(visibleForeground, background);
+ if (ratio < minimum)
+ {
+ minimum = ratio;
+ }
+ }
+
+ return minimum;
+ }
+
+ private static Color ToOpaqueAgainst(Color foreground, Color background)
+ {
+ if (foreground.A >= 0xFF)
+ {
+ return Color.FromArgb(0xFF, foreground.R, foreground.G, foreground.B);
+ }
+
+ var alpha = foreground.A / 255d;
+ var red = (byte)Math.Round((foreground.R * alpha) + (background.R * (1 - alpha)));
+ var green = (byte)Math.Round((foreground.G * alpha) + (background.G * (1 - alpha)));
+ var blue = (byte)Math.Round((foreground.B * alpha) + (background.B * (1 - alpha)));
+ return Color.FromArgb(0xFF, red, green, blue);
+ }
+
+ private static double RelativeLuminance(Color color)
+ {
+ static double ToLinear(byte channel)
+ {
+ var c = channel / 255d;
+ return c <= 0.03928
+ ? c / 12.92
+ : Math.Pow((c + 0.055) / 1.055, 2.4);
+ }
+
+ var r = ToLinear(color.R);
+ var g = ToLinear(color.G);
+ var b = ToLinear(color.B);
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
+ }
+
+ private static string FormatElapsed(TimeSpan elapsed)
+ {
+ if (elapsed.TotalHours >= 1)
+ {
+ return elapsed.ToString(@"hh\:mm\:ss");
+ }
+
+ return elapsed.ToString(@"mm\:ss");
+ }
+
+ private void ReloadLanguageCode()
+ {
+ var snapshot = _settingsService.Load();
+ _languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
+ }
+
+ private string L(string key, string fallback)
+ {
+ return _localizationService.GetString(_languageCode, key, fallback);
+ }
+}
diff --git a/LanMountainDesktop/Views/Components/StudySessionReportProjection.cs b/LanMountainDesktop/Views/Components/StudySessionReportProjection.cs
new file mode 100644
index 0000000..bf046cd
--- /dev/null
+++ b/LanMountainDesktop/Views/Components/StudySessionReportProjection.cs
@@ -0,0 +1,208 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using LanMountainDesktop.Models;
+
+namespace LanMountainDesktop.Views.Components;
+
+internal readonly record struct StudySessionReportAggregate(
+ TimeSpan Duration,
+ double AverageDisplayDb,
+ double AverageDbfs,
+ double P95DisplayDb,
+ double P50Dbfs,
+ double OverRatio,
+ int SegmentCount,
+ double SegmentsPerMin,
+ double SustainedPenalty,
+ double TimePenalty,
+ double SegmentPenalty,
+ double TotalPenalty,
+ double Score);
+
+internal static class StudySessionReportProjection
+{
+ public static IReadOnlyList BuildSyntheticRealtimePoints(
+ StudySessionReport report,
+ StudyAnalyticsConfig config,
+ int maxPoints = 360)
+ {
+ if (report.Slices.Count == 0)
+ {
+ return Array.Empty();
+ }
+
+ var ordered = report.Slices
+ .OrderBy(slice => slice.StartAt)
+ .ToList();
+
+ var synthetic = new List(ordered.Count * 3);
+ var previousTimestamp = ordered[0].StartAt;
+
+ for (var i = 0; i < ordered.Count; i++)
+ {
+ var slice = ordered[i];
+ var start = slice.StartAt;
+ var end = slice.EndAt > start
+ ? slice.EndAt
+ : start.AddMilliseconds(Math.Max(1, ResolveSliceDurationMs(slice)));
+
+ if (start <= previousTimestamp)
+ {
+ start = previousTimestamp.AddMilliseconds(1);
+ }
+
+ if (end <= start)
+ {
+ end = start.AddMilliseconds(1);
+ }
+
+ var middle = start + TimeSpan.FromTicks(Math.Max(1, (end - start).Ticks / 2));
+ var avgDisplay = Math.Clamp(slice.Display.AvgDb, 20, 100);
+ var p95Display = Math.Clamp(slice.Display.P95Db, 20, 100);
+ var avgDbfs = Math.Clamp(slice.Raw.AvgDbfs, -100, 0);
+ var p95Dbfs = Math.Clamp(slice.Raw.P95Dbfs, -100, 0);
+
+ synthetic.Add(CreatePoint(start, avgDisplay, avgDbfs, config.ScoreThresholdDbfs));
+ synthetic.Add(CreatePoint(middle, p95Display, p95Dbfs, config.ScoreThresholdDbfs));
+ synthetic.Add(CreatePoint(end, avgDisplay, avgDbfs, config.ScoreThresholdDbfs));
+
+ previousTimestamp = end;
+ }
+
+ return DownsampleIfNeeded(synthetic, Math.Max(60, maxPoints));
+ }
+
+ public static bool TryAggregate(
+ StudySessionReport report,
+ StudyAnalyticsConfig config,
+ out StudySessionReportAggregate aggregate)
+ {
+ aggregate = default;
+ if (report.Slices.Count == 0)
+ {
+ return false;
+ }
+
+ var totalDurationMs = 0d;
+ var weightedDisplay = 0d;
+ var weightedDisplayP95 = 0d;
+ var weightedDbfs = 0d;
+ var weightedP50Dbfs = 0d;
+ var weightedOverRatio = 0d;
+ var totalSegments = 0;
+
+ for (var i = 0; i < report.Slices.Count; i++)
+ {
+ var slice = report.Slices[i];
+ var durationMs = ResolveSliceDurationMs(slice);
+ if (durationMs <= 0)
+ {
+ continue;
+ }
+
+ totalDurationMs += durationMs;
+ weightedDisplay += slice.Display.AvgDb * durationMs;
+ weightedDisplayP95 += slice.Display.P95Db * durationMs;
+ weightedDbfs += slice.Raw.AvgDbfs * durationMs;
+ weightedP50Dbfs += slice.Raw.P50Dbfs * durationMs;
+ weightedOverRatio += slice.Raw.OverRatioDbfs * durationMs;
+ totalSegments += Math.Max(0, slice.Raw.SegmentCount);
+ }
+
+ if (totalDurationMs <= 0)
+ {
+ return false;
+ }
+
+ var averageDisplay = weightedDisplay / totalDurationMs;
+ var p95Display = weightedDisplayP95 / totalDurationMs;
+ var averageDbfs = weightedDbfs / totalDurationMs;
+ var p50Dbfs = weightedP50Dbfs / totalDurationMs;
+ var overRatio = Math.Clamp(weightedOverRatio / totalDurationMs, 0, 1);
+ var minutes = Math.Max(1d / 60d, totalDurationMs / 60000d);
+ var segmentsPerMin = totalSegments / minutes;
+
+ var sustainedPenalty = Clamp01((p50Dbfs - config.ScoreThresholdDbfs) / 6d);
+ var timePenalty = Clamp01(overRatio / 0.30d);
+ var segmentPenalty = Clamp01(segmentsPerMin / Math.Max(1, config.MaxSegmentsPerMin));
+ var totalPenalty = (0.40d * sustainedPenalty) + (0.30d * timePenalty) + (0.30d * segmentPenalty);
+ var score = Math.Clamp(100d * (1d - totalPenalty), 0, 100);
+
+ aggregate = new StudySessionReportAggregate(
+ Duration: TimeSpan.FromMilliseconds(totalDurationMs),
+ AverageDisplayDb: Math.Round(averageDisplay, 2),
+ AverageDbfs: Math.Round(averageDbfs, 2),
+ P95DisplayDb: Math.Round(p95Display, 2),
+ P50Dbfs: Math.Round(p50Dbfs, 2),
+ OverRatio: Math.Round(overRatio, 4),
+ SegmentCount: Math.Max(0, totalSegments),
+ SegmentsPerMin: Math.Round(segmentsPerMin, 3),
+ SustainedPenalty: Math.Round(sustainedPenalty, 4),
+ TimePenalty: Math.Round(timePenalty, 4),
+ SegmentPenalty: Math.Round(segmentPenalty, 4),
+ TotalPenalty: Math.Round(totalPenalty, 4),
+ Score: Math.Round(score, 1));
+ return true;
+ }
+
+ public static double ResolveSliceDurationMs(NoiseSliceSummary slice)
+ {
+ if (slice.ScoreDetail.DurationMs > 0)
+ {
+ return slice.ScoreDetail.DurationMs;
+ }
+
+ if (slice.Raw.SampledDurationMs > 0)
+ {
+ return slice.Raw.SampledDurationMs;
+ }
+
+ return Math.Max(1, (slice.EndAt - slice.StartAt).TotalMilliseconds);
+ }
+
+ private static NoiseRealtimePoint CreatePoint(
+ DateTimeOffset timestamp,
+ double displayDb,
+ double dbfs,
+ double scoreThresholdDbfs)
+ {
+ var clampedDbfs = Math.Clamp(dbfs, -100, 0);
+ var rms = Math.Pow(10d, clampedDbfs / 20d);
+ return new NoiseRealtimePoint(
+ Timestamp: timestamp,
+ Rms: rms,
+ Dbfs: clampedDbfs,
+ DisplayDb: Math.Clamp(displayDb, 20, 100),
+ Peak: rms,
+ IsOverThreshold: clampedDbfs >= scoreThresholdDbfs);
+ }
+
+ private static IReadOnlyList DownsampleIfNeeded(List points, int maxPoints)
+ {
+ if (points.Count <= maxPoints)
+ {
+ return points;
+ }
+
+ var result = new List(maxPoints);
+ result.Add(points[0]);
+
+ var middleCount = maxPoints - 2;
+ var sourceMiddleCount = points.Count - 2;
+ for (var i = 0; i < middleCount; i++)
+ {
+ var sourceIndex = 1 + (int)Math.Round(i * (sourceMiddleCount - 1) / (double)Math.Max(1, middleCount - 1));
+ sourceIndex = Math.Clamp(sourceIndex, 1, points.Count - 2);
+ result.Add(points[sourceIndex]);
+ }
+
+ result.Add(points[^1]);
+ return result;
+ }
+
+ private static double Clamp01(double value)
+ {
+ return Math.Clamp(value, 0, 1);
+ }
+}