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:
parent
9614f9de14
commit
d8bae9520c
6 changed files with 279 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
</span>
|
||||
</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 && (
|
||||
<p className="mt-3 text-xs text-ink-secondary">
|
||||
Minimum 30 secondes requis ({remaining} s restantes).
|
||||
|
|
|
|||
56
src/features/simulations/components/RecordingTimeline.tsx
Normal file
56
src/features/simulations/components/RecordingTimeline.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
121
src/features/simulations/components/RecordingWaveform.tsx
Normal file
121
src/features/simulations/components/RecordingWaveform.tsx
Normal 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
|
||||
* 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<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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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<void>
|
||||
|
|
@ -60,6 +66,7 @@ export function useAudioRecorder(options: UseAudioRecorderOptions = {}): UseAudi
|
|||
const [audioMimeType, setAudioMimeType] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [permissionDenied, setPermissionDenied] = useState(false)
|
||||
const [mediaStream, setMediaStream] = useState<MediaStream | null>(null)
|
||||
|
||||
const recorderRef = useRef<MediaRecorder | 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
|
||||
// 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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue