add docs, protocol, plugins, README, CHANGELOG

This commit is contained in:
loki5512344 2026-07-09 12:30:55 +02:00
parent 1d2209bfac
commit f262da222b
Signed by: boba
GPG key ID: 253067914055423B
44 changed files with 9269 additions and 0 deletions

View file

@ -0,0 +1,85 @@
# LNEx Protocol
> **Phase 1:** JSON over plain TCP, raw Opus over UDP. Wire encryption is Phase 2.
> See [00-status.md](../00-status.md).
LNEx (Loki Network Exchange) is the networking protocol used by VNOX.
It is a custom application-layer protocol that runs over TCP and UDP.
It defines how VNOX clients and servers communicate — packet format,
encryption, routing, identity, and federation.
---
## Design goals
- low latency for voice packets (UDP path)
- reliable delivery for control messages (TCP path)
- encrypted by default in production (Phase 2 target; plaintext in v0.1.x dev builds)
- identity without central authority: keypair-based, no email, no server account registry
- federation between independent nodes (Phase 3)
## Non-goals
- backward compatibility with Discord, TeamSpeak, or Matrix
- browser / WebSocket native support (use a gateway adapter if needed)
- guaranteed ordering on the UDP voice path
---
## Version
Current version: `LNEx v1`
Version is negotiated during handshake. Clients and servers advertise
their supported versions. If no common version exists, the connection
is rejected with `ERR_VERSION_MISMATCH`.
LNEx versioning is independent from VNOX client and server versioning.
A new VNOX release may or may not bump the LNEx version.
---
## Transport mapping
| Path | Transport | Used for |
|------|-----------|---------|
| Control | TCP | auth, chat, channels, events, federation |
| Voice | UDP | audio frames, realtime state |
Both paths use LNEx packet framing. The packet format is the same;
the transport-specific behavior (ordering, retransmit) is left to TCP/UDP.
---
## Connection lifecycle
```
Client Gateway
│ │
│──── TCP connect ───────────────▶│
│◀─── HELLO (server pubkey) ──────│
│──── AUTH (client pubkey + sig) ─▶│
│◀─── SESSION (session_id, token) ─│
│ │
│ [control channel established] │
│ │
│──── JOIN_CHANNEL ──────────────▶│
│◀─── CHANNEL_STATE ──────────────│
│ │
│ [UDP voice path] │
│──── UDP VOICE_PACKET ──────────▶│ Voice Node
│◀─── UDP VOICE_PACKET ───────────│
```
Full state machine: see individual spec files.
---
## Sections
- [packets.md](packets.md) — packet format, base and voice packets, Protobuf schema
- [voice-pipeline.md](voice-pipeline.md) — Opus codec, UDP relay, jitter buffer
- [federation/README.md](federation/README.md) — node discovery, cross-node routing
- [identity.md](identity.md) — keypair model, auth flow, permissions
- [security.md](security.md) — encryption, threat model

View file

