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
|
## [Unreleased] — 2026-04-25 — Sprint 4.5 Clean + fixes Golden Dataset
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
|
|
|
||||||
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
|
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,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue