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 Coqui TTS text-to-speech. /// Uses Python process to call Coqui TTS library. /// This is part of the Infrastructure layer. /// public class TtsService : ITtsService { private readonly CoquiConfig _config; private readonly ILogger _logger; public TtsService( IOptions config, ILogger logger) { _config = config.Value; _logger = logger; // Skip validation during EF migrations or when configs are not set bool isEfDesignTime = Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_EF") == "true"; bool hasPythonPath = !string.IsNullOrWhiteSpace(_config?.PythonPath); if (!isEfDesignTime && hasPythonPath) { ValidateConfiguration(); // Ensure audio storage directory exists Directory.CreateDirectory(_config.AudioStoragePath); } } /// /// Validates TTS configuration on startup. /// /// Thrown when configuration is invalid private void ValidateConfiguration() { if (string.IsNullOrWhiteSpace(_config.PythonPath)) { _logger.LogError("Coqui PythonPath is not configured"); throw new InvalidOperationException( "Coqui PythonPath is not configured. Please set Coqui:PythonPath in appsettings.json"); } if (string.IsNullOrWhiteSpace(_config.ModelName)) { _logger.LogError("Coqui ModelName is not configured"); throw new InvalidOperationException( "Coqui ModelName is not configured. Please set Coqui:ModelName in appsettings.json. " + "Example: tts_models/de/deu/fairseq/vits"); } if (string.IsNullOrWhiteSpace(_config.AudioStoragePath)) { _logger.LogError("Coqui AudioStoragePath is not configured"); throw new InvalidOperationException( "Coqui AudioStoragePath is not configured. Please set Coqui:AudioStoragePath in appsettings.json"); } if (_config.MaxTextLength <= 0) { _logger.LogError("Coqui MaxTextLength must be positive"); throw new InvalidOperationException( "Coqui MaxTextLength must be greater than 0"); } if (_config.TimeoutSeconds <= 0) { _logger.LogError("Coqui TimeoutSeconds must be positive"); throw new InvalidOperationException( "Coqui TimeoutSeconds must be greater than 0"); } _logger.LogInformation("Coqui TTS configuration validated: Model={ModelName}, Storage={AudioStoragePath}", _config.ModelName, _config.AudioStoragePath); } /// /// Generates audio from text. /// public virtual async Task GenerateAudioAsync( string text, string? speaker = null, string language = "de", CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(text)) throw new ArgumentException("Text cannot be empty", nameof(text)); // Split long text into chunks if (text.Length > _config.MaxTextLength) { var chunks = SplitText(text, _config.MaxTextLength); var audioParts = new List(); foreach (var chunk in chunks) { var audio = await GenerateAudioForChunkAsync(chunk, speaker, language, cancellationToken); audioParts.Add(audio); } // Combine audio bytes return CombineAudioBytes(audioParts); } return await GenerateAudioForChunkAsync(text, speaker, language, cancellationToken); } /// /// Generates audio for a single text chunk. /// private async Task GenerateAudioForChunkAsync( string text, string? speaker, string language, CancellationToken cancellationToken) { var tempFile = Path.GetTempFileName() + "." + _config.OutputFormat; try { await GenerateAudioToFileInternalAsync(text, tempFile, speaker, language, cancellationToken); return await File.ReadAllBytesAsync(tempFile, cancellationToken); } finally { try { File.Delete(tempFile); } catch (Exception ex) { _logger.LogError(ex, "Failed to delete temp file: {File}", tempFile); } } } /// /// Generates audio and saves to a file. /// public virtual async Task GenerateAudioToFileAsync( string text, string outputPath, string? speaker = null, string language = "de", CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(text)) throw new ArgumentException("Text cannot be empty", nameof(text)); // Ensure output directory exists var directory = Path.GetDirectoryName(outputPath); if (!string.IsNullOrEmpty(directory)) Directory.CreateDirectory(directory); await GenerateAudioToFileInternalAsync(text, outputPath, speaker, language, cancellationToken); return outputPath; } /// /// Internal method to generate audio to a specific file path. /// private async Task GenerateAudioToFileInternalAsync( string text, string outputPath, string? speaker, string language, CancellationToken cancellationToken) { var script = BuildTtsScript(text, outputPath, speaker, language); var tempScript = Path.GetTempFileName() + ".py"; try { await File.WriteAllTextAsync(tempScript, script, cancellationToken); var result = await ExecutePythonProcessAsync(tempScript, cancellationToken); if (result.ExitCode != 0) { _logger.LogError("TTS generation failed. stderr: {Error}", result.StandardError); throw new InvalidOperationException( "TTS generation failed: " + result.StandardError); } // Verify output file exists if (!File.Exists(outputPath)) { throw new FileNotFoundException( "TTS output file not created", outputPath); } } finally { try { File.Delete(tempScript); } catch (Exception ex) { _logger.LogError(ex, "Failed to delete temp script: {File}", tempScript); } } } /// /// Generates audio as a stream. /// public virtual async Task GenerateAudioStreamAsync( string text, string? speaker = null, string language = "de", CancellationToken cancellationToken = default) { // For streaming, we generate to a temp file and return a FileStream var tempFile = Path.GetTempFileName() + "." + _config.OutputFormat; try { await GenerateAudioToFileInternalAsync(text, tempFile, speaker, language, cancellationToken); return new FileStream(tempFile, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, true); } catch { // Clean up on error try { File.Delete(tempFile); } catch { } throw; } } /// /// Builds the Python script for TTS generation. /// private string BuildTtsScript(string text, string outputPath, string? speaker, string language) { var script = new StringBuilder(); script.AppendLine("import sys"); script.AppendLine("import json"); script.AppendLine(); // Handle import errors script.AppendLine("try:"); script.AppendLine(" from TTS.api import TTS"); script.AppendLine("except ImportError as e:"); script.AppendLine(" print(json.dumps({'error': 'Coqui TTS not installed: ' + str(e)}))"); script.AppendLine(" sys.exit(1)"); script.AppendLine(); // Load TTS model script.AppendLine($"model_name = '{_config.ModelName}'"); script.AppendLine("try:"); script.AppendLine(" tts = TTS(model_name=model_name)"); script.AppendLine("except Exception as e:"); script.AppendLine(" print(json.dumps({'error': 'Failed to load TTS model: ' + str(e)}))"); script.AppendLine(" sys.exit(1)"); script.AppendLine(); // Configure TTS if (!string.IsNullOrEmpty(speaker)) { script.AppendLine($"tts.speaker = '{speaker}'"); } script.AppendLine($"tts.language = '{language}'"); script.AppendLine(); // Generate and save audio script.AppendLine($"text = '''{text.Replace("'''", "\\'''")}'''"); script.AppendLine($"output_path = '{outputPath.Replace("\\", "\\\\")}'"); script.AppendLine("tts.tts_to_file(text=text, file_path=output_path)"); script.AppendLine("print('success')"); return script.ToString(); } /// /// Result of Python process execution. /// private class ProcessResult { public string StandardOutput { get; set; } = string.Empty; public string StandardError { get; set; } = string.Empty; public int ExitCode { get; set; } } /// /// Executes a Python process and returns the result. /// private async Task 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("TTS process timed out after {Seconds} seconds", _config.TimeoutSeconds); throw new TimeoutException( $"TTS generation timed out after {_config.TimeoutSeconds} seconds"); } var output = await outputTask; var error = await errorTask; return new ProcessResult { StandardOutput = output, StandardError = error, ExitCode = process.ExitCode }; } /// /// Splits text into chunks of maximum length. /// private IReadOnlyList SplitText(string text, int maxLength) { var chunks = new List(); var currentChunk = new StringBuilder(); foreach (var word in text.Split(new[] { ' ', '\n', '\r', '\t' }, StringSplitOptions.RemoveEmptyEntries)) { if (currentChunk.Length + word.Length + 1 <= maxLength) { if (currentChunk.Length > 0) currentChunk.Append(' '); currentChunk.Append(word); } else { chunks.Add(currentChunk.ToString()); currentChunk.Clear(); currentChunk.Append(word); } } if (currentChunk.Length > 0) chunks.Add(currentChunk.ToString()); return chunks; } /// /// Combines multiple audio byte arrays into a single array. /// Note: This is a simple concatenation. For proper audio merging, /// you would need to use an audio library to handle the WAV header correctly. /// private byte[] CombineAudioBytes(IReadOnlyList audioParts) { if (audioParts.Count == 1) return audioParts[0]; // For WAV files, we need to handle the header properly // This is a simplified version that assumes all parts are valid WAV files // In production, you would want to use NAudio or similar to properly merge WAV files var totalLength = audioParts.Sum(p => p.Length); var combined = new byte[totalLength]; var offset = 0; foreach (var part in audioParts) { Buffer.BlockCopy(part, 0, combined, offset, part.Length); offset += part.Length; } return combined; } /// /// Tests the Coqui TTS model and configuration. /// public virtual async Task TestModelAsync(CancellationToken cancellationToken = default) { try { // Test with a simple phrase var testText = "Hallo, das ist ein Test."; var testFile = Path.Combine(_config.AudioStoragePath, "test." + _config.OutputFormat); await GenerateAudioToFileAsync(testText, testFile, null, "de", cancellationToken); // Verify file was created if (File.Exists(testFile)) { // Clean up test file File.Delete(testFile); return true; } return false; } catch (Exception ex) { _logger.LogError(ex, "TTS model test failed"); return false; } } /// /// Gets the current TTS model information. /// public virtual Task<(string ModelName, string? ModelPath)> GetModelInfoAsync() { return Task.FromResult<(string ModelName, string? ModelPath)>((_config.ModelName, null)); } /// /// Gets list of available voices/speakers for the current model. /// public virtual Task> GetAvailableSpeakersAsync(CancellationToken cancellationToken = default) { // Coqui TTS doesn't always have multiple speakers for all models // This would need to query the model or use a predefined list // For now, return a default list for German models var defaultSpeakers = new List { "default" }; return Task.FromResult>(defaultSpeakers); } /// /// Cleans up old audio files from storage. /// public virtual async Task CleanupOldFilesAsync( TimeSpan olderThan, CancellationToken cancellationToken = default) { var cutoff = DateTime.UtcNow - olderThan; var deletedCount = 0; if (Directory.Exists(_config.AudioStoragePath)) { foreach (var file in Directory.GetFiles(_config.AudioStoragePath, "*", SearchOption.AllDirectories)) { try { var fileInfo = new FileInfo(file); if (fileInfo.LastWriteTimeUtc < cutoff) { File.Delete(file); deletedCount++; _logger.LogInformation("Deleted old audio file: {File}", file); } } catch (Exception ex) { _logger.LogError(ex, "Failed to delete audio file: {File}", file); } // Yield to avoid blocking for long periods await Task.Yield(); } } return deletedCount; } }