From fd66917f03417211ca123a7561d60f1d506547cb Mon Sep 17 00:00:00 2001 From: David Blanc Brioir Date: Tue, 30 Dec 2025 19:45:29 +0100 Subject: [PATCH] =?UTF-8?q?G=C3=A9n=C3=A9ration=20TTS=20asynchrone=20pour?= =?UTF-8?q?=20=C3=A9viter=20le=20blocage=20Flask?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - Nouveau dictionnaire global tts_jobs pour tracker les jobs TTS - Fonction _generate_audio_background() pour génération en thread - POST /chat/generate-audio: lance génération et retourne job_id - GET /chat/audio-status/: polling du statut - GET /chat/download-audio/: télécharge l'audio terminé - États: pending → processing → completed/failed Frontend: - Fonction exportToAudio() asynchrone avec polling (1s) - Spinner animé pendant génération ("Génération...") - Téléchargement automatique quand prêt - Restauration bouton en cas d'erreur - Animation CSS @keyframes spin pour le spinner Avantages: - Flask reste responsive pendant génération TTS - Navigation possible pendant génération audio - Expérience utilisateur améliorée avec feedback visuel 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- generations/library_rag/flask_app.py | 208 ++++++++++++++++++++ generations/library_rag/templates/chat.html | 95 +++++++-- 2 files changed, 287 insertions(+), 16 deletions(-) diff --git a/generations/library_rag/flask_app.py b/generations/library_rag/flask_app.py index 666b570..e13ab29 100644 --- a/generations/library_rag/flask_app.py +++ b/generations/library_rag/flask_app.py @@ -105,6 +105,9 @@ processing_jobs: Dict[str, Dict[str, Any]] = {} # {job_id: {"status": str, "que # Stockage des sessions de chat en cours chat_sessions: Dict[str, Dict[str, Any]] = {} # {session_id: {"status": str, "queue": Queue, "context": list}} +# Stockage des jobs TTS en cours +tts_jobs: Dict[str, Dict[str, Any]] = {} # {job_id: {"status": str, "filepath": Path, "error": str}} + # ═══════════════════════════════════════════════════════════════════════════════ # Weaviate Connection # ═══════════════════════════════════════════════════════════════════════════════ @@ -1641,6 +1644,211 @@ def chat_export_audio() -> Union[WerkzeugResponse, tuple[Dict[str, Any], int]]: return jsonify({"error": f"TTS failed: {str(e)}"}), 500 +def _generate_audio_background(job_id: str, text: str, language: str) -> None: + """Background worker for TTS audio generation. + + Generates audio in a separate thread to avoid blocking Flask. + Updates the global tts_jobs dict with status and result. + + Args: + job_id: Unique identifier for this TTS job. + text: Text to convert to speech. + language: Language code for TTS. + """ + try: + from utils.tts_generator import generate_speech + + # Update status to processing + tts_jobs[job_id]["status"] = "processing" + + # Generate audio file + filepath = generate_speech( + text=text, + output_dir=app.config["UPLOAD_FOLDER"], + language=language, + ) + + # Update job with success status + tts_jobs[job_id]["status"] = "completed" + tts_jobs[job_id]["filepath"] = filepath + + except Exception as e: + # Update job with error status + tts_jobs[job_id]["status"] = "failed" + tts_jobs[job_id]["error"] = str(e) + print(f"TTS job {job_id} failed: {e}") + + +@app.route("/chat/generate-audio", methods=["POST"]) +def chat_generate_audio() -> tuple[Dict[str, Any], int]: + """Start asynchronous TTS audio generation (non-blocking). + + Launches TTS generation in a background thread and immediately returns + a job ID for status polling. This allows the Flask app to remain responsive + during audio generation. + + Request JSON: + assistant_response (str): The assistant's complete response (required). + language (str, optional): Language code for TTS ("fr", "en", etc.). + Default: "fr" (French). + + Returns: + JSON response with job_id and 202 Accepted status on success. + JSON error response with 400 status on validation failure. + + Example: + POST /chat/generate-audio + Content-Type: application/json + + { + "assistant_response": "La phénoménologie est une approche philosophique...", + "language": "fr" + } + + Response (202): + { + "job_id": "550e8400-e29b-41d4-a716-446655440000", + "status": "pending" + } + + See Also: + - ``/chat/audio-status/`` : Check generation status + - ``/chat/download-audio/`` : Download completed audio + """ + try: + data = request.get_json() + + if not data: + return {"error": "No JSON data provided"}, 400 + + assistant_response = data.get("assistant_response") + language = data.get("language", "fr") + + if not assistant_response: + return {"error": "assistant_response is required"}, 400 + + # Generate unique job ID + job_id = str(uuid.uuid4()) + + # Initialize job in pending state + tts_jobs[job_id] = { + "status": "pending", + "filepath": None, + "error": None, + } + + # Launch background thread for audio generation + thread = threading.Thread( + target=_generate_audio_background, + args=(job_id, assistant_response, language), + daemon=True, + ) + thread.start() + + # Return job ID immediately + return {"job_id": job_id, "status": "pending"}, 202 + + except Exception as e: + return {"error": f"Failed to start TTS job: {str(e)}"}, 500 + + +@app.route("/chat/audio-status/", methods=["GET"]) +def chat_audio_status(job_id: str) -> tuple[Dict[str, Any], int]: + """Check the status of a TTS audio generation job. + + Args: + job_id: Unique identifier for the TTS job. + + Returns: + JSON response with job status and 200 OK on success. + JSON error response with 404 status if job not found. + + Status Values: + - "pending": Job created but not started yet + - "processing": Audio generation in progress + - "completed": Audio ready for download + - "failed": Generation failed (error message included) + + Example: + GET /chat/audio-status/550e8400-e29b-41d4-a716-446655440000 + + Response (processing): + { + "job_id": "550e8400-e29b-41d4-a716-446655440000", + "status": "processing" + } + + Response (completed): + { + "job_id": "550e8400-e29b-41d4-a716-446655440000", + "status": "completed", + "filename": "chat_audio_20250130_143045.wav" + } + + Response (failed): + { + "job_id": "550e8400-e29b-41d4-a716-446655440000", + "status": "failed", + "error": "TTS generation failed: ..." + } + """ + job = tts_jobs.get(job_id) + + if not job: + return {"error": "Job not found"}, 404 + + response = { + "job_id": job_id, + "status": job["status"], + } + + if job["status"] == "completed" and job["filepath"]: + response["filename"] = job["filepath"].name + + if job["status"] == "failed" and job["error"]: + response["error"] = job["error"] + + return response, 200 + + +@app.route("/chat/download-audio/", methods=["GET"]) +def chat_download_audio(job_id: str) -> Union[WerkzeugResponse, tuple[Dict[str, Any], int]]: + """Download the generated audio file for a completed TTS job. + + Args: + job_id: Unique identifier for the TTS job. + + Returns: + Audio file download (.wav) if job completed successfully. + JSON error response with 404/400 status if job not found or not ready. + + Example: + GET /chat/download-audio/550e8400-e29b-41d4-a716-446655440000 + + Response: chat_audio_20250130_143045.wav (download) + """ + job = tts_jobs.get(job_id) + + if not job: + return {"error": "Job not found"}, 404 + + if job["status"] != "completed": + return {"error": f"Job not ready (status: {job['status']})"}, 400 + + filepath = job["filepath"] + + if not filepath or not filepath.exists(): + return {"error": "Audio file not found"}, 404 + + # Send file as download + return send_from_directory( + directory=filepath.parent, + path=filepath.name, + as_attachment=True, + download_name=filepath.name, + ) + + # ═══════════════════════════════════════════════════════════════════════════════ # PDF Upload & Processing # ═══════════════════════════════════════════════════════════════════════════════ diff --git a/generations/library_rag/templates/chat.html b/generations/library_rag/templates/chat.html index 6c69bf2..b083c3c 100644 --- a/generations/library_rag/templates/chat.html +++ b/generations/library_rag/templates/chat.html @@ -422,6 +422,15 @@ } } + @keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } + } + .reformulation-header { display: flex; align-items: center; @@ -1065,7 +1074,7 @@ // Add click handler for Audio export exportAudioBtn.addEventListener('click', async () => { - await exportToAudio(accumulatedText); + await exportToAudio(accumulatedText, exportAudioBtn); }); } @@ -1379,9 +1388,24 @@ } } - async function exportToAudio(assistantResponse) { + async function exportToAudio(assistantResponse, buttonElement) { try { - const response = await fetch('/chat/export-audio', { + // Save original button state + const originalHTML = buttonElement.innerHTML; + const originalDisabled = buttonElement.disabled; + + // Update button to show loading state + buttonElement.disabled = true; + buttonElement.innerHTML = ` + + + + + Génération... + `; + + // Start async audio generation + const startResponse = await fetch('/chat/generate-audio', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -1390,25 +1414,64 @@ }) }); - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || 'TTS failed'); + if (!startResponse.ok) { + const error = await startResponse.json(); + throw new Error(error.error || 'Failed to start TTS job'); } - // Download file - const blob = await response.blob(); - const url = window.URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `chat_audio_${new Date().getTime()}.wav`; - document.body.appendChild(a); - a.click(); - window.URL.revokeObjectURL(url); - document.body.removeChild(a); + const { job_id } = await startResponse.json(); + + // Poll for job status + const checkStatus = async () => { + const statusResponse = await fetch(`/chat/audio-status/${job_id}`); + if (!statusResponse.ok) { + throw new Error('Failed to check TTS status'); + } + + const status = await statusResponse.json(); + + if (status.status === 'completed') { + // Download the audio file + const downloadUrl = `/chat/download-audio/${job_id}`; + const a = document.createElement('a'); + a.href = downloadUrl; + a.download = status.filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + + // Restore button state + buttonElement.innerHTML = originalHTML; + buttonElement.disabled = originalDisabled; + + } else if (status.status === 'failed') { + throw new Error(status.error || 'TTS generation failed'); + + } else { + // Still processing, check again in 1 second + setTimeout(checkStatus, 1000); + } + }; + + // Start polling + await checkStatus(); } catch (error) { console.error('TTS error:', error); alert(`Erreur TTS: ${error.message}`); + + // Restore button state on error + if (buttonElement) { + buttonElement.disabled = false; + buttonElement.innerHTML = ` + + + + + + Audio + `; + } } }