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 ( + + ) +} 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,