This commit is contained in:
2026-04-24 21:49:58 +02:00
parent e592ed44af
commit 8cd28873e8
6 changed files with 265 additions and 66 deletions

View File

@@ -34,12 +34,12 @@ docker compose up --build
- Cliquer sur "Démarrer la séance" - Cliquer sur "Démarrer la séance"
- Poser une question ou répondre au quiz - Poser une question ou répondre au quiz
- Regarder la progression se mettre à jour - 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 ## Limites du POC
- l'avatar est volontairement simple (SVG/CSS) pour rester 100 % web - 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 - la progression couvre seulement quelques micro-compétences pour la démo
- pas encore de dashboard parent ni de conformité RGPD/CNIL complète - pas encore de dashboard parent ni de conformité RGPD/CNIL complète

View File

@@ -1,5 +1,5 @@
from contextlib import asynccontextmanager 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 fastapi.middleware.cors import CORSMiddleware
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .database import Base, engine, get_db from .database import Base, engine, get_db
@@ -9,8 +9,10 @@ from .services import (
build_llm_reply, build_llm_reply,
ensure_student_mastery, ensure_student_mastery,
evaluate_answer, evaluate_answer,
list_tts_profiles,
pick_next_skill, pick_next_skill,
seed_skills, seed_skills,
synthesize_speech,
transcribe_audio, transcribe_audio,
) )
@@ -68,7 +70,7 @@ def start_session(student_id: int, db: Session = Depends(get_db)):
ensure_student_mastery(db, student) ensure_student_mastery(db, student)
message = ( 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à." "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)) 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} 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) @app.get("/progress/{student_id}", response_model=schemas.ProgressResponse)
def get_progress(student_id: int, db: Session = Depends(get_db)): def get_progress(student_id: int, db: Session = Depends(get_db)):
student = db.query(models.Student).filter_by(id=student_id).first() student = db.query(models.Student).filter_by(id=student_id).first()

View File

@@ -28,6 +28,21 @@ class ChatResponse(BaseModel):
should_speak: bool = True 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): class SkillProgress(BaseModel):
code: str code: str
subject: str subject: str

View File

