DeutschLernen/GermanApp/Infrastructure/Services/VoskService.cs
Lasse Rune Hansen 9e753d9b40 feat(backend/ai-services): Phase 3 - Model validation and setup guide
Phase 3 Tasks Completed:
- Added model directory validation to VoskService constructor
- Added configuration validation to TtsService constructor
- Added comprehensive setup instructions to VoskConfig and CoquiConfig
- Added AI Model Setup Guide to docs/features/ai-services.md with:
  - Vosk model download and setup instructions
  - Coqui TTS model download and setup instructions
  - Mistral API configuration guide
  - Verification commands
  - Troubleshooting table
- Updated appsettings.json and appsettings.Development.json with comments

All configuration is now validated on service startup with helpful error messages
that guide users to download and configure the required models.

Build: Success
Tests: 296 passing (148 unit + 148 integration)

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-06-13 10:47:02 +02:00

351 lines
13 KiB
C#

using GermanApp.Domain.Interfaces;
using GermanApp.Infrastructure.Configuration;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System.Diagnostics;
using System.Text;
using System.Text.Json;
namespace GermanApp.Infrastructure.Services;
/// <summary>
/// Infrastructure service for Vosk speech recognition.
/// Uses Python process to call Vosk library.
/// This is part of the Infrastructure layer.
/// </summary>
public class VoskService : IVoskService
{
private readonly VoskConfig _config;
private readonly ILogger<VoskService> _logger;
public VoskService(
IOptions<VoskConfig> config,
ILogger<VoskService> logger)
{
_config = config.Value;
_logger = logger;
// Validate model path on startup
ValidateModelPath();
}
/// <summary>
/// Validates that the Vosk model directory exists and is accessible.
/// </summary>
/// <exception cref="InvalidOperationException">Thrown when model path is not configured</exception>
/// <exception cref="DirectoryNotFoundException">Thrown when model directory doesn't exist</exception>
private void ValidateModelPath()
{
if (string.IsNullOrWhiteSpace(_config.ModelPath))
{
_logger.LogError("Vosk ModelPath is not configured. Please set Vosk:ModelPath in appsettings.json");
throw new InvalidOperationException(
"Vosk model path is not configured. " +
"Please download vosk-model-de-0.22 from https://alphacephei.com/vosk/models " +
"and set the ModelPath in appsettings.json");
}
if (!Directory.Exists(_config.ModelPath))
{
_logger.LogError("Vosk model directory not found: {ModelPath}", _config.ModelPath);
throw new DirectoryNotFoundException(
$"Vosk model directory not found: {_config.ModelPath}. " +
"Please download vosk-model-de-0.22 from https://alphacephei.com/vosk/models " +
"and extract it to the configured path");
}
_logger.LogInformation("Vosk model directory validated: {ModelPath}", _config.ModelPath);
}
/// <summary>
/// Recognizes speech from audio bytes.
/// </summary>
public virtual async Task<string> RecognizeSpeechAsync(
byte[] audioBytes,
int sampleRate = 16000,
string? hint = null,
CancellationToken cancellationToken = default)
{
if (audioBytes == null || audioBytes.Length == 0)
throw new ArgumentException("Audio data cannot be empty", nameof(audioBytes));
// Validate sample rate
if (sampleRate != _config.SampleRate)
{
_logger.LogWarning(
"Audio sample rate ({SampleRate}) does not match expected ({Expected}). " +
"Results may be inaccurate.",
sampleRate,
_config.SampleRate);
}
// Write audio to temp file
var tempFile = Path.GetTempFileName() + ".wav";
try
{
await File.WriteAllBytesAsync(tempFile, audioBytes, cancellationToken);
return await RecognizeFromFileInternalAsync(tempFile, hint, cancellationToken);
}
finally
{
// Clean up temp file
try { File.Delete(tempFile); }
catch (Exception ex) { _logger.LogError(ex, "Failed to delete temp file: {File}", tempFile); }
}
}
/// <summary>
/// Recognizes speech from an audio file path.
/// </summary>
public virtual async Task<string> RecognizeSpeechFromFileAsync(
string audioFilePath,
CancellationToken cancellationToken = default)
{
if (!File.Exists(audioFilePath))
throw new FileNotFoundException("Audio file not found", audioFilePath);
return await RecognizeFromFileInternalAsync(audioFilePath, null, cancellationToken);
}
/// <summary>
/// Recognizes speech from a stream.
/// </summary>
public virtual async Task<string> RecognizeSpeechFromStreamAsync(
System.IO.Stream audioStream,
int sampleRate = 16000,
CancellationToken cancellationToken = default)
{
if (audioStream == null || !audioStream.CanRead)
throw new ArgumentException("Audio stream must be readable", nameof(audioStream));
// Read stream to temp file
var tempFile = Path.GetTempFileName() + ".wav";
try
{
using (var fileStream = File.Create(tempFile))
{
await audioStream.CopyToAsync(fileStream, cancellationToken);
}
return await RecognizeFromFileInternalAsync(tempFile, null, cancellationToken);
}
finally
{
try { File.Delete(tempFile); }
catch (Exception ex) { _logger.LogError(ex, "Failed to delete temp file: {File}", tempFile); }
}
}
/// <summary>
/// Internal method to recognize from file with error handling.
/// </summary>
private async Task<string> RecognizeFromFileInternalAsync(
string audioFilePath,
string? hint,
CancellationToken cancellationToken)
{
// Validate model path
if (!Directory.Exists(_config.ModelPath))
throw new InvalidOperationException(
$"Vosk model directory not found: {_config.ModelPath}");
// Build Python command
var script = BuildRecognitionScript(audioFilePath, hint);
var tempScript = Path.GetTempFileName() + ".py";
try
{
// Write the Python script to a temp file
await File.WriteAllTextAsync(tempScript, script, cancellationToken);
// Execute Python process
var result = await ExecutePythonProcessAsync(tempScript, cancellationToken);
// Parse result
var recognizedText = ParseVoskOutput(result.StandardOutput);
if (string.IsNullOrWhiteSpace(recognizedText))
{
_logger.LogWarning("Vosk returned empty result. stderr: {Error}", result.StandardError);
throw new InvalidOperationException(
"Speech recognition failed: " + result.StandardError);
}
return recognizedText.Trim();
}
finally
{
try { File.Delete(tempScript); }
catch (Exception ex) { _logger.LogError(ex, "Failed to delete temp script: {File}", tempScript); }
}
}
/// <summary>
/// Builds the Python script for speech recognition.
/// </summary>
private string BuildRecognitionScript(string audioFilePath, string? hint)
{
var script = new StringBuilder();
script.AppendLine("import sys");
script.AppendLine("import json");
script.AppendLine();
// Handle potential import errors
script.AppendLine("try:");
script.AppendLine(" from vosk import Model, KaldiRecognizer, SetLogLevel");
script.AppendLine("except ImportError as e:");
script.AppendLine(" print(json.dumps({'error': 'Vosk not installed: ' + str(e)}))");
script.AppendLine(" sys.exit(1)");
script.AppendLine();
// Set log level to suppress warnings
script.AppendLine("SetLogLevel(-1)");
script.AppendLine();
// Load model
script.AppendLine($"model_path = '{_config.ModelPath.Replace("\\", "\\\\")}'");
script.AppendLine("try:");
script.AppendLine(" model = Model(model_path)");
script.AppendLine("except Exception as e:");
script.AppendLine(" print(json.dumps({'error': 'Failed to load model: ' + str(e)}))");
script.AppendLine(" sys.exit(1)");
script.AppendLine();
// Configure recognizer
script.AppendLine($"sample_rate = {_config.SampleRate}");
script.AppendLine($"beam_width = {_config.BeamWidth}");
script.AppendLine("rec = KaldiRecognizer(model, sample_rate)");
if (!string.IsNullOrEmpty(hint))
{
script.AppendLine($"rec.SetGrammar('\"{hint.Replace("'", "\\'")}\"')");
}
script.AppendLine();
// Process audio file
script.AppendLine($"audio_path = '{audioFilePath.Replace("\\", "\\\\")}'");
script.AppendLine("with open(audio_path, 'rb') as f:");
script.AppendLine(" while True:");
script.AppendLine(" data = f.read(4000)");
script.AppendLine(" if len(data) == 0:");
script.AppendLine(" break");
script.AppendLine(" if rec.AcceptWaveform(data):");
script.AppendLine(" pass");
script.AppendLine(" else:");
script.AppendLine(" pass");
script.AppendLine();
script.AppendLine("result = rec.FinalResult()");
script.AppendLine("result_dict = json.loads(result)");
script.AppendLine("print(result_dict.get('text', ''))");
return script.ToString();
}
/// <summary>
/// Executes a Python process and returns the result.
/// </summary>
private async Task<(string StandardOutput, string StandardError)> ExecutePythonProcessAsync(
string scriptPath,
CancellationToken cancellationToken)
{
var processInfo = new ProcessStartInfo
{
FileName = _config.PythonPath,
Arguments = scriptPath,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
StandardOutputEncoding = Encoding.UTF8,
StandardErrorEncoding = Encoding.UTF8
};
using var process = new Process { StartInfo = processInfo };
// Use a timeout for the process
var timeoutTask = Task.Delay(_config.TimeoutSeconds * 1000, cancellationToken);
process.Start();
var outputTask = process.StandardOutput.ReadToEndAsync();
var errorTask = process.StandardError.ReadToEndAsync();
var exitTask = process.WaitForExitAsync(cancellationToken);
// Wait for process to complete or timeout
var completedTask = await Task.WhenAny(exitTask, timeoutTask);
if (completedTask == timeoutTask)
{
// Kill the process if it times out
try { process.Kill(); }
catch { }
_logger.LogError("Vosk process timed out after {Seconds} seconds", _config.TimeoutSeconds);
throw new TimeoutException(
$"Vosk speech recognition timed out after {_config.TimeoutSeconds} seconds");
}
var output = await outputTask;
var error = await errorTask;
return (output, error);
}
/// <summary>
/// Parses Vosk JSON output to extract the recognized text.
/// </summary>
private string ParseVoskOutput(string output)
{
// Vosk outputs JSON with a "text" field
try
{
using var doc = JsonDocument.Parse(output.Trim());
if (doc.RootElement.TryGetProperty("text", out var textElement))
{
return textElement.GetString() ?? string.Empty;
}
// Sometimes the output is just the text directly
return output.Trim();
}
catch
{
// If JSON parsing fails, try to return the output as-is
return output.Trim();
}
}
/// <summary>
/// Tests the Vosk model and configuration.
/// </summary>
public virtual async Task<bool> TestModelAsync(CancellationToken cancellationToken = default)
{
try
{
// Create a simple test: try to recognize silence or a known phrase
// For now, just validate that the model directory exists
if (!Directory.Exists(_config.ModelPath))
return false;
// Try to run a simple test with an empty/short audio
// This validates the Python environment and model loading
var testAudio = new byte[100]; // Very short audio
await RecognizeSpeechAsync(testAudio, _config.SampleRate, null, cancellationToken);
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Vosk model test failed");
return false;
}
}
/// <summary>
/// Gets the current Vosk model information.
/// </summary>
public virtual Task<(string ModelName, string ModelPath)> GetModelInfoAsync()
{
var modelName = Path.GetFileName(_config.ModelPath.TrimEnd('/', '\\'));
return Task.FromResult((modelName, _config.ModelPath));
}
}