Self-hosted realtime mesh voice/video platform. Local-first, zero-cloud. Your LAN, your data.
HALCYON is a self-hosted realtime communication platform designed to run entirely on your LAN. Two peers, two minutes, zero accounts. The whole stack is vanilla JavaScript on the client and a single Node entry point on the server.
- Mesh WebRTC P2P with W3C-spec perfect-negotiation glare handling so either peer can add tracks (camera, screen-share) mid-call without the remote peer getting stuck on a missed renegotiation.
- End-to-end DTLS-SRTP audio between peers, no server in the media path.
- 1080p60 video with codec preference AV1 → H.264 (NVENC if available) → VP9 → VP8, max-bitrate 6 Mbps, hardware-accelerated when available.
- Screen sharing with tab/system audio capture. The screen-share
audio rides on the same
MediaStreamas the video and plays through the remote<video>tile (the mic<audio>element stays independent, so neither stream overwrites the other). - Resilient signaling: WebSocket reconnect with exponential backoff
and jitter, ICE restart on
failed, 30 s session-grace window server-side. - Persistent profiles via SQLite (
data/app.db) and a tiny REST API. Settings sync local-first then server. - Multi-room via
?room=<id>URL parameter, chat scoped per room. - Markdown chat with reactions, edits, soft-delete, mention
rendering, XSS-safe parser (URL whitelist
https?:/mailto:only). - Four themes: Graphite (default), Terminal, Ember, Dawn. Neutral by default with a single emerald accent reserved for live and online states.
- Accessible: WCAG-aligned focus indicators, ARIA pressed/expanded
state, live-region announcements,
prefers-reduced-motion&prefers-contrastaware.
One port (:8443). One launcher. One LAN. No third-party services.
| Layer | What |
|---|---|
| Server | Node 24 ESM, https + WebSocketServer (ws), selfsigned certs |
| Storage | better-sqlite3 WAL, single file data/app.db (settings + chat) |
| Client | Vanilla JS ES Modules + DOM render, no build step, no dependencies |
| Static | In-memory ETag cache, Brotli + gzip negotiation, 304 conditional GET |
| Tests | vitest (34 unit) + playwright (chromium, audio fake-routing) |
| Lint | prettier 3.8 + eslint v9 flat config |
git clone https://github.com/999purple999/halcyon.git
cd halcyon
npm install
npm startThen open https://localhost:8443 in Chrome 124+. Accept the
self-signed certificate, pick a nickname, join. Share
https://<your-LAN-IP>:8443 with a friend on the same network and
you're talking.
For separate rooms add ?room=<name> to the URL.
graph LR
A[Browser A] -- WSS signaling --> N[Node :8443]
B[Browser B] -- WSS signaling --> N
A <-- WebRTC P2P DTLS-SRTP audio+video --> B
N --- DB[(SQLite WAL<br/>settings + chat)]
N -- REST --> A
N -- REST --> B
The Node server does only:
- HTTPS static (ETag + brotli/gzip)
- WebSocket signaling relay (offer/answer/ICE)
- REST
/api/settings(UUID-keyed JSON blobs) - WebSocket chat (per-room broadcast + SQLite persistence)
- Health probes (
/healthz,/readyz)
The media never transits the server. Once two peers exchange SDP via
the WebSocket, their browsers establish a direct P2P
RTCPeerConnection.
| Audio | Video | Chat + room | UX |
|---|---|---|---|
| Mesh P2P Opus | Camera 1080p60 | Markdown chat | 4 themes (Graphite default) |
| Mute / deafen | Screen + tab audio | Mentions @user |
Keyboard shortcuts |
| AEC toggle | Audio-only share | Edit / delete msgs | Push-to-talk (Space) |
| Optional noise gate | Codec AV1 / NVENC | Reactions on msgs | Notification sounds (sounds.js) |
| VU meter input | Per-peer tile | File transfer P2P | Pre-join mic test + echo loop |
| Test beep | Grid / Speaker view | Soundboard per room | Stats panel + 60 s RTT graph |
| Per-peer volume | Click-to-pin speaker | Floating reactions | Hand-raise + connection-toast |
| Local recorder | Self-tile mirror | Typing indicator | PWA install + reduced motion |
| Key | Action |
|---|---|
M |
Toggle microphone |
Space (hold) |
Push-to-talk |
D |
Deafen (silence incoming audio) |
C |
Toggle camera |
S |
Toggle screen share / audio |
R |
Open react popover (hand + emoji) |
G |
Cycle Grid / Speaker view |
B |
Open the soundboard drawer |
T |
Local test beep |
Ctrl+Shift+D |
Stats / debug panel |
? |
Shortcut cheatsheet |
Esc |
Close panels |
HALCYON is built keyboard-first and screen-reader friendly:
- All interactive controls expose
aria-pressed/aria-expandedand fullaria-labels. - A polite
aria-liveregion announces toggles (mute / deafen / new chat) for VoiceOver / NVDA. :focus-visibleoutlines are explicit but suppressed for mouse-driven focus.- Skip-link jumps from
<body>to the participants grid. @media (prefers-reduced-motion: reduce)disables aurora, shimmer, entrance and pulse animations.@media (prefers-contrast: more)thickens borders and lightens muted text.
The client is hot-path conscious:
- Pause-on-hidden. The rAF tick loop is fully suspended when the
tab loses visibility (
document.hidden);getStats()polling also skips background ticks. - Incremental chat render. New messages append to the DOM, edits/deletes/reactions mutate the existing node in place. No more full-list rebuild on every event (preserves text selection & scroll).
- Cached DOM lookups. The participants grid uses a
Map<id, el>to avoid aquerySelectorper peer per frame. - Threshold-gated CSS var writes. Speaking-intensity (
--rms) only updates on delta ≥ 0.02 to avoid style recalc spam. - Compressed FFT. The audio analyser uses
fftSize=256(half of the original), since DOM rendering doesn't need fine-grained bins. - Smaller paint area. The heavy multi-radial-gradient star-drift layer is gone; aurora is a single softer layer.
- Static asset cache. Server keeps SHA-1 ETags in memory and
serves brotli/gzip pre-compressed bodies;
If-None-Matchreturns304with zero body for unchanged reloads.
The result: app.js is ~20% smaller, CSS is ~10% smaller, and a typical Discord-style busy chat (~200 messages) no longer drops frames on each new message.
- All media is end-to-end encrypted (WebRTC DTLS-SRTP, mandatory).
- Chat is stored only in
data/app.dbon your server. Nothing leaves the LAN. - The self-signed certificate is generated once on first run and
cached in
certs/. You can replace it with a real cert if you want. - No telemetry. No analytics. No third-party scripts.
- HTTPS responses set
X-Content-Type-Options: nosniffandReferrer-Policy: no-referrer.
npm run lint :: eslint + prettier
npm run test:unit :: vitest (34 tests)
npm run test:e2e :: playwright chromium
npm run verify :: full local CI suiteThe e2e suite includes bidir_renegotiation.spec.js which validates
the perfect-negotiation fix by having both peers add a camera track at
runtime (the same code path that screen-share uses), then asserting
zero entries in the client error ring.
- Soundboard. Per-room shared soundboard with upload (5 MB cap per file,
200 sounds per user, Opus 128 kbps mono recommended), local Test preview,
and Play-in-room that broadcasts via WebSocket so every peer fetches the
same blob and plays it in sync. The sender's participant tile gets a
brief halo for visual attribution. Owner-only delete. New SQLite store
(
lib/sound_store.js) + binary files underdata/sounds/. Topbar button or theBshortcut opens the dedicated drawer.
- Notification sounds (synthesized via WebAudio, 7 voicings for join, leave, hand-raise, chat msg, mute toggle, push-to-talk on/off, warn). Topbar bell button toggles, preference persisted to localStorage.
- Mobile responsive deck. Below 560 px the topbar scrolls horizontally, secondary labels collapse, participant tiles shrink to 140 px, the chat drawer goes full-screen, the control deck reflows to compact squircles. iOS safe-area insets respected on topbar + deck.
- Local meeting recorder. Mixes own mic + every peer audio + screen
share audio via WebAudio destination, bundles camera or screen video,
writes WebM via
MediaRecorder, auto-downloads ashalcyon-<timestamp>.webm. Topbar Record button pulses red and shows elapsedMM:SS. - Pre-join mic preview + 3 s echo loopback test on the join card. Opt-in (a button, not auto) so it doesn't burn the browser's mic permission prompt before the user even sees the page.
- Hand-raise + floating reactions. New React deck button opens a
popover with a hand toggle + 6-emoji quick pick. Reactions float up
over the sender's avatar (1.4 s rise + fade). Hand-raise paints a
persistent amber badge on the tile.
Ropens the popover. - Connection-quality toast. Per-peer 20-sample rolling RTT baseline; a single throttled toast (max 1 per peer per 30 s) when the latest sample exceeds 200 ms AND 2.2x baseline, or audio packet loss crosses 5 percent.
- Optional noise gate (
highpass 80 Hz Q 0.7 -> compressor thr -32 dB ratio 8:1 attack 8 ms release 200 ms -> 1.05x gain). Topbar waveform button toggles. Caches the raw mic stream so the toggle never re-prompts for permissions. - Grid / Speaker view toggle (
Gshortcut). Click any video tile to pin / unpin it as the speaker-view main tile. Auto-pick when nothing pinned: screen-share self -> camera self -> first remote video. - 60 s RTT sparkline per peer in the debug panel (80x18 canvas, colour shifts green to amber to rose at 200 / 350 ms thresholds).
- PWA install.
manifest.webmanifest, app icon SVG, minimal service worker with network-first strategy + opportunistic cache refresh. Browsers offer "Install Halcyon" once criteria are met. - File transfer over WebRTC DataChannel. Chat input row Attach
button; each peer connection carries a
fileordered DataChannel; 16 KB chunks, backpressure-aware (pauses at 4 MB bufferedAmount); 200 MB cap; auto-download on receive. No server involvement.
- UI redesign: design system v2 (graphite). Sober, neutral-first palette with
one accent (emerald) used only for live / online states. Replaced the cosmic
shimmer logo and aurora background animation with a static, calm gradient that
is correctly disabled for
prefers-reduced-motion. - SVG icon library. New
public/icons.jsexposes a single functionicon(name, opts)returning a Lucide-style inline SVG string. The library drives every glyph in the UI: control deck buttons (mic, headphones, video, monitor), status badges (link, gauge, palette, bell, shield), chat header, audio gate. Static markup usesdata-icon="..."placeholders auto-filled atDOMContentLoaded; dynamic toggles callicon()directly. No emoji left in the UI shell, only in chat reactions where users actually want them. - Refined control deck. Round buttons swapped for soft squircles (16px radius), the central mic kill-switch gains an accent ring when live and a rose ring when muted. AEC switch knob is tighter and reveals the shield icon only when on.
- Tighter typography. Inter when available, system fallback. Negative letter-spacing on headings (-0.025em), tabular numerics for all latency, bitrate, FPS, and timestamp readouts.
- Theme rename + cleanup.
data-themeattribute values are unchanged for backward compatibility, but their visual identity and label changed: cosmic to Graphite (default), matrix to Terminal, cyberpunk to Ember, apple to Dawn.
- Fixed: screen-share never started.
getDisplayMediawas being called with the sameVIDEO_PROFILE_HQconstraints object asgetUserMedia, which containsmin:keys for camera fallback. The spec forbidsminongetDisplayMedia→ Chrome rejected withTypeError: min constraints are not supported. The error was logged but no user-visible toast surfaced it. Now the call uses anideal-only subset of the constraints, plus a video-only retry and an error toast for any remaining failure mode. - New
tests/e2e/screen_share.spec.jsexercises the full click-to-tile pipeline end-to-end using Chrome's fake-ui auto-accept.
- Fixed: screen-share / camera from the non-initiator peer. The
previous code attached
negotiationneededonly on the initiator side, so when the second peer added a track at runtime no SDP renegotiation fired and the remote saw nothing. Rewritten with the W3C perfect-negotiation pattern (polite/impolite glare handling). - Fixed: screen-share audio was overwriting mic audio. The screen
audio track shares its
MediaStreamwith the screen video; it now plays through the remote<video>element instead of replacing the mic<audio>element'ssrcObject. - Fixed:
maximizeBitratecould crash on an empty encodings array, bubbling as an unhandled rejection during early renegotiation. Now defensive and never throws. - UI fully translated to English (HTML, JS strings, server banner).
- New e2e test
bidir_renegotiation.spec.js.
- Removed ~500 lines of dead canvas-radar and SFU code (
-20%JS,-9%CSS). - A11y polish:
prefers-reduced-motion,prefers-contrast,:focus-visible, skip-link, ARIA pressed/expanded sync, live-region announcements. - Perf: pause-on-hidden rAF loop, incremental chat render, cached participants-grid lookups, FFT size halved, single-layer aurora.
- Server: in-memory ETag cache + Brotli/gzip negotiation + 304 conditional GET, security headers.
GPL-3.0-or-later. Copyleft.