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"
- 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

View File

@@ -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()

View File

@@ -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

View File

@@ -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(

View File

@@ -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 (
<div className="avatar-shell">
@@ -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..."
/>
<select
value={selectedVoiceURI}
onChange={(event) => setSelectedVoiceURI(event.target.value)}
title="Choisir la voix du professeur"
value={selectedVoiceProfileId}
onChange={(event) => setSelectedVoiceProfileId(event.target.value)}
title="Choisir la personnalité vocale de Professeur TOP"
>
<option value="">Voix du professeur</option>
{availableVoices.map((voice) => (
<option key={voice.voiceURI} value={voice.voiceURI}>
{voice.name} · {voice.lang}
<option value="">Voix de Professeur TOP</option>
{voiceProfiles.map((profile) => (
<option key={profile.id} value={profile.id}>
{profile.label}
</option>
))}
</select>
@@ -693,6 +779,11 @@ export default function App() {
Niveau micro: {Math.round(Math.min(micLevel * 1200, 100))}%
</div>
)}
{selectedVoiceProfile && (
<p className="muted voice-note">
Voix IA non humaine: {selectedVoiceProfile.label} · {selectedVoiceProfile.description}
</p>
)}
{voiceStatus && <p className="muted voice-status">{voiceStatus}</p>}
{errorMessage && <p className="muted voice-status">{errorMessage}</p>}
{DEBUG_AUDIO && (
@@ -704,16 +795,3 @@ export default function App() {
</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);
}
.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;