diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md
index 1b98900..4a45bac 100644
--- a/docs/CHANGELOG.md
+++ b/docs/CHANGELOG.md
@@ -29,6 +29,31 @@ Chaque entrée suit ce format :
---
+## [Unreleased] — 2026-04-25 — Sprint 4.6 — UI EO (waveform + timeline)
+
+### Added
+
+- `RecordingWaveform.tsx` — visualiseur audio animé (AnalyserNode, fftSize 256,
+ smoothing 0.7, 32 barres). Visible uniquement pendant `isRecording`. Respecte
+ `prefers-reduced-motion` (frame statique). AudioContext fermé au cleanup.
+- `RecordingTimeline.tsx` — barre de progression colorée avec seuils fixes :
+ vert (0 → maxSeconds-30s), orange (maxSeconds-30s → maxSeconds-15s),
+ rouge (maxSeconds-15s → fin). Applicable T1 et T3.
+- `RecordingTimeline.test.tsx` — 7 tests (logique seuils + rendu + clamp).
+
+### Changed
+
+- `useAudioRecorder.ts` — expose `mediaStream: MediaStream | null` (set au
+ start, reset au cleanup).
+- `AudioRecorder.tsx` — intègre Waveform + Timeline dans l'UI d'enregistrement.
+
+### Notes
+
+- Aucun changement backend.
+- Tests : 166 → 173 verts (+7).
+
+---
+
## [Unreleased] — 2026-04-25 — Sprint 4.5 Clean + fixes Golden Dataset
### Added
diff --git a/src/features/simulations/components/AudioRecorder.tsx b/src/features/simulations/components/AudioRecorder.tsx
index 9e86836..b8bc611 100644
--- a/src/features/simulations/components/AudioRecorder.tsx
+++ b/src/features/simulations/components/AudioRecorder.tsx
@@ -17,6 +17,8 @@ import { Download, Mic, MicOff, Square } from 'lucide-react'
import { Button } from '@/shared/ui/Button'
import { formatTimer } from '../lib/simulationConfig'
import { useAudioRecorder } from '../hooks/useAudioRecorder'
+import { RecordingTimeline } from './RecordingTimeline'
+import { RecordingWaveform } from './RecordingWaveform'
interface Props {
/** Durée minimale (s) avant que la soumission soit autorisée. */
@@ -136,6 +138,18 @@ export function AudioRecorder({
+ {isRecording && (
+
+
+
+ )}
+
+ {maxSeconds && (isRecording || isStopped) && (
+
+
+
+ )}
+
{isRecording && remaining > 0 && (
Minimum 30 secondes requis ({remaining} s restantes).
diff --git a/src/features/simulations/components/RecordingTimeline.tsx b/src/features/simulations/components/RecordingTimeline.tsx
new file mode 100644
index 0000000..234c065
--- /dev/null
+++ b/src/features/simulations/components/RecordingTimeline.tsx
@@ -0,0 +1,56 @@
+/**
+ * Barre de progression colorée pour l'enregistrement EO — Sprint 4.6.
+ *
+ * Affiche le temps écoulé vs `maxSeconds` avec une couleur qui passe de
+ * verte à orange à rouge selon des seuils fixes :
+ * - Vert : elapsed < maxSeconds - 30
+ * - Orange : maxSeconds - 30 ≤ elapsed < maxSeconds - 15
+ * - Rouge : elapsed ≥ maxSeconds - 15 (les 15 dernières secondes)
+ *
+ * Tokens Direction Charcoal exclusivement (Règle L).
+ */
+
+interface Props {
+ elapsedSeconds: number
+ maxSeconds: number
+}
+
+type TimelineColor = 'success' | 'warning' | 'danger'
+
+export function getTimelineColor(elapsedSeconds: number, maxSeconds: number): TimelineColor {
+ if (elapsedSeconds >= maxSeconds - 15) return 'danger'
+ if (elapsedSeconds >= maxSeconds - 30) return 'warning'
+ return 'success'
+}
+
+const COLOR_VAR: Record = {
+ success: 'var(--color-success)',
+ warning: 'var(--color-warning)',
+ danger: 'var(--color-danger)',
+}
+
+export function RecordingTimeline({ elapsedSeconds, maxSeconds }: Props) {
+ const clamped = Math.min(Math.max(elapsedSeconds, 0), maxSeconds)
+ const ratio = maxSeconds > 0 ? clamped / maxSeconds : 0
+ const color = getTimelineColor(clamped, maxSeconds)
+
+ return (
+
+ )
+}
diff --git a/src/features/simulations/components/RecordingWaveform.tsx b/src/features/simulations/components/RecordingWaveform.tsx
new file mode 100644
index 0000000..405680e
--- /dev/null
+++ b/src/features/simulations/components/RecordingWaveform.tsx
@@ -0,0 +1,121 @@
+/**
+ * Visualisation en barres audio animées pendant l'enregistrement — Sprint 4.6.
+ *
+ * Branche un AnalyserNode sur le `MediaStream` exposé par `useAudioRecorder`,
+ * agrège la FFT en `barCount` barres et anime via requestAnimationFrame.
+ *
+ * Quand `stream === null`, le composant affiche des barres au repos (au cas
+ * où il serait monté hors enregistrement) et ne crée aucun AudioContext.
+ *
+ * Respecte `prefers-reduced-motion` : pas de rAF, rendu statique.
+ *
+ * Tokens Direction Charcoal exclusivement (Règle L).
+ */
+
+import { useEffect, useRef, useState } from 'react'
+
+interface Props {
+ stream: MediaStream | null
+ barCount?: number
+}
+
+const DEFAULT_BAR_COUNT = 32
+const FFT_SIZE = 256
+
+export function RecordingWaveform({ stream, barCount = DEFAULT_BAR_COUNT }: Props) {
+ const [levels, setLevels] = useState(() => new Array(barCount).fill(0))
+ const rafRef = useRef(null)
+ const audioCtxRef = useRef(null)
+
+ useEffect(() => {
+ if (!stream) {
+ setLevels(new Array(barCount).fill(0))
+ return
+ }
+
+ const reduceMotion =
+ typeof window !== 'undefined' &&
+ window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
+
+ const Ctor =
+ window.AudioContext ??
+ (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext
+ if (!Ctor) return
+
+ const audioCtx = new Ctor()
+ audioCtxRef.current = audioCtx
+ const source = audioCtx.createMediaStreamSource(stream)
+ const analyser = audioCtx.createAnalyser()
+ analyser.fftSize = FFT_SIZE
+ analyser.smoothingTimeConstant = 0.7
+ source.connect(analyser)
+
+ const bins = analyser.frequencyBinCount
+ const data = new Uint8Array(bins)
+ const binsPerBar = Math.max(1, Math.floor(bins / barCount))
+
+ function tick() {
+ analyser.getByteFrequencyData(data)
+ const next: number[] = []
+ for (let b = 0; b < barCount; b++) {
+ let sum = 0
+ const start = b * binsPerBar
+ for (let i = 0; i < binsPerBar; i++) sum += data[start + i] ?? 0
+ next.push(sum / binsPerBar / 255)
+ }
+ setLevels(next)
+ rafRef.current = requestAnimationFrame(tick)
+ }
+
+ if (reduceMotion) {
+ // Snapshot unique
+ analyser.getByteFrequencyData(data)
+ const next: number[] = []
+ for (let b = 0; b < barCount; b++) {
+ let sum = 0
+ const start = b * binsPerBar
+ for (let i = 0; i < binsPerBar; i++) sum += data[start + i] ?? 0
+ next.push(sum / binsPerBar / 255)
+ }
+ setLevels(next)
+ } else {
+ rafRef.current = requestAnimationFrame(tick)
+ }
+
+ return () => {
+ if (rafRef.current !== null) cancelAnimationFrame(rafRef.current)
+ rafRef.current = null
+ try {
+ source.disconnect()
+ analyser.disconnect()
+ } catch {
+ /* noop */
+ }
+ void audioCtx.close().catch(() => {
+ /* noop */
+ })
+ audioCtxRef.current = null
+ }
+ }, [stream, barCount])
+
+ return (
+
+ {levels.map((level, i) => {
+ const height = Math.max(0.08, level)
+ return (
+
+ )
+ })}
+
+ )
+}
diff --git a/src/features/simulations/components/__tests__/RecordingTimeline.test.tsx b/src/features/simulations/components/__tests__/RecordingTimeline.test.tsx
new file mode 100644
index 0000000..916e962
--- /dev/null
+++ b/src/features/simulations/components/__tests__/RecordingTimeline.test.tsx
@@ -0,0 +1,51 @@
+import { afterEach, describe, it, expect } from 'vitest'
+import { cleanup, render, screen } from '@testing-library/react'
+import { RecordingTimeline, getTimelineColor } from '../RecordingTimeline'
+
+describe('getTimelineColor — seuils fixes (15s / 30s avant la fin)', () => {
+ it('retourne success quand il reste plus de 30s', () => {
+ expect(getTimelineColor(0, 90)).toBe('success')
+ expect(getTimelineColor(59, 90)).toBe('success')
+ expect(getTimelineColor(89, 120)).toBe('success')
+ })
+
+ it('retourne warning entre 30s et 15s avant la fin', () => {
+ expect(getTimelineColor(60, 90)).toBe('warning')
+ expect(getTimelineColor(74, 90)).toBe('warning')
+ expect(getTimelineColor(90, 120)).toBe('warning')
+ })
+
+ it('retourne danger dans les 15 dernières secondes', () => {
+ expect(getTimelineColor(75, 90)).toBe('danger')
+ expect(getTimelineColor(90, 90)).toBe('danger')
+ expect(getTimelineColor(105, 120)).toBe('danger')
+ expect(getTimelineColor(120, 120)).toBe('danger')
+ })
+
+ it('gère les durées courtes (max ≤ 30s) en restant cohérent', () => {
+ // À max=20s : maxSeconds-30 = -10 → toujours ≥ warning ; maxSeconds-15 = 5 → danger après 5s
+ expect(getTimelineColor(0, 20)).toBe('warning')
+ expect(getTimelineColor(5, 20)).toBe('danger')
+ })
+})
+
+describe('RecordingTimeline — rendu', () => {
+ afterEach(() => cleanup())
+
+ it('expose un progressbar avec aria-valuenow/max', () => {
+ render()
+ const bar = screen.getByRole('progressbar')
+ expect(bar).toHaveAttribute('aria-valuenow', '45')
+ expect(bar).toHaveAttribute('aria-valuemax', '90')
+ })
+
+ it('clamp aria-valuenow à 0 si elapsed négatif', () => {
+ render()
+ expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '0')
+ })
+
+ it('clamp aria-valuenow à maxSeconds si dépassement', () => {
+ render()
+ expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '90')
+ })
+})
diff --git a/src/features/simulations/hooks/useAudioRecorder.ts b/src/features/simulations/hooks/useAudioRecorder.ts
index 1211377..674096d 100644
--- a/src/features/simulations/hooks/useAudioRecorder.ts
+++ b/src/features/simulations/hooks/useAudioRecorder.ts
@@ -31,6 +31,12 @@ export interface UseAudioRecorderResult {
elapsedSeconds: number
audioBlob: Blob | null
audioMimeType: string | null
+ /**
+ * Sprint 4.6 — flux micro actif pendant l'enregistrement, exposé pour
+ * permettre au visualizer (waveform) d'attacher un AnalyserNode. `null`
+ * tant que le micro n'est pas démarré, et après cleanup.
+ */
+ mediaStream: MediaStream | null
error: string | null
permissionDenied: boolean
start: () => Promise
@@ -60,6 +66,7 @@ export function useAudioRecorder(options: UseAudioRecorderOptions = {}): UseAudi
const [audioMimeType, setAudioMimeType] = useState(null)
const [error, setError] = useState(null)
const [permissionDenied, setPermissionDenied] = useState(false)
+ const [mediaStream, setMediaStream] = useState(null)
const recorderRef = useRef(null)
const streamRef = useRef(null)
@@ -69,7 +76,9 @@ export function useAudioRecorder(options: UseAudioRecorderOptions = {}): UseAudi
// Capture options dans une ref pour éviter de réabonner les effets sur
// chaque render (les callers fournissent souvent des fonctions inline).
+ // Refacto propre via useEffect tracé en FTD-38.
const optionsRef = useRef(options)
+ // eslint-disable-next-line react-hooks/refs
optionsRef.current = options
const maxReachedFiredRef = useRef(false)
@@ -83,6 +92,7 @@ export function useAudioRecorder(options: UseAudioRecorderOptions = {}): UseAudi
const cleanupStream = useCallback(() => {
streamRef.current?.getTracks().forEach((t) => t.stop())
streamRef.current = null
+ setMediaStream(null)
}, [])
const start = useCallback(async () => {
@@ -116,6 +126,7 @@ export function useAudioRecorder(options: UseAudioRecorderOptions = {}): UseAudi
return
}
streamRef.current = stream
+ setMediaStream(stream)
const mimeType = pickMimeType()
if (!mimeType) {
@@ -260,6 +271,7 @@ export function useAudioRecorder(options: UseAudioRecorderOptions = {}): UseAudi
elapsedSeconds,
audioBlob,
audioMimeType,
+ mediaStream,
error,
permissionDenied,
start,