I listen to a lossless library, FLAC files and whole-album FLAC images with .cue sheets, through two small network streamers. For a while I drove them from foobar2000: install the UPnP component, pick the renderer as the output, choose the format it should be sent, keep the DSP chain empty so the samples arrive untouched, restart playback after every change. It works, but it is a ritual.
Then it dawned on me what a UPnP renderer actually is: an HTTP client. You tell it, in a small SOAP request, "play this URL", and it comes to you for the bytes with a plain GET. No driver sits in between, no output plugin, nothing to install on either side. Everything else is settings around an HTTP download.
So I wrote a player that is just that: one Go binary that finds the streamers on the home network, serves them the music itself and has nothing to configure.
This is the story of why a streamer in the first place, and of what two small boxes taught me about UPnP.
Why a streamer and not a USB DAC
USB ties the DAC to the computer's ground, and with it to the noise of switching power supplies, a graphics card and fans. Ethernet carries only data and is isolated by transformers.
A streamer is a small box with nothing else to do. It buffers the stream at its leisure (music, unlike games, does not care whether a track starts 5 ms or half a second after Play) and feeds the DAC, which clocks the samples out by its own oscillator. The computer is out of the picture.
I have two of them, both running gmrender-resurrect:
- a home-made one, a BeagleBone Green with a USB DAC;
- a FiiO SR11.
Both speak UPnP/DLNA.
What "bit-perfect" means here
The player decodes FLAC on the PC and sends the streamer the original samples as WAV: no resampling, no dither, no volume applied on the way. Two things change samples, and only when they apply:
- the 18-band equalizer, while it is on (it reads foobar2000
.feqpresets); - a 10 ms fade-in where a stream starts mid-wave (Play, a seek), so music cut in the middle of a waveform does not click. foobar2000 does the same after a seek.
Tracks that follow one another are never touched.
Lossy formats are not supported and will not be. A link built for bit-perfect sound has no use for MP3.
The architecture is boring on purpose
It is one Go binary (Fiber v3) with a React page embedded in it:
- SSDP finds the renderers; their device descriptions say which MIME types they take.
- SOAP drives them:
SetAVTransportURI,Play,Pause,Seek,Stop, and pollsGetTransportInfo+GetPositionInfoonce a second. - HTTP serves the music: the renderer is told "play this URL" and comes back for the bytes, with Range requests.
- SSE pushes the state to the page.
A CUE track has no file of its own, so it is served as a virtual WAV: a 44-byte header plus the PCM of its sample range, decoded on the fly, with Range support so the renderer can seek.
Because the whole exchange is plain HTTP and UPnP, the same binary runs wherever Go builds. It is tested on Windows, Linux and macOS 12. On Windows it installs with a window of its own (WebView2), and there is also a native Qt build (miqt) that calls the player's Go packages directly instead of going through REST. Anywhere else the UI is the browser, and from a phone the same page is a remote.
That was the easy part. Then the streamers started having opinions.
Lesson 1: the renderer's position is a rumour
The obvious design is "the player keeps a model of what the renderer is doing". It does not survive contact with gmrender:
-
TrackURIjumps to the next track minutes early, as soon as GStreamer has buffered the current WAV. Taken at its word, the playlist advances while the old song still plays. -
Seekanswers OK at once, butRelTimestays at the old place for half a second, or over a second for a jump of minutes in a long stream. A click on track 17 polled as "still on track 15", and the player helpfully "corrected" the playlist. - A
Seekin the first moments of a stream is silently ignored. OK, and it keeps playing from 0. - Now and then it reports
0:00:00far behind where it actually plays.
Each of these got its own fix (one odd report is not acted on, the same one twice is; a start at a position is a stream that begins there instead of a seek). But the real lesson came later.
Lesson 2: clicks on the coaxial output
The FiiO's coaxial output clicked at track boundaries. Its USB output to the same DAC was silent.
I measured before guessing:
- The served audio was clean at the boundary: the last samples of one track and the first of the next differed by 1–7 out of 32768.
- The player answered in 3–5 ms.
- The FiiO, though, read the current stream to the end 5 s early and opened the next URI only at the last second. Its output buffer ran dry for a moment, the S/PDIF receiver lost lock, click.
The cure was to stop giving it boundaries: the tracks of one format (rate, channels, bits) go as one continuous WAV stream, the way foobar2000 sends them. A jump to another track of the album is a seek in that stream. The player keeps the layout and turns the stream position back into "track 7, 2:13" for the page.
Lesson 3: I tried to be clever, and it hung
Early on the FiiO took its whole UPnP service down on a Stop, until a power cycle. So the player stopped sending Stop at all. Tracks were switched through the gapless path instead (SetNextAVTransportURI, then a seek to the end of the current stream), the Stop button became a pause, and the end of a playlist left the renderer paused at the last second.
It worked, mostly. Then, one after another:
- The BeagleBone started racing: PLAYING, no sound, position running at 5–40x real time. Only
Stop → SetAVTransportURI → Playcured it, which is exactly what foobar2000 always sends. - A played-out FiiO sat at 37:37 of 37:37, accepted Seek and Play without an error, and moved nowhere. The next start hung.
- The BeagleBone hung on the clever switch path three times in a row.
Every workaround had bred the next bug. So I threw them out and wrote down a rule instead:
Stop is Stop, on every renderer. Ask the renderer its state first. If it already reports STOPPED, send nothing. Otherwise send one
Stop, never repeated, and then only poll until it reports STOPPED. Every next step is sent only once the renderer has reported the previous one done.
No delays, no silence windows, no special cases per model. The end of the playlist stops the renderer, as foobar2000 does. A start from STOPPED is a plain new stream at the chosen track. The page shows the state the renderer reports, never what the player intends.
The hangs went away together with the workarounds. In hindsight that's not surprising: the parallel realities the player kept in its head were the bug.
Lesson 4: measure with your ears, systematically
Some of this could only be heard. For the click hunt I added a temporary "mark" button and pressed it on every click while doing a dozen random Next / Prev / track clicks. The marks lined up exactly with jumps that needed a new stream and never with seeks inside one. That settled an argument the logs could not.
Every decision like this is written down as an ADR in the repository, with the options that were rejected and the measurement that decided it. There are 27 of them now. Half are a field guide to gmrender's behaviour.
Try it
- Windows: a per-user setup or a zip from Releases, no administrator rights.
- Linux and macOS:
go install github.com/panedrone/go-upnp-player/cmd/go-upnp-player@latest(on macOS 12, use Go 1.26 andCGO_ENABLED=0).
Source, docs and all the ADRs: github.com/panedrone/go-upnp-player (ISC license).
If you have a streamer that is not gmrender-based, I would love to hear how it behaves.

