# rspektrum An interactive spectrogram viewer for inspecting **mLink** radio captures (and any other audio). It computes an STFT of a WAV file, draws it as a zoomable, pannable spectrogram, and can overlay **mLnL annotations** — labelled regions (TX frames, assertion outcomes, impairment fires, …) carried *inside* the WAV file itself. You can box a time/frequency region, hear it back through a bandpass filter, and export either the picture (PNG) or the isolated audio (WAV). It runs as a native desktop app (C + raylib), as a headless CLI renderer, and as a WebAssembly build in the browser. ## What it's for The primary use case is reviewing captures from the mLink stack: a WAV recording of an over-the-air signal, with an embedded `mLnL` chunk describing what the modem/daemon believed it was transmitting at each instant. rspektrum renders those annotations on top of the measured spectrogram so you can see, frame by frame, what was *intended* versus what actually hit the air. See [`mlnl_chunk_spec.md`](mlnl_chunk_spec.md) for the annotation format. It also works as a general-purpose spectrogram tool for plain WAVs with no annotations. ## Features - **STFT spectrogram** with selectable colormaps, adjustable dB floor / dynamic range, and absolute (dBFS) or relative amplitude scaling. - **mLnL annotation overlay** — labelled boxes drawn from the WAV's embedded annotation chunk; hover a box (or its region on the waveform scope) for a tooltip with per-frame detail (sequence, channel, rate, scheduling offset…). - **Zoom & pan** the time/frequency view (mouse wheel + Alt/middle-drag). - **Region selection**: box a time **and** frequency range with the mouse. - **Filtered playback**: play just the selected region, band-limited to the selected frequency box (FFT bandpass — "what you hear is what you'd export"). - **Waveform scope** (toggle) showing the time-domain signal under the spectrum. - **Marker / ruler tool** and a **spectrum slice (PSD)** readout. - **Export**: save the view as a **PNG**, or the selected region as a **WAV**. - **Headless render mode**: produce an annotated PNG from the command line with no window, no GL, and no X server — pure CPU, runs anywhere (see below). - **Broad input support**: WAV directly (8/16-bit PCM, 32-bit float; stereo downmixed to mono); other formats transparently transcoded via `ffmpeg` if it is on `PATH`. Drag-and-drop loading. - **Cross-platform**: Linux/desktop, Windows, and a WebAssembly build. ## Building The build is driven by a checked-in Makefile (premake5 is **not** required to build — only to regenerate the makefiles): ```bash make -f rspektrum.make config=debug_x64 # -> bin/Debug/rspektrum make -f rspektrum.make config=release_x64 # -> bin/Release/rspektrum ``` The web build: ```bash ./build_web.sh # emscripten; emits the WebAssembly bundle ``` > Note: the release build enables `-O2`, which turns on extra warnings > (`-Wformat-truncation`) that the debug build doesn't — build release before > declaring a change clean. ## Running (GUI) ```bash ./bin/Debug/rspektrum [input.wav] ``` Load a file by passing it on the command line, dragging a `.wav` onto the window, or pressing **O** for the file browser. ### Controls | Input | Action | |-------|--------| | **O** | Open file browser | | **Mouse wheel** | Zoom time/frequency | | **Alt+drag** / **middle-drag** | Pan the view | | **LMB drag** | Select a time + frequency region | | **Space** | Play / stop the selected region | | **Hover an annotation** | Tooltip with that frame's mLnL detail | | **P** | Show / hide the waveform scope | | **M** | Marker / ruler tool | | **S** | Spectrum slice (PSD) | | **E** | Export PNG | | **W** | Export selection as WAV | | **Home** | Reset view (fit all) | | **End** | Zoom to start | | **F11** | Toggle fullscreen | | **F1** | About / help | | **Esc** | Clear selection / close dialog | Most controls are also available as buttons in the left sidebar (colormap, floor, dynamic range, annotation opacity, grid, etc.). ## Headless rendering (CLI) `--render` writes the spectrogram straight to a PNG **with no window, no GL context, and no X server** — it computes the STFT, colorizes the bitmap, bakes the annotation overlay onto it, and exports, all on the CPU. This runs anywhere (CI, a bare SSH session, a container with no display), not just under Xvfb: ```bash ./bin/Debug/rspektrum --render OUT.png INPUT.wav [options] ``` The output is the **real spectrogram bitmap** at native STFT resolution (not a screenshot of the UI), so it carries no sidebar/scope chrome — just the time–frequency image with the annotation overlay. Options: | Flag | Effect | |------|--------| | `-r, --render OUT.png` | Render to `OUT.png` and exit (no window/GL/X) | | `-a, --annotations` | Force the annotation overlay **on** | | `--no-annotations` | Force the overlay off | | `--annotation-opacity=V` | Overlay strength `0..1` (default `0.5`) | | `--annotation-kinds=LIST` | Comma-separated kinds to draw (default: all) | | `--width N` | Resize output to `N` px wide (default: native STFT size) | | `-h, --help` | Usage | Annotation boxes are drawn **outline + label only** (no translucent fill): mLnL captures contain many overlapping full-band boxes whose fills would alpha-stack to opaque and bury the signal, so the outline marks each region while the spectrogram reads through. `--annotation-opacity` controls outline/label strength. Filter to just the kinds you care about with `--annotation-kinds`: ```bash # everything, brighter overlay ./bin/Debug/rspektrum --render /tmp/all.png mlnl_samples.wav --annotation-opacity=0.7 # only on-air frames and failed assertions ./bin/Debug/rspektrum --render /tmp/tx.png mlnl_samples.wav \ --annotation-kinds=tx_frame,assertion_failed ``` Kinds: `tx_frame`, `tx_burst`, `control`, `channel_up`, `channel_down`, `assertion_passed`, `assertion_failed`, `impairment_fire`, `gain_change`, `unknown`. `mlnl_samples.wav` is an in-repo WAV that carries an embedded `mLnL` chunk. > The hover tooltip (sched offset, per-frame detail) only appears with a live > mouse over a box, so it cannot show up in a static `--render`. To verify > tooltip behaviour you need a real (or virtual) display driving the GUI — see > below. ## Driving the GUI headlessly (for agents / CI) The app can be run, screenshotted, and clicked on a virtual X display with no monitor or GPU (Mesa software GL under Xvfb). The accumulated playbook lives in [`AGENTS.md`](AGENTS.md); the working reference implementation is [`shot_input.sh`](shot_input.sh). The loop in one breath: ```bash Xvfb :99 -screen 0 1280x800x24 >/tmp/xvfb.log 2>&1 & # 1. fake screen DISPLAY=:99 ./bin/Debug/rspektrum mlnl_samples.wav \ >/tmp/app.log 2>&1 & # 2. run on it sleep 2 # 3. reach a steady frame DISPLAY=:99 import -window root /tmp/shot.png # 4. grab the frame ``` Prerequisites (Debian/Ubuntu): `sudo apt-get install xvfb imagemagick xdotool` (plus `libgl1-mesa-dri` and `LIBGL_ALWAYS_SOFTWARE=1` if GL fails / frames are black). To exercise UI paths, synthesize input with `xdotool` against `DISPLAY=:99` — e.g. move the mouse over an annotation box and re-grab to capture the hover tooltip: ```bash DISPLAY=:99 xdotool mousemove 640 400 # hover a box (coords from the spectrogram) DISPLAY=:99 import -window root /tmp/hover.png DISPLAY=:99 xdotool key space # play the selection, etc. ``` There is no window manager, so the window sits at `0,0` and fills the Xvfb screen — match the screen size to the window and a root grab equals the app's frame. Always capture stdout/stderr to a log; it's your only view of `TraceLog` output and crashes. `shot_input.sh` wraps all of this (start → settle → screenshot → optional input → diff); read `AGENTS.md` for the traps (input focus, ImageMagick v6 vs v7 command names, software-GL timing). ## Technical notes - **STFT**: Hann-windowed, 2048-point FFT with 50% overlap by default; frequency resolution `sampleRate / fftSize` Hz per bin. Amplitude shown in dB. - **Axes**: X = time (s), Y = frequency (Hz, scaled to the file's Nyquist), colour = amplitude. - **Playback / WAV export** share the same processing path: the selected time span, FFT-bandpassed to the selected frequency box, peak-normalised. - **mLnL parsing**: walks the WAV's RIFF chunks for the four-CC `mLnL` chunk (UTF-8 JSON Lines); unknown chunks are skipped, so annotated files remain standards-compliant audio everywhere else. ## Source layout ``` src/ spectrogram.c # entry point, main loop, CLI args, headless render stft.c / fft.c # STFT + FFT render.c # spectrogram, annotations, tooltips, scope ui.c # sidebar, file browser, buttons audio.c # WAV load (ffmpeg fallback), bandpass, playback, WAV export mlnl.c / mlnl.h # mLnL annotation chunk parser platform_*.c # per-OS shims (linux / win32 / web) ``` See [`raylib_for_desktop_applications.md`](raylib_for_desktop_applications.md) for the performance/idle-CPU lessons behind the desktop build.