Compare commits

..
9 Commits
Author SHA1 Message Date
tylerandClaude Opus 5 438150080c fix: check STFT allocations; reject files too large for the web build
Every malloc/calloc in stft.c was written through unchecked. On desktop
that never bites — Linux overcommits and swaps — but WebAssembly has a
hard 32-bit address-space ceiling, so on a long capture the allocations
genuinely fail and the code wrote through NULL into low memory. That is
why it surfaced as corrupted font glyphs rather than a crash, and only
after loading a large file.

The apparent hang had the same root: ComputeSegment returned void, so a
failed allocation was invisible and the loop ground through all remaining
segments failing identically. The cursor readout showing "-" for the level
was the tell — the segments were there but their spectra were NULL.

ComputeSegment now returns a bool, ComputeSTFTIncremental stops at the
first failure instead of churning, and both halves of a segment's spectra
are freed together (reassignment reads them in lockstep, so half a segment
is worse than none). ComputeSTFTInit, CopySTFT and the scratch buffers are
checked too.

Segments left NULL are already skipped by every consumer — that is how the
progressive fill renders partial results — so a file that nearly fits
degrades to a truncated spectrogram rather than corrupting.

The web load path treats exhaustion as fatal and says so: a multi-hour
capture needs ~11 GB of spectra (478k segments x 1025 bins x two spectra x
12 bytes for the 5.7-hour case) against a 4 GB ceiling browsers cap below
in practice, so there is nothing useful to fall back to. Better to explain
that than present a spectrogram full of holes.

Corrects the known_bugs.md entry, which blamed ALLOW_MEMORY_GROWTH
invalidating cached pointers. That theory fit the symptom but was wrong;
the allocations were simply failing. Also notes what making large files
actually work would take — 16-bit magnitudes, dropping the derivative
spectrum when synchrosqueezing is off, or streaming segments.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V8ZWfr5XZyyDttvkhJUgHN
2026-08-12 20:32:17 -07:00
tylerandClaude Opus 5 bc8bcf78b7 fix: web text corruption, blocking-load notice, no-cache dev server
**Most UI text drew with raylib's built-in font, not the loaded one.**
Thirteen call sites used DrawText/MeasureText directly instead of the
project's DrawTextScaled/MeasureTextScaled wrappers, so the cursor and
marker readouts, spectrum panel labels and export toast rendered from
raylib's default bitmap atlas. On desktop that merely looked slightly off;
on web it renders as garbage. The only raw calls left are the two
fallbacks inside the wrappers themselves.

**The rail tooltip held a dangling pointer.** RailButton stored a
TextFormat() result for the deferred draw pass, and raylib documents that
string as expiring once TextFormat has been called a few more times — which
it has by then. It copies into owned storage now.

**Loading a large file in the browser looked like a crash.** The web build
computes its whole STFT in one synchronous pass, with no frame presented in
between, so nothing drawn on the canvas during that window ever reaches the
screen. A notice now goes into the host DOM instead, which the browser
paints independently, and yields once so that paint actually happens before
the work starts. Because that yield unwinds the stack under ASYNCIFY, the
load block is guarded by stftBusy — without it the re-entered main loop
calls ComputeSTFTInit again and frees the STFT the suspended call is still
building.

**Removed EXPORTED_FUNCTIONS from the web link flags.** It replaces
emscripten's default export list rather than extending it, so everything
unnamed is dead-code-eliminated. The upload callback stays reachable via
the EMSCRIPTEN_KEEPALIVE already on its definition.

**Adds serve_web.py**, a dev server that sends no-store. Browsers cache
.wasm hard enough that a plain reload runs a stale module while the page
looks freshly loaded — which makes a rebuild appear to change nothing.
That cost most of a debugging session today: three separate fixes were
tested against a binary that never changed, each returning an identical
fault at an identical address. README now points at it and says why.

Documents the remaining web issue in known_bugs.md: large captures still
corrupt text after loading. Leading theory is ALLOW_MEMORY_GROWTH
reallocating the heap mid-load and invalidating pointers cached across
that moment, which fits the symptom being text specifically.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V8ZWfr5XZyyDttvkhJUgHN
2026-08-12 18:38:59 -07:00
tylerandClaude Opus 5 40623feadc feat: web file upload, axis-locked grid, averaged cursor readout
**File loading on the web.** The browser build's filesystem is an in-memory
sandbox, so the file browser could only ever list what the page itself had
written — useless for opening a capture off the user's disk. Adds
Platform_NeedsFileUpload / RequestFileUpload / TakeUploadedFile: no-ops on
desktop, and on web they drive a real <input type="file">, copy the chosen
bytes into MEMFS, and hand the path back through a callback the main loop
polls. The ordinary load path takes it from there, so mLnL parsing and
collision detection work identically.

All three entry points (the O key, File -> Open, and the empty-state
button) route through the same place, so they can't disagree about what
"open" means. ccall/cwrap/FS and the callback symbol needed explicit
export — recent emscripten omits them by default and the failure is
silent until someone clicks the button.

The empty-state banner was centred with the old 320px sidebar's width
hardcoded and never measured its text, so it sat well off-centre once the
rail shrank to 46px. It now measures and centres against the area right of
the rail, and says something useful on web.

**Grid was not locked to the axes.** It drew a fixed 10x8 even divisions of
the viewport rectangle, with no idea what time or frequency those lines
fell on — so they marked arbitrary values and slid continuously while
panning, never coinciding with the labels. Both axes now draw at real
values from a 1-2-5 ladder, and the time labels moved onto that same
ladder (they were at 11 fixed fractions of the view, printing values like
"3.47s" that matched no gridline). Grid and labels share one set of
spacing helpers so they cannot drift apart again.

**Cursor dB readout was a single bin.** On an OFDM burst that swings ~30 dB
between adjacent subcarriers and symbols, so the number reported where the
cursor landed rather than the level of the signal under it. Now averaged
over +/-3 segments x +/-6 bins (~76 Hz x 300 ms at 12 kHz / 2048, under a
third of the narrowest mLink channel, so it stays inside one signal).
Simulated against Rayleigh-distributed subcarriers this cuts the standard
deviation from 5.6 dB to 0.44 dB.

The mean is taken over power and converted afterwards: averaging dB values
is a geometric mean of power and read ~2 dB low. The window is sized in
STFT cells rather than screen pixels, which would otherwise mean something
different at every zoom — one pixel spans hundreds of segments zoomed out
and a fraction of a bin zoomed in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V8ZWfr5XZyyDttvkhJUgHN
2026-08-12 16:23:26 -07:00
tylerandClaude Opus 5 56b1666850 fix: playhead drift, replay, and cursor flicker; add selection transport
**Playhead ran ahead of the audio.** It was dead-reckoned by summing
GetFrameTime() every frame, so every frame's *render* work counted as
playback time and the error compounded — by seconds over a long region,
and worst when zoomed out where each frame does the most work. That is why
it tracked fine zoomed in. It is now derived from a wall-clock instant
captured when the buffer is handed to the device, so it cannot drift from
the audio regardless of frame timing.

**Replay after a natural finish left the marker stuck at the end.** The
rail's play button never cleared playbackFinished, so the stale
playheadT > 1.0 persisted and DrawPlayhead early-returned while the audio
played from the top. The Space path already handled this; the button did
not.

**No way to replay a subrange.** The playhead is now drawn while stopped
(with a grab tab) and can be dragged to set where the next play starts
*within* the selection, leaving the region itself intact. Stopping parks
the marker where it stopped rather than snapping to the start.

playheadT is a fraction of the *played* span, which stops being the
selection once a scrub offset exists — so every conversion goes through
absolute file time, the only frame the two share. Getting this wrong made
stop-after-scrub jump backwards.

**Selection transport bar.** Rewind / play-pause / stop / loop, attached
above the selection box. Pause resumes where it left off; loop restarts
from the top of the region rather than repeating whatever tail the last
play started from.

**Cursor flicker.** Twelve unconditional SetMouseCursor calls ran per
frame and the last one won. Two blocks in particular both ran every frame:
one set a cursor regardless of mouse position, and a second overrode it
only when the mouse was inside the spectrogram — so when a capture guard
had parked mousePos off-screen, the first block's stale choice stuck. That
is the fight between finger/pointer, resize/pointer and crosshair/pointer.
Handlers now record a prioritised request (active drag > hover hint >
default) and it is applied once at the end of the frame. The divider hint
is RESIZE_NS rather than the 4-way arrow while here.

The rewind glyph drew its bar and triangle with a gap between them and read
as a lone vertical bar; verified the fix by rendering it offscreen and
dumping pixels rather than eyeballing the geometry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V8ZWfr5XZyyDttvkhJUgHN
2026-08-12 15:56:33 -07:00
tylerandClaude Opus 5 cce96659b1 feat: menubar + icon rail, minimap, Blender-style navigation
A pass over the whole interface, driven by using it on a 5.7-hour capture.

**Layout.** The 320px sidebar of labelled widgets is now a 46px icon rail —
one column of square buttons, sized so it costs the spectrogram as little
width as possible — plus a menubar for one-shot actions. Menu items are
defined by naming a keymap entry, so an item reuses that binding's action,
gate and shortcut label and the two can't drift; items grey out under
exactly the conditions that make the shortcut a no-op. Settings that need
more than an on/off (FFT size, colours/levels, annotation kinds) open as
popouts beside the rail rather than widening it. Every toggle has exactly
one home: nothing is reachable from both the rail and a menu.

Icons are drawn from raylib primitives rather than an atlas or font
glyphs — the bundled font has no symbol coverage, and vector shapes stay
crisp at any UI scale with no assets to ship.

**Navigation.** Left-drag now pans and Ctrl+drag box-selects, with Tab
swapping which is bare (Ctrl always means "the other one", so either mode
does both). Previously a bare left-drag did three different things
depending on invisible state, with no cursor feedback; the cursor now
reports the active gesture. Wheeling a scrollbar pans that axis, or zooms
it with Shift.

**Minimap.** Whole-file thumbnail in the top-right with the current view
drawn on it; click or drag to scrub, corner handle switches between two
sizes. Rendered once per size into a cached texture and rebuilt only when
its content changes — panning and zooming just move the rectangle drawn on
top. The reduction is strided (each thumbnail cell samples at most 8x8),
because reducing every segment x bin meant ~1 G reads per rebuild and a
visible hitch on every overlay toggle.

**Fixes found along the way:**
- The timeline lane mapped events across the whole file while the
  spectrogram above it showed a zoomed window, so the two only lined up at
  full zoom-out and an event's tick sat nowhere near its burst. It is also
  properly toggleable now: the old flag only grew an always-present lane.
