feat(api): add backend

routes and WebSockets
This commit is contained in:
2025-12-26 18:19:06 +03:00
parent cfec8d0ff6
commit 1b864228d4
28 changed files with 631 additions and 2 deletions

View File

@@ -0,0 +1,12 @@
# collector
SERIAL_PORT=/dev/ttyACM0
BAUDRATE=115200
# DB
DB_NAME=audio_analyzer
DB_USER=postgres
DB_PASSWORD=postgres
DB_PORT=5432
# api
DATABASE_URL=postgresql+asyncpg://postgres:postgres@db:5432/audio_analyzer

View File

@@ -6,7 +6,7 @@ CREATE EXTENSION IF NOT EXISTS timescaledb;
-- Create audio data table
CREATE TABLE IF NOT EXISTS audio_data (
time TIMESTAMPTZ NOT NULL,
rms_db REAL CHECK (rms_db >= -40.0 AND rms_db <= 80.0),
rms_db REAL CHECK (rms_db >= -50.0 AND rms_db <= 0.0),
frequency_hz INTEGER CHECK (frequency_hz >= 100 AND frequency_hz <= 8000),
is_silence BOOLEAN NOT NULL DEFAULT FALSE
);

View File

@@ -64,7 +64,11 @@ services:
networks:
- audio_network
healthcheck:
test: ["CMD-SHELL", "curl --fail http://localhost:8000/health || exit 1"]
test:
[
"CMD-SHELL",
"curl --fail http://localhost:8000/api/v1/health/live || exit 1",
]
interval: 10s
timeout: 5s
retries: 5

15
services/api/Dockerfile Normal file
View File

@@ -0,0 +1,15 @@
FROM python:3.11-slim
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]

View File

@@ -0,0 +1,36 @@
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.session import get_db
from app.repositories.audio_repository import AudioRepository
from app.schemas.base import ApiResponse
from app.schemas.audio import AudioPoint
from app.services.audio_service import AudioService
router = APIRouter()
@router.get("/latest", response_model=ApiResponse[list[AudioPoint]])
async def latest(
limit: int = Query(100, ge=1, le=10000), db: AsyncSession = Depends(get_db)
):
service = AudioService(AudioRepository(db))
items = await service.latest(limit)
return ApiResponse(data=items, count=len(items))
@router.get("/range", response_model=ApiResponse[list[AudioPoint]])
async def range_(
time_from: datetime = Query(..., alias="from"),
time_to: datetime = Query(..., alias="to"),
db: AsyncSession = Depends(get_db),
):
if time_from >= time_to:
raise HTTPException(
status_code=400,
detail="'from' timestamp must be earlier than 'to' timestamp",
)
service = AudioService(AudioRepository(db))
items = await service.range(time_from, time_to)
return ApiResponse(data=items, count=len(items))

View File

@@ -0,0 +1,35 @@
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.session import get_db
from app.repositories.audio_repository import AudioRepository
from app.schemas.base import ApiResponse
from app.schemas.events import LoudEvent
from app.services.events_service import EventsService
router = APIRouter()
@router.get("/loud", response_model=ApiResponse[list[LoudEvent]])
async def loud_events(
threshold: float = Query(
default=-35.0, ge=-50.0, le=0.0, description="RMS dB threshold"
),
time_from: Optional[datetime] = Query(None, alias="from"),
time_to: Optional[datetime] = Query(None, alias="to"),
db: AsyncSession = Depends(get_db),
):
if time_from and time_to and time_from >= time_to:
raise HTTPException(
status_code=400,
detail="'from' timestamp must be earlier than 'to' timestamp",
)
service = EventsService(AudioRepository(db))
events = await service.loud_events(
threshold=threshold, time_from=time_from, time_to=time_to
)
return ApiResponse(data=events, count=len(events))

View File

@@ -0,0 +1,42 @@
from datetime import datetime
import csv
import io
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.session import get_db
from app.repositories.audio_repository import AudioRepository
router = APIRouter()
@router.get("/csv")
async def export_csv(
time_from: datetime = Query(..., alias="from"),
time_to: datetime = Query(..., alias="to"),
db: AsyncSession = Depends(get_db),
):
if time_from >= time_to:
raise HTTPException(
status_code=400,
detail="'from' timestamp must be earlier than 'to' timestamp",
)
repo = AudioRepository(db)
rows = await repo.range(time_from, time_to)
buf = io.StringIO()
w = csv.writer(buf)
w.writerow(["time", "rms_db", "frequency_hz", "is_silence"])
for r in rows:
w.writerow([r.time.isoformat(), r.rms_db, r.frequency_hz, r.is_silence])
buf.seek(0)
filename = f"audio_{time_from:%Y%m%d_%H%M%S}_to_{time_to:%Y%m%d_%H%M%S}.csv"
return StreamingResponse(
iter([buf.getvalue()]),
media_type="text/csv",
headers={"Content-Disposition": f"attachment; filename={filename}"},
)

