diff --git a/LanMountainDesktop.Tests/StudyAnalyticsServiceTests.cs b/LanMountainDesktop.Tests/StudyAnalyticsServiceTests.cs new file mode 100644 index 0000000..1ba235e --- /dev/null +++ b/LanMountainDesktop.Tests/StudyAnalyticsServiceTests.cs @@ -0,0 +1,113 @@ +using System; +using System.Threading; +using LanMountainDesktop.Models; +using LanMountainDesktop.Services; +using Xunit; + +namespace LanMountainDesktop.Tests; + +public sealed class StudyAnalyticsServiceTests +{ + [Fact] + public void SnapshotUpdated_UsesUiPublishThrottle() + { + using var recorder = new FakeAudioRecorderService(); + using var service = new StudyAnalyticsService(recorder); + service.UpdateConfig(new StudyAnalyticsConfig(FrameMs: 20, UiPublishIntervalMs: 120)); + + var updateCount = 0; + service.SnapshotUpdated += (_, _) => Interlocked.Increment(ref updateCount); + + Assert.True(service.StartOrResumeMonitoring()); + Thread.Sleep(280); + Assert.True(service.PauseMonitoring()); + + var totalUpdates = Volatile.Read(ref updateCount); + Assert.InRange(totalUpdates, 2, 6); + } + + [Fact] + public void GetSnapshot_ReusesRealtimeBufferSnapshot_WhenNoNewFramesArrive() + { + using var recorder = new FakeAudioRecorderService(); + using var service = new StudyAnalyticsService(recorder); + service.UpdateConfig(new StudyAnalyticsConfig(FrameMs: 20, UiPublishIntervalMs: 120)); + + using var firstUpdate = new ManualResetEventSlim(false); + service.SnapshotUpdated += (_, args) => + { + if (args.Snapshot.RealtimeBuffer.Count > 0) + { + firstUpdate.Set(); + } + }; + + Assert.True(service.StartOrResumeMonitoring()); + Assert.True(firstUpdate.Wait(TimeSpan.FromSeconds(2))); + Assert.True(service.PauseMonitoring()); + + var firstSnapshot = service.GetSnapshot(); + var secondSnapshot = service.GetSnapshot(); + + Assert.NotEmpty(firstSnapshot.RealtimeBuffer); + Assert.Same(firstSnapshot.RealtimeBuffer, secondSnapshot.RealtimeBuffer); + } + + private sealed class FakeAudioRecorderService : IAudioRecorderService + { + private readonly object _syncRoot = new(); + private AudioRecorderRuntimeState _state = AudioRecorderRuntimeState.Ready; + + public AudioRecorderSnapshot GetSnapshot() + { + lock (_syncRoot) + { + return new AudioRecorderSnapshot( + State: _state, + Duration: TimeSpan.Zero, + InputLevel: _state == AudioRecorderRuntimeState.Recording ? 0.55 : 0, + LastSavedFilePath: string.Empty, + LastError: string.Empty); + } + } + + public bool StartOrResume() + { + lock (_syncRoot) + { + _state = AudioRecorderRuntimeState.Recording; + return true; + } + } + + public bool Pause() + { + lock (_syncRoot) + { + _state = AudioRecorderRuntimeState.Paused; + return true; + } + } + + public string? StopAndSave(string? outputPath = null) + { + lock (_syncRoot) + { + _state = AudioRecorderRuntimeState.Ready; + return outputPath; + } + } + + public void Discard() + { + lock (_syncRoot) + { + _state = AudioRecorderRuntimeState.Ready; + } + } + + public void Dispose() + { + } + } +} diff --git a/LanMountainDesktop/ComponentSystem/ComponentRegistry.cs b/LanMountainDesktop/ComponentSystem/ComponentRegistry.cs index a62fa87..f5708da 100644 --- a/LanMountainDesktop/ComponentSystem/ComponentRegistry.cs +++ b/LanMountainDesktop/ComponentSystem/ComponentRegistry.cs @@ -425,7 +425,7 @@ public sealed class ComponentRegistry BuiltInComponentIds.DesktopShortcut, "快捷方式", "App", - "Launcher", + "File", MinWidthCells: 1, MinHeightCells: 1, AllowStatusBarPlacement: false, diff --git a/LanMountainDesktop/Models/ComponentSettingsSnapshot.cs b/LanMountainDesktop/Models/ComponentSettingsSnapshot.cs index efb1d5b..f2b8292 100644 --- a/LanMountainDesktop/Models/ComponentSettingsSnapshot.cs +++ b/LanMountainDesktop/Models/ComponentSettingsSnapshot.cs @@ -136,9 +136,9 @@ public sealed class ComponentSettingsSnapshot public string ShortcutClickMode { get; set; } = "Double"; /// - /// 是否透明背景 + /// 是否显示背景 /// - public bool ShortcutTransparentBackground { get; set; } = false; + public bool ShortcutShowBackground { get; set; } = true; #endregion diff --git a/LanMountainDesktop/Models/StudyAnalyticsModels.cs b/LanMountainDesktop/Models/StudyAnalyticsModels.cs index b647a7a..163eb54 100644 --- a/LanMountainDesktop/Models/StudyAnalyticsModels.cs +++ b/LanMountainDesktop/Models/StudyAnalyticsModels.cs @@ -37,6 +37,7 @@ public enum StudyDataMode public sealed record StudyAnalyticsConfig( int FrameMs = 50, + int UiPublishIntervalMs = 125, int SliceSec = 30, double ScoreThresholdDbfs = -50, int SegmentMergeGapMs = 500, diff --git a/LanMountainDesktop/Services/StudyAnalyticsInternals.cs b/LanMountainDesktop/Services/StudyAnalyticsInternals.cs index 7c8227e..aea19dd 100644 --- a/LanMountainDesktop/Services/StudyAnalyticsInternals.cs +++ b/LanMountainDesktop/Services/StudyAnalyticsInternals.cs @@ -12,8 +12,13 @@ internal readonly record struct NoisePipelineTickResult( internal sealed class NoiseFramePipeline { private StudyAnalyticsConfig _config; - private readonly Queue _realtimeBuffer = new(); private readonly List _slicePoints = []; + private NoiseRealtimePoint[] _realtimeBuffer; + private IReadOnlyList _realtimeSnapshot = Array.Empty(); + private int _realtimeBufferStart; + private int _realtimeBufferCount; + private int _realtimeBufferVersion; + private int _realtimeSnapshotVersion = -1; private DateTimeOffset _sliceStartAt; private DateTimeOffset _lastFrameAt; @@ -28,18 +33,29 @@ internal sealed class NoiseFramePipeline public NoiseFramePipeline(StudyAnalyticsConfig config) { _config = NormalizeConfig(config); + _realtimeBuffer = new NoiseRealtimePoint[_config.RealtimeBufferCapacity]; } public void UpdateConfig(StudyAnalyticsConfig config) { - _config = NormalizeConfig(config); + var normalized = NormalizeConfig(config); + if (normalized.RealtimeBufferCapacity != _config.RealtimeBufferCapacity) + { + _realtimeBuffer = new NoiseRealtimePoint[normalized.RealtimeBufferCapacity]; + } + + _config = normalized; Reset(); } public void Reset() { - _realtimeBuffer.Clear(); _slicePoints.Clear(); + _realtimeBufferStart = 0; + _realtimeBufferCount = 0; + _realtimeBufferVersion++; + _realtimeSnapshot = Array.Empty(); + _realtimeSnapshotVersion = -1; _sliceStartAt = default; _lastFrameAt = default; _lastOverThresholdAt = default; @@ -52,7 +68,27 @@ internal sealed class NoiseFramePipeline public IReadOnlyList GetRealtimeBufferSnapshot() { - return _realtimeBuffer.ToArray(); + if (_realtimeBufferCount == 0) + { + return Array.Empty(); + } + + if (_realtimeSnapshotVersion == _realtimeBufferVersion) + { + return _realtimeSnapshot; + } + + var snapshot = new NoiseRealtimePoint[_realtimeBufferCount]; + var firstSegmentLength = Math.Min(_realtimeBufferCount, _realtimeBuffer.Length - _realtimeBufferStart); + Array.Copy(_realtimeBuffer, _realtimeBufferStart, snapshot, 0, firstSegmentLength); + if (firstSegmentLength < _realtimeBufferCount) + { + Array.Copy(_realtimeBuffer, 0, snapshot, firstSegmentLength, _realtimeBufferCount - firstSegmentLength); + } + + _realtimeSnapshot = snapshot; + _realtimeSnapshotVersion = _realtimeBufferVersion; + return snapshot; } public NoisePipelineTickResult AddFrame(DateTimeOffset timestamp, double rms, double dbfs, double displayDb, double peak) @@ -114,12 +150,7 @@ internal sealed class NoiseFramePipeline peak, isOverThreshold); _slicePoints.Add(point); - _realtimeBuffer.Enqueue(point); - - while (_realtimeBuffer.Count > _config.RealtimeBufferCapacity) - { - _realtimeBuffer.Dequeue(); - } + AddRealtimePoint(point); var elapsedSeconds = (timestamp - _sliceStartAt).TotalSeconds; if (elapsedSeconds + 1e-6 < _config.SliceSec) @@ -132,6 +163,29 @@ internal sealed class NoiseFramePipeline return new NoisePipelineTickResult(point, slice); } + private void AddRealtimePoint(NoiseRealtimePoint point) + { + if (_realtimeBuffer.Length == 0) + { + _realtimeBuffer = new NoiseRealtimePoint[Math.Max(1, _config.RealtimeBufferCapacity)]; + } + + if (_realtimeBufferCount < _realtimeBuffer.Length) + { + var writeIndex = (_realtimeBufferStart + _realtimeBufferCount) % _realtimeBuffer.Length; + _realtimeBuffer[writeIndex] = point; + _realtimeBufferCount++; + } + else + { + _realtimeBuffer[_realtimeBufferStart] = point; + _realtimeBufferStart = (_realtimeBufferStart + 1) % _realtimeBuffer.Length; + } + + _realtimeBufferVersion++; + _realtimeSnapshotVersion = -1; + } + private NoiseSliceSummary BuildClosedSlice(DateTimeOffset endAt) { var sampledDurationMs = _slicePoints.Count * _config.FrameMs; @@ -247,6 +301,7 @@ internal sealed class NoiseFramePipeline 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); @@ -259,6 +314,7 @@ internal sealed class NoiseFramePipeline return config with { FrameMs = frameMs, + UiPublishIntervalMs = uiPublishIntervalMs, SliceSec = sliceSec, ScoreThresholdDbfs = threshold, SegmentMergeGapMs = mergeGapMs, diff --git a/LanMountainDesktop/Services/StudyAnalyticsService.cs b/LanMountainDesktop/Services/StudyAnalyticsService.cs index 5d4b856..c69653f 100644 --- a/LanMountainDesktop/Services/StudyAnalyticsService.cs +++ b/LanMountainDesktop/Services/StudyAnalyticsService.cs @@ -46,6 +46,7 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService private readonly List _sessionHistory = []; private string? _selectedSessionReportId; private string _lastError = string.Empty; + private DateTimeOffset _lastUiPublishedAt; private bool _disposed; public StudyAnalyticsService(IAudioRecorderService? audioRecorderService = null) @@ -102,6 +103,7 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService ThrowIfDisposedLocked(); _config = NormalizeConfig(config); _pipeline.UpdateConfig(_config); + _lastUiPublishedAt = default; if (_state == StudyAnalyticsRuntimeState.Running) { StartTimerLocked(); @@ -546,7 +548,11 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService _lastError = string.Empty; UpdateDataModeLocked(); - snapshot = BuildSnapshotLocked(now); + if (ShouldPublishRealtimeSnapshotLocked(now, closedSlice is not null)) + { + snapshot = BuildSnapshotLocked(now); + _lastUiPublishedAt = now; + } } } @@ -599,6 +605,7 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService private void StartTimerLocked() { + _lastUiPublishedAt = default; _samplingTimer.Change( dueTime: TimeSpan.Zero, period: TimeSpan.FromMilliseconds(_config.FrameMs)); @@ -673,6 +680,7 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService 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); @@ -685,6 +693,7 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService return config with { FrameMs = frameMs, + UiPublishIntervalMs = uiPublishIntervalMs, SliceSec = sliceSec, ScoreThresholdDbfs = threshold, SegmentMergeGapMs = mergeGapMs, @@ -696,6 +705,16 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService }; } + private bool ShouldPublishRealtimeSnapshotLocked(DateTimeOffset now, bool hasClosedSlice) + { + if (hasClosedSlice || _lastUiPublishedAt == default) + { + return true; + } + + return (now - _lastUiPublishedAt).TotalMilliseconds >= _config.UiPublishIntervalMs; + } + private void ThrowIfDisposedLocked() { if (_disposed) diff --git a/LanMountainDesktop/Styles/GlassModule.axaml b/LanMountainDesktop/Styles/GlassModule.axaml index 7474126..d52e5de 100644 --- a/LanMountainDesktop/Styles/GlassModule.axaml +++ b/LanMountainDesktop/Styles/GlassModule.axaml @@ -222,10 +222,37 @@ - + + + + diff --git a/LanMountainDesktop/ViewModels/ShortcutEditorViewModel.cs b/LanMountainDesktop/ViewModels/ShortcutEditorViewModel.cs new file mode 100644 index 0000000..d09d7c2 --- /dev/null +++ b/LanMountainDesktop/ViewModels/ShortcutEditorViewModel.cs @@ -0,0 +1,87 @@ +using System; +using System.Collections.ObjectModel; +using System.Linq; +using CommunityToolkit.Mvvm.ComponentModel; +using LanMountainDesktop.ComponentSystem; +using LanMountainDesktop.Models; + +namespace LanMountainDesktop.ViewModels; + +public sealed partial class ShortcutEditorViewModel : ViewModelBase +{ + private readonly DesktopComponentEditorContext? _context; + private bool _isInitializing; + + public ShortcutEditorViewModel(DesktopComponentEditorContext? context) + { + _context = context; + + ClickModeOptions = new ObservableCollection + { + new("Double", "双击打开"), + new("Single", "单击打开") + }; + + LoadSettings(); + } + + private void LoadSettings() + { + var snapshot = _context?.ComponentSettingsAccessor.LoadSnapshot() + ?? new ComponentSettingsSnapshot(); + + _isInitializing = true; + + TargetPath = snapshot.ShortcutTargetPath ?? string.Empty; + SelectedClickMode = ClickModeOptions.FirstOrDefault(o => o.Value == snapshot.ShortcutClickMode) + ?? ClickModeOptions[0]; + ShowBackground = snapshot.ShortcutShowBackground; + + _isInitializing = false; + } + + private void SaveSettings() + { + if (_isInitializing || _context == null) return; + + var snapshot = _context.ComponentSettingsAccessor.LoadSnapshot(); + + snapshot.ShortcutTargetPath = string.IsNullOrWhiteSpace(TargetPath) ? null : TargetPath; + snapshot.ShortcutClickMode = SelectedClickMode?.Value ?? "Double"; + snapshot.ShortcutShowBackground = ShowBackground; + + _context.ComponentSettingsAccessor.SaveSnapshot(snapshot); + + _context.HostContext.RequestRefresh(); + } + + [ObservableProperty] private string _descriptionText = "配置此快捷方式组件的目标路径和打开方式。这些设置仅作用于当前组件实例。"; + [ObservableProperty] private string _targetPathLabel = "目标路径"; + [ObservableProperty] private string _targetPathPlaceholder = "未选择目标"; + [ObservableProperty] private string _browseButtonText = "浏览..."; + [ObservableProperty] private string _clearButtonText = "清除"; + [ObservableProperty] private string _clickModeLabel = "打开方式"; + [ObservableProperty] private string _backgroundLabel = "显示背景"; + [ObservableProperty] private string _backgroundDescription = "关闭后组件背景将变为透明。"; + + [ObservableProperty] private string _targetPath = string.Empty; + [ObservableProperty] private SelectionOption? _selectedClickMode; + [ObservableProperty] private bool _showBackground = true; + + public ObservableCollection ClickModeOptions { get; } + + public void SetTargetPath(string? path) + { + TargetPath = path ?? string.Empty; + SaveSettings(); + } + + public void ClearTargetPath() + { + TargetPath = string.Empty; + SaveSettings(); + } + + partial void OnSelectedClickModeChanged(SelectionOption? value) => SaveSettings(); + partial void OnShowBackgroundChanged(bool value) => SaveSettings(); +} diff --git a/LanMountainDesktop/Views/ComponentEditors/ShortcutComponentEditor.axaml b/LanMountainDesktop/Views/ComponentEditors/ShortcutComponentEditor.axaml index d448e99..211fa59 100644 --- a/LanMountainDesktop/Views/ComponentEditors/ShortcutComponentEditor.axaml +++ b/LanMountainDesktop/Views/ComponentEditors/ShortcutComponentEditor.axaml @@ -1,86 +1,66 @@ + xmlns:vm="using:LanMountainDesktop.ViewModels" + x:Class="LanMountainDesktop.Views.ComponentEditors.ShortcutComponentEditor" + x:DataType="vm:ShortcutEditorViewModel"> - - - - - - + + + + - - - - - -