diff --git a/src/lib/__tests__/geminiLive.test.ts b/src/lib/__tests__/geminiLive.test.ts index 90bea3a..f3250c1 100644 --- a/src/lib/__tests__/geminiLive.test.ts +++ b/src/lib/__tests__/geminiLive.test.ts @@ -1,53 +1,9 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { EventEmitter } from "node:events"; - -// ─── Mock du SDK @google/genai ─────────────────────────────────────────────── -// -// On capture les callbacks passés à `ai.live.connect` pour pouvoir simuler les -// événements (onopen, onmessage, onerror, onclose) depuis les tests. La -// fabrique `clientFactory` injectée dans openGeminiLiveSession permet de -// remplacer `new GoogleGenAI(...)` par un stub. - -interface CapturedConnect { - model: string; - config: Record; - callbacks: { - onopen?: () => void; - onmessage?: (msg: unknown) => void; - onerror?: (err: unknown) => void; - onclose?: (evt: unknown) => void; - }; - session: { - sendRealtimeInput: ReturnType; - close: ReturnType; - }; -} - -let capturedConnect: CapturedConnect | null = null; - -function makeFakeClient() { - return { - live: { - connect: vi.fn(async (params: CapturedConnect) => { - const session = { - sendRealtimeInput: vi.fn(), - close: vi.fn(), - }; - capturedConnect = { - model: params.model, - config: params.config, - callbacks: params.callbacks, - session, - }; - return session; - }), - }, - }; -} - import { openGeminiLiveSession, buildT2SystemPrompt, + GEMINI_LIVE_MODEL, type WebSocketLike, } from "../geminiLive"; @@ -75,33 +31,6 @@ const SUJET_OPTS = { "Vous cherchez un appartement de 2 pièces dans le centre-ville, votre budget est limité et vous souhaitez emménager le mois prochain.", }; -/** Helper : ouvre une session avec un client mocké et retourne la capture. */ -async function openWithMock( - client: FakeWs, - extra: Partial<{ - onSessionEnd: (transcript: string) => void | Promise; - timeoutMs: number; - warningMs: number; - }> = {}, -) { - capturedConnect = null; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - openGeminiLiveSession(client, { - ...SUJET_OPTS, - apiKey: "test-key", - clientFactory: () => makeFakeClient() as any, - ...extra, - }); - // Le `await live.connect()` est dans un `.then()` du code prod ; on laisse - // les microtasks se vider avant de retourner la capture. - await Promise.resolve(); - await Promise.resolve(); - if (!capturedConnect) { - throw new Error("Le mock du SDK n'a pas capturé de connect()"); - } - return capturedConnect; -} - describe("buildT2SystemPrompt", () => { it("substitue role et contexte dans le template", () => { const prompt = buildT2SystemPrompt(SUJET_OPTS); @@ -114,87 +43,117 @@ describe("buildT2SystemPrompt", () => { }); }); -describe("openGeminiLiveSession (SDK)", () => { +describe("openGeminiLiveSession (raw WS)", () => { + let originalKey: string | undefined; + beforeEach(() => { + originalKey = process.env.GEMINI_API_KEY; + process.env.GEMINI_API_KEY = "test-key"; vi.useFakeTimers(); }); afterEach(() => { + if (originalKey === undefined) { + delete process.env.GEMINI_API_KEY; + } else { + process.env.GEMINI_API_KEY = originalKey; + } vi.useRealTimers(); vi.restoreAllMocks(); - capturedConnect = null; }); - it("appelle live.connect avec une config minimale (debug Sprint 6d — isolement champ rejeté)", async () => { + it("envoie le setup frame minimal à l'open Gemini (model + responseModalities AUDIO)", () => { const client = new FakeWs(); - const capture = await openWithMock(client); + const gemini = new FakeWs(); + openGeminiLiveSession(client, { + ...SUJET_OPTS, + clientFactory: () => gemini, + }); + gemini.emit("open"); - expect(capture.model).toMatch(/gemini/); - const config = capture.config; - expect(config.responseModalities).toContain("AUDIO"); - // ⚠ DEBUG : les autres champs sont temporairement commentés dans - // geminiLive.ts pour isoler celui qui fait rejeter le setup par Gemini. - expect(config.systemInstruction).toBeUndefined(); - expect(config.inputAudioTranscription).toBeUndefined(); - expect(config.outputAudioTranscription).toBeUndefined(); - expect(config.realtimeInputConfig).toBeUndefined(); + expect(gemini.sent).toHaveLength(1); + const setup = JSON.parse(gemini.sent[0] as string); + expect(setup.setup.model).toBe(`models/${GEMINI_LIVE_MODEL}`); + expect(setup.setup.generationConfig.responseModalities).toContain("AUDIO"); + // ⚠ DEBUG : champs volontairement absents tant que setupComplete n'est pas + // confirmé en prod. Réintégration champ par champ ensuite. + expect(setup.setup.systemInstruction).toBeUndefined(); + expect(setup.setup.inputAudioTranscription).toBeUndefined(); + expect(setup.setup.outputAudioTranscription).toBeUndefined(); + expect(setup.setup.realtimeInputConfig).toBeUndefined(); }); - it("forwarde un chunk audio client {type:'audio'} via session.sendRealtimeInput (PCM 16k base64)", async () => { + it("forwarde un chunk audio client {type:'audio'} en realtimeInput vers Gemini", () => { const client = new FakeWs(); - const capture = await openWithMock(client); - capture.callbacks.onopen?.(); + const gemini = new FakeWs(); + openGeminiLiveSession(client, { + ...SUJET_OPTS, + clientFactory: () => gemini, + }); + gemini.emit("open"); - const base64 = "AQIDBA=="; // base64 de [1,2,3,4] + const base64 = "AQIDBA=="; client.emit("message", JSON.stringify({ type: "audio", data: base64 })); - expect(capture.session.sendRealtimeInput).toHaveBeenCalledTimes(1); - expect(capture.session.sendRealtimeInput).toHaveBeenCalledWith({ - audio: { data: base64, mimeType: "audio/pcm;rate=16000" }, + // [0] = setup frame, [1] = realtimeInput audio + expect(gemini.sent).toHaveLength(2); + const audioFrame = JSON.parse(gemini.sent[1] as string); + expect(audioFrame).toEqual({ + realtimeInput: { + audio: { data: base64, mimeType: "audio/pcm;rate=16000" }, + }, }); }); - it("forwarde un message Gemini (audio inlineData) au client en JSON", async () => { + it("forwarde un message Gemini (Buffer audio inlineData) verbatim au client", () => { const client = new FakeWs(); - const capture = await openWithMock(client); - capture.callbacks.onopen?.(); + const gemini = new FakeWs(); + openGeminiLiveSession(client, { + ...SUJET_OPTS, + clientFactory: () => gemini, + }); + gemini.emit("open"); - const geminiMsg = { - serverContent: { - modelTurn: { - parts: [ - { - inlineData: { data: "EAYE", mimeType: "audio/pcm;rate=24000" }, - }, - ], - }, - }, - }; - capture.callbacks.onmessage?.(geminiMsg); + const buf = Buffer.from([0x10, 0x20, 0x30]); + gemini.emit("message", buf); expect(client.sent).toHaveLength(1); - expect(JSON.parse(client.sent[0] as string)).toEqual(geminiMsg); + expect(client.sent[0]).toBe(buf); }); - it("accumule input/outputTranscription et reconstruit le transcript chronologique", async () => { + it("accumule input/outputTranscription depuis les messages JSON Gemini", async () => { const client = new FakeWs(); + const gemini = new FakeWs(); const onSessionEnd = vi.fn(); - const capture = await openWithMock(client, { onSessionEnd }); - capture.callbacks.onopen?.(); + openGeminiLiveSession(client, { + ...SUJET_OPTS, + clientFactory: () => gemini, + onSessionEnd, + }); + gemini.emit("open"); - capture.callbacks.onmessage?.({ - serverContent: { - inputTranscription: { text: "Bonjour, je voudrais louer." }, - }, - }); - capture.callbacks.onmessage?.({ - serverContent: { - outputTranscription: { text: "Bonjour, c’est pour quel quartier ?" }, - }, - }); - capture.callbacks.onmessage?.({ - serverContent: { inputTranscription: { text: "Le centre-ville." } }, - }); + gemini.emit( + "message", + JSON.stringify({ + serverContent: { + inputTranscription: { text: "Bonjour, je voudrais louer." }, + }, + }), + ); + gemini.emit( + "message", + JSON.stringify({ + serverContent: { + outputTranscription: { text: "Bonjour, c’est pour quel quartier ?" }, + }, + }), + ); + gemini.emit( + "message", + JSON.stringify({ + serverContent: { inputTranscription: { text: "Le centre-ville." } }, + }), + ); client.emit("message", JSON.stringify({ type: "end" })); await vi.runAllTimersAsync(); @@ -205,24 +164,35 @@ describe("openGeminiLiveSession (SDK)", () => { ); }); - it("ferme la session SDK après onSessionEnd, sans fermer le client", async () => { + it("ferme Gemini après onSessionEnd, sans fermer le client", async () => { const client = new FakeWs(); + const gemini = new FakeWs(); const onSessionEnd = vi.fn(); - const capture = await openWithMock(client, { onSessionEnd }); - capture.callbacks.onopen?.(); + openGeminiLiveSession(client, { + ...SUJET_OPTS, + clientFactory: () => gemini, + onSessionEnd, + }); + gemini.emit("open"); client.emit("message", JSON.stringify({ type: "end" })); await vi.runAllTimersAsync(); - expect(capture.session.close).toHaveBeenCalledTimes(1); + expect(gemini.closed).toBe(true); + expect(gemini.closeCode).toBe(1000); expect(client.closed).toBe(false); }); it("warning à 180 s puis timeout à 210 s déclenche endSession", async () => { const client = new FakeWs(); + const gemini = new FakeWs(); const onSessionEnd = vi.fn(); - const capture = await openWithMock(client, { onSessionEnd }); - capture.callbacks.onopen?.(); + openGeminiLiveSession(client, { + ...SUJET_OPTS, + clientFactory: () => gemini, + onSessionEnd, + }); + gemini.emit("open"); await vi.advanceTimersByTimeAsync(180_000); const warningFrame = client.sent.find( @@ -237,14 +207,19 @@ describe("openGeminiLiveSession (SDK)", () => { await vi.advanceTimersByTimeAsync(30_000); expect(onSessionEnd).toHaveBeenCalledTimes(1); - expect(capture.session.close).toHaveBeenCalled(); + expect(gemini.closed).toBe(true); }); it("signal end client est idempotent (un seul onSessionEnd)", async () => { const client = new FakeWs(); + const gemini = new FakeWs(); const onSessionEnd = vi.fn(); - const capture = await openWithMock(client, { onSessionEnd }); - capture.callbacks.onopen?.(); + openGeminiLiveSession(client, { + ...SUJET_OPTS, + clientFactory: () => gemini, + onSessionEnd, + }); + gemini.emit("open"); client.emit("message", JSON.stringify({ type: "end" })); client.emit("message", JSON.stringify({ type: "end" })); @@ -253,47 +228,45 @@ describe("openGeminiLiveSession (SDK)", () => { expect(onSessionEnd).toHaveBeenCalledTimes(1); }); - it("onclose SDK avant fin → close client 4006 GEMINI_DISCONNECTED", async () => { + it("close Gemini avant fin → close client 4006 GEMINI_DISCONNECTED", () => { const client = new FakeWs(); - const capture = await openWithMock(client); - capture.callbacks.onopen?.(); - - capture.callbacks.onclose?.({ code: 1000 }); + const gemini = new FakeWs(); + openGeminiLiveSession(client, { + ...SUJET_OPTS, + clientFactory: () => gemini, + }); + gemini.emit("open"); + gemini.emit("close", 1006, Buffer.from("")); expect(client.closed).toBe(true); expect(client.closeCode).toBe(4006); expect(client.closeReason).toBe("GEMINI_DISCONNECTED"); }); - it("onerror SDK → close client 4006", async () => { + it("error Gemini → close client 4006", () => { const client = new FakeWs(); - const capture = await openWithMock(client); - capture.callbacks.onopen?.(); - - capture.callbacks.onerror?.(new Error("boom")); + const gemini = new FakeWs(); + openGeminiLiveSession(client, { + ...SUJET_OPTS, + clientFactory: () => gemini, + }); + gemini.emit("open"); + gemini.emit("error", new Error("boom")); expect(client.closed).toBe(true); expect(client.closeCode).toBe(4006); }); - it("absence de GEMINI_API_KEY → close client 4005 GEMINI_CONFIG sans appel à live.connect", () => { - const originalKey = process.env.GEMINI_API_KEY; + it("absence de GEMINI_API_KEY → close client 4005 GEMINI_CONFIG sans appel à la factory", () => { delete process.env.GEMINI_API_KEY; - capturedConnect = null; const client = new FakeWs(); - const factory = vi.fn(() => makeFakeClient()); + const factory = vi.fn(() => new FakeWs()); - openGeminiLiveSession(client, { - ...SUJET_OPTS, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - clientFactory: factory as any, - }); + openGeminiLiveSession(client, { ...SUJET_OPTS, clientFactory: factory }); expect(factory).not.toHaveBeenCalled(); expect(client.closed).toBe(true); expect(client.closeCode).toBe(4005); expect(client.closeReason).toBe("GEMINI_CONFIG"); - - if (originalKey !== undefined) process.env.GEMINI_API_KEY = originalKey; }); }); diff --git a/src/lib/geminiLive.ts b/src/lib/geminiLive.ts index 92be3dd..2c30304 100644 --- a/src/lib/geminiLive.ts +++ b/src/lib/geminiLive.ts @@ -1,36 +1,28 @@ /** - * geminiLive.ts — Sprint 6d. + * geminiLive.ts — Sprint 6d (revert WS brut). * - * Migration du WebSocket brut (`wss://generativelanguage.googleapis.com/...`) - * vers le SDK officiel `@google/genai` v1.50.x. Motif : Google a migré les - * clés API vers le mode "Vertex AI Express", incompatible avec l'endpoint WS - * historique (réponse 403 systématique). Le SDK gère l'auth automatiquement - * et accepte les clés Express bound à un service account. + * Le SDK `@google/genai` fermait la session sans setupComplete ni raison + * exploitable. On revient au WebSocket brut (package `ws`) qui était utilisé + * par `test-gemini-live.js` et permet de loguer précisément ce que Gemini + * répond. Config setup réduite au strict minimum tant que `setupComplete` + * n'est pas confirmé en prod ; on réintègre champs un par un ensuite. * - * Interface publique (consommée par `routes/t2live.ts`) : - * - openGeminiLiveSession(clientWs, opts) : ouvre une session Live et - * proxifie les messages dans les deux sens entre le client (navigateur) - * et Gemini, accumule les transcripts, gère timeouts + close codes. - * - WebSocketLike : interface minimale pour le client WS (Hono adapter). - * - buildT2SystemPrompt({role, contexte}) : prompt dynamique T2 Live. - * - GEMINI_LIVE_MODEL, T2_SESSION_TIMEOUT_MS, T2_SESSION_WARNING_MS. - * - * Cf. docs/IMPLEMENTATION_T2_LIVE.md §3, docs/Prompt_t2live.md §3. + * Interface publique (consommée par `routes/t2live.ts`) — INCHANGÉE : + * - openGeminiLiveSession(clientWs, opts) + * - WebSocketLike, OpenGeminiLiveSessionOptions + * - buildT2SystemPrompt({role, contexte}) + * - GEMINI_LIVE_MODEL, T2_SESSION_TIMEOUT_MS, T2_SESSION_WARNING_MS */ -import { - GoogleGenAI, - Modality, - StartSensitivity, - EndSensitivity, - type Session, -} from "@google/genai"; +import { WebSocket as NodeWebSocket } from "ws"; + +export const GEMINI_LIVE_URL = + "wss://generativelanguage.googleapis.com/ws/google.ai.generativelanguage.v1beta.GenerativeService.BidiGenerateContent"; /** - * Modèle Live cible. `gemini-3.1-flash-live-preview` est le choix par défaut - * (Sprint 6d), à valider sur Express Mode via `test-gemini-live.js`. Fallback - * documenté : `gemini-2.0-flash-live-001` (modèle Live garanti sur Express - * d'après la doc Vertex Express). + * Modèle Live cible. `gemini-2.0-flash-live-001` est le modèle Live confirmé + * par la doc Google pour les clés API Developer + Express. Format `models/...` + * dans le setup frame natif (cf. `test-gemini-live.js`). */ export const GEMINI_LIVE_MODEL = "gemini-2.0-flash-live-001"; @@ -41,7 +33,8 @@ 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. + * Cf. docs/Prompt_t2live.md §3. Conservé en signature pour usage futur quand + * `systemInstruction` sera réintégré dans le setup frame. */ export function buildT2SystemPrompt(input: { role: string; @@ -65,6 +58,7 @@ Règles à respecter impérativement : /** * 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 { @@ -90,17 +84,16 @@ export interface OpenGeminiLiveSessionOptions { /** Surcharge la clé API (par défaut : process.env.GEMINI_API_KEY). */ apiKey?: string; /** - * Injection pour les tests — fabrique de client SDK. Permet de remplacer - * `new GoogleGenAI(...)` par un mock dans les tests sans toucher au code prod. + * Injection pour les tests — fabrique de WebSocket vers Gemini. */ - clientFactory?: (apiKey: string) => GoogleGenAI; + clientFactory?: (url: string) => WebSocketLike; } /** - * Forme minimale d'un message Live retourné par le SDK. On n'exporte pas - * `LiveServerMessage` du SDK pour ne pas coupler les tests à son shape exact. + * Forme minimale d'un message Gemini Live JSON entrant. */ -interface LiveServerMessage { +interface GeminiServerMessage { + setupComplete?: unknown; serverContent?: { modelTurn?: { parts?: Array<{ @@ -112,7 +105,6 @@ interface LiveServerMessage { interrupted?: boolean; turnComplete?: boolean; }; - setupComplete?: unknown; } interface TranscriptEntry { @@ -185,24 +177,70 @@ function parseAudioChunk(data: unknown): string | null { } /** - * Ouvre une session Gemini Live via le SDK et proxifie les messages - * dans les deux sens entre le client (navigateur) et Gemini. + * Tente de parser un message Gemini en JSON. Retourne null si binaire / non-JSON. + */ +function tryParseGeminiJson(data: unknown): GeminiServerMessage | null { + let text: string; + if (typeof data === "string") { + text = data; + } else if (data instanceof Buffer) { + 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 { + return JSON.parse(text) as GeminiServerMessage; + } catch { + return null; + } +} + +/** + * Construit le setup frame minimal Gemini Live (équivalent du mode + * `minimal` de `test-gemini-live.js`). Les champs `systemInstruction`, + * `inputAudioTranscription`, `outputAudioTranscription`, + * `realtimeInputConfig.automaticActivityDetection` sont volontairement + * retirés tant que `setupComplete` n'est pas confirmé en prod. + */ +function buildSetupFrame(): string { + return JSON.stringify({ + setup: { + model: `models/${GEMINI_LIVE_MODEL}`, + generationConfig: { + responseModalities: ["AUDIO"], + }, + }, + }); +} + +/** + * Ouvre une session Gemini Live via WebSocket brut (`ws://...?key=...`) et + * proxifie les messages dans les deux sens entre le client (navigateur) et + * Gemini. * - * - Init : `new GoogleGenAI({ vertexai: true, apiKey })` → mode Vertex Express - * (compatible avec les clés API auto-bound à un service account). - * - Setup config : modèle + responseModalities AUDIO + systemInstruction - * + inputAudioTranscription + outputAudioTranscription + VAD. + * - URL : GEMINI_LIVE_URL?key=apiKey + * - À l'open Gemini : envoi du setup frame minimal. * - Forward client → Gemini : parse `{type:'audio', data: base64}` → - * `session.sendRealtimeInput({audio: {data, mimeType: 'audio/pcm;rate=16000'}})`. - * - Forward Gemini → client : `clientWs.send(JSON.stringify(msg))` (le frontend - * parse `serverContent.modelTurn.parts[].inlineData.data`). + * message JSON `{ realtimeInput: { audio: { data, mimeType } } }`. + * - Forward Gemini → client : forward verbatim (string ou Buffer). * - Accumule input/outputTranscription pour la correction finale. * - Détecte `{type:'end'}` du client → fin de session. * - Timer 210 s : warning à 180 s, fin auto à 210 s. - * - En fin : `onSessionEnd(transcript)` puis ferme la session SDK. Le client WS - * n'est PAS fermé ici — c'est l'appelant qui décide (envoi du rapport puis - * close 1000). - * - Erreur SDK / close Gemini → close client 4006 GEMINI_DISCONNECTED. + * - En fin : `onSessionEnd(transcript)` puis ferme Gemini. Le client WS + * n'est PAS fermé ici — c'est l'appelant qui décide. + * - Erreur Gemini / close prématurée → close client 4006 GEMINI_DISCONNECTED. * - GEMINI_API_KEY absente → close client 4005 GEMINI_CONFIG. */ export function openGeminiLiveSession( @@ -218,18 +256,27 @@ export function openGeminiLiveSession( const timeoutMs = opts.timeoutMs ?? T2_SESSION_TIMEOUT_MS; const warningMs = opts.warningMs ?? T2_SESSION_WARNING_MS; - const systemPrompt = buildT2SystemPrompt({ + // Conservé en signature pour usage futur (réintégration `systemInstruction`). + const _systemPrompt = buildT2SystemPrompt({ role: opts.role, contexte: opts.contexte, }); + void _systemPrompt; - const ai = opts.clientFactory?.(apiKey) ?? new GoogleGenAI({ apiKey }); + const url = `${GEMINI_LIVE_URL}?key=${apiKey}`; + const factory = + opts.clientFactory ?? + ((u: string) => new NodeWebSocket(u) as unknown as WebSocketLike); + + console.log("[T2] Gemini WS URL:", GEMINI_LIVE_URL + "?key=***"); + console.log("[T2] Gemini WS model:", GEMINI_LIVE_MODEL); + + const geminiWs = factory(url); const transcriptEntries: TranscriptEntry[] = []; let sessionEnded = false; let warningTimer: ReturnType | null = null; let timeoutTimer: ReturnType | null = null; - let session: Session | null = null; const clearTimers = () => { if (warningTimer !== null) { @@ -246,12 +293,10 @@ export function openGeminiLiveSession( if (sessionEnded) return; sessionEnded = true; clearTimers(); - if (session) { - try { - session.close(); - } catch { - /* ignore */ - } + try { + geminiWs.close(1000); + } catch { + /* ignore */ } if (opts.onSessionEnd) { try { @@ -265,130 +310,123 @@ export function openGeminiLiveSession( } }; - const handleSdkMessage = (msg: LiveServerMessage) => { - // Accumuler transcripts pour la correction finale. - const sc = msg.serverContent; - if (sc?.inputTranscription?.text && sc.inputTranscription.text.length > 0) { - transcriptEntries.push({ - speaker: "candidat", - text: sc.inputTranscription.text, - }); - } - if ( - sc?.outputTranscription?.text && - sc.outputTranscription.text.length > 0 - ) { - transcriptEntries.push({ - speaker: "examinateur", - text: sc.outputTranscription.text, - }); - } - - // Forward verbatim au client. Le frontend parse serverContent.modelTurn. + geminiWs.on("open", () => { + console.log("[T2] Gemini WS open"); + const frame = buildSetupFrame(); + console.log("[T2] Gemini setup frame:", frame); try { - clientWs.send(JSON.stringify(msg)); - } catch { - void endSession(); - } - }; - - // ── Ouverture de la session SDK ────────────────────────────────────── - // ⚠ DEBUG : config minimale pour isoler le champ qui fait rejeter le setup - // par Gemini. À restaurer une fois identifié. - // Variables conservées en signature pour ne pas casser les imports / la - // construction du prompt qui valide le sujet. - void systemPrompt; - void StartSensitivity; - void EndSensitivity; - const sdkConfig = { - responseModalities: [Modality.AUDIO], - // systemInstruction: systemPrompt, - // inputAudioTranscription: {}, - // outputAudioTranscription: {}, - // realtimeInputConfig: { - // automaticActivityDetection: { - // disabled: false, - // startOfSpeechSensitivity: StartSensitivity.START_SENSITIVITY_LOW, - // endOfSpeechSensitivity: EndSensitivity.END_SENSITIVITY_LOW, - // silenceDurationMs: 2000, - // }, - // }, - }; - - console.log("[T2] SDK config:", JSON.stringify(sdkConfig, null, 2)); - console.log("[T2] SDK model:", GEMINI_LIVE_MODEL); - - ai.live - .connect({ - model: GEMINI_LIVE_MODEL, - config: sdkConfig, - callbacks: { - onopen: () => { - console.log("[T2] Gemini SDK onopen"); - // Démarrer les timers une fois la session 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); - }, - onmessage: (msg: LiveServerMessage) => { - console.log( - "[T2] Gemini SDK message:", - JSON.stringify(msg).substring(0, 200), - ); - handleSdkMessage(msg); - }, - onerror: (err: unknown) => { - console.log("[T2] Gemini SDK error:", JSON.stringify(err)); - if (!sessionEnded) { - clearTimers(); - sessionEnded = true; - try { - clientWs.close(4006, "GEMINI_DISCONNECTED"); - } catch { - /* ignore */ - } - } - }, - onclose: (e: unknown) => { - console.log("[T2] Gemini SDK close:", JSON.stringify(e)); - if (!sessionEnded) { - clearTimers(); - try { - clientWs.close(4006, "GEMINI_DISCONNECTED"); - } catch { - /* ignore */ - } - } - }, - }, - }) - .then((s: Session) => { - session = s; - }) - .catch((err: unknown) => { - console.error("[T2] SDK connect error:", err); - sessionEnded = true; - clearTimers(); + geminiWs.send(frame); + } catch (err) { + console.error( + "[T2] Gemini setup frame send failed:", + err instanceof Error ? err.message : String(err), + ); try { clientWs.close(4006, "GEMINI_DISCONNECTED"); } catch { /* ignore */ } - }); + return; + } + + // Timers démarrés à l'ouverture de la WS (avant setupComplete éventuel). + warningTimer = setTimeout(() => { + if (sessionEnded) return; + try { + clientWs.send( + JSON.stringify({ + type: "warning", + message: "30 secondes restantes", + }), + ); + } catch { + /* ignore */ + } + }, warningMs); + + timeoutTimer = setTimeout(() => { + void endSession(); + }, timeoutMs); + }); + + geminiWs.on("message", (data) => { + const preview = + typeof data === "string" + ? data.slice(0, 300) + : data instanceof Buffer + ? data.toString("utf8").slice(0, 300) + : "[binary]"; + console.log("[T2] Gemini WS message:", preview); + + // Accumuler input/outputTranscription. + const parsed = tryParseGeminiJson(data); + if (parsed) { + const sc = parsed.serverContent; + if ( + sc?.inputTranscription?.text && + sc.inputTranscription.text.length > 0 + ) { + transcriptEntries.push({ + speaker: "candidat", + text: sc.inputTranscription.text, + }); + } + if ( + sc?.outputTranscription?.text && + sc.outputTranscription.text.length > 0 + ) { + transcriptEntries.push({ + speaker: "examinateur", + text: sc.outputTranscription.text, + }); + } + } + + // Forward verbatim au client (string ou Buffer audio inlineData). + try { + clientWs.send(data); + } catch { + void endSession(); + } + }); + + geminiWs.on("close", (code, reason) => { + const reasonStr = + reason instanceof Buffer + ? reason.toString("utf8") + : typeof reason === "string" + ? reason + : ""; + console.log( + "[T2] Gemini WS close:", + JSON.stringify({ code, reason: reasonStr }), + ); + if (!sessionEnded) { + clearTimers(); + sessionEnded = true; + try { + clientWs.close(4006, "GEMINI_DISCONNECTED"); + } catch { + /* ignore */ + } + } + }); + + geminiWs.on("error", (err) => { + console.log( + "[T2] Gemini WS error:", + JSON.stringify(err instanceof Error ? { message: err.message } : err), + ); + if (!sessionEnded) { + clearTimers(); + sessionEnded = true; + try { + clientWs.close(4006, "GEMINI_DISCONNECTED"); + } catch { + /* ignore */ + } + } + }); // ── Forward client → Gemini ────────────────────────────────────────── clientWs.on("message", (data) => { @@ -397,46 +435,46 @@ export function openGeminiLiveSession( return; } const audioBase64 = parseAudioChunk(data); - if (audioBase64 !== null && session !== null && !sessionEnded) { + if (audioBase64 !== null && !sessionEnded) { try { - session.sendRealtimeInput({ - audio: { - data: audioBase64, - mimeType: "audio/pcm;rate=16000", - }, - }); + geminiWs.send( + JSON.stringify({ + realtimeInput: { + audio: { + data: audioBase64, + mimeType: "audio/pcm;rate=16000", + }, + }, + }), + ); } catch (err) { console.log( - "[T2] sendRealtimeInput a échoué :", + "[T2] Gemini WS send (audio) failed:", err instanceof Error ? err.message : String(err), ); void endSession(); } } - // Tout autre message client est ignoré (ex: ping keep-alive frontend). + // Tout autre message client est ignoré. }); clientWs.on("close", () => { clearTimers(); sessionEnded = true; - if (session) { - try { - session.close(); - } catch { - /* ignore */ - } + try { + geminiWs.close(1000); + } catch { + /* ignore */ } }); clientWs.on("error", () => { clearTimers(); sessionEnded = true; - if (session) { - try { - session.close(); - } catch { - /* ignore */ - } + try { + geminiWs.close(1011); + } catch { + /* ignore */ } }); }