feat(frontend): add basic frontend
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
import { useLiveStreamStore } from "../../../features/liveStream/model/liveStream.store";
|
||||
import { WsStatusBadge } from "../../../features/liveStream/ui/WsStatusBadge";
|
||||
import { AudioMeter } from "./AudioMeter";
|
||||
import { FrequencyHistoryChart } from "./FrequencyHistoryChart";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../../shared/ui/card";
|
||||
import { freqToNote } from "../../../entities/audioSample/lib/note";
|
||||
import { formatTimeHHMMSS, isStale } from "../../../shared/lib/time";
|
||||
|
||||
export function AudioLiveWidget() {
|
||||
const status = useLiveStreamStore((s) => s.status);
|
||||
const latest = useLiveStreamStore((s) => s.latest);
|
||||
const history60s = useLiveStreamStore((s) => s.history60s);
|
||||
const peakHoldDb3s = useLiveStreamStore((s) => s.peakHoldDb3s);
|
||||
const lastMessageAt = useLiveStreamStore((s) => s.lastMessageAt);
|
||||
|
||||
const stale = isStale(lastMessageAt, 1500);
|
||||
|
||||
const note = latest ? freqToNote(latest.freq_hz) : "--";
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
<div className="lg:col-span-1">
|
||||
<AudioMeter
|
||||
rmsDb={latest?.rms_db ?? null}
|
||||
peakHoldDb3s={peakHoldDb3s}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-2 grid gap-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-2 flex flex-row items-center justify-between gap-3">
|
||||
<CardTitle className="text-base">Live status</CardTitle>
|
||||
<WsStatusBadge status={status} />
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">Last update</div>
|
||||
<div className="text-lg font-medium tabular-nums">
|
||||
{lastMessageAt ? formatTimeHHMMSS(lastMessageAt) : "—"}
|
||||
<span
|
||||
className={`ml-2 text-xs ${stale ? "text-rose-600" : "text-emerald-600"}`}
|
||||
>
|
||||
{stale ? "stale" : "ok"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">Frequency</div>
|
||||
<div className="text-lg font-medium tabular-nums">
|
||||
{latest ? `${latest.freq_hz.toFixed(0)} Hz` : "—"}{" "}
|
||||
<span className="text-muted-foreground">({note})</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">RMS</div>
|
||||
<div className="text-lg font-medium tabular-nums">
|
||||
{latest ? `${latest.rms_db.toFixed(1)} dB` : "—"}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<FrequencyHistoryChart history={history60s} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
96
services/frontend/src/widgets/audioLive/ui/AudioMeter.tsx
Normal file
96
services/frontend/src/widgets/audioLive/ui/AudioMeter.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../../shared/ui/card";
|
||||
|
||||
type Props = {
|
||||
rmsDb: number | null;
|
||||
peakHoldDb3s: number | null;
|
||||
};
|
||||
|
||||
function clamp(v: number, min: number, max: number) {
|
||||
return Math.max(min, Math.min(max, v));
|
||||
}
|
||||
|
||||
function colorByDb(db: number): string {
|
||||
// default thresholds from spec:
|
||||
// green: < -20, yellow: < -10, red: >= -10
|
||||
if (db < -20) return "bg-emerald-500";
|
||||
if (db < -10) return "bg-amber-500";
|
||||
return "bg-rose-500";
|
||||
}
|
||||
|
||||
export function AudioMeter({ rmsDb, peakHoldDb3s }: Props) {
|
||||
const minDb = -50;
|
||||
const maxDb = 0;
|
||||
|
||||
const fillPct = useMemo(() => {
|
||||
if (rmsDb === null) return 0;
|
||||
const v = clamp(rmsDb, minDb, maxDb);
|
||||
return ((v - minDb) / (maxDb - minDb)) * 100;
|
||||
}, [rmsDb]);
|
||||
|
||||
const peakTopPct = useMemo(() => {
|
||||
if (peakHoldDb3s === null) return null;
|
||||
const v = clamp(peakHoldDb3s, minDb, maxDb);
|
||||
const pct = ((v - minDb) / (maxDb - minDb)) * 100;
|
||||
return 100 - pct; // from top
|
||||
}, [peakHoldDb3s]);
|
||||
|
||||
const barColor = rmsDb === null ? "bg-slate-300" : colorByDb(rmsDb);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">Audio meter</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex items-center gap-4">
|
||||
{/* Scale + meter */}
|
||||
<div className="flex items-stretch gap-3">
|
||||
{/* ticks */}
|
||||
<div className="flex flex-col justify-between text-xs text-muted-foreground h-56 select-none">
|
||||
<div>0 dB</div>
|
||||
<div>-10</div>
|
||||
<div>-20</div>
|
||||
<div>-30</div>
|
||||
<div>-40</div>
|
||||
<div>-50</div>
|
||||
</div>
|
||||
|
||||
<div className="relative h-56 w-10 rounded-md bg-slate-200 overflow-hidden">
|
||||
<div
|
||||
className={`absolute bottom-0 left-0 right-0 ${barColor} transition-[height] duration-100`}
|
||||
style={{ height: `${fillPct}%` }}
|
||||
/>
|
||||
{peakTopPct !== null && (
|
||||
<div
|
||||
className="absolute left-0 right-0 h-[2px] bg-slate-900/80"
|
||||
style={{ top: `${peakTopPct}%` }}
|
||||
title={`Peak hold (3s): ${peakHoldDb3s?.toFixed(1)} dB`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Values */}
|
||||
<div className="min-w-40">
|
||||
<div className="text-sm text-muted-foreground">Current</div>
|
||||
<div className="text-2xl font-semibold tabular-nums">
|
||||
{rmsDb === null ? "—" : `${rmsDb.toFixed(1)} dB`}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 text-sm text-muted-foreground">
|
||||
Peak hold (3s)
|
||||
</div>
|
||||
<div className="text-lg font-medium tabular-nums">
|
||||
{peakHoldDb3s === null ? "—" : `${peakHoldDb3s.toFixed(1)} dB`}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
Line,
|
||||
LineChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../../shared/ui/card";
|
||||
import type { AudioSample } from "../../../entities/audioSample/model/types";
|
||||
import { formatTimeHHMMSS } from "../../../shared/lib/time";
|
||||
|
||||
type Props = {
|
||||
history: AudioSample[];
|
||||
};
|
||||
|
||||
type ChartPoint = {
|
||||
timeMs: number;
|
||||
freq_hz: number;
|
||||
};
|
||||
|
||||
export function FrequencyHistoryChart({ history }: Props) {
|
||||
const data: ChartPoint[] = history.map((s) => ({
|
||||
timeMs: s.timeMs,
|
||||
freq_hz: s.freq_hz,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">Frequency (last 60s)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-56 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart
|
||||
data={data}
|
||||
margin={{ top: 10, right: 12, left: 0, bottom: 0 }}
|
||||
>
|
||||
<XAxis
|
||||
dataKey="timeMs"
|
||||
type="number"
|
||||
domain={["dataMin", "dataMax"]}
|
||||
tickFormatter={(v) => formatTimeHHMMSS(Number(v))}
|
||||
tick={{ fontSize: 12 }}
|
||||
/>
|
||||
<YAxis domain={[20, 8000]} tick={{ fontSize: 12 }} width={44} />
|
||||
<Tooltip
|
||||
labelFormatter={(v) => formatTimeHHMMSS(Number(v))}
|
||||
formatter={(v) => [`${Number(v).toFixed(0)} Hz`, "freq"]}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="freq_hz"
|
||||
stroke="hsl(var(--primary))"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user