No description
  • Go 96.1%
  • Shell 2%
  • Python 1.5%
  • Makefile 0.4%
Find a file
BuildUser e10f0cb282 Choose the devices rather than asking, and drop the rest of the settings
Nine hundred and fifty lines of configuration removed, most of it questions
with only one sensible answer.

Virtual devices are no longer picked from menus. A camera is taken from
whichever loopback nodes are free, an audio device is created for the source
and named after it, and each source has two switches instead. Which device
carries which source is not a decision worth putting to anyone, and every
question asked is another way to end up pointing at the wrong thing. What was
chosen is remembered, so a scene built against a device keeps working.

Gone with it: the manual delay and safety margin, since the delay tracks the
slowest source and a number typed into a box cannot improve on that; the fast
first frame switch, since joining at a keyframe is simply correct and the
alternative leaves sources permanently offset; the pinned size, made
redundant by reading the picture size from the stream; the timestamp rebase
on outputs, which discards the alignment this program exists to produce; and
the preview size, now taken from what the source actually sends.

The program view keeps one window per source and loses the tiled grid, so the
same rig is set up the same way on both platforms: a source becomes a device
on Linux and a window on Windows, one for one.

What remains is what cannot be worked out: which ports to listen on, the
shared secret, where the outputs go, the per-source offset, and where to look
for updates.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 19:22:23 -04:00
build Add a desktop client, and check for updates 2026-09-06 22:29:01 -04:00
clientui Add a desktop client, and check for updates 2026-09-06 22:29:01 -04:00
cmd Diagnose the audio side, and name packages this machine actually has 2026-09-06 23:48:43 -04:00
internal Choose the devices rather than asking, and drop the rest of the settings 2026-09-07 19:22:23 -04:00
ui Choose the devices rather than asking, and drop the rest of the settings 2026-09-07 19:22:23 -04:00
.gitignore Initial commit: synchronised multi-machine OBS relay 2026-09-06 00:02:58 -04:00
go.mod Carry audio alongside the virtual camera, and set devices up from the app 2026-09-06 20:57:04 -04:00
go.sum Carry audio alongside the virtual camera, and set devices up from the app 2026-09-06 20:57:04 -04:00
LICENSE Read what size each source is actually sending 2026-09-07 01:32:20 -04:00
Makefile Add a desktop client, and check for updates 2026-09-06 22:29:01 -04:00
README.md Choose the devices rather than asking, and drop the rest of the settings 2026-09-07 19:22:23 -04:00

StreamSink

StreamSink accepts live streams from several machines running OBS, holds them until they line up, and hands them onward to whatever actually broadcasts them. Frames captured at the same instant on different machines leave the sink at the same instant.

It is a GTK4 desktop application. There is no web interface and no browser involved.


The problem it solves

Point three copies of OBS at one server and you get three streams that have nothing in common. RTMP timestamps restart at zero on every connection, so the receiver cannot tell whether cam1's frame 400 was captured before, after, or at the same moment as cam2's frame 400. Aligning on arrival time does not fix it either, because arrival time includes each machine's network latency, which differs and drifts.

StreamSink solves this by putting a small agent on each OBS machine.

  MACHINE A                          THE SINK                      DOWNSTREAM
  ┌──────────────────┐              ┌──────────────────────┐
  │ OBS              │              │                      │
  │  ↓ rtmp loopback │              │   sync buffer        │
  │ streamsink-agent │═════════════▶│   ┌───────────────┐  │
  └──────────────────┘   capture    │   │ A held 180ms  │  │      ┌──────────┐
                         time in    │   │ B held  60ms  │──┼─────▶│ upstream │
  MACHINE B              the sink's │   │ C held   0ms  │  │ push │ RTMP     │
  ┌──────────────────┐   timebase   │   └───────────────┘  │      └──────────┘
  │ OBS              │              │          │           │
  │  ↓ rtmp loopback │              │          │           │      ┌──────────┐
  │ streamsink-agent │═════════════▶│          └───────────┼─────▶│ your     │
  └──────────────────┘              │              pull    │      │ switcher │
                                    └──────────────────────┘      └──────────┘

The agent does two things. It continuously measures its clock offset against the sink — the same idea as NTP, using the round trip with the lowest latency and slewing rather than stepping — and it works out when each frame was actually captured, then rewrites that capture time into the sink's timebase before forwarding the packet.

Working out capture time is the harder half, and it is where a rig usually goes wrong. The obvious answer is to stamp a packet when it reaches the agent. That is right only while OBS hands packets over promptly. As soon as OBS buffers its output — a busy encoder, a congested output, a capture source holding frames — arrival time runs late, and that machine drifts behind the others by however deep the buffer is.

