diff --git a/frontend/src/components/AudioPlayer.tsx b/frontend/src/components/AudioPlayer.tsx
index 63a7fa4..0d581e2 100644
--- a/frontend/src/components/AudioPlayer.tsx
+++ b/frontend/src/components/AudioPlayer.tsx
@@ -231,7 +231,7 @@ export default function AudioPlayer({ episode, onClose, userId, mode = 'floating
{/* Audio Visualizer */}
-
+
diff --git a/frontend/src/components/AudioVisualizer.tsx b/frontend/src/components/AudioVisualizer.tsx
index 7f0218c..5074406 100644
--- a/frontend/src/components/AudioVisualizer.tsx
+++ b/frontend/src/components/AudioVisualizer.tsx
@@ -1,26 +1,26 @@
-import { useEffect, useRef, useState } from 'react';
+import { useEffect, useRef } from 'react';
import { useAudio } from '../context/AudioContext';
interface AudioVisualizerProps {
- width?: number;
height?: number;
- barCount?: number;
}
-export default function AudioVisualizer({
- width,
- height = 72,
- barCount = 48,
-}: AudioVisualizerProps) {
- const { isPlaying, getFrequencyData } = useAudio();
+const NUM_POINTS = 80;
+const BASE_AMPLITUDE = 0.12;
+const MAX_AMPLITUDE = 0.42;
+
+export default function AudioVisualizer({ height = 80 }: AudioVisualizerProps) {
+ const { isPlaying, currentTime, duration, getFrequencyData } = useAudio();
const canvasRef = useRef
(null);
const rafRef = useRef(0);
- const heightsRef = useRef(Array(barCount).fill(3));
+ const heightsRef = useRef(Array(NUM_POINTS).fill(BASE_AMPLITUDE));
const playingRef = useRef(isPlaying);
const getDataRef = useRef(getFrequencyData);
+ const timeRef = useRef({ currentTime, duration });
useEffect(() => { playingRef.current = isPlaying; }, [isPlaying]);
useEffect(() => { getDataRef.current = getFrequencyData; }, [getFrequencyData]);
+ useEffect(() => { timeRef.current = { currentTime, duration }; }, [currentTime, duration]);
useEffect(() => {
const canvas = canvasRef.current;
@@ -29,7 +29,9 @@ export default function AudioVisualizer({
if (!ctx) return;
const dpr = window.devicePixelRatio || 1;
- const displayW = width || canvas.parentElement?.clientWidth || 300;
+ const parentW = canvas.parentElement?.clientWidth || 300;
+ const maxW = Math.min(parentW, 800);
+ const displayW = maxW;
const displayH = height;
canvas.width = displayW * dpr;
canvas.height = displayH * dpr;
@@ -37,9 +39,6 @@ export default function AudioVisualizer({
canvas.style.height = `${displayH}px`;
ctx.scale(dpr, dpr);
- const half = barCount / 2;
- const barW = Math.max(2, (displayW - barCount * 2) / barCount);
- const gap = (displayW - barW * barCount) / (barCount + 1);
const midY = displayH / 2;
let frame = 0;
@@ -48,110 +47,136 @@ export default function AudioVisualizer({
if (!ctx || !canvas) return;
frame++;
const raw = getDataRef.current?.();
- const currentHeights = heightsRef.current;
+ const hts = heightsRef.current;
+ const step = displayW / (NUM_POINTS - 1);
+ const { currentTime: ct, duration: dur } = timeRef.current;
+ const progress = dur > 0 ? ct / dur : 0;
- // Update target heights from frequency data
- if (raw && playingRef.current && frame % 2 === 0) {
- for (let i = 0; i < barCount; i++) {
- const distFromCenter = Math.abs(i - (barCount - 1) / 2);
- const halfIdx = Math.floor(distFromCenter);
- const dataIndex = Math.min(halfIdx, raw.length - 1);
- const value = raw[dataIndex] ?? 0;
-
- // Edge fade — center bars are taller
- const edgeFade = 1 - (distFromCenter / half) * 0.25;
- // Boost mid frequencies
- const midBoost = distFromCenter < half * 0.5 ? 1.2 : 1;
- const target = Math.max(4, Math.min(100, (value / 255) * 100 * edgeFade * midBoost));
-
- // Smooth easing toward target
- currentHeights[i] += (target - currentHeights[i]) * 0.35;
+ // Update heights from frequency data
+ if (raw && playingRef.current && frame % 3 === 0) {
+ for (let i = 0; i < NUM_POINTS; i++) {
+ const bucketSize = raw.length / NUM_POINTS;
+ const startIdx = Math.floor(i * bucketSize);
+ const endIdx = Math.floor((i + 1) * bucketSize);
+ let sum = 0;
+ let count = 0;
+ for (let j = startIdx; j < endIdx && j < raw.length; j++) {
+ sum += raw[j];
+ count++;
+ }
+ const avg = count > 0 ? sum / count : 0;
+ const target = BASE_AMPLITUDE + (avg / 255) * (MAX_AMPLITUDE - BASE_AMPLITUDE);
+ const rate = target > hts[i] ? 0.3 : 0.12;
+ hts[i] += (target - hts[i]) * rate;
}
} else if (!playingRef.current) {
- // Gentle decay when paused
- for (let i = 0; i < barCount; i++) {
- currentHeights[i] += (3 - currentHeights[i]) * 0.08;
+ for (let i = 0; i < NUM_POINTS; i++) {
+ hts[i] += (BASE_AMPLITUDE - hts[i]) * 0.06;
}
}
- // Clear
+ // Compute path points
+ const points: { x: number; y: number }[] = [];
+ for (let i = 0; i < NUM_POINTS; i++) {
+ const x = i * step;
+ const direction = i % 2 === 0 ? -1 : 1;
+ const y = midY + direction * hts[i] * midY;
+ points.push({ x, y });
+ }
+
ctx.clearRect(0, 0, displayW, displayH);
- // Draw bars
- for (let i = 0; i < barCount; i++) {
- const h = currentHeights[i];
- const barH = Math.max(2, (h / 100) * midY * 0.9);
- const x = gap + i * (barW + gap);
- const radius = barW / 2;
+ // ── Background muted waveform ──
+ drawSmoothPath(ctx, points, 'rgba(100, 116, 139, 0.25)', 2, displayW, midY);
- // Glow on taller bars
- if (h > 25) {
- const glowAlpha = Math.min(0.35, h / 300);
- const glowGrad = ctx.createRadialGradient(x + barW / 2, midY, barW, x + barW / 2, midY, barH + barW);
- glowGrad.addColorStop(0, `rgba(108, 111, 247, ${glowAlpha})`);
- glowGrad.addColorStop(1, 'rgba(108, 111, 247, 0)');
- ctx.fillStyle = glowGrad;
- ctx.fillRect(x - barW, midY - barH - barW, barW * 3, barH * 2 + barW * 2);
- }
+ // ── Active glowing waveform ──
+ // Outer glow
+ ctx.save();
+ ctx.shadowColor = 'rgba(34, 211, 238, 0.6)';
+ ctx.shadowBlur = 14;
+ const grad = ctx.createLinearGradient(0, 0, displayW, 0);
+ grad.addColorStop(0, '#22d3ee');
+ grad.addColorStop(0.5, '#818cf8');
+ grad.addColorStop(1, '#c084fc');
+ drawSmoothPath(ctx, points, grad, 3, displayW, midY);
+ ctx.restore();
- // Bar gradient (top half = cyan, bottom half = indigo)
- const barGrad = ctx.createLinearGradient(x, midY - barH, x, midY + barH);
- barGrad.addColorStop(0, '#0dc5e0');
- barGrad.addColorStop(0.4, '#6c6ff7');
- barGrad.addColorStop(0.6, '#6c6ff7');
- barGrad.addColorStop(1, '#0dc5e0');
- ctx.fillStyle = barGrad;
+ // ── Progress indicator dot ──
+ const dotX = progress * displayW;
+ const dotIdx = Math.floor(progress * (NUM_POINTS - 1));
+ const clampedIdx = Math.min(dotIdx, NUM_POINTS - 1);
+ const dotY = points[clampedIdx]?.y ?? midY;
- // Draw upper bar (rounded top)
- ctx.beginPath();
- ctx.moveTo(x, midY);
- ctx.lineTo(x, midY - barH + radius);
- ctx.arcTo(x, midY - barH, x + barW, midY - barH, radius);
- ctx.lineTo(x + barW, midY);
- ctx.fill();
+ // Outer glow
+ const dotGlow = ctx.createRadialGradient(dotX, dotY, 0, dotX, dotY, 10);
+ dotGlow.addColorStop(0, 'rgba(255, 255, 255, 0.9)');
+ dotGlow.addColorStop(0.3, 'rgba(255, 255, 255, 0.5)');
+ dotGlow.addColorStop(1, 'rgba(255, 255, 255, 0)');
+ ctx.fillStyle = dotGlow;
+ ctx.beginPath();
+ ctx.arc(dotX, dotY, 10, 0, Math.PI * 2);
+ ctx.fill();
- // Draw lower bar (mirrored, rounded bottom)
- ctx.beginPath();
- ctx.moveTo(x, midY);
- ctx.lineTo(x, midY + barH - radius);
- ctx.arcTo(x, midY + barH, x + barW, midY + barH, radius);
- ctx.lineTo(x + barW, midY);
- ctx.fill();
-
- // Bright top highlight on peak bars
- if (h > 50) {
- ctx.fillStyle = `rgba(255, 255, 255, ${Math.min(0.5, (h - 50) / 100)})`;
- ctx.beginPath();
- ctx.arc(x + barW / 2, midY - barH + radius, radius * 0.6, 0, Math.PI * 2);
- ctx.fill();
- ctx.beginPath();
- ctx.arc(x + barW / 2, midY + barH - radius, radius * 0.6, 0, Math.PI * 2);
- ctx.fill();
- }
-
- // Center line — thin subtle separator
- if (i === 0) {
- ctx.strokeStyle = 'rgba(255, 255, 255, 0.04)';
- ctx.lineWidth = 1;
- ctx.beginPath();
- ctx.moveTo(0, midY);
- ctx.lineTo(displayW, midY);
- ctx.stroke();
- }
- }
+ // Core dot
+ ctx.fillStyle = '#ffffff';
+ ctx.beginPath();
+ ctx.arc(dotX, dotY, 4, 0, Math.PI * 2);
+ ctx.fill();
rafRef.current = requestAnimationFrame(animate);
};
rafRef.current = requestAnimationFrame(animate);
return () => cancelAnimationFrame(rafRef.current);
- }, [width, height, barCount]);
+ }, [height]);
return (
);
}
+
+/** Draw a smooth waveform using quadratic bezier curves */
+function drawSmoothPath(
+ ctx: CanvasRenderingContext2D,
+ points: { x: number; y: number }[],
+ stroke: string | CanvasGradient,
+ lineWidth: number,
+ _displayW: number,
+ midY: number,
+) {
+ if (points.length < 2) return;
+
+ ctx.beginPath();
+ ctx.moveTo(points[0].x, points[0].y);
+
+ // First segment: control point is halfway between first two points at midY level
+ const firstCpX = (points[0].x + points[1].x) / 2;
+ const firstCpY = midY + (midY - points[0].y); // reflect first point around midY
+ ctx.quadraticCurveTo(firstCpX, firstCpY, points[1].x, points[1].y);
+
+ // Subsequent segments: reflect previous control point for smoothness (SVG T command equivalent)
+ let prevCpX = firstCpX;
+ let prevCpY = firstCpY;
+ let prevX = points[1].x;
+ let prevY = points[1].y;
+
+ for (let i = 2; i < points.length; i++) {
+ const reflectedCpX = 2 * prevX - prevCpX;
+ const reflectedCpY = 2 * prevY - prevCpY;
+ ctx.quadraticCurveTo(reflectedCpX, reflectedCpY, points[i].x, points[i].y);
+ prevCpX = reflectedCpX;
+ prevCpY = reflectedCpY;
+ prevX = points[i].x;
+ prevY = points[i].y;
+ }
+
+ ctx.strokeStyle = stroke;
+ ctx.lineWidth = lineWidth;
+ ctx.lineCap = 'round';
+ ctx.lineJoin = 'round';
+ ctx.stroke();
+}