24 KiB
RemSound relay upgrade — lobby model with auto-updates
Design + ops handover for upgrading the existing remsound-relay.py from a two-slot pairing reflector to a small lobby-style multi-peer relay. Same bundle works on a Raspberry Pi (the existing deployment) and on a full Linux host (Andre's box). Includes a self-update mechanism so once a host is on the new bundle, future releases roll out without anyone manually copying files.
The existing two-slot relay (server/remsound-relay.py on GitHub) stays usable. The lobby relay is a parallel script — same package, additional file — so existing hosts that just want a private two-peer relay keep working unmodified.
1. What's changing and why
Current state
remsound-relay.py has two peer slots. The first two UDP endpoints to send a valid RemSound packet claim the slots; everything from one slot is reflected to the other. Third endpoint is dropped silently. Slots get reclaimed after 60 s idle.
This means:
- Hard cap of two connected peers per relay.
- Two clients behind the same NAT can both register (different ephemeral ports), but they get paired with each other instead of with the intended remote peer.
- A third RemSound instance joining an active pair gets ignored with no feedback.
What we want
- Any number of RemSound instances (up to a configurable cap, default 10) all able to be in the same conversation through one server.
- Each instance is identified by a stable client ID, not by its network endpoint. NAT rebinds, network switches, and same-NAT-multiple-clients all stop being special cases.
- The relay forwards each packet to every other registered client. No mixing on the server. Clients use
PlayoutEngine's existing per-session mixing exactly as they do today. - The relay logs joins, leaves, and per-minute stats. It never decodes audio and never logs payload bytes.
- The relay auto-updates: when a new release is published on GitHub, every running relay picks it up within an hour and restarts itself.
What we're not adding (yet)
- Rooms / channels. One server = one lobby. Want a second lobby? Run a second instance on a different port.
- Server-side mixing. Forwarding is simpler, faster, and matches RemSound's existing client-side mix path.
- Voice activation / push-to-talk gating at the server.
- Authentication or lobby passwords.
All of those are future work and don't need to block this change.
2. Lobby model
Capacity
- Cap = 10 clients per lobby. Configurable on the server (
--max-clients). - 11th client gets a
LobbyFullcontrol packet back with the current member count. Client surfaces a "Lobby is full (10 / 10)" message; no queue. - At PCM-stereo (~1.5 Mbps/stream) a 10-peer lobby is ~13.5 Mbps downlink per client. Opus 192 kbps brings that down to ~1.7 Mbps. Both fine on consumer broadband.
Identity
Each RemSound instance generates a UUID once and persists it in remsound.config.json (machine-local, not per-profile, not part of the profile JSON). Survives reboots, profile switches, network changes. Two instances on the same machine have distinct IDs — that's exactly how the "DT + LT both behind same NAT" case stops being special.
Forwarding rule
- Every packet from a registered client gets forwarded to every other registered client in the same lobby. Unchanged bytes, including the original sender's CLIENT_ID, so receivers know who the audio came from.
- Sender does not need a destination peer list any more. It sends one stream to the server; the server fan-outs.
- Receiver routes incoming packets to the matching
SessionPlayoutkeyed by CLIENT_ID (instead of by(endpoint, streamId)as today).
Idle handling
- Per-client idle timeout, 60 s (same as the current relay). Goes silent → expire that client. Doesn't affect anyone else in the lobby.
- Roster broadcast (see §4) fires whenever lobby membership changes, so other clients learn about joins / leaves promptly.
3. Wire format v2
The relay must distinguish v1 packets (legacy remsound-relay.py clients) from v2 (lobby-aware clients) and route accordingly.
v1 header (existing, unchanged)
offset size field
0 4 MAGIC = 'RMND'
4 1 VERSION = 1
5 1 TYPE (Format=1, Audio=2, KeepAlive=3, Heartbeat=4, Control=5)
6 2 STREAM_ID (LE)
8 4 SEQUENCE (LE)
12 payload...
Total 12 bytes.
v2 header (new)
offset size field
0 4 MAGIC = 'RMND'
4 1 VERSION = 2
5 1 TYPE (Format=1, Audio=2, KeepAlive=3, Heartbeat=4, Control=5,
LobbyHello=6, LobbyRoster=7, LobbyFull=8, LobbyBye=9)
6 2 STREAM_ID (LE)
8 4 SEQUENCE (LE)
12 16 CLIENT_ID (UUID, RFC 4122 binary form, big-endian by convention)
28 payload...
Total 28 bytes. CLIENT_ID slot is the only structural addition.
Backward compatibility
Server inspects byte 4 (VERSION):
0x01→ v1 client. Apply the existing two-slot pairing logic, identical to today. Lets unmodified legacy clients keep working against the new server.0x02→ v2 client. Apply lobby logic.
A single relay instance handles both protocols concurrently. A v1 client and a v2 client cannot share a lobby in this release — that's a deliberate limitation. Practical answer: anyone running a fresh server runs the new bundle, and any client wanting to use a lobby upgrades to a v2-aware build. v1 clients keep working against the same server for pairwise use.
New packet types (v2 only)
- LobbyHello (6) — client → server. Sent immediately after the first audio/format packet, but redundant if those were the first thing seen. Carries the client's display name (UTF-8, max 32 bytes, padded with null). Server uses this for roster announcements.
- LobbyRoster (7) — server → client. Sent on every membership change (someone joins, someone leaves, someone updates display name) and periodically at ~1 Hz heartbeat. Carries
countfollowed bycount×{CLIENT_ID(16), display_name(32 bytes, null-padded UTF-8)}. Max realistic size: 10 × 48 = 480 bytes + header = well under MTU. - LobbyFull (8) — server → would-be client. Sent in response to a registration attempt when the lobby is at capacity. Carries
current_count(1) max_count(1). - LobbyBye (9) — client → server, or server → client. Carries no payload (or just a reason code). Sent on graceful disconnect (client closing) or eviction (server pruning).
These are all small, low-rate, never on the audio hot path.
4. Server design
State
# Per-client entry. Identifies who, where, when last seen.
ClientEntry = (
endpoint: (host, port),
display_name: str,
last_seen_monotonic: float,
rx_packets: int,
tx_packets: int,
)
# The entire lobby.
clients: dict[uuid.UUID, ClientEntry] = {}
max_clients: int = 10 # configurable
Single flat dict, keyed by CLIENT_ID. No nested rooms.
Packet flow
For an incoming UDP packet with (data, addr):
- Parse header. If
MAGIC != 'RMND': drop (rejected_bad_header++). - If
VERSION == 1: route through legacy two-slot logic (unchanged from today). - If
VERSION == 2: a. ReadCLIENT_IDfrom bytes 12..28. b. IfCLIENT_ID not in clients:- If
len(clients) >= max_clients: sendLobbyFulltoaddr, drop the original packet (dropped_lobby_full++). - Else: insert new
ClientEntry(addr, display_name="", monotonic_now, 0, 0). Logevent=client_joined. Schedule a roster broadcast. c. Existing entry: refreshendpoint(handles NAT rebinding) andlast_seen_monotonic. Bumprx_packets. d. By packet type: LobbyHello: extract display name, store. Schedule a roster broadcast.LobbyBye: remove the entry. Logevent=client_left. Schedule a roster broadcast.Audio/Format/KeepAlive/Heartbeat/Control: forward to every OTHER client inclients. Bump each recipient'stx_packets.- Anything else: drop.
- If
Roster broadcast
- Sent whenever a join / leave / name update happens.
- Also sent periodically (every 1 s) as a heartbeat, so clients quietly detect disconnects when their roster goes empty.
- Built once per cycle, sent unmodified to every connected client.
Idle expiry
- Once per main loop iteration: walk
clients, remove any entry whoselast_seenis older thanIDLE_TIMEOUT_SECONDS(60). Same timeout as today. - On removal, log
event=client_idle_expiredand schedule a roster broadcast.
Logging
Same format as the current relay: structured key=value lines to /var/log/remsound-relay.log and stderr. New events:
event=client_joined client_id=<uuid> addr=<ip:port> name=<display>event=client_left client_id=<uuid>event=client_idle_expired client_id=<uuid>event=lobby_full attempted_client_id=<uuid> addr=<ip:port>event=stats forwarded=N dropped_lobby_full=N rejected_bad_header=N client_count=N peers=[...]
Never logs CLIENT_ID payload bytes beyond the UUID itself. Never logs audio payload.
Capacity check
def can_admit(client_id: uuid.UUID) -> bool:
return client_id in clients or len(clients) < max_clients
That's it. The cap is a soft limit at admit time; existing clients can never get evicted by a newcomer.
Approximate complexity
For an active lobby of N peers, each audio packet from one client triggers (N - 1) sock.sendto calls. At 10 peers and PCM rates (~1500 packets/sec/client → 10 × 1500 = 15,000 inbound/sec → 10 × 9 × 1500 ≈ 135,000 outbound sendto's/sec). Comfortable on any modern Linux. Even a Pi 4 will handle it; a real server is bored.
5. Client changes (high-level — actual implementation is a separate task)
These are listed so Andre can see the full picture, not because the server bundle has to contain client code.
Profile/AppConfig: newClientIdfield (Guid), persisted inremsound.config.json. Generated on first launch.RemPacket: header reading / writing learns v2 format. Old v1 reads/writes still supported.AudioSender: sends one stream to the lobby server. Drops the per-peer fan-out loop.AudioReceiver/StreamSession: dictionary key changes from(IPEndPoint, ushort)to(Guid clientId, ushort streamId). Endpoint becomes a routing detail.HeartbeatService: peer-health tracking keyed byClientId.Connectivitytab: "Lobby" section showing connected lobby members from the latestLobbyRoster. Replaces the per-pair "Discovered peers" model for lobby connections. LAN discovery still works for non-lobby pair sessions.Selected peerssemantics unchanged: still a tick-to-accept allow-list, but keyed on CLIENT_ID rather than IP.
A migration phase where v1 packets are still emitted lets older clients keep talking to the new server for pair use.
6. Auto-update on the server
Goal
Push a new server release to GitHub → every relay running this bundle picks it up within an hour and restarts itself onto the new version. Andre never has to re-SCP, never has to remember to update.
Mechanism
Three new pieces ship in the bundle alongside remsound-relay.py:
remsound-relay-update.sh— the updater script. Bash. Talks to GitHub.remsound-relay-update.service— systemd unit that runs the updater once.remsound-relay-update.timer— systemd timer firing the updater on a schedule.
The relay binary itself doesn't reach out. Separation of concerns: the relay just relays; the updater just updates. If the updater is broken, the relay keeps running. If the relay crashes, the updater keeps trying to install fixes.
Updater script behaviour
remsound-relay-update.sh:
1. Read current version from /etc/remsound-relay/version (file contains a single line like "server-v2.0").
If the file is missing, treat the installed version as "server-v0".
2. Call GitHub API: https://api.github.com/repos/Ednunp/RemSound/releases
Filter releases whose tag starts with "server-". Take the highest by tag semver.
3. If latest <= current: log "up to date" and exit 0.
4. If latest > current:
a. Look in the release's assets for `remsound-server-<tag>.tar.gz`. If missing, log warning, exit 1.
b. curl the asset to /tmp/remsound-server-<tag>.tar.gz.
c. (Optional v2) Verify SHA256 against a second asset `remsound-server-<tag>.tar.gz.sha256`.
d. Extract to /tmp/remsound-server-<tag>/.
e. Stop the relay: systemctl stop remsound-relay.
f. Copy the new files into place (replacing /usr/local/sbin/remsound-relay.py and
/etc/systemd/system/remsound-relay.service if present in the asset).
g. systemctl daemon-reload, systemctl start remsound-relay.
h. Wait 3 seconds. Check the service is active. If yes: write the new tag to
/etc/remsound-relay/version, log success, clean up /tmp staging.
If no: roll back from a backup of the old files (kept at /etc/remsound-relay/backup/),
restart the service, log failure. Exit 1.
5. Log to /var/log/remsound-relay-update.log.
The updater self-update case (a new updater script is itself shipped in a release) is handled by the same copy step in 4f — the running updater finishes its current cycle, exits, and next cycle the new updater script is what runs.
Systemd timer
# remsound-relay-update.timer
[Unit]
Description=Periodic update check for remsound-relay
[Timer]
OnBootSec=2min
OnUnitActiveSec=1h
RandomizedDelaySec=10min
Persistent=true
[Install]
WantedBy=timers.target
- Fires 2 minutes after boot, then every hour thereafter.
RandomizedDelaySec=10minso multiple relays don't all hit the GitHub API at the same exact instant if you ever run many.Persistent=trueensures missed runs (host was off) catch up on next boot.
Systemd service for the updater
# remsound-relay-update.service
[Unit]
Description=Check for and apply remsound-relay updates from GitHub
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/remsound-relay-update.sh
Restart=no
One-shot. Triggered only by the timer. Failure is logged but not retried — the next hourly tick has another go.
Release tag convention
Server releases use the tag pattern server-vMAJOR.MINOR (e.g. server-v2.0, server-v2.1). Client releases keep their existing vMAJOR.MINOR pattern (v1.4, v1.5). The updater filter (tag startswith "server-") makes the two streams orthogonal: client releases don't trigger relay updates, and vice versa.
A release asset name is remsound-server-<tag>.tar.gz. Tarball contents:
remsound-server-v2.0/
├── remsound-relay.py
├── remsound-relay.service
├── remsound-relay-update.sh
├── remsound-relay-update.service
├── remsound-relay-update.timer
├── install.sh
├── uninstall.sh
├── smoke-test.sh
├── VERSION # contains "server-v2.0\n"
└── README.md
Rollback path
On failed update (service won't come up after install), the updater restores from /etc/remsound-relay/backup/, which install.sh populates at install time (and which the updater itself refreshes before every replacement). If even that fails, manual recovery is the same as today: SSH in, apt install python3 (already there), and copy the bundle from a working host or git clone the repo.
Disabling auto-update
Anyone who wants the relay to stay pinned at a known version simply disables the timer:
sudo systemctl disable --now remsound-relay-update.timer
Service keeps running. No further updates. Re-enabling resumes the schedule.
7. Installation on a fresh host
Raspberry Pi (or any Debian/Ubuntu/Pi OS)
- Download the latest server tarball from the GitHub release page (or have the updater do it after manual bootstrap).
- Extract,
cdinto the folder,sudo ./install.sh. - Open UDP 47830 in the host's firewall and / or router.
Full Linux server (Andre's box)
Same script. The differences are operational, not structural:
- Andre will likely want a non-default port (e.g. 47830 is fine for one lobby; second instance on 47831 if needed). Add
--portflag toremsound-relay.serviceExecStart=line. - He may want the log file under
/var/log/journal/and a smaller log retention; that's ajournald.confconcern, not the relay's. max-clientsconfigurable via env varREMSOUND_MAX_CLIENTSso it can be set in the systemd unit without editing the script.
What install.sh does in the new bundle
Adds three things to the existing five steps:
- Install
remsound-relay-update.shto/usr/local/sbin/. - Install
remsound-relay-update.serviceandremsound-relay-update.timerto/etc/systemd/system/. systemctl enable --now remsound-relay-update.timer.- Write the current bundle's version to
/etc/remsound-relay/version. - Snapshot current files to
/etc/remsound-relay/backup/for the updater's rollback path.
uninstall.sh gets the matching teardown.
Smoke test
smoke-test.sh checks:
- Service is active.
- UDP 47830 has a listener.
- Log file is being written to.
- (New) Updater service exists and the timer is active.
- (New)
/etc/remsound-relay/versionis readable and matches expected format.
8. GitHub release process
When we cut a new server release:
- Bump version in
VERSIONand inremsound-relay.py's startup-log line. - Tag:
git tag server-v2.1 && git push origin server-v2.1. tar czf remsound-server-v2.1.tar.gz remsound-server-v2.1/(where the folder is a staging area with the bundle contents).gh release create server-v2.1 remsound-server-v2.1.tar.gz --title "server v2.1" --notes "…".- Within an hour, every running relay's updater catches the release, downloads it, restarts the service.
We can additionally publish a .sha256 alongside if we want signature-style integrity checks; not strictly required because GitHub serves the tarball over HTTPS.
Release notes content
Keep them short and operational: what changed, what the operator needs to know, anything they need to do manually (usually nothing — that's the whole point of auto-update).
9. Migration phases
Ordered so each step is independently testable and rollback-able. Server work is steps 1–3; client work is 4–6.
- v2 wire format added to client side, still emits v1 by default. No behaviour change. Just makes the new header reading / writing code paths exist. Backward-compatible with existing relays.
- New
remsound-lobby.pyships asserver-v2.0. Handles v1 packets exactly like today's relay (two-slot pairing); handles v2 packets via the lobby logic. Initial deployment: install on onj.me alongside / replacing the current relay. Existing v1 clients keep working pairwise. - Auto-updater bundle goes live. Once steps 1 and 2 are in place, future server changes roll out without manual deployment.
- Client adds CLIENT_ID generation + persistence. New UUID stamped on every outbound v2 packet. Server now sees the same client by ID even if endpoint changes.
- Client receiver re-keys sessions on CLIENT_ID. Internal change. Lobby connections become a real first-class thing in the UI.
- Client UI: lobby tab / lobby roster integration. Replace per-pair manual peer entry with a "connect to lobby" affordance. LAN discovery still works in parallel for non-lobby setups.
Each phase is a separate commit (and release where appropriate). Steps 1–3 are server-side and can ship without any RemSound client release. Steps 4–6 are client-side; they should ship in a single client release (probably v1.5 or v2.0 depending on how disruptive the wire change feels).
10. Testing
Server-side
smoke-test.shruns the basic install-check (listener bound, service active, log present, updater timer enabled).- 2-client v1 pair test: existing test from the current bundle. Should still pass — proves backward compat.
- 3-client v2 lobby test: bring up three RemSound instances (or three test scripts that mimic v2 packets), all dial the relay, confirm each receives the other two's audio.
- Lobby-full test: 11th client receives
LobbyFull. Server doesn't crash. - Idle eviction test: stop one client, wait 60 s, confirm the other clients receive an updated roster.
- Auto-update dry-run: tag a no-op server release, watch the timer fire, confirm the relay picks it up, restarts, and the new version shows in
/etc/remsound-relay/version.
Client-side (once steps 4–6 land)
- Two instances behind the same NAT both connect to the same lobby: each sees the other and the remote peer in the lobby roster.
- Network rebinding (Wi-Fi → Ethernet): client session stays alive; endpoint update is picked up by the server.
- Lobby-full: clean UI message.
- Mixed lobby of v2 clients only (initially we won't support mixing v1 and v2 in one lobby — it's a separate work stream if ever needed).
Production canary
When server-v2.0 is ready, ship it first to one of the relays (the Pi or onj.me, your call), watch logs for a day or two, then deploy to the other. Auto-update + atomic rollback means even a bad release doesn't take both hosts offline simultaneously.
11. Open questions
These should be answered before code starts but aren't blocking for design review.
- Display names: do we want them at all in v2.0, or defer to v2.1? They're nice for UI roster display but not load-bearing for routing. Suggestion: defer. Client sends
LobbyHellowith display name = profile name; server treats missing / empty display names as "Unknown" in the roster. - Authentication: do we need a shared secret to join a lobby on onj.me? Suggestion: not for v2.0. Anyone who knows the address can connect. If unwanted-strangers becomes a problem, add a
LobbyAuth(secret)packet type in a later release. - IPv6: the existing relay binds IPv4 only. For Andre's full server it'd be reasonable to bind both. Suggestion: optional dual-stack via
--bind6. Default IPv4 to match existing behaviour. - Metrics endpoint: would a Prometheus-style
/metricsHTTP endpoint be useful for Andre? Suggestion: not in v2.0. The per-minute stats line in the log file is enough until someone asks. - Recording at the server: a server-side recording feature would be powerful (capture all lobby audio for later playback / archive) but adds the kind of complexity the rest of this design carefully avoids. Suggestion: defer indefinitely.
- Renegotiating CLIENT_ID: if someone wants to wipe their identity (privacy / fresh start), the simplest answer is "delete the line from
remsound.config.jsonand restart". No protocol-level renegotiation needed.
12. Quick reference for Andre
If you're reading this to set up your Linux box once the v2.0 release is out:
# 1. Download and extract the latest server bundle.
curl -L -o /tmp/remsound-server.tar.gz \
https://github.com/Ednunp/RemSound/releases/download/server-v2.0/remsound-server-v2.0.tar.gz
tar xzf /tmp/remsound-server.tar.gz -C /tmp
cd /tmp/remsound-server-v2.0
# 2. Install. This sets up the relay AND the auto-updater.
sudo ./install.sh
# 3. Open UDP 47830 in the firewall.
sudo ufw allow 47830/udp
# (or whatever your firewall is)
# 4. Confirm it's alive.
sudo ./smoke-test.sh
After that you can forget about it. Future updates roll out automatically. If you want to pin a version, sudo systemctl disable --now remsound-relay-update.timer. If you want to remove it entirely, sudo ./uninstall.sh.
Log file is /var/log/remsound-relay.log for the relay itself, /var/log/remsound-relay-update.log for the update history. Both rotate via the system's logrotate defaults.
13. Status
This is a design + handover document, not the implementation. None of the v2 code exists yet. When ready to build:
remsound-lobby.py(~250 lines Python) — the new lobby relay.remsound-relay-update.sh(~150 lines Bash) — the updater.- Two systemd units (~30 lines total) — service + timer for the updater.
install.sh/uninstall.sh/smoke-test.shupdates.- Wire format v2 in
RemSound.Core.RemPacket(~100 lines C# delta). - Per-client UUID persistence + heartbeat / session refactor in the client (~few hundred lines).
- Client UI for the lobby tab (later phase, optional in v2.0 release).
Total scope: a couple of focused days for the server bundle, several days for the client refactor + UI. Server-side ships first and runs alongside the existing two-slot relay without breaking it.