View File

@@ -0,0 +1,19 @@
from fastapi import APIRouter, Depends
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.session import get_db
from app.schemas.base import ApiResponse
router = APIRouter()
@router.get("/live", response_model=ApiResponse[dict])
async def live() -> ApiResponse[dict]:
return ApiResponse(data={"status": "alive"})
@router.get("/ready", response_model=ApiResponse[dict])
async def ready(db: AsyncSession = Depends(get_db)) -> ApiResponse[dict]:
await db.execute(text("SELECT 1"))
return ApiResponse(data={"status": "ready", "db": "ok"})

View File

@@ -0,0 +1,23 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.session import get_db
from app.repositories.audio_repository import AudioRepository
from app.schemas.base import ApiResponse
from app.schemas.stats import StatsSummary
from app.services.stats_service import StatsService
router = APIRouter()
@router.get("/summary", response_model=ApiResponse[StatsSummary])
async def summary(
period: str = Query("1h", pattern="^(10s|1m|1h|6h|24h|7d|30d)$"),
db: AsyncSession = Depends(get_db),
):
service = StatsService(AudioRepository(db))
try:
data = await service.summary(period)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
return ApiResponse(data=data)

View File

@@ -0,0 +1,9 @@
from fastapi import APIRouter
from app.api.v1.endpoints import audio, stats, events, export, health
router = APIRouter()
router.include_router(health.router, prefix="/health", tags=["health"])
router.include_router(audio.router, prefix="/audio", tags=["audio"])
router.include_router(stats.router, prefix="/stats", tags=["stats"])
router.include_router(events.router, prefix="/events", tags=["events"])
router.include_router(export.router, prefix="/export", tags=["export"])

View File

@@ -0,0 +1,11 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@db:5432/audio_analyzer"
API_V1_PREFIX: str = "/api/v1"
settings = Settings()

View File

@@ -0,0 +1,5 @@
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
pass

View File

@@ -0,0 +1,12 @@
from typing import AsyncGenerator
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.core.config import settings
engine = create_async_engine(settings.DATABASE_URL, echo=False, pool_pre_ping=True)
SessionLocal = async_sessionmaker(engine, expire_on_commit=False, autoflush=False)
async def get_db() -> AsyncGenerator[AsyncSession, None]:
async with SessionLocal() as session:
yield session

46
services/api/app/main.py Normal file
View File

@@ -0,0 +1,46 @@
from fastapi import FastAPI
import asyncio
from contextlib import asynccontextmanager, suppress
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api.v1.router import router as v1_router
from app.core.config import settings
from app.ws.router import router as ws_router
from app.ws.broadcaster import audio_live_broadcaster
@asynccontextmanager
async def lifespan(app: FastAPI):
task = asyncio.create_task(audio_live_broadcaster())
try:
yield
finally:
task.cancel()
with suppress(asyncio.CancelledError):
await task
def create_app() -> FastAPI:
app = FastAPI(
title="Audio Analyzer API",
version="1.0.0",
docs_url="/api/docs",
redoc_url="/api/redoc",
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(v1_router, prefix=settings.API_V1_PREFIX)
app.include_router(ws_router) # /ws/live
return app
app = create_app()

View File

@@ -0,0 +1,14 @@
from sqlalchemy import Boolean, Integer, Float
from sqlalchemy.dialects.postgresql import TIMESTAMP
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class AudioData(Base):
__tablename__ = "audio_data"
time: Mapped[object] = mapped_column(TIMESTAMP(timezone=True), primary_key=True)
rms_db: Mapped[float] = mapped_column(Float, nullable=False)
frequency_hz: Mapped[int] = mapped_column(Integer, nullable=False)
is_silence: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)

View File

