NathMen12 commited on
Commit
dc67aba
·
verified ·
1 Parent(s): bedbfbd

Update index.html

Browse files
Files changed (1) hide show
  1. index.html +148 -154
index.html CHANGED
@@ -3,24 +3,24 @@
3
  <head>
4
  <meta charset="UTF-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
- <title>Gemini Live WebRTC Tester</title>
7
  <style>
8
  :root {
9
- --bg-color: #121214;
10
- --card-bg: #1a1a1e;
11
- --accent-color: #4c8bf5;
12
- --accent-success: #2ecc71;
13
- --accent-error: #e74c3c;
14
- --text-main: #f5f5f7;
15
- --text-muted: #9e9e9e;
16
- --border-color: #2a2a30;
17
  }
18
 
19
  * {
20
  box-sizing: border-box;
21
  margin: 0;
22
  padding: 0;
23
- font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
24
  }
25
 
26
  body {
@@ -35,25 +35,23 @@
35
 
36
  .container {
37
  width: 100%;
38
- max-width: 600px;
39
  background-color: var(--card-bg);
40
- border-radius: 16px;
41
  border: 1px solid var(--border-color);
42
  padding: 24px;
43
- box-shadow: 0 8px 32px rgba(0,0,0,0.4);
44
  }
45
 
46
  h1 {
47
- font-size: 1.5rem;
48
- margin-bottom: 8px;
49
  text-align: center;
50
- background: linear-gradient(45deg, #4c8bf5, #9b51e0);
51
- -webkit-background-clip: text;
52
- -webkit-text-fill-color: transparent;
53
  }
54
 
55
  .subtitle {
56
- font-size: 0.9rem;
57
  color: var(--text-muted);
58
  text-align: center;
59
  margin-bottom: 24px;
@@ -65,21 +63,22 @@
65
 
66
  label {
67
  display: block;
68
- font-size: 0.85rem;
69
  font-weight: 600;
70
  margin-bottom: 6px;
71
  color: var(--text-muted);
 
 
72
  }
73
 
74
  input, select {
75
  width: 100%;
76
- padding: 12px;
77
- background-color: #0c0c0e;
78
  border: 1px solid var(--border-color);
79
- border-radius: 8px;
80
  color: var(--text-main);
81
- font-size: 0.95rem;
82
- transition: border-color 0.2s;
83
  }
84
 
85
  input:focus, select:focus {
@@ -92,81 +91,74 @@
92
  align-items: center;
93
  gap: 10px;
94
  padding: 12px;
95
- background-color: #0c0c0e;
96
- border-radius: 8px;
97
- margin-bottom: 20px;
98
  border: 1px solid var(--border-color);
99
- font-size: 0.9rem;
100
  }
101
 
102
  .status-dot {
103
- width: 10px;
104
- height: 10px;
105
  border-radius: 50%;
106
  background-color: var(--text-muted);
107
  }
108
 
109
  .status-dot.connected { background-color: var(--accent-success); }
110
  .status-dot.disconnected { background-color: var(--accent-error); }
111
- .status-dot.connecting { background-color: #f1c40f; }
112
 
113
  .controls {
114
  display: flex;
115
  justify-content: center;
116
- margin-top: 24px;
117
  }
118
 
119
  .btn-mic {
120
- width: 80px;
121
- height: 80px;
122
  border-radius: 50%;
123
  border: none;
124
- background-color: #2a2a30;
125
  color: var(--text-main);
126
  cursor: pointer;
127
  display: flex;
128
  justify-content: center;
129
  align-items: center;
130
- transition: all 0.3s ease;
131
- box-shadow: 0 4px 12px rgba(0,0,0,0.2);
132
- }
133
-
134
- .btn-mic:disabled {
135
- opacity: 0.5;
136
- cursor: not-allowed;
137
  }
138
 
139
  .btn-mic.active {
140
  background-color: var(--accent-error);
141
- box-shadow: 0 0 20px rgba(231, 76, 60, 0.4);
142
  transform: scale(1.05);
143
  }
144
 
145
  .btn-mic svg {
146
- width: 32px;
147
- height: 32px;
148
  fill: currentColor;
149
  }
150
 
151
  .logs {
152
- margin-top: 24px;
153
- background-color: #0c0c0e;
154
  border: 1px solid var(--border-color);
155
- border-radius: 8px;
156
  padding: 12px;
157
- height: 120px;
158
  overflow-y: auto;
159
  font-family: monospace;
160
- font-size: 0.8rem;
161
- color: #a4a4a8;
162
  }
 
163
  </style>
164
  </head>
165
  <body>
166
 
167
  <div class="container">
168
- <h1>Gemini Multimodal Live Client</h1>
169
- <div class="subtitle">Test d'interaction audio en temps réel</div>
170
 
171
  <div class="field">
172
  <label for="apiKey">Clé API Google AI Studio</label>
@@ -174,11 +166,10 @@
174
  </div>
175
 
176
  <div class="field">
177
- <label for="modelSelect">Modèle</label>
178
  <select id="modelSelect">
179
- <option value="gemini-2.0-flash-exp">Gemini 2.0 Flash Exp (Bidi Audio)</option>
180
- <option value="gemini-2.5-flash-native-audio">Gemini 2.5 Flash Live</option>
181
- <option value="gemini-3-flash-live">Gemini 3 Flash Live</option>
182
  </select>
183
  </div>
184
 
@@ -188,14 +179,16 @@
188
  </div>
189
 
190
  <div class="controls">
191
- <button id="micBtn" class="btn-mic" title="Démarrer la session">
192
  <svg viewBox="0 0 24 24">
193
  <path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm5.3-3c0 3-2.54 5.1-5.3 5.1S6.7 14 6.7 11H5c0 3.41 2.72 6.23 6 6.72V21h2v-3.28c3.28-.48 6-3.3 6-6.72h-1.7z"/>
194
  </svg>
195
  </button>
196
  </div>
197
 
198
- <div class="logs" id="logs">Initialisation du client prête.</div>
 
 
199
  </div>
200
 
201
  <script>
@@ -208,13 +201,14 @@
208
 
209
  let ws = null;
210
  let audioContext = null;
211
- let audioWorkletNode = null;
212
  let mediaStream = null;
 
213
  let isConnected = false;
 
214
 
215
  function log(message) {
216
  const time = new Date().toLocaleTimeString();
217
- logsContainer.innerHTML += `<div>[${time}] ${message}</div>`;
218
  logsContainer.scrollTop = logsContainer.scrollHeight;
219
  }
220
 
@@ -231,37 +225,35 @@
231
 
232
  const apiKey = apiKeyInput.value.trim();
233
  if (!apiKey) {
234
- alert('Veuillez entrer une clé API valide.');
235
  return;
236
  }
237
 
238
- const model = modelSelect.value;
239
- startLiveSession(apiKey, model);
240
  });
241
 
242
- async function startLiveSession(apiKey, model) {
243
- setStatus('connecting', 'Connexion au serveur Gemini...');
244
- log(`Connexion via WebSocket pour le modèle: ${model}...`);
245
 
246
- // L'API WebSocket Live utilise l'URL v1alpha ou v1 (selon dispo des modèles)
247
- const host = "generativelanguage.googleapis.com";
248
- const wsUrl = `wss://${host}/ws/google.ai.generativelanguage.v1alpha.GenerativeService.BidiGenerateContent?key=${apiKey}`;
249
 
250
  try {
251
  ws = new WebSocket(wsUrl);
252
  } catch (e) {
253
- log(`Erreur création WS: ${e.message}`);
254
- setStatus('disconnected', 'Erreur de connexion');
255
  return;
256
  }
257
 
258
  ws.onopen = async () => {
259
  isConnected = true;
260
  micBtn.classList.add('active');
261
- setStatus('connected', 'Connecté Parlez maintenant');
262
- log("Session WebSocket établie.");
263
 
264
- // Étape 1 : Envoyer la configuration initiale
265
  const setupMessage = {
266
  setup: {
267
  model: `models/${model}`,
@@ -270,85 +262,84 @@
270
  speechConfig: {
271
  voiceConfig: {
272
  prebuiltVoiceConfig: {
273
- voiceName: "Aoede" // Choix de voix standard (Aoede, Charon, Fenrir, Kore, Puck)
274
  }
275
  }
276
  }
277
  }
278
  }
279
  };
 
280
  ws.send(JSON.stringify(setupMessage));
281
- log("Configuration envoyée au modèle.");
282
 
283
- // Étape 2 : Démarrer la capture audio du micro
284
- await initAudioInput();
285
  };
286
 
287
  ws.onmessage = async (event) => {
288
  try {
289
- let data;
290
  if (event.data instanceof Blob) {
291
- const text = await event.data.text();
292
- data = JSON.parse(text);
293
  } else {
294
- data = JSON.parse(event.data);
295
  }
296
 
297
- if (data.serverContent && data.serverContent.modelTurn) {
298
- const parts = data.serverContent.modelTurn.parts;
 
299
  for (const part of parts) {
300
- if (part.inlineData && part.inlineData.mimeType.startsWith('audio/pcm')) {
301
- // Lecture du flux audio reçu (PCM 24kHz brut)
302
  playAudioChunk(part.inlineData.data);
303
  }
304
  }
305
  }
306
  } catch (err) {
307
- // Erreur de parsing mineure ou frame vide
308
  }
309
  };
310
 
311
  ws.onclose = (e) => {
312
- log(`Session fermée (${e.code}).`);
313
  disconnect();
314
  };
315
 
316
  ws.onerror = (err) => {
317
- log("Erreur WebSocket détectée.");
318
- console.error(err);
319
  };
320
  }
321
 
322
- async function initAudioInput() {
323
  try {
324
- mediaStream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true } });
325
- audioContext = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: 16000 });
 
