噪音数据历史记录,引入数据库
This commit is contained in:
lincube
2026-03-05 00:40:49 +08:00
parent 9ec879cc17
commit 417cfa362e
21 changed files with 2228 additions and 27 deletions

View File

@@ -0,0 +1,146 @@
using System;
using System.IO;
using Microsoft.Data.Sqlite;
namespace LanMountainDesktop.Services;
public static class AppDatabaseServiceFactory
{
private static readonly Lazy<AppDatabaseService> SharedService = new(
() => new AppDatabaseService(),
isThreadSafe: true);
public static AppDatabaseService CreateDefault()
{
return SharedService.Value;
}
}
public sealed class AppDatabaseService
{
private readonly object _schemaSyncRoot = new();
private readonly string _databasePath;
private bool _schemaInitialized;
public AppDatabaseService()
{
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
var dataDirectory = Path.Combine(appData, "LanMountainDesktop");
_databasePath = Path.Combine(dataDirectory, "app.db");
}
public SqliteConnection OpenConnection()
{
var directory = Path.GetDirectoryName(_databasePath);
if (!string.IsNullOrWhiteSpace(directory))
{
Directory.CreateDirectory(directory);
}
var connection = new SqliteConnection($"Data Source={_databasePath};Cache=Shared;Mode=ReadWriteCreate");
connection.Open();
using (var command = connection.CreateCommand())
{
command.CommandText = "PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 3000;";
command.ExecuteNonQuery();
}
EnsureSchema(connection);
return connection;
}
private void EnsureSchema(SqliteConnection connection)
{
if (_schemaInitialized)
{
return;
}
lock (_schemaSyncRoot)
{
if (_schemaInitialized)
{
return;
}
using var command = connection.CreateCommand();
command.CommandText = """
PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
CREATE TABLE IF NOT EXISTS app_meta (
key TEXT NOT NULL PRIMARY KEY,
value TEXT NULL
);
CREATE TABLE IF NOT EXISTS study_session_reports (
session_id TEXT NOT NULL PRIMARY KEY,
label TEXT NOT NULL,
started_at_utc_ms INTEGER NOT NULL,
ended_at_utc_ms INTEGER NOT NULL,
duration_ms INTEGER NOT NULL,
avg_score REAL NOT NULL,
slice_count INTEGER NOT NULL,
report_json TEXT NOT NULL,
updated_at_utc_ms INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_study_session_reports_ended_at
ON study_session_reports(ended_at_utc_ms DESC);
CREATE TABLE IF NOT EXISTS study_noise_slices (
timeline_id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
start_at_utc_ms INTEGER NOT NULL,
end_at_utc_ms INTEGER NOT NULL,
source_type INTEGER NOT NULL,
session_id TEXT NULL,
score REAL NOT NULL,
avg_db REAL NOT NULL,
p95_db REAL NOT NULL,
p50_dbfs REAL NOT NULL,
over_ratio_dbfs REAL NOT NULL,
segment_count INTEGER NOT NULL,
slice_json TEXT NOT NULL,
created_at_utc_ms INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_study_noise_slices_end_at
ON study_noise_slices(end_at_utc_ms DESC);
CREATE INDEX IF NOT EXISTS idx_study_noise_slices_source_time
ON study_noise_slices(source_type, end_at_utc_ms DESC);
CREATE TABLE IF NOT EXISTS attendance_sessions (
session_id TEXT NOT NULL PRIMARY KEY,
label TEXT NOT NULL,
started_at_utc_ms INTEGER NOT NULL,
ended_at_utc_ms INTEGER NULL,
status TEXT NOT NULL,
score REAL NULL,
payload_json TEXT NULL,
created_at_utc_ms INTEGER NOT NULL,
updated_at_utc_ms INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_attendance_sessions_started_at
ON attendance_sessions(started_at_utc_ms DESC);
CREATE TABLE IF NOT EXISTS attendance_events (
event_id TEXT NOT NULL PRIMARY KEY,
session_id TEXT NOT NULL,
event_type TEXT NOT NULL,
occurred_at_utc_ms INTEGER NOT NULL,
payload_json TEXT NULL,
created_at_utc_ms INTEGER NOT NULL,
FOREIGN KEY(session_id) REFERENCES attendance_sessions(session_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_attendance_events_session_time
ON attendance_events(session_id, occurred_at_utc_ms DESC);
""";
command.ExecuteNonQuery();
_schemaInitialized = true;
}
}
}

View File

