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:
parent
71c1ad3018
commit
d1c8b548bb
34 changed files with 3255 additions and 70 deletions
187
src/features/simulations/components/AudioRecorder.tsx
Normal file
187
src/features/simulations/components/AudioRecorder.tsx
Normal 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 été 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>
|
||||
)
|
||||
}
|
||||
60
src/features/simulations/components/TranscriptionDisplay.tsx
Normal file
60
src/features/simulations/components/TranscriptionDisplay.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
/**
|
||||
* Affichage du transcript live Deepgram — Sprint 4c-1.
|
||||
*
|
||||
* Présente le transcript final accumulé + l'interim en cours (en italique).
|
||||
* Compteur de mots informatif. Empty state explicite tant qu'aucun mot n'a
|
||||
* été retourné.
|
||||
*/
|
||||
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { countWords } from '../lib/simulationConfig'
|
||||
|
||||
interface Props {
|
||||
/** Transcript final accumulé (segments is_final=true). */
|
||||
transcript: string
|
||||
/** Buffer interim (segment is_final=false en cours). */
|
||||
interim?: string
|
||||
/** True quand la WS Deepgram est ouverte. */
|
||||
isConnected: boolean
|
||||
}
|
||||
|
||||
export function TranscriptionDisplay({ transcript, interim = '', isConnected }: Props) {
|
||||
const total = transcript + (interim ? ` ${interim}` : '')
|
||||
const wordCount = countWords(transcript)
|
||||
const isEmpty = total.trim().length === 0
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-surface p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{isConnected ? (
|
||||
<>
|
||||
<Loader2 className="size-3.5 animate-spin text-brand-text" aria-hidden="true" />
|
||||
<span className="text-sm font-medium text-ink-primary">Transcription en cours…</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-sm font-medium text-ink-secondary">Transcription en attente</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="font-mono text-xs tabular-nums text-ink-secondary">
|
||||
{wordCount} mot{wordCount > 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="max-h-64 overflow-y-auto rounded-md bg-canvas p-3 text-sm leading-relaxed text-ink-primary"
|
||||
aria-live="polite"
|
||||
aria-atomic="false"
|
||||
>
|
||||
{isEmpty ? (
|
||||
<span className="italic text-ink-tertiary">En attente du premier mot…</span>
|
||||
) : (
|
||||
<>
|
||||
<span>{transcript}</span>
|
||||
{interim && <span className="ml-1 italic text-ink-secondary">{interim}</span>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { TranscriptionDisplay } from '../TranscriptionDisplay'
|
||||
|
||||
describe('TranscriptionDisplay', () => {
|
||||
it('affiche un état "Transcription en attente" quand non connecté et vide', () => {
|
||||
render(<TranscriptionDisplay transcript="" isConnected={false} />)
|
||||
expect(screen.getByText(/Transcription en attente/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/En attente du premier mot/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/^0 mot$/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('affiche le label "Transcription en cours…" quand connecté', () => {
|
||||
render(<TranscriptionDisplay transcript="" isConnected={true} />)
|
||||
expect(screen.getByText(/Transcription en cours/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("compte les mots du transcript final (ignore l'interim)", () => {
|
||||
render(
|
||||
<TranscriptionDisplay
|
||||
transcript="Bonjour je m appelle Pierre"
|
||||
interim="et je"
|
||||
isConnected={true}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText(/^5 mots$/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('rend transcript final + interim concaténés', () => {
|
||||
const { container } = render(
|
||||
<TranscriptionDisplay transcript="Bonjour" interim="je continue" isConnected={true} />,
|
||||
)
|
||||
expect(container.textContent).toContain('Bonjour')
|
||||
expect(container.textContent).toContain('je continue')
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue