-
Notifications
You must be signed in to change notification settings - Fork 0
Transport Tiers
Every peer's media travels one of three ways. AudioHub calls this the connection method, and it exists for one reason:
When a link gets worse, the cause should be sitting next to the symptom.
With nothing beside it, the only conclusion available to you is this software is bad. With TCP relay beside it, the conclusion becomes this network can only do this — and that one is true.
| In the app | Where | What it is |
|---|---|---|
| Connection method | peer detail page, below the four latency/quality controls | your choice: Auto, Direct (UDP), TCP relay, Single-link mux |
| Current path | the line directly above those buttons | what the bytes are actually doing right now |
| Path: | Stats tab, per session | the same reading, for one stream |
| Fallbacks | Stats tab | the card listing every degraded link with its counters |
The two readings are separate on purpose and are never allowed to impersonate each other. A peer set to Auto can be running on TCP relay right now, and the buttons will still say Auto — because Auto is what you chose.
The CLI and the service log use short codes for the same three things:
tier0, tier1, tier2. Those are the values --tier takes; they never
appear in the interface.
Worth knowing before you read any of the rest, because it is the thing most likely to make you distrust the label: a degraded path does not necessarily show a higher millisecond figure. TCP's handshake is about as quick as UDP's, so a stream can open on TCP relay with a latency reading that has barely moved. What changes first is the number of dropouts.
Measured across two machines, 150 s on each path with the same pair of counters: TCP relay produced +4 jitter-buffer underruns and +13 drops; direct UDP produced +0 and +0. That is why the app's own consequence line reads "May stutter" rather than "Higher latency". The latency reading rises later, as the deeper degraded buffer fills.
TCP for control, UDP for media, both on port 47810. This is the intended path and covers everything reachable at layer 4 or below: same subnet, routed across subnets, or an L3 VPN that presents a virtual NIC (WireGuard, Tailscale and similar).
Media leaves the initiator's socket and the peer replies to the same 5-tuple on the same socket, so only the side being connected to needs a reachable port. The initiator may sit behind NAT or a strict outbound-only firewall.
Full experience, best latency and jitter. Direct (UDP) carries no badge. Marking every healthy peer "all fine" only trains you to ignore the one place that has to be noticed when it changes.
The app's consequence line: Lowest latency and jitter.
Used when layer 4 is reachable but UDP is blocked. Media moves to a second TCP connection to the same address and port the control channel already uses — so it needs no new firewall hole. The frames on it are byte-for-byte the same sealed datagrams UDP carries.
Nothing is removed. Sharing and consuming, volume sync, and the per-peer, per-direction latency and quality settings all keep identical semantics. What changes is timing.
The app's consequence line: No UDP · Full features · May stutter.
The floor. Used when only an application-layer path exists — some VPNs and HTTP tunnels forward at layer 7, which destroys the source-address property and allows connections to be initiated in one direction only.
Whichever side can initiate opens one TCP or WebSocket connection, and control plus both media directions are multiplexed onto it.
One connection means one congestion window and one retransmission timer, so loss in one direction stalls delivery in the other. That interference is inherent to the shape, not a defect waiting to be tuned away. Media is drained first, but the writer guarantees a control frame roughly every 100 ms even under a saturated media queue — measured at 122 ms at saturation, because whatever media frame is already being written has to finish. That is what keeps the round-trip reading honest exactly when the tunnel is at its worst, which is when you are reading it.
The app's consequence line: App-layer only · Shared send/receive link · Most likely to stutter.
Direct → TCP relay is automatic. Nothing is probed during connection setup: a probe would cost its own timeout on every first connection, including the overwhelming majority where UDP works. Media opens on UDP and the answer is read off the media itself. Three signals reach the same verdict:
| Signal | Threshold | Seen by |
|---|---|---|
| A receiving stream has had no media datagram at all | 600 ms | the receiver — the only side that can see "nothing arrived" |
| A sending stream has never been answered by a single keepalive | 3 s | the sender, locally |
| The peer announces its own downgrade | — | either side |
Each verdict additionally requires the control channel to be healthy — a complete control frame received after the stream armed, and the channel still delivering now. Without that clause, a network that died a moment ago would be downgraded and, because the verdict is persisted, pinned there.
Single-link mux is never automatic. Its precondition is a property of your tunnel, and from where AudioHub sits, "a peer we cannot dial" and "a peer that is switched off" produce the same observation. There is nothing to probe for.
Any peer can be pinned, including pinned back to Direct (UDP). A pin beats an observation — pinning Direct is you saying "do not fall back", and honouring the detector there would be the setting failing at the one thing it exists for. Only Auto defers to the detector.
Once degraded, the verdict is remembered and the next connection starts there, so you do not replay the silence every time. It is not permanent: a verdict older than one hour is retired at the next connection, which then starts on UDP and lets the detector look again. Retirement happens between connections — a promotion back to Direct never happens mid-stream.
On a tunnel that only carries connections one way, the side that cannot
originate will keep dialling a peer it can never reach, and the failure looks
exactly like a machine that is switched off. Each peer therefore carries a
dial policy: both (the default), outbound_only, or inbound_only. Set
the side that cannot originate to inbound_only and it stops dialling.
This setting has no interface in 1.0.0 — it is CLI-only, and it must be written together with the connection method, because the two are one decision about how the peer is reached and each write drops the control connection:
audiohub ctl peer-transport --peer <fingerprint> --tier tier2 --dial-policy inbound_only
A peer configured this way is in a third state — not offline, but expected to arrive. 1.0.0's interface does not distinguish it from offline, so between sessions that peer card will read as disconnected even though the setup is working exactly as configured.
The Tunnel address field sits at the bottom of the Connection section on the peer's detail page. It appears once Single-link mux is selected, or whenever an address is already stored — a stored address that you could not see would be one you could not clear either.
| Field contents | What happens |
|---|---|
| Empty | the connection goes to the IP:port recorded at pairing time |
ws://host[:port][/path] |
the connection is wrapped in a WebSocket, for tunnels that forward HTTP only |
Four things worth having in front of you before you fill it in:
It overrides the picker for outbound connections. With an address stored, anything this machine dials uses the single-link mux, whatever the four buttons say. The service decides the carrier with an or — address set, or method pinned to Single-link mux — so the app states the override on screen rather than leaving the picker looking effective. Clear the field to get the picker back. Storing an address deliberately does not move the picker for you; if it did, clearing the address later would leave behind a pin you never chose.
Single-link mux does not require an address. Without one it multiplexes over plain TCP; with one it multiplexes inside a WebSocket. Both are the same connection method. The address is about what the tunnel will carry, not about unlocking anything.
wss:// is not supported in this release. There is no TLS client compiled
in. Point the field at the tunnel's cleartext entrance, or terminate TLS locally
in front of it. The app says "unsupported" rather than reporting the address as
malformed — the two send you looking in different places.
The "add a peer" field also accepts ws://, but does not remember it. That
path asks the service for one connection, not for a setting, so the address is
gone on the next reconnect. The detail page field is the only place it is
stored.
Pairing cannot be performed over a tunnel. Pair over IP:port first, then
set the tunnel address; see
Discovery and Pairing.
On a link where UDP is genuinely blocked, the automatic downgrade means roughly 0.6 seconds of silence before the first sound, because that is how long AudioHub waits before concluding UDP is dead. On every other link the cost is zero. This is also why the verdict is persisted — without it, every connection would replay the same 0.6 s.
This is the steady-state price, and it is the largest user-visible consequence on this page.
A single TCP retransmission timeout is 200 ms or more — Linux clamps its minimum RTO to 200 ms, Windows to 300 ms. On a healthy link the jitter buffer's reachable depth is 6 frames = 60 ms: its nominal ceiling is 12 frames, but the underrun penalty may only add 2 frames to the 4-frame floor, so 12 is never reached.
Any single retransmission is therefore three to five times the entire buffer. It punches straight through and causes an underrun. No amount of tuning fixes that at a 60 ms working depth.
So a degraded link gets its own buffer profile, with the ceiling raised to 400 ms and the penalty loop allowed to climb all the way to it. Measured on a real TCP relay link, the buffer settles at 39–41 frames — about 400 ms, against roughly 60 ms on a healthy link. That is paid on every stream, not just the first.
Stated plainly: 400 ms is derived from the 200–300 ms RTO floors as an order of magnitude. It is not a measured optimum.
On a degraded link, a frame still queued 440 ms after it was enqueued is discarded instead of sent. The number is bracketed by two figures the receiver actually lives by: it sits above the degraded buffer's deepest target (400 ms), so it never throws away audio the receiver would still have played, and below that buffer's hard ceiling (480 ms), so the drop is counted here — where it can be explained — rather than happening silently at the peer.
This is not a new source of loss. TCP delivers in order and hides the loss signal; skipping a frame leaves a hole in the sequence numbers, which the receiver's concealment then handles correctly, as real loss.
The Timed-out drops counter in the Stats tab's Fallbacks card is this gate. While it is still growing, AUTO refuses to raise the quality rung — a link that is dropping audio right now does not get more bandwidth asked of it.
Two promises are made about degraded links — label them honestly and make them measurable — and deliberately no third one.
Latency on a degraded link is set by TCP retransmission, head-of-line blocking, and whatever buffering your tunnel does. None of that is under this project's control. A millisecond figure here would be a number we could not keep.
- AUTO both starts and stops one rung lower. On a direct link AUTO starts at and never climbs past rung 2 (48 kHz · 16-bit). On a degraded link it starts at and never climbs past rung 3 (32 kHz · 16-bit) — the higher-bitrate rungs suffer disproportionately there, and having sound at all comes first. The same AUTO setting therefore lands on different rungs on different paths, by design. See Audio Quality.
- AUTO watches different signals. On a degraded link it moves on this machine's send-queue backlog instead of packet loss, and on the receiver's one-way delay spread instead of frame-to-frame jitter. Two reasons. TCP's failure mode is "pause, then deliver in a burst", and within a burst the frame-to-frame differences are near zero, so a conventional jitter estimate systematically understates the problem. And loss on TCP arrives too late to be useful: it is manufactured by the 440 ms gate above, which by construction cannot fire before the first audible gap — demoting on it would mean waiting to sound bad first. (Loss on a TCP link is emphatically not zero. On an overloaded link it has been measured climbing from 6.1 % to 41.7 %. The argument against it is timing, not absence.)
- The rest is unchanged. A degraded path removes no feature: sharing, consuming, volume sync and every per-peer setting behave identically.
In order:
- Allow inbound UDP on port 47810. Strictly it is only needed on the side being dialled, but either side may be the one that reconnects, so open it on both. It is the same port number already open for TCP control — which is exactly why the relay works while the direct path does not.
- Pin the peer to Direct (UDP) to test immediately — a pin beats the stored verdict, so this does not wait for anything. If audio flows, the hole is open. If it goes silent, it is not.
- Otherwise wait. A stored verdict is retired at the first new connection made more than an hour after it was recorded, and that connection starts on UDP again.
- The connection method is one state per peer, not per direction. An asymmetry (say, only inbound UDP blocked) is carried by the reason text, not by splitting the peer into two states.
- A degraded path is top-level information, next to the latency number — not a badge in a card corner far from the number it explains.
- The text says what the traffic is doing and is always followed by a
consequence clause, because a reader who is told only "Single-link mux" is
being handed a noun when what they wanted was "so what". The internal codes
tier0/tier1/tier2stay in the log, the CLI and the IPC layer. - "Not yet determined" and "determined to be direct" never render the same way. An undetermined path shows a greyed dash, and the app says which of the two reasons applies: the service does not report connection methods, or the peer is offline so there is no path to identify. Direct (UDP) does not impersonate unknown, and unknown does not impersonate Direct.
- The full detail stays on the Stats tab: the Fallbacks card lists every degraded link with its address, liveness, send backlog and timed-out drops. Promoting information to the top level does not mean moving it there. Those counters only appear while a degraded link exists — on a healthy system the card is empty and says so.
Being specific, because "supported" and "shipped" are different words:
| Capability | Status |
|---|---|
| Direct (UDP) | Working, the normal path |
| TCP relay transport | Implemented, and measured across two machines against the direct path (the 150 s A/B above) |
| Automatic Direct → TCP relay | Implemented. Three independent signals trigger it: inbound media silence (600 ms), keepalive silence (3 s), and a downgrade the peer announces |
| Remembering a downgrade | Implemented. Persisted per peer, retired at the first connection more than an hour later |
| Single-link mux | Implemented; never yet exercised between two machines |
| Dial policy | Implemented in the service and the CLI; no interface, and the resulting "waiting for inbound" state renders as offline |
Start here
Using AudioHub
- Window and tray
- Operating Modes
- Virtual Driver
- Discovery and Pairing
- Peer detail page
- Share Protocols (Sharing tab)
- Web Access
How it behaves
Reference
- Settings Reference
- Platform Notes
- Firewall and network
- CLI Reference
- Versions and Compatibility
- Glossary
- Licenses and Attribution
Troubleshooting and removal
This wiki describes AudioHub 1.0.0.