File size: 5,439 Bytes
037667d
 
d65a9ae
 
 
037667d
d65a9ae
037667d
d65a9ae
037667d
 
d65a9ae
 
 
 
 
 
 
 
 
bf1bfa7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d65a9ae
bf1bfa7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d65a9ae
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
---
title: Nova
emoji: 🎙️
colorFrom: indigo
colorTo: purple
sdk: docker
app_port: 7860
pinned: false
short_description: Talk to an on-device voice agent on a Raspberry Pi
---

# nova-connect

Talk to **Nova** — a voice assistant whose speech recognition, speech synthesis and
wake word run on a Raspberry Pi 5 with a Hailo NPU — from any browser, anywhere.

This Space is only the front desk. It serves the page and signs a short-lived
LiveKit ticket after a passcode check. It never touches audio and never talks to
the Pi: the browser and the Pi each dial out to a LiveKit server and meet in a room.

### What runs where

```mermaid
flowchart TB
    subgraph B["Browser — laptop or phone, anywhere"]
        PAGE["Nova page<br/>orb · state · transcript · LLM switch · chime"]
    end

    subgraph HF["This Space — Hugging Face, Docker"]
        DESK["POST /api/session<br/>passcode → scrypt check → signed ticket"]
    end

    subgraph SFU["LiveKit server — self-hosted SFU"]
        ROOM["room nova-xxxx<br/>relays encrypted audio + data · no AI"]
    end

    subgraph PI["Raspberry Pi 5 + Hailo NPU — one nova-hailo process"]
        direction TB
        WORKER["LiveKit Agents worker<br/>registered as nova-&lt;hostname&gt;"]
        WAKE["wake word on the caller's audio<br/>openWakeWord · nova_pro_v3"]
        SESS["AgentSession<br/>Silero VAD · endpointing · interruptions"]
        STT["Parakeet STT (CPU)"]
        PIPE["NovaPipeline<br/>router · tools · search · research<br/>LLM: Groq ⇄ Qwen2 on Hailo · Piper TTS"]
        LAN["/v1/realtime — LAN door<br/>in-car Qt HMI"]
        LEASE["SessionLease — one conversation at a time"]
        WORKER --> WAKE --> SESS --> STT --> PIPE
        LAN --> PIPE
        LEASE -.- WORKER
        LEASE -.- LAN
    end

    PAGE -- "① passcode" --> DESK
    DESK -- "② url + ticket (10 min)" --> PAGE
    PAGE <-- "③ WebRTC audio + data" --> ROOM
    WORKER -. "outbound WSS, always on<br/>(no inbound port on the Pi)" .-> ROOM
    ROOM -- "④ dispatch to nova-&lt;hostname&gt;" --> WORKER
    PIPE -- "⑤ Nova's voice (PCM → Opus)" --> ROOM
```

### Wake word and follow-up

Same contract as the in-car HMI: nothing is transcribed while idle.

```mermaid
stateDiagram-v2
    [*] --> Idle: Nova joins the room
    Idle --> Listening: "Hey Nova" or tap the orb · chime
    Listening --> Thinking: you stop talking
    Thinking --> Speaking: first audio
    Speaking --> Listening: answer done · follow-up window restarts
    Speaking --> Listening: you interrupt
    Listening --> Idle: 5 s of silence (livekit.followup_s)
```

### One conversation, step by step

```mermaid
sequenceDiagram
    autonumber
    actor U as You (browser)
    participant S as This Space
    participant R as LiveKit SFU
    participant W as Pi worker
    participant N as NovaPipeline

    Note over W: at boot: registers as nova-<hostname>
    U->>S: POST /api/session {passcode}
    S-->>U: url + ticket (1 room, dispatch nova-<hostname>)
    U->>R: join room, publish mic
    R->>W: dispatch job
    W->>W: take SessionLease, or reply "Nova is in use"
    W->>R: join as agent
    W-->>U: nova.state idle
    U->>R: "Hey Nova"
    R->>W: mic audio (16 kHz copy to the wake word)
    W-->>U: nova.state listening → chime
    U->>R: "what's the weather in Chennai?"
    W->>W: VAD + endpointing → Parakeet → text
    W->>N: run_text_turn(text)
    N-->>W: Piper PCM, clause by clause
    W->>R: Nova's voice + transcript
    R-->>U: Nova speaks
    Note over U,W: 5 s of silence → nova.state idle
```

## Setting up a Pi

On the Pi, in `edge/nova-hailo`:

1. Install the extra: `uv sync --extra livekit` (plus any extras already in use).
2. Add the LiveKit server to `.env`: `LIVEKIT_URL`, `LIVEKIT_API_KEY`, `LIVEKIT_API_SECRET`.
3. Turn the online door on in the active profile config:
   ```yaml
   livekit:
     enabled: true
   ```
4. Start Nova as usual (`./scripts/run_demo_oem.sh`). The log prints the Pi's
   agent name, taken from its hostname:
   `LiveKit: registered as nova-<hostname>`.

The LAN realtime API keeps working unchanged; one conversation at a time across
both (a second caller hears "Nova is in use").

## Setting up this Space

1. Make a passcode: `python scripts/make_passcode.py` (shows the passcode once and
   prints its hash).
2. Space → Settings → **Secrets**:

   | Secret | Value |
   |---|---|
   | `LIVEKIT_URL` | the LiveKit server, `wss://…` |
   | `LIVEKIT_API_KEY` / `LIVEKIT_API_SECRET` | its key pair |
   | `NOVA_AGENT_NAME` | exactly the name the Pi printed, e.g. `nova-conmod-integration1` |
   | `NOVA_PASSCODE_HASH` | the `scrypt$…` line from step 1 |

3. `GET /api/health` shows `{"configured": true, ...}` once all five are set.

## Run locally

```bash
pip install -r requirements.txt
export LIVEKIT_URL=... LIVEKIT_API_KEY=... LIVEKIT_API_SECRET=... \
       NOVA_AGENT_NAME=nova-<hostname> NOVA_PASSCODE_HASH='scrypt$...'
python app.py          # http://localhost:7860
```

Tests: `python -m pytest tests -q`.

## Security notes

- The LiveKit API secret lives only in Space secrets and on the Pi.
- Tickets last 10 minutes, join one fresh room, may publish only the microphone
  and data, and dispatch only the configured Pi.
- Passcodes are checked against an scrypt hash; 5 attempts per 5 minutes per IP.
- Media is encrypted in transit (DTLS-SRTP); the LiveKit server can see it.