@@ -0,0 +1,130 @@
using System;
using System.Collections.Generic;
using LanMountainDesktop.Models;
namespace LanMountainDesktop.Services;
public sealed class AttendanceDataStore
{
private readonly AppDatabaseService _databaseService;
public AttendanceDataStore(AppDatabaseService? databaseService = null)
{
_databaseService = databaseService ?? AppDatabaseServiceFactory.CreateDefault();
}
public IReadOnlyList<AttendanceSessionRecord> LoadSessions(int limit = 200)
{
try
{
using var connection = _databaseService.OpenConnection();
using var command = connection.CreateCommand();
command.CommandText = limit > 0
? """
SELECT session_id, label, started_at_utc_ms, ended_at_utc_ms, status, score, payload_json
FROM attendance_sessions
ORDER BY started_at_utc_ms DESC
LIMIT $limit;
"""
: """
SELECT session_id, label, started_at_utc_ms, ended_at_utc_ms, status, score, payload_json
FROM attendance_sessions
ORDER BY started_at_utc_ms DESC;
""";
if (limit > 0)
{
command.Parameters.AddWithValue("$limit", limit);
}
var sessions = new List<AttendanceSessionRecord>();
using var reader = command.ExecuteReader();
while (reader.Read())
{
var sessionId = reader.IsDBNull(0) ? string.Empty : reader.GetString(0);
if (string.IsNullOrWhiteSpace(sessionId))
{
continue;
}
var label = reader.IsDBNull(1) ? string.Empty : reader.GetString(1);
var startedAt = DateTimeOffset.FromUnixTimeMilliseconds(reader.GetInt64(2));
DateTimeOffset? endedAt = reader.IsDBNull(3)
? null
: DateTimeOffset.FromUnixTimeMilliseconds(reader.GetInt64(3));
var status = reader.IsDBNull(4) ? string.Empty : reader.GetString(4);
double? score = reader.IsDBNull(5) ? null : reader.GetDouble(5);
var payload = reader.IsDBNull(6) ? null : reader.GetString(6);
sessions.Add(new AttendanceSessionRecord(
SessionId: sessionId,
Label: label,
StartedAt: startedAt,
EndedAt: endedAt,
Status: status,
Score: score,
PayloadJson: payload));
}
return sessions;
}
catch
{
return Array.Empty<AttendanceSessionRecord>();
}
}
public void UpsertSession(AttendanceSessionRecord record)
{
try
{
using var connection = _databaseService.OpenConnection();
using var command = connection.CreateCommand();
command.CommandText = """
INSERT INTO attendance_sessions(
session_id,
label,
started_at_utc_ms,
ended_at_utc_ms,
status,
score,
payload_json,
created_at_utc_ms,
updated_at_utc_ms)
VALUES(
$sessionId,
$label,
$startedAtUtcMs,
$endedAtUtcMs,
$status,
$score,
$payloadJson,
$createdAtUtcMs,
$updatedAtUtcMs)
ON CONFLICT(session_id) DO UPDATE SET
label = excluded.label,
started_at_utc_ms = excluded.started_at_utc_ms,
ended_at_utc_ms = excluded.ended_at_utc_ms,
status = excluded.status,
score = excluded.score,
payload_json = excluded.payload_json,
updated_at_utc_ms = excluded.updated_at_utc_ms;
""";
var nowUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
command.Parameters.AddWithValue("$sessionId", record.SessionId);
command.Parameters.AddWithValue("$label", record.Label);
command.Parameters.AddWithValue("$startedAtUtcMs", record.StartedAt.ToUnixTimeMilliseconds());
command.Parameters.AddWithValue("$endedAtUtcMs", record.EndedAt?.ToUnixTimeMilliseconds() ?? (object)DBNull.Value);
command.Parameters.AddWithValue("$status", record.Status);
command.Parameters.AddWithValue("$score", record.Score ?? (object)DBNull.Value);
command.Parameters.AddWithValue("$payloadJson", record.PayloadJson ?? (object)DBNull.Value);
command.Parameters.AddWithValue("$createdAtUtcMs", nowUtcMs);
command.Parameters.AddWithValue("$updatedAtUtcMs", nowUtcMs);
command.ExecuteNonQuery();
}
catch
{
// Keep runtime resilient when persistence is unavailable.
}
}
}

View File

