| <!DOCTYPE html> |
| <html lang="fr"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>Gemini Live API Tester</title> |
| <style> |
| :root { |
| --bg-color: #0e0e10; |
| --card-bg: #18181b; |
| --accent-color: #3b82f6; |
| --accent-success: #10b981; |
| --accent-error: #ef4444; |
| --text-main: #f4f4f5; |
| --text-muted: #a1a1aa; |
| --border-color: #27272a; |
| } |
| |
| * { |
| box-sizing: border-box; |
| margin: 0; |
| padding: 0; |
| font-family: system-ui, -apple-system, sans-serif; |
| } |
| |
| body { |
| background-color: var(--bg-color); |
| color: var(--text-main); |
| display: flex; |
| justify-content: center; |
| align-items: center; |
| min-height: 100vh; |
| padding: 20px; |
| } |
| |
| .container { |
| width: 100%; |
| max-width: 550px; |
| background-color: var(--card-bg); |
| border-radius: 12px; |
| border: 1px solid var(--border-color); |
| padding: 24px; |
| box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.5); |
| } |
| |
| h1 { |
| font-size: 1.35rem; |
| margin-bottom: 4px; |
| text-align: center; |
| color: #fff; |
| } |
| |
| .subtitle { |
| font-size: 0.85rem; |
| color: var(--text-muted); |
| text-align: center; |
| margin-bottom: 24px; |
| } |
| |
| .field { |
| margin-bottom: 16px; |
| } |
| |
| label { |
| display: block; |
| font-size: 0.8rem; |
| font-weight: 600; |
| margin-bottom: 6px; |
| color: var(--text-muted); |
| text-transform: uppercase; |
| letter-spacing: 0.05em; |
| } |
| |
| input, select { |
| width: 100%; |
| padding: 10px 12px; |
| background-color: #09090b; |
| border: 1px solid var(--border-color); |
| border-radius: 6px; |
| color: var(--text-main); |
| font-size: 0.9rem; |
| } |
| |
| input:focus, select:focus { |
| outline: none; |
| border-color: var(--accent-color); |
| } |
| |
| .status-box { |
| display: flex; |
| align-items: center; |
| gap: 10px; |
| padding: 12px; |
| background-color: #09090b; |
| border-radius: 6px; |
| margin-bottom: 24px; |
| border: 1px solid var(--border-color); |
| font-size: 0.85rem; |
| } |
| |
| .status-dot { |
| width: 8px; |
| height: 8px; |
| border-radius: 50%; |
| background-color: var(--text-muted); |
| } |
| |
| .status-dot.connected { background-color: var(--accent-success); } |
| .status-dot.disconnected { background-color: var(--accent-error); } |
| .status-dot.connecting { background-color: #eab308; } |
| |
| .controls { |
| display: flex; |
| justify-content: center; |
| margin-bottom: 24px; |
| } |
| |
| .btn-mic { |
| width: 70px; |
| height: 70px; |
| border-radius: 50%; |
| border: none; |
| background-color: #27272a; |
| color: var(--text-main); |
| cursor: pointer; |
| display: flex; |
| justify-content: center; |
| align-items: center; |
| transition: all 0.2s ease; |
| } |
| |
| .btn-mic.active { |
| background-color: var(--accent-error); |
| transform: scale(1.05); |
| } |
| |
| .btn-mic svg { |
| width: 28px; |
| height: 28px; |
| fill: currentColor; |
| } |
| |
| .logs { |
| background-color: #09090b; |
| border: 1px solid var(--border-color); |
| border-radius: 6px; |
| padding: 12px; |
| height: 130px; |
| overflow-y: auto; |
| font-family: monospace; |
| font-size: 0.75rem; |
| color: #a1a1aa; |
| } |
| .log-item { margin-bottom: 4px; } |
| </style> |
| </head> |
| <body> |
|
|
| <div class="container"> |
| <h1>Gemini Live Client</h1> |
| <div class="subtitle">Test audio bidirectionnel temps réel</div> |
|
|
| <div class="field"> |
| <label for="apiKey">Clé API Google AI Studio</label> |
| <input type="password" id="apiKey" placeholder="AIzaSy..."> |
| </div> |
|
|
| <div class="field"> |
| <label for="modelSelect">Modèle Multi-modal</label> |
| <select id="modelSelect"> |
| <option value="gemini-2.5-flash">Gemini 2.5 Flash (Recommandé)</option> |
| <option value="gemini-2.0-flash-exp">Gemini 2.0 Flash Exp</option> |
| </select> |
| </div> |
|
|
| <div class="status-box"> |
| <div id="statusDot" class="status-dot"></div> |
| <span id="statusText">Déconnecté — Entrez votre clé API et cliquez sur le micro</span> |
| </div> |
|
|
| <div class="controls"> |
| <button id="micBtn" class="btn-mic" title="Démarrer/Arrêter"> |
| <svg viewBox="0 0 24 24"> |
| <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"/> |
| </svg> |
| </button> |
| </div> |
|
|
| <div class="logs" id="logs"> |
| <div class="log-item">Prêt pour le test.</div> |
| </div> |
| </div> |
|
|
| <script> |
| const apiKeyInput = document.getElementById('apiKey'); |
| const modelSelect = document.getElementById('modelSelect'); |
| const micBtn = document.getElementById('micBtn'); |
| const statusDot = document.getElementById('statusDot'); |
| const statusText = document.getElementById('statusText'); |
| const logsContainer = document.getElementById('logs'); |
| |
| let ws = null; |
| let audioContext = null; |
| let mediaStream = null; |
| let scriptProcessor = null; |
| let isConnected = false; |
| let nextStartTime = 0; |
| |
| function log(message) { |
| const time = new Date().toLocaleTimeString(); |
| logsContainer.innerHTML += `<div class="log-item">[${time}] ${message}</div>`; |
| logsContainer.scrollTop = logsContainer.scrollHeight; |
| } |
| |
| function setStatus(state, text) { |
| statusDot.className = 'status-dot ' + state; |
| statusText.innerText = text; |
| } |
| |
| micBtn.addEventListener('click', async () => { |
| if (isConnected) { |
| disconnect(); |
| return; |
| } |
| |
| const apiKey = apiKeyInput.value.trim(); |
| if (!apiKey) { |
| alert('Veuillez entrer votre clé API.'); |
| return; |
| } |
| |
| startSession(apiKey, modelSelect.value); |
| }); |
| |
| function startSession(apiKey, model) { |
| setStatus('connecting', 'Connexion...'); |
| log(`Tentative de connexion au WebSocket Live (${model})...`); |
| |
| |
| const wsUrl = `wss://generativelanguage.googleapis.com/ws/google.ai.generativelanguage.v1beta.GenerativeService.BidiGenerateContent?key=${apiKey}`; |
| |
| try { |
| ws = new WebSocket(wsUrl); |
| } catch (e) { |
| log(`Échec création WS : ${e.message}`); |
| setStatus('disconnected', 'Erreur'); |
| return; |
| } |
| |
| ws.onopen = async () => { |
| isConnected = true; |
| micBtn.classList.add('active'); |
| setStatus('connected', 'Connecté ! Parlez maintenant.'); |
| log("WebSocket connecté avec succès."); |
| |
| |
| const setupMessage = { |
| setup: { |
| model: `models/${model}`, |
| generationConfig: { |
| responseModalities: ["AUDIO"], |
| speechConfig: { |
| voiceConfig: { |
| prebuiltVoiceConfig: { |
| voiceName: "Aoede" |
| } |
| } |
| } |
| } |
| } |
| }; |
| |
| ws.send(JSON.stringify(setupMessage)); |
| log("Configuration de session envoyée."); |
| |
| |
| await startAudioCapture(); |
| }; |
| |
| ws.onmessage = async (event) => { |
| try { |
| let json; |
| if (event.data instanceof Blob) { |
| json = JSON.parse(await event.data.text()); |
| } else { |
| json = JSON.parse(event.data); |
| } |
| |
| |
| if (json.serverContent && json.serverContent.modelTurn) { |
| const parts = json.serverContent.modelTurn.parts; |
| for (const part of parts) { |
| if (part.inlineData && part.inlineData.data) { |
| playAudioChunk(part.inlineData.data); |
| } |
| } |
| } |
| } catch (err) { |
| |
| } |
| }; |
| |
| ws.onclose = (e) => { |
| log(`Connexion fermée par le serveur. Code: ${e.code} / Raison: ${e.reason || 'Non spécifiée'}`); |
| disconnect(); |
| }; |
| |
| ws.onerror = (err) => { |
| log("Erreur détectée sur le flux WebSocket."); |
| }; |
| } |
| |
| async function startAudioCapture() { |
| try { |
| mediaStream = await navigator.mediaDevices.getUserMedia({ |
| audio: { echoCancellation: true, noiseSuppression: true } |
| }); |
| |
| audioContext = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: 16000 }); |
| const source = audioContext.createMediaStreamSource(mediaStream); |
| |
| |
| scriptProcessor = audioContext.createScriptProcessor(2048, 1, 1); |
| source.connect(scriptProcessor); |
| scriptProcessor.connect(audioContext.destination); |
| |
| scriptProcessor.onaudioprocess = (e) => { |
| if (!isConnected || !ws || ws.readyState !== WebSocket.OPEN) return; |
| |
| const floatSamples = e.inputBuffer.getChannelData(0); |
| const int16Buffer = new Int16Array(floatSamples.length); |
| |
| |
| for (let i = 0; i < floatSamples.length; i++) { |
| let s = Math.max(-1, Math.min(1, floatSamples[i])); |
| int16Buffer[i] = s < 0 ? s * 0x8000 : s * 0x7FFF; |
| } |
| |
| |
| const base64Audio = btoa(String.fromCharCode(...new Uint8Array(int16Buffer.buffer))); |
| |
| const payload = { |
| realtimeInput: { |
| mediaChunks: [{ |
| mimeType: "audio/pcm;rate=16000", |
| data: base64Audio |
| }] |
| } |
| }; |
| ws.send(JSON.stringify(payload)); |
| }; |
| log("Capture microphone active (PCM 16kHz)."); |
| } catch (err) { |
| log(`Erreur microphone : ${err.message}`); |
| disconnect(); |
| } |
| } |
| |
| function playAudioChunk(base64Data) { |
| if (!audioContext) return; |
| |
| try { |
| const binaryString = atob(base64Data); |
| const len = binaryString.length; |
| const bytes = new Uint8Array(len); |
| for (let i = 0; i < len; i++) { |
| bytes[i] = binaryString.charCodeAt(i); |
| } |
| |
| const int16Array = new Int16Array(bytes.buffer); |
| const float32Array = new Float32Array(int16Array.length); |
| for (let i = 0; i < int16Array.length; i++) { |
| float32Array[i] = int16Array[i] / 32768.0; |
| } |
| |
| |
| const audioBuffer = audioContext.createBuffer(1, float32Array.length, 24000); |
| audioBuffer.getChannelData(0).set(float32Array); |
| |
| const source = audioContext.createBufferSource(); |
| source.buffer = audioBuffer; |
| source.connect(audioContext.destination); |
| |
| const currentTime = audioContext.currentTime; |
| if (nextStartTime < currentTime) { |
| nextStartTime = currentTime; |
| } |
| source.start(nextStartTime); |
| nextStartTime += audioBuffer.duration; |
| } catch (e) { |
| console.error("Erreur de décodage/lecture audio", e); |
| } |
| } |
| |
| function disconnect() { |
| isConnected = false; |
| micBtn.classList.remove('active'); |
| setStatus('disconnected', 'Déconnecté'); |
| |
| if (scriptProcessor) { |
| scriptProcessor.disconnect(); |
| scriptProcessor = null; |
| } |
| if (mediaStream) { |
| mediaStream.getTracks().forEach(track => track.stop()); |
| mediaStream = null; |
| } |
| if (ws) { |
| if (ws.readyState === WebSocket.OPEN) ws.close(); |
| ws = null; |
| } |
| if (audioContext) { |
| audioContext.close(); |
| audioContext = null; |
| } |
| nextStartTime = 0; |
| log("Session nettoyée et arrêtée."); |
| } |
| </script> |
| </body> |
| </html> |
|
|