All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- Added Role to AuthResponse DTO and all auth endpoints - Fixed null config handling in MistralConnector, TtsService, VoskService, MistralService - Fixed BaseAddress setup in MistralConnector to work without API key - Reverted seed data to use hardcoded bcrypt hashes (compatible with PasswordHasher) - Added integration tests for StoryController - Added unit tests for MistralConnector - Updated frontend AuthResponse type to include role Fixes admin redirect to /, story generation null reference, and Docker build failures. Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
357 lines
13 KiB
C#
357 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 ?? new VoskConfig();
|
|
_logger = logger;
|
|
|
|
// Skip validation during EF migrations or when configs are not set
|
|
bool isEfDesignTime = Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_EF") == "true";
|
|
bool hasModelPath = !string.IsNullOrWhiteSpace(_config.ModelPath);
|
|
|
|
if (!isEfDesignTime && hasModelPath)
|
|
{
|
|
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));
|
|
}
|
|
}
|