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
|
- **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,14 +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) |
|
||||||
| **Mouse wheel** | Zoom time/frequency |
|
| **Tab** | Swap pan / select mode |
|
||||||
| **Alt+drag** / **middle-drag** | Pan the view |
|
| **Middle-drag** / **Alt+drag** | Pan, regardless of mode |
|
||||||
| **LMB drag** | Select a time + frequency region |
|
| **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 |
|
| **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) |
|
||||||
@@ -145,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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -201,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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -229,6 +304,40 @@ 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
|
||||||
|
(`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,
|
- **Playback / WAV export** share one processing path: the selected time span,
|
||||||
FFT-bandpassed to the selected frequency box, peak-normalised.
|
FFT-bandpassed to the selected frequency box, peak-normalised.
|
||||||
- **mLnL parsing** — walks the WAV's RIFF chunks for the four-CC `mLnL` chunk
|
- **mLnL parsing** — walks the WAV's RIFF chunks for the four-CC `mLnL` chunk
|
||||||
@@ -243,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)
|
||||||
@@ -253,3 +363,6 @@ src/
|
|||||||
See [`raylib_for_desktop_applications.md`](raylib_for_desktop_applications.md)
|
See [`raylib_for_desktop_applications.md`](raylib_for_desktop_applications.md)
|
||||||
for the performance / idle-CPU lessons behind the desktop build, and
|
for the performance / idle-CPU lessons behind the desktop build, and
|
||||||
[`AGENTS.md`](AGENTS.md) for the headless-testing playbook.
|
[`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.
|
# 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"
|
||||||
|
|||||||
+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 mouseup 1; sleep 0.1
|
||||||
xd keyup alt; sleep 0.15
|
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 ;;
|
*) xd "$@"; sleep 0.15 ;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|||||||
+37
-5
@@ -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;
|
||||||
|
|
||||||
@@ -204,10 +212,16 @@ static float* BuildSelectionAudio(int* outNumSamples)
|
|||||||
if (!regionSamples) return NULL;
|
if (!regionSamples) return NULL;
|
||||||
memcpy(regionSamples, app.signal.samples + startSample, numSamples * sizeof(float));
|
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 freqLow = app.sel.freqStart * maxFreq;
|
||||||
float freqHigh = app.sel.freqEnd * 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);
|
TraceLog(LOG_INFO, "Applying bandpass filter: %.0f - %.0f Hz", freqLow, freqHigh);
|
||||||
ApplyBandpassFilter(regionSamples, numSamples, app.signal.sampleRate, freqLow, freqHigh);
|
ApplyBandpassFilter(regionSamples, numSamples, app.signal.sampleRate, freqLow, freqHigh);
|
||||||
}
|
}
|
||||||
@@ -237,6 +251,24 @@ void PlaySelectedRegion(void)
|
|||||||
float* regionSamples = BuildSelectionAudio(&numSamples);
|
float* regionSamples = BuildSelectionAudio(&numSamples);
|
||||||
if (!regionSamples) return;
|
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
|
EnsureAudioDevice(); // opened on demand; released again once playback ends
|
||||||
if (AudioPlaybackSound.frameCount != 0) UnloadSound(AudioPlaybackSound);
|
if (AudioPlaybackSound.frameCount != 0) UnloadSound(AudioPlaybackSound);
|
||||||
|
|
||||||
@@ -257,7 +289,7 @@ void ExportSelectionWAV(const char* dirPath)
|
|||||||
return;
|
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 t0 = app.sel.timeStart * app.signal.duration;
|
||||||
float t1 = app.sel.timeEnd * app.signal.duration;
|
float t1 = app.sel.timeEnd * app.signal.duration;
|
||||||
float f0 = app.sel.freqStart * maxFreq;
|
float f0 = app.sel.freqStart * maxFreq;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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) { }
|
||||||
|
|||||||
@@ -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';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -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) { }
|
||||||
|
|||||||
+100
-10
@@ -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;
|
||||||
@@ -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);
|
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)
|
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)
|
void DrawScopeView(ScopeView* view, float cursorT)
|
||||||
@@ -56,9 +113,11 @@ void DrawScopeView(ScopeView* view, float cursorT)
|
|||||||
if (view->showGrid) {
|
if (view->showGrid) {
|
||||||
Color gridColor = (Color){ view->gridR, view->gridG, view->gridB, (int)(view->gridAlpha * 255) };
|
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++) {
|
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);
|
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;
|
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++) {
|
||||||
@@ -106,12 +174,33 @@ 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];
|
|
||||||
for (int s = s0 + 1; s < s1; s++) {
|
if (useEnvelope) {
|
||||||
float v = view->data.samples[s];
|
// Bucket range covering [s0, s1). Rounding inward would leave the
|
||||||
if (v < minAmp) minAmp = v;
|
// column's edges unsampled, so the span is widened to whole buckets
|
||||||
if (v > maxAmp) maxAmp = v;
|
// — 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);
|
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);
|
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) {
|
if (cursorT >= 0.0f && cursorT <= 1.0f) {
|
||||||
int cursorX = TimeToX(view, cursorT);
|
int cursorX = TimeToX(view, cursorT);
|
||||||
if (cursorX >= view->x && cursorX <= view->x + view->width) {
|
if (cursorX >= view->x && cursorX <= view->x + view->width) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
+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).
|
// 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);
|
||||||
|
|||||||
+849
-199
File diff suppressed because it is too large
Load Diff
+242
-4
@@ -30,6 +30,76 @@
|
|||||||
#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
|
||||||
|
// 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)
|
// Base resolution for proportional UI scaling (see GetUIScale in render.c)
|
||||||
#define BASE_WIDTH 1280
|
#define BASE_WIDTH 1280
|
||||||
#define BASE_HEIGHT 800
|
#define BASE_HEIGHT 800
|
||||||
@@ -138,8 +208,39 @@ typedef struct {
|
|||||||
bool stftComputed;
|
bool stftComputed;
|
||||||
|
|
||||||
// Playback state
|
// Playback state
|
||||||
float playheadT; // 0-1 normalized position in selection
|
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
|
||||||
|
// 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.
|
// Time + frequency box selection and its drag/move interaction state.
|
||||||
Selection sel;
|
Selection sel;
|
||||||
@@ -183,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
|
||||||
@@ -256,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:
|
||||||
@@ -266,6 +406,30 @@ typedef struct {
|
|||||||
// the overlay drowning the underlying signal.
|
// the overlay drowning the underlying signal.
|
||||||
MlnlAnnotations annotations;
|
MlnlAnnotations annotations;
|
||||||
int hoveredEvent; // spectrogram-cursor hit (-1 = none); used for tooltip
|
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 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)
|
||||||
@@ -275,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;
|
||||||
@@ -307,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
|
||||||
@@ -342,6 +528,30 @@ static inline float DisplayFreqFraction(void)
|
|||||||
return EffectiveMaxFreqHz() / nyq;
|
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.
|
// Keymap — single source of truth for global key bindings.
|
||||||
// The dispatcher (DispatchKeymap in spectrogram.c) runs every entry whose
|
// 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).
|
// 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
@@ -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)
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user