Skip to content

Releases: SAKMZ/syncwave

v0.6.0 — A clock that only goes forward, and a cache that stops

Choose a tag to compare

@SAKMZ SAKMZ released this 01 Aug 07:17

Most of this release came out of one review comment on r/coolgithubprojects. Two of the four points raised were already handled; two were real, and both are fixed here.

⏱️ Playback no longer trusts the wall clock

Every client renders its audio position from the server's timestamps, so a listener whose laptop clock is twenty seconds off still hears the track in the right place. That part was fine. The problem was one level down: the server's own epoch was Date.now().

Wall clock time is not monotonic. An NTP correction, a VM resuming from suspend, a container host stepping its clock — any of them move Date.now() sideways, and because every position in the room is measured from that epoch, the whole room jumps together mid-track.

The playback clock is now anchored to performance.now(), which only ever moves forward at one second per second:

const WALL_AT_BOOT = Date.now();
const MONO_AT_BOOT = performance.now();
const monoNow = () => WALL_AT_BOOT + (performance.now() - MONO_AT_BOOT);

Seven timing sites moved onto it: the serverNow in both state broadcasts, the start epoch for repeat-one and for advancing the queue, the pause and seek arithmetic, and the sync handshake. Room creation timestamps and chat timestamps stay on the wall clock deliberately — those are dates, and a date that drifts from the world is the wrong answer.

Measured on the live instance afterwards: two clients in the same room, sampled 10.681s apart in wall time, were 10.681s apart in track position. No correction jumps, position advancing at exactly 1.0×.

💾 The cache has a ceiling now, and you can see it

Tracks were dropped 72 hours after their last play. Age is not a bound. A busy room pulls far more inside that window than a small disk holds, and Syncwave is usually running on a machine somebody also lives on — filling their disk is a worse failure than re-downloading a track.

CACHE_MAX_MB caps it, defaulting to 4096. The sweep runs at boot and hourly: the age pass first, then, if the total is still over, least-recently-played files go until it's back under. 0 disables the cap and restores the old behaviour.

The Storage panel in /admin shows what it's actually costing you — usage against the limit, track count, and buttons to sweep or clear. Clearing is safe: rooms and queues are untouched, tracks just download again next time someone plays them.

"Why is my disk full" is a bad way to discover a listening room has been busy.

📱 The room fits a phone properly

  • Reactions are one tap. They were behind a popover — tap to open, tap to send. Now the six emoji sit directly above the player controls, and one tap sends a burst of floating emoji rather than a single one.
  • The listener list no longer flows behind the player. With more than one name it ran under the bottom controls; with one name it still did, after the first attempt at fixing it. The actual cause was a flex child without min-h-0, so it never shrank to its container.
  • On phones the panel doesn't list listeners at all. The top bar already shows them, and showing the same thing twice cost the artwork the room it needed.
  • The panel is centred and no longer double-counts. It was carrying 122px of dead space and two identical 👥 N counters.

💜 A note at the bottom

The landing page and the join screen now say what this was actually built for. Improvise your own; it's one component.

🔍 Findable

README and repo description now say self-hosted Spotify Jam alternative in the words people search for, rather than leaving it to be inferred.

🐛 The Storage panel shipped broken, briefly

Worth writing down because it was invisible. .gitignore had cache/ with no leading slash, which matches a directory called cache at any depth — including app/api/admin/cache/. The route behind the Storage panel was never committed, git status showed clean because ignored files don't appear in it, and every build from a clone got a panel stuck on "Checking disk usage…" forever.

cache/, data/, out/ and build/ are now anchored to the repo root. If you cloned between the two commits, pull again.


Upgrading: git pull && docker compose up -d --build.

If your cache is already over 4 GB, the first sweep after restart will trim it back to the cap, oldest first. Set CACHE_MAX_MB higher, or 0, if you'd rather it didn't.

v0.5.0 — Cloud hosting works, and audio files are 3× smaller

Choose a tag to compare

@SAKMZ SAKMZ released this 27 Jul 19:04

v0.4.0 said, flatly, that Syncwave could not run on a cloud host. That was true when it was written. It isn't any more.

☁️ Cloud hosting works now

