Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse files- .dockerignore +5 -0
- Dockerfile +18 -0
- README.md +70 -4
- app.py +106 -0
- requirements.txt +5 -0
- scripts/make_passcode.py +40 -0
- static/index.html +43 -0
- static/nova.css +57 -0
- static/nova.js +230 -0
- static/vendor/livekit-client.umd.min.js +0 -0
- static/wake.mp3 +0 -0
- token_service.py +107 -0
.dockerignore
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
tests/
|
| 2 |
+
scripts/
|
| 3 |
+
__pycache__/
|
| 4 |
+
*.pyc
|
| 5 |
+
.env
|
Dockerfile
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# nova-connect: the Nova web page + room-ticket service. Hugging Face Docker
|
| 2 |
+
# Space (port 7860) or any container host. Holds no models and no audio.
|
| 3 |
+
FROM python:3.12-slim
|
| 4 |
+
|
| 5 |
+
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PORT=7860
|
| 6 |
+
# HF Spaces run the container as uid 1000.
|
| 7 |
+
RUN useradd --create-home --uid 1000 app
|
| 8 |
+
WORKDIR /home/app/nova-connect
|
| 9 |
+
|
| 10 |
+
COPY --chown=app requirements.txt .
|
| 11 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 12 |
+
|
| 13 |
+
COPY --chown=app app.py token_service.py ./
|
| 14 |
+
COPY --chown=app static ./static
|
| 15 |
+
|
| 16 |
+
USER app
|
| 17 |
+
EXPOSE 7860
|
| 18 |
+
CMD ["python", "app.py"]
|
README.md
CHANGED
|
@@ -1,10 +1,76 @@
|
|
| 1 |
---
|
| 2 |
title: Nova
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
|
|
|
| 7 |
pinned: false
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
title: Nova
|
| 3 |
+
emoji: 🎙️
|
| 4 |
+
colorFrom: indigo
|
| 5 |
+
colorTo: purple
|
| 6 |
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
pinned: false
|
| 9 |
+
short_description: Talk to an on-device voice agent on a Raspberry Pi
|
| 10 |
---
|
| 11 |
|
| 12 |
+
# nova-connect
|
| 13 |
+
|
| 14 |
+
Talk to **Nova** — a voice assistant whose speech recognition, speech synthesis and
|
| 15 |
+
wake word run on a Raspberry Pi 5 with a Hailo NPU — from any browser, anywhere.
|
| 16 |
+
|
| 17 |
+
This Space is only the front desk. It serves the page and signs a short-lived
|
| 18 |
+
LiveKit ticket after a passcode check. It never touches audio and never talks to
|
| 19 |
+
the Pi: the browser and the Pi each dial out to a LiveKit server and meet in a room.
|
| 20 |
+
|
| 21 |
+
```
|
| 22 |
+
browser ─ passcode ─► this Space ─► ticket (10 min, 1 room, dispatch nova-<pi>)
|
| 23 |
+
└──── WebRTC ────► LiveKit server ◄──── outbound, always on ──── Pi (all models local)
|
| 24 |
+
```
|
| 25 |
+
|
| 26 |
+
## Setting up a Pi
|
| 27 |
+
|
| 28 |
+
On the Pi, in `edge/nova-hailo`:
|
| 29 |
+
|
| 30 |
+
1. Install the extra: `uv sync --extra livekit` (plus any extras already in use).
|
| 31 |
+
2. Add the LiveKit server to `.env`: `LIVEKIT_URL`, `LIVEKIT_API_KEY`, `LIVEKIT_API_SECRET`.
|
| 32 |
+
3. Turn the online door on in the active profile config:
|
| 33 |
+
```yaml
|
| 34 |
+
livekit:
|
| 35 |
+
enabled: true
|
| 36 |
+
```
|
| 37 |
+
4. Start Nova as usual (`./scripts/run_demo_oem.sh`). The log prints the Pi's
|
| 38 |
+
agent name, taken from its hostname:
|
| 39 |
+
`LiveKit: registered as nova-<hostname>`.
|
| 40 |
+
|
| 41 |
+
The LAN realtime API keeps working unchanged; one conversation at a time across
|
| 42 |
+
both (a second caller hears "Nova is in use").
|
| 43 |
+
|
| 44 |
+
## Setting up this Space
|
| 45 |
+
|
| 46 |
+
1. Make a passcode: `python scripts/make_passcode.py` (shows the passcode once and
|
| 47 |
+
prints its hash).
|
| 48 |
+
2. Space → Settings → **Secrets**:
|
| 49 |
+
|
| 50 |
+
| Secret | Value |
|
| 51 |
+
|---|---|
|
| 52 |
+
| `LIVEKIT_URL` | the LiveKit server, `wss://…` |
|
| 53 |
+
| `LIVEKIT_API_KEY` / `LIVEKIT_API_SECRET` | its key pair |
|
| 54 |
+
| `NOVA_AGENT_NAME` | exactly the name the Pi printed, e.g. `nova-conmod-integration1` |
|
| 55 |
+
| `NOVA_PASSCODE_HASH` | the `scrypt$…` line from step 1 |
|
| 56 |
+
|
| 57 |
+
3. `GET /api/health` shows `{"configured": true, ...}` once all five are set.
|
| 58 |
+
|
| 59 |
+
## Run locally
|
| 60 |
+
|
| 61 |
+
```bash
|
| 62 |
+
pip install -r requirements.txt
|
| 63 |
+
export LIVEKIT_URL=... LIVEKIT_API_KEY=... LIVEKIT_API_SECRET=... \
|
| 64 |
+
NOVA_AGENT_NAME=nova-<hostname> NOVA_PASSCODE_HASH='scrypt$...'
|
| 65 |
+
python app.py # http://localhost:7860
|
| 66 |
+
```
|
| 67 |
+
|
| 68 |
+
Tests: `python -m pytest tests -q`.
|
| 69 |
+
|
| 70 |
+
## Security notes
|
| 71 |
+
|
| 72 |
+
- The LiveKit API secret lives only in Space secrets and on the Pi.
|
| 73 |
+
- Tickets last 10 minutes, join one fresh room, may publish only the microphone
|
| 74 |
+
and data, and dispatch only the configured Pi.
|
| 75 |
+
- Passcodes are checked against an scrypt hash; 5 attempts per 5 minutes per IP.
|
| 76 |
+
- Media is encrypted in transit (DTLS-SRTP); the LiveKit server can see it.
|
app.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""nova-connect: the web page and the room-ticket desk for a Nova Pi.
|
| 2 |
+
|
| 3 |
+
Runs as a Hugging Face Docker Space (port 7860) or any container host. It serves
|
| 4 |
+
the static UI and one endpoint: POST /api/session with the passcode returns a
|
| 5 |
+
LiveKit URL and a 10-minute token. It holds the LiveKit API secret; the browser
|
| 6 |
+
never sees it. Audio never passes through here -- browser and Pi meet at the SFU.
|
| 7 |
+
|
| 8 |
+
Configuration (Space secrets / environment):
|
| 9 |
+
LIVEKIT_URL, LIVEKIT_API_KEY, LIVEKIT_API_SECRET -- the SFU and its key pair
|
| 10 |
+
NOVA_AGENT_NAME -- the Pi's agent name, printed by the Pi at startup
|
| 11 |
+
("LiveKit: registered as nova-<hostname>")
|
| 12 |
+
NOVA_PASSCODE_HASH -- from scripts/make_passcode.py
|
| 13 |
+
"""
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import logging
|
| 17 |
+
import os
|
| 18 |
+
from dataclasses import dataclass
|
| 19 |
+
from pathlib import Path
|
| 20 |
+
|
| 21 |
+
from fastapi import FastAPI, Request
|
| 22 |
+
from fastapi.responses import FileResponse, JSONResponse
|
| 23 |
+
from fastapi.staticfiles import StaticFiles
|
| 24 |
+
from pydantic import BaseModel
|
| 25 |
+
|
| 26 |
+
import token_service as ts
|
| 27 |
+
|
| 28 |
+
STATIC = Path(__file__).resolve().parent / "static"
|
| 29 |
+
logger = logging.getLogger("nova-connect")
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
@dataclass
|
| 33 |
+
class Settings:
|
| 34 |
+
livekit_url: str
|
| 35 |
+
api_key: str
|
| 36 |
+
api_secret: str
|
| 37 |
+
agent_name: str
|
| 38 |
+
passcode_hash: str
|
| 39 |
+
|
| 40 |
+
@classmethod
|
| 41 |
+
def from_env(cls) -> Settings:
|
| 42 |
+
e = os.environ.get
|
| 43 |
+
return cls(
|
| 44 |
+
livekit_url=e("LIVEKIT_URL", ""),
|
| 45 |
+
api_key=e("LIVEKIT_API_KEY", ""),
|
| 46 |
+
api_secret=e("LIVEKIT_API_SECRET", ""),
|
| 47 |
+
agent_name=e("NOVA_AGENT_NAME", ""),
|
| 48 |
+
passcode_hash=e("NOVA_PASSCODE_HASH", ""),
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
@property
|
| 52 |
+
def configured(self) -> bool:
|
| 53 |
+
return all(
|
| 54 |
+
(self.livekit_url, self.api_key, self.api_secret, self.agent_name, self.passcode_hash)
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
class SessionRequest(BaseModel):
|
| 59 |
+
passcode: str
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def _client_ip(request: Request) -> str:
|
| 63 |
+
# Behind the HF / reverse proxy the socket peer is the proxy; the first
|
| 64 |
+
# X-Forwarded-For hop is the caller.
|
| 65 |
+
fwd = request.headers.get("x-forwarded-for", "")
|
| 66 |
+
if fwd:
|
| 67 |
+
return fwd.split(",")[0].strip()
|
| 68 |
+
return request.client.host if request.client else "unknown"
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def create_app(settings: Settings | None = None, *, limiter: ts.RateLimiter | None = None) -> FastAPI:
|
| 72 |
+
settings = settings or Settings.from_env()
|
| 73 |
+
limiter = limiter or ts.RateLimiter()
|
| 74 |
+
app = FastAPI(title="nova-connect", docs_url=None, redoc_url=None)
|
| 75 |
+
|
| 76 |
+
@app.get("/api/health")
|
| 77 |
+
def health():
|
| 78 |
+
return {"configured": settings.configured, "agent": settings.agent_name or None}
|
| 79 |
+
|
| 80 |
+
@app.post("/api/session")
|
| 81 |
+
def session(req: SessionRequest, request: Request):
|
| 82 |
+
if not settings.configured:
|
| 83 |
+
return JSONResponse({"error": "not_configured"}, status_code=503)
|
| 84 |
+
if not limiter.allow(_client_ip(request)):
|
| 85 |
+
return JSONResponse({"error": "rate_limited"}, status_code=429)
|
| 86 |
+
if not ts.verify_passcode(req.passcode, settings.passcode_hash):
|
| 87 |
+
return JSONResponse({"error": "bad_passcode"}, status_code=401)
|
| 88 |
+
s = ts.mint_session(
|
| 89 |
+
api_key=settings.api_key, api_secret=settings.api_secret, agent_name=settings.agent_name
|
| 90 |
+
)
|
| 91 |
+
logger.info("session issued room=%s", s.room)
|
| 92 |
+
return {"url": settings.livekit_url, "token": s.token, "room": s.room, "expires_in": s.expires_in}
|
| 93 |
+
|
| 94 |
+
@app.get("/")
|
| 95 |
+
def index():
|
| 96 |
+
return FileResponse(STATIC / "index.html")
|
| 97 |
+
|
| 98 |
+
app.mount("/static", StaticFiles(directory=STATIC), name="static")
|
| 99 |
+
return app
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
if __name__ == "__main__":
|
| 103 |
+
import uvicorn
|
| 104 |
+
|
| 105 |
+
logging.basicConfig(level=logging.INFO)
|
| 106 |
+
uvicorn.run(create_app(), host="0.0.0.0", port=int(os.environ.get("PORT", "7860")))
|
requirements.txt
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Pinned to the versions the tests ran against.
|
| 2 |
+
fastapi==0.115.6
|
| 3 |
+
uvicorn==0.32.1
|
| 4 |
+
pydantic==2.13.5
|
| 5 |
+
livekit-api==1.2.1
|
scripts/make_passcode.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Create the passcode for the Nova page and the hash to store as a secret.
|
| 2 |
+
|
| 3 |
+
python scripts/make_passcode.py # generates a random passcode
|
| 4 |
+
python scripts/make_passcode.py --ask # type your own (not echoed)
|
| 5 |
+
|
| 6 |
+
Paste the printed hash into the Space secret NOVA_PASSCODE_HASH. The passcode
|
| 7 |
+
itself is shown once and stored nowhere -- share it only with people who
|
| 8 |
+
should be able to talk to the Pi.
|
| 9 |
+
"""
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import argparse
|
| 13 |
+
import getpass
|
| 14 |
+
import secrets
|
| 15 |
+
import sys
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
|
| 18 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
| 19 |
+
|
| 20 |
+
import token_service # noqa: E402
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def main() -> None:
|
| 24 |
+
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
| 25 |
+
ap.add_argument("--ask", action="store_true", help="type a passcode instead of generating one")
|
| 26 |
+
args = ap.parse_args()
|
| 27 |
+
if args.ask:
|
| 28 |
+
passcode = getpass.getpass("Passcode: ")
|
| 29 |
+
if passcode != getpass.getpass("Again: "):
|
| 30 |
+
sys.exit("passcodes differ")
|
| 31 |
+
if len(passcode) < 8:
|
| 32 |
+
sys.exit("use at least 8 characters")
|
| 33 |
+
else:
|
| 34 |
+
passcode = secrets.token_urlsafe(9)
|
| 35 |
+
print(f"Passcode (shown once): {passcode}")
|
| 36 |
+
print(f"NOVA_PASSCODE_HASH={token_service.hash_passcode(passcode)}")
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
if __name__ == "__main__":
|
| 40 |
+
main()
|
static/index.html
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
| 6 |
+
<title>Nova</title>
|
| 7 |
+
<link rel="icon" href="data:,">
|
| 8 |
+
<link rel="stylesheet" href="/static/nova.css">
|
| 9 |
+
</head>
|
| 10 |
+
<body>
|
| 11 |
+
<main id="app" data-state="idle">
|
| 12 |
+
<header class="topbar">
|
| 13 |
+
<span class="brand">NOVA</span>
|
| 14 |
+
<button id="llm" class="chip" type="button" hidden title="Switch between the Pi's Hailo model and the cloud LLM">—</button>
|
| 15 |
+
<span id="phase" class="phase">Idle</span>
|
| 16 |
+
</header>
|
| 17 |
+
|
| 18 |
+
<section id="gate" class="gate">
|
| 19 |
+
<p class="lede">Talk to Nova on its Raspberry Pi — the speech models run on the device;
|
| 20 |
+
this page only connects you to it.</p>
|
| 21 |
+
<form id="login" autocomplete="off">
|
| 22 |
+
<label for="passcode">Passcode</label>
|
| 23 |
+
<input id="passcode" type="password" required autocomplete="current-password" placeholder="Enter passcode">
|
| 24 |
+
<button id="connect" type="submit">Connect</button>
|
| 25 |
+
</form>
|
| 26 |
+
<p id="gate-msg" class="msg" role="status"></p>
|
| 27 |
+
</section>
|
| 28 |
+
|
| 29 |
+
<section id="live" class="live" hidden>
|
| 30 |
+
<div class="orb-wrap"><canvas id="orb" width="320" height="320" aria-hidden="true"></canvas></div>
|
| 31 |
+
<p id="live-msg" class="msg" role="status"></p>
|
| 32 |
+
<ol id="transcript" class="transcript" aria-live="polite"></ol>
|
| 33 |
+
<footer class="controls">
|
| 34 |
+
<button id="mute" type="button" class="ghost">Mute</button>
|
| 35 |
+
<span id="metrics" class="metrics"></span>
|
| 36 |
+
<button id="hangup" type="button" class="danger">End</button>
|
| 37 |
+
</footer>
|
| 38 |
+
</section>
|
| 39 |
+
</main>
|
| 40 |
+
<script src="/static/vendor/livekit-client.umd.min.js"></script>
|
| 41 |
+
<script src="/static/nova.js"></script>
|
| 42 |
+
</body>
|
| 43 |
+
</html>
|
static/nova.css
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* Palette and rhythm from the in-car Qt HMI (hmi_qt/NovaUI/Theme.qml). */
|
| 2 |
+
:root {
|
| 3 |
+
--base: #07080b; --surface: #0e1016; --surface-hi: #151922; --hairline: #1e2330;
|
| 4 |
+
--cyan: #4cd6ff; --violet: #b66bff; --red: #ff5e7e; --green: #4dffb4; --yellow: #ffd84d;
|
| 5 |
+
--text-hi: #eef1f7; --text-mid: #8b93a7; --text-lo: #4d5566;
|
| 6 |
+
--idle: #2a3242; --accent: var(--idle);
|
| 7 |
+
--u: 8px; --radius: 14px;
|
| 8 |
+
--font: "SF Pro Text", Inter, Roboto, system-ui, sans-serif;
|
| 9 |
+
color-scheme: dark;
|
| 10 |
+
}
|
| 11 |
+
[data-state="listening"] { --accent: var(--cyan); }
|
| 12 |
+
[data-state="thinking"] { --accent: var(--violet); }
|
| 13 |
+
[data-state="speaking"] { --accent: var(--green); }
|
| 14 |
+
[data-state="error"] { --accent: var(--red); }
|
| 15 |
+
|
| 16 |
+
* { box-sizing: border-box; }
|
| 17 |
+
html, body { margin: 0; height: 100%; background: var(--base); color: var(--text-hi); font: 15px/1.45 var(--font); }
|
| 18 |
+
#app { max-width: 560px; min-height: 100%; margin: 0 auto; padding: calc(2 * var(--u)); display: flex; flex-direction: column; }
|
| 19 |
+
|
| 20 |
+
.topbar { display: flex; align-items: center; gap: var(--u); padding-block: var(--u); }
|
| 21 |
+
.brand { font-weight: 700; letter-spacing: .28em; font-size: 14px; }
|
| 22 |
+
.phase { margin-left: auto; font-size: 12px; letter-spacing: .14em; text-transform: uppercase; color: var(--accent); transition: color .3s; }
|
| 23 |
+
[data-state="idle"] .phase { color: var(--text-lo); }
|
| 24 |
+
|
| 25 |
+
button, input { font: inherit; color: inherit; }
|
| 26 |
+
button { cursor: pointer; border: 1px solid var(--hairline); background: var(--surface-hi); border-radius: 999px; padding: 6px 14px; }
|
| 27 |
+
button:hover:not(:disabled) { border-color: var(--text-lo); }
|
| 28 |
+
button:disabled { opacity: .5; cursor: default; }
|
| 29 |
+
button:focus-visible, input:focus-visible { outline: 2px solid var(--cyan); outline-offset: 2px; }
|
| 30 |
+
.chip { font-size: 12px; color: var(--text-mid); padding: 3px 10px; }
|
| 31 |
+
.chip[data-mode="local"] { color: var(--yellow); }
|
| 32 |
+
.chip[data-mode="openrouter"] { color: var(--cyan); }
|
| 33 |
+
.ghost { background: transparent; }
|
| 34 |
+
.danger { border-color: #3a1d27; color: var(--red); }
|
| 35 |
+
|
| 36 |
+
.gate { margin-top: 18vh; }
|
| 37 |
+
.lede { color: var(--text-mid); margin: 0 0 calc(3 * var(--u)); }
|
| 38 |
+
#login { display: flex; flex-wrap: wrap; gap: var(--u); align-items: center; }
|
| 39 |
+
#login label { width: 100%; font-size: 12px; color: var(--text-mid); letter-spacing: .08em; text-transform: uppercase; }
|
| 40 |
+
#passcode { flex: 1 1 200px; min-width: 0; background: var(--surface); border: 1px solid var(--hairline); border-radius: var(--radius); padding: 10px 14px; }
|
| 41 |
+
#connect { background: var(--cyan); color: var(--base); border: 0; font-weight: 600; padding: 10px 20px; }
|
| 42 |
+
.msg { min-height: 1.45em; color: var(--text-mid); font-size: 13px; }
|
| 43 |
+
.msg.bad { color: var(--red); }
|
| 44 |
+
|
| 45 |
+
.live { flex: 1; display: flex; flex-direction: column; }
|
| 46 |
+
.orb-wrap { display: grid; place-items: center; padding-block: calc(2 * var(--u)); }
|
| 47 |
+
#orb { width: min(260px, 60vw); aspect-ratio: 1; max-width: 100%; }
|
| 48 |
+
.transcript { list-style: none; margin: 0; padding: 0; flex: 1; overflow-y: auto; max-height: 38vh; display: flex; flex-direction: column; gap: var(--u); }
|
| 49 |
+
.transcript li { background: var(--surface); border: 1px solid var(--hairline); border-radius: var(--radius); padding: 8px 12px; max-width: 88%; }
|
| 50 |
+
.transcript li.you { align-self: flex-end; color: var(--text-mid); }
|
| 51 |
+
.transcript li.nova { align-self: flex-start; }
|
| 52 |
+
.transcript li::before { display: block; font-size: 10px; letter-spacing: .14em; color: var(--text-lo); margin-bottom: 2px; }
|
| 53 |
+
.transcript li.you::before { content: "YOU"; }
|
| 54 |
+
.transcript li.nova::before { content: "NOVA"; }
|
| 55 |
+
.controls { display: flex; align-items: center; gap: var(--u); padding-top: calc(2 * var(--u)); padding-bottom: env(safe-area-inset-bottom, 0px); }
|
| 56 |
+
.metrics { flex: 1; text-align: center; font: 12px "SF Mono", "JetBrains Mono", monospace; color: var(--text-lo); }
|
| 57 |
+
#mute[aria-pressed="true"] { color: var(--yellow); border-color: var(--yellow); }
|
static/nova.js
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Nova web client: passcode -> room ticket -> LiveKit room with the Pi's agent.
|
| 2 |
+
// Audio: WebRTC via livekit-client. Controls: JSON on data topic "nova.control",
|
| 3 |
+
// the same nova.* messages the in-car HMI speaks on /v1/realtime.
|
| 4 |
+
(() => {
|
| 5 |
+
"use strict";
|
| 6 |
+
const LK = window.LivekitClient;
|
| 7 |
+
const TOPIC = "nova.control";
|
| 8 |
+
const AGENT_JOIN_TIMEOUT_MS = 20000;
|
| 9 |
+
const $ = (id) => document.getElementById(id);
|
| 10 |
+
const app = $("app");
|
| 11 |
+
|
| 12 |
+
let room = null;
|
| 13 |
+
let agent = null;
|
| 14 |
+
let chimed = false;
|
| 15 |
+
let llmMode = null;
|
| 16 |
+
let joinTimer = null;
|
| 17 |
+
let analyser = null;
|
| 18 |
+
const chime = new Audio("/static/wake.mp3");
|
| 19 |
+
|
| 20 |
+
// ---- UI helpers ---------------------------------------------------------
|
| 21 |
+
const LABELS = { listening: "Listening", thinking: "Thinking", speaking: "Speaking", error: "Error" };
|
| 22 |
+
function setState(s) {
|
| 23 |
+
app.dataset.state = s;
|
| 24 |
+
$("phase").textContent = LABELS[s] || (s === "connecting" ? "Connecting" : "Idle");
|
| 25 |
+
}
|
| 26 |
+
function say(el, text, bad = false) { el.textContent = text; el.classList.toggle("bad", bad); }
|
| 27 |
+
function addLine(who, text) {
|
| 28 |
+
const li = document.createElement("li");
|
| 29 |
+
li.className = who;
|
| 30 |
+
li.textContent = text;
|
| 31 |
+
$("transcript").append(li);
|
| 32 |
+
li.scrollIntoView({ block: "end", behavior: "smooth" });
|
| 33 |
+
}
|
| 34 |
+
function showLlm(snap) {
|
| 35 |
+
llmMode = snap.mode;
|
| 36 |
+
const b = $("llm");
|
| 37 |
+
b.hidden = false;
|
| 38 |
+
b.dataset.mode = snap.mode;
|
| 39 |
+
b.textContent = snap.mode === "local" ? "Local Hailo" : `Cloud · ${snap.llm_backend || "llm"}`;
|
| 40 |
+
b.disabled = false;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
// ---- control channel ----------------------------------------------------
|
| 44 |
+
function send(msg) {
|
| 45 |
+
if (!room) return;
|
| 46 |
+
const data = new TextEncoder().encode(JSON.stringify(msg));
|
| 47 |
+
room.localParticipant.publishData(data, { reliable: true, topic: TOPIC }).catch(() => {});
|
| 48 |
+
}
|
| 49 |
+
function onControl(msg) {
|
| 50 |
+
switch (msg.type) {
|
| 51 |
+
case "nova.settings": showLlm(msg); break;
|
| 52 |
+
case "nova.llm_status":
|
| 53 |
+
if (msg.status === "loading") { $("llm").disabled = true; $("llm").textContent = "Switching…"; }
|
| 54 |
+
if (msg.status === "error") { say($("live-msg"), `Model switch failed: ${msg.error}`, true); send({ type: "nova.settings.get" }); }
|
| 55 |
+
break;
|
| 56 |
+
case "nova.turn_metrics":
|
| 57 |
+
if (msg.ttfa_ms != null) $("metrics").textContent = `first audio ${Math.round(msg.ttfa_ms)} ms`;
|
| 58 |
+
break;
|
| 59 |
+
case "error":
|
| 60 |
+
if (msg.error?.type === "session_limit_reached") {
|
| 61 |
+
hangUp("Nova is in use right now (someone is talking to it in the car). Try again shortly.", true);
|
| 62 |
+
} else {
|
| 63 |
+
say($("live-msg"), msg.error?.message || "Error", true);
|
| 64 |
+
}
|
| 65 |
+
break;
|
| 66 |
+
}
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
// ---- orb: agent audio level ---------------------------------------------
|
| 70 |
+
function watchLevel(track) {
|
| 71 |
+
try {
|
| 72 |
+
const ctx = new (window.AudioContext || window.webkitAudioContext)();
|
| 73 |
+
const src = ctx.createMediaStreamSource(new MediaStream([track.mediaStreamTrack]));
|
| 74 |
+
analyser = ctx.createAnalyser();
|
| 75 |
+
analyser.fftSize = 256;
|
| 76 |
+
src.connect(analyser);
|
| 77 |
+
} catch { analyser = null; }
|
| 78 |
+
}
|
| 79 |
+
function drawOrb() {
|
| 80 |
+
const c = $("orb"), g = c.getContext("2d"), w = c.width, h = c.height;
|
| 81 |
+
const accent = getComputedStyle(app).getPropertyValue("--accent").trim() || "#2a3242";
|
| 82 |
+
let level = 0;
|
| 83 |
+
if (analyser) {
|
| 84 |
+
const buf = new Uint8Array(analyser.frequencyBinCount);
|
| 85 |
+
analyser.getByteFrequencyData(buf);
|
| 86 |
+
level = buf.reduce((a, b) => a + b, 0) / (buf.length * 255);
|
| 87 |
+
}
|
| 88 |
+
const t = performance.now() / 1000;
|
| 89 |
+
const r = w * 0.26 * (1 + 0.08 * Math.sin(t * 1.6) + 0.9 * level);
|
| 90 |
+
g.clearRect(0, 0, w, h);
|
| 91 |
+
const grad = g.createRadialGradient(w / 2, h / 2, r * 0.1, w / 2, h / 2, r * 1.5);
|
| 92 |
+
grad.addColorStop(0, accent);
|
| 93 |
+
grad.addColorStop(0.55, accent + "55");
|
| 94 |
+
grad.addColorStop(1, "transparent");
|
| 95 |
+
g.fillStyle = grad;
|
| 96 |
+
g.beginPath(); g.arc(w / 2, h / 2, r * 1.5, 0, Math.PI * 2); g.fill();
|
| 97 |
+
if (room) requestAnimationFrame(drawOrb);
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
// ---- room lifecycle -----------------------------------------------------
|
| 101 |
+
function isAgent(p) { return p && (p.kind === LK.ParticipantKind?.AGENT || p.identity?.startsWith("agent-")); }
|
| 102 |
+
|
| 103 |
+
function agentJoined(p) {
|
| 104 |
+
agent = p;
|
| 105 |
+
clearTimeout(joinTimer);
|
| 106 |
+
say($("live-msg"), "");
|
| 107 |
+
send({ type: "nova.settings.get" });
|
| 108 |
+
agentState(p.attributes?.["lk.agent.state"]);
|
| 109 |
+
}
|
| 110 |
+
function agentState(s) {
|
| 111 |
+
if (!s) return;
|
| 112 |
+
if (s === "listening" && !chimed) {
|
| 113 |
+
// Joining is the wake event online: one chime when Nova is first ready.
|
| 114 |
+
chimed = true;
|
| 115 |
+
chime.play().catch(() => {});
|
| 116 |
+
}
|
| 117 |
+
setState(s === "initializing" ? "connecting" : s);
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
async function connect(passcode) {
|
| 121 |
+
say($("gate-msg"), "Connecting…");
|
| 122 |
+
$("connect").disabled = true;
|
| 123 |
+
let ticket;
|
| 124 |
+
try {
|
| 125 |
+
const r = await fetch("/api/session", {
|
| 126 |
+
method: "POST", headers: { "content-type": "application/json" },
|
| 127 |
+
body: JSON.stringify({ passcode }),
|
| 128 |
+
});
|
| 129 |
+
const body = await r.json().catch(() => ({}));
|
| 130 |
+
if (r.status === 401) throw new Error("Wrong passcode.");
|
| 131 |
+
if (r.status === 429) throw new Error("Too many attempts — wait a few minutes.");
|
| 132 |
+
if (!r.ok) throw new Error(body.error === "not_configured" ? "This page is not configured yet." : `Server error (${r.status}).`);
|
| 133 |
+
ticket = body;
|
| 134 |
+
} catch (e) {
|
| 135 |
+
say($("gate-msg"), e.message, true);
|
| 136 |
+
$("connect").disabled = false;
|
| 137 |
+
return;
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
room = new LK.Room({ adaptiveStream: true, dynacast: true });
|
| 141 |
+
const E = LK.RoomEvent;
|
| 142 |
+
room.on(E.TrackSubscribed, (track, _pub, p) => {
|
| 143 |
+
if (track.kind !== "audio") return;
|
| 144 |
+
const el = track.attach();
|
| 145 |
+
el.hidden = true;
|
| 146 |
+
document.body.append(el);
|
| 147 |
+
if (isAgent(p)) watchLevel(track);
|
| 148 |
+
});
|
| 149 |
+
room.on(E.ParticipantConnected, (p) => { if (isAgent(p)) agentJoined(p); });
|
| 150 |
+
room.on(E.ParticipantDisconnected, (p) => {
|
| 151 |
+
if (p === agent) hangUp("Nova left the room.", true);
|
| 152 |
+
});
|
| 153 |
+
room.on(E.ParticipantAttributesChanged, (changed, p) => {
|
| 154 |
+
if (isAgent(p) && "lk.agent.state" in changed) agentState(changed["lk.agent.state"]);
|
| 155 |
+
});
|
| 156 |
+
room.on(E.DataReceived, (payload, _p, _kind, topic) => {
|
| 157 |
+
if (topic !== TOPIC) return;
|
| 158 |
+
try { onControl(JSON.parse(new TextDecoder().decode(payload))); } catch { /* ignore */ }
|
| 159 |
+
});
|
| 160 |
+
room.on(E.Disconnected, () => { if (room) hangUp("Disconnected.", false); });
|
| 161 |
+
room.registerTextStreamHandler("lk.transcription", async (reader, info) => {
|
| 162 |
+
const text = (await reader.readAll()).trim();
|
| 163 |
+
if (!text) return;
|
| 164 |
+
const final = reader.info?.attributes?.["lk.transcription_final"];
|
| 165 |
+
const mine = info.identity === room?.localParticipant.identity;
|
| 166 |
+
if (mine && final === "false") return; // show the user's settled words only
|
| 167 |
+
addLine(mine ? "you" : "nova", text);
|
| 168 |
+
});
|
| 169 |
+
|
| 170 |
+
try {
|
| 171 |
+
await room.connect(ticket.url, ticket.token);
|
| 172 |
+
await room.localParticipant.setMicrophoneEnabled(true);
|
| 173 |
+
} catch (e) {
|
| 174 |
+
const why = /permission|notallowed/i.test(String(e)) ? "Microphone permission was denied." : "Could not reach the voice server.";
|
| 175 |
+
room = null;
|
| 176 |
+
say($("gate-msg"), why, true);
|
| 177 |
+
$("connect").disabled = false;
|
| 178 |
+
return;
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
$("gate").hidden = true;
|
| 182 |
+
$("live").hidden = false;
|
| 183 |
+
setState("connecting");
|
| 184 |
+
say($("live-msg"), "Waiting for Nova to join from the Pi…");
|
| 185 |
+
requestAnimationFrame(drawOrb);
|
| 186 |
+
const already = [...room.remoteParticipants.values()].find(isAgent);
|
| 187 |
+
if (already) agentJoined(already);
|
| 188 |
+
else joinTimer = setTimeout(() => {
|
| 189 |
+
if (!agent) say($("live-msg"), "Nova hasn't joined — is the Pi on and online?", true);
|
| 190 |
+
}, AGENT_JOIN_TIMEOUT_MS);
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
function hangUp(message = "", bad = false) {
|
| 194 |
+
clearTimeout(joinTimer);
|
| 195 |
+
const r = room;
|
| 196 |
+
room = null; agent = null; chimed = false; analyser = null;
|
| 197 |
+
if (r) r.disconnect();
|
| 198 |
+
document.querySelectorAll("body > audio").forEach((a) => a.remove());
|
| 199 |
+
$("transcript").replaceChildren();
|
| 200 |
+
$("metrics").textContent = "";
|
| 201 |
+
$("llm").hidden = true;
|
| 202 |
+
$("live").hidden = true;
|
| 203 |
+
$("gate").hidden = false;
|
| 204 |
+
$("connect").disabled = false;
|
| 205 |
+
$("mute").setAttribute("aria-pressed", "false");
|
| 206 |
+
$("mute").textContent = "Mute";
|
| 207 |
+
setState("idle");
|
| 208 |
+
say($("gate-msg"), message, bad);
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
// ---- wiring -------------------------------------------------------------
|
| 212 |
+
$("login").addEventListener("submit", (e) => {
|
| 213 |
+
e.preventDefault();
|
| 214 |
+
chime.load(); // a user gesture: lets the chime play later on iOS/Safari
|
| 215 |
+
connect($("passcode").value);
|
| 216 |
+
});
|
| 217 |
+
$("hangup").addEventListener("click", () => hangUp());
|
| 218 |
+
$("mute").addEventListener("click", async () => {
|
| 219 |
+
if (!room) return;
|
| 220 |
+
const muted = $("mute").getAttribute("aria-pressed") === "true";
|
| 221 |
+
await room.localParticipant.setMicrophoneEnabled(muted);
|
| 222 |
+
$("mute").setAttribute("aria-pressed", String(!muted));
|
| 223 |
+
$("mute").textContent = muted ? "Mute" : "Unmute";
|
| 224 |
+
});
|
| 225 |
+
$("llm").addEventListener("click", () => {
|
| 226 |
+
const next = llmMode === "local" ? "openrouter" : "local";
|
| 227 |
+
send({ type: "nova.settings.set", settings: { mode: next } });
|
| 228 |
+
});
|
| 229 |
+
window.addEventListener("beforeunload", () => room?.disconnect());
|
| 230 |
+
})();
|
static/vendor/livekit-client.umd.min.js
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
static/wake.mp3
ADDED
|
Binary file (92.9 kB). View file
|
|
|
token_service.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Passcode check, LiveKit room tokens, and a login rate limit.
|
| 2 |
+
|
| 3 |
+
Pure functions, no network: signing a LiveKit token is local HMAC with the API
|
| 4 |
+
secret, so this service never talks to the SFU or the Pi. The browser and the
|
| 5 |
+
Pi meet at the SFU; this only hands the browser a ticket that (a) joins one
|
| 6 |
+
fresh room and (b) asks the SFU to dispatch exactly one agent -- the Pi.
|
| 7 |
+
"""
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import base64
|
| 11 |
+
import hashlib
|
| 12 |
+
import hmac
|
| 13 |
+
import secrets
|
| 14 |
+
import time
|
| 15 |
+
from collections import defaultdict, deque
|
| 16 |
+
from dataclasses import dataclass
|
| 17 |
+
from datetime import timedelta
|
| 18 |
+
|
| 19 |
+
from livekit import api
|
| 20 |
+
|
| 21 |
+
# scrypt cost: ~50 ms on a small CPU. The hash lives in a secret, not a public
|
| 22 |
+
# database, so this is defence in depth rather than the only barrier.
|
| 23 |
+
_SCRYPT = {"n": 2**14, "r": 8, "p": 1}
|
| 24 |
+
TOKEN_TTL_S = 600
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _b64(b: bytes) -> str:
|
| 28 |
+
return base64.urlsafe_b64encode(b).decode().rstrip("=")
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _unb64(s: str) -> bytes:
|
| 32 |
+
return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def hash_passcode(passcode: str) -> str:
|
| 36 |
+
"""'scrypt$n$r$p$salt$hash' -- stored as the NOVA_PASSCODE_HASH secret."""
|
| 37 |
+
salt = secrets.token_bytes(16)
|
| 38 |
+
digest = hashlib.scrypt(passcode.encode(), salt=salt, dklen=32, **_SCRYPT)
|
| 39 |
+
return f"scrypt${_SCRYPT['n']}${_SCRYPT['r']}${_SCRYPT['p']}${_b64(salt)}${_b64(digest)}"
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def verify_passcode(passcode: str, stored: str | None) -> bool:
|
| 43 |
+
"""Constant-time check. A malformed stored hash never verifies."""
|
| 44 |
+
try:
|
| 45 |
+
scheme, n, r, p, salt, want = (stored or "").split("$")
|
| 46 |
+
if scheme != "scrypt":
|
| 47 |
+
return False
|
| 48 |
+
want_b = _unb64(want)
|
| 49 |
+
got = hashlib.scrypt(
|
| 50 |
+
passcode.encode(), salt=_unb64(salt), dklen=len(want_b), n=int(n), r=int(r), p=int(p)
|
| 51 |
+
)
|
| 52 |
+
except (ValueError, TypeError):
|
| 53 |
+
return False
|
| 54 |
+
return hmac.compare_digest(got, want_b)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
@dataclass(frozen=True)
|
| 58 |
+
class Session:
|
| 59 |
+
room: str
|
| 60 |
+
identity: str
|
| 61 |
+
token: str
|
| 62 |
+
expires_in: int
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def mint_session(*, api_key: str, api_secret: str, agent_name: str) -> Session:
|
| 66 |
+
room = f"nova-{secrets.token_hex(4)}"
|
| 67 |
+
identity = f"web-{secrets.token_hex(4)}"
|
| 68 |
+
token = (
|
| 69 |
+
api.AccessToken(api_key, api_secret)
|
| 70 |
+
.with_identity(identity)
|
| 71 |
+
.with_ttl(timedelta(seconds=TOKEN_TTL_S))
|
| 72 |
+
.with_grants(
|
| 73 |
+
api.VideoGrants(
|
| 74 |
+
room_join=True,
|
| 75 |
+
room=room,
|
| 76 |
+
can_subscribe=True,
|
| 77 |
+
can_publish=True,
|
| 78 |
+
# a voice session: the mic only -- no camera, no screen share
|
| 79 |
+
can_publish_sources=["microphone"],
|
| 80 |
+
can_publish_data=True, # nova.control settings messages
|
| 81 |
+
)
|
| 82 |
+
)
|
| 83 |
+
.with_room_config(
|
| 84 |
+
api.RoomConfiguration(agents=[api.RoomAgentDispatch(agent_name=agent_name)])
|
| 85 |
+
)
|
| 86 |
+
.to_jwt()
|
| 87 |
+
)
|
| 88 |
+
return Session(room=room, identity=identity, token=token, expires_in=TOKEN_TTL_S)
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
class RateLimiter:
|
| 92 |
+
"""Sliding window per caller. In memory: one process, a demo-sized budget."""
|
| 93 |
+
|
| 94 |
+
def __init__(self, *, max_attempts: int = 5, window_s: float = 300.0) -> None:
|
| 95 |
+
self._max = max_attempts
|
| 96 |
+
self._window = window_s
|
| 97 |
+
self._hits: dict[str, deque[float]] = defaultdict(deque)
|
| 98 |
+
|
| 99 |
+
def allow(self, key: str, *, now: float | None = None) -> bool:
|
| 100 |
+
now = time.monotonic() if now is None else now
|
| 101 |
+
hits = self._hits[key]
|
| 102 |
+
while hits and now - hits[0] >= self._window:
|
| 103 |
+
hits.popleft()
|
| 104 |
+
if len(hits) >= self._max:
|
| 105 |
+
return False
|
| 106 |
+
hits.append(now)
|
| 107 |
+
return True
|