Skip to content
Rymembra Docs GitHub

Docs

Listen together

Several Rymembra on the same local network play the same music in sync, close enough for loudspeakers in one room. One PC hosts, the others follow; every PC decodes for itself, the connections are encrypted, and two PCs trust each other only after their users compared the same four-digit code.

Listen together on the LAN#

Source core/rymembra-insieme/src/lib.rs core/rymembra-insieme/src/rete.rs core/rymembra-insieme/src/messaggi.rs core/rymembra-insieme/src/scoperta.rs core/rymembra-insieme/src/ascolto.rs apps/desktop/src-tauri/src/insieme/mod.rs apps/desktop/src-tauri/src/insieme/fasi.rs apps/desktop/src-tauri/src/insieme/filo.rs apps/desktop/src-tauri/src/insieme/voci.rs apps/desktop/src-tauri/src/insieme/ipc.rs apps/desktop/src-tauri/src/insieme/strada.rs apps/desktop/src-tauri/src/riproduzione/insieme.rs apps/desktop/src-tauri/src/riproduzione/ospiti.rs core/rymembra-app/src/insieme.rs apps/desktop/src/insieme.ts apps/desktop/src/parti/Insieme.tsx

Off by default (insieme.attivo). Switched off, there is no socket and no packet. Switched on and outside a session, the PC runs a discovery round every 10 s — and at once when the panel opens — and keeps a control connection to every host it has seen (at most 16), reconnecting to trusted ones with a backoff from 5 to 60 s. The backoff starts over when the person presses «Search», changes the hand-typed addresses, or the host turns up at a new address: a wait of up to a minute, right while someone is trying to connect, was the «nothing happens» of the first test between two real PCs.

How it is used

  1. Settings › Listen together: turn it on and name the PC (default: the computer name; insieme.nome, at most 64 characters). The same card lists the hosting PCs it finds, with «Pair» and «Ask to join», and «Open the panel» opens the full panel.
  2. A hosts: «Host a session» from the button in the player bar, the third column or Settings. The first time, a dialog explains the Windows Firewall prompt (see below). If a paired PC is already hosting, A is offered to join that session instead. While hosting, the panel and Settings show «This PC's address: 10.0.0.5:47811» with «Copy»: the address of the default route first (found with a UDP connect to a documentation address, which sends nothing), virtual adapters last.
  3. First contact: on B the panel lists «A's PC» with «Pair». Both screens show the same code, «48 21»; both users confirm, and from then on the two PCs trust each other.
  4. Joining: A presses «Invite» on a connected paired PC and B gets a banner with Accept/Decline and a 60 s countdown (the window is brought forward from the tray); or B presses «Ask to join» and A admits. Either way A picks, next to the button, what B will be able to do — Listen, Control or Everything, see «Who can do what» below.
  5. In the session a strip at the top of the page says «Listening with A · 3 people», with Leave (End, for the host); the panel adds who is there, invitations and calibration.
  6. Leaving gives everyone their own queue back, exactly as it was.

Roles and topology

The host is the only source of truth for the queue, the current voice, pause and position, and its audible output is the reference clock. Only the host listens: TCP and UDP on the same port, PORTA = 47811, then the next nine, then any port (the discovery answer says which). Members only connect outwards — they find hosts with a directed UDP broadcast on every IPv4 subnet of every adapter (if-addrs, loopback and 169.254 excluded) plus 255.255.255.255, sent to all ten candidate ports, plus a unicast query to each address typed by hand, and receive unicast answers — so the firewall prompt appears on one PC only and a member is never «unreachable». Every connection carries a heartbeat every 5 s, and 15 s of silence closes it. If the host goes away (connection lost, or that silence) the session ends for everyone: there is no hand-over. At most MASSIMO_PERSONE = 9 people, the host included (insieme.sessionePiena beyond), and 1,000 voices in the session queue.

Each PC decodes the audio itself. A member reads the host's file through a LAN source wrapped in FlussoHttp::da that declares remoto() = false, so seeks inside VBR files stay accurate (Seekable HTTP streams); catalog tracks are opened by every PC from its own catalog and never relayed (TERMS.md § 2).

Handles and session rules

In the player, an id above zero is a library row of this PC and an id below zero is a session voice; riproduzione/ospiti.rs::chi is the only place that tells them apart. The host assigns voice uids from 1; each PC maps them to its own negative handles, and the uid is also the engine label of the anchors. Library gestures — like, rating, playlists, show in folder, go to album or artist, lyrics — are hidden on negative ids in the window (idDiLibreria in insieme.ts) and refused by the core.