So the agent asks OBS directly, over obs-websocket. GetStreamStatus reports how far into its output OBS currently is; sampling that against the local clock gives the instant the output timeline began. From there, a packet's own media timestamp says when it was composited, no matter how long it then sat in a buffer.

One wrinkle is worth knowing about, because getting it wrong would reintroduce the very problem being solved. OBS's reported duration counts from when the output started, while media timestamps count from the first packet actually produced, and connection setup sits in between. That gap is constant on a given machine but differs between machines. The agent measures it rather than assuming it: the smallest lag between the anchor's prediction and real arrival, seen over a rolling window, is the gap on its own, because some packet in any given second gets out promptly. The estimate drops instantly on better evidence and is allowed to rise only slowly, so a spell of buffering cannot drag it along.

If obs-websocket is unavailable the agent falls back to stamping on arrival and says so, in the log and in the sink's UI.

Two agents on two machines therefore describe the same instant with the same number. Alignment at the sink becomes arithmetic instead of guesswork: hold every packet until a fixed delay has elapsed since its capture, and release.

Nothing is transcoded. The encoded payload OBS produced is relayed byte for byte.


Quick start

On the machine running StreamSink:

make build
./dist/linux/streamsink

It starts listening straight away. The Sources page shows the ports.

On each machine running OBS, either the headless agent or the desktop client. They do identical work; the client just shows it.

./streamsink-client          # a window, for an operator's machine
./streamsink-agent -sink ... # headless, for a rack machine or a service

The client shows whether the sink is reachable, whether OBS is publishing, and crucially whether capture times are coming from OBS's timeline or merely from packet arrival, which is the difference between a machine that stays in step and one that drifts. It also shows the address to paste into OBS, with a button to copy it.

On each machine running OBS:

First, in OBS enable Tools → WebSocket Server Settings → Enable WebSocket server. Note the port and password if you set one. This is what lets the agent read capture times from OBS's own timeline; without it the agent still works, but it can only time packets by arrival.

Then run the agent:

./streamsink-agent -sink SINK-HOSTNAME:9077 -name "Studio A"
# if OBS asks for a websocket password:
./streamsink-agent -sink SINK-HOSTNAME:9077 -name "Studio A" -obs-password SECRET

Then in OBS, under Settings → Stream, choose Custom and set:

Field Value
Server rtmp://127.0.0.1:1935/live
Stream Key anything

Start streaming. The source appears in StreamSink marked agent and OBS-timed. If it says arrival-timed instead, the agent could not reach obs-websocket — the reason is printed on the source and in the agent's log.

Sending it onward. On the Outputs page, add an output per source:

  • Pull — StreamSink serves it, and the downstream system connects to rtmp://SINK-HOSTNAME:1936/live/NAME to play it.
  • Push — StreamSink connects out and publishes to an RTMP URL and stream key you provide.

Without an agent

OBS can publish straight to the sink's port 1935. It will work, and the source appears marked no agent.

It cannot be truly synced, because nothing in that path carries a shared clock; those sources are aligned on arrival time, which drifts with network latency. The interface says so rather than implying a precision it does not have. Use it for a source where sync does not matter, or as a fallback.

To publish directly when the sink has a stream key set, append it to the OBS stream key: cam1?key=YOUR-SECRET.


Tuning

There is very little. The delay every source is held by tracks the slowest source automatically, and a player joins at the next keyframe so that sources attaching together are not left permanently offset. Neither is worth an operator's attention, so neither is exposed.

Offset on the Sources page is the exception, and exists because no clock can see it: the anchor removes buffering and encoder delay, but a capture card holding two frames or a camera with its own processing delay happens before OBS and is invisible to everything here.

Building

Linux

Needs Go and the GTK4 development files. On Arch that is the gtk4 package; on Debian and Ubuntu, libgtk-4-dev; on Fedora, gtk4-devel.

make build      # both binaries
make test       # unit and end-to-end tests, race detector on

Windows

The agent is pure Go and needs no toolchain at all:

make agent-windows

The desktop application links against GTK4, so it needs a Windows C toolchain and Windows GTK4 headers. make windows fetches both into build/windows/.cache and builds against them — no root, no container, and nothing installed on the host:

make windows   # dist/windows/
make package   # archive both platforms into dist/

It pulls llvm-mingw (a Linux-hosted toolchain targeting Windows) and the GTK4 dependency closure from the MSYS2 ucrt64 repository, then walks the import tables of what it built to bundle the runtime GTK loads on startup: the DLLs, the GDK-Pixbuf image loaders, the compiled settings schemas and the icon theme.

Copy the whole dist/windows folder to the Windows machine and run streamsink.exe from inside it. For the program view, extract the decoder archive into that same folder so ffmpeg.exe sits beside streamsink.exe. The first build downloads roughly 200 MB and takes a few minutes; later builds reuse the cache and take about a minute.

