Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse files- README.md +82 -2
- static/nova.css +1 -0
- static/nova.js +27 -9
README.md
CHANGED
|
@@ -18,9 +18,89 @@ 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 |
-
|
| 23 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
```
|
| 25 |
|
| 26 |
## Setting up a Pi
|
|
|
|
| 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 |
+
### What runs where
|
| 22 |
+
|
| 23 |
+
```mermaid
|
| 24 |
+
flowchart TB
|
| 25 |
+
subgraph B["Browser — laptop or phone, anywhere"]
|
| 26 |
+
PAGE["Nova page<br/>orb · state · transcript · LLM switch · chime"]
|
| 27 |
+
end
|
| 28 |
+
|
| 29 |
+
subgraph HF["This Space — Hugging Face, Docker"]
|
| 30 |
+
DESK["POST /api/session<br/>passcode → scrypt check → signed ticket"]
|
| 31 |
+
end
|
| 32 |
+
|
| 33 |
+
subgraph SFU["LiveKit server — self-hosted SFU"]
|
| 34 |
+
ROOM["room nova-xxxx<br/>relays encrypted audio + data · no AI"]
|
| 35 |
+
end
|
| 36 |
+
|
| 37 |
+
subgraph PI["Raspberry Pi 5 + Hailo NPU — one nova-hailo process"]
|
| 38 |
+
direction TB
|
| 39 |
+
WORKER["LiveKit Agents worker<br/>registered as nova-<hostname>"]
|
| 40 |
+
WAKE["wake word on the caller's audio<br/>openWakeWord · nova_pro_v3"]
|
| 41 |
+
SESS["AgentSession<br/>Silero VAD · endpointing · interruptions"]
|
| 42 |
+
STT["Parakeet STT (CPU)"]
|
| 43 |
+
PIPE["NovaPipeline<br/>router · tools · search · research<br/>LLM: Groq ⇄ Qwen2 on Hailo · Piper TTS"]
|
| 44 |
+
LAN["/v1/realtime — LAN door<br/>in-car Qt HMI"]
|
| 45 |
+
LEASE["SessionLease — one conversation at a time"]
|
| 46 |
+
WORKER --> WAKE --> SESS --> STT --> PIPE
|
| 47 |
+
LAN --> PIPE
|
| 48 |
+
LEASE -.- WORKER
|
| 49 |
+
LEASE -.- LAN
|
| 50 |
+
end
|
| 51 |
+
|
| 52 |
+
PAGE -- "① passcode" --> DESK
|
| 53 |
+
DESK -- "② url + ticket (10 min)" --> PAGE
|
| 54 |
+
PAGE <-- "③ WebRTC audio + data" --> ROOM
|
| 55 |
+
WORKER -. "outbound WSS, always on<br/>(no inbound port on the Pi)" .-> ROOM
|
| 56 |
+
ROOM -- "④ dispatch to nova-<hostname>" --> WORKER
|
| 57 |
+
PIPE -- "⑤ Nova's voice (PCM → Opus)" --> ROOM
|
| 58 |
```
|
| 59 |
+
|
| 60 |
+
### Wake word and follow-up
|
| 61 |
+
|
| 62 |
+
Same contract as the in-car HMI: nothing is transcribed while idle.
|
| 63 |
+
|
| 64 |
+
```mermaid
|
| 65 |
+
stateDiagram-v2
|
| 66 |
+
[*] --> Idle: Nova joins the room
|
| 67 |
+
Idle --> Listening: "Hey Nova" or tap the orb · chime
|
| 68 |
+
Listening --> Thinking: you stop talking
|
| 69 |
+
Thinking --> Speaking: first audio
|
| 70 |
+
Speaking --> Listening: answer done · follow-up window restarts
|
| 71 |
+
Speaking --> Listening: you interrupt
|
| 72 |
+
Listening --> Idle: 5 s of silence (livekit.followup_s)
|
| 73 |
+
```
|
| 74 |
+
|
| 75 |
+
### One conversation, step by step
|
| 76 |
+
|
| 77 |
+
```mermaid
|
| 78 |
+
sequenceDiagram
|
| 79 |
+
autonumber
|
| 80 |
+
actor U as You (browser)
|
| 81 |
+
participant S as This Space
|
| 82 |
+
participant R as LiveKit SFU
|
| 83 |
+
participant W as Pi worker
|
| 84 |
+
participant N as NovaPipeline
|
| 85 |
+
|
| 86 |
+
Note over W: at boot: registers as nova-<hostname>
|
| 87 |
+
U->>S: POST /api/session {passcode}
|
| 88 |
+
S-->>U: url + ticket (1 room, dispatch nova-<hostname>)
|
| 89 |
+
U->>R: join room, publish mic
|
| 90 |
+
R->>W: dispatch job
|
| 91 |
+
W->>W: take SessionLease, or reply "Nova is in use"
|
| 92 |
+
W->>R: join as agent
|
| 93 |
+
W-->>U: nova.state idle
|
| 94 |
+
U->>R: "Hey Nova"
|
| 95 |
+
R->>W: mic audio (16 kHz copy to the wake word)
|
| 96 |
+
W-->>U: nova.state listening → chime
|
| 97 |
+
U->>R: "what's the weather in Chennai?"
|
| 98 |
+
W->>W: VAD + endpointing → Parakeet → text
|
| 99 |
+
W->>N: run_text_turn(text)
|
| 100 |
+
N-->>W: Piper PCM, clause by clause
|
| 101 |
+
W->>R: Nova's voice + transcript
|
| 102 |
+
R-->>U: Nova speaks
|
| 103 |
+
Note over U,W: 5 s of silence → nova.state idle
|
| 104 |
```
|
| 105 |
|
| 106 |
## Setting up a Pi
|
static/nova.css
CHANGED
|
@@ -55,3 +55,4 @@ button:focus-visible, input:focus-visible { outline: 2px solid var(--cyan); outl
|
|
| 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); }
|
|
|
|
|
|
| 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); }
|
| 58 |
+
#orb { cursor: pointer; }
|
static/nova.js
CHANGED
|
@@ -11,7 +11,10 @@
|
|
| 11 |
|
| 12 |
let room = null;
|
| 13 |
let agent = null;
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
| 15 |
let llmMode = null;
|
| 16 |
let joinTimer = null;
|
| 17 |
let analyser = null;
|
|
@@ -48,7 +51,17 @@
|
|
| 48 |
}
|
| 49 |
function onControl(msg) {
|
| 50 |
switch (msg.type) {
|
| 51 |
-
case "nova.settings":
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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" }); }
|
|
@@ -109,12 +122,15 @@
|
|
| 109 |
}
|
| 110 |
function agentState(s) {
|
| 111 |
if (!s) return;
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
|
|
|
|
|
|
|
|
|
| 118 |
}
|
| 119 |
|
| 120 |
async function connect(passcode) {
|
|
@@ -193,7 +209,7 @@
|
|
| 193 |
function hangUp(message = "", bad = false) {
|
| 194 |
clearTimeout(joinTimer);
|
| 195 |
const r = room;
|
| 196 |
-
room = null; agent = null;
|
| 197 |
if (r) r.disconnect();
|
| 198 |
document.querySelectorAll("body > audio").forEach((a) => a.remove());
|
| 199 |
$("transcript").replaceChildren();
|
|
@@ -215,6 +231,8 @@
|
|
| 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";
|
|
|
|
| 11 |
|
| 12 |
let room = null;
|
| 13 |
let agent = null;
|
| 14 |
+
// Two sources of state: the Pi's wake gate (idle until "Hey Nova") and
|
| 15 |
+
// LiveKit's agent state (listening / thinking / speaking).
|
| 16 |
+
let gate = "idle";
|
| 17 |
+
let lkState = "listening";
|
| 18 |
let llmMode = null;
|
| 19 |
let joinTimer = null;
|
| 20 |
let analyser = null;
|
|
|
|
| 51 |
}
|
| 52 |
function onControl(msg) {
|
| 53 |
switch (msg.type) {
|
| 54 |
+
case "nova.settings":
|
| 55 |
+
showLlm(msg);
|
| 56 |
+
if (msg.wake_state) { gate = msg.wake_state; render(); }
|
| 57 |
+
break;
|
| 58 |
+
case "nova.state":
|
| 59 |
+
// idle -> listening is a wake: "Hey Nova", a tap, or (no wake word
|
| 60 |
+
// configured) the session itself becoming ready. Chime on that edge.
|
| 61 |
+
if (msg.state === "listening" && gate !== "listening") chime.play().catch(() => {});
|
| 62 |
+
gate = msg.state;
|
| 63 |
+
render();
|
| 64 |
+
break;
|
| 65 |
case "nova.llm_status":
|
| 66 |
if (msg.status === "loading") { $("llm").disabled = true; $("llm").textContent = "Switching…"; }
|
| 67 |
if (msg.status === "error") { say($("live-msg"), `Model switch failed: ${msg.error}`, true); send({ type: "nova.settings.get" }); }
|
|
|
|
| 122 |
}
|
| 123 |
function agentState(s) {
|
| 124 |
if (!s) return;
|
| 125 |
+
lkState = s;
|
| 126 |
+
render();
|
| 127 |
+
}
|
| 128 |
+
function render() {
|
| 129 |
+
if (!agent || lkState === "initializing") return setState("connecting");
|
| 130 |
+
const busy = lkState === "thinking" || lkState === "speaking";
|
| 131 |
+
// A closed gate shows Idle unless Nova is still talking (e.g. research).
|
| 132 |
+
setState(gate === "idle" && !busy ? "idle" : lkState);
|
| 133 |
+
say($("live-msg"), gate === "idle" && !busy ? "Say “Hey Nova” — or tap the orb." : "");
|
| 134 |
}
|
| 135 |
|
| 136 |
async function connect(passcode) {
|
|
|
|
| 209 |
function hangUp(message = "", bad = false) {
|
| 210 |
clearTimeout(joinTimer);
|
| 211 |
const r = room;
|
| 212 |
+
room = null; agent = null; gate = "idle"; lkState = "listening"; analyser = null;
|
| 213 |
if (r) r.disconnect();
|
| 214 |
document.querySelectorAll("body > audio").forEach((a) => a.remove());
|
| 215 |
$("transcript").replaceChildren();
|
|
|
|
| 231 |
connect($("passcode").value);
|
| 232 |
});
|
| 233 |
$("hangup").addEventListener("click", () => hangUp());
|
| 234 |
+
// Tap to talk: the same wake event as "Hey Nova", like the HMI's PTT.
|
| 235 |
+
$("orb").addEventListener("click", () => { if (agent && gate === "idle") send({ type: "nova.wake" }); });
|
| 236 |
$("mute").addEventListener("click", async () => {
|
| 237 |
if (!room) return;
|
| 238 |
const muted = $("mute").getAttribute("aria-pressed") === "true";
|