326
 
327
- // Note technique : Pour de la production, un AudioWorklet est requis pour streamer le PCM 16kHz brut direct.
328
- // Pour ce template HTML single-file ultra-léger, on prépare le terrain de capture :
329
  const source = audioContext.createMediaStreamSource(mediaStream);
330
 
331
- // Création d'un ScriptProcessor basique (fallback compatible sans fichier externe pour le single-file)
332
- const processor = audioContext.createScriptProcessor(2048, 1, 1);
333
- source.connect(processor);
334
- processor.connect(audioContext.destination);
 
 
 
335
 
336
- processor.onaudioprocess = (e) => {
337
- if (!isConnected || ws.readyState !== WebSocket.OPEN) return;
338
 
339
- const inputData = e.inputBuffer.getChannelData(0);
340
- // Conversion Float32 vers Int16 (PCM 16-bit little-endian attendu par Gemini)
341
- const l = inputData.length;
342
- const buf = new Int16Array(l);
343
- for (let i = 0; i < l; i++) {
344
- let s = Math.max(-1, Math.min(1, inputData[i]));
345
- buf[i] = s < 0 ? s * 0x8000 : s * 0x7FFF;
346
  }
347
-
348
- // Encodage Base64 du chunk PCM direct
349
- const base64Audio = btoa(String.fromCharCode.apply(null, new Uint8Array(buf.buffer)));
350
-
351
- const audioMessage = {
352
  realtimeInput: {
353
  mediaChunks: [{
354
  mimeType: "audio/pcm;rate=16000",
@@ -356,71 +347,74 @@
356
  }]
357
  }
358
  };
359
- ws.send(JSON.stringify(audioMessage));
360
  };
