Compare commits

..

9 Commits

50 changed files with 6320 additions and 71 deletions

View File

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

View File

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

View File

@@ -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()

View File

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

View File

@@ -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()

View File

@@ -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__":

View File

@@ -3,3 +3,6 @@ asyncpg
numpy numpy
pytest pytest
pytest-asyncio pytest-asyncio
fastapi
uvicorn
websockets

View 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)

View 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
View 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?

View File

@@ -0,0 +1 @@
/home/mikhail/repos/sound-analizer/services/api/.gitignore

View 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"]

View 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...
},
},
])
```

View 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": {}
}

View 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,
},
},
])

View 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

File diff suppressed because it is too large Load Diff

View 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"
}
}

View 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

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

View 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;
}

View 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;
}

View 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

View 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;
}

View File

@@ -0,0 +1,6 @@
export type AudioSample = {
time: string; // ISO
timeMs: number;
rms_db: number; // expected [-50..0]
freq_hz: number; // expected [20..8000]
};

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
export type WsStatus = "connecting" | "open" | "reconnecting" | "disconnected";

View File

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

View File

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

View 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))
}

View 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>,
);

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

View 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),
};

View 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;
}
}

View 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;
}

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

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

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

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

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

View File

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

View File

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

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

View 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")],
};

View 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"
]
}

View File

@@ -0,0 +1,19 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.node.json"
}
],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": [
"./src/*"
]
}
}
}

View 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"]
}

View 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"),
},
},
});