feat(frontend): добавлены виджеты Текущая частота и Форма волны
This commit is contained in:
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`}
|
||||||
|
|||||||
@@ -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 (
|
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
|
||||||
|
|||||||
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