diff --git a/README.md b/README.md index d541f69..9e71afa 100644 --- a/README.md +++ b/README.md @@ -34,12 +34,12 @@ docker compose up --build - Cliquer sur "Démarrer la séance" - Poser une question ou répondre au quiz - Regarder la progression se mettre à jour -- L'avatar lit les réponses via la synthèse vocale du navigateur +- L'avatar lit les réponses via une synthèse vocale OpenAI avec plusieurs styles de voix ## Limites du POC - l'avatar est volontairement simple (SVG/CSS) pour rester 100 % web -- la voix entrante utilise le navigateur via Web Speech API quand disponible +- la voix sortante utilise l'API TTS OpenAI avec des profils de personnalité - la progression couvre seulement quelques micro-compétences pour la démo - pas encore de dashboard parent ni de conformité RGPD/CNIL complète diff --git a/backend/app/main.py b/backend/app/main.py index 8ecf813..7143722 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,5 +1,5 @@ from contextlib import asynccontextmanager -from fastapi import Depends, FastAPI, File, HTTPException, UploadFile +from fastapi import Depends, FastAPI, File, HTTPException, Response, UploadFile from fastapi.middleware.cors import CORSMiddleware from sqlalchemy.orm import Session from .database import Base, engine, get_db @@ -9,8 +9,10 @@ from .services import ( build_llm_reply, ensure_student_mastery, evaluate_answer, + list_tts_profiles, pick_next_skill, seed_skills, + synthesize_speech, transcribe_audio, ) @@ -68,7 +70,7 @@ def start_session(student_id: int, db: Session = Depends(get_db)): ensure_student_mastery(db, student) message = ( - f"Bonjour {student.first_name} ! Je suis ton professeur virtuel. " + f"Bonjour {student.first_name} ! Je suis Professeur TOP, ton professeur virtuel. " "Aujourd'hui, on va apprendre pas à pas et faire un petit test pour voir ce que tu maîtrises déjà." ) db.add(models.Message(student_id=student.id, role="assistant", content=message)) @@ -109,6 +111,23 @@ async def transcribe(file: UploadFile = File(...)): return {"text": text} +@app.get("/tts/profiles", response_model=schemas.TTSProfilesResponse) +def get_tts_profiles(): + return schemas.TTSProfilesResponse(profiles=list_tts_profiles()) + + +@app.post("/tts") +def text_to_speech(payload: schemas.TTSRequest): + try: + audio_bytes = synthesize_speech(payload.text, payload.profile_id) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except Exception as exc: + raise HTTPException(status_code=502, detail=f"Erreur de synthèse vocale: {exc}") from exc + + return Response(content=audio_bytes, media_type="audio/mpeg") + + @app.get("/progress/{student_id}", response_model=schemas.ProgressResponse) def get_progress(student_id: int, db: Session = Depends(get_db)): student = db.query(models.Student).filter_by(id=student_id).first() diff --git a/backend/app/schemas.py b/backend/app/schemas.py index b3283c0..c7f5bf8 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -28,6 +28,21 @@ class ChatResponse(BaseModel): should_speak: bool = True +class TTSProfile(BaseModel): + id: str + label: str + description: str + + +class TTSProfilesResponse(BaseModel): + profiles: List[TTSProfile] + + +class TTSRequest(BaseModel): + text: str = Field(..., min_length=1, max_length=4096) + profile_id: str = Field(..., min_length=1) + + class SkillProgress(BaseModel): code: str subject: str diff --git a/backend/app/services.py b/backend/app/services.py index da1f374..9007c5f 100644 --- a/backend/app/services.py +++ b/backend/app/services.py @@ -9,7 +9,7 @@ client = OpenAI(api_key=os.getenv("OPENAI_API_KEY")) SYSTEM_PROMPT = """ -Tu es ProfAmi, un professeur virtuel français pour enfants de 8 à 12 ans. +Tu es Professeur TOP, un professeur virtuel français pour enfants de 8 à 12 ans. Règles : - Tu parles toujours en français simple et chaleureux. - Tu donnes des explications très courtes, puis un mini exemple. @@ -20,6 +20,58 @@ Règles : - Tu enseignes principalement le programme national français niveau primaire/cycle 3. """.strip() +TTS_PROFILES = [ + { + "id": "rigolote", + "label": "Rigolote", + "description": "La plus joueuse et cartoon.", + "voice": "coral", + "speed": 1.08, + "instructions": ( + "Parle en français avec une énergie joyeuse, malicieuse et très expressive. " + "Tu es Professeur TOP, un professeur amusant, chaleureux et un peu théâtral. " + "Le ton doit rester clair pour un enfant, avec des intonations souriantes, " + "des fins de phrases dynamiques et une diction très vivante." + ), + }, + { + "id": "petillante", + "label": "Pétillante", + "description": "Enjouée, dynamique et encourageante.", + "voice": "shimmer", + "speed": 1.03, + "instructions": ( + "Parle en français avec une voix lumineuse, motivante et positive. " + "Tu es Professeur TOP: enthousiaste, rassurant et très engageant, " + "sans caricature excessive. Garde une diction nette et chaleureuse." + ), + }, + { + "id": "douce", + "label": "Douce", + "description": "Calme, rassurante et patiente.", + "voice": "sage", + "speed": 0.98, + "instructions": ( + "Parle en français avec une voix douce, calme et très rassurante. " + "Tu es Professeur TOP dans une version posée, bienveillante et patiente. " + "Le rythme est fluide, jamais pressé, avec une intonation apaisante." + ), + }, + { + "id": "sobre", + "label": "Sobre", + "description": "La plus neutre et sérieuse.", + "voice": "alloy", + "speed": 1.0, + "instructions": ( + "Parle en français avec une voix claire, naturelle et sobre. " + "Tu es Professeur TOP dans une version très lisible, professionnelle et mesurée. " + "Reste chaleureux, mais sans effet théâtral." + ), + }, +] + def seed_skills(db: Session) -> None: for skill in SKILLS: @@ -88,6 +140,37 @@ def build_llm_reply(db: Session, student_id: int, user_message: str) -> str: return response.output_text.strip() +def list_tts_profiles() -> list[dict]: + return [ + { + "id": profile["id"], + "label": profile["label"], + "description": profile["description"], + } + for profile in TTS_PROFILES + ] + + +def synthesize_speech(text: str, profile_id: str) -> bytes: + clean_text = text.strip() + if not clean_text: + raise ValueError("Texte à synthétiser manquant") + + profile = next((item for item in TTS_PROFILES if item["id"] == profile_id), None) + if not profile: + raise ValueError("Profil de voix inconnu") + + response = client.audio.speech.create( + model="gpt-4o-mini-tts", + voice=profile["voice"], + input=clean_text[:4096], + instructions=profile["instructions"], + response_format="mp3", + speed=profile["speed"], + ) + return response.content + + def transcribe_audio(filename: str, audio_bytes: bytes, content_type: str | None = None) -> str: file_payload = (filename, audio_bytes, content_type or "application/octet-stream") transcript = client.audio.transcriptions.create( diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index df74119..91b0685 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -6,6 +6,7 @@ const AUTO_STOP_SILENCE_MS = 2500 const SPEECH_START_THRESHOLD = 0.02 const SILENCE_THRESHOLD = 0.012 const DEBUG_AUDIO = false +const TTS_PROFILE_STORAGE_KEY = 'professeur-top-tts-profile' async function parseApiResponse(res) { const contentType = res.headers.get('content-type') || '' @@ -34,6 +35,28 @@ async function apiFetch(path, options) { return parseApiResponse(res) } +async function fetchAudio(path, options) { + const res = await fetch(`${API_BASE}${path}`, options) + if (!res.ok) { + let detail = `Erreur API (${res.status})` + const contentType = res.headers.get('content-type') || '' + const bodyText = await res.text() + + if (contentType.includes('application/json') && bodyText) { + try { + const data = JSON.parse(bodyText) + detail = data?.detail || data?.message || detail + } catch { + if (bodyText.trim()) detail = bodyText.trim() + } + } else if (bodyText.trim()) { + detail = bodyText.trim() + } + throw new Error(detail) + } + return res.blob() +} + function Avatar({ speaking }) { return (
@@ -93,8 +116,8 @@ export default function App() { const [errorMessage, setErrorMessage] = useState('') const [micLevel, setMicLevel] = useState(0) const [audioDebug, setAudioDebug] = useState([]) - const [voices, setVoices] = useState([]) - const [selectedVoiceURI, setSelectedVoiceURI] = useState('') + const [voiceProfiles, setVoiceProfiles] = useState([]) + const [selectedVoiceProfileId, setSelectedVoiceProfileId] = useState('') const mediaRecorderRef = useRef(null) const mediaStreamRef = useRef(null) const recordedChunksRef = useRef([]) @@ -111,16 +134,19 @@ export default function App() { const isTranscribingRef = useRef(false) const speakingRef = useRef(false) const levelFrameCountRef = useRef(0) - - const availableVoices = useMemo(() => { - return [...voices].sort((a, b) => scoreVoice(b) - scoreVoice(a)) - }, [voices]) + const audioPlaybackRef = useRef(null) + const audioPlaybackUrlRef = useRef('') const selectedStudent = useMemo( () => students.find((student) => String(student.id) === String(selectedStudentId)), [students, selectedStudentId] ) + const selectedVoiceProfile = useMemo( + () => voiceProfiles.find((profile) => profile.id === selectedVoiceProfileId) || null, + [voiceProfiles, selectedVoiceProfileId] + ) + useEffect(() => { loadStudents() }, []) @@ -154,23 +180,15 @@ export default function App() { }, [selectedStudentId]) useEffect(() => { - if (!('speechSynthesis' in window)) return undefined - - const loadVoices = () => { - const nextVoices = window.speechSynthesis.getVoices() - setVoices(nextVoices) - setSelectedVoiceURI((currentVoiceURI) => { - if (currentVoiceURI) return currentVoiceURI - const preferredVoice = [...nextVoices].sort((a, b) => scoreVoice(b) - scoreVoice(a))[0] - return preferredVoice?.voiceURI || '' - }) + if (selectedVoiceProfileId) { + window.localStorage.setItem(TTS_PROFILE_STORAGE_KEY, selectedVoiceProfileId) } + }, [selectedVoiceProfileId]) - loadVoices() - window.speechSynthesis.onvoiceschanged = loadVoices - + useEffect(() => { + loadVoiceProfiles() return () => { - window.speechSynthesis.onvoiceschanged = null + stopSpeechPlayback() deactivateAutoListening(false) } }, []) @@ -188,6 +206,28 @@ export default function App() { } } + async function loadVoiceProfiles() { + try { + const data = await apiFetch('/tts/profiles') + const nextProfiles = data.profiles || [] + setVoiceProfiles(nextProfiles) + setSelectedVoiceProfileId((currentId) => { + if (currentId && nextProfiles.some((profile) => profile.id === currentId)) { + return currentId + } + + const storedId = window.localStorage.getItem(TTS_PROFILE_STORAGE_KEY) + if (storedId && nextProfiles.some((profile) => profile.id === storedId)) { + return storedId + } + + return nextProfiles[0]?.id || '' + }) + } catch (error) { + setErrorMessage(error.message || 'Impossible de charger les voix du professeur.') + } + } + async function createStudent(e) { e.preventDefault() if (!form.first_name.trim()) { @@ -272,31 +312,77 @@ export default function App() { } } - function speak(text) { - if (!('speechSynthesis' in window)) return - window.speechSynthesis.cancel() - const utterance = new SpeechSynthesisUtterance(text) - const selectedVoice = voices.find((voice) => voice.voiceURI === selectedVoiceURI) - utterance.lang = selectedVoice?.lang || 'fr-FR' - if (selectedVoice) { - utterance.voice = selectedVoice + function stopSpeechPlayback() { + if (audioPlaybackRef.current) { + audioPlaybackRef.current.pause() + audioPlaybackRef.current.src = '' + audioPlaybackRef.current = null } - utterance.onstart = () => setSpeaking(true) - utterance.onend = async () => { - setSpeaking(false) - if (isAutoListeningRef.current && !isRecordingRef.current && !isTranscribingRef.current) { - pushAudioDebug('Fin de voix prof, réarmement du segment micro') - await startSegmentRecording() + if (audioPlaybackUrlRef.current) { + URL.revokeObjectURL(audioPlaybackUrlRef.current) + audioPlaybackUrlRef.current = '' + } + setSpeaking(false) + } + + async function finishSpeechPlayback(debugMessage) { + setSpeaking(false) + if (audioPlaybackUrlRef.current) { + URL.revokeObjectURL(audioPlaybackUrlRef.current) + audioPlaybackUrlRef.current = '' + } + audioPlaybackRef.current = null + + if (debugMessage) { + pushAudioDebug(debugMessage) + } + + if (isAutoListeningRef.current && !isRecordingRef.current && !isTranscribingRef.current) { + await startSegmentRecording() + } + } + + async function speak(text) { + const cleanText = text.trim() + if (!cleanText) return + if (!selectedVoiceProfileId) { + setVoiceStatus('Aucune voix Professeur TOP disponible.') + return + } + + stopSpeechPlayback() + setVoiceStatus(`Professeur TOP parle en mode ${selectedVoiceProfile?.label || selectedVoiceProfileId}...`) + + try { + const audioBlob = await fetchAudio('/tts', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + text: cleanText, + profile_id: selectedVoiceProfileId, + }), + }) + + const objectUrl = URL.createObjectURL(audioBlob) + const audio = new Audio(objectUrl) + audioPlaybackRef.current = audio + audioPlaybackUrlRef.current = objectUrl + + audio.onplay = () => setSpeaking(true) + audio.onended = () => { + setVoiceStatus('') + void finishSpeechPlayback('Fin de voix prof, réarmement du segment micro') } - } - utterance.onerror = async () => { - setSpeaking(false) - if (isAutoListeningRef.current && !isRecordingRef.current && !isTranscribingRef.current) { - pushAudioDebug('Erreur voix prof, réarmement du segment micro') - await startSegmentRecording() + audio.onerror = () => { + setVoiceStatus('Lecture audio impossible.') + void finishSpeechPlayback('Erreur voix prof, réarmement du segment micro') } + + await audio.play() + } catch (error) { + setVoiceStatus(error.message || 'Impossible de générer la voix du professeur.') + await finishSpeechPlayback('Erreur synthèse vocale, réarmement du segment micro') } - window.speechSynthesis.speak(utterance) } function stopMediaStream() { @@ -671,14 +757,14 @@ export default function App() { placeholder="Pose une question ou demande une explication..." /> @@ -693,6 +779,11 @@ export default function App() { Niveau micro: {Math.round(Math.min(micLevel * 1200, 100))}%
)} + {selectedVoiceProfile && ( +

+ Voix IA non humaine: {selectedVoiceProfile.label} · {selectedVoiceProfile.description} +

+ )} {voiceStatus &&

{voiceStatus}

} {errorMessage &&

{errorMessage}

} {DEBUG_AUDIO && ( @@ -704,16 +795,3 @@ export default function App() { ) } - -function scoreVoice(voice) { - let score = 0 - const name = `${voice.name} ${voice.voiceURI}`.toLowerCase() - const lang = (voice.lang || '').toLowerCase() - - if (lang.startsWith('fr')) score += 100 - if (name.includes('google') || name.includes('microsoft')) score += 20 - if (name.includes('natural') || name.includes('premium') || name.includes('enhanced')) score += 15 - if (name.includes('hortense') || name.includes('amelie') || name.includes('thomas')) score += 10 - - return score -} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 56f0a4d..87ec25f 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -92,6 +92,10 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } background: linear-gradient(90deg, #60a5fa, #2563eb); } .muted { color: #64748b; } +.voice-note { + margin: 0; + font-size: 0.95rem; +} .avatar-shell { display: flex; flex-direction: column; align-items: center; gap: 0.35rem; } .avatar { width: 144px;