Voices
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user