Session rules are overrides in memory, never written to settings: crossfade 0; no autoplay or radio; no shuffle; the sleep timer is cancelled on entry and refused during; the saved queue (scrivi_coda) is suspended, so the queue on disk stays the user's; positions are never saved for handles. If the app crashes mid-session, settings and saved queue are intact. Only the owner of a row counts the listen and scrobbles it.

Every transport and queue command passes insieme::instrada first, without the player lock — the media keys, the taskbar thumbnail and the tray call the same functions, so they pass too. strada::regola is exhaustive: on the host, tracks entering the queue become voices (LocaleConVoci); on a member transport and queue are forwarded to the host (phase 2) and everything that would change the music for the others without them seeing it is refused (insieme.gestoNonDisponibile). Volume, EQ, output and latency are always local.

PhaseWhat it adds
1The host leads and the members follow, with the audio streamed from the host. A member whose level doesn't cover a gesture has its button greyed out (diritti in StatoRiproduzione.insieme), and the session strip says why.
2Jam, within the level the host gave (Diritti). A member's gestures travel to the host as Comando/Esito, rate-limited per member, and run through the host's own code paths — translated from queue positions to voice uids, so two people moving the same queue never hit the wrong row; for pause and seek the last one wins. A member's own tracks are served over a service connection the member opens towards the host, which relays the bytes to the others: the member still opens no port. Shuffle, clearing the queue and the queue «Undo» stay refused for members. The host can «Remove» a member, whose voices leave the queue; every queue row that belongs to another PC says whose it is («Marco's», from SessioneInsieme.proprietari).
3Local copy: if the receiver has an identical file it plays that instead of the stream, and the strip says «from a local file». The quick fingerprint is blake3(size as u64 little-endian ‖ first 64 KiB ‖ last 64 KiB). A copy is certain with the same UNC path (only \\… shares travel), size and date; it is verified with the same content key, size and duration within 500 ms, and a matching fingerprint read from the local disk. Image-CD voices accept only a certain copy. A copy found counts as a listen of the receiver's own row.

Who can do what

The host chooses, every time someone joins, one of three levels (Diritti in messaggi.rs), and nothing is remembered per PC: the next time starts again from the preselected Control.

LevelWhat the member can do
Listen (ascolta)Nothing that changes the music for the others.
Control (comanda)Pause and resume, next, previous, seeking, repeat.
Everything (tutto)Also the queue: play a voice of the queue, remove and reorder voices, and add tracks of their own (play, play next, enqueue).

Volume, equalizer, output and latency are each person's own at every level, and shuffle, radio, autoplay, the sleep timer, clearing the queue and «Undo» stay refused for every member. The choice sits next to «Let in» on a join request (in the strip above the page and in the panel, one choice for both), next to «Invite» before the invitation leaves — the level travels with it and applies when the answer arrives — and next to «Remove» in «In the session», where the host changes it at any time. Arrow keys move through the three tabs and the change is sent once they settle (ATTESA_CONCEDI, 400 ms), so passing from Listen to Everything does not grant Control on the way; the member gets one notice (together.notice.rights*).

The host is the door. fasi.rs checks every Comando against the member's level as it arrives, before the rate limiter and before anything runs — an Aggiungi refused there registers no voice and relays no token — and answers insieme.nonConcesso, whatever version sent it. A member does not even send what isn't its own: the level rides in the role atomic (Ruolo::Membro(Diritti)), strada::regola returns NonConcesso, and media keys, the taskbar and the tray log it instead of asking. The window greys out transport and queue (gestoPermesso) and, in the selection bar, Play, Play next and Enqueue; the library menus leave out «Play next» and «Enqueue». Azione::ambito (host) and strada::ambito (member) split gestures the same way, and a test keeps them in step. Gestures already queued on the host when the level drops still run: they passed the door before the change.

The level travels in Ammesso and in each Persona of the session state, with #[serde(default)]: a host that predates the field reads as Everything, as before; an unknown level reads as Listen, the strictest.

Port and firewall

On a network Windows classifies as Public, incoming traffic is blocked and Windows asks the first time a program listens. Before the first «Host» the window explains it (insieme.firewall_spiegato): choose «Allow» and tick «Public networks» too, or set the network to Private first. «Cancel» in that prompt creates permanent block rules, which are removed in Windows Security › Firewall & network protection › Allow an app through firewall. A host behind a silent firewall is indistinguishable from a host that is off, so a member that sees nobody gets a list of causes to check instead of a guess.

Where broadcast does not get through — guest Wi-Fi, VPNs, isolated access points — up to 16 addresses can be typed by hand (insieme.indirizzi, IPv4 ip:port, port 47811 when omitted); discovery also sends its query to each of them by unicast.

Can't see the other PC

  • Both PCs run Rymembra 3.1 or later, with «Listen together» on, and the other one is hosting: only a hosting PC answers discovery.
  • Same network: a guest Wi-Fi, a VPN or an access point with client isolation keeps broadcasts apart. Type the address the host shows («This PC's address») under Settings › Listen together › Addresses typed by hand.
  • On the hosting PC the firewall lets Rymembra through, for «Public networks» too if Windows marks the network that way — Windows Security › Firewall & network protection › Allow an app through firewall.
  • An unpaired connection lasts two minutes (insieme.estraneoScaduto); a pairing in progress keeps it alive, and the PC reconnects by itself as soon as discovery sees the host again. A host that is already pairing another PC answers «busy» (insieme.associazioneOccupata) without counting it as a failed attempt.

If a command is slow

The log (diario) says where the time goes, in milliseconds and codes only: «gesture «pause» answered by the host in N ms» on the member when an answer takes more than 300 ms or is a refusal; «gesture … of member N slow: X ms queued, Y ms to do it» on the host; «the session thread stayed busy N ms (…)» and «network mail waited N ms» when the session thread falls behind (at most one line every 5 s, with the count of the ones left unsaid).

What made gestures slow or lost in the first test between two PCs, and what changed: the host rebuilt and resent the whole session state — the queue window, up to a thousand voices — up to ten times a second while it was still describing the voices of a new queue, and a member's pause waited seconds behind it. A state that changes only because more voices are described (or a copy descriptor arrived) now goes at most once a second, the voice that plays is described before the others, a state over the frame limit is trimmed in linear time, and messages above a few kilobytes are compressed on the wire. A gesture that names a voice that no longer plays (two people pressing «next» together) is answered insieme.superato instead of a silent «done».

The window

One store (creaArchivio) in parti/Insieme.tsx, fed by insieme:stato — a full snapshot at every change, including whose each foreign voice is (proprietari) and whether the calibration clicks are playing (calibrazione) — and insieme:avviso, which carries an i18n key (together.notice.* or errors.insieme.*) and at most a PC name: no free text from the core or from another PC reaches the screen. Even the calibration voice's title is written by the window, in the language of whoever is looking (InsiemeNelLettore.calibrazione). Command answers are adopted only if no event arrived meanwhile, so a late answer never puts the past back on screen. Errors and notices that arrive while the panel is open are drawn inside it — behind its veil they were invisible — and the session queue rereads its rows when InsiemeNelLettore.revisione changes, so covers that arrive later appear (session cover keys are served with no-store: voice numbers restart every session). The settings keys and the insieme_pari table (migration 035) belong to this machine and stay out of profiles, Drive backups and device sync.

See also: Encryption, pairing and what travels in clear, Synchronisation and calibration, Output devices, stream sleep and latency, Seekable HTTP streams, Device sync

Encryption, pairing and what travels in clear#

Source core/rymembra-insieme/src/identita.rs core/rymembra-insieme/src/canale.rs core/rymembra-insieme/src/cornice.rs core/rymembra-insieme/src/codice.rs core/rymembra-insieme/src/servente.rs core/rymembra-insieme/src/cliente.rs apps/desktop/src-tauri/src/insieme/fiducia.rs core/rymembra-insieme/src/rete.rs core/rymembra-insieme/src/messaggi.rs core/rymembra-domain/src/errors/catalog.rs core/rymembra-app/src/insieme.rs

Every connection is Noise XX_25519_ChaChaPoly_SHA256 (snow) with a fixed prologue, which binds every handshake to this protocol; it has a 5 s handshake timeout and TCP_NODELAY. Afterwards each side knows the other's static key, and both send Ciao{version, name, hosting}, a message whose shape never changes so that two versions can always tell each other they differ. A different protocol version closes with insieme.versione. A Noise message travels as a u16 length plus the message; an application message is a u32 length — at most 1 MiB, checked before allocating — a type byte (JSON, or raw bytes for audio and covers: no base64) and the payload, split into Noise messages of at most 65,535 bytes. Every incoming Messaggio goes through valida(); one that fails closes the connection with insieme.protocollo, unknown kinds are ignored.

Identity

Each PC has a static X25519 key. The secret half lives in the Windows Credential Manager under insieme.chiave.<device id> (Portachiavi::DiSistema); the device id in the name keeps two data folders of the same Windows user from sharing an identity. The secret is zeroed in memory when it is dropped. If the keychain does not answer the feature stops with insieme.identitaPersa — no fallback to a temporary key, which would silently undo every pairing at each start. Paired PCs are rows of insieme_pari holding only public keys.

The four-digit code

  1. The member (who pressed «Pair») sends Impegno = H(n_m ‖ the two keys in byte order): a commitment to a 32-byte nonce it does not reveal yet.
  2. The host answers with its own nonce n_h.
  3. The member reveals n_m; the host checks it against the commitment in constant time, and a false reveal closes with insieme.codiceRifiutato.
  4. Both compute the code from the first four bytes of H(handshake hash ‖ n_m ‖ n_h ‖ the two keys in byte order), as a u32 mod 10000, shown as «48 21»; both users must confirm it matches.

Without the commitment, someone in the middle could try nonces until the two codes collided; with it, each side's nonce is fixed before seeing the other's, and a man in the middle has one chance in ten thousand. H is blake3 in key-derivation mode, with one context for the commitment and another for the code, so neither can ever stand for the other. Limits: a host runs one pairing at a time; an attempt is counted as soon as the commitment is sent or received and cleared only when it succeeds, so an interrupted one counts as failed — otherwise someone in the middle could open many handshakes at once and reveal only where the code matched. 3 failed pairings in 10 minutes per key and 10 in total in the same window (insieme.troppiTentativi); a pairing lasts at most 2 minutes. Only the member starts a pairing (on an outgoing connection) and only the host answers one (on an incoming connection). Pairing with one's own key — two instances on the same identity — is refused.

What travels in clear

Only the discovery datagrams. The query is 40 bytes: magic RYMI, version, type, a 16-byte nonce and padding. The answer is 34 bytes: magic, version, type, port, the 8-byte fingerprint (Impronta, the first 8 bytes of blake3 of the public key) and the echo of the nonce; an answer with the wrong echo is dropped — which keeps out stale or stray answers, not someone on the same LAN, who sees the nonce. That is why a member connects to a discovered host with collega_atteso: after the handshake the host's key must match the fingerprint the answer gave, or the connection closes. Answers are de-duplicated per round (at most 64). No names, titles or addresses. Only hosts answer, at most 4 answers per second per IP and 50 in total, and an answer is shorter than the question. A PC that hosts is therefore recognisable on the LAN; its name is not.

Reading another PC's file

The host serves a 16-byte random token per file and per session, only to keys that are trusted and currently members of the session (Rete::ammessi), in reads of at most 2 MiB; the path comes from its own library, never from the wire. Each read reopens the file and re-checks size and date (insieme.fileCambiato), and revoking a token ends reads in progress with insieme.gettoneScaduto, answers already queued included. A member's token relayed by the host (inoltra) can never take over a token of the host or of another member: the call says no, and the member's addition is refused. Reads travel on their own connections, separate from control so that two megabytes queued ahead of an anchor never delay it; a member keeps at most two of them, reusable, and cancelling a read drops the connection rather than waiting for it.

Caps: 48 connections, 6 per address, 4 handshakes in progress and 2 per address, whose first message must arrive within a second; at most 2 read connections and 1 service connection per trusted key; at most 8 MiB of answers waiting to be written on a connection, after which the host stops serving reads until TCP drains them; 9 people, 1,000 voices, and a queue of 256 messages per connection. A key that is not trusted may open only control connections — at most 8 in all and 1 per address — may send nothing larger than 4 KiB and nothing compressed, and is dropped after 2 minutes unless it is pairing (insieme.estraneoScaduto). Application messages over 8 KiB travel deflated (Tipo::JsonCompresso, raw deflate level 3): a 971-voice state goes from about 600 KB to about 120 KB in 2 ms, inflating stops at the peer's JSON limit without reserving more, and when several states are queued for one connection only the newest is written. Only error codes of the Insieme catalog domain cross the wire, never sentences: each PC translates them in its own language, and a foreign PC cannot make a sentence of its own appear on someone else's screen. The log gets codes, counts and pari #id only — never IP addresses, PC names, titles, tokens, keys or pairing codes.

See also: Listen together on the LAN, OAuth and the keychain

Synchronisation and calibration#

Source core/rymembra-play/src/tempo.rs core/rymembra-play/src/ancora.rs core/rymembra-play/src/sincronia/orologio.rs core/rymembra-play/src/sincronia/regolatore.rs apps/desktop/src-tauri/src/riproduzione/insieme.rs apps/desktop/src-tauri/src/insieme/segui.rs apps/desktop/src-tauri/src/insieme/filo.rs apps/desktop/src-tauri/src/riproduzione/riapertura.rs apps/desktop/src/parti/UscitaAudio.tsx apps/desktop/src/parti/Insieme.tsx

The target is the same room with loudspeakers: under 20 ms acoustically on wired and USB outputs, Bluetooth «as good as it gets after calibration». The model is Snapcast's: anchors, a clock estimate between PCs, and a regulator on each member.

  • One time base per process (rymembra_play::tempo), nanoseconds as u64: the engine's anchors and the transport's clock samples use the same one, or the offset estimate would be meaningless.
  • Anchors: the audio callback publishes «content P is heard at local instant T» — cpal's playback timestamp plus the output latency — through a seqlock. The host sends Ancora 4 times a second and at once on every change, labelled with the voice uid; a member only compares anchors with the same label. When the host's engine has nothing in its hands but the session has a current voice — an open from a network share that failed — the host sends an explicit «stopped» anchor for that voice, so members fall silent with it instead of playing on alone.
  • Clock: NTP-style ping/pong on the control connection; t2 is taken right after the read and t3 right before the write, in the reading thread. A ping every 250 ms for the first 10 s, then one a second. The estimator filters on minimum round-trip and regresses offset and drift; an exchange whose round trip exceeds 100 ms (rtt_tetto_ns) is discarded whatever the minimum. The cap was 20 ms, and on a busy Wi-Fi every exchange went over it: the member never got an estimate and never started. Between two PCs the regulator never runs without an estimate: until there is one, the member waits and the window says «syncing…».
  • Regulator at 10 Hz on each member: Riapri beyond 500 ms of error; Gradino between 20 and 500 ms — positive is silence (the member is ahead and waits), negative skips music — with the 12 ms gate fade; Deriva for slow drift, dropping or repeating single interpolated frames up to ±200 ppm.
  • Scheduled start: resume, seek and the start of a new track are scheduled 250 ms ahead with riprendi_a, sample-accurate, so everyone starts together; the anchor announces the future instant. Pause is immediate, and the few milliseconds a member stops late are recovered by the next resume. A scheduled start (AvviaA) is never executed as a step: while the engine is paused the callback drops steps.
  • Gapless: a member pre-opens the next voice of the mirrored queue, but the switch is decided by the host's anchor; if the next voice did not arrive in time, the regulator reopens it.
  • A member's own output: when it goes missing, or the track it was opening fails, «Retry» on the member reopens that PC's output if it was lost, and does nothing else (riprova_corrente). It used to become a play, and a member's play is sent to the host: one PC's broken headphones took the whole session out of pause. The host's track needs no button — the regulator reopens it whenever the member has nothing playing.

Calibration

What the anchors cannot see is what happens after the driver: the mixer, the DAC, the amplifier, and on Bluetooth the radio. That latency is corrected per output (audio.latenza_per_uscita, by device name) with a slider in 1 ms steps, ±300 ms in the panel (±500 in Settings › Output). During calibration the host plays the voice UID_CALIBRAZIONE — identical mono click bursts of 2 ms at fixed pseudo-random intervals, generated in memory on every PC, so no byte crosses the network — and each person moves their own slider. The window shows whether the clicks are playing from SessioneInsieme.calibrazione, so members see it too.

The criterion written in the panel: stand halfway between the speakers and move the slider until the click seems to come from the centre; with Bluetooth headphones alongside speakers, until the double hit disappears. Not «until the clicks merge»: because of the precedence effect they already merge at a few milliseconds of offset, which would leave the correction half done. When calibration stops, the previous voice resumes from where it was.

See also: Listen together on the LAN, Output devices, stream sleep and latency, Engine structure