feat(simulations/eo): waveform + timeline colorée pendant l'enregistrement (Sprint 4.6)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hermann_Kitio 2026-04-26 00:04:03 +03:00
parent 9614f9de14
commit d8bae9520c
6 changed files with 279 additions and 0 deletions

View file

@ -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 ## [Unreleased] — 2026-04-25 — Sprint 4.5 Clean + fixes Golden Dataset
### Added ### Added

View file

@ -17,6 +17,8 @@ import { Download, Mic, MicOff, Square } from 'lucide-react'
import { Button } from '@/shared/ui/Button' import { Button } from '@/shared/ui/Button'
import { formatTimer } from '../lib/simulationConfig' import { formatTimer } from '../lib/simulationConfig'
import { useAudioRecorder } from '../hooks/useAudioRecorder' import { useAudioRecorder } from '../hooks/useAudioRecorder'
import { RecordingTimeline } from './RecordingTimeline'
import { RecordingWaveform } from './RecordingWaveform'
interface Props { interface Props {
/** Durée minimale (s) avant que la soumission soit autorisée. */ /** Durée minimale (s) avant que la soumission soit autorisée. */
@ -136,6 +138,18 @@ export function AudioRecorder({
</span> </span>
</div> </div>
{isRecording && (
<div className="mt-3">
<RecordingWaveform stream={recorder.mediaStream} />
</div>
)}
{maxSeconds && (isRecording || isStopped) && (
<div className="mt-3">
<RecordingTimeline elapsedSeconds={recorder.elapsedSeconds} maxSeconds={maxSeconds} />
</div>
)}
{isRecording && remaining > 0 && ( {isRecording && remaining > 0 && (
<p className="mt-3 text-xs text-ink-secondary"> <p className="mt-3 text-xs text-ink-secondary">
Minimum 30 secondes requis ({remaining} s restantes). Minimum 30 secondes requis ({remaining} s restantes).

View file

@ -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<TimelineColor, string> = {
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 (
<div
role="progressbar"
aria-valuemin={0}
aria-valuemax={maxSeconds}
aria-valuenow={clamped}
aria-label="Temps écoulé"
className="h-2 w-full overflow-hidden rounded-pill bg-[var(--color-border)]"
>
<div
className="h-full rounded-pill"
style={{
width: `${ratio * 100}%`,
background: COLOR_VAR[color],
transition: 'width 1s linear, background-color 200ms ease-out',
}}
/>
</div>
)
}

View file

@ -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
* 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<number[]>(() => new Array(barCount).fill(0))
const rafRef = useRef<number | null>(null)
const audioCtxRef = useRef<AudioContext | null>(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 (
<div
aria-hidden="true"
className="flex h-12 items-center justify-center gap-[2px] rounded-[var(--radius-sm)] bg-[var(--color-brand-soft)] px-3"
>
{levels.map((level, i) => {
const height = Math.max(0.08, level)
return (
<span
key={i}
className="w-[3px] rounded-pill bg-[var(--color-brand-text)]"
style={{
height: `${height * 100}%`,
transition: 'height 80ms linear',
}}
/>
)
})}
</div>
)
}

View file

@ -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(<RecordingTimeline elapsedSeconds={45} maxSeconds={90} />)
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(<RecordingTimeline elapsedSeconds={-5} maxSeconds={90} />)
expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '0')
})
it('clamp aria-valuenow à maxSeconds si dépassement', () => {
render(<RecordingTimeline elapsedSeconds={200} maxSeconds={90} />)
expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '90')
})
})

View file

@ -31,6 +31,12 @@ export interface UseAudioRecorderResult {
elapsedSeconds: number elapsedSeconds: number
audioBlob: Blob | null audioBlob: Blob | null
audioMimeType: string | 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 error: string | null
permissionDenied: boolean permissionDenied: boolean
start: () => Promise<void> start: () => Promise<void>
@ -60,6 +66,7 @@ export function useAudioRecorder(options: UseAudioRecorderOptions = {}): UseAudi
const [audioMimeType, setAudioMimeType] = useState<string | null>(null) const [audioMimeType, setAudioMimeType] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [permissionDenied, setPermissionDenied] = useState(false) const [permissionDenied, setPermissionDenied] = useState(false)
const [mediaStream, setMediaStream] = useState<MediaStream | null>(null)
const recorderRef = useRef<MediaRecorder | null>(null) const recorderRef = useRef<MediaRecorder | null>(null)
const streamRef = useRef<MediaStream | null>(null) const streamRef = useRef<MediaStream | null>(null)
@ -69,7 +76,9 @@ export function useAudioRecorder(options: UseAudioRecorderOptions = {}): UseAudi
// Capture options dans une ref pour éviter de réabonner les effets sur // Capture options dans une ref pour éviter de réabonner les effets sur
// chaque render (les callers fournissent souvent des fonctions inline). // chaque render (les callers fournissent souvent des fonctions inline).
// Refacto propre via useEffect tracé en FTD-38.
const optionsRef = useRef(options) const optionsRef = useRef(options)
// eslint-disable-next-line react-hooks/refs
optionsRef.current = options optionsRef.current = options
const maxReachedFiredRef = useRef(false) const maxReachedFiredRef = useRef(false)
@ -83,6 +92,7 @@ export function useAudioRecorder(options: UseAudioRecorderOptions = {}): UseAudi
const cleanupStream = useCallback(() => { const cleanupStream = useCallback(() => {
streamRef.current?.getTracks().forEach((t) => t.stop()) streamRef.current?.getTracks().forEach((t) => t.stop())
streamRef.current = null streamRef.current = null
setMediaStream(null)
}, []) }, [])
const start = useCallback(async () => { const start = useCallback(async () => {
@ -116,6 +126,7 @@ export function useAudioRecorder(options: UseAudioRecorderOptions = {}): UseAudi
return return
} }
streamRef.current = stream streamRef.current = stream
setMediaStream(stream)
const mimeType = pickMimeType() const mimeType = pickMimeType()
if (!mimeType) { if (!mimeType) {
@ -260,6 +271,7 @@ export function useAudioRecorder(options: UseAudioRecorderOptions = {}): UseAudi
elapsedSeconds, elapsedSeconds,
audioBlob, audioBlob,
audioMimeType, audioMimeType,
mediaStream,
error, error,
permissionDenied, permissionDenied,
start, start,