@@ -0,0 +1,74 @@
from __future__ import annotations
from datetime import datetime, timedelta
from sqlalchemy import and_, func, select, text
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.audio_data import AudioData
class AudioRepository:
def __init__(self, db: AsyncSession):
self.db = db
async def latest(self, limit: int) -> list[AudioData]:
q = select(AudioData).order_by(AudioData.time.desc()).limit(limit)
res = await self.db.execute(q)
return list(res.scalars().all())
async def range(self, time_from: datetime, time_to: datetime) -> list[AudioData]:
q = (
select(AudioData)
.where(and_(AudioData.time >= time_from, AudioData.time <= time_to))
.order_by(AudioData.time.asc())
)
res = await self.db.execute(q)
return list(res.scalars().all())
async def loud_samples(
self,
threshold: float,
time_from: datetime | None,
time_to: datetime | None,
) -> list[AudioData]:
cond = [AudioData.rms_db >= threshold]
if time_from:
cond.append(AudioData.time >= time_from)
if time_to:
cond.append(AudioData.time <= time_to)
q = select(AudioData).where(and_(*cond)).order_by(AudioData.time.asc())
res = await self.db.execute(q)
return list(res.scalars().all())
async def summary_since(self, since: datetime) -> dict:
q = select(
func.avg(AudioData.rms_db).label("avg_db"),
func.max(AudioData.rms_db).label("max_db"),
func.sum(func.case((AudioData.is_silence.is_(True), 1), else_=0)).label(
"silence_count"
),
func.count().label("total_count"),
).where(AudioData.time >= since)
res = await self.db.execute(q)
row = res.one()
# dominant freq excluding silence
fq = (
select(AudioData.frequency_hz, func.count().label("cnt"))
.where(and_(AudioData.time >= since, AudioData.is_silence.is_(False)))
.group_by(AudioData.frequency_hz)
.order_by(text("cnt DESC"))
.limit(1)
)
fres = await self.db.execute(fq)
frow = fres.first()
return {
"avg_db": float(row.avg_db or 0.0),
"max_db": float(row.max_db or 0.0),
"dominant_freq": int(frow[0]) if frow else 0,
"silence_count": int(row.silence_count or 0),
"total_count": int(row.total_count or 0),
}

View File

@@ -0,0 +1,9 @@
from datetime import datetime
from pydantic import BaseModel
class AudioPoint(BaseModel):
time: datetime
rms_db: float
frequency_hz: int
is_silence: bool

View File

@@ -0,0 +1,19 @@
from __future__ import annotations
from typing import Generic, TypeVar
from pydantic import BaseModel, Field
T = TypeVar("T")
class ApiError(BaseModel):
code: str = Field(..., examples=["validation_error", "db_error"])
message: str
details: dict | None = None
class ApiResponse(BaseModel, Generic[T]):
success: bool = True
errors: list[ApiError] | None = None
count: int | None = None
data: T | None = None

View File

@@ -0,0 +1,9 @@
from datetime import datetime
from pydantic import BaseModel
class LoudEvent(BaseModel):
time: datetime
rms_db: float
frequency_hz: int
duration_sec: float | None = None

View File

@@ -0,0 +1,8 @@
from pydantic import BaseModel
class StatsSummary(BaseModel):
avg_db: float
max_db: float
dominant_freq: int
silence_percent: float

View File

@@ -0,0 +1,16 @@
from datetime import datetime
from app.repositories.audio_repository import AudioRepository
from app.schemas.audio import AudioPoint
class AudioService:
def __init__(self, repo: AudioRepository):
self.repo = repo
async def latest(self, limit: int) -> list[AudioPoint]:
rows = await self.repo.latest(limit)
return [AudioPoint.model_validate(r, from_attributes=True) for r in rows]
async def range(self, time_from: datetime, time_to: datetime) -> list[AudioPoint]:
rows = await self.repo.range(time_from, time_to)
return [AudioPoint.model_validate(r, from_attributes=True) for r in rows]

View File

