Compare commits
10 Commits
262c42c1b3
...
804527133c
| Author | SHA1 | Date | |
|---|---|---|---|
| 804527133c | |||
| 5e0f22e39e | |||
| 799a11b86d | |||
| f8edaa0aaf | |||
| 734c65253d | |||
| bcc94b40fe | |||
| c560b9be76 | |||
| e6f361def4 | |||
| 7334855ba2 | |||
| 707a474ef3 |
@@ -36,6 +36,10 @@ services:
|
|||||||
DB_NAME: ${DB_NAME:-audio_analyzer}
|
DB_NAME: ${DB_NAME:-audio_analyzer}
|
||||||
DB_USER: ${DB_USER:-postgres}
|
DB_USER: ${DB_USER:-postgres}
|
||||||
DB_PASSWORD: ${DB_PASSWORD:-postgres}
|
DB_PASSWORD: ${DB_PASSWORD:-postgres}
|
||||||
|
WS_HOST: 0.0.0.0
|
||||||
|
WS_PORT: 8000
|
||||||
|
ports:
|
||||||
|
- "8001:8000"
|
||||||
devices:
|
devices:
|
||||||
- "${SERIAL_PORT:-/dev/ttyACM0}:${SERIAL_PORT:-/dev/ttyACM0}"
|
- "${SERIAL_PORT:-/dev/ttyACM0}:${SERIAL_PORT:-/dev/ttyACM0}"
|
||||||
networks:
|
networks:
|
||||||
@@ -74,6 +78,26 @@ services:
|
|||||||
retries: 5
|
retries: 5
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./services/frontend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: audio_frontend_dev
|
||||||
|
ports:
|
||||||
|
- "3000:5173"
|
||||||
|
environment:
|
||||||
|
VITE_API_URL: http://localhost:8000
|
||||||
|
VITE_WS_URL: ws://localhost:8001/ws/live
|
||||||
|
# VITE_API_URL: http://api:8000
|
||||||
|
# VITE_WS_URL: ws://api:8001
|
||||||
|
volumes:
|
||||||
|
- ./services/frontend:/app
|
||||||
|
- /app/node_modules
|
||||||
|
networks:
|
||||||
|
- audio_network
|
||||||
|
stdin_open: true
|
||||||
|
tty: true
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
timescale_data:
|
timescale_data:
|
||||||
|
|||||||
@@ -3,24 +3,26 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from datetime import timezone
|
from datetime import timezone
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.db.session import SessionLocal
|
from app.db.session import SessionLocal
|
||||||
from app.repositories.audio_repository import AudioRepository
|
from app.repositories.audio_repository import AudioRepository
|
||||||
from app.ws.manager import manager
|
from app.ws.router import manager # используем тот же manager, что и в ws/router.py
|
||||||
|
|
||||||
|
|
||||||
def _iso_z(dt) -> str:
|
def _iso_z(dt) -> str:
|
||||||
# dt ожидается timezone-aware
|
|
||||||
return dt.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
return dt.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||||
|
|
||||||
|
|
||||||
async def audio_live_broadcaster(poll_interval_sec: float = 0.2) -> None:
|
async def audio_live_broadcaster(poll_interval_sec: float = 0.05) -> None:
|
||||||
|
"""
|
||||||
|
Poll latest row and broadcast only when a NEW row appears.
|
||||||
|
Throttling per client is handled by manager.broadcast_json().
|
||||||
|
"""
|
||||||
last_time = None
|
last_time = None
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
async with SessionLocal() as db: # AsyncSession
|
async with SessionLocal() as db:
|
||||||
repo = AudioRepository(db)
|
repo = AudioRepository(db)
|
||||||
rows = await repo.latest(1)
|
rows = await repo.latest(1)
|
||||||
if rows:
|
if rows:
|
||||||
@@ -35,7 +37,7 @@ async def audio_live_broadcaster(poll_interval_sec: float = 0.2) -> None:
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
# чтобы WS не умирал из-за временных проблем с БД
|
# не даём таске умереть при временных проблемах БД
|
||||||
pass
|
pass
|
||||||
|
|
||||||
await asyncio.sleep(poll_interval_sec)
|
await asyncio.sleep(poll_interval_sec)
|
||||||
|
|||||||
@@ -1,39 +1,65 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import WebSocket
|
from fastapi import WebSocket
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class ClientConn:
|
||||||
|
ws: WebSocket
|
||||||
|
hz: int
|
||||||
|
min_interval: float
|
||||||
|
last_sent_monotonic: float
|
||||||
|
|
||||||
|
|
||||||
class ConnectionManager:
|
class ConnectionManager:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._connections: set[WebSocket] = set()
|
self._conns: dict[WebSocket, ClientConn] = {}
|
||||||
self._lock = asyncio.Lock()
|
self._lock = asyncio.Lock()
|
||||||
|
|
||||||
async def connect(self, ws: WebSocket) -> None:
|
async def connect(self, ws: WebSocket, hz: int) -> None:
|
||||||
await ws.accept()
|
await ws.accept()
|
||||||
|
now = time.monotonic()
|
||||||
|
client = ClientConn(
|
||||||
|
ws=ws, hz=hz, min_interval=1.0 / hz, last_sent_monotonic=0.0
|
||||||
|
)
|
||||||
|
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
self._connections.add(ws)
|
self._conns[ws] = client
|
||||||
|
|
||||||
|
# Небольшой лог (можно заменить на structlog/loguru)
|
||||||
|
print(f"[ws] connected client={id(ws)} hz={hz} at={now:.3f}")
|
||||||
|
|
||||||
async def disconnect(self, ws: WebSocket) -> None:
|
async def disconnect(self, ws: WebSocket) -> None:
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
self._connections.discard(ws)
|
existed = ws in self._conns
|
||||||
|
self._conns.pop(ws, None)
|
||||||
|
if existed:
|
||||||
|
print(f"[ws] disconnected client={id(ws)}")
|
||||||
|
|
||||||
async def broadcast_json(self, payload: dict[str, Any]) -> None:
|
async def broadcast_json(self, payload: dict[str, Any]) -> None:
|
||||||
|
now = time.monotonic()
|
||||||
|
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
conns = list(self._connections)
|
clients = list(self._conns.values())
|
||||||
|
|
||||||
to_remove: list[WebSocket] = []
|
to_remove: list[WebSocket] = []
|
||||||
for ws in conns:
|
for c in clients:
|
||||||
|
# throttling per connection
|
||||||
|
if c.last_sent_monotonic and (now - c.last_sent_monotonic) < c.min_interval:
|
||||||
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await ws.send_json(payload)
|
await c.ws.send_json(payload)
|
||||||
|
c.last_sent_monotonic = now
|
||||||
except Exception:
|
except Exception:
|
||||||
to_remove.append(ws)
|
to_remove.append(c.ws)
|
||||||
|
|
||||||
if to_remove:
|
if to_remove:
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
for ws in to_remove:
|
for ws in to_remove:
|
||||||
self._connections.discard(ws)
|
self._conns.pop(ws, None)
|
||||||
|
|
||||||
|
|
||||||
manager = ConnectionManager()
|
|
||||||
|
|||||||
@@ -1,19 +1,52 @@
|
|||||||
from fastapi import APIRouter, WebSocket
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, WebSocket, status
|
||||||
|
from fastapi.exceptions import WebSocketException
|
||||||
from starlette.websockets import WebSocketDisconnect
|
from starlette.websockets import WebSocketDisconnect
|
||||||
|
|
||||||
from app.ws.manager import manager
|
from app.ws.manager import ConnectionManager
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
manager = ConnectionManager()
|
||||||
|
|
||||||
|
DEFAULT_HZ = 10
|
||||||
|
MIN_HZ = 1
|
||||||
|
MAX_HZ = 60
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_hz(ws: WebSocket) -> int:
|
||||||
|
raw = ws.query_params.get("hz")
|
||||||
|
if raw is None:
|
||||||
|
return DEFAULT_HZ
|
||||||
|
try:
|
||||||
|
hz = int(raw)
|
||||||
|
except ValueError:
|
||||||
|
raise WebSocketException(
|
||||||
|
code=status.WS_1008_POLICY_VIOLATION, reason="Invalid 'hz' (int expected)"
|
||||||
|
)
|
||||||
|
if hz < MIN_HZ or hz > MAX_HZ:
|
||||||
|
raise WebSocketException(
|
||||||
|
code=status.WS_1008_POLICY_VIOLATION,
|
||||||
|
reason=f"Invalid 'hz' (allowed {MIN_HZ}..{MAX_HZ})",
|
||||||
|
)
|
||||||
|
return hz
|
||||||
|
|
||||||
|
|
||||||
@router.websocket("/ws/live")
|
@router.websocket("/ws/live")
|
||||||
async def ws_live(ws: WebSocket) -> None:
|
async def ws_live(ws: WebSocket) -> None:
|
||||||
await manager.connect(ws)
|
hz = _parse_hz(ws)
|
||||||
|
|
||||||
|
await manager.connect(ws, hz=hz)
|
||||||
try:
|
try:
|
||||||
# Держим соединение
|
# Не обязательно принимать сообщения от клиента
|
||||||
|
# Но чтобы корректно ловить disconnect в некоторых клиентах - держим receive loop
|
||||||
while True:
|
while True:
|
||||||
await ws.receive_text()
|
await ws.receive_text()
|
||||||
except WebSocketDisconnect:
|
except WebSocketDisconnect:
|
||||||
await manager.disconnect(ws)
|
await manager.disconnect(ws)
|
||||||
|
except WebSocketException:
|
||||||
|
# если прилетит exception после accept — корректно удалим
|
||||||
|
await manager.disconnect(ws)
|
||||||
|
raise
|
||||||
except Exception:
|
except Exception:
|
||||||
await manager.disconnect(ws)
|
await manager.disconnect(ws)
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
FR-2.3: Database Writer with Batch Processing
|
FR-2.3: Database Writer with Batch Processing
|
||||||
Buffers audio metrics and writes in batches (50 records or 5 seconds)
|
Buffers audio metrics and writes in batches
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import List, Optional
|
from typing import List, Optional, final
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
import asyncpg
|
import asyncpg
|
||||||
@@ -32,8 +32,8 @@ class DatabaseWriter:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
BATCH_SIZE = 50
|
BATCH_SIZE = 50
|
||||||
BATCH_TIMEOUT = 5.0 # seconds
|
BATCH_TIMEOUT: float = 5.0 # seconds
|
||||||
SILENCE_THRESHOLD_DB = -30.0 # dB below = silence
|
SILENCE_THRESHOLD_DB: float = -30.0 # dB below = silence
|
||||||
|
|
||||||
def __init__(self, db_url: str):
|
def __init__(self, db_url: str):
|
||||||
self.db_url = db_url
|
self.db_url = db_url
|
||||||
@@ -124,7 +124,6 @@ class DatabaseWriter:
|
|||||||
f"silence={is_silence} (buffer={len(self.buffer)})"
|
f"silence={is_silence} (buffer={len(self.buffer)})"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Flush if batch size reached
|
|
||||||
if len(self.buffer) >= self.BATCH_SIZE:
|
if len(self.buffer) >= self.BATCH_SIZE:
|
||||||
await self.flush()
|
await self.flush()
|
||||||
|
|
||||||
|
|||||||
@@ -1,33 +1,46 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
FR-2: Audio Data Collector Service
|
FR-2: Audio Data Collector Service with WebSocket Live Streaming
|
||||||
Reads audio metrics from STM32, validates, and writes to TimescaleDB
|
Reads audio metrics from STM32, validates, writes to DB, and streams via WebSocket.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import signal
|
import signal
|
||||||
import sys
|
import sys
|
||||||
|
from contextlib import suppress
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Callable, Optional
|
||||||
|
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
from serial_reader import SerialReader
|
|
||||||
from audio_validator import AudioValidator
|
from audio_validator import AudioValidator
|
||||||
from db_writer import DatabaseWriter
|
from db_writer import DatabaseWriter
|
||||||
from protocol_parser import AudioMetrics
|
from protocol_parser import AudioMetrics
|
||||||
|
from serial_reader import SerialReader
|
||||||
|
from ws_app import app as ws_app
|
||||||
|
from ws_app import manager
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=logging.INFO,
|
level=logging.INFO,
|
||||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||||
datefmt="%Y-%m-%d %H:%M:%S",
|
datefmt="%Y-%m-%d %H:%M:%S",
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class CollectorService:
|
def _iso_z(dt: datetime) -> str:
|
||||||
"""Main collector service orchestrating serial reading and database writing"""
|
"""Format datetime as ISO8601 with 'Z' suffix (UTC)."""
|
||||||
|
return dt.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||||
|
|
||||||
def __init__(self, serial_port: str, db_url: str, baudrate: int = 115200):
|
|
||||||
|
class CollectorService:
|
||||||
|
"""Main collector service: serial → validate → DB + WebSocket."""
|
||||||
|
|
||||||
|
def __init__(self, serial_port: str, db_url: str, baudrate: int = 115200) -> None:
|
||||||
self.serial_reader = SerialReader(
|
self.serial_reader = SerialReader(
|
||||||
port=serial_port, baudrate=baudrate, on_packet=self._handle_packet
|
port=serial_port, baudrate=baudrate, on_packet=self._handle_packet
|
||||||
)
|
)
|
||||||
@@ -36,23 +49,94 @@ class CollectorService:
|
|||||||
|
|
||||||
self._shutdown_event = asyncio.Event()
|
self._shutdown_event = asyncio.Event()
|
||||||
|
|
||||||
async def _handle_packet(self, packet: AudioMetrics):
|
# WebSocket broadcast queue (bounded to prevent memory issues)
|
||||||
|
self._ws_queue: asyncio.Queue[dict] = asyncio.Queue(maxsize=200)
|
||||||
|
self._ws_broadcast_task: Optional[asyncio.Task[None]] = None
|
||||||
|
|
||||||
|
# Uvicorn server for WebSocket endpoint
|
||||||
|
self._uvicorn_server: Optional[uvicorn.Server] = None
|
||||||
|
self._ws_server_task: Optional[asyncio.Task[None]] = None
|
||||||
|
|
||||||
|
def shutdown(self) -> None:
|
||||||
|
"""Trigger graceful shutdown (called from signal handler)."""
|
||||||
|
logger.info("Shutdown requested")
|
||||||
|
self._shutdown_event.set()
|
||||||
|
|
||||||
|
async def _ws_broadcast_loop(self) -> None:
|
||||||
|
"""Background task: consume queue and broadcast to WebSocket clients."""
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
msg = await self._ws_queue.get()
|
||||||
|
try:
|
||||||
|
await manager.broadcast_json(msg)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("WS broadcast error: %s", e)
|
||||||
|
finally:
|
||||||
|
self._ws_queue.task_done()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
logger.debug("WS broadcast loop cancelled")
|
||||||
|
|
||||||
|
async def _start_ws_server(self) -> None:
|
||||||
|
"""Start uvicorn server for WebSocket endpoint."""
|
||||||
|
host = os.getenv("WS_HOST", "0.0.0.0")
|
||||||
|
port = int(os.getenv("WS_PORT", "8001"))
|
||||||
|
|
||||||
|
config = uvicorn.Config(
|
||||||
|
ws_app,
|
||||||
|
host=host,
|
||||||
|
port=port,
|
||||||
|
log_level="warning",
|
||||||
|
loop="asyncio",
|
||||||
|
access_log=False,
|
||||||
|
)
|
||||||
|
self._uvicorn_server = uvicorn.Server(config)
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.info("Starting WebSocket server on ws://%s:%d/ws/live", host, port)
|
||||||
|
await self._uvicorn_server.serve()
|
||||||
|
except SystemExit as e:
|
||||||
|
logger.error("WS server failed (port %d already in use?): %s", port, e)
|
||||||
|
self.shutdown()
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("WS server crashed: %s", e)
|
||||||
|
self.shutdown()
|
||||||
|
|
||||||
|
async def _handle_packet(self, packet: AudioMetrics) -> None:
|
||||||
"""
|
"""
|
||||||
Process received audio packet: validate and write to database.
|
Process received audio packet: validate, write to DB, push to WebSocket.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
packet: Parsed audio metrics packet
|
packet: Parsed audio metrics from STM32
|
||||||
"""
|
"""
|
||||||
# Validate packet
|
# Validate packet
|
||||||
validation = self.validator.validate_packet(packet.rms_db, packet.freq_hz)
|
validation = self.validator.validate_packet(packet.rms_db, packet.freq_hz)
|
||||||
|
|
||||||
if not validation.valid:
|
if not validation.valid:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"Invalid packet: {validation.error} "
|
"Invalid packet: %s (rms=%.1fdB freq=%dHz)",
|
||||||
f"(rms={packet.rms_db:.1f}dB freq={packet.freq_hz}Hz)"
|
validation.error,
|
||||||
|
packet.rms_db,
|
||||||
|
packet.freq_hz,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Push to WebSocket queue (non-blocking)
|
||||||
|
msg = {
|
||||||
|
"time": _iso_z(datetime.now(timezone.utc)),
|
||||||
|
"rms_db": float(packet.rms_db),
|
||||||
|
"freq_hz": int(packet.freq_hz),
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._ws_queue.put_nowait(msg)
|
||||||
|
except asyncio.QueueFull:
|
||||||
|
# Drop oldest message if queue full
|
||||||
|
try:
|
||||||
|
_ = self._ws_queue.get_nowait()
|
||||||
|
self._ws_queue.task_done()
|
||||||
|
except asyncio.QueueEmpty:
|
||||||
|
pass
|
||||||
|
self._ws_queue.put_nowait(msg)
|
||||||
|
|
||||||
# Write to database
|
# Write to database
|
||||||
try:
|
try:
|
||||||
await self.db_writer.add_record(
|
await self.db_writer.add_record(
|
||||||
@@ -61,10 +145,10 @@ class CollectorService:
|
|||||||
freq_hz=packet.freq_hz,
|
freq_hz=packet.freq_hz,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to add record to database: {e}")
|
logger.error("Failed to write to database: %s", e)
|
||||||
|
|
||||||
async def start(self):
|
async def start(self) -> None:
|
||||||
"""Start collector service"""
|
"""Start collector service: DB, WS, serial reader."""
|
||||||
logger.info("Starting Audio Data Collector Service")
|
logger.info("Starting Audio Data Collector Service")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -72,6 +156,13 @@ class CollectorService:
|
|||||||
await self.db_writer.connect()
|
await self.db_writer.connect()
|
||||||
await self.db_writer.start_auto_flush()
|
await self.db_writer.start_auto_flush()
|
||||||
|
|
||||||
|
# Start WebSocket server and broadcaster
|
||||||
|
self._ws_broadcast_task = asyncio.create_task(self._ws_broadcast_loop())
|
||||||
|
self._ws_server_task = asyncio.create_task(self._start_ws_server())
|
||||||
|
|
||||||
|
# Give uvicorn a moment to bind (avoid race on port check)
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
|
||||||
# Connect to serial port
|
# Connect to serial port
|
||||||
await self.serial_reader.connect()
|
await self.serial_reader.connect()
|
||||||
await self.serial_reader.start_reading()
|
await self.serial_reader.start_reading()
|
||||||
@@ -82,62 +173,75 @@ class CollectorService:
|
|||||||
await self._shutdown_event.wait()
|
await self._shutdown_event.wait()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Service startup failed: {e}")
|
logger.error("Service startup failed: %s", e)
|
||||||
raise
|
raise
|
||||||
finally:
|
finally:
|
||||||
await self.stop()
|
await self.stop()
|
||||||
|
|
||||||
async def stop(self):
|
async def stop(self) -> None:
|
||||||
"""Stop collector service gracefully"""
|
"""Stop collector service gracefully."""
|
||||||
logger.info("Stopping Audio Data Collector Service")
|
logger.info("Stopping Audio Data Collector Service")
|
||||||
|
|
||||||
# Disconnect serial reader
|
# Stop serial reader
|
||||||
await self.serial_reader.disconnect()
|
await self.serial_reader.disconnect()
|
||||||
|
|
||||||
|
# Stop WebSocket server
|
||||||
|
if self._uvicorn_server is not None:
|
||||||
|
self._uvicorn_server.should_exit = True
|
||||||
|
|
||||||
|
if self._ws_server_task is not None:
|
||||||
|
self._ws_server_task.cancel()
|
||||||
|
with suppress(asyncio.CancelledError, SystemExit, Exception):
|
||||||
|
await self._ws_server_task
|
||||||
|
|
||||||
|
# Stop WebSocket broadcaster
|
||||||
|
if self._ws_broadcast_task is not None:
|
||||||
|
self._ws_broadcast_task.cancel()
|
||||||
|
with suppress(asyncio.CancelledError):
|
||||||
|
await self._ws_broadcast_task
|
||||||
|
|
||||||
# Close database writer (flushes remaining data)
|
# Close database writer (flushes remaining data)
|
||||||
await self.db_writer.close()
|
await self.db_writer.close()
|
||||||
|
|
||||||
logger.info("Service stopped")
|
logger.info("Service stopped")
|
||||||
|
|
||||||
def shutdown(self):
|
|
||||||
"""Trigger graceful shutdown"""
|
|
||||||
logger.info("Shutdown requested")
|
|
||||||
self._shutdown_event.set()
|
|
||||||
|
|
||||||
|
async def _amain() -> None:
|
||||||
def main():
|
"""Async main entry point."""
|
||||||
"""Main entry point"""
|
|
||||||
# Read configuration from environment
|
# Read configuration from environment
|
||||||
SERIAL_PORT = os.getenv("SERIAL_PORT", "/dev/ttyACM0")
|
serial_port = os.getenv("SERIAL_PORT", "/dev/ttyACM0")
|
||||||
BAUDRATE = int(os.getenv("BAUDRATE", "115200"))
|
baudrate = int(os.getenv("BAUDRATE", "115200"))
|
||||||
DB_HOST = os.getenv("DB_HOST", "localhost")
|
|
||||||
DB_PORT = os.getenv("DB_PORT", "5432")
|
|
||||||
DB_NAME = os.getenv("DB_NAME", "audio_analyzer")
|
|
||||||
DB_USER = os.getenv("DB_USER", "postgres")
|
|
||||||
DB_PASSWORD = os.getenv("DB_PASSWORD", "postgres")
|
|
||||||
|
|
||||||
db_url = f"postgresql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_NAME}"
|
db_host = os.getenv("DB_HOST", "localhost")
|
||||||
|
db_port = os.getenv("DB_PORT", "5432")
|
||||||
|
db_name = os.getenv("DB_NAME", "audio_analyzer")
|
||||||
|
db_user = os.getenv("DB_USER", "postgres")
|
||||||
|
db_password = os.getenv("DB_PASSWORD", "postgres")
|
||||||
|
db_url = f"postgresql://{db_user}:{db_password}@{db_host}:{db_port}/{db_name}"
|
||||||
|
|
||||||
# Create service
|
# Create service
|
||||||
service = CollectorService(
|
service = CollectorService(
|
||||||
serial_port=SERIAL_PORT, db_url=db_url, baudrate=BAUDRATE
|
serial_port=serial_port, db_url=db_url, baudrate=baudrate
|
||||||
)
|
)
|
||||||
|
|
||||||
# Setup signal handlers for graceful shutdown
|
# Setup signal handlers for graceful shutdown
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_running_loop()
|
||||||
|
shutdown_callback: Callable[[], None] = service.shutdown
|
||||||
for sig in (signal.SIGTERM, signal.SIGINT):
|
for sig in (signal.SIGTERM, signal.SIGINT):
|
||||||
loop.add_signal_handler(sig, service.shutdown)
|
loop.add_signal_handler(sig, shutdown_callback)
|
||||||
|
|
||||||
|
await service.start()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
"""Main entry point."""
|
||||||
try:
|
try:
|
||||||
# Run service
|
asyncio.run(_amain())
|
||||||
loop.run_until_complete(service.start())
|
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
logger.info("Interrupted by user")
|
logger.info("Interrupted by user")
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.error(f"Service error: {e}")
|
logger.exception("Service error")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
finally:
|
|
||||||
loop.close()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -3,3 +3,6 @@ asyncpg
|
|||||||
numpy
|
numpy
|
||||||
pytest
|
pytest
|
||||||
pytest-asyncio
|
pytest-asyncio
|
||||||
|
fastapi
|
||||||
|
uvicorn
|
||||||
|
websockets
|
||||||
|
|||||||
49
services/collector/ws_app.py
Normal file
49
services/collector/ws_app.py
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""FastAPI WebSocket endpoint with rate limiting support."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Query
|
||||||
|
|
||||||
|
from ws_manager import ConnectionManager
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
app = FastAPI(title="Audio Analyzer WebSocket")
|
||||||
|
manager = ConnectionManager()
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health() -> dict[str, str]:
|
||||||
|
"""Health check endpoint."""
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.websocket("/ws/live")
|
||||||
|
async def ws_live(
|
||||||
|
websocket: WebSocket,
|
||||||
|
hz: float = Query(default=10.0, ge=0.1, le=100.0, description="Update rate in Hz"),
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
WebSocket endpoint for real-time audio data streaming.
|
||||||
|
|
||||||
|
Query parameters:
|
||||||
|
hz: Update rate in Hz (0.1 - 100.0, default: 10.0)
|
||||||
|
Examples:
|
||||||
|
- ws://localhost:8001/ws/live?hz=10 → 10 messages/sec
|
||||||
|
- ws://localhost:8001/ws/live?hz=1 → 1 message/sec
|
||||||
|
- ws://localhost:8001/ws/live?hz=30 → 30 messages/sec
|
||||||
|
"""
|
||||||
|
await manager.connect(websocket, rate_hz=hz)
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
# Keep connection alive; client can send pings
|
||||||
|
await websocket.receive_text()
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("WS connection error: %s", e)
|
||||||
|
finally:
|
||||||
|
await manager.disconnect(websocket)
|
||||||
175
services/collector/ws_manager.py
Normal file
175
services/collector/ws_manager.py
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""WebSocket connection manager with per-client rate limiting and median aggregation."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import statistics
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from starlette.websockets import WebSocket, WebSocketState
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ThrottledClient:
|
||||||
|
"""WebSocket client wrapper with rate limiting and median aggregation."""
|
||||||
|
|
||||||
|
def __init__(self, ws: WebSocket, rate_hz: float) -> None:
|
||||||
|
self.ws = ws
|
||||||
|
self.rate_hz = rate_hz
|
||||||
|
self.interval = 1.0 / rate_hz if rate_hz > 0 else 0.0
|
||||||
|
self._last_send_time = 0.0
|
||||||
|
|
||||||
|
# Accumulator for aggregation (list of samples within current window)
|
||||||
|
self._buffer: list[dict[str, Any]] = []
|
||||||
|
self._buffer_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
self._task: asyncio.Task[None] | None = None
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
"""Start background sender task."""
|
||||||
|
self._task = asyncio.create_task(self._send_loop())
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
"""Stop background sender task."""
|
||||||
|
if self._task and not self._task.done():
|
||||||
|
self._task.cancel()
|
||||||
|
try:
|
||||||
|
await self._task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def enqueue(self, message: dict[str, Any]) -> None:
|
||||||
|
"""Add message to aggregation buffer."""
|
||||||
|
async with self._buffer_lock:
|
||||||
|
self._buffer.append(message)
|
||||||
|
# Limit buffer size (prevent memory issues if rate is very low)
|
||||||
|
if len(self._buffer) > 1000:
|
||||||
|
self._buffer.pop(0)
|
||||||
|
|
||||||
|
async def _send_loop(self) -> None:
|
||||||
|
"""Background task: aggregate and send messages respecting rate limit."""
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
# Wait for next send window
|
||||||
|
if self.interval > 0:
|
||||||
|
now = asyncio.get_event_loop().time()
|
||||||
|
elapsed = now - self._last_send_time
|
||||||
|
if elapsed < self.interval:
|
||||||
|
await asyncio.sleep(self.interval - elapsed)
|
||||||
|
self._last_send_time = asyncio.get_event_loop().time()
|
||||||
|
else:
|
||||||
|
await asyncio.sleep(0.01) # Minimal delay
|
||||||
|
|
||||||
|
# Get accumulated samples
|
||||||
|
async with self._buffer_lock:
|
||||||
|
if not self._buffer:
|
||||||
|
continue
|
||||||
|
|
||||||
|
samples = self._buffer[:]
|
||||||
|
self._buffer.clear()
|
||||||
|
|
||||||
|
# Aggregate: compute median
|
||||||
|
aggregated = self._aggregate_median(samples)
|
||||||
|
|
||||||
|
# Send aggregated message
|
||||||
|
try:
|
||||||
|
if self.ws.client_state == WebSocketState.CONNECTED:
|
||||||
|
payload = json.dumps(
|
||||||
|
aggregated, ensure_ascii=False, separators=(",", ":")
|
||||||
|
)
|
||||||
|
await self.ws.send_text(payload)
|
||||||
|
else:
|
||||||
|
break # Connection closed
|
||||||
|
except Exception:
|
||||||
|
break # Send failed
|
||||||
|
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _aggregate_median(samples: list[dict[str, Any]]) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Aggregate samples by computing median of rms_db and freq_hz.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
samples: List of raw messages
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Aggregated message with median values
|
||||||
|
"""
|
||||||
|
if not samples:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
if len(samples) == 1:
|
||||||
|
return samples[0]
|
||||||
|
|
||||||
|
# Extract numeric values
|
||||||
|
rms_values = [s["rms_db"] for s in samples if "rms_db" in s]
|
||||||
|
freq_values = [s["freq_hz"] for s in samples if "freq_hz" in s]
|
||||||
|
|
||||||
|
# Compute medians
|
||||||
|
rms_median = statistics.median(rms_values) if rms_values else 0.0
|
||||||
|
freq_median = statistics.median(freq_values) if freq_values else 0
|
||||||
|
|
||||||
|
# Use timestamp from last sample (most recent)
|
||||||
|
time_value = samples[-1].get("time", "")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"time": time_value,
|
||||||
|
"rms_db": round(rms_median, 1),
|
||||||
|
"freq_hz": int(round(freq_median)),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectionManager:
|
||||||
|
"""Manages WebSocket connections with per-client rate limiting."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._clients: dict[WebSocket, ThrottledClient] = {}
|
||||||
|
self._lock = asyncio.Lock()
|
||||||
|
|
||||||
|
async def connect(self, ws: WebSocket, rate_hz: float = 10.0) -> None:
|
||||||
|
"""Accept and register new WebSocket connection with rate limiting."""
|
||||||
|
await ws.accept()
|
||||||
|
|
||||||
|
client = ThrottledClient(ws, rate_hz)
|
||||||
|
await client.start()
|
||||||
|
|
||||||
|
async with self._lock:
|
||||||
|
self._clients[ws] = client
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"WS client connected (rate=%.1fHz, total=%d)", rate_hz, len(self._clients)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def disconnect(self, ws: WebSocket) -> None:
|
||||||
|
"""Remove WebSocket connection and stop its sender."""
|
||||||
|
async with self._lock:
|
||||||
|
client = self._clients.pop(ws, None)
|
||||||
|
|
||||||
|
if client:
|
||||||
|
await client.stop()
|
||||||
|
|
||||||
|
logger.info("WS client disconnected (total=%d)", len(self._clients))
|
||||||
|
|
||||||
|
async def broadcast_json(self, message: dict[str, Any]) -> None:
|
||||||
|
"""Broadcast JSON message to all connected clients (respecting per-client rates)."""
|
||||||
|
async with self._lock:
|
||||||
|
clients = list(self._clients.values())
|
||||||
|
|
||||||
|
dead: list[WebSocket] = []
|
||||||
|
for client in clients:
|
||||||
|
try:
|
||||||
|
await client.enqueue(message)
|
||||||
|
except Exception:
|
||||||
|
dead.append(client.ws)
|
||||||
|
|
||||||
|
if dead:
|
||||||
|
async with self._lock:
|
||||||
|
for ws in dead:
|
||||||
|
self._clients.pop(ws, None)
|
||||||
|
logger.debug("WS cleanup: removed %d dead clients", len(dead))
|
||||||
24
services/frontend/.gitignore
vendored
Normal file
24
services/frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
1
services/frontend/.gitignore_1
Symbolic link
1
services/frontend/.gitignore_1
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
/home/mikhail/repos/sound-analizer/services/api/.gitignore
|
||||||
13
services/frontend/Dockerfile
Normal file
13
services/frontend/Dockerfile
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# Node 20 + Vite dev server
|
||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
EXPOSE 5173
|
||||||
|
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "5173"]
|
||||||
|
|
||||||
73
services/frontend/README.md
Normal file
73
services/frontend/README.md
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# React + TypeScript + Vite
|
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||||
|
|
||||||
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||||
|
|
||||||
|
## React Compiler
|
||||||
|
|
||||||
|
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||||
|
|
||||||
|
## Expanding the ESLint configuration
|
||||||
|
|
||||||
|
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
// Other configs...
|
||||||
|
|
||||||
|
// Remove tseslint.configs.recommended and replace with this
|
||||||
|
tseslint.configs.recommendedTypeChecked,
|
||||||
|
// Alternatively, use this for stricter rules
|
||||||
|
tseslint.configs.strictTypeChecked,
|
||||||
|
// Optionally, add this for stylistic rules
|
||||||
|
tseslint.configs.stylisticTypeChecked,
|
||||||
|
|
||||||
|
// Other configs...
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
// other options...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// eslint.config.js
|
||||||
|
import reactX from 'eslint-plugin-react-x'
|
||||||
|
import reactDom from 'eslint-plugin-react-dom'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
// Other configs...
|
||||||
|
// Enable lint rules for React
|
||||||
|
reactX.configs['recommended-typescript'],
|
||||||
|
// Enable lint rules for React DOM
|
||||||
|
reactDom.configs.recommended,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
// other options...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
22
services/frontend/components.json
Normal file
22
services/frontend/components.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "new-york",
|
||||||
|
"rsc": false,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "tailwind.config.js",
|
||||||
|
"css": "src/index.css",
|
||||||
|
"baseColor": "slate",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide",
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"registries": {}
|
||||||
|
}
|
||||||
23
services/frontend/eslint.config.js
Normal file
23
services/frontend/eslint.config.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
tseslint.configs.recommended,
|
||||||
|
reactHooks.configs.flat.recommended,
|
||||||
|
reactRefresh.configs.vite,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
13
services/frontend/index.html
Normal file
13
services/frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>frontend</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
3877
services/frontend/package-lock.json
generated
Normal file
3877
services/frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
44
services/frontend/package.json
Normal file
44
services/frontend/package.json
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^0.562.0",
|
||||||
|
"react": "^19.2.0",
|
||||||
|
"react-dom": "^19.2.0",
|
||||||
|
"recharts": "^3.6.0",
|
||||||
|
"tailwind-merge": "^3.4.0",
|
||||||
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"zustand": "^5.0.9"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.39.1",
|
||||||
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
|
"@types/node": "^24.10.4",
|
||||||
|
"@types/react": "^19.2.5",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
|
"eslint": "^9.39.1",
|
||||||
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
|
"globals": "^16.5.0",
|
||||||
|
"tailwindcss": "^4.1.18",
|
||||||
|
"tw-animate-css": "^1.4.0",
|
||||||
|
"typescript": "~5.9.3",
|
||||||
|
"typescript-eslint": "^8.46.4",
|
||||||
|
"vite": "npm:rolldown-vite@7.2.5"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"vite": "npm:rolldown-vite@7.2.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
services/frontend/public/vite.svg
Normal file
1
services/frontend/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
9
services/frontend/src/app/App.tsx
Normal file
9
services/frontend/src/app/App.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { DashboardPage } from "../pages/dashboard/ui/DashboardPage";
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background text-foreground">
|
||||||
|
<DashboardPage />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
7
services/frontend/src/app/providers/withLiveStream.tsx
Normal file
7
services/frontend/src/app/providers/withLiveStream.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import type { PropsWithChildren } from "react";
|
||||||
|
|
||||||
|
export function WithLiveStream({ children }: PropsWithChildren) {
|
||||||
|
// placeholder for future providers
|
||||||
|
// theme, router, query-client, etc.
|
||||||
|
return children;
|
||||||
|
}
|
||||||
137
services/frontend/src/app/styles/index.css
Normal file
137
services/frontend/src/app/styles/index.css
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@plugin "tailwindcss-animate";
|
||||||
|
@import "tw-animate-css";
|
||||||
|
|
||||||
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
|
--radius-2xl: calc(var(--radius) + 8px);
|
||||||
|
--radius-3xl: calc(var(--radius) + 12px);
|
||||||
|
--radius-4xl: calc(var(--radius) + 16px);
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
--color-chart-1: var(--chart-1);
|
||||||
|
--color-chart-2: var(--chart-2);
|
||||||
|
--color-chart-3: var(--chart-3);
|
||||||
|
--color-chart-4: var(--chart-4);
|
||||||
|
--color-chart-5: var(--chart-5);
|
||||||
|
--color-sidebar: var(--sidebar);
|
||||||
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--background: oklch(1 0 0);
|
||||||
|
--foreground: oklch(0.129 0.042 264.695);
|
||||||
|
--card: oklch(1 0 0);
|
||||||
|
--card-foreground: oklch(0.129 0.042 264.695);
|
||||||
|
--popover: oklch(1 0 0);
|
||||||
|
--popover-foreground: oklch(0.129 0.042 264.695);
|
||||||
|
--primary: oklch(0.208 0.042 265.755);
|
||||||
|
--primary-foreground: oklch(0.984 0.003 247.858);
|
||||||
|
--secondary: oklch(0.968 0.007 247.896);
|
||||||
|
--secondary-foreground: oklch(0.208 0.042 265.755);
|
||||||
|
--muted: oklch(0.968 0.007 247.896);
|
||||||
|
--muted-foreground: oklch(0.554 0.046 257.417);
|
||||||
|
--accent: oklch(0.968 0.007 247.896);
|
||||||
|
--accent-foreground: oklch(0.208 0.042 265.755);
|
||||||
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
|
--border: oklch(0.929 0.013 255.508);
|
||||||
|
--input: oklch(0.929 0.013 255.508);
|
||||||
|
--ring: oklch(0.704 0.04 256.788);
|
||||||
|
--chart-1: oklch(0.646 0.222 41.116);
|
||||||
|
--chart-2: oklch(0.6 0.118 184.704);
|
||||||
|
--chart-3: oklch(0.398 0.07 227.392);
|
||||||
|
--chart-4: oklch(0.828 0.189 84.429);
|
||||||
|
--chart-5: oklch(0.769 0.188 70.08);
|
||||||
|
--sidebar: oklch(0.984 0.003 247.858);
|
||||||
|
--sidebar-foreground: oklch(0.129 0.042 264.695);
|
||||||
|
--sidebar-primary: oklch(0.208 0.042 265.755);
|
||||||
|
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
|
||||||
|
--sidebar-accent: oklch(0.968 0.007 247.896);
|
||||||
|
--sidebar-accent-foreground: oklch(0.208 0.042 265.755);
|
||||||
|
--sidebar-border: oklch(0.929 0.013 255.508);
|
||||||
|
--sidebar-ring: oklch(0.704 0.04 256.788);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: oklch(0.129 0.042 264.695);
|
||||||
|
--foreground: oklch(0.984 0.003 247.858);
|
||||||
|
--card: oklch(0.208 0.042 265.755);
|
||||||
|
--card-foreground: oklch(0.984 0.003 247.858);
|
||||||
|
--popover: oklch(0.208 0.042 265.755);
|
||||||
|
--popover-foreground: oklch(0.984 0.003 247.858);
|
||||||
|
--primary: oklch(0.929 0.013 255.508);
|
||||||
|
--primary-foreground: oklch(0.208 0.042 265.755);
|
||||||
|
--secondary: oklch(0.279 0.041 260.031);
|
||||||
|
--secondary-foreground: oklch(0.984 0.003 247.858);
|
||||||
|
--muted: oklch(0.279 0.041 260.031);
|
||||||
|
--muted-foreground: oklch(0.704 0.04 256.788);
|
||||||
|
--accent: oklch(0.279 0.041 260.031);
|
||||||
|
--accent-foreground: oklch(0.984 0.003 247.858);
|
||||||
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
|
--border: oklch(1 0 0 / 10%);
|
||||||
|
--input: oklch(1 0 0 / 15%);
|
||||||
|
--ring: oklch(0.551 0.027 264.364);
|
||||||
|
--chart-1: oklch(0.488 0.243 264.376);
|
||||||
|
--chart-2: oklch(0.696 0.17 162.48);
|
||||||
|
--chart-3: oklch(0.769 0.188 70.08);
|
||||||
|
--chart-4: oklch(0.627 0.265 303.9);
|
||||||
|
--chart-5: oklch(0.645 0.246 16.439);
|
||||||
|
--sidebar: oklch(0.208 0.042 265.755);
|
||||||
|
--sidebar-foreground: oklch(0.984 0.003 247.858);
|
||||||
|
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||||
|
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
|
||||||
|
--sidebar-accent: oklch(0.279 0.041 260.031);
|
||||||
|
--sidebar-accent-foreground: oklch(0.984 0.003 247.858);
|
||||||
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
|
--sidebar-ring: oklch(0.551 0.027 264.364);
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border outline-ring/50;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin-slow {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.animate-spin-slow {
|
||||||
|
animation: spin-slow 4s linear infinite;
|
||||||
|
}
|
||||||
1
services/frontend/src/assets/react.svg
Normal file
1
services/frontend/src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
31
services/frontend/src/entities/audioSample/lib/note.ts
Normal file
31
services/frontend/src/entities/audioSample/lib/note.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
const NOTE_NAMES = [
|
||||||
|
"C",
|
||||||
|
"C#",
|
||||||
|
"D",
|
||||||
|
"D#",
|
||||||
|
"E",
|
||||||
|
"F",
|
||||||
|
"F#",
|
||||||
|
"G",
|
||||||
|
"G#",
|
||||||
|
"A",
|
||||||
|
"A#",
|
||||||
|
"B",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type NoteName = `${(typeof NOTE_NAMES)[number]}${number}`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A4 = 440 Hz, MIDI 69.
|
||||||
|
* Returns nearest tempered note (e.g. 440 -> A4).
|
||||||
|
*/
|
||||||
|
export function freqToNote(freqHz: number): NoteName | "--" {
|
||||||
|
if (!Number.isFinite(freqHz) || freqHz <= 0) return "--";
|
||||||
|
|
||||||
|
const midi = 69 + 12 * Math.log2(freqHz / 440);
|
||||||
|
const midiRounded = Math.round(midi);
|
||||||
|
|
||||||
|
const name = NOTE_NAMES[((midiRounded % 12) + 12) % 12];
|
||||||
|
const octave = Math.floor(midiRounded / 12) - 1;
|
||||||
|
return `${name}${octave}` as NoteName;
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export type AudioSample = {
|
||||||
|
time: string; // ISO
|
||||||
|
timeMs: number;
|
||||||
|
rms_db: number; // expected [-50..0]
|
||||||
|
freq_hz: number; // expected [20..8000]
|
||||||
|
};
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import type { WsStatus } from "../model/types";
|
||||||
|
|
||||||
|
export type LiveWsClientHandlers = {
|
||||||
|
onStatus: (status: WsStatus) => void;
|
||||||
|
onMessage: (data: unknown) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class LiveWsClient {
|
||||||
|
private ws: WebSocket | null = null;
|
||||||
|
private closedByUser = false;
|
||||||
|
|
||||||
|
private retryAttempt = 0;
|
||||||
|
private retryTimer: number | null = null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly url: string,
|
||||||
|
private readonly handlers: LiveWsClientHandlers,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
connect(): void {
|
||||||
|
this.closedByUser = false;
|
||||||
|
this.open("connecting");
|
||||||
|
}
|
||||||
|
|
||||||
|
close(): void {
|
||||||
|
this.closedByUser = true;
|
||||||
|
this.clearRetry();
|
||||||
|
this.handlers.onStatus("disconnected");
|
||||||
|
this.ws?.close();
|
||||||
|
this.ws = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private open(status: WsStatus): void {
|
||||||
|
this.handlers.onStatus(status);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ws = new WebSocket(this.url);
|
||||||
|
this.ws = ws;
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
this.retryAttempt = 0;
|
||||||
|
this.handlers.onStatus("open");
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (ev) => {
|
||||||
|
this.handlers.onMessage(ev.data);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = () => {
|
||||||
|
// let onclose handle reconnect
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
this.ws = null;
|
||||||
|
if (this.closedByUser) return;
|
||||||
|
this.scheduleReconnect();
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
this.scheduleReconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private scheduleReconnect(): void {
|
||||||
|
this.handlers.onStatus("reconnecting");
|
||||||
|
|
||||||
|
// backoff: 0.5s, 1s, 2s, 4s ... max 10s (+ jitter)
|
||||||
|
const base = 500 * Math.pow(2, this.retryAttempt);
|
||||||
|
const capped = Math.min(base, 10_000);
|
||||||
|
const jitter = capped * (Math.random() * 0.2); // 0..20%
|
||||||
|
const delay = Math.round(capped + jitter);
|
||||||
|
|
||||||
|
this.retryAttempt += 1;
|
||||||
|
|
||||||
|
this.clearRetry();
|
||||||
|
this.retryTimer = window.setTimeout(() => {
|
||||||
|
if (this.closedByUser) return;
|
||||||
|
this.open("reconnecting");
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearRetry(): void {
|
||||||
|
if (this.retryTimer !== null) {
|
||||||
|
window.clearTimeout(this.retryTimer);
|
||||||
|
this.retryTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import type { AudioSample } from "../../../entities/audioSample/model/types";
|
||||||
|
import { parseIsoToMs } from "../../../shared/lib/time";
|
||||||
|
|
||||||
|
type RawMessage = {
|
||||||
|
time: unknown;
|
||||||
|
rms_db: unknown;
|
||||||
|
freq_hz: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ParseResult =
|
||||||
|
| { ok: true; sample: AudioSample }
|
||||||
|
| { ok: false; reason: string };
|
||||||
|
|
||||||
|
function devWarn(...args: unknown[]) {
|
||||||
|
if (import.meta.env.DEV) console.warn(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseAndValidateMessage(data: unknown): ParseResult {
|
||||||
|
let obj: RawMessage;
|
||||||
|
try {
|
||||||
|
obj = JSON.parse(String(data)) as RawMessage;
|
||||||
|
} catch {
|
||||||
|
devWarn("[liveStream] drop: invalid JSON", data);
|
||||||
|
return { ok: false, reason: "invalid_json" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof obj.time !== "string") {
|
||||||
|
devWarn("[liveStream] drop: time is not string", obj);
|
||||||
|
return { ok: false, reason: "invalid_time_type" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeMs = parseIsoToMs(obj.time);
|
||||||
|
if (timeMs === null) {
|
||||||
|
devWarn("[liveStream] drop: time is not ISO", obj.time);
|
||||||
|
return { ok: false, reason: "invalid_time_value" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const rms = typeof obj.rms_db === "number" ? obj.rms_db : Number.NaN;
|
||||||
|
const freq = typeof obj.freq_hz === "number" ? obj.freq_hz : Number.NaN;
|
||||||
|
|
||||||
|
if (!Number.isFinite(rms) || rms < -50 || rms > 0) {
|
||||||
|
devWarn("[liveStream] drop: rms_db out of range", rms);
|
||||||
|
return { ok: false, reason: "rms_out_of_range" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Number.isFinite(freq) || freq < 20 || freq > 8000) {
|
||||||
|
devWarn("[liveStream] drop: freq_hz out of range", freq);
|
||||||
|
return { ok: false, reason: "freq_out_of_range" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
sample: {
|
||||||
|
time: obj.time,
|
||||||
|
timeMs,
|
||||||
|
rms_db: rms,
|
||||||
|
freq_hz: freq,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
// WS frequency control
|
||||||
|
requestedHz: number; // 0.1-60
|
||||||
|
setRequestedHz: (hz: number) => void;
|
||||||
|
|
||||||
|
// Window selection
|
||||||
|
windowMs: number;
|
||||||
|
setWindowMs: (ms: number) => void;
|
||||||
|
|
||||||
|
// Derived UI state (throttled)
|
||||||
|
latest: AudioSample | null;
|
||||||
|
peakHoldDb3s: number | null;
|
||||||
|
chartHistory: AudioSample[];
|
||||||
|
|
||||||
|
connect: () => void;
|
||||||
|
disconnect: () => void;
|
||||||
|
loadLatest: (limit?: number) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PEAK_WINDOW_MS = 3_000;
|
||||||
|
|
||||||
|
const WINDOW_OPTIONS_MS = [15_000, 30_000, 60_000, 120_000, 300_000] as const;
|
||||||
|
const DEFAULT_WINDOW_MS = 60_000;
|
||||||
|
|
||||||
|
const MIN_HZ = 0.1;
|
||||||
|
// const MAX_HZ = 60;
|
||||||
|
const DEFAULT_REQUESTED_HZ = 10;
|
||||||
|
|
||||||
|
// If there are more than 600 points in selected window -> downsample
|
||||||
|
const MAX_CHART_POINTS = 600;
|
||||||
|
|
||||||
|
// UI updates not more than ~12 Hz
|
||||||
|
const UI_FLUSH_HZ = 12;
|
||||||
|
const UI_FLUSH_MS = Math.round(1000 / UI_FLUSH_HZ);
|
||||||
|
|
||||||
|
// Max window is 5m; worst-case 60 Hz => 18k points (+ headroom)
|
||||||
|
const RAW_CAPACITY = 20_000;
|
||||||
|
|
||||||
|
// -------- Module-level raw buffers (NOT in Zustand state) --------
|
||||||
|
const rawHistory = new RingBuffer<AudioSample>(RAW_CAPACITY);
|
||||||
|
let peakWindow: AudioSample[] = [];
|
||||||
|
|
||||||
|
let client: LiveWsClient | null = null;
|
||||||
|
let flushTimer: number | null = null;
|
||||||
|
|
||||||
|
let lastSeenSample: AudioSample | null = null;
|
||||||
|
|
||||||
|
function clampInt(v: number, min: number): number {
|
||||||
|
if (!Number.isFinite(v)) return min;
|
||||||
|
return Math.max(min, v);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAllowedWindowMs(
|
||||||
|
ms: number,
|
||||||
|
): ms is (typeof WINDOW_OPTIONS_MS)[number] {
|
||||||
|
return (WINDOW_OPTIONS_MS as readonly number[]).includes(ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWsUrl(base: string, hz: number): string {
|
||||||
|
const safeHz = clampInt(hz, MIN_HZ);
|
||||||
|
|
||||||
|
// Prefer URL() for correctness (keeps existing params)
|
||||||
|
try {
|
||||||
|
const u = new URL(base);
|
||||||
|
u.searchParams.set("hz", String(safeHz));
|
||||||
|
return u.toString();
|
||||||
|
} catch {
|
||||||
|
const sep = base.includes("?") ? "&" : "?";
|
||||||
|
return `${base}${sep}hz=${encodeURIComponent(String(safeHz))}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimPeakWindow(nowSampleMs: number): void {
|
||||||
|
const cutoff = nowSampleMs - PEAK_WINDOW_MS;
|
||||||
|
while (peakWindow.length && peakWindow[0]!.timeMs < cutoff)
|
||||||
|
peakWindow.shift();
|
||||||
|
|
||||||
|
// Safety cap: even at 60 Hz, 3 sec ~ 180 points; allow some jitter
|
||||||
|
if (peakWindow.length > 512) peakWindow = peakWindow.slice(-512);
|
||||||
|
}
|
||||||
|
|
||||||
|
function computePeakDb3s(): number | null {
|
||||||
|
if (!peakWindow.length) return null;
|
||||||
|
let max = -Infinity;
|
||||||
|
for (const s of peakWindow) max = Math.max(max, s.rms_db);
|
||||||
|
return Number.isFinite(max) ? max : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeChartHistory(windowMs: number): AudioSample[] {
|
||||||
|
const latest = lastSeenSample ?? rawHistory.last();
|
||||||
|
if (!latest) return [];
|
||||||
|
|
||||||
|
const cutoff = latest.timeMs - windowMs;
|
||||||
|
|
||||||
|
// Note: rawHistory.toArray() allocates, but it's called at ~12 Hz, not per WS packet
|
||||||
|
const windowed = rawHistory.toArray().filter((s) => s.timeMs >= cutoff);
|
||||||
|
|
||||||
|
if (windowed.length <= MAX_CHART_POINTS) return windowed;
|
||||||
|
|
||||||
|
const step = Math.ceil(windowed.length / MAX_CHART_POINTS);
|
||||||
|
const out: AudioSample[] = [];
|
||||||
|
for (let i = 0; i < windowed.length; i += step) out.push(windowed[i]!);
|
||||||
|
|
||||||
|
// Ensure last point is present (prevents “missing tail” effect)
|
||||||
|
const last = windowed.at(-1);
|
||||||
|
if (last && out.at(-1)?.timeMs !== last.timeMs) out.push(last);
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useLiveStreamStore = create<LiveStreamState>()((set, get) => {
|
||||||
|
function clearFlushTimer(): void {
|
||||||
|
if (flushTimer !== null) {
|
||||||
|
window.clearTimeout(flushTimer);
|
||||||
|
flushTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function flushToUi(): void {
|
||||||
|
clearFlushTimer();
|
||||||
|
|
||||||
|
const { windowMs } = get();
|
||||||
|
const latest = lastSeenSample ?? rawHistory.last();
|
||||||
|
const chartHistory = makeChartHistory(windowMs);
|
||||||
|
|
||||||
|
set({
|
||||||
|
latest: latest ?? null,
|
||||||
|
peakHoldDb3s: computePeakDb3s(),
|
||||||
|
chartHistory,
|
||||||
|
lastMessageAt: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleFlush(): void {
|
||||||
|
if (flushTimer !== null) return;
|
||||||
|
flushTimer = window.setTimeout(flushToUi, UI_FLUSH_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureClientConnected(): void {
|
||||||
|
if (client) return;
|
||||||
|
|
||||||
|
const hz = get().requestedHz;
|
||||||
|
const url = buildWsUrl(env.wsUrl, hz);
|
||||||
|
|
||||||
|
client = new LiveWsClient(url, {
|
||||||
|
onStatus: (st) => set({ status: st }),
|
||||||
|
onMessage: (data) => {
|
||||||
|
const parsed = parseAndValidateMessage(data);
|
||||||
|
if (!parsed.ok) return;
|
||||||
|
|
||||||
|
const sample = parsed.sample;
|
||||||
|
lastSeenSample = sample;
|
||||||
|
|
||||||
|
rawHistory.push(sample);
|
||||||
|
|
||||||
|
peakWindow.push(sample);
|
||||||
|
trimPeakWindow(sample.timeMs);
|
||||||
|
|
||||||
|
// Throttled UI update
|
||||||
|
scheduleFlush();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
client.connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
function reconnectWithNewHz(): void {
|
||||||
|
if (!client) return;
|
||||||
|
client.close();
|
||||||
|
client = null;
|
||||||
|
ensureClientConnected();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: "disconnected",
|
||||||
|
lastMessageAt: null,
|
||||||
|
|
||||||
|
requestedHz: DEFAULT_REQUESTED_HZ,
|
||||||
|
setRequestedHz: (hz) => {
|
||||||
|
const next = clampInt(hz, MIN_HZ);
|
||||||
|
const prev = get().requestedHz;
|
||||||
|
if (next === prev) return;
|
||||||
|
|
||||||
|
set({ requestedHz: next });
|
||||||
|
reconnectWithNewHz();
|
||||||
|
},
|
||||||
|
|
||||||
|
windowMs: DEFAULT_WINDOW_MS,
|
||||||
|
setWindowMs: (ms) => {
|
||||||
|
const next = isAllowedWindowMs(ms) ? ms : DEFAULT_WINDOW_MS;
|
||||||
|
if (next === get().windowMs) return;
|
||||||
|
|
||||||
|
set({ windowMs: next });
|
||||||
|
// Recompute immediately (doesn't touch WS)
|
||||||
|
flushToUi();
|
||||||
|
},
|
||||||
|
|
||||||
|
latest: null,
|
||||||
|
peakHoldDb3s: null,
|
||||||
|
chartHistory: [],
|
||||||
|
|
||||||
|
connect: () => {
|
||||||
|
ensureClientConnected();
|
||||||
|
},
|
||||||
|
|
||||||
|
disconnect: () => {
|
||||||
|
clearFlushTimer();
|
||||||
|
client?.close();
|
||||||
|
client = null;
|
||||||
|
set({ status: "disconnected" });
|
||||||
|
// Raw history intentionally preserved to avoid “jumping” on reconnect
|
||||||
|
},
|
||||||
|
|
||||||
|
loadLatest: async (limit = 300) => {
|
||||||
|
const safeLimit = clampInt(limit, 1);
|
||||||
|
const base = env.apiUrl.replace(/\/$/, "");
|
||||||
|
const url = `${base}/api/v1/audio/latest?limit=${safeLimit}`;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
// Push historical points into raw buffer (no per-item setState!)
|
||||||
|
for (const item of raw) {
|
||||||
|
const parsed = parseAndValidateMessage(JSON.stringify(item));
|
||||||
|
if (!parsed.ok) continue;
|
||||||
|
|
||||||
|
rawHistory.push(parsed.sample);
|
||||||
|
lastSeenSample = parsed.sample;
|
||||||
|
|
||||||
|
// Warm-up peak window too (optional but consistent)
|
||||||
|
peakWindow.push(parsed.sample);
|
||||||
|
trimPeakWindow(parsed.sample.timeMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single UI update after warm-up
|
||||||
|
flushToUi();
|
||||||
|
} catch {
|
||||||
|
// graceful fallback: ignore (dashboard must stay usable without REST)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
1
services/frontend/src/features/liveStream/model/types.ts
Normal file
1
services/frontend/src/features/liveStream/model/types.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export type WsStatus = "connecting" | "open" | "reconnecting" | "disconnected";
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { Badge } from "../../../shared/ui/badge";
|
||||||
|
import type { WsStatus } from "../model/types";
|
||||||
|
|
||||||
|
const toneByStatus: Record<WsStatus, string> = {
|
||||||
|
connecting: "bg-slate-600",
|
||||||
|
open: "bg-emerald-600",
|
||||||
|
reconnecting: "bg-amber-600",
|
||||||
|
disconnected: "bg-rose-600",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function WsStatusBadge({ status }: { status: WsStatus }) {
|
||||||
|
return (
|
||||||
|
<Badge className={`${toneByStatus[status]} text-white`}>ws: {status}</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { useLiveStreamStore } from "../model/liveStream.store";
|
||||||
|
|
||||||
|
export function useLiveStream(options?: { loadLatest?: boolean }) {
|
||||||
|
const connect = useLiveStreamStore((s) => s.connect);
|
||||||
|
const disconnect = useLiveStreamStore((s) => s.disconnect);
|
||||||
|
const loadLatest = useLiveStreamStore((s) => s.loadLatest);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 1. Подключаемся по WS
|
||||||
|
connect();
|
||||||
|
|
||||||
|
// 2. Опционально грузим историю, чтобы график не был пустым первые секунды
|
||||||
|
if (options?.loadLatest) {
|
||||||
|
void loadLatest(300); // 300 точек ~30 сек при 10Hz
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => disconnect();
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
6
services/frontend/src/lib/utils.ts
Normal file
6
services/frontend/src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
13
services/frontend/src/main.tsx
Normal file
13
services/frontend/src/main.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import React from "react";
|
||||||
|
import ReactDOM from "react-dom/client";
|
||||||
|
import { App } from "./app/App";
|
||||||
|
import { WithLiveStream } from "./app/providers/withLiveStream";
|
||||||
|
import "./app/styles/index.css";
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<WithLiveStream>
|
||||||
|
<App />
|
||||||
|
</WithLiveStream>
|
||||||
|
</React.StrictMode>,
|
||||||
|
);
|
||||||
19
services/frontend/src/pages/dashboard/ui/DashboardPage.tsx
Normal file
19
services/frontend/src/pages/dashboard/ui/DashboardPage.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { AudioLiveWidget } from "../../../widgets/audioLive/ui/AudioLiveWidget";
|
||||||
|
import { useLiveStream } from "../../../features/liveStream/ui/useLiveStream";
|
||||||
|
|
||||||
|
export function DashboardPage() {
|
||||||
|
useLiveStream({ loadLatest: true });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto w-full max-w-6xl p-4 md:p-6">
|
||||||
|
<header className="mb-4">
|
||||||
|
<h1 className="text-2xl font-semibold">STM32 Панель анализа звука</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Частоты: 129 Гц - 5,5 кГц; Громкость: -50 - 0 дБ
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<AudioLiveWidget />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
services/frontend/src/shared/config/env.ts
Normal file
16
services/frontend/src/shared/config/env.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
export type EnvConfig = {
|
||||||
|
wsUrl: string;
|
||||||
|
apiUrl: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function required(name: string, value: unknown): string {
|
||||||
|
if (typeof value !== "string" || !value.length) {
|
||||||
|
throw new Error(`Missing required env var: ${name}`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const env: EnvConfig = {
|
||||||
|
wsUrl: required("VITE_WS_URL", import.meta.env.VITE_WS_URL),
|
||||||
|
apiUrl: required("VITE_API_URL", import.meta.env.VITE_API_URL),
|
||||||
|
};
|
||||||
50
services/frontend/src/shared/lib/ringBuffer.ts
Normal file
50
services/frontend/src/shared/lib/ringBuffer.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
export class RingBuffer<T> {
|
||||||
|
private buf: (T | undefined)[];
|
||||||
|
private head = 0;
|
||||||
|
private len = 0;
|
||||||
|
|
||||||
|
constructor(private readonly capacity: number) {
|
||||||
|
if (!Number.isFinite(capacity) || capacity <= 0) {
|
||||||
|
throw new Error("RingBuffer capacity must be > 0");
|
||||||
|
}
|
||||||
|
this.buf = new Array<T | undefined>(capacity);
|
||||||
|
}
|
||||||
|
|
||||||
|
size(): number {
|
||||||
|
return this.len;
|
||||||
|
}
|
||||||
|
|
||||||
|
maxSize(): number {
|
||||||
|
return this.capacity;
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this.head = 0;
|
||||||
|
this.len = 0;
|
||||||
|
this.buf.fill(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
push(item: T): void {
|
||||||
|
this.buf[this.head] = item;
|
||||||
|
this.head = (this.head + 1) % this.capacity;
|
||||||
|
if (this.len < this.capacity) this.len += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
toArray(): T[] {
|
||||||
|
const out: T[] = [];
|
||||||
|
out.length = this.len;
|
||||||
|
|
||||||
|
const start = (this.head - this.len + this.capacity) % this.capacity;
|
||||||
|
for (let i = 0; i < this.len; i++) {
|
||||||
|
const idx = (start + i) % this.capacity;
|
||||||
|
out[i] = this.buf[idx] as T;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
last(): T | null {
|
||||||
|
if (this.len === 0) return null;
|
||||||
|
const idx = (this.head - 1 + this.capacity) % this.capacity;
|
||||||
|
return (this.buf[idx] as T) ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
13
services/frontend/src/shared/lib/time.ts
Normal file
13
services/frontend/src/shared/lib/time.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
export function parseIsoToMs(iso: string): number | null {
|
||||||
|
const ms = Date.parse(iso);
|
||||||
|
return Number.isFinite(ms) ? ms : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTimeHHMMSS(ms: number): string {
|
||||||
|
return new Date(ms).toLocaleTimeString([], { hour12: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isStale(lastMs: number | null, thresholdMs: number): boolean {
|
||||||
|
if (lastMs === null) return true;
|
||||||
|
return Date.now() - lastMs > thresholdMs;
|
||||||
|
}
|
||||||
36
services/frontend/src/shared/ui/badge.tsx
Normal file
36
services/frontend/src/shared/ui/badge.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
|
||||||
|
secondary:
|
||||||
|
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
destructive:
|
||||||
|
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
|
||||||
|
outline: "text-foreground",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface BadgeProps
|
||||||
|
extends React.HTMLAttributes<HTMLDivElement>,
|
||||||
|
VariantProps<typeof badgeVariants> {}
|
||||||
|
|
||||||
|
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants }
|
||||||
57
services/frontend/src/shared/ui/button.tsx
Normal file
57
services/frontend/src/shared/ui/button.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||||
|
outline:
|
||||||
|
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||||
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-9 px-4 py-2",
|
||||||
|
sm: "h-8 rounded-md px-3 text-xs",
|
||||||
|
lg: "h-10 rounded-md px-8",
|
||||||
|
icon: "h-9 w-9",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : "button"
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Button.displayName = "Button"
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
76
services/frontend/src/shared/ui/card.tsx
Normal file
76
services/frontend/src/shared/ui/card.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Card = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"rounded-xl border bg-card text-card-foreground shadow",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Card.displayName = "Card"
|
||||||
|
|
||||||
|
const CardHeader = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardHeader.displayName = "CardHeader"
|
||||||
|
|
||||||
|
const CardTitle = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("font-semibold leading-none tracking-tight", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardTitle.displayName = "CardTitle"
|
||||||
|
|
||||||
|
const CardDescription = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardDescription.displayName = "CardDescription"
|
||||||
|
|
||||||
|
const CardContent = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||||
|
))
|
||||||
|
CardContent.displayName = "CardContent"
|
||||||
|
|
||||||
|
const CardFooter = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex items-center p-6 pt-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardFooter.displayName = "CardFooter"
|
||||||
|
|
||||||
|
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||||
145
services/frontend/src/widgets/audioLive/ui/AudioLiveWidget.tsx
Normal file
145
services/frontend/src/widgets/audioLive/ui/AudioLiveWidget.tsx
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
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";
|
||||||
|
import { FrequencyCurrentDisplay } from "./FrequencyCurrentDisplay";
|
||||||
|
import { WaveformDisplay } from "./WaveFormDisplay";
|
||||||
|
|
||||||
|
const HZ_OPTIONS: number[] = [0.1, 0.25, 0.5, 1, 2.5, 5, 10, 15] as const;
|
||||||
|
|
||||||
|
const WINDOW_OPTIONS: Array<{ label: string; ms: number }> = [
|
||||||
|
{ label: "15s", ms: 15_000 },
|
||||||
|
{ label: "30s", ms: 30_000 },
|
||||||
|
{ label: "60s", ms: 60_000 },
|
||||||
|
{ label: "2m", ms: 120_000 },
|
||||||
|
{ label: "5m", ms: 300_000 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const LOUDNESS_THRESHOLD = -35.0;
|
||||||
|
|
||||||
|
export const MIN_FREQUENCY = 129;
|
||||||
|
export const MAX_FREQUENCY = 5500;
|
||||||
|
|
||||||
|
export function AudioLiveWidget() {
|
||||||
|
const status = useLiveStreamStore((s) => s.status);
|
||||||
|
const latest = useLiveStreamStore((s) => s.latest);
|
||||||
|
const chartHistory = useLiveStreamStore((s) => s.chartHistory);
|
||||||
|
const peakHoldDb3s = useLiveStreamStore((s) => s.peakHoldDb3s);
|
||||||
|
const lastMessageAt = useLiveStreamStore((s) => s.lastMessageAt);
|
||||||
|
|
||||||
|
const requestedHz = useLiveStreamStore((s) => s.requestedHz);
|
||||||
|
const setRequestedHz = useLiveStreamStore((s) => s.setRequestedHz);
|
||||||
|
|
||||||
|
const windowMs = useLiveStreamStore((s) => s.windowMs);
|
||||||
|
const setWindowMs = useLiveStreamStore((s) => s.setWindowMs);
|
||||||
|
|
||||||
|
const stale = isStale(lastMessageAt, 1500);
|
||||||
|
|
||||||
|
const note = useMemo(() => {
|
||||||
|
return latest ? freqToNote(latest.freq_hz) : "--";
|
||||||
|
}, [latest]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4 lg:grid-cols-4">
|
||||||
|
<div className="lg:col-span-1 row-span-2">
|
||||||
|
<AudioMeter
|
||||||
|
rmsDb={latest?.rms_db ?? null}
|
||||||
|
peakHoldDb3s={peakHoldDb3s}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="lg:col-span-3 row-span-3 grid gap-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2 flex flex-row items-center justify-between gap-3">
|
||||||
|
<CardTitle className="text-base">Состояние</CardTitle>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<WsStatusBadge status={status} />
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="flex flex-wrap items-end justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-muted-foreground">Последнее обновление</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">Частота</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>
|
||||||
|
|
||||||
|
{/* Controls */}
|
||||||
|
<div className="flex flex-wrap items-end gap-3">
|
||||||
|
<label className="grid gap-1">
|
||||||
|
<span className="text-sm text-muted-foreground">Частота обновлений</span>
|
||||||
|
<select
|
||||||
|
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
|
||||||
|
value={requestedHz}
|
||||||
|
onChange={(e) => setRequestedHz(Number(e.target.value))}
|
||||||
|
>
|
||||||
|
{HZ_OPTIONS.map((hz) => (
|
||||||
|
<option key={hz} value={hz}>
|
||||||
|
{hz} Гц
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="grid gap-1">
|
||||||
|
<span className="text-sm text-muted-foreground">Окно</span>
|
||||||
|
<select
|
||||||
|
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
|
||||||
|
value={windowMs}
|
||||||
|
onChange={(e) => setWindowMs(Number(e.target.value))}
|
||||||
|
>
|
||||||
|
{WINDOW_OPTIONS.map((opt) => (
|
||||||
|
<option key={opt.ms} value={opt.ms}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<FrequencyHistoryChart history={chartHistory} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="lg:col-span-1 row-span-2 grid gap-4">
|
||||||
|
<FrequencyCurrentDisplay
|
||||||
|
latest={latest}
|
||||||
|
history={chartHistory}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="lg:col-span-3 grid gap-4">
|
||||||
|
<WaveformDisplay latest={latest} />
|
||||||
|
</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">Аудио измеритель</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 дБ</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">Сейчас</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">
|
||||||
|
Пик (3с)
|
||||||
|
</div>
|
||||||
|
<div className="text-lg font-medium tabular-nums">
|
||||||
|
{peakHoldDb3s === null ? "—" : `${peakHoldDb3s.toFixed(1)} dB`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import { memo, useMemo } from "react";
|
||||||
|
import { Line, LineChart, ResponsiveContainer, Tooltip, XAxis } from "recharts";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "../../../shared/ui/card";
|
||||||
|
import type { AudioSample } from "../../../entities/audioSample/model/types";
|
||||||
|
import { freqToNote } from "../../../entities/audioSample/lib/note";
|
||||||
|
import { formatTimeHHMMSS } from "../../../shared/lib/time";
|
||||||
|
import {
|
||||||
|
LOUDNESS_THRESHOLD,
|
||||||
|
MAX_FREQUENCY,
|
||||||
|
MIN_FREQUENCY,
|
||||||
|
} from "./AudioLiveWidget";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
latest: AudioSample | null;
|
||||||
|
history: AudioSample[]; // последние ~15 секунд
|
||||||
|
};
|
||||||
|
|
||||||
|
const HISTORY_WINDOW_MS = 15_000; // 15 секунд истории
|
||||||
|
const STABILITY_WINDOW = 5; // анализируем последние 10 точек
|
||||||
|
|
||||||
|
function getStability(history: AudioSample[]): number {
|
||||||
|
if (history.length < STABILITY_WINDOW) return 1;
|
||||||
|
const recent = history.slice(-STABILITY_WINDOW);
|
||||||
|
const values = recent.map((s) => s.freq_hz);
|
||||||
|
// Среднее арифметическое
|
||||||
|
const mean = values.reduce((a, b) => a + b, 0) / values.length;
|
||||||
|
// Дисперсия
|
||||||
|
const variance =
|
||||||
|
values.reduce((sum, v) => sum + Math.pow(v - mean, 2), 0) / values.length;
|
||||||
|
// Коэффициент вариации
|
||||||
|
return Math.max(0, 1 - Math.sqrt(variance) / mean); // 0..1, где 1 = идеально стабильно
|
||||||
|
}
|
||||||
|
|
||||||
|
const ORANGE_500: [number, number, number] = [249, 215, 22]; // #f97316
|
||||||
|
const GRAY: [number, number, number] = [50, 50, 50];
|
||||||
|
|
||||||
|
// Helper function to convert an RGB array to a CSS rgb() string
|
||||||
|
const toRgbString = (color: [number, number, number]): string =>
|
||||||
|
`rgb(${color[0]},${color[1]},${color[2]})`;
|
||||||
|
|
||||||
|
function getActivityColor(freq: number, rms: number): string {
|
||||||
|
let fromRgb: [number, number, number];
|
||||||
|
let toRgb: [number, number, number];
|
||||||
|
|
||||||
|
if (rms > LOUDNESS_THRESHOLD) {
|
||||||
|
const factor = (freq - MIN_FREQUENCY) / (MAX_FREQUENCY - MIN_FREQUENCY);
|
||||||
|
fromRgb = [
|
||||||
|
Math.round(ORANGE_500[0] * factor),
|
||||||
|
ORANGE_500[1],
|
||||||
|
Math.round(255 - (255 - ORANGE_500[2]) * factor),
|
||||||
|
];
|
||||||
|
toRgb = fromRgb;
|
||||||
|
} else {
|
||||||
|
fromRgb = GRAY;
|
||||||
|
toRgb = GRAY;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return a CSS linear-gradient string
|
||||||
|
return `linear-gradient(to right, ${toRgbString(fromRgb)}, ${toRgbString(toRgb)})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FrequencyCurrentDisplay = memo(function FrequencyCurrentDisplay({
|
||||||
|
latest,
|
||||||
|
history,
|
||||||
|
}: Props) {
|
||||||
|
const recentHistory = useMemo(() => {
|
||||||
|
if (!latest) return [];
|
||||||
|
const cutoff = latest.timeMs - HISTORY_WINDOW_MS;
|
||||||
|
return history.filter((s) => s.timeMs >= cutoff);
|
||||||
|
}, [latest?.timeMs, history]);
|
||||||
|
|
||||||
|
const stability = useMemo(() => getStability(recentHistory), [recentHistory]);
|
||||||
|
const note = latest ? freqToNote(latest.freq_hz) : "--";
|
||||||
|
const gradientColor = latest
|
||||||
|
? getActivityColor(latest.freq_hz, latest.rms_db)
|
||||||
|
: "from-slate-400";
|
||||||
|
|
||||||
|
const chartData = useMemo(() => {
|
||||||
|
return recentHistory.map((s) => ({
|
||||||
|
timeMs: s.timeMs,
|
||||||
|
freq_hz: s.freq_hz,
|
||||||
|
}));
|
||||||
|
}, [recentHistory]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="col-span-full lg:col-span-1">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-lg">Текущая частота</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* Центральный круглый индикатор */}
|
||||||
|
<div className="flex flex-col items-center space-y-2">
|
||||||
|
<div className="relative">
|
||||||
|
{/* Фон круга */}
|
||||||
|
<div
|
||||||
|
className="w-32 h-32 rounded-full border-8 border-slate-200 dark:border-slate-800 shadow-lg"
|
||||||
|
style={{
|
||||||
|
background: `radial-gradient(circle at center, hsl(var(--background)) 0%, hsl(var(--muted)) 100%)`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Градиентный круг по активности */}
|
||||||
|
{latest && (
|
||||||
|
<div
|
||||||
|
className={`absolute inset-0 w-32 h-32 rounded-full border-8 animate-spin-slow border-transparent bg-gradient-to-r opacity-70`}
|
||||||
|
style={{
|
||||||
|
background: gradientColor,
|
||||||
|
mask: "radial-gradient(circle at center, black 60%, transparent 70%)",
|
||||||
|
WebkitMask:
|
||||||
|
"radial-gradient(circle at center, black 60%, transparent 70%)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Центральный текст */}
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||||
|
<div className="text-3xl font-bold tabular-nums text-foreground">
|
||||||
|
{latest ? `${latest.freq_hz.toFixed(0)}` : "---"}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs font-medium text-muted-foreground tracking-wide uppercase">
|
||||||
|
Hz
|
||||||
|
</div>
|
||||||
|
<div className="text-xs font-mono bg-background/80 px-2 py-0.5 rounded-full mt-1">
|
||||||
|
{note}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Стабильность */}
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
<div
|
||||||
|
className="w-2 h-2 rounded-full"
|
||||||
|
style={{ backgroundColor: `hsl(${240 * stability}, 70%, 50%)` }}
|
||||||
|
/>Текущая частота</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Мини-график последних 15 секунд */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||||
|
<span>Последние изменения ({Math.round(HISTORY_WINDOW_MS / 1000)}с)</span>
|
||||||
|
<span>{recentHistory.length} pts</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-20">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<LineChart
|
||||||
|
data={chartData}
|
||||||
|
margin={{ top: 0, right: 0, left: 0, bottom: 0 }}
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="freqTrend" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stopColor="#3b82f6" stopOpacity={0.8} />
|
||||||
|
<stop offset="100%" stopColor="#1d4ed8" stopOpacity={0.4} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<XAxis
|
||||||
|
dataKey="timeMs"
|
||||||
|
hide
|
||||||
|
type="number"
|
||||||
|
domain={["dataMin", "dataMax"]}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
labelFormatter={(v) => formatTimeHHMMSS(Number(v))}
|
||||||
|
formatter={(v) => [`${Number(v).toFixed(0)} Hz`, "freq"]}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="freq_hz"
|
||||||
|
stroke="url(#freqTrend)"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={false}
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
// src/widgets/audioLive/ui/FrequencyHistoryChart.tsx
|
||||||
|
import { memo, useMemo } from "react";
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Y_MIN = 129;
|
||||||
|
const Y_MAX = 5500;
|
||||||
|
|
||||||
|
const Y_TICKS = [139, 200, 500, 1000, 2000, 5000, 5500];
|
||||||
|
|
||||||
|
function formatHzTick(v: number): string {
|
||||||
|
const n = Number(v);
|
||||||
|
if (!Number.isFinite(n)) return "";
|
||||||
|
if (n >= 1000) return `${(n / 1000).toFixed(n % 1000 === 0 ? 0 : 1)}k`;
|
||||||
|
return `${Math.round(n)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FrequencyHistoryChart = memo(function FrequencyHistoryChart({
|
||||||
|
history = [],
|
||||||
|
}: Props) {
|
||||||
|
const data: ChartPoint[] = useMemo(() => {
|
||||||
|
if (!history?.length) return [];
|
||||||
|
return history.map((s) => ({ timeMs: s.timeMs, freq_hz: s.freq_hz }));
|
||||||
|
}, [history]);
|
||||||
|
|
||||||
|
const hasData = data.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-base">Доминантная частота</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent>
|
||||||
|
<div className="relative h-56 min-h-[14rem] w-full">
|
||||||
|
{!hasData && (
|
||||||
|
<div className="absolute inset-0 grid place-items-center text-sm text-muted-foreground">
|
||||||
|
Пока нет данных
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<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 }}
|
||||||
|
interval="preserveStartEnd"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<YAxis
|
||||||
|
type="number"
|
||||||
|
scale="log"
|
||||||
|
domain={[Y_MIN, Y_MAX]}
|
||||||
|
ticks={Y_TICKS}
|
||||||
|
allowDataOverflow
|
||||||
|
tick={{ fontSize: 12 }}
|
||||||
|
width={52}
|
||||||
|
tickFormatter={(v) => formatHzTick(Number(v))}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Tooltip
|
||||||
|
labelFormatter={(v) => formatTimeHHMMSS(Number(v))}
|
||||||
|
formatter={(v) => [`${Number(v).toFixed(0)} Гц`, "част."]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="freq_hz"
|
||||||
|
stroke="oklch(0.208 0.042 265.755)"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={false}
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
});
|
||||||
131
services/frontend/src/widgets/audioLive/ui/WaveFormDisplay.tsx
Normal file
131
services/frontend/src/widgets/audioLive/ui/WaveFormDisplay.tsx
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import { memo, useEffect, useRef } from "react";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "../../../shared/ui/card";
|
||||||
|
import type { AudioSample } from "../../../entities/audioSample/model/types";
|
||||||
|
import { LOUDNESS_THRESHOLD } from "./AudioLiveWidget";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
latest: AudioSample | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Синтетическая волна: синусоида с частотой freq_hz и амплитудой из rms_db
|
||||||
|
function drawSyntheticWaveform(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
freqHz: number,
|
||||||
|
rmsDb: number,
|
||||||
|
) {
|
||||||
|
ctx.clearRect(0, 0, width, height);
|
||||||
|
|
||||||
|
// Конвертируем dB в амплитуду (0..1)
|
||||||
|
// rms_db диапазон [-50..0], при -50 амплитуда ~0, при 0 амплитуда ~1
|
||||||
|
const amplitude = Math.pow(10, rmsDb / 150); // линейная амплитуда (0..1)
|
||||||
|
const visualAmp = amplitude * (height / 2) * 0.8; // оставляем margin
|
||||||
|
|
||||||
|
// Количество точек на экране
|
||||||
|
const points = width;
|
||||||
|
|
||||||
|
// Частота в Гц -> сколько периодов умещается на экран
|
||||||
|
// Пусть экран = 1 секунда визуально
|
||||||
|
const timeScale = 0.01; // 50 мс на экран
|
||||||
|
const angularFreq = 2 * Math.PI * freqHz * timeScale;
|
||||||
|
|
||||||
|
// Рисуем синусоиду
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.strokeStyle = "oklch(0.6 0.2 260)"; // синий градиент
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
|
||||||
|
for (let x = 0; x < points; x++) {
|
||||||
|
const t = x / width; // нормализованное время [0..1]
|
||||||
|
const y = height / 2 + visualAmp * Math.sin(angularFreq * t);
|
||||||
|
|
||||||
|
if (x === 0) {
|
||||||
|
ctx.moveTo(x, y);
|
||||||
|
} else {
|
||||||
|
ctx.lineTo(x, y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Центральная линия (0-уровень)
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.strokeStyle = "oklch(0.5 0 0 / 0.3)";
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.setLineDash([4, 4]);
|
||||||
|
ctx.moveTo(0, height / 2);
|
||||||
|
ctx.lineTo(width, height / 2);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WaveformDisplay = memo(function WaveformDisplay({
|
||||||
|
latest,
|
||||||
|
}: Props) {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
// Подстраиваем размер под DPI (для чёткости на Retina)
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
canvas.width = rect.width * dpr;
|
||||||
|
canvas.height = rect.height * dpr;
|
||||||
|
ctx.scale(dpr, dpr);
|
||||||
|
|
||||||
|
// Рисуем волну
|
||||||
|
if (latest && latest.rms_db > LOUDNESS_THRESHOLD) {
|
||||||
|
drawSyntheticWaveform(
|
||||||
|
ctx,
|
||||||
|
rect.width,
|
||||||
|
rect.height,
|
||||||
|
latest.freq_hz,
|
||||||
|
latest.rms_db,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Рисуем линию
|
||||||
|
ctx.clearRect(0, 0, rect.width, rect.height);
|
||||||
|
ctx.strokeStyle = "oklch(0.5 0 0 / 0.2)";
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(0, rect.height / 2);
|
||||||
|
ctx.lineTo(rect.width, rect.height / 2);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
}, [latest]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-base">Форма волны</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="relative w-full h-32 bg-slate-50 dark:bg-slate-900 rounded-md overflow-hidden">
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
className="w-full h-full"
|
||||||
|
style={{ display: "block" }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Метки */}
|
||||||
|
<div className="absolute top-1 left-2 text-xs text-muted-foreground font-mono">
|
||||||
|
{latest ? `${latest.freq_hz.toFixed(0)} Гц` : "---"}
|
||||||
|
</div>
|
||||||
|
<div className="absolute top-1 right-2 text-xs text-muted-foreground font-mono">
|
||||||
|
{latest ? `${latest.rms_db.toFixed(1)} дБ` : "---"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
});
|
||||||
57
services/frontend/tailwind.config.js
Normal file
57
services/frontend/tailwind.config.js
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
darkMode: ["class"],
|
||||||
|
content: ["./index.html", "./src/**/*.{ts,tsx}"],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
borderRadius: {
|
||||||
|
lg: 'var(--radius)',
|
||||||
|
md: 'calc(var(--radius) - 2px)',
|
||||||
|
sm: 'calc(var(--radius) - 4px)'
|
||||||
|
},
|
||||||
|
colors: {
|
||||||
|
background: 'hsl(var(--background))',
|
||||||
|
foreground: 'hsl(var(--foreground))',
|
||||||
|
card: {
|
||||||
|
DEFAULT: 'hsl(var(--card))',
|
||||||
|
foreground: 'hsl(var(--card-foreground))'
|
||||||
|
},
|
||||||
|
popover: {
|
||||||
|
DEFAULT: 'hsl(var(--popover))',
|
||||||
|
foreground: 'hsl(var(--popover-foreground))'
|
||||||
|
},
|
||||||
|
primary: {
|
||||||
|
DEFAULT: 'hsl(var(--primary))',
|
||||||
|
foreground: 'hsl(var(--primary-foreground))'
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
DEFAULT: 'hsl(var(--secondary))',
|
||||||
|
foreground: 'hsl(var(--secondary-foreground))'
|
||||||
|
},
|
||||||
|
muted: {
|
||||||
|
DEFAULT: 'hsl(var(--muted))',
|
||||||
|
foreground: 'hsl(var(--muted-foreground))'
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
DEFAULT: 'hsl(var(--accent))',
|
||||||
|
foreground: 'hsl(var(--accent-foreground))'
|
||||||
|
},
|
||||||
|
destructive: {
|
||||||
|
DEFAULT: 'hsl(var(--destructive))',
|
||||||
|
foreground: 'hsl(var(--destructive-foreground))'
|
||||||
|
},
|
||||||
|
border: 'hsl(var(--border))',
|
||||||
|
input: 'hsl(var(--input))',
|
||||||
|
ring: 'hsl(var(--ring))',
|
||||||
|
chart: {
|
||||||
|
'1': 'hsl(var(--chart-1))',
|
||||||
|
'2': 'hsl(var(--chart-2))',
|
||||||
|
'3': 'hsl(var(--chart-3))',
|
||||||
|
'4': 'hsl(var(--chart-4))',
|
||||||
|
'5': 'hsl(var(--chart-5))'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: [require("tailwindcss-animate")],
|
||||||
|
};
|
||||||
40
services/frontend/tsconfig.app.json
Normal file
40
services/frontend/tsconfig.app.json
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": [
|
||||||
|
"ES2022",
|
||||||
|
"DOM",
|
||||||
|
"DOM.Iterable"
|
||||||
|
],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": [
|
||||||
|
"vite/client"
|
||||||
|
],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"./src/*"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": false,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src"
|
||||||
|
]
|
||||||
|
}
|
||||||
19
services/frontend/tsconfig.json
Normal file
19
services/frontend/tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.app.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.node.json"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"./src/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
26
services/frontend/tsconfig.node.json
Normal file
26
services/frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
14
services/frontend/vite.config.ts
Normal file
14
services/frontend/vite.config.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import path from "path";
|
||||||
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": path.resolve(__dirname, "./src"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user