- Go 96.1%
- Shell 2%
- Python 1.5%
- Makefile 0.4%
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> |
||
|---|---|---|
| build | ||
| clientui | ||
| cmd | ||
| internal | ||
| ui | ||
| .gitignore | ||
| go.mod | ||
| go.sum | ||
| LICENSE | ||
| Makefile | ||
| README.md | ||
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/NAMEto 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
v4l2loopbackloaded. 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.exeis a PE32+ GUI binary — linked against GTK4 4.22.4, the same version used on Linux — andstreamsink-agent.exea 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.