Skip to content

999purple999/halcyon

Repository files navigation

HALCYON

Self-hosted realtime mesh voice/video platform. Local-first, zero-cloud. Your LAN, your data.

License: GPL v3 Node 24+ No build step A11y

What it is

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 MediaStream as 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-contrast aware.

One port (:8443). One launcher. One LAN. No third-party services.

Stack

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

Quick start

git clone https://github.com/999purple999/halcyon.git
cd halcyon
npm install
npm start

Then 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.

Architecture

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
Loading

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.

Features

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

Keyboard shortcuts

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

Accessibility

HALCYON is built keyboard-first and screen-reader friendly:

  • All interactive controls expose aria-pressed / aria-expanded and full aria-labels.
  • A polite aria-live region announces toggles (mute / deafen / new chat) for VoiceOver / NVDA.
  • :focus-visible outlines 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.

Performance

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 a querySelector per 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-Match returns 304 with 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.

Privacy

  • All media is end-to-end encrypted (WebRTC DTLS-SRTP, mandatory).
  • Chat is stored only in data/app.db on 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: nosniff and Referrer-Policy: no-referrer.

Testing

npm run lint           :: eslint + prettier
npm run test:unit      :: vitest  (34 tests)
npm run test:e2e       :: playwright chromium
npm run verify         :: full local CI suite

The 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.

Changelog

v1.5.0

  • 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 under data/sounds/. Topbar button or the B shortcut opens the dedicated drawer.

v1.4.0

  • 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 as halcyon-<timestamp>.webm. Topbar Record button pulses red and shows elapsed MM: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. R opens 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 (G shortcut). 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 file ordered DataChannel; 16 KB chunks, backpressure-aware (pauses at 4 MB bufferedAmount); 200 MB cap; auto-download on receive. No server involvement.

v1.3.0

  • 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.js exposes a single function icon(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 uses data-icon="..." placeholders auto-filled at DOMContentLoaded; dynamic toggles call icon() 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-theme attribute 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.

v1.2.1

  • Fixed: screen-share never started. getDisplayMedia was being called with the same VIDEO_PROFILE_HQ constraints object as getUserMedia, which contains min: keys for camera fallback. The spec forbids min on getDisplayMedia → Chrome rejected with TypeError: min constraints are not supported. The error was logged but no user-visible toast surfaced it. Now the call uses an ideal-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.js exercises the full click-to-tile pipeline end-to-end using Chrome's fake-ui auto-accept.

v1.2.0

  • Fixed: screen-share / camera from the non-initiator peer. The previous code attached negotiationneeded only 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 MediaStream with the screen video; it now plays through the remote <video> element instead of replacing the mic <audio> element's srcObject.
  • Fixed: maximizeBitrate could 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.

v1.1.0

  • 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.

License

GPL-3.0-or-later. Copyleft.

About

Self-hosted realtime mesh voice/video platform. Local-first, zero-cloud. GPL-3.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors