A music visualizer built like an instrument, and built like a classic Windows app: one C++ source tree, one statically linked exe, OS facilities everywhere they exist, and exactly two deliberate dependencies — libprojectM for real MilkDrop and the Spout SDK for OBS texture sharing, both pinned. The audit surface is this repo, Windows, and those two pins.
This is the native successor to the WaveScope web app (live at
https://wavescope.signalridgelabs.com, source at
github.com/jasonulbright/wavescope). It matches the web app feature for
feature — same analysis math, same 34 modes, same 17 GPU shaders, same
palettes, the same 15 .milk presets plus the full 551-preset classic
MilkDrop library in original form — and adds the things only a desktop app
can do: silent WASAPI loopback, MP4 clip recording, companion displays from
one engine, Spout output for OBS, always-on-top, tray, launch at login.
Grab WaveScope-v<version>-win64.zip from the repo's Releases, unzip
anywhere — USB stick included — and run WaveScope.exe. The zip carries
everything: the projectM DLLs, their app-local VC++ runtime, both preset
libraries with the texture pack, and the third-party notices. Fully
portable: settings live in wavescope.ini beside the exe, nothing touches
the registry except the opt-in launch-at-login key, and deleting the
folder removes every trace.
build.bat
Requires Visual Studio 2026 (any edition) and the projectM native build at
C:\projects\projectm\install-native (built there via its
build-native.bat; MSVC/Ninja, shared DLL). The batch file:
- builds and runs the unit tests (
src/tests.cpp) — a red test fails the build; - builds
WaveScope.exe(/MT, no MSVC redistributable needed for the core app); - stages
projectM-4.dll+projectM-4-playlist.dllbeside the exe (these two need the VC runtime, present wherever VS or the VC redist is).
WaveScope.exe selftest
Renders every engine on the demo signal and asserts real output: sweeps all
34 modes and all 17 shaders (17/17 must compile), records a 1.6 s MP4 and
re-opens/decodes it, does a Spout roundtrip through an in-process receiver,
sweeps a spread of the 551 classic presets through the ns-eel parser, dumps
PNGs of what actually rendered beside the exe, writes
wavescope-selftest.log, and exits nonzero on any failure. Run it after
every change; CI-in-a-flag. Selftest never writes wavescope.ini — your
engine/mode/palette survive it.
package.bat builds (unit-test gate included), stages the portable layout
under dist\, runs the staged exe's selftest as the release gate — the
zip layout has to prove it runs standalone — and produces
dist\WaveScope-v<version>-win64.zip.
| Engine | What it is |
|---|---|
| WaveScope | 34 built-in modes in five families (spectrum 9, waveform 6, particles 6, geometry 6, field 7), Direct2D, faithful ports of the web canvas math, phosphor-trail retained rendering. |
| GPU | All 17 web WGSL shader modes ported 1:1 to GLSL 330 (Plasma field … Scope lines), compiled at runtime on a WGL context. Audio uniforms plus per-frame FFT (1024×R8) and waveform (2048×R8) textures with spec()/specLog()/wav(). |
| projectM / MILK | Real MilkDrop via native libprojectM. PROJM plays the 15 bundled WaveScope presets (same files as the web app). MILK plays the original classic library — 551 presets from the official MilkDrop distribution (presets-classic\, with the shared texture pack in textures\) — or your own .milk folder (L to pick, hot-reloads on change). These are the true originals of the ~60 converted classics the web's Butterchurn engine ships, running on the real ns-eel VM. |
1 system audio (WASAPI loopback — hears whatever the machine plays,
silently, no permission dialogs), 2 microphone, 3 demo signal
(oscillator bank + synthetic kick, analysis-only, matches the web spec),
4 audio file (Media Foundation decode, audible, looped). The capture
survives device unplugs and follows Windows default-device changes; silence
decays to dark instead of freezing.
Analysis matches the web app's Web Audio analyser exactly — Blackman window, |X|/N, 0.8 smoothing on linear magnitudes, [-100,-30] dB mapping, Hz-based bands (250 Hz / 4 kHz splits) from the real device sample rate, quarter-decimated RMS level, bass-flux beat envelope (1.35× gate, 0.08 floor, e^-6dt decay). Same music, same numbers, both apps.
| Key | Action |
|---|---|
SPACE / → |
next mode / preset / shader |
← |
previous |
E |
cycle engine (WaveScope → GPU → projectM) |
G |
GPU shader engine |
P |
cycle trace palette |
N |
palettes + shuffle panel (include list, 3-stop editor) |
1 2 3 4 |
source: system / mic / demo / file picker |
M |
displays menu (move to a monitor, ALL DISPLAYS companions) |
R |
record clip → wavescope-<timestamp>.mp4 (H.264 + AAC) |
O |
Spout output — OBS/Resolume ingest the visual as sender "WaveScope" |
B |
Spotify: authorize once + start playback (Premium) |
S |
shuffle timer (off / 15s / 30s / 1m / 5m / DJ / VJ) |
C |
calm mode (photosensitive-safe: no burst modes, softened beat) |
L |
preset lab — edit the active preset's .milk, apply, save |
T |
always on top |
F |
fullscreen — H HUD — F1 / ? help |
ESC |
close panel / menu / help / fullscreen, then quit |
Resolution targets (AUTO / 1080 / 1440 / 4K / 8K) live in the deck's
RES ▾ dropdown; they drive the built-in modes' offscreen render. The GL
engines (GPU / projectM) always render at window size — fullscreen is
already native resolution — and the HUD's RES line reports the pixels
actually rendered either way. The deck reveals when the mouse nears the
bottom edge and hides after 3 s idle; under GPU/projectM the trace-palette
controls hide (they color the built-in modes), web-style. DJ shuffle hops
engine AND visual every 30 s; VJ shuffle switches on the music itself — a
bass drop hard-cuts, a long lull morphs.
Under the projectM engine the deck grows a MORPH row: a target dropdown
(shuffle, or any preset in the library), a 0.5–10 s blend slider, and a
morph button that cross-fades from the playing preset to the target using
the engine's soft cut. Timed shuffle uses that same blend length, so preset
hops glide instead of cutting; VJ shuffle drops on the beat and morphs
through the lulls. The blend length is saved in wavescope.ini.
L (or the deck's lab (L) button) opens the preset lab — a live editor
over the active preset's whole .milk source. Ctrl+Enter (or apply)
recompiles it against the running signal; a syntax error keeps the previous
preset on screen and shows the engine's message. save writes the edit to
presets\user\ and switches the picker to it. To point MILK at your own
.milk archive, click the milkdrop deck button again.
R (or the deck's rec (R) button) starts/stops an MP4 beside the exe:
hardware H.264 where available plus AAC of the analysed audio, via Media
Foundation (no new dependencies). It captures the engine's render target
before the UI, at the current render resolution (built-ins) or window
size (GL engines), clamped to 4K — H.264 encoders stop near 4096 px a
side, so an 8K-target take lands as a 3840-wide file instead of failing.
Switching engine or resolution mid-take finalizes the file cleanly.
● REC mm:ss shows in the HUD.
O publishes the pre-UI visual as a Spout shared texture named
WaveScope — in OBS add a Spout2 Capture source (obs-spout2-plugin)
and pick it; Resolume/TouchDesigner see it the same way. This is the
desktop analog of the web app's ?embed=1 browser source, without the
browser. The state persists across launches. Implementation is the
vendored, pinned Spout SDK subset in third_party/spout (BSD-2-Clause;
see PIN.md there) — the frame feed reuses the recorder's capture path,
so the readback cost is only paid while the output is on. selftest
verifies the sender end-to-end with an in-process Spout receiver doing a
pixel roundtrip.
M opens the displays menu: move the window to any monitor, or
ALL DISPLAYS to open a borderless companion on every other screen — all
driven by the one audio engine (one capture, one analysis; the web app
needs BroadcastChannel + PCM streaming for this). Each companion keeps its
own engine/mode/palette: focus it and use the normal keys; Esc closes
just that companion. WaveScope.exe wall boots straight into that state;
kiosk is a single borderless fullscreen with idle cursor-hide.
B signs you in to Spotify once through your browser (no client secret; the
client ID lives in wavescope.ini, yours to change) and starts playback on
your device. The sign-in returns to a local listener on 127.0.0.1:8888,
opened before the browser and matched to that one request; calls go over
WinHTTP and your stored sign-in is kept for your Windows user account, so
signing in again is rare. Playback control needs Premium (visualizing
already-playing audio needs nothing), and the HUD shows now-playing
(♪ track — artist), resumed on later launches.
The bundled client ID is this project's registered Spotify app. If new
sign-ins are refused (Spotify limits development-mode apps to allowlisted
users), create your own free app at developer.spotify.com and put its
client ID in wavescope.ini.
wavescope.ini beside the exe: mode, engine (incl. MILK library folder),
palette + custom palettes, resolution, shuffle (incl. include-list), calm,
window placement, and your Spotify sign-in (client ID + stored login, tied
to your Windows user account). The primary window's setup is what persists;
source intentionally resets to system audio each launch.
demo · file=<path> · milkdrop · shader · wall · kiosk ·
install-startup / remove-startup (HKCU Run key, off by default) ·
selftest. Tray icon: right-click for Show/Hide, Always on top, Quit;
minimize hides to tray (and rendering fully stops while hidden).
src/analysis.inl— the pure math shared with the tests:AudioFrame, HSL,BinLog, radix-2 FFT,AnalyzeWindow(the analyser contract).src/main.cpp— infrastructure:AudioTap(WASAPI loopback/mic thread, ring buffer, file playback, device-change + silence handling, per-consumerReadNewcursors),App(window, D2D, deck/HUD/panel UI, engines, persistence), companion windows, tray, message loop.src/viz_modes.inl— palettes,DrawCtx,VizState(per-window mode state), all 34 modes.src/shader_host.inl— the GPU engine: WGL host, 17 GLSL ports, audio textures.src/projectm_host.inl— the MilkDrop engine host: WGL host, libprojectM, preset scan/sort/hot-reload.src/spotify.inl— the Spotify sign-in flow, local return listener, WinHTTP calls, stored login, now-playing poller.src/record.inl— MP4 clip recorder (Media Foundation SinkWriter).src/spout_out.inl— Spout output wrapper (third_party/spout).src/ui_paint.inl— the abstract UI painter (one UI, every engine).src/selftest.inl— theselftestharness.src/tests.cpp— unit tests for the pure math (run bybuild.bat).
OS facilities everywhere they exist (WinHTTP, Media Foundation, WASAPI,
D2D/DirectWrite/WIC, WGL, and the OS secret-store APIs), two vendored dependencies (libprojectM
4.x, LGPL-2.1, pinned, built from C:\projects\projectm; the Spout SDK's
DX subset, BSD-2-Clause, pinned in-tree), and small hand-rolled code only
where it is boring and testable (ini, flat JSON reads, base64url).
MIT © 2026 Signal Ridge Labs — same as the web app. Third-party:
- libprojectM 4.x — LGPL-2.1, satisfied by dynamic linking
(
projectM-4.dllships beside the exe, never statically linked). - Spout SDK (DX subset) — BSD-2-Clause, vendored verbatim with its
license at
third_party/spout/LICENSE.txt(pin inPIN.mdthere). - MilkDrop preset + texture packs — community-distributed content,
redistributed from the projectM-visualizer org (
presets-classic/andtextures/; the pack's own README ships alongside).
Full license texts and exact upstream pins live in
THIRD-PARTY-NOTICES.txt (repo root, and inside every release zip).