YouTube refuses most datacenter IPs, and cookies genuinely do not lift it — that finding stands. What was wrong was the conclusion drawn from it.

The refusal isn't uniform. Measured from a fresh cloud instance: every direct attempt was refused, while 4 of 10 commodity proxies fetched the same track fine. Which proxies work varies by origin and drifts over time, so the useful fix was never "route through a proxy" — it's try routes until one answers, and remember which did.

  • Direct is always tried first. At home it simply works, and on a metered plan every byte not spent is another track someone gets to play. The pool is only reached for when the IP is actually refused.
  • Only IP-reputation failures reroute — bot-check, 429, 403. A deleted video fails identically through all ten proxies, so retrying it there is waste.
  • It learns. A proxy that delivers gets tried first next time; a refused one backs off with an escalating cooldown, but is never parked permanently, since providers recycle addresses.
  • Credentials never reach a log line — only host:port is printed.

Point it at a provider, or supply your own list:

WEBSHARE_API_KEY=your-key
# or, any provider:
YTDLP_PROXY_LIST=http://user:pass@host:port,http://user:pass@host2:port

Both are also settable in /admin on a running instance, so a host that turns out to be blocked can be fixed from the browser instead of an SSH session and a redeploy.

Verified live: 5 of 5 previously-refused tracks now serve HTTP 200 from a cloud instance.

🎧 Audio files are 3× smaller, and better

This one is a plain bug fix and it helps every install, cloud or not.

Asking for bestaudio gets Opus in a webm container. Converting that to m4a re-encoded it — losing a generation of quality and inflating the file, because the encoder was told to aim high. A 4.4 MB Opus stream came back out as a 14 MB m4a.

Requesting the m4a rendition instead means ffmpeg only rewrites the container. Same audio, no transcode, no CPU cost:

Before After
Cached file, direct 14.0 MB 4.3 MB
Cached file, via proxy 14.0 MB 1.6 MB

On a proxy the bitrate is capped as well, since that traffic is billed by the byte — taking a 1 GB allowance from ~210 tracks to ~380. Direct connections are never capped. PROXY_MAX_ABR tunes the cap; 0 turns it off.

The ceiling is bandwidth, not blocking. Measured end to end a proxied track costs ~2.7 MB — the file is ~1.6 MB, the rest is metadata and protocol overhead. A 1 GB/month free plan is therefore roughly 350–400 tracks: plenty for a few friends, not enough for a public instance. Every track is cached for 72h, so repeat plays cost nothing.

🚂 Running it on Railway

If you have no machine to leave running, there's now a
step-by-step Railway guide
— repo to listening room in about ten minutes.

Two steps decide whether it works, and both are easy to miss: attach a volume
before first boot, or your admin password, rooms and cache are wiped by the
next redeploy; and configure a proxy pool, or Railway's datacenter IP means
nothing plays.

🛠️ Also

  • docker-compose.yml whitelists environment variables explicitly, so the new proxy settings were being read for substitution and then never reaching the process that needed them. Fixed.
  • README and DEPLOY.md rewritten around what actually happens, including the bandwidth ceiling.