@@ -0,0 +1,54 @@
from __future__ import annotations
from datetime import datetime
from app.repositories.audio_repository import AudioRepository
from app.schemas.events import LoudEvent
class EventsService:
def __init__(self, repo: AudioRepository):
self.repo = repo
async def loud_events(
self,
threshold: float,
time_from: datetime | None,
time_to: datetime | None,
max_gap_sec: float = 1.0,
) -> list[LoudEvent]:
samples = await self.repo.loud_samples(threshold, time_from, time_to)
if not samples:
return []
events: list[LoudEvent] = []
start = samples[0].time
end = samples[0].time
max_db = samples[0].rms_db
freq = samples[0].frequency_hz
for s in samples[1:]:
gap = (s.time - end).total_seconds()
if gap <= max_gap_sec:
end = s.time
if s.rms_db > max_db:
max_db = s.rms_db
else:
events.append(
LoudEvent(
time=start,
rms_db=round(float(max_db), 2),
frequency_hz=int(freq),
duration_sec=round((end - start).total_seconds(), 2),
)
)
start, end, max_db, freq = s.time, s.time, s.rms_db, s.frequency_hz
events.append(
LoudEvent(
time=start,
rms_db=round(float(max_db), 2),
frequency_hz=int(freq),
duration_sec=round((end - start).total_seconds(), 2),
)
)
return events

View File

@@ -0,0 +1,35 @@
from datetime import datetime, timedelta
from app.repositories.audio_repository import AudioRepository
from app.schemas.stats import StatsSummary
_PERIODS = {
"10s": timedelta(seconds=10),
"1m": timedelta(minutes=1),
"1h": timedelta(hours=1),
"6h": timedelta(hours=6),
"24h": timedelta(hours=24),
"7d": timedelta(days=7),
"30d": timedelta(days=30),
}
class StatsService:
def __init__(self, repo: AudioRepository):
self.repo = repo
async def summary(self, period: str) -> StatsSummary:
if period not in _PERIODS:
raise ValueError(f"Unsupported period: {period}")
since = datetime.utcnow() - _PERIODS[period]
raw = await self.repo.summary_since(since)
total = raw["total_count"]
silence_percent = (raw["silence_count"] / total * 100.0) if total else 0.0
return StatsSummary(
avg_db=round(raw["avg_db"], 2),
max_db=round(raw["max_db"], 2),
dominant_freq=raw["dominant_freq"],
silence_percent=round(silence_percent, 2),
)

View File

@@ -0,0 +1,41 @@
from __future__ import annotations
import asyncio
from contextlib import suppress
from datetime import timezone
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.session import SessionLocal
from app.repositories.audio_repository import AudioRepository
from app.ws.manager import manager
def _iso_z(dt) -> str:
# dt ожидается timezone-aware
return dt.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
async def audio_live_broadcaster(poll_interval_sec: float = 0.2) -> None:
last_time = None
while True:
try:
async with SessionLocal() as db: # AsyncSession
repo = AudioRepository(db)
rows = await repo.latest(1)
if rows:
row = rows[0]
if last_time is None or row.time > last_time:
last_time = row.time
await manager.broadcast_json(
{
"time": _iso_z(row.time),
"rms_db": float(row.rms_db),
"freq_hz": int(row.frequency_hz),
}
)
except Exception:
# чтобы WS не умирал из-за временных проблем с БД
pass
await asyncio.sleep(poll_interval_sec)

View File

@@ -0,0 +1,39 @@
from __future__ import annotations
import asyncio
from typing import Any
from fastapi import WebSocket
class ConnectionManager:
def __init__(self) -> None:
self._connections: set[WebSocket] = set()
self._lock = asyncio.Lock()
async def connect(self, ws: WebSocket) -> None:
await ws.accept()
async with self._lock:
self._connections.add(ws)
async def disconnect(self, ws: WebSocket) -> None:
async with self._lock:
self._connections.discard(ws)
async def broadcast_json(self, payload: dict[str, Any]) -> None:
async with self._lock:
conns = list(self._connections)
to_remove: list[WebSocket] = []
for ws in conns:
try:
await ws.send_json(payload)
except Exception:
to_remove.append(ws)
if to_remove:
async with self._lock:
for ws in to_remove:
self._connections.discard(ws)
manager = ConnectionManager()

View File

@@ -0,0 +1,19 @@
from fastapi import APIRouter, WebSocket
from starlette.websockets import WebSocketDisconnect
from app.ws.manager import manager
router = APIRouter()
@router.websocket("/ws/live")
async def ws_live(ws: WebSocket) -> None:
await manager.connect(ws)
try:
# Держим соединение
while True:
await ws.receive_text()
except WebSocketDisconnect:
await manager.disconnect(ws)
except Exception:
await manager.disconnect(ws)

View File

@@ -0,0 +1,15 @@
FROM python:3.11-slim
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@@ -0,0 +1,8 @@
fastapi
uvicorn[standard]
sqlalchemy
asyncpg
pydantic
pydantic-settings
python-dateutil
websockets