- Clicks preferred the spectrogram over the minimap. Input handling runs
  ~900 lines before the draw pass that computed the minimap's rect, so a
  press there started a pan AND a scrub — two handlers writing app.view in
  one frame. Geometry queries that input depends on now live outside the
  draw pass.
- Repainting during the background fill re-ran the full synchrosqueeze
  every 0.5 s. Zoomed out that is ~0.5 G bin-visits with four trig calls
  each, twice a second, for minutes — while showing almost nothing new,
  since folded segments land in columns already drawn. The interval now
  scales with how much work a repaint actually costs.
- IsUserInteracting() sweeps 512 key codes and was called three times a
  frame; memoized per frame.
- Frequency labels were drawn at a fixed offset wider than their gutter and
  overflowed into the rail. They are measured and right-aligned now, with
  one format chosen per axis so the column doesn't mix "3k" with "2.3k".
- The horizontal scrollbar is pinned to the window bottom; it used to sit
  mid-layout competing with the scope's divider, and the scope covered it
  outright at larger window sizes.
- The scope starts hidden — the spectrogram is the primary view.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V8ZWfr5XZyyDttvkhJUgHN
2026-08-12 15:28:44 -07:00
tylerandClaude Opus 5 82294844dd feat: hide control annotations by default, demote auto-crop notice to a toast
Control events are zero-duration log markers about the run rather than
signals on the air. A busy capture carries thousands, and they clutter the
overlay without saying anything about what was transmitted, so the GUI now
starts with that kind hidden; the per-kind checkbox brings it back. The
headless --render path still enables every kind — an export should render
what was asked for, not a GUI preference.

The auto-crop notice was a full modal: it dimmed the window, took focus,
and demanded a click before the user could look at the file they had just
opened. Auto-crop is a helpful default, not a decision worth blocking on.
It is now a bottom-right toast offering Undo / Dismiss, with a progress
strip showing the remaining time so it doesn't just vanish mid-read, and
it is out of UiModalOpen() so it no longer swallows keys or blocks the
spectrogram underneath.

The countdown only advances while the window is focused, so a crop applied
during a background load is still readable when the user returns. The
per-frame step is clamped: auto-crop fires on the frame right after the
blocking STFT compute, and GetFrameTime() there reports the entire
compute, which drained the whole 5 s budget at once and made the toast
flash by in an instant.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V8ZWfr5XZyyDttvkhJUgHN
2026-08-12 14:17:05 -07:00
tylerandClaude Opus 5 4c3c6a955a perf: unblock long-file loading; add collision detection and jump nav
Three things, all surfaced while working a multi-node protocol issue on a
5.7-hour capture.

**Loading was frame-paced, not compute-bound.** The STFT overview advanced
a fixed 200 segments per frame, so the frame limiter — not the FFT — set
the pace: 478k segments at ACTIVE_FPS meant over a minute spent waiting
between frames rather than computing. The tell was absurd: backgrounding
the window, which skips presenting entirely, loaded the same file in
seconds. The progress bar was slower precisely because you were watching
it. The overview is now computed in one blocking call after presenting the
loading panel, so focused load matches the unfocused speed. The panel says
the window will stop responding and drops the percentage, which could not
animate and would have read as a hang. ACTIVE_FPS 30 -> 60 while here;
idle still parks at ~0% CPU through the event-wait path.

Background work also no longer stops when the window loses focus. Pending
work now counts as "active" regardless of focus, so the loop doesn't block
in PollInputEvents waiting for input that isn't coming, and an unfocused
frame with work outstanding skips the draw pass entirely rather than
throttling the high-res fill to the refresh rate.

**Collision detection.** Annotations that overlap in both time and
frequency are flagged, merged into contiguous regions, and drawn as red
bands confined to the band the overlap occupies (padded, so a narrow
overlap stays findable) rather than spanning the full axis and hiding the
signal being pointed at. N / Shift+N and sidebar buttons jump between
regions, centring each without disturbing the current zoom.

Point markers are excluded: control events and assertions have no band and
zero duration, and treating a missing band as "whole spectrum" — which is
how they are *drawn* — made every marker collide with whatever it sat
inside. That was 38% of the reported collisions on a real capture. Only
things that actually occupy the air can interfere. Counts verified against
an independent reference implementation on two captures.

**Scope waveform via min/max summary.** The envelope rescanned every
visible sample every frame — ~245M reads, near 1 GB of memory traffic, on
a multi-hour file, measured at ~60 ms/frame for a few hundred pixel
columns. It now draws from 1024-sample buckets built once at load (60 ms,
1.9 MB), measured at ~0.07 ms/frame. Keeping both extremes per bucket
means single-sample transients still show at full zoom-out, which plain
decimation would drop; verified that no column ever understates a true
peak. Zoomed in past a bucket it falls back to raw samples, which is cheap
there by definition.

Also fixes ComputeCollisions never running for files opened through the
file browser, and moves the collision panel above the annotations dropdown
where it isn't pushed off the bottom of the sidebar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V8ZWfr5XZyyDttvkhJUgHN
2026-08-12 13:58:58 -07:00
tylerandClaude Opus 5 8026d10547 fix: stop annotation labels overlapping, drop click-to-pin
Every box drew its label unconditionally, so overlapping transmissions
stacked their text into an unreadable smear — several frame names painted
over each other at the same pixels.

DrawBoxLabel now claims a screen-space rect before drawing and skips the
label if it would intersect one already placed this frame. Draw order
decides the winner, so the topmost box keeps its text and the ones beneath
go quiet instead of smearing. The claim is clipped to the box width, so a
long label reserves only what the scissor actually paints rather than
silencing neighbours over space it never uses. Nothing is lost: hovering
still reports every box under the cursor.

The stacked-hover tooltip now sits above the cursor and centred on it,
flipping below only when there is no room. It previously opened down and
to the right, covering the very boxes being described.

Also removes click-to-pin. It was never asked for, it did not reliably
work, and hover alone answers the question it was meant to serve.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V8ZWfr5XZyyDttvkhJUgHN
2026-08-12 12:50:30 -07:00
tylerandClaude Opus 5 8017954aa1 fix: render the spectrogram from the visible segment range
The source image was sized at one column per STFT segment with no upper
bound. That is fine for short captures but impossible for long ones: a
5.7-hour file at 12 kHz yields ~478k segments, i.e. a 478450x1025 RGBA
image (~2 GB) roughly 29x past the ~16k-per-dimension texture limit every
GL implementation enforces. The upload failed, the texture id stayed 0,
and the draw was skipped — so the spectrogram was simply blank, with no
error anywhere to say why.

Build the image for the segment range actually on screen instead of the
whole file, capped at MAX_SPECTRO_IMAGE_WIDTH. Simply clamping the
full-file width would have fixed the blank render while permanently
discarding the detail zooming is meant to reveal (478k segments into 8k
columns is 59:1, no matter how far in you go). Tying the range to the view
keeps resolution proportional to zoom: 59 segments per column at full
zoom-out, reaching 1:1 by ~1% zoom, with the image never exceeding ~33 MB.

Where several segments do share a column their per-bin MAX is kept rather
than a sum or mean, so a short burst lights its column instead of being
diluted by quiet neighbours — the same reasoning behind a min/max waveform
envelope. Amplitude normalisation is likewise scoped to the visible span,
which keeps rebuild cost independent of total duration and lets the colour
scale follow what is on screen instead of a loud burst hours away.

