feat(eo): complete EO simulation flow (T1 + T3) with Gemini transcription

- Gemini batch transcription (no Deepgram live)
- blobToBase64 helper (shared/lib/audio.ts)
- AudioRecorder: remove onChunk, add maxSeconds/onMaxReached auto-submit
- Timer stops at maxSeconds and triggers auto-submission
- EnregistrementEOPage: audioBase64 to backend, fix race condition step=done
- SimulationFlowProvider: submitEoAudio(audioBase64, mimeType, nclcCible)
- MIME normalization (strip codec params)
- Split CORRECTION_EE_TIMEOUT_MS (60s) / CORRECTION_EO_TIMEOUT_MS (120s)
- PresentationGenereeT1Page: localStorage persistence

Typecheck: OK · Tests: 159/159 

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hermann_Kitio 2026-04-25 08:28:51 +03:00
parent 71c1ad3018
commit d1c8b548bb
34 changed files with 3255 additions and 70 deletions

View file

@ -0,0 +1,187 @@
/**
* Composant d'enregistrement audio pour les productions orales
* Sprint 4c-1, simplifié au Sprint 4c-3.
*
* Encapsule `useAudioRecorder` côté UI : timer montant MM:SS, indicateur
* visuel d'enregistrement, garde-fou minimum 30 s, bouton de téléchargement
* local de l'audio (le backend ne stocke aucun audio).
*
* Le streaming chunk-par-chunk a é retiré au Sprint 4c-3 : l'audio est
* envoyé entier au backend après stop, le backend appelle Gemini batch pour
* transcrire. `useAudioRecorder.subscribeChunks` reste exposé côté hook
* pour un usage futur (ex. réactivation Deepgram live).
*/
import { useEffect } from 'react'
import { Download, Mic, MicOff, Square } from 'lucide-react'
import { Button } from '@/shared/ui/Button'
import { formatTimer } from '../lib/simulationConfig'
import { useAudioRecorder } from '../hooks/useAudioRecorder'
interface Props {
/** Durée minimale (s) avant que la soumission soit autorisée. */
minSeconds: number
/**
* Sprint 4b.3 durée maximale recommandée (s). À l'atteinte, le hook
* arrête automatiquement l'enregistrement et l'`onSubmit` est déclenché
* via le chemin existant (status='stopped' useEffect onSubmit).
*/
maxSeconds?: number
/** Notification optionnelle quand `maxSeconds` est atteint. */
onMaxReached?: () => void
/** Nom de fichier proposé au téléchargement local (sans extension). */
downloadFilename: string
/** Appelé au clic « Arrêter et soumettre » avec le blob final + son MIME. */
onSubmit: (audioBlob: Blob, audioMimeType: string | null) => void
onCancel: () => void
/** Initialisé à true l'utilisateur démarre l'enregistrement automatiquement
* au mount. Sinon, un bouton « Démarrer » est affiché. */
autoStart?: boolean
/**
* Sprint 4c-3 désactive les contrôles tant qu'une soumission est en
* cours côté parent (transcription + correction backend ~30-60 s).
*/
disabled?: boolean
}
export function AudioRecorder({
minSeconds,
maxSeconds,
onMaxReached,
downloadFilename,
onSubmit,
onCancel,
autoStart = true,
disabled = false,
}: Props) {
const recorder = useAudioRecorder({ maxSeconds, onMaxReached })
// Auto-start au mount si demandé. Pas de dépendance sur `recorder.start`
// pour éviter les re-runs au changement d'identité de la fonction.
useEffect(() => {
if (!autoStart) return
void recorder.start()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [autoStart])
const isRecording = recorder.status === 'recording'
const isStopped = recorder.status === 'stopped'
const remaining = Math.max(0, minSeconds - recorder.elapsedSeconds)
const submitEnabled = isRecording && remaining === 0
function handleSubmitClick() {
recorder.stop()
}
// Quand le recorder passe en 'stopped', on remonte le blob au parent.
useEffect(() => {
if (recorder.status === 'stopped' && recorder.audioBlob) {
onSubmit(recorder.audioBlob, recorder.audioMimeType)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [recorder.status, recorder.audioBlob])
if (recorder.status === 'error') {
return (
<div
role="alert"
className="rounded-lg border border-danger/40 bg-danger-soft px-4 py-3 text-sm text-danger"
>
<div className="flex items-start gap-2">
<MicOff className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
<div className="flex-1">
<p className="font-medium">{recorder.error ?? 'Erreur audio.'}</p>
{recorder.permissionDenied && (
<p className="mt-1 text-xs">
Vérifiez que le site a l'autorisation d'utiliser le micro dans les réglages du
navigateur, puis réessayez.
</p>
)}
</div>
</div>
<div className="mt-3 flex gap-2">
<Button variant="secondary" size="sm" onClick={() => void recorder.start()}>
Réessayer
</Button>
<Button variant="ghost" size="sm" onClick={onCancel}>
Annuler
</Button>
</div>
</div>
)
}
return (
<div className="rounded-lg border border-border bg-surface p-4">
<div className="flex items-center gap-3">
<span
aria-hidden="true"
className={
isRecording
? 'inline-block size-3 animate-pulse rounded-pill bg-danger'
: 'inline-block size-3 rounded-pill bg-ink-tertiary/40'
}
/>
<span className="text-sm font-medium text-ink-primary">
{recorder.status === 'requesting' && 'Autorisation du micro…'}
{isRecording && 'Enregistrement actif'}
{isStopped && 'Enregistrement terminé'}
{recorder.status === 'idle' && 'Prêt'}
</span>
<span
className="ml-auto font-mono text-xl tabular-nums text-ink-primary"
aria-live="polite"
>
{formatTimer(recorder.elapsedSeconds)}
</span>
</div>
{isRecording && remaining > 0 && (
<p className="mt-3 text-xs text-ink-secondary">
Minimum 30 secondes requis ({remaining} s restantes).
</p>
)}
<div className="mt-4 flex flex-wrap items-center gap-2">
{isRecording && (
<>
<Button
variant="primary"
size="sm"
icon={<Square className="size-4" aria-hidden="true" />}
onClick={handleSubmitClick}
disabled={!submitEnabled || disabled}
>
{submitEnabled ? 'Arrêter et soumettre' : `Arrêter et soumettre (${remaining}s)`}
</Button>
<Button variant="ghost" size="sm" onClick={onCancel} disabled={disabled}>
Annuler
</Button>
</>
)}
{recorder.status === 'idle' && (
<Button
variant="primary"
size="sm"
icon={<Mic className="size-4" aria-hidden="true" />}
onClick={() => void recorder.start()}
>
Démarrer l'enregistrement
</Button>
)}
{isStopped && (
<Button
variant="secondary"
size="sm"
icon={<Download className="size-4" aria-hidden="true" />}
onClick={() => recorder.downloadAudio(`${downloadFilename}.webm`)}
>
Télécharger l'audio
</Button>
)}
</div>
</div>
)
}