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

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