diff --git a/frontend/app/InterviewClient.tsx b/frontend/app/InterviewClient.tsx index 4962ef1..e4f948d 100644 --- a/frontend/app/InterviewClient.tsx +++ b/frontend/app/InterviewClient.tsx @@ -7,6 +7,7 @@ import { type InterviewQuestion, } from "@/lib/prompts/questions"; import { INTERVIEWERS, DEFAULT_INTERVIEWER, type Interviewer } from "@/lib/prompts/interviewers"; +import { MicWaveform, type MicWaveformHandle } from "@/app/components/MicWaveform/MicWaveform"; import { ScorecardPanel } from "@/app/scorecard/ScorecardPanel"; import { buildReviewContext } from "@/lib/interview-coach/sessionAdapter"; import type { ReviewContextPayload } from "@/lib/interview-coach/types"; @@ -79,6 +80,7 @@ export default function InterviewClient() { const rafRef = useRef(null); const abortRef = useRef(null); const usedIdsRef = useRef>(new Set()); + const waveformRef = useRef(null); const newAbort = useCallback((): AbortSignal => { abortRef.current?.abort(); @@ -178,6 +180,7 @@ export default function InterviewClient() { if (!audioCtxRef.current) return; analyser.getFloatTimeDomainData(buf); const rms = Math.sqrt(buf.reduce((s, v) => s + v * v, 0) / buf.length); + waveformRef.current?.drawWaveform(buf, rms); if (rms >= SILENCE_THRESHOLD) { hasSpokeRef.current = true; setShowDonePrompt(false); @@ -317,6 +320,10 @@ export default function InterviewClient() { {statusText} + {stage === "recording" && ( + + )} + {showDonePrompt && (
It looks like you've paused. Are you finished answering? diff --git a/frontend/app/components/MicWaveform/MicWaveform.module.css b/frontend/app/components/MicWaveform/MicWaveform.module.css new file mode 100644 index 0000000..1cfdc7f --- /dev/null +++ b/frontend/app/components/MicWaveform/MicWaveform.module.css @@ -0,0 +1,75 @@ +.root { + display: flex; + flex-direction: column; + gap: 0.45rem; +} + +.header { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 0.75rem; +} + +.title { + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.08em; + opacity: 0.5; +} + +.statusIdle, +.statusActive { + font-size: 0.72rem; + transition: color 0.2s ease; +} + +.statusIdle { + color: var(--color-muted-light); +} + +.statusActive { + display: none; + color: var(--color-success); +} + +.root[data-speaking="true"] .statusIdle { + display: none; +} + +.root[data-speaking="true"] .statusActive { + display: inline; +} + +.canvasWrap { + position: relative; + width: 100%; + height: 72px; + border-radius: 10px; + overflow: hidden; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.08); +} + +.root[data-speaking="true"] .canvasWrap { + border-color: rgba(34, 197, 94, 0.35); + box-shadow: inset 0 0 0 1px rgba(34, 197, 94, 0.08); +} + +.canvas { + display: block; + width: 100%; + height: 100%; +} + +.root[data-active="false"] .canvasWrap { + opacity: 0.45; +} + +.root[data-active="false"] .statusIdle { + color: var(--color-muted); +} + +.root[data-active="false"] .statusActive { + display: none; +} diff --git a/frontend/app/components/MicWaveform/MicWaveform.stories.tsx b/frontend/app/components/MicWaveform/MicWaveform.stories.tsx new file mode 100644 index 0000000..092da02 --- /dev/null +++ b/frontend/app/components/MicWaveform/MicWaveform.stories.tsx @@ -0,0 +1,101 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { useEffect, useRef } from "react"; + +import { + MicWaveform, + paintMicWaveform, + type MicWaveformHandle, + type MicWaveformProps, +} from "./MicWaveform"; + +const meta: Meta = { + title: "Interview/MicWaveform", + component: MicWaveform, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; + +type Story = StoryObj; + +function SilentDemo() { + const ref = useRef(null); + + useEffect(() => { + const samples = new Float32Array(512); + ref.current?.drawWaveform(samples, 0); + }, []); + + return ; +} + +function AnimatedSpeechDemo({ amplitude }: { amplitude: number }) { + const ref = useRef(null); + const samplesRef = useRef(new Float32Array(512)); + + useEffect(() => { + let frame = 0; + let raf = 0; + const tick = () => { + frame += 1; + const samples = samplesRef.current; + for (let i = 0; i < samples.length; i++) { + const t = (i / samples.length) * Math.PI * 8 + frame * 0.12; + samples[i] = Math.sin(t) * amplitude; + } + const rms = Math.sqrt( + samples.reduce((sum, value) => sum + value * value, 0) / samples.length + ); + ref.current?.drawWaveform(samples, rms); + raf = requestAnimationFrame(tick); + }; + raf = requestAnimationFrame(tick); + return () => cancelAnimationFrame(raf); + }, [amplitude]); + + return ; +} + +function PaintHelperDemo() { + const canvasRef = useRef(null); + + useEffect(() => { + const canvas = canvasRef.current; + const ctx = canvas?.getContext("2d"); + if (!canvas || !ctx) return; + paintMicWaveform(ctx, canvas.width, canvas.height, new Float32Array(512), 0, 0.015); + }, []); + + return ( + + ); +} + +export const Silent: Story = { + render: () => , +}; + +export const ActiveSpeech: Story = { + render: () => , +}; + +export const Inactive: Story = { + args: { + active: false, + }, +}; + +export const PaintHelperFlatLine: Story = { + render: () => , +}; diff --git a/frontend/app/components/MicWaveform/MicWaveform.tsx b/frontend/app/components/MicWaveform/MicWaveform.tsx new file mode 100644 index 0000000..5db0acf --- /dev/null +++ b/frontend/app/components/MicWaveform/MicWaveform.tsx @@ -0,0 +1,168 @@ +"use client"; + +import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef } from "react"; + +import styles from "./MicWaveform.module.css"; + +export type MicWaveformHandle = { + drawWaveform: (samples: Float32Array, rms: number) => void; +}; + +export type MicWaveformProps = { + active?: boolean; + speechThreshold?: number; +}; + +const WAVE_COLOR_IDLE = "#6b7280"; +const WAVE_COLOR_ACTIVE = "#22c55e"; +const GRID_COLOR = "rgba(255, 255, 255, 0.08)"; + +export function paintMicWaveform( + ctx: CanvasRenderingContext2D, + width: number, + height: number, + samples: Float32Array, + rms: number, + speechThreshold: number +): void { + const speaking = rms >= speechThreshold; + const midY = height / 2; + + ctx.clearRect(0, 0, width, height); + + ctx.strokeStyle = GRID_COLOR; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(0, midY); + ctx.lineTo(width, midY); + ctx.stroke(); + + ctx.strokeStyle = speaking ? WAVE_COLOR_ACTIVE : WAVE_COLOR_IDLE; + ctx.lineWidth = speaking ? 2 : 1.5; + ctx.beginPath(); + + const sliceWidth = width / samples.length; + let x = 0; + + for (let i = 0; i < samples.length; i++) { + const y = midY + samples[i] * (height * 0.42); + if (i === 0) { + ctx.moveTo(x, y); + } else { + ctx.lineTo(x, y); + } + x += sliceWidth; + } + + ctx.stroke(); +} + +export const MicWaveform = forwardRef(function MicWaveform( + { active = true, speechThreshold = 0.015 }, + ref +) { + const canvasRef = useRef(null); + const containerRef = useRef(null); + const speakingRef = useRef(false); + const silentSamplesRef = useRef(null); + + const syncCanvasSize = useCallback(() => { + const canvas = canvasRef.current; + const container = containerRef.current; + if (!canvas || !container) return; + + const rect = container.getBoundingClientRect(); + const dpr = window.devicePixelRatio || 1; + canvas.width = Math.max(1, Math.floor(rect.width * dpr)); + canvas.height = Math.max(1, Math.floor(rect.height * dpr)); + canvas.style.width = `${rect.width}px`; + canvas.style.height = `${rect.height}px`; + + const ctx = canvas.getContext("2d"); + if (ctx) { + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + } + }, []); + + useEffect(() => { + syncCanvasSize(); + const container = containerRef.current; + if (!container) return; + + const observer = new ResizeObserver(syncCanvasSize); + observer.observe(container); + return () => observer.disconnect(); + }, [syncCanvasSize]); + + useEffect(() => { + if (active) return; + + const canvas = canvasRef.current; + const ctx = canvas?.getContext("2d"); + if (!canvas || !ctx) return; + + if (!silentSamplesRef.current) { + silentSamplesRef.current = new Float32Array(512); + } + + paintMicWaveform( + ctx, + canvas.clientWidth, + canvas.clientHeight, + silentSamplesRef.current, + 0, + speechThreshold + ); + speakingRef.current = false; + containerRef.current?.setAttribute("data-speaking", "false"); + }, [active, speechThreshold]); + + useImperativeHandle( + ref, + () => ({ + drawWaveform(samples, rms) { + if (!active) return; + + const canvas = canvasRef.current; + const container = containerRef.current; + const ctx = canvas?.getContext("2d"); + if (!canvas || !ctx || !container) return; + + paintMicWaveform( + ctx, + canvas.clientWidth, + canvas.clientHeight, + samples, + rms, + speechThreshold + ); + + const speaking = rms >= speechThreshold; + if (speaking !== speakingRef.current) { + speakingRef.current = speaking; + container.setAttribute("data-speaking", speaking ? "true" : "false"); + } + }, + }), + [active, speechThreshold] + ); + + return ( +
+
+ Microphone + {active ? "Waiting for audio…" : "Inactive"} + Receiving audio +
+
+
+
+ ); +}); diff --git a/frontend/types/storybook.d.ts b/frontend/types/storybook.d.ts index 42dc6e3..75f7a5d 100644 --- a/frontend/types/storybook.d.ts +++ b/frontend/types/storybook.d.ts @@ -1,10 +1,14 @@ declare module "@storybook/react" { + import type { ReactNode } from "react"; + export type Meta = { title?: string; component?: T; + decorators?: Array<(Story: () => ReactNode) => ReactNode>; }; export type StoryObj = { args?: Partial; + render?: () => ReactNode; }; }