361
- log("Microphone activé et flux audio initialisé (16kHz).");
362
  } catch (err) {
363
- log(`Impossible d'accéder au micro : ${err.message}`);
 
364
  }
365
  }
366
 
367
- // Queue pour gérer la lecture audio fluide en sortie
368
- let nextStartTime = 0;
369
  function playAudioChunk(base64Data) {
370
- if (!audioContext) {
371
- audioContext = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: 24000 });
372
- }
373
-
374
- // Décodage base64
375
- const binaryString = atob(base64Data);
376
- const len = binaryString.length;
377
- const bytes = new Uint8Array(len);
378
- for (let i = 0; i < len; i++) {
379
- bytes[i] = binaryString.charCodeAt(i);
380
- }
381
 
382
- // Conversion PCM 16bit vers Float32
383
- const int16Array = new Int16Array(bytes.buffer);
384
- const float32Array = new Float32Array(int16Array.length);
385
- for (let i = 0; i < int16Array.length; i++) {
386
- float32Array[i] = int16Array[i] / 32768.0;
387
- }
 
 
 
 
 
 
 
388
 
389
- // Création du buffer audio (Gemini renvoie généralement du 24kHz en sortie)
390
- const audioBuffer = audioContext.createBuffer(1, float32Array.length, 24000);
391
- audioBuffer.getChannelData(0).set(float32Array);
392
 
393
- const source = audioContext.createBufferSource();
394
- source.buffer = audioBuffer;
395
- source.connect(audioContext.destination);
396
 
397
- // Planification de la lecture pour éviter les micro-coupures
398
- const currentTime = audioContext.currentTime;
399
- if (nextStartTime < currentTime) {
400
- nextStartTime = currentTime;
 
 
 
 
401
  }
402
- source.start(nextStartTime);
403
- nextStartTime += audioBuffer.duration;
404
  }
405
 
406
  function disconnect() {
407
  isConnected = false;
408
  micBtn.classList.remove('active');
409
  setStatus('disconnected', 'Déconnecté');
410
-
411
- if (ws) {
412
- if (ws.readyState === WebSocket.OPEN) ws.close();
413
- ws = null;
414
  }
415
  if (mediaStream) {
416
  mediaStream.getTracks().forEach(track => track.stop());
417
  mediaStream = null;
418
  }
 
 
 
 
419
  if (audioContext) {
420
  audioContext.close();
421
  audioContext = null;
422
  }
423
- log("Session arrêtée.");
 
424
  }
425
  </script>
426
  </body>
 
3
  <head>
4
  <meta charset="UTF-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Gemini Live API Tester</title>
7
  <style>
8
  :root {
9
+ --bg-color: #0e0e10;
10
+ --card-bg: #18181b;
11
+ --accent-color: #3b82f6;
12
+ --accent-success: #10b981;
13
+ --accent-error: #ef4444;
14
+ --text-main: #f4f4f5;
15
+ --text-muted: #a1a1aa;
16
+ --border-color: #27272a;
17
  }
18
 
19
  * {
20
  box-sizing: border-box;
21
  margin: 0;
22
  padding: 0;
23
+ font-family: system-ui, -apple-system, sans-serif;
24
  }
25
 
26
  body {
 
35
 
36
  .container {
37
  width: 100%;
38
+ max-width: 550px;
39
  background-color: var(--card-bg);
40
+ border-radius: 12px;
41
  border: 1px solid var(--border-color);
42
  padding: 24px;
43
+ box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.5);
44
  }
45
 
46
  h1 {
47
+ font-size: 1.35rem;
48
+ margin-bottom: 4px;
49
  text-align: center;
50
+ color: #fff;
 
 
51
  }
52
 
