feat(api): add backend
routes and WebSockets
This commit is contained in:
12
.example.env
12
.example.env
@@ -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
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ CREATE EXTENSION IF NOT EXISTS timescaledb;
|
|||||||
-- Create audio data table
|
-- Create audio data table
|
||||||
CREATE TABLE IF NOT EXISTS audio_data (
|
CREATE TABLE IF NOT EXISTS audio_data (
|
||||||
time TIMESTAMPTZ NOT NULL,
|
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),
|
frequency_hz INTEGER CHECK (frequency_hz >= 100 AND frequency_hz <= 8000),
|
||||||
is_silence BOOLEAN NOT NULL DEFAULT FALSE
|
is_silence BOOLEAN NOT NULL DEFAULT FALSE
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -64,7 +64,11 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- audio_network
|
- audio_network
|
||||||
healthcheck:
|
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
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|||||||
15
services/api/Dockerfile
Normal file
15
services/api/Dockerfile
Normal 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"]
|
||||||
36
services/api/app/api/v1/endpoints/audio.py
Normal file
36
services/api/app/api/v1/endpoints/audio.py
Normal 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))
|
||||||
35
services/api/app/api/v1/endpoints/events.py
Normal file
35
services/api/app/api/v1/endpoints/events.py
Normal 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))
|
||||||
42
services/api/app/api/v1/endpoints/export.py
Normal file
42
services/api/app/api/v1/endpoints/export.py
Normal 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}"},
|
||||||
|
)
|
||||||
19
services/api/app/api/v1/endpoints/health.py
Normal file
19
services/api/app/api/v1/endpoints/health.py
Normal 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"})
|
||||||
23
services/api/app/api/v1/endpoints/stats.py
Normal file
23
services/api/app/api/v1/endpoints/stats.py
Normal 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)
|
||||||
9
services/api/app/api/v1/router.py
Normal file
9
services/api/app/api/v1/router.py
Normal 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"])
|
||||||
11
services/api/app/core/config.py
Normal file
11
services/api/app/core/config.py
Normal 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()
|
||||||
5
services/api/app/db/base.py
Normal file
5
services/api/app/db/base.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
12
services/api/app/db/session.py
Normal file
12
services/api/app/db/session.py
Normal 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
46
services/api/app/main.py
Normal 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()
|
||||||
14
services/api/app/models/audio_data.py
Normal file
14
services/api/app/models/audio_data.py
Normal 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)
|
||||||
74
services/api/app/repositories/audio_repository.py
Normal file
74
services/api/app/repositories/audio_repository.py
Normal 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),
|
||||||
|
}
|
||||||
9
services/api/app/schemas/audio.py
Normal file
9
services/api/app/schemas/audio.py
Normal 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
|
||||||
19
services/api/app/schemas/base.py
Normal file
19
services/api/app/schemas/base.py
Normal 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
|
||||||
9
services/api/app/schemas/events.py
Normal file
9
services/api/app/schemas/events.py
Normal 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
|
||||||
8
services/api/app/schemas/stats.py
Normal file
8
services/api/app/schemas/stats.py
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class StatsSummary(BaseModel):
|
||||||
|
avg_db: float
|
||||||
|
max_db: float
|
||||||
|
dominant_freq: int
|
||||||
|
silence_percent: float
|
||||||
16
services/api/app/services/audio_service.py
Normal file
16
services/api/app/services/audio_service.py
Normal 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]
|
||||||
54
services/api/app/services/events_service.py
Normal file
54
services/api/app/services/events_service.py
Normal 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
|
||||||
35
services/api/app/services/stats_service.py
Normal file
35
services/api/app/services/stats_service.py
Normal 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),
|
||||||
|
)
|
||||||
41
services/api/app/ws/broadcaster.py
Normal file
41
services/api/app/ws/broadcaster.py
Normal 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)
|
||||||
39
services/api/app/ws/manager.py
Normal file
39
services/api/app/ws/manager.py
Normal 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()
|
||||||
19
services/api/app/ws/router.py
Normal file
19
services/api/app/ws/router.py
Normal 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)
|
||||||
15
services/api/production.Dockerfile
Normal file
15
services/api/production.Dockerfile
Normal 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"]
|
||||||
8
services/api/requirements.txt
Normal file
8
services/api/requirements.txt
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
fastapi
|
||||||
|
uvicorn[standard]
|
||||||
|
sqlalchemy
|
||||||
|
asyncpg
|
||||||
|
pydantic
|
||||||
|
pydantic-settings
|
||||||
|
python-dateutil
|
||||||
|
websockets
|
||||||
Reference in New Issue
Block a user