The only host requirements are curl, python3, tar with zstd support, and glib-compile-schemas (from glib2, which any GTK-capable Linux already has).


Updates

The sink checks for newer releases and says so in Settings. Installing is always a decision: replacing the program someone is relying on partway through an event is not something to do unasked.

What is downloaded is verified against the SHA256SUMS published with the release, and the new binaries are made to run and identify themselves before anything is replaced. A release without checksums cannot be installed automatically at all, and a download that does not match is discarded rather than kept. The previous binaries stay alongside with a .old suffix until the new version has started.

A private repository needs credentials, set under Settings: an API token on its own, or an account name alongside its password. The two are not interchangeable, so which is meant has to be said. Point update.base_url, update.owner and update.repo elsewhere to check a different forge.

Configuration

Settings live in config.json under your user configuration directory (~/.config/streamsink/ on Linux, %AppData%\streamsink\ on Windows), and are written whenever you change something in the interface. Pass -config PATH to use a different file.

Key Meaning
agent_addr where agents connect (default :9077)
ingest_addr where OBS may publish directly (default :1935)
pull_addr where downstream systems pull from (default :1936)
stream_key optional shared secret required of every connection
auto_latency track the delay to the slowest source
target_latency_ms fixed delay, used when auto_latency is off
safety_margin_ms headroom above the slowest source
sources per-source labels and manual offsets
outputs configured push and pull destinations

How it fits together

Package Responsibility
internal/clock process-stable clock, NTP-style offset estimation
internal/obsws obs-websocket client and the OBS timeline anchor
internal/proto the agent-to-sink wire protocol
internal/agent the process that runs beside OBS
internal/ingest agent connections, and direct RTMP from OBS
internal/syncbuf holds packets and releases them on the shared timeline
internal/egress fan-out, RTMP push, and the pull server
internal/flv the FLV container, for handing a stream to a decoder
internal/decode ffmpeg subprocess decoding, for the program view
internal/ffmpeg locating the decoder both output paths shell out to
internal/vsink virtual audio devices, for the sound a camera cannot carry
internal/update checking for, verifying and installing releases
clientui the desktop interface for the agent
internal/vcam virtual camera output through v4l2loopback
internal/hub wires ingest to the buffer to the outputs
ui the GTK4 interface

Audio and video from one source share a single queue, so their relative timing is preserved exactly as OBS produced it. Only whole sources are shifted against each other.


Virtual cameras

The best output is one OBS already knows how to take. Turn on Camera and Audio for a source and OBS sees it as an ordinary Video Capture Device and Audio Input Capture, at the source's own resolution, with nothing buffering it on the way in.

Which devices are used is not asked. A camera is taken from whichever loopback nodes are free, an audio device is created for the source and named after it, and the Sources page says exactly what to point OBS at. The choice is remembered, so a scene built against a device keeps working across restarts.

Frames never pass through StreamSink's own code here: ffmpeg reads the relayed stream and writes straight to the device, so the resolution and frame rate are whatever the source sent.

Cameras are provided by the kernel, not by StreamSink. On Linux that is v4l2loopback, the same module OBS uses for its own virtual camera:

sudo modprobe v4l2loopback devices=2 exclusive_caps=1      card_label="StreamSink 1,StreamSink 2"

exclusive_caps=1 matters: without it OBS may not list the device. So does video_nr, which keeps StreamSink's devices clear of the numbers OBS reaches for when it starts. Create one device per source; audio devices need none of this and are made for you. StreamSink lists whatever it finds and says so in Settings when there are none.

To check the whole path before relying on it:

./streamsink -check

That works through everything a camera needs, in dependency order, and stops at the first thing that makes the rest moot. The last step is the one that matters: it writes a test pattern to a device and reads a frame back, which is the only check that proves the path rather than one part of it. Every failure comes with the command to fix it. Add -check-device /dev/video1 to exercise a particular device.

This is Linux only. A virtual camera on Windows is a signed DirectShow or Media Foundation driver registered with administrator rights, which an application cannot supply for itself; use the program view there.

The program view

The surest way to keep feeds together is not to hand them to a player at all.

Program view decodes every source and draws them into a window for OBS to capture. A player pulling a stream applies its own buffering, and two players buffer differently, which pulls feeds apart after StreamSink has lined them up. A window has no buffer: what has been drawn is what OBS sees, and every source is drawn by one loop on the same tick.

Each source gets its own bare window showing only the video, so each becomes an independent OBS source. That mirrors how virtual cameras work on Linux, so the same rig is set up the same way on either platform. Windows are sized from what the source actually sends.

Decoding is done by an ffmpeg subprocess rather than a linked library: it keeps the media path free of cgo, isolates a decoder crash from the sink, and cross-compiles without a second toolchain.

