mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-22 09:14:25 +08:00
0.2.8
天气组件、倒计时组件微调。引入浏览器组件。
This commit is contained in:
48
LanMontainDesktop/Services/IStudyAnalyticsService.cs
Normal file
48
LanMontainDesktop/Services/IStudyAnalyticsService.cs
Normal file
@@ -0,0 +1,48 @@
|
||||
using System;
|
||||
using LanMontainDesktop.Models;
|
||||
|
||||
namespace LanMontainDesktop.Services;
|
||||
|
||||
public sealed class StudyAnalyticsSnapshotChangedEventArgs(StudyAnalyticsSnapshot snapshot) : EventArgs
|
||||
{
|
||||
public StudyAnalyticsSnapshot Snapshot { get; } = snapshot;
|
||||
}
|
||||
|
||||
public sealed class NoiseSliceClosedEventArgs(NoiseSliceSummary slice) : EventArgs
|
||||
{
|
||||
public NoiseSliceSummary Slice { get; } = slice;
|
||||
}
|
||||
|
||||
public sealed class StudySessionCompletedEventArgs(StudySessionReport report) : EventArgs
|
||||
{
|
||||
public StudySessionReport Report { get; } = report;
|
||||
}
|
||||
|
||||
public interface IStudyAnalyticsService : IDisposable
|
||||
{
|
||||
StudyAnalyticsSnapshot GetSnapshot();
|
||||
|
||||
StudyAnalyticsConfig GetConfig();
|
||||
|
||||
void UpdateConfig(StudyAnalyticsConfig config);
|
||||
|
||||
bool StartOrResumeMonitoring();
|
||||
|
||||
bool PauseMonitoring();
|
||||
|
||||
bool StopMonitoring();
|
||||
|
||||
bool StartStudySession(StudySessionOptions? options = null);
|
||||
|
||||
bool StopStudySession();
|
||||
|
||||
bool CancelStudySession();
|
||||
|
||||
void ClearLastSessionReport();
|
||||
|
||||
event EventHandler<StudyAnalyticsSnapshotChangedEventArgs>? SnapshotUpdated;
|
||||
|
||||
event EventHandler<NoiseSliceClosedEventArgs>? SliceClosed;
|
||||
|
||||
event EventHandler<StudySessionCompletedEventArgs>? SessionCompleted;
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
493
LanMontainDesktop/Services/StudyAnalyticsService.cs
Normal file
493
LanMontainDesktop/Services/StudyAnalyticsService.cs
Normal file
@@ -0,0 +1,493 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using LanMontainDesktop.Models;
|
||||
|
||||
namespace LanMontainDesktop.Services;
|
||||
|
||||
public static class StudyAnalyticsServiceFactory
|
||||
{
|
||||
private static readonly Lazy<IStudyAnalyticsService> SharedService = new(
|
||||
() => new StudyAnalyticsService(),
|
||||
isThreadSafe: true);
|
||||
|
||||
public static IStudyAnalyticsService CreateDefault()
|
||||
{
|
||||
return SharedService.Value;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class StudyAnalyticsService : IStudyAnalyticsService
|
||||
{
|
||||
private readonly object _syncRoot = new();
|
||||
private readonly IAudioRecorderService _audioRecorderService;
|
||||
private readonly Timer _samplingTimer;
|
||||
private readonly NoiseFramePipeline _pipeline;
|
||||
private readonly SessionAccumulator _sessionAccumulator = new();
|
||||
|
||||
private StudyAnalyticsConfig _config = new();
|
||||
private StudyAnalyticsRuntimeState _state;
|
||||
private NoiseStreamStatus _streamStatus = NoiseStreamStatus.Initializing;
|
||||
private StudyDataMode _dataMode = StudyDataMode.Realtime;
|
||||
private NoiseRealtimePoint? _latestRealtime;
|
||||
private NoiseSliceSummary? _latestSlice;
|
||||
private StudySessionReport? _lastSessionReport;
|
||||
private string _lastError = string.Empty;
|
||||
private bool _disposed;
|
||||
|
||||
public StudyAnalyticsService(IAudioRecorderService? audioRecorderService = null)
|
||||
{
|
||||
_audioRecorderService = audioRecorderService ?? AudioRecorderServiceFactory.CreateDefault();
|
||||
_pipeline = new NoiseFramePipeline(_config);
|
||||
_samplingTimer = new Timer(OnSamplingTick, null, Timeout.Infinite, Timeout.Infinite);
|
||||
|
||||
var audioSnapshot = _audioRecorderService.GetSnapshot();
|
||||
if (audioSnapshot.IsSupported)
|
||||
{
|
||||
_state = StudyAnalyticsRuntimeState.Ready;
|
||||
_streamStatus = NoiseStreamStatus.Quiet;
|
||||
_lastError = string.Empty;
|
||||
}
|
||||
else
|
||||
{
|
||||
_state = StudyAnalyticsRuntimeState.Unsupported;
|
||||
_streamStatus = NoiseStreamStatus.Error;
|
||||
_lastError = audioSnapshot.LastError;
|
||||
}
|
||||
}
|
||||
|
||||
public event EventHandler<StudyAnalyticsSnapshotChangedEventArgs>? SnapshotUpdated;
|
||||
|
||||
public event EventHandler<NoiseSliceClosedEventArgs>? SliceClosed;
|
||||
|
||||
public event EventHandler<StudySessionCompletedEventArgs>? SessionCompleted;
|
||||
|
||||
public StudyAnalyticsSnapshot GetSnapshot()
|
||||
{
|
||||
lock (_syncRoot)
|
||||
{
|
||||
return BuildSnapshotLocked(DateTimeOffset.UtcNow);
|
||||
}
|
||||
}
|
||||
|
||||
public StudyAnalyticsConfig GetConfig()
|
||||
{
|
||||
lock (_syncRoot)
|
||||
{
|
||||
return _config;
|
||||
}
|
||||
}
|
||||
|
||||
public void UpdateConfig(StudyAnalyticsConfig config)
|
||||
{
|
||||
StudyAnalyticsSnapshot snapshot;
|
||||
lock (_syncRoot)
|
||||
{
|
||||
ThrowIfDisposedLocked();
|
||||
_config = NormalizeConfig(config);
|
||||
_pipeline.UpdateConfig(_config);
|
||||
if (_state == StudyAnalyticsRuntimeState.Running)
|
||||
{
|
||||
StartTimerLocked();
|
||||
}
|
||||
|
||||
_latestSlice = null;
|
||||
UpdateDataModeLocked();
|
||||
snapshot = BuildSnapshotLocked(DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
SnapshotUpdated?.Invoke(this, new StudyAnalyticsSnapshotChangedEventArgs(snapshot));
|
||||
}
|
||||
|
||||
public bool StartOrResumeMonitoring()
|
||||
{
|
||||
StudyAnalyticsSnapshot snapshot;
|
||||
bool started;
|
||||
lock (_syncRoot)
|
||||
{
|
||||
ThrowIfDisposedLocked();
|
||||
started = TryStartMonitoringLocked();
|
||||
snapshot = BuildSnapshotLocked(DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
SnapshotUpdated?.Invoke(this, new StudyAnalyticsSnapshotChangedEventArgs(snapshot));
|
||||
return started;
|
||||
}
|
||||
|
||||
public bool PauseMonitoring()
|
||||
{
|
||||
StudyAnalyticsSnapshot snapshot;
|
||||
bool paused;
|
||||
lock (_syncRoot)
|
||||
{
|
||||
ThrowIfDisposedLocked();
|
||||
if (_state != StudyAnalyticsRuntimeState.Running)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!_audioRecorderService.Pause())
|
||||
{
|
||||
_state = StudyAnalyticsRuntimeState.Error;
|
||||
_streamStatus = NoiseStreamStatus.Error;
|
||||
_lastError = _audioRecorderService.GetSnapshot().LastError;
|
||||
snapshot = BuildSnapshotLocked(DateTimeOffset.UtcNow);
|
||||
paused = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
StopTimerLocked();
|
||||
_state = StudyAnalyticsRuntimeState.Paused;
|
||||
_lastError = string.Empty;
|
||||
UpdateDataModeLocked();
|
||||
snapshot = BuildSnapshotLocked(DateTimeOffset.UtcNow);
|
||||
paused = true;
|
||||
}
|
||||
}
|
||||
|
||||
SnapshotUpdated?.Invoke(this, new StudyAnalyticsSnapshotChangedEventArgs(snapshot));
|
||||
return paused;
|
||||
}
|
||||
|
||||
public bool StopMonitoring()
|
||||
{
|
||||
StudyAnalyticsSnapshot snapshot;
|
||||
StudySessionReport? finishedReport = null;
|
||||
lock (_syncRoot)
|
||||
{
|
||||
ThrowIfDisposedLocked();
|
||||
if (_state is StudyAnalyticsRuntimeState.Unsupported)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_audioRecorderService.Discard();
|
||||
StopTimerLocked();
|
||||
_pipeline.Reset();
|
||||
_latestRealtime = null;
|
||||
_latestSlice = null;
|
||||
_state = StudyAnalyticsRuntimeState.Ready;
|
||||
_streamStatus = NoiseStreamStatus.Quiet;
|
||||
_lastError = string.Empty;
|
||||
|
||||
if (_sessionAccumulator.IsRunning)
|
||||
{
|
||||
finishedReport = _sessionAccumulator.Stop(DateTimeOffset.UtcNow);
|
||||
_lastSessionReport = finishedReport;
|
||||
}
|
||||
|
||||
UpdateDataModeLocked();
|
||||
snapshot = BuildSnapshotLocked(DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
if (finishedReport is not null)
|
||||
{
|
||||
SessionCompleted?.Invoke(this, new StudySessionCompletedEventArgs(finishedReport));
|
||||
}
|
||||
|
||||
SnapshotUpdated?.Invoke(this, new StudyAnalyticsSnapshotChangedEventArgs(snapshot));
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool StartStudySession(StudySessionOptions? options = null)
|
||||
{
|
||||
StudyAnalyticsSnapshot snapshot;
|
||||
bool started;
|
||||
lock (_syncRoot)
|
||||
{
|
||||
ThrowIfDisposedLocked();
|
||||
if (_sessionAccumulator.IsRunning)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!TryStartMonitoringLocked())
|
||||
{
|
||||
snapshot = BuildSnapshotLocked(DateTimeOffset.UtcNow);
|
||||
started = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
var normalizedOptions = options ?? new StudySessionOptions();
|
||||
if (!_sessionAccumulator.Start(DateTimeOffset.UtcNow, normalizedOptions))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_lastSessionReport = null;
|
||||
UpdateDataModeLocked();
|
||||
snapshot = BuildSnapshotLocked(DateTimeOffset.UtcNow);
|
||||
started = true;
|
||||
}
|
||||
}
|
||||
|
||||
SnapshotUpdated?.Invoke(this, new StudyAnalyticsSnapshotChangedEventArgs(snapshot));
|
||||
return started;
|
||||
}
|
||||
|
||||
public bool StopStudySession()
|
||||
{
|
||||
StudySessionReport? report;
|
||||
StudyAnalyticsSnapshot snapshot;
|
||||
lock (_syncRoot)
|
||||
{
|
||||
ThrowIfDisposedLocked();
|
||||
report = _sessionAccumulator.Stop(DateTimeOffset.UtcNow);
|
||||
if (report is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_lastSessionReport = report;
|
||||
UpdateDataModeLocked();
|
||||
snapshot = BuildSnapshotLocked(DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
SessionCompleted?.Invoke(this, new StudySessionCompletedEventArgs(report));
|
||||
SnapshotUpdated?.Invoke(this, new StudyAnalyticsSnapshotChangedEventArgs(snapshot));
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool CancelStudySession()
|
||||
{
|
||||
StudyAnalyticsSnapshot snapshot;
|
||||
lock (_syncRoot)
|
||||
{
|
||||
ThrowIfDisposedLocked();
|
||||
if (!_sessionAccumulator.Cancel())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
UpdateDataModeLocked();
|
||||
snapshot = BuildSnapshotLocked(DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
SnapshotUpdated?.Invoke(this, new StudyAnalyticsSnapshotChangedEventArgs(snapshot));
|
||||
return true;
|
||||
}
|
||||
|
||||
public void ClearLastSessionReport()
|
||||
{
|
||||
StudyAnalyticsSnapshot snapshot;
|
||||
lock (_syncRoot)
|
||||
{
|
||||
ThrowIfDisposedLocked();
|
||||
_lastSessionReport = null;
|
||||
UpdateDataModeLocked();
|
||||
snapshot = BuildSnapshotLocked(DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
SnapshotUpdated?.Invoke(this, new StudyAnalyticsSnapshotChangedEventArgs(snapshot));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
lock (_syncRoot)
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
StopTimerLocked();
|
||||
_samplingTimer.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnSamplingTick(object? state)
|
||||
{
|
||||
StudyAnalyticsSnapshot? snapshot = null;
|
||||
NoiseSliceSummary? closedSlice = null;
|
||||
lock (_syncRoot)
|
||||
{
|
||||
if (_disposed || _state != StudyAnalyticsRuntimeState.Running)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var audioSnapshot = _audioRecorderService.GetSnapshot();
|
||||
if (!audioSnapshot.IsSupported)
|
||||
{
|
||||
_state = StudyAnalyticsRuntimeState.Unsupported;
|
||||
_streamStatus = NoiseStreamStatus.Error;
|
||||
_lastError = audioSnapshot.LastError;
|
||||
StopTimerLocked();
|
||||
snapshot = BuildSnapshotLocked(DateTimeOffset.UtcNow);
|
||||
}
|
||||
else if (audioSnapshot.State == AudioRecorderRuntimeState.Error)
|
||||
{
|
||||
_state = StudyAnalyticsRuntimeState.Error;
|
||||
_streamStatus = NoiseStreamStatus.Error;
|
||||
_lastError = string.IsNullOrWhiteSpace(audioSnapshot.LastError)
|
||||
? "Audio recorder returned an error state."
|
||||
: audioSnapshot.LastError;
|
||||
StopTimerLocked();
|
||||
snapshot = BuildSnapshotLocked(DateTimeOffset.UtcNow);
|
||||
}
|
||||
else
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var rms = Math.Clamp(audioSnapshot.InputLevel, 0, 1);
|
||||
var dbfs = ConvertInputLevelToDbfs(rms, _config.SilenceFloorDbfs);
|
||||
var displayDb = ComputeDisplayDb(dbfs, _config);
|
||||
var tickResult = _pipeline.AddFrame(
|
||||
now,
|
||||
rms,
|
||||
dbfs,
|
||||
displayDb,
|
||||
peak: rms);
|
||||
|
||||
_latestRealtime = tickResult.RealtimePoint;
|
||||
_streamStatus = tickResult.RealtimePoint.IsOverThreshold
|
||||
? NoiseStreamStatus.Noisy
|
||||
: NoiseStreamStatus.Quiet;
|
||||
|
||||
if (tickResult.ClosedSlice is not null)
|
||||
{
|
||||
closedSlice = tickResult.ClosedSlice;
|
||||
_latestSlice = closedSlice;
|
||||
if (_sessionAccumulator.IsRunning)
|
||||
{
|
||||
_sessionAccumulator.AddSlice(closedSlice);
|
||||
}
|
||||
}
|
||||
|
||||
_lastError = string.Empty;
|
||||
UpdateDataModeLocked();
|
||||
snapshot = BuildSnapshotLocked(now);
|
||||
}
|
||||
}
|
||||
|
||||
if (snapshot is not null)
|
||||
{
|
||||
SnapshotUpdated?.Invoke(this, new StudyAnalyticsSnapshotChangedEventArgs(snapshot));
|
||||
}
|
||||
|
||||
if (closedSlice is not null)
|
||||
{
|
||||
SliceClosed?.Invoke(this, new NoiseSliceClosedEventArgs(closedSlice));
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryStartMonitoringLocked()
|
||||
{
|
||||
if (_state == StudyAnalyticsRuntimeState.Unsupported)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_state == StudyAnalyticsRuntimeState.Running)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!_audioRecorderService.StartOrResume())
|
||||
{
|
||||
_state = StudyAnalyticsRuntimeState.Error;
|
||||
_streamStatus = NoiseStreamStatus.Error;
|
||||
_lastError = _audioRecorderService.GetSnapshot().LastError;
|
||||
return false;
|
||||
}
|
||||
|
||||
_state = StudyAnalyticsRuntimeState.Running;
|
||||
_streamStatus = NoiseStreamStatus.Quiet;
|
||||
_lastError = string.Empty;
|
||||
StartTimerLocked();
|
||||
UpdateDataModeLocked();
|
||||
return true;
|
||||
}
|
||||
|
||||
private void StartTimerLocked()
|
||||
{
|
||||
_samplingTimer.Change(
|
||||
dueTime: TimeSpan.Zero,
|
||||
period: TimeSpan.FromMilliseconds(_config.FrameMs));
|
||||
}
|
||||
|
||||
private void StopTimerLocked()
|
||||
{
|
||||
_samplingTimer.Change(Timeout.Infinite, Timeout.Infinite);
|
||||
}
|
||||
|
||||
private void UpdateDataModeLocked()
|
||||
{
|
||||
if (_sessionAccumulator.IsRunning)
|
||||
{
|
||||
_dataMode = StudyDataMode.SessionRunning;
|
||||
return;
|
||||
}
|
||||
|
||||
_dataMode = _lastSessionReport is null
|
||||
? StudyDataMode.Realtime
|
||||
: StudyDataMode.SessionReport;
|
||||
}
|
||||
|
||||
private StudyAnalyticsSnapshot BuildSnapshotLocked(DateTimeOffset now)
|
||||
{
|
||||
return new StudyAnalyticsSnapshot(
|
||||
State: _state,
|
||||
StreamStatus: _streamStatus,
|
||||
DataMode: _dataMode,
|
||||
Config: _config,
|
||||
LatestRealtimePoint: _latestRealtime,
|
||||
LatestSlice: _latestSlice,
|
||||
RealtimeBuffer: _pipeline.GetRealtimeBufferSnapshot(),
|
||||
Session: _sessionAccumulator.GetSnapshot(now),
|
||||
LastSessionReport: _lastSessionReport,
|
||||
LastError: _lastError);
|
||||
}
|
||||
|
||||
private static double ConvertInputLevelToDbfs(double level, double silenceFloorDbfs)
|
||||
{
|
||||
var clampedLevel = Math.Clamp(level, 0, 1);
|
||||
if (clampedLevel <= 1e-5)
|
||||
{
|
||||
return silenceFloorDbfs;
|
||||
}
|
||||
|
||||
var dbfs = 20d * Math.Log10(clampedLevel);
|
||||
return Math.Clamp(dbfs, silenceFloorDbfs, 0);
|
||||
}
|
||||
|
||||
private static double ComputeDisplayDb(double dbfs, StudyAnalyticsConfig config)
|
||||
{
|
||||
// Keep score and calibration decoupled: scoring uses dBFS, display maps it to user-facing dB.
|
||||
var referenceDelta = dbfs - config.ScoreThresholdDbfs;
|
||||
return Math.Round(config.BaselineDb + referenceDelta, 2);
|
||||
}
|
||||
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
private void ThrowIfDisposedLocked()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
throw new ObjectDisposedException(nameof(StudyAnalyticsService));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user