Add hands-free PWA mode and systemd service
This commit is contained in:
244
static/app.js
244
static/app.js
@@ -8,11 +8,26 @@ 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 isRecording = false;
|
||||
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(() => {});
|
||||
@@ -89,7 +104,7 @@ async function sendText() {
|
||||
body: JSON.stringify({
|
||||
sessionId,
|
||||
text,
|
||||
withAudio: audioToggle.checked,
|
||||
withAudio: false,
|
||||
}),
|
||||
});
|
||||
const data = await response.json();
|
||||
@@ -102,6 +117,14 @@ async function sendText() {
|
||||
}
|
||||
|
||||
async function toggleVoice() {
|
||||
if (handsFreeMode) {
|
||||
stopHandsFree("Arrete");
|
||||
return;
|
||||
}
|
||||
await startHandsFree();
|
||||
}
|
||||
|
||||
async function startHandsFree() {
|
||||
setStatus("Activation du micro", "live");
|
||||
await unlockAudio();
|
||||
|
||||
@@ -117,52 +140,167 @@ async function toggleVoice() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isRecording) {
|
||||
recorder.stop();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({audio: true});
|
||||
const mimeType = pickMimeType();
|
||||
recorder = new MediaRecorder(stream, mimeType ? {mimeType} : undefined);
|
||||
chunks = [];
|
||||
|
||||
recorder.ondataavailable = (event) => {
|
||||
if (event.data.size) chunks.push(event.data);
|
||||
};
|
||||
|
||||
recorder.onstop = async () => {
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
isRecording = false;
|
||||
voiceButton.classList.remove("recording");
|
||||
voiceLabel.textContent = "Demarrer";
|
||||
await sendVoiceBlob(new Blob(chunks, {type: recorder.mimeType || "audio/webm"}));
|
||||
};
|
||||
|
||||
recorder.start();
|
||||
isRecording = true;
|
||||
mediaStream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
autoGainControl: true,
|
||||
},
|
||||
});
|
||||
handsFreeMode = true;
|
||||
voiceButton.classList.add("recording");
|
||||
voiceLabel.textContent = "Stop";
|
||||
setStatus("Je t'ecoute", "live");
|
||||
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) {
|
||||
addBubble("Message vocal envoye", "user");
|
||||
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 {
|
||||
@@ -174,23 +312,65 @@ async function sendVoiceBlob(blob) {
|
||||
} catch (error) {
|
||||
addBubble(error.message, "error");
|
||||
setStatus("Erreur", "error");
|
||||
} finally {
|
||||
processingVoice = false;
|
||||
if (handsFreeMode) {
|
||||
window.setTimeout(startListeningTurn, 250);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAssistantResponse(data) {
|
||||
addBubble(data.reply || "", "assistant");
|
||||
const reply = data.reply || "";
|
||||
addBubble(reply, "assistant");
|
||||
setStatus(data.emailSent ? "Email envoye" : "Pret", data.emailSent ? "live" : "idle");
|
||||
|
||||
if (audioToggle.checked && data.audioUrl) {
|
||||
if (!audioToggle.checked || !reply) return;
|
||||
|
||||
if (data.audioUrl) {
|
||||
try {
|
||||
const audio = new Audio(data.audioUrl);
|
||||
await audio.play();
|
||||
return;
|
||||
} catch {
|
||||
addBubble("Audio pret, mais le navigateur a bloque la lecture automatique.", "error");
|
||||
// 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",
|
||||
|
||||
Reference in New Issue
Block a user