feat/frontend #1
@@ -1,7 +1,6 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import type { AudioSample } from "../../../entities/audioSample/model/types";
|
import type { AudioSample } from "../../../entities/audioSample/model/types";
|
||||||
import { env } from "../../../shared/config/env";
|
import { env } from "../../../shared/config/env";
|
||||||
import { RingBuffer } from "../../../shared/lib/ringBuffer";
|
|
||||||
import type { WsStatus } from "./types";
|
import type { WsStatus } from "./types";
|
||||||
import { LiveWsClient } from "../lib/liveWsClient";
|
import { LiveWsClient } from "../lib/liveWsClient";
|
||||||
import { parseAndValidateMessage } from "../lib/parseAndValidate";
|
import { parseAndValidateMessage } from "../lib/parseAndValidate";
|
||||||
@@ -12,8 +11,6 @@ type LiveStreamState = {
|
|||||||
|
|
||||||
latest: AudioSample | null;
|
latest: AudioSample | null;
|
||||||
|
|
||||||
// snapshot for UI
|
|
||||||
// storage is ring buffer inside module
|
|
||||||
history60s: AudioSample[];
|
history60s: AudioSample[];
|
||||||
peakHoldDb3s: number | null;
|
peakHoldDb3s: number | null;
|
||||||
|
|
||||||
@@ -22,13 +19,24 @@ type LiveStreamState = {
|
|||||||
loadLatest: (limit?: number) => Promise<void>;
|
loadLatest: (limit?: number) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const HISTORY_POINTS = 600; // 60s * 10Hz
|
const WINDOW_MS = 60_000;
|
||||||
const PEAK_WINDOW_MS = 3000;
|
const PEAK_WINDOW_MS = 3_000;
|
||||||
|
|
||||||
const historyRing = new RingBuffer<AudioSample>(HISTORY_POINTS);
|
// safety cap для worst-case 60Hz: ~3600 точек за 60 секунд
|
||||||
let peakWindow: AudioSample[] = []; // time-based window
|
// (нужен на случай странных таймстампов/скачков времени)
|
||||||
|
const MAX_POINTS_60S = 60 * 60;
|
||||||
|
|
||||||
|
let historyWindow: AudioSample[] = [];
|
||||||
|
let peakWindow: AudioSample[] = [];
|
||||||
let client: LiveWsClient | null = null;
|
let client: LiveWsClient | null = null;
|
||||||
|
|
||||||
|
function trimByTime(arr: AudioSample[], cutoffMs: number): AudioSample[] {
|
||||||
|
// shift в цикле ок для ~3600 элементов; если захочешь — можно оптимизировать индексом
|
||||||
|
while (arr.length && arr[0]!.timeMs < cutoffMs) arr.shift();
|
||||||
|
if (arr.length > MAX_POINTS_60S) arr = arr.slice(-MAX_POINTS_60S);
|
||||||
|
return arr;
|
||||||
|
}
|
||||||
|
|
||||||
function computePeakDb(windowSamples: AudioSample[]): number | null {
|
function computePeakDb(windowSamples: AudioSample[]): number | null {
|
||||||
if (windowSamples.length === 0) return null;
|
if (windowSamples.length === 0) return null;
|
||||||
let max = -Infinity;
|
let max = -Infinity;
|
||||||
@@ -36,7 +44,7 @@ function computePeakDb(windowSamples: AudioSample[]): number | null {
|
|||||||
return Number.isFinite(max) ? max : null;
|
return Number.isFinite(max) ? max : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useLiveStreamStore = create<LiveStreamState>()((set, get) => ({
|
export const useLiveStreamStore = create<LiveStreamState>()((set) => ({
|
||||||
status: "disconnected",
|
status: "disconnected",
|
||||||
lastMessageAt: null,
|
lastMessageAt: null,
|
||||||
|
|
||||||
@@ -55,22 +63,19 @@ export const useLiveStreamStore = create<LiveStreamState>()((set, get) => ({
|
|||||||
|
|
||||||
const sample = parsed.sample;
|
const sample = parsed.sample;
|
||||||
|
|
||||||
// 60s ring
|
// 60s time window (по времени, а не по "600 точек")
|
||||||
historyRing.push(sample);
|
historyWindow.push(sample);
|
||||||
|
historyWindow = trimByTime(historyWindow, sample.timeMs - WINDOW_MS);
|
||||||
|
|
||||||
// 3s peak window
|
// 3s peak-hold window (тоже по времени сэмпла)
|
||||||
const now = Date.now();
|
|
||||||
peakWindow.push(sample);
|
peakWindow.push(sample);
|
||||||
const cutoff = now - PEAK_WINDOW_MS;
|
peakWindow = trimByTime(peakWindow, sample.timeMs - PEAK_WINDOW_MS);
|
||||||
while (peakWindow.length && peakWindow[0]!.timeMs < cutoff) {
|
|
||||||
peakWindow.shift();
|
|
||||||
}
|
|
||||||
|
|
||||||
set({
|
set({
|
||||||
latest: sample,
|
latest: sample,
|
||||||
history60s: historyRing.toArray(),
|
history60s: historyWindow.slice(), // копия для UI
|
||||||
peakHoldDb3s: computePeakDb(peakWindow),
|
peakHoldDb3s: computePeakDb(peakWindow),
|
||||||
lastMessageAt: now,
|
lastMessageAt: Date.now(),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -82,13 +87,11 @@ export const useLiveStreamStore = create<LiveStreamState>()((set, get) => ({
|
|||||||
client?.close();
|
client?.close();
|
||||||
client = null;
|
client = null;
|
||||||
peakWindow = [];
|
peakWindow = [];
|
||||||
// historyRing intentionally kept:
|
// historyWindow оставляем: график не должен "прыгать" при reconnect
|
||||||
// UI/график не прыгает при дисконнекте
|
|
||||||
set({ status: "disconnected" });
|
set({ status: "disconnected" });
|
||||||
},
|
},
|
||||||
|
|
||||||
loadLatest: async (limit = 100) => {
|
loadLatest: async (limit = 100) => {
|
||||||
// optional warm-up for chart after refresh
|
|
||||||
const url = `${env.apiUrl.replace(/\/$/, "")}/api/v1/audio/latest?limit=${limit}`;
|
const url = `${env.apiUrl.replace(/\/$/, "")}/api/v1/audio/latest?limit=${limit}`;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url);
|
const res = await fetch(url);
|
||||||
@@ -99,12 +102,19 @@ export const useLiveStreamStore = create<LiveStreamState>()((set, get) => ({
|
|||||||
rms_db: number;
|
rms_db: number;
|
||||||
freq_hz: number;
|
freq_hz: number;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
for (const item of raw) {
|
for (const item of raw) {
|
||||||
const parsed = parseAndValidateMessage(JSON.stringify(item));
|
const parsed = parseAndValidateMessage(JSON.stringify(item));
|
||||||
if (!parsed.ok) continue;
|
if (!parsed.ok) continue;
|
||||||
historyRing.push(parsed.sample);
|
historyWindow.push(parsed.sample);
|
||||||
}
|
}
|
||||||
set({ history60s: historyRing.toArray() });
|
|
||||||
|
// после warm-up тоже режем по времени "последней точки"
|
||||||
|
const last = historyWindow.at(-1);
|
||||||
|
if (last)
|
||||||
|
historyWindow = trimByTime(historyWindow, last.timeMs - WINDOW_MS);
|
||||||
|
|
||||||
|
set({ history60s: historyWindow.slice() });
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,14 @@ export function useLiveStream(options?: { loadLatest?: boolean }) {
|
|||||||
const loadLatest = useLiveStreamStore((s) => s.loadLatest);
|
const loadLatest = useLiveStreamStore((s) => s.loadLatest);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (options?.loadLatest) void loadLatest(100);
|
// 1. Подключаемся по WS
|
||||||
connect();
|
connect();
|
||||||
|
|
||||||
|
// 2. Опционально грузим историю, чтобы график не был пустым первые секунды
|
||||||
|
if (options?.loadLatest) {
|
||||||
|
void loadLatest(300); // 300 точек ~30 сек при 10Hz
|
||||||
|
}
|
||||||
|
|
||||||
return () => disconnect();
|
return () => disconnect();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []);
|
}, []);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { memo, useMemo } from "react";
|
||||||
import {
|
import {
|
||||||
Line,
|
Line,
|
||||||
LineChart,
|
LineChart,
|
||||||
@@ -16,7 +17,7 @@ import type { AudioSample } from "../../../entities/audioSample/model/types";
|
|||||||
import { formatTimeHHMMSS } from "../../../shared/lib/time";
|
import { formatTimeHHMMSS } from "../../../shared/lib/time";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
history: AudioSample[];
|
history?: AudioSample[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type ChartPoint = {
|
type ChartPoint = {
|
||||||
@@ -24,17 +25,24 @@ type ChartPoint = {
|
|||||||
freq_hz: number;
|
freq_hz: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function FrequencyHistoryChart({ history }: Props) {
|
const Y_MIN = 129;
|
||||||
const data: ChartPoint[] = history.map((s) => ({
|
const Y_MAX = 5500;
|
||||||
timeMs: s.timeMs,
|
|
||||||
freq_hz: s.freq_hz,
|
const Y_TICKS = [130, 200, 300, 440, 660, 880, 1000, 2000, 3000, 4000, 5500];
|
||||||
}));
|
|
||||||
|
export const FrequencyHistoryChart = memo(function FrequencyHistoryChart({
|
||||||
|
history = [],
|
||||||
|
}: Props) {
|
||||||
|
const data: ChartPoint[] = useMemo(() => {
|
||||||
|
return history.map((s) => ({ timeMs: s.timeMs, freq_hz: s.freq_hz }));
|
||||||
|
}, [history]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="text-base">Frequency (last 60s)</CardTitle>
|
<CardTitle className="text-base">Frequency</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="h-56 w-full">
|
<div className="h-56 w-full">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
@@ -49,15 +57,27 @@ export function FrequencyHistoryChart({ history }: Props) {
|
|||||||
tickFormatter={(v) => formatTimeHHMMSS(Number(v))}
|
tickFormatter={(v) => formatTimeHHMMSS(Number(v))}
|
||||||
tick={{ fontSize: 12 }}
|
tick={{ fontSize: 12 }}
|
||||||
/>
|
/>
|
||||||
<YAxis domain={[20, 8000]} tick={{ fontSize: 12 }} width={44} />
|
|
||||||
|
<YAxis
|
||||||
|
type="number"
|
||||||
|
scale="log"
|
||||||
|
domain={[Y_MIN, Y_MAX]}
|
||||||
|
ticks={Y_TICKS}
|
||||||
|
allowDataOverflow
|
||||||
|
tick={{ fontSize: 12 }}
|
||||||
|
width={52}
|
||||||
|
tickFormatter={(v) => `${Number(v).toFixed(0)}`}
|
||||||
|
/>
|
||||||
|
|
||||||
<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)} Hz`, "freq"]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Line
|
<Line
|
||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey="freq_hz"
|
dataKey="freq_hz"
|
||||||
stroke="hsl(var(--primary))"
|
stroke="oklch(0.208 0.042 265.755)"
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
dot={false}
|
dot={false}
|
||||||
isAnimationActive={false}
|
isAnimationActive={false}
|
||||||
@@ -68,4 +88,4 @@ export function FrequencyHistoryChart({ history }: Props) {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user