using System; using System.Collections.Generic; using System.Linq; using System.Threading; using LanMountainDesktop.Models; namespace LanMountainDesktop.Services; public static class StudyAnalyticsServiceFactory { private static readonly Lazy SharedService = new( () => new StudyAnalyticsService(), isThreadSafe: true); public static IStudyAnalyticsService CreateDefault() { return SharedService.Value; } public static void DisposeSharedService() { if (SharedService.IsValueCreated) { SharedService.Value.Dispose(); } } } public sealed class StudyAnalyticsService : IStudyAnalyticsService { private const int MaxPersistedSessionReports = 120; private readonly object _syncRoot = new(); private readonly StudyDataStore _studyDataStore = 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 readonly List _sessionHistory = []; private string? _selectedSessionReportId; private string _lastError = string.Empty; private DateTimeOffset _lastUiPublishedAt; private bool _disposed; public StudyAnalyticsService(IAudioRecorderService? audioRecorderService = null) { _audioRecorderService = audioRecorderService ?? AudioRecorderServiceFactory.CreateStudyMonitoring(); _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; } RestoreSessionHistoryFromDatabaseLocked(); UpdateDataModeLocked(); } public event EventHandler? SnapshotUpdated; public event EventHandler? SliceClosed; public event EventHandler? 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); _lastUiPublishedAt = default; 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); if (finishedReport is not null) { UpsertSessionReportLocked(finishedReport, selectReport: true); } } 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; _selectedSessionReportId = null; PersistSessionHistoryLocked(); 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; } UpsertSessionReportLocked(report, selectReport: true); 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; _selectedSessionReportId = null; PersistSessionHistoryLocked(); UpdateDataModeLocked(); snapshot = BuildSnapshotLocked(DateTimeOffset.UtcNow); } SnapshotUpdated?.Invoke(this, new StudyAnalyticsSnapshotChangedEventArgs(snapshot)); } public bool SelectSessionReport(string sessionId) { if (string.IsNullOrWhiteSpace(sessionId)) { return false; } StudyAnalyticsSnapshot snapshot; lock (_syncRoot) { ThrowIfDisposedLocked(); if (_sessionAccumulator.IsRunning) { return false; } // 如果找不到报告,尝试重新从数据库加载 if (!TryFindSessionReportLocked(sessionId, out var report)) { // 重新加载历史数据 RestoreSessionHistoryFromDatabaseLocked(); // 再次尝试查找 if (!TryFindSessionReportLocked(sessionId, out report)) { return false; } } _selectedSessionReportId = report.SessionId; _lastSessionReport = report; PersistSessionHistoryLocked(); UpdateDataModeLocked(); snapshot = BuildSnapshotLocked(DateTimeOffset.UtcNow); } SnapshotUpdated?.Invoke(this, new StudyAnalyticsSnapshotChangedEventArgs(snapshot)); return true; } public bool RenameSessionReport(string sessionId, string label) { if (string.IsNullOrWhiteSpace(sessionId)) { return false; } var normalizedLabel = string.IsNullOrWhiteSpace(label) ? string.Empty : label.Trim(); if (string.IsNullOrWhiteSpace(normalizedLabel)) { return false; } StudyAnalyticsSnapshot snapshot; lock (_syncRoot) { ThrowIfDisposedLocked(); var index = FindSessionReportIndexLocked(sessionId); // 如果找不到报告,尝试重新从数据库加载 if (index < 0) { RestoreSessionHistoryFromDatabaseLocked(); index = FindSessionReportIndexLocked(sessionId); if (index < 0) { return false; } } var updated = _sessionHistory[index] with { Label = normalizedLabel }; _sessionHistory[index] = updated; if (string.Equals(_selectedSessionReportId, updated.SessionId, StringComparison.OrdinalIgnoreCase)) { _lastSessionReport = updated; } PersistSessionHistoryLocked(); UpdateDataModeLocked(); snapshot = BuildSnapshotLocked(DateTimeOffset.UtcNow); } SnapshotUpdated?.Invoke(this, new StudyAnalyticsSnapshotChangedEventArgs(snapshot)); return true; } public bool DeleteSessionReport(string sessionId) { if (string.IsNullOrWhiteSpace(sessionId)) { return false; } StudyAnalyticsSnapshot snapshot; lock (_syncRoot) { ThrowIfDisposedLocked(); var index = FindSessionReportIndexLocked(sessionId); // 如果找不到报告,尝试重新从数据库加载 if (index < 0) { RestoreSessionHistoryFromDatabaseLocked(); index = FindSessionReportIndexLocked(sessionId); if (index < 0) { return false; } } var removed = _sessionHistory[index]; _sessionHistory.RemoveAt(index); if (string.Equals(_selectedSessionReportId, removed.SessionId, StringComparison.OrdinalIgnoreCase)) { _selectedSessionReportId = null; _lastSessionReport = null; } PersistSessionHistoryLocked(); UpdateDataModeLocked(); snapshot = BuildSnapshotLocked(DateTimeOffset.UtcNow); } SnapshotUpdated?.Invoke(this, new StudyAnalyticsSnapshotChangedEventArgs(snapshot)); return true; } public IReadOnlyList QueryNoiseSliceTimeline( DateTimeOffset? startAt = null, DateTimeOffset? endAt = null, int limit = 720, bool includeRealtimeSlices = true, bool includeSessionSlices = true) { lock (_syncRoot) { ThrowIfDisposedLocked(); } return _studyDataStore.LoadNoiseSliceTimeline( startAt: startAt, endAt: endAt, limit: limit, includeRealtimeSlices: includeRealtimeSlices, includeSessionSlices: includeSessionSlices); } public void ClearNoiseSliceTimeline(DateTimeOffset? olderThan = null) { lock (_syncRoot) { ThrowIfDisposedLocked(); } _studyDataStore.ClearNoiseSliceTimeline(olderThan); } public void Dispose() { lock (_syncRoot) { if (_disposed) { return; } _disposed = true; StopTimerLocked(); _samplingTimer.Dispose(); _audioRecorderService.Dispose(); } } private void OnSamplingTick(object? state) { StudyAnalyticsSnapshot? snapshot = null; NoiseSliceSummary? closedSlice = null; string? closedSliceSessionId = null; var closedSliceSourceType = NoiseSliceSourceType.Realtime; 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); closedSliceSessionId = _sessionAccumulator.CurrentSessionId; closedSliceSourceType = NoiseSliceSourceType.Session; } } _lastError = string.Empty; UpdateDataModeLocked(); if (ShouldPublishRealtimeSnapshotLocked(now, closedSlice is not null)) { snapshot = BuildSnapshotLocked(now); _lastUiPublishedAt = now; } } } if (closedSlice is not null) { _studyDataStore.AppendNoiseSlice( slice: closedSlice, sessionId: closedSliceSessionId, sourceType: closedSliceSourceType); } 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() { _lastUiPublishedAt = default; _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) { var historyEntries = _sessionHistory .OrderByDescending(report => report.EndedAt) .Select(report => new StudySessionHistoryEntry( SessionId: report.SessionId, Label: report.Label, StartedAt: report.StartedAt, EndedAt: report.EndedAt, Duration: report.Duration, AverageScore: report.Metrics.AvgScore, SliceCount: report.Metrics.SliceCount)) .ToArray(); return new StudyAnalyticsSnapshot( State: _state, StreamStatus: _streamStatus, DataMode: _dataMode, Config: _config, LatestRealtimePoint: _latestRealtime, LatestSlice: _latestSlice, RealtimeBuffer: _pipeline.GetRealtimeBufferSnapshot(), Session: _sessionAccumulator.GetSnapshot(now), LastSessionReport: _lastSessionReport, SelectedSessionReportId: _selectedSessionReportId, SessionHistory: historyEntries, 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 uiPublishIntervalMs = Math.Clamp(config.UiPublishIntervalMs, 50, 500); 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, UiPublishIntervalMs = uiPublishIntervalMs, SliceSec = sliceSec, ScoreThresholdDbfs = threshold, SegmentMergeGapMs = mergeGapMs, MaxSegmentsPerMin = maxSegments, SilenceFloorDbfs = silenceFloor, BaselineDb = baselineDb, AvgWindowSec = avgWindowSec, RealtimeBufferCapacity = ringCapacity }; } private bool ShouldPublishRealtimeSnapshotLocked(DateTimeOffset now, bool hasClosedSlice) { if (hasClosedSlice || _lastUiPublishedAt == default) { return true; } return (now - _lastUiPublishedAt).TotalMilliseconds >= _config.UiPublishIntervalMs; } private void ThrowIfDisposedLocked() { if (_disposed) { throw new ObjectDisposedException(nameof(StudyAnalyticsService)); } } private void UpsertSessionReportLocked(StudySessionReport report, bool selectReport) { var index = FindSessionReportIndexLocked(report.SessionId); if (index >= 0) { _sessionHistory[index] = report; } else { _sessionHistory.Add(report); } NormalizeSessionHistoryLocked(); if (selectReport) { _selectedSessionReportId = report.SessionId; _lastSessionReport = report; } PersistSessionHistoryLocked(); } private bool TryFindSessionReportLocked(string sessionId, out StudySessionReport report) { var index = FindSessionReportIndexLocked(sessionId); if (index >= 0) { report = _sessionHistory[index]; return true; } report = null!; return false; } private int FindSessionReportIndexLocked(string sessionId) { return _sessionHistory.FindIndex(report => string.Equals(report.SessionId, sessionId, StringComparison.OrdinalIgnoreCase)); } private void NormalizeSessionHistoryLocked() { _sessionHistory.Sort((left, right) => right.EndedAt.CompareTo(left.EndedAt)); if (_sessionHistory.Count > MaxPersistedSessionReports) { _sessionHistory.RemoveRange(MaxPersistedSessionReports, _sessionHistory.Count - MaxPersistedSessionReports); } } private void PersistSessionHistoryLocked() { var orderedReports = _sessionHistory .OrderByDescending(report => report.EndedAt) .Take(MaxPersistedSessionReports) .ToList(); _studyDataStore.ReplaceSessionReports(orderedReports); _studyDataStore.SetSelectedSessionReportId(_selectedSessionReportId); } private void RestoreSessionHistoryFromDatabaseLocked() { _sessionHistory.Clear(); var restored = _studyDataStore.LoadSessionReports(MaxPersistedSessionReports) .Where(report => !string.IsNullOrWhiteSpace(report.SessionId) && report.EndedAt >= report.StartedAt) .GroupBy(report => report.SessionId, StringComparer.OrdinalIgnoreCase) .Select(group => group.First()) .OrderByDescending(report => report.EndedAt) .Take(MaxPersistedSessionReports); _sessionHistory.AddRange(restored); _selectedSessionReportId = _studyDataStore.GetSelectedSessionReportId(); if (!string.IsNullOrWhiteSpace(_selectedSessionReportId) && TryFindSessionReportLocked(_selectedSessionReportId, out var selectedReport)) { _lastSessionReport = selectedReport; return; } _selectedSessionReportId = null; _lastSessionReport = null; } }