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:#fffLes 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-1est optimisé pour la vitesse avec une qualité acceptable. Pour une qualité supérieure (au prix d'une latence plus élevée), utiliseztts-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:#fffLe 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:#fffCoû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, 1sTemps 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. 🎙️