Rebuilds trigger when the view leaves the cached range, which is padded by
25% so ordinary panning re-renders about once every six frames rather than
every frame. The headless --render path sets no range and so still covers
the whole file, capped; verified end to end on a 5.67-hour capture, which
now renders 8110x1025 with visible structure where it previously produced
nothing at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V8ZWfr5XZyyDttvkhJUgHN
2026-08-12 12:50:16 -07:00
18 changed files with 3450 additions and 704 deletions
+130 -29
View File
@@ -44,10 +44,15 @@ format.
- **mLnL annotation overlay** — labelled boxes from the WAV's embedded annotation - **mLnL annotation overlay** — labelled boxes from the WAV's embedded annotation
chunk; hover a box (or its region on the scope) for per-frame detail (sequence, chunk; hover a box (or its region on the scope) for per-frame detail (sequence,
channel, rate, scheduling offset…). channel, rate, scheduling offset…).
- **Zoom & pan** the time/frequency view. - **Zoom & pan** the time/frequency view, with a **minimap** for navigating a
long capture without zooming out and back in.
- **Collision detection** — finds transmissions that genuinely overlap in *both*
time and frequency, and jumps between them.
- **Region selection** — box a time *and* frequency range with the mouse. - **Region selection** — box a time *and* frequency range with the mouse.
- **Filtered playback** — play just the selected region, band-limited to the - **Filtered playback** — play just the selected region, band-limited to the
selected frequency box via an FFT bandpass. What you hear is what you'd export. selected frequency box via an FFT bandpass. What you hear is what you'd export.
Transport controls (play/pause, rewind, loop) attach to the selection, and the
playhead can be dragged to replay a subrange.
- **Waveform scope** — toggleable time-domain view beneath the spectrum. - **Waveform scope** — toggleable time-domain view beneath the spectrum.
- **Marker / ruler** and a **spectrum slice (PSD)** readout. - **Marker / ruler** and a **spectrum slice (PSD)** readout.
- **Export** — save the view as a PNG, or the selected region as a WAV. - **Export** — save the view as a PNG, or the selected region as a WAV.
@@ -106,9 +111,28 @@ prints the install hint for your platform if anything is missing.
### Web (WebAssembly) build ### Web (WebAssembly) build
```bash ```bash
./build_web.sh # emscripten; emits the WebAssembly bundle to bin/web/ source ~/emsdk/emsdk_env.sh # emscripten on PATH
./build_web.sh # emits the bundle to bin/web/
cd bin/web && python3 ../../serve_web.py
``` ```
Then open <http://localhost:8080/rspektrum.html>.
**Use `serve_web.py`, not `python3 -m http.server`.** Browsers cache `.wasm`
and `.js` hard, and a plain reload will happily run a stale module while the
page looks freshly loaded — so a rebuild appears to change nothing and you end
up debugging a binary you already fixed. `serve_web.py` sends `no-store` on
everything, which makes any reload authoritative.
The browser build's filesystem is an in-memory sandbox, so files come in by
**drag-and-drop** or the **Open file** button (which drives the host's native
file picker and copies the result in). The desktop file browser is bypassed
there — it could only ever list what the page itself had written.
The STFT is computed in one synchronous pass on load rather than progressively,
because the incremental fill depends on idle main-loop frames that the browser
doesn't give back the same way. Long captures therefore block until they finish.
--- ---
## Usage (desktop GUI) ## Usage (desktop GUI)
@@ -126,16 +150,25 @@ or pressing **O** for the file browser. Try the bundled sample:
### Controls ### Controls
**Navigating.** A left-drag pans by default and **Ctrl+drag** draws a selection
box; **Tab** (or the rail's pan/select icons) swaps which one is bare, and Ctrl
always means "the other one", so either mode does both without switching back.
Middle-drag always pans.
| Input | Action | | Input | Action |
|-------|--------| |-------|--------|
| **O** | Open file browser | | **LMB drag** | Pan the view (**Ctrl+drag** to box-select) |
| **Tab** | Swap pan / select mode |
| **Middle-drag** / **Alt+drag** | Pan, regardless of mode |
| **Mouse wheel** | Zoom both axes (preserves aspect ratio) | | **Mouse wheel** | Zoom both axes (preserves aspect ratio) |
| **Shift+wheel** | Zoom the time axis only | | **Shift+wheel** | Zoom the time axis only |
| **Ctrl+wheel** | Zoom the frequency axis only | | **Ctrl+wheel** | Zoom the frequency axis only |
| **Alt+drag** / **middle-drag** | Pan the view | | **Wheel on a scrollbar** | Pan that axis (**Shift** to zoom it) |
| **LMB drag** | Select a time + frequency region |
| **Space** | Play / stop the selected region | | **Space** | Play / stop the selected region |
| **Hover an annotation** | Tooltip with that frame's mLnL detail | | **Drag the playhead** | (while stopped) set where the next play starts |
| **Hover an annotation** | Tooltip with that frame's mLnL detail; lists **every** overlapping frame under the cursor |
| **N** / **Shift+N** | Jump to the next / previous collision |
| **O** | Open file browser (the host's file picker on web) |
| **P** | Show / hide the waveform scope | | **P** | Show / hide the waveform scope |
| **M** | Marker / ruler tool | | **M** | Marker / ruler tool |
| **S** | Spectrum slice (PSD) | | **S** | Spectrum slice (PSD) |
@@ -147,8 +180,63 @@ or pressing **O** for the file browser. Try the bundled sample:
| **F1** | About / help | | **F1** | About / help |
| **Esc** | Clear selection / close dialog | | **Esc** | Clear selection / close dialog |
Most controls are also available as buttons in the left sidebar (colormap, floor, ### Layout
dynamic range, annotation opacity, grid, …).
A menubar across the top holds one-shot actions (**File** — open, export
PNG/WAV; **View** — reset/zoom, hide the icon rail, fullscreen; **Annotations**
jump to next collision; **Help**). Everything that toggles lives on the rail
instead, so no control has two homes. Menu items are defined by naming a keyboard shortcut, so an item and
its key can never drift apart, and items grey out under exactly the conditions
that make the shortcut a no-op.
Down the left is a narrow **icon rail** — one column of square buttons, sized so
it costs the spectrogram as little width as possible. Hover any icon for a
tooltip. Left to right in function: play/stop and clear selection; pan/select
mode; marker, spectrum slice, scope, grid, minimap; FFT size and colour/level
popouts; annotations, collisions, and the timeline lane. The three settings
popouts open beside the rail rather than widening it. `View → Hide icon rail`
hands its width back to the spectrogram.
The **minimap** (top-right, toggled from the rail) is a thumbnail of the whole
capture with the current view drawn on it — click or drag anywhere on it to
scrub. Annotation density runs along its bottom edge and collisions along its
top. The corner handle switches between two sizes. It is rendered once into a
texture and only rebuilt when its *content* changes (new file, colormap,
overlays toggled); panning and zooming just move the rectangle drawn on top, so
navigation costs nothing.
### Playback
**Space** plays the selected region, band-limited to the selected frequency box.
Selecting a region also brings up a small transport bar above it — rewind,
play/pause, stop, and loop. Pause resumes where it left off; loop repeats the
whole region.
While stopped, the playhead stays where it is and can be dragged: that sets
where the next play begins *within* the selection, so a subrange can be replayed
without redrawing the region. Clearing or redrawing the selection resets it.
### Inspecting overlapping transmissions
When several stations are on the air at once their annotation boxes stack, and
the one drawn last hides the rest. Two features address that:
- **Hover** any pile-up and the tooltip lists *every* frame under the cursor —
one row per frame with its own colour swatch, led by the fields that actually
tell them apart (node, frame name, position in the PTT, channel). Deep piles
are capped with a `+N more` count.
- **Collisions** (sidebar toggle) highlights where transmissions genuinely
overlap in **both** time and frequency. `N` / `Shift+N`, or the sidebar
`< prev` / `next >` buttons, jump between them; each jump centres the region,
keeps the current zoom unless the region needs more room, and reports its
position (`Collision 7/54 — 3 frames at 1284.95s`).
A collision requires a real overlap in time *and* band, so two frames in
different channels at the same instant are not flagged, and neither are
zero-duration point markers (`control`, assertions), which annotate the run
rather than occupy the air. Markers are drawn only across the band the overlap
occupies, not the full frequency axis. Adjacent collisions merge into one
region, so a busy stretch reads as a single span rather than dozens of bars.
--- ---
@@ -203,25 +291,10 @@ Annotation kinds: `tx_frame`, `tx_burst`, `control`, `channel_up`,
## Driving the GUI headlessly (agents / CI) ## Driving the GUI headlessly (agents / CI)
The app can be run, screenshotted, and clicked on a virtual X display with no The app runs, screenshots, and takes synthetic input on a virtual X display with
monitor or GPU (Mesa software GL under Xvfb). The full playbook lives in no monitor or GPU (Mesa software GL under Xvfb). The playbook is in
[`AGENTS.md`](AGENTS.md); the working reference implementation is [`AGENTS.md`](AGENTS.md); [`shot_input.sh`](shot_input.sh) is the working
[`shot_input.sh`](shot_input.sh). reference implementation.
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). Synthesize input with `xdotool` against `DISPLAY=:99` to exercise UI
paths.
--- ---
@@ -231,6 +304,33 @@ paths.
frequency resolution `sampleRate / fftSize` Hz per bin. Amplitude in dB. frequency resolution `sampleRate / fftSize` Hz per bin. Amplitude in dB.
- **Axes** — X = time (s), Y = frequency (Hz, scaled to the file's Nyquist), - **Axes** — X = time (s), Y = frequency (Hz, scaled to the file's Nyquist),
colour = amplitude. colour = amplitude.
- **Loading** — the STFT overview is computed in one blocking pass behind the
progress panel. It used to advance a fixed number of segments per frame, which
made loading frame-paced rather than compute-bound: the frame limiter, not the
FFT, set the speed, so a 478k-segment capture spent over a minute waiting
between frames. The tell was that backgrounding the window — which skips
presenting entirely — loaded the same file in seconds. Background work also
continues while the window is unfocused, so a long capture can be left to
finish behind another window.
- **Long files** — two things keep cost tied to what's on screen rather than to
total duration. The spectrogram image is built for the *visible* segment range
(capped at 8192 px wide), so a multi-hour capture renders at all — an
unbounded full-file image exceeds the GPU texture limit and silently draws
nothing — and zooming in genuinely re-renders at higher resolution instead of
magnifying pixels. The scope draws from a precomputed min/max summary
(1024-sample buckets) rather than rescanning every visible sample each frame,
which on a 5.7-hour file is the difference between ~60 ms and ~0.07 ms per
frame. Keeping both extremes per bucket means a single-sample transient still
shows up when fully zoomed out.
- **Cursor dB readout** — averaged over a neighbourhood of STFT cells
(±3 segments × ±6 bins, roughly 76 Hz × 300 ms at 12 kHz / 2048) rather than
read from one bin. A single bin of an OFDM burst swings ~30 dB between
adjacent subcarriers and symbols, so a one-bin readout reports where the
cursor happened to land rather than the level of the signal under it. The mean
is taken over power and converted to dB afterwards — averaging dB values is a
geometric mean of power and reads a couple of dB low. Sized in STFT cells, not
screen pixels: one pixel spans hundreds of segments zoomed out and a fraction
of a bin zoomed in.
- **Time zoom limit** — the tightest visible window is derived from the STFT hop - **Time zoom limit** — the tightest visible window is derived from the STFT hop
(`fftSize / HOP_RATIO` samples), not from a fixed fraction of the file, so time (`fftSize / HOP_RATIO` samples), not from a fixed fraction of the file, so time
resolution does not degrade as files get longer: a 30-minute recording zooms in resolution does not degrade as files get longer: a 30-minute recording zooms in
@@ -252,8 +352,9 @@ paths.
src/ src/
spectrogram.c # entry point, main loop, CLI args, headless render spectrogram.c # entry point, main loop, CLI args, headless render
stft.c / fft.c # STFT + FFT stft.c / fft.c # STFT + FFT
render.c # spectrogram, annotations, tooltips, scope render.c # spectrogram, annotations, tooltips, minimap, scope
ui.c # sidebar, file browser, buttons ui.c # menubar, icon rail + popouts, file browser
primitives.c # waveform scope + its min/max envelope summary
audio.c # WAV load (ffmpeg fallback), bandpass, playback, WAV export audio.c # WAV load (ffmpeg fallback), bandpass, playback, WAV export
mlnl.c / mlnl.h # mLnL annotation chunk parser mlnl.c / mlnl.h # mLnL annotation chunk parser
platform_*.c # per-OS shims (linux / win32 / web) platform_*.c # per-OS shims (linux / win32 / web)
+16 -1
View File
@@ -90,7 +90,22 @@ done
# the resources dir at runtime, so the resources@resources preload covers it. # the resources dir at runtime, so the resources@resources preload covers it.
# INITIAL_MEMORY + ALLOW_MEMORY_GROWTH: the web build now computes the full STFT # INITIAL_MEMORY + ALLOW_MEMORY_GROWTH: the web build now computes the full STFT
# up front, so the heap must be able to grow for longer recordings. # up front, so the heap must be able to grow for longer recordings.
LDFLAGS="-s USE_GLFW=3 -s ASYNCIFY -s INITIAL_MEMORY=67108864 -s ALLOW_MEMORY_GROWTH=1 -s FORCE_FILESYSTEM=1 --preload-file resources@resources --shell-file $SCRIPT_DIR/web_shell.html -s NO_EXIT_RUNTIME=1" # ASYNCIFY_STACK_SIZE: the whole main loop runs under ASYNCIFY, so every yield
# copies the live C stack into this buffer. The 4 KB default is far too small
# for a stack that runs through the render/STFT call chain — overflowing it
# corrupts the heap and surfaces as "memory access out of bounds" at doRewind.
# EXPORTED_RUNTIME_METHODS: the file-upload path calls back into C from a
# browser event (ccall), and writes the chosen file into MEMFS (FS). Neither is
# exported by default in recent emscripten, and omitting them fails only at
# runtime, when the user clicks "Open file".
#
# Do NOT add EXPORTED_FUNCTIONS here. It *replaces* the default export list
# rather than extending it, so everything unnamed gets dead-code-eliminated —
# including the ASYNCIFY rewind machinery this build depends on for its blocking
# main loop. The symptom is a blank canvas after load with "memory access out of
# bounds" at doRewind. The upload callback stays reachable via
# EMSCRIPTEN_KEEPALIVE on its definition instead.
LDFLAGS="-s USE_GLFW=3 -s ASYNCIFY -s INITIAL_MEMORY=67108864 -s ALLOW_MEMORY_GROWTH=1 -s FORCE_FILESYSTEM=1 --preload-file resources@resources --shell-file $SCRIPT_DIR/web_shell.html -s NO_EXIT_RUNTIME=1 -s EXPORTED_RUNTIME_METHODS=ccall,cwrap,FS -s ASYNCIFY_STACK_SIZE=1048576"
if [ "$BUILD_TYPE" = "debug" ]; then if [ "$BUILD_TYPE" = "debug" ]; then
LDFLAGS="$LDFLAGS -g -O0 -s ASSERTIONS=1" LDFLAGS="$LDFLAGS -g -O0 -s ASSERTIONS=1"
+36
View File
@@ -6,6 +6,42 @@ need to decide.
--- ---
## Large WAVs in the browser: too large to analyse
**Status:** diagnosed and handled. The file is rejected with a message instead
of corrupting or hanging; it still cannot be opened in the browser.
A multi-hour capture needs far more memory than a 32-bit WebAssembly page can
address. v23 (5.7 h at 12 kHz) works out to ~478k STFT segments x 1025 bins x
two spectra x 12 bytes — roughly **11 GB of spectra alone**, against a hard
wasm32 ceiling of 4 GB that browsers cap below in practice.
The old symptom was not a memory-growth bug, as first assumed. The per-segment
`malloc`s in `ComputeSegment` were simply failing and their results written
through unchecked. On desktop that never bites (Linux overcommits and swaps),
but in wasm the failure is real, so the code wrote through NULL into low memory
— which is why it surfaced as *corrupted font glyphs* rather than a crash. The
apparent "hang" was the loop grinding through every remaining segment, each
failing the same way, since `ComputeSegment` returned `void` and nothing noticed.
Every allocation in `stft.c` is now checked. `ComputeSegment` reports failure,
`ComputeSTFTIncremental` stops at the first one rather than churning, and the
web load path frees the partial result and explains that the file is too large.
A file that *nearly* fits should degrade to a truncated spectrogram — NULL
segments are already skipped everywhere, which is how the progressive fill draws
partial results — though that path is reasoned rather than tested.
Making large files actually work in the browser needs a different data layout:
storing magnitudes as 16-bit, dropping the derivative spectrum unless
synchrosqueezing is on, or streaming segments rather than holding them all.
**Testing note:** always serve the web build with `serve_web.py`, never
`python3 -m http.server`. Browsers cache `.wasm` hard enough that a plain reload
runs a stale module, which makes rebuilds look like no-ops and has already
burned significant time chasing bugs that were fixed.
---
## Playhead vs. a selection edited mid-playback ## Playhead vs. a selection edited mid-playback
**Status:** partially addressed; underlying semantics still undefined. **Status:** partially addressed; underlying semantics still undefined.
Executable
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env python3
"""Dev server for bin/web with caching disabled.
The browser aggressively caches .wasm/.js, and a plain reload (Ctrl+R) will
happily serve a stale module while the page *looks* freshly loaded — which makes
"did my rebuild take effect?" impossible to answer and sends you chasing bugs
that were already fixed. Everything here is served no-store so a rebuild is
always what you get, regardless of how the page is reloaded.
"""
import http.server, socketserver, sys
PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8080
class NoCache(http.server.SimpleHTTPRequestHandler):
def end_headers(self):
self.send_header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
self.send_header("Pragma", "no-cache")
self.send_header("Expires", "0")
super().end_headers()
def log_message(self, fmt, *args):
pass # quiet; the build script is the interesting output
socketserver.TCPServer.allow_reuse_address = True
with socketserver.TCPServer(("", PORT), NoCache) as httpd:
print(f"serving bin/web on http://localhost:{PORT}/rspektrum.html (no-store)")
httpd.serve_forever()
+20 -2
View File
@@ -195,8 +195,16 @@ static float* BuildSelectionAudio(int* outNumSamples)
{ {
if (!app.loaded || !app.stftComputed) return NULL; if (!app.loaded || !app.stftComputed) return NULL;
int startSample = (int)(app.sel.timeStart * app.signal.numSamples); // playFromT lets playback begin partway into the selection (the user
int endSample = (int)(app.sel.timeEnd * app.signal.numSamples); // scrubbed the playhead) without disturbing the selection itself — the
// whole point is to replay a subrange while keeping the region intact.
float t0 = app.sel.timeStart;
float t1 = app.sel.timeEnd;
if (app.playFromT > 0.0f && app.playFromT < 1.0f)
t0 = t0 + app.playFromT * (t1 - t0);
int startSample = (int)(t0 * app.signal.numSamples);
int endSample = (int)(t1 * app.signal.numSamples);
int numSamples = endSample - startSample; int numSamples = endSample - startSample;
if (numSamples <= 0 || startSample < 0 || endSample > app.signal.numSamples) return NULL; if (numSamples <= 0 || startSample < 0 || endSample > app.signal.numSamples) return NULL;
@@ -246,10 +254,20 @@ void PlaySelectedRegion(void)
// Snapshot what we're about to play so the playhead tracks THIS region even // Snapshot what we're about to play so the playhead tracks THIS region even
// if the user moves the selection mid-playback. Duration comes from the // if the user moves the selection mid-playback. Duration comes from the
// buffer we actually built, not from app.signal.duration. // buffer we actually built, not from app.signal.duration.
// Snapshot the span actually handed to the device, which is the selection
// narrowed by any scrub offset — the playhead is measured against this.
app.playSelStart = app.sel.timeStart; app.playSelStart = app.sel.timeStart;
app.playSelEnd = app.sel.timeEnd; app.playSelEnd = app.sel.timeEnd;
if (app.playFromT > 0.0f && app.playFromT < 1.0f)
app.playSelStart += app.playFromT * (app.sel.timeEnd - app.sel.timeStart);
app.playDuration = (app.signal.sampleRate > 0) app.playDuration = (app.signal.sampleRate > 0)
? (float)numSamples / (float)app.signal.sampleRate : 0.0f; ? (float)numSamples / (float)app.signal.sampleRate : 0.0f;
// Anchor the playhead to the wall clock (see playStartTime). Set here, at
// the moment the buffer is handed to the device, so the marker can't drift
// from the audio no matter how long a frame takes.
app.playStartTime = GetTime();
app.playheadSeekT = 0.0f;
app.playheadT = 0.0f;
EnsureAudioDevice(); // opened on demand; released again once playback ends EnsureAudioDevice(); // opened on demand; released again once playback ends
if (AudioPlaybackSound.frameCount != 0) UnloadSound(AudioPlaybackSound); if (AudioPlaybackSound.frameCount != 0) UnloadSound(AudioPlaybackSound);
+47
View File
@@ -1,6 +1,7 @@
#pragma once #pragma once
#include <stddef.h> #include <stddef.h>
#include <stdbool.h>
/* ── Public API ─────────────────────────────────────────────────────────── */ /* ── Public API ─────────────────────────────────────────────────────────── */
@@ -108,3 +109,49 @@ const char *Platform_GetTempDir(void);
* @param path Path the file was just written to. * @param path Path the file was just written to.
*/ */
void Platform_OfferFileToUser(const char *path); void Platform_OfferFileToUser(const char *path);
/**
* Whether this platform needs an explicit "upload" affordance to get a file in.
*
* True only on the web, where the app's filesystem is an in-memory sandbox the
* user cannot see: a file browser there lists nothing useful, so the UI offers
* a native file picker instead.
*/
bool Platform_NeedsFileUpload(void);
/**
* Open the host's file picker and copy the chosen file into a place this
* process can read.
*
* Asynchronous by nature on the web (the picker resolves in a browser event),
* so this returns immediately; poll Platform_TakeUploadedFile() for the result.
* No-op on desktop, where the built-in file browser already works.
*/
void Platform_RequestFileUpload(void);
/**
* Collect a file delivered by Platform_RequestFileUpload, if one is ready.
*
* @param outPath Buffer receiving the path within the app's filesystem.
* @param cap Size of `outPath`.
* @return true exactly once per uploaded file; false when nothing is pending.
*/
bool Platform_TakeUploadedFile(char *outPath, int cap);
/**
* Show a blocking-work notice outside the app's own render surface.
*
* The web build computes its STFT in a single pass with no frame presented in
* between, so anything drawn on the canvas during that window never reaches the
* screen and the page simply appears to hang. This puts a message in the host
* document instead, which the browser paints independently, and yields long
* enough for that paint to happen before the caller starts working.
*
* No-op on desktop, which presents a progress panel normally.
*
* @param message Text to display, or NULL to dismiss.
*/
void Platform_ShowBlockingNotice(const char *message);
/** Dismiss whatever Platform_ShowBlockingNotice put up. */
void Platform_HideBlockingNotice(void);
+18
View File
@@ -105,3 +105,21 @@ void Platform_OfferFileToUser(const char *path) {
/* Desktop: the file is already on disk where the user wanted it. */ /* Desktop: the file is already on disk where the user wanted it. */
(void)path; (void)path;
} }
bool Platform_NeedsFileUpload(void) { return false; }
void Platform_RequestFileUpload(void) {
/* Desktop has a real filesystem and a working file browser; nothing to do. */
}
bool Platform_TakeUploadedFile(char *outPath, int cap) {
(void)outPath; (void)cap;
return false;
}
void Platform_ShowBlockingNotice(const char *message) {
/* Desktop draws its own progress panel; nothing to do here. */
(void)message;
}
void Platform_HideBlockingNotice(void) { }
+136
View File
@@ -93,3 +93,139 @@ void Platform_OfferFileToUser(const char *path) {
} }
}, path); }, path);
} }
/*
* File upload.
*
* The web build's filesystem is an in-memory sandbox, so the app's own file
* browser can only ever list files this process already wrote — useless for
* opening a capture off the user's disk. Instead we drive a real
* <input type="file"> and copy the chosen file into MEMFS, where the ordinary
* LoadWavFile path can read it like any other.
*
* The picker resolves in a browser event long after the C call returns, so the
* result is parked in these globals and collected by polling from the main
* loop. g_uploadReady is written from JS (see EM_ASM below) and read from C.
*/
static char g_uploadPath[512];
static volatile int g_uploadReady = 0;
/* Called from JS once the file's bytes are in MEMFS. */
EMSCRIPTEN_KEEPALIVE
void rspektrum_upload_done(const char *path) {
if (!path) return;
strncpy(g_uploadPath, path, sizeof(g_uploadPath) - 1);
g_uploadPath[sizeof(g_uploadPath) - 1] = '\0';
g_uploadReady = 1;
}
bool Platform_NeedsFileUpload(void) { return true; }
void Platform_RequestFileUpload(void) {
EM_ASM({
// Reuse one hidden input across calls: creating a fresh element per
// click leaks nodes, and some browsers ignore a picker opened from an
// element that isn't in the document.
var input = document.getElementById('rspektrum-upload');
if (!input) {
input = document.createElement('input');
input.type = 'file';
input.id = 'rspektrum-upload';
input.accept = '.wav,.wave,audio/*';
input.style.display = 'none';
document.body.appendChild(input);
}
input.onchange = function(ev) {
var file = ev.target.files && ev.target.files[0];
if (!file) return;
var reader = new FileReader();
reader.onload = function() {
try {
var bytes = new Uint8Array(reader.result);
// Keep the original name so the UI can show something
// meaningful; sanitise it into a flat MEMFS path.
var safe = file.name.replace(/[^A-Za-z0-9._-]/g, '_');
var path = '/uploads/' + safe;
try { FS.mkdir('/uploads'); } catch (e) {}
try { FS.unlink(path); } catch (e) {}
FS.writeFile(path, bytes);
ccall('rspektrum_upload_done', null, ['string'], [path]);
} catch (e) {
console.error('rspektrum: upload failed: ' + e);
}
};
reader.readAsArrayBuffer(file);
// Allow re-picking the same file next time.
ev.target.value = '';
};
input.click();
});
}
bool Platform_TakeUploadedFile(char *outPath, int cap) {
if (!g_uploadReady || !outPath || cap <= 0) return false;
g_uploadReady = 0;
strncpy(outPath, g_uploadPath, (size_t)cap - 1);
outPath[cap - 1] = '\0';
return true;
}
/*
* Blocking-work notice.
*
* Drawn into the host document rather than onto the raylib canvas: the STFT
* runs to completion inside one main-loop iteration, so nothing presented on
* the canvas during that window is ever painted, and the page looks frozen.
* The DOM is painted by the browser on its own schedule, so an overlay put up
* here survives the wasm thread being busy.
*
* EM_ASM only *queues* the mutation — the browser cannot paint until control
* returns to its event loop, so we yield briefly afterwards (ASYNCIFY is
* already enabled for the blocking main loop). Without that yield the overlay
* would appear only after the work it was meant to announce had finished.
*/
void Platform_ShowBlockingNotice(const char *message) {
if (!message) { Platform_HideBlockingNotice(); return; }
EM_ASM({
var msg = UTF8ToString($0);
var el = document.getElementById('rspektrum-blocking');
if (!el) {
el = document.createElement('div');
el.id = 'rspektrum-blocking';
el.style.cssText =
'position:fixed;inset:0;z-index:9999;display:flex;' +
'align-items:center;justify-content:center;' +
'background:rgba(12,12,16,0.88);color:#dfe4ee;' +
'font:14px system-ui,sans-serif;text-align:center;' +
'pointer-events:all;';
var box = document.createElement('div');
box.id = 'rspektrum-blocking-box';
box.style.cssText =
'padding:22px 28px;border:1px solid #6a6a80;border-radius:6px;' +
'background:#1b1b22;max-width:32em;line-height:1.5;';
el.appendChild(box);
document.body.appendChild(el);
}
document.getElementById('rspektrum-blocking-box').innerHTML = msg;
el.style.display = 'flex';
}, message);
/*
* Yield so the browser can actually paint the overlay before the caller
* blocks. EM_ASM only queues the DOM mutation; nothing is drawn until
* control returns to the event loop.
*
* Under ASYNCIFY this unwinds the C stack and resumes later, so the main
* loop CAN re-enter while this is suspended. The load block guards against
* that with stftBusy — without it, the second pass re-runs ComputeSTFTInit
* and frees the STFT the suspended call is still building.
*/
emscripten_sleep(32);
}
void Platform_HideBlockingNotice(void) {
EM_ASM({
var el = document.getElementById('rspektrum-blocking');
if (el) el.style.display = 'none';
});
}
+18
View File
@@ -132,3 +132,21 @@ void Platform_OfferFileToUser(const char *path) {
/* Desktop: the file is already on disk where the user wanted it. */ /* Desktop: the file is already on disk where the user wanted it. */
(void)path; (void)path;
} }
bool Platform_NeedsFileUpload(void) { return false; }
void Platform_RequestFileUpload(void) {
/* Desktop has a real filesystem and a working file browser; nothing to do. */
}
bool Platform_TakeUploadedFile(char *outPath, int cap) {
(void)outPath; (void)cap;
return false;
}
void Platform_ShowBlockingNotice(const char *message) {
/* Desktop draws its own progress panel; nothing to do here. */
(void)message;
}
void Platform_HideBlockingNotice(void) { }
+82 -2
View File
@@ -4,9 +4,59 @@
#include <stdio.h> #include <stdio.h>
#include <stdlib.h> #include <stdlib.h>
// Bucket width for the envelope summary. 1024 samples keeps the table ~0.8%
// of the signal's own footprint (1.9 MB for a 5.7-hour capture) while still
// giving a pixel column several buckets to reduce over at typical zooms.
#define WAVE_BUCKET_SIZE 1024
void FreeWaveEnvelope(WaveEnvelope* env)
{
free(env->buckets);
env->buckets = NULL;
env->bucketCount = 0;
env->samples = NULL;
env->numSamples = 0;
}
void BuildWaveEnvelope(WaveEnvelope* env, const float* samples, int numSamples)
{
// Already summarises this exact buffer — nothing to do. Comparing the
// pointer AND the length catches both a new file and a re-decode that
// happened to land on the same address.
if (env->buckets && env->samples == samples && env->numSamples == numSamples)
return;
FreeWaveEnvelope(env);
if (!samples || numSamples <= 0) return;
int bucketSize = WAVE_BUCKET_SIZE;
int bucketCount = (numSamples + bucketSize - 1) / bucketSize;
env->buckets = (WaveMinMax*)malloc((size_t)bucketCount * sizeof(WaveMinMax));
if (!env->buckets) return; // fall back to the raw-sample path
for (int b = 0; b < bucketCount; b++) {
int a = b * bucketSize;
int e = a + bucketSize;
if (e > numSamples) e = numSamples;
float mn = samples[a], mx = samples[a];
for (int k = a + 1; k < e; k++) {
float v = samples[k];
if (v < mn) mn = v;
if (v > mx) mx = v;
}
env->buckets[b].mn = mn;
env->buckets[b].mx = mx;
}
env->bucketCount = bucketCount;
env->bucketSize = bucketSize;
env->samples = samples;
env->numSamples = numSamples;
}
void InitScopeView(ScopeView* view, WaveformData data, int x, int y, int width, int height) void InitScopeView(ScopeView* view, WaveformData data, int x, int y, int width, int height)
{ {
view->data = data; view->data = data;
view->envelope = (WaveEnvelope){ 0 };
view->x = x; view->x = x;
view->y = y; view->y = y;
view->width = width; view->width = width;
@@ -107,6 +157,15 @@ void DrawScopeView(ScopeView* view, float cursorT)
int spp = (visibleSamples + view->width - 1) / view->width; int spp = (visibleSamples + view->width - 1) / view->width;
if (spp < 1) spp = 1; if (spp < 1) spp = 1;
// Keep the summary current; no-op unless the sample buffer changed.
BuildWaveEnvelope(&view->envelope, view->data.samples, totalSamples);
// Reduce over whole buckets only when a column covers at least one, so the
// summary is never used to answer a question finer than it can. Zoomed in
// past a bucket the raw path runs, and is cheap there by definition.
const WaveEnvelope* env = &view->envelope;
bool useEnvelope = env->buckets != NULL && spp >= env->bucketSize;
// Draw envelope: per-pixel min/max (Audacity-style) // Draw envelope: per-pixel min/max (Audacity-style)
Color waveColor = (Color){ 200, 220, 255, 255 }; Color waveColor = (Color){ 200, 220, 255, 255 };
for (int px = 0; px < view->width; px++) { for (int px = 0; px < view->width; px++) {
@@ -115,13 +174,34 @@ void DrawScopeView(ScopeView* view, float cursorT)
if (s0 >= endSample) s0 = endSample - 1; if (s0 >= endSample) s0 = endSample - 1;
if (s1 > endSample) s1 = endSample; if (s1 > endSample) s1 = endSample;
float minAmp = view->data.samples[s0]; float minAmp, maxAmp;
float maxAmp = view->data.samples[s0];
if (useEnvelope) {
// Bucket range covering [s0, s1). Rounding inward would leave the
// column's edges unsampled, so the span is widened to whole buckets
// — at this zoom a bucket is at most one pixel wide anyway.
int b0 = s0 / env->bucketSize;
int b1 = (s1 + env->bucketSize - 1) / env->bucketSize;
if (b0 < 0) b0 = 0;
if (b1 > env->bucketCount) b1 = env->bucketCount;
if (b1 <= b0) b1 = b0 + 1;
if (b0 >= env->bucketCount) b0 = env->bucketCount - 1;
minAmp = env->buckets[b0].mn;
maxAmp = env->buckets[b0].mx;
for (int b = b0 + 1; b < b1; b++) {
if (env->buckets[b].mn < minAmp) minAmp = env->buckets[b].mn;
if (env->buckets[b].mx > maxAmp) maxAmp = env->buckets[b].mx;
}
} else {
minAmp = view->data.samples[s0];
maxAmp = view->data.samples[s0];
for (int s = s0 + 1; s < s1; s++) { for (int s = s0 + 1; s < s1; s++) {
float v = view->data.samples[s]; float v = view->data.samples[s];
if (v < minAmp) minAmp = v; if (v < minAmp) minAmp = v;
if (v > maxAmp) maxAmp = v; if (v > maxAmp) maxAmp = v;
} }
}
int yTop = AmplitudeToY(view, maxAmp); int yTop = AmplitudeToY(view, maxAmp);
int yBot = AmplitudeToY(view, minAmp); int yBot = AmplitudeToY(view, minAmp);
+32
View File
@@ -10,9 +10,41 @@ typedef struct {
int sampleRate; int sampleRate;
} WaveformData; } WaveformData;
// Per-bucket amplitude extremes, the unit of the envelope summary below.
typedef struct { float mn, mx; } WaveMinMax;
// Precomputed min/max summary of the signal so drawing the waveform costs
// O(pixels) instead of O(visible samples).
//
// Without it the scope rescanned every visible sample every frame: on a
// multi-hour capture that is hundreds of millions of reads (~1 GB of memory
// traffic) to produce a few hundred pixel columns, measured at ~60 ms/frame —
// a 16 fps ceiling before anything else drew. Bucketing collapses that to a
// few reads per column. Keeping the true min AND max per bucket is what lets
// a one-sample transient still show at full zoom-out; plain decimation would
// drop it, which matters when the whole point is spotting brief bursts.
//
// Used only when a pixel column spans at least one whole bucket. Zoomed in
// past that the scope reads raw samples, which is cheap precisely because few
// are visible. `samples`/`numSamples` record what the summary was built from,
// so a new file (or re-decoded buffer) invalidates it automatically.
typedef struct {
WaveMinMax* buckets;
int bucketCount;
int bucketSize; // samples per bucket
const float* samples; // provenance: buffer this was built from
int numSamples;
} WaveEnvelope;
// Build (or rebuild, if the source buffer changed) the envelope summary.
// Safe to call every frame: returns immediately when already current.
void BuildWaveEnvelope(WaveEnvelope* env, const float* samples, int numSamples);
void FreeWaveEnvelope(WaveEnvelope* env);
// Scope view state for time/amplitude waveform display // Scope view state for time/amplitude waveform display
typedef struct { typedef struct {
WaveformData data; WaveformData data;
WaveEnvelope envelope; // cached min/max summary; rebuilt when data changes
// View bounds (in pixels) // View bounds (in pixels)
int x, y; int x, y;
+905 -100
View File
File diff suppressed because it is too large Load Diff
+30
View File
@@ -28,6 +28,8 @@ const char* ColormapName(ColormapType type);
// reassignment to colors (use for dB-floor / colormap changes). // reassignment to colors (use for dB-floor / colormap changes).
void GenerateSpectrogramTexture(StftResult* stft, Image* image, Texture2D* texture); void GenerateSpectrogramTexture(StftResult* stft, Image* image, Texture2D* texture);
void ColorizeSpectrogram(Image* image, Texture2D* texture); void ColorizeSpectrogram(Image* image, Texture2D* texture);
// Colormap lookup (0-1 -> colour); used by the sidebar's colormap swatch.
Color GetColormapColor(float t, ColormapType type);
// --- Headless (no-GL) spectrogram + annotation rendering --- // --- Headless (no-GL) spectrogram + annotation rendering ---
// BuildSpectrogramImageCPU fills `image` with the colorized spectrogram with no // BuildSpectrogramImageCPU fills `image` with the colorized spectrogram with no
@@ -43,12 +45,40 @@ void DrawAnnotationsToImage(Image* img, Font font);
void DrawSpectrogramGrid(Rectangle bounds, int numCellsX, int numCellsY, Color color); void DrawSpectrogramGrid(Rectangle bounds, int numCellsX, int numCellsY, Color color);
void DrawLabels(Rectangle bounds); void DrawLabels(Rectangle bounds);
void DrawSelection(Rectangle bounds); void DrawSelection(Rectangle bounds);
// --- Selection transport bar ---
// Play controls attached to the selection box. Geometry and input are split
// from drawing so a click can be claimed during the input phase, before the
// pan/select handlers run.
Rectangle SelectionTransportRect(Rectangle bounds);
bool TransportCapturesMouse(Rectangle bounds);
void UpdateSelectionTransport(Rectangle bounds);
void DrawSelectionTransport(Rectangle bounds);
void DrawSelectionDrag(Rectangle bounds); void DrawSelectionDrag(Rectangle bounds);
void DrawCursorReadout(Rectangle bounds); void DrawCursorReadout(Rectangle bounds);
void DrawMarkers(Rectangle bounds); void DrawMarkers(Rectangle bounds);
void DrawSpectrumPanel(Rectangle bounds); void DrawSpectrumPanel(Rectangle bounds);
void DrawPlayhead(Rectangle bounds); void DrawPlayhead(Rectangle bounds);
// Playhead X within bounds, or <0 when there's nothing to draw. Used by the
// scrub hit-test so the marker and its grab region agree.
float PlayheadScreenX(Rectangle bounds);
void DrawAnnotations(Rectangle bounds); void DrawAnnotations(Rectangle bounds);
// --- Minimap ---
// Whole-file thumbnail in the top-right corner with a viewport rectangle.
// Returns the rect it occupies (zero-sized when hidden) so the caller can
// route clicks to it. The texture is cached; InvalidateMinimap() forces a
// rebuild on the next draw.
Rectangle DrawMinimap(void);
// Minimap rect without drawing, plus a hit test — needed during input handling,
// which runs long before the draw pass.
Rectangle MinimapBounds(void);
bool MinimapCapturesMouse(void);
Rectangle MinimapHandleRect(void); // corner size-toggle square
void InvalidateMinimap(void);
// Recompute which annotations overlap in time+frequency. Call after the
// annotation set changes; result is cached in app.collisionFlags/Regions.
void ComputeCollisions(void);
// Annotation timeline lane. Updates app.hoveredTimelineEvent and // Annotation timeline lane. Updates app.hoveredTimelineEvent and
// app.selectedAnnotation in response to mouse interaction in `lane`. // app.selectedAnnotation in response to mouse interaction in `lane`.
void DrawTimeline(Rectangle lane); void DrawTimeline(Rectangle lane);
+815 -206
View File
File diff suppressed because it is too large Load Diff
+194 -6
View File
@@ -30,6 +30,71 @@
#define MAX_SAMPLE_RATE 48000 #define MAX_SAMPLE_RATE 48000
#define LOUDNESS_FLOOR_DB -80.0f #define LOUDNESS_FLOOR_DB -80.0f
// Hard ceiling on the spectrogram image's width in pixels. GL implementations
// commonly cap textures at 16384 px per dimension, and a multi-hour capture
// produces far more STFT segments than that (~478k for 5.7 h at 12 kHz), so
// without a cap the texture upload fails and nothing draws at all. Segments
// beyond the cap are folded into columns; see ComputeSpectrogramReassignment.
// Kept below the common limit to leave headroom on weaker GL drivers.
#define MAX_SPECTRO_IMAGE_WIDTH 8192
// Contiguous time span containing one or more colliding annotations. Adjacent
// collisions are merged into a single region so the overlay draws one band per
// pile-up rather than one per event, which would smear into a solid wall when
// zoomed out on a long capture.
typedef struct {
double t0, t1;
double f_lo, f_hi; // union of the colliding events' bands, in Hz
int count; // events involved in this region
} CollisionRegion;
// Vertical padding added to a collision band when drawing it, so a narrow
// overlap is still visible without covering the whole frequency axis.
#define COLLISION_BAND_PAD_HZ 100.0
// Cap on merged collision regions tracked per file. Beyond this the overlay
// still reports the total collision count, it just stops adding bands.
#define MAX_COLLISION_REGIONS 4096
// How long the auto-crop toast stays up, in seconds of *focused* time.
#define AUTOCROP_NOTICE_SECONDS 5.0f
// Sidebar sizing. It is an icon rail: the default width is exactly one icon
// plus its margins, so it costs the spectrogram as little width as possible.
// Settings that need more room open as popouts rather than widening the rail.
// Drag the right edge to resize, or drag past the minimum / double-click to
// collapse. RAIL_ICON is the button size the width is derived from.
#define RAIL_ICON_SIZE 34.0f
#define RAIL_ICON_MARGIN 6.0f
#define SIDEBAR_WIDTH_DEFAULT (RAIL_ICON_SIZE + RAIL_ICON_MARGIN * 2) // 46
#define SIDEBAR_WIDTH_MIN (RAIL_ICON_SIZE + RAIL_ICON_MARGIN * 2)
// Menubar across the top of the window.
#define MENUBAR_HEIGHT 22.0f
// Minimap thumbnail: a heavily downscaled view of the whole capture, rendered
// once into a texture and reused every frame. Small on purpose — it exists to
// show *where* you are, not to be readable, so a coarse blob is fine and keeps
// the one-time build cheap.
// Two sizes, large exactly double small, each with its own cached texture.
// The corner handle switches between them rather than free-resizing, so there
// are only ever two thumbnails to keep valid.
#define MINIMAP_TEX_W 256
#define MINIMAP_TEX_H 64
#define MINIMAP_TEX_W_LG 512
#define MINIMAP_TEX_H_LG 128
#define MINIMAP_DRAW_W 240.0f
#define MINIMAP_DRAW_H 60.0f
#define MINIMAP_MARGIN 10.0f
#define MINIMAP_HANDLE 12.0f // corner grab square, bottom-left
// Neighbourhood the cursor's dB readout averages over, in STFT cells (not
// screen pixels — see DrawCursorReadout). Wider in frequency than in time:
// OFDM subcarriers are the noisy axis, whereas widening time would average
// across symbol boundaries. Sized to stay well inside one mLink channel.
#define CURSOR_AVG_SEGS 3 // +/- segments (~300 ms at 12 kHz / 2048)
#define CURSOR_AVG_BINS 6 // +/- bins (~76 Hz)
// How many overlapping annotation boxes the cursor-hit stack retains. Deeper // How many overlapping annotation boxes the cursor-hit stack retains. Deeper
// piles than this are counted but not listed individually (the tooltip says // piles than this are counted but not listed individually (the tooltip says
// "+N more"), which keeps a dense pile-up from covering the spectrogram. // "+N more"), which keeps a dense pile-up from covering the spectrogram.
@@ -144,7 +209,28 @@ typedef struct {
// Playback state // Playback state
float playheadT; // 0-1 normalized position within the PLAYING region float playheadT; // 0-1 normalized position within the PLAYING region
float playheadElapsed; // Elapsed seconds since play started // Wall-clock instant (GetTime()) at which the current buffer started
// playing. The playhead is derived from this rather than accumulated per
// frame: summing GetFrameTime() drifts, because every frame spent on
// rendering work is counted as playback time, and the error compounds over
// a long region. Audio runs on its own clock, so the playhead has to as
// well. Offset by playheadSeekT when the user scrubs.
double playStartTime;
float playheadSeekT; // 0-1 offset into the buffer that playback began at
// Where the next play should start within the selection (0-1). Set by
// dragging the playhead while stopped; reset once the selection changes.
float playFromT;
bool playheadDragging;
// Repeat the selection when it reaches the end, instead of stopping.
bool loopPlayback;
// Mouse cursor requested for this frame, applied once at the end of it.
// Several handlers have an opinion about the cursor and they run in
// sequence, each calling SetMouseCursor unconditionally — so whichever ran
// last won, and two of them disagreeing produced a visible flicker between
// shapes. Handlers now record a request and the highest-priority one wins.
int cursorRequest;
int cursorPriority;
// Snapshot of the region actually handed to the audio device, captured at // Snapshot of the region actually handed to the audio device, captured at
// PlaySelectedRegion time. The playhead must be measured against this, not // PlaySelectedRegion time. The playhead must be measured against this, not
@@ -198,6 +284,15 @@ typedef struct {
float* reassignBuffer; float* reassignBuffer;
int reassignWidth; int reassignWidth;
int reassignHeight; int reassignHeight;
// STFT segments folded into each image column (1 = one column per segment).
// >1 once the visible span has more segments than MAX_SPECTRO_IMAGE_WIDTH,
// and needed by anything converting between segment indices and image X.
int reassignSegsPerCol;
// Segment range the cached image covers, as [first, last). The image is
// built for the visible span rather than the whole file, so zooming in
// genuinely re-renders at higher resolution instead of magnifying pixels.
// A rebuild is triggered when the view leaves this range.
int reassignSegFirst, reassignSegLast;
// Overlays // Overlays
bool showAbout; // About / help dialog bool showAbout; // About / help dialog
@@ -271,6 +366,36 @@ typedef struct {
// user dismisses with "OK" (keep crop) or "Uncrop" (restore full view). // user dismisses with "OK" (keep crop) or "Uncrop" (restore full view).
bool autocropNoticeActive; bool autocropNoticeActive;
char autocropNoticeMsg[256]; char autocropNoticeMsg[256];
float autocropNoticeTimer; // seconds left; counts down only while focused
// Default LMB gesture on the spectrogram. Off (the default) = LMB pans and
// Ctrl+LMB drags a selection box; on = the two swap, so LMB selects. The
// modifier always means "the other one", so either mode can do both without
// going back to the rail.
bool selectMode;
// Minimap: a cached thumbnail of the whole capture in the top-right corner,
// with the current view drawn as a rectangle on it. Click or drag to
// navigate. The texture is rendered once and only rebuilt when something
// that changes its content does (new file, colormap, annotation/collision
// overlays toggled) — never per frame.
bool showMinimap;
bool minimapLarge; // corner handle toggles small <-> large
// One cached texture per size. Both are built at the same source
// resolution, so switching size never triggers a rebuild — only a content
// change (new file, colormap, overlay toggles) does.
Texture2D minimapTexture[2];
bool minimapValid[2];
bool minimapDragging;
// Icon rail width (fixed — every button is the same size) and its collapse
// state. Collapsing hands the whole window width to the spectrogram.
float sidebarWidth;
bool sidebarCollapsed;
// Menubar: index of the open menu (-1 = none). Click to open, move across
// to switch, click elsewhere or pick an item to close.
int openMenu;
// Optional mLnL annotations parsed from the loaded WAV (empty if the file // Optional mLnL annotations parsed from the loaded WAV (empty if the file
// doesn't carry the chunk). The annotations overlay has two surfaces: // doesn't carry the chunk). The annotations overlay has two surfaces:
@@ -287,11 +412,24 @@ typedef struct {
// air at once), and a single hit index silently hid everything underneath — // air at once), and a single hit index silently hid everything underneath —
// so the stack is collected during the draw pass and the tooltip reports // so the stack is collected during the draw pass and the tooltip reports
// all of it. Topmost-last, matching draw order; hoveredEvent is the last // all of it. Topmost-last, matching draw order; hoveredEvent is the last
// entry. Pinning freezes the stack so it can be read without the cursor // entry.
// having to stay perfectly still.
int hoverStack[MAX_HOVER_STACK]; int hoverStack[MAX_HOVER_STACK];
int hoverStackCount; int hoverStackCount;
bool hoverStackPinned; // click-to-pin: survives cursor movement
// Collision analysis: which events share time AND frequency with another,
// i.e. genuinely overlap on the air rather than merely looking stacked at
// the current zoom. Computed once per annotation set (see ComputeCollisions)
// because it depends only on the event data, not on the view.
unsigned char* collisionFlags; // one byte per event, 1 = collides
int collisionCount; // events involved in any collision
CollisionRegion collisionRegions[MAX_COLLISION_REGIONS];
int collisionRegionCount; // merged contiguous spans of collisions
bool showCollisions; // overlay toggle
int currentCollision; // region index of the last jump (-1 = none)
// Sidebar prev/next request, consumed by the main loop: -1 back, +1 forward,
// 0 idle. The jump helpers are static to spectrogram.c, so the button can't
// call them directly.
int jumpCollisionRequest;
bool showAnnotations; // master on/off bool showAnnotations; // master on/off
bool annotationsExpanded; // sidebar dropdown open (per-kind checkboxes etc.) bool annotationsExpanded; // sidebar dropdown open (per-kind checkboxes etc.)
bool annotationKindEnabled[MLNL_KIND_MAX]; // per-kind visibility (filters both surfaces) bool annotationKindEnabled[MLNL_KIND_MAX]; // per-kind visibility (filters both surfaces)
@@ -301,7 +439,8 @@ typedef struct {
// Timeline lane state. The lane is rendered between the freq-range banner // Timeline lane state. The lane is rendered between the freq-range banner
// and the spectrogram pixels. Collapsed = single-row sparkline; expanded = // and the spectrogram pixels. Collapsed = single-row sparkline; expanded =
// one row per kind currently enabled in the file. // one row per kind currently enabled in the file.
bool timelineExpanded; bool showTimeline; // lane visible at all (off => spectrogram gets the space)
bool timelineExpanded; // one row per kind instead of a single sparkline
int hoveredTimelineEvent; // -1 = none; event index hovered in the lane int hoveredTimelineEvent; // -1 = none; event index hovered in the lane
int selectedAnnotation; // -1 = none; persistent selection from a lane click int selectedAnnotation; // -1 = none; persistent selection from a lane click
} SpectrogramApp; } SpectrogramApp;
@@ -333,14 +472,35 @@ void ApplyAutoCrop(void);
// is gated off while this is the case. Add new overlays here in one place. // is gated off while this is the case. Add new overlays here in one place.
static inline bool UiModalOpen(void) static inline bool UiModalOpen(void)
{ {
return app.showFileBrowser || app.showAbout || app.autocropNoticeActive; // The auto-crop notice is a toast, not a modal — it must not swallow keys
// or block interaction with the spectrogram underneath it.
return app.showFileBrowser || app.showAbout;
} }
// Request a mouse cursor for this frame. Higher priority wins; ties go to the
// first caller. Applied once per frame (see the end of the main loop), so
// handlers can state their preference without fighting each other.
static inline void RequestCursor(int shape, int priority)
{
if (priority > app.cursorPriority) {
app.cursorPriority = priority;
app.cursorRequest = shape;
}
}
// Cursor priorities: an active drag outranks a hover hint, which outranks the
// default. Keeps "what am I doing" ahead of "what could I do".
#define CURSOR_PRI_DEFAULT 0
#define CURSOR_PRI_HOVER 10
#define CURSOR_PRI_ACTIVE 20
// Reset the box selection to the full signal (the "no selection" state). // Reset the box selection to the full signal (the "no selection" state).
static inline void ClearSelection(void) static inline void ClearSelection(void)
{ {
app.sel.timeStart = 0.0f; app.sel.timeEnd = 1.0f; app.sel.timeStart = 0.0f; app.sel.timeEnd = 1.0f;
app.sel.freqStart = 0.0f; app.sel.freqEnd = 1.0f; app.sel.freqStart = 0.0f; app.sel.freqEnd = 1.0f;
// A scrub offset is meaningless against a selection that no longer exists.
app.playFromT = 0.0f;
} }
// Effective top of the displayed frequency axis (Hz). Capped at the actual // Effective top of the displayed frequency axis (Hz). Capped at the actual
@@ -418,6 +578,34 @@ typedef struct {
// Returns the keymap table and its entry count (defined in spectrogram.c). // Returns the keymap table and its entry count (defined in spectrogram.c).
const KeyBinding* GetKeymap(int* count); const KeyBinding* GetKeymap(int* count);
// ============================================================================
// Menubar — built on top of the keymap so a menu item and its shortcut can
// never drift apart. Most items name a keymap entry by its raylib key code and
// reuse that entry's action, gate, and shortcut label. Items that toggle a
// simple flag with no binding point at the flag instead; a separator is an
// entry with neither.
// ============================================================================
typedef struct {
const char* label; // NULL = separator
int key; // keymap key to invoke, or 0
bool* toggle; // flag to flip when there's no keymap entry, or NULL
} MenuItem;
typedef struct {
const char* title;
const MenuItem* items;
int itemCount;
} Menu;
// Returns the menubar definition and its menu count (defined in ui.c).
const Menu* GetMenus(int* count);
// Run a keymap entry's action by key code, honoring its gate. Used by the
// menubar so clicking an item goes through exactly the same path as the key.
void InvokeKeymapAction(int key);
bool KeymapActionEnabled(int key); // false => draw the item greyed out
const char* KeymapLabelFor(int key); // shortcut text, "" if unbound
// ============================================================================ // ============================================================================
// Small math helpers (header-inline so every module can use them) // Small math helpers (header-inline so every module can use them)
// ============================================================================ // ============================================================================
+47 -5
View File
@@ -29,6 +29,7 @@ static void CopySTFT(StftResult* dst, const StftResult* src)
dst->totalSamples = src->totalSamples; dst->totalSamples = src->totalSamples;
dst->useHannWindow = src->useHannWindow; dst->useHannWindow = src->useHannWindow;
dst->segments = (StftSegment*)malloc(src->numSegments * sizeof(StftSegment)); dst->segments = (StftSegment*)malloc(src->numSegments * sizeof(StftSegment));
if (dst->segments == NULL) { dst->numSegments = 0; return; }
for (int i = 0; i < src->numSegments; i++) { for (int i = 0; i < src->numSegments; i++) {
const StftSegment* s = &src->segments[i]; const StftSegment* s = &src->segments[i];
StftSegment* d = &dst->segments[i]; StftSegment* d = &dst->segments[i];
@@ -156,6 +157,14 @@ static SegScratch AllocSegScratch(int fftSize)
return sc; return sc;
} }
// True when every scratch buffer was allocated. These are only a few KB, so
// this realistically only fails when the heap is already exhausted — but the
// caller must not run a pass with a NULL buffer either way.
static bool SegScratchOk(const SegScratch* sc)
{
return sc->windowed && sc->derivWindowed && sc->fftIn && sc->fftOut;
}
static void FreeSegScratch(SegScratch* sc) static void FreeSegScratch(SegScratch* sc)
{ {
free(sc->windowed); free(sc->windowed);
@@ -166,7 +175,7 @@ static void FreeSegScratch(SegScratch* sc)
// Compute one STFT segment (normal V_f + derivative-window V_fd spectra) into // Compute one STFT segment (normal V_f + derivative-window V_fd spectra) into
// result->segments[seg]. Caller ensures the segment isn't already computed. // result->segments[seg]. Caller ensures the segment isn't already computed.
static void ComputeSegment(AudioSignal* signal, StftResult* result, int fftSize, int seg, SegScratch* sc) static bool ComputeSegment(AudioSignal* signal, StftResult* result, int fftSize, int seg, SegScratch* sc)
{ {
int hopSize = fftSize / HOP_RATIO; int hopSize = fftSize / HOP_RATIO;
int numBins = fftSize / 2 + 1; int numBins = fftSize / 2 + 1;
@@ -195,7 +204,12 @@ static void ComputeSegment(AudioSignal* signal, StftResult* result, int fftSize,
// Normal STFT (V_f) // Normal STFT (V_f)
for (int i = 0; i < fftSize; i++) sc->fftIn[i] = sc->windowed[i] + 0.0f * I; for (int i = 0; i < fftSize; i++) sc->fftIn[i] = sc->windowed[i] + 0.0f * I;
FFT(sc->fftIn, sc->fftOut, fftSize, false); FFT(sc->fftIn, sc->fftOut, fftSize, false);
// Out of memory: leave the segment NULL. Every consumer already skips
// NULL segments (that is how the progressive fill renders partial results),
// so a truncated spectrogram degrades gracefully — whereas writing through
// the NULL scribbles over low memory and shows up later as corrupted glyphs.
result->segments[seg].spectrum = (FrequencyData*)malloc(numBins * sizeof(FrequencyData)); result->segments[seg].spectrum = (FrequencyData*)malloc(numBins * sizeof(FrequencyData));
if (result->segments[seg].spectrum == NULL) return false;
for (int bin = 0; bin < numBins; bin++) { for (int bin = 0; bin < numBins; bin++) {
result->segments[seg].spectrum[bin].frequency = (float)bin * signal->sampleRate / fftSize; result->segments[seg].spectrum[bin].frequency = (float)bin * signal->sampleRate / fftSize;
result->segments[seg].spectrum[bin].amplitude = (bin == 0) ? cabsf(sc->fftOut[bin]) / fftSize : 2.0f * cabsf(sc->fftOut[bin]) / fftSize; result->segments[seg].spectrum[bin].amplitude = (bin == 0) ? cabsf(sc->fftOut[bin]) / fftSize : 2.0f * cabsf(sc->fftOut[bin]) / fftSize;
@@ -206,11 +220,19 @@ static void ComputeSegment(AudioSignal* signal, StftResult* result, int fftSize,
for (int i = 0; i < fftSize; i++) sc->fftIn[i] = sc->derivWindowed[i] + 0.0f * I; for (int i = 0; i < fftSize; i++) sc->fftIn[i] = sc->derivWindowed[i] + 0.0f * I;
FFT(sc->fftIn, sc->fftOut, fftSize, false); FFT(sc->fftIn, sc->fftOut, fftSize, false);
result->segments[seg].derivativeSpectrum = (FrequencyData*)malloc(numBins * sizeof(FrequencyData)); result->segments[seg].derivativeSpectrum = (FrequencyData*)malloc(numBins * sizeof(FrequencyData));
if (result->segments[seg].derivativeSpectrum == NULL) {
// Reassignment reads both buffers in lockstep, so a segment with only
// half of them is worse than none.
free(result->segments[seg].spectrum);
result->segments[seg].spectrum = NULL;
return false;
}
for (int bin = 0; bin < numBins; bin++) { for (int bin = 0; bin < numBins; bin++) {
result->segments[seg].derivativeSpectrum[bin].frequency = (float)bin * signal->sampleRate / fftSize; result->segments[seg].derivativeSpectrum[bin].frequency = (float)bin * signal->sampleRate / fftSize;
result->segments[seg].derivativeSpectrum[bin].amplitude = cabsf(sc->fftOut[bin]) / fftSize; result->segments[seg].derivativeSpectrum[bin].amplitude = cabsf(sc->fftOut[bin]) / fftSize;
result->segments[seg].derivativeSpectrum[bin].phase = cargf(sc->fftOut[bin]); result->segments[seg].derivativeSpectrum[bin].phase = cargf(sc->fftOut[bin]);
} }
return true;
} }
// ===== Background high-res computation ===== // ===== Background high-res computation =====
@@ -220,9 +242,10 @@ int ComputeNextHighResChunk(AudioSignal* signal, StftResult* result,
int fftSize, int startSeg, int endSeg) int fftSize, int startSeg, int endSeg)
{ {
SegScratch sc = AllocSegScratch(fftSize); SegScratch sc = AllocSegScratch(fftSize);
if (!SegScratchOk(&sc)) { FreeSegScratch(&sc); return endSeg; }
for (int seg = startSeg; seg < endSeg && seg < result->numSegments; seg++) { for (int seg = startSeg; seg < endSeg && seg < result->numSegments; seg++) {
if (result->segments[seg].spectrum != NULL) continue; // already computed if (result->segments[seg].spectrum != NULL) continue; // already computed
ComputeSegment(signal, result, fftSize, seg, &sc); if (!ComputeSegment(signal, result, fftSize, seg, &sc)) break; // out of memory
} }
FreeSegScratch(&sc); FreeSegScratch(&sc);
@@ -238,8 +261,17 @@ void ComputeSTFTInit(AudioSignal* signal, StftResult* result, int fftSize)
int numSegments = (signal->numSamples - fftSize) / hopSize + 1; int numSegments = (signal->numSamples - fftSize) / hopSize + 1;
if (numSegments <= 0) numSegments = 1; if (numSegments <= 0) numSegments = 1;
result->numSegments = numSegments;
result->segments = (StftSegment*)calloc(numSegments, sizeof(StftSegment)); result->segments = (StftSegment*)calloc(numSegments, sizeof(StftSegment));
if (result->segments == NULL) {
// wasm has a hard address-space ceiling, so this genuinely fails on a
// long capture where desktop would just swap. Writing through the NULL
// corrupts low memory and surfaces later as garbled glyphs rather than
// a crash, so report it and leave the result empty.
TraceLog(LOG_ERROR, "STFT: out of memory for %d segments", numSegments);
result->numSegments = 0;
return;
}
result->numSegments = numSegments;
result->sampleRate = signal->sampleRate; result->sampleRate = signal->sampleRate;
result->totalSamples = signal->numSamples; result->totalSamples = signal->numSamples;
result->useHannWindow = true; result->useHannWindow = true;
@@ -248,13 +280,23 @@ void ComputeSTFTInit(AudioSignal* signal, StftResult* result, int fftSize)
bool ComputeSTFTIncremental(AudioSignal* signal, StftResult* result, int fftSize, int startSegment) bool ComputeSTFTIncremental(AudioSignal* signal, StftResult* result, int fftSize, int startSegment)
{ {
SegScratch sc = AllocSegScratch(fftSize); SegScratch sc = AllocSegScratch(fftSize);
if (!SegScratchOk(&sc)) { FreeSegScratch(&sc); return false; }
bool ok = true;
for (int seg = startSegment; seg < result->numSegments; seg++) { for (int seg = startSegment; seg < result->numSegments; seg++) {
if (seg % app.skipFactor != 0) continue; // overview stride if (seg % app.skipFactor != 0) continue; // overview stride
if (result->segments[seg].spectrum != NULL) continue; // already computed if (result->segments[seg].spectrum != NULL) continue; // already computed
ComputeSegment(signal, result, fftSize, seg, &sc); if (!ComputeSegment(signal, result, fftSize, seg, &sc)) {
// Heap exhausted. Stop rather than grinding through every remaining
// segment failing the same way, and tell the caller so it can say
// something useful instead of showing an empty spectrogram.
TraceLog(LOG_ERROR, "STFT: out of memory at segment %d of %d",
seg, result->numSegments);
ok = false;
break;
}
} }
FreeSegScratch(&sc); FreeSegScratch(&sc);
return true; return ok;
} }
void FreeSTFT(StftResult* result) void FreeSTFT(StftResult* result)
+876 -342
View File
File diff suppressed because it is too large Load Diff
+10
View File
@@ -9,8 +9,18 @@ void ScanDirectory(const char* path);
void FreeBrowserFiles(void); void FreeBrowserFiles(void);
void DrawFileBrowser(void); void DrawFileBrowser(void);
// --- Menubar ---
void DrawMenubar(void);
// True when the cursor is over the menubar or an open dropdown, so the
// spectrogram doesn't also act on a click meant for a menu.
bool MenubarCapturesMouse(void);
// --- Sidebar --- // --- Sidebar ---
void DrawSidebar(void); void DrawSidebar(void);
// True when the cursor is over the icon rail or one of its popout panels.
bool SidebarCapturesMouse(void);
// Rail popouts, drawn late so they float above the spectrogram and scope.
void DrawSidebarPopouts(void);
// --- PNG export --- // --- PNG export ---
void ExportPNG(const SpectrogramApp* spa, const char* dirPath); void ExportPNG(const SpectrogramApp* spa, const char* dirPath);