NathMen12's picture
Update index.html
dc67aba verified
Raw
History Blame Contribute Delete
13.6 kB
<!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})...`);
// URL Officielle stable v1beta pour l'API Live de Google AI Studio
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.");
// Structure Setup officielle validée par l'API Gemini Live
const setupMessage = {
setup: {
model: `models/${model}`,
generationConfig: {
responseModalities: ["AUDIO"],
speechConfig: {
voiceConfig: {
prebuiltVoiceConfig: {
voiceName: "Aoede" // Voix disponible (Aoede, Charon, Kore, etc.)
}
}
}
}
}
};
ws.send(JSON.stringify(setupMessage));
log("Configuration de session envoyée.");
// Activer la capture micro
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);
}
// Extraction des données audio retournées par Gemini
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) {
// Erreur de parsing ou paquets vides ignorés
}
};
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 standard pour capturer les buffers et les envoyer
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);
// Conversion obligatoire vers PCM 16-bit Little Endian
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;
}
// Encodage en base64 direct
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;
}
// Gemini renvoie du PCM natif à 24kHz
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>