feat(t1-live): T1 Live frontend — Sprint 7b
Some checks are pending
CI / quality (push) Waiting to run

- Add T1 state machine (8 states, presenting ⇄ interrupted)
- Add useT1LiveSession (WS /t1/live, uplink gate by ref, no context msg)
- Add T1PreparationPage, T1DialoguePage, T1SpeakingIndicator
- Add EO_T1_LIVE card in TaskSelector gated via oral_t2_live
- Extract shared t1Questionnaire.ts for batch/live DRY
- Remove T1LiveQuestionnairePage + T1LiveContext (post patch 7a)
- Simplified flow: card → preparation → dialogue
- FTD-44 frozen (cross-feature audio hooks, Sprint 7.5)
- FTD-45/46 frozen (Gemini relance quality + transcription)
- Tests: 301/301 green
This commit is contained in:
Hermann_Kitio 2026-06-30 22:53:57 +03:00
parent eb8987ddb3
commit 3016d909a6
14 changed files with 1385 additions and 68 deletions

View file

@ -29,6 +29,35 @@ Chaque entrée suit ce format :
---
## [Unreleased] — 2026-06-30 — Sprint 7b — Frontend T1 Live (monologue + interruption non déterministe)
### Added
- Machine d'état T1 (`features/t1-live/state/t1-machine.ts`) — 8 états purs (`idle`, `preparing`, `connecting`, `presenting`, `interrupted`, `processing`, `ended`, `error`). Le cœur est la transition `interrupted ⇄ presenting` (interruption examinateur puis reprise candidat). +23 tests.
- `useT1LiveSession` (`features/t1-live/hooks/useT1LiveSession.ts`) — orchestrateur du dialogue T1, calqué sur `useT2LiveSession` (discipline « Voie A »). WS `wss://${API_URL}/t1/live?token=<jwt>` (PAS de `&sujet=` — T1 n'est pas subject-based). Aucun VAD micro (T1 = monologue) ; l'uplink micro est coupé/rétabli pendant une interruption via un **ref** (`uplinkMutedRef`), jamais via `setState` (leçon Voie A). Réagit aux signaux applicatifs `{type:'interruption_start'}` / `{type:'interruption_end'}`. Timer dur 180 s. Close codes 1000/4001/4003/4005/4006.
- `T1PreparationPage` + `T1DialoguePage` (`features/t1-live/pages/`) — parcours préparation → dialogue (3:00) ; écran terminal « Télécharger l'audio » + « Voir le rapport » (`/rapport/:id`). L'UI ne suppose JAMAIS qu'une relance suit (interruption non déterministe).
- `T1SpeakingIndicator` (`features/t1-live/components/`) — indicateur de prise de parole (amplitude micro réelle en `presenting`, animation décorative en `interrupted`).
- Carte `EO_T1_LIVE` dans `TaskSelector` (discriminateur `live?: 'T1' | 'T2'`, label « Tâche 1 — Live ») gatée Premium via `hasAccess(plan, 'oral_t2_live')` (TD-24 — pas de nouvelle permission, le gate couvre T1 et T2 Live) + prop `onT1LiveSelect`. `SimulationEOPage` câble `onT1LiveSelect → /simulation/eo/t1/live/preparation`.
- `features/simulations/lib/t1Questionnaire.ts` — définition partagée du questionnaire T1 (FIELDS + schéma zod + `EMPTY_REPONSES`), réutilisée par le batch `QuestionnaireT1Page`.
### Changed
- `useT1LiveSession` aligné sur le **Patch 7a backend** : plus d'envoi du message `{type:'context'}`, plus d'option `reponses`, la session audio démarre directement sur `ws.onopen` (WS_OPENED → presenting).
- Parcours T1 Live simplifié : carte `EO_T1_LIVE` → préparation → dialogue (plus d'étape questionnaire intermédiaire).
- `t1-machine` : commentaire et test nettoyés (mapping close **4004** retiré → 4006), cohérent avec la suppression du contexte côté backend.
### Removed
- `T1LiveQuestionnairePage` et `T1LiveContext` (post-Patch 7a) — le backend n'exige plus de message `context` ni de réponses pré-remplies ; ces écrans/état deviennent sans objet.
### Notes
- **FTD-44 gelée** (§3bis TECH_DEBT) — les trois hooks audio génériques sont empruntés à `features/t2-live/hooks/` (violation FSD inter-features assumée et tracée, sites marqués `// TODO(FTD-44)`), réactivée au Sprint 7.5 (factorisation Sprint 7).
- WebSocket / AudioContext non matérialisables en jsdom → validation manuelle ; la logique pure de transition est couverte par `t1-machine.test.ts`.
- Bugs amont observés au test manuel, hors contrôle frontend : **FTD-45** (relances Gemini hors-sujet, extension TD-23) et **FTD-46** (transcription Gemini Live hasardeuse).
---
## [Unreleased] — 2026-06-29 — Sprint 6e — T2 Live « Voie A » (mix audio temps réel)
### Added

View file

@ -164,7 +164,7 @@
## Sprint 7 — T1 Live (interruption aléatoire)
- **7a (backend) ✅** : extension du proxy WebSocket Gemini Live (`gemini-3.1-flash-live-preview`, ws brut, pas de SDK) au mode T1 — system prompt « examinateur », décision d'interruption probabiliste, génération de la question de relance sur transcription partielle (DeepSeek). Réutilise l'infra T2 Live. Scoring EO 5 critères × /4. Phonologie live = 0 (TD-08, gelé). Contraintes héritées : pas de `speechConfig`. Livré : commits `868bd09` (code) + `3722e2a` (docs) ; dettes tracées TD-23/24/25 (cf. `TECH_DEBT-backend.md`).
- **7b (frontend)** : UI T1 Live réutilisant ws-client + audio worklet + state machine T2 ; phase préparation ; gestion interruption / reprise du flux audio dans la state machine ; gating Premium.
- **7b (frontend) ✅** : UI T1 Live — machine d'état T1 (8 états, `interrupted ⇄ presenting`), `useT1LiveSession` (WS `/t1/live`, sans message `context` post-Patch 7a, uplink coupé par ref pendant interruption), `T1PreparationPage` / `T1DialoguePage` / `T1SpeakingIndicator`, carte `EO_T1_LIVE` gatée Premium (`oral_t2_live`). Parcours simplifié carte → prépa → dialogue. `T1LiveQuestionnairePage` + `T1LiveContext` retirés. Réutilise les hooks audio T2 (FTD-44 gelée). **Bugs amont observés au test manuel** (hors contrôle frontend) : **FTD-45** (relances Gemini hors-sujet, extension TD-23) et **FTD-46** (transcription Gemini Live hasardeuse).
## Sprint 7.5 — Clean

View file

@ -1,6 +1,6 @@
# TECH_DEBT.md — Expria Frontend
> **Document de référence — Version 1.28**
> **Document de référence — Version 1.30**
> Ce document recense les décisions techniques prises par pragmatisme qui devront être revisitées, les stubs temporaires, et les fonctionnalités reportées.
> À mettre à jour après chaque session de développement.
>
@ -456,6 +456,49 @@ Frontend :
---
### FTD-44 — Hooks audio génériques empruntés à `features/t2-live/` (T1 Live)
**Priorité :** 🟡 Important
**Statut :** Gelé — réactivé au Sprint 7.5 (« factorisation Sprint 7 »)
**Estimation de session :** 0,5 jour
**Description :** Le flux T1 Live (Sprint 7b) importe directement les trois hooks audio génériques de `features/t2-live/hooks/` (`useAudioCapture`, `useAudioPlayback`, `useAudioRecording`) — une violation assumée de la frontière inter-features FSD (un feature ne devrait pas importer un sibling). Décision prise pour NE PAS toucher aux fichiers T2 (pipeline audio validé à l'oreille, intouchable jusqu'à factorisation). Sites d'import marqués `// TODO(FTD-44)` dans `features/t1-live/hooks/useT1LiveSession.ts`.
**À faire :** relocaliser les trois hooks (génériques par nature : aucune logique T2 spécifique) vers `shared/lib/audio/`, puis migrer les imports T2 ET T1 vers ce chemin partagé. Validation à l'oreille obligatoire après déplacement (T2 + T1).
**Condition de résolution :** Sprint 7.5 (factorisation Sprint 7), une fois les flux T1 et T2 Live stabilisés.
---
### FTD-45 — Relances Gemini T1 Live hors-sujet (extension TD-23)
**Priorité :** 🟡 Important
**Statut :** Gelé — dépend de l'amont (Gemini / backend TD-23), hors contrôle frontend
**Estimation de session :** à évaluer (chantier backend/prompt)
**Description :** En T1 Live, l'examinateur (Gemini) formule ses relances à partir de son contexte audio interne. Au test manuel, certaines relances partent **hors-sujet** (sans rapport avec ce que le candidat vient de dire). Extension de la dette backend **TD-23** : en VAD manuel, `inputTranscription` candidat n'est flushé qu'à `activityEnd`, donc le modèle relance sans transcription token-par-token fiable.
**Impact actuel :** dégrade le réalisme de l'entretien T1 ; non bloquant pour la livraison 7b (le flux fonctionne, l'évaluation finale reste correcte).
**À faire :** ré-évaluer côté backend/prompt (formulation de la consigne de relance, fenêtre de contexte) une fois la transcription incrémentale repensée (Sprint 7e / TD-23).
**Condition de résolution :** après traitement de TD-23 (transcription live) — non actionnable côté frontend seul.
---
### FTD-46 — Transcription Gemini Live hasardeuse (qualité audio→texte)
**Priorité :** 🟡 Important
**Statut :** Gelé — dépend de l'amont (qualité Gemini Live), hors contrôle frontend
**Estimation de session :** à évaluer
**Description :** La transcription produite par Gemini Live (`input/outputTranscription`) est de qualité **inégale** : mots manqués, segments approximatifs. Observé au test manuel T1 Live (et applicable au T2 Live). Affecte la fidélité du transcript utilisé pour l'évaluation et bloquera l'affichage live (Sprint 7e).
**Impact actuel :** qualité du transcript variable ; non bloquant pour 7b (l'évaluation 5 critères reste exploitable).
**À faire :** suivre l'évolution du modèle Gemini Live ; évaluer un post-traitement ou une source de transcription alternative si la qualité reste insuffisante au Sprint 7e.
**Condition de résolution :** amélioration amont (modèle) ou décision d'architecture transcription au Sprint 7e.
---
## 4. Tests à renforcer
> FTD-09 gelée au Sprint 5.5 (2026-04-26) — voir §3bis Backlog gelé.
@ -555,3 +598,5 @@ Frontend :
| 1.26 | 2026-04-26 | Sprint 5e (clean Sprint 5 Billing) — Ajout FTD-42 🟡 (modal prorata Standard→Premium avec montant exact — divergence PARCOURS_UTILISATEURS §3, actuellement Customer Portal natif sans preview in-app) et FTD-43 🟢 (race condition webhook post-redirect Stripe — `usePlan()` peut retourner ancien plan brièvement). **21 FTD actives — cap 15 dépassé de 6. Résorption FTD critique au Sprint 5.5 avant Sprint 6.** |
| 1.27 | 2026-04-26 | Sprint 5.5 Clean — FTD-09, FTD-33, FTD-42 gelées. FTD-35 fermée (subsumée par FTD-41). FTD-14, FTD-38, FTD-39 résolues. **14 FTD actives** (cap 15 respecté). |
| 1.28 | 2026-04-26 | Sprint 6c — FTD-09 et FTD-33 résolues (dégelées → fermées). **14 FTD actives** (inchangé — les gelées ne comptaient pas dans le cap). |
| 1.29 | 2026-06-30 | Sprint 7b (T1 Live) — Ajout FTD-44 🟡 **gelée** (hooks audio génériques empruntés à `features/t2-live/`, réactivée au Sprint 7.5). **14 FTD actives** (inchangé — entrée gelée, ne compte pas dans le cap, même mécanique que FTD-06). |
| 1.30 | 2026-06-30 | Sprint 7b (T1 Live, finalisation) — Ajout FTD-45 🟡 **gelée** (relances Gemini hors-sujet, extension TD-23) et FTD-46 🟡 **gelée** (transcription Gemini Live hasardeuse). Bugs amont observés au test manuel, hors contrôle frontend. **14 FTD actives** (inchangé — entrées gelées, ne comptent pas dans le cap). |

View file

@ -24,6 +24,8 @@ import { T2LiveProvider } from '@/features/t2-live/state/T2LiveContext'
import { T2SujetsPage } from '@/features/t2-live/pages/T2SujetsPage'
import { T2PreparationPage } from '@/features/t2-live/pages/T2PreparationPage'
import { T2DialoguePage } from '@/features/t2-live/pages/T2DialoguePage'
import { T1PreparationPage } from '@/features/t1-live/pages/T1PreparationPage'
import { T1DialoguePage } from '@/features/t1-live/pages/T1DialoguePage'
import { AppLayout } from './AppLayout'
const DesignSystemPage = import.meta.env.DEV
@ -105,6 +107,12 @@ export function AppRouter() {
<Route path="/simulation/eo/t2/dialogue" element={<T2DialoguePage />} />
</Route>
{/* Sprint 7b T1 Live (Premium) : prépa 2min dialogue 3:00 avec
interruption non déterministe de l'examinateur. Entrée directe depuis
la carte TaskSelector (plus de questionnaire Patch 7a backend). */}
<Route path="/simulation/eo/t1/live/preparation" element={<T1PreparationPage />} />
<Route path="/simulation/eo/t1/live/dialogue" element={<T1DialoguePage />} />
{/* Autres sections — Sprint 4+ */}
<Route path="/examen" element={<ComingSoon />} />
<Route path="/progression" element={<ProgressionPage />} />

View file

@ -33,14 +33,22 @@ interface Props {
* reste verrouillée.
*/
onT2LiveSelect?: () => void
/**
* Sprint 7b handler dédié pour la carte EO_T1_LIVE. Même gating Premium
* que T2 Live (`oral_t2_live`, TD-24 pas de permission distincte). La
* production EO_T1 est créée en backend en fin de session, pas au clic.
*/
onT1LiveSelect?: () => void
}
interface TaskCard {
key: string
tache: Tache | null // null = carte verrouillée (EO_T2 Live)
tache: Tache | null // null = carte non sélectionnable directement (Live → handler dédié)
label: string
sublabel: string
lockLabel?: string
/** Sprint 7b — carte « Live » (T1 ou T2) : gating Premium via hasAccess. */
live?: 'T1' | 'T2'
}
const EE_CARDS: readonly TaskCard[] = [
@ -52,12 +60,21 @@ const EE_CARDS: readonly TaskCard[] = [
const EO_CARDS: readonly TaskCard[] = [
{ key: 'EO_T1', tache: 'EO_T1', label: 'Expression Orale', sublabel: 'Entretien' },
{ key: 'EO_T3', tache: 'EO_T3', label: 'Expression Orale', sublabel: 'Point de vue' },
{
key: 'EO_T1_LIVE',
tache: null,
label: 'Expression Orale',
sublabel: 'Tâche 1 — Live',
lockLabel: 'Exclusivité Premium',
live: 'T1',
},
{
key: 'EO_T2_LIVE',
tache: 'EO_T2_LIVE',
label: 'Expression Orale',
sublabel: 'Tâche 2 — Live',
lockLabel: 'Exclusivité Premium',
live: 'T2',
},
]
@ -68,10 +85,13 @@ export function TaskSelector({
isLoading,
onSelect,
onT2LiveSelect,
onT1LiveSelect,
}: Props) {
const simulationCheck = canSimulate(plan, simulationsUsed)
const quotaBlocked = !simulationCheck.allowed
const cards = type === 'EE' ? EE_CARDS : EO_CARDS
// TD-24 : T1 Live ET T2 Live partagent la même permission `oral_t2_live`.
const t1LiveUnlocked = hasAccess(plan, 'oral_t2_live') && Boolean(onT1LiveSelect)
const t2LiveUnlocked = hasAccess(plan, 'oral_t2_live') && Boolean(onT2LiveSelect)
return (
@ -98,16 +118,21 @@ export function TaskSelector({
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
{cards.map((card) => {
const isT2Live = card.tache === 'EO_T2_LIVE'
// T2 Live ne consomme pas le quota Free (plan Premium uniquement, illimité).
// Verrouillage : T2 Live → hasAccess(plan, 'oral_t2_live') ; autres → quota.
const locked = isT2Live ? !t2LiveUnlocked : card.tache === null || quotaBlocked
const isT1Live = card.live === 'T1'
const isT2Live = card.live === 'T2'
// Live ne consomme pas le quota Free (plan Premium uniquement, illimité).
// Verrouillage : Live → hasAccess(plan, 'oral_t2_live') ; autres → quota.
const locked = isT1Live
? !t1LiveUnlocked
: isT2Live
? !t2LiveUnlocked
: card.tache === null || quotaBlocked
const abbrev = card.tache ? card.tache.split('_')[0] : 'EO'
if (locked) {
return (
<Card key={card.key} variant="default" className="flex flex-col p-4 opacity-60">
{(card.tache === null || isT2Live) && (
{(card.tache === null || isT1Live || isT2Live) && (
<Lock className="mb-2 size-4 text-ink-secondary" aria-hidden="true" />
)}
<span className="text-[11px] font-semibold uppercase tracking-widest text-ink-tertiary">
@ -128,11 +153,15 @@ export function TaskSelector({
className={cn('relative flex flex-col p-4', isLoading && 'cursor-wait')}
onClick={() => {
if (isLoading) return
if (isT1Live && onT1LiveSelect) {
onT1LiveSelect()
return
}
if (isT2Live && onT2LiveSelect) {
onT2LiveSelect()
return
}
if (card.tache && card.tache !== 'EO_T2_LIVE') {
if (card.tache && !card.live) {
onSelect({ tache: card.tache, mode: 'entrainement' })
}
}}

View file

@ -0,0 +1,78 @@
/**
* t1Questionnaire définitions partagées du questionnaire Tâche 1 EO.
*
* Source UNIQUE des 5 champs + schéma Zod, réutilisée par DEUX flux distincts :
* - `QuestionnaireT1Page` (batch) : génère une présentation via DeepSeek ;
* - `features/t1-live` (live, Sprint 7b) : envoie `{type:'context', reponses}`
* au WebSocket T1 comme contexte de l'examinateur.
*
* PORTÉE STRICTE : ce module ne contient QUE la définition du formulaire (libellés,
* placeholders, schéma). AUCUNE logique de génération ni de persistance le flux
* « présentation EO T1 » (localStorage `expria_eo_t1_presentation`, FTD-34/41)
* reste entièrement dans `QuestionnaireT1Page` / le domaine `presentation`.
*
* ADR-004 : `PresentationReponses` n'est PAS redéfini ici il est importé de
* `@/entities/presentation/types`, déjà aligné au backend `validateReponses`
* (mêmes champs). Le backend n'exige qu'un trim non vide ; le `max(FIELD_MAX)`
* ci-dessous est une contrainte UX frontend (plus stricte, donc sûre).
*/
import { z } from 'zod'
import type { PresentationReponses } from '@/entities/presentation/types'
export const FIELD_MAX = 500
export const reponsesSchema = z.object({
prenom_age_ville: z.string().trim().min(1, 'Champ obligatoire.').max(FIELD_MAX),
formation_metier: z.string().trim().min(1, 'Champ obligatoire.').max(FIELD_MAX),
situation_familiale: z.string().trim().min(1, 'Champ obligatoire.').max(FIELD_MAX),
loisirs: z.string().trim().min(1, 'Champ obligatoire.').max(FIELD_MAX),
motivation_canada: z.string().trim().min(1, 'Champ obligatoire.').max(FIELD_MAX),
})
export type FieldKey = keyof PresentationReponses
export interface FieldDef {
key: FieldKey
label: string
placeholder: string
multiline?: boolean
}
export const T1_QUESTIONNAIRE_FIELDS: FieldDef[] = [
{
key: 'prenom_age_ville',
label: "Ton prénom, âge, ville d'origine et ville où tu habites actuellement ?",
placeholder: 'Ex : Marie, 32 ans, Douala — habite actuellement Moscou',
},
{
key: 'formation_metier',
label: 'Quelle est ta formation et ton métier actuel ou passé ?',
placeholder: 'Ex : Master en gestion, comptable dans une PME',
},
{
key: 'situation_familiale',
label: 'Quelle est ta situation familiale ? (marié(e), enfants, etc.)',
placeholder: 'Ex : Marié(e), 2 enfants de 5 et 8 ans',
},
{
key: 'loisirs',
label: 'Quels sont tes 2 ou 3 loisirs ou passions principales ?',
placeholder: 'Ex : Lecture, cuisine, randonnée',
multiline: true,
},
{
key: 'motivation_canada',
label: 'Pourquoi souhaites-tu immigrer au Canada et quand envisages-tu de partir ?',
placeholder: 'Ex : Pour de meilleures opportunités professionnelles, départ prévu en 2025',
multiline: true,
},
]
export const EMPTY_REPONSES: PresentationReponses = {
prenom_age_ville: '',
formation_metier: '',
situation_familiale: '',
loisirs: '',
motivation_canada: '',
}

View file

@ -14,69 +14,18 @@ import { useEffect, useState, type FormEvent } from 'react'
import { useNavigate } from 'react-router-dom'
import { useMutation } from '@tanstack/react-query'
import { Sparkles } from 'lucide-react'
import { z } from 'zod'
import { Button } from '@/shared/ui/Button'
import type { ApiError } from '@/shared/types/api'
import { generatePresentation } from '@/entities/presentation/api'
import type { PresentationReponses } from '@/entities/presentation/types'
import { useSimulationFlow } from '../state/simulationFlow'
const FIELD_MAX = 500
const reponsesSchema = z.object({
prenom_age_ville: z.string().trim().min(1, 'Champ obligatoire.').max(FIELD_MAX),
formation_metier: z.string().trim().min(1, 'Champ obligatoire.').max(FIELD_MAX),
situation_familiale: z.string().trim().min(1, 'Champ obligatoire.').max(FIELD_MAX),
loisirs: z.string().trim().min(1, 'Champ obligatoire.').max(FIELD_MAX),
motivation_canada: z.string().trim().min(1, 'Champ obligatoire.').max(FIELD_MAX),
})
type FieldKey = keyof PresentationReponses
interface FieldDef {
key: FieldKey
label: string
placeholder: string
multiline?: boolean
}
const FIELDS: FieldDef[] = [
{
key: 'prenom_age_ville',
label: "Ton prénom, âge, ville d'origine et ville où tu habites actuellement ?",
placeholder: 'Ex : Marie, 32 ans, Douala — habite actuellement Moscou',
},
{
key: 'formation_metier',
label: 'Quelle est ta formation et ton métier actuel ou passé ?',
placeholder: 'Ex : Master en gestion, comptable dans une PME',
},
{
key: 'situation_familiale',
label: 'Quelle est ta situation familiale ? (marié(e), enfants, etc.)',
placeholder: 'Ex : Marié(e), 2 enfants de 5 et 8 ans',
},
{
key: 'loisirs',
label: 'Quels sont tes 2 ou 3 loisirs ou passions principales ?',
placeholder: 'Ex : Lecture, cuisine, randonnée',
multiline: true,
},
{
key: 'motivation_canada',
label: 'Pourquoi souhaites-tu immigrer au Canada et quand envisages-tu de partir ?',
placeholder: 'Ex : Pour de meilleures opportunités professionnelles, départ prévu en 2025',
multiline: true,
},
]
const EMPTY_REPONSES: PresentationReponses = {
prenom_age_ville: '',
formation_metier: '',
situation_familiale: '',
loisirs: '',
motivation_canada: '',
}
import {
EMPTY_REPONSES,
FIELD_MAX,
T1_QUESTIONNAIRE_FIELDS,
reponsesSchema,
type FieldKey,
} from '../lib/t1Questionnaire'
const inputBase =
'w-full rounded-md border border-border bg-surface px-3 py-2 text-sm text-ink-primary placeholder:text-ink-tertiary focus:border-brand focus:outline-none focus:shadow-focus'
@ -178,7 +127,7 @@ export function QuestionnaireT1Page() {
</p>
<form onSubmit={handleSubmit} className="space-y-5" noValidate>
{FIELDS.map((field) => {
{T1_QUESTIONNAIRE_FIELDS.map((field) => {
const value = reponses[field.key]
const showError = touched[field.key] && fieldErrors[field.key]
const id = `q-${field.key}`

View file

@ -64,6 +64,7 @@ export function SimulationEOPage() {
isLoading={isCreating}
onSelect={selectTask}
onT2LiveSelect={() => navigate('/simulation/eo/t2')}
onT1LiveSelect={() => navigate('/simulation/eo/t1/live/preparation')}
/>
{taskUnavailableMessage && (

View file

@ -0,0 +1,114 @@
/**
* T1SpeakingIndicator Indicateur de prise de parole T1 Live (Sprint 7b).
*
* Calqué sur T2SpeakingIndicator (mêmes garde-fous Voie A : analyser dérivé du
* graphe de capture lu par ref en rAF, écriture DOM directe, aucun setState).
* Sémantique adaptée au monologue T1 :
*
* - 'presenting' le candidat présente : barres pilotées par l'amplitude micro
* RÉELLE (analyser).
* - 'interrupted' l'examinateur a pris la parole : barres décoratives pilotées
* par l'ÉTAT (CSS), sans sonde audio (l'uplink micro est coupé).
* - autres états rien (la page affiche le libellé d'état).
*/
import { useEffect, useRef, type RefObject } from 'react'
import { Mic, Volume2 } from 'lucide-react'
import type { T1State } from '../state/t1-machine'
const BAR_COUNT = 5
interface T1SpeakingIndicatorProps {
/** Analyser dérivé du graphe de capture (par ref, jamais en state). */
analyserRef: RefObject<AnalyserNode | null>
state: T1State
}
export function T1SpeakingIndicator({ analyserRef, state }: T1SpeakingIndicatorProps) {
const barRefs = useRef<Array<HTMLSpanElement | null>>([])
const rafRef = useRef<number | null>(null)
const dataRef = useRef<Uint8Array<ArrayBuffer> | null>(null)
// rAF actif UNIQUEMENT en 'presenting' : lit l'analyser micro par ref et écrit
// la hauteur des barres directement dans le DOM (aucun setState).
useEffect(() => {
if (state !== 'presenting') return
let active = true
const tick = () => {
if (!active) return
const analyser = analyserRef.current
if (analyser) {
if (!dataRef.current || dataRef.current.length !== analyser.fftSize) {
dataRef.current = new Uint8Array(analyser.fftSize)
}
analyser.getByteTimeDomainData(dataRef.current)
let sumSq = 0
for (let i = 0; i < dataRef.current.length; i++) {
const v = (dataRef.current[i]! - 128) / 128
sumSq += v * v
}
const rms = Math.sqrt(sumSq / dataRef.current.length)
const now = performance.now()
for (let i = 0; i < barRefs.current.length; i++) {
const el = barRefs.current[i]
if (!el) continue
const wave = 0.55 + 0.45 * Math.sin(now / 110 + i * 0.9)
const h = Math.max(14, Math.min(100, 14 + rms * 260 * wave))
el.style.height = `${h}%`
}
}
rafRef.current = requestAnimationFrame(tick)
}
rafRef.current = requestAnimationFrame(tick)
return () => {
active = false
if (rafRef.current !== null) {
cancelAnimationFrame(rafRef.current)
rafRef.current = null
}
}
}, [state, analyserRef])
if (state === 'presenting') {
return (
<div className="flex items-center justify-center gap-3 rounded-md border border-success/40 bg-success-soft px-4 py-3">
<Mic className="size-4 text-success" aria-hidden="true" />
<div className="flex h-6 items-center gap-1" aria-label="Niveau de votre voix">
{Array.from({ length: BAR_COUNT }).map((_, i) => (
<span
key={i}
ref={(el) => {
barRefs.current[i] = el
}}
className="w-1 rounded-full bg-success"
style={{ height: '14%' }}
/>
))}
</div>
<p className="text-sm font-semibold text-success">À vous présentez-vous</p>
</div>
)
}
if (state === 'interrupted') {
return (
<div className="flex items-center justify-center gap-3 rounded-md border border-border bg-surface px-4 py-3">
<Volume2 className="size-4 text-brand-text" aria-hidden="true" />
<div className="flex h-6 items-end gap-1" aria-hidden="true">
{Array.from({ length: BAR_COUNT }).map((_, i) => (
<span
key={i}
className="w-1 animate-pulse rounded-full bg-brand-text/70 motion-reduce:animate-none"
style={{ height: `${40 + (i % 3) * 25}%`, animationDelay: `${i * 110}ms` }}
/>
))}
</div>
<p className="text-sm font-medium text-ink-secondary">Lexaminateur vous interrompt</p>
</div>
)
}
return null
}

View file

@ -0,0 +1,453 @@
/**
* useT1LiveSession Hook orchestrateur du dialogue T1 Live (Sprint 7b).
*
* Calqué sur useT2LiveSession (même discipline Voie A : AudioContext unique,
* jamais de MediaStream en state, flags pilotés par ref dans le chemin audio).
* MAIS la sémantique T1 diffère fondamentalement :
*
* 1. URL `wss://${API_URL}/t1/live?token=<jwt>` PAS de `&sujet=` : la Tâche 1
* n'est PAS subject-based. L'examinateur formule ses relances à partir de ce
* qu'il ENTEND en temps réel (Patch 7a backend plus de questionnaire).
* 2. AUCUN message de contexte. La session audio démarre dès `ws.onopen`
* (WS_OPENED presenting) : le candidat envoie directement son audio.
* 3. AUCUN VAD micro (contrairement à T2). T1 est un MONOLOGUE : c'est le BACKEND
* (horloge probabiliste) qui décide quand l'examinateur interrompt. Le frontend
* réagit aux signaux applicatifs `{type:'interruption_start'}` /
* `{type:'interruption_end'}` dispatch INTERRUPTION_START / INTERRUPTION_END.
* 4. Pendant une interruption, l'uplink micro est COUPÉ (l'examinateur a la
* parole) via un ref (`uplinkMutedRef`) jamais via setState, pour ne pas
* perturber le chemin sourceworkletWS (leçon Voie A).
* 5. Timer dur 180 s côté frontend (redondant avec le backend, warning 150 s
* émis par `{type:'warning'}`).
* 6. Gère les close codes (1000, 4001, 4003, 4005, 4006).
*
* Validation : test manuel uniquement (WebSocket + AudioContext non testables en
* jsdom la logique pure de transition est couverte par t1-machine.test.ts).
*/
import { useCallback, useEffect, useRef, useState, type RefObject } from 'react'
import { useNavigate } from 'react-router-dom'
import { env } from '@/shared/config/env'
import { getAccessToken } from '@/shared/lib/auth-client'
// TODO(FTD-44): hooks audio génériques empruntés à features/t2-live/ (violation
// FSD inter-features assumée et tracée). À relocaliser vers shared/lib/audio/
// au Sprint 7.5 (« factorisation Sprint 7 »). Cf. TECH_DEBT.md §3bis.
import { useAudioCapture } from '@/features/t2-live/hooks/useAudioCapture'
import { useAudioPlayback } from '@/features/t2-live/hooks/useAudioPlayback'
import { useAudioRecording } from '@/features/t2-live/hooks/useAudioRecording'
import { transition, T1_INITIAL_STATE, type T1State, type T1Event } from '../state/t1-machine'
const DIALOGUE_TIMEOUT_MS = 180_000 // 3 min
const WS_PING_INTERVAL_MS = 30_000
export interface UseT1LiveSessionOptions {
/** Appelé quand le rapport est reçu — l'appelant décide de la navigation. */
onReportReady?: (simulationId: string) => void
}
export interface UseT1LiveSessionResult {
state: T1State
startDialogue: () => Promise<void>
endDialogue: () => void
/** Abandon : ferme le WS sans déclencher d'évaluation ni de persistance. */
cancelDialogue: () => void
warning: boolean
errorMessage: string | null
simulationId: string | null
recording: ReturnType<typeof useAudioRecording>
/** Secondes écoulées depuis l'ouverture du WS — pour le timer affiché. */
elapsedSeconds: number
/**
* AnalyserNode dérivé du graphe de capture (par ref stable) pour
* l'indicateur de prise de parole, sans re-render ni sonde sur le flux montant.
*/
analyserRef: RefObject<AnalyserNode | null>
}
interface GeminiPart {
inlineData?: { data?: string; mimeType?: string }
}
interface GeminiServerContent {
modelTurn?: { parts?: GeminiPart[] }
inputTranscription?: { text?: string }
outputTranscription?: { text?: string }
interrupted?: boolean
turnComplete?: boolean
}
interface GeminiMessage {
serverContent?: GeminiServerContent
}
interface AppMessage {
type: 'warning' | 'report' | 'error' | 'audio' | 'interruption_start' | 'interruption_end'
data?: { simulation_id?: string } & Record<string, unknown>
message?: string
code?: string
}
function buildWsUrl(token: string): string {
const base = env.VITE_API_URL.replace(/^http/, 'ws')
return `${base}/t1/live?token=${encodeURIComponent(token)}`
}
export function useT1LiveSession(opts: UseT1LiveSessionOptions = {}): UseT1LiveSessionResult {
const { onReportReady } = opts
const navigate = useNavigate()
const [state, setState] = useState<T1State>(T1_INITIAL_STATE)
const [warning, setWarning] = useState(false)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const [simulationId, setSimulationId] = useState<string | null>(null)
const [elapsedSeconds, setElapsedSeconds] = useState(0)
const wsRef = useRef<WebSocket | null>(null)
const sessionEndedRef = useRef(false)
const timeoutTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const elapsedTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
const pingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
// Uplink micro coupé pendant une interruption (l'examinateur a la parole).
// Piloté par ref — JAMAIS setState — pour ne pas perturber le chemin audio
// source→worklet→WS (leçon Voie A : un setState dans ce chemin affame l'uplink).
const uplinkMutedRef = useRef(false)
// Sprint 6d (repris) — token de cancellation pour rendre `startDialogue`
// idempotent sur les appels rapprochés (StrictMode dev double-mount, etc.).
const cancelTokenRef = useRef<{ cancelled: boolean } | null>(null)
const recording = useAudioRecording()
// Déclaré avant `capture` car onChunk en dépend.
const dispatch = useCallback((event: T1Event) => {
setState((prev) => transition(prev, event))
}, [])
// Capture branchée à l'envoi WS. Aucun VAD (T1 = monologue) : on transmet le
// chunk uplink tel quel SAUF pendant une interruption (uplinkMutedRef).
const capture = useAudioCapture({
onChunk: (base64: string) => {
if (uplinkMutedRef.current) return
const ws = wsRef.current
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'audio', data: base64 }))
}
},
})
// Playback déclaré APRÈS capture : il consomme le contexte + le mix PARTAGÉS
// (Voie A — horloge unique, voix examinateur routée vers le mix + destination).
const playback = useAudioPlayback({
contextRef: capture.contextRef,
mixNodeRef: capture.mixNodeRef,
})
const cleanupTimers = useCallback(() => {
if (timeoutTimerRef.current !== null) {
clearTimeout(timeoutTimerRef.current)
timeoutTimerRef.current = null
}
if (elapsedTimerRef.current !== null) {
clearInterval(elapsedTimerRef.current)
elapsedTimerRef.current = null
}
if (pingTimerRef.current !== null) {
clearInterval(pingTimerRef.current)
pingTimerRef.current = null
}
}, [])
const closeAll = useCallback(() => {
sessionEndedRef.current = true
cleanupTimers()
if (cancelTokenRef.current) {
cancelTokenRef.current.cancelled = true
cancelTokenRef.current = null
}
capture.stop()
if (wsRef.current && wsRef.current.readyState !== WebSocket.CLOSED) {
try {
wsRef.current.close()
} catch {
/* ignore */
}
}
// playback continue jusqu'à fin de file pour ne pas couper la dernière phrase.
}, [capture, cleanupTimers])
const handleAudioReceived = useCallback(
(base64: string) => {
// Voie A : playChunk route la voix examinateur vers destination ET vers le
// mixGain ; le tap d'enregistrement la capte sur ce mix en temps réel.
playback.playChunk(base64)
},
[playback],
)
const handleGeminiMessage = useCallback(
(msg: GeminiMessage) => {
const sc = msg.serverContent
if (!sc) return
if (sc.modelTurn?.parts) {
for (const part of sc.modelTurn.parts) {
if (part.inlineData?.data) {
handleAudioReceived(part.inlineData.data)
}
}
}
},
[handleAudioReceived],
)
const handleAppMessage = useCallback(
(msg: AppMessage) => {
if (msg.type === 'warning') {
setWarning(true)
return
}
// Interruption NON DÉTERMINISTE pilotée par le backend (jamais déduite côté
// front). L'examinateur prend / rend la parole : on coupe / rétablit l'uplink
// micro via ref et on dispatche la transition d'état.
if (msg.type === 'interruption_start') {
uplinkMutedRef.current = true
dispatch({ type: 'INTERRUPTION_START' })
return
}
if (msg.type === 'interruption_end') {
uplinkMutedRef.current = false
dispatch({ type: 'INTERRUPTION_END' })
return
}
if (msg.type === 'report' && msg.data) {
const simId = (msg.data.simulation_id as string | undefined) ?? null
if (simId) setSimulationId(simId)
dispatch({ type: 'REPORT_READY' })
if (simId && onReportReady) onReportReady(simId)
return
}
if (msg.type === 'error') {
setErrorMessage(msg.message ?? 'Une erreur est survenue.')
dispatch({ type: 'ERROR', message: msg.message })
}
},
[dispatch, onReportReady],
)
const handleWsMessage = useCallback(
(evt: MessageEvent) => {
let text: string
if (typeof evt.data === 'string') {
text = evt.data
} else if (evt.data instanceof ArrayBuffer) {
text = new TextDecoder().decode(evt.data)
} else if (evt.data instanceof Blob) {
console.warn('[T1] Frame Blob reçu, attendu string/ArrayBuffer')
return
} else {
return
}
try {
const parsed = JSON.parse(text) as GeminiMessage & AppMessage
if (
parsed.type === 'warning' ||
parsed.type === 'report' ||
parsed.type === 'error' ||
parsed.type === 'interruption_start' ||
parsed.type === 'interruption_end'
) {
handleAppMessage(parsed as AppMessage)
} else if (parsed.serverContent) {
handleGeminiMessage(parsed as GeminiMessage)
}
} catch {
/* JSON malformé — ignorer */
}
},
[handleAppMessage, handleGeminiMessage],
)
// Indirection par ref : le binding ws.onmessage appelle TOUJOURS le handler
// courant (immunisé au gel de closure / HMR). Cf. INSTRUMENT REPAIR 6e (T2).
const handleWsMessageRef = useRef(handleWsMessage)
useEffect(() => {
handleWsMessageRef.current = handleWsMessage
}, [handleWsMessage])
const handleWsClose = useCallback(
(evt: CloseEvent) => {
if (sessionEndedRef.current) return
sessionEndedRef.current = true
cleanupTimers()
recording.stop()
capture.stop()
switch (evt.code) {
case 1000:
if (state !== 'ended') {
dispatch({ type: 'ERROR', message: 'Session interrompue' })
}
break
case 4001:
setErrorMessage('Authentification expirée.')
dispatch({ type: 'ERROR', code: 4001 })
navigate('/login')
break
case 4003:
setErrorMessage('La Tâche 1 Live est réservée au plan Premium.')
dispatch({ type: 'ERROR', code: 4003 })
break
case 4005:
setErrorMessage('Service temporairement indisponible.')
dispatch({ type: 'ERROR', code: 4005 })
break
case 4006:
setErrorMessage('Connexion à lexaminateur perdue.')
dispatch({ type: 'ERROR', code: 4006 })
break
default:
setErrorMessage('Une erreur est survenue. Réessayez dans quelques instants.')
dispatch({ type: 'ERROR', code: evt.code })
}
},
[capture, cleanupTimers, dispatch, navigate, recording, state],
)
const startDialogue = useCallback(async () => {
if (wsRef.current || cancelTokenRef.current) return
const localToken = { cancelled: false }
cancelTokenRef.current = localToken
setErrorMessage(null)
setWarning(false)
sessionEndedRef.current = false
uplinkMutedRef.current = false
dispatch({ type: 'START_DIALOGUE' })
const token = await getAccessToken()
if (localToken.cancelled) return
if (!token) {
cancelTokenRef.current = null
setErrorMessage('Authentification requise.')
dispatch({ type: 'ERROR', code: 4001 })
navigate('/login')
return
}
let ws: WebSocket
try {
ws = new WebSocket(buildWsUrl(token))
ws.binaryType = 'arraybuffer'
} catch (err) {
cancelTokenRef.current = null
const message = err instanceof Error ? err.message : 'Connexion impossible'
setErrorMessage(message)
dispatch({ type: 'ERROR' })
return
}
if (localToken.cancelled) {
try {
ws.close()
} catch {
/* ignore */
}
return
}
wsRef.current = ws
ws.onopen = () => {
// Aucun message de contexte (Patch 7a backend) : la session audio démarre
// dès l'ouverture du WS, le candidat a la parole (monologue).
dispatch({ type: 'WS_OPENED' })
// Démarrer la capture micro PUIS brancher le tap d'enregistrement sur le
// contexte + mixGain (qui n'existent qu'après résolution de capture.start()).
void capture.start().then(() => {
const ctx = capture.contextRef.current
const mix = capture.mixNodeRef.current
if (ctx && mix) {
recording.reset()
void recording.start(ctx, mix)
}
})
const startTime = Date.now()
elapsedTimerRef.current = setInterval(() => {
setElapsedSeconds(Math.floor((Date.now() - startTime) / 1000))
}, 250)
// Timeout dur frontend (redondance avec le 180 s backend).
timeoutTimerRef.current = setTimeout(() => {
if (sessionEndedRef.current) return
if (ws.readyState === WebSocket.OPEN) {
try {
ws.send(JSON.stringify({ type: 'end' }))
} catch {
/* ignore */
}
}
dispatch({ type: 'END_REQUESTED' })
}, DIALOGUE_TIMEOUT_MS)
pingTimerRef.current = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
try {
ws.send(JSON.stringify({ type: 'ping' }))
} catch {
/* ignore */
}
}
}, WS_PING_INTERVAL_MS)
}
ws.onmessage = (evt) => handleWsMessageRef.current(evt)
ws.onclose = handleWsClose
ws.onerror = () => {
// 'close' suit toujours 'error' — gestion centralisée dans handleWsClose.
}
}, [capture, dispatch, handleWsClose, navigate, recording])
const endDialogue = useCallback(() => {
if (sessionEndedRef.current) return
cleanupTimers()
recording.stop()
capture.stop()
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
try {
wsRef.current.send(JSON.stringify({ type: 'end' }))
} catch {
/* ignore */
}
}
dispatch({ type: 'END_REQUESTED' })
}, [capture, cleanupTimers, dispatch, recording])
// Abandon utilisateur : ferme le WS SANS envoyer `{type:'end'}` → le backend ne
// déclenche NI correction NI persistance. Machine → 'idle' via CANCEL.
const cancelDialogue = useCallback(() => {
if (sessionEndedRef.current) return
closeAll()
playback.stop()
dispatch({ type: 'CANCEL' })
}, [closeAll, playback, dispatch])
// Cleanup au démontage UNIQUEMENT (cf. T2 : ref tenant la dernière version de
// closeAll + effet à deps vides, pour ne pas fermer le WS à chaque render).
const closeAllRef = useRef(closeAll)
useEffect(() => {
closeAllRef.current = closeAll
})
useEffect(() => {
return () => {
closeAllRef.current()
}
}, [])
return {
state,
startDialogue,
endDialogue,
cancelDialogue,
warning,
errorMessage,
simulationId,
recording,
elapsedSeconds,
analyserRef: capture.analyserRef,
}
}

View file

@ -0,0 +1,237 @@
/**
* Page /simulation/eo/t1/live/dialogue phase de dialogue live T1 (Sprint 7b).
*
* Démarre la session WS au mount, pilote l'UI selon l'état machine T1, affiche le
* timer 3:00 et l'indicateur d'état. Spécificité T1 : l'examinateur peut
* INTERROMPRE le monologue de façon NON DÉTERMINISTE (état `interrupted`) l'UI
* ne suppose JAMAIS qu'une relance suit. À la fin (REPORT_READY), écran terminal
* avec « Télécharger l'audio » + « Voir le rapport ».
*/
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Mic, Volume2, Download, FileText, Loader2 } from 'lucide-react'
import { Button } from '@/shared/ui/Button'
import { Card } from '@/shared/ui/Card'
import { T1SpeakingIndicator } from '../components/T1SpeakingIndicator'
import { useT1LiveSession } from '../hooks/useT1LiveSession'
const DIALOGUE_SECONDS = 180 // 3:00
function formatMmSs(totalSeconds: number): string {
const remaining = Math.max(0, totalSeconds)
const m = Math.floor(remaining / 60)
const s = remaining % 60
return `${m}:${s.toString().padStart(2, '0')}`
}
export function T1DialoguePage() {
const navigate = useNavigate()
const [autoStarted, setAutoStarted] = useState(false)
const session = useT1LiveSession()
// Démarrer le dialogue automatiquement au mount (la prépa est déjà finie).
useEffect(() => {
if (autoStarted) return
setAutoStarted(true)
void session.startDialogue()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [autoStarted])
const remaining = DIALOGUE_SECONDS - session.elapsedSeconds
const stateLabel = (() => {
switch (session.state) {
case 'idle':
case 'connecting':
return 'Connexion à lexaminateur…'
case 'presenting':
return 'À vous — présentez-vous.'
case 'interrupted':
return 'Lexaminateur vous interrompt — répondez-lui.'
case 'processing':
return 'Évaluation en cours…'
case 'ended':
return 'Session terminée.'
case 'error':
return 'Erreur.'
case 'preparing':
return 'Préparation…'
}
})()
function handleDownload() {
const blob = session.recording.exportWAV()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `expria-t1-${new Date().toISOString().slice(0, 10)}.wav`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
function handleViewReport() {
if (!session.simulationId) return
navigate(`/rapport/${session.simulationId}`)
}
function handleRestart() {
navigate('/simulation/eo/t1/live/preparation')
}
// Abandon : ferme la session sans évaluation (cancelDialogue ne déclenche ni
// correction ni persistance), puis sortie du flux.
function handleCancel() {
session.cancelDialogue()
navigate('/simulation/eo')
}
// « Annuler » / « Terminer » n'ont de sens que pendant la session active
// (connexion, présentation ou interruption), pas en évaluation.
const canCancel =
session.state === 'connecting' ||
session.state === 'presenting' ||
session.state === 'interrupted'
// ── État terminal : rapport prêt ─────────────────────────────────────────
if (session.state === 'ended') {
return (
<div className="mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">
<main className="mx-auto max-w-2xl space-y-5">
<h2 className="text-lg font-semibold text-ink-primary">Session terminée</h2>
<Card variant="default" className="space-y-3 p-5">
<p className="text-sm text-ink-primary">
Votre présentation a é évaluée. Vous pouvez télécharger l'enregistrement audio avant
de consulter le rapport.
</p>
<p className="text-xs text-ink-secondary">
Durée enregistrée :{' '}
<span className="font-semibold tabular-nums text-ink-primary">
{session.recording.durationSeconds.toFixed(1)} s
</span>
</p>
</Card>
<div className="flex flex-wrap items-center gap-3">
<Button
variant="secondary"
icon={<Download className="size-4" aria-hidden="true" />}
onClick={handleDownload}
disabled={session.recording.durationSeconds === 0}
>
Télécharger l'audio
</Button>
<Button
variant="primary"
icon={<FileText className="size-4" aria-hidden="true" />}
onClick={handleViewReport}
disabled={!session.simulationId}
>
Voir le rapport
</Button>
</div>
</main>
</div>
)
}
// ── État erreur ──────────────────────────────────────────────────────────
if (session.state === 'error') {
return (
<div className="mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">
<main className="mx-auto max-w-2xl space-y-5">
<h2 className="text-lg font-semibold text-ink-primary">Erreur</h2>
<div
role="alert"
className="rounded-md border border-danger/40 bg-danger-soft px-4 py-3 text-sm text-danger"
>
{session.errorMessage ?? 'Une erreur est survenue.'}
</div>
<div className="flex gap-3">
<Button variant="secondary" onClick={handleRestart}>
Recommencer
</Button>
</div>
</main>
</div>
)
}
// ── État dialogue actif ──────────────────────────────────────────────────
return (
<div className="mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">
<main className="mx-auto max-w-2xl space-y-5">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-ink-primary">Présentation en cours</h2>
<span
className="rounded-full border border-border bg-surface px-3 py-1 text-sm font-semibold tabular-nums text-ink-primary"
aria-label="Temps restant de présentation"
>
{formatMmSs(remaining)}
</span>
</div>
{session.warning && (
<div
role="status"
className="rounded-md border border-warning/40 bg-warning-soft px-3 py-2 text-sm text-ink-primary"
>
30 secondes restantes.
</div>
)}
<Card variant="default" className="space-y-4 p-5">
<div className="flex items-center gap-3">
{session.state === 'processing' ? (
<Loader2 className="size-5 animate-spin text-brand-text" aria-hidden="true" />
) : session.state === 'interrupted' ? (
<Volume2 className="size-5 text-brand-text" aria-hidden="true" />
) : (
<Mic
className={
session.state === 'presenting'
? 'size-5 text-success'
: 'size-5 text-ink-secondary'
}
aria-hidden="true"
/>
)}
<p className="text-sm font-semibold text-ink-primary">{stateLabel}</p>
</div>
{/* Indicateur : 'presenting' = amplitude micro réelle (analyser lu par
ref en rAF) ; 'interrupted' = animation décorative (uplink coupé). */}
{canCancel && (
<T1SpeakingIndicator analyserRef={session.analyserRef} state={session.state} />
)}
<p className="text-xs text-ink-secondary">
Présentez-vous naturellement. L'examinateur peut vous interrompre à tout moment.
</p>
</Card>
<div className="flex items-center justify-center gap-3">
{canCancel && (
<Button
variant="ghost"
className="text-danger hover:bg-danger-soft hover:text-danger"
onClick={handleCancel}
title="Abandonner sans générer de rapport"
>
Annuler
</Button>
)}
<Button
variant="secondary"
onClick={() => session.endDialogue()}
disabled={session.state === 'processing'}
>
Terminer
</Button>
</div>
<p className="text-center text-xs text-ink-tertiary">
« Annuler » abandonne la session sans rapport. « Terminer » lance l'évaluation.
</p>
</main>
</div>
)
}

View file

@ -0,0 +1,122 @@
/**
* Page /simulation/eo/t1/live/preparation préparation T1 Live (Sprint 7b).
*
* Calquée sur T2PreparationPage MAIS sans « Suggestions d'idées » (outil de
* questions de service propre au T2, inapplicable à un monologue de présentation
* DÉCISION 3). La prépa T1 = rappel des points de présentation (les 5 réponses
* saisies) + pré-warm micro + timer.
*
* - Timer 2 min visible (countdown), auto-navigation à 0:00.
* - Pré-warm permission micro pour éviter la latence au début du dialogue.
* - Entrée directe depuis la carte TaskSelector (plus de questionnaire Patch 7a).
*/
import { useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Button } from '@/shared/ui/Button'
import { Card } from '@/shared/ui/Card'
const PREPARATION_SECONDS = 120
function formatMmSs(totalSeconds: number): string {
const m = Math.floor(totalSeconds / 60)
const s = totalSeconds % 60
return `${m}:${s.toString().padStart(2, '0')}`
}
export function T1PreparationPage() {
const navigate = useNavigate()
const [secondsLeft, setSecondsLeft] = useState(PREPARATION_SECONDS)
const [micWarmed, setMicWarmed] = useState<boolean | null>(null)
const expiredRef = useRef(false)
// Pré-warm permission micro (cf. T2PreparationPage — évite la latence à 0:00).
useEffect(() => {
let cancelled = false
let stream: MediaStream | null = null
void navigator.mediaDevices
.getUserMedia({
audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true },
})
.then((s) => {
stream = s
if (cancelled) {
s.getTracks().forEach((t) => t.stop())
return
}
s.getTracks().forEach((t) => t.stop())
setMicWarmed(true)
})
.catch(() => {
if (!cancelled) setMicWarmed(false)
})
return () => {
cancelled = true
if (stream) stream.getTracks().forEach((t) => t.stop())
}
}, [])
// Timer countdown 2 min → auto-navigation vers le dialogue.
useEffect(() => {
const id = setInterval(() => {
setSecondsLeft((s) => {
if (s <= 1) {
clearInterval(id)
if (!expiredRef.current) {
expiredRef.current = true
queueMicrotask(() => navigate('/simulation/eo/t1/live/dialogue'))
}
return 0
}
return s - 1
})
}, 1000)
return () => clearInterval(id)
}, [navigate])
function handleReady() {
navigate('/simulation/eo/t1/live/dialogue')
}
return (
<div className="mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">
<main className="mx-auto max-w-3xl space-y-5">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-ink-primary">Préparation Tâche 1 Live</h2>
<span
className="rounded-full border border-border bg-surface px-3 py-1 text-sm font-semibold tabular-nums text-ink-primary"
aria-label="Temps restant de préparation"
>
{formatMmSs(secondsLeft)}
</span>
</div>
<Card variant="default" className="p-4">
<p className="text-sm text-ink-secondary">
<strong className="text-ink-primary">Comment ça se passe :</strong> à votre signal,
présentez-vous à l'oral en quelques phrases. L'examinateur IA pourra vous interrompre
spontanément pour vous poser des questions répondez-lui, puis poursuivez votre
présentation.
</p>
</Card>
{micWarmed === false && (
<div
role="alert"
className="rounded-md border border-warning/40 bg-warning-soft px-3 py-2 text-sm text-ink-primary"
>
Accès au micro refusé. Activez-le dans les paramètres du navigateur avant de démarrer le
dialogue.
</div>
)}
<div className="flex flex-wrap items-center gap-3">
<Button variant="primary" onClick={handleReady}>
Je suis prêt démarrer la présentation
</Button>
</div>
</main>
</div>
)
}

View file

@ -0,0 +1,127 @@
import { describe, it, expect } from 'vitest'
import { transition, T1_INITIAL_STATE } from '../t1-machine'
import type { T1State } from '../t1-machine'
describe('T1 state machine — transitions nominales', () => {
it('idle → preparing sur START_PREPARATION', () => {
expect(transition('idle', { type: 'START_PREPARATION' })).toBe('preparing')
})
it('preparing → connecting sur START_DIALOGUE', () => {
expect(transition('preparing', { type: 'START_DIALOGUE' })).toBe('connecting')
})
it('idle → connecting sur START_DIALOGUE (saut de prépa autorisé)', () => {
expect(transition('idle', { type: 'START_DIALOGUE' })).toBe('connecting')
})
it('connecting → presenting sur WS_OPENED (candidat a la parole)', () => {
expect(transition('connecting', { type: 'WS_OPENED' })).toBe('presenting')
})
it('processing → ended sur REPORT_READY', () => {
expect(transition('processing', { type: 'REPORT_READY' })).toBe('ended')
})
})
describe('T1 state machine — interruption / reprise (cœur du sprint)', () => {
it('presenting → interrupted sur INTERRUPTION_START', () => {
expect(transition('presenting', { type: 'INTERRUPTION_START' })).toBe('interrupted')
})
it('interrupted → presenting sur INTERRUPTION_END', () => {
expect(transition('interrupted', { type: 'INTERRUPTION_END' })).toBe('presenting')
})
it('cycle complet presenting ⇄ interrupted (plusieurs relances possibles)', () => {
let s: T1State = 'presenting'
s = transition(s, { type: 'INTERRUPTION_START' })
expect(s).toBe('interrupted')
s = transition(s, { type: 'INTERRUPTION_END' })
expect(s).toBe('presenting')
s = transition(s, { type: 'INTERRUPTION_START' })
expect(s).toBe('interrupted')
s = transition(s, { type: 'INTERRUPTION_END' })
expect(s).toBe('presenting')
})
it('chemin SANS interruption est nominal (presenting → processing → ended)', () => {
// Non déterminisme P0=0.2 : une session peut n'avoir AUCUNE relance.
let s: T1State = 'presenting'
s = transition(s, { type: 'END_REQUESTED' })
expect(s).toBe('processing')
s = transition(s, { type: 'REPORT_READY' })
expect(s).toBe('ended')
})
it('INTERRUPTION_START est ignoré hors de presenting', () => {
expect(transition('interrupted', { type: 'INTERRUPTION_START' })).toBe('interrupted')
expect(transition('connecting', { type: 'INTERRUPTION_START' })).toBe('connecting')
expect(transition('processing', { type: 'INTERRUPTION_START' })).toBe('processing')
})
it('INTERRUPTION_END est ignoré hors de interrupted', () => {
expect(transition('presenting', { type: 'INTERRUPTION_END' })).toBe('presenting')
})
})
describe('T1 state machine — END_REQUESTED → processing depuis tout état actif', () => {
it.each<T1State>(['connecting', 'presenting', 'interrupted'])(
'transition %s → processing sur END_REQUESTED',
(from) => {
expect(transition(from, { type: 'END_REQUESTED' })).toBe('processing')
},
)
})
describe('T1 state machine — ERROR terminal', () => {
it.each<T1State>(['idle', 'preparing', 'connecting', 'presenting', 'interrupted', 'processing'])(
'transition %s → error sur ERROR',
(from) => {
expect(transition(from, { type: 'ERROR', code: 4006 })).toBe('error')
},
)
it('ended est insensible à ERROR (état terminal protégé)', () => {
expect(transition('ended', { type: 'ERROR', code: 4006 })).toBe('ended')
})
})
describe('T1 state machine — CANCEL (abandon) → idle depuis tout état actif', () => {
it.each<T1State>(['preparing', 'connecting', 'presenting', 'interrupted', 'processing'])(
'transition %s → idle sur CANCEL',
(from) => {
expect(transition(from, { type: 'CANCEL' })).toBe('idle')
},
)
it('CANCEL en idle reste idle (no-op)', () => {
expect(transition('idle', { type: 'CANCEL' })).toBe('idle')
})
it('états terminaux (ended, error) sont protégés contre CANCEL', () => {
expect(transition('ended', { type: 'CANCEL' })).toBe('ended')
expect(transition('error', { type: 'CANCEL' })).toBe('error')
})
})
describe('T1 state machine — événements invalides ignorés', () => {
it('INTERRUPTION_START en idle est ignoré', () => {
expect(transition('idle', { type: 'INTERRUPTION_START' })).toBe('idle')
})
it('REPORT_READY en presenting est ignoré (doit passer par END_REQUESTED → processing)', () => {
expect(transition('presenting', { type: 'REPORT_READY' })).toBe('presenting')
})
it('états terminaux (ended, error) sont insensibles aux events nominaux', () => {
expect(transition('ended', { type: 'INTERRUPTION_START' })).toBe('ended')
expect(transition('error', { type: 'WS_OPENED' })).toBe('error')
})
})
describe('T1_INITIAL_STATE', () => {
it('vaut idle', () => {
expect(T1_INITIAL_STATE).toBe('idle')
})
})

View file

@ -0,0 +1,125 @@
/**
* t1-machine State machine pure pour le flux T1 Live (Sprint 7b).
*
* Calquée sur t2-machine (même discipline : fonction pure, aucun side-effect,
* aucune référence DOM/React/WebSocket l'orchestration vit dans
* useT1LiveSession). MAIS la sémantique diffère fondamentalement :
*
* - T2 : le candidat initie une interaction de service ; les états
* speaking/listening sont pilotés par le VAD micro côté frontend.
* - T1 : le candidat fait un MONOLOGUE de présentation. C'est le BACKEND
* (horloge probabiliste, Sprint 7a) qui décide QUAND l'examinateur
* interrompt. Le frontend ne fait pas de VAD : il réagit aux signaux
* {type:'interruption_start'} / {type:'interruption_end'}.
*
* CŒUR DU SPRINT : l'interruption / reprise est modélisée ICI, dans la machine,
* via l'état `interrupted`. L'interruption est NON DÉTERMINISTE (P0=0.2,
* P1=0.6, P2=0.2) la machine ne suppose JAMAIS qu'une relance suit : le
* chemin `presenting → processing → ended` SANS aucune interruption est
* parfaitement nominal.
*
* Cycle de vie d'une session :
*
* idle
* START_PREPARATION preparing
* START_DIALOGUE connecting
* WS_OPENED presenting
* INTERRUPTION_START interrupted
* INTERRUPTION_END presenting
* END_REQUESTED processing
* REPORT_READY ended
* ERROR error
*
* Toute transition non listée est ignorée (état conservé).
*/
export type T1State =
| 'idle'
| 'preparing'
| 'connecting'
| 'presenting'
| 'interrupted'
| 'processing'
| 'ended'
| 'error'
export type T1Event =
| { type: 'START_PREPARATION' }
| { type: 'START_DIALOGUE' }
// WS_OPENED — la socket est ouverte : le candidat a la parole (monologue).
// Pas d'état 'ready' distinct comme en T2, car aucun VAD ne pilote la prise
// de parole, et aucun message de contexte n'est envoyé (Patch 7a backend).
| { type: 'WS_OPENED' }
// INTERRUPTION_START / END — pilotés par les signaux backend (jamais déduits
// côté frontend). L'examinateur prend / rend la parole.
| { type: 'INTERRUPTION_START' }
| { type: 'INTERRUPTION_END' }
| { type: 'END_REQUESTED' }
| { type: 'REPORT_READY' }
// CANCEL — abandon utilisateur : ferme la session SANS évaluation (cf.
// useT1LiveSession.cancelDialogue, qui ferme le WS sans envoyer `{type:'end'}`).
// La machine revient à 'idle'.
| { type: 'CANCEL' }
| { type: 'ERROR'; code?: number; message?: string }
/**
* Transition pure : (state, event) newState.
*
* Les événements `WARNING` (timer 30 s restantes) ne sont pas modélisés ici
* car ils n'affectent pas l'état ils déclenchent un side-effect d'affichage
* géré directement par le hook orchestrateur.
*/
export function transition(state: T1State, event: T1Event): T1State {
// ERROR est terminal et bypasse tous les guards : peut être émis depuis
// n'importe quel état non-terminal.
if (event.type === 'ERROR' && state !== 'ended') {
return 'error'
}
// CANCEL (abandon) bypasse les guards depuis tout état non-terminal et
// ramène la machine à 'idle'. Les états terminaux ('ended', 'error') sont
// protégés.
if (event.type === 'CANCEL' && state !== 'ended' && state !== 'error') {
return 'idle'
}
switch (state) {
case 'idle':
if (event.type === 'START_PREPARATION') return 'preparing'
// Permet de sauter la prépa si l'appelant le souhaite.
if (event.type === 'START_DIALOGUE') return 'connecting'
return state
case 'preparing':
if (event.type === 'START_DIALOGUE') return 'connecting'
return state
case 'connecting':
if (event.type === 'WS_OPENED') return 'presenting'
if (event.type === 'END_REQUESTED') return 'processing'
return state
case 'presenting':
if (event.type === 'INTERRUPTION_START') return 'interrupted'
if (event.type === 'END_REQUESTED') return 'processing'
return state
case 'interrupted':
if (event.type === 'INTERRUPTION_END') return 'presenting'
// « Terminer » ou le timeout 180 s peuvent tomber pendant une relance.
if (event.type === 'END_REQUESTED') return 'processing'
return state
case 'processing':
if (event.type === 'REPORT_READY') return 'ended'
return state
case 'ended':
case 'error':
// États terminaux — aucune transition.
return state
}
}
/** État initial de toute session T1 Live. */
export const T1_INITIAL_STATE: T1State = 'idle'