From c3c73fe8feb010113562392cd9ff7c32aac9c854 Mon Sep 17 00:00:00 2001 From: Milkitic Date: Sat, 11 Jul 2026 22:15:37 +0800 Subject: [PATCH 01/19] refactor: add layered shared components alongside legacy project --- KeyAsio.Net.slnx | 6 + .../Abstractions/IApplicationDispatcher.cs | 6 + .../KeyAsio.Application.csproj | 27 + .../Localization/ILanguagePreferenceStore.cs | 8 + .../Localization/LanguageItem.cs | 9 + .../Localization/LanguageManager.cs | 114 +++ .../Localization/LocalizationService.cs | 57 ++ .../Models/ApplicationState.cs | 57 ++ .../Models/SkinDescription.cs | 44 ++ .../Plugins/AudioEngineWrapper.cs | 44 ++ .../Plugins/GameplaySessionAdapter.cs | 91 +++ .../Plugins/PluginContext.cs | 62 ++ .../Plugins/PluginManager.cs | 296 +++++++ .../Plugins/PluginSettingsAdapter.cs | 44 ++ .../Services/RtssMonitorService.cs | 284 +++++++ .../Services/RtssOsdWriter.cs | 244 ++++++ .../Services/SkinManager.cs | 745 ++++++++++++++++++ .../KeyAsio.Application/Utils/OsuLocator.cs | 195 +++++ src/Common/KeyAsio.Common/AsyncLock.cs | 44 ++ .../KeyAsio.Common/AsyncSequentialWorker.cs | 177 +++++ src/Common/KeyAsio.Common/DebugUtils.cs | 157 ++++ src/Common/KeyAsio.Common/EncodeUtils.cs | 57 ++ .../KeyAsio.Common/KeyAsio.Common.csproj | 12 + .../ObservableRangeCollection.cs | 251 ++++++ src/Common/KeyAsio.Common/RentedArray.cs | 37 + src/Common/KeyAsio.Common/RuntimeInfo.cs | 90 +++ .../KeyAsio.Configuration/AppSettings.cs | 202 +++++ .../KeyAsio.Configuration/FodyWeavers.xml | 3 + .../IAppSettingsPersistence.cs | 20 + .../KeyAsio.Configuration.csproj | 27 + .../LegacyAppSettings.cs | 94 +++ .../KeyAsio.Configuration/Models/AppTheme.cs | 13 + .../KeyAsio.Configuration/Models/BindKeys.cs | 85 ++ .../Models/GameClientType.cs | 7 + .../Models/RealtimeOptions.cs | 141 ++++ .../Models/SliderTailPlaybackBehavior.cs | 6 + .../Models/ViewModelBase.cs | 22 + .../Serialization/BindKeysConverter.cs | 28 + .../DescriptionCommentsObjectGraphVisitor.cs | 25 + .../MyYamlConfigurationConverter.cs | 208 +++++ .../KeyAsio.Plugins.Contracts/HandleResult.cs | 28 + .../Host/IGameplaySession.cs | 32 + .../Host/IPluginInteractionService.cs | 26 + .../Host/IPluginSettings.cs | 15 + .../KeyAsio.Plugins.Contracts/IAudioEngine.cs | 32 + .../IGameStateHandler.cs | 37 + .../IMusicManagerPlugin.cs | 25 + .../KeyAsio.Plugins.Contracts/IPlugin.cs | 53 ++ .../IPluginContext.cs | 36 + .../IPluginManager.cs | 47 ++ .../KeyAsio.Plugins.Contracts/ISyncContext.cs | 49 ++ .../KeyAsio.Plugins.Contracts/ISyncPlugin.cs | 34 + .../IUpdateImplementation.cs | 6 + .../IUpdateSupportPlugin.cs | 6 + .../IUserInterfacePlugin.cs | 13 + .../KeyAsio.Plugins.Contracts.csproj | 16 + .../KeyAsio.Plugins.Contracts/Sync/Mods.cs | 40 + .../Sync/OsuMemoryStatus.cs | 25 + .../SyncBeatmapInfo.cs | 10 + .../SyncHitErrors.cs | 6 + .../SyncOsuStatus.cs | 25 + .../SyncStatistics.cs | 12 + .../Updating/UpdateRelease.cs | 14 + .../Abstractions/IGameplayAudioCache.cs | 6 + .../Abstractions/IPlaybackRuntimeState.cs | 10 + .../Abstractions/ISkinResourceProvider.cs | 12 + src/Core/KeyAsio.Sync/AssemblyInfo.cs | 3 + .../KeyAsio.Sync/Events/EventDelegates.cs | 29 + src/Core/KeyAsio.Sync/KeyAsio.Sync.csproj | 31 + src/Core/KeyAsio.Sync/Models/PlaybackInfo.cs | 6 + .../AudioProviders/CatchHitsoundSequencer.cs | 187 +++++ .../AudioProviders/ManiaHitsoundSequencer.cs | 242 ++++++ .../StandardHitsoundSequencer.cs | 271 +++++++ .../AudioProviders/TaikoHitsoundSequencer.cs | 400 ++++++++++ .../KeyAsio.Sync/Runtime/ConfigManager.cs | 55 ++ .../Runtime/DependencyInjectionExtensions.cs | 34 + .../KeyAsio.Sync/Runtime/GameStateMachine.cs | 68 ++ .../Runtime/IHitsoundSequencer.cs | 13 + .../Runtime/Services/BeatmapHitsoundLoader.cs | 105 +++ .../Runtime/Services/GameplayAudioService.cs | 399 ++++++++++ .../Services/GameplaySessionManager.cs | 211 +++++ .../Runtime/Services/SfxPlaybackService.cs | 222 ++++++ .../Runtime/SnareDrumSampleProvider.cs | 318 ++++++++ .../Runtime/States/BrowsingState.cs | 41 + .../KeyAsio.Sync/Runtime/States/IGameState.cs | 16 + .../Runtime/States/NotRunningState.cs | 36 + .../Runtime/States/PlayingState.cs | 226 ++++++ .../Runtime/States/ResultsState.cs | 36 + .../Runtime/SyncContextWrapper.cs | 49 ++ .../KeyAsio.Sync/Runtime/SyncController.cs | 346 ++++++++ .../Runtime/SyncSessionContext.cs | 296 +++++++ .../KeyAsio.Sync/Sources/BeatmapIdentifier.cs | 30 + .../KeyAsio.Sync/Sources/GameSyncSnapshot.cs | 83 ++ .../Sources/GameSyncSourceCoordinator.cs | 161 ++++ .../KeyAsio.Sync/Sources/IGameSyncSource.cs | 19 + .../KeyAsio.Sync/Sources/LazerIpcBridge.cs | 259 ++++++ .../KeyAsio.Sync/Sources/LazerIpcFrame.cs | 137 ++++ .../Sources/LazerIpcGameSyncSource.cs | 251 ++++++ .../KeyAsio.Sync/Sources/MemoryReadObject.cs | 187 +++++ src/Core/KeyAsio.Sync/Sources/MemoryScan.cs | 612 ++++++++++++++ .../KeyAsio.Sync/Sources/MemorySyncBridge.cs | 136 ++++ .../KeyAsio.Sync/Sources/OsuMemoryData.cs | 35 + .../Sources/StableMemoryGameSyncSource.cs | 122 +++ 103 files changed, 10326 insertions(+) create mode 100644 src/Common/KeyAsio.Application/Abstractions/IApplicationDispatcher.cs create mode 100644 src/Common/KeyAsio.Application/KeyAsio.Application.csproj create mode 100644 src/Common/KeyAsio.Application/Localization/ILanguagePreferenceStore.cs create mode 100644 src/Common/KeyAsio.Application/Localization/LanguageItem.cs create mode 100644 src/Common/KeyAsio.Application/Localization/LanguageManager.cs create mode 100644 src/Common/KeyAsio.Application/Localization/LocalizationService.cs create mode 100644 src/Common/KeyAsio.Application/Models/ApplicationState.cs create mode 100644 src/Common/KeyAsio.Application/Models/SkinDescription.cs create mode 100644 src/Common/KeyAsio.Application/Plugins/AudioEngineWrapper.cs create mode 100644 src/Common/KeyAsio.Application/Plugins/GameplaySessionAdapter.cs create mode 100644 src/Common/KeyAsio.Application/Plugins/PluginContext.cs create mode 100644 src/Common/KeyAsio.Application/Plugins/PluginManager.cs create mode 100644 src/Common/KeyAsio.Application/Plugins/PluginSettingsAdapter.cs create mode 100644 src/Common/KeyAsio.Application/Services/RtssMonitorService.cs create mode 100644 src/Common/KeyAsio.Application/Services/RtssOsdWriter.cs create mode 100644 src/Common/KeyAsio.Application/Services/SkinManager.cs create mode 100644 src/Common/KeyAsio.Application/Utils/OsuLocator.cs create mode 100644 src/Common/KeyAsio.Common/AsyncLock.cs create mode 100644 src/Common/KeyAsio.Common/AsyncSequentialWorker.cs create mode 100644 src/Common/KeyAsio.Common/DebugUtils.cs create mode 100644 src/Common/KeyAsio.Common/EncodeUtils.cs create mode 100644 src/Common/KeyAsio.Common/KeyAsio.Common.csproj create mode 100644 src/Common/KeyAsio.Common/ObservableRangeCollection.cs create mode 100644 src/Common/KeyAsio.Common/RentedArray.cs create mode 100644 src/Common/KeyAsio.Common/RuntimeInfo.cs create mode 100644 src/Common/KeyAsio.Configuration/AppSettings.cs create mode 100644 src/Common/KeyAsio.Configuration/FodyWeavers.xml create mode 100644 src/Common/KeyAsio.Configuration/IAppSettingsPersistence.cs create mode 100644 src/Common/KeyAsio.Configuration/KeyAsio.Configuration.csproj create mode 100644 src/Common/KeyAsio.Configuration/LegacyAppSettings.cs create mode 100644 src/Common/KeyAsio.Configuration/Models/AppTheme.cs create mode 100644 src/Common/KeyAsio.Configuration/Models/BindKeys.cs create mode 100644 src/Common/KeyAsio.Configuration/Models/GameClientType.cs create mode 100644 src/Common/KeyAsio.Configuration/Models/RealtimeOptions.cs create mode 100644 src/Common/KeyAsio.Configuration/Models/SliderTailPlaybackBehavior.cs create mode 100644 src/Common/KeyAsio.Configuration/Models/ViewModelBase.cs create mode 100644 src/Common/KeyAsio.Configuration/Serialization/BindKeysConverter.cs create mode 100644 src/Common/KeyAsio.Configuration/Serialization/DescriptionCommentsObjectGraphVisitor.cs create mode 100644 src/Common/KeyAsio.Configuration/Serialization/MyYamlConfigurationConverter.cs create mode 100644 src/Common/KeyAsio.Plugins.Contracts/HandleResult.cs create mode 100644 src/Common/KeyAsio.Plugins.Contracts/Host/IGameplaySession.cs create mode 100644 src/Common/KeyAsio.Plugins.Contracts/Host/IPluginInteractionService.cs create mode 100644 src/Common/KeyAsio.Plugins.Contracts/Host/IPluginSettings.cs create mode 100644 src/Common/KeyAsio.Plugins.Contracts/IAudioEngine.cs create mode 100644 src/Common/KeyAsio.Plugins.Contracts/IGameStateHandler.cs create mode 100644 src/Common/KeyAsio.Plugins.Contracts/IMusicManagerPlugin.cs create mode 100644 src/Common/KeyAsio.Plugins.Contracts/IPlugin.cs create mode 100644 src/Common/KeyAsio.Plugins.Contracts/IPluginContext.cs create mode 100644 src/Common/KeyAsio.Plugins.Contracts/IPluginManager.cs create mode 100644 src/Common/KeyAsio.Plugins.Contracts/ISyncContext.cs create mode 100644 src/Common/KeyAsio.Plugins.Contracts/ISyncPlugin.cs create mode 100644 src/Common/KeyAsio.Plugins.Contracts/IUpdateImplementation.cs create mode 100644 src/Common/KeyAsio.Plugins.Contracts/IUpdateSupportPlugin.cs create mode 100644 src/Common/KeyAsio.Plugins.Contracts/IUserInterfacePlugin.cs create mode 100644 src/Common/KeyAsio.Plugins.Contracts/KeyAsio.Plugins.Contracts.csproj create mode 100644 src/Common/KeyAsio.Plugins.Contracts/Sync/Mods.cs create mode 100644 src/Common/KeyAsio.Plugins.Contracts/Sync/OsuMemoryStatus.cs create mode 100644 src/Common/KeyAsio.Plugins.Contracts/SyncBeatmapInfo.cs create mode 100644 src/Common/KeyAsio.Plugins.Contracts/SyncHitErrors.cs create mode 100644 src/Common/KeyAsio.Plugins.Contracts/SyncOsuStatus.cs create mode 100644 src/Common/KeyAsio.Plugins.Contracts/SyncStatistics.cs create mode 100644 src/Common/KeyAsio.Plugins.Contracts/Updating/UpdateRelease.cs create mode 100644 src/Core/KeyAsio.Sync/Abstractions/IGameplayAudioCache.cs create mode 100644 src/Core/KeyAsio.Sync/Abstractions/IPlaybackRuntimeState.cs create mode 100644 src/Core/KeyAsio.Sync/Abstractions/ISkinResourceProvider.cs create mode 100644 src/Core/KeyAsio.Sync/AssemblyInfo.cs create mode 100644 src/Core/KeyAsio.Sync/Events/EventDelegates.cs create mode 100644 src/Core/KeyAsio.Sync/KeyAsio.Sync.csproj create mode 100644 src/Core/KeyAsio.Sync/Models/PlaybackInfo.cs create mode 100644 src/Core/KeyAsio.Sync/Runtime/AudioProviders/CatchHitsoundSequencer.cs create mode 100644 src/Core/KeyAsio.Sync/Runtime/AudioProviders/ManiaHitsoundSequencer.cs create mode 100644 src/Core/KeyAsio.Sync/Runtime/AudioProviders/StandardHitsoundSequencer.cs create mode 100644 src/Core/KeyAsio.Sync/Runtime/AudioProviders/TaikoHitsoundSequencer.cs create mode 100644 src/Core/KeyAsio.Sync/Runtime/ConfigManager.cs create mode 100644 src/Core/KeyAsio.Sync/Runtime/DependencyInjectionExtensions.cs create mode 100644 src/Core/KeyAsio.Sync/Runtime/GameStateMachine.cs create mode 100644 src/Core/KeyAsio.Sync/Runtime/IHitsoundSequencer.cs create mode 100644 src/Core/KeyAsio.Sync/Runtime/Services/BeatmapHitsoundLoader.cs create mode 100644 src/Core/KeyAsio.Sync/Runtime/Services/GameplayAudioService.cs create mode 100644 src/Core/KeyAsio.Sync/Runtime/Services/GameplaySessionManager.cs create mode 100644 src/Core/KeyAsio.Sync/Runtime/Services/SfxPlaybackService.cs create mode 100644 src/Core/KeyAsio.Sync/Runtime/SnareDrumSampleProvider.cs create mode 100644 src/Core/KeyAsio.Sync/Runtime/States/BrowsingState.cs create mode 100644 src/Core/KeyAsio.Sync/Runtime/States/IGameState.cs create mode 100644 src/Core/KeyAsio.Sync/Runtime/States/NotRunningState.cs create mode 100644 src/Core/KeyAsio.Sync/Runtime/States/PlayingState.cs create mode 100644 src/Core/KeyAsio.Sync/Runtime/States/ResultsState.cs create mode 100644 src/Core/KeyAsio.Sync/Runtime/SyncContextWrapper.cs create mode 100644 src/Core/KeyAsio.Sync/Runtime/SyncController.cs create mode 100644 src/Core/KeyAsio.Sync/Runtime/SyncSessionContext.cs create mode 100644 src/Core/KeyAsio.Sync/Sources/BeatmapIdentifier.cs create mode 100644 src/Core/KeyAsio.Sync/Sources/GameSyncSnapshot.cs create mode 100644 src/Core/KeyAsio.Sync/Sources/GameSyncSourceCoordinator.cs create mode 100644 src/Core/KeyAsio.Sync/Sources/IGameSyncSource.cs create mode 100644 src/Core/KeyAsio.Sync/Sources/LazerIpcBridge.cs create mode 100644 src/Core/KeyAsio.Sync/Sources/LazerIpcFrame.cs create mode 100644 src/Core/KeyAsio.Sync/Sources/LazerIpcGameSyncSource.cs create mode 100644 src/Core/KeyAsio.Sync/Sources/MemoryReadObject.cs create mode 100644 src/Core/KeyAsio.Sync/Sources/MemoryScan.cs create mode 100644 src/Core/KeyAsio.Sync/Sources/MemorySyncBridge.cs create mode 100644 src/Core/KeyAsio.Sync/Sources/OsuMemoryData.cs create mode 100644 src/Core/KeyAsio.Sync/Sources/StableMemoryGameSyncSource.cs diff --git a/KeyAsio.Net.slnx b/KeyAsio.Net.slnx index 57c10d0f..38411932 100644 --- a/KeyAsio.Net.slnx +++ b/KeyAsio.Net.slnx @@ -3,6 +3,7 @@ + @@ -20,9 +21,14 @@ + + + + + diff --git a/src/Common/KeyAsio.Application/Abstractions/IApplicationDispatcher.cs b/src/Common/KeyAsio.Application/Abstractions/IApplicationDispatcher.cs new file mode 100644 index 00000000..b60444d8 --- /dev/null +++ b/src/Common/KeyAsio.Application/Abstractions/IApplicationDispatcher.cs @@ -0,0 +1,6 @@ +namespace KeyAsio.Application.Abstractions; + +public interface IApplicationDispatcher +{ + Task InvokeAsync(Action action); +} diff --git a/src/Common/KeyAsio.Application/KeyAsio.Application.csproj b/src/Common/KeyAsio.Application/KeyAsio.Application.csproj new file mode 100644 index 00000000..aba690a6 --- /dev/null +++ b/src/Common/KeyAsio.Application/KeyAsio.Application.csproj @@ -0,0 +1,27 @@ + + + + net10.0 + enable + enable + AnyCPU;x64 + + + + + + + + + + + + + + + + + + + + diff --git a/src/Common/KeyAsio.Application/Localization/ILanguagePreferenceStore.cs b/src/Common/KeyAsio.Application/Localization/ILanguagePreferenceStore.cs new file mode 100644 index 00000000..eb74e1ab --- /dev/null +++ b/src/Common/KeyAsio.Application/Localization/ILanguagePreferenceStore.cs @@ -0,0 +1,8 @@ +namespace KeyAsio.Application.Localization; + +public interface ILanguagePreferenceStore +{ + string? GetLanguageCode(); + + void SetLanguageCode(string languageCode); +} diff --git a/src/Common/KeyAsio.Application/Localization/LanguageItem.cs b/src/Common/KeyAsio.Application/Localization/LanguageItem.cs new file mode 100644 index 00000000..d14596de --- /dev/null +++ b/src/Common/KeyAsio.Application/Localization/LanguageItem.cs @@ -0,0 +1,9 @@ +namespace KeyAsio.Application.Localization; + +public sealed class LanguageItem +{ + public required string Code { get; init; } + public required string Name { get; init; } + + public override string ToString() => Name; +} diff --git a/src/Common/KeyAsio.Application/Localization/LanguageManager.cs b/src/Common/KeyAsio.Application/Localization/LanguageManager.cs new file mode 100644 index 00000000..063a0b0f --- /dev/null +++ b/src/Common/KeyAsio.Application/Localization/LanguageManager.cs @@ -0,0 +1,114 @@ +using System.Collections.ObjectModel; +using System.Globalization; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace KeyAsio.Application.Localization; + +public partial class LanguageManager : ObservableObject +{ + private const string SystemLanguageCode = "system"; + private readonly ILanguagePreferenceStore _languagePreferenceStore; + private bool _isUpdating; + + public LanguageManager(ILanguagePreferenceStore languagePreferenceStore) + { + _languagePreferenceStore = languagePreferenceStore; + InitializeLanguages(); + } + + public ObservableCollection AvailableLanguages { get; } = []; + + [ObservableProperty] + public partial LanguageItem? SelectedLanguageItem { get; set; } + + [ObservableProperty] + public partial bool IsChinese { get; set; } + + partial void OnSelectedLanguageItemChanged(LanguageItem? value) + { + if (value is null || _isUpdating) + { + return; + } + + _languagePreferenceStore.SetLanguageCode(value.Code); + ApplyLanguage(value.Code); + RefreshAvailableLanguages(value.Code); + } + + private void InitializeLanguages() + { + var persistedCode = _languagePreferenceStore.GetLanguageCode(); + var savedCode = string.IsNullOrWhiteSpace(persistedCode) + ? SystemLanguageCode + : persistedCode; + + ApplyLanguage(savedCode); + + _isUpdating = true; + try + { + PopulateLanguages(); + SelectedLanguageItem = AvailableLanguages.FirstOrDefault(x => + string.Equals(x.Code, savedCode, StringComparison.OrdinalIgnoreCase)) + ?? AvailableLanguages[0]; + } + finally + { + _isUpdating = false; + } + } + + private void RefreshAvailableLanguages(string selectedCode) + { + _isUpdating = true; + try + { + // Deselect before clearing to avoid SelectionModel accessing a stale index. + SelectedLanguageItem = null; + AvailableLanguages.Clear(); + PopulateLanguages(); + SelectedLanguageItem = + AvailableLanguages + .FirstOrDefault(x => string.Equals(x.Code, selectedCode, StringComparison.OrdinalIgnoreCase)) + ?? AvailableLanguages[0]; + } + finally + { + _isUpdating = false; + } + } + + private void PopulateLanguages() + { + AvailableLanguages.Add(new LanguageItem + { + Name = LocalizationService.Instance["Language_SystemDefault"], + Code = SystemLanguageCode + }); + AvailableLanguages.Add(new LanguageItem + { + Name = CultureInfo.GetCultureInfo("zh-CN").NativeName, + Code = "zh-CN" + }); + AvailableLanguages.Add(new LanguageItem + { + Name = CultureInfo.GetCultureInfo("en").NativeName, + Code = "en" + }); + } + + private void ApplyLanguage(string languageCode) + { + var culture = ResolveCulture(languageCode); + LocalizationService.Instance.ApplyCulture(culture); + IsChinese = culture.Name.StartsWith("zh", StringComparison.OrdinalIgnoreCase); + } + + private static CultureInfo ResolveCulture(string languageCode) + { + return string.Equals(languageCode, SystemLanguageCode, StringComparison.OrdinalIgnoreCase) + ? CultureInfo.InstalledUICulture + : new CultureInfo(languageCode); + } +} diff --git a/src/Common/KeyAsio.Application/Localization/LocalizationService.cs b/src/Common/KeyAsio.Application/Localization/LocalizationService.cs new file mode 100644 index 00000000..579dd390 --- /dev/null +++ b/src/Common/KeyAsio.Application/Localization/LocalizationService.cs @@ -0,0 +1,57 @@ +using System.ComponentModel; +using System.Globalization; + +namespace KeyAsio.Application.Localization; + +public sealed class LocalizationService : INotifyPropertyChanged +{ + private Action _cultureApplier = static _ => { }; + private Func _stringResolver = static key => key; + private long _version; + + public static LocalizationService Instance { get; } = new(); + + public string this[string key] + { + get + { + var resolver = _stringResolver; + return resolver(key); + } + } + + /// + /// Monotonically increasing version counter; incremented on every language change. + /// Used by bindings to trigger re-evaluation. + /// + public long Version => _version; + + public event PropertyChangedEventHandler? PropertyChanged; + + public void ConfigureStringResolver(Func resolver) + { + _stringResolver = resolver ?? throw new ArgumentNullException(nameof(resolver)); + } + + public void ConfigureCultureApplier(Action cultureApplier) + { + _cultureApplier = cultureApplier ?? throw new ArgumentNullException(nameof(cultureApplier)); + } + + public void ApplyCulture(CultureInfo culture) + { + CultureInfo.CurrentCulture = culture; + CultureInfo.CurrentUICulture = culture; + CultureInfo.DefaultThreadCurrentCulture = culture; + CultureInfo.DefaultThreadCurrentUICulture = culture; + _cultureApplier(culture); + NotifyLanguageChanged(); + } + + public void NotifyLanguageChanged() + { + Interlocked.Increment(ref _version); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Version))); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs("Item[]")); + } +} diff --git a/src/Common/KeyAsio.Application/Models/ApplicationState.cs b/src/Common/KeyAsio.Application/Models/ApplicationState.cs new file mode 100644 index 00000000..b8b7de3c --- /dev/null +++ b/src/Common/KeyAsio.Application/Models/ApplicationState.cs @@ -0,0 +1,57 @@ +using KeyAsio.Core.Audio; +using KeyAsio.Common; +using KeyAsio.Configuration; +using KeyAsio.Configuration.Models; +using KeyAsio.Sync.Abstractions; + +namespace KeyAsio.Application.Models; + +public class ApplicationState : ViewModelBase, IPlaybackRuntimeState +{ + private DeviceDescription? _deviceDescription; + private int _framesPerBuffer; + private int _playbackLatency; + private SkinDescription? _selectedSkin; + + public ApplicationState(AppSettings appSettings) + { + AppSettings = appSettings; + } + public ObservableRangeCollection Skins { get; } = [SkinDescription.Internal]; + + public SkinDescription? SelectedSkin + { + get => _selectedSkin; + set + { + if (!SetField(ref _selectedSkin, value)) return; + SelectedSkinChanged?.Invoke(); + } + } + + public string SelectedSkinFolder => SelectedSkin?.Folder ?? string.Empty; + + public event Action? SelectedSkinChanged; + + public DeviceDescription? DeviceDescription + { + get => _deviceDescription; + set => SetField(ref _deviceDescription, value); + } + + public int FramesPerBuffer + { + get => _framesPerBuffer; + set => SetField(ref _framesPerBuffer, value); + } + + public int PlaybackLatency + { + get => _playbackLatency; + set => SetField(ref _playbackLatency, value); + } + public bool AutoMode { get; set; } + + public string DefaultFolder { get; } = Path.Combine(Environment.CurrentDirectory, "resources", "default"); + public AppSettings AppSettings { get; } +} diff --git a/src/Common/KeyAsio.Application/Models/SkinDescription.cs b/src/Common/KeyAsio.Application/Models/SkinDescription.cs new file mode 100644 index 00000000..0bc54586 --- /dev/null +++ b/src/Common/KeyAsio.Application/Models/SkinDescription.cs @@ -0,0 +1,44 @@ +namespace KeyAsio.Application.Models; + +public record SkinDescription(string FolderName, string Folder, string? Name, string? Author) +{ + public string Description + { + get + { + if (FolderName == "{classic}") return "classic"; + if (FolderName == "{internal}") return "ProMix™ Snare"; + + if (Name == null) return FolderName; + if (FolderName == Name) return FolderName; + + return $"{FolderName} ({Name})"; + } + } + + public string? CopyRight + { + get + { + if (FolderName == "{classic}") + { + return "Original copyright © ppy Pty Ltd."; + } + + if (FolderName == "{internal}") + { + return "Copyright © KeyASIO Team"; + } + + if (Author == null) + { + return "Unknown author"; + } + + return $"Skin made by {Author}"; + } + } + + public static SkinDescription Internal { get; } = new("{internal}", "{internal}", null, null); + public static SkinDescription Classic { get; } = new("{classic}", "{classic}", null, null); +} \ No newline at end of file diff --git a/src/Common/KeyAsio.Application/Plugins/AudioEngineWrapper.cs b/src/Common/KeyAsio.Application/Plugins/AudioEngineWrapper.cs new file mode 100644 index 00000000..149d460d --- /dev/null +++ b/src/Common/KeyAsio.Application/Plugins/AudioEngineWrapper.cs @@ -0,0 +1,44 @@ +using KeyAsio.Core.Audio; +using KeyAsio.Plugins.Contracts; +using NAudio.Wave; +using HostAudioFileReader = KeyAsio.Core.Audio.Wave.AudioFileReader; + +namespace KeyAsio.Application.Plugins; + +public sealed class AudioEngineWrapper : IAudioEngine +{ + private readonly IPlaybackEngine _engine; + + public AudioEngineWrapper(IPlaybackEngine engine) + { + _engine = engine; + } + + public WaveFormat OutputWaveFormat => _engine.EngineWaveFormat; + + public IPluginAudioFile OpenAudioFile(string path) => new PluginAudioFile(path); + + public void AddMusicInput(ISampleProvider input) => _engine.MusicMixer.AddMixerInput(input); + + public void RemoveMusicInput(ISampleProvider input) => _engine.MusicMixer.RemoveMixerInput(input); + + private sealed class PluginAudioFile : IPluginAudioFile + { + private readonly HostAudioFileReader _reader; + + public PluginAudioFile(string path) + { + _reader = new HostAudioFileReader(path); + } + + public WaveFormat WaveFormat => _reader.WaveFormat; + public TimeSpan Position { get => _reader.CurrentTime; set => _reader.CurrentTime = value; } + public TimeSpan Duration => _reader.TotalTime; + + public int Read(float[] buffer, int offset, int count) => _reader.Read(buffer, offset, count); + + public void Dispose() => _reader.Dispose(); + + public ValueTask DisposeAsync() => _reader.DisposeAsync(); + } +} diff --git a/src/Common/KeyAsio.Application/Plugins/GameplaySessionAdapter.cs b/src/Common/KeyAsio.Application/Plugins/GameplaySessionAdapter.cs new file mode 100644 index 00000000..80c4f492 --- /dev/null +++ b/src/Common/KeyAsio.Application/Plugins/GameplaySessionAdapter.cs @@ -0,0 +1,91 @@ +using System.Diagnostics.CodeAnalysis; +using Coosu.Beatmap; +using KeyAsio.Core.Audio.Caching; +using KeyAsio.Core.Audio.SampleProviders; +using KeyAsio.Plugins.Contracts; +using KeyAsio.Sync; +using KeyAsio.Sync.Services; + +namespace KeyAsio.Application.Plugins; + +public sealed class GameplaySessionAdapter : IGameplaySession, IDisposable +{ + private readonly GameplaySessionManager _session; + private readonly SyncSessionContext _syncContext; + private readonly AudioCacheManager _audioCache; + + public GameplaySessionAdapter( + GameplaySessionManager session, + SyncSessionContext syncContext, + AudioCacheManager audioCache) + { + _session = session; + _syncContext = syncContext; + _audioCache = audioCache; + _session.SessionStopped += OnSessionStopped; + } + + public OsuFile? Beatmap => _session.OsuFile; + public string? BeatmapFolder => _session.BeatmapFolder; + public string? AudioFilename => _session.AudioFilename; + public event Action? SessionStopped; + + public bool TryResolveResource(string name, out PluginGameplayResource resource) + { + resource = null!; + if (_syncContext.BeatmapResourceCatalog?.TryResolve(name, out var resolved) != true) + { + return false; + } + + resource = new PluginGameplayResource(resolved.Name, resolved.Path); + return true; + } + + public bool TryResolveAudioResource(string fileNameOrNameWithoutExtension, out PluginGameplayResource resource) + { + resource = null!; + if (_syncContext.BeatmapResourceCatalog?.TryResolveAudio(fileNameOrNameWithoutExtension, out var resolved) != true) + { + return false; + } + + resource = new PluginGameplayResource(resolved.Name, resolved.Path); + return true; + } + + public bool TryCreateCachedAudioProvider( + string path, + [NotNullWhen(true)] out ISeekableAudioSampleProvider? sampleProvider) + { + sampleProvider = null; + if (!_audioCache.TryGet(path, out var cachedAudio)) + { + return false; + } + + sampleProvider = new CachedPluginAudioProvider(path, cachedAudio); + return true; + } + + public void Dispose() => _session.SessionStopped -= OnSessionStopped; + + private void OnSessionStopped() => SessionStopped?.Invoke(); + + private sealed class CachedPluginAudioProvider : ISeekableAudioSampleProvider + { + private readonly CachedAudioProvider _provider; + + public CachedPluginAudioProvider(string path, CachedAudio cachedAudio) + { + ResourceId = cachedAudio.SourceHash ?? Path.GetFullPath(path); + _provider = new CachedAudioProvider(cachedAudio) { ExcludeFromPool = true }; + } + + public string ResourceId { get; } + public NAudio.Wave.WaveFormat WaveFormat => _provider.WaveFormat; + public TimeSpan Position { get => _provider.PlayTime; set => _provider.PlayTime = value; } + + public int Read(float[] buffer, int offset, int count) => _provider.Read(buffer, offset, count); + } +} diff --git a/src/Common/KeyAsio.Application/Plugins/PluginContext.cs b/src/Common/KeyAsio.Application/Plugins/PluginContext.cs new file mode 100644 index 00000000..7a7688d4 --- /dev/null +++ b/src/Common/KeyAsio.Application/Plugins/PluginContext.cs @@ -0,0 +1,62 @@ +using KeyAsio.Plugins.Contracts; +using Microsoft.Extensions.Logging; + +namespace KeyAsio.Application.Plugins; + +public class PluginContext : IPluginContext +{ + public PluginContext( + string pluginDirectory, + ILoggerFactory loggerFactory, + IAudioEngine audioEngine, + IPluginSettings settings, + IGameplaySession gameplay, + IPluginInteractionService interaction) + { + PluginDirectory = pluginDirectory; + LoggerFactory = loggerFactory; + AudioEngine = audioEngine; + Settings = settings; + Gameplay = gameplay; + Interaction = interaction; + } + + public string PluginDirectory { get; } + public ILoggerFactory LoggerFactory { get; } + public IAudioEngine AudioEngine { get; } + public IPluginSettings Settings { get; } + public IGameplaySession Gameplay { get; } + public IPluginInteractionService Interaction { get; } + + private readonly Dictionary> _stateHandlers = new(); + + public void RegisterStateHandler(SyncOsuStatus status, IGameStateHandler handler) + { + if (!_stateHandlers.TryGetValue(status, out var list)) + { + list = new List(); + _stateHandlers[status] = list; + } + + // Avoid duplicate registration of same instance + if (!list.Contains(handler)) + { + list.Add(handler); + } + } + + public void UnregisterStateHandler(SyncOsuStatus status) + { + _stateHandlers.Remove(status); + } + + internal IReadOnlyList GetHandlers(SyncOsuStatus status) + { + if (_stateHandlers.TryGetValue(status, out var list)) + { + return list; + } + + return Array.Empty(); + } +} diff --git a/src/Common/KeyAsio.Application/Plugins/PluginManager.cs b/src/Common/KeyAsio.Application/Plugins/PluginManager.cs new file mode 100644 index 00000000..e8cd41f9 --- /dev/null +++ b/src/Common/KeyAsio.Application/Plugins/PluginManager.cs @@ -0,0 +1,296 @@ +using System.Reflection; +using System.Runtime.Loader; +using KeyAsio.Plugins.Contracts; +using Microsoft.Extensions.Logging; + +namespace KeyAsio.Application.Plugins; + +public class PluginManager : IPluginManager, IDisposable +{ + private readonly ILogger _logger; + private readonly ILoggerFactory _loggerFactory; + private readonly IAudioEngine _audioEngine; + private readonly IPluginSettings _settings; + private readonly IGameplaySession _gameplay; + private readonly IPluginInteractionService _interaction; + private readonly List _plugins = new(); + private readonly HashSet _loadContexts = []; + + public PluginManager( + ILogger logger, + ILoggerFactory loggerFactory, + IAudioEngine audioEngine, + IPluginSettings settings, + IGameplaySession gameplay, + IPluginInteractionService interaction) + { + _logger = logger; + _loggerFactory = loggerFactory; + _audioEngine = audioEngine; + _settings = settings; + _gameplay = gameplay; + _interaction = interaction; + } + + public IEnumerable GetAllPlugins() + { + return _plugins.Where(p => p.IsInitialized).Select(p => p.Instance); + } + + public T? GetPlugin() where T : class, IPlugin + { + return _plugins.Where(p => p.IsInitialized).Select(p => p.Instance).OfType().FirstOrDefault(); + } + + public IEnumerable GetActiveHandlers(SyncOsuStatus status) + { + var handlers = new List(); + foreach (var wrapper in _plugins) + { + if (!wrapper.IsInitialized) + { + continue; + } + + if (wrapper.Context is PluginContext ctx) + { + handlers.AddRange(ctx.GetHandlers(status)); + } + } + + // Sort by Priority Descending (Higher priority first) + handlers.Sort((a, b) => b.Priority.CompareTo(a.Priority)); + return handlers; + } + + public void LoadPlugins(string pluginDirectory, string searchPattern = "*.dll", + SearchOption searchOption = SearchOption.AllDirectories) + { + if (!Directory.Exists(pluginDirectory)) + { + if (pluginDirectory == AppDomain.CurrentDomain.BaseDirectory) + { + // The root directory must exist, this is a safety check. + return; + } + + try + { + Directory.CreateDirectory(pluginDirectory); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to create plugin directory: {PluginDirectory}", pluginDirectory); + return; + } + } + + var dllFiles = Directory.GetFiles(pluginDirectory, searchPattern, searchOption); + foreach (var dllPath in dllFiles) + { + // The contract assembly is host-owned, not a plugin entry point. + if (Path.GetFileName(dllPath) + .Equals("KeyAsio.Plugins.Contracts.dll", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + LoadPlugin(dllPath); + } + } + + private bool ValidatePluginAssembly(Assembly assembly) + { + // 1. Compatibility Check + var abstractionsRef = assembly.GetReferencedAssemblies() + .FirstOrDefault(a => a.Name == "KeyAsio.Plugins.Contracts"); + + if (abstractionsRef != null) + { + var currentVersion = typeof(IPlugin).Assembly.GetName().Version; + // Assuming major version change breaks compatibility + if (abstractionsRef.Version != null && currentVersion != null && + abstractionsRef.Version.Major != currentVersion.Major) + { + _logger.LogWarning("Plugin {PluginAssembly} references an incompatible version of Abstractions: {RefVersion} (Current: {CurrentVersion})", + assembly.GetName().Name, abstractionsRef.Version, currentVersion); + return false; + } + } + + return true; + } + + private void LoadPlugin(string dllPath) + { + try + { + var loadContext = new PluginLoadContext(dllPath); + var assembly = loadContext.LoadFromAssemblyPath(dllPath); + + if (!ValidatePluginAssembly(assembly)) + { + loadContext.Unload(); + return; + } + + var pluginTypes = assembly.GetTypes() + .Where(t => typeof(IPlugin).IsAssignableFrom(t) && t is { IsInterface: false, IsAbstract: false }); + + foreach (var type in pluginTypes) + { + if (Activator.CreateInstance(type) is IPlugin plugin) + { + var wrapper = new PluginWrapper(plugin, loadContext, + Path.GetDirectoryName(dllPath) ?? string.Empty); + _plugins.Add(wrapper); + _loadContexts.Add(loadContext); + _logger.LogInformation("Loaded plugin: {PluginName} ({PluginId})", plugin.Name, plugin.Id); + } + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to load plugin from {DllPath}", dllPath); + } + } + + public void InitializePlugins() + { + foreach (var wrapper in _plugins) + { + try + { + var context = new PluginContext( + wrapper.PluginDirectory, + _loggerFactory, + _audioEngine, + _settings, + _gameplay, + _interaction); + wrapper.Context = context; + wrapper.Instance.Initialize(context); + wrapper.IsInitialized = true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error initializing plugin {PluginName}", wrapper.Instance.Name); + } + } + } + + public void StartupPlugins() + { + foreach (var wrapper in _plugins) + { + if (!wrapper.IsInitialized) + { + continue; + } + + try + { + wrapper.Instance.Startup(); + wrapper.IsStarted = true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error starting plugin {PluginName}", wrapper.Instance.Name); + } + } + } + + public void UnloadPlugins() + { + foreach (var wrapper in _plugins) + { + if (wrapper.IsStarted) + { + try + { + wrapper.Instance.Shutdown(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error shutting down plugin {PluginName}", wrapper.Instance.Name); + } + } + + try + { + wrapper.Instance.Unload(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error unloading plugin {PluginName}", wrapper.Instance.Name); + } + } + + _plugins.Clear(); + foreach (var context in _loadContexts) + { + try + { + context.Unload(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error unloading AssemblyLoadContext"); + } + } + + _loadContexts.Clear(); + } + + public void Dispose() + { + UnloadPlugins(); + } + + private class PluginWrapper + { + public IPlugin Instance { get; } + public PluginLoadContext LoadContext { get; } + public string PluginDirectory { get; } + public PluginContext? Context { get; set; } + public bool IsInitialized { get; set; } + public bool IsStarted { get; set; } + + public PluginWrapper(IPlugin instance, PluginLoadContext loadContext, string pluginDirectory) + { + Instance = instance; + LoadContext = loadContext; + PluginDirectory = pluginDirectory; + } + } + + private sealed class PluginLoadContext : AssemblyLoadContext + { + private readonly AssemblyDependencyResolver _resolver; + + public PluginLoadContext(string pluginPath) + : base(Path.GetFileNameWithoutExtension(pluginPath), isCollectible: true) + { + _resolver = new AssemblyDependencyResolver(pluginPath); + } + + protected override Assembly? Load(AssemblyName assemblyName) + { + var sharedAssembly = Default.Assemblies.FirstOrDefault( + assembly => AssemblyName.ReferenceMatchesDefinition(assembly.GetName(), assemblyName)); + if (sharedAssembly is not null) + { + return sharedAssembly; + } + + var path = _resolver.ResolveAssemblyToPath(assemblyName); + return path is null ? null : LoadFromAssemblyPath(path); + } + + protected override nint LoadUnmanagedDll(string unmanagedDllName) + { + var path = _resolver.ResolveUnmanagedDllToPath(unmanagedDllName); + return path is null ? 0 : LoadUnmanagedDllFromPath(path); + } + } +} diff --git a/src/Common/KeyAsio.Application/Plugins/PluginSettingsAdapter.cs b/src/Common/KeyAsio.Application/Plugins/PluginSettingsAdapter.cs new file mode 100644 index 00000000..676d6262 --- /dev/null +++ b/src/Common/KeyAsio.Application/Plugins/PluginSettingsAdapter.cs @@ -0,0 +1,44 @@ +using System.ComponentModel; +using KeyAsio.Configuration; +using KeyAsio.Plugins.Contracts; + +namespace KeyAsio.Application.Plugins; + +public sealed class PluginSettingsAdapter : IPluginSettings, IDisposable +{ + private readonly AppSettings _settings; + private readonly IAppSettingsPersistence _persistence; + + public PluginSettingsAdapter(AppSettings settings, IAppSettingsPersistence persistence) + { + _settings = settings; + _persistence = persistence; + _settings.Sync.PropertyChanged += OnSyncSettingsChanged; + } + + public bool MusicMixingEnabled + { + get => _settings.Sync.EnableMixSync; + set => _settings.Sync.EnableMixSync = value; + } + + public string? SkippedUpdateVersion + { + get => _settings.Update.SkipVersion; + set => _settings.Update.SkipVersion = value; + } + + public event EventHandler? MusicMixingEnabledChanged; + + public void Save() => _persistence.Save(); + + public void Dispose() => _settings.Sync.PropertyChanged -= OnSyncSettingsChanged; + + private void OnSyncSettingsChanged(object? sender, PropertyChangedEventArgs eventArgs) + { + if (eventArgs.PropertyName == nameof(AppSettingsSync.EnableMixSync)) + { + MusicMixingEnabledChanged?.Invoke(this, EventArgs.Empty); + } + } +} diff --git a/src/Common/KeyAsio.Application/Services/RtssMonitorService.cs b/src/Common/KeyAsio.Application/Services/RtssMonitorService.cs new file mode 100644 index 00000000..e766559f --- /dev/null +++ b/src/Common/KeyAsio.Application/Services/RtssMonitorService.cs @@ -0,0 +1,284 @@ +using KeyAsio.Configuration; +using System.ComponentModel; +using System.Diagnostics; +using System.Text; +using KeyAsio.Plugins.Contracts; +using KeyAsio.Sync; +using Microsoft.Extensions.Logging; + +namespace KeyAsio.Application.Services; + +public sealed class RtssMonitorService : IDisposable +{ + // RTSS hypertext color tags: we only colorize keys for quick visual scan. + private const string CriticalKeyColorTag = ""; + private const string ResetColorTag = ""; + private const string BoolTrueColorTag = ""; + private const string BoolFalseColorTag = ""; + private const string SeparatorColorTag = ""; + + private readonly AppSettings _appSettings; + private readonly SyncSessionContext _syncSessionContext; + private readonly ILogger _logger; + + private RtssOsdWriter? _osdWriter; + private Task? _updateTask; + private CancellationTokenSource? _cts; + private bool _disposed; + private long _nextFailureLogTimeMs; + private readonly Queue _hitErrorWindow = new(); + private int _hitErrorWindowSum; + private int _hitErrorWindowAbsSum; + private int? _lastHitError; + private const int HitErrorWindowSize = 64; + + public RtssMonitorService( + AppSettings appSettings, + SyncSessionContext syncSessionContext, + ILogger logger) + { + _appSettings = appSettings; + _syncSessionContext = syncSessionContext; + _logger = logger; + + _appSettings.Sync.PropertyChanged += OnSyncSettingsChanged; + + if (_appSettings.Sync.EnableRtssMonitoring) + { + StartMonitoring(); + } + } + + private void OnSyncSettingsChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(AppSettingsSync.EnableRtssMonitoring)) + { + if (_appSettings.Sync.EnableRtssMonitoring) + { + _logger.LogInformation("RTSS monitoring enabled."); + StartMonitoring(); + } + else + { + _logger.LogInformation("RTSS monitoring disabled."); + StopMonitoring(); + } + } + } + + private void StartMonitoring() + { + if (_updateTask != null) return; + + try + { + _osdWriter?.Dispose(); + _osdWriter = new RtssOsdWriter("KeyASIO"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to initialize RTSS OSD writer."); + return; + } + + _cts = new CancellationTokenSource(); + _updateTask = Task.Factory.StartNew( + (Action)UpdateLoop, + _cts.Token, + _cts.Token, + TaskCreationOptions.LongRunning | TaskCreationOptions.DenyChildAttach, + TaskScheduler.Default); + } + + private void StopMonitoring() + { + if (_updateTask == null) return; + + _cts?.Cancel(); + try + { + _updateTask.Wait(TimeSpan.FromSeconds(2)); + } + catch + { + // Ignored + } + + _cts?.Dispose(); + _cts = null; + _updateTask = null; + + _osdWriter?.Dispose(); + _osdWriter = null; + } + + private void UpdateLoop(object? state) + { + var token = (CancellationToken)state!; + var sb = new StringBuilder(1024); + var stopwatch = Stopwatch.StartNew(); + const int targetIntervalMs = 10; // 100 fps + + while (!token.IsCancellationRequested) + { + var frameStart = stopwatch.ElapsedMilliseconds; + + try + { + sb.Clear(); + AppendField(sb, "File", _syncSessionContext.Beatmap.Filename ?? "(null)"); + AppendLineEnd(sb); + + AppendField(sb, "Folder", _syncSessionContext.Beatmap.Folder ?? "(null)"); + AppendLineEnd(sb); + + AppendField(sb, "PID", _syncSessionContext.ProcessId); + AppendSeparator(sb); + AppendField(sb, "Client", _syncSessionContext.ClientType); + AppendLineEnd(sb); + + AppendField(sb, "User", _syncSessionContext.Username ?? "(null)"); + AppendSeparator(sb); + AppendField(sb, "Status", _syncSessionContext.OsuStatus); + AppendLineEnd(sb); + + AppendField(sb, "IsStarted", _syncSessionContext.IsStarted); + AppendSeparator(sb); + AppendField(sb, "IsReplay", _syncSessionContext.IsReplay); + AppendLineEnd(sb); + + AppendCriticalField(sb, "Time", _syncSessionContext.PlayTime); + AppendSeparator(sb); + AppendCriticalField(sb, "RawTime", _syncSessionContext.BaseMemoryTime); + AppendLineEnd(sb); + + AppendCriticalField(sb, "Mods", _syncSessionContext.PlayMods); + AppendSeparator(sb); + AppendCriticalField(sb, "Combo", _syncSessionContext.Combo); + AppendSeparator(sb); + AppendCriticalField(sb, "Score", _syncSessionContext.Score); + AppendLineEnd(sb); + + var stats = _syncSessionContext.Statistics; + AppendCriticalField(sb, "Stats", + $"300:{stats.Great} 100:{stats.Ok} 50:{stats.Meh} miss:{stats.Miss} geki:{stats.Perfect} katu:{stats.Good}"); + AppendLineEnd(sb); + + var hitErrors = _syncSessionContext.HitErrors; + UpdateHitErrorWindow(hitErrors); + AppendCriticalField(sb, "HitErr", BuildHitErrorSummary(hitErrors)); + AppendLineEnd(sb); + + AppendField(sb, "Update", _syncSessionContext.LastUpdateTimestamp); + AppendLineEnd(sb); + + var text = sb.ToString(); + _osdWriter?.Update(text); + } + catch (Exception ex) + { + var now = Environment.TickCount64; + if (now >= _nextFailureLogTimeMs) + { + _nextFailureLogTimeMs = now + 5000; + _logger.LogWarning(ex, "Failed to update RTSS OSD."); + } + } + + var elapsed = stopwatch.ElapsedMilliseconds - frameStart; + var delay = targetIntervalMs - (int)elapsed; + if (delay > 0) + { + Thread.Sleep(delay); + } + } + } + + private static void AppendField(StringBuilder sb, string key, T value) + { + sb.Append(key); + sb.Append(": "); + AppendValue(sb, value); + } + + private static void AppendCriticalField(StringBuilder sb, string key, T value) + { + sb.Append(CriticalKeyColorTag) + .Append(key) + .Append(ResetColorTag) + .Append(": "); + AppendValue(sb, value); + } + + private static void AppendSeparator(StringBuilder sb) + { + sb.Append(SeparatorColorTag) + .Append(" \t") + .Append(ResetColorTag); + } + + private static void AppendLineEnd(StringBuilder sb) + { + sb.Append('\n'); + } + + private static void AppendValue(StringBuilder sb, T value) + { + if (value is bool boolValue) + { + sb.Append(boolValue ? BoolTrueColorTag : BoolFalseColorTag) + .Append(boolValue ? "true" : "false") + .Append(ResetColorTag); + return; + } + + sb.Append(value?.ToString()); + } + + private void UpdateHitErrorWindow(SyncHitErrors hitErrors) + { + var values = hitErrors.Values; + if (values == null || values.Length == 0) return; + + for (int i = 0; i < values.Length; i++) + { + var error = values[i]; + _hitErrorWindow.Enqueue(error); + _hitErrorWindowSum += error; + _hitErrorWindowAbsSum += Math.Abs(error); + _lastHitError = error; + + while (_hitErrorWindow.Count > HitErrorWindowSize) + { + var removed = _hitErrorWindow.Dequeue(); + _hitErrorWindowSum -= removed; + _hitErrorWindowAbsSum -= Math.Abs(removed); + } + } + } + + private string BuildHitErrorSummary(SyncHitErrors hitErrors) + { + int delta = hitErrors.Values?.Length ?? 0; + int count = _hitErrorWindow.Count; + string lastText = _lastHitError.HasValue ? $"{FormatSigned(_lastHitError.Value)}ms" : "--"; + string avgText = count > 0 ? $"{(double)_hitErrorWindowSum / count:F1}ms" : "--"; + string avgAbsText = count > 0 ? $"{(double)_hitErrorWindowAbsSum / count:F1}ms" : "--"; + + return $"idx:{hitErrors.Index} Δ:{delta} last:{lastText} avg:{avgText} abs:{avgAbsText}"; + } + + private static string FormatSigned(int value) + { + return value >= 0 ? $"+{value}" : value.ToString(); + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + + _appSettings.Sync.PropertyChanged -= OnSyncSettingsChanged; + StopMonitoring(); + } +} diff --git a/src/Common/KeyAsio.Application/Services/RtssOsdWriter.cs b/src/Common/KeyAsio.Application/Services/RtssOsdWriter.cs new file mode 100644 index 00000000..648d9b36 --- /dev/null +++ b/src/Common/KeyAsio.Application/Services/RtssOsdWriter.cs @@ -0,0 +1,244 @@ +using System.IO.MemoryMappedFiles; +using System.Runtime.InteropServices; +using System.Text; + +namespace KeyAsio.Application.Services; + +public sealed class RtssOsdWriter : IDisposable +{ + private const string SharedMemoryName = "RTSSSharedMemoryV2"; + private const int OwnerFieldLength = 256; + private const int LegacyTextLength = 256; + private const int ExtendedTextLength = 4096; + private const uint SupportedVersionMin = 0x00020000; + private const uint ExtendedTextVersionMin = 0x00020007; + private const uint RtssSignature = 0x52545353; // 'RTSS' + + private readonly string _entryName; + private readonly byte[] _entryNameBytes = new byte[OwnerFieldLength]; + private readonly byte[] _ownerBuffer = new byte[OwnerFieldLength]; + private readonly byte[] _legacyTextBuffer = new byte[LegacyTextLength]; + private readonly byte[] _extendedTextBuffer = new byte[ExtendedTextLength]; + private readonly object _syncRoot = new(); + + private MemoryMappedFile? _mmf; + private MemoryMappedViewAccessor? _accessor; + private uint _osdEntrySize; + private uint _osdArrOffset; + private uint _osdArrSize; + private bool _useExtendedText; + + private uint _osdSlot; + private long _entryOffset; + private bool _disposed; + + public RtssOsdWriter(string entryName) + { + if (string.IsNullOrWhiteSpace(entryName)) + throw new ArgumentException("Entry name cannot be null, empty, or whitespace.", nameof(entryName)); + + var bytes = Encoding.ASCII.GetByteCount(entryName); + if (bytes > OwnerFieldLength - 1) + throw new ArgumentException("Entry name exceeds max length of 255 when converted to ANSI.", nameof(entryName)); + + _entryName = entryName; + _osdSlot = 0; + _entryOffset = 0; + + Encoding.ASCII.GetBytes(entryName.AsSpan(), _entryNameBytes); + + lock (_syncRoot) + { + EnsureConnected(); + } + } + + public void Update(string text) + { + ObjectDisposedException.ThrowIf(_disposed, this); + ArgumentNullException.ThrowIfNull(text); + + lock (_syncRoot) + { + EnsureConnected(); + if (!TryEnsureSlot()) + { + return; + } + + var maxTextLength = _useExtendedText ? ExtendedTextLength - 1 : LegacyTextLength - 1; + var textBytes = Encoding.ASCII.GetByteCount(text); + if (textBytes > maxTextLength) + throw new ArgumentException($"Text exceeds max length of {maxTextLength} when converted to ANSI.", nameof(text)); + + var targetBuffer = _useExtendedText ? _extendedTextBuffer : _legacyTextBuffer; + var written = Encoding.ASCII.GetBytes(text.AsSpan(), targetBuffer); + + var textOffset = _entryOffset + (_useExtendedText ? 512 : 0); + _accessor!.WriteArray(textOffset, targetBuffer, 0, written); + _accessor.Write(textOffset + written, (byte)0); + + IncrementFrameCounter(); + } + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + + try + { + lock (_syncRoot) + { + if (_accessor != null && _osdSlot != 0 && IsSlotOwnedByEntry(_osdSlot)) + { + var zeroBytes = new byte[_osdEntrySize]; + _accessor.WriteArray(_entryOffset, zeroBytes, 0, (int)_osdEntrySize); + IncrementFrameCounter(); + } + } + } + catch + { + // Ignored during disposal + } + finally + { + _accessor?.Dispose(); + _accessor = null; + _mmf?.Dispose(); + _mmf = null; + } + } + + private void EnsureConnected() + { + if (_accessor != null) + { + return; + } + + _mmf = OpenSharedMemory(); + _accessor = _mmf.CreateViewAccessor(); + + var signature = _accessor.ReadUInt32(0); + if (signature != RtssSignature) + throw new InvalidOperationException("Invalid RTSS shared memory signature."); + + var version = _accessor.ReadUInt32(4); + if (version < SupportedVersionMin) + throw new InvalidOperationException("Unsupported RTSS shared memory version."); + + _osdEntrySize = _accessor.ReadUInt32(20); + _osdArrOffset = _accessor.ReadUInt32(24); + _osdArrSize = _accessor.ReadUInt32(28); + _useExtendedText = version >= ExtendedTextVersionMin; + + _osdSlot = 0; + _entryOffset = 0; + } + + private bool TryEnsureSlot() + { + if (_osdSlot != 0 && IsSlotOwnedByEntry(_osdSlot)) + { + return true; + } + + _osdSlot = 0; + _entryOffset = 0; + + for (uint i = 1; i < _osdArrSize; i++) + { + var entryOffset = GetEntryOffset(i); + var ownerOffset = entryOffset + OwnerFieldLength; + + _accessor!.ReadArray(ownerOffset, _ownerBuffer, 0, OwnerFieldLength); + + if (IsOwnerBufferEmpty()) + { + _accessor.WriteArray(ownerOffset, _entryNameBytes, 0, OwnerFieldLength); + _osdSlot = i; + _entryOffset = entryOffset; + return true; + } + + if (IsOwnerBufferEntryName()) + { + _osdSlot = i; + _entryOffset = entryOffset; + return true; + } + } + + return false; + } + + private bool IsSlotOwnedByEntry(uint slot) + { + var entryOffset = GetEntryOffset(slot); + _accessor!.ReadArray(entryOffset + OwnerFieldLength, _ownerBuffer, 0, OwnerFieldLength); + if (!IsOwnerBufferEntryName()) + { + return false; + } + + _entryOffset = entryOffset; + return true; + } + + private static bool IsOwnerByteTerminator(byte value) + { + return value == 0; + } + + private bool IsOwnerBufferEmpty() + { + return IsOwnerByteTerminator(_ownerBuffer[0]); + } + + private bool IsOwnerBufferEntryName() + { + for (var i = 0; i < OwnerFieldLength; i++) + { + var actual = _ownerBuffer[i]; + var expected = _entryNameBytes[i]; + + if (actual != expected) + { + return false; + } + + if (IsOwnerByteTerminator(expected)) + { + return true; + } + } + + return true; + } + + private long GetEntryOffset(uint slot) + { + return _osdArrOffset + (long)slot * _osdEntrySize; + } + + private void IncrementFrameCounter() + { + var currentFrame = _accessor!.ReadUInt32(32); + _accessor.Write(32, currentFrame + 1); + } + + private static MemoryMappedFile OpenSharedMemory() + { + try + { + return MemoryMappedFile.OpenExisting(SharedMemoryName, MemoryMappedFileRights.ReadWrite); + } + catch (Exception ex) + { + throw new InvalidOperationException("Failed to open RTSS shared memory. Is RTSS running?", ex); + } + } +} diff --git a/src/Common/KeyAsio.Application/Services/SkinManager.cs b/src/Common/KeyAsio.Application/Services/SkinManager.cs new file mode 100644 index 00000000..b3f15f7b --- /dev/null +++ b/src/Common/KeyAsio.Application/Services/SkinManager.cs @@ -0,0 +1,745 @@ +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using Coosu.Shared.IO; +using dnlib.DotNet; +using KeyAsio.Core.Audio.Caching; +using KeyAsio.Core.OsuAudio.Hitsounds; +using KeyAsio.LazerProtocol; +using KeyAsio.Application.Abstractions; +using KeyAsio.Application.Utils; +using KeyAsio.Application.Models; +using KeyAsio.Configuration; +using KeyAsio.Configuration.Models; +using KeyAsio.Sync.Sources; +using KeyAsio.Sync; +using KeyAsio.Sync.Abstractions; +using KeyAsio.Common; +using Microsoft.Extensions.Logging; + +namespace KeyAsio.Application.Services; + +public sealed class SkinManager : ISkinResourceProvider, IDisposable +{ + private static readonly HashSet s_resourcesKeys = + [ + "taiko-normal-hitclap", "taiko-normal-hitfinish", "taiko-normal-hitnormal", "taiko-normal-hitwhistle", + "taiko-soft-hitclap", "taiko-soft-hitfinish", "taiko-soft-hitnormal", "taiko-soft-hitwhistle", + + "drum-hitclap", "drum-hitfinish", "drum-hitnormal", "drum-hitwhistle", + "drum-sliderslide", "drum-slidertick", "drum-sliderwhistle", + + "normal-hitclap", "normal-hitfinish", "normal-hitnormal", "normal-hitwhistle", + "normal-sliderslide", "normal-slidertick", "normal-sliderwhistle", + + "soft-sliderslide", "soft-slidertick", "soft-sliderwhistle", + "soft-hitclap", "soft-hitfinish", "soft-hitnormal", "soft-hitwhistle", + + "combobreak", + "nightcore-clap", "nightcore-finish", "nightcore-hat", "nightcore-kick" + ]; + + // Lazer built-in skin folders (mirroring osu.Game SkinInfo well-known GUIDs). + // FolderName is the GUID string; Folder points to the lazer user data directory + // (used to resolve the realm-backed file store at runtime). + private static readonly (string Guid, SkinDescription Description)[] s_lazerBuiltinSkins = + [ + ("CFFA69DE-B3E3-4DEE-8563-3C4F425C05D0", + new SkinDescription("argon", "{lazer-argon}", "osu! \"argon\" (2022)", "team osu!")), + ("9FC9CF5D-0F16-4C71-8256-98868321AC43", + new SkinDescription("argon_pro", "{lazer-argon_pro}", "osu! \"argon\" pro (2022)", "team osu!")), + ("2991CFD8-2140-469A-BCB9-2EC23FBCE4AD", + new SkinDescription("triangles", "{lazer-triangles}", "osu! \"triangles\" (2017)", "team osu!")), + ("81F02CD3-EEC6-4865-AC23-FAE26A386187", + new SkinDescription("classic", "{lazer-classic}", "osu! \"classic\" (2013)", "team osu!")), + ("0555C76A-CC6B-4BB4-9548-DF76BA72EF25", + new SkinDescription("retro", "{lazer-retro}", "osu! \"retro\" (2008)", "team osu!")), + ]; + + // Maps lazer built-in skin Folder → resource path prefix in osu.Game.Resources.dll. + // Retro has no gameplay audio samples; it shares Legacy (classic) sounds. + private static readonly Dictionary s_lazerBuiltinResourcePrefixes = new() + { + ["{lazer-argon}"] = "Samples.Gameplay.Argon", + ["{lazer-argon_pro}"] = "Samples.Gameplay.ArgonPro", + ["{lazer-triangles}"] = "Samples.Gameplay", + ["{lazer-classic}"] = "Skins.Legacy", + ["{lazer-retro}"] = "Skins.Legacy", + }; + + private readonly ILogger _logger; + private readonly AppSettings _appSettings; + private readonly IAppSettingsPersistence _settingsPersistence; + private readonly AudioCacheManager _audioCacheManager; + private readonly IApplicationDispatcher _dispatcher; + private readonly ApplicationState _sharedViewModel; + private readonly LazerIpcGameSyncSource? _lazerSyncSource; + private readonly SyncSessionContext _syncSessionContext; + private readonly GameSyncSourceCoordinator _syncSourceCoordinator; + + private readonly AsyncLock _asyncLock = new(); + + private CancellationTokenSource? _processPollingCts; + private Task? _processPollingTask; + private CancellationTokenSource? _skinLoadCts; + + private readonly AsyncSequentialWorker _skinLoadingWorker; + private bool _disposed; + + private readonly Dictionary _stableDefaultResources = new(); + private readonly Dictionary _lazerSkinCatalogs = new(); + private readonly Dictionary _lazerDefaultResources = new(); + + // Lazer skin context (received via IPC). + private LazerSkinInfo[]? _lazerSkinInfos; + private string? _lazerUserDataDirectory; + private string? _lazerExeDirectory; + private GameClientType _lastKnownClientType = GameClientType.Stable; + + public SkinManager(ILogger logger, AppSettings appSettings, + IAppSettingsPersistence settingsPersistence, AudioCacheManager audioCacheManager, + ApplicationState sharedViewModel, LazerIpcGameSyncSource? lazerSyncSource, SyncSessionContext syncSessionContext, + GameSyncSourceCoordinator syncSourceCoordinator, IApplicationDispatcher dispatcher) + { + _logger = logger; + _appSettings = appSettings; + _settingsPersistence = settingsPersistence; + _audioCacheManager = audioCacheManager; + _dispatcher = dispatcher; + _sharedViewModel = sharedViewModel; + _lazerSyncSource = lazerSyncSource; + _syncSessionContext = syncSessionContext; + _syncSourceCoordinator = syncSourceCoordinator; + _sharedViewModel.PropertyChanged += ApplicationState_PropertyChanged; + _appSettings.Paths.PropertyChanged += Paths_PropertyChanged; + + _skinLoadingWorker = new AsyncSequentialWorker(_logger, "SkinManagerWorker"); + + if (_lazerSyncSource != null) + { + _lazerSyncSource.LazerSkinContextReceived += OnLazerSkinContextReceived; + } + + _syncSourceCoordinator.ClientTypeChanged += OnClientTypeChanged; + } + + public bool IsStarted => _processPollingCts != null; + + public bool TryGetStableResource(string key, out byte[] data) + { + return _stableDefaultResources.TryGetValue(key, out data); + } + + public bool TryGetSkinCatalog(string folder, out IBeatmapResourceCatalog catalog) + { + return _lazerSkinCatalogs.TryGetValue(folder, out catalog); + } + + public bool TryGetLazerResource(string skinFolder, string key, out byte[] data) + { + // Try the specified skin first + if (_lazerDefaultResources.TryGetValue($"{skinFolder}:{key}", out data)) + return true; + + // Fallback: classic → triangles (for missing keys like nightcore in argon) + if (_lazerDefaultResources.TryGetValue($"{{lazer-classic}}:{key}", out data)) + return true; + + if (_lazerDefaultResources.TryGetValue($"{{lazer-triangles}}:{key}", out data)) + return true; + + return false; + } + + public Task ReloadSkinsAsync() => RefreshSkinsAsync(); + + public void Start() + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (IsStarted) + { + return; + } + + if (string.IsNullOrWhiteSpace(_appSettings.Paths.OsuFolderPath)) + { + CheckOsuRegistry(); + } + + _ = RefreshSkinsAsync(); + + StartProcessListener(); + } + + public void Stop() + { + StopProcessListener(); + StopRefreshTask(); + } + + private void Paths_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(AppSettings.Paths.OsuFolderPath) || + e.PropertyName == nameof(AppSettings.Paths.AllowAutoLoadSkins)) + { + _ = RefreshSkinsAsync(); + } + } + + private void ApplicationState_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(ApplicationState.SelectedSkin)) + { + _appSettings.Paths.SelectedSkinName = _sharedViewModel.SelectedSkin?.FolderName; + _audioCacheManager.ClearAll(); + } + } + + private void OnLazerSkinContextReceived(LazerSkinInfo[]? skinInfos, string? userDataDirectory, string? exeDirectory) + { + bool changed = false; + + if (skinInfos != null) + { + _lazerSkinInfos = skinInfos; + changed = true; + } + + if (userDataDirectory != null) + { + if (_lazerUserDataDirectory != userDataDirectory) + { + _lazerUserDataDirectory = userDataDirectory; + changed = true; + } + } + + if (exeDirectory != null) + { + if (_lazerExeDirectory != exeDirectory) + { + _lazerExeDirectory = exeDirectory; + changed = true; + } + } + + if (!changed) + return; + + _logger.LogInformation( + "Lazer skin context updated: {SkinCount} skins, user data: {UserDataDir}, exe: {ExeDir}", + _lazerSkinInfos?.Length ?? 0, _lazerUserDataDirectory, _lazerExeDirectory); + + EnsureLazerClientTypeAndOsuFolder(); + _ = RefreshSkinsAsync(); + } + + private void EnsureLazerClientTypeAndOsuFolder() + { + // Update ClientType to Lazer and set OsuFolderPath to lazer's exe directory. + if (_lazerExeDirectory == null) + return; + + _appSettings.Paths.ClientType = GameClientType.Lazer; + _lastKnownClientType = GameClientType.Lazer; + + if (!string.Equals(_appSettings.Paths.OsuFolderPath, _lazerExeDirectory, StringComparison.OrdinalIgnoreCase)) + { + _logger.LogInformation("Updating osu folder to lazer exe directory: {Path}", _lazerExeDirectory); + _appSettings.Paths.OsuFolderPath = _lazerExeDirectory; + } + } + + private void OnClientTypeChanged(GameClientType newClientType) + { + if (newClientType == _lastKnownClientType) + return; + + _lastKnownClientType = newClientType; + _appSettings.Paths.ClientType = newClientType; + _logger.LogInformation("Sync client type changed to {ClientType}", newClientType); + + // Clear default resources so they get re-extracted from the appropriate source + // (osu!gameplay.dll for stable, osu.Game.Resources.dll for lazer). + _stableDefaultResources.Clear(); + _lazerSkinCatalogs.Clear(); + _lazerDefaultResources.Clear(); + + if (newClientType == GameClientType.Stable) + { + // When switching back to stable, re-detect stable's osu folder from running process. + CheckAndSetOsuPath(Process.GetProcessesByName("osu!")); + } + + _ = RefreshSkinsAsync(); + } + + private void StartProcessListener() + { + var processes = Process.GetProcessesByName("osu!"); + CheckAndSetOsuPath(processes); + + try + { + _processPollingCts = new CancellationTokenSource(); + var token = _processPollingCts.Token; + _processPollingTask = Task.Run(() => ProcessPollingLoop(token), token); + _logger.LogInformation("Osu process listener started."); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to start process polling."); + } + } + + private void StopProcessListener() + { + try + { + _processPollingCts?.Cancel(); + try + { + _processPollingTask?.Wait(1000); + } + catch (AggregateException) + { + } + + _processPollingCts?.Dispose(); + _processPollingCts = null; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Error stopping process polling."); + } + } + + private async Task ProcessPollingLoop(CancellationToken token) + { + using var timer = new PeriodicTimer(TimeSpan.FromSeconds(3)); + bool wasRunning = IsOsuRunning(); + + while (await timer.WaitForNextTickAsync(token)) + { + var processes = Process.GetProcessesByName("osu!"); + bool isRunning = processes.Length > 0; + + if (isRunning && !wasRunning) + { + _logger.LogInformation("Detected osu! process start via polling."); + CheckAndSetOsuPath(processes); + _ = RefreshSkinsAsync(); + } + + wasRunning = isRunning; + } + } + + private static bool IsOsuRunning() + { + var processes = Process.GetProcessesByName("osu!"); + bool any = processes.Length > 0; + foreach (var p in processes) p.Dispose(); + return any; + } + + private void CheckAndSetOsuPath(Process[] processes) + { + try + { + var detectedPath = OsuLocator.FindFromRunningProcess(processes); + if (detectedPath != null && _appSettings.Paths.OsuFolderPath != detectedPath) + { + _logger.LogInformation("Auto-detected osu! path: {Path}", detectedPath); + _appSettings.Paths.OsuFolderPath = detectedPath; + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Error inspecting osu! process module."); + } + } + + private async Task RefreshSkinsAsync() + { + using var @lock = await _asyncLock.LockAsync(); + + StopRefreshTask(); + + if (_appSettings.Paths.AllowAutoLoadSkins != true) + { + await _dispatcher.InvokeAsync(() => + { + _sharedViewModel.Skins.Clear(); + _sharedViewModel.Skins.Add(SkinDescription.Internal); + _sharedViewModel.SelectedSkin = SkinDescription.Internal; + foreach (var key in _stableDefaultResources.Keys) + { + _stableDefaultResources[key] = Array.Empty(); + } + }); + return; + } + + _skinLoadCts = new CancellationTokenSource(); + var token = _skinLoadCts.Token; + + _skinLoadingWorker.Enqueue(async () => await LoadSkinsInternal(token)); + } + + private void CheckOsuRegistry() + { + try + { + if (OsuLocator.FindFromRegistry() is { } path) + { + _appSettings.Paths.OsuFolderPath = path; + _settingsPersistence.Save(); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error occurs while finding registry"); + } + } + + private async Task LoadSkinsInternal(CancellationToken token) + { + if (string.IsNullOrEmpty(_appSettings.Paths.OsuFolderPath)) + { + // Even without an osu folder, we can still expose lazer's built-in skins. + if (_lazerSkinInfos != null) + { + await LoadLazerSkinsAsync(token); + } + + return; + } + + if (_appSettings.Paths.ClientType == GameClientType.Lazer) + { + await LoadLazerSkinsAsync(token); + return; + } + + ExtractDefaultResources(_appSettings.Paths.OsuFolderPath, token); + + var skinsDir = Path.Combine(_appSettings.Paths.OsuFolderPath, "Skins"); + if (!Directory.Exists(skinsDir)) return; + + var directories = Directory.EnumerateDirectories(skinsDir); + var loadedSkins = new List(); + + foreach (var dir in directories) + { + if (token.IsCancellationRequested) return; + var iniPath = Path.Combine(dir, "skin.ini"); + string? name = null; + string? author = null; + if (File.Exists(iniPath)) + { + (name, author) = ReadIniFile(iniPath); + } + + var skinDescription = new SkinDescription(Path.GetFileName(dir), dir, name, author); + _logger.LogDebug("Find skin: {SkinDescription}", skinDescription); + loadedSkins.Add(skinDescription); + } + + var newSkinList = new List { SkinDescription.Internal, SkinDescription.Classic }; + newSkinList.AddRange(OrderUserSkins(loadedSkins)); + + await PublishSkinListAsync(newSkinList, token); + } + + private async Task LoadLazerSkinsAsync(CancellationToken token) + { + _lazerSkinCatalogs.Clear(); + + var newSkinList = new List { SkinDescription.Internal }; + + // 5 built-in skins (mirrors stable's behavior: always available). + foreach (var (_, description) in s_lazerBuiltinSkins) + { + newSkinList.Add(description); + } + + // Extract default gameplay audio from osu.Game.Resources.dll for built-in skins. + ExtractLazerDefaultResources(token); + + // User skins from lazer realm (via IPC). + var lazerUserSkins = new List(); + if (_lazerSkinInfos != null) + { + foreach (var info in _lazerSkinInfos) + { + if (info.Protected) + continue; // Built-in skins are added separately with stable FolderNames. + + if (token.IsCancellationRequested) return; + + var folder = Path.Combine(_lazerUserDataDirectory ?? "", "files", info.Id); + var folderName = info.Name ?? info.Id; + + // Build a resource catalog from the skin's files so audio can be + // resolved by name to the actual hash-based file store paths. + if (info.Files.Length > 0) + { + var catalog = BeatmapResourceCatalog.FromMappings( + info.Files.Select(f => new BeatmapResource(f.Name, f.Path)), + folder, + $"lazer-skin:{info.Id}"); + _lazerSkinCatalogs[folder] = catalog; + } + + lazerUserSkins.Add(new SkinDescription( + folderName, + folder, + info.Name, + info.Creator)); + } + } + + newSkinList.AddRange(OrderUserSkins(lazerUserSkins)); + + await PublishSkinListAsync(newSkinList, token); + } + + /// + /// Sort user skins alphabetically by their display description (case-insensitive, ordinal), + /// matching the ordering users expect from osu! stable/lazer skin dropdowns. + /// + private static IEnumerable OrderUserSkins(IEnumerable userSkins) + => userSkins.OrderBy(static s => s.Description, StringComparer.OrdinalIgnoreCase); + + private async Task PublishSkinListAsync(List newSkinList, CancellationToken token) + { + var selectedName = _appSettings.Paths.SelectedSkinName; + var targetSkin = newSkinList.FirstOrDefault(k => k.FolderName == selectedName) + ?? SkinDescription.Internal; + + await _dispatcher.InvokeAsync(() => + { + if (token.IsCancellationRequested) return; + _sharedViewModel.Skins.Clear(); + var type = SynchronizationContext.Current.GetType(); + if (type.Namespace == "System.Windows.Threading") + { + foreach (var skinDescription in newSkinList) + { + _sharedViewModel.Skins.Add(skinDescription); + } + } + else + { + _sharedViewModel.Skins.AddRange(newSkinList); + } + + _sharedViewModel.SelectedSkin = targetSkin; + }); + } + + private void ExtractDefaultResources(string osuPath, CancellationToken token) + { + if (_stableDefaultResources.Count > 0) return; + var dllPath = Path.Combine(osuPath, "osu!gameplay.dll"); + if (!File.Exists(dllPath)) + { + return; + } + + try + { + if (token.IsCancellationRequested) return; + + using var module = ModuleDefMD.Load(dllPath); + var resource = module.Resources.FindEmbeddedResource("osu_gameplay.ResourcesStore.resources"); + if (resource == null) + { + return; + } + + using var stream = resource.CreateReader().AsStream(); + using var reader = new System.Resources.ResourceReader(stream); + + foreach (var resourcesKey in s_resourcesKeys) + { + try + { + reader.GetResourceData(resourcesKey, out var resourceType, out var resourceData); + + if (!resourceType.Contains("ResourceTypeCode.ByteArray")) return; + // [ 长度 (Int32, 4字节) ] + [ 实际数据 (N字节) ] + if (resourceData.Length <= 4) return; + + var bytes = resourceData.AsSpan(4).ToArray(); + _stableDefaultResources[resourcesKey] = bytes; + _logger.LogDebug("Extracted '{ResourcesKey}' ({Bytes} bytes)", resourcesKey, bytes.Length); + } + catch (ArgumentException) + { + _logger.LogWarning("Resource '{ResourcesKey}' not found in osu!gameplay.dll", resourcesKey); + } + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to extract default resources from osu!gameplay.dll"); + } + } + + private void ExtractLazerDefaultResources(CancellationToken token) + { + if (_lazerDefaultResources.Count > 0) return; + + var exeDir = _lazerExeDirectory; + if (string.IsNullOrEmpty(exeDir)) + { + _logger.LogDebug("Lazer exe directory not available; skipping default resource extraction."); + return; + } + + var dllPath = Path.Combine(exeDir, "osu.Game.Resources.dll"); + if (!File.Exists(dllPath)) + { + _logger.LogDebug("osu.Game.Resources.dll not found at {Path}", dllPath); + return; + } + + try + { + if (token.IsCancellationRequested) return; + + using var module = ModuleDefMD.Load(dllPath); + var assemblyName = module.Assembly?.Name ?? "osu.Game.Resources"; + + // Build a lookup of all embedded resource names. + var resourceNames = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var res in module.Resources) + { + if (res is EmbeddedResource emb) + resourceNames.Add(emb.Name); + } + + // For each built-in skin, extract its samples from the corresponding resource path. + // e.g. argon → "osu.Game.Resources.Samples.Gameplay.Argon.normal-hitnormal.wav" + // classic → "osu.Game.Resources.Skins.Legacy.normal-hitnormal.wav" + foreach (var (skinFolder, pathPrefix) in s_lazerBuiltinResourcePrefixes) + { + var dotPrefix = pathPrefix.Replace('/', '.'); + + foreach (var key in s_resourcesKeys) + { + if (token.IsCancellationRequested) return; + + // Try .wav > .mp3 > .ogg in priority order + foreach (var ext in new[] { ".wav", ".mp3", ".ogg" }) + { + var manifestName = $"{assemblyName}.{dotPrefix}.{key}{ext}"; + if (!resourceNames.Contains(manifestName)) + continue; + + var emb = module.Resources.FindEmbeddedResource(manifestName); + if (emb == null) break; + + try + { + using var stream = emb.CreateReader().AsStream(); + using var ms = new MemoryStream(); + stream.CopyTo(ms); + var storageKey = $"{skinFolder}:{key}"; + _lazerDefaultResources[storageKey] = ms.ToArray(); + _logger.LogDebug("Extracted '{Key}' for {Skin} from osu.Game.Resources.dll ({Bytes} bytes)", + key, skinFolder, _lazerDefaultResources[storageKey].Length); + break; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to extract '{Key}' for {Skin} from osu.Game.Resources.dll", + key, skinFolder); + break; + } + } + } + } + + _logger.LogInformation("Extracted {Count} lazer default audio resources from osu.Game.Resources.dll", + _lazerDefaultResources.Count); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to extract default resources from osu.Game.Resources.dll"); + } + } + + private static (string?, string?) ReadIniFile(string iniFile) + { + string? name = null; + string? author = null; + + using var fs = File.Open(iniFile, FileMode.Open, FileAccess.Read, FileShare.Read); + using var sr = new StreamReader(fs); + + using var lineReader = new EphemeralLineReader(sr); + ReadOnlyMemory? currentLineMemory; + + while ((currentLineMemory = lineReader.ReadLine()) != null) + { + var lineSpan = currentLineMemory.Value.Span; + + var commentIndex = lineSpan.IndexOf("//"); + if (commentIndex >= 0) + { + lineSpan = lineSpan.Slice(0, commentIndex); + } + + var trimmedLineSpan = lineSpan.Trim(); + + if (trimmedLineSpan.StartsWith("Name:", StringComparison.OrdinalIgnoreCase)) + { + name = trimmedLineSpan.Slice(5).TrimStart().ToString(); + } + else if (trimmedLineSpan.StartsWith("Author:", StringComparison.OrdinalIgnoreCase)) + { + author = trimmedLineSpan.Slice(7).TrimStart().ToString(); + } + + if (name is not null && author is not null) + { + break; + } + } + + return (name, author); + } + + private void StopRefreshTask() + { + if (_skinLoadCts != null) + { + _skinLoadCts.Cancel(); + _skinLoadCts.Dispose(); + } + + _skinLoadCts = null; + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + Stop(); + _sharedViewModel.PropertyChanged -= ApplicationState_PropertyChanged; + _appSettings.Paths.PropertyChanged -= Paths_PropertyChanged; + if (_lazerSyncSource is not null) + { + _lazerSyncSource.LazerSkinContextReceived -= OnLazerSkinContextReceived; + } + + _syncSourceCoordinator.ClientTypeChanged -= OnClientTypeChanged; + _skinLoadingWorker.Dispose(); + } +} diff --git a/src/Common/KeyAsio.Application/Utils/OsuLocator.cs b/src/Common/KeyAsio.Application/Utils/OsuLocator.cs new file mode 100644 index 00000000..ce543126 --- /dev/null +++ b/src/Common/KeyAsio.Application/Utils/OsuLocator.cs @@ -0,0 +1,195 @@ +using System.Diagnostics; +using System.Runtime.Versioning; +using Microsoft.Win32; + +namespace KeyAsio.Application.Utils; + +public static class OsuLocator +{ + public const string LazerExeName = "osu!"; + public const string LazerProcessName = "osu!"; + + [SupportedOSPlatform("windows")] + public static string? FindFromRegistry() + { + using var reg = Registry.ClassesRoot.OpenSubKey(@"osu!\shell\open\command"); + var parameters = reg?.GetValue(null)?.ToString(); + if (string.IsNullOrWhiteSpace(parameters)) return null; + + var path = parameters.Replace(" \"%1\"", "").Trim(' ', '"'); + var dir = Path.GetDirectoryName(Path.GetFullPath(path)); + return Directory.Exists(dir) ? dir : null; + } + + public static string? FindFromRunningProcess(Process[]? processes = null) + { + processes ??= Process.GetProcessesByName("osu!"); + string? result = null; + + foreach (var proc in processes) + { + try + { + if (result != null) continue; + + if (proc.HasExited) continue; + if (proc.MainModule is not { } module) continue; + + var fileName = module.FileName; + if (string.IsNullOrEmpty(fileName)) continue; + + var fileVersionInfo = FileVersionInfo.GetVersionInfo(fileName); + if (fileVersionInfo.CompanyName == "ppy") + { + var detectedPath = Path.GetDirectoryName(Path.GetFullPath(fileName)); + if (Directory.Exists(detectedPath)) + { + result = detectedPath; + } + } + else if (fileVersionInfo.CompanyName == "ppy Pty Ltd") + { + // lazer wip + } + } + catch (System.ComponentModel.Win32Exception) + { + // Ignore access denied + } + finally + { + proc.Dispose(); + } + } + + return result; + } + + /// + /// Find the executable directory of a running osu!lazer process. + /// Returns null if lazer is not running or the directory cannot be determined. + /// + public static string? FindLazerExeDirectoryFromRunningProcess(Process[]? processes = null) + { + processes ??= Process.GetProcessesByName(LazerProcessName); + string? result = null; + + foreach (var proc in processes) + { + try + { + if (result != null) continue; + if (proc.HasExited) continue; + if (proc.MainModule is not { } module) continue; + + var fileName = module.FileName; + if (string.IsNullOrEmpty(fileName)) continue; + + var fileVersionInfo = FileVersionInfo.GetVersionInfo(fileName); + if (fileVersionInfo.CompanyName == "ppy Pty Ltd") + { + var detectedPath = Path.GetDirectoryName(Path.GetFullPath(fileName)); + if (Directory.Exists(detectedPath)) + { + result = detectedPath; + } + } + } + catch (System.ComponentModel.Win32Exception) + { + // Ignore access denied + } + finally + { + proc.Dispose(); + } + } + + return result; + } + + /// + /// Find the osu!lazer user data directory. + /// Lazer stores the user-selected data path in `storage.ini` (FullPath key), + /// which is located next to the lazer executable. If no custom path is set, + /// lazer uses %LOCALAPPDATA%/osu! on Windows. + /// + public static string? FindLazerUserDataDirectory(string? lazerExeDirectory = null) + { + if (lazerExeDirectory != null) + { + var configured = ReadLazerCustomStoragePath(lazerExeDirectory); + if (configured != null && Directory.Exists(configured)) + { + return configured; + } + } + + // Default location on Windows: %LOCALAPPDATA%/osu! + try + { + var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + var defaultPath = Path.Combine(localAppData, "osu!"); + if (Directory.Exists(defaultPath)) + { + return defaultPath; + } + } + catch + { + // Ignore + } + + return null; + } + + /// + /// Read the custom storage path from lazer's storage.ini next to the executable. + /// Returns the path or null if not set / file missing. + /// + public static string? ReadLazerCustomStoragePath(string lazerExeDirectory) + { + var storageIniPath = Path.Combine(lazerExeDirectory, "storage.ini"); + if (!File.Exists(storageIniPath)) + { + return null; + } + + try + { + using var sr = new StreamReader(storageIniPath); + string? line; + string? fullPath = null; + + while ((line = sr.ReadLine()) != null) + { + var trimmed = line.AsSpan().Trim(); + if (trimmed.IsEmpty) continue; + + // Skip section headers like [StorageConfig] + if (trimmed[0] == '[') continue; + + var eq = trimmed.IndexOf('='); + if (eq < 0) continue; + + var key = trimmed.Slice(0, eq).Trim(); + if (key.Equals("FullPath", StringComparison.OrdinalIgnoreCase)) + { + var value = trimmed.Slice(eq + 1).Trim().ToString(); + if (!string.IsNullOrWhiteSpace(value)) + { + fullPath = value; + } + + break; + } + } + + return fullPath; + } + catch + { + return null; + } + } +} diff --git a/src/Common/KeyAsio.Common/AsyncLock.cs b/src/Common/KeyAsio.Common/AsyncLock.cs new file mode 100644 index 00000000..45c6571e --- /dev/null +++ b/src/Common/KeyAsio.Common/AsyncLock.cs @@ -0,0 +1,44 @@ +namespace KeyAsio.Common; + +public class AsyncLock : IDisposable +{ + private class AsyncLockImpl : IDisposable + { + private readonly AsyncLock _parent; + public AsyncLockImpl(AsyncLock parent) => _parent = parent; + public void Dispose() => _parent._semaphoreSlim.Release(); + } + + private readonly AsyncLockImpl _asyncLockImpl; + private readonly SemaphoreSlim _semaphoreSlim; + private readonly Task _completeTask; + + public AsyncLock() + { + _semaphoreSlim = new SemaphoreSlim(1, 1); + _asyncLockImpl = new AsyncLockImpl(this); + _completeTask = Task.FromResult((IDisposable)_asyncLockImpl); + } + + public IDisposable Lock() + { + _semaphoreSlim.Wait(); + return _asyncLockImpl; + } + + public Task LockAsync(CancellationToken cancellationToken = default) + { + var task = _semaphoreSlim.WaitAsync(cancellationToken); + return task.IsCompleted + ? _completeTask + : task.ContinueWith( + (_, state) => (IDisposable)((AsyncLock)state!)._asyncLockImpl, + this, CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); + } + + public void Dispose() + { + _semaphoreSlim.Dispose(); + } +} \ No newline at end of file diff --git a/src/Common/KeyAsio.Common/AsyncSequentialWorker.cs b/src/Common/KeyAsio.Common/AsyncSequentialWorker.cs new file mode 100644 index 00000000..9d8869a7 --- /dev/null +++ b/src/Common/KeyAsio.Common/AsyncSequentialWorker.cs @@ -0,0 +1,177 @@ +using System.Threading.Channels; +using Microsoft.Extensions.Logging; + +namespace KeyAsio.Common; + +/// +/// Executes asynchronous work in submission order. Bounded workers apply real +/// backpressure through . +/// +public sealed class AsyncSequentialWorker : IDisposable, IAsyncDisposable +{ + private readonly Channel _channel; + private readonly CancellationTokenSource _cts = new(); + private readonly ILogger? _logger; + private readonly string _name; + private readonly Task _workerLoop; + private int _disposeState; + + public AsyncSequentialWorker(ILogger? logger = null, string name = "AsyncSequentialWorker", int capacity = 0) + { + _logger = logger; + _name = name; + _channel = capacity > 0 + ? Channel.CreateBounded(new BoundedChannelOptions(capacity) + { + SingleReader = true, + SingleWriter = false, + FullMode = BoundedChannelFullMode.Wait + }) + : Channel.CreateUnbounded(new UnboundedChannelOptions + { + SingleReader = true, + SingleWriter = false + }); + + _workerLoop = Task.Run(ProcessQueueAsync); + } + + public void Enqueue(Func workItem) + { + ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposeState) != 0, this); + if (!_channel.Writer.TryWrite(new WorkItem(workItem, null))) + { + throw new InvalidOperationException( + $"{_name} is bounded and currently full. Use EnqueueAsync to apply backpressure."); + } + } + + public void Enqueue(Func workItem) => Enqueue(_ => workItem()); + + public Task EnqueueAsync(Func workItem) => + EnqueueAsync(async cancellationToken => + { + await workItem(cancellationToken).ConfigureAwait(false); + return null; + }); + + public Task EnqueueAsync(Func workItem) => EnqueueAsync(_ => workItem()); + + public async Task EnqueueAsync( + Func> workItem, + CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposeState) != 0, this); + + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var queued = new WorkItem( + async workerToken => + { + try + { + completion.TrySetResult(await workItem(workerToken).ConfigureAwait(false)); + } + catch (OperationCanceledException exception) + { + completion.TrySetCanceled(exception.CancellationToken); + } + catch (Exception exception) + { + completion.TrySetException(exception); + } + }, + token => completion.TrySetCanceled(token)); + + try + { + await _channel.Writer.WriteAsync(queued, cancellationToken).ConfigureAwait(false); + } + catch (ChannelClosedException) + { + throw new ObjectDisposedException(_name); + } + + return await completion.Task.ConfigureAwait(false); + } + + public Task EnqueueAsync(Func> workItem, CancellationToken cancellationToken = default) => + EnqueueAsync(_ => workItem(), cancellationToken); + + public void Dispose() + { + BeginDispose(); + } + + public async ValueTask DisposeAsync() + { + BeginDispose(); + try + { + await _workerLoop.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); + } + catch (TimeoutException) + { + _logger?.LogWarning("Worker loop in {Name} did not stop within five seconds", _name); + } + } + + private void BeginDispose() + { + if (Interlocked.Exchange(ref _disposeState, 1) != 0) + { + return; + } + + _channel.Writer.TryComplete(); + _cts.Cancel(); + _ = _workerLoop.ContinueWith( + static (_, state) => ((CancellationTokenSource)state!).Dispose(), + _cts, + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + } + + private async Task ProcessQueueAsync() + { + try + { + await foreach (var workItem in _channel.Reader.ReadAllAsync(_cts.Token).ConfigureAwait(false)) + { + try + { + await workItem.Execute(_cts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (_cts.IsCancellationRequested) + { + workItem.Cancel?.Invoke(_cts.Token); + break; + } + catch (Exception exception) + { + _logger?.LogError(exception, "Error processing work item in {Name}", _name); + } + + if (_cts.IsCancellationRequested) + { + break; + } + } + } + catch (OperationCanceledException) when (_cts.IsCancellationRequested) + { + // Expected during shutdown. + } + finally + { + while (_channel.Reader.TryRead(out var pending)) + { + pending.Cancel?.Invoke(_cts.Token); + } + } + } + + private sealed record WorkItem( + Func Execute, + Action? Cancel); +} diff --git a/src/Common/KeyAsio.Common/DebugUtils.cs b/src/Common/KeyAsio.Common/DebugUtils.cs new file mode 100644 index 00000000..fb51a614 --- /dev/null +++ b/src/Common/KeyAsio.Common/DebugUtils.cs @@ -0,0 +1,157 @@ +using System.Runtime.CompilerServices; +using System.Diagnostics; +using System.Text; +using Microsoft.Extensions.Logging; + +namespace KeyAsio.Common; + +public static class DebugUtils +{ + private const string Normal = "│ "; + private const string Middle = "├─ "; + private const string Last = "└─ "; + + public static string ToFullTypeMessage(this Exception exception) + { + return ExceptionToFullMessage(exception, new StringBuilder(), 0, true, true)!; + } + + public static string ToSimpleTypeMessage(this Exception exception) + { + return ExceptionToFullMessage(exception, new StringBuilder(), 0, true, false)!; + } + + public static string ToMessage(this Exception exception) + { + return ExceptionToFullMessage(exception, new StringBuilder(), 0, true, null)!; + } + + private static string? ExceptionToFullMessage(Exception exception, StringBuilder stringBuilder, int deep, + bool isLastItem, bool? includeFullType) + { + var hasChild = exception.InnerException != null; + if (deep > 0) + { + for (int i = 0; i < deep; i++) + { + if (i == deep - 1) + { + stringBuilder.Append((isLastItem && !hasChild) ? Last : Middle); + } + else + { + stringBuilder.Append(Normal + " "); + } + } + } + + var agg = exception as AggregateException; + if (includeFullType == true) + { + var prefix = agg == null ? exception.GetType().ToString() : "!!AggregateException"; + stringBuilder.Append($"{prefix}: {GetTrueExceptionMessage(exception)}"); + } + else if (includeFullType == false) + { + var prefix = exception.GetType().Name; + stringBuilder.Append($"{prefix}: {GetTrueExceptionMessage(exception)}"); + } + else + { + stringBuilder.Append(GetTrueExceptionMessage(exception)); + } + + stringBuilder.AppendLine(); + if (!hasChild) + { + return deep == 0 ? stringBuilder.ToString().Trim() : null; + } + + if (agg != null) + { + for (int i = 0; i < agg.InnerExceptions.Count; i++) + { + ExceptionToFullMessage(agg.InnerExceptions[i], stringBuilder, deep + 1, + i == agg.InnerExceptions.Count - 1, includeFullType); + } + } + else + { + ExceptionToFullMessage(exception.InnerException!, stringBuilder, deep + 1, true, includeFullType); + } + + return deep == 0 ? stringBuilder.ToString().Trim() : null; + + static string GetTrueExceptionMessage(Exception ex) + { + if (ex is AggregateException { InnerException: { } innerException } agg) + { + var complexMessage = agg.Message; + var i = complexMessage.IndexOf(innerException.Message, StringComparison.Ordinal); + if (i == -1) + return complexMessage; + return complexMessage[..Math.Max(0, i - 2)]; + } + + return string.IsNullOrWhiteSpace(ex.Message) ? "{Empty Message}" : ex.Message; + } + } + + public static void InvokeAndPrint(Action method, string caller = "anonymous method") + { + var sw = Stopwatch.StartNew(); + method?.Invoke(); + Console.WriteLine($"[{caller}] Executed in {sw.Elapsed.TotalMilliseconds:#0.000} ms"); + } + + public static T InvokeAndPrint(Func method, string caller = "anonymous method") + { + var sw = Stopwatch.StartNew(); + var value = method.Invoke(); + Console.WriteLine($"[{caller}] Executed in {sw.Elapsed.TotalMilliseconds:#0.000} ms"); + return value; + } + + public static IDisposable CreateTimer(string name, ILogger? logger = null) + { + return new TimerImpl(name, logger); + } + + private class TimerImpl : IDisposable + { + private readonly string _name; + private readonly ILogger? _logger; + private readonly Stopwatch _sw; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public TimerImpl(string name, ILogger? logger) + { + _name = name; + _logger = logger; + + if (_logger == null) + { + Console.WriteLine($"[{_name}] executing"); + } + else + { + _logger.LogTrace("[{Name}] executing", _name); + } + + _sw = Stopwatch.StartNew(); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Dispose() + { + if (_logger == null) + { + Console.WriteLine($"[{_name}] executed in {_sw.Elapsed.TotalMilliseconds:#0.000}ms"); + } + else + { + _logger.LogDebug("[{Name}] executed in {Elapsed:#0.000}ms", _name, _sw.Elapsed.TotalMilliseconds); + } + } + } +} diff --git a/src/Common/KeyAsio.Common/EncodeUtils.cs b/src/Common/KeyAsio.Common/EncodeUtils.cs new file mode 100644 index 00000000..b9465250 --- /dev/null +++ b/src/Common/KeyAsio.Common/EncodeUtils.cs @@ -0,0 +1,57 @@ +using System.Runtime.CompilerServices; +using System.Text; + +namespace KeyAsio.Common; + +public static class EncodeUtils +{ + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static string GetBase64String(string value, Encoding encoding) + { + return Convert.ToBase64String(encoding.GetBytes(value)); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static string FromBase64String(string value, Encoding encoding) + { + return encoding.GetString(Convert.FromBase64String(value)); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static string FromBase64StringEmptyIfError(string value, Encoding encoding) + { + try + { + return FromBase64String(value, encoding); + } + catch + { + return ""; + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static string GetBase64String(string value) + { + return GetBase64String(value, Encoding.UTF8); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static string FromBase64String(string value) + { + return FromBase64String(value, Encoding.UTF8); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static string FromBase64StringEmptyIfError(string value) + { + try + { + return FromBase64String(value, Encoding.UTF8); + } + catch + { + return ""; + } + } +} \ No newline at end of file diff --git a/src/Common/KeyAsio.Common/KeyAsio.Common.csproj b/src/Common/KeyAsio.Common/KeyAsio.Common.csproj new file mode 100644 index 00000000..227b9f91 --- /dev/null +++ b/src/Common/KeyAsio.Common/KeyAsio.Common.csproj @@ -0,0 +1,12 @@ + + + net10.0 + enable + enable + true + + + + + + diff --git a/src/Common/KeyAsio.Common/ObservableRangeCollection.cs b/src/Common/KeyAsio.Common/ObservableRangeCollection.cs new file mode 100644 index 00000000..c69046bc --- /dev/null +++ b/src/Common/KeyAsio.Common/ObservableRangeCollection.cs @@ -0,0 +1,251 @@ +using System.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; + +namespace KeyAsio.Common; + +public class ObservableRangeCollection : ObservableCollection +{ + #region Constructors + + public ObservableRangeCollection() + { + } + + public ObservableRangeCollection(IEnumerable collection) : base(collection) { } + + #endregion + + #region AddRange + + /// + /// 添加多个元素到集合末尾 + /// + /// 要添加的元素集合 + /// 通知模式: Add(逐项通知) 或 Reset(重置通知) + public void AddRange(IEnumerable collection, NotifyCollectionChangedAction notificationMode = NotifyCollectionChangedAction.Add) + { + InsertRange(Count, collection, notificationMode); + } + + #endregion + + #region InsertRange + + /// + /// 在指定位置插入多个元素 + /// + public void InsertRange(int index, IEnumerable collection, NotifyCollectionChangedAction notificationMode = NotifyCollectionChangedAction.Add) + { + if (collection == null) + throw new ArgumentNullException(nameof(collection)); + if (index < 0 || index > Count) + throw new ArgumentOutOfRangeException(nameof(index)); + if (notificationMode != NotifyCollectionChangedAction.Add && notificationMode != NotifyCollectionChangedAction.Reset) + throw new ArgumentException("Mode must be either Add or Reset.", nameof(notificationMode)); + + // 提前物化集合,避免多次枚举 + var items = collection as IList ?? collection.ToList(); + if (items.Count == 0) return; + + CheckReentrancy(); + + // 执行插入 + var itemsList = (List)Items; + itemsList.InsertRange(index, items); + + // 发送通知 + if (notificationMode == NotifyCollectionChangedAction.Reset) + { + RaiseChangeNotificationEvents(NotifyCollectionChangedAction.Reset); + } + else + { + RaiseChangeNotificationEvents( + NotifyCollectionChangedAction.Add, + items as List ?? new List(items), + index); + } + } + + #endregion + + #region RemoveRange + + /// + /// 移除指定元素集合中的每个元素(第一次出现) + /// + public void RemoveRange(IEnumerable collection, NotifyCollectionChangedAction notificationMode = NotifyCollectionChangedAction.Reset) + { + if (collection == null) + throw new ArgumentNullException(nameof(collection)); + if (notificationMode != NotifyCollectionChangedAction.Remove && notificationMode != NotifyCollectionChangedAction.Reset) + throw new ArgumentException("Mode must be either Remove or Reset.", nameof(notificationMode)); + + if (Count == 0) return; + + var items = collection as IList ?? collection.ToList(); + if (items.Count == 0) return; + + CheckReentrancy(); + + if (notificationMode == NotifyCollectionChangedAction.Reset) + { + var removed = false; + foreach (var item in items) + { + if (Items.Remove(item)) + removed = true; + } + + if (removed) + { + RaiseChangeNotificationEvents(NotifyCollectionChangedAction.Reset); + } + + return; + } + + // Remove 模式: 只移除实际存在的元素 + var removedItems = new List(); + foreach (var item in items) + { + if (Items.Remove(item)) + removedItems.Add(item); + } + + if (removedItems.Count > 0) + { + RaiseChangeNotificationEvents( + NotifyCollectionChangedAction.Remove, + removedItems); + } + } + + /// + /// 移除指定范围的元素 + /// + public void RemoveRange(int index, int count) + { + if (index < 0) throw new ArgumentOutOfRangeException(nameof(index)); + if (count < 0) throw new ArgumentOutOfRangeException(nameof(count)); + if (index + count > Count) throw new ArgumentOutOfRangeException(nameof(index)); + + if (count == 0) return; + + if (count == 1) + { + RemoveItem(index); + return; + } + + CheckReentrancy(); + + var items = (List)Items; + var removedItems = items.GetRange(index, count); + items.RemoveRange(index, count); + + RaiseChangeNotificationEvents( + NotifyCollectionChangedAction.Remove, + removedItems, + index); + } + + #endregion + + #region Replace + + /// + /// 清空集合并替换为单个元素 + /// + public void Replace(T item) => ReplaceRange([item]); + + /// + /// 清空集合并替换为指定集合 + /// + public void ReplaceRange(IEnumerable collection) + { + if (collection == null) throw new ArgumentNullException(nameof(collection)); + + var items = collection as IList ?? collection.ToList(); + + if (Count == 0 && items.Count == 0) return; + + CheckReentrancy(); + + Items.Clear(); + + if (items.Count > 0) + { + var itemsList = (List)Items; + itemsList.AddRange(items); + } + + RaiseChangeNotificationEvents(NotifyCollectionChangedAction.Reset); + } + + #endregion + + #region RemoveAll (Bonus) + + /// + /// 移除所有满足条件的元素 + /// + /// 移除的元素数量 + public int RemoveAll(Predicate match) + { + if (match == null) throw new ArgumentNullException(nameof(match)); + if (Count == 0) return 0; + + CheckReentrancy(); + + var removedItems = new List(); + + // 从后向前遍历,避免索引问题 + for (int i = Count - 1; i >= 0; i--) + { + if (match(Items[i])) + { + removedItems.Insert(0, Items[i]); + Items.RemoveAt(i); + } + } + + if (removedItems.Count > 0) + { + RaiseChangeNotificationEvents(NotifyCollectionChangedAction.Reset); + } + + return removedItems.Count; + } + + #endregion + + #region Helper Methods + + /// + /// 触发属性和集合变更通知 + /// + private void RaiseChangeNotificationEvents( + NotifyCollectionChangedAction action, + List? changedItems = null, + int startingIndex = -1) + { + OnPropertyChanged(new PropertyChangedEventArgs(nameof(Count))); + OnPropertyChanged(new PropertyChangedEventArgs("Item[]")); + + if (changedItems == null) + { + OnCollectionChanged(new NotifyCollectionChangedEventArgs(action)); + } + else + { + OnCollectionChanged(new NotifyCollectionChangedEventArgs( + action, + changedItems: changedItems, + startingIndex: startingIndex)); + } + } + + #endregion +} diff --git a/src/Common/KeyAsio.Common/RentedArray.cs b/src/Common/KeyAsio.Common/RentedArray.cs new file mode 100644 index 00000000..748a0008 --- /dev/null +++ b/src/Common/KeyAsio.Common/RentedArray.cs @@ -0,0 +1,37 @@ +using System.Buffers; + +namespace KeyAsio.Common; + +/// +/// Represents a rented array from an that returns itself to the pool when disposed. +/// +/// The type of elements in the array. +public readonly struct RentedArray : IDisposable +{ + /// + /// Gets the rented array instance. + /// + /// The rented array containing elements of type . + public T[] Array { get; } + + private readonly ArrayPool _pool; + + /// + /// Initializes a new instance of the struct. + /// + /// The array pool to rent from. + /// The minimum required length of the array. + /// + /// The actual length of the array may be greater than . + /// + public RentedArray(ArrayPool pool, int minimumLength) + { + _pool = pool; + Array = _pool.Rent(minimumLength); + } + + /// + /// Returns the array to its originating pool. + /// + public void Dispose() => _pool.Return(Array); +} \ No newline at end of file diff --git a/src/Common/KeyAsio.Common/RuntimeInfo.cs b/src/Common/KeyAsio.Common/RuntimeInfo.cs new file mode 100644 index 00000000..117f3a67 --- /dev/null +++ b/src/Common/KeyAsio.Common/RuntimeInfo.cs @@ -0,0 +1,90 @@ +using System.Diagnostics; +using System.Text; +using Microsoft.Extensions.Logging; + +namespace KeyAsio.Common; + +public class RuntimeInfo +{ + public static bool IsSatori { get; private set; } + + public static void CheckCoreClr(ILogger logger) + { + var appDirectory = AppContext.BaseDirectory; + + var modules = Process.GetCurrentProcess().Modules; + foreach (ProcessModule module in modules) + { + if (!module.ModuleName.Equals("coreclr.dll", StringComparison.OrdinalIgnoreCase)) continue; + + Console.WriteLine($"[CoreCLR Path] {module.FileName}"); + + var moduleDir = Path.GetDirectoryName(module.FileName); + var isLocal = string.Equals(moduleDir, appDirectory.TrimEnd(Path.DirectorySeparatorChar), + StringComparison.OrdinalIgnoreCase); + + if (isLocal) + { + var hasSatori = ContainsStringReverse(module.FileName, "SatoriGC"); + if (hasSatori) + { + logger.LogInformation("[CoreCLR Mode] Local Satori"); + IsSatori = true; + } + else + { + logger.LogInformation("[CoreCLR Mode] Local Original"); + } + } + else + { + logger.LogInformation("[CoreCLR Mode] Shared Original"); + } + } + } + + private static bool ContainsStringReverse(string filePath, string targetStr) + { + if (string.IsNullOrEmpty(targetStr)) return false; + + var targetArray = Encoding.UTF8.GetBytes(targetStr); + var targetSpan = targetArray.AsSpan(); + + const int bufferSize = 4096; + var buffer = new byte[bufferSize]; + + using var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + + var fileLength = fs.Length; + if (fileLength < targetArray.Length) return false; + + var position = fileLength; + + while (position > 0) + { + var readStart = position - bufferSize; + var bytesToRead = bufferSize; + + if (readStart < 0) + { + bytesToRead = (int)position; + readStart = 0; + } + + fs.Seek(readStart, SeekOrigin.Begin); + var readCount = fs.Read(buffer, 0, bytesToRead); + + ReadOnlySpan bufferSpan = buffer.AsSpan(0, readCount); + + if (bufferSpan.IndexOf(targetSpan) >= 0) + { + return true; + } + + if (readStart == 0) break; + position -= (bufferSize - targetArray.Length + 1); + } + + return false; + } +} \ No newline at end of file diff --git a/src/Common/KeyAsio.Configuration/AppSettings.cs b/src/Common/KeyAsio.Configuration/AppSettings.cs new file mode 100644 index 00000000..97939363 --- /dev/null +++ b/src/Common/KeyAsio.Configuration/AppSettings.cs @@ -0,0 +1,202 @@ +using System.ComponentModel; +using KeyAsio.Core.Audio; +using KeyAsio.Core.Audio.SampleProviders.BalancePans; +using KeyAsio.Configuration.Models; +using Milki.Extensions.Configuration; +using Milki.Extensions.MouseKeyHook; + +namespace KeyAsio.Configuration; + +public class AppSettings : IConfigurationBase +{ + public AppSettingsGeneral General { get => field ??= new(); init; } + public AppSettingsInput Input { get => field ??= new(); init; } + public AppSettingsPaths Paths { get => field ??= new(); init; } + public AppSettingsAudio Audio { get => field ??= new(); init; } + public AppSettingsLogging Logging { get => field ??= new(); init; } + public AppSettingsPerformance Performance { get => field ??= new(); init; } + public AppSettingsSync Sync { get => field ??= new(); init; } + public AppSettingsUpdate Update { get => field ??= new(); init; } +} + +public partial class AppSettingsGeneral : INotifyPropertyChanged +{ + [Description("Allow multiple instances of the application to run simultaneously.")] + public bool AllowMultipleInstance { get; set; } + + [Description("Application language.")] + public string? Language { get; set; } + + [Description("Application theme.")] + public AppTheme Theme { get; set; } = AppTheme.System; + + public bool IsFirstRun { get; set; } = true; +} + +public partial class AppSettingsInput : INotifyPropertyChanged +{ + [Description("Use raw input for capturing keys; otherwise uses low‑level keyboard hook. " + + "Switch only if you encounter issues.")] + public bool UseRawInput { get; set; } = true; + + [Description("Trigger keys for standard mode. Refer to https://docs.microsoft.com/en-us/dotnet/api/System.Windows.Forms.Keys.")] + public List OsuKeys { get; set; } = [HookKeys.Z, HookKeys.X]; + + [Description("Trigger keys for taiko mode.")] + public List TaikoKeys { get; set; } = [HookKeys.Z, HookKeys.X, HookKeys.C, HookKeys.V]; + + [Description("Trigger keys for catch mode.")] + public List CatchKeys { get; set; } = [HookKeys.Shift, HookKeys.Left, HookKeys.Right]; + + [Description("Trigger keys for mania mode (4k-10k). Key is key count, Value is list of keys.")] + public Dictionary> ManiaKeys { get; set; } = new() + { + [4] = [HookKeys.D, HookKeys.F, HookKeys.J, HookKeys.K], + [5] = [HookKeys.D, HookKeys.F, HookKeys.Space, HookKeys.J, HookKeys.K], + [6] = [HookKeys.S, HookKeys.D, HookKeys.F, HookKeys.J, HookKeys.K, HookKeys.L], + [7] = [HookKeys.S, HookKeys.D, HookKeys.F, HookKeys.Space, HookKeys.J, HookKeys.K, HookKeys.L], + [8] = [HookKeys.A, HookKeys.S, HookKeys.D, HookKeys.F, HookKeys.J, HookKeys.K, HookKeys.L, HookKeys.OemSemicolon], + [9] = [HookKeys.A, HookKeys.S, HookKeys.D, HookKeys.F, HookKeys.Space, HookKeys.J, HookKeys.K, HookKeys.L, HookKeys.OemSemicolon], + [10] = [HookKeys.Q, HookKeys.W, HookKeys.E, HookKeys.R, HookKeys.V, HookKeys.N, HookKeys.U, HookKeys.I, HookKeys.O, HookKeys.P] + }; +} + +public partial class AppSettingsPaths : INotifyPropertyChanged +{ + [Description("osu! folder. Usually auto-detected.")] + public string? OsuFolderPath { get; set; } = ""; + + [Description("Skin used when sync mode is enabled.")] + public string? SelectedSkinName { get; set; } + + [Description("Allow automatic loading of skins from osu! folder.")] + public bool AllowAutoLoadSkins { get; set; } + + [Description("Last synced game client type (Stable or Lazer).")] + public GameClientType ClientType { get; set; } = GameClientType.Stable; +} + +public partial class AppSettingsAudio : INotifyPropertyChanged +{ + [Description("Output sample rate (adjustable in GUI).")] + public int SampleRate { get; set; } = 48000; + + [Description("Playback device configuration (configure in GUI).")] + public DeviceDescription? PlaybackDevice { get; set; } + + [Description("Master volume. Range: 0–150. " + + "For values above 100, consider disabling the Limiter to avoid aggressive compression.")] + public int MasterVolume { get; set; } = 50; + + [Description("Music track volume.")] + public int MusicVolume { get; set; } = 100; + + [Description("Effect track volume.")] + public int EffectVolume { get; set; } = 100; + + [Description("Extend the maximum volume limit to 150%.")] + public bool EnableExtendedVolume { get; set; } +} + +public partial class AppSettingsLogging : INotifyPropertyChanged +{ + [Description("Enable console window for logs.")] + public bool EnableDebugConsole { get; set; } + + [Description("Enable error/bug reporting to developer.")] + public bool? EnableErrorReporting { get; set; } + + public string? PlayerBase64 { get; set; } +} + +public partial class AppSettingsPerformance : INotifyPropertyChanged +{ + [Description("Accelerates processing using AVX-512. " + + "Disable on older Intel CPUs (pre-11th Gen) to avoid clock speed throttling.")] + public bool EnableAvx512 { get; set; } = true; +} + +public partial class AppSettingsSync : INotifyPropertyChanged +{ + [Description("Enable memory scanning and correct hitsound playback.")] + public bool EnableSync { get; set; } = true; + + [Description("[Experimental] Enable music‑related functions.")] + public bool EnableMixSync { get; set; } + + [Description("Enable RTSS on-screen display monitoring for SyncSessionContext data.")] + public bool EnableRtssMonitoring { get; set; } + + public AppSettingsSyncScanning Scanning { get => field ??= new(); init; } + public AppSettingsSyncPlayback Playback { get => field ??= new(); init; } + public AppSettingsSyncFilters Filters { get => field ??= new(); init; } +} + +public partial class AppSettingsSyncScanning : INotifyPropertyChanged +{ + [Description("Lower values update generic fields more promptly. " + + "Intended for delay-insensitive fields; increase to reduce CPU usage.")] + public int GeneralScanInterval { get; set; } = 50; + + [Description("Lower values update timing fields more promptly. " + + "Intended for delay‑sensitive fields; keep as low as possible. " + + "Increase if audio cutting occurs.")] + public int TimingScanInterval { get; set; } = 2; +} + +public partial class AppSettingsSyncPlayback : INotifyPropertyChanged +{ + [Description("Slider‑tail playback behavior. " + + "Normal: always play; KeepReverse: play only on multi‑reverse sliders; Ignore: never play.")] + public SliderTailPlaybackBehavior TailPlaybackBehavior { get; set; } = SliderTailPlaybackBehavior.Normal; + + [Description("Force use of nightcore beats.")] + public bool NightcoreBeats { get; set; } + + [Description("Prevents clipping when hitsounds stack. " + + "'Polynomial' mode is recommended; disable for raw audio. " + + "Note: 'Master' mode increases CPU usage.")] + public LimiterType LimiterType { get; set; } = LimiterType.Master; + + [Description("Stereo balance processing mode.")] + public BalanceMode BalanceMode { get; set; } = BalanceMode.MidSide; + + [Description("Balance factor.")] + public float BalanceFactor { get; set; } = 0.3333333f; +} + +public partial class AppSettingsSyncFilters : INotifyPropertyChanged +{ + [Description("Ignore beatmap hitsounds and use user skin instead.")] + public bool DisableBeatmapHitsounds { get; set; } + + [Description("Ignore beatmap storyboard samples.")] + public bool DisableStoryboardSamples { get; set; } + + [Description("Ignore slider ticks and slides.")] + public bool DisableSliderTicksAndSlides { get; set; } + + [Description("Ignore combo break sound.")] + public bool DisableComboBreakSfx { get; set; } + + [Description("Ignore beatmap line volume changes.")] + public bool IgnoreLineVolumes { get; set; } +} + +public partial class AppSettingsUpdate : INotifyPropertyChanged +{ + public string? SkipVersion { get; set; } + + [Description("Update channel.")] + public UpdateChannel Channel { get; set; } = UpdateChannel.Stable; +} + +public enum UpdateChannel +{ + [Description("UpdateChannel_Stable")] + Stable = 0, + [Description("UpdateChannel_Beta")] + Beta = 1, + // [Description("UpdateChannel_Alpha")] + // Alpha = 2 +} diff --git a/src/Common/KeyAsio.Configuration/FodyWeavers.xml b/src/Common/KeyAsio.Configuration/FodyWeavers.xml new file mode 100644 index 00000000..d5abfed8 --- /dev/null +++ b/src/Common/KeyAsio.Configuration/FodyWeavers.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/Common/KeyAsio.Configuration/IAppSettingsPersistence.cs b/src/Common/KeyAsio.Configuration/IAppSettingsPersistence.cs new file mode 100644 index 00000000..f03e4333 --- /dev/null +++ b/src/Common/KeyAsio.Configuration/IAppSettingsPersistence.cs @@ -0,0 +1,20 @@ +using Milki.Extensions.Configuration; + +namespace KeyAsio.Configuration; + +public interface IAppSettingsPersistence +{ + void Save(); +} + +public sealed class AppSettingsPersistence : IAppSettingsPersistence +{ + private readonly AppSettings _settings; + + public AppSettingsPersistence(AppSettings settings) + { + _settings = settings; + } + + public void Save() => _settings.Save(); +} diff --git a/src/Common/KeyAsio.Configuration/KeyAsio.Configuration.csproj b/src/Common/KeyAsio.Configuration/KeyAsio.Configuration.csproj new file mode 100644 index 00000000..db08464c --- /dev/null +++ b/src/Common/KeyAsio.Configuration/KeyAsio.Configuration.csproj @@ -0,0 +1,27 @@ + + + net10.0 + enable + enable + true + + + + + + all + compile; runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + compile; runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + diff --git a/src/Common/KeyAsio.Configuration/LegacyAppSettings.cs b/src/Common/KeyAsio.Configuration/LegacyAppSettings.cs new file mode 100644 index 00000000..2752b052 --- /dev/null +++ b/src/Common/KeyAsio.Configuration/LegacyAppSettings.cs @@ -0,0 +1,94 @@ +using System.ComponentModel; +using KeyAsio.Core.Audio; +using KeyAsio.Configuration.Models; +using Milki.Extensions.Configuration; +using Milki.Extensions.MouseKeyHook; + +namespace KeyAsio.Configuration; + +public sealed class LegacyAppSettings : ViewModelBase +{ + private List _keys = [HookKeys.Z, HookKeys.X]; + + private RealtimeOptions? _realtimeOptions; + private int _volume = 100; + private bool _sendLogsToDeveloper = true; + private string? _osuFolder = ""; + private bool _debugging = false; + + public bool UseRawInput { get; set; } = true; + + [Description("Triggering keys. See https://docs.microsoft.com/en-us/dotnet/api/system.windows.forms.keys?view=windowsdesktop-6.0 for more inforamtion.")] + public List Keys + { + get => _keys; + set => SetField(ref _keys, value); + } + + [Description("Default hitsound path (relative or absolute) for playing.")] + public string HitsoundPath { get; set; } = "click.wav"; + + [Description("Osu's folder. For the most of time this value is auto detected.")] + public string? OsuFolder + { + get => _osuFolder; + set => SetField(ref _osuFolder, value); + } + + [Description("The skin when `RealtimeMode` is true.")] + public string SelectedSkin { get; set; } = ""; + + [Description("If true, the software will create a console window to show logs.")] + public bool Debugging + { + get => _debugging; + set => SetField(ref _debugging, value); + } + + [Description("Device's sample rate (allow adjusting in GUI).")] + public int SampleRate { get; set; } = 48000; + //public int Bits { get; set; } = 16; + //public int Channels { get; set; } = 2; + + [Description("Device configuration (Recommend to configure in GUI).")] + public DeviceDescription? Device { get; set; } + + [Description("Enable limiter to prevent clipping or distortion; disable for an unprocessed signal. Especially effective when master volume is high.")] + public bool EnableLimiter { get; set; } = true; + + [Description("Configured device volume, range: 0~150")] + public float Volume + { + get => _volume; + set + { + if (value > 150) value = 150; + else if (value < 0) value = 0; + else if (value < 1) value *= 100; // Convert from old version + _volume = (int)Math.Round(value); + } + } + + [Description("Set whether the software can report logs/bugs to developer.")] + public bool SendLogsToDeveloper + { + get => _sendLogsToDeveloper; + set => SetField(ref _sendLogsToDeveloper, value); + } + + public bool SendLogsToDeveloperConfirmed { get; set; } + + public RealtimeOptions RealtimeOptions + { + get => _realtimeOptions ??= new(); + set => _realtimeOptions = value; + } + + public string PlayerBase64 { get; set; } = ""; + public int AudioCachingThreads { get; set; } = 2; + + public void Save() + { + ConfigurationFactory.Save(this); + } +} \ No newline at end of file diff --git a/src/Common/KeyAsio.Configuration/Models/AppTheme.cs b/src/Common/KeyAsio.Configuration/Models/AppTheme.cs new file mode 100644 index 00000000..985a2145 --- /dev/null +++ b/src/Common/KeyAsio.Configuration/Models/AppTheme.cs @@ -0,0 +1,13 @@ +using System.ComponentModel; + +namespace KeyAsio.Configuration.Models; + +public enum AppTheme +{ + [Description("Settings_FollowSystem")] + System, + [Description("Settings_Theme_Light")] + Light, + [Description("Settings_Theme_Dark")] + Dark +} \ No newline at end of file diff --git a/src/Common/KeyAsio.Configuration/Models/BindKeys.cs b/src/Common/KeyAsio.Configuration/Models/BindKeys.cs new file mode 100644 index 00000000..27538fc4 --- /dev/null +++ b/src/Common/KeyAsio.Configuration/Models/BindKeys.cs @@ -0,0 +1,85 @@ +using System.Text; +using Milki.Extensions.MouseKeyHook; + +namespace KeyAsio.Configuration.Models; + +public class BindKeys +{ + private BindKeys(HookModifierKeys modifierKeys, HookKeys? keys) + { + ModifierKeys = modifierKeys; + Keys = keys; + } + + public HookModifierKeys ModifierKeys { get; } + public HookKeys? Keys { get; } + + public override string ToString() + { + var sb = new StringBuilder(); + if (ModifierKeys.HasFlag(HookModifierKeys.Control)) + { + sb.Append("Ctrl"); + } + + if (ModifierKeys.HasFlag(HookModifierKeys.Shift)) + { + if (sb.Length > 0) + { + sb.Append('+'); + } + + sb.Append("Shift"); + } + + if (ModifierKeys.HasFlag(HookModifierKeys.Alt)) + { + if (sb.Length > 0) + { + sb.Append('+'); + } + + sb.Append("Alt"); + } + + if (Keys != null) + { + if (sb.Length > 0) + { + sb.Append('+'); + } + + sb.Append(Keys.ToString()); + } + + return sb.ToString(); + } + + public static BindKeys Parse(string str) + { + var modifierKeys = HookModifierKeys.None; + HookKeys? keys = null; + var split = str.Split('+', StringSplitOptions.RemoveEmptyEntries); + foreach (var s in split) + { + if (s.Equals("Ctrl", StringComparison.OrdinalIgnoreCase)) + { + modifierKeys |= HookModifierKeys.Control; + } + else if (s.Equals("Shift", StringComparison.OrdinalIgnoreCase)) + { + modifierKeys |= HookModifierKeys.Shift; + } + else if (s.Equals("Alt", StringComparison.OrdinalIgnoreCase)) + { + modifierKeys |= HookModifierKeys.Alt; + } + else if (keys == null) + { + keys = Enum.Parse(s); + } + } + + return new BindKeys(modifierKeys, keys); + } +} \ No newline at end of file diff --git a/src/Common/KeyAsio.Configuration/Models/GameClientType.cs b/src/Common/KeyAsio.Configuration/Models/GameClientType.cs new file mode 100644 index 00000000..721ab407 --- /dev/null +++ b/src/Common/KeyAsio.Configuration/Models/GameClientType.cs @@ -0,0 +1,7 @@ +namespace KeyAsio.Configuration.Models; + +public enum GameClientType +{ + Stable, + Lazer +} diff --git a/src/Common/KeyAsio.Configuration/Models/RealtimeOptions.cs b/src/Common/KeyAsio.Configuration/Models/RealtimeOptions.cs new file mode 100644 index 00000000..b9298bdc --- /dev/null +++ b/src/Common/KeyAsio.Configuration/Models/RealtimeOptions.cs @@ -0,0 +1,141 @@ +using System.ComponentModel; +using YamlDotNet.Serialization; + +namespace KeyAsio.Configuration.Models; + +public class RealtimeOptions : ViewModelBase +{ + private bool _realtimeMode = true; + private int _realtimeModeAudioOffset; + private bool _ignoreBeatmapHitsound; + private bool _ignoreStoryboardSamples; + private bool _ignoreSliderTicksAndSlides; + private SliderTailPlaybackBehavior _sliderTailPlaybackBehavior; + private float _balanceFactor = 0.3f; + private bool _ignoreComboBreak; + private bool _ignoreLineVolumes; + private bool _enableMusicFunctions; + private int _musicTrackVolume = 100; + private int _effectTrackVolume = 100; + private bool _forceNightcoreBeats; + private int _generalScanInterval = 50; + private int _timingScanInterval = 15; + + [Description("If the set value is lower, the generic fields will be updated more promptly.\r\n" + + "This property is targeted at delay-insensitive fields and can be appropriately increased to reduce CPU usage.")] + public int GeneralScanInterval + { + get => _generalScanInterval; + set => SetField(ref _generalScanInterval, value); + } + + [Description("If the set value is lower, the timing fields will be updated more promptly.\r\n" + + "This property is targeted at delay-sensitive field and best kept as low as possible.\r\n" + + "If you experience audio cutting issues, please increase the value appropriately.")] + public int TimingScanInterval + { + get => _timingScanInterval; + set => SetField(ref _timingScanInterval, value); + } + + [Description("If enabled, the software will perform memory scanning and play the right hitsounds of beatmaps.")] + public bool RealtimeMode + { + get => _realtimeMode; + set => SetField(ref _realtimeMode, value); + } + + [Description("[EXPERIMENTAL] If enabled, the software will enable music related functions.")] + public bool EnableMusicFunctions + { + get => _enableMusicFunctions; + set => SetField(ref _enableMusicFunctions, value); + } + + [YamlIgnore] + [Description("The offset when `RealtimeMode` is true (allow adjusting in GUI).")] + public int RealtimeModeAudioOffset + { + get => _realtimeModeAudioOffset; + set => SetField(ref _realtimeModeAudioOffset, value); + } + + [Description("Ignore beatmap's hitsound and force using user skin instead.")] + public bool IgnoreBeatmapHitsound + { + get => _ignoreBeatmapHitsound; + set => SetField(ref _ignoreBeatmapHitsound, value); + } + + [YamlIgnore] + public BindKeys? IgnoreBeatmapHitsoundBindKey { get; set; } + + [Description("Ignore beatmap's storyboard samples.")] + public bool IgnoreStoryboardSamples + { + get => _ignoreStoryboardSamples; + set => SetField(ref _ignoreStoryboardSamples, value); + } + + [YamlIgnore] + public BindKeys? IgnoreStoryboardSamplesBindKey { get; set; } + + [Description("Ignore slider's ticks and slides.")] + public bool IgnoreSliderTicksAndSlides + { + get => _ignoreSliderTicksAndSlides; + set => SetField(ref _ignoreSliderTicksAndSlides, value); + } + + [YamlIgnore] + public BindKeys? IgnoreSliderTicksAndSlidesBindKey { get; set; } + + [Description("Slider tail's playback behavior. Normal: Force to play slider tail's sounds; KeepReverse: Play only if a slider with multiple reverses; Ignore: Ignore slider tail's sounds.")] + public SliderTailPlaybackBehavior SliderTailPlaybackBehavior + { + get => _sliderTailPlaybackBehavior; + set => SetField(ref _sliderTailPlaybackBehavior, value); + } + + [Description("Balance factor.")] + public float BalanceFactor + { + get => _balanceFactor; + set => SetField(ref _balanceFactor, value); + } + + [Description("Ignore combo break sound.")] + public bool IgnoreComboBreak + { + get => _ignoreComboBreak; + set => SetField(ref _ignoreComboBreak, value); + } + + [Description("Ignore combo break sound.")] + public bool IgnoreLineVolumes + { + get => _ignoreLineVolumes; + set => SetField(ref _ignoreLineVolumes, value); + } + + [Description("Music track volume.")] + public int MusicTrackVolume + { + get => _musicTrackVolume; + set => SetField(ref _musicTrackVolume, value); + } + + [Description("Effect track volume.")] + public int EffectTrackVolume + { + get => _effectTrackVolume; + set => SetField(ref _effectTrackVolume, value); + } + + [Description("Force to use nightcore beats.")] + public bool ForceNightcoreBeats + { + get => _forceNightcoreBeats; + set => SetField(ref _forceNightcoreBeats, value); + } +} \ No newline at end of file diff --git a/src/Common/KeyAsio.Configuration/Models/SliderTailPlaybackBehavior.cs b/src/Common/KeyAsio.Configuration/Models/SliderTailPlaybackBehavior.cs new file mode 100644 index 00000000..244ed75a --- /dev/null +++ b/src/Common/KeyAsio.Configuration/Models/SliderTailPlaybackBehavior.cs @@ -0,0 +1,6 @@ +namespace KeyAsio.Configuration.Models; + +public enum SliderTailPlaybackBehavior +{ + Normal, KeepReverse, Ignore +} \ No newline at end of file diff --git a/src/Common/KeyAsio.Configuration/Models/ViewModelBase.cs b/src/Common/KeyAsio.Configuration/Models/ViewModelBase.cs new file mode 100644 index 00000000..bced92dd --- /dev/null +++ b/src/Common/KeyAsio.Configuration/Models/ViewModelBase.cs @@ -0,0 +1,22 @@ +using System.ComponentModel; +using System.Runtime.CompilerServices; + +namespace KeyAsio.Configuration.Models; + +public abstract class ViewModelBase : INotifyPropertyChanged +{ + public event PropertyChangedEventHandler? PropertyChanged; + + protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + + protected bool SetField(ref T field, T value, [CallerMemberName] string? propertyName = null) + { + if (EqualityComparer.Default.Equals(field, value)) return false; + field = value; + OnPropertyChanged(propertyName); + return true; + } +} \ No newline at end of file diff --git a/src/Common/KeyAsio.Configuration/Serialization/BindKeysConverter.cs b/src/Common/KeyAsio.Configuration/Serialization/BindKeysConverter.cs new file mode 100644 index 00000000..00b7dea6 --- /dev/null +++ b/src/Common/KeyAsio.Configuration/Serialization/BindKeysConverter.cs @@ -0,0 +1,28 @@ +using KeyAsio.Configuration.Models; +using YamlDotNet.Core; +using YamlDotNet.Core.Events; +using YamlDotNet.Serialization; + +namespace KeyAsio.Configuration.Serialization; + +public class BindKeysConverter : IYamlTypeConverter +{ + private static readonly Type s_type = typeof(BindKeys); + public bool Accepts(Type type) + { + if (type == s_type) return true; + return false; + } + + public object ReadYaml(IParser parser, Type type, ObjectDeserializer rootDeserializer) + { + var s = parser.Consume(); + var convertFrom = BindKeys.Parse(s.Value); + return convertFrom; + } + + public void WriteYaml(IEmitter emitter, object? value, Type type, ObjectSerializer serializer) + { + emitter.Emit(new Scalar(value?.ToString() ?? "")); + } +} \ No newline at end of file diff --git a/src/Common/KeyAsio.Configuration/Serialization/DescriptionCommentsObjectGraphVisitor.cs b/src/Common/KeyAsio.Configuration/Serialization/DescriptionCommentsObjectGraphVisitor.cs new file mode 100644 index 00000000..99ec8871 --- /dev/null +++ b/src/Common/KeyAsio.Configuration/Serialization/DescriptionCommentsObjectGraphVisitor.cs @@ -0,0 +1,25 @@ +using System.ComponentModel; +using YamlDotNet.Core; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.ObjectGraphVisitors; + +namespace KeyAsio.Configuration.Serialization; + +public class DescriptionCommentsObjectGraphVisitor : ChainedObjectGraphVisitor +{ + public DescriptionCommentsObjectGraphVisitor(IObjectGraphVisitor nextVisitor) + : base(nextVisitor) + { + } + + public override bool EnterMapping(IPropertyDescriptor key, IObjectDescriptor value, IEmitter context, ObjectSerializer serializer) + { + var attr = key.GetCustomAttribute(); + if (attr is { Description: not null }) + { + context.Emit(new YamlDotNet.Core.Events.Comment(attr.Description, false)); + } + + return base.EnterMapping(key, value, context, serializer); + } +} \ No newline at end of file diff --git a/src/Common/KeyAsio.Configuration/Serialization/MyYamlConfigurationConverter.cs b/src/Common/KeyAsio.Configuration/Serialization/MyYamlConfigurationConverter.cs new file mode 100644 index 00000000..6c1ca40d --- /dev/null +++ b/src/Common/KeyAsio.Configuration/Serialization/MyYamlConfigurationConverter.cs @@ -0,0 +1,208 @@ +using System.Diagnostics.CodeAnalysis; +using KeyAsio.Core.Audio; +using Milki.Extensions.Configuration.Converters; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; + +namespace KeyAsio.Configuration.Serialization; + +public class MyYamlConfigurationConverter : YamlConfigurationConverter +{ + private MyYamlConfigurationConverter() + { + } + + public static MyYamlConfigurationConverter Instance { get; } = new(); + + protected override void ConfigSerializeBuilder(SerializerBuilder builder) + { + base.ConfigSerializeBuilder(builder); + builder.WithTypeConverter(new BindKeysConverter()); + } + + protected override void ConfigDeserializeBuilder(DeserializerBuilder builder) + { + base.ConfigDeserializeBuilder(builder); + builder.WithTypeConverter(new BindKeysConverter()); + } + + public override object DeserializeSettings(string content, + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] Type type) + { + if (type == typeof(AppSettings)) + { + if (!content.StartsWith("default:") && !LooksLikeNewYaml(content)) + { + return ToYaml((LegacyAppSettings)base.DeserializeSettings(content, typeof(LegacyAppSettings))); + } + + var builder = new DeserializerBuilder() + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .IgnoreUnmatchedProperties() + .IgnoreFields() + .WithTypeConverter(new BindKeysConverter()); + var deserializer = builder.Build(); + var yamlModel = deserializer.Deserialize(content); + return yamlModel; + } + + return base.DeserializeSettings(content, type); + } + + private static bool LooksLikeNewYaml(string content) + { + var tokens = new[] { "input", "paths", "audio", "logging", "performance", "sync" }; + using var sr = new StringReader(content); + string? line; + while ((line = sr.ReadLine()) != null) + { + if (string.IsNullOrWhiteSpace(line)) continue; + var span = line.AsSpan().TrimStart(); + foreach (var t in tokens) + { + if (span.StartsWith(t, StringComparison.OrdinalIgnoreCase)) + { + var idx = span.IndexOf(':'); + if (idx == t.Length) return true; + } + } + } + return false; + } + + public override string SerializeSettings(object obj) + { + if (obj is AppSettings yamlModel) + { + var builder = new SerializerBuilder() + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .WithEmissionPhaseObjectGraphVisitor(args => new DescriptionCommentsObjectGraphVisitor(args.InnerVisitor)) + .DisableAliases() + .IgnoreFields() + .WithTypeConverter(new BindKeysConverter()); + var converter = builder.Build(); + var content = converter.Serialize(yamlModel); + return content; + } + + return base.SerializeSettings(obj); + } + + private static AppSettings ToYaml(LegacyAppSettings s) + { + return new AppSettings + { + Input = new AppSettingsInput + { + UseRawInput = s.UseRawInput, + OsuKeys = s.Keys + }, + Paths = new AppSettingsPaths + { + OsuFolderPath = s.OsuFolder, + SelectedSkinName = s.SelectedSkin + }, + Audio = new AppSettingsAudio + { + SampleRate = s.SampleRate, + PlaybackDevice = s.Device, + MasterVolume = (int)s.Volume, + MusicVolume = s.RealtimeOptions.MusicTrackVolume, + EffectVolume = s.RealtimeOptions.EffectTrackVolume + }, + Logging = new AppSettingsLogging + { + EnableDebugConsole = s.Debugging, + EnableErrorReporting = s.SendLogsToDeveloperConfirmed ? s.SendLogsToDeveloper : null, + PlayerBase64 = s.PlayerBase64 + }, + Performance = new AppSettingsPerformance + { + }, + Sync = new AppSettingsSync + { + EnableSync = s.RealtimeOptions.RealtimeMode, + EnableMixSync = s.RealtimeOptions.EnableMusicFunctions, + Scanning = new AppSettingsSyncScanning + { + GeneralScanInterval = s.RealtimeOptions.GeneralScanInterval, + TimingScanInterval = s.RealtimeOptions.TimingScanInterval + }, + Playback = new AppSettingsSyncPlayback + { + TailPlaybackBehavior = s.RealtimeOptions.SliderTailPlaybackBehavior, + NightcoreBeats = s.RealtimeOptions.ForceNightcoreBeats, + LimiterType = s.EnableLimiter ? LimiterType.Master : LimiterType.Off, + BalanceFactor = s.RealtimeOptions.BalanceFactor, + }, + Filters = new AppSettingsSyncFilters + { + DisableBeatmapHitsounds = s.RealtimeOptions.IgnoreBeatmapHitsound, + DisableStoryboardSamples = s.RealtimeOptions.IgnoreStoryboardSamples, + DisableSliderTicksAndSlides = s.RealtimeOptions.IgnoreSliderTicksAndSlides, + DisableComboBreakSfx = s.RealtimeOptions.IgnoreComboBreak, + IgnoreLineVolumes = s.RealtimeOptions.IgnoreLineVolumes + } + } + }; + } + + private static LegacyAppSettings FromYaml(AppSettings y) + { + var s = new LegacyAppSettings(); + if (y.Input != null) + { + s.UseRawInput = y.Input.UseRawInput; + if (y.Input.OsuKeys != null) s.Keys = y.Input.OsuKeys.ToList(); + } + if (y.Paths != null) + { + s.OsuFolder = y.Paths.OsuFolderPath; + s.SelectedSkin = y.Paths.SelectedSkinName ?? s.SelectedSkin; + } + if (y.Audio != null) + { + s.SampleRate = y.Audio.SampleRate; + s.Device = y.Audio.PlaybackDevice; + s.Volume = y.Audio.MasterVolume; + s.RealtimeOptions.MusicTrackVolume = y.Audio.MusicVolume; + s.RealtimeOptions.EffectTrackVolume = y.Audio.EffectVolume; + } + if (y.Logging != null) + { + s.Debugging = y.Logging.EnableDebugConsole; + s.SendLogsToDeveloper = y.Logging.EnableErrorReporting ?? true; + s.SendLogsToDeveloperConfirmed = y.Logging.EnableErrorReporting.HasValue; + s.PlayerBase64 = y.Logging.PlayerBase64 ?? ""; + } + if (y.Performance != null) + { + } + if (y.Sync != null) + { + s.RealtimeOptions.RealtimeMode = y.Sync.EnableSync; + if (y.Sync.Scanning != null) + { + s.RealtimeOptions.GeneralScanInterval = y.Sync.Scanning.GeneralScanInterval; + s.RealtimeOptions.TimingScanInterval = y.Sync.Scanning.TimingScanInterval; + } + if (y.Sync.Playback != null) + { + s.RealtimeOptions.SliderTailPlaybackBehavior = y.Sync.Playback.TailPlaybackBehavior; + s.RealtimeOptions.ForceNightcoreBeats = y.Sync.Playback.NightcoreBeats; + s.EnableLimiter = y.Sync.Playback.LimiterType != LimiterType.Off; + s.RealtimeOptions.BalanceFactor = y.Sync.Playback.BalanceFactor; + s.RealtimeOptions.EnableMusicFunctions = y.Sync.EnableMixSync; + } + if (y.Sync.Filters != null) + { + s.RealtimeOptions.IgnoreBeatmapHitsound = y.Sync.Filters.DisableBeatmapHitsounds; + s.RealtimeOptions.IgnoreStoryboardSamples = y.Sync.Filters.DisableStoryboardSamples; + s.RealtimeOptions.IgnoreSliderTicksAndSlides = y.Sync.Filters.DisableSliderTicksAndSlides; + s.RealtimeOptions.IgnoreComboBreak = y.Sync.Filters.DisableComboBreakSfx; + s.RealtimeOptions.IgnoreLineVolumes = y.Sync.Filters.IgnoreLineVolumes; + } + } + return s; + } +} \ No newline at end of file diff --git a/src/Common/KeyAsio.Plugins.Contracts/HandleResult.cs b/src/Common/KeyAsio.Plugins.Contracts/HandleResult.cs new file mode 100644 index 00000000..322a150e --- /dev/null +++ b/src/Common/KeyAsio.Plugins.Contracts/HandleResult.cs @@ -0,0 +1,28 @@ +namespace KeyAsio.Plugins.Contracts; + +/// +/// Result of a game state handler operation, controlling the propagation of the event. +/// +[Flags] +public enum HandleResult +{ + /// + /// Continue to execute lower priority handlers and the base logic. + /// + Continue = 0, + + /// + /// Stop executing lower priority handlers. + /// + BlockLowerPriority = 1 << 0, + + /// + /// Block the base logic (default internal logic) from executing. + /// + BlockBaseLogic = 1 << 1, + + /// + /// Stop executing both lower priority handlers and the base logic. + /// + BlockAll = BlockLowerPriority | BlockBaseLogic +} \ No newline at end of file diff --git a/src/Common/KeyAsio.Plugins.Contracts/Host/IGameplaySession.cs b/src/Common/KeyAsio.Plugins.Contracts/Host/IGameplaySession.cs new file mode 100644 index 00000000..78cabcde --- /dev/null +++ b/src/Common/KeyAsio.Plugins.Contracts/Host/IGameplaySession.cs @@ -0,0 +1,32 @@ +using System.Diagnostics.CodeAnalysis; +using Coosu.Beatmap; +using NAudio.Wave; + +namespace KeyAsio.Plugins.Contracts; + +/// +/// Read-only view of the host gameplay session and its prepared audio resources. +/// +public interface IGameplaySession +{ + OsuFile? Beatmap { get; } + + string? BeatmapFolder { get; } + + string? AudioFilename { get; } + + event Action? SessionStopped; + + bool TryResolveResource(string name, out PluginGameplayResource resource); + + bool TryResolveAudioResource(string fileNameOrNameWithoutExtension, out PluginGameplayResource resource); + + bool TryCreateCachedAudioProvider( + string path, + [NotNullWhen(true)] out ISeekableAudioSampleProvider? sampleProvider); +} + +public sealed record PluginGameplayResource(string Name, string Path) +{ + public Stream OpenRead() => File.Open(Path, FileMode.Open, FileAccess.Read, FileShare.Read); +} diff --git a/src/Common/KeyAsio.Plugins.Contracts/Host/IPluginInteractionService.cs b/src/Common/KeyAsio.Plugins.Contracts/Host/IPluginInteractionService.cs new file mode 100644 index 00000000..7121657d --- /dev/null +++ b/src/Common/KeyAsio.Plugins.Contracts/Host/IPluginInteractionService.cs @@ -0,0 +1,26 @@ +namespace KeyAsio.Plugins.Contracts; + +public enum PluginNotificationKind +{ + Information, + Success, + Warning, + Error +} + +/// +/// Host-owned presentation gateway. Plugins may supply their own view object, but +/// they do not receive the application's dialog manager or dispatcher. +/// +public interface IPluginInteractionService +{ + ValueTask InvokeAsync(Action action); + + void ShowDialog(object content); + + void DismissDialog(); + + void ShowMessage(string title, string content); + + void ShowNotification(string title, string content, PluginNotificationKind kind = PluginNotificationKind.Information); +} diff --git a/src/Common/KeyAsio.Plugins.Contracts/Host/IPluginSettings.cs b/src/Common/KeyAsio.Plugins.Contracts/Host/IPluginSettings.cs new file mode 100644 index 00000000..2adfe906 --- /dev/null +++ b/src/Common/KeyAsio.Plugins.Contracts/Host/IPluginSettings.cs @@ -0,0 +1,15 @@ +namespace KeyAsio.Plugins.Contracts; + +/// +/// Stable, plugin-facing preferences owned and persisted by the host. +/// +public interface IPluginSettings +{ + bool MusicMixingEnabled { get; set; } + + string? SkippedUpdateVersion { get; set; } + + event EventHandler? MusicMixingEnabledChanged; + + void Save(); +} diff --git a/src/Common/KeyAsio.Plugins.Contracts/IAudioEngine.cs b/src/Common/KeyAsio.Plugins.Contracts/IAudioEngine.cs new file mode 100644 index 00000000..b8347ce9 --- /dev/null +++ b/src/Common/KeyAsio.Plugins.Contracts/IAudioEngine.cs @@ -0,0 +1,32 @@ +using NAudio.Wave; + +namespace KeyAsio.Plugins.Contracts; + +/// +/// Narrow host audio capabilities available to plugins. Mixer and decoder +/// implementations remain owned by the host. +/// +public interface IAudioEngine +{ + WaveFormat OutputWaveFormat { get; } + + IPluginAudioFile OpenAudioFile(string path); + + void AddMusicInput(ISampleProvider input); + + void RemoveMusicInput(ISampleProvider input); +} + +public interface IPluginAudioFile : ISampleProvider, IDisposable, IAsyncDisposable +{ + TimeSpan Position { get; set; } + + TimeSpan Duration { get; } +} + +public interface ISeekableAudioSampleProvider : ISampleProvider +{ + string ResourceId { get; } + + TimeSpan Position { get; set; } +} diff --git a/src/Common/KeyAsio.Plugins.Contracts/IGameStateHandler.cs b/src/Common/KeyAsio.Plugins.Contracts/IGameStateHandler.cs new file mode 100644 index 00000000..13869419 --- /dev/null +++ b/src/Common/KeyAsio.Plugins.Contracts/IGameStateHandler.cs @@ -0,0 +1,37 @@ +namespace KeyAsio.Plugins.Contracts; + +/// +/// Game state handler interface +/// +public interface IGameStateHandler +{ + /// + /// Handler priority. Higher values are executed first. + /// + int Priority { get; } + + /// + /// Called when entering the state + /// + /// Result controlling the propagation. + HandleResult HandleEnter(ISyncContext context); + + /// + /// Called when the state updates + /// + /// Result controlling the propagation. + HandleResult HandleTick(ISyncContext context); + + /// + /// Called when exiting the state + /// + /// Result controlling the propagation. + HandleResult HandleExit(ISyncContext context); + + /// + /// Called when the beatmap changes + /// + /// New beatmap information + /// Result controlling the propagation. + HandleResult HandleBeatmapChange(SyncBeatmapInfo beatmap) => HandleResult.Continue; +} \ No newline at end of file diff --git a/src/Common/KeyAsio.Plugins.Contracts/IMusicManagerPlugin.cs b/src/Common/KeyAsio.Plugins.Contracts/IMusicManagerPlugin.cs new file mode 100644 index 00000000..e2d0b167 --- /dev/null +++ b/src/Common/KeyAsio.Plugins.Contracts/IMusicManagerPlugin.cs @@ -0,0 +1,25 @@ +using Coosu.Beatmap; +using KeyAsio.Plugins.Contracts.Sync; +using NAudio.Wave; + +namespace KeyAsio.Plugins.Contracts; + +public interface IMusicManagerPlugin : IPlugin +{ + event EventHandler? OptionStateChanged; + + string OptionName { get; } + string OptionTag { get; } + int OptionPriority { get; } + bool CanEnableOption { get; } + + void StartLowPass(int fadeMilliseconds, int targetFrequency); + void StopCurrentMusic(int fadeMs = 0); + void PauseCurrentMusic(); + void RecoverCurrentMusic(); + void PlaySingleAudioPreview(OsuFile osuFile, string? path, int playTime); + void SetSingleTrackPlayMods(Mods mods); + void SetMainTrackOffsetAndLeadIn(int offset, int leadInMs); + void SyncMainTrackAudio(ISeekableAudioSampleProvider sound, int positionMs); + void ClearMainTrackAudio(); +} diff --git a/src/Common/KeyAsio.Plugins.Contracts/IPlugin.cs b/src/Common/KeyAsio.Plugins.Contracts/IPlugin.cs new file mode 100644 index 00000000..ee533688 --- /dev/null +++ b/src/Common/KeyAsio.Plugins.Contracts/IPlugin.cs @@ -0,0 +1,53 @@ +namespace KeyAsio.Plugins.Contracts; + +/// +/// Base plugin interface, all plugins must implement this interface +/// +public interface IPlugin +{ + /// + /// Plugin unique identifier + /// + string Id { get; } + + /// + /// Plugin display name + /// + string Name { get; } + + /// + /// Plugin version + /// + string Version { get; } + + /// + /// Plugin author + /// + string Author { get; } + + /// + /// Plugin description + /// + string Description { get; } + + /// + /// Initialize the plugin, perform service registration and configuration reading at this stage + /// + /// Plugin context + void Initialize(IPluginContext context); + + /// + /// Start the plugin, perform resource loading and start services at this stage + /// + void Startup(); + + /// + /// Stop the plugin, stop services at this stage + /// + void Shutdown(); + + /// + /// Unload the plugin, release all resources at this stage + /// + void Unload(); +} diff --git a/src/Common/KeyAsio.Plugins.Contracts/IPluginContext.cs b/src/Common/KeyAsio.Plugins.Contracts/IPluginContext.cs new file mode 100644 index 00000000..00230f5f --- /dev/null +++ b/src/Common/KeyAsio.Plugins.Contracts/IPluginContext.cs @@ -0,0 +1,36 @@ +using Microsoft.Extensions.Logging; + +namespace KeyAsio.Plugins.Contracts; + +/// +/// Plugin context, providing core system access capabilities +/// +public interface IPluginContext +{ + string PluginDirectory { get; } + + ILoggerFactory LoggerFactory { get; } + + /// + /// Gets the audio engine access interface + /// + IAudioEngine AudioEngine { get; } + + IPluginSettings Settings { get; } + + IGameplaySession Gameplay { get; } + + IPluginInteractionService Interaction { get; } + + /// + /// Registers a state handler, allowing plugins to take over logic for specific states + /// + /// Game state + /// Handler + void RegisterStateHandler(SyncOsuStatus status, IGameStateHandler handler); + + /// + /// Unregisters a state handler + /// + void UnregisterStateHandler(SyncOsuStatus status); +} diff --git a/src/Common/KeyAsio.Plugins.Contracts/IPluginManager.cs b/src/Common/KeyAsio.Plugins.Contracts/IPluginManager.cs new file mode 100644 index 00000000..d36d923c --- /dev/null +++ b/src/Common/KeyAsio.Plugins.Contracts/IPluginManager.cs @@ -0,0 +1,47 @@ +namespace KeyAsio.Plugins.Contracts; + +/// +/// Plugin manager interface +/// +public interface IPluginManager +{ + /// + /// Gets all loaded plugins + /// + IEnumerable GetAllPlugins(); + + /// + /// Gets a plugin of the specified type + /// + T? GetPlugin() where T : class, IPlugin; + + /// + /// Gets active handlers for the specified state, sorted by priority (Descending) + /// + /// Game state + /// List of handlers + IEnumerable GetActiveHandlers(SyncOsuStatus status); + + /// + /// Loads plugins from the specified directory + /// + /// Plugin directory path + /// Search pattern, default is "*.dll" + /// Search option, default is SearchOption.AllDirectories + void LoadPlugins(string pluginDirectory, string searchPattern = "*.dll", SearchOption searchOption = SearchOption.AllDirectories); + + /// + /// Initializes all plugins + /// + void InitializePlugins(); + + /// + /// Starts all plugins + /// + void StartupPlugins(); + + /// + /// Unloads all plugins + /// + void UnloadPlugins(); +} diff --git a/src/Common/KeyAsio.Plugins.Contracts/ISyncContext.cs b/src/Common/KeyAsio.Plugins.Contracts/ISyncContext.cs new file mode 100644 index 00000000..ac8372f3 --- /dev/null +++ b/src/Common/KeyAsio.Plugins.Contracts/ISyncContext.cs @@ -0,0 +1,49 @@ +namespace KeyAsio.Plugins.Contracts; + +public interface ISyncContext +{ + /// + /// Current play time (ms) + /// + int PlayTime { get; } + + /// + /// Whether started (Gameplay session active) + /// + bool IsStarted { get; } + + /// + /// Current game state + /// + SyncOsuStatus OsuStatus { get; } + + /// + /// Timestamp of last update (Ticks) + /// + long LastUpdateTimestamp { get; } + + /// + /// Current mods (Bitmask) + /// + int PlayMods { get; } + + /// + /// Gameplay judgement statistics. + /// + SyncStatistics Statistics { get; } + + /// + /// Latest hit error stream update. + /// + SyncHitErrors HitErrors { get; } + + /// + /// Current beatmap information + /// + SyncBeatmapInfo? Beatmap { get; } + + /// + /// Whether audio is paused/frozen (predicted time is in micro-regression protection). + /// + bool IsAudioPaused { get; } +} diff --git a/src/Common/KeyAsio.Plugins.Contracts/ISyncPlugin.cs b/src/Common/KeyAsio.Plugins.Contracts/ISyncPlugin.cs new file mode 100644 index 00000000..6e74380a --- /dev/null +++ b/src/Common/KeyAsio.Plugins.Contracts/ISyncPlugin.cs @@ -0,0 +1,34 @@ +namespace KeyAsio.Plugins.Contracts; + +/// +/// Sync plugin interface, used to take over SyncController events and logic +/// +public interface ISyncPlugin : IPlugin +{ + /// + /// Called when the sync loop starts + /// + void OnSyncStart(); + + /// + /// Called when the sync loop stops + /// + void OnSyncStop(); + + /// + /// Called every sync frame (high frequency call, pay attention to performance) + /// + /// Sync context + /// Time difference from the previous frame (ms) + void OnTick(ISyncContext context, int deltaMs); + + /// + /// Called when the game status changes + /// + void OnStatusChanged(SyncOsuStatus oldStatus, SyncOsuStatus newStatus); + + /// + /// Called when the beatmap changes + /// + void OnBeatmapChanged(SyncBeatmapInfo beatmap); +} \ No newline at end of file diff --git a/src/Common/KeyAsio.Plugins.Contracts/IUpdateImplementation.cs b/src/Common/KeyAsio.Plugins.Contracts/IUpdateImplementation.cs new file mode 100644 index 00000000..33f074e6 --- /dev/null +++ b/src/Common/KeyAsio.Plugins.Contracts/IUpdateImplementation.cs @@ -0,0 +1,6 @@ +namespace KeyAsio.Plugins.Contracts; + +public interface IUpdateImplementation +{ + Task StartUpdateAsync(UpdateRelease release, CancellationToken cancellationToken = default); +} diff --git a/src/Common/KeyAsio.Plugins.Contracts/IUpdateSupportPlugin.cs b/src/Common/KeyAsio.Plugins.Contracts/IUpdateSupportPlugin.cs new file mode 100644 index 00000000..a4021115 --- /dev/null +++ b/src/Common/KeyAsio.Plugins.Contracts/IUpdateSupportPlugin.cs @@ -0,0 +1,6 @@ +namespace KeyAsio.Plugins.Contracts; + +public interface IUpdateSupportPlugin : IPlugin +{ + IUpdateImplementation UpdateImplementation { get; } +} \ No newline at end of file diff --git a/src/Common/KeyAsio.Plugins.Contracts/IUserInterfacePlugin.cs b/src/Common/KeyAsio.Plugins.Contracts/IUserInterfacePlugin.cs new file mode 100644 index 00000000..aa607114 --- /dev/null +++ b/src/Common/KeyAsio.Plugins.Contracts/IUserInterfacePlugin.cs @@ -0,0 +1,13 @@ +namespace KeyAsio.Plugins.Contracts; + +/// +/// Interface for plugins that provide a user interface component to be injected into the main application. +/// +public interface IUserInterfacePlugin : IPlugin +{ + /// + /// Creates the plugin-owned view. The desktop host decides whether the returned + /// object is a supported presentation element. + /// + object CreateView(); +} diff --git a/src/Common/KeyAsio.Plugins.Contracts/KeyAsio.Plugins.Contracts.csproj b/src/Common/KeyAsio.Plugins.Contracts/KeyAsio.Plugins.Contracts.csproj new file mode 100644 index 00000000..142d2d29 --- /dev/null +++ b/src/Common/KeyAsio.Plugins.Contracts/KeyAsio.Plugins.Contracts.csproj @@ -0,0 +1,16 @@ + + + + net10.0 + enable + enable + true + + + + + + + + + diff --git a/src/Common/KeyAsio.Plugins.Contracts/Sync/Mods.cs b/src/Common/KeyAsio.Plugins.Contracts/Sync/Mods.cs new file mode 100644 index 00000000..ce7784f1 --- /dev/null +++ b/src/Common/KeyAsio.Plugins.Contracts/Sync/Mods.cs @@ -0,0 +1,40 @@ +namespace KeyAsio.Plugins.Contracts.Sync; + +[Flags] +public enum Mods : uint +{ + NoMod = 0u, + None = 0u, + NoFail = 1u << 0, + Easy = 1u << 1, + TouchDevice = 1u << 2, + Hidden = 1u << 3, + HardRock = 1u << 4, + SuddenDeath = 1u << 5, + DoubleTime = 1u << 6, + Relax = 1u << 7, + HalfTime = 1u << 8, + Nightcore = 1u << 9, + Flashlight = 1u << 10, + Autoplay = 1u << 11, + SpunOut = 1u << 12, + AutoPilot = 1u << 13, + Perfect = 1u << 14, + Key4 = 1u << 15, + Key5 = 1u << 16, + Key6 = 1u << 17, + Key7 = 1u << 18, + Key8 = 1u << 19, + FadeIn = 1u << 20, + Random = 1u << 21, + Cinema = 1u << 22, + Target = 1u << 23, + Key9 = 1u << 24, + KeyCoop = 1u << 25, + Key1 = 1u << 26, + Key3 = 1u << 27, + Key2 = 1u << 28, + ScoreV2 = 1u << 29, + Mirror = 1u << 30, + Unknown = 0xFFFFFFFFu +} \ No newline at end of file diff --git a/src/Common/KeyAsio.Plugins.Contracts/Sync/OsuMemoryStatus.cs b/src/Common/KeyAsio.Plugins.Contracts/Sync/OsuMemoryStatus.cs new file mode 100644 index 00000000..0eecc02b --- /dev/null +++ b/src/Common/KeyAsio.Plugins.Contracts/Sync/OsuMemoryStatus.cs @@ -0,0 +1,25 @@ +namespace KeyAsio.Plugins.Contracts.Sync; + +public enum OsuMemoryStatus +{ + Unknown = -0xff, + NotRunning = -0x0f, + + MainView = 0, + Editing = 1, + Playing = 2, + GameShutdown = 3, + EditSongSelection = 4, + SongSelection = 5, + ResultsScreen = 7, + GameStartup = 10, + MultiLobby = 11, + MultiRoom = 12, + MultiSongSelection = 13, + MultiResultsScreen = 14, + OsuDirect = 15, + TagCoopRanking = 17, + TeamRanking = 18, + BeatmapProcessing = 19, + Tourney = 22, +} \ No newline at end of file diff --git a/src/Common/KeyAsio.Plugins.Contracts/SyncBeatmapInfo.cs b/src/Common/KeyAsio.Plugins.Contracts/SyncBeatmapInfo.cs new file mode 100644 index 00000000..d3b419b8 --- /dev/null +++ b/src/Common/KeyAsio.Plugins.Contracts/SyncBeatmapInfo.cs @@ -0,0 +1,10 @@ +namespace KeyAsio.Plugins.Contracts; + +public class SyncBeatmapInfo +{ + public int SetId { get; set; } + public int MapId { get; set; } + public string? Md5 { get; set; } + public string? Folder { get; set; } + public string? Filename { get; set; } +} \ No newline at end of file diff --git a/src/Common/KeyAsio.Plugins.Contracts/SyncHitErrors.cs b/src/Common/KeyAsio.Plugins.Contracts/SyncHitErrors.cs new file mode 100644 index 00000000..83daaa76 --- /dev/null +++ b/src/Common/KeyAsio.Plugins.Contracts/SyncHitErrors.cs @@ -0,0 +1,6 @@ +namespace KeyAsio.Plugins.Contracts; + +public readonly record struct SyncHitErrors(int Index, int[] Values) +{ + public static SyncHitErrors Empty { get; } = new(0, []); +} diff --git a/src/Common/KeyAsio.Plugins.Contracts/SyncOsuStatus.cs b/src/Common/KeyAsio.Plugins.Contracts/SyncOsuStatus.cs new file mode 100644 index 00000000..81f82dc8 --- /dev/null +++ b/src/Common/KeyAsio.Plugins.Contracts/SyncOsuStatus.cs @@ -0,0 +1,25 @@ +namespace KeyAsio.Plugins.Contracts; + +public enum SyncOsuStatus +{ + Unknown = -0xff, + NotRunning = -0x0f, + + MainView = 0, + Editing = 1, + Playing = 2, + GameShutdown = 3, + EditSongSelection = 4, + SongSelection = 5, + ResultsScreen = 7, + GameStartup = 10, + MultiLobby = 11, + MultiRoom = 12, + MultiSongSelection = 13, + MultiResultsScreen = 14, + OsuDirect = 15, + TagCoopRanking = 17, + TeamRanking = 18, + BeatmapProcessing = 19, + Tourney = 22, +} \ No newline at end of file diff --git a/src/Common/KeyAsio.Plugins.Contracts/SyncStatistics.cs b/src/Common/KeyAsio.Plugins.Contracts/SyncStatistics.cs new file mode 100644 index 00000000..ea9f90db --- /dev/null +++ b/src/Common/KeyAsio.Plugins.Contracts/SyncStatistics.cs @@ -0,0 +1,12 @@ +namespace KeyAsio.Plugins.Contracts; + +public readonly record struct SyncStatistics( + int Perfect, + int Great, + int Good, + int Ok, + int Meh, + int Miss) +{ + public static SyncStatistics Empty { get; } = new(); +} diff --git a/src/Common/KeyAsio.Plugins.Contracts/Updating/UpdateRelease.cs b/src/Common/KeyAsio.Plugins.Contracts/Updating/UpdateRelease.cs new file mode 100644 index 00000000..4aad6af9 --- /dev/null +++ b/src/Common/KeyAsio.Plugins.Contracts/Updating/UpdateRelease.cs @@ -0,0 +1,14 @@ +namespace KeyAsio.Plugins.Contracts; + +public sealed record UpdateAsset( + string Name, + string DownloadUrl, + long? Size = null); + +public sealed record UpdateRelease( + string Version, + string? ReleasePageUrl, + string? Notes, + bool IsPrerelease, + DateTimeOffset? PublishedAt, + IReadOnlyList Assets); diff --git a/src/Core/KeyAsio.Sync/Abstractions/IGameplayAudioCache.cs b/src/Core/KeyAsio.Sync/Abstractions/IGameplayAudioCache.cs new file mode 100644 index 00000000..f78eed3c --- /dev/null +++ b/src/Core/KeyAsio.Sync/Abstractions/IGameplayAudioCache.cs @@ -0,0 +1,6 @@ +namespace KeyAsio.Sync.Abstractions; + +public interface IGameplayAudioCache +{ + void ClearCaches(); +} diff --git a/src/Core/KeyAsio.Sync/Abstractions/IPlaybackRuntimeState.cs b/src/Core/KeyAsio.Sync/Abstractions/IPlaybackRuntimeState.cs new file mode 100644 index 00000000..fdb53b96 --- /dev/null +++ b/src/Core/KeyAsio.Sync/Abstractions/IPlaybackRuntimeState.cs @@ -0,0 +1,10 @@ +namespace KeyAsio.Sync.Abstractions; + +public interface IPlaybackRuntimeState +{ + bool AutoMode { get; } + + string SelectedSkinFolder { get; } + + event Action? SelectedSkinChanged; +} diff --git a/src/Core/KeyAsio.Sync/Abstractions/ISkinResourceProvider.cs b/src/Core/KeyAsio.Sync/Abstractions/ISkinResourceProvider.cs new file mode 100644 index 00000000..cc8d61a7 --- /dev/null +++ b/src/Core/KeyAsio.Sync/Abstractions/ISkinResourceProvider.cs @@ -0,0 +1,12 @@ +using KeyAsio.Core.OsuAudio.Hitsounds; + +namespace KeyAsio.Sync.Abstractions; + +public interface ISkinResourceProvider +{ + bool TryGetSkinCatalog(string skinFolder, out IBeatmapResourceCatalog catalog); + + bool TryGetLazerResource(string skinFolder, string key, out byte[] data); + + bool TryGetStableResource(string key, out byte[] data); +} diff --git a/src/Core/KeyAsio.Sync/AssemblyInfo.cs b/src/Core/KeyAsio.Sync/AssemblyInfo.cs new file mode 100644 index 00000000..df63a5c0 --- /dev/null +++ b/src/Core/KeyAsio.Sync/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("ColumnTest")] diff --git a/src/Core/KeyAsio.Sync/Events/EventDelegates.cs b/src/Core/KeyAsio.Sync/Events/EventDelegates.cs new file mode 100644 index 00000000..ac5c1fb6 --- /dev/null +++ b/src/Core/KeyAsio.Sync/Events/EventDelegates.cs @@ -0,0 +1,29 @@ +namespace KeyAsio.Sync.Events; + +/// +/// Represents a method that handles a signal event (no data). +/// +public delegate void SignalEventHandler(); + +/// +/// Represents an asynchronous method that handles a signal event (no data). +/// +/// A task that represents the asynchronous operation. +public delegate Task SignalAsyncEventHandler(); + +/// +/// Represents a method that handles a value change event. +/// +/// The type of the value. +/// The old value. +/// The new value. +public delegate void ValueChangedEventHandler(T oldValue, T newValue); + +/// +/// Represents an asynchronous method that handles a value change event. +/// +/// The type of the value. +/// The old value. +/// The new value. +/// A task that represents the asynchronous operation. +public delegate Task ValueChangedAsyncEventHandler(T oldValue, T newValue); \ No newline at end of file diff --git a/src/Core/KeyAsio.Sync/KeyAsio.Sync.csproj b/src/Core/KeyAsio.Sync/KeyAsio.Sync.csproj new file mode 100644 index 00000000..50113464 --- /dev/null +++ b/src/Core/KeyAsio.Sync/KeyAsio.Sync.csproj @@ -0,0 +1,31 @@ + + + net10.0 + enable + enable + AnyCPU;x64 + true + + + + + + + + + + + + + + + + + + + + osu_memory_rules.json + PreserveNewest + + + diff --git a/src/Core/KeyAsio.Sync/Models/PlaybackInfo.cs b/src/Core/KeyAsio.Sync/Models/PlaybackInfo.cs new file mode 100644 index 00000000..757c5a5d --- /dev/null +++ b/src/Core/KeyAsio.Sync/Models/PlaybackInfo.cs @@ -0,0 +1,6 @@ +using KeyAsio.Core.Audio.Caching; +using KeyAsio.Core.OsuAudio.Hitsounds.Playback; + +namespace KeyAsio.Sync.Models; + +public readonly record struct PlaybackInfo(CachedAudio? CachedAudio, PlaybackEvent PlaybackEvent); diff --git a/src/Core/KeyAsio.Sync/Runtime/AudioProviders/CatchHitsoundSequencer.cs b/src/Core/KeyAsio.Sync/Runtime/AudioProviders/CatchHitsoundSequencer.cs new file mode 100644 index 00000000..55f902ec --- /dev/null +++ b/src/Core/KeyAsio.Sync/Runtime/AudioProviders/CatchHitsoundSequencer.cs @@ -0,0 +1,187 @@ +using KeyAsio.Configuration; +using KeyAsio.Configuration.Models; +using System.Runtime.CompilerServices; +using KeyAsio.Core.Audio; +using KeyAsio.Core.OsuAudio.Hitsounds.Playback; +using KeyAsio.Sync.Models; +using KeyAsio.Sync.Services; +using Microsoft.Extensions.Logging; + +namespace KeyAsio.Sync.AudioProviders; + +public class CatchHitsoundSequencer : IHitsoundSequencer +{ + private const int AudioLatencyTolerance = 200; + + private readonly ILogger _logger; + private readonly AppSettings _appSettings; + private readonly SyncSessionContext _syncSessionContext; + private readonly IPlaybackEngine _playbackEngine; + private readonly GameplayAudioService _gameplayAudioService; + private readonly GameplaySessionManager _gameplaySessionManager; + + private Queue _hitQueue = new(); + private Queue _playbackQueue = new(); + + public CatchHitsoundSequencer(ILogger logger, + AppSettings appSettings, + SyncSessionContext syncSessionContext, + IPlaybackEngine playbackEngine, + GameplayAudioService gameplayAudioService, + GameplaySessionManager gameplaySessionManager) + { + _logger = logger; + _appSettings = appSettings; + _syncSessionContext = syncSessionContext; + _playbackEngine = playbackEngine; + _gameplayAudioService = gameplayAudioService; + _gameplaySessionManager = gameplaySessionManager; + } + + public int KeyThresholdMilliseconds { get; set; } = 100; + + public void SeekTo(int playTime) + { + _hitQueue = new Queue( + _gameplaySessionManager.KeyList.Where(k => k.Offset >= playTime - KeyThresholdMilliseconds) + ); + _playbackQueue = new Queue(_gameplaySessionManager.PlaybackList + .Where(k => k.Offset >= playTime)); + } + + public void ProcessAutoPlay(List buffer, bool processHitQueueAsAuto) + { + if (!IsEngineReady()) return; + + var playTime = _syncSessionContext.PlayTime; + + // In Catch mode (Auto-Play approach), we treat hit objects as auto-played sounds. + // Regardless of whether it's replay/auto or manual play, we play the hit sounds. + + // Process background sounds (slider ticks, etc.) + ProcessTimeBasedQueue(buffer, _playbackQueue, playTime); + + // Process hit objects (fruits, drops) + ProcessTimeBasedQueue(buffer, _hitQueue, playTime); + } + + public void ProcessInteraction(List buffer, int keyIndex, int keyTotal) + { + // Catch mode (Auto-Play approach) does not require user interaction to trigger sounds. + // Sounds are triggered automatically based on time. + } + + public void FillAudioList(IReadOnlyList nodeList, List keyList, + List playbackList) + { + // Use logic similar to Standard mode + var secondaryCache = new List(); + var options = _appSettings.Sync; + + foreach (var hitsoundNode in nodeList) + { + if (hitsoundNode is not SampleEvent playableNode) + { + if (hitsoundNode is ControlEvent controlEvent && + controlEvent.ControlEventType != ControlEventType.Balance && + controlEvent.ControlEventType != ControlEventType.None && + !options.Filters.DisableSliderTicksAndSlides) + { + playbackList.Add(controlEvent); + } + + continue; + } + + switch (playableNode.Layer) + { + case SampleLayer.Primary: + CheckSecondary(); + secondaryCache.Clear(); + keyList.Add(playableNode); + break; + case SampleLayer.Secondary: + if (options.Playback.TailPlaybackBehavior == SliderTailPlaybackBehavior.Normal) + playbackList.Add(playableNode); + else if (options.Playback.TailPlaybackBehavior == SliderTailPlaybackBehavior.KeepReverse) + secondaryCache.Add(playableNode); + break; + case SampleLayer.Sampling: + if (!options.Filters.DisableStoryboardSamples) + { + playbackList.Add(playableNode); + } + + break; + } + } + + CheckSecondary(); + + void CheckSecondary() + { + if (secondaryCache.Count > 0) + { + if (options.Playback.TailPlaybackBehavior == SliderTailPlaybackBehavior.KeepReverse) + { + playbackList.AddRange(secondaryCache); + } + + secondaryCache.Clear(); + } + } + } + + private void ProcessTimeBasedQueue(List buffer, Queue queue, int playTime) + where T : PlaybackEvent + { + while (queue.TryPeek(out var node)) + { + if (playTime < node.Offset) + { + // Not yet time + break; + } + + bool mustDispatchControlSignal = node is ControlEvent + { + ControlEventType: ControlEventType.LoopStop or ControlEventType.Volume or ControlEventType.Balance + }; + + // Control signals must never be dropped because they release/update active loops. + if (mustDispatchControlSignal) + { + buffer.Add(new PlaybackInfo(null, node)); + } + // Only play if within tolerance + else if (playTime < node.Offset + AudioLatencyTolerance) + { + if (_gameplayAudioService.TryGetAudioByNode(node, out var cachedSound)) + { + buffer.Add(new PlaybackInfo(cachedSound, node)); + } + } + + // Dequeue regardless of whether played (played, timed out, or missing resource) + queue.Dequeue(); + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private bool IsEngineReady() + { + if (_playbackEngine.CurrentDevice == null) + { + _logger.LogWarning("Engine not ready."); + return false; + } + + if (!_syncSessionContext.IsStarted) + { + _logger.LogInformation("Game hasn't started."); + return false; + } + + return true; + } +} diff --git a/src/Core/KeyAsio.Sync/Runtime/AudioProviders/ManiaHitsoundSequencer.cs b/src/Core/KeyAsio.Sync/Runtime/AudioProviders/ManiaHitsoundSequencer.cs new file mode 100644 index 00000000..2ad332c8 --- /dev/null +++ b/src/Core/KeyAsio.Sync/Runtime/AudioProviders/ManiaHitsoundSequencer.cs @@ -0,0 +1,242 @@ +using KeyAsio.Configuration; +using KeyAsio.Core.Audio; +using KeyAsio.Core.OsuAudio.Hitsounds.Playback; +using KeyAsio.Sync.Models; +using KeyAsio.Sync.Services; +using KeyAsio.Common; +using Microsoft.Extensions.Logging; + +namespace KeyAsio.Sync.AudioProviders; + +public class ManiaHitsoundSequencer : IHitsoundSequencer +{ + private readonly ILogger _logger; + private readonly AppSettings _appSettings; + private readonly SyncSessionContext _syncSessionContext; + private readonly IPlaybackEngine _playbackEngine; + private readonly GameplayAudioService _gameplayAudioService; + private readonly GameplaySessionManager _gameplaySessionManager; + + private List> _hitQueue = new(); + private SampleEvent?[] _hitQueueCache = Array.Empty(); + + private Queue _playQueue = new(); + private Queue _autoPlayQueue = new(); + + private PlaybackEvent? _firstAutoNode; + private PlaybackEvent? _firstPlayNode; + + public ManiaHitsoundSequencer(ILogger logger, + AppSettings appSettings, + SyncSessionContext syncSessionContext, + IPlaybackEngine playbackEngine, + GameplayAudioService gameplayAudioService, + GameplaySessionManager gameplaySessionManager) + { + _logger = logger; + _appSettings = appSettings; + _syncSessionContext = syncSessionContext; + _playbackEngine = playbackEngine; + _gameplayAudioService = gameplayAudioService; + _gameplaySessionManager = gameplaySessionManager; + } + + public void ProcessAutoPlay(List buffer, bool processHitQueueAsAuto) + { + var playTime = _syncSessionContext.PlayTime; + var isStarted = _syncSessionContext.IsStarted; + + if (_playbackEngine.CurrentDevice == null) + { + _logger.LogWarning("Engine not ready, return empty."); + return; + } + + if (!isStarted) + { + _logger.LogWarning("Game hasn't started, return empty."); + return; + } + + var first = processHitQueueAsAuto ? _firstPlayNode : _firstAutoNode; + if (first == null) + { + return; + _logger.LogWarning("First is null, no item returned."); + } + + if (playTime < first.Offset) + { + return; + _logger.LogWarning("Haven't reached first, no item returned."); + } + + FillNextPlaybackAudio(buffer, first, playTime, processHitQueueAsAuto); + } + + public void ProcessInteraction(List buffer, int keyIndex, int keyTotal) + { + using var _ = DebugUtils.CreateTimer($"GetSoundOnClick", _logger); + var playTime = _syncSessionContext.PlayTime; + var isStarted = _syncSessionContext.IsStarted; + + if (_playbackEngine.CurrentDevice == null) + { + _logger.LogWarning("Engine not ready, return empty."); + return; + } + + if (!isStarted) + { + _logger.LogWarning("Game hasn't started, return empty."); + return; + } + + if (_hitQueue.Count - 1 < keyIndex || _hitQueueCache.Length - 1 < keyIndex) + { + _logger.LogWarning( + "Key index was out of range ({KeyIndex}). Please check your key configuration to match mania columns.", + keyIndex); + return; + } + + var queue = _hitQueue[keyIndex]; + while (true) + { + if (queue.TryPeek(out var node)) + { + if (playTime < node.Offset - 80 /*odMax*/) + { + _hitQueueCache[keyIndex] = null; + break; + } + + if (playTime <= node.Offset + 50 /*odMax*/) + { + _hitQueueCache[keyIndex] = queue.Dequeue(); + _logger.LogInformation("Dequeued and will use Col." + keyIndex); + break; + } + + queue.Dequeue(); + _logger.LogInformation("Dropped Col." + keyIndex); + _hitQueueCache[keyIndex] = null; + } + else + { + _hitQueueCache[keyIndex] = null; + break; + } + } + + var playableNode = _hitQueueCache[keyIndex]; + if (playableNode == null) + { + _hitQueue[keyIndex].TryPeek(out playableNode); + _logger.LogDebug("Use first"); + } + else + { + _logger.LogDebug("Use cache"); + } + + if (playableNode == null || !_gameplayAudioService.TryGetAudioByNode(playableNode, out var cachedAudio)) + { + _logger.LogWarning("No audio returned."); + } + else + { + buffer.Add(new PlaybackInfo(cachedAudio, playableNode)); + } + } + + public void FillAudioList(IReadOnlyList nodeList, List keyList, + List playbackList) + { + foreach (var hitsoundNode in nodeList) + { + if (hitsoundNode is not SampleEvent playableNode) continue; + + if (playableNode.Layer is SampleLayer.Sampling) + { + if (!_appSettings.Sync.Filters.DisableStoryboardSamples) + { + playbackList.Add(playableNode); + } + } + else + { + keyList.Add(playableNode); + } + } + } + + public void SeekTo(int playTime) + { + _hitQueue = GetHitQueue(_gameplaySessionManager.KeyList, playTime); + _hitQueueCache = new SampleEvent[_hitQueue.Count]; + + _autoPlayQueue = new Queue(_gameplaySessionManager.KeyList); + _playQueue = new Queue(_gameplaySessionManager.PlaybackList.Where(k => k.Offset >= playTime)); + _autoPlayQueue.TryDequeue(out _firstAutoNode); + _playQueue.TryDequeue(out _firstPlayNode); + } + + private List> GetHitQueue(IReadOnlyList keyList, int playTime) + { + if (_gameplaySessionManager.OsuFile == null) + return new List>(); + + var keyCount = (int)_gameplaySessionManager.OsuFile.Difficulty.CircleSize; + var list = new List>(keyCount); + for (int i = 0; i < keyCount; i++) + { + list.Add(new Queue()); + } + + foreach (var playableNode in keyList.Where(k => k.Offset >= playTime)) + { + var ratio = (playableNode.Balance + 1d) / 2; + var column = (int)Math.Round(ratio * keyCount - 0.5); + list[column].Enqueue(playableNode); + } + + return list; + } + + private void FillNextPlaybackAudio(List buffer, PlaybackEvent? firstNode, int playTime, + bool includeKey) + { + while (firstNode != null) + { + if (playTime < firstNode.Offset) + { + break; + } + + if (playTime < firstNode.Offset + 200 && + _gameplayAudioService.TryGetAudioByNode(firstNode, out var cachedSound)) + { + buffer.Add(new PlaybackInfo(cachedSound, firstNode)); + } + + if (includeKey) + { + _playQueue.TryDequeue(out firstNode); + } + else + { + _autoPlayQueue.TryDequeue(out firstNode); + } + } + + if (includeKey) + { + _firstPlayNode = firstNode; + } + else + { + _firstAutoNode = firstNode; + } + } +} \ No newline at end of file diff --git a/src/Core/KeyAsio.Sync/Runtime/AudioProviders/StandardHitsoundSequencer.cs b/src/Core/KeyAsio.Sync/Runtime/AudioProviders/StandardHitsoundSequencer.cs new file mode 100644 index 00000000..fc5547c2 --- /dev/null +++ b/src/Core/KeyAsio.Sync/Runtime/AudioProviders/StandardHitsoundSequencer.cs @@ -0,0 +1,271 @@ +using KeyAsio.Configuration; +using KeyAsio.Configuration.Models; +using System.Runtime.CompilerServices; +using KeyAsio.Core.Audio; +using KeyAsio.Core.OsuAudio.Hitsounds.Playback; +using KeyAsio.Sync.Models; +using KeyAsio.Sync.Services; +using Microsoft.Extensions.Logging; + +namespace KeyAsio.Sync.AudioProviders; + +public class StandardHitsoundSequencer : IHitsoundSequencer +{ + private const int AudioLatencyTolerance = 200; + + private readonly ILogger _logger; + private readonly AppSettings _appSettings; + private readonly SyncSessionContext _syncSessionContext; + private readonly IPlaybackEngine _playbackEngine; + private readonly GameplayAudioService _gameplayAudioService; + private readonly GameplaySessionManager _gameplaySessionManager; + + private Queue _hitQueue = new(); + private Queue _playbackQueue = new(); + + public StandardHitsoundSequencer(ILogger logger, + AppSettings appSettings, + SyncSessionContext syncSessionContext, + IPlaybackEngine playbackEngine, + GameplayAudioService gameplayAudioService, + GameplaySessionManager gameplaySessionManager) + { + _logger = logger; + _appSettings = appSettings; + _syncSessionContext = syncSessionContext; + _playbackEngine = playbackEngine; + _gameplayAudioService = gameplayAudioService; + _gameplaySessionManager = gameplaySessionManager; + } + + public int KeyThresholdMilliseconds { get; set; } = 100; + + public void SeekTo(int playTime) + { + _hitQueue = new Queue( + _gameplaySessionManager.KeyList.Where(k => k.Offset >= playTime - KeyThresholdMilliseconds) + ); + _playbackQueue = new Queue(_gameplaySessionManager.PlaybackList + .Where(k => k.Offset >= playTime)); + } + + public void ProcessAutoPlay(List buffer, bool processHitQueueAsAuto) + { + if (!IsEngineReady()) return; + + var first = processHitQueueAsAuto + ? (_playbackQueue.TryPeek(out var pNode) ? pNode : null) + : (_hitQueue.TryPeek(out var hNode) ? hNode : null); + + if (first == null) + { + return; + } + + var playTime = _syncSessionContext.PlayTime; + if (playTime < first.Offset) + { + return; + } + + // 如果需要,将 HitQueue 当作自动播放处理(例如回放模式或 Auto 模式) + if (processHitQueueAsAuto) + { + ProcessTimeBasedQueue(buffer, _playbackQueue, playTime); + } + else + { + ProcessTimeBasedQueue(buffer, _hitQueue, playTime); + } + } + + public void ProcessInteraction(List buffer, int keyIndex, int keyTotal) + { + if (!IsEngineReady()) return; + + var playTime = _syncSessionContext.PlayTime; + + // 用于处理同组音符(堆叠/Chord) + bool hasHit = false; + Guid? currentGroupGuid = null; + + // 循环处理队列,直到: + // 1. 队列空了 + // 2. 判定太早(Early Reject) + // 3. 判定命中(Hit)并处理完该组所有音符 + while (_hitQueue.TryPeek(out var node)) + { + // 计算时间差 + // diff > 0: 此时刻在音符之后 (Late) + // diff < 0: 此时刻在音符之前 (Early) + var diff = playTime - node.Offset; + + // --- 情况 1: 已经命中过,正在处理同组音符 (Chord) --- + if (hasHit) + { + // 如果 GUID 变了,说明这一组(Chord)处理完了,停止 + if (node.Guid != currentGroupGuid) + { + return; + } + + // 同组音符,直接播放,不需要再判定时间 + DequeueAndPlay(buffer, _hitQueue); + continue; + } + + // --- 情况 2: 音符已彻底过期 (Missed) --- + // 例如:当前 1500ms,音符 1000ms,阈值 100ms。 + // 1500 > 1000 + 100 -> 过期。 + if (diff > KeyThresholdMilliseconds) + { + //_logger.LogDebug("Pruning expired node at {Offset} (Current: {PlayTime})", node.Offset, playTime); + _hitQueue.Dequeue(); // 移除过期音符 + continue; // 【关键修复】:不返回,继续用当次点击去检查下一个音符! + } + + // --- 情况 3: 点击太早 (Too Early) --- + // 例如:当前 800ms,音符 1000ms,阈值 100ms。 + // 800 < 1000 - 100 -> 太早。 + if (diff < -KeyThresholdMilliseconds) + { + // 还没到判定窗口,且因为队列是有序的,后面的肯定也更早。 + // 停止处理,等待时间流逝。 + return; + } + + // --- 情况 4: 命中判定窗口 (Hit) --- + // 代码能走到这,说明:Offset - Threshold <= playTime <= Offset + Threshold + + //_logger.LogDebug("Hit node at {Offset} (Current: {PlayTime})", node.Offset, playTime); + + // 记录状态,标记为已命中 + hasHit = true; + currentGroupGuid = node.Guid; + + // 播放并移除 + DequeueAndPlay(buffer, _hitQueue); + + // 循环继续,去检查是否还有同 GUID 的重叠音符 + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private bool IsEngineReady() + { + if (_playbackEngine.CurrentDevice == null) + { + _logger.LogWarning("Engine not ready."); + return false; + } + + if (!_syncSessionContext.IsStarted) + { + _logger.LogInformation("Game hasn't started."); + return false; + } + + return true; + } + + public void FillAudioList(IReadOnlyList nodeList, List keyList, + List playbackList) + { + var secondaryCache = new List(); + var options = _appSettings.Sync; + + foreach (var hitsoundNode in nodeList) + { + if (hitsoundNode is not SampleEvent playableNode) + { + if (hitsoundNode is ControlEvent controlNode && + controlNode.ControlEventType != ControlEventType.Balance && + controlNode.ControlEventType != ControlEventType.None && + !options.Filters.DisableSliderTicksAndSlides) + { + playbackList.Add(controlNode); + } + + continue; + } + + switch (playableNode.Layer) + { + case SampleLayer.Primary: + CheckSecondary(); + secondaryCache.Clear(); + keyList.Add(playableNode); + break; + case SampleLayer.Secondary: + if (options.Playback.TailPlaybackBehavior == SliderTailPlaybackBehavior.Normal) + playbackList.Add(playableNode); + else if (options.Playback.TailPlaybackBehavior == SliderTailPlaybackBehavior.KeepReverse) + secondaryCache.Add(playableNode); + break; + case SampleLayer.Effects: + if (!options.Filters.DisableSliderTicksAndSlides) + playbackList.Add(playableNode); + break; + case SampleLayer.Sampling: + if (!options.Filters.DisableStoryboardSamples) + playbackList.Add(playableNode); + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + CheckSecondary(); + + void CheckSecondary() + { + if (secondaryCache.Count <= 1) return; + playbackList.AddRange(secondaryCache); + } + } + + private void ProcessTimeBasedQueue(List buffer, Queue queue, int playTime) + where T : PlaybackEvent + { + while (queue.TryPeek(out var node)) + { + if (playTime < node.Offset) + { + // 时间未到 + break; + } + + bool mustDispatchControlSignal = node is ControlEvent + { + ControlEventType: ControlEventType.LoopStop or ControlEventType.Volume or ControlEventType.Balance + }; + + // LoopStop/Volume/Balance 是控制信号,绝不能因为延迟或缓存未命中被丢弃 + if (mustDispatchControlSignal) + { + buffer.Add(new PlaybackInfo(null, node)); + } + + // 其他事件仍遵循延迟容忍窗口 + else if (playTime < node.Offset + AudioLatencyTolerance) + { + if (_gameplayAudioService.TryGetAudioByNode(node, out var cachedSound)) + { + buffer.Add(new PlaybackInfo(cachedSound, node)); + } + } + + // 无论是否播放(播放了 or 超时了 or 找不到资源),只要时间到了就移除 + queue.Dequeue(); + } + } + + private void DequeueAndPlay(List buffer, Queue queue) where T : PlaybackEvent + { + var node = queue.Dequeue(); + if (_gameplayAudioService.TryGetAudioByNode(node, out var cachedSound)) + { + buffer.Add(new PlaybackInfo(cachedSound, node)); + } + } +} diff --git a/src/Core/KeyAsio.Sync/Runtime/AudioProviders/TaikoHitsoundSequencer.cs b/src/Core/KeyAsio.Sync/Runtime/AudioProviders/TaikoHitsoundSequencer.cs new file mode 100644 index 00000000..f08b7759 --- /dev/null +++ b/src/Core/KeyAsio.Sync/Runtime/AudioProviders/TaikoHitsoundSequencer.cs @@ -0,0 +1,400 @@ +using KeyAsio.Configuration; +using KeyAsio.Configuration.Models; +using System.Diagnostics; +using System.Runtime.CompilerServices; +using KeyAsio.Core.Audio; +using KeyAsio.Core.Audio.Caching; +using KeyAsio.Core.OsuAudio.Hitsounds.Playback; +using KeyAsio.Sync.Models; +using KeyAsio.Sync.Services; +using Microsoft.Extensions.Logging; + +namespace KeyAsio.Sync.AudioProviders; + +public class TaikoHitsoundSequencer : IHitsoundSequencer +{ + private const int AudioLatencyTolerance = 200; + + private readonly ILogger _logger; + private readonly AppSettings _appSettings; + private readonly SyncSessionContext _syncSessionContext; + private readonly IPlaybackEngine _playbackEngine; + private readonly GameplayAudioService _gameplayAudioService; + private readonly GameplaySessionManager _gameplaySessionManager; + + private Queue _hitQueue = new(); + private Queue _playbackQueue = new(); + + private long _lastDonTime = 0; + private long _lastKatTime = 0; + private const double MinIntervalMs = 30.0; + + public TaikoHitsoundSequencer(ILogger logger, + AppSettings appSettings, + SyncSessionContext syncSessionContext, + IPlaybackEngine playbackEngine, + GameplayAudioService gameplayAudioService, + GameplaySessionManager gameplaySessionManager) + { + _logger = logger; + _appSettings = appSettings; + _syncSessionContext = syncSessionContext; + _playbackEngine = playbackEngine; + _gameplayAudioService = gameplayAudioService; + _gameplaySessionManager = gameplaySessionManager; + } + + public int KeyThresholdMilliseconds { get; set; } = 100; + + public void SeekTo(int playTime) + { + _hitQueue = new Queue( + _gameplaySessionManager.KeyList.Where(k => k.Offset >= playTime - KeyThresholdMilliseconds) + ); + _playbackQueue = new Queue(_gameplaySessionManager.PlaybackList + .Where(k => k.Offset >= playTime)); + } + + public void ProcessAutoPlay(List buffer, bool processHitQueueAsAuto) + { + if (!IsEngineReady()) return; + + var first = processHitQueueAsAuto + ? (_playbackQueue.TryPeek(out var pNode) ? pNode : null) + : (_hitQueue.TryPeek(out var hNode) ? hNode : null); + + if (first == null) + { + return; + } + + var playTime = _syncSessionContext.PlayTime; + if (playTime < first.Offset) + { + return; + } + + // 如果需要,将 HitQueue 当作自动播放处理(例如回放模式或 Auto 模式) + if (processHitQueueAsAuto) + { + ProcessTimeBasedQueue(buffer, _playbackQueue, playTime); + } + else + { + ProcessTimeBasedQueue(buffer, _hitQueue, playTime); + } + } + + public void ProcessInteraction(List buffer, int keyIndex, int keyTotal) + { + if (!IsEngineReady()) return; + + var playTime = _syncSessionContext.PlayTime; + + // 用于处理同组音符(堆叠/Chord) + bool hasHit = false; + Guid? currentGroupGuid = null; + + // Taiko Logic: + // KeyTotal usually is 4 (KDDK) + // 0: Kat (Left Rim) + // 1: Don (Left Center) + // 2: Don (Right Center) + // 3: Kat (Right Rim) + bool isDonInput = keyIndex == 1 || keyIndex == 2; + bool isKatInput = keyIndex == 0 || keyIndex == 3; + + // 获取参考 Note 用于空打音效 + PlaybackEvent? refNode = null; + + if (_hitQueue.TryPeek(out var headNode)) + { + refNode = headNode; + } + else if (_playbackQueue.TryPeek(out var pbNode)) + { + refNode = pbNode; + } + + bool soundPlayed = false; + + // 借用 mania 的逻辑来处理 Taiko 的判定,Chord 实际上不应存在 + + // 循环处理队列,直到: + // 1. 队列空了 + // 2. 判定太早(Early Reject) + // 3. 判定命中(Hit)并处理完该组所有音符 + while (_hitQueue.TryPeek(out var node)) + { + // 计算时间差 + // diff > 0: 此时刻在音符之后 (Late) + // diff < 0: 此时刻在音符之前 (Early) + var diff = playTime - node.Offset; + + // --- 情况 1: 已经命中过,正在处理同组音符 (Chord) --- + if (hasHit) + { + // 如果 GUID 变了,说明这一组(Chord)处理完了,停止 + if (node.Guid != currentGroupGuid) + { + return; + } + + DequeueAndPlay(buffer, _hitQueue); + continue; + } + + // --- 情况 2: 音符已彻底过期 (Missed) --- + if (diff > KeyThresholdMilliseconds) + { + _hitQueue.Dequeue(); // 移除过期音符 + // 更新参考 Note + if (_hitQueue.TryPeek(out var nextNode)) refNode = nextNode; + continue; + } + + // --- 情况 3: 点击太早 (Too Early) --- + if (diff < -KeyThresholdMilliseconds) + { + break; + } + + // --- 情况 4: 命中判定窗口 (Hit) --- + + // Check Note Type + // Filename check is a heuristic + var filename = node.Filename ?? ""; + bool isKatNode = filename.Contains("clap", StringComparison.OrdinalIgnoreCase) || + filename.Contains("whistle", StringComparison.OrdinalIgnoreCase); + bool isDonNode = !isKatNode; + + bool typeMatch = (isDonInput && isDonNode) || (isKatInput && isKatNode); + + if (typeMatch) + { + // 记录状态,标记为已命中 + hasHit = true; + currentGroupGuid = node.Guid; + + // 播放并移除 + if (ShouldPlaySound(isDonInput)) + { + DequeueAndPlay(buffer, _hitQueue); + UpdateLastPlayTime(isDonInput); + soundPlayed = true; + } + else + { + // 防抖生效:只移除不播放 + _hitQueue.Dequeue(); + soundPlayed = true; // 视为已处理,避免触发后续空打逻辑 + } + } + else + { + // 类型不匹配 + // 消耗掉音符,不播放。 + _hitQueue.Dequeue(); + refNode = node; // 使用这个 Miss 的 Note 作为空打声音的参考 + + // 既然这次点击消耗了这个音符(判定为 Miss),那么我们就不能再用这次点击去匹配其他音符了。 + break; + } + } + + if (!soundPlayed && refNode != null) + { + if (ShouldPlaySound(isDonInput)) + { + if (GetFallbackAudio(refNode, isDonInput, out var cachedAudio)) + { + buffer.Add(new PlaybackInfo(cachedAudio!, refNode)); + UpdateLastPlayTime(isDonInput); + } + } + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private bool ShouldPlaySound(bool isDon) + { + long currentTimestamp = Stopwatch.GetTimestamp(); + long lastTime = isDon ? _lastDonTime : _lastKatTime; + double elapsedMs = (currentTimestamp - lastTime) * 1000.0 / Stopwatch.Frequency; + return elapsedMs >= MinIntervalMs; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void UpdateLastPlayTime(bool isDon) + { + if (isDon) _lastDonTime = Stopwatch.GetTimestamp(); + else _lastKatTime = Stopwatch.GetTimestamp(); + } + + private bool GetFallbackAudio(PlaybackEvent node, bool isDon, out CachedAudio? cachedAudio) + { + cachedAudio = null; + if (node.Filename == null) return false; + + var originalFilename = Path.GetFileNameWithoutExtension(node.Filename); + string newFilename = originalFilename; + + if (isDon) + { + // 确保移除 clap/whistle 等元素,只保留 normal + // 这里假设文件名格式是标准的,或者包含这些关键字 + newFilename = newFilename + .Replace("hitclap", "hitnormal") + .Replace("hitwhistle", "hitnormal") + .Replace("hitfinish", "hitnormal"); + } + else + { + // 如果是 Kat,优先使用 clap + // 如果原文件名是 hitwhistle,保留(Whistle 也是 Kat) + if (!newFilename.Contains("hitwhistle")) + { + newFilename = newFilename + .Replace("hitnormal", "hitclap") + .Replace("hitfinish", "hitclap"); + } + } + + if (_gameplayAudioService.TryGetCachedAudio(newFilename, out cachedAudio)) + return true; + + string sampleSet = "normal"; + if (originalFilename.Contains("soft")) sampleSet = "soft"; + else if (originalFilename.Contains("drum")) sampleSet = "drum"; + + string suffix = isDon ? "hitnormal" : "hitclap"; + + if (_gameplayAudioService.TryGetCachedAudio($"taiko-{sampleSet}-{suffix}", out cachedAudio)) + return true; + + if (_gameplayAudioService.TryGetCachedAudio($"{sampleSet}-{suffix}", out cachedAudio)) + return true; + + return false; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private bool IsEngineReady() + { + if (_playbackEngine.CurrentDevice == null) + { + _logger.LogWarning("Engine not ready."); + return false; + } + + if (!_syncSessionContext.IsStarted) + { + _logger.LogInformation("Game hasn't started."); + return false; + } + + return true; + } + + public void FillAudioList(IReadOnlyList nodeList, List keyList, + List playbackList) + { + var secondaryCache = new List(); + var options = _appSettings.Sync; + + foreach (var hitsoundNode in nodeList) + { + if (hitsoundNode is not SampleEvent playableNode) + { + if (hitsoundNode is ControlEvent controlNode && + controlNode.ControlEventType != ControlEventType.Balance && + controlNode.ControlEventType != ControlEventType.None && + controlNode.ControlEventType != ControlEventType.LoopStart && + controlNode.ControlEventType != ControlEventType.LoopStop && + !options.Filters.DisableSliderTicksAndSlides) + { + playbackList.Add(controlNode); + } + + continue; + } + + switch (playableNode.Layer) + { + case SampleLayer.Primary: + CheckSecondary(); + secondaryCache.Clear(); + keyList.Add(playableNode); + break; + case SampleLayer.Secondary: + if (options.Playback.TailPlaybackBehavior == SliderTailPlaybackBehavior.Normal) + playbackList.Add(playableNode); + else if (options.Playback.TailPlaybackBehavior == SliderTailPlaybackBehavior.KeepReverse) + secondaryCache.Add(playableNode); + break; + case SampleLayer.Effects: + if (!options.Filters.DisableSliderTicksAndSlides) + playbackList.Add(playableNode); + break; + case SampleLayer.Sampling: + if (!options.Filters.DisableStoryboardSamples) + playbackList.Add(playableNode); + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + CheckSecondary(); + + void CheckSecondary() + { + if (secondaryCache.Count <= 1) return; + playbackList.AddRange(secondaryCache); + } + } + + private void ProcessTimeBasedQueue(List buffer, Queue queue, int playTime) + where T : PlaybackEvent + { + while (queue.TryPeek(out var node)) + { + if (playTime < node.Offset) + { + // 时间未到 + break; + } + + bool mustDispatchControlSignal = node is ControlEvent + { + ControlEventType: ControlEventType.LoopStop or ControlEventType.Volume or ControlEventType.Balance + }; + + // Loop control signals must be dispatched even if delayed. + if (mustDispatchControlSignal) + { + buffer.Add(new PlaybackInfo(null, node)); + } + // 只有在延迟容忍度内才播放 + else if (playTime < node.Offset + AudioLatencyTolerance) + { + if (_gameplayAudioService.TryGetAudioByNode(node, out var cachedSound)) + { + buffer.Add(new PlaybackInfo(cachedSound, node)); + } + } + + // 无论是否播放(播放了 or 超时了 or 找不到资源),只要时间到了就移除 + queue.Dequeue(); + } + } + + private void DequeueAndPlay(List buffer, Queue queue) where T : PlaybackEvent + { + var node = queue.Dequeue(); + if (_gameplayAudioService.TryGetAudioByNode(node, out var cachedSound)) + { + buffer.Add(new PlaybackInfo(cachedSound, node)); + } + } +} diff --git a/src/Core/KeyAsio.Sync/Runtime/ConfigManager.cs b/src/Core/KeyAsio.Sync/Runtime/ConfigManager.cs new file mode 100644 index 00000000..04b1a021 --- /dev/null +++ b/src/Core/KeyAsio.Sync/Runtime/ConfigManager.cs @@ -0,0 +1,55 @@ +using KeyAsio.Configuration; +using Coosu.Shared.IO; +using Milki.Extensions.MouseKeyHook; + +namespace KeyAsio.Sync; + +internal class ConfigManager +{ + private readonly AppSettings _appSettings; + + private ConfigManager(AppSettings appSettings) + { + _appSettings = appSettings; + } + + public HookKeys KeyOsuLeft { get; private set; } + public HookKeys KeyOsuRight { get; private set; } + public HookKeys KeyTaikoInnerLeft { get; private set; } + public HookKeys KeyTaikoInnerRight { get; private set; } + public HookKeys KeyTaikoOuterLeft { get; private set; } + public HookKeys KeyTaikoOuterRight { get; private set; } + + public void ReadConfigs() + { + if (_appSettings.Paths.OsuFolderPath == null) return; + var cfgFile = Path.Combine(_appSettings.Paths.OsuFolderPath, GetUserConfigFilename(Environment.UserName)); + using var fs = File.Open(cfgFile, FileMode.Open, FileAccess.Read, FileShare.Read); + using var sr = new StreamReader(fs); + + while (sr.ReadLine() is { } line) + { + var span = line.AsSpan(); + var s = span.IndexOf('='); + + if (span.Length == 0 || span[0] == '#' || s < 0) continue; + + var key = span[..s].Trim(); + var value = span[(s + 1)..].Trim(); + + if (key.Equals("keyOsuLeft", StringComparison.Ordinal)) + { + KeyOsuLeft = Enum.Parse(value); + } + else if (key.Equals("keyOsuRight", StringComparison.Ordinal)) + { + KeyOsuRight = Enum.Parse(value); + } + } + } + + private static string GetUserConfigFilename(string username) + { + return $"osu!.{PathUtils.EscapeFileName(username)}.cfg"; + } +} \ No newline at end of file diff --git a/src/Core/KeyAsio.Sync/Runtime/DependencyInjectionExtensions.cs b/src/Core/KeyAsio.Sync/Runtime/DependencyInjectionExtensions.cs new file mode 100644 index 00000000..3834c626 --- /dev/null +++ b/src/Core/KeyAsio.Sync/Runtime/DependencyInjectionExtensions.cs @@ -0,0 +1,34 @@ +using KeyAsio.Sync.Sources; +using KeyAsio.Sync.Services; +using KeyAsio.Sync.Abstractions; +using Microsoft.Extensions.DependencyInjection; + +namespace KeyAsio.Sync; + +public static class DependencyInjectionExtensions +{ + public static IServiceCollection AddSyncModule(this IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(serviceProvider => + serviceProvider.GetRequiredService()); + services.AddSingleton(serviceProvider => + serviceProvider.GetRequiredService()); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(static provider => + provider.GetRequiredService()); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + services.AddSingleton(); + services.AddSingleton(); + + return services; + } +} diff --git a/src/Core/KeyAsio.Sync/Runtime/GameStateMachine.cs b/src/Core/KeyAsio.Sync/Runtime/GameStateMachine.cs new file mode 100644 index 00000000..a4b6eef7 --- /dev/null +++ b/src/Core/KeyAsio.Sync/Runtime/GameStateMachine.cs @@ -0,0 +1,68 @@ +using KeyAsio.Plugins.Contracts.Sync; +using KeyAsio.Sync.States; + +namespace KeyAsio.Sync; + +public class GameStateMachine +{ + private readonly Dictionary _states; + + public IGameState? Current { get; private set; } + public OsuMemoryStatus CurrentStatus { get; private set; } + + public GameStateMachine(Dictionary states) + { + _states = states; + CurrentStatus = OsuMemoryStatus.NotRunning; + } + + public async Task TransitionToAsync(SyncSessionContext ctx, OsuMemoryStatus next) + { + var from = CurrentStatus; + if (!_states.TryGetValue(next, out var target)) + { + // fallback to Browsing/SongSelect if unknown status + if (_states.TryGetValue(OsuMemoryStatus.SongSelection, out var songSelect)) + { + Current?.Exit(ctx, next); + Current = songSelect; + CurrentStatus = OsuMemoryStatus.SongSelection; + await songSelect.EnterAsync(ctx, from); + } + + return; + } + + Current?.Exit(ctx, next); + Current = target; + CurrentStatus = next; + await target.EnterAsync(ctx, from); + } + + public void ExitCurrent(SyncSessionContext ctx, OsuMemoryStatus next) + { + Current?.Exit(ctx, next); + Current = null; + CurrentStatus = next; + } + + public async Task EnterFromAsync(SyncSessionContext ctx, OsuMemoryStatus from, OsuMemoryStatus next) + { + if (_states.TryGetValue(next, out var target)) + { + Current = target; + CurrentStatus = next; + await target.EnterAsync(ctx, from); + } + else + { + // Fallback logic similar to TransitionToAsync if needed, or just ignore + if (_states.TryGetValue(OsuMemoryStatus.SongSelection, out var songSelect)) + { + Current = songSelect; + CurrentStatus = OsuMemoryStatus.SongSelection; + await songSelect.EnterAsync(ctx, from); + } + } + } +} \ No newline at end of file diff --git a/src/Core/KeyAsio.Sync/Runtime/IHitsoundSequencer.cs b/src/Core/KeyAsio.Sync/Runtime/IHitsoundSequencer.cs new file mode 100644 index 00000000..5cea65cb --- /dev/null +++ b/src/Core/KeyAsio.Sync/Runtime/IHitsoundSequencer.cs @@ -0,0 +1,13 @@ +using KeyAsio.Core.OsuAudio.Hitsounds.Playback; +using KeyAsio.Sync.Models; + +namespace KeyAsio.Sync; + +public interface IHitsoundSequencer +{ + void ProcessAutoPlay(List buffer, bool processHitQueueAsAuto); + void ProcessInteraction(List buffer, int keyIndex, int keyTotal); + + void FillAudioList(IReadOnlyList nodeList, List keyList, List playbackList); + void SeekTo(int playTime); +} \ No newline at end of file diff --git a/src/Core/KeyAsio.Sync/Runtime/Services/BeatmapHitsoundLoader.cs b/src/Core/KeyAsio.Sync/Runtime/Services/BeatmapHitsoundLoader.cs new file mode 100644 index 00000000..04d28dee --- /dev/null +++ b/src/Core/KeyAsio.Sync/Runtime/Services/BeatmapHitsoundLoader.cs @@ -0,0 +1,105 @@ +using KeyAsio.Configuration; +using Coosu.Beatmap; +using KeyAsio.Core.OsuAudio.Hitsounds; +using KeyAsio.Core.OsuAudio.Hitsounds.Playback; +using KeyAsio.Plugins.Contracts.Sync; +using KeyAsio.Common; +using Microsoft.Extensions.Logging; + +namespace KeyAsio.Sync.Services; + +public class BeatmapHitsoundLoader +{ + private readonly ILogger _logger; + private readonly AppSettings _appSettings; + private readonly GameplayAudioService _gameplayAudioService; + + private readonly List _keyList = new(); + private readonly List _playbackList = new(); + private int _nextCachingTime; + + public BeatmapHitsoundLoader(ILogger logger, AppSettings appSettings, + GameplayAudioService gameplayAudioService) + { + _logger = logger; + _appSettings = appSettings; + _gameplayAudioService = gameplayAudioService; + } + + public IReadOnlyList PlaybackList => _playbackList; + public List KeyList => _keyList; + + public async Task InitializeNodeListsAsync(string folder, string diffFilename, + IHitsoundSequencer hitsoundSequencer, Mods playMods, IBeatmapResourceCatalog? resourceCatalog = null) + { + _keyList.Clear(); + _playbackList.Clear(); + + var beatmapSetContext = resourceCatalog != null + ? new BeatmapSetContext(resourceCatalog) + : new BeatmapSetContext(folder); + using (DebugUtils.CreateTimer("InitFolder", _logger)) + { + await beatmapSetContext.InitializeAsync(diffFilename, + ignoreWaveFiles: _appSettings.Sync.Filters.DisableBeatmapHitsounds); + } + + if (beatmapSetContext.OsuFiles.Count <= 0) + { + _logger.LogWarning("There is no available beatmaps after scanning. Directory: {Folder}; File: {Filename}", + folder, diffFilename); + return null; + } + + var osuFile = beatmapSetContext.OsuFiles[0]; + + using var _ = DebugUtils.CreateTimer("InitAudio", _logger); + var hitsoundList = await beatmapSetContext.GetHitsoundNodesAsync(osuFile); + await Task.Delay(100); + + var isNightcore = playMods != Mods.Unknown && (playMods & Mods.Nightcore) != 0; + if (isNightcore || _appSettings.Sync.Playback.NightcoreBeats) + { + if (isNightcore) + { + _logger.LogInformation("Current Mods: {PlayMods}", playMods); + } + + var list = NightcoreBeatGenerator.GetHitsoundNodes(osuFile, TimeSpan.Zero); + hitsoundList.AddRange(list); + hitsoundList = hitsoundList.OrderBy(k => k.Offset).ToList(); + } + + hitsoundSequencer.FillAudioList(hitsoundList, _keyList, _playbackList); + return osuFile; + } + + public void CacheAllHitsounds() + { + _gameplayAudioService.PrecacheHitsoundsRangeInBackground(0, int.MaxValue, _keyList); + _gameplayAudioService.PrecacheHitsoundsRangeInBackground(0, int.MaxValue, _playbackList); + } + + public void ResetNodes(IHitsoundSequencer hitsoundSequencer, int playTime) + { + hitsoundSequencer.SeekTo(playTime); // slow + _gameplayAudioService.PrecacheHitsoundsRangeInBackground(0, 13000, _keyList); + _gameplayAudioService.PrecacheHitsoundsRangeInBackground(0, 13000, _playbackList); + _nextCachingTime = 10000; + } + + public void AdvanceCachingWindow(int newMs) + { + if (newMs > _nextCachingTime) + { + AddAudioCacheInBackground(_nextCachingTime, _nextCachingTime + 13000, _keyList); + AddAudioCacheInBackground(_nextCachingTime, _nextCachingTime + 13000, _playbackList); + _nextCachingTime += 10000; + } + } + + private void AddAudioCacheInBackground(int startTime, int endTime, IEnumerable playableNodes) + { + _gameplayAudioService.PrecacheHitsoundsRangeInBackground(startTime, endTime, playableNodes); + } +} diff --git a/src/Core/KeyAsio.Sync/Runtime/Services/GameplayAudioService.cs b/src/Core/KeyAsio.Sync/Runtime/Services/GameplayAudioService.cs new file mode 100644 index 00000000..d4c25a46 --- /dev/null +++ b/src/Core/KeyAsio.Sync/Runtime/Services/GameplayAudioService.cs @@ -0,0 +1,399 @@ +using KeyAsio.Configuration; +using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +using KeyAsio.Core.Audio; +using KeyAsio.Core.Audio.Caching; +using KeyAsio.Core.OsuAudio.Hitsounds; +using KeyAsio.Core.OsuAudio.Hitsounds.Playback; +using KeyAsio.Core.OsuAudio.Utils; +using KeyAsio.Common; +using KeyAsio.Sync.Abstractions; +using Microsoft.Extensions.Logging; +using NAudio.Wave; + +namespace KeyAsio.Sync.Services; + +public class GameplayAudioService : IGameplayAudioCache, IDisposable +{ + private const string BeatmapCacheIdentifier = "default"; + private const string UserCacheIdentifier = "internal"; + + private static readonly string[] s_skinAudioFiles = ["combobreak"]; + + private OsuAudioFileCache _osuAudioFileCache = new(); + private readonly ConcurrentDictionary _playNodeToCachedAudioMapping = new(); + private readonly ConcurrentDictionary _filenameToCachedAudioMapping = new(); + + private readonly ILogger _logger; + private readonly SyncSessionContext _syncSessionContext; + private readonly AppSettings _appSettings; + private readonly IPlaybackEngine _playbackEngine; + private readonly AudioCacheManager _audioCacheManager; + private readonly IPlaybackRuntimeState _runtimeState; + private readonly ISkinResourceProvider _skinResources; + private string? _beatmapFolder; + private string? _audioFilename; + private IBeatmapResourceCatalog? _beatmapResourceCatalog; + + private readonly AsyncSequentialWorker _cachingWorker; + + public GameplayAudioService(ILogger logger, + SyncSessionContext syncSessionContext, + AppSettings appSettings, + IPlaybackEngine playbackEngine, + AudioCacheManager audioCacheManager, + IPlaybackRuntimeState runtimeState, + ISkinResourceProvider skinResources) + { + _logger = logger; + _syncSessionContext = syncSessionContext; + _appSettings = appSettings; + _playbackEngine = playbackEngine; + _audioCacheManager = audioCacheManager; + _runtimeState = runtimeState; + _skinResources = skinResources; + + _cachingWorker = new AsyncSequentialWorker(_logger, "GameplayAudioServiceWorker"); + _runtimeState.SelectedSkinChanged += OnSelectedSkinChanged; + } + + private void OnSelectedSkinChanged() + { + _logger.LogInformation("Skin changed, clearing gameplay audio service caches."); + ClearCaches(); + } + + public void SetContext(string? beatmapFolder, string? audioFilename, + IBeatmapResourceCatalog? beatmapResourceCatalog = null) + { + _beatmapFolder = beatmapFolder; + _audioFilename = audioFilename; + _beatmapResourceCatalog = beatmapResourceCatalog; + } + + public void ClearCaches() + { + _osuAudioFileCache = new OsuAudioFileCache(); + _audioCacheManager.ClearAll(); + _playNodeToCachedAudioMapping.Clear(); + _filenameToCachedAudioMapping.Clear(); + } + + public bool TryGetAudioByNode(PlaybackEvent node, [NotNullWhen(true)] out CachedAudio? cachedAudio) + { + if (!_playNodeToCachedAudioMapping.TryGetValue(node, out cachedAudio)) return false; + return true; + } + + public bool TryGetCachedAudio(string filenameWithoutExt, out CachedAudio? cachedAudio) + { + return _filenameToCachedAudioMapping.TryGetValue(filenameWithoutExt, out cachedAudio); + } + + public void PrecacheMusicAndSkinInBackground() + { + if (_beatmapFolder == null) + { + _logger.LogWarning("Beatmap folder is null, stop adding cache."); + return; + } + + if (_playbackEngine.CurrentDevice == null) + { + _logger.LogWarning("AudioEngine is null, stop adding cache."); + return; + } + + var folder = _beatmapFolder; + var waveFormat = _playbackEngine.EngineWaveFormat; + var skinFolder = _runtimeState.SelectedSkinFolder; + + _cachingWorker.Enqueue(async token => + { + if (folder != null && _audioFilename != null) + { + var musicPath = ResolveBeatmapFilePath(folder, _audioFilename); + + var (_, status) = await _audioCacheManager.GetOrCreateOrEmptyFromFileAsync(musicPath, waveFormat); + + if (status == CacheGetStatus.Failed) + { + _logger.LogWarning("Caching music failed: " + + (File.Exists(musicPath) ? musicPath : "FileNotFound")); + } + else if (status == CacheGetStatus.Hit) + { + _logger.LogTrace("Got music cache: " + musicPath); + } + else if (status == CacheGetStatus.Created) + { + _logger.LogDebug("Cached music: " + musicPath); + } + } + + try + { + foreach (var skinAudioFile in s_skinAudioFiles) + { + if (token.IsCancellationRequested) break; + await AddSkinCacheAsync(skinAudioFile, folder!, skinFolder, waveFormat); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "An error occurred during skin caching."); + } + }); + } + + public void PrecacheHitsoundsRangeInBackground( + int startTime, + int endTime, + IEnumerable playableNodes, + [CallerArgumentExpression("playableNodes")] + string? expression = null) + { + if (_beatmapFolder == null) + { + _logger.LogWarning("Beatmap folder is null, stop adding cache."); + return; + } + + if (_playbackEngine.CurrentDevice == null) + { + _logger.LogWarning("AudioEngine is null, stop adding cache."); + return; + } + + if (playableNodes is System.Collections.IList { Count: 0 }) + { + _logger.LogWarning($"{expression} has no hitsounds, stop adding cache."); + return; + } + + var folder = _beatmapFolder; + var waveFormat = _playbackEngine.SourceWaveFormat; + var skinFolder = _runtimeState.SelectedSkinFolder; + + _cachingWorker.Enqueue(async token => + { + using var _ = DebugUtils.CreateTimer($"CacheAudio {startTime}~{endTime}", _logger); + var nodesToCache = playableNodes.Where(k => k.Offset >= startTime && k.Offset < endTime).ToList(); + + try + { + foreach (var node in nodesToCache) + { + if (token.IsCancellationRequested) break; + await AddHitsoundCacheAsync(node, folder!, skinFolder, waveFormat); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "An error occurred during hitsound caching."); + } + }); + } + + public async Task AddSkinCacheAsync( + string filenameWithoutExt, + string beatmapFolder, + string skinFolder, + WaveFormat waveFormat) + { + if (_filenameToCachedAudioMapping.TryGetValue(filenameWithoutExt, out var value)) return value; + + string category; + var filename = _osuAudioFileCache.GetFileUntilFind(beatmapFolder, filenameWithoutExt, out var resourceOwner); + + CachedAudio result; + if (TryResolveBeatmapAudioPath(beatmapFolder, filenameWithoutExt, out var beatmapPath)) + { + category = BeatmapCacheIdentifier; + result = await LoadAndCacheAudioAsync(beatmapPath, category, waveFormat); + } + else if (resourceOwner == ResourceOwner.UserSkin) + { + category = UserCacheIdentifier; + result = await ResolveAndLoadSkinAudioAsync(filenameWithoutExt, skinFolder, category, waveFormat); + } + else + { + category = BeatmapCacheIdentifier; + var path = ResolveBeatmapFilePath(beatmapFolder, filename); + result = await LoadAndCacheAudioAsync(path, category, waveFormat); + } + + _filenameToCachedAudioMapping.TryAdd(filenameWithoutExt, result); + + return result; + } + + public async Task AddHitsoundCacheAsync( + PlaybackEvent playbackEvent, + string beatmapFolder, + string skinFolder, + WaveFormat waveFormat) + { + if (!_syncSessionContext.IsStarted) + { + _logger.LogWarning("Isn't started, stop adding cache."); + return; + } + + if (playbackEvent.Filename == null) + { + if (playbackEvent is SampleEvent) + { + _logger.LogWarning("Filename is null, add null cache."); + } + + var cacheResult = await _audioCacheManager.GetOrCreateEmptyAsync("null", waveFormat); + _playNodeToCachedAudioMapping.TryAdd(playbackEvent, cacheResult.CachedAudio!); + return; + } + + string category; + CachedAudio result; + + if (playbackEvent.ResourceOwner == ResourceOwner.UserSkin) + { + category = UserCacheIdentifier; + result = await ResolveAndLoadSkinAudioAsync(playbackEvent.Filename, skinFolder, category, waveFormat); + } + else + { + category = BeatmapCacheIdentifier; + var path = ResolveBeatmapFilePath(beatmapFolder, playbackEvent.Filename); + result = await LoadAndCacheAudioAsync(path, category, waveFormat); + } + + _playNodeToCachedAudioMapping.TryAdd(playbackEvent, result); + _filenameToCachedAudioMapping.TryAdd(playbackEvent.Filename, result); + } + + private string ResolveBeatmapFilePath(string beatmapFolder, string filename) + { + if (_beatmapResourceCatalog?.TryResolve(filename, out var mappedFile) == true) + { + return mappedFile.Path; + } + + return Path.Combine(beatmapFolder, filename); + } + + private bool TryResolveBeatmapAudioPath(string beatmapFolder, string filenameWithoutExt, out string path) + { + if (_beatmapResourceCatalog?.TryResolveAudio(filenameWithoutExt, out var mappedFile) == true) + { + path = mappedFile.Path; + return true; + } + + var filename = _osuAudioFileCache.GetFileUntilFind(beatmapFolder, filenameWithoutExt, out var resourceOwner); + if (resourceOwner == ResourceOwner.Beatmap) + { + path = ResolveBeatmapFilePath(beatmapFolder, filename); + return true; + } + + path = string.Empty; + return false; + } + + private async Task ResolveAndLoadSkinAudioAsync(string filenameKey, string skinFolder, string category, + WaveFormat waveFormat) + { + var filename = _osuAudioFileCache.GetFileUntilFind(skinFolder, filenameKey, out var resourceOwner); + if (resourceOwner == ResourceOwner.Beatmap) // Here means file exists in skin folder + { + var path = Path.Combine(skinFolder, filename); + return await LoadAndCacheAudioAsync(path, category, waveFormat); + } + + if (skinFolder == "{internal}") + { + var dynamicKey = $"internal://dynamic/{filenameKey}"; + return _audioCacheManager.CreateDynamic(dynamicKey, waveFormat); + } + + // Lazer user skins: resolve via the skin's resource catalog (file-to-path mappings + // received from the lazer IPC). The skinFolder is a virtual key, not a real directory. + if (_skinResources.TryGetSkinCatalog(skinFolder, out var skinCatalog) && + skinCatalog.TryResolveAudio(filenameKey, out var skinResource)) + { + return await LoadAndCacheAudioAsync(skinResource.Path, category, waveFormat); + } + + // Lazer built-in skins: use per-skin extracted resources with classic → triangles fallback. + // For custom lazer skins that didn't have the sample, fall back to classic sounds. + var lazerSkinFolder = skinFolder.StartsWith("{lazer-", StringComparison.OrdinalIgnoreCase) + ? skinFolder + : "{lazer-classic}"; + if (_skinResources.TryGetLazerResource(lazerSkinFolder, filenameKey, out var lazerBytes)) + { + var key = $"lazer://{lazerSkinFolder}/{filenameKey}"; + using var stream = new MemoryStream(lazerBytes); + return await LoadAndCacheAudioFromStreamAsync(key, stream, category, waveFormat); + } + + // Stable fallback (osu!gameplay.dll resources) + if (_skinResources.TryGetStableResource(filenameKey, out var bytes)) + { + var key = $"internal://{filenameKey}"; + using var stream = new MemoryStream(bytes); + return await LoadAndCacheAudioFromStreamAsync(key, stream, category, waveFormat); + } + + _logger.LogWarning("Skin audio not found in skin or resources: {FilenameKey}", filenameKey); + var empty = await _audioCacheManager.GetOrCreateEmptyAsync(filenameKey, waveFormat, category); + return empty.CachedAudio!; + } + + private async Task LoadAndCacheAudioFromStreamAsync(string key, Stream stream, string category, + WaveFormat waveFormat) + { + var (result, status) = await _audioCacheManager.GetOrCreateOrEmptyAsync(key, stream, waveFormat, category); + if (status == CacheGetStatus.Failed) + { + _logger.LogWarning("Caching effect failed: {Key}", key); + } + else if (status == CacheGetStatus.Hit) + { + _logger.LogTrace("Got effect cache: {Key}", key); + } + else if (status == CacheGetStatus.Created) + { + _logger.LogDebug("Cached effect: {Key}", key); + } + + return result!; + } + + private async Task LoadAndCacheAudioAsync(string path, string category, WaveFormat waveFormat) + { + var (result, status) = await _audioCacheManager.GetOrCreateOrEmptyFromFileAsync(path, waveFormat, category); + if (status == CacheGetStatus.Failed) + { + var file = (File.Exists(path) ? path : "FileNotFound"); + _logger.LogWarning("Caching effect failed: {File}", file); + } + else if (status == CacheGetStatus.Hit) + { + _logger.LogTrace("Got effect cache: {Path}", path); + } + else if (status == CacheGetStatus.Created) + { + _logger.LogDebug("Cached effect: {Path}", path); + } + + return result!; + } + + public void Dispose() + { + _runtimeState.SelectedSkinChanged -= OnSelectedSkinChanged; + _cachingWorker.Dispose(); + } +} diff --git a/src/Core/KeyAsio.Sync/Runtime/Services/GameplaySessionManager.cs b/src/Core/KeyAsio.Sync/Runtime/Services/GameplaySessionManager.cs new file mode 100644 index 00000000..6fea4335 --- /dev/null +++ b/src/Core/KeyAsio.Sync/Runtime/Services/GameplaySessionManager.cs @@ -0,0 +1,211 @@ +using System.Runtime; +using System.Runtime.CompilerServices; +using Coosu.Beatmap; +using Coosu.Beatmap.Sections.GamePlay; +using KeyAsio.Core.Audio; +using KeyAsio.Core.OsuAudio.Hitsounds; +using KeyAsio.Core.OsuAudio.Hitsounds.Playback; +using KeyAsio.Sync.Events; +using KeyAsio.Common; +using Microsoft.Extensions.Logging; + +namespace KeyAsio.Sync.Services; + +public class GameplaySessionManager +{ + private readonly ILogger _logger; + private readonly GameplayAudioService _gameplayAudioService; + private readonly IPlaybackEngine _audioEngine; + private readonly SyncSessionContext _syncSessionContext; + private readonly BeatmapHitsoundLoader _beatmapHitsoundLoader; + private readonly SfxPlaybackService _sfxPlaybackService; + + public event SignalEventHandler? SessionStopped; + + private readonly Dictionary _audioProviderDictionary = new(); + private OsuFile? _osuFile; + private IHitsoundSequencer? _cachedHitsoundSequencer; + private string? _lastCachedFolder; + private GCLatencyMode _oldLatencyMode; + private bool _isLowLatencyModeActive; + + public GameplaySessionManager(ILogger logger, + GameplayAudioService gameplayAudioService, + IPlaybackEngine audioEngine, + SyncSessionContext syncSessionContext, + BeatmapHitsoundLoader beatmapHitsoundLoader, + SfxPlaybackService sfxPlaybackService) + { + _logger = logger; + _gameplayAudioService = gameplayAudioService; + _audioEngine = audioEngine; + _syncSessionContext = syncSessionContext; + _beatmapHitsoundLoader = beatmapHitsoundLoader; + _sfxPlaybackService = sfxPlaybackService; + } + + public OsuFile? OsuFile + { + get => _osuFile; + internal set + { + _osuFile = value; + UpdateCachedSequencer(); + } + } + + public string? AudioFilename { get; internal set; } + public string? BeatmapFolder { get; private set; } + public IReadOnlyList PlaybackList => _beatmapHitsoundLoader.PlaybackList; + public List KeyList => _beatmapHitsoundLoader.KeyList; + + public void InitializeProviders(IHitsoundSequencer standardHitsoundSequencer, + IHitsoundSequencer taikoHitsoundSequencer, + IHitsoundSequencer catchHitsoundSequencer, + IHitsoundSequencer maniaHitsoundSequencer) + { + _audioProviderDictionary[GameMode.Circle] = standardHitsoundSequencer; + _audioProviderDictionary[GameMode.Taiko] = taikoHitsoundSequencer; + _audioProviderDictionary[GameMode.Catch] = catchHitsoundSequencer; + _audioProviderDictionary[GameMode.Mania] = maniaHitsoundSequencer; + UpdateCachedSequencer(); + } + + public IHitsoundSequencer CurrentHitsoundSequencer + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => _cachedHitsoundSequencer ?? _audioProviderDictionary[GameMode.Circle]; + } + + private void UpdateCachedSequencer() + { + if (_audioProviderDictionary.Count == 0) return; + + if (_osuFile == null) + { + _cachedHitsoundSequencer = _audioProviderDictionary[GameMode.Circle]; + } + else + { + _cachedHitsoundSequencer = _audioProviderDictionary.TryGetValue(_osuFile.General.Mode, out var sequencer) + ? sequencer + : _audioProviderDictionary[GameMode.Circle]; + } + } + + public async Task StartAsync(string? beatmapFilenameFull, string? beatmapFilename) + { + try + { + _logger.LogInformation("Start playing."); + OsuFile = null; + + var resourceCatalog = _syncSessionContext.BeatmapResourceCatalog; + var folder = ResolveBeatmapFolder(beatmapFilenameFull, beatmapFilename, resourceCatalog); + if (folder == null) + { + throw new DirectoryNotFoundException("Failed to determine the beatmap directory."); + } + + if (beatmapFilename == null) + { + throw new ArgumentNullException(nameof(beatmapFilename), "Beatmap filename cannot be null."); + } + + var osuFile = await _beatmapHitsoundLoader.InitializeNodeListsAsync(folder, beatmapFilename, + CurrentHitsoundSequencer, _syncSessionContext.PlayMods, resourceCatalog); + OsuFile = osuFile; + AudioFilename = osuFile?.General?.AudioFilename; + BeatmapFolder = folder; + + var cacheContextKey = resourceCatalog != null + ? $"{resourceCatalog.CacheKey}|{beatmapFilename}" + : folder; + + PerformCache(folder, AudioFilename, resourceCatalog, cacheContextKey); + //ResetNodes(_syncSessionContext.PlayTime); + + _syncSessionContext.IsStarted = true; + + _oldLatencyMode = GCSettings.LatencyMode; + if (!RuntimeInfo.IsSatori) + { + GCSettings.LatencyMode = GCLatencyMode.SustainedLowLatency; + _logger.LogInformation("GC LatencyMode set to SustainedLowLatency"); + } + else + { + GCSettings.LatencyMode = GCLatencyMode.LowLatency; + _logger.LogInformation("GC LatencyMode set to LowLatency (Satori)"); + } + + _isLowLatencyModeActive = true; + } + catch (Exception ex) + { + _syncSessionContext.IsStarted = false; + _logger.LogError(ex, "Error while starting a beatmap. Filename: {BeatmapFilename}. FilenameReal: {OsuFile}", + beatmapFilename, OsuFile); + } + } + + private static string? ResolveBeatmapFolder(string? beatmapFilenameFull, string? beatmapFilename, + IBeatmapResourceCatalog? resourceCatalog) + { + if (beatmapFilename != null && resourceCatalog?.TryResolve(beatmapFilename, out var mappedBeatmap) == true) + { + return Path.GetDirectoryName(mappedBeatmap.Path); + } + + return beatmapFilenameFull != null + ? Path.GetDirectoryName(beatmapFilenameFull) + : resourceCatalog?.RootPath; + } + + private void PerformCache(string newFolder, string? audioFilename, IBeatmapResourceCatalog? resourceCatalog, + string cacheContextKey) + { + if (_lastCachedFolder != null && _lastCachedFolder != cacheContextKey) + { + _logger.LogInformation("Cleaning caches caused by folder changing."); + _gameplayAudioService.ClearCaches(); + } + + _lastCachedFolder = cacheContextKey; + + if (_audioEngine.CurrentDevice == null) + { + _logger.LogWarning($"AudioEngine is null, stop adding cache."); + return; + } + + _gameplayAudioService.SetContext(newFolder, audioFilename, resourceCatalog); + _gameplayAudioService.PrecacheMusicAndSkinInBackground(); + } + + public void Stop() + { + if (_isLowLatencyModeActive) + { + GCSettings.LatencyMode = _oldLatencyMode; + _isLowLatencyModeActive = false; + _logger.LogInformation("GC LatencyMode restored to {Mode}", _oldLatencyMode); + } + + _logger.LogInformation("Stop playing."); + _syncSessionContext.IsStarted = false; + var mixer = _audioEngine.EffectMixer; + _sfxPlaybackService.ClearAllLoops(mixer); + mixer?.RemoveAllMixerInputs(); + + SessionStopped?.Invoke(); + + _syncSessionContext.BaseMemoryTime = 0; + _syncSessionContext.Combo = 0; + } + + private void ResetNodes(int playTime) + { + _beatmapHitsoundLoader.ResetNodes(CurrentHitsoundSequencer, playTime); + } +} diff --git a/src/Core/KeyAsio.Sync/Runtime/Services/SfxPlaybackService.cs b/src/Core/KeyAsio.Sync/Runtime/Services/SfxPlaybackService.cs new file mode 100644 index 00000000..eb8ecff2 --- /dev/null +++ b/src/Core/KeyAsio.Sync/Runtime/Services/SfxPlaybackService.cs @@ -0,0 +1,222 @@ +using KeyAsio.Configuration; +using System.ComponentModel; +using KeyAsio.Core.Audio; +using KeyAsio.Core.Audio.Caching; +using KeyAsio.Core.Audio.SampleProviders; +using KeyAsio.Core.Audio.SampleProviders.BalancePans; +using KeyAsio.Core.Audio.Utils; +using KeyAsio.Core.OsuAudio.Hitsounds.Playback; +using KeyAsio.Sync.Models; +using Microsoft.Extensions.Logging; + +namespace KeyAsio.Sync.Services; + +public class SfxPlaybackService +{ + private readonly LoopProviderManager _loopProviderManager = new(); + private readonly ILogger _logger; + private readonly IPlaybackEngine _playbackEngine; + private readonly AppSettings _appSettings; + + public SfxPlaybackService(ILogger logger, IPlaybackEngine playbackEngine, AppSettings appSettings) + { + _logger = logger; + _playbackEngine = playbackEngine; + _appSettings = appSettings; + + _appSettings.Sync.Playback.PropertyChanged += OnPlaybackSettingsChanged; + } + + public void DispatchPlayback(PlaybackInfo playbackInfo, float? overrideVolume = null) + { + var cachedAudio = playbackInfo.CachedAudio; + var hitsoundNode = playbackInfo.PlaybackEvent; + if (hitsoundNode is SampleEvent playableNode) + { + if (cachedAudio is null) + { + _logger.LogWarning("Fail to play sample event: CachedSound not found"); + return; + } + + float volume; + if (_appSettings.Sync.Filters.IgnoreLineVolumes) + { + volume = 1; + } + else + { + if (overrideVolume != null) + volume = overrideVolume.Value; + else if (playableNode.Layer == SampleLayer.Effects) + volume = playableNode.Volume * 1.25f; + else + volume = playableNode.Volume; + } + + PlayEffectsAudio(cachedAudio, volume, playableNode.Balance); + } + else + { + var controlNode = (ControlEvent)hitsoundNode; + PlayLoopAudio(cachedAudio, controlNode); + } + } + + public void PlayEffectsAudio(CachedAudio? cachedAudio, float volume, float balance) + { + if (cachedAudio is null) + { + _logger.LogWarning("Fail to play: CachedSound not found"); + return; + } + + if (cachedAudio.SourceHash != null && cachedAudio.SourceHash.StartsWith("internal://dynamic/")) + { + try + { + var filename = cachedAudio.SourceHash.Substring("internal://dynamic/".Length); + + // 创建一次性军鼓生成器 + var provider = new SnareDrumSampleProvider(_playbackEngine.EffectMixer.WaveFormat); + + // 简单的参数随机化 + var random = Random.Shared; + + // 1. 基频微调 (±5%) + // 模拟击打位置不同导致的音高微差 + float freqOffset = (random.NextSingle() * 2f - 1f) * 0.05f; + provider.FundamentalFrequency *= (1f + freqOffset); + + // 2. 混合比例微调 (±10%) + // 模拟响弦共振的不同 + float mixOffset = (random.NextSingle() * 2f - 1f) * 0.1f; + provider.SnareMixLevel = Math.Clamp(provider.SnareMixLevel + mixOffset, 0f, 1f); + provider.SnapMixLevel = Math.Clamp(provider.SnapMixLevel - mixOffset * 0.5f, 0f, 1f); + + // 3. 衰减时间微调 (±10%) + // 模拟力度的不同导致余音长度变化 + float decayOffset = (random.NextSingle() * 2f - 1f) * 0.1f; + provider.SnareDecayDuration *= (1f + decayOffset); + provider.SnapDecayDuration *= (1f + decayOffset); + + // 根据文件名做一些简单的预设调整 + if (filename.Contains("soft", StringComparison.OrdinalIgnoreCase)) + { + provider.InitialSnapGain *= 0.8f; + provider.InitialSnareGain *= 0.7f; + provider.FundamentalFrequency *= 0.9f; + } + else if (filename.Contains("drum", StringComparison.OrdinalIgnoreCase)) + { + provider.FundamentalFrequency *= 0.8f; + provider.SnareMixLevel *= 0.5f; + provider.SnapMixLevel *= 1.2f; + } + // normal-hitnormal 保持原样 + + var volumeProvider = RecyclableSampleProviderFactory.RentVolumeProvider(provider, volume); + var balanceProvider = RecyclableSampleProviderFactory.RentBalanceProvider(volumeProvider, balance, + _appSettings.Sync.Playback.BalanceMode, AntiClipStrategy.None); + + _playbackEngine.EffectMixer.AddMixerInput(balanceProvider); + _logger.LogTrace("Play Dynamic: {Key} (Freq: {Freq:F1})", cachedAudio.SourceHash, + provider.FundamentalFrequency); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error playing dynamic audio"); + } + + return; + } + + if (_appSettings.Sync.Filters.IgnoreLineVolumes) + { + volume = 1; + } + + balance *= _appSettings.Sync.Playback.BalanceFactor; + + try + { + var cachedAudioProvider = RecyclableSampleProviderFactory.RentCacheProvider(cachedAudio); + var volumeProvider = RecyclableSampleProviderFactory.RentVolumeProvider(cachedAudioProvider, volume); + var balanceProvider = RecyclableSampleProviderFactory.RentBalanceProvider(volumeProvider, balance, + _appSettings.Sync.Playback.BalanceMode, AntiClipStrategy.None); // 削波处理交给MasterLimiterProvider + + _playbackEngine.EffectMixer.AddMixerInput(balanceProvider); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error occurs while playing audio."); + } + + _logger.LogTrace("Play {File}; Vol. {Volume}; Bal. {Balance}", cachedAudio.SourceHash, volume, balance); + } + + public void PlayLoopAudio(CachedAudio? cachedAudio, ControlEvent controlEvent) + { + var effectMixer = _playbackEngine.EffectMixer; + var volume = _appSettings.Sync.Filters.IgnoreLineVolumes ? 1 : controlEvent.Volume; + + if (controlEvent.ControlEventType == ControlEventType.LoopStart) + { + if (cachedAudio is null) + { + _logger.LogWarning("LoopStart ignored because cached audio is missing. File: {File}", + controlEvent.Filename ?? "(null)"); + return; + } + + if (_loopProviderManager.ShouldRemoveAll((int)controlEvent.LoopChannel)) + { + _loopProviderManager.RemoveAll(effectMixer); + } + + try + { + _loopProviderManager.Create((int)controlEvent.LoopChannel, cachedAudio, effectMixer, volume, 0, + _appSettings.Sync.Playback.BalanceMode, balanceFactor: 0); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error occurs while playing looped audio."); + } + } + else if (controlEvent.ControlEventType == ControlEventType.LoopStop) + { + _loopProviderManager.Remove((int)controlEvent.LoopChannel, effectMixer); + } + else if (controlEvent.ControlEventType == ControlEventType.Volume) + { + _loopProviderManager.ChangeAllVolumes(volume); + } + } + + public void ClearAllLoops(IMixingSampleProvider? mixingSampleProvider = null) + { + mixingSampleProvider ??= _playbackEngine.EffectMixer; + _loopProviderManager.RemoveAll(mixingSampleProvider); + } + + public void PauseAllLoops(IMixingSampleProvider? mixingSampleProvider = null) + { + mixingSampleProvider ??= _playbackEngine.EffectMixer; + _loopProviderManager.PauseAll(mixingSampleProvider); + } + + public void ResumeAllLoops(IMixingSampleProvider? mixingSampleProvider = null) + { + mixingSampleProvider ??= _playbackEngine.EffectMixer; + _loopProviderManager.RecoverAll(mixingSampleProvider); + } + + private void OnPlaybackSettingsChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(AppSettingsSyncPlayback.BalanceMode)) + { + _loopProviderManager.ChangeAllBalanceModes(_appSettings.Sync.Playback.BalanceMode); + } + } +} diff --git a/src/Core/KeyAsio.Sync/Runtime/SnareDrumSampleProvider.cs b/src/Core/KeyAsio.Sync/Runtime/SnareDrumSampleProvider.cs new file mode 100644 index 00000000..81c35899 --- /dev/null +++ b/src/Core/KeyAsio.Sync/Runtime/SnareDrumSampleProvider.cs @@ -0,0 +1,318 @@ +using System.Runtime.CompilerServices; +using KeyAsio.Core.Audio.SampleProviders; +using NAudio.Wave; + +namespace KeyAsio.Sync; + +/// +/// 军鼓生成器 +/// +public class SnareDrumSampleProvider : IRecyclableProvider +{ + private readonly WaveFormat _waveFormat; + private uint _rngState; + + // 状态变量 + private long _sampleCount; + private float _snapGain; + private float _snareGain; + private float _phase; + private long _maxDurationSamples; + + // 物理参数配置 (时间单位:秒) + /// + /// 军鼓基频 (Hz) + /// + public float FundamentalFrequency { get; set; } = 180f; + + /// + /// 频率滑落范围 (Hz) + /// + public float FrequencySweepRange { get; set; } = 150f; + + /// + /// 鼓面打击衰减时长 (秒) + /// + public float SnapDecayDuration { get; set; } = 0.08f; + + /// + /// 响弦噪声衰减时长 (秒) + /// + public float SnareDecayDuration { get; set; } = 0.1f; + + private float _totalDuration = 1.0f; + + /// + /// 总时长 (秒) + /// + public float TotalDuration + { + get => _totalDuration; + set + { + _totalDuration = value; + UpdateMaxDurationSamples(); + } + } + + /// + /// 频率滑落时间常数 (秒) + /// + public float PitchDecayTimeConstant { get; set; } = 0.013605442f; + + /// + /// 初始打击增益 (0-1) + /// + public float InitialSnapGain { get; set; } = 0.9f; + + /// + /// 初始响弦增益 (0-1) + /// + public float InitialSnareGain { get; set; } = 0.6f; + + /// + /// 冲击瞬态时长 (秒) + /// + public float ImpactDuration { get; set; } = 0.0035f; + + /// + /// 冲击瞬态强度 (0-1) + /// + public float ImpactLevel { get; set; } = 0.5f; + + /// + /// 鼓面打击混合比例 (0-1) + /// + public float SnapMixLevel { get; set; } = 0.5f; + + /// + /// 响弦噪声混合比例 (0-1) + /// + public float SnareMixLevel { get; set; } = 0.4f; + + public WaveFormat WaveFormat => _waveFormat; + + public ISampleProvider? ResetAndGetSource() + { + Reset(); + return null; + } + + /// + /// 创建一个新的军鼓击打实例 + /// + /// 混合器的目标格式(支持任意采样率和声道数) + public SnareDrumSampleProvider(WaveFormat targetFormat) + { + _waveFormat = targetFormat; + _rngState = (uint)DateTime.Now.Ticks; + + // 初始化增益 + _snapGain = InitialSnapGain; + _snareGain = InitialSnareGain; + _phase = 0; + _sampleCount = 0; + + // 根据采样率计算总采样数 + UpdateMaxDurationSamples(); + } + + private void UpdateMaxDurationSamples() + { + if (_waveFormat != null) + { + _maxDurationSamples = (long)(TotalDuration * _waveFormat.SampleRate); + } + } + + public int Read(float[] buffer, int offset, int count) + { + if (_sampleCount >= _maxDurationSamples) + { + Array.Clear(buffer, offset, count); + return 0; + } + + int samplesRead = 0; + int channels = _waveFormat.Channels; + int sampleRate = _waveFormat.SampleRate; + + // 计算每帧处理多少个采样点 (Buffer是交错的:L, R, L, R...) + // 我们需要填充的 "帧" 数 = count / channels + int samplesToFill = count / channels; + + // 限制处理长度不超过剩余总时长 + long samplesRemainingTotal = _maxDurationSamples - _sampleCount; + if (samplesToFill > samplesRemainingTotal) + { + samplesToFill = (int)samplesRemainingTotal; + } + + // --- 预计算和缓存 (性能优化) --- + // 缓存属性到局部变量,避免循环中重复访问属性 getter + float fundamentalFreq = FundamentalFrequency; + float freqSweepRange = FrequencySweepRange; + float pitchDecayTimeConstant = PitchDecayTimeConstant; + float snapMixLevel = SnapMixLevel; + float snareMixLevel = SnareMixLevel; + float impactLevel = ImpactLevel; + + // 预计算衰减因子 + // MathF.Exp(-1.0f / (sampleRate * PitchDecayTimeConstant)) + float freqDecayFactor = MathF.Exp(-1.0f / (sampleRate * pitchDecayTimeConstant)); + + // 预计算当前频率包络值 (避免循环内调用 Exp) + // 初始值基于当前 _sampleCount + float currentFreqEnv = MathF.Exp(-((float)_sampleCount / sampleRate) / pitchDecayTimeConstant); + + // 预计算 Snap 和 Snare 的衰减因子 + // 原公式: Gain *= (1.0f - 1.0f / (sampleRate * Duration)) + float snapDecayFactor = 1.0f - 1.0f / (sampleRate * SnapDecayDuration); + float snareDecayFactor = 1.0f - 1.0f / (sampleRate * SnareDecayDuration); + + // 预计算 Impact 持续的采样数,避免循环内浮点除法和比较 + long impactDurationSamples = (long)(ImpactDuration * sampleRate); + + // 预计算相位增量常数 + float twoPi = 2f * MathF.PI; + float phaseIncrementBase = twoPi / sampleRate; + + // 将循环分为两部分:有 Impact 的部分和无 Impact 的部分 + long samplesRemainingImpact = impactDurationSamples - _sampleCount; + int countImpact = 0; + if (samplesRemainingImpact > 0) + { + countImpact = (int)Math.Min(samplesToFill, samplesRemainingImpact); + } + + int countNoImpact = samplesToFill - countImpact; + + // 循环 1:包含 Impact 计算 + for (int i = 0; i < countImpact; i++) + { + // --- 合成核心逻辑 (基于单声道计算) --- + + // 2. 合成 "Snap" (正弦波 + 快速频率掉落) + // 使用累乘更新频率包络 + float freq = fundamentalFreq + (freqSweepRange * currentFreqEnv); + currentFreqEnv *= freqDecayFactor; + + _phase += phaseIncrementBase * freq; + if (_phase > twoPi) _phase -= twoPi; + + float snap = MathF.Sin(_phase) * _snapGain; + + // 3. 合成 "Snare" (白噪声) + float noise = FastNextFloat() * _snareGain; + + // 4. "Impact" 瞬态 (存在) + float impact = FastNextFloat() * impactLevel; + + // 5. 混合 + float sampleValue = (snap * snapMixLevel) + (noise * snareMixLevel) + impact; + + // 硬限制器 + if (sampleValue > 1.0f) sampleValue = 1.0f; + else if (sampleValue < -1.0f) sampleValue = -1.0f; + + // --- 声道处理 --- + if (channels == 2) + { + buffer[offset + samplesRead] = sampleValue; + buffer[offset + samplesRead + 1] = sampleValue; + samplesRead += 2; + } + else + { + for (int ch = 0; ch < channels; ch++) + buffer[offset + samplesRead + ch] = sampleValue; + samplesRead += channels; + } + + // 6. 包络衰减 + _snapGain *= snapDecayFactor; + _snareGain *= snareDecayFactor; + if (_snapGain < 0.0001f) _snapGain = 0f; + if (_snareGain < 0.0001f) _snareGain = 0f; + + _sampleCount++; + } + + // 循环 2:不包含 Impact 计算 (Impact = 0) + for (int i = 0; i < countNoImpact; i++) + { + // --- 合成核心逻辑 (基于单声道计算) --- + + // 2. 合成 "Snap" (正弦波 + 快速频率掉落) + // 使用累乘更新频率包络 + float freq = fundamentalFreq + (freqSweepRange * currentFreqEnv); + currentFreqEnv *= freqDecayFactor; + + _phase += phaseIncrementBase * freq; + if (_phase > twoPi) _phase -= twoPi; + + float snap = MathF.Sin(_phase) * _snapGain; + + // 3. 合成 "Snare" (白噪声) + float noise = FastNextFloat() * _snareGain; + + // 4. "Impact" 瞬态 (为 0,省略计算) + // float impact = 0f; + + // 5. 混合 + float sampleValue = (snap * snapMixLevel) + (noise * snareMixLevel); // + 0 + + // 硬限制器 + sampleValue = Math.Clamp(sampleValue, -1.0f, 1.0f); + + // --- 声道处理 --- + if (channels == 2) + { + buffer[offset + samplesRead] = sampleValue; + buffer[offset + samplesRead + 1] = sampleValue; + samplesRead += 2; + } + else + { + for (int ch = 0; ch < channels; ch++) + buffer[offset + samplesRead + ch] = sampleValue; + samplesRead += channels; + } + + // 6. 包络衰减 + _snapGain *= snapDecayFactor; + _snareGain *= snareDecayFactor; + + _sampleCount++; + } + + if (samplesRead < count) + { + Array.Clear(buffer, offset + samplesRead, count - samplesRead); + } + + return samplesRead; + } + + private void Reset() + { + _sampleCount = 0; + _snapGain = InitialSnapGain; + _snareGain = InitialSnareGain; + _phase = 0; + } + + /// + /// 快速随机数生成器 (Xorshift) + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private float FastNextFloat() + { + uint x = _rngState; + x ^= x << 13; + x ^= x >> 17; + x ^= x << 5; + _rngState = x; + return (int)x * 4.6566128752458e-10f; + } +} \ No newline at end of file diff --git a/src/Core/KeyAsio.Sync/Runtime/States/BrowsingState.cs b/src/Core/KeyAsio.Sync/Runtime/States/BrowsingState.cs new file mode 100644 index 00000000..6ec17b22 --- /dev/null +++ b/src/Core/KeyAsio.Sync/Runtime/States/BrowsingState.cs @@ -0,0 +1,41 @@ +using KeyAsio.Plugins.Contracts.Sync; +using KeyAsio.Sync.Sources; +using KeyAsio.Sync.Services; + +namespace KeyAsio.Sync.States; + +public class BrowsingState : IGameState +{ + private readonly GameplaySessionManager _gameplaySessionManager; + + public BrowsingState(GameplaySessionManager gameplaySessionManager) + { + _gameplaySessionManager = gameplaySessionManager; + } + + public Task EnterAsync(SyncSessionContext ctx, OsuMemoryStatus from) + { + _gameplaySessionManager.Stop(); + return Task.CompletedTask; + } + + public void Exit(SyncSessionContext ctx, OsuMemoryStatus to) + { + } + + public void OnTick(SyncSessionContext ctx, int prevMs, int currMs, bool isAudioPaused) + { + } + + public void OnComboChanged(SyncSessionContext ctx, int oldCombo, int newCombo) + { + } + + public void OnBeatmapChanged(SyncSessionContext ctx, BeatmapIdentifier beatmap) + { + } + + public void OnModsChanged(SyncSessionContext ctx, Mods oldMods, Mods newMods) + { + } +} \ No newline at end of file diff --git a/src/Core/KeyAsio.Sync/Runtime/States/IGameState.cs b/src/Core/KeyAsio.Sync/Runtime/States/IGameState.cs new file mode 100644 index 00000000..91049212 --- /dev/null +++ b/src/Core/KeyAsio.Sync/Runtime/States/IGameState.cs @@ -0,0 +1,16 @@ +using KeyAsio.Plugins.Contracts.Sync; +using KeyAsio.Sync.Sources; + +namespace KeyAsio.Sync.States; + +public interface IGameState +{ + Task EnterAsync(SyncSessionContext ctx, OsuMemoryStatus from); + void Exit(SyncSessionContext ctx, OsuMemoryStatus to); + + void OnTick(SyncSessionContext ctx, int prevMs, int currMs, bool isAudioPaused); + + void OnComboChanged(SyncSessionContext ctx, int oldCombo, int newCombo); + void OnBeatmapChanged(SyncSessionContext ctx, BeatmapIdentifier beatmap); + void OnModsChanged(SyncSessionContext ctx, Mods oldMods, Mods newMods); +} \ No newline at end of file diff --git a/src/Core/KeyAsio.Sync/Runtime/States/NotRunningState.cs b/src/Core/KeyAsio.Sync/Runtime/States/NotRunningState.cs new file mode 100644 index 00000000..9d35a0a8 --- /dev/null +++ b/src/Core/KeyAsio.Sync/Runtime/States/NotRunningState.cs @@ -0,0 +1,36 @@ +using KeyAsio.Plugins.Contracts.Sync; +using KeyAsio.Sync.Sources; + +namespace KeyAsio.Sync.States; + +public class NotRunningState : IGameState +{ + public NotRunningState() + { + } + + public Task EnterAsync(SyncSessionContext ctx, OsuMemoryStatus from) + { + return Task.CompletedTask; + } + + public void Exit(SyncSessionContext ctx, OsuMemoryStatus to) + { + } + + public void OnTick(SyncSessionContext ctx, int prevMs, int currMs, bool isAudioPaused) + { + } + + public void OnComboChanged(SyncSessionContext ctx, int oldCombo, int newCombo) + { + } + + public void OnBeatmapChanged(SyncSessionContext ctx, BeatmapIdentifier beatmap) + { + } + + public void OnModsChanged(SyncSessionContext ctx, Mods oldMods, Mods newMods) + { + } +} \ No newline at end of file diff --git a/src/Core/KeyAsio.Sync/Runtime/States/PlayingState.cs b/src/Core/KeyAsio.Sync/Runtime/States/PlayingState.cs new file mode 100644 index 00000000..b2061a5f --- /dev/null +++ b/src/Core/KeyAsio.Sync/Runtime/States/PlayingState.cs @@ -0,0 +1,226 @@ +using KeyAsio.Configuration.Models; +using KeyAsio.Configuration; +using System.Diagnostics; +using Coosu.Beatmap.Sections.GamePlay; +using KeyAsio.Core.Audio; +using KeyAsio.Core.OsuAudio.Hitsounds.Playback; +using KeyAsio.Plugins.Contracts.Sync; +using KeyAsio.Sync.Abstractions; +using KeyAsio.Sync.Models; +using KeyAsio.Sync.Sources; +using KeyAsio.Sync.Services; +using Microsoft.Extensions.Logging; + +namespace KeyAsio.Sync.States; + +public class PlayingState : IGameState +{ + private static readonly long s_hitsoundSyncIntervalTicks = Stopwatch.Frequency / 1000; // 1000hz + + private readonly ILogger _logger; + private readonly IPlaybackEngine _playbackEngine; + private readonly BeatmapHitsoundLoader _beatmapHitsoundLoader; + private readonly SfxPlaybackService _sfxPlaybackService; + private readonly IPlaybackRuntimeState _runtimeState; + private readonly GameplaySessionManager _gameplaySessionManager; + private readonly GameplayAudioService _gameplayAudioService; + private readonly List _playbackBuffer = new(64); + + private long _lastHitsoundSyncTimestamp; + private bool _disableComboBreakSfx; + private bool? _lastIsReplay; + private int _lastObservedLazerMissCount; + private bool _wasAudioPaused; + + public PlayingState( + ILogger logger, + AppSettings appSettings, + IPlaybackEngine playbackEngine, + BeatmapHitsoundLoader beatmapHitsoundLoader, + SfxPlaybackService sfxPlaybackService, + IPlaybackRuntimeState runtimeState, + GameplaySessionManager gameplaySessionManager, + GameplayAudioService gameplayAudioService) + { + _logger = logger; + _playbackEngine = playbackEngine; + _beatmapHitsoundLoader = beatmapHitsoundLoader; + _sfxPlaybackService = sfxPlaybackService; + _runtimeState = runtimeState; + _gameplaySessionManager = gameplaySessionManager; + _gameplayAudioService = gameplayAudioService; + + _disableComboBreakSfx = appSettings.Sync.Filters.DisableComboBreakSfx; + appSettings.Sync.Filters.PropertyChanged += (_, e) => + { + if (e.PropertyName == nameof(AppSettings.Sync.Filters.DisableComboBreakSfx)) + { + _disableComboBreakSfx = appSettings.Sync.Filters.DisableComboBreakSfx; + } + }; + } + + public async Task EnterAsync(SyncSessionContext ctx, OsuMemoryStatus from) + { + _lastHitsoundSyncTimestamp = 0; + _lastObservedLazerMissCount = ctx.Statistics.Miss; + _wasAudioPaused = false; + if (ctx.Beatmap == default) + { + // Beatmap is required to start; keep silent if absent + return; + } + + await _gameplaySessionManager.StartAsync(ctx.Beatmap.FilenameFull?.Trim(), ctx.Beatmap.Filename?.Trim()); + } + + public void Exit(SyncSessionContext ctx, OsuMemoryStatus to) + { + } + + public void OnTick(SyncSessionContext ctx, int prevMs, int currMs, bool isAudioPaused) + { + if (!ctx.IsStarted) return; + + if (_lastIsReplay != ctx.IsReplay) + { + _logger.LogInformation("IsReplay status changed: {Old} -> {New} (Mods: {Mods})", _lastIsReplay, + ctx.IsReplay, ctx.PlayMods); + _lastIsReplay = ctx.IsReplay; + } + + // Retry: song time moved backward during playing + if (prevMs > currMs) + { + OnRetry(ctx); + return; + } + + // Pause/resume loop audio on edge transitions + if (isAudioPaused != _wasAudioPaused) + { + if (isAudioPaused) + { + _sfxPlaybackService.PauseAllLoops(_playbackEngine.EffectMixer); + } + else + { + _sfxPlaybackService.ResumeAllLoops(_playbackEngine.EffectMixer); + } + + _wasAudioPaused = isAudioPaused; + } + + // Skip hitsound sync while paused to avoid timing-window responses + if (isAudioPaused) return; + + var timestamp = ctx.LastUpdateTimestamp; + + // Logic for Hitsounds + if (timestamp - _lastHitsoundSyncTimestamp >= s_hitsoundSyncIntervalTicks) + { + try + { + SyncHitsounds(ctx, currMs); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error syncing hitsounds."); + } + + _lastHitsoundSyncTimestamp = timestamp; + } + } + + public void OnComboChanged(SyncSessionContext ctx, int oldCombo, int newCombo) + { + var hasNewLazerMiss = ctx.ClientType != GameClientType.Lazer || ConsumeLazerMissIncrease(ctx.Statistics.Miss); + + if (_disableComboBreakSfx) return; + if (!ctx.IsStarted) return; + if (ctx.Score == 0) return; + if (newCombo >= oldCombo || oldCombo < 20) return; + if (!hasNewLazerMiss) return; + + if (_gameplayAudioService.TryGetCachedAudio("combobreak", out var cachedAudio)) + { + _sfxPlaybackService.PlayEffectsAudio(cachedAudio, 1, 0); + } + } + + public void OnBeatmapChanged(SyncSessionContext ctx, BeatmapIdentifier beatmap) + { + } + + public void OnModsChanged(SyncSessionContext ctx, Mods oldMods, Mods newMods) + { + } + + private void OnRetry(SyncSessionContext ctx) + { + _lastObservedLazerMissCount = ctx.Statistics.Miss; + _wasAudioPaused = false; + var mixer = _playbackEngine.EffectMixer; + _sfxPlaybackService.ClearAllLoops(mixer); + mixer?.RemoveAllMixerInputs(); + _beatmapHitsoundLoader.ResetNodes(_gameplaySessionManager.CurrentHitsoundSequencer, ctx.PlayTime); + } + + private bool ConsumeLazerMissIncrease(int missCount) + { + if (missCount < _lastObservedLazerMissCount) + { + _lastObservedLazerMissCount = missCount; + return false; + } + + var increased = missCount > _lastObservedLazerMissCount; + _lastObservedLazerMissCount = missCount; + return increased; + } + + private void SyncHitsounds(SyncSessionContext ctx, int newMs) + { + _beatmapHitsoundLoader.AdvanceCachingWindow(newMs); + PlayAutoPlaybackIfNeeded(ctx); + PlayManualPlaybackIfNeeded(ctx); + } + + private void PlayAutoPlaybackIfNeeded(SyncSessionContext ctx) + { + if (!_runtimeState.AutoMode && + (ctx.PlayMods & Mods.Autoplay) == 0 && + (ctx.PlayMods & Mods.Relax) == 0 && + !ctx.IsReplay) return; + + _playbackBuffer.Clear(); + _gameplaySessionManager.CurrentHitsoundSequencer.ProcessAutoPlay(_playbackBuffer, false); + + var count = _playbackBuffer.Count; + for (int i = 0; i < count; i++) + { + var playbackObject = _playbackBuffer[i]; + _sfxPlaybackService.DispatchPlayback(playbackObject); + } + } + + private void PlayManualPlaybackIfNeeded(SyncSessionContext ctx) + { + _playbackBuffer.Clear(); + _gameplaySessionManager.CurrentHitsoundSequencer.ProcessAutoPlay(_playbackBuffer, true); + + var count = _playbackBuffer.Count; + for (int i = 0; i < count; i++) + { + var playbackObject = _playbackBuffer[i]; + if (_gameplaySessionManager.OsuFile.General.Mode == GameMode.Mania && + playbackObject.PlaybackEvent is SampleEvent { Layer: SampleLayer.Sampling }) + { + _sfxPlaybackService.DispatchPlayback(playbackObject, playbackObject.PlaybackEvent.Volume * 0.866666666f); + continue; + } + + _sfxPlaybackService.DispatchPlayback(playbackObject); + } + } +} diff --git a/src/Core/KeyAsio.Sync/Runtime/States/ResultsState.cs b/src/Core/KeyAsio.Sync/Runtime/States/ResultsState.cs new file mode 100644 index 00000000..e091d825 --- /dev/null +++ b/src/Core/KeyAsio.Sync/Runtime/States/ResultsState.cs @@ -0,0 +1,36 @@ +using KeyAsio.Plugins.Contracts.Sync; +using KeyAsio.Sync.Sources; + +namespace KeyAsio.Sync.States; + +public class ResultsState : IGameState +{ + public ResultsState() + { + } + + public Task EnterAsync(SyncSessionContext ctx, OsuMemoryStatus from) + { + return Task.CompletedTask; + } + + public void Exit(SyncSessionContext ctx, OsuMemoryStatus to) + { + } + + public void OnTick(SyncSessionContext ctx, int prevMs, int currMs, bool isAudioPaused) + { + } + + public void OnComboChanged(SyncSessionContext ctx, int oldCombo, int newCombo) + { + } + + public void OnBeatmapChanged(SyncSessionContext ctx, BeatmapIdentifier beatmap) + { + } + + public void OnModsChanged(SyncSessionContext ctx, Mods oldMods, Mods newMods) + { + } +} \ No newline at end of file diff --git a/src/Core/KeyAsio.Sync/Runtime/SyncContextWrapper.cs b/src/Core/KeyAsio.Sync/Runtime/SyncContextWrapper.cs new file mode 100644 index 00000000..53a87baa --- /dev/null +++ b/src/Core/KeyAsio.Sync/Runtime/SyncContextWrapper.cs @@ -0,0 +1,49 @@ +using KeyAsio.Plugins.Contracts; +using KeyAsio.Sync.Sources; + +namespace KeyAsio.Sync; + +public class SyncContextWrapper : ISyncContext +{ + private readonly SyncSessionContext _context; + + private BeatmapIdentifier _cachedIdentifier; + private SyncBeatmapInfo? _cachedInfo; + + public SyncContextWrapper(SyncSessionContext context) + { + _context = context; + } + + public int PlayTime => _context.PlayTime; + public bool IsStarted => _context.IsStarted; + + public SyncOsuStatus OsuStatus => (SyncOsuStatus)_context.OsuStatus; + + public long LastUpdateTimestamp => _context.LastUpdateTimestamp; + + public int PlayMods => (int)_context.PlayMods; + public SyncStatistics Statistics => _context.Statistics; + public SyncHitErrors HitErrors => _context.HitErrors; + public bool IsAudioPaused => _context.IsAudioPaused; + + public SyncBeatmapInfo? Beatmap + { + get + { + var current = _context.Beatmap; + if (current == _cachedIdentifier) return _cachedInfo; + + _cachedIdentifier = current; + _cachedInfo = current.Folder == null + ? null + : new SyncBeatmapInfo + { + Folder = current.Folder, + Filename = current.Filename + }; + + return _cachedInfo; + } + } +} diff --git a/src/Core/KeyAsio.Sync/Runtime/SyncController.cs b/src/Core/KeyAsio.Sync/Runtime/SyncController.cs new file mode 100644 index 00000000..17094177 --- /dev/null +++ b/src/Core/KeyAsio.Sync/Runtime/SyncController.cs @@ -0,0 +1,346 @@ +using KeyAsio.Configuration; +using System.Diagnostics; +using KeyAsio.Core.Audio; +using KeyAsio.Core.Memory.Utils; +using KeyAsio.Plugins.Contracts; +using KeyAsio.Plugins.Contracts.Sync; +using KeyAsio.Sync.Abstractions; +using KeyAsio.Sync.Sources; +using KeyAsio.Sync.AudioProviders; +using KeyAsio.Sync.Services; +using KeyAsio.Sync.States; +using Microsoft.Extensions.Logging; + +namespace KeyAsio.Sync; + +public class SyncController : IDisposable +{ + private readonly SyncSessionContext _syncSessionContext; + private readonly GameStateMachine _stateMachine; + private readonly IPluginManager _pluginManager; + private readonly ILogger _logger; + private readonly List _activeSyncPlugins; + + public SyncController(ILogger playingStateLogger, + ILogger standardSequencerLogger, + ILogger taikoSequencerLogger, + ILogger maniaSequencerLogger, + ILogger catchSequencerLogger, + ILogger logger, + AppSettings appSettings, + IPlaybackEngine playbackEngine, + IPlaybackRuntimeState runtimeState, + GameplayAudioService gameplayAudioService, + BeatmapHitsoundLoader beatmapHitsoundLoader, + SfxPlaybackService sfxPlaybackService, + GameplaySessionManager gameplaySessionManager, + SyncSessionContext syncSessionContext, + IPluginManager pluginManager) + { + _syncSessionContext = syncSessionContext; + _pluginManager = pluginManager; + _logger = logger; + + _activeSyncPlugins = _pluginManager.GetAllPlugins().OfType().ToList(); + + _syncSessionContext.OnBeatmapChanged = OnBeatmapChanged; + _syncSessionContext.OnComboChanged = OnComboChanged; + _syncSessionContext.OnStatusChanged = OnStatusChanged; + _syncSessionContext.OnPlayModsChanged = OnPlayModsChanged; + + var standardAudioProvider = new StandardHitsoundSequencer( + standardSequencerLogger, + appSettings, syncSessionContext, playbackEngine, gameplayAudioService, gameplaySessionManager); + var taikoAudioProvider = new TaikoHitsoundSequencer( + taikoSequencerLogger, + appSettings, syncSessionContext, playbackEngine, gameplayAudioService, gameplaySessionManager); + var maniaAudioProvider = new ManiaHitsoundSequencer( + maniaSequencerLogger, + appSettings, syncSessionContext, playbackEngine, gameplayAudioService, gameplaySessionManager); + var catchAudioProvider = new CatchHitsoundSequencer( + catchSequencerLogger, + appSettings, syncSessionContext, playbackEngine, gameplayAudioService, gameplaySessionManager); + gameplaySessionManager.InitializeProviders(standardAudioProvider, taikoAudioProvider, catchAudioProvider, maniaAudioProvider); + + // Initialize realtime state machine with scene mappings + _stateMachine = new GameStateMachine(new Dictionary + { + [OsuMemoryStatus.Playing] = new PlayingState(playingStateLogger, appSettings, playbackEngine, + beatmapHitsoundLoader, sfxPlaybackService, runtimeState, gameplaySessionManager, + gameplayAudioService), + [OsuMemoryStatus.ResultsScreen] = new ResultsState(), + [OsuMemoryStatus.NotRunning] = new NotRunningState(), + [OsuMemoryStatus.SongSelection] = new BrowsingState(gameplaySessionManager), + [OsuMemoryStatus.EditSongSelection] = new BrowsingState(gameplaySessionManager), + [OsuMemoryStatus.MainView] = new BrowsingState(gameplaySessionManager), + [OsuMemoryStatus.MultiSongSelection] = new BrowsingState(gameplaySessionManager), + }); + } + + private CancellationTokenSource? _syncLoopCts; + + public void Start() + { + if (_syncLoopCts != null) return; + _syncLoopCts = new CancellationTokenSource(); + var token = _syncLoopCts.Token; + + foreach (var plugin in _activeSyncPlugins) + { + try + { + plugin.OnSyncStart(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error starting sync plugin {PluginName} ({PluginId})", plugin.Name, plugin.Id); + } + } + + Task.Factory.StartNew(() => RunSyncLoop(token, _activeSyncPlugins), TaskCreationOptions.LongRunning); + } + + public void Stop() + { + _syncLoopCts?.Cancel(); + _syncLoopCts?.Dispose(); + _syncLoopCts = null; + + foreach (var plugin in _activeSyncPlugins) + { + try + { + plugin.OnSyncStop(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error stopping sync plugin {PluginName} ({PluginId})", plugin.Name, plugin.Id); + } + } + } + + private void RunSyncLoop(CancellationToken token, List plugins) + { + using var highPrecisionTimerScope = new HighPrecisionTimerScope(); + var contextWrapper = new SyncContextWrapper(_syncSessionContext); + + const long intervalMs = 2; // 500Hz + var stopwatch = Stopwatch.StartNew(); + var nextTrigger = stopwatch.ElapsedMilliseconds; + var oldTime = _syncSessionContext.PlayTime; + + // Cache variables + List cachedHandlers = new(); + var cachedStatus = SyncOsuStatus.Unknown; + + while (!token.IsCancellationRequested) + { + var current = stopwatch.ElapsedMilliseconds; + var wait = nextTrigger - current; + + if (wait > 0) + { + Thread.Sleep(Math.Max(0, (int)wait)); + } + + var newTime = _syncSessionContext.PlayTime; + var currentStatus = (SyncOsuStatus)_syncSessionContext.OsuStatus; + + // Update cache if status changed + if (currentStatus != cachedStatus) + { + cachedHandlers = _pluginManager.GetActiveHandlers(currentStatus).ToList(); + cachedStatus = currentStatus; + } + + // Invoke plugins + foreach (var plugin in plugins) + { + try + { + plugin.OnTick(contextWrapper, newTime - oldTime); + } + catch + { + // Suppress plugin errors to keep loop running + } + } + + // Check if any plugin overrides the current state + bool blockBase = false; + foreach (var handler in cachedHandlers) + { + var result = handler.HandleTick(contextWrapper); + if ((result & HandleResult.BlockBaseLogic) != 0) + { + blockBase = true; + } + + if ((result & HandleResult.BlockLowerPriority) != 0) + { + break; + } + } + + if (!blockBase) + { + _stateMachine.Current?.OnTick(_syncSessionContext, oldTime, newTime, _syncSessionContext.IsAudioPaused); + } + + oldTime = newTime; + + nextTrigger += intervalMs; + + if (stopwatch.ElapsedMilliseconds > nextTrigger + 50) + { + nextTrigger = stopwatch.ElapsedMilliseconds; + } + } + } + + private Task OnComboChanged(int oldCombo, int newCombo) + { + _stateMachine.Current?.OnComboChanged(_syncSessionContext, oldCombo, newCombo); + return Task.CompletedTask; + } + + private async Task OnStatusChanged(OsuMemoryStatus oldStatus, OsuMemoryStatus newStatus) + { + var contextWrapper = new SyncContextWrapper(_syncSessionContext); + var oldHandlers = _pluginManager.GetActiveHandlers((SyncOsuStatus)oldStatus); + var newHandlers = _pluginManager.GetActiveHandlers((SyncOsuStatus)newStatus); + + // 1. Exit Old State + bool blockBaseExit = false; + foreach (var handler in oldHandlers) + { + try + { + var result = handler.HandleExit(contextWrapper); + if ((result & HandleResult.BlockBaseLogic) != 0) + { + blockBaseExit = true; + } + + if ((result & HandleResult.BlockLowerPriority) != 0) + { + break; + } + } + catch + { + // Ignore + } + } + + if (!blockBaseExit) + { + _stateMachine.ExitCurrent(_syncSessionContext, newStatus); + } + + // 2. Enter New State + bool blockBaseEnter = false; + foreach (var handler in newHandlers) + { + try + { + var result = handler.HandleEnter(contextWrapper); + if ((result & HandleResult.BlockBaseLogic) != 0) + { + blockBaseEnter = true; + } + + if ((result & HandleResult.BlockLowerPriority) != 0) + { + break; + } + } + catch + { + // Ignore + } + } + + if (!blockBaseEnter) + { + await _stateMachine.EnterFromAsync(_syncSessionContext, oldStatus, newStatus); + } + + foreach (var plugin in _activeSyncPlugins) + { + try + { + plugin.OnStatusChanged((SyncOsuStatus)oldStatus, (SyncOsuStatus)newStatus); + } + catch + { + // Ignore + } + } + } + + private Task OnBeatmapChanged(BeatmapIdentifier oldBeatmap, BeatmapIdentifier newBeatmap) + { + var absBeatmap = new SyncBeatmapInfo + { + Folder = newBeatmap.Folder, + Filename = newBeatmap.Filename + }; + + // Notify plugins (Legacy behavior, always notified) + foreach (var plugin in _activeSyncPlugins) + { + try + { + plugin.OnBeatmapChanged(absBeatmap); + } + catch + { + // Ignore + } + } + + // Notify active handlers + var handlers = _pluginManager.GetActiveHandlers((SyncOsuStatus)_syncSessionContext.OsuStatus); + bool blockBase = false; + foreach (var handler in handlers) + { + try + { + var result = handler.HandleBeatmapChange(absBeatmap); + + if ((result & HandleResult.BlockBaseLogic) != 0) + { + blockBase = true; + } + + if ((result & HandleResult.BlockLowerPriority) != 0) + { + break; + } + } + catch + { + // Ignore + } + } + + if (!blockBase) + { + _stateMachine.Current?.OnBeatmapChanged(_syncSessionContext, newBeatmap); + } + + return Task.CompletedTask; + } + + private Task OnPlayModsChanged(Mods oldMods, Mods newMods) + { + _stateMachine.Current?.OnModsChanged(_syncSessionContext, oldMods, newMods); + return Task.CompletedTask; + } + + public void Dispose() + { + Stop(); + } +} diff --git a/src/Core/KeyAsio.Sync/Runtime/SyncSessionContext.cs b/src/Core/KeyAsio.Sync/Runtime/SyncSessionContext.cs new file mode 100644 index 00000000..9aa055e7 --- /dev/null +++ b/src/Core/KeyAsio.Sync/Runtime/SyncSessionContext.cs @@ -0,0 +1,296 @@ +using System.Diagnostics; +using System.Runtime.CompilerServices; +using System.Text; +using KeyAsio.Configuration; +using KeyAsio.Configuration.Models; +using KeyAsio.Core.OsuAudio.Hitsounds; +using KeyAsio.Plugins.Contracts; +using KeyAsio.Plugins.Contracts.Sync; +using KeyAsio.Sync.Events; +using KeyAsio.Sync.Sources; +using KeyAsio.Common; + +namespace KeyAsio.Sync; + +public class SyncSessionContext +{ + public ValueChangedAsyncEventHandler? OnComboChanged; + public ValueChangedAsyncEventHandler? OnPlayModsChanged; + public ValueChangedAsyncEventHandler? OnStatusChanged; + public ValueChangedAsyncEventHandler? OnBeatmapChanged; + + private readonly AppSettings _appSettings; + + private long _anchorTick; // 锚点 Tick + private int _anchorTime; // 锚点时间 (ms) + private double _playbackRate = 1.0; + private static readonly double s_tickToMs = 1000.0 / Stopwatch.Frequency; + + // 音频同步与防倒退 + private int _lastReturnedPlayTime = int.MinValue; + + private long _frozenStartTick; // 冻结检测 + private bool _isFrozen; + private const int FrozenTimeoutMs = 200; // 冻结超时阈值 + + public SyncSessionContext(AppSettings appSettings) + { + _appSettings = appSettings; + ClientType = GameClientType.Stable; // 默认值 + + _anchorTick = Stopwatch.GetTimestamp(); + } + + public GameClientType ClientType { get; set; } + public bool IsStarted { get; set; } + public bool IsReplay { get; set; } + + /// + /// 音频是否处于暂停/冻结状态。 + /// 由 PlayTime 的单调性保护逻辑自动维护:当预测时间持续微小倒退时为 true,恢复正常前进时为 false。 + /// + public bool IsAudioPaused => _isFrozen; + public int ProcessId { get; set; } = -1; + + public string? Username + { + get; + set + { + if (field == value) return; + field = value; + if (!string.IsNullOrEmpty(value)) + { + _appSettings.Logging.PlayerBase64 = EncodeUtils.GetBase64String(value, Encoding.ASCII); + } + } + } + + public Mods PlayMods + { + get; + set + { + if (field == value) return; + var oldValue = field; + field = value; + + UpdatePlaybackRate(value); + OnPlayModsChanged?.Invoke(oldValue, value); + } + } + + private void UpdatePlaybackRate(Mods mods) + { + var oldRate = _playbackRate; + + if (mods.HasFlag(Mods.DoubleTime) || mods.HasFlag(Mods.Nightcore)) + _playbackRate = 1.5; + else if (mods.HasFlag(Mods.HalfTime)) + _playbackRate = 0.75; + else + _playbackRate = 1.0; + + // 倍率改变极其罕见,但若发生,需要重置锚点以保证连续性 + if (Math.Abs(oldRate - _playbackRate) > 0.001) + { + // 获取当前的预测值(不带副作用) + int currentPredictedTime = CalculatePredictedTime(); + + // 重新锚定 + _anchorTick = Stopwatch.GetTimestamp(); + _anchorTime = currentPredictedTime; + + // 重要:同步更新最后返回时间,防止因重置导致的微小回退 + _lastReturnedPlayTime = currentPredictedTime; + _isFrozen = false; + } + } + + /// + /// 根据当前状态计算预测时间。 + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private int CalculatePredictedTime() + { + if (OsuStatus is OsuMemoryStatus.NotRunning or OsuMemoryStatus.Unknown) + { + return _anchorTime; + } + + double effectiveRate = _playbackRate; + if (ClientType == GameClientType.Stable && OsuStatus != OsuMemoryStatus.Playing) + { + effectiveRate = 1.0; + } + + long currentTick = Stopwatch.GetTimestamp(); + double elapsedRealMs = (currentTick - _anchorTick) * s_tickToMs; + + // 限制最大预测范围(例如防止暂停后恢复时的瞬间飞跃),这里取 500ms 作为安全上限 + // 如果是正常游玩,BaseMemoryTime 会频繁更新,不会超过这个值 + const int realMs = 100; + if (elapsedRealMs > realMs) elapsedRealMs = realMs; + + double interpolatedMs = elapsedRealMs * effectiveRate; + return _anchorTime + (int)interpolatedMs; + } + + public int PlayTime + { + get + { + // 1. 获取纯计算的目标时间 + int targetTime = CalculatePredictedTime(); + + // 2. 音频同步关键:单调性与跳跃保护 + if (targetTime < _lastReturnedPlayTime) + { + int backwardAmount = _lastReturnedPlayTime - targetTime; + + // Case A: 大幅度倒退 (> 100ms) -> 视为 Seek 或 Retry + if (backwardAmount > 100) + { + _lastReturnedPlayTime = targetTime; + _isFrozen = false; + } + // Case B: 微小倒退 -> 启动/维持冻结逻辑 + else + { + var currentTick = Stopwatch.GetTimestamp(); + + if (!_isFrozen) + { + // 刚开始倒退,进入冻结 + _isFrozen = true; + _frozenStartTick = currentTick; + } + else + { + // 已经冻结,检查是否超时 + double frozenDuration = (currentTick - _frozenStartTick) * s_tickToMs; + if (frozenDuration > FrozenTimeoutMs) + { + // 超时:强制同步(可能是游戏真的卡顿后回退了) + _lastReturnedPlayTime = targetTime; + _isFrozen = false; + } + } + + // 冻结期间,返回旧值 + return _lastReturnedPlayTime; + } + } + else + { + // 正常前进 + _lastReturnedPlayTime = targetTime; + _isFrozen = false; + } + + return _lastReturnedPlayTime; + } + } + + public int BaseMemoryTime + { + get => _anchorTime; + set + { + var currentTick = Stopwatch.GetTimestamp(); + var oldValue = _anchorTime; + + // 1. 极小抖动过滤 (内存读取误差) + if (value < oldValue && oldValue - value < 5) return; + + // 2. 只有值真正改变才处理 + if (value != oldValue) + { + int predicted = CalculatePredictedTime(); + + // 动态阈值:DT 模式下允许更大的预测误差 + double dynamicThreshold = 50 * Math.Max(1.0, _playbackRate); + + // 检测是否发生了“大跳跃”(Seek / Retry) + // 如果内存读到的新值和我们预测的值差距过大,说明预测失效,需要硬同步 + if (Math.Abs(value - predicted) > dynamicThreshold) + { + // 允许跳跃,重置单调性保护 + _lastReturnedPlayTime = int.MinValue; + _isFrozen = false; + } + + _anchorTime = value; + _anchorTick = currentTick; + } + + LastUpdateTimestamp = currentTick; + } + } + + public int Combo + { + get; + set + { + if (field == value) return; + var oldValue = field; + field = value; + OnComboChanged?.Invoke(oldValue, value); + } + } + + public int Score { get; set; } + public SyncStatistics Statistics { get; set; } = SyncStatistics.Empty; + public SyncHitErrors HitErrors { get; set; } = SyncHitErrors.Empty; + public IBeatmapResourceCatalog? BeatmapResourceCatalog { get; set; } + + public OsuMemoryStatus OsuStatus + { + get; + set + { + if (field == value) return; + var oldValue = field; + field = value; + + // 状态切换时,重置所有保护逻辑,允许时间跳变 + _lastReturnedPlayTime = int.MinValue; + _isFrozen = false; + + if (value != OsuMemoryStatus.Playing) + { + Statistics = SyncStatistics.Empty; + HitErrors = SyncHitErrors.Empty; + } + + // 重置锚点 + _anchorTick = Stopwatch.GetTimestamp(); + + OnStatusChanged?.Invoke(oldValue, value); + } + } = OsuMemoryStatus.Unknown; + + public string SyncedStatusText => OsuStatus is OsuMemoryStatus.NotRunning or OsuMemoryStatus.Unknown + ? "OFFLINE" + : "SYNCED"; + + public BeatmapIdentifier Beatmap + { + get; + set + { + if (field == value) return; + var oldValue = field; + field = value; + OnBeatmapChanged?.Invoke(oldValue, value); + } + } + + public long LastUpdateTimestamp + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get; + private set; + } +} diff --git a/src/Core/KeyAsio.Sync/Sources/BeatmapIdentifier.cs b/src/Core/KeyAsio.Sync/Sources/BeatmapIdentifier.cs new file mode 100644 index 00000000..bd579763 --- /dev/null +++ b/src/Core/KeyAsio.Sync/Sources/BeatmapIdentifier.cs @@ -0,0 +1,30 @@ +namespace KeyAsio.Sync.Sources; + +public readonly record struct BeatmapIdentifier +{ + public BeatmapIdentifier(string? filenameFull) + { + FilenameFull = filenameFull; + if (filenameFull != null) + { + Folder = Path.GetDirectoryName(filenameFull); + Filename = Path.GetFileName(filenameFull); + } + } + + public BeatmapIdentifier(string? folder, string? filename) + { + Folder = folder; + Filename = filename; + if (folder != null && filename != null) + { + FilenameFull = Path.Combine(folder, filename); + } + } + + public string? FilenameFull { get; } + public string? Folder { get; } + public string? Filename { get; } + + public static BeatmapIdentifier Default = new(); +} \ No newline at end of file diff --git a/src/Core/KeyAsio.Sync/Sources/GameSyncSnapshot.cs b/src/Core/KeyAsio.Sync/Sources/GameSyncSnapshot.cs new file mode 100644 index 00000000..deadd5b6 --- /dev/null +++ b/src/Core/KeyAsio.Sync/Sources/GameSyncSnapshot.cs @@ -0,0 +1,83 @@ +using KeyAsio.Configuration.Models; +using KeyAsio.Core.OsuAudio.Hitsounds; +using KeyAsio.Plugins.Contracts; +using KeyAsio.Plugins.Contracts.Sync; +using KeyAsio.Sync; + +namespace KeyAsio.Sync.Sources; + +/// +/// Mutable game sync snapshot. A single instance is reused per sync source to +/// avoid per-frame allocations on the high-frequency memory-scan / IPC paths. +/// Consumers must read fields synchronously inside +/// or and must not retain the +/// reference across invocations. +/// +public sealed class GameSyncSnapshot +{ + public GameClientType ClientType { get; set; } = GameClientType.Stable; + public int ProcessId { get; set; } + public string? Username { get; set; } + public Mods PlayMods { get; set; } + public bool IsReplay { get; set; } + public int Score { get; set; } + public int Combo { get; set; } + public SyncStatistics Statistics { get; set; } = SyncStatistics.Empty; + public SyncHitErrors HitErrors { get; set; } = SyncHitErrors.Empty; + public BeatmapIdentifier Beatmap { get; set; } + public IBeatmapResourceCatalog? BeatmapResourceCatalog { get; set; } + public int PlayTime { get; set; } + public OsuMemoryStatus Status { get; set; } = OsuMemoryStatus.Unknown; + + public static GameSyncSnapshot NotRunning(GameClientType clientType) => new() + { + ClientType = clientType, + Status = OsuMemoryStatus.NotRunning, + Statistics = SyncStatistics.Empty, + HitErrors = SyncHitErrors.Empty + }; + + public GameSyncSnapshot Clone() => new() + { + ClientType = ClientType, + ProcessId = ProcessId, + Username = Username, + PlayMods = PlayMods, + IsReplay = IsReplay, + Score = Score, + Combo = Combo, + Statistics = Statistics, + HitErrors = CloneHitErrors(HitErrors), + Beatmap = Beatmap, + BeatmapResourceCatalog = BeatmapResourceCatalog, + PlayTime = PlayTime, + Status = Status + }; + + /// + /// Resets this instance to a disconnected state in place, avoiding a new + /// allocation on the low-frequency disconnect path. + /// + public void ResetToNotRunning(GameClientType clientType) + { + ClientType = clientType; + ProcessId = 0; + Username = null; + PlayMods = default; + IsReplay = false; + Score = 0; + Combo = 0; + Statistics = SyncStatistics.Empty; + HitErrors = SyncHitErrors.Empty; + Beatmap = default; + BeatmapResourceCatalog = null; + PlayTime = 0; + Status = OsuMemoryStatus.NotRunning; + } + + private static SyncHitErrors CloneHitErrors(SyncHitErrors hitErrors) + { + var values = hitErrors.Values; + return new SyncHitErrors(hitErrors.Index, values.Length == 0 ? [] : (int[])values.Clone()); + } +} diff --git a/src/Core/KeyAsio.Sync/Sources/GameSyncSourceCoordinator.cs b/src/Core/KeyAsio.Sync/Sources/GameSyncSourceCoordinator.cs new file mode 100644 index 00000000..5639d011 --- /dev/null +++ b/src/Core/KeyAsio.Sync/Sources/GameSyncSourceCoordinator.cs @@ -0,0 +1,161 @@ +using KeyAsio.Configuration.Models; +using KeyAsio.Plugins.Contracts.Sync; +using KeyAsio.Sync; +using Microsoft.Extensions.Logging; + +namespace KeyAsio.Sync.Sources; + +public sealed class GameSyncSourceCoordinator +{ + private readonly SyncSessionContext _syncSessionContext; + private readonly IGameSyncSource[] _sources; + private readonly ILogger _logger; + private IGameSyncSource? _activeSource; + private bool _started; + private GameSyncSnapshot? _disconnectedSnapshot; + private GameClientType _lastClientType = GameClientType.Stable; + + public event Action? ClientTypeChanged; + + public GameSyncSourceCoordinator( + SyncSessionContext syncSessionContext, + IEnumerable sources, + ILogger logger) + { + _syncSessionContext = syncSessionContext; + _sources = sources.OrderByDescending(source => source.Priority).ToArray(); + _logger = logger; + + foreach (var source in _sources) + { + source.AvailabilityChanged += OnSourceAvailabilityChanged; + source.SnapshotReceived += OnSourceSnapshotReceived; + } + } + + public void Start() + { + if (_started) return; + _started = true; + + foreach (var source in _sources) + { + source.Start(); + } + + SelectActiveSource(applyCurrentSnapshot: true); + } + + public async Task StopAsync() + { + if (!_started) return; + _started = false; + + foreach (var source in _sources) + { + await source.StopAsync(); + } + + _activeSource = null; + ApplyDisconnectedSnapshot(GameClientType.Stable); + } + + private void OnSourceAvailabilityChanged(IGameSyncSource source, bool isAvailable) + { + if (!_started) return; + + if (!isAvailable && ReferenceEquals(_activeSource, source)) + { + _logger.LogInformation("Game sync source unavailable: {Source}", source.Name); + } + else if (isAvailable) + { + _logger.LogInformation("Game sync source available: {Source}", source.Name); + } + + SelectActiveSource(applyCurrentSnapshot: true); + } + + private void OnSourceSnapshotReceived(IGameSyncSource source, GameSyncSnapshot snapshot) + { + if (!_started) return; + + SelectActiveSource(applyCurrentSnapshot: false); + if (!ReferenceEquals(_activeSource, source)) + { + return; + } + + ApplySnapshot(snapshot); + } + + private void SelectActiveSource(bool applyCurrentSnapshot) + { + var nextSource = _sources.FirstOrDefault(source => source.IsAvailable); + if (ReferenceEquals(_activeSource, nextSource)) + { + return; + } + + var oldSource = _activeSource; + _activeSource = nextSource; + + _logger.LogInformation("Game sync source switched: {OldSource} -> {NewSource}", + oldSource?.Name ?? "none", nextSource?.Name ?? "none"); + + if (nextSource != null) + { + if (applyCurrentSnapshot) + { + ApplySnapshot(nextSource.CurrentSnapshot); + } + } + else + { + ApplyDisconnectedSnapshot(oldSource?.ClientType ?? GameClientType.Stable); + } + } + + private void ApplySnapshot(GameSyncSnapshot snapshot) + { + try + { + _syncSessionContext.ClientType = snapshot.ClientType; + if (snapshot.ClientType != _lastClientType) + { + _lastClientType = snapshot.ClientType; + ClientTypeChanged?.Invoke(snapshot.ClientType); + } + _syncSessionContext.ProcessId = snapshot.ProcessId; + _syncSessionContext.Username = snapshot.Username; + _syncSessionContext.PlayMods = snapshot.PlayMods; + _syncSessionContext.IsReplay = snapshot.IsReplay; + _syncSessionContext.Score = snapshot.Score; + _syncSessionContext.Statistics = snapshot.Statistics; + _syncSessionContext.HitErrors = snapshot.HitErrors; + _syncSessionContext.BeatmapResourceCatalog = snapshot.BeatmapResourceCatalog; + _syncSessionContext.Beatmap = snapshot.Beatmap; + _syncSessionContext.BaseMemoryTime = snapshot.PlayTime; + _syncSessionContext.OsuStatus = snapshot.Status; + _syncSessionContext.Combo = snapshot.Combo; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to apply game sync snapshot from {ClientType}.", snapshot.ClientType); + } + } + + private void ApplyDisconnectedSnapshot(GameClientType disconnectedClientType) + { + var fallbackClientType = disconnectedClientType == GameClientType.Lazer + ? GameClientType.Stable + : disconnectedClientType; + + // Reuse a single disconnected snapshot to avoid allocating on the + // low-frequency disconnect path. ApplySnapshot only reads the fields + // synchronously, so mutating a shared instance is safe. + _disconnectedSnapshot ??= GameSyncSnapshot.NotRunning(fallbackClientType); + _disconnectedSnapshot.ResetToNotRunning(fallbackClientType); + ApplySnapshot(_disconnectedSnapshot); + } +} diff --git a/src/Core/KeyAsio.Sync/Sources/IGameSyncSource.cs b/src/Core/KeyAsio.Sync/Sources/IGameSyncSource.cs new file mode 100644 index 00000000..b74abf49 --- /dev/null +++ b/src/Core/KeyAsio.Sync/Sources/IGameSyncSource.cs @@ -0,0 +1,19 @@ +using KeyAsio.Configuration.Models; +using KeyAsio.Sync; + +namespace KeyAsio.Sync.Sources; + +public interface IGameSyncSource +{ + string Name { get; } + GameClientType ClientType { get; } + int Priority { get; } + bool IsAvailable { get; } + GameSyncSnapshot CurrentSnapshot { get; } + + event Action? AvailabilityChanged; + event Action? SnapshotReceived; + + void Start(); + Task StopAsync(); +} diff --git a/src/Core/KeyAsio.Sync/Sources/LazerIpcBridge.cs b/src/Core/KeyAsio.Sync/Sources/LazerIpcBridge.cs new file mode 100644 index 00000000..43706e24 --- /dev/null +++ b/src/Core/KeyAsio.Sync/Sources/LazerIpcBridge.cs @@ -0,0 +1,259 @@ +using System.Buffers; +using System.Buffers.Binary; +using System.Collections.Concurrent; +using System.IO.Pipes; +using KeyAsio.LazerProtocol; +using Microsoft.Extensions.Logging; + +namespace KeyAsio.Sync.Sources; + +public sealed class LazerIpcBridge : IDisposable +{ + public const string PipeName = LazerProtocolConstants.TimingPipeName; + public const string EventPipeName = LazerProtocolConstants.EventPipeName; + public const int ProtocolVersion = LazerProtocolConstants.ProtocolVersion; + // Event frames may include lazer skin and file metadata; large skin libraries can exceed several MB. + private const int MaxFrameLength = 64 * 1024 * 1024; + + private readonly ILogger _logger; + private readonly ConcurrentDictionary _clientTasks = new(); + private CancellationTokenSource? _cts; + private Task[]? _acceptLoopTasks; + private int _clientCount; + private int _timingClientCount; + private int _eventClientCount; + + public LazerIpcBridge(ILogger logger) + { + _logger = logger; + } + + public bool IsTimingConnected => Volatile.Read(ref _timingClientCount) > 0; + public bool IsEventsConnected => Volatile.Read(ref _eventClientCount) > 0; + public bool IsAnyChannelConnected => IsTimingConnected || IsEventsConnected; + + public event Action? ChannelConnectionChanged; + public event Action? FrameReceived; + + public void Start() + { + if (_acceptLoopTasks != null) return; + + _cts = new CancellationTokenSource(); + _acceptLoopTasks = + [ + Task.Run(() => AcceptLoopAsync(LazerIpcChannel.Timing, PipeName, _cts.Token)), + Task.Run(() => AcceptLoopAsync(LazerIpcChannel.Events, EventPipeName, _cts.Token)), + ]; + } + + public async Task StopAsync() + { + if (_cts == null) return; + + await _cts.CancelAsync(); + + if (_acceptLoopTasks != null) + { + try + { + await Task.WhenAll(_acceptLoopTasks); + } + catch (OperationCanceledException) + { + // Expected during shutdown. + } + } + + var clientTasks = _clientTasks.Keys.ToArray(); + if (clientTasks.Length > 0) + { + await Task.WhenAll(clientTasks); + } + + _cts.Dispose(); + _cts = null; + _acceptLoopTasks = null; + } + + private async Task AcceptLoopAsync(LazerIpcChannel channel, string pipeName, CancellationToken token) + { + while (!token.IsCancellationRequested) + { + var server = new NamedPipeServerStream(pipeName, PipeDirection.In, + NamedPipeServerStream.MaxAllowedServerInstances, PipeTransmissionMode.Byte, + PipeOptions.Asynchronous); + + try + { + await server.WaitForConnectionAsync(token); + TrackClientTask(Task.Run(() => HandleClientAsync(server, channel, pipeName, token), + CancellationToken.None)); + } + catch (OperationCanceledException) + { + await server.DisposeAsync(); + break; + } + catch (Exception ex) + { + await server.DisposeAsync(); + _logger.LogWarning(ex, "Failed to accept lazer IPC client on pipe {PipeName}.", pipeName); + + try + { + await Task.Delay(1000, token); + } + catch (OperationCanceledException) + { + break; + } + } + } + } + + private void TrackClientTask(Task task) + { + _clientTasks.TryAdd(task, 0); + _ = task.ContinueWith(static (completedTask, state) => + { + var tasks = (ConcurrentDictionary)state!; + tasks.TryRemove(completedTask, out _); + }, _clientTasks, CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); + } + + private async Task HandleClientAsync(NamedPipeServerStream server, LazerIpcChannel channel, string pipeName, + CancellationToken token) + { + var oldChannelCount = IncrementChannelClientCount(channel) - 1; + if (oldChannelCount == 0) + { + ChannelConnectionChanged?.Invoke(channel, false, true); + } + + var oldCount = Interlocked.Increment(ref _clientCount) - 1; + if (oldCount == 0) + { + _logger.LogInformation("osu!lazer IPC bridge connected."); + } + + _logger.LogDebug("osu!lazer IPC pipe connected: {PipeName}.", pipeName); + + await using (server) + { + var lengthBuffer = new byte[sizeof(int)]; + try + { + while (!token.IsCancellationRequested && server.IsConnected) + { + LazerDeltaFrame? frame; + try + { + frame = await ReadFrameAsync(server, lengthBuffer, token); + } + catch (InvalidDataException ex) + { + _logger.LogDebug(ex, "Ignoring malformed lazer IPC frame."); + continue; + } + + if (frame == null) break; + + if (frame.Version != ProtocolVersion) + { + _logger.LogDebug("Ignoring unsupported lazer IPC protocol version {Version}.", frame.Version); + continue; + } + + FrameReceived?.Invoke(channel, frame); + } + } + catch (OperationCanceledException) + { + // Expected during shutdown. + } + catch (IOException ex) + { + _logger.LogDebug(ex, "osu!lazer IPC pipe disconnected: {PipeName}.", pipeName); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Unexpected lazer IPC bridge error on pipe {PipeName}.", pipeName); + } + } + + var newChannelCount = DecrementChannelClientCount(channel); + if (newChannelCount == 0) + { + ChannelConnectionChanged?.Invoke(channel, true, false); + } + + var newCount = Interlocked.Decrement(ref _clientCount); + _logger.LogDebug("osu!lazer IPC pipe disconnected: {PipeName}.", pipeName); + if (newCount == 0) + { + _logger.LogInformation("osu!lazer IPC bridge disconnected."); + } + } + + private int IncrementChannelClientCount(LazerIpcChannel channel) + { + return channel switch + { + LazerIpcChannel.Timing => Interlocked.Increment(ref _timingClientCount), + LazerIpcChannel.Events => Interlocked.Increment(ref _eventClientCount), + _ => throw new ArgumentOutOfRangeException(nameof(channel), channel, null) + }; + } + + private int DecrementChannelClientCount(LazerIpcChannel channel) + { + return channel switch + { + LazerIpcChannel.Timing => Interlocked.Decrement(ref _timingClientCount), + LazerIpcChannel.Events => Interlocked.Decrement(ref _eventClientCount), + _ => throw new ArgumentOutOfRangeException(nameof(channel), channel, null) + }; + } + + private async ValueTask ReadFrameAsync(Stream stream, byte[] lengthBuffer, + CancellationToken token) + { + try + { + await stream.ReadExactlyAsync(lengthBuffer, token); + } + catch (EndOfStreamException) + { + return null; + } + + var length = BinaryPrimitives.ReadInt32LittleEndian(lengthBuffer); + if (length <= 0 || length > MaxFrameLength) + { + throw new IOException($"Invalid lazer IPC frame length: {length}."); + } + + var buffer = ArrayPool.Shared.Rent(length); + try + { + await stream.ReadExactlyAsync(buffer.AsMemory(0, length), token); + return LazerDeltaFrame.Parse(buffer.AsSpan(0, length)); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + + public void Dispose() + { + StopAsync().GetAwaiter().GetResult(); + } +} + +public enum LazerIpcChannel +{ + Timing, + Events, +} diff --git a/src/Core/KeyAsio.Sync/Sources/LazerIpcFrame.cs b/src/Core/KeyAsio.Sync/Sources/LazerIpcFrame.cs new file mode 100644 index 00000000..6d8bc1eb --- /dev/null +++ b/src/Core/KeyAsio.Sync/Sources/LazerIpcFrame.cs @@ -0,0 +1,137 @@ +using KeyAsio.LazerProtocol; +using KeyAsio.Plugins.Contracts; + +namespace KeyAsio.Sync.Sources; + +public sealed class LazerIpcFrame +{ + public int Version { get; private set; } + public int ProcessId { get; private set; } + public int Status { get; private set; } + public int PlayTime { get; private set; } + public uint Mods { get; private set; } + public int Combo { get; private set; } + public int Score { get; private set; } + public bool IsReplay { get; private set; } + public string? Username { get; private set; } + public string? BeatmapFolder { get; private set; } + public string? BeatmapFilename { get; private set; } + public LazerFile[] BeatmapFiles { get; private set; } = []; + public SyncStatistics Statistics { get; private set; } + public int HitErrorIndex { get; private set; } + public int[] HitErrors { get; private set; } = []; + public LazerSkinInfo[]? SkinInfos { get; private set; } + public string? UserDataDirectory { get; private set; } + public string? ExeDirectory { get; private set; } + + public void Reset() + { + Version = 0; + ProcessId = 0; + Status = 0; + PlayTime = 0; + Mods = 0; + Combo = 0; + Score = 0; + IsReplay = false; + Username = null; + BeatmapFolder = null; + BeatmapFilename = null; + BeatmapFiles = []; + Statistics = SyncStatistics.Empty; + HitErrorIndex = 0; + HitErrors = []; + SkinInfos = null; + UserDataDirectory = null; + ExeDirectory = null; + } + + public void ClearBeatmapFiles() + { + BeatmapFiles = []; + } + + public bool HasLazerSkinInfos => SkinInfos != null; + + public void Apply(LazerDeltaFrame deltaFrame) + { + Version = deltaFrame.Version; + + foreach (var field in deltaFrame.Fields) + { + switch (field.Kind) + { + case LazerFieldKind.ProcessId: + ProcessId = field.IntValue; + break; + + case LazerFieldKind.Status: + Status = field.IntValue; + break; + + case LazerFieldKind.PlayTime: + PlayTime = field.IntValue; + break; + + case LazerFieldKind.Mods: + Mods = field.UIntValue; + break; + + case LazerFieldKind.Combo: + Combo = field.IntValue; + break; + + case LazerFieldKind.Score: + Score = field.IntValue; + break; + + case LazerFieldKind.IsReplay: + IsReplay = field.BoolValue; + break; + + case LazerFieldKind.Username: + Username = field.StringValue; + break; + + case LazerFieldKind.BeatmapFolder: + BeatmapFolder = field.StringValue; + break; + + case LazerFieldKind.BeatmapFilename: + BeatmapFilename = field.StringValue; + break; + + case LazerFieldKind.BeatmapFiles: + BeatmapFiles = field.FilesValue ?? []; + break; + + case LazerFieldKind.Statistics: + Statistics = field.StatisticsValue.ToSyncStatistics(); + break; + + case LazerFieldKind.HitErrors: + HitErrorIndex = field.IntValue; + HitErrors = field.IntArrayValue ?? []; + break; + + case LazerFieldKind.SkinInfos: + SkinInfos = field.SkinInfosValue; + break; + + case LazerFieldKind.UserDataDirectory: + UserDataDirectory = field.StringValue; + break; + + case LazerFieldKind.ExeDirectory: + ExeDirectory = field.StringValue; + break; + } + } + } +} + +internal static class LazerStatisticsExtensions +{ + public static SyncStatistics ToSyncStatistics(this LazerStatistics statistics) + => new(statistics.Perfect, statistics.Great, statistics.Good, statistics.Ok, statistics.Meh, statistics.Miss); +} \ No newline at end of file diff --git a/src/Core/KeyAsio.Sync/Sources/LazerIpcGameSyncSource.cs b/src/Core/KeyAsio.Sync/Sources/LazerIpcGameSyncSource.cs new file mode 100644 index 00000000..c5230e21 --- /dev/null +++ b/src/Core/KeyAsio.Sync/Sources/LazerIpcGameSyncSource.cs @@ -0,0 +1,251 @@ +using KeyAsio.Configuration.Models; +using KeyAsio.Core.OsuAudio.Hitsounds; +using KeyAsio.LazerProtocol; +using KeyAsio.Plugins.Contracts; +using KeyAsio.Plugins.Contracts.Sync; +using KeyAsio.Sync; + +namespace KeyAsio.Sync.Sources; + +public sealed class LazerIpcGameSyncSource : IGameSyncSource +{ + private readonly LazerIpcBridge _lazerIpcBridge; + private readonly GameSyncSnapshot _snapshot; + private readonly LazerIpcFrame _frame = new(); + private readonly object frameLock = new(); + private bool _eventsBound; + private bool _connected; + private bool _hasTimingFrame; + private bool _hasEventFrame; + private IBeatmapResourceCatalog? _resourceCatalog; + private LazerSkinInfo[]? _lastPublishedSkinInfos; + private string? _lastPublishedUserDataDirectory; + private string? _lastPublishedExeDirectory; + + public event Action? LazerSkinContextReceived; + + public LazerIpcGameSyncSource(LazerIpcBridge lazerIpcBridge) + { + _lazerIpcBridge = lazerIpcBridge; + _snapshot = GameSyncSnapshot.NotRunning(ClientType); + CurrentSnapshot = _snapshot; + } + + public string Name => "osu!lazer IPC"; + public GameClientType ClientType => GameClientType.Lazer; + public int Priority => 100; + public bool IsAvailable => _connected; + public GameSyncSnapshot CurrentSnapshot { get; private set; } + + public event Action? AvailabilityChanged; + public event Action? SnapshotReceived; + + public void Start() + { + BindEvents(); + _lazerIpcBridge.Start(); + } + + public async Task StopAsync() + { + await _lazerIpcBridge.StopAsync(); + + bool availabilityChanged; + lock (frameLock) + { + ResetFrameStateLocked(); + availabilityChanged = SetAvailabilityLocked(false); + } + + if (availabilityChanged) + AvailabilityChanged?.Invoke(this, false); + } + + private void BindEvents() + { + if (_eventsBound) return; + + _lazerIpcBridge.ChannelConnectionChanged += OnChannelConnectionChanged; + _lazerIpcBridge.FrameReceived += OnFrameReceived; + _eventsBound = true; + } + + private void OnChannelConnectionChanged(LazerIpcChannel channel, bool oldValue, bool newValue) + { + bool availabilityChanged; + bool isAvailable; + + lock (frameLock) + { + if (!newValue) + { + ResetFrameStateLocked(); + } + + isAvailable = CanBeAvailableLocked(); + availabilityChanged = SetAvailabilityLocked(isAvailable); + } + + if (availabilityChanged) + AvailabilityChanged?.Invoke(this, isAvailable); + } + + private void OnFrameReceived(LazerIpcChannel channel, LazerDeltaFrame deltaFrame) + { + bool availabilityChanged; + bool isAvailable; + LazerSkinInfo[]? skinInfosToPublish = null; + string? userDataDirectoryToPublish = null; + string? exeDirectoryToPublish = null; + bool skinContextChanged = false; + + lock (frameLock) + { + switch (channel) + { + case LazerIpcChannel.Timing: + _hasTimingFrame = true; + break; + + case LazerIpcChannel.Events: + _hasEventFrame = true; + break; + + default: + throw new ArgumentOutOfRangeException(nameof(channel), channel, null); + } + + ApplyFrameLocked(deltaFrame); + isAvailable = CanBeAvailableLocked(); + availabilityChanged = SetAvailabilityLocked(isAvailable); + + // Detect changes to lazer skin context. + if (!ReferenceEquals(_frame.SkinInfos, _lastPublishedSkinInfos)) + { + _lastPublishedSkinInfos = _frame.SkinInfos; + skinInfosToPublish = _frame.SkinInfos; + skinContextChanged = true; + } + + if (_frame.UserDataDirectory != _lastPublishedUserDataDirectory) + { + _lastPublishedUserDataDirectory = _frame.UserDataDirectory; + userDataDirectoryToPublish = _frame.UserDataDirectory; + skinContextChanged = true; + } + + if (_frame.ExeDirectory != _lastPublishedExeDirectory) + { + _lastPublishedExeDirectory = _frame.ExeDirectory; + exeDirectoryToPublish = _frame.ExeDirectory; + skinContextChanged = true; + } + } + + if (availabilityChanged) + AvailabilityChanged?.Invoke(this, isAvailable); + + if (isAvailable) + SnapshotReceived?.Invoke(this, _snapshot); + + if (skinContextChanged) + { + LazerSkinContextReceived?.Invoke( + skinInfosToPublish, + userDataDirectoryToPublish, + exeDirectoryToPublish); + } + } + + private void ApplyFrameLocked(LazerDeltaFrame deltaFrame) + { + var beatmapChanged = deltaFrame.HasField(LazerFieldKind.BeatmapFolder) || + deltaFrame.HasField(LazerFieldKind.BeatmapFilename); + var beatmapFilesChanged = deltaFrame.HasField(LazerFieldKind.BeatmapFiles); + + if (beatmapChanged && !beatmapFilesChanged) + { + _resourceCatalog = null; + _frame.ClearBeatmapFiles(); + } + + _frame.Apply(deltaFrame); + var frame = _frame; + + var status = Enum.IsDefined(typeof(OsuMemoryStatus), frame.Status) + ? (OsuMemoryStatus)frame.Status + : OsuMemoryStatus.Unknown; + + if (beatmapFilesChanged && frame.BeatmapFiles.Length > 0) + { + var resourceCatalog = BeatmapResourceCatalog.FromMappings( + frame.BeatmapFiles.Select(file => new BeatmapResource(file.Name, file.Path)), + frame.BeatmapFolder, + CreateCatalogCacheKey(frame)); + + if (!resourceCatalog.IsEmpty) + { + _resourceCatalog = resourceCatalog; + } + } + + var beatmap = !string.IsNullOrWhiteSpace(frame.BeatmapFolder) && + !string.IsNullOrWhiteSpace(frame.BeatmapFilename) + ? new BeatmapIdentifier(frame.BeatmapFolder, frame.BeatmapFilename) + : default; + + var snapshot = _snapshot; + snapshot.ProcessId = frame.ProcessId; + snapshot.Username = frame.Username; + snapshot.PlayMods = (Mods)frame.Mods; + snapshot.IsReplay = frame.IsReplay; + snapshot.Score = frame.Score; + snapshot.Combo = frame.Combo; + snapshot.Statistics = frame.Statistics; + snapshot.HitErrors = new SyncHitErrors(frame.HitErrorIndex, frame.HitErrors); + snapshot.Beatmap = beatmap; + snapshot.BeatmapResourceCatalog = _resourceCatalog; + snapshot.PlayTime = frame.PlayTime; + snapshot.Status = status; + } + + private void ResetFrameStateLocked() + { + _hasTimingFrame = false; + _hasEventFrame = false; + _resourceCatalog = null; + _frame.Reset(); + _snapshot.ResetToNotRunning(ClientType); + CurrentSnapshot = _snapshot; + + _lastPublishedSkinInfos = null; + _lastPublishedUserDataDirectory = null; + _lastPublishedExeDirectory = null; + } + + private bool CanBeAvailableLocked() + => _lazerIpcBridge.IsTimingConnected && + _lazerIpcBridge.IsEventsConnected && + _hasTimingFrame && + _hasEventFrame; + + private bool SetAvailabilityLocked(bool isAvailable) + { + if (_connected == isAvailable) return false; + + _connected = isAvailable; + return true; + } + + private static string CreateCatalogCacheKey(LazerIpcFrame frame) + { + var beatmapFilename = frame.BeatmapFilename; + var beatmapPath = string.IsNullOrWhiteSpace(beatmapFilename) + ? null + : frame.BeatmapFiles.FirstOrDefault(file => + string.Equals(BeatmapResourceCatalog.NormalizeName(file.Name), + BeatmapResourceCatalog.NormalizeName(beatmapFilename), StringComparison.OrdinalIgnoreCase))?.Path; + + return $"lazer:{frame.BeatmapFolder}:{beatmapFilename}:{beatmapPath}:{frame.BeatmapFiles.Length}"; + } +} diff --git a/src/Core/KeyAsio.Sync/Sources/MemoryReadObject.cs b/src/Core/KeyAsio.Sync/Sources/MemoryReadObject.cs new file mode 100644 index 00000000..292e9eec --- /dev/null +++ b/src/Core/KeyAsio.Sync/Sources/MemoryReadObject.cs @@ -0,0 +1,187 @@ +using System.Runtime.CompilerServices; +using KeyAsio.Plugins.Contracts; +using KeyAsio.Plugins.Contracts.Sync; +using KeyAsio.Sync.Events; + +namespace KeyAsio.Sync.Sources; + +public class MemoryReadObject +{ + public event ValueChangedEventHandler? PlayerNameChanged; + public event ValueChangedEventHandler? ComboChanged; + public event ValueChangedEventHandler? ScoreChanged; + public event ValueChangedEventHandler? IsReplayChanged; + public event ValueChangedEventHandler? OsuStatusChanged; + public event ValueChangedEventHandler? PlayingTimeChanged; + public event ValueChangedEventHandler? ModsChanged; + public event ValueChangedEventHandler? ProcessIdChanged; + public event ValueChangedEventHandler? BeatmapIdentifierChanged; + public event ValueChangedEventHandler? StatisticsChanged; + public event ValueChangedEventHandler? HitErrorsChanged; + + public string? PlayerName + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get; + [MethodImpl(MethodImplOptions.AggressiveInlining)] + set + { + if (field == value) return; + var old = field; + field = value; + PlayerNameChanged?.Invoke(old, value); + } + } // BanchoUser.UserName + + public int Combo + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get; + [MethodImpl(MethodImplOptions.AggressiveInlining)] + set + { + if (field == value) return; + var old = field; + field = value; + ComboChanged?.Invoke(old, value); + } + } // Player.(RulesetPlayData.Combo) + + public int Score + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get; + [MethodImpl(MethodImplOptions.AggressiveInlining)] + set + { + if (field == value) return; + var old = field; + field = value; + ScoreChanged?.Invoke(old, value); + } + } // Player.(RulesetPlayData.Score) + + public bool IsReplay + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get; + [MethodImpl(MethodImplOptions.AggressiveInlining)] + set + { + if (field == value) return; + var old = field; + field = value; + IsReplayChanged?.Invoke(old, value); + } + } // Player.IsReplay + + public OsuMemoryStatus OsuStatus + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get; + [MethodImpl(MethodImplOptions.AggressiveInlining)] + set + { + if (field == value) return; + var old = field; + field = value; + OsuStatusChanged?.Invoke(old, value); + } + } // GeneralData.OsuStatus + + public int PlayingTime + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get; + [MethodImpl(MethodImplOptions.AggressiveInlining)] + set + { + if (field == value) return; + var old = field; + field = value; + PlayingTimeChanged?.Invoke(old, value); + } + } // GeneralData.AudioTime + + public Mods Mods + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get; + [MethodImpl(MethodImplOptions.AggressiveInlining)] + set + { + if (field == value) return; + var old = field; + field = value; + ModsChanged?.Invoke(old, value); + } + } // GeneralData.Mods + + public int ProcessId + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get; + [MethodImpl(MethodImplOptions.AggressiveInlining)] + set + { + if (field == value) return; + var old = field; + field = value; + ProcessIdChanged?.Invoke(old, value); + } + } + + public SyncStatistics Statistics + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get; + [MethodImpl(MethodImplOptions.AggressiveInlining)] + set + { + if (field == value) return; + var old = field; + field = value; + StatisticsChanged?.Invoke(old, value); + } + } = SyncStatistics.Empty; + + public SyncHitErrors HitErrors + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get; + [MethodImpl(MethodImplOptions.AggressiveInlining)] + set + { + var oldValues = field.Values ?? []; + var newValues = value.Values ?? []; + if (field.Index == value.Index && oldValues.AsSpan().SequenceEqual(newValues)) + { + return; + } + + var old = field; + field = value with + { + Values = newValues + }; + HitErrorsChanged?.Invoke(old, field); + } + } = SyncHitErrors.Empty; + + //public string? BeatmapFolder { get; set; } // CurrentBeatmap.FolderName + //public string? BeatmapFileName { get; set; } // CurrentBeatmap.OsuFileName + + public BeatmapIdentifier BeatmapIdentifier + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get; + [MethodImpl(MethodImplOptions.AggressiveInlining)] + set + { + if (EqualityComparer.Default.Equals(field, value)) return; + var old = field; + field = value; + BeatmapIdentifierChanged?.Invoke(old, value); + } + } +} diff --git a/src/Core/KeyAsio.Sync/Sources/MemoryScan.cs b/src/Core/KeyAsio.Sync/Sources/MemoryScan.cs new file mode 100644 index 00000000..e2ec00eb --- /dev/null +++ b/src/Core/KeyAsio.Sync/Sources/MemoryScan.cs @@ -0,0 +1,612 @@ +using System.Diagnostics; +using System.Runtime.CompilerServices; +using Coosu.Shared.IO; +using KeyAsio.Core.Memory; +using KeyAsio.Core.Memory.Configuration; +using KeyAsio.Core.Memory.Utils; +using KeyAsio.Plugins.Contracts; +using KeyAsio.Plugins.Contracts.Sync; +using Microsoft.Extensions.Logging; + +namespace KeyAsio.Sync.Sources; + +public class MemoryScan +{ + private readonly ILogger _logger; + + private int _generalInterval; + private int _timingInterval; + + private Process? _process; + private volatile bool _processExited; + private SigScan? _sigScan; + private MemoryProfile? _memoryProfile; + private MemoryContext? _memoryContext; + + private string? _songsDirectory; + private bool _scanSuccessful; + private readonly OsuMemoryData _osuMemoryData = new(); + + private Task? _readTask; + private CancellationTokenSource? _cts; + private bool _isStarted; + private readonly ManualResetEventSlim _intervalUpdatedEvent = new(false); + private readonly ManualResetEventSlim _scanResumeEvent = new(true); + private volatile bool _scanSuppressed; + private ValueDefinition? _valueDefinition; + private ValueDefinition? _scoreProcessorValueDefinition; + private ValueDefinition? _scoreV2ValueDefinition; + private ValueDefinition? _comboValueDefinition; + private ValueDefinition? _hit100ValueDefinition; + private ValueDefinition? _hit300ValueDefinition; + private ValueDefinition? _hit50ValueDefinition; + private ValueDefinition? _hitGekiValueDefinition; + private ValueDefinition? _hitKatuValueDefinition; + private ValueDefinition? _hitMissValueDefinition; + + private string? _folderName; + + public MemoryScan(ILogger logger) + { + _logger = logger; + } + + public MemoryReadObject MemoryReadObject + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get; + } = new(); + + public void Start(int generalInterval, int timingInterval) + { + if (_isStarted) return; + _isStarted = true; + _generalInterval = generalInterval; + _timingInterval = timingInterval; + if (_scanSuppressed) + _scanResumeEvent.Reset(); + else + _scanResumeEvent.Set(); + + try + { + // Load from the output directory or adjacent to the assembly + var assemblyPath = Path.GetDirectoryName(typeof(MemoryScan).Assembly.Location) ?? string.Empty; + var rulesPath = Path.Combine(assemblyPath, "osu_memory_rules.json"); + _memoryProfile = MemoryProfile.Load(rulesPath); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to load memory rules"); + throw; + } + + _cts = new CancellationTokenSource(); + // WARN: Single threaded reading to avoid thread pool switching + _readTask = Task.Factory.StartNew(ReadImpl, + TaskCreationOptions.LongRunning | TaskCreationOptions.DenyChildAttach); + } + + public async Task StopAsync() + { + if (!_isStarted) return; + await _cts!.CancelAsync(); + _scanResumeEvent.Set(); + + if (_readTask != null) + await _readTask; + + CleanupProcess(MemoryReadObject); + _cts.Dispose(); + _intervalUpdatedEvent.Reset(); + + _isStarted = false; + } + + public void UpdateIntervals(int generalInterval, int timingInterval) + { + _generalInterval = generalInterval; + _timingInterval = timingInterval; + _intervalUpdatedEvent.Set(); + } + + public void SetScanSuppressed(bool suppressed) + { + if (_scanSuppressed == suppressed) return; + + _scanSuppressed = suppressed; + if (suppressed) + { + _scanResumeEvent.Reset(); + _logger.LogInformation("osu!stable memory scan suppressed."); + } + else + { + _scanResumeEvent.Set(); + _logger.LogInformation("osu!stable memory scan resumed."); + } + } + + public void ReloadRules() + { + try + { + var assemblyPath = Path.GetDirectoryName(typeof(MemoryScan).Assembly.Location) ?? string.Empty; + var rulesPath = Path.Combine(assemblyPath, "osu_memory_rules.json"); + _memoryProfile = MemoryProfile.Load(rulesPath); + + // Force reconnection to rebuild MemoryContext with new profile + CleanupProcess(MemoryReadObject); + + _logger.LogInformation("Memory rules reloaded."); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to reload memory rules"); + } + } + + private void ReadImpl() + { + var memoryReadObject = MemoryReadObject; + using var timerScope = new HighPrecisionTimerScope(); + var nextGeneralScan = 0L; + var nextTimingScan = 0L; + var stopwatch = Stopwatch.StartNew(); + + while (!_cts!.IsCancellationRequested) + { + if (_scanSuppressed) + { + WaitWhileScanSuppressed(memoryReadObject); + nextGeneralScan = stopwatch.ElapsedMilliseconds; + nextTimingScan = nextGeneralScan; + continue; + } + + if (!EnsureConnected(memoryReadObject)) + { + Thread.Sleep(500); + continue; + } + + if (_scanSuppressed) + { + WaitWhileScanSuppressed(memoryReadObject); + nextGeneralScan = stopwatch.ElapsedMilliseconds; + nextTimingScan = nextGeneralScan; + continue; + } + + if (!EnsureScanned()) + { + Thread.Sleep(100); + continue; + } + + EnsureSongsDirectory(); + + long now = stopwatch.ElapsedMilliseconds; + bool didWork = false; + + if (now >= nextTimingScan) + { + ReadTiming(memoryReadObject); + nextTimingScan = now + _timingInterval; + didWork = true; + } + + if (now >= nextGeneralScan) + { + ReadGeneralData(memoryReadObject); + nextGeneralScan = now + _generalInterval; + didWork = true; + } + + if (!didWork) + { + Thread.Sleep(1); + } + } + } + + private void WaitWhileScanSuppressed(MemoryReadObject memoryReadObject) + { + CleanupProcess(memoryReadObject, delayOnDisconnect: false); + + while (_scanSuppressed && _cts?.IsCancellationRequested == false) + { + try + { + _scanResumeEvent.Wait(_cts.Token); + } + catch (OperationCanceledException) + { + break; + } + } + } + + private bool EnsureConnected(MemoryReadObject memoryReadObject) + { + if (_scanSuppressed) return false; + + if (_process != null && !_processExited) + return true; + + CleanupProcess(memoryReadObject); + + try + { + var processes = Process.GetProcessesByName("osu!"); + if (processes.Length > 0) + { + _process = processes[0]; + + try + { + var uptime = DateTime.Now - _process.StartTime; + if (uptime.TotalSeconds < 6) + { + _logger.LogInformation( + "osu! process detected early (uptime {Uptime:F1}s). Delaying connection for 10s...", + uptime.TotalSeconds); + for (var i = 0; i < 60; i++) + { + if (_cts?.IsCancellationRequested == true) return false; + if (_scanSuppressed) return false; + Thread.Sleep(100); + } + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to check process uptime"); + } + + _process.EnableRaisingEvents = true; + _process.Exited += OnProcessExited; + _processExited = false; + + if (_process.HasExited) + { + CleanupProcess(memoryReadObject); + return false; + } + + _sigScan = new SigScan(_process); + _memoryContext = new MemoryContext(_sigScan, _memoryProfile!); + if (!_memoryContext.TryGetProfile("AudioTime", out _valueDefinition)) + { + _logger.LogWarning("Memory profile is missing required 'AudioTime' definition"); + } + + _memoryContext.TryGetProfile("ScoreProcessor", out _scoreProcessorValueDefinition); + _memoryContext.TryGetProfile("ScoreV2", out _scoreV2ValueDefinition); + _memoryContext.TryGetProfile("Combo", out _comboValueDefinition); + _memoryContext.TryGetProfile("Hit100", out _hit100ValueDefinition); + _memoryContext.TryGetProfile("Hit300", out _hit300ValueDefinition); + _memoryContext.TryGetProfile("Hit50", out _hit50ValueDefinition); + _memoryContext.TryGetProfile("HitGeki", out _hitGekiValueDefinition); + _memoryContext.TryGetProfile("HitKatu", out _hitKatuValueDefinition); + _memoryContext.TryGetProfile("HitMiss", out _hitMissValueDefinition); + + _logger.LogInformation("Connected to osu! process"); + MemoryReadObject.ProcessId = _process.Id; + return true; + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error finding osu! process"); + } + + return false; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void OnProcessExited(object? sender, EventArgs e) + { + _processExited = true; + } + + private void CleanupProcess(MemoryReadObject memoryReadObject, bool delayOnDisconnect = true) + { + var exiting = _process != null; + if (_process != null) + { + _process.Exited -= OnProcessExited; + } + + _process?.Dispose(); + _sigScan?.Dispose(); + + _process = null; + _sigScan = null; + _memoryContext = null; + _songsDirectory = null; + _scanSuccessful = false; + _scoreProcessorValueDefinition = null; + _scoreV2ValueDefinition = null; + _comboValueDefinition = null; + _hit100ValueDefinition = null; + _hit300ValueDefinition = null; + _hit50ValueDefinition = null; + _hitGekiValueDefinition = null; + _hitKatuValueDefinition = null; + _hitMissValueDefinition = null; + + _folderName = null; + memoryReadObject.OsuStatus = OsuMemoryStatus.NotRunning; + memoryReadObject.PlayingTime = 0; + memoryReadObject.ProcessId = 0; + memoryReadObject.BeatmapIdentifier = default; + memoryReadObject.Statistics = SyncStatistics.Empty; + memoryReadObject.HitErrors = SyncHitErrors.Empty; + if (exiting) + { + _logger.LogInformation("Disconnected from osu! process"); + if (delayOnDisconnect) + { + Thread.Sleep(2000); + } + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private bool EnsureScanned() + { + if (_scanSuccessful) return true; + if (_scanSuppressed) return false; + if (_memoryContext == null) return false; + + _memoryContext.Scan(); + _memoryContext.BeginUpdate(); + + if (_memoryContext.TryGetValueDef(_valueDefinition, out _)) + { + _scanSuccessful = true; + EnsureSongsDirectory(); + return true; + } + + _sigScan?.Reload(); + return false; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void EnsureSongsDirectory() + { + if (_songsDirectory != null) return; + + try + { + var mainModuleFileName = _process?.MainModule?.FileName; + if (string.IsNullOrEmpty(mainModuleFileName)) return; + + var baseDirectory = Path.GetDirectoryName(mainModuleFileName); + if (baseDirectory == null) return; + + var beatmapDirectory = "Songs"; + var configPath = Path.Combine(baseDirectory, $"osu!.{Environment.UserName}.cfg"); + + if (File.Exists(configPath)) + { + try + { + using var sr = new StreamReader(configPath); + using var lineReader = new EphemeralLineReader(sr); + while (lineReader.ReadLine() is { } memory) + { + var span = memory.Span.Trim(); + if (span.StartsWith('#')) continue; + if (span.IsEmpty) continue; + + var commentIndex = span.IndexOf('#'); + var validSpan = commentIndex == -1 ? span : span.Slice(0, commentIndex).TrimEnd(); + + var splitterIndex = span.IndexOf('='); + if (splitterIndex == -1) continue; + + var key = validSpan.Slice(0, splitterIndex).TrimEnd(); + var value = validSpan.Slice(splitterIndex + 1).TrimStart(); + + if (key.Equals("BeatmapDirectory", StringComparison.OrdinalIgnoreCase)) + { + beatmapDirectory = value.ToString(); + break; + } + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to read osu! configuration file"); + } + } + else + { + _logger.LogWarning("osu! configuration file not found at {ConfigPath}", configPath); + } + + _songsDirectory = Path.IsPathRooted(beatmapDirectory) + ? beatmapDirectory + : Path.Combine(baseDirectory, beatmapDirectory); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting osu! main module path"); + // Ignore exceptions when accessing MainModule + } + } + + private bool ReadGeneralData(MemoryReadObject memoryReadObject) + { + try + { + _memoryContext!.BeginUpdate(); + _memoryContext.Populate(_osuMemoryData); + + memoryReadObject.OsuStatus = (OsuMemoryStatus)_osuMemoryData.RawStatus; + memoryReadObject.PlayerName = _osuMemoryData.Username; + memoryReadObject.Mods = (Mods)_osuMemoryData.Mods; + + if (memoryReadObject.OsuStatus == OsuMemoryStatus.Playing) + { + memoryReadObject.IsReplay = _osuMemoryData.IsReplay; + var score = _osuMemoryData.Score; + + if (_scoreProcessorValueDefinition != null && + _memoryContext.TryGetValueDef(_scoreProcessorValueDefinition, out var scoreProcessor) && + scoreProcessor != 0 && + _scoreV2ValueDefinition != null && + _memoryContext.TryGetValueDef(_scoreV2ValueDefinition, out var scoreV2) && + scoreV2 > 0) + { + score = scoreV2; + } + + memoryReadObject.Score = score; + memoryReadObject.Combo = _osuMemoryData.Combo; + } + else + { + memoryReadObject.Score = 0; + memoryReadObject.Combo = 0; + } + + if (_songsDirectory != null) + { + var folderName = _osuMemoryData.FolderName; + var osuFileName = _osuMemoryData.OsuFileName; + + if (string.IsNullOrEmpty(osuFileName)) + { + return true; + } + + if (memoryReadObject.BeatmapIdentifier.Filename == osuFileName && _folderName == folderName) + { + return true; + } + + _folderName = folderName; + var directory = Path.Combine(_songsDirectory, folderName); + memoryReadObject.BeatmapIdentifier = new BeatmapIdentifier(directory, osuFileName); + } + + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error reading memory"); + CleanupProcess(memoryReadObject); + return false; + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void ReadTiming(MemoryReadObject memoryReadObject) + { + if (_memoryContext != null && _memoryContext.TryGetValueDef(_valueDefinition, out var audioTime)) + { + memoryReadObject.PlayingTime = audioTime; + } + + if (memoryReadObject.OsuStatus != OsuMemoryStatus.Playing) + { + memoryReadObject.Statistics = SyncStatistics.Empty; + memoryReadObject.HitErrors = SyncHitErrors.Empty; + return; + } + + memoryReadObject.Statistics = new SyncStatistics( + ReadStat(_hitGekiValueDefinition), + ReadStat(_hit300ValueDefinition), + ReadStat(_hitKatuValueDefinition), + ReadStat(_hit100ValueDefinition), + ReadStat(_hit50ValueDefinition), + ReadStat(_hitMissValueDefinition)); + + memoryReadObject.HitErrors = ReadHitErrors(memoryReadObject.HitErrors.Index); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private int ReadStat(ValueDefinition? definition) + { + return _memoryContext != null && _memoryContext.TryGetValueDef(definition, out var value) + ? value + : 0; + } + + private SyncHitErrors ReadHitErrors(int lastIndex) + { + int safeLastIndex = Math.Max(0, lastIndex); + + if (_memoryContext == null || _sigScan == null || _comboValueDefinition == null) + { + return new SyncHitErrors(safeLastIndex, []); + } + + var scoreBase = _memoryContext.ResolveBaseAddress(_comboValueDefinition); + if (scoreBase == IntPtr.Zero) + { + return new SyncHitErrors(safeLastIndex, []); + } + + if (!MemoryReadHelper.TryGetPointer(_sigScan, scoreBase + 0x38, out var hitErrorsListBase) || + hitErrorsListBase == IntPtr.Zero) + { + return new SyncHitErrors(safeLastIndex, []); + } + + if (!MemoryReadHelper.TryGetPointer(_sigScan, hitErrorsListBase + 0x4, out var itemsBase) || + itemsBase == IntPtr.Zero) + { + return new SyncHitErrors(safeLastIndex, []); + } + + if (!MemoryReadHelper.TryGetValue(_sigScan, hitErrorsListBase + 0xc, out var size) || + size is < 0 or > 100_000) + { + return new SyncHitErrors(safeLastIndex, []); + } + + if (safeLastIndex > size) + { + safeLastIndex = 0; + } + + int count = size - safeLastIndex; + if (count <= 0) + { + return new SyncHitErrors(size, []); + } + + var errors = new int[count]; + int readCount = 0; + int index = safeLastIndex; + for (int i = safeLastIndex; i < size; i++) + { + if (!MemoryReadHelper.TryGetValue(_sigScan, itemsBase + 0x8 + 0x4 * i, out var error)) + { + break; + } + + if (error is < -10_000 or > 10_000) + { + _logger.LogDebug("Strange value in hitErrors: {HitError}", error); + break; + } + + errors[readCount++] = error; + index = i + 1; + } + + if (readCount != errors.Length) + { + Array.Resize(ref errors, readCount); + } + + return new SyncHitErrors(index, errors); + } +} diff --git a/src/Core/KeyAsio.Sync/Sources/MemorySyncBridge.cs b/src/Core/KeyAsio.Sync/Sources/MemorySyncBridge.cs new file mode 100644 index 00000000..52351c34 --- /dev/null +++ b/src/Core/KeyAsio.Sync/Sources/MemorySyncBridge.cs @@ -0,0 +1,136 @@ +using KeyAsio.Configuration; +using System.ComponentModel; +using System.Text; +using KeyAsio.Sync; +using KeyAsio.Common; +using Microsoft.Extensions.Logging; + +namespace KeyAsio.Sync.Sources; + +public class MemorySyncBridge +{ + private readonly GameSyncSourceCoordinator _sourceCoordinator; + private readonly StableMemoryGameSyncSource _stableSource; + private readonly LazerIpcBridge _lazerIpcBridge; + private readonly SyncSessionContext _syncSessionContext; + private readonly AppSettings _appSettings; + private readonly ILogger _logger; + private bool _initialized; + private bool _isRunning; + private volatile bool _isStopping; + + public MemorySyncBridge( + GameSyncSourceCoordinator sourceCoordinator, + StableMemoryGameSyncSource stableSource, + LazerIpcBridge lazerIpcBridge, + SyncSessionContext syncSessionContext, + AppSettings appSettings, + ILogger logger) + { + _sourceCoordinator = sourceCoordinator; + _stableSource = stableSource; + _lazerIpcBridge = lazerIpcBridge; + _syncSessionContext = syncSessionContext; + _appSettings = appSettings; + _logger = logger; + } + + public void Start() + { + if (_initialized) return; + _initialized = true; + + _appSettings.Sync.Scanning.PropertyChanged += OnScanningSettingsChanged; + _appSettings.Sync.PropertyChanged += OnSyncSettingsChanged; + _lazerIpcBridge.ChannelConnectionChanged += OnLazerIpcChannelConnectionChanged; + + ConfigureStableSourceIntervals(); + + _logger.LogInformation("Initial EnableSync state: {State}", _appSettings.Sync.EnableSync); + _logger.LogInformation("Initial EnableMixSync state: {State}", _appSettings.Sync.EnableMixSync); + + if (_appSettings.Sync.EnableSync) + { + StartScanning(); + } + } + + private void OnScanningSettingsChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName is nameof(AppSettingsSyncScanning.GeneralScanInterval) + or nameof(AppSettingsSyncScanning.TimingScanInterval)) + { + ConfigureStableSourceIntervals(); + } + } + + private void ConfigureStableSourceIntervals() + { + _stableSource.ConfigureIntervals(_appSettings.Sync.Scanning.GeneralScanInterval, + _appSettings.Sync.Scanning.TimingScanInterval); + } + + private void OnLazerIpcChannelConnectionChanged(LazerIpcChannel channel, bool oldValue, bool newValue) + { + if (_isStopping) return; + + _stableSource.SetMemoryScanSuppressed(_lazerIpcBridge.IsAnyChannelConnected); + } + + private void OnSyncSettingsChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(AppSettingsSync.EnableSync)) + { + if (_appSettings.Sync.EnableSync) + { + _logger.LogInformation("Memory Sync enabled."); + StartScanning(); + } + else + { + _logger.LogInformation("Memory Sync disabled."); + _ = StopScanningAsync(); + } + } + else if (e.PropertyName == nameof(AppSettingsSync.EnableMixSync)) + { + _logger.LogInformation("EnableMixSync changed to: {State}", _appSettings.Sync.EnableMixSync); + } + } + + private void StartScanning() + { + if (_isRunning) return; + + try + { + var player = EncodeUtils.FromBase64String(_appSettings.Logging.PlayerBase64 ?? "", Encoding.ASCII); + _syncSessionContext.Username = player; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to decode PlayerBase64 string."); + } + + ConfigureStableSourceIntervals(); + _sourceCoordinator.Start(); + _isRunning = true; + } + + private async Task StopScanningAsync() + { + if (!_isRunning) return; + + _isStopping = true; + try + { + await _sourceCoordinator.StopAsync(); + } + finally + { + _stableSource.SetMemoryScanSuppressed(false); + _isStopping = false; + _isRunning = false; + } + } +} diff --git a/src/Core/KeyAsio.Sync/Sources/OsuMemoryData.cs b/src/Core/KeyAsio.Sync/Sources/OsuMemoryData.cs new file mode 100644 index 00000000..5f7eb9b8 --- /dev/null +++ b/src/Core/KeyAsio.Sync/Sources/OsuMemoryData.cs @@ -0,0 +1,35 @@ +namespace KeyAsio.Sync.Sources; + +public record OsuMemoryData +{ + // Beatmap Data + //public int Id { get; set; } + //public int SetId { get; set; } + //public string MapString { get; set; } = string.Empty; + public string FolderName { get; set; } = string.Empty; + public string OsuFileName { get; set; } = string.Empty; + //public string MD5 { get; set; } = string.Empty; + //public float AR { get; set; } + //public float CS { get; set; } + //public float HP { get; set; } + //public float OD { get; set; } + //public short Status { get; set; } // Beatmap Status + + // General Data + public int RawStatus { get; set; } // OsuStatus + //public int GameMode { get; set; } + //public int Retries { get; set; } + //public double TotalAudioTime { get; set; } + //public bool ChatIsExpanded { get; set; } + public int Mods { get; set; } + //public bool ShowPlayingInterface { get; set; } + //public string OsuVersion { get; set; } = string.Empty; + + // Additional Data + //public bool IsLoggedIn { get; set; } + public string Username { get; set; } = string.Empty; + //public string SkinFolder { get; set; } = string.Empty; + public bool IsReplay { get; set; } + public int Score { get; set; } + public ushort Combo { get; set; } +} diff --git a/src/Core/KeyAsio.Sync/Sources/StableMemoryGameSyncSource.cs b/src/Core/KeyAsio.Sync/Sources/StableMemoryGameSyncSource.cs new file mode 100644 index 00000000..663cde20 --- /dev/null +++ b/src/Core/KeyAsio.Sync/Sources/StableMemoryGameSyncSource.cs @@ -0,0 +1,122 @@ +using KeyAsio.Configuration.Models; +using KeyAsio.Sync; + +namespace KeyAsio.Sync.Sources; + +public sealed class StableMemoryGameSyncSource : IGameSyncSource +{ + private readonly MemoryScan _memoryScan; + private readonly GameSyncSnapshot _snapshot; + private bool _eventsBound; + private bool _started; + private bool _memoryScanSuppressed; + private int _generalInterval; + private int _timingInterval; + + public StableMemoryGameSyncSource(MemoryScan memoryScan) + { + _memoryScan = memoryScan; + _snapshot = new GameSyncSnapshot { ClientType = ClientType }; + UpdateSnapshot(); + CurrentSnapshot = _snapshot; + } + + public string Name => "osu!stable memory"; + public GameClientType ClientType => GameClientType.Stable; + public int Priority => 0; + public bool IsAvailable => _started; + public GameSyncSnapshot CurrentSnapshot { get; private set; } + + public event Action? AvailabilityChanged; + public event Action? SnapshotReceived; + + public void ConfigureIntervals(int generalInterval, int timingInterval) + { + _generalInterval = generalInterval; + _timingInterval = timingInterval; + + if (_started) + { + _memoryScan.UpdateIntervals(generalInterval, timingInterval); + } + } + + public void SetMemoryScanSuppressed(bool suppressed) + { + if (_memoryScanSuppressed == suppressed) return; + + _memoryScanSuppressed = suppressed; + _memoryScan.SetScanSuppressed(suppressed); + + if (suppressed) + { + _snapshot.ResetToNotRunning(ClientType); + SnapshotReceived?.Invoke(this, _snapshot); + } + } + + public void Start() + { + if (_started) return; + + BindEvents(); + _started = true; + _memoryScan.Start(_generalInterval, _timingInterval); + PublishSnapshot(); + AvailabilityChanged?.Invoke(this, true); + } + + public async Task StopAsync() + { + if (!_started) return; + + await _memoryScan.StopAsync(); + _started = false; + _snapshot.ResetToNotRunning(ClientType); + AvailabilityChanged?.Invoke(this, false); + } + + private void BindEvents() + { + if (_eventsBound) return; + + var memory = _memoryScan.MemoryReadObject; + memory.PlayerNameChanged += (_, _) => PublishSnapshot(); + memory.ModsChanged += (_, _) => PublishSnapshot(); + memory.ComboChanged += (_, _) => PublishSnapshot(); + memory.ScoreChanged += (_, _) => PublishSnapshot(); + memory.IsReplayChanged += (_, _) => PublishSnapshot(); + memory.BeatmapIdentifierChanged += (_, _) => PublishSnapshot(); + memory.OsuStatusChanged += (_, _) => PublishSnapshot(); + memory.ProcessIdChanged += (_, _) => PublishSnapshot(); + memory.PlayingTimeChanged += (_, _) => PublishSnapshot(); + memory.StatisticsChanged += (_, _) => PublishSnapshot(); + memory.HitErrorsChanged += (_, _) => PublishSnapshot(); + + _eventsBound = true; + } + + private void PublishSnapshot() + { + UpdateSnapshot(); + SnapshotReceived?.Invoke(this, _snapshot); + } + + private void UpdateSnapshot() + { + var memory = _memoryScan.MemoryReadObject; + var snapshot = _snapshot; + snapshot.ProcessId = memory.ProcessId; + snapshot.Username = memory.PlayerName; + snapshot.PlayMods = memory.Mods; + snapshot.IsReplay = memory.IsReplay; + snapshot.Score = memory.Score; + snapshot.Combo = memory.Combo; + snapshot.Statistics = memory.Statistics; + snapshot.HitErrors = memory.HitErrors; + snapshot.Beatmap = memory.BeatmapIdentifier; + snapshot.BeatmapResourceCatalog = null; + snapshot.PlayTime = memory.PlayingTime; + snapshot.Status = memory.OsuStatus; + } +} From 146e5477a258a6c119419e77ffec77df9e47a321 Mon Sep 17 00:00:00 2001 From: Milkitic Date: Sat, 11 Jul 2026 22:37:11 +0800 Subject: [PATCH 02/19] refactor: coordinate audio settings transitions transactionally --- .../KeyAsio/DependencyInjectionExtensions.cs | 2 + .../AudioDeviceOperationCoordinator.cs | 234 ++++++++++++++++++ .../ViewModels/AudioSettingsViewModel.cs | 233 ++++++----------- .../ViewModels/WizardAudioConfigViewModel.cs | 81 +++--- .../KeyAsio/ViewModels/WizardViewModel.cs | 12 +- .../AudioDeviceOperationCoordinatorTests.cs | 157 ++++++++++++ .../WizardAudioConfigViewModelTests.cs | 52 ++-- 7 files changed, 535 insertions(+), 236 deletions(-) create mode 100644 src/Apps/KeyAsio/Services/AudioDeviceOperationCoordinator.cs create mode 100644 tests/KeyAsio.UnitTests/AudioDeviceOperationCoordinatorTests.cs diff --git a/src/Apps/KeyAsio/DependencyInjectionExtensions.cs b/src/Apps/KeyAsio/DependencyInjectionExtensions.cs index 142fba27..48f431fd 100644 --- a/src/Apps/KeyAsio/DependencyInjectionExtensions.cs +++ b/src/Apps/KeyAsio/DependencyInjectionExtensions.cs @@ -23,6 +23,8 @@ public static IServiceCollection AddGuiModule(this IServiceCollection services) services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddTransient(); services.AddTransient(); diff --git a/src/Apps/KeyAsio/Services/AudioDeviceOperationCoordinator.cs b/src/Apps/KeyAsio/Services/AudioDeviceOperationCoordinator.cs new file mode 100644 index 00000000..6cca6086 --- /dev/null +++ b/src/Apps/KeyAsio/Services/AudioDeviceOperationCoordinator.cs @@ -0,0 +1,234 @@ +using KeyAsio.Core.Audio; +using KeyAsio.Shared; +using KeyAsio.Shared.Sync.Services; +using Microsoft.Extensions.Logging; +using Milki.Extensions.Configuration; +using NAudio.Wave; + +namespace KeyAsio.Services; + +public sealed record AudioDeviceOperationResult( + bool IsSuccess, + DeviceDescription? ActiveDevice, + Exception? Error = null, + Exception? RollbackError = null) +{ + public bool WasRolledBack => Error is not null && RollbackError is null; +} + +public interface IAudioDeviceOperationCoordinator +{ + Task InitializeConfiguredAsync(CancellationToken cancellationToken = default); + + Task ApplyAsync( + DeviceDescription? device, + int sampleRate, + CancellationToken cancellationToken = default); + + Task ReloadAsync(CancellationToken cancellationToken = default); + + Task DeactivateAsync(CancellationToken cancellationToken = default); + + Task ClearAsync(CancellationToken cancellationToken = default); +} + +public interface IAudioSettingsPersistence +{ + void Save(); +} + +public sealed class AudioSettingsPersistence : IAudioSettingsPersistence +{ + private readonly AppSettings _settings; + + public AudioSettingsPersistence(AppSettings settings) + { + _settings = settings; + } + + public void Save() => _settings.Save(); +} + +/// +/// Owns the complete audio-device transition. Operations are serialized and +/// settings are committed only after the requested device is running. +/// +public sealed class AudioDeviceOperationCoordinator : IAudioDeviceOperationCoordinator, IDisposable +{ + private readonly AppSettings _settings; + private readonly IAudioSettingsPersistence _persistence; + private readonly IPlaybackEngine _engine; + private readonly GameplayAudioService? _gameplayAudio; + private readonly ILogger _logger; + private readonly SemaphoreSlim _operationGate = new(1, 1); + + public AudioDeviceOperationCoordinator( + AppSettings settings, + IAudioSettingsPersistence persistence, + IPlaybackEngine engine, + GameplayAudioService? gameplayAudio, + ILogger logger) + { + _settings = settings; + _persistence = persistence; + _engine = engine; + _gameplayAudio = gameplayAudio; + _logger = logger; + } + + public Task InitializeConfiguredAsync(CancellationToken cancellationToken = default) => + TransitionAsync( + _settings.Audio.PlaybackDevice, + _settings.Audio.SampleRate, + persist: false, + cancellationToken); + + public Task ApplyAsync( + DeviceDescription? device, + int sampleRate, + CancellationToken cancellationToken = default) => + TransitionAsync(device, sampleRate, persist: true, cancellationToken); + + public Task ReloadAsync(CancellationToken cancellationToken = default) => + TransitionAsync( + _settings.Audio.PlaybackDevice, + _settings.Audio.SampleRate, + persist: false, + cancellationToken); + + public Task DeactivateAsync(CancellationToken cancellationToken = default) => + TransitionAsync(null, _settings.Audio.SampleRate, persist: false, cancellationToken); + + public Task ClearAsync(CancellationToken cancellationToken = default) => + TransitionAsync(null, _settings.Audio.SampleRate, persist: true, cancellationToken); + + public void Dispose() => _operationGate.Dispose(); + + private async Task TransitionAsync( + DeviceDescription? requestedDevice, + int requestedSampleRate, + bool persist, + CancellationToken cancellationToken) + { + await _operationGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + var savedDevice = _settings.Audio.PlaybackDevice; + var savedSampleRate = _settings.Audio.SampleRate; + var rollbackDevice = _engine.CurrentDevice is null + ? null + : _engine.CurrentDeviceDescription; + var rollbackSampleRate = _engine.CurrentDevice is null + ? savedSampleRate + : _engine.SourceWaveFormat.SampleRate; + + var commitAttempted = false; + try + { + StopCurrentDevice(); + StartRequestedDevice(requestedDevice, requestedSampleRate); + + if (persist) + { + _settings.Audio.PlaybackDevice = requestedDevice; + _settings.Audio.SampleRate = requestedSampleRate; + commitAttempted = true; + _persistence.Save(); + } + + InvalidateGameplayAudio(); + return new AudioDeviceOperationResult(true, _engine.CurrentDeviceDescription); + } + catch (Exception error) + { + _logger.LogError(error, + "Audio transition failed. Device={Device}; SampleRate={SampleRate}", + requestedDevice?.FriendlyName ?? "none", + requestedSampleRate); + + _settings.Audio.PlaybackDevice = savedDevice; + _settings.Audio.SampleRate = savedSampleRate; + + List? rollbackErrors = null; + try + { + StopCurrentDevice(); + StartRequestedDevice(rollbackDevice, rollbackSampleRate); + } + catch (Exception exception) + { + (rollbackErrors ??= []).Add(exception); + _logger.LogCritical(exception, "Failed to restore the previous audio device"); + } + + if (commitAttempted) + { + try + { + _persistence.Save(); + } + catch (Exception exception) + { + (rollbackErrors ??= []).Add(exception); + _logger.LogCritical(exception, "Failed to restore the previous persisted audio settings"); + } + } + + InvalidateGameplayAudio(); + + Exception? rollbackError = rollbackErrors switch + { + null or { Count: 0 } => null, + { Count: 1 } => rollbackErrors[0], + _ => new AggregateException("Multiple audio rollback operations failed", rollbackErrors) + }; + + return new AudioDeviceOperationResult( + false, + _engine.CurrentDeviceDescription, + error, + rollbackError); + } + } + finally + { + _operationGate.Release(); + } + } + + private void StartRequestedDevice(DeviceDescription? device, int sampleRate) + { + if (device is null) + { + return; + } + + _engine.LimiterType = _settings.Sync.Playback.LimiterType; + _engine.MainVolume = _settings.Audio.MasterVolume / 100f; + _engine.MusicVolume = _settings.Audio.MusicVolume / 100f; + _engine.EffectVolume = _settings.Audio.EffectVolume / 100f; + _engine.StartDevice(device, new WaveFormat(sampleRate, 2)); + } + + private void StopCurrentDevice() + { + if (_engine.CurrentDevice is not null) + { + _engine.StopDevice(); + } + } + + private void InvalidateGameplayAudio() + { + if (_gameplayAudio is null) return; + + try + { + _gameplayAudio.ClearCaches(); + } + catch (Exception exception) + { + _logger.LogWarning(exception, "Failed to invalidate gameplay audio caches after a device transition"); + } + } +} diff --git a/src/Apps/KeyAsio/ViewModels/AudioSettingsViewModel.cs b/src/Apps/KeyAsio/ViewModels/AudioSettingsViewModel.cs index 8cda7fb2..17b4cb43 100644 --- a/src/Apps/KeyAsio/ViewModels/AudioSettingsViewModel.cs +++ b/src/Apps/KeyAsio/ViewModels/AudioSettingsViewModel.cs @@ -1,5 +1,3 @@ -using System.Collections.ObjectModel; -using System.Text.Json; using Avalonia.Controls; using Avalonia.Controls.Notifications; using CommunityToolkit.Mvvm.ComponentModel; @@ -7,29 +5,23 @@ using KeyAsio.Core.Audio; using KeyAsio.Core.Audio.SampleProviders.BalancePans; using KeyAsio.Lang; +using KeyAsio.Services; using KeyAsio.Shared; -using KeyAsio.Shared.Sync.Services; using Microsoft.Extensions.Logging; -using Milki.Extensions.Configuration; using NAudio.Wave; using SukiUI.Toasts; +using System.Collections.ObjectModel; namespace KeyAsio.ViewModels; public partial class AudioSettingsViewModel : ObservableObject { - private static readonly JsonSerializerOptions JsonSerializerOptions = new() - { - WriteIndented = true, - Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping - }; - public event Action? OnDeviceChanged; private readonly ILogger _logger; private readonly IAudioDeviceManager _audioDeviceManager; + private readonly IAudioDeviceOperationCoordinator _deviceOperations; private readonly AppSettings _appSettings; - private readonly GameplayAudioService _gameplayAudioService; private bool _isInitializing; private (DeviceDescription? PlaybackDevice, int SampleRate) _originalAudioSettings; @@ -46,7 +38,7 @@ public AudioSettingsViewModel() _audioDeviceManager = null!; _logger = null!; PlaybackEngine = null!; - _gameplayAudioService = null!; + _deviceOperations = null!; } } @@ -54,12 +46,12 @@ public AudioSettingsViewModel(ILogger logger, AppSettings appSettings, IAudioDeviceManager audioDeviceManager, IPlaybackEngine playbackEngine, - GameplayAudioService gameplayAudioService) + IAudioDeviceOperationCoordinator deviceOperations) { _logger = logger; _appSettings = appSettings; _audioDeviceManager = audioDeviceManager; - _gameplayAudioService = gameplayAudioService; + _deviceOperations = deviceOperations; PlaybackEngine = playbackEngine; PlaybackEngine.DeviceError += PlaybackEngine_DeviceError; @@ -186,7 +178,7 @@ private void UpdateInfoBarState() public async Task InitializeDevice() { if (_appSettings.Audio.PlaybackDevice == null) return; - await LoadDevice(_appSettings.Audio.PlaybackDevice); + ApplyOperationResult(await _deviceOperations.InitializeConfiguredAsync()); } [RelayCommand] @@ -194,59 +186,34 @@ public async Task ApplyAudioSettings() { DeviceErrorMessage = null; DeviceFullErrorMessage = null; - try + DeviceDescription? requestedDevice = null; + if (SelectedAudioDevice != null) { - DeviceDescription? newDeviceSettings = null; - if (SelectedAudioDevice != null) - { - newDeviceSettings = SelectedAudioDevice with - { - Latency = (int)TargetBufferSize, - IsExclusive = IsExclusiveMode, - ForceASIOBufferSize = (ushort)ForceAsioBufferSize - }; - } - - _appSettings.Audio.PlaybackDevice = newDeviceSettings; - _appSettings.Audio.SampleRate = SelectedSampleRate; - - _originalAudioSettings = (_appSettings.Audio.PlaybackDevice, _appSettings.Audio.SampleRate); - _appSettings.Save(); - CheckAudioChanges(); - - if (newDeviceSettings != null) + requestedDevice = SelectedAudioDevice with { - await DisposeDeviceAsync(); - await InitializeDevice(); - - if (DeviceErrorMessage != null) - { - ToastManager?.CreateToast() - .WithTitle("Device Initialization Failed") - .WithContent(DeviceErrorMessage) - .OfType(NotificationType.Error) - .Dismiss().After(TimeSpan.FromSeconds(5)) - .Dismiss().ByClicking() - .Queue(); - } - else - { - ToastManager?.CreateSimpleInfoToast() - .WithTitle("Audio Settings Applied") - .WithContent( - $"Successfully applied new device: {PlaybackEngine.CurrentDeviceDescription?.FriendlyName}") - .Queue(); - } - } - else - { - await DisposeDeviceAsync(); - } + Latency = (int)TargetBufferSize, + IsExclusive = IsExclusiveMode, + ForceASIOBufferSize = (ushort)ForceAsioBufferSize + }; } - catch (Exception ex) + + var result = await _deviceOperations.ApplyAsync(requestedDevice, SelectedSampleRate); + ApplyOperationResult(result); + if (!result.IsSuccess) { - _logger.LogError(ex, "Error occurs while applying audio settings."); + ShowOperationFailure("Device Initialization Failed", result); + CheckAudioChanges(); + return; } + + _originalAudioSettings = (requestedDevice, SelectedSampleRate); + CheckAudioChanges(); + ToastManager?.CreateSimpleInfoToast() + .WithTitle("Audio Settings Applied") + .WithContent(requestedDevice is null + ? "Audio output disabled." + : $"Successfully applied new device: {result.ActiveDevice?.FriendlyName}") + .Queue(); } [RelayCommand] @@ -267,28 +234,20 @@ public void OpenAsioPanel() [RelayCommand] public async Task ReloadAudioDevice() { - if (_appSettings.Audio.PlaybackDevice != null) - { - await DisposeDeviceAsync(); - await InitializeDevice(); + if (_appSettings.Audio.PlaybackDevice == null) return; - if (PlaybackEngine.CurrentDeviceDescription != null) - { - ToastManager?.CreateSimpleInfoToast() - .WithTitle("Device Reloaded") - .WithContent($"Successfully reloaded device: {PlaybackEngine.CurrentDeviceDescription.FriendlyName}") - .Queue(); - } - else if (DeviceErrorMessage != null) - { - ToastManager?.CreateToast() - .WithTitle("Device Reload Failed") - .WithContent(DeviceErrorMessage) - .OfType(NotificationType.Error) - .Dismiss().After(TimeSpan.FromSeconds(8)) - .Dismiss().ByClicking() - .Queue(); - } + var result = await _deviceOperations.ReloadAsync(); + ApplyOperationResult(result); + if (result.IsSuccess) + { + ToastManager?.CreateSimpleInfoToast() + .WithTitle("Device Reloaded") + .WithContent($"Successfully reloaded device: {result.ActiveDevice?.FriendlyName}") + .Queue(); + } + else + { + ShowOperationFailure("Device Reload Failed", result); } } @@ -297,9 +256,13 @@ public async Task ClearAudioDevice() { DeviceErrorMessage = null; DeviceFullErrorMessage = null; - _appSettings.Audio.PlaybackDevice = null; - _appSettings.Save(); - await DisposeDeviceAsync(); + var result = await _deviceOperations.ClearAsync(); + ApplyOperationResult(result); + if (!result.IsSuccess) + { + ShowOperationFailure("Failed to Disable Audio", result); + return; + } // Also update UI selection if we are on settings page SelectedAudioDevice = null; @@ -424,65 +387,42 @@ private void CheckAudioChanges() SelectedSampleRate != _originalAudioSettings.SampleRate; } - private async Task LoadDevice(DeviceDescription deviceDescription) + private void ApplyOperationResult(AudioDeviceOperationResult result) { - DeviceErrorMessage = null; - DeviceFullErrorMessage = null; - try + if (result.IsSuccess) { - PlaybackEngine.LimiterType = _appSettings.Sync.Playback.LimiterType; - PlaybackEngine.MainVolume = _appSettings.Audio.MasterVolume / 100f; - PlaybackEngine.MusicVolume = _appSettings.Audio.MusicVolume / 100f; - PlaybackEngine.EffectVolume = _appSettings.Audio.EffectVolume / 100f; - PlaybackEngine.StartDevice(deviceDescription, new WaveFormat(SelectedSampleRate, 2)); - - if (PlaybackEngine.CurrentDevice is AsioOut asioOut) - { - var actualDd = PlaybackEngine.CurrentDeviceDescription; - FramesPerBuffer = $"{asioOut.FramesPerBuffer}→{actualDd.AsioActualSamples} samples"; - AsioLatencyMs = actualDd.AsioLatencyMs; - } - - OnDeviceChanged?.Invoke(PlaybackEngine.CurrentDeviceDescription); + DeviceErrorMessage = null; + DeviceFullErrorMessage = null; } - catch (Exception ex) + else { - DeviceErrorMessage = ex.Message; - DeviceFullErrorMessage = ex.ToString(); - _logger.LogError(ex, "Error occurs while creating device: {Information}", - GetConfigInformation(deviceDescription)); - await DisposeDeviceAsync(); + DeviceErrorMessage = result.RollbackError is null + ? result.Error?.Message + : $"{result.Error?.Message} (rollback also failed: {result.RollbackError.Message})"; + DeviceFullErrorMessage = result.RollbackError is null + ? result.Error?.ToString() + : $"{result.Error}\n\nRollback failure:\n{result.RollbackError}"; } - } - private string GetConfigInformation(DeviceDescription deviceDescription) - { - try + if (PlaybackEngine.CurrentDevice is AsioOut asioOut && + PlaybackEngine.CurrentDeviceDescription is { } actualDevice) { - var info = new - { - Device = new - { - deviceDescription.FriendlyName, - deviceDescription.DeviceId, - Type = deviceDescription.WavePlayerType.ToString(), - deviceDescription.Latency, - deviceDescription.IsExclusive, - deviceDescription.ForceASIOBufferSize - }, - Settings = new - { - SampleRate = SelectedSampleRate, - } - }; - return JsonSerializer.Serialize(info, JsonSerializerOptions); - } - catch (Exception ex) - { - return $"Error generating config info: {ex.Message}"; + FramesPerBuffer = $"{asioOut.FramesPerBuffer}→{actualDevice.AsioActualSamples} samples"; + AsioLatencyMs = actualDevice.AsioLatencyMs; } + + OnDeviceChanged?.Invoke(result.ActiveDevice); } + private void ShowOperationFailure(string title, AudioDeviceOperationResult result) => + ToastManager?.CreateToast() + .WithTitle(title) + .WithContent(DeviceErrorMessage ?? "Unknown audio device error.") + .OfType(NotificationType.Error) + .Dismiss().After(TimeSpan.FromSeconds(result.RollbackError is null ? 5 : 10)) + .Dismiss().ByClicking() + .Queue(); + private void PlaybackEngine_DeviceError(Exception ex) { _ = Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(() => @@ -498,25 +438,4 @@ private void PlaybackEngine_DeviceError(Exception ex) .Queue(); }); } - - private async ValueTask DisposeDeviceAsync() - { - OnDeviceChanged?.Invoke(null); - - for (var i = 0; i < 3; i++) - { - try - { - PlaybackEngine.StopDevice(); - break; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error while disposing device."); - await Task.Delay(100); - } - } - - _gameplayAudioService.ClearCaches(); - } } diff --git a/src/Apps/KeyAsio/ViewModels/WizardAudioConfigViewModel.cs b/src/Apps/KeyAsio/ViewModels/WizardAudioConfigViewModel.cs index c58ab492..b395a392 100644 --- a/src/Apps/KeyAsio/ViewModels/WizardAudioConfigViewModel.cs +++ b/src/Apps/KeyAsio/ViewModels/WizardAudioConfigViewModel.cs @@ -1,12 +1,11 @@ -using System.Collections.ObjectModel; +using System.Collections.ObjectModel; using Avalonia.Controls.Notifications; using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using KeyAsio.Core.Audio; +using KeyAsio.Services; using KeyAsio.Shared; -using Milki.Extensions.Configuration; -using NAudio.Wave; using SukiUI.Toasts; namespace KeyAsio.ViewModels; @@ -28,18 +27,18 @@ public enum AudioSubStep public partial class WizardAudioConfigViewModel : ViewModelBase { private readonly IAudioDeviceManager _audioDeviceManager; - private readonly IPlaybackEngine _playbackEngine; + private readonly IAudioDeviceOperationCoordinator _deviceOperations; private readonly ISukiToastManager _toastManager; private readonly AppSettings _appSettings; public WizardAudioConfigViewModel( IAudioDeviceManager audioDeviceManager, - IPlaybackEngine playbackEngine, + IAudioDeviceOperationCoordinator deviceOperations, ISukiToastManager toastManager, AppSettings appSettings) { _audioDeviceManager = audioDeviceManager; - _playbackEngine = playbackEngine; + _deviceOperations = deviceOperations; _toastManager = toastManager; _appSettings = appSettings; @@ -115,11 +114,11 @@ public WizardAudioConfigViewModel( public partial string ValidationMessage { get; set; } = ""; - public bool TryGoBack() + public async Task TryGoBackAsync() { if (CurrentAudioSubStep == AudioSubStep.Configuration) { - BackToSelection(); + await BackToSelection(); return true; } @@ -129,18 +128,18 @@ public bool TryGoBack() IsValidationRunning = false; ValidationSuccess = false; IsAudioConfigFinished = false; - _playbackEngine.StopDevice(); + await _deviceOperations.DeactivateAsync(); return true; } return false; } - public bool TryGoForward() + public async Task TryGoForwardAsync() { if (CurrentAudioSubStep == AudioSubStep.Configuration) { - ApplyAndTestConfig(); + await ApplyAndTestConfig(); return true; } @@ -154,7 +153,7 @@ public bool TryGoForward() else { // Retry - ApplyAndTestConfig(); + await ApplyAndTestConfig(); return true; } } @@ -216,69 +215,45 @@ private void SelectMode(WizardMode mode) } [RelayCommand] - private void BackToSelection() + private async Task BackToSelection() { SelectedMode = WizardMode.NotSelected; CurrentAudioSubStep = AudioSubStep.Selection; IsAudioConfigFinished = false; // Stop any playing audio - _playbackEngine.StopDevice(); + await _deviceOperations.DeactivateAsync(); } [RelayCommand] - private void ApplyAndTestConfig() + private async Task ApplyAndTestConfig() { CurrentAudioSubStep = AudioSubStep.Validation; IsValidationRunning = true; ValidationMessage = "正在初始化音频引擎..."; ValidationSuccess = false; - try - { - if (SelectedAudioDevice != null) - { - _playbackEngine.StopDevice(); - _playbackEngine.LimiterType = _appSettings.Sync.Playback.LimiterType; - _playbackEngine.MainVolume = _appSettings.Audio.MasterVolume / 100f; - _playbackEngine.MusicVolume = _appSettings.Audio.MusicVolume / 100f; - _playbackEngine.EffectVolume = _appSettings.Audio.EffectVolume / 100f; - _playbackEngine.StartDevice(SelectedAudioDevice, new WaveFormat(_appSettings.Audio.SampleRate, 2)); - - _appSettings.Audio.PlaybackDevice = _playbackEngine.CurrentDeviceDescription ?? SelectedAudioDevice; - SaveSettings(); - - // If success - ValidationSuccess = true; - IsAudioConfigFinished = true; - ValidationMessage = "配置成功"; - } - } - catch (Exception ex) - { - ValidationSuccess = false; - ValidationMessage = $"初始化失败: {ex.Message}"; - IsAudioConfigFinished = false; - } - finally + if (SelectedAudioDevice is null) { + ValidationMessage = "请选择音频设备"; IsValidationRunning = false; + return; } - } - private void SaveSettings() - { - try + var result = await _deviceOperations.ApplyAsync(SelectedAudioDevice, _appSettings.Audio.SampleRate); + if (result.IsSuccess) { - _appSettings.Save(); + ValidationSuccess = true; + IsAudioConfigFinished = true; + ValidationMessage = "配置成功"; } - catch (Exception ex) + else { - _toastManager.CreateToast() - .WithTitle("无法保存设置") - .WithContent(ex.Message) - .OfType(NotificationType.Error) - .Queue(); + ValidationSuccess = false; + ValidationMessage = $"初始化失败: {result.Error?.Message ?? "未知错误"}"; + IsAudioConfigFinished = false; } + + IsValidationRunning = false; } [RelayCommand] diff --git a/src/Apps/KeyAsio/ViewModels/WizardViewModel.cs b/src/Apps/KeyAsio/ViewModels/WizardViewModel.cs index 60f211db..2e2c92c7 100644 --- a/src/Apps/KeyAsio/ViewModels/WizardViewModel.cs +++ b/src/Apps/KeyAsio/ViewModels/WizardViewModel.cs @@ -1,4 +1,4 @@ -using System.Collections.ObjectModel; +using System.Collections.ObjectModel; using Avalonia; using Avalonia.Controls; using Avalonia.Controls.ApplicationLifetimes; @@ -266,11 +266,11 @@ public bool CanGoNext() } [RelayCommand(CanExecute = nameof(CanGoNext))] - private void Next() + private async Task Next() { if (StepIndex == 3) { - if (WizardAudioConfigViewModel.TryGoForward()) return; + if (await WizardAudioConfigViewModel.TryGoForwardAsync()) return; } if (StepIndex < Steps.Count - 1) @@ -284,11 +284,11 @@ private void Next() } [RelayCommand] - private void Previous() + private async Task Previous() { if (StepIndex == 3) { - if (WizardAudioConfigViewModel.TryGoBack()) + if (await WizardAudioConfigViewModel.TryGoBackAsync()) { return; } @@ -319,4 +319,4 @@ private void Finish() } public event Action? OnRequestClose; -} \ No newline at end of file +} diff --git a/tests/KeyAsio.UnitTests/AudioDeviceOperationCoordinatorTests.cs b/tests/KeyAsio.UnitTests/AudioDeviceOperationCoordinatorTests.cs new file mode 100644 index 00000000..0d86be6a --- /dev/null +++ b/tests/KeyAsio.UnitTests/AudioDeviceOperationCoordinatorTests.cs @@ -0,0 +1,157 @@ +using KeyAsio.Core.Audio; +using KeyAsio.Services; +using KeyAsio.Shared; +using Microsoft.Extensions.Logging; +using Moq; +using NAudio.Wave; + +namespace KeyAsio.UnitTests; + +public sealed class AudioDeviceOperationCoordinatorTests +{ + [Fact] + public async Task ApplyAsync_StartsBeforeCommittingSettings() + { + var previous = Device("previous"); + var requested = Device("requested"); + var settings = Settings(previous, 44100); + var engine = new PlaybackEngineHarness(previous, 44100); + var persistence = new Mock(); + var events = engine.Events; + persistence.Setup(x => x.Save()).Callback(() => events.Add("save")); + + using var coordinator = CreateCoordinator(settings, persistence, engine); + var result = await coordinator.ApplyAsync(requested, 96000, TestContext.Current.CancellationToken); + + Assert.True(result.IsSuccess); + Assert.Equal(requested, settings.Audio.PlaybackDevice); + Assert.Equal(96000, settings.Audio.SampleRate); + Assert.Equal(["stop:previous", "start:requested", "save"], events); + Assert.Equal(requested, result.ActiveDevice); + } + + [Fact] + public async Task ApplyAsync_WhenStartFails_RestoresDeviceAndSettings() + { + var previous = Device("previous"); + var requested = Device("requested"); + var settings = Settings(previous, 44100); + var engine = new PlaybackEngineHarness(previous, 44100) + { + StartFailure = device => device == requested ? new InvalidOperationException("cannot start") : null + }; + var persistence = new Mock(); + + using var coordinator = CreateCoordinator(settings, persistence, engine); + var result = await coordinator.ApplyAsync(requested, 96000, TestContext.Current.CancellationToken); + + Assert.False(result.IsSuccess); + Assert.True(result.WasRolledBack); + Assert.Equal(previous, settings.Audio.PlaybackDevice); + Assert.Equal(44100, settings.Audio.SampleRate); + Assert.Equal(previous, result.ActiveDevice); + Assert.Equal(["stop:previous", "start:requested", "start:previous"], engine.Events); + persistence.Verify(x => x.Save(), Times.Never); + } + + [Fact] + public async Task ApplyAsync_WhenCommitFails_RestoresPersistedAndRuntimeState() + { + var previous = Device("previous"); + var requested = Device("requested"); + var settings = Settings(previous, 48000); + var engine = new PlaybackEngineHarness(previous, 48000); + var persistence = new Mock(); + var saveAttempt = 0; + persistence.Setup(x => x.Save()).Callback(() => + { + if (Interlocked.Increment(ref saveAttempt) == 1) + { + throw new IOException("write failed"); + } + }); + + using var coordinator = CreateCoordinator(settings, persistence, engine); + var result = await coordinator.ApplyAsync(requested, 96000, TestContext.Current.CancellationToken); + + Assert.False(result.IsSuccess); + Assert.True(result.WasRolledBack); + Assert.Equal(previous, settings.Audio.PlaybackDevice); + Assert.Equal(48000, settings.Audio.SampleRate); + Assert.Equal(previous, result.ActiveDevice); + persistence.Verify(x => x.Save(), Times.Exactly(2)); + } + + private static AudioDeviceOperationCoordinator CreateCoordinator( + AppSettings settings, + Mock persistence, + PlaybackEngineHarness engine) => + new( + settings, + persistence.Object, + engine.Engine.Object, + null, + Mock.Of>()); + + private static AppSettings Settings(DeviceDescription device, int sampleRate) => new() + { + Audio = new AppSettingsAudio + { + PlaybackDevice = device, + SampleRate = sampleRate + } + }; + + private static DeviceDescription Device(string name) => new() + { + WavePlayerType = WavePlayerType.WASAPI, + DeviceId = name, + FriendlyName = name + }; + + private sealed class PlaybackEngineHarness + { + private readonly IWavePlayer _device = Mock.Of(); + private IWavePlayer? _currentDevice; + private DeviceDescription? _description; + private WaveFormat _sourceFormat; + + public PlaybackEngineHarness(DeviceDescription? initialDevice, int initialSampleRate) + { + _currentDevice = initialDevice is null ? null : _device; + _description = initialDevice; + _sourceFormat = new WaveFormat(initialSampleRate, 2); + + Engine.SetupGet(x => x.CurrentDevice).Returns(() => _currentDevice); + Engine.SetupGet(x => x.CurrentDeviceDescription).Returns(() => _description); + Engine.SetupGet(x => x.SourceWaveFormat).Returns(() => _sourceFormat); + Engine.Setup(x => x.StopDevice()).Callback(() => + { + Events.Add($"stop:{_description?.DeviceId}"); + _currentDevice = null; + _description = null; + }); + Engine.Setup(x => x.StartDevice(It.IsAny(), It.IsAny())) + .Callback((DeviceDescription? device, WaveFormat? format) => + { + Events.Add($"start:{device?.DeviceId}"); + var failure = StartFailure?.Invoke(device); + if (failure is not null) + { + throw failure; + } + + _currentDevice = device is null ? null : _device; + _description = device; + if (format is not null) + { + _sourceFormat = format; + } + }); + } + + public Mock Engine { get; } = new(); + public List Events { get; } = []; + public Func? StartFailure { get; init; } + } +} diff --git a/tests/KeyAsio.UnitTests/WizardAudioConfigViewModelTests.cs b/tests/KeyAsio.UnitTests/WizardAudioConfigViewModelTests.cs index 685e9c90..ffc6465f 100644 --- a/tests/KeyAsio.UnitTests/WizardAudioConfigViewModelTests.cs +++ b/tests/KeyAsio.UnitTests/WizardAudioConfigViewModelTests.cs @@ -1,10 +1,10 @@ -using Avalonia.Headless.XUnit; +using Avalonia.Headless.XUnit; using Avalonia.Threading; using KeyAsio.Core.Audio; +using KeyAsio.Services; using KeyAsio.Shared; using KeyAsio.ViewModels; using Moq; -using NAudio.Wave; using SukiUI.Toasts; namespace KeyAsio.UnitTests; @@ -12,27 +12,34 @@ namespace KeyAsio.UnitTests; public class WizardAudioConfigViewModelTests { private readonly Mock _mockDeviceManager; - private readonly Mock _mockPlaybackEngine; + private readonly Mock _mockDeviceOperations; private readonly Mock _mockToastManager; private readonly AppSettings _appSettings; public WizardAudioConfigViewModelTests() { _mockDeviceManager = new Mock(); - _mockPlaybackEngine = new Mock(); + _mockDeviceOperations = new Mock(); _mockToastManager = new Mock(); _appSettings = new AppSettings(); // Default setup for device manager _mockDeviceManager.Setup(m => m.GetCachedAvailableDevicesAsync()) .ReturnsAsync(new List()); + _mockDeviceOperations + .Setup(x => x.ApplyAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns((DeviceDescription? device, int sampleRate, CancellationToken cancellationToken) => + Task.FromResult(new AudioDeviceOperationResult(true, device))); } private WizardAudioConfigViewModel CreateViewModel() { return new WizardAudioConfigViewModel( _mockDeviceManager.Object, - _mockPlaybackEngine.Object, + _mockDeviceOperations.Object, _mockToastManager.Object, _appSettings); } @@ -173,7 +180,7 @@ public void LoadDevices_FiltersByDriverType() } [AvaloniaFact] - public void TryGoForward_Configuration_Success() + public async Task TryGoForward_Configuration_Success() { // Arrange var vm = CreateViewModel(); @@ -183,19 +190,20 @@ public void TryGoForward_Configuration_Success() vm.SelectedAudioDevice = device; // Act - bool result = vm.TryGoForward(); + bool result = await vm.TryGoForwardAsync(); // Assert Assert.True(result); Assert.Equal(AudioSubStep.Validation, vm.CurrentAudioSubStep); Assert.True(vm.ValidationSuccess); - Assert.Equal(device, _appSettings.Audio.PlaybackDevice); - _mockPlaybackEngine.Verify(e => e.StartDevice(device, - It.Is(f => f.SampleRate == _appSettings.Audio.SampleRate && f.Channels == 2)), Times.Once); + _mockDeviceOperations.Verify(x => x.ApplyAsync( + device, + _appSettings.Audio.SampleRate, + It.IsAny()), Times.Once); } [AvaloniaFact] - public void TryGoForward_Configuration_Failure() + public async Task TryGoForward_Configuration_Failure() { // Arrange var vm = CreateViewModel(); @@ -203,11 +211,15 @@ public void TryGoForward_Configuration_Failure() var device = new DeviceDescription { WavePlayerType = WavePlayerType.ASIO }; vm.SelectedAudioDevice = device; - _mockPlaybackEngine.Setup(e => e.StartDevice(It.IsAny(), It.IsAny())) - .Throws(new Exception("Fail")); + _mockDeviceOperations + .Setup(x => x.ApplyAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new AudioDeviceOperationResult(false, null, new Exception("Fail"))); // Act - bool result = vm.TryGoForward(); + bool result = await vm.TryGoForwardAsync(); // Assert Assert.True(result); @@ -238,7 +250,7 @@ public void VirtualDriver_Detection_Success() } [AvaloniaFact] - public void Integration_HardwareFlow_Full() + public async Task Integration_HardwareFlow_Full() { // Arrange var asioDevice = new DeviceDescription { WavePlayerType = WavePlayerType.ASIO, FriendlyName = "Real ASIO" }; @@ -258,17 +270,17 @@ public void Integration_HardwareFlow_Full() Assert.True(vm.CanGoForward); // 3. Go Forward (Validation) - vm.TryGoForward(); + await vm.TryGoForwardAsync(); Assert.Equal(AudioSubStep.Validation, vm.CurrentAudioSubStep); Assert.True(vm.ValidationSuccess); // 4. Go Forward (Finish) - bool result = vm.TryGoForward(); + bool result = await vm.TryGoForwardAsync(); Assert.False(result); // Should return false to indicate proceeding to next main wizard step } [AvaloniaFact] - public void Integration_ProMixSoftwareFlow_Full() + public async Task Integration_ProMixSoftwareFlow_Full() { // Arrange var cableDevice = new DeviceDescription @@ -296,12 +308,12 @@ public void Integration_ProMixSoftwareFlow_Full() Assert.True(vm.CanGoForward); // 5. Go Forward (Validation) - vm.TryGoForward(); + await vm.TryGoForwardAsync(); Assert.Equal(AudioSubStep.Validation, vm.CurrentAudioSubStep); Assert.True(vm.ValidationSuccess); // 6. Go Forward (Finish) - bool result = vm.TryGoForward(); + bool result = await vm.TryGoForwardAsync(); Assert.False(result); } } From bf7858374b54882c8bde8047015961ca96c88996 Mon Sep 17 00:00:00 2001 From: Milkitic Date: Sat, 11 Jul 2026 23:16:23 +0800 Subject: [PATCH 03/19] refactor: migrate applications to plugin contracts and sync sources --- KeyAsio.Net.slnx | 2 - src/Apps/KeyAsio/App.axaml.cs | 23 +- .../KeyAsio}/AssemblyInfo.cs | 7 +- .../OsuStatusToActiveBoolConverter.cs | 2 +- .../Converters/StringColorToBrushConverter.cs | 4 +- .../KeyAsio/DependencyInjectionExtensions.cs | 35 +- src/Apps/KeyAsio/KeyAsio.csproj | 31 +- .../KeyAsio}/Localization/I18NExtension.cs | 2 +- src/Apps/KeyAsio/MessageBox.cs | 4 +- src/Apps/KeyAsio/Program.cs | 18 +- .../AudioDeviceOperationCoordinator.cs | 32 +- .../Services/AvaloniaApplicationDispatcher.cs | 13 + .../Services/BasicUpdateImplementation.cs | 13 +- .../KeyAsio/Services/GuiStartupService.cs | 8 +- .../Services/KeyAsioSentryEventProcessor.cs | 10 +- .../Services/KeyboardBindingInitializer.cs | 21 +- .../AppSettingsLanguagePreferenceStore.cs | 4 +- .../Services/PluginInteractionService.cs | 54 ++ src/Apps/KeyAsio/Services/PresetManager.cs | 4 +- src/Apps/KeyAsio/Services/SettingsManager.cs | 11 +- src/Apps/KeyAsio/Services/UpdateService.cs | 28 +- src/Apps/KeyAsio/Utils/AppExtensions.cs | 4 +- .../ViewModels/AudioSettingsViewModel.cs | 2 +- .../KeyAsio/ViewModels/KeyBindingViewModel.cs | 4 +- .../KeyAsio/ViewModels/MainWindowViewModel.cs | 44 +- .../ViewModels/PluginManagerViewModel.cs | 6 +- .../ViewModels/SyncDisplayViewModel.cs | 8 +- .../ViewModels/WizardAudioConfigViewModel.cs | 2 +- .../KeyAsio/ViewModels/WizardViewModel.cs | 9 +- .../KeyAsio/Views/Controls/SupporterSphere.cs | 7 +- src/Apps/KeyAsio/Views/MainWindow.axaml.cs | 16 +- .../Views/Pages/DashboardPage.axaml.cs | 4 +- .../HandleResult.cs | 28 - .../IAudioEngine.cs | 39 - .../IGameStateHandler.cs | 37 - .../IMusicManagerPlugin.cs | 25 - .../KeyAsio.Plugins.Abstractions/IPlugin.cs | 53 -- .../IPluginContext.cs | 29 - .../IPluginManager.cs | 48 -- .../ISyncContext.cs | 49 -- .../ISyncPlugin.cs | 34 - .../IUpdateImplementation.cs | 8 - .../IUpdateSupportPlugin.cs | 6 - .../KeyAsio.Plugins.Abstractions.csproj | 19 - .../OsuMemory/Mods.cs | 40 - .../OsuMemory/OsuMemoryStatus.cs | 25 - .../SyncBeatmapInfo.cs | 10 - .../SyncHitErrors.cs | 6 - .../SyncOsuStatus.cs | 25 - .../SyncStatistics.cs | 12 - src/Common/KeyAsio.Shared/AppSettings.cs | 203 ----- .../Configuration/BindKeysConverter.cs | 28 - .../DescriptionCommentsObjectGraphVisitor.cs | 25 - .../MyYamlConfigurationConverter.cs | 208 ----- .../KeyAsio.Shared/Events/EventDelegates.cs | 29 - src/Common/KeyAsio.Shared/FodyWeavers.xml | 3 - src/Common/KeyAsio.Shared/FodyWeavers.xsd | 74 -- .../KeyAsio.Shared/KeyAsio.Shared.csproj | 56 -- .../KeyAsio.Shared/LegacyAppSettings.cs | 94 --- .../Localization/ILanguagePreferenceStore.cs | 8 - .../Localization/LanguageItem.cs | 9 - .../Localization/LanguageManager.cs | 114 --- .../Localization/LocalizationService.cs | 57 -- src/Common/KeyAsio.Shared/LogUtils.cs | 60 -- src/Common/KeyAsio.Shared/Models/AppTheme.cs | 13 - src/Common/KeyAsio.Shared/Models/BindKeys.cs | 85 --- .../KeyAsio.Shared/Models/PlaybackInfo.cs | 6 - .../KeyAsio.Shared/Models/RealtimeOptions.cs | 141 ---- .../KeyAsio.Shared/Models/SharedViewModel.cs | 46 -- .../KeyAsio.Shared/Models/SkinDescription.cs | 44 -- .../Models/SliderTailPlaybackBehavior.cs | 6 - .../KeyAsio.Shared/Models/ViewModelBase.cs | 22 - .../OsuMemory/BeatmapIdentifier.cs | 30 - .../OsuMemory/GameSyncSnapshot.cs | 82 -- .../OsuMemory/GameSyncSourceCoordinator.cs | 160 ---- .../OsuMemory/IGameSyncSource.cs | 18 - .../OsuMemory/LazerIpcBridge.cs | 259 ------- .../KeyAsio.Shared/OsuMemory/LazerIpcFrame.cs | 137 ---- .../OsuMemory/LazerIpcGameSyncSource.cs | 250 ------ .../OsuMemory/MemoryReadObject.cs | 187 ----- .../KeyAsio.Shared/OsuMemory/MemoryScan.cs | 612 --------------- .../OsuMemory/MemorySyncBridge.cs | 135 ---- .../KeyAsio.Shared/OsuMemory/OsuMemoryData.cs | 35 - .../OsuMemory/StableMemoryGameSyncSource.cs | 121 --- .../Plugins/AudioEngineWrapper.cs | 30 - .../Plugins/IUserInterfacePlugin.cs | 16 - .../KeyAsio.Shared/Plugins/PluginContext.cs | 49 -- .../KeyAsio.Shared/Plugins/PluginManager.cs | 221 ------ .../Services/RtssMonitorService.cs | 283 ------- .../KeyAsio.Shared/Services/RtssOsdWriter.cs | 244 ------ .../KeyAsio.Shared/Services/SkinManager.cs | 720 ------------------ .../AudioProviders/CatchHitsoundSequencer.cs | 185 ----- .../AudioProviders/ManiaHitsoundSequencer.cs | 241 ------ .../StandardHitsoundSequencer.cs | 269 ------- .../AudioProviders/TaikoHitsoundSequencer.cs | 398 ---------- .../KeyAsio.Shared/Sync/ConfigManager.cs | 54 -- .../Sync/DependencyInjectionExtensions.cs | 35 - .../KeyAsio.Shared/Sync/GameStateMachine.cs | 68 -- .../KeyAsio.Shared/Sync/IHitsoundSequencer.cs | 13 - .../Sync/Services/BeatmapHitsoundLoader.cs | 104 --- .../Sync/Services/GameplayAudioService.cs | 402 ---------- .../Sync/Services/GameplaySessionManager.cs | 216 ------ .../Sync/Services/SfxPlaybackService.cs | 221 ------ .../Sync/SnareDrumSampleProvider.cs | 318 -------- .../Sync/States/BrowsingState.cs | 41 - .../KeyAsio.Shared/Sync/States/IGameState.cs | 16 - .../Sync/States/NotRunningState.cs | 36 - .../Sync/States/PlayingState.cs | 223 ------ .../Sync/States/ResultsState.cs | 36 - .../KeyAsio.Shared/Sync/SyncContextWrapper.cs | 49 -- .../KeyAsio.Shared/Sync/SyncController.cs | 343 --------- .../KeyAsio.Shared/Sync/SyncSessionContext.cs | 300 -------- src/Common/KeyAsio.Shared/UiDispatcher.cs | 86 --- src/Common/KeyAsio.Shared/Utils/AsyncLock.cs | 44 -- .../Utils/AsyncSequentialWorker.cs | 109 --- src/Common/KeyAsio.Shared/Utils/DebugUtils.cs | 157 ---- .../KeyAsio.Shared/Utils/EncodeUtils.cs | 57 -- .../Utils/ObservableRangeCollection.cs | 251 ------ src/Common/KeyAsio.Shared/Utils/OsuLocator.cs | 195 ----- .../KeyAsio.Shared/Utils/RentedArray.cs | 37 - .../KeyAsio.Shared/Utils/RuntimeInfo.cs | 90 --- .../Utils/UniqueObservableCollection.cs | 60 -- tests/ColumnTest/ColumnTest.csproj | 6 +- tests/ColumnTest/Program.cs | 16 +- .../AsyncSequentialWorkerTests.cs | 72 ++ .../AudioDeviceOperationCoordinatorTests.cs | 29 +- .../GameSyncSourceCoordinatorTests.cs | 11 +- .../KeyAsio.UnitTests.csproj | 9 +- tests/KeyAsio.UnitTests/TestAppBuilder.cs | 4 +- .../WizardAudioConfigViewModelTests.cs | 2 +- tests/MemoryReadingTest/App.axaml.cs | 10 +- tests/MemoryReadingTest/AppBootstrapper.cs | 16 +- .../Charts/PlayTimeChart.axaml.cs | 4 +- .../MemoryReadingTest.csproj | 17 +- tests/MemoryReadingTest/Program.cs | 2 +- tests/PlayingTests/PlayingTests.csproj | 8 +- tests/PlayingTests/Program.cs | 22 +- tests/StressTest/Program.cs | 39 +- tests/StressTest/StressTest.csproj | 6 +- 139 files changed, 435 insertions(+), 10337 deletions(-) rename src/{Common/KeyAsio.Shared => Apps/KeyAsio}/AssemblyInfo.cs (53%) rename src/{Common/KeyAsio.Shared => Apps/KeyAsio}/Localization/I18NExtension.cs (97%) create mode 100644 src/Apps/KeyAsio/Services/AvaloniaApplicationDispatcher.cs create mode 100644 src/Apps/KeyAsio/Services/PluginInteractionService.cs delete mode 100644 src/Common/KeyAsio.Plugins.Abstractions/HandleResult.cs delete mode 100644 src/Common/KeyAsio.Plugins.Abstractions/IAudioEngine.cs delete mode 100644 src/Common/KeyAsio.Plugins.Abstractions/IGameStateHandler.cs delete mode 100644 src/Common/KeyAsio.Plugins.Abstractions/IMusicManagerPlugin.cs delete mode 100644 src/Common/KeyAsio.Plugins.Abstractions/IPlugin.cs delete mode 100644 src/Common/KeyAsio.Plugins.Abstractions/IPluginContext.cs delete mode 100644 src/Common/KeyAsio.Plugins.Abstractions/IPluginManager.cs delete mode 100644 src/Common/KeyAsio.Plugins.Abstractions/ISyncContext.cs delete mode 100644 src/Common/KeyAsio.Plugins.Abstractions/ISyncPlugin.cs delete mode 100644 src/Common/KeyAsio.Plugins.Abstractions/IUpdateImplementation.cs delete mode 100644 src/Common/KeyAsio.Plugins.Abstractions/IUpdateSupportPlugin.cs delete mode 100644 src/Common/KeyAsio.Plugins.Abstractions/KeyAsio.Plugins.Abstractions.csproj delete mode 100644 src/Common/KeyAsio.Plugins.Abstractions/OsuMemory/Mods.cs delete mode 100644 src/Common/KeyAsio.Plugins.Abstractions/OsuMemory/OsuMemoryStatus.cs delete mode 100644 src/Common/KeyAsio.Plugins.Abstractions/SyncBeatmapInfo.cs delete mode 100644 src/Common/KeyAsio.Plugins.Abstractions/SyncHitErrors.cs delete mode 100644 src/Common/KeyAsio.Plugins.Abstractions/SyncOsuStatus.cs delete mode 100644 src/Common/KeyAsio.Plugins.Abstractions/SyncStatistics.cs delete mode 100644 src/Common/KeyAsio.Shared/AppSettings.cs delete mode 100644 src/Common/KeyAsio.Shared/Configuration/BindKeysConverter.cs delete mode 100644 src/Common/KeyAsio.Shared/Configuration/DescriptionCommentsObjectGraphVisitor.cs delete mode 100644 src/Common/KeyAsio.Shared/Configuration/MyYamlConfigurationConverter.cs delete mode 100644 src/Common/KeyAsio.Shared/Events/EventDelegates.cs delete mode 100644 src/Common/KeyAsio.Shared/FodyWeavers.xml delete mode 100644 src/Common/KeyAsio.Shared/FodyWeavers.xsd delete mode 100644 src/Common/KeyAsio.Shared/KeyAsio.Shared.csproj delete mode 100644 src/Common/KeyAsio.Shared/LegacyAppSettings.cs delete mode 100644 src/Common/KeyAsio.Shared/Localization/ILanguagePreferenceStore.cs delete mode 100644 src/Common/KeyAsio.Shared/Localization/LanguageItem.cs delete mode 100644 src/Common/KeyAsio.Shared/Localization/LanguageManager.cs delete mode 100644 src/Common/KeyAsio.Shared/Localization/LocalizationService.cs delete mode 100644 src/Common/KeyAsio.Shared/LogUtils.cs delete mode 100644 src/Common/KeyAsio.Shared/Models/AppTheme.cs delete mode 100644 src/Common/KeyAsio.Shared/Models/BindKeys.cs delete mode 100644 src/Common/KeyAsio.Shared/Models/PlaybackInfo.cs delete mode 100644 src/Common/KeyAsio.Shared/Models/RealtimeOptions.cs delete mode 100644 src/Common/KeyAsio.Shared/Models/SharedViewModel.cs delete mode 100644 src/Common/KeyAsio.Shared/Models/SkinDescription.cs delete mode 100644 src/Common/KeyAsio.Shared/Models/SliderTailPlaybackBehavior.cs delete mode 100644 src/Common/KeyAsio.Shared/Models/ViewModelBase.cs delete mode 100644 src/Common/KeyAsio.Shared/OsuMemory/BeatmapIdentifier.cs delete mode 100644 src/Common/KeyAsio.Shared/OsuMemory/GameSyncSnapshot.cs delete mode 100644 src/Common/KeyAsio.Shared/OsuMemory/GameSyncSourceCoordinator.cs delete mode 100644 src/Common/KeyAsio.Shared/OsuMemory/IGameSyncSource.cs delete mode 100644 src/Common/KeyAsio.Shared/OsuMemory/LazerIpcBridge.cs delete mode 100644 src/Common/KeyAsio.Shared/OsuMemory/LazerIpcFrame.cs delete mode 100644 src/Common/KeyAsio.Shared/OsuMemory/LazerIpcGameSyncSource.cs delete mode 100644 src/Common/KeyAsio.Shared/OsuMemory/MemoryReadObject.cs delete mode 100644 src/Common/KeyAsio.Shared/OsuMemory/MemoryScan.cs delete mode 100644 src/Common/KeyAsio.Shared/OsuMemory/MemorySyncBridge.cs delete mode 100644 src/Common/KeyAsio.Shared/OsuMemory/OsuMemoryData.cs delete mode 100644 src/Common/KeyAsio.Shared/OsuMemory/StableMemoryGameSyncSource.cs delete mode 100644 src/Common/KeyAsio.Shared/Plugins/AudioEngineWrapper.cs delete mode 100644 src/Common/KeyAsio.Shared/Plugins/IUserInterfacePlugin.cs delete mode 100644 src/Common/KeyAsio.Shared/Plugins/PluginContext.cs delete mode 100644 src/Common/KeyAsio.Shared/Plugins/PluginManager.cs delete mode 100644 src/Common/KeyAsio.Shared/Services/RtssMonitorService.cs delete mode 100644 src/Common/KeyAsio.Shared/Services/RtssOsdWriter.cs delete mode 100644 src/Common/KeyAsio.Shared/Services/SkinManager.cs delete mode 100644 src/Common/KeyAsio.Shared/Sync/AudioProviders/CatchHitsoundSequencer.cs delete mode 100644 src/Common/KeyAsio.Shared/Sync/AudioProviders/ManiaHitsoundSequencer.cs delete mode 100644 src/Common/KeyAsio.Shared/Sync/AudioProviders/StandardHitsoundSequencer.cs delete mode 100644 src/Common/KeyAsio.Shared/Sync/AudioProviders/TaikoHitsoundSequencer.cs delete mode 100644 src/Common/KeyAsio.Shared/Sync/ConfigManager.cs delete mode 100644 src/Common/KeyAsio.Shared/Sync/DependencyInjectionExtensions.cs delete mode 100644 src/Common/KeyAsio.Shared/Sync/GameStateMachine.cs delete mode 100644 src/Common/KeyAsio.Shared/Sync/IHitsoundSequencer.cs delete mode 100644 src/Common/KeyAsio.Shared/Sync/Services/BeatmapHitsoundLoader.cs delete mode 100644 src/Common/KeyAsio.Shared/Sync/Services/GameplayAudioService.cs delete mode 100644 src/Common/KeyAsio.Shared/Sync/Services/GameplaySessionManager.cs delete mode 100644 src/Common/KeyAsio.Shared/Sync/Services/SfxPlaybackService.cs delete mode 100644 src/Common/KeyAsio.Shared/Sync/SnareDrumSampleProvider.cs delete mode 100644 src/Common/KeyAsio.Shared/Sync/States/BrowsingState.cs delete mode 100644 src/Common/KeyAsio.Shared/Sync/States/IGameState.cs delete mode 100644 src/Common/KeyAsio.Shared/Sync/States/NotRunningState.cs delete mode 100644 src/Common/KeyAsio.Shared/Sync/States/PlayingState.cs delete mode 100644 src/Common/KeyAsio.Shared/Sync/States/ResultsState.cs delete mode 100644 src/Common/KeyAsio.Shared/Sync/SyncContextWrapper.cs delete mode 100644 src/Common/KeyAsio.Shared/Sync/SyncController.cs delete mode 100644 src/Common/KeyAsio.Shared/Sync/SyncSessionContext.cs delete mode 100644 src/Common/KeyAsio.Shared/UiDispatcher.cs delete mode 100644 src/Common/KeyAsio.Shared/Utils/AsyncLock.cs delete mode 100644 src/Common/KeyAsio.Shared/Utils/AsyncSequentialWorker.cs delete mode 100644 src/Common/KeyAsio.Shared/Utils/DebugUtils.cs delete mode 100644 src/Common/KeyAsio.Shared/Utils/EncodeUtils.cs delete mode 100644 src/Common/KeyAsio.Shared/Utils/ObservableRangeCollection.cs delete mode 100644 src/Common/KeyAsio.Shared/Utils/OsuLocator.cs delete mode 100644 src/Common/KeyAsio.Shared/Utils/RentedArray.cs delete mode 100644 src/Common/KeyAsio.Shared/Utils/RuntimeInfo.cs delete mode 100644 src/Common/KeyAsio.Shared/Utils/UniqueObservableCollection.cs create mode 100644 tests/KeyAsio.UnitTests/AsyncSequentialWorkerTests.cs diff --git a/KeyAsio.Net.slnx b/KeyAsio.Net.slnx index 38411932..92e74354 100644 --- a/KeyAsio.Net.slnx +++ b/KeyAsio.Net.slnx @@ -20,13 +20,11 @@ - - diff --git a/src/Apps/KeyAsio/App.axaml.cs b/src/Apps/KeyAsio/App.axaml.cs index afbac6ac..decaf9b3 100644 --- a/src/Apps/KeyAsio/App.axaml.cs +++ b/src/Apps/KeyAsio/App.axaml.cs @@ -1,21 +1,21 @@ -using Avalonia; +using Avalonia; using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Markup.Xaml; using KeyAsio.Core.Audio; -using KeyAsio.Plugins.Abstractions; +using KeyAsio.Plugins.Contracts; using KeyAsio.Services; -using KeyAsio.Shared; -using KeyAsio.Shared.Localization; -using KeyAsio.Shared.OsuMemory; -using KeyAsio.Shared.Plugins; -using KeyAsio.Shared.Services; -using KeyAsio.Shared.Sync; +using KeyAsio.Configuration; +using KeyAsio.Application.Localization; +using KeyAsio.Sync.Sources; +using KeyAsio.Application.Plugins; +using KeyAsio.Application.Services; +using KeyAsio.Sync; using KeyAsio.Views; using Microsoft.Extensions.DependencyInjection; namespace KeyAsio; -public partial class App : Application +public partial class App : Avalonia.Application { public override void Initialize() { @@ -25,8 +25,6 @@ public override void Initialize() public override void OnFrameworkInitializationCompleted() { //I18NExtension.Culture = new CultureInfo("en-US"); - UiDispatcher.SetUiSynchronizationContext(); - LocalizationService.Instance.ConfigureStringResolver(static key => KeyAsio.Lang.SR.ResourceManager.GetString(key) ?? key); LocalizationService.Instance.ConfigureCultureApplier(static culture => KeyAsio.Lang.SR.Culture = culture); @@ -47,7 +45,6 @@ public override void OnFrameworkInitializationCompleted() keyboardBindingInitializer.RegisterAllKeys(); var pluginManager = services.GetRequiredService(); - var playbackEngine = services.GetRequiredService(); // InternalPlugins pluginManager.LoadPlugins(AppDomain.CurrentDomain.BaseDirectory, "KeyAsio.Plugins.*.dll", @@ -57,7 +54,7 @@ public override void OnFrameworkInitializationCompleted() // var pluginDir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Plugins"); // pluginManager.LoadPlugins(pluginDir, "*.dll", SearchOption.AllDirectories); - pluginManager.InitializePlugins(new AudioEngineWrapper(playbackEngine)); + pluginManager.InitializePlugins(); pluginManager.StartupPlugins(); var syncController = services.GetRequiredService(); diff --git a/src/Common/KeyAsio.Shared/AssemblyInfo.cs b/src/Apps/KeyAsio/AssemblyInfo.cs similarity index 53% rename from src/Common/KeyAsio.Shared/AssemblyInfo.cs rename to src/Apps/KeyAsio/AssemblyInfo.cs index 4e910653..09b27132 100644 --- a/src/Common/KeyAsio.Shared/AssemblyInfo.cs +++ b/src/Apps/KeyAsio/AssemblyInfo.cs @@ -1,7 +1,4 @@ -using System.Runtime.CompilerServices; using Avalonia.Metadata; -[assembly: XmlnsDefinition("https://github.com/avaloniaui", "KeyAsio.Shared.Localization")] -[assembly: XmlnsDefinition("https://github.com/avaloniaui", "KeyAsio.Shared.Lang")] - -[assembly: InternalsVisibleTo("ColumnTest")] +[assembly: XmlnsDefinition("https://github.com/avaloniaui", "KeyAsio.Application.Localization")] +[assembly: XmlnsDefinition("https://github.com/avaloniaui", "KeyAsio.Lang")] diff --git a/src/Apps/KeyAsio/Converters/OsuStatusToActiveBoolConverter.cs b/src/Apps/KeyAsio/Converters/OsuStatusToActiveBoolConverter.cs index eacd98af..5cad29c4 100644 --- a/src/Apps/KeyAsio/Converters/OsuStatusToActiveBoolConverter.cs +++ b/src/Apps/KeyAsio/Converters/OsuStatusToActiveBoolConverter.cs @@ -1,7 +1,7 @@ using System; using System.Globalization; using Avalonia.Data.Converters; -using KeyAsio.Plugins.Abstractions.OsuMemory; +using KeyAsio.Plugins.Contracts.Sync; namespace KeyAsio.Converters; diff --git a/src/Apps/KeyAsio/Converters/StringColorToBrushConverter.cs b/src/Apps/KeyAsio/Converters/StringColorToBrushConverter.cs index 0f2d5eb6..2845556b 100644 --- a/src/Apps/KeyAsio/Converters/StringColorToBrushConverter.cs +++ b/src/Apps/KeyAsio/Converters/StringColorToBrushConverter.cs @@ -1,4 +1,4 @@ -using System.Globalization; +using System.Globalization; using Avalonia; using Avalonia.Controls; using Avalonia.Data.Converters; @@ -28,7 +28,7 @@ public class StringColorToBrushConverter : IValueConverter return new SolidColorBrush(color, opacity); } - if (Application.Current?.TryFindResource(colorStr, Application.Current?.ActualThemeVariant, out var res) != true) return null; + if (Avalonia.Application.Current?.TryFindResource(colorStr, Avalonia.Application.Current?.ActualThemeVariant, out var res) != true) return null; { switch (res) { diff --git a/src/Apps/KeyAsio/DependencyInjectionExtensions.cs b/src/Apps/KeyAsio/DependencyInjectionExtensions.cs index 48f431fd..51f9e031 100644 --- a/src/Apps/KeyAsio/DependencyInjectionExtensions.cs +++ b/src/Apps/KeyAsio/DependencyInjectionExtensions.cs @@ -1,7 +1,13 @@ -using KeyAsio.Services; +using KeyAsio.Services; using KeyAsio.Services.Localization; -using KeyAsio.Shared.Localization; -using KeyAsio.Shared.Models; +using KeyAsio.Application.Localization; +using KeyAsio.Application.Models; +using KeyAsio.Application.Abstractions; +using KeyAsio.Application.Plugins; +using KeyAsio.Application.Services; +using KeyAsio.Configuration; +using KeyAsio.Plugins.Contracts; +using KeyAsio.Sync.Abstractions; using KeyAsio.ViewModels; using KeyAsio.ViewModels.Dialogs; using KeyAsio.Views; @@ -15,15 +21,34 @@ public static class DependencyInjectionExtensions { public static IServiceCollection AddGuiModule(this IServiceCollection services) { - services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(static provider => + provider.GetRequiredService()); + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(static provider => + provider.GetRequiredService()); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + + services.AddSingleton(); + services.AddSingleton(static provider => + provider.GetRequiredService()); + services.AddSingleton(); + services.AddSingleton(static provider => + provider.GetRequiredService()); + services.AddSingleton(); + services.AddSingleton(static provider => + provider.GetRequiredService()); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); services.AddSingleton(); services.AddTransient(); diff --git a/src/Apps/KeyAsio/KeyAsio.csproj b/src/Apps/KeyAsio/KeyAsio.csproj index 7549f3c8..9b4c79e8 100644 --- a/src/Apps/KeyAsio/KeyAsio.csproj +++ b/src/Apps/KeyAsio/KeyAsio.csproj @@ -47,16 +47,17 @@ - - + + + - - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -65,17 +66,23 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - + + + + + + + - + + + + + + diff --git a/src/Common/KeyAsio.Shared/Localization/I18NExtension.cs b/src/Apps/KeyAsio/Localization/I18NExtension.cs similarity index 97% rename from src/Common/KeyAsio.Shared/Localization/I18NExtension.cs rename to src/Apps/KeyAsio/Localization/I18NExtension.cs index a62c9198..e5bd20ce 100644 --- a/src/Common/KeyAsio.Shared/Localization/I18NExtension.cs +++ b/src/Apps/KeyAsio/Localization/I18NExtension.cs @@ -5,7 +5,7 @@ using Avalonia.Markup.Xaml; using Avalonia.Metadata; -namespace KeyAsio.Shared.Localization; +namespace KeyAsio.Application.Localization; public sealed class I18NExtension : MarkupExtension { diff --git a/src/Apps/KeyAsio/MessageBox.cs b/src/Apps/KeyAsio/MessageBox.cs index 8c4077d0..7a971755 100644 --- a/src/Apps/KeyAsio/MessageBox.cs +++ b/src/Apps/KeyAsio/MessageBox.cs @@ -1,4 +1,4 @@ -using System.Reflection; +using System.Reflection; using System.Runtime.CompilerServices; using System.Runtime.Versioning; using Avalonia; @@ -229,7 +229,7 @@ private static void GetWindowInfo(Window? window, string? title, out HWND actual // 尝试从 Avalonia 生命周期获取主窗口 if (window == null) { - if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime + if (Avalonia.Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime { MainWindow: { } mainWindow }) diff --git a/src/Apps/KeyAsio/Program.cs b/src/Apps/KeyAsio/Program.cs index 79790ac4..e542d9ed 100644 --- a/src/Apps/KeyAsio/Program.cs +++ b/src/Apps/KeyAsio/Program.cs @@ -1,16 +1,13 @@ -using System.Diagnostics; using Avalonia; +using KeyAsio.Application.Services; +using KeyAsio.Common; +using KeyAsio.Configuration; +using KeyAsio.Configuration.Serialization; using KeyAsio.Core.Audio; using KeyAsio.Core.Memory.Utils; using KeyAsio.Lang; -using KeyAsio.Secrets; using KeyAsio.Services; -using KeyAsio.Shared; -using KeyAsio.Shared.Configuration; -using KeyAsio.Shared.OsuMemory; -using KeyAsio.Shared.Services; -using KeyAsio.Shared.Sync; -using KeyAsio.Shared.Utils; +using KeyAsio.Sync; using KeyAsio.Utils; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -18,6 +15,7 @@ using Milki.Extensions.Configuration; using NLog.Extensions.Logging; using Sentry.Extensibility; +using System.Diagnostics; using Windows.Win32; using Windows.Win32.Foundation; using Windows.Win32.UI.WindowsAndMessaging; @@ -133,8 +131,6 @@ public static async Task Main(string[] args) .ConfigureServices(services => services .AddSingleton() .AddSingleton() - .AddSingleton() - .AddSingleton() .AddSingleton() .AddSingleton() .AddAudioModule() @@ -199,4 +195,4 @@ private static bool IsDirectoryWritable(string dirPath) return false; } } -} \ No newline at end of file +} diff --git a/src/Apps/KeyAsio/Services/AudioDeviceOperationCoordinator.cs b/src/Apps/KeyAsio/Services/AudioDeviceOperationCoordinator.cs index 6cca6086..ec38aa96 100644 --- a/src/Apps/KeyAsio/Services/AudioDeviceOperationCoordinator.cs +++ b/src/Apps/KeyAsio/Services/AudioDeviceOperationCoordinator.cs @@ -1,8 +1,7 @@ +using KeyAsio.Configuration; using KeyAsio.Core.Audio; -using KeyAsio.Shared; -using KeyAsio.Shared.Sync.Services; +using KeyAsio.Sync.Abstractions; using Microsoft.Extensions.Logging; -using Milki.Extensions.Configuration; using NAudio.Wave; namespace KeyAsio.Services; @@ -32,23 +31,6 @@ Task ApplyAsync( Task ClearAsync(CancellationToken cancellationToken = default); } -public interface IAudioSettingsPersistence -{ - void Save(); -} - -public sealed class AudioSettingsPersistence : IAudioSettingsPersistence -{ - private readonly AppSettings _settings; - - public AudioSettingsPersistence(AppSettings settings) - { - _settings = settings; - } - - public void Save() => _settings.Save(); -} - /// /// Owns the complete audio-device transition. Operations are serialized and /// settings are committed only after the requested device is running. @@ -56,17 +38,17 @@ public AudioSettingsPersistence(AppSettings settings) public sealed class AudioDeviceOperationCoordinator : IAudioDeviceOperationCoordinator, IDisposable { private readonly AppSettings _settings; - private readonly IAudioSettingsPersistence _persistence; + private readonly IAppSettingsPersistence _persistence; private readonly IPlaybackEngine _engine; - private readonly GameplayAudioService? _gameplayAudio; + private readonly IGameplayAudioCache _gameplayAudio; private readonly ILogger _logger; private readonly SemaphoreSlim _operationGate = new(1, 1); public AudioDeviceOperationCoordinator( AppSettings settings, - IAudioSettingsPersistence persistence, + IAppSettingsPersistence persistence, IPlaybackEngine engine, - GameplayAudioService? gameplayAudio, + IGameplayAudioCache gameplayAudio, ILogger logger) { _settings = settings; @@ -220,8 +202,6 @@ private void StopCurrentDevice() private void InvalidateGameplayAudio() { - if (_gameplayAudio is null) return; - try { _gameplayAudio.ClearCaches(); diff --git a/src/Apps/KeyAsio/Services/AvaloniaApplicationDispatcher.cs b/src/Apps/KeyAsio/Services/AvaloniaApplicationDispatcher.cs new file mode 100644 index 00000000..db0ef1a2 --- /dev/null +++ b/src/Apps/KeyAsio/Services/AvaloniaApplicationDispatcher.cs @@ -0,0 +1,13 @@ +using Avalonia.Threading; +using KeyAsio.Application.Abstractions; + +namespace KeyAsio.Services; + +public sealed class AvaloniaApplicationDispatcher : IApplicationDispatcher +{ + public async Task InvokeAsync(Action action) + { + ArgumentNullException.ThrowIfNull(action); + await Dispatcher.UIThread.InvokeAsync(action); + } +} diff --git a/src/Apps/KeyAsio/Services/BasicUpdateImplementation.cs b/src/Apps/KeyAsio/Services/BasicUpdateImplementation.cs index 94492e20..8a565451 100644 --- a/src/Apps/KeyAsio/Services/BasicUpdateImplementation.cs +++ b/src/Apps/KeyAsio/Services/BasicUpdateImplementation.cs @@ -1,18 +1,17 @@ -using System.Diagnostics; -using KeyAsio.Plugins.Abstractions; -using Octokit; +using System.Diagnostics; +using KeyAsio.Plugins.Contracts; namespace KeyAsio.Services; public class BasicUpdateImplementation : IUpdateImplementation { - public Task StartUpdateAsync(Release release) + public Task StartUpdateAsync(UpdateRelease release, CancellationToken cancellationToken = default) { - if (release.HtmlUrl != null) + if (release.ReleasePageUrl != null) { - Process.Start(new ProcessStartInfo(release.HtmlUrl) { UseShellExecute = true }); + Process.Start(new ProcessStartInfo(release.ReleasePageUrl) { UseShellExecute = true }); } return Task.CompletedTask; } -} \ No newline at end of file +} diff --git a/src/Apps/KeyAsio/Services/GuiStartupService.cs b/src/Apps/KeyAsio/Services/GuiStartupService.cs index 5a60e61f..67ef20e2 100644 --- a/src/Apps/KeyAsio/Services/GuiStartupService.cs +++ b/src/Apps/KeyAsio/Services/GuiStartupService.cs @@ -1,5 +1,5 @@ -using Avalonia; -using KeyAsio.Shared; +using Avalonia; +using KeyAsio.Configuration; using KeyAsio.Utils; using Microsoft.Extensions.Hosting; @@ -7,12 +7,10 @@ namespace KeyAsio.Services; internal class GuiStartupService : IHostedService { - private readonly IServiceProvider _serviceProvider; private readonly AppSettings _appSettings; - public GuiStartupService(IServiceProvider serviceProvider, AppSettings appSettings) + public GuiStartupService(AppSettings appSettings) { - _serviceProvider = serviceProvider; _appSettings = appSettings; } diff --git a/src/Apps/KeyAsio/Services/KeyAsioSentryEventProcessor.cs b/src/Apps/KeyAsio/Services/KeyAsioSentryEventProcessor.cs index ab281782..fb01f670 100644 --- a/src/Apps/KeyAsio/Services/KeyAsioSentryEventProcessor.cs +++ b/src/Apps/KeyAsio/Services/KeyAsioSentryEventProcessor.cs @@ -1,8 +1,8 @@ -using System.Text; -using KeyAsio.Shared; -using KeyAsio.Shared.Sync; -using KeyAsio.Shared.Sync.Services; -using KeyAsio.Shared.Utils; +using System.Text; +using KeyAsio.Configuration; +using KeyAsio.Sync; +using KeyAsio.Sync.Services; +using KeyAsio.Common; using Sentry.Extensibility; namespace KeyAsio.Services; diff --git a/src/Apps/KeyAsio/Services/KeyboardBindingInitializer.cs b/src/Apps/KeyAsio/Services/KeyboardBindingInitializer.cs index 18470fa3..a7b950e3 100644 --- a/src/Apps/KeyAsio/Services/KeyboardBindingInitializer.cs +++ b/src/Apps/KeyAsio/Services/KeyboardBindingInitializer.cs @@ -5,13 +5,14 @@ using KeyAsio.Core.OsuAudio.Hitsounds; using KeyAsio.Core.OsuAudio.Hitsounds.Playback; using KeyAsio.Core.OsuAudio.Utils; -using KeyAsio.Shared; -using KeyAsio.Shared.Models; -using KeyAsio.Shared.Services; -using KeyAsio.Shared.Sync; -using KeyAsio.Shared.Sync.AudioProviders; -using KeyAsio.Shared.Sync.Services; -using KeyAsio.Shared.Utils; +using KeyAsio.Configuration; +using KeyAsio.Application.Models; +using KeyAsio.Application.Services; +using KeyAsio.Sync; +using KeyAsio.Sync.Models; +using KeyAsio.Sync.AudioProviders; +using KeyAsio.Sync.Services; +using KeyAsio.Common; using Microsoft.Extensions.Logging; using Milki.Extensions.MouseKeyHook; using NAudio.Wave; @@ -39,7 +40,7 @@ public class KeyboardBindingInitializer private readonly GameplaySessionManager _gameplaySessionManager; private readonly SfxPlaybackService _sfxPlaybackService; private readonly SkinManager _skinManager; - private readonly SharedViewModel _sharedViewModel; + private readonly ApplicationState _sharedViewModel; private readonly SyncSessionContext _syncSessionContext; private IKeyboardHook _keyboardHook = null!; @@ -59,7 +60,7 @@ public KeyboardBindingInitializer( GameplaySessionManager gameplaySessionManager, SfxPlaybackService sfxPlaybackService, SkinManager skinManager, - SharedViewModel sharedViewModel, + ApplicationState sharedViewModel, SyncSessionContext syncSessionContext) { _logger = logger; @@ -349,4 +350,4 @@ private CachedAudio ResolveKeyOnlyAudio() var result = _audioCacheManager.GetOrCreateOrEmptyAsync(cacheKey, stream, waveFormat).GetAwaiter().GetResult(); return result.CachedAudio; } -} \ No newline at end of file +} diff --git a/src/Apps/KeyAsio/Services/Localization/AppSettingsLanguagePreferenceStore.cs b/src/Apps/KeyAsio/Services/Localization/AppSettingsLanguagePreferenceStore.cs index b2b797ca..1fa6659b 100644 --- a/src/Apps/KeyAsio/Services/Localization/AppSettingsLanguagePreferenceStore.cs +++ b/src/Apps/KeyAsio/Services/Localization/AppSettingsLanguagePreferenceStore.cs @@ -1,5 +1,5 @@ -using KeyAsio.Shared; -using KeyAsio.Shared.Localization; +using KeyAsio.Configuration; +using KeyAsio.Application.Localization; using Milki.Extensions.Configuration; namespace KeyAsio.Services.Localization; diff --git a/src/Apps/KeyAsio/Services/PluginInteractionService.cs b/src/Apps/KeyAsio/Services/PluginInteractionService.cs new file mode 100644 index 00000000..5e92d9cf --- /dev/null +++ b/src/Apps/KeyAsio/Services/PluginInteractionService.cs @@ -0,0 +1,54 @@ +using Avalonia.Controls.Notifications; +using Avalonia.Threading; +using KeyAsio.Plugins.Contracts; +using SukiUI.Dialogs; +using SukiUI.Toasts; + +namespace KeyAsio.Services; + +public sealed class PluginInteractionService : IPluginInteractionService +{ + private readonly ISukiDialogManager _dialogs; + private readonly ISukiToastManager _toasts; + + public PluginInteractionService(ISukiDialogManager dialogs, ISukiToastManager toasts) + { + _dialogs = dialogs; + _toasts = toasts; + } + + public async ValueTask InvokeAsync(Action action) + { + ArgumentNullException.ThrowIfNull(action); + await Dispatcher.UIThread.InvokeAsync(action); + } + + public void ShowDialog(object content) => Dispatcher.UIThread.Post(() => + _dialogs.CreateDialog().WithContent(content).TryShow()); + + public void DismissDialog() => Dispatcher.UIThread.Post(_dialogs.DismissDialog); + + public void ShowMessage(string title, string content) => Dispatcher.UIThread.Post(() => + _dialogs.CreateDialog() + .WithTitle(title) + .WithContent(content) + .WithActionButton("OK", _ => { }, true) + .TryShow()); + + public void ShowNotification( + string title, + string content, + PluginNotificationKind kind = PluginNotificationKind.Information) => + Dispatcher.UIThread.Post(() => + _toasts.CreateToast() + .WithTitle(title) + .WithContent(content) + .OfType(kind switch + { + PluginNotificationKind.Success => NotificationType.Success, + PluginNotificationKind.Warning => NotificationType.Warning, + PluginNotificationKind.Error => NotificationType.Error, + _ => NotificationType.Information + }) + .Queue()); +} diff --git a/src/Apps/KeyAsio/Services/PresetManager.cs b/src/Apps/KeyAsio/Services/PresetManager.cs index a38992c3..1fe4cd5b 100644 --- a/src/Apps/KeyAsio/Services/PresetManager.cs +++ b/src/Apps/KeyAsio/Services/PresetManager.cs @@ -1,7 +1,7 @@ -using KeyAsio.Core.Audio; +using KeyAsio.Core.Audio; using KeyAsio.Core.Audio.SampleProviders.BalancePans; using KeyAsio.Lang; -using KeyAsio.Shared; +using KeyAsio.Configuration; using KeyAsio.ViewModels; using Material.Icons; diff --git a/src/Apps/KeyAsio/Services/SettingsManager.cs b/src/Apps/KeyAsio/Services/SettingsManager.cs index 869d58a9..7ad7d5a1 100644 --- a/src/Apps/KeyAsio/Services/SettingsManager.cs +++ b/src/Apps/KeyAsio/Services/SettingsManager.cs @@ -1,11 +1,12 @@ -using System.ComponentModel; +using System.ComponentModel; using Avalonia; using Avalonia.Controls.Notifications; using Avalonia.Styling; using Avalonia.Threading; using KeyAsio.Core.Audio; -using KeyAsio.Shared; -using KeyAsio.Shared.Models; +using KeyAsio.Configuration; +using KeyAsio.Configuration.Models; +using KeyAsio.Application.Models; using Microsoft.Extensions.Logging; using Milki.Extensions.Configuration; using SukiUI.Toasts; @@ -40,12 +41,12 @@ public SettingsManager( private void ApplyTheme() { - if (Application.Current == null) return; + if (Avalonia.Application.Current == null) return; Dispatcher.UIThread.Post(() => { var theme = _appSettings.General.Theme; - Application.Current.RequestedThemeVariant = theme switch + Avalonia.Application.Current.RequestedThemeVariant = theme switch { AppTheme.Light => ThemeVariant.Light, AppTheme.Dark => ThemeVariant.Dark, diff --git a/src/Apps/KeyAsio/Services/UpdateService.cs b/src/Apps/KeyAsio/Services/UpdateService.cs index d7b12980..b8a081ca 100644 --- a/src/Apps/KeyAsio/Services/UpdateService.cs +++ b/src/Apps/KeyAsio/Services/UpdateService.cs @@ -1,11 +1,11 @@ -using System.Diagnostics; +using System.Diagnostics; using System.Reflection; using System.Text.Json.Nodes; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; -using KeyAsio.Plugins.Abstractions; -using KeyAsio.Shared; -using KeyAsio.Shared.OsuMemory; +using KeyAsio.Plugins.Contracts; +using KeyAsio.Configuration; +using KeyAsio.Sync.Sources; using Microsoft.Extensions.Logging; using Octokit; using Semver; @@ -41,7 +41,7 @@ public UpdateService(ILogger logger, MemoryScan memoryScan, AppSe public partial bool IsRunningChecking { get; private set; } [ObservableProperty] - public partial Release? NewRelease { get; private set; } + public partial UpdateRelease? NewRelease { get; private set; } [ObservableProperty] public partial SemVersion? SemVersion { get; private set; } @@ -141,7 +141,7 @@ public void CancelUpdate() } // Map Octokit Release to UpdateUtils.GithubRelease to maintain compatibility - NewRelease = latest; + NewRelease = ToUpdateRelease(latest); NewVersion = FixCommit(remoteVersion); NewSemVersion = remoteSemVersion; StatusMessage = null; // Clear status message when update is available @@ -231,8 +231,8 @@ public async Task UpdateRulesAsync() public void OpenLastReleasePage() { - if (NewRelease?.HtmlUrl == null) return; - Process.Start(new ProcessStartInfo(NewRelease.HtmlUrl) { UseShellExecute = true }); + if (NewRelease?.ReleasePageUrl == null) return; + Process.Start(new ProcessStartInfo(NewRelease.ReleasePageUrl) { UseShellExecute = true }); } private void InitializeVersion() @@ -269,4 +269,14 @@ private void InitializeVersion() return version.Substring(0, lastIndexOf); #endif } -} \ No newline at end of file + + private static UpdateRelease ToUpdateRelease(Release release) => new( + release.TagName, + release.HtmlUrl, + release.Body, + release.Prerelease, + release.PublishedAt, + release.Assets + .Select(static asset => new UpdateAsset(asset.Name, asset.BrowserDownloadUrl, asset.Size)) + .ToArray()); +} diff --git a/src/Apps/KeyAsio/Utils/AppExtensions.cs b/src/Apps/KeyAsio/Utils/AppExtensions.cs index 1e902b82..b367f940 100644 --- a/src/Apps/KeyAsio/Utils/AppExtensions.cs +++ b/src/Apps/KeyAsio/Utils/AppExtensions.cs @@ -1,4 +1,4 @@ -using Avalonia; +using Avalonia; using Avalonia.Controls.ApplicationLifetimes; namespace KeyAsio.Utils; @@ -8,5 +8,5 @@ namespace KeyAsio.Utils; internal static class AppExtensions { public static IClassicDesktopStyleApplicationLifetime? CurrentDesktop => - Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime; + Avalonia.Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime; } \ No newline at end of file diff --git a/src/Apps/KeyAsio/ViewModels/AudioSettingsViewModel.cs b/src/Apps/KeyAsio/ViewModels/AudioSettingsViewModel.cs index 17b4cb43..6a9b8973 100644 --- a/src/Apps/KeyAsio/ViewModels/AudioSettingsViewModel.cs +++ b/src/Apps/KeyAsio/ViewModels/AudioSettingsViewModel.cs @@ -2,11 +2,11 @@ using Avalonia.Controls.Notifications; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; +using KeyAsio.Configuration; using KeyAsio.Core.Audio; using KeyAsio.Core.Audio.SampleProviders.BalancePans; using KeyAsio.Lang; using KeyAsio.Services; -using KeyAsio.Shared; using Microsoft.Extensions.Logging; using NAudio.Wave; using SukiUI.Toasts; diff --git a/src/Apps/KeyAsio/ViewModels/KeyBindingViewModel.cs b/src/Apps/KeyAsio/ViewModels/KeyBindingViewModel.cs index 9f35d97e..e32fd6ab 100644 --- a/src/Apps/KeyAsio/ViewModels/KeyBindingViewModel.cs +++ b/src/Apps/KeyAsio/ViewModels/KeyBindingViewModel.cs @@ -1,9 +1,9 @@ -using System.Collections.ObjectModel; +using System.Collections.ObjectModel; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using KeyAsio.Lang; using KeyAsio.Services; -using KeyAsio.Shared; +using KeyAsio.Configuration; using KeyAsio.Views.Dialogs; using Microsoft.Extensions.Logging; using Milki.Extensions.Configuration; diff --git a/src/Apps/KeyAsio/ViewModels/MainWindowViewModel.cs b/src/Apps/KeyAsio/ViewModels/MainWindowViewModel.cs index 843e081c..92cf56ec 100644 --- a/src/Apps/KeyAsio/ViewModels/MainWindowViewModel.cs +++ b/src/Apps/KeyAsio/ViewModels/MainWindowViewModel.cs @@ -8,17 +8,16 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using KeyAsio.Lang; -using KeyAsio.Plugins.Abstractions; -using KeyAsio.Shared.Plugins; +using KeyAsio.Plugins.Contracts; using KeyAsio.Secrets; using KeyAsio.Services; -using KeyAsio.Shared; -using KeyAsio.Shared.Localization; -using KeyAsio.Shared.Models; -using KeyAsio.Shared.Sync; +using KeyAsio.Configuration; +using KeyAsio.Configuration.Models; +using KeyAsio.Application.Localization; +using KeyAsio.Application.Models; +using KeyAsio.Sync; using KeyAsio.ViewModels.Dialogs; using KeyAsio.Views.Dialogs; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using SukiUI.Dialogs; using SukiUI.Toasts; @@ -31,9 +30,8 @@ public partial class MainWindowViewModel : IDisposable public event Action? RequestShowWizard; private readonly ILogger _logger; - private readonly IServiceProvider _serviceProvider; - private readonly SettingsManager _settingsManager; private readonly PresetManager _presetManager; + private readonly PresetSelectionDialogViewModel _presetSelectionDialog; private readonly PropertyChangedEventHandler _languageManagerPropertyChangedHandler; private bool _isNavigating; private bool _disposed; @@ -51,7 +49,7 @@ public MainWindowViewModel() MainToastManager = new SukiToastManager(); AppSettings = new AppSettings(); AudioSettings = new AudioSettingsViewModel(); - Shared = new SharedViewModel(AppSettings); + Shared = new ApplicationState(AppSettings); SyncSession = new SyncSessionContext(AppSettings); SyncDisplay = new SyncDisplayViewModel(SyncSession); @@ -60,6 +58,7 @@ public MainWindowViewModel() LanguageManager = new LanguageManager(null!); _presetManager = new PresetManager(AppSettings); + _presetSelectionDialog = null!; UpdateService = null!; _logger = null!; @@ -79,23 +78,23 @@ public MainWindowViewModel() } public MainWindowViewModel(ILogger logger, - IServiceProvider serviceProvider, AppSettings appSettings, UpdateService updateService, AudioSettingsViewModel audioSettingsViewModel, - SharedViewModel sharedViewModel, + ApplicationState sharedViewModel, SyncSessionContext syncSession, PluginManagerViewModel pluginManagerViewModel, KeyBindingViewModel keyBindingViewModel, ISukiDialogManager dialogManager, ISukiToastManager toastManager, LanguageManager languageManager, - PresetManager presetManager) + PresetManager presetManager, + PresetSelectionDialogViewModel presetSelectionDialog, + IPluginManager pluginManager) { AppSettings = appSettings; UpdateService = updateService; _logger = logger; - _serviceProvider = serviceProvider; AudioSettings = audioSettingsViewModel; Shared = sharedViewModel; SyncSession = syncSession; @@ -110,6 +109,7 @@ public MainWindowViewModel(ILogger logger, LanguageManager = languageManager; _presetManager = presetManager; + _presetSelectionDialog = presetSelectionDialog; IsVerified = VerifyUtils.IsOfficialBuildUnsafe(); #if DEBUG IsDevelopment = true; @@ -133,11 +133,10 @@ public MainWindowViewModel(ILogger logger, }; UpdateUpdateImplementation(); - var pluginManager = serviceProvider.GetService(); - var uiPlugin = pluginManager?.GetAllPlugins().OfType().FirstOrDefault(); - if (uiPlugin != null) + var uiPlugin = pluginManager.GetAllPlugins().OfType().FirstOrDefault(); + if (uiPlugin?.CreateView() is Control view) { - PluginUI = uiPlugin.GetPluginControl(); + PluginUI = view; } } @@ -159,7 +158,7 @@ private void UpdateUpdateImplementation() public AppSettings AppSettings { get; } public UpdateService UpdateService { get; } public AudioSettingsViewModel AudioSettings { get; } - public SharedViewModel Shared { get; } + public ApplicationState Shared { get; } public SyncSessionContext SyncSession { get; } public SyncDisplayViewModel SyncDisplay { get; } public KeyBindingViewModel KeyBinding { get; } @@ -199,17 +198,16 @@ partial void OnIsVerifiedChanged(bool value) [RelayCommand] public void OpenPresetSelection() { - var vm = _serviceProvider.GetRequiredService(); DialogManager.CreateDialog() .WithTitle(SR.Preset_SelectionTitle) - .WithContent(new PresetSelectionDialog { DataContext = vm }) + .WithContent(new PresetSelectionDialog { DataContext = _presetSelectionDialog }) .TryShow(); } [RelayCommand] public void ShowMainWindow() { - if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + if (Avalonia.Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { desktop.MainWindow?.Show(); desktop.MainWindow?.Activate(); @@ -221,7 +219,7 @@ public void ShowMainWindow() public void ExitApplication() { IsExiting = true; - if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + if (Avalonia.Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { desktop.Shutdown(); } diff --git a/src/Apps/KeyAsio/ViewModels/PluginManagerViewModel.cs b/src/Apps/KeyAsio/ViewModels/PluginManagerViewModel.cs index 51a0ce28..0d2c7b3b 100644 --- a/src/Apps/KeyAsio/ViewModels/PluginManagerViewModel.cs +++ b/src/Apps/KeyAsio/ViewModels/PluginManagerViewModel.cs @@ -1,8 +1,8 @@ -using System.ComponentModel; +using System.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel; using KeyAsio.Lang; -using KeyAsio.Plugins.Abstractions; -using KeyAsio.Shared; +using KeyAsio.Plugins.Contracts; +using KeyAsio.Configuration; namespace KeyAsio.ViewModels; diff --git a/src/Apps/KeyAsio/ViewModels/SyncDisplayViewModel.cs b/src/Apps/KeyAsio/ViewModels/SyncDisplayViewModel.cs index 2c6070f6..50be485b 100644 --- a/src/Apps/KeyAsio/ViewModels/SyncDisplayViewModel.cs +++ b/src/Apps/KeyAsio/ViewModels/SyncDisplayViewModel.cs @@ -1,8 +1,8 @@ -using Avalonia.Threading; +using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; -using KeyAsio.Plugins.Abstractions.OsuMemory; -using KeyAsio.Shared.OsuMemory; -using KeyAsio.Shared.Sync; +using KeyAsio.Plugins.Contracts.Sync; +using KeyAsio.Sync.Sources; +using KeyAsio.Sync; namespace KeyAsio.ViewModels; diff --git a/src/Apps/KeyAsio/ViewModels/WizardAudioConfigViewModel.cs b/src/Apps/KeyAsio/ViewModels/WizardAudioConfigViewModel.cs index b395a392..64b622b4 100644 --- a/src/Apps/KeyAsio/ViewModels/WizardAudioConfigViewModel.cs +++ b/src/Apps/KeyAsio/ViewModels/WizardAudioConfigViewModel.cs @@ -3,9 +3,9 @@ using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; +using KeyAsio.Configuration; using KeyAsio.Core.Audio; using KeyAsio.Services; -using KeyAsio.Shared; using SukiUI.Toasts; namespace KeyAsio.ViewModels; diff --git a/src/Apps/KeyAsio/ViewModels/WizardViewModel.cs b/src/Apps/KeyAsio/ViewModels/WizardViewModel.cs index 2e2c92c7..04c7ffc3 100644 --- a/src/Apps/KeyAsio/ViewModels/WizardViewModel.cs +++ b/src/Apps/KeyAsio/ViewModels/WizardViewModel.cs @@ -7,9 +7,10 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using KeyAsio.Lang; -using KeyAsio.Shared; -using KeyAsio.Shared.Services; -using KeyAsio.Shared.Utils; +using KeyAsio.Configuration; +using KeyAsio.Application.Services; +using KeyAsio.Application.Utils; +using KeyAsio.Common; using KeyAsio.ViewModels.Dialogs; namespace KeyAsio.ViewModels; @@ -122,7 +123,7 @@ or nameof(ViewModels.WizardAudioConfigViewModel.CanGoForward)) private async Task BrowseOsuExecutable() { var topLevel = TopLevel.GetTopLevel( - Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop + Avalonia.Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop ? desktop.MainWindow : null); if (topLevel == null) return; diff --git a/src/Apps/KeyAsio/Views/Controls/SupporterSphere.cs b/src/Apps/KeyAsio/Views/Controls/SupporterSphere.cs index f97d2083..1ecc7ac3 100644 --- a/src/Apps/KeyAsio/Views/Controls/SupporterSphere.cs +++ b/src/Apps/KeyAsio/Views/Controls/SupporterSphere.cs @@ -1,15 +1,14 @@ -using System.Buffers; -using System.Collections.Specialized; using Avalonia; using Avalonia.Controls; using Avalonia.Input; using Avalonia.Media; -using Avalonia.Platform; using Avalonia.Rendering.SceneGraph; using Avalonia.Skia; using Avalonia.Styling; using KeyAsio.ViewModels; using SkiaSharp; +using System.Buffers; +using System.Collections.Specialized; namespace KeyAsio.Views.Controls; @@ -240,7 +239,7 @@ protected override void OnPointerCaptureLost(PointerCaptureLostEventArgs e) private void UpdateTags() { - var isDarkMode = Application.Current?.ActualThemeVariant == ThemeVariant.Dark; + var isDarkMode = Avalonia.Application.Current?.ActualThemeVariant == ThemeVariant.Dark; s_tagBgPaint.Color = isDarkMode ? new SKColor(16, 16, 16, 48) : new SKColor(242, 242, 242, 210); diff --git a/src/Apps/KeyAsio/Views/MainWindow.axaml.cs b/src/Apps/KeyAsio/Views/MainWindow.axaml.cs index 1e09bdfa..c9dc98bf 100644 --- a/src/Apps/KeyAsio/Views/MainWindow.axaml.cs +++ b/src/Apps/KeyAsio/Views/MainWindow.axaml.cs @@ -1,4 +1,4 @@ -using System.ComponentModel; +using System.ComponentModel; using Avalonia; using Avalonia.Controls; using Avalonia.Controls.Notifications; @@ -12,8 +12,8 @@ using KeyAsio.Core.Audio.Utils; using KeyAsio.Lang; using KeyAsio.Services; -using KeyAsio.Shared; -using KeyAsio.Shared.Services; +using KeyAsio.Configuration; +using KeyAsio.Application.Services; using KeyAsio.Utils; using KeyAsio.ViewModels; using KeyAsio.Views.Dialogs; @@ -42,7 +42,7 @@ public MainWindow(ILogger logger, MainWindowViewModel mainWindowView _logger = logger; _skinManager = skinManager; DataContext = _viewModel = mainWindowViewModel; - Application.Current!.ActualThemeVariantChanged += (_, _) => + Avalonia.Application.Current!.ActualThemeVariantChanged += (_, _) => { UpdateThemeByDevice(_viewModel.AudioSettings.PlaybackEngine.CurrentDeviceDescription); }; @@ -147,7 +147,7 @@ private void InitializeTrayIcon() { var trayIcons = this.Resources["TrayIcons"] as TrayIcons; if (trayIcons == null) return; - TrayIcon.SetIcons(Application.Current!, trayIcons); + TrayIcon.SetIcons(Avalonia.Application.Current!, trayIcons); _viewModel.AudioSettings.PropertyChanged += (s, e) => AudioSettings_PropertyChanged(s, e, trayIcons.FirstOrDefault()); @@ -208,7 +208,7 @@ private void UpdateAsioMenuVisibility(TrayIcon? trayIcon) private void UpdatePinkTheme() { - var isDark = Application.Current?.ActualThemeVariant == ThemeVariant.Dark; + var isDark = Avalonia.Application.Current?.ActualThemeVariant == ThemeVariant.Dark; if (isDark) { SukiTheme.GetInstance().ChangeColorTheme(new SukiColorTheme("Pink", @@ -327,7 +327,7 @@ private void InitializeStartupLogic() var result = await updateService.CheckUpdateAsync(); if (result == true) { - if (_viewModel.AppSettings.Update.SkipVersion != updateService.NewRelease?.TagName) + if (_viewModel.AppSettings.Update.SkipVersion != updateService.NewRelease?.Version) { Dispatcher.UIThread.Invoke(() => ShowUpdateToast(updateService)); } @@ -364,4 +364,4 @@ private void InitializeStartupLogic() } }); } -} \ No newline at end of file +} diff --git a/src/Apps/KeyAsio/Views/Pages/DashboardPage.axaml.cs b/src/Apps/KeyAsio/Views/Pages/DashboardPage.axaml.cs index 267ed0ac..cd4c5eb2 100644 --- a/src/Apps/KeyAsio/Views/Pages/DashboardPage.axaml.cs +++ b/src/Apps/KeyAsio/Views/Pages/DashboardPage.axaml.cs @@ -1,7 +1,7 @@ -using Avalonia.Controls; +using Avalonia.Controls; using Avalonia.Interactivity; using Avalonia.Media; -using KeyAsio.Plugins.Abstractions.OsuMemory; +using KeyAsio.Plugins.Contracts.Sync; using KeyAsio.ViewModels; namespace KeyAsio.Views.Pages; diff --git a/src/Common/KeyAsio.Plugins.Abstractions/HandleResult.cs b/src/Common/KeyAsio.Plugins.Abstractions/HandleResult.cs deleted file mode 100644 index df250838..00000000 --- a/src/Common/KeyAsio.Plugins.Abstractions/HandleResult.cs +++ /dev/null @@ -1,28 +0,0 @@ -namespace KeyAsio.Plugins.Abstractions; - -/// -/// Result of a game state handler operation, controlling the propagation of the event. -/// -[Flags] -public enum HandleResult -{ - /// - /// Continue to execute lower priority handlers and the base logic. - /// - Continue = 0, - - /// - /// Stop executing lower priority handlers. - /// - BlockLowerPriority = 1 << 0, - - /// - /// Block the base logic (default internal logic) from executing. - /// - BlockBaseLogic = 1 << 1, - - /// - /// Stop executing both lower priority handlers and the base logic. - /// - BlockAll = BlockLowerPriority | BlockBaseLogic -} \ No newline at end of file diff --git a/src/Common/KeyAsio.Plugins.Abstractions/IAudioEngine.cs b/src/Common/KeyAsio.Plugins.Abstractions/IAudioEngine.cs deleted file mode 100644 index c33e74e0..00000000 --- a/src/Common/KeyAsio.Plugins.Abstractions/IAudioEngine.cs +++ /dev/null @@ -1,39 +0,0 @@ -using NAudio.Wave; - -namespace KeyAsio.Plugins.Abstractions; - -/// -/// Audio engine abstraction interface -/// -public interface IAudioEngine -{ - /// - /// Gets the main mixer - /// - ISampleProvider MainMixer { get; } - - /// - /// Gets the effect mixer - /// - ISampleProvider EffectMixer { get; } - - /// - /// Gets the music mixer - /// - ISampleProvider MusicMixer { get; } - - /// - /// Gets the engine's WaveFormat - /// - WaveFormat EngineWaveFormat { get; } - - /// - /// Adds a mixer input to the main mixer - /// - void AddMixerInput(ISampleProvider input); - - /// - /// Removes a mixer input from the main mixer - /// - void RemoveMixerInput(ISampleProvider input); -} diff --git a/src/Common/KeyAsio.Plugins.Abstractions/IGameStateHandler.cs b/src/Common/KeyAsio.Plugins.Abstractions/IGameStateHandler.cs deleted file mode 100644 index b586a8e9..00000000 --- a/src/Common/KeyAsio.Plugins.Abstractions/IGameStateHandler.cs +++ /dev/null @@ -1,37 +0,0 @@ -namespace KeyAsio.Plugins.Abstractions; - -/// -/// Game state handler interface -/// -public interface IGameStateHandler -{ - /// - /// Handler priority. Higher values are executed first. - /// - int Priority { get; } - - /// - /// Called when entering the state - /// - /// Result controlling the propagation. - HandleResult HandleEnter(ISyncContext context); - - /// - /// Called when the state updates - /// - /// Result controlling the propagation. - HandleResult HandleTick(ISyncContext context); - - /// - /// Called when exiting the state - /// - /// Result controlling the propagation. - HandleResult HandleExit(ISyncContext context); - - /// - /// Called when the beatmap changes - /// - /// New beatmap information - /// Result controlling the propagation. - HandleResult HandleBeatmapChange(SyncBeatmapInfo beatmap) => HandleResult.Continue; -} \ No newline at end of file diff --git a/src/Common/KeyAsio.Plugins.Abstractions/IMusicManagerPlugin.cs b/src/Common/KeyAsio.Plugins.Abstractions/IMusicManagerPlugin.cs deleted file mode 100644 index c12362ab..00000000 --- a/src/Common/KeyAsio.Plugins.Abstractions/IMusicManagerPlugin.cs +++ /dev/null @@ -1,25 +0,0 @@ -using Coosu.Beatmap; -using KeyAsio.Core.Audio.Caching; -using KeyAsio.Plugins.Abstractions.OsuMemory; - -namespace KeyAsio.Plugins.Abstractions; - -public interface IMusicManagerPlugin : IPlugin -{ - event EventHandler? OptionStateChanged; - - string OptionName { get; } - string OptionTag { get; } - int OptionPriority { get; } - bool CanEnableOption { get; } - - void StartLowPass(int fadeMilliseconds, int targetFrequency); - void StopCurrentMusic(int fadeMs = 0); - void PauseCurrentMusic(); - void RecoverCurrentMusic(); - void PlaySingleAudioPreview(OsuFile osuFile, string? path, int playTime); - void SetSingleTrackPlayMods(Mods mods); - void SetMainTrackOffsetAndLeadIn(int offset, int leadInMs); - void SyncMainTrackAudio(CachedAudio sound, int positionMs); - void ClearMainTrackAudio(); -} \ No newline at end of file diff --git a/src/Common/KeyAsio.Plugins.Abstractions/IPlugin.cs b/src/Common/KeyAsio.Plugins.Abstractions/IPlugin.cs deleted file mode 100644 index 820132e6..00000000 --- a/src/Common/KeyAsio.Plugins.Abstractions/IPlugin.cs +++ /dev/null @@ -1,53 +0,0 @@ -namespace KeyAsio.Plugins.Abstractions; - -/// -/// Base plugin interface, all plugins must implement this interface -/// -public interface IPlugin -{ - /// - /// Plugin unique identifier - /// - string Id { get; } - - /// - /// Plugin display name - /// - string Name { get; } - - /// - /// Plugin version - /// - string Version { get; } - - /// - /// Plugin author - /// - string Author { get; } - - /// - /// Plugin description - /// - string Description { get; } - - /// - /// Initialize the plugin, perform service registration and configuration reading at this stage - /// - /// Plugin context - void Initialize(IPluginContext context); - - /// - /// Start the plugin, perform resource loading and start services at this stage - /// - void Startup(); - - /// - /// Stop the plugin, stop services at this stage - /// - void Shutdown(); - - /// - /// Unload the plugin, release all resources at this stage - /// - void Unload(); -} diff --git a/src/Common/KeyAsio.Plugins.Abstractions/IPluginContext.cs b/src/Common/KeyAsio.Plugins.Abstractions/IPluginContext.cs deleted file mode 100644 index 25c79501..00000000 --- a/src/Common/KeyAsio.Plugins.Abstractions/IPluginContext.cs +++ /dev/null @@ -1,29 +0,0 @@ -namespace KeyAsio.Plugins.Abstractions; - -/// -/// Plugin context, providing core system access capabilities -/// -public interface IPluginContext -{ - /// - /// Gets the system service provider - /// - IServiceProvider ServiceProvider { get; } - - /// - /// Gets the audio engine access interface - /// - IAudioEngine AudioEngine { get; } - - /// - /// Registers a state handler, allowing plugins to take over logic for specific states - /// - /// Game state - /// Handler - void RegisterStateHandler(SyncOsuStatus status, IGameStateHandler handler); - - /// - /// Unregisters a state handler - /// - void UnregisterStateHandler(SyncOsuStatus status); -} diff --git a/src/Common/KeyAsio.Plugins.Abstractions/IPluginManager.cs b/src/Common/KeyAsio.Plugins.Abstractions/IPluginManager.cs deleted file mode 100644 index beefdfdc..00000000 --- a/src/Common/KeyAsio.Plugins.Abstractions/IPluginManager.cs +++ /dev/null @@ -1,48 +0,0 @@ -namespace KeyAsio.Plugins.Abstractions; - -/// -/// Plugin manager interface -/// -public interface IPluginManager -{ - /// - /// Gets all loaded plugins - /// - IEnumerable GetAllPlugins(); - - /// - /// Gets a plugin of the specified type - /// - T? GetPlugin() where T : class, IPlugin; - - /// - /// Gets active handlers for the specified state, sorted by priority (Descending) - /// - /// Game state - /// List of handlers - IEnumerable GetActiveHandlers(SyncOsuStatus status); - - /// - /// Loads plugins from the specified directory - /// - /// Plugin directory path - /// Search pattern, default is "*.dll" - /// Search option, default is SearchOption.AllDirectories - void LoadPlugins(string pluginDirectory, string searchPattern = "*.dll", SearchOption searchOption = SearchOption.AllDirectories); - - /// - /// Initializes all plugins - /// - /// Audio engine - void InitializePlugins(IAudioEngine audioEngine); - - /// - /// Starts all plugins - /// - void StartupPlugins(); - - /// - /// Unloads all plugins - /// - void UnloadPlugins(); -} diff --git a/src/Common/KeyAsio.Plugins.Abstractions/ISyncContext.cs b/src/Common/KeyAsio.Plugins.Abstractions/ISyncContext.cs deleted file mode 100644 index d2d0f840..00000000 --- a/src/Common/KeyAsio.Plugins.Abstractions/ISyncContext.cs +++ /dev/null @@ -1,49 +0,0 @@ -namespace KeyAsio.Plugins.Abstractions; - -public interface ISyncContext -{ - /// - /// Current play time (ms) - /// - int PlayTime { get; } - - /// - /// Whether started (Gameplay session active) - /// - bool IsStarted { get; } - - /// - /// Current game state - /// - SyncOsuStatus OsuStatus { get; } - - /// - /// Timestamp of last update (Ticks) - /// - long LastUpdateTimestamp { get; } - - /// - /// Current mods (Bitmask) - /// - int PlayMods { get; } - - /// - /// Gameplay judgement statistics. - /// - SyncStatistics Statistics { get; } - - /// - /// Latest hit error stream update. - /// - SyncHitErrors HitErrors { get; } - - /// - /// Current beatmap information - /// - SyncBeatmapInfo? Beatmap { get; } - - /// - /// Whether audio is paused/frozen (predicted time is in micro-regression protection). - /// - bool IsAudioPaused { get; } -} diff --git a/src/Common/KeyAsio.Plugins.Abstractions/ISyncPlugin.cs b/src/Common/KeyAsio.Plugins.Abstractions/ISyncPlugin.cs deleted file mode 100644 index fa5dd1c4..00000000 --- a/src/Common/KeyAsio.Plugins.Abstractions/ISyncPlugin.cs +++ /dev/null @@ -1,34 +0,0 @@ -namespace KeyAsio.Plugins.Abstractions; - -/// -/// Sync plugin interface, used to take over SyncController events and logic -/// -public interface ISyncPlugin : IPlugin -{ - /// - /// Called when the sync loop starts - /// - void OnSyncStart(); - - /// - /// Called when the sync loop stops - /// - void OnSyncStop(); - - /// - /// Called every sync frame (high frequency call, pay attention to performance) - /// - /// Sync context - /// Time difference from the previous frame (ms) - void OnTick(ISyncContext context, int deltaMs); - - /// - /// Called when the game status changes - /// - void OnStatusChanged(SyncOsuStatus oldStatus, SyncOsuStatus newStatus); - - /// - /// Called when the beatmap changes - /// - void OnBeatmapChanged(SyncBeatmapInfo beatmap); -} \ No newline at end of file diff --git a/src/Common/KeyAsio.Plugins.Abstractions/IUpdateImplementation.cs b/src/Common/KeyAsio.Plugins.Abstractions/IUpdateImplementation.cs deleted file mode 100644 index c7f82440..00000000 --- a/src/Common/KeyAsio.Plugins.Abstractions/IUpdateImplementation.cs +++ /dev/null @@ -1,8 +0,0 @@ -using Octokit; - -namespace KeyAsio.Plugins.Abstractions; - -public interface IUpdateImplementation -{ - Task StartUpdateAsync(Release release); -} \ No newline at end of file diff --git a/src/Common/KeyAsio.Plugins.Abstractions/IUpdateSupportPlugin.cs b/src/Common/KeyAsio.Plugins.Abstractions/IUpdateSupportPlugin.cs deleted file mode 100644 index 25fac6db..00000000 --- a/src/Common/KeyAsio.Plugins.Abstractions/IUpdateSupportPlugin.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace KeyAsio.Plugins.Abstractions; - -public interface IUpdateSupportPlugin : IPlugin -{ - IUpdateImplementation UpdateImplementation { get; } -} \ No newline at end of file diff --git a/src/Common/KeyAsio.Plugins.Abstractions/KeyAsio.Plugins.Abstractions.csproj b/src/Common/KeyAsio.Plugins.Abstractions/KeyAsio.Plugins.Abstractions.csproj deleted file mode 100644 index 1b4b56a4..00000000 --- a/src/Common/KeyAsio.Plugins.Abstractions/KeyAsio.Plugins.Abstractions.csproj +++ /dev/null @@ -1,19 +0,0 @@ - - - - net10.0 - enable - enable - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/Common/KeyAsio.Plugins.Abstractions/OsuMemory/Mods.cs b/src/Common/KeyAsio.Plugins.Abstractions/OsuMemory/Mods.cs deleted file mode 100644 index 71786a6c..00000000 --- a/src/Common/KeyAsio.Plugins.Abstractions/OsuMemory/Mods.cs +++ /dev/null @@ -1,40 +0,0 @@ -namespace KeyAsio.Plugins.Abstractions.OsuMemory; - -[Flags] -public enum Mods : uint -{ - NoMod = 0u, - None = 0u, - NoFail = 1u << 0, - Easy = 1u << 1, - TouchDevice = 1u << 2, - Hidden = 1u << 3, - HardRock = 1u << 4, - SuddenDeath = 1u << 5, - DoubleTime = 1u << 6, - Relax = 1u << 7, - HalfTime = 1u << 8, - Nightcore = 1u << 9, - Flashlight = 1u << 10, - Autoplay = 1u << 11, - SpunOut = 1u << 12, - AutoPilot = 1u << 13, - Perfect = 1u << 14, - Key4 = 1u << 15, - Key5 = 1u << 16, - Key6 = 1u << 17, - Key7 = 1u << 18, - Key8 = 1u << 19, - FadeIn = 1u << 20, - Random = 1u << 21, - Cinema = 1u << 22, - Target = 1u << 23, - Key9 = 1u << 24, - KeyCoop = 1u << 25, - Key1 = 1u << 26, - Key3 = 1u << 27, - Key2 = 1u << 28, - ScoreV2 = 1u << 29, - Mirror = 1u << 30, - Unknown = 0xFFFFFFFFu -} \ No newline at end of file diff --git a/src/Common/KeyAsio.Plugins.Abstractions/OsuMemory/OsuMemoryStatus.cs b/src/Common/KeyAsio.Plugins.Abstractions/OsuMemory/OsuMemoryStatus.cs deleted file mode 100644 index 576c8763..00000000 --- a/src/Common/KeyAsio.Plugins.Abstractions/OsuMemory/OsuMemoryStatus.cs +++ /dev/null @@ -1,25 +0,0 @@ -namespace KeyAsio.Plugins.Abstractions.OsuMemory; - -public enum OsuMemoryStatus -{ - Unknown = -0xff, - NotRunning = -0x0f, - - MainView = 0, - Editing = 1, - Playing = 2, - GameShutdown = 3, - EditSongSelection = 4, - SongSelection = 5, - ResultsScreen = 7, - GameStartup = 10, - MultiLobby = 11, - MultiRoom = 12, - MultiSongSelection = 13, - MultiResultsScreen = 14, - OsuDirect = 15, - TagCoopRanking = 17, - TeamRanking = 18, - BeatmapProcessing = 19, - Tourney = 22, -} \ No newline at end of file diff --git a/src/Common/KeyAsio.Plugins.Abstractions/SyncBeatmapInfo.cs b/src/Common/KeyAsio.Plugins.Abstractions/SyncBeatmapInfo.cs deleted file mode 100644 index 7741c56d..00000000 --- a/src/Common/KeyAsio.Plugins.Abstractions/SyncBeatmapInfo.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace KeyAsio.Plugins.Abstractions; - -public class SyncBeatmapInfo -{ - public int SetId { get; set; } - public int MapId { get; set; } - public string? Md5 { get; set; } - public string? Folder { get; set; } - public string? Filename { get; set; } -} \ No newline at end of file diff --git a/src/Common/KeyAsio.Plugins.Abstractions/SyncHitErrors.cs b/src/Common/KeyAsio.Plugins.Abstractions/SyncHitErrors.cs deleted file mode 100644 index 78c343ca..00000000 --- a/src/Common/KeyAsio.Plugins.Abstractions/SyncHitErrors.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace KeyAsio.Plugins.Abstractions; - -public readonly record struct SyncHitErrors(int Index, int[] Values) -{ - public static SyncHitErrors Empty { get; } = new(0, []); -} diff --git a/src/Common/KeyAsio.Plugins.Abstractions/SyncOsuStatus.cs b/src/Common/KeyAsio.Plugins.Abstractions/SyncOsuStatus.cs deleted file mode 100644 index 486bd329..00000000 --- a/src/Common/KeyAsio.Plugins.Abstractions/SyncOsuStatus.cs +++ /dev/null @@ -1,25 +0,0 @@ -namespace KeyAsio.Plugins.Abstractions; - -public enum SyncOsuStatus -{ - Unknown = -0xff, - NotRunning = -0x0f, - - MainView = 0, - Editing = 1, - Playing = 2, - GameShutdown = 3, - EditSongSelection = 4, - SongSelection = 5, - ResultsScreen = 7, - GameStartup = 10, - MultiLobby = 11, - MultiRoom = 12, - MultiSongSelection = 13, - MultiResultsScreen = 14, - OsuDirect = 15, - TagCoopRanking = 17, - TeamRanking = 18, - BeatmapProcessing = 19, - Tourney = 22, -} \ No newline at end of file diff --git a/src/Common/KeyAsio.Plugins.Abstractions/SyncStatistics.cs b/src/Common/KeyAsio.Plugins.Abstractions/SyncStatistics.cs deleted file mode 100644 index ba17a4eb..00000000 --- a/src/Common/KeyAsio.Plugins.Abstractions/SyncStatistics.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace KeyAsio.Plugins.Abstractions; - -public readonly record struct SyncStatistics( - int Perfect, - int Great, - int Good, - int Ok, - int Meh, - int Miss) -{ - public static SyncStatistics Empty { get; } = new(); -} diff --git a/src/Common/KeyAsio.Shared/AppSettings.cs b/src/Common/KeyAsio.Shared/AppSettings.cs deleted file mode 100644 index 78142056..00000000 --- a/src/Common/KeyAsio.Shared/AppSettings.cs +++ /dev/null @@ -1,203 +0,0 @@ -using System.ComponentModel; -using KeyAsio.Core.Audio; -using KeyAsio.Core.Audio.SampleProviders.BalancePans; -using KeyAsio.Shared.Models; -using KeyAsio.Shared.Sync; -using Milki.Extensions.Configuration; -using Milki.Extensions.MouseKeyHook; - -namespace KeyAsio.Shared; - -public class AppSettings : IConfigurationBase -{ - public AppSettingsGeneral General { get => field ??= new(); init; } - public AppSettingsInput Input { get => field ??= new(); init; } - public AppSettingsPaths Paths { get => field ??= new(); init; } - public AppSettingsAudio Audio { get => field ??= new(); init; } - public AppSettingsLogging Logging { get => field ??= new(); init; } - public AppSettingsPerformance Performance { get => field ??= new(); init; } - public AppSettingsSync Sync { get => field ??= new(); init; } - public AppSettingsUpdate Update { get => field ??= new(); init; } -} - -public partial class AppSettingsGeneral : INotifyPropertyChanged -{ - [Description("Allow multiple instances of the application to run simultaneously.")] - public bool AllowMultipleInstance { get; set; } - - [Description("Application language.")] - public string? Language { get; set; } - - [Description("Application theme.")] - public AppTheme Theme { get; set; } = AppTheme.System; - - public bool IsFirstRun { get; set; } = true; -} - -public partial class AppSettingsInput : INotifyPropertyChanged -{ - [Description("Use raw input for capturing keys; otherwise uses low‑level keyboard hook. " + - "Switch only if you encounter issues.")] - public bool UseRawInput { get; set; } = true; - - [Description("Trigger keys for standard mode. Refer to https://docs.microsoft.com/en-us/dotnet/api/System.Windows.Forms.Keys.")] - public List OsuKeys { get; set; } = [HookKeys.Z, HookKeys.X]; - - [Description("Trigger keys for taiko mode.")] - public List TaikoKeys { get; set; } = [HookKeys.Z, HookKeys.X, HookKeys.C, HookKeys.V]; - - [Description("Trigger keys for catch mode.")] - public List CatchKeys { get; set; } = [HookKeys.Shift, HookKeys.Left, HookKeys.Right]; - - [Description("Trigger keys for mania mode (4k-10k). Key is key count, Value is list of keys.")] - public Dictionary> ManiaKeys { get; set; } = new() - { - [4] = [HookKeys.D, HookKeys.F, HookKeys.J, HookKeys.K], - [5] = [HookKeys.D, HookKeys.F, HookKeys.Space, HookKeys.J, HookKeys.K], - [6] = [HookKeys.S, HookKeys.D, HookKeys.F, HookKeys.J, HookKeys.K, HookKeys.L], - [7] = [HookKeys.S, HookKeys.D, HookKeys.F, HookKeys.Space, HookKeys.J, HookKeys.K, HookKeys.L], - [8] = [HookKeys.A, HookKeys.S, HookKeys.D, HookKeys.F, HookKeys.J, HookKeys.K, HookKeys.L, HookKeys.OemSemicolon], - [9] = [HookKeys.A, HookKeys.S, HookKeys.D, HookKeys.F, HookKeys.Space, HookKeys.J, HookKeys.K, HookKeys.L, HookKeys.OemSemicolon], - [10] = [HookKeys.Q, HookKeys.W, HookKeys.E, HookKeys.R, HookKeys.V, HookKeys.N, HookKeys.U, HookKeys.I, HookKeys.O, HookKeys.P] - }; -} - -public partial class AppSettingsPaths : INotifyPropertyChanged -{ - [Description("osu! folder. Usually auto-detected.")] - public string? OsuFolderPath { get; set; } = ""; - - [Description("Skin used when sync mode is enabled.")] - public string? SelectedSkinName { get; set; } - - [Description("Allow automatic loading of skins from osu! folder.")] - public bool AllowAutoLoadSkins { get; set; } - - [Description("Last synced game client type (Stable or Lazer).")] - public GameClientType ClientType { get; set; } = GameClientType.Stable; -} - -public partial class AppSettingsAudio : INotifyPropertyChanged -{ - [Description("Output sample rate (adjustable in GUI).")] - public int SampleRate { get; set; } = 48000; - - [Description("Playback device configuration (configure in GUI).")] - public DeviceDescription? PlaybackDevice { get; set; } - - [Description("Master volume. Range: 0–150. " + - "For values above 100, consider disabling the Limiter to avoid aggressive compression.")] - public int MasterVolume { get; set; } = 50; - - [Description("Music track volume.")] - public int MusicVolume { get; set; } = 100; - - [Description("Effect track volume.")] - public int EffectVolume { get; set; } = 100; - - [Description("Extend the maximum volume limit to 150%.")] - public bool EnableExtendedVolume { get; set; } -} - -public partial class AppSettingsLogging : INotifyPropertyChanged -{ - [Description("Enable console window for logs.")] - public bool EnableDebugConsole { get; set; } - - [Description("Enable error/bug reporting to developer.")] - public bool? EnableErrorReporting { get; set; } - - public string? PlayerBase64 { get; set; } -} - -public partial class AppSettingsPerformance : INotifyPropertyChanged -{ - [Description("Accelerates processing using AVX-512. " + - "Disable on older Intel CPUs (pre-11th Gen) to avoid clock speed throttling.")] - public bool EnableAvx512 { get; set; } = true; -} - -public partial class AppSettingsSync : INotifyPropertyChanged -{ - [Description("Enable memory scanning and correct hitsound playback.")] - public bool EnableSync { get; set; } = true; - - [Description("[Experimental] Enable music‑related functions.")] - public bool EnableMixSync { get; set; } - - [Description("Enable RTSS on-screen display monitoring for SyncSessionContext data.")] - public bool EnableRtssMonitoring { get; set; } - - public AppSettingsSyncScanning Scanning { get => field ??= new(); init; } - public AppSettingsSyncPlayback Playback { get => field ??= new(); init; } - public AppSettingsSyncFilters Filters { get => field ??= new(); init; } -} - -public partial class AppSettingsSyncScanning : INotifyPropertyChanged -{ - [Description("Lower values update generic fields more promptly. " + - "Intended for delay-insensitive fields; increase to reduce CPU usage.")] - public int GeneralScanInterval { get; set; } = 50; - - [Description("Lower values update timing fields more promptly. " + - "Intended for delay‑sensitive fields; keep as low as possible. " + - "Increase if audio cutting occurs.")] - public int TimingScanInterval { get; set; } = 2; -} - -public partial class AppSettingsSyncPlayback : INotifyPropertyChanged -{ - [Description("Slider‑tail playback behavior. " + - "Normal: always play; KeepReverse: play only on multi‑reverse sliders; Ignore: never play.")] - public SliderTailPlaybackBehavior TailPlaybackBehavior { get; set; } = SliderTailPlaybackBehavior.Normal; - - [Description("Force use of nightcore beats.")] - public bool NightcoreBeats { get; set; } - - [Description("Prevents clipping when hitsounds stack. " + - "'Polynomial' mode is recommended; disable for raw audio. " + - "Note: 'Master' mode increases CPU usage.")] - public LimiterType LimiterType { get; set; } = LimiterType.Master; - - [Description("Stereo balance processing mode.")] - public BalanceMode BalanceMode { get; set; } = BalanceMode.MidSide; - - [Description("Balance factor.")] - public float BalanceFactor { get; set; } = 0.3333333f; -} - -public partial class AppSettingsSyncFilters : INotifyPropertyChanged -{ - [Description("Ignore beatmap hitsounds and use user skin instead.")] - public bool DisableBeatmapHitsounds { get; set; } - - [Description("Ignore beatmap storyboard samples.")] - public bool DisableStoryboardSamples { get; set; } - - [Description("Ignore slider ticks and slides.")] - public bool DisableSliderTicksAndSlides { get; set; } - - [Description("Ignore combo break sound.")] - public bool DisableComboBreakSfx { get; set; } - - [Description("Ignore beatmap line volume changes.")] - public bool IgnoreLineVolumes { get; set; } -} - -public partial class AppSettingsUpdate : INotifyPropertyChanged -{ - public string? SkipVersion { get; set; } - - [Description("Update channel.")] - public UpdateChannel Channel { get; set; } = UpdateChannel.Stable; -} - -public enum UpdateChannel -{ - [Description("UpdateChannel_Stable")] - Stable = 0, - [Description("UpdateChannel_Beta")] - Beta = 1, - // [Description("UpdateChannel_Alpha")] - // Alpha = 2 -} \ No newline at end of file diff --git a/src/Common/KeyAsio.Shared/Configuration/BindKeysConverter.cs b/src/Common/KeyAsio.Shared/Configuration/BindKeysConverter.cs deleted file mode 100644 index a62eb7c1..00000000 --- a/src/Common/KeyAsio.Shared/Configuration/BindKeysConverter.cs +++ /dev/null @@ -1,28 +0,0 @@ -using KeyAsio.Shared.Models; -using YamlDotNet.Core; -using YamlDotNet.Core.Events; -using YamlDotNet.Serialization; - -namespace KeyAsio.Shared.Configuration; - -public class BindKeysConverter : IYamlTypeConverter -{ - private static readonly Type s_type = typeof(BindKeys); - public bool Accepts(Type type) - { - if (type == s_type) return true; - return false; - } - - public object ReadYaml(IParser parser, Type type, ObjectDeserializer rootDeserializer) - { - var s = parser.Consume(); - var convertFrom = BindKeys.Parse(s.Value); - return convertFrom; - } - - public void WriteYaml(IEmitter emitter, object? value, Type type, ObjectSerializer serializer) - { - emitter.Emit(new Scalar(value?.ToString() ?? "")); - } -} \ No newline at end of file diff --git a/src/Common/KeyAsio.Shared/Configuration/DescriptionCommentsObjectGraphVisitor.cs b/src/Common/KeyAsio.Shared/Configuration/DescriptionCommentsObjectGraphVisitor.cs deleted file mode 100644 index 3d7192c8..00000000 --- a/src/Common/KeyAsio.Shared/Configuration/DescriptionCommentsObjectGraphVisitor.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System.ComponentModel; -using YamlDotNet.Core; -using YamlDotNet.Serialization; -using YamlDotNet.Serialization.ObjectGraphVisitors; - -namespace KeyAsio.Shared.Configuration; - -public class DescriptionCommentsObjectGraphVisitor : ChainedObjectGraphVisitor -{ - public DescriptionCommentsObjectGraphVisitor(IObjectGraphVisitor nextVisitor) - : base(nextVisitor) - { - } - - public override bool EnterMapping(IPropertyDescriptor key, IObjectDescriptor value, IEmitter context, ObjectSerializer serializer) - { - var attr = key.GetCustomAttribute(); - if (attr is { Description: not null }) - { - context.Emit(new YamlDotNet.Core.Events.Comment(attr.Description, false)); - } - - return base.EnterMapping(key, value, context, serializer); - } -} \ No newline at end of file diff --git a/src/Common/KeyAsio.Shared/Configuration/MyYamlConfigurationConverter.cs b/src/Common/KeyAsio.Shared/Configuration/MyYamlConfigurationConverter.cs deleted file mode 100644 index 12db6553..00000000 --- a/src/Common/KeyAsio.Shared/Configuration/MyYamlConfigurationConverter.cs +++ /dev/null @@ -1,208 +0,0 @@ -using System.Diagnostics.CodeAnalysis; -using KeyAsio.Core.Audio; -using Milki.Extensions.Configuration.Converters; -using YamlDotNet.Serialization; -using YamlDotNet.Serialization.NamingConventions; - -namespace KeyAsio.Shared.Configuration; - -public class MyYamlConfigurationConverter : YamlConfigurationConverter -{ - private MyYamlConfigurationConverter() - { - } - - public static MyYamlConfigurationConverter Instance { get; } = new(); - - protected override void ConfigSerializeBuilder(SerializerBuilder builder) - { - base.ConfigSerializeBuilder(builder); - builder.WithTypeConverter(new BindKeysConverter()); - } - - protected override void ConfigDeserializeBuilder(DeserializerBuilder builder) - { - base.ConfigDeserializeBuilder(builder); - builder.WithTypeConverter(new BindKeysConverter()); - } - - public override object DeserializeSettings(string content, - [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] Type type) - { - if (type == typeof(AppSettings)) - { - if (!content.StartsWith("default:") && !LooksLikeNewYaml(content)) - { - return ToYaml((LegacyAppSettings)base.DeserializeSettings(content, typeof(LegacyAppSettings))); - } - - var builder = new DeserializerBuilder() - .WithNamingConvention(CamelCaseNamingConvention.Instance) - .IgnoreUnmatchedProperties() - .IgnoreFields() - .WithTypeConverter(new BindKeysConverter()); - var deserializer = builder.Build(); - var yamlModel = deserializer.Deserialize(content); - return yamlModel; - } - - return base.DeserializeSettings(content, type); - } - - private static bool LooksLikeNewYaml(string content) - { - var tokens = new[] { "input", "paths", "audio", "logging", "performance", "sync" }; - using var sr = new StringReader(content); - string? line; - while ((line = sr.ReadLine()) != null) - { - if (string.IsNullOrWhiteSpace(line)) continue; - var span = line.AsSpan().TrimStart(); - foreach (var t in tokens) - { - if (span.StartsWith(t, StringComparison.OrdinalIgnoreCase)) - { - var idx = span.IndexOf(':'); - if (idx == t.Length) return true; - } - } - } - return false; - } - - public override string SerializeSettings(object obj) - { - if (obj is AppSettings yamlModel) - { - var builder = new SerializerBuilder() - .WithNamingConvention(CamelCaseNamingConvention.Instance) - .WithEmissionPhaseObjectGraphVisitor(args => new DescriptionCommentsObjectGraphVisitor(args.InnerVisitor)) - .DisableAliases() - .IgnoreFields() - .WithTypeConverter(new BindKeysConverter()); - var converter = builder.Build(); - var content = converter.Serialize(yamlModel); - return content; - } - - return base.SerializeSettings(obj); - } - - private static AppSettings ToYaml(LegacyAppSettings s) - { - return new AppSettings - { - Input = new AppSettingsInput - { - UseRawInput = s.UseRawInput, - OsuKeys = s.Keys - }, - Paths = new AppSettingsPaths - { - OsuFolderPath = s.OsuFolder, - SelectedSkinName = s.SelectedSkin - }, - Audio = new AppSettingsAudio - { - SampleRate = s.SampleRate, - PlaybackDevice = s.Device, - MasterVolume = (int)s.Volume, - MusicVolume = s.RealtimeOptions.MusicTrackVolume, - EffectVolume = s.RealtimeOptions.EffectTrackVolume - }, - Logging = new AppSettingsLogging - { - EnableDebugConsole = s.Debugging, - EnableErrorReporting = s.SendLogsToDeveloperConfirmed ? s.SendLogsToDeveloper : null, - PlayerBase64 = s.PlayerBase64 - }, - Performance = new AppSettingsPerformance - { - }, - Sync = new AppSettingsSync - { - EnableSync = s.RealtimeOptions.RealtimeMode, - EnableMixSync = s.RealtimeOptions.EnableMusicFunctions, - Scanning = new AppSettingsSyncScanning - { - GeneralScanInterval = s.RealtimeOptions.GeneralScanInterval, - TimingScanInterval = s.RealtimeOptions.TimingScanInterval - }, - Playback = new AppSettingsSyncPlayback - { - TailPlaybackBehavior = s.RealtimeOptions.SliderTailPlaybackBehavior, - NightcoreBeats = s.RealtimeOptions.ForceNightcoreBeats, - LimiterType = s.EnableLimiter ? LimiterType.Master : LimiterType.Off, - BalanceFactor = s.RealtimeOptions.BalanceFactor, - }, - Filters = new AppSettingsSyncFilters - { - DisableBeatmapHitsounds = s.RealtimeOptions.IgnoreBeatmapHitsound, - DisableStoryboardSamples = s.RealtimeOptions.IgnoreStoryboardSamples, - DisableSliderTicksAndSlides = s.RealtimeOptions.IgnoreSliderTicksAndSlides, - DisableComboBreakSfx = s.RealtimeOptions.IgnoreComboBreak, - IgnoreLineVolumes = s.RealtimeOptions.IgnoreLineVolumes - } - } - }; - } - - private static LegacyAppSettings FromYaml(AppSettings y) - { - var s = new LegacyAppSettings(); - if (y.Input != null) - { - s.UseRawInput = y.Input.UseRawInput; - if (y.Input.OsuKeys != null) s.Keys = y.Input.OsuKeys.ToList(); - } - if (y.Paths != null) - { - s.OsuFolder = y.Paths.OsuFolderPath; - s.SelectedSkin = y.Paths.SelectedSkinName ?? s.SelectedSkin; - } - if (y.Audio != null) - { - s.SampleRate = y.Audio.SampleRate; - s.Device = y.Audio.PlaybackDevice; - s.Volume = y.Audio.MasterVolume; - s.RealtimeOptions.MusicTrackVolume = y.Audio.MusicVolume; - s.RealtimeOptions.EffectTrackVolume = y.Audio.EffectVolume; - } - if (y.Logging != null) - { - s.Debugging = y.Logging.EnableDebugConsole; - s.SendLogsToDeveloper = y.Logging.EnableErrorReporting ?? true; - s.SendLogsToDeveloperConfirmed = y.Logging.EnableErrorReporting.HasValue; - s.PlayerBase64 = y.Logging.PlayerBase64 ?? ""; - } - if (y.Performance != null) - { - } - if (y.Sync != null) - { - s.RealtimeOptions.RealtimeMode = y.Sync.EnableSync; - if (y.Sync.Scanning != null) - { - s.RealtimeOptions.GeneralScanInterval = y.Sync.Scanning.GeneralScanInterval; - s.RealtimeOptions.TimingScanInterval = y.Sync.Scanning.TimingScanInterval; - } - if (y.Sync.Playback != null) - { - s.RealtimeOptions.SliderTailPlaybackBehavior = y.Sync.Playback.TailPlaybackBehavior; - s.RealtimeOptions.ForceNightcoreBeats = y.Sync.Playback.NightcoreBeats; - s.EnableLimiter = y.Sync.Playback.LimiterType != LimiterType.Off; - s.RealtimeOptions.BalanceFactor = y.Sync.Playback.BalanceFactor; - s.RealtimeOptions.EnableMusicFunctions = y.Sync.EnableMixSync; - } - if (y.Sync.Filters != null) - { - s.RealtimeOptions.IgnoreBeatmapHitsound = y.Sync.Filters.DisableBeatmapHitsounds; - s.RealtimeOptions.IgnoreStoryboardSamples = y.Sync.Filters.DisableStoryboardSamples; - s.RealtimeOptions.IgnoreSliderTicksAndSlides = y.Sync.Filters.DisableSliderTicksAndSlides; - s.RealtimeOptions.IgnoreComboBreak = y.Sync.Filters.DisableComboBreakSfx; - s.RealtimeOptions.IgnoreLineVolumes = y.Sync.Filters.IgnoreLineVolumes; - } - } - return s; - } -} \ No newline at end of file diff --git a/src/Common/KeyAsio.Shared/Events/EventDelegates.cs b/src/Common/KeyAsio.Shared/Events/EventDelegates.cs deleted file mode 100644 index 4a883456..00000000 --- a/src/Common/KeyAsio.Shared/Events/EventDelegates.cs +++ /dev/null @@ -1,29 +0,0 @@ -namespace KeyAsio.Shared.Events; - -/// -/// Represents a method that handles a signal event (no data). -/// -public delegate void SignalEventHandler(); - -/// -/// Represents an asynchronous method that handles a signal event (no data). -/// -/// A task that represents the asynchronous operation. -public delegate Task SignalAsyncEventHandler(); - -/// -/// Represents a method that handles a value change event. -/// -/// The type of the value. -/// The old value. -/// The new value. -public delegate void ValueChangedEventHandler(T oldValue, T newValue); - -/// -/// Represents an asynchronous method that handles a value change event. -/// -/// The type of the value. -/// The old value. -/// The new value. -/// A task that represents the asynchronous operation. -public delegate Task ValueChangedAsyncEventHandler(T oldValue, T newValue); \ No newline at end of file diff --git a/src/Common/KeyAsio.Shared/FodyWeavers.xml b/src/Common/KeyAsio.Shared/FodyWeavers.xml deleted file mode 100644 index d5abfed8..00000000 --- a/src/Common/KeyAsio.Shared/FodyWeavers.xml +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file diff --git a/src/Common/KeyAsio.Shared/FodyWeavers.xsd b/src/Common/KeyAsio.Shared/FodyWeavers.xsd deleted file mode 100644 index 69dbe488..00000000 --- a/src/Common/KeyAsio.Shared/FodyWeavers.xsd +++ /dev/null @@ -1,74 +0,0 @@ - - - - - - - - - - - Used to control if the On_PropertyName_Changed feature is enabled. - - - - - Used to control if the Dependent properties feature is enabled. - - - - - Used to control if the IsChanged property feature is enabled. - - - - - Used to change the name of the method that fires the notify event. This is a string that accepts multiple values in a comma separated form. - - - - - Used to control if equality checks should be inserted. If false, equality checking will be disabled for the project. - - - - - Used to control if equality checks should use the Equals method resolved from the base class. - - - - - Used to control if equality checks should use the static Equals method resolved from the base class. - - - - - Used to turn off build warnings from this weaver. - - - - - Used to turn off build warnings about mismatched On_PropertyName_Changed methods. - - - - - - - - 'true' to run assembly verification (PEVerify) on the target assembly after all weavers have been executed. - - - - - A comma-separated list of error codes that can be safely ignored in assembly verification. - - - - - 'false' to turn off automatic generation of the XML Schema file. - - - - - \ No newline at end of file diff --git a/src/Common/KeyAsio.Shared/KeyAsio.Shared.csproj b/src/Common/KeyAsio.Shared/KeyAsio.Shared.csproj deleted file mode 100644 index 99262842..00000000 --- a/src/Common/KeyAsio.Shared/KeyAsio.Shared.csproj +++ /dev/null @@ -1,56 +0,0 @@ - - - - net10.0 - enable - enable - AnyCPU;x64 - true - true - - - - - - - - - - - all - compile; runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - all - compile; runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - - - - - - - - - - - - - - - osu_memory_rules.json - PreserveNewest - - - - diff --git a/src/Common/KeyAsio.Shared/LegacyAppSettings.cs b/src/Common/KeyAsio.Shared/LegacyAppSettings.cs deleted file mode 100644 index c63d8436..00000000 --- a/src/Common/KeyAsio.Shared/LegacyAppSettings.cs +++ /dev/null @@ -1,94 +0,0 @@ -using System.ComponentModel; -using KeyAsio.Core.Audio; -using KeyAsio.Shared.Models; -using Milki.Extensions.Configuration; -using Milki.Extensions.MouseKeyHook; - -namespace KeyAsio.Shared; - -public sealed class LegacyAppSettings : ViewModelBase -{ - private List _keys = [HookKeys.Z, HookKeys.X]; - - private RealtimeOptions? _realtimeOptions; - private int _volume = 100; - private bool _sendLogsToDeveloper = true; - private string? _osuFolder = ""; - private bool _debugging = false; - - public bool UseRawInput { get; set; } = true; - - [Description("Triggering keys. See https://docs.microsoft.com/en-us/dotnet/api/system.windows.forms.keys?view=windowsdesktop-6.0 for more inforamtion.")] - public List Keys - { - get => _keys; - set => SetField(ref _keys, value); - } - - [Description("Default hitsound path (relative or absolute) for playing.")] - public string HitsoundPath { get; set; } = "click.wav"; - - [Description("Osu's folder. For the most of time this value is auto detected.")] - public string? OsuFolder - { - get => _osuFolder; - set => SetField(ref _osuFolder, value); - } - - [Description("The skin when `RealtimeMode` is true.")] - public string SelectedSkin { get; set; } = ""; - - [Description("If true, the software will create a console window to show logs.")] - public bool Debugging - { - get => _debugging; - set => SetField(ref _debugging, value); - } - - [Description("Device's sample rate (allow adjusting in GUI).")] - public int SampleRate { get; set; } = 48000; - //public int Bits { get; set; } = 16; - //public int Channels { get; set; } = 2; - - [Description("Device configuration (Recommend to configure in GUI).")] - public DeviceDescription? Device { get; set; } - - [Description("Enable limiter to prevent clipping or distortion; disable for an unprocessed signal. Especially effective when master volume is high.")] - public bool EnableLimiter { get; set; } = true; - - [Description("Configured device volume, range: 0~150")] - public float Volume - { - get => _volume; - set - { - if (value > 150) value = 150; - else if (value < 0) value = 0; - else if (value < 1) value *= 100; // Convert from old version - _volume = (int)Math.Round(value); - } - } - - [Description("Set whether the software can report logs/bugs to developer.")] - public bool SendLogsToDeveloper - { - get => _sendLogsToDeveloper; - set => SetField(ref _sendLogsToDeveloper, value); - } - - public bool SendLogsToDeveloperConfirmed { get; set; } - - public RealtimeOptions RealtimeOptions - { - get => _realtimeOptions ??= new(); - set => _realtimeOptions = value; - } - - public string PlayerBase64 { get; set; } = ""; - public int AudioCachingThreads { get; set; } = 2; - - public void Save() - { - ConfigurationFactory.Save(this); - } -} \ No newline at end of file diff --git a/src/Common/KeyAsio.Shared/Localization/ILanguagePreferenceStore.cs b/src/Common/KeyAsio.Shared/Localization/ILanguagePreferenceStore.cs deleted file mode 100644 index 2dd499a1..00000000 --- a/src/Common/KeyAsio.Shared/Localization/ILanguagePreferenceStore.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace KeyAsio.Shared.Localization; - -public interface ILanguagePreferenceStore -{ - string? GetLanguageCode(); - - void SetLanguageCode(string languageCode); -} diff --git a/src/Common/KeyAsio.Shared/Localization/LanguageItem.cs b/src/Common/KeyAsio.Shared/Localization/LanguageItem.cs deleted file mode 100644 index 039bcadd..00000000 --- a/src/Common/KeyAsio.Shared/Localization/LanguageItem.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace KeyAsio.Shared.Localization; - -public sealed class LanguageItem -{ - public required string Code { get; init; } - public required string Name { get; init; } - - public override string ToString() => Name; -} diff --git a/src/Common/KeyAsio.Shared/Localization/LanguageManager.cs b/src/Common/KeyAsio.Shared/Localization/LanguageManager.cs deleted file mode 100644 index ac723261..00000000 --- a/src/Common/KeyAsio.Shared/Localization/LanguageManager.cs +++ /dev/null @@ -1,114 +0,0 @@ -using System.Collections.ObjectModel; -using System.Globalization; -using CommunityToolkit.Mvvm.ComponentModel; - -namespace KeyAsio.Shared.Localization; - -public partial class LanguageManager : ObservableObject -{ - private const string SystemLanguageCode = "system"; - private readonly ILanguagePreferenceStore _languagePreferenceStore; - private bool _isUpdating; - - public LanguageManager(ILanguagePreferenceStore languagePreferenceStore) - { - _languagePreferenceStore = languagePreferenceStore; - InitializeLanguages(); - } - - public ObservableCollection AvailableLanguages { get; } = []; - - [ObservableProperty] - public partial LanguageItem? SelectedLanguageItem { get; set; } - - [ObservableProperty] - public partial bool IsChinese { get; set; } - - partial void OnSelectedLanguageItemChanged(LanguageItem? value) - { - if (value is null || _isUpdating) - { - return; - } - - _languagePreferenceStore.SetLanguageCode(value.Code); - ApplyLanguage(value.Code); - RefreshAvailableLanguages(value.Code); - } - - private void InitializeLanguages() - { - var persistedCode = _languagePreferenceStore.GetLanguageCode(); - var savedCode = string.IsNullOrWhiteSpace(persistedCode) - ? SystemLanguageCode - : persistedCode; - - ApplyLanguage(savedCode); - - _isUpdating = true; - try - { - PopulateLanguages(); - SelectedLanguageItem = AvailableLanguages.FirstOrDefault(x => - string.Equals(x.Code, savedCode, StringComparison.OrdinalIgnoreCase)) - ?? AvailableLanguages[0]; - } - finally - { - _isUpdating = false; - } - } - - private void RefreshAvailableLanguages(string selectedCode) - { - _isUpdating = true; - try - { - // Deselect before clearing to avoid SelectionModel accessing a stale index. - SelectedLanguageItem = null; - AvailableLanguages.Clear(); - PopulateLanguages(); - SelectedLanguageItem = - AvailableLanguages - .FirstOrDefault(x => string.Equals(x.Code, selectedCode, StringComparison.OrdinalIgnoreCase)) - ?? AvailableLanguages[0]; - } - finally - { - _isUpdating = false; - } - } - - private void PopulateLanguages() - { - AvailableLanguages.Add(new LanguageItem - { - Name = LocalizationService.Instance["Language_SystemDefault"], - Code = SystemLanguageCode - }); - AvailableLanguages.Add(new LanguageItem - { - Name = CultureInfo.GetCultureInfo("zh-CN").NativeName, - Code = "zh-CN" - }); - AvailableLanguages.Add(new LanguageItem - { - Name = CultureInfo.GetCultureInfo("en").NativeName, - Code = "en" - }); - } - - private void ApplyLanguage(string languageCode) - { - var culture = ResolveCulture(languageCode); - LocalizationService.Instance.ApplyCulture(culture); - IsChinese = culture.Name.StartsWith("zh", StringComparison.OrdinalIgnoreCase); - } - - private static CultureInfo ResolveCulture(string languageCode) - { - return string.Equals(languageCode, SystemLanguageCode, StringComparison.OrdinalIgnoreCase) - ? CultureInfo.InstalledUICulture - : new CultureInfo(languageCode); - } -} diff --git a/src/Common/KeyAsio.Shared/Localization/LocalizationService.cs b/src/Common/KeyAsio.Shared/Localization/LocalizationService.cs deleted file mode 100644 index d6425fb9..00000000 --- a/src/Common/KeyAsio.Shared/Localization/LocalizationService.cs +++ /dev/null @@ -1,57 +0,0 @@ -using System.ComponentModel; -using System.Globalization; - -namespace KeyAsio.Shared.Localization; - -public sealed class LocalizationService : INotifyPropertyChanged -{ - private Action _cultureApplier = static _ => { }; - private Func _stringResolver = static key => key; - private long _version; - - public static LocalizationService Instance { get; } = new(); - - public string this[string key] - { - get - { - var resolver = _stringResolver; - return resolver(key); - } - } - - /// - /// Monotonically increasing version counter; incremented on every language change. - /// Used by bindings to trigger re-evaluation. - /// - public long Version => _version; - - public event PropertyChangedEventHandler? PropertyChanged; - - public void ConfigureStringResolver(Func resolver) - { - _stringResolver = resolver ?? throw new ArgumentNullException(nameof(resolver)); - } - - public void ConfigureCultureApplier(Action cultureApplier) - { - _cultureApplier = cultureApplier ?? throw new ArgumentNullException(nameof(cultureApplier)); - } - - public void ApplyCulture(CultureInfo culture) - { - CultureInfo.CurrentCulture = culture; - CultureInfo.CurrentUICulture = culture; - CultureInfo.DefaultThreadCurrentCulture = culture; - CultureInfo.DefaultThreadCurrentUICulture = culture; - _cultureApplier(culture); - NotifyLanguageChanged(); - } - - public void NotifyLanguageChanged() - { - Interlocked.Increment(ref _version); - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Version))); - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs("Item[]")); - } -} diff --git a/src/Common/KeyAsio.Shared/LogUtils.cs b/src/Common/KeyAsio.Shared/LogUtils.cs deleted file mode 100644 index de91bf38..00000000 --- a/src/Common/KeyAsio.Shared/LogUtils.cs +++ /dev/null @@ -1,60 +0,0 @@ -using System.Runtime.CompilerServices; -using KeyAsio.Shared.Configuration; -using Microsoft.Extensions.Logging; -using Milki.Extensions.Configuration; - -namespace KeyAsio.Shared; - -public static class LogUtils -{ - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static void LogToSentry(LogLevel logLevel, string content, Exception? exception = null, - Action? configureScope = null) - { - var settings = ConfigurationFactory.GetConfiguration( - ".", "appsettings.yaml", MyYamlConfigurationConverter.Instance); - if (settings.Logging?.EnableErrorReporting != true) return; - if (exception != null) - { - SentrySdk.CaptureException(exception, k => - { - k.SetTag("message", content); - ConfigureScope(k); - }); - } - else - { - var sentryLevel = logLevel switch - { - LogLevel.Trace => SentryLevel.Debug, - LogLevel.Debug => SentryLevel.Debug, - LogLevel.Information => SentryLevel.Info, - LogLevel.Warning => SentryLevel.Warning, - LogLevel.Error => SentryLevel.Error, - LogLevel.Critical => SentryLevel.Fatal, - LogLevel.None => SentryLevel.Info, - _ => throw new ArgumentOutOfRangeException(nameof(logLevel), logLevel, null) - }; - if (exception != null) - { - sentryLevel = SentryLevel.Error; - content += $"\r\n{exception}"; - } - - SentrySdk.CaptureMessage(content, ConfigureScope, sentryLevel); - } - - void ConfigureScope(Scope scope) - { - //scope.SetTag("osu.filename_real", RealtimeModeManager.Instance.OsuFile?.ToString() ?? ""); - //scope.SetTag("osu.status", RealtimeModeManager.Instance.OsuStatus.ToString()); - //var username = RealtimeModeManager.Instance.Username; - //scope.SetTag("osu.username", string.IsNullOrEmpty(username) - // ? EncodeUtils.FromBase64StringEmptyIfError(settings.PlayerBase64, Encoding.ASCII) - // : username); - - // todo: BREAK: realtime dep - configureScope?.Invoke(scope); - } - } -} \ No newline at end of file diff --git a/src/Common/KeyAsio.Shared/Models/AppTheme.cs b/src/Common/KeyAsio.Shared/Models/AppTheme.cs deleted file mode 100644 index e987c401..00000000 --- a/src/Common/KeyAsio.Shared/Models/AppTheme.cs +++ /dev/null @@ -1,13 +0,0 @@ -using System.ComponentModel; - -namespace KeyAsio.Shared.Models; - -public enum AppTheme -{ - [Description("Settings_FollowSystem")] - System, - [Description("Settings_Theme_Light")] - Light, - [Description("Settings_Theme_Dark")] - Dark -} \ No newline at end of file diff --git a/src/Common/KeyAsio.Shared/Models/BindKeys.cs b/src/Common/KeyAsio.Shared/Models/BindKeys.cs deleted file mode 100644 index 13132f7e..00000000 --- a/src/Common/KeyAsio.Shared/Models/BindKeys.cs +++ /dev/null @@ -1,85 +0,0 @@ -using System.Text; -using Milki.Extensions.MouseKeyHook; - -namespace KeyAsio.Shared.Models; - -public class BindKeys -{ - private BindKeys(HookModifierKeys modifierKeys, HookKeys? keys) - { - ModifierKeys = modifierKeys; - Keys = keys; - } - - public HookModifierKeys ModifierKeys { get; } - public HookKeys? Keys { get; } - - public override string ToString() - { - var sb = new StringBuilder(); - if (ModifierKeys.HasFlag(HookModifierKeys.Control)) - { - sb.Append("Ctrl"); - } - - if (ModifierKeys.HasFlag(HookModifierKeys.Shift)) - { - if (sb.Length > 0) - { - sb.Append('+'); - } - - sb.Append("Shift"); - } - - if (ModifierKeys.HasFlag(HookModifierKeys.Alt)) - { - if (sb.Length > 0) - { - sb.Append('+'); - } - - sb.Append("Alt"); - } - - if (Keys != null) - { - if (sb.Length > 0) - { - sb.Append('+'); - } - - sb.Append(Keys.ToString()); - } - - return sb.ToString(); - } - - public static BindKeys Parse(string str) - { - var modifierKeys = HookModifierKeys.None; - HookKeys? keys = null; - var split = str.Split('+', StringSplitOptions.RemoveEmptyEntries); - foreach (var s in split) - { - if (s.Equals("Ctrl", StringComparison.OrdinalIgnoreCase)) - { - modifierKeys |= HookModifierKeys.Control; - } - else if (s.Equals("Shift", StringComparison.OrdinalIgnoreCase)) - { - modifierKeys |= HookModifierKeys.Shift; - } - else if (s.Equals("Alt", StringComparison.OrdinalIgnoreCase)) - { - modifierKeys |= HookModifierKeys.Alt; - } - else if (keys == null) - { - keys = Enum.Parse(s); - } - } - - return new BindKeys(modifierKeys, keys); - } -} \ No newline at end of file diff --git a/src/Common/KeyAsio.Shared/Models/PlaybackInfo.cs b/src/Common/KeyAsio.Shared/Models/PlaybackInfo.cs deleted file mode 100644 index 5742514a..00000000 --- a/src/Common/KeyAsio.Shared/Models/PlaybackInfo.cs +++ /dev/null @@ -1,6 +0,0 @@ -using KeyAsio.Core.Audio.Caching; -using KeyAsio.Core.OsuAudio.Hitsounds.Playback; - -namespace KeyAsio.Shared.Models; - -public readonly record struct PlaybackInfo(CachedAudio? CachedAudio, PlaybackEvent PlaybackEvent); diff --git a/src/Common/KeyAsio.Shared/Models/RealtimeOptions.cs b/src/Common/KeyAsio.Shared/Models/RealtimeOptions.cs deleted file mode 100644 index 8e804c76..00000000 --- a/src/Common/KeyAsio.Shared/Models/RealtimeOptions.cs +++ /dev/null @@ -1,141 +0,0 @@ -using System.ComponentModel; -using YamlDotNet.Serialization; - -namespace KeyAsio.Shared.Models; - -public class RealtimeOptions : ViewModelBase -{ - private bool _realtimeMode = true; - private int _realtimeModeAudioOffset; - private bool _ignoreBeatmapHitsound; - private bool _ignoreStoryboardSamples; - private bool _ignoreSliderTicksAndSlides; - private SliderTailPlaybackBehavior _sliderTailPlaybackBehavior; - private float _balanceFactor = 0.3f; - private bool _ignoreComboBreak; - private bool _ignoreLineVolumes; - private bool _enableMusicFunctions; - private int _musicTrackVolume = 100; - private int _effectTrackVolume = 100; - private bool _forceNightcoreBeats; - private int _generalScanInterval = 50; - private int _timingScanInterval = 15; - - [Description("If the set value is lower, the generic fields will be updated more promptly.\r\n" + - "This property is targeted at delay-insensitive fields and can be appropriately increased to reduce CPU usage.")] - public int GeneralScanInterval - { - get => _generalScanInterval; - set => SetField(ref _generalScanInterval, value); - } - - [Description("If the set value is lower, the timing fields will be updated more promptly.\r\n" + - "This property is targeted at delay-sensitive field and best kept as low as possible.\r\n" + - "If you experience audio cutting issues, please increase the value appropriately.")] - public int TimingScanInterval - { - get => _timingScanInterval; - set => SetField(ref _timingScanInterval, value); - } - - [Description("If enabled, the software will perform memory scanning and play the right hitsounds of beatmaps.")] - public bool RealtimeMode - { - get => _realtimeMode; - set => SetField(ref _realtimeMode, value); - } - - [Description("[EXPERIMENTAL] If enabled, the software will enable music related functions.")] - public bool EnableMusicFunctions - { - get => _enableMusicFunctions; - set => SetField(ref _enableMusicFunctions, value); - } - - [YamlIgnore] - [Description("The offset when `RealtimeMode` is true (allow adjusting in GUI).")] - public int RealtimeModeAudioOffset - { - get => _realtimeModeAudioOffset; - set => SetField(ref _realtimeModeAudioOffset, value); - } - - [Description("Ignore beatmap's hitsound and force using user skin instead.")] - public bool IgnoreBeatmapHitsound - { - get => _ignoreBeatmapHitsound; - set => SetField(ref _ignoreBeatmapHitsound, value); - } - - [YamlIgnore] - public BindKeys? IgnoreBeatmapHitsoundBindKey { get; set; } - - [Description("Ignore beatmap's storyboard samples.")] - public bool IgnoreStoryboardSamples - { - get => _ignoreStoryboardSamples; - set => SetField(ref _ignoreStoryboardSamples, value); - } - - [YamlIgnore] - public BindKeys? IgnoreStoryboardSamplesBindKey { get; set; } - - [Description("Ignore slider's ticks and slides.")] - public bool IgnoreSliderTicksAndSlides - { - get => _ignoreSliderTicksAndSlides; - set => SetField(ref _ignoreSliderTicksAndSlides, value); - } - - [YamlIgnore] - public BindKeys? IgnoreSliderTicksAndSlidesBindKey { get; set; } - - [Description("Slider tail's playback behavior. Normal: Force to play slider tail's sounds; KeepReverse: Play only if a slider with multiple reverses; Ignore: Ignore slider tail's sounds.")] - public SliderTailPlaybackBehavior SliderTailPlaybackBehavior - { - get => _sliderTailPlaybackBehavior; - set => SetField(ref _sliderTailPlaybackBehavior, value); - } - - [Description("Balance factor.")] - public float BalanceFactor - { - get => _balanceFactor; - set => SetField(ref _balanceFactor, value); - } - - [Description("Ignore combo break sound.")] - public bool IgnoreComboBreak - { - get => _ignoreComboBreak; - set => SetField(ref _ignoreComboBreak, value); - } - - [Description("Ignore combo break sound.")] - public bool IgnoreLineVolumes - { - get => _ignoreLineVolumes; - set => SetField(ref _ignoreLineVolumes, value); - } - - [Description("Music track volume.")] - public int MusicTrackVolume - { - get => _musicTrackVolume; - set => SetField(ref _musicTrackVolume, value); - } - - [Description("Effect track volume.")] - public int EffectTrackVolume - { - get => _effectTrackVolume; - set => SetField(ref _effectTrackVolume, value); - } - - [Description("Force to use nightcore beats.")] - public bool ForceNightcoreBeats - { - get => _forceNightcoreBeats; - set => SetField(ref _forceNightcoreBeats, value); - } -} \ No newline at end of file diff --git a/src/Common/KeyAsio.Shared/Models/SharedViewModel.cs b/src/Common/KeyAsio.Shared/Models/SharedViewModel.cs deleted file mode 100644 index c04d9788..00000000 --- a/src/Common/KeyAsio.Shared/Models/SharedViewModel.cs +++ /dev/null @@ -1,46 +0,0 @@ -using KeyAsio.Core.Audio; -using KeyAsio.Shared.Utils; - -namespace KeyAsio.Shared.Models; - -public class SharedViewModel : ViewModelBase -{ - private DeviceDescription? _deviceDescription; - private int _framesPerBuffer; - private int _playbackLatency; - private SkinDescription? _selectedSkin; - - public SharedViewModel(AppSettings appSettings) - { - AppSettings = appSettings; - } - public ObservableRangeCollection Skins { get; } = [SkinDescription.Internal]; - - public SkinDescription? SelectedSkin - { - get => _selectedSkin; - set => SetField(ref _selectedSkin, value); - } - - public DeviceDescription? DeviceDescription - { - get => _deviceDescription; - set => SetField(ref _deviceDescription, value); - } - - public int FramesPerBuffer - { - get => _framesPerBuffer; - set => SetField(ref _framesPerBuffer, value); - } - - public int PlaybackLatency - { - get => _playbackLatency; - set => SetField(ref _playbackLatency, value); - } - public bool AutoMode { get; set; } - - public string DefaultFolder { get; } = Path.Combine(Environment.CurrentDirectory, "resources", "default"); - public AppSettings AppSettings { get; } -} \ No newline at end of file diff --git a/src/Common/KeyAsio.Shared/Models/SkinDescription.cs b/src/Common/KeyAsio.Shared/Models/SkinDescription.cs deleted file mode 100644 index 7a1a1631..00000000 --- a/src/Common/KeyAsio.Shared/Models/SkinDescription.cs +++ /dev/null @@ -1,44 +0,0 @@ -namespace KeyAsio.Shared.Models; - -public record SkinDescription(string FolderName, string Folder, string? Name, string? Author) -{ - public string Description - { - get - { - if (FolderName == "{classic}") return "classic"; - if (FolderName == "{internal}") return "ProMix™ Snare"; - - if (Name == null) return FolderName; - if (FolderName == Name) return FolderName; - - return $"{FolderName} ({Name})"; - } - } - - public string? CopyRight - { - get - { - if (FolderName == "{classic}") - { - return "Original copyright © ppy Pty Ltd."; - } - - if (FolderName == "{internal}") - { - return "Copyright © KeyASIO Team"; - } - - if (Author == null) - { - return "Unknown author"; - } - - return $"Skin made by {Author}"; - } - } - - public static SkinDescription Internal { get; } = new("{internal}", "{internal}", null, null); - public static SkinDescription Classic { get; } = new("{classic}", "{classic}", null, null); -} \ No newline at end of file diff --git a/src/Common/KeyAsio.Shared/Models/SliderTailPlaybackBehavior.cs b/src/Common/KeyAsio.Shared/Models/SliderTailPlaybackBehavior.cs deleted file mode 100644 index 8e8fb44f..00000000 --- a/src/Common/KeyAsio.Shared/Models/SliderTailPlaybackBehavior.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace KeyAsio.Shared.Models; - -public enum SliderTailPlaybackBehavior -{ - Normal, KeepReverse, Ignore -} \ No newline at end of file diff --git a/src/Common/KeyAsio.Shared/Models/ViewModelBase.cs b/src/Common/KeyAsio.Shared/Models/ViewModelBase.cs deleted file mode 100644 index 753b2be1..00000000 --- a/src/Common/KeyAsio.Shared/Models/ViewModelBase.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System.ComponentModel; -using System.Runtime.CompilerServices; - -namespace KeyAsio.Shared.Models; - -public abstract class ViewModelBase : INotifyPropertyChanged -{ - public event PropertyChangedEventHandler? PropertyChanged; - - protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null) - { - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); - } - - protected bool SetField(ref T field, T value, [CallerMemberName] string? propertyName = null) - { - if (EqualityComparer.Default.Equals(field, value)) return false; - field = value; - OnPropertyChanged(propertyName); - return true; - } -} \ No newline at end of file diff --git a/src/Common/KeyAsio.Shared/OsuMemory/BeatmapIdentifier.cs b/src/Common/KeyAsio.Shared/OsuMemory/BeatmapIdentifier.cs deleted file mode 100644 index e3c15166..00000000 --- a/src/Common/KeyAsio.Shared/OsuMemory/BeatmapIdentifier.cs +++ /dev/null @@ -1,30 +0,0 @@ -namespace KeyAsio.Shared.OsuMemory; - -public readonly record struct BeatmapIdentifier -{ - public BeatmapIdentifier(string? filenameFull) - { - FilenameFull = filenameFull; - if (filenameFull != null) - { - Folder = Path.GetDirectoryName(filenameFull); - Filename = Path.GetFileName(filenameFull); - } - } - - public BeatmapIdentifier(string? folder, string? filename) - { - Folder = folder; - Filename = filename; - if (folder != null && filename != null) - { - FilenameFull = Path.Combine(folder, filename); - } - } - - public string? FilenameFull { get; } - public string? Folder { get; } - public string? Filename { get; } - - public static BeatmapIdentifier Default = new(); -} \ No newline at end of file diff --git a/src/Common/KeyAsio.Shared/OsuMemory/GameSyncSnapshot.cs b/src/Common/KeyAsio.Shared/OsuMemory/GameSyncSnapshot.cs deleted file mode 100644 index 49517006..00000000 --- a/src/Common/KeyAsio.Shared/OsuMemory/GameSyncSnapshot.cs +++ /dev/null @@ -1,82 +0,0 @@ -using KeyAsio.Core.OsuAudio.Hitsounds; -using KeyAsio.Plugins.Abstractions; -using KeyAsio.Plugins.Abstractions.OsuMemory; -using KeyAsio.Shared.Sync; - -namespace KeyAsio.Shared.OsuMemory; - -/// -/// Mutable game sync snapshot. A single instance is reused per sync source to -/// avoid per-frame allocations on the high-frequency memory-scan / IPC paths. -/// Consumers must read fields synchronously inside -/// or and must not retain the -/// reference across invocations. -/// -public sealed class GameSyncSnapshot -{ - public GameClientType ClientType { get; set; } = GameClientType.Stable; - public int ProcessId { get; set; } - public string? Username { get; set; } - public Mods PlayMods { get; set; } - public bool IsReplay { get; set; } - public int Score { get; set; } - public int Combo { get; set; } - public SyncStatistics Statistics { get; set; } = SyncStatistics.Empty; - public SyncHitErrors HitErrors { get; set; } = SyncHitErrors.Empty; - public BeatmapIdentifier Beatmap { get; set; } - public IBeatmapResourceCatalog? BeatmapResourceCatalog { get; set; } - public int PlayTime { get; set; } - public OsuMemoryStatus Status { get; set; } = OsuMemoryStatus.Unknown; - - public static GameSyncSnapshot NotRunning(GameClientType clientType) => new() - { - ClientType = clientType, - Status = OsuMemoryStatus.NotRunning, - Statistics = SyncStatistics.Empty, - HitErrors = SyncHitErrors.Empty - }; - - public GameSyncSnapshot Clone() => new() - { - ClientType = ClientType, - ProcessId = ProcessId, - Username = Username, - PlayMods = PlayMods, - IsReplay = IsReplay, - Score = Score, - Combo = Combo, - Statistics = Statistics, - HitErrors = CloneHitErrors(HitErrors), - Beatmap = Beatmap, - BeatmapResourceCatalog = BeatmapResourceCatalog, - PlayTime = PlayTime, - Status = Status - }; - - /// - /// Resets this instance to a disconnected state in place, avoiding a new - /// allocation on the low-frequency disconnect path. - /// - public void ResetToNotRunning(GameClientType clientType) - { - ClientType = clientType; - ProcessId = 0; - Username = null; - PlayMods = default; - IsReplay = false; - Score = 0; - Combo = 0; - Statistics = SyncStatistics.Empty; - HitErrors = SyncHitErrors.Empty; - Beatmap = default; - BeatmapResourceCatalog = null; - PlayTime = 0; - Status = OsuMemoryStatus.NotRunning; - } - - private static SyncHitErrors CloneHitErrors(SyncHitErrors hitErrors) - { - var values = hitErrors.Values; - return new SyncHitErrors(hitErrors.Index, values.Length == 0 ? [] : (int[])values.Clone()); - } -} diff --git a/src/Common/KeyAsio.Shared/OsuMemory/GameSyncSourceCoordinator.cs b/src/Common/KeyAsio.Shared/OsuMemory/GameSyncSourceCoordinator.cs deleted file mode 100644 index 81a87c13..00000000 --- a/src/Common/KeyAsio.Shared/OsuMemory/GameSyncSourceCoordinator.cs +++ /dev/null @@ -1,160 +0,0 @@ -using KeyAsio.Plugins.Abstractions.OsuMemory; -using KeyAsio.Shared.Sync; -using Microsoft.Extensions.Logging; - -namespace KeyAsio.Shared.OsuMemory; - -public sealed class GameSyncSourceCoordinator -{ - private readonly SyncSessionContext _syncSessionContext; - private readonly IGameSyncSource[] _sources; - private readonly ILogger _logger; - private IGameSyncSource? _activeSource; - private bool _started; - private GameSyncSnapshot? _disconnectedSnapshot; - private GameClientType _lastClientType = GameClientType.Stable; - - public event Action? ClientTypeChanged; - - public GameSyncSourceCoordinator( - SyncSessionContext syncSessionContext, - IEnumerable sources, - ILogger logger) - { - _syncSessionContext = syncSessionContext; - _sources = sources.OrderByDescending(source => source.Priority).ToArray(); - _logger = logger; - - foreach (var source in _sources) - { - source.AvailabilityChanged += OnSourceAvailabilityChanged; - source.SnapshotReceived += OnSourceSnapshotReceived; - } - } - - public void Start() - { - if (_started) return; - _started = true; - - foreach (var source in _sources) - { - source.Start(); - } - - SelectActiveSource(applyCurrentSnapshot: true); - } - - public async Task StopAsync() - { - if (!_started) return; - _started = false; - - foreach (var source in _sources) - { - await source.StopAsync(); - } - - _activeSource = null; - ApplyDisconnectedSnapshot(GameClientType.Stable); - } - - private void OnSourceAvailabilityChanged(IGameSyncSource source, bool isAvailable) - { - if (!_started) return; - - if (!isAvailable && ReferenceEquals(_activeSource, source)) - { - _logger.LogInformation("Game sync source unavailable: {Source}", source.Name); - } - else if (isAvailable) - { - _logger.LogInformation("Game sync source available: {Source}", source.Name); - } - - SelectActiveSource(applyCurrentSnapshot: true); - } - - private void OnSourceSnapshotReceived(IGameSyncSource source, GameSyncSnapshot snapshot) - { - if (!_started) return; - - SelectActiveSource(applyCurrentSnapshot: false); - if (!ReferenceEquals(_activeSource, source)) - { - return; - } - - ApplySnapshot(snapshot); - } - - private void SelectActiveSource(bool applyCurrentSnapshot) - { - var nextSource = _sources.FirstOrDefault(source => source.IsAvailable); - if (ReferenceEquals(_activeSource, nextSource)) - { - return; - } - - var oldSource = _activeSource; - _activeSource = nextSource; - - _logger.LogInformation("Game sync source switched: {OldSource} -> {NewSource}", - oldSource?.Name ?? "none", nextSource?.Name ?? "none"); - - if (nextSource != null) - { - if (applyCurrentSnapshot) - { - ApplySnapshot(nextSource.CurrentSnapshot); - } - } - else - { - ApplyDisconnectedSnapshot(oldSource?.ClientType ?? GameClientType.Stable); - } - } - - private void ApplySnapshot(GameSyncSnapshot snapshot) - { - try - { - _syncSessionContext.ClientType = snapshot.ClientType; - if (snapshot.ClientType != _lastClientType) - { - _lastClientType = snapshot.ClientType; - ClientTypeChanged?.Invoke(snapshot.ClientType); - } - _syncSessionContext.ProcessId = snapshot.ProcessId; - _syncSessionContext.Username = snapshot.Username; - _syncSessionContext.PlayMods = snapshot.PlayMods; - _syncSessionContext.IsReplay = snapshot.IsReplay; - _syncSessionContext.Score = snapshot.Score; - _syncSessionContext.Statistics = snapshot.Statistics; - _syncSessionContext.HitErrors = snapshot.HitErrors; - _syncSessionContext.BeatmapResourceCatalog = snapshot.BeatmapResourceCatalog; - _syncSessionContext.Beatmap = snapshot.Beatmap; - _syncSessionContext.BaseMemoryTime = snapshot.PlayTime; - _syncSessionContext.OsuStatus = snapshot.Status; - _syncSessionContext.Combo = snapshot.Combo; - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to apply game sync snapshot from {ClientType}.", snapshot.ClientType); - } - } - - private void ApplyDisconnectedSnapshot(GameClientType disconnectedClientType) - { - var fallbackClientType = disconnectedClientType == GameClientType.Lazer - ? GameClientType.Stable - : disconnectedClientType; - - // Reuse a single disconnected snapshot to avoid allocating on the - // low-frequency disconnect path. ApplySnapshot only reads the fields - // synchronously, so mutating a shared instance is safe. - _disconnectedSnapshot ??= GameSyncSnapshot.NotRunning(fallbackClientType); - _disconnectedSnapshot.ResetToNotRunning(fallbackClientType); - ApplySnapshot(_disconnectedSnapshot); - } -} diff --git a/src/Common/KeyAsio.Shared/OsuMemory/IGameSyncSource.cs b/src/Common/KeyAsio.Shared/OsuMemory/IGameSyncSource.cs deleted file mode 100644 index dd02c7ee..00000000 --- a/src/Common/KeyAsio.Shared/OsuMemory/IGameSyncSource.cs +++ /dev/null @@ -1,18 +0,0 @@ -using KeyAsio.Shared.Sync; - -namespace KeyAsio.Shared.OsuMemory; - -public interface IGameSyncSource -{ - string Name { get; } - GameClientType ClientType { get; } - int Priority { get; } - bool IsAvailable { get; } - GameSyncSnapshot CurrentSnapshot { get; } - - event Action? AvailabilityChanged; - event Action? SnapshotReceived; - - void Start(); - Task StopAsync(); -} diff --git a/src/Common/KeyAsio.Shared/OsuMemory/LazerIpcBridge.cs b/src/Common/KeyAsio.Shared/OsuMemory/LazerIpcBridge.cs deleted file mode 100644 index c0d91603..00000000 --- a/src/Common/KeyAsio.Shared/OsuMemory/LazerIpcBridge.cs +++ /dev/null @@ -1,259 +0,0 @@ -using System.Buffers; -using System.Buffers.Binary; -using System.Collections.Concurrent; -using System.IO.Pipes; -using KeyAsio.LazerProtocol; -using Microsoft.Extensions.Logging; - -namespace KeyAsio.Shared.OsuMemory; - -public sealed class LazerIpcBridge : IDisposable -{ - public const string PipeName = LazerProtocolConstants.TimingPipeName; - public const string EventPipeName = LazerProtocolConstants.EventPipeName; - public const int ProtocolVersion = LazerProtocolConstants.ProtocolVersion; - // Event frames may include lazer skin and file metadata; large skin libraries can exceed several MB. - private const int MaxFrameLength = 64 * 1024 * 1024; - - private readonly ILogger _logger; - private readonly ConcurrentDictionary _clientTasks = new(); - private CancellationTokenSource? _cts; - private Task[]? _acceptLoopTasks; - private int _clientCount; - private int _timingClientCount; - private int _eventClientCount; - - public LazerIpcBridge(ILogger logger) - { - _logger = logger; - } - - public bool IsTimingConnected => Volatile.Read(ref _timingClientCount) > 0; - public bool IsEventsConnected => Volatile.Read(ref _eventClientCount) > 0; - public bool IsAnyChannelConnected => IsTimingConnected || IsEventsConnected; - - public event Action? ChannelConnectionChanged; - public event Action? FrameReceived; - - public void Start() - { - if (_acceptLoopTasks != null) return; - - _cts = new CancellationTokenSource(); - _acceptLoopTasks = - [ - Task.Run(() => AcceptLoopAsync(LazerIpcChannel.Timing, PipeName, _cts.Token)), - Task.Run(() => AcceptLoopAsync(LazerIpcChannel.Events, EventPipeName, _cts.Token)), - ]; - } - - public async Task StopAsync() - { - if (_cts == null) return; - - await _cts.CancelAsync(); - - if (_acceptLoopTasks != null) - { - try - { - await Task.WhenAll(_acceptLoopTasks); - } - catch (OperationCanceledException) - { - // Expected during shutdown. - } - } - - var clientTasks = _clientTasks.Keys.ToArray(); - if (clientTasks.Length > 0) - { - await Task.WhenAll(clientTasks); - } - - _cts.Dispose(); - _cts = null; - _acceptLoopTasks = null; - } - - private async Task AcceptLoopAsync(LazerIpcChannel channel, string pipeName, CancellationToken token) - { - while (!token.IsCancellationRequested) - { - var server = new NamedPipeServerStream(pipeName, PipeDirection.In, - NamedPipeServerStream.MaxAllowedServerInstances, PipeTransmissionMode.Byte, - PipeOptions.Asynchronous); - - try - { - await server.WaitForConnectionAsync(token); - TrackClientTask(Task.Run(() => HandleClientAsync(server, channel, pipeName, token), - CancellationToken.None)); - } - catch (OperationCanceledException) - { - await server.DisposeAsync(); - break; - } - catch (Exception ex) - { - await server.DisposeAsync(); - _logger.LogWarning(ex, "Failed to accept lazer IPC client on pipe {PipeName}.", pipeName); - - try - { - await Task.Delay(1000, token); - } - catch (OperationCanceledException) - { - break; - } - } - } - } - - private void TrackClientTask(Task task) - { - _clientTasks.TryAdd(task, 0); - _ = task.ContinueWith(static (completedTask, state) => - { - var tasks = (ConcurrentDictionary)state!; - tasks.TryRemove(completedTask, out _); - }, _clientTasks, CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); - } - - private async Task HandleClientAsync(NamedPipeServerStream server, LazerIpcChannel channel, string pipeName, - CancellationToken token) - { - var oldChannelCount = IncrementChannelClientCount(channel) - 1; - if (oldChannelCount == 0) - { - ChannelConnectionChanged?.Invoke(channel, false, true); - } - - var oldCount = Interlocked.Increment(ref _clientCount) - 1; - if (oldCount == 0) - { - _logger.LogInformation("osu!lazer IPC bridge connected."); - } - - _logger.LogDebug("osu!lazer IPC pipe connected: {PipeName}.", pipeName); - - await using (server) - { - var lengthBuffer = new byte[sizeof(int)]; - try - { - while (!token.IsCancellationRequested && server.IsConnected) - { - LazerDeltaFrame? frame; - try - { - frame = await ReadFrameAsync(server, lengthBuffer, token); - } - catch (InvalidDataException ex) - { - _logger.LogDebug(ex, "Ignoring malformed lazer IPC frame."); - continue; - } - - if (frame == null) break; - - if (frame.Version != ProtocolVersion) - { - _logger.LogDebug("Ignoring unsupported lazer IPC protocol version {Version}.", frame.Version); - continue; - } - - FrameReceived?.Invoke(channel, frame); - } - } - catch (OperationCanceledException) - { - // Expected during shutdown. - } - catch (IOException ex) - { - _logger.LogDebug(ex, "osu!lazer IPC pipe disconnected: {PipeName}.", pipeName); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Unexpected lazer IPC bridge error on pipe {PipeName}.", pipeName); - } - } - - var newChannelCount = DecrementChannelClientCount(channel); - if (newChannelCount == 0) - { - ChannelConnectionChanged?.Invoke(channel, true, false); - } - - var newCount = Interlocked.Decrement(ref _clientCount); - _logger.LogDebug("osu!lazer IPC pipe disconnected: {PipeName}.", pipeName); - if (newCount == 0) - { - _logger.LogInformation("osu!lazer IPC bridge disconnected."); - } - } - - private int IncrementChannelClientCount(LazerIpcChannel channel) - { - return channel switch - { - LazerIpcChannel.Timing => Interlocked.Increment(ref _timingClientCount), - LazerIpcChannel.Events => Interlocked.Increment(ref _eventClientCount), - _ => throw new ArgumentOutOfRangeException(nameof(channel), channel, null) - }; - } - - private int DecrementChannelClientCount(LazerIpcChannel channel) - { - return channel switch - { - LazerIpcChannel.Timing => Interlocked.Decrement(ref _timingClientCount), - LazerIpcChannel.Events => Interlocked.Decrement(ref _eventClientCount), - _ => throw new ArgumentOutOfRangeException(nameof(channel), channel, null) - }; - } - - private async ValueTask ReadFrameAsync(Stream stream, byte[] lengthBuffer, - CancellationToken token) - { - try - { - await stream.ReadExactlyAsync(lengthBuffer, token); - } - catch (EndOfStreamException) - { - return null; - } - - var length = BinaryPrimitives.ReadInt32LittleEndian(lengthBuffer); - if (length <= 0 || length > MaxFrameLength) - { - throw new IOException($"Invalid lazer IPC frame length: {length}."); - } - - var buffer = ArrayPool.Shared.Rent(length); - try - { - await stream.ReadExactlyAsync(buffer.AsMemory(0, length), token); - return LazerDeltaFrame.Parse(buffer.AsSpan(0, length)); - } - finally - { - ArrayPool.Shared.Return(buffer); - } - } - - public void Dispose() - { - StopAsync().GetAwaiter().GetResult(); - } -} - -public enum LazerIpcChannel -{ - Timing, - Events, -} diff --git a/src/Common/KeyAsio.Shared/OsuMemory/LazerIpcFrame.cs b/src/Common/KeyAsio.Shared/OsuMemory/LazerIpcFrame.cs deleted file mode 100644 index 90021ec4..00000000 --- a/src/Common/KeyAsio.Shared/OsuMemory/LazerIpcFrame.cs +++ /dev/null @@ -1,137 +0,0 @@ -using KeyAsio.LazerProtocol; -using KeyAsio.Plugins.Abstractions; - -namespace KeyAsio.Shared.OsuMemory; - -public sealed class LazerIpcFrame -{ - public int Version { get; private set; } - public int ProcessId { get; private set; } - public int Status { get; private set; } - public int PlayTime { get; private set; } - public uint Mods { get; private set; } - public int Combo { get; private set; } - public int Score { get; private set; } - public bool IsReplay { get; private set; } - public string? Username { get; private set; } - public string? BeatmapFolder { get; private set; } - public string? BeatmapFilename { get; private set; } - public LazerFile[] BeatmapFiles { get; private set; } = []; - public SyncStatistics Statistics { get; private set; } - public int HitErrorIndex { get; private set; } - public int[] HitErrors { get; private set; } = []; - public LazerSkinInfo[]? SkinInfos { get; private set; } - public string? UserDataDirectory { get; private set; } - public string? ExeDirectory { get; private set; } - - public void Reset() - { - Version = 0; - ProcessId = 0; - Status = 0; - PlayTime = 0; - Mods = 0; - Combo = 0; - Score = 0; - IsReplay = false; - Username = null; - BeatmapFolder = null; - BeatmapFilename = null; - BeatmapFiles = []; - Statistics = SyncStatistics.Empty; - HitErrorIndex = 0; - HitErrors = []; - SkinInfos = null; - UserDataDirectory = null; - ExeDirectory = null; - } - - public void ClearBeatmapFiles() - { - BeatmapFiles = []; - } - - public bool HasLazerSkinInfos => SkinInfos != null; - - public void Apply(LazerDeltaFrame deltaFrame) - { - Version = deltaFrame.Version; - - foreach (var field in deltaFrame.Fields) - { - switch (field.Kind) - { - case LazerFieldKind.ProcessId: - ProcessId = field.IntValue; - break; - - case LazerFieldKind.Status: - Status = field.IntValue; - break; - - case LazerFieldKind.PlayTime: - PlayTime = field.IntValue; - break; - - case LazerFieldKind.Mods: - Mods = field.UIntValue; - break; - - case LazerFieldKind.Combo: - Combo = field.IntValue; - break; - - case LazerFieldKind.Score: - Score = field.IntValue; - break; - - case LazerFieldKind.IsReplay: - IsReplay = field.BoolValue; - break; - - case LazerFieldKind.Username: - Username = field.StringValue; - break; - - case LazerFieldKind.BeatmapFolder: - BeatmapFolder = field.StringValue; - break; - - case LazerFieldKind.BeatmapFilename: - BeatmapFilename = field.StringValue; - break; - - case LazerFieldKind.BeatmapFiles: - BeatmapFiles = field.FilesValue ?? []; - break; - - case LazerFieldKind.Statistics: - Statistics = field.StatisticsValue.ToSyncStatistics(); - break; - - case LazerFieldKind.HitErrors: - HitErrorIndex = field.IntValue; - HitErrors = field.IntArrayValue ?? []; - break; - - case LazerFieldKind.SkinInfos: - SkinInfos = field.SkinInfosValue; - break; - - case LazerFieldKind.UserDataDirectory: - UserDataDirectory = field.StringValue; - break; - - case LazerFieldKind.ExeDirectory: - ExeDirectory = field.StringValue; - break; - } - } - } -} - -internal static class LazerStatisticsExtensions -{ - public static SyncStatistics ToSyncStatistics(this LazerStatistics statistics) - => new(statistics.Perfect, statistics.Great, statistics.Good, statistics.Ok, statistics.Meh, statistics.Miss); -} \ No newline at end of file diff --git a/src/Common/KeyAsio.Shared/OsuMemory/LazerIpcGameSyncSource.cs b/src/Common/KeyAsio.Shared/OsuMemory/LazerIpcGameSyncSource.cs deleted file mode 100644 index 22a18323..00000000 --- a/src/Common/KeyAsio.Shared/OsuMemory/LazerIpcGameSyncSource.cs +++ /dev/null @@ -1,250 +0,0 @@ -using KeyAsio.Core.OsuAudio.Hitsounds; -using KeyAsio.LazerProtocol; -using KeyAsio.Plugins.Abstractions; -using KeyAsio.Plugins.Abstractions.OsuMemory; -using KeyAsio.Shared.Sync; - -namespace KeyAsio.Shared.OsuMemory; - -public sealed class LazerIpcGameSyncSource : IGameSyncSource -{ - private readonly LazerIpcBridge _lazerIpcBridge; - private readonly GameSyncSnapshot _snapshot; - private readonly LazerIpcFrame _frame = new(); - private readonly object frameLock = new(); - private bool _eventsBound; - private bool _connected; - private bool _hasTimingFrame; - private bool _hasEventFrame; - private IBeatmapResourceCatalog? _resourceCatalog; - private LazerSkinInfo[]? _lastPublishedSkinInfos; - private string? _lastPublishedUserDataDirectory; - private string? _lastPublishedExeDirectory; - - public event Action? LazerSkinContextReceived; - - public LazerIpcGameSyncSource(LazerIpcBridge lazerIpcBridge) - { - _lazerIpcBridge = lazerIpcBridge; - _snapshot = GameSyncSnapshot.NotRunning(ClientType); - CurrentSnapshot = _snapshot; - } - - public string Name => "osu!lazer IPC"; - public GameClientType ClientType => GameClientType.Lazer; - public int Priority => 100; - public bool IsAvailable => _connected; - public GameSyncSnapshot CurrentSnapshot { get; private set; } - - public event Action? AvailabilityChanged; - public event Action? SnapshotReceived; - - public void Start() - { - BindEvents(); - _lazerIpcBridge.Start(); - } - - public async Task StopAsync() - { - await _lazerIpcBridge.StopAsync(); - - bool availabilityChanged; - lock (frameLock) - { - ResetFrameStateLocked(); - availabilityChanged = SetAvailabilityLocked(false); - } - - if (availabilityChanged) - AvailabilityChanged?.Invoke(this, false); - } - - private void BindEvents() - { - if (_eventsBound) return; - - _lazerIpcBridge.ChannelConnectionChanged += OnChannelConnectionChanged; - _lazerIpcBridge.FrameReceived += OnFrameReceived; - _eventsBound = true; - } - - private void OnChannelConnectionChanged(LazerIpcChannel channel, bool oldValue, bool newValue) - { - bool availabilityChanged; - bool isAvailable; - - lock (frameLock) - { - if (!newValue) - { - ResetFrameStateLocked(); - } - - isAvailable = CanBeAvailableLocked(); - availabilityChanged = SetAvailabilityLocked(isAvailable); - } - - if (availabilityChanged) - AvailabilityChanged?.Invoke(this, isAvailable); - } - - private void OnFrameReceived(LazerIpcChannel channel, LazerDeltaFrame deltaFrame) - { - bool availabilityChanged; - bool isAvailable; - LazerSkinInfo[]? skinInfosToPublish = null; - string? userDataDirectoryToPublish = null; - string? exeDirectoryToPublish = null; - bool skinContextChanged = false; - - lock (frameLock) - { - switch (channel) - { - case LazerIpcChannel.Timing: - _hasTimingFrame = true; - break; - - case LazerIpcChannel.Events: - _hasEventFrame = true; - break; - - default: - throw new ArgumentOutOfRangeException(nameof(channel), channel, null); - } - - ApplyFrameLocked(deltaFrame); - isAvailable = CanBeAvailableLocked(); - availabilityChanged = SetAvailabilityLocked(isAvailable); - - // Detect changes to lazer skin context. - if (!ReferenceEquals(_frame.SkinInfos, _lastPublishedSkinInfos)) - { - _lastPublishedSkinInfos = _frame.SkinInfos; - skinInfosToPublish = _frame.SkinInfos; - skinContextChanged = true; - } - - if (_frame.UserDataDirectory != _lastPublishedUserDataDirectory) - { - _lastPublishedUserDataDirectory = _frame.UserDataDirectory; - userDataDirectoryToPublish = _frame.UserDataDirectory; - skinContextChanged = true; - } - - if (_frame.ExeDirectory != _lastPublishedExeDirectory) - { - _lastPublishedExeDirectory = _frame.ExeDirectory; - exeDirectoryToPublish = _frame.ExeDirectory; - skinContextChanged = true; - } - } - - if (availabilityChanged) - AvailabilityChanged?.Invoke(this, isAvailable); - - if (isAvailable) - SnapshotReceived?.Invoke(this, _snapshot); - - if (skinContextChanged) - { - LazerSkinContextReceived?.Invoke( - skinInfosToPublish, - userDataDirectoryToPublish, - exeDirectoryToPublish); - } - } - - private void ApplyFrameLocked(LazerDeltaFrame deltaFrame) - { - var beatmapChanged = deltaFrame.HasField(LazerFieldKind.BeatmapFolder) || - deltaFrame.HasField(LazerFieldKind.BeatmapFilename); - var beatmapFilesChanged = deltaFrame.HasField(LazerFieldKind.BeatmapFiles); - - if (beatmapChanged && !beatmapFilesChanged) - { - _resourceCatalog = null; - _frame.ClearBeatmapFiles(); - } - - _frame.Apply(deltaFrame); - var frame = _frame; - - var status = Enum.IsDefined(typeof(OsuMemoryStatus), frame.Status) - ? (OsuMemoryStatus)frame.Status - : OsuMemoryStatus.Unknown; - - if (beatmapFilesChanged && frame.BeatmapFiles.Length > 0) - { - var resourceCatalog = BeatmapResourceCatalog.FromMappings( - frame.BeatmapFiles.Select(file => new BeatmapResource(file.Name, file.Path)), - frame.BeatmapFolder, - CreateCatalogCacheKey(frame)); - - if (!resourceCatalog.IsEmpty) - { - _resourceCatalog = resourceCatalog; - } - } - - var beatmap = !string.IsNullOrWhiteSpace(frame.BeatmapFolder) && - !string.IsNullOrWhiteSpace(frame.BeatmapFilename) - ? new BeatmapIdentifier(frame.BeatmapFolder, frame.BeatmapFilename) - : default; - - var snapshot = _snapshot; - snapshot.ProcessId = frame.ProcessId; - snapshot.Username = frame.Username; - snapshot.PlayMods = (Mods)frame.Mods; - snapshot.IsReplay = frame.IsReplay; - snapshot.Score = frame.Score; - snapshot.Combo = frame.Combo; - snapshot.Statistics = frame.Statistics; - snapshot.HitErrors = new SyncHitErrors(frame.HitErrorIndex, frame.HitErrors); - snapshot.Beatmap = beatmap; - snapshot.BeatmapResourceCatalog = _resourceCatalog; - snapshot.PlayTime = frame.PlayTime; - snapshot.Status = status; - } - - private void ResetFrameStateLocked() - { - _hasTimingFrame = false; - _hasEventFrame = false; - _resourceCatalog = null; - _frame.Reset(); - _snapshot.ResetToNotRunning(ClientType); - CurrentSnapshot = _snapshot; - - _lastPublishedSkinInfos = null; - _lastPublishedUserDataDirectory = null; - _lastPublishedExeDirectory = null; - } - - private bool CanBeAvailableLocked() - => _lazerIpcBridge.IsTimingConnected && - _lazerIpcBridge.IsEventsConnected && - _hasTimingFrame && - _hasEventFrame; - - private bool SetAvailabilityLocked(bool isAvailable) - { - if (_connected == isAvailable) return false; - - _connected = isAvailable; - return true; - } - - private static string CreateCatalogCacheKey(LazerIpcFrame frame) - { - var beatmapFilename = frame.BeatmapFilename; - var beatmapPath = string.IsNullOrWhiteSpace(beatmapFilename) - ? null - : frame.BeatmapFiles.FirstOrDefault(file => - string.Equals(BeatmapResourceCatalog.NormalizeName(file.Name), - BeatmapResourceCatalog.NormalizeName(beatmapFilename), StringComparison.OrdinalIgnoreCase))?.Path; - - return $"lazer:{frame.BeatmapFolder}:{beatmapFilename}:{beatmapPath}:{frame.BeatmapFiles.Length}"; - } -} diff --git a/src/Common/KeyAsio.Shared/OsuMemory/MemoryReadObject.cs b/src/Common/KeyAsio.Shared/OsuMemory/MemoryReadObject.cs deleted file mode 100644 index 5eb7767d..00000000 --- a/src/Common/KeyAsio.Shared/OsuMemory/MemoryReadObject.cs +++ /dev/null @@ -1,187 +0,0 @@ -using System.Runtime.CompilerServices; -using KeyAsio.Plugins.Abstractions; -using KeyAsio.Plugins.Abstractions.OsuMemory; -using KeyAsio.Shared.Events; - -namespace KeyAsio.Shared.OsuMemory; - -public class MemoryReadObject -{ - public event ValueChangedEventHandler? PlayerNameChanged; - public event ValueChangedEventHandler? ComboChanged; - public event ValueChangedEventHandler? ScoreChanged; - public event ValueChangedEventHandler? IsReplayChanged; - public event ValueChangedEventHandler? OsuStatusChanged; - public event ValueChangedEventHandler? PlayingTimeChanged; - public event ValueChangedEventHandler? ModsChanged; - public event ValueChangedEventHandler? ProcessIdChanged; - public event ValueChangedEventHandler? BeatmapIdentifierChanged; - public event ValueChangedEventHandler? StatisticsChanged; - public event ValueChangedEventHandler? HitErrorsChanged; - - public string? PlayerName - { - [MethodImpl(MethodImplOptions.AggressiveInlining)] - get; - [MethodImpl(MethodImplOptions.AggressiveInlining)] - set - { - if (field == value) return; - var old = field; - field = value; - PlayerNameChanged?.Invoke(old, value); - } - } // BanchoUser.UserName - - public int Combo - { - [MethodImpl(MethodImplOptions.AggressiveInlining)] - get; - [MethodImpl(MethodImplOptions.AggressiveInlining)] - set - { - if (field == value) return; - var old = field; - field = value; - ComboChanged?.Invoke(old, value); - } - } // Player.(RulesetPlayData.Combo) - - public int Score - { - [MethodImpl(MethodImplOptions.AggressiveInlining)] - get; - [MethodImpl(MethodImplOptions.AggressiveInlining)] - set - { - if (field == value) return; - var old = field; - field = value; - ScoreChanged?.Invoke(old, value); - } - } // Player.(RulesetPlayData.Score) - - public bool IsReplay - { - [MethodImpl(MethodImplOptions.AggressiveInlining)] - get; - [MethodImpl(MethodImplOptions.AggressiveInlining)] - set - { - if (field == value) return; - var old = field; - field = value; - IsReplayChanged?.Invoke(old, value); - } - } // Player.IsReplay - - public OsuMemoryStatus OsuStatus - { - [MethodImpl(MethodImplOptions.AggressiveInlining)] - get; - [MethodImpl(MethodImplOptions.AggressiveInlining)] - set - { - if (field == value) return; - var old = field; - field = value; - OsuStatusChanged?.Invoke(old, value); - } - } // GeneralData.OsuStatus - - public int PlayingTime - { - [MethodImpl(MethodImplOptions.AggressiveInlining)] - get; - [MethodImpl(MethodImplOptions.AggressiveInlining)] - set - { - if (field == value) return; - var old = field; - field = value; - PlayingTimeChanged?.Invoke(old, value); - } - } // GeneralData.AudioTime - - public Mods Mods - { - [MethodImpl(MethodImplOptions.AggressiveInlining)] - get; - [MethodImpl(MethodImplOptions.AggressiveInlining)] - set - { - if (field == value) return; - var old = field; - field = value; - ModsChanged?.Invoke(old, value); - } - } // GeneralData.Mods - - public int ProcessId - { - [MethodImpl(MethodImplOptions.AggressiveInlining)] - get; - [MethodImpl(MethodImplOptions.AggressiveInlining)] - set - { - if (field == value) return; - var old = field; - field = value; - ProcessIdChanged?.Invoke(old, value); - } - } - - public SyncStatistics Statistics - { - [MethodImpl(MethodImplOptions.AggressiveInlining)] - get; - [MethodImpl(MethodImplOptions.AggressiveInlining)] - set - { - if (field == value) return; - var old = field; - field = value; - StatisticsChanged?.Invoke(old, value); - } - } = SyncStatistics.Empty; - - public SyncHitErrors HitErrors - { - [MethodImpl(MethodImplOptions.AggressiveInlining)] - get; - [MethodImpl(MethodImplOptions.AggressiveInlining)] - set - { - var oldValues = field.Values ?? []; - var newValues = value.Values ?? []; - if (field.Index == value.Index && oldValues.AsSpan().SequenceEqual(newValues)) - { - return; - } - - var old = field; - field = value with - { - Values = newValues - }; - HitErrorsChanged?.Invoke(old, field); - } - } = SyncHitErrors.Empty; - - //public string? BeatmapFolder { get; set; } // CurrentBeatmap.FolderName - //public string? BeatmapFileName { get; set; } // CurrentBeatmap.OsuFileName - - public BeatmapIdentifier BeatmapIdentifier - { - [MethodImpl(MethodImplOptions.AggressiveInlining)] - get; - [MethodImpl(MethodImplOptions.AggressiveInlining)] - set - { - if (EqualityComparer.Default.Equals(field, value)) return; - var old = field; - field = value; - BeatmapIdentifierChanged?.Invoke(old, value); - } - } -} diff --git a/src/Common/KeyAsio.Shared/OsuMemory/MemoryScan.cs b/src/Common/KeyAsio.Shared/OsuMemory/MemoryScan.cs deleted file mode 100644 index 7b4b13cb..00000000 --- a/src/Common/KeyAsio.Shared/OsuMemory/MemoryScan.cs +++ /dev/null @@ -1,612 +0,0 @@ -using System.Diagnostics; -using System.Runtime.CompilerServices; -using Coosu.Shared.IO; -using KeyAsio.Core.Memory; -using KeyAsio.Core.Memory.Configuration; -using KeyAsio.Core.Memory.Utils; -using KeyAsio.Plugins.Abstractions; -using KeyAsio.Plugins.Abstractions.OsuMemory; -using Microsoft.Extensions.Logging; - -namespace KeyAsio.Shared.OsuMemory; - -public class MemoryScan -{ - private readonly ILogger _logger; - - private int _generalInterval; - private int _timingInterval; - - private Process? _process; - private volatile bool _processExited; - private SigScan? _sigScan; - private MemoryProfile? _memoryProfile; - private MemoryContext? _memoryContext; - - private string? _songsDirectory; - private bool _scanSuccessful; - private readonly OsuMemoryData _osuMemoryData = new(); - - private Task? _readTask; - private CancellationTokenSource? _cts; - private bool _isStarted; - private readonly ManualResetEventSlim _intervalUpdatedEvent = new(false); - private readonly ManualResetEventSlim _scanResumeEvent = new(true); - private volatile bool _scanSuppressed; - private ValueDefinition? _valueDefinition; - private ValueDefinition? _scoreProcessorValueDefinition; - private ValueDefinition? _scoreV2ValueDefinition; - private ValueDefinition? _comboValueDefinition; - private ValueDefinition? _hit100ValueDefinition; - private ValueDefinition? _hit300ValueDefinition; - private ValueDefinition? _hit50ValueDefinition; - private ValueDefinition? _hitGekiValueDefinition; - private ValueDefinition? _hitKatuValueDefinition; - private ValueDefinition? _hitMissValueDefinition; - - private string? _folderName; - - public MemoryScan(ILogger logger) - { - _logger = logger; - } - - public MemoryReadObject MemoryReadObject - { - [MethodImpl(MethodImplOptions.AggressiveInlining)] - get; - } = new(); - - public void Start(int generalInterval, int timingInterval) - { - if (_isStarted) return; - _isStarted = true; - _generalInterval = generalInterval; - _timingInterval = timingInterval; - if (_scanSuppressed) - _scanResumeEvent.Reset(); - else - _scanResumeEvent.Set(); - - try - { - // Load from the output directory or adjacent to the assembly - var assemblyPath = Path.GetDirectoryName(typeof(MemoryScan).Assembly.Location) ?? string.Empty; - var rulesPath = Path.Combine(assemblyPath, "osu_memory_rules.json"); - _memoryProfile = MemoryProfile.Load(rulesPath); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to load memory rules"); - throw; - } - - _cts = new CancellationTokenSource(); - // WARN: Single threaded reading to avoid thread pool switching - _readTask = Task.Factory.StartNew(ReadImpl, - TaskCreationOptions.LongRunning | TaskCreationOptions.DenyChildAttach); - } - - public async Task StopAsync() - { - if (!_isStarted) return; - await _cts!.CancelAsync(); - _scanResumeEvent.Set(); - - if (_readTask != null) - await _readTask; - - CleanupProcess(MemoryReadObject); - _cts.Dispose(); - _intervalUpdatedEvent.Reset(); - - _isStarted = false; - } - - public void UpdateIntervals(int generalInterval, int timingInterval) - { - _generalInterval = generalInterval; - _timingInterval = timingInterval; - _intervalUpdatedEvent.Set(); - } - - public void SetScanSuppressed(bool suppressed) - { - if (_scanSuppressed == suppressed) return; - - _scanSuppressed = suppressed; - if (suppressed) - { - _scanResumeEvent.Reset(); - _logger.LogInformation("osu!stable memory scan suppressed."); - } - else - { - _scanResumeEvent.Set(); - _logger.LogInformation("osu!stable memory scan resumed."); - } - } - - public void ReloadRules() - { - try - { - var assemblyPath = Path.GetDirectoryName(typeof(MemoryScan).Assembly.Location) ?? string.Empty; - var rulesPath = Path.Combine(assemblyPath, "osu_memory_rules.json"); - _memoryProfile = MemoryProfile.Load(rulesPath); - - // Force reconnection to rebuild MemoryContext with new profile - CleanupProcess(MemoryReadObject); - - _logger.LogInformation("Memory rules reloaded."); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to reload memory rules"); - } - } - - private void ReadImpl() - { - var memoryReadObject = MemoryReadObject; - using var timerScope = new HighPrecisionTimerScope(); - var nextGeneralScan = 0L; - var nextTimingScan = 0L; - var stopwatch = Stopwatch.StartNew(); - - while (!_cts!.IsCancellationRequested) - { - if (_scanSuppressed) - { - WaitWhileScanSuppressed(memoryReadObject); - nextGeneralScan = stopwatch.ElapsedMilliseconds; - nextTimingScan = nextGeneralScan; - continue; - } - - if (!EnsureConnected(memoryReadObject)) - { - Thread.Sleep(500); - continue; - } - - if (_scanSuppressed) - { - WaitWhileScanSuppressed(memoryReadObject); - nextGeneralScan = stopwatch.ElapsedMilliseconds; - nextTimingScan = nextGeneralScan; - continue; - } - - if (!EnsureScanned()) - { - Thread.Sleep(100); - continue; - } - - EnsureSongsDirectory(); - - long now = stopwatch.ElapsedMilliseconds; - bool didWork = false; - - if (now >= nextTimingScan) - { - ReadTiming(memoryReadObject); - nextTimingScan = now + _timingInterval; - didWork = true; - } - - if (now >= nextGeneralScan) - { - ReadGeneralData(memoryReadObject); - nextGeneralScan = now + _generalInterval; - didWork = true; - } - - if (!didWork) - { - Thread.Sleep(1); - } - } - } - - private void WaitWhileScanSuppressed(MemoryReadObject memoryReadObject) - { - CleanupProcess(memoryReadObject, delayOnDisconnect: false); - - while (_scanSuppressed && _cts?.IsCancellationRequested == false) - { - try - { - _scanResumeEvent.Wait(_cts.Token); - } - catch (OperationCanceledException) - { - break; - } - } - } - - private bool EnsureConnected(MemoryReadObject memoryReadObject) - { - if (_scanSuppressed) return false; - - if (_process != null && !_processExited) - return true; - - CleanupProcess(memoryReadObject); - - try - { - var processes = Process.GetProcessesByName("osu!"); - if (processes.Length > 0) - { - _process = processes[0]; - - try - { - var uptime = DateTime.Now - _process.StartTime; - if (uptime.TotalSeconds < 6) - { - _logger.LogInformation( - "osu! process detected early (uptime {Uptime:F1}s). Delaying connection for 10s...", - uptime.TotalSeconds); - for (var i = 0; i < 60; i++) - { - if (_cts?.IsCancellationRequested == true) return false; - if (_scanSuppressed) return false; - Thread.Sleep(100); - } - } - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to check process uptime"); - } - - _process.EnableRaisingEvents = true; - _process.Exited += OnProcessExited; - _processExited = false; - - if (_process.HasExited) - { - CleanupProcess(memoryReadObject); - return false; - } - - _sigScan = new SigScan(_process); - _memoryContext = new MemoryContext(_sigScan, _memoryProfile!); - if (!_memoryContext.TryGetProfile("AudioTime", out _valueDefinition)) - { - _logger.LogWarning("Memory profile is missing required 'AudioTime' definition"); - } - - _memoryContext.TryGetProfile("ScoreProcessor", out _scoreProcessorValueDefinition); - _memoryContext.TryGetProfile("ScoreV2", out _scoreV2ValueDefinition); - _memoryContext.TryGetProfile("Combo", out _comboValueDefinition); - _memoryContext.TryGetProfile("Hit100", out _hit100ValueDefinition); - _memoryContext.TryGetProfile("Hit300", out _hit300ValueDefinition); - _memoryContext.TryGetProfile("Hit50", out _hit50ValueDefinition); - _memoryContext.TryGetProfile("HitGeki", out _hitGekiValueDefinition); - _memoryContext.TryGetProfile("HitKatu", out _hitKatuValueDefinition); - _memoryContext.TryGetProfile("HitMiss", out _hitMissValueDefinition); - - _logger.LogInformation("Connected to osu! process"); - MemoryReadObject.ProcessId = _process.Id; - return true; - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Error finding osu! process"); - } - - return false; - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private void OnProcessExited(object? sender, EventArgs e) - { - _processExited = true; - } - - private void CleanupProcess(MemoryReadObject memoryReadObject, bool delayOnDisconnect = true) - { - var exiting = _process != null; - if (_process != null) - { - _process.Exited -= OnProcessExited; - } - - _process?.Dispose(); - _sigScan?.Dispose(); - - _process = null; - _sigScan = null; - _memoryContext = null; - _songsDirectory = null; - _scanSuccessful = false; - _scoreProcessorValueDefinition = null; - _scoreV2ValueDefinition = null; - _comboValueDefinition = null; - _hit100ValueDefinition = null; - _hit300ValueDefinition = null; - _hit50ValueDefinition = null; - _hitGekiValueDefinition = null; - _hitKatuValueDefinition = null; - _hitMissValueDefinition = null; - - _folderName = null; - memoryReadObject.OsuStatus = OsuMemoryStatus.NotRunning; - memoryReadObject.PlayingTime = 0; - memoryReadObject.ProcessId = 0; - memoryReadObject.BeatmapIdentifier = default; - memoryReadObject.Statistics = SyncStatistics.Empty; - memoryReadObject.HitErrors = SyncHitErrors.Empty; - if (exiting) - { - _logger.LogInformation("Disconnected from osu! process"); - if (delayOnDisconnect) - { - Thread.Sleep(2000); - } - } - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private bool EnsureScanned() - { - if (_scanSuccessful) return true; - if (_scanSuppressed) return false; - if (_memoryContext == null) return false; - - _memoryContext.Scan(); - _memoryContext.BeginUpdate(); - - if (_memoryContext.TryGetValueDef(_valueDefinition, out _)) - { - _scanSuccessful = true; - EnsureSongsDirectory(); - return true; - } - - _sigScan?.Reload(); - return false; - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private void EnsureSongsDirectory() - { - if (_songsDirectory != null) return; - - try - { - var mainModuleFileName = _process?.MainModule?.FileName; - if (string.IsNullOrEmpty(mainModuleFileName)) return; - - var baseDirectory = Path.GetDirectoryName(mainModuleFileName); - if (baseDirectory == null) return; - - var beatmapDirectory = "Songs"; - var configPath = Path.Combine(baseDirectory, $"osu!.{Environment.UserName}.cfg"); - - if (File.Exists(configPath)) - { - try - { - using var sr = new StreamReader(configPath); - using var lineReader = new EphemeralLineReader(sr); - while (lineReader.ReadLine() is { } memory) - { - var span = memory.Span.Trim(); - if (span.StartsWith('#')) continue; - if (span.IsEmpty) continue; - - var commentIndex = span.IndexOf('#'); - var validSpan = commentIndex == -1 ? span : span.Slice(0, commentIndex).TrimEnd(); - - var splitterIndex = span.IndexOf('='); - if (splitterIndex == -1) continue; - - var key = validSpan.Slice(0, splitterIndex).TrimEnd(); - var value = validSpan.Slice(splitterIndex + 1).TrimStart(); - - if (key.Equals("BeatmapDirectory", StringComparison.OrdinalIgnoreCase)) - { - beatmapDirectory = value.ToString(); - break; - } - } - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to read osu! configuration file"); - } - } - else - { - _logger.LogWarning("osu! configuration file not found at {ConfigPath}", configPath); - } - - _songsDirectory = Path.IsPathRooted(beatmapDirectory) - ? beatmapDirectory - : Path.Combine(baseDirectory, beatmapDirectory); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting osu! main module path"); - // Ignore exceptions when accessing MainModule - } - } - - private bool ReadGeneralData(MemoryReadObject memoryReadObject) - { - try - { - _memoryContext!.BeginUpdate(); - _memoryContext.Populate(_osuMemoryData); - - memoryReadObject.OsuStatus = (OsuMemoryStatus)_osuMemoryData.RawStatus; - memoryReadObject.PlayerName = _osuMemoryData.Username; - memoryReadObject.Mods = (Mods)_osuMemoryData.Mods; - - if (memoryReadObject.OsuStatus == OsuMemoryStatus.Playing) - { - memoryReadObject.IsReplay = _osuMemoryData.IsReplay; - var score = _osuMemoryData.Score; - - if (_scoreProcessorValueDefinition != null && - _memoryContext.TryGetValueDef(_scoreProcessorValueDefinition, out var scoreProcessor) && - scoreProcessor != 0 && - _scoreV2ValueDefinition != null && - _memoryContext.TryGetValueDef(_scoreV2ValueDefinition, out var scoreV2) && - scoreV2 > 0) - { - score = scoreV2; - } - - memoryReadObject.Score = score; - memoryReadObject.Combo = _osuMemoryData.Combo; - } - else - { - memoryReadObject.Score = 0; - memoryReadObject.Combo = 0; - } - - if (_songsDirectory != null) - { - var folderName = _osuMemoryData.FolderName; - var osuFileName = _osuMemoryData.OsuFileName; - - if (string.IsNullOrEmpty(osuFileName)) - { - return true; - } - - if (memoryReadObject.BeatmapIdentifier.Filename == osuFileName && _folderName == folderName) - { - return true; - } - - _folderName = folderName; - var directory = Path.Combine(_songsDirectory, folderName); - memoryReadObject.BeatmapIdentifier = new BeatmapIdentifier(directory, osuFileName); - } - - return true; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error reading memory"); - CleanupProcess(memoryReadObject); - return false; - } - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private void ReadTiming(MemoryReadObject memoryReadObject) - { - if (_memoryContext != null && _memoryContext.TryGetValueDef(_valueDefinition, out var audioTime)) - { - memoryReadObject.PlayingTime = audioTime; - } - - if (memoryReadObject.OsuStatus != OsuMemoryStatus.Playing) - { - memoryReadObject.Statistics = SyncStatistics.Empty; - memoryReadObject.HitErrors = SyncHitErrors.Empty; - return; - } - - memoryReadObject.Statistics = new SyncStatistics( - ReadStat(_hitGekiValueDefinition), - ReadStat(_hit300ValueDefinition), - ReadStat(_hitKatuValueDefinition), - ReadStat(_hit100ValueDefinition), - ReadStat(_hit50ValueDefinition), - ReadStat(_hitMissValueDefinition)); - - memoryReadObject.HitErrors = ReadHitErrors(memoryReadObject.HitErrors.Index); - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private int ReadStat(ValueDefinition? definition) - { - return _memoryContext != null && _memoryContext.TryGetValueDef(definition, out var value) - ? value - : 0; - } - - private SyncHitErrors ReadHitErrors(int lastIndex) - { - int safeLastIndex = Math.Max(0, lastIndex); - - if (_memoryContext == null || _sigScan == null || _comboValueDefinition == null) - { - return new SyncHitErrors(safeLastIndex, []); - } - - var scoreBase = _memoryContext.ResolveBaseAddress(_comboValueDefinition); - if (scoreBase == IntPtr.Zero) - { - return new SyncHitErrors(safeLastIndex, []); - } - - if (!MemoryReadHelper.TryGetPointer(_sigScan, scoreBase + 0x38, out var hitErrorsListBase) || - hitErrorsListBase == IntPtr.Zero) - { - return new SyncHitErrors(safeLastIndex, []); - } - - if (!MemoryReadHelper.TryGetPointer(_sigScan, hitErrorsListBase + 0x4, out var itemsBase) || - itemsBase == IntPtr.Zero) - { - return new SyncHitErrors(safeLastIndex, []); - } - - if (!MemoryReadHelper.TryGetValue(_sigScan, hitErrorsListBase + 0xc, out var size) || - size is < 0 or > 100_000) - { - return new SyncHitErrors(safeLastIndex, []); - } - - if (safeLastIndex > size) - { - safeLastIndex = 0; - } - - int count = size - safeLastIndex; - if (count <= 0) - { - return new SyncHitErrors(size, []); - } - - var errors = new int[count]; - int readCount = 0; - int index = safeLastIndex; - for (int i = safeLastIndex; i < size; i++) - { - if (!MemoryReadHelper.TryGetValue(_sigScan, itemsBase + 0x8 + 0x4 * i, out var error)) - { - break; - } - - if (error is < -10_000 or > 10_000) - { - _logger.LogDebug("Strange value in hitErrors: {HitError}", error); - break; - } - - errors[readCount++] = error; - index = i + 1; - } - - if (readCount != errors.Length) - { - Array.Resize(ref errors, readCount); - } - - return new SyncHitErrors(index, errors); - } -} diff --git a/src/Common/KeyAsio.Shared/OsuMemory/MemorySyncBridge.cs b/src/Common/KeyAsio.Shared/OsuMemory/MemorySyncBridge.cs deleted file mode 100644 index 004962af..00000000 --- a/src/Common/KeyAsio.Shared/OsuMemory/MemorySyncBridge.cs +++ /dev/null @@ -1,135 +0,0 @@ -using System.ComponentModel; -using System.Text; -using KeyAsio.Shared.Sync; -using KeyAsio.Shared.Utils; -using Microsoft.Extensions.Logging; - -namespace KeyAsio.Shared.OsuMemory; - -public class MemorySyncBridge -{ - private readonly GameSyncSourceCoordinator _sourceCoordinator; - private readonly StableMemoryGameSyncSource _stableSource; - private readonly LazerIpcBridge _lazerIpcBridge; - private readonly SyncSessionContext _syncSessionContext; - private readonly AppSettings _appSettings; - private readonly ILogger _logger; - private bool _initialized; - private bool _isRunning; - private volatile bool _isStopping; - - public MemorySyncBridge( - GameSyncSourceCoordinator sourceCoordinator, - StableMemoryGameSyncSource stableSource, - LazerIpcBridge lazerIpcBridge, - SyncSessionContext syncSessionContext, - AppSettings appSettings, - ILogger logger) - { - _sourceCoordinator = sourceCoordinator; - _stableSource = stableSource; - _lazerIpcBridge = lazerIpcBridge; - _syncSessionContext = syncSessionContext; - _appSettings = appSettings; - _logger = logger; - } - - public void Start() - { - if (_initialized) return; - _initialized = true; - - _appSettings.Sync.Scanning.PropertyChanged += OnScanningSettingsChanged; - _appSettings.Sync.PropertyChanged += OnSyncSettingsChanged; - _lazerIpcBridge.ChannelConnectionChanged += OnLazerIpcChannelConnectionChanged; - - ConfigureStableSourceIntervals(); - - _logger.LogInformation("Initial EnableSync state: {State}", _appSettings.Sync.EnableSync); - _logger.LogInformation("Initial EnableMixSync state: {State}", _appSettings.Sync.EnableMixSync); - - if (_appSettings.Sync.EnableSync) - { - StartScanning(); - } - } - - private void OnScanningSettingsChanged(object? sender, PropertyChangedEventArgs e) - { - if (e.PropertyName is nameof(AppSettingsSyncScanning.GeneralScanInterval) - or nameof(AppSettingsSyncScanning.TimingScanInterval)) - { - ConfigureStableSourceIntervals(); - } - } - - private void ConfigureStableSourceIntervals() - { - _stableSource.ConfigureIntervals(_appSettings.Sync.Scanning.GeneralScanInterval, - _appSettings.Sync.Scanning.TimingScanInterval); - } - - private void OnLazerIpcChannelConnectionChanged(LazerIpcChannel channel, bool oldValue, bool newValue) - { - if (_isStopping) return; - - _stableSource.SetMemoryScanSuppressed(_lazerIpcBridge.IsAnyChannelConnected); - } - - private void OnSyncSettingsChanged(object? sender, PropertyChangedEventArgs e) - { - if (e.PropertyName == nameof(AppSettingsSync.EnableSync)) - { - if (_appSettings.Sync.EnableSync) - { - _logger.LogInformation("Memory Sync enabled."); - StartScanning(); - } - else - { - _logger.LogInformation("Memory Sync disabled."); - _ = StopScanningAsync(); - } - } - else if (e.PropertyName == nameof(AppSettingsSync.EnableMixSync)) - { - _logger.LogInformation("EnableMixSync changed to: {State}", _appSettings.Sync.EnableMixSync); - } - } - - private void StartScanning() - { - if (_isRunning) return; - - try - { - var player = EncodeUtils.FromBase64String(_appSettings.Logging.PlayerBase64 ?? "", Encoding.ASCII); - _syncSessionContext.Username = player; - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to decode PlayerBase64 string."); - } - - ConfigureStableSourceIntervals(); - _sourceCoordinator.Start(); - _isRunning = true; - } - - private async Task StopScanningAsync() - { - if (!_isRunning) return; - - _isStopping = true; - try - { - await _sourceCoordinator.StopAsync(); - } - finally - { - _stableSource.SetMemoryScanSuppressed(false); - _isStopping = false; - _isRunning = false; - } - } -} diff --git a/src/Common/KeyAsio.Shared/OsuMemory/OsuMemoryData.cs b/src/Common/KeyAsio.Shared/OsuMemory/OsuMemoryData.cs deleted file mode 100644 index 215366d3..00000000 --- a/src/Common/KeyAsio.Shared/OsuMemory/OsuMemoryData.cs +++ /dev/null @@ -1,35 +0,0 @@ -namespace KeyAsio.Shared.OsuMemory; - -public record OsuMemoryData -{ - // Beatmap Data - //public int Id { get; set; } - //public int SetId { get; set; } - //public string MapString { get; set; } = string.Empty; - public string FolderName { get; set; } = string.Empty; - public string OsuFileName { get; set; } = string.Empty; - //public string MD5 { get; set; } = string.Empty; - //public float AR { get; set; } - //public float CS { get; set; } - //public float HP { get; set; } - //public float OD { get; set; } - //public short Status { get; set; } // Beatmap Status - - // General Data - public int RawStatus { get; set; } // OsuStatus - //public int GameMode { get; set; } - //public int Retries { get; set; } - //public double TotalAudioTime { get; set; } - //public bool ChatIsExpanded { get; set; } - public int Mods { get; set; } - //public bool ShowPlayingInterface { get; set; } - //public string OsuVersion { get; set; } = string.Empty; - - // Additional Data - //public bool IsLoggedIn { get; set; } - public string Username { get; set; } = string.Empty; - //public string SkinFolder { get; set; } = string.Empty; - public bool IsReplay { get; set; } - public int Score { get; set; } - public ushort Combo { get; set; } -} diff --git a/src/Common/KeyAsio.Shared/OsuMemory/StableMemoryGameSyncSource.cs b/src/Common/KeyAsio.Shared/OsuMemory/StableMemoryGameSyncSource.cs deleted file mode 100644 index 8f79f669..00000000 --- a/src/Common/KeyAsio.Shared/OsuMemory/StableMemoryGameSyncSource.cs +++ /dev/null @@ -1,121 +0,0 @@ -using KeyAsio.Shared.Sync; - -namespace KeyAsio.Shared.OsuMemory; - -public sealed class StableMemoryGameSyncSource : IGameSyncSource -{ - private readonly MemoryScan _memoryScan; - private readonly GameSyncSnapshot _snapshot; - private bool _eventsBound; - private bool _started; - private bool _memoryScanSuppressed; - private int _generalInterval; - private int _timingInterval; - - public StableMemoryGameSyncSource(MemoryScan memoryScan) - { - _memoryScan = memoryScan; - _snapshot = new GameSyncSnapshot { ClientType = ClientType }; - UpdateSnapshot(); - CurrentSnapshot = _snapshot; - } - - public string Name => "osu!stable memory"; - public GameClientType ClientType => GameClientType.Stable; - public int Priority => 0; - public bool IsAvailable => _started; - public GameSyncSnapshot CurrentSnapshot { get; private set; } - - public event Action? AvailabilityChanged; - public event Action? SnapshotReceived; - - public void ConfigureIntervals(int generalInterval, int timingInterval) - { - _generalInterval = generalInterval; - _timingInterval = timingInterval; - - if (_started) - { - _memoryScan.UpdateIntervals(generalInterval, timingInterval); - } - } - - public void SetMemoryScanSuppressed(bool suppressed) - { - if (_memoryScanSuppressed == suppressed) return; - - _memoryScanSuppressed = suppressed; - _memoryScan.SetScanSuppressed(suppressed); - - if (suppressed) - { - _snapshot.ResetToNotRunning(ClientType); - SnapshotReceived?.Invoke(this, _snapshot); - } - } - - public void Start() - { - if (_started) return; - - BindEvents(); - _started = true; - _memoryScan.Start(_generalInterval, _timingInterval); - PublishSnapshot(); - AvailabilityChanged?.Invoke(this, true); - } - - public async Task StopAsync() - { - if (!_started) return; - - await _memoryScan.StopAsync(); - _started = false; - _snapshot.ResetToNotRunning(ClientType); - AvailabilityChanged?.Invoke(this, false); - } - - private void BindEvents() - { - if (_eventsBound) return; - - var memory = _memoryScan.MemoryReadObject; - memory.PlayerNameChanged += (_, _) => PublishSnapshot(); - memory.ModsChanged += (_, _) => PublishSnapshot(); - memory.ComboChanged += (_, _) => PublishSnapshot(); - memory.ScoreChanged += (_, _) => PublishSnapshot(); - memory.IsReplayChanged += (_, _) => PublishSnapshot(); - memory.BeatmapIdentifierChanged += (_, _) => PublishSnapshot(); - memory.OsuStatusChanged += (_, _) => PublishSnapshot(); - memory.ProcessIdChanged += (_, _) => PublishSnapshot(); - memory.PlayingTimeChanged += (_, _) => PublishSnapshot(); - memory.StatisticsChanged += (_, _) => PublishSnapshot(); - memory.HitErrorsChanged += (_, _) => PublishSnapshot(); - - _eventsBound = true; - } - - private void PublishSnapshot() - { - UpdateSnapshot(); - SnapshotReceived?.Invoke(this, _snapshot); - } - - private void UpdateSnapshot() - { - var memory = _memoryScan.MemoryReadObject; - var snapshot = _snapshot; - snapshot.ProcessId = memory.ProcessId; - snapshot.Username = memory.PlayerName; - snapshot.PlayMods = memory.Mods; - snapshot.IsReplay = memory.IsReplay; - snapshot.Score = memory.Score; - snapshot.Combo = memory.Combo; - snapshot.Statistics = memory.Statistics; - snapshot.HitErrors = memory.HitErrors; - snapshot.Beatmap = memory.BeatmapIdentifier; - snapshot.BeatmapResourceCatalog = null; - snapshot.PlayTime = memory.PlayingTime; - snapshot.Status = memory.OsuStatus; - } -} diff --git a/src/Common/KeyAsio.Shared/Plugins/AudioEngineWrapper.cs b/src/Common/KeyAsio.Shared/Plugins/AudioEngineWrapper.cs deleted file mode 100644 index df56dbf2..00000000 --- a/src/Common/KeyAsio.Shared/Plugins/AudioEngineWrapper.cs +++ /dev/null @@ -1,30 +0,0 @@ -using KeyAsio.Core.Audio; -using KeyAsio.Plugins.Abstractions; -using NAudio.Wave; - -namespace KeyAsio.Shared.Plugins; - -public class AudioEngineWrapper : IAudioEngine -{ - private readonly IPlaybackEngine _engine; - - public AudioEngineWrapper(IPlaybackEngine engine) - { - _engine = engine; - } - - public ISampleProvider MainMixer => _engine.RootMixer; - public ISampleProvider EffectMixer => _engine.EffectMixer; - public ISampleProvider MusicMixer => _engine.MusicMixer; - public WaveFormat EngineWaveFormat => _engine.EngineWaveFormat; - - public void AddMixerInput(ISampleProvider input) - { - _engine.RootMixer.AddMixerInput(input); - } - - public void RemoveMixerInput(ISampleProvider input) - { - _engine.RootMixer.RemoveMixerInput(input); - } -} \ No newline at end of file diff --git a/src/Common/KeyAsio.Shared/Plugins/IUserInterfacePlugin.cs b/src/Common/KeyAsio.Shared/Plugins/IUserInterfacePlugin.cs deleted file mode 100644 index 0ca9a243..00000000 --- a/src/Common/KeyAsio.Shared/Plugins/IUserInterfacePlugin.cs +++ /dev/null @@ -1,16 +0,0 @@ -using Avalonia.Controls; -using KeyAsio.Plugins.Abstractions; - -namespace KeyAsio.Shared.Plugins; - -/// -/// Interface for plugins that provide a user interface component to be injected into the main application. -/// -public interface IUserInterfacePlugin : IPlugin -{ - /// - /// Gets the UI control provided by the plugin. - /// - /// The Avalonia control to be displayed. - Control GetPluginControl(); -} \ No newline at end of file diff --git a/src/Common/KeyAsio.Shared/Plugins/PluginContext.cs b/src/Common/KeyAsio.Shared/Plugins/PluginContext.cs deleted file mode 100644 index 3bae7374..00000000 --- a/src/Common/KeyAsio.Shared/Plugins/PluginContext.cs +++ /dev/null @@ -1,49 +0,0 @@ -using KeyAsio.Plugins.Abstractions; - -namespace KeyAsio.Shared.Plugins; - -public class PluginContext : IPluginContext -{ - public PluginContext(IServiceProvider serviceProvider, IAudioEngine audioEngine, string pluginDirectory) - { - ServiceProvider = serviceProvider; - AudioEngine = audioEngine; - PluginDirectory = pluginDirectory; - } - - public IServiceProvider ServiceProvider { get; } - public IAudioEngine AudioEngine { get; } - public string PluginDirectory { get; } - - private readonly Dictionary> _stateHandlers = new(); - - public void RegisterStateHandler(SyncOsuStatus status, IGameStateHandler handler) - { - if (!_stateHandlers.TryGetValue(status, out var list)) - { - list = new List(); - _stateHandlers[status] = list; - } - - // Avoid duplicate registration of same instance - if (!list.Contains(handler)) - { - list.Add(handler); - } - } - - public void UnregisterStateHandler(SyncOsuStatus status) - { - _stateHandlers.Remove(status); - } - - internal IReadOnlyList GetHandlers(SyncOsuStatus status) - { - if (_stateHandlers.TryGetValue(status, out var list)) - { - return list; - } - - return Array.Empty(); - } -} \ No newline at end of file diff --git a/src/Common/KeyAsio.Shared/Plugins/PluginManager.cs b/src/Common/KeyAsio.Shared/Plugins/PluginManager.cs deleted file mode 100644 index a4971de2..00000000 --- a/src/Common/KeyAsio.Shared/Plugins/PluginManager.cs +++ /dev/null @@ -1,221 +0,0 @@ -using System.Reflection; -using System.Runtime.Loader; -using KeyAsio.Plugins.Abstractions; -using Microsoft.Extensions.Logging; - -namespace KeyAsio.Shared.Plugins; - -public class PluginManager : IPluginManager, IDisposable -{ - private readonly ILogger _logger; - private readonly IServiceProvider _serviceProvider; - private readonly List _plugins = new(); - private readonly Dictionary _loadContexts = new(); - - public PluginManager(ILogger logger, IServiceProvider serviceProvider) - { - _logger = logger; - _serviceProvider = serviceProvider; - } - - public IEnumerable GetAllPlugins() - { - return _plugins.Select(p => p.Instance); - } - - public T? GetPlugin() where T : class, IPlugin - { - return _plugins.Select(p => p.Instance).OfType().FirstOrDefault(); - } - - public IEnumerable GetActiveHandlers(SyncOsuStatus status) - { - var handlers = new List(); - foreach (var wrapper in _plugins) - { - if (wrapper.Context is PluginContext ctx) - { - handlers.AddRange(ctx.GetHandlers(status)); - } - } - - // Sort by Priority Descending (Higher priority first) - handlers.Sort((a, b) => b.Priority.CompareTo(a.Priority)); - return handlers; - } - - public void LoadPlugins(string pluginDirectory, string searchPattern = "*.dll", - SearchOption searchOption = SearchOption.AllDirectories) - { - if (!Directory.Exists(pluginDirectory)) - { - if (pluginDirectory == AppDomain.CurrentDomain.BaseDirectory) - { - // The root directory must exist, this is a safety check. - return; - } - - try - { - Directory.CreateDirectory(pluginDirectory); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to create plugin directory: {PluginDirectory}", pluginDirectory); - return; - } - } - - var dllFiles = Directory.GetFiles(pluginDirectory, searchPattern, searchOption); - foreach (var dllPath in dllFiles) - { - // Exclude Abstractions lib - if (Path.GetFileName(dllPath) - .Equals("KeyAsio.Plugins.Abstractions.dll", StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - LoadPlugin(dllPath); - } - } - - private bool ValidatePluginAssembly(Assembly assembly) - { - // 1. Compatibility Check - var abstractionsRef = assembly.GetReferencedAssemblies() - .FirstOrDefault(a => a.Name == "KeyAsio.Plugins.Abstractions"); - - if (abstractionsRef != null) - { - var currentVersion = typeof(IPlugin).Assembly.GetName().Version; - // Assuming major version change breaks compatibility - if (abstractionsRef.Version != null && currentVersion != null && - abstractionsRef.Version.Major != currentVersion.Major) - { - _logger.LogWarning("Plugin {PluginAssembly} references an incompatible version of Abstractions: {RefVersion} (Current: {CurrentVersion})", - assembly.GetName().Name, abstractionsRef.Version, currentVersion); - return false; - } - } - - return true; - } - - private void LoadPlugin(string dllPath) - { - try - { - var loadContext = new AssemblyLoadContext(Path.GetFileNameWithoutExtension(dllPath), true); - var assembly = loadContext.LoadFromAssemblyPath(dllPath); - - if (!ValidatePluginAssembly(assembly)) - { - loadContext.Unload(); - return; - } - - var pluginTypes = assembly.GetTypes() - .Where(t => typeof(IPlugin).IsAssignableFrom(t) && t is { IsInterface: false, IsAbstract: false }); - - foreach (var type in pluginTypes) - { - if (Activator.CreateInstance(type) is IPlugin plugin) - { - var wrapper = new PluginWrapper(plugin, loadContext, - Path.GetDirectoryName(dllPath) ?? string.Empty); - _plugins.Add(wrapper); - _loadContexts[plugin.Id] = loadContext; - _logger.LogInformation("Loaded plugin: {PluginName} ({PluginId})", plugin.Name, plugin.Id); - } - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to load plugin from {DllPath}", dllPath); - } - } - - public void InitializePlugins(IAudioEngine audioEngine) - { - foreach (var wrapper in _plugins) - { - try - { - var context = new PluginContext(_serviceProvider, audioEngine, wrapper.PluginDirectory); - wrapper.Context = context; - wrapper.Instance.Initialize(context); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error initializing plugin {PluginName}", wrapper.Instance.Name); - } - } - } - - public void StartupPlugins() - { - foreach (var wrapper in _plugins) - { - try - { - wrapper.Instance.Startup(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error starting plugin {PluginName}", wrapper.Instance.Name); - } - } - } - - public void UnloadPlugins() - { - foreach (var wrapper in _plugins) - { - try - { - wrapper.Instance.Shutdown(); - wrapper.Instance.Unload(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error unloading plugin {PluginName}", wrapper.Instance.Name); - } - } - - _plugins.Clear(); - foreach (var context in _loadContexts.Values) - { - try - { - context.Unload(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error unloading AssemblyLoadContext"); - } - } - - _loadContexts.Clear(); - } - - public void Dispose() - { - UnloadPlugins(); - } - - private class PluginWrapper - { - public IPlugin Instance { get; } - public AssemblyLoadContext LoadContext { get; } - public string PluginDirectory { get; } - public PluginContext? Context { get; set; } - - public PluginWrapper(IPlugin instance, AssemblyLoadContext loadContext, string pluginDirectory) - { - Instance = instance; - LoadContext = loadContext; - PluginDirectory = pluginDirectory; - } - } -} \ No newline at end of file diff --git a/src/Common/KeyAsio.Shared/Services/RtssMonitorService.cs b/src/Common/KeyAsio.Shared/Services/RtssMonitorService.cs deleted file mode 100644 index 04996647..00000000 --- a/src/Common/KeyAsio.Shared/Services/RtssMonitorService.cs +++ /dev/null @@ -1,283 +0,0 @@ -using System.ComponentModel; -using System.Diagnostics; -using System.Text; -using KeyAsio.Plugins.Abstractions; -using KeyAsio.Shared.Sync; -using Microsoft.Extensions.Logging; - -namespace KeyAsio.Shared.Services; - -public sealed class RtssMonitorService : IDisposable -{ - // RTSS hypertext color tags: we only colorize keys for quick visual scan. - private const string CriticalKeyColorTag = ""; - private const string ResetColorTag = ""; - private const string BoolTrueColorTag = ""; - private const string BoolFalseColorTag = ""; - private const string SeparatorColorTag = ""; - - private readonly AppSettings _appSettings; - private readonly SyncSessionContext _syncSessionContext; - private readonly ILogger _logger; - - private RtssOsdWriter? _osdWriter; - private Task? _updateTask; - private CancellationTokenSource? _cts; - private bool _disposed; - private long _nextFailureLogTimeMs; - private readonly Queue _hitErrorWindow = new(); - private int _hitErrorWindowSum; - private int _hitErrorWindowAbsSum; - private int? _lastHitError; - private const int HitErrorWindowSize = 64; - - public RtssMonitorService( - AppSettings appSettings, - SyncSessionContext syncSessionContext, - ILogger logger) - { - _appSettings = appSettings; - _syncSessionContext = syncSessionContext; - _logger = logger; - - _appSettings.Sync.PropertyChanged += OnSyncSettingsChanged; - - if (_appSettings.Sync.EnableRtssMonitoring) - { - StartMonitoring(); - } - } - - private void OnSyncSettingsChanged(object? sender, PropertyChangedEventArgs e) - { - if (e.PropertyName == nameof(AppSettingsSync.EnableRtssMonitoring)) - { - if (_appSettings.Sync.EnableRtssMonitoring) - { - _logger.LogInformation("RTSS monitoring enabled."); - StartMonitoring(); - } - else - { - _logger.LogInformation("RTSS monitoring disabled."); - StopMonitoring(); - } - } - } - - private void StartMonitoring() - { - if (_updateTask != null) return; - - try - { - _osdWriter?.Dispose(); - _osdWriter = new RtssOsdWriter("KeyASIO"); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to initialize RTSS OSD writer."); - return; - } - - _cts = new CancellationTokenSource(); - _updateTask = Task.Factory.StartNew( - (Action)UpdateLoop, - _cts.Token, - _cts.Token, - TaskCreationOptions.LongRunning | TaskCreationOptions.DenyChildAttach, - TaskScheduler.Default); - } - - private void StopMonitoring() - { - if (_updateTask == null) return; - - _cts?.Cancel(); - try - { - _updateTask.Wait(TimeSpan.FromSeconds(2)); - } - catch - { - // Ignored - } - - _cts?.Dispose(); - _cts = null; - _updateTask = null; - - _osdWriter?.Dispose(); - _osdWriter = null; - } - - private void UpdateLoop(object? state) - { - var token = (CancellationToken)state!; - var sb = new StringBuilder(1024); - var stopwatch = Stopwatch.StartNew(); - const int targetIntervalMs = 10; // 100 fps - - while (!token.IsCancellationRequested) - { - var frameStart = stopwatch.ElapsedMilliseconds; - - try - { - sb.Clear(); - AppendField(sb, "File", _syncSessionContext.Beatmap.Filename ?? "(null)"); - AppendLineEnd(sb); - - AppendField(sb, "Folder", _syncSessionContext.Beatmap.Folder ?? "(null)"); - AppendLineEnd(sb); - - AppendField(sb, "PID", _syncSessionContext.ProcessId); - AppendSeparator(sb); - AppendField(sb, "Client", _syncSessionContext.ClientType); - AppendLineEnd(sb); - - AppendField(sb, "User", _syncSessionContext.Username ?? "(null)"); - AppendSeparator(sb); - AppendField(sb, "Status", _syncSessionContext.OsuStatus); - AppendLineEnd(sb); - - AppendField(sb, "IsStarted", _syncSessionContext.IsStarted); - AppendSeparator(sb); - AppendField(sb, "IsReplay", _syncSessionContext.IsReplay); - AppendLineEnd(sb); - - AppendCriticalField(sb, "Time", _syncSessionContext.PlayTime); - AppendSeparator(sb); - AppendCriticalField(sb, "RawTime", _syncSessionContext.BaseMemoryTime); - AppendLineEnd(sb); - - AppendCriticalField(sb, "Mods", _syncSessionContext.PlayMods); - AppendSeparator(sb); - AppendCriticalField(sb, "Combo", _syncSessionContext.Combo); - AppendSeparator(sb); - AppendCriticalField(sb, "Score", _syncSessionContext.Score); - AppendLineEnd(sb); - - var stats = _syncSessionContext.Statistics; - AppendCriticalField(sb, "Stats", - $"300:{stats.Great} 100:{stats.Ok} 50:{stats.Meh} miss:{stats.Miss} geki:{stats.Perfect} katu:{stats.Good}"); - AppendLineEnd(sb); - - var hitErrors = _syncSessionContext.HitErrors; - UpdateHitErrorWindow(hitErrors); - AppendCriticalField(sb, "HitErr", BuildHitErrorSummary(hitErrors)); - AppendLineEnd(sb); - - AppendField(sb, "Update", _syncSessionContext.LastUpdateTimestamp); - AppendLineEnd(sb); - - var text = sb.ToString(); - _osdWriter?.Update(text); - } - catch (Exception ex) - { - var now = Environment.TickCount64; - if (now >= _nextFailureLogTimeMs) - { - _nextFailureLogTimeMs = now + 5000; - _logger.LogWarning(ex, "Failed to update RTSS OSD."); - } - } - - var elapsed = stopwatch.ElapsedMilliseconds - frameStart; - var delay = targetIntervalMs - (int)elapsed; - if (delay > 0) - { - Thread.Sleep(delay); - } - } - } - - private static void AppendField(StringBuilder sb, string key, T value) - { - sb.Append(key); - sb.Append(": "); - AppendValue(sb, value); - } - - private static void AppendCriticalField(StringBuilder sb, string key, T value) - { - sb.Append(CriticalKeyColorTag) - .Append(key) - .Append(ResetColorTag) - .Append(": "); - AppendValue(sb, value); - } - - private static void AppendSeparator(StringBuilder sb) - { - sb.Append(SeparatorColorTag) - .Append(" \t") - .Append(ResetColorTag); - } - - private static void AppendLineEnd(StringBuilder sb) - { - sb.Append('\n'); - } - - private static void AppendValue(StringBuilder sb, T value) - { - if (value is bool boolValue) - { - sb.Append(boolValue ? BoolTrueColorTag : BoolFalseColorTag) - .Append(boolValue ? "true" : "false") - .Append(ResetColorTag); - return; - } - - sb.Append(value?.ToString()); - } - - private void UpdateHitErrorWindow(SyncHitErrors hitErrors) - { - var values = hitErrors.Values; - if (values == null || values.Length == 0) return; - - for (int i = 0; i < values.Length; i++) - { - var error = values[i]; - _hitErrorWindow.Enqueue(error); - _hitErrorWindowSum += error; - _hitErrorWindowAbsSum += Math.Abs(error); - _lastHitError = error; - - while (_hitErrorWindow.Count > HitErrorWindowSize) - { - var removed = _hitErrorWindow.Dequeue(); - _hitErrorWindowSum -= removed; - _hitErrorWindowAbsSum -= Math.Abs(removed); - } - } - } - - private string BuildHitErrorSummary(SyncHitErrors hitErrors) - { - int delta = hitErrors.Values?.Length ?? 0; - int count = _hitErrorWindow.Count; - string lastText = _lastHitError.HasValue ? $"{FormatSigned(_lastHitError.Value)}ms" : "--"; - string avgText = count > 0 ? $"{(double)_hitErrorWindowSum / count:F1}ms" : "--"; - string avgAbsText = count > 0 ? $"{(double)_hitErrorWindowAbsSum / count:F1}ms" : "--"; - - return $"idx:{hitErrors.Index} Δ:{delta} last:{lastText} avg:{avgText} abs:{avgAbsText}"; - } - - private static string FormatSigned(int value) - { - return value >= 0 ? $"+{value}" : value.ToString(); - } - - public void Dispose() - { - if (_disposed) return; - _disposed = true; - - _appSettings.Sync.PropertyChanged -= OnSyncSettingsChanged; - StopMonitoring(); - } -} diff --git a/src/Common/KeyAsio.Shared/Services/RtssOsdWriter.cs b/src/Common/KeyAsio.Shared/Services/RtssOsdWriter.cs deleted file mode 100644 index 6c4a9229..00000000 --- a/src/Common/KeyAsio.Shared/Services/RtssOsdWriter.cs +++ /dev/null @@ -1,244 +0,0 @@ -using System.IO.MemoryMappedFiles; -using System.Runtime.InteropServices; -using System.Text; - -namespace KeyAsio.Shared.Services; - -public sealed class RtssOsdWriter : IDisposable -{ - private const string SharedMemoryName = "RTSSSharedMemoryV2"; - private const int OwnerFieldLength = 256; - private const int LegacyTextLength = 256; - private const int ExtendedTextLength = 4096; - private const uint SupportedVersionMin = 0x00020000; - private const uint ExtendedTextVersionMin = 0x00020007; - private const uint RtssSignature = 0x52545353; // 'RTSS' - - private readonly string _entryName; - private readonly byte[] _entryNameBytes = new byte[OwnerFieldLength]; - private readonly byte[] _ownerBuffer = new byte[OwnerFieldLength]; - private readonly byte[] _legacyTextBuffer = new byte[LegacyTextLength]; - private readonly byte[] _extendedTextBuffer = new byte[ExtendedTextLength]; - private readonly object _syncRoot = new(); - - private MemoryMappedFile? _mmf; - private MemoryMappedViewAccessor? _accessor; - private uint _osdEntrySize; - private uint _osdArrOffset; - private uint _osdArrSize; - private bool _useExtendedText; - - private uint _osdSlot; - private long _entryOffset; - private bool _disposed; - - public RtssOsdWriter(string entryName) - { - if (string.IsNullOrWhiteSpace(entryName)) - throw new ArgumentException("Entry name cannot be null, empty, or whitespace.", nameof(entryName)); - - var bytes = Encoding.ASCII.GetByteCount(entryName); - if (bytes > OwnerFieldLength - 1) - throw new ArgumentException("Entry name exceeds max length of 255 when converted to ANSI.", nameof(entryName)); - - _entryName = entryName; - _osdSlot = 0; - _entryOffset = 0; - - Encoding.ASCII.GetBytes(entryName.AsSpan(), _entryNameBytes); - - lock (_syncRoot) - { - EnsureConnected(); - } - } - - public void Update(string text) - { - ObjectDisposedException.ThrowIf(_disposed, this); - ArgumentNullException.ThrowIfNull(text); - - lock (_syncRoot) - { - EnsureConnected(); - if (!TryEnsureSlot()) - { - return; - } - - var maxTextLength = _useExtendedText ? ExtendedTextLength - 1 : LegacyTextLength - 1; - var textBytes = Encoding.ASCII.GetByteCount(text); - if (textBytes > maxTextLength) - throw new ArgumentException($"Text exceeds max length of {maxTextLength} when converted to ANSI.", nameof(text)); - - var targetBuffer = _useExtendedText ? _extendedTextBuffer : _legacyTextBuffer; - var written = Encoding.ASCII.GetBytes(text.AsSpan(), targetBuffer); - - var textOffset = _entryOffset + (_useExtendedText ? 512 : 0); - _accessor!.WriteArray(textOffset, targetBuffer, 0, written); - _accessor.Write(textOffset + written, (byte)0); - - IncrementFrameCounter(); - } - } - - public void Dispose() - { - if (_disposed) return; - _disposed = true; - - try - { - lock (_syncRoot) - { - if (_accessor != null && _osdSlot != 0 && IsSlotOwnedByEntry(_osdSlot)) - { - var zeroBytes = new byte[_osdEntrySize]; - _accessor.WriteArray(_entryOffset, zeroBytes, 0, (int)_osdEntrySize); - IncrementFrameCounter(); - } - } - } - catch - { - // Ignored during disposal - } - finally - { - _accessor?.Dispose(); - _accessor = null; - _mmf?.Dispose(); - _mmf = null; - } - } - - private void EnsureConnected() - { - if (_accessor != null) - { - return; - } - - _mmf = OpenSharedMemory(); - _accessor = _mmf.CreateViewAccessor(); - - var signature = _accessor.ReadUInt32(0); - if (signature != RtssSignature) - throw new InvalidOperationException("Invalid RTSS shared memory signature."); - - var version = _accessor.ReadUInt32(4); - if (version < SupportedVersionMin) - throw new InvalidOperationException("Unsupported RTSS shared memory version."); - - _osdEntrySize = _accessor.ReadUInt32(20); - _osdArrOffset = _accessor.ReadUInt32(24); - _osdArrSize = _accessor.ReadUInt32(28); - _useExtendedText = version >= ExtendedTextVersionMin; - - _osdSlot = 0; - _entryOffset = 0; - } - - private bool TryEnsureSlot() - { - if (_osdSlot != 0 && IsSlotOwnedByEntry(_osdSlot)) - { - return true; - } - - _osdSlot = 0; - _entryOffset = 0; - - for (uint i = 1; i < _osdArrSize; i++) - { - var entryOffset = GetEntryOffset(i); - var ownerOffset = entryOffset + OwnerFieldLength; - - _accessor!.ReadArray(ownerOffset, _ownerBuffer, 0, OwnerFieldLength); - - if (IsOwnerBufferEmpty()) - { - _accessor.WriteArray(ownerOffset, _entryNameBytes, 0, OwnerFieldLength); - _osdSlot = i; - _entryOffset = entryOffset; - return true; - } - - if (IsOwnerBufferEntryName()) - { - _osdSlot = i; - _entryOffset = entryOffset; - return true; - } - } - - return false; - } - - private bool IsSlotOwnedByEntry(uint slot) - { - var entryOffset = GetEntryOffset(slot); - _accessor!.ReadArray(entryOffset + OwnerFieldLength, _ownerBuffer, 0, OwnerFieldLength); - if (!IsOwnerBufferEntryName()) - { - return false; - } - - _entryOffset = entryOffset; - return true; - } - - private static bool IsOwnerByteTerminator(byte value) - { - return value == 0; - } - - private bool IsOwnerBufferEmpty() - { - return IsOwnerByteTerminator(_ownerBuffer[0]); - } - - private bool IsOwnerBufferEntryName() - { - for (var i = 0; i < OwnerFieldLength; i++) - { - var actual = _ownerBuffer[i]; - var expected = _entryNameBytes[i]; - - if (actual != expected) - { - return false; - } - - if (IsOwnerByteTerminator(expected)) - { - return true; - } - } - - return true; - } - - private long GetEntryOffset(uint slot) - { - return _osdArrOffset + (long)slot * _osdEntrySize; - } - - private void IncrementFrameCounter() - { - var currentFrame = _accessor!.ReadUInt32(32); - _accessor.Write(32, currentFrame + 1); - } - - private static MemoryMappedFile OpenSharedMemory() - { - try - { - return MemoryMappedFile.OpenExisting(SharedMemoryName, MemoryMappedFileRights.ReadWrite); - } - catch (Exception ex) - { - throw new InvalidOperationException("Failed to open RTSS shared memory. Is RTSS running?", ex); - } - } -} diff --git a/src/Common/KeyAsio.Shared/Services/SkinManager.cs b/src/Common/KeyAsio.Shared/Services/SkinManager.cs deleted file mode 100644 index 4bb18782..00000000 --- a/src/Common/KeyAsio.Shared/Services/SkinManager.cs +++ /dev/null @@ -1,720 +0,0 @@ -using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; -using Coosu.Shared.IO; -using dnlib.DotNet; -using KeyAsio.Core.Audio.Caching; -using KeyAsio.Core.OsuAudio.Hitsounds; -using KeyAsio.LazerProtocol; -using KeyAsio.Shared.Models; -using KeyAsio.Shared.OsuMemory; -using KeyAsio.Shared.Sync; -using KeyAsio.Shared.Utils; -using Microsoft.Extensions.Logging; -using Milki.Extensions.Configuration; - -namespace KeyAsio.Shared.Services; - -public class SkinManager -{ - private static readonly HashSet s_resourcesKeys = - [ - "taiko-normal-hitclap", "taiko-normal-hitfinish", "taiko-normal-hitnormal", "taiko-normal-hitwhistle", - "taiko-soft-hitclap", "taiko-soft-hitfinish", "taiko-soft-hitnormal", "taiko-soft-hitwhistle", - - "drum-hitclap", "drum-hitfinish", "drum-hitnormal", "drum-hitwhistle", - "drum-sliderslide", "drum-slidertick", "drum-sliderwhistle", - - "normal-hitclap", "normal-hitfinish", "normal-hitnormal", "normal-hitwhistle", - "normal-sliderslide", "normal-slidertick", "normal-sliderwhistle", - - "soft-sliderslide", "soft-slidertick", "soft-sliderwhistle", - "soft-hitclap", "soft-hitfinish", "soft-hitnormal", "soft-hitwhistle", - - "combobreak", - "nightcore-clap", "nightcore-finish", "nightcore-hat", "nightcore-kick" - ]; - - // Lazer built-in skin folders (mirroring osu.Game SkinInfo well-known GUIDs). - // FolderName is the GUID string; Folder points to the lazer user data directory - // (used to resolve the realm-backed file store at runtime). - private static readonly (string Guid, SkinDescription Description)[] s_lazerBuiltinSkins = - [ - ("CFFA69DE-B3E3-4DEE-8563-3C4F425C05D0", - new SkinDescription("argon", "{lazer-argon}", "osu! \"argon\" (2022)", "team osu!")), - ("9FC9CF5D-0F16-4C71-8256-98868321AC43", - new SkinDescription("argon_pro", "{lazer-argon_pro}", "osu! \"argon\" pro (2022)", "team osu!")), - ("2991CFD8-2140-469A-BCB9-2EC23FBCE4AD", - new SkinDescription("triangles", "{lazer-triangles}", "osu! \"triangles\" (2017)", "team osu!")), - ("81F02CD3-EEC6-4865-AC23-FAE26A386187", - new SkinDescription("classic", "{lazer-classic}", "osu! \"classic\" (2013)", "team osu!")), - ("0555C76A-CC6B-4BB4-9548-DF76BA72EF25", - new SkinDescription("retro", "{lazer-retro}", "osu! \"retro\" (2008)", "team osu!")), - ]; - - // Maps lazer built-in skin Folder → resource path prefix in osu.Game.Resources.dll. - // Retro has no gameplay audio samples; it shares Legacy (classic) sounds. - private static readonly Dictionary s_lazerBuiltinResourcePrefixes = new() - { - ["{lazer-argon}"] = "Samples.Gameplay.Argon", - ["{lazer-argon_pro}"] = "Samples.Gameplay.ArgonPro", - ["{lazer-triangles}"] = "Samples.Gameplay", - ["{lazer-classic}"] = "Skins.Legacy", - ["{lazer-retro}"] = "Skins.Legacy", - }; - - private readonly ILogger _logger; - private readonly AppSettings _appSettings; - private readonly AudioCacheManager _audioCacheManager; - private readonly SharedViewModel _sharedViewModel; - private readonly LazerIpcGameSyncSource? _lazerSyncSource; - private readonly SyncSessionContext _syncSessionContext; - private readonly GameSyncSourceCoordinator _syncSourceCoordinator; - - private readonly AsyncLock _asyncLock = new(); - - private CancellationTokenSource? _processPollingCts; - private Task? _processPollingTask; - private CancellationTokenSource? _skinLoadCts; - - private readonly AsyncSequentialWorker _skinLoadingWorker; - - private readonly Dictionary _stableDefaultResources = new(); - private readonly Dictionary _lazerSkinCatalogs = new(); - private readonly Dictionary _lazerDefaultResources = new(); - - // Lazer skin context (received via IPC). - private LazerSkinInfo[]? _lazerSkinInfos; - private string? _lazerUserDataDirectory; - private string? _lazerExeDirectory; - private GameClientType _lastKnownClientType = GameClientType.Stable; - - public SkinManager(ILogger logger, AppSettings appSettings, AudioCacheManager audioCacheManager, - SharedViewModel sharedViewModel, LazerIpcGameSyncSource? lazerSyncSource, SyncSessionContext syncSessionContext, - GameSyncSourceCoordinator syncSourceCoordinator) - { - _logger = logger; - _appSettings = appSettings; - _audioCacheManager = audioCacheManager; - _sharedViewModel = sharedViewModel; - _lazerSyncSource = lazerSyncSource; - _syncSessionContext = syncSessionContext; - _syncSourceCoordinator = syncSourceCoordinator; - _sharedViewModel.PropertyChanged += SharedViewModel_PropertyChanged; - - _skinLoadingWorker = new AsyncSequentialWorker(_logger, "SkinManagerWorker"); - - if (_lazerSyncSource != null) - { - _lazerSyncSource.LazerSkinContextReceived += OnLazerSkinContextReceived; - } - - _syncSourceCoordinator.ClientTypeChanged += OnClientTypeChanged; - } - - public bool IsStarted => _processPollingCts != null; - - public bool TryGetStableResource(string key, [NotNullWhen(true)] out byte[]? data) - { - return _stableDefaultResources.TryGetValue(key, out data); - } - - public bool TryGetSkinCatalog(string folder, [NotNullWhen(true)] out IBeatmapResourceCatalog? catalog) - { - return _lazerSkinCatalogs.TryGetValue(folder, out catalog); - } - - public bool TryGetLazerResource(string skinFolder, string key, [NotNullWhen(true)] out byte[]? data) - { - // Try the specified skin first - if (_lazerDefaultResources.TryGetValue($"{skinFolder}:{key}", out data)) - return true; - - // Fallback: classic → triangles (for missing keys like nightcore in argon) - if (_lazerDefaultResources.TryGetValue($"{{lazer-classic}}:{key}", out data)) - return true; - - if (_lazerDefaultResources.TryGetValue($"{{lazer-triangles}}:{key}", out data)) - return true; - - return false; - } - - public Task ReloadSkinsAsync() => RefreshSkinsAsync(); - - public void Start() - { - if (string.IsNullOrWhiteSpace(_appSettings.Paths.OsuFolderPath)) - { - CheckOsuRegistry(); - } - - ListenPropertyChanging(); - _ = RefreshSkinsAsync(); - - StartProcessListener(); - } - - public void Stop() - { - StopProcessListener(); - _skinLoadingWorker.Dispose(); - } - - public void ListenPropertyChanging() - { - _sharedViewModel.PropertyChanged += (s, e) => - { - if (e.PropertyName == nameof(_sharedViewModel.SelectedSkin)) - { - _appSettings.Paths.SelectedSkinName = _sharedViewModel.SelectedSkin?.FolderName ?? ""; - _audioCacheManager.ClearAll(); - } - }; - - _appSettings.Paths.PropertyChanged += (s, e) => - { - if (e.PropertyName == nameof(AppSettings.Paths.OsuFolderPath) || - e.PropertyName == nameof(AppSettings.Paths.AllowAutoLoadSkins)) - { - _ = RefreshSkinsAsync(); - } - }; - } - - private void SharedViewModel_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) - { - if (e.PropertyName == nameof(SharedViewModel.SelectedSkin)) - { - _appSettings.Paths.SelectedSkinName = _sharedViewModel.SelectedSkin?.FolderName; - } - } - - private void OnLazerSkinContextReceived(LazerSkinInfo[]? skinInfos, string? userDataDirectory, string? exeDirectory) - { - bool changed = false; - - if (skinInfos != null) - { - _lazerSkinInfos = skinInfos; - changed = true; - } - - if (userDataDirectory != null) - { - if (_lazerUserDataDirectory != userDataDirectory) - { - _lazerUserDataDirectory = userDataDirectory; - changed = true; - } - } - - if (exeDirectory != null) - { - if (_lazerExeDirectory != exeDirectory) - { - _lazerExeDirectory = exeDirectory; - changed = true; - } - } - - if (!changed) - return; - - _logger.LogInformation( - "Lazer skin context updated: {SkinCount} skins, user data: {UserDataDir}, exe: {ExeDir}", - _lazerSkinInfos?.Length ?? 0, _lazerUserDataDirectory, _lazerExeDirectory); - - EnsureLazerClientTypeAndOsuFolder(); - _ = RefreshSkinsAsync(); - } - - private void EnsureLazerClientTypeAndOsuFolder() - { - // Update ClientType to Lazer and set OsuFolderPath to lazer's exe directory. - if (_lazerExeDirectory == null) - return; - - _appSettings.Paths.ClientType = GameClientType.Lazer; - _lastKnownClientType = GameClientType.Lazer; - - if (!string.Equals(_appSettings.Paths.OsuFolderPath, _lazerExeDirectory, StringComparison.OrdinalIgnoreCase)) - { - _logger.LogInformation("Updating osu folder to lazer exe directory: {Path}", _lazerExeDirectory); - _appSettings.Paths.OsuFolderPath = _lazerExeDirectory; - } - } - - private void OnClientTypeChanged(GameClientType newClientType) - { - if (newClientType == _lastKnownClientType) - return; - - _lastKnownClientType = newClientType; - _appSettings.Paths.ClientType = newClientType; - _logger.LogInformation("Sync client type changed to {ClientType}", newClientType); - - // Clear default resources so they get re-extracted from the appropriate source - // (osu!gameplay.dll for stable, osu.Game.Resources.dll for lazer). - _stableDefaultResources.Clear(); - _lazerSkinCatalogs.Clear(); - _lazerDefaultResources.Clear(); - - if (newClientType == GameClientType.Stable) - { - // When switching back to stable, re-detect stable's osu folder from running process. - CheckAndSetOsuPath(Process.GetProcessesByName("osu!")); - } - - _ = RefreshSkinsAsync(); - } - - private void StartProcessListener() - { - var processes = Process.GetProcessesByName("osu!"); - CheckAndSetOsuPath(processes); - - try - { - _processPollingCts = new CancellationTokenSource(); - var token = _processPollingCts.Token; - _processPollingTask = Task.Run(() => ProcessPollingLoop(token), token); - _logger.LogInformation("Osu process listener started."); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to start process polling."); - } - } - - private void StopProcessListener() - { - try - { - _processPollingCts?.Cancel(); - try - { - _processPollingTask?.Wait(1000); - } - catch (AggregateException) - { - } - - _processPollingCts?.Dispose(); - _processPollingCts = null; - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Error stopping process polling."); - } - } - - private async Task ProcessPollingLoop(CancellationToken token) - { - using var timer = new PeriodicTimer(TimeSpan.FromSeconds(3)); - bool wasRunning = IsOsuRunning(); - - while (await timer.WaitForNextTickAsync(token)) - { - var processes = Process.GetProcessesByName("osu!"); - bool isRunning = processes.Length > 0; - - if (isRunning && !wasRunning) - { - _logger.LogInformation("Detected osu! process start via polling."); - CheckAndSetOsuPath(processes); - _ = RefreshSkinsAsync(); - } - - wasRunning = isRunning; - } - } - - private static bool IsOsuRunning() - { - var processes = Process.GetProcessesByName("osu!"); - bool any = processes.Length > 0; - foreach (var p in processes) p.Dispose(); - return any; - } - - private void CheckAndSetOsuPath(Process[] processes) - { - try - { - var detectedPath = OsuLocator.FindFromRunningProcess(processes); - if (detectedPath != null && _appSettings.Paths.OsuFolderPath != detectedPath) - { - _logger.LogInformation("Auto-detected osu! path: {Path}", detectedPath); - _appSettings.Paths.OsuFolderPath = detectedPath; - } - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Error inspecting osu! process module."); - } - } - - private async Task RefreshSkinsAsync() - { - using var @lock = await _asyncLock.LockAsync(); - - StopRefreshTask(); - - if (_appSettings.Paths.AllowAutoLoadSkins != true) - { - await UiDispatcher.InvokeAsync(() => - { - _sharedViewModel.Skins.Clear(); - _sharedViewModel.Skins.Add(SkinDescription.Internal); - _sharedViewModel.SelectedSkin = SkinDescription.Internal; - foreach (var key in _stableDefaultResources.Keys) - { - _stableDefaultResources[key] = Array.Empty(); - } - }); - return; - } - - _skinLoadCts = new CancellationTokenSource(); - var token = _skinLoadCts.Token; - - _skinLoadingWorker.Enqueue(async () => await LoadSkinsInternal(token)); - } - - private void CheckOsuRegistry() - { - try - { - if (OsuLocator.FindFromRegistry() is { } path) - { - _appSettings.Paths.OsuFolderPath = path; - _appSettings.Save(); - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Error occurs while finding registry"); - } - } - - private async Task LoadSkinsInternal(CancellationToken token) - { - if (string.IsNullOrEmpty(_appSettings.Paths.OsuFolderPath)) - { - // Even without an osu folder, we can still expose lazer's built-in skins. - if (_lazerSkinInfos != null) - { - await LoadLazerSkinsAsync(token); - } - - return; - } - - if (_appSettings.Paths.ClientType == GameClientType.Lazer) - { - await LoadLazerSkinsAsync(token); - return; - } - - ExtractDefaultResources(_appSettings.Paths.OsuFolderPath, token); - - var skinsDir = Path.Combine(_appSettings.Paths.OsuFolderPath, "Skins"); - if (!Directory.Exists(skinsDir)) return; - - var directories = Directory.EnumerateDirectories(skinsDir); - var loadedSkins = new List(); - - foreach (var dir in directories) - { - if (token.IsCancellationRequested) return; - var iniPath = Path.Combine(dir, "skin.ini"); - string? name = null; - string? author = null; - if (File.Exists(iniPath)) - { - (name, author) = ReadIniFile(iniPath); - } - - var skinDescription = new SkinDescription(Path.GetFileName(dir), dir, name, author); - _logger.LogDebug("Find skin: {SkinDescription}", skinDescription); - loadedSkins.Add(skinDescription); - } - - var newSkinList = new List { SkinDescription.Internal, SkinDescription.Classic }; - newSkinList.AddRange(OrderUserSkins(loadedSkins)); - - await PublishSkinListAsync(newSkinList, token); - } - - private async Task LoadLazerSkinsAsync(CancellationToken token) - { - _lazerSkinCatalogs.Clear(); - - var newSkinList = new List { SkinDescription.Internal }; - - // 5 built-in skins (mirrors stable's behavior: always available). - foreach (var (_, description) in s_lazerBuiltinSkins) - { - newSkinList.Add(description); - } - - // Extract default gameplay audio from osu.Game.Resources.dll for built-in skins. - ExtractLazerDefaultResources(token); - - // User skins from lazer realm (via IPC). - var lazerUserSkins = new List(); - if (_lazerSkinInfos != null) - { - foreach (var info in _lazerSkinInfos) - { - if (info.Protected) - continue; // Built-in skins are added separately with stable FolderNames. - - if (token.IsCancellationRequested) return; - - var folder = Path.Combine(_lazerUserDataDirectory ?? "", "files", info.Id); - var folderName = info.Name ?? info.Id; - - // Build a resource catalog from the skin's files so audio can be - // resolved by name to the actual hash-based file store paths. - if (info.Files.Length > 0) - { - var catalog = BeatmapResourceCatalog.FromMappings( - info.Files.Select(f => new BeatmapResource(f.Name, f.Path)), - folder, - $"lazer-skin:{info.Id}"); - _lazerSkinCatalogs[folder] = catalog; - } - - lazerUserSkins.Add(new SkinDescription( - folderName, - folder, - info.Name, - info.Creator)); - } - } - - newSkinList.AddRange(OrderUserSkins(lazerUserSkins)); - - await PublishSkinListAsync(newSkinList, token); - } - - /// - /// Sort user skins alphabetically by their display description (case-insensitive, ordinal), - /// matching the ordering users expect from osu! stable/lazer skin dropdowns. - /// - private static IEnumerable OrderUserSkins(IEnumerable userSkins) - => userSkins.OrderBy(static s => s.Description, StringComparer.OrdinalIgnoreCase); - - private async Task PublishSkinListAsync(List newSkinList, CancellationToken token) - { - var selectedName = _appSettings.Paths.SelectedSkinName; - var targetSkin = newSkinList.FirstOrDefault(k => k.FolderName == selectedName) - ?? SkinDescription.Internal; - - await UiDispatcher.InvokeAsync(() => - { - if (token.IsCancellationRequested) return; - _sharedViewModel.Skins.Clear(); - var type = SynchronizationContext.Current.GetType(); - if (type.Namespace == "System.Windows.Threading") - { - foreach (var skinDescription in newSkinList) - { - _sharedViewModel.Skins.Add(skinDescription); - } - } - else - { - _sharedViewModel.Skins.AddRange(newSkinList); - } - - _sharedViewModel.SelectedSkin = targetSkin; - }); - } - - private void ExtractDefaultResources(string osuPath, CancellationToken token) - { - if (_stableDefaultResources.Count > 0) return; - var dllPath = Path.Combine(osuPath, "osu!gameplay.dll"); - if (!File.Exists(dllPath)) - { - return; - } - - try - { - if (token.IsCancellationRequested) return; - - using var module = ModuleDefMD.Load(dllPath); - var resource = module.Resources.FindEmbeddedResource("osu_gameplay.ResourcesStore.resources"); - if (resource == null) - { - return; - } - - using var stream = resource.CreateReader().AsStream(); - using var reader = new System.Resources.ResourceReader(stream); - - foreach (var resourcesKey in s_resourcesKeys) - { - try - { - reader.GetResourceData(resourcesKey, out var resourceType, out var resourceData); - - if (!resourceType.Contains("ResourceTypeCode.ByteArray")) return; - // [ 长度 (Int32, 4字节) ] + [ 实际数据 (N字节) ] - if (resourceData.Length <= 4) return; - - var bytes = resourceData.AsSpan(4).ToArray(); - _stableDefaultResources[resourcesKey] = bytes; - _logger.LogDebug("Extracted '{ResourcesKey}' ({Bytes} bytes)", resourcesKey, bytes.Length); - } - catch (ArgumentException) - { - _logger.LogWarning("Resource '{ResourcesKey}' not found in osu!gameplay.dll", resourcesKey); - } - } - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to extract default resources from osu!gameplay.dll"); - } - } - - private void ExtractLazerDefaultResources(CancellationToken token) - { - if (_lazerDefaultResources.Count > 0) return; - - var exeDir = _lazerExeDirectory; - if (string.IsNullOrEmpty(exeDir)) - { - _logger.LogDebug("Lazer exe directory not available; skipping default resource extraction."); - return; - } - - var dllPath = Path.Combine(exeDir, "osu.Game.Resources.dll"); - if (!File.Exists(dllPath)) - { - _logger.LogDebug("osu.Game.Resources.dll not found at {Path}", dllPath); - return; - } - - try - { - if (token.IsCancellationRequested) return; - - using var module = ModuleDefMD.Load(dllPath); - var assemblyName = module.Assembly?.Name ?? "osu.Game.Resources"; - - // Build a lookup of all embedded resource names. - var resourceNames = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var res in module.Resources) - { - if (res is EmbeddedResource emb) - resourceNames.Add(emb.Name); - } - - // For each built-in skin, extract its samples from the corresponding resource path. - // e.g. argon → "osu.Game.Resources.Samples.Gameplay.Argon.normal-hitnormal.wav" - // classic → "osu.Game.Resources.Skins.Legacy.normal-hitnormal.wav" - foreach (var (skinFolder, pathPrefix) in s_lazerBuiltinResourcePrefixes) - { - var dotPrefix = pathPrefix.Replace('/', '.'); - - foreach (var key in s_resourcesKeys) - { - if (token.IsCancellationRequested) return; - - // Try .wav > .mp3 > .ogg in priority order - foreach (var ext in new[] { ".wav", ".mp3", ".ogg" }) - { - var manifestName = $"{assemblyName}.{dotPrefix}.{key}{ext}"; - if (!resourceNames.Contains(manifestName)) - continue; - - var emb = module.Resources.FindEmbeddedResource(manifestName); - if (emb == null) break; - - try - { - using var stream = emb.CreateReader().AsStream(); - using var ms = new MemoryStream(); - stream.CopyTo(ms); - var storageKey = $"{skinFolder}:{key}"; - _lazerDefaultResources[storageKey] = ms.ToArray(); - _logger.LogDebug("Extracted '{Key}' for {Skin} from osu.Game.Resources.dll ({Bytes} bytes)", - key, skinFolder, _lazerDefaultResources[storageKey].Length); - break; - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to extract '{Key}' for {Skin} from osu.Game.Resources.dll", - key, skinFolder); - break; - } - } - } - } - - _logger.LogInformation("Extracted {Count} lazer default audio resources from osu.Game.Resources.dll", - _lazerDefaultResources.Count); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to extract default resources from osu.Game.Resources.dll"); - } - } - - private static (string?, string?) ReadIniFile(string iniFile) - { - string? name = null; - string? author = null; - - using var fs = File.Open(iniFile, FileMode.Open, FileAccess.Read, FileShare.Read); - using var sr = new StreamReader(fs); - - using var lineReader = new EphemeralLineReader(sr); - ReadOnlyMemory? currentLineMemory; - - while ((currentLineMemory = lineReader.ReadLine()) != null) - { - var lineSpan = currentLineMemory.Value.Span; - - var commentIndex = lineSpan.IndexOf("//"); - if (commentIndex >= 0) - { - lineSpan = lineSpan.Slice(0, commentIndex); - } - - var trimmedLineSpan = lineSpan.Trim(); - - if (trimmedLineSpan.StartsWith("Name:", StringComparison.OrdinalIgnoreCase)) - { - name = trimmedLineSpan.Slice(5).TrimStart().ToString(); - } - else if (trimmedLineSpan.StartsWith("Author:", StringComparison.OrdinalIgnoreCase)) - { - author = trimmedLineSpan.Slice(7).TrimStart().ToString(); - } - - if (name is not null && author is not null) - { - break; - } - } - - return (name, author); - } - - private void StopRefreshTask() - { - if (_skinLoadCts != null) - { - _skinLoadCts.Cancel(); - _skinLoadCts.Dispose(); - } - - _skinLoadCts = null; - } -} \ No newline at end of file diff --git a/src/Common/KeyAsio.Shared/Sync/AudioProviders/CatchHitsoundSequencer.cs b/src/Common/KeyAsio.Shared/Sync/AudioProviders/CatchHitsoundSequencer.cs deleted file mode 100644 index c6a1ad84..00000000 --- a/src/Common/KeyAsio.Shared/Sync/AudioProviders/CatchHitsoundSequencer.cs +++ /dev/null @@ -1,185 +0,0 @@ -using System.Runtime.CompilerServices; -using KeyAsio.Core.Audio; -using KeyAsio.Core.OsuAudio.Hitsounds.Playback; -using KeyAsio.Shared.Models; -using KeyAsio.Shared.Sync.Services; -using Microsoft.Extensions.Logging; - -namespace KeyAsio.Shared.Sync.AudioProviders; - -public class CatchHitsoundSequencer : IHitsoundSequencer -{ - private const int AudioLatencyTolerance = 200; - - private readonly ILogger _logger; - private readonly AppSettings _appSettings; - private readonly SyncSessionContext _syncSessionContext; - private readonly IPlaybackEngine _playbackEngine; - private readonly GameplayAudioService _gameplayAudioService; - private readonly GameplaySessionManager _gameplaySessionManager; - - private Queue _hitQueue = new(); - private Queue _playbackQueue = new(); - - public CatchHitsoundSequencer(ILogger logger, - AppSettings appSettings, - SyncSessionContext syncSessionContext, - IPlaybackEngine playbackEngine, - GameplayAudioService gameplayAudioService, - GameplaySessionManager gameplaySessionManager) - { - _logger = logger; - _appSettings = appSettings; - _syncSessionContext = syncSessionContext; - _playbackEngine = playbackEngine; - _gameplayAudioService = gameplayAudioService; - _gameplaySessionManager = gameplaySessionManager; - } - - public int KeyThresholdMilliseconds { get; set; } = 100; - - public void SeekTo(int playTime) - { - _hitQueue = new Queue( - _gameplaySessionManager.KeyList.Where(k => k.Offset >= playTime - KeyThresholdMilliseconds) - ); - _playbackQueue = new Queue(_gameplaySessionManager.PlaybackList - .Where(k => k.Offset >= playTime)); - } - - public void ProcessAutoPlay(List buffer, bool processHitQueueAsAuto) - { - if (!IsEngineReady()) return; - - var playTime = _syncSessionContext.PlayTime; - - // In Catch mode (Auto-Play approach), we treat hit objects as auto-played sounds. - // Regardless of whether it's replay/auto or manual play, we play the hit sounds. - - // Process background sounds (slider ticks, etc.) - ProcessTimeBasedQueue(buffer, _playbackQueue, playTime); - - // Process hit objects (fruits, drops) - ProcessTimeBasedQueue(buffer, _hitQueue, playTime); - } - - public void ProcessInteraction(List buffer, int keyIndex, int keyTotal) - { - // Catch mode (Auto-Play approach) does not require user interaction to trigger sounds. - // Sounds are triggered automatically based on time. - } - - public void FillAudioList(IReadOnlyList nodeList, List keyList, - List playbackList) - { - // Use logic similar to Standard mode - var secondaryCache = new List(); - var options = _appSettings.Sync; - - foreach (var hitsoundNode in nodeList) - { - if (hitsoundNode is not SampleEvent playableNode) - { - if (hitsoundNode is ControlEvent controlEvent && - controlEvent.ControlEventType != ControlEventType.Balance && - controlEvent.ControlEventType != ControlEventType.None && - !options.Filters.DisableSliderTicksAndSlides) - { - playbackList.Add(controlEvent); - } - - continue; - } - - switch (playableNode.Layer) - { - case SampleLayer.Primary: - CheckSecondary(); - secondaryCache.Clear(); - keyList.Add(playableNode); - break; - case SampleLayer.Secondary: - if (options.Playback.TailPlaybackBehavior == SliderTailPlaybackBehavior.Normal) - playbackList.Add(playableNode); - else if (options.Playback.TailPlaybackBehavior == SliderTailPlaybackBehavior.KeepReverse) - secondaryCache.Add(playableNode); - break; - case SampleLayer.Sampling: - if (!options.Filters.DisableStoryboardSamples) - { - playbackList.Add(playableNode); - } - - break; - } - } - - CheckSecondary(); - - void CheckSecondary() - { - if (secondaryCache.Count > 0) - { - if (options.Playback.TailPlaybackBehavior == SliderTailPlaybackBehavior.KeepReverse) - { - playbackList.AddRange(secondaryCache); - } - - secondaryCache.Clear(); - } - } - } - - private void ProcessTimeBasedQueue(List buffer, Queue queue, int playTime) - where T : PlaybackEvent - { - while (queue.TryPeek(out var node)) - { - if (playTime < node.Offset) - { - // Not yet time - break; - } - - bool mustDispatchControlSignal = node is ControlEvent - { - ControlEventType: ControlEventType.LoopStop or ControlEventType.Volume or ControlEventType.Balance - }; - - // Control signals must never be dropped because they release/update active loops. - if (mustDispatchControlSignal) - { - buffer.Add(new PlaybackInfo(null, node)); - } - // Only play if within tolerance - else if (playTime < node.Offset + AudioLatencyTolerance) - { - if (_gameplayAudioService.TryGetAudioByNode(node, out var cachedSound)) - { - buffer.Add(new PlaybackInfo(cachedSound, node)); - } - } - - // Dequeue regardless of whether played (played, timed out, or missing resource) - queue.Dequeue(); - } - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private bool IsEngineReady() - { - if (_playbackEngine.CurrentDevice == null) - { - _logger.LogWarning("Engine not ready."); - return false; - } - - if (!_syncSessionContext.IsStarted) - { - _logger.LogInformation("Game hasn't started."); - return false; - } - - return true; - } -} \ No newline at end of file diff --git a/src/Common/KeyAsio.Shared/Sync/AudioProviders/ManiaHitsoundSequencer.cs b/src/Common/KeyAsio.Shared/Sync/AudioProviders/ManiaHitsoundSequencer.cs deleted file mode 100644 index 65dbf3ba..00000000 --- a/src/Common/KeyAsio.Shared/Sync/AudioProviders/ManiaHitsoundSequencer.cs +++ /dev/null @@ -1,241 +0,0 @@ -using KeyAsio.Core.Audio; -using KeyAsio.Core.OsuAudio.Hitsounds.Playback; -using KeyAsio.Shared.Models; -using KeyAsio.Shared.Sync.Services; -using KeyAsio.Shared.Utils; -using Microsoft.Extensions.Logging; - -namespace KeyAsio.Shared.Sync.AudioProviders; - -public class ManiaHitsoundSequencer : IHitsoundSequencer -{ - private readonly ILogger _logger; - private readonly AppSettings _appSettings; - private readonly SyncSessionContext _syncSessionContext; - private readonly IPlaybackEngine _playbackEngine; - private readonly GameplayAudioService _gameplayAudioService; - private readonly GameplaySessionManager _gameplaySessionManager; - - private List> _hitQueue = new(); - private SampleEvent?[] _hitQueueCache = Array.Empty(); - - private Queue _playQueue = new(); - private Queue _autoPlayQueue = new(); - - private PlaybackEvent? _firstAutoNode; - private PlaybackEvent? _firstPlayNode; - - public ManiaHitsoundSequencer(ILogger logger, - AppSettings appSettings, - SyncSessionContext syncSessionContext, - IPlaybackEngine playbackEngine, - GameplayAudioService gameplayAudioService, - GameplaySessionManager gameplaySessionManager) - { - _logger = logger; - _appSettings = appSettings; - _syncSessionContext = syncSessionContext; - _playbackEngine = playbackEngine; - _gameplayAudioService = gameplayAudioService; - _gameplaySessionManager = gameplaySessionManager; - } - - public void ProcessAutoPlay(List buffer, bool processHitQueueAsAuto) - { - var playTime = _syncSessionContext.PlayTime; - var isStarted = _syncSessionContext.IsStarted; - - if (_playbackEngine.CurrentDevice == null) - { - _logger.LogWarning("Engine not ready, return empty."); - return; - } - - if (!isStarted) - { - _logger.LogWarning("Game hasn't started, return empty."); - return; - } - - var first = processHitQueueAsAuto ? _firstPlayNode : _firstAutoNode; - if (first == null) - { - return; - _logger.LogWarning("First is null, no item returned."); - } - - if (playTime < first.Offset) - { - return; - _logger.LogWarning("Haven't reached first, no item returned."); - } - - FillNextPlaybackAudio(buffer, first, playTime, processHitQueueAsAuto); - } - - public void ProcessInteraction(List buffer, int keyIndex, int keyTotal) - { - using var _ = DebugUtils.CreateTimer($"GetSoundOnClick", _logger); - var playTime = _syncSessionContext.PlayTime; - var isStarted = _syncSessionContext.IsStarted; - - if (_playbackEngine.CurrentDevice == null) - { - _logger.LogWarning("Engine not ready, return empty."); - return; - } - - if (!isStarted) - { - _logger.LogWarning("Game hasn't started, return empty."); - return; - } - - if (_hitQueue.Count - 1 < keyIndex || _hitQueueCache.Length - 1 < keyIndex) - { - _logger.LogWarning( - "Key index was out of range ({KeyIndex}). Please check your key configuration to match mania columns.", - keyIndex); - return; - } - - var queue = _hitQueue[keyIndex]; - while (true) - { - if (queue.TryPeek(out var node)) - { - if (playTime < node.Offset - 80 /*odMax*/) - { - _hitQueueCache[keyIndex] = null; - break; - } - - if (playTime <= node.Offset + 50 /*odMax*/) - { - _hitQueueCache[keyIndex] = queue.Dequeue(); - _logger.LogInformation("Dequeued and will use Col." + keyIndex); - break; - } - - queue.Dequeue(); - _logger.LogInformation("Dropped Col." + keyIndex); - _hitQueueCache[keyIndex] = null; - } - else - { - _hitQueueCache[keyIndex] = null; - break; - } - } - - var playableNode = _hitQueueCache[keyIndex]; - if (playableNode == null) - { - _hitQueue[keyIndex].TryPeek(out playableNode); - _logger.LogDebug("Use first"); - } - else - { - _logger.LogDebug("Use cache"); - } - - if (playableNode == null || !_gameplayAudioService.TryGetAudioByNode(playableNode, out var cachedAudio)) - { - _logger.LogWarning("No audio returned."); - } - else - { - buffer.Add(new PlaybackInfo(cachedAudio, playableNode)); - } - } - - public void FillAudioList(IReadOnlyList nodeList, List keyList, - List playbackList) - { - foreach (var hitsoundNode in nodeList) - { - if (hitsoundNode is not SampleEvent playableNode) continue; - - if (playableNode.Layer is SampleLayer.Sampling) - { - if (!_appSettings.Sync.Filters.DisableStoryboardSamples) - { - playbackList.Add(playableNode); - } - } - else - { - keyList.Add(playableNode); - } - } - } - - public void SeekTo(int playTime) - { - _hitQueue = GetHitQueue(_gameplaySessionManager.KeyList, playTime); - _hitQueueCache = new SampleEvent[_hitQueue.Count]; - - _autoPlayQueue = new Queue(_gameplaySessionManager.KeyList); - _playQueue = new Queue(_gameplaySessionManager.PlaybackList.Where(k => k.Offset >= playTime)); - _autoPlayQueue.TryDequeue(out _firstAutoNode); - _playQueue.TryDequeue(out _firstPlayNode); - } - - private List> GetHitQueue(IReadOnlyList keyList, int playTime) - { - if (_gameplaySessionManager.OsuFile == null) - return new List>(); - - var keyCount = (int)_gameplaySessionManager.OsuFile.Difficulty.CircleSize; - var list = new List>(keyCount); - for (int i = 0; i < keyCount; i++) - { - list.Add(new Queue()); - } - - foreach (var playableNode in keyList.Where(k => k.Offset >= playTime)) - { - var ratio = (playableNode.Balance + 1d) / 2; - var column = (int)Math.Round(ratio * keyCount - 0.5); - list[column].Enqueue(playableNode); - } - - return list; - } - - private void FillNextPlaybackAudio(List buffer, PlaybackEvent? firstNode, int playTime, - bool includeKey) - { - while (firstNode != null) - { - if (playTime < firstNode.Offset) - { - break; - } - - if (playTime < firstNode.Offset + 200 && - _gameplayAudioService.TryGetAudioByNode(firstNode, out var cachedSound)) - { - buffer.Add(new PlaybackInfo(cachedSound, firstNode)); - } - - if (includeKey) - { - _playQueue.TryDequeue(out firstNode); - } - else - { - _autoPlayQueue.TryDequeue(out firstNode); - } - } - - if (includeKey) - { - _firstPlayNode = firstNode; - } - else - { - _firstAutoNode = firstNode; - } - } -} \ No newline at end of file diff --git a/src/Common/KeyAsio.Shared/Sync/AudioProviders/StandardHitsoundSequencer.cs b/src/Common/KeyAsio.Shared/Sync/AudioProviders/StandardHitsoundSequencer.cs deleted file mode 100644 index 635657b4..00000000 --- a/src/Common/KeyAsio.Shared/Sync/AudioProviders/StandardHitsoundSequencer.cs +++ /dev/null @@ -1,269 +0,0 @@ -using System.Runtime.CompilerServices; -using KeyAsio.Core.Audio; -using KeyAsio.Core.OsuAudio.Hitsounds.Playback; -using KeyAsio.Shared.Models; -using KeyAsio.Shared.Sync.Services; -using Microsoft.Extensions.Logging; - -namespace KeyAsio.Shared.Sync.AudioProviders; - -public class StandardHitsoundSequencer : IHitsoundSequencer -{ - private const int AudioLatencyTolerance = 200; - - private readonly ILogger _logger; - private readonly AppSettings _appSettings; - private readonly SyncSessionContext _syncSessionContext; - private readonly IPlaybackEngine _playbackEngine; - private readonly GameplayAudioService _gameplayAudioService; - private readonly GameplaySessionManager _gameplaySessionManager; - - private Queue _hitQueue = new(); - private Queue _playbackQueue = new(); - - public StandardHitsoundSequencer(ILogger logger, - AppSettings appSettings, - SyncSessionContext syncSessionContext, - IPlaybackEngine playbackEngine, - GameplayAudioService gameplayAudioService, - GameplaySessionManager gameplaySessionManager) - { - _logger = logger; - _appSettings = appSettings; - _syncSessionContext = syncSessionContext; - _playbackEngine = playbackEngine; - _gameplayAudioService = gameplayAudioService; - _gameplaySessionManager = gameplaySessionManager; - } - - public int KeyThresholdMilliseconds { get; set; } = 100; - - public void SeekTo(int playTime) - { - _hitQueue = new Queue( - _gameplaySessionManager.KeyList.Where(k => k.Offset >= playTime - KeyThresholdMilliseconds) - ); - _playbackQueue = new Queue(_gameplaySessionManager.PlaybackList - .Where(k => k.Offset >= playTime)); - } - - public void ProcessAutoPlay(List buffer, bool processHitQueueAsAuto) - { - if (!IsEngineReady()) return; - - var first = processHitQueueAsAuto - ? (_playbackQueue.TryPeek(out var pNode) ? pNode : null) - : (_hitQueue.TryPeek(out var hNode) ? hNode : null); - - if (first == null) - { - return; - } - - var playTime = _syncSessionContext.PlayTime; - if (playTime < first.Offset) - { - return; - } - - // 如果需要,将 HitQueue 当作自动播放处理(例如回放模式或 Auto 模式) - if (processHitQueueAsAuto) - { - ProcessTimeBasedQueue(buffer, _playbackQueue, playTime); - } - else - { - ProcessTimeBasedQueue(buffer, _hitQueue, playTime); - } - } - - public void ProcessInteraction(List buffer, int keyIndex, int keyTotal) - { - if (!IsEngineReady()) return; - - var playTime = _syncSessionContext.PlayTime; - - // 用于处理同组音符(堆叠/Chord) - bool hasHit = false; - Guid? currentGroupGuid = null; - - // 循环处理队列,直到: - // 1. 队列空了 - // 2. 判定太早(Early Reject) - // 3. 判定命中(Hit)并处理完该组所有音符 - while (_hitQueue.TryPeek(out var node)) - { - // 计算时间差 - // diff > 0: 此时刻在音符之后 (Late) - // diff < 0: 此时刻在音符之前 (Early) - var diff = playTime - node.Offset; - - // --- 情况 1: 已经命中过,正在处理同组音符 (Chord) --- - if (hasHit) - { - // 如果 GUID 变了,说明这一组(Chord)处理完了,停止 - if (node.Guid != currentGroupGuid) - { - return; - } - - // 同组音符,直接播放,不需要再判定时间 - DequeueAndPlay(buffer, _hitQueue); - continue; - } - - // --- 情况 2: 音符已彻底过期 (Missed) --- - // 例如:当前 1500ms,音符 1000ms,阈值 100ms。 - // 1500 > 1000 + 100 -> 过期。 - if (diff > KeyThresholdMilliseconds) - { - //_logger.LogDebug("Pruning expired node at {Offset} (Current: {PlayTime})", node.Offset, playTime); - _hitQueue.Dequeue(); // 移除过期音符 - continue; // 【关键修复】:不返回,继续用当次点击去检查下一个音符! - } - - // --- 情况 3: 点击太早 (Too Early) --- - // 例如:当前 800ms,音符 1000ms,阈值 100ms。 - // 800 < 1000 - 100 -> 太早。 - if (diff < -KeyThresholdMilliseconds) - { - // 还没到判定窗口,且因为队列是有序的,后面的肯定也更早。 - // 停止处理,等待时间流逝。 - return; - } - - // --- 情况 4: 命中判定窗口 (Hit) --- - // 代码能走到这,说明:Offset - Threshold <= playTime <= Offset + Threshold - - //_logger.LogDebug("Hit node at {Offset} (Current: {PlayTime})", node.Offset, playTime); - - // 记录状态,标记为已命中 - hasHit = true; - currentGroupGuid = node.Guid; - - // 播放并移除 - DequeueAndPlay(buffer, _hitQueue); - - // 循环继续,去检查是否还有同 GUID 的重叠音符 - } - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private bool IsEngineReady() - { - if (_playbackEngine.CurrentDevice == null) - { - _logger.LogWarning("Engine not ready."); - return false; - } - - if (!_syncSessionContext.IsStarted) - { - _logger.LogInformation("Game hasn't started."); - return false; - } - - return true; - } - - public void FillAudioList(IReadOnlyList nodeList, List keyList, - List playbackList) - { - var secondaryCache = new List(); - var options = _appSettings.Sync; - - foreach (var hitsoundNode in nodeList) - { - if (hitsoundNode is not SampleEvent playableNode) - { - if (hitsoundNode is ControlEvent controlNode && - controlNode.ControlEventType != ControlEventType.Balance && - controlNode.ControlEventType != ControlEventType.None && - !options.Filters.DisableSliderTicksAndSlides) - { - playbackList.Add(controlNode); - } - - continue; - } - - switch (playableNode.Layer) - { - case SampleLayer.Primary: - CheckSecondary(); - secondaryCache.Clear(); - keyList.Add(playableNode); - break; - case SampleLayer.Secondary: - if (options.Playback.TailPlaybackBehavior == SliderTailPlaybackBehavior.Normal) - playbackList.Add(playableNode); - else if (options.Playback.TailPlaybackBehavior == SliderTailPlaybackBehavior.KeepReverse) - secondaryCache.Add(playableNode); - break; - case SampleLayer.Effects: - if (!options.Filters.DisableSliderTicksAndSlides) - playbackList.Add(playableNode); - break; - case SampleLayer.Sampling: - if (!options.Filters.DisableStoryboardSamples) - playbackList.Add(playableNode); - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - CheckSecondary(); - - void CheckSecondary() - { - if (secondaryCache.Count <= 1) return; - playbackList.AddRange(secondaryCache); - } - } - - private void ProcessTimeBasedQueue(List buffer, Queue queue, int playTime) - where T : PlaybackEvent - { - while (queue.TryPeek(out var node)) - { - if (playTime < node.Offset) - { - // 时间未到 - break; - } - - bool mustDispatchControlSignal = node is ControlEvent - { - ControlEventType: ControlEventType.LoopStop or ControlEventType.Volume or ControlEventType.Balance - }; - - // LoopStop/Volume/Balance 是控制信号,绝不能因为延迟或缓存未命中被丢弃 - if (mustDispatchControlSignal) - { - buffer.Add(new PlaybackInfo(null, node)); - } - - // 其他事件仍遵循延迟容忍窗口 - else if (playTime < node.Offset + AudioLatencyTolerance) - { - if (_gameplayAudioService.TryGetAudioByNode(node, out var cachedSound)) - { - buffer.Add(new PlaybackInfo(cachedSound, node)); - } - } - - // 无论是否播放(播放了 or 超时了 or 找不到资源),只要时间到了就移除 - queue.Dequeue(); - } - } - - private void DequeueAndPlay(List buffer, Queue queue) where T : PlaybackEvent - { - var node = queue.Dequeue(); - if (_gameplayAudioService.TryGetAudioByNode(node, out var cachedSound)) - { - buffer.Add(new PlaybackInfo(cachedSound, node)); - } - } -} \ No newline at end of file diff --git a/src/Common/KeyAsio.Shared/Sync/AudioProviders/TaikoHitsoundSequencer.cs b/src/Common/KeyAsio.Shared/Sync/AudioProviders/TaikoHitsoundSequencer.cs deleted file mode 100644 index db391455..00000000 --- a/src/Common/KeyAsio.Shared/Sync/AudioProviders/TaikoHitsoundSequencer.cs +++ /dev/null @@ -1,398 +0,0 @@ -using System.Diagnostics; -using System.Runtime.CompilerServices; -using KeyAsio.Core.Audio; -using KeyAsio.Core.Audio.Caching; -using KeyAsio.Core.OsuAudio.Hitsounds.Playback; -using KeyAsio.Shared.Models; -using KeyAsio.Shared.Sync.Services; -using Microsoft.Extensions.Logging; - -namespace KeyAsio.Shared.Sync.AudioProviders; - -public class TaikoHitsoundSequencer : IHitsoundSequencer -{ - private const int AudioLatencyTolerance = 200; - - private readonly ILogger _logger; - private readonly AppSettings _appSettings; - private readonly SyncSessionContext _syncSessionContext; - private readonly IPlaybackEngine _playbackEngine; - private readonly GameplayAudioService _gameplayAudioService; - private readonly GameplaySessionManager _gameplaySessionManager; - - private Queue _hitQueue = new(); - private Queue _playbackQueue = new(); - - private long _lastDonTime = 0; - private long _lastKatTime = 0; - private const double MinIntervalMs = 30.0; - - public TaikoHitsoundSequencer(ILogger logger, - AppSettings appSettings, - SyncSessionContext syncSessionContext, - IPlaybackEngine playbackEngine, - GameplayAudioService gameplayAudioService, - GameplaySessionManager gameplaySessionManager) - { - _logger = logger; - _appSettings = appSettings; - _syncSessionContext = syncSessionContext; - _playbackEngine = playbackEngine; - _gameplayAudioService = gameplayAudioService; - _gameplaySessionManager = gameplaySessionManager; - } - - public int KeyThresholdMilliseconds { get; set; } = 100; - - public void SeekTo(int playTime) - { - _hitQueue = new Queue( - _gameplaySessionManager.KeyList.Where(k => k.Offset >= playTime - KeyThresholdMilliseconds) - ); - _playbackQueue = new Queue(_gameplaySessionManager.PlaybackList - .Where(k => k.Offset >= playTime)); - } - - public void ProcessAutoPlay(List buffer, bool processHitQueueAsAuto) - { - if (!IsEngineReady()) return; - - var first = processHitQueueAsAuto - ? (_playbackQueue.TryPeek(out var pNode) ? pNode : null) - : (_hitQueue.TryPeek(out var hNode) ? hNode : null); - - if (first == null) - { - return; - } - - var playTime = _syncSessionContext.PlayTime; - if (playTime < first.Offset) - { - return; - } - - // 如果需要,将 HitQueue 当作自动播放处理(例如回放模式或 Auto 模式) - if (processHitQueueAsAuto) - { - ProcessTimeBasedQueue(buffer, _playbackQueue, playTime); - } - else - { - ProcessTimeBasedQueue(buffer, _hitQueue, playTime); - } - } - - public void ProcessInteraction(List buffer, int keyIndex, int keyTotal) - { - if (!IsEngineReady()) return; - - var playTime = _syncSessionContext.PlayTime; - - // 用于处理同组音符(堆叠/Chord) - bool hasHit = false; - Guid? currentGroupGuid = null; - - // Taiko Logic: - // KeyTotal usually is 4 (KDDK) - // 0: Kat (Left Rim) - // 1: Don (Left Center) - // 2: Don (Right Center) - // 3: Kat (Right Rim) - bool isDonInput = keyIndex == 1 || keyIndex == 2; - bool isKatInput = keyIndex == 0 || keyIndex == 3; - - // 获取参考 Note 用于空打音效 - PlaybackEvent? refNode = null; - - if (_hitQueue.TryPeek(out var headNode)) - { - refNode = headNode; - } - else if (_playbackQueue.TryPeek(out var pbNode)) - { - refNode = pbNode; - } - - bool soundPlayed = false; - - // 借用 mania 的逻辑来处理 Taiko 的判定,Chord 实际上不应存在 - - // 循环处理队列,直到: - // 1. 队列空了 - // 2. 判定太早(Early Reject) - // 3. 判定命中(Hit)并处理完该组所有音符 - while (_hitQueue.TryPeek(out var node)) - { - // 计算时间差 - // diff > 0: 此时刻在音符之后 (Late) - // diff < 0: 此时刻在音符之前 (Early) - var diff = playTime - node.Offset; - - // --- 情况 1: 已经命中过,正在处理同组音符 (Chord) --- - if (hasHit) - { - // 如果 GUID 变了,说明这一组(Chord)处理完了,停止 - if (node.Guid != currentGroupGuid) - { - return; - } - - DequeueAndPlay(buffer, _hitQueue); - continue; - } - - // --- 情况 2: 音符已彻底过期 (Missed) --- - if (diff > KeyThresholdMilliseconds) - { - _hitQueue.Dequeue(); // 移除过期音符 - // 更新参考 Note - if (_hitQueue.TryPeek(out var nextNode)) refNode = nextNode; - continue; - } - - // --- 情况 3: 点击太早 (Too Early) --- - if (diff < -KeyThresholdMilliseconds) - { - break; - } - - // --- 情况 4: 命中判定窗口 (Hit) --- - - // Check Note Type - // Filename check is a heuristic - var filename = node.Filename ?? ""; - bool isKatNode = filename.Contains("clap", StringComparison.OrdinalIgnoreCase) || - filename.Contains("whistle", StringComparison.OrdinalIgnoreCase); - bool isDonNode = !isKatNode; - - bool typeMatch = (isDonInput && isDonNode) || (isKatInput && isKatNode); - - if (typeMatch) - { - // 记录状态,标记为已命中 - hasHit = true; - currentGroupGuid = node.Guid; - - // 播放并移除 - if (ShouldPlaySound(isDonInput)) - { - DequeueAndPlay(buffer, _hitQueue); - UpdateLastPlayTime(isDonInput); - soundPlayed = true; - } - else - { - // 防抖生效:只移除不播放 - _hitQueue.Dequeue(); - soundPlayed = true; // 视为已处理,避免触发后续空打逻辑 - } - } - else - { - // 类型不匹配 - // 消耗掉音符,不播放。 - _hitQueue.Dequeue(); - refNode = node; // 使用这个 Miss 的 Note 作为空打声音的参考 - - // 既然这次点击消耗了这个音符(判定为 Miss),那么我们就不能再用这次点击去匹配其他音符了。 - break; - } - } - - if (!soundPlayed && refNode != null) - { - if (ShouldPlaySound(isDonInput)) - { - if (GetFallbackAudio(refNode, isDonInput, out var cachedAudio)) - { - buffer.Add(new PlaybackInfo(cachedAudio!, refNode)); - UpdateLastPlayTime(isDonInput); - } - } - } - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private bool ShouldPlaySound(bool isDon) - { - long currentTimestamp = Stopwatch.GetTimestamp(); - long lastTime = isDon ? _lastDonTime : _lastKatTime; - double elapsedMs = (currentTimestamp - lastTime) * 1000.0 / Stopwatch.Frequency; - return elapsedMs >= MinIntervalMs; - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private void UpdateLastPlayTime(bool isDon) - { - if (isDon) _lastDonTime = Stopwatch.GetTimestamp(); - else _lastKatTime = Stopwatch.GetTimestamp(); - } - - private bool GetFallbackAudio(PlaybackEvent node, bool isDon, out CachedAudio? cachedAudio) - { - cachedAudio = null; - if (node.Filename == null) return false; - - var originalFilename = Path.GetFileNameWithoutExtension(node.Filename); - string newFilename = originalFilename; - - if (isDon) - { - // 确保移除 clap/whistle 等元素,只保留 normal - // 这里假设文件名格式是标准的,或者包含这些关键字 - newFilename = newFilename - .Replace("hitclap", "hitnormal") - .Replace("hitwhistle", "hitnormal") - .Replace("hitfinish", "hitnormal"); - } - else - { - // 如果是 Kat,优先使用 clap - // 如果原文件名是 hitwhistle,保留(Whistle 也是 Kat) - if (!newFilename.Contains("hitwhistle")) - { - newFilename = newFilename - .Replace("hitnormal", "hitclap") - .Replace("hitfinish", "hitclap"); - } - } - - if (_gameplayAudioService.TryGetCachedAudio(newFilename, out cachedAudio)) - return true; - - string sampleSet = "normal"; - if (originalFilename.Contains("soft")) sampleSet = "soft"; - else if (originalFilename.Contains("drum")) sampleSet = "drum"; - - string suffix = isDon ? "hitnormal" : "hitclap"; - - if (_gameplayAudioService.TryGetCachedAudio($"taiko-{sampleSet}-{suffix}", out cachedAudio)) - return true; - - if (_gameplayAudioService.TryGetCachedAudio($"{sampleSet}-{suffix}", out cachedAudio)) - return true; - - return false; - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private bool IsEngineReady() - { - if (_playbackEngine.CurrentDevice == null) - { - _logger.LogWarning("Engine not ready."); - return false; - } - - if (!_syncSessionContext.IsStarted) - { - _logger.LogInformation("Game hasn't started."); - return false; - } - - return true; - } - - public void FillAudioList(IReadOnlyList nodeList, List keyList, - List playbackList) - { - var secondaryCache = new List(); - var options = _appSettings.Sync; - - foreach (var hitsoundNode in nodeList) - { - if (hitsoundNode is not SampleEvent playableNode) - { - if (hitsoundNode is ControlEvent controlNode && - controlNode.ControlEventType != ControlEventType.Balance && - controlNode.ControlEventType != ControlEventType.None && - controlNode.ControlEventType != ControlEventType.LoopStart && - controlNode.ControlEventType != ControlEventType.LoopStop && - !options.Filters.DisableSliderTicksAndSlides) - { - playbackList.Add(controlNode); - } - - continue; - } - - switch (playableNode.Layer) - { - case SampleLayer.Primary: - CheckSecondary(); - secondaryCache.Clear(); - keyList.Add(playableNode); - break; - case SampleLayer.Secondary: - if (options.Playback.TailPlaybackBehavior == SliderTailPlaybackBehavior.Normal) - playbackList.Add(playableNode); - else if (options.Playback.TailPlaybackBehavior == SliderTailPlaybackBehavior.KeepReverse) - secondaryCache.Add(playableNode); - break; - case SampleLayer.Effects: - if (!options.Filters.DisableSliderTicksAndSlides) - playbackList.Add(playableNode); - break; - case SampleLayer.Sampling: - if (!options.Filters.DisableStoryboardSamples) - playbackList.Add(playableNode); - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - CheckSecondary(); - - void CheckSecondary() - { - if (secondaryCache.Count <= 1) return; - playbackList.AddRange(secondaryCache); - } - } - - private void ProcessTimeBasedQueue(List buffer, Queue queue, int playTime) - where T : PlaybackEvent - { - while (queue.TryPeek(out var node)) - { - if (playTime < node.Offset) - { - // 时间未到 - break; - } - - bool mustDispatchControlSignal = node is ControlEvent - { - ControlEventType: ControlEventType.LoopStop or ControlEventType.Volume or ControlEventType.Balance - }; - - // Loop control signals must be dispatched even if delayed. - if (mustDispatchControlSignal) - { - buffer.Add(new PlaybackInfo(null, node)); - } - // 只有在延迟容忍度内才播放 - else if (playTime < node.Offset + AudioLatencyTolerance) - { - if (_gameplayAudioService.TryGetAudioByNode(node, out var cachedSound)) - { - buffer.Add(new PlaybackInfo(cachedSound, node)); - } - } - - // 无论是否播放(播放了 or 超时了 or 找不到资源),只要时间到了就移除 - queue.Dequeue(); - } - } - - private void DequeueAndPlay(List buffer, Queue queue) where T : PlaybackEvent - { - var node = queue.Dequeue(); - if (_gameplayAudioService.TryGetAudioByNode(node, out var cachedSound)) - { - buffer.Add(new PlaybackInfo(cachedSound, node)); - } - } -} \ No newline at end of file diff --git a/src/Common/KeyAsio.Shared/Sync/ConfigManager.cs b/src/Common/KeyAsio.Shared/Sync/ConfigManager.cs deleted file mode 100644 index 32827a4c..00000000 --- a/src/Common/KeyAsio.Shared/Sync/ConfigManager.cs +++ /dev/null @@ -1,54 +0,0 @@ -using Coosu.Shared.IO; -using Milki.Extensions.MouseKeyHook; - -namespace KeyAsio.Shared.Sync; - -internal class ConfigManager -{ - private readonly AppSettings _appSettings; - - private ConfigManager(AppSettings appSettings) - { - _appSettings = appSettings; - } - - public HookKeys KeyOsuLeft { get; private set; } - public HookKeys KeyOsuRight { get; private set; } - public HookKeys KeyTaikoInnerLeft { get; private set; } - public HookKeys KeyTaikoInnerRight { get; private set; } - public HookKeys KeyTaikoOuterLeft { get; private set; } - public HookKeys KeyTaikoOuterRight { get; private set; } - - public void ReadConfigs() - { - if (_appSettings.Paths.OsuFolderPath == null) return; - var cfgFile = Path.Combine(_appSettings.Paths.OsuFolderPath, GetUserConfigFilename(Environment.UserName)); - using var fs = File.Open(cfgFile, FileMode.Open, FileAccess.Read, FileShare.Read); - using var sr = new StreamReader(fs); - - while (sr.ReadLine() is { } line) - { - var span = line.AsSpan(); - var s = span.IndexOf('='); - - if (span.Length == 0 || span[0] == '#' || s < 0) continue; - - var key = span[..s].Trim(); - var value = span[(s + 1)..].Trim(); - - if (key.Equals("keyOsuLeft", StringComparison.Ordinal)) - { - KeyOsuLeft = Enum.Parse(value); - } - else if (key.Equals("keyOsuRight", StringComparison.Ordinal)) - { - KeyOsuRight = Enum.Parse(value); - } - } - } - - private static string GetUserConfigFilename(string username) - { - return $"osu!.{PathUtils.EscapeFileName(username)}.cfg"; - } -} \ No newline at end of file diff --git a/src/Common/KeyAsio.Shared/Sync/DependencyInjectionExtensions.cs b/src/Common/KeyAsio.Shared/Sync/DependencyInjectionExtensions.cs deleted file mode 100644 index b92c1464..00000000 --- a/src/Common/KeyAsio.Shared/Sync/DependencyInjectionExtensions.cs +++ /dev/null @@ -1,35 +0,0 @@ -using KeyAsio.Plugins.Abstractions; -using KeyAsio.Shared.OsuMemory; -using KeyAsio.Shared.Plugins; -using KeyAsio.Shared.Services; -using KeyAsio.Shared.Sync.Services; -using Microsoft.Extensions.DependencyInjection; - -namespace KeyAsio.Shared.Sync; - -public static class DependencyInjectionExtensions -{ - public static IServiceCollection AddSyncModule(this IServiceCollection services) - { - services.AddSingleton(); - - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(serviceProvider => - serviceProvider.GetRequiredService()); - services.AddSingleton(serviceProvider => - serviceProvider.GetRequiredService()); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - - services.AddSingleton(); - services.AddSingleton(); - - services.AddSingleton(); - return services; - } -} diff --git a/src/Common/KeyAsio.Shared/Sync/GameStateMachine.cs b/src/Common/KeyAsio.Shared/Sync/GameStateMachine.cs deleted file mode 100644 index c6977713..00000000 --- a/src/Common/KeyAsio.Shared/Sync/GameStateMachine.cs +++ /dev/null @@ -1,68 +0,0 @@ -using KeyAsio.Plugins.Abstractions.OsuMemory; -using KeyAsio.Shared.Sync.States; - -namespace KeyAsio.Shared.Sync; - -public class GameStateMachine -{ - private readonly Dictionary _states; - - public IGameState? Current { get; private set; } - public OsuMemoryStatus CurrentStatus { get; private set; } - - public GameStateMachine(Dictionary states) - { - _states = states; - CurrentStatus = OsuMemoryStatus.NotRunning; - } - - public async Task TransitionToAsync(SyncSessionContext ctx, OsuMemoryStatus next) - { - var from = CurrentStatus; - if (!_states.TryGetValue(next, out var target)) - { - // fallback to Browsing/SongSelect if unknown status - if (_states.TryGetValue(OsuMemoryStatus.SongSelection, out var songSelect)) - { - Current?.Exit(ctx, next); - Current = songSelect; - CurrentStatus = OsuMemoryStatus.SongSelection; - await songSelect.EnterAsync(ctx, from); - } - - return; - } - - Current?.Exit(ctx, next); - Current = target; - CurrentStatus = next; - await target.EnterAsync(ctx, from); - } - - public void ExitCurrent(SyncSessionContext ctx, OsuMemoryStatus next) - { - Current?.Exit(ctx, next); - Current = null; - CurrentStatus = next; - } - - public async Task EnterFromAsync(SyncSessionContext ctx, OsuMemoryStatus from, OsuMemoryStatus next) - { - if (_states.TryGetValue(next, out var target)) - { - Current = target; - CurrentStatus = next; - await target.EnterAsync(ctx, from); - } - else - { - // Fallback logic similar to TransitionToAsync if needed, or just ignore - if (_states.TryGetValue(OsuMemoryStatus.SongSelection, out var songSelect)) - { - Current = songSelect; - CurrentStatus = OsuMemoryStatus.SongSelection; - await songSelect.EnterAsync(ctx, from); - } - } - } -} \ No newline at end of file diff --git a/src/Common/KeyAsio.Shared/Sync/IHitsoundSequencer.cs b/src/Common/KeyAsio.Shared/Sync/IHitsoundSequencer.cs deleted file mode 100644 index 772cb97f..00000000 --- a/src/Common/KeyAsio.Shared/Sync/IHitsoundSequencer.cs +++ /dev/null @@ -1,13 +0,0 @@ -using KeyAsio.Core.OsuAudio.Hitsounds.Playback; -using KeyAsio.Shared.Models; - -namespace KeyAsio.Shared.Sync; - -public interface IHitsoundSequencer -{ - void ProcessAutoPlay(List buffer, bool processHitQueueAsAuto); - void ProcessInteraction(List buffer, int keyIndex, int keyTotal); - - void FillAudioList(IReadOnlyList nodeList, List keyList, List playbackList); - void SeekTo(int playTime); -} \ No newline at end of file diff --git a/src/Common/KeyAsio.Shared/Sync/Services/BeatmapHitsoundLoader.cs b/src/Common/KeyAsio.Shared/Sync/Services/BeatmapHitsoundLoader.cs deleted file mode 100644 index b6d74ab2..00000000 --- a/src/Common/KeyAsio.Shared/Sync/Services/BeatmapHitsoundLoader.cs +++ /dev/null @@ -1,104 +0,0 @@ -using Coosu.Beatmap; -using KeyAsio.Core.OsuAudio.Hitsounds; -using KeyAsio.Core.OsuAudio.Hitsounds.Playback; -using KeyAsio.Plugins.Abstractions.OsuMemory; -using KeyAsio.Shared.Utils; -using Microsoft.Extensions.Logging; - -namespace KeyAsio.Shared.Sync.Services; - -public class BeatmapHitsoundLoader -{ - private readonly ILogger _logger; - private readonly AppSettings _appSettings; - private readonly GameplayAudioService _gameplayAudioService; - - private readonly List _keyList = new(); - private readonly List _playbackList = new(); - private int _nextCachingTime; - - public BeatmapHitsoundLoader(ILogger logger, AppSettings appSettings, - GameplayAudioService gameplayAudioService) - { - _logger = logger; - _appSettings = appSettings; - _gameplayAudioService = gameplayAudioService; - } - - public IReadOnlyList PlaybackList => _playbackList; - public List KeyList => _keyList; - - public async Task InitializeNodeListsAsync(string folder, string diffFilename, - IHitsoundSequencer hitsoundSequencer, Mods playMods, IBeatmapResourceCatalog? resourceCatalog = null) - { - _keyList.Clear(); - _playbackList.Clear(); - - var beatmapSetContext = resourceCatalog != null - ? new BeatmapSetContext(resourceCatalog) - : new BeatmapSetContext(folder); - using (DebugUtils.CreateTimer("InitFolder", _logger)) - { - await beatmapSetContext.InitializeAsync(diffFilename, - ignoreWaveFiles: _appSettings.Sync.Filters.DisableBeatmapHitsounds); - } - - if (beatmapSetContext.OsuFiles.Count <= 0) - { - _logger.LogWarning("There is no available beatmaps after scanning. Directory: {Folder}; File: {Filename}", - folder, diffFilename); - return null; - } - - var osuFile = beatmapSetContext.OsuFiles[0]; - - using var _ = DebugUtils.CreateTimer("InitAudio", _logger); - var hitsoundList = await beatmapSetContext.GetHitsoundNodesAsync(osuFile); - await Task.Delay(100); - - var isNightcore = playMods != Mods.Unknown && (playMods & Mods.Nightcore) != 0; - if (isNightcore || _appSettings.Sync.Playback.NightcoreBeats) - { - if (isNightcore) - { - _logger.LogInformation("Current Mods: {PlayMods}", playMods); - } - - var list = NightcoreBeatGenerator.GetHitsoundNodes(osuFile, TimeSpan.Zero); - hitsoundList.AddRange(list); - hitsoundList = hitsoundList.OrderBy(k => k.Offset).ToList(); - } - - hitsoundSequencer.FillAudioList(hitsoundList, _keyList, _playbackList); - return osuFile; - } - - public void CacheAllHitsounds() - { - _gameplayAudioService.PrecacheHitsoundsRangeInBackground(0, int.MaxValue, _keyList); - _gameplayAudioService.PrecacheHitsoundsRangeInBackground(0, int.MaxValue, _playbackList); - } - - public void ResetNodes(IHitsoundSequencer hitsoundSequencer, int playTime) - { - hitsoundSequencer.SeekTo(playTime); // slow - _gameplayAudioService.PrecacheHitsoundsRangeInBackground(0, 13000, _keyList); - _gameplayAudioService.PrecacheHitsoundsRangeInBackground(0, 13000, _playbackList); - _nextCachingTime = 10000; - } - - public void AdvanceCachingWindow(int newMs) - { - if (newMs > _nextCachingTime) - { - AddAudioCacheInBackground(_nextCachingTime, _nextCachingTime + 13000, _keyList); - AddAudioCacheInBackground(_nextCachingTime, _nextCachingTime + 13000, _playbackList); - _nextCachingTime += 10000; - } - } - - private void AddAudioCacheInBackground(int startTime, int endTime, IEnumerable playableNodes) - { - _gameplayAudioService.PrecacheHitsoundsRangeInBackground(startTime, endTime, playableNodes); - } -} diff --git a/src/Common/KeyAsio.Shared/Sync/Services/GameplayAudioService.cs b/src/Common/KeyAsio.Shared/Sync/Services/GameplayAudioService.cs deleted file mode 100644 index aee7d230..00000000 --- a/src/Common/KeyAsio.Shared/Sync/Services/GameplayAudioService.cs +++ /dev/null @@ -1,402 +0,0 @@ -using System.Collections.Concurrent; -using System.Diagnostics.CodeAnalysis; -using System.Runtime.CompilerServices; -using KeyAsio.Core.Audio; -using KeyAsio.Core.Audio.Caching; -using KeyAsio.Core.OsuAudio.Hitsounds; -using KeyAsio.Core.OsuAudio.Hitsounds.Playback; -using KeyAsio.Core.OsuAudio.Utils; -using KeyAsio.Shared.Models; -using KeyAsio.Shared.Services; -using KeyAsio.Shared.Utils; -using Microsoft.Extensions.Logging; -using NAudio.Wave; - -namespace KeyAsio.Shared.Sync.Services; - -public class GameplayAudioService : IDisposable -{ - private const string BeatmapCacheIdentifier = "default"; - private const string UserCacheIdentifier = "internal"; - - private static readonly string[] s_skinAudioFiles = ["combobreak"]; - - private OsuAudioFileCache _osuAudioFileCache = new(); - private readonly ConcurrentDictionary _playNodeToCachedAudioMapping = new(); - private readonly ConcurrentDictionary _filenameToCachedAudioMapping = new(); - - private readonly ILogger _logger; - private readonly SyncSessionContext _syncSessionContext; - private readonly AppSettings _appSettings; - private readonly IPlaybackEngine _playbackEngine; - private readonly AudioCacheManager _audioCacheManager; - private readonly SharedViewModel _sharedViewModel; - private readonly SkinManager _skinManager; - private string? _beatmapFolder; - private string? _audioFilename; - private IBeatmapResourceCatalog? _beatmapResourceCatalog; - - private readonly AsyncSequentialWorker _cachingWorker; - - public GameplayAudioService(ILogger logger, - SyncSessionContext syncSessionContext, - AppSettings appSettings, - IPlaybackEngine playbackEngine, - AudioCacheManager audioCacheManager, - SharedViewModel sharedViewModel, - SkinManager skinManager) - { - _logger = logger; - _syncSessionContext = syncSessionContext; - _appSettings = appSettings; - _playbackEngine = playbackEngine; - _audioCacheManager = audioCacheManager; - _sharedViewModel = sharedViewModel; - _skinManager = skinManager; - - _cachingWorker = new AsyncSequentialWorker(_logger, "GameplayAudioServiceWorker"); - _sharedViewModel.PropertyChanged += SharedViewModel_PropertyChanged; - } - - private void SharedViewModel_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) - { - if (e.PropertyName == nameof(SharedViewModel.SelectedSkin)) - { - _logger.LogInformation("Skin changed, clearing gameplay audio service caches."); - ClearCaches(); - } - } - - public void SetContext(string? beatmapFolder, string? audioFilename, - IBeatmapResourceCatalog? beatmapResourceCatalog = null) - { - _beatmapFolder = beatmapFolder; - _audioFilename = audioFilename; - _beatmapResourceCatalog = beatmapResourceCatalog; - } - - public void ClearCaches() - { - _osuAudioFileCache = new OsuAudioFileCache(); - _audioCacheManager.ClearAll(); - _playNodeToCachedAudioMapping.Clear(); - _filenameToCachedAudioMapping.Clear(); - } - - public bool TryGetAudioByNode(PlaybackEvent node, [NotNullWhen(true)] out CachedAudio? cachedAudio) - { - if (!_playNodeToCachedAudioMapping.TryGetValue(node, out cachedAudio)) return false; - return true; - } - - public bool TryGetCachedAudio(string filenameWithoutExt, out CachedAudio? cachedAudio) - { - return _filenameToCachedAudioMapping.TryGetValue(filenameWithoutExt, out cachedAudio); - } - - public void PrecacheMusicAndSkinInBackground() - { - if (_beatmapFolder == null) - { - _logger.LogWarning("Beatmap folder is null, stop adding cache."); - return; - } - - if (_playbackEngine.CurrentDevice == null) - { - _logger.LogWarning("AudioEngine is null, stop adding cache."); - return; - } - - var folder = _beatmapFolder; - var waveFormat = _playbackEngine.EngineWaveFormat; - var skinFolder = _sharedViewModel.SelectedSkin?.Folder ?? ""; - - _cachingWorker.Enqueue(async token => - { - if (folder != null && _audioFilename != null) - { - var musicPath = ResolveBeatmapFilePath(folder, _audioFilename); - - var (_, status) = await _audioCacheManager.GetOrCreateOrEmptyFromFileAsync(musicPath, waveFormat); - - if (status == CacheGetStatus.Failed) - { - _logger.LogWarning("Caching music failed: " + - (File.Exists(musicPath) ? musicPath : "FileNotFound")); - } - else if (status == CacheGetStatus.Hit) - { - _logger.LogTrace("Got music cache: " + musicPath); - } - else if (status == CacheGetStatus.Created) - { - _logger.LogDebug("Cached music: " + musicPath); - } - } - - try - { - foreach (var skinAudioFile in s_skinAudioFiles) - { - if (token.IsCancellationRequested) break; - await AddSkinCacheAsync(skinAudioFile, folder!, skinFolder, waveFormat); - } - } - catch (Exception ex) - { - _logger.LogError(ex, "An error occurred during skin caching."); - } - }); - } - - public void PrecacheHitsoundsRangeInBackground( - int startTime, - int endTime, - IEnumerable playableNodes, - [CallerArgumentExpression("playableNodes")] - string? expression = null) - { - if (_beatmapFolder == null) - { - _logger.LogWarning("Beatmap folder is null, stop adding cache."); - return; - } - - if (_playbackEngine.CurrentDevice == null) - { - _logger.LogWarning("AudioEngine is null, stop adding cache."); - return; - } - - if (playableNodes is System.Collections.IList { Count: 0 }) - { - _logger.LogWarning($"{expression} has no hitsounds, stop adding cache."); - return; - } - - var folder = _beatmapFolder; - var waveFormat = _playbackEngine.SourceWaveFormat; - var skinFolder = _sharedViewModel.SelectedSkin?.Folder ?? ""; - - _cachingWorker.Enqueue(async token => - { - using var _ = DebugUtils.CreateTimer($"CacheAudio {startTime}~{endTime}", _logger); - var nodesToCache = playableNodes.Where(k => k.Offset >= startTime && k.Offset < endTime).ToList(); - - try - { - foreach (var node in nodesToCache) - { - if (token.IsCancellationRequested) break; - await AddHitsoundCacheAsync(node, folder!, skinFolder, waveFormat); - } - } - catch (Exception ex) - { - _logger.LogError(ex, "An error occurred during hitsound caching."); - } - }); - } - - public async Task AddSkinCacheAsync( - string filenameWithoutExt, - string beatmapFolder, - string skinFolder, - WaveFormat waveFormat) - { - if (_filenameToCachedAudioMapping.TryGetValue(filenameWithoutExt, out var value)) return value; - - string category; - var filename = _osuAudioFileCache.GetFileUntilFind(beatmapFolder, filenameWithoutExt, out var resourceOwner); - - CachedAudio result; - if (TryResolveBeatmapAudioPath(beatmapFolder, filenameWithoutExt, out var beatmapPath)) - { - category = BeatmapCacheIdentifier; - result = await LoadAndCacheAudioAsync(beatmapPath, category, waveFormat); - } - else if (resourceOwner == ResourceOwner.UserSkin) - { - category = UserCacheIdentifier; - result = await ResolveAndLoadSkinAudioAsync(filenameWithoutExt, skinFolder, category, waveFormat); - } - else - { - category = BeatmapCacheIdentifier; - var path = ResolveBeatmapFilePath(beatmapFolder, filename); - result = await LoadAndCacheAudioAsync(path, category, waveFormat); - } - - _filenameToCachedAudioMapping.TryAdd(filenameWithoutExt, result); - - return result; - } - - public async Task AddHitsoundCacheAsync( - PlaybackEvent playbackEvent, - string beatmapFolder, - string skinFolder, - WaveFormat waveFormat) - { - if (!_syncSessionContext.IsStarted) - { - _logger.LogWarning("Isn't started, stop adding cache."); - return; - } - - if (playbackEvent.Filename == null) - { - if (playbackEvent is SampleEvent) - { - _logger.LogWarning("Filename is null, add null cache."); - } - - var cacheResult = await _audioCacheManager.GetOrCreateEmptyAsync("null", waveFormat); - _playNodeToCachedAudioMapping.TryAdd(playbackEvent, cacheResult.CachedAudio!); - return; - } - - string category; - CachedAudio result; - - if (playbackEvent.ResourceOwner == ResourceOwner.UserSkin) - { - category = UserCacheIdentifier; - result = await ResolveAndLoadSkinAudioAsync(playbackEvent.Filename, skinFolder, category, waveFormat); - } - else - { - category = BeatmapCacheIdentifier; - var path = ResolveBeatmapFilePath(beatmapFolder, playbackEvent.Filename); - result = await LoadAndCacheAudioAsync(path, category, waveFormat); - } - - _playNodeToCachedAudioMapping.TryAdd(playbackEvent, result); - _filenameToCachedAudioMapping.TryAdd(playbackEvent.Filename, result); - } - - private string ResolveBeatmapFilePath(string beatmapFolder, string filename) - { - if (_beatmapResourceCatalog?.TryResolve(filename, out var mappedFile) == true) - { - return mappedFile.Path; - } - - return Path.Combine(beatmapFolder, filename); - } - - private bool TryResolveBeatmapAudioPath(string beatmapFolder, string filenameWithoutExt, out string path) - { - if (_beatmapResourceCatalog?.TryResolveAudio(filenameWithoutExt, out var mappedFile) == true) - { - path = mappedFile.Path; - return true; - } - - var filename = _osuAudioFileCache.GetFileUntilFind(beatmapFolder, filenameWithoutExt, out var resourceOwner); - if (resourceOwner == ResourceOwner.Beatmap) - { - path = ResolveBeatmapFilePath(beatmapFolder, filename); - return true; - } - - path = string.Empty; - return false; - } - - private async Task ResolveAndLoadSkinAudioAsync(string filenameKey, string skinFolder, string category, - WaveFormat waveFormat) - { - var filename = _osuAudioFileCache.GetFileUntilFind(skinFolder, filenameKey, out var resourceOwner); - if (resourceOwner == ResourceOwner.Beatmap) // Here means file exists in skin folder - { - var path = Path.Combine(skinFolder, filename); - return await LoadAndCacheAudioAsync(path, category, waveFormat); - } - - if (skinFolder == "{internal}") - { - var dynamicKey = $"internal://dynamic/{filenameKey}"; - return _audioCacheManager.CreateDynamic(dynamicKey, waveFormat); - } - - // Lazer user skins: resolve via the skin's resource catalog (file-to-path mappings - // received from the lazer IPC). The skinFolder is a virtual key, not a real directory. - if (_skinManager.TryGetSkinCatalog(skinFolder, out var skinCatalog) && - skinCatalog.TryResolveAudio(filenameKey, out var skinResource)) - { - return await LoadAndCacheAudioAsync(skinResource.Path, category, waveFormat); - } - - // Lazer built-in skins: use per-skin extracted resources with classic → triangles fallback. - // For custom lazer skins that didn't have the sample, fall back to classic sounds. - var lazerSkinFolder = skinFolder.StartsWith("{lazer-", StringComparison.OrdinalIgnoreCase) - ? skinFolder - : "{lazer-classic}"; - if (_skinManager.TryGetLazerResource(lazerSkinFolder, filenameKey, out var lazerBytes)) - { - var key = $"lazer://{lazerSkinFolder}/{filenameKey}"; - using var stream = new MemoryStream(lazerBytes); - return await LoadAndCacheAudioFromStreamAsync(key, stream, category, waveFormat); - } - - // Stable fallback (osu!gameplay.dll resources) - if (_skinManager.TryGetStableResource(filenameKey, out var bytes)) - { - var key = $"internal://{filenameKey}"; - using var stream = new MemoryStream(bytes); - return await LoadAndCacheAudioFromStreamAsync(key, stream, category, waveFormat); - } - - _logger.LogWarning("Skin audio not found in skin or resources: {FilenameKey}", filenameKey); - var empty = await _audioCacheManager.GetOrCreateEmptyAsync(filenameKey, waveFormat, category); - return empty.CachedAudio!; - } - - private async Task LoadAndCacheAudioFromStreamAsync(string key, Stream stream, string category, - WaveFormat waveFormat) - { - var (result, status) = await _audioCacheManager.GetOrCreateOrEmptyAsync(key, stream, waveFormat, category); - if (status == CacheGetStatus.Failed) - { - _logger.LogWarning("Caching effect failed: {Key}", key); - } - else if (status == CacheGetStatus.Hit) - { - _logger.LogTrace("Got effect cache: {Key}", key); - } - else if (status == CacheGetStatus.Created) - { - _logger.LogDebug("Cached effect: {Key}", key); - } - - return result!; - } - - private async Task LoadAndCacheAudioAsync(string path, string category, WaveFormat waveFormat) - { - var (result, status) = await _audioCacheManager.GetOrCreateOrEmptyFromFileAsync(path, waveFormat, category); - if (status == CacheGetStatus.Failed) - { - var file = (File.Exists(path) ? path : "FileNotFound"); - _logger.LogWarning("Caching effect failed: {File}", file); - } - else if (status == CacheGetStatus.Hit) - { - _logger.LogTrace("Got effect cache: {Path}", path); - } - else if (status == CacheGetStatus.Created) - { - _logger.LogDebug("Cached effect: {Path}", path); - } - - return result!; - } - - public void Dispose() - { - _sharedViewModel.PropertyChanged -= SharedViewModel_PropertyChanged; - _cachingWorker.Dispose(); - } -} diff --git a/src/Common/KeyAsio.Shared/Sync/Services/GameplaySessionManager.cs b/src/Common/KeyAsio.Shared/Sync/Services/GameplaySessionManager.cs deleted file mode 100644 index 7ffb0d29..00000000 --- a/src/Common/KeyAsio.Shared/Sync/Services/GameplaySessionManager.cs +++ /dev/null @@ -1,216 +0,0 @@ -using System.Runtime; -using System.Runtime.CompilerServices; -using Coosu.Beatmap; -using Coosu.Beatmap.Sections.GamePlay; -using KeyAsio.Core.Audio; -using KeyAsio.Core.OsuAudio.Hitsounds; -using KeyAsio.Core.OsuAudio.Hitsounds.Playback; -using KeyAsio.Shared.Events; -using KeyAsio.Shared.Utils; -using Microsoft.Extensions.Logging; - -namespace KeyAsio.Shared.Sync.Services; - -public class GameplaySessionManager -{ - private readonly ILogger _logger; - private readonly GameplayAudioService _gameplayAudioService; - private readonly IPlaybackEngine _audioEngine; - private readonly SyncSessionContext _syncSessionContext; - private readonly BeatmapHitsoundLoader _beatmapHitsoundLoader; - private readonly SfxPlaybackService _sfxPlaybackService; - - public event SignalEventHandler? SessionStopped; - - private readonly Dictionary _audioProviderDictionary = new(); - private OsuFile? _osuFile; - private IHitsoundSequencer? _cachedHitsoundSequencer; - private string? _lastCachedFolder; - private GCLatencyMode _oldLatencyMode; - private bool _isLowLatencyModeActive; - - public GameplaySessionManager(ILogger logger, - GameplayAudioService gameplayAudioService, - IPlaybackEngine audioEngine, - SyncSessionContext syncSessionContext, - BeatmapHitsoundLoader beatmapHitsoundLoader, - SfxPlaybackService sfxPlaybackService) - { - _logger = logger; - _gameplayAudioService = gameplayAudioService; - _audioEngine = audioEngine; - _syncSessionContext = syncSessionContext; - _beatmapHitsoundLoader = beatmapHitsoundLoader; - _sfxPlaybackService = sfxPlaybackService; - } - - public OsuFile? OsuFile - { - get => _osuFile; - internal set - { - _osuFile = value; - UpdateCachedSequencer(); - } - } - - public string? AudioFilename { get; internal set; } - public string? BeatmapFolder { get; private set; } - public IReadOnlyList PlaybackList => _beatmapHitsoundLoader.PlaybackList; - public List KeyList => _beatmapHitsoundLoader.KeyList; - - public void InitializeProviders(IHitsoundSequencer standardHitsoundSequencer, - IHitsoundSequencer taikoHitsoundSequencer, - IHitsoundSequencer catchHitsoundSequencer, - IHitsoundSequencer maniaHitsoundSequencer) - { - _audioProviderDictionary[GameMode.Circle] = standardHitsoundSequencer; - _audioProviderDictionary[GameMode.Taiko] = taikoHitsoundSequencer; - _audioProviderDictionary[GameMode.Catch] = catchHitsoundSequencer; - _audioProviderDictionary[GameMode.Mania] = maniaHitsoundSequencer; - UpdateCachedSequencer(); - } - - public IHitsoundSequencer CurrentHitsoundSequencer - { - [MethodImpl(MethodImplOptions.AggressiveInlining)] - get => _cachedHitsoundSequencer ?? _audioProviderDictionary[GameMode.Circle]; - } - - private void UpdateCachedSequencer() - { - if (_audioProviderDictionary.Count == 0) return; - - if (_osuFile == null) - { - _cachedHitsoundSequencer = _audioProviderDictionary[GameMode.Circle]; - } - else - { - _cachedHitsoundSequencer = _audioProviderDictionary.TryGetValue(_osuFile.General.Mode, out var sequencer) - ? sequencer - : _audioProviderDictionary[GameMode.Circle]; - } - } - - public async Task StartAsync(string? beatmapFilenameFull, string? beatmapFilename) - { - try - { - _logger.LogInformation("Start playing."); - OsuFile = null; - - var resourceCatalog = _syncSessionContext.BeatmapResourceCatalog; - var folder = ResolveBeatmapFolder(beatmapFilenameFull, beatmapFilename, resourceCatalog); - if (folder == null) - { - throw new DirectoryNotFoundException("Failed to determine the beatmap directory."); - } - - if (beatmapFilename == null) - { - throw new ArgumentNullException(nameof(beatmapFilename), "Beatmap filename cannot be null."); - } - - var osuFile = await _beatmapHitsoundLoader.InitializeNodeListsAsync(folder, beatmapFilename, - CurrentHitsoundSequencer, _syncSessionContext.PlayMods, resourceCatalog); - OsuFile = osuFile; - AudioFilename = osuFile?.General?.AudioFilename; - BeatmapFolder = folder; - - var cacheContextKey = resourceCatalog != null - ? $"{resourceCatalog.CacheKey}|{beatmapFilename}" - : folder; - - PerformCache(folder, AudioFilename, resourceCatalog, cacheContextKey); - //ResetNodes(_syncSessionContext.PlayTime); - - _syncSessionContext.IsStarted = true; - - _oldLatencyMode = GCSettings.LatencyMode; - if (!RuntimeInfo.IsSatori) - { - GCSettings.LatencyMode = GCLatencyMode.SustainedLowLatency; - _logger.LogInformation("GC LatencyMode set to SustainedLowLatency"); - } - else - { - GCSettings.LatencyMode = GCLatencyMode.LowLatency; - _logger.LogInformation("GC LatencyMode set to LowLatency (Satori)"); - } - - _isLowLatencyModeActive = true; - } - catch (Exception ex) - { - _syncSessionContext.IsStarted = false; - _logger.LogError(ex, "Error while starting a beatmap. Filename: {BeatmapFilename}. FilenameReal: {OsuFile}", - beatmapFilename, OsuFile); - LogUtils.LogToSentry(LogLevel.Error, "Error while starting a beatmap", ex, k => - { - k.SetTag("osu.filename", beatmapFilename); - k.SetTag("osu.filename_real", OsuFile?.ToString() ?? ""); - }); - } - } - - private static string? ResolveBeatmapFolder(string? beatmapFilenameFull, string? beatmapFilename, - IBeatmapResourceCatalog? resourceCatalog) - { - if (beatmapFilename != null && resourceCatalog?.TryResolve(beatmapFilename, out var mappedBeatmap) == true) - { - return Path.GetDirectoryName(mappedBeatmap.Path); - } - - return beatmapFilenameFull != null - ? Path.GetDirectoryName(beatmapFilenameFull) - : resourceCatalog?.RootPath; - } - - private void PerformCache(string newFolder, string? audioFilename, IBeatmapResourceCatalog? resourceCatalog, - string cacheContextKey) - { - if (_lastCachedFolder != null && _lastCachedFolder != cacheContextKey) - { - _logger.LogInformation("Cleaning caches caused by folder changing."); - _gameplayAudioService.ClearCaches(); - } - - _lastCachedFolder = cacheContextKey; - - if (_audioEngine.CurrentDevice == null) - { - _logger.LogWarning($"AudioEngine is null, stop adding cache."); - return; - } - - _gameplayAudioService.SetContext(newFolder, audioFilename, resourceCatalog); - _gameplayAudioService.PrecacheMusicAndSkinInBackground(); - } - - public void Stop() - { - if (_isLowLatencyModeActive) - { - GCSettings.LatencyMode = _oldLatencyMode; - _isLowLatencyModeActive = false; - _logger.LogInformation("GC LatencyMode restored to {Mode}", _oldLatencyMode); - } - - _logger.LogInformation("Stop playing."); - _syncSessionContext.IsStarted = false; - var mixer = _audioEngine.EffectMixer; - _sfxPlaybackService.ClearAllLoops(mixer); - mixer?.RemoveAllMixerInputs(); - - SessionStopped?.Invoke(); - - _syncSessionContext.BaseMemoryTime = 0; - _syncSessionContext.Combo = 0; - } - - private void ResetNodes(int playTime) - { - _beatmapHitsoundLoader.ResetNodes(CurrentHitsoundSequencer, playTime); - } -} diff --git a/src/Common/KeyAsio.Shared/Sync/Services/SfxPlaybackService.cs b/src/Common/KeyAsio.Shared/Sync/Services/SfxPlaybackService.cs deleted file mode 100644 index f8ff1231..00000000 --- a/src/Common/KeyAsio.Shared/Sync/Services/SfxPlaybackService.cs +++ /dev/null @@ -1,221 +0,0 @@ -using System.ComponentModel; -using KeyAsio.Core.Audio; -using KeyAsio.Core.Audio.Caching; -using KeyAsio.Core.Audio.SampleProviders; -using KeyAsio.Core.Audio.SampleProviders.BalancePans; -using KeyAsio.Core.Audio.Utils; -using KeyAsio.Core.OsuAudio.Hitsounds.Playback; -using KeyAsio.Shared.Models; -using Microsoft.Extensions.Logging; - -namespace KeyAsio.Shared.Sync.Services; - -public class SfxPlaybackService -{ - private readonly LoopProviderManager _loopProviderManager = new(); - private readonly ILogger _logger; - private readonly IPlaybackEngine _playbackEngine; - private readonly AppSettings _appSettings; - - public SfxPlaybackService(ILogger logger, IPlaybackEngine playbackEngine, AppSettings appSettings) - { - _logger = logger; - _playbackEngine = playbackEngine; - _appSettings = appSettings; - - _appSettings.Sync.Playback.PropertyChanged += OnPlaybackSettingsChanged; - } - - public void DispatchPlayback(PlaybackInfo playbackInfo, float? overrideVolume = null) - { - var cachedAudio = playbackInfo.CachedAudio; - var hitsoundNode = playbackInfo.PlaybackEvent; - if (hitsoundNode is SampleEvent playableNode) - { - if (cachedAudio is null) - { - _logger.LogWarning("Fail to play sample event: CachedSound not found"); - return; - } - - float volume; - if (_appSettings.Sync.Filters.IgnoreLineVolumes) - { - volume = 1; - } - else - { - if (overrideVolume != null) - volume = overrideVolume.Value; - else if (playableNode.Layer == SampleLayer.Effects) - volume = playableNode.Volume * 1.25f; - else - volume = playableNode.Volume; - } - - PlayEffectsAudio(cachedAudio, volume, playableNode.Balance); - } - else - { - var controlNode = (ControlEvent)hitsoundNode; - PlayLoopAudio(cachedAudio, controlNode); - } - } - - public void PlayEffectsAudio(CachedAudio? cachedAudio, float volume, float balance) - { - if (cachedAudio is null) - { - _logger.LogWarning("Fail to play: CachedSound not found"); - return; - } - - if (cachedAudio.SourceHash != null && cachedAudio.SourceHash.StartsWith("internal://dynamic/")) - { - try - { - var filename = cachedAudio.SourceHash.Substring("internal://dynamic/".Length); - - // 创建一次性军鼓生成器 - var provider = new SnareDrumSampleProvider(_playbackEngine.EffectMixer.WaveFormat); - - // 简单的参数随机化 - var random = Random.Shared; - - // 1. 基频微调 (±5%) - // 模拟击打位置不同导致的音高微差 - float freqOffset = (random.NextSingle() * 2f - 1f) * 0.05f; - provider.FundamentalFrequency *= (1f + freqOffset); - - // 2. 混合比例微调 (±10%) - // 模拟响弦共振的不同 - float mixOffset = (random.NextSingle() * 2f - 1f) * 0.1f; - provider.SnareMixLevel = Math.Clamp(provider.SnareMixLevel + mixOffset, 0f, 1f); - provider.SnapMixLevel = Math.Clamp(provider.SnapMixLevel - mixOffset * 0.5f, 0f, 1f); - - // 3. 衰减时间微调 (±10%) - // 模拟力度的不同导致余音长度变化 - float decayOffset = (random.NextSingle() * 2f - 1f) * 0.1f; - provider.SnareDecayDuration *= (1f + decayOffset); - provider.SnapDecayDuration *= (1f + decayOffset); - - // 根据文件名做一些简单的预设调整 - if (filename.Contains("soft", StringComparison.OrdinalIgnoreCase)) - { - provider.InitialSnapGain *= 0.8f; - provider.InitialSnareGain *= 0.7f; - provider.FundamentalFrequency *= 0.9f; - } - else if (filename.Contains("drum", StringComparison.OrdinalIgnoreCase)) - { - provider.FundamentalFrequency *= 0.8f; - provider.SnareMixLevel *= 0.5f; - provider.SnapMixLevel *= 1.2f; - } - // normal-hitnormal 保持原样 - - var volumeProvider = RecyclableSampleProviderFactory.RentVolumeProvider(provider, volume); - var balanceProvider = RecyclableSampleProviderFactory.RentBalanceProvider(volumeProvider, balance, - _appSettings.Sync.Playback.BalanceMode, AntiClipStrategy.None); - - _playbackEngine.EffectMixer.AddMixerInput(balanceProvider); - _logger.LogTrace("Play Dynamic: {Key} (Freq: {Freq:F1})", cachedAudio.SourceHash, - provider.FundamentalFrequency); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error playing dynamic audio"); - } - - return; - } - - if (_appSettings.Sync.Filters.IgnoreLineVolumes) - { - volume = 1; - } - - balance *= _appSettings.Sync.Playback.BalanceFactor; - - try - { - var cachedAudioProvider = RecyclableSampleProviderFactory.RentCacheProvider(cachedAudio); - var volumeProvider = RecyclableSampleProviderFactory.RentVolumeProvider(cachedAudioProvider, volume); - var balanceProvider = RecyclableSampleProviderFactory.RentBalanceProvider(volumeProvider, balance, - _appSettings.Sync.Playback.BalanceMode, AntiClipStrategy.None); // 削波处理交给MasterLimiterProvider - - _playbackEngine.EffectMixer.AddMixerInput(balanceProvider); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error occurs while playing audio."); - } - - _logger.LogTrace("Play {File}; Vol. {Volume}; Bal. {Balance}", cachedAudio.SourceHash, volume, balance); - } - - public void PlayLoopAudio(CachedAudio? cachedAudio, ControlEvent controlEvent) - { - var effectMixer = _playbackEngine.EffectMixer; - var volume = _appSettings.Sync.Filters.IgnoreLineVolumes ? 1 : controlEvent.Volume; - - if (controlEvent.ControlEventType == ControlEventType.LoopStart) - { - if (cachedAudio is null) - { - _logger.LogWarning("LoopStart ignored because cached audio is missing. File: {File}", - controlEvent.Filename ?? "(null)"); - return; - } - - if (_loopProviderManager.ShouldRemoveAll((int)controlEvent.LoopChannel)) - { - _loopProviderManager.RemoveAll(effectMixer); - } - - try - { - _loopProviderManager.Create((int)controlEvent.LoopChannel, cachedAudio, effectMixer, volume, 0, - _appSettings.Sync.Playback.BalanceMode, balanceFactor: 0); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error occurs while playing looped audio."); - } - } - else if (controlEvent.ControlEventType == ControlEventType.LoopStop) - { - _loopProviderManager.Remove((int)controlEvent.LoopChannel, effectMixer); - } - else if (controlEvent.ControlEventType == ControlEventType.Volume) - { - _loopProviderManager.ChangeAllVolumes(volume); - } - } - - public void ClearAllLoops(IMixingSampleProvider? mixingSampleProvider = null) - { - mixingSampleProvider ??= _playbackEngine.EffectMixer; - _loopProviderManager.RemoveAll(mixingSampleProvider); - } - - public void PauseAllLoops(IMixingSampleProvider? mixingSampleProvider = null) - { - mixingSampleProvider ??= _playbackEngine.EffectMixer; - _loopProviderManager.PauseAll(mixingSampleProvider); - } - - public void ResumeAllLoops(IMixingSampleProvider? mixingSampleProvider = null) - { - mixingSampleProvider ??= _playbackEngine.EffectMixer; - _loopProviderManager.RecoverAll(mixingSampleProvider); - } - - private void OnPlaybackSettingsChanged(object? sender, PropertyChangedEventArgs e) - { - if (e.PropertyName == nameof(AppSettingsSyncPlayback.BalanceMode)) - { - _loopProviderManager.ChangeAllBalanceModes(_appSettings.Sync.Playback.BalanceMode); - } - } -} diff --git a/src/Common/KeyAsio.Shared/Sync/SnareDrumSampleProvider.cs b/src/Common/KeyAsio.Shared/Sync/SnareDrumSampleProvider.cs deleted file mode 100644 index f238f90a..00000000 --- a/src/Common/KeyAsio.Shared/Sync/SnareDrumSampleProvider.cs +++ /dev/null @@ -1,318 +0,0 @@ -using System.Runtime.CompilerServices; -using KeyAsio.Core.Audio.SampleProviders; -using NAudio.Wave; - -namespace KeyAsio.Shared.Sync; - -/// -/// 军鼓生成器 -/// -public class SnareDrumSampleProvider : IRecyclableProvider -{ - private readonly WaveFormat _waveFormat; - private uint _rngState; - - // 状态变量 - private long _sampleCount; - private float _snapGain; - private float _snareGain; - private float _phase; - private long _maxDurationSamples; - - // 物理参数配置 (时间单位:秒) - /// - /// 军鼓基频 (Hz) - /// - public float FundamentalFrequency { get; set; } = 180f; - - /// - /// 频率滑落范围 (Hz) - /// - public float FrequencySweepRange { get; set; } = 150f; - - /// - /// 鼓面打击衰减时长 (秒) - /// - public float SnapDecayDuration { get; set; } = 0.08f; - - /// - /// 响弦噪声衰减时长 (秒) - /// - public float SnareDecayDuration { get; set; } = 0.1f; - - private float _totalDuration = 1.0f; - - /// - /// 总时长 (秒) - /// - public float TotalDuration - { - get => _totalDuration; - set - { - _totalDuration = value; - UpdateMaxDurationSamples(); - } - } - - /// - /// 频率滑落时间常数 (秒) - /// - public float PitchDecayTimeConstant { get; set; } = 0.013605442f; - - /// - /// 初始打击增益 (0-1) - /// - public float InitialSnapGain { get; set; } = 0.9f; - - /// - /// 初始响弦增益 (0-1) - /// - public float InitialSnareGain { get; set; } = 0.6f; - - /// - /// 冲击瞬态时长 (秒) - /// - public float ImpactDuration { get; set; } = 0.0035f; - - /// - /// 冲击瞬态强度 (0-1) - /// - public float ImpactLevel { get; set; } = 0.5f; - - /// - /// 鼓面打击混合比例 (0-1) - /// - public float SnapMixLevel { get; set; } = 0.5f; - - /// - /// 响弦噪声混合比例 (0-1) - /// - public float SnareMixLevel { get; set; } = 0.4f; - - public WaveFormat WaveFormat => _waveFormat; - - public ISampleProvider? ResetAndGetSource() - { - Reset(); - return null; - } - - /// - /// 创建一个新的军鼓击打实例 - /// - /// 混合器的目标格式(支持任意采样率和声道数) - public SnareDrumSampleProvider(WaveFormat targetFormat) - { - _waveFormat = targetFormat; - _rngState = (uint)DateTime.Now.Ticks; - - // 初始化增益 - _snapGain = InitialSnapGain; - _snareGain = InitialSnareGain; - _phase = 0; - _sampleCount = 0; - - // 根据采样率计算总采样数 - UpdateMaxDurationSamples(); - } - - private void UpdateMaxDurationSamples() - { - if (_waveFormat != null) - { - _maxDurationSamples = (long)(TotalDuration * _waveFormat.SampleRate); - } - } - - public int Read(float[] buffer, int offset, int count) - { - if (_sampleCount >= _maxDurationSamples) - { - Array.Clear(buffer, offset, count); - return 0; - } - - int samplesRead = 0; - int channels = _waveFormat.Channels; - int sampleRate = _waveFormat.SampleRate; - - // 计算每帧处理多少个采样点 (Buffer是交错的:L, R, L, R...) - // 我们需要填充的 "帧" 数 = count / channels - int samplesToFill = count / channels; - - // 限制处理长度不超过剩余总时长 - long samplesRemainingTotal = _maxDurationSamples - _sampleCount; - if (samplesToFill > samplesRemainingTotal) - { - samplesToFill = (int)samplesRemainingTotal; - } - - // --- 预计算和缓存 (性能优化) --- - // 缓存属性到局部变量,避免循环中重复访问属性 getter - float fundamentalFreq = FundamentalFrequency; - float freqSweepRange = FrequencySweepRange; - float pitchDecayTimeConstant = PitchDecayTimeConstant; - float snapMixLevel = SnapMixLevel; - float snareMixLevel = SnareMixLevel; - float impactLevel = ImpactLevel; - - // 预计算衰减因子 - // MathF.Exp(-1.0f / (sampleRate * PitchDecayTimeConstant)) - float freqDecayFactor = MathF.Exp(-1.0f / (sampleRate * pitchDecayTimeConstant)); - - // 预计算当前频率包络值 (避免循环内调用 Exp) - // 初始值基于当前 _sampleCount - float currentFreqEnv = MathF.Exp(-((float)_sampleCount / sampleRate) / pitchDecayTimeConstant); - - // 预计算 Snap 和 Snare 的衰减因子 - // 原公式: Gain *= (1.0f - 1.0f / (sampleRate * Duration)) - float snapDecayFactor = 1.0f - 1.0f / (sampleRate * SnapDecayDuration); - float snareDecayFactor = 1.0f - 1.0f / (sampleRate * SnareDecayDuration); - - // 预计算 Impact 持续的采样数,避免循环内浮点除法和比较 - long impactDurationSamples = (long)(ImpactDuration * sampleRate); - - // 预计算相位增量常数 - float twoPi = 2f * MathF.PI; - float phaseIncrementBase = twoPi / sampleRate; - - // 将循环分为两部分:有 Impact 的部分和无 Impact 的部分 - long samplesRemainingImpact = impactDurationSamples - _sampleCount; - int countImpact = 0; - if (samplesRemainingImpact > 0) - { - countImpact = (int)Math.Min(samplesToFill, samplesRemainingImpact); - } - - int countNoImpact = samplesToFill - countImpact; - - // 循环 1:包含 Impact 计算 - for (int i = 0; i < countImpact; i++) - { - // --- 合成核心逻辑 (基于单声道计算) --- - - // 2. 合成 "Snap" (正弦波 + 快速频率掉落) - // 使用累乘更新频率包络 - float freq = fundamentalFreq + (freqSweepRange * currentFreqEnv); - currentFreqEnv *= freqDecayFactor; - - _phase += phaseIncrementBase * freq; - if (_phase > twoPi) _phase -= twoPi; - - float snap = MathF.Sin(_phase) * _snapGain; - - // 3. 合成 "Snare" (白噪声) - float noise = FastNextFloat() * _snareGain; - - // 4. "Impact" 瞬态 (存在) - float impact = FastNextFloat() * impactLevel; - - // 5. 混合 - float sampleValue = (snap * snapMixLevel) + (noise * snareMixLevel) + impact; - - // 硬限制器 - if (sampleValue > 1.0f) sampleValue = 1.0f; - else if (sampleValue < -1.0f) sampleValue = -1.0f; - - // --- 声道处理 --- - if (channels == 2) - { - buffer[offset + samplesRead] = sampleValue; - buffer[offset + samplesRead + 1] = sampleValue; - samplesRead += 2; - } - else - { - for (int ch = 0; ch < channels; ch++) - buffer[offset + samplesRead + ch] = sampleValue; - samplesRead += channels; - } - - // 6. 包络衰减 - _snapGain *= snapDecayFactor; - _snareGain *= snareDecayFactor; - if (_snapGain < 0.0001f) _snapGain = 0f; - if (_snareGain < 0.0001f) _snareGain = 0f; - - _sampleCount++; - } - - // 循环 2:不包含 Impact 计算 (Impact = 0) - for (int i = 0; i < countNoImpact; i++) - { - // --- 合成核心逻辑 (基于单声道计算) --- - - // 2. 合成 "Snap" (正弦波 + 快速频率掉落) - // 使用累乘更新频率包络 - float freq = fundamentalFreq + (freqSweepRange * currentFreqEnv); - currentFreqEnv *= freqDecayFactor; - - _phase += phaseIncrementBase * freq; - if (_phase > twoPi) _phase -= twoPi; - - float snap = MathF.Sin(_phase) * _snapGain; - - // 3. 合成 "Snare" (白噪声) - float noise = FastNextFloat() * _snareGain; - - // 4. "Impact" 瞬态 (为 0,省略计算) - // float impact = 0f; - - // 5. 混合 - float sampleValue = (snap * snapMixLevel) + (noise * snareMixLevel); // + 0 - - // 硬限制器 - sampleValue = Math.Clamp(sampleValue, -1.0f, 1.0f); - - // --- 声道处理 --- - if (channels == 2) - { - buffer[offset + samplesRead] = sampleValue; - buffer[offset + samplesRead + 1] = sampleValue; - samplesRead += 2; - } - else - { - for (int ch = 0; ch < channels; ch++) - buffer[offset + samplesRead + ch] = sampleValue; - samplesRead += channels; - } - - // 6. 包络衰减 - _snapGain *= snapDecayFactor; - _snareGain *= snareDecayFactor; - - _sampleCount++; - } - - if (samplesRead < count) - { - Array.Clear(buffer, offset + samplesRead, count - samplesRead); - } - - return samplesRead; - } - - private void Reset() - { - _sampleCount = 0; - _snapGain = InitialSnapGain; - _snareGain = InitialSnareGain; - _phase = 0; - } - - /// - /// 快速随机数生成器 (Xorshift) - /// - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private float FastNextFloat() - { - uint x = _rngState; - x ^= x << 13; - x ^= x >> 17; - x ^= x << 5; - _rngState = x; - return (int)x * 4.6566128752458e-10f; - } -} \ No newline at end of file diff --git a/src/Common/KeyAsio.Shared/Sync/States/BrowsingState.cs b/src/Common/KeyAsio.Shared/Sync/States/BrowsingState.cs deleted file mode 100644 index 121e11a2..00000000 --- a/src/Common/KeyAsio.Shared/Sync/States/BrowsingState.cs +++ /dev/null @@ -1,41 +0,0 @@ -using KeyAsio.Plugins.Abstractions.OsuMemory; -using KeyAsio.Shared.OsuMemory; -using KeyAsio.Shared.Sync.Services; - -namespace KeyAsio.Shared.Sync.States; - -public class BrowsingState : IGameState -{ - private readonly GameplaySessionManager _gameplaySessionManager; - - public BrowsingState(GameplaySessionManager gameplaySessionManager) - { - _gameplaySessionManager = gameplaySessionManager; - } - - public Task EnterAsync(SyncSessionContext ctx, OsuMemoryStatus from) - { - _gameplaySessionManager.Stop(); - return Task.CompletedTask; - } - - public void Exit(SyncSessionContext ctx, OsuMemoryStatus to) - { - } - - public void OnTick(SyncSessionContext ctx, int prevMs, int currMs, bool isAudioPaused) - { - } - - public void OnComboChanged(SyncSessionContext ctx, int oldCombo, int newCombo) - { - } - - public void OnBeatmapChanged(SyncSessionContext ctx, BeatmapIdentifier beatmap) - { - } - - public void OnModsChanged(SyncSessionContext ctx, Mods oldMods, Mods newMods) - { - } -} \ No newline at end of file diff --git a/src/Common/KeyAsio.Shared/Sync/States/IGameState.cs b/src/Common/KeyAsio.Shared/Sync/States/IGameState.cs deleted file mode 100644 index 636d3bfd..00000000 --- a/src/Common/KeyAsio.Shared/Sync/States/IGameState.cs +++ /dev/null @@ -1,16 +0,0 @@ -using KeyAsio.Plugins.Abstractions.OsuMemory; -using KeyAsio.Shared.OsuMemory; - -namespace KeyAsio.Shared.Sync.States; - -public interface IGameState -{ - Task EnterAsync(SyncSessionContext ctx, OsuMemoryStatus from); - void Exit(SyncSessionContext ctx, OsuMemoryStatus to); - - void OnTick(SyncSessionContext ctx, int prevMs, int currMs, bool isAudioPaused); - - void OnComboChanged(SyncSessionContext ctx, int oldCombo, int newCombo); - void OnBeatmapChanged(SyncSessionContext ctx, BeatmapIdentifier beatmap); - void OnModsChanged(SyncSessionContext ctx, Mods oldMods, Mods newMods); -} \ No newline at end of file diff --git a/src/Common/KeyAsio.Shared/Sync/States/NotRunningState.cs b/src/Common/KeyAsio.Shared/Sync/States/NotRunningState.cs deleted file mode 100644 index eef48366..00000000 --- a/src/Common/KeyAsio.Shared/Sync/States/NotRunningState.cs +++ /dev/null @@ -1,36 +0,0 @@ -using KeyAsio.Plugins.Abstractions.OsuMemory; -using KeyAsio.Shared.OsuMemory; - -namespace KeyAsio.Shared.Sync.States; - -public class NotRunningState : IGameState -{ - public NotRunningState() - { - } - - public Task EnterAsync(SyncSessionContext ctx, OsuMemoryStatus from) - { - return Task.CompletedTask; - } - - public void Exit(SyncSessionContext ctx, OsuMemoryStatus to) - { - } - - public void OnTick(SyncSessionContext ctx, int prevMs, int currMs, bool isAudioPaused) - { - } - - public void OnComboChanged(SyncSessionContext ctx, int oldCombo, int newCombo) - { - } - - public void OnBeatmapChanged(SyncSessionContext ctx, BeatmapIdentifier beatmap) - { - } - - public void OnModsChanged(SyncSessionContext ctx, Mods oldMods, Mods newMods) - { - } -} \ No newline at end of file diff --git a/src/Common/KeyAsio.Shared/Sync/States/PlayingState.cs b/src/Common/KeyAsio.Shared/Sync/States/PlayingState.cs deleted file mode 100644 index 19c2ac22..00000000 --- a/src/Common/KeyAsio.Shared/Sync/States/PlayingState.cs +++ /dev/null @@ -1,223 +0,0 @@ -using System.Diagnostics; -using Coosu.Beatmap.Sections.GamePlay; -using KeyAsio.Core.Audio; -using KeyAsio.Core.OsuAudio.Hitsounds.Playback; -using KeyAsio.Plugins.Abstractions.OsuMemory; -using KeyAsio.Shared.Models; -using KeyAsio.Shared.OsuMemory; -using KeyAsio.Shared.Sync.Services; -using Microsoft.Extensions.Logging; - -namespace KeyAsio.Shared.Sync.States; - -public class PlayingState : IGameState -{ - private static readonly long s_hitsoundSyncIntervalTicks = Stopwatch.Frequency / 1000; // 1000hz - - private readonly ILogger _logger; - private readonly IPlaybackEngine _playbackEngine; - private readonly BeatmapHitsoundLoader _beatmapHitsoundLoader; - private readonly SfxPlaybackService _sfxPlaybackService; - private readonly SharedViewModel _sharedViewModel; - private readonly GameplaySessionManager _gameplaySessionManager; - private readonly GameplayAudioService _gameplayAudioService; - private readonly List _playbackBuffer = new(64); - - private long _lastHitsoundSyncTimestamp; - private bool _disableComboBreakSfx; - private bool? _lastIsReplay; - private int _lastObservedLazerMissCount; - private bool _wasAudioPaused; - - public PlayingState( - ILogger logger, - AppSettings appSettings, - IPlaybackEngine playbackEngine, - BeatmapHitsoundLoader beatmapHitsoundLoader, - SfxPlaybackService sfxPlaybackService, - SharedViewModel sharedViewModel, - GameplaySessionManager gameplaySessionManager, - GameplayAudioService gameplayAudioService) - { - _logger = logger; - _playbackEngine = playbackEngine; - _beatmapHitsoundLoader = beatmapHitsoundLoader; - _sfxPlaybackService = sfxPlaybackService; - _sharedViewModel = sharedViewModel; - _gameplaySessionManager = gameplaySessionManager; - _gameplayAudioService = gameplayAudioService; - - _disableComboBreakSfx = appSettings.Sync.Filters.DisableComboBreakSfx; - appSettings.Sync.Filters.PropertyChanged += (_, e) => - { - if (e.PropertyName == nameof(AppSettings.Sync.Filters.DisableComboBreakSfx)) - { - _disableComboBreakSfx = appSettings.Sync.Filters.DisableComboBreakSfx; - } - }; - } - - public async Task EnterAsync(SyncSessionContext ctx, OsuMemoryStatus from) - { - _lastHitsoundSyncTimestamp = 0; - _lastObservedLazerMissCount = ctx.Statistics.Miss; - _wasAudioPaused = false; - if (ctx.Beatmap == default) - { - // Beatmap is required to start; keep silent if absent - return; - } - - await _gameplaySessionManager.StartAsync(ctx.Beatmap.FilenameFull?.Trim(), ctx.Beatmap.Filename?.Trim()); - } - - public void Exit(SyncSessionContext ctx, OsuMemoryStatus to) - { - } - - public void OnTick(SyncSessionContext ctx, int prevMs, int currMs, bool isAudioPaused) - { - if (!ctx.IsStarted) return; - - if (_lastIsReplay != ctx.IsReplay) - { - _logger.LogInformation("IsReplay status changed: {Old} -> {New} (Mods: {Mods})", _lastIsReplay, - ctx.IsReplay, ctx.PlayMods); - _lastIsReplay = ctx.IsReplay; - } - - // Retry: song time moved backward during playing - if (prevMs > currMs) - { - OnRetry(ctx); - return; - } - - // Pause/resume loop audio on edge transitions - if (isAudioPaused != _wasAudioPaused) - { - if (isAudioPaused) - { - _sfxPlaybackService.PauseAllLoops(_playbackEngine.EffectMixer); - } - else - { - _sfxPlaybackService.ResumeAllLoops(_playbackEngine.EffectMixer); - } - - _wasAudioPaused = isAudioPaused; - } - - // Skip hitsound sync while paused to avoid timing-window responses - if (isAudioPaused) return; - - var timestamp = ctx.LastUpdateTimestamp; - - // Logic for Hitsounds - if (timestamp - _lastHitsoundSyncTimestamp >= s_hitsoundSyncIntervalTicks) - { - try - { - SyncHitsounds(ctx, currMs); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error syncing hitsounds."); - } - - _lastHitsoundSyncTimestamp = timestamp; - } - } - - public void OnComboChanged(SyncSessionContext ctx, int oldCombo, int newCombo) - { - var hasNewLazerMiss = ctx.ClientType != GameClientType.Lazer || ConsumeLazerMissIncrease(ctx.Statistics.Miss); - - if (_disableComboBreakSfx) return; - if (!ctx.IsStarted) return; - if (ctx.Score == 0) return; - if (newCombo >= oldCombo || oldCombo < 20) return; - if (!hasNewLazerMiss) return; - - if (_gameplayAudioService.TryGetCachedAudio("combobreak", out var cachedAudio)) - { - _sfxPlaybackService.PlayEffectsAudio(cachedAudio, 1, 0); - } - } - - public void OnBeatmapChanged(SyncSessionContext ctx, BeatmapIdentifier beatmap) - { - } - - public void OnModsChanged(SyncSessionContext ctx, Mods oldMods, Mods newMods) - { - } - - private void OnRetry(SyncSessionContext ctx) - { - _lastObservedLazerMissCount = ctx.Statistics.Miss; - _wasAudioPaused = false; - var mixer = _playbackEngine.EffectMixer; - _sfxPlaybackService.ClearAllLoops(mixer); - mixer?.RemoveAllMixerInputs(); - _beatmapHitsoundLoader.ResetNodes(_gameplaySessionManager.CurrentHitsoundSequencer, ctx.PlayTime); - } - - private bool ConsumeLazerMissIncrease(int missCount) - { - if (missCount < _lastObservedLazerMissCount) - { - _lastObservedLazerMissCount = missCount; - return false; - } - - var increased = missCount > _lastObservedLazerMissCount; - _lastObservedLazerMissCount = missCount; - return increased; - } - - private void SyncHitsounds(SyncSessionContext ctx, int newMs) - { - _beatmapHitsoundLoader.AdvanceCachingWindow(newMs); - PlayAutoPlaybackIfNeeded(ctx); - PlayManualPlaybackIfNeeded(ctx); - } - - private void PlayAutoPlaybackIfNeeded(SyncSessionContext ctx) - { - if (!_sharedViewModel.AutoMode && - (ctx.PlayMods & Mods.Autoplay) == 0 && - (ctx.PlayMods & Mods.Relax) == 0 && - !ctx.IsReplay) return; - - _playbackBuffer.Clear(); - _gameplaySessionManager.CurrentHitsoundSequencer.ProcessAutoPlay(_playbackBuffer, false); - - var count = _playbackBuffer.Count; - for (int i = 0; i < count; i++) - { - var playbackObject = _playbackBuffer[i]; - _sfxPlaybackService.DispatchPlayback(playbackObject); - } - } - - private void PlayManualPlaybackIfNeeded(SyncSessionContext ctx) - { - _playbackBuffer.Clear(); - _gameplaySessionManager.CurrentHitsoundSequencer.ProcessAutoPlay(_playbackBuffer, true); - - var count = _playbackBuffer.Count; - for (int i = 0; i < count; i++) - { - var playbackObject = _playbackBuffer[i]; - if (_gameplaySessionManager.OsuFile.General.Mode == GameMode.Mania && - playbackObject.PlaybackEvent is SampleEvent { Layer: SampleLayer.Sampling }) - { - _sfxPlaybackService.DispatchPlayback(playbackObject, playbackObject.PlaybackEvent.Volume * 0.866666666f); - continue; - } - - _sfxPlaybackService.DispatchPlayback(playbackObject); - } - } -} diff --git a/src/Common/KeyAsio.Shared/Sync/States/ResultsState.cs b/src/Common/KeyAsio.Shared/Sync/States/ResultsState.cs deleted file mode 100644 index 6f1f9ca1..00000000 --- a/src/Common/KeyAsio.Shared/Sync/States/ResultsState.cs +++ /dev/null @@ -1,36 +0,0 @@ -using KeyAsio.Plugins.Abstractions.OsuMemory; -using KeyAsio.Shared.OsuMemory; - -namespace KeyAsio.Shared.Sync.States; - -public class ResultsState : IGameState -{ - public ResultsState() - { - } - - public Task EnterAsync(SyncSessionContext ctx, OsuMemoryStatus from) - { - return Task.CompletedTask; - } - - public void Exit(SyncSessionContext ctx, OsuMemoryStatus to) - { - } - - public void OnTick(SyncSessionContext ctx, int prevMs, int currMs, bool isAudioPaused) - { - } - - public void OnComboChanged(SyncSessionContext ctx, int oldCombo, int newCombo) - { - } - - public void OnBeatmapChanged(SyncSessionContext ctx, BeatmapIdentifier beatmap) - { - } - - public void OnModsChanged(SyncSessionContext ctx, Mods oldMods, Mods newMods) - { - } -} \ No newline at end of file diff --git a/src/Common/KeyAsio.Shared/Sync/SyncContextWrapper.cs b/src/Common/KeyAsio.Shared/Sync/SyncContextWrapper.cs deleted file mode 100644 index aa9cbfe8..00000000 --- a/src/Common/KeyAsio.Shared/Sync/SyncContextWrapper.cs +++ /dev/null @@ -1,49 +0,0 @@ -using KeyAsio.Plugins.Abstractions; -using KeyAsio.Shared.OsuMemory; - -namespace KeyAsio.Shared.Sync; - -public class SyncContextWrapper : ISyncContext -{ - private readonly SyncSessionContext _context; - - private BeatmapIdentifier _cachedIdentifier; - private SyncBeatmapInfo? _cachedInfo; - - public SyncContextWrapper(SyncSessionContext context) - { - _context = context; - } - - public int PlayTime => _context.PlayTime; - public bool IsStarted => _context.IsStarted; - - public SyncOsuStatus OsuStatus => (SyncOsuStatus)_context.OsuStatus; - - public long LastUpdateTimestamp => _context.LastUpdateTimestamp; - - public int PlayMods => (int)_context.PlayMods; - public SyncStatistics Statistics => _context.Statistics; - public SyncHitErrors HitErrors => _context.HitErrors; - public bool IsAudioPaused => _context.IsAudioPaused; - - public SyncBeatmapInfo? Beatmap - { - get - { - var current = _context.Beatmap; - if (current == _cachedIdentifier) return _cachedInfo; - - _cachedIdentifier = current; - _cachedInfo = current.Folder == null - ? null - : new SyncBeatmapInfo - { - Folder = current.Folder, - Filename = current.Filename - }; - - return _cachedInfo; - } - } -} diff --git a/src/Common/KeyAsio.Shared/Sync/SyncController.cs b/src/Common/KeyAsio.Shared/Sync/SyncController.cs deleted file mode 100644 index 355f1ffe..00000000 --- a/src/Common/KeyAsio.Shared/Sync/SyncController.cs +++ /dev/null @@ -1,343 +0,0 @@ -using System.Diagnostics; -using KeyAsio.Core.Audio; -using KeyAsio.Core.Memory.Utils; -using KeyAsio.Plugins.Abstractions; -using KeyAsio.Plugins.Abstractions.OsuMemory; -using KeyAsio.Shared.Models; -using KeyAsio.Shared.OsuMemory; -using KeyAsio.Shared.Sync.AudioProviders; -using KeyAsio.Shared.Sync.Services; -using KeyAsio.Shared.Sync.States; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; - -namespace KeyAsio.Shared.Sync; - -public class SyncController : IDisposable -{ - private readonly SyncSessionContext _syncSessionContext; - private readonly GameStateMachine _stateMachine; - private readonly IPluginManager _pluginManager; - private readonly ILogger _logger; - private readonly List _activeSyncPlugins; - - public SyncController(ILogger playingStateLogger, - ILogger logger, - IServiceProvider serviceProvider, - AppSettings appSettings, - IPlaybackEngine playbackEngine, - SharedViewModel sharedViewModel, - GameplayAudioService gameplayAudioService, - BeatmapHitsoundLoader beatmapHitsoundLoader, - SfxPlaybackService sfxPlaybackService, - GameplaySessionManager gameplaySessionManager, - SyncSessionContext syncSessionContext, - IPluginManager pluginManager) - { - _syncSessionContext = syncSessionContext; - _pluginManager = pluginManager; - _logger = logger; - - _activeSyncPlugins = _pluginManager.GetAllPlugins().OfType().ToList(); - - _syncSessionContext.OnBeatmapChanged = OnBeatmapChanged; - _syncSessionContext.OnComboChanged = OnComboChanged; - _syncSessionContext.OnStatusChanged = OnStatusChanged; - _syncSessionContext.OnPlayModsChanged = OnPlayModsChanged; - - var standardAudioProvider = new StandardHitsoundSequencer( - serviceProvider.GetRequiredService>(), - appSettings, syncSessionContext, playbackEngine, gameplayAudioService, gameplaySessionManager); - var taikoAudioProvider = new TaikoHitsoundSequencer( - serviceProvider.GetRequiredService>(), - appSettings, syncSessionContext, playbackEngine, gameplayAudioService, gameplaySessionManager); - var maniaAudioProvider = new ManiaHitsoundSequencer( - serviceProvider.GetRequiredService>(), - appSettings, syncSessionContext, playbackEngine, gameplayAudioService, gameplaySessionManager); - var catchAudioProvider = new CatchHitsoundSequencer( - serviceProvider.GetRequiredService>(), - appSettings, syncSessionContext, playbackEngine, gameplayAudioService, gameplaySessionManager); - gameplaySessionManager.InitializeProviders(standardAudioProvider, taikoAudioProvider, catchAudioProvider, maniaAudioProvider); - - // Initialize realtime state machine with scene mappings - _stateMachine = new GameStateMachine(new Dictionary - { - [OsuMemoryStatus.Playing] = new PlayingState(playingStateLogger, appSettings, playbackEngine, - beatmapHitsoundLoader, sfxPlaybackService, sharedViewModel, gameplaySessionManager, - gameplayAudioService), - [OsuMemoryStatus.ResultsScreen] = new ResultsState(), - [OsuMemoryStatus.NotRunning] = new NotRunningState(), - [OsuMemoryStatus.SongSelection] = new BrowsingState(gameplaySessionManager), - [OsuMemoryStatus.EditSongSelection] = new BrowsingState(gameplaySessionManager), - [OsuMemoryStatus.MainView] = new BrowsingState(gameplaySessionManager), - [OsuMemoryStatus.MultiSongSelection] = new BrowsingState(gameplaySessionManager), - }); - } - - private CancellationTokenSource? _syncLoopCts; - - public void Start() - { - if (_syncLoopCts != null) return; - _syncLoopCts = new CancellationTokenSource(); - var token = _syncLoopCts.Token; - - foreach (var plugin in _activeSyncPlugins) - { - try - { - plugin.OnSyncStart(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error starting sync plugin {PluginName} ({PluginId})", plugin.Name, plugin.Id); - } - } - - Task.Factory.StartNew(() => RunSyncLoop(token, _activeSyncPlugins), TaskCreationOptions.LongRunning); - } - - public void Stop() - { - _syncLoopCts?.Cancel(); - _syncLoopCts?.Dispose(); - _syncLoopCts = null; - - foreach (var plugin in _activeSyncPlugins) - { - try - { - plugin.OnSyncStop(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error stopping sync plugin {PluginName} ({PluginId})", plugin.Name, plugin.Id); - } - } - } - - private void RunSyncLoop(CancellationToken token, List plugins) - { - using var highPrecisionTimerScope = new HighPrecisionTimerScope(); - var contextWrapper = new SyncContextWrapper(_syncSessionContext); - - const long intervalMs = 2; // 500Hz - var stopwatch = Stopwatch.StartNew(); - var nextTrigger = stopwatch.ElapsedMilliseconds; - var oldTime = _syncSessionContext.PlayTime; - - // Cache variables - List cachedHandlers = new(); - var cachedStatus = SyncOsuStatus.Unknown; - - while (!token.IsCancellationRequested) - { - var current = stopwatch.ElapsedMilliseconds; - var wait = nextTrigger - current; - - if (wait > 0) - { - Thread.Sleep(Math.Max(0, (int)wait)); - } - - var newTime = _syncSessionContext.PlayTime; - var currentStatus = (SyncOsuStatus)_syncSessionContext.OsuStatus; - - // Update cache if status changed - if (currentStatus != cachedStatus) - { - cachedHandlers = _pluginManager.GetActiveHandlers(currentStatus).ToList(); - cachedStatus = currentStatus; - } - - // Invoke plugins - foreach (var plugin in plugins) - { - try - { - plugin.OnTick(contextWrapper, newTime - oldTime); - } - catch - { - // Suppress plugin errors to keep loop running - } - } - - // Check if any plugin overrides the current state - bool blockBase = false; - foreach (var handler in cachedHandlers) - { - var result = handler.HandleTick(contextWrapper); - if ((result & HandleResult.BlockBaseLogic) != 0) - { - blockBase = true; - } - - if ((result & HandleResult.BlockLowerPriority) != 0) - { - break; - } - } - - if (!blockBase) - { - _stateMachine.Current?.OnTick(_syncSessionContext, oldTime, newTime, _syncSessionContext.IsAudioPaused); - } - - oldTime = newTime; - - nextTrigger += intervalMs; - - if (stopwatch.ElapsedMilliseconds > nextTrigger + 50) - { - nextTrigger = stopwatch.ElapsedMilliseconds; - } - } - } - - private Task OnComboChanged(int oldCombo, int newCombo) - { - _stateMachine.Current?.OnComboChanged(_syncSessionContext, oldCombo, newCombo); - return Task.CompletedTask; - } - - private async Task OnStatusChanged(OsuMemoryStatus oldStatus, OsuMemoryStatus newStatus) - { - var contextWrapper = new SyncContextWrapper(_syncSessionContext); - var oldHandlers = _pluginManager.GetActiveHandlers((SyncOsuStatus)oldStatus); - var newHandlers = _pluginManager.GetActiveHandlers((SyncOsuStatus)newStatus); - - // 1. Exit Old State - bool blockBaseExit = false; - foreach (var handler in oldHandlers) - { - try - { - var result = handler.HandleExit(contextWrapper); - if ((result & HandleResult.BlockBaseLogic) != 0) - { - blockBaseExit = true; - } - - if ((result & HandleResult.BlockLowerPriority) != 0) - { - break; - } - } - catch - { - // Ignore - } - } - - if (!blockBaseExit) - { - _stateMachine.ExitCurrent(_syncSessionContext, newStatus); - } - - // 2. Enter New State - bool blockBaseEnter = false; - foreach (var handler in newHandlers) - { - try - { - var result = handler.HandleEnter(contextWrapper); - if ((result & HandleResult.BlockBaseLogic) != 0) - { - blockBaseEnter = true; - } - - if ((result & HandleResult.BlockLowerPriority) != 0) - { - break; - } - } - catch - { - // Ignore - } - } - - if (!blockBaseEnter) - { - await _stateMachine.EnterFromAsync(_syncSessionContext, oldStatus, newStatus); - } - - foreach (var plugin in _activeSyncPlugins) - { - try - { - plugin.OnStatusChanged((SyncOsuStatus)oldStatus, (SyncOsuStatus)newStatus); - } - catch - { - // Ignore - } - } - } - - private Task OnBeatmapChanged(BeatmapIdentifier oldBeatmap, BeatmapIdentifier newBeatmap) - { - var absBeatmap = new SyncBeatmapInfo - { - Folder = newBeatmap.Folder, - Filename = newBeatmap.Filename - }; - - // Notify plugins (Legacy behavior, always notified) - foreach (var plugin in _activeSyncPlugins) - { - try - { - plugin.OnBeatmapChanged(absBeatmap); - } - catch - { - // Ignore - } - } - - // Notify active handlers - var handlers = _pluginManager.GetActiveHandlers((SyncOsuStatus)_syncSessionContext.OsuStatus); - bool blockBase = false; - foreach (var handler in handlers) - { - try - { - var result = handler.HandleBeatmapChange(absBeatmap); - - if ((result & HandleResult.BlockBaseLogic) != 0) - { - blockBase = true; - } - - if ((result & HandleResult.BlockLowerPriority) != 0) - { - break; - } - } - catch - { - // Ignore - } - } - - if (!blockBase) - { - _stateMachine.Current?.OnBeatmapChanged(_syncSessionContext, newBeatmap); - } - - return Task.CompletedTask; - } - - private Task OnPlayModsChanged(Mods oldMods, Mods newMods) - { - _stateMachine.Current?.OnModsChanged(_syncSessionContext, oldMods, newMods); - return Task.CompletedTask; - } - - public void Dispose() - { - Stop(); - } -} \ No newline at end of file diff --git a/src/Common/KeyAsio.Shared/Sync/SyncSessionContext.cs b/src/Common/KeyAsio.Shared/Sync/SyncSessionContext.cs deleted file mode 100644 index c2b369ab..00000000 --- a/src/Common/KeyAsio.Shared/Sync/SyncSessionContext.cs +++ /dev/null @@ -1,300 +0,0 @@ -using System.Diagnostics; -using System.Runtime.CompilerServices; -using System.Text; -using KeyAsio.Core.OsuAudio.Hitsounds; -using KeyAsio.Plugins.Abstractions; -using KeyAsio.Plugins.Abstractions.OsuMemory; -using KeyAsio.Shared.Events; -using KeyAsio.Shared.OsuMemory; -using KeyAsio.Shared.Utils; - -namespace KeyAsio.Shared.Sync; - -public enum GameClientType -{ - Stable, // osu!stable: 仅在 Playing 状态应用倍率 - Lazer // osu!lazer: 任何时候都应用倍率 -} - -public class SyncSessionContext -{ - public ValueChangedAsyncEventHandler? OnComboChanged; - public ValueChangedAsyncEventHandler? OnPlayModsChanged; - public ValueChangedAsyncEventHandler? OnStatusChanged; - public ValueChangedAsyncEventHandler? OnBeatmapChanged; - - private readonly AppSettings _appSettings; - - private long _anchorTick; // 锚点 Tick - private int _anchorTime; // 锚点时间 (ms) - private double _playbackRate = 1.0; - private static readonly double s_tickToMs = 1000.0 / Stopwatch.Frequency; - - // 音频同步与防倒退 - private int _lastReturnedPlayTime = int.MinValue; - - private long _frozenStartTick; // 冻结检测 - private bool _isFrozen; - private const int FrozenTimeoutMs = 200; // 冻结超时阈值 - - public SyncSessionContext(AppSettings appSettings) - { - _appSettings = appSettings; - ClientType = GameClientType.Stable; // 默认值 - - _anchorTick = Stopwatch.GetTimestamp(); - } - - public GameClientType ClientType { get; set; } - public bool IsStarted { get; set; } - public bool IsReplay { get; set; } - - /// - /// 音频是否处于暂停/冻结状态。 - /// 由 PlayTime 的单调性保护逻辑自动维护:当预测时间持续微小倒退时为 true,恢复正常前进时为 false。 - /// - public bool IsAudioPaused => _isFrozen; - public int ProcessId { get; set; } = -1; - - public string? Username - { - get; - set - { - if (field == value) return; - field = value; - if (!string.IsNullOrEmpty(value)) - { - _appSettings.Logging.PlayerBase64 = EncodeUtils.GetBase64String(value, Encoding.ASCII); - } - } - } - - public Mods PlayMods - { - get; - set - { - if (field == value) return; - var oldValue = field; - field = value; - - UpdatePlaybackRate(value); - OnPlayModsChanged?.Invoke(oldValue, value); - } - } - - private void UpdatePlaybackRate(Mods mods) - { - var oldRate = _playbackRate; - - if (mods.HasFlag(Mods.DoubleTime) || mods.HasFlag(Mods.Nightcore)) - _playbackRate = 1.5; - else if (mods.HasFlag(Mods.HalfTime)) - _playbackRate = 0.75; - else - _playbackRate = 1.0; - - // 倍率改变极其罕见,但若发生,需要重置锚点以保证连续性 - if (Math.Abs(oldRate - _playbackRate) > 0.001) - { - // 获取当前的预测值(不带副作用) - int currentPredictedTime = CalculatePredictedTime(); - - // 重新锚定 - _anchorTick = Stopwatch.GetTimestamp(); - _anchorTime = currentPredictedTime; - - // 重要:同步更新最后返回时间,防止因重置导致的微小回退 - _lastReturnedPlayTime = currentPredictedTime; - _isFrozen = false; - } - } - - /// - /// 根据当前状态计算预测时间。 - /// - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private int CalculatePredictedTime() - { - if (OsuStatus is OsuMemoryStatus.NotRunning or OsuMemoryStatus.Unknown) - { - return _anchorTime; - } - - double effectiveRate = _playbackRate; - if (ClientType == GameClientType.Stable && OsuStatus != OsuMemoryStatus.Playing) - { - effectiveRate = 1.0; - } - - long currentTick = Stopwatch.GetTimestamp(); - double elapsedRealMs = (currentTick - _anchorTick) * s_tickToMs; - - // 限制最大预测范围(例如防止暂停后恢复时的瞬间飞跃),这里取 500ms 作为安全上限 - // 如果是正常游玩,BaseMemoryTime 会频繁更新,不会超过这个值 - const int realMs = 100; - if (elapsedRealMs > realMs) elapsedRealMs = realMs; - - double interpolatedMs = elapsedRealMs * effectiveRate; - return _anchorTime + (int)interpolatedMs; - } - - public int PlayTime - { - get - { - // 1. 获取纯计算的目标时间 - int targetTime = CalculatePredictedTime(); - - // 2. 音频同步关键:单调性与跳跃保护 - if (targetTime < _lastReturnedPlayTime) - { - int backwardAmount = _lastReturnedPlayTime - targetTime; - - // Case A: 大幅度倒退 (> 100ms) -> 视为 Seek 或 Retry - if (backwardAmount > 100) - { - _lastReturnedPlayTime = targetTime; - _isFrozen = false; - } - // Case B: 微小倒退 -> 启动/维持冻结逻辑 - else - { - var currentTick = Stopwatch.GetTimestamp(); - - if (!_isFrozen) - { - // 刚开始倒退,进入冻结 - _isFrozen = true; - _frozenStartTick = currentTick; - } - else - { - // 已经冻结,检查是否超时 - double frozenDuration = (currentTick - _frozenStartTick) * s_tickToMs; - if (frozenDuration > FrozenTimeoutMs) - { - // 超时:强制同步(可能是游戏真的卡顿后回退了) - _lastReturnedPlayTime = targetTime; - _isFrozen = false; - } - } - - // 冻结期间,返回旧值 - return _lastReturnedPlayTime; - } - } - else - { - // 正常前进 - _lastReturnedPlayTime = targetTime; - _isFrozen = false; - } - - return _lastReturnedPlayTime; - } - } - - public int BaseMemoryTime - { - get => _anchorTime; - set - { - var currentTick = Stopwatch.GetTimestamp(); - var oldValue = _anchorTime; - - // 1. 极小抖动过滤 (内存读取误差) - if (value < oldValue && oldValue - value < 5) return; - - // 2. 只有值真正改变才处理 - if (value != oldValue) - { - int predicted = CalculatePredictedTime(); - - // 动态阈值:DT 模式下允许更大的预测误差 - double dynamicThreshold = 50 * Math.Max(1.0, _playbackRate); - - // 检测是否发生了“大跳跃”(Seek / Retry) - // 如果内存读到的新值和我们预测的值差距过大,说明预测失效,需要硬同步 - if (Math.Abs(value - predicted) > dynamicThreshold) - { - // 允许跳跃,重置单调性保护 - _lastReturnedPlayTime = int.MinValue; - _isFrozen = false; - } - - _anchorTime = value; - _anchorTick = currentTick; - } - - LastUpdateTimestamp = currentTick; - } - } - - public int Combo - { - get; - set - { - if (field == value) return; - var oldValue = field; - field = value; - OnComboChanged?.Invoke(oldValue, value); - } - } - - public int Score { get; set; } - public SyncStatistics Statistics { get; set; } = SyncStatistics.Empty; - public SyncHitErrors HitErrors { get; set; } = SyncHitErrors.Empty; - public IBeatmapResourceCatalog? BeatmapResourceCatalog { get; set; } - - public OsuMemoryStatus OsuStatus - { - get; - set - { - if (field == value) return; - var oldValue = field; - field = value; - - // 状态切换时,重置所有保护逻辑,允许时间跳变 - _lastReturnedPlayTime = int.MinValue; - _isFrozen = false; - - if (value != OsuMemoryStatus.Playing) - { - Statistics = SyncStatistics.Empty; - HitErrors = SyncHitErrors.Empty; - } - - // 重置锚点 - _anchorTick = Stopwatch.GetTimestamp(); - - OnStatusChanged?.Invoke(oldValue, value); - } - } = OsuMemoryStatus.Unknown; - - public string SyncedStatusText => OsuStatus is OsuMemoryStatus.NotRunning or OsuMemoryStatus.Unknown - ? "OFFLINE" - : "SYNCED"; - - public BeatmapIdentifier Beatmap - { - get; - set - { - if (field == value) return; - var oldValue = field; - field = value; - OnBeatmapChanged?.Invoke(oldValue, value); - } - } - - public long LastUpdateTimestamp - { - [MethodImpl(MethodImplOptions.AggressiveInlining)] - get; - private set; - } -} diff --git a/src/Common/KeyAsio.Shared/UiDispatcher.cs b/src/Common/KeyAsio.Shared/UiDispatcher.cs deleted file mode 100644 index dae366ae..00000000 --- a/src/Common/KeyAsio.Shared/UiDispatcher.cs +++ /dev/null @@ -1,86 +0,0 @@ -namespace KeyAsio.Shared; - -public static class UiDispatcher -{ - private static SynchronizationContext? _uiContext; - - /// - /// Should be called on UI thread - /// - public static void SetUiSynchronizationContext(SynchronizationContext? synchronizationContext = null) - { - if (synchronizationContext != null) - { - _uiContext = synchronizationContext; - return; - } - - _uiContext ??= SynchronizationContext.Current; - - if (_uiContext != null) return; - var assemblies = AppDomain.CurrentDomain.GetAssemblies(); - foreach (var assembly in assemblies) - { - var fileName = Path.GetFileName(assembly.Location); - if (fileName == "System.Windows.Forms.dll") - { - var type = assembly.DefinedTypes.First(k => k.Name.StartsWith("WindowsFormsSynchronizationContext")); - _uiContext = Activator.CreateInstance(type) as SynchronizationContext; - break; - } - else if (fileName == "WindowsBase.dll") - { - var type = assembly.DefinedTypes.First(k => k.Name.StartsWith("DispatcherSynchronizationContext")); - _uiContext = Activator.CreateInstance(type) as SynchronizationContext; - break; - } - } - } - - public static void Invoke(Action action) - { - if (action == null) throw new ArgumentNullException(nameof(action)); - if (_uiContext != null) - { - _uiContext.Send(obj => { action?.Invoke(); }, null); - } - else - { - try - { - action?.Invoke(); - } - catch (Exception ex) - { - Console.WriteLine("UiContext execute error: " + ex.Message); - } - } - } - - public static async Task InvokeAsync(Action action) - { - var tcs = new TaskCompletionSource(); - - _uiContext.Post(_ => - { - action.Invoke(); - tcs.TrySetResult(); - }, null); - await tcs.Task; - - } - - public static async Task InvokeAsync(Func action) - { - var tcs = new TaskCompletionSource(); - Task? task = null; - _uiContext.Post(_ => - { - task = action.Invoke(); - tcs.TrySetResult(); - }, null); - - await tcs.Task; - await task!; - } -} \ No newline at end of file diff --git a/src/Common/KeyAsio.Shared/Utils/AsyncLock.cs b/src/Common/KeyAsio.Shared/Utils/AsyncLock.cs deleted file mode 100644 index 65338008..00000000 --- a/src/Common/KeyAsio.Shared/Utils/AsyncLock.cs +++ /dev/null @@ -1,44 +0,0 @@ -namespace KeyAsio.Shared.Utils; - -public class AsyncLock : IDisposable -{ - private class AsyncLockImpl : IDisposable - { - private readonly AsyncLock _parent; - public AsyncLockImpl(AsyncLock parent) => _parent = parent; - public void Dispose() => _parent._semaphoreSlim.Release(); - } - - private readonly AsyncLockImpl _asyncLockImpl; - private readonly SemaphoreSlim _semaphoreSlim; - private readonly Task _completeTask; - - public AsyncLock() - { - _semaphoreSlim = new SemaphoreSlim(1, 1); - _asyncLockImpl = new AsyncLockImpl(this); - _completeTask = Task.FromResult((IDisposable)_asyncLockImpl); - } - - public IDisposable Lock() - { - _semaphoreSlim.Wait(); - return _asyncLockImpl; - } - - public Task LockAsync(CancellationToken cancellationToken = default) - { - var task = _semaphoreSlim.WaitAsync(cancellationToken); - return task.IsCompleted - ? _completeTask - : task.ContinueWith( - (_, state) => (IDisposable)((AsyncLock)state!)._asyncLockImpl, - this, CancellationToken.None, - TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); - } - - public void Dispose() - { - _semaphoreSlim.Dispose(); - } -} \ No newline at end of file diff --git a/src/Common/KeyAsio.Shared/Utils/AsyncSequentialWorker.cs b/src/Common/KeyAsio.Shared/Utils/AsyncSequentialWorker.cs deleted file mode 100644 index ca31a54f..00000000 --- a/src/Common/KeyAsio.Shared/Utils/AsyncSequentialWorker.cs +++ /dev/null @@ -1,109 +0,0 @@ -using System.Threading.Channels; -using Microsoft.Extensions.Logging; - -namespace KeyAsio.Shared.Utils; - -public sealed class AsyncSequentialWorker : IDisposable -{ - private readonly Channel> _channel; - private readonly Task _workerLoop; - private readonly CancellationTokenSource _cts = new(); - private readonly ILogger? _logger; - private readonly string _name; - - public AsyncSequentialWorker(ILogger? logger = null, string name = "AsyncSequentialWorker") - { - _logger = logger; - _name = name; - _channel = Channel.CreateUnbounded>(new UnboundedChannelOptions - { - SingleReader = true, - SingleWriter = false - }); - _workerLoop = Task.Factory.StartNew(LoopAsync, TaskCreationOptions.LongRunning).Unwrap(); - } - - public void Enqueue(Func workItem) - { - _channel.Writer.TryWrite(workItem); - } - - public void Enqueue(Func workItem) - { - _channel.Writer.TryWrite(_ => workItem()); - } - - public Task EnqueueAsync(Func workItem) - { - var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - - _channel.Writer.TryWrite(async ct => - { - try - { - await workItem(ct).ConfigureAwait(false); - tcs.TrySetResult(); - } - catch (OperationCanceledException oce) - { - tcs.TrySetCanceled(oce.CancellationToken); - throw; - } - catch (Exception ex) - { - tcs.TrySetException(ex); - throw; - } - }); - - return tcs.Task; - } - - public Task EnqueueAsync(Func workItem) - { - return EnqueueAsync(_ => workItem()); - } - - private async Task LoopAsync() - { - var reader = _channel.Reader; - try - { - while (await reader.WaitToReadAsync(_cts.Token)) - { - while (reader.TryRead(out var workItem)) - { - if (_cts.IsCancellationRequested) break; - try - { - await workItem(_cts.Token).ConfigureAwait(false); - } - catch (OperationCanceledException) - { - // Ignore - } - catch (Exception ex) - { - _logger?.LogError(ex, "Error processing work item in {Name}", _name); - } - } - } - } - catch (OperationCanceledException) - { - // Ignore - } - catch (Exception ex) - { - _logger?.LogError(ex, "Fatal error in {Name} loop", _name); - } - } - - public void Dispose() - { - if (_cts.IsCancellationRequested) return; - _cts.Cancel(); - _channel.Writer.TryComplete(); - _cts.Dispose(); - } -} diff --git a/src/Common/KeyAsio.Shared/Utils/DebugUtils.cs b/src/Common/KeyAsio.Shared/Utils/DebugUtils.cs deleted file mode 100644 index e24c9805..00000000 --- a/src/Common/KeyAsio.Shared/Utils/DebugUtils.cs +++ /dev/null @@ -1,157 +0,0 @@ -using System.Runtime.CompilerServices; -using System.Text; -using KeyAsio.Core.Audio.Utils; -using Microsoft.Extensions.Logging; - -namespace KeyAsio.Shared.Utils; - -public static class DebugUtils -{ - private const string Normal = "│ "; - private const string Middle = "├─ "; - private const string Last = "└─ "; - - public static string ToFullTypeMessage(this Exception exception) - { - return ExceptionToFullMessage(exception, new StringBuilder(), 0, true, true)!; - } - - public static string ToSimpleTypeMessage(this Exception exception) - { - return ExceptionToFullMessage(exception, new StringBuilder(), 0, true, false)!; - } - - public static string ToMessage(this Exception exception) - { - return ExceptionToFullMessage(exception, new StringBuilder(), 0, true, null)!; - } - - private static string? ExceptionToFullMessage(Exception exception, StringBuilder stringBuilder, int deep, - bool isLastItem, bool? includeFullType) - { - var hasChild = exception.InnerException != null; - if (deep > 0) - { - for (int i = 0; i < deep; i++) - { - if (i == deep - 1) - { - stringBuilder.Append((isLastItem && !hasChild) ? Last : Middle); - } - else - { - stringBuilder.Append(Normal + " "); - } - } - } - - var agg = exception as AggregateException; - if (includeFullType == true) - { - var prefix = agg == null ? exception.GetType().ToString() : "!!AggregateException"; - stringBuilder.Append($"{prefix}: {GetTrueExceptionMessage(exception)}"); - } - else if (includeFullType == false) - { - var prefix = exception.GetType().Name; - stringBuilder.Append($"{prefix}: {GetTrueExceptionMessage(exception)}"); - } - else - { - stringBuilder.Append(GetTrueExceptionMessage(exception)); - } - - stringBuilder.AppendLine(); - if (!hasChild) - { - return deep == 0 ? stringBuilder.ToString().Trim() : null; - } - - if (agg != null) - { - for (int i = 0; i < agg.InnerExceptions.Count; i++) - { - ExceptionToFullMessage(agg.InnerExceptions[i], stringBuilder, deep + 1, - i == agg.InnerExceptions.Count - 1, includeFullType); - } - } - else - { - ExceptionToFullMessage(exception.InnerException!, stringBuilder, deep + 1, true, includeFullType); - } - - return deep == 0 ? stringBuilder.ToString().Trim() : null; - - static string GetTrueExceptionMessage(Exception ex) - { - if (ex is AggregateException { InnerException: { } } agg) - { - var complexMessage = agg.Message; - var i = complexMessage.IndexOf(agg.InnerException.Message, StringComparison.Ordinal); - if (i == -1) - return complexMessage; - return complexMessage.Substring(0, i - 2); - } - - return string.IsNullOrWhiteSpace(ex.Message) ? "{Empty Message}" : ex.Message; - } - } - - public static void InvokeAndPrint(Action method, string caller = "anonymous method") - { - var sw = HighPrecisionTimer.StartNew(); - method?.Invoke(); - Console.WriteLine($"[{caller}] Executed in {sw.Elapsed.TotalMilliseconds:#0.000} ms"); - } - - public static T InvokeAndPrint(Func method, string caller = "anonymous method") - { - var sw = HighPrecisionTimer.StartNew(); - var value = method.Invoke(); - Console.WriteLine($"[{caller}] Executed in {sw.Elapsed.TotalMilliseconds:#0.000} ms"); - return value; - } - - public static IDisposable CreateTimer(string name, ILogger? logger = null) - { - return new TimerImpl(name, logger); - } - - private class TimerImpl : IDisposable - { - private readonly string _name; - private readonly ILogger? _logger; - private readonly HighPrecisionTimer _sw; - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public TimerImpl(string name, ILogger? logger) - { - _name = name; - _logger = logger; - - if (_logger == null) - { - Console.WriteLine($"[{_name}] executing"); - } - else - { - _logger.LogTrace("[{Name}] executing", _name); - } - - _sw = HighPrecisionTimer.StartNew(); - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public void Dispose() - { - if (_logger == null) - { - Console.WriteLine($"[{_name}] executed in {_sw.Elapsed.TotalMilliseconds:#0.000}ms"); - } - else - { - _logger.LogDebug("[{Name}] executed in {Elapsed:#0.000}ms", _name, _sw.Elapsed.TotalMilliseconds); - } - } - } -} diff --git a/src/Common/KeyAsio.Shared/Utils/EncodeUtils.cs b/src/Common/KeyAsio.Shared/Utils/EncodeUtils.cs deleted file mode 100644 index 35494d2e..00000000 --- a/src/Common/KeyAsio.Shared/Utils/EncodeUtils.cs +++ /dev/null @@ -1,57 +0,0 @@ -using System.Runtime.CompilerServices; -using System.Text; - -namespace KeyAsio.Shared.Utils; - -public static class EncodeUtils -{ - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static string GetBase64String(string value, Encoding encoding) - { - return Convert.ToBase64String(encoding.GetBytes(value)); - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static string FromBase64String(string value, Encoding encoding) - { - return encoding.GetString(Convert.FromBase64String(value)); - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static string FromBase64StringEmptyIfError(string value, Encoding encoding) - { - try - { - return FromBase64String(value, encoding); - } - catch - { - return ""; - } - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static string GetBase64String(string value) - { - return GetBase64String(value, Encoding.UTF8); - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static string FromBase64String(string value) - { - return FromBase64String(value, Encoding.UTF8); - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static string FromBase64StringEmptyIfError(string value) - { - try - { - return FromBase64String(value, Encoding.UTF8); - } - catch - { - return ""; - } - } -} \ No newline at end of file diff --git a/src/Common/KeyAsio.Shared/Utils/ObservableRangeCollection.cs b/src/Common/KeyAsio.Shared/Utils/ObservableRangeCollection.cs deleted file mode 100644 index a124982f..00000000 --- a/src/Common/KeyAsio.Shared/Utils/ObservableRangeCollection.cs +++ /dev/null @@ -1,251 +0,0 @@ -using System.Collections.ObjectModel; -using System.Collections.Specialized; -using System.ComponentModel; - -namespace KeyAsio.Shared.Utils; - -public class ObservableRangeCollection : ObservableCollection -{ - #region Constructors - - public ObservableRangeCollection() - { - } - - public ObservableRangeCollection(IEnumerable collection) : base(collection) { } - - #endregion - - #region AddRange - - /// - /// 添加多个元素到集合末尾 - /// - /// 要添加的元素集合 - /// 通知模式: Add(逐项通知) 或 Reset(重置通知) - public void AddRange(IEnumerable collection, NotifyCollectionChangedAction notificationMode = NotifyCollectionChangedAction.Add) - { - InsertRange(Count, collection, notificationMode); - } - - #endregion - - #region InsertRange - - /// - /// 在指定位置插入多个元素 - /// - public void InsertRange(int index, IEnumerable collection, NotifyCollectionChangedAction notificationMode = NotifyCollectionChangedAction.Add) - { - if (collection == null) - throw new ArgumentNullException(nameof(collection)); - if (index < 0 || index > Count) - throw new ArgumentOutOfRangeException(nameof(index)); - if (notificationMode != NotifyCollectionChangedAction.Add && notificationMode != NotifyCollectionChangedAction.Reset) - throw new ArgumentException("Mode must be either Add or Reset.", nameof(notificationMode)); - - // 提前物化集合,避免多次枚举 - var items = collection as IList ?? collection.ToList(); - if (items.Count == 0) return; - - CheckReentrancy(); - - // 执行插入 - var itemsList = (List)Items; - itemsList.InsertRange(index, items); - - // 发送通知 - if (notificationMode == NotifyCollectionChangedAction.Reset) - { - RaiseChangeNotificationEvents(NotifyCollectionChangedAction.Reset); - } - else - { - RaiseChangeNotificationEvents( - NotifyCollectionChangedAction.Add, - items as List ?? new List(items), - index); - } - } - - #endregion - - #region RemoveRange - - /// - /// 移除指定元素集合中的每个元素(第一次出现) - /// - public void RemoveRange(IEnumerable collection, NotifyCollectionChangedAction notificationMode = NotifyCollectionChangedAction.Reset) - { - if (collection == null) - throw new ArgumentNullException(nameof(collection)); - if (notificationMode != NotifyCollectionChangedAction.Remove && notificationMode != NotifyCollectionChangedAction.Reset) - throw new ArgumentException("Mode must be either Remove or Reset.", nameof(notificationMode)); - - if (Count == 0) return; - - var items = collection as IList ?? collection.ToList(); - if (items.Count == 0) return; - - CheckReentrancy(); - - if (notificationMode == NotifyCollectionChangedAction.Reset) - { - var removed = false; - foreach (var item in items) - { - if (Items.Remove(item)) - removed = true; - } - - if (removed) - { - RaiseChangeNotificationEvents(NotifyCollectionChangedAction.Reset); - } - - return; - } - - // Remove 模式: 只移除实际存在的元素 - var removedItems = new List(); - foreach (var item in items) - { - if (Items.Remove(item)) - removedItems.Add(item); - } - - if (removedItems.Count > 0) - { - RaiseChangeNotificationEvents( - NotifyCollectionChangedAction.Remove, - removedItems); - } - } - - /// - /// 移除指定范围的元素 - /// - public void RemoveRange(int index, int count) - { - if (index < 0) throw new ArgumentOutOfRangeException(nameof(index)); - if (count < 0) throw new ArgumentOutOfRangeException(nameof(count)); - if (index + count > Count) throw new ArgumentOutOfRangeException(nameof(index)); - - if (count == 0) return; - - if (count == 1) - { - RemoveItem(index); - return; - } - - CheckReentrancy(); - - var items = (List)Items; - var removedItems = items.GetRange(index, count); - items.RemoveRange(index, count); - - RaiseChangeNotificationEvents( - NotifyCollectionChangedAction.Remove, - removedItems, - index); - } - - #endregion - - #region Replace - - /// - /// 清空集合并替换为单个元素 - /// - public void Replace(T item) => ReplaceRange([item]); - - /// - /// 清空集合并替换为指定集合 - /// - public void ReplaceRange(IEnumerable collection) - { - if (collection == null) throw new ArgumentNullException(nameof(collection)); - - var items = collection as IList ?? collection.ToList(); - - if (Count == 0 && items.Count == 0) return; - - CheckReentrancy(); - - Items.Clear(); - - if (items.Count > 0) - { - var itemsList = (List)Items; - itemsList.AddRange(items); - } - - RaiseChangeNotificationEvents(NotifyCollectionChangedAction.Reset); - } - - #endregion - - #region RemoveAll (Bonus) - - /// - /// 移除所有满足条件的元素 - /// - /// 移除的元素数量 - public int RemoveAll(Predicate match) - { - if (match == null) throw new ArgumentNullException(nameof(match)); - if (Count == 0) return 0; - - CheckReentrancy(); - - var removedItems = new List(); - - // 从后向前遍历,避免索引问题 - for (int i = Count - 1; i >= 0; i--) - { - if (match(Items[i])) - { - removedItems.Insert(0, Items[i]); - Items.RemoveAt(i); - } - } - - if (removedItems.Count > 0) - { - RaiseChangeNotificationEvents(NotifyCollectionChangedAction.Reset); - } - - return removedItems.Count; - } - - #endregion - - #region Helper Methods - - /// - /// 触发属性和集合变更通知 - /// - private void RaiseChangeNotificationEvents( - NotifyCollectionChangedAction action, - List? changedItems = null, - int startingIndex = -1) - { - OnPropertyChanged(new PropertyChangedEventArgs(nameof(Count))); - OnPropertyChanged(new PropertyChangedEventArgs("Item[]")); - - if (changedItems == null) - { - OnCollectionChanged(new NotifyCollectionChangedEventArgs(action)); - } - else - { - OnCollectionChanged(new NotifyCollectionChangedEventArgs( - action, - changedItems: changedItems, - startingIndex: startingIndex)); - } - } - - #endregion -} \ No newline at end of file diff --git a/src/Common/KeyAsio.Shared/Utils/OsuLocator.cs b/src/Common/KeyAsio.Shared/Utils/OsuLocator.cs deleted file mode 100644 index 084c3765..00000000 --- a/src/Common/KeyAsio.Shared/Utils/OsuLocator.cs +++ /dev/null @@ -1,195 +0,0 @@ -using System.Diagnostics; -using System.Runtime.Versioning; -using Microsoft.Win32; - -namespace KeyAsio.Shared.Utils; - -public static class OsuLocator -{ - public const string LazerExeName = "osu!"; - public const string LazerProcessName = "osu!"; - - [SupportedOSPlatform("windows")] - public static string? FindFromRegistry() - { - using var reg = Registry.ClassesRoot.OpenSubKey(@"osu!\shell\open\command"); - var parameters = reg?.GetValue(null)?.ToString(); - if (string.IsNullOrWhiteSpace(parameters)) return null; - - var path = parameters.Replace(" \"%1\"", "").Trim(' ', '"'); - var dir = Path.GetDirectoryName(Path.GetFullPath(path)); - return Directory.Exists(dir) ? dir : null; - } - - public static string? FindFromRunningProcess(Process[]? processes = null) - { - processes ??= Process.GetProcessesByName("osu!"); - string? result = null; - - foreach (var proc in processes) - { - try - { - if (result != null) continue; - - if (proc.HasExited) continue; - if (proc.MainModule is not { } module) continue; - - var fileName = module.FileName; - if (string.IsNullOrEmpty(fileName)) continue; - - var fileVersionInfo = FileVersionInfo.GetVersionInfo(fileName); - if (fileVersionInfo.CompanyName == "ppy") - { - var detectedPath = Path.GetDirectoryName(Path.GetFullPath(fileName)); - if (Directory.Exists(detectedPath)) - { - result = detectedPath; - } - } - else if (fileVersionInfo.CompanyName == "ppy Pty Ltd") - { - // lazer wip - } - } - catch (System.ComponentModel.Win32Exception) - { - // Ignore access denied - } - finally - { - proc.Dispose(); - } - } - - return result; - } - - /// - /// Find the executable directory of a running osu!lazer process. - /// Returns null if lazer is not running or the directory cannot be determined. - /// - public static string? FindLazerExeDirectoryFromRunningProcess(Process[]? processes = null) - { - processes ??= Process.GetProcessesByName(LazerProcessName); - string? result = null; - - foreach (var proc in processes) - { - try - { - if (result != null) continue; - if (proc.HasExited) continue; - if (proc.MainModule is not { } module) continue; - - var fileName = module.FileName; - if (string.IsNullOrEmpty(fileName)) continue; - - var fileVersionInfo = FileVersionInfo.GetVersionInfo(fileName); - if (fileVersionInfo.CompanyName == "ppy Pty Ltd") - { - var detectedPath = Path.GetDirectoryName(Path.GetFullPath(fileName)); - if (Directory.Exists(detectedPath)) - { - result = detectedPath; - } - } - } - catch (System.ComponentModel.Win32Exception) - { - // Ignore access denied - } - finally - { - proc.Dispose(); - } - } - - return result; - } - - /// - /// Find the osu!lazer user data directory. - /// Lazer stores the user-selected data path in `storage.ini` (FullPath key), - /// which is located next to the lazer executable. If no custom path is set, - /// lazer uses %LOCALAPPDATA%/osu! on Windows. - /// - public static string? FindLazerUserDataDirectory(string? lazerExeDirectory = null) - { - if (lazerExeDirectory != null) - { - var configured = ReadLazerCustomStoragePath(lazerExeDirectory); - if (configured != null && Directory.Exists(configured)) - { - return configured; - } - } - - // Default location on Windows: %LOCALAPPDATA%/osu! - try - { - var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); - var defaultPath = Path.Combine(localAppData, "osu!"); - if (Directory.Exists(defaultPath)) - { - return defaultPath; - } - } - catch - { - // Ignore - } - - return null; - } - - /// - /// Read the custom storage path from lazer's storage.ini next to the executable. - /// Returns the path or null if not set / file missing. - /// - public static string? ReadLazerCustomStoragePath(string lazerExeDirectory) - { - var storageIniPath = Path.Combine(lazerExeDirectory, "storage.ini"); - if (!File.Exists(storageIniPath)) - { - return null; - } - - try - { - using var sr = new StreamReader(storageIniPath); - string? line; - string? fullPath = null; - - while ((line = sr.ReadLine()) != null) - { - var trimmed = line.AsSpan().Trim(); - if (trimmed.IsEmpty) continue; - - // Skip section headers like [StorageConfig] - if (trimmed[0] == '[') continue; - - var eq = trimmed.IndexOf('='); - if (eq < 0) continue; - - var key = trimmed.Slice(0, eq).Trim(); - if (key.Equals("FullPath", StringComparison.OrdinalIgnoreCase)) - { - var value = trimmed.Slice(eq + 1).Trim().ToString(); - if (!string.IsNullOrWhiteSpace(value)) - { - fullPath = value; - } - - break; - } - } - - return fullPath; - } - catch - { - return null; - } - } -} \ No newline at end of file diff --git a/src/Common/KeyAsio.Shared/Utils/RentedArray.cs b/src/Common/KeyAsio.Shared/Utils/RentedArray.cs deleted file mode 100644 index 82369b8f..00000000 --- a/src/Common/KeyAsio.Shared/Utils/RentedArray.cs +++ /dev/null @@ -1,37 +0,0 @@ -using System.Buffers; - -namespace KeyAsio.Shared.Utils; - -/// -/// Represents a rented array from an that returns itself to the pool when disposed. -/// -/// The type of elements in the array. -public readonly struct RentedArray : IDisposable -{ - /// - /// Gets the rented array instance. - /// - /// The rented array containing elements of type . - public T[] Array { get; } - - private readonly ArrayPool _pool; - - /// - /// Initializes a new instance of the struct. - /// - /// The array pool to rent from. - /// The minimum required length of the array. - /// - /// The actual length of the array may be greater than . - /// - public RentedArray(ArrayPool pool, int minimumLength) - { - _pool = pool; - Array = _pool.Rent(minimumLength); - } - - /// - /// Returns the array to its originating pool. - /// - public void Dispose() => _pool.Return(Array); -} \ No newline at end of file diff --git a/src/Common/KeyAsio.Shared/Utils/RuntimeInfo.cs b/src/Common/KeyAsio.Shared/Utils/RuntimeInfo.cs deleted file mode 100644 index 3f9778a3..00000000 --- a/src/Common/KeyAsio.Shared/Utils/RuntimeInfo.cs +++ /dev/null @@ -1,90 +0,0 @@ -using System.Diagnostics; -using System.Text; -using Microsoft.Extensions.Logging; - -namespace KeyAsio.Shared.Utils; - -public class RuntimeInfo -{ - public static bool IsSatori { get; private set; } - - public static void CheckCoreClr(ILogger logger) - { - var appDirectory = AppContext.BaseDirectory; - - var modules = Process.GetCurrentProcess().Modules; - foreach (ProcessModule module in modules) - { - if (!module.ModuleName.Equals("coreclr.dll", StringComparison.OrdinalIgnoreCase)) continue; - - Console.WriteLine($"[CoreCLR Path] {module.FileName}"); - - var moduleDir = Path.GetDirectoryName(module.FileName); - var isLocal = string.Equals(moduleDir, appDirectory.TrimEnd(Path.DirectorySeparatorChar), - StringComparison.OrdinalIgnoreCase); - - if (isLocal) - { - var hasSatori = ContainsStringReverse(module.FileName, "SatoriGC"); - if (hasSatori) - { - logger.LogInformation("[CoreCLR Mode] Local Satori"); - IsSatori = true; - } - else - { - logger.LogInformation("[CoreCLR Mode] Local Original"); - } - } - else - { - logger.LogInformation("[CoreCLR Mode] Shared Original"); - } - } - } - - private static bool ContainsStringReverse(string filePath, string targetStr) - { - if (string.IsNullOrEmpty(targetStr)) return false; - - var targetArray = Encoding.UTF8.GetBytes(targetStr); - var targetSpan = targetArray.AsSpan(); - - const int bufferSize = 4096; - var buffer = new byte[bufferSize]; - - using var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); - - var fileLength = fs.Length; - if (fileLength < targetArray.Length) return false; - - var position = fileLength; - - while (position > 0) - { - var readStart = position - bufferSize; - var bytesToRead = bufferSize; - - if (readStart < 0) - { - bytesToRead = (int)position; - readStart = 0; - } - - fs.Seek(readStart, SeekOrigin.Begin); - var readCount = fs.Read(buffer, 0, bytesToRead); - - ReadOnlySpan bufferSpan = buffer.AsSpan(0, readCount); - - if (bufferSpan.IndexOf(targetSpan) >= 0) - { - return true; - } - - if (readStart == 0) break; - position -= (bufferSize - targetArray.Length + 1); - } - - return false; - } -} \ No newline at end of file diff --git a/src/Common/KeyAsio.Shared/Utils/UniqueObservableCollection.cs b/src/Common/KeyAsio.Shared/Utils/UniqueObservableCollection.cs deleted file mode 100644 index 7e14bf9f..00000000 --- a/src/Common/KeyAsio.Shared/Utils/UniqueObservableCollection.cs +++ /dev/null @@ -1,60 +0,0 @@ -namespace KeyAsio.Shared.Utils; - -public class UniqueObservableCollection : ObservableRangeCollection -{ - private readonly HashSet _itemSet; - private readonly IEqualityComparer _comparer; - - public UniqueObservableCollection(IEqualityComparer? comparer = null) - { - _comparer = comparer ?? EqualityComparer.Default; - _itemSet = new HashSet(_comparer); - } - - public UniqueObservableCollection(IEnumerable collection, IEqualityComparer? comparer = null) - : base(collection.Distinct(comparer ?? EqualityComparer.Default)) - { - _comparer = comparer ?? EqualityComparer.Default; - _itemSet = new HashSet(Items, _comparer); - } - - public bool ContainsFast(T item) - { - return _itemSet.Contains(item); - } - - protected override void InsertItem(int index, T item) - { - if (_itemSet.Add(item)) - { - base.InsertItem(index, item); - } - } - - protected override void SetItem(int index, T item) - { - var oldItem = Items[index]; - - if (_comparer.Equals(oldItem, item)) - return; - - if (_itemSet.Contains(item)) - return; - - _itemSet.Remove(oldItem); - _itemSet.Add(item); - base.SetItem(index, item); - } - - protected override void RemoveItem(int index) - { - _itemSet.Remove(Items[index]); - base.RemoveItem(index); - } - - protected override void ClearItems() - { - _itemSet.Clear(); - base.ClearItems(); - } -} \ No newline at end of file diff --git a/tests/ColumnTest/ColumnTest.csproj b/tests/ColumnTest/ColumnTest.csproj index 089894e7..403f92a5 100644 --- a/tests/ColumnTest/ColumnTest.csproj +++ b/tests/ColumnTest/ColumnTest.csproj @@ -1,4 +1,4 @@ - + Exe @@ -9,7 +9,9 @@ - + + + diff --git a/tests/ColumnTest/Program.cs b/tests/ColumnTest/Program.cs index c56f9e78..fdd0bd99 100644 --- a/tests/ColumnTest/Program.cs +++ b/tests/ColumnTest/Program.cs @@ -3,20 +3,20 @@ using KeyAsio.Core.Audio; using KeyAsio.Core.OsuAudio.Hitsounds; using KeyAsio.Core.OsuAudio.Hitsounds.Playback; -using KeyAsio.Plugins.Abstractions; -using KeyAsio.Shared; -using KeyAsio.Shared.Models; -using KeyAsio.Shared.Plugins; -using KeyAsio.Shared.Sync; -using KeyAsio.Shared.Sync.AudioProviders; -using KeyAsio.Shared.Sync.Services; +using KeyAsio.Plugins.Contracts; +using KeyAsio.Configuration; +using KeyAsio.Application.Models; +using KeyAsio.Application.Plugins; +using KeyAsio.Sync; +using KeyAsio.Sync.AudioProviders; +using KeyAsio.Sync.Services; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; var services = new ServiceCollection(); var appSettings = new AppSettings(); services.AddSingleton(appSettings); -services.AddSingleton(); +services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/tests/KeyAsio.UnitTests/AsyncSequentialWorkerTests.cs b/tests/KeyAsio.UnitTests/AsyncSequentialWorkerTests.cs new file mode 100644 index 00000000..94cc80a2 --- /dev/null +++ b/tests/KeyAsio.UnitTests/AsyncSequentialWorkerTests.cs @@ -0,0 +1,72 @@ +using KeyAsio.Common; + +namespace KeyAsio.UnitTests; + +public sealed class AsyncSequentialWorkerTests +{ + [Fact] + public async Task EnqueueAsync_ExecutesWorkInSubmissionOrder() + { + await using var worker = new AsyncSequentialWorker(); + var firstStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var releaseFirst = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + List order = []; + + var first = worker.EnqueueAsync(async () => + { + order.Add(1); + firstStarted.SetResult(); + await releaseFirst.Task; + order.Add(2); + }); + await firstStarted.Task; + + var second = worker.EnqueueAsync(() => + { + order.Add(3); + return Task.CompletedTask; + }); + releaseFirst.SetResult(); + + await Task.WhenAll(first, second); + Assert.Equal([1, 2, 3], order); + } + + [Fact] + public async Task BoundedWorker_RejectsSynchronousWriteWhenQueueIsFull() + { + await using var worker = new AsyncSequentialWorker(capacity: 1); + var firstStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var releaseFirst = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + worker.Enqueue(async () => + { + firstStarted.SetResult(); + await releaseFirst.Task; + }); + await firstStarted.Task; + worker.Enqueue(() => Task.CompletedTask); + + Assert.Throws(() => worker.Enqueue(() => Task.CompletedTask)); + releaseFirst.SetResult(); + } + + [Fact] + public async Task DisposeAsync_CancelsRunningAndPendingWork() + { + var worker = new AsyncSequentialWorker(capacity: 1); + var firstStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var first = worker.EnqueueAsync(async cancellationToken => + { + firstStarted.SetResult(); + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + }); + await firstStarted.Task; + var pending = worker.EnqueueAsync(_ => Task.CompletedTask); + + await worker.DisposeAsync(); + + await Assert.ThrowsAnyAsync(() => first); + await Assert.ThrowsAnyAsync(() => pending); + } +} diff --git a/tests/KeyAsio.UnitTests/AudioDeviceOperationCoordinatorTests.cs b/tests/KeyAsio.UnitTests/AudioDeviceOperationCoordinatorTests.cs index 0d86be6a..5d222370 100644 --- a/tests/KeyAsio.UnitTests/AudioDeviceOperationCoordinatorTests.cs +++ b/tests/KeyAsio.UnitTests/AudioDeviceOperationCoordinatorTests.cs @@ -1,6 +1,7 @@ +using KeyAsio.Configuration; using KeyAsio.Core.Audio; using KeyAsio.Services; -using KeyAsio.Shared; +using KeyAsio.Sync.Abstractions; using Microsoft.Extensions.Logging; using Moq; using NAudio.Wave; @@ -16,17 +17,19 @@ public async Task ApplyAsync_StartsBeforeCommittingSettings() var requested = Device("requested"); var settings = Settings(previous, 44100); var engine = new PlaybackEngineHarness(previous, 44100); - var persistence = new Mock(); + var persistence = new Mock(); + var audioCache = new Mock(); var events = engine.Events; persistence.Setup(x => x.Save()).Callback(() => events.Add("save")); + audioCache.Setup(x => x.ClearCaches()).Callback(() => events.Add("clear")); - using var coordinator = CreateCoordinator(settings, persistence, engine); + using var coordinator = CreateCoordinator(settings, persistence, engine, audioCache); var result = await coordinator.ApplyAsync(requested, 96000, TestContext.Current.CancellationToken); Assert.True(result.IsSuccess); Assert.Equal(requested, settings.Audio.PlaybackDevice); Assert.Equal(96000, settings.Audio.SampleRate); - Assert.Equal(["stop:previous", "start:requested", "save"], events); + Assert.Equal(["stop:previous", "start:requested", "save", "clear"], events); Assert.Equal(requested, result.ActiveDevice); } @@ -40,9 +43,10 @@ public async Task ApplyAsync_WhenStartFails_RestoresDeviceAndSettings() { StartFailure = device => device == requested ? new InvalidOperationException("cannot start") : null }; - var persistence = new Mock(); + var persistence = new Mock(); + var audioCache = new Mock(); - using var coordinator = CreateCoordinator(settings, persistence, engine); + using var coordinator = CreateCoordinator(settings, persistence, engine, audioCache); var result = await coordinator.ApplyAsync(requested, 96000, TestContext.Current.CancellationToken); Assert.False(result.IsSuccess); @@ -52,6 +56,7 @@ public async Task ApplyAsync_WhenStartFails_RestoresDeviceAndSettings() Assert.Equal(previous, result.ActiveDevice); Assert.Equal(["stop:previous", "start:requested", "start:previous"], engine.Events); persistence.Verify(x => x.Save(), Times.Never); + audioCache.Verify(x => x.ClearCaches(), Times.Once); } [Fact] @@ -61,7 +66,7 @@ public async Task ApplyAsync_WhenCommitFails_RestoresPersistedAndRuntimeState() var requested = Device("requested"); var settings = Settings(previous, 48000); var engine = new PlaybackEngineHarness(previous, 48000); - var persistence = new Mock(); + var persistence = new Mock(); var saveAttempt = 0; persistence.Setup(x => x.Save()).Callback(() => { @@ -70,8 +75,9 @@ public async Task ApplyAsync_WhenCommitFails_RestoresPersistedAndRuntimeState() throw new IOException("write failed"); } }); + var audioCache = new Mock(); - using var coordinator = CreateCoordinator(settings, persistence, engine); + using var coordinator = CreateCoordinator(settings, persistence, engine, audioCache); var result = await coordinator.ApplyAsync(requested, 96000, TestContext.Current.CancellationToken); Assert.False(result.IsSuccess); @@ -84,13 +90,14 @@ public async Task ApplyAsync_WhenCommitFails_RestoresPersistedAndRuntimeState() private static AudioDeviceOperationCoordinator CreateCoordinator( AppSettings settings, - Mock persistence, - PlaybackEngineHarness engine) => + Mock persistence, + PlaybackEngineHarness engine, + Mock audioCache) => new( settings, persistence.Object, engine.Engine.Object, - null, + audioCache.Object, Mock.Of>()); private static AppSettings Settings(DeviceDescription device, int sampleRate) => new() diff --git a/tests/KeyAsio.UnitTests/GameSyncSourceCoordinatorTests.cs b/tests/KeyAsio.UnitTests/GameSyncSourceCoordinatorTests.cs index 95769234..b49f21e6 100644 --- a/tests/KeyAsio.UnitTests/GameSyncSourceCoordinatorTests.cs +++ b/tests/KeyAsio.UnitTests/GameSyncSourceCoordinatorTests.cs @@ -1,8 +1,9 @@ -using KeyAsio.Plugins.Abstractions; -using KeyAsio.Plugins.Abstractions.OsuMemory; -using KeyAsio.Shared; -using KeyAsio.Shared.OsuMemory; -using KeyAsio.Shared.Sync; +using KeyAsio.Plugins.Contracts; +using KeyAsio.Plugins.Contracts.Sync; +using KeyAsio.Configuration; +using KeyAsio.Configuration.Models; +using KeyAsio.Sync.Sources; +using KeyAsio.Sync; using Microsoft.Extensions.Logging.Abstractions; namespace KeyAsio.UnitTests; diff --git a/tests/KeyAsio.UnitTests/KeyAsio.UnitTests.csproj b/tests/KeyAsio.UnitTests/KeyAsio.UnitTests.csproj index f884dd41..a26e9732 100644 --- a/tests/KeyAsio.UnitTests/KeyAsio.UnitTests.csproj +++ b/tests/KeyAsio.UnitTests/KeyAsio.UnitTests.csproj @@ -9,14 +9,14 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -25,6 +25,7 @@ + diff --git a/tests/KeyAsio.UnitTests/TestAppBuilder.cs b/tests/KeyAsio.UnitTests/TestAppBuilder.cs index dda5c4c7..f9027b6e 100644 --- a/tests/KeyAsio.UnitTests/TestAppBuilder.cs +++ b/tests/KeyAsio.UnitTests/TestAppBuilder.cs @@ -6,7 +6,7 @@ namespace KeyAsio.UnitTests; -public class TestApp : Application +public class TestApp : Avalonia.Application { } @@ -14,4 +14,4 @@ public class TestAppBuilder { public static AppBuilder BuildAvaloniaApp() => AppBuilder.Configure() .UseHeadless(new AvaloniaHeadlessPlatformOptions()); -} \ No newline at end of file +} diff --git a/tests/KeyAsio.UnitTests/WizardAudioConfigViewModelTests.cs b/tests/KeyAsio.UnitTests/WizardAudioConfigViewModelTests.cs index ffc6465f..779d3b52 100644 --- a/tests/KeyAsio.UnitTests/WizardAudioConfigViewModelTests.cs +++ b/tests/KeyAsio.UnitTests/WizardAudioConfigViewModelTests.cs @@ -1,8 +1,8 @@ using Avalonia.Headless.XUnit; using Avalonia.Threading; using KeyAsio.Core.Audio; +using KeyAsio.Configuration; using KeyAsio.Services; -using KeyAsio.Shared; using KeyAsio.ViewModels; using Moq; using SukiUI.Toasts; diff --git a/tests/MemoryReadingTest/App.axaml.cs b/tests/MemoryReadingTest/App.axaml.cs index 8eca3329..aa6433d8 100644 --- a/tests/MemoryReadingTest/App.axaml.cs +++ b/tests/MemoryReadingTest/App.axaml.cs @@ -1,11 +1,11 @@ using Avalonia; using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Markup.Xaml; -using KeyAsio.Shared; -using KeyAsio.Shared.Models; -using KeyAsio.Shared.OsuMemory; -using KeyAsio.Shared.Sync; -using KeyAsio.Shared.Sync.Services; +using KeyAsio.Configuration; +using KeyAsio.Application.Models; +using KeyAsio.Sync.Sources; +using KeyAsio.Sync; +using KeyAsio.Sync.Services; using Microsoft.Extensions.DependencyInjection; using NLog.Extensions.Logging; diff --git a/tests/MemoryReadingTest/AppBootstrapper.cs b/tests/MemoryReadingTest/AppBootstrapper.cs index 43b9ecb9..6a0f2be8 100644 --- a/tests/MemoryReadingTest/AppBootstrapper.cs +++ b/tests/MemoryReadingTest/AppBootstrapper.cs @@ -1,10 +1,10 @@ -using KeyAsio.Shared; -using KeyAsio.Shared.Models; -using KeyAsio.Shared.OsuMemory; -using KeyAsio.Shared.Sync; -using KeyAsio.Shared.Sync.Services; -using KeyAsio.Plugins.Abstractions; -using KeyAsio.Shared.Plugins; +using KeyAsio.Configuration; +using KeyAsio.Application.Models; +using KeyAsio.Sync.Sources; +using KeyAsio.Sync; +using KeyAsio.Sync.Services; +using KeyAsio.Plugins.Contracts; +using KeyAsio.Application.Plugins; using Microsoft.Extensions.DependencyInjection; using NLog.Extensions.Logging; using System; @@ -18,7 +18,7 @@ public static IServiceProvider InitServices() var services = new ServiceCollection(); services.AddNLog(); services.AddSingleton(new AppSettings()); - services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/tests/MemoryReadingTest/Charts/PlayTimeChart.axaml.cs b/tests/MemoryReadingTest/Charts/PlayTimeChart.axaml.cs index 28ee72ba..3171bc95 100644 --- a/tests/MemoryReadingTest/Charts/PlayTimeChart.axaml.cs +++ b/tests/MemoryReadingTest/Charts/PlayTimeChart.axaml.cs @@ -1,8 +1,8 @@ -using System.Collections.ObjectModel; +using System.Collections.ObjectModel; using Avalonia; using Avalonia.Controls; using Avalonia.Threading; -using KeyAsio.Shared.Models; +using KeyAsio.Configuration.Models; using LiveChartsCore; using LiveChartsCore.Defaults; using LiveChartsCore.SkiaSharpView; diff --git a/tests/MemoryReadingTest/MemoryReadingTest.csproj b/tests/MemoryReadingTest/MemoryReadingTest.csproj index b8296e0e..0cf61571 100644 --- a/tests/MemoryReadingTest/MemoryReadingTest.csproj +++ b/tests/MemoryReadingTest/MemoryReadingTest.csproj @@ -14,17 +14,20 @@ - - - - + + + + - - + + + - + + + diff --git a/tests/MemoryReadingTest/Program.cs b/tests/MemoryReadingTest/Program.cs index 838d3236..14a1022f 100644 --- a/tests/MemoryReadingTest/Program.cs +++ b/tests/MemoryReadingTest/Program.cs @@ -1,5 +1,5 @@ using Avalonia; -using KeyAsio.Shared.OsuMemory; +using KeyAsio.Sync.Sources; using Microsoft.Extensions.DependencyInjection; using System; using System.Linq; diff --git a/tests/PlayingTests/PlayingTests.csproj b/tests/PlayingTests/PlayingTests.csproj index c2c1c0c5..f94befd4 100644 --- a/tests/PlayingTests/PlayingTests.csproj +++ b/tests/PlayingTests/PlayingTests.csproj @@ -1,4 +1,4 @@ - + Exe @@ -9,11 +9,13 @@ - + - + + + diff --git a/tests/PlayingTests/Program.cs b/tests/PlayingTests/Program.cs index f56bdd71..b3fa4063 100644 --- a/tests/PlayingTests/Program.cs +++ b/tests/PlayingTests/Program.cs @@ -1,4 +1,4 @@ -// See https://aka.ms/new-console-template for more information +// See https://aka.ms/new-console-template for more information using System; using System.IO; @@ -8,14 +8,14 @@ using KeyAsio.Core.Audio; using KeyAsio.Core.Audio.Caching; using KeyAsio.Core.Audio.Utils; -using KeyAsio.Plugins.Abstractions; -using KeyAsio.Plugins.Abstractions.OsuMemory; -using KeyAsio.Shared; -using KeyAsio.Shared.Models; -using KeyAsio.Shared.OsuMemory; -using KeyAsio.Shared.Plugins; -using KeyAsio.Shared.Sync; -using KeyAsio.Shared.Sync.Services; +using KeyAsio.Plugins.Contracts; +using KeyAsio.Plugins.Contracts.Sync; +using KeyAsio.Configuration; +using KeyAsio.Application.Models; +using KeyAsio.Sync.Sources; +using KeyAsio.Application.Plugins; +using KeyAsio.Sync; +using KeyAsio.Sync.Services; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Milki.Extensions.Configuration; @@ -41,14 +41,14 @@ public static async Task Main(string[] args) var services = new ServiceCollection(); services.AddSingleton(appSettings); services.AddSingleton(); - services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); var provider = services.BuildServiceProvider(); - var sharedViewModel = provider.GetRequiredService(); + var sharedViewModel = provider.GetRequiredService(); sharedViewModel.AutoMode = true; var audioEngine = provider.GetRequiredService(); diff --git a/tests/StressTest/Program.cs b/tests/StressTest/Program.cs index 4cae344c..88b3c981 100644 --- a/tests/StressTest/Program.cs +++ b/tests/StressTest/Program.cs @@ -5,14 +5,15 @@ using KeyAsio.Core.Audio.Caching; using KeyAsio.Core.Audio.SampleProviders; using KeyAsio.Core.OsuAudio.Hitsounds.Playback; -using KeyAsio.Plugins.Abstractions; -using KeyAsio.Shared; -using KeyAsio.Shared.Models; -using KeyAsio.Shared.Plugins; -using KeyAsio.Shared.Services; -using KeyAsio.Shared.Sync; -using KeyAsio.Shared.Sync.Services; -using KeyAsio.Shared.Sync.States; +using KeyAsio.Plugins.Contracts; +using KeyAsio.Configuration; +using KeyAsio.Application.Models; +using KeyAsio.Application.Plugins; +using KeyAsio.Application.Services; +using KeyAsio.Sync; +using KeyAsio.Sync.Services; +using KeyAsio.Sync.Models; +using KeyAsio.Sync.States; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using NAudio.Wave; @@ -37,7 +38,7 @@ public static async Task Main(string[] args) services.AddSingleton(); // We will manipulate this instance later // Register Sync Module Services - services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -65,7 +66,7 @@ public static async Task Main(string[] args) audioEngine, provider.GetRequiredService(), provider.GetRequiredService(), - provider.GetRequiredService(), + provider.GetRequiredService(), gameplaySessionManager, gameplayAudioService ); @@ -93,32 +94,32 @@ public static async Task Main(string[] args) // Let's manually create them. var loggerFactory = provider.GetRequiredService(); - var maniaSequencer = new KeyAsio.Shared.Sync.AudioProviders.ManiaHitsoundSequencer( - loggerFactory.CreateLogger(), + var maniaSequencer = new KeyAsio.Sync.AudioProviders.ManiaHitsoundSequencer( + loggerFactory.CreateLogger(), provider.GetRequiredService(), ctx, audioEngine, gameplayAudioService, gameplaySessionManager ); - var stdSequencer = new KeyAsio.Shared.Sync.AudioProviders.StandardHitsoundSequencer( - loggerFactory.CreateLogger(), + var stdSequencer = new KeyAsio.Sync.AudioProviders.StandardHitsoundSequencer( + loggerFactory.CreateLogger(), provider.GetRequiredService(), ctx, audioEngine, gameplayAudioService, gameplaySessionManager ); - var taikoSequencer = new KeyAsio.Shared.Sync.AudioProviders.TaikoHitsoundSequencer( - loggerFactory.CreateLogger(), + var taikoSequencer = new KeyAsio.Sync.AudioProviders.TaikoHitsoundSequencer( + loggerFactory.CreateLogger(), provider.GetRequiredService(), ctx, audioEngine, gameplayAudioService, gameplaySessionManager ); - var catchSequencer = new KeyAsio.Shared.Sync.AudioProviders.CatchHitsoundSequencer( - loggerFactory.CreateLogger(), + var catchSequencer = new KeyAsio.Sync.AudioProviders.CatchHitsoundSequencer( + loggerFactory.CreateLogger(), provider.GetRequiredService(), ctx, audioEngine, @@ -370,4 +371,4 @@ public void Dispose() { Stop(); } -} \ No newline at end of file +} diff --git a/tests/StressTest/StressTest.csproj b/tests/StressTest/StressTest.csproj index ad80e2e3..a77bd534 100644 --- a/tests/StressTest/StressTest.csproj +++ b/tests/StressTest/StressTest.csproj @@ -9,11 +9,13 @@ - + + + - + From 9bed2c68bc782a4e14df083d452c23f7787a3de2 Mon Sep 17 00:00:00 2001 From: Milkitic Date: Sun, 12 Jul 2026 00:04:54 +0800 Subject: [PATCH 04/19] refactor: add .editorconfig for consistent coding style --- .editorconfig | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 .editorconfig diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..6553990d --- /dev/null +++ b/.editorconfig @@ -0,0 +1,4 @@ +root = true + +[*] +insert_final_newline = true \ No newline at end of file From 2995d732f008d012073908abfebf966eb2dd0a24 Mon Sep 17 00:00:00 2001 From: Milkitic Date: Sun, 12 Jul 2026 00:06:55 +0800 Subject: [PATCH 05/19] refactor: standardize package references by removing version numbers --- Directory.Packages.props | 56 +++++++++++++++++++ .../SimdBenchmarks/SimdBenchmarks.csproj | 4 +- dependencies/Directory.Packages.props | 6 ++ src/Apps/KeyAsio/KeyAsio.csproj | 32 +++++------ .../KeyAsio.Application.csproj | 8 +-- .../KeyAsio.Common/KeyAsio.Common.csproj | 2 +- .../KeyAsio.Configuration.csproj | 8 +-- .../KeyAsio.Plugins.Contracts.csproj | 6 +- .../KeyAsio.Secrets/KeyAsio.Secrets.csproj | 4 +- .../KeyAsio.Core.Audio.csproj | 20 +++---- .../KeyAsio.Core.Memory.csproj | 10 ++-- .../KeyAsio.Core.OsuAudio.csproj | 2 +- src/Core/KeyAsio.Sync/KeyAsio.Sync.csproj | 4 +- tests/AudioTests/AudioTests.csproj | 10 ++-- .../KeyAsio.UnitTests.csproj | 12 ++-- .../MemoryReadingTest.csproj | 14 ++--- tests/PlayingTests/PlayingTests.csproj | 2 +- tests/StressTest/StressTest.csproj | 2 +- 18 files changed, 132 insertions(+), 70 deletions(-) create mode 100644 Directory.Packages.props create mode 100644 dependencies/Directory.Packages.props diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 00000000..ddd14f74 --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,56 @@ + + + true + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/benchmarks/SimdBenchmarks/SimdBenchmarks.csproj b/benchmarks/SimdBenchmarks/SimdBenchmarks.csproj index 72fe3962..91580bdd 100644 --- a/benchmarks/SimdBenchmarks/SimdBenchmarks.csproj +++ b/benchmarks/SimdBenchmarks/SimdBenchmarks.csproj @@ -1,4 +1,4 @@ - + net10.0 @@ -8,7 +8,7 @@ - + diff --git a/dependencies/Directory.Packages.props b/dependencies/Directory.Packages.props new file mode 100644 index 00000000..1d169061 --- /dev/null +++ b/dependencies/Directory.Packages.props @@ -0,0 +1,6 @@ + + + + false + + diff --git a/src/Apps/KeyAsio/KeyAsio.csproj b/src/Apps/KeyAsio/KeyAsio.csproj index 9b4c79e8..484de988 100644 --- a/src/Apps/KeyAsio/KeyAsio.csproj +++ b/src/Apps/KeyAsio/KeyAsio.csproj @@ -47,32 +47,32 @@ - - - + + + - + - - - - + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - + + + + + + + diff --git a/src/Common/KeyAsio.Application/KeyAsio.Application.csproj b/src/Common/KeyAsio.Application/KeyAsio.Application.csproj index aba690a6..6b207faf 100644 --- a/src/Common/KeyAsio.Application/KeyAsio.Application.csproj +++ b/src/Common/KeyAsio.Application/KeyAsio.Application.csproj @@ -8,10 +8,10 @@ - - - - + + + + diff --git a/src/Common/KeyAsio.Common/KeyAsio.Common.csproj b/src/Common/KeyAsio.Common/KeyAsio.Common.csproj index 227b9f91..fdc5a184 100644 --- a/src/Common/KeyAsio.Common/KeyAsio.Common.csproj +++ b/src/Common/KeyAsio.Common/KeyAsio.Common.csproj @@ -7,6 +7,6 @@ - + diff --git a/src/Common/KeyAsio.Configuration/KeyAsio.Configuration.csproj b/src/Common/KeyAsio.Configuration/KeyAsio.Configuration.csproj index db08464c..0bb166e6 100644 --- a/src/Common/KeyAsio.Configuration/KeyAsio.Configuration.csproj +++ b/src/Common/KeyAsio.Configuration/KeyAsio.Configuration.csproj @@ -7,16 +7,16 @@ - - + + all compile; runtime; build; native; contentfiles; analyzers; buildtransitive - + all compile; runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/src/Common/KeyAsio.Plugins.Contracts/KeyAsio.Plugins.Contracts.csproj b/src/Common/KeyAsio.Plugins.Contracts/KeyAsio.Plugins.Contracts.csproj index 142d2d29..3f5f769b 100644 --- a/src/Common/KeyAsio.Plugins.Contracts/KeyAsio.Plugins.Contracts.csproj +++ b/src/Common/KeyAsio.Plugins.Contracts/KeyAsio.Plugins.Contracts.csproj @@ -8,9 +8,9 @@ - - - + + + diff --git a/src/Common/KeyAsio.Secrets/KeyAsio.Secrets.csproj b/src/Common/KeyAsio.Secrets/KeyAsio.Secrets.csproj index 16205c12..fe0ec666 100644 --- a/src/Common/KeyAsio.Secrets/KeyAsio.Secrets.csproj +++ b/src/Common/KeyAsio.Secrets/KeyAsio.Secrets.csproj @@ -1,4 +1,4 @@ - + net10.0 @@ -18,7 +18,7 @@ - + diff --git a/src/Core/KeyAsio.Core.Audio/KeyAsio.Core.Audio.csproj b/src/Core/KeyAsio.Core.Audio/KeyAsio.Core.Audio.csproj index 9271b0f6..58f7accb 100644 --- a/src/Core/KeyAsio.Core.Audio/KeyAsio.Core.Audio.csproj +++ b/src/Core/KeyAsio.Core.Audio/KeyAsio.Core.Audio.csproj @@ -1,4 +1,4 @@ - + net10.0 @@ -9,15 +9,15 @@ - - - - - - - - - + + + + + + + + + diff --git a/src/Core/KeyAsio.Core.Memory/KeyAsio.Core.Memory.csproj b/src/Core/KeyAsio.Core.Memory/KeyAsio.Core.Memory.csproj index 99a38b47..0e8a2f82 100644 --- a/src/Core/KeyAsio.Core.Memory/KeyAsio.Core.Memory.csproj +++ b/src/Core/KeyAsio.Core.Memory/KeyAsio.Core.Memory.csproj @@ -1,4 +1,4 @@ - + net10.0 @@ -10,13 +10,13 @@ - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + diff --git a/src/Core/KeyAsio.Core.OsuAudio/KeyAsio.Core.OsuAudio.csproj b/src/Core/KeyAsio.Core.OsuAudio/KeyAsio.Core.OsuAudio.csproj index 2e1bda74..016347eb 100644 --- a/src/Core/KeyAsio.Core.OsuAudio/KeyAsio.Core.OsuAudio.csproj +++ b/src/Core/KeyAsio.Core.OsuAudio/KeyAsio.Core.OsuAudio.csproj @@ -8,6 +8,6 @@ - + diff --git a/src/Core/KeyAsio.Sync/KeyAsio.Sync.csproj b/src/Core/KeyAsio.Sync/KeyAsio.Sync.csproj index 50113464..4b848d1e 100644 --- a/src/Core/KeyAsio.Sync/KeyAsio.Sync.csproj +++ b/src/Core/KeyAsio.Sync/KeyAsio.Sync.csproj @@ -8,8 +8,8 @@ - - + + diff --git a/tests/AudioTests/AudioTests.csproj b/tests/AudioTests/AudioTests.csproj index ef067ae9..c2282ce0 100644 --- a/tests/AudioTests/AudioTests.csproj +++ b/tests/AudioTests/AudioTests.csproj @@ -1,4 +1,4 @@ - + net10.0 @@ -11,13 +11,13 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/tests/KeyAsio.UnitTests/KeyAsio.UnitTests.csproj b/tests/KeyAsio.UnitTests/KeyAsio.UnitTests.csproj index a26e9732..832cdfa6 100644 --- a/tests/KeyAsio.UnitTests/KeyAsio.UnitTests.csproj +++ b/tests/KeyAsio.UnitTests/KeyAsio.UnitTests.csproj @@ -9,15 +9,15 @@ - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - - + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/tests/MemoryReadingTest/MemoryReadingTest.csproj b/tests/MemoryReadingTest/MemoryReadingTest.csproj index 0cf61571..e67b3af3 100644 --- a/tests/MemoryReadingTest/MemoryReadingTest.csproj +++ b/tests/MemoryReadingTest/MemoryReadingTest.csproj @@ -14,14 +14,14 @@ - - - - + + + + - - - + + + diff --git a/tests/PlayingTests/PlayingTests.csproj b/tests/PlayingTests/PlayingTests.csproj index f94befd4..1fdd654f 100644 --- a/tests/PlayingTests/PlayingTests.csproj +++ b/tests/PlayingTests/PlayingTests.csproj @@ -9,7 +9,7 @@ - + diff --git a/tests/StressTest/StressTest.csproj b/tests/StressTest/StressTest.csproj index a77bd534..b9966e8f 100644 --- a/tests/StressTest/StressTest.csproj +++ b/tests/StressTest/StressTest.csproj @@ -15,7 +15,7 @@ - + From ea3595f5190365ef7da293242f395d3f6dbbfc44 Mon Sep 17 00:00:00 2001 From: Milkitic Date: Sun, 12 Jul 2026 00:07:59 +0800 Subject: [PATCH 06/19] refactor: add CI workflow for build and test processes --- .github/workflows/ci.yml | 37 +++++++++++++++++++++++++++++++++++ .github/workflows/publish.yml | 5 +++++ 2 files changed, 42 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..c2054e41 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,37 @@ +name: ci + +on: + push: + branches: [master] + pull_request: + +permissions: + contents: read + +jobs: + build-and-test: + runs-on: windows-latest + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + submodules: recursive + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: 10.0.x + + - name: Restore + run: dotnet restore KeyAsio.Net.slnx + + - name: Build + run: dotnet build KeyAsio.Net.slnx --configuration Release --no-restore + + - name: Test application behavior + run: dotnet test tests/KeyAsio.UnitTests/KeyAsio.UnitTests.csproj --configuration Release --no-build + + - name: Test audio primitives + run: dotnet test tests/AudioTests/AudioTests.csproj --configuration Release --no-build diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 392530e7..c31695f2 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -42,6 +42,11 @@ jobs: if ($env:OFFICIAL_PRIVATE_KEY) { Set-Content -Path "src/Apps/KeyAsio/keyasio_private.key" -Value $env:OFFICIAL_PRIVATE_KEY } + + - name: Verify release candidate + run: | + dotnet test tests/KeyAsio.UnitTests/KeyAsio.UnitTests.csproj --configuration Release + dotnet test tests/AudioTests/AudioTests.csproj --configuration Release - name: Publish run: | From b3e3b0323a197909949776befe312223a1dab33f Mon Sep 17 00:00:00 2001 From: Milkitic Date: Sun, 12 Jul 2026 00:08:10 +0800 Subject: [PATCH 07/19] refactor: add architecture documentation outlining project boundaries and plugin contract migration --- docs/architecture.md | 46 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 docs/architecture.md diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 00000000..e3b4a1d7 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,46 @@ +# Architecture + +The solution uses the application executable as the composition root. Lower layers do not resolve services from an +`IServiceProvider`; dependencies are supplied through constructors or narrow capability interfaces. + +## Project boundaries + +- `KeyAsio.Common` contains implementation-independent utilities and collections. +- `KeyAsio.Plugins.Contracts` is the only API surface plugins should compile against. It depends only on stable public + data/audio packages and never exposes the host service container. +- `KeyAsio.Configuration` owns persisted settings and their serialization. +- `KeyAsio.Core.*` owns audio, memory and beatmap-audio primitives. +- `KeyAsio.Sync` owns game-state acquisition and synchronized playback orchestration. +- `KeyAsio.Application` owns host runtime services, plugin loading, localization and skin discovery. +- `KeyAsio` owns Avalonia/Suki presentation, OS integration and dependency injection composition. + +Dependencies point inward toward contracts and primitives. In particular, `KeyAsio.Sync` has no dependency on +`KeyAsio.Application`, and neither plugin contracts nor application services depend on Avalonia/Suki. + +## Audio device transactions + +All application-level device changes go through `IAudioDeviceOperationCoordinator`. A transition is serialized, +stops the current device, starts the requested device, and only then commits settings. A failed start or persistence +operation restores both the previous runtime device and the previous persisted configuration. Cache invalidation is a +post-transition concern and cannot corrupt the transaction. + +## Plugin contract migration + +The contract is intentionally breaking. A plugin project should reference `KeyAsio.Plugins.Contracts` and must not +reference `KeyAsio.Application`, `KeyAsio.Configuration`, `KeyAsio.Sync`, Avalonia host services, Suki managers or +Octokit. + +| Previous dependency | Contract capability | +| --- | --- | +| Root `IServiceProvider` | Explicit properties on `IPluginContext` | +| `AppSettings` | `IPluginSettings` | +| `GameplaySessionManager` / `SyncSessionContext` | `IGameplaySession` | +| `AudioCacheManager` / `CachedAudio` | `IGameplaySession.TryCreateCachedAudioProvider` | +| Beatmap resource catalog implementation | `TryResolveResource` / `TryResolveAudioResource` | +| Dispatcher, dialog and toast managers | `IPluginInteractionService` | +| Logger resolved from DI | `IPluginContext.LoggerFactory` | +| `Octokit.Release` | `UpdateRelease` and `UpdateAsset` | + +The host shares already-loaded assemblies with each collectible plugin load context and resolves plugin-private managed +and native dependencies from the plugin output directory. This preserves type identity for the contract while keeping +plugin-only dependencies unloadable. From 7371c4a3b12db32abeafec6bf2ff1d9bb953a492 Mon Sep 17 00:00:00 2001 From: Milkitic Date: Sun, 12 Jul 2026 15:17:52 +0800 Subject: [PATCH 08/19] Retry StopDevice; make OutputWaveFormat nullable Add async StopCurrentDeviceAsync that retries up to 3 times with logging and delay to handle transient StopDevice failures; update callers to await the async stop. Make IAudioEngine.OutputWaveFormat nullable and update AudioEngineWrapper to return null when no device is active. Add unit test to verify retry behavior and extend the playback engine test harness with StopFailuresRemaining to simulate transient failures. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AudioDeviceOperationCoordinator.cs | 24 +++++++++++--- .../Plugins/AudioEngineWrapper.cs | 2 +- .../KeyAsio.Plugins.Contracts/IAudioEngine.cs | 2 +- .../AudioDeviceOperationCoordinatorTests.cs | 32 +++++++++++++++++++ 4 files changed, 53 insertions(+), 7 deletions(-) diff --git a/src/Apps/KeyAsio/Services/AudioDeviceOperationCoordinator.cs b/src/Apps/KeyAsio/Services/AudioDeviceOperationCoordinator.cs index ec38aa96..8b5aab5c 100644 --- a/src/Apps/KeyAsio/Services/AudioDeviceOperationCoordinator.cs +++ b/src/Apps/KeyAsio/Services/AudioDeviceOperationCoordinator.cs @@ -107,7 +107,7 @@ private async Task TransitionAsync( var commitAttempted = false; try { - StopCurrentDevice(); + await StopCurrentDeviceAsync(cancellationToken).ConfigureAwait(false); StartRequestedDevice(requestedDevice, requestedSampleRate); if (persist) @@ -134,7 +134,7 @@ private async Task TransitionAsync( List? rollbackErrors = null; try { - StopCurrentDevice(); + await StopCurrentDeviceAsync(CancellationToken.None).ConfigureAwait(false); StartRequestedDevice(rollbackDevice, rollbackSampleRate); } catch (Exception exception) @@ -192,11 +192,25 @@ private void StartRequestedDevice(DeviceDescription? device, int sampleRate) _engine.StartDevice(device, new WaveFormat(sampleRate, 2)); } - private void StopCurrentDevice() + private async Task StopCurrentDeviceAsync(CancellationToken cancellationToken) { - if (_engine.CurrentDevice is not null) + const int maxAttempts = 3; + for (var attempt = 1; attempt <= maxAttempts; attempt++) { - _engine.StopDevice(); + if (_engine.CurrentDevice is null) return; + + try + { + _engine.StopDevice(); + return; + } + catch (Exception exception) when (attempt < maxAttempts) + { + _logger.LogWarning(exception, + "Failed to stop the audio device on attempt {Attempt}; retrying", + attempt); + await Task.Delay(100, cancellationToken).ConfigureAwait(false); + } } } diff --git a/src/Common/KeyAsio.Application/Plugins/AudioEngineWrapper.cs b/src/Common/KeyAsio.Application/Plugins/AudioEngineWrapper.cs index 149d460d..964d6f66 100644 --- a/src/Common/KeyAsio.Application/Plugins/AudioEngineWrapper.cs +++ b/src/Common/KeyAsio.Application/Plugins/AudioEngineWrapper.cs @@ -14,7 +14,7 @@ public AudioEngineWrapper(IPlaybackEngine engine) _engine = engine; } - public WaveFormat OutputWaveFormat => _engine.EngineWaveFormat; + public WaveFormat? OutputWaveFormat => _engine.CurrentDevice is null ? null : _engine.EngineWaveFormat; public IPluginAudioFile OpenAudioFile(string path) => new PluginAudioFile(path); diff --git a/src/Common/KeyAsio.Plugins.Contracts/IAudioEngine.cs b/src/Common/KeyAsio.Plugins.Contracts/IAudioEngine.cs index b8347ce9..dc1301fc 100644 --- a/src/Common/KeyAsio.Plugins.Contracts/IAudioEngine.cs +++ b/src/Common/KeyAsio.Plugins.Contracts/IAudioEngine.cs @@ -8,7 +8,7 @@ namespace KeyAsio.Plugins.Contracts; /// public interface IAudioEngine { - WaveFormat OutputWaveFormat { get; } + WaveFormat? OutputWaveFormat { get; } IPluginAudioFile OpenAudioFile(string path); diff --git a/tests/KeyAsio.UnitTests/AudioDeviceOperationCoordinatorTests.cs b/tests/KeyAsio.UnitTests/AudioDeviceOperationCoordinatorTests.cs index 5d222370..e044a1f3 100644 --- a/tests/KeyAsio.UnitTests/AudioDeviceOperationCoordinatorTests.cs +++ b/tests/KeyAsio.UnitTests/AudioDeviceOperationCoordinatorTests.cs @@ -88,6 +88,31 @@ public async Task ApplyAsync_WhenCommitFails_RestoresPersistedAndRuntimeState() persistence.Verify(x => x.Save(), Times.Exactly(2)); } + [Fact] + public async Task ApplyAsync_WhenStoppingFailsTransiently_RetriesThenApplies() + { + var previous = Device("previous"); + var requested = Device("requested"); + var settings = Settings(previous, 44100); + var engine = new PlaybackEngineHarness(previous, 44100) + { + StopFailuresRemaining = 2 + }; + var persistence = new Mock(); + var audioCache = new Mock(); + + using var coordinator = CreateCoordinator(settings, persistence, engine, audioCache); + var result = await coordinator.ApplyAsync(requested, 48000, TestContext.Current.CancellationToken); + + Assert.True(result.IsSuccess); + Assert.Equal(requested, result.ActiveDevice); + Assert.Equal( + ["stop:previous", "stop:previous", "stop:previous", "start:requested"], + engine.Events); + persistence.Verify(x => x.Save(), Times.Once); + audioCache.Verify(x => x.ClearCaches(), Times.Once); + } + private static AudioDeviceOperationCoordinator CreateCoordinator( AppSettings settings, Mock persistence, @@ -135,6 +160,12 @@ public PlaybackEngineHarness(DeviceDescription? initialDevice, int initialSample Engine.Setup(x => x.StopDevice()).Callback(() => { Events.Add($"stop:{_description?.DeviceId}"); + if (StopFailuresRemaining > 0) + { + StopFailuresRemaining--; + throw new IOException("transient stop failure"); + } + _currentDevice = null; _description = null; }); @@ -160,5 +191,6 @@ public PlaybackEngineHarness(DeviceDescription? initialDevice, int initialSample public Mock Engine { get; } = new(); public List Events { get; } = []; public Func? StartFailure { get; init; } + public int StopFailuresRemaining { get; set; } } } From b05e434a5088bc1da50d477ad1e0ecbd6a0a9139 Mon Sep 17 00:00:00 2001 From: Milkitic Date: Sun, 12 Jul 2026 19:55:46 +0800 Subject: [PATCH 09/19] Add NeedsShutdown flag and robust plugin shutdown Introduce a NeedsShutdown property and TryShutdownPlugin helper to ensure plugins are shut down reliably. Set NeedsShutdown after successful Initialize, attempt shutdown on Initialize/Startup failures, and always call TryShutdownPlugin during unload. On shutdown exception, the flag remains true so UnloadPlugins can retry cleanup. Replaced the previous IsStarted flag with NeedsShutdown to track required cleanup. --- .../Plugins/PluginManager.cs | 35 ++++++++++++------- 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/src/Common/KeyAsio.Application/Plugins/PluginManager.cs b/src/Common/KeyAsio.Application/Plugins/PluginManager.cs index e8cd41f9..1d8d341f 100644 --- a/src/Common/KeyAsio.Application/Plugins/PluginManager.cs +++ b/src/Common/KeyAsio.Application/Plugins/PluginManager.cs @@ -169,12 +169,14 @@ public void InitializePlugins() _gameplay, _interaction); wrapper.Context = context; + wrapper.NeedsShutdown = true; wrapper.Instance.Initialize(context); wrapper.IsInitialized = true; } catch (Exception ex) { _logger.LogError(ex, "Error initializing plugin {PluginName}", wrapper.Instance.Name); + TryShutdownPlugin(wrapper); } } } @@ -191,11 +193,12 @@ public void StartupPlugins() try { wrapper.Instance.Startup(); - wrapper.IsStarted = true; } catch (Exception ex) { _logger.LogError(ex, "Error starting plugin {PluginName}", wrapper.Instance.Name); + wrapper.IsInitialized = false; + TryShutdownPlugin(wrapper); } } } @@ -204,17 +207,7 @@ public void UnloadPlugins() { foreach (var wrapper in _plugins) { - if (wrapper.IsStarted) - { - try - { - wrapper.Instance.Shutdown(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error shutting down plugin {PluginName}", wrapper.Instance.Name); - } - } + TryShutdownPlugin(wrapper); try { @@ -242,6 +235,22 @@ public void UnloadPlugins() _loadContexts.Clear(); } + private void TryShutdownPlugin(PluginWrapper wrapper) + { + if (!wrapper.NeedsShutdown) return; + + try + { + wrapper.Instance.Shutdown(); + wrapper.NeedsShutdown = false; + } + catch (Exception ex) + { + // Keep NeedsShutdown set so UnloadPlugins can retry a failed cleanup. + _logger.LogError(ex, "Error shutting down plugin {PluginName}", wrapper.Instance.Name); + } + } + public void Dispose() { UnloadPlugins(); @@ -254,7 +263,7 @@ private class PluginWrapper public string PluginDirectory { get; } public PluginContext? Context { get; set; } public bool IsInitialized { get; set; } - public bool IsStarted { get; set; } + public bool NeedsShutdown { get; set; } public PluginWrapper(IPlugin instance, PluginLoadContext loadContext, string pluginDirectory) { From 586eed58dedb20e7e0f1efd5953a5fdea96732cc Mon Sep 17 00:00:00 2001 From: Milkitic Date: Mon, 13 Jul 2026 22:34:40 +0800 Subject: [PATCH 10/19] Refactor file layout, clean up usings and small fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move many Sync runtime sources out of Runtime/ into top-level folders and rename related files. Perform widespread using reorder/cleanup, trailing-newline fixes and minor formatting improvements (multi-line properties/params, wrapped exception messages). Change RtssOsdWriter sync primitive and small API/constructor tweaks (SkinManager ctor reordering). Update AsyncLock inner implementation and make several small readability/consistency adjustments across the codebase. No functional logic changes intended—primarily refactoring and style/structure cleanup. --- .../Models/ApplicationState.cs | 25 ++++++++---------- .../Models/SkinDescription.cs | 2 +- .../Plugins/AudioEngineWrapper.cs | 8 +++++- .../Services/RtssMonitorService.cs | 2 +- .../Services/RtssOsdWriter.cs | 11 ++++---- .../Services/SkinManager.cs | 26 +++++++++++-------- src/Common/KeyAsio.Common/AsyncLock.cs | 8 +++--- src/Common/KeyAsio.Common/DebugUtils.cs | 2 +- src/Common/KeyAsio.Common/EncodeUtils.cs | 2 +- src/Common/KeyAsio.Common/RentedArray.cs | 2 +- src/Common/KeyAsio.Common/RuntimeInfo.cs | 2 +- .../Host/IGameplaySession.cs | 1 - .../IMusicManagerPlugin.cs | 1 - .../EmbeddedSentryConfiguration.cs | 2 +- src/Common/KeyAsio.Secrets/Secrets.cs | 2 +- src/Common/KeyAsio.Secrets/VerifyUtils.cs | 2 +- .../AudioProviders/CatchHitsoundSequencer.cs | 2 +- .../AudioProviders/ManiaHitsoundSequencer.cs | 4 +-- .../StandardHitsoundSequencer.cs | 2 +- .../AudioProviders/TaikoHitsoundSequencer.cs | 4 +-- .../{Runtime => }/ConfigManager.cs | 4 +-- .../DependencyInjectionExtensions.cs | 4 +-- .../KeyAsio.Sync/Events/EventDelegates.cs | 2 +- .../{Runtime => }/GameStateMachine.cs | 2 +- .../{Runtime => }/IHitsoundSequencer.cs | 6 +++-- .../Services/BeatmapHitsoundLoader.cs | 4 +-- .../Services/GameplayAudioService.cs | 4 +-- .../Services/GameplaySessionManager.cs | 2 +- .../Services/SfxPlaybackService.cs | 5 ++-- .../{Runtime => }/SnareDrumSampleProvider.cs | 2 +- .../KeyAsio.Sync/Sources/BeatmapIdentifier.cs | 2 +- .../KeyAsio.Sync/Sources/GameSyncSnapshot.cs | 1 - .../Sources/GameSyncSourceCoordinator.cs | 3 +-- .../KeyAsio.Sync/Sources/IGameSyncSource.cs | 1 - .../KeyAsio.Sync/Sources/LazerIpcBridge.cs | 2 ++ .../KeyAsio.Sync/Sources/LazerIpcFrame.cs | 2 +- .../Sources/LazerIpcGameSyncSource.cs | 1 - .../KeyAsio.Sync/Sources/MemorySyncBridge.cs | 3 +-- .../KeyAsio.Sync/Sources/OsuMemoryData.cs | 3 +++ .../Sources/StableMemoryGameSyncSource.cs | 1 - .../{Runtime => }/States/BrowsingState.cs | 4 +-- .../{Runtime => }/States/IGameState.cs | 2 +- .../{Runtime => }/States/NotRunningState.cs | 2 +- .../{Runtime => }/States/PlayingState.cs | 9 ++++--- .../{Runtime => }/States/ResultsState.cs | 2 +- .../{Runtime => }/SyncContextWrapper.cs | 0 .../{Runtime => }/SyncController.cs | 7 ++--- .../{Runtime => }/SyncSessionContext.cs | 3 ++- 48 files changed, 100 insertions(+), 93 deletions(-) rename src/Core/KeyAsio.Sync/{Runtime => }/AudioProviders/CatchHitsoundSequencer.cs (100%) rename src/Core/KeyAsio.Sync/{Runtime => }/AudioProviders/ManiaHitsoundSequencer.cs (99%) rename src/Core/KeyAsio.Sync/{Runtime => }/AudioProviders/StandardHitsoundSequencer.cs (100%) rename src/Core/KeyAsio.Sync/{Runtime => }/AudioProviders/TaikoHitsoundSequencer.cs (100%) rename src/Core/KeyAsio.Sync/{Runtime => }/ConfigManager.cs (99%) rename src/Core/KeyAsio.Sync/{Runtime => }/DependencyInjectionExtensions.cs (100%) rename src/Core/KeyAsio.Sync/{Runtime => }/GameStateMachine.cs (99%) rename src/Core/KeyAsio.Sync/{Runtime => }/IHitsoundSequencer.cs (84%) rename src/Core/KeyAsio.Sync/{Runtime => }/Services/BeatmapHitsoundLoader.cs (100%) rename src/Core/KeyAsio.Sync/{Runtime => }/Services/GameplayAudioService.cs (100%) rename src/Core/KeyAsio.Sync/{Runtime => }/Services/GameplaySessionManager.cs (100%) rename src/Core/KeyAsio.Sync/{Runtime => }/Services/SfxPlaybackService.cs (99%) rename src/Core/KeyAsio.Sync/{Runtime => }/SnareDrumSampleProvider.cs (99%) rename src/Core/KeyAsio.Sync/{Runtime => }/States/BrowsingState.cs (99%) rename src/Core/KeyAsio.Sync/{Runtime => }/States/IGameState.cs (99%) rename src/Core/KeyAsio.Sync/{Runtime => }/States/NotRunningState.cs (99%) rename src/Core/KeyAsio.Sync/{Runtime => }/States/PlayingState.cs (98%) rename src/Core/KeyAsio.Sync/{Runtime => }/States/ResultsState.cs (99%) rename src/Core/KeyAsio.Sync/{Runtime => }/SyncContextWrapper.cs (100%) rename src/Core/KeyAsio.Sync/{Runtime => }/SyncController.cs (99%) rename src/Core/KeyAsio.Sync/{Runtime => }/SyncSessionContext.cs (99%) diff --git a/src/Common/KeyAsio.Application/Models/ApplicationState.cs b/src/Common/KeyAsio.Application/Models/ApplicationState.cs index b8b7de3c..adcd4c4a 100644 --- a/src/Common/KeyAsio.Application/Models/ApplicationState.cs +++ b/src/Common/KeyAsio.Application/Models/ApplicationState.cs @@ -1,30 +1,26 @@ -using KeyAsio.Core.Audio; using KeyAsio.Common; using KeyAsio.Configuration; using KeyAsio.Configuration.Models; +using KeyAsio.Core.Audio; using KeyAsio.Sync.Abstractions; namespace KeyAsio.Application.Models; public class ApplicationState : ViewModelBase, IPlaybackRuntimeState { - private DeviceDescription? _deviceDescription; - private int _framesPerBuffer; - private int _playbackLatency; - private SkinDescription? _selectedSkin; - public ApplicationState(AppSettings appSettings) { AppSettings = appSettings; } + public ObservableRangeCollection Skins { get; } = [SkinDescription.Internal]; public SkinDescription? SelectedSkin { - get => _selectedSkin; + get; set { - if (!SetField(ref _selectedSkin, value)) return; + if (!SetField(ref field, value)) return; SelectedSkinChanged?.Invoke(); } } @@ -35,21 +31,22 @@ public SkinDescription? SelectedSkin public DeviceDescription? DeviceDescription { - get => _deviceDescription; - set => SetField(ref _deviceDescription, value); + get; + set => SetField(ref field, value); } public int FramesPerBuffer { - get => _framesPerBuffer; - set => SetField(ref _framesPerBuffer, value); + get; + set => SetField(ref field, value); } public int PlaybackLatency { - get => _playbackLatency; - set => SetField(ref _playbackLatency, value); + get; + set => SetField(ref field, value); } + public bool AutoMode { get; set; } public string DefaultFolder { get; } = Path.Combine(Environment.CurrentDirectory, "resources", "default"); diff --git a/src/Common/KeyAsio.Application/Models/SkinDescription.cs b/src/Common/KeyAsio.Application/Models/SkinDescription.cs index 0bc54586..03c90b1d 100644 --- a/src/Common/KeyAsio.Application/Models/SkinDescription.cs +++ b/src/Common/KeyAsio.Application/Models/SkinDescription.cs @@ -41,4 +41,4 @@ public string? CopyRight public static SkinDescription Internal { get; } = new("{internal}", "{internal}", null, null); public static SkinDescription Classic { get; } = new("{classic}", "{classic}", null, null); -} \ No newline at end of file +} diff --git a/src/Common/KeyAsio.Application/Plugins/AudioEngineWrapper.cs b/src/Common/KeyAsio.Application/Plugins/AudioEngineWrapper.cs index 964d6f66..fbf70a5e 100644 --- a/src/Common/KeyAsio.Application/Plugins/AudioEngineWrapper.cs +++ b/src/Common/KeyAsio.Application/Plugins/AudioEngineWrapper.cs @@ -32,7 +32,13 @@ public PluginAudioFile(string path) } public WaveFormat WaveFormat => _reader.WaveFormat; - public TimeSpan Position { get => _reader.CurrentTime; set => _reader.CurrentTime = value; } + + public TimeSpan Position + { + get => _reader.CurrentTime; + set => _reader.CurrentTime = value; + } + public TimeSpan Duration => _reader.TotalTime; public int Read(float[] buffer, int offset, int count) => _reader.Read(buffer, offset, count); diff --git a/src/Common/KeyAsio.Application/Services/RtssMonitorService.cs b/src/Common/KeyAsio.Application/Services/RtssMonitorService.cs index e766559f..8b2b3d03 100644 --- a/src/Common/KeyAsio.Application/Services/RtssMonitorService.cs +++ b/src/Common/KeyAsio.Application/Services/RtssMonitorService.cs @@ -1,7 +1,7 @@ -using KeyAsio.Configuration; using System.ComponentModel; using System.Diagnostics; using System.Text; +using KeyAsio.Configuration; using KeyAsio.Plugins.Contracts; using KeyAsio.Sync; using Microsoft.Extensions.Logging; diff --git a/src/Common/KeyAsio.Application/Services/RtssOsdWriter.cs b/src/Common/KeyAsio.Application/Services/RtssOsdWriter.cs index 648d9b36..f0cd7770 100644 --- a/src/Common/KeyAsio.Application/Services/RtssOsdWriter.cs +++ b/src/Common/KeyAsio.Application/Services/RtssOsdWriter.cs @@ -1,5 +1,4 @@ using System.IO.MemoryMappedFiles; -using System.Runtime.InteropServices; using System.Text; namespace KeyAsio.Application.Services; @@ -14,12 +13,11 @@ public sealed class RtssOsdWriter : IDisposable private const uint ExtendedTextVersionMin = 0x00020007; private const uint RtssSignature = 0x52545353; // 'RTSS' - private readonly string _entryName; private readonly byte[] _entryNameBytes = new byte[OwnerFieldLength]; private readonly byte[] _ownerBuffer = new byte[OwnerFieldLength]; private readonly byte[] _legacyTextBuffer = new byte[LegacyTextLength]; private readonly byte[] _extendedTextBuffer = new byte[ExtendedTextLength]; - private readonly object _syncRoot = new(); + private readonly Lock _syncRoot = new(); private MemoryMappedFile? _mmf; private MemoryMappedViewAccessor? _accessor; @@ -39,9 +37,9 @@ public RtssOsdWriter(string entryName) var bytes = Encoding.ASCII.GetByteCount(entryName); if (bytes > OwnerFieldLength - 1) - throw new ArgumentException("Entry name exceeds max length of 255 when converted to ANSI.", nameof(entryName)); + throw new ArgumentException("Entry name exceeds max length of 255 when converted to ANSI.", + nameof(entryName)); - _entryName = entryName; _osdSlot = 0; _entryOffset = 0; @@ -69,7 +67,8 @@ public void Update(string text) var maxTextLength = _useExtendedText ? ExtendedTextLength - 1 : LegacyTextLength - 1; var textBytes = Encoding.ASCII.GetByteCount(text); if (textBytes > maxTextLength) - throw new ArgumentException($"Text exceeds max length of {maxTextLength} when converted to ANSI.", nameof(text)); + throw new ArgumentException($"Text exceeds max length of {maxTextLength} when converted to ANSI.", + nameof(text)); var targetBuffer = _useExtendedText ? _extendedTextBuffer : _legacyTextBuffer; var written = Encoding.ASCII.GetBytes(text.AsSpan(), targetBuffer); diff --git a/src/Common/KeyAsio.Application/Services/SkinManager.cs b/src/Common/KeyAsio.Application/Services/SkinManager.cs index b3f15f7b..9810bbff 100644 --- a/src/Common/KeyAsio.Application/Services/SkinManager.cs +++ b/src/Common/KeyAsio.Application/Services/SkinManager.cs @@ -1,19 +1,18 @@ using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; using Coosu.Shared.IO; using dnlib.DotNet; -using KeyAsio.Core.Audio.Caching; -using KeyAsio.Core.OsuAudio.Hitsounds; -using KeyAsio.LazerProtocol; using KeyAsio.Application.Abstractions; -using KeyAsio.Application.Utils; using KeyAsio.Application.Models; +using KeyAsio.Application.Utils; +using KeyAsio.Common; using KeyAsio.Configuration; using KeyAsio.Configuration.Models; -using KeyAsio.Sync.Sources; +using KeyAsio.Core.Audio.Caching; +using KeyAsio.Core.OsuAudio.Hitsounds; +using KeyAsio.LazerProtocol; using KeyAsio.Sync; using KeyAsio.Sync.Abstractions; -using KeyAsio.Common; +using KeyAsio.Sync.Sources; using Microsoft.Extensions.Logging; namespace KeyAsio.Application.Services; @@ -95,10 +94,15 @@ private static readonly (string Guid, SkinDescription Description)[] s_lazerBuil private string? _lazerExeDirectory; private GameClientType _lastKnownClientType = GameClientType.Stable; - public SkinManager(ILogger logger, AppSettings appSettings, - IAppSettingsPersistence settingsPersistence, AudioCacheManager audioCacheManager, - ApplicationState sharedViewModel, LazerIpcGameSyncSource? lazerSyncSource, SyncSessionContext syncSessionContext, - GameSyncSourceCoordinator syncSourceCoordinator, IApplicationDispatcher dispatcher) + public SkinManager(IApplicationDispatcher dispatcher, + IAppSettingsPersistence settingsPersistence, + ILogger logger, + AppSettings appSettings, + AudioCacheManager audioCacheManager, + ApplicationState sharedViewModel, + GameSyncSourceCoordinator syncSourceCoordinator, + LazerIpcGameSyncSource? lazerSyncSource, + SyncSessionContext syncSessionContext) { _logger = logger; _appSettings = appSettings; diff --git a/src/Common/KeyAsio.Common/AsyncLock.cs b/src/Common/KeyAsio.Common/AsyncLock.cs index 45c6571e..f4acc10a 100644 --- a/src/Common/KeyAsio.Common/AsyncLock.cs +++ b/src/Common/KeyAsio.Common/AsyncLock.cs @@ -2,11 +2,9 @@ namespace KeyAsio.Common; public class AsyncLock : IDisposable { - private class AsyncLockImpl : IDisposable + private class AsyncLockImpl(AsyncLock parent) : IDisposable { - private readonly AsyncLock _parent; - public AsyncLockImpl(AsyncLock parent) => _parent = parent; - public void Dispose() => _parent._semaphoreSlim.Release(); + public void Dispose() => parent._semaphoreSlim.Release(); } private readonly AsyncLockImpl _asyncLockImpl; @@ -41,4 +39,4 @@ public void Dispose() { _semaphoreSlim.Dispose(); } -} \ No newline at end of file +} diff --git a/src/Common/KeyAsio.Common/DebugUtils.cs b/src/Common/KeyAsio.Common/DebugUtils.cs index fb51a614..96f77bf1 100644 --- a/src/Common/KeyAsio.Common/DebugUtils.cs +++ b/src/Common/KeyAsio.Common/DebugUtils.cs @@ -1,5 +1,5 @@ -using System.Runtime.CompilerServices; using System.Diagnostics; +using System.Runtime.CompilerServices; using System.Text; using Microsoft.Extensions.Logging; diff --git a/src/Common/KeyAsio.Common/EncodeUtils.cs b/src/Common/KeyAsio.Common/EncodeUtils.cs index b9465250..57c51ae5 100644 --- a/src/Common/KeyAsio.Common/EncodeUtils.cs +++ b/src/Common/KeyAsio.Common/EncodeUtils.cs @@ -54,4 +54,4 @@ public static string FromBase64StringEmptyIfError(string value) return ""; } } -} \ No newline at end of file +} diff --git a/src/Common/KeyAsio.Common/RentedArray.cs b/src/Common/KeyAsio.Common/RentedArray.cs index 748a0008..fc5d6fdc 100644 --- a/src/Common/KeyAsio.Common/RentedArray.cs +++ b/src/Common/KeyAsio.Common/RentedArray.cs @@ -34,4 +34,4 @@ public RentedArray(ArrayPool pool, int minimumLength) /// Returns the array to its originating pool. /// public void Dispose() => _pool.Return(Array); -} \ No newline at end of file +} diff --git a/src/Common/KeyAsio.Common/RuntimeInfo.cs b/src/Common/KeyAsio.Common/RuntimeInfo.cs index 117f3a67..95257192 100644 --- a/src/Common/KeyAsio.Common/RuntimeInfo.cs +++ b/src/Common/KeyAsio.Common/RuntimeInfo.cs @@ -87,4 +87,4 @@ private static bool ContainsStringReverse(string filePath, string targetStr) return false; } -} \ No newline at end of file +} diff --git a/src/Common/KeyAsio.Plugins.Contracts/Host/IGameplaySession.cs b/src/Common/KeyAsio.Plugins.Contracts/Host/IGameplaySession.cs index 78cabcde..f77d5a4f 100644 --- a/src/Common/KeyAsio.Plugins.Contracts/Host/IGameplaySession.cs +++ b/src/Common/KeyAsio.Plugins.Contracts/Host/IGameplaySession.cs @@ -1,6 +1,5 @@ using System.Diagnostics.CodeAnalysis; using Coosu.Beatmap; -using NAudio.Wave; namespace KeyAsio.Plugins.Contracts; diff --git a/src/Common/KeyAsio.Plugins.Contracts/IMusicManagerPlugin.cs b/src/Common/KeyAsio.Plugins.Contracts/IMusicManagerPlugin.cs index e2d0b167..8b391680 100644 --- a/src/Common/KeyAsio.Plugins.Contracts/IMusicManagerPlugin.cs +++ b/src/Common/KeyAsio.Plugins.Contracts/IMusicManagerPlugin.cs @@ -1,6 +1,5 @@ using Coosu.Beatmap; using KeyAsio.Plugins.Contracts.Sync; -using NAudio.Wave; namespace KeyAsio.Plugins.Contracts; diff --git a/src/Common/KeyAsio.Secrets/EmbeddedSentryConfiguration.cs b/src/Common/KeyAsio.Secrets/EmbeddedSentryConfiguration.cs index 4e8b3147..b0484a9f 100644 --- a/src/Common/KeyAsio.Secrets/EmbeddedSentryConfiguration.cs +++ b/src/Common/KeyAsio.Secrets/EmbeddedSentryConfiguration.cs @@ -30,4 +30,4 @@ public void Dispose() { _sentrySdk.Dispose(); } -} \ No newline at end of file +} diff --git a/src/Common/KeyAsio.Secrets/Secrets.cs b/src/Common/KeyAsio.Secrets/Secrets.cs index 06a16a61..9e412b1a 100644 --- a/src/Common/KeyAsio.Secrets/Secrets.cs +++ b/src/Common/KeyAsio.Secrets/Secrets.cs @@ -2,4 +2,4 @@ { internal static readonly string Dsn = "mock-dsn"; internal static readonly string OfficialPublicKey = "mock-key"; -} \ No newline at end of file +} diff --git a/src/Common/KeyAsio.Secrets/VerifyUtils.cs b/src/Common/KeyAsio.Secrets/VerifyUtils.cs index c9566384..1974ca96 100644 --- a/src/Common/KeyAsio.Secrets/VerifyUtils.cs +++ b/src/Common/KeyAsio.Secrets/VerifyUtils.cs @@ -79,4 +79,4 @@ public static bool IsOfficialBuildUnsafe() return false; } } -} \ No newline at end of file +} diff --git a/src/Core/KeyAsio.Sync/Runtime/AudioProviders/CatchHitsoundSequencer.cs b/src/Core/KeyAsio.Sync/AudioProviders/CatchHitsoundSequencer.cs similarity index 100% rename from src/Core/KeyAsio.Sync/Runtime/AudioProviders/CatchHitsoundSequencer.cs rename to src/Core/KeyAsio.Sync/AudioProviders/CatchHitsoundSequencer.cs index 55f902ec..f480256d 100644 --- a/src/Core/KeyAsio.Sync/Runtime/AudioProviders/CatchHitsoundSequencer.cs +++ b/src/Core/KeyAsio.Sync/AudioProviders/CatchHitsoundSequencer.cs @@ -1,6 +1,6 @@ +using System.Runtime.CompilerServices; using KeyAsio.Configuration; using KeyAsio.Configuration.Models; -using System.Runtime.CompilerServices; using KeyAsio.Core.Audio; using KeyAsio.Core.OsuAudio.Hitsounds.Playback; using KeyAsio.Sync.Models; diff --git a/src/Core/KeyAsio.Sync/Runtime/AudioProviders/ManiaHitsoundSequencer.cs b/src/Core/KeyAsio.Sync/AudioProviders/ManiaHitsoundSequencer.cs similarity index 99% rename from src/Core/KeyAsio.Sync/Runtime/AudioProviders/ManiaHitsoundSequencer.cs rename to src/Core/KeyAsio.Sync/AudioProviders/ManiaHitsoundSequencer.cs index 2ad332c8..7f68f5d2 100644 --- a/src/Core/KeyAsio.Sync/Runtime/AudioProviders/ManiaHitsoundSequencer.cs +++ b/src/Core/KeyAsio.Sync/AudioProviders/ManiaHitsoundSequencer.cs @@ -1,9 +1,9 @@ +using KeyAsio.Common; using KeyAsio.Configuration; using KeyAsio.Core.Audio; using KeyAsio.Core.OsuAudio.Hitsounds.Playback; using KeyAsio.Sync.Models; using KeyAsio.Sync.Services; -using KeyAsio.Common; using Microsoft.Extensions.Logging; namespace KeyAsio.Sync.AudioProviders; @@ -239,4 +239,4 @@ private void FillNextPlaybackAudio(List buffer, PlaybackEvent? fir _firstAutoNode = firstNode; } } -} \ No newline at end of file +} diff --git a/src/Core/KeyAsio.Sync/Runtime/AudioProviders/StandardHitsoundSequencer.cs b/src/Core/KeyAsio.Sync/AudioProviders/StandardHitsoundSequencer.cs similarity index 100% rename from src/Core/KeyAsio.Sync/Runtime/AudioProviders/StandardHitsoundSequencer.cs rename to src/Core/KeyAsio.Sync/AudioProviders/StandardHitsoundSequencer.cs index fc5547c2..d7a22961 100644 --- a/src/Core/KeyAsio.Sync/Runtime/AudioProviders/StandardHitsoundSequencer.cs +++ b/src/Core/KeyAsio.Sync/AudioProviders/StandardHitsoundSequencer.cs @@ -1,6 +1,6 @@ +using System.Runtime.CompilerServices; using KeyAsio.Configuration; using KeyAsio.Configuration.Models; -using System.Runtime.CompilerServices; using KeyAsio.Core.Audio; using KeyAsio.Core.OsuAudio.Hitsounds.Playback; using KeyAsio.Sync.Models; diff --git a/src/Core/KeyAsio.Sync/Runtime/AudioProviders/TaikoHitsoundSequencer.cs b/src/Core/KeyAsio.Sync/AudioProviders/TaikoHitsoundSequencer.cs similarity index 100% rename from src/Core/KeyAsio.Sync/Runtime/AudioProviders/TaikoHitsoundSequencer.cs rename to src/Core/KeyAsio.Sync/AudioProviders/TaikoHitsoundSequencer.cs index f08b7759..4a337fde 100644 --- a/src/Core/KeyAsio.Sync/Runtime/AudioProviders/TaikoHitsoundSequencer.cs +++ b/src/Core/KeyAsio.Sync/AudioProviders/TaikoHitsoundSequencer.cs @@ -1,7 +1,7 @@ -using KeyAsio.Configuration; -using KeyAsio.Configuration.Models; using System.Diagnostics; using System.Runtime.CompilerServices; +using KeyAsio.Configuration; +using KeyAsio.Configuration.Models; using KeyAsio.Core.Audio; using KeyAsio.Core.Audio.Caching; using KeyAsio.Core.OsuAudio.Hitsounds.Playback; diff --git a/src/Core/KeyAsio.Sync/Runtime/ConfigManager.cs b/src/Core/KeyAsio.Sync/ConfigManager.cs similarity index 99% rename from src/Core/KeyAsio.Sync/Runtime/ConfigManager.cs rename to src/Core/KeyAsio.Sync/ConfigManager.cs index 04b1a021..b2462534 100644 --- a/src/Core/KeyAsio.Sync/Runtime/ConfigManager.cs +++ b/src/Core/KeyAsio.Sync/ConfigManager.cs @@ -1,5 +1,5 @@ -using KeyAsio.Configuration; using Coosu.Shared.IO; +using KeyAsio.Configuration; using Milki.Extensions.MouseKeyHook; namespace KeyAsio.Sync; @@ -52,4 +52,4 @@ private static string GetUserConfigFilename(string username) { return $"osu!.{PathUtils.EscapeFileName(username)}.cfg"; } -} \ No newline at end of file +} diff --git a/src/Core/KeyAsio.Sync/Runtime/DependencyInjectionExtensions.cs b/src/Core/KeyAsio.Sync/DependencyInjectionExtensions.cs similarity index 100% rename from src/Core/KeyAsio.Sync/Runtime/DependencyInjectionExtensions.cs rename to src/Core/KeyAsio.Sync/DependencyInjectionExtensions.cs index 3834c626..783bd7cf 100644 --- a/src/Core/KeyAsio.Sync/Runtime/DependencyInjectionExtensions.cs +++ b/src/Core/KeyAsio.Sync/DependencyInjectionExtensions.cs @@ -1,6 +1,6 @@ -using KeyAsio.Sync.Sources; -using KeyAsio.Sync.Services; using KeyAsio.Sync.Abstractions; +using KeyAsio.Sync.Services; +using KeyAsio.Sync.Sources; using Microsoft.Extensions.DependencyInjection; namespace KeyAsio.Sync; diff --git a/src/Core/KeyAsio.Sync/Events/EventDelegates.cs b/src/Core/KeyAsio.Sync/Events/EventDelegates.cs index ac5c1fb6..46e377eb 100644 --- a/src/Core/KeyAsio.Sync/Events/EventDelegates.cs +++ b/src/Core/KeyAsio.Sync/Events/EventDelegates.cs @@ -26,4 +26,4 @@ namespace KeyAsio.Sync.Events; /// The old value. /// The new value. /// A task that represents the asynchronous operation. -public delegate Task ValueChangedAsyncEventHandler(T oldValue, T newValue); \ No newline at end of file +public delegate Task ValueChangedAsyncEventHandler(T oldValue, T newValue); diff --git a/src/Core/KeyAsio.Sync/Runtime/GameStateMachine.cs b/src/Core/KeyAsio.Sync/GameStateMachine.cs similarity index 99% rename from src/Core/KeyAsio.Sync/Runtime/GameStateMachine.cs rename to src/Core/KeyAsio.Sync/GameStateMachine.cs index a4b6eef7..9f858fcc 100644 --- a/src/Core/KeyAsio.Sync/Runtime/GameStateMachine.cs +++ b/src/Core/KeyAsio.Sync/GameStateMachine.cs @@ -65,4 +65,4 @@ public async Task EnterFromAsync(SyncSessionContext ctx, OsuMemoryStatus from, O } } } -} \ No newline at end of file +} diff --git a/src/Core/KeyAsio.Sync/Runtime/IHitsoundSequencer.cs b/src/Core/KeyAsio.Sync/IHitsoundSequencer.cs similarity index 84% rename from src/Core/KeyAsio.Sync/Runtime/IHitsoundSequencer.cs rename to src/Core/KeyAsio.Sync/IHitsoundSequencer.cs index 5cea65cb..5c482cb8 100644 --- a/src/Core/KeyAsio.Sync/Runtime/IHitsoundSequencer.cs +++ b/src/Core/KeyAsio.Sync/IHitsoundSequencer.cs @@ -8,6 +8,8 @@ public interface IHitsoundSequencer void ProcessAutoPlay(List buffer, bool processHitQueueAsAuto); void ProcessInteraction(List buffer, int keyIndex, int keyTotal); - void FillAudioList(IReadOnlyList nodeList, List keyList, List playbackList); + void FillAudioList(IReadOnlyList nodeList, List keyList, + List playbackList); + void SeekTo(int playTime); -} \ No newline at end of file +} diff --git a/src/Core/KeyAsio.Sync/Runtime/Services/BeatmapHitsoundLoader.cs b/src/Core/KeyAsio.Sync/Services/BeatmapHitsoundLoader.cs similarity index 100% rename from src/Core/KeyAsio.Sync/Runtime/Services/BeatmapHitsoundLoader.cs rename to src/Core/KeyAsio.Sync/Services/BeatmapHitsoundLoader.cs index 04d28dee..f0098851 100644 --- a/src/Core/KeyAsio.Sync/Runtime/Services/BeatmapHitsoundLoader.cs +++ b/src/Core/KeyAsio.Sync/Services/BeatmapHitsoundLoader.cs @@ -1,9 +1,9 @@ -using KeyAsio.Configuration; using Coosu.Beatmap; +using KeyAsio.Common; +using KeyAsio.Configuration; using KeyAsio.Core.OsuAudio.Hitsounds; using KeyAsio.Core.OsuAudio.Hitsounds.Playback; using KeyAsio.Plugins.Contracts.Sync; -using KeyAsio.Common; using Microsoft.Extensions.Logging; namespace KeyAsio.Sync.Services; diff --git a/src/Core/KeyAsio.Sync/Runtime/Services/GameplayAudioService.cs b/src/Core/KeyAsio.Sync/Services/GameplayAudioService.cs similarity index 100% rename from src/Core/KeyAsio.Sync/Runtime/Services/GameplayAudioService.cs rename to src/Core/KeyAsio.Sync/Services/GameplayAudioService.cs index d4c25a46..426207f2 100644 --- a/src/Core/KeyAsio.Sync/Runtime/Services/GameplayAudioService.cs +++ b/src/Core/KeyAsio.Sync/Services/GameplayAudioService.cs @@ -1,13 +1,13 @@ -using KeyAsio.Configuration; using System.Collections.Concurrent; using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; +using KeyAsio.Common; +using KeyAsio.Configuration; using KeyAsio.Core.Audio; using KeyAsio.Core.Audio.Caching; using KeyAsio.Core.OsuAudio.Hitsounds; using KeyAsio.Core.OsuAudio.Hitsounds.Playback; using KeyAsio.Core.OsuAudio.Utils; -using KeyAsio.Common; using KeyAsio.Sync.Abstractions; using Microsoft.Extensions.Logging; using NAudio.Wave; diff --git a/src/Core/KeyAsio.Sync/Runtime/Services/GameplaySessionManager.cs b/src/Core/KeyAsio.Sync/Services/GameplaySessionManager.cs similarity index 100% rename from src/Core/KeyAsio.Sync/Runtime/Services/GameplaySessionManager.cs rename to src/Core/KeyAsio.Sync/Services/GameplaySessionManager.cs index 6fea4335..4a8e723f 100644 --- a/src/Core/KeyAsio.Sync/Runtime/Services/GameplaySessionManager.cs +++ b/src/Core/KeyAsio.Sync/Services/GameplaySessionManager.cs @@ -2,11 +2,11 @@ using System.Runtime.CompilerServices; using Coosu.Beatmap; using Coosu.Beatmap.Sections.GamePlay; +using KeyAsio.Common; using KeyAsio.Core.Audio; using KeyAsio.Core.OsuAudio.Hitsounds; using KeyAsio.Core.OsuAudio.Hitsounds.Playback; using KeyAsio.Sync.Events; -using KeyAsio.Common; using Microsoft.Extensions.Logging; namespace KeyAsio.Sync.Services; diff --git a/src/Core/KeyAsio.Sync/Runtime/Services/SfxPlaybackService.cs b/src/Core/KeyAsio.Sync/Services/SfxPlaybackService.cs similarity index 99% rename from src/Core/KeyAsio.Sync/Runtime/Services/SfxPlaybackService.cs rename to src/Core/KeyAsio.Sync/Services/SfxPlaybackService.cs index eb8ecff2..dd2d81a5 100644 --- a/src/Core/KeyAsio.Sync/Runtime/Services/SfxPlaybackService.cs +++ b/src/Core/KeyAsio.Sync/Services/SfxPlaybackService.cs @@ -1,5 +1,5 @@ -using KeyAsio.Configuration; using System.ComponentModel; +using KeyAsio.Configuration; using KeyAsio.Core.Audio; using KeyAsio.Core.Audio.Caching; using KeyAsio.Core.Audio.SampleProviders; @@ -18,7 +18,8 @@ public class SfxPlaybackService private readonly IPlaybackEngine _playbackEngine; private readonly AppSettings _appSettings; - public SfxPlaybackService(ILogger logger, IPlaybackEngine playbackEngine, AppSettings appSettings) + public SfxPlaybackService(ILogger logger, IPlaybackEngine playbackEngine, + AppSettings appSettings) { _logger = logger; _playbackEngine = playbackEngine; diff --git a/src/Core/KeyAsio.Sync/Runtime/SnareDrumSampleProvider.cs b/src/Core/KeyAsio.Sync/SnareDrumSampleProvider.cs similarity index 99% rename from src/Core/KeyAsio.Sync/Runtime/SnareDrumSampleProvider.cs rename to src/Core/KeyAsio.Sync/SnareDrumSampleProvider.cs index 81c35899..f28a907a 100644 --- a/src/Core/KeyAsio.Sync/Runtime/SnareDrumSampleProvider.cs +++ b/src/Core/KeyAsio.Sync/SnareDrumSampleProvider.cs @@ -315,4 +315,4 @@ private float FastNextFloat() _rngState = x; return (int)x * 4.6566128752458e-10f; } -} \ No newline at end of file +} diff --git a/src/Core/KeyAsio.Sync/Sources/BeatmapIdentifier.cs b/src/Core/KeyAsio.Sync/Sources/BeatmapIdentifier.cs index bd579763..828b766c 100644 --- a/src/Core/KeyAsio.Sync/Sources/BeatmapIdentifier.cs +++ b/src/Core/KeyAsio.Sync/Sources/BeatmapIdentifier.cs @@ -27,4 +27,4 @@ public BeatmapIdentifier(string? folder, string? filename) public string? Filename { get; } public static BeatmapIdentifier Default = new(); -} \ No newline at end of file +} diff --git a/src/Core/KeyAsio.Sync/Sources/GameSyncSnapshot.cs b/src/Core/KeyAsio.Sync/Sources/GameSyncSnapshot.cs index deadd5b6..aa627a0f 100644 --- a/src/Core/KeyAsio.Sync/Sources/GameSyncSnapshot.cs +++ b/src/Core/KeyAsio.Sync/Sources/GameSyncSnapshot.cs @@ -2,7 +2,6 @@ using KeyAsio.Core.OsuAudio.Hitsounds; using KeyAsio.Plugins.Contracts; using KeyAsio.Plugins.Contracts.Sync; -using KeyAsio.Sync; namespace KeyAsio.Sync.Sources; diff --git a/src/Core/KeyAsio.Sync/Sources/GameSyncSourceCoordinator.cs b/src/Core/KeyAsio.Sync/Sources/GameSyncSourceCoordinator.cs index 5639d011..5efe57e1 100644 --- a/src/Core/KeyAsio.Sync/Sources/GameSyncSourceCoordinator.cs +++ b/src/Core/KeyAsio.Sync/Sources/GameSyncSourceCoordinator.cs @@ -1,6 +1,4 @@ using KeyAsio.Configuration.Models; -using KeyAsio.Plugins.Contracts.Sync; -using KeyAsio.Sync; using Microsoft.Extensions.Logging; namespace KeyAsio.Sync.Sources; @@ -126,6 +124,7 @@ private void ApplySnapshot(GameSyncSnapshot snapshot) _lastClientType = snapshot.ClientType; ClientTypeChanged?.Invoke(snapshot.ClientType); } + _syncSessionContext.ProcessId = snapshot.ProcessId; _syncSessionContext.Username = snapshot.Username; _syncSessionContext.PlayMods = snapshot.PlayMods; diff --git a/src/Core/KeyAsio.Sync/Sources/IGameSyncSource.cs b/src/Core/KeyAsio.Sync/Sources/IGameSyncSource.cs index b74abf49..bac797c6 100644 --- a/src/Core/KeyAsio.Sync/Sources/IGameSyncSource.cs +++ b/src/Core/KeyAsio.Sync/Sources/IGameSyncSource.cs @@ -1,5 +1,4 @@ using KeyAsio.Configuration.Models; -using KeyAsio.Sync; namespace KeyAsio.Sync.Sources; diff --git a/src/Core/KeyAsio.Sync/Sources/LazerIpcBridge.cs b/src/Core/KeyAsio.Sync/Sources/LazerIpcBridge.cs index 43706e24..c1932c00 100644 --- a/src/Core/KeyAsio.Sync/Sources/LazerIpcBridge.cs +++ b/src/Core/KeyAsio.Sync/Sources/LazerIpcBridge.cs @@ -11,7 +11,9 @@ public sealed class LazerIpcBridge : IDisposable { public const string PipeName = LazerProtocolConstants.TimingPipeName; public const string EventPipeName = LazerProtocolConstants.EventPipeName; + public const int ProtocolVersion = LazerProtocolConstants.ProtocolVersion; + // Event frames may include lazer skin and file metadata; large skin libraries can exceed several MB. private const int MaxFrameLength = 64 * 1024 * 1024; diff --git a/src/Core/KeyAsio.Sync/Sources/LazerIpcFrame.cs b/src/Core/KeyAsio.Sync/Sources/LazerIpcFrame.cs index 6d8bc1eb..124a2095 100644 --- a/src/Core/KeyAsio.Sync/Sources/LazerIpcFrame.cs +++ b/src/Core/KeyAsio.Sync/Sources/LazerIpcFrame.cs @@ -134,4 +134,4 @@ internal static class LazerStatisticsExtensions { public static SyncStatistics ToSyncStatistics(this LazerStatistics statistics) => new(statistics.Perfect, statistics.Great, statistics.Good, statistics.Ok, statistics.Meh, statistics.Miss); -} \ No newline at end of file +} diff --git a/src/Core/KeyAsio.Sync/Sources/LazerIpcGameSyncSource.cs b/src/Core/KeyAsio.Sync/Sources/LazerIpcGameSyncSource.cs index c5230e21..322f65c9 100644 --- a/src/Core/KeyAsio.Sync/Sources/LazerIpcGameSyncSource.cs +++ b/src/Core/KeyAsio.Sync/Sources/LazerIpcGameSyncSource.cs @@ -3,7 +3,6 @@ using KeyAsio.LazerProtocol; using KeyAsio.Plugins.Contracts; using KeyAsio.Plugins.Contracts.Sync; -using KeyAsio.Sync; namespace KeyAsio.Sync.Sources; diff --git a/src/Core/KeyAsio.Sync/Sources/MemorySyncBridge.cs b/src/Core/KeyAsio.Sync/Sources/MemorySyncBridge.cs index 52351c34..019dc0a2 100644 --- a/src/Core/KeyAsio.Sync/Sources/MemorySyncBridge.cs +++ b/src/Core/KeyAsio.Sync/Sources/MemorySyncBridge.cs @@ -1,8 +1,7 @@ -using KeyAsio.Configuration; using System.ComponentModel; using System.Text; -using KeyAsio.Sync; using KeyAsio.Common; +using KeyAsio.Configuration; using Microsoft.Extensions.Logging; namespace KeyAsio.Sync.Sources; diff --git a/src/Core/KeyAsio.Sync/Sources/OsuMemoryData.cs b/src/Core/KeyAsio.Sync/Sources/OsuMemoryData.cs index 5f7eb9b8..67a355d4 100644 --- a/src/Core/KeyAsio.Sync/Sources/OsuMemoryData.cs +++ b/src/Core/KeyAsio.Sync/Sources/OsuMemoryData.cs @@ -7,6 +7,7 @@ public record OsuMemoryData //public int SetId { get; set; } //public string MapString { get; set; } = string.Empty; public string FolderName { get; set; } = string.Empty; + public string OsuFileName { get; set; } = string.Empty; //public string MD5 { get; set; } = string.Empty; //public float AR { get; set; } @@ -17,6 +18,7 @@ public record OsuMemoryData // General Data public int RawStatus { get; set; } // OsuStatus + //public int GameMode { get; set; } //public int Retries { get; set; } //public double TotalAudioTime { get; set; } @@ -28,6 +30,7 @@ public record OsuMemoryData // Additional Data //public bool IsLoggedIn { get; set; } public string Username { get; set; } = string.Empty; + //public string SkinFolder { get; set; } = string.Empty; public bool IsReplay { get; set; } public int Score { get; set; } diff --git a/src/Core/KeyAsio.Sync/Sources/StableMemoryGameSyncSource.cs b/src/Core/KeyAsio.Sync/Sources/StableMemoryGameSyncSource.cs index 663cde20..c3e05cc9 100644 --- a/src/Core/KeyAsio.Sync/Sources/StableMemoryGameSyncSource.cs +++ b/src/Core/KeyAsio.Sync/Sources/StableMemoryGameSyncSource.cs @@ -1,5 +1,4 @@ using KeyAsio.Configuration.Models; -using KeyAsio.Sync; namespace KeyAsio.Sync.Sources; diff --git a/src/Core/KeyAsio.Sync/Runtime/States/BrowsingState.cs b/src/Core/KeyAsio.Sync/States/BrowsingState.cs similarity index 99% rename from src/Core/KeyAsio.Sync/Runtime/States/BrowsingState.cs rename to src/Core/KeyAsio.Sync/States/BrowsingState.cs index 6ec17b22..7d310085 100644 --- a/src/Core/KeyAsio.Sync/Runtime/States/BrowsingState.cs +++ b/src/Core/KeyAsio.Sync/States/BrowsingState.cs @@ -1,6 +1,6 @@ using KeyAsio.Plugins.Contracts.Sync; -using KeyAsio.Sync.Sources; using KeyAsio.Sync.Services; +using KeyAsio.Sync.Sources; namespace KeyAsio.Sync.States; @@ -38,4 +38,4 @@ public void OnBeatmapChanged(SyncSessionContext ctx, BeatmapIdentifier beatmap) public void OnModsChanged(SyncSessionContext ctx, Mods oldMods, Mods newMods) { } -} \ No newline at end of file +} diff --git a/src/Core/KeyAsio.Sync/Runtime/States/IGameState.cs b/src/Core/KeyAsio.Sync/States/IGameState.cs similarity index 99% rename from src/Core/KeyAsio.Sync/Runtime/States/IGameState.cs rename to src/Core/KeyAsio.Sync/States/IGameState.cs index 91049212..dccedf44 100644 --- a/src/Core/KeyAsio.Sync/Runtime/States/IGameState.cs +++ b/src/Core/KeyAsio.Sync/States/IGameState.cs @@ -13,4 +13,4 @@ public interface IGameState void OnComboChanged(SyncSessionContext ctx, int oldCombo, int newCombo); void OnBeatmapChanged(SyncSessionContext ctx, BeatmapIdentifier beatmap); void OnModsChanged(SyncSessionContext ctx, Mods oldMods, Mods newMods); -} \ No newline at end of file +} diff --git a/src/Core/KeyAsio.Sync/Runtime/States/NotRunningState.cs b/src/Core/KeyAsio.Sync/States/NotRunningState.cs similarity index 99% rename from src/Core/KeyAsio.Sync/Runtime/States/NotRunningState.cs rename to src/Core/KeyAsio.Sync/States/NotRunningState.cs index 9d35a0a8..2f042cf5 100644 --- a/src/Core/KeyAsio.Sync/Runtime/States/NotRunningState.cs +++ b/src/Core/KeyAsio.Sync/States/NotRunningState.cs @@ -33,4 +33,4 @@ public void OnBeatmapChanged(SyncSessionContext ctx, BeatmapIdentifier beatmap) public void OnModsChanged(SyncSessionContext ctx, Mods oldMods, Mods newMods) { } -} \ No newline at end of file +} diff --git a/src/Core/KeyAsio.Sync/Runtime/States/PlayingState.cs b/src/Core/KeyAsio.Sync/States/PlayingState.cs similarity index 98% rename from src/Core/KeyAsio.Sync/Runtime/States/PlayingState.cs rename to src/Core/KeyAsio.Sync/States/PlayingState.cs index b2061a5f..28a3e3ad 100644 --- a/src/Core/KeyAsio.Sync/Runtime/States/PlayingState.cs +++ b/src/Core/KeyAsio.Sync/States/PlayingState.cs @@ -1,14 +1,14 @@ -using KeyAsio.Configuration.Models; -using KeyAsio.Configuration; using System.Diagnostics; using Coosu.Beatmap.Sections.GamePlay; +using KeyAsio.Configuration; +using KeyAsio.Configuration.Models; using KeyAsio.Core.Audio; using KeyAsio.Core.OsuAudio.Hitsounds.Playback; using KeyAsio.Plugins.Contracts.Sync; using KeyAsio.Sync.Abstractions; using KeyAsio.Sync.Models; -using KeyAsio.Sync.Sources; using KeyAsio.Sync.Services; +using KeyAsio.Sync.Sources; using Microsoft.Extensions.Logging; namespace KeyAsio.Sync.States; @@ -216,7 +216,8 @@ private void PlayManualPlaybackIfNeeded(SyncSessionContext ctx) if (_gameplaySessionManager.OsuFile.General.Mode == GameMode.Mania && playbackObject.PlaybackEvent is SampleEvent { Layer: SampleLayer.Sampling }) { - _sfxPlaybackService.DispatchPlayback(playbackObject, playbackObject.PlaybackEvent.Volume * 0.866666666f); + _sfxPlaybackService.DispatchPlayback(playbackObject, + playbackObject.PlaybackEvent.Volume * 0.866666666f); continue; } diff --git a/src/Core/KeyAsio.Sync/Runtime/States/ResultsState.cs b/src/Core/KeyAsio.Sync/States/ResultsState.cs similarity index 99% rename from src/Core/KeyAsio.Sync/Runtime/States/ResultsState.cs rename to src/Core/KeyAsio.Sync/States/ResultsState.cs index e091d825..094a241c 100644 --- a/src/Core/KeyAsio.Sync/Runtime/States/ResultsState.cs +++ b/src/Core/KeyAsio.Sync/States/ResultsState.cs @@ -33,4 +33,4 @@ public void OnBeatmapChanged(SyncSessionContext ctx, BeatmapIdentifier beatmap) public void OnModsChanged(SyncSessionContext ctx, Mods oldMods, Mods newMods) { } -} \ No newline at end of file +} diff --git a/src/Core/KeyAsio.Sync/Runtime/SyncContextWrapper.cs b/src/Core/KeyAsio.Sync/SyncContextWrapper.cs similarity index 100% rename from src/Core/KeyAsio.Sync/Runtime/SyncContextWrapper.cs rename to src/Core/KeyAsio.Sync/SyncContextWrapper.cs diff --git a/src/Core/KeyAsio.Sync/Runtime/SyncController.cs b/src/Core/KeyAsio.Sync/SyncController.cs similarity index 99% rename from src/Core/KeyAsio.Sync/Runtime/SyncController.cs rename to src/Core/KeyAsio.Sync/SyncController.cs index 17094177..71edb3f6 100644 --- a/src/Core/KeyAsio.Sync/Runtime/SyncController.cs +++ b/src/Core/KeyAsio.Sync/SyncController.cs @@ -1,13 +1,13 @@ -using KeyAsio.Configuration; using System.Diagnostics; +using KeyAsio.Configuration; using KeyAsio.Core.Audio; using KeyAsio.Core.Memory.Utils; using KeyAsio.Plugins.Contracts; using KeyAsio.Plugins.Contracts.Sync; using KeyAsio.Sync.Abstractions; -using KeyAsio.Sync.Sources; using KeyAsio.Sync.AudioProviders; using KeyAsio.Sync.Services; +using KeyAsio.Sync.Sources; using KeyAsio.Sync.States; using Microsoft.Extensions.Logging; @@ -60,7 +60,8 @@ public SyncController(ILogger playingStateLogger, var catchAudioProvider = new CatchHitsoundSequencer( catchSequencerLogger, appSettings, syncSessionContext, playbackEngine, gameplayAudioService, gameplaySessionManager); - gameplaySessionManager.InitializeProviders(standardAudioProvider, taikoAudioProvider, catchAudioProvider, maniaAudioProvider); + gameplaySessionManager.InitializeProviders(standardAudioProvider, taikoAudioProvider, catchAudioProvider, + maniaAudioProvider); // Initialize realtime state machine with scene mappings _stateMachine = new GameStateMachine(new Dictionary diff --git a/src/Core/KeyAsio.Sync/Runtime/SyncSessionContext.cs b/src/Core/KeyAsio.Sync/SyncSessionContext.cs similarity index 99% rename from src/Core/KeyAsio.Sync/Runtime/SyncSessionContext.cs rename to src/Core/KeyAsio.Sync/SyncSessionContext.cs index 9aa055e7..300fc185 100644 --- a/src/Core/KeyAsio.Sync/Runtime/SyncSessionContext.cs +++ b/src/Core/KeyAsio.Sync/SyncSessionContext.cs @@ -1,6 +1,7 @@ using System.Diagnostics; using System.Runtime.CompilerServices; using System.Text; +using KeyAsio.Common; using KeyAsio.Configuration; using KeyAsio.Configuration.Models; using KeyAsio.Core.OsuAudio.Hitsounds; @@ -8,7 +9,6 @@ using KeyAsio.Plugins.Contracts.Sync; using KeyAsio.Sync.Events; using KeyAsio.Sync.Sources; -using KeyAsio.Common; namespace KeyAsio.Sync; @@ -50,6 +50,7 @@ public SyncSessionContext(AppSettings appSettings) /// 由 PlayTime 的单调性保护逻辑自动维护:当预测时间持续微小倒退时为 true,恢复正常前进时为 false。 /// public bool IsAudioPaused => _isFrozen; + public int ProcessId { get; set; } = -1; public string? Username From 620819669f0583a1a793b4c4dc21a60bc7cf9010 Mon Sep 17 00:00:00 2001 From: Milkitic Date: Mon, 13 Jul 2026 23:29:15 +0800 Subject: [PATCH 11/19] Add pause/resume for loop providers Introduce Pause/Resume on LoopProvider and make LoopProviderManager pause/resume providers instead of removing/adding them so mixer inputs stay alive. LoopSampleProvider gained an IsPaused flag and returns SignalKeepAlive while zeroing output when paused; Reset clears the paused state. Dispose now ensures provider is unpaused. Added LoopProviderTests verifying pause preserves source position and that pausing keeps mixer input alive. --- src/Core/KeyAsio.Core.Audio/LoopProvider.cs | 13 +++- .../KeyAsio.Core.Audio/LoopProviderManager.cs | 11 ++- .../SampleProviders/LoopSampleProvider.cs | 19 ++++- tests/AudioTests/LoopProviderTests.cs | 70 +++++++++++++++++++ 4 files changed, 104 insertions(+), 9 deletions(-) create mode 100644 tests/AudioTests/LoopProviderTests.cs diff --git a/src/Core/KeyAsio.Core.Audio/LoopProvider.cs b/src/Core/KeyAsio.Core.Audio/LoopProvider.cs index 13953a6b..d00828f9 100644 --- a/src/Core/KeyAsio.Core.Audio/LoopProvider.cs +++ b/src/Core/KeyAsio.Core.Audio/LoopProvider.cs @@ -38,6 +38,16 @@ public void SetVolume(float volume) _volumeProvider.Volume = volume; } + public void Pause() + { + _loopWrapper.IsPaused = true; + } + + public void Resume() + { + _loopWrapper.IsPaused = false; + } + public void AddTo(IMixingSampleProvider? mixer) { if (_baseMixer != null) return; @@ -54,6 +64,7 @@ public void RemoveFrom(IMixingSampleProvider? mixer) public void Dispose() { + _loopWrapper.IsPaused = false; _baseMixer = null; } -} \ No newline at end of file +} diff --git a/src/Core/KeyAsio.Core.Audio/LoopProviderManager.cs b/src/Core/KeyAsio.Core.Audio/LoopProviderManager.cs index 7d8d829a..66940c17 100644 --- a/src/Core/KeyAsio.Core.Audio/LoopProviderManager.cs +++ b/src/Core/KeyAsio.Core.Audio/LoopProviderManager.cs @@ -89,12 +89,11 @@ public void RemoveAll(IMixingSampleProvider? mixer) public void PauseAll(IMixingSampleProvider? mixer) { + // Mixer removal owns and recycles the entire provider chain, so pause in place. foreach (var kvp in _dictionary) { - var channel = kvp.Key; var loopProvider = kvp.Value; - - loopProvider.RemoveFrom(mixer); + loopProvider.Pause(); } } @@ -102,10 +101,8 @@ public void RecoverAll(IMixingSampleProvider? mixer) { foreach (var kvp in _dictionary) { - var channel = kvp.Key; var loopProvider = kvp.Value; - - loopProvider.AddTo(mixer); + loopProvider.Resume(); } } @@ -134,4 +131,4 @@ public void Create(int slideChannel, _dictionary.Add(slideChannel, loopProvider); loopProvider.AddTo(mixingSampleProvider); } -} \ No newline at end of file +} diff --git a/src/Core/KeyAsio.Core.Audio/SampleProviders/LoopSampleProvider.cs b/src/Core/KeyAsio.Core.Audio/SampleProviders/LoopSampleProvider.cs index dd8ea4e3..caa3011e 100644 --- a/src/Core/KeyAsio.Core.Audio/SampleProviders/LoopSampleProvider.cs +++ b/src/Core/KeyAsio.Core.Audio/SampleProviders/LoopSampleProvider.cs @@ -5,6 +5,8 @@ namespace KeyAsio.Core.Audio.SampleProviders; public sealed class LoopSampleProvider : IRecyclableProvider, IPoolable { + private volatile bool _isPaused; + public LoopSampleProvider() { } @@ -18,6 +20,12 @@ public LoopSampleProvider(CachedAudioProvider source) public bool EnableLooping { get; set; } = true; + public bool IsPaused + { + get => _isPaused; + set => _isPaused = value; + } + public WaveFormat WaveFormat => Source?.WaveFormat ?? throw new InvalidOperationException("Source not initialized"); public ISampleProvider? ResetAndGetSource() @@ -29,6 +37,14 @@ public LoopSampleProvider(CachedAudioProvider source) public int Read(float[] buffer, int offset, int count) { + if (count == 0) return 0; + + if (IsPaused) + { + Array.Clear(buffer, offset, count); + return QueueMixingSampleProvider.SignalKeepAlive; + } + if (Source == null) { Array.Clear(buffer, offset, count); @@ -75,7 +91,8 @@ public void Reset() { Source = null; EnableLooping = true; + IsPaused = false; } public bool ExcludeFromPool { get; init; } -} \ No newline at end of file +} diff --git a/tests/AudioTests/LoopProviderTests.cs b/tests/AudioTests/LoopProviderTests.cs new file mode 100644 index 00000000..f2664e0c --- /dev/null +++ b/tests/AudioTests/LoopProviderTests.cs @@ -0,0 +1,70 @@ +using KeyAsio.Core.Audio; +using KeyAsio.Core.Audio.Caching; +using KeyAsio.Core.Audio.SampleProviders; +using KeyAsio.Core.Audio.SampleProviders.BalancePans; +using Microsoft.Extensions.Logging.Abstractions; +using NAudio.Wave; + +namespace AudioTests; + +public sealed class LoopProviderTests +{ + [Fact] + public async Task LoopSampleProvider_PauseKeepsSourcePosition() + { + var cachedAudio = await LoadCachedAudioAsync(); + var source = new CachedAudioProvider(cachedAudio); + var loop = new LoopSampleProvider(source); + var buffer = new float[256]; + + Assert.Equal(buffer.Length, loop.Read(buffer, 0, buffer.Length)); + var positionBeforePause = source.PlayTime; + + Array.Fill(buffer, 1f); + loop.IsPaused = true; + + Assert.Equal(QueueMixingSampleProvider.SignalKeepAlive, loop.Read(buffer, 0, buffer.Length)); + Assert.Equal(positionBeforePause, source.PlayTime); + Assert.All(buffer, sample => Assert.Equal(0f, sample)); + + loop.IsPaused = false; + + Assert.Equal(buffer.Length, loop.Read(buffer, 0, buffer.Length)); + Assert.True(source.PlayTime > positionBeforePause); + } + + [Fact] + public async Task LoopProviderManager_PauseAndResumeKeepsMixerInputAlive() + { + var cachedAudio = await LoadCachedAudioAsync(); + var waveFormat = WaveFormat.CreateIeeeFloatWaveFormat( + cachedAudio.WaveFormat.SampleRate, + cachedAudio.WaveFormat.Channels); + var mixer = new QueueMixingSampleProvider(waveFormat); + var manager = new LoopProviderManager(); + var buffer = new float[256]; + + manager.Create(1, cachedAudio, mixer, 1f, 0f, BalanceMode.Off); + Assert.Equal(buffer.Length, mixer.Read(buffer, 0, buffer.Length)); + + manager.PauseAll(mixer); + Array.Fill(buffer, 1f); + + Assert.Equal(buffer.Length, mixer.Read(buffer, 0, buffer.Length)); + Assert.All(buffer, sample => Assert.Equal(0f, sample)); + + manager.RecoverAll(mixer); + Array.Clear(buffer); + + Assert.Equal(buffer.Length, mixer.Read(buffer, 0, buffer.Length)); + Assert.Contains(buffer, sample => sample != 0f); + } + + private static async Task LoadCachedAudioAsync() + { + var audioCacheManager = new AudioCacheManager(NullLogger.Instance); + var filePath = Path.Combine(AppContext.BaseDirectory, "files", "normal-hitnormal.wav"); + var result = await audioCacheManager.GetOrCreateOrEmptyFromFileAsync(filePath, new WaveFormat(48000, 2)); + return Assert.IsType(result.CachedAudio); + } +} From 13c096fb832e9629979b6d1fb36fbfca2d471e4f Mon Sep 17 00:00:00 2001 From: Milkitic Date: Tue, 14 Jul 2026 13:46:02 +0800 Subject: [PATCH 12/19] =?UTF-8?q?fix:=20=E4=BF=AE=E6=AD=A3=E6=8C=81?= =?UTF-8?q?=E4=B9=85=E5=8C=96=20ClientType=20=E8=BF=87=E6=97=B6=E9=97=AE?= =?UTF-8?q?=E9=A2=98=E5=B9=B6=E6=9B=B4=E6=96=B0=E5=AD=90=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 由于之前的 lazer 会话可能留下过时的 ClientType,现在在 SkinManager 初始化时使用 _syncSessionContext.ClientType 进行校正,并确保 Lazer 皮肤加载使用正确的来源。同时更新 osu.Game.Rulesets.KeyAsio 子模块到最新版本。 --- dependencies/osu.Game.Rulesets.KeyAsio | 2 +- .../KeyAsio.Application/Services/SkinManager.cs | 15 ++++++++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/dependencies/osu.Game.Rulesets.KeyAsio b/dependencies/osu.Game.Rulesets.KeyAsio index 88af8661..1f7be98e 160000 --- a/dependencies/osu.Game.Rulesets.KeyAsio +++ b/dependencies/osu.Game.Rulesets.KeyAsio @@ -1 +1 @@ -Subproject commit 88af866100dd05b2fa24a80175287cdcd20b65ad +Subproject commit 1f7be98e1f9308e17c65464783136130e52aabce diff --git a/src/Common/KeyAsio.Application/Services/SkinManager.cs b/src/Common/KeyAsio.Application/Services/SkinManager.cs index 9810bbff..df128404 100644 --- a/src/Common/KeyAsio.Application/Services/SkinManager.cs +++ b/src/Common/KeyAsio.Application/Services/SkinManager.cs @@ -169,6 +169,19 @@ public void Start() CheckOsuRegistry(); } + // Correct a stale persisted ClientType from a previous lazer session. + // The coordinator starts fresh with Stable as the default active source, + // so _syncSessionContext.ClientType reflects the actual current state. + var liveClientType = _syncSessionContext.ClientType; + if (_appSettings.Paths.ClientType != liveClientType) + { + _logger.LogInformation( + "Correcting stale persisted ClientType {Old} -> {New}", + _appSettings.Paths.ClientType, liveClientType); + _appSettings.Paths.ClientType = liveClientType; + } + _lastKnownClientType = liveClientType; + _ = RefreshSkinsAsync(); StartProcessListener(); @@ -419,7 +432,7 @@ private async Task LoadSkinsInternal(CancellationToken token) return; } - if (_appSettings.Paths.ClientType == GameClientType.Lazer) + if (_syncSessionContext.ClientType == GameClientType.Lazer) { await LoadLazerSkinsAsync(token); return; From def81d9aea63aacdf59969eec69562340755077b Mon Sep 17 00:00:00 2001 From: Milkitic Date: Tue, 14 Jul 2026 15:55:52 +0800 Subject: [PATCH 13/19] =?UTF-8?q?fix:=20=E8=B0=83=E6=95=B4BalanceFactor?= =?UTF-8?q?=E9=BB=98=E8=AE=A4=E5=80=BC=E4=B8=BA0.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将BalanceFactor的默认值从0.3333333调整为0.2,以提供更合适的默认平衡效果,避免初始设置下平衡过于偏向一侧。 --- src/Common/KeyAsio.Configuration/AppSettings.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Common/KeyAsio.Configuration/AppSettings.cs b/src/Common/KeyAsio.Configuration/AppSettings.cs index 97939363..c9076df4 100644 --- a/src/Common/KeyAsio.Configuration/AppSettings.cs +++ b/src/Common/KeyAsio.Configuration/AppSettings.cs @@ -162,7 +162,7 @@ public partial class AppSettingsSyncPlayback : INotifyPropertyChanged public BalanceMode BalanceMode { get; set; } = BalanceMode.MidSide; [Description("Balance factor.")] - public float BalanceFactor { get; set; } = 0.3333333f; + public float BalanceFactor { get; set; } = 0.2f; } public partial class AppSettingsSyncFilters : INotifyPropertyChanged From 815e4155d63ad9d6177ba8ac0d9d6ec8b534c25d Mon Sep 17 00:00:00 2001 From: Milkitic Date: Tue, 14 Jul 2026 16:03:30 +0800 Subject: [PATCH 14/19] =?UTF-8?q?fix:=20=E4=BF=AE=E6=AD=A3ProfessionalBala?= =?UTF-8?q?nceProvider=E7=9A=84=E5=B9=B3=E8=A1=A1=E7=AE=97=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原来的实现使用了等功率平衡(sin/cos),这适用于声像定位但不适用于立体声平衡控制。新的实现使用等幅度平衡,在中间位置左右声道均为0dB,调整平衡时仅衰减对侧声道,符合专业音频的平衡行为。 --- .../ProfessionalBalanceProvider.cs | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/src/Core/KeyAsio.Core.Audio/SampleProviders/BalancePans/ProfessionalBalanceProvider.cs b/src/Core/KeyAsio.Core.Audio/SampleProviders/BalancePans/ProfessionalBalanceProvider.cs index bafb4aa1..7a5e0127 100644 --- a/src/Core/KeyAsio.Core.Audio/SampleProviders/BalancePans/ProfessionalBalanceProvider.cs +++ b/src/Core/KeyAsio.Core.Audio/SampleProviders/BalancePans/ProfessionalBalanceProvider.cs @@ -213,10 +213,22 @@ private void UpdateGains() [MethodImpl(MethodImplOptions.AggressiveInlining)] private void UpdateSimpleFadeGains() { - float pan = (_balanceValue + 1f) * 0.5f; - float angle = pan * MathF.PI * 0.5f; - _leftDirectGain = MathF.Cos(angle); - _rightDirectGain = MathF.Sin(angle); + if (_balanceValue < 0) + { + _leftDirectGain = 1.0f; + _rightDirectGain = MathF.Cos(-_balanceValue * MathF.PI * 0.5f); + } + else if (_balanceValue > 0) + { + _leftDirectGain = MathF.Cos(_balanceValue * MathF.PI * 0.5f); + _rightDirectGain = 1.0f; + } + else + { + _leftDirectGain = 1.0f; + _rightDirectGain = 1.0f; + } + _leftCrossGain = 0f; _rightCrossGain = 0f; } From 6fb2c9b75810faed3fcfef360980a42ff6df8fbd Mon Sep 17 00:00:00 2001 From: Milkitic Date: Tue, 14 Jul 2026 16:44:57 +0800 Subject: [PATCH 15/19] =?UTF-8?q?feat:=20=E5=B0=86=20Mid-Side=20=E5=B9=B3?= =?UTF-8?q?=E8=A1=A1=E6=A8=A1=E5=BC=8F=E9=87=8D=E5=91=BD=E5=90=8D=E4=B8=BA?= =?UTF-8?q?=20ProMix=20Focus?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 `BalanceMode.MidSide` 重命名为 `BalanceMode.ProMixFocus`,更新所有引用、本地化字符串和序列化迁移逻辑。新增 YAML 配置向后兼容处理,自动将旧配置中的 `MidSide` 迁移为 `ProMixFocus`。更新平衡模式描述以反映新的品牌声场聚焦算法。 --- src/Apps/KeyAsio/Lang/SR.Designer.cs | 16 +++++------ src/Apps/KeyAsio/Lang/SR.en.resx | 10 +++---- src/Apps/KeyAsio/Lang/SR.ja.resx | 7 ++--- src/Apps/KeyAsio/Lang/SR.resx | 8 +++--- src/Apps/KeyAsio/Lang/SR.zh-hans.resx | 9 +++--- src/Apps/KeyAsio/Services/PresetManager.cs | 6 ++-- .../KeyAsio.Configuration/AppSettings.cs | 2 +- .../MyYamlConfigurationConverter.cs | 10 ++++++- .../EnhancedMixingSampleProviderExtension.cs | 6 ++-- .../BalancePans/BalanceMode.cs | 12 ++++---- .../ProfessionalBalanceProvider.cs | 28 +++++++++---------- 11 files changed, 60 insertions(+), 54 deletions(-) diff --git a/src/Apps/KeyAsio/Lang/SR.Designer.cs b/src/Apps/KeyAsio/Lang/SR.Designer.cs index 3cd98418..a46edb96 100644 --- a/src/Apps/KeyAsio/Lang/SR.Designer.cs +++ b/src/Apps/KeyAsio/Lang/SR.Designer.cs @@ -1,4 +1,4 @@ -//------------------------------------------------------------------------------ +//------------------------------------------------------------------------------ // // 此代码由工具生成。 // 运行时版本:4.0.30319.42000 @@ -502,20 +502,20 @@ internal static string BalanceMode_CrossMix { } /// - /// 查找类似 Mid-Side (Professional) 的本地化字符串。 + /// 查找类似 Off 的本地化字符串。 /// - internal static string BalanceMode_MidSide { + internal static string BalanceMode_Off { get { - return ResourceManager.GetString("BalanceMode_MidSide", resourceCulture); + return ResourceManager.GetString("BalanceMode_Off", resourceCulture); } } /// - /// 查找类似 Off 的本地化字符串。 + /// 查找类似 ProMix Focus 的本地化字符串。 /// - internal static string BalanceMode_Off { + internal static string BalanceMode_ProMixFocus { get { - return ResourceManager.GetString("BalanceMode_Off", resourceCulture); + return ResourceManager.GetString("BalanceMode_ProMixFocus", resourceCulture); } } @@ -1340,7 +1340,7 @@ internal static string Sync_BalanceMode { } /// - /// 查找类似 Controls stereo balance processing. 'ConstantPower' is standard; 'CrossMix' preserves ambience. Note: 'Mid-Side' mode increases CPU usage. 的本地化字符串。 + /// 查找类似 Controls stereo balance processing. ProMix Focus preserves the center while smoothly focusing the sound field toward the target side. 的本地化字符串。 /// internal static string Sync_BalanceModeDescription { get { diff --git a/src/Apps/KeyAsio/Lang/SR.en.resx b/src/Apps/KeyAsio/Lang/SR.en.resx index eaf644e5..eb298fd8 100644 --- a/src/Apps/KeyAsio/Lang/SR.en.resx +++ b/src/Apps/KeyAsio/Lang/SR.en.resx @@ -1,4 +1,4 @@ - +