GPT-Live : écoute et parole simultanées — fonctionnement et intégration
1. Aperçu
« GPT-Live » est le terme générique pour les modèles LLM capables d’écouter et de parler simultanément sur un seul canal de streaming audio, au lieu d’exécuter séquentiellement Speech-to-Text → LLM → Text-to-Speech. Cet article explique comment le mécanisme full-duplex fonctionne au niveau du protocole, puis construit un exemple Spring Boot qui se connecte via WebSocket, envoie l’audio, reçoit une réponse en streaming et gère le cas où l’utilisateur interrompt la parole (barge-in). Après la lecture, vous aurez assez de compréhension pour écrire vous-même un client Java appelant une API voice temps réel et connaître les pièges à éviter lors de la mise en production de cette fonctionnalité.
2. Pourquoi l’ancien pipeline (STT → LLM → TTS) n’est pas « en direct »
Autrefois, les assistants vocaux combinaient généralement 3 modèles distincts :
Mic → [Speech-to-Text] → text → [LLM] → text → [Text-to-Speech] → Speaker
Chaque étape attend que la précédente soit complète avant de s’exécuter. Les latences s’accumulent (généralement 2–5 secondes), et l’utilisateur ne peut pas interrompre l’assistant au milieu d’une réponse car le système n’« écoute » pas pendant qu’il parle.
GPT-Live fusionne les trois rôles en un seul modèle multimodal qui reçoit directement des frames audio et produit des frames audio, via une connexion bidirectionnelle (duplex) continue. Il n’y a pas d’étape « attendre la fin de la phrase » — le modèle traite chaque petit segment audio (chunk) dès qu’il arrive, et peut commencer à prononcer sa réponse avant même que l’utilisateur ait terminé sa phrase si la confiance sémantique est suffisante.
Remarque : « En direct » ici ne signifie pas zéro latence, mais plutôt que la latence est distribuée par chunk (généralement 20–100 ms) au lieu de s’accumuler à la fin du pipeline.
3. Architecture full-duplex : flux d’événements via WebSocket
Contrairement aux API REST (requête-réponse unique), les API temps réel utilisent WebSocket pour maintenir une connexion ouverte, échangeant des événements au format JSON. Deux flux de données s’exécutent simultanément :
- Client → serveur : chunk audio (base64 PCM16), événements de contrôle (début/fin de parole).
- Serveur → client : chunk audio de réponse, transcription partielle, événement indiquant « le modèle détecte que vous parlez » (pour arrêter automatiquement la sortie).
Client Serveur (GPT-Live)
|--- audio.append (chunk1) -->|
|--- audio.append (chunk2) -->|
| |-- transcript.delta (partiel) -->
|--- audio.append (chunk3) -->|
|<------ response.audio.delta -| (le modèle a commencé à répondre)
|--- audio.append (interrupt) -->|
|<------ response.cancelled ---| (le serveur annule la parole en cours)
Le fait que deux flux audio s’exécutent en parallèle sur un seul socket est la nature de « parler et écouter en même temps ».
4. Configurer les dépendances de connexion WebSocket depuis Spring Boot
Spring Boot supporte nativement les clients WebSocket via spring-websocket. Ajoutez la dépendance :
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-websocket</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-json</artifactId>
</dependency>
Créez une configuration pour la connexion à l’endpoint temps réel (par exemple wss://api.gptlive.example/v1/voice) :
@Configuration
public class VoiceSessionConfig {
@Bean
public StandardWebSocketClient webSocketClient() {
return new StandardWebSocketClient();
}
}
StandardWebSocketClient est l’implémentation standard de Spring, suffisante pour une utilisation client-side sans avoir besoin d’un conteneur servlet.
5. Envoyer des chunks audio et recevoir les réponses en streaming
L’idée clé : chaque fois que le microphone capture un segment audio (par exemple 20 ms), envoyez immédiatement un événement audio.append au serveur, sans attendre une phrase complète.
public class VoiceSessionHandler extends TextWebSocketHandler {
private final ObjectMapper mapper = new ObjectMapper();
public void sendAudioChunk(WebSocketSession session, byte[] pcmChunk) throws IOException {
String base64Audio = Base64.getEncoder().encodeToString(pcmChunk);
Map<String, Object> event = Map.of(
"type", "input_audio.append",
"audio", base64Audio
);
session.sendMessage(new TextMessage(mapper.writeValueAsString(event)));
}
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
JsonNode event = mapper.readTree(message.getPayload());
String type = event.get("type").asText();
switch (type) {
case "response.audio.delta" -> playAudioChunk(event.get("audio").asText());
case "response.text.delta" -> System.out.print(event.get("delta").asText());
case "input_audio.speech_started" -> cancelOngoingPlayback();
default -> { /* ignorer les événements non traités */ }
}
}
private void playAudioChunk(String base64Audio) {
byte[] pcm = Base64.getDecoder().decode(base64Audio);
// envoyer pcm dans le buffer de sortie audio du haut-parleur
}
private void cancelOngoingPlayback() {
// arrêter la lecture audio en cours — c'est le mécanisme d'interruption
}
}
Explication :
sendAudioChunkest appelé continuellement depuis le thread d’enregistrement du microphone (par exemple toutes les 20 ms), sans regroupement par lot pour maintenir une latence faible.handleTextMessageest le callback qui reçoit les événements du serveur ;response.audio.deltasignifie que le modèle est en train de parler, tandis queinput_audio.speech_startedsignifie que le serveur détecte que l’utilisateur commence à parler — à ce moment, le client doit arrêter le haut-parleur immédiatement.event.get("type").asText()utilise JacksonJsonNodepour lire le champtypesans avoir besoin de définir un DTO statique — utile car le schéma d’événement peut être étendu avec de nouveaux types.
6. Mécanisme d’interruption : qui peut parler et quand ?
L’interruption est la capacité de l’utilisateur à interrompre l’IA au milieu d’une phrase. Pour ce faire, le serveur a besoin d’un détecteur d’activité vocale (Voice Activity Detection — VAD) qui s’exécute continuellement sur le flux audio d’entrée, indépendamment du fait que le serveur soit ou non en train de lire de l’audio.
public class TurnStateMachine {
public enum State { IDLE, USER_SPEAKING, MODEL_SPEAKING }
private volatile State state = State.IDLE;
public void onServerEvent(String eventType) {
state = switch (eventType) {
case "input_audio.speech_started" -> {
// l'utilisateur parle pendant que le modèle parle → interrompre
if (state == State.MODEL_SPEAKING) {
stopPlaybackImmediately();
}
yield State.USER_SPEAKING;
}
case "input_audio.speech_stopped" -> State.IDLE;
case "response.audio.delta" -> State.MODEL_SPEAKING;
case "response.done" -> State.IDLE;
default -> state;
};
}
private void stopPlaybackImmediately() {
System.out.println("Arrêt de la lecture audio, envoi de response.cancel au serveur");
}
}
Explication : TurnStateMachine suit qui « tient le microphone » via 3 états. Quand input_audio.speech_started arrive pendant MODEL_SPEAKING, cela signifie que l’utilisateur interrompt — le client doit arrêter le haut-parleur immédiatement et dire au serveur d’annuler la réponse en cours (en envoyant l’événement response.cancel), évitant que l’audio ancien et nouveau se chevauchent.
Remarque : Si vous ne gérez pas l’interruption, l’IA parlera par-dessus l’utilisateur — c’est l’expérience la plus inconfortable d’un assistant vocal. C’est la partie souvent oubliée lors d’une démo rapide mais obligatoire en production.
7. Gestion de session et contexte dans la conversation vocale
Parce que la connexion est avec état (maintenue tout au long de l’appel), vous avez besoin d’un objet session pour stocker l’historique de la conversation et la configuration vocale, séparé de la logique de transport.
public class VoiceSession {
private final String sessionId;
private final List<ConversationTurn> history = new ArrayList<>();
private final String voice; // par exemple « alloy », « verse »
public VoiceSession(String sessionId, String voice) {
this.sessionId = sessionId;
this.voice = voice;
}
public void appendTurn(String role, String transcript) {
history.add(new ConversationTurn(role, transcript, Instant.now()));
}
public record ConversationTurn(String role, String transcript, Instant at) {}
}
Lors de l’initialisation d’une session, vous envoyez un événement session.update pour configurer la voix de lecture et les instructions système avant de commencer l’enregistrement audio :
Map<String, Object> sessionUpdate = Map.of(
"type", "session.update",
"session", Map.of(
"voice", "alloy",
"instructions", "Vous êtes un assistant de prise de rendez-vous médical, répondez brièvement.",
"turn_detection", Map.of("type", "server_vad")
)
);
turn_detection: server_vad signifie que vous déléguez au serveur la détection de la fin de phrase de l’utilisateur via VAD, au lieu que le client devienne le juge en se basant sur les pauses — cette approche est plus précise car le serveur peut utiliser à la fois des signaux sémantiques (par exemple, une phrase grammaticalement incomplète n’est pas considérée comme terminée).
8. Tester la logique de gestion des tours avec JUnit
La partie transport (WebSocket réel) est difficile à tester unitairement, mais la machine d’état qui contrôle les tours de parole est entièrement testable de manière indépendante.
class TurnStateMachineTest {
@Test
void whenModelSpeakingAndUserStarts_thenStopPlayback() {
TurnStateMachine machine = new TurnStateMachine();
machine.onServerEvent("response.audio.delta");
assertThat(machine.getState()).isEqualTo(TurnStateMachine.State.MODEL_SPEAKING);
machine.onServerEvent("input_audio.speech_started");
assertThat(machine.getState()).isEqualTo(TurnStateMachine.State.USER_SPEAKING);
}
@Test
void whenNoOneSpeaks_thenStateIsIdle() {
TurnStateMachine machine = new TurnStateMachine();
machine.onServerEvent("response.done");
assertThat(machine.getState()).isEqualTo(TurnStateMachine.State.IDLE);
}
}
(En supposant que vous ajoutiez le getter getState() à TurnStateMachine.) Le premier test confirme la transition d’état dans le bon ordre lors d’une interruption ; le deuxième test confirme que le système revient à IDLE après la fin de la réponse — c’est la condition pour que la boucle de conversation suivante commence proprement.
9. Erreurs courantes et quand ne pas utiliser
- Envoyer l’audio par gros lots (par exemple une fois par 2 secondes) : élimine tous les avantages du streaming, la latence redevient comme l’ancien pipeline. Envoyez toujours de petits chunks (20–100 ms).
- Ne pas gérer l’événement
response.cancel: si vous ne faites qu’arrêter la lecture audio côté client sans notifier le serveur d’annuler, le serveur continue à générer de l’audio inutile, gaspillant les ressources et pouvant causer une divergence d’état de session. - Utiliser le VAD côté client pour décider soi-même quand l’utilisateur a fini de parler : risque de couper la phrase trop tôt ou trop tard dans un environnement bruyant ; préférez laisser le serveur VAD gérer s’il est supporté par l’API, sauf si vous avez besoin du fonctionnement hors ligne.
- Appliquer à des cas d’usage qui n’ont pas besoin d’interaction en temps réel (par exemple générer des rapports, résumer de longs documents) : les modèles voice temps réel sont beaucoup plus chers et complexes que le pipeline STT/LLM/TTS traditionnel, ce n’est pas justifié d’échanger pour une latence faible dont vous n’avez pas besoin.
- Exécuter le client WebSocket sur le thread principal de l’UI sans séparer les threads d’enregistrement/lecture audio : risque de saccade, blocage quand le parsing JSON entrave le flux audio.
Remarque : La voix full-duplex consomme de la bande passante et des frais API continus pendant tout l’appel, pas seulement le coût par token de texte. Envisagez de limiter la durée de session ou d’ajouter un timeout si inactif.
10. Conclusion
GPT-Live fonctionne sur une connexion WebSocket maintenue ouverte, transférant des chunks audio bidirectionnels et utilisant VAD pour déterminer qui a le tour de parole — c’est le mécanisme qui permet « parler et écouter en même temps » au lieu d’attendre séquentiellement comme l’ancien pipeline STT/LLM/TTS. Lors de l’intégration dans une application Java/Spring Boot, votre travail principal est de gérer la machine d’état des tours de parole, de gérer correctement l’interruption, et de séparer clairement la logique de session du transport pour faciliter les tests. Si votre cas d’usage ne nécessite pas de réponse en temps réel, le pipeline traditionnel reste un choix plus simple et moins coûteux.