feat(frontend): добавлены виджеты Текущая частота и Форма волны

This commit is contained in:
2025-12-29 00:46:36 +03:00
parent f8edaa0aaf
commit 799a11b86d
8 changed files with 367 additions and 20 deletions

View File

@@ -123,3 +123,15 @@
@apply bg-background text-foreground; @apply bg-background text-foreground;
} }
} }
@keyframes spin-slow {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.animate-spin-slow {
animation: spin-slow 4s linear infinite;
}

View File

@@ -167,7 +167,7 @@ export const useLiveStreamStore = create<LiveStreamState>()((set, get) => {
peakWindow.push(sample); peakWindow.push(sample);
trimPeakWindow(sample.timeMs); trimPeakWindow(sample.timeMs);
// Throttled UI update (important for Recharts + overall UI smoothness) // Throttled UI update
scheduleFlush(); scheduleFlush();
}, },
}); });

View File

@@ -7,7 +7,7 @@ export function DashboardPage() {
return ( return (
<div className="mx-auto w-full max-w-6xl p-4 md:p-6"> <div className="mx-auto w-full max-w-6xl p-4 md:p-6">
<header className="mb-4"> <header className="mb-4">
<h1 className="text-2xl font-semibold">STM32 Audio Analyze Dashboard</h1> <h1 className="text-2xl font-semibold">STM32 Панель анализа звука</h1>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Частоты: 129 Гц - 5,5 кГц; Громкость: -50 - 0 дБ Частоты: 129 Гц - 5,5 кГц; Громкость: -50 - 0 дБ
</p> </p>

View File

@@ -1,4 +1,3 @@
// src/widgets/audioLive/ui/AudioLiveWidget.tsx
import { useMemo } from "react"; import { useMemo } from "react";
import { useLiveStreamStore } from "../../../features/liveStream/model/liveStream.store"; import { useLiveStreamStore } from "../../../features/liveStream/model/liveStream.store";
import { WsStatusBadge } from "../../../features/liveStream/ui/WsStatusBadge"; import { WsStatusBadge } from "../../../features/liveStream/ui/WsStatusBadge";
@@ -12,6 +11,8 @@ import {
} from "../../../shared/ui/card"; } from "../../../shared/ui/card";
import { freqToNote } from "../../../entities/audioSample/lib/note"; import { freqToNote } from "../../../entities/audioSample/lib/note";
import { formatTimeHHMMSS, isStale } from "../../../shared/lib/time"; import { formatTimeHHMMSS, isStale } from "../../../shared/lib/time";
import { FrequencyCurrentDisplay } from "./FrequencyCurrentDisplay";
import { WaveformDisplay } from "./WaveFormDisplay";
const HZ_OPTIONS: number[] = [0.1, 0.25, 0.5, 1, 2.5, 5, 10, 15] as const; const HZ_OPTIONS: number[] = [0.1, 0.25, 0.5, 1, 2.5, 5, 10, 15] as const;
@@ -23,6 +24,11 @@ const WINDOW_OPTIONS: Array<{ label: string; ms: number }> = [
{ label: "5m", ms: 300_000 }, { label: "5m", ms: 300_000 },
]; ];
export const LOUDNESS_THRESHOLD = -35.0;
export const MIN_FREQUENCY = 129;
export const MAX_FREQUENCY = 5500;
export function AudioLiveWidget() { export function AudioLiveWidget() {
const status = useLiveStreamStore((s) => s.status); const status = useLiveStreamStore((s) => s.status);
const latest = useLiveStreamStore((s) => s.latest); const latest = useLiveStreamStore((s) => s.latest);
@@ -43,18 +49,18 @@ export function AudioLiveWidget() {
}, [latest]); }, [latest]);
return ( return (
<div className="grid gap-4 lg:grid-cols-3"> <div className="grid gap-4 lg:grid-cols-4">
<div className="lg:col-span-1"> <div className="lg:col-span-1 row-span-2">
<AudioMeter <AudioMeter
rmsDb={latest?.rms_db ?? null} rmsDb={latest?.rms_db ?? null}
peakHoldDb3s={peakHoldDb3s} peakHoldDb3s={peakHoldDb3s}
/> />
</div> </div>
<div className="lg:col-span-2 grid gap-4"> <div className="lg:col-span-3 row-span-3 grid gap-4">
<Card> <Card>
<CardHeader className="pb-2 flex flex-row items-center justify-between gap-3"> <CardHeader className="pb-2 flex flex-row items-center justify-between gap-3">
<CardTitle className="text-base">Live status</CardTitle> <CardTitle className="text-base">Состояние</CardTitle>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<WsStatusBadge status={status} /> <WsStatusBadge status={status} />
</div> </div>
@@ -62,7 +68,7 @@ export function AudioLiveWidget() {
<CardContent className="flex flex-wrap items-end justify-between gap-4"> <CardContent className="flex flex-wrap items-end justify-between gap-4">
<div> <div>
<div className="text-sm text-muted-foreground">Last update</div> <div className="text-sm text-muted-foreground">Последнее обновление</div>
<div className="text-lg font-medium tabular-nums"> <div className="text-lg font-medium tabular-nums">
{lastMessageAt ? formatTimeHHMMSS(lastMessageAt) : "—"} {lastMessageAt ? formatTimeHHMMSS(lastMessageAt) : "—"}
<span <span
@@ -74,7 +80,7 @@ export function AudioLiveWidget() {
</div> </div>
<div> <div>
<div className="text-sm text-muted-foreground">Frequency</div> <div className="text-sm text-muted-foreground">Частота</div>
<div className="text-lg font-medium tabular-nums"> <div className="text-lg font-medium tabular-nums">
{latest ? `${latest.freq_hz.toFixed(0)} Hz` : "—"}{" "} {latest ? `${latest.freq_hz.toFixed(0)} Hz` : "—"}{" "}
<span className="text-muted-foreground">({note})</span> <span className="text-muted-foreground">({note})</span>
@@ -91,7 +97,7 @@ export function AudioLiveWidget() {
{/* Controls */} {/* Controls */}
<div className="flex flex-wrap items-end gap-3"> <div className="flex flex-wrap items-end gap-3">
<label className="grid gap-1"> <label className="grid gap-1">
<span className="text-sm text-muted-foreground">WS Hz</span> <span className="text-sm text-muted-foreground">Частота обновлений</span>
<select <select
className="h-9 rounded-md border border-input bg-background px-3 text-sm" className="h-9 rounded-md border border-input bg-background px-3 text-sm"
value={requestedHz} value={requestedHz}
@@ -99,14 +105,14 @@ export function AudioLiveWidget() {
> >
{HZ_OPTIONS.map((hz) => ( {HZ_OPTIONS.map((hz) => (
<option key={hz} value={hz}> <option key={hz} value={hz}>
{hz} Hz {hz} Гц
</option> </option>
))} ))}
</select> </select>
</label> </label>
<label className="grid gap-1"> <label className="grid gap-1">
<span className="text-sm text-muted-foreground">Window</span> <span className="text-sm text-muted-foreground">Окно</span>
<select <select
className="h-9 rounded-md border border-input bg-background px-3 text-sm" className="h-9 rounded-md border border-input bg-background px-3 text-sm"
value={windowMs} value={windowMs}
@@ -122,9 +128,19 @@ export function AudioLiveWidget() {
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
<FrequencyHistoryChart history={chartHistory} /> <FrequencyHistoryChart history={chartHistory} />
</div> </div>
<div className="lg:col-span-1 row-span-2 grid gap-4">
<FrequencyCurrentDisplay
latest={latest}
history={chartHistory}
windowMs={windowMs}
/>
</div>
<div className="lg:col-span-3 grid gap-4">
<WaveformDisplay latest={latest} />
</div>
</div> </div>
); );
} }

View File

@@ -45,7 +45,7 @@ export function AudioMeter({ rmsDb, peakHoldDb3s }: Props) {
return ( return (
<Card> <Card>
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<CardTitle className="text-base">Audio meter</CardTitle> <CardTitle className="text-base">Аудио измеритель</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="flex items-center gap-4"> <CardContent className="flex items-center gap-4">
@@ -53,7 +53,7 @@ export function AudioMeter({ rmsDb, peakHoldDb3s }: Props) {
<div className="flex items-stretch gap-3"> <div className="flex items-stretch gap-3">
{/* ticks */} {/* ticks */}
<div className="flex flex-col justify-between text-xs text-muted-foreground h-56 select-none"> <div className="flex flex-col justify-between text-xs text-muted-foreground h-56 select-none">
<div>0 dB</div> <div>0 дБ</div>
<div>-10</div> <div>-10</div>
<div>-20</div> <div>-20</div>
<div>-30</div> <div>-30</div>
@@ -78,13 +78,13 @@ export function AudioMeter({ rmsDb, peakHoldDb3s }: Props) {
{/* Values */} {/* Values */}
<div className="min-w-40"> <div className="min-w-40">
<div className="text-sm text-muted-foreground">Current</div> <div className="text-sm text-muted-foreground">Сейчас</div>
<div className="text-2xl font-semibold tabular-nums"> <div className="text-2xl font-semibold tabular-nums">
{rmsDb === null ? "—" : `${rmsDb.toFixed(1)} dB`} {rmsDb === null ? "—" : `${rmsDb.toFixed(1)} dB`}
</div> </div>
<div className="mt-3 text-sm text-muted-foreground"> <div className="mt-3 text-sm text-muted-foreground">
Peak hold (3s) Пик (3с)
</div> </div>
<div className="text-lg font-medium tabular-nums"> <div className="text-lg font-medium tabular-nums">
{peakHoldDb3s === null ? "—" : `${peakHoldDb3s.toFixed(1)} dB`} {peakHoldDb3s === null ? "—" : `${peakHoldDb3s.toFixed(1)} dB`}

View File

@@ -0,0 +1,188 @@
import { memo, useMemo } from "react";
import { Line, LineChart, ResponsiveContainer, Tooltip, XAxis } from "recharts";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "../../../shared/ui/card";
import type { AudioSample } from "../../../entities/audioSample/model/types";
import { freqToNote } from "../../../entities/audioSample/lib/note";
import { formatTimeHHMMSS } from "../../../shared/lib/time";
import {
LOUDNESS_THRESHOLD,
MAX_FREQUENCY,
MIN_FREQUENCY,
} from "./AudioLiveWidget";
type Props = {
latest: AudioSample | null;
history: AudioSample[]; // последние ~15 секунд
windowMs: number;
};
const HISTORY_WINDOW_MS = 15_000; // 15 секунд истории
const STABILITY_WINDOW = 5; // анализируем последние 10 точек
function getStability(history: AudioSample[]): number {
if (history.length < STABILITY_WINDOW) return 1;
const recent = history.slice(-STABILITY_WINDOW);
const values = recent.map((s) => s.freq_hz);
// Среднее арифметическое
const mean = values.reduce((a, b) => a + b, 0) / values.length;
// Дисперсия
const variance =
values.reduce((sum, v) => sum + Math.pow(v - mean, 2), 0) / values.length;
// Коэффициент вариации
return Math.max(0, 1 - Math.sqrt(variance) / mean); // 0..1, где 1 = идеально стабильно
}
const ORANGE_500: [number, number, number] = [249, 215, 22]; // #f97316
const GRAY: [number, number, number] = [50, 50, 50];
// Helper function to convert an RGB array to a CSS rgb() string
const toRgbString = (color: [number, number, number]): string =>
`rgb(${color[0]},${color[1]},${color[2]})`;
function getActivityColor(freq: number, rms: number): string {
let fromRgb: [number, number, number];
let toRgb: [number, number, number];
if (rms > LOUDNESS_THRESHOLD) {
const factor = (freq - MIN_FREQUENCY) / (MAX_FREQUENCY - MIN_FREQUENCY);
fromRgb = [
Math.round(ORANGE_500[0] * factor),
ORANGE_500[1],
Math.round(255 - (255 - ORANGE_500[2]) * factor),
];
toRgb = fromRgb;
} else {
fromRgb = GRAY;
toRgb = GRAY;
}
// Return a CSS linear-gradient string
return `linear-gradient(to right, ${toRgbString(fromRgb)}, ${toRgbString(toRgb)})`;
}
export const FrequencyCurrentDisplay = memo(function FrequencyCurrentDisplay({
latest,
history,
windowMs,
}: Props) {
const recentHistory = useMemo(() => {
if (!latest) return [];
const cutoff = latest.timeMs - HISTORY_WINDOW_MS;
return history.filter((s) => s.timeMs >= cutoff);
}, [latest?.timeMs, history]);
const stability = useMemo(() => getStability(recentHistory), [recentHistory]);
const note = latest ? freqToNote(latest.freq_hz) : "--";
const gradientColor = latest
? getActivityColor(latest.freq_hz, latest.rms_db)
: "from-slate-400";
const chartData = useMemo(() => {
return recentHistory.map((s) => ({
timeMs: s.timeMs,
freq_hz: s.freq_hz,
}));
}, [recentHistory]);
return (
<Card className="col-span-full lg:col-span-1">
<CardHeader className="pb-3">
<CardTitle className="text-lg">Текущая частота</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Центральный круглый индикатор */}
<div className="flex flex-col items-center space-y-2">
<div className="relative">
{/* Фон круга */}
<div
className="w-32 h-32 rounded-full border-8 border-slate-200 dark:border-slate-800 shadow-lg"
style={{
background: `radial-gradient(circle at center, hsl(var(--background)) 0%, hsl(var(--muted)) 100%)`,
}}
/>
{/* Градиентный круг по активности */}
{latest && (
<div
className={`absolute inset-0 w-32 h-32 rounded-full border-8 animate-spin-slow border-transparent bg-gradient-to-r opacity-70`}
style={{
background: gradientColor,
mask: "radial-gradient(circle at center, black 60%, transparent 70%)",
WebkitMask:
"radial-gradient(circle at center, black 60%, transparent 70%)",
}}
/>
)}
{/* Центральный текст */}
<div className="absolute inset-0 flex flex-col items-center justify-center">
<div className="text-3xl font-bold tabular-nums text-foreground">
{latest ? `${latest.freq_hz.toFixed(0)}` : "---"}
</div>
<div className="text-xs font-medium text-muted-foreground tracking-wide uppercase">
Hz
</div>
<div className="text-xs font-mono bg-background/80 px-2 py-0.5 rounded-full mt-1">
{note}
</div>
</div>
</div>
{/* Стабильность */}
<div className="flex items-center gap-2 text-xs">
<div
className="w-2 h-2 rounded-full"
style={{ backgroundColor: `hsl(${240 * stability}, 70%, 50%)` }}
/>Текущая частота</div>
</div>
{/* Мини-график последних 15 секунд */}
<div className="space-y-1">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>Последние изменения ({Math.round(HISTORY_WINDOW_MS / 1000)}с)</span>
<span>{recentHistory.length} pts</span>
</div>
<div className="h-20">
<ResponsiveContainer width="100%" height="100%">
<LineChart
data={chartData}
margin={{ top: 0, right: 0, left: 0, bottom: 0 }}
>
<defs>
<linearGradient id="freqTrend" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stopColor="#3b82f6" stopOpacity={0.8} />
<stop offset="100%" stopColor="#1d4ed8" stopOpacity={0.4} />
</linearGradient>
</defs>
<XAxis
dataKey="timeMs"
hide
type="number"
domain={["dataMin", "dataMax"]}
/>
<Tooltip
labelFormatter={(v) => formatTimeHHMMSS(Number(v))}
formatter={(v) => [`${Number(v).toFixed(0)} Hz`, "freq"]}
/>
<Line
type="monotone"
dataKey="freq_hz"
stroke="url(#freqTrend)"
strokeWidth={2}
dot={false}
isAnimationActive={false}
/>
</LineChart>
</ResponsiveContainer>
</div>
</div>
</CardContent>
</Card>
);
});

View File

@@ -51,14 +51,14 @@ export const FrequencyHistoryChart = memo(function FrequencyHistoryChart({
return ( return (
<Card> <Card>
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<CardTitle className="text-base">Frequency</CardTitle> <CardTitle className="text-base">Доминантная частота</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="relative h-56 min-h-[14rem] w-full"> <div className="relative h-56 min-h-[14rem] w-full">
{!hasData && ( {!hasData && (
<div className="absolute inset-0 grid place-items-center text-sm text-muted-foreground"> <div className="absolute inset-0 grid place-items-center text-sm text-muted-foreground">
No data yet Пока нет данных
</div> </div>
)} )}
@@ -89,7 +89,7 @@ export const FrequencyHistoryChart = memo(function FrequencyHistoryChart({
<Tooltip <Tooltip
labelFormatter={(v) => formatTimeHHMMSS(Number(v))} labelFormatter={(v) => formatTimeHHMMSS(Number(v))}
formatter={(v) => [`${Number(v).toFixed(0)} Hz`, "freq"]} formatter={(v) => [`${Number(v).toFixed(0)} Гц`, "част."]}
/> />
<Line <Line

View File

@@ -0,0 +1,131 @@
import { memo, useEffect, useRef } from "react";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "../../../shared/ui/card";
import type { AudioSample } from "../../../entities/audioSample/model/types";
import { LOUDNESS_THRESHOLD } from "./AudioLiveWidget";
type Props = {
latest: AudioSample | null;
};
// Синтетическая волна: синусоида с частотой freq_hz и амплитудой из rms_db
function drawSyntheticWaveform(
ctx: CanvasRenderingContext2D,
width: number,
height: number,
freqHz: number,
rmsDb: number,
) {
ctx.clearRect(0, 0, width, height);
// Конвертируем dB в амплитуду (0..1)
// rms_db диапазон [-50..0], при -50 амплитуда ~0, при 0 амплитуда ~1
const amplitude = Math.pow(10, rmsDb / 150); // линейная амплитуда (0..1)
const visualAmp = amplitude * (height / 2) * 0.8; // оставляем margin
// Количество точек на экране
const points = width;
// Частота в Гц -> сколько периодов умещается на экран
// Пусть экран = 1 секунда визуально
const timeScale = 0.01; // 50 мс на экран
const angularFreq = 2 * Math.PI * freqHz * timeScale;
// Рисуем синусоиду
ctx.beginPath();
ctx.strokeStyle = "oklch(0.6 0.2 260)"; // синий градиент
ctx.lineWidth = 2;
for (let x = 0; x < points; x++) {
const t = x / width; // нормализованное время [0..1]
const y = height / 2 + visualAmp * Math.sin(angularFreq * t);
if (x === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
}
ctx.stroke();
// Центральная линия (0-уровень)
ctx.beginPath();
ctx.strokeStyle = "oklch(0.5 0 0 / 0.3)";
ctx.lineWidth = 1;
ctx.setLineDash([4, 4]);
ctx.moveTo(0, height / 2);
ctx.lineTo(width, height / 2);
ctx.stroke();
ctx.setLineDash([]);
}
export const WaveformDisplay = memo(function WaveformDisplay({
latest,
}: Props) {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
// Подстраиваем размер под DPI (для чёткости на Retina)
const dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
ctx.scale(dpr, dpr);
// Рисуем волну
if (latest && latest.rms_db > LOUDNESS_THRESHOLD) {
drawSyntheticWaveform(
ctx,
rect.width,
rect.height,
latest.freq_hz,
latest.rms_db,
);
} else {
// Рисуем линию
ctx.clearRect(0, 0, rect.width, rect.height);
ctx.strokeStyle = "oklch(0.5 0 0 / 0.2)";
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(0, rect.height / 2);
ctx.lineTo(rect.width, rect.height / 2);
ctx.stroke();
}
}, [latest]);
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base">Форма волны</CardTitle>
</CardHeader>
<CardContent>
<div className="relative w-full h-32 bg-slate-50 dark:bg-slate-900 rounded-md overflow-hidden">
<canvas
ref={canvasRef}
className="w-full h-full"
style={{ display: "block" }}
/>
{/* Метки */}
<div className="absolute top-1 left-2 text-xs text-muted-foreground font-mono">
{latest ? `${latest.freq_hz.toFixed(0)} Гц` : "---"}
</div>
<div className="absolute top-1 right-2 text-xs text-muted-foreground font-mono">
{latest ? `${latest.rms_db.toFixed(1)} дБ` : "---"}
</div>
</div>
</CardContent>
</Card>
);
});