@ -0,0 +1,174 @@
# Federation
> Status: Phase 3 — design draft, not yet implemented.
> This document describes the intended design. Details may change.
---
## Goal
Federation allows independent VNOX nodes to communicate with each other.
Users on one node can join channels, send messages, and voice-chat with
users on a different node — without either node being subordinate to the other.
There is no central federation server. Every node is equal.
---
## Identity in federation
Federated identity format:
```
user@node
```
Examples:
```
raven@nightcore.lnex
0xmist@dev.vnox.io
you@192.168.1.10
```
The `node` part is a resolvable address: domain, IP, or LNEx node ID.
The `user` part is the short form of the user's pubkey (or their chosen nickname,
disambiguated by pubkey if collision).
---
## What can be federated
| Feature | Status |
|---------|--------|
| Text chat bridging | Phase 3 |
| Voice relay across nodes | Phase 3 |
| Channel sharing | Phase 3 |
| Identity sync | Phase 3 |
| Permissions across nodes | Phase 4 |
| Encrypted federation | Phase 4 |
---
## Node discovery
Three strategies are planned. A node may use any combination.
### 1. Bootstrap list
A static list of known nodes in `config.toml`:
```toml
[federation]
bootstrap = [
"relay.nightcore.lnex",
"192.168.1.20:7700",
]
```
Simple and predictable. Suitable for private networks.
### 2. DNS SRV records
A node publishes its LNEx endpoint via DNS:
```
_lnex._udp.nightcore.lnex. SRV 0 0 7700 nightcore.lnex.
```
Allows discovery via domain name without hardcoded IPs.
### 3. DHT (future)
For fully decentralized discovery without any bootstrap list or DNS.
Not planned until Phase 4.
---
## Federation routing
When a client on node A wants to reach node B:
```
Client (node A)
│
▼
Gateway A ──── resolves node B address (DNS / bootstrap)
│
▼
LNEx Federation handshake (node A → node B)
│ mutual auth: exchange pubkeys
│ negotiate: shared channels, relay policy
▼
Established federation link
│
├── text: messages forwarded via TCP federation channel
└── voice: packets relayed via UDP federation relay path
```
### Federation link lifecycle
1. Node A initiates connection to node B
2. Both nodes authenticate using their node keypairs
3. Nodes exchange a list of channels available for bridging
4. A federation session is established with a session token
5. Session is kept alive with periodic PING/PONG
6. On disconnect, pending messages are queued for retry
---
## Voice relay across nodes
```
Client A (node A)
│ UDP
▼
Voice Node A
│ UDP (federation relay path)
▼
Voice Node B
│ UDP
▼
Client B (node B)
```
Voice packets are forwarded between voice nodes using the same
LNEx UDP packet format. The federation relay path adds one hop
and ~1–10ms additional latency depending on network distance.
---
## Channel bridging
A channel can be bridged between two nodes. Bridged channels appear
in both node's channel lists. Messages and voice from either side
flow through the federation link.
```toml
# On node A config
[federation.bridges]
"#general" = "nightcore.lnex/#general"
```
---
## Anti-spam and rate limiting
Federation links are subject to rate limiting to prevent a rogue node
from flooding a target node.
Limits (configurable):
- max federation connections per node: 16
- max bridged channels: 64
- message rate per federation link: 100/s
- voice packet rate per relayed user: standard voice rate
A node can block or denylist other nodes in `config.toml`.
---
## Open questions (TODO)
- How to handle split-brain: node B is temporarily unreachable during a conversation
- Message history sync across federated nodes
- Permission model for federated users (what can a `user@external-node` do on your node)
- Trust levels: `trusted` / `untrusted` / `blocked` per federation link

View file

@ -0,0 +1,169 @@
# Identity
> **Phase 1 note:** the client stores the keypair as plain JSON in the data directory.
> Passphrase encryption (Argon2id), seed phrase UI, and keyfile export are Phase 2.
## Model
VNOX has no accounts. No email. No phone number. No central registry.
On first launch, the client generates a keypair locally:
```json
{
"id": "4f3a8b2c9d1e7a0f3b5c8d2e1a9f4b7c2d8e3f1a",
"nickname": "user",
"pubkey": "ed25519:4f3a8b2c...",
"created_at": 1716000000
}
```
The private key never leaves the device (unless explicitly exported by the user).
The public key is the identity. Everything — permissions, history, session tokens —
is tied to the pubkey.
---
## Keypair
Algorithm: Ed25519
- fast signature verification
- small key size (32 bytes public, 64 bytes private)
- widely supported in Rust ecosystem (dalek-cryptography)
The keypair is stored locally in the client's data directory.
**Phase 1 (current):** plain JSON file (`identity.json`).
**Phase 2 (planned):** encrypted at rest with a user-chosen passphrase (Argon2id KDF).
---
## Auth flow
```
Client Gateway
│ │
│── TCP connect ────────────────────▶│
│ │
│◀── HELLO { server_pubkey, │
│ challenge_nonce } ──────│
│ │
│── AUTH { │
│ client_pubkey, │
│ nickname, │
│ lnex_version, │
│ sig: sign(challenge_nonce, │
│ client_privkey) │
│ } ──────────────────────────────▶│
│ │ verify signature
│ │ check if banned
│◀── SESSION { │
│ session_id, │
│ token, │
│ expires_at │
│ } ──────────────────────────────│
│ │
│ [authenticated] │
```
The gateway verifies the signature against the challenge nonce.
If valid, a session token is issued. The token is used for subsequent
requests within the TCP session.
The gateway does not store private keys. It only stores public keys
of users who have connected (for banning and permission assignment).
---
## Session
Sessions are in-memory on the gateway. They expire when the TCP connection closes
or after a configurable idle timeout.
There is no persistent login. Each connection requires a fresh auth exchange.
The session token is not a password — it proves nothing without the underlying
keypair.
---
## Nickname
Nickname is a human-readable label, not unique. Two users may have the same nickname.
The pubkey (or its short form) is the unique identifier.
Nickname is self-asserted and can be changed at any time.
Servers may impose length or character limits.
---
## Permissions
Permissions are assigned to pubkeys on each node independently.
There is no global permission registry.
Permission levels (example model — configurable per node):
```
guest — read channels, no voice
member — read + write + voice
moderator — member + kick + mute others + manage channels
admin — full control of the node
owner — same as admin, cannot be demoted by admins
```
A user's permission level on node A has no bearing on their level on node B.
Federated permission model is a Phase 4 design question.
---
## Keypair backup
> **Phase 2.** UI and export flows below are not implemented in v0.1.x.
If the keypair is lost, the identity is lost. There is no recovery
without a backup. This is intentional. There is no central authority
to reset your account.
Two backup methods are planned:
### Seed phrase
A 24-word BIP39-compatible mnemonic derived from the private key entropy.
```
word1 word2 word3 ... word24
```
The seed phrase can regenerate the keypair deterministically.
Store it offline. Never share it.
To enable: Settings → Identity → Seed phrase backup → Show phrase.
### Encrypted keyfile
Export the keypair as a `.vnox` file, encrypted with a passphrase
(AES-256-GCM, passphrase stretched with Argon2id).
```
Settings → Identity → Export keypair → Save as .vnox
```
To restore: launch client → Import keypair → select .vnox file → enter passphrase.
---
## Rotating the keypair
If a keypair is compromised, the user can generate a new one.
The old keypair becomes inactive immediately.
Effects:
- new identity on all nodes (the old pubkey is a different person)
- permissions tied to old pubkey remain on the server (admins can clean up)
- history attributed to old pubkey is not migrated
Rotation is permanent and cannot be undone.
`Settings → Identity → Rotate keypair → Confirm`

