feat(frontend): добавлены виджеты Текущая частота и Форма волны
This commit is contained in:
@@ -123,3 +123,15 @@
|
||||
@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;
|
||||
}
|
||||
|
||||
@@ -167,7 +167,7 @@ export const useLiveStreamStore = create<LiveStreamState>()((set, get) => {
|
||||
peakWindow.push(sample);
|
||||
trimPeakWindow(sample.timeMs);
|
||||
|
||||
// Throttled UI update (important for Recharts + overall UI smoothness)
|
||||
// Throttled UI update
|
||||
scheduleFlush();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ export function DashboardPage() {
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-6xl p-4 md:p-6">
|
||||
<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">
|
||||
Частоты: 129 Гц - 5,5 кГц; Громкость: -50 - 0 дБ
|
||||
</p>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// src/widgets/audioLive/ui/AudioLiveWidget.tsx
|
||||
import { useMemo } from "react";
|
||||
import { useLiveStreamStore } from "../../../features/liveStream/model/liveStream.store";
|
||||
import { WsStatusBadge } from "../../../features/liveStream/ui/WsStatusBadge";
|
||||
@@ -12,6 +11,8 @@ import {
|
||||
} from "../../../shared/ui/card";
|
||||
import { freqToNote } from "../../../entities/audioSample/lib/note";
|
||||
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;
|
||||
|
||||
@@ -23,6 +24,11 @@ const WINDOW_OPTIONS: Array<{ label: string; ms: number }> = [
|
||||
{ label: "5m", ms: 300_000 },
|
||||
];
|
||||
|
||||
export const LOUDNESS_THRESHOLD = -35.0;
|
||||
|
||||
export const MIN_FREQUENCY = 129;
|
||||
export const MAX_FREQUENCY = 5500;
|
||||
|
||||
export function AudioLiveWidget() {
|
||||
const status = useLiveStreamStore((s) => s.status);
|
||||
const latest = useLiveStreamStore((s) => s.latest);
|
||||
@@ -43,18 +49,18 @@ export function AudioLiveWidget() {
|
||||
}, [latest]);
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
<div className="lg:col-span-1">
|
||||
<div className="grid gap-4 lg:grid-cols-4">
|
||||
<div className="lg:col-span-1 row-span-2">
|
||||
<AudioMeter
|
||||
rmsDb={latest?.rms_db ?? null}
|
||||
peakHoldDb3s={peakHoldDb3s}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-2 grid gap-4">
|
||||
<div className="lg:col-span-3 row-span-3 grid gap-4">
|
||||
<Card>
|
||||
<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">
|
||||
<WsStatusBadge status={status} />
|
||||
</div>
|
||||
@@ -62,7 +68,7 @@ export function AudioLiveWidget() {
|
||||
|
||||
<CardContent className="flex flex-wrap items-end justify-between gap-4">
|
||||
<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">
|
||||
{lastMessageAt ? formatTimeHHMMSS(lastMessageAt) : "—"}
|
||||
<span
|
||||
@@ -74,7 +80,7 @@ export function AudioLiveWidget() {
|
||||
</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">
|
||||
{latest ? `${latest.freq_hz.toFixed(0)} Hz` : "—"}{" "}
|
||||
<span className="text-muted-foreground">({note})</span>
|
||||
@@ -91,7 +97,7 @@ export function AudioLiveWidget() {
|
||||
{/* Controls */}
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<label className="grid gap-1">
|
||||
<span className="text-sm text-muted-foreground">WS Hz</span>
|
||||
<span className="text-sm text-muted-foreground">Частота обновлений</span>
|
||||
<select
|
||||
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
|
||||
value={requestedHz}
|
||||
@@ -99,14 +105,14 @@ export function AudioLiveWidget() {
|
||||
>
|
||||
{HZ_OPTIONS.map((hz) => (
|
||||
<option key={hz} value={hz}>
|
||||
{hz} Hz
|
||||
{hz} Гц
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="grid gap-1">
|
||||
<span className="text-sm text-muted-foreground">Window</span>
|
||||
<span className="text-sm text-muted-foreground">Окно</span>
|
||||
<select
|
||||
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
|
||||
value={windowMs}
|
||||
@@ -122,9 +128,19 @@ export function AudioLiveWidget() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<FrequencyHistoryChart history={chartHistory} />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ export function AudioMeter({ rmsDb, peakHoldDb3s }: Props) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">Audio meter</CardTitle>
|
||||
<CardTitle className="text-base">Аудио измеритель</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex items-center gap-4">
|
||||
@@ -53,7 +53,7 @@ export function AudioMeter({ rmsDb, peakHoldDb3s }: Props) {
|
||||
<div className="flex items-stretch gap-3">
|
||||
{/* ticks */}
|
||||
<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>-20</div>
|
||||
<div>-30</div>
|
||||
@@ -78,13 +78,13 @@ export function AudioMeter({ rmsDb, peakHoldDb3s }: Props) {
|
||||
|
||||
{/* Values */}
|
||||
<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">
|
||||
{rmsDb === null ? "—" : `${rmsDb.toFixed(1)} dB`}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 text-sm text-muted-foreground">
|
||||
Peak hold (3s)
|
||||
Пик (3с)
|
||||
</div>
|
||||
<div className="text-lg font-medium tabular-nums">
|
||||
{peakHoldDb3s === null ? "—" : `${peakHoldDb3s.toFixed(1)} dB`}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -51,14 +51,14 @@ export const FrequencyHistoryChart = memo(function FrequencyHistoryChart({
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">Frequency</CardTitle>
|
||||
<CardTitle className="text-base">Доминантная частота</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<div className="relative h-56 min-h-[14rem] w-full">
|
||||
{!hasData && (
|
||||
<div className="absolute inset-0 grid place-items-center text-sm text-muted-foreground">
|
||||
No data yet
|
||||
Пока нет данных
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -89,7 +89,7 @@ export const FrequencyHistoryChart = memo(function FrequencyHistoryChart({
|
||||
|
||||
<Tooltip
|
||||
labelFormatter={(v) => formatTimeHHMMSS(Number(v))}
|
||||
formatter={(v) => [`${Number(v).toFixed(0)} Hz`, "freq"]}
|
||||
formatter={(v) => [`${Number(v).toFixed(0)} Гц`, "част."]}
|
||||
/>
|
||||
|
||||
<Line
|
||||
|
||||
131
services/frontend/src/widgets/audioLive/ui/WaveFormDisplay.tsx
Normal file
131
services/frontend/src/widgets/audioLive/ui/WaveFormDisplay.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user