On Linux the system ffmpeg is used. On Windows it ships as a separate decoder archive that extracts into the same folder as the server: ffmpeg links a large closure of encoders that the program view never uses but which cannot simply be deleted, and that closure is bigger than the application itself. Splitting it keeps the base download reasonable for anyone not using the program view. StreamSink prefers a copy sitting beside its own executable over anything on PATH, and says plainly when it cannot find one.

This is the only place StreamSink decodes anything. The relay path is untouched and still passes encoded media through byte for byte.

The playback end

Everything above gets frames onto a shared timeline and out of StreamSink in step. Whatever plays them can still pull them apart again, and usually it is the larger effect. Two things are worth checking when feeds still disagree.

Match the encoder bitrates across machines. OBS's Media Source buffers a number of megabytes, not a number of seconds. At the default 2 MB, a 6 Mbps feed buffers about 2.7 s while a 4.5 Mbps feed buffers about 3.6 s — nearly a second of skew from the bitrate difference alone, before anything else. Equal bitrates make an equal-size buffer an equal-duration one. Lowering Network Buffering on both sources shrinks whatever difference remains.

Start the sources together. A Media Source begins buffering when it starts playing, so bringing one up before the other bakes the difference in. Toggling both at once, or reloading both, puts them back on equal terms.

Anything still left over is constant, and that is what the per-source offset on the Sources page is for: nudge one source until the two agree as seen in the downstream player, not as seen in StreamSink.

Limitations

  • RTMP only. No SRT, and no WHIP.
  • Relay, not transcode. Every source must already be in a format the downstream accepts. StreamSink will not re-encode to make them match.
  • Sync reaches back as far as OBS, and no further. The anchor puts every machine on OBS's composition timeline, which removes encoder and output buffering. What happens upstream of OBS — a capture card buffering frames, a camera's own processing delay, a stream deck of adapters — is invisible to StreamSink and to obs-websocket alike. That residual is what the per-source offset is for.
  • Virtual cameras are Linux only, and need v4l2loopback loaded. Windows has no equivalent an application can provide.
  • StreamSink cannot see past its own outputs. It can put two feeds on one timeline and release them together; it cannot make a downstream player show them together. Player-side buffering is the operator's to match, and the section above says how.
  • obs-websocket is strongly recommended, not required. Without it a source is timed by packet arrival and will drift whenever that machine's OBS buffers. The UI marks such sources arrival-timed.
  • The sink is the time reference. Agents synchronise to it. Restarting the sink re-establishes the offsets; it does not require restarting the agents.
  • One publisher per source. A second connection claiming a live source's identity is refused rather than silently taking over.

Status

Verified on Linux:

  • The full path — OBS-style RTMP publish → agent → sink → RTMP push to an upstream — carries payloads byte for byte, in order, with the codec sequence header leading. See internal/e2e.
  • Sources arriving 300 ms apart with identical capture times are released within one scheduler tick of each other and given the same timestamp on the shared timeline. See internal/syncbuf.
  • The clock estimator recovers a known offset to within 2 ms through heavy round-trip jitter, and slews rather than steps. See internal/clock.
  • The OBS timeline anchor returns the true composition time for a frame that was buffered for three seconds before being handed over, and recognises a restarted output rather than crawling to it. See internal/obsws.
  • The origin-gap estimator recovers a constant seven second offset from a signal where every packet also carried up to three seconds of buffering, and does not follow ten seconds of sustained buffering upward. See internal/agent.
  • Two agents whose OBS instances reported timeline origins twelve seconds apart both settled to under a millisecond of transit, with the sink's delay at 403 ms rather than the 7.4 seconds an uncorrected origin produced.
  • The application, two agents and two publishers running together, with live bitrate and transit measurements in the interface.

Built and checked for Windows:

  • streamsink.exe is a PE32+ GUI binary — linked against GTK4 4.22.4, the same version used on Linux — and streamsink-agent.exe a PE32+ console binary.
  • Every DLL either binary imports, and every DLL the GDK-Pixbuf loaders import, is present in the bundle or ships with Windows. Checked by walking the import tables of all 78 binaries in the output.
  • The compiled settings schemas are present, which GTK requires to start at all.

Not verified: virtual camera output has not been exercised end to end, because v4l2loopback is not installed on the machine this was built on and installing a kernel module was out of scope. Device discovery, the permission and configuration checks, and the interface around them are all tested; what is untested is ffmpeg actually writing frames into a loopback node. -check exists to test exactly that on a machine which does have the module.

Not verified: the Windows binaries have not been executed. There is no Windows machine and no Wine on this host, so the bundle is verified by construction and by import analysis, not by running it.

Licence

MIT. See LICENSE.