天气组件、倒计时组件微调。引入浏览器组件。
This commit is contained in:
lincube
2026-03-04 03:41:59 +08:00
parent e8276c4d1e
commit 3d22c04a04
31 changed files with 3258 additions and 576 deletions

View 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;
}

View 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);
}
}

View 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));
}
}
}