From 7334855ba21f125a3f2315e5e1bac4360a7d2cfa Mon Sep 17 00:00:00 2001 From: Iwwww Date: Fri, 26 Dec 2025 21:51:59 +0300 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20=D0=B8=D1=81=D0=BF=D1=80?= =?UTF-8?q?=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=20=D0=B3=D1=80=D0=B0=D1=84=D0=B8?= =?UTF-8?q?=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../liveStream/model/liveStream.store.ts | 56 +++++++++++-------- .../features/liveStream/ui/useLiveStream.ts | 9 ++- .../audioLive/ui/FrequencyHistoryChart.tsx | 40 +++++++++---- 3 files changed, 70 insertions(+), 35 deletions(-) diff --git a/services/frontend/src/features/liveStream/model/liveStream.store.ts b/services/frontend/src/features/liveStream/model/liveStream.store.ts index e2fdfa3..f4151ad 100644 --- a/services/frontend/src/features/liveStream/model/liveStream.store.ts +++ b/services/frontend/src/features/liveStream/model/liveStream.store.ts @@ -1,7 +1,6 @@ import { create } from "zustand"; import type { AudioSample } from "../../../entities/audioSample/model/types"; import { env } from "../../../shared/config/env"; -import { RingBuffer } from "../../../shared/lib/ringBuffer"; import type { WsStatus } from "./types"; import { LiveWsClient } from "../lib/liveWsClient"; import { parseAndValidateMessage } from "../lib/parseAndValidate"; @@ -12,8 +11,6 @@ type LiveStreamState = { latest: AudioSample | null; - // snapshot for UI - // storage is ring buffer inside module history60s: AudioSample[]; peakHoldDb3s: number | null; @@ -22,13 +19,24 @@ type LiveStreamState = { loadLatest: (limit?: number) => Promise; }; -const HISTORY_POINTS = 600; // 60s * 10Hz -const PEAK_WINDOW_MS = 3000; +const WINDOW_MS = 60_000; +const PEAK_WINDOW_MS = 3_000; -const historyRing = new RingBuffer(HISTORY_POINTS); -let peakWindow: AudioSample[] = []; // time-based window +// safety cap для worst-case 60Hz: ~3600 точек за 60 секунд +// (нужен на случай странных таймстампов/скачков времени) +const MAX_POINTS_60S = 60 * 60; + +let historyWindow: AudioSample[] = []; +let peakWindow: AudioSample[] = []; 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 { if (windowSamples.length === 0) return null; let max = -Infinity; @@ -36,7 +44,7 @@ function computePeakDb(windowSamples: AudioSample[]): number | null { return Number.isFinite(max) ? max : null; } -export const useLiveStreamStore = create()((set, get) => ({ +export const useLiveStreamStore = create()((set) => ({ status: "disconnected", lastMessageAt: null, @@ -55,22 +63,19 @@ export const useLiveStreamStore = create()((set, get) => ({ const sample = parsed.sample; - // 60s ring - historyRing.push(sample); + // 60s time window (по времени, а не по "600 точек") + historyWindow.push(sample); + historyWindow = trimByTime(historyWindow, sample.timeMs - WINDOW_MS); - // 3s peak window - const now = Date.now(); + // 3s peak-hold window (тоже по времени сэмпла) peakWindow.push(sample); - const cutoff = now - PEAK_WINDOW_MS; - while (peakWindow.length && peakWindow[0]!.timeMs < cutoff) { - peakWindow.shift(); - } + peakWindow = trimByTime(peakWindow, sample.timeMs - PEAK_WINDOW_MS); set({ latest: sample, - history60s: historyRing.toArray(), + history60s: historyWindow.slice(), // копия для UI peakHoldDb3s: computePeakDb(peakWindow), - lastMessageAt: now, + lastMessageAt: Date.now(), }); }, }); @@ -82,13 +87,11 @@ export const useLiveStreamStore = create()((set, get) => ({ client?.close(); client = null; peakWindow = []; - // historyRing intentionally kept: - // UI/график не прыгает при дисконнекте + // historyWindow оставляем: график не должен "прыгать" при reconnect set({ status: "disconnected" }); }, loadLatest: async (limit = 100) => { - // optional warm-up for chart after refresh const url = `${env.apiUrl.replace(/\/$/, "")}/api/v1/audio/latest?limit=${limit}`; try { const res = await fetch(url); @@ -99,12 +102,19 @@ export const useLiveStreamStore = create()((set, get) => ({ rms_db: number; freq_hz: number; }>; + for (const item of raw) { const parsed = parseAndValidateMessage(JSON.stringify(item)); 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 { // ignore } diff --git a/services/frontend/src/features/liveStream/ui/useLiveStream.ts b/services/frontend/src/features/liveStream/ui/useLiveStream.ts index 9b61c93..a21e085 100644 --- a/services/frontend/src/features/liveStream/ui/useLiveStream.ts +++ b/services/frontend/src/features/liveStream/ui/useLiveStream.ts @@ -7,9 +7,14 @@ export function useLiveStream(options?: { loadLatest?: boolean }) { const loadLatest = useLiveStreamStore((s) => s.loadLatest); useEffect(() => { - if (options?.loadLatest) void loadLatest(100); + // 1. Подключаемся по WS connect(); + + // 2. Опционально грузим историю, чтобы график не был пустым первые секунды + if (options?.loadLatest) { + void loadLatest(300); // 300 точек ~30 сек при 10Hz + } + return () => disconnect(); - // eslint-disable-next-line react-hooks/exhaustive-deps }, []); } diff --git a/services/frontend/src/widgets/audioLive/ui/FrequencyHistoryChart.tsx b/services/frontend/src/widgets/audioLive/ui/FrequencyHistoryChart.tsx index 34e8b11..0ec60f2 100644 --- a/services/frontend/src/widgets/audioLive/ui/FrequencyHistoryChart.tsx +++ b/services/frontend/src/widgets/audioLive/ui/FrequencyHistoryChart.tsx @@ -1,3 +1,4 @@ +import { memo, useMemo } from "react"; import { Line, LineChart, @@ -16,7 +17,7 @@ import type { AudioSample } from "../../../entities/audioSample/model/types"; import { formatTimeHHMMSS } from "../../../shared/lib/time"; type Props = { - history: AudioSample[]; + history?: AudioSample[]; }; type ChartPoint = { @@ -24,17 +25,24 @@ type ChartPoint = { freq_hz: number; }; -export function FrequencyHistoryChart({ history }: Props) { - const data: ChartPoint[] = history.map((s) => ({ - timeMs: s.timeMs, - freq_hz: s.freq_hz, - })); +const Y_MIN = 129; +const Y_MAX = 5500; + +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 ( - Frequency (last 60s) + Frequency +
@@ -49,15 +57,27 @@ export function FrequencyHistoryChart({ history }: Props) { tickFormatter={(v) => formatTimeHHMMSS(Number(v))} tick={{ fontSize: 12 }} /> - + + `${Number(v).toFixed(0)}`} + /> + formatTimeHHMMSS(Number(v))} formatter={(v) => [`${Number(v).toFixed(0)} Hz`, "freq"]} /> + ); -} +});