diff --git a/build_web.sh b/build_web.sh new file mode 100755 index 0000000..7db3d24 --- /dev/null +++ b/build_web.sh @@ -0,0 +1,74 @@ +#!/bin/bash +# Build spectrogram viewer for the web using Emscripten +# Usage: ./build_web.sh [debug|release] + +set -e + +# Get script directory (project root) +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$SCRIPT_DIR" + +BUILD_TYPE=${1:-release} +RAYLIB_DIR="$SCRIPT_DIR/build/external/raylib-master" +SRC_DIR="$SCRIPT_DIR/src" +BUILD_DIR="$SCRIPT_DIR/bin/web" +RAYLIB_BUILD="$BUILD_DIR/raylib_build" +OUTPUT="$BUILD_DIR/rspektrum.html" + +# Create build directories +mkdir -p "$BUILD_DIR" "$RAYLIB_BUILD" + +echo "=== Building Spectrogram Viewer for Web ($BUILD_TYPE) ===" +echo "Raylib dir: $RAYLIB_DIR" + +# Common compile flags for raylib +RAYLIB_CFLAGS="-Os -Wall -DPLATFORM_WEB -DGRAPHICS_API_OPENGL_ES2" +if [ "$BUILD_TYPE" = "debug" ]; then + RAYLIB_CFLAGS="-g -O0 -Wall -DPLATFORM_WEB -DGRAPHICS_API_OPENGL_ES2" +fi + +echo "=== Step 1: Compiling raylib for web ===" + +# Compile raylib source files +emcc -c "$RAYLIB_DIR/src/rcore.c" $RAYLIB_CFLAGS -o "$RAYLIB_BUILD/rcore.o" +emcc -c "$RAYLIB_DIR/src/rshapes.c" $RAYLIB_CFLAGS -o "$RAYLIB_BUILD/rshapes.o" +emcc -c "$RAYLIB_DIR/src/rtextures.c" $RAYLIB_CFLAGS -o "$RAYLIB_BUILD/rtextures.o" +emcc -c "$RAYLIB_DIR/src/rtext.c" $RAYLIB_CFLAGS -o "$RAYLIB_BUILD/rtext.o" +emcc -c "$RAYLIB_DIR/src/rmodels.c" $RAYLIB_CFLAGS -o "$RAYLIB_BUILD/rmodels.o" +emcc -c "$RAYLIB_DIR/src/raudio.c" $RAYLIB_CFLAGS -o "$RAYLIB_BUILD/raudio.o" + +# Create static library +cd "$RAYLIB_BUILD" +emar rcs libraylib.a rcore.o rshapes.o rtextures.o rtext.o rmodels.o raudio.o +cd "$SCRIPT_DIR" + +echo "=== Step 2: Compiling spectrogram for web ===" +cd "$SCRIPT_DIR" + +# Linker flags +LDFLAGS="-s USE_GLFW=3 -s ASYNCIFY -s TOTAL_MEMORY=67108864 -s FORCE_FILESYSTEM=1 --preload-file resources@resources --shell-file $SCRIPT_DIR/web_shell.html -s NO_EXIT_RUNTIME=1" + +if [ "$BUILD_TYPE" = "debug" ]; then + LDFLAGS="$LDFLAGS -g -O0 -s ASSERTIONS=1" +else + LDFLAGS="$LDFLAGS -O2" +fi + +# Compile and link +emcc -o "$OUTPUT" \ + "$SRC_DIR/spectrogram.c" \ + -I"$RAYLIB_DIR/src" \ + -I"$RAYLIB_DIR/src/external" \ + -I"$RAYLIB_DIR/src/external/glfw/include" \ + -Iinclude \ + -DPLATFORM_WEB \ + "$RAYLIB_BUILD/libraylib.a" \ + $LDFLAGS + +echo "" +echo "=== Build complete ===" +echo "Output: $OUTPUT" +echo "" +echo "To test locally, run:" +echo " cd $BUILD_DIR && python3 -m http.server 8080" +echo "Then open http://localhost:8080/rspektrum.html in your browser" diff --git a/src/spectrogram.c b/src/spectrogram.c index 465e340..633320f 100644 --- a/src/spectrogram.c +++ b/src/spectrogram.c @@ -96,6 +96,11 @@ typedef struct { float freqSelectionStart; float freqSelectionEnd; bool isFreqSelecting; + Vector2 selectStartPos; // For minimum drag distance check + bool isDraggingSelection; // Dragging existing selection box + Vector2 dragSelectionStartPos; // Mouse position when started dragging selection + float dragSelectionTimeStart; // Selection start time when dragging + float dragSelectionFreqStart; // Selection freq start when dragging // Viewport/zoom controls float viewStart; // 0-1, start of visible time region @@ -941,26 +946,66 @@ static void DrawSidebar(void); static void DrawSelection(Rectangle bounds) { - Color overlayColor = Fade(BLACK, 0.5f); - float selStartX = bounds.x + app.timeSelectionStart * bounds.width; - float selEndX = bounds.x + app.timeSelectionEnd * bounds.width; + // Only draw if selection is not full range AND not currently dragging + bool hasSelection = (app.timeSelectionStart > 0.001f || app.timeSelectionEnd < 0.999f || + app.freqSelectionStart > 0.001f || app.freqSelectionEnd < 0.999f); + if (!hasSelection || app.isTimeSelecting) return; // Don't draw overlay while dragging + + Color overlayColor = Fade(BLACK, 0.25f); // Lighter overlay + + // Convert signal coordinates to viewport coordinates + float viewWidth = app.viewEnd - app.viewStart; + float freqWidth = app.freqViewEnd - app.freqViewStart; + + float selStartX = bounds.x + ((app.timeSelectionStart - app.viewStart) / viewWidth) * bounds.width; + float selEndX = bounds.x + ((app.timeSelectionEnd - app.viewStart) / viewWidth) * bounds.width; + float selStartY = bounds.y + bounds.height - ((app.freqSelectionEnd - app.freqViewStart) / freqWidth) * bounds.height; + float selEndY = bounds.y + bounds.height - ((app.freqSelectionStart - app.freqViewStart) / freqWidth) * bounds.height; + + // Clamp to viewport bounds + selStartX = fmaxf(bounds.x, fminf(bounds.x + bounds.width, selStartX)); + selEndX = fmaxf(bounds.x, fminf(bounds.x + bounds.width, selEndX)); + selStartY = fmaxf(bounds.y, fminf(bounds.y + bounds.height, selStartY)); + selEndY = fmaxf(bounds.y, fminf(bounds.y + bounds.height, selEndY)); + + // Draw overlay outside the selection box + DrawRectangle(bounds.x, bounds.y, selStartX - bounds.x, bounds.height, overlayColor); + DrawRectangle(selEndX, bounds.y, bounds.x + bounds.width - selEndX, bounds.height, overlayColor); + DrawRectangle(selStartX, bounds.y, selEndX - selStartX, selStartY - bounds.y, overlayColor); + DrawRectangle(selStartX, selEndY, selEndX - selStartX, bounds.y + bounds.height - selEndY, overlayColor); + + // Draw selection box border + DrawRectangleLinesEx((Rectangle){ selStartX, selStartY, selEndX - selStartX, selEndY - selStartY }, 2, YELLOW); +} - if (selStartX > bounds.x) DrawRectangle(bounds.x, bounds.y, selStartX - bounds.x, bounds.height, overlayColor); - if (selEndX < bounds.x + bounds.width) DrawRectangle(selEndX, bounds.y, bounds.x + bounds.width - selEndX, bounds.height, overlayColor); - - float selStartY = bounds.y + bounds.height - app.freqSelectionEnd * bounds.height; - float selEndY = bounds.y + bounds.height - app.freqSelectionStart * bounds.height; - - if (selStartY > bounds.y) DrawRectangle(selStartX, bounds.y, selEndX - selStartX, selStartY - bounds.y, overlayColor); - if (selEndY < bounds.y + bounds.height) DrawRectangle(selStartX, selEndY, selEndX - selStartX, bounds.y + bounds.height - selEndY, overlayColor); - - DrawLineV((Vector2){ selStartX, bounds.y }, (Vector2){ selStartX, bounds.y + bounds.height }, YELLOW); - DrawLineV((Vector2){ selEndX, bounds.y }, (Vector2){ selEndX, bounds.y + bounds.height }, YELLOW); - - if (app.freqSelectionStart > 0.0f || app.freqSelectionEnd < 1.0f) { - DrawLineV((Vector2){ selStartX, selStartY }, (Vector2){ selEndX, selStartY }, CYAN); - DrawLineV((Vector2){ selStartX, selEndY }, (Vector2){ selEndX, selEndY }, CYAN); - } +static void DrawSelectionDrag(Rectangle bounds) +{ + // Draw bounding box while dragging (no overlay) + if ((!app.isTimeSelecting && !app.isFreqSelecting && !app.isDraggingSelection) || + (app.isDraggingSelection && !IsMouseButtonDown(MOUSE_LEFT_BUTTON))) return; + + // Convert signal coordinates to viewport coordinates + float viewWidth = app.viewEnd - app.viewStart; + float freqWidth = app.freqViewEnd - app.freqViewStart; + + float selStartX = bounds.x + ((app.timeSelectionStart - app.viewStart) / viewWidth) * bounds.width; + float selEndX = bounds.x + ((app.timeSelectionEnd - app.viewStart) / viewWidth) * bounds.width; + float selStartY = bounds.y + bounds.height - ((app.freqSelectionEnd - app.freqViewStart) / freqWidth) * bounds.height; + float selEndY = bounds.y + bounds.height - ((app.freqSelectionStart - app.freqViewStart) / freqWidth) * bounds.height; + + // Clamp to viewport bounds + selStartX = fmaxf(bounds.x, fminf(bounds.x + bounds.width, selStartX)); + selEndX = fmaxf(bounds.x, fminf(bounds.x + bounds.width, selEndX)); + selStartY = fmaxf(bounds.y, fminf(bounds.y + bounds.height, selStartY)); + selEndY = fmaxf(bounds.y, fminf(bounds.y + bounds.height, selEndY)); + + // Normalize coordinates for drawing + float x = selStartX < selEndX ? selStartX : selEndX; + float w = fabsf(selEndX - selStartX); + float y = selStartY < selEndY ? selStartY : selEndY; + float h = fabsf(selEndY - selStartY); + + DrawRectangleLinesEx((Rectangle){ x, y, w, h }, 2, YELLOW); } static void DrawSidebar(void) @@ -1139,7 +1184,7 @@ static bool UpdateSlider(Rectangle bounds, float* value) int main(int argc, char* argv[]) { - SetConfigFlags(FLAG_VSYNC_HINT | FLAG_WINDOW_HIGHDPI); + SetConfigFlags(FLAG_VSYNC_HINT); InitWindow(1280, 800, "Spectrogram Viewer"); SetTargetFPS(60); SetTraceLogLevel(LOG_WARNING); // Suppress INFO texture logs @@ -1227,6 +1272,11 @@ int main(int argc, char* argv[]) } } + // Handle window resize + if (IsWindowResized()) { + app.visibleTextureValid = false; + } + // View controls if (app.loaded && !app.showFileBrowser) { // Spectrogram area fills remaining window space @@ -1357,7 +1407,7 @@ int main(int argc, char* argv[]) } } - // Selection (only when mouse is in spectrogram area, not sidebar) + // Selection: box select with LMB drag, right-click to clear float selSidebarWidth = 320; float selLabelHeight = 15; float selScrollbarHeight = 18; @@ -1374,46 +1424,128 @@ int main(int argc, char* argv[]) }; Vector2 mousePos = GetMousePosition(); - if (app.loaded && !app.showFileBrowser && mousePos.x > 385 && CheckCollisionPointRec(mousePos, selBounds)) { - bool shiftHeld = IsKeyDown(KEY_LEFT_SHIFT) || IsKeyDown(KEY_RIGHT_SHIFT); + // Right-click clears selection + if (IsMouseButtonPressed(MOUSE_BUTTON_RIGHT) && CheckCollisionPointRec(mousePos, selBounds)) { + app.timeSelectionStart = 0.0f; + app.timeSelectionEnd = 1.0f; + app.freqSelectionStart = 0.0f; + app.freqSelectionEnd = 1.0f; + } - if (IsMouseButtonPressed(MOUSE_LEFT_BUTTON) && !app.isPanning) { - if (shiftHeld) { - app.isFreqSelecting = true; - float t = 1.0f - (mousePos.y - selBounds.y) / selBounds.height; - app.freqSelectionStart = Clamp(t, 0.0f, 1.0f); - app.freqSelectionEnd = app.freqSelectionStart; - } else if (!IsKeyDown(KEY_LEFT_ALT) && !IsKeyDown(KEY_RIGHT_ALT)) { - app.isTimeSelecting = true; - app.timeSelectionStart = Clamp((mousePos.x - selBounds.x) / selBounds.width, 0.0f, 1.0f); - app.timeSelectionEnd = app.timeSelectionStart; + // Check if click is inside existing selection (for dragging) + bool hasSelection = (app.timeSelectionStart > 0.001f || app.timeSelectionEnd < 0.999f || + app.freqSelectionStart > 0.001f || app.freqSelectionEnd < 0.999f); + bool clickInsideSelection = false; + bool hoverInsideSelection = false; + if (hasSelection && CheckCollisionPointRec(mousePos, selBounds)) { + // Convert mouse position to signal coordinates + float viewWidth = app.viewEnd - app.viewStart; + float freqWidth = app.freqViewEnd - app.freqViewStart; + float mouseTime = app.viewStart + ((mousePos.x - selBounds.x) / selBounds.width) * viewWidth; + float mouseFreq = app.freqViewStart + (1.0f - (mousePos.y - selBounds.y) / selBounds.height) * freqWidth; + + if (mouseTime >= app.timeSelectionStart && mouseTime <= app.timeSelectionEnd && + mouseFreq >= app.freqSelectionStart && mouseFreq <= app.freqSelectionEnd) { + hoverInsideSelection = true; + if (IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) { + clickInsideSelection = true; } } + } + + // Set cursor based on context + 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 (app.isTimeSelecting && IsMouseButtonDown(MOUSE_LEFT_BUTTON)) { - app.timeSelectionEnd = Clamp((mousePos.x - selBounds.x) / selBounds.width, 0.0f, 1.0f); + // LMB drag = box select (time + frequency) OR drag existing selection + if (app.loaded && !app.showFileBrowser && CheckCollisionPointRec(mousePos, selBounds)) { + if (IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) { + if (clickInsideSelection) { + // Start dragging existing selection + app.isDraggingSelection = true; + app.dragSelectionStartPos = mousePos; + app.dragSelectionTimeStart = app.timeSelectionStart; + app.dragSelectionFreqStart = app.freqSelectionStart; + } else { + // Start new box selection + app.isTimeSelecting = true; + app.isFreqSelecting = true; + app.selectStartPos = mousePos; + + // Convert screen position to signal coordinates (accounting for zoom) + float viewportT = (mousePos.x - selBounds.x) / selBounds.width; + float viewportF = 1.0f - (mousePos.y - selBounds.y) / selBounds.height; + + app.timeSelectionStart = Clamp(app.viewStart + viewportT * (app.viewEnd - app.viewStart), 0.0f, 1.0f); + app.timeSelectionEnd = app.timeSelectionStart; + app.freqSelectionStart = Clamp(app.freqViewStart + viewportF * (app.freqViewEnd - app.freqViewStart), 0.0f, 1.0f); + app.freqSelectionEnd = app.freqSelectionStart; + } } - if (app.isFreqSelecting && IsMouseButtonDown(MOUSE_LEFT_BUTTON)) { - float t = 1.0f - (mousePos.y - selBounds.y) / selBounds.height; - app.freqSelectionEnd = Clamp(t, 0.0f, 1.0f); + + // Dragging existing selection + if (app.isDraggingSelection && IsMouseButtonDown(MOUSE_LEFT_BUTTON)) { + float viewWidth = app.viewEnd - app.viewStart; + float freqWidth = app.freqViewEnd - app.freqViewStart; + + float dx = (mousePos.x - app.dragSelectionStartPos.x) / selBounds.width; + float dy = (mousePos.y - app.dragSelectionStartPos.y) / selBounds.height; + + float timeShift = dx * viewWidth; + float freqShift = -dy * freqWidth; // Y is inverted + + float timeWidth = app.timeSelectionEnd - app.timeSelectionStart; + float freqHeight = app.freqSelectionEnd - app.freqSelectionStart; + + app.timeSelectionStart = Clamp(app.dragSelectionTimeStart + timeShift, 0.0f, 1.0f - timeWidth); + app.timeSelectionEnd = app.timeSelectionStart + timeWidth; + app.freqSelectionStart = Clamp(app.dragSelectionFreqStart + freqShift, 0.0f, 1.0f - freqHeight); + app.freqSelectionEnd = app.freqSelectionStart + freqHeight; + } + + // Creating new box selection + if ((app.isTimeSelecting || app.isFreqSelecting) && IsMouseButtonDown(MOUSE_LEFT_BUTTON)) { + float viewportT = (mousePos.x - selBounds.x) / selBounds.width; + float viewportF = 1.0f - (mousePos.y - selBounds.y) / selBounds.height; + + app.timeSelectionEnd = Clamp(app.viewStart + viewportT * (app.viewEnd - app.viewStart), 0.0f, 1.0f); + app.freqSelectionEnd = Clamp(app.freqViewStart + viewportF * (app.freqViewEnd - app.freqViewStart), 0.0f, 1.0f); } if (IsMouseButtonReleased(MOUSE_LEFT_BUTTON)) { - if (app.isTimeSelecting) { + if (app.isDraggingSelection) { + app.isDraggingSelection = false; + } else if (app.isTimeSelecting || app.isFreqSelecting) { + // Check if drag was large enough (minimum 5 pixels) + float dx = mousePos.x - app.selectStartPos.x; + float dy = mousePos.y - app.selectStartPos.y; + float dragDist = sqrtf(dx * dx + dy * dy); + if (dragDist > 5.0f) { + // Normalize so start < end + if (app.timeSelectionEnd < app.timeSelectionStart) { + float tmp = app.timeSelectionStart; + app.timeSelectionStart = app.timeSelectionEnd; + app.timeSelectionEnd = tmp; + } + if (app.freqSelectionEnd < app.freqSelectionStart) { + float tmp = app.freqSelectionStart; + app.freqSelectionStart = app.freqSelectionEnd; + app.freqSelectionEnd = tmp; + } + } else { + // Drag too small - revert to full range + app.timeSelectionStart = 0.0f; + app.timeSelectionEnd = 1.0f; + app.freqSelectionStart = 0.0f; + app.freqSelectionEnd = 1.0f; + } app.isTimeSelecting = false; - if (app.timeSelectionEnd < app.timeSelectionStart) { - float tmp = app.timeSelectionStart; - app.timeSelectionStart = app.timeSelectionEnd; - app.timeSelectionEnd = tmp; - } - } - if (app.isFreqSelecting) { app.isFreqSelecting = false; - if (app.freqSelectionEnd < app.freqSelectionStart) { - float tmp = app.freqSelectionStart; - app.freqSelectionStart = app.freqSelectionEnd; - app.freqSelectionEnd = tmp; - } } } } @@ -1566,6 +1698,7 @@ int main(int argc, char* argv[]) if (app.showGrid) DrawSpectrogramGrid(viewBounds, 10, 8, Fade(GRAY, 0.3f)); DrawSelection(viewBounds); + DrawSelectionDrag(viewBounds); DrawLabels(viewBounds); float maxFreq = (float)app.signal.sampleRate / 2.0f; float freqMin = app.freqViewStart * maxFreq; diff --git a/web_shell.html b/web_shell.html new file mode 100644 index 0000000..b4f8b9f --- /dev/null +++ b/web_shell.html @@ -0,0 +1,52 @@ + + + + + + Spectrogram Viewer + + + + + +

+ + {{{ SCRIPT }}} + +