E5K7 commited on
Commit
eea7d47
·
1 Parent(s): 8aba1e2

feat: Live Vocal Stress Meter — real-time SVG gauge with needle, pitch/energy sub-metrics, and spectral analysis during recording

Browse files
frontend/app/record/page.tsx CHANGED
@@ -6,6 +6,7 @@ import { useDemoMode } from "@/hooks/useDemoMode";
6
  import { api, AnalyzeResult } from "@/lib/api";
7
  import { getGreeting, getMoodEmoji } from "@/lib/utils";
8
  import WaveformVisualizer from "@/components/WaveformVisualizer";
 
9
  import CircularProgress from "@/components/CircularProgress";
10
  import EmotionBadge from "@/components/EmotionBadge";
11
 
@@ -99,6 +100,13 @@ export default function RecordPage() {
99
  )}
100
  </div>
101
 
 
 
 
 
 
 
 
102
  <div className="flex flex-col items-center gap-4">
103
  <button
104
  onClick={state === "recording" ? handleStop : startRecording}
 
6
  import { api, AnalyzeResult } from "@/lib/api";
7
  import { getGreeting, getMoodEmoji } from "@/lib/utils";
8
  import WaveformVisualizer from "@/components/WaveformVisualizer";
9
+ import VocalStressMeter from "@/components/VocalStressMeter";
10
  import CircularProgress from "@/components/CircularProgress";
11
  import EmotionBadge from "@/components/EmotionBadge";
12
 
 
100
  )}
101
  </div>
102
 
103
+ {/* Live Stress Meter — only during recording */}
104
+ {state === "recording" && (
105
+ <div className="flex justify-center">
106
+ <VocalStressMeter analyserNode={analyserNode} isActive={true} />
107
+ </div>
108
+ )}
109
+
110
  <div className="flex flex-col items-center gap-4">
111
  <button
112
  onClick={state === "recording" ? handleStop : startRecording}
