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

Upload folder using huggingface_hub

Browse files
Files changed (3) hide show
  1. README.md +82 -2
  2. static/nova.css +1 -0
  3. 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
- 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
 
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-&lt;hostname&gt;"]
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-&lt;hostname&gt;" --> 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
- let chimed = false;
 
 
 
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": 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" }); }
@@ -109,12 +122,15 @@
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) {
@@ -193,7 +209,7 @@
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();
@@ -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";