266
docs/02-protocol/packets.md Normal file
View file

@ -0,0 +1,266 @@
# Packets
> **Phase 1 note:** TCP control payloads are serialized as **JSON**, not Protobuf.
> Protobuf is the Phase 2 target. See [01-architecture.md](../01-architecture.md).
## Base packet
All LNEx packets share a common header, regardless of transport.
```
┌──────────────┬──────────────┬──────────────┬────────────────┬─────────┐
│ packet_id │ flags │ sequence │ payload_length │ payload │
│ (2 bytes) │ (2 bytes) │ (4 bytes) │ (4 bytes) │ (var) │
└──────────────┴──────────────┴──────────────┴────────────────┴─────────┘
```
### Fields
`packet_id` — identifies the packet type. See packet type registry below.
`flags` — bitmask:
```
bit 0 — COMPRESSED payload is zstd compressed
bit 1 — ENCRYPTED payload is encrypted (ChaCha20-Poly1305)
bit 2 — FRAGMENTED this is a fragment of a larger payload
bit 3 — LAST_FRAG this is the last fragment
bit 4 — ACK_REQ sender requests acknowledgement (TCP path only)
bit 5-15 — reserved
```
`sequence` — monotonically increasing per-session counter.
On the UDP voice path, gaps in sequence indicate packet loss.
`payload_length` — byte length of the payload that follows.
`payload` - packet-type-specific data. **Phase 1:** JSON. **Phase 2:** Protobuf (planned).
---
## Voice packet
Voice packets are sent over UDP. They extend the base header with
audio-specific fields before the Opus payload.
```
┌──────────────┬──────────────┬──────────────┬────────────────┬────────────────┬────────────┐
│ packet_id │ flags │ voice_seq │ timestamp │ channel_id │ opus_data │
│ = 0x0010 │ (2 bytes) │ (4 bytes) │ (4 bytes) │ (8 bytes) │ (var) │
└──────────────┴──────────────┴──────────────┴────────────────┴────────────────┴────────────┘
```
### Fields
`voice_seq` — separate sequence counter for the voice stream.
Resets per channel join. Used by jitter buffer for reordering.
`timestamp` — RTP-style timestamp in samples (48000 Hz clock).
Used for jitter buffer and playout scheduling.
`channel_id` — identifies which voice channel this frame belongs to.
Allows a single UDP socket to carry multiple channels.
`opus_data` — raw Opus-encoded frame. Length derived from `payload_length`
minus the fixed voice header (16 bytes).
---
## Packet type registry
```
0x0001 HELLO server → client on connect
0x0002 AUTH client → server, identity + signature
0x0003 SESSION server → client, session established
0x0004 PING either direction
0x0005 PONG reply to PING
0x0010 VOICE_PACKET UDP, client ↔ voice-node
0x0011 VOICE_STATE speaking / silent / muted
0x0020 CHAT_MESSAGE text message in channel
0x0021 CHAT_HISTORY batch of historical messages
0x0030 JOIN_CHANNEL client → gateway
0x0031 LEAVE_CHANNEL client → gateway
0x0032 CHANNEL_STATE gateway → client, full channel snapshot
0x0040 USER_JOIN broadcast to channel members
0x0041 USER_LEAVE broadcast to channel members
0x0050 PERMISSION_CHECK gateway → client
0x0051 PERMISSION_DENY gateway → client
0x0060 DM_START client → gateway, initiate 1:1 DM conversation
0x0061 DM_MESSAGE client ↔ gateway, individual DM message
0x0062 DM_HISTORY client → gateway, fetch last 50 messages
0x0100 GUILD_CREATE client → gateway
0x0101 GUILD_DELETE client → gateway
0x0102 GUILD_LIST client → gateway
0x0103 GUILD_MEMBER_JOIN client → gateway
0x0104 GUILD_MEMBER_LEAVE client → gateway
0x0105 GUILD_MEMBER_KICK client → gateway
0x0106 ROLE_CREATE client → gateway
0x0107 ROLE_DELETE client → gateway
0x0108 INVITE_CREATE client → gateway
0x0109 INVITE_ACCEPT client → gateway
0x010A INVITE_DELETE client → gateway
0x0140 PRESENCE_UPDATE client → gateway
0x0141 PRESENCE_SYNC gateway → client
0x0142 PRESENCE_EVENT gateway → client
0x0150 FRIEND_REQUEST client → gateway
0x0151 FRIEND_ACCEPT client → gateway
0x0152 FRIEND_DECLINE client → gateway
0x0153 FRIEND_REMOVE client → gateway
0x0154 FRIEND_LIST client → gateway
0x0155 BLOCK_USER client → gateway
0x0156 UNBLOCK_USER client → gateway
0x0157 BLOCK_LIST client → gateway
0x00F0 ERROR any direction, see error codes
0x00FF DISCONNECT graceful close
```
---
## Direct Messages (Phase 1.1)
DM support enables 1:1 private messaging with persistent history. DMs are identified by a canonical ID format:
`dm_{lexicographically_smaller_uid}_{larger_uid}`. This ensures both participants reference the same conversation.
### DmStart (0x0060)
Client initiates or opens an existing DM conversation with another user.
```json
{
"target_user_id": "user_pubkey_b64"
}
```
Response (server sends back as 0x0060):
```json
{
"dm_id": "dm_userid1_userid2",
"other_user_id": "user_pubkey_b64",
"other_nickname": "Alice",
"messages": [
{
"dm_id": "dm_userid1_userid2",
"sender_id": "user_pubkey_b64",
"content": "Hello!",
"timestamp": 1234567890000
}
]
}
```
### DmMessage (0x0061)
Send a new DM or receive one from another user.
```json
{
"dm_id": "dm_userid1_userid2",
"sender_id": "user_pubkey_b64",
"content": "Hello Alice!",
"timestamp": 1234567890000
}
```
Server-authoritative: sender_id and timestamp are set by the server based on the authenticated session.
### DmHistory (0x0062)
Fetch historical messages from a DM.
Request:
```json
{
"dm_id": "dm_userid1_userid2"
}
```
Response:
```json
{
"dm_id": "dm_userid1_userid2",
"messages": [
{ "dm_id": "...", "sender_id": "...", "content": "...", "timestamp": 1234567890000 }
]
}
```
---
## Protobuf schema
Payload of each packet is a serialized Protobuf message.
Schemas live in `protocol/` at the repository root.
Example — `ChatMessage`:
```protobuf
syntax = "proto3";
message ChatMessage {
string message_id = 1; // UUID v4
string channel_id = 2;
string sender_id = 3; // sender pubkey (short form)
string content = 4;
int64 timestamp = 5; // Unix ms
}
```
Example — `VoiceState`:
```protobuf
message VoiceState {
string user_id = 1;
string channel_id = 2;
enum State {
SILENT = 0;
SPEAKING = 1;
MUTED = 2;
DEAFENED = 3;
}
State state = 3;
}
```
Full schema reference: `protocol/*.proto`
---
## Fragmentation
Payloads larger than 1400 bytes on the UDP path are fragmented.
Each fragment carries the same `sequence`, with `FRAGMENTED` and optionally
`LAST_FRAG` flags set. The receiver reassembles before passing to the
application layer.
On TCP, fragmentation is not used — TCP handles it at the transport layer.
---
## Error codes
```
0x01 ERR_VERSION_MISMATCH unsupported LNEx version
0x02 ERR_AUTH_FAILED invalid signature or unknown identity
0x03 ERR_SESSION_EXPIRED session token no longer valid
0x04 ERR_PERMISSION_DENIED insufficient permissions for action
0x05 ERR_CHANNEL_NOT_FOUND channel does not exist on this node
0x06 ERR_RATE_LIMITED too many packets in window
0x07 ERR_INVALID_PACKET malformed header or payload
0x08 ERR_NODE_UNAVAILABLE federation target node unreachable
0x09 ERR_GUILD_NOT_FOUND guild does not exist
0x0A ERR_BLOCKED you are blocked by this user
0xFF ERR_INTERNAL server-side error
```

