Imaginez : vous venez de publier un article de blog technique, et en un clic, une conversation vivante entre deux animateurs se génère automatiquement en audio MP3. C'est exactement ce que j'ai implémenté dans cette plateforme de blog, et dans cet article, je vous dévoile toute la mécanique du podcast que vous pouvez voir juste au dessus de cet article.

Nous allons voir comment combiner Semantic Kernel (le SDK d'orchestration IA de Microsoft), l'API Chat Completion d'OpenAI pour générer un script de dialogue, puis l'API Text-to-Speech (TTS) pour transformer chaque réplique en audio, et enfin fusionner le tout en un fichier MP3.

Pourquoi Semantic Kernel plutôt qu'un appel HTTP direct ?

Semantic Kernel est le SDK officiel de Microsoft pour intégrer l'IA générative dans les applications .NET. Il offre :

  • Une abstraction unifiée sur les modèles (OpenAI, Azure OpenAI, Hugging Face…) ce qui permet de changer si les tarifs explosent.
  • Un système de plugins et de fonctions natives
  • La gestion du chat history nativement
  • Une intégration naturelle avec l'injection de dépendances .NET
// Configuration de Semantic Kernel dans le Startup
var openAIConfig = new OpenAIConfiguration();
openAiConfiguration.Invoke(openAIConfig);

builder.Services.AddSingleton(sp =>
{
    var kb = Kernel.CreateBuilder();
    if (!string.IsNullOrWhiteSpace(openAIConfig.ApiKey))
    {
        kb.AddOpenAIChatCompletion(openAIConfig.Model, openAIConfig.ApiKey);
    }
    return kb.Build();
});

Architecture globale

Le pipeline de génération de podcast suit trois étapes distinctes orchestrées par un handler principal. Chaque étape est une requête indépendante, ce qui permet le découplage, la testabilité et la réutilisation.

flowchart TB
    subgraph Déclenchement
        A[📝 Article de blog] -->|Clic sur Générer Podcast| B[EnqueueGeneratePodcastFromPostRequest]
    end

    subgraph Pipeline de génération
        B --> C[🎭 Étape 1 : Générer le script de dialogue]
        C -->|GeneratePodcastDialogueScriptRequest| D["OpenAI Chat Completion<br/>(Semantic Kernel)"]
        D --> E["Liste de PodcastDialogueSegment<br/>(Speaker, Text, Order)"]
        
        E --> F[🔊 Étape 2 : Générer l'audio par segment]
        F -->|GenerateAudioSegmentRequest| G["OpenAI TTS API<br/>(tts-1)"]
        G --> H["byte[] par segment<br/>(MP3)"]
        
        H --> I[🔗 Étape 3 : Fusionner les segments]
        I -->|MergeAudioSegmentsRequest| J[Concaténation MP3]
    end

    subgraph Persistance
        J --> K[📦 Créer un Document]
        K --> L[💾 Sauvegarder en base]
        L --> M[🔗 Associer au Post]
    end

    style A fill:#4a90d9,color:#fff
    style D fill:#10a37f,color:#fff
    style G fill:#10a37f,color:#fff
    style M fill:#28a745,color:#fff

Étape 1 : Générer le script de dialogue avec Semantic Kernel

Le principe

On envoie le contenu de l'article à GPT-4 avec un system prompt très détaillé qui définit les personnages, le ton, le format de sortie attendu (JSON). Le modèle retourne un tableau JSON de répliques alternées entre les deux intervenants.

sequenceDiagram
    participant Handler as DialogueScriptHandler
    participant SK as Semantic Kernel
    participant OpenAI as OpenAI GPT-4

    Handler->>SK: Créer un Kernel avec OpenAI Chat Completion
    Handler->>SK: Construire ChatHistory (System + User)
    SK->>OpenAI: Envoyer le prompt avec l'article
    OpenAI-->>SK: Réponse JSON (tableau de segments)
    SK-->>Handler: ChatMessageContent
    Handler->>Handler: Parser le JSON en List<PodcastDialogueSegment>

Le modèle de données

Chaque segment du dialogue est représenté par un record immuable :

/// <summary>
/// Représente un segment de dialogue dans le podcast
/// </summary>
public record PodcastDialogueSegment(
    string Speaker,  // "Marc" ou "Caroline"
    string Text,     // Le texte de la réplique
    int Order        // L'ordre dans la conversation
);

La requête Mediator

/// <summary>
/// Requête pour générer un script de dialogue podcast entre Marc et Caroline
/// </summary>
public record GeneratePodcastDialogueScriptRequest(
    string Title,
    string Content
) : IRequest<List<PodcastDialogueSegment>>;

Le handler complet

C'est ici que Semantic Kernel entre en jeu. On crée un Kernel, on récupère le service IChatCompletionService, et on construit un ChatHistory avec un system prompt et le contenu de l'article :

internal class GeneratePodcastDialogueScriptRequestHandler(
    OpenAIConfiguration config,
    ILogger<GeneratePodcastDialogueScriptRequestHandler> logger
) : IRequestHandler<GeneratePodcastDialogueScriptRequest, List<PodcastDialogueSegment>>
{
    public async Task<List<PodcastDialogueSegment>> Handle(
        GeneratePodcastDialogueScriptRequest request,
        CancellationToken cancellationToken)
    {
        // 1. Construire le Kernel Semantic Kernel
        var builder = Kernel.CreateBuilder();
        builder.AddOpenAIChatCompletion(
            modelId: config.Model,
            apiKey: config.ApiKey);
        var kernel = builder.Build();

        // 2. Obtenir le service de chat
        var chatService = kernel.GetRequiredService<IChatCompletionService>();

        // 3. Construire l'historique de conversation
        var chatHistory = new ChatHistory();
        chatHistory.AddSystemMessage(BuildDialogueSystemPrompt());
        chatHistory.AddUserMessage($"""
            Titre de l'article : {request.Title}

            Contenu de l'article :
            {request.Content}
            """);

        // 4. Configurer les paramètres d'exécution
        var executionSettings = new PromptExecutionSettings
        {
            ExtensionData = new Dictionary<string, object>
            {
                ["temperature"] = 0.8,
                ["max_tokens"] = 4000
            }
        };

        // 5. Appeler le modèle
        var response = await chatService.GetChatMessageContentAsync(
            chatHistory,
            executionSettings,
            kernel,
            cancellationToken);

        // 6. Parser la réponse JSON
        var responseText = response.Content ?? string.Empty;
        return ParseDialogueResponse(responseText);
    }
}

Le system prompt : la clé de la qualité

Le system prompt est crucial : c'est lui qui définit le style du podcast, les règles de conversation et surtout le format de sortie JSON attendu.

private static string BuildDialogueSystemPrompt()
{
    return """
        Tu es un créateur de podcasts expert. Tu dois transformer un article 
        de blog en une conversation dynamique et joyeuse entre deux personnes : 
        Marc et Caroline.

        RÈGLES IMPORTANTES :
        1. Marc et Caroline discutent de manière naturelle et enthousiaste
        2. Utilise un ton joyeux, amical et accessible
        3. Caroline pose souvent des questions pour clarifier les points complexes
        4. Marc est un expert en développement informatique
        5. Ils peuvent se couper la parole gentiment, réagir avec des interjections
        6. Inclus des moments d'humour léger
        7. La conversation doit couvrir tous les points importants de l'article
        8. Commence par une introduction accrocheuse et termine par une conclusion
        9. S'il y a des blocs de code, Marc les explique sans les réciter

        FORMAT DE RÉPONSE (JSON strict) :
        [
          {"speaker": "Marc", "text": "Salut Caroline ! Aujourd'hui on parle de..."},
          {"speaker": "Caroline", "text": "Salut Marc ! Oui, c'est passionnant..."},
          ...
        ]

        CONTRAINTES :
        - Chaque segment : 1 à 3 phrases maximum
        - Alterne régulièrement entre Marc et Caroline
        - Génère entre 15 et 30 segments
        - Texte en français
        - Pas de balises markdown, juste le JSON brut
        """;
}

Parsing robuste du JSON

Le modèle retourne parfois du texte autour du JSON. On utilise une regex pour extraire le tableau JSON :

private List<PodcastDialogueSegment> ParseDialogueResponse(string response)
{
    var segments = new List<PodcastDialogueSegment>();

    try
    {
        // Extraire le tableau JSON même s'il est entouré de texte
        var jsonArrayRegex = new Regex(@"\[[\s\S]*\]");
        var jsonMatch = jsonArrayRegex.Match(response);
        var jsonContent = jsonMatch.Success ? jsonMatch.Value : response.Trim();

        using var document = JsonDocument.Parse(jsonContent);
        var order = 0;

        foreach (var element in document.RootElement.EnumerateArray())
        {
            var speaker = element.GetProperty("speaker").GetString() ?? "Marc";
            var text = element.GetProperty("text").GetString() ?? "";

            if (!string.IsNullOrWhiteSpace(text))
            {
                segments.Add(new PodcastDialogueSegment(speaker, text, order++));
            }
        }
    }
    catch (JsonException ex)
    {
        logger.LogError(ex, "Erreur lors du parsing du dialogue JSON");

        // Fallback : un segment d'erreur gracieux
        segments.Add(new PodcastDialogueSegment(
            "Caroline",
            "Désolée, nous n'avons pas pu générer le podcast. Veuillez réessayer.",
            0));
    }

    return segments;
}

Étape 2 : Transformer chaque réplique en audio (Text-to-Speech)

Le principe

Chaque PodcastDialogueSegment est envoyé à l'API TTS d'OpenAI. On attribue une voix différente à chaque intervenant pour créer une vraie conversation naturelle.

flowchart LR
    subgraph Pour chaque segment
        A["PodcastDialogueSegment<br/>Speaker: Marc<br/>Text: Salut Caroline !"] --> B{Quel speaker ?}
        B -->|Marc| C["Voix : Onyx 🎙️<br/>(masculine, grave)"]
        B -->|Caroline| D["Voix : Nova 🎙️<br/>(féminine, chaleureuse)"]
        C --> E["OpenAI TTS API<br/>Modèle : tts-1"]
        D --> E
        E --> F["byte[]<br/>(segment MP3)"]
    end

    style C fill:#3498db,color:#fff
    style D fill:#e74c7c,color:#fff
    style E fill:#10a37f,color:#fff

Les voix OpenAI disponibles

Voix Genre Caractéristique Utilisé pour
alloy Neutre Polyvalent -
echo Masculine Douce -
fable Masculine Narrative -
onyx Masculine Grave et assurée Marc
nova Féminine Chaleureuse et naturelle Caroline
shimmer Féminine Légère et claire -

Le handler TTS

internal class GenerateAudioSegmentRequestHandler(
    OpenAIConfiguration config
) : IRequestHandler<GenerateAudioSegmentRequest, byte[]>
{
    private const string MARC_VOICE = "onyx";
    private const string CAROLINE_VOICE = "nova";

    public async Task<byte[]> Handle(
        GenerateAudioSegmentRequest request, 
        CancellationToken cancellationToken)
    {
        // 1. Créer le client OpenAI
        var client = new OpenAI.OpenAIClient(config.ApiKey);
        var audioClient = client.GetAudioClient("tts-1");

        // 2. Sélectionner la voix selon l'intervenant
        var voice = request.Segment.Speaker
            .Equals("Marc", StringComparison.OrdinalIgnoreCase)
            ? MARC_VOICE
            : CAROLINE_VOICE;

        var generatedVoice = voice switch
        {
            "onyx"    => GeneratedSpeechVoice.Onyx,
            "nova"    => GeneratedSpeechVoice.Nova,
            "echo"    => GeneratedSpeechVoice.Echo,
            "shimmer" => GeneratedSpeechVoice.Shimmer,
            "alloy"   => GeneratedSpeechVoice.Alloy,
            "fable"   => GeneratedSpeechVoice.Fable,
            _         => GeneratedSpeechVoice.Nova
        };

        // 3. Configurer le format de sortie
        var options = new SpeechGenerationOptions
        {
            ResponseFormat = GeneratedSpeechFormat.Mp3,
            SpeedRatio = 1.0f
        };

        // 4. Générer l'audio
        var result = await audioClient.GenerateSpeechAsync(
            request.Segment.Text,
            generatedVoice,
            options,
            cancellationToken);

        return result.Value.ToArray();
    }
}

Note : Le modèle tts-1 est optimisé pour la vitesse avec une qualité acceptable. Pour une qualité supérieure (au prix d'une latence plus élevée), utilisez tts-1-hd.


Étape 3 : Fusionner les segments audio

Le principe

Le format MP3 utilise des frames indépendants : chaque frame contient ses propres informations d'en-tête et peut être décodé seul. Cela signifie qu'on peut simplement concaténer les fichiers MP3 binaires les uns à la suite des autres pour obtenir un fichier MP3 valide.

flowchart LR
    A["🔊 Segment 1<br/>Marc : Salut !<br/>(12 Ko)"] --> D
    B["🔊 Segment 2<br/>Caroline : Salut !<br/>(15 Ko)"] --> D
    C["🔊 Segment 3<br/>Marc : Aujourd'hui...<br/>(18 Ko)"] --> D
    D["🔗 Concaténation<br/>binaire"] --> E["🎵 Podcast final<br/>(45 Ko MP3)"]

    style D fill:#f39c12,color:#fff
    style E fill:#28a745,color:#fff

Le handler de fusion

internal class MergeAudioSegmentsRequestHandler
    : IRequestHandler<MergeAudioSegmentsRequest, byte[]>
{
    public Task<byte[]> Handle(
        MergeAudioSegmentsRequest request, 
        CancellationToken cancellationToken)
    {
        // Les fichiers MP3 peuvent être simplement concaténés
        // car ils utilisent des frames indépendants
        using var outputStream = new MemoryStream();

        foreach (var segment in request.AudioSegments)
        {
            if (cancellationToken.IsCancellationRequested)
            {
                break;
            }
            outputStream.Write(segment, 0, segment.Length);
        }

        return Task.FromResult(outputStream.ToArray());
    }
}

L'orchestrateur : le handler principal

Le handler EnqueueGeneratePodcastFromPostRequestHandler orchestre les trois étapes et gère les notifications de progression en temps réel via un système de LongTaskNotifier (pour le feedback utilisateur dans l'interface Blazor).

sequenceDiagram
    participant UI as Interface Blazor
    participant Orchestrator as EnqueueHandler
    participant Notifier as LongTaskNotifier
    participant M as Mediator
    participant GPT as OpenAI GPT-4
    participant TTS as OpenAI TTS

    UI->>Orchestrator: Générer le podcast pour le Post X
    Orchestrator->>Notifier: Start("Début de la génération")
    
    Orchestrator->>Notifier: Publish("Récupération de l'article...")
    Orchestrator->>M: GetPostById(postId)
    M-->>Orchestrator: Post (Title + Content)

    Orchestrator->>Notifier: Publish("Génération du script...")
    Orchestrator->>M: Send(GeneratePodcastDialogueScriptRequest)
    M->>GPT: Chat Completion
    GPT-->>M: JSON dialogue (20 segments)
    M-->>Orchestrator: List<PodcastDialogueSegment>
    Orchestrator->>Notifier: Publish("Script généré avec 20 segments")

    Orchestrator->>Notifier: StartProgress("Génération audio...", 20)
    
    loop Pour chaque segment (1 à 20)
        Orchestrator->>M: Send(GenerateAudioSegmentRequest)
        M->>TTS: GenerateSpeechAsync(text, voice)
        TTS-->>M: byte[] (MP3)
        M-->>Orchestrator: byte[]
        Orchestrator->>Notifier: Progress("Segment X/20 - Marc")
    end

    Orchestrator->>Notifier: Publish("Fusion des segments...")
    Orchestrator->>M: Send(MergeAudioSegmentsRequest)
    M-->>Orchestrator: byte[] (final MP3)

    Orchestrator->>Notifier: Publish("Enregistrement...")
    Orchestrator->>M: CreateDocument + SaveDocument
    Orchestrator->>M: AddDocumentToMetaEntity(postId, docId)
    Orchestrator->>Notifier: Completed("Podcast généré avec succès !")
    Notifier-->>UI: Notification temps réel ✅

Le code de l'orchestrateur

internal class EnqueueGeneratePodcastFromPostRequestHandler(
    IMediator mediator,
    ILongTaskNotifier<EnqueueGeneratePodcastFromPostRequestHandler> notifier
) : IRequestHandler<EnqueueGeneratePodcastFromPostRequest, EnqueueLongTaskResult>
{
    private const string PODCAST_DOCUMENT_TITLE = "Podcast";
    private const string AUDIO_MIME_TYPE = "audio/mpeg";

    public async Task<EnqueueLongTaskResult> Handle(
        EnqueueGeneratePodcastFromPostRequest request,
        CancellationToken cancellationToken)
    {
        var result = new EnqueueLongTaskResult { TaskId = request.PostId };
        var postId = request.PostId;

        await notifier.Start(postId, "Début de la génération du podcast");

        try
        {
            // ── Récupérer l'article ──
            await notifier.Publish(postId, "Récupération de l'article...");
            var post = await mediator.GetPostById(postId, cancellationToken);
            if (post is null)
            {
                await notifier.Failed(postId, "Article introuvable");
                return result;
            }

            // ── Étape 1 : Générer le script de conversation ──
            await notifier.Publish(postId, 
                "Génération du script de conversation entre Marc et Caroline...");
            var dialogueSegments = await mediator.Send(
                new GeneratePodcastDialogueScriptRequest(post.Title, post.Content),
                cancellationToken);

            if (dialogueSegments.Count == 0)
            {
                await notifier.Failed(postId, 
                    "Impossible de générer le script de conversation");
                return result;
            }

            await notifier.Publish(postId, 
                $"Script généré avec {dialogueSegments.Count} segments de dialogue");

            // ── Étape 2 : Générer l'audio pour chaque segment ──
            await notifier.StartProgress(postId, 
                "Génération des segments audio...", 
                "Podcast", 
                dialogueSegments.Count);

            var audioSegments = new List<byte[]>();
            var segmentIndex = 0;

            foreach (var segment in dialogueSegments.OrderBy(s => s.Order))
            {
                if (cancellationToken.IsCancellationRequested)
                {
                    await notifier.Canceled(postId);
                    return result;
                }

                var audioData = await mediator.Send(
                    new GenerateAudioSegmentRequest(segment), 
                    cancellationToken);
                audioSegments.Add(audioData);
                segmentIndex++;

                await notifier.Progress(postId, "Podcast", 
                    $"Segment {segmentIndex}/{dialogueSegments.Count} - {segment.Speaker}", 
                    segmentIndex);
            }

            await notifier.EndProgress(postId, "Podcast");

            // ── Étape 3 : Fusionner les segments ──
            await notifier.Publish(postId, "Fusion des segments audio...");
            var finalAudio = await mediator.Send(
                new MergeAudioSegmentsRequest(audioSegments), 
                cancellationToken);

            // ── Persistance ──
            await notifier.Publish(postId, "Enregistrement du podcast...");
            var document = await mediator.CreateDocument(cancellationToken);
            document.Title = PODCAST_DOCUMENT_TITLE;
            document.Description = 
                $"Podcast généré automatiquement pour l'article : {post.Title}";
            document.ContentType = AUDIO_MIME_TYPE;
            document.FileName = $"podcast-{post.Slug}.mp3";
            document.FileSize = finalAudio.Length;
            document.Base64String = Convert.ToBase64String(finalAudio);

            var saveResult = await mediator.SaveDocument(document, 
                bypassRules: false, 
                cancellationToken: cancellationToken);

            if (saveResult.HasError)
            {
                var errorMessage = saveResult.ErrorBrokenRuleList
                    .FirstOrDefault()?.MessageList.FirstOrDefault()
                    ?? "Erreur inconnue";
                await notifier.Failed(postId, 
                    $"Erreur lors de la sauvegarde : {errorMessage}");
                return result;
            }

            // Associer le document au post
            await mediator.AddDocumentToMetaEntity(
                postId, document.Id, post.AuthorId, cancellationToken);

            await notifier.Completed(postId, "Podcast généré avec succès !", 
                $"Durée estimée : {EstimateAudioDuration(finalAudio.Length)}");
        }
        catch (Exception ex)
        {
            await notifier.Failed(postId, 
                $"Erreur lors de la génération : {ex.Message}");
        }

        return result;
    }

    private static string EstimateAudioDuration(long fileSizeBytes)
    {
        // Estimation basée sur un bitrate MP3 moyen de 128 kbps
        const int bitrate = 128000;
        var durationSeconds = (fileSizeBytes * 8.0) / bitrate;
        var timeSpan = TimeSpan.FromSeconds(durationSeconds);
        return timeSpan.TotalMinutes >= 1
            ? $"{timeSpan.Minutes} min {timeSpan.Seconds} sec"
            : $"{timeSpan.Seconds} sec";
    }
}

Vue d'ensemble de l'architecture en couches

graph TB
    subgraph "🖥️ Blazor Server (UI)"
        A[EditPost.razor] -->|Clic Générer| B[Mediator.Send]
    end

    subgraph "📦 Core - Requests (Contrats)"
        C[EnqueueGeneratePodcastFromPostRequest]
        D[GeneratePodcastDialogueScriptRequest]
        E[GenerateAudioSegmentRequest]
        F[MergeAudioSegmentsRequest]
    end

    subgraph "⚙️ Core - Handlers (Implémentation)"
        G[EnqueueHandler<br/>Orchestrateur]
        H[DialogueScriptHandler<br/>Semantic Kernel]
        I[AudioSegmentHandler<br/>OpenAI TTS]
        J[MergeHandler<br/>Concaténation]
    end

    subgraph "☁️ APIs externes"
        K["OpenAI Chat Completion<br/>(gpt-4)"]
        L["OpenAI TTS<br/>(tts-1)"]
    end

    subgraph "💾 Persistance"
        M[Document Entity]
        N[DocumentByEntity<br/>liaison Post ↔ Document]
    end

    B --> C
    C --> G
    G --> D --> H --> K
    G --> E --> I --> L
    G --> F --> J
    G --> M --> N

    style A fill:#7c3aed,color:#fff
    style K fill:#10a37f,color:#fff
    style L fill:#10a37f,color:#fff
    style G fill:#f59e0b,color:#fff

Coûts et performances

Estimation des coûts OpenAI

Pour un article de ~1000 mots générant ~25 segments de dialogue :

Opération Modèle Tokens/Caractères Coût estimé
Script de dialogue gpt-4 ~3000 tokens entrée + ~2000 sortie ~0.10 €
TTS (25 segments) tts-1 ~5000 caractères total ~0.07 €
Total ~0.17 €

Temps de génération typique

gantt
    title Temps de génération d'un podcast (article 1000 mots)
    dateFormat ss
    axisFormat %S sec

    section Étape 1
    Génération du script (GPT-4)    :a1, 00, 8s

    section Étape 2
    Segment audio 1-5               :a2, after a1, 5s
    Segment audio 6-10              :a3, after a2, 5s
    Segment audio 11-15             :a4, after a3, 5s
    Segment audio 16-20             :a5, after a4, 5s
    Segment audio 21-25             :a6, after a5, 5s

    section Étape 3
    Fusion MP3                      :a7, after a6, 1s
    Sauvegarde                      :a8, after a7, 1s

Temps total estimé : 30 à 40 secondes (d'où l'intérêt du LongTaskNotifier pour le feedback en temps réel).


Conclusion

Cette approche en trois étapes (script → TTS → fusion) offre une architecture modulaire et extensible. Grâce au pattern Mediator, chaque étape est indépendante et testable isolément. Semantic Kernel simplifie considérablement l'intégration avec OpenAI en offrant une couche d'abstraction propre et compatible avec l'écosystème .NET.

Le résultat ? Un podcast audio naturel avec deux voix distinctes, généré automatiquement en moins d'une minute, directement associé à l'article de blog. 🎙️