diff --git a/.github/workflows/build_and_draft.yml b/.github/workflows/build_and_draft.yml index e9c92924..89efeebe 100644 --- a/.github/workflows/build_and_draft.yml +++ b/.github/workflows/build_and_draft.yml @@ -14,7 +14,7 @@ jobs: - name: Setup Dotnet uses: actions/setup-dotnet@v5 with: - dotnet-version: ${{ startsWith(matrix.os, 'macos') && '9.0.312' || '9.0.x' }} + dotnet-version: ${{ startsWith(matrix.os, 'macos') && '10.0.x' || '10.0.x' }} - name: Restore Core Tests run: dotnet restore ./VoiceCraft.Core.Tests/VoiceCraft.Core.Tests.csproj @@ -38,7 +38,7 @@ jobs: runs-on: windows-latest strategy: matrix: - arch: [ x64, x86, arm64 ] + arch: [x64, x86, arm64] fail-fast: false defaults: run: @@ -48,8 +48,8 @@ jobs: - name: Setup Dotnet uses: actions/setup-dotnet@v5 with: - dotnet-version: "9.0.x" - + dotnet-version: "10.0.x" + - name: Install WorkLoads run: dotnet workload restore @@ -60,20 +60,20 @@ jobs: shell: pwsh run: | $uri = "https://aka.ms/vc14/vc_redist.${{ matrix.arch }}.exe" - $outFile = "./bin/Release/net9.0-windows/win-${{ matrix.arch }}/publish/vc_redist.${{ matrix.arch }}.exe" + $outFile = "./bin/Release/net10.0-windows/win-${{ matrix.arch }}/publish/vc_redist.${{ matrix.arch }}.exe" Invoke-WebRequest -Uri $uri -OutFile $outFile - name: Upload Artifact uses: actions/upload-artifact@v6 with: name: VoiceCraft.Client.Windows.${{ matrix.arch }} - path: ./VoiceCraft.Client/VoiceCraft.Client.Windows/bin/Release/net9.0-windows/win-${{ matrix.arch }}/publish/ - + path: ./VoiceCraft.Client/VoiceCraft.Client.Windows/bin/Release/net10.0-windows/win-${{ matrix.arch }}/publish/ + VoiceCraft-Client-Linux: runs-on: ubuntu-latest strategy: matrix: - arch: [ x64, arm, arm64 ] + arch: [x64, arm, arm64] fail-fast: false defaults: run: @@ -83,7 +83,7 @@ jobs: - name: Setup Dotnet uses: actions/setup-dotnet@v5 with: - dotnet-version: "9.0.x" + dotnet-version: "10.0.x" - name: Install WorkLoads run: dotnet workload restore @@ -95,13 +95,13 @@ jobs: uses: actions/upload-artifact@v6 with: name: VoiceCraft.Client.Linux.${{ matrix.arch }} - path: ./VoiceCraft.Client/VoiceCraft.Client.Linux/bin/Release/net9.0/linux-${{ matrix.arch }}/publish/ - + path: ./VoiceCraft.Client/VoiceCraft.Client.Linux/bin/Release/net10.0/linux-${{ matrix.arch }}/publish/ + VoiceCraft-Client-Android: runs-on: ubuntu-latest strategy: matrix: - arch: [ arm, arm64 ] + arch: [arm64] fail-fast: false defaults: run: @@ -111,11 +111,11 @@ jobs: - name: Setup Dotnet uses: actions/setup-dotnet@v5 with: - dotnet-version: "9.0.x" + dotnet-version: "10.0.x" - name: Install WorkLoads run: dotnet workload restore - + - name: Setup Keystore File run: echo -n "${{ secrets.ANDROID_KEYSTORE }}" | base64 --decode >> android.keystore @@ -126,7 +126,7 @@ jobs: uses: actions/upload-artifact@v6 with: name: VoiceCraft.Client.Android.${{ matrix.arch }} - path: ./VoiceCraft.Client/VoiceCraft.Client.Android/bin/Release/net9.0-android/android-${{ matrix.arch }}/publish/com.AvionBlock.VoiceCraft.Client-Signed.apk + path: ./VoiceCraft.Client/VoiceCraft.Client.Android/bin/Release/net10.0-android/android-${{ matrix.arch }}/publish/*.apk VoiceCraft-Client-iOS: runs-on: macos-26 @@ -138,7 +138,7 @@ jobs: - name: Setup Dotnet uses: actions/setup-dotnet@v5 with: - dotnet-version: "9.0.312" + dotnet-version: "10.0.x" - name: Install WorkLoads run: dotnet workload restore @@ -150,13 +150,13 @@ jobs: uses: actions/upload-artifact@v6 with: name: VoiceCraft.Client.iOS.arm64 - path: ./VoiceCraft.Client/VoiceCraft.Client.iOS/bin/Release/net9.0-ios/ios-arm64/publish/*.ipa - + path: ./VoiceCraft.Client/VoiceCraft.Client.iOS/bin/Release/net10.0-ios/ios-arm64/publish/*.ipa + VoiceCraft-Client-MacOS: runs-on: macos-26 strategy: matrix: - arch: [ x64, arm64 ] + arch: [x64, arm64] fail-fast: false defaults: run: @@ -166,20 +166,20 @@ jobs: - name: Setup Dotnet uses: actions/setup-dotnet@v5 with: - dotnet-version: "9.0.312" + dotnet-version: "10.0.x" - name: Install WorkLoads run: dotnet workload restore - name: Restore ${{ matrix.arch }} Workloads run: dotnet restore -r osx-${{ matrix.arch }} - + - name: Publish Build run: dotnet publish -c Release -r osx-${{ matrix.arch }} --no-restore -p:ValidateXcodeVersion=false - + - name: Build VoiceCraft.Client.MacOS.${{ matrix.arch }}.dmg run: | - cd ./bin/Release/net9.0-macos/osx-${{ matrix.arch }} + cd ./bin/Release/net10.0-macos/osx-${{ matrix.arch }} APP_BUNDLE=$(find . -maxdepth 1 -name "*.app" -print -quit) if [ -z "$APP_BUNDLE" ]; then echo "No .app bundle found" @@ -198,27 +198,27 @@ jobs: - name: Normalize VoiceCraft.Client.MacOS.${{ matrix.arch }}.pkg name run: | - cd ./bin/Release/net9.0-macos/osx-${{ matrix.arch }} + cd ./bin/Release/net10.0-macos/osx-${{ matrix.arch }} PKG_PATH=$(ls ./publish/*.pkg | head -n 1) cp "$PKG_PATH" "VoiceCraft.Client.MacOS.${{ matrix.arch }}.pkg" - + - name: Upload Artifact uses: actions/upload-artifact@v6 with: name: VoiceCraft.Client.MacOS.${{ matrix.arch }}.dmg - path: ./VoiceCraft.Client/VoiceCraft.Client.MacOS/bin/Release/net9.0-macos/osx-${{ matrix.arch }}/VoiceCraft.Client.MacOS.${{ matrix.arch }}.dmg + path: ./VoiceCraft.Client/VoiceCraft.Client.MacOS/bin/Release/net10.0-macos/osx-${{ matrix.arch }}/VoiceCraft.Client.MacOS.${{ matrix.arch }}.dmg - name: Upload Artifact uses: actions/upload-artifact@v6 with: name: VoiceCraft.Client.MacOS.${{ matrix.arch }}.pkg - path: ./VoiceCraft.Client/VoiceCraft.Client.MacOS/bin/Release/net9.0-macos/osx-${{ matrix.arch }}/VoiceCraft.Client.MacOS.${{ matrix.arch }}.pkg + path: ./VoiceCraft.Client/VoiceCraft.Client.MacOS/bin/Release/net10.0-macos/osx-${{ matrix.arch }}/VoiceCraft.Client.MacOS.${{ matrix.arch }}.pkg VoiceCraft-Server-Windows: runs-on: ubuntu-latest strategy: matrix: - arch: [ x64, x86, arm64 ] + arch: [x64, x86, arm64] fail-fast: false defaults: run: @@ -228,7 +228,7 @@ jobs: - name: Setup Dotnet uses: actions/setup-dotnet@v5 with: - dotnet-version: "9.0.x" + dotnet-version: "10.0.x" - name: Install WorkLoads run: dotnet workload restore @@ -240,13 +240,13 @@ jobs: uses: actions/upload-artifact@v6 with: name: VoiceCraft.Server.Windows.${{ matrix.arch }} - path: ./VoiceCraft.Server/bin/Release/net9.0/win-${{ matrix.arch }}/publish - + path: ./VoiceCraft.Server/bin/Release/net10.0/win-${{ matrix.arch }}/publish + VoiceCraft-Server-Linux: runs-on: ubuntu-latest strategy: matrix: - arch: [ x64, arm, arm64 ] + arch: [x64, arm, arm64] fail-fast: false defaults: run: @@ -256,7 +256,7 @@ jobs: - name: Setup Dotnet uses: actions/setup-dotnet@v5 with: - dotnet-version: "9.0.x" + dotnet-version: "10.0.x" - name: Install WorkLoads run: dotnet workload restore @@ -268,27 +268,30 @@ jobs: uses: actions/upload-artifact@v6 with: name: VoiceCraft.Server.Linux.${{ matrix.arch }} - path: ./VoiceCraft.Server/bin/Release/net9.0/linux-${{ matrix.arch }}/publish - + path: ./VoiceCraft.Server/bin/Release/net10.0/linux-${{ matrix.arch }}/publish + Publish-Draft: runs-on: ubuntu-latest - needs: ["VoiceCraft-Client-Windows", - "VoiceCraft-Tests", - "VoiceCraft-Client-Linux", - "VoiceCraft-Client-Android", - "VoiceCraft-Client-iOS", - "VoiceCraft-Client-MacOS", - "VoiceCraft-Server-Windows", - "VoiceCraft-Server-Linux"] + needs: + [ + "VoiceCraft-Client-Windows", + "VoiceCraft-Tests", + "VoiceCraft-Client-Linux", + "VoiceCraft-Client-Android", + "VoiceCraft-Client-iOS", + "VoiceCraft-Client-MacOS", + "VoiceCraft-Server-Windows", + "VoiceCraft-Server-Linux", + ] steps: - uses: actions/checkout@v5 - + - name: Download Artifacts uses: actions/download-artifact@v8 with: skip-decompress: true path: ./artifacts - + - name: Create Draft Release uses: softprops/action-gh-release@v2 with: diff --git a/.github/workflows/build_test.yml b/.github/workflows/build_test.yml index b94c0574..da854215 100644 --- a/.github/workflows/build_test.yml +++ b/.github/workflows/build_test.yml @@ -16,7 +16,7 @@ jobs: - name: Setup Dotnet uses: actions/setup-dotnet@v5 with: - dotnet-version: ${{ startsWith(matrix.os, 'macos') && '9.0.312' || '9.0.x' }} + dotnet-version: ${{ startsWith(matrix.os, 'macos') && '10.0.x' || '10.0.x' }} - name: Restore Core Tests run: dotnet restore ./VoiceCraft.Core.Tests/VoiceCraft.Core.Tests.csproj @@ -40,7 +40,7 @@ jobs: runs-on: windows-latest strategy: matrix: - arch: [ x64, x86, arm64 ] + arch: [x64, x86, arm64] fail-fast: false defaults: run: @@ -50,7 +50,7 @@ jobs: - name: Setup Dotnet uses: actions/setup-dotnet@v5 with: - dotnet-version: "9.0.x" + dotnet-version: "10.0.x" - name: Install WorkLoads run: dotnet workload restore @@ -62,20 +62,20 @@ jobs: shell: pwsh run: | $uri = "https://aka.ms/vc14/vc_redist.${{ matrix.arch }}.exe" - $outFile = "./bin/Release/net9.0-windows/win-${{ matrix.arch }}/publish/vc_redist.${{ matrix.arch }}.exe" + $outFile = "./bin/Release/net10.0-windows/win-${{ matrix.arch }}/publish/vc_redist.${{ matrix.arch }}.exe" Invoke-WebRequest -Uri $uri -OutFile $outFile - name: Upload Artifact uses: actions/upload-artifact@v6 with: name: VoiceCraft.Client.Windows.${{ matrix.arch }} - path: ./VoiceCraft.Client/VoiceCraft.Client.Windows/bin/Release/net9.0-windows/win-${{ matrix.arch }}/publish/ - + path: ./VoiceCraft.Client/VoiceCraft.Client.Windows/bin/Release/net10.0-windows/win-${{ matrix.arch }}/publish/ + VoiceCraft-Client-Linux: runs-on: ubuntu-latest strategy: matrix: - arch: [ x64, arm, arm64 ] + arch: [x64, arm, arm64] fail-fast: false defaults: run: @@ -85,7 +85,7 @@ jobs: - name: Setup Dotnet uses: actions/setup-dotnet@v5 with: - dotnet-version: "9.0.x" + dotnet-version: "10.0.x" - name: Install WorkLoads run: dotnet workload restore @@ -97,13 +97,13 @@ jobs: uses: actions/upload-artifact@v6 with: name: VoiceCraft.Client.Linux.${{ matrix.arch }} - path: ./VoiceCraft.Client/VoiceCraft.Client.Linux/bin/Release/net9.0/linux-${{ matrix.arch }}/publish/ - + path: ./VoiceCraft.Client/VoiceCraft.Client.Linux/bin/Release/net10.0/linux-${{ matrix.arch }}/publish/ + VoiceCraft-Client-Android: runs-on: ubuntu-latest strategy: matrix: - arch: [arm, arm64] + arch: [arm64] fail-fast: false defaults: run: @@ -118,7 +118,7 @@ jobs: - name: Setup Dotnet uses: actions/setup-dotnet@v5 with: - dotnet-version: "9.0.x" + dotnet-version: "10.0.x" - name: Install WorkLoads run: dotnet workload restore @@ -130,7 +130,8 @@ jobs: - name: Publish Build (Signed) if: ${{ github.event_name != 'pull_request' && env.ANDROID_KEYSTORE != '' }} run: > - dotnet publish -c Release -r android-${{ matrix.arch }} + dotnet publish -c Release + -r android-${{ matrix.arch }} -p:AndroidKeyStore=true -p:AndroidSigningKeyStore=./android.keystore -p:AndroidSigningKeyAlias=$ALIAS @@ -145,7 +146,7 @@ jobs: uses: actions/upload-artifact@v6 with: name: VoiceCraft.Client.Android.${{ matrix.arch }} - path: ./VoiceCraft.Client/VoiceCraft.Client.Android/bin/Release/net9.0-android/android-${{ matrix.arch }}/publish/*.apk + path: ./VoiceCraft.Client/VoiceCraft.Client.Android/bin/Release/net10.0-android/android-${{ matrix.arch }}/publish/*.apk VoiceCraft-Client-iOS: runs-on: macos-26 @@ -157,7 +158,7 @@ jobs: - name: Setup Dotnet uses: actions/setup-dotnet@v5 with: - dotnet-version: "9.0.312" + dotnet-version: "10.0.x" - name: Install WorkLoads run: dotnet workload restore @@ -169,13 +170,13 @@ jobs: uses: actions/upload-artifact@v6 with: name: VoiceCraft.Client.iOS.arm64 - path: ./VoiceCraft.Client/VoiceCraft.Client.iOS/bin/Release/net9.0-ios/ios-arm64/publish/*.ipa - + path: ./VoiceCraft.Client/VoiceCraft.Client.iOS/bin/Release/net10.0-ios/ios-arm64/publish/*.ipa + VoiceCraft-Client-MacOS: runs-on: macos-26 strategy: matrix: - arch: [ x64, arm64 ] + arch: [x64, arm64] fail-fast: false defaults: run: @@ -185,7 +186,7 @@ jobs: - name: Setup Dotnet uses: actions/setup-dotnet@v5 with: - dotnet-version: "9.0.312" + dotnet-version: "10.0.x" - name: Install WorkLoads run: dotnet workload restore @@ -198,7 +199,7 @@ jobs: - name: Build VoiceCraft.Client.MacOS.${{ matrix.arch }}.dmg run: | - cd ./bin/Release/net9.0-macos/osx-${{ matrix.arch }} + cd ./bin/Release/net10.0-macos/osx-${{ matrix.arch }} APP_BUNDLE=$(find . -maxdepth 1 -name "*.app" -print -quit) if [ -z "$APP_BUNDLE" ]; then echo "No .app bundle found" @@ -217,7 +218,7 @@ jobs: - name: Normalize VoiceCraft.Client.MacOS.${{ matrix.arch }}.pkg name run: | - cd ./bin/Release/net9.0-macos/osx-${{ matrix.arch }} + cd ./bin/Release/net10.0-macos/osx-${{ matrix.arch }} PKG_PATH=$(ls ./publish/*.pkg | head -n 1) cp "$PKG_PATH" "VoiceCraft.Client.MacOS.${{ matrix.arch }}.pkg" @@ -225,19 +226,19 @@ jobs: uses: actions/upload-artifact@v6 with: name: VoiceCraft.Client.MacOS.${{ matrix.arch }}.dmg - path: ./VoiceCraft.Client/VoiceCraft.Client.MacOS/bin/Release/net9.0-macos/osx-${{ matrix.arch }}/VoiceCraft.Client.MacOS.${{ matrix.arch }}.dmg + path: ./VoiceCraft.Client/VoiceCraft.Client.MacOS/bin/Release/net10.0-macos/osx-${{ matrix.arch }}/VoiceCraft.Client.MacOS.${{ matrix.arch }}.dmg - name: Upload Artifact uses: actions/upload-artifact@v6 with: name: VoiceCraft.Client.MacOS.${{ matrix.arch }}.pkg - path: ./VoiceCraft.Client/VoiceCraft.Client.MacOS/bin/Release/net9.0-macos/osx-${{ matrix.arch }}/VoiceCraft.Client.MacOS.${{ matrix.arch }}.pkg + path: ./VoiceCraft.Client/VoiceCraft.Client.MacOS/bin/Release/net10.0-macos/osx-${{ matrix.arch }}/VoiceCraft.Client.MacOS.${{ matrix.arch }}.pkg VoiceCraft-Server-Windows: runs-on: ubuntu-latest strategy: matrix: - arch: [ x64, x86, arm64 ] + arch: [x64, x86, arm64] fail-fast: false defaults: run: @@ -247,7 +248,7 @@ jobs: - name: Setup Dotnet uses: actions/setup-dotnet@v5 with: - dotnet-version: "9.0.x" + dotnet-version: "10.0.x" - name: Install WorkLoads run: dotnet workload restore @@ -259,13 +260,13 @@ jobs: uses: actions/upload-artifact@v6 with: name: VoiceCraft.Server.Windows.${{ matrix.arch }} - path: ./VoiceCraft.Server/bin/Release/net9.0/win-${{ matrix.arch }}/publish - + path: ./VoiceCraft.Server/bin/Release/net10.0/win-${{ matrix.arch }}/publish + VoiceCraft-Server-Linux: runs-on: ubuntu-latest strategy: matrix: - arch: [ x64, arm, arm64 ] + arch: [x64, arm, arm64] fail-fast: false defaults: run: @@ -275,7 +276,7 @@ jobs: - name: Setup Dotnet uses: actions/setup-dotnet@v5 with: - dotnet-version: "9.0.x" + dotnet-version: "10.0.x" - name: Install WorkLoads run: dotnet workload restore @@ -287,4 +288,4 @@ jobs: uses: actions/upload-artifact@v6 with: name: VoiceCraft.Server.Linux.${{ matrix.arch }} - path: ./VoiceCraft.Server/bin/Release/net9.0/linux-${{ matrix.arch }}/publish + path: ./VoiceCraft.Server/bin/Release/net10.0/linux-${{ matrix.arch }}/publish diff --git a/.gitignore b/.gitignore index 86bb20a0..0549b186 100644 --- a/.gitignore +++ b/.gitignore @@ -819,3 +819,5 @@ $RECYCLE.BIN/ !.vscode/tasks.json !.vscode/launch.json !.vscode/extensions.json +.temp/ +config/CrashLogs.json diff --git a/Directory.Build.props b/Directory.Build.props index 34a40042..f75158c6 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 1.5.1 + 1.6.0 15 diff --git a/Directory.Packages.props b/Directory.Packages.props index 85cfb02e..1e129f6b 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -2,7 +2,7 @@ true - 11.3.14 + 12.0.1 @@ -10,31 +10,29 @@ - - all - none - + + - + - + - + - + - - + + - \ No newline at end of file + diff --git a/NuGet.config b/NuGet.config new file mode 100644 index 00000000..765346e5 --- /dev/null +++ b/NuGet.config @@ -0,0 +1,7 @@ + + + + + + + diff --git a/VoiceCraft.Client.Tests/Audio/CombinedAudioPreprocessorTests.cs b/VoiceCraft.Client.Tests/Audio/CombinedAudioPreprocessorTests.cs index 5d3f1c90..b11b0ee7 100644 --- a/VoiceCraft.Client.Tests/Audio/CombinedAudioPreprocessorTests.cs +++ b/VoiceCraft.Client.Tests/Audio/CombinedAudioPreprocessorTests.cs @@ -46,17 +46,16 @@ public void Dispose_DisposesInstantiatedProcessorsAndBlocksFurtherUse() { FakeAudioPreprocessor? gain = null; FakeAudioPreprocessor? denoiser = null; - using var preprocessor = new CombinedAudioPreprocessor( + var preprocessor = new CombinedAudioPreprocessor( new RegisteredAudioPreprocessor(Guid.NewGuid(), "gain", () => gain = new FakeAudioPreprocessor(), true, true, true), new RegisteredAudioPreprocessor(Guid.NewGuid(), "denoiser", () => denoiser = new FakeAudioPreprocessor(), true, true, true), null); - + preprocessor.Dispose(); - Assert.NotNull(gain); Assert.NotNull(denoiser); - Assert.Equal(1, gain!.DisposeCalls); - Assert.Equal(1, denoiser!.DisposeCalls); + Assert.Equal(1, gain.DisposeCalls); + Assert.Equal(1, denoiser.DisposeCalls); Assert.Throws(() => preprocessor.Process([0])); } diff --git a/VoiceCraft.Client.Tests/Services/LogServiceTests.cs b/VoiceCraft.Client.Tests/Services/LogServiceTests.cs new file mode 100644 index 00000000..ee3a35dc --- /dev/null +++ b/VoiceCraft.Client.Tests/Services/LogServiceTests.cs @@ -0,0 +1,17 @@ +using VoiceCraft.Client.Services; + +namespace VoiceCraft.Client.Tests.Services; + +public class LogServiceTests +{ + [Fact] + public void Log_TrimsExceptionLogsToLimit() + { + LogService.ClearExceptionLogs(); + + for (var i = 0; i < 55; i++) + LogService.Log(new InvalidOperationException($"test-{i}")); + + Assert.InRange(LogService.ExceptionLogs.Count(), 1, 50); + } +} diff --git a/VoiceCraft.Client.Tests/Services/SettingsServiceTests.cs b/VoiceCraft.Client.Tests/Services/SettingsServiceTests.cs index 9e778514..a0d22c9a 100644 --- a/VoiceCraft.Client.Tests/Services/SettingsServiceTests.cs +++ b/VoiceCraft.Client.Tests/Services/SettingsServiceTests.cs @@ -53,11 +53,27 @@ public async Task SaveImmediate_WritesSerializedSettings() var saved = JsonSerializer.Deserialize(storage.StoredBytes, SettingsStructureGenerationContext.Default.SettingsStructure); Assert.NotNull(saved); - Assert.Equal("Mic-2", saved!.InputSettings.InputDevice); + Assert.Equal("Mic-2", saved.InputSettings.InputDevice); Assert.Equal("Speaker-2", saved.OutputSettings.OutputDevice); Assert.Equal(Constants.LightThemeGuid, saved.ThemeSettings.SelectedTheme); } + [Fact] + public void Constructor_WhenPersistedSettingsAreInvalid_FallsBackToDefaults() + { + InitializeLocalizer(); + var storage = new FakeStorageService + { + ExistsResult = true, + StoredBytes = Encoding.UTF8.GetBytes("{") + }; + + var service = new SettingsService(storage); + + Assert.Equal("Default", service.InputSettings.InputDevice); + Assert.NotEqual(Guid.Empty, service.UserGuid); + } + private static void InitializeLocalizer() { Localizer.BaseLocalizer = new EmbeddedJsonLocalizer("VoiceCraft.Client.Locales"); diff --git a/VoiceCraft.Client.Tests/VoiceCraft.Client.Tests.csproj b/VoiceCraft.Client.Tests/VoiceCraft.Client.Tests.csproj index d52f31f6..eea21d1e 100644 --- a/VoiceCraft.Client.Tests/VoiceCraft.Client.Tests.csproj +++ b/VoiceCraft.Client.Tests/VoiceCraft.Client.Tests.csproj @@ -1,7 +1,7 @@ - net9.0 + net10.0 enable enable false diff --git a/VoiceCraft.Client/VoiceCraft.Client.Android/AndroidApp.cs b/VoiceCraft.Client/VoiceCraft.Client.Android/AndroidApp.cs new file mode 100644 index 00000000..eb281696 --- /dev/null +++ b/VoiceCraft.Client/VoiceCraft.Client.Android/AndroidApp.cs @@ -0,0 +1,101 @@ +using System; +using Android.Media; +using Android.Runtime; +using Avalonia; +using Avalonia.Android; +using Microsoft.Extensions.DependencyInjection; +using SoundFlow.Abstracts; +using VoiceCraft.Client.Android.Audio; +using VoiceCraft.Client.Services; +using VoiceCraft.Core; +using VoiceCraft.Core.Interfaces; +using VoiceCraft.Network.Clients; +using Debug = System.Diagnostics.Debug; +using Exception = System.Exception; + +namespace VoiceCraft.Client.Android; + +[global::Android.App.Application] +public class AndroidApp : AvaloniaAndroidApplication +{ + protected AndroidApp(nint javaReference, JniHandleOwnership transfer) : base(javaReference, transfer) + { + + } + + public override void OnCreate() + { + BootstrapServices(); + base.OnCreate(); + } + + protected override void Dispose(bool disposing) + { + try + { + if (App.ServiceProvider == null) return; + var serviceProvider = App.ServiceProvider; + serviceProvider.Dispose(); + } + finally + { + base.Dispose(disposing); + } + } + + protected override AppBuilder CustomizeAppBuilder(AppBuilder builder) + { + return base.CustomizeAppBuilder(builder) + .WithInterFont(); + } + + private static void BootstrapServices() + { + var nativeStorage = new NativeStorageService(); + LogService.NativeStorageService = nativeStorage; + LogService.Load(); + AppDomain.CurrentDomain.UnhandledException += CurrentDomainOnUnhandledException; + + var audioManager = (AudioManager?)Context.GetSystemService(AudioService); + if (audioManager == null) + throw new Exception($"Could not find {AudioService}. Cannot initialize audio service."); + + App.ServiceCollection.AddSingleton(_ => + new AndroidMiniAudioEngine(audioManager)); + App.ServiceCollection.AddSingleton(nativeStorage); + App.ServiceCollection.AddSingleton(); + App.ServiceCollection.AddSingleton(x => + new NativeBackgroundService(x.GetRequiredService(), x.GetRequiredService)); + App.ServiceCollection.AddSingleton(_ => + new RegisteredAudioPreprocessor( + Constants.SpeexDspPreprocessorGuid, + "AudioService.Preprocessors.Speex", + () => new SpeexDspPreprocessor( + Constants.SampleRate, + Constants.FrameSize, + Constants.RecordingChannels, + Constants.PlaybackChannels), + true, + true, + true)); + App.ServiceCollection.AddTransient(x => + new LiteNetVoiceCraftClient( + x.GetRequiredService(), + x.GetRequiredService)); + App.ServiceCollection.AddTransient(); + App.ServiceCollection.AddTransient(); + } + + private static void CurrentDomainOnUnhandledException(object sender, UnhandledExceptionEventArgs e) + { + try + { + if (e.ExceptionObject is Exception ex) + LogService.LogCrash(ex); + } + catch (Exception writeEx) + { + Debug.WriteLine(writeEx); + } + } +} diff --git a/VoiceCraft.Client/VoiceCraft.Client.Android/Audio/AndroidMiniAudioEngine.cs b/VoiceCraft.Client/VoiceCraft.Client.Android/Audio/AndroidMiniAudioEngine.cs index 629aba94..6eed55bc 100644 --- a/VoiceCraft.Client/VoiceCraft.Client.Android/Audio/AndroidMiniAudioEngine.cs +++ b/VoiceCraft.Client/VoiceCraft.Client.Android/Audio/AndroidMiniAudioEngine.cs @@ -1,19 +1,24 @@ using System; +using System.Collections.Generic; using System.Linq; +using System.Reflection; using System.Threading; using System.Threading.Tasks; using Android.Media; +using Android.OS; using SoundFlow.Abstracts; using SoundFlow.Abstracts.Devices; using SoundFlow.Backends.MiniAudio.Devices; using SoundFlow.Backends.MiniAudio.Enums; using SoundFlow.Enums; +using VoiceCraft.Core.Audio; namespace VoiceCraft.Client.Android.Audio; public class AndroidMiniAudioEngine : AudioEngine { private readonly AudioManager _audioManager; + private readonly List _activeDevices = []; public AndroidMiniAudioEngine(AudioManager audioManager) { @@ -23,22 +28,42 @@ public AndroidMiniAudioEngine(AudioManager audioManager) protected override void CleanupBackend() { + foreach (var device in _activeDevices.ToList()) + { + device.Dispose(); + } + + _activeDevices.Clear(); } public override AudioPlaybackDevice InitializePlaybackDevice( - SoundFlow.Structs.DeviceInfo? deviceInfo, + SoundFlow.Structs.DeviceInfo? deviceInfo, SoundFlow.Structs.AudioFormat format, DeviceConfig? config = null) { - throw new System.NotImplementedException(); + if (config != null && config is not MiniAudioDeviceConfig) + throw new ArgumentException($"config must be of type {typeof(MiniAudioDeviceConfig)}"); + config ??= new MiniAudioDeviceConfig(); + + var device = new AndroidAudioPlaybackDevice(_audioManager, this, deviceInfo, format, config); + _activeDevices.Add(device); + device.OnDisposed += OnDeviceDisposing; + return device; } public override AudioCaptureDevice InitializeCaptureDevice( - SoundFlow.Structs.DeviceInfo? deviceInfo, + SoundFlow.Structs.DeviceInfo? deviceInfo, SoundFlow.Structs.AudioFormat format, DeviceConfig? config = null) { - throw new System.NotImplementedException(); + if (config != null && config is not MiniAudioDeviceConfig) + throw new ArgumentException($"config must be of type {typeof(MiniAudioDeviceConfig)}"); + config ??= new MiniAudioDeviceConfig(); + + var device = new AndroidAudioCaptureDevice(_audioManager, this, deviceInfo, format, config); + _activeDevices.Add(device); + device.OnDisposed += OnDeviceDisposing; + return device; } public override FullDuplexDevice InitializeFullDuplexDevice( @@ -72,7 +97,7 @@ public override AudioPlaybackDevice SwitchDevice( } public override AudioCaptureDevice SwitchDevice( - AudioCaptureDevice oldDevice, + AudioCaptureDevice oldDevice, SoundFlow.Structs.DeviceInfo newDeviceInfo, DeviceConfig? config = null) { @@ -89,7 +114,7 @@ public override AudioCaptureDevice SwitchDevice( public override FullDuplexDevice SwitchDevice( FullDuplexDevice oldDevice, SoundFlow.Structs.DeviceInfo? newPlaybackInfo, - SoundFlow.Structs.DeviceInfo? newCaptureInfo, + SoundFlow.Structs.DeviceInfo? newCaptureInfo, DeviceConfig? config = null) { throw new NotSupportedException("Full duplex mode is not supported by the Android audio engine."); @@ -128,6 +153,14 @@ private void UpdateCaptureDevices() CaptureDevices = []; } } + + private void OnDeviceDisposing(object? sender, EventArgs e) + { + if (sender is AudioDevice device) + { + _activeDevices.Remove(device); + } + } } internal sealed class AndroidAudioCaptureDevice : AudioCaptureDevice @@ -135,6 +168,8 @@ internal sealed class AndroidAudioCaptureDevice : AudioCaptureDevice private readonly Lock _lock = new(); private readonly AudioRecord _nativeRecorder; private readonly float[] _buffer; + private readonly short[] _pcm16Buffer; + private readonly bool _readPcm16; public AndroidAudioCaptureDevice( AudioManager audioManager, @@ -168,13 +203,15 @@ public AndroidAudioCaptureDevice( var periods = deviceConfig?.Periods > 0 ? deviceConfig.Periods : 3; var bufferSize = periodFrames * format.Channels; _buffer = new float[bufferSize]; + _readPcm16 = IsGoogleDevice(); + _pcm16Buffer = _readPcm16 ? new short[bufferSize] : []; _nativeRecorder = new AudioRecord( source, format.SampleRate, channelMask, - Encoding.PcmFloat, - (int)(bufferSize * sizeof(float) * periods)); + _readPcm16 ? Encoding.Pcm16bit : Encoding.PcmFloat, + (int)(bufferSize * (_readPcm16 ? sizeof(short) : sizeof(float)) * periods)); var device = audioManager.GetDevices(GetDevicesTargets.Inputs) ?.FirstOrDefault(device => device.Id == deviceInfo?.Id); _nativeRecorder.SetPreferredDevice(device); @@ -200,7 +237,9 @@ public override void Stop() if (IsDisposed || !IsRunning) return; - _nativeRecorder.Stop(); + if (_nativeRecorder is { RecordingState: RecordState.Recording, State: State.Initialized }) + _nativeRecorder.Stop(); + IsRunning = false; } } @@ -220,23 +259,58 @@ private void RecordingLogic() { while (_nativeRecorder is { RecordingState: RecordState.Recording, State: State.Initialized }) { - lock (_lock) + Array.Clear(_buffer, 0, _buffer.Length); + var read = 0; + try { - Array.Clear(_buffer, 0, _buffer.Length); - var read = _nativeRecorder.Read(_buffer, 0, _buffer.Length, 0); - if (read <= 0) return; - InvokeOnAudioProcessed(_buffer); + lock (_lock) + { + if (_readPcm16) + { + read = _nativeRecorder.Read(_pcm16Buffer, 0, _pcm16Buffer.Length, 0); + if (read > 0) + Sample16ToFloat.Read(_pcm16Buffer.AsSpan(0, read), _buffer.AsSpan(0, read)); + } + else + { + read = _nativeRecorder.Read(_buffer, 0, _buffer.Length, 0); + } + } } + catch (Exception) + { + //Do Nothing + } + + if (read <= 0) + continue; + + InvokeOnAudioProcessed(_buffer); } + + Stop(); + } + + private static bool IsGoogleDevice() + { + var manufacturer = Build.Manufacturer ?? string.Empty; + var brand = Build.Brand ?? string.Empty; + return manufacturer.Equals("Google", StringComparison.OrdinalIgnoreCase) || + brand.Equals("google", StringComparison.OrdinalIgnoreCase); } } internal sealed class AndroidAudioPlaybackDevice : AudioPlaybackDevice { + private delegate void SoundComponentProcessDelegate(SoundComponent component, Span outputBuffer, + int channels); + + private static readonly SoundComponentProcessDelegate ProcessComponent = BuildProcessDelegate(); + private readonly Lock _lock = new(); private readonly AudioTrack _nativePlayer; private readonly float[] _buffer; - + public AndroidAudioPlaybackDevice( AudioManager audioManager, AudioEngine engine, @@ -246,7 +320,7 @@ public AndroidAudioPlaybackDevice( { Capability = Capability.Playback; Info = deviceInfo; - + var deviceConfig = config as MiniAudioDeviceConfig; var periodFrames = deviceConfig?.PeriodSizeInFrames switch { @@ -262,9 +336,9 @@ public AndroidAudioPlaybackDevice( AAudioUsage.Notification => AudioUsageKind.Notification, AAudioUsage.NotificationRingtone => AudioUsageKind.NotificationRingtone, AAudioUsage.NotificationEvent => AudioUsageKind.NotificationEvent, - AAudioUsage.AssistanceAccessibility => AudioUsageKind.AssistanceAccessibility, - AAudioUsage.AssistanceNavigationGuidance => AudioUsageKind.AssistanceNavigationGuidance, - AAudioUsage.AssistanceSonification => AudioUsageKind.AssistanceSonification, + AAudioUsage.AssistanceAccessibility => AudioUsageKind.AssistanceAccessibility, + AAudioUsage.AssistanceNavigationGuidance => AudioUsageKind.AssistanceNavigationGuidance, + AAudioUsage.AssistanceSonification => AudioUsageKind.AssistanceSonification, AAudioUsage.Game => AudioUsageKind.Game, _ => AudioUsageKind.Unknown }; @@ -303,12 +377,12 @@ public AndroidAudioPlaybackDevice( .SetTransferMode(AudioTrackMode.Stream) .Build(); _nativePlayer.SetVolume(1.0f); - + var device = audioManager.GetDevices(GetDevicesTargets.Outputs) ?.FirstOrDefault(device => device.Id == deviceInfo?.Id); _nativePlayer.SetPreferredDevice(device); } - + public override void Start() { lock (_lock) @@ -329,7 +403,13 @@ public override void Stop() if (IsDisposed || !IsRunning) return; - _nativePlayer.Stop(); + if (_nativePlayer is { PlayState: PlayState.Playing, State: AudioTrackState.Initialized }) + { + _nativePlayer.Pause(); + _nativePlayer.Flush(); + _nativePlayer.Stop(); + } + IsRunning = false; } } @@ -344,21 +424,38 @@ public override void Dispose() IsDisposed = true; } } - + private void PlaybackLogic() { while (_nativePlayer is { PlayState: PlayState.Playing, State: AudioTrackState.Initialized }) { - lock (_lock) + Array.Clear(_buffer, 0, _buffer.Length); + // Process the audio graph + var soloed = Engine.GetSoloedComponent(); + if (soloed != null) + ProcessComponent(soloed, _buffer, Format.Channels); + else + ProcessComponent(MasterMixer, _buffer, Format.Channels); + + try + { + lock (_lock) + _nativePlayer.Write(_buffer, 0, _buffer.Length, WriteMode.Blocking); + } + catch (Exception) { - Array.Clear(_buffer, 0, _buffer.Length); - // Process the audio graph - var soloed = Engine.GetSoloedComponent(); - if (soloed != null) - soloed.Process(_buffer, Format.Channels); - else - MasterMixer.Process(_buffer, Format.Channels); + //Do Nothing } } + + Stop(); } -} \ No newline at end of file + + private static SoundComponentProcessDelegate BuildProcessDelegate() + { + var method = typeof(SoundComponent).GetMethod("Process", BindingFlags.Instance | BindingFlags.NonPublic); + return method == null + ? throw new InvalidOperationException("SoundFlow process method not found.") + : method.CreateDelegate(); + } +} diff --git a/VoiceCraft.Client/VoiceCraft.Client.Android/Audio/SpeexDspPreprocessor.cs b/VoiceCraft.Client/VoiceCraft.Client.Android/Audio/SpeexDspPreprocessor.cs index aca101da..2e24fb54 100644 --- a/VoiceCraft.Client/VoiceCraft.Client.Android/Audio/SpeexDspPreprocessor.cs +++ b/VoiceCraft.Client/VoiceCraft.Client.Android/Audio/SpeexDspPreprocessor.cs @@ -84,7 +84,7 @@ public SpeexDspPreprocessor(int sampleRate, int frameSize, int nbMicrophones, in var filterLength = 100 * sampleRate / 1000; _preprocessor = new SpeexDSPPreprocessor(frameSize, sampleRate); - _echoCanceler = new SpeexDSPEchoCanceler(frameSize, filterLength, 1, nbSpeakers); + _echoCanceler = new SpeexDSPEchoCanceler(frameSize, filterLength, 1, nbSpeakers, use_static: false); _captureBuffer = new SampleBufferProvider(filterLength * nbSpeakers); _captureBufferFrame = new short[frameSize * nbSpeakers]; DenoiserEnabled = true; @@ -178,4 +178,4 @@ private void Dispose(bool disposing) _disposed = true; } -} \ No newline at end of file +} diff --git a/VoiceCraft.Client/VoiceCraft.Client.Android/MainActivity.cs b/VoiceCraft.Client/VoiceCraft.Client.Android/MainActivity.cs index 48078106..df23c09a 100644 --- a/VoiceCraft.Client/VoiceCraft.Client.Android/MainActivity.cs +++ b/VoiceCraft.Client/VoiceCraft.Client.Android/MainActivity.cs @@ -1,22 +1,10 @@ -using System; using Android.App; using Android.Content.PM; -using Android.Media; using Android.OS; -using AndroidX.Activity; -using Avalonia; using Avalonia.Android; using Microsoft.Extensions.DependencyInjection; using Microsoft.Maui.ApplicationModel; -using SoundFlow.Abstracts; -using SoundFlow.Backends.MiniAudio; -using VoiceCraft.Client.Android.Audio; using VoiceCraft.Client.Services; -using VoiceCraft.Core; -using VoiceCraft.Core.Interfaces; -using VoiceCraft.Network.Clients; -using Debug = System.Diagnostics.Debug; -using Exception = System.Exception; namespace VoiceCraft.Client.Android; @@ -26,86 +14,27 @@ namespace VoiceCraft.Client.Android; Icon = "@drawable/icon", MainLauncher = true, ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize | ConfigChanges.UiMode)] -public class MainActivity : AvaloniaMainActivity +public class MainActivity : AvaloniaMainActivity { - public override void OnRequestPermissionsResult(int requestCode, string[] permissions, - Permission[] grantResults) - { - Platform.OnRequestPermissionsResult(requestCode, permissions, grantResults); - - base.OnRequestPermissionsResult(requestCode, permissions, grantResults); - } - - protected override AppBuilder CustomizeAppBuilder(AppBuilder builder) - { - return base.CustomizeAppBuilder(builder) - .WithInterFont(); - } - protected override void OnCreate(Bundle? app) { - var nativeStorage = new NativeStorageService(); - LogService.NativeStorageService = nativeStorage; - LogService.Load(); - AppDomain.CurrentDomain.UnhandledException += CurrentDomainOnUnhandledException; - OnBackPressedDispatcher.AddCallback(this, new BackPressedCallback(this)); - - var audioManager = (AudioManager?)GetSystemService(AudioService); - if (audioManager == null) - throw new Exception($"Could not find {AudioService}. Cannot initialize audio service."); - - App.ServiceCollection.AddSingleton(); - App.ServiceCollection.AddSingleton(nativeStorage); - App.ServiceCollection.AddSingleton(); - App.ServiceCollection.AddSingleton(x => - new NativeBackgroundService(x.GetRequiredService(), x.GetRequiredService)); - App.ServiceCollection.AddSingleton(_ => - new RegisteredAudioPreprocessor( - Constants.SpeexDspPreprocessorGuid, - "AudioService.Preprocessors.Speex", - () => new SpeexDspPreprocessor( - Constants.SampleRate, - Constants.FrameSize, - Constants.RecordingChannels, - Constants.PlaybackChannels), - true, - true, - true)); - App.ServiceCollection.AddTransient(x => - new LiteNetVoiceCraftClient(x.GetRequiredService(), - x.GetRequiredService)); - App.ServiceCollection.AddTransient(); - App.ServiceCollection.AddTransient(); - - Platform.Init(this, app); base.OnCreate(app); + Platform.Init(this, app); } - - protected override void OnDestroy() + + public override void OnRequestPermissionsResult(int requestCode, string[] permissions, + Permission[] grantResults) { - try - { - if (App.ServiceProvider == null) return; - var serviceProvider = App.ServiceProvider; - serviceProvider.Dispose(); - } - finally - { - base.OnDestroy(); - } + Platform.OnRequestPermissionsResult(requestCode, permissions, grantResults); + base.OnRequestPermissionsResult(requestCode, permissions, grantResults); } - private static void CurrentDomainOnUnhandledException(object sender, UnhandledExceptionEventArgs e) + public override void OnBackPressed() { - try - { - if (e.ExceptionObject is Exception ex) - LogService.LogCrash(ex); //Log it - } - catch (Exception writeEx) - { - Debug.WriteLine(writeEx); //We don't want to crash if the log failed. - } + if (BackButtonBehavior()) return; + #pragma warning disable + base.OnBackPressed(); + #pragma warning restore } private static bool BackButtonBehavior() @@ -114,14 +43,4 @@ private static bool BackButtonBehavior() var navigationService = App.ServiceProvider.GetService(); return navigationService?.Back(true) != null; } - - private class BackPressedCallback(MainActivity activity, bool enabled = true) : OnBackPressedCallback(enabled) - { - public override void HandleOnBackPressed() - { - if (BackButtonBehavior()) return; - activity.FinishAndRemoveTask(); - Process.KillProcess(Process.MyPid()); - } - } -} \ No newline at end of file +} diff --git a/VoiceCraft.Client/VoiceCraft.Client.Android/NativeBackgroundService.cs b/VoiceCraft.Client/VoiceCraft.Client.Android/NativeBackgroundService.cs index d6bd0fdf..6d00975a 100644 --- a/VoiceCraft.Client/VoiceCraft.Client.Android/NativeBackgroundService.cs +++ b/VoiceCraft.Client/VoiceCraft.Client.Android/NativeBackgroundService.cs @@ -58,7 +58,7 @@ public async Task StartServiceAsync(Action, Action> public void Dispose() { var context = Application.Context; - var intent = new Intent(context, typeof(NativeBackgroundService)); + var intent = new Intent(context, typeof(AndroidBackgroundService)); context.StopService(intent); GC.SuppressFinalize(this); } @@ -146,4 +146,4 @@ public void Dispose() GC.SuppressFinalize(this); } } -} \ No newline at end of file +} diff --git a/VoiceCraft.Client/VoiceCraft.Client.Android/NativeHotKeyService.cs b/VoiceCraft.Client/VoiceCraft.Client.Android/NativeHotKeyService.cs index 32797870..42a2ca0b 100644 --- a/VoiceCraft.Client/VoiceCraft.Client.Android/NativeHotKeyService.cs +++ b/VoiceCraft.Client/VoiceCraft.Client.Android/NativeHotKeyService.cs @@ -4,13 +4,9 @@ namespace VoiceCraft.Client.Android; -public class NativeHotKeyService : HotKeyService +public class NativeHotKeyService(IEnumerable registeredHotKeyActions, SettingsService settingsService) + : HotKeyService(registeredHotKeyActions, settingsService) { - public NativeHotKeyService(IEnumerable registeredHotKeyActions, SettingsService settingsService) : - base(registeredHotKeyActions, settingsService) - { - } - public override void Initialize() { } diff --git a/VoiceCraft.Client/VoiceCraft.Client.Android/Resources/values-v31/styles.xml b/VoiceCraft.Client/VoiceCraft.Client.Android/Resources/values-v31/styles.xml index a93e7c47..a4601725 100644 --- a/VoiceCraft.Client/VoiceCraft.Client.Android/Resources/values-v31/styles.xml +++ b/VoiceCraft.Client/VoiceCraft.Client.Android/Resources/values-v31/styles.xml @@ -11,11 +11,6 @@ @color/splash_background @drawable/avalonia_anim 1000 - @style/MyTheme.Main - - - diff --git a/VoiceCraft.Client/VoiceCraft.Client.Android/VoiceCraft.Client.Android.csproj b/VoiceCraft.Client/VoiceCraft.Client.Android/VoiceCraft.Client.Android.csproj index 948e0b52..17e06191 100644 --- a/VoiceCraft.Client/VoiceCraft.Client.Android/VoiceCraft.Client.Android.csproj +++ b/VoiceCraft.Client/VoiceCraft.Client.Android/VoiceCraft.Client.Android.csproj @@ -1,10 +1,10 @@ Exe - net9.0-android + net10.0-android 21 23 - 35 + 36 enable com.AvionBlock.VoiceCraft.Client $(AndroidVersion) diff --git a/VoiceCraft.Client/VoiceCraft.Client.Browser/VoiceCraft.Client.Browser.csproj b/VoiceCraft.Client/VoiceCraft.Client.Browser/VoiceCraft.Client.Browser.csproj index f0844958..30eeb8b0 100644 --- a/VoiceCraft.Client/VoiceCraft.Client.Browser/VoiceCraft.Client.Browser.csproj +++ b/VoiceCraft.Client/VoiceCraft.Client.Browser/VoiceCraft.Client.Browser.csproj @@ -1,6 +1,6 @@ - + - net9.0-browser + net10.0-browser Exe true enable diff --git a/VoiceCraft.Client/VoiceCraft.Client.Linux/Audio/SpeexDspPreprocessor.cs b/VoiceCraft.Client/VoiceCraft.Client.Linux/Audio/SpeexDspPreprocessor.cs index dcf00c12..502000bc 100644 --- a/VoiceCraft.Client/VoiceCraft.Client.Linux/Audio/SpeexDspPreprocessor.cs +++ b/VoiceCraft.Client/VoiceCraft.Client.Linux/Audio/SpeexDspPreprocessor.cs @@ -84,7 +84,7 @@ public SpeexDspPreprocessor(int sampleRate, int frameSize, int nbMicrophones, in var filterLength = 100 * sampleRate / 1000; _preprocessor = new SpeexDSPPreprocessor(frameSize, sampleRate); - _echoCanceler = new SpeexDSPEchoCanceler(frameSize, filterLength, 1, nbSpeakers); + _echoCanceler = new SpeexDSPEchoCanceler(frameSize, filterLength, 1, nbSpeakers, use_static: false); _captureBuffer = new SampleBufferProvider(filterLength * nbSpeakers); _captureBufferFrame = new short[frameSize * nbSpeakers]; DenoiserEnabled = true; @@ -178,4 +178,4 @@ private void Dispose(bool disposing) _disposed = true; } -} \ No newline at end of file +} diff --git a/VoiceCraft.Client/VoiceCraft.Client.Linux/Program.cs b/VoiceCraft.Client/VoiceCraft.Client.Linux/Program.cs index beae28d2..977ac893 100644 --- a/VoiceCraft.Client/VoiceCraft.Client.Linux/Program.cs +++ b/VoiceCraft.Client/VoiceCraft.Client.Linux/Program.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Diagnostics; using Avalonia; using Microsoft.Extensions.DependencyInjection; @@ -45,7 +45,8 @@ public static void Main(string[] args) true, true)); App.ServiceCollection.AddTransient(x => - new LiteNetVoiceCraftClient(x.GetRequiredService(), + new LiteNetVoiceCraftClient( + x.GetRequiredService(), x.GetRequiredService)); App.ServiceCollection.AddTransient(); BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); @@ -81,4 +82,4 @@ private static void CurrentDomainOnUnhandledException(object sender, UnhandledEx Debug.WriteLine(writeEx); //We don't want to crash if the log failed. } } -} \ No newline at end of file +} diff --git a/VoiceCraft.Client/VoiceCraft.Client.Linux/VoiceCraft.Client.Linux.csproj b/VoiceCraft.Client/VoiceCraft.Client.Linux/VoiceCraft.Client.Linux.csproj index be3a2a6b..7a5de2dd 100644 --- a/VoiceCraft.Client/VoiceCraft.Client.Linux/VoiceCraft.Client.Linux.csproj +++ b/VoiceCraft.Client/VoiceCraft.Client.Linux/VoiceCraft.Client.Linux.csproj @@ -1,7 +1,9 @@  Exe - net9.0 + + net10.0 enable true link diff --git a/VoiceCraft.Client/VoiceCraft.Client.MacOS/Info.plist b/VoiceCraft.Client/VoiceCraft.Client.MacOS/Info.plist index a6e52985..c62049a2 100644 --- a/VoiceCraft.Client/VoiceCraft.Client.MacOS/Info.plist +++ b/VoiceCraft.Client/VoiceCraft.Client.MacOS/Info.plist @@ -11,9 +11,9 @@ CFBundleIdentifier com.avionblock.voicecraft.client.macos CFBundleShortVersionString - 1.5.1 + 1.6.0 CFBundleVersion - 1.5.1 + 1.6.0 CFBundleIconFile AppIcon.icns LSApplicationCategoryType diff --git a/VoiceCraft.Client/VoiceCraft.Client.MacOS/Program.cs b/VoiceCraft.Client/VoiceCraft.Client.MacOS/Program.cs index 6ba94332..d8940921 100644 --- a/VoiceCraft.Client/VoiceCraft.Client.MacOS/Program.cs +++ b/VoiceCraft.Client/VoiceCraft.Client.MacOS/Program.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Diagnostics; using Avalonia; using Microsoft.Extensions.DependencyInjection; diff --git a/VoiceCraft.Client/VoiceCraft.Client.MacOS/VoiceCraft.Client.MacOS.csproj b/VoiceCraft.Client/VoiceCraft.Client.MacOS/VoiceCraft.Client.MacOS.csproj index 75d09850..67f29b83 100644 --- a/VoiceCraft.Client/VoiceCraft.Client.MacOS/VoiceCraft.Client.MacOS.csproj +++ b/VoiceCraft.Client/VoiceCraft.Client.MacOS/VoiceCraft.Client.MacOS.csproj @@ -1,7 +1,9 @@  Exe - net9.0-macos + + net10.0-macos 13.0 VoiceCraft VoiceCraft diff --git a/VoiceCraft.Client/VoiceCraft.Client.Windows/Audio/SpeexDspPreprocessor.cs b/VoiceCraft.Client/VoiceCraft.Client.Windows/Audio/SpeexDspPreprocessor.cs index 360770a6..a1ccca10 100644 --- a/VoiceCraft.Client/VoiceCraft.Client.Windows/Audio/SpeexDspPreprocessor.cs +++ b/VoiceCraft.Client/VoiceCraft.Client.Windows/Audio/SpeexDspPreprocessor.cs @@ -84,7 +84,7 @@ public SpeexDspPreprocessor(int sampleRate, int frameSize, int nbMicrophones, in var filterLength = 100 * sampleRate / 1000; _preprocessor = new SpeexDSPPreprocessor(frameSize, sampleRate); - _echoCanceler = new SpeexDSPEchoCanceler(frameSize, filterLength, 1, nbSpeakers); + _echoCanceler = new SpeexDSPEchoCanceler(frameSize, filterLength, 1, nbSpeakers, use_static: false); _captureBuffer = new SampleBufferProvider(filterLength * nbSpeakers); _captureBufferFrame = new short[frameSize * nbSpeakers]; DenoiserEnabled = true; @@ -178,4 +178,4 @@ private void Dispose(bool disposing) _disposed = true; } -} \ No newline at end of file +} diff --git a/VoiceCraft.Client/VoiceCraft.Client.Windows/Program.cs b/VoiceCraft.Client/VoiceCraft.Client.Windows/Program.cs index 558879a2..bd337ec3 100644 --- a/VoiceCraft.Client/VoiceCraft.Client.Windows/Program.cs +++ b/VoiceCraft.Client/VoiceCraft.Client.Windows/Program.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Diagnostics; using Avalonia; using Microsoft.Extensions.DependencyInjection; @@ -37,7 +37,7 @@ public static void Main(string[] args) new NativeBackgroundService(x.GetRequiredService)); App.ServiceCollection.AddSingleton(_ => new RegisteredAudioPreprocessor( - Constants.SpeexDspPreprocessorGuid, + Constants.SpeexDspPreprocessorGuid, "AudioService.Preprocessors.Speex", () => new SpeexDspPreprocessor( Constants.SampleRate, @@ -48,7 +48,8 @@ public static void Main(string[] args) true, true)); App.ServiceCollection.AddTransient(x => - new LiteNetVoiceCraftClient(x.GetRequiredService(), + new LiteNetVoiceCraftClient( + x.GetRequiredService(), x.GetRequiredService)); App.ServiceCollection.AddTransient(); BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); diff --git a/VoiceCraft.Client/VoiceCraft.Client.Windows/VoiceCraft.Client.Windows.csproj b/VoiceCraft.Client/VoiceCraft.Client.Windows/VoiceCraft.Client.Windows.csproj index 4083664f..bb57261f 100644 --- a/VoiceCraft.Client/VoiceCraft.Client.Windows/VoiceCraft.Client.Windows.csproj +++ b/VoiceCraft.Client/VoiceCraft.Client.Windows/VoiceCraft.Client.Windows.csproj @@ -1,7 +1,9 @@  WinExe - net9.0-windows + + net10.0-windows enable link true @@ -14,6 +16,8 @@ + + diff --git a/VoiceCraft.Client/VoiceCraft.Client.iOS/Audio/IosMiniAudioEngine.cs b/VoiceCraft.Client/VoiceCraft.Client.iOS/Audio/IosMiniAudioEngine.cs index e6c76a5e..9618d062 100644 --- a/VoiceCraft.Client/VoiceCraft.Client.iOS/Audio/IosMiniAudioEngine.cs +++ b/VoiceCraft.Client/VoiceCraft.Client.iOS/Audio/IosMiniAudioEngine.cs @@ -124,7 +124,7 @@ public IosAudioCaptureDevice(AudioEngine engine, DeviceInfo deviceInfo, AudioFor _periodFrames = config is MiniAudioDeviceConfig deviceConfig && deviceConfig.PeriodSizeInFrames > 0 ? deviceConfig.PeriodSizeInFrames : 960u; - _chunkSamples = Constants.FrameSize * Math.Max(1, format.Channels); + _chunkSamples = (int)_periodFrames * Math.Max(1, format.Channels); _stagingBuffer = new float[_chunkSamples * 8]; _stagingCount = 0; } diff --git a/VoiceCraft.Client/VoiceCraft.Client.iOS/Info.plist b/VoiceCraft.Client/VoiceCraft.Client.iOS/Info.plist index 3cfd94a8..bec982bb 100644 --- a/VoiceCraft.Client/VoiceCraft.Client.iOS/Info.plist +++ b/VoiceCraft.Client/VoiceCraft.Client.iOS/Info.plist @@ -9,9 +9,9 @@ CFBundleIdentifier com.avionblock.voicecraft.client CFBundleShortVersionString - 1.5.1 + 1.6.0 CFBundleVersion - 1.5.1 + 1.6.0 NSMicrophoneUsageDescription VoiceCraft needs microphone access for voice chat. LSRequiresIPhoneOS diff --git a/VoiceCraft.Client/VoiceCraft.Client.iOS/VoiceCraft.Client.iOS.csproj b/VoiceCraft.Client/VoiceCraft.Client.iOS/VoiceCraft.Client.iOS.csproj index 75c84d2a..22d4cbc8 100644 --- a/VoiceCraft.Client/VoiceCraft.Client.iOS/VoiceCraft.Client.iOS.csproj +++ b/VoiceCraft.Client/VoiceCraft.Client.iOS/VoiceCraft.Client.iOS.csproj @@ -1,7 +1,7 @@ - + Exe - net9.0-ios + net10.0-ios 13.0 ios-arm64 VoiceCraft diff --git a/VoiceCraft.Client/VoiceCraft.Client/App.axaml.cs b/VoiceCraft.Client/VoiceCraft.Client/App.axaml.cs index 56268eff..6acbd9e8 100644 --- a/VoiceCraft.Client/VoiceCraft.Client/App.axaml.cs +++ b/VoiceCraft.Client/VoiceCraft.Client/App.axaml.cs @@ -1,9 +1,7 @@ using System; -using System.Linq; using Avalonia; using Avalonia.Controls; using Avalonia.Controls.ApplicationLifetimes; -using Avalonia.Data.Core.Plugins; using Avalonia.Markup.Xaml; using Avalonia.Styling; using Microsoft.Extensions.DependencyInjection; @@ -15,10 +13,12 @@ using VoiceCraft.Client.Themes.Dark; using VoiceCraft.Client.ViewModels; using VoiceCraft.Client.ViewModels.Home; +using VoiceCraft.Client.ViewModels.Modals; using VoiceCraft.Client.ViewModels.Settings; using VoiceCraft.Client.Views; using VoiceCraft.Client.Views.Error; using VoiceCraft.Client.Views.Home; +using VoiceCraft.Client.Views.Modals; using VoiceCraft.Client.Views.Settings; using VoiceCraft.Core; using VoiceCraft.Core.Audio; @@ -42,10 +42,6 @@ public override void OnFrameworkInitializationCompleted() { try { - // Avoid duplicate validations from both Avalonia and the CommunityToolkit. - // More info: https://docs.avaloniaui.net/docs/guides/development-guides/data-validation#manage-validationplugins - DisableAvaloniaDataAnnotationValidation(); - var serviceProvider = BuildServiceProvider(); SetupServices(serviceProvider); @@ -56,17 +52,31 @@ public override void OnFrameworkInitializationCompleted() { DataContext = serviceProvider.GetRequiredService() }; + serviceProvider.GetRequiredService().RegisterTopLevel(desktop.MainWindow); desktop.MainWindow.Closing += (__, ___) => { _ = serviceProvider.GetRequiredService().SaveImmediate(); }; break; + case IActivityApplicationLifetime activityLifetime: + activityLifetime.MainViewFactory = () => + { + var mainView = new MainView + { + DataContext = serviceProvider.GetRequiredService() + }; + RegisterClipboardWhenAttached(serviceProvider, mainView); + return mainView; + }; + break; case ISingleViewApplicationLifetime singleViewPlatform: - singleViewPlatform.MainView = new MainView + var singleView = new MainView { DataContext = serviceProvider.GetRequiredService() }; + RegisterClipboardWhenAttached(serviceProvider, singleView); + singleViewPlatform.MainView = singleView; break; } @@ -83,6 +93,12 @@ public override void OnFrameworkInitializationCompleted() DataContext = new ErrorViewModel { ErrorMessage = ex.ToString() } }; break; + case IActivityApplicationLifetime activityLifetime: + activityLifetime.MainViewFactory = () => new MainView() + { + DataContext = new ErrorViewModel { ErrorMessage = ex.ToString() } + }; + break; case ISingleViewApplicationLifetime singleViewPlatform: singleViewPlatform.MainView = new ErrorView { @@ -97,18 +113,6 @@ public override void OnFrameworkInitializationCompleted() base.OnFrameworkInitializationCompleted(); } - private static void DisableAvaloniaDataAnnotationValidation() - { -#pragma warning disable IL2026 - // Get an array of plugins to remove - var dataValidationPluginsToRemove = - BindingPlugins.DataValidators.OfType().ToArray(); - - // remove each entry found - foreach (var plugin in dataValidationPluginsToRemove) BindingPlugins.DataValidators.Remove(plugin); -#pragma warning restore IL2026 - } - private static ServiceProvider BuildServiceProvider() { //Service Registry @@ -117,6 +121,8 @@ private static ServiceProvider BuildServiceProvider() ServiceCollection.AddSingleton(x => new NavigationService(y => (ViewModelBase)x.GetRequiredService(y))); ServiceCollection.AddSingleton(); + ServiceCollection.AddSingleton(); + ServiceCollection.AddSingleton(); ServiceCollection.AddSingleton(x => new PermissionsService( x.GetRequiredService(), y => (Permissions.BasePermission)x.GetRequiredService(y))); @@ -127,6 +133,11 @@ private static ServiceProvider BuildServiceProvider() //Pages Registry ServiceCollection.AddSingleton(); + //Modals + ServiceCollection.AddTransient(); + ServiceCollection.AddTransient(); + ServiceCollection.AddTransient(); + //Main Pages ServiceCollection.AddSingleton(); ServiceCollection.AddTransient(); @@ -149,6 +160,9 @@ private static ServiceProvider BuildServiceProvider() //Views ServiceCollection.AddKeyedTransient(typeof(MainView).FullName); + ServiceCollection.AddKeyedTransient(typeof(TelemetryConsentView).FullName); + ServiceCollection.AddKeyedTransient(typeof(EntityDataSettingsView).FullName); + ServiceCollection.AddKeyedTransient(typeof(HotKeyCaptureView).FullName); ServiceCollection.AddKeyedTransient(typeof(HomeView).FullName); ServiceCollection.AddKeyedTransient(typeof(EditServerView).FullName); ServiceCollection.AddKeyedTransient(typeof(AddServerView).FullName); @@ -247,5 +261,15 @@ private void SetupServices(IServiceProvider serviceProvider) { Localizer.BaseLocalizer = new EmbeddedJsonLocalizer("VoiceCraft.Client.Locales"); DataTemplates.Add(serviceProvider.GetRequiredService()); + _ = serviceProvider.GetRequiredService().ReportStartupAsync(); + } + + private static void RegisterClipboardWhenAttached(IServiceProvider serviceProvider, Control control) + { + control.AttachedToVisualTree += (_, _) => + { + if (TopLevel.GetTopLevel(control) is { } topLevel) + serviceProvider.GetRequiredService().RegisterTopLevel(topLevel); + }; } -} +} \ No newline at end of file diff --git a/VoiceCraft.Client/VoiceCraft.Client/Locales/LocalizeExtension.cs b/VoiceCraft.Client/VoiceCraft.Client/Locales/LocalizeExtension.cs index 1560b14b..c0496b80 100644 --- a/VoiceCraft.Client/VoiceCraft.Client/Locales/LocalizeExtension.cs +++ b/VoiceCraft.Client/VoiceCraft.Client/Locales/LocalizeExtension.cs @@ -33,8 +33,8 @@ public override object ProvideValue(IServiceProvider serviceProvider) }; } - if (arg is not IBinding binding) - throw new Exception("Argument must be of type IBinding or string!"); + if (arg is not BindingBase binding) + throw new Exception("Argument must be of type BindingBase or string!"); var mb = new MultiBinding { @@ -48,4 +48,4 @@ public override object ProvideValue(IServiceProvider serviceProvider) return mb; } -} \ No newline at end of file +} diff --git a/VoiceCraft.Client/VoiceCraft.Client/Locales/de-DE.json b/VoiceCraft.Client/VoiceCraft.Client/Locales/de-DE.json index 2dec2de6..3eccbed1 100644 --- a/VoiceCraft.Client/VoiceCraft.Client/Locales/de-DE.json +++ b/VoiceCraft.Client/VoiceCraft.Client/Locales/de-DE.json @@ -13,7 +13,8 @@ "Language": "Sprache", "NotificationDismiss": "Benachrichtigung ausblenden (ms)", "DisableNotifications": "Benachrichtigungen deaktivieren", - "HideServerAddresses": "Serveradressen ausblenden" + "HideServerAddresses": "Serveradressen ausblenden", + "EnableTelemetry": "Anonyme Telemetrie aktivieren" }, "Appearance": { "Title": "Darstellung", @@ -28,6 +29,9 @@ "Denoisers": "Rauschunterdrückung", "AutomaticGainControllers": "Automatische Verstärkungsregelung", "EchoCancelers": "Echo-Unterdrückung", + "PushToTalk": "Zum Sprechen gedrückt halten", + "HardwarePreprocessors": null, + "PlayPushToTalkCue": "Push-to-Talk-Sounds abspielen", "MicrophoneTest": { "MicrophoneTest": "Mikrofontest", "Test": "Test" @@ -58,7 +62,11 @@ }, "Network": { "Title": "Netzwerkeinstellungen", - "PositioningType": "Positionierungstyp", + "PositioningType": { + "Title": "Positionierungstyp", + "Server": null, + "Client": null + }, "McWssListenIp": "MCWSS Listening-IP-Adresse", "McWssHostPort": "MCWSS-Host-Port" }, @@ -68,7 +76,6 @@ "CapturePrompt": "Taste oder Maustaste drücken", "SaveBinding": "Belegung speichern", "CancelRebind": "Abbrechen", - "PlayPushToTalkCues": "Push-to-Talk-Sounds abspielen", "Actions": { "Mute": "Stummschalten", "Deafen": "Taub schalten", @@ -79,10 +86,15 @@ "Title": "Erweiterte Einstellungen", "TriggerGc": "Garbage Collector auslösen", "Crash": "Testabsturz", + "ResetAllSettings": "Alle Einstellungen zurücksetzen", "Notification": { "GC": { "Badge": "GC", "Triggered": "Garbage Collection ausgelöst. Freigegebener Speicher: {0}mb." + }, + "Reset": { + "Badge": "Einstellungen", + "Done": "Alle Einstellungen wurden auf Standardwerte zurückgesetzt. Starte den Client neu, damit Sprache und Design vollständig übernommen werden." } } }, @@ -110,14 +122,31 @@ }, "CrashLogs": { "Title": "Absturzprotokolle", + "Action": { + "DumpLink": "Dump-Link" + }, "Notification": { "Badge": "Absturzprotokolle", "Cleared": "Alle Protokolle wurden erfolgreich gelöscht.", "Copied": "Absturzprotokolle wurden in die Zwischenablage kopiert.", + "DumpCopied": "Link zum Absturz-Dump wurde in die Zwischenablage kopiert.", + "DumpUploadFailed": "Der Absturz-Dump konnte nicht hochgeladen werden.", + "DumpUnavailable": "Für dieses Absturzprotokoll ist noch kein Dump-Link verfügbar.", "Empty": "Keine Absturzprotokolle zum Kopieren vorhanden." } }, - "AddServer": { + "TelemetryConsent": { + "Title": "VoiceCraft verbessern", + "Description": "VoiceCraft kann anonyme Telemetrie senden, damit wir Abstürze besser verstehen und die Stabilität auf allen Plattformen verbessern können.", + "CollectsTitle": "Was wir erfassen", + "CollectsBody": "App-Version, Plattform, Betriebssystem- und Runtime-Informationen, Architektur, Gebietsschema, CPU- und Speicherinformationen sowie anonyme Start- und Heartbeat-Diagnosen.", + "NotCollectedTitle": "Was wir nicht automatisch erfassen", + "NotCollectedBody": "Wir laden deine Serverliste, Tokens, Konfigurationswerte oder Chat- und Inhaltsdaten nicht automatisch hoch.", + "DumpBody": "Absturz-Dumps werden nur hochgeladen, wenn du im Absturzprotokoll ausdrücklich auf die Dump-Link-Schaltfläche klickst.", + "Accept": "Akzeptieren", + "Decline": "Ablehnen" + }, + "AddServer": { "Title": "Server hinzufügen", "Name": "Name", "IP": "IP", @@ -214,7 +243,8 @@ "TanhSoft": "Weicher Tanh-Clipper" }, "AudioDeviceInfo": { - "Default": "{0} - Standard" + "Default": "Standard", + "DefaultDevice": "{0} - Standard" } }, "ThemesService": { diff --git a/VoiceCraft.Client/VoiceCraft.Client/Locales/en-US.json b/VoiceCraft.Client/VoiceCraft.Client/Locales/en-US.json index 4de55c51..a17efce2 100644 --- a/VoiceCraft.Client/VoiceCraft.Client/Locales/en-US.json +++ b/VoiceCraft.Client/VoiceCraft.Client/Locales/en-US.json @@ -13,7 +13,8 @@ "Language": "Language", "NotificationDismiss": "Notification Dismiss(ms)", "DisableNotifications": "Disable Notifications", - "HideServerAddresses": "Hide Server Addresses" + "HideServerAddresses": "Hide Server Addresses", + "EnableTelemetry": "Enable Anonymous Telemetry" }, "Appearance": { "Title": "Appearance", @@ -28,6 +29,9 @@ "Denoisers": "Denoisers", "AutomaticGainControllers": "Automatic Gain Controllers", "EchoCancelers": "Echo Cancelers", + "HardwarePreprocessors": "Hardware Preprocessors", + "PushToTalk": "Push To Talk", + "PlayPushToTalkCue": "Play Push To Talk sounds", "MicrophoneTest": { "MicrophoneTest": "Microphone Test", "Test": "Test" @@ -58,7 +62,11 @@ }, "Network": { "Title": "Network Settings", - "PositioningType": "Positioning Type", + "PositioningType": { + "Title": "Positioning Type", + "Server": "Server", + "Client": "Client" + }, "McWssListenIp": "MCWSS Listening IP Address", "McWssHostPort": "MCWSS Host Port" }, @@ -68,7 +76,6 @@ "CapturePrompt": "Press a key or mouse button", "SaveBinding": "Save Binding", "CancelRebind": "Cancel", - "PlayPushToTalkCues": "Play Push To Talk sounds", "Actions": { "Mute": "Mute", "Deafen": "Deafen", @@ -79,10 +86,15 @@ "Title": "Advanced Settings", "TriggerGc": "Trigger Garbage Collector", "Crash": "Test Crash", + "ResetAllSettings": "Reset All Settings", "Notification": { "GC": { "Badge": "GC", "Triggered": "Garbage Collection Triggered. Memory Cleared: {0}mb." + }, + "Reset": { + "Badge": "Settings", + "Done": "All settings were reset to defaults. Restart the client to fully apply theme and language changes." } } }, @@ -110,14 +122,31 @@ }, "CrashLogs": { "Title": "Crash Logs", + "Action": { + "DumpLink": "Dump Link" + }, "Notification": { "Badge": "CrashLogs", "Cleared": "Successfully cleared all logs.", "Copied": "Crash logs copied to clipboard.", + "DumpCopied": "Crash dump link copied to clipboard.", + "DumpUploadFailed": "Failed to upload the crash dump.", + "DumpUnavailable": "No uploaded dump link is available for this crash log.", "Empty": "No crash logs to copy." } }, - + "TelemetryConsent": { + "Title": "Help Improve VoiceCraft", + "Description": "VoiceCraft can send anonymous telemetry so we can understand crashes and improve stability across platforms.", + "CollectsTitle": "What we collect", + "CollectsBody": "App version, platform, OS and runtime info, architecture, locale, CPU and memory info, plus anonymous startup and heartbeat diagnostics.", + "NotCollectedTitle": "What we do not collect automatically", + "NotCollectedBody": "We do not automatically upload your server list, tokens, config values, or chat/content data.", + "DumpBody": "Crash dumps are only uploaded when you explicitly press the dump link button in crash logs.", + "Accept": "Accept", + "Decline": "Decline" + }, + "AddServer": { "Title": "Add Server", "Name": "Name", @@ -217,7 +246,8 @@ "TanhSoft": "Tanh Soft Clipper" }, "AudioDeviceInfo": { - "Default": "{0} - Default" + "Default": "Default", + "DefaultDevice": "{0} - Default" } }, "ThemesService": { diff --git a/VoiceCraft.Client/VoiceCraft.Client/Locales/nl-NL.json b/VoiceCraft.Client/VoiceCraft.Client/Locales/nl-NL.json index a1ab1e86..33a82354 100644 --- a/VoiceCraft.Client/VoiceCraft.Client/Locales/nl-NL.json +++ b/VoiceCraft.Client/VoiceCraft.Client/Locales/nl-NL.json @@ -13,7 +13,8 @@ "Language": "Taal", "NotificationDismiss": "Melding sluiten (ms)", "DisableNotifications": "Meldingen uitschakelen", - "HideServerAddresses": "Serveradressen verbergen" + "HideServerAddresses": "Serveradressen verbergen", + "EnableTelemetry": "Anonieme telemetrie inschakelen" }, "Appearance": { "Title": "Uiterlijk", @@ -28,6 +29,9 @@ "Denoisers": "Ruisonderdrukkers", "AutomaticGainControllers": "Automatische versterkingsregelaars", "EchoCancelers": "Echo-onderdrukkers", + "HardwarePreprocessors": null, + "PushToTalk": "Ingedrukt houden om te praten", + "PlayPushToTalkCue": "Push-to-talk-geluiden afspelen", "MicrophoneTest": { "MicrophoneTest": "Microfoontest", "Test": "Test" @@ -58,7 +62,11 @@ }, "Network": { "Title": "Netwerkinstellingen", - "PositioningType": "Positioneringstype", + "PositioningType": { + "Title": "Positioneringstype", + "Server": null, + "Client": null + }, "McWssListenIp": "MCWSS-luister-IP-adres", "McWssHostPort": "MCWSS-hostpoort" }, @@ -68,7 +76,6 @@ "CapturePrompt": "Druk op een toets of muisknop", "SaveBinding": "Binding opslaan", "CancelRebind": "Annuleren", - "PlayPushToTalkCues": "Push-to-talk-geluiden afspelen", "Actions": { "Mute": "Dempen", "Deafen": "Doof maken", @@ -79,10 +86,15 @@ "Title": "Geavanceerde instellingen", "TriggerGc": "Garbagecollector uitvoeren", "Crash": "Testcrash", + "ResetAllSettings": "Alle instellingen resetten", "Notification": { "GC": { "Badge": "GC", "Triggered": "Garbage Collection uitgevoerd. Vrijgemaakt geheugen: {0}mb." + }, + "Reset": { + "Badge": "Instellingen", + "Done": "Alle instellingen zijn teruggezet naar de standaardwaarden. Start de client opnieuw om taal en thema volledig toe te passen." } } }, @@ -110,14 +122,31 @@ }, "CrashLogs": { "Title": "Crashlogboeken", + "Action": { + "DumpLink": "Dumplink" + }, "Notification": { "Badge": "Crashlogboeken", "Cleared": "Alle logboeken zijn succesvol gewist.", "Copied": "Crashlogboeken zijn naar het klembord gekopieerd.", + "DumpCopied": "Crashdumplink is naar het klembord gekopieerd.", + "DumpUploadFailed": "Het uploaden van de crashdump is mislukt.", + "DumpUnavailable": "Er is nog geen geuploade dumplink beschikbaar voor dit crashlogboek.", "Empty": "Geen crashlogboeken om te kopieren." } }, - "AddServer": { + "TelemetryConsent": { + "Title": "Help VoiceCraft verbeteren", + "Description": "VoiceCraft kan anonieme telemetrie verzenden zodat we crashes beter begrijpen en de stabiliteit op alle platforms kunnen verbeteren.", + "CollectsTitle": "Wat we verzamelen", + "CollectsBody": "App-versie, platform, OS- en runtime-informatie, architectuur, landinstelling, CPU- en geheugengegevens, plus anonieme opstart- en heartbeat-diagnostiek.", + "NotCollectedTitle": "Wat we niet automatisch verzamelen", + "NotCollectedBody": "We uploaden niet automatisch je serverlijst, tokens, configuratiewaarden of chat-/inhoudsgegevens.", + "DumpBody": "Crashdumps worden alleen geüpload als je zelf expliciet op de dump-linkknop in de crashlogs drukt.", + "Accept": "Accepteren", + "Decline": "Weigeren" + }, + "AddServer": { "Title": "Server toevoegen", "Name": "Naam", "IP": "IP", @@ -214,7 +243,8 @@ "TanhSoft": "Zachte Tanh-clipper" }, "AudioDeviceInfo": { - "Default": "{0} - Standaard" + "Default": "Standaard", + "DefaultDevice": "{0} - Standaard" } }, "ThemesService": { diff --git a/VoiceCraft.Client/VoiceCraft.Client/Locales/pl-PL.json b/VoiceCraft.Client/VoiceCraft.Client/Locales/pl-PL.json index 6dfc3fe0..178bd189 100644 --- a/VoiceCraft.Client/VoiceCraft.Client/Locales/pl-PL.json +++ b/VoiceCraft.Client/VoiceCraft.Client/Locales/pl-PL.json @@ -13,7 +13,8 @@ "Language": "Język", "NotificationDismiss": "Zamykanie powiadomienia (ms)", "DisableNotifications": "Wyłącz powiadomienia", - "HideServerAddresses": "Ukryj adresy serwerów" + "HideServerAddresses": "Ukryj adresy serwerów", + "EnableTelemetry": "Włącz anonimową telemetrię" }, "Appearance": { "Title": "Wygląd", @@ -28,6 +29,9 @@ "Denoisers": "Redukcja szumów", "AutomaticGainControllers": "Automatyczna regulacja wzmocnienia", "EchoCancelers": "Redukcja echa", + "HardwarePreprocessors": null, + "PushToTalk": "Przytrzymaj, aby mówić", + "PlayPushToTalkCue": "Odtwarzaj dźwięki Push To Talk", "MicrophoneTest": { "MicrophoneTest": "Test mikrofonu", "Test": "Test" @@ -58,7 +62,11 @@ }, "Network": { "Title": "Ustawienia sieci", - "PositioningType": "Typ pozycjonowania", + "PositioningType": { + "Title": "Typ pozycjonowania", + "Server": null, + "Client": null + }, "McWssListenIp": "Adres IP nasłuchiwania MCWSS", "McWssHostPort": "Port hosta MCWSS" }, @@ -68,7 +76,6 @@ "CapturePrompt": "Naciśnij klawisz lub przycisk myszy", "SaveBinding": "Zapisz przypisanie", "CancelRebind": "Anuluj", - "PlayPushToTalkCues": "Odtwarzaj dźwięki Push To Talk", "Actions": { "Mute": "Wycisz", "Deafen": "Zagłusz", @@ -79,10 +86,15 @@ "Title": "Ustawienia zaawansowane", "TriggerGc": "Uruchom garbage collector", "Crash": "Test awarii", + "ResetAllSettings": "Zresetuj wszystkie ustawienia", "Notification": { "GC": { "Badge": "GC", "Triggered": "Uruchomiono garbage collection. Zwolniona pamięć: {0}mb." + }, + "Reset": { + "Badge": "Ustawienia", + "Done": "Wszystkie ustawienia zostały przywrócone do domyślnych. Uruchom klienta ponownie, aby w pełni zastosować język i motyw." } } }, @@ -110,14 +122,31 @@ }, "CrashLogs": { "Title": "Logi awarii", + "Action": { + "DumpLink": "Link do zrzutu" + }, "Notification": { "Badge": "Logi awarii", "Cleared": "Pomyślnie wyczyszczono wszystkie logi.", "Copied": "Logi awarii skopiowano do schowka.", + "DumpCopied": "Link do zrzutu awarii został skopiowany do schowka.", + "DumpUploadFailed": "Nie udało się przesłać zrzutu awarii.", + "DumpUnavailable": "Dla tego logu awarii nie ma jeszcze linku do przesłanego zrzutu.", "Empty": "Brak logów awarii do skopiowania." } }, - "AddServer": { + "TelemetryConsent": { + "Title": "Pomóż ulepszyć VoiceCraft", + "Description": "VoiceCraft może wysyłać anonimową telemetrię, abyśmy mogli lepiej rozumieć awarie i poprawiać stabilność na wszystkich platformach.", + "CollectsTitle": "Co zbieramy", + "CollectsBody": "Wersję aplikacji, platformę, informacje o systemie operacyjnym i runtime, architekturę, ustawienia regionalne, dane o CPU i pamięci oraz anonimowe dane diagnostyczne dotyczące uruchomienia i heartbeat.", + "NotCollectedTitle": "Czego nie zbieramy automatycznie", + "NotCollectedBody": "Nie przesyłamy automatycznie listy serwerów, tokenów, wartości konfiguracji ani danych czatu lub treści.", + "DumpBody": "Zrzuty awarii są wysyłane tylko wtedy, gdy samodzielnie naciśniesz przycisk linku do zrzutu w logach awarii.", + "Accept": "Akceptuj", + "Decline": "Odrzuć" + }, + "AddServer": { "Title": "Dodaj serwer", "Name": "Nazwa", "IP": "IP", @@ -214,7 +243,8 @@ "TanhSoft": "Miękki clipper Tanh" }, "AudioDeviceInfo": { - "Default": "{0} - Domyślne" + "Default": "Domyślne", + "DefaultDevice": "{0} - Domyślne" } }, "ThemesService": { diff --git a/VoiceCraft.Client/VoiceCraft.Client/Locales/ru-RU.json b/VoiceCraft.Client/VoiceCraft.Client/Locales/ru-RU.json index fb693789..2b85d743 100644 --- a/VoiceCraft.Client/VoiceCraft.Client/Locales/ru-RU.json +++ b/VoiceCraft.Client/VoiceCraft.Client/Locales/ru-RU.json @@ -13,7 +13,8 @@ "Language": "Язык", "NotificationDismiss": "Скрытие уведомления (мс)", "DisableNotifications": "Отключить уведомления", - "HideServerAddresses": "Скрывать адреса серверов" + "HideServerAddresses": "Скрывать адреса серверов", + "EnableTelemetry": "Включить анонимную телеметрию" }, "Appearance": { "Title": "Внешний вид", @@ -28,6 +29,9 @@ "Denoisers": "Шумоподавители", "AutomaticGainControllers": "Автоматическая регулировка усиления", "EchoCancelers": "Подавление эха", + "HardwarePreprocessors": null, + "PushToTalk": "Удерживай, чтобы говорить", + "PlayPushToTalkCue": "Проигрывать звуки Push To Talk", "MicrophoneTest": { "MicrophoneTest": "Проверка микрофона", "Test": "Тест" @@ -58,7 +62,11 @@ }, "Network": { "Title": "Сетевые настройки", - "PositioningType": "Тип позиционирования", + "PositioningType": { + "Title": "Тип позиционирования", + "Server": null, + "Client": null + }, "McWssListenIp": "IP-адрес прослушивания MCWSS", "McWssHostPort": "Порт хоста MCWSS" }, @@ -68,7 +76,6 @@ "CapturePrompt": "Нажми клавишу или кнопку мыши", "SaveBinding": "Сохранить", "CancelRebind": "Отмена", - "PlayPushToTalkCues": "Проигрывать звуки Push To Talk", "Actions": { "Mute": "Выключить микрофон", "Deafen": "Оглушить", @@ -79,10 +86,15 @@ "Title": "Расширенные настройки", "TriggerGc": "Запустить сборщик мусора", "Crash": "Тестовый сбой", + "ResetAllSettings": "Сбросить все настройки", "Notification": { "GC": { "Badge": "GC", "Triggered": "Сборка мусора запущена. Очищено памяти: {0}mb." + }, + "Reset": { + "Badge": "Настройки", + "Done": "Все настройки сброшены к значениям по умолчанию. Перезапустите клиент, чтобы тема и язык применились полностью." } } }, @@ -110,13 +122,30 @@ }, "CrashLogs": { "Title": "Журналы сбоев", + "Action": { + "DumpLink": "Ссылка на дамп" + }, "Notification": { "Badge": "Журналы сбоев", "Cleared": "Все журналы успешно очищены.", "Copied": "Журналы сбоев скопированы в буфер обмена.", + "DumpCopied": "Ссылка на дамп сбоя скопирована в буфер обмена.", + "DumpUploadFailed": "Не удалось загрузить дамп сбоя.", + "DumpUnavailable": "Для этого журнала сбоя ещё нет ссылки на загруженный дамп.", "Empty": "Нет журналов сбоев для копирования." } }, + "TelemetryConsent": { + "Title": "Помогите улучшить VoiceCraft", + "Description": "VoiceCraft может отправлять анонимную телеметрию, чтобы мы лучше понимали падения и улучшали стабильность на всех платформах.", + "CollectsTitle": "Что мы собираем", + "CollectsBody": "Версию приложения, платформу, информацию об ОС и рантайме, архитектуру, локаль, данные о CPU и памяти, а также анонимные события запуска и heartbeat.", + "NotCollectedTitle": "Что мы не собираем автоматически", + "NotCollectedBody": "Мы не загружаем автоматически ваш список серверов, токены, значения конфигов или содержимое чатов и данных.", + "DumpBody": "Краш-дампы загружаются только когда вы сами нажимаете кнопку ссылки на дамп в журнале сбоев.", + "Accept": "Принять", + "Decline": "Отклонить" + }, "AddServer": { "Title": "Добавить сервер", "Name": "Название", @@ -214,7 +243,8 @@ "TanhSoft": "Мягкий клиппер Tanh" }, "AudioDeviceInfo": { - "Default": "{0} - По умолчанию" + "Default": "По умолчанию", + "DefaultDevice": "{0} - По умолчанию" } }, "ThemesService": { diff --git a/VoiceCraft.Client/VoiceCraft.Client/Locales/zh-CN.json b/VoiceCraft.Client/VoiceCraft.Client/Locales/zh-CN.json index 5837467b..958bb3f4 100644 --- a/VoiceCraft.Client/VoiceCraft.Client/Locales/zh-CN.json +++ b/VoiceCraft.Client/VoiceCraft.Client/Locales/zh-CN.json @@ -13,7 +13,8 @@ "Language": "语言", "NotificationDismiss": "通知消失时间(毫秒)", "DisableNotifications": "禁用通知", - "HideServerAddresses": "隐藏服务器地址" + "HideServerAddresses": "隐藏服务器地址", + "EnableTelemetry": "启用匿名遥测" }, "Appearance": { "Title": "外观", @@ -28,6 +29,9 @@ "Denoisers": "降噪器", "AutomaticGainControllers": "自动增益控制器", "EchoCancelers": "回声消除器", + "HardwarePreprocessors": null, + "PushToTalk": "按住说话", + "PlayPushToTalkCue": "播放按住说话提示音", "MicrophoneTest": { "MicrophoneTest": "麦克风测试", "Test": "测试" @@ -58,7 +62,11 @@ }, "Network": { "Title": "网络设置", - "PositioningType": "定位类型", + "PositioningType": { + "Title": "定位类型", + "Server": null, + "Client": null + }, "McWssListenIp": "MCWSS 监听 IP 地址", "McWssHostPort": "MCWSS 主机端口" }, @@ -68,7 +76,6 @@ "CapturePrompt": "按下按键或鼠标按钮", "SaveBinding": "保存绑定", "CancelRebind": "取消", - "PlayPushToTalkCues": "播放按住说话提示音", "Actions": { "Mute": "静音", "Deafen": "拒听", @@ -79,10 +86,15 @@ "Title": "高级设置", "TriggerGc": "触发垃圾回收", "Crash": "测试崩溃", + "ResetAllSettings": "重置所有设置", "Notification": { "GC": { "Badge": "GC", "Triggered": "已触发垃圾回收。清理的内存: {0}mb。" + }, + "Reset": { + "Badge": "设置", + "Done": "所有设置已重置为默认值。请重新启动客户端以完整应用语言和主题更改。" } } }, @@ -110,14 +122,31 @@ }, "CrashLogs": { "Title": "崩溃日志", + "Action": { + "DumpLink": "转储链接" + }, "Notification": { "Badge": "崩溃日志", "Cleared": "已成功清除所有日志。", "Copied": "崩溃日志已复制到剪贴板。", + "DumpCopied": "崩溃转储链接已复制到剪贴板。", + "DumpUploadFailed": "上传崩溃转储失败。", + "DumpUnavailable": "此崩溃日志暂时还没有可用的已上传转储链接。", "Empty": "没有可复制的崩溃日志。" } }, - "AddServer": { + "TelemetryConsent": { + "Title": "帮助改进 VoiceCraft", + "Description": "VoiceCraft 可以发送匿名遥测,以便我们了解崩溃情况并提升所有平台上的稳定性。", + "CollectsTitle": "我们收集什么", + "CollectsBody": "应用版本、平台、操作系统和运行时信息、架构、区域设置、CPU 和内存信息,以及匿名的启动和心跳诊断数据。", + "NotCollectedTitle": "我们不会自动收集什么", + "NotCollectedBody": "我们不会自动上传你的服务器列表、令牌、配置值或聊天与内容数据。", + "DumpBody": "只有当你在崩溃日志中明确点击转储链接按钮时,崩溃转储才会被上传。", + "Accept": "接受", + "Decline": "拒绝" + }, + "AddServer": { "Title": "添加服务器", "Name": "名称", "IP": "IP", @@ -214,7 +243,8 @@ "TanhSoft": "Tanh 软限幅器" }, "AudioDeviceInfo": { - "Default": "{0} - 默认" + "Default": "默认", + "DefaultDevice": "{0} - 默认" } }, "ThemesService": { diff --git a/VoiceCraft.Client/VoiceCraft.Client/Locales/zh-TW.json b/VoiceCraft.Client/VoiceCraft.Client/Locales/zh-TW.json index 32d1e607..8d465317 100644 --- a/VoiceCraft.Client/VoiceCraft.Client/Locales/zh-TW.json +++ b/VoiceCraft.Client/VoiceCraft.Client/Locales/zh-TW.json @@ -13,7 +13,8 @@ "Language": "語言", "NotificationDismiss": "通知消失時間(毫秒)", "DisableNotifications": "停用通知", - "HideServerAddresses": "隱藏伺服器地址" + "HideServerAddresses": "隱藏伺服器地址", + "EnableTelemetry": "啟用匿名遙測" }, "Appearance": { "Title": "外觀", @@ -28,6 +29,9 @@ "Denoisers": "降噪器", "AutomaticGainControllers": "自動增益控制器", "EchoCancelers": "回聲消除器", + "HardwarePreprocessors": null, + "PushToTalk": "按住說話", + "PlayPushToTalkCue": "播放按住說話提示音", "MicrophoneTest": { "MicrophoneTest": "麥克風測試", "Test": "測試" @@ -58,7 +62,11 @@ }, "Network": { "Title": "網路設定", - "PositioningType": "定位類型", + "PositioningType": { + "Title": "定位類型", + "Server": null, + "Client": null + }, "McWssListenIp": "MCWSS 監聽 IP 位址", "McWssHostPort": "MCWSS 主機連接埠" }, @@ -68,7 +76,6 @@ "CapturePrompt": "按下按鍵或滑鼠按鈕", "SaveBinding": "儲存綁定", "CancelRebind": "取消", - "PlayPushToTalkCues": "播放按住說話提示音", "Actions": { "Mute": "靜音", "Deafen": "拒聽", @@ -79,10 +86,15 @@ "Title": "進階設定", "TriggerGc": "觸發垃圾回收", "Crash": "測試當機", + "ResetAllSettings": "重設所有設定", "Notification": { "GC": { "Badge": "GC", "Triggered": "已觸發垃圾回收。清理的記憶體: {0}mb。" + }, + "Reset": { + "Badge": "設定", + "Done": "所有設定已重設為預設值。請重新啟動客戶端,以完整套用語言與主題變更。" } } }, @@ -110,13 +122,30 @@ }, "CrashLogs": { "Title": "當機記錄", + "Action": { + "DumpLink": "傾印連結" + }, "Notification": { "Badge": "當機記錄", "Cleared": "已成功清除所有記錄。", "Copied": "當機記錄已複製到剪貼簿。", + "DumpCopied": "當機傾印連結已複製到剪貼簿。", + "DumpUploadFailed": "上傳當機傾印失敗。", + "DumpUnavailable": "這筆當機記錄目前還沒有可用的已上傳傾印連結。", "Empty": "沒有可複製的當機記錄。" } }, + "TelemetryConsent": { + "Title": "協助改進 VoiceCraft", + "Description": "VoiceCraft 可以傳送匿名遙測,讓我們更了解當機情況並提升所有平台上的穩定性。", + "CollectsTitle": "我們會收集什麼", + "CollectsBody": "應用程式版本、平台、作業系統與執行階段資訊、架構、地區設定、CPU 與記憶體資訊,以及匿名的啟動與 heartbeat 診斷資料。", + "NotCollectedTitle": "我們不會自動收集什麼", + "NotCollectedBody": "我們不會自動上傳你的伺服器清單、權杖、設定值或聊天與內容資料。", + "DumpBody": "只有當你在當機記錄中明確按下傾印連結按鈕時,當機傾印才會上傳。", + "Accept": "接受", + "Decline": "拒絕" + }, "AddServer": { "Title": "新增伺服器", "Name": "名稱", @@ -214,7 +243,8 @@ "TanhSoft": "Tanh 軟限幅器" }, "AudioDeviceInfo": { - "Default": "{0} - 預設" + "Default": "預設", + "DefaultDevice": "{0} - 預設" } }, "ThemesService": { diff --git a/VoiceCraft.Client/VoiceCraft.Client/Models/EntityDataSettingsNavigationData.cs b/VoiceCraft.Client/VoiceCraft.Client/Models/EntityDataSettingsNavigationData.cs new file mode 100644 index 00000000..6ea62641 --- /dev/null +++ b/VoiceCraft.Client/VoiceCraft.Client/Models/EntityDataSettingsNavigationData.cs @@ -0,0 +1,5 @@ +using VoiceCraft.Client.ViewModels.Data; + +namespace VoiceCraft.Client.Models; + +public record EntityDataSettingsNavigationData(EntityDataViewModel Entity); \ No newline at end of file diff --git a/VoiceCraft.Client/VoiceCraft.Client/Models/HotKeyCaptureNavigationData.cs b/VoiceCraft.Client/VoiceCraft.Client/Models/HotKeyCaptureNavigationData.cs new file mode 100644 index 00000000..5c7a5161 --- /dev/null +++ b/VoiceCraft.Client/VoiceCraft.Client/Models/HotKeyCaptureNavigationData.cs @@ -0,0 +1,5 @@ +using VoiceCraft.Client.ViewModels.Data; + +namespace VoiceCraft.Client.Models; + +public record HotKeyCaptureNavigationData(HotKeyActionDataViewModel HotKey); \ No newline at end of file diff --git a/VoiceCraft.Client/VoiceCraft.Client/Models/Settings/HotKeySettings.cs b/VoiceCraft.Client/VoiceCraft.Client/Models/Settings/HotKeySettings.cs index dd7bf4c8..ae95c309 100644 --- a/VoiceCraft.Client/VoiceCraft.Client/Models/Settings/HotKeySettings.cs +++ b/VoiceCraft.Client/VoiceCraft.Client/Models/Settings/HotKeySettings.cs @@ -6,24 +6,22 @@ namespace VoiceCraft.Client.Models.Settings; public class HotKeySettings : Setting { - private Dictionary _bindings = new(); - public Dictionary Bindings { - get => _bindings; + get; set { - _bindings = value; + field = value; OnUpdated?.Invoke(this); } - } + } = new(); public override event Action? OnUpdated; public override object Clone() { var clone = (HotKeySettings)MemberwiseClone(); - clone.Bindings = new Dictionary(_bindings, StringComparer.Ordinal); + clone.Bindings = new Dictionary(Bindings, StringComparer.Ordinal); clone.OnUpdated = null; return clone; } diff --git a/VoiceCraft.Client/VoiceCraft.Client/Models/Settings/InputSettings.cs b/VoiceCraft.Client/VoiceCraft.Client/Models/Settings/InputSettings.cs index 06974d08..fe67bb41 100644 --- a/VoiceCraft.Client/VoiceCraft.Client/Models/Settings/InputSettings.cs +++ b/VoiceCraft.Client/VoiceCraft.Client/Models/Settings/InputSettings.cs @@ -5,100 +5,99 @@ namespace VoiceCraft.Client.Models.Settings; public class InputSettings : Setting { - private string _inputDevice = "Default"; - private float _inputVolume = 1.0f; - private float _microphoneSensitivity = 0.04f; - - private Guid _automaticGainController = Guid.Empty; - private Guid _denoiser = Guid.Empty; - private Guid _echoCanceler = Guid.Empty; - - private bool _pushToTalkEnabled; - private bool _pushToTalkCue = true; - public string InputDevice { - get => _inputDevice; + get; set { - _inputDevice = value; + field = value; OnUpdated?.Invoke(this); } - } + } = "Default"; public float InputVolume { - get => _inputVolume; + get; set { - if(value is > 2 or < 0) + if (value is > 2 or < 0) throw new ArgumentException("Settings.Input.Validation.InputVolume"); - _inputVolume = value; + field = value; OnUpdated?.Invoke(this); } - } + } = 1.0f; public float MicrophoneSensitivity { - get => _microphoneSensitivity; + get; set { if (value is > 1 or < 0) throw new ArgumentException("Settings.Input.Validation.MicrophonesSensitivity"); - _microphoneSensitivity = value; + field = value; OnUpdated?.Invoke(this); } - } - + } = 0.04f; + public Guid AutomaticGainController { - get => _automaticGainController; + get; set { - _automaticGainController = value; + field = value; OnUpdated?.Invoke(this); } - } + } = Guid.Empty; public Guid Denoiser { - get => _denoiser; + get; set { - _denoiser = value; + field = value; OnUpdated?.Invoke(this); } - } + } = Guid.Empty; public Guid EchoCanceler { - get => _echoCanceler; + get; set { - _echoCanceler = value; + field = value; OnUpdated?.Invoke(this); } - } + } = Guid.Empty; + + public bool HardwarePreprocessorsEnabled + { + get; + set + { + field = value; + OnUpdated?.Invoke(this); + } + } = true; public bool PushToTalkEnabled { - get => _pushToTalkEnabled; + get; set { - _pushToTalkEnabled = value; + field = value; OnUpdated?.Invoke(this); } } public bool PushToTalkCue { - get => _pushToTalkCue; + get; set { - _pushToTalkCue = value; + field = value; OnUpdated?.Invoke(this); } - } + } = true; public override event Action? OnUpdated; @@ -108,4 +107,4 @@ public override object Clone() clone.OnUpdated = null; return clone; } -} \ No newline at end of file +} diff --git a/VoiceCraft.Client/VoiceCraft.Client/Models/Settings/LocaleSettings.cs b/VoiceCraft.Client/VoiceCraft.Client/Models/Settings/LocaleSettings.cs index 28271b61..cf55ad5e 100644 --- a/VoiceCraft.Client/VoiceCraft.Client/Models/Settings/LocaleSettings.cs +++ b/VoiceCraft.Client/VoiceCraft.Client/Models/Settings/LocaleSettings.cs @@ -8,19 +8,17 @@ namespace VoiceCraft.Client.Models.Settings; public class LocaleSettings : Setting { - private string _culture = Localizer.Languages.Contains(CultureInfo.CurrentCulture.Name) - ? CultureInfo.CurrentCulture.Name - : Constants.DefaultLanguage; - public string Culture { - get => _culture; + get; set { - _culture = value; + field = value; OnUpdated?.Invoke(this); } - } + } = Localizer.Languages.Contains(CultureInfo.CurrentCulture.Name) + ? CultureInfo.CurrentCulture.Name + : Constants.DefaultLanguage; public override event Action? OnUpdated; diff --git a/VoiceCraft.Client/VoiceCraft.Client/Models/Settings/NetworkSettings.cs b/VoiceCraft.Client/VoiceCraft.Client/Models/Settings/NetworkSettings.cs index 3ede4316..952f6604 100644 --- a/VoiceCraft.Client/VoiceCraft.Client/Models/Settings/NetworkSettings.cs +++ b/VoiceCraft.Client/VoiceCraft.Client/Models/Settings/NetworkSettings.cs @@ -6,39 +6,35 @@ namespace VoiceCraft.Client.Models.Settings; public class NetworkSettings : Setting { - private ushort _mcWssHostPort = 8080; - private string _mcWssListenIp = "127.0.0.1"; - private PositioningType _positioningType = PositioningType.Server; - public PositioningType PositioningType { - get => _positioningType; + get; set { - _positioningType = value; + field = value; OnUpdated?.Invoke(this); } - } + } = PositioningType.Server; public string McWssListenIp { - get => _mcWssListenIp; + get; set { - _mcWssListenIp = value; + field = value; OnUpdated?.Invoke(this); } - } + } = "127.0.0.1"; public ushort McWssHostPort { - get => _mcWssHostPort; + get; set { - _mcWssHostPort = value; + field = value; OnUpdated?.Invoke(this); } - } + } = 8080; public override event Action? OnUpdated; diff --git a/VoiceCraft.Client/VoiceCraft.Client/Models/Settings/NotificationSettings.cs b/VoiceCraft.Client/VoiceCraft.Client/Models/Settings/NotificationSettings.cs index d4adfc3a..22ae5493 100644 --- a/VoiceCraft.Client/VoiceCraft.Client/Models/Settings/NotificationSettings.cs +++ b/VoiceCraft.Client/VoiceCraft.Client/Models/Settings/NotificationSettings.cs @@ -5,28 +5,25 @@ namespace VoiceCraft.Client.Models.Settings; public class NotificationSettings : Setting { - private bool _disableNotifications; - private ushort _dismissDelayMs = 2000; - public bool DisableNotifications { - get => _disableNotifications; + get; set { - _disableNotifications = value; + field = value; OnUpdated?.Invoke(this); } } public ushort DismissDelayMs { - get => _dismissDelayMs; + get; set { - _dismissDelayMs = value; + field = value; OnUpdated?.Invoke(this); } - } + } = 2000; public override event Action? OnUpdated; diff --git a/VoiceCraft.Client/VoiceCraft.Client/Models/Settings/OutputSettings.cs b/VoiceCraft.Client/VoiceCraft.Client/Models/Settings/OutputSettings.cs index 72fa3c82..759b7237 100644 --- a/VoiceCraft.Client/VoiceCraft.Client/Models/Settings/OutputSettings.cs +++ b/VoiceCraft.Client/VoiceCraft.Client/Models/Settings/OutputSettings.cs @@ -6,41 +6,37 @@ namespace VoiceCraft.Client.Models.Settings; public class OutputSettings : Setting { - private string _outputDevice = "Default"; - private float _outputVolume = 1.0f; - private Guid _audioClipper = Constants.TanhSoftAudioClipperGuid; //Set as default on initialize. - public string OutputDevice { - get => _outputDevice; + get; set { - _outputDevice = value; + field = value; OnUpdated?.Invoke(this); } - } + } = "Default"; public float OutputVolume { - get => _outputVolume; + get; set { if (value is > 2 or < 0) throw new ArgumentException("Settings.Input.Validation.OutputVolume"); - _outputVolume = value; + field = value; OnUpdated?.Invoke(this); } - } + } = 1.0f; public Guid AudioClipper { - get => _audioClipper; + get; set { - _audioClipper = value; + field = value; OnUpdated?.Invoke(this); } - } + } = Constants.TanhSoftAudioClipperGuid; public override event Action? OnUpdated; diff --git a/VoiceCraft.Client/VoiceCraft.Client/Models/Settings/ServersSettings.cs b/VoiceCraft.Client/VoiceCraft.Client/Models/Settings/ServersSettings.cs index 35e7f2c6..18feae88 100644 --- a/VoiceCraft.Client/VoiceCraft.Client/Models/Settings/ServersSettings.cs +++ b/VoiceCraft.Client/VoiceCraft.Client/Models/Settings/ServersSettings.cs @@ -7,15 +7,14 @@ namespace VoiceCraft.Client.Models.Settings; public class ServersSettings : Setting { - private bool _hideServerAddresses; private List _servers = []; public bool HideServerAddresses { - get => _hideServerAddresses; + get; set { - _hideServerAddresses = value; + field = value; OnUpdated?.Invoke(this); } } @@ -73,46 +72,42 @@ public class Server : Setting { public const int NameLimit = 12; public const int IpLimit = 30; - private string _ip = string.Empty; - - private string _name = string.Empty; - private ushort _port = 9050; public string Name { - get => _name; + get; set { if (value.Length > NameLimit) throw new ArgumentException($"Settings.Servers.Validation.NameLimit:{NameLimit}"); - _name = value; + field = value; OnUpdated?.Invoke(this); } - } + } = string.Empty; public string Ip { - get => _ip; + get; set { if (value.Length > IpLimit) throw new ArgumentException($"Settings.Servers.Validation.IpLimit:{IpLimit}"); - _ip = value; + field = value; OnUpdated?.Invoke(this); } - } + } = string.Empty; public ushort Port { - get => _port; + get; set { if (value < 1) throw new ArgumentException("Settings.Servers.Validation.Port"); - _port = value; + field = value; OnUpdated?.Invoke(this); } - } + } = 9050; public override event Action? OnUpdated; diff --git a/VoiceCraft.Client/VoiceCraft.Client/Models/Settings/TelemetrySettings.cs b/VoiceCraft.Client/VoiceCraft.Client/Models/Settings/TelemetrySettings.cs new file mode 100644 index 00000000..31b0be3f --- /dev/null +++ b/VoiceCraft.Client/VoiceCraft.Client/Models/Settings/TelemetrySettings.cs @@ -0,0 +1,36 @@ +using System; +using VoiceCraft.Client.Services; + +namespace VoiceCraft.Client.Models.Settings; + +public class TelemetrySettings : Setting +{ + public bool Enabled + { + get; + set + { + field = value; + OnUpdated?.Invoke(this); + } + } = true; + + public bool ConsentShown + { + get; + set + { + field = value; + OnUpdated?.Invoke(this); + } + } + + public override event Action? OnUpdated; + + public override object Clone() + { + var clone = (TelemetrySettings)MemberwiseClone(); + clone.OnUpdated = null; + return clone; + } +} diff --git a/VoiceCraft.Client/VoiceCraft.Client/Models/Settings/ThemeSettings.cs b/VoiceCraft.Client/VoiceCraft.Client/Models/Settings/ThemeSettings.cs index 8de192de..a89340fa 100644 --- a/VoiceCraft.Client/VoiceCraft.Client/Models/Settings/ThemeSettings.cs +++ b/VoiceCraft.Client/VoiceCraft.Client/Models/Settings/ThemeSettings.cs @@ -6,28 +6,25 @@ namespace VoiceCraft.Client.Models.Settings; public class ThemeSettings : Setting { - private Guid _selectedBackgroundImage = Constants.DockNightGuid; - private Guid _selectedTheme = Constants.DarkThemeGuid; - public Guid SelectedBackgroundImage { - get => _selectedBackgroundImage; + get; set { - _selectedBackgroundImage = value; + field = value; OnUpdated?.Invoke(this); } - } + } = Constants.DockNightGuid; public Guid SelectedTheme { - get => _selectedTheme; + get; set { - _selectedTheme = value; + field = value; OnUpdated?.Invoke(this); } - } + } = Constants.DarkThemeGuid; public override event Action? OnUpdated; diff --git a/VoiceCraft.Client/VoiceCraft.Client/Models/Settings/UserSetting.cs b/VoiceCraft.Client/VoiceCraft.Client/Models/Settings/UserSetting.cs index b7ec0453..e7572b84 100644 --- a/VoiceCraft.Client/VoiceCraft.Client/Models/Settings/UserSetting.cs +++ b/VoiceCraft.Client/VoiceCraft.Client/Models/Settings/UserSetting.cs @@ -5,25 +5,22 @@ namespace VoiceCraft.Client.Models.Settings; public class UserSetting : Setting { - private bool _userMuted; - private float _volume = 1f; - public float Volume { - get => _volume; + get; set { - _volume = value; + field = value; OnUpdated?.Invoke(this); } - } + } = 1f; public bool UserMuted { - get => _userMuted; + get; set { - _userMuted = value; + field = value; OnUpdated?.Invoke(this); } } diff --git a/VoiceCraft.Client/VoiceCraft.Client/Models/Settings/UserSettings.cs b/VoiceCraft.Client/VoiceCraft.Client/Models/Settings/UserSettings.cs index 4fdf8171..0c2a9788 100644 --- a/VoiceCraft.Client/VoiceCraft.Client/Models/Settings/UserSettings.cs +++ b/VoiceCraft.Client/VoiceCraft.Client/Models/Settings/UserSettings.cs @@ -6,17 +6,15 @@ namespace VoiceCraft.Client.Models.Settings; public class UserSettings : Setting { - private Dictionary _users = new(); - public Dictionary Users { - get => _users; + get; set { - _users = value; + field = value; OnUpdated?.Invoke(this); } - } + } = new(); public override event Action? OnUpdated; @@ -24,7 +22,7 @@ public override object Clone() { var clone = (UserSettings)MemberwiseClone(); clone.Users = new Dictionary(); - foreach (var user in _users) + foreach (var user in Users) { var clonedEntity = (UserSetting)user.Value.Clone(); clone.Users.TryAdd(user.Key, clonedEntity); diff --git a/VoiceCraft.Client/VoiceCraft.Client/Servers/McWssServer.cs b/VoiceCraft.Client/VoiceCraft.Client/Servers/McWssServer.cs index c774d03f..f3af035f 100644 --- a/VoiceCraft.Client/VoiceCraft.Client/Servers/McWssServer.cs +++ b/VoiceCraft.Client/VoiceCraft.Client/Servers/McWssServer.cs @@ -75,7 +75,10 @@ private static void SendEventSubscribe(IWebSocketConnection socket, string event private void OnClientConnected(IWebSocketConnection socket) { if (_peerConnection != null) + { socket.Close(); //Full. + return; + } _customEventTriggered = false; _peerConnection = socket; @@ -225,4 +228,4 @@ private void HandleLocalPlayerUpdatedEvent(McWssLocalPlayerUpdatedEvent localPla client.CaveFactor = caveFactor; client.MuffleFactor = muffleFactor; } -} \ No newline at end of file +} diff --git a/VoiceCraft.Client/VoiceCraft.Client/Services/AudioService.cs b/VoiceCraft.Client/VoiceCraft.Client/Services/AudioService.cs index 5f201a75..0144b329 100644 --- a/VoiceCraft.Client/VoiceCraft.Client/Services/AudioService.cs +++ b/VoiceCraft.Client/VoiceCraft.Client/Services/AudioService.cs @@ -62,14 +62,15 @@ public IEnumerable GetInputDevices() { defaultDevice = new AudioDeviceInfo( "Default", - Localizer.Get($"AudioService.AudioDeviceInfo.Default:{device.Name}"), + Localizer.Get($"AudioService.AudioDeviceInfo.DefaultDevice:{device.Name}"), true); } devices.Add(new AudioDeviceInfo(device.Name, device.Name, false)); } - devices.Insert(0, defaultDevice ?? new AudioDeviceInfo("Default", "Default", true)); + devices.Insert(0, + defaultDevice ?? new AudioDeviceInfo("Default", "AudioService.AudioDeviceInfo.Default", true)); return devices; } @@ -84,18 +85,24 @@ public IEnumerable GetOutputDevices() { defaultDevice = new AudioDeviceInfo( "Default", - Localizer.Get($"AudioService.AudioDeviceInfo.Default:{device.Name}"), + Localizer.Get($"AudioService.AudioDeviceInfo.DefaultDevice:{device.Name}"), true); } devices.Add(new AudioDeviceInfo(device.Name, device.Name, false)); } - - devices.Insert(0, defaultDevice ?? new AudioDeviceInfo("Default", "Default", true)); + + devices.Insert(0, + defaultDevice ?? new AudioDeviceInfo("Default", "AudioService.AudioDeviceInfo.Default", true)); return devices; } - public AudioCaptureDevice InitializeCaptureDevice(int sampleRate, int channels, uint frameSize, string inputDevice) + public AudioCaptureDevice InitializeCaptureDevice( + int sampleRate, + int channels, + uint frameSize, + string inputDevice, + bool hardwarePreprocessorsEnabled) { _engine.UpdateAudioDevicesInfo(); var format = new AudioFormat() @@ -110,11 +117,15 @@ public AudioCaptureDevice InitializeCaptureDevice(int sampleRate, int channels, AAudio = new AAudioSettings() { Usage = AAudioUsage.VoiceCommunication, - InputPreset = AAudioInputPreset.VoiceCommunication + InputPreset = hardwarePreprocessorsEnabled + ? AAudioInputPreset.VoiceCommunication + : AAudioInputPreset.Default }, OpenSL = new OpenSlSettings() { - RecordingPreset = OpenSlRecordingPreset.VoiceCommunication + RecordingPreset = hardwarePreprocessorsEnabled + ? OpenSlRecordingPreset.VoiceCommunication + : OpenSlRecordingPreset.Default }, CoreAudio = new CoreAudioSettings() { diff --git a/VoiceCraft.Client/VoiceCraft.Client/Services/ClientTelemetryService.cs b/VoiceCraft.Client/VoiceCraft.Client/Services/ClientTelemetryService.cs new file mode 100644 index 00000000..9c10fd4a --- /dev/null +++ b/VoiceCraft.Client/VoiceCraft.Client/Services/ClientTelemetryService.cs @@ -0,0 +1,257 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Reflection; +using System.Runtime.InteropServices; +using System.Threading.Tasks; +using Microsoft.Maui.Devices; +using VoiceCraft.Core; +using VoiceCraft.Core.Telemetry; + +namespace VoiceCraft.Client.Services; + +public sealed class ClientTelemetryService(SettingsService settingsService) +{ + private const int MaxCrashLogLength = 250_000; + private readonly TelemetryTransport _transport = new(); + private bool IsEnabled => + settingsService.TelemetrySettings is { Enabled: true, ConsentShown: true }; + + public async Task ReportStartupAsync() + { + await ReportStartupAsync(3); + } + + public async Task ReportCrashAsync(string crashText, string? title = null) + { + if (!IsEnabled) + return null; + + var trimmedCrashText = TrimCrashText(crashText, out var wasTrimmed); + var payload = new TelemetryDumpRequest + { + Role = "client", + Category = "crash", + Title = string.IsNullOrWhiteSpace(title) ? "CrashLog" : title, + App = BuildAppInfo(), + Device = BuildDeviceInfo(), + Payload = new Dictionary + { + ["crash_log"] = trimmedCrashText, + ["truncated"] = wasTrimmed.ToString() + } + }; + + try + { + return await _transport.SendDumpAsync(payload); + } + catch(Exception ex) + { + LogService.Log(ex); + return null; + } + } + + private async Task ReportStartupAsync(int attempts) + { + if (!IsEnabled) + return; + + var payload = new TelemetryEventRequest + { + Fingerprint = settingsService.TelemetryToken, + Role = "client", + App = BuildAppInfo(), + Device = BuildDeviceInfo(), + Metrics = new Dictionary + { + ["positioning_type"] = settingsService.NetworkSettings.PositioningType.ToString(), + ["push_to_talk_enabled"] = settingsService.InputSettings.PushToTalkEnabled.ToString() + }, + Tags = ["startup"], + Timestamp = DateTime.UtcNow.ToString("O") + }; + + var retries = Math.Max(1, attempts); + for (var attempt = 0; attempt < retries; attempt++) + { + try + { + await _transport.SendTelemetryAsync(payload); + } + catch(Exception ex) + { + LogService.Log(ex); + if (attempt < retries - 1) + await Task.Delay(1500); + } + } + } + + private static TelemetryAppInfo BuildAppInfo() + { + return new TelemetryAppInfo + { + AppName = "VoiceCraft", + Version = ResolveVersion(), + Channel = ResolveChannel(), + Build = ResolveBuild() + }; + } + + private static TelemetryDeviceInfo BuildDeviceInfo() + { + var deviceInfo = TryGetDeviceInfo(); + var totalAvailableBytes = GC.GetGCMemoryInfo().TotalAvailableMemoryBytes; + long? memoryMb = totalAvailableBytes > 0 ? totalAvailableBytes / (1024 * 1024) : null; + + return new TelemetryDeviceInfo + { + OsName = GetPlatformName(deviceInfo), + OsVersion = GetOsVersion(deviceInfo), + OsBuild = GetOsBuild(deviceInfo), + OsDescription = GetOsDescription(deviceInfo), + Vendor = NormalizeValue(deviceInfo?.Manufacturer), + Model = NormalizeValue(deviceInfo?.Model), + Architecture = RuntimeInformation.OSArchitecture.ToString().ToLowerInvariant(), + ProcessArchitecture = RuntimeInformation.ProcessArchitecture.ToString().ToLowerInvariant(), + Runtime = RuntimeInformation.FrameworkDescription, + Locale = CultureInfo.CurrentUICulture.Name, + CpuCores = Environment.ProcessorCount, + MemoryMb = memoryMb + }; + } + + private static string ResolveVersion() + { + var version = Assembly.GetEntryAssembly()?.GetName().Version; + return version != null + ? $"{version.Major}.{version.Minor}.{version.Build}" + : $"{Constants.Major}.{Constants.Minor}.{Constants.Patch}"; + } + + private static string ResolveBuild() + { + var informationalVersion = Assembly.GetEntryAssembly()? + .GetCustomAttribute()? + .InformationalVersion; + + return string.IsNullOrWhiteSpace(informationalVersion) ? string.Empty : informationalVersion; + } + + private static string ResolveChannel() + { +#if DEBUG + return "debug"; +#else + return "stable"; +#endif + } + + private static IDeviceInfo? TryGetDeviceInfo() + { + if (!(OperatingSystem.IsAndroid() || OperatingSystem.IsIOS() || OperatingSystem.IsMacOS())) + return null; + + try + { + return DeviceInfo.Current; + } + catch + { + return null; + } + } + + private static string GetPlatformName(IDeviceInfo? deviceInfo) + { + if (deviceInfo?.Platform == DevicePlatform.WinUI) + return IsWindows11(deviceInfo.Version) ? "Windows 11" : "Windows"; + if (deviceInfo?.Platform == DevicePlatform.Android) + return "Android"; + if (deviceInfo?.Platform == DevicePlatform.iOS) + return "iOS"; + if (deviceInfo?.Platform == DevicePlatform.macOS) + return "macOS"; + if (OperatingSystem.IsWindows()) + return IsWindows11(Environment.OSVersion.Version) ? "Windows 11" : "Windows"; + if (OperatingSystem.IsLinux()) + return "Linux"; + return OperatingSystem.IsBrowser() + ? "Browser" + : RuntimeInformation.OSDescription; + } + + private static string GetOsVersion(IDeviceInfo? deviceInfo) + { + if (deviceInfo?.Platform == DevicePlatform.WinUI) + return GetPlatformName(deviceInfo); + if (deviceInfo?.Platform == DevicePlatform.Android) + return $"Android {NormalizeVersionString(deviceInfo.VersionString, deviceInfo.Version)}"; + if (deviceInfo?.Platform == DevicePlatform.iOS) + return $"iOS {NormalizeVersionString(deviceInfo.VersionString, deviceInfo.Version)}"; + if (deviceInfo?.Platform == DevicePlatform.macOS) + return $"macOS {NormalizeVersionString(deviceInfo.VersionString, deviceInfo.Version)}"; + if (OperatingSystem.IsWindows()) + return GetPlatformName(deviceInfo); + + if (deviceInfo == null) return Environment.OSVersion.VersionString; + var version = NormalizeVersionString(deviceInfo.VersionString, deviceInfo.Version); + return !string.IsNullOrWhiteSpace(version) + ? version + : Environment.OSVersion.VersionString; + } + + private static string GetOsBuild(IDeviceInfo? deviceInfo) + { + return deviceInfo is not null + ? deviceInfo.Version.ToString() + : Environment.OSVersion.Version.ToString(); + } + + private static string GetOsDescription(IDeviceInfo? deviceInfo) + { + if (deviceInfo?.Platform == DevicePlatform.WinUI) + return $"{GetPlatformName(deviceInfo)} build {GetOsBuild(deviceInfo)}"; + if (deviceInfo?.Platform == DevicePlatform.Android) + return + $"Android {NormalizeVersionString(deviceInfo.VersionString, deviceInfo.Version)} (API level {deviceInfo.Version.Major})"; + if (deviceInfo?.Platform == DevicePlatform.iOS) + return $"iOS {NormalizeVersionString(deviceInfo.VersionString, deviceInfo.Version)}"; + if (deviceInfo?.Platform == DevicePlatform.macOS) + return $"macOS {NormalizeVersionString(deviceInfo.VersionString, deviceInfo.Version)}"; + return OperatingSystem.IsWindows() + ? $"{GetPlatformName(deviceInfo)} build {Environment.OSVersion.Version}" + : RuntimeInformation.OSDescription; + } + + private static string NormalizeVersionString(string? versionString, Version version) + { + return !string.IsNullOrWhiteSpace(versionString) + ? versionString + : version.ToString(); + } + + private static string? NormalizeValue(string? value) + { + return string.IsNullOrWhiteSpace(value) ? null : value; + } + + private static bool IsWindows11(Version version) + { + return version is { Major: >= 10, Build: >= 22000 }; + } + + private static string TrimCrashText(string crashText, out bool wasTrimmed) + { + if (crashText.Length <= MaxCrashLogLength) + { + wasTrimmed = false; + return crashText; + } + + wasTrimmed = true; + return crashText[..MaxCrashLogLength]; + } +} diff --git a/VoiceCraft.Client/VoiceCraft.Client/Services/ClipboardService.cs b/VoiceCraft.Client/VoiceCraft.Client/Services/ClipboardService.cs new file mode 100644 index 00000000..11b39efb --- /dev/null +++ b/VoiceCraft.Client/VoiceCraft.Client/Services/ClipboardService.cs @@ -0,0 +1,27 @@ +using System; +using System.Threading.Tasks; +using Avalonia.Controls; +using Avalonia.Input.Platform; + +namespace VoiceCraft.Client.Services; + +public class ClipboardService +{ + private IClipboard? _clipboard; + + public void RegisterTopLevel(TopLevel topLevel) + { + _clipboard = topLevel.Clipboard; + } + + public async Task SetTextAsync(string text) + { + if (string.IsNullOrWhiteSpace(text)) + throw new ArgumentException("Clipboard text cannot be empty.", nameof(text)); + + if (_clipboard is null) + throw new InvalidOperationException("Clipboard is not available yet."); + + await _clipboard.SetTextAsync(text); + } +} diff --git a/VoiceCraft.Client/VoiceCraft.Client/Services/HotKeyService.cs b/VoiceCraft.Client/VoiceCraft.Client/Services/HotKeyService.cs index 4d9653cb..3d445d9d 100644 --- a/VoiceCraft.Client/VoiceCraft.Client/Services/HotKeyService.cs +++ b/VoiceCraft.Client/VoiceCraft.Client/Services/HotKeyService.cs @@ -182,48 +182,44 @@ public sealed class HotKeyBinding(HotKeyAction action, string keyCombo) public class MuteAction(IBackgroundService backgroundService) : HotKeyAction { public override string Id => "Mute"; - public override string Title => "Mute"; + public override string Title => "Settings.HotKey.Actions.Mute"; public override string DefaultKeyCombo => "LeftControl\0LeftShift\0M"; public override void Press() { var service = backgroundService.GetService(); - if(service == null) return; - service.Muted = !service.Muted; + service?.Muted = !service.Muted; } } public class DeafenAction(IBackgroundService backgroundService) : HotKeyAction { public override string Id => "Deafen"; - public override string Title => "Deafen"; + public override string Title => "Settings.HotKey.Actions.Deafen"; public override string DefaultKeyCombo => "LeftControl\0LeftShift\0D"; public override void Press() { var service = backgroundService.GetService(); - if(service == null) return; - service.Deafened = !service.Deafened; + service?.Deafened = !service.Deafened; } } public class PushToTalkAction(IBackgroundService backgroundService) : HotKeyAction { public override string Id => "PushToTalk"; - public override string Title => "PushToTalk"; + public override string Title => "Settings.HotKey.Actions.PushToTalk"; public override string DefaultKeyCombo => "LeftControl"; public override void Press() { var service = backgroundService.GetService(); - if (service == null) return; - service.PushToTalk = true; + service?.PushToTalk = true; } public override void Release() { var service = backgroundService.GetService(); - if (service == null) return; - service.PushToTalk = false; + service?.PushToTalk = false; } } diff --git a/VoiceCraft.Client/VoiceCraft.Client/Services/LogService.cs b/VoiceCraft.Client/VoiceCraft.Client/Services/LogService.cs index fa168eff..63085be4 100644 --- a/VoiceCraft.Client/VoiceCraft.Client/Services/LogService.cs +++ b/VoiceCraft.Client/VoiceCraft.Client/Services/LogService.cs @@ -1,11 +1,13 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Text.Json; using System.Text.Json.Serialization; using System.Threading.Tasks; using VoiceCraft.Core; +using VoiceCraft.Core.Diagnostics; namespace VoiceCraft.Client.Services; @@ -20,26 +22,9 @@ public static class LogService public static IEnumerable> ExceptionLogs => _exceptionLogs.ExceptionLogs.OrderByDescending(d => d.Key); - public static IEnumerable> CrashLogs => + public static IEnumerable> CrashLogs => _exceptionLogs.CrashLogs.OrderByDescending(d => d.Key); - public static void Log(Exception exception) - { - Console.WriteLine(exception); - _exceptionLogs.ExceptionLogs.TryAdd(DateTime.UtcNow, exception.ToString()); - TrimExceptionLogs(); - _ = SaveAsync(); - } - - public static void LogCrash(Exception exception) - { - if (exception is TaskCanceledException) return; //Ignore this shit. - Console.WriteLine(exception); - _exceptionLogs.CrashLogs.TryAdd(DateTime.UtcNow, exception.ToString()); - TrimCrashLogs(); - SaveLogs(); - } - public static void Load() { try @@ -48,11 +33,10 @@ public static void Load() return; var result = NativeStorageService?.Load(Constants.ExceptionLogsFile); - var loadedLogs = - JsonSerializer.Deserialize(result, - CrashLogGenerationContext.Default.ExceptionLogsStructure); - if (loadedLogs == null) return; - _exceptionLogs = loadedLogs; + if (result == null) + return; + + if (!TryLoadCurrent(result) && !TryLoadLegacy(result)) return; TrimExceptionLogs(); TrimCrashLogs(); } @@ -61,11 +45,37 @@ public static void Load() Log(ex); //Log it, Don't care what we log. } } + + public static void LogCrash(Exception exception) + { + if (exception is TaskCanceledException) return; //Ignore this shit. + Console.WriteLine(exception); + _exceptionLogs.CrashLogs.TryAdd(DateTime.UtcNow, new CrashLogRecord + { + Message = exception.ToString() + }); + TrimCrashLogs(); + SaveLogs(); + } + + public static void Log(Exception exception) + { + Console.WriteLine(exception); + _exceptionLogs.ExceptionLogs.TryAdd(DateTime.UtcNow, exception.ToString()); + TrimExceptionLogs(); + _ = SaveAsync(); + } + + public static bool TryGetLog(DateTime timeStamp, [NotNullWhen(true)] out string? log) + { + return _exceptionLogs.ExceptionLogs.TryGetValue(timeStamp, out log); + } - public static void ClearCrashLogs() + public static void UpdateLog(DateTime timeStamp, string log) { - _exceptionLogs.CrashLogs.Clear(); - _ = SaveAsync(); //Since we don't to a save immediate, we need to call the save. + if (!_exceptionLogs.ExceptionLogs.ContainsKey(timeStamp)) return; + _exceptionLogs.ExceptionLogs[timeStamp] = log; + _ = SaveAsync(); } public static void ClearExceptionLogs() @@ -74,15 +84,24 @@ public static void ClearExceptionLogs() _ = SaveAsync(); } - private static void TrimExceptionLogs() + public static bool TryGetCrashLog(DateTime timeStamp, [NotNullWhen(true)] out CrashLogRecord? crashLog) { - foreach (var log in _exceptionLogs.ExceptionLogs.OrderBy(d => d.Key)) - { - if (_exceptionLogs.CrashLogs.Count <= Limit) return; - _exceptionLogs.ExceptionLogs.TryRemove(log.Key, out _); - } + return _exceptionLogs.CrashLogs.TryGetValue(timeStamp, out crashLog); + } + + public static void UpdateCrashLog(DateTime timeStamp, CrashLogRecord crashLog) + { + if (!_exceptionLogs.CrashLogs.ContainsKey(timeStamp)) return; + _exceptionLogs.CrashLogs[timeStamp] = crashLog; + _ = SaveAsync(); } + public static void ClearCrashLogs() + { + _exceptionLogs.CrashLogs.Clear(); + _ = SaveAsync(); //Since we don't to a save immediate, we need to call the save. + } + private static void TrimCrashLogs() { foreach (var log in _exceptionLogs.CrashLogs.OrderBy(d => d.Key)) @@ -92,6 +111,46 @@ private static void TrimCrashLogs() } } + private static void TrimExceptionLogs() + { + foreach (var log in _exceptionLogs.ExceptionLogs.OrderBy(d => d.Key)) + { + if (_exceptionLogs.ExceptionLogs.Count <= Limit) return; + _exceptionLogs.ExceptionLogs.TryRemove(log.Key, out _); + } + } + + private static bool TryLoadCurrent(byte[] result) + { + var loadedLogs = JsonSerializer.Deserialize( + result, + CrashLogGenerationContext.Default.ExceptionLogsStructure); + if (loadedLogs == null) + return false; + + _exceptionLogs = loadedLogs; + return true; + } + + private static bool TryLoadLegacy(byte[] result) + { + var loadedLogs = JsonSerializer.Deserialize( + result, + CrashLogGenerationContext.Default.LegacyExceptionLogsStructure); + if (loadedLogs == null) + return false; + + _exceptionLogs = new ExceptionLogsStructure + { + ExceptionLogs = loadedLogs.ExceptionLogs, + CrashLogs = new ConcurrentDictionary( + loadedLogs.CrashLogs.ToDictionary( + x => x.Key, + x => new CrashLogRecord { Message = x.Value })) + }; + return true; + } + private static async Task SaveAsync() { _queueWrite = true; @@ -118,6 +177,12 @@ private static void SaveLogs() } public class ExceptionLogsStructure +{ + public ConcurrentDictionary CrashLogs { get; set; } = new(); + public ConcurrentDictionary ExceptionLogs { get; set; } = new(); +} + +public class LegacyExceptionLogsStructure { public ConcurrentDictionary CrashLogs { get; set; } = new(); public ConcurrentDictionary ExceptionLogs { get; set; } = new(); @@ -125,4 +190,5 @@ public class ExceptionLogsStructure [JsonSourceGenerationOptions(WriteIndented = true)] [JsonSerializable(typeof(ExceptionLogsStructure), GenerationMode = JsonSourceGenerationMode.Metadata)] -public partial class CrashLogGenerationContext : JsonSerializerContext; \ No newline at end of file +[JsonSerializable(typeof(LegacyExceptionLogsStructure), GenerationMode = JsonSourceGenerationMode.Metadata)] +public partial class CrashLogGenerationContext : JsonSerializerContext; diff --git a/VoiceCraft.Client/VoiceCraft.Client/Services/NavigationService.cs b/VoiceCraft.Client/VoiceCraft.Client/Services/NavigationService.cs index e1f6ef4d..4b2352a9 100644 --- a/VoiceCraft.Client/VoiceCraft.Client/Services/NavigationService.cs +++ b/VoiceCraft.Client/VoiceCraft.Client/Services/NavigationService.cs @@ -10,7 +10,9 @@ public sealed class NavigationService(Func createViewModel, { private readonly Lock _lock = new(); private ViewModelBase? _currentViewModel; + private ViewModelBase? _currentModalViewModel; private List _history = []; + private List _modalStack = []; private int _historyIndex = -1; // ReSharper disable once MemberCanBePrivate.Global @@ -38,6 +40,7 @@ public bool HasPrev } public event Action? OnViewModelChanged; + public event Action? OnModalViewModelChanged; private void Push(ViewModelBase item) { @@ -61,6 +64,11 @@ private void Push(ViewModelBase item) _historyIndex = _history.Count - 1; } + private void PushModal(ViewModelBase item) + { + _modalStack.Add(item); + } + private void SetCurrentViewModel(ViewModelBase viewModel, object? data = null) { if (viewModel == _currentViewModel) return; @@ -70,6 +78,27 @@ private void SetCurrentViewModel(ViewModelBase viewModel, object? data = null) OnViewModelChanged?.Invoke(viewModel); } + private void SetCurrentModalViewModel(ViewModelBase? viewModel, object? data = null) + { + if (viewModel == _currentModalViewModel) return; + _currentModalViewModel?.OnDisappearing(); + _currentModalViewModel = viewModel; + _currentModalViewModel?.OnAppearing(data); + OnModalViewModelChanged?.Invoke(viewModel); + } + + private void ClearModalStack() + { + if (_currentModalViewModel != null) + SetCurrentModalViewModel(null); + + foreach (var modal in _modalStack) + if (modal is IDisposable disposable) + disposable.Dispose(); + + _modalStack.Clear(); + } + // ReSharper disable once MemberCanBePrivate.Global public ViewModelBase? Go(int offset = 0, bool checkBackButton = false) { @@ -94,6 +123,12 @@ private void SetCurrentViewModel(ViewModelBase viewModel, object? data = null) public ViewModelBase? Back(bool checkBackButton = false) { + lock (_lock) + { + if (_currentModalViewModel != null) + return PopModal(checkBackButton); + } + return HasPrev ? Go(-1, checkBackButton) : null; } @@ -106,12 +141,45 @@ public void NavigateTo(object? data = null) where T : ViewModelBase { lock (_lock) { + ClearModalStack(); var viewModel = InstantiateViewModel(); SetCurrentViewModel(viewModel, data); Push(viewModel); } } + public void PushModal(object? data = null) where T : ViewModelBase + { + lock (_lock) + { + var viewModel = InstantiateViewModel(); + PushModal(viewModel); + SetCurrentModalViewModel(viewModel, data); + } + } + + public ViewModelBase? PopModal(bool checkBackButton = false) + { + lock (_lock) + { + if (_currentModalViewModel == null) + return null; + + if (checkBackButton && _currentModalViewModel.DisableBackButton) + return _currentModalViewModel; + + var currentModal = _currentModalViewModel; + _modalStack.Remove(currentModal); + var nextModal = _modalStack.LastOrDefault(); + SetCurrentModalViewModel(nextModal); + + if (currentModal is IDisposable disposable) + disposable.Dispose(); + + return nextModal; + } + } + private T InstantiateViewModel() where T : ViewModelBase { return (T)Convert.ChangeType(createViewModel(typeof(T)), typeof(T)); diff --git a/VoiceCraft.Client/VoiceCraft.Client/Services/SettingsService.cs b/VoiceCraft.Client/VoiceCraft.Client/Services/SettingsService.cs index 5aad82b2..1bd61a3d 100644 --- a/VoiceCraft.Client/VoiceCraft.Client/Services/SettingsService.cs +++ b/VoiceCraft.Client/VoiceCraft.Client/Services/SettingsService.cs @@ -24,6 +24,8 @@ public SettingsService(StorageService storageService) // ReSharper disable once InconsistentNaming public Guid UserGuid => _settings.UserGuid; public Guid ServerUserGuid => _settings.ServerUserGuid; + public string TelemetryToken => _settings.TelemetryToken; + public TelemetrySettings TelemetrySettings => _settings.TelemetrySettings; public InputSettings InputSettings => _settings.InputSettings; public OutputSettings OutputSettings => _settings.OutputSettings; public LocaleSettings LocaleSettings => _settings.LocaleSettings; @@ -40,6 +42,12 @@ public async Task SaveImmediate() await SaveSettingsAsync(); } + public async Task ResetToDefaultsAsync() + { + _settings = new SettingsStructure(); + await SaveSettingsAsync(); + } + public async Task SaveAsync() { _queueWrite = true; @@ -59,25 +67,33 @@ public async Task SaveAsync() private void Load() { - if (!_storageService.Exists(Constants.SettingsFile)) return; - - var result = _storageService.Load(Constants.SettingsFile); - var loadedSettings = - JsonSerializer.Deserialize(result, - SettingsStructureGenerationContext.Default.SettingsStructure); - if (loadedSettings == null) return; - - loadedSettings.InputSettings.OnLoading(); - loadedSettings.OutputSettings.OnLoading(); - loadedSettings.LocaleSettings.OnLoading(); - loadedSettings.NotificationSettings.OnLoading(); - loadedSettings.ServersSettings.OnLoading(); - loadedSettings.ThemeSettings.OnLoading(); - loadedSettings.NetworkSettings.OnLoading(); - loadedSettings.UserSettings.OnLoading(); - loadedSettings.HotKeySettings.OnLoading(); - - _settings = loadedSettings; + try + { + if (!_storageService.Exists(Constants.SettingsFile)) return; + + var result = _storageService.Load(Constants.SettingsFile); + var loadedSettings = + JsonSerializer.Deserialize(result, + SettingsStructureGenerationContext.Default.SettingsStructure); + if (loadedSettings == null) return; + + loadedSettings.InputSettings.OnLoading(); + loadedSettings.OutputSettings.OnLoading(); + loadedSettings.LocaleSettings.OnLoading(); + loadedSettings.TelemetrySettings.OnLoading(); + loadedSettings.NotificationSettings.OnLoading(); + loadedSettings.ServersSettings.OnLoading(); + loadedSettings.ThemeSettings.OnLoading(); + loadedSettings.NetworkSettings.OnLoading(); + loadedSettings.UserSettings.OnLoading(); + loadedSettings.HotKeySettings.OnLoading(); + + _settings = loadedSettings; + } + catch (Exception ex) + { + LogService.Log(ex); + } } private async Task SaveSettingsAsync() @@ -85,6 +101,7 @@ private async Task SaveSettingsAsync() InputSettings.OnSaving(); OutputSettings.OnSaving(); LocaleSettings.OnSaving(); + TelemetrySettings.OnSaving(); NotificationSettings.OnSaving(); ServersSettings.OnSaving(); ThemeSettings.OnSaving(); @@ -124,6 +141,8 @@ public class SettingsStructure { public Guid UserGuid { get; set; } = Guid.NewGuid(); public Guid ServerUserGuid { get; set; } = Guid.NewGuid(); + public string TelemetryToken { get; set; } = Guid.NewGuid().ToString("N"); + public TelemetrySettings TelemetrySettings { get; set; } = new(); public InputSettings InputSettings { get; set; } = new(); public OutputSettings OutputSettings { get; set; } = new(); public LocaleSettings LocaleSettings { get; set; } = new(); diff --git a/VoiceCraft.Client/VoiceCraft.Client/Services/ThemesService.cs b/VoiceCraft.Client/VoiceCraft.Client/Services/ThemesService.cs index 9f163e56..c5b121d4 100644 --- a/VoiceCraft.Client/VoiceCraft.Client/Services/ThemesService.cs +++ b/VoiceCraft.Client/VoiceCraft.Client/Services/ThemesService.cs @@ -5,7 +5,6 @@ using System.Linq; using Avalonia; using Avalonia.Controls; -using Avalonia.Media; using Avalonia.Media.Imaging; using Avalonia.Platform; using Avalonia.Styling; @@ -23,8 +22,10 @@ public class ThemesService public ThemesService(IEnumerable registeredThemes, IEnumerable registeredBackgroundImages) { - _registeredThemes.TryAdd(Guid.Empty, new RegisteredTheme(Guid.Empty, "Default", ThemeVariant.Default, [], [])); - _registeredBackgroundImages.TryAdd(Guid.Empty, new RegisteredBackgroundImage(Guid.Empty, "None", string.Empty)); + _registeredThemes.TryAdd(Guid.Empty, + new RegisteredTheme(Guid.Empty, "ThemesService.Themes.Default", ThemeVariant.Default, [], [])); + _registeredBackgroundImages.TryAdd(Guid.Empty, + new RegisteredBackgroundImage(Guid.Empty, "ThemesService.BackgroundImages.None", string.Empty)); foreach (var registeredTheme in registeredThemes) _registeredThemes.TryAdd(registeredTheme.Id, registeredTheme); @@ -84,22 +85,6 @@ public void SwitchBackgroundImage(Guid id) _currentBackgroundImage = backgroundImage; OnBackgroundImageChanged?.Invoke(_currentBackgroundImage); } - - /// - /// Get Brush from resource . Returns if key has not been found OR - /// returns a default color if has not been defined. - /// - /// Key for TryGetResource - /// Fallback for when the resource cannot be found. Can be null - /// An IBrush with the value of or or the default color. - public static IBrush GetBrushResource(string key, IBrush? fallback = null) - { - return Application.Current is not null && - Application.Current.TryGetResource(key, Application.Current.ActualThemeVariant, out var val) && - val is not null - ? (IBrush)val - : fallback ?? new SolidColorBrush(new Color()); - } } public class RegisteredTheme( @@ -123,23 +108,18 @@ public class RegisteredBackgroundImage(Guid id, string name, string path) public string Path { get; } = path; public Bitmap? BackgroundImageBitmap { get; private set; } - public Bitmap LoadBitmap() + public void LoadBitmap() { UnloadBitmap(); if (File.Exists(Path)) { - using (var fileStream = File.OpenRead(Path)) - { - BackgroundImageBitmap = new Bitmap(fileStream); - } - - return BackgroundImageBitmap; + using var fileStream = File.OpenRead(Path); + BackgroundImageBitmap = new Bitmap(fileStream); } - if (AssetLoader.Exists(new Uri(Path))) - return BackgroundImageBitmap = new Bitmap(AssetLoader.Open(new Uri(Path))); + if (!AssetLoader.Exists(new Uri(Path))) throw new FileNotFoundException("Could not find image file.", Path); + BackgroundImageBitmap = new Bitmap(AssetLoader.Open(new Uri(Path))); - throw new FileNotFoundException("Could not find image file.", Path); } public void UnloadBitmap() diff --git a/VoiceCraft.Client/VoiceCraft.Client/Services/VoiceCraftService.cs b/VoiceCraft.Client/VoiceCraft.Client/Services/VoiceCraftService.cs index 13c8e0d1..cc59054f 100644 --- a/VoiceCraft.Client/VoiceCraft.Client/Services/VoiceCraftService.cs +++ b/VoiceCraft.Client/VoiceCraft.Client/Services/VoiceCraftService.cs @@ -23,11 +23,9 @@ public class VoiceCraftService( NotificationService notificationService) { private McWssServer? _mcWssServer; + private int _disconnecting; private bool _pttEnabled; private bool _pttCue; - private bool _pushToTalk; - private string _title = string.Empty; - private string _description = string.Empty; //Audio private AudioPlaybackDevice? _audioPlayer; @@ -56,37 +54,37 @@ public bool Deafened public bool PushToTalk { - get => _pushToTalk; + get; set { - if (!_pttEnabled || _pushToTalk == value) return; - _pushToTalk = value; + if (!_pttEnabled || field == value) return; + field = value; client.Muted = !value; - - if(_pttCue) + + if (_pttCue) _pttToneProvider?.Play(TimeSpan.FromMilliseconds(80), value ? 880f : 620f); } } public string Title { - get => _title; + get; private set { - _title = value; + field = value; OnUpdateTitle?.Invoke(value); } - } + } = string.Empty; public string Description { - get => _description; + get; private set { - _description = value; + field = value; OnUpdateDescription?.Invoke(value); } - } + } = string.Empty; //Events public event Action? OnConnected; @@ -103,6 +101,9 @@ private set public async Task ConnectAsync(string ip, int port) { + if (client.ConnectionState != VcConnectionState.Disconnected) return; + _disconnecting = 0; + try { client.OnConnected += ClientOnConnected; @@ -141,7 +142,7 @@ public async Task ConnectAsync(string ip, int port) client.MicrophoneSensitivity = inputSettings.MicrophoneSensitivity; client.Muted = _pttEnabled; - _audioRecorder = InitializeAudioRecorder(inputSettings.InputDevice); + _audioRecorder = InitializeAudioRecorder(inputSettings.InputDevice, inputSettings.HardwarePreprocessorsEnabled); _audioPlayer = InitializeAudioPlayer(outputSettings.OutputDevice); _audioPreprocessor = InitializeAudioPreprocessor(inputSettings); _pttToneProvider = InitializeToneProvider(_audioPlayer); @@ -152,7 +153,7 @@ public async Task ConnectAsync(string ip, int port) if (!_audioRecorder.IsRunning) throw new Exception("VoiceCraft.DisconnectReason.Error"); _audioPlayer.Start(); - if (!_audioRecorder.IsRunning) + if (!_audioPlayer.IsRunning) throw new Exception("VoiceCraft.DisconnectReason.Error"); _mcWssServer?.Start(networkSettings.McWssListenIp, networkSettings.McWssHostPort); @@ -162,7 +163,7 @@ public async Task ConnectAsync(string ip, int port) settingsService.ServerUserGuid, localeSettings.Culture, networkSettings.PositioningType); - _ = Task.Run(() => UpdateLogic(client)); + _ = Task.Run(UpdateLogicAsync); await result; } catch (Exception ex) @@ -174,6 +175,8 @@ public async Task ConnectAsync(string ip, int port) public async Task DisconnectAsync(string? reason = null) { + if (Interlocked.Exchange(ref _disconnecting, 1) == 1) return; + await StopClientAsync(client, reason); if (_audioRecorder != null) @@ -222,17 +225,29 @@ private int Read(Span buffer) return read; } - private static void UpdateLogic(VoiceCraftClient client) + private async Task UpdateLogicAsync() { - var startTime = DateTime.UtcNow; - while (client.ConnectionState != VcConnectionState.Disconnected) + try { - client.Update(); //Update all networking processes. - var dist = DateTime.UtcNow - startTime; - var delay = Constants.TickRate - dist.TotalMilliseconds; - if (delay > 0) - Thread.Sleep((int)delay); - startTime = DateTime.UtcNow; + var startTime = DateTime.UtcNow; + while (client.ConnectionState != VcConnectionState.Disconnected) + { + if (_audioRecorder is { IsRunning: false } || + _audioPlayer is { IsRunning: false }) + throw new Exception("VoiceCraft.DisconnectReason.Error"); + + client.Update(); //Update all networking processes. + var dist = DateTime.UtcNow - startTime; + var delay = Constants.TickRate - dist.TotalMilliseconds; + if (delay > 0) + await Task.Delay((int)delay); + startTime = DateTime.UtcNow; + } + } + catch (Exception ex) + { + LogService.Log(ex); + await DisconnectAsync(ex.Message); } } @@ -325,10 +340,10 @@ private static ToneProvider InitializeToneProvider(AudioPlaybackDevice playbackD return toneProvider; } - private AudioCaptureDevice InitializeAudioRecorder(string inputDevice) + private AudioCaptureDevice InitializeAudioRecorder(string inputDevice, bool hardwarePreprocessorsEnabled) { var recorder = audioService.InitializeCaptureDevice(Constants.SampleRate, Constants.RecordingChannels, - Constants.FrameSize, inputDevice); + Constants.FrameSize, inputDevice, hardwarePreprocessorsEnabled); recorder.OnAudioProcessed += Write; return recorder; } @@ -422,4 +437,4 @@ private void StopMcWssServer(McWssServer server) LogService.Log(ex); } } -} \ No newline at end of file +} diff --git a/VoiceCraft.Client/VoiceCraft.Client/ViewModels/AddServerViewModel.cs b/VoiceCraft.Client/VoiceCraft.Client/ViewModels/AddServerViewModel.cs index a6efec9f..eb334253 100644 --- a/VoiceCraft.Client/VoiceCraft.Client/ViewModels/AddServerViewModel.cs +++ b/VoiceCraft.Client/VoiceCraft.Client/ViewModels/AddServerViewModel.cs @@ -13,10 +13,9 @@ public partial class AddServerViewModel( { private bool _updatingPort; - [ObservableProperty] private Server _server = new(); - [ObservableProperty] private decimal? _serverPort = 9050; - - [ObservableProperty] private ServersSettings _servers = settings.ServersSettings; + [ObservableProperty] public partial Server Server { get; set; } = new(); + [ObservableProperty] public partial decimal? ServerPort { get; set; } = 9050; + [ObservableProperty] public partial ServersSettings Servers { get; set; } = settings.ServersSettings; partial void OnServerChanged(Server value) { @@ -61,4 +60,4 @@ private void AddServer() notificationService.SendErrorNotification("AddServer.Notification.Badge", ex.Message); } } -} +} \ No newline at end of file diff --git a/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Data/ContributorDataViewModel.cs b/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Data/ContributorDataViewModel.cs index 746fa184..4aed2857 100644 --- a/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Data/ContributorDataViewModel.cs +++ b/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Data/ContributorDataViewModel.cs @@ -6,7 +6,7 @@ namespace VoiceCraft.Client.ViewModels.Data; public partial class ContributorDataViewModel(string name, string[] roles, Bitmap? imageIcon = null) : ObservableObject { - [ObservableProperty] private Bitmap? _imageIcon = imageIcon; - [ObservableProperty] private string _name = name; - [ObservableProperty] private ObservableCollection _roles = new(roles); + [ObservableProperty] public partial Bitmap? ImageIcon { get; set; } = imageIcon; + [ObservableProperty] public partial string Name { get; set; } = name; + [ObservableProperty] public partial ObservableCollection Roles { get; set; } = new(roles); } \ No newline at end of file diff --git a/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Data/EntityDataViewModel.cs b/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Data/EntityDataViewModel.cs index e92acbc6..80d17860 100644 --- a/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Data/EntityDataViewModel.cs +++ b/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Data/EntityDataViewModel.cs @@ -14,20 +14,21 @@ public partial class EntityDataViewModel : ObservableObject private readonly UserSettings _userSettings; //Entity Display. - [ObservableProperty] private string _displayName; - [ObservableProperty] private bool _isDeafened; - [ObservableProperty] private bool _isMuted; - [ObservableProperty] private bool _isServerDeafened; - [ObservableProperty] private bool _isServerMuted; - [ObservableProperty] private bool _isSpeaking; - [ObservableProperty] private bool _isVisible; - [ObservableProperty] private bool _userMuted; + [ObservableProperty] public partial string DisplayName { get; set; } + [ObservableProperty] public partial bool IsDeafened { get; set; } + [ObservableProperty] public partial bool IsMuted { get; set; } + [ObservableProperty] public partial bool IsServerDeafened { get; set; } + [ObservableProperty] public partial bool IsServerMuted { get; set; } + [ObservableProperty] public partial bool IsSpeaking { get; set; } + [ObservableProperty] public partial bool IsVisible { get; set; } + [ObservableProperty] public partial bool UserMuted { get; set; } + private bool _userMutedUpdating; private bool _userVolumeUpdating; //User Settings [ObservableProperty] private float _volume; - + public VoiceCraftClientEntity Entity { get; } public EntityDataViewModel(VoiceCraftClientEntity entity, SettingsService settingsService) @@ -39,8 +40,8 @@ public EntityDataViewModel(VoiceCraftClientEntity entity, SettingsService settin if (entity is VoiceCraftClientNetworkEntity networkEntity) { _entityUserId = networkEntity.UserGuid; - _isServerMuted = networkEntity.ServerMuted; - _isServerDeafened = networkEntity.ServerDeafened; + IsServerMuted = networkEntity.ServerMuted; + IsServerDeafened = networkEntity.ServerDeafened; if (_userSettings.Users.TryGetValue((Guid)_entityUserId, out var entitySetting)) { entity.Volume = entitySetting.Volume; @@ -51,13 +52,13 @@ public EntityDataViewModel(VoiceCraftClientEntity entity, SettingsService settin networkEntity.OnServerDeafenUpdated += (value, _) => IsServerDeafened = value; } - _displayName = entity.Name; - _isMuted = entity.Muted; - _isDeafened = entity.Deafened; - _isVisible = entity.IsVisible; - _isSpeaking = entity.IsSpeaking; + DisplayName = entity.Name; + IsMuted = entity.Muted; + IsDeafened = entity.Deafened; + IsVisible = entity.IsVisible; + IsSpeaking = entity.IsSpeaking; _volume = entity.Volume; - _userMuted = entity.UserMuted; + UserMuted = entity.UserMuted; entity.OnNameUpdated += (value, _) => DisplayName = value; entity.OnMuteUpdated += (value, _) => IsMuted = value; diff --git a/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Data/HotKeyActionDataViewModel.cs b/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Data/HotKeyActionDataViewModel.cs index 4ceb2187..b64fcda4 100644 --- a/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Data/HotKeyActionDataViewModel.cs +++ b/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Data/HotKeyActionDataViewModel.cs @@ -6,6 +6,6 @@ namespace VoiceCraft.Client.ViewModels.Data; public partial class HotKeyActionDataViewModel(HotKeyAction hotKeyAction, string keybind) : ObservableObject { public HotKeyAction Action { get; } = hotKeyAction; - [ObservableProperty] private string _keybind = keybind.Replace("\0", " + "); - [ObservableProperty] private string _title = $"Settings.HotKey.Actions.{hotKeyAction.Title}"; -} + [ObservableProperty] public partial string Keybind { get; set; } = keybind.Replace("\0", " + "); + [ObservableProperty] public partial string Title { get; set; } = hotKeyAction.Title; +} \ No newline at end of file diff --git a/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Data/HotKeySettingsDataViewModel.cs b/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Data/HotKeySettingsDataViewModel.cs index a20948a8..689404c4 100644 --- a/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Data/HotKeySettingsDataViewModel.cs +++ b/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Data/HotKeySettingsDataViewModel.cs @@ -1,7 +1,6 @@ using System; using System.Collections.ObjectModel; using System.Linq; -using VoiceCraft.Client.Models.Settings; using VoiceCraft.Client.Services; namespace VoiceCraft.Client.ViewModels.Data; diff --git a/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Data/InputSettingsDataViewModel.cs b/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Data/InputSettingsDataViewModel.cs index 47d24ed4..e8b20bdb 100644 --- a/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Data/InputSettingsDataViewModel.cs +++ b/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Data/InputSettingsDataViewModel.cs @@ -13,20 +13,26 @@ public partial class InputSettingsDataViewModel : ObservableObject, IDisposable private readonly InputSettings _inputSettings; private readonly SettingsService _settingsService; - [ObservableProperty] private string _inputDevice; - [ObservableProperty] private float _inputVolume; - [ObservableProperty] private float _microphoneSensitivity; - [ObservableProperty] private Guid _denoiser; - [ObservableProperty] private Guid _automaticGainController; - [ObservableProperty] private Guid _echoCanceler; - [ObservableProperty] private bool _pushToTalkEnabled; - [ObservableProperty] private bool _pushToTalkCue; + [ObservableProperty] public partial string InputDevice { get; set; } + [ObservableProperty] public partial float InputVolume { get; set; } + [ObservableProperty] public partial float MicrophoneSensitivity { get; set; } + [ObservableProperty] public partial Guid Denoiser { get; set; } + [ObservableProperty] public partial Guid AutomaticGainController { get; set; } + [ObservableProperty] public partial Guid EchoCanceler { get; set; } + [ObservableProperty] public partial bool HardwarePreprocessorsEnabled { get; set; } + [ObservableProperty] public partial bool PushToTalkEnabled { get; set; } + [ObservableProperty] public partial bool PushToTalkCue { get; set; } //Lists - [ObservableProperty] private ObservableCollection _inputDevices = []; - [ObservableProperty] private ObservableCollection _denoisers = []; - [ObservableProperty] private ObservableCollection _automaticGainControllers = []; - [ObservableProperty] private ObservableCollection _echoCancelers = []; + [ObservableProperty] public partial ObservableCollection InputDevices { get; set; } = []; + [ObservableProperty] public partial ObservableCollection Denoisers { get; set; } = []; + + [ObservableProperty] + public partial ObservableCollection AutomaticGainControllers { get; set; } = []; + + [ObservableProperty] + public partial ObservableCollection EchoCancelers { get; set; } = []; + private bool _disposed; private bool _updating; @@ -37,14 +43,15 @@ public InputSettingsDataViewModel(SettingsService settingsService, AudioService _settingsService = settingsService; _inputSettings.OnUpdated += Update; - _inputDevice = _inputSettings.InputDevice; - _inputVolume = _inputSettings.InputVolume; - _microphoneSensitivity = _inputSettings.MicrophoneSensitivity; - _denoiser = _inputSettings.Denoiser; - _automaticGainController = _inputSettings.AutomaticGainController; - _echoCanceler = _inputSettings.EchoCanceler; - _pushToTalkEnabled = _inputSettings.PushToTalkEnabled; - _pushToTalkCue = _inputSettings.PushToTalkCue; + InputDevice = _inputSettings.InputDevice; + InputVolume = _inputSettings.InputVolume; + MicrophoneSensitivity = _inputSettings.MicrophoneSensitivity; + Denoiser = _inputSettings.Denoiser; + AutomaticGainController = _inputSettings.AutomaticGainController; + EchoCanceler = _inputSettings.EchoCanceler; + HardwarePreprocessorsEnabled = _inputSettings.HardwarePreprocessorsEnabled; + PushToTalkEnabled = _inputSettings.PushToTalkEnabled; + PushToTalkCue = _inputSettings.PushToTalkCue; } public void Dispose() @@ -62,7 +69,7 @@ public void ReloadDevices() Denoisers = [.._audioService.RegisteredAudioPreprocessors.Where(x => x.DenoiserSupported)]; AutomaticGainControllers = [.._audioService.RegisteredAudioPreprocessors.Where(x => x.GainControllerSupported)]; EchoCancelers = [.._audioService.RegisteredAudioPreprocessors.Where(x => x.EchoCancelerSupported)]; - + if (InputDevices.All(outputDevice => outputDevice.Name != InputDevice)) InputDevice = InputDevices.First(x => x.IsDefault).Name; if (Denoisers.FirstOrDefault(x => x.Id == Denoiser) == null) @@ -139,6 +146,17 @@ partial void OnEchoCancelerChanging(Guid value) _updating = false; } + partial void OnHardwarePreprocessorsEnabledChanging(bool value) + { + ThrowIfDisposed(); + + if (_updating) return; + _updating = true; + _inputSettings.HardwarePreprocessorsEnabled = value; + _ = _settingsService.SaveAsync(); + _updating = false; + } + partial void OnPushToTalkEnabledChanging(bool value) { ThrowIfDisposed(); @@ -149,7 +167,7 @@ partial void OnPushToTalkEnabledChanging(bool value) _ = _settingsService.SaveAsync(); _updating = false; } - + partial void OnPushToTalkCueChanging(bool value) { ThrowIfDisposed(); @@ -172,6 +190,7 @@ private void Update(InputSettings inputSettings) Denoiser = inputSettings.Denoiser; AutomaticGainController = inputSettings.AutomaticGainController; EchoCanceler = inputSettings.EchoCanceler; + HardwarePreprocessorsEnabled = inputSettings.HardwarePreprocessorsEnabled; PushToTalkEnabled = inputSettings.PushToTalkEnabled; PushToTalkCue = inputSettings.PushToTalkCue; diff --git a/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Data/LocaleSettingsDataViewModel.cs b/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Data/LocaleSettingsDataViewModel.cs index d3f3b37e..42899c72 100644 --- a/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Data/LocaleSettingsDataViewModel.cs +++ b/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Data/LocaleSettingsDataViewModel.cs @@ -11,7 +11,8 @@ public partial class LocaleSettingsDataViewModel : ObservableObject, IDisposable private readonly LocaleSettings _localeSettings; private readonly SettingsService _settingsService; - [ObservableProperty] private string _culture; + [ObservableProperty] public partial string Culture { get; set; } + private bool _disposed; private bool _updating; @@ -20,7 +21,7 @@ public LocaleSettingsDataViewModel(SettingsService settingsService) _localeSettings = settingsService.LocaleSettings; _settingsService = settingsService; _localeSettings.OnUpdated += Update; - _culture = _localeSettings.Culture; + Culture = _localeSettings.Culture; } public void Dispose() diff --git a/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Data/NetworkSettingsDataViewModel.cs b/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Data/NetworkSettingsDataViewModel.cs index 69cee3b3..e9b1b842 100644 --- a/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Data/NetworkSettingsDataViewModel.cs +++ b/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Data/NetworkSettingsDataViewModel.cs @@ -11,10 +11,10 @@ public partial class NetworkSettingsDataViewModel : ObservableObject, IDisposabl private readonly NetworkSettings _networkSettings; private readonly SettingsService _settingsService; private bool _disposed; - [ObservableProperty] private ushort _mcWssHostPort; - [ObservableProperty] private string _mcWssListenIp; + [ObservableProperty] public partial ushort McWssHostPort { get; set; } + [ObservableProperty] public partial string McWssListenIp { get; set; } + [ObservableProperty] public partial PositioningType PositioningType { get; set; } - [ObservableProperty] private PositioningType _positioningType; private bool _updating; public NetworkSettingsDataViewModel(SettingsService settingsService) @@ -22,9 +22,9 @@ public NetworkSettingsDataViewModel(SettingsService settingsService) _networkSettings = settingsService.NetworkSettings; _settingsService = settingsService; _networkSettings.OnUpdated += Update; - _positioningType = _networkSettings.PositioningType; - _mcWssListenIp = _networkSettings.McWssListenIp; - _mcWssHostPort = _networkSettings.McWssHostPort; + PositioningType = _networkSettings.PositioningType; + McWssListenIp = _networkSettings.McWssListenIp; + McWssHostPort = _networkSettings.McWssHostPort; } public void Dispose() diff --git a/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Data/NotificationSettingsDataViewModel.cs b/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Data/NotificationSettingsDataViewModel.cs index 93a40242..3ab5c7a2 100644 --- a/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Data/NotificationSettingsDataViewModel.cs +++ b/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Data/NotificationSettingsDataViewModel.cs @@ -9,9 +9,9 @@ public partial class NotificationSettingsDataViewModel : ObservableObject, IDisp { private readonly NotificationSettings _notificationSettings; private readonly SettingsService _settingsService; - [ObservableProperty] private bool _disableNotifications; + [ObservableProperty] public partial bool DisableNotifications { get; set; } + [ObservableProperty] public partial ushort DismissDelayMs { get; set; } - [ObservableProperty] private ushort _dismissDelayMs; private bool _disposed; private bool _updating; @@ -20,8 +20,8 @@ public NotificationSettingsDataViewModel(SettingsService settingsService) _notificationSettings = settingsService.NotificationSettings; _settingsService = settingsService; _notificationSettings.OnUpdated += Update; - _dismissDelayMs = _notificationSettings.DismissDelayMs; - _disableNotifications = _notificationSettings.DisableNotifications; + DismissDelayMs = _notificationSettings.DismissDelayMs; + DisableNotifications = _notificationSettings.DisableNotifications; } public void Dispose() diff --git a/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Data/OutputSettingsDataViewModel.cs b/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Data/OutputSettingsDataViewModel.cs index 7e344cfe..9ff73437 100644 --- a/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Data/OutputSettingsDataViewModel.cs +++ b/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Data/OutputSettingsDataViewModel.cs @@ -13,15 +13,16 @@ public partial class OutputSettingsDataViewModel : ObservableObject, IDisposable private readonly OutputSettings _outputSettings; private readonly SettingsService _settingsService; - [ObservableProperty] private string _outputDevice; - [ObservableProperty] private float _outputVolume; - [ObservableProperty] private Guid _audioClipper; + [ObservableProperty] public partial string OutputDevice { get; set; } + [ObservableProperty] public partial float OutputVolume { get; set; } + [ObservableProperty] public partial Guid AudioClipper { get; set; } + private bool _disposed; private bool _updating; //Lists - [ObservableProperty] private ObservableCollection _outputDevices = []; - [ObservableProperty] private ObservableCollection _audioClippers = []; + [ObservableProperty] public partial ObservableCollection OutputDevices { get; set; } = []; + [ObservableProperty] public partial ObservableCollection AudioClippers { get; set; } = []; public OutputSettingsDataViewModel(SettingsService settingsService, AudioService audioService) { @@ -30,9 +31,9 @@ public OutputSettingsDataViewModel(SettingsService settingsService, AudioService _settingsService = settingsService; _outputSettings.OnUpdated += Update; - _outputDevice = _outputSettings.OutputDevice; - _outputVolume = _outputSettings.OutputVolume; - _audioClipper = _outputSettings.AudioClipper; + OutputDevice = _outputSettings.OutputDevice; + OutputVolume = _outputSettings.OutputVolume; + AudioClipper = _outputSettings.AudioClipper; } public void Dispose() diff --git a/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Data/ServerDataViewModel.cs b/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Data/ServerDataViewModel.cs index 76bf5dfe..93f59ddb 100644 --- a/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Data/ServerDataViewModel.cs +++ b/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Data/ServerDataViewModel.cs @@ -11,9 +11,10 @@ public partial class ServerDataViewModel : ObservableObject, IDisposable public readonly Server Server; private bool _disposed; - [ObservableProperty] private string _ip; - [ObservableProperty] private string _name; - [ObservableProperty] private ushort _port; + [ObservableProperty] public partial string Ip { get; set; } + [ObservableProperty] public partial string Name { get; set; } + [ObservableProperty] public partial ushort Port { get; set; } + private bool _updating; public ServerDataViewModel(Server server, SettingsService settingsService) @@ -21,9 +22,9 @@ public ServerDataViewModel(Server server, SettingsService settingsService) Server = server; _settingsService = settingsService; Server.OnUpdated += Update; - _name = Server.Name; - _ip = Server.Ip; - _port = Server.Port; + Name = Server.Name; + Ip = Server.Ip; + Port = Server.Port; } public void Dispose() diff --git a/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Data/ServersSettingsViewModel.cs b/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Data/ServersSettingsViewModel.cs index 44876977..b51bf851 100644 --- a/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Data/ServersSettingsViewModel.cs +++ b/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Data/ServersSettingsViewModel.cs @@ -13,8 +13,9 @@ public partial class ServersSettingsViewModel : ObservableObject, IDisposable public readonly ServersSettings ServersSettings; private bool _disposed; - [ObservableProperty] private bool _hideServerAddresses; - [ObservableProperty] private ObservableCollection _servers; + [ObservableProperty] public partial bool HideServerAddresses { get; set; } + [ObservableProperty] public partial ObservableCollection Servers { get; set; } + private bool _updating; public ServersSettingsViewModel(SettingsService settingsService) @@ -22,8 +23,8 @@ public ServersSettingsViewModel(SettingsService settingsService) ServersSettings = settingsService.ServersSettings; _settingsService = settingsService; ServersSettings.OnUpdated += Update; - _hideServerAddresses = ServersSettings.HideServerAddresses; - _servers = new ObservableCollection( + HideServerAddresses = ServersSettings.HideServerAddresses; + Servers = new ObservableCollection( ServersSettings.Servers.Select(s => new ServerDataViewModel(s, _settingsService))); } diff --git a/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Data/TelemetrySettingsDataViewModel.cs b/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Data/TelemetrySettingsDataViewModel.cs new file mode 100644 index 00000000..c27be054 --- /dev/null +++ b/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Data/TelemetrySettingsDataViewModel.cs @@ -0,0 +1,58 @@ +using System; +using CommunityToolkit.Mvvm.ComponentModel; +using VoiceCraft.Client.Models.Settings; +using VoiceCraft.Client.Services; + +namespace VoiceCraft.Client.ViewModels.Data; + +public partial class TelemetrySettingsDataViewModel : ObservableObject, IDisposable +{ + private readonly SettingsService _settingsService; + private readonly TelemetrySettings _telemetrySettings; + private bool _disposed; + private bool _updating; + + [ObservableProperty] public partial bool Enabled { get; set; } + + public TelemetrySettingsDataViewModel(SettingsService settingsService) + { + _settingsService = settingsService; + _telemetrySettings = settingsService.TelemetrySettings; + _telemetrySettings.OnUpdated += Update; + Enabled = _telemetrySettings.Enabled; + } + + public void Dispose() + { + if (_disposed) return; + _telemetrySettings.OnUpdated -= Update; + _disposed = true; + GC.SuppressFinalize(this); + } + + partial void OnEnabledChanging(bool value) + { + ThrowIfDisposed(); + + if (_updating) return; + _updating = true; + _telemetrySettings.Enabled = value; + _telemetrySettings.ConsentShown = true; + _ = _settingsService.SaveAsync(); + _updating = false; + } + + private void Update(TelemetrySettings telemetrySettings) + { + if (_updating) return; + _updating = true; + Enabled = telemetrySettings.Enabled; + _updating = false; + } + + private void ThrowIfDisposed() + { + if (!_disposed) return; + throw new ObjectDisposedException(typeof(TelemetrySettingsDataViewModel).ToString()); + } +} diff --git a/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Data/ThemeSettingsDataViewModel.cs b/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Data/ThemeSettingsDataViewModel.cs index fd02848f..241479d8 100644 --- a/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Data/ThemeSettingsDataViewModel.cs +++ b/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Data/ThemeSettingsDataViewModel.cs @@ -11,9 +11,9 @@ public partial class ThemeSettingsDataViewModel : ObservableObject, IDisposable private readonly ThemeSettings _themeSettings; private readonly ThemesService _themesService; private bool _disposed; - [ObservableProperty] private Guid _selectedBackgroundImage; + [ObservableProperty] public partial Guid SelectedBackgroundImage { get; set; } + [ObservableProperty] public partial Guid SelectedTheme { get; set; } - [ObservableProperty] private Guid _selectedTheme; private bool _updating; public ThemeSettingsDataViewModel(SettingsService settingsService, ThemesService themesService) @@ -22,8 +22,8 @@ public ThemeSettingsDataViewModel(SettingsService settingsService, ThemesService _settingsService = settingsService; _themesService = themesService; _themeSettings.OnUpdated += Update; - _selectedTheme = _themeSettings.SelectedTheme; - _selectedBackgroundImage = _themeSettings.SelectedBackgroundImage; + SelectedTheme = _themeSettings.SelectedTheme; + SelectedBackgroundImage = _themeSettings.SelectedBackgroundImage; } public void Dispose() diff --git a/VoiceCraft.Client/VoiceCraft.Client/ViewModels/EditServerViewModel.cs b/VoiceCraft.Client/VoiceCraft.Client/ViewModels/EditServerViewModel.cs index 26560d94..add19482 100644 --- a/VoiceCraft.Client/VoiceCraft.Client/ViewModels/EditServerViewModel.cs +++ b/VoiceCraft.Client/VoiceCraft.Client/ViewModels/EditServerViewModel.cs @@ -15,9 +15,9 @@ public partial class EditServerViewModel( { private bool _updatingPort; - [ObservableProperty] private Server _editableServer = new(); - [ObservableProperty] private decimal? _editableServerPort = 9050; - [ObservableProperty] private Server _server = new(); + [ObservableProperty] public partial Server EditableServer { get; set; } = new(); + [ObservableProperty] public partial decimal? EditableServerPort { get; set; } = 9050; + [ObservableProperty] public partial Server Server { get; set; } = new(); public override void OnAppearing(object? data = null) { diff --git a/VoiceCraft.Client/VoiceCraft.Client/ViewModels/ErrorViewModel.cs b/VoiceCraft.Client/VoiceCraft.Client/ViewModels/ErrorViewModel.cs index cee74359..d3db89ab 100644 --- a/VoiceCraft.Client/VoiceCraft.Client/ViewModels/ErrorViewModel.cs +++ b/VoiceCraft.Client/VoiceCraft.Client/ViewModels/ErrorViewModel.cs @@ -4,5 +4,5 @@ namespace VoiceCraft.Client.ViewModels; public partial class ErrorViewModel : ViewModelBase { - [ObservableProperty] private string _errorMessage = string.Empty; + [ObservableProperty] public partial string ErrorMessage { get; set; } = string.Empty; } \ No newline at end of file diff --git a/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Home/CrashLogViewModel.cs b/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Home/CrashLogViewModel.cs index 597bd674..98a3918c 100644 --- a/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Home/CrashLogViewModel.cs +++ b/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Home/CrashLogViewModel.cs @@ -6,14 +6,33 @@ using System.Threading.Tasks; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; -using Microsoft.Maui.ApplicationModel.DataTransfer; using VoiceCraft.Client.Services; +using VoiceCraft.Core.Diagnostics; namespace VoiceCraft.Client.ViewModels.Home; -public partial class CrashLogViewModel(NotificationService notificationService) : ViewModelBase +public partial class CrashLogViewModel( + NotificationService notificationService, + ClientTelemetryService clientTelemetryService, + ClipboardService clipboardService) : ViewModelBase { - [ObservableProperty] private ObservableCollection> _crashLogs = []; + [ObservableProperty] public partial ObservableCollection> CrashLogs { get; set; } = []; + + private async Task UploadCrashLog(DateTime timeStamp) + { + if (!LogService.TryGetCrashLog(timeStamp, out var crashLog)) return null; + if (!string.IsNullOrWhiteSpace(crashLog.DumpUrl)) + return crashLog.DumpUrl; + + var dumpResponse = await clientTelemetryService.ReportCrashAsync(crashLog.Message); + var dumpUrl = dumpResponse?.ViewUrl ?? dumpResponse?.Url; + if (string.IsNullOrWhiteSpace(dumpUrl)) + return null; + + crashLog.DumpUrl = dumpUrl; + LogService.UpdateCrashLog(timeStamp, crashLog); + return dumpUrl; + } [RelayCommand] private async Task CopyLogs() @@ -32,11 +51,13 @@ private async Task CopyLogs() foreach (var crashLog in CrashLogs.OrderByDescending(x => x.Key)) { _ = text.AppendLine($"[{crashLog.Key:O}]"); - _ = text.AppendLine(crashLog.Value); + _ = text.AppendLine(crashLog.Value.Message); + if (!string.IsNullOrWhiteSpace(crashLog.Value.DumpUrl)) + _ = text.AppendLine($"Dump: {crashLog.Value.DumpUrl}"); _ = text.AppendLine(); } - await Clipboard.Default.SetTextAsync(text.ToString().Trim()); + await clipboardService.SetTextAsync(text.ToString().Trim()); notificationService.SendSuccessNotification( "CrashLogs.Notification.Badge", "CrashLogs.Notification.Copied"); @@ -49,6 +70,41 @@ private async Task CopyLogs() } } + [RelayCommand] + private async Task CopyDumpLink(KeyValuePair? crashLog) + { + try + { + if (crashLog is null) + { + notificationService.SendNotification( + "CrashLogs.Notification.Badge", + "CrashLogs.Notification.DumpUnavailable"); + return; + } + + var dumpUrl = await UploadCrashLog(crashLog.Value.Key); + if (string.IsNullOrWhiteSpace(dumpUrl)) + { + notificationService.SendNotification( + "CrashLogs.Notification.Badge", + "CrashLogs.Notification.DumpUploadFailed"); + return; + } + + await clipboardService.SetTextAsync(dumpUrl); + notificationService.SendSuccessNotification( + "CrashLogs.Notification.Badge", + "CrashLogs.Notification.DumpCopied"); + } + catch (Exception ex) + { + notificationService.SendErrorNotification( + "CrashLogs.Notification.Badge", + ex.Message); + } + } + [RelayCommand] private void ClearLogs() { @@ -70,6 +126,6 @@ private void ClearLogs() public override void OnAppearing(object? data = null) { - CrashLogs = new ObservableCollection>(LogService.CrashLogs); + CrashLogs = new ObservableCollection>(LogService.CrashLogs); } } diff --git a/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Home/CreditsViewModel.cs b/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Home/CreditsViewModel.cs index 988499bb..88806687 100644 --- a/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Home/CreditsViewModel.cs +++ b/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Home/CreditsViewModel.cs @@ -15,17 +15,14 @@ public partial class CreditsViewModel : ViewModelBase { //private readonly Bitmap? _defaultIcon = LoadImage("avares://VoiceCraft.Client/Assets/Contributors/vc.png"); - [ObservableProperty] private string _appVersion = string.Empty; - - [ObservableProperty] private string _codec = string.Empty; - - [ObservableProperty] private ObservableCollection _contributors; - - [ObservableProperty] private string _version = string.Empty; + [ObservableProperty] public partial string AppVersion { get; set; } = string.Empty; + [ObservableProperty] public partial string Codec { get; set; } = string.Empty; + [ObservableProperty] public partial ObservableCollection Contributors { get; set; } + [ObservableProperty] public partial string Version { get; set; } = string.Empty; public CreditsViewModel() { - _contributors = + Contributors = [ new ContributorDataViewModel( "SineVector241", @@ -82,4 +79,4 @@ private static string GetCodecVersion() return "N.A."; } } -} +} \ No newline at end of file diff --git a/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Home/ServersViewModel.cs b/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Home/ServersViewModel.cs index 98051bab..7029f7af 100644 --- a/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Home/ServersViewModel.cs +++ b/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Home/ServersViewModel.cs @@ -1,4 +1,5 @@ using System; +using System.Threading.Tasks; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using VoiceCraft.Client.Models; @@ -13,26 +14,23 @@ public partial class ServersViewModel( SettingsService settings) : ViewModelBase, IDisposable { - [ObservableProperty] private ServerDataViewModel? _selectedServer; - - [ObservableProperty] private ServersSettingsViewModel _serversSettings = new(settings); + [ObservableProperty] public partial ServerDataViewModel? SelectedServer { get; set; } + [ObservableProperty] public partial ServersSettingsViewModel ServersSettings { get; set; } = new(settings); public void Dispose() { ServersSettings.Dispose(); GC.SuppressFinalize(this); } - - private void OpenServer(ServerDataViewModel? server) - { - if (server == null) return; - navigationService.NavigateTo(new SelectedServerNavigationData(server.Server)); - } partial void OnSelectedServerChanged(ServerDataViewModel? value) { - OpenServer(value); - SelectedServer = null; + if (value == null) return; + navigationService.NavigateTo(new SelectedServerNavigationData(value.Server)); + Task.Run(() => + { + SelectedServer = null; //This bug is annoying. + }); } [RelayCommand] @@ -57,4 +55,4 @@ private void EditServer(ServerDataViewModel? server) if (server == null) return; navigationService.NavigateTo(new EditServerNavigationData(server.Server)); } -} +} \ No newline at end of file diff --git a/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Home/SettingsViewModel.cs b/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Home/SettingsViewModel.cs index 3b25ea20..8218e54a 100644 --- a/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Home/SettingsViewModel.cs +++ b/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Home/SettingsViewModel.cs @@ -8,32 +8,35 @@ namespace VoiceCraft.Client.ViewModels.Home; public partial class SettingsViewModel : ViewModelBase { - [ObservableProperty] private ObservableCollection _items = []; - [ObservableProperty] private ListItemTemplate? _selectedListItem; + [ObservableProperty] public partial ObservableCollection Items { get; set; } + [ObservableProperty] public partial ListItemTemplate? SelectedListItem { get; set; } public SettingsViewModel(NavigationService navigationService) { - _items.Add(new ListItemTemplate("Settings.General.Title", - () => { navigationService.NavigateTo(); })); - _items.Add(new ListItemTemplate("Settings.Appearance.Title", - () => { navigationService.NavigateTo(); })); - _items.Add(new ListItemTemplate("Settings.Input.Title", - () => { navigationService.NavigateTo(); })); - _items.Add(new ListItemTemplate("Settings.Output.Title", - () => { navigationService.NavigateTo(); })); - _items.Add(new ListItemTemplate("Settings.Network.Title", - () => { navigationService.NavigateTo(); })); - _items.Add(new ListItemTemplate("Settings.HotKey.Title", - () => { navigationService.NavigateTo(); })); - _items.Add(new ListItemTemplate("Settings.Advanced.Title", - () => { navigationService.NavigateTo(); })); + Items = + [ + new ListItemTemplate("Settings.General.Title", + () => { navigationService.NavigateTo(); }), + new ListItemTemplate("Settings.Appearance.Title", + () => { navigationService.NavigateTo(); }), + new ListItemTemplate("Settings.Input.Title", + () => { navigationService.NavigateTo(); }), + new ListItemTemplate("Settings.Output.Title", + () => { navigationService.NavigateTo(); }), + new ListItemTemplate("Settings.Network.Title", + () => { navigationService.NavigateTo(); }), + new ListItemTemplate("Settings.HotKey.Title", + () => { navigationService.NavigateTo(); }), + new ListItemTemplate("Settings.Advanced.Title", + () => { navigationService.NavigateTo(); }) + ]; } partial void OnSelectedListItemChanged(ListItemTemplate? value) { if (value == null) return; value.OnClicked.Invoke(); - _selectedListItem = null; + SelectedListItem = null; } } diff --git a/VoiceCraft.Client/VoiceCraft.Client/ViewModels/HomeViewModel.cs b/VoiceCraft.Client/VoiceCraft.Client/ViewModels/HomeViewModel.cs index 2e984cde..98509f8e 100644 --- a/VoiceCraft.Client/VoiceCraft.Client/ViewModels/HomeViewModel.cs +++ b/VoiceCraft.Client/VoiceCraft.Client/ViewModels/HomeViewModel.cs @@ -9,24 +9,25 @@ namespace VoiceCraft.Client.ViewModels; public partial class HomeViewModel : ViewModelBase { - [ObservableProperty] private ViewModelBase _content; - - [ObservableProperty] private ObservableCollection _items = []; - - [ObservableProperty] private ListItemTemplate? _selectedListItem; - [ObservableProperty] private string _title; + [ObservableProperty] public partial ViewModelBase Content { get; set; } + [ObservableProperty] public partial ObservableCollection Items { get; set; } + [ObservableProperty] public partial ListItemTemplate? SelectedListItem { get; set; } + [ObservableProperty] public partial string Title { get; set; } public HomeViewModel(ServersViewModel servers, SettingsViewModel settings, CreditsViewModel credits, CrashLogViewModel crashLog) { - _items.Add(new ListItemTemplate("Servers.Title", servers, "HomeRegular")); - _items.Add(new ListItemTemplate("Settings.Title", settings, "SettingsRegular")); - _items.Add(new ListItemTemplate("Credits.Title", credits, "InformationRegular")); - _items.Add(new ListItemTemplate("CrashLogs.Title", crashLog, "NotebookErrorRegular")); - - SelectedListItem = _items[0]; - _content = _items[0].Content; - _title = _items[0].Title; + Items = + [ + new ListItemTemplate("Servers.Title", servers, "HomeRegular"), + new ListItemTemplate("Settings.Title", settings, "SettingsRegular"), + new ListItemTemplate("Credits.Title", credits, "InformationRegular"), + new ListItemTemplate("CrashLogs.Title", crashLog, "NotebookErrorRegular") + ]; + + SelectedListItem = Items[0]; + Content = Items[0].Content; + Title = Items[0].Title; } partial void OnSelectedListItemChanged(ListItemTemplate? value) diff --git a/VoiceCraft.Client/VoiceCraft.Client/ViewModels/MainViewModel.cs b/VoiceCraft.Client/VoiceCraft.Client/ViewModels/MainViewModel.cs index 021873b5..f4f0100f 100644 --- a/VoiceCraft.Client/VoiceCraft.Client/ViewModels/MainViewModel.cs +++ b/VoiceCraft.Client/VoiceCraft.Client/ViewModels/MainViewModel.cs @@ -1,15 +1,19 @@ -using Avalonia.Media.Imaging; +using Avalonia.Media.Imaging; using CommunityToolkit.Mvvm.ComponentModel; using VoiceCraft.Client.Models; using VoiceCraft.Client.Services; +using VoiceCraft.Client.ViewModels.Modals; using VoiceCraft.Core.Locales; namespace VoiceCraft.Client.ViewModels; public partial class MainViewModel : ObservableObject { - [ObservableProperty] private Bitmap? _backgroundImage; - [ObservableProperty] private object? _content; + private readonly NavigationService _navigationService; + [ObservableProperty] public partial Bitmap? BackgroundImage { get; set; } + [ObservableProperty] public partial object? Content { get; set; } + [ObservableProperty] public partial object? ModalContent { get; set; } + [ObservableProperty] public partial bool HasModal { get; set; } public MainViewModel(NavigationService navigationService, ThemesService themesService, @@ -18,6 +22,8 @@ public MainViewModel(NavigationService navigationService, HotKeyService hotKeyService, IBackgroundService backgroundService) { + _navigationService = navigationService; + themesService.OnBackgroundImageChanged += backgroundImage => { BackgroundImage = backgroundImage?.BackgroundImageBitmap; @@ -29,6 +35,11 @@ public MainViewModel(NavigationService navigationService, Content = viewModel; discordRpcService.SetState($"In page {viewModel.GetType().Name.Replace("ViewModel", "")}"); }; + navigationService.OnModalViewModelChanged += viewModel => + { + ModalContent = viewModel; + HasModal = viewModel != null; + }; //Initialize Themes var themeSettings = settingsService.ThemeSettings; themesService.SwitchTheme(themeSettings.SelectedTheme); @@ -40,9 +51,17 @@ public MainViewModel(NavigationService navigationService, // change to HomeView navigationService.NavigateTo(); - + var voiceCraftService = backgroundService.GetService(); - if (voiceCraftService == null) return; - navigationService.NavigateTo(new VoiceNavigationData(voiceCraftService)); + if (voiceCraftService != null) + navigationService.NavigateTo(new VoiceNavigationData(voiceCraftService)); + + if (!settingsService.TelemetrySettings.ConsentShown) + navigationService.PushModal(); + } + + public void PopModal() + { + _navigationService.PopModal(true); } -} \ No newline at end of file +} diff --git a/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Modals/EntityDataSettingsViewModel.cs b/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Modals/EntityDataSettingsViewModel.cs new file mode 100644 index 00000000..6483d19f --- /dev/null +++ b/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Modals/EntityDataSettingsViewModel.cs @@ -0,0 +1,18 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using VoiceCraft.Client.Models; +using VoiceCraft.Client.ViewModels.Data; + +namespace VoiceCraft.Client.ViewModels.Modals; + +public partial class EntityDataSettingsViewModel : ViewModelBase +{ + [ObservableProperty] public partial EntityDataViewModel? Entity { get; private set; } + + public override void OnAppearing(object? data = null) + { + if (data is EntityDataSettingsNavigationData navigationData) + { + Entity = navigationData.Entity; + } + } +} diff --git a/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Modals/HotKeyCaptureViewModel.cs b/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Modals/HotKeyCaptureViewModel.cs new file mode 100644 index 00000000..34f7274d --- /dev/null +++ b/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Modals/HotKeyCaptureViewModel.cs @@ -0,0 +1,60 @@ +using System; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using VoiceCraft.Client.Models; +using VoiceCraft.Client.Services; +using VoiceCraft.Client.ViewModels.Data; + +namespace VoiceCraft.Client.ViewModels.Modals; + +public partial class HotKeyCaptureViewModel(NavigationService navigationService, HotKeyService hotKeyService) + : ViewModelBase +{ + public override bool DisableBackButton => true; + private readonly HotKeySettingsDataViewModel _hotKeySettingsData = new(hotKeyService); + private HotKeyActionDataViewModel? _hotKeyAction; + [ObservableProperty] public partial string RebindingTitle { get; set; } = string.Empty; + [ObservableProperty] public partial string RebindingPreview { get; set; } = string.Empty; + + public override void OnAppearing(object? data = null) + { + if (data is not HotKeyCaptureNavigationData navigationData) return; + _hotKeyAction = navigationData.HotKey; + RebindingTitle = _hotKeyAction.Title; + RebindingPreview = _hotKeyAction.Keybind; + } + + [RelayCommand] + private void ConfirmRebind() + { + if (string.IsNullOrWhiteSpace(RebindingPreview) || + string.IsNullOrWhiteSpace(_hotKeyAction?.Action.Id)) return; + var action = _hotKeyAction; + if (action == null) return; + + _hotKeySettingsData.SetBinding(action.Action, + HotKeyService.NormalizeKeyCombo(RebindingPreview.Replace(" + ", "\0"))); + _hotKeyAction.Keybind = RebindingPreview; + CancelRebind(); + } + + [RelayCommand] + private void UpdateBindingPreview(string? text) + { + if (string.IsNullOrWhiteSpace(text)) return; + var keys = text.Split(" + ", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + RebindingPreview = HotKeyService.NormalizeKeyCombo(keys).Replace("\0", " + "); + } + + [RelayCommand] + private void CancelRebind() + { + navigationService.PopModal(); + } + + [RelayCommand] + private void ClearBindingPreview() + { + RebindingPreview = string.Empty; + } +} \ No newline at end of file diff --git a/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Modals/TelemetryConsentViewModel.cs b/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Modals/TelemetryConsentViewModel.cs new file mode 100644 index 00000000..a8dfc4a8 --- /dev/null +++ b/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Modals/TelemetryConsentViewModel.cs @@ -0,0 +1,32 @@ +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.Input; +using VoiceCraft.Client.Services; + +namespace VoiceCraft.Client.ViewModels.Modals; + +public partial class TelemetryConsentViewModel( + NavigationService navigationService, + SettingsService settingsService, + ClientTelemetryService clientTelemetryService) : ViewModelBase +{ + public override bool DisableBackButton { get; protected set; } = true; + + [RelayCommand] + private async Task Accept() + { + settingsService.TelemetrySettings.Enabled = true; + settingsService.TelemetrySettings.ConsentShown = true; + await settingsService.SaveImmediate(); + _ = clientTelemetryService.ReportStartupAsync(); + navigationService.PopModal(); + } + + [RelayCommand] + private async Task Decline() + { + settingsService.TelemetrySettings.Enabled = false; + settingsService.TelemetrySettings.ConsentShown = true; + await settingsService.SaveImmediate(); + navigationService.PopModal(); + } +} diff --git a/VoiceCraft.Client/VoiceCraft.Client/ViewModels/SelectedServerViewModel.cs b/VoiceCraft.Client/VoiceCraft.Client/ViewModels/SelectedServerViewModel.cs index b011f680..e271c8d9 100644 --- a/VoiceCraft.Client/VoiceCraft.Client/ViewModels/SelectedServerViewModel.cs +++ b/VoiceCraft.Client/VoiceCraft.Client/ViewModels/SelectedServerViewModel.cs @@ -20,13 +20,13 @@ public partial class SelectedServerViewModel( : ViewModelBase, IDisposable { private CancellationTokenSource? _cts; - [ObservableProperty] private string _connectedClients = string.Empty; - [ObservableProperty] private string _latency = string.Empty; - [ObservableProperty] private string _motd = string.Empty; - [ObservableProperty] private string _positioningType = string.Empty; - [ObservableProperty] private ServerDataViewModel? _selectedServer; - [ObservableProperty] private ServersSettingsViewModel _serversSettings = new(settingsService); - [ObservableProperty] private string _version = string.Empty; + [ObservableProperty] public partial string ConnectedClients { get; set; } = string.Empty; + [ObservableProperty] public partial string Latency { get; set; } = string.Empty; + [ObservableProperty] public partial string Motd { get; set; } = string.Empty; + [ObservableProperty] public partial string PositioningType { get; set; } = string.Empty; + [ObservableProperty] public partial ServerDataViewModel? SelectedServer { get; set; } + [ObservableProperty] public partial ServersSettingsViewModel ServersSettings { get; set; } = new(settingsService); + [ObservableProperty] public partial string Version { get; set; } = string.Empty; public void Dispose() { diff --git a/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Settings/AdvancedSettingsViewModel.cs b/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Settings/AdvancedSettingsViewModel.cs index 3d406db5..8faccab9 100644 --- a/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Settings/AdvancedSettingsViewModel.cs +++ b/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Settings/AdvancedSettingsViewModel.cs @@ -1,4 +1,5 @@ using System; +using System.Threading.Tasks; using CommunityToolkit.Mvvm.Input; using VoiceCraft.Client.Services; @@ -6,7 +7,8 @@ namespace VoiceCraft.Client.ViewModels.Settings; public partial class AdvancedSettingsViewModel( NavigationService navigationService, - NotificationService notificationService) : ViewModelBase + NotificationService notificationService, + SettingsService settingsService) : ViewModelBase { [RelayCommand] private void TriggerGc() @@ -33,10 +35,28 @@ private static void Crash() throw new Exception("Task failed successfully."); } + [RelayCommand] + private async Task ResetAllSettings() + { + try + { + await settingsService.ResetToDefaultsAsync(); + notificationService.SendSuccessNotification( + "Settings.Advanced.Notification.Reset.Badge", + "Settings.Advanced.Notification.Reset.Done"); + } + catch (Exception ex) + { + notificationService.SendErrorNotification( + "Settings.Advanced.Notification.Reset.Badge", + ex.Message); + } + } + [RelayCommand] private void Cancel() { if (DisableBackButton) return; navigationService.Back(); } -} \ No newline at end of file +} diff --git a/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Settings/AppearanceSettingsViewModel.cs b/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Settings/AppearanceSettingsViewModel.cs index c0c4d333..7e2e6055 100644 --- a/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Settings/AppearanceSettingsViewModel.cs +++ b/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Settings/AppearanceSettingsViewModel.cs @@ -12,11 +12,15 @@ public partial class AppearanceSettingsViewModel( ThemesService themesService, SettingsService settingsService) : ViewModelBase, IDisposable { - [ObservableProperty] private ObservableCollection _backgroundImages = + [ObservableProperty] + public partial ObservableCollection BackgroundImages { get; set; } = new(themesService.RegisteredBackgroundImages); - [ObservableProperty] private ObservableCollection _themes = new(themesService.RegisteredThemes); - [ObservableProperty] private ThemeSettingsDataViewModel _themeSettingsData = new(settingsService, themesService); + [ObservableProperty] + public partial ObservableCollection Themes { get; set; } = new(themesService.RegisteredThemes); + + [ObservableProperty] + public partial ThemeSettingsDataViewModel ThemeSettingsData { get; set; } = new(settingsService, themesService); public void Dispose() { diff --git a/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Settings/GeneralSettingsViewModel.cs b/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Settings/GeneralSettingsViewModel.cs index feb5da4c..b8ba0f23 100644 --- a/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Settings/GeneralSettingsViewModel.cs +++ b/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Settings/GeneralSettingsViewModel.cs @@ -15,30 +15,36 @@ public partial class GeneralSettingsViewModel { private readonly NavigationService _navigationService; - [ObservableProperty] private ObservableCollection> _locales = []; + [ObservableProperty] public partial ObservableCollection> Locales { get; set; } = []; //Language Settings - [ObservableProperty] private LocaleSettingsDataViewModel _localeSettingsData; + [ObservableProperty] public partial LocaleSettingsDataViewModel LocaleSettingsData { get; set; } //Notification Settings - [ObservableProperty] private NotificationSettingsDataViewModel _notificationSettingsData; + [ObservableProperty] public partial NotificationSettingsDataViewModel NotificationSettingsData { get; set; } + + //Telemetry Settings + [ObservableProperty] public partial TelemetrySettingsDataViewModel TelemetrySettingsData { get; set; } //Server Settings - [ObservableProperty] private ServersSettingsViewModel _serversSettings; + [ObservableProperty] public partial ServersSettingsViewModel ServersSettings { get; set; } public GeneralSettingsViewModel(NavigationService navigationService, SettingsService settingsService) { foreach (var locale in Localizer.Languages) - _locales.Add(new KeyValuePair(CultureInfo.GetCultureInfo(locale).NativeName, locale)); + Locales.Add(new KeyValuePair(CultureInfo.GetCultureInfo(locale).NativeName, locale)); _navigationService = navigationService; - _localeSettingsData = new LocaleSettingsDataViewModel(settingsService); - _notificationSettingsData = new NotificationSettingsDataViewModel(settingsService); - _serversSettings = new ServersSettingsViewModel(settingsService); + LocaleSettingsData = new LocaleSettingsDataViewModel(settingsService); + TelemetrySettingsData = new TelemetrySettingsDataViewModel(settingsService); + NotificationSettingsData = new NotificationSettingsDataViewModel(settingsService); + ServersSettings = new ServersSettingsViewModel(settingsService); } public void Dispose() { NotificationSettingsData.Dispose(); + TelemetrySettingsData.Dispose(); + LocaleSettingsData.Dispose(); ServersSettings.Dispose(); GC.SuppressFinalize(this); } @@ -49,4 +55,4 @@ private void Cancel() if (DisableBackButton) return; _navigationService.Back(); } -} \ No newline at end of file +} diff --git a/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Settings/HotKeySettingsViewModel.cs b/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Settings/HotKeySettingsViewModel.cs index 169e90ea..a4f8b9b8 100644 --- a/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Settings/HotKeySettingsViewModel.cs +++ b/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Settings/HotKeySettingsViewModel.cs @@ -1,10 +1,10 @@ using System; -using System.Linq; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; +using VoiceCraft.Client.Models; using VoiceCraft.Client.Services; using VoiceCraft.Client.ViewModels.Data; -using VoiceCraft.Core.Locales; +using VoiceCraft.Client.ViewModels.Modals; namespace VoiceCraft.Client.ViewModels.Settings; @@ -12,18 +12,15 @@ public partial class HotKeySettingsViewModel : ViewModelBase, IDisposable { private readonly NavigationService _navigationService; private readonly HotKeySettingsDataViewModel _hotKeySettingsData; - private string? _rebindActionId; - [ObservableProperty] private System.Collections.ObjectModel.ObservableCollection _hotKeys; - [ObservableProperty] private bool _isRebinding; - [ObservableProperty] private string _rebindingTitle = string.Empty; - [ObservableProperty] private string _rebindingPreview = string.Empty; + [ObservableProperty] + public partial System.Collections.ObjectModel.ObservableCollection HotKeys { get; set; } public HotKeySettingsViewModel(NavigationService navigationService, HotKeyService hotKeyService) { _navigationService = navigationService; _hotKeySettingsData = new HotKeySettingsDataViewModel(hotKeyService); - _hotKeys = _hotKeySettingsData.HotKeys; + HotKeys = _hotKeySettingsData.HotKeys; } public void Dispose() @@ -42,47 +39,6 @@ private void Cancel() [RelayCommand] private void StartRebind(HotKeyActionDataViewModel hotKey) { - if (IsRebinding) return; - _rebindActionId = hotKey.Action.Id; - IsRebinding = true; - DisableBackButton = true; - RebindingTitle = Localizer.Get(hotKey.Title); - RebindingPreview = hotKey.Keybind; - } - - [RelayCommand] - private void UpdateBindingPreview(string? text) - { - if (!IsRebinding || string.IsNullOrWhiteSpace(text)) return; - var keys = text.Split(" + ", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - RebindingPreview = HotKeyService.NormalizeKeyCombo(keys).Replace("\0", " + "); - } - - [RelayCommand] - private void ClearBindingPreview() - { - if (!IsRebinding) return; - RebindingPreview = string.Empty; - } - - [RelayCommand] - private void ConfirmRebind() - { - if (!IsRebinding || string.IsNullOrWhiteSpace(RebindingPreview) || string.IsNullOrWhiteSpace(_rebindActionId)) return; - var action = HotKeys.FirstOrDefault(x => x.Action.Id == _rebindActionId)?.Action; - if (action == null) return; - _hotKeySettingsData.SetBinding(action, HotKeyService.NormalizeKeyCombo(RebindingPreview.Replace(" + ", "\0"))); - HotKeys = _hotKeySettingsData.HotKeys; - CancelRebind(); - } - - [RelayCommand] - private void CancelRebind() - { - _rebindActionId = null; - IsRebinding = false; - DisableBackButton = false; - RebindingTitle = string.Empty; - RebindingPreview = string.Empty; + _navigationService.PushModal(new HotKeyCaptureNavigationData(hotKey)); } -} +} \ No newline at end of file diff --git a/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Settings/InputSettingsViewModel.cs b/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Settings/InputSettingsViewModel.cs index 84a7adbb..6ded3b90 100644 --- a/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Settings/InputSettingsViewModel.cs +++ b/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Settings/InputSettingsViewModel.cs @@ -24,10 +24,13 @@ public partial class InputSettingsViewModel( { private readonly Lock _lock = new(); - [ObservableProperty] private InputSettingsDataViewModel _inputSettingsData = new(settingsService, audioService); - [ObservableProperty] private bool _isRecording; - [ObservableProperty] private float _microphoneValue; - [ObservableProperty] private bool _detectingVoiceActivity; + [ObservableProperty] + public partial InputSettingsDataViewModel InputSettingsData { get; set; } = new(settingsService, audioService); + + [ObservableProperty] public partial bool IsRecording { get; set; } + [ObservableProperty] public partial float MicrophoneValue { get; set; } + [ObservableProperty] public partial bool DetectingVoiceActivity { get; set; } + private AudioCaptureDevice? _captureDevice; private CombinedAudioPreprocessor? _audioPreprocessor; @@ -69,7 +72,8 @@ private async Task OpenRecorder() { try { - if (await permissionsService.CheckAndRequestPermission() != PermissionStatus.Granted) + if (await permissionsService.CheckAndRequestPermission() != + PermissionStatus.Granted) throw new PermissionException("Settings.Input.Permissions.MicrophoneNotGranted"); lock (_lock) @@ -81,7 +85,8 @@ private async Task OpenRecorder() Constants.SampleRate, Constants.RecordingChannels, Constants.FrameSize, - InputSettingsData.InputDevice); + InputSettingsData.InputDevice, + InputSettingsData.HardwarePreprocessorsEnabled); _captureDevice.Start(); _captureDevice.OnAudioProcessed += Write; IsRecording = true; @@ -92,7 +97,7 @@ private async Task OpenRecorder() CloseRecorder(); // ReSharper disable once InconsistentlySynchronizedField notificationService.SendErrorNotification( - "Settings.Input.Notification.Badge", + "Settings.Input.Notification.Badge", ex.Message); } } @@ -100,6 +105,7 @@ private async Task OpenRecorder() private void Write(Span buffer, Capability _) { + if (!IsRecording) return; _audioPreprocessor?.Process(buffer); var floatCount = SampleVolume.Read(buffer, InputSettingsData.InputVolume); diff --git a/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Settings/NetworkSettingsViewModel.cs b/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Settings/NetworkSettingsViewModel.cs index 6c3a674b..2b377bdd 100644 --- a/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Settings/NetworkSettingsViewModel.cs +++ b/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Settings/NetworkSettingsViewModel.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.ObjectModel; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using VoiceCraft.Client.Services; @@ -13,8 +14,15 @@ public partial class NetworkSettingsViewModel( : ViewModelBase, IDisposable { //Network Settings - [ObservableProperty] private NetworkSettingsDataViewModel _networkSettingsData = new(settingsService); - [ObservableProperty] private PositioningType[] _positioningTypes = Enum.GetValues(); + [ObservableProperty] + public partial NetworkSettingsDataViewModel NetworkSettingsData { get; set; } = new(settingsService); + + [ObservableProperty] + public partial ObservableCollection PositioningTypes { get; set; } = + [ + new("Settings.Network.PositioningType.Server", PositioningType.Server), + new("Settings.Network.PositioningType.Client", PositioningType.Client) + ]; public void Dispose() { @@ -28,4 +36,6 @@ private void Cancel() if (DisableBackButton) return; navigationService.Back(); } + + public record PositioningTypeValue(string Title, PositioningType Value); } \ No newline at end of file diff --git a/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Settings/OutputSettingsViewModel.cs b/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Settings/OutputSettingsViewModel.cs index 5f273c28..909e80d2 100644 --- a/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Settings/OutputSettingsViewModel.cs +++ b/VoiceCraft.Client/VoiceCraft.Client/ViewModels/Settings/OutputSettingsViewModel.cs @@ -20,8 +20,9 @@ public partial class OutputSettingsViewModel : ViewModelBase, IDisposable private readonly NotificationService _notificationService; private readonly Lock _lock = new(); - [ObservableProperty] private OutputSettingsDataViewModel _outputSettingsData; - [ObservableProperty] private bool _isPlaying; + [ObservableProperty] public partial OutputSettingsDataViewModel OutputSettingsData { get; set; } + [ObservableProperty] public partial bool IsPlaying { get; set; } + private AudioPlaybackDevice? _playbackDevice; private IAudioClipper? _audioClipper; @@ -34,7 +35,7 @@ public OutputSettingsViewModel( _navigationService = navigationService; _audioService = audioService; _notificationService = notificationService; - _outputSettingsData = new OutputSettingsDataViewModel(settingsService, _audioService); + OutputSettingsData = new OutputSettingsDataViewModel(settingsService, _audioService); } public void Dispose() @@ -99,7 +100,7 @@ private void OpenPlayer() var callbackComponent = new CallbackProvider(_playbackDevice.Engine, _playbackDevice.Format, Read); callbackComponent.ConnectInput(new Oscillator(_playbackDevice.Engine, _playbackDevice.Format)); _playbackDevice.MasterMixer.AddComponent(callbackComponent); - + _playbackDevice.Start(); IsPlaying = true; } diff --git a/VoiceCraft.Client/VoiceCraft.Client/ViewModels/VoiceViewModel.cs b/VoiceCraft.Client/VoiceCraft.Client/ViewModels/VoiceViewModel.cs index fb5ab343..dbae3c4b 100644 --- a/VoiceCraft.Client/VoiceCraft.Client/ViewModels/VoiceViewModel.cs +++ b/VoiceCraft.Client/VoiceCraft.Client/ViewModels/VoiceViewModel.cs @@ -9,6 +9,7 @@ using VoiceCraft.Client.Models; using VoiceCraft.Client.Services; using VoiceCraft.Client.ViewModels.Data; +using VoiceCraft.Client.ViewModels.Modals; using VoiceCraft.Network; using VoiceCraft.Network.World; @@ -22,17 +23,15 @@ public partial class VoiceViewModel( : ViewModelBase, IDisposable { private VoiceCraftService? _service; - [ObservableProperty] private ObservableCollection _entityViewModels = []; - [ObservableProperty] private bool _isDeafened; - [ObservableProperty] private bool _isMuted; - [ObservableProperty] private bool _isServerDeafened; - [ObservableProperty] private bool _isServerMuted; - [ObservableProperty] private bool _isSpeaking; - [ObservableProperty] private EntityDataViewModel? _selectedEntity; - [ObservableProperty] private bool _showModal; - [ObservableProperty] private string _statusDescriptionText = string.Empty; - - [ObservableProperty] private string _statusTitleText = string.Empty; + [ObservableProperty] public partial ObservableCollection EntityViewModels { get; set; } = []; + [ObservableProperty] public partial bool IsDeafened { get; set; } + [ObservableProperty] public partial bool IsMuted { get; set; } + [ObservableProperty] public partial bool IsServerDeafened { get; set; } + [ObservableProperty] public partial bool IsServerMuted { get; set; } + [ObservableProperty] public partial bool IsSpeaking { get; set; } + [ObservableProperty] public partial EntityDataViewModel? SelectedEntity { get; set; } + [ObservableProperty] public partial string StatusDescriptionText { get; set; } = string.Empty; + [ObservableProperty] public partial string StatusTitleText { get; set; } = string.Empty; public override bool DisableBackButton { get; protected set; } = true; public void Dispose() @@ -55,13 +54,12 @@ public void Dispose() partial void OnSelectedEntityChanged(EntityDataViewModel? value) { - if (value == null) + if (value == null) return; + navigationService.PushModal(new EntityDataSettingsNavigationData(value)); + Task.Run(() => { - ShowModal = false; - return; - } - - ShowModal = true; + SelectedEntity = null; //This bug is annoying. + }); } [RelayCommand] @@ -71,7 +69,7 @@ private void ToggleMute() _service.Muted = !_service.Muted; IsMuted = _service.Muted; } - + [RelayCommand] private void ToggleDeafen() { diff --git a/VoiceCraft.Client/VoiceCraft.Client/Views/Error/ErrorMainWindow.axaml b/VoiceCraft.Client/VoiceCraft.Client/Views/Error/ErrorMainWindow.axaml index fe6399aa..e5aa2ed1 100644 --- a/VoiceCraft.Client/VoiceCraft.Client/Views/Error/ErrorMainWindow.axaml +++ b/VoiceCraft.Client/VoiceCraft.Client/Views/Error/ErrorMainWindow.axaml @@ -7,7 +7,8 @@ xmlns:views="clr-namespace:VoiceCraft.Client.Views.Error" MinHeight="200" MinWidth="200" Height="850" Width="1600" + WindowStartupLocation="CenterScreen" Icon="/Assets/vc.png" Title="Error"> - \ No newline at end of file + diff --git a/VoiceCraft.Client/VoiceCraft.Client/Views/Home/CrashLogView.axaml b/VoiceCraft.Client/VoiceCraft.Client/Views/Home/CrashLogView.axaml index 4e4b1458..548a7116 100644 --- a/VoiceCraft.Client/VoiceCraft.Client/Views/Home/CrashLogView.axaml +++ b/VoiceCraft.Client/VoiceCraft.Client/Views/Home/CrashLogView.axaml @@ -4,6 +4,7 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" x:Class="VoiceCraft.Client.Views.Home.CrashLogView" + xmlns:locale="clr-namespace:VoiceCraft.Client.Locales" xmlns:vm="clr-namespace:VoiceCraft.Client.ViewModels.Home" x:DataType="vm:CrashLogViewModel"> @@ -17,8 +18,11 @@ - - + + + + + @@ -54,4 +78,4 @@ - + \ No newline at end of file diff --git a/VoiceCraft.Client/VoiceCraft.Client/Views/MainView.axaml b/VoiceCraft.Client/VoiceCraft.Client/Views/MainView.axaml index 0a08bbd1..b5af931a 100644 --- a/VoiceCraft.Client/VoiceCraft.Client/Views/MainView.axaml +++ b/VoiceCraft.Client/VoiceCraft.Client/Views/MainView.axaml @@ -7,10 +7,38 @@ mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" x:Class="VoiceCraft.Client.Views.MainView" x:DataType="vm:MainViewModel"> - + + + + + + + + + + + + + + + - \ No newline at end of file diff --git a/VoiceCraft.Client/VoiceCraft.Client/Views/MainView.axaml.cs b/VoiceCraft.Client/VoiceCraft.Client/Views/MainView.axaml.cs index 4ec07a0d..625b1ea1 100644 --- a/VoiceCraft.Client/VoiceCraft.Client/Views/MainView.axaml.cs +++ b/VoiceCraft.Client/VoiceCraft.Client/Views/MainView.axaml.cs @@ -1,4 +1,7 @@ using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using VoiceCraft.Client.ViewModels; namespace VoiceCraft.Client.Views; @@ -8,4 +11,22 @@ public MainView() { InitializeComponent(); } + + protected override void OnLoaded(RoutedEventArgs e) + { + base.OnLoaded(e); + var insetsManager = TopLevel.GetTopLevel(this)?.InsetsManager; + + if (insetsManager == null) return; + insetsManager.DisplayEdgeToEdgePreference = true; + insetsManager.IsSystemBarVisible = true; + } + + private void ModalBackgroundOnPointerPressed(object? sender, PointerPressedEventArgs e) + { + var viewModel = (MainViewModel?)DataContext; + if (viewModel == null) return; + if (sender is not Border border || (border.Child?.IsPointerOver ?? false)) return; + viewModel.PopModal(); + } } \ No newline at end of file diff --git a/VoiceCraft.Client/VoiceCraft.Client/Views/MainWindow.axaml b/VoiceCraft.Client/VoiceCraft.Client/Views/MainWindow.axaml index d5bf44e2..84bd2e32 100644 --- a/VoiceCraft.Client/VoiceCraft.Client/Views/MainWindow.axaml +++ b/VoiceCraft.Client/VoiceCraft.Client/Views/MainWindow.axaml @@ -6,9 +6,10 @@ mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" MinHeight="200" MinWidth="200" Height="850" Width="1600" + WindowStartupLocation="CenterScreen" x:Class="VoiceCraft.Client.Views.MainWindow" Icon="/Assets/vc.png" Title="VoiceCraft"> - \ No newline at end of file + diff --git a/VoiceCraft.Client/VoiceCraft.Client/Views/Modals/EntityDataSettingsView.axaml b/VoiceCraft.Client/VoiceCraft.Client/Views/Modals/EntityDataSettingsView.axaml new file mode 100644 index 00000000..e46193a1 --- /dev/null +++ b/VoiceCraft.Client/VoiceCraft.Client/Views/Modals/EntityDataSettingsView.axaml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/VoiceCraft.Client/VoiceCraft.Client/Views/Modals/EntityDataSettingsView.axaml.cs b/VoiceCraft.Client/VoiceCraft.Client/Views/Modals/EntityDataSettingsView.axaml.cs new file mode 100644 index 00000000..b5954465 --- /dev/null +++ b/VoiceCraft.Client/VoiceCraft.Client/Views/Modals/EntityDataSettingsView.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace VoiceCraft.Client.Views.Modals; + +public partial class EntityDataSettingsView : UserControl +{ + public EntityDataSettingsView() + { + InitializeComponent(); + } +} \ No newline at end of file diff --git a/VoiceCraft.Client/VoiceCraft.Client/Views/Modals/HotKeyCaptureView.axaml b/VoiceCraft.Client/VoiceCraft.Client/Views/Modals/HotKeyCaptureView.axaml new file mode 100644 index 00000000..f0c2cc27 --- /dev/null +++ b/VoiceCraft.Client/VoiceCraft.Client/Views/Modals/HotKeyCaptureView.axaml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/VoiceCraft.Client/VoiceCraft.Client/Views/Modals/HotKeyCaptureView.axaml.cs b/VoiceCraft.Client/VoiceCraft.Client/Views/Modals/HotKeyCaptureView.axaml.cs new file mode 100644 index 00000000..6217ebea --- /dev/null +++ b/VoiceCraft.Client/VoiceCraft.Client/Views/Modals/HotKeyCaptureView.axaml.cs @@ -0,0 +1,95 @@ +using System.Collections.Generic; +using System.Linq; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using VoiceCraft.Client.Services; +using VoiceCraft.Client.ViewModels.Modals; + +namespace VoiceCraft.Client.Views.Modals; + +public partial class HotKeyCaptureView : UserControl +{ + private readonly HashSet _pressedKeys = []; + private readonly HashSet _pressedMouseButtons = []; + + public HotKeyCaptureView() + { + InitializeComponent(); + } + + private void HotKeyCapture_OnKeyDown(object? sender, KeyEventArgs e) + { + if (e.Key is Key.None or Key.System) return; + + _pressedKeys.Add(e.Key); + UpdatePreview(); + e.Handled = true; + } + + private void HotKeyCapture_OnKeyUp(object? sender, KeyEventArgs e) + { + if (e.Key is Key.None or Key.System) return; + + _pressedKeys.Remove(e.Key); + e.Handled = true; + } + + private void HotKeyCapture_OnPointerPressed(object? sender, PointerPressedEventArgs e) + { + var mouseButton = GetMouseButton(e.GetCurrentPoint(this).Properties.PointerUpdateKind); + + if (mouseButton == null) + return; + + _pressedMouseButtons.Add(mouseButton); + UpdatePreview(); + e.Handled = true; + } + + private void HotKeyCapture_OnPointerReleased(object? sender, PointerReleasedEventArgs e) + { + var mouseButton = e.InitialPressMouseButton switch + { + MouseButton.Left => HotKeyService.NormalizeMouseButton("Left"), + MouseButton.Right => HotKeyService.NormalizeMouseButton("Right"), + MouseButton.Middle => HotKeyService.NormalizeMouseButton("Middle"), + MouseButton.XButton1 => HotKeyService.NormalizeMouseButton("XButton1"), + MouseButton.XButton2 => HotKeyService.NormalizeMouseButton("XButton2"), + _ => null + }; + + if (mouseButton == null) + return; + + _pressedMouseButtons.Remove(mouseButton); + e.Handled = true; + } + + private void Visual_OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e) + { + if(sender is Control control) + control.Focus(); + } + + private void UpdatePreview() + { + if (DataContext is not HotKeyCaptureViewModel viewModel) return; + var keys = _pressedKeys.Select(key => key.ToString()).ToList(); + keys.AddRange(_pressedMouseButtons); + viewModel.UpdateBindingPreviewCommand.Execute(string.Join(" + ", keys)); + } + + private static string? GetMouseButton(PointerUpdateKind updateKind) + { + return updateKind switch + { + PointerUpdateKind.LeftButtonPressed => HotKeyService.NormalizeMouseButton("Left"), + PointerUpdateKind.RightButtonPressed => HotKeyService.NormalizeMouseButton("Right"), + PointerUpdateKind.MiddleButtonPressed => HotKeyService.NormalizeMouseButton("Middle"), + PointerUpdateKind.XButton1Pressed => HotKeyService.NormalizeMouseButton("XButton1"), + PointerUpdateKind.XButton2Pressed => HotKeyService.NormalizeMouseButton("XButton2"), + _ => null + }; + } +} \ No newline at end of file diff --git a/VoiceCraft.Client/VoiceCraft.Client/Views/Modals/TelemetryConsentView.axaml b/VoiceCraft.Client/VoiceCraft.Client/Views/Modals/TelemetryConsentView.axaml new file mode 100644 index 00000000..c05c3e62 --- /dev/null +++ b/VoiceCraft.Client/VoiceCraft.Client/Views/Modals/TelemetryConsentView.axaml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/VoiceCraft.Client/VoiceCraft.Client/Views/Modals/TelemetryConsentView.axaml.cs b/VoiceCraft.Client/VoiceCraft.Client/Views/Modals/TelemetryConsentView.axaml.cs new file mode 100644 index 00000000..58e9fc7d --- /dev/null +++ b/VoiceCraft.Client/VoiceCraft.Client/Views/Modals/TelemetryConsentView.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace VoiceCraft.Client.Views.Modals; + +public partial class TelemetryConsentView : UserControl +{ + public TelemetryConsentView() + { + InitializeComponent(); + } +} diff --git a/VoiceCraft.Client/VoiceCraft.Client/Views/Settings/AdvancedSettingsView.axaml b/VoiceCraft.Client/VoiceCraft.Client/Views/Settings/AdvancedSettingsView.axaml index a0cf9dd8..7f890238 100644 --- a/VoiceCraft.Client/VoiceCraft.Client/Views/Settings/AdvancedSettingsView.axaml +++ b/VoiceCraft.Client/VoiceCraft.Client/Views/Settings/AdvancedSettingsView.axaml @@ -42,8 +42,13 @@ Command="{Binding CrashCommand}"> + + - \ No newline at end of file + diff --git a/VoiceCraft.Client/VoiceCraft.Client/Views/Settings/GeneralSettingsView.axaml b/VoiceCraft.Client/VoiceCraft.Client/Views/Settings/GeneralSettingsView.axaml index 70f8a6db..7f6dceda 100644 --- a/VoiceCraft.Client/VoiceCraft.Client/Views/Settings/GeneralSettingsView.axaml +++ b/VoiceCraft.Client/VoiceCraft.Client/Views/Settings/GeneralSettingsView.axaml @@ -63,8 +63,13 @@ IsChecked="{Binding NotificationSettingsData.DisableNotifications}"> + + + + - \ No newline at end of file + diff --git a/VoiceCraft.Client/VoiceCraft.Client/Views/Settings/HotKeySettingsView.axaml b/VoiceCraft.Client/VoiceCraft.Client/Views/Settings/HotKeySettingsView.axaml index 12d202d3..7d4a0fbd 100644 --- a/VoiceCraft.Client/VoiceCraft.Client/Views/Settings/HotKeySettingsView.axaml +++ b/VoiceCraft.Client/VoiceCraft.Client/Views/Settings/HotKeySettingsView.axaml @@ -1,20 +1,10 @@ + x:Class="VoiceCraft.Client.Views.Settings.HotKeySettingsView"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/VoiceCraft.Client/VoiceCraft.Client/Views/Settings/HotKeySettingsView.axaml.cs b/VoiceCraft.Client/VoiceCraft.Client/Views/Settings/HotKeySettingsView.axaml.cs index 15753c37..752e6b4f 100644 --- a/VoiceCraft.Client/VoiceCraft.Client/Views/Settings/HotKeySettingsView.axaml.cs +++ b/VoiceCraft.Client/VoiceCraft.Client/Views/Settings/HotKeySettingsView.axaml.cs @@ -1,125 +1,11 @@ -using System.Collections.Generic; -using System.ComponentModel; -using System.Linq; using Avalonia.Controls; -using Avalonia.Input; -using VoiceCraft.Client.Services; -using VoiceCraft.Client.ViewModels.Settings; namespace VoiceCraft.Client.Views.Settings; public partial class HotKeySettingsView : UserControl { - private readonly HashSet _pressedKeys = []; - private readonly HashSet _pressedMouseButtons = []; - private HotKeySettingsViewModel? _observedViewModel; - public HotKeySettingsView() { InitializeComponent(); - DataContextChanged += OnDataContextChanged; - } - - private void HotKeyCapture_OnKeyDown(object? sender, KeyEventArgs e) - { - if (DataContext is not HotKeySettingsViewModel viewModel) return; - if (!viewModel.IsRebinding || e.Key is Key.None or Key.System) return; - - _pressedKeys.Add(e.Key); - UpdatePreview(viewModel); - e.Handled = true; - } - - private void HotKeyCapture_OnKeyUp(object? sender, KeyEventArgs e) - { - if (DataContext is not HotKeySettingsViewModel viewModel) return; - if (!viewModel.IsRebinding ||e.Key is Key.None or Key.System) return; - - _pressedKeys.Remove(e.Key); - e.Handled = true; - } - - private void HotKeyCapture_OnPointerPressed(object? sender, PointerPressedEventArgs e) - { - if (DataContext is not HotKeySettingsViewModel viewModel) return; - if (!viewModel.IsRebinding) return; - - var mouseButton = GetMouseButton(e.GetCurrentPoint(this).Properties.PointerUpdateKind); - - if (mouseButton == null) - return; - - _pressedMouseButtons.Add(mouseButton); - UpdatePreview(viewModel); - e.Handled = true; - } - - private void HotKeyCapture_OnPointerReleased(object? sender, PointerReleasedEventArgs e) - { - if (DataContext is not HotKeySettingsViewModel viewModel) return; - if (!viewModel.IsRebinding) return; - - var mouseButton = e.InitialPressMouseButton switch - { - MouseButton.Left => HotKeyService.NormalizeMouseButton("Left"), - MouseButton.Right => HotKeyService.NormalizeMouseButton("Right"), - MouseButton.Middle => HotKeyService.NormalizeMouseButton("Middle"), - MouseButton.XButton1 => HotKeyService.NormalizeMouseButton("XButton1"), - MouseButton.XButton2 => HotKeyService.NormalizeMouseButton("XButton2"), - _ => null - }; - - if (mouseButton == null) - return; - - _pressedMouseButtons.Remove(mouseButton); - e.Handled = true; - } - - private void OnDataContextChanged(object? sender, System.EventArgs e) - { - if (_observedViewModel != null) - _observedViewModel.PropertyChanged -= OnViewModelPropertyChanged; - - _observedViewModel = DataContext as HotKeySettingsViewModel; - - if (_observedViewModel != null) - _observedViewModel.PropertyChanged += OnViewModelPropertyChanged; - } - - private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e) - { - if (sender is not HotKeySettingsViewModel viewModel || e.PropertyName != nameof(HotKeySettingsViewModel.IsRebinding)) - return; - - ResetCapture(clearPreview: !viewModel.IsRebinding); - } - - private void UpdatePreview(HotKeySettingsViewModel viewModel) - { - var keys = _pressedKeys.Select(key => key.ToString()).ToList(); - keys.AddRange(_pressedMouseButtons); - viewModel.UpdateBindingPreviewCommand.Execute(string.Join(" + ", keys)); - } - - private static string? GetMouseButton(PointerUpdateKind updateKind) - { - return updateKind switch - { - PointerUpdateKind.LeftButtonPressed => HotKeyService.NormalizeMouseButton("Left"), - PointerUpdateKind.RightButtonPressed => HotKeyService.NormalizeMouseButton("Right"), - PointerUpdateKind.MiddleButtonPressed => HotKeyService.NormalizeMouseButton("Middle"), - PointerUpdateKind.XButton1Pressed => HotKeyService.NormalizeMouseButton("XButton1"), - PointerUpdateKind.XButton2Pressed => HotKeyService.NormalizeMouseButton("XButton2"), - _ => null - }; - } - - private void ResetCapture(bool clearPreview) - { - _pressedKeys.Clear(); - _pressedMouseButtons.Clear(); - if (clearPreview && DataContext is HotKeySettingsViewModel viewModel) - viewModel.ClearBindingPreviewCommand.Execute(null); } } diff --git a/VoiceCraft.Client/VoiceCraft.Client/Views/Settings/InputSettingsView.axaml b/VoiceCraft.Client/VoiceCraft.Client/Views/Settings/InputSettingsView.axaml index d7092677..eb085cd6 100644 --- a/VoiceCraft.Client/VoiceCraft.Client/Views/Settings/InputSettingsView.axaml +++ b/VoiceCraft.Client/VoiceCraft.Client/Views/Settings/InputSettingsView.axaml @@ -103,14 +103,19 @@ SelectedValue="{Binding InputSettingsData.EchoCanceler}" SelectedValueBinding="{Binding Id}" /> + + + + - + - + @@ -150,4 +155,4 @@ - \ No newline at end of file + diff --git a/VoiceCraft.Client/VoiceCraft.Client/Views/Settings/NetworkSettingsView.axaml b/VoiceCraft.Client/VoiceCraft.Client/Views/Settings/NetworkSettingsView.axaml index 452f3264..397d0306 100644 --- a/VoiceCraft.Client/VoiceCraft.Client/Views/Settings/NetworkSettingsView.axaml +++ b/VoiceCraft.Client/VoiceCraft.Client/Views/Settings/NetworkSettingsView.axaml @@ -36,11 +36,13 @@ + Text="{locale:Localize Settings.Network.PositioningType.Title}" /> + DisplayMemberBinding="{locale:Localize {Binding Title}}" + SelectedValue="{Binding NetworkSettingsData.PositioningType}" + SelectedValueBinding="{Binding Value}" /> - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/VoiceCraft.Client/VoiceCraft.Client/Views/VoiceView.axaml.cs b/VoiceCraft.Client/VoiceCraft.Client/Views/VoiceView.axaml.cs index 694dfc98..91a81897 100644 --- a/VoiceCraft.Client/VoiceCraft.Client/Views/VoiceView.axaml.cs +++ b/VoiceCraft.Client/VoiceCraft.Client/Views/VoiceView.axaml.cs @@ -1,6 +1,4 @@ using Avalonia.Controls; -using Avalonia.Input; -using VoiceCraft.Client.ViewModels; namespace VoiceCraft.Client.Views; @@ -10,12 +8,4 @@ public VoiceView() { InitializeComponent(); } - - private void ModalBackgroundOnPointerPressed(object? sender, PointerPressedEventArgs e) - { - var viewModel = (VoiceViewModel?)DataContext; - if (viewModel == null) return; - if (sender is not Border border || (border.Child?.IsPointerOver ?? false)) return; - viewModel.SelectedEntity = null; - } } \ No newline at end of file diff --git a/VoiceCraft.Client/VoiceCraft.Client/VoiceCraft.Client.csproj b/VoiceCraft.Client/VoiceCraft.Client/VoiceCraft.Client.csproj index 3bd7df9c..e8d434d8 100644 --- a/VoiceCraft.Client/VoiceCraft.Client/VoiceCraft.Client.csproj +++ b/VoiceCraft.Client/VoiceCraft.Client/VoiceCraft.Client.csproj @@ -1,6 +1,6 @@ - + - net9.0 + net10.0 enable latest true @@ -19,15 +19,10 @@ - - - None - All - - + diff --git a/VoiceCraft.Core.Tests/VoiceCraft.Core.Tests.csproj b/VoiceCraft.Core.Tests/VoiceCraft.Core.Tests.csproj index dc23f17b..707bdd1a 100644 --- a/VoiceCraft.Core.Tests/VoiceCraft.Core.Tests.csproj +++ b/VoiceCraft.Core.Tests/VoiceCraft.Core.Tests.csproj @@ -1,7 +1,7 @@ - net9.0 + net10.0 enable enable false diff --git a/VoiceCraft.Core.Tests/World/VoiceCraftEntityTests.cs b/VoiceCraft.Core.Tests/World/VoiceCraftEntityTests.cs index 02655473..57ae0c73 100644 --- a/VoiceCraft.Core.Tests/World/VoiceCraftEntityTests.cs +++ b/VoiceCraft.Core.Tests/World/VoiceCraftEntityTests.cs @@ -10,7 +10,7 @@ public class VoiceCraftEntityTests public void Name_TooLong_Throws() { var entity = new VoiceCraftEntity(1); - var tooLong = new string('a', VoiceCraft.Core.Constants.MaxStringLength + 1); + var tooLong = new string('a', Constants.MaxStringLength + 1); Assert.Throws(() => entity.Name = tooLong); } @@ -19,7 +19,7 @@ public void Name_TooLong_Throws() public void WorldId_TooLong_Throws() { var entity = new VoiceCraftEntity(1); - var tooLong = new string('a', VoiceCraft.Core.Constants.MaxStringLength + 1); + var tooLong = new string('a', Constants.MaxStringLength + 1); Assert.Throws(() => entity.WorldId = tooLong); } @@ -36,6 +36,22 @@ public void CaveFactor_And_MuffleFactor_AreClamped() Assert.Equal(0.0f, entity.MuffleFactor, 3); } + [Fact] + public void NonFiniteSpatialValues_AreSanitized() + { + var entity = new VoiceCraftEntity(1); + + entity.Position = new Vector3(float.NaN, float.PositiveInfinity, 3); + entity.Rotation = new Vector2(float.NegativeInfinity, 5); + entity.CaveFactor = float.NaN; + entity.MuffleFactor = float.PositiveInfinity; + + Assert.Equal(new Vector3(0, 0, 3), entity.Position); + Assert.Equal(new Vector2(0, 5), entity.Rotation); + Assert.Equal(0.0f, entity.CaveFactor, 3); + Assert.Equal(0.0f, entity.MuffleFactor, 3); + } + [Fact] public void AddVisibleEntity_IgnoresSelfAndDuplicates() { diff --git a/VoiceCraft.Core/Audio/SampleLerpVolume.cs b/VoiceCraft.Core/Audio/SampleLerpVolume.cs index 08add839..a870e888 100644 --- a/VoiceCraft.Core/Audio/SampleLerpVolume.cs +++ b/VoiceCraft.Core/Audio/SampleLerpVolume.cs @@ -6,23 +6,23 @@ public class SampleLerpVolume { public float TargetVolume { - get => _targetVolume; + get; set { - if (Math.Abs(_targetVolume - value) < Constants.FloatingPointTolerance) + if (Math.Abs(field - value) < Constants.FloatingPointTolerance) return; //Return since it's the same target volume, and we don't want to reset the counter. - _previousVolume = _targetVolume; - _targetVolume = value; + _previousVolume = field; + field = value; _fadeSamplesPosition = 0; //Reset position since we have a new target. } - } + } = 1; public TimeSpan FadeDuration { - get => _fadeDuration; + get; set { - _fadeDuration = value; + field = value; var newSamples = (int)(value.TotalMilliseconds * _sampleRate / 1000); if (newSamples < _fadeSamplesPosition) //Make sure we don't overshoot the target when lerping. { @@ -33,9 +33,7 @@ public TimeSpan FadeDuration } } - private int _sampleRate; - private TimeSpan _fadeDuration; - private float _targetVolume = 1; + private readonly int _sampleRate; private float _previousVolume; private float _fadeSamplesDuration; private float _fadeSamplesPosition; diff --git a/VoiceCraft.Core/Constants.cs b/VoiceCraft.Core/Constants.cs index 99e58b86..876fcba1 100644 --- a/VoiceCraft.Core/Constants.cs +++ b/VoiceCraft.Core/Constants.cs @@ -5,8 +5,8 @@ namespace VoiceCraft.Core public static class Constants { public const int Major = 1; //These need to be the same on both client and server! - public const int Minor = 5; //These need to be the same on both client and server! - public const int Patch = 1; //This does not need to be the same on client and server. + public const int Minor = 6; //These need to be the same on both client and server! + public const int Patch = 0; //This does not need to be the same on client and server. //Tick public const int TickRate = 50; @@ -40,6 +40,7 @@ public static class Constants public const string ApplicationDirectory = "voicecraft"; public const string SettingsFile = "Settings.json"; public const string ExceptionLogsFile = "ExceptionLogs.json"; + public const string TelemetryBaseUrl = "https://vc-api.avion.team"; //RPC public const string ApplicationId = "1364434932968984669"; diff --git a/VoiceCraft.Core/Diagnostics/CrashLogRecord.cs b/VoiceCraft.Core/Diagnostics/CrashLogRecord.cs new file mode 100644 index 00000000..1375ce9a --- /dev/null +++ b/VoiceCraft.Core/Diagnostics/CrashLogRecord.cs @@ -0,0 +1,43 @@ +using System.ComponentModel; +using System.Runtime.CompilerServices; + +namespace VoiceCraft.Core.Diagnostics; + +public class CrashLogRecord : INotifyPropertyChanged +{ + public string Message + { + get; + set + { + if (field == value) + return; + + field = value; + OnPropertyChanged(); + } + } = string.Empty; + + public string? DumpUrl + { + get; + set + { + if (field == value) + return; + + field = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(HasDumpUrl)); + } + } + + public bool HasDumpUrl => !string.IsNullOrWhiteSpace(DumpUrl); + + public event PropertyChangedEventHandler? PropertyChanged; + + private void OnPropertyChanged([CallerMemberName] string? propertyName = null) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } +} diff --git a/VoiceCraft.Core/Locales/Localizer.cs b/VoiceCraft.Core/Locales/Localizer.cs index 98168aef..90ba5841 100644 --- a/VoiceCraft.Core/Locales/Localizer.cs +++ b/VoiceCraft.Core/Locales/Localizer.cs @@ -3,40 +3,62 @@ using System.Collections.ObjectModel; using System.ComponentModel; using System.Runtime.CompilerServices; +using System.Threading; using VoiceCraft.Core.Interfaces; namespace VoiceCraft.Core.Locales { public sealed class Localizer : INotifyPropertyChanged, INotifyPropertyChanging { + private static readonly Lock Lock = new(); private static IBaseLocalizer _baseLocalizer = new EmptyBaseLocalizer(); //Private set language - private string _language = ""; public static Localizer Instance { get; } = new(); public static IBaseLocalizer BaseLocalizer { - get => _baseLocalizer; + get + { + lock (Lock) + { + return _baseLocalizer; + } + } set { - if (value == _baseLocalizer) return; - _baseLocalizer = value; - Instance.Language = Instance.Language; + lock (Lock) + { + if (value == _baseLocalizer) return; + _baseLocalizer = value; + Instance.Language = Instance.Language; + } } } public string Language { - get => _language; + get; set { - value = _baseLocalizer.Reload(value); - Instance.SetField(ref _language, value); + lock (Lock) + { + value = _baseLocalizer.Reload(value); + Instance.SetField(ref field, value); + } } - } + } = ""; - public static ObservableCollection Languages => _baseLocalizer.Languages; + public static ObservableCollection Languages + { + get + { + lock (Lock) + { + return _baseLocalizer.Languages; + } + } + } public event PropertyChangedEventHandler? PropertyChanged; //Property Changed Events @@ -45,7 +67,10 @@ public string Language public static string Get(string key) { - return _baseLocalizer.Get(key); + lock (Lock) + { + return _baseLocalizer.Get(key); + } } private void SetField(ref T field, T value, [CallerMemberName] string? propertyName = null) @@ -88,4 +113,4 @@ public string Reload(string language) throw new NotSupportedException(); } } -} \ No newline at end of file +} diff --git a/VoiceCraft.Core/Telemetry/TelemetryContracts.cs b/VoiceCraft.Core/Telemetry/TelemetryContracts.cs new file mode 100644 index 00000000..415401bd --- /dev/null +++ b/VoiceCraft.Core/Telemetry/TelemetryContracts.cs @@ -0,0 +1,72 @@ +using System.Collections.Generic; + +namespace VoiceCraft.Core.Telemetry; + +public class TelemetryAppInfo +{ + public string AppName { get; set; } = string.Empty; + public string Version { get; set; } = string.Empty; + public string? Channel { get; set; } + public string? Build { get; set; } + public Dictionary? Config { get; set; } +} + +public class TelemetryDeviceInfo +{ + public string? OsVersion { get; set; } + public string? OsName { get; set; } + public string? OsBuild { get; set; } + public string? OsDescription { get; set; } + public string? Vendor { get; set; } + public string? Model { get; set; } + public string? Architecture { get; set; } + public string? ProcessArchitecture { get; set; } + public string? Runtime { get; set; } + public string? Locale { get; set; } + public int? CpuCores { get; set; } + public long? MemoryMb { get; set; } +} + +public class TelemetryServerInfo +{ + public int? CpuCores { get; set; } + public long? MemoryMb { get; set; } + public long? UptimeSec { get; set; } + public string? Platform { get; set; } + public string? Architecture { get; set; } + public string? Locale { get; set; } + public int? ConnectedClients { get; set; } +} + +public class TelemetryEventRequest +{ + public string? Fingerprint { get; set; } + public string Role { get; set; } = string.Empty; + public TelemetryAppInfo App { get; set; } = new(); + public TelemetryDeviceInfo? Device { get; set; } + public TelemetryServerInfo? Server { get; set; } + public Dictionary? Metrics { get; set; } + public string[]? Tags { get; set; } + public string? Timestamp { get; set; } +} + +public class TelemetryDumpRequest +{ + public string? Fingerprint { get; set; } + public string Role { get; set; } = string.Empty; + public string Category { get; set; } = string.Empty; + public string? Title { get; set; } + public TelemetryAppInfo? App { get; set; } + public TelemetryDeviceInfo? Device { get; set; } + public TelemetryServerInfo? Server { get; set; } + public Dictionary Payload { get; set; } = new(); +} + +public class TelemetryDumpResponse +{ + public string Id { get; set; } = string.Empty; + public string Url { get; set; } = string.Empty; + public string? ViewUrl { get; set; } + public string? JsonUrl { get; set; } + public string CreatedAt { get; set; } = string.Empty; +} diff --git a/VoiceCraft.Core/Telemetry/TelemetryTransport.cs b/VoiceCraft.Core/Telemetry/TelemetryTransport.cs new file mode 100644 index 00000000..c03fd045 --- /dev/null +++ b/VoiceCraft.Core/Telemetry/TelemetryTransport.cs @@ -0,0 +1,78 @@ +using System; +using System.Net.Http; +using System.Net.Http.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; + +namespace VoiceCraft.Core.Telemetry; + +public class TelemetryTransport +{ + private readonly HttpClient _httpClient = new() + { + Timeout = TimeSpan.FromSeconds(15) + }; + + public async Task SendTelemetryAsync(TelemetryEventRequest payload, + CancellationToken cancellationToken = default) + { + var uri = BuildUri("/v1/telemetry"); + Exception exception; + try + { + using var content = JsonContent.Create(payload, TelemetryJsonContext.Default.TelemetryEventRequest); + using var response = await _httpClient.PostAsync(uri, content, cancellationToken); + if (response.IsSuccessStatusCode) return; + var body = await response.Content.ReadAsStringAsync(cancellationToken); + exception = new Exception( + $"Telemetry POST {uri} failed with {(int)response.StatusCode} {response.ReasonPhrase}. Body: {body}"); + } + catch (Exception ex) + { + exception = new Exception($"Telemetry POST {uri} failed with exception {ex.GetType().Name}: {ex.Message}"); + } + + throw exception; + } + + public async Task SendDumpAsync(TelemetryDumpRequest payload, + CancellationToken cancellationToken = default) + { + var uri = BuildUri("/v1/dumps"); + Exception exception; + try + { + using var content = JsonContent.Create(payload, TelemetryJsonContext.Default.TelemetryDumpRequest); + using var response = await _httpClient.PostAsync(uri, content, cancellationToken); + if (response.IsSuccessStatusCode) + return await response.Content.ReadFromJsonAsync( + TelemetryJsonContext.Default.TelemetryDumpResponse, + cancellationToken); + var body = await response.Content.ReadAsStringAsync(cancellationToken); + exception = new Exception( + $"Telemetry dump POST {uri} failed with {(int)response.StatusCode} {response.ReasonPhrase}. Body: {body}"); + } + catch (Exception ex) + { + exception = new Exception( + $"Telemetry dump POST {uri} failed with exception {ex.GetType().Name}: {ex.Message}"); + } + + throw exception; + } + + private static Uri BuildUri(string relativePath) + { + return new Uri(new Uri(Constants.TelemetryBaseUrl, UriKind.Absolute), relativePath); + } +} + +[JsonSourceGenerationOptions( + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true)] +[JsonSerializable(typeof(TelemetryEventRequest))] +[JsonSerializable(typeof(TelemetryDumpRequest))] +[JsonSerializable(typeof(TelemetryDumpResponse))] +public partial class TelemetryJsonContext : JsonSerializerContext; \ No newline at end of file diff --git a/VoiceCraft.Core/VoiceCraft.Core.csproj b/VoiceCraft.Core/VoiceCraft.Core.csproj index 8eabd440..93a84f5a 100644 --- a/VoiceCraft.Core/VoiceCraft.Core.csproj +++ b/VoiceCraft.Core/VoiceCraft.Core.csproj @@ -1,6 +1,6 @@  - net9.0 + net10.0 enable true diff --git a/VoiceCraft.Core/World/VoiceCraftEntity.cs b/VoiceCraft.Core/World/VoiceCraftEntity.cs index d042b517..9859e7c9 100644 --- a/VoiceCraft.Core/World/VoiceCraftEntity.cs +++ b/VoiceCraft.Core/World/VoiceCraftEntity.cs @@ -9,18 +9,7 @@ namespace VoiceCraft.Core.World public class VoiceCraftEntity(int id) { private readonly ConcurrentDictionary _visibleEntities = new(); - private float _caveFactor; - private bool _deafened; - private ushort _effectBitmask = ushort.MaxValue; - private ushort _listenBitmask = ushort.MaxValue; private float _loudness; - private float _muffleFactor; - private bool _muted; - private string _name = "New Entity"; - private Vector3 _position; - private Vector2 _rotation; - private ushort _talkBitmask = ushort.MaxValue; - private string _worldId = string.Empty; //Properties public int Id { get; } = id; @@ -116,127 +105,156 @@ public virtual void Destroy() public string WorldId { - get => _worldId; + get; set { - if (_worldId == value) return; + if (field == value) return; if (value.Length > Constants.MaxStringLength) throw new ArgumentOutOfRangeException(); - _worldId = value; - OnWorldIdUpdated?.Invoke(_worldId, this); + field = value; + OnWorldIdUpdated?.Invoke(field, this); } - } + } = string.Empty; public string Name { - get => _name; + get; set { - if (_name == value) return; + if (field == value) return; if (value.Length > Constants.MaxStringLength) throw new ArgumentOutOfRangeException(); - _name = value; - OnNameUpdated?.Invoke(_name, this); + field = value; + OnNameUpdated?.Invoke(field, this); } - } + } = "New Entity"; public bool Muted { - get => _muted; + get; set { - if (_muted == value) return; - _muted = value; - OnMuteUpdated?.Invoke(_muted, this); + if (field == value) return; + field = value; + OnMuteUpdated?.Invoke(field, this); } } public bool Deafened { - get => _deafened; + get; set { - if (_deafened == value) return; - _deafened = value; - OnDeafenUpdated?.Invoke(_deafened, this); + if (field == value) return; + field = value; + OnDeafenUpdated?.Invoke(field, this); } } public ushort TalkBitmask { - get => _talkBitmask; + get; set { - if (_talkBitmask == value) return; - _talkBitmask = value; - OnTalkBitmaskUpdated?.Invoke(_talkBitmask, this); + if (field == value) return; + field = value; + OnTalkBitmaskUpdated?.Invoke(field, this); } - } + } = ushort.MaxValue; public ushort ListenBitmask { - get => _listenBitmask; + get; set { - if (_listenBitmask == value) return; - _listenBitmask = value; - OnListenBitmaskUpdated?.Invoke(_listenBitmask, this); + if (field == value) return; + field = value; + OnListenBitmaskUpdated?.Invoke(field, this); } - } + } = ushort.MaxValue; public ushort EffectBitmask { - get => _effectBitmask; + get; set { - if (_effectBitmask == value) return; - _effectBitmask = value; - OnEffectBitmaskUpdated?.Invoke(_effectBitmask, this); + if (field == value) return; + field = value; + OnEffectBitmaskUpdated?.Invoke(field, this); } - } + } = ushort.MaxValue; public Vector3 Position { - get => _position; + get; set { - if (_position == value) return; - _position = value; - OnPositionUpdated?.Invoke(_position, this); + value = Sanitize(value); + if (field == value) return; + field = value; + OnPositionUpdated?.Invoke(field, this); } } public Vector2 Rotation { - get => _rotation; + get; set { - if (_rotation == value) return; - _rotation = value; - OnRotationUpdated?.Invoke(_rotation, this); + value = Sanitize(value); + if (field == value) return; + field = value; + OnRotationUpdated?.Invoke(field, this); } } public float CaveFactor { - get => _caveFactor; + get; set { - if (Math.Abs(_caveFactor - value) < Constants.FloatingPointTolerance) return; - _caveFactor = Math.Clamp(value, 0f, 1f); - OnCaveFactorUpdated?.Invoke(_caveFactor, this); + value = ClampFinite(value, 0f, 1f); + if (Math.Abs(field - value) < Constants.FloatingPointTolerance) return; + field = value; + OnCaveFactorUpdated?.Invoke(field, this); } } public float MuffleFactor { - get => _muffleFactor; + get; set { - if (Math.Abs(_muffleFactor - value) < Constants.FloatingPointTolerance) return; - _muffleFactor = Math.Clamp(value, 0f, 1f); - OnMuffleFactorUpdated?.Invoke(_muffleFactor, this); + value = ClampFinite(value, 0f, 1f); + if (Math.Abs(field - value) < Constants.FloatingPointTolerance) return; + field = value; + OnMuffleFactorUpdated?.Invoke(field, this); } } #endregion + + private static Vector3 Sanitize(Vector3 value) + { + return new Vector3( + Sanitize(value.X), + Sanitize(value.Y), + Sanitize(value.Z)); + } + + private static Vector2 Sanitize(Vector2 value) + { + return new Vector2( + Sanitize(value.X), + Sanitize(value.Y)); + } + + private static float Sanitize(float value) + { + return float.IsFinite(value) ? value : 0f; + } + + private static float ClampFinite(float value, float min, float max) + { + return float.IsFinite(value) ? Math.Clamp(value, min, max) : min; + } } -} \ No newline at end of file +} diff --git a/VoiceCraft.Core/World/VoiceCraftWorld.cs b/VoiceCraft.Core/World/VoiceCraftWorld.cs index 5b3e6d8f..dddccd9c 100644 --- a/VoiceCraft.Core/World/VoiceCraftWorld.cs +++ b/VoiceCraft.Core/World/VoiceCraftWorld.cs @@ -44,6 +44,11 @@ public void AddEntity(VoiceCraftEntity entity) return entity; } + public bool ContainsEntity(int id) + { + return _entities.ContainsKey(id); + } + public int GetNextId() { while (_entities.ContainsKey(_nextEntityId)) diff --git a/VoiceCraft.Network.Tests/Audio/JitterBufferTests.cs b/VoiceCraft.Network.Tests/Audio/JitterBufferTests.cs new file mode 100644 index 00000000..f60e5ac3 --- /dev/null +++ b/VoiceCraft.Network.Tests/Audio/JitterBufferTests.cs @@ -0,0 +1,19 @@ +using Xunit; +using VoiceCraft.Network.Audio; + +namespace VoiceCraft.Network.Tests.Audio; + +public class JitterBufferTests +{ + [Fact] + public void Get_WhenSequenceWraps_DoesNotThrow() + { + var buffer = new JitterBuffer(TimeSpan.Zero); + var packet = new JitterPacket(ushort.MaxValue, [1, 2, 3]); + + buffer.Add(packet); + + Assert.True(buffer.Get(out var readPacket)); + Assert.Same(packet, readPacket); + } +} diff --git a/VoiceCraft.Network.Tests/Clients/VoiceCraftClientTests.cs b/VoiceCraft.Network.Tests/Clients/VoiceCraftClientTests.cs new file mode 100644 index 00000000..00bfff2b --- /dev/null +++ b/VoiceCraft.Network.Tests/Clients/VoiceCraftClientTests.cs @@ -0,0 +1,105 @@ +using VoiceCraft.Core.Interfaces; +using VoiceCraft.Network.Clients; +using VoiceCraft.Network.Packets.VcPackets; +using VoiceCraft.Network.Packets.VcPackets.Event; +using Xunit; + +namespace VoiceCraft.Network.Tests.Clients; + +public class VoiceCraftClientTests +{ + [Fact] + public void EntityCreated_WhenEntityAlreadyExists_IsIgnored() + { + using var client = new TestVoiceCraftClient(); + var packet = new VcOnEntityCreatedPacket(); + packet.Set(42, "First"); + + client.Dispatch(packet); + packet.Set(42, "Duplicate", true, true); + client.Dispatch(packet); + + var entity = Assert.Single(client.World.Entities); + Assert.Equal("First", entity.Name); + Assert.False(entity.Muted); + Assert.False(entity.Deafened); + } + + [Fact] + public void EntityDestroyed_WhenEntityDoesNotExist_IsIgnored() + { + using var client = new TestVoiceCraftClient(); + + client.Dispatch(new VcOnEntityDestroyedPacket().Set(404)); + + Assert.Empty(client.World.Entities); + } + + private sealed class TestVoiceCraftClient() : VoiceCraftClient(new FakeAudioEncoder(), () => new FakeAudioDecoder()) + { + public override PositioningType PositioningType => PositioningType.Client; + public override event Action? OnConnected; + public override event Action? OnDisconnected; + + public void Dispatch(IVoiceCraftPacket packet) + { + ExecutePacket(packet); + } + + public override Task PingAsync(string ip, int port, CancellationToken token = default) + { + throw new NotSupportedException(); + } + + public override Task ConnectAsync(string ip, int port, Guid userGuid, Guid serverUserGuid, string locale, + PositioningType positioningType) + { + OnConnected?.Invoke(); + return Task.CompletedTask; + } + + public override void Update() + { + } + + public override Task DisconnectAsync(string? reason = null) + { + OnDisconnected?.Invoke(reason); + return Task.CompletedTask; + } + + public override void SendUnconnectedPacket(string ip, int port, T packet) + { + PacketPool.Return(packet); + } + + public override void SendPacket(T packet, VcDeliveryMethod deliveryMethod = VcDeliveryMethod.Reliable) + { + PacketPool.Return(packet); + } + } + + private sealed class FakeAudioEncoder : IAudioEncoder + { + public int Encode(Span data, Span output, int samples) + { + return 0; + } + + public void Dispose() + { + } + } + + private sealed class FakeAudioDecoder : IAudioDecoder + { + public int Decode(Span buffer, Span output, int samples) + { + return 0; + } + + public void Dispose() + { + } + } +} diff --git a/VoiceCraft.Network.Tests/Codecs/McApiStringCodecTests.cs b/VoiceCraft.Network.Tests/Codecs/McApiStringCodecTests.cs deleted file mode 100644 index 6e688019..00000000 --- a/VoiceCraft.Network.Tests/Codecs/McApiStringCodecTests.cs +++ /dev/null @@ -1,87 +0,0 @@ -using System.Text.Json; -using Xunit; -using VoiceCraft.Network.Packets.McHttpPackets; - -namespace VoiceCraft.Network.Tests.Codecs; - -public class McApiStringCodecTests -{ - [Fact] - public void EncodeDecode_RoundTrips_ByteSequences() - { - var rng = new Random(1337); - - foreach (var length in Enumerable.Range(0, 257)) - { - var data = new byte[length]; - rng.NextBytes(data); - - var encoded = McApiStringCodec.Encode(data); - var decoded = McApiStringCodec.Decode(encoded); - - Assert.Equal(data, decoded); - } - } - - [Fact] - public void Encode_Uses_OnlyMcApiSafeCharacters() - { - var data = Enumerable.Range(0, ushort.MaxValue + 1) - .SelectMany(value => new[] { (byte)(value >> 8), (byte)value }) - .ToArray(); - - var encoded = McApiStringCodec.Encode(data); - - Assert.All(encoded, ch => - { - Assert.True(McApiStringCodec.IsSafePayloadCharacter(ch)); - Assert.False(ch == '|'); - Assert.False(ch == '"'); - Assert.False(ch == '\\'); - Assert.False(ch == '%'); - Assert.False(ch == ' '); - Assert.False(ch < 0x20); - Assert.False(ch == 0x7F); - Assert.True(ch <= 0x7E); - }); - } - - [Fact] - public void Decode_Rejects_InvalidCharacters() - { - Assert.Throws(() => McApiStringCodec.Decode("|")); - Assert.Throws(() => McApiStringCodec.Decode("\"")); - Assert.Throws(() => McApiStringCodec.Decode("\\")); - Assert.Throws(() => McApiStringCodec.Decode("%")); - Assert.Throws(() => McApiStringCodec.Decode(" ")); - } - - [Fact] - public void EncodeDecode_Preserves_LeadingZeroBytes() - { - var data = new byte[] { 0, 0, 0, 1, 2, 3, 0, 4 }; - var encoded = McApiStringCodec.Encode(data); - var decoded = McApiStringCodec.Decode(encoded); - - Assert.Equal(data, decoded); - } - - [Fact] - public void EncodedPacket_RoundTrips_ThroughJson() - { - var bytes = Enumerable.Range(0, 511).Select(i => (byte)(i % 256)).ToArray(); - var encoded = McApiStringCodec.Encode(bytes); - var packet = new McHttpUpdatePacket - { - Packets = [encoded] - }; - - var json = JsonSerializer.Serialize(packet, McHttpUpdatePacketGenerationContext.Default.McHttpUpdatePacket); - var roundTripped = JsonSerializer.Deserialize(json, McHttpUpdatePacketGenerationContext.Default.McHttpUpdatePacket); - - Assert.NotNull(roundTripped); - Assert.Single(roundTripped!.Packets); - Assert.Equal(encoded, roundTripped.Packets[0]); - Assert.Equal(bytes, McApiStringCodec.Decode(roundTripped.Packets[0])); - } -} diff --git a/VoiceCraft.Network.Tests/Codecs/McWssPacketFramingTests.cs b/VoiceCraft.Network.Tests/Codecs/McWssPacketFramingTests.cs deleted file mode 100644 index a48aca5d..00000000 --- a/VoiceCraft.Network.Tests/Codecs/McWssPacketFramingTests.cs +++ /dev/null @@ -1,64 +0,0 @@ -using Xunit; - -namespace VoiceCraft.Network.Tests.Codecs; - -public class McWssPacketFramingTests -{ - [Fact] - public void PackUnpack_RoundTrips_MultiplePackets() - { - var packets = new[] - { - "abc123", - "payload:with:colon", - string.Empty, - "ZZ-top" - }; - - var packed = McWssPacketFraming.Pack(packets); - var unpacked = McWssPacketFraming.Unpack(packed); - - Assert.Equal(packets, unpacked); - } - - [Fact] - public void TryAppendFrame_Stops_WhenFrameWouldExceedMaxLength() - { - var builder = new System.Text.StringBuilder(); - - var first = McWssPacketFraming.TryAppendFrame(builder, "abcd", 14, allowOversizedFirstFrame: false); - var second = McWssPacketFraming.TryAppendFrame(builder, "efghijkl", 14, allowOversizedFirstFrame: false); - - Assert.True(first); - Assert.False(second); - Assert.Equal(["abcd"], McWssPacketFraming.Unpack(builder.ToString())); - } - - [Fact] - public void TryAppendFrame_AllowsOversizedFirstFrame_WhenConfigured() - { - var builder = new System.Text.StringBuilder(); - - var appended = McWssPacketFraming.TryAppendFrame(builder, "abcdefghijklmnop", 5, allowOversizedFirstFrame: true); - - Assert.True(appended); - Assert.Equal(["abcdefghijklmnop"], McWssPacketFraming.Unpack(builder.ToString())); - } - - [Fact] - public void Unpack_Rejects_MalformedFrames() - { - Assert.Throws(() => McWssPacketFraming.Unpack("abc")); - Assert.Throws(() => McWssPacketFraming.Unpack("|x")); - Assert.Throws(() => McWssPacketFraming.Unpack("00short")); - } - - [Fact] - public void Pack_UsesCompactTwoCharacterHeader() - { - var packed = McWssPacketFraming.Pack(["abcd"]); - - Assert.Equal(6, packed.Length); - Assert.Equal("abcd", McWssPacketFraming.Unpack(packed).Single()); - } -} diff --git a/VoiceCraft.Network.Tests/Integration/VoiceCraftClientServerSmokeTests.cs b/VoiceCraft.Network.Tests/Integration/VoiceCraftClientServerSmokeTests.cs index 76a57326..ce721b8d 100644 --- a/VoiceCraft.Network.Tests/Integration/VoiceCraftClientServerSmokeTests.cs +++ b/VoiceCraft.Network.Tests/Integration/VoiceCraftClientServerSmokeTests.cs @@ -3,7 +3,6 @@ using System.Numerics; using Xunit; using VoiceCraft.Core.World; -using VoiceCraft.Network; using VoiceCraft.Network.Clients; using VoiceCraft.Network.Servers; using VoiceCraft.Network.Systems; diff --git a/VoiceCraft.Network.Tests/Packets/PacketSerializationTests.cs b/VoiceCraft.Network.Tests/Packets/PacketSerializationTests.cs index 07a0f8a3..13155d3e 100644 --- a/VoiceCraft.Network.Tests/Packets/PacketSerializationTests.cs +++ b/VoiceCraft.Network.Tests/Packets/PacketSerializationTests.cs @@ -82,7 +82,7 @@ public void NetworkEntityCreatedEvent_RoundTrips() { var userGuid = Guid.NewGuid(); var packet = new VcOnNetworkEntityCreatedPacket() - .Set(42, "Alpha", true, false, userGuid, true, false); + .Set(42, "Alpha", true, false, userGuid, true); var clone = RoundTrip(packet, () => new VcOnNetworkEntityCreatedPacket()); diff --git a/VoiceCraft.Network.Tests/Performance/AllocationRegressionTests.cs b/VoiceCraft.Network.Tests/Performance/AllocationRegressionTests.cs new file mode 100644 index 00000000..59c1d528 --- /dev/null +++ b/VoiceCraft.Network.Tests/Performance/AllocationRegressionTests.cs @@ -0,0 +1,206 @@ +using LiteNetLib.Utils; +using Xunit; +using VoiceCraft.Core.Interfaces; +using VoiceCraft.Core.World; +using VoiceCraft.Network.Interfaces; +using VoiceCraft.Network.NetPeers; +using VoiceCraft.Network.Systems; +using VoiceCraft.Network.World; + +namespace VoiceCraft.Network.Tests.Performance; + +[Collection("AllocationRegression")] +public class AllocationRegressionTests +{ + private const int SnapshotReadMaxBytesPerRead = 2; + + [Fact] + public void AudioEffects_RepeatedReads_AreNearlyAllocationFree() + { + using var effectSystem = new AudioEffectSystem(); + effectSystem.SetEffect(1, new FakeVisibleEffect(true)); + + var effects = effectSystem.AudioEffects; + Assert.NotNull(effects); + + var allocated = MeasureAllocatedBytes( + () => GC.KeepAlive(effects), + iterations: 10_000); + + AssertNearlyAllocationFreeSnapshotReads(allocated, iterations: 10_000); + } + + [Fact] + public void AudioEffects_Getter_RepeatedReads_AreNearlyAllocationFree() + { + using var effectSystem = new AudioEffectSystem(); + AddVisibleEffects(effectSystem, 4); + + var getterAllocated = MeasureAllocatedBytes( + () => GC.KeepAlive(effectSystem.AudioEffects), + iterations: 5_000); + + AssertNearlyAllocationFreeSnapshotReads(getterAllocated, iterations: 5_000); + } + + [Fact] + public void Snapshot_Instance_IsStable_OnRead_AndChanges_OnMutation() + { + using var effectSystem = new AudioEffectSystem(); + AddVisibleEffects(effectSystem, 2); + + var snapshotBeforeRead = effectSystem.AudioEffects; + Assert.NotNull(snapshotBeforeRead); + + var readAllocated = MeasureAllocatedBytes( + () => GC.KeepAlive(snapshotBeforeRead), + iterations: 5_000); + var snapshotAfterRead = effectSystem.AudioEffects; + Assert.NotNull(snapshotAfterRead); + + effectSystem.SetEffect(4, new FakeVisibleEffect(true)); + + var snapshotAfterMutation = effectSystem.AudioEffects; + Assert.NotNull(snapshotAfterMutation); + + AssertNearlyAllocationFreeSnapshotReads(readAllocated, iterations: 5_000); + Assert.Same(snapshotBeforeRead, snapshotAfterRead); + Assert.NotSame(snapshotAfterRead, snapshotAfterMutation); + } + + [Fact] + public void VisibilityUpdate_Allocation_Remains_Roughly_Flat_As_EffectCount_Grows() + { + var lowEffectAllocated = MeasureCurrentVisibilityUpdateAllocations(entityCount: 40, effectCount: 1); + var highEffectAllocated = MeasureCurrentVisibilityUpdateAllocations(entityCount: 40, effectCount: 8); + + Assert.True( + highEffectAllocated < lowEffectAllocated * 2 + 8_192, + $"Expected current visibility allocations to stay roughly flat as effect count grows. Low={lowEffectAllocated}, High={highEffectAllocated}"); + } + + [Theory] + [InlineData(16, 2)] + [InlineData(32, 2)] + [InlineData(64, 2)] + public void VisibilityUpdate_SteadyState_Allocations_Scale_With_WorldSize_Not_EffectLookups( + int entityCount, + int iterations) + { + using var world = new VoiceCraftWorld(); + using var effectSystem = new AudioEffectSystem(); + var visibilitySystem = new VisibilitySystem(world, effectSystem); + + AddEntities(world, entityCount); + AddVisibleEffects(effectSystem, 4); + + visibilitySystem.Update(); + + var allocated = MeasureAllocatedBytes(visibilitySystem.Update, iterations: iterations); + + Assert.True( + allocated / Math.Max(1, entityCount * iterations) < 8_192, + $"Expected steady-state visibility allocations to stay bounded per entity/update for {entityCount} entities. Allocated={allocated}"); + } + + private static long MeasureCurrentVisibilityUpdateAllocations(int entityCount, int effectCount) + { + using var world = new VoiceCraftWorld(); + using var effectSystem = new AudioEffectSystem(); + var visibilitySystem = new VisibilitySystem(world, effectSystem); + + AddEntities(world, entityCount); + AddVisibleEffects(effectSystem, effectCount); + visibilitySystem.Update(); + + return MeasureAllocatedBytes(visibilitySystem.Update, iterations: 20); + } + + private static void AddEntities(VoiceCraftWorld world, int entityCount) + { + for (var i = 0; i < entityCount; i++) + world.AddEntity(CreateNetworkEntity(i + 1)); + } + + private static void AddVisibleEffects(AudioEffectSystem effectSystem, int effectCount) + { + for (var i = 0; i < effectCount; i++) + effectSystem.SetEffect((ushort)(1 << i), new FakeVisibleEffect(true)); + } + + private static VoiceCraftNetworkEntity CreateNetworkEntity(int id) + { + return new VoiceCraftNetworkEntity( + new FakeNetPeer(Guid.NewGuid(), Guid.NewGuid(), "en-US", PositioningType.Client), + id); + } + + private static long MeasureAllocatedBytes(Action action, int iterations) + { + action(); + + var best = long.MaxValue; + for (var sample = 0; sample < 4; sample++) + { + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + + var before = GC.GetTotalAllocatedBytes(true); + for (var i = 0; i < iterations; i++) + action(); + var after = GC.GetTotalAllocatedBytes(true); + + best = Math.Min(best, after - before); + } + + return best; + } + + private static void AssertNearlyAllocationFreeSnapshotReads(long allocated, int iterations) + { + var maxAllocated = iterations * SnapshotReadMaxBytesPerRead; + Assert.True( + allocated <= maxAllocated, + $"Expected snapshot reads to stay near allocation-free. Allocated={allocated}, Iterations={iterations}, MaxAllowed={maxAllocated}"); + } + + private sealed class FakeNetPeer(Guid userGuid, Guid serverUserGuid, string locale, PositioningType positioningType) + : VoiceCraftNetPeer(userGuid, serverUserGuid, locale, positioningType) + { + public override VcConnectionState ConnectionState => VcConnectionState.Connected; + } + + private sealed class FakeVisibleEffect(bool result) : IAudioEffect, IVisible + { + public EffectType EffectType => EffectType.Visibility; + + public bool Visibility(VoiceCraftEntity from, VoiceCraftEntity to, ushort effectBitmask) + { + return result; + } + + public void Process(VoiceCraftEntity from, VoiceCraftEntity to, ushort effectBitmask, Span buffer) + { + } + + public void Reset() + { + } + + public void Serialize(NetDataWriter writer) + { + } + + public void Deserialize(NetDataReader reader) + { + } + + public void Dispose() + { + } + } +} + +[CollectionDefinition("AllocationRegression", DisableParallelization = true)] +public sealed class AllocationRegressionCollection; diff --git a/VoiceCraft.Network.Tests/Servers/McApiServerTests.cs b/VoiceCraft.Network.Tests/Servers/McApiServerTests.cs new file mode 100644 index 00000000..e8e54312 --- /dev/null +++ b/VoiceCraft.Network.Tests/Servers/McApiServerTests.cs @@ -0,0 +1,110 @@ +using System.Numerics; +using VoiceCraft.Core.World; +using VoiceCraft.Network.NetPeers; +using VoiceCraft.Network.Packets.McApiPackets; +using VoiceCraft.Network.Packets.McApiPackets.Request; +using VoiceCraft.Network.Packets.McApiPackets.Response; +using VoiceCraft.Network.Servers; +using VoiceCraft.Network.Systems; +using Xunit; + +namespace VoiceCraft.Network.Tests.Servers; + +public class McApiServerTests +{ + [Fact] + public void CreateEntityRequest_AddsEntityToWorld() + { + using var world = new VoiceCraftWorld(); + using var effectSystem = new AudioEffectSystem(); + var server = new TestMcApiServer(world, effectSystem); + var peer = new HttpMcApiNetPeer(); + peer.SetConnectionState(McApiConnectionState.Connected); + peer.SetSessionToken("session-token"); + + var request = new McApiCreateEntityRequestPacket().Set( + requestId: "create-1", + worldId: "world", + name: "Created Entity", + muted: true, + deafened: false, + talkBitmask: 3, + listenBitmask: 5, + effectBitmask: 7, + position: new Vector3(1, 2, 3), + rotation: new Vector2(4, 5), + caveFactor: 0.25f, + muffleFactor: 0.5f); + + server.Dispatch(request, peer); + + var response = Assert.IsType(server.LastPacket); + Assert.Equal(McApiCreateEntityResponsePacket.ResponseCodes.Ok, response.ResponseCode); + + var entity = world.GetEntity(response.Id); + Assert.NotNull(entity); + Assert.Equal("world", entity.WorldId); + Assert.Equal("Created Entity", entity.Name); + Assert.True(entity.Muted); + Assert.Equal((ushort)3, entity.TalkBitmask); + Assert.Equal((ushort)5, entity.ListenBitmask); + Assert.Equal((ushort)7, entity.EffectBitmask); + Assert.Equal(new Vector3(1, 2, 3), entity.Position); + Assert.Equal(new Vector2(4, 5), entity.Rotation); + Assert.Equal(0.25f, entity.CaveFactor); + Assert.Equal(0.5f, entity.MuffleFactor); + } + + private sealed class TestMcApiServer(VoiceCraftWorld world, AudioEffectSystem audioEffectSystem) + : McApiServer(world, audioEffectSystem) + { + public IMcApiPacket? LastPacket { get; private set; } + + public override string LoginToken => string.Empty; + public override uint MaxClients => 10; + public override int ConnectedPeers => 1; + + public override event Action? OnPeerConnected; + public override event Action? OnPeerDisconnected; + + public void Dispatch(IMcApiPacket packet, object? data) + { + ExecutePacket(packet, data); + } + + public override void Start() + { + } + + public override void Update() + { + } + + public override void Stop() + { + } + + public override void SendPacket(McApiNetPeer netPeer, T packet) + { + LastPacket = packet; + } + + public override void Broadcast(T packet, params McApiNetPeer?[] excludes) + { + } + + public override void Disconnect(McApiNetPeer netPeer, bool force = false) + { + } + + protected override void AcceptRequest(McApiLoginRequestPacket packet, object? data) + { + OnPeerConnected?.Invoke((McApiNetPeer)data!, string.Empty); + } + + protected override void RejectRequest(McApiLoginRequestPacket packet, string reason, object? data) + { + OnPeerDisconnected?.Invoke((McApiNetPeer)data!, reason); + } + } +} diff --git a/VoiceCraft.Network.Tests/Servers/VoiceCraftServerTests.cs b/VoiceCraft.Network.Tests/Servers/VoiceCraftServerTests.cs index 114428f6..33ad9ab5 100644 --- a/VoiceCraft.Network.Tests/Servers/VoiceCraftServerTests.cs +++ b/VoiceCraft.Network.Tests/Servers/VoiceCraftServerTests.cs @@ -1,5 +1,4 @@ using System.Net; -using LiteNetLib.Utils; using Xunit; using VoiceCraft.Core.World; using VoiceCraft.Network.NetPeers; diff --git a/VoiceCraft.Network.Tests/Systems/VisibilitySystemTests.cs b/VoiceCraft.Network.Tests/Systems/VisibilitySystemTests.cs index b8e518ba..8447a537 100644 --- a/VoiceCraft.Network.Tests/Systems/VisibilitySystemTests.cs +++ b/VoiceCraft.Network.Tests/Systems/VisibilitySystemTests.cs @@ -2,7 +2,6 @@ using Xunit; using VoiceCraft.Core.Interfaces; using VoiceCraft.Core.World; -using VoiceCraft.Network.Audio.Effects; using VoiceCraft.Network.Interfaces; using VoiceCraft.Network.NetPeers; using VoiceCraft.Network.Systems; diff --git a/VoiceCraft.Network.Tests/VoiceCraft.Network.Tests.csproj b/VoiceCraft.Network.Tests/VoiceCraft.Network.Tests.csproj index 898f5e82..ec1d55c2 100644 --- a/VoiceCraft.Network.Tests/VoiceCraft.Network.Tests.csproj +++ b/VoiceCraft.Network.Tests/VoiceCraft.Network.Tests.csproj @@ -1,7 +1,7 @@ - net9.0 + net10.0 enable enable false diff --git a/VoiceCraft.Network.Tests/Z85Tests.cs b/VoiceCraft.Network.Tests/Z85Tests.cs new file mode 100644 index 00000000..6ed22c3d --- /dev/null +++ b/VoiceCraft.Network.Tests/Z85Tests.cs @@ -0,0 +1,19 @@ +using Xunit; + +namespace VoiceCraft.Network.Tests; + +public class Z85Tests +{ + [Fact] + public void GetBytes_WithInvalidCharacter_ThrowsArgumentException() + { + Assert.Throws(() => Z85.GetBytes("0000 ")); + Assert.Throws(() => Z85.GetBytes("0000~")); + } + + [Fact] + public void GetString_WithUnpaddedLength_ThrowsArgumentException() + { + Assert.Throws(() => Z85.GetString([1, 2, 3])); + } +} diff --git a/VoiceCraft.Network/Audio/Effects/DirectionalEffect.cs b/VoiceCraft.Network/Audio/Effects/DirectionalEffect.cs index 164947b9..38de5507 100644 --- a/VoiceCraft.Network/Audio/Effects/DirectionalEffect.cs +++ b/VoiceCraft.Network/Audio/Effects/DirectionalEffect.cs @@ -13,15 +13,13 @@ public class DirectionalEffect : IAudioEffect { private readonly Dictionary _lerpSampleDirectionalVolumes = new(); - private float _wetDry = 1.0f; - public static int SampleRate => Constants.SampleRate; public float WetDry { - get => _wetDry; - set => _wetDry = Math.Clamp(value, 0.0f, 1.0f); - } + get; + set => field = Math.Clamp(value, 0.0f, 1.0f); + } = 1.0f; public EffectType EffectType => EffectType.Directional; diff --git a/VoiceCraft.Network/Audio/Effects/EchoEffect.cs b/VoiceCraft.Network/Audio/Effects/EchoEffect.cs index 2ab965ee..8acd8109 100644 --- a/VoiceCraft.Network/Audio/Effects/EchoEffect.cs +++ b/VoiceCraft.Network/Audio/Effects/EchoEffect.cs @@ -14,8 +14,6 @@ public class EchoEffect : IAudioEffect private readonly Dictionary _delayLines = new(); private float _delay; - private float _wetDry = 1.0f; - private float _feedback = 0.5f; public EchoEffect() { @@ -24,9 +22,9 @@ public EchoEffect() public float WetDry { - get => _wetDry; - set => _wetDry = Math.Clamp(value, 0.0f, 1.0f); - } + get; + set => field = Math.Clamp(value, 0.0f, 1.0f); + } = 1.0f; public static int SampleRate => Constants.SampleRate; @@ -38,9 +36,9 @@ public float Delay public float Feedback { - get => _feedback; - set => _feedback = Math.Clamp(value, 0.0f, 1.0f); - } + get; + set => field = Math.Clamp(value, 0.0f, 1.0f); + } = 0.5f; public EffectType EffectType => EffectType.Echo; diff --git a/VoiceCraft.Network/Audio/Effects/MuffleEffect.cs b/VoiceCraft.Network/Audio/Effects/MuffleEffect.cs index a671c134..f8c2dfdc 100644 --- a/VoiceCraft.Network/Audio/Effects/MuffleEffect.cs +++ b/VoiceCraft.Network/Audio/Effects/MuffleEffect.cs @@ -13,13 +13,11 @@ public class MuffleEffect : IAudioEffect { private readonly Dictionary _biquadFilters = new(); - private float _wetDry = 1.0f; - public float WetDry { - get => _wetDry; - set => _wetDry = Math.Clamp(value, 0.0f, 1.0f); - } + get; + set => field = Math.Clamp(value, 0.0f, 1.0f); + } = 1.0f; public static int SampleRate => Constants.SampleRate; diff --git a/VoiceCraft.Network/Audio/Effects/ProximityEchoEffect.cs b/VoiceCraft.Network/Audio/Effects/ProximityEchoEffect.cs index ff6282e5..27b4eadb 100644 --- a/VoiceCraft.Network/Audio/Effects/ProximityEchoEffect.cs +++ b/VoiceCraft.Network/Audio/Effects/ProximityEchoEffect.cs @@ -15,8 +15,6 @@ public class ProximityEchoEffect : IAudioEffect private readonly Dictionary _delayLines = new(); private float _delay; - private float _wetDry = 1.0f; - private float _range; public ProximityEchoEffect() { @@ -24,12 +22,12 @@ public ProximityEchoEffect() } public static int SampleRate => Constants.SampleRate; - + public float WetDry { - get => _wetDry; - set => _wetDry = Math.Clamp(value, 0.0f, 1.0f); - } + get; + set => field = Math.Clamp(value, 0.0f, 1.0f); + } = 1.0f; public float Delay { @@ -39,8 +37,8 @@ public float Delay public float Range { - get => _range; - set => _range = Math.Max(value, 0.0f); + get; + set => field = Math.Max(value, 0.0f); } public EffectType EffectType => EffectType.ProximityEcho; diff --git a/VoiceCraft.Network/Audio/Effects/ProximityEffect.cs b/VoiceCraft.Network/Audio/Effects/ProximityEffect.cs index b49da375..aee5e781 100644 --- a/VoiceCraft.Network/Audio/Effects/ProximityEffect.cs +++ b/VoiceCraft.Network/Audio/Effects/ProximityEffect.cs @@ -14,17 +14,17 @@ namespace VoiceCraft.Network.Audio.Effects public class ProximityEffect : IAudioEffect, IVisible { private readonly Dictionary _lerpSampleVolumes = new(); - private float _wetDry = 1.0f; public static int SampleRate => Constants.SampleRate; - + public float WetDry { - get => _wetDry; - set => _wetDry = Math.Clamp(value, 0.0f, 1.0f); - } - public int MinRange { get; set; } - public int MaxRange { get; set; } + get; + set => field = Math.Clamp(value, 0.0f, 1.0f); + } = 1.0f; + + public float MinRange { get; set; } + public float MaxRange { get; set; } public EffectType EffectType => EffectType.Proximity; @@ -62,8 +62,8 @@ public void Serialize(NetDataWriter writer) public void Deserialize(NetDataReader reader) { - MinRange = reader.GetInt(); - MaxRange = reader.GetInt(); + MinRange = reader.GetFloat(); + MaxRange = reader.GetFloat(); WetDry = reader.GetFloat(); } diff --git a/VoiceCraft.Network/Audio/Effects/ProximityMuffleEffect.cs b/VoiceCraft.Network/Audio/Effects/ProximityMuffleEffect.cs index 3b80d3da..69eacb19 100644 --- a/VoiceCraft.Network/Audio/Effects/ProximityMuffleEffect.cs +++ b/VoiceCraft.Network/Audio/Effects/ProximityMuffleEffect.cs @@ -13,14 +13,13 @@ public class ProximityMuffleEffect : IAudioEffect { private readonly Dictionary _biquadFilters = new(); - private float _wetDry = 1.0f; public static int SampleRate => Constants.SampleRate; public float WetDry { - get => _wetDry; - set => _wetDry = Math.Clamp(value, 0.0f, 1.0f); - } + get; + set => field = Math.Clamp(value, 0.0f, 1.0f); + } = 1.0f; public EffectType EffectType => EffectType.ProximityMuffle; diff --git a/VoiceCraft.Network/Audio/JitterBuffer.cs b/VoiceCraft.Network/Audio/JitterBuffer.cs index 5980aabd..5d365e0d 100644 --- a/VoiceCraft.Network/Audio/JitterBuffer.cs +++ b/VoiceCraft.Network/Audio/JitterBuffer.cs @@ -45,7 +45,7 @@ public bool Get([NotNullWhen(true)] out JitterPacket? packet) packet = Last; _data.RemoveLast(); - _currentSeqId = (ushort)checked(packet.SequenceId + 1); + _currentSeqId = (ushort)(packet.SequenceId + 1); return true; } @@ -108,4 +108,4 @@ public class JitterPacket(ushort sequenceId, byte[] data) public readonly byte[] Data = data; public readonly DateTime ReceivedTime = DateTime.UtcNow; public readonly ushort SequenceId = sequenceId; -} \ No newline at end of file +} diff --git a/VoiceCraft.Network/Clients/VoiceCraftClient.cs b/VoiceCraft.Network/Clients/VoiceCraftClient.cs index 2d733449..68538676 100644 --- a/VoiceCraft.Network/Clients/VoiceCraftClient.cs +++ b/VoiceCraft.Network/Clients/VoiceCraftClient.cs @@ -24,13 +24,7 @@ public abstract class VoiceCraftClient : VoiceCraftEntity, IDisposable private readonly Func _audioDecoderFactory; private readonly IAudioEncoder _audioEncoder; private DateTime _lastAudioPeakTime = DateTime.MinValue; - private float _inputVolume; - private float _outputVolume; - private float _microphoneSensitivity; private ushort _sendTimestamp; - private bool _serverDeafened; - private bool _serverMuted; - private bool _speakingState; public static Version Version { get; } = new(Constants.Major, Constants.Minor, Constants.Patch); public VoiceCraftWorld World { get; } = new(); @@ -40,52 +34,52 @@ public abstract class VoiceCraftClient : VoiceCraftEntity, IDisposable public float InputVolume { - get => _inputVolume; - set => _inputVolume = Math.Clamp(value, 0, 2); + get; + set => field = ClampFinite(value, 0, 2); } public float OutputVolume { - get => _outputVolume; - set => _outputVolume = Math.Clamp(value, 0, 2); + get; + set => field = ClampFinite(value, 0, 2); } - + public float MicrophoneSensitivity { - get => _microphoneSensitivity; - set => _microphoneSensitivity = Math.Clamp(value, 0, 1); + get; + set => field = ClampFinite(value, 0, 1); } public bool SpeakingState { - get => _speakingState; + get; private set { - if (_speakingState == value) return; - _speakingState = value; + if (field == value) return; + field = value; OnSpeakingUpdated?.Invoke(value); } } public bool ServerMuted { - get => _serverMuted; + get; private set { - if (_serverMuted == value) return; - _serverMuted = value; - OnServerMuteUpdated?.Invoke(_serverMuted); + if (field == value) return; + field = value; + OnServerMuteUpdated?.Invoke(field); } } public bool ServerDeafened { - get => _serverDeafened; + get; private set { - if (_serverDeafened == value) return; - _serverDeafened = value; - OnServerDeafenUpdated?.Invoke(_serverDeafened); + if (field == value) return; + field = value; + OnServerDeafenUpdated?.Invoke(field); } } @@ -341,9 +335,6 @@ protected void ProcessUnconnectedPacket(NetDataReader reader, Action new VcInfoResponsePacket()); break; @@ -608,6 +599,8 @@ private void HandleOnEffectUpdatedPacket(VcOnEffectUpdatedPacket packet) private void HandleOnEntityCreatedPacket(VcOnEntityCreatedPacket packet) { + if (World.ContainsEntity(packet.Id)) return; + var entity = new VoiceCraftClientEntity(packet.Id, _audioDecoderFactory.Invoke()) { Name = packet.Name, @@ -619,6 +612,8 @@ private void HandleOnEntityCreatedPacket(VcOnEntityCreatedPacket packet) private void HandleOnNetworkEntityCreatedPacket(VcOnNetworkEntityCreatedPacket packet) { + if (World.ContainsEntity(packet.Id)) return; + var entity = new VoiceCraftClientNetworkEntity(packet.Id, _audioDecoderFactory.Invoke(), packet.UserGuid) { @@ -633,28 +628,26 @@ private void HandleOnNetworkEntityCreatedPacket(VcOnNetworkEntityCreatedPacket p private void HandleOnEntityDestroyedPacket(VcOnEntityDestroyedPacket packet) { + if (!World.ContainsEntity(packet.Id)) return; World.DestroyEntity(packet.Id); } private void HandleOnEntityNameUpdatedPacket(VcOnEntityNameUpdatedPacket packet) { var entity = World.GetEntity(packet.Id); - if (entity == null) return; - entity.Name = packet.Value; + entity?.Name = packet.Value; } private void HandleOnEntityMuteUpdatedPacket(VcOnEntityMuteUpdatedPacket packet) { var entity = World.GetEntity(packet.Id); - if (entity == null) return; - entity.Muted = packet.Value; + entity?.Muted = packet.Value; } private void HandleOnEntityDeafenUpdatedPacket(VcOnEntityDeafenUpdatedPacket packet) { var entity = World.GetEntity(packet.Id); - if (entity == null) return; - entity.Deafened = packet.Value; + entity?.Deafened = packet.Value; } private void HandleOnEntityServerMuteUpdatedPacket(VcOnEntityServerMuteUpdatedPacket packet) @@ -674,50 +667,43 @@ private void HandleOnEntityServerDeafenUpdatedPacket(VcOnEntityServerDeafenUpdat private void HandleOnEntityTalkBitmaskUpdatedPacket(VcOnEntityTalkBitmaskUpdatedPacket packet) { var entity = World.GetEntity(packet.Id); - if (entity == null) return; - entity.TalkBitmask = packet.Value; + entity?.TalkBitmask = packet.Value; } private void HandleOnEntityListenBitmaskUpdatedPacket(VcOnEntityListenBitmaskUpdatedPacket packet) { var entity = World.GetEntity(packet.Id); - if (entity == null) return; - entity.ListenBitmask = packet.Value; + entity?.ListenBitmask = packet.Value; } private void HandleOnEntityEffectBitmaskUpdatedPacket(VcOnEntityEffectBitmaskUpdatedPacket packet) { var entity = World.GetEntity(packet.Id); - if (entity == null) return; - entity.EffectBitmask = packet.Value; + entity?.EffectBitmask = packet.Value; } private void HandleOnEntityPositionUpdatedPacket(VcOnEntityPositionUpdatedPacket packet) { var entity = World.GetEntity(packet.Id); - if (entity == null) return; - entity.Position = packet.Value; + entity?.Position = packet.Value; } private void HandleOnEntityRotationUpdatedPacket(VcOnEntityRotationUpdatedPacket packet) { var entity = World.GetEntity(packet.Id); - if (entity == null) return; - entity.Rotation = packet.Value; + entity?.Rotation = packet.Value; } private void HandleOnEntityCaveFactorUpdatedPacket(VcOnEntityCaveFactorUpdatedPacket packet) { var entity = World.GetEntity(packet.Id); - if (entity == null) return; - entity.CaveFactor = packet.Value; + entity?.CaveFactor = packet.Value; } private void HandleOnEntityMuffleFactorUpdatedPacket(VcOnEntityMuffleFactorUpdatedPacket packet) { var entity = World.GetEntity(packet.Id); - if (entity == null) return; - entity.MuffleFactor = packet.Value; + entity?.MuffleFactor = packet.Value; } private void HandleOnEntityAudioReceivedPacket(VcOnEntityAudioReceivedPacket packet) @@ -809,4 +795,9 @@ private void ProcessUnconnectedPacket(NetDataReader reader, Action.Return(packet); } } + + private static float ClampFinite(float value, float min, float max) + { + return float.IsFinite(value) ? Math.Clamp(value, min, max) : min; + } } diff --git a/VoiceCraft.Network/McApiStringCodec.cs b/VoiceCraft.Network/McApiStringCodec.cs deleted file mode 100644 index f2bebef8..00000000 --- a/VoiceCraft.Network/McApiStringCodec.cs +++ /dev/null @@ -1,115 +0,0 @@ -using System; -using System.Linq; -using System.Numerics; -using System.Text; - -namespace VoiceCraft.Network; - -public static class McApiStringCodec -{ - // Printable ASCII only, excluding characters known to be problematic for MCBE/data tunnel transport. - private const string Alphabet = - "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz!#$&'()*+,-./:;<=>?@[]^_`{}~"; - - private static readonly BigInteger Base = Alphabet.Length; - private static readonly int[] ReverseLookup = BuildReverseLookup(); - internal static int AlphabetSize => Alphabet.Length; - - public static string Encode(ReadOnlySpan data) - { - if (data.Length == 0) - return string.Empty; - - var leadingZeroCount = 0; - while (leadingZeroCount < data.Length && data[leadingZeroCount] == 0) - leadingZeroCount++; - - var builder = new StringBuilder(data.Length * 2); - for (var i = 0; i < leadingZeroCount; i++) - builder.Append(Alphabet[0]); - - if (leadingZeroCount == data.Length) - return builder.ToString(); - - var magnitude = new byte[data.Length - leadingZeroCount + 1]; - for (var i = 0; i < data.Length - leadingZeroCount; i++) - magnitude[i] = data[data.Length - 1 - i]; - - var value = new BigInteger(magnitude); - Span buffer = stackalloc char[512]; - - while (value > BigInteger.Zero) - { - value = BigInteger.DivRem(value, Base, out var remainder); - if (buffer.Length == 0) - throw new InvalidOperationException("Unexpected buffer exhaustion."); - builder.Append(Alphabet[(int)remainder]); - } - - if (builder.Length == leadingZeroCount) - builder.Append(Alphabet[0]); - - var chars = builder.ToString().ToCharArray(); - Array.Reverse(chars, leadingZeroCount, chars.Length - leadingZeroCount); - return new string(chars); - } - - public static byte[] Decode(string data) - { - if (string.IsNullOrEmpty(data)) - return []; - - var leadingZeroCount = 0; - while (leadingZeroCount < data.Length && data[leadingZeroCount] == Alphabet[0]) - leadingZeroCount++; - - var value = BigInteger.Zero; - for (var i = leadingZeroCount; i < data.Length; i++) - { - var current = data[i]; - if (current >= ReverseLookup.Length || ReverseLookup[current] < 0) - throw new ArgumentException($"Character '{current}' is not valid in an encoded McApi payload.", - nameof(data)); - - value *= Base; - value += ReverseLookup[current]; - } - - var decoded = value == BigInteger.Zero ? [] : value.ToByteArray(isUnsigned: true, isBigEndian: true); - if (leadingZeroCount == 0) - return decoded; - - var output = new byte[leadingZeroCount + decoded.Length]; - if (decoded.Length > 0) - Buffer.BlockCopy(decoded, 0, output, leadingZeroCount, decoded.Length); - return output; - } - - public static bool IsSafePayloadCharacter(char value) - { - return value < ReverseLookup.Length && ReverseLookup[value] >= 0; - } - - internal static char GetAlphabetChar(int index) - { - if (index < 0 || index >= Alphabet.Length) - throw new ArgumentOutOfRangeException(nameof(index)); - return Alphabet[index]; - } - - internal static int GetAlphabetIndex(char value) - { - if (value >= ReverseLookup.Length || ReverseLookup[value] < 0) - throw new ArgumentException($"Character '{value}' is not valid in an encoded McApi payload.", - nameof(value)); - return ReverseLookup[value]; - } - - private static int[] BuildReverseLookup() - { - var lookup = Enumerable.Repeat(-1, 128).ToArray(); - for (var i = 0; i < Alphabet.Length; i++) - lookup[Alphabet[i]] = i; - return lookup; - } -} diff --git a/VoiceCraft.Network/McWssPacketFraming.cs b/VoiceCraft.Network/McWssPacketFraming.cs deleted file mode 100644 index c64a688a..00000000 --- a/VoiceCraft.Network/McWssPacketFraming.cs +++ /dev/null @@ -1,96 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text; - -namespace VoiceCraft.Network; - -public static class McWssPacketFraming -{ - private const int HeaderSize = 2; - private static readonly int MaxPacketLength = McApiStringCodec.AlphabetSize * McApiStringCodec.AlphabetSize - 1; - - public static string Pack(IEnumerable packets) - { - var builder = new StringBuilder(); - foreach (var packet in packets) - AppendFrame(builder, packet); - return builder.ToString(); - } - - public static bool TryAppendFrame(StringBuilder builder, string packet, int maxLength, bool allowOversizedFirstFrame) - { - ArgumentNullException.ThrowIfNull(builder); - ArgumentNullException.ThrowIfNull(packet); - - ValidatePacketLength(packet); - var frameLength = GetFrameLength(packet); - if (builder.Length > 0 && builder.Length + frameLength > maxLength) - return false; - - if (builder.Length == 0 && !allowOversizedFirstFrame && frameLength > maxLength) - return false; - - AppendFrame(builder, packet); - return true; - } - - public static List Unpack(string data) - { - var packets = new List(); - if (string.IsNullOrEmpty(data)) - return packets; - - var index = 0; - while (index < data.Length) - { - if (data.Length - index < HeaderSize) - throw new ArgumentException("Packet frame is truncated and missing its length header.", nameof(data)); - - var length = DecodeLength(data[index], data[index + 1]); - index += HeaderSize; - if (index + length > data.Length) - throw new ArgumentException("Packet frame length exceeds the available payload.", nameof(data)); - - packets.Add(data.Substring(index, length)); - index += length; - } - - return packets; - } - - private static void AppendFrame(StringBuilder builder, string packet) - { - ValidatePacketLength(packet); - var (high, low) = EncodeLength(packet.Length); - builder.Append(high); - builder.Append(low); - builder.Append(packet); - } - - private static int GetFrameLength(string packet) - { - return packet.Length + HeaderSize; - } - - private static (char High, char Low) EncodeLength(int length) - { - var baseSize = McApiStringCodec.AlphabetSize; - return ( - McApiStringCodec.GetAlphabetChar(length / baseSize), - McApiStringCodec.GetAlphabetChar(length % baseSize)); - } - - private static int DecodeLength(char high, char low) - { - var baseSize = McApiStringCodec.AlphabetSize; - return McApiStringCodec.GetAlphabetIndex(high) * baseSize + - McApiStringCodec.GetAlphabetIndex(low); - } - - private static void ValidatePacketLength(string packet) - { - if (packet.Length > MaxPacketLength) - throw new ArgumentOutOfRangeException(nameof(packet), - $"Packet length {packet.Length} exceeds the McWss frame limit of {MaxPacketLength} characters."); - } -} diff --git a/VoiceCraft.Network/NetPeers/HttpMcApiNetPeer.cs b/VoiceCraft.Network/NetPeers/HttpMcApiNetPeer.cs index 7d79aee6..e844e97c 100644 --- a/VoiceCraft.Network/NetPeers/HttpMcApiNetPeer.cs +++ b/VoiceCraft.Network/NetPeers/HttpMcApiNetPeer.cs @@ -1,14 +1,14 @@ using System; -using System.Net; namespace VoiceCraft.Network.NetPeers; -public class HttpMcApiNetPeer(IPAddress ipAddress) : McApiNetPeer +public class HttpMcApiNetPeer : McApiNetPeer { private McApiConnectionState _connectionState; private string _sessionToken = string.Empty; public DateTime LastUpdate { get; set; } = DateTime.UtcNow; - public IPAddress IpAddress { get; } = ipAddress; + public string LookupToken { get; private set; } = string.Empty; + public override McApiConnectionState ConnectionState => _connectionState; public override string SessionToken => _sessionToken; @@ -21,4 +21,9 @@ public void SetSessionToken(string token) { _sessionToken = token; } -} \ No newline at end of file + + public void SetLookupToken(string token) + { + LookupToken = token; + } +} diff --git a/VoiceCraft.Network/NetPeers/McApiNetPeer.cs b/VoiceCraft.Network/NetPeers/McApiNetPeer.cs index 57da83db..e428a579 100644 --- a/VoiceCraft.Network/NetPeers/McApiNetPeer.cs +++ b/VoiceCraft.Network/NetPeers/McApiNetPeer.cs @@ -11,9 +11,9 @@ public abstract class McApiNetPeer public abstract string SessionToken { get; } public object? Tag { get; set; } - public struct QueuedPacket(string data, string token) + public struct QueuedPacket(byte[] data, string token) { - public string Data = data; - public string Token = token; + public readonly byte[] Data = data; + public readonly string Token = token; } } \ No newline at end of file diff --git a/VoiceCraft.Network/NetPeers/TcpMcApiNetPeer.cs b/VoiceCraft.Network/NetPeers/TcpMcApiNetPeer.cs index 4334d74a..1307ca0b 100644 --- a/VoiceCraft.Network/NetPeers/TcpMcApiNetPeer.cs +++ b/VoiceCraft.Network/NetPeers/TcpMcApiNetPeer.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Collections.Concurrent; using System.Net.Sockets; using System.Threading; using System.Threading.Tasks; @@ -16,8 +15,6 @@ public class TcpMcApiNetPeer(TcpClient client) : McApiNetPeer public TcpClient Client { get; } = client; public DateTime LastUpdate { get; set; } = DateTime.UtcNow; - public ConcurrentQueue IncomingRawQueue { get; } = new(); - public ConcurrentQueue OutgoingRawQueue { get; } = new(); public override McApiConnectionState ConnectionState => _connectionState; public override string SessionToken => _sessionToken; @@ -71,10 +68,4 @@ public void CancelPendingResponse() pendingResponse?.TrySetCanceled(); } - - public readonly struct QueuedRawPacket(byte[] data, string token) - { - public byte[] Data { get; } = data; - public string Token { get; } = token; - } } diff --git a/VoiceCraft.Network/Packets/McHttpPackets/McHttpUpdatePacket.cs b/VoiceCraft.Network/Packets/McHttpPackets/McHttpUpdatePacket.cs deleted file mode 100644 index e48a96cf..00000000 --- a/VoiceCraft.Network/Packets/McHttpPackets/McHttpUpdatePacket.cs +++ /dev/null @@ -1,13 +0,0 @@ -using System.Collections.Generic; -using System.Text.Json.Serialization; - -namespace VoiceCraft.Network.Packets.McHttpPackets; - -public class McHttpUpdatePacket -{ - public List Packets { get; set; } = []; -} - -[JsonSourceGenerationOptions(WriteIndented = true)] -[JsonSerializable(typeof(McHttpUpdatePacket), GenerationMode = JsonSourceGenerationMode.Metadata)] -public partial class McHttpUpdatePacketGenerationContext : JsonSerializerContext; \ No newline at end of file diff --git a/VoiceCraft.Network/ServerInfo.cs b/VoiceCraft.Network/ServerInfo.cs index b5cf11ed..fd14aefe 100644 --- a/VoiceCraft.Network/ServerInfo.cs +++ b/VoiceCraft.Network/ServerInfo.cs @@ -3,11 +3,11 @@ namespace VoiceCraft.Network; -public struct ServerInfo(VcInfoResponsePacket infoPacket) +public readonly struct ServerInfo(VcInfoResponsePacket infoPacket) { - public string Motd { get; set; } = infoPacket.Motd; - public int Clients { get; set; } = infoPacket.Clients; - public PositioningType PositioningType { get; set; } = infoPacket.PositioningType; - public int Tick { get; set; } = infoPacket.Tick; - public Version Version { get; set; } = infoPacket.Version; + public string Motd { get; } = infoPacket.Motd; + public int Clients { get; } = infoPacket.Clients; + public PositioningType PositioningType { get; } = infoPacket.PositioningType; + public int Tick { get; } = infoPacket.Tick; + public Version Version { get; } = infoPacket.Version; } \ No newline at end of file diff --git a/VoiceCraft.Network/Servers/HttpMcApiServer.cs b/VoiceCraft.Network/Servers/HttpMcApiServer.cs index 67c2459b..1b4fc1ff 100644 --- a/VoiceCraft.Network/Servers/HttpMcApiServer.cs +++ b/VoiceCraft.Network/Servers/HttpMcApiServer.cs @@ -1,10 +1,10 @@ using System; +using System.Buffers; using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Net; using System.Text; -using System.Text.Json; using System.Text.Json.Serialization; using System.Threading.Tasks; using LiteNetLib.Utils; @@ -13,7 +13,6 @@ using VoiceCraft.Network.NetPeers; using VoiceCraft.Network.Packets.McApiPackets.Request; using VoiceCraft.Network.Packets.McApiPackets.Response; -using VoiceCraft.Network.Packets.McHttpPackets; using VoiceCraft.Network.Systems; namespace VoiceCraft.Network.Servers; @@ -21,8 +20,13 @@ namespace VoiceCraft.Network.Servers; public class HttpMcApiServer(VoiceCraftWorld world, AudioEffectSystem audioEffectSystem) : McApiServer(world, audioEffectSystem) { + private const int MaxRequestLength = 1_000_000; + private HttpMcApiConfig _config = new(); - private readonly ConcurrentDictionary _mcApiPeers = new(); + private readonly ConcurrentDictionary _mcApiPeers = new(); + private readonly ConcurrentQueue _pendingRequests = new(); + private readonly NetDataReader _httpReader = new(); + private readonly NetDataWriter _httpWriter = new(); private readonly NetDataReader _reader = new(); private readonly NetDataWriter _writer = new(); private HttpListener? _httpServer; @@ -75,22 +79,25 @@ public override void Start() public override void Update() { if (_httpServer == null) return; + ProcessPendingRequests(); foreach (var peer in _mcApiPeers) UpdatePeer(peer.Key, peer.Value); } public override void Stop() { - if (_httpServer == null) return; + if (_httpServer == null) + { + CompletePendingRequests(new OperationCanceledException("McHttp server stopped.")); + _mcApiPeers.Clear(); + return; + } + try { if (_httpServer.IsListening) _httpServer.Stop(); } - catch (ObjectDisposedException) - { - //Do Nothing - } - catch (HttpListenerException) + catch { //Do Nothing } @@ -99,12 +106,14 @@ public override void Stop() { _httpServer.Close(); } - catch (ObjectDisposedException) + catch { //Do Nothing } _httpServer = null; + CompletePendingRequests(new OperationCanceledException("McHttp server stopped.")); + _mcApiPeers.Clear(); } public override void SendPacket(McApiNetPeer netPeer, T packet) @@ -121,8 +130,7 @@ public override void SendPacket(McApiNetPeer netPeer, T packet) if (_writer.Length > short.MaxValue) throw new ArgumentOutOfRangeException(nameof(packet)); - var encodedPacket = McApiStringCodec.Encode(_writer.AsReadOnlySpan()); - netPeer.OutgoingQueue.Enqueue(new McApiNetPeer.QueuedPacket(encodedPacket, string.Empty)); + netPeer.OutgoingQueue.Enqueue(new McApiNetPeer.QueuedPacket(_writer.CopyData(), string.Empty)); } } finally @@ -138,18 +146,18 @@ public override void Broadcast(T packet, params McApiNetPeer?[] excludes) { lock (_writer) { - var netPeers = _mcApiPeers.Where(x => x.Value.ConnectionState == McApiConnectionState.Connected); _writer.Reset(); _writer.Put((byte)packet.PacketType); _writer.Put(packet); if (_writer.Length > short.MaxValue) throw new ArgumentOutOfRangeException(nameof(packet)); - var encodedPacket = McApiStringCodec.Encode(_writer.AsReadOnlySpan()); - foreach (var netPeer in netPeers) + var data = _writer.CopyData(); + foreach (var netPeer in _mcApiPeers.Values) { - if (excludes.Contains(netPeer.Value)) continue; - netPeer.Value.OutgoingQueue.Enqueue(new McApiNetPeer.QueuedPacket(encodedPacket, string.Empty)); + if (netPeer.ConnectionState != McApiConnectionState.Connected || excludes.Contains(netPeer)) + continue; + netPeer.OutgoingQueue.Enqueue(new McApiNetPeer.QueuedPacket(data, string.Empty)); } } } @@ -172,7 +180,8 @@ public override void Disconnect(McApiNetPeer netPeer, bool force = false) OnPeerDisconnected?.Invoke(httpNetPeer, sessionToken); if (force) { - _mcApiPeers.TryRemove(httpNetPeer.IpAddress, out _); //Remove Immediately. + if (!string.IsNullOrEmpty(httpNetPeer.LookupToken)) + _mcApiPeers.TryRemove(httpNetPeer.LookupToken, out _); //Remove Immediately. return; } @@ -184,8 +193,7 @@ public override void Disconnect(McApiNetPeer netPeer, bool force = false) if (_writer.Length > short.MaxValue) throw new ArgumentOutOfRangeException(nameof(netPeer)); - var encodedPacket = McApiStringCodec.Encode(_writer.AsReadOnlySpan()); - netPeer.OutgoingQueue.Enqueue(new McApiNetPeer.QueuedPacket(encodedPacket, string.Empty)); + netPeer.OutgoingQueue.Enqueue(new McApiNetPeer.QueuedPacket(_writer.CopyData(), string.Empty)); } } finally @@ -199,12 +207,16 @@ protected override void AcceptRequest(McApiLoginRequestPacket packet, object? da if (data is not HttpMcApiNetPeer httpNetPeer) return; try { + var previousLookupToken = httpNetPeer.LookupToken; if (httpNetPeer.ConnectionState != McApiConnectionState.Connected) { httpNetPeer.SetSessionToken(Guid.NewGuid().ToString()); httpNetPeer.SetConnectionState(McApiConnectionState.Connected); } + httpNetPeer.SetLookupToken(httpNetPeer.SessionToken); + TrackPeer(httpNetPeer, previousLookupToken); + SendPacket(httpNetPeer, PacketPool.GetPacket(() => new McApiAcceptResponsePacket()) .Set(packet.RequestId, httpNetPeer.SessionToken)); @@ -233,8 +245,7 @@ protected override void RejectRequest(McApiLoginRequestPacket packet, string rea if (_writer.Length > short.MaxValue) throw new ArgumentOutOfRangeException(nameof(packet)); - var encodedPacket = McApiStringCodec.Encode(_writer.AsReadOnlySpan()); - httpNetPeer.OutgoingQueue.Enqueue(new McApiNetPeer.QueuedPacket(encodedPacket, string.Empty)); + httpNetPeer.OutgoingQueue.Enqueue(new McApiNetPeer.QueuedPacket(_writer.CopyData(), string.Empty)); } } finally @@ -259,14 +270,10 @@ private async Task ListenerLoop(HttpListener listener) while (listener.IsListening) { var context = await listener.GetContextAsync(); - await HandleRequest(context); + _ = HandleRequest(context); } } - catch (ObjectDisposedException) - { - //Do Nothing - } - catch (HttpListenerException) + catch { //Do Nothing } @@ -283,69 +290,118 @@ private async Task HandleRequest(HttpListenerContext context) return; } - if (context.Request.ContentLength64 >= 1e+6) //Do not accept anything higher than a mb. + switch (context.Request.ContentLength64) { - context.Response.StatusCode = 413; - context.Response.Close(); - return; + case < 0: + context.Response.StatusCode = 411; + context.Response.Close(); + return; + //Do not accept anything higher than a mb. + case > MaxRequestLength: + context.Response.StatusCode = 413; + context.Response.Close(); + return; + } + + var size = (int)context.Request.ContentLength64; + var data = ArrayPool.Shared.Rent(size); + var packets = new List(); + try + { + await context.Request.InputStream.ReadExactlyAsync(data, 0, size); + var stringData = Encoding.UTF8.GetString(data.AsSpan(0, size)); + if (!TryReadPackedPackets(stringData, packets)) + { + context.Response.StatusCode = 400; + context.Response.Close(); + return; + } + } + finally + { + ArrayPool.Shared.Return(data); } - var token = context.Request.Headers.Get("Authorization")?.Remove(0, 7); - if (string.IsNullOrWhiteSpace(token)) + var isLoginOnlyRequest = ContainsOnlyLoginRequests(packets); + var hasBearerToken = TryGetBearerToken(context.Request.Headers.Get("Authorization"), out var token); + if (!isLoginOnlyRequest && !hasBearerToken) { context.Response.StatusCode = 401; context.Response.Close(); return; } - var packet = await JsonSerializer.DeserializeAsync(context.Request.InputStream, - McHttpUpdatePacketGenerationContext.Default.McHttpUpdatePacket); - if (packet == null) + var netPeer = ResolvePeer(token, isLoginOnlyRequest); + if (netPeer == null) { - context.Response.StatusCode = 400; + context.Response.StatusCode = 401; context.Response.Close(); return; } - var netPeer = GetOrCreatePeer(context.Request.RemoteEndPoint.Address); - ReceivePacketsLogic(netPeer, packet.Packets, token); - packet.Packets.Clear(); - SendPacketsLogic(netPeer, packet.Packets); + var pendingRequest = new PendingHttpRequest(netPeer, packets, token); + _pendingRequests.Enqueue(pendingRequest); + packets = await pendingRequest.CompletionSource.Task; + + string encoded; + lock (_httpWriter) + { + _httpWriter.Reset(); + foreach (var packet in packets) + { + _httpWriter.Put((ushort)packet.Length); + _httpWriter.Put(packet); + } + encoded = Z85.GetStringWithPadding(_httpWriter.AsReadOnlySpan()); + } - var responseData = - JsonSerializer.Serialize(packet, McHttpUpdatePacketGenerationContext.Default.McHttpUpdatePacket); - var buffer = Encoding.UTF8.GetBytes(responseData); - context.Response.StatusCode = 200; - context.Response.ContentLength64 = buffer.Length; - await context.Response.OutputStream.WriteAsync(buffer); - context.Response.OutputStream.Close(); - } - catch (JsonException) - { - context.Response.StatusCode = 400; - context.Response.Close(); + var bufferSize = Encoding.UTF8.GetMaxByteCount(encoded.Length); //Zero alloc. GetByteCount allocates a char[]. + var buffer = ArrayPool.Shared.Rent(bufferSize); //Use Pooling. + try + { + var encodedBytes = Encoding.UTF8.GetBytes(encoded, buffer); + context.Response.StatusCode = 200; + context.Response.ContentLength64 = encodedBytes; + await context.Response.OutputStream.WriteAsync(buffer.AsMemory(0, encodedBytes)); + context.Response.OutputStream.Close(); + } + finally + { + ArrayPool.Shared.Return(buffer); + } } catch { - context.Response.StatusCode = 500; - context.Response.Close(); + try + { + context.Response.StatusCode = 500; + context.Response.Close(); + } + catch + { + //Do Nothing + } } } - private HttpMcApiNetPeer GetOrCreatePeer(IPAddress ipAddress) + private HttpMcApiNetPeer? ResolvePeer(string token, bool isLoginOnlyRequest) { - return _mcApiPeers.GetOrAdd(ipAddress, _ => + if (isLoginOnlyRequest) { - var httpNetPeer = new HttpMcApiNetPeer(ipAddress); - httpNetPeer.Tag = this; - return httpNetPeer; - }); + return new HttpMcApiNetPeer() + { + Tag = this + }; + } + + return _mcApiPeers.GetValueOrDefault(token); } - private static void ReceivePacketsLogic(HttpMcApiNetPeer httpNetPeer, List packets, string token) + private static void ReceivePacketsLogic(HttpMcApiNetPeer httpNetPeer, IEnumerable packets, string token) { - foreach (var data in packets.Where(data => data.Length > 0)) + foreach (var data in packets) { + if (data.Length == 0) continue; try { httpNetPeer.IncomingQueue.Enqueue(new McApiNetPeer.QueuedPacket(data, token)); @@ -357,7 +413,55 @@ private static void ReceivePacketsLogic(HttpMcApiNetPeer httpNetPeer, List packets) + private void ProcessPendingRequests() + { + while (_pendingRequests.TryDequeue(out var pendingRequest)) + try + { + ProcessPackets(pendingRequest.NetPeer, pendingRequest.Packets, pendingRequest.Token); + pendingRequest.Packets.Clear(); + SendPacketsLogic(pendingRequest.NetPeer, pendingRequest.Packets); + pendingRequest.CompletionSource.TrySetResult(pendingRequest.Packets); + } + catch (Exception ex) + { + pendingRequest.CompletionSource.TrySetException(ex); + } + } + + private void CompletePendingRequests(Exception exception) + { + while (_pendingRequests.TryDequeue(out var pendingRequest)) + pendingRequest.CompletionSource.TrySetException(exception); + } + + private void ProcessPackets(HttpMcApiNetPeer httpNetPeer, List packets, string token) + { + ReceivePacketsLogic(httpNetPeer, packets, token); + lock (_reader) + { + while (httpNetPeer.IncomingQueue.TryDequeue(out var packet)) + try + { + var packetToken = packet.Token; + _reader.Clear(); + _reader.SetSource(packet.Data); + ProcessPacket(_reader, mcApiPacket => + { + httpNetPeer.LastUpdate = DateTime.UtcNow; + if (!AuthorizePacket(mcApiPacket, httpNetPeer, packetToken) || + Config.DisabledPacketTypes.Contains(mcApiPacket.PacketType)) return; + ExecutePacket(mcApiPacket, httpNetPeer); + }); + } + catch + { + //Do Nothing + } + } + } + + private static void SendPacketsLogic(HttpMcApiNetPeer netPeer, List packets) { while (netPeer.OutgoingQueue.TryDequeue(out var packet)) { @@ -372,7 +476,66 @@ private static void SendPacketsLogic(HttpMcApiNetPeer netPeer, List pack } } - private void UpdatePeer(IPAddress ipAddress, HttpMcApiNetPeer httpNetPeer) + private static bool ContainsOnlyLoginRequests(IEnumerable packets) + { + var hasPackets = false; + foreach (var packet in packets) + { + if (!IsLoginRequestPacket(packet)) + return false; + + hasPackets = true; + } + + return hasPackets; + } + + private static bool IsLoginRequestPacket(byte[] packet) + { + return packet.Length > 0 && (McApiPacketType)packet[0] == McApiPacketType.LoginRequest; + } + + private bool TryReadPackedPackets(string stringData, List packets) + { + try + { + var packedPackets = Z85.GetBytesWithPadding(stringData); + lock (_httpReader) + { + _httpReader.Clear(); + _httpReader.SetSource(packedPackets); + while (!_httpReader.EndOfData) + { + var packetSize = _httpReader.GetUShort(); + if (packetSize <= 0) continue; + if (_httpReader.AvailableBytes < packetSize) + return false; + + var packet = new byte[packetSize]; + _httpReader.GetBytes(packet, packetSize); + packets.Add(packet); + } + } + + return true; + } + catch (ArgumentException) + { + return false; + } + } + + private void UpdatePeer(string lookupToken, HttpMcApiNetPeer httpNetPeer) + { + ProcessPackets(httpNetPeer); + if (DateTime.UtcNow - httpNetPeer.LastUpdate < TimeSpan.FromMilliseconds(Config.MaxTimeoutMs)) return; + Disconnect(httpNetPeer); + //Double the amount of time. We remove the peer. + if (DateTime.UtcNow - httpNetPeer.LastUpdate < TimeSpan.FromMilliseconds(Config.MaxTimeoutMs * 2)) return; + _mcApiPeers.TryRemove(lookupToken, out _); + } + + private void ProcessPackets(HttpMcApiNetPeer httpNetPeer) { lock (_reader) { @@ -381,7 +544,7 @@ private void UpdatePeer(IPAddress ipAddress, HttpMcApiNetPeer httpNetPeer) { var packetToken = packet.Token; _reader.Clear(); - _reader.SetSource(McApiStringCodec.Decode(packet.Data)); + _reader.SetSource(packet.Data); ProcessPacket(_reader, mcApiPacket => { httpNetPeer.LastUpdate = DateTime.UtcNow; @@ -395,12 +558,15 @@ private void UpdatePeer(IPAddress ipAddress, HttpMcApiNetPeer httpNetPeer) //Do Nothing } } - - if (DateTime.UtcNow - httpNetPeer.LastUpdate < TimeSpan.FromMilliseconds(Config.MaxTimeoutMs)) return; - Disconnect(httpNetPeer); - //Double the amount of time. We remove the peer. - if (DateTime.UtcNow - httpNetPeer.LastUpdate < TimeSpan.FromMilliseconds(Config.MaxTimeoutMs * 2)) return; - _mcApiPeers.TryRemove(ipAddress, out _); + } + + private void TrackPeer(HttpMcApiNetPeer httpNetPeer, string? previousLookupToken = null) + { + if (!string.IsNullOrEmpty(previousLookupToken) && previousLookupToken != httpNetPeer.LookupToken) + _mcApiPeers.TryRemove(previousLookupToken, out _); + + if (!string.IsNullOrEmpty(httpNetPeer.LookupToken)) + _mcApiPeers[httpNetPeer.LookupToken] = httpNetPeer; } private static string BuildListenerPrefix(string configuredHostname) @@ -428,6 +594,28 @@ private static string BuildListenerPrefix(string configuredHostname) return $"{Uri.UriSchemeHttp}://{host}:{port}/"; } + private static bool TryGetBearerToken(string? authorizationHeader, out string token) + { + const string bearerPrefix = "Bearer "; + + token = string.Empty; + if (string.IsNullOrWhiteSpace(authorizationHeader) || + !authorizationHeader.StartsWith(bearerPrefix, StringComparison.OrdinalIgnoreCase)) + return false; + + token = authorizationHeader[bearerPrefix.Length..].Trim(); + return !string.IsNullOrWhiteSpace(token); + } + + private sealed class PendingHttpRequest(HttpMcApiNetPeer netPeer, List packets, string token) + { + public HttpMcApiNetPeer NetPeer { get; } = netPeer; + public List Packets { get; } = packets; + public string Token { get; } = token; + public TaskCompletionSource> CompletionSource { get; } = + new(TaskCreationOptions.RunContinuationsAsynchronously); + } + public class HttpMcApiConfig { [JsonConverter(typeof(JsonBooleanConverter))] diff --git a/VoiceCraft.Network/Servers/LiteNetVoiceCraftServer.cs b/VoiceCraft.Network/Servers/LiteNetVoiceCraftServer.cs index c2df3ce0..35ccbe1e 100644 --- a/VoiceCraft.Network/Servers/LiteNetVoiceCraftServer.cs +++ b/VoiceCraft.Network/Servers/LiteNetVoiceCraftServer.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Concurrent; -using System.Linq; using System.Net; using System.Net.Sockets; using System.Text.Json.Serialization; diff --git a/VoiceCraft.Network/Servers/McApiServer.cs b/VoiceCraft.Network/Servers/McApiServer.cs index bec82508..ac5437e4 100644 --- a/VoiceCraft.Network/Servers/McApiServer.cs +++ b/VoiceCraft.Network/Servers/McApiServer.cs @@ -333,6 +333,8 @@ private void HandleCreateEntityRequestPacket(McApiCreateEntityRequestPacket pack CaveFactor = packet.CaveFactor, MuffleFactor = packet.MuffleFactor }; + + world.AddEntity(entity); SendPacket(netPeer, PacketPool .GetPacket(() => new McApiCreateEntityResponsePacket()) .Set(packet.RequestId, McApiCreateEntityResponsePacket.ResponseCodes.Ok, entity.Id)); @@ -508,4 +510,4 @@ private static void ProcessPacket(NetDataReader reader, Action PacketPool.Return(packet); } } -} \ No newline at end of file +} diff --git a/VoiceCraft.Network/Servers/McWssMcApiServer.cs b/VoiceCraft.Network/Servers/McWssMcApiServer.cs index ed81f280..24977a3c 100644 --- a/VoiceCraft.Network/Servers/McWssMcApiServer.cs +++ b/VoiceCraft.Network/Servers/McWssMcApiServer.cs @@ -2,7 +2,6 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; -using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using Fleck; @@ -22,6 +21,8 @@ public class McWssMcApiServer(VoiceCraftWorld world, AudioEffectSystem audioEffe { private McWssMcApiConfig _config = new(); private readonly ConcurrentDictionary _mcApiPeers = new(); + private readonly NetDataWriter _mcWssWriter = new(); + private readonly NetDataReader _mcWssReader = new(); private readonly NetDataReader _reader = new(); private readonly NetDataWriter _writer = new(); private WebSocketServer? _wsServer; @@ -66,11 +67,24 @@ public override void Update() public override void Stop() { - if (_wsServer == null) return; + if (_wsServer == null) + { + _mcApiPeers.Clear(); + return; + } + _wsServer.Dispose(); - foreach (var client in _mcApiPeers) + foreach (var client in _mcApiPeers.ToArray()) try { + if (client.Value.ConnectionState == McApiConnectionState.Connected) + { + var sessionToken = client.Value.SessionToken; + client.Value.SetConnectionState(McApiConnectionState.Disconnected); + client.Value.SetSessionToken(string.Empty); + OnPeerDisconnected?.Invoke(client.Value, sessionToken); + } + client.Key.Close(); } catch @@ -78,6 +92,7 @@ public override void Stop() //Do Nothing } + _mcApiPeers.Clear(); _wsServer = null; } @@ -95,8 +110,7 @@ public override void SendPacket(McApiNetPeer netPeer, T packet) if (_writer.Length > short.MaxValue) throw new ArgumentOutOfRangeException(nameof(packet)); - var encodedPacket = McApiStringCodec.Encode(_writer.AsReadOnlySpan()); - netPeer.OutgoingQueue.Enqueue(new McApiNetPeer.QueuedPacket(encodedPacket, string.Empty)); + netPeer.OutgoingQueue.Enqueue(new McApiNetPeer.QueuedPacket(_writer.CopyData(), string.Empty)); } } finally @@ -112,18 +126,18 @@ public override void Broadcast(T packet, params McApiNetPeer?[] excludes) { lock (_writer) { - var netPeers = _mcApiPeers.Where(x => x.Value.ConnectionState == McApiConnectionState.Connected); _writer.Reset(); _writer.Put((byte)packet.PacketType); _writer.Put(packet); if (_writer.Length > short.MaxValue) throw new ArgumentOutOfRangeException(nameof(packet)); - var encodedPacket = McApiStringCodec.Encode(_writer.AsReadOnlySpan()); - foreach (var netPeer in netPeers) + var data = _writer.CopyData(); + foreach (var netPeer in _mcApiPeers.Values) { - if (excludes.Contains(netPeer.Value)) continue; - netPeer.Value.OutgoingQueue.Enqueue(new McApiNetPeer.QueuedPacket(encodedPacket, string.Empty)); + if (netPeer.ConnectionState != McApiConnectionState.Connected || excludes.Contains(netPeer)) + continue; + netPeer.OutgoingQueue.Enqueue(new McApiNetPeer.QueuedPacket(data, string.Empty)); } } } @@ -159,8 +173,7 @@ public override void Disconnect(McApiNetPeer netPeer, bool force = false) if (_writer.Length > short.MaxValue) throw new ArgumentOutOfRangeException(nameof(netPeer)); - var encodedPacket = McApiStringCodec.Encode(_writer.AsReadOnlySpan()); - netPeer.OutgoingQueue.Enqueue(new McApiNetPeer.QueuedPacket(encodedPacket, string.Empty)); + netPeer.OutgoingQueue.Enqueue(new McApiNetPeer.QueuedPacket(_writer.CopyData(), string.Empty)); } } finally @@ -208,8 +221,7 @@ protected override void RejectRequest(McApiLoginRequestPacket packet, string rea if (_writer.Length > short.MaxValue) throw new ArgumentOutOfRangeException(nameof(packet)); - var encodedPacket = McApiStringCodec.Encode(_writer.AsReadOnlySpan()); - mcWssNetPeer.OutgoingQueue.Enqueue(new McApiNetPeer.QueuedPacket(encodedPacket, string.Empty)); + mcWssNetPeer.OutgoingQueue.Enqueue(new McApiNetPeer.QueuedPacket(_writer.CopyData(), string.Empty)); } } finally @@ -232,10 +244,23 @@ private void HandleDataTunnelCommandResponse(IWebSocketConnection socket, string try { if (!_mcApiPeers.TryGetValue(socket, out var peer) || string.IsNullOrWhiteSpace(data)) return; - var packets = McWssPacketFraming.Unpack(data); - - foreach (var packet in packets) - peer.IncomingQueue.Enqueue(new McApiNetPeer.QueuedPacket(packet, string.Empty)); + var packedPackets = Z85.GetBytesWithPadding(data); + lock (_mcWssReader) + { + _mcWssReader.Clear(); + _mcWssReader.SetSource(packedPackets); + while (!_mcWssReader.EndOfData) + { + var packetSize = _mcWssReader.GetUShort(); + if (packetSize <= 0) continue; + if (_mcWssReader.AvailableBytes < packetSize) + return; + + var packet = new byte[packetSize]; + _mcWssReader.GetBytes(packet, packetSize); + peer.IncomingQueue.Enqueue(new McApiNetPeer.QueuedPacket(packet, string.Empty)); + } + } } catch { @@ -244,6 +269,22 @@ private void HandleDataTunnelCommandResponse(IWebSocketConnection socket, string } private void UpdatePeer(IWebSocketConnection connection, McWssMcApiNetPeer mcWssNetPeer) + { + ProcessPackets(mcWssNetPeer); + + for (var i = 0; i < Config.CommandsPerTick; i++) + if (!SendPacketsLogic(connection, mcWssNetPeer)) + SendPacketCommand(connection, string.Empty); + + if (DateTime.UtcNow - mcWssNetPeer.LastUpdate < TimeSpan.FromMilliseconds(Config.MaxTimeoutMs)) return; + Disconnect(mcWssNetPeer); + //Double the amount of time. We remove the peer. + if (DateTime.UtcNow - mcWssNetPeer.LastUpdate < TimeSpan.FromMilliseconds(Config.MaxTimeoutMs * 2)) return; + if (_mcApiPeers.TryRemove(connection, out _)) + connection.Close(); + } + + private void ProcessPackets(McWssMcApiNetPeer mcWssNetPeer) { lock (_reader) { @@ -251,7 +292,7 @@ private void UpdatePeer(IWebSocketConnection connection, McWssMcApiNetPeer mcWss try { _reader.Clear(); - _reader.SetSource(McApiStringCodec.Decode(packet.Data)); + _reader.SetSource(packet.Data); ProcessPacket(_reader, mcApiPacket => { mcWssNetPeer.LastUpdate = DateTime.UtcNow; @@ -265,50 +306,30 @@ private void UpdatePeer(IWebSocketConnection connection, McWssMcApiNetPeer mcWss //Do Nothing } } - - for (var i = 0; i < Config.CommandsPerTick; i++) - if (!SendPacketsLogic(connection, mcWssNetPeer)) - SendPacketCommand(connection, string.Empty); - - if (DateTime.UtcNow - mcWssNetPeer.LastUpdate < TimeSpan.FromMilliseconds(Config.MaxTimeoutMs)) return; - Disconnect(mcWssNetPeer); - //Double the amount of time. We remove the peer. - if (DateTime.UtcNow - mcWssNetPeer.LastUpdate < TimeSpan.FromMilliseconds(Config.MaxTimeoutMs * 2)) return; - if (_mcApiPeers.TryRemove(connection, out _)) - connection.Close(); } private bool SendPacketsLogic(IWebSocketConnection socket, McApiNetPeer netPeer) { - var stringBuilder = new StringBuilder(); if (!netPeer.OutgoingQueue.TryDequeue(out var outboundPacket)) return false; - McWssPacketFraming.TryAppendFrame( - stringBuilder, - outboundPacket.Data, - (int)Config.MaxStringLengthPerCommand, - allowOversizedFirstFrame: true); - - var deferredPackets = new List(); - while (netPeer.OutgoingQueue.TryDequeue(out outboundPacket)) + string data; + lock (_mcWssWriter) { - if (McWssPacketFraming.TryAppendFrame( - stringBuilder, - outboundPacket.Data, - (int)Config.MaxStringLengthPerCommand, - allowOversizedFirstFrame: false)) - continue; - - deferredPackets.Add(outboundPacket); - break; - } + _mcWssWriter.Reset(); + _mcWssWriter.Put((ushort)outboundPacket.Data.Length); + _mcWssWriter.Put(outboundPacket.Data); - while (netPeer.OutgoingQueue.TryDequeue(out outboundPacket)) - deferredPackets.Add(outboundPacket); + while (netPeer.OutgoingQueue.TryPeek(out var nextPacket) && + _mcWssWriter.Length + sizeof(ushort) + nextPacket.Data.Length <= Config.MaxByteLengthPerCommand && + netPeer.OutgoingQueue.TryDequeue(out outboundPacket)) + { + _mcWssWriter.Put((ushort)outboundPacket.Data.Length); + _mcWssWriter.Put(outboundPacket.Data); + } - foreach (var deferredPacket in deferredPackets) - netPeer.OutgoingQueue.Enqueue(deferredPacket); + data = Z85.GetStringWithPadding(_mcWssWriter.AsReadOnlySpan()); + } - SendPacketCommand(socket, stringBuilder.ToString()); + SendPacketCommand(socket, data); return true; } @@ -316,7 +337,7 @@ private void SendPacketCommand(IWebSocketConnection socket, string packetData) { var packet = new McWssCommandRequest( - $"{Config.DataTunnelCommand} {Config.MaxStringLengthPerCommand} \"{packetData}\""); + $"{Config.DataTunnelCommand} {Config.MaxByteLengthPerCommand} \"{packetData}\""); socket.Send(JsonSerializer.Serialize(packet, McWssCommandRequestGenerationContext.Default.McWssCommandRequest)); } @@ -325,10 +346,15 @@ private void SendPacketCommand(IWebSocketConnection socket, string packetData) private void OnClientConnected(IWebSocketConnection socket) { if (_mcApiPeers.Count >= Config.MaxClients) + { socket.Close(); //Full. + return; + } - var netPeer = new McWssMcApiNetPeer(socket); - netPeer.Tag = this; + var netPeer = new McWssMcApiNetPeer(socket) + { + Tag = this + }; _mcApiPeers.TryAdd(socket, netPeer); } @@ -373,8 +399,8 @@ public class McWssMcApiConfig public uint MaxClients { get; set; } = 1; public uint MaxTimeoutMs { get; set; } = 10000; public string DataTunnelCommand { get; set; } = "voicecraft:data_tunnel"; - public uint CommandsPerTick { get; set; } = 5; - public uint MaxStringLengthPerCommand { get; set; } = 1000; + public uint CommandsPerTick { get; set; } = 3; + public uint MaxByteLengthPerCommand { get; set; } = 300; public HashSet DisabledPacketTypes { get; set; } = []; } } diff --git a/VoiceCraft.Network/Servers/TcpMcApiServer.cs b/VoiceCraft.Network/Servers/TcpMcApiServer.cs index 235ddb2d..4c79ada9 100644 --- a/VoiceCraft.Network/Servers/TcpMcApiServer.cs +++ b/VoiceCraft.Network/Servers/TcpMcApiServer.cs @@ -1,8 +1,8 @@ using System; +using System.Buffers; using System.Buffers.Binary; using System.Collections.Concurrent; using System.Collections.Generic; -using System.IO; using System.Linq; using System.Net; using System.Net.Sockets; @@ -37,6 +37,8 @@ public class TcpMcApiServer(VoiceCraftWorld world, AudioEffectSystem audioEffect private TcpListener? _listener; private CancellationTokenSource? _listenerCancellationTokenSource; + private readonly record struct RentedFramePayload(byte[] Buffer, int Length); + public McTcpConfig Config { get => _config; @@ -118,7 +120,7 @@ public override void SendPacket(McApiNetPeer netPeer, T packet) if (netPeer is not TcpMcApiNetPeer tcpNetPeer) return; - tcpNetPeer.OutgoingRawQueue.Enqueue(_writer.CopyData()); + tcpNetPeer.OutgoingQueue.Enqueue(new McApiNetPeer.QueuedPacket(_writer.CopyData(), string.Empty)); } } finally @@ -134,7 +136,6 @@ public override void Broadcast(T packet, params McApiNetPeer?[] excludes) { lock (_writer) { - var netPeers = _mcApiPeers.Where(x => x.Value.ConnectionState == McApiConnectionState.Connected); _writer.Reset(); _writer.Put((byte)packet.PacketType); _writer.Put(packet); @@ -142,10 +143,11 @@ public override void Broadcast(T packet, params McApiNetPeer?[] excludes) throw new ArgumentOutOfRangeException(nameof(packet)); var encodedPacket = _writer.CopyData(); - foreach (var netPeer in netPeers) + foreach (var netPeer in _mcApiPeers.Values) { - if (excludes.Contains(netPeer.Value)) continue; - netPeer.Value.OutgoingRawQueue.Enqueue(encodedPacket); + if (netPeer.ConnectionState != McApiConnectionState.Connected || excludes.Contains(netPeer)) + continue; + netPeer.OutgoingQueue.Enqueue(new McApiNetPeer.QueuedPacket(encodedPacket, string.Empty)); } } } @@ -209,7 +211,7 @@ protected override void RejectRequest(McApiLoginRequestPacket packet, string rea if (_writer.Length > short.MaxValue) throw new ArgumentOutOfRangeException(nameof(packet)); - tcpNetPeer.OutgoingRawQueue.Enqueue(_writer.CopyData()); + tcpNetPeer.OutgoingQueue.Enqueue(new McApiNetPeer.QueuedPacket(_writer.CopyData(), string.Empty)); } } finally @@ -238,58 +240,51 @@ private async Task ListenerLoopAsync(TcpListener listener, CancellationToken can _ = HandleClientAsync(client, cancellationToken); } } - catch (OperationCanceledException) - { - // Do Nothing - } - catch (ObjectDisposedException) - { - // Do Nothing - } - catch (SocketException) + catch { - // Do Nothing + //Do Nothing } } private async Task HandleClientAsync(TcpClient client, CancellationToken cancellationToken) { + var headerBuffer = ArrayPool.Shared.Rent(FrameHeaderSize); try { await using var stream = client.GetStream(); while (!cancellationToken.IsCancellationRequested && client.Connected) { - var payload = await ReadFrameAsync(stream, cancellationToken); - if (payload == null) + var framePayload = await ReadFrameAsync(stream, headerBuffer, cancellationToken); + if (framePayload == null) break; - if (!_mcApiPeers.TryGetValue(client, out var peer)) - break; + var payload = framePayload.Value; + try + { + if (!_mcApiPeers.TryGetValue(client, out var peer)) + break; - if (!TryReadPayload(payload, out var token, out var packets)) - break; + if (!TryReadPayload(payload.Buffer.AsSpan(0, payload.Length), out var token, out var packets)) + break; - var responseTask = peer.CreatePendingResponseTask(); - ReceivePacketsLogic(peer, packets, token); - var responsePackets = await responseTask.WaitAsync(cancellationToken); - var response = WritePayload(string.Empty, responsePackets); - await WriteFrameAsync(stream, response, cancellationToken); + var responseTask = peer.CreatePendingResponseTask(); + ReceivePacketsLogic(peer, packets, token); + var responsePackets = await responseTask.WaitAsync(cancellationToken); + await WriteFrameAsync(stream, string.Empty, responsePackets, cancellationToken); + } + finally + { + ArrayPool.Shared.Return(payload.Buffer); + } } } - catch (OperationCanceledException) - { - // Do Nothing - } - catch (IOException) - { - // Do Nothing - } - catch (ObjectDisposedException) + catch { - // Do Nothing + //Do Nothing } finally { + ArrayPool.Shared.Return(headerBuffer); OnClientDisconnected(client); } } @@ -332,19 +327,18 @@ private void OnClientDisconnected(TcpClient client) private void UpdatePeer(TcpClient client, TcpMcApiNetPeer tcpNetPeer) { - ProcessIncomingPackets(tcpNetPeer); - CompletePendingResponse(tcpNetPeer); - + ProcessPackets(tcpNetPeer); + SendPacketsLogic(tcpNetPeer); if (DateTime.UtcNow - tcpNetPeer.LastUpdate < TimeSpan.FromMilliseconds(Config.MaxTimeoutMs)) return; if (_mcApiPeers.TryRemove(client, out _)) Disconnect(tcpNetPeer, true); } - private void ProcessIncomingPackets(TcpMcApiNetPeer tcpNetPeer) + private void ProcessPackets(TcpMcApiNetPeer tcpNetPeer) { lock (_reader) { - while (tcpNetPeer.IncomingRawQueue.TryDequeue(out var packet)) + while (tcpNetPeer.IncomingQueue.TryDequeue(out var packet)) try { var packetToken = packet.Token; @@ -365,16 +359,16 @@ private void ProcessIncomingPackets(TcpMcApiNetPeer tcpNetPeer) } } - private static void CompletePendingResponse(TcpMcApiNetPeer netPeer) + private static void SendPacketsLogic(TcpMcApiNetPeer netPeer) { if (!netPeer.HasPendingResponse()) return; var packets = new List(); - while (netPeer.OutgoingRawQueue.TryDequeue(out var packet)) + while (netPeer.OutgoingQueue.TryDequeue(out var packet)) { try { - packets.Add(packet); + packets.Add(packet.Data); } catch { @@ -387,11 +381,12 @@ private static void CompletePendingResponse(TcpMcApiNetPeer netPeer) private static void ReceivePacketsLogic(TcpMcApiNetPeer tcpNetPeer, IReadOnlyList packets, string token) { - foreach (var data in packets.Where(data => data.Length > 0)) + foreach (var data in packets) { + if (data.Length == 0) continue; try { - tcpNetPeer.IncomingRawQueue.Enqueue(new TcpMcApiNetPeer.QueuedRawPacket(data, token)); + tcpNetPeer.IncomingQueue.Enqueue(new McApiNetPeer.QueuedPacket(data, token)); } catch { @@ -400,10 +395,12 @@ private static void ReceivePacketsLogic(TcpMcApiNetPeer tcpNetPeer, IReadOnlyLis } } - private static async Task ReadFrameAsync(NetworkStream stream, CancellationToken cancellationToken) + private static async Task ReadFrameAsync( + NetworkStream stream, + byte[] headerBuffer, + CancellationToken cancellationToken) { - var headerBuffer = new byte[FrameHeaderSize]; - if (!await ReadExactAsync(stream, headerBuffer, cancellationToken)) + if (!await ReadExactAsync(stream, headerBuffer.AsMemory(0, FrameHeaderSize), cancellationToken)) return null; var magic = BinaryPrimitives.ReadInt32BigEndian(headerBuffer.AsSpan(0, 4)); @@ -416,30 +413,60 @@ private static void ReceivePacketsLogic(TcpMcApiNetPeer tcpNetPeer, IReadOnlyLis if (payloadLength is < 0 or > MaxFramePayloadLength) return null; - var payloadBuffer = new byte[payloadLength]; - if (!await ReadExactAsync(stream, payloadBuffer, cancellationToken)) - return null; + var payloadBuffer = ArrayPool.Shared.Rent(payloadLength); + if (await ReadExactAsync(stream, payloadBuffer.AsMemory(0, payloadLength), cancellationToken)) + return new RentedFramePayload(payloadBuffer, payloadLength); + ArrayPool.Shared.Return(payloadBuffer); + return null; - return payloadBuffer; } - private static async Task WriteFrameAsync(NetworkStream stream, byte[] payloadBuffer, CancellationToken cancellationToken) + private static async Task WriteFrameAsync( + NetworkStream stream, + string token, + IReadOnlyList packets, + CancellationToken cancellationToken) { - if (payloadBuffer.Length > MaxFramePayloadLength) + var tokenLength = string.IsNullOrEmpty(token) ? 0 : Encoding.UTF8.GetByteCount(token); + var payloadLength = 4 + tokenLength + 4; + foreach (var packet in packets) + payloadLength += 4 + packet.Length; + + if (payloadLength > MaxFramePayloadLength) throw new InvalidOperationException("McTcp response payload exceeds the maximum frame size."); - var frameBuffer = new byte[FrameHeaderSize + payloadBuffer.Length]; - BinaryPrimitives.WriteInt32BigEndian(frameBuffer.AsSpan(0, 4), FrameMagic); - BinaryPrimitives.WriteUInt16BigEndian(frameBuffer.AsSpan(4, 2), FrameVersion); - BinaryPrimitives.WriteUInt16BigEndian(frameBuffer.AsSpan(6, 2), ResponseKind); - BinaryPrimitives.WriteInt32BigEndian(frameBuffer.AsSpan(8, 4), payloadBuffer.Length); - payloadBuffer.CopyTo(frameBuffer.AsSpan(FrameHeaderSize)); + var frameLength = FrameHeaderSize + payloadLength; + var frameBuffer = ArrayPool.Shared.Rent(frameLength); + try + { + BinaryPrimitives.WriteInt32BigEndian(frameBuffer.AsSpan(0, 4), FrameMagic); + BinaryPrimitives.WriteUInt16BigEndian(frameBuffer.AsSpan(4, 2), FrameVersion); + BinaryPrimitives.WriteUInt16BigEndian(frameBuffer.AsSpan(6, 2), ResponseKind); + BinaryPrimitives.WriteInt32BigEndian(frameBuffer.AsSpan(8, 4), payloadLength); + + var offset = FrameHeaderSize; + WriteInt32(frameBuffer, ref offset, tokenLength); + if (tokenLength > 0) + offset += Encoding.UTF8.GetBytes(token, frameBuffer.AsSpan(offset, tokenLength)); - await stream.WriteAsync(frameBuffer, cancellationToken); - await stream.FlushAsync(cancellationToken); + WriteInt32(frameBuffer, ref offset, packets.Count); + foreach (var packet in packets) + { + WriteInt32(frameBuffer, ref offset, packet.Length); + packet.CopyTo(frameBuffer.AsSpan(offset)); + offset += packet.Length; + } + + await stream.WriteAsync(frameBuffer.AsMemory(0, frameLength), cancellationToken); + await stream.FlushAsync(cancellationToken); + } + finally + { + ArrayPool.Shared.Return(frameBuffer); + } } - private static bool TryReadPayload(byte[] payload, out string token, out List packets) + private static bool TryReadPayload(ReadOnlySpan payload, out string token, out List packets) { token = string.Empty; packets = []; @@ -447,14 +474,17 @@ private static bool TryReadPayload(byte[] payload, out string token, out List (payload.Length - offset) / 5) + return false; packets = new List(packetCount); for (var i = 0; i < packetCount; i++) @@ -464,7 +494,7 @@ private static bool TryReadPayload(byte[] payload, out string token, out List packets) - { - var tokenBytes = string.IsNullOrEmpty(token) ? [] : Encoding.UTF8.GetBytes(token); - var payloadLength = 4 + tokenBytes.Length + 4 + packets.Sum(packet => 4 + packet.Length); - - var payload = new byte[payloadLength]; - var offset = 0; - WriteInt32(payload, ref offset, tokenBytes.Length); - if (tokenBytes.Length > 0) - { - tokenBytes.CopyTo(payload, offset); - offset += tokenBytes.Length; - } - - WriteInt32(payload, ref offset, packets.Count); - foreach (var packet in packets) - { - WriteInt32(payload, ref offset, packet.Length); - packet.CopyTo(payload, offset); - offset += packet.Length; - } - - return payload; - } - - private static bool TryReadInt32(byte[] payload, ref int offset, out int value) + private static bool TryReadInt32(ReadOnlySpan payload, ref int offset, out int value) { value = 0; if (payload.Length - offset < 4) return false; - value = BinaryPrimitives.ReadInt32BigEndian(payload.AsSpan(offset, 4)); + value = BinaryPrimitives.ReadInt32BigEndian(payload.Slice(offset, 4)); offset += 4; return true; } @@ -514,13 +519,13 @@ private static void WriteInt32(byte[] payload, ref int offset, int value) offset += 4; } - private static async ValueTask ReadExactAsync(NetworkStream stream, byte[] buffer, + private static async ValueTask ReadExactAsync(NetworkStream stream, Memory buffer, CancellationToken cancellationToken) { var offset = 0; while (offset < buffer.Length) { - var read = await stream.ReadAsync(buffer.AsMemory(offset, buffer.Length - offset), cancellationToken); + var read = await stream.ReadAsync(buffer[offset..], cancellationToken); if (read == 0) return false; offset += read; @@ -549,13 +554,9 @@ private static void CloseClient(TcpClient client) { client.Close(); } - catch (SocketException) - { - // Do Nothing - } - catch (ObjectDisposedException) + catch { - // Do Nothing + //Do Nothing } } diff --git a/VoiceCraft.Network/Systems/AudioEffectSystem.cs b/VoiceCraft.Network/Systems/AudioEffectSystem.cs index c95f7b8e..f8c8b30f 100644 --- a/VoiceCraft.Network/Systems/AudioEffectSystem.cs +++ b/VoiceCraft.Network/Systems/AudioEffectSystem.cs @@ -18,16 +18,10 @@ public class AudioEffectSystem : IDisposable { private readonly OrderedDictionary _audioEffects = new(); private OrderedDictionary _defaultAudioEffects = new(); + private volatile ImmutableSortedDictionary _audioEffectsSnapshot = + ImmutableSortedDictionary.Empty; private readonly Lock _lock = new(); - - public IImmutableDictionary AudioEffects - { - get - { - lock (_audioEffects) - return _audioEffects.ToImmutableSortedDictionary(); - } - } + public IImmutableDictionary AudioEffects => _audioEffectsSnapshot; public OrderedDictionary DefaultAudioEffects { @@ -61,14 +55,17 @@ public void SetEffect(ushort bitmask, IAudioEffect? effect) { case null when _audioEffects.Remove(bitmask, out var audioEffect): audioEffect.Dispose(); + _audioEffectsSnapshot = _audioEffects.ToImmutableSortedDictionary(); OnEffectSet?.Invoke(bitmask, null); return; case null: return; } - if (!_audioEffects.TryAdd(bitmask, effect)) - _audioEffects[bitmask] = effect; + if (_audioEffects.TryGetValue(bitmask, out var oldEffect) && !ReferenceEquals(oldEffect, effect)) + oldEffect.Dispose(); + _audioEffects[bitmask] = effect; + _audioEffectsSnapshot = _audioEffects.ToImmutableSortedDictionary(); OnEffectSet?.Invoke(bitmask, effect); } } @@ -87,6 +84,7 @@ public void ClearEffects() { var effects = _audioEffects.ToArray(); //Copy the effects. _audioEffects.Clear(); + _audioEffectsSnapshot = ImmutableSortedDictionary.Empty; foreach (var effect in effects) { effect.Value.Dispose(); @@ -157,11 +155,9 @@ private int ProcessEntityAudio(Span buffer, VoiceCraftClientEntity from, private void ProcessEntityEffects(Span buffer, VoiceCraftClientEntity from, VoiceCraftEntity to) { - lock (_audioEffects) - { - foreach (var effect in _audioEffects) - effect.Value.Process(from, to, effect.Key, buffer); - } + var snapshot = _audioEffectsSnapshot; + foreach (var effect in snapshot) + effect.Value.Process(from, to, effect.Key, buffer); } public void Dispose() @@ -176,4 +172,4 @@ private void Dispose(bool disposing) ClearEffects(); OnEffectSet = null; } -} \ No newline at end of file +} diff --git a/VoiceCraft.Network/Systems/VisibilitySystem.cs b/VoiceCraft.Network/Systems/VisibilitySystem.cs index add1dfd1..fa85fa59 100644 --- a/VoiceCraft.Network/Systems/VisibilitySystem.cs +++ b/VoiceCraft.Network/Systems/VisibilitySystem.cs @@ -1,7 +1,9 @@ +using System.Collections.Immutable; using System.Linq; using System.Threading.Tasks; using VoiceCraft.Core.Interfaces; using VoiceCraft.Core.World; +using VoiceCraft.Network.Interfaces; using VoiceCraft.Network.World; namespace VoiceCraft.Network.Systems; @@ -10,20 +12,27 @@ public class VisibilitySystem(VoiceCraftWorld world, AudioEffectSystem audioEffe { public void Update() { - Parallel.ForEach(world.Entities, UpdateVisibleNetworkEntities); + var entities = world.Entities.ToArray(); + var visibleNetworkEntities = entities.OfType().ToArray(); + var audioEffects = audioEffectSystem.AudioEffects; + + Parallel.ForEach(entities, + entity => UpdateVisibleNetworkEntities(entity, visibleNetworkEntities, audioEffects)); } - private void UpdateVisibleNetworkEntities(VoiceCraftEntity entity) + private static void UpdateVisibleNetworkEntities( + VoiceCraftEntity entity, + VoiceCraftNetworkEntity[] visibleNetworkEntities, + IImmutableDictionary audioEffects) { //Remove dead network entities. entity.TrimDeadEntities(); //Add any new possible entities. - var visibleNetworkEntities = world.Entities.OfType(); foreach (var possibleEntity in visibleNetworkEntities) { if (possibleEntity.Id == entity.Id) continue; - if (!EntityVisibility(entity, possibleEntity)) + if (!EntityVisibility(entity, possibleEntity, audioEffects)) { entity.RemoveVisibleEntity(possibleEntity); continue; @@ -33,10 +42,13 @@ private void UpdateVisibleNetworkEntities(VoiceCraftEntity entity) } } - private bool EntityVisibility(VoiceCraftEntity from, VoiceCraftNetworkEntity to) + private static bool EntityVisibility( + VoiceCraftEntity from, + VoiceCraftNetworkEntity to, + IImmutableDictionary audioEffects) { if ((from.TalkBitmask & to.ListenBitmask) == 0) return false; - foreach (var effect in audioEffectSystem.AudioEffects) + foreach (var effect in audioEffects) { if (effect.Value is not IVisible visibleEffect) continue; if (!visibleEffect.Visibility(from, to, effect.Key)) return false; @@ -44,4 +56,4 @@ private bool EntityVisibility(VoiceCraftEntity from, VoiceCraftNetworkEntity to) return true; } -} \ No newline at end of file +} diff --git a/VoiceCraft.Network/VoiceCraft.Network.csproj b/VoiceCraft.Network/VoiceCraft.Network.csproj index c04e4893..014c9fdb 100644 --- a/VoiceCraft.Network/VoiceCraft.Network.csproj +++ b/VoiceCraft.Network/VoiceCraft.Network.csproj @@ -1,7 +1,7 @@ - + - net9.0 + net10.0 enable true @@ -15,8 +15,4 @@ - - - - diff --git a/VoiceCraft.Network/World/VoiceCraftClientEntity.cs b/VoiceCraft.Network/World/VoiceCraftClientEntity.cs index cf227057..5ef2aeaf 100644 --- a/VoiceCraft.Network/World/VoiceCraftClientEntity.cs +++ b/VoiceCraft.Network/World/VoiceCraftClientEntity.cs @@ -17,10 +17,6 @@ public class VoiceCraftClientEntity : VoiceCraftEntity { PrefillSize = Constants.PrefillBufferSize }; private DateTime _lastPacket = DateTime.MinValue; - private bool _speaking; - private bool _userMuted; - private bool _isVisible; - private float _volume = 1f; public VoiceCraftClientEntity(int id, IAudioDecoder decoder) : base(id) { @@ -30,13 +26,13 @@ public VoiceCraftClientEntity(int id, IAudioDecoder decoder) : base(id) public bool IsVisible { - get => _isVisible; + get; set { - if (_isVisible == value) return; - _isVisible = value; - OnIsVisibleUpdated?.Invoke(_isVisible, this); - if (_isVisible) return; + if (field == value) return; + field = value; + OnIsVisibleUpdated?.Invoke(field, this); + if (field) return; Speaking = false; ClearBuffer(); } @@ -44,33 +40,33 @@ public bool IsVisible public float Volume { - get => _volume; + get; set { - if (Math.Abs(_volume - value) < Constants.FloatingPointTolerance) return; - _volume = value; - OnVolumeUpdated?.Invoke(_volume, this); + if (Math.Abs(field - value) < Constants.FloatingPointTolerance) return; + field = value; + OnVolumeUpdated?.Invoke(field, this); } - } + } = 1f; public bool UserMuted { - get => _userMuted; + get; set { - if (_userMuted == value) return; - _userMuted = value; - OnUserMutedUpdated?.Invoke(_userMuted, this); + if (field == value) return; + field = value; + OnUserMutedUpdated?.Invoke(field, this); } } public bool Speaking { - get => _speaking; + get; set { - if (_speaking == value) return; - _speaking = value; + if (field == value) return; + field = value; if (value) OnStartedSpeaking?.Invoke(this); else OnStoppedSpeaking?.Invoke(this); } @@ -84,7 +80,7 @@ public bool Speaking public int Read(Span buffer) { - if (_userMuted) + if (UserMuted) { Speaking = false; return 0; @@ -180,7 +176,7 @@ private async Task TaskLogicAsync() startTick += Constants.FrameSizeMs; //Step Forwards. Array.Clear(readBuffer); //Clear Read Buffer. var read = GetNextPacket(readBuffer); - if (read <= 0 || _userMuted) continue; + if (read <= 0 || UserMuted) continue; _outputBuffer.Write(readBuffer.AsSpan(0, read)); } catch diff --git a/VoiceCraft.Network/World/VoiceCraftClientNetworkEntity.cs b/VoiceCraft.Network/World/VoiceCraftClientNetworkEntity.cs index 4cb9a49b..6d92fdb0 100644 --- a/VoiceCraft.Network/World/VoiceCraftClientNetworkEntity.cs +++ b/VoiceCraft.Network/World/VoiceCraftClientNetworkEntity.cs @@ -6,30 +6,27 @@ namespace VoiceCraft.Network.World; public class VoiceCraftClientNetworkEntity(int id, IAudioDecoder decoder, Guid userGuid) : VoiceCraftClientEntity(id, decoder) { - private bool _serverDeafened; - private bool _serverMuted; - public Guid UserGuid { get; private set; } = userGuid; public bool ServerMuted { - get => _serverMuted; + get; set { - if (_serverMuted == value) return; - _serverMuted = value; - OnServerMuteUpdated?.Invoke(_serverMuted, this); + if (field == value) return; + field = value; + OnServerMuteUpdated?.Invoke(field, this); } } public bool ServerDeafened { - get => _serverDeafened; + get; set { - if (_serverDeafened == value) return; - _serverDeafened = value; - OnServerDeafenUpdated?.Invoke(_serverDeafened, this); + if (field == value) return; + field = value; + OnServerDeafenUpdated?.Invoke(field, this); } } diff --git a/VoiceCraft.Network/World/VoiceCraftNetworkEntity.cs b/VoiceCraft.Network/World/VoiceCraftNetworkEntity.cs index ff59b869..adcf9b51 100644 --- a/VoiceCraft.Network/World/VoiceCraftNetworkEntity.cs +++ b/VoiceCraft.Network/World/VoiceCraftNetworkEntity.cs @@ -7,9 +7,6 @@ namespace VoiceCraft.Network.World { public class VoiceCraftNetworkEntity : VoiceCraftEntity { - private bool _serverDeafened; - private bool _serverMuted; - public VoiceCraftNetworkEntity( VoiceCraftNetPeer netPeer, int id) : base(id) @@ -32,23 +29,23 @@ public VoiceCraftNetworkEntity( public bool ServerMuted { - get => _serverMuted; + get; set { - if (_serverMuted == value) return; - _serverMuted = value; - OnServerMuteUpdated?.Invoke(_serverMuted, this); + if (field == value) return; + field = value; + OnServerMuteUpdated?.Invoke(field, this); } } public bool ServerDeafened { - get => _serverDeafened; + get; set { - if (_serverDeafened == value) return; - _serverDeafened = value; - OnServerDeafenUpdated?.Invoke(_serverDeafened, this); + if (field == value) return; + field = value; + OnServerDeafenUpdated?.Invoke(field, this); } } diff --git a/VoiceCraft.Network/Z85.cs b/VoiceCraft.Network/Z85.cs new file mode 100644 index 00000000..a4386ee4 --- /dev/null +++ b/VoiceCraft.Network/Z85.cs @@ -0,0 +1,181 @@ +using System; +using System.Text; + +namespace VoiceCraft.Network +{ + public static class Z85 + { + private const int Base85 = 85; + + private static readonly char[] EncodingTable = + [ + '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', + 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', + 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', + 'u', 'v', 'w', 'x', 'y', 'z', 'A', 'B', 'C', 'D', + 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', + 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', + 'Y', 'Z', '.', '-', ':', '+', '=', '^', '!', '/', + '*', '?', '&', '<', '>', '(', ')', '[', ']', '{', + '}', '@', '%', '$', '#' + ]; + + private static readonly uint[] DecodingTable = + [ + 0, 68, 0, 84, 83, 82, 72, 0, + 75, 76, 70, 65, 0, 63, 62, 69, + 0, 1, 2, 3, 4, 5, 6, 7, + 8, 9, 64, 0, 73, 66, 74, 71, + 81, 36, 37, 38, 39, 40, 41, 42, + 43, 44, 45, 46, 47, 48, 49, 50, + 51, 52, 53, 54, 55, 56, 57, 58, + 59, 60, 61, 77, 0, 78, 67, 0, + 0, 10, 11, 12, 13, 14, 15, 16, + 17, 18, 19, 20, 21, 22, 23, 24, + 25, 26, 27, 28, 29, 30, 31, 32, + 33, 34, 35, 79, 0, 80, 0, 0 + ]; + + private static readonly bool[] ValidDecodingTable = BuildValidDecodingTable(); + + /// + /// Encodes a byte array into a Z85 string with padding. + /// + /// The input byte array to encode + /// The Z85 encoded string + /// Thrown when the input length is not a multiple of 4 + public static string GetStringWithPadding(ReadOnlySpan data) + { + var lengthMod4 = data.Length % 4; + var paddingRequired = lengthMod4 != 0; + var bytesToEncode = data; + var bytesToPad = 0; + if (paddingRequired) + { + bytesToPad = 4 - lengthMod4; + var paddedBytes = new byte[data.Length + bytesToPad]; + data.CopyTo(paddedBytes); + bytesToEncode = paddedBytes; + } + + var z85String = GetString(bytesToEncode); + if (paddingRequired) z85String += bytesToPad; + + return z85String; + } + + /// + /// Encodes a byte array into a Z85 string. The input length must be a multiple of 4. + /// + /// The input byte array to encode + /// The Z85 encoded string + /// Thrown when the input length is not a multiple of 4 + public static string GetString(ReadOnlySpan data) + { + if (data.Length % 4 != 0) throw new ArgumentException("Input length must be a multiple of 4.", nameof(data)); + + var stringBuilder = new StringBuilder(data.Length / 4 * 5); + var encodedChars = new char[5]; + + for (var i = 0; i < data.Length; i += 4) + { + var binaryFrame = (uint)((data[i + 0] << 24) | + (data[i + 1] << 16) | + (data[i + 2] << 8) | + data[i + 3]); + + var divisor = (uint)(Base85 * Base85 * Base85 * Base85); + for (var j = 0; j < 5; j++) + { + var divisible = binaryFrame / divisor % 85; + encodedChars[j] = EncodingTable[divisible]; + binaryFrame -= divisible * divisor; + divisor /= Base85; + } + + stringBuilder.Append(encodedChars); + } + + return stringBuilder.ToString(); + } + + /// + /// Decodes a Z85 string into a byte array. The input length must be a multiple of 5 (+ 1 with padding). + /// + /// The input Z85 string to decode + /// The decoded byte array + /// Thrown when the input length is not a multiple of 5 (+ 1 with padding). + public static byte[] GetBytesWithPadding(string data) + { + var lengthMod5 = data.Length % 5; + if (lengthMod5 != 0 && (data.Length - 1) % 5 != 0) + throw new ArgumentException("Input length must be a multiple of 5 with either padding or no padding.", + nameof(data)); + + var paddedBytes = 0; + if (lengthMod5 != 0) + { + if (!int.TryParse(data[^1].ToString(), out paddedBytes) + || paddedBytes < 1 + || paddedBytes > 3) + throw new ArgumentException("Invalid padding character for a Z85 string."); + + data = data.Remove(data.Length - 1); + } + + var output = GetBytes(data); + //Remove padded bytes + if (paddedBytes > 0) + Array.Resize(ref output, output.Length - paddedBytes); + return output; + } + + /// + /// Decodes a Z85 string into a byte array. The input length must be a multiple of 5. + /// + /// The input Z85 string to decode + /// The decoded byte array + /// Thrown when the input length is not a multiple of 5 + public static byte[] GetBytes(string data) + { + if (data.Length % 5 != 0) throw new ArgumentException("Input length must be a multiple of 5", nameof(data)); + + var output = new byte[data.Length / 5 * 4]; + var outputIndex = 0; + for (var i = 0; i < data.Length; i += 5) + { + uint value = 0; + value = value * Base85 + Decode(data[i]); + value = value * Base85 + Decode(data[i + 1]); + value = value * Base85 + Decode(data[i + 2]); + value = value * Base85 + Decode(data[i + 3]); + value = value * Base85 + Decode(data[i + 4]); + + output[outputIndex] = (byte)(value >> 24); + output[outputIndex + 1] = (byte)(value >> 16); + output[outputIndex + 2] = (byte)(value >> 8); + output[outputIndex + 3] = (byte)value; + outputIndex += 4; + } + + return output; + } + + private static uint Decode(char value) + { + var index = value - 32; + if ((uint)index >= DecodingTable.Length || !ValidDecodingTable[index]) + throw new ArgumentException($"Invalid Z85 character '{value}'.", nameof(value)); + + return DecodingTable[index]; + } + + private static bool[] BuildValidDecodingTable() + { + var table = new bool[DecodingTable.Length]; + foreach (var value in EncodingTable) + table[value - 32] = true; + return table; + } + } +} diff --git a/VoiceCraft.Server/App.cs b/VoiceCraft.Server/App.cs index c4e27387..ee5805a5 100644 --- a/VoiceCraft.Server/App.cs +++ b/VoiceCraft.Server/App.cs @@ -35,6 +35,7 @@ public static async Task Start(RuntimeOptions runtimeOptions) var rootCommand = Program.ServiceProvider.GetRequiredService(); //Other var properties = Program.ServiceProvider.GetRequiredService(); + var telemetry = Program.ServiceProvider.GetRequiredService(); try { @@ -45,6 +46,9 @@ public static async Task Start(RuntimeOptions runtimeOptions) //Properties properties.Load(runtimeOptions.ExitOnInvalidProperties); properties.ApplyRuntimeOverrides(runtimeOptions); + AnsiConsole.MarkupLine(properties.TelemetryEnabled + ? "[aqua]Telemetry is enabled. VoiceCraft sends anonymous startup, heartbeat, and crash diagnostics. Set \"TelemetryEnabled\": false in config/ServerProperties.json to disable it.[/]" + : "[aqua]Telemetry is disabled in config/ServerProperties.json.[/]"); //Set locale if not overriden. if (!languageOverriden) Localizer.Instance.Language = properties.VoiceCraftConfig.Language; @@ -110,9 +114,16 @@ public static async Task Start(RuntimeOptions runtimeOptions) AnsiConsole.MarkupLine($"[bold green]{Localizer.Get("Startup.Success")}[/]"); AnsiConsole.MarkupLine("\0\0\0"); //This is here for docker images to detect server is running. Console.Title = $"VoiceCraft - {VoiceCraftServer.Version}: {Localizer.Get("Title.Running")}"; + await telemetry.ReportStartupAsync(CreateTelemetrySnapshot( + liteNetServer, + httpMcApiServer, + tcpMcApiServer, + mcWssMcApiServer)); StartCommandTask(); var startTime = DateTime.UtcNow; + var lastTelemetryAt = DateTime.UtcNow; + Task? telemetryReportTask = null; while (!Cts.IsCancellationRequested) try { @@ -123,6 +134,19 @@ public static async Task Start(RuntimeOptions runtimeOptions) visibilitySystem.Update(); eventHandlerSystem.Update(); await FlushCommand(rootCommand); + if (properties.TelemetryEnabled && + DateTime.UtcNow - lastTelemetryAt >= ServerTelemetryService.HeartbeatInterval) + { + if (telemetryReportTask == null || telemetryReportTask.IsCompleted) + { + lastTelemetryAt = DateTime.UtcNow; + telemetryReportTask = telemetry.ReportHeartbeatAsync(CreateTelemetrySnapshot( + liteNetServer, + httpMcApiServer, + tcpMcApiServer, + mcWssMcApiServer)); + } + } var dist = DateTime.UtcNow - startTime; var delay = Constants.TickRate - dist.TotalMilliseconds; @@ -179,7 +203,7 @@ private static void StartServer(LiteNetVoiceCraftServer server) throw new Exception(Localizer.Get("VoiceCraftServer.Exceptions.Failed")); } } - + private static void StartServer(McWssMcApiServer server) { if (!server.Config.Enabled) return; @@ -307,4 +331,23 @@ private static void StartCommandTask() } }); } -} + + private static ServerTelemetrySnapshot CreateTelemetrySnapshot( + LiteNetVoiceCraftServer liteNetServer, + HttpMcApiServer httpMcApiServer, + TcpMcApiServer tcpMcApiServer, + McWssMcApiServer mcWssMcApiServer) + { + return new ServerTelemetrySnapshot + { + Version = VoiceCraftServer.Version.ToString(), + Language = Localizer.Instance.Language, + PositioningType = liteNetServer.Config.PositioningType.ToString(), + EnableVisibilityDisplay = liteNetServer.Config.EnableVisibilityDisplay, + McHttpEnabled = httpMcApiServer.Config.Enabled, + McTcpEnabled = tcpMcApiServer.Config.Enabled, + McWssEnabled = mcWssMcApiServer.Config.Enabled, + ConnectedClients = liteNetServer.ConnectedPeers + }; + } +} \ No newline at end of file diff --git a/VoiceCraft.Server/Locales/EmbededJsonLocaliser.cs b/VoiceCraft.Server/Locales/EmbededJsonLocaliser.cs index 2fa95fca..b28b3ec0 100644 --- a/VoiceCraft.Server/Locales/EmbededJsonLocaliser.cs +++ b/VoiceCraft.Server/Locales/EmbededJsonLocaliser.cs @@ -68,7 +68,7 @@ public string Get(string key) } } - public string GetTranslation(string key) + private string GetTranslation(string key) { if (_languageStrings is null) return key; diff --git a/VoiceCraft.Server/LogService.cs b/VoiceCraft.Server/LogService.cs index 08b5fb63..0bdfcb6c 100644 --- a/VoiceCraft.Server/LogService.cs +++ b/VoiceCraft.Server/LogService.cs @@ -1,7 +1,9 @@ using System.Collections.Concurrent; using System.Text.Json; using System.Text.Json.Serialization; +using Microsoft.Extensions.DependencyInjection; using VoiceCraft.Core; +using VoiceCraft.Core.Diagnostics; namespace VoiceCraft.Server; @@ -17,7 +19,7 @@ public static class LogService public static IEnumerable> ExceptionLogs => _exceptionLogs.ExceptionLogs.OrderByDescending(d => d.Key); - public static IEnumerable> CrashLogs => + public static IEnumerable> CrashLogs => _exceptionLogs.CrashLogs.OrderByDescending(d => d.Key); public static void Log(Exception exception) @@ -30,9 +32,14 @@ public static void Log(Exception exception) public static void LogCrash(Exception exception) { Console.WriteLine(exception); - _exceptionLogs.CrashLogs.TryAdd(DateTime.UtcNow, exception.ToString()); + var timestamp = DateTime.UtcNow; + _exceptionLogs.CrashLogs.TryAdd(timestamp, new CrashLogRecord + { + Message = exception.ToString() + }); TrimCrashLogs(); SaveLogs(); + _ = AttachDumpUrlAsync(timestamp, exception); } public static void Load() @@ -44,11 +51,7 @@ public static void Load() return; var result = File.ReadAllText(fileDirectory); - var loadedLogs = - JsonSerializer.Deserialize(result, - CrashLogGenerationContext.Default.ExceptionLogsStructure); - if (loadedLogs == null) return; - _exceptionLogs = loadedLogs; + if (!TryLoadCurrent(result) && !TryLoadLegacy(result)) return; TrimExceptionLogs(); TrimCrashLogs(); } @@ -74,7 +77,7 @@ private static void TrimExceptionLogs() { foreach (var log in _exceptionLogs.ExceptionLogs.OrderBy(d => d.Key)) { - if (_exceptionLogs.CrashLogs.Count <= Limit) return; + if (_exceptionLogs.ExceptionLogs.Count <= Limit) return; _exceptionLogs.ExceptionLogs.TryRemove(log.Key, out _); } } @@ -88,6 +91,55 @@ private static void TrimCrashLogs() } } + private static bool TryLoadCurrent(string result) + { + var loadedLogs = JsonSerializer.Deserialize( + result, + CrashLogGenerationContext.Default.ExceptionLogsStructure); + if (loadedLogs == null) + return false; + + _exceptionLogs = loadedLogs; + return true; + } + + private static bool TryLoadLegacy(string result) + { + var loadedLogs = JsonSerializer.Deserialize( + result, + CrashLogGenerationContext.Default.LegacyExceptionLogsStructure); + if (loadedLogs == null) + return false; + + _exceptionLogs = new ExceptionLogsStructure + { + ExceptionLogs = loadedLogs.ExceptionLogs, + CrashLogs = new ConcurrentDictionary( + loadedLogs.CrashLogs.ToDictionary( + x => x.Key, + x => new CrashLogRecord { Message = x.Value })) + }; + return true; + } + + private static async Task AttachDumpUrlAsync(DateTime timestamp, Exception exception) + { + var telemetry = Program.ServiceProvider.GetService(); + if (telemetry == null) + return; + + var dumpResponse = await telemetry.ReportCrashAsync(exception); + var dumpUrl = dumpResponse?.ViewUrl ?? dumpResponse?.Url; + if (string.IsNullOrWhiteSpace(dumpUrl)) + return; + + if (!_exceptionLogs.CrashLogs.TryGetValue(timestamp, out var crashLog)) + return; + + crashLog.DumpUrl = dumpUrl; + SaveLogs(); + } + private static async Task SaveAsync() { _queueWrite = true; @@ -117,6 +169,12 @@ private static void SaveLogs() } public class ExceptionLogsStructure +{ + public ConcurrentDictionary CrashLogs { get; set; } = new(); + public ConcurrentDictionary ExceptionLogs { get; set; } = new(); +} + +public class LegacyExceptionLogsStructure { public ConcurrentDictionary CrashLogs { get; set; } = new(); public ConcurrentDictionary ExceptionLogs { get; set; } = new(); @@ -124,4 +182,5 @@ public class ExceptionLogsStructure [JsonSourceGenerationOptions(WriteIndented = true)] [JsonSerializable(typeof(ExceptionLogsStructure), GenerationMode = JsonSourceGenerationMode.Metadata)] -public partial class CrashLogGenerationContext : JsonSerializerContext; \ No newline at end of file +[JsonSerializable(typeof(LegacyExceptionLogsStructure), GenerationMode = JsonSourceGenerationMode.Metadata)] +public partial class CrashLogGenerationContext : JsonSerializerContext; diff --git a/VoiceCraft.Server/Program.cs b/VoiceCraft.Server/Program.cs index 526f7b14..67b21a7a 100644 --- a/VoiceCraft.Server/Program.cs +++ b/VoiceCraft.Server/Program.cs @@ -62,6 +62,7 @@ private static ServiceProvider BuildServiceProvider() //Other serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); return serviceCollection.BuildServiceProvider(); } diff --git a/VoiceCraft.Server/ServerProperties.cs b/VoiceCraft.Server/ServerProperties.cs index 11c95247..37756396 100644 --- a/VoiceCraft.Server/ServerProperties.cs +++ b/VoiceCraft.Server/ServerProperties.cs @@ -20,6 +20,8 @@ public class ServerProperties public McWssMcApiServer.McWssMcApiConfig McWssConfig => _properties.McWssConfig; public HttpMcApiServer.HttpMcApiConfig McHttpConfig => _properties.McHttpConfig; public TcpMcApiServer.McTcpConfig McTcpConfig => _properties.McTcpConfig; + public bool TelemetryEnabled => _properties.TelemetryEnabled; + public string TelemetryToken => _properties.TelemetryToken; public OrderedDictionary DefaultAudioEffects { get; } = []; public void Load(bool throwOnInvalidProperties) @@ -32,7 +34,7 @@ public void Load(bool throwOnInvalidProperties) AnsiConsole.MarkupLine($"[yellow]{Localizer.Get("ServerProperties.NotFound")}[/]"); _properties = CreateConfigFile(); ParseAudioEffects(); - AnsiConsole.MarkupLine($"[green]{Localizer.Get("ServerProperties.Success")}[/]"); + AnsiConsole.MarkupLine($"[green]{Localizer.Get($"ServerProperties.Success")}[/]"); return; } @@ -106,7 +108,7 @@ private static ServerPropertiesStructure CreateConfigFile() File.WriteAllText(filePath, JsonSerializer.Serialize(properties, ServerPropertiesStructureGenerationContext.Default.ServerPropertiesStructure)); - AnsiConsole.MarkupLine($"[green]{Localizer.Get("ServerProperties.Generating.Success")}[/]"); + AnsiConsole.MarkupLine($"[green]{Localizer.Get($"ServerProperties.Generating.Success:{path}")}[/]"); } catch (Exception ex) { @@ -216,6 +218,8 @@ public ServerPropertiesStructure() ProximityMuffleEffectGenerationContext.Default.ProximityMuffleEffect)); } + public bool TelemetryEnabled { get; set; } = true; + public string TelemetryToken { get; set; } = Guid.NewGuid().ToString("N"); public LiteNetVoiceCraftServer.LiteNetVoiceCraftConfig VoiceCraftConfig { get; set; } = new(); public McWssMcApiServer.McWssMcApiConfig McWssConfig { get; set; } = new(); public HttpMcApiServer.HttpMcApiConfig McHttpConfig { get; set; } = new(); @@ -225,12 +229,12 @@ public ServerPropertiesStructure() public class RuntimeOptions { - public bool ExitOnInvalidProperties { get; set; } - public string? Language { get; set; } - public string[] TransportMode { get; set; } = []; - public string? TransportHost { get; set; } - public int? TransportPort { get; set; } - public string? ServerKey { get; set; } + public bool ExitOnInvalidProperties { get; init; } + public string? Language { get; init; } + public string[] TransportMode { get; init; } = []; + public string? TransportHost { get; init; } + public int? TransportPort { get; init; } + public string? ServerKey { get; init; } } [JsonSourceGenerationOptions(WriteIndented = true)] diff --git a/VoiceCraft.Server/ServerTelemetryService.cs b/VoiceCraft.Server/ServerTelemetryService.cs new file mode 100644 index 00000000..1f57be2e --- /dev/null +++ b/VoiceCraft.Server/ServerTelemetryService.cs @@ -0,0 +1,206 @@ +using System.Diagnostics; +using System.Globalization; +using System.Reflection; +using System.Runtime.InteropServices; +using VoiceCraft.Core; +using VoiceCraft.Core.Telemetry; + +namespace VoiceCraft.Server; + +public sealed class ServerTelemetryService(ServerProperties properties) +{ + private readonly Stopwatch _uptime = Stopwatch.StartNew(); + private readonly TelemetryTransport _transport = new(); + public static TimeSpan HeartbeatInterval { get; } = TimeSpan.FromMinutes(1); + private bool IsEnabled => properties.TelemetryEnabled; + private string TelemetryToken => properties.TelemetryToken; + + public Task ReportStartupAsync(ServerTelemetrySnapshot snapshot) + { + return ReportTelemetryAsync(snapshot, "startup"); + } + + public Task ReportHeartbeatAsync(ServerTelemetrySnapshot snapshot) + { + return ReportTelemetryAsync(snapshot, "heartbeat"); + } + + public async Task ReportCrashAsync(Exception exception) + { + if (!IsEnabled) + return null; + + var payload = new TelemetryDumpRequest + { + Role = "server", + Category = "crash", + Title = exception.GetType().Name, + App = BuildAppInfo(), + Device = BuildDeviceInfo(), + Server = BuildServerInfo(), + Payload = new Dictionary + { + ["crash_log"] = exception.ToString(), + ["uptime_sec"] = ((long)_uptime.Elapsed.TotalSeconds).ToString(CultureInfo.InvariantCulture) + } + }; + + try + { + return await _transport.SendDumpAsync(payload); + } + catch (Exception ex) + { + LogService.Log(ex); + return null; + } + } + + private async Task ReportTelemetryAsync(ServerTelemetrySnapshot snapshot, string tag) + { + if (!IsEnabled) + return; + + var payload = new TelemetryEventRequest + { + Fingerprint = GetFingerprint(), + Role = "server", + App = BuildAppInfo(snapshot.Version), + Device = BuildDeviceInfo(), + Server = BuildServerInfo(snapshot), + Metrics = new Dictionary + { + ["positioning_type"] = snapshot.PositioningType, + ["mc_http_enabled"] = snapshot.McHttpEnabled.ToString(), + ["mc_tcp_enabled"] = snapshot.McTcpEnabled.ToString(), + ["mc_wss_enabled"] = snapshot.McWssEnabled.ToString() + }, + Tags = [tag], + Timestamp = DateTime.UtcNow.ToString("O") + }; + + try + { + await _transport.SendTelemetryAsync(payload); + } + catch (Exception ex) + { + LogService.Log(ex); + } + } + + private string GetFingerprint() + { + return string.IsNullOrWhiteSpace(TelemetryToken) + ? Guid.NewGuid().ToString("N") + : TelemetryToken; + } + + private static TelemetryAppInfo BuildAppInfo(string? version = null) + { + return new TelemetryAppInfo + { + AppName = "VoiceCraft", + Version = version ?? ResolveVersion(), + Channel = ResolveChannel(), + Build = ResolveBuild() + }; + } + + private static TelemetryDeviceInfo BuildDeviceInfo() + { + var totalAvailableBytes = GC.GetGCMemoryInfo().TotalAvailableMemoryBytes; + long? memoryMb = totalAvailableBytes > 0 ? totalAvailableBytes / (1024 * 1024) : null; + + return new TelemetryDeviceInfo + { + OsName = GetPlatformName(), + OsVersion = Environment.OSVersion.VersionString, + OsBuild = Environment.OSVersion.Version.ToString(), + OsDescription = RuntimeInformation.OSDescription, + Architecture = RuntimeInformation.OSArchitecture.ToString().ToLowerInvariant(), + ProcessArchitecture = RuntimeInformation.ProcessArchitecture.ToString().ToLowerInvariant(), + Runtime = RuntimeInformation.FrameworkDescription, + Locale = CultureInfo.CurrentUICulture.Name, + CpuCores = Environment.ProcessorCount, + MemoryMb = memoryMb + }; + } + + private TelemetryServerInfo BuildServerInfo(ServerTelemetrySnapshot? snapshot) + { + var totalAvailableBytes = GC.GetGCMemoryInfo().TotalAvailableMemoryBytes; + long? memoryMb = totalAvailableBytes > 0 ? totalAvailableBytes / (1024 * 1024) : null; + + return new TelemetryServerInfo + { + Platform = GetPlatformName(), + Architecture = RuntimeInformation.OSArchitecture.ToString().ToLowerInvariant(), + Locale = snapshot?.Language, + CpuCores = Environment.ProcessorCount, + MemoryMb = memoryMb, + UptimeSec = (long)_uptime.Elapsed.TotalSeconds, + ConnectedClients = snapshot?.ConnectedClients + }; + } + + private TelemetryServerInfo BuildServerInfo() + { + var totalAvailableBytes = GC.GetGCMemoryInfo().TotalAvailableMemoryBytes; + + return new TelemetryServerInfo + { + Platform = GetPlatformName(), + Architecture = RuntimeInformation.OSArchitecture.ToString().ToLowerInvariant(), + CpuCores = Environment.ProcessorCount, + MemoryMb = totalAvailableBytes > 0 ? totalAvailableBytes / (1024 * 1024) : null, + UptimeSec = (long)_uptime.Elapsed.TotalSeconds + }; + } + + private static string ResolveVersion() + { + return $"{Constants.Major}.{Constants.Minor}.{Constants.Patch}"; + } + + private static string ResolveBuild() + { + var informationalVersion = Assembly.GetEntryAssembly()? + .GetCustomAttribute()? + .InformationalVersion; + + return informationalVersion ?? string.Empty; + } + + private static string ResolveChannel() + { +#if DEBUG + return "debug"; +#else + return "stable"; +#endif + } + + private static string GetPlatformName() + { + if (OperatingSystem.IsWindows()) + return "Windows"; + if (OperatingSystem.IsLinux()) + return "Linux"; + return OperatingSystem.IsMacOS() + ? "macOS" + : RuntimeInformation.OSDescription; + } +} + +public sealed class ServerTelemetrySnapshot +{ + public string Version { get; init; } = string.Empty; + public string Language { get; init; } = Constants.DefaultLanguage; + public string PositioningType { get; init; } = string.Empty; + public bool EnableVisibilityDisplay { get; init; } + public bool McHttpEnabled { get; init; } + public bool McTcpEnabled { get; init; } + public bool McWssEnabled { get; init; } + public int ConnectedClients { get; init; } +} \ No newline at end of file diff --git a/VoiceCraft.Server/Systems/EventHandlerSystem.cs b/VoiceCraft.Server/Systems/EventHandlerSystem.cs index c3105135..5c43b4b7 100644 --- a/VoiceCraft.Server/Systems/EventHandlerSystem.cs +++ b/VoiceCraft.Server/Systems/EventHandlerSystem.cs @@ -73,21 +73,16 @@ public void Update() } } - private void BroadcastMcApi(Func packetFactory, Action configure) where T : class, IMcApiPacket + private void BroadcastMcApi(T packet) where T : class, IMcApiPacket { foreach (var mcApiServer in _mcApiServers) { - var packet = PacketPool.GetPacket(packetFactory); - configure(packet); mcApiServer.Broadcast(packet); } } - private void SendMcApi(McApiServer server, McApiNetPeer peer, Func packetFactory, Action configure) - where T : class, IMcApiPacket + private static void SendMcApi(McApiServer server, McApiNetPeer peer, T packet) where T : class, IMcApiPacket { - var packet = PacketPool.GetPacket(packetFactory); - configure(packet); server.SendPacket(peer, packet); } @@ -99,7 +94,8 @@ private void OnAudioEffectSet(ushort bitmask, IAudioEffect? effect) { _liteNetServer.Broadcast(PacketPool.GetPacket(() => new VcOnEffectUpdatedPacket()) .Set(bitmask, effect)); - BroadcastMcApi(() => new McApiOnEffectUpdatedPacket(), packet => packet.Set(bitmask, effect)); + BroadcastMcApi(PacketPool.GetPacket(() => + new McApiOnEffectUpdatedPacket()).Set(bitmask, effect)); }); } @@ -158,10 +154,12 @@ private void OnEntityCreated(VoiceCraftEntity newEntity) VcDeliveryMethod.Reliable, networkEntity.NetPeer); } - BroadcastMcApi(() => new McApiOnNetworkEntityCreatedPacket(), packet => packet.Set(networkEntity)); + BroadcastMcApi(PacketPool + .GetPacket(() => new McApiOnNetworkEntityCreatedPacket()).Set(networkEntity)); //Send Effects - foreach (var effect in _audioEffectSystem.AudioEffects) + var audioEffects = _audioEffectSystem.AudioEffects; + foreach (var effect in audioEffects) _liteNetServer.SendPacket(networkEntity.NetPeer, PacketPool.GetPacket(() => new VcOnEffectUpdatedPacket()) .Set(effect.Key, effect.Value)); @@ -191,7 +189,8 @@ private void OnEntityCreated(VoiceCraftEntity newEntity) { _liteNetServer.Broadcast(PacketPool .GetPacket(() => new VcOnEntityCreatedPacket()).Set(newEntity)); - BroadcastMcApi(() => new McApiOnEntityCreatedPacket(), packet => packet.Set(newEntity)); + BroadcastMcApi(PacketPool.GetPacket(() => new McApiOnEntityCreatedPacket()) + .Set(newEntity)); } }); } @@ -233,7 +232,8 @@ private void OnEntityDestroyed(VoiceCraftEntity entity) } _liteNetServer.Broadcast(entityDestroyedPacket); - BroadcastMcApi(() => new McApiOnEntityDestroyedPacket(), packet => packet.Set(entity.Id)); + BroadcastMcApi(PacketPool.GetPacket(() => new McApiOnEntityDestroyedPacket()) + .Set(entity.Id)); }); } @@ -244,17 +244,19 @@ private void OnMcApiPeerConnected(McApiNetPeer peer, string token) if (peer.Tag is not McApiServer server) return; //Send Effects - foreach (var effect in _audioEffectSystem.AudioEffects) - SendMcApi(server, peer, () => new McApiOnEffectUpdatedPacket(), - packet => packet.Set(effect.Key, effect.Value)); + var audioEffects = _audioEffectSystem.AudioEffects; + foreach (var effect in audioEffects) + SendMcApi(server, peer, PacketPool.GetPacket(() => + new McApiOnEffectUpdatedPacket()).Set(effect.Key, effect.Value)); //Send other entities. foreach (var entity in _world.Entities) if (entity is VoiceCraftNetworkEntity otherNetworkEntity) - SendMcApi(server, peer, () => new McApiOnNetworkEntityCreatedPacket(), - packet => packet.Set(otherNetworkEntity)); + SendMcApi(server, peer, PacketPool + .GetPacket(() => new McApiOnNetworkEntityCreatedPacket()).Set(otherNetworkEntity)); else - SendMcApi(server, peer, () => new McApiOnEntityCreatedPacket(), packet => packet.Set(entity)); + SendMcApi(server, peer, PacketPool.GetPacket(() => + new McApiOnEntityCreatedPacket()).Set(entity)); AnsiConsole.MarkupLine($"[green]{Localizer.Get($"Events.McApi.Client.Connected:{token}")}[/]"); }); @@ -299,7 +301,8 @@ private void OnNetworkEntityServerMuteUpdated(bool muted, VoiceCraftNetworkEntit PacketPool.GetPacket(() => new VcOnEntityServerMuteUpdatedPacket()) .Set(entity.Id, muted), VcDeliveryMethod.Reliable, entity.NetPeer); - BroadcastMcApi(() => new McApiOnEntityServerMuteUpdatedPacket(), packet => packet.Set(entity.Id, muted)); + BroadcastMcApi(PacketPool.GetPacket(() => + new McApiOnEntityServerMuteUpdatedPacket()).Set(entity.Id, muted)); }); } @@ -314,8 +317,8 @@ private void OnNetworkEntityServerDeafenUpdated(bool deafened, VoiceCraftNetwork PacketPool .GetPacket(() => new VcOnEntityServerDeafenUpdatedPacket()).Set(entity.Id, deafened), VcDeliveryMethod.Reliable, entity.NetPeer); - BroadcastMcApi(() => new McApiOnEntityServerDeafenUpdatedPacket(), - packet => packet.Set(entity.Id, deafened)); + BroadcastMcApi(PacketPool.GetPacket(() => + new McApiOnEntityServerDeafenUpdatedPacket()).Set(entity.Id, deafened)); }); } @@ -323,7 +326,8 @@ private void OnEntityWorldIdUpdated(string worldId, VoiceCraftEntity entity) { _tasks.Enqueue(() => { - BroadcastMcApi(() => new McApiOnEntityWorldIdUpdatedPacket(), packet => packet.Set(entity.Id, worldId)); + BroadcastMcApi(PacketPool.GetPacket(() => + new McApiOnEntityWorldIdUpdatedPacket()).Set(entity.Id, worldId)); }); } @@ -346,7 +350,8 @@ private void OnEntityNameUpdated(string name, VoiceCraftEntity entity) _liteNetServer.Broadcast(packet); } - BroadcastMcApi(() => new McApiOnEntityNameUpdatedPacket(), _ => packet.Set(entity.Id, name)); + BroadcastMcApi(PacketPool + .GetPacket(() => new McApiOnEntityNameUpdatedPacket()).Set(entity.Id, name)); }); } @@ -361,7 +366,8 @@ private void OnEntityMuteUpdated(bool mute, VoiceCraftEntity entity) else _liteNetServer.Broadcast(packet); - BroadcastMcApi(() => new McApiOnEntityMuteUpdatedPacket(), _ => packet.Set(entity.Id, mute)); + BroadcastMcApi(PacketPool.GetPacket(() => + new McApiOnEntityMuteUpdatedPacket()).Set(entity.Id, mute)); }); } @@ -376,7 +382,8 @@ private void OnEntityDeafenUpdated(bool deafen, VoiceCraftEntity entity) else _liteNetServer.Broadcast(packet); - BroadcastMcApi(() => new McApiOnEntityDeafenUpdatedPacket(), _ => packet.Set(entity.Id, deafen)); + BroadcastMcApi(PacketPool.GetPacket(() => + new McApiOnEntityDeafenUpdatedPacket()).Set(entity.Id, deafen)); }); } @@ -398,7 +405,8 @@ private void OnEntityTalkBitmaskUpdated(ushort bitmask, VoiceCraftEntity entity) _liteNetServer.Broadcast(packet); } - BroadcastMcApi(() => new McApiOnEntityTalkBitmaskUpdatedPacket(), _ => packet.Set(entity.Id, bitmask)); + BroadcastMcApi(PacketPool.GetPacket(() => + new McApiOnEntityTalkBitmaskUpdatedPacket()).Set(entity.Id, bitmask)); }); } @@ -420,7 +428,9 @@ private void OnEntityListenBitmaskUpdated(ushort bitmask, VoiceCraftEntity entit _liteNetServer.Broadcast(packet); } - BroadcastMcApi(() => new McApiOnEntityListenBitmaskUpdatedPacket(), _ => packet.Set(entity.Id, bitmask)); + BroadcastMcApi( + PacketPool.GetPacket(() => + new McApiOnEntityListenBitmaskUpdatedPacket()).Set(entity.Id, bitmask)); }); } @@ -442,7 +452,8 @@ private void OnEntityEffectBitmaskUpdated(ushort bitmask, VoiceCraftEntity entit _liteNetServer.Broadcast(packet); } - BroadcastMcApi(() => new McApiOnEntityEffectBitmaskUpdatedPacket(), _ => packet.Set(entity.Id, bitmask)); + BroadcastMcApi(PacketPool.GetPacket(() => + new McApiOnEntityEffectBitmaskUpdatedPacket()).Set(entity.Id, bitmask)); }); } @@ -464,7 +475,8 @@ private void OnEntityPositionUpdated(Vector3 position, VoiceCraftEntity entity) _liteNetServer.SendPacket(visibleEntity.NetPeer, packet); } - BroadcastMcApi(() => new McApiOnEntityPositionUpdatedPacket(), packet => packet.Set(entity.Id, position)); + BroadcastMcApi(PacketPool.GetPacket(() => + new McApiOnEntityPositionUpdatedPacket()).Set(entity.Id, position)); }); } @@ -485,7 +497,8 @@ private void OnEntityRotationUpdated(Vector2 rotation, VoiceCraftEntity entity) _liteNetServer.SendPacket(visibleEntity.NetPeer, packet); } - BroadcastMcApi(() => new McApiOnEntityRotationUpdatedPacket(), packet => packet.Set(entity.Id, rotation)); + BroadcastMcApi(PacketPool.GetPacket(() => + new McApiOnEntityRotationUpdatedPacket()).Set(entity.Id, rotation)); }); } @@ -505,8 +518,8 @@ private void OnEntityCaveFactorUpdated(float caveFactor, VoiceCraftEntity entity _liteNetServer.SendPacket(visibleEntity.NetPeer, packet); } - BroadcastMcApi(() => new McApiOnEntityCaveFactorUpdatedPacket(), - packet => packet.Set(entity.Id, caveFactor)); + BroadcastMcApi(PacketPool.GetPacket(() => + new McApiOnEntityCaveFactorUpdatedPacket()).Set(entity.Id, caveFactor)); }); } @@ -527,8 +540,8 @@ private void OnEntityMuffleFactorUpdated(float muffleFactor, VoiceCraftEntity en _liteNetServer.SendPacket(visibleEntity.NetPeer, packet); } - BroadcastMcApi(() => new McApiOnEntityMuffleFactorUpdatedPacket(), - packet => packet.Set(entity.Id, muffleFactor)); + BroadcastMcApi(PacketPool.GetPacket(() => + new McApiOnEntityMuffleFactorUpdatedPacket()).Set(entity.Id, muffleFactor)); }); } @@ -566,8 +579,8 @@ private void OnEntityVisibleEntityAdded(VoiceCraftEntity addedEntity, VoiceCraft _liteNetServer.SendPacket(networkEntity.NetPeer, muffleFactorPacket); } - BroadcastMcApi(() => new McApiOnEntityVisibilityUpdatedPacket(), - packet => packet.Set(entity.Id, addedEntity.Id, true)); + BroadcastMcApi(PacketPool.GetPacket(() => + new McApiOnEntityVisibilityUpdatedPacket()).Set(entity.Id, addedEntity.Id, true)); }); } @@ -585,8 +598,8 @@ private void OnEntityVisibleEntityRemoved(VoiceCraftEntity removedEntity, VoiceC } } - BroadcastMcApi(() => new McApiOnEntityVisibilityUpdatedPacket(), - packet => packet.Set(entity.Id, removedEntity.Id)); + BroadcastMcApi(PacketPool.GetPacket(() => + new McApiOnEntityVisibilityUpdatedPacket()).Set(entity.Id, removedEntity.Id)); }); } @@ -604,10 +617,10 @@ private void OnEntityAudioReceived(byte[] buffer, ushort timestamp, float frameL _liteNetServer.SendPacket(visibleEntity.NetPeer, packet, VcDeliveryMethod.Unreliable); } - BroadcastMcApi(() => new McApiOnEntityAudioReceivedPacket(), - packet => packet.Set(entity.Id, timestamp, frameLoudness)); + BroadcastMcApi(PacketPool.GetPacket(() => + new McApiOnEntityAudioReceivedPacket()).Set(entity.Id, timestamp, frameLoudness)); }); } #endregion -} +} \ No newline at end of file diff --git a/VoiceCraft.Server/VoiceCraft.Server.csproj b/VoiceCraft.Server/VoiceCraft.Server.csproj index ab8f2317..1181154d 100644 --- a/VoiceCraft.Server/VoiceCraft.Server.csproj +++ b/VoiceCraft.Server/VoiceCraft.Server.csproj @@ -1,7 +1,7 @@ - + Exe - net9.0 + net10.0 enable enable link diff --git a/global.json b/global.json index 9a247ed2..06316314 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "9.0.0", + "version": "10.0.201", "rollForward": "latestMinor" } -} \ No newline at end of file +}