DeutschLernen/GermanApp/Infrastructure/Services/TtsService.cs
Lasse Rune Hansen 28c111f8e0 feat(backend/story-integration): Phase 5 - Apply database migration with EF design-time support
- Created AppDbContextFactory for EF Core design-time DbContext creation
- Added DOTNET_RUNNING_IN_EF environment variable checks to skip validation during migrations
- Modified MistralConnector, TtsService, VoskService to skip validation in EF design-time
- Updated Program.cs ValidateAiConfigurations to skip during migrations
- Applied migration 20260613125706_AddStorySegmentAndStoryProgressTables to database
- Updated feature documentation for Phase 5 completion

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

478 lines
16 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 Coqui TTS text-to-speech.
/// Uses Python process to call Coqui TTS library.
/// This is part of the Infrastructure layer.
/// </summary>
public class TtsService : ITtsService
{
private readonly CoquiConfig _config;
private readonly ILogger<TtsService> _logger;
public TtsService(
IOptions<CoquiConfig> config,
ILogger<TtsService> logger)
{
_config = config.Value;
_logger = logger;
// Skip validation during EF migrations
bool isEfDesignTime = Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_EF") == "true";
if (!isEfDesignTime)
{
ValidateConfiguration();
// Ensure audio storage directory exists
Directory.CreateDirectory(_config.AudioStoragePath);
}
}
/// <summary>
/// Validates TTS configuration on startup.
/// </summary>
/// <exception cref="InvalidOperationException">Thrown when configuration is invalid</exception>
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);
}
/// <summary>
/// Generates audio from text.
/// </summary>
public virtual async Task<byte[]> 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<byte[]>();
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);
}
/// <summary>
/// Generates audio for a single text chunk.
/// </summary>
private async Task<byte[]> 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); }
}
}
/// <summary>
/// Generates audio and saves to a file.
/// </summary>
public virtual async Task<string> 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;
}
/// <summary>
/// Internal method to generate audio to a specific file path.
/// </summary>
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); }
}
}
/// <summary>
/// Generates audio as a stream.
/// </summary>
public virtual async Task<System.IO.Stream> 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;
}
}
/// <summary>
/// Builds the Python script for TTS generation.
/// </summary>
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();
}
/// <summary>
/// Result of Python process execution.
/// </summary>
private class ProcessResult
{
public string StandardOutput { get; set; } = string.Empty;
public string StandardError { get; set; } = string.Empty;
public int ExitCode { get; set; }
}
/// <summary>
/// Executes a Python process and returns the result.
/// </summary>
private async Task<ProcessResult> 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
};
}
/// <summary>
/// Splits text into chunks of maximum length.
/// </summary>
private IReadOnlyList<string> SplitText(string text, int maxLength)
{
var chunks = new List<string>();
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;
}
/// <summary>
/// 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.
/// </summary>
private byte[] CombineAudioBytes(IReadOnlyList<byte[]> 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;
}
/// <summary>
/// Tests the Coqui TTS model and configuration.
/// </summary>
public virtual async Task<bool> 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;
}
}
/// <summary>
/// Gets the current TTS model information.
/// </summary>
public virtual Task<(string ModelName, string? ModelPath)> GetModelInfoAsync()
{
return Task.FromResult<(string ModelName, string? ModelPath)>((_config.ModelName, null));
}
/// <summary>
/// Gets list of available voices/speakers for the current model.
/// </summary>
public virtual Task<IReadOnlyList<string>> 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<string> { "default" };
return Task.FromResult<IReadOnlyList<string>>(defaultSpeakers);
}
/// <summary>
/// Cleans up old audio files from storage.
/// </summary>
public virtual async Task<int> 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;
}
}