feat(frontend): add basic frontend

This commit is contained in:
2025-12-26 20:14:30 +03:00
parent 262c42c1b3
commit 707a474ef3
38 changed files with 5230 additions and 0 deletions

View File

@@ -0,0 +1,112 @@
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";
type LiveStreamState = {
status: WsStatus;
lastMessageAt: number | null;
latest: AudioSample | null;
// snapshot for UI
// storage is ring buffer inside module
history60s: AudioSample[];
peakHoldDb3s: number | null;
connect: () => void;
disconnect: () => void;
loadLatest: (limit?: number) => Promise<void>;
};
const HISTORY_POINTS = 600; // 60s * 10Hz
const PEAK_WINDOW_MS = 3000;
const historyRing = new RingBuffer<AudioSample>(HISTORY_POINTS);
let peakWindow: AudioSample[] = []; // time-based window
let client: LiveWsClient | null = null;
function computePeakDb(windowSamples: AudioSample[]): number | null {
if (windowSamples.length === 0) return null;
let max = -Infinity;
for (const s of windowSamples) max = Math.max(max, s.rms_db);
return Number.isFinite(max) ? max : null;
}
export const useLiveStreamStore = create<LiveStreamState>()((set, get) => ({
status: "disconnected",
lastMessageAt: null,
latest: null,
history60s: [],
peakHoldDb3s: null,
connect: () => {
if (client) return;
client = new LiveWsClient(env.wsUrl, {
onStatus: (st) => set({ status: st }),
onMessage: (data) => {
const parsed = parseAndValidateMessage(data);
if (!parsed.ok) return;
const sample = parsed.sample;
// 60s ring
historyRing.push(sample);
// 3s peak window
const now = Date.now();
peakWindow.push(sample);
const cutoff = now - PEAK_WINDOW_MS;
while (peakWindow.length && peakWindow[0]!.timeMs < cutoff) {
peakWindow.shift();
}
set({
latest: sample,
history60s: historyRing.toArray(),
peakHoldDb3s: computePeakDb(peakWindow),
lastMessageAt: now,
});
},
});
client.connect();
},
disconnect: () => {
client?.close();
client = null;
peakWindow = [];
// historyRing intentionally kept:
// UI/график не прыгает при дисконнекте
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);
if (!res.ok) return;
const raw = (await res.json()) as Array<{
time: string;
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);
}
set({ history60s: historyRing.toArray() });
} catch {
// ignore
}
},
}));