Génération TTS asynchrone pour éviter le blocage Flask
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/<job_id>: polling du statut
- GET /chat/download-audio/<job_id>: 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 <noreply@anthropic.com>
This commit is contained in:
@@ -105,6 +105,9 @@ processing_jobs: Dict[str, Dict[str, Any]] = {} # {job_id: {"status": str, "que
|
|||||||
# Stockage des sessions de chat en cours
|
# Stockage des sessions de chat en cours
|
||||||
chat_sessions: Dict[str, Dict[str, Any]] = {} # {session_id: {"status": str, "queue": Queue, "context": list}}
|
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
|
# 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
|
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/<job_id>`` : Check generation status
|
||||||
|
- ``/chat/download-audio/<job_id>`` : 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/<job_id>", 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/<job_id>", 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
|
# PDF Upload & Processing
|
||||||
# ═══════════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -422,6 +422,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.reformulation-header {
|
.reformulation-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1065,7 +1074,7 @@
|
|||||||
|
|
||||||
// Add click handler for Audio export
|
// Add click handler for Audio export
|
||||||
exportAudioBtn.addEventListener('click', async () => {
|
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 {
|
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 = `
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" style="animation: spin 1s linear infinite;">
|
||||||
|
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" fill="none" opacity="0.25"/>
|
||||||
|
<path d="M12 2a10 10 0 0 1 10 10" stroke="currentColor" stroke-width="2" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
Génération...
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Start async audio generation
|
||||||
|
const startResponse = await fetch('/chat/generate-audio', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -1390,25 +1414,64 @@
|
|||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!startResponse.ok) {
|
||||||
const error = await response.json();
|
const error = await startResponse.json();
|
||||||
throw new Error(error.error || 'TTS failed');
|
throw new Error(error.error || 'Failed to start TTS job');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download file
|
const { job_id } = await startResponse.json();
|
||||||
const blob = await response.blob();
|
|
||||||
const url = window.URL.createObjectURL(blob);
|
// Poll for job status
|
||||||
const a = document.createElement('a');
|
const checkStatus = async () => {
|
||||||
a.href = url;
|
const statusResponse = await fetch(`/chat/audio-status/${job_id}`);
|
||||||
a.download = `chat_audio_${new Date().getTime()}.wav`;
|
if (!statusResponse.ok) {
|
||||||
document.body.appendChild(a);
|
throw new Error('Failed to check TTS status');
|
||||||
a.click();
|
}
|
||||||
window.URL.revokeObjectURL(url);
|
|
||||||
document.body.removeChild(a);
|
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) {
|
} catch (error) {
|
||||||
console.error('TTS error:', error);
|
console.error('TTS error:', error);
|
||||||
alert(`Erreur TTS: ${error.message}`);
|
alert(`Erreur TTS: ${error.message}`);
|
||||||
|
|
||||||
|
// Restore button state on error
|
||||||
|
if (buttonElement) {
|
||||||
|
buttonElement.disabled = false;
|
||||||
|
buttonElement.innerHTML = `
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M11 5L6 9H2v6h4l5 4V5z"/>
|
||||||
|
<path d="M15.54 8.46a5 5 0 0 1 0 7.07"/>
|
||||||
|
<path d="M19.07 4.93a10 10 0 0 1 0 14.14"/>
|
||||||
|
</svg>
|
||||||
|
Audio
|
||||||
|
`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user