Senthi1Kumar commited on
Commit
d65a9ae
·
verified ·
1 Parent(s): 037667d

Upload folder using huggingface_hub

Browse files
.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: gray
5
- colorTo: gray
6
  sdk: docker
 
7
  pinned: false
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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