feat: headless PNG render, mLnL annotations, and per-frame sched offset
Commit the accumulated working-tree changes as one snapshot. - Headless render: `--render OUT.png INPUT.wav` draws the spectrogram (full window, or `--pane` for the spectrogram pane only) to a PNG with no visible window. Options: `--annotations`/`--no-annotations`, `--annotation-opacity`, `--width`/`--height`. - mLnL annotations: parse the optional `mLnL` RIFF chunk (schema v2) and render tx_frame/assertion/control overlays, a timeline lane, and a waveform-scope echo, with hover tooltips on the spectrogram, timeline, and scope. - sched_offset_ms: parse the per-frame intent->air latency and surface it in the hover tooltips (boxes stay air-anchored upstream). - Supporting: build wiring (rspektrum.make), shared types/headers, web-build and capture-script tweaks, and removal of the old synchrosqueezing LaTeX doc. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -55,7 +55,7 @@ fi
|
|||||||
echo "=== Step 2: Compiling spectrogram modules for web ==="
|
echo "=== Step 2: Compiling spectrogram modules for web ==="
|
||||||
cd "$SCRIPT_DIR"
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
APP_MODULES="spectrogram fft stft audio render ui utils primitives platform_web"
|
APP_MODULES="spectrogram fft stft audio render ui utils primitives mlnl platform_web"
|
||||||
|
|
||||||
APP_OPT="-Os"
|
APP_OPT="-Os"
|
||||||
if [ "$BUILD_TYPE" = "debug" ]; then
|
if [ "$BUILD_TYPE" = "debug" ]; then
|
||||||
|
|||||||
@@ -0,0 +1,276 @@
|
|||||||
|
# mLnL: mLink-annotated WAV chunk specification (v2)
|
||||||
|
|
||||||
|
A way to ship spectrograph annotations *inside* a regular WAV file so
|
||||||
|
the file stays standards-compliant audio everywhere while a viewer that
|
||||||
|
knows the format can render labelled regions (TX bursts, assertion
|
||||||
|
outcomes, impairment fires, etc.) over the audio's spectrogram.
|
||||||
|
|
||||||
|
**Status**: v2, stable. Producer: `mlink_fake_ota` (see
|
||||||
|
`src/tools/harness/fota_capture.c` + `fota_events.c`). Consumer: any
|
||||||
|
tool that walks RIFF chunks and looks for the four-CC `mLnL`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. WAV / RIFF refresher
|
||||||
|
|
||||||
|
A standard WAV file is a RIFF container:
|
||||||
|
|
||||||
|
```
|
||||||
|
RIFF<file_size - 8 (uint32 LE)>WAVE
|
||||||
|
fmt <16 (uint32 LE)><PCM format fields>
|
||||||
|
data<N (uint32 LE)><PCM samples>
|
||||||
|
... more chunks may follow ...
|
||||||
|
```
|
||||||
|
|
||||||
|
Each chunk = 4-byte four-CC ID + 4-byte unsigned little-endian size +
|
||||||
|
payload of exactly that many bytes + 1 pad byte if the size is odd. Per
|
||||||
|
the RIFF specification, a reader MUST skip any chunk whose ID it does
|
||||||
|
not recognise.
|
||||||
|
|
||||||
|
## 2. The mLnL chunk
|
||||||
|
|
||||||
|
```
|
||||||
|
mLnL<payload_size (uint32 LE)><JSONL bytes>(+ 1 pad if size odd)
|
||||||
|
```
|
||||||
|
|
||||||
|
ID: ASCII `mLnL` (= bytes `0x6D 0x4C 0x6E 0x4C`).
|
||||||
|
|
||||||
|
Payload: UTF-8 JSON Lines. One JSON object per line, terminated by
|
||||||
|
`\n`. Lines are emitted in chronological order of `t_start`.
|
||||||
|
|
||||||
|
A truncation marker may appear as the **last** line:
|
||||||
|
|
||||||
|
```
|
||||||
|
{"truncated":true}
|
||||||
|
```
|
||||||
|
|
||||||
|
This means the producer's in-memory buffer overflowed and some events
|
||||||
|
were dropped.
|
||||||
|
|
||||||
|
## 3. Reading the chunk
|
||||||
|
|
||||||
|
```python
|
||||||
|
def read_mlnl(path):
|
||||||
|
with open(path, 'rb') as f:
|
||||||
|
data = f.read()
|
||||||
|
if data[:4] != b'RIFF' or data[8:12] != b'WAVE':
|
||||||
|
return None
|
||||||
|
i = 12
|
||||||
|
while i + 8 <= len(data):
|
||||||
|
cid = data[i:i+4]
|
||||||
|
sz = int.from_bytes(data[i+4:i+8], 'little')
|
||||||
|
if cid == b'mLnL':
|
||||||
|
return data[i+8 : i+8+sz].decode('utf-8').splitlines()
|
||||||
|
i += 8 + sz + (sz & 1)
|
||||||
|
return None
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Event schema (v2)
|
||||||
|
|
||||||
|
**Every** event is a single JSON object with these REQUIRED fields:
|
||||||
|
|
||||||
|
| field | type | meaning |
|
||||||
|
|----------|--------|---------|
|
||||||
|
| `t_start`| number | start of the event's time range, seconds since channel start |
|
||||||
|
| `t_end` | number | end of the event's time range, seconds; == t_start for instantaneous |
|
||||||
|
| `kind` | string | event kind (see §4.2) |
|
||||||
|
|
||||||
|
### 4.1 Optional fields available on any kind
|
||||||
|
|
||||||
|
| field | type | meaning |
|
||||||
|
|------------|--------|---------|
|
||||||
|
| `f_lo` | number | low edge of frequency annotation (Hz) |
|
||||||
|
| `f_hi` | number | high edge of frequency annotation (Hz) |
|
||||||
|
| `f_center` | number | center frequency (Hz); alternative to `f_lo`/`f_hi` |
|
||||||
|
| `f_bw` | number | bandwidth around `f_center` (Hz); paired with `f_center` |
|
||||||
|
| `note` | string | free-form human-readable label (the "why this is here" line) |
|
||||||
|
| `color` | string | suggested fill or outline colour, `#RRGGBB` |
|
||||||
|
|
||||||
|
A consumer should accept EITHER `f_lo`+`f_hi` OR `f_center`+`f_bw` (or
|
||||||
|
both -- prefer `f_lo`/`f_hi` if both are present). Events with no
|
||||||
|
frequency fields apply to the whole spectrum.
|
||||||
|
|
||||||
|
### 4.2 Kinds emitted by mlink_fake_ota today
|
||||||
|
|
||||||
|
**`tx_frame` is the primary annotation** — ONE per on-air air-frame,
|
||||||
|
sourced from the *transmitting daemon's own per-frame log event* (its
|
||||||
|
self-report, forwarded to the channel over the logging egress), NOT guessed
|
||||||
|
from the mixed audio. A whole transmission renders as its real labelled
|
||||||
|
sequence of boxes: e.g. PRESENCE + RTS in an announce channel, then N bulk
|
||||||
|
OFDM frames in the bulk band, each tagged with its LDPC rate.
|
||||||
|
|
||||||
|
```
|
||||||
|
tx_frame t_start = frame key-down, t_end = key-up, placed at the
|
||||||
|
ACTUAL on-air anchor + the frame's sample offset (NOT the
|
||||||
|
modem's render/intent time -- see note below)
|
||||||
|
fields:
|
||||||
|
node u32 transmitting node_id (attribution + color)
|
||||||
|
seq int 0-based index of this frame within the PTT
|
||||||
|
n int total frames in this PTT -> "seq of n"
|
||||||
|
frame str mLink frame name (vocabulary below)
|
||||||
|
ch str daemon channel: EMERGENCY / ANNOUNCE_0|1|2 /
|
||||||
|
BULK (the region the frame occupies)
|
||||||
|
rate str? LDPC rate of a bulk OFDM frame
|
||||||
|
("1/2","2/3","3/4","5/6"); absent on announce
|
||||||
|
f_lo num low edge of `ch`'s band (Hz; see §4.4)
|
||||||
|
f_hi num high edge of `ch`'s band (Hz)
|
||||||
|
sched_offset_ms num intent->air latency of this burst:
|
||||||
|
(on-air rising edge) - (modem render time).
|
||||||
|
A "modem wants vs actually does" metric;
|
||||||
|
coarse (block-quantized, multi-stage).
|
||||||
|
color str #RRGGBB, stable per node
|
||||||
|
note str pre-formatted "node N <frame> [rate]"
|
||||||
|
|
||||||
|
control t_start == t_end ; one channel control verb dispatched
|
||||||
|
fields: command, req_id?
|
||||||
|
assertion_passed t_start = watch start, t_end = match observation
|
||||||
|
fields: name, slack_s, note, color (green default
|
||||||
|
#3CB371), f_lo?, f_hi?
|
||||||
|
assertion_failed t_start = watch start, t_end = watch deadline
|
||||||
|
fields: name, reason, note, color (red default
|
||||||
|
#D62828), f_lo?, f_hi?
|
||||||
|
```
|
||||||
|
|
||||||
|
**`frame` vocabulary** (announce family/subtype + bulk; render verbatim):
|
||||||
|
`PRESENCE/AUTO`, `PRESENCE/OPERATOR`, `PRESENCE/GEOPOLL`,
|
||||||
|
`PRESENCE/GEOPOLL_RESP`, `PRESENCE/SEED`, `PRESENCE/SEED_ACK`, `BULK/RTS`,
|
||||||
|
`BULK/ACK`, `BULK/DNAK`, `BULK/SBSERIES`, `BULK/SBRTS`, `BULK/RETX_RTS`,
|
||||||
|
`BULK/SB_RETX_RTS`, `BULK/INET_REQ`, `BULK` (one OFDM data frame),
|
||||||
|
`SHORTBURST` (one strung-announce data frame), `RELAY`, `APRS`. New names
|
||||||
|
appear as the protocol grows — treat `frame` as a free-form short string.
|
||||||
|
|
||||||
|
**REMOVED — `tx_burst`** (energy-detected): the channel's old *guess* at "a
|
||||||
|
station keyed, roughly narrow or wide," from mixed-audio energy + an HF ratio +
|
||||||
|
manual `tag_next_burst` labels. Replaced by `tx_frame` (the daemon knows exactly
|
||||||
|
what it keyed) and no longer emitted; `tag_next_burst` is gone too. A pre-2026-
|
||||||
|
05-28 wav may still contain `tx_burst`; treat it as opaque/legacy.
|
||||||
|
|
||||||
|
**On-air anchoring (why `sched_offset_ms` exists).** The daemon emits its
|
||||||
|
`tx_frame` log when it *renders* a transmission (its intent), but the audio only
|
||||||
|
reaches the air after the keying/buffering pipeline. The producer buffers each
|
||||||
|
node's frames as the logs arrive (intent time) and places them only once the
|
||||||
|
node's *actual* on-air energy rising edge is observed (air time) -- so the boxes
|
||||||
|
land on the real signal. `sched_offset_ms = air_time - intent_time` is that gap,
|
||||||
|
reported per burst. It's a useful "modem wants vs actually does" metric but
|
||||||
|
coarse (block-quantized; conflates render/queue/buffer/transit), so read it as
|
||||||
|
an indicator, not a precise stopwatch.
|
||||||
|
|
||||||
|
`channel_up` / `channel_down` are not emitted: the wav itself bounds the
|
||||||
|
session (first/last sample == up/down edge).
|
||||||
|
|
||||||
|
Future kinds reserved (not all emitted yet):
|
||||||
|
|
||||||
|
```
|
||||||
|
rx_event a receiver's decode/deliver outcome
|
||||||
|
({ node, from, snr_db, frame|kind, ... })
|
||||||
|
impairment_fire { name, ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
Consumers should ignore unknown kinds gracefully.
|
||||||
|
|
||||||
|
### 4.3 Field conventions
|
||||||
|
|
||||||
|
- `node` is a 32-bit unsigned integer (the registered node_id).
|
||||||
|
- Frequencies are in Hz, durations in seconds, amplitudes in
|
||||||
|
channel-internal units roughly [-1, +1] before auto-gain.
|
||||||
|
- `command` matches the verbs in `tools/harness/fota_control.h`.
|
||||||
|
- `name` (for assertions) should ideally read as a hypothesis like
|
||||||
|
`IF SEED THEN B SEED_ACK WITHIN 4s` -- the renderer uses it as the
|
||||||
|
region label.
|
||||||
|
|
||||||
|
### 4.4 Channel -> spectral band
|
||||||
|
|
||||||
|
`tx_frame` carries both the daemon channel name (`ch`) and the resolved
|
||||||
|
band (`f_lo`/`f_hi`). The mapping (mLink's finalized spectrum allocation):
|
||||||
|
|
||||||
|
| `ch` | band (Hz) | mode / center |
|
||||||
|
|--------------|---------------|----------------------|
|
||||||
|
| `EMERGENCY` | 125 .. 175 | NB8FSK, 150 Hz |
|
||||||
|
| `ANNOUNCE_0` | 265 .. 535 | QPSK, 400 Hz |
|
||||||
|
| `ANNOUNCE_1` | 665 .. 935 | QPSK, 800 Hz |
|
||||||
|
| `ANNOUNCE_2` | 1065 .. 1335 | QPSK, 1200 Hz |
|
||||||
|
| `BULK` | 1594 .. 2906 | OFDM, 2250 Hz center |
|
||||||
|
|
||||||
|
The three announce channels are ~270 Hz wide and non-overlapping, so a
|
||||||
|
PRESENCE/RTS box and the bulk blob of the same transmission land in clearly
|
||||||
|
separate horizontal lanes. A renderer can trust `f_lo`/`f_hi` directly or
|
||||||
|
re-derive from `ch` via this table.
|
||||||
|
|
||||||
|
## 5. Rendering tips for a spectrograph
|
||||||
|
|
||||||
|
- Time axis: `t_start` and `t_end` map directly to the wav's time axis
|
||||||
|
(sample N = t * sample_rate). Draw the annotation as a horizontal
|
||||||
|
span over [t_start, t_end].
|
||||||
|
- Frequency axis: if the event has `f_lo`/`f_hi`, render as a rectangle
|
||||||
|
spanning [t_start, t_end] x [f_lo, f_hi]. If only `f_center`/`f_bw`,
|
||||||
|
use [f_center - f_bw/2, f_center + f_bw/2]. If neither, span the
|
||||||
|
full frequency axis.
|
||||||
|
- For `tx_frame` (the main case): render a filled / translucent box over
|
||||||
|
[t_start, t_end] x [f_lo, f_hi] in the frame's `color`; label it with
|
||||||
|
`frame` (or the pre-formatted `note`). Because each frame carries its own
|
||||||
|
exact band, consecutive frames of one transmission draw as a readable
|
||||||
|
sequence -- a narrow PRESENCE then RTS in an announce lane, then the bulk
|
||||||
|
frames stacked in the bulk lane. Use `seq`/`n` to show position
|
||||||
|
("3/6") and `rate` to annotate the LDPC ramp on bulk frames.
|
||||||
|
- For `assertion_passed`: thin outlined rectangle in green (use `color`
|
||||||
|
field if present, else `#3CB371`); label with `note`.
|
||||||
|
- For `assertion_failed`: thin outlined rectangle in red (or `color`);
|
||||||
|
label with `note`. This is the "we were expecting something here
|
||||||
|
and it never arrived" overlay -- visually distinguishing it from
|
||||||
|
passes is the point.
|
||||||
|
- For `control`: vertical markers at `t_start` (zero-width).
|
||||||
|
- Per-node colors are stable across the run (the producer cycles a
|
||||||
|
built-in palette by node_id); consumers may honour them or override
|
||||||
|
with their own per-node scheme.
|
||||||
|
- Layer order: `tx_frame` (background fill) -> assertion outcomes (mid-
|
||||||
|
layer outlines) -> `control` markers (top). Keep opacity moderate so
|
||||||
|
overlapping annotations stay legible.
|
||||||
|
- `tx_burst` is no longer emitted (removed). Only a legacy pre-2026-05-28 wav
|
||||||
|
carries it; render `tx_frame` and ignore `tx_burst` if present.
|
||||||
|
|
||||||
|
## 6. Producer responsibilities
|
||||||
|
|
||||||
|
1. Write `fmt ` + `data` chunks first in standard form (file plays as
|
||||||
|
plain audio for any tool).
|
||||||
|
2. Append `mLnL` after `data` with the JSONL bytes.
|
||||||
|
3. Patch the outer RIFF size at file offset 4 to `file_size - 8`.
|
||||||
|
4. Pad to even byte boundary if needed (single `\0` byte; not counted
|
||||||
|
in the chunk size field).
|
||||||
|
5. Emit JSONL in monotonic `t_start` order.
|
||||||
|
6. Each line is a single JSON object terminated by `\n` -- no
|
||||||
|
multi-line objects, no trailing commas.
|
||||||
|
|
||||||
|
## 7. Consumer responsibilities
|
||||||
|
|
||||||
|
- Treat any unknown chunk ID as opaque -- skip it per RIFF rules.
|
||||||
|
- Walk chunks from the start; don't assume `mLnL` is at a fixed offset.
|
||||||
|
- Handle a `{"truncated":true}` last-line case as a status flag.
|
||||||
|
- Tolerate unknown fields and unknown `kind` values.
|
||||||
|
|
||||||
|
## 8. Version + compatibility
|
||||||
|
|
||||||
|
The chunk ID `mLnL` corresponds to schema v2 (this document). v1 (now
|
||||||
|
deprecated) used `{t_s, kind, ...}` with paired `*_start`/`*_stop`
|
||||||
|
events; v2 collapses pairs into ranges with `t_start`/`t_end` and adds
|
||||||
|
the frequency / note / color fields. Within v2: `tx_frame` (the
|
||||||
|
daemon-sourced per-frame kind, with `node/seq/n/frame/ch/rate/f_lo/f_hi/
|
||||||
|
sched_offset_ms/color/note`) was added 2026-05-28 and is the primary
|
||||||
|
annotation, anchored on the actual on-air edge; the energy-detected
|
||||||
|
`tx_burst` and the `tag_next_burst` verb were removed in its favour.
|
||||||
|
Consumers MUST tolerate both presence and absence of all optional fields
|
||||||
|
and unknown `kind`s.
|
||||||
|
|
||||||
|
If we need an incompatible schema change later, we'll mint a new
|
||||||
|
four-CC (`mLn3` etc.) and keep `mLnL` reserved for v2.
|
||||||
|
|
||||||
|
## 9. References
|
||||||
|
|
||||||
|
- mLink producer source: `src/tools/harness/fota_capture.c`
|
||||||
|
(`append_riff_chunk` + `fota_capture_write_wav_with_annotations`)
|
||||||
|
- Event emitter: `src/tools/harness/fota_events.{c,h}`
|
||||||
|
- `tx_frame` ingest (forwarded-log -> annotation): the FOTA_MSG_LOG handler
|
||||||
|
+ `ingest_log_event` in `src/tools/harness/mlink_fake_ota.c`; the daemon
|
||||||
|
side emits `tx_frame` in `src/protocol/tx_render.c`.
|
||||||
|
- Assertion engine: `src/tools/harness/fota_assert.{c,h}`
|
||||||
|
- RIFF specification: Microsoft Multimedia Programming Interface and
|
||||||
|
Data Specifications 1.0 (1991).
|
||||||
Binary file not shown.
@@ -127,6 +127,7 @@ GENERATED += $(OBJDIR)/ui.o
|
|||||||
GENERATED += $(OBJDIR)/platform_linux.o
|
GENERATED += $(OBJDIR)/platform_linux.o
|
||||||
GENERATED += $(OBJDIR)/utils.o
|
GENERATED += $(OBJDIR)/utils.o
|
||||||
GENERATED += $(OBJDIR)/primitives.o
|
GENERATED += $(OBJDIR)/primitives.o
|
||||||
|
GENERATED += $(OBJDIR)/mlnl.o
|
||||||
OBJECTS += $(OBJDIR)/spectrogram.o
|
OBJECTS += $(OBJDIR)/spectrogram.o
|
||||||
OBJECTS += $(OBJDIR)/fft.o
|
OBJECTS += $(OBJDIR)/fft.o
|
||||||
OBJECTS += $(OBJDIR)/stft.o
|
OBJECTS += $(OBJDIR)/stft.o
|
||||||
@@ -136,6 +137,7 @@ OBJECTS += $(OBJDIR)/ui.o
|
|||||||
OBJECTS += $(OBJDIR)/platform_linux.o
|
OBJECTS += $(OBJDIR)/platform_linux.o
|
||||||
OBJECTS += $(OBJDIR)/utils.o
|
OBJECTS += $(OBJDIR)/utils.o
|
||||||
OBJECTS += $(OBJDIR)/primitives.o
|
OBJECTS += $(OBJDIR)/primitives.o
|
||||||
|
OBJECTS += $(OBJDIR)/mlnl.o
|
||||||
|
|
||||||
# Rules
|
# Rules
|
||||||
# #############################################
|
# #############################################
|
||||||
@@ -235,6 +237,10 @@ $(OBJDIR)/primitives.o: src/primitives.c
|
|||||||
@echo "$(notdir $<)"
|
@echo "$(notdir $<)"
|
||||||
$(SILENT) $(CC) $(ALL_CFLAGS) $(FORCE_INCLUDE) -o "$@" -MF "$(@:%.o=%.d)" -c "$<"
|
$(SILENT) $(CC) $(ALL_CFLAGS) $(FORCE_INCLUDE) -o "$@" -MF "$(@:%.o=%.d)" -c "$<"
|
||||||
|
|
||||||
|
$(OBJDIR)/mlnl.o: src/mlnl.c
|
||||||
|
@echo "$(notdir $<)"
|
||||||
|
$(SILENT) $(CC) $(ALL_CFLAGS) $(FORCE_INCLUDE) -o "$@" -MF "$(@:%.o=%.d)" -c "$<"
|
||||||
|
|
||||||
-include $(OBJECTS:%.o=%.d)
|
-include $(OBJECTS:%.o=%.d)
|
||||||
ifneq (,$(PCH))
|
ifneq (,$(PCH))
|
||||||
-include $(PCH_PLACEHOLDER).d
|
-include $(PCH_PLACEHOLDER).d
|
||||||
|
|||||||
+1
-1
@@ -25,7 +25,7 @@
|
|||||||
# a selection box needs a >5 px drag or it reverts to "no selection".
|
# a selection box needs a >5 px drag or it reverts to "no selection".
|
||||||
set -u
|
set -u
|
||||||
OUT="${1:-/tmp/rspek_shot.png}"; shift || true
|
OUT="${1:-/tmp/rspek_shot.png}"; shift || true
|
||||||
REPO=/home/tyler/ham/Audio-Experiments/rspektrum
|
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
DISP=:99
|
DISP=:99
|
||||||
WAV="${RSPEK_WAV:-/tmp/mlink_bulk_drop.wav}"
|
WAV="${RSPEK_WAV:-/tmp/mlink_bulk_drop.wav}"
|
||||||
|
|
||||||
|
|||||||
+387
@@ -0,0 +1,387 @@
|
|||||||
|
// mlnl.c - parse the optional `mLnL` RIFF chunk from a WAV file (schema v2).
|
||||||
|
// Payload is UTF-8 JSON Lines, one flat object per line.
|
||||||
|
#include "mlnl.h"
|
||||||
|
|
||||||
|
#include <ctype.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// RIFF walker — find the `mLnL` chunk payload.
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
static unsigned int ReadU32LE(const unsigned char* p)
|
||||||
|
{
|
||||||
|
return (unsigned int)p[0] | ((unsigned int)p[1] << 8) |
|
||||||
|
((unsigned int)p[2] << 16) | ((unsigned int)p[3] << 24);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns a malloc'd payload buffer (NUL-terminated for splitline convenience)
|
||||||
|
// and sets *outLen. NULL if the file isn't WAV or has no mLnL chunk.
|
||||||
|
static char* FindMlnlChunk(const char* path, size_t* outLen)
|
||||||
|
{
|
||||||
|
FILE* f = fopen(path, "rb");
|
||||||
|
if (!f) return NULL;
|
||||||
|
|
||||||
|
unsigned char hdr[12];
|
||||||
|
if (fread(hdr, 1, 12, f) != 12) { fclose(f); return NULL; }
|
||||||
|
if (memcmp(hdr, "RIFF", 4) != 0 || memcmp(hdr + 8, "WAVE", 4) != 0) {
|
||||||
|
fclose(f); return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (;;) {
|
||||||
|
unsigned char ch[8];
|
||||||
|
size_t got = fread(ch, 1, 8, f);
|
||||||
|
if (got != 8) break;
|
||||||
|
unsigned int sz = ReadU32LE(ch + 4);
|
||||||
|
if (memcmp(ch, "mLnL", 4) == 0) {
|
||||||
|
// Cap at 64 MiB to avoid a corrupt size eating memory.
|
||||||
|
if (sz > 64u * 1024u * 1024u) { fclose(f); return NULL; }
|
||||||
|
char* buf = (char*)malloc((size_t)sz + 1);
|
||||||
|
if (!buf) { fclose(f); return NULL; }
|
||||||
|
if (fread(buf, 1, sz, f) != sz) { free(buf); fclose(f); return NULL; }
|
||||||
|
buf[sz] = '\0';
|
||||||
|
*outLen = sz;
|
||||||
|
fclose(f);
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
// Skip the payload (+ 1 pad byte if size is odd).
|
||||||
|
long skip = (long)sz + (long)(sz & 1u);
|
||||||
|
if (fseek(f, skip, SEEK_CUR) != 0) break;
|
||||||
|
}
|
||||||
|
fclose(f);
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Tiny JSON-object-per-line parser. Flat objects with scalar values.
|
||||||
|
// Nested objects/arrays in unknown fields are tolerated by being skipped.
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
const char* s;
|
||||||
|
int pos;
|
||||||
|
int len;
|
||||||
|
} Scanner;
|
||||||
|
|
||||||
|
static void SkipWs(Scanner* sc)
|
||||||
|
{
|
||||||
|
while (sc->pos < sc->len && (sc->s[sc->pos] == ' ' || sc->s[sc->pos] == '\t')) sc->pos++;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool ReadString(Scanner* sc, char* out, int outCap)
|
||||||
|
{
|
||||||
|
if (sc->pos >= sc->len || sc->s[sc->pos] != '"') return false;
|
||||||
|
sc->pos++;
|
||||||
|
int w = 0;
|
||||||
|
while (sc->pos < sc->len && sc->s[sc->pos] != '"') {
|
||||||
|
char c = sc->s[sc->pos++];
|
||||||
|
if (c == '\\' && sc->pos < sc->len) {
|
||||||
|
char e = sc->s[sc->pos++];
|
||||||
|
switch (e) {
|
||||||
|
case 'n': c = '\n'; break;
|
||||||
|
case 't': c = '\t'; break;
|
||||||
|
case 'r': c = '\r'; break;
|
||||||
|
case '"': case '\\': case '/': c = e; break;
|
||||||
|
case 'u': {
|
||||||
|
// Bare ASCII passthrough — drop the 4 hex digits, write '?'.
|
||||||
|
if (sc->pos + 4 <= sc->len) sc->pos += 4;
|
||||||
|
c = '?';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default: c = e; break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (w + 1 < outCap) out[w++] = c;
|
||||||
|
}
|
||||||
|
if (sc->pos >= sc->len) return false;
|
||||||
|
sc->pos++; // consume closing quote
|
||||||
|
if (w < outCap) out[w] = '\0';
|
||||||
|
else if (outCap > 0) out[outCap - 1] = '\0';
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void SkipValue(Scanner* sc)
|
||||||
|
{
|
||||||
|
SkipWs(sc);
|
||||||
|
if (sc->pos >= sc->len) return;
|
||||||
|
char c = sc->s[sc->pos];
|
||||||
|
if (c == '"') {
|
||||||
|
char trash[4];
|
||||||
|
ReadString(sc, trash, sizeof(trash));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (c == '{' || c == '[') {
|
||||||
|
char open = c, close = (c == '{') ? '}' : ']';
|
||||||
|
int depth = 1;
|
||||||
|
sc->pos++;
|
||||||
|
while (sc->pos < sc->len && depth > 0) {
|
||||||
|
char k = sc->s[sc->pos];
|
||||||
|
if (k == '"') {
|
||||||
|
char trash[4];
|
||||||
|
ReadString(sc, trash, sizeof(trash));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (k == open) depth++;
|
||||||
|
else if (k == close) depth--;
|
||||||
|
sc->pos++;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
while (sc->pos < sc->len) {
|
||||||
|
char k = sc->s[sc->pos];
|
||||||
|
if (k == ',' || k == '}' || k == ']') break;
|
||||||
|
sc->pos++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool ReadNumber(Scanner* sc, double* out)
|
||||||
|
{
|
||||||
|
SkipWs(sc);
|
||||||
|
int start = sc->pos;
|
||||||
|
if (sc->pos < sc->len && (sc->s[sc->pos] == '-' || sc->s[sc->pos] == '+')) sc->pos++;
|
||||||
|
bool any = false;
|
||||||
|
while (sc->pos < sc->len) {
|
||||||
|
char c = sc->s[sc->pos];
|
||||||
|
if ((c >= '0' && c <= '9') || c == '.' || c == 'e' || c == 'E' || c == '+' || c == '-') {
|
||||||
|
sc->pos++; any = true;
|
||||||
|
} else break;
|
||||||
|
}
|
||||||
|
if (!any) return false;
|
||||||
|
char tmp[64];
|
||||||
|
int n = sc->pos - start;
|
||||||
|
if (n >= (int)sizeof(tmp)) n = (int)sizeof(tmp) - 1;
|
||||||
|
memcpy(tmp, sc->s + start, n);
|
||||||
|
tmp[n] = '\0';
|
||||||
|
*out = strtod(tmp, NULL);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static MlnlKind LookupKind(const char* s)
|
||||||
|
{
|
||||||
|
if (strcmp(s, "channel_up") == 0) return MLNL_KIND_CHANNEL_UP;
|
||||||
|
if (strcmp(s, "channel_down") == 0) return MLNL_KIND_CHANNEL_DOWN;
|
||||||
|
if (strcmp(s, "control") == 0) return MLNL_KIND_CONTROL;
|
||||||
|
if (strcmp(s, "tx_frame") == 0) return MLNL_KIND_TX_FRAME;
|
||||||
|
if (strcmp(s, "tx_burst") == 0) return MLNL_KIND_TX_BURST;
|
||||||
|
if (strcmp(s, "assertion_passed") == 0) return MLNL_KIND_ASSERTION_PASSED;
|
||||||
|
if (strcmp(s, "assertion_failed") == 0) return MLNL_KIND_ASSERTION_FAILED;
|
||||||
|
if (strcmp(s, "impairment_fire") == 0) return MLNL_KIND_IMPAIRMENT_FIRE;
|
||||||
|
if (strcmp(s, "gain_change") == 0) return MLNL_KIND_GAIN_CHANGE;
|
||||||
|
return MLNL_KIND_UNKNOWN;
|
||||||
|
}
|
||||||
|
|
||||||
|
const char* MlnlKindName(MlnlKind k)
|
||||||
|
{
|
||||||
|
switch (k) {
|
||||||
|
case MLNL_KIND_CHANNEL_UP: return "channel_up";
|
||||||
|
case MLNL_KIND_CHANNEL_DOWN: return "channel_down";
|
||||||
|
case MLNL_KIND_CONTROL: return "control";
|
||||||
|
case MLNL_KIND_TX_FRAME: return "tx_frame";
|
||||||
|
case MLNL_KIND_TX_BURST: return "tx_burst";
|
||||||
|
case MLNL_KIND_ASSERTION_PASSED: return "assertion_passed";
|
||||||
|
case MLNL_KIND_ASSERTION_FAILED: return "assertion_failed";
|
||||||
|
case MLNL_KIND_IMPAIRMENT_FIRE: return "impairment_fire";
|
||||||
|
case MLNL_KIND_GAIN_CHANGE: return "gain_change";
|
||||||
|
default: return "?";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse a "#RRGGBB" string into r/g/b. Returns false on malformed input.
|
||||||
|
static bool ParseHexColor(const char* s, unsigned char* r, unsigned char* g, unsigned char* b)
|
||||||
|
{
|
||||||
|
if (!s || s[0] != '#') return false;
|
||||||
|
unsigned int v = 0;
|
||||||
|
int n = 0;
|
||||||
|
for (int i = 1; i < 7 && s[i]; i++) {
|
||||||
|
char c = s[i];
|
||||||
|
unsigned int d;
|
||||||
|
if (c >= '0' && c <= '9') d = c - '0';
|
||||||
|
else if (c >= 'a' && c <= 'f') d = c - 'a' + 10;
|
||||||
|
else if (c >= 'A' && c <= 'F') d = c - 'A' + 10;
|
||||||
|
else return false;
|
||||||
|
v = (v << 4) | d;
|
||||||
|
n++;
|
||||||
|
}
|
||||||
|
if (n != 6) return false;
|
||||||
|
*r = (v >> 16) & 0xff;
|
||||||
|
*g = (v >> 8) & 0xff;
|
||||||
|
*b = v & 0xff;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns true if the line produced an event for the array; false if it was
|
||||||
|
// the truncation sentinel or empty/malformed.
|
||||||
|
static bool ParseLine(const char* line, int n, MlnlEvent* ev, bool* truncatedOut)
|
||||||
|
{
|
||||||
|
Scanner sc = { .s = line, .pos = 0, .len = n };
|
||||||
|
SkipWs(&sc);
|
||||||
|
if (sc.pos >= sc.len || sc.s[sc.pos] != '{') return false;
|
||||||
|
sc.pos++;
|
||||||
|
|
||||||
|
memset(ev, 0, sizeof(*ev));
|
||||||
|
|
||||||
|
// f_center/f_bw resolve into f_lo/f_hi after the line is fully parsed.
|
||||||
|
double f_center = 0, f_bw = 0;
|
||||||
|
bool have_f_lo = false, have_f_hi = false;
|
||||||
|
bool have_f_center = false, have_f_bw = false;
|
||||||
|
|
||||||
|
for (;;) {
|
||||||
|
SkipWs(&sc);
|
||||||
|
if (sc.pos >= sc.len) return false;
|
||||||
|
if (sc.s[sc.pos] == '}') { sc.pos++; break; }
|
||||||
|
if (sc.s[sc.pos] == ',') { sc.pos++; continue; }
|
||||||
|
|
||||||
|
char key[32] = {0};
|
||||||
|
if (!ReadString(&sc, key, sizeof(key))) return false;
|
||||||
|
SkipWs(&sc);
|
||||||
|
if (sc.pos >= sc.len || sc.s[sc.pos] != ':') return false;
|
||||||
|
sc.pos++;
|
||||||
|
SkipWs(&sc);
|
||||||
|
|
||||||
|
if (strcmp(key, "t_start") == 0) {
|
||||||
|
ReadNumber(&sc, &ev->t_start);
|
||||||
|
} else if (strcmp(key, "t_end") == 0) {
|
||||||
|
ReadNumber(&sc, &ev->t_end);
|
||||||
|
} else if (strcmp(key, "kind") == 0) {
|
||||||
|
ReadString(&sc, ev->kindStr, sizeof(ev->kindStr));
|
||||||
|
ev->kind = LookupKind(ev->kindStr);
|
||||||
|
} else if (strcmp(key, "f_lo") == 0) {
|
||||||
|
ReadNumber(&sc, &ev->f_lo_hz); have_f_lo = true;
|
||||||
|
} else if (strcmp(key, "f_hi") == 0) {
|
||||||
|
ReadNumber(&sc, &ev->f_hi_hz); have_f_hi = true;
|
||||||
|
} else if (strcmp(key, "f_center") == 0) {
|
||||||
|
ReadNumber(&sc, &f_center); have_f_center = true;
|
||||||
|
} else if (strcmp(key, "f_bw") == 0) {
|
||||||
|
ReadNumber(&sc, &f_bw); have_f_bw = true;
|
||||||
|
} else if (strcmp(key, "note") == 0) {
|
||||||
|
ReadString(&sc, ev->note, sizeof(ev->note));
|
||||||
|
ev->has_note = true;
|
||||||
|
} else if (strcmp(key, "color") == 0) {
|
||||||
|
char hex[16] = {0};
|
||||||
|
ReadString(&sc, hex, sizeof(hex));
|
||||||
|
if (ParseHexColor(hex, &ev->colorR, &ev->colorG, &ev->colorB))
|
||||||
|
ev->has_color = true;
|
||||||
|
} else if (strcmp(key, "node") == 0) {
|
||||||
|
double v = 0;
|
||||||
|
if (ReadNumber(&sc, &v)) { ev->node = (unsigned int)v; ev->has_node = true; }
|
||||||
|
else SkipValue(&sc);
|
||||||
|
} else if (strcmp(key, "command") == 0) {
|
||||||
|
ReadString(&sc, ev->command, sizeof(ev->command));
|
||||||
|
ev->has_command = true;
|
||||||
|
} else if (strcmp(key, "name") == 0) {
|
||||||
|
ReadString(&sc, ev->name, sizeof(ev->name));
|
||||||
|
ev->has_name = true;
|
||||||
|
} else if (strcmp(key, "reason") == 0) {
|
||||||
|
ReadString(&sc, ev->reason, sizeof(ev->reason));
|
||||||
|
ev->has_reason = true;
|
||||||
|
} else if (strcmp(key, "peak") == 0) {
|
||||||
|
ReadNumber(&sc, &ev->peak); ev->has_stats = true;
|
||||||
|
} else if (strcmp(key, "rms") == 0) {
|
||||||
|
ReadNumber(&sc, &ev->rms); ev->has_stats = true;
|
||||||
|
} else if (strcmp(key, "papr_db") == 0) {
|
||||||
|
ReadNumber(&sc, &ev->papr_db); ev->has_stats = true;
|
||||||
|
} else if (strcmp(key, "slack_s") == 0) {
|
||||||
|
ReadNumber(&sc, &ev->slack_s); ev->has_slack = true;
|
||||||
|
} else if (strcmp(key, "id") == 0) {
|
||||||
|
double v = 0;
|
||||||
|
if (ReadNumber(&sc, &v)) { ev->id = (unsigned int)v; ev->has_id = true; }
|
||||||
|
else SkipValue(&sc);
|
||||||
|
} else if (strcmp(key, "label") == 0) {
|
||||||
|
ReadString(&sc, ev->label, sizeof(ev->label));
|
||||||
|
ev->has_label = (ev->label[0] != '\0');
|
||||||
|
} else if (strcmp(key, "seq") == 0) {
|
||||||
|
double v = 0;
|
||||||
|
if (ReadNumber(&sc, &v)) ev->seq = (int)v;
|
||||||
|
else SkipValue(&sc);
|
||||||
|
} else if (strcmp(key, "n") == 0) {
|
||||||
|
double v = 0;
|
||||||
|
if (ReadNumber(&sc, &v)) { ev->nFrames = (int)v; ev->has_seqn = true; }
|
||||||
|
else SkipValue(&sc);
|
||||||
|
} else if (strcmp(key, "frame") == 0) {
|
||||||
|
ReadString(&sc, ev->frame, sizeof(ev->frame));
|
||||||
|
ev->has_frame = (ev->frame[0] != '\0');
|
||||||
|
} else if (strcmp(key, "ch") == 0) {
|
||||||
|
ReadString(&sc, ev->ch, sizeof(ev->ch));
|
||||||
|
ev->has_ch = (ev->ch[0] != '\0');
|
||||||
|
} else if (strcmp(key, "rate") == 0) {
|
||||||
|
ReadString(&sc, ev->rate, sizeof(ev->rate));
|
||||||
|
ev->has_rate = (ev->rate[0] != '\0');
|
||||||
|
} else if (strcmp(key, "sched_offset_ms") == 0) {
|
||||||
|
if (ReadNumber(&sc, &ev->sched_offset_ms)) ev->has_sched_offset = true;
|
||||||
|
else SkipValue(&sc);
|
||||||
|
} else if (strcmp(key, "truncated") == 0) {
|
||||||
|
if (sc.pos < sc.len && (sc.s[sc.pos] == 't' || sc.s[sc.pos] == 'T'))
|
||||||
|
*truncatedOut = true;
|
||||||
|
SkipValue(&sc);
|
||||||
|
} else {
|
||||||
|
// Unknown field — swallow scalar/composite alike.
|
||||||
|
SkipValue(&sc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve frequency: prefer f_lo/f_hi if present, else expand f_center/f_bw.
|
||||||
|
if (have_f_lo && have_f_hi) {
|
||||||
|
ev->has_freq = true;
|
||||||
|
} else if (have_f_center && have_f_bw) {
|
||||||
|
ev->f_lo_hz = f_center - f_bw * 0.5;
|
||||||
|
ev->f_hi_hz = f_center + f_bw * 0.5;
|
||||||
|
ev->has_freq = true;
|
||||||
|
}
|
||||||
|
if (ev->has_freq && ev->f_hi_hz < ev->f_lo_hz) {
|
||||||
|
double t = ev->f_lo_hz; ev->f_lo_hz = ev->f_hi_hz; ev->f_hi_hz = t;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The truncation line carries no kind; treat it as a non-event so it
|
||||||
|
// doesn't bloat the events array (the truncated flag still propagates).
|
||||||
|
if (ev->kindStr[0] == '\0') return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Public API
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
bool LoadMlnlFromWav(const char* path, MlnlAnnotations* out)
|
||||||
|
{
|
||||||
|
memset(out, 0, sizeof(*out));
|
||||||
|
size_t len = 0;
|
||||||
|
char* payload = FindMlnlChunk(path, &len);
|
||||||
|
if (!payload) return false;
|
||||||
|
|
||||||
|
int lineCount = 0;
|
||||||
|
for (size_t i = 0; i < len; i++) if (payload[i] == '\n') lineCount++;
|
||||||
|
if (lineCount == 0) lineCount = 1;
|
||||||
|
|
||||||
|
out->events = (MlnlEvent*)calloc(lineCount, sizeof(MlnlEvent));
|
||||||
|
if (!out->events) { free(payload); return false; }
|
||||||
|
|
||||||
|
int lineStart = 0;
|
||||||
|
for (size_t i = 0; i <= len; i++) {
|
||||||
|
if (i == len || payload[i] == '\n') {
|
||||||
|
int n = (int)i - lineStart;
|
||||||
|
if (n > 0 && payload[lineStart + n - 1] == '\r') n--;
|
||||||
|
if (n > 0) {
|
||||||
|
MlnlEvent ev = {0};
|
||||||
|
if (ParseLine(payload + lineStart, n, &ev, &out->truncated)) {
|
||||||
|
out->events[out->eventCount++] = ev;
|
||||||
|
if (ev.kind >= 0 && ev.kind < MLNL_KIND_MAX)
|
||||||
|
out->kindPresent[ev.kind] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lineStart = (int)i + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out->loaded = true;
|
||||||
|
free(payload);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void FreeMlnl(MlnlAnnotations* a)
|
||||||
|
{
|
||||||
|
if (!a) return;
|
||||||
|
free(a->events);
|
||||||
|
memset(a, 0, sizeof(*a));
|
||||||
|
}
|
||||||
+98
@@ -0,0 +1,98 @@
|
|||||||
|
// mlnl.h - mLnL annotation chunk parser (schema v2).
|
||||||
|
// Walks a WAV file's RIFF chunks looking for the optional `mLnL` chunk
|
||||||
|
// (UTF-8 JSON Lines), parses each line into an MlnlEvent. Every event is a
|
||||||
|
// self-contained time range [t_start, t_end] (instantaneous events have
|
||||||
|
// t_start == t_end). See mlnl_chunk_spec.md.
|
||||||
|
#ifndef MLNL_H
|
||||||
|
#define MLNL_H
|
||||||
|
|
||||||
|
#include <stdbool.h>
|
||||||
|
|
||||||
|
typedef enum {
|
||||||
|
MLNL_KIND_UNKNOWN = 0,
|
||||||
|
MLNL_KIND_CHANNEL_UP,
|
||||||
|
MLNL_KIND_CHANNEL_DOWN,
|
||||||
|
MLNL_KIND_CONTROL,
|
||||||
|
MLNL_KIND_TX_FRAME, // v2 primary annotation: one daemon-sourced on-air frame
|
||||||
|
MLNL_KIND_TX_BURST, // deprecated, energy-detected guess; superseded by tx_frame
|
||||||
|
MLNL_KIND_ASSERTION_PASSED,
|
||||||
|
MLNL_KIND_ASSERTION_FAILED,
|
||||||
|
MLNL_KIND_IMPAIRMENT_FIRE,
|
||||||
|
MLNL_KIND_GAIN_CHANGE,
|
||||||
|
} MlnlKind;
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
// Required.
|
||||||
|
double t_start, t_end;
|
||||||
|
MlnlKind kind;
|
||||||
|
char kindStr[32]; // original "kind" string (for unknown kinds + display)
|
||||||
|
|
||||||
|
// Frequency annotation (resolved from f_lo/f_hi OR f_center/f_bw).
|
||||||
|
double f_lo_hz, f_hi_hz;
|
||||||
|
bool has_freq;
|
||||||
|
|
||||||
|
// Optional human/visual hints.
|
||||||
|
char note[96];
|
||||||
|
bool has_note;
|
||||||
|
unsigned char colorR, colorG, colorB; // parsed from "#RRGGBB"
|
||||||
|
bool has_color;
|
||||||
|
|
||||||
|
// Kind-specific fields. The has_* flag says whether the producer set it.
|
||||||
|
unsigned int node;
|
||||||
|
bool has_node;
|
||||||
|
char command[64];
|
||||||
|
bool has_command;
|
||||||
|
char name[128]; // assertion name
|
||||||
|
bool has_name;
|
||||||
|
char reason[96]; // assertion_failed reason
|
||||||
|
bool has_reason;
|
||||||
|
double peak, rms, papr_db; // tx_burst
|
||||||
|
bool has_stats;
|
||||||
|
double slack_s; // assertion_passed
|
||||||
|
bool has_slack;
|
||||||
|
unsigned int id; // tx_burst: monotonic per-wav identifier
|
||||||
|
bool has_id;
|
||||||
|
char label[32]; // tx_burst: optional frame tag (SEED/BULK/ACK/...)
|
||||||
|
bool has_label;
|
||||||
|
|
||||||
|
// tx_frame fields (schema v2 primary annotation). `node`/`has_node` above
|
||||||
|
// carry the transmitting node_id (attribution + color).
|
||||||
|
int seq, nFrames; // 0-based index + total frames in the PTT ("seq of n")
|
||||||
|
bool has_seqn; // set once `n` is present
|
||||||
|
char frame[32]; // mLink frame name (PRESENCE/SEED, BULK, BULK/RTS, ...)
|
||||||
|
bool has_frame;
|
||||||
|
char ch[16]; // daemon channel (EMERGENCY / ANNOUNCE_0|1|2 / BULK)
|
||||||
|
bool has_ch;
|
||||||
|
char rate[8]; // LDPC rate of a bulk OFDM frame ("1/2".."5/6"); else empty
|
||||||
|
bool has_rate;
|
||||||
|
// intent->air latency of this burst: air_time - modem render/intent time.
|
||||||
|
// The producer already anchors t_start/t_end on the ACTUAL on-air edge, so
|
||||||
|
// this is a profiling metric ("modem wants vs. actually does"), not a
|
||||||
|
// plotting offset. Coarse/block-quantized. See mlnl_chunk_spec.md §4.2.
|
||||||
|
double sched_offset_ms;
|
||||||
|
bool has_sched_offset;
|
||||||
|
} MlnlEvent;
|
||||||
|
|
||||||
|
// Up to this many distinct MlnlKind values are tracked per-file (any new kinds
|
||||||
|
// added to the enum must bump this). Indexed by the MlnlKind value.
|
||||||
|
#define MLNL_KIND_MAX 16
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
MlnlEvent* events;
|
||||||
|
int eventCount;
|
||||||
|
bool truncated; // {"truncated":true} sentinel appeared
|
||||||
|
bool loaded; // true iff the file contained a parseable mLnL chunk
|
||||||
|
bool kindPresent[MLNL_KIND_MAX]; // which kinds the parser saw (drives the UI)
|
||||||
|
} MlnlAnnotations;
|
||||||
|
|
||||||
|
// Walk `path`'s RIFF chunks looking for `mLnL` and parse the JSONL payload.
|
||||||
|
// Returns true on success (out->loaded is set the same way). On failure
|
||||||
|
// (no chunk / unreadable / not WAV) returns false and zeroes *out.
|
||||||
|
bool LoadMlnlFromWav(const char* path, MlnlAnnotations* out);
|
||||||
|
|
||||||
|
void FreeMlnl(MlnlAnnotations* a);
|
||||||
|
|
||||||
|
// Display label for an MlnlKind.
|
||||||
|
const char* MlnlKindName(MlnlKind k);
|
||||||
|
|
||||||
|
#endif // MLNL_H
|
||||||
+751
-7
@@ -293,8 +293,9 @@ void DrawLabels(Rectangle bounds)
|
|||||||
DrawTextScaled(label, x, bounds.y + bounds.height + 5, baseFontSize, textColor);
|
DrawTextScaled(label, x, bounds.y + bounds.height + 5, baseFontSize, textColor);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Frequency labels adapted to current zoom level
|
// Frequency labels adapted to current zoom level. Honors the display crop:
|
||||||
float maxFreq = (float)app.signal.sampleRate / 2.0f;
|
// 1.0 of the view range is the user's chosen max, not raw Nyquist.
|
||||||
|
float maxFreq = EffectiveMaxFreqHz();
|
||||||
float freqMin = app.view.freqStart * maxFreq;
|
float freqMin = app.view.freqStart * maxFreq;
|
||||||
float freqMax = app.view.freqEnd * maxFreq;
|
float freqMax = app.view.freqEnd * maxFreq;
|
||||||
|
|
||||||
@@ -503,8 +504,12 @@ void DrawCursorReadout(Rectangle bounds)
|
|||||||
float tFrac = app.view.start + ((m.x - bounds.x) / bounds.width) * (app.view.end - app.view.start);
|
float tFrac = app.view.start + ((m.x - bounds.x) / bounds.width) * (app.view.end - app.view.start);
|
||||||
float fFrac = app.view.freqStart + (1.0f - (m.y - bounds.y) / bounds.height) * (app.view.freqEnd - app.view.freqStart);
|
float fFrac = app.view.freqStart + (1.0f - (m.y - bounds.y) / bounds.height) * (app.view.freqEnd - app.view.freqStart);
|
||||||
float timeSec = tFrac * app.signal.duration;
|
float timeSec = tFrac * app.signal.duration;
|
||||||
float nyquist = app.signal.sampleRate * 0.5f;
|
// Map cursor freq through the cropped axis (so 100% of view = chosen max,
|
||||||
float freqHz = fFrac * nyquist;
|
// not raw Nyquist), but use the true Nyquist for STFT bin spacing — the
|
||||||
|
// bins themselves still cover the full signal regardless of the crop.
|
||||||
|
float displayMax = EffectiveMaxFreqHz();
|
||||||
|
float dataNyquist = app.signal.sampleRate * 0.5f;
|
||||||
|
float freqHz = fFrac * displayMax;
|
||||||
|
|
||||||
// Sample the STFT level at this (time, freq).
|
// Sample the STFT level at this (time, freq).
|
||||||
char level[32] = "--";
|
char level[32] = "--";
|
||||||
@@ -514,7 +519,7 @@ void DrawCursorReadout(Rectangle bounds)
|
|||||||
if (seg >= app.stft.numSegments) seg = app.stft.numSegments - 1;
|
if (seg >= app.stft.numSegments) seg = app.stft.numSegments - 1;
|
||||||
const StftSegment* s = &app.stft.segments[seg];
|
const StftSegment* s = &app.stft.segments[seg];
|
||||||
if (s->spectrum && s->numBins > 1) {
|
if (s->spectrum && s->numBins > 1) {
|
||||||
float binHz = nyquist / (float)(s->numBins - 1);
|
float binHz = dataNyquist / (float)(s->numBins - 1);
|
||||||
int bin = (int)(freqHz / binHz + 0.5f);
|
int bin = (int)(freqHz / binHz + 0.5f);
|
||||||
if (bin < 0) bin = 0;
|
if (bin < 0) bin = 0;
|
||||||
if (bin >= s->numBins) bin = s->numBins - 1;
|
if (bin >= s->numBins) bin = s->numBins - 1;
|
||||||
@@ -581,8 +586,10 @@ void DrawMarkers(Rectangle bounds)
|
|||||||
if (bIn) DrawMarkerCross(b, (Color){ 255, 180, 80, 255 }, "B");
|
if (bIn) DrawMarkerCross(b, (Color){ 255, 180, 80, 255 }, "B");
|
||||||
EndScissorMode();
|
EndScissorMode();
|
||||||
|
|
||||||
// Compute deltas in real units.
|
// Compute deltas in real units. Marker positions are normalized inside
|
||||||
float nyquist = app.signal.sampleRate * 0.5f;
|
// the displayed view, so they scale with the crop just like the freq
|
||||||
|
// axis labels do — what the user sees IS what they measure.
|
||||||
|
float nyquist = EffectiveMaxFreqHz();
|
||||||
float ta = app.marker.t0 * app.signal.duration, tb = app.marker.t1 * app.signal.duration;
|
float ta = app.marker.t0 * app.signal.duration, tb = app.marker.t1 * app.signal.duration;
|
||||||
float fa = app.marker.f0 * nyquist, fb = app.marker.f1 * nyquist;
|
float fa = app.marker.f0 * nyquist, fb = app.marker.f1 * nyquist;
|
||||||
float dt = fabsf(tb - ta);
|
float dt = fabsf(tb - ta);
|
||||||
@@ -722,6 +729,743 @@ void DrawSpectrumPanel(Rectangle bounds)
|
|||||||
free(power);
|
free(power);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// mLnL annotation overlay (schema v2)
|
||||||
|
//
|
||||||
|
// Layer order (per the spec; deepest first):
|
||||||
|
// 1. tx_frame — PRIMARY: filled translucent box + outline + per-frame label,
|
||||||
|
// each frame drawn in its own frequency lane (announce / bulk / emergency)
|
||||||
|
// 2. assertion_passed / assertion_failed — thin outlined rectangles
|
||||||
|
// 3. control / channel_up / channel_down / impairment / gain — vertical lines
|
||||||
|
// Each pass also records the topmost hover hit so the tooltip drawn last
|
||||||
|
// reflects the visually-frontmost annotation under the cursor.
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// Default per-node palette used when an event omits the `color` field.
|
||||||
|
static Color NodeColor(unsigned int node)
|
||||||
|
{
|
||||||
|
static const Color palette[] = {
|
||||||
|
{ 120, 220, 255, 255 }, // sky
|
||||||
|
{ 255, 180, 80, 255 }, // amber
|
||||||
|
{ 160, 255, 140, 255 }, // mint
|
||||||
|
{ 255, 130, 200, 255 }, // pink
|
||||||
|
{ 200, 160, 255, 255 }, // lavender
|
||||||
|
{ 255, 230, 110, 255 }, // pale gold
|
||||||
|
};
|
||||||
|
return palette[node % (sizeof(palette) / sizeof(palette[0]))];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve an event's display color: use the producer-supplied `color` field
|
||||||
|
// if present, otherwise fall back to a per-kind default (tx_burst uses node
|
||||||
|
// palette; assertions use spec defaults; controls/etc. get a neutral hint).
|
||||||
|
static Color EventColor(const MlnlEvent* e)
|
||||||
|
{
|
||||||
|
if (e->has_color) return (Color){ e->colorR, e->colorG, e->colorB, 255 };
|
||||||
|
switch (e->kind) {
|
||||||
|
case MLNL_KIND_TX_FRAME:
|
||||||
|
case MLNL_KIND_TX_BURST:
|
||||||
|
return NodeColor(e->has_node ? e->node : 0);
|
||||||
|
case MLNL_KIND_ASSERTION_PASSED: return (Color){ 60, 179, 113, 255 }; // #3CB371
|
||||||
|
case MLNL_KIND_ASSERTION_FAILED: return (Color){ 214, 40, 40, 255 }; // #D62828
|
||||||
|
case MLNL_KIND_CONTROL: return (Color){ 255, 220, 120, 255 };
|
||||||
|
default: return (Color){ 200, 200, 220, 255 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map (t_s, freq_hz) to screen, honoring zoom. duration_s and nyquist_hz are
|
||||||
|
// the signal's extents.
|
||||||
|
static Vector2 AnnoToScreen(Rectangle bounds, double t_s, double f_hz,
|
||||||
|
double duration_s, double nyquist_hz)
|
||||||
|
{
|
||||||
|
double tFrac = (duration_s > 0.0) ? (t_s / duration_s) : 0.0;
|
||||||
|
double fFrac = (nyquist_hz > 0.0) ? (f_hz / nyquist_hz) : 0.0;
|
||||||
|
double vw = app.view.end - app.view.start;
|
||||||
|
double fw = app.view.freqEnd - app.view.freqStart;
|
||||||
|
Vector2 p;
|
||||||
|
p.x = bounds.x + (float)((tFrac - app.view.start) / vw) * bounds.width;
|
||||||
|
p.y = bounds.y + bounds.height - (float)((fFrac - app.view.freqStart) / fw) * bounds.height;
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Project the event's time+frequency band into a screen rectangle, clipped to
|
||||||
|
// the viewport. Events with no freq fields span the full frequency axis.
|
||||||
|
// Returns false if the result is entirely outside the visible area.
|
||||||
|
static bool EventRect(Rectangle bounds, const MlnlEvent* e,
|
||||||
|
double duration_s, double nyquist_hz, Rectangle* out)
|
||||||
|
{
|
||||||
|
double f0 = e->has_freq ? e->f_lo_hz : 0.0;
|
||||||
|
double f1 = e->has_freq ? e->f_hi_hz : nyquist_hz;
|
||||||
|
Vector2 lo = AnnoToScreen(bounds, e->t_start, f0, duration_s, nyquist_hz);
|
||||||
|
Vector2 hi = AnnoToScreen(bounds, e->t_end, f1, duration_s, nyquist_hz);
|
||||||
|
float x = fminf(lo.x, hi.x);
|
||||||
|
float y = fminf(lo.y, hi.y);
|
||||||
|
float w = fabsf(hi.x - lo.x);
|
||||||
|
float h = fabsf(hi.y - lo.y);
|
||||||
|
if (x + w < bounds.x || x > bounds.x + bounds.width) return false;
|
||||||
|
float x0 = fmaxf(x, bounds.x);
|
||||||
|
float x1 = fminf(x + w, bounds.x + bounds.width);
|
||||||
|
float y0 = fmaxf(y, bounds.y);
|
||||||
|
float y1 = fminf(y + h, bounds.y + bounds.height);
|
||||||
|
if (x1 <= x0 || y1 <= y0) return false;
|
||||||
|
*out = (Rectangle){ x0, y0, x1 - x0, y1 - y0 };
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the tooltip lines for an event. Fields are listed in priority order
|
||||||
|
// (kind banner, note, time range, freq band, then kind-specific extras).
|
||||||
|
// Format a tx_frame's intent->air latency compactly: "+160ms", "+4.0s".
|
||||||
|
// sched_offset_ms = air_time - modem intent time (how late the burst hit the
|
||||||
|
// air vs. when it was rendered); see mlnl_chunk_spec.md §4.2.
|
||||||
|
static void FormatSchedOffset(double ms, char* out, int cap)
|
||||||
|
{
|
||||||
|
const char* sign = (ms < 0) ? "-" : "+";
|
||||||
|
double a = fabs(ms);
|
||||||
|
if (a >= 1000.0) snprintf(out, cap, "%s%.1fs", sign, a / 1000.0);
|
||||||
|
else snprintf(out, cap, "%s%.0fms", sign, a);
|
||||||
|
}
|
||||||
|
|
||||||
|
static int BuildEventLines(const MlnlEvent* e, char lines[][96], int maxLines)
|
||||||
|
{
|
||||||
|
int n = 0;
|
||||||
|
if (n < maxLines) snprintf(lines[n++], 96, "%s", e->kindStr);
|
||||||
|
if (e->has_note && n < maxLines) snprintf(lines[n++], 96, "%s", e->note);
|
||||||
|
|
||||||
|
double dt = e->t_end - e->t_start;
|
||||||
|
if (dt > 0.0) {
|
||||||
|
if (n < maxLines) snprintf(lines[n++], 96, "%.3f - %.3f s", e->t_start, e->t_end);
|
||||||
|
if (n < maxLines) snprintf(lines[n++], 96, "dur: %.3f s", dt);
|
||||||
|
} else {
|
||||||
|
if (n < maxLines) snprintf(lines[n++], 96, "t: %.3f s", e->t_start);
|
||||||
|
}
|
||||||
|
if (e->has_freq && n < maxLines) snprintf(lines[n++], 96, "%.0f - %.0f Hz", e->f_lo_hz, e->f_hi_hz);
|
||||||
|
|
||||||
|
// tx_frame specifics: frame name, daemon channel, position in the PTT, rate.
|
||||||
|
if (e->has_frame && n < maxLines) snprintf(lines[n++], 96, "frame: %s", e->frame);
|
||||||
|
if (e->has_ch && n < maxLines) snprintf(lines[n++], 96, "ch: %s", e->ch);
|
||||||
|
if (e->has_seqn && e->nFrames > 1 && n < maxLines)
|
||||||
|
snprintf(lines[n++], 96, "frame %d of %d", e->seq + 1, e->nFrames);
|
||||||
|
if (e->has_rate && n < maxLines) snprintf(lines[n++], 96, "rate: %s", e->rate);
|
||||||
|
// Intent->air latency: how late this burst hit the air vs. the modem's
|
||||||
|
// render time. Boxes are already air-anchored upstream, so this is purely
|
||||||
|
// informational (see mlnl_chunk_spec.md §4.2).
|
||||||
|
if (e->has_sched_offset && n < maxLines) {
|
||||||
|
char so[24];
|
||||||
|
FormatSchedOffset(e->sched_offset_ms, so, sizeof(so));
|
||||||
|
snprintf(lines[n++], 96, "sched offset: %s", so);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e->has_node && n < maxLines) {
|
||||||
|
// For tx_burst prefer "node N <LABEL> #id" so the operator can map back
|
||||||
|
// to the harness log even when note is identical between siblings.
|
||||||
|
if (e->has_label && e->has_id)
|
||||||
|
snprintf(lines[n++], 96, "node %u %s #%u", e->node, e->label, e->id);
|
||||||
|
else if (e->has_id)
|
||||||
|
snprintf(lines[n++], 96, "node %u #%u", e->node, e->id);
|
||||||
|
else
|
||||||
|
snprintf(lines[n++], 96, "node: %u", e->node);
|
||||||
|
} else if (e->has_id && n < maxLines) {
|
||||||
|
snprintf(lines[n++], 96, "#%u", e->id);
|
||||||
|
}
|
||||||
|
if (e->has_command && n < maxLines) snprintf(lines[n++], 96, "cmd: %s", e->command);
|
||||||
|
if (e->has_name && n < maxLines) snprintf(lines[n++], 96, "%.90s", e->name);
|
||||||
|
if (e->has_reason && n < maxLines) snprintf(lines[n++], 96, "reason: %.80s", e->reason);
|
||||||
|
if (e->has_slack && n < maxLines) snprintf(lines[n++], 96, "slack: %.2fs", e->slack_s);
|
||||||
|
if (e->has_stats) {
|
||||||
|
if (n < maxLines) snprintf(lines[n++], 96, "peak: %.3f", e->peak);
|
||||||
|
if (n < maxLines) snprintf(lines[n++], 96, "rms: %.3f", e->rms);
|
||||||
|
if (n < maxLines) snprintf(lines[n++], 96, "PAPR: %.1f dB", e->papr_db);
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw a label inside (or just above) `box` in `color`, scissor-clipped to
|
||||||
|
// the box's horizontal extent so long notes don't bleed over neighbors.
|
||||||
|
// topInside=true places the label inside the top of the box (used for tx_bursts
|
||||||
|
// so the box outline still reads clearly above); false places it just above,
|
||||||
|
// falling back to inside if the box is at the top of the viewport.
|
||||||
|
static void DrawBoxLabel(Rectangle box, const char* text, Color color, bool topInside)
|
||||||
|
{
|
||||||
|
if (!text || !*text || box.width < 18.0f) return;
|
||||||
|
float scale = GetUIScale();
|
||||||
|
float fs = 10.0f;
|
||||||
|
float lineH = fs * scale + 2;
|
||||||
|
int x = (int)box.x + 3;
|
||||||
|
int y = topInside ? (int)box.y + 2 : (int)(box.y - lineH);
|
||||||
|
if (y < 0) y = (int)box.y + 2;
|
||||||
|
BeginScissorMode(x, y, (int)box.width - 4, (int)lineH);
|
||||||
|
DrawTextScaled(text, x, y, fs, color);
|
||||||
|
EndScissorMode();
|
||||||
|
}
|
||||||
|
|
||||||
|
static void DrawTooltip(Rectangle bounds, Vector2 anchor,
|
||||||
|
char lines[][96], int n, Color border)
|
||||||
|
{
|
||||||
|
if (n <= 0) return;
|
||||||
|
float scale = GetUIScale();
|
||||||
|
float fontSize = 11.0f;
|
||||||
|
float lineH = fontSize * scale + 4;
|
||||||
|
float padX = 10 * scale;
|
||||||
|
float padY = 6 * scale;
|
||||||
|
|
||||||
|
float maxW = 0;
|
||||||
|
for (int i = 0; i < n; i++) {
|
||||||
|
float w = MeasureTextScaled(lines[i], fontSize);
|
||||||
|
if (w > maxW) maxW = w;
|
||||||
|
}
|
||||||
|
int boxW = (int)(maxW + padX * 2);
|
||||||
|
int boxH = (int)(n * lineH + padY * 2);
|
||||||
|
float bx = anchor.x + 12, by = anchor.y + 12;
|
||||||
|
if (bx + boxW > bounds.x + bounds.width) bx = anchor.x - boxW - 12;
|
||||||
|
if (bx < bounds.x) bx = bounds.x;
|
||||||
|
if (by + boxH > bounds.y + bounds.height) by = bounds.y + bounds.height - boxH;
|
||||||
|
if (by < bounds.y) by = bounds.y;
|
||||||
|
|
||||||
|
DrawRectangle((int)bx, (int)by, boxW, boxH, (Color){ 0, 0, 0, 220 });
|
||||||
|
DrawRectangleLines((int)bx, (int)by, boxW, boxH, Fade(border, 0.8f));
|
||||||
|
for (int i = 0; i < n; i++)
|
||||||
|
DrawTextScaled(lines[i], bx + padX, by + padY + i * lineH, fontSize, LIGHTGRAY);
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool IsPointEvent(const MlnlEvent* e)
|
||||||
|
{
|
||||||
|
if (e->t_end > e->t_start) return false; // has a range -> draw as rect
|
||||||
|
switch (e->kind) {
|
||||||
|
case MLNL_KIND_CONTROL:
|
||||||
|
case MLNL_KIND_CHANNEL_UP:
|
||||||
|
case MLNL_KIND_CHANNEL_DOWN:
|
||||||
|
case MLNL_KIND_IMPAIRMENT_FIRE:
|
||||||
|
case MLNL_KIND_GAIN_CHANGE:
|
||||||
|
case MLNL_KIND_UNKNOWN:
|
||||||
|
return true;
|
||||||
|
default:
|
||||||
|
return true; // zero-width assertion etc. also render as vertical line
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// True iff the given event is currently emphasized (selected, or hovered in
|
||||||
|
// the timeline lane). Spectrogram-cursor hover is intentionally NOT emphasized
|
||||||
|
// — otherwise simply moving the mouse over the viewport would flicker the
|
||||||
|
// overlays. Emphasis is reserved for explicit indication via the timeline.
|
||||||
|
static bool AnnotationEmphasized(int eventIndex)
|
||||||
|
{
|
||||||
|
return (app.selectedAnnotation == eventIndex) ||
|
||||||
|
(app.hoveredTimelineEvent == eventIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
static unsigned char AlphaForEvent(int eventIndex, float fillMultiplier)
|
||||||
|
{
|
||||||
|
float op = AnnotationEmphasized(eventIndex)
|
||||||
|
? app.annotationOpacityHover
|
||||||
|
: app.annotationOpacityBase;
|
||||||
|
return (unsigned char)Clamp(op * fillMultiplier, 0.0f, 255.0f);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compose a tx_frame's in-box label per spec §5: the frame name, its LDPC rate
|
||||||
|
// (bulk frames only), then its position in the PTT ("seq of n"). e.g.
|
||||||
|
// "BULK 3/4 3/6" or "PRESENCE/SEED". Falls back to the producer note.
|
||||||
|
static void FormatTxFrameLabel(const MlnlEvent* e, char* out, int cap)
|
||||||
|
{
|
||||||
|
char pos[16] = { 0 };
|
||||||
|
if (e->has_seqn && e->nFrames > 1)
|
||||||
|
snprintf(pos, sizeof(pos), " %d/%d", e->seq + 1, e->nFrames);
|
||||||
|
|
||||||
|
const char* base = e->has_frame ? e->frame : (e->has_note ? e->note : "tx_frame");
|
||||||
|
if (e->has_frame && e->has_rate)
|
||||||
|
snprintf(out, cap, "%s %s%s", base, e->rate, pos);
|
||||||
|
else
|
||||||
|
snprintf(out, cap, "%s%s", base, pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
void DrawAnnotations(Rectangle bounds)
|
||||||
|
{
|
||||||
|
if (!app.loaded || !app.annotations.loaded) return;
|
||||||
|
if (!app.showAnnotations) { app.hoveredEvent = -1; return; }
|
||||||
|
if (app.signal.duration <= 0.0f) return;
|
||||||
|
|
||||||
|
app.hoveredEvent = -1;
|
||||||
|
|
||||||
|
double duration = app.signal.duration;
|
||||||
|
// Annotation freq mapping uses the DISPLAYED top-of-axis: events with
|
||||||
|
// f_lo/f_hi above the crop simply get clipped at the top of the visible
|
||||||
|
// area, the same way pan/zoom already handles off-screen events.
|
||||||
|
double nyquist = EffectiveMaxFreqHz();
|
||||||
|
Vector2 m = GetMousePosition();
|
||||||
|
bool mouseInBounds = CheckCollisionPointRec(m, bounds);
|
||||||
|
bool suppressHover = app.sel.isTimeSelecting || app.sel.isFreqSelecting ||
|
||||||
|
app.sel.isDragging || app.view.isPanning || app.marker.dragging ||
|
||||||
|
app.markerMode;
|
||||||
|
|
||||||
|
int hoverEvent = -1;
|
||||||
|
// hover prefers later layers (assertions over bursts, point markers over both),
|
||||||
|
// which matches the spec's draw-order rationale: things on top win the click.
|
||||||
|
|
||||||
|
// ---- Layer 1: tx_burst (wash + outline) ----
|
||||||
|
// tx_burst uses the full 200 fill multiplier; emphasis comes from a higher
|
||||||
|
// per-event opacity, NOT a different multiplier.
|
||||||
|
for (int i = 0; i < app.annotations.eventCount; i++) {
|
||||||
|
const MlnlEvent* e = &app.annotations.events[i];
|
||||||
|
if (e->kind != MLNL_KIND_TX_BURST) continue;
|
||||||
|
if (e->kind < MLNL_KIND_MAX && !app.annotationKindEnabled[e->kind]) continue;
|
||||||
|
Rectangle r;
|
||||||
|
if (!EventRect(bounds, e, duration, nyquist, &r)) continue;
|
||||||
|
Color c = EventColor(e);
|
||||||
|
unsigned char strokeA = AlphaForEvent(i, 255.0f);
|
||||||
|
unsigned char fillA = AlphaForEvent(i, 200.0f);
|
||||||
|
Color stroke = (Color){ c.r, c.g, c.b, strokeA };
|
||||||
|
DrawRectangleRec(r, (Color){ c.r, c.g, c.b, fillA });
|
||||||
|
DrawRectangleLinesEx(r, 1.5f, stroke);
|
||||||
|
DrawBoxLabel(r, e->has_note ? e->note : e->kindStr, stroke, /*topInside=*/true);
|
||||||
|
|
||||||
|
if (!suppressHover && mouseInBounds && CheckCollisionPointRec(m, r))
|
||||||
|
hoverEvent = i;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Layer 1b: tx_frame (PRIMARY per-frame fill + outline + label) ----
|
||||||
|
// The daemon's own per-frame self-report. Each frame carries its exact band
|
||||||
|
// (f_lo/f_hi resolved from its `ch`), so one transmission renders as a
|
||||||
|
// readable sequence of boxes laid out across the announce / bulk lanes.
|
||||||
|
for (int i = 0; i < app.annotations.eventCount; i++) {
|
||||||
|
const MlnlEvent* e = &app.annotations.events[i];
|
||||||
|
if (e->kind != MLNL_KIND_TX_FRAME) continue;
|
||||||
|
if (e->kind < MLNL_KIND_MAX && !app.annotationKindEnabled[e->kind]) continue;
|
||||||
|
Rectangle r;
|
||||||
|
if (!EventRect(bounds, e, duration, nyquist, &r)) continue;
|
||||||
|
Color c = EventColor(e);
|
||||||
|
unsigned char strokeA = AlphaForEvent(i, 255.0f);
|
||||||
|
unsigned char fillA = AlphaForEvent(i, 200.0f);
|
||||||
|
Color stroke = (Color){ c.r, c.g, c.b, strokeA };
|
||||||
|
DrawRectangleRec(r, (Color){ c.r, c.g, c.b, fillA });
|
||||||
|
DrawRectangleLinesEx(r, 1.5f, stroke);
|
||||||
|
char lbl[64];
|
||||||
|
FormatTxFrameLabel(e, lbl, sizeof(lbl));
|
||||||
|
DrawBoxLabel(r, lbl, stroke, /*topInside=*/true);
|
||||||
|
|
||||||
|
if (!suppressHover && mouseInBounds && CheckCollisionPointRec(m, r))
|
||||||
|
hoverEvent = i;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Layer 2: assertion_passed / assertion_failed (outline-dominant; a
|
||||||
|
// very light wash so failed regions still tint red without blanking the
|
||||||
|
// signal underneath) ----
|
||||||
|
for (int i = 0; i < app.annotations.eventCount; i++) {
|
||||||
|
const MlnlEvent* e = &app.annotations.events[i];
|
||||||
|
if (e->kind != MLNL_KIND_ASSERTION_PASSED && e->kind != MLNL_KIND_ASSERTION_FAILED)
|
||||||
|
continue;
|
||||||
|
if (e->kind < MLNL_KIND_MAX && !app.annotationKindEnabled[e->kind]) continue;
|
||||||
|
if (e->t_end <= e->t_start) continue;
|
||||||
|
Rectangle r;
|
||||||
|
if (!EventRect(bounds, e, duration, nyquist, &r)) continue;
|
||||||
|
Color c = EventColor(e);
|
||||||
|
unsigned char strokeA = AlphaForEvent(i, 255.0f);
|
||||||
|
unsigned char fillA = AlphaForEvent(i, 100.0f);
|
||||||
|
Color stroke = (Color){ c.r, c.g, c.b, strokeA };
|
||||||
|
DrawRectangleRec(r, (Color){ c.r, c.g, c.b, fillA });
|
||||||
|
DrawRectangleLinesEx(r, 1.0f, stroke);
|
||||||
|
DrawBoxLabel(r, e->has_note ? e->note : (e->has_name ? e->name : e->kindStr),
|
||||||
|
stroke, /*topInside=*/false);
|
||||||
|
|
||||||
|
if (!suppressHover && mouseInBounds && CheckCollisionPointRec(m, r))
|
||||||
|
hoverEvent = i;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Layer 3: point markers (vertical lines for zero-width events) ----
|
||||||
|
float labelStaggerY = 0.0f;
|
||||||
|
for (int i = 0; i < app.annotations.eventCount; i++) {
|
||||||
|
const MlnlEvent* e = &app.annotations.events[i];
|
||||||
|
if (!IsPointEvent(e)) continue;
|
||||||
|
if (e->kind < MLNL_KIND_MAX && !app.annotationKindEnabled[e->kind]) continue;
|
||||||
|
|
||||||
|
double tFrac = e->t_start / duration;
|
||||||
|
if (tFrac < app.view.start || tFrac > app.view.end) continue;
|
||||||
|
float x = bounds.x + (float)((tFrac - app.view.start) /
|
||||||
|
(app.view.end - app.view.start)) * bounds.width;
|
||||||
|
|
||||||
|
Color c = EventColor(e);
|
||||||
|
unsigned char strokeA = AlphaForEvent(i, 255.0f);
|
||||||
|
Color stroke = (Color){ c.r, c.g, c.b, strokeA };
|
||||||
|
DrawLine((int)x, (int)bounds.y, (int)x, (int)(bounds.y + bounds.height), Fade(stroke, 0.6f));
|
||||||
|
DrawCircleLines((int)x, (int)bounds.y + 6, 3, stroke);
|
||||||
|
|
||||||
|
const char* lbl = e->has_note ? e->note :
|
||||||
|
e->has_command ? e->command :
|
||||||
|
e->has_name ? e->name : e->kindStr;
|
||||||
|
float lblScale = GetUIScale();
|
||||||
|
float lblFs = 10.0f;
|
||||||
|
float lblLineH = lblFs * lblScale + 2;
|
||||||
|
int labelY = (int)(bounds.y + 14 * lblScale + labelStaggerY);
|
||||||
|
int labelMaxW = (int)(bounds.x + bounds.width) - ((int)x + 4);
|
||||||
|
if (labelMaxW > 8) {
|
||||||
|
BeginScissorMode((int)x + 4, labelY, labelMaxW, (int)lblLineH);
|
||||||
|
DrawTextScaled(lbl, (int)x + 4, labelY, lblFs, stroke);
|
||||||
|
EndScissorMode();
|
||||||
|
}
|
||||||
|
labelStaggerY = (labelStaggerY > 24.0f * lblScale) ? 0.0f : labelStaggerY + lblLineH;
|
||||||
|
|
||||||
|
if (!suppressHover && mouseInBounds &&
|
||||||
|
m.x >= x - 4 && m.x <= x + 4 &&
|
||||||
|
m.y >= bounds.y && m.y <= bounds.y + bounds.height) {
|
||||||
|
hoverEvent = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
app.hoveredEvent = hoverEvent;
|
||||||
|
|
||||||
|
// ---- Tooltip ---- Timeline hover takes priority over spectrogram hover
|
||||||
|
// (the lane is the active surface when you're hovering it).
|
||||||
|
int tipFor = (app.hoveredTimelineEvent >= 0) ? app.hoveredTimelineEvent : hoverEvent;
|
||||||
|
if (tipFor >= 0) {
|
||||||
|
const MlnlEvent* e = &app.annotations.events[tipFor];
|
||||||
|
char lines[12][96];
|
||||||
|
int n = BuildEventLines(e, lines, 12);
|
||||||
|
DrawTooltip(bounds, m, lines, n, EventColor(e));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (app.annotations.truncated) {
|
||||||
|
const char* msg = "mLnL: truncated";
|
||||||
|
float fs = 10.0f;
|
||||||
|
float w = MeasureTextScaled(msg, fs);
|
||||||
|
float h = fs * GetUIScale() + 6;
|
||||||
|
DrawRectangle((int)(bounds.x + bounds.width - w - 14), (int)(bounds.y + 4),
|
||||||
|
(int)(w + 10), (int)h, (Color){ 60, 0, 0, 200 });
|
||||||
|
DrawTextScaled(msg, bounds.x + bounds.width - w - 9, bounds.y + 7,
|
||||||
|
fs, (Color){ 255, 200, 200, 255 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Annotation overlay on the waveform scope (time-axis only)
|
||||||
|
// ============================================================================
|
||||||
|
//
|
||||||
|
// The scope has no frequency axis so annotations render as full-height time
|
||||||
|
// bands (tx_burst + assertion) or vertical lines (point events). No hit-test:
|
||||||
|
// hover/select still happens via the timeline lane, this is purely a visual
|
||||||
|
// echo so the user can correlate the spectrogram and waveform views.
|
||||||
|
//
|
||||||
|
// Reuses AlphaForEvent / EventColor / IsPointEvent so the spectrogram and
|
||||||
|
// scope move together — selecting an event in the timeline lights it on both.
|
||||||
|
|
||||||
|
void DrawAnnotationsOnScope(Rectangle bounds)
|
||||||
|
{
|
||||||
|
if (!app.loaded || !app.annotations.loaded) return;
|
||||||
|
if (!app.showAnnotations) return;
|
||||||
|
if (app.signal.duration <= 0.0f) return;
|
||||||
|
if (bounds.width < 4 || bounds.height < 4) return;
|
||||||
|
|
||||||
|
double duration = app.signal.duration;
|
||||||
|
double viewW = app.view.end - app.view.start;
|
||||||
|
if (viewW <= 0.0) return;
|
||||||
|
|
||||||
|
// Map a time fraction (0..1) to a screen x within the scope bounds.
|
||||||
|
#define SC_X(tFrac) (bounds.x + (float)(((tFrac) - app.view.start) / viewW) * bounds.width)
|
||||||
|
|
||||||
|
// ---- Layer 1: tx_burst (full-height band) ----
|
||||||
|
for (int i = 0; i < app.annotations.eventCount; i++) {
|
||||||
|
const MlnlEvent* e = &app.annotations.events[i];
|
||||||
|
if (e->kind != MLNL_KIND_TX_BURST) continue;
|
||||||
|
if (e->kind < MLNL_KIND_MAX && !app.annotationKindEnabled[e->kind]) continue;
|
||||||
|
double t0f = e->t_start / duration, t1f = e->t_end / duration;
|
||||||
|
if (t1f < app.view.start || t0f > app.view.end) continue;
|
||||||
|
float x0 = SC_X(t0f), x1 = SC_X(t1f);
|
||||||
|
if (x0 < bounds.x) x0 = bounds.x;
|
||||||
|
if (x1 > bounds.x + bounds.width) x1 = bounds.x + bounds.width;
|
||||||
|
if (x1 - x0 < 1) continue;
|
||||||
|
|
||||||
|
Color c = EventColor(e);
|
||||||
|
unsigned char fillA = AlphaForEvent(i, 200.0f);
|
||||||
|
unsigned char strokeA = AlphaForEvent(i, 255.0f);
|
||||||
|
DrawRectangle((int)x0, (int)bounds.y, (int)(x1 - x0), (int)bounds.height,
|
||||||
|
(Color){ c.r, c.g, c.b, fillA });
|
||||||
|
// Hairlines top & bottom so the band reads as deliberate framing
|
||||||
|
// rather than tinted background, even at low alpha.
|
||||||
|
DrawLine((int)x0, (int)bounds.y, (int)x1, (int)bounds.y,
|
||||||
|
(Color){ c.r, c.g, c.b, strokeA });
|
||||||
|
DrawLine((int)x0, (int)(bounds.y + bounds.height - 1),
|
||||||
|
(int)x1, (int)(bounds.y + bounds.height - 1),
|
||||||
|
(Color){ c.r, c.g, c.b, strokeA });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Layer 1b: tx_frame (full-height band) ----
|
||||||
|
for (int i = 0; i < app.annotations.eventCount; i++) {
|
||||||
|
const MlnlEvent* e = &app.annotations.events[i];
|
||||||
|
if (e->kind != MLNL_KIND_TX_FRAME) continue;
|
||||||
|
if (e->kind < MLNL_KIND_MAX && !app.annotationKindEnabled[e->kind]) continue;
|
||||||
|
double t0f = e->t_start / duration, t1f = e->t_end / duration;
|
||||||
|
if (t1f < app.view.start || t0f > app.view.end) continue;
|
||||||
|
float x0 = SC_X(t0f), x1 = SC_X(t1f);
|
||||||
|
if (x0 < bounds.x) x0 = bounds.x;
|
||||||
|
if (x1 > bounds.x + bounds.width) x1 = bounds.x + bounds.width;
|
||||||
|
if (x1 - x0 < 1) continue;
|
||||||
|
|
||||||
|
Color c = EventColor(e);
|
||||||
|
unsigned char fillA = AlphaForEvent(i, 200.0f);
|
||||||
|
unsigned char strokeA = AlphaForEvent(i, 255.0f);
|
||||||
|
DrawRectangle((int)x0, (int)bounds.y, (int)(x1 - x0), (int)bounds.height,
|
||||||
|
(Color){ c.r, c.g, c.b, fillA });
|
||||||
|
DrawLine((int)x0, (int)bounds.y, (int)x1, (int)bounds.y,
|
||||||
|
(Color){ c.r, c.g, c.b, strokeA });
|
||||||
|
DrawLine((int)x0, (int)(bounds.y + bounds.height - 1),
|
||||||
|
(int)x1, (int)(bounds.y + bounds.height - 1),
|
||||||
|
(Color){ c.r, c.g, c.b, strokeA });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Layer 2: assertion outlines (full-height) ----
|
||||||
|
for (int i = 0; i < app.annotations.eventCount; i++) {
|
||||||
|
const MlnlEvent* e = &app.annotations.events[i];
|
||||||
|
if (e->kind != MLNL_KIND_ASSERTION_PASSED && e->kind != MLNL_KIND_ASSERTION_FAILED)
|
||||||
|
continue;
|
||||||
|
if (e->kind < MLNL_KIND_MAX && !app.annotationKindEnabled[e->kind]) continue;
|
||||||
|
if (e->t_end <= e->t_start) continue;
|
||||||
|
double t0f = e->t_start / duration, t1f = e->t_end / duration;
|
||||||
|
if (t1f < app.view.start || t0f > app.view.end) continue;
|
||||||
|
float x0 = SC_X(t0f), x1 = SC_X(t1f);
|
||||||
|
if (x0 < bounds.x) x0 = bounds.x;
|
||||||
|
if (x1 > bounds.x + bounds.width) x1 = bounds.x + bounds.width;
|
||||||
|
if (x1 - x0 < 1) continue;
|
||||||
|
|
||||||
|
Color c = EventColor(e);
|
||||||
|
unsigned char fillA = AlphaForEvent(i, 100.0f);
|
||||||
|
unsigned char strokeA = AlphaForEvent(i, 255.0f);
|
||||||
|
Rectangle r = { x0, bounds.y, x1 - x0, bounds.height };
|
||||||
|
DrawRectangleRec(r, (Color){ c.r, c.g, c.b, fillA });
|
||||||
|
DrawRectangleLinesEx(r, 1, (Color){ c.r, c.g, c.b, strokeA });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Layer 3: point events as vertical lines ----
|
||||||
|
for (int i = 0; i < app.annotations.eventCount; i++) {
|
||||||
|
const MlnlEvent* e = &app.annotations.events[i];
|
||||||
|
if (!IsPointEvent(e)) continue;
|
||||||
|
if (e->kind < MLNL_KIND_MAX && !app.annotationKindEnabled[e->kind]) continue;
|
||||||
|
double t0f = e->t_start / duration;
|
||||||
|
if (t0f < app.view.start || t0f > app.view.end) continue;
|
||||||
|
float x = SC_X(t0f);
|
||||||
|
Color c = EventColor(e);
|
||||||
|
unsigned char strokeA = AlphaForEvent(i, 255.0f);
|
||||||
|
DrawLine((int)x, (int)bounds.y, (int)x, (int)(bounds.y + bounds.height),
|
||||||
|
Fade((Color){ c.r, c.g, c.b, strokeA }, 0.6f));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Hover hit-test + tooltip ----
|
||||||
|
// The scope only carries a time axis, so a hit is purely horizontal: the
|
||||||
|
// mouse x falls inside an event's band (range events) or near its line
|
||||||
|
// (point events). Narrowest match wins so a short event inside a wide band
|
||||||
|
// is still reachable. Pops the same detail tooltip as the spectrogram, so
|
||||||
|
// the sched-offset / metadata is available from either view.
|
||||||
|
Vector2 m = GetMousePosition();
|
||||||
|
bool suppress = app.sel.isDragging || app.sel.isTimeSelecting || app.sel.isFreqSelecting ||
|
||||||
|
app.view.isPanning || app.marker.dragging;
|
||||||
|
if (!suppress && CheckCollisionPointRec(m, bounds)) {
|
||||||
|
int hover = -1;
|
||||||
|
double bestW = 1e18;
|
||||||
|
for (int i = 0; i < app.annotations.eventCount; i++) {
|
||||||
|
const MlnlEvent* e = &app.annotations.events[i];
|
||||||
|
if (e->kind < MLNL_KIND_MAX && !app.annotationKindEnabled[e->kind]) continue;
|
||||||
|
if (IsPointEvent(e)) {
|
||||||
|
double t0f = e->t_start / duration;
|
||||||
|
if (t0f < app.view.start || t0f > app.view.end) continue;
|
||||||
|
float x = SC_X(t0f);
|
||||||
|
if (m.x >= x - 4 && m.x <= x + 4) { bestW = 0.0; hover = i; }
|
||||||
|
} else if (e->kind == MLNL_KIND_TX_BURST || e->kind == MLNL_KIND_TX_FRAME ||
|
||||||
|
e->kind == MLNL_KIND_ASSERTION_PASSED || e->kind == MLNL_KIND_ASSERTION_FAILED) {
|
||||||
|
double t0f = e->t_start / duration, t1f = e->t_end / duration;
|
||||||
|
if (t1f < app.view.start || t0f > app.view.end) continue;
|
||||||
|
float x0 = SC_X(t0f), x1 = SC_X(t1f);
|
||||||
|
if (m.x >= x0 && m.x <= x1) {
|
||||||
|
double w = t1f - t0f;
|
||||||
|
if (w <= bestW) { bestW = w; hover = i; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (hover >= 0) {
|
||||||
|
const MlnlEvent* e = &app.annotations.events[hover];
|
||||||
|
char lines[12][96];
|
||||||
|
int n = BuildEventLines(e, lines, 12);
|
||||||
|
DrawTooltip(bounds, m, lines, n, EventColor(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#undef SC_X
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Timeline lane
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// Count enabled-and-present annotation kinds. Mirrors the helper in
|
||||||
|
// spectrogram.c — kept local because static functions don't cross translation
|
||||||
|
// units; if it grows beyond two callers move it into a shared module.
|
||||||
|
static int CountVisibleAnnotationKindsLocal(void)
|
||||||
|
{
|
||||||
|
int n = 0;
|
||||||
|
for (int k = 0; k < MLNL_KIND_MAX; k++)
|
||||||
|
if (app.annotations.kindPresent[k] && app.annotationKindEnabled[k]) n++;
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort items by event duration descending: widest first, narrowest last.
|
||||||
|
// We draw in that order so the narrowest event ends up on top, and our
|
||||||
|
// hit-test (which keeps the *last* hit) naturally picks the narrowest event
|
||||||
|
// under the cursor — exactly what the user asked for so big chunks can't
|
||||||
|
// wash out short events sitting inside them.
|
||||||
|
typedef struct { int idx; double dur; } TlSortItem;
|
||||||
|
static int CmpTlSortDesc(const void* a, const void* b)
|
||||||
|
{
|
||||||
|
double da = ((const TlSortItem*)a)->dur, db = ((const TlSortItem*)b)->dur;
|
||||||
|
if (da > db) return -1;
|
||||||
|
if (da < db) return 1;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Row index (0..numRows-1) for a kind in the expanded timeline. -1 if hidden.
|
||||||
|
static int TimelineRowForKind(int kind)
|
||||||
|
{
|
||||||
|
if (kind < 0 || kind >= MLNL_KIND_MAX) return -1;
|
||||||
|
if (!app.annotations.kindPresent[kind] || !app.annotationKindEnabled[kind]) return -1;
|
||||||
|
int r = 0;
|
||||||
|
for (int k = 0; k < kind; k++)
|
||||||
|
if (app.annotations.kindPresent[k] && app.annotationKindEnabled[k]) r++;
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
void DrawTimeline(Rectangle lane)
|
||||||
|
{
|
||||||
|
if (!app.annotations.loaded || !app.showAnnotations) return;
|
||||||
|
if (app.annotations.eventCount == 0) return;
|
||||||
|
if (lane.width < 8 || lane.height < 4) return;
|
||||||
|
|
||||||
|
// Background. Slightly different from the spectrogram so the lane reads
|
||||||
|
// as a separate UI surface rather than blending into the viewport.
|
||||||
|
DrawRectangleRec(lane, (Color){ 8, 10, 14, 235 });
|
||||||
|
DrawRectangleLinesEx(lane, 1, Fade(GRAY, 0.35f));
|
||||||
|
|
||||||
|
double duration = app.signal.duration;
|
||||||
|
if (duration <= 0) return;
|
||||||
|
|
||||||
|
// Reserve a narrow chevron strip on the right for the expand/collapse toggle.
|
||||||
|
float chevW = 14.0f * GetUIScale();
|
||||||
|
Rectangle chev = { lane.x + lane.width - chevW - 2, lane.y, chevW, lane.height };
|
||||||
|
Rectangle plot = { lane.x + 2, lane.y + 1, lane.width - chevW - 6, lane.height - 2 };
|
||||||
|
|
||||||
|
Vector2 m = GetMousePosition();
|
||||||
|
bool mouseInLane = CheckCollisionPointRec(m, lane);
|
||||||
|
bool mouseInPlot = CheckCollisionPointRec(m, plot);
|
||||||
|
bool mouseInChev = CheckCollisionPointRec(m, chev);
|
||||||
|
|
||||||
|
app.hoveredTimelineEvent = -1;
|
||||||
|
|
||||||
|
// Build a list of visible event indices (those not filtered by the
|
||||||
|
// per-kind checkboxes) sorted by duration descending so we draw widest
|
||||||
|
// first / narrowest last (last hit wins on hover).
|
||||||
|
int eventCount = app.annotations.eventCount;
|
||||||
|
TlSortItem* items = (TlSortItem*)malloc((size_t)eventCount * sizeof(TlSortItem));
|
||||||
|
if (!items) return;
|
||||||
|
int nVisible = 0;
|
||||||
|
for (int i = 0; i < eventCount; i++) {
|
||||||
|
const MlnlEvent* e = &app.annotations.events[i];
|
||||||
|
if (e->kind < MLNL_KIND_MAX && !app.annotationKindEnabled[e->kind]) continue;
|
||||||
|
items[nVisible].idx = i;
|
||||||
|
items[nVisible].dur = e->t_end - e->t_start;
|
||||||
|
nVisible++;
|
||||||
|
}
|
||||||
|
qsort(items, nVisible, sizeof(TlSortItem), CmpTlSortDesc);
|
||||||
|
|
||||||
|
// Helper: time (seconds) -> screen X within plot.
|
||||||
|
#define TL_X(t) (plot.x + (float)((t) / duration) * plot.width)
|
||||||
|
|
||||||
|
if (!app.timelineExpanded) {
|
||||||
|
// Single mixed strip. Each event is a colored rect spanning [t0,t1]
|
||||||
|
// (point events get a 2px tick). Narrowest-on-top is implicit in the
|
||||||
|
// sort order.
|
||||||
|
for (int k = 0; k < nVisible; k++) {
|
||||||
|
int i = items[k].idx;
|
||||||
|
const MlnlEvent* e = &app.annotations.events[i];
|
||||||
|
Color c = EventColor(e);
|
||||||
|
float x0 = TL_X(e->t_start);
|
||||||
|
float x1 = TL_X(e->t_end);
|
||||||
|
float w = x1 - x0;
|
||||||
|
if (w < 2.0f) w = 2.0f; // visibility minimum for narrow events
|
||||||
|
Rectangle r = { x0, plot.y, w, plot.height };
|
||||||
|
if (app.selectedAnnotation == i)
|
||||||
|
DrawRectangleLinesEx((Rectangle){r.x-1, r.y-1, r.width+2, r.height+2}, 1, WHITE);
|
||||||
|
DrawRectangleRec(r, c);
|
||||||
|
if (mouseInPlot && CheckCollisionPointRec(m, r))
|
||||||
|
app.hoveredTimelineEvent = i;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// One row per enabled kind. Draw row labels + separators first, then
|
||||||
|
// events on top (still narrowest-last).
|
||||||
|
int numRows = CountVisibleAnnotationKindsLocal();
|
||||||
|
if (numRows < 1) numRows = 1;
|
||||||
|
float rowH = plot.height / (float)numRows;
|
||||||
|
|
||||||
|
for (int kind = 0, row = 0; kind < MLNL_KIND_MAX; kind++) {
|
||||||
|
if (!app.annotations.kindPresent[kind] || !app.annotationKindEnabled[kind]) continue;
|
||||||
|
float ry = plot.y + row * rowH;
|
||||||
|
if (row > 0)
|
||||||
|
DrawLine((int)plot.x, (int)ry, (int)(plot.x + plot.width), (int)ry, Fade(GRAY, 0.2f));
|
||||||
|
DrawTextScaled(MlnlKindName((MlnlKind)kind), plot.x + 4, ry + 1, 9,
|
||||||
|
Fade(LIGHTGRAY, 0.7f));
|
||||||
|
row++;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int k = 0; k < nVisible; k++) {
|
||||||
|
int i = items[k].idx;
|
||||||
|
const MlnlEvent* e = &app.annotations.events[i];
|
||||||
|
int row = TimelineRowForKind(e->kind);
|
||||||
|
if (row < 0) continue;
|
||||||
|
Color c = EventColor(e);
|
||||||
|
float x0 = TL_X(e->t_start);
|
||||||
|
float x1 = TL_X(e->t_end);
|
||||||
|
float w = x1 - x0;
|
||||||
|
if (w < 2.0f) w = 2.0f;
|
||||||
|
float ry = plot.y + row * rowH + 1;
|
||||||
|
float rh = rowH - 2;
|
||||||
|
Rectangle r = { x0, ry, w, rh };
|
||||||
|
if (app.selectedAnnotation == i)
|
||||||
|
DrawRectangleLinesEx((Rectangle){r.x-1, r.y-1, r.width+2, r.height+2}, 1, WHITE);
|
||||||
|
DrawRectangleRec(r, c);
|
||||||
|
if (mouseInPlot && CheckCollisionPointRec(m, r))
|
||||||
|
app.hoveredTimelineEvent = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
free(items);
|
||||||
|
#undef TL_X
|
||||||
|
|
||||||
|
// Chevron toggle (separate hit area from the plot so it always responds).
|
||||||
|
if (mouseInChev) DrawRectangleRec(chev, Fade(GRAY, 0.25f));
|
||||||
|
float chevFs = 10.0f;
|
||||||
|
DrawTextScaled(app.timelineExpanded ? "v" : ">",
|
||||||
|
chev.x + 3, chev.y + chev.height * 0.5f - chevFs * GetUIScale() * 0.5f,
|
||||||
|
chevFs, LIGHTGRAY);
|
||||||
|
if (mouseInChev && IsMouseButtonPressed(MOUSE_LEFT_BUTTON))
|
||||||
|
app.timelineExpanded = !app.timelineExpanded;
|
||||||
|
|
||||||
|
// Click handling. In collapsed mode the lane is small + low-resolution,
|
||||||
|
// so any click in the plot expands rather than risking an accidental
|
||||||
|
// selection. Selection is an "expanded mode" gesture.
|
||||||
|
if (mouseInPlot && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) {
|
||||||
|
if (!app.timelineExpanded) {
|
||||||
|
app.timelineExpanded = true;
|
||||||
|
} else if (app.hoveredTimelineEvent >= 0) {
|
||||||
|
app.selectedAnnotation = (app.selectedAnnotation == app.hoveredTimelineEvent)
|
||||||
|
? -1 : app.hoveredTimelineEvent;
|
||||||
|
} else {
|
||||||
|
app.selectedAnnotation = -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subtle hint when hovering a collapsed lane (helps the user discover
|
||||||
|
// the expand affordance the first time they see annotations).
|
||||||
|
if (!app.timelineExpanded && mouseInLane) {
|
||||||
|
int kinds = CountVisibleAnnotationKindsLocal();
|
||||||
|
if (kinds > 0) {
|
||||||
|
char hint[48];
|
||||||
|
snprintf(hint, sizeof(hint), "%d kinds — click to expand", kinds);
|
||||||
|
float hintFs = 9.0f;
|
||||||
|
float w = MeasureTextScaled(hint, hintFs);
|
||||||
|
DrawTextScaled(hint, plot.x + plot.width - w - 4, plot.y - 1,
|
||||||
|
hintFs, Fade(LIGHTGRAY, 0.85f));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Playhead
|
// Playhead
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -29,5 +29,12 @@ 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);
|
||||||
|
void DrawAnnotations(Rectangle bounds);
|
||||||
|
// Annotation timeline lane. Updates app.hoveredTimelineEvent and
|
||||||
|
// app.selectedAnnotation in response to mouse interaction in `lane`.
|
||||||
|
void DrawTimeline(Rectangle lane);
|
||||||
|
// Time-only annotation overlay rendered on top of the waveform scope.
|
||||||
|
// Shares opacity/selection state with the spectrogram overlay.
|
||||||
|
void DrawAnnotationsOnScope(Rectangle scopeBounds);
|
||||||
|
|
||||||
#endif // RENDER_H
|
#endif // RENDER_H
|
||||||
|
|||||||
+484
-22
@@ -91,10 +91,22 @@ typedef struct {
|
|||||||
float vScrollbarWidth;
|
float vScrollbarWidth;
|
||||||
float topMargin;
|
float topMargin;
|
||||||
float bottomMargin;
|
float bottomMargin;
|
||||||
float spectroHeight; // height of the spectrogram (respects the scope divider)
|
float spectroHeight; // height of the spectrogram (respects the scope divider AND the timeline lane)
|
||||||
Rectangle viewBounds; // the spectrogram drawing area
|
float timelineHeight; // 0 if no annotations / lane hidden
|
||||||
|
Rectangle viewBounds; // the spectrogram drawing area
|
||||||
|
Rectangle timelineBounds;// the annotations timeline lane (zero-sized if not shown)
|
||||||
} Layout;
|
} Layout;
|
||||||
|
|
||||||
|
// Number of MlnlKind rows that should be visible in the expanded timeline:
|
||||||
|
// kinds present in this file AND not filtered out by the per-kind checkboxes.
|
||||||
|
static int CountVisibleAnnotationKinds(void)
|
||||||
|
{
|
||||||
|
int n = 0;
|
||||||
|
for (int k = 0; k < MLNL_KIND_MAX; k++)
|
||||||
|
if (app.annotations.kindPresent[k] && app.annotationKindEnabled[k]) n++;
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
static Layout ComputeLayout(void)
|
static Layout ComputeLayout(void)
|
||||||
{
|
{
|
||||||
Layout L;
|
Layout L;
|
||||||
@@ -107,12 +119,35 @@ static Layout ComputeLayout(void)
|
|||||||
L.topMargin = 50 * L.scale;
|
L.topMargin = 50 * L.scale;
|
||||||
L.bottomMargin = 10 * L.scale;
|
L.bottomMargin = 10 * L.scale;
|
||||||
L.spectroHeight = (GetScreenHeight() - L.topMargin - L.bottomMargin - L.labelHeight - L.scrollbarHeight - 10 * L.scale) * ScopeDivider();
|
L.spectroHeight = (GetScreenHeight() - L.topMargin - L.bottomMargin - L.labelHeight - L.scrollbarHeight - 10 * L.scale) * ScopeDivider();
|
||||||
|
|
||||||
|
// Timeline lane sits above the spectrogram, eating from spectro height
|
||||||
|
// (not from the scope area). Collapsed is a thin sparkline; expanded grows
|
||||||
|
// by one row per enabled kind. Only present when the file carries
|
||||||
|
// annotations and the master toggle is on.
|
||||||
|
L.timelineHeight = 0;
|
||||||
|
if (app.annotations.loaded && app.annotations.eventCount > 0 && app.showAnnotations) {
|
||||||
|
if (app.timelineExpanded) {
|
||||||
|
int rows = CountVisibleAnnotationKinds();
|
||||||
|
if (rows < 1) rows = 1;
|
||||||
|
L.timelineHeight = (rows * 14.0f + 4.0f) * L.scale;
|
||||||
|
} else {
|
||||||
|
L.timelineHeight = 10.0f * L.scale;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
float laneX = L.sidebarWidth + L.freqLabelWidth;
|
||||||
|
float laneW = GetScreenWidth() - L.sidebarWidth - L.freqLabelWidth - L.vScrollbarWidth - 20 * L.scale;
|
||||||
|
L.timelineBounds = (Rectangle){ laneX, L.topMargin, laneW, L.timelineHeight };
|
||||||
|
|
||||||
|
// Spectrogram starts below the lane (with a 2-pixel gap) and shrinks accordingly.
|
||||||
|
float gap = (L.timelineHeight > 0) ? 2.0f * L.scale : 0.0f;
|
||||||
L.viewBounds = (Rectangle){
|
L.viewBounds = (Rectangle){
|
||||||
L.sidebarWidth + L.freqLabelWidth,
|
laneX,
|
||||||
L.topMargin,
|
L.topMargin + L.timelineHeight + gap,
|
||||||
GetScreenWidth() - L.sidebarWidth - L.freqLabelWidth - L.vScrollbarWidth - 20 * L.scale,
|
laneW,
|
||||||
L.spectroHeight
|
L.spectroHeight - L.timelineHeight - gap
|
||||||
};
|
};
|
||||||
|
L.spectroHeight = L.viewBounds.height;
|
||||||
return L;
|
return L;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,6 +195,245 @@ void ResetForNewSignal(void)
|
|||||||
if (app.visibleTexture.id != 0) UnloadTexture(app.visibleTexture);
|
if (app.visibleTexture.id != 0) UnloadTexture(app.visibleTexture);
|
||||||
app.visibleTexture = (Texture2D){ 0 };
|
app.visibleTexture = (Texture2D){ 0 };
|
||||||
app.visibleTextureValid = false;
|
app.visibleTextureValid = false;
|
||||||
|
|
||||||
|
// Drop the previous file's annotations; the caller re-parses from the new
|
||||||
|
// path after this returns (LoadMlnlFromWav needs the source path that
|
||||||
|
// raylib's LoadWave already consumed).
|
||||||
|
FreeMlnl(&app.annotations);
|
||||||
|
app.hoveredEvent = -1;
|
||||||
|
app.hoveredTimelineEvent = -1;
|
||||||
|
app.selectedAnnotation = -1;
|
||||||
|
app.autocropPending = true; // run once when this file's STFT is ready
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Auto-crop: shrink the displayed freq axis + time view to where the data
|
||||||
|
// actually lives. Two independent sources, tried in priority order.
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// 15% headroom above the highest annotated f_hi keeps event boxes from
|
||||||
|
// touching the top edge; 5% time padding gives breathing room around the
|
||||||
|
// outermost events without pushing them into the corners.
|
||||||
|
#define AUTOCROP_FREQ_HEADROOM 1.15f
|
||||||
|
#define AUTOCROP_TIME_PAD_FRAC 0.05f
|
||||||
|
// Confidence thresholds for the energy heuristic. If the cropped freq band
|
||||||
|
// would still cover >80% of Nyquist, or the cropped time would cover >90%
|
||||||
|
// of the timeline, the signal genuinely uses most of the available range
|
||||||
|
// and we leave the view alone (the crop would only be churn).
|
||||||
|
#define AUTOCROP_FREQ_MAX_FRAC 0.80f
|
||||||
|
#define AUTOCROP_TIME_MAX_FRAC 0.90f
|
||||||
|
// Cumulative energy fraction that defines "where signal lives". 0.99 means
|
||||||
|
// the cropped freq range holds 99% of the spectrogram's total power.
|
||||||
|
#define AUTOCROP_FREQ_ENERGY 0.99
|
||||||
|
// Per-segment activity threshold (fraction of the peak segment's energy).
|
||||||
|
// Anything below this is treated as silence at the timeline edges.
|
||||||
|
#define AUTOCROP_TIME_ACTIVITY 0.01
|
||||||
|
|
||||||
|
// Annotation-driven crop: trusts the producer. Always confident when ≥1
|
||||||
|
// annotation has f_hi or any have a non-zero time span. Returns the computed
|
||||||
|
// crop in the out-params; leaves them at "no crop" values on failure.
|
||||||
|
static bool ComputeAnnotationCrop(float* outFreqMaxHz, float* outViewStart, float* outViewEnd)
|
||||||
|
{
|
||||||
|
*outFreqMaxHz = 0.0f;
|
||||||
|
*outViewStart = 0.0f; *outViewEnd = 1.0f;
|
||||||
|
if (!app.annotations.loaded || app.annotations.eventCount == 0) return false;
|
||||||
|
|
||||||
|
double fMax = 0.0;
|
||||||
|
double tMin = 1e18, tMax = -1e18;
|
||||||
|
bool anyFreq = false, anyTime = false;
|
||||||
|
for (int i = 0; i < app.annotations.eventCount; i++) {
|
||||||
|
const MlnlEvent* e = &app.annotations.events[i];
|
||||||
|
if (e->has_freq && e->f_hi_hz > fMax) { fMax = e->f_hi_hz; anyFreq = true; }
|
||||||
|
if (e->t_end >= e->t_start) {
|
||||||
|
if (e->t_start < tMin) tMin = e->t_start;
|
||||||
|
if (e->t_end > tMax) tMax = e->t_end;
|
||||||
|
anyTime = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (anyFreq) *outFreqMaxHz = (float)(fMax * AUTOCROP_FREQ_HEADROOM);
|
||||||
|
|
||||||
|
if (anyTime && app.signal.duration > 0.0f) {
|
||||||
|
double pad = (tMax - tMin) * AUTOCROP_TIME_PAD_FRAC;
|
||||||
|
double s = tMin - pad, e = tMax + pad;
|
||||||
|
if (s < 0.0) s = 0.0;
|
||||||
|
if (e > app.signal.duration) e = app.signal.duration;
|
||||||
|
if (e > s) {
|
||||||
|
*outViewStart = (float)(s / app.signal.duration);
|
||||||
|
*outViewEnd = (float)(e / app.signal.duration);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return anyFreq || anyTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Energy heuristic: walk the STFT, build per-bin and per-segment energy.
|
||||||
|
// Crop freq if 99% of energy fits below 80% of Nyquist; crop time if the
|
||||||
|
// activity envelope occupies <90% of the timeline. Returns true if at least
|
||||||
|
// one axis was confidently cropped.
|
||||||
|
static bool ComputeEnergyCrop(float* outFreqMaxHz, float* outViewStart, float* outViewEnd)
|
||||||
|
{
|
||||||
|
*outFreqMaxHz = 0.0f;
|
||||||
|
*outViewStart = 0.0f; *outViewEnd = 1.0f;
|
||||||
|
if (app.stft.numSegments < 2) return false;
|
||||||
|
int nbins = 0;
|
||||||
|
for (int s = 0; s < app.stft.numSegments; s++) {
|
||||||
|
if (app.stft.segments[s].spectrum && app.stft.segments[s].numBins > nbins)
|
||||||
|
nbins = app.stft.segments[s].numBins;
|
||||||
|
}
|
||||||
|
if (nbins < 4) return false;
|
||||||
|
|
||||||
|
int nsegs = app.stft.numSegments;
|
||||||
|
double* binE = (double*)calloc((size_t)nbins, sizeof(double));
|
||||||
|
double* segE = (double*)calloc((size_t)nsegs, sizeof(double));
|
||||||
|
if (!binE || !segE) { free(binE); free(segE); return false; }
|
||||||
|
|
||||||
|
double totalE = 0.0, segPeak = 0.0;
|
||||||
|
for (int s = 0; s < nsegs; s++) {
|
||||||
|
if (!app.stft.segments[s].spectrum) continue;
|
||||||
|
int nb = app.stft.segments[s].numBins;
|
||||||
|
for (int b = 0; b < nb; b++) {
|
||||||
|
double a = app.stft.segments[s].spectrum[b].amplitude;
|
||||||
|
double e = a * a;
|
||||||
|
binE[b] += e;
|
||||||
|
segE[s] += e;
|
||||||
|
totalE += e;
|
||||||
|
}
|
||||||
|
if (segE[s] > segPeak) segPeak = segE[s];
|
||||||
|
}
|
||||||
|
|
||||||
|
bool didCrop = false;
|
||||||
|
|
||||||
|
// --- Freq axis: smallest bin whose cumulative energy reaches 99%. ---
|
||||||
|
if (totalE > 0.0) {
|
||||||
|
double thr = totalE * AUTOCROP_FREQ_ENERGY;
|
||||||
|
double cum = 0.0;
|
||||||
|
int cropBin = nbins - 1;
|
||||||
|
for (int b = 0; b < nbins; b++) {
|
||||||
|
cum += binE[b];
|
||||||
|
if (cum >= thr) { cropBin = b; break; }
|
||||||
|
}
|
||||||
|
float fraction = (float)cropBin / (float)(nbins - 1);
|
||||||
|
if (fraction <= AUTOCROP_FREQ_MAX_FRAC) {
|
||||||
|
float nyq = app.signal.sampleRate * 0.5f;
|
||||||
|
*outFreqMaxHz = fraction * nyq * AUTOCROP_FREQ_HEADROOM;
|
||||||
|
didCrop = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Time axis: activity envelope at 1% of segment-peak energy. ---
|
||||||
|
if (segPeak > 0.0) {
|
||||||
|
double thr = segPeak * AUTOCROP_TIME_ACTIVITY;
|
||||||
|
int first = -1, last = -1;
|
||||||
|
for (int s = 0; s < nsegs; s++) {
|
||||||
|
if (segE[s] >= thr) { if (first < 0) first = s; last = s; }
|
||||||
|
}
|
||||||
|
if (first >= 0 && last > first) {
|
||||||
|
float coverage = (float)(last - first + 1) / (float)nsegs;
|
||||||
|
if (coverage <= AUTOCROP_TIME_MAX_FRAC) {
|
||||||
|
float s0 = (float)first / (float)nsegs;
|
||||||
|
float s1 = (float)(last + 1) / (float)nsegs;
|
||||||
|
float pad = (s1 - s0) * AUTOCROP_TIME_PAD_FRAC;
|
||||||
|
s0 -= pad; s1 += pad;
|
||||||
|
if (s0 < 0.0f) s0 = 0.0f;
|
||||||
|
if (s1 > 1.0f) s1 = 1.0f;
|
||||||
|
*outViewStart = s0;
|
||||||
|
*outViewEnd = s1;
|
||||||
|
didCrop = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
free(binE); free(segE);
|
||||||
|
return didCrop;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ApplyAutoCrop(void)
|
||||||
|
{
|
||||||
|
if (app.signal.sampleRate <= 0 || app.signal.duration <= 0.0f) return;
|
||||||
|
float nyq = app.signal.sampleRate * 0.5f;
|
||||||
|
|
||||||
|
// Compute BOTH heuristics for BOTH axes, then pick the more focused
|
||||||
|
// result per axis. Annotations can be authoritative for freq (the
|
||||||
|
// producer knows the band) yet wide for time (a single late `control`
|
||||||
|
// marker can span almost the whole file even if signal activity ended
|
||||||
|
// long before) — so we don't tie the time choice to the freq choice.
|
||||||
|
float aFreq = 0.0f, aStart = 0.0f, aEnd = 1.0f;
|
||||||
|
float eFreq = 0.0f, eStart = 0.0f, eEnd = 1.0f;
|
||||||
|
ComputeAnnotationCrop(&aFreq, &aStart, &aEnd);
|
||||||
|
ComputeEnergyCrop(&eFreq, &eStart, &eEnd);
|
||||||
|
|
||||||
|
// ---- Freq axis: smaller cropped max wins. ----
|
||||||
|
// Both candidates are 0 when the source didn't propose a crop; treat
|
||||||
|
// those as "didn't propose" rather than "crop to 0".
|
||||||
|
float freqMax = 0.0f;
|
||||||
|
const char* freqSrc = NULL;
|
||||||
|
if (aFreq > 0.0f && eFreq > 0.0f) {
|
||||||
|
if (eFreq < aFreq) { freqMax = eFreq; freqSrc = "energy"; }
|
||||||
|
else { freqMax = aFreq; freqSrc = "annotations"; }
|
||||||
|
} else if (aFreq > 0.0f) { freqMax = aFreq; freqSrc = "annotations"; }
|
||||||
|
else if (eFreq > 0.0f) { freqMax = eFreq; freqSrc = "energy"; }
|
||||||
|
|
||||||
|
// ---- Time axis: more focused (shorter) range wins. ----
|
||||||
|
// Each source's output is a 0..1 fraction of the signal duration; a
|
||||||
|
// value of [0..1] means "didn't crop". We bias against picking a source
|
||||||
|
// that's effectively the whole timeline.
|
||||||
|
bool aShrunk = (aEnd - aStart) < 0.999f;
|
||||||
|
bool eShrunk = (eEnd - eStart) < 0.999f;
|
||||||
|
float vStart = 0.0f, vEnd = 1.0f;
|
||||||
|
const char* timeSrc = NULL;
|
||||||
|
if (aShrunk && eShrunk) {
|
||||||
|
if ((eEnd - eStart) < (aEnd - aStart)) {
|
||||||
|
vStart = eStart; vEnd = eEnd; timeSrc = "energy";
|
||||||
|
} else {
|
||||||
|
vStart = aStart; vEnd = aEnd; timeSrc = "annotations";
|
||||||
|
}
|
||||||
|
} else if (aShrunk) { vStart = aStart; vEnd = aEnd; timeSrc = "annotations"; }
|
||||||
|
else if (eShrunk) { vStart = eStart; vEnd = eEnd; timeSrc = "energy"; }
|
||||||
|
|
||||||
|
bool freqChanged = (freqMax > 0.0f && freqMax < nyq * 0.99f);
|
||||||
|
bool timeChanged = (timeSrc != NULL);
|
||||||
|
|
||||||
|
if (!freqChanged && !timeChanged) {
|
||||||
|
TraceLog(LOG_INFO, "Auto-crop: no confident source, leaving view alone");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (freqChanged) {
|
||||||
|
if (freqMax > nyq) freqMax = nyq;
|
||||||
|
app.displayMaxFreqHz = freqMax;
|
||||||
|
}
|
||||||
|
if (timeChanged) {
|
||||||
|
app.view.start = vStart;
|
||||||
|
app.view.end = vEnd;
|
||||||
|
}
|
||||||
|
// Fit freq view to the cropped band; otherwise a prior zoom would
|
||||||
|
// double-zoom on top of the new crop.
|
||||||
|
app.view.freqStart = 0.0f;
|
||||||
|
app.view.freqEnd = 1.0f;
|
||||||
|
app.visibleTextureValid = false;
|
||||||
|
|
||||||
|
// Splash message — mention per-axis source separately when they diverge
|
||||||
|
// (e.g. freq from annotations, time from energy on a file with a stray
|
||||||
|
// late control event).
|
||||||
|
char freqPart[64] = "", timePart[64] = "";
|
||||||
|
if (freqChanged) snprintf(freqPart, sizeof(freqPart), "0-%.0f Hz", freqMax);
|
||||||
|
if (timeChanged) snprintf(timePart, sizeof(timePart), "%.2f-%.2f s",
|
||||||
|
vStart * app.signal.duration, vEnd * app.signal.duration);
|
||||||
|
char srcPart[80];
|
||||||
|
if (freqChanged && timeChanged && freqSrc && timeSrc && strcmp(freqSrc, timeSrc) != 0) {
|
||||||
|
snprintf(srcPart, sizeof(srcPart), "freq: %s, time: %s", freqSrc, timeSrc);
|
||||||
|
} else {
|
||||||
|
const char* s = freqSrc ? freqSrc : timeSrc;
|
||||||
|
snprintf(srcPart, sizeof(srcPart), "%s", s ? s : "auto");
|
||||||
|
}
|
||||||
|
snprintf(app.autocropNoticeMsg, sizeof(app.autocropNoticeMsg),
|
||||||
|
"View auto-cropped to %s%s%s (%s).",
|
||||||
|
freqPart,
|
||||||
|
(freqChanged && timeChanged) ? ", " : "",
|
||||||
|
timePart,
|
||||||
|
srcPart);
|
||||||
|
app.autocropNoticeActive = true;
|
||||||
|
TraceLog(LOG_INFO, "Auto-crop: %s", app.autocropNoticeMsg);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -234,6 +508,69 @@ static void DispatchKeymap(void)
|
|||||||
|
|
||||||
int main(int argc, char* argv[])
|
int main(int argc, char* argv[])
|
||||||
{
|
{
|
||||||
|
// ---- Command-line arguments ----
|
||||||
|
// Two modes:
|
||||||
|
// GUI: rspektrum [input.wav]
|
||||||
|
// Headless: rspektrum --render OUT.png INPUT.wav [options]
|
||||||
|
// The headless path computes the spectrogram, draws annotations, writes a
|
||||||
|
// PNG, and exits without ever showing a window (FLAG_WINDOW_HIDDEN keeps a
|
||||||
|
// GL context for rendering but puts nothing on screen).
|
||||||
|
const char* inputArg = NULL; // input WAV (positional)
|
||||||
|
const char* renderOut = NULL; // --render target; non-NULL => headless mode
|
||||||
|
bool headless = false;
|
||||||
|
int annoChoice = -1; // -1 = auto (show if present), 0 = off, 1 = on
|
||||||
|
float annoOpacity = -1.0f; // <0 = keep default; else override resting overlay alpha
|
||||||
|
bool paneOnly = false; // crop to the spectrogram pane (no sidebar/scope)
|
||||||
|
int reqW = 1280, reqH = 800; // headless output size
|
||||||
|
|
||||||
|
for (int i = 1; i < argc; i++) {
|
||||||
|
const char* a = argv[i];
|
||||||
|
if ((strcmp(a, "--render") == 0 || strcmp(a, "-r") == 0) && i + 1 < argc) {
|
||||||
|
renderOut = argv[++i];
|
||||||
|
headless = true;
|
||||||
|
} else if (strcmp(a, "--annotations") == 0 || strcmp(a, "-a") == 0) {
|
||||||
|
annoChoice = 1;
|
||||||
|
} else if (strcmp(a, "--no-annotations") == 0) {
|
||||||
|
annoChoice = 0;
|
||||||
|
} else if (strncmp(a, "--annotation-opacity=", 21) == 0) {
|
||||||
|
annoOpacity = (float)atof(a + 21);
|
||||||
|
} else if (strcmp(a, "--annotation-opacity") == 0 && i + 1 < argc) {
|
||||||
|
annoOpacity = (float)atof(argv[++i]);
|
||||||
|
} else if (strcmp(a, "--pane") == 0) {
|
||||||
|
paneOnly = true;
|
||||||
|
} else if (strcmp(a, "--width") == 0 && i + 1 < argc) {
|
||||||
|
reqW = atoi(argv[++i]);
|
||||||
|
} else if (strcmp(a, "--height") == 0 && i + 1 < argc) {
|
||||||
|
reqH = atoi(argv[++i]);
|
||||||
|
} else if (strcmp(a, "--help") == 0 || strcmp(a, "-h") == 0) {
|
||||||
|
printf(
|
||||||
|
"rspektrum - spectrogram viewer\n\n"
|
||||||
|
"Usage:\n"
|
||||||
|
" rspektrum [input.wav] open the GUI\n"
|
||||||
|
" rspektrum --render OUT.png INPUT.wav [opts] write a PNG headlessly\n\n"
|
||||||
|
"Headless options:\n"
|
||||||
|
" -r, --render OUT.png render a screenshot to OUT.png (no window)\n"
|
||||||
|
" -a, --annotations force the annotation overlay on\n"
|
||||||
|
" --no-annotations force the annotation overlay off\n"
|
||||||
|
" (default: shown when the WAV carries annotations)\n"
|
||||||
|
" --annotation-opacity=V resting overlay alpha 0..1 (default 0.06, faint)\n"
|
||||||
|
" --pane capture only the spectrogram pane (no sidebar/scope)\n"
|
||||||
|
" --width N output width (default 1280)\n"
|
||||||
|
" --height N output height (default 800)\n"
|
||||||
|
" -h, --help show this help\n");
|
||||||
|
return 0;
|
||||||
|
} else if (a[0] != '-') {
|
||||||
|
if (!inputArg) inputArg = a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (reqW < 16) reqW = 1280;
|
||||||
|
if (reqH < 16) reqH = 800;
|
||||||
|
if (annoOpacity > 1.0f) annoOpacity = 1.0f;
|
||||||
|
if (headless && !inputArg) {
|
||||||
|
fprintf(stderr, "rspektrum: --render requires an input WAV file\n");
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
#ifdef __EMSCRIPTEN__
|
#ifdef __EMSCRIPTEN__
|
||||||
// FLAG_WINDOW_HIGHDPI is buggy on the web backend: the Emscripten resize
|
// FLAG_WINDOW_HIGHDPI is buggy on the web backend: the Emscripten resize
|
||||||
// callback sets the screen size to window.innerWidth, but the GLFW window-
|
// callback sets the screen size to window.innerWidth, but the GLFW window-
|
||||||
@@ -244,9 +581,16 @@ int main(int argc, char* argv[])
|
|||||||
// resizes the canvas to the window when FLAG_WINDOW_RESIZABLE is set.
|
// resizes the canvas to the window when FLAG_WINDOW_RESIZABLE is set.
|
||||||
SetConfigFlags(FLAG_VSYNC_HINT | FLAG_WINDOW_RESIZABLE);
|
SetConfigFlags(FLAG_VSYNC_HINT | FLAG_WINDOW_RESIZABLE);
|
||||||
#else
|
#else
|
||||||
SetConfigFlags(FLAG_VSYNC_HINT | FLAG_WINDOW_RESIZABLE | FLAG_WINDOW_HIGHDPI);
|
if (headless) {
|
||||||
|
// Hidden window: keeps a GL context for rendering the frame, but
|
||||||
|
// nothing is ever shown. No HIGHDPI so the framebuffer matches the
|
||||||
|
// requested size exactly (LoadImageFromScreen reads it back 1:1).
|
||||||
|
SetConfigFlags(FLAG_WINDOW_HIDDEN);
|
||||||
|
} else {
|
||||||
|
SetConfigFlags(FLAG_VSYNC_HINT | FLAG_WINDOW_RESIZABLE | FLAG_WINDOW_HIGHDPI);
|
||||||
|
}
|
||||||
#endif
|
#endif
|
||||||
InitWindow(1280, 800, "Spectrogram Viewer");
|
InitWindow(headless ? reqW : 1280, headless ? reqH : 800, "Spectrogram Viewer");
|
||||||
SetTargetFPS(60);
|
SetTargetFPS(60);
|
||||||
SetTraceLogLevel(LOG_WARNING); // Suppress INFO texture logs
|
SetTraceLogLevel(LOG_WARNING); // Suppress INFO texture logs
|
||||||
InitAudioDevice();
|
InitAudioDevice();
|
||||||
@@ -309,6 +653,18 @@ int main(int argc, char* argv[])
|
|||||||
}
|
}
|
||||||
app.isPlaying = false;
|
app.isPlaying = false;
|
||||||
app.playbackFinished = false;
|
app.playbackFinished = false;
|
||||||
|
app.displayMaxFreqHz = 0.0f; // 0 = no crop; user sets via sidebar slider
|
||||||
|
app.showAnnotations = true;
|
||||||
|
app.annotationsExpanded = false;
|
||||||
|
app.annotationOpacityBase = 0.06f; // whisper-faint by default — signal wins
|
||||||
|
app.annotationOpacityHover = 0.65f; // pop on hover / selection
|
||||||
|
// CLI override: there's no hover in a headless render, so the resting alpha
|
||||||
|
// governs every overlay — bump it to make annotations read in the PNG.
|
||||||
|
if (annoOpacity >= 0.0f) app.annotationOpacityBase = annoOpacity;
|
||||||
|
app.timelineExpanded = false;
|
||||||
|
app.hoveredTimelineEvent = -1;
|
||||||
|
app.selectedAnnotation = -1;
|
||||||
|
for (int i = 0; i < MLNL_KIND_MAX; i++) app.annotationKindEnabled[i] = true;
|
||||||
app.showScope = true;
|
app.showScope = true;
|
||||||
app.dividerY = 0.6f; // Start with 60% spectro, 40% scope
|
app.dividerY = 0.6f; // Start with 60% spectro, 40% scope
|
||||||
app.isDividing = false;
|
app.isDividing = false;
|
||||||
@@ -326,27 +682,71 @@ int main(int argc, char* argv[])
|
|||||||
TraceLog(LOG_INFO, "Spectrogram Viewer initialized");
|
TraceLog(LOG_INFO, "Spectrogram Viewer initialized");
|
||||||
|
|
||||||
bool fileLoaded = false;
|
bool fileLoaded = false;
|
||||||
if (argc > 1) {
|
if (inputArg) {
|
||||||
TraceLog(LOG_INFO, "Loading file from command line: %s", argv[1]);
|
TraceLog(LOG_INFO, "Loading file from command line: %s", inputArg);
|
||||||
char resolvedPath[8192] = { 0 };
|
char resolvedPath[8192] = { 0 };
|
||||||
|
|
||||||
// If the path doesn't exist as-is, try prepending original dir
|
// If the path doesn't exist as-is, try prepending original dir
|
||||||
if (!FileExists(argv[1]) && originalDir[0]) {
|
if (!FileExists(inputArg) && originalDir[0]) {
|
||||||
snprintf(resolvedPath, sizeof(resolvedPath), "%s/%s", originalDir, argv[1]);
|
snprintf(resolvedPath, sizeof(resolvedPath), "%s/%s", originalDir, inputArg);
|
||||||
TraceLog(LOG_INFO, "Trying prepended path: %s", resolvedPath);
|
TraceLog(LOG_INFO, "Trying prepended path: %s", resolvedPath);
|
||||||
}
|
}
|
||||||
const char* pathToLoad = FileExists(argv[1]) ? argv[1] : resolvedPath;
|
const char* pathToLoad = FileExists(inputArg) ? inputArg : resolvedPath;
|
||||||
|
|
||||||
if (FileExists(pathToLoad) && LoadWavFile(pathToLoad, &app.signal)) {
|
if (FileExists(pathToLoad) && LoadWavFile(pathToLoad, &app.signal)) {
|
||||||
fileLoaded = true;
|
fileLoaded = true;
|
||||||
ResetForNewSignal();
|
ResetForNewSignal();
|
||||||
|
LoadMlnlFromWav(pathToLoad, &app.annotations);
|
||||||
TraceLog(LOG_INFO, "File loaded successfully");
|
TraceLog(LOG_INFO, "File loaded successfully");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!fileLoaded) TraceLog(LOG_INFO, "Press 'O' for file browser or drag & drop WAV file");
|
if (!fileLoaded) TraceLog(LOG_INFO, "Press 'O' for file browser or drag & drop WAV file");
|
||||||
|
|
||||||
while (!WindowShouldClose())
|
// ---- Headless render setup ----
|
||||||
|
// Compute the spectrogram synchronously here; the frame is drawn and
|
||||||
|
// captured at the bottom of the (single-pass) main loop below. headlessRc
|
||||||
|
// gates the loop: a load failure skips it entirely.
|
||||||
|
int headlessRc = 0;
|
||||||
|
char headlessOut[8192] = { 0 };
|
||||||
|
if (headless) {
|
||||||
|
if (!fileLoaded) {
|
||||||
|
fprintf(stderr, "rspektrum: failed to load input WAV '%s'\n", inputArg);
|
||||||
|
headlessRc = 1;
|
||||||
|
} else {
|
||||||
|
// Resolve the output path relative to the launch dir (CWD was
|
||||||
|
// changed to resources/ by SearchAndSetResourceDir).
|
||||||
|
if (renderOut[0] == '/') {
|
||||||
|
snprintf(headlessOut, sizeof(headlessOut), "%s", renderOut);
|
||||||
|
} else {
|
||||||
|
snprintf(headlessOut, sizeof(headlessOut), "%s/%s", originalDir, renderOut);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (annoChoice == 0) app.showAnnotations = false;
|
||||||
|
else if (annoChoice == 1) app.showAnnotations = true;
|
||||||
|
|
||||||
|
// Compute the full-resolution STFT in one shot (no incremental /
|
||||||
|
// background passes — there is no interactive loop to spread them
|
||||||
|
// over). Mirrors the Emscripten single-shot path above.
|
||||||
|
ComputeSTFTInit(&app.signal, &app.stft, app.fftSize);
|
||||||
|
app.skipFactor = 1;
|
||||||
|
ComputeSTFTIncremental(&app.signal, &app.stft, app.fftSize, 0);
|
||||||
|
AutoScaleAmplitude(&app.stft);
|
||||||
|
GenerateSpectrogramTexture(&app.stft, &app.spectrogramImage, &app.spectrogramTexture);
|
||||||
|
app.currentSTFTSegment = app.stft.numSegments;
|
||||||
|
app.bgHighResSeg = app.stft.numSegments;
|
||||||
|
app.stftComputed = true;
|
||||||
|
app.highResFinished = true;
|
||||||
|
app.bgFinished = true;
|
||||||
|
app.isBgProcessing = false;
|
||||||
|
app.loadingPhase = 0;
|
||||||
|
if (app.autocropPending) { ApplyAutoCrop(); app.autocropPending = false; }
|
||||||
|
app.autocropNoticeActive = false; // don't draw the crop splash into the shot
|
||||||
|
app.exportMessage[0] = '\0';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
while (!WindowShouldClose() && headlessRc == 0)
|
||||||
{
|
{
|
||||||
#ifdef __EMSCRIPTEN__
|
#ifdef __EMSCRIPTEN__
|
||||||
// Track the browser viewport (fill + reflow on resize, like desktop).
|
// Track the browser viewport (fill + reflow on resize, like desktop).
|
||||||
@@ -362,6 +762,7 @@ int main(int argc, char* argv[])
|
|||||||
if (isWav && FileExists(dropped.paths[0])) {
|
if (isWav && FileExists(dropped.paths[0])) {
|
||||||
if (LoadWavFile(dropped.paths[0], &app.signal)) {
|
if (LoadWavFile(dropped.paths[0], &app.signal)) {
|
||||||
ResetForNewSignal();
|
ResetForNewSignal();
|
||||||
|
LoadMlnlFromWav(dropped.paths[0], &app.annotations);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -606,7 +1007,12 @@ int main(int argc, char* argv[])
|
|||||||
Vector2 mousePos = GetMousePosition();
|
Vector2 mousePos = GetMousePosition();
|
||||||
|
|
||||||
// Calculate divider screen position (for hover detection)
|
// Calculate divider screen position (for hover detection)
|
||||||
float dividerScreenY = selTopMargin + selSpectroHeight;
|
// Divider is drawn at the BOTTOM of the spectrogram viewport. With the
|
||||||
|
// timeline lane occupying space above the viewport, viewBounds.y was
|
||||||
|
// shifted down — so the divider's actual screen Y is `viewBounds.y +
|
||||||
|
// viewBounds.height`, NOT `topMargin + spectroHeight` (those two used
|
||||||
|
// to be equal before the lane existed).
|
||||||
|
float dividerScreenY = selBounds.y + selBounds.height;
|
||||||
bool mouseNearDivider = mousePos.y >= (dividerScreenY - 5) && mousePos.y <= (dividerScreenY + 5) &&
|
bool mouseNearDivider = mousePos.y >= (dividerScreenY - 5) && mousePos.y <= (dividerScreenY + 5) &&
|
||||||
mousePos.x >= selBounds.x && mousePos.x <= selBounds.x + selBounds.width;
|
mousePos.x >= selBounds.x && mousePos.x <= selBounds.x + selBounds.width;
|
||||||
|
|
||||||
@@ -817,6 +1223,7 @@ int main(int argc, char* argv[])
|
|||||||
app.isBgProcessing = false;
|
app.isBgProcessing = false;
|
||||||
app.loadingPhase = 0;
|
app.loadingPhase = 0;
|
||||||
SaveToCache();
|
SaveToCache();
|
||||||
|
if (app.autocropPending) { ApplyAutoCrop(); app.autocropPending = false; }
|
||||||
#else
|
#else
|
||||||
if (app.loadingPhase == 0) {
|
if (app.loadingPhase == 0) {
|
||||||
// Initialize STFT once
|
// Initialize STFT once
|
||||||
@@ -855,6 +1262,11 @@ int main(int argc, char* argv[])
|
|||||||
app.stft.numSegments, app.skipFactor);
|
app.stft.numSegments, app.skipFactor);
|
||||||
// Save the overview result to cache (will be overwritten when full-res completes)
|
// Save the overview result to cache (will be overwritten when full-res completes)
|
||||||
SaveToCache();
|
SaveToCache();
|
||||||
|
// Run auto-crop now that we have both annotations (loaded right
|
||||||
|
// after LoadWavFile) AND an STFT (for the energy fallback).
|
||||||
|
// Gated on autocropPending so an FFT-size change (which routes
|
||||||
|
// through the same loadingPhase=2 block) doesn't re-fire it.
|
||||||
|
if (app.autocropPending) { ApplyAutoCrop(); app.autocropPending = false; }
|
||||||
}
|
}
|
||||||
#endif // __EMSCRIPTEN__
|
#endif // __EMSCRIPTEN__
|
||||||
}
|
}
|
||||||
@@ -950,9 +1362,14 @@ int main(int argc, char* argv[])
|
|||||||
int visibleEndX = (int)(app.view.end * imgWidth);
|
int visibleEndX = (int)(app.view.end * imgWidth);
|
||||||
int visibleWidth = visibleEndX - visibleStartX;
|
int visibleWidth = visibleEndX - visibleStartX;
|
||||||
|
|
||||||
// Frequency: 0 = bottom of image (bin 0), 1 = top of image (bin max)
|
// Frequency: 0 = bottom of image (bin 0), 1 = top of image (bin max).
|
||||||
int visibleStartY = (int)((1.0f - app.view.freqEnd) * imgHeight);
|
// The display-crop slider maps view.freqStart/End=1.0 to a fraction
|
||||||
int visibleEndY = (int)((1.0f - app.view.freqStart) * imgHeight);
|
// of the texture's height < 1.0, effectively zooming the freq axis
|
||||||
|
// so the cropped band fills the viewport while leaving the source
|
||||||
|
// texture untouched.
|
||||||
|
float cropFrac = DisplayFreqFraction();
|
||||||
|
int visibleStartY = (int)((1.0f - app.view.freqEnd * cropFrac) * imgHeight);
|
||||||
|
int visibleEndY = (int)((1.0f - app.view.freqStart * cropFrac) * imgHeight);
|
||||||
int visibleHeight = visibleEndY - visibleStartY;
|
int visibleHeight = visibleEndY - visibleStartY;
|
||||||
|
|
||||||
// Invalidate cache if view changed or texture not valid
|
// Invalidate cache if view changed or texture not valid
|
||||||
@@ -1054,18 +1471,25 @@ int main(int argc, char* argv[])
|
|||||||
if (IsMouseButtonReleased(MOUSE_LEFT_BUTTON)) { draggingH = false; draggingV = false; }
|
if (IsMouseButtonReleased(MOUSE_LEFT_BUTTON)) { draggingH = false; draggingV = false; }
|
||||||
|
|
||||||
if (app.showGrid) DrawSpectrogramGrid(viewBounds, 10, 8, Fade(GRAY, 0.3f));
|
if (app.showGrid) DrawSpectrogramGrid(viewBounds, 10, 8, Fade(GRAY, 0.3f));
|
||||||
|
// Timeline lane sits above the spectrogram. Drawn before the
|
||||||
|
// overlays so its hover/selection state is set for the same frame.
|
||||||
|
if (L.timelineHeight > 0) DrawTimeline(L.timelineBounds);
|
||||||
|
DrawAnnotations(viewBounds);
|
||||||
DrawSelection(viewBounds);
|
DrawSelection(viewBounds);
|
||||||
DrawSelectionDrag(viewBounds);
|
DrawSelectionDrag(viewBounds);
|
||||||
DrawMarkers(viewBounds);
|
DrawMarkers(viewBounds);
|
||||||
DrawPlayhead(viewBounds);
|
DrawPlayhead(viewBounds);
|
||||||
DrawLabels(viewBounds);
|
DrawLabels(viewBounds);
|
||||||
if (!UiModalOpen()) DrawCursorReadout(viewBounds);
|
if (!UiModalOpen() && app.hoveredEvent < 0 && app.hoveredTimelineEvent < 0)
|
||||||
|
DrawCursorReadout(viewBounds);
|
||||||
DrawSpectrumPanel(viewBounds);
|
DrawSpectrumPanel(viewBounds);
|
||||||
float maxFreq = (float)app.signal.sampleRate / 2.0f;
|
float maxFreq = EffectiveMaxFreqHz();
|
||||||
float freqMin = app.view.freqStart * maxFreq;
|
float freqMin = app.view.freqStart * maxFreq;
|
||||||
float freqMax = app.view.freqEnd * maxFreq;
|
float freqMax = app.view.freqEnd * maxFreq;
|
||||||
|
// Pin to the top margin so the timeline lane (which lives between
|
||||||
|
// the banner and the spectrogram) doesn't shove the banner down.
|
||||||
DrawTextScaled(TextFormat("Freq: %.0f-%.0f Hz", freqMin, freqMax),
|
DrawTextScaled(TextFormat("Freq: %.0f-%.0f Hz", freqMin, freqMax),
|
||||||
viewBounds.x, viewBounds.y - 30, 20, LIGHTGRAY);
|
viewBounds.x, topMargin - 30, 20, LIGHTGRAY);
|
||||||
|
|
||||||
// Draw waveform scope view underneath the spectrogram
|
// Draw waveform scope view underneath the spectrogram
|
||||||
if (app.showScope && app.loaded && app.signal.samples != NULL) {
|
if (app.showScope && app.loaded && app.signal.samples != NULL) {
|
||||||
@@ -1088,6 +1512,11 @@ int main(int argc, char* argv[])
|
|||||||
} else {
|
} else {
|
||||||
DrawScopeView(&app.scopeView, -1.0f);
|
DrawScopeView(&app.scopeView, -1.0f);
|
||||||
}
|
}
|
||||||
|
// Echo the annotation overlay onto the scope so selecting an
|
||||||
|
// event in the timeline highlights both surfaces at once.
|
||||||
|
DrawAnnotationsOnScope((Rectangle){
|
||||||
|
(float)app.scopeView.x, (float)app.scopeView.y,
|
||||||
|
(float)app.scopeView.width, (float)app.scopeView.height });
|
||||||
// Scope label, tucked inside the top-left so it clears the time
|
// Scope label, tucked inside the top-left so it clears the time
|
||||||
// axis labels and scrollbar that sit in the band above the scope.
|
// axis labels and scrollbar that sit in the band above the scope.
|
||||||
DrawTextScaled("Waveform", viewBounds.x + 4 * renderScale, app.scopeView.y + 3 * renderScale,
|
DrawTextScaled("Waveform", viewBounds.x + 4 * renderScale, app.scopeView.y + 3 * renderScale,
|
||||||
@@ -1143,6 +1572,10 @@ int main(int argc, char* argv[])
|
|||||||
// Draw file browser on top (if active)
|
// Draw file browser on top (if active)
|
||||||
if (app.showFileBrowser) DrawFileBrowser();
|
if (app.showFileBrowser) DrawFileBrowser();
|
||||||
|
|
||||||
|
// Auto-crop notice modal — drawn below About so About still wins if
|
||||||
|
// both happened to be up at once (shouldn't happen in practice).
|
||||||
|
DrawAutocropNotice();
|
||||||
|
|
||||||
// About / help dialog (topmost)
|
// About / help dialog (topmost)
|
||||||
DrawAboutDialog();
|
DrawAboutDialog();
|
||||||
|
|
||||||
@@ -1165,6 +1598,34 @@ int main(int argc, char* argv[])
|
|||||||
}
|
}
|
||||||
|
|
||||||
EndDrawing();
|
EndDrawing();
|
||||||
|
|
||||||
|
// Headless: the frame is now fully rendered. Read it back, optionally
|
||||||
|
// crop to the spectrogram pane, write the PNG, and stop the loop.
|
||||||
|
if (headless) {
|
||||||
|
Image shot = LoadImageFromScreen();
|
||||||
|
if (paneOnly) {
|
||||||
|
// Crop to the spectrogram pane: freq labels + banner + timeline
|
||||||
|
// lane + spectrogram + time-axis labels. Drops sidebar + scope.
|
||||||
|
Layout capL = ComputeLayout();
|
||||||
|
Rectangle vb = capL.viewBounds;
|
||||||
|
float top = capL.topMargin - 30.0f;
|
||||||
|
if (top < 0.0f) top = 0.0f;
|
||||||
|
float left = capL.sidebarWidth;
|
||||||
|
float right = vb.x + vb.width + capL.vScrollbarWidth + 10.0f * capL.scale;
|
||||||
|
float bottom = vb.y + vb.height + capL.labelHeight + 4.0f * capL.scale;
|
||||||
|
ImageCrop(&shot, (Rectangle){ left, top, right - left, bottom - top });
|
||||||
|
}
|
||||||
|
int outW = shot.width, outH = shot.height;
|
||||||
|
bool ok = ExportImage(shot, headlessOut);
|
||||||
|
UnloadImage(shot);
|
||||||
|
if (ok) {
|
||||||
|
printf("Wrote %s (%dx%d)\n", headlessOut, outW, outH);
|
||||||
|
} else {
|
||||||
|
fprintf(stderr, "rspektrum: failed to write '%s'\n", headlessOut);
|
||||||
|
headlessRc = 1;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
TraceLog(LOG_INFO, "Shutting down...");
|
TraceLog(LOG_INFO, "Shutting down...");
|
||||||
@@ -1178,8 +1639,9 @@ int main(int argc, char* argv[])
|
|||||||
FreeBrowserFiles();
|
FreeBrowserFiles();
|
||||||
FreeAllCacheEntries(&app.fftCache);
|
FreeAllCacheEntries(&app.fftCache);
|
||||||
free(app.reassignBuffer);
|
free(app.reassignBuffer);
|
||||||
|
FreeMlnl(&app.annotations);
|
||||||
FreeSignal(&app.signal);
|
FreeSignal(&app.signal);
|
||||||
CloseAudioDevice();
|
CloseAudioDevice();
|
||||||
CloseWindow();
|
CloseWindow();
|
||||||
return 0;
|
return headlessRc;
|
||||||
}
|
}
|
||||||
|
|||||||
+78
-1
@@ -6,6 +6,7 @@
|
|||||||
#include "raylib.h"
|
#include "raylib.h"
|
||||||
#include "utils.h" // AudioSignal, SignalStats
|
#include "utils.h" // AudioSignal, SignalStats
|
||||||
#include "primitives.h" // ScopeView, WaveformData
|
#include "primitives.h" // ScopeView, WaveformData
|
||||||
|
#include "mlnl.h" // MlnlAnnotations
|
||||||
|
|
||||||
#include <stdbool.h>
|
#include <stdbool.h>
|
||||||
#include <math.h>
|
#include <math.h>
|
||||||
@@ -233,6 +234,47 @@ typedef struct {
|
|||||||
bool isDividing; // True while user is dragging the divider
|
bool isDividing; // True while user is dragging the divider
|
||||||
Vector2 dividerStartPos; // Mouse position when started dividing
|
Vector2 dividerStartPos; // Mouse position when started dividing
|
||||||
float dividerStartY; // Spectro height when started dividing
|
float dividerStartY; // Spectro height when started dividing
|
||||||
|
|
||||||
|
// Display-side frequency crop. Caps the displayed frequency axis at this
|
||||||
|
// Hz value — purely a visualization concern (signal data, STFT, audio
|
||||||
|
// playback are unaffected). 0 = no crop, use full Nyquist. The crop is
|
||||||
|
// automatically clamped to the current signal's Nyquist by the helper
|
||||||
|
// below, so a 3 kHz crop is harmless when loading a 2 kHz-sample file.
|
||||||
|
// Persists across loads so a user analyzing mLink (≤3 kHz) doesn't have
|
||||||
|
// to re-set it after every file open.
|
||||||
|
float displayMaxFreqHz;
|
||||||
|
// True when a fresh signal has loaded and is waiting for ApplyAutoCrop to
|
||||||
|
// run (after the STFT exists, so the energy fallback can compute). Set by
|
||||||
|
// ResetForNewSignal; cleared by the loadingPhase=2 hook once autocrop has
|
||||||
|
// run, so an FFT-size change (same loadingPhase path) won't retrigger.
|
||||||
|
bool autocropPending;
|
||||||
|
|
||||||
|
// Notice splash shown when ApplyAutoCrop actually shrank the view. Modal:
|
||||||
|
// user dismisses with "OK" (keep crop) or "Uncrop" (restore full view).
|
||||||
|
bool autocropNoticeActive;
|
||||||
|
char autocropNoticeMsg[256];
|
||||||
|
|
||||||
|
// Optional mLnL annotations parsed from the loaded WAV (empty if the file
|
||||||
|
// doesn't carry the chunk). The annotations overlay has two surfaces:
|
||||||
|
// 1. A faint always-on draw on the spectrogram (alpha = opacityBase).
|
||||||
|
// 2. A "timeline lane" above the spectrogram for browsing events;
|
||||||
|
// hover/click in the lane bumps the matching spectrogram overlay to
|
||||||
|
// opacityHover so the user can find/inspect specific events without
|
||||||
|
// the overlay drowning the underlying signal.
|
||||||
|
MlnlAnnotations annotations;
|
||||||
|
int hoveredEvent; // spectrogram-cursor hit (-1 = none); used for tooltip
|
||||||
|
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)
|
||||||
|
float annotationOpacityBase; // 0..1 — quiet always-on alpha for spectrogram overlay
|
||||||
|
float annotationOpacityHover; // 0..1 — alpha for hovered/selected events
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
int hoveredTimelineEvent; // -1 = none; event index hovered in the lane
|
||||||
|
int selectedAnnotation; // -1 = none; persistent selection from a lane click
|
||||||
} SpectrogramApp;
|
} SpectrogramApp;
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -248,11 +290,21 @@ extern Font mainFont;
|
|||||||
// (defined in spectrogram.c; used by every load path).
|
// (defined in spectrogram.c; used by every load path).
|
||||||
void ResetForNewSignal(void);
|
void ResetForNewSignal(void);
|
||||||
|
|
||||||
|
// Auto-crop the display freq axis and time view to "frequencies/times of
|
||||||
|
// interest" using whichever source has high confidence:
|
||||||
|
// 1) mLnL annotations — max(f_hi)+headroom for freq, span(t_start..t_end)+pad for time
|
||||||
|
// 2) STFT energy heuristic — cumulative-energy threshold for freq, activity envelope for time
|
||||||
|
// A no-op when neither source meets the confidence test (e.g. signal genuinely
|
||||||
|
// uses most of the band/timeline). Modifies displayMaxFreqHz, view.start/end,
|
||||||
|
// and invalidates the texture cache. Called automatically after STFT init on
|
||||||
|
// every file load; can also be re-run from the sidebar button.
|
||||||
|
void ApplyAutoCrop(void);
|
||||||
|
|
||||||
// True when a modal overlay owns input; normal spectrogram/keyboard interaction
|
// True when a modal overlay owns input; normal spectrogram/keyboard interaction
|
||||||
// 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;
|
return app.showFileBrowser || app.showAbout || app.autocropNoticeActive;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset the box selection to the full signal (the "no selection" state).
|
// Reset the box selection to the full signal (the "no selection" state).
|
||||||
@@ -262,6 +314,31 @@ static inline void ClearSelection(void)
|
|||||||
app.sel.freqStart = 0.0f; app.sel.freqEnd = 1.0f;
|
app.sel.freqStart = 0.0f; app.sel.freqEnd = 1.0f;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Effective top of the displayed frequency axis (Hz). Capped at the actual
|
||||||
|
// signal Nyquist so the crop never tries to show frequencies that aren't in
|
||||||
|
// the data. All DISPLAY-side code paths (labels, banner, annotation freq
|
||||||
|
// mapping, texture sampling fraction) should reach the frequency axis through
|
||||||
|
// this helper instead of computing sampleRate*0.5 directly. Data-side math
|
||||||
|
// (STFT bin spacing, PSD, audio filtering) keeps using the true Nyquist.
|
||||||
|
static inline float EffectiveMaxFreqHz(void)
|
||||||
|
{
|
||||||
|
if (app.signal.sampleRate <= 0) return 1.0f;
|
||||||
|
float nyq = app.signal.sampleRate * 0.5f;
|
||||||
|
if (app.displayMaxFreqHz > 0.0f && app.displayMaxFreqHz < nyq) return app.displayMaxFreqHz;
|
||||||
|
return nyq;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fraction of the texture's full frequency axis that should be visible (0..1).
|
||||||
|
// Used by the spectrogram's texture sub-image extraction: a value of 0.5 means
|
||||||
|
// "the visible window's freqEnd=1.0 corresponds to the texture's mid-row".
|
||||||
|
static inline float DisplayFreqFraction(void)
|
||||||
|
{
|
||||||
|
if (app.signal.sampleRate <= 0) return 1.0f;
|
||||||
|
float nyq = app.signal.sampleRate * 0.5f;
|
||||||
|
if (nyq <= 0.0f) return 1.0f;
|
||||||
|
return EffectiveMaxFreqHz() / nyq;
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// 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
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ static void LoadSelectedFile(void)
|
|||||||
NavigateToDirectory(app.browserFiles[app.browserSelected]);
|
NavigateToDirectory(app.browserFiles[app.browserSelected]);
|
||||||
} else if (FileExists(filePath) && LoadWavFile(filePath, &app.signal)) {
|
} else if (FileExists(filePath) && LoadWavFile(filePath, &app.signal)) {
|
||||||
ResetForNewSignal();
|
ResetForNewSignal();
|
||||||
|
LoadMlnlFromWav(filePath, &app.annotations);
|
||||||
app.showFileBrowser = false;
|
app.showFileBrowser = false;
|
||||||
TraceLog(LOG_INFO, "Loaded: %s", filePath);
|
TraceLog(LOG_INFO, "Loaded: %s", filePath);
|
||||||
}
|
}
|
||||||
@@ -427,6 +428,36 @@ void DrawSidebar(void)
|
|||||||
DrawPanelBox(gridCheck, app.showGrid ? BLUE : DARKGRAY, WHITE);
|
DrawPanelBox(gridCheck, app.showGrid ? BLUE : DARKGRAY, WHITE);
|
||||||
DrawTextScaled("Show Grid", x + 25 * scale, y + 2 * scale, 14, LIGHTGRAY); y += 28 * scale;
|
DrawTextScaled("Show Grid", x + 25 * scale, y + 2 * scale, 14, LIGHTGRAY); y += 28 * scale;
|
||||||
|
|
||||||
|
// Display freq crop — only meaningful when a signal is loaded.
|
||||||
|
if (app.loaded && app.signal.sampleRate > 0) {
|
||||||
|
float nyq = app.signal.sampleRate * 0.5f;
|
||||||
|
float curr = EffectiveMaxFreqHz();
|
||||||
|
DrawTextScaled(TextFormat("Display max: %.0f Hz", curr), x, y, 13, LIGHTGRAY);
|
||||||
|
// "Auto" button to re-run the auto-crop heuristic (annotations first,
|
||||||
|
// then energy). Same logic that runs once on file load.
|
||||||
|
Rectangle autoBtn = { x + sidebarWidth - 58 * scale, y - 2 * scale,
|
||||||
|
48 * scale, 16 * scale };
|
||||||
|
if (Clicked(autoBtn)) ApplyAutoCrop();
|
||||||
|
DrawPanelBox(autoBtn, (Color){ 60, 50, 80, 255 }, (Color){ 160, 130, 200, 255 });
|
||||||
|
DrawTextScaled("auto", autoBtn.x + 12 * scale, autoBtn.y + 2 * scale, 10, WHITE);
|
||||||
|
y += 18 * scale;
|
||||||
|
Rectangle cropSlider = { x, y, sidebarWidth - 10 * scale, 14 * scale };
|
||||||
|
float frac = curr / nyq;
|
||||||
|
DrawSlider(cropSlider, frac);
|
||||||
|
if (UpdateSlider(cropSlider, &frac)) {
|
||||||
|
// Don't let the user crop to a useless sliver — keep at least
|
||||||
|
// ~2% of the band visible (250 Hz on a 12 kHz file).
|
||||||
|
if (frac < 0.02f) frac = 0.02f;
|
||||||
|
app.displayMaxFreqHz = frac * nyq;
|
||||||
|
// Reset view to show the full cropped range — otherwise a prior
|
||||||
|
// zoom-into-half-band would magnify even further after a crop.
|
||||||
|
app.view.freqStart = 0.0f;
|
||||||
|
app.view.freqEnd = 1.0f;
|
||||||
|
app.visibleTextureValid = false;
|
||||||
|
}
|
||||||
|
y += 22 * scale;
|
||||||
|
}
|
||||||
|
|
||||||
// Analysis toggles: marker/ruler tool and spectrum-slice (PSD) panel.
|
// Analysis toggles: marker/ruler tool and spectrum-slice (PSD) panel.
|
||||||
Rectangle markerBtn = { x, y, sidebarWidth - 10 * scale, 24 * scale };
|
Rectangle markerBtn = { x, y, sidebarWidth - 10 * scale, 24 * scale };
|
||||||
if (Clicked(markerBtn)) app.markerMode = !app.markerMode;
|
if (Clicked(markerBtn)) app.markerMode = !app.markerMode;
|
||||||
@@ -444,6 +475,71 @@ void DrawSidebar(void)
|
|||||||
specBtn.x + 10 * scale, specBtn.y + 5 * scale, 13, WHITE);
|
specBtn.x + 10 * scale, specBtn.y + 5 * scale, 13, WHITE);
|
||||||
y += 30 * scale;
|
y += 30 * scale;
|
||||||
|
|
||||||
|
// ---- mLnL annotations panel — only when the loaded file has any ----
|
||||||
|
if (app.annotations.loaded && app.annotations.eventCount > 0) {
|
||||||
|
// Left half = master toggle. Right edge = small expand chevron that
|
||||||
|
// reveals per-kind checkboxes. The chevron is also clickable when the
|
||||||
|
// master toggle is OFF so the user can configure what to show before
|
||||||
|
// re-enabling overlays.
|
||||||
|
Rectangle annBtn = { x, y, (sidebarWidth - 10 * scale) - 28 * scale, 24 * scale };
|
||||||
|
Rectangle annExp = { annBtn.x + annBtn.width + 2 * scale, y, 26 * scale, 24 * scale };
|
||||||
|
if (Clicked(annBtn)) app.showAnnotations = !app.showAnnotations;
|
||||||
|
if (Clicked(annExp)) app.annotationsExpanded = !app.annotationsExpanded;
|
||||||
|
|
||||||
|
DrawPanelBox(annBtn,
|
||||||
|
app.showAnnotations ? (Color){ 70, 40, 90, 255 } : (Color){ 50, 50, 60, 255 },
|
||||||
|
app.showAnnotations ? (Color){ 200, 160, 255, 255 } : GRAY);
|
||||||
|
DrawTextScaled(app.showAnnotations ? "Annotations: ON" : "Annotations: off",
|
||||||
|
annBtn.x + 10 * scale, annBtn.y + 5 * scale, 13, WHITE);
|
||||||
|
DrawPanelBox(annExp, (Color){ 50, 50, 60, 255 }, GRAY);
|
||||||
|
DrawTextScaled(app.annotationsExpanded ? "v" : ">",
|
||||||
|
annExp.x + 9 * scale, annExp.y + 5 * scale, 13, WHITE);
|
||||||
|
y += 28 * scale;
|
||||||
|
|
||||||
|
if (app.annotationsExpanded) {
|
||||||
|
// Two opacity sliders: the spectrogram overlay is drawn at the
|
||||||
|
// "Base" alpha by default, and bumps to "Highlight" for any event
|
||||||
|
// that's hovered or selected in the timeline lane. Outlines and
|
||||||
|
// labels scale together with the fill so the whole overlay fades
|
||||||
|
// as one unit.
|
||||||
|
DrawTextScaled(TextFormat("Base: %d%%", (int)(app.annotationOpacityBase * 100.0f)),
|
||||||
|
x + 8 * scale, y, 12, LIGHTGRAY);
|
||||||
|
y += 16 * scale;
|
||||||
|
Rectangle baseSlider = { x + 8 * scale, y, sidebarWidth - 26 * scale, 12 * scale };
|
||||||
|
DrawSlider(baseSlider, app.annotationOpacityBase);
|
||||||
|
UpdateSlider(baseSlider, &app.annotationOpacityBase);
|
||||||
|
y += 20 * scale;
|
||||||
|
|
||||||
|
DrawTextScaled(TextFormat("Highlight: %d%%", (int)(app.annotationOpacityHover * 100.0f)),
|
||||||
|
x + 8 * scale, y, 12, LIGHTGRAY);
|
||||||
|
y += 16 * scale;
|
||||||
|
Rectangle hovSlider = { x + 8 * scale, y, sidebarWidth - 26 * scale, 12 * scale };
|
||||||
|
DrawSlider(hovSlider, app.annotationOpacityHover);
|
||||||
|
UpdateSlider(hovSlider, &app.annotationOpacityHover);
|
||||||
|
y += 22 * scale;
|
||||||
|
|
||||||
|
// Indented checkbox per kind actually present in this file. Order:
|
||||||
|
// walk the enum so the menu order is stable across files.
|
||||||
|
for (int k = 0; k < MLNL_KIND_MAX; k++) {
|
||||||
|
if (!app.annotations.kindPresent[k]) continue;
|
||||||
|
Rectangle cb = { x + 8 * scale, y + 2 * scale, 14 * scale, 14 * scale };
|
||||||
|
if (Clicked(cb)) app.annotationKindEnabled[k] = !app.annotationKindEnabled[k];
|
||||||
|
DrawPanelBox(cb, app.annotationKindEnabled[k] ? (Color){ 140, 100, 200, 255 } : DARKGRAY,
|
||||||
|
LIGHTGRAY);
|
||||||
|
DrawTextScaled(MlnlKindName((MlnlKind)k),
|
||||||
|
cb.x + 22 * scale, cb.y - 1 * scale, 12, LIGHTGRAY);
|
||||||
|
y += 18 * scale;
|
||||||
|
}
|
||||||
|
y += 4 * scale;
|
||||||
|
}
|
||||||
|
if (app.annotations.truncated) {
|
||||||
|
DrawTextScaled("(file truncated)", x + 4 * scale, y, 11,
|
||||||
|
(Color){ 255, 180, 180, 255 });
|
||||||
|
y += 16 * scale;
|
||||||
|
}
|
||||||
|
y += 6 * scale;
|
||||||
|
}
|
||||||
|
|
||||||
// File loading
|
// File loading
|
||||||
DrawTextScaled("File:", x, y, 14, LIGHTGRAY); y += 20 * scale;
|
DrawTextScaled("File:", x, y, 14, LIGHTGRAY); y += 20 * scale;
|
||||||
Rectangle fileButton = { x, y, sidebarWidth - 10 * scale, 25 * scale };
|
Rectangle fileButton = { x, y, sidebarWidth - 10 * scale, 25 * scale };
|
||||||
@@ -649,3 +745,75 @@ void DrawAboutDialog(void)
|
|||||||
// Note: opening/closing is handled in the main input loop (not here) so the
|
// Note: opening/closing is handled in the main input loop (not here) so the
|
||||||
// same click/keypress that opens the dialog can't immediately close it.
|
// same click/keypress that opens the dialog can't immediately close it.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== Auto-crop notice splash =====
|
||||||
|
//
|
||||||
|
// Raised by ApplyAutoCrop when the view actually shrank. Modal: routes
|
||||||
|
// through UiModalOpen() so the spectrogram doesn't accept clicks underneath.
|
||||||
|
// Two buttons:
|
||||||
|
// [Uncrop] — restore full freq axis and full-duration view
|
||||||
|
// [OK] — dismiss; keep the cropped view
|
||||||
|
// Esc closes (= OK). Clicks outside the panel do nothing (avoids losing the
|
||||||
|
// crop by missing a button by a few px).
|
||||||
|
void DrawAutocropNotice(void)
|
||||||
|
{
|
||||||
|
if (!app.autocropNoticeActive) return;
|
||||||
|
float scale = GetUIScale();
|
||||||
|
int sw = GetScreenWidth();
|
||||||
|
int sh = GetScreenHeight();
|
||||||
|
|
||||||
|
DrawRectangle(0, 0, sw, sh, Fade(BLACK, 0.55f));
|
||||||
|
|
||||||
|
float pw = 460 * scale;
|
||||||
|
float ph = 170 * scale;
|
||||||
|
Rectangle panel = { (sw - pw) * 0.5f, (sh - ph) * 0.5f, pw, ph };
|
||||||
|
DrawRectangleRec(panel, (Color){ 30, 30, 40, 255 });
|
||||||
|
DrawRectangleLinesEx(panel, 2, (Color){ 160, 130, 200, 255 });
|
||||||
|
|
||||||
|
DrawTextScaled("Auto-crop applied",
|
||||||
|
panel.x + 20 * scale, panel.y + 16 * scale, 16,
|
||||||
|
(Color){ 200, 170, 240, 255 });
|
||||||
|
|
||||||
|
// Body: word-wrap not needed for the short message ApplyAutoCrop builds.
|
||||||
|
DrawTextScaled(app.autocropNoticeMsg,
|
||||||
|
panel.x + 20 * scale, panel.y + 50 * scale, 13, LIGHTGRAY);
|
||||||
|
|
||||||
|
float btnW = 110 * scale, btnH = 32 * scale;
|
||||||
|
float btnY = panel.y + ph - btnH - 16 * scale;
|
||||||
|
Rectangle uncropBtn = { panel.x + 20 * scale, btnY, btnW, btnH };
|
||||||
|
Rectangle okBtn = { panel.x + pw - btnW - 20*scale, btnY, btnW, btnH };
|
||||||
|
|
||||||
|
bool uncropHover = CheckCollisionPointRec(GetMousePosition(), uncropBtn);
|
||||||
|
bool okHover = CheckCollisionPointRec(GetMousePosition(), okBtn);
|
||||||
|
|
||||||
|
DrawPanelBox(uncropBtn,
|
||||||
|
uncropHover ? (Color){ 100, 60, 60, 255 } : (Color){ 70, 40, 40, 255 },
|
||||||
|
(Color){ 230, 160, 160, 255 });
|
||||||
|
DrawTextScaled("Uncrop",
|
||||||
|
uncropBtn.x + btnW * 0.5f - MeasureTextScaled("Uncrop", 14) * 0.5f,
|
||||||
|
uncropBtn.y + 8 * scale, 14, WHITE);
|
||||||
|
|
||||||
|
DrawPanelBox(okBtn,
|
||||||
|
okHover ? (Color){ 60, 90, 60, 255 } : (Color){ 40, 70, 40, 255 },
|
||||||
|
(Color){ 160, 220, 160, 255 });
|
||||||
|
DrawTextScaled("OK",
|
||||||
|
okBtn.x + btnW * 0.5f - MeasureTextScaled("OK", 14) * 0.5f,
|
||||||
|
okBtn.y + 8 * scale, 14, WHITE);
|
||||||
|
|
||||||
|
DrawTextScaled("Esc or Enter = OK", panel.x + pw - 156 * scale,
|
||||||
|
panel.y + ph - 14 * scale, 10, GRAY);
|
||||||
|
|
||||||
|
// Hit handling. Esc/Enter dismiss (keep crop). Uncrop restores full view.
|
||||||
|
if (IsKeyPressed(KEY_ESCAPE) || IsKeyPressed(KEY_ENTER)) {
|
||||||
|
app.autocropNoticeActive = false;
|
||||||
|
}
|
||||||
|
if (uncropHover && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) {
|
||||||
|
app.displayMaxFreqHz = 0.0f;
|
||||||
|
app.view.start = 0.0f; app.view.end = 1.0f;
|
||||||
|
app.view.freqStart = 0.0f; app.view.freqEnd = 1.0f;
|
||||||
|
app.visibleTextureValid = false;
|
||||||
|
app.autocropNoticeActive = false;
|
||||||
|
} else if (okHover && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) {
|
||||||
|
app.autocropNoticeActive = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,4 +18,9 @@ void ExportPNG(const SpectrogramApp* spa, const char* dirPath);
|
|||||||
// --- About / help dialog ---
|
// --- About / help dialog ---
|
||||||
void DrawAboutDialog(void);
|
void DrawAboutDialog(void);
|
||||||
|
|
||||||
|
// --- Auto-crop notice splash ---
|
||||||
|
// Shown right after ApplyAutoCrop changed the view; OK keeps the crop,
|
||||||
|
// Uncrop restores the full freq + time view.
|
||||||
|
void DrawAutocropNotice(void);
|
||||||
|
|
||||||
#endif // UI_H
|
#endif // UI_H
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
\relax
|
|
||||||
\providecommand\hyper@newdestlabel[2]{}
|
|
||||||
\providecommand\HyField@AuxAddToFields[1]{}
|
|
||||||
\providecommand\HyField@AuxAddToCoFields[2]{}
|
|
||||||
\gdef \@abspage@last{5}
|
|
||||||
Binary file not shown.
@@ -1,219 +0,0 @@
|
|||||||
\documentclass[12pt]{article}
|
|
||||||
\usepackage[margin=1in]{geometry}
|
|
||||||
\usepackage{amsmath}
|
|
||||||
\usepackage{tikz}
|
|
||||||
\usepackage{color}
|
|
||||||
\usepackage{hyperref}
|
|
||||||
|
|
||||||
\title{\textbf{Why Spectrograms Look Smudged (And How to Fix Them)}\\
|
|
||||||
\small\textit{A beginner-friendly guide to synchrosqueezing}}
|
|
||||||
\author{}
|
|
||||||
\date{}
|
|
||||||
|
|
||||||
\begin{document}
|
|
||||||
\maketitle
|
|
||||||
|
|
||||||
\section*{What You Already Know}
|
|
||||||
|
|
||||||
You know what an FFT does. You feed it a chunk of audio, it tells you which frequencies are present. You slide a window across your audio, stack up the FFTs, and you get a spectrogram.
|
|
||||||
|
|
||||||
But your spectrogram looks \textbf{smudged}. A pure tone doesn't look like a clean line --- it looks like someone took a finger and smeared it vertically. You're wondering: \textit{``Is this just how FFTs work, or am I doing something wrong?''}
|
|
||||||
|
|
||||||
You're not doing anything wrong. This is a known limitation of the standard approach. And there's a technique called \textbf{synchrosqueezing} that fixes it.
|
|
||||||
|
|
||||||
\section*{Why the Smudge Happens}
|
|
||||||
|
|
||||||
Your FFT has bins at fixed frequencies. If your FFT size is 512 and your sample rate is 48\,kHz, the bins are spaced 93.75\,Hz apart:
|
|
||||||
|
|
||||||
\begin{center}
|
|
||||||
\begin{tabular}{|c|c|c|c|c|c|c|}
|
|
||||||
\hline
|
|
||||||
Bin 0 & Bin 1 & Bin 2 & Bin 3 & Bin 4 & Bin 5 & Bin 6 \\
|
|
||||||
0 Hz & 94 Hz & 188 Hz & 281 Hz & 375 Hz & 469 Hz & 563 Hz \\
|
|
||||||
\hline
|
|
||||||
\end{tabular}
|
|
||||||
\end{center}
|
|
||||||
|
|
||||||
Now imagine your signal has a pure 500\,Hz tone. Where does it go?
|
|
||||||
|
|
||||||
It doesn't match any bin exactly. It falls \textbf{between} bin 5 (469\,Hz) and bin 6 (563\,Hz). The FFT doesn't know what to do with it, so it spreads the energy across both bins.
|
|
||||||
|
|
||||||
\textbf{Result:} Your clean 500\,Hz tone now shows up as energy at both 469\,Hz and 563\,Hz. That's the smudge.
|
|
||||||
|
|
||||||
\textbf{This is called \textit{spectral leakage}, and it's a fundamental property of the FFT.} It's not a bug --- it's just how the math works.
|
|
||||||
|
|
||||||
\section*{The Hidden Information: Phase}
|
|
||||||
|
|
||||||
Every FFT output value is a \textbf{complex number}. That means it has two parts:
|
|
||||||
|
|
||||||
\begin{itemize}
|
|
||||||
\item \textbf{Magnitude} --- how strong is this frequency? (This is what you use to draw the spectrogram.)
|
|
||||||
\item \textbf{Phase} --- where are we in the wave cycle? (This is what you've been ignoring.)
|
|
||||||
\end{itemize}
|
|
||||||
|
|
||||||
Here's the thing: \textbf{the phase contains information about the true frequency.}
|
|
||||||
|
|
||||||
\subsection*{Think of It Like This: Engine Timing}
|
|
||||||
|
|
||||||
Imagine you're tuning an engine. There's a timing mark painted on the crankshaft pulley, and a sensor that detects when the mark passes by.
|
|
||||||
|
|
||||||
You \textbf{expect} the engine to run at 3000 RPM. At that speed, the timing mark should pass the sensor at exactly the same point in each revolution.
|
|
||||||
|
|
||||||
But the engine is actually running at 3100 RPM --- slightly faster than expected. What happens?
|
|
||||||
|
|
||||||
\begin{itemize}
|
|
||||||
\item Revolution 1: the mark passes right where you expect it
|
|
||||||
\item Revolution 2: the mark arrives a little \textbf{early} (the engine completed the rotation faster than expected)
|
|
||||||
\item Revolution 3: the mark arrives even earlier
|
|
||||||
\item Revolution 4: the mark is now noticeably ahead of where it should be
|
|
||||||
\end{itemize}
|
|
||||||
|
|
||||||
The mark is \textbf{drifting forward} relative to your expectation. By measuring \textbf{how much it drifts per revolution}, you can calculate the \textbf{actual RPM}.
|
|
||||||
|
|
||||||
\textbf{This is exactly what happens with FFT bins.}
|
|
||||||
|
|
||||||
Each FFT bin is like expecting the engine to run at a specific RPM (the bin center frequency). The phase tells you where the ``timing mark'' is. If the phase drifts forward from one window to the next, the true frequency is higher than the bin center. If it drifts backward, the true frequency is lower.
|
|
||||||
|
|
||||||
\textbf{Measure the drift rate, and you know the true frequency.}
|
|
||||||
|
|
||||||
\section*{The Formula}
|
|
||||||
|
|
||||||
The instantaneous frequency estimate is:
|
|
||||||
|
|
||||||
\begin{equation}
|
|
||||||
\hat{\omega}(a,b) = \text{Re}\left[ \frac{\partial_b V_f(a,b)}{i \cdot V_f(a,b)} \right]
|
|
||||||
\end{equation}
|
|
||||||
|
|
||||||
Let's decode this:
|
|
||||||
|
|
||||||
\begin{itemize}
|
|
||||||
\item $V_f(a,b)$ = the complex FFT value for bin $a$ at time window $b$
|
|
||||||
\item $\partial_b V_f$ = how much that value changed from the previous time window
|
|
||||||
\item $i$ = the imaginary unit (a math trick that converts phase rotation into Hz)
|
|
||||||
\item $\text{Re}[\ldots]$ = take the real part of the result
|
|
||||||
\end{itemize}
|
|
||||||
|
|
||||||
\textbf{In plain English:}
|
|
||||||
|
|
||||||
\begin{center}
|
|
||||||
\fbox{\parbox{0.7\textwidth}{\centering
|
|
||||||
``How fast is the phase changing from one window to the next?'' \\
|
|
||||||
$=$ \\
|
|
||||||
``What is the true frequency at this point?''
|
|
||||||
}}
|
|
||||||
\end{center}
|
|
||||||
|
|
||||||
\section*{How to Actually Compute This}
|
|
||||||
|
|
||||||
``Wait, what's that $\partial_b$ symbol? That's a derivative!''
|
|
||||||
|
|
||||||
Good news: you don't need to compute derivatives numerically. There's a neat trick.
|
|
||||||
|
|
||||||
You compute \textbf{two FFTs} per window:
|
|
||||||
|
|
||||||
\begin{enumerate}
|
|
||||||
\item \textbf{Normal STFT} using your Hann window $h(t)$ $\rightarrow$ gives you $V_f$
|
|
||||||
\item \textbf{Second STFT} using the \textbf{derivative of the Hann window} $h'(t)$ $\rightarrow$ gives you $V_{fd}$
|
|
||||||
\end{enumerate}
|
|
||||||
|
|
||||||
The Hann window is:
|
|
||||||
$$h(t) = 0.5 \times \bigl(1 - \cos(2\pi t)\bigr)$$
|
|
||||||
|
|
||||||
Its derivative is:
|
|
||||||
$$h'(t) = \pi \times \sin(2\pi t)$$
|
|
||||||
|
|
||||||
\textbf{That's all.} You already know how to window and FFT. Just use a different window function for the second FFT.
|
|
||||||
|
|
||||||
Then the formula becomes:
|
|
||||||
|
|
||||||
\begin{equation}
|
|
||||||
\hat{\omega} = \text{bin\_frequency} + \text{Re}\left[ \frac{V_{fd}}{i \cdot V_f} \right]
|
|
||||||
\end{equation}
|
|
||||||
|
|
||||||
\section*{What You Do With This Number}
|
|
||||||
|
|
||||||
For every FFT bin at every time window, you now know the \textbf{true frequency}, not just the bin center.
|
|
||||||
|
|
||||||
So instead of drawing the energy at the bin center (469\,Hz), you draw it at the true frequency (500\,Hz).
|
|
||||||
|
|
||||||
\textbf{This is synchrosqueezing:} you're taking the smeared energy and ``squeezing'' it back to where it actually belongs.
|
|
||||||
|
|
||||||
\section*{The Algorithm}
|
|
||||||
|
|
||||||
\begin{verbatim}
|
|
||||||
for each time window:
|
|
||||||
# Step 1: Normal STFT with Hann window
|
|
||||||
windowed = samples * hann_window
|
|
||||||
V_f = FFT(windowed)
|
|
||||||
|
|
||||||
# Step 2: STFT with derivative-of-Hann window
|
|
||||||
deriv_windowed = samples * hann_derivative
|
|
||||||
V_fd = FFT(deriv_windowed)
|
|
||||||
|
|
||||||
# Step 3: For each bin, compute true frequency
|
|
||||||
for each bin:
|
|
||||||
# Complex division
|
|
||||||
correction = Re(V_fd / (i * V_f))
|
|
||||||
true_freq = bin_frequency + correction
|
|
||||||
|
|
||||||
# Step 4: Put the energy at the true frequency
|
|
||||||
target_bin = round(true_freq / freq_per_bin)
|
|
||||||
output[target_bin] += magnitude(V_f)
|
|
||||||
\end{verbatim}
|
|
||||||
|
|
||||||
\section*{What Changes Visually}
|
|
||||||
|
|
||||||
\begin{center}
|
|
||||||
\begin{tabular}{|p{0.45\textwidth}|p{0.45\textwidth}|}
|
|
||||||
\hline
|
|
||||||
\textbf{Standard STFT} & \textbf{Synchrosqueezed} \\
|
|
||||||
\hline
|
|
||||||
Energy spread across 2--4 bins & Energy concentrated in 1 bin \\
|
|
||||||
Tone looks like a fuzzy bar & Tone looks like a sharp line \\
|
|
||||||
Hard to distinguish close tones & Close tones are clearly separate \\
|
|
||||||
\hline
|
|
||||||
\end{tabular}
|
|
||||||
\end{center}
|
|
||||||
|
|
||||||
For an FSK signal (like your FSK4 at 50 baud):
|
|
||||||
\begin{itemize}
|
|
||||||
\item \textbf{Before:} each symbol looks like a blurry blob
|
|
||||||
\item \textbf{After:} each symbol looks like a clean rectangle
|
|
||||||
\end{itemize}
|
|
||||||
|
|
||||||
\section*{The Trade-offs}
|
|
||||||
|
|
||||||
\begin{itemize}
|
|
||||||
\item \textbf{Computation:} 2 FFTs per window instead of 1 (roughly 1.5--2$\times$ slower)
|
|
||||||
\item \textbf{Memory:} need to store both $V_f$ and $V_{fd}$
|
|
||||||
\item \textbf{Quality:} significantly sharper time-frequency representation
|
|
||||||
\end{itemize}
|
|
||||||
|
|
||||||
For a 2-second audio file at FFT size 512, we're talking about 0.1 seconds vs.\ 0.15 seconds. Not a big deal for offline analysis.
|
|
||||||
|
|
||||||
\section*{Why Not Just Use a Bigger FFT?}
|
|
||||||
|
|
||||||
You could increase your FFT size to get narrower bins. But:
|
|
||||||
\begin{itemize}
|
|
||||||
\item Bigger FFT = wider time window = worse \textbf{time} resolution
|
|
||||||
\item You trade frequency smearing for time smearing
|
|
||||||
\item Synchrosqueezing gives you sharp frequency \textbf{and} sharp time
|
|
||||||
\end{itemize}
|
|
||||||
|
|
||||||
It's not a replacement for choosing the right FFT size. It's a way to get the most out of whatever FFT size you choose.
|
|
||||||
|
|
||||||
\section*{Further Reading}
|
|
||||||
|
|
||||||
If you want to go deeper:
|
|
||||||
\begin{itemize}
|
|
||||||
\item Thakur et al., \textit{The Synchrosqueezing algorithm for time-varying spectral analysis} (2011) --- the standard reference
|
|
||||||
\item \texttt{librosa.reassigned\_spectrogram} (Python) --- a working implementation
|
|
||||||
\item \texttt{scipy.signal.spectrogram} with phase-based reassignment --- another approach
|
|
||||||
\end{itemize}
|
|
||||||
|
|
||||||
\vspace{1cm}
|
|
||||||
\begin{center}
|
|
||||||
\textit{The smudge isn't your fault. But now you know how to fix it.}
|
|
||||||
\end{center}
|
|
||||||
|
|
||||||
\end{document}
|
|
||||||
Reference in New Issue
Block a user