Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions 01.VRCLS.Core/Interfaces/IModelPathResolver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,10 @@ public interface IModelPathResolver
/// Resolve the Sherpa model directory for local recognition, if available.
/// </summary>
string? ResolveSherpaModelDir();

/// <summary>
/// Resolve a complete SenseVoice model directory containing model.onnx and tokens.txt.
/// </summary>
string? ResolveSenseVoiceModelDir();
}
}
1 change: 1 addition & 0 deletions 01.VRCLS.Core/Models/ServiceErrorCode.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ public enum ServiceErrorCode
AudioProcessingRepeatedFailures,
RecognitionAuthRequired,
RecognitionAuthExpired,
RecognitionFailed,
TtsAuthRequired,
TtsRateLimit,
TtsAuthExpired,
Expand Down
80 changes: 52 additions & 28 deletions 02.VRCLS.Services/Audio/AudioPipelineFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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.");
Expand All @@ -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(
Expand All @@ -122,61 +124,83 @@ 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)
{
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)
Expand Down
72 changes: 72 additions & 0 deletions 02.VRCLS.Services/Audio/ModelPathResolver.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<string>();

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"));
}
}
}
31 changes: 31 additions & 0 deletions 02.VRCLS.Services/Audio/RecognitionRouteSelector.cs
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
9 changes: 9 additions & 0 deletions 02.VRCLS.Services/Recognition/IOfflineSenseVoiceRecognizer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
using System;

namespace VRCLS.Services.Recognition
{
internal interface IOfflineSenseVoiceRecognizer : IDisposable
{
string Decode(float[] audioData);
}
}
10 changes: 10 additions & 0 deletions 02.VRCLS.Services/Recognition/SenseVoiceAsrService.Sherpa.cs
Original file line number Diff line number Diff line change
@@ -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)
{
}
}
}
Loading