diff --git a/01.VRCLS.Core/Interfaces/IModelPathResolver.cs b/01.VRCLS.Core/Interfaces/IModelPathResolver.cs index 3c7cc7b..a342494 100644 --- a/01.VRCLS.Core/Interfaces/IModelPathResolver.cs +++ b/01.VRCLS.Core/Interfaces/IModelPathResolver.cs @@ -16,5 +16,10 @@ public interface IModelPathResolver /// Resolve the Sherpa model directory for local recognition, if available. /// string? ResolveSherpaModelDir(); + + /// + /// Resolve a complete SenseVoice model directory containing model.onnx and tokens.txt. + /// + string? ResolveSenseVoiceModelDir(); } } diff --git a/01.VRCLS.Core/Models/ServiceErrorCode.cs b/01.VRCLS.Core/Models/ServiceErrorCode.cs index 29bebd8..7eb1cd9 100644 --- a/01.VRCLS.Core/Models/ServiceErrorCode.cs +++ b/01.VRCLS.Core/Models/ServiceErrorCode.cs @@ -9,6 +9,7 @@ public enum ServiceErrorCode AudioProcessingRepeatedFailures, RecognitionAuthRequired, RecognitionAuthExpired, + RecognitionFailed, TtsAuthRequired, TtsRateLimit, TtsAuthExpired, diff --git a/02.VRCLS.Services/Audio/AudioPipelineFactory.cs b/02.VRCLS.Services/Audio/AudioPipelineFactory.cs index 42de71b..f2efc43 100644 --- a/02.VRCLS.Services/Audio/AudioPipelineFactory.cs +++ b/02.VRCLS.Services/Audio/AudioPipelineFactory.cs @@ -52,6 +52,9 @@ public AudioPipelineService CreatePipeline(string source, bool isLoopback) { _logger.Information("Creating audio pipeline. Source: {Source}, Loopback: {IsLoopback}", source, isLoopback); + AudioCaptureService? capture = null; + IVadService? vad = null; + IRecognitionService? recognition = null; try { var useVad = !IsMicStreamingLocalMode(source); @@ -77,9 +80,8 @@ public AudioPipelineService CreatePipeline(string source, bool isLoopback) } _logger.Debug("Creating audio capture service."); - var capture = new AudioCaptureService(); + capture = new AudioCaptureService(); - IVadService? vad = null; if (useVad) { _logger.Debug("Creating VAD service."); @@ -104,7 +106,7 @@ public AudioPipelineService CreatePipeline(string source, bool isLoopback) } _logger.Debug("Creating recognition service."); - var recognition = CreateRecognitionService(source, useVad); + recognition = CreateRecognitionService(source, useVad); _logger.Debug("Creating audio pipeline service."); var pipeline = new AudioPipelineService( @@ -122,30 +124,36 @@ public AudioPipelineService CreatePipeline(string source, bool isLoopback) catch (Exception ex) { _logger.Error(ex, "Failed to create audio pipeline. Source: {Source}", source); + DisposeFailedPipelineComponent(recognition, "recognition"); + DisposeFailedPipelineComponent(vad, "VAD"); + DisposeFailedPipelineComponent(capture, "capture"); throw; } } - private bool IsMicStreamingLocalMode(string source) + private static void DisposeFailedPipelineComponent(object? component, string componentName) { - if (!string.Equals(source, "Mic", StringComparison.OrdinalIgnoreCase)) + if (component is not IDisposable disposable) { - return false; + return; } - if (_config.RecognitionBackendModeValue != RecognitionBackendMode.Local) + try { - return false; + disposable.Dispose(); } - - if (_config.LocalRecognitionModelValue == LocalRecognitionModel.Sherpa) + catch (Exception ex) { - return true; + _logger.Warning(ex, "Failed to dispose {Component} after pipeline creation failure.", componentName); } + } - // If a Sherpa streaming model exists, treat it as streaming even when the model type is not set. - var sherpaDir = _modelPathResolver.ResolveSherpaModelDir(); - return !string.IsNullOrWhiteSpace(sherpaDir); + private bool IsMicStreamingLocalMode(string source) + { + return RecognitionRouteSelector.Select( + source, + _config.RecognitionBackendModeValue, + _config.LocalRecognitionModelValue) == RecognitionRoute.LocalSherpa; } private IRecognitionService CreateRecognitionService(string source, bool useVad) @@ -153,30 +161,46 @@ private IRecognitionService CreateRecognitionService(string source, bool useVad) var mode = _config.RecognitionBackendModeValue; _logger.Debug("Recognition service mode: {Mode}", mode); - if (string.Equals(source, "Desktop", StringComparison.OrdinalIgnoreCase)) + var route = RecognitionRouteSelector.Select( + source, + mode, + _config.LocalRecognitionModelValue); + + if (route == RecognitionRoute.Remote) { - _logger.Information("Desktop source uses remote recognition service only."); + _logger.Information("Using remote FastAPI recognition service. Source: {Source}", source); return new FastApiRecognitionService(_config, source, _backendAuthService, _httpClientFactory); } - if (mode == RecognitionBackendMode.Local) + if (route == RecognitionRoute.LocalSenseVoice) { - _logger.Debug("Attempting to use local recognition service for source: {Source}", source); - - var sherpaDir = _modelPathResolver.ResolveSherpaModelDir(); - if (!string.IsNullOrWhiteSpace(sherpaDir)) + var senseVoiceDir = _modelPathResolver.ResolveSenseVoiceModelDir(); + if (string.IsNullOrWhiteSpace(senseVoiceDir)) { - _logger.Information("Using local Sherpa recognition service. Source: {Source}, ModelDir: {SherpaDir}", source, sherpaDir); - var endpointSilence = Math.Clamp(_config.LocalStreamingEndpointSilenceSeconds, 0.8f, 3.0f); - return new SherpaAsrService(sherpaDir, source, useVad, endpointSilence); + throw new InvalidOperationException( + "Local SenseVoice model is incomplete. Configure a directory containing model.onnx and tokens.txt."); } - _logger.Warning("Sherpa model directory not found; falling back to remote recognition."); + _logger.Information( + "Using local SenseVoice recognition service. Source: {Source}, ModelDir: {SenseVoiceDir}", + source, + senseVoiceDir); + return new SenseVoiceAsrService(senseVoiceDir, source); + } + + var sherpaDir = _modelPathResolver.ResolveSherpaModelDir(); + if (string.IsNullOrWhiteSpace(sherpaDir)) + { + throw new InvalidOperationException( + "Local Sherpa model is incomplete or missing. Check the local model directory."); } - // Default to remote API - _logger.Information("Using remote FastAPI recognition service."); - return new FastApiRecognitionService(_config, source, _backendAuthService, _httpClientFactory); + _logger.Information( + "Using local Sherpa recognition service. Source: {Source}, ModelDir: {SherpaDir}", + source, + sherpaDir); + var endpointSilence = Math.Clamp(_config.LocalStreamingEndpointSilenceSeconds, 0.8f, 3.0f); + return new SherpaAsrService(sherpaDir, source, useVad, endpointSilence); } private void ValidateOfficialVadChecksum(string vadPath) diff --git a/02.VRCLS.Services/Audio/ModelPathResolver.cs b/02.VRCLS.Services/Audio/ModelPathResolver.cs index da343a3..9f3a0dc 100644 --- a/02.VRCLS.Services/Audio/ModelPathResolver.cs +++ b/02.VRCLS.Services/Audio/ModelPathResolver.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; using VRCLS.Core.Interfaces; using VRCLS.Core.Models; using Serilog; @@ -151,5 +152,76 @@ private string ResolveRootDir(string baseDir) _logger.Warning("未找到Sherpa模型目录"); return null; } + + public string? ResolveSenseVoiceModelDir() + { + _logger.Debug("开始解析SenseVoice模型目录"); + + var baseDir = AppDomain.CurrentDomain.BaseDirectory; + var rootDir = ResolveRootDir(baseDir); + var preModelsRoot = GetPreModelsRoot(baseDir, rootDir); + var candidates = new List(); + + if (!string.IsNullOrWhiteSpace(_config.SenseVoiceModelDir)) + { + candidates.Add(_config.SenseVoiceModelDir); + } + + candidates.Add(Path.Combine(preModelsRoot, "sensevoice-small")); + candidates.Add(Path.Combine(baseDir, "models", "sensevoice-small")); + candidates.Add(Path.Combine(rootDir, "models", "sensevoice-small")); + + foreach (var candidate in candidates) + { + if (IsCompleteSenseVoiceModel(candidate)) + { + _logger.Information("找到SenseVoice模型 - 路径: {Path}", candidate); + return candidate; + } + } + + foreach (var searchRoot in new[] + { + preModelsRoot, + Path.Combine(baseDir, "models"), + Path.Combine(rootDir, "models") + }.Distinct(StringComparer.OrdinalIgnoreCase)) + { + if (!Directory.Exists(searchRoot)) + { + continue; + } + + try + { + foreach (var modelPath in Directory.EnumerateFiles( + searchRoot, + "model.onnx", + SearchOption.AllDirectories)) + { + var modelDir = Path.GetDirectoryName(modelPath); + if (IsCompleteSenseVoiceModel(modelDir)) + { + _logger.Information("找到SenseVoice模型 - 路径: {Path}", modelDir); + return modelDir; + } + } + } + catch (Exception ex) + { + _logger.Debug(ex, "搜索SenseVoice模型目录失败: {Path}", searchRoot); + } + } + + _logger.Warning("未找到完整SenseVoice模型目录"); + return null; + } + + private static bool IsCompleteSenseVoiceModel(string? modelDir) + { + return !string.IsNullOrWhiteSpace(modelDir) + && File.Exists(Path.Combine(modelDir, "model.onnx")) + && File.Exists(Path.Combine(modelDir, "tokens.txt")); + } } } diff --git a/02.VRCLS.Services/Audio/RecognitionRouteSelector.cs b/02.VRCLS.Services/Audio/RecognitionRouteSelector.cs new file mode 100644 index 0000000..964a7e5 --- /dev/null +++ b/02.VRCLS.Services/Audio/RecognitionRouteSelector.cs @@ -0,0 +1,31 @@ +using System; +using VRCLS.Core.Models; + +namespace VRCLS.Services.Audio +{ + internal enum RecognitionRoute + { + Remote, + LocalSherpa, + LocalSenseVoice + } + + internal static class RecognitionRouteSelector + { + public static RecognitionRoute Select( + string source, + RecognitionBackendMode mode, + LocalRecognitionModel localModel) + { + if (!string.Equals(source, "Mic", StringComparison.OrdinalIgnoreCase) + || mode != RecognitionBackendMode.Local) + { + return RecognitionRoute.Remote; + } + + return localModel == LocalRecognitionModel.SenseVoice + ? RecognitionRoute.LocalSenseVoice + : RecognitionRoute.LocalSherpa; + } + } +} diff --git a/02.VRCLS.Services/Recognition/IOfflineSenseVoiceRecognizer.cs b/02.VRCLS.Services/Recognition/IOfflineSenseVoiceRecognizer.cs new file mode 100644 index 0000000..9030443 --- /dev/null +++ b/02.VRCLS.Services/Recognition/IOfflineSenseVoiceRecognizer.cs @@ -0,0 +1,9 @@ +using System; + +namespace VRCLS.Services.Recognition +{ + internal interface IOfflineSenseVoiceRecognizer : IDisposable + { + string Decode(float[] audioData); + } +} diff --git a/02.VRCLS.Services/Recognition/SenseVoiceAsrService.Sherpa.cs b/02.VRCLS.Services/Recognition/SenseVoiceAsrService.Sherpa.cs new file mode 100644 index 0000000..f9f19f8 --- /dev/null +++ b/02.VRCLS.Services/Recognition/SenseVoiceAsrService.Sherpa.cs @@ -0,0 +1,10 @@ +namespace VRCLS.Services.Recognition +{ + public sealed partial class SenseVoiceAsrService + { + public SenseVoiceAsrService(string modelPath, string source = "Mic") + : this(new SherpaOfflineSenseVoiceRecognizer(modelPath), source) + { + } + } +} diff --git a/02.VRCLS.Services/Recognition/SenseVoiceAsrService.cs b/02.VRCLS.Services/Recognition/SenseVoiceAsrService.cs new file mode 100644 index 0000000..edeac65 --- /dev/null +++ b/02.VRCLS.Services/Recognition/SenseVoiceAsrService.cs @@ -0,0 +1,126 @@ +using System; +using System.Collections.Generic; +using Serilog; +using VRCLS.Core.Interfaces; +using VRCLS.Core.Models; + +namespace VRCLS.Services.Recognition +{ + public sealed partial class SenseVoiceAsrService : IRecognitionService, IDisposable + { + private static readonly ILogger _logger = Log.ForContext(); + private readonly object _recognizerLock = new(); + private readonly IOfflineSenseVoiceRecognizer _recognizer; + private readonly string _source; + private bool _isRunning; + private bool _disposed; + + public event EventHandler? TextRecognized; + public event EventHandler? ErrorOccurred; + + internal SenseVoiceAsrService( + IOfflineSenseVoiceRecognizer recognizer, + string source = "Mic") + { + _recognizer = recognizer ?? throw new ArgumentNullException(nameof(recognizer)); + _source = source; + } + + public bool IsRunning + { + get + { + lock (_recognizerLock) + { + return !_disposed && _isRunning; + } + } + } + + public void Start() + { + lock (_recognizerLock) + { + ObjectDisposedException.ThrowIf(_disposed, this); + _isRunning = true; + } + } + + public void Stop() + { + lock (_recognizerLock) + { + _isRunning = false; + } + } + + public void ProcessAudio( + float[] audioData, + RecognitionSegmentInfo? segmentInfo = null) + { + lock (_recognizerLock) + { + if (_disposed || !_isRunning || audioData == null || audioData.Length == 0) + { + return; + } + + try + { + var text = _recognizer.Decode(audioData).Trim(); + if (string.IsNullOrWhiteSpace(text)) + { + return; + } + + var metadata = segmentInfo ?? RecognitionSegmentInfo.Empty; + TextRecognized?.Invoke(this, new TranscriptionResult + { + Text = text, + Original = text, + IsFinal = true, + Confidence = 1.0f, + Source = _source, + SegmentId = metadata.SegmentId, + CapturedAtUtc = metadata.CapturedAtUtc + }); + } + catch (Exception ex) + { + _logger.Error(ex, "SenseVoice recognition failed."); + ErrorOccurred?.Invoke(this, new ServiceErrorEvent + { + Code = ServiceErrorCode.RecognitionFailed, + Context = new Dictionary + { + ["Reason"] = ex.Message + }, + Exception = ex + }); + } + } + } + + public void FinishSegment() + { + // ProcessAudio receives complete VAD segments for offline recognition. + } + + public void Dispose() + { + lock (_recognizerLock) + { + if (_disposed) + { + return; + } + + _isRunning = false; + _recognizer.Dispose(); + _disposed = true; + } + + GC.SuppressFinalize(this); + } + } +} diff --git a/02.VRCLS.Services/Recognition/SherpaOfflineSenseVoiceRecognizer.cs b/02.VRCLS.Services/Recognition/SherpaOfflineSenseVoiceRecognizer.cs new file mode 100644 index 0000000..07e5f21 --- /dev/null +++ b/02.VRCLS.Services/Recognition/SherpaOfflineSenseVoiceRecognizer.cs @@ -0,0 +1,68 @@ +using System; +using System.IO; +using SherpaOnnx; + +namespace VRCLS.Services.Recognition +{ + internal sealed class SherpaOfflineSenseVoiceRecognizer : IOfflineSenseVoiceRecognizer + { + private const int SampleRate = 16000; + private readonly OfflineRecognizer _recognizer; + + public SherpaOfflineSenseVoiceRecognizer(string modelPath) + { + if (string.IsNullOrWhiteSpace(modelPath) || !Directory.Exists(modelPath)) + { + throw new DirectoryNotFoundException( + $"SenseVoice model path not found: {modelPath}"); + } + + var modelFile = Path.Combine(modelPath, "model.onnx"); + var tokensFile = Path.Combine(modelPath, "tokens.txt"); + if (!File.Exists(modelFile) || !File.Exists(tokensFile)) + { + throw new FileNotFoundException( + "SenseVoice requires both model.onnx and tokens.txt.", + !File.Exists(modelFile) ? modelFile : tokensFile); + } + + var config = new OfflineRecognizerConfig + { + FeatConfig = new FeatureConfig + { + SampleRate = SampleRate, + FeatureDim = 80 + }, + ModelConfig = new OfflineModelConfig + { + SenseVoice = new OfflineSenseVoiceModelConfig + { + Model = modelFile, + Language = "auto", + UseInverseTextNormalization = 1 + }, + Tokens = tokensFile, + Provider = "cpu", + NumThreads = Math.Clamp(Environment.ProcessorCount / 2, 1, 4), + Debug = 0 + }, + DecodingMethod = "greedy_search" + }; + + _recognizer = new OfflineRecognizer(config); + } + + public string Decode(float[] audioData) + { + using var stream = _recognizer.CreateStream(); + stream.AcceptWaveform(SampleRate, audioData); + _recognizer.Decode(stream); + return stream.Result.Text ?? string.Empty; + } + + public void Dispose() + { + _recognizer.Dispose(); + } + } +} diff --git a/02.VRCLS.Services/Update/ModelUpdateService.cs b/02.VRCLS.Services/Update/ModelUpdateService.cs index 737607e..2bfabb6 100644 --- a/02.VRCLS.Services/Update/ModelUpdateService.cs +++ b/02.VRCLS.Services/Update/ModelUpdateService.cs @@ -270,28 +270,7 @@ public bool IsModelTypeInstalled(string type) private bool HasSenseVoiceModel() { - if (!string.IsNullOrWhiteSpace(_config.SenseVoiceModelDir)) - { - var configured = Path.Combine(_config.SenseVoiceModelDir, "model.onnx"); - if (File.Exists(configured)) - { - return true; - } - } - - var root = ResolveModelRoot(); - var defaultPath = Path.Combine(root, "sensevoice-small", "model.onnx"); - if (File.Exists(defaultPath)) - { - return true; - } - - if (!Directory.Exists(root)) - { - return false; - } - - return Directory.EnumerateFiles(root, "model.onnx", SearchOption.AllDirectories).Any(); + return !string.IsNullOrWhiteSpace(_modelPathResolver.ResolveSenseVoiceModelDir()); } private static ModelPackageInfo? SelectBestCandidate(IReadOnlyList models, string type) diff --git a/03.VRCLS.UI/ViewModels/MainViewModel.cs b/03.VRCLS.UI/ViewModels/MainViewModel.cs index 9c8bbda..1196c22 100644 --- a/03.VRCLS.UI/ViewModels/MainViewModel.cs +++ b/03.VRCLS.UI/ViewModels/MainViewModel.cs @@ -1190,6 +1190,15 @@ private void OnServiceError(object? sender, ServiceErrorEvent error) NotifyServiceWarning("Recognition", "识别服务登录状态已过期,请重新登录后再试。"); return; + case ServiceErrorCode.RecognitionFailed: + { + var reason = ResolveServiceErrorReason(error); + NotifyServiceWarning("Recognition", string.IsNullOrWhiteSpace(reason) + ? "本地语音识别失败。" + : $"本地语音识别失败:{reason}"); + return; + } + case ServiceErrorCode.TtsRateLimit: return; diff --git a/03.VRCLS.UI/ViewModels/SettingsViewModel.cs b/03.VRCLS.UI/ViewModels/SettingsViewModel.cs index 914aa2f..64113cb 100644 --- a/03.VRCLS.UI/ViewModels/SettingsViewModel.cs +++ b/03.VRCLS.UI/ViewModels/SettingsViewModel.cs @@ -2929,7 +2929,7 @@ private string BuildLocalModelStatusText() var recognitionModelName = GetCurrentLocalRecognitionModelDisplayName(); var recognitionModelInstalled = _config.LocalRecognitionModelValue == LocalRecognitionModel.SenseVoice - ? !string.IsNullOrWhiteSpace(ResolveSenseVoiceModelDir()) + ? !string.IsNullOrWhiteSpace(resolver.ResolveSenseVoiceModelDir()) : !string.IsNullOrWhiteSpace(resolver.ResolveSherpaModelDir()); if (!recognitionModelInstalled) { @@ -3297,67 +3297,6 @@ private static string FormatModelPackageList(IEnumerable model .Distinct(StringComparer.OrdinalIgnoreCase)); } - private string? ResolveSenseVoiceModelDir() - { - try - { - if (!string.IsNullOrWhiteSpace(_config.SenseVoiceModelDir)) - { - var configured = Path.Combine(_config.SenseVoiceModelDir, "model.onnx"); - if (File.Exists(configured)) - { - return _config.SenseVoiceModelDir; - } - } - - foreach (var root in EnumerateModelRoots()) - { - var defaultDir = Path.Combine(root, "sensevoice-small"); - if (File.Exists(Path.Combine(defaultDir, "model.onnx"))) - { - return defaultDir; - } - - if (!Directory.Exists(root)) - { - continue; - } - - var modelPath = Directory.EnumerateFiles(root, "model.onnx", SearchOption.AllDirectories).FirstOrDefault(); - if (!string.IsNullOrWhiteSpace(modelPath)) - { - return Path.GetDirectoryName(modelPath); - } - } - } - catch (Exception ex) - { - _logger.Debug(ex, "ResolveSenseVoiceModelDir failed."); - } - - return null; - } - - private IEnumerable EnumerateModelRoots() - { - var roots = new List(); - if (!string.IsNullOrWhiteSpace(_config.PreModelsDir)) - { - try - { - roots.Add(Path.GetFullPath(_config.PreModelsDir)); - } - catch - { - } - } - - roots.Add(Path.Combine(AppContext.BaseDirectory, "pre-models")); - return roots - .Where(root => !string.IsNullOrWhiteSpace(root)) - .Distinct(StringComparer.OrdinalIgnoreCase); - } - public string DisplayUsername { get diff --git a/VRCLS.sln b/VRCLS.sln index f9ba763..71e77a0 100644 --- a/VRCLS.sln +++ b/VRCLS.sln @@ -15,6 +15,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tools", "tools", "{07C2787E EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VRCLS.ConfigSnapshotTool", "tools\VRCLS.ConfigSnapshotTool\VRCLS.ConfigSnapshotTool.csproj", "{8B3C1931-7AED-41C2-A7F2-BE3AF2CF5C86}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VRCLS.Services.Tests", "tests\VRCLS.Services.Tests\VRCLS.Services.Tests.csproj", "{DADCE65B-2C6B-413C-B583-AF23D47E0ECC}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -85,12 +89,25 @@ Global {8B3C1931-7AED-41C2-A7F2-BE3AF2CF5C86}.Release|x64.Build.0 = Release|Any CPU {8B3C1931-7AED-41C2-A7F2-BE3AF2CF5C86}.Release|x86.ActiveCfg = Release|Any CPU {8B3C1931-7AED-41C2-A7F2-BE3AF2CF5C86}.Release|x86.Build.0 = Release|Any CPU + {DADCE65B-2C6B-413C-B583-AF23D47E0ECC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DADCE65B-2C6B-413C-B583-AF23D47E0ECC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DADCE65B-2C6B-413C-B583-AF23D47E0ECC}.Debug|x64.ActiveCfg = Debug|Any CPU + {DADCE65B-2C6B-413C-B583-AF23D47E0ECC}.Debug|x64.Build.0 = Debug|Any CPU + {DADCE65B-2C6B-413C-B583-AF23D47E0ECC}.Debug|x86.ActiveCfg = Debug|Any CPU + {DADCE65B-2C6B-413C-B583-AF23D47E0ECC}.Debug|x86.Build.0 = Debug|Any CPU + {DADCE65B-2C6B-413C-B583-AF23D47E0ECC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DADCE65B-2C6B-413C-B583-AF23D47E0ECC}.Release|Any CPU.Build.0 = Release|Any CPU + {DADCE65B-2C6B-413C-B583-AF23D47E0ECC}.Release|x64.ActiveCfg = Release|Any CPU + {DADCE65B-2C6B-413C-B583-AF23D47E0ECC}.Release|x64.Build.0 = Release|Any CPU + {DADCE65B-2C6B-413C-B583-AF23D47E0ECC}.Release|x86.ActiveCfg = Release|Any CPU + {DADCE65B-2C6B-413C-B583-AF23D47E0ECC}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection GlobalSection(NestedProjects) = preSolution {8B3C1931-7AED-41C2-A7F2-BE3AF2CF5C86} = {07C2787E-EAC7-C090-1BA3-A61EC2A24D84} + {DADCE65B-2C6B-413C-B583-AF23D47E0ECC} = {0AB3BF05-4346-4AA6-1389-037BE0695223} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {A1B2C3D4-E5F6-7890-ABCD-EF1234567890} diff --git a/tests/VRCLS.Services.Tests/ModelPathResolverTests.cs b/tests/VRCLS.Services.Tests/ModelPathResolverTests.cs new file mode 100644 index 0000000..7919d53 --- /dev/null +++ b/tests/VRCLS.Services.Tests/ModelPathResolverTests.cs @@ -0,0 +1,73 @@ +using VRCLS.Core.Models; +using VRCLS.Services.Audio; +using Xunit; + +namespace VRCLS.Services.Tests; + +public sealed class ModelPathResolverTests : IDisposable +{ + private readonly string _tempRoot = Path.Combine( + Path.GetTempPath(), + $"vrcls-sensevoice-{Guid.NewGuid():N}"); + + [Fact] + public void ResolveSenseVoiceModelDir_ReturnsConfiguredCompleteModel() + { + var modelDir = CreateModelDirectory(includeModel: true, includeTokens: true); + var resolver = CreateResolver(modelDir); + + var resolved = resolver.ResolveSenseVoiceModelDir(); + + Assert.Equal(modelDir, resolved); + } + + [Theory] + [InlineData(true, false)] + [InlineData(false, true)] + public void ResolveSenseVoiceModelDir_RejectsIncompleteModel( + bool includeModel, + bool includeTokens) + { + var modelDir = CreateModelDirectory(includeModel, includeTokens); + var resolver = CreateResolver(modelDir); + + var resolved = resolver.ResolveSenseVoiceModelDir(); + + Assert.Null(resolved); + } + + public void Dispose() + { + if (Directory.Exists(_tempRoot)) + { + Directory.Delete(_tempRoot, recursive: true); + } + } + + private ModelPathResolver CreateResolver(string modelDir) + { + return new ModelPathResolver(new AppConfig + { + SenseVoiceModelDir = modelDir, + PreModelsDir = Path.Combine(_tempRoot, "unused-pre-models") + }); + } + + private string CreateModelDirectory(bool includeModel, bool includeTokens) + { + var modelDir = Path.Combine(_tempRoot, Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(modelDir); + + if (includeModel) + { + File.WriteAllText(Path.Combine(modelDir, "model.onnx"), "model"); + } + + if (includeTokens) + { + File.WriteAllText(Path.Combine(modelDir, "tokens.txt"), "tokens"); + } + + return modelDir; + } +} diff --git a/tests/VRCLS.Services.Tests/RecognitionRouteSelectorTests.cs b/tests/VRCLS.Services.Tests/RecognitionRouteSelectorTests.cs new file mode 100644 index 0000000..a084553 --- /dev/null +++ b/tests/VRCLS.Services.Tests/RecognitionRouteSelectorTests.cs @@ -0,0 +1,38 @@ +using VRCLS.Core.Models; +using VRCLS.Services.Audio; +using Xunit; + +namespace VRCLS.Services.Tests; + +public sealed class RecognitionRouteSelectorTests +{ + [Theory] + [InlineData(LocalRecognitionModel.Sherpa, (int)RecognitionRoute.LocalSherpa)] + [InlineData(LocalRecognitionModel.SenseVoice, (int)RecognitionRoute.LocalSenseVoice)] + public void Select_RoutesLocalMicrophoneToConfiguredModel( + LocalRecognitionModel model, + int expected) + { + var route = RecognitionRouteSelector.Select( + "Mic", + RecognitionBackendMode.Local, + model); + + Assert.Equal((RecognitionRoute)expected, route); + } + + [Theory] + [InlineData("Desktop", RecognitionBackendMode.Local)] + [InlineData("Mic", RecognitionBackendMode.Remote)] + public void Select_KeepsDesktopAndRemoteModeOnRemoteService( + string source, + RecognitionBackendMode mode) + { + var route = RecognitionRouteSelector.Select( + source, + mode, + LocalRecognitionModel.SenseVoice); + + Assert.Equal(RecognitionRoute.Remote, route); + } +} diff --git a/tests/VRCLS.Services.Tests/SenseVoiceAsrServiceTests.cs b/tests/VRCLS.Services.Tests/SenseVoiceAsrServiceTests.cs new file mode 100644 index 0000000..59878d9 --- /dev/null +++ b/tests/VRCLS.Services.Tests/SenseVoiceAsrServiceTests.cs @@ -0,0 +1,216 @@ +using VRCLS.Core.Models; +using VRCLS.Services.Recognition; +using Xunit; + +namespace VRCLS.Services.Tests; + +public sealed class SenseVoiceAsrServiceTests +{ + [Fact] + public void ProcessAudio_EmitsOneFinalResultWithSegmentMetadata() + { + using var recognizer = new FakeRecognizer { Result = " hello world " }; + using var service = new SenseVoiceAsrService(recognizer, "Mic"); + var segment = new RecognitionSegmentInfo + { + SegmentId = 42, + CapturedAtUtc = new DateTime(2026, 7, 27, 1, 2, 3, DateTimeKind.Utc) + }; + var results = new List(); + service.TextRecognized += (_, result) => results.Add(result); + + service.Start(); + service.ProcessAudio([0.1f, -0.1f], segment); + service.FinishSegment(); + + var result = Assert.Single(results); + Assert.Equal("hello world", result.Text); + Assert.Equal("hello world", result.Original); + Assert.True(result.IsFinal); + Assert.Equal("Mic", result.Source); + Assert.Equal(segment.SegmentId, result.SegmentId); + Assert.Equal(segment.CapturedAtUtc, result.CapturedAtUtc); + Assert.Equal(1, recognizer.DecodeCount); + } + + [Fact] + public void ProcessAudio_IgnoresEmptyAudioAndBlankRecognition() + { + using var recognizer = new FakeRecognizer { Result = " " }; + using var service = new SenseVoiceAsrService(recognizer); + var results = new List(); + service.TextRecognized += (_, result) => results.Add(result); + + service.Start(); + service.ProcessAudio([]); + service.ProcessAudio([0.1f]); + + Assert.Empty(results); + Assert.Equal(1, recognizer.DecodeCount); + } + + [Fact] + public void ProcessAudio_ReportsRecognitionFailure() + { + using var recognizer = new FakeRecognizer + { + Exception = new InvalidOperationException("decode failed") + }; + using var service = new SenseVoiceAsrService(recognizer); + var errors = new List(); + var results = new List(); + service.ErrorOccurred += (_, error) => errors.Add(error); + service.TextRecognized += (_, result) => results.Add(result); + + service.Start(); + service.ProcessAudio([0.1f]); + + var error = Assert.Single(errors); + Assert.Equal(ServiceErrorCode.RecognitionFailed, error.Code); + Assert.Equal("decode failed", error.GetContext("Reason")); + Assert.Same(recognizer.Exception, error.Exception); + Assert.Empty(results); + } + + [Fact] + public void StopAndDispose_PreventFurtherRecognition() + { + var recognizer = new FakeRecognizer { Result = "ignored" }; + var service = new SenseVoiceAsrService(recognizer); + var eventCount = 0; + service.TextRecognized += (_, _) => eventCount++; + service.ErrorOccurred += (_, _) => eventCount++; + + service.Start(); + service.Stop(); + service.ProcessAudio([0.1f]); + service.Dispose(); + service.ProcessAudio([0.1f]); + + Assert.False(service.IsRunning); + Assert.Equal(0, eventCount); + Assert.Equal(0, recognizer.DecodeCount); + Assert.Equal(1, recognizer.DisposeCount); + Assert.Throws(() => service.Start()); + } + + [Fact] + public async Task Stop_WaitsForInFlightDecode() + { + using var decodeStarted = new ManualResetEventSlim(); + using var allowDecode = new ManualResetEventSlim(); + using var stopStarted = new ManualResetEventSlim(); + using var recognizer = new FakeRecognizer + { + DecodeHandler = _ => + { + decodeStarted.Set(); + allowDecode.Wait(); + return "finished"; + } + }; + using var service = new SenseVoiceAsrService(recognizer); + service.Start(); + + var decodeTask = Task.Run(() => service.ProcessAudio([0.1f])); + Task? stopTask = null; + try + { + Assert.True(decodeStarted.Wait(TimeSpan.FromSeconds(5))); + stopTask = Task.Run(() => + { + stopStarted.Set(); + service.Stop(); + }); + Assert.True(stopStarted.Wait(TimeSpan.FromSeconds(5))); + var completed = await Task.WhenAny(stopTask, Task.Delay(100)); + Assert.NotSame(stopTask, completed); + } + finally + { + allowDecode.Set(); + } + + await decodeTask; + if (stopTask != null) + { + await stopTask; + } + + Assert.False(service.IsRunning); + Assert.Equal(1, recognizer.DecodeCount); + } + + [Fact] + public async Task Dispose_WaitsForInFlightDecode() + { + using var decodeStarted = new ManualResetEventSlim(); + using var allowDecode = new ManualResetEventSlim(); + using var disposeStarted = new ManualResetEventSlim(); + var recognizer = new FakeRecognizer + { + DecodeHandler = _ => + { + decodeStarted.Set(); + allowDecode.Wait(); + return "finished"; + } + }; + var service = new SenseVoiceAsrService(recognizer); + service.Start(); + + var decodeTask = Task.Run(() => service.ProcessAudio([0.1f])); + Task? disposeTask = null; + try + { + Assert.True(decodeStarted.Wait(TimeSpan.FromSeconds(5))); + disposeTask = Task.Run(() => + { + disposeStarted.Set(); + service.Dispose(); + }); + Assert.True(disposeStarted.Wait(TimeSpan.FromSeconds(5))); + var completed = await Task.WhenAny(disposeTask, Task.Delay(100)); + Assert.NotSame(disposeTask, completed); + } + finally + { + allowDecode.Set(); + } + + await decodeTask; + if (disposeTask != null) + { + await disposeTask; + } + + Assert.False(service.IsRunning); + Assert.Equal(1, recognizer.DecodeCount); + Assert.Equal(1, recognizer.DisposeCount); + } + + private sealed class FakeRecognizer : IOfflineSenseVoiceRecognizer + { + public string Result { get; init; } = string.Empty; + public Exception? Exception { get; init; } + public Func? DecodeHandler { get; init; } + public int DecodeCount { get; private set; } + public int DisposeCount { get; private set; } + + public string Decode(float[] audioData) + { + DecodeCount++; + if (Exception != null) + { + throw Exception; + } + + return DecodeHandler?.Invoke(audioData) ?? Result; + } + + public void Dispose() + { + DisposeCount++; + } + } +} diff --git a/tests/VRCLS.Services.Tests/TestAppConfig.cs b/tests/VRCLS.Services.Tests/TestAppConfig.cs new file mode 100644 index 0000000..59de5dc --- /dev/null +++ b/tests/VRCLS.Services.Tests/TestAppConfig.cs @@ -0,0 +1,9 @@ +namespace VRCLS.Core.Models; + +public sealed class AppConfig +{ + public string VadModelPath { get; set; } = string.Empty; + public string PreModelsDir { get; set; } = string.Empty; + public string SherpaModelDir { get; set; } = string.Empty; + public string SenseVoiceModelDir { get; set; } = string.Empty; +} diff --git a/tests/VRCLS.Services.Tests/VRCLS.Services.Tests.csproj b/tests/VRCLS.Services.Tests/VRCLS.Services.Tests.csproj new file mode 100644 index 0000000..c422124 --- /dev/null +++ b/tests/VRCLS.Services.Tests/VRCLS.Services.Tests.csproj @@ -0,0 +1,34 @@ + + + + net8.0 + enable + enable + false + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + + + + + +