mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-21 16:14:28 +08:00
0.2.8
天气组件、倒计时组件微调。引入浏览器组件。
This commit is contained in:
492
LanMontainDesktop/Services/StudyAnalyticsInternals.cs
Normal file
492
LanMontainDesktop/Services/StudyAnalyticsInternals.cs
Normal file
@@ -0,0 +1,492 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using LanMontainDesktop.Models;
|
||||
|
||||
namespace LanMontainDesktop.Services;
|
||||
|
||||
internal readonly record struct NoisePipelineTickResult(
|
||||
NoiseRealtimePoint RealtimePoint,
|
||||
NoiseSliceSummary? ClosedSlice);
|
||||
|
||||
internal sealed class NoiseFramePipeline
|
||||
{
|
||||
private StudyAnalyticsConfig _config;
|
||||
private readonly Queue<NoiseRealtimePoint> _realtimeBuffer = new();
|
||||
private readonly List<NoiseRealtimePoint> _slicePoints = [];
|
||||
|
||||
private DateTimeOffset _sliceStartAt;
|
||||
private DateTimeOffset _lastFrameAt;
|
||||
private DateTimeOffset _lastOverThresholdAt;
|
||||
|
||||
private int _overThresholdFrameCount;
|
||||
private int _segmentCount;
|
||||
private bool _segmentOpen;
|
||||
private int _gapCount;
|
||||
private double _maxGapMs;
|
||||
|
||||
public NoiseFramePipeline(StudyAnalyticsConfig config)
|
||||
{
|
||||
_config = NormalizeConfig(config);
|
||||
}
|
||||
|
||||
public void UpdateConfig(StudyAnalyticsConfig config)
|
||||
{
|
||||
_config = NormalizeConfig(config);
|
||||
Reset();
|
||||
}
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
_realtimeBuffer.Clear();
|
||||
_slicePoints.Clear();
|
||||
_sliceStartAt = default;
|
||||
_lastFrameAt = default;
|
||||
_lastOverThresholdAt = default;
|
||||
_overThresholdFrameCount = 0;
|
||||
_segmentCount = 0;
|
||||
_segmentOpen = false;
|
||||
_gapCount = 0;
|
||||
_maxGapMs = 0;
|
||||
}
|
||||
|
||||
public IReadOnlyList<NoiseRealtimePoint> GetRealtimeBufferSnapshot()
|
||||
{
|
||||
return _realtimeBuffer.ToArray();
|
||||
}
|
||||
|
||||
public NoisePipelineTickResult AddFrame(DateTimeOffset timestamp, double rms, double dbfs, double displayDb, double peak)
|
||||
{
|
||||
if (_sliceStartAt == default)
|
||||
{
|
||||
_sliceStartAt = timestamp;
|
||||
}
|
||||
|
||||
if (_lastFrameAt != default)
|
||||
{
|
||||
var actualGapMs = (timestamp - _lastFrameAt).TotalMilliseconds;
|
||||
var expectedGapMs = _config.FrameMs;
|
||||
var jitterMs = Math.Max(0, actualGapMs - expectedGapMs);
|
||||
if (jitterMs > Math.Max(12, expectedGapMs * 0.8))
|
||||
{
|
||||
_gapCount++;
|
||||
_maxGapMs = Math.Max(_maxGapMs, jitterMs);
|
||||
}
|
||||
}
|
||||
|
||||
_lastFrameAt = timestamp;
|
||||
|
||||
var isOverThreshold = dbfs > _config.ScoreThresholdDbfs;
|
||||
if (isOverThreshold)
|
||||
{
|
||||
_overThresholdFrameCount++;
|
||||
if (_segmentOpen)
|
||||
{
|
||||
_lastOverThresholdAt = timestamp;
|
||||
}
|
||||
else
|
||||
{
|
||||
var canMergeToPrevious = _lastOverThresholdAt != default &&
|
||||
(timestamp - _lastOverThresholdAt).TotalMilliseconds <= _config.SegmentMergeGapMs;
|
||||
if (!canMergeToPrevious)
|
||||
{
|
||||
_segmentCount++;
|
||||
}
|
||||
|
||||
_segmentOpen = true;
|
||||
_lastOverThresholdAt = timestamp;
|
||||
}
|
||||
}
|
||||
else if (_segmentOpen && _lastOverThresholdAt != default)
|
||||
{
|
||||
var silentGapMs = (timestamp - _lastOverThresholdAt).TotalMilliseconds;
|
||||
if (silentGapMs > _config.SegmentMergeGapMs)
|
||||
{
|
||||
_segmentOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
var point = new NoiseRealtimePoint(
|
||||
timestamp,
|
||||
rms,
|
||||
dbfs,
|
||||
displayDb,
|
||||
peak,
|
||||
isOverThreshold);
|
||||
_slicePoints.Add(point);
|
||||
_realtimeBuffer.Enqueue(point);
|
||||
|
||||
while (_realtimeBuffer.Count > _config.RealtimeBufferCapacity)
|
||||
{
|
||||
_realtimeBuffer.Dequeue();
|
||||
}
|
||||
|
||||
var elapsedSeconds = (timestamp - _sliceStartAt).TotalSeconds;
|
||||
if (elapsedSeconds + 1e-6 < _config.SliceSec)
|
||||
{
|
||||
return new NoisePipelineTickResult(point, null);
|
||||
}
|
||||
|
||||
var slice = BuildClosedSlice(timestamp);
|
||||
ResetSliceState(timestamp);
|
||||
return new NoisePipelineTickResult(point, slice);
|
||||
}
|
||||
|
||||
private NoiseSliceSummary BuildClosedSlice(DateTimeOffset endAt)
|
||||
{
|
||||
var sampledDurationMs = _slicePoints.Count * _config.FrameMs;
|
||||
if (_slicePoints.Count == 0 || sampledDurationMs <= 0)
|
||||
{
|
||||
var emptyRaw = new NoiseSliceRawStats(
|
||||
AvgDbfs: _config.SilenceFloorDbfs,
|
||||
MaxDbfs: _config.SilenceFloorDbfs,
|
||||
P50Dbfs: _config.SilenceFloorDbfs,
|
||||
P95Dbfs: _config.SilenceFloorDbfs,
|
||||
OverRatioDbfs: 0,
|
||||
SegmentCount: 0,
|
||||
SampledDurationMs: 0,
|
||||
GapCount: _gapCount,
|
||||
MaxGapMs: _maxGapMs);
|
||||
var emptyDisplay = new NoiseSliceDisplayStats(_config.BaselineDb, _config.BaselineDb);
|
||||
var emptyScore = ScoreCalculator.Calculate(
|
||||
p50Dbfs: emptyRaw.P50Dbfs,
|
||||
overRatioDbfs: emptyRaw.OverRatioDbfs,
|
||||
segmentCount: emptyRaw.SegmentCount,
|
||||
sampledDurationMs: 0,
|
||||
scoreThresholdDbfs: _config.ScoreThresholdDbfs,
|
||||
maxSegmentsPerMin: _config.MaxSegmentsPerMin);
|
||||
return new NoiseSliceSummary(
|
||||
_sliceStartAt,
|
||||
endAt,
|
||||
0,
|
||||
emptyRaw,
|
||||
emptyDisplay,
|
||||
emptyScore.Score,
|
||||
emptyScore);
|
||||
}
|
||||
|
||||
var dbfsList = _slicePoints.Select(p => p.Dbfs).OrderBy(v => v).ToArray();
|
||||
var displayList = _slicePoints.Select(p => p.DisplayDb).OrderBy(v => v).ToArray();
|
||||
|
||||
var avgDbfs = ScoreCalculator.ComputeAverageDbfs(dbfsList);
|
||||
var maxDbfs = dbfsList[^1];
|
||||
var p50Dbfs = Percentile(sortedValues: dbfsList, percentile: 0.50);
|
||||
var p95Dbfs = Percentile(sortedValues: dbfsList, percentile: 0.95);
|
||||
var overRatio = _overThresholdFrameCount / (double)_slicePoints.Count;
|
||||
|
||||
var raw = new NoiseSliceRawStats(
|
||||
AvgDbfs: avgDbfs,
|
||||
MaxDbfs: maxDbfs,
|
||||
P50Dbfs: p50Dbfs,
|
||||
P95Dbfs: p95Dbfs,
|
||||
OverRatioDbfs: Math.Clamp(overRatio, 0, 1),
|
||||
SegmentCount: _segmentCount,
|
||||
SampledDurationMs: sampledDurationMs,
|
||||
GapCount: _gapCount,
|
||||
MaxGapMs: _maxGapMs);
|
||||
|
||||
var display = new NoiseSliceDisplayStats(
|
||||
AvgDb: Math.Round(displayList.Average(), 2),
|
||||
P95Db: Math.Round(Percentile(displayList, 0.95), 2));
|
||||
|
||||
var score = ScoreCalculator.Calculate(
|
||||
p50Dbfs: raw.P50Dbfs,
|
||||
overRatioDbfs: raw.OverRatioDbfs,
|
||||
segmentCount: raw.SegmentCount,
|
||||
sampledDurationMs: raw.SampledDurationMs,
|
||||
scoreThresholdDbfs: _config.ScoreThresholdDbfs,
|
||||
maxSegmentsPerMin: _config.MaxSegmentsPerMin);
|
||||
|
||||
return new NoiseSliceSummary(
|
||||
_sliceStartAt,
|
||||
endAt,
|
||||
_slicePoints.Count,
|
||||
raw,
|
||||
display,
|
||||
score.Score,
|
||||
score);
|
||||
}
|
||||
|
||||
private void ResetSliceState(DateTimeOffset nextSliceStartAt)
|
||||
{
|
||||
_slicePoints.Clear();
|
||||
_sliceStartAt = nextSliceStartAt;
|
||||
_lastOverThresholdAt = default;
|
||||
_overThresholdFrameCount = 0;
|
||||
_segmentCount = 0;
|
||||
_segmentOpen = false;
|
||||
_gapCount = 0;
|
||||
_maxGapMs = 0;
|
||||
}
|
||||
|
||||
private static double Percentile(double[] sortedValues, double percentile)
|
||||
{
|
||||
if (sortedValues.Length == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (sortedValues.Length == 1)
|
||||
{
|
||||
return sortedValues[0];
|
||||
}
|
||||
|
||||
var clamped = Math.Clamp(percentile, 0, 1);
|
||||
var position = (sortedValues.Length - 1) * clamped;
|
||||
var lower = (int)Math.Floor(position);
|
||||
var upper = (int)Math.Ceiling(position);
|
||||
if (lower == upper)
|
||||
{
|
||||
return sortedValues[lower];
|
||||
}
|
||||
|
||||
var factor = position - lower;
|
||||
return sortedValues[lower] + ((sortedValues[upper] - sortedValues[lower]) * factor);
|
||||
}
|
||||
|
||||
private static StudyAnalyticsConfig NormalizeConfig(StudyAnalyticsConfig config)
|
||||
{
|
||||
var frameMs = Math.Clamp(config.FrameMs, 20, 250);
|
||||
var sliceSec = Math.Clamp(config.SliceSec, 5, 600);
|
||||
var threshold = Math.Clamp(config.ScoreThresholdDbfs, -100, -5);
|
||||
var mergeGapMs = Math.Clamp(config.SegmentMergeGapMs, 100, 4000);
|
||||
var maxSegments = Math.Clamp(config.MaxSegmentsPerMin, 1, 40);
|
||||
var silenceFloor = Math.Clamp(config.SilenceFloorDbfs, -100, -20);
|
||||
var baselineDb = Math.Clamp(config.BaselineDb, 20, 90);
|
||||
var avgWindowSec = Math.Clamp(config.AvgWindowSec, 1, 8);
|
||||
var ringCapacity = Math.Clamp(config.RealtimeBufferCapacity, 60, 1200);
|
||||
|
||||
return config with
|
||||
{
|
||||
FrameMs = frameMs,
|
||||
SliceSec = sliceSec,
|
||||
ScoreThresholdDbfs = threshold,
|
||||
SegmentMergeGapMs = mergeGapMs,
|
||||
MaxSegmentsPerMin = maxSegments,
|
||||
SilenceFloorDbfs = silenceFloor,
|
||||
BaselineDb = baselineDb,
|
||||
AvgWindowSec = avgWindowSec,
|
||||
RealtimeBufferCapacity = ringCapacity
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
internal static class ScoreCalculator
|
||||
{
|
||||
public static NoiseScoreBreakdown Calculate(
|
||||
double p50Dbfs,
|
||||
double overRatioDbfs,
|
||||
int segmentCount,
|
||||
double sampledDurationMs,
|
||||
double scoreThresholdDbfs,
|
||||
int maxSegmentsPerMin)
|
||||
{
|
||||
var minutes = Math.Max(1d / 60d, sampledDurationMs / 60000d);
|
||||
var sustainedPenalty = Clamp01((p50Dbfs - scoreThresholdDbfs) / 6d);
|
||||
var timePenalty = Clamp01(overRatioDbfs / 0.30d);
|
||||
var segmentRatePerMin = segmentCount / minutes;
|
||||
var segmentPenalty = Clamp01(segmentRatePerMin / Math.Max(1, maxSegmentsPerMin));
|
||||
var totalPenalty = (0.40d * sustainedPenalty) + (0.30d * timePenalty) + (0.30d * segmentPenalty);
|
||||
var score = Math.Clamp(100d * (1d - totalPenalty), 0, 100);
|
||||
|
||||
return new NoiseScoreBreakdown(
|
||||
SustainedPenalty: Math.Round(sustainedPenalty, 4),
|
||||
TimePenalty: Math.Round(timePenalty, 4),
|
||||
SegmentPenalty: Math.Round(segmentPenalty, 4),
|
||||
TotalPenalty: Math.Round(totalPenalty, 4),
|
||||
Score: Math.Round(score, 2),
|
||||
SustainedLevelDbfs: Math.Round(p50Dbfs, 3),
|
||||
OverRatioDbfs: Math.Round(Math.Clamp(overRatioDbfs, 0, 1), 4),
|
||||
SegmentCount: Math.Max(0, segmentCount),
|
||||
Minutes: Math.Round(minutes, 4),
|
||||
DurationMs: Math.Max(0, sampledDurationMs));
|
||||
}
|
||||
|
||||
public static double ComputeAverageDbfs(double[] dbfsValues)
|
||||
{
|
||||
if (dbfsValues.Length == 0)
|
||||
{
|
||||
return -100;
|
||||
}
|
||||
|
||||
// Average in energy domain then convert back to dBFS.
|
||||
var avgPower = dbfsValues
|
||||
.Select(DbfsToPower)
|
||||
.Average();
|
||||
return Math.Round(PowerToDbfs(avgPower), 3);
|
||||
}
|
||||
|
||||
public static double DbfsToPower(double dbfs)
|
||||
{
|
||||
return Math.Pow(10d, dbfs / 10d);
|
||||
}
|
||||
|
||||
public static double PowerToDbfs(double power)
|
||||
{
|
||||
if (power <= 1e-12)
|
||||
{
|
||||
return -100;
|
||||
}
|
||||
|
||||
return Math.Clamp(10d * Math.Log10(power), -100, 0);
|
||||
}
|
||||
|
||||
private static double Clamp01(double value)
|
||||
{
|
||||
return Math.Clamp(value, 0, 1);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class SessionAccumulator
|
||||
{
|
||||
private readonly List<NoiseSliceSummary> _slices = [];
|
||||
|
||||
private StudySessionRuntimeState _state = StudySessionRuntimeState.Idle;
|
||||
private string? _sessionId;
|
||||
private string _label = string.Empty;
|
||||
private DateTimeOffset? _startedAt;
|
||||
private DateTimeOffset? _endedAt;
|
||||
private string _lastError = string.Empty;
|
||||
|
||||
private double _sumEffectiveMs;
|
||||
private double _sumWeightedScore;
|
||||
private double _sumWeightedOverRatio;
|
||||
private int _totalSegments;
|
||||
private double _minScore = 100;
|
||||
private double _maxScore;
|
||||
private double _currentScore;
|
||||
|
||||
public bool IsRunning => _state == StudySessionRuntimeState.Running;
|
||||
|
||||
public bool Start(DateTimeOffset now, StudySessionOptions options)
|
||||
{
|
||||
if (IsRunning)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_state = StudySessionRuntimeState.Running;
|
||||
_sessionId = Guid.NewGuid().ToString("N");
|
||||
_label = string.IsNullOrWhiteSpace(options.Label) ? "Study Session" : options.Label.Trim();
|
||||
_startedAt = now;
|
||||
_endedAt = null;
|
||||
_lastError = string.Empty;
|
||||
_slices.Clear();
|
||||
_sumEffectiveMs = 0;
|
||||
_sumWeightedScore = 0;
|
||||
_sumWeightedOverRatio = 0;
|
||||
_totalSegments = 0;
|
||||
_minScore = 100;
|
||||
_maxScore = 0;
|
||||
_currentScore = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
public void AddSlice(NoiseSliceSummary slice)
|
||||
{
|
||||
if (!IsRunning)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_slices.Add(slice);
|
||||
var effectiveMs = Math.Max(0, slice.Raw.SampledDurationMs);
|
||||
_sumEffectiveMs += effectiveMs;
|
||||
_sumWeightedScore += slice.Score * effectiveMs;
|
||||
_sumWeightedOverRatio += slice.Raw.OverRatioDbfs * effectiveMs;
|
||||
_totalSegments += Math.Max(0, slice.Raw.SegmentCount);
|
||||
_currentScore = slice.Score;
|
||||
_minScore = Math.Min(_minScore, slice.Score);
|
||||
_maxScore = Math.Max(_maxScore, slice.Score);
|
||||
}
|
||||
|
||||
public StudySessionReport? Stop(DateTimeOffset now)
|
||||
{
|
||||
if (!IsRunning || _startedAt is null || string.IsNullOrWhiteSpace(_sessionId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
_state = StudySessionRuntimeState.Completed;
|
||||
_endedAt = now;
|
||||
|
||||
var metrics = BuildMetrics();
|
||||
return new StudySessionReport(
|
||||
SessionId: _sessionId,
|
||||
Label: _label,
|
||||
StartedAt: _startedAt.Value,
|
||||
EndedAt: _endedAt.Value,
|
||||
Duration: _endedAt.Value - _startedAt.Value,
|
||||
Metrics: metrics,
|
||||
Slices: _slices.ToArray());
|
||||
}
|
||||
|
||||
public bool Cancel()
|
||||
{
|
||||
if (!IsRunning)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
ResetToIdle();
|
||||
return true;
|
||||
}
|
||||
|
||||
public StudySessionSnapshot GetSnapshot(DateTimeOffset now)
|
||||
{
|
||||
var startedAt = _startedAt;
|
||||
var endedAt = _endedAt;
|
||||
var elapsed = startedAt is null
|
||||
? TimeSpan.Zero
|
||||
: (endedAt ?? now) - startedAt.Value;
|
||||
if (elapsed < TimeSpan.Zero)
|
||||
{
|
||||
elapsed = TimeSpan.Zero;
|
||||
}
|
||||
|
||||
return new StudySessionSnapshot(
|
||||
State: _state,
|
||||
SessionId: _sessionId,
|
||||
Label: _label,
|
||||
StartedAt: startedAt,
|
||||
EndedAt: endedAt,
|
||||
Elapsed: elapsed,
|
||||
Metrics: BuildMetrics(),
|
||||
LastError: _lastError);
|
||||
}
|
||||
|
||||
public void ResetToIdle()
|
||||
{
|
||||
_state = StudySessionRuntimeState.Idle;
|
||||
_sessionId = null;
|
||||
_label = string.Empty;
|
||||
_startedAt = null;
|
||||
_endedAt = null;
|
||||
_lastError = string.Empty;
|
||||
_slices.Clear();
|
||||
_sumEffectiveMs = 0;
|
||||
_sumWeightedScore = 0;
|
||||
_sumWeightedOverRatio = 0;
|
||||
_totalSegments = 0;
|
||||
_minScore = 100;
|
||||
_maxScore = 0;
|
||||
_currentScore = 0;
|
||||
}
|
||||
|
||||
private StudySessionMetrics BuildMetrics()
|
||||
{
|
||||
var avgScore = _sumEffectiveMs <= 0 ? 0 : _sumWeightedScore / _sumEffectiveMs;
|
||||
var avgOverRatio = _sumEffectiveMs <= 0 ? 0 : _sumWeightedOverRatio / _sumEffectiveMs;
|
||||
var minScore = _slices.Count == 0 ? 0 : _minScore;
|
||||
var maxScore = _slices.Count == 0 ? 0 : _maxScore;
|
||||
return new StudySessionMetrics(
|
||||
CurrentScore: Math.Round(_currentScore, 2),
|
||||
AvgScore: Math.Round(avgScore, 2),
|
||||
MinScore: Math.Round(minScore, 2),
|
||||
MaxScore: Math.Round(maxScore, 2),
|
||||
WeightedOverRatioDbfs: Math.Round(avgOverRatio, 4),
|
||||
TotalSegmentCount: _totalSegments,
|
||||
EffectiveDuration: TimeSpan.FromMilliseconds(_sumEffectiveMs),
|
||||
SliceCount: _slices.Count);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user