Compare commits
14
Commits
d35cc66ae6
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
438150080c | ||
|
|
bc8bcf78b7 | ||
|
|
40623feadc | ||
|
|
56b1666850 | ||
|
|
cce96659b1 | ||
|
|
82294844dd | ||
|
|
4c3c6a955a | ||
|
|
8026d10547 | ||
|
|
8017954aa1 | ||
|
|
98e42b0a6a | ||
|
|
5c3c88dc22 | ||
|
|
0f9ad03fc5 | ||
|
|
c4687ce80d | ||
|
|
46e796cdeb |
@@ -44,10 +44,15 @@ format.
|
||||
- **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,
|
||||
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.
|
||||
- **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.
|
||||
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.
|
||||
- **Marker / ruler** and a **spectrum slice (PSD)** readout.
|
||||
- **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
|
||||
|
||||
```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)
|
||||
@@ -126,14 +150,25 @@ or pressing **O** for the file browser. Try the bundled sample:
|
||||
|
||||
### 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 |
|
||||
|-------|--------|
|
||||
| **O** | Open file browser |
|
||||
| **Mouse wheel** | Zoom time/frequency |
|
||||
| **Alt+drag** / **middle-drag** | Pan the view |
|
||||
| **LMB drag** | Select a time + frequency region |
|
||||
| **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) |
|
||||
| **Shift+wheel** | Zoom the time axis only |
|
||||
| **Ctrl+wheel** | Zoom the frequency axis only |
|
||||
| **Wheel on a scrollbar** | Pan that axis (**Shift** to zoom it) |
|
||||
| **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 |
|
||||
| **M** | Marker / ruler tool |
|
||||
| **S** | Spectrum slice (PSD) |
|
||||
@@ -145,8 +180,63 @@ or pressing **O** for the file browser. Try the bundled sample:
|
||||
| **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, …).
|
||||
### Layout
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
@@ -201,25 +291,10 @@ Annotation kinds: `tx_frame`, `tx_burst`, `control`, `channel_up`,
|
||||
|
||||
## Driving the GUI headlessly (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 full 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). Synthesize input with `xdotool` against `DISPLAY=:99` to exercise UI
|
||||
paths.
|
||||
The app runs, screenshots, and takes synthetic input on a virtual X display with
|
||||
no monitor or GPU (Mesa software GL under Xvfb). The playbook is in
|
||||
[`AGENTS.md`](AGENTS.md); [`shot_input.sh`](shot_input.sh) is the working
|
||||
reference implementation.
|
||||
|
||||
---
|
||||
|
||||
@@ -229,6 +304,40 @@ paths.
|
||||
frequency resolution `sampleRate / fftSize` Hz per bin. Amplitude in dB.
|
||||
- **Axes** — X = time (s), Y = frequency (Hz, scaled to the file's Nyquist),
|
||||
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
|
||||
(`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
|
||||
just as far as a 30-second one. At 48 kHz / 2048-point FFT the floor is ~85 ms
|
||||
across the viewport; a smaller FFT zooms correspondingly tighter. Past that
|
||||
point there are no further STFT segments to show, so the view would only
|
||||
interpolate.
|
||||
- **Playback / WAV export** share one 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
|
||||
@@ -243,8 +352,9 @@ paths.
|
||||
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
|
||||
render.c # spectrogram, annotations, tooltips, minimap, scope
|
||||
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
|
||||
mlnl.c / mlnl.h # mLnL annotation chunk parser
|
||||
platform_*.c # per-OS shims (linux / win32 / web)
|
||||
@@ -253,3 +363,6 @@ src/
|
||||
See [`raylib_for_desktop_applications.md`](raylib_for_desktop_applications.md)
|
||||
for the performance / idle-CPU lessons behind the desktop build, and
|
||||
[`AGENTS.md`](AGENTS.md) for the headless-testing playbook.
|
||||
|
||||
Known rough edges — behaviour that is unspecified or awkward rather than simply
|
||||
broken — are tracked in [`known_bugs.md`](known_bugs.md).
|
||||
|
||||
+16
-1
@@ -90,7 +90,22 @@ done
|
||||
# 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
|
||||
# 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
|
||||
LDFLAGS="$LDFLAGS -g -O0 -s ASSERTIONS=1"
|
||||
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
# Known bugs & rough edges
|
||||
|
||||
Behaviour that is unspecified, awkward, or known-imperfect — as distinct from
|
||||
outright breakage. Each entry says what happens, why, and what a real fix would
|
||||
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
|
||||
|
||||
**Status:** partially addressed; underlying semantics still undefined.
|
||||
|
||||
Playback hands a *snapshot* of the selected region to the audio device — the
|
||||
samples are copied, bandpassed, and normalised up front, so the sound coming out
|
||||
of the speakers is fixed the moment **Space** is pressed. The selection box,
|
||||
however, stays live and editable while that audio plays.
|
||||
|
||||
Previously the playhead marker was drawn against the *live* `app.sel`, so moving
|
||||
or resizing the selection during playback made the marker jump, run off the end,
|
||||
or scale to a region that had nothing to do with what was audible. The playhead
|
||||
is now measured against `playSelStart` / `playSelEnd` / `playDuration`, captured
|
||||
at `PlaySelectedRegion()` time, so it tracks the audio that is actually playing.
|
||||
|
||||
What remains undefined is the *product* question, not the drawing math:
|
||||
|
||||
- If the user drags the selection somewhere else mid-playback, should the audio
|
||||
follow (restart / re-seek against the new region), or should playback keep
|
||||
going with the old buffer and the marker stay where it is (current behaviour)?
|
||||
- Should editing the selection during playback simply stop playback?
|
||||
- Should the playhead remain visible when the region it refers to is scrolled
|
||||
off-screen, or has been replaced by a selection elsewhere in the file?
|
||||
|
||||
Current behaviour is the conservative reading: **the sound wins**. The marker
|
||||
always describes real audio, and a mid-playback edit is treated as staging the
|
||||
*next* thing to play rather than modifying the current one. That is defensible
|
||||
but was never explicitly chosen, and the UI gives no feedback that the box on
|
||||
screen and the audio in flight have diverged.
|
||||
|
||||
Related: a sub-threshold click *inside* an existing selection deliberately does
|
||||
not clear it (`hoverInsideSelection` in `spectrogram.c`), because silently
|
||||
clearing changes what **Space** would play. A click on empty space still resets
|
||||
to full range.
|
||||
|
||||
**Touches:** `audio.c` (`PlaySelectedRegion`), `spectrogram.c` (playhead
|
||||
advance, scope cursor), `render.c` (`DrawPlayhead`), `spectrogram_types.h`
|
||||
(`playSelStart` / `playSelEnd` / `playDuration`).
|
||||
|
||||
---
|
||||
|
||||
## Long-file zoom sharpness lags the zoom gesture
|
||||
|
||||
**Status:** working as designed, but reads as a bug.
|
||||
|
||||
`ComputeSkipFactor()` (`stft.c`) strides the initial STFT pass for long files —
|
||||
every 8th segment past 10 minutes — so the overview loads promptly. The missing
|
||||
segments are filled at full resolution afterwards: the visible range first, then
|
||||
a background sweep of the whole file.
|
||||
|
||||
The practical effect is that a hard zoom into a 30-minute file can look blocky
|
||||
for a moment before the foreground fill catches up and it sharpens. The fill is
|
||||
gated on `view.end - view.start <= 0.25f`, so it only runs once reasonably zoomed
|
||||
in. If a view stays blocky indefinitely, the fill is not reaching that range and
|
||||
that *is* a real bug worth chasing.
|
||||
|
||||
---
|
||||
|
||||
## Load time on long files is unbounded and unreported
|
||||
|
||||
A 30-minute 48 kHz file spends a long time in `Processing…` before the UI is
|
||||
usable, and the percentage indicator advances non-linearly (the strided overview
|
||||
completes fast, the high-res fill does not). There is no cancel. Headless/scripted
|
||||
runs must wait this out; see `AGENTS.md`.
|
||||
Executable
+27
@@ -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()
|
||||
@@ -71,6 +71,23 @@ for act in "$@"; do
|
||||
xd mouseup 1; sleep 0.1
|
||||
xd keyup alt; sleep 0.15
|
||||
;;
|
||||
wheel)
|
||||
# "wheel X Y N [mod]" — N wheel clicks at (X,Y); N<0 scrolls down.
|
||||
# X11 maps wheel up/down to buttons 4/5. Each click needs its own
|
||||
# frame, same edge-detect reason as the click helper above.
|
||||
xd mousemove "$2" "$3"; sleep 0.1
|
||||
_n="$4"; _btn=4
|
||||
if [ "$_n" -lt 0 ]; then _btn=5; _n=$(( -_n )); fi
|
||||
_mod="${5:-}"
|
||||
[ -n "$_mod" ] && { xd keydown "$_mod"; sleep 0.05; }
|
||||
_i=0
|
||||
while [ "$_i" -lt "$_n" ]; do
|
||||
xd click "$_btn"; sleep 0.05
|
||||
_i=$(( _i + 1 ))
|
||||
done
|
||||
[ -n "$_mod" ] && { xd keyup "$_mod"; sleep 0.05; }
|
||||
sleep 0.15
|
||||
;;
|
||||
*) xd "$@"; sleep 0.15 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
+37
-5
@@ -195,8 +195,16 @@ static float* BuildSelectionAudio(int* outNumSamples)
|
||||
{
|
||||
if (!app.loaded || !app.stftComputed) return NULL;
|
||||
|
||||
int startSample = (int)(app.sel.timeStart * app.signal.numSamples);
|
||||
int endSample = (int)(app.sel.timeEnd * app.signal.numSamples);
|
||||
// playFromT lets playback begin partway into the selection (the user
|
||||
// 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;
|
||||
if (numSamples <= 0 || startSample < 0 || endSample > app.signal.numSamples) return NULL;
|
||||
|
||||
@@ -204,10 +212,16 @@ static float* BuildSelectionAudio(int* outNumSamples)
|
||||
if (!regionSamples) return NULL;
|
||||
memcpy(regionSamples, app.signal.samples + startSample, numSamples * sizeof(float));
|
||||
|
||||
float maxFreq = (float)app.signal.sampleRate / 2.0f;
|
||||
// sel.freq* are fractions of the *displayed* axis (capped at
|
||||
// EffectiveMaxFreqHz), not of true Nyquist — same convention the PNG export
|
||||
// in ui.c uses. Convert through EffectiveMaxFreqHz so a display crop doesn't
|
||||
// scale the passband up by 1/DisplayFreqFraction(). The filter itself still
|
||||
// works in true-Nyquist terms, which is what nyquist is for.
|
||||
float nyquist = (float)app.signal.sampleRate / 2.0f;
|
||||
float maxFreq = EffectiveMaxFreqHz();
|
||||
float freqLow = app.sel.freqStart * maxFreq;
|
||||
float freqHigh = app.sel.freqEnd * maxFreq;
|
||||
if (freqLow > 10.0f || freqHigh < maxFreq - 10.0f) {
|
||||
if (freqLow > 10.0f || freqHigh < nyquist - 10.0f) {
|
||||
TraceLog(LOG_INFO, "Applying bandpass filter: %.0f - %.0f Hz", freqLow, freqHigh);
|
||||
ApplyBandpassFilter(regionSamples, numSamples, app.signal.sampleRate, freqLow, freqHigh);
|
||||
}
|
||||
@@ -237,6 +251,24 @@ void PlaySelectedRegion(void)
|
||||
float* regionSamples = BuildSelectionAudio(&numSamples);
|
||||
if (!regionSamples) return;
|
||||
|
||||
// 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
|
||||
// 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.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)
|
||||
? (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
|
||||
if (AudioPlaybackSound.frameCount != 0) UnloadSound(AudioPlaybackSound);
|
||||
|
||||
@@ -257,7 +289,7 @@ void ExportSelectionWAV(const char* dirPath)
|
||||
return;
|
||||
}
|
||||
|
||||
float maxFreq = (float)app.signal.sampleRate / 2.0f;
|
||||
float maxFreq = EffectiveMaxFreqHz(); // sel.freq* are display-axis fractions
|
||||
float t0 = app.sel.timeStart * app.signal.duration;
|
||||
float t1 = app.sel.timeEnd * app.signal.duration;
|
||||
float f0 = app.sel.freqStart * maxFreq;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdbool.h>
|
||||
|
||||
/* ── Public API ─────────────────────────────────────────────────────────── */
|
||||
|
||||
@@ -108,3 +109,49 @@ const char *Platform_GetTempDir(void);
|
||||
* @param path Path the file was just written to.
|
||||
*/
|
||||
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);
|
||||
|
||||
@@ -105,3 +105,21 @@ void Platform_OfferFileToUser(const char *path) {
|
||||
/* Desktop: the file is already on disk where the user wanted it. */
|
||||
(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) { }
|
||||
|
||||
@@ -93,3 +93,139 @@ void Platform_OfferFileToUser(const char *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';
|
||||
});
|
||||
}
|
||||
|
||||
@@ -132,3 +132,21 @@ void Platform_OfferFileToUser(const char *path) {
|
||||
/* Desktop: the file is already on disk where the user wanted it. */
|
||||
(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) { }
|
||||
|
||||
+100
-10
@@ -4,9 +4,59 @@
|
||||
#include <stdio.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)
|
||||
{
|
||||
view->data = data;
|
||||
view->envelope = (WaveEnvelope){ 0 };
|
||||
view->x = x;
|
||||
view->y = y;
|
||||
view->width = width;
|
||||
@@ -33,9 +83,16 @@ static int AmplitudeToY(ScopeView* view, float amp)
|
||||
return view->y + view->height - (int)((amp - view->ampMin) / (view->ampMax - view->ampMin) * view->height);
|
||||
}
|
||||
|
||||
// Map a signal-space time (0-1 over the WHOLE signal) to a screen X, honoring
|
||||
// the visible window. The waveform envelope below already draws only
|
||||
// viewStart..viewEnd, so grid lines and the cursor have to use the same mapping
|
||||
// or they drift out of register with the trace (and with the spectrogram above)
|
||||
// as soon as the user zooms or pans.
|
||||
static int TimeToX(ScopeView* view, float t)
|
||||
{
|
||||
return view->x + (int)(t * view->width);
|
||||
float span = view->viewEnd - view->viewStart;
|
||||
if (span <= 0.0f) span = 1.0f;
|
||||
return view->x + (int)((t - view->viewStart) / span * view->width);
|
||||
}
|
||||
|
||||
void DrawScopeView(ScopeView* view, float cursorT)
|
||||
@@ -56,9 +113,11 @@ void DrawScopeView(ScopeView* view, float cursorT)
|
||||
if (view->showGrid) {
|
||||
Color gridColor = (Color){ view->gridR, view->gridG, view->gridB, (int)(view->gridAlpha * 255) };
|
||||
|
||||
// Vertical time divisions
|
||||
// Vertical time divisions — ten evenly spaced lines across the VISIBLE
|
||||
// window, so the grid stays put under zoom instead of sliding off.
|
||||
for (int i = 0; i <= 10; i++) {
|
||||
int x = TimeToX(view, (float)i / 10.0f);
|
||||
float t = view->viewStart + (float)i / 10.0f * (view->viewEnd - view->viewStart);
|
||||
int x = TimeToX(view, t);
|
||||
DrawLineV((Vector2){ x, view->y }, (Vector2){ x, view->y + view->height }, gridColor);
|
||||
}
|
||||
|
||||
@@ -98,6 +157,15 @@ void DrawScopeView(ScopeView* view, float cursorT)
|
||||
int spp = (visibleSamples + view->width - 1) / view->width;
|
||||
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)
|
||||
Color waveColor = (Color){ 200, 220, 255, 255 };
|
||||
for (int px = 0; px < view->width; px++) {
|
||||
@@ -106,12 +174,33 @@ void DrawScopeView(ScopeView* view, float cursorT)
|
||||
if (s0 >= endSample) s0 = endSample - 1;
|
||||
if (s1 > endSample) s1 = endSample;
|
||||
|
||||
float minAmp = view->data.samples[s0];
|
||||
float maxAmp = view->data.samples[s0];
|
||||
for (int s = s0 + 1; s < s1; s++) {
|
||||
float v = view->data.samples[s];
|
||||
if (v < minAmp) minAmp = v;
|
||||
if (v > maxAmp) maxAmp = v;
|
||||
float minAmp, maxAmp;
|
||||
|
||||
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++) {
|
||||
float v = view->data.samples[s];
|
||||
if (v < minAmp) minAmp = v;
|
||||
if (v > maxAmp) maxAmp = v;
|
||||
}
|
||||
}
|
||||
|
||||
int yTop = AmplitudeToY(view, maxAmp);
|
||||
@@ -119,7 +208,8 @@ void DrawScopeView(ScopeView* view, float cursorT)
|
||||
DrawLine(px + view->x, yTop, px + view->x, yBot, waveColor);
|
||||
}
|
||||
|
||||
// Cursor
|
||||
// Cursor. cursorT is signal-space; TimeToX maps it into the visible window,
|
||||
// and the bounds check below drops it when it falls outside the current view.
|
||||
if (cursorT >= 0.0f && cursorT <= 1.0f) {
|
||||
int cursorX = TimeToX(view, cursorT);
|
||||
if (cursorX >= view->x && cursorX <= view->x + view->width) {
|
||||
|
||||
@@ -10,9 +10,41 @@ typedef struct {
|
||||
int sampleRate;
|
||||
} 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
|
||||
typedef struct {
|
||||
WaveformData data;
|
||||
WaveEnvelope envelope; // cached min/max summary; rebuilt when data changes
|
||||
|
||||
// View bounds (in pixels)
|
||||
int x, y;
|
||||
|
||||
+1034
-80
File diff suppressed because it is too large
Load Diff
@@ -28,6 +28,8 @@ const char* ColormapName(ColormapType type);
|
||||
// reassignment to colors (use for dB-floor / colormap changes).
|
||||
void GenerateSpectrogramTexture(StftResult* stft, 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 ---
|
||||
// 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 DrawLabels(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 DrawCursorReadout(Rectangle bounds);
|
||||
void DrawMarkers(Rectangle bounds);
|
||||
void DrawSpectrumPanel(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);
|
||||
|
||||
// --- 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
|
||||
// app.selectedAnnotation in response to mouse interaction in `lane`.
|
||||
void DrawTimeline(Rectangle lane);
|
||||
|
||||
+852
-202
File diff suppressed because it is too large
Load Diff
+242
-4
@@ -30,6 +30,76 @@
|
||||
#define MAX_SAMPLE_RATE 48000
|
||||
#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
|
||||
// piles than this are counted but not listed individually (the tooltip says
|
||||
// "+N more"), which keeps a dense pile-up from covering the spectrogram.
|
||||
#define MAX_HOVER_STACK 12
|
||||
|
||||
// Base resolution for proportional UI scaling (see GetUIScale in render.c)
|
||||
#define BASE_WIDTH 1280
|
||||
#define BASE_HEIGHT 800
|
||||
@@ -138,8 +208,39 @@ typedef struct {
|
||||
bool stftComputed;
|
||||
|
||||
// Playback state
|
||||
float playheadT; // 0-1 normalized position in selection
|
||||
float playheadElapsed; // Elapsed seconds since play started
|
||||
float playheadT; // 0-1 normalized position within the PLAYING region
|
||||
// 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
|
||||
// PlaySelectedRegion time. The playhead must be measured against this, not
|
||||
// against the live app.sel — the user can move or resize the selection while
|
||||
// audio is still playing, and the marker has to keep tracking the sound
|
||||
// that's really coming out. playDuration comes from the buffer's own sample
|
||||
// count / sampleRate, so it can't drift from app.signal.duration (which is
|
||||
// derived pre-mono-downmix and disagrees for stereo files).
|
||||
float playSelStart, playSelEnd; // sel.timeStart/End when playback began
|
||||
float playDuration; // true length of the playing buffer, seconds
|
||||
|
||||
// Time + frequency box selection and its drag/move interaction state.
|
||||
Selection sel;
|
||||
@@ -183,6 +284,15 @@ typedef struct {
|
||||
float* reassignBuffer;
|
||||
int reassignWidth;
|
||||
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
|
||||
bool showAbout; // About / help dialog
|
||||
@@ -256,6 +366,36 @@ typedef struct {
|
||||
// user dismisses with "OK" (keep crop) or "Uncrop" (restore full view).
|
||||
bool autocropNoticeActive;
|
||||
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
|
||||
// doesn't carry the chunk). The annotations overlay has two surfaces:
|
||||
@@ -266,6 +406,30 @@ typedef struct {
|
||||
// the overlay drowning the underlying signal.
|
||||
MlnlAnnotations annotations;
|
||||
int hoveredEvent; // spectrogram-cursor hit (-1 = none); used for tooltip
|
||||
|
||||
// Every annotation box the cursor is currently inside, not just the topmost.
|
||||
// Overlapping transmissions are the normal case (multiple stations on the
|
||||
// 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
|
||||
// all of it. Topmost-last, matching draw order; hoveredEvent is the last
|
||||
// entry.
|
||||
int hoverStack[MAX_HOVER_STACK];
|
||||
int hoverStackCount;
|
||||
|
||||
// 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 annotationsExpanded; // sidebar dropdown open (per-kind checkboxes etc.)
|
||||
bool annotationKindEnabled[MLNL_KIND_MAX]; // per-kind visibility (filters both surfaces)
|
||||
@@ -275,7 +439,8 @@ typedef struct {
|
||||
// Timeline lane state. The lane is rendered between the freq-range banner
|
||||
// and the spectrogram pixels. Collapsed = single-row sparkline; expanded =
|
||||
// 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 selectedAnnotation; // -1 = none; persistent selection from a lane click
|
||||
} SpectrogramApp;
|
||||
@@ -307,14 +472,35 @@ void ApplyAutoCrop(void);
|
||||
// is gated off while this is the case. Add new overlays here in one place.
|
||||
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).
|
||||
static inline void ClearSelection(void)
|
||||
{
|
||||
app.sel.timeStart = 0.0f; app.sel.timeEnd = 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
|
||||
@@ -342,6 +528,30 @@ static inline float DisplayFreqFraction(void)
|
||||
return EffectiveMaxFreqHz() / nyq;
|
||||
}
|
||||
|
||||
// Tightest allowed time window, as a fraction of the whole file.
|
||||
//
|
||||
// This MUST be derived from the file's duration rather than being a flat
|
||||
// fraction: view.start/end are normalized to the file, so a constant floor
|
||||
// makes the achievable time resolution scale with file length (a flat 2%
|
||||
// capped a 30-minute recording at a 36-second window, while a 30-second one
|
||||
// reached 0.6 s). The physical limit is the STFT hop — segments sit
|
||||
// fftSize/HOP_RATIO samples apart, so once only a few segments span the
|
||||
// viewport there is no further detail to reveal and zooming past that just
|
||||
// interpolates. MIN_VISIBLE_SEGMENTS sets how many must stay in view.
|
||||
#define MIN_VISIBLE_SEGMENTS 8
|
||||
static inline float MinTimeViewWidth(void)
|
||||
{
|
||||
if (app.signal.sampleRate <= 0 || app.signal.duration <= 0.0f) return 0.02f;
|
||||
int hopSamples = app.fftSize / HOP_RATIO;
|
||||
if (hopSamples < 1) hopSamples = 1;
|
||||
float hopSec = (float)hopSamples / (float)app.signal.sampleRate;
|
||||
float minSpanSec = hopSec * (float)MIN_VISIBLE_SEGMENTS;
|
||||
float w = minSpanSec / app.signal.duration;
|
||||
if (w > 1.0f) w = 1.0f; // file shorter than the floor: whole file is the min
|
||||
if (w < 1e-7f) w = 1e-7f; // guard float precision in the view math
|
||||
return w;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Keymap — single source of truth for global key bindings.
|
||||
// The dispatcher (DispatchKeymap in spectrogram.c) runs every entry whose
|
||||
@@ -368,6 +578,34 @@ typedef struct {
|
||||
// Returns the keymap table and its entry count (defined in spectrogram.c).
|
||||
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)
|
||||
// ============================================================================
|
||||
|
||||
+47
-5
@@ -29,6 +29,7 @@ static void CopySTFT(StftResult* dst, const StftResult* src)
|
||||
dst->totalSamples = src->totalSamples;
|
||||
dst->useHannWindow = src->useHannWindow;
|
||||
dst->segments = (StftSegment*)malloc(src->numSegments * sizeof(StftSegment));
|
||||
if (dst->segments == NULL) { dst->numSegments = 0; return; }
|
||||
for (int i = 0; i < src->numSegments; i++) {
|
||||
const StftSegment* s = &src->segments[i];
|
||||
StftSegment* d = &dst->segments[i];
|
||||
@@ -156,6 +157,14 @@ static SegScratch AllocSegScratch(int fftSize)
|
||||
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)
|
||||
{
|
||||
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
|
||||
// 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 numBins = fftSize / 2 + 1;
|
||||
@@ -195,7 +204,12 @@ static void ComputeSegment(AudioSignal* signal, StftResult* result, int fftSize,
|
||||
// Normal STFT (V_f)
|
||||
for (int i = 0; i < fftSize; i++) sc->fftIn[i] = sc->windowed[i] + 0.0f * I;
|
||||
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));
|
||||
if (result->segments[seg].spectrum == NULL) return false;
|
||||
for (int bin = 0; bin < numBins; bin++) {
|
||||
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;
|
||||
@@ -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;
|
||||
FFT(sc->fftIn, sc->fftOut, fftSize, false);
|
||||
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++) {
|
||||
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].phase = cargf(sc->fftOut[bin]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ===== Background high-res computation =====
|
||||
@@ -220,9 +242,10 @@ int ComputeNextHighResChunk(AudioSignal* signal, StftResult* result,
|
||||
int fftSize, int startSeg, int endSeg)
|
||||
{
|
||||
SegScratch sc = AllocSegScratch(fftSize);
|
||||
if (!SegScratchOk(&sc)) { FreeSegScratch(&sc); return endSeg; }
|
||||
for (int seg = startSeg; seg < endSeg && seg < result->numSegments; seg++) {
|
||||
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);
|
||||
|
||||
@@ -238,8 +261,17 @@ void ComputeSTFTInit(AudioSignal* signal, StftResult* result, int fftSize)
|
||||
int numSegments = (signal->numSamples - fftSize) / hopSize + 1;
|
||||
if (numSegments <= 0) numSegments = 1;
|
||||
|
||||
result->numSegments = numSegments;
|
||||
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->totalSamples = signal->numSamples;
|
||||
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)
|
||||
{
|
||||
SegScratch sc = AllocSegScratch(fftSize);
|
||||
if (!SegScratchOk(&sc)) { FreeSegScratch(&sc); return false; }
|
||||
bool ok = true;
|
||||
for (int seg = startSegment; seg < result->numSegments; seg++) {
|
||||
if (seg % app.skipFactor != 0) continue; // overview stride
|
||||
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);
|
||||
return true;
|
||||
return ok;
|
||||
}
|
||||
|
||||
void FreeSTFT(StftResult* result)
|
||||
|
||||
@@ -9,8 +9,18 @@ void ScanDirectory(const char* path);
|
||||
void FreeBrowserFiles(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 ---
|
||||
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 ---
|
||||
void ExportPNG(const SpectrogramApp* spa, const char* dirPath);
|
||||
|
||||
Reference in New Issue
Block a user