@@ -9,7 +9,7 @@ client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
SYSTEM_PROMPT = """ 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 : Règles :
- Tu parles toujours en français simple et chaleureux. - Tu parles toujours en français simple et chaleureux.
- Tu donnes des explications très courtes, puis un mini exemple. - 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. - Tu enseignes principalement le programme national français niveau primaire/cycle 3.
""".strip() """.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: def seed_skills(db: Session) -> None:
for skill in SKILLS: 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() 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: 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") file_payload = (filename, audio_bytes, content_type or "application/octet-stream")
transcript = client.audio.transcriptions.create( transcript = client.audio.transcriptions.create(

View File

@@ -6,6 +6,7 @@ const AUTO_STOP_SILENCE_MS = 2500
const SPEECH_START_THRESHOLD = 0.02 const SPEECH_START_THRESHOLD = 0.02
const SILENCE_THRESHOLD = 0.012 const SILENCE_THRESHOLD = 0.012
const DEBUG_AUDIO = false const DEBUG_AUDIO = false
const TTS_PROFILE_STORAGE_KEY = 'professeur-top-tts-profile'
async function parseApiResponse(res) { async function parseApiResponse(res) {
const contentType = res.headers.get('content-type') || '' const contentType = res.headers.get('content-type') || ''
@@ -34,6 +35,28 @@ async function apiFetch(path, options) {
return parseApiResponse(res) 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 }) { function Avatar({ speaking }) {
return ( return (
<div className="avatar-shell"> <div className="avatar-shell">
@@ -93,8 +116,8 @@ export default function App() {
const [errorMessage, setErrorMessage] = useState('') const [errorMessage, setErrorMessage] = useState('')
const [micLevel, setMicLevel] = useState(0) const [micLevel, setMicLevel] = useState(0)
const [audioDebug, setAudioDebug] = useState([]) const [audioDebug, setAudioDebug] = useState([])
const [voices, setVoices] = useState([]) const [voiceProfiles, setVoiceProfiles] = useState([])
const [selectedVoiceURI, setSelectedVoiceURI] = useState('') const [selectedVoiceProfileId, setSelectedVoiceProfileId] = useState('')
const mediaRecorderRef = useRef(null) const mediaRecorderRef = useRef(null)
const mediaStreamRef = useRef(null) const mediaStreamRef = useRef(null)
const recordedChunksRef = useRef([]) const recordedChunksRef = useRef([])
@@ -111,16 +134,19 @@ export default function App() {
const isTranscribingRef = useRef(false) const isTranscribingRef = useRef(false)
const speakingRef = useRef(false) const speakingRef = useRef(false)
const levelFrameCountRef = useRef(0) const levelFrameCountRef = useRef(0)
const audioPlaybackRef = useRef(null)
const availableVoices = useMemo(() => { const audioPlaybackUrlRef = useRef('')
return [...voices].sort((a, b) => scoreVoice(b) - scoreVoice(a))
}, [voices])
const selectedStudent = useMemo( const selectedStudent = useMemo(
() => students.find((student) => String(student.id) === String(selectedStudentId)), () => students.find((student) => String(student.id) === String(selectedStudentId)),
[students, selectedStudentId] [students, selectedStudentId]
) )
const selectedVoiceProfile = useMemo(
() => voiceProfiles.find((profile) => profile.id === selectedVoiceProfileId) || null,
[voiceProfiles, selectedVoiceProfileId]
)
useEffect(() => { useEffect(() => {
loadStudents() loadStudents()
}, []) }, [])
@@ -154,23 +180,15 @@ export default function App() {
}, [selectedStudentId]) }, [selectedStudentId])
useEffect(() => { useEffect(() => {
if (!('speechSynthesis' in window)) return undefined if (selectedVoiceProfileId) {
window.localStorage.setItem(TTS_PROFILE_STORAGE_KEY, selectedVoiceProfileId)
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 || ''
})
} }
}, [selectedVoiceProfileId])
loadVoices() useEffect(() => {
window.speechSynthesis.onvoiceschanged = loadVoices loadVoiceProfiles()
return () => { return () => {
window.speechSynthesis.onvoiceschanged = null stopSpeechPlayback()
deactivateAutoListening(false) 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) { async function createStudent(e) {
e.preventDefault() e.preventDefault()
if (!form.first_name.trim()) { if (!form.first_name.trim()) {
@@ -272,31 +312,77 @@ export default function App() {
} }
} }
function speak(text) { function stopSpeechPlayback() {
if (!('speechSynthesis' in window)) return if (audioPlaybackRef.current) {
window.speechSynthesis.cancel() audioPlaybackRef.current.pause()
const utterance = new SpeechSynthesisUtterance(text) audioPlaybackRef.current.src = ''
const selectedVoice = voices.find((voice) => voice.voiceURI === selectedVoiceURI) audioPlaybackRef.current = null
utterance.lang = selectedVoice?.lang || 'fr-FR'
if (selectedVoice) {
utterance.voice = selectedVoice
} }
utterance.onstart = () => setSpeaking(true) if (audioPlaybackUrlRef.current) {
utterance.onend = async () => { URL.revokeObjectURL(audioPlaybackUrlRef.current)
setSpeaking(false) audioPlaybackUrlRef.current = ''
if (isAutoListeningRef.current && !isRecordingRef.current && !isTranscribingRef.current) { }
pushAudioDebug('Fin de voix prof, réarmement du segment micro') setSpeaking(false)
await startSegmentRecording() }
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')
} }
} audio.onerror = () => {
utterance.onerror = async () => { setVoiceStatus('Lecture audio impossible.')
setSpeaking(false) void finishSpeechPlayback('Erreur voix prof, réarmement du segment micro')
if (isAutoListeningRef.current && !isRecordingRef.current && !isTranscribingRef.current) {
pushAudioDebug('Erreur voix prof, réarmement du segment micro')
await startSegmentRecording()
} }
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() { function stopMediaStream() {
@@ -671,14 +757,14 @@ export default function App() {
placeholder="Pose une question ou demande une explication..." placeholder="Pose une question ou demande une explication..."
/> />
<select <select
value={selectedVoiceURI} value={selectedVoiceProfileId}
onChange={(event) => setSelectedVoiceURI(event.target.value)} onChange={(event) => setSelectedVoiceProfileId(event.target.value)}
title="Choisir la voix du professeur" title="Choisir la personnalité vocale de Professeur TOP"
> >
<option value="">Voix du professeur</option> <option value="">Voix de Professeur TOP</option>
{availableVoices.map((voice) => ( {voiceProfiles.map((profile) => (
<option key={voice.voiceURI} value={voice.voiceURI}> <option key={profile.id} value={profile.id}>
{voice.name} · {voice.lang} {profile.label}
</option> </option>
))} ))}
</select> </select>
@@ -693,6 +779,11 @@ export default function App() {
Niveau micro: {Math.round(Math.min(micLevel * 1200, 100))}% Niveau micro: {Math.round(Math.min(micLevel * 1200, 100))}%
</div> </div>
)} )}
{selectedVoiceProfile && (
<p className="muted voice-note">
Voix IA non humaine: {selectedVoiceProfile.label} · {selectedVoiceProfile.description}
</p>
)}
{voiceStatus && <p className="muted voice-status">{voiceStatus}</p>} {voiceStatus && <p className="muted voice-status">{voiceStatus}</p>}
{errorMessage && <p className="muted voice-status">{errorMessage}</p>} {errorMessage && <p className="muted voice-status">{errorMessage}</p>}
{DEBUG_AUDIO && ( {DEBUG_AUDIO && (
@@ -704,16 +795,3 @@ export default function App() {
</div> </div>
) )
} }
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
}

View File

@@ -92,6 +92,10 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
background: linear-gradient(90deg, #60a5fa, #2563eb); background: linear-gradient(90deg, #60a5fa, #2563eb);
} }
.muted { color: #64748b; } .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-shell { display: flex; flex-direction: column; align-items: center; gap: 0.35rem; }
.avatar { .avatar {
width: 144px; width: 144px;