feat/frontend #1

Merged
Iwwww merged 9 commits from feat/frontend into main 2025-12-29 01:42:28 +01:00
3 changed files with 70 additions and 35 deletions
Showing only changes of commit 7334855ba2 - Show all commits

View File

@@ -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
} }

View File

@@ -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
}, []); }, []);
} }

View File

@@ -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>
); );
} });