import { WebSocket as NodeWebSocket } from "ws"; export const GEMINI_LIVE_URL = "wss://generativelanguage.googleapis.com/ws/google.ai.generativelanguage.v1beta.GenerativeService.BidiGenerateContent"; export const GEMINI_LIVE_MODEL = "models/gemini-2.5-flash-native-audio-latest"; /** Timeout total session WS T2 Live : 3 min 30 (durée TCF) + marge évaluation. */ export const T2_SESSION_TIMEOUT_MS = 210_000; /** Warning au client : 30 s avant le timeout. */ export const T2_SESSION_WARNING_MS = 180_000; /** * Construit le prompt système T2 Live à partir du sujet (role + contexte). * Cf. docs/Prompt_t2live.md §3. */ export function buildT2SystemPrompt(input: { role: string; contexte: string; }): string { const { role, contexte } = input; return `Tu joues le rôle de ${role} dans la situation suivante : ${contexte} Règles à respecter impérativement : - Tu réponds uniquement en français, quelle que soit la langue de ton interlocuteur. - Tu joues ton rôle de façon naturelle et réaliste. Tu n'es pas un examinateur — tu es ${role}. - Tu réponds aux questions qu'on te pose de façon honnête et naturelle, comme le ferait une vraie personne dans cette situation. - Tu ne facilites pas la tâche : tu ne reformules pas les questions, tu n'anticipes pas ce que l'interlocuteur veut savoir, tu ne lui suggères pas quoi demander. - Si ton interlocuteur marque une longue pause ou semble avoir terminé, tu peux dire : "Avez-vous d'autres questions ?" — c'est la seule relance autorisée. - Tu ne fais aucun commentaire sur la langue, les erreurs ou le niveau de français de ton interlocuteur. - Tu ne sors jamais de ton rôle. - Tu ne prends PAS la parole en premier. Tu attends que ton interlocuteur s'adresse à toi, puis tu réponds naturellement dans ton rôle. - Tes réponses sont concises et naturelles : ni monosyllabiques, ni des monologues.`; } /** * Subset minimal d'une WebSocket — compatible avec : * - le wrapper exposé par @hono/node-ws (côté client navigateur) * - la WebSocket de `ws` (côté Gemini) * - les fakes basés sur EventEmitter dans les tests */ export interface WebSocketLike { send(data: unknown): void; close(code?: number, reason?: string): void; on(event: "message", listener: (data: unknown) => void): void; on(event: "close", listener: (code?: number, reason?: unknown) => void): void; on(event: "error", listener: (err: unknown) => void): void; on(event: "open", listener: () => void): void; } export interface OpenGeminiLiveSessionOptions { /** Rôle joué par l'IA, injecté dans le prompt système. */ role: string; /** Contexte de la situation, injecté dans le prompt système. */ contexte: string; /** Callback déclenché en fin de session avec le transcript reconstruit. */ onSessionEnd?: (transcript: string) => void | Promise; /** Override timeout (par défaut T2_SESSION_TIMEOUT_MS). */ timeoutMs?: number; /** Override warning (par défaut T2_SESSION_WARNING_MS). */ warningMs?: number; /** Injection pour les tests — fabrique de WebSocket vers Gemini. */ geminiFactory?: (url: string) => WebSocketLike; /** Surcharge la clé API (par défaut : process.env.GEMINI_API_KEY). */ apiKey?: string; } function buildSetupFrame(systemPrompt: string): string { return JSON.stringify({ setup: { model: GEMINI_LIVE_MODEL, systemInstruction: { parts: [{ text: systemPrompt }], }, generationConfig: { responseModalities: ["AUDIO"], }, inputAudioTranscription: {}, outputAudioTranscription: {}, realtimeInputConfig: { automaticActivityDetection: { disabled: false, startOfSpeechSensitivity: "START_SENSITIVITY_LOW", endOfSpeechSensitivity: "END_SENSITIVITY_LOW", silenceDurationMs: 2000, }, }, }, }); } interface TranscriptEntry { speaker: "candidat" | "examinateur"; text: string; } function reconstructTranscript(entries: TranscriptEntry[]): string { return entries .map((e) => e.speaker === "candidat" ? `Candidat : ${e.text}` : `Examinateur : ${e.text}`, ) .join("\n"); } /** * Tente de parser un message Gemini en JSON pour en extraire les transcripts. * Retourne null si non-JSON (chunks audio binaires). */ function tryParseGeminiMessage(data: unknown): { inputText?: string; outputText?: string; } | null { let text: string; if (typeof data === "string") { text = data; } else if (data instanceof Buffer) { // Heuristique : tenter de parser comme JSON UTF-8 ; si ça échoue, c'est binaire. try { text = data.toString("utf8"); if (!text.startsWith("{")) return null; } catch { return null; } } else if (typeof data === "object" && data !== null && "toString" in data) { try { text = (data as { toString: () => string }).toString(); if (!text.startsWith("{")) return null; } catch { return null; } } else { return null; } try { const parsed = JSON.parse(text) as { serverContent?: { inputTranscription?: { text?: string }; outputTranscription?: { text?: string }; }; }; const sc = parsed.serverContent; if (!sc) return {}; return { inputText: sc.inputTranscription?.text, outputText: sc.outputTranscription?.text, }; } catch { return null; } } /** * Détecte un signal de fin de session envoyé par le client : `{type:'end'}`. */ function isEndSignal(data: unknown): boolean { let text: string; if (typeof data === "string") { text = data; } else if (data instanceof Buffer) { try { text = data.toString("utf8"); } catch { return false; } } else { return false; } if (!text.startsWith("{")) return false; try { const parsed = JSON.parse(text) as { type?: string }; return parsed.type === "end"; } catch { return false; } } /** * Ouvre une session Gemini Live et proxifie les messages * dans les deux sens entre le client (navigateur) et Gemini. * * - À l'open Gemini : envoie le setup frame avec prompt dynamique + VAD * + inputAudioTranscription + outputAudioTranscription. * - Forward transparent des frames audio dans les deux directions. * - Accumule les transcripts (input = candidat, output = examinateur IA). * - Détecte signal client `{type:'end'}` → déclenche fin de session. * - Timeout 210 s : warning client à 180 s, fin auto à 210 s. * - En fin de session : appelle `onSessionEnd(transcript)` puis ferme Gemini. * Le client WS n'est PAS fermé ici — c'est l'appelant qui décide (envoi du * rapport puis close 1000). * - Erreur Gemini → close client 4006 GEMINI_DISCONNECTED. * - GEMINI_API_KEY absente → close client 4005 GEMINI_CONFIG. */ export function openGeminiLiveSession( clientWs: WebSocketLike, opts: OpenGeminiLiveSessionOptions, ): void { const apiKey = opts.apiKey ?? process.env.GEMINI_API_KEY; if (!apiKey) { clientWs.close(4005, "GEMINI_CONFIG"); return; } const timeoutMs = opts.timeoutMs ?? T2_SESSION_TIMEOUT_MS; const warningMs = opts.warningMs ?? T2_SESSION_WARNING_MS; const systemPrompt = buildT2SystemPrompt({ role: opts.role, contexte: opts.contexte, }); const url = `${GEMINI_LIVE_URL}?key=${apiKey}`; const factory = opts.geminiFactory ?? ((u: string) => new NodeWebSocket(u) as unknown as WebSocketLike); const geminiWs = factory(url); const transcriptEntries: TranscriptEntry[] = []; let sessionEnded = false; let warningTimer: ReturnType | null = null; let timeoutTimer: ReturnType | null = null; const clearTimers = () => { if (warningTimer !== null) { clearTimeout(warningTimer); warningTimer = null; } if (timeoutTimer !== null) { clearTimeout(timeoutTimer); timeoutTimer = null; } }; const endSession = async () => { if (sessionEnded) return; sessionEnded = true; clearTimers(); try { geminiWs.close(1000); } catch { /* ignore */ } if (opts.onSessionEnd) { try { await opts.onSessionEnd(reconstructTranscript(transcriptEntries)); } catch (err) { console.error( "[T2] onSessionEnd threw:", err instanceof Error ? err.message : String(err), ); } } }; geminiWs.on("open", () => { console.log("[T2] Gemini WS opened"); try { geminiWs.send(buildSetupFrame(systemPrompt)); console.log("[T2] Setup frame sent"); // Démarrer les timers une fois la session Gemini effectivement ouverte. warningTimer = setTimeout(() => { if (sessionEnded) return; try { clientWs.send( JSON.stringify({ type: "warning", message: "30 secondes restantes", }), ); } catch { /* ignore */ } }, warningMs); timeoutTimer = setTimeout(() => { void endSession(); }, timeoutMs); } catch { try { clientWs.close(4005, "GEMINI_CONFIG"); } catch { /* ignore */ } } }); geminiWs.on("message", (data) => { // Tentative d'extraction des transcripts — si JSON, on accumule ; // dans tous les cas (JSON ou audio binaire), on forward au client. const parsed = tryParseGeminiMessage(data); if (parsed) { if (parsed.inputText && parsed.inputText.length > 0) { transcriptEntries.push({ speaker: "candidat", text: parsed.inputText, }); } if (parsed.outputText && parsed.outputText.length > 0) { transcriptEntries.push({ speaker: "examinateur", text: parsed.outputText, }); } } try { clientWs.send(data); } catch { void endSession(); } }); clientWs.on("message", (data) => { if (isEndSignal(data)) { void endSession(); return; } try { geminiWs.send(data); } catch { void endSession(); } }); geminiWs.on("close", () => { console.log("[T2] Gemini closed"); if (!sessionEnded) { clearTimers(); try { clientWs.close(4006, "GEMINI_DISCONNECTED"); } catch { /* ignore */ } } }); clientWs.on("close", () => { clearTimers(); sessionEnded = true; try { geminiWs.close(1000); } catch { /* ignore */ } }); geminiWs.on("error", (err) => { console.log("[T2] Gemini error:", (err as Error)?.message); if (!sessionEnded) { clearTimers(); sessionEnded = true; try { clientWs.close(4006, "GEMINI_DISCONNECTED"); } catch { /* ignore */ } } }); clientWs.on("error", () => { clearTimers(); sessionEnded = true; try { geminiWs.close(1011); } catch { /* ignore */ } }); }