frontend/components/VocalStressMeter.tsx ADDED
@@ -0,0 +1,240 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+ import { useEffect, useRef, useState } from "react";
3
+ import { motion, AnimatePresence } from "framer-motion";
4
+
5
+ interface VocalStressMeterProps {
6
+ analyserNode: AnalyserNode | null;
7
+ isActive: boolean;
8
+ }
9
+
10
+ // ── Stress labels & theme at each level ──────────────────────────────────────
11
+ const LEVELS = [
12
+ { max: 22, label: "Calm", sublabel: "Relaxed, grounded", color: "#34d399", glow: "#34d39988" },
13
+ { max: 42, label: "Ease", sublabel: "Measured, composed", color: "#6ee7b7", glow: "#6ee7b788" },
14
+ { max: 58, label: "Focused", sublabel: "Engaged, present", color: "#fbbf24", glow: "#fbbf2488" },
15
+ { max: 72, label: "Tension", sublabel: "Slight stress detected", color: "#f97316", glow: "#f9731688" },
16
+ { max: 86, label: "Stress", sublabel: "Elevated vocal stress", color: "#ef4444", glow: "#ef444488" },
17
+ { max: 100, label: "High", sublabel: "Strong stress signals", color: "#dc2626", glow: "#dc262688" },
18
+ ];
19
+
20
+ function getLevel(score: number) {
21
+ return LEVELS.find(l => score <= l.max) ?? LEVELS[LEVELS.length - 1];
22
+ }
23
+
24
+ // ── SVG Arc helpers ──────────────────────────────────────────────────────────
25
+ const CX = 100, CY = 100, R = 80;
26
+ const START_ANGLE = -210; // degrees from 3-o'clock (SVG convention)
27
+ const SWEEP = 240; // total arc sweep in degrees
28
+
29
+ function polarToXY(angleDeg: number, r = R) {
30
+ const rad = ((angleDeg - 90) * Math.PI) / 180;
31
+ return { x: CX + r * Math.cos(rad), y: CY + r * Math.sin(rad) };
32
+ }
33
+
34
+ function arcPath(startDeg: number, endDeg: number, r = R) {
35
+ const s = polarToXY(startDeg, r);
36
+ const e = polarToXY(endDeg, r);
37
+ const large = endDeg - startDeg > 180 ? 1 : 0;
38
+ return `M ${s.x} ${s.y} A ${r} ${r} 0 ${large} 1 ${e.x} ${e.y}`;
39
+ }
40
+
41
+ export default function VocalStressMeter({ analyserNode, isActive }: VocalStressMeterProps) {
42
+ const rafRef = useRef<number>(0);
43
+ const smoothRef = useRef<number>(0);
44
+ const [score, setScore] = useState(0);
45
+ const [energy, setEnergy] = useState(0);
46
+ const [centroid, setCentroid] = useState(0);
47
+
48
+ useEffect(() => {
49
+ if (!analyserNode || !isActive) {
50
+ cancelAnimationFrame(rafRef.current);
51
+ // Decay to zero
52
+ const decay = () => {
53
+ setScore(prev => {
54
+ const next = prev * 0.92;
55
+ if (next > 0.5) rafRef.current = requestAnimationFrame(decay);
56
+ return next;
57
+ });
58
+ };
59
+ rafRef.current = requestAnimationFrame(decay);
60
+ return;
61
+ }
62
+
63
+ const bufLen = analyserNode.fftSize;
64
+ const freqLen = analyserNode.frequencyBinCount;
65
+ const timeDomain = new Uint8Array(bufLen);
66
+ const freqDomain = new Uint8Array(freqLen);
67
+ const sampleRate = analyserNode.context.sampleRate;
68
+
69
+ const tick = () => {
70
+ rafRef.current = requestAnimationFrame(tick);
71
+
72
+ analyserNode.getByteTimeDomainData(timeDomain);
73
+ analyserNode.getByteFrequencyData(freqDomain);
74
+
75
+ // ── RMS Energy ────────────────────────────────────────────────────────
76
+ let sumSq = 0;
77
+ for (let i = 0; i < bufLen; i++) {
78
+ const v = (timeDomain[i] - 128) / 128;
79
+ sumSq += v * v;
80
+ }
81
+ const rms = Math.sqrt(sumSq / bufLen); // 0..1
82
+
83
+ // ── Spectral Centroid (proxy for pitch height) ─────────────────────────
84
+ let weightedSum = 0, totalMag = 0;
85
+ for (let i = 0; i < freqLen; i++) {
86
+ const mag = freqDomain[i] / 255;
87
+ const freq = (i * sampleRate) / (2 * freqLen);
88
+ weightedSum += freq * mag;
89
+ totalMag += mag;
90
+ }
91
+ const specCentroid = totalMag > 0 ? weightedSum / totalMag : 0; // Hz
92
+
93
+ // ── Spectral Flux (rate of change in spectrum — jitter/tremor) ────────
94
+ // Simple approximation: variance of frequency magnitudes
95
+ const meanMag = totalMag / freqLen;
96
+ let fluxSum = 0;
97
+ for (let i = 0; i < freqLen; i++) {
98
+ const diff = freqDomain[i] / 255 - meanMag;
99
+ fluxSum += diff * diff;
100
+ }
101
+ const flux = Math.sqrt(fluxSum / freqLen);
102
+
103
+ // ── Stress Score (0-100) ──────────────────────────────────────────────
104
+ // normalise each component
105
+ const normEnergy = Math.min(1, rms * 6); // 0..1
106
+ const normCentroid = Math.min(1, specCentroid / 4000); // 0..1 (0-4kHz range)
107
+ const normFlux = Math.min(1, flux * 4); // 0..1
108
+
109
+ const rawStress = (normEnergy * 0.45 + normCentroid * 0.35 + normFlux * 0.20) * 100;
110
+
111
+ // Exponential smoothing (α = 0.08 → slow, smooth animation)
112
+ smoothRef.current = smoothRef.current * 0.92 + rawStress * 0.08;
113
+ const s = Math.min(100, Math.max(0, smoothRef.current));
114
+
115
+ setScore(s);
116
+ setEnergy(Math.round(normEnergy * 100));
117
+ setCentroid(Math.round(specCentroid));
118
+ };
119
+
120
+ rafRef.current = requestAnimationFrame(tick);
121
+ return () => cancelAnimationFrame(rafRef.current);
122
+ }, [analyserNode, isActive]);
123
+
124
+ const level = getLevel(score);
125
+ const needleAngle = START_ANGLE + (score / 100) * SWEEP;
126
+ const arcEnd = START_ANGLE + (score / 100) * SWEEP;
127
+ const needleTip = polarToXY(needleAngle, 62);
128
+ const needleBase1 = polarToXY(needleAngle + 90, 6);
129
+ const needleBase2 = polarToXY(needleAngle - 90, 6);
130
+
131
+ return (
132
+ <div className="flex flex-col items-center gap-6 w-full">
133
+ {/* Gauge */}
134
+ <div className="relative">
135
+ <svg
136
+ viewBox="20 20 160 130"
137
+ className="w-56 h-44"
138
+ style={{ filter: isActive ? `drop-shadow(0 0 12px ${level.glow})` : "none", transition: "filter 0.4s" }}
139
+ >
140
+ {/* Track arc (background) */}
141
+ <path
142
+ d={arcPath(START_ANGLE, START_ANGLE + SWEEP)}
143
+ fill="none"
144
+ stroke="#ffffff12"
145
+ strokeWidth="10"
146
+ strokeLinecap="round"
147
+ />
148
+
149
+ {/* Colored fill arc */}
150
+ <motion.path
151
+ d={arcPath(START_ANGLE, score > 0 ? arcEnd : START_ANGLE)}
152
+ fill="none"
153
+ stroke={level.color}
154
+ strokeWidth="10"
155
+ strokeLinecap="round"
156
+ style={{ transition: "stroke 0.4s ease, d 0.1s" }}
157
+ />
158
+
159
+ {/* Tick marks */}
160
+ {[0, 20, 40, 60, 80, 100].map(v => {
161
+ const a = START_ANGLE + (v / 100) * SWEEP;
162
+ const outer = polarToXY(a, 92);
163
+ const inner = polarToXY(a, 84);
164
+ return (
165
+ <line
166
+ key={v}
167
+ x1={inner.x} y1={inner.y}
168
+ x2={outer.x} y2={outer.y}
169
+ stroke="#ffffff25"
170
+ strokeWidth="1.5"
171
+ />
172
+ );
173
+ })}
174
+
175
+ {/* Needle */}
176
+ <motion.polygon
177
+ points={`${needleTip.x},${needleTip.y} ${needleBase1.x},${needleBase1.y} ${needleBase2.x},${needleBase2.y}`}
178
+ fill={level.color}
179
+ style={{ filter: `drop-shadow(0 0 4px ${level.color})`, transition: "fill 0.4s ease" }}
180
+ />
181
+
182
+ {/* Center cap */}
183
+ <circle cx={CX} cy={CY} r="6" fill={level.color} style={{ transition: "fill 0.4s ease" }} />
184
+ <circle cx={CX} cy={CY} r="3" fill="#0a0a0f" />
185
+
186
+ {/* Score in center */}
187
+ <text
188
+ x={CX} y={CY + 22}
189
+ textAnchor="middle"
190
+ fill="white"
191
+ fontSize="18"
192
+ fontWeight="bold"
193
+ fontFamily="system-ui"
194
+ >
195
+ {Math.round(score)}
196
+ </text>
197
+ </svg>
198
+
199
+ {/* Level label badge */}
200
+ <div className="absolute bottom-0 left-1/2 -translate-x-1/2 flex flex-col items-center">
201
+ <motion.div
202
+ key={level.label}
203
+ initial={{ opacity: 0, scale: 0.8 }}
204
+ animate={{ opacity: 1, scale: 1 }}
205
+ className="text-sm font-bold tracking-wide"
206
+ style={{ color: level.color }}
207
+ >
208
+ {level.label}
209
+ </motion.div>
210
+ <div className="text-[10px] text-white/30 mt-0.5">{level.sublabel}</div>
211
+ </div>
212
+ </div>
213
+
214
+ {/* Sub-metrics */}
215
+ <div className="flex gap-8 text-center">
216
+ <div>
217
+ <div className="text-lg font-bold text-emerald-400">{energy}</div>
218
+ <div className="text-[10px] text-white/30 uppercase tracking-wider">Volume</div>
219
+ </div>
220
+ <div className="w-px bg-white/10" />
221
+ <div>
222
+ <div className="text-lg font-bold text-purple-400">{centroid} <span className="text-xs font-normal text-white/30">Hz</span></div>
223
+ <div className="text-[10px] text-white/30 uppercase tracking-wider">Pitch</div>
224
+ </div>
225
+ <div className="w-px bg-white/10" />
226
+ <div>
227
+ <div className="text-lg font-bold" style={{ color: level.color }}>{Math.round(score)}</div>
228
+ <div className="text-[10px] text-white/30 uppercase tracking-wider">Stress</div>
229
+ </div>
230
+ </div>
231
+
232
+ {/* Range labels */}
233
+ <div className="flex justify-between w-48 text-[10px] text-white/20">
234
+ <span>Calm</span>
235
+ <span>Focused</span>
236
+ <span>High</span>
237
+ </div>
238
+ </div>
239
+ );
240
+ }