- 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>
478 lines
16 KiB
C#
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;
|
|
}
|
|
}
|