const conversation = document.querySelector("#conversation"); const statusDot = document.querySelector("#statusDot"); const statusText = document.querySelector("#statusText"); const textInput = document.querySelector("#textInput"); const sendButton = document.querySelector("#sendButton"); const voiceButton = document.querySelector("#voiceButton"); const voiceLabel = document.querySelector("#voiceLabel"); const resetButton = document.querySelector("#resetButton"); const audioToggle = document.querySelector("#audioToggle"); const SILENCE_DELAY_MS = 2000; const MIN_VOICE_MS = 450; const MAX_UTTERANCE_MS = 30000; const VOICE_THRESHOLD = 0.025; const sessionId = getSessionId(); let recorder = null; let chunks = []; let audioUnlocked = false; let handsFreeMode = false; let processingVoice = false; let mediaStream = null; let audioContext = null; let analyser = null; let analyserData = null; let monitorFrame = null; let speechDetected = false; let speechStartedAt = 0; let silenceStartedAt = null; let utteranceStartedAt = 0; if ("serviceWorker" in navigator) { navigator.serviceWorker.register("/sw.js").catch(() => {}); } sendButton.addEventListener("click", sendText); voiceButton.addEventListener("click", toggleVoice); resetButton.addEventListener("click", resetConversation); textInput.addEventListener("keydown", (event) => { if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); sendText(); } }); function getSessionId() { const key = "opensquared_assistant_session"; let value = localStorage.getItem(key); if (!value) { if (window.crypto && typeof window.crypto.randomUUID === "function") { value = window.crypto.randomUUID(); } else { value = `session-${Date.now()}-${Math.random().toString(16).slice(2)}`; } localStorage.setItem(key, value); } return value; } function setStatus(text, mode = "idle") { statusText.textContent = text; statusDot.classList.toggle("live", mode === "live"); statusDot.classList.toggle("error", mode === "error"); } function addBubble(text, role = "assistant") { const bubble = document.createElement("article"); bubble.className = `bubble ${role}`; bubble.textContent = text; conversation.appendChild(bubble); conversation.scrollTop = conversation.scrollHeight; return bubble; } async function unlockAudio() { if (audioUnlocked) return; try { const AudioContext = window.AudioContext || window.webkitAudioContext; if (AudioContext) { const context = new AudioContext(); if (context.state === "suspended") { await context.resume(); } await context.close(); } } catch { // The click itself is enough to authorize later playback in most browsers. } audioUnlocked = true; } async function sendText() { const text = textInput.value.trim(); if (!text) return; textInput.value = ""; addBubble(text, "user"); setStatus("Analyse en cours", "live"); try { const response = await fetch("/api/chat", { method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({ sessionId, text, withAudio: false, }), }); const data = await response.json(); if (!response.ok) throw new Error(data.error || "Erreur serveur"); await handleAssistantResponse(data); } catch (error) { addBubble(error.message, "error"); setStatus("Erreur", "error"); } } async function toggleVoice() { if (handsFreeMode) { stopHandsFree("Arrete"); return; } await startHandsFree(); } async function startHandsFree() { setStatus("Activation du micro", "live"); await unlockAudio(); if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { addBubble("Le micro n'est pas disponible dans ce navigateur. Ouvre l'app en HTTPS avec Chrome.", "error"); setStatus("Micro indisponible", "error"); return; } if (typeof MediaRecorder === "undefined") { addBubble("L'enregistrement audio n'est pas supporte par ce navigateur.", "error"); setStatus("Enregistrement indisponible", "error"); return; } try { mediaStream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true, }, }); handsFreeMode = true; voiceButton.classList.add("recording"); voiceLabel.textContent = "Arreter"; await startListeningTurn(); } catch (error) { addBubble("Micro indisponible : " + error.message, "error"); setStatus("Micro indisponible", "error"); } } async function startListeningTurn() { if (!handsFreeMode || processingVoice) return; cleanupRecorder(); chunks = []; speechDetected = false; speechStartedAt = 0; silenceStartedAt = null; utteranceStartedAt = Date.now(); const mimeType = pickMimeType(); recorder = new MediaRecorder(mediaStream, mimeType ? {mimeType} : undefined); recorder.ondataavailable = (event) => { if (event.data.size) chunks.push(event.data); }; recorder.onstop = async () => { stopVolumeMonitor(); if (!handsFreeMode) return; const spokenLongEnough = speechDetected && Date.now() - speechStartedAt >= MIN_VOICE_MS; if (!spokenLongEnough) { window.setTimeout(startListeningTurn, 120); return; } const blob = new Blob(chunks, {type: recorder.mimeType || "audio/webm"}); await sendVoiceBlob(blob); }; recorder.start(250); startVolumeMonitor(); setStatus("Je t'ecoute", "live"); } function pickMimeType() { const types = ["audio/webm;codecs=opus", "audio/webm", "audio/ogg;codecs=opus"]; return types.find((type) => MediaRecorder.isTypeSupported(type)) || ""; } function startVolumeMonitor() { stopVolumeMonitor(); const AudioContext = window.AudioContext || window.webkitAudioContext; audioContext = new AudioContext(); const source = audioContext.createMediaStreamSource(mediaStream); analyser = audioContext.createAnalyser(); analyser.fftSize = 2048; analyserData = new Uint8Array(analyser.fftSize); source.connect(analyser); const tick = () => { if (!recorder || recorder.state !== "recording") return; const now = Date.now(); const volume = getCurrentVolume(); if (volume > VOICE_THRESHOLD) { if (!speechDetected) { speechStartedAt = now; setStatus("Je t'entends", "live"); } speechDetected = true; silenceStartedAt = null; } else if (speechDetected) { silenceStartedAt = silenceStartedAt || now; if (now - silenceStartedAt >= SILENCE_DELAY_MS) { setStatus("Message recu", "live"); recorder.stop(); return; } } if (now - utteranceStartedAt >= MAX_UTTERANCE_MS) { setStatus("Message recu", "live"); recorder.stop(); return; } monitorFrame = window.requestAnimationFrame(tick); }; monitorFrame = window.requestAnimationFrame(tick); } function getCurrentVolume() { analyser.getByteTimeDomainData(analyserData); let sum = 0; for (const value of analyserData) { const centered = (value - 128) / 128; sum += centered * centered; } return Math.sqrt(sum / analyserData.length); } function stopVolumeMonitor() { if (monitorFrame) { window.cancelAnimationFrame(monitorFrame); monitorFrame = null; } if (audioContext) { audioContext.close().catch(() => {}); audioContext = null; } analyser = null; analyserData = null; } function cleanupRecorder() { stopVolumeMonitor(); recorder = null; chunks = []; } function stopHandsFree(label = "Pret") { handsFreeMode = false; processingVoice = false; if (recorder && recorder.state !== "inactive") { recorder.onstop = null; recorder.stop(); } cleanupRecorder(); if (mediaStream) { mediaStream.getTracks().forEach((track) => track.stop()); mediaStream = null; } window.speechSynthesis?.cancel(); voiceButton.classList.remove("recording"); voiceLabel.textContent = "Demarrer"; setStatus(label, "idle"); } async function sendVoiceBlob(blob) { processingVoice = true; setStatus("Transcription", "live"); const form = new FormData(); const extension = blob.type.includes("ogg") ? "ogg" : "webm"; form.append("sessionId", sessionId); form.append("withAudio", "false"); form.append("audio", blob, `voice.${extension}`); try { const response = await fetch("/api/voice", {method: "POST", body: form}); const data = await response.json(); if (!response.ok) throw new Error(data.error || "Erreur serveur"); if (data.transcript) addBubble(data.transcript, "user"); await handleAssistantResponse(data); } catch (error) { addBubble(error.message, "error"); setStatus("Erreur", "error"); } finally { processingVoice = false; if (handsFreeMode) { window.setTimeout(startListeningTurn, 250); } } } async function handleAssistantResponse(data) { const reply = data.reply || ""; addBubble(reply, "assistant"); const status = data.emailSent ? "Email envoye" : data.contactSaved ? "Contact ajoute" : data.memorySaved ? "Memoire mise a jour" : "Pret"; setStatus(status, data.emailSent || data.contactSaved || data.memorySaved ? "live" : "idle"); if (!audioToggle.checked || !reply) return; if (data.audioUrl) { try { const audio = new Audio(data.audioUrl); await audio.play(); return; } catch { // Fall through to the local French browser voice. } } await speakInFrench(reply); } function speakInFrench(text) { return new Promise((resolve) => { if (!("speechSynthesis" in window) || !("SpeechSynthesisUtterance" in window)) { resolve(); return; } window.speechSynthesis.cancel(); const utterance = new SpeechSynthesisUtterance(text); utterance.lang = "fr-FR"; utterance.rate = 0.9; utterance.pitch = 1.08; utterance.volume = 0.92; utterance.voice = pickSoftFrenchVoice(); utterance.onend = resolve; utterance.onerror = resolve; window.speechSynthesis.speak(utterance); }); } function pickSoftFrenchVoice() { const voices = window.speechSynthesis.getVoices(); const frenchVoices = voices.filter((voice) => voice.lang && voice.lang.toLowerCase().startsWith("fr")); const preferredNames = [ "audrey", "aurelie", "aurélie", "marie", "julie", "celine", "céline", "lea", "léa", "amelie", "amélie", "sylvie", "hortense" ]; return ( frenchVoices.find((voice) => preferredNames.some((name) => voice.name.toLowerCase().includes(name))) || frenchVoices.find((voice) => voice.lang === "fr-FR" && /female|femme|google|microsoft/i.test(voice.name)) || frenchVoices.find((voice) => voice.lang === "fr-FR") || frenchVoices[0] || null ); } window.speechSynthesis?.addEventListener?.("voiceschanged", pickSoftFrenchVoice); async function resetConversation() { await fetch("/api/reset", { method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({sessionId}), }).catch(() => {}); conversation.innerHTML = ""; addBubble("Nouvelle conversation. Je t'ecoute.", "assistant"); setStatus("Conversation reinitialisee", "live"); }