A cross-platform port of Cog, the macOS audio player by Vincent Spader and Christopher Snowhill, built on wxWidgets. XPCog targets Windows, macOS and Linux from a single codebase.
Status: milestone 6 — breadth. A window with a playlist, transport, seek bar, file browser, preferences, undo, drag-and-drop and a persistent library. Gapless across formats and sample rates, ReplayGain, cue sheets, HDCD. A 31-band equaliser at Cog's frequencies, transport fades, matrix downmix/upmix and FreeSurround stereo-to-5.1. Media keys and Now Playing on all three platforms — MediaPlayer.framework, SMTC and MPRIS — plus a tray icon on Windows and Linux, the Dock menu on macOS, one instance per user, a spectrum analyser on Cog's own band frequencies, a mini player, and a taskbar badge and progress bar. Now playing over HTTP too — File → Open URL, internet radio included, with SHOUTcast stream titles live in the window as the station announces them, HLS for the stations that use it, and chained Ogg so a stream survives its own track changes. Breadth since: archives played in place, tracker modules, game music rips, vgmstream's console formats, the whole PSF family on all eight of its emulator cores, Commodore 64 tunes, Musepack, Monkey's Audio Link files, and MIDI on a SoundFont bank — one ships with it — a Sound Blaster's OPL3, or an emulated Roland SC-55 with its front panel. 842 extensions across 23 decoders. The interface is wxWidgets, and there is no Qt anywhere in the tree — which is also why there is no dependency outside vcpkg, no environment variable pointing at a toolkit, and no deploy step. See the roadmap, or
docs/PORTING.mdfor the full plan and the reasoning behind the structure.
Cog is excellent and thoroughly macOS-shaped. Its UI is 12 Cocoa XIBs driven by 190 bindings, persistence is Core Data, audio output is AUHAL, every DSP kernel is Accelerate/vDSP, and decoders are Objective-C bundles discovered at runtime. None of that survives a move off Apple platforms.
What does survive is the part that matters: Cog's decoder contract
(Audio/Plugin.h) is a narrow six-protocol interface that maps almost one-to-one onto
C++ abstract base classes, and the bulk of its format support lives in portable C/C++
libraries. XPCog keeps that contract and rebuilds everything around it.
xpcog-app ──┬── xpcog-platform (per-OS integration; NO toolkit)
└── xpcog-codecs ──┐
├── xpcog-core (NO toolkit)
xpcog-cli ── core + codecs ────┘
Two rules do most of the structural work:
Only xpcog-app links a UI toolkit. The engine, plugin registry, library and
playlist view depend only on the C++ standard library and codec libraries, which keeps
them embeddable and testable without a display. xpcog-platform links none either: it
talks to Win32, C++/WinRT, CoreFoundation, MediaPlayer.framework and GDBus, and its
public headers name no toolkit at all.
The rule enforces itself — xpcog-cli links nothing, so a leak breaks that target
immediately — and cmake/CheckNoToolkit.cmake reports it earlier with a clearer
message. It was put to the test in earnest: the interface was moved from Qt 6 to
wxWidgets without core/ or codecs/ changing at all. See
docs/WXPORT.md.
Codecs are registered at compile time, not discovered at runtime. Each codec exposes
one registrar function; CMake generates a RegisterAll.cpp that calls every one of them
in a deterministic order. Self-registering statics are deliberately avoided: inside a
static library the linker only extracts an archive member that resolves an undefined
symbol, so a self-registering codec is silently dropped and fails at runtime rather than
at build time. See cmake/XPCogCodec.cmake.
Requires CMake 3.24+, Ninja, a C++20 compiler, and
vcpkg with VCPKG_ROOT set. Every
dependency, wxWidgets included, comes from vcpkg — there is nothing to install
separately and no environment variable to point at a toolkit. On Linux the
toolkit is the distribution's, and the linux-repo-* presets take as much of the
rest from it as the machine can supply; both are below.
cmake --preset macos-debug # or linux-debug / windows-debug
cmake --build --preset macos-debug
ctest --preset macos-debug:: Windows, from a Developer Command Prompt
cmake --preset windows-debug
cmake --build --preset windows-debug
ctest --preset windows-debugThere is no deploy step. On Windows, vcpkg's applocal pass copies every dependent
DLL beside XPCog.exe as part of the build — the same pass that has always placed
FFmpeg's and TagLib's — so a freshly built binary starts from Explorer. On macOS the
triplet is static and there is nothing to copy. The one thing the build stages
itself is crashpad_handler, which is a second executable rather than a library
and so is not something applocal knows about; it lands beside the binary as a
post-build step. What remains is signing, and
cmake --build build/macos-debug --target sign does that when
XPCOG_CODESIGN_IDENTITY names a Developer ID identity.
wxWidgets is declared under a gui feature rather than as a plain dependency, so
a headless configuration (-D XPCOG_BUILD_APP=OFF) builds no toolkit at all.
On Linux the toolkit comes from the distribution, not from vcpkg — install
libwxgtk3.2-dev (Debian/Ubuntu), wxGTK-devel (Fedora) or wxgtk3 (Arch).
vcpkg's wxwidgets port depends on its gtk3 port, so asking vcpkg for wx there
builds 57 packages from source — wx, GTK and 55 more beneath them: glib, pango,
cairo, harfbuzz, fontconfig, at-spi2, dbus, seven X11 libraries — on a machine
that already has all of them. 98 packages for the Linux build against 41
without. The Linux presets therefore leave gui out, and
cmake/XPCogWx.cmake finds the system wx through CMake's FindwxWidgets. It
needs wxWidgets 3.2 or newer — the oldest release carrying wxTaskBarIcon and
wxNotificationMessage in core rather than in the since-merged adv library.
To build the toolkit through vcpkg anyway, add the feature back:
cmake --preset linux-debug -D "VCPKG_MANIFEST_FEATURES=gui;sentry;ffmpeg;vgmstream;mgba;psf-cores;sid;musepack;adplug;libvgm".
wxWidgets is not the only dependency a Linux machine already has. The
linux-repo-debug and linux-repo-release presets are the ordinary Linux build
with one difference — before anything is asked of vcpkg, pkg-config is asked
what is installed, and every library the system has at a version this code can
use is taken from there:
sudo pacman -S ffmpeg curl libarchive taglib sqlite libsoxr opusfile wavpack \
libopenmpt libgme catch2 libmpcdec # Arch; similar elsewhere
paru -S vgmstream-git libspessasynth-git # AUR, and optional
cmake --preset linux-repo-release
cmake --build --preset linux-repo-releaseOn a machine with those installed vcpkg goes from 41 packages to 14 — 12 with
vgmstream-git as well, 11 with libspessasynth-git too — and from 643 MB of
vcpkg_installed to 46 MB. What is dropped is not the cheap half: FFmpeg is the
longest build in the manifest, OpenSSL is the second, and libarchive brings
bzip2, liblzma, lz4, zstd, libxml2 and libiconv with it. Nothing else changes —
same options, same codecs, same tests.
Every version floor is the oldest release carrying an API this code calls, and
cmake/XPCogSystemDeps.cmake says which for each.
A library that is missing, or too old, is simply built by vcpkg as before; the
configure summary lists what was taken from the system and at what version, so
there is no guessing about which half a build came from. Two of the floors are
worth knowing about because a current distribution can fail them:
- TagLib 2.0, for
TagLib::Variant— Ubuntu 24.04 ships 1.13. - Catch2 3.7.1, which is where a binary whose tests all skipped began
exiting 4 and
catch_discover_testsbegan registering that with ctest. Below it, the many corpus-gated tests here are reported as failures rather than as skips — Ubuntu 24.04's 3.4.0 turns 95 of 600 green tests red. - libsidplayfp 2.x, and not 3.0 or newer: sidplayfp 3.0 replaced the
play(short*, count)this decoder is written against with a cycle-driven call and a separate mix step.
vgmstream and SpessaSynth are the two odd entries. Neither ships a .pc
file or a CMake config package, so both are found as a header and a library by
name; and what is packaged is not a release but a rolling build of a repository —
vgmstream-git and libspessasynth-git, both from the AUR, the second of them
maintained by this project's author. Neither can be held to a release number,
and each is held to the one thing its install does state about itself:
- vgmstream to
LIBVGMSTREAM_API_VERSION_*inlibvgmstream.h, read straight out of the header — at least 1.0, which is all this decoder calls, and below 2.0, which that header defines as the next set of breaking changes. A distribution build also has the optional codecs on whereports/vgmstreamturns them all off, so the system copy decodes a superset and says so throughlibvgmstream_get_extensions(). - SpessaSynth to its soname, at least
libspessasynth.so.11, because its headers carry no version at all. Upstream commit 28a362a widened member types fromfloattodoubleacross the public headers without moving the soname off 10, so a package still at 10 may be either side of that change with nothing in the install to say which; 11 is the soname the break was finally given, and whatports/spessasynth-coreis pinned past. A machine whose package predates the bump keeps building the port.
Along with libsidplayfp, vgmstream is one of the two entries with an upper bound. These two are also the only ones CI cannot exercise the system half of — no Debian or Ubuntu release packages either library — so the job below asserts the fallback for them instead.
CI builds this configuration too, on Ubuntu 24.04, where TagLib and Catch2 fall below their floors, vgmstream and SpessaSynth are not packaged at all, and the other twelve do not — so one job exercises the system path and the fallback path at once, and asserts against vcpkg's installed tree which of the two each dependency took.
The plain linux-debug and linux-release presets are unchanged and still take
everything from vcpkg. That is what CI builds, and what to use when a build has
to come out the same on a machine other than the one that configured it — which
is also why the two sets of presets build into separate directories, and why
flipping XPCOG_USE_SYSTEM_LIBS inside one of them is refused rather than
obeyed.
Four libraries are never substituted, whatever is installed: libogg, libflac,
libvorbis and zlib. codecs/flac and codecs/vorbis link three of them
directly, and the overlay ports in ports/ build against all
four — SpessaSynth reads FLAC- and Vorbis-compressed SF3 samples, libvgm and mGBA
read gzip — so vcpkg builds the four whichever way the ports go, and linking a
second copy of any of them would buy nothing. mGBA is a deliberate omission of a
different kind: a system libmgba exists, and struct mCore declares its members
inside #ifdef ENABLE_VFS and friends, so one built with a different set of
those has different member offsets — which compiles, links, and then calls
whatever is in the slot.
nasm is required on every platform for FFmpeg's assembly — vcpkg downloads it
itself on Windows, and expects the package manager to supply it elsewhere. macOS
also needs pkg-config for vcpkg's ports.
brew install ninja pkg-config nasm # macOS
sudo apt install ninja-build pkg-config nasm autoconf automake libtool \
libwxgtk3.2-dev libglib2.0-dev # Debian/UbuntumacOS builds the app icon from app/icons/xpcog.icon, an Icon Composer package,
using actool from Xcode 26 or newer — not the Command Line Tools. Without
it the build still succeeds and falls back to a committed .icns, saying so as
it configures; what is lost is the icon's container and its dark and tinted
appearances, which the system composes from the layered source and cannot
recover from a bitmap.
Forty-one tests build their fixtures by shelling out to command-line encoders, and skip silently when those are absent — a skip is not a failure, so the suite still reports success while the gapless, seek and cue-span tests never run. Install them to get real coverage:
brew install flac vorbis-tools opus-tools lame wavpack ffmpeg # macOS
sudo apt install flac vorbis-tools opus-tools lame wavpack ffmpeg # Debian/UbuntuOn Windows, four of the six come from winget and the other two from their upstream builds:
winget install Xiph.FLAC Gyan.FFmpeg LAME.LAME Mozilla.opus-toolsoggenc and wavpack are not packaged. Take wavpack-5.9.0-x64.zip from
wavpack.com and oggenc2 from
RareWares, and put wavpack.exe and
oggenc.exe (renamed from oggenc2.exe) anywhere on PATH.
Watch the skip count in ctest output, not just the pass rate. With the encoders
installed a full run is 497 tests, 65 skipped, and those 64 want something no
package manager can supply: rips of copyrighted game programs, a Roland's
firmware, a SoundFont bank. Point XPCOG_PSF_CORPUS, XPCOG_VGM_CORPUS,
XPCOG_SID_CORPUS, XPCOG_MIDI_CORPUS, XPCOG_HIVELY_CORPUS,
XPCOG_ADPLUG_CORPUS, XPCOG_ORGANYA_CORPUS, XPCOG_SYNTRAX_CORPUS,
XPCOG_DSD_CORPUS, XPCOG_SC55_ROMS, XPCOG_SOUNDFONT, XPCOG_SHORTEN_FILE
or XPCOG_DSD_FILE at one and the matching cases run. XPCOG_VGM_CORPUS is read
by two codecs' tests — vgmstream's and libvgm's — because a folder of game rips
holds streamed audio and chip logs side by side. Organya, Shorten and the
silence:// track need a corpus least: most of what those three assert runs
against files the tests write themselves.
Without the encoders, 41 more go quiet.
Note that the encoders alone were not enough before the fixture commands stopped
assuming a POSIX shell: 2>/dev/null under cmd.exe fails the whole command,
which every call site read as "encoder missing". See tests/TestShell.hpp.
To build the engine with no toolkit at all:
cmake --preset macos-headless && cmake --build --preset macos-headless
./build/macos-headless/bin/xpcog-cli codecsxpcog-cli codecs # what this build can decode
xpcog-cli info song.flac # format, duration, ReplayGain, tags
xpcog-cli expand album.cue # the tracks a playlist or cue sheet holds
xpcog-cli info album.cue#3 # one track of a single-file album
xpcog-cli decode song.flac out.raw # headerless native-endian PCM
xpcog-cli play a.flac b.m4a c.mp3 # gapless across the queueA .cue expands to one URL per track (album.cue#1, #2, …). Opening one decodes
the referenced audio file, seeks to that track's INDEX 01, and stops at the next
track's start, so each track reports its own duration and metadata and seeks
relative to itself.
Two bugs in Cog's parser are fixed rather than reproduced, both of which corrupt real albums:
- Cog keeps one
artistvariable for the whole sheet and never resets it per track, so a single track-levelPERFORMERmis-credits every following track. Track-level fields here fall back to the album value instead. - A non-
AUDIOTRACKis skipped, but Cog still lets itsINDEXcreate an entry, so a mixed-mode disc gains a bogus track that decodes to noise.
Dedicated decoders for FLAC, Ogg Vorbis, Opus, MP3 (minimp3), WavPack and Musepack (libmpcdec), plus FFmpeg as the catch-all for AAC, ALAC, WMA, AC3, DTS, TAK, TTA, APE, PCM and the MP4/MKV/ASF containers.
Beyond those: tracker modules (libopenmpt), chiptune rips (Game_Music_Emu),
console streamed audio (vgmstream), the PSF family on all eight of the emulator
cores behind it — USF, GSF, 2SF, SNSF, SSF/DSF, NCSF, PSF/PSF2 and QSF — and
Commodore 64 tunes (libsidplayfp). MIDI is its own thing again: a score rather
than a recording, so what it sounds like is a choice of synthesiser. Fourteen
extensions -- .mid and .midi among them, with HMI, XMI, Doom's MUS and
Loudness LDS each reaching their own parser in midi_processing -- render on a
SoundFont bank (SpessaSynth), an emulated Sound Blaster (Nuked OPL3, under two
different drivers), or a Roland SC-55mkII running its own firmware, if you have
the ROMs.
A bank ships with it, so MIDI plays on real instruments out of the box
rather than on an FM chip: GeneralUserXG-SFeTest.sf3, which is what Cog
bundles, together with the tg300b map that XPCog selects instead when a
sequence announces itself as GS or GM2. Point soundFontPath at your own bank
to replace it, or drop one beside a file — song.sf2, or Album/Album.sf2 for
a folder — to override it for that music alone. An RMID that carries its own
bank inside it beats all of those, since that bank is part of the music.
Archives are a source rather than a format,
so a .zip of FLAC plays without being unpacked first, and a Monkey's Audio Link
(.apl) is a range within one -- the same shape as a cue sheet track, which is
how a single-file CD rip becomes an album.
xpcog-cli codecs prints what a given build claims; a default one is 23 decoders
and 842 extensions. Cog recognises around 900 across ~35 decoders.
Selection follows Cog's rules: extension first, then MIME type, with several claimants tried in descending priority. FFmpeg registers below default priority, so a dedicated decoder always wins for formats that have one, while FFmpeg still picks up files those decoders reject.
Every codec is checked against one asymmetric reference signal — 440 Hz in the left channel, 660 Hz in the right, at different levels — verifying per-channel frequency and amplitude. That catches swapped, duplicated and silent channels, which a duration or size check would miss. Adding a codec means adding a row to that table.
decode output is byte-identical to flac -d with the WAV header stripped, which
is how the decoder is regression-tested.
When a decoder reaches end of stream the engine opens the next track immediately,
while the audio already buffered is still playing, and keeps writing into the same
ring — so a same-format handoff needs no device reconfiguration and produces no gap.
This is the shape of Cog's -endOfInputReached:. Track changes are announced when
the seam becomes audible, not when it is decoded.
The seam is covered by tests that run the real engine against a capturing output, so they are deterministic and need no audio device: sample-exactness against separately-decoded references, waveform continuity across the join, notification ordering, and a three-track case where a per-seam off-by-one accumulates rather than cancels. The tests were confirmed to fail when a chunk is deliberately dropped at each seam.
A track at a different sample rate joins gaplessly too. The device stays at the first track's format and later tracks are resampled into it (libsoxr, as in Cog), because reconfiguring the device mid-stream cannot be seamless. The outgoing resampler is flushed before it is reconfigured, so the few milliseconds held in its delay line — exactly the samples that meet the seam — are not lost.
Matching rates bypass the resampler entirely, so a same-rate file is passed through bit-exactly rather than being needlessly recomputed.
HDCD codes are decoded when present, expanding the extra resolution the format carries. Because the decoder runs on every 16-bit 44.1 kHz stereo lossless stream — almost all CD-sourced material, and almost none of it actually HDCD — it has to be bit-transparent when no codes are found. It is, and that is asserted rather than assumed.
The audio callback reads from a lock-free SPSC ring, applies an atomic gain, and
zeroes any tail it could not fill. That is the whole callback: no lock, no
allocation, no std::function, no logging. A feeder thread does the decoding and
writes into the ring.
This is deliberately stricter than Cog, whose callback
(Audio/Output/OutputCoreAudio.m:877) takes an NSLock and enters an
@autoreleasepool on the real-time thread. xpcog-cli play reports underruns
separately for playback and for the post-stream drain, so a genuine dropout is
never confused with the expected tail.
Off unless you turn it on. XPCog carries the same arrangement Cog does: on the first launch it asks, once, whether it may send crash reports and usage data to https://cog-analytics.losno.co, and it never asks again whatever the answer was. Preferences → General is where it is changed afterwards, and the switch takes effect immediately in both directions — unticking it shuts the reporter down for the running session rather than at the next launch.
Until it is ticked, no reporter is initialised, no report database is created and nothing leaves the machine. That is deliberately stronger than the SDK's own opt-in mode, which starts a client and then holds events back: here there is no client. What is collected, and what happens to it.
It is sentry-native underneath,
where Cog uses the Sentry Cocoa SDK, and the keys are Cog's own —
sentryConsented and sentryAskedConsent — so an answer given in Cog on macOS
carries over rather than being asked for again. The reporter lives in
platform/, behind a four-function header that never exposes the SDK; see
platform/include/xpcog/platform/CrashReporter.hpp.
The presets build it. -D XPCOG_WITH_SENTRY=OFF leaves it out entirely, which is
the default for a plain cmake with no preset: the port builds crashpad, and that
is a lot to hand someone who only wants a player. Such a build still shows the
switch, greyed out, saying why.
| Milestone | Scope | |
|---|---|---|
| ✅ | M0 | Toolchain, module layout, codec registration, Qt shell |
| ✅ | M1a | Walking skeleton: FLAC decode → miniaudio output |
| ✅ | M1b | Transport, gapless, seven decoders, M3U/PLS playlists and cue sheets |
| ✅ | M1c | ReplayGain, resampling, settings, HDCD |
| ✅ | M2 | SQLite library, playlist model, shuffle/repeat/queue, scanner, tag reading |
| ✅ | M3 | The Qt application: playlist view, preferences, undo, media keys |
| ✅ | M4 | DSP chain: equalizer, fader, downmix/upmix, FreeSurround. Time-stretch dropped by decision |
| ✅ | M5 | SMTC, MPRIS, tray icon / Dock menu, single instance, app icon, spectrum, mini player, taskbar badge. NSDockTile dropped by decision |
| 🚧 | M6 | Breadth. HTTP and internet radio, HLS, chained Ogg, archive sources, tracker modules, game music rips, vgmstream, the eight PSF cores, SID, Musepack, APL, DSD, .dsf/.dff, output device selection and exclusive mode, the 31-band equaliser with Cog's preset library, and MIDI on OPL3, SpessaSynth and an emulated SC-55 with its front panel all done, and the decoder list closed; cogimport and scrobbling to come. DoP output waits on a DAC to verify against; HRTF is deferred; global hotkeys are not coming, because the media keys they bind are already delivered by SMTC, MPRIS and MediaPlayer.framework |
| ✅ | M7 | The interface moved from Qt 6 to wxWidgets. core/ and codecs/ unchanged; platform/ de-Qt'd and now links no toolkit either. Qt was the last dependency outside vcpkg, and the deploy step went with it. See docs/WXPORT.md |
Picking this up on another machine? docs/PORTING.md ends with
Where to pick up next — the remaining work itemised, in order, each with where
Cog does it and what the trap is.
Milestone 1's formats were FLAC, MP3, Vorbis, Opus, AAC/ALAC and WavPack, with APE
and Musepack arriving through FFmpeg rather than their own decoders; Musepack has
its own now, and APE still does not, because Cog has none either. M6 has taken the
recognised extension count from 30-odd to 842, against the roughly 900 Cog
recognises across ~35 decoders. Getting the rest is the remainder of M6 and beyond,
and the architecture is sized for it — each additional decoder is one
xpcog_add_codec() call, never a refactor, and every one added so far has cost
exactly that.
The Mac App Store sandbox (SandboxBroker, security-scoped bookmarks), AudioUnit MIDI
instrument hosting, AppleScript, Spotlight integration and the MCP server are macOS-only
and are not being ported. A no-op IFileAccess seam preserves the sandbox call sites in
case that changes.
AudioUnit hosting is one of Cog's four MIDI backends, not MIDI itself — .mid and
its dozen relatives play here through the other three, all of which have landed:
SpessaSynth, Nuked OPL3 and Nuked SC-55. See docs/MIDI.md.
XPCog is a derivative work and tracks Cog's behaviour closely, including quirks worth preserving. Where it deliberately differs, the difference is documented — for example, Cog's shuffle and next/previous operate on the sorted playlist order, whereas XPCog keeps playback order canonical and treats sorting as display-only.
The full porting plan, milestone-by-milestone progress, and the complete list of
deliberate behaviour differences live in docs/PORTING.md.
Work that spans several commits gets its own plan beside it —
docs/HIGHLYCOMPLETE.md staged the eight emulator
cores behind the PSF formats, one at a time, and is now the record of all eight.
Upstream Cog: https://github.com/losnoco/Cog
GPL-2.0-or-later, following upstream Cog. See COPYING.
Cog is copyright Vincent Spader and Christopher Snowhill. Bundled decoding and tagging
libraries are under their own licenses. Interface icons are Lucide
under the ISC license — see app/icons/lucide/LICENSE.