feat: add waveform scope view with resizable divider

- Create primitives.h/c for waveform visualization (time/amplitude display)
- Scope view draws signal as per-pixel min/max envelope (Audacity-style)
- Add draggable divider between spectrogram and scope (30%-80% range)
- Fix selection bounds to use correct spectrogram area (not full viewport)
- Scope syncs with spectrogram zoom/pan for aligned time axis
This commit is contained in:
2026-05-24 23:35:17 -07:00
parent cc58965acc
commit 0e72bf4172
4 changed files with 324 additions and 6 deletions
+135 -6
View File
@@ -5,6 +5,7 @@
#include "resource_dir.h"
#include "platform.h"
#include "utils.h"
#include "primitives.h"
#include <stdlib.h>
#include <string.h>
@@ -172,6 +173,16 @@ typedef struct {
// the current view range.
int skipFactor;
bool highResFinished;
// Waveform scope view (underneath spectrogram viewport)
ScopeView scopeView;
bool showScope; // Toggle to show/hide scope view
// Scope view divider
float dividerY; // Y position of divider between spectrogram and scope (0-1 normalized)
bool isDividing; // True while user is dragging the divider
Vector2 dividerStartPos; // Mouse position when started dividing
float dividerStartY; // Spectro height when started dividing
} SpectrogramApp;
// ============================================================================
@@ -1733,7 +1744,17 @@ int main(int argc, char* argv[])
app.highResFinished = false;
app.isPlaying = false;
app.playbackFinished = false;
app.showScope = true;
app.dividerY = 0.6f; // Start with 60% spectro, 40% scope
app.isDividing = false;
app.dividerStartPos = (Vector2){ 0, 0 };
app.dividerStartY = 0;
// Initialize scope view (data synced in render loop when signal loads)
InitScopeView(&app.scopeView,
(WaveformData){app.signal.samples, app.signal.numSamples, app.signal.sampleRate},
0, 0, GetScreenWidth(), 200);
GenerateColormapTexture();
ScanDirectory(GetWorkingDirectory());
@@ -1802,6 +1823,11 @@ int main(int argc, char* argv[])
ScanDirectory(GetWorkingDirectory());
}
// Scope view toggle (P key)
if (IsKeyPressed(KEY_P)) {
app.showScope = !app.showScope;
}
// File browser update
if (app.showFileBrowser) {
// Browser handles its own input
@@ -1839,11 +1865,12 @@ int main(int argc, char* argv[])
float topMargin = 50 * viewScale;
float bottomMargin = 10 * viewScale;
float spectroHeight = (GetScreenHeight() - topMargin - bottomMargin - labelHeight - scrollbarHeight - 10 * viewScale) * app.dividerY;
Rectangle viewBounds = {
sidebarWidth + freqLabelWidth,
topMargin,
GetScreenWidth() - sidebarWidth - freqLabelWidth - vScrollbarWidth - 20 * viewScale,
GetScreenHeight() - topMargin - bottomMargin - labelHeight - scrollbarHeight - 10 * viewScale
spectroHeight
};
// Zoom with mouse wheel (zooms both time and frequency to maintain aspect ratio)
@@ -2023,14 +2050,20 @@ int main(int argc, char* argv[])
float selVScrollbarWidth = 18 * selScale;
float selTopMargin = 50 * selScale;
float selBottomMargin = 10 * selScale;
float selSpectroHeight = (GetScreenHeight() - selTopMargin - selBottomMargin - selLabelHeight - selScrollbarHeight - 10.0f * selScale) * app.dividerY;
Rectangle selBounds = {
selSidebarWidth + selFreqLabelWidth,
selTopMargin,
GetScreenWidth() - selSidebarWidth - selFreqLabelWidth - selVScrollbarWidth - 20.0f * selScale,
GetScreenHeight() - selTopMargin - selBottomMargin - selLabelHeight - selScrollbarHeight - 10.0f * selScale
selSpectroHeight
};
Vector2 mousePos = GetMousePosition();
// Calculate divider screen position (for hover detection)
float dividerScreenY = selTopMargin + selSpectroHeight;
bool mouseNearDivider = mousePos.y >= (dividerScreenY - 5) && mousePos.y <= (dividerScreenY + 5) &&
mousePos.x >= selBounds.x && mousePos.x <= selBounds.x + selBounds.width;
// Right-click clears selection
if (IsMouseButtonPressed(MOUSE_BUTTON_RIGHT) && CheckCollisionPointRec(mousePos, selBounds)) {
@@ -2072,6 +2105,16 @@ int main(int argc, char* argv[])
// LMB drag = box select (time + frequency) OR drag existing selection
if (app.loaded && !app.showFileBrowser && CheckCollisionPointRec(mousePos, selBounds)) {
// Set cursor to resize all when near divider
if (mouseNearDivider && !app.isDividing) {
SetMouseCursor(MOUSE_CURSOR_RESIZE_ALL);
} else if (app.isDraggingSelection) {
SetMouseCursor(MOUSE_CURSOR_RESIZE_ALL); // 4-way arrow while dragging
} else if (hoverInsideSelection) {
SetMouseCursor(MOUSE_CURSOR_POINTING_HAND); // Pointing hand on hover
} else {
SetMouseCursor(MOUSE_CURSOR_DEFAULT); // Normal arrow
}
if (IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) {
if (clickInsideSelection) {
// Start dragging existing selection
@@ -2157,7 +2200,35 @@ int main(int argc, char* argv[])
}
}
}
// Handle divider drag
if (app.loaded && app.showScope && !app.showFileBrowser) {
// Calculate divider position (using same calculation as selBounds)
float viewScale = GetUIScale();
float topMargin = 50 * viewScale;
float bottomMargin = 10 * viewScale;
float labelHeight = 15 * viewScale;
float scrollbarHeight = 18 * viewScale;
float totalHeight = GetScreenHeight() - topMargin - bottomMargin - labelHeight - scrollbarHeight;
// Check if mouse is near divider
if (mouseNearDivider && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) {
app.isDividing = true;
app.dividerStartPos = mousePos;
app.dividerStartY = app.dividerY;
}
if (app.isDividing && IsMouseButtonDown(MOUSE_LEFT_BUTTON)) {
float dy = (mousePos.y - app.dividerStartPos.y) / GetScreenHeight();
app.dividerY = app.dividerStartY + dy;
// Clamp to reasonable range: 0.3 to 0.8 (30% to 80% for spectrogram)
if (app.dividerY < 0.3f) app.dividerY = 0.3f;
if (app.dividerY > 0.8f) app.dividerY = 0.8f;
}
if (IsMouseButtonReleased(MOUSE_LEFT_BUTTON)) {
app.isDividing = false;
}
}
// Processing (incremental across frames)
if (app.loaded && !app.stftComputed) {
if (app.loadingPhase == 0) {
@@ -2258,11 +2329,12 @@ int main(int argc, char* argv[])
float bottomMargin = 10 * renderScale;
// Spectrogram area (excludes labels and scrollbars)
float spectroHeight = (GetScreenHeight() - topMargin - bottomMargin - labelHeight - scrollbarHeight - 10 * renderScale) * app.dividerY;
Rectangle viewBounds = {
sidebarWidth + freqLabelWidth,
topMargin,
GetScreenWidth() - sidebarWidth - freqLabelWidth - vScrollbarWidth - 20 * renderScale,
GetScreenHeight() - topMargin - bottomMargin - labelHeight - scrollbarHeight - 10 * renderScale
spectroHeight
};
// Time labels sit just below the spectrogram
Rectangle timeLabelArea = { viewBounds.x, viewBounds.y + viewBounds.height, viewBounds.width, labelHeight };
@@ -2397,6 +2469,63 @@ int main(int argc, char* argv[])
float freqMax = app.freqViewEnd * maxFreq;
DrawTextScaled(TextFormat("Freq: %.0f-%.0f Hz", freqMin, freqMax),
viewBounds.x, viewBounds.y - 30, 20, LIGHTGRAY);
// Draw waveform scope view underneath the spectrogram
if (app.showScope && app.loaded && app.signal.samples != NULL) {
float totalArea = GetScreenHeight() - topMargin - bottomMargin - labelHeight - scrollbarHeight - 10 * renderScale;
float scopeHeight = totalArea * (1.0f - app.dividerY) - 30 * renderScale;
app.scopeView.y = viewBounds.y + viewBounds.height + 30;
app.scopeView.x = viewBounds.x;
app.scopeView.width = viewBounds.width;
app.scopeView.height = (int)scopeHeight;
// Keep time view in sync with spectrogram view
app.scopeView.viewStart = app.viewStart;
app.scopeView.viewEnd = app.viewEnd;
// Update waveform data
app.scopeView.data.samples = app.signal.samples;
app.scopeView.data.numSamples = app.signal.numSamples;
app.scopeView.data.sampleRate = app.signal.sampleRate;
// Show playhead if playing
if (app.isPlaying) {
DrawScopeView(&app.scopeView, app.timeSelectionStart + app.playheadT * (app.timeSelectionEnd - app.timeSelectionStart));
} else {
DrawScopeView(&app.scopeView, -1.0f);
}
// Draw scope label
DrawTextScaled("Waveform (Time/Amplitude)", viewBounds.x, app.scopeView.y - 20, 14, LIGHTGRAY);
}
// Draw divider line between spectrogram and scope
if (app.showScope && app.loaded) {
float dividerY = viewBounds.y + viewBounds.height;
Color dividerColor = app.isDividing ? CYAN : Fade((Color){ 180, 180, 200, 255 }, 0.7f);
// Draw divider with handle
int handleW = 40;
int handleH = 10;
int handleX = viewBounds.x + viewBounds.width / 2 - handleW / 2;
int handleY = (int)dividerY - handleH / 2;
if (app.isDividing) {
// Highlighted handle while dragging
DrawRectangle(handleX, handleY, handleW, handleH, CYAN);
DrawRectangleLines(handleX, handleY, handleW, handleH, WHITE);
} else {
// Normal handle
DrawRectangle(handleX, handleY, handleW, handleH, GRAY);
DrawRectangleLines(handleX, handleY, handleW, handleH, Fade(YELLOW, 0.6f));
// Draw handle grips (3 dots)
Color gripColor = Fade(WHITE, 0.6f);
DrawPixel(handleX + handleW / 3, handleY + handleH / 2, gripColor);
DrawPixel(handleX + handleW / 2, handleY + handleH / 2, gripColor);
DrawPixel(handleX + handleW * 2 / 3, handleY + handleH / 2, gripColor);
}
// Draw line extending from handle to edges
DrawLine(viewBounds.x, (int)dividerY, handleX, (int)dividerY, dividerColor);
DrawLine(handleX + handleW, (int)dividerY, viewBounds.x + viewBounds.width, (int)dividerY, dividerColor);
}
} else if (!app.showFileBrowser) {
const char* msg1 = "Press 'O' or click 'Open File Browser' to load a WAV";
const char* msg2 = "Or drag & drop a file, or use: ./rspektrum <file.wav>";