View file

@ -0,0 +1,150 @@
# Security
> **Phase 1.1 status.** TCP traffic is encrypted with ChaCha20-Poly1305 AEAD.
> UDP voice path is still plaintext (Phase 2). See
> [features/encryption.md](../05-features/encryption.md) for details.
## Threat model
VNOX is designed for self-hosted deployments where the node operator is trusted.
The threat model covers:
| Threat | Mitigated by |
|--------|--------------|
| MITM on client-server connection | LNEx packet encryption (ChaCha20-Poly1305) active on TCP |
| MITM on UDP voice path | LNEx packet encryption (Phase 2; not active in v0.1.x) |
| Rogue node in federation | Mutual keypair authentication on federation handshake (Phase 3) |
| Identity spoofing | Ed25519 signature on every auth (challenge-response) |
| Metadata leakage (who talks to whom) | Phase 2 |
| Passive voice interception | Packet encryption (Phase 2; not active in v0.1.x) |
| Replay attacks | Sequence number + nonce per packet (Phase 2 wire encryption) |
| Brute-force on session | Sessions are short-lived, token is not a password |
### Out of scope
- Physical access to the server
- Compromised node operator (they own the node, this is by design)
- E2EE between clients (planned Phase 2, not in v1)
- Anonymity / traffic analysis resistance
---
## Encryption
> **Phase 1.1 target.** See [features/encryption.md](../05-features/encryption.md) for the implementation plan.
> Phase 1 uses JSON over plain TCP and raw Opus over UDP.
### LNEx packet encryption
All LNEx packets (both TCP and UDP paths) are encrypted at the LNEx layer.
Algorithm: **ChaCha20-Poly1305** (AEAD)
- ChaCha20 for stream cipher
- Poly1305 for authentication tag (16 bytes)
- 96-bit nonce, derived from: `session_id || sequence`
- Key derived from ECDH exchange during handshake (X25519)
ChaCha20-Poly1305 is chosen over AES-GCM because:
- constant-time on all platforms (no hardware AES requirement)
- faster in software on hardware without AES-NI (common on ARM)
- simpler nonce management
### Key exchange
During the LNEx handshake:
1. Server sends its ephemeral X25519 public key in HELLO
2. Client generates its own ephemeral X25519 keypair
3. Both compute the shared secret via X25519 ECDH
4. Shared secret is passed through HKDF-SHA256 to derive:
- client → server encryption key
- server → client encryption key
Ephemeral keys are discarded after the session. This provides
**forward secrecy** — compromising the long-term identity keypair
does not expose past sessions.
### TCP transport
TCP connections additionally use TLS 1.3.
LNEx packet encryption runs inside TLS — defense in depth.
TLS certificate: self-signed by default, pinned on first connect (TOFU).
Node operators may configure a proper CA-signed certificate.
### UDP transport
UDP has no TLS. LNEx packet encryption (ChaCha20-Poly1305) is the only
protection layer on the voice path. This is standard practice for
real-time voice protocols (SRTP, DTLS-SRTP follow the same model).
---
## E2EE (Phase 2)
In v1, encryption is between client and server (node). The node operator
can theoretically decrypt voice and text in transit.
Phase 2 will introduce optional end-to-end encryption for:
- direct messages
- private channels (opt-in)
E2EE for voice is significantly harder (requires key distribution to all
channel members in real time) and is a Phase 4 design question.
---
## Identity verification
When user A sees a message from `raven@nightcore.lnex`, how do they know
it's the same raven they spoke to yesterday?
In v1: the gateway enforces that a connected user's pubkey matches their
asserted identity. The client can verify the server's claim by checking
the pubkey shown in the UI against a known value.
Future: out-of-band key verification (QR code, safety number, similar to Signal).
---
## Rate limiting and anti-flood
Applied at the gateway level:
| Limit | Default | Configurable |
|-------|---------|-------------|
| Auth attempts per IP | 5 / minute | yes |
| Messages per user per second | 10 | yes |
| Voice packet rate per user | ~50/s (20ms frames) | no (codec-determined) |
| Federation connection attempts | 3 / minute per remote | yes |
| Max concurrent connections per IP | 4 | yes |
Exceeding a rate limit returns `ERR_RATE_LIMITED` and may trigger a
temporary ban depending on node configuration.
---
## Node operator notes
### What the operator can see
- IP addresses of connected clients
- usernames (nicknames) and pubkeys
- channel activity (who joined when)
- message content (in v1, no E2EE)
### What the operator cannot do (by design)
- impersonate a user's identity (requires their private key)
- forge signatures on behalf of a user
### Recommended hardening
- run gateway behind a reverse proxy (nginx / caddy) for TLS termination
- restrict UDP port to known IP ranges if possible
- enable fail2ban or equivalent on auth failure logs
- back up the node keypair (used for federation identity)
- rotate node keypair on suspected compromise
See `03-server/operations.md` for hardening checklist.

