Voices
This commit is contained in:
@@ -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