@@ -1,4 +1,5 @@
using System;
using System;
using System.Collections.Generic;
using LanMountainDesktop.Models;
namespace LanMountainDesktop.Services;
@@ -40,9 +41,25 @@ public interface IStudyAnalyticsService : IDisposable
void ClearLastSessionReport();
bool SelectSessionReport(string sessionId);
bool RenameSessionReport(string sessionId, string label);
bool DeleteSessionReport(string sessionId);
IReadOnlyList<NoiseSliceTimelineEntry> QueryNoiseSliceTimeline(
DateTimeOffset? startAt = null,
DateTimeOffset? endAt = null,
int limit = 720,
bool includeRealtimeSlices = true,
bool includeSessionSlices = true);
void ClearNoiseSliceTimeline(DateTimeOffset? olderThan = null);
event EventHandler<StudyAnalyticsSnapshotChangedEventArgs>? SnapshotUpdated;
event EventHandler<NoiseSliceClosedEventArgs>? SliceClosed;
event EventHandler<StudySessionCompletedEventArgs>? SessionCompleted;
}

View File

@@ -358,6 +358,8 @@ internal sealed class SessionAccumulator
public bool IsRunning => _state == StudySessionRuntimeState.Running;
public string? CurrentSessionId => _sessionId;
public bool Start(DateTimeOffset now, StudySessionOptions options)
{
if (IsRunning)

View File

@@ -1,4 +1,6 @@
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using LanMountainDesktop.Models;
@@ -18,7 +20,9 @@ public static class StudyAnalyticsServiceFactory
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;
@@ -31,6 +35,8 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService
private NoiseRealtimePoint? _latestRealtime;
private NoiseSliceSummary? _latestSlice;
private StudySessionReport? _lastSessionReport;
private readonly List<StudySessionReport> _sessionHistory = [];
private string? _selectedSessionReportId;
private string _lastError = string.Empty;
private bool _disposed;
@@ -53,6 +59,9 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService
_streamStatus = NoiseStreamStatus.Error;
_lastError = audioSnapshot.LastError;
}
RestoreSessionHistoryFromDatabaseLocked();
UpdateDataModeLocked();
}
public event EventHandler<StudyAnalyticsSnapshotChangedEventArgs>? SnapshotUpdated;
@@ -172,7 +181,10 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService
if (_sessionAccumulator.IsRunning)
{
finishedReport = _sessionAccumulator.Stop(DateTimeOffset.UtcNow);
_lastSessionReport = finishedReport;
if (finishedReport is not null)
{
UpsertSessionReportLocked(finishedReport, selectReport: true);
}
}
UpdateDataModeLocked();
@@ -214,6 +226,8 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService
}
_lastSessionReport = null;
_selectedSessionReportId = null;
PersistSessionHistoryLocked();
UpdateDataModeLocked();
snapshot = BuildSnapshotLocked(DateTimeOffset.UtcNow);
started = true;
@@ -237,7 +251,7 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService
return false;
}
_lastSessionReport = report;
UpsertSessionReportLocked(report, selectReport: true);
UpdateDataModeLocked();
snapshot = BuildSnapshotLocked(DateTimeOffset.UtcNow);
}
@@ -273,6 +287,8 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService
{
ThrowIfDisposedLocked();
_lastSessionReport = null;
_selectedSessionReportId = null;
PersistSessionHistoryLocked();
UpdateDataModeLocked();
snapshot = BuildSnapshotLocked(DateTimeOffset.UtcNow);
}
@@ -280,6 +296,144 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService
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))
{
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)
{
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)
{
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<NoiseSliceTimelineEntry> 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)
@@ -299,6 +453,8 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService
{
StudyAnalyticsSnapshot? snapshot = null;
NoiseSliceSummary? closedSlice = null;
string? closedSliceSessionId = null;
var closedSliceSourceType = NoiseSliceSourceType.Realtime;
lock (_syncRoot)
{
if (_disposed || _state != StudyAnalyticsRuntimeState.Running)
@@ -350,6 +506,8 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService
if (_sessionAccumulator.IsRunning)
{
_sessionAccumulator.AddSlice(closedSlice);
closedSliceSessionId = _sessionAccumulator.CurrentSessionId;
closedSliceSourceType = NoiseSliceSourceType.Session;
}
}
@@ -359,6 +517,14 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService
}
}
if (closedSlice is not null)
{
_studyDataStore.AppendNoiseSlice(
slice: closedSlice,
sessionId: closedSliceSessionId,
sourceType: closedSliceSourceType);
}
if (snapshot is not null)
{
SnapshotUpdated?.Invoke(this, new StudyAnalyticsSnapshotChangedEventArgs(snapshot));
@@ -425,6 +591,18 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService
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,
@@ -435,6 +613,8 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService
RealtimeBuffer: _pipeline.GetRealtimeBufferSnapshot(),
Session: _sessionAccumulator.GetSnapshot(now),
LastSessionReport: _lastSessionReport,
SelectedSessionReportId: _selectedSessionReportId,
SessionHistory: historyEntries,
LastError: _lastError);
}
@@ -490,4 +670,93 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService
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;
}
}

