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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+