384 lines
10 KiB
JavaScript
384 lines
10 KiB
JavaScript
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");
|
|
setStatus(data.emailSent ? "Email envoye" : "Pret", data.emailSent ? "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.98;
|
|
utterance.pitch = 1;
|
|
utterance.voice = pickFrenchVoice();
|
|
utterance.onend = resolve;
|
|
utterance.onerror = resolve;
|
|
window.speechSynthesis.speak(utterance);
|
|
});
|
|
}
|
|
|
|
function pickFrenchVoice() {
|
|
const voices = window.speechSynthesis.getVoices();
|
|
return (
|
|
voices.find((voice) => voice.lang === "fr-FR" && /google|microsoft/i.test(voice.name)) ||
|
|
voices.find((voice) => voice.lang === "fr-FR") ||
|
|
voices.find((voice) => voice.lang && voice.lang.toLowerCase().startsWith("fr")) ||
|
|
null
|
|
);
|
|
}
|
|
|
|
window.speechSynthesis?.addEventListener?.("voiceschanged", pickFrenchVoice);
|
|
|
|
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");
|
|
}
|