Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions frontend/app/InterviewClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -79,6 +80,7 @@ export default function InterviewClient() {
const rafRef = useRef<number | null>(null);
const abortRef = useRef<AbortController | null>(null);
const usedIdsRef = useRef<Set<number>>(new Set());
const waveformRef = useRef<MicWaveformHandle>(null);

const newAbort = useCallback((): AbortSignal => {
abortRef.current?.abort();
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -317,6 +320,10 @@ export default function InterviewClient() {
<span>{statusText}</span>
</div>

{stage === "recording" && (
<MicWaveform ref={waveformRef} active speechThreshold={SILENCE_THRESHOLD} />
)}

{showDonePrompt && (
<div className={styles.donePrompt}>
<span>It looks like you&apos;ve paused. Are you finished answering?</span>
Expand Down
75 changes: 75 additions & 0 deletions frontend/app/components/MicWaveform/MicWaveform.module.css
Original file line number Diff line number Diff line change
@@ -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;
}
101 changes: 101 additions & 0 deletions frontend/app/components/MicWaveform/MicWaveform.stories.tsx
Original file line number Diff line number Diff line change
@@ -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) => (
<div style={{ maxWidth: 560, padding: 24, background: "#18181b" }}>
<Story />
</div>
),
],
};

export default meta;

type Story = StoryObj<MicWaveformProps>;

function SilentDemo() {
const ref = useRef<MicWaveformHandle>(null);

useEffect(() => {
const samples = new Float32Array(512);
ref.current?.drawWaveform(samples, 0);
}, []);

return <MicWaveform ref={ref} active speechThreshold={0.015} />;
}

function AnimatedSpeechDemo({ amplitude }: { amplitude: number }) {
const ref = useRef<MicWaveformHandle>(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 <MicWaveform ref={ref} active speechThreshold={0.015} />;
}

function PaintHelperDemo() {
const canvasRef = useRef<HTMLCanvasElement>(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 (
<canvas
ref={canvasRef}
width={560}
height={72}
style={{ width: "100%", background: "rgba(255,255,255,0.03)", borderRadius: 10 }}
/>
);
}

export const Silent: Story = {
render: () => <SilentDemo />,
};

export const ActiveSpeech: Story = {
render: () => <AnimatedSpeechDemo amplitude={0.35} />,
};

export const Inactive: Story = {
args: {
active: false,
},
};

export const PaintHelperFlatLine: Story = {
render: () => <PaintHelperDemo />,
};
168 changes: 168 additions & 0 deletions frontend/app/components/MicWaveform/MicWaveform.tsx
Original file line number Diff line number Diff line change
@@ -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<MicWaveformHandle, MicWaveformProps>(function MicWaveform(
{ active = true, speechThreshold = 0.015 },
ref
) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const speakingRef = useRef(false);
const silentSamplesRef = useRef<Float32Array | null>(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 (
<div
ref={containerRef}
className={styles.root}
data-active={active ? "true" : "false"}
data-speaking="false"
aria-live="polite"
>
<div className={styles.header}>
<span className={styles.title}>Microphone</span>
<span className={styles.statusIdle}>{active ? "Waiting for audio…" : "Inactive"}</span>
<span className={styles.statusActive}>Receiving audio</span>
</div>
<div className={styles.canvasWrap}>
<canvas ref={canvasRef} className={styles.canvas} aria-hidden="true" />
</div>
</div>
);
});
4 changes: 4 additions & 0 deletions frontend/types/storybook.d.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
declare module "@storybook/react" {
import type { ReactNode } from "react";

export type Meta<T = unknown> = {
title?: string;
component?: T;
decorators?: Array<(Story: () => ReactNode) => ReactNode>;
};

export type StoryObj<T = unknown> = {
args?: Partial<T>;
render?: () => ReactNode;
};
}
Loading