53
  .subtitle {
54
+ font-size: 0.85rem;
55
  color: var(--text-muted);
56
  text-align: center;
57
  margin-bottom: 24px;
 
63
 
64
  label {
65
  display: block;
66
+ font-size: 0.8rem;
67
  font-weight: 600;
68
  margin-bottom: 6px;
69
  color: var(--text-muted);
70
+ text-transform: uppercase;
71
+ letter-spacing: 0.05em;
72
  }
73
 
74
  input, select {
75
  width: 100%;
76
+ padding: 10px 12px;
77
+ background-color: #09090b;
78
  border: 1px solid var(--border-color);
79
+ border-radius: 6px;
80
  color: var(--text-main);
81
+ font-size: 0.9rem;
 
82
  }
83
 
84
  input:focus, select:focus {
 
91
  align-items: center;
92
  gap: 10px;
93
  padding: 12px;
94
+ background-color: #09090b;
95
+ border-radius: 6px;
96
+ margin-bottom: 24px;
97
  border: 1px solid var(--border-color);
98
+ font-size: 0.85rem;
99
  }
100
 
101
  .status-dot {
102
+ width: 8px;
103
+ height: 8px;
104
  border-radius: 50%;
105
  background-color: var(--text-muted);
106
  }
107
 
108
  .status-dot.connected { background-color: var(--accent-success); }
109
  .status-dot.disconnected { background-color: var(--accent-error); }
110
+ .status-dot.connecting { background-color: #eab308; }
111
 
112
  .controls {
113
  display: flex;
114
  justify-content: center;
115
+ margin-bottom: 24px;
116
  }
117
 
118
  .btn-mic {
119
+ width: 70px;
120
+ height: 70px;
121
  border-radius: 50%;
122
  border: none;
123
+ background-color: #27272a;
124
  color: var(--text-main);
125
  cursor: pointer;
126
  display: flex;
127
  justify-content: center;
128
  align-items: center;
129
+ transition: all 0.2s ease;
 
 
 
 
 
 
130
  }
131
 
132
  .btn-mic.active {
133
  background-color: var(--accent-error);
 
134
  transform: scale(1.05);
135
  }
136
 
137
  .btn-mic svg {
138
+ width: 28px;
139
+ height: 28px;
140
  fill: currentColor;
141
  }
142
 
143
  .logs {
144
+ background-color: #09090b;
 
145
  border: 1px solid var(--border-color);
146
+ border-radius: 6px;
147
  padding: 12px;
148
+ height: 130px;
149
  overflow-y: auto;
150
  font-family: monospace;
151
+ font-size: 0.75rem;
152
+ color: #a1a1aa;
153
  }
154
+ .log-item { margin-bottom: 4px; }
155
  </style>
156
  </head>
157
  <body>
158
 
159
  <div class="container">
160
+ <h1>Gemini Live Client</h1>
161
+ <div class="subtitle">Test audio bidirectionnel temps réel</div>
162
 
163
  <div class="field">
164
  <label for="apiKey">Clé API Google AI Studio</label>
 
166
  </div>
167
 
168
  <div class="field">
169
+ <label for="modelSelect">Modèle Multi-modal</label>
170
  <select id="modelSelect">
171
+ <option value="gemini-2.5-flash">Gemini 2.5 Flash (Recommandé)</option>
172
+ <option value="gemini-2.0-flash-exp">Gemini 2.0 Flash Exp</option>
 
173
  </select>
174
  </div>
175
 
 
179
  </div>
180
 
181
  <div class="controls">
182
+ <button id="micBtn" class="btn-mic" title="Démarrer/Arrêter">
183
  <svg viewBox="0 0 24 24">
184
  <path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm5.3-3c0 3-2.54 5.1-5.3 5.1S6.7 14 6.7 11H5c0 3.41 2.72 6.23 6 6.72V21h2v-3.28c3.28-.48 6-3.3 6-6.72h-1.7z"/>
185
  </svg>
186
  </button>
187
  </div>
188
 
189
+ <div class="logs" id="logs">
190
+ <div class="log-item">Prêt pour le test.</div>
191
+ </div>
192
  </div>
193
 
194
  <script>
 
201
 
202
  let ws = null;
203
  let audioContext = null;
 
204
  let mediaStream = null;
205
+ let scriptProcessor = null;
206
  let isConnected = false;
207
+ let nextStartTime = 0;
208
 
209
  function log(message) {
210
  const time = new Date().toLocaleTimeString();
211
+ logsContainer.innerHTML += `<div class="log-item">[${time}] ${message}</div>`;
212
  logsContainer.scrollTop = logsContainer.scrollHeight;
213
  }
214
 
 
225
 
226
  const apiKey = apiKeyInput.value.trim();
227
  if (!apiKey) {
228
+ alert('Veuillez entrer votre clé API.');
229
  return;
230
  }
231
 
232
+ startSession(apiKey, modelSelect.value);
 
233
  });
234
 
235
+ function startSession(apiKey, model) {
236
+ setStatus('connecting', 'Connexion...');
237
+ log(`Tentative de connexion au WebSocket Live (${model})...`);
238
 
239
+ // URL Officielle stable v1beta pour l'API Live de Google AI Studio
240
+ const wsUrl = `wss://generativelanguage.googleapis.com/ws/google.ai.generativelanguage.v1beta.GenerativeService.BidiGenerateContent?key=${apiKey}`;
 
241
 
242
  try {
243
  ws = new WebSocket(wsUrl);
244
  } catch (e) {
245
+ log(`Échec création WS : ${e.message}`);
246
+ setStatus('disconnected', 'Erreur');
247
  return;
248
  }
249
 
250
  ws.onopen = async () => {
251
  isConnected = true;
252
  micBtn.classList.add('active');
253
+ setStatus('connected', 'Connecté ! Parlez maintenant.');
254
+ log("WebSocket connecté avec succès.");
255
 
256
+ // Structure Setup officielle validée par l'API Gemini Live
257
  const setupMessage = {
258
  setup: {
259
  model: `models/${model}`,
 
262
  speechConfig: {
263
  voiceConfig: {
264
  prebuiltVoiceConfig: {
265
+ voiceName: "Aoede" // Voix disponible (Aoede, Charon, Kore, etc.)
266
  }
267
  }
268
  }
269
  }
270
  }
271
  };
272
+
273
  ws.send(JSON.stringify(setupMessage));
274
+ log("Configuration de session envoyée.");
275
 
276
+ // Activer la capture micro
277
+ await startAudioCapture();
278
  };
279
 
280
  ws.onmessage = async (event) => {
281
  try {
282
+ let json;
283
  if (event.data instanceof Blob) {
284
+ json = JSON.parse(await event.data.text());
 
285
  } else {
286
+ json = JSON.parse(event.data);
287
  }
288
 
289
+ // Extraction des données audio retournées par Gemini
290
+ if (json.serverContent && json.serverContent.modelTurn) {
291
+ const parts = json.serverContent.modelTurn.parts;
292
  for (const part of parts) {
293
+ if (part.inlineData && part.inlineData.data) {
 
294
  playAudioChunk(part.inlineData.data);
295
  }
296
  }
297
  }
298
  } catch (err) {
299
+ // Erreur de parsing ou paquets vides ignorés
300
  }
301
  };
302
 
303
  ws.onclose = (e) => {
304
+ log(`Connexion fermée par le serveur. Code: ${e.code} / Raison: ${e.reason || 'Non spécifiée'}`);
305
  disconnect();
306
  };
307
 
308
  ws.onerror = (err) => {
309
+ log("Erreur détectée sur le flux WebSocket.");
 
310
  };
311
  }
312
 
313
+ async function startAudioCapture() {
314
  try {
315
+ mediaStream = await navigator.mediaDevices.getUserMedia({
316
+ audio: { echoCancellation: true, noiseSuppression: true }
317
+ });
318
 
319
+ audioContext = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: 16000 });
 
320
  const source = audioContext.createMediaStreamSource(mediaStream);
321
 
322
+ // ScriptProcessor standard pour capturer les buffers et les envoyer
323
+ scriptProcessor = audioContext.createScriptProcessor(2048, 1, 1);
324
+ source.connect(scriptProcessor);
325
+ scriptProcessor.connect(audioContext.destination);
326
+
327
+ scriptProcessor.onaudioprocess = (e) => {
328
+ if (!isConnected || !ws || ws.readyState !== WebSocket.OPEN) return;
329
 
330
+ const floatSamples = e.inputBuffer.getChannelData(0);
331
+ const int16Buffer = new Int16Array(floatSamples.length);
332
 
333
+ // Conversion obligatoire vers PCM 16-bit Little Endian
334
+ for (let i = 0; i < floatSamples.length; i++) {
335
+ let s = Math.max(-1, Math.min(1, floatSamples[i]));
336
+ int16Buffer[i] = s < 0 ? s * 0x8000 : s * 0x7FFF;
 
 
 
337
  }
338
+
339
+ // Encodage en base64 direct
340
+ const base64Audio = btoa(String.fromCharCode(...new Uint8Array(int16Buffer.buffer)));
341
+
342
+ const payload = {
343
  realtimeInput: {
344
  mediaChunks: [{
345
  mimeType: "audio/pcm;rate=16000",
 
347
  }]
348
  }
349
  };
350
+ ws.send(JSON.stringify(payload));
351
  };
352
+ log("Capture microphone active (PCM 16kHz).");
353
  } catch (err) {
354
+ log(`Erreur microphone : ${err.message}`);
355
+ disconnect();
356
  }
357
  }
358
 
 
 
359
  function playAudioChunk(base64Data) {
360
+ if (!audioContext) return;
 
 
 
 
 
 
 
 
 
 
361
 
362
+ try {
363
+ const binaryString = atob(base64Data);
364
+ const len = binaryString.length;
365
+ const bytes = new Uint8Array(len);
366
+ for (let i = 0; i < len; i++) {
367
+ bytes[i] = binaryString.charCodeAt(i);
368
+ }
369
+
370
+ const int16Array = new Int16Array(bytes.buffer);
371
+ const float32Array = new Float32Array(int16Array.length);
372
+ for (let i = 0; i < int16Array.length; i++) {
373
+ float32Array[i] = int16Array[i] / 32768.0;
374
+ }
375
 
376
+ // Gemini renvoie du PCM natif à 24kHz
377
+ const audioBuffer = audioContext.createBuffer(1, float32Array.length, 24000);
378
+ audioBuffer.getChannelData(0).set(float32Array);
379
 
380
+ const source = audioContext.createBufferSource();
381
+ source.buffer = audioBuffer;
382
+ source.connect(audioContext.destination);
383
 
384
+ const currentTime = audioContext.currentTime;
385
+ if (nextStartTime < currentTime) {
386
+ nextStartTime = currentTime;
387
+ }
388
+ source.start(nextStartTime);
389
+ nextStartTime += audioBuffer.duration;
390
+ } catch (e) {
391
+ console.error("Erreur de décodage/lecture audio", e);
392
  }
 
 
393
  }
394
 
395
  function disconnect() {
396
  isConnected = false;
397
  micBtn.classList.remove('active');
398
  setStatus('disconnected', 'Déconnecté');
399
+
400
+ if (scriptProcessor) {
401
+ scriptProcessor.disconnect();
402
+ scriptProcessor = null;
403
  }
404
  if (mediaStream) {
405
  mediaStream.getTracks().forEach(track => track.stop());
406
  mediaStream = null;
407
  }
408
+ if (ws) {
409
+ if (ws.readyState === WebSocket.OPEN) ws.close();
410
+ ws = null;
411
+ }
412
  if (audioContext) {
413
  audioContext.close();
414
  audioContext = null;
415
  }
416
+ nextStartTime = 0;
417
+ log("Session nettoyée et arrêtée.");
418
  }
419
  </script>
420
  </body>