DeutschLernen/GermanApp/Application/Services/AudioGenerationService.cs
Lasse Rune Hansen e002868b74 feat(backend/application): implement Phase 5 AI Service Integration
- Create higher-level AI services:
  - StoryGenerationService (uses MistralService)
  - WritingFeedbackService (uses MistralService)
  - SpeechExerciseService (uses VoskService)
  - AudioGenerationService (uses TtsService)
  - AiFallbackService (fallback mechanisms for service failures)
- Register AiFallbackService in Program.cs DI container
- Add comprehensive unit tests for all Phase 5 services:
  - AiFallbackServiceTests (14 tests)
  - AudioGenerationServiceTests (16 tests)
  - MistralServiceTests (16 tests)
  - SpeechExerciseServiceTests (12 tests)
  - StoryGenerationServiceTests (13 tests)
  - WritingFeedbackServiceTests (11 tests)
  - VoskServiceTests (15 tests)
  - TtsServiceTests (21 tests)
- Update feature document (ai-services.md) to mark Phase 5 as complete

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

363 lines
13 KiB
C#

using GermanApp.Domain.Interfaces;
using Microsoft.Extensions.Logging;
namespace GermanApp.Application.Services;
/// <summary>
/// Application service for generating audio from text using Coqui TTS.
/// This is part of the Application layer.
/// </summary>
public class AudioGenerationService
{
private readonly ITtsService _ttsService;
private readonly ILogger<AudioGenerationService> _logger;
/// <summary>
/// Creates a new AudioGenerationService.
/// </summary>
/// <param name="ttsService">The Coqui TTS service</param>
/// <param name="logger">Logger for service operations</param>
public AudioGenerationService(
ITtsService ttsService,
ILogger<AudioGenerationService> logger)
{
_ttsService = ttsService;
_logger = logger;
}
/// <summary>
/// Generates audio from text.
/// </summary>
/// <param name="text">The text to convert to speech</param>
/// <param name="language">The language code (default: "de" for German)</param>
/// <param name="speaker">Optional speaker ID</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Audio data as byte array</returns>
public virtual async Task<byte[]> GenerateAudioAsync(
string text,
string language = "de",
string? speaker = null,
CancellationToken cancellationToken = default)
{
_logger.LogInformation(
"Generating audio: TextLength={Length}, Language={Language}, Speaker={Speaker}",
text?.Length ?? 0, language, speaker ?? "default");
try
{
var audio = await _ttsService.GenerateAudioAsync(
text,
speaker,
language,
cancellationToken);
_logger.LogInformation("Audio generated successfully: {ByteCount} bytes", audio.Length);
return audio;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to generate audio");
throw new AiServiceException(
"Failed to generate audio: " + ex.Message,
AiErrorCode.Temporary);
}
}
/// <summary>
/// Generates audio and saves to a file.
/// </summary>
/// <param name="text">The text to convert to speech</param>
/// <param name="outputPath">Path to save the audio file</param>
/// <param name="language">The language code (default: "de")</param>
/// <param name="speaker">Optional speaker ID</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Path to the generated audio file</returns>
public virtual async Task<string> GenerateAudioToFileAsync(
string text,
string outputPath,
string language = "de",
string? speaker = null,
CancellationToken cancellationToken = default)
{
_logger.LogInformation(
"Generating audio to file: TextLength={Length}, Output={Output}",
text?.Length ?? 0, outputPath);
try
{
await _ttsService.GenerateAudioToFileAsync(
text,
outputPath,
speaker,
language,
cancellationToken);
_logger.LogInformation("Audio saved to: {OutputPath}", outputPath);
return outputPath;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to generate audio to file");
throw;
}
}
/// <summary>
/// Generates audio as a stream.
/// </summary>
/// <param name="text">The text to convert to speech</param>
/// <param name="language">The language code (default: "de")</param>
/// <param name="speaker">Optional speaker ID</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Stream containing audio data</returns>
public virtual async Task<System.IO.Stream> GenerateAudioStreamAsync(
string text,
string language = "de",
string? speaker = null,
CancellationToken cancellationToken = default)
{
_logger.LogInformation("Generating audio stream: TextLength={Length}", text?.Length ?? 0);
try
{
return await _ttsService.GenerateAudioStreamAsync(
text,
speaker,
language,
cancellationToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to generate audio stream");
throw;
}
}
/// <summary>
/// Generates audio for a vocabulary word.
/// </summary>
/// <param name="word">The vocabulary word</param>
/// <param name="language">The language code (default: "de")</param>
/// <param name="speaker">Optional speaker ID</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Audio data as byte array</returns>
public virtual async Task<byte[]> GenerateVocabularyAudioAsync(
string word,
string language = "de",
string? speaker = null,
CancellationToken cancellationToken = default)
{
_logger.LogInformation("Generating vocabulary audio: Word={Word}", word);
// For vocabulary, we might want to add a slight pause or emphasis
// For now, just generate the word directly
return await GenerateAudioAsync(word, language, speaker, cancellationToken);
}
/// <summary>
/// Generates audio for a complete lesson including vocabulary and example sentences.
/// </summary>
/// <param name="lessonText">The lesson text to narrate</param>
/// <param name="vocabularyWords">Vocabulary words to include</param>
/// <param name="language">The language code (default: "de")</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Audio data as byte array</returns>
public virtual async Task<byte[]> GenerateLessonAudioAsync(
string lessonText,
IReadOnlyList<string> vocabularyWords,
string language = "de",
CancellationToken cancellationToken = default)
{
_logger.LogInformation(
"Generating lesson audio: TextLength={Length}, VocabularyCount={Count}",
lessonText?.Length ?? 0, vocabularyWords?.Count ?? 0);
// Combine lesson text with vocabulary
var fullText = BuildLessonNarration(lessonText, vocabularyWords);
return await GenerateAudioAsync(fullText, language, null, cancellationToken);
}
/// <summary>
/// Generates audio for a story.
/// </summary>
/// <param name="storyText">The story text to narrate</param>
/// <param name="language">The language code (default: "de")</param>
/// <param name="speaker">Optional speaker ID</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Audio data as byte array</returns>
public virtual async Task<byte[]> GenerateStoryAudioAsync(
string storyText,
string language = "de",
string? speaker = null,
CancellationToken cancellationToken = default)
{
_logger.LogInformation("Generating story audio: TextLength={Length}", storyText?.Length ?? 0);
// Stories might be long, so we use the TTS service's built-in text splitting
return await GenerateAudioAsync(storyText, language, speaker, cancellationToken);
}
/// <summary>
/// Generates audio for a quiz question.
/// </summary>
/// <param name="questionText">The quiz question text</param>
/// <param name="options">The answer options</param>
/// <param name="language">The language code (default: "de")</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Audio data as byte array</returns>
public virtual async Task<byte[]> GenerateQuizAudioAsync(
string questionText,
IReadOnlyList<string> options,
string language = "de",
CancellationToken cancellationToken = default)
{
_logger.LogInformation(
"Generating quiz audio: QuestionLength={Length}, OptionCount={Count}",
questionText?.Length ?? 0, options?.Count ?? 0);
// Build the full quiz narration
var fullText = BuildQuizNarration(questionText, options);
return await GenerateAudioAsync(fullText, language, null, cancellationToken);
}
/// <summary>
/// Generates multiple audio files for a batch of texts.
/// </summary>
/// <param name="texts">Dictionary mapping IDs to texts</param>
/// <param name="outputDirectory">Directory to save audio files</param>
/// <param name="language">The language code (default: "de")</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Dictionary mapping IDs to output file paths</returns>
public virtual async Task<IDictionary<string, string>> GenerateBatchAudioAsync(
IDictionary<string, string> texts,
string outputDirectory,
string language = "de",
CancellationToken cancellationToken = default)
{
_logger.LogInformation("Generating batch audio: Count={Count}", texts?.Count ?? 0);
var results = new Dictionary<string, string>();
// Ensure output directory exists
Directory.CreateDirectory(outputDirectory);
foreach (var kvp in texts)
{
var id = kvp.Key;
var text = kvp.Value;
var outputPath = Path.Combine(outputDirectory, $"{id}.wav");
try
{
await GenerateAudioToFileAsync(text, outputPath, language, null, cancellationToken);
results[id] = outputPath;
_logger.LogInformation("Generated audio for: {Id}", id);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to generate audio for: {Id}", id);
results[id] = string.Empty;
}
}
return results;
}
/// <summary>
/// Gets available voices for the current TTS model.
/// </summary>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>List of available speaker IDs</returns>
public virtual async Task<IReadOnlyList<string>> GetAvailableVoicesAsync(
CancellationToken cancellationToken = default)
{
try
{
return await _ttsService.GetAvailableSpeakersAsync(cancellationToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get available voices");
return new List<string> { "default" };
}
}
/// <summary>
/// Gets information about the current TTS model.
/// </summary>
/// <returns>Model information tuple</returns>
public virtual async Task<(string ModelName, string? ModelPath)> GetModelInfoAsync()
{
try
{
return await _ttsService.GetModelInfoAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get model info");
return ("unknown", null);
}
}
/// <summary>
/// Tests the audio generation service.
/// </summary>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>True if service is working, false otherwise</returns>
public virtual async Task<bool> TestServiceAsync(CancellationToken cancellationToken = default)
{
try
{
// Test with a simple phrase
var audio = await GenerateAudioAsync("Hallo Welt", "de", null, cancellationToken);
return audio != null && audio.Length > 0;
}
catch (Exception ex)
{
_logger.LogError(ex, "Audio generation service test failed");
return false;
}
}
/// <summary>
/// Builds narration text for a lesson including vocabulary words.
/// </summary>
private string BuildLessonNarration(string lessonText, IReadOnlyList<string> vocabularyWords)
{
// Start with the lesson text
var narration = lessonText;
// Append vocabulary words with pauses
if (vocabularyWords != null && vocabularyWords.Count > 0)
{
narration += "\n\n";
narration += "Vocabulary words: ";
narration += string.Join(", ", vocabularyWords);
}
return narration;
}
/// <summary>
/// Builds narration text for a quiz question with options.
/// </summary>
private string BuildQuizNarration(string questionText, IReadOnlyList<string> options)
{
var narration = questionText;
if (options != null && options.Count > 0)
{
narration += "\n";
for (int i = 0; i < options.Count; i++)
{
narration += $"Option {i + 1}: {options[i]}";
if (i < options.Count - 1)
narration += ". ";
}
}
return narration;
}
}