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; /// /// Infrastructure service for Vosk speech recognition. /// Uses Python process to call Vosk library. /// This is part of the Infrastructure layer. /// public class VoskService : IVoskService { private readonly VoskConfig _config; private readonly ILogger _logger; public VoskService( IOptions config, ILogger logger) { _config = config.Value; _logger = logger; // Skip validation during EF migrations bool isEfDesignTime = Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_EF") == "true"; if (!isEfDesignTime) { ValidateModelPath(); } } /// /// Validates that the Vosk model directory exists and is accessible. /// /// Thrown when model path is not configured /// Thrown when model directory doesn't exist 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); } /// /// Recognizes speech from audio bytes. /// public virtual async Task 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); } } } /// /// Recognizes speech from an audio file path. /// public virtual async Task RecognizeSpeechFromFileAsync( string audioFilePath, CancellationToken cancellationToken = default) { if (!File.Exists(audioFilePath)) throw new FileNotFoundException("Audio file not found", audioFilePath); return await RecognizeFromFileInternalAsync(audioFilePath, null, cancellationToken); } /// /// Recognizes speech from a stream. /// public virtual async Task 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); } } } /// /// Internal method to recognize from file with error handling. /// private async Task 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); } } } /// /// Builds the Python script for speech recognition. /// 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(); } /// /// Executes a Python process and returns the result. /// 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); } /// /// Parses Vosk JSON output to extract the recognized text. /// 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(); } } /// /// Tests the Vosk model and configuration. /// public virtual async Task 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; } } /// /// Gets the current Vosk model information. /// public virtual Task<(string ModelName, string ModelPath)> GetModelInfoAsync() { var modelName = Path.GetFileName(_config.ModelPath.TrimEnd('/', '\\')); return Task.FromResult((modelName, _config.ModelPath)); } }