View File

@@ -0,0 +1,450 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using LanMountainDesktop.Models;
using Microsoft.Data.Sqlite;
namespace LanMountainDesktop.Services;
public sealed class StudyDataStore
{
private const string SelectedSessionReportIdMetaKey = "study.selected_session_report_id";
private const int DefaultNoiseSliceCapacity = 50000;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true
};
private readonly AppDatabaseService _databaseService;
public StudyDataStore(AppDatabaseService? databaseService = null)
{
_databaseService = databaseService ?? AppDatabaseServiceFactory.CreateDefault();
}
public IReadOnlyList<StudySessionReport> LoadSessionReports(int limit = 120)
{
try
{
using var connection = _databaseService.OpenConnection();
using var command = connection.CreateCommand();
command.CommandText = limit > 0
? """
SELECT report_json
FROM study_session_reports
ORDER BY ended_at_utc_ms DESC
LIMIT $limit;
"""
: """
SELECT report_json
FROM study_session_reports
ORDER BY ended_at_utc_ms DESC;
""";
if (limit > 0)
{
command.Parameters.AddWithValue("$limit", limit);
}
var reports = new List<StudySessionReport>();
using var reader = command.ExecuteReader();
while (reader.Read())
{
if (reader.IsDBNull(0))
{
continue;
}
var json = reader.GetString(0);
if (string.IsNullOrWhiteSpace(json))
{
continue;
}
var report = JsonSerializer.Deserialize<StudySessionReport>(json, JsonOptions);
if (report is not null)
{
reports.Add(report);
}
}
return reports;
}
catch
{
return Array.Empty<StudySessionReport>();
}
}
public bool TryGetSessionReport(string sessionId, out StudySessionReport report)
{
report = null!;
if (string.IsNullOrWhiteSpace(sessionId))
{
return false;
}
try
{
using var connection = _databaseService.OpenConnection();
using var command = connection.CreateCommand();
command.CommandText = """
SELECT report_json
FROM study_session_reports
WHERE session_id = $sessionId
LIMIT 1;
""";
command.Parameters.AddWithValue("$sessionId", sessionId.Trim());
var json = command.ExecuteScalar() as string;
if (string.IsNullOrWhiteSpace(json))
{
return false;
}
var parsed = JsonSerializer.Deserialize<StudySessionReport>(json, JsonOptions);
if (parsed is null)
{
return false;
}
report = parsed;
return true;
}
catch
{
return false;
}
}
public void ReplaceSessionReports(IReadOnlyList<StudySessionReport> reports)
{
try
{
using var connection = _databaseService.OpenConnection();
using var transaction = connection.BeginTransaction();
using (var clearCommand = connection.CreateCommand())
{
clearCommand.Transaction = transaction;
clearCommand.CommandText = "DELETE FROM study_session_reports;";
clearCommand.ExecuteNonQuery();
}
for (var i = 0; i < reports.Count; i++)
{
InsertOrUpdateSessionReport(connection, transaction, reports[i]);
}
transaction.Commit();
}
catch
{
// Keep runtime resilient when persistence is unavailable.
}
}
public string? GetSelectedSessionReportId()
{
try
{
using var connection = _databaseService.OpenConnection();
using var command = connection.CreateCommand();
command.CommandText = """
SELECT value
FROM app_meta
WHERE key = $key
LIMIT 1;
""";
command.Parameters.AddWithValue("$key", SelectedSessionReportIdMetaKey);
var value = command.ExecuteScalar() as string;
return string.IsNullOrWhiteSpace(value)
? null
: value.Trim();
}
catch
{
return null;
}
}
public void SetSelectedSessionReportId(string? sessionId)
{
try
{
using var connection = _databaseService.OpenConnection();
if (string.IsNullOrWhiteSpace(sessionId))
{
using var deleteCommand = connection.CreateCommand();
deleteCommand.CommandText = "DELETE FROM app_meta WHERE key = $key;";
deleteCommand.Parameters.AddWithValue("$key", SelectedSessionReportIdMetaKey);
deleteCommand.ExecuteNonQuery();
return;
}
using var upsertCommand = connection.CreateCommand();
upsertCommand.CommandText = """
INSERT INTO app_meta(key, value)
VALUES ($key, $value)
ON CONFLICT(key) DO UPDATE SET value = excluded.value;
""";
upsertCommand.Parameters.AddWithValue("$key", SelectedSessionReportIdMetaKey);
upsertCommand.Parameters.AddWithValue("$value", sessionId.Trim());
upsertCommand.ExecuteNonQuery();
}
catch
{
// Keep runtime resilient when persistence is unavailable.
}
}
public void AppendNoiseSlice(NoiseSliceSummary slice, string? sessionId, NoiseSliceSourceType sourceType)
{
try
{
using var connection = _databaseService.OpenConnection();
using var transaction = connection.BeginTransaction();
var nowUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
var sliceJson = JsonSerializer.Serialize(slice, JsonOptions);
using (var command = connection.CreateCommand())
{
command.Transaction = transaction;
command.CommandText = """
INSERT INTO study_noise_slices(
start_at_utc_ms,
end_at_utc_ms,
source_type,
session_id,
score,
avg_db,
p95_db,
p50_dbfs,
over_ratio_dbfs,
segment_count,
slice_json,
created_at_utc_ms)
VALUES(
$startAtUtcMs,
$endAtUtcMs,
$sourceType,
$sessionId,
$score,
$avgDb,
$p95Db,
$p50Dbfs,
$overRatioDbfs,
$segmentCount,
$sliceJson,
$createdAtUtcMs);
""";
command.Parameters.AddWithValue("$startAtUtcMs", slice.StartAt.ToUnixTimeMilliseconds());
command.Parameters.AddWithValue("$endAtUtcMs", slice.EndAt.ToUnixTimeMilliseconds());
command.Parameters.AddWithValue("$sourceType", (int)sourceType);
command.Parameters.AddWithValue("$sessionId", string.IsNullOrWhiteSpace(sessionId) ? (object)DBNull.Value : sessionId.Trim());
command.Parameters.AddWithValue("$score", slice.Score);
command.Parameters.AddWithValue("$avgDb", slice.Display.AvgDb);
command.Parameters.AddWithValue("$p95Db", slice.Display.P95Db);
command.Parameters.AddWithValue("$p50Dbfs", slice.Raw.P50Dbfs);
command.Parameters.AddWithValue("$overRatioDbfs", slice.Raw.OverRatioDbfs);
command.Parameters.AddWithValue("$segmentCount", Math.Max(0, slice.Raw.SegmentCount));
command.Parameters.AddWithValue("$sliceJson", sliceJson);
command.Parameters.AddWithValue("$createdAtUtcMs", nowUtcMs);
command.ExecuteNonQuery();
}
using (var trimCommand = connection.CreateCommand())
{
trimCommand.Transaction = transaction;
trimCommand.CommandText = """
DELETE FROM study_noise_slices
WHERE timeline_id NOT IN (
SELECT timeline_id
FROM study_noise_slices
ORDER BY end_at_utc_ms DESC
LIMIT $limit
);
""";
trimCommand.Parameters.AddWithValue("$limit", DefaultNoiseSliceCapacity);
trimCommand.ExecuteNonQuery();
}
transaction.Commit();
}
catch
{
// Keep runtime resilient when persistence is unavailable.
}
}
public IReadOnlyList<NoiseSliceTimelineEntry> LoadNoiseSliceTimeline(
DateTimeOffset? startAt = null,
DateTimeOffset? endAt = null,
int limit = 720,
bool includeRealtimeSlices = true,
bool includeSessionSlices = true)
{
if (!includeRealtimeSlices && !includeSessionSlices)
{
return Array.Empty<NoiseSliceTimelineEntry>();
}
try
{
using var connection = _databaseService.OpenConnection();
using var command = connection.CreateCommand();
var whereParts = new List<string>();
if (startAt is not null)
{
whereParts.Add("end_at_utc_ms >= $startAtUtcMs");
command.Parameters.AddWithValue("$startAtUtcMs", startAt.Value.ToUnixTimeMilliseconds());
}
if (endAt is not null)
{
whereParts.Add("start_at_utc_ms <= $endAtUtcMs");
command.Parameters.AddWithValue("$endAtUtcMs", endAt.Value.ToUnixTimeMilliseconds());
}
if (includeRealtimeSlices != includeSessionSlices)
{
var sourceType = includeSessionSlices
? (int)NoiseSliceSourceType.Session
: (int)NoiseSliceSourceType.Realtime;
whereParts.Add("source_type = $sourceType");
command.Parameters.AddWithValue("$sourceType", sourceType);
}
var whereClause = whereParts.Count == 0
? string.Empty
: $"WHERE {string.Join(" AND ", whereParts)}";
var normalizedLimit = Math.Clamp(limit, 1, DefaultNoiseSliceCapacity);
command.CommandText = $"""
SELECT timeline_id, source_type, session_id, slice_json
FROM study_noise_slices
{whereClause}
ORDER BY end_at_utc_ms DESC
LIMIT $limit;
""";
command.Parameters.AddWithValue("$limit", normalizedLimit);
var entries = new List<NoiseSliceTimelineEntry>(Math.Min(normalizedLimit, 2048));
using var reader = command.ExecuteReader();
while (reader.Read())
{
var timelineId = reader.GetInt64(0);
var sourceTypeRaw = reader.IsDBNull(1) ? 0 : reader.GetInt32(1);
var sourceType = sourceTypeRaw == (int)NoiseSliceSourceType.Session
? NoiseSliceSourceType.Session
: NoiseSliceSourceType.Realtime;
var sessionId = reader.IsDBNull(2) ? null : reader.GetString(2);
if (reader.IsDBNull(3))
{
continue;
}
var sliceJson = reader.GetString(3);
if (string.IsNullOrWhiteSpace(sliceJson))
{
continue;
}
var slice = JsonSerializer.Deserialize<NoiseSliceSummary>(sliceJson, JsonOptions);
if (slice is null)
{
continue;
}
entries.Add(new NoiseSliceTimelineEntry(
TimelineId: timelineId,
SourceType: sourceType,
SessionId: sessionId,
Slice: slice));
}
return entries;
}
catch
{
return Array.Empty<NoiseSliceTimelineEntry>();
}
}
public void ClearNoiseSliceTimeline(DateTimeOffset? olderThan = null)
{
try
{
using var connection = _databaseService.OpenConnection();
using var command = connection.CreateCommand();
if (olderThan is null)
{
command.CommandText = "DELETE FROM study_noise_slices;";
}
else
{
command.CommandText = "DELETE FROM study_noise_slices WHERE end_at_utc_ms <= $olderThanUtcMs;";
command.Parameters.AddWithValue("$olderThanUtcMs", olderThan.Value.ToUnixTimeMilliseconds());
}
command.ExecuteNonQuery();
}
catch
{
// Keep runtime resilient when persistence is unavailable.
}
}
private static void InsertOrUpdateSessionReport(
SqliteConnection connection,
SqliteTransaction transaction,
StudySessionReport report)
{
var json = JsonSerializer.Serialize(report, JsonOptions);
var nowUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
using var command = connection.CreateCommand();
command.Transaction = transaction;
command.CommandText = """
INSERT INTO study_session_reports(
session_id,
label,
started_at_utc_ms,
ended_at_utc_ms,
duration_ms,
avg_score,
slice_count,
report_json,
updated_at_utc_ms)
VALUES (
$sessionId,
$label,
$startedAtUtcMs,
$endedAtUtcMs,
$durationMs,
$avgScore,
$sliceCount,
$reportJson,
$updatedAtUtcMs)
ON CONFLICT(session_id) DO UPDATE SET
label = excluded.label,
started_at_utc_ms = excluded.started_at_utc_ms,
ended_at_utc_ms = excluded.ended_at_utc_ms,
duration_ms = excluded.duration_ms,
avg_score = excluded.avg_score,
slice_count = excluded.slice_count,
report_json = excluded.report_json,
updated_at_utc_ms = excluded.updated_at_utc_ms;
""";
command.Parameters.AddWithValue("$sessionId", report.SessionId);
command.Parameters.AddWithValue("$label", report.Label);
command.Parameters.AddWithValue("$startedAtUtcMs", report.StartedAt.ToUnixTimeMilliseconds());
command.Parameters.AddWithValue("$endedAtUtcMs", report.EndedAt.ToUnixTimeMilliseconds());
command.Parameters.AddWithValue("$durationMs", (long)Math.Max(0, report.Duration.TotalMilliseconds));
command.Parameters.AddWithValue("$avgScore", report.Metrics.AvgScore);
command.Parameters.AddWithValue("$sliceCount", report.Metrics.SliceCount);
command.Parameters.AddWithValue("$reportJson", json);
command.Parameters.AddWithValue("$updatedAtUtcMs", nowUtcMs);
command.ExecuteNonQuery();
}
}