- 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>
363 lines
13 KiB
C#
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;
|
|
}
|
|
}
|