Compare commits
4
Commits
d35cc66ae6
...
5c3c88dc22
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c3c88dc22 | ||
|
|
0f9ad03fc5 | ||
|
|
c4687ce80d | ||
|
|
46e796cdeb |
@@ -129,7 +129,9 @@ or pressing **O** for the file browser. Try the bundled sample:
|
||||
| Input | Action |
|
||||
|-------|--------|
|
||||
| **O** | Open file browser |
|
||||
| **Mouse wheel** | Zoom time/frequency |
|
||||
| **Mouse wheel** | Zoom both axes (preserves aspect ratio) |
|
||||
| **Shift+wheel** | Zoom the time axis only |
|
||||
| **Ctrl+wheel** | Zoom the frequency axis only |
|
||||
| **Alt+drag** / **middle-drag** | Pan the view |
|
||||
| **LMB drag** | Select a time + frequency region |
|
||||
| **Space** | Play / stop the selected region |
|
||||
@@ -229,6 +231,13 @@ paths.
|
||||
frequency resolution `sampleRate / fftSize` Hz per bin. Amplitude in dB.
|
||||
- **Axes** — X = time (s), Y = frequency (Hz, scaled to the file's Nyquist),
|
||||
colour = amplitude.
|
||||
- **Time zoom limit** — the tightest visible window is derived from the STFT hop
|
||||
(`fftSize / HOP_RATIO` samples), not from a fixed fraction of the file, so time
|
||||
resolution does not degrade as files get longer: a 30-minute recording zooms in
|
||||
just as far as a 30-second one. At 48 kHz / 2048-point FFT the floor is ~85 ms
|
||||
across the viewport; a smaller FFT zooms correspondingly tighter. Past that
|
||||
point there are no further STFT segments to show, so the view would only
|
||||
interpolate.
|
||||
- **Playback / WAV export** share one processing path: the selected time span,
|
||||
FFT-bandpassed to the selected frequency box, peak-normalised.
|
||||
- **mLnL parsing** — walks the WAV's RIFF chunks for the four-CC `mLnL` chunk
|
||||
@@ -253,3 +262,6 @@ src/
|
||||
See [`raylib_for_desktop_applications.md`](raylib_for_desktop_applications.md)
|
||||
for the performance / idle-CPU lessons behind the desktop build, and
|
||||
[`AGENTS.md`](AGENTS.md) for the headless-testing playbook.
|
||||
|
||||
Known rough edges — behaviour that is unspecified or awkward rather than simply
|
||||
broken — are tracked in [`known_bugs.md`](known_bugs.md).
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
# Known bugs & rough edges
|
||||
|
||||
Behaviour that is unspecified, awkward, or known-imperfect — as distinct from
|
||||
outright breakage. Each entry says what happens, why, and what a real fix would
|
||||
need to decide.
|
||||
|
||||
---
|
||||
|
||||
## Playhead vs. a selection edited mid-playback
|
||||
|
||||
**Status:** partially addressed; underlying semantics still undefined.
|
||||
|
||||
Playback hands a *snapshot* of the selected region to the audio device — the
|
||||
samples are copied, bandpassed, and normalised up front, so the sound coming out
|
||||
of the speakers is fixed the moment **Space** is pressed. The selection box,
|
||||
however, stays live and editable while that audio plays.
|
||||
|
||||
Previously the playhead marker was drawn against the *live* `app.sel`, so moving
|
||||
or resizing the selection during playback made the marker jump, run off the end,
|
||||
or scale to a region that had nothing to do with what was audible. The playhead
|
||||
is now measured against `playSelStart` / `playSelEnd` / `playDuration`, captured
|
||||
at `PlaySelectedRegion()` time, so it tracks the audio that is actually playing.
|
||||
|
||||
What remains undefined is the *product* question, not the drawing math:
|
||||
|
||||
- If the user drags the selection somewhere else mid-playback, should the audio
|
||||
follow (restart / re-seek against the new region), or should playback keep
|
||||
going with the old buffer and the marker stay where it is (current behaviour)?
|
||||
- Should editing the selection during playback simply stop playback?
|
||||
- Should the playhead remain visible when the region it refers to is scrolled
|
||||
off-screen, or has been replaced by a selection elsewhere in the file?
|
||||
|
||||
Current behaviour is the conservative reading: **the sound wins**. The marker
|
||||
always describes real audio, and a mid-playback edit is treated as staging the
|
||||
*next* thing to play rather than modifying the current one. That is defensible
|
||||
but was never explicitly chosen, and the UI gives no feedback that the box on
|
||||
screen and the audio in flight have diverged.
|
||||
|
||||
Related: a sub-threshold click *inside* an existing selection deliberately does
|
||||
not clear it (`hoverInsideSelection` in `spectrogram.c`), because silently
|
||||
clearing changes what **Space** would play. A click on empty space still resets
|
||||
to full range.
|
||||
|
||||
**Touches:** `audio.c` (`PlaySelectedRegion`), `spectrogram.c` (playhead
|
||||
advance, scope cursor), `render.c` (`DrawPlayhead`), `spectrogram_types.h`
|
||||
(`playSelStart` / `playSelEnd` / `playDuration`).
|
||||
|
||||
---
|
||||
|
||||
## Long-file zoom sharpness lags the zoom gesture
|
||||
|
||||
**Status:** working as designed, but reads as a bug.
|
||||
|
||||
`ComputeSkipFactor()` (`stft.c`) strides the initial STFT pass for long files —
|
||||
every 8th segment past 10 minutes — so the overview loads promptly. The missing
|
||||
segments are filled at full resolution afterwards: the visible range first, then
|
||||
a background sweep of the whole file.
|
||||
|
||||
The practical effect is that a hard zoom into a 30-minute file can look blocky
|
||||
for a moment before the foreground fill catches up and it sharpens. The fill is
|
||||
gated on `view.end - view.start <= 0.25f`, so it only runs once reasonably zoomed
|
||||
in. If a view stays blocky indefinitely, the fill is not reaching that range and
|
||||
that *is* a real bug worth chasing.
|
||||
|
||||
---
|
||||
|
||||
## Load time on long files is unbounded and unreported
|
||||
|
||||
A 30-minute 48 kHz file spends a long time in `Processing…` before the UI is
|
||||
usable, and the percentage indicator advances non-linearly (the strided overview
|
||||
completes fast, the high-res fill does not). There is no cancel. Headless/scripted
|
||||
runs must wait this out; see `AGENTS.md`.
|
||||
@@ -71,6 +71,23 @@ for act in "$@"; do
|
||||
xd mouseup 1; sleep 0.1
|
||||
xd keyup alt; sleep 0.15
|
||||
;;
|
||||
wheel)
|
||||
# "wheel X Y N [mod]" — N wheel clicks at (X,Y); N<0 scrolls down.
|
||||
# X11 maps wheel up/down to buttons 4/5. Each click needs its own
|
||||
# frame, same edge-detect reason as the click helper above.
|
||||
xd mousemove "$2" "$3"; sleep 0.1
|
||||
_n="$4"; _btn=4
|
||||
if [ "$_n" -lt 0 ]; then _btn=5; _n=$(( -_n )); fi
|
||||
_mod="${5:-}"
|
||||
[ -n "$_mod" ] && { xd keydown "$_mod"; sleep 0.05; }
|
||||
_i=0
|
||||
while [ "$_i" -lt "$_n" ]; do
|
||||
xd click "$_btn"; sleep 0.05
|
||||
_i=$(( _i + 1 ))
|
||||
done
|
||||
[ -n "$_mod" ] && { xd keyup "$_mod"; sleep 0.05; }
|
||||
sleep 0.15
|
||||
;;
|
||||
*) xd "$@"; sleep 0.15 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
+17
-3
@@ -204,10 +204,16 @@ static float* BuildSelectionAudio(int* outNumSamples)
|
||||
if (!regionSamples) return NULL;
|
||||
memcpy(regionSamples, app.signal.samples + startSample, numSamples * sizeof(float));
|
||||
|
||||
float maxFreq = (float)app.signal.sampleRate / 2.0f;
|
||||
// sel.freq* are fractions of the *displayed* axis (capped at
|
||||
// EffectiveMaxFreqHz), not of true Nyquist — same convention the PNG export
|
||||
// in ui.c uses. Convert through EffectiveMaxFreqHz so a display crop doesn't
|
||||
// scale the passband up by 1/DisplayFreqFraction(). The filter itself still
|
||||
// works in true-Nyquist terms, which is what nyquist is for.
|
||||
float nyquist = (float)app.signal.sampleRate / 2.0f;
|
||||
float maxFreq = EffectiveMaxFreqHz();
|
||||
float freqLow = app.sel.freqStart * maxFreq;
|
||||
float freqHigh = app.sel.freqEnd * maxFreq;
|
||||
if (freqLow > 10.0f || freqHigh < maxFreq - 10.0f) {
|
||||
if (freqLow > 10.0f || freqHigh < nyquist - 10.0f) {
|
||||
TraceLog(LOG_INFO, "Applying bandpass filter: %.0f - %.0f Hz", freqLow, freqHigh);
|
||||
ApplyBandpassFilter(regionSamples, numSamples, app.signal.sampleRate, freqLow, freqHigh);
|
||||
}
|
||||
@@ -237,6 +243,14 @@ void PlaySelectedRegion(void)
|
||||
float* regionSamples = BuildSelectionAudio(&numSamples);
|
||||
if (!regionSamples) return;
|
||||
|
||||
// Snapshot what we're about to play so the playhead tracks THIS region even
|
||||
// if the user moves the selection mid-playback. Duration comes from the
|
||||
// buffer we actually built, not from app.signal.duration.
|
||||
app.playSelStart = app.sel.timeStart;
|
||||
app.playSelEnd = app.sel.timeEnd;
|
||||
app.playDuration = (app.signal.sampleRate > 0)
|
||||
? (float)numSamples / (float)app.signal.sampleRate : 0.0f;
|
||||
|
||||
EnsureAudioDevice(); // opened on demand; released again once playback ends
|
||||
if (AudioPlaybackSound.frameCount != 0) UnloadSound(AudioPlaybackSound);
|
||||
|
||||
@@ -257,7 +271,7 @@ void ExportSelectionWAV(const char* dirPath)
|
||||
return;
|
||||
}
|
||||
|
||||
float maxFreq = (float)app.signal.sampleRate / 2.0f;
|
||||
float maxFreq = EffectiveMaxFreqHz(); // sel.freq* are display-axis fractions
|
||||
float t0 = app.sel.timeStart * app.signal.duration;
|
||||
float t1 = app.sel.timeEnd * app.signal.duration;
|
||||
float f0 = app.sel.freqStart * maxFreq;
|
||||
|
||||
+14
-4
@@ -33,9 +33,16 @@ static int AmplitudeToY(ScopeView* view, float amp)
|
||||
return view->y + view->height - (int)((amp - view->ampMin) / (view->ampMax - view->ampMin) * view->height);
|
||||
}
|
||||
|
||||
// Map a signal-space time (0-1 over the WHOLE signal) to a screen X, honoring
|
||||
// the visible window. The waveform envelope below already draws only
|
||||
// viewStart..viewEnd, so grid lines and the cursor have to use the same mapping
|
||||
// or they drift out of register with the trace (and with the spectrogram above)
|
||||
// as soon as the user zooms or pans.
|
||||
static int TimeToX(ScopeView* view, float t)
|
||||
{
|
||||
return view->x + (int)(t * view->width);
|
||||
float span = view->viewEnd - view->viewStart;
|
||||
if (span <= 0.0f) span = 1.0f;
|
||||
return view->x + (int)((t - view->viewStart) / span * view->width);
|
||||
}
|
||||
|
||||
void DrawScopeView(ScopeView* view, float cursorT)
|
||||
@@ -56,9 +63,11 @@ void DrawScopeView(ScopeView* view, float cursorT)
|
||||
if (view->showGrid) {
|
||||
Color gridColor = (Color){ view->gridR, view->gridG, view->gridB, (int)(view->gridAlpha * 255) };
|
||||
|
||||
// Vertical time divisions
|
||||
// Vertical time divisions — ten evenly spaced lines across the VISIBLE
|
||||
// window, so the grid stays put under zoom instead of sliding off.
|
||||
for (int i = 0; i <= 10; i++) {
|
||||
int x = TimeToX(view, (float)i / 10.0f);
|
||||
float t = view->viewStart + (float)i / 10.0f * (view->viewEnd - view->viewStart);
|
||||
int x = TimeToX(view, t);
|
||||
DrawLineV((Vector2){ x, view->y }, (Vector2){ x, view->y + view->height }, gridColor);
|
||||
}
|
||||
|
||||
@@ -119,7 +128,8 @@ void DrawScopeView(ScopeView* view, float cursorT)
|
||||
DrawLine(px + view->x, yTop, px + view->x, yBot, waveColor);
|
||||
}
|
||||
|
||||
// Cursor
|
||||
// Cursor. cursorT is signal-space; TimeToX maps it into the visible window,
|
||||
// and the bounds check below drops it when it falls outside the current view.
|
||||
if (cursorT >= 0.0f && cursorT <= 1.0f) {
|
||||
int cursorX = TimeToX(view, cursorT);
|
||||
if (cursorX >= view->x && cursorX <= view->x + view->width) {
|
||||
|
||||
+24
-4
@@ -355,14 +355,33 @@ void DrawLabels(Rectangle bounds)
|
||||
int baseFontSize = 12;
|
||||
Color textColor = LIGHTGRAY;
|
||||
|
||||
// Time labels
|
||||
// Time labels. Precision tracks the zoom: the span across two adjacent
|
||||
// labels decides how many decimals are meaningful. Without this a deep
|
||||
// zoom prints the same "%.1fs" value in every slot, which reads as a
|
||||
// frozen axis even though the view is moving.
|
||||
float viewSpanSec = (app.view.end - app.view.start) * app.signal.duration;
|
||||
float labelStepSec = viewSpanSec / 10.0f;
|
||||
int decimals;
|
||||
if (labelStepSec >= 1.0f) decimals = 1;
|
||||
else if (labelStepSec >= 0.1f) decimals = 2;
|
||||
else if (labelStepSec >= 0.01f) decimals = 3;
|
||||
else decimals = 4;
|
||||
|
||||
for (int i = 0; i <= 10; i++) {
|
||||
float t = (float)i / 10;
|
||||
float timeSec = (app.view.start + t * (app.view.end - app.view.start)) * app.signal.duration;
|
||||
float x = bounds.x + t * bounds.width;
|
||||
char label[32];
|
||||
if (timeSec >= 60) sprintf(label, "%d:%02d", (int)(timeSec / 60), (int)(timeSec) % 60);
|
||||
else sprintf(label, "%.1fs", timeSec);
|
||||
// Past a minute the m:ss form stays readable only while the step is
|
||||
// coarse; zoomed in we need the fractional seconds inside the minute.
|
||||
if (timeSec >= 60) {
|
||||
int mins = (int)(timeSec / 60);
|
||||
float secs = timeSec - mins * 60.0f;
|
||||
if (labelStepSec >= 1.0f) sprintf(label, "%d:%02d", mins, (int)secs);
|
||||
else sprintf(label, "%d:%0*.*f", mins, decimals + 3, decimals, secs);
|
||||
} else {
|
||||
sprintf(label, "%.*fs", decimals, timeSec);
|
||||
}
|
||||
DrawTextScaled(label, x, bounds.y + bounds.height + 5, baseFontSize, textColor);
|
||||
}
|
||||
|
||||
@@ -1685,7 +1704,8 @@ void DrawPlayhead(Rectangle bounds)
|
||||
{
|
||||
if (!app.isPlaying || app.playheadT < 0.0f || app.playheadT > 1.0f) return;
|
||||
|
||||
float timePos = app.sel.timeStart + app.playheadT * (app.sel.timeEnd - app.sel.timeStart);
|
||||
// Against the snapshot of the playing region, not the live selection.
|
||||
float timePos = app.playSelStart + app.playheadT * (app.playSelEnd - app.playSelStart);
|
||||
float viewWidth = app.view.end - app.view.start;
|
||||
float t = (timePos - app.view.start) / viewWidth;
|
||||
float x = bounds.x + t * bounds.width;
|
||||
|
||||
+36
-11
@@ -945,11 +945,14 @@ int main(int argc, char* argv[])
|
||||
app.isPlaying = false;
|
||||
app.playbackFinished = true;
|
||||
}
|
||||
// Track playhead position manually
|
||||
// Track playhead position manually, against the length of the buffer
|
||||
// that's actually playing (see playDuration) rather than a length
|
||||
// re-derived from the live selection — the user can move the
|
||||
// selection mid-playback without the marker jumping.
|
||||
app.playheadElapsed += GetFrameTime();
|
||||
float selectionDuration = (app.sel.timeEnd - app.sel.timeStart) * app.signal.duration;
|
||||
if (selectionDuration > 0) {
|
||||
app.playheadT = app.playheadElapsed / selectionDuration;
|
||||
if (app.playDuration > 0.0f) {
|
||||
app.playheadT = app.playheadElapsed / app.playDuration;
|
||||
if (app.playheadT > 1.0f) app.playheadT = 1.0f;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -973,18 +976,32 @@ int main(int argc, char* argv[])
|
||||
float spectroHeight = L.spectroHeight;
|
||||
Rectangle viewBounds = L.viewBounds;
|
||||
|
||||
// Zoom with mouse wheel (zooms both time and frequency to maintain aspect ratio)
|
||||
// Zoom with mouse wheel. Bare wheel zooms both axes together (keeps
|
||||
// the aspect ratio); Shift+wheel is time-only and Ctrl+wheel is
|
||||
// frequency-only, for when you need to stretch one axis alone.
|
||||
if (GetMousePosition().x > sidebarWidth + 5 && CheckCollisionPointRec(GetMousePosition(), viewBounds)) {
|
||||
int wheel = GetMouseWheelMove();
|
||||
if (wheel != 0) {
|
||||
float zoomFactor = (wheel > 0) ? 0.8f : 1.2f;
|
||||
|
||||
|
||||
bool shiftHeld = IsKeyDown(KEY_LEFT_SHIFT) || IsKeyDown(KEY_RIGHT_SHIFT);
|
||||
bool ctrlHeld = IsKeyDown(KEY_LEFT_CONTROL) || IsKeyDown(KEY_RIGHT_CONTROL);
|
||||
bool zoomTime = !ctrlHeld; // Ctrl = frequency only
|
||||
bool zoomFreq = !shiftHeld; // Shift = time only
|
||||
|
||||
// --- Time axis zoom (around cursor X) ---
|
||||
if (zoomTime) {
|
||||
float mouseT = (GetMousePosition().x - viewBounds.x) / viewBounds.width;
|
||||
mouseT = app.view.start + mouseT * (app.view.end - app.view.start);
|
||||
float viewWidth = app.view.end - app.view.start;
|
||||
float newWidth = viewWidth * zoomFactor;
|
||||
if (newWidth < 0.02f) newWidth = 0.02f;
|
||||
// Floor the window in SECONDS, not as a fraction of the file.
|
||||
// A flat 2% floor meant a 30-minute recording could never show
|
||||
// less than 36 s, while a 30-second one bottomed out at 0.6 s.
|
||||
// The real limit is the STFT hop: once fewer than a handful of
|
||||
// segments span the viewport there is no more detail to expose.
|
||||
float minWidth = MinTimeViewWidth();
|
||||
if (newWidth < minWidth) newWidth = minWidth;
|
||||
if (newWidth > 1.0f) newWidth = 1.0f;
|
||||
float leftOfMouse = mouseT - app.view.start;
|
||||
float rightOfMouse = app.view.end - mouseT;
|
||||
@@ -992,8 +1009,10 @@ int main(int argc, char* argv[])
|
||||
app.view.end = mouseT + rightOfMouse * (newWidth / viewWidth);
|
||||
if (app.view.start < 0) { app.view.start = 0; app.view.end = newWidth; }
|
||||
if (app.view.end > 1) { app.view.end = 1; app.view.start = 1 - newWidth; }
|
||||
|
||||
}
|
||||
|
||||
// --- Frequency axis zoom (around cursor Y) ---
|
||||
if (zoomFreq) {
|
||||
float mouseF = 1.0f - (GetMousePosition().y - viewBounds.y) / viewBounds.height;
|
||||
mouseF = app.view.freqStart + mouseF * (app.view.freqEnd - app.view.freqStart);
|
||||
float freqWidth = app.view.freqEnd - app.view.freqStart;
|
||||
@@ -1006,6 +1025,7 @@ int main(int argc, char* argv[])
|
||||
// Clamp to physical frequency limits [0, 1] — can't see beyond Nyquist or below 0 Hz
|
||||
if (app.view.freqStart < 0) { app.view.freqStart = 0; app.view.freqEnd = fminf(app.view.freqEnd, 1.0f); }
|
||||
if (app.view.freqEnd > 1) { app.view.freqEnd = 1; app.view.freqStart = fmaxf(app.view.freqStart, 0.0f); }
|
||||
}
|
||||
|
||||
// Invalidate texture cache
|
||||
app.visibleTextureValid = false;
|
||||
@@ -1324,8 +1344,13 @@ int main(int argc, char* argv[])
|
||||
app.sel.freqStart = app.sel.freqEnd;
|
||||
app.sel.freqEnd = tmp;
|
||||
}
|
||||
} else {
|
||||
// Drag too small - revert to full range
|
||||
} else if (!hoverInsideSelection) {
|
||||
// Sub-threshold drag outside any existing selection: treat
|
||||
// as a click on empty space and reset to full range. A
|
||||
// stray click *inside* the current box leaves it alone —
|
||||
// silently clearing it there changes what Space plays.
|
||||
// (hoverInsideSelection, not clickInsideSelection: the
|
||||
// latter is press-frame-only and is always false here.)
|
||||
ClearSelection();
|
||||
}
|
||||
app.sel.isTimeSelecting = false;
|
||||
@@ -1715,7 +1740,7 @@ int main(int argc, char* argv[])
|
||||
app.scopeView.data.sampleRate = app.signal.sampleRate;
|
||||
// Show playhead if playing
|
||||
if (app.isPlaying) {
|
||||
DrawScopeView(&app.scopeView, app.sel.timeStart + app.playheadT * (app.sel.timeEnd - app.sel.timeStart));
|
||||
DrawScopeView(&app.scopeView, app.playSelStart + app.playheadT * (app.playSelEnd - app.playSelStart));
|
||||
} else {
|
||||
DrawScopeView(&app.scopeView, -1.0f);
|
||||
}
|
||||
|
||||
+35
-1
@@ -138,9 +138,19 @@ typedef struct {
|
||||
bool stftComputed;
|
||||
|
||||
// Playback state
|
||||
float playheadT; // 0-1 normalized position in selection
|
||||
float playheadT; // 0-1 normalized position within the PLAYING region
|
||||
float playheadElapsed; // Elapsed seconds since play started
|
||||
|
||||
// Snapshot of the region actually handed to the audio device, captured at
|
||||
// PlaySelectedRegion time. The playhead must be measured against this, not
|
||||
// against the live app.sel — the user can move or resize the selection while
|
||||
// audio is still playing, and the marker has to keep tracking the sound
|
||||
// that's really coming out. playDuration comes from the buffer's own sample
|
||||
// count / sampleRate, so it can't drift from app.signal.duration (which is
|
||||
// derived pre-mono-downmix and disagrees for stereo files).
|
||||
float playSelStart, playSelEnd; // sel.timeStart/End when playback began
|
||||
float playDuration; // true length of the playing buffer, seconds
|
||||
|
||||
// Time + frequency box selection and its drag/move interaction state.
|
||||
Selection sel;
|
||||
|
||||
@@ -342,6 +352,30 @@ static inline float DisplayFreqFraction(void)
|
||||
return EffectiveMaxFreqHz() / nyq;
|
||||
}
|
||||
|
||||
// Tightest allowed time window, as a fraction of the whole file.
|
||||
//
|
||||
// This MUST be derived from the file's duration rather than being a flat
|
||||
// fraction: view.start/end are normalized to the file, so a constant floor
|
||||
// makes the achievable time resolution scale with file length (a flat 2%
|
||||
// capped a 30-minute recording at a 36-second window, while a 30-second one
|
||||
// reached 0.6 s). The physical limit is the STFT hop — segments sit
|
||||
// fftSize/HOP_RATIO samples apart, so once only a few segments span the
|
||||
// viewport there is no further detail to reveal and zooming past that just
|
||||
// interpolates. MIN_VISIBLE_SEGMENTS sets how many must stay in view.
|
||||
#define MIN_VISIBLE_SEGMENTS 8
|
||||
static inline float MinTimeViewWidth(void)
|
||||
{
|
||||
if (app.signal.sampleRate <= 0 || app.signal.duration <= 0.0f) return 0.02f;
|
||||
int hopSamples = app.fftSize / HOP_RATIO;
|
||||
if (hopSamples < 1) hopSamples = 1;
|
||||
float hopSec = (float)hopSamples / (float)app.signal.sampleRate;
|
||||
float minSpanSec = hopSec * (float)MIN_VISIBLE_SEGMENTS;
|
||||
float w = minSpanSec / app.signal.duration;
|
||||
if (w > 1.0f) w = 1.0f; // file shorter than the floor: whole file is the min
|
||||
if (w < 1e-7f) w = 1e-7f; // guard float precision in the view math
|
||||
return w;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Keymap — single source of truth for global key bindings.
|
||||
// The dispatcher (DispatchKeymap in spectrogram.c) runs every entry whose
|
||||
|
||||
@@ -765,7 +765,10 @@ void DrawAboutDialog(void)
|
||||
DrawTextScaled(TextFormat(" %-5s %s", km[i].label, km[i].help), px, py, 13, LIGHTGRAY);
|
||||
py += 16 * scale;
|
||||
}
|
||||
DrawTextScaled(" Mouse wheel = zoom, Alt+drag = pan, drag = select box",
|
||||
DrawTextScaled(" Mouse wheel = zoom (Shift = time only, Ctrl = freq only)",
|
||||
px, py, 13, LIGHTGRAY);
|
||||
py += 16 * scale;
|
||||
DrawTextScaled(" Alt+drag = pan, drag = select box",
|
||||
px, py, 13, LIGHTGRAY);
|
||||
|
||||
DrawTextScaled("F1 / Esc / click to close", panel.x + pw - 196 * scale,
|
||||
|
||||
Reference in New Issue
Block a user