View file

@ -0,0 +1,217 @@
# State Machine
> **Phase 1 note:** the gateway and voice-node do not share membership state.
> The voice node registers a client on the first UDP packet and drops idle addresses
> after 30 seconds. Diagrams that show `gateway notify voice node` describe the Phase 2 target.
Client and server maintain synchronized state machines over the LNEx connection.
This document describes the states, transitions, and what triggers them.
---
## Client states
```
┌─────────────────┐
│ DISCONNECTED │ ◄─── initial state / after disconnect
└────────┬────────┘
│ connect(address)
▼
┌─────────────────┐
│ CONNECTING │ TCP handshake in progress
└────────┬────────┘
│ TCP established
▼
┌─────────────────┐
│ HANDSHAKING │ HELLO received, sending AUTH
└────────┬────────┘
│ SESSION received
▼
┌─────────────────┐
│ CONNECTED │ ◄─── idle, no channel joined
└────────┬────────┘
│ join_channel()
▼
┌─────────────────┐
┌────►│ IN_CHANNEL │ text + voice available
│ └────────┬────────┘
│ │ leave_channel() / switch channel
└──────────────┘
│ disconnect() / TCP drop
▼
┌─────────────────┐
│ RECONNECTING │ exponential backoff (Phase 2)
└────────┬────────┘
│ max retries exceeded
▼
┌─────────────────┐
│ DISCONNECTED │
└─────────────────┘
```
Any state can transition to `DISCONNECTED` on:
- TCP connection drop
- `DISCONNECT` packet received
- auth failure (`ERR_AUTH_FAILED`)
- version mismatch (`ERR_VERSION_MISMATCH`)
---
## Server session states
Per-client session on the gateway:
```
┌─────────────────┐
│ ACCEPTING │ TCP connection received, sending HELLO
└────────┬────────┘
│ AUTH received
▼
┌─────────────────┐
│ AUTHENTICATING │ verifying Ed25519 signature
└────────┬────────┘
│ signature valid + not banned
▼
┌─────────────────┐
│ ESTABLISHED │ SESSION sent, client is authenticated
└────────┬────────┘
│ JOIN_CHANNEL received
▼
┌─────────────────┐
┌────►│ IN_CHANNEL │ forwarding messages + voice state
│ └────────┬────────┘
│ │ LEAVE_CHANNEL / JOIN different channel
└──────────────┘
│ TCP drop / DISCONNECT / idle timeout
▼
┌─────────────────┐
│ TERMINATED │ session cleaned up, resources freed
└─────────────────┘
```
### Auth failure paths
```
AUTHENTICATING
│ invalid signature → send ERR_AUTH_FAILED → TERMINATED
│ banned pubkey → send ERR_AUTH_FAILED → TERMINATED
│ unsupported version → send ERR_VERSION_MISMATCH → TERMINATED
│ rate limit on auth → send ERR_RATE_LIMITED → TERMINATED
```
---
## Voice session states (per user, on voice-node)
> **Phase 1:** transition from `INACTIVE` to `JOINING` happens when the first UDP
> packet arrives from a client address, not when the gateway sends a signal.
```
┌─────────────────┐
│ INACTIVE │ user not in a voice channel
└────────┬────────┘
│ first UDP packet from client (Phase 1)
│ gateway signals join (Phase 2, not implemented)
▼
┌─────────────────┐
│ JOINING │ UDP path being established
└────────┬────────┘
│ first UDP packet received from client
▼
┌─────────────────┐
│ SILENT │ in channel, not transmitting
└────────┬────────┘
│ voice packets arriving
▼
┌─────────────────┐
┌────►│ SPEAKING │ relaying to other channel members
│ └────────┬────────┘
│ │ DTX / push-to-talk released / silence
└──────────────┘
┌─────────────────┐
│ MUTED │ server-side mute (moderator action)
└────────┬────────┘
│ unmuted by moderator
▼
┌─────────────────┐
│ SILENT │
└─────────────────┘
```
Voice state is broadcast to all channel members as `VOICE_STATE` packets
on every transition: `SILENT → SPEAKING`, `SPEAKING → SILENT`, `MUTED`, `DEAFENED`.
---
## Channel join sequence (detailed)
Full flow from `join_channel()` call to receiving audio:
```
Client Gateway Voice Node
│ │ │
│── JOIN_CHANNEL ─────────►│ │
│ │ check permissions │
│ │ check channel exists │
│◄─ CHANNEL_STATE ─────────│ │
│ { members, │ │
│ voice_endpoint, │ (Phase 2: gateway │
│ channel_id } │ notifies voice node) │
│ │ │
│ [start UDP] │ │
│── UDP VOICE_PACKET ──────────────────────────────── ►│
│ │ │ relay to others
│◄─ UDP VOICE_PACKET ───────────────────────────────── │
│ │ │
│◄─ USER_JOIN broadcast ───│ │
│ (sent to all members) │ │
```
If `ERR_PERMISSION_DENIED` is returned on `JOIN_CHANNEL`, the client
stays in `CONNECTED` state and does not attempt UDP.
---
## Reconnect logic (Phase 2)
On unexpected TCP drop from `IN_CHANNEL` or `CONNECTED`:
```
disconnect detected
│
▼
RECONNECTING state
│
├── attempt 1: wait 1s
├── attempt 2: wait 2s
├── attempt 3: wait 4s
├── attempt 4: wait 8s
├── attempt 5: wait 16s
└── attempt 6+: wait 30s (cap)
```
On successful reconnect:
- full auth exchange (new session token)
- if user was `IN_CHANNEL` before disconnect: auto-rejoin same channel
On reconnect failure after N attempts (configurable, default 10):
- transition to `DISCONNECTED`
- notify user in UI
---
## Packet validity by state
Packets received in an unexpected state are dropped with `ERR_INVALID_PACKET`.
| Packet | Valid in states |
|--------|----------------|
| `HELLO` | server sends in `ACCEPTING` |
| `AUTH` | client sends in `HANDSHAKING` |
| `SESSION` | server sends in `AUTHENTICATING` |
| `JOIN_CHANNEL` | `CONNECTED`, `IN_CHANNEL` |
| `LEAVE_CHANNEL` | `IN_CHANNEL` |
| `CHAT_MESSAGE` | `IN_CHANNEL` |
| `VOICE_PACKET` | UDP, user in voice channel |
| `PING` / `PONG` | any authenticated state |
| `DISCONNECT` | any state |

View file

@ -0,0 +1,197 @@
# Voice Pipeline
> **Phase 1 note:** capture, Opus encode/decode, and UDP relay exist in the repo.
> RNNoise, echo cancellation, VAD, and jitter buffer integration are Phase 2 or not wired yet.
> See [00-status.md](../00-status.md).
## Overview
```
Microphone
│
▼
PCM Capture (cpal)
│ 48000 Hz, mono, f32
▼
Pre-processing
│ noise suppression (RNNoise) - Phase 2
│ echo cancellation - Phase 2
│ VAD (voice activity detection) - Phase 2
▼
Opus Encode
│ frame: 10 / 20 / 40ms
│ bitrate: 8–128 kbps (default 64k)
│ mode: VOIP (optimized for speech)
▼
LNEx Voice Packet
│ header + opus_data
│ encrypted + compressed (Phase 2; plaintext in v0.1.x)
▼
UDP → Voice Node
│
▼
Jitter Buffer
│ reorder by voice_seq (code exists; not used in relay yet)
│ schedule by timestamp
│ adaptive size: 20–80ms
▼
Opus Decode
│ packet loss concealment if gap in sequence
▼
PCM Output
│
▼
Playback (cpal / rodio)
```
---
## Codec
### Opus
VNOX uses Opus exclusively for voice encoding.
Parameters:
| Setting | Value | Notes |
|---------|-------|-------|
| Sample rate | 48000 Hz | Opus native rate |
| Channels | 1 (mono) | stereo optional in Phase 2 |
| Application | VOIP | optimized for speech, lower complexity |
| Bitrate | 8–128 kbps | default 64k, user-configurable |
| Frame size | 20ms default | configurable: 10 / 20 / 40ms |
| FEC | enabled | forward error correction for packet loss |
| DTX | enabled | discontinuous transmission, silence suppression |
Lower frame size = lower latency, higher CPU and packet rate.
Recommended: 20ms for balance, 10ms for ultra-low latency setups.
### Why Opus
- royalty-free
- outperforms MP3/AAC at low bitrates for speech
- built-in packet loss concealment
- adaptive bitrate
- widely supported (libopus, bindings for every language)
---
## Pre-processing
> **Phase 2.** Not implemented in the current client (`client/src/audio/`).
Applied before Opus encoding on the capture path (target design).
### Noise suppression
Implementation: RNNoise (ML-based, ~2% CPU)
Applied to raw PCM before encoding.
Configurable: on / off.
### Echo cancellation
Removes microphone pickup of speaker output.
Implementation: platform AEC or software fallback.
Configurable: on / off.
### Voice activity detection (VAD)
Detects when the user is speaking to avoid sending silence packets.
Used in "voice activity" mode (alternative to push-to-talk).
Threshold: configurable 0–100%, default 40%.
In push-to-talk mode, VAD is bypassed — packets are sent only while
the hotkey is held.
DTX in Opus also provides a secondary layer of silence suppression
at the encoder level.
---
## UDP relay
### Path
```
Client A ──UDP──▶ Voice Node ──UDP──▶ Client B
│
──UDP──▶ Client C
│
──UDP──▶ Client D
```
The voice node receives packets from each speaker and relays them
to all other clients in the same channel.
No mixing is done on the server. Clients receive separate streams
per speaker and mix locally. This allows per-speaker volume control
on the client side.
### Direct P2P (future)
In Phase 4, direct P2P paths may be established between clients
to skip the relay hop. The relay remains as fallback.
---
## Jitter buffer
The jitter buffer absorbs network jitter and reorders out-of-order packets
before passing them to the decoder.
### Operation
1. Packets arrive with `voice_seq` and `timestamp`
2. Buffer holds packets for a configurable window
3. Packets are released in sequence order at scheduled playout time
4. If a packet is missing when due: Opus PLC generates a concealment frame
5. If a late packet arrives after playout: discarded
### Configuration
| Setting | Default | Range | Notes |
|---------|---------|-------|-------|
| Buffer size | 40ms | 20–80ms | lower = less latency, more glitches |
| Adaptive mode | on | on/off | auto-adjusts based on observed jitter |
| Max late tolerance | 80ms | — | packets older than this are discarded |
Adaptive mode measures jitter over a rolling window and expands/shrinks
the buffer target accordingly. On a stable LAN, buffer converges to ~20ms.
On a lossy WAN, it may expand to 60–80ms.
---
## Packet loss concealment
When a voice_seq gap is detected, Opus generates a concealment frame
using the previous frame's data. This produces a short fade or
interpolated audio rather than a click or silence.
FEC (Forward Error Correction) in Opus encodes redundant data from
the previous frame into the current packet. If the previous packet was
lost but the current one arrives, the previous frame can be recovered.
---
## Latency budget (target)
```
Microphone capture latency ~5ms
Pre-processing (RNNoise, AEC) ~2ms
Opus encode (20ms frame) ~20ms
UDP tx ~1–5ms (local)
Voice node relay ~0.5ms
UDP rx ~1–5ms (local)
Jitter buffer (adaptive) ~20–40ms
Opus decode ~1ms
Playback buffer ~5ms
──────────────────────────────────────
Total (local network) ~55–80ms
Target (good conditions) < 60ms
```
For ultra-low latency setups (LAN gaming): use 10ms frame size,
reduce jitter buffer to 20ms, disable adaptive mode.
Expected total: ~35–45ms.