Upgrading: git pull && docker compose up -d --build. Existing cached audio stays at the old size until it ages out; delete cache/*.m4a to reclaim the space immediately.

v0.4.0 — Nothing to install, and a link you can send

Choose a tag to compare

@SAKMZ SAKMZ released this 27 Jul 11:38

Two things stood between "I found this repo" and "my friend is listening with me": having to install Node first, and having to figure out how to let someone outside your house reach it. Both are gone.

📦 Nothing to install

Download the ZIP, double-click start.bat (Windows) or run ./start.sh (macOS/Linux). That's it — there is no step 0.

If the machine has no Node.js, or one older than 20, the launcher fetches an official build from nodejs.org, verifies it against the published SHA-256, and unpacks it into a .runtime folder beside the app. Nothing is installed system-wide, nothing touches your PATH or registry, and deleting .runtime reverses it. An existing Node 20+ is used as-is.

The version is read from the current LTS manifest rather than pinned, so it keeps up on its own. ffmpeg and a JavaScript runtime were already bundled — now the runtime is too.

🌍 A link you can actually send someone

The public link is now the default. Listening together is the entire point, and the people you want to listen with are usually not on your Wi-Fi. The launcher prints an HTTPS URL from a Cloudflare quick tunnel — no account, no port forwarding, no domain. Pass --local to stay on your network.

Because a public URL changes the stakes, the tunnel waits until you've set an admin password. An unclaimed instance lets whoever arrives first claim it, which is fine on a LAN and not fine on the open internet. First run now opens /setup instead of the home page, so the thing standing between you and a shareable link is visible rather than buried.

  • 🔗 Share button in every room — shows the link that actually works for the person you're sending it to, not whatever is in your address bar. The host is almost always on localhost; copying that hands friends a dead link. It tells you plainly whether the room is reachable from anywhere or only on your network, and picks up the tunnel if it comes up after you opened the room.
  • 📖 Tailscale and bring-your-own-domain setups are documented in DEPLOY.md for a permanent address.

🛠️ Fixes

  • Desktop installs couldn't play anything. yt-dlp needs a JavaScript runtime to solve YouTube's player challenge; without one every track failed. A runtime now ships with the app. This was the single biggest reliability problem.
  • Rate limiting was reported as a permanent block. An HTTP 429 is temporary and now says so, instead of showing the "your IP is blocked" message and sending people off to export cookies they didn't need.
  • The launcher could corrupt its own build. Building while another copy was running rewrote files the live server had open, and you'd get a working-looking page whose stylesheet 400s. It now refuses to start when the port is busy, and --rebuild clears the previous output instead of building over it.
  • Cover art was blurry. The hero image was drawing a 120px thumbnail at several times its size; it now requests the size it actually renders.
  • Docker builds were broken by --omit=optional, which also drops the platform-native binaries npm ships as optional dependencies.

📸 Also

  • A real screenshot and a demo GIF in the README, instead of a description of one.

Get it: download the source ZIP below, unzip, and double-click start.bat or run ./start.sh.

v0.3.0 — Self-hosted first, artwork-forward redesign

Choose a tag to compare

@SAKMZ SAKMZ released this 26 Jul 14:50

Note

Superseded on the cloud-hosting question. The finding below — that YouTube
refuses datacenter IPs and cookies don't lift it — still holds. The conclusion
drawn from it does not: the refusal isn't uniform, and
v0.5.0 makes cloud
hosting work by falling back through a pool of proxies. Home hosting is still
the simplest path.

Syncwave is now self-hosted first — it runs on a machine you own, and it looks the part.

🏠 Runs at home, by design

Cloud one-click deploys were removed. Testing on a cloud host with a valid logged-in cookies.txt, a working JS runtime, and all five yt-dlp player clients, every request was refused with "Sign in to confirm you're not a bot" — YouTube blocks datacenter IP ranges, and cookies don't lift it. The same build on a home connection works with no cookies at all. Shipping those buttons would have sent people to an app that can't play music.

Three ways to run it now:

  • 🖱️ Double-click a filestart.bat (Windows) or ./start.sh (macOS/Linux). Installs, builds, starts, opens your browser, and prints the LAN address to share. Rebuilds itself when you update.
  • 🐳 Dockerdocker compose up -d --build
  • 🐧 One commandcurl -fsSL .../scripts/install.sh | sudo bash

ffmpeg now ships with it, so a desktop user needs nothing but Node.

🎨 Redesigned around the artwork

  • Now Playing panel — large cover art with a colour halo, live equalizer, and who's listening. Its own tab on mobile, a dedicated column on wide screens.
  • Rebuilt player — identity left, transport centred, volume and reactions right. The old 4px hairline is a real scrubber with elapsed/remaining, keyboard support, and buffer fill while a track caches.
  • Fixed the ambient backdrop — a saturated album cover used to flood the whole app and wreck text contrast. It's now mood lighting under a two-layer scrim.
  • Volume control, per listener, remembered across visits. Position stays shared; loudness is yours.
  • Real empty states, queue numbering and run time, and prefers-reduced-motion support.

🔐 Setup and security

  • First-run wizard at /setup — set an admin password, optionally upload YouTube cookies, optionally enable the AI DJ.
  • /admin is now behind that password. It previously wasn't: anyone who could reach the instance could read your LLM configuration and change it. /api/settings is gated too.
  • Upload cookies.txt from the browser — validated on upload, stored 0600, applied to the very next track without a restart. Clear messages for the usual mistakes (JSON export, wrong site, exported while signed out).

🛠️ Fixes

  • yt-dlp failures say what went wrong. Its stderr was read for progress then discarded, so any failure produced an empty trace. Bot-checks, unavailable tracks, and everything else now report a real reason.
  • Deno is installed in the image — yt-dlp needs a JS runtime for YouTube's player challenge and was silently falling back to limited clients.
  • YTDLP_PROXY for anyone who must run on a datacenter IP.
  • Join with a room code from the home page. Guests told a code aloud previously had no way in.

Upgrading

git pull && docker compose up -d --build

Your rooms, settings, and cache are preserved. On first load you'll be sent to /setup to claim the instance with an admin password — do this before sharing the address.

v0.2.0 — Spotify-style player, shuffle & repeat

Choose a tag to compare

@SAKMZ SAKMZ released this 21 Jul 16:59

Big UX release: Syncwave now feels like a real music app, and it's rock-solid to self-host.

✨ New

  • 🎚️ Spotify / YouTube-Music-style player — a fixed bottom player with shuffle and repeat (off / all / one) as shared room controls, plus play/pause, skip, a scrubber, and a reactions popover.
  • 📲 Mobile-first app shell — fixed top bar + bottom player with tabbed Queue / Add / Chat that fits a phone screen in one view; two-column layout on desktop. Chat is a proper scrolling window.
  • 🎨 Consistent design system — unified rounded pills, panels, and rows (no more mismatched square/rounded controls).

🛠️ Fixes & reliability

  • 🐳 yt-dlp works out of the box in Docker — the image now ships python3 (yt-dlp's Python build needs it); tracks resolve and cache instead of skipping.
  • 📱 Mobile rendering fixed — killed a horizontal-overflow bug that made phones render the desktop layout.
  • Honest caching UX — the progress bar no longer advances while a track is still downloading; it shows a caching/processing state, then starts the shared clock when audio is ready. Failed tracks auto-skip.
  • 📌 Per-room PWA install — installing from a room opens straight back into that room (server-rendered per-room manifest); a secure-context-aware install banner.

📚 Docs

  • HTTPS is required for PWA install — added a clear guide with the easiest paths: Tailscale Serve/Funnel (free, no domain) and Caddy + domain. See DEPLOY.md.

Deploy

cp .env.example .env
docker compose up -d --build

Runs on a VPS or home server (needs a long-lived WebSocket server + disk cache) — not serverless. Node 20+ and ffmpeg required for local dev.

v0.1.0 — Syncwave

Choose a tag to compare

@SAKMZ SAKMZ released this 21 Jul 14:49

🌊 First public release of Syncwave — self-hosted, AI-powered listening rooms. Your own Spotify Jam, on a server you control.

Features

  • 🎧 Perfectly synced playback — a server-authoritative clock keeps every listener locked together.
  • 🔗 One-link rooms — create, share a code/URL, done. No accounts.
  • Shared queue — anyone in the room can search and add tracks.
  • 🗳️ Vote-to-skip — the room decides; the host can always drive.
  • 💬 Live chat + 🎉 floating reactions in real time.
  • 🤖 Optional AI DJ — swappable LLM providers (OpenAI / Anthropic / local Ollama) with selectable personas; degrades gracefully.
  • 📌 Permanent rooms — rooms and settings survive restarts.
  • 📱 Installable PWA — install a room and it reopens straight into that room (per-room manifest).
  • 🎨 Immersive UI (aurora + album-art glow + glassmorphism) with a real mobile layout.
  • 🐳 One-container Docker deploy.

Get started

cp .env.example .env
docker compose up -d --build

Local dev: npm install && npm run dev. Requires Node 20+ and ffmpeg. See DEPLOY.md for VPS / HTTPS / cookies setup.

Runs on a VPS or home server (needs a long-lived WebSocket server + disk cache) — not on serverless platforms.