A real-time music listening room - join a room, build a queue together, vote on tracks, and listen in sync. Available as both a web app and a terminal CLI.
Vaux on web: https://vaux-ten.vercel.app/
Screen_Recording_20260525_190959_Opera.mp4
Vaux on CLI - 🐍 Latest Version on Pypi: https://pypi.org/project/vaux-cli/
What's new: see the release notes for changes between versions. Click "Watch → Releases only" on the GitHub repo to get notified when a new version ships.
- Create or join a room via a shareable link (web) or a single command (CLI)
- Search YouTube and add tracks to a shared queue
- Vote tracks up or down — the queue re-sorts in real time for everyone
- Synchronized playback — everyone in the room hears the same track at the same timestamp
- Host controls: Play, pause, seek, skip tracks, and transfer host privileges
- Live chat alongside the music
- Local volume controls for all users (Web and CLI)
- Private rooms — invite-only with end-to-end encrypted chat. Server only sees ciphertext. See Private rooms below.
One backend, two clients. The real-time sync logic is written once and consumed by both the web app and the CLI over the same Socket.io event contract.
No YouTube API key required — uses yt-dlp for metadata and stream extraction.: The Node.js server runs yt-dlp with Node as a JS runtime to search YouTube and extract direct audio stream URLs — no YouTube API keys or Google quotas. The server also uses short-TTL stream URL caching and queue-time pre-resolution to reduce play latency. The web client uses the YouTube IFrame player; the CLI uses mpv and races server/local resolution paths with preload + cache.
vaux/
├── server/ Node.js + Express + Socket.io (shared backend, port 4000)
├── web/ Next.js + React + Tailwind (browser client, port 3000)
└── cli/ Python + textual (terminal client)
┌─────────────────────┐
│ YouTube │
└──────────┬──────────┘
│
▼
┌─────────────────────┐
│ yt-dlp (Server) │
│ Search │
│ Metadata │
│ Stream Extraction │
└──────────┬──────────┘
│
┌─────────────────┴─────────────────┐
│ │
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ Web Client │ │ CLI Client │
│ IFrame Player │ │ mpv Player │
└─────────────────┘ └─────────────────┘
Identity note: userId is assigned by the server on socket connection and stamped onto every chat/queue message server-side. Clients never send or override userId — anything they pass is discarded.
| Event | Direction | Payload |
|---|---|---|
room:join |
Client → Server | { roomId, username } |
room:joined |
Server → Client | { room: { id }, userId, members[], queue[], playbackState, role } |
room:member_joined |
Server → Client | { userId, username } |
room:member_left |
Server → Client | { userId } |
room:join_failed |
Server → Client | { reason } — server at capacity, room full, etc. |
host:transfer |
Client → Server | { roomId, newHostId } — host only |
host:changed |
Server → Client | { newHostId, newHostUsername } |
| Event | Direction | Payload |
|---|---|---|
queue:add |
Client → Server | { roomId, videoId, title, channel, duration } |
queue:vote |
Client → Server | { roomId, itemId, value } — 1 or -1 |
queue:remove |
Client → Server | { roomId, itemId } — host only |
queue:updated |
Server → Client | { queue[] } — full queue, sorted by votes |
queue:full |
Server → Client | { max } — emitted when room queue hits its hard cap |
Note: queue:add thumbnails are server-derived from videoId. Any client-supplied thumbnail URL is discarded to prevent tracking-pixel leaks.
| Event | Direction | Payload |
|---|---|---|
playback:play_track |
Client → Server | { roomId, itemId } — pops the queue item |
playback:play |
Client → Server | { roomId, positionSeconds } |
playback:pause |
Client → Server | { roomId, positionSeconds } |
playback:seek |
Client → Server | { roomId, positionSeconds } |
playback:ended |
Client → Server | { roomId } — auto-plays next queue item |
playback:state |
Server → Client | { videoId, title, channel, thumbnail, trackId, duration, positionSeconds, isPlaying, updatedAt } |
playback:track_ended |
Server → Client | { nextItem | null } |
| Event | Direction | Payload |
|---|---|---|
chat:send |
Client → Server | { roomId, text } (public) or { roomId, ct, nonce } (private — ciphertext) |
chat:message |
Server → Client | { userId, username, text, timestamp } (public) or { userId, ct, nonce, timestamp } (private — no username) |
chat:rate_limited |
Server → Client | { retryAfterMs } — sent only to the throttled socket |
| Event | Direction | Payload |
|---|---|---|
room:join |
Client → Server | adds { authProof, create? } and ships username as {ct, nonce} |
room:destroy |
Client → Server | { roomId } — host only; burns the room immediately |
room:join_failed |
Server → Client | adds { reason: "auth_failed" | "locked" | ... }; locked includes retryAfterMs |
Both clients implement this identically on every playback:state event:
currentPosition = state.positionSeconds + (Date.now() - state.updatedAt) / 1000;
// seek player to currentPosition — corrects drift automaticallyInvite-only rooms with end-to-end encrypted chat. Both the web client and the CLI implement the same crypto. The server never sees plaintext chat content or member names for private rooms.
| Protected | How |
|---|---|
| Chat content | XSalsa20-Poly1305 (libsodium crypto_secretbox). Server only relays opaque ciphertext. |
| Member display names | Encrypted with the same key as chat. Each client decrypts locally. |
| Room existence | roomId is derived client-side from the invite code via Argon2id + a KDF. The server only sees the derived ID, never the code. |
| Access | Argon2id-hashed auth proof on the server. Lockout for 60 s after 10 failed attempts per room. |
| NOT protected | Why |
|---|---|
YouTube videoId |
Server resolves stream URLs via yt-dlp. Unavoidable. |
| Queue metadata (title, channel, who added) | v1 scope is chat-only. |
| Playback events (play, pause, seek) | Server-relayed for sync. |
| Member count, connection timing | Server sees socket connections. |
Treat private rooms as "private chat in a public room" — not a hidden room. If you wouldn't want a server admin to see your queue or playback history, don't use vaux for that.
- Password: 16 random bytes encoded as 22-char base64url (~128 bits entropy).
- KDF: Argon2id (
opslimit=2,memlimit=64 MiB,outlen=32) over a fixed application salt → libsodium KDF (crypto_kdf_derive_from_key) forroomId,authProof, andchatKeysubkeys. - Salt: First 16 bytes of
SHA-256("vaux/private-room/v1"), pinned in source. Per-room salts would require a server lookup that signals room existence. - Wire: Auth proof and ciphertext travel as standard base64. The roomId is base64url-unpadded.
A cross-client KDF pin test (web/__tests__/crypto.test.ts ↔ cli/tests/test_crypto.py) asserts that JS and Python derive byte-identical material from the same password. Any drift fails CI.
- Create: First joiner sends
create: true. Server stores the Argon2id hash of the auth proof. - Last leave: Room is deleted after a 5 s blip window (so reconnects don't burn it).
- Host quits/disconnects without transfer: Room is destroyed immediately. All other members are disconnected.
https://vaux-ten.vercel.app/#<22-char-code>
The fragment never reaches the server. The web client parses it, derives keys, then strips it via history.replaceState. The CLI accepts the same URL or the bare 22-char code.
| Layer | Technology |
|---|---|
| Web frontend | Next.js 16, React, Tailwind CSS, TypeScript |
| CLI frontend | Python 3.11+, textual, python-socketio |
| Backend | Node.js, Express, Socket.io |
| Database | In-memory (Currently) |
| Music source | yt-dlp + Node.js (server search & stream extraction), YouTube IFrame API (web), mpv (CLI) |
| Hosting | Vercel (web), Render (server) |
Install from PyPI: pypi.org/project/vaux-cli
pipx install vaux-clior
pip install vaux-cliRequirements: mpv for audio playback (auto-downloaded on Windows). For local yt-dlp fallback, keep yt-dlp updated and have Node.js on your PATH.
Once installed, launch from anywhere:
Launch the interactive lobby:
vauxOr bypass the lobby to join a room directly:
vaux <room-id> -u <your-name>| Key | Action |
|---|---|
Ctrl+S |
Focus search |
Ctrl+R |
Clear search results |
Ctrl+T |
Focus chat |
↑ / ↓ |
Chat history (chat input focused) |
Ctrl+O |
Play / pause (host) |
Ctrl+N |
Skip track (host) |
x / Del |
Remove queue item (host, queue focused) |
Ctrl+U |
Vote up selected track |
Ctrl+D |
Vote down selected track |
- / = |
Volume down / up |
m |
Mute / unmute |
Ctrl+K |
Copy room name to clipboard |
Ctrl+L |
Listeners & transfer host (host) |
Ctrl+G |
Info (version, links, shortcuts) |
Ctrl+B |
Report a bug (Google Form / GitHub) |
Ctrl+P |
Command palette — save screenshot, change theme, etc. |
Ctrl+C |
Quit |
Theme: vaux defaults to whichever Textual theme your terminal prefers. Open
Ctrl+P → Change themeto pick another (dracula, nord, gruvbox, tokyo-night, monokai, catppuccin variants, etc.). The choice applies for the current session.
The room host can:
- Play tracks
- Pause playback
- Resume playback
- Skip tracks
- Transfer host privileges
- Control room playback state
Listeners can:
- Search tracks
- Add songs to the queue
- Vote on songs
- Participate in chat
Hosts can transfer control to another user directly from chat:
/host username
Example:
/host john
- Node.js v18+ (required for yt-dlp YouTube extraction on the server)
- Python 3.11+
git clone https://github.com/itsvee0120/vaux.git
cd vauxcd server
npm install
cp .env.example .env # optional — defaults work out of the box
npm run dev
# running on http://localhost:4000cd web
npm install
cp .env.local.example .env.local # optional
npm run dev
# running on http://localhost:3000The CLI defaults to the hosted server (https://vaux.onrender.com). When you run the local server and web app, you must point the CLI at http://localhost:4000 or it will join a different backend — chat, queue, and playback will not sync with the browser.
cd cli
python -m venv .venv
.venv\Scripts\activate # Windows
source .venv/bin/activate # macOS/Linux
pip install -e .
# Open the interactive lobby (local server):
python main.py --server http://localhost:4000
# Or join a room directly:
python main.py --server http://localhost:4000 my-room --username yournameIf you installed vaux-cli from PyPI instead of running from source, use the same flag:
vaux --server http://localhost:4000
vaux --server http://localhost:4000 my-room -u yournameAll clients share a built-in public dev gate key for /youtube routes (vaux-02187xdsx-4335). It is not a secret — it only filters casual bot traffic. Override it in production if desired.
PORT=4000
# API_KEY=vaux-02187xdsx-4335 # optional override
NEXT_PUBLIC_SERVER_URL=http://localhost:4000
# NEXT_PUBLIC_API_KEY=... # optional override
# VAUX_API_KEY=... # optional override
--server http://localhost:4000
Pass --server when running against a local backend:
--server http://localhost:4000
example: python main.py --server http://localhost:4000
Omit --server only when you intend to use the public hosted server.
| Role | Can do |
|---|---|
| Host | Everything — play, pause, seek, skip, remove tracks |
| Listener | Add tracks, vote, chat |
(X = done, - = in progress)
- Real-time rooms
- Shared queue
- Live voting
- Synchronized playback
- Host controls
- Chat
- Private rooms with end-to-end encrypted chat
- Emoji reactions
- Song history
- Public room discovery
- AI playlist seeding
- Persistent PostgreSQL storage
- Installable CLI via
pip install vaux-cli
Every released version has notes on the GitHub Releases page:
github.com/itsvee0120/vaux/releases
That's the source of truth for what changed and when. The page is reachable from inside the CLI as well — press Ctrl+G to open the Info modal and follow the Releases link.
To get notified when a new version drops:
- Visit the repo on GitHub.
- Click Watch → Custom → Releases.
PyPI users can also see the version history at pypi.org/project/vaux-cli, with the Changelog sidebar link pointing back to the same Releases page.
I'm too lazy to switch tabs and need my full screen while coding ... That's it, that's why. 😺
Okay, not entirely.
This project was created because I wanted to learn how to use Socket.IO across a CLI application and a web app. It started as a fun hobby project and a learning exercise, and I decided to publish it so others can play around with it, learn from it, or build their own ideas based on what I've made.
Disclaimer: This is a personal hobby project created for learning and experimentation. It is free, open source, non-commercial, and released under the MIT License. No donations, sponsorships, subscriptions, or other forms of compensation are requested or expected. If you find it useful, that's more than enough. This project is intended for personal and educational purposes. While released under the MIT License, the author’s intent is for this project to remain a learning-focused, non-commercial open-source project.
Violet Nguyen — nviolet0120@gmail.com
This project is licensed under the MIT License.
Copyright (c) 2026 Violet Nguyen
