feat(web): proper viewport scaling, instant load, and file downloads

Make the Emscripten build behave like the desktop app for loading, window
scaling, and exporting. All changes are #ifdef __EMSCRIPTEN__-gated or no-op on
desktop, so native behavior is unchanged.

Scaling:
- Drop FLAG_WINDOW_HIGHDPI on web. The emscripten-GLFW shim forces a fixed
  pixel canvas style (!important) when HiDPI-aware, overriding the shell's
  100vw/100vh CSS so the canvas can't fill the page; raylib's own resize/window
  callbacks also disagree about dividing by devicePixelRatio, desyncing the
  framebuffer from the reported screen size.
- Sync raylib's window size to window.innerWidth/innerHeight each frame via
  SetWindowSize (guarded against no-op churn). This keeps screen size, GL
  viewport, and projection consistent, so the UI fills the viewport and reflows
  on resize like the desktop window.

Loading:
- Compute the full-resolution STFT synchronously when a file loads instead of
  the desktop overview-then-deferred-high-res path, which relied on many
  main-loop iterations yielding to the browser and appeared to stall partway.
- Allow the wasm heap to grow (INITIAL_MEMORY + ALLOW_MEMORY_GROWTH) so longer
  recordings fit now that everything is computed up front.

Exports:
- Add Platform_OfferFileToUser(): no-op on desktop (file is already on disk);
  on web it reads the just-written file from MEMFS and triggers a browser
  download, then unlinks the temp copy. Wired into PNG and WAV export, which
  now show just the filename in the status message.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 11:48:47 -07:00
parent 6347eb172e
commit cef7619833
8 changed files with 118 additions and 6 deletions
+3 -1
View File
@@ -79,7 +79,9 @@ done
# Linker flags
# The font lives at resources/fonts/DejaVuSansMono.ttf and is loaded relative to
# the resources dir at runtime, so the resources@resources preload covers it.
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"
# INITIAL_MEMORY + ALLOW_MEMORY_GROWTH: the web build now computes the full STFT
# up front, so the heap must be able to grow for longer recordings.
LDFLAGS="-s USE_GLFW=3 -s ASYNCIFY -s INITIAL_MEMORY=67108864 -s ALLOW_MEMORY_GROWTH=1 -s FORCE_FILESYSTEM=1 --preload-file resources@resources --shell-file $SCRIPT_DIR/web_shell.html -s NO_EXIT_RUNTIME=1"
if [ "$BUILD_TYPE" = "debug" ]; then
LDFLAGS="$LDFLAGS -g -O0 -s ASSERTIONS=1"
+4 -3
View File
@@ -253,9 +253,10 @@ void ExportSelectionWAV(const char* dirPath)
Wave wave = { .data = samples, .frameCount = (unsigned int)numSamples,
.sampleRate = (unsigned int)app.signal.sampleRate, .sampleSize = 32, .channels = 1 };
if (ExportWave(wave, path))
snprintf(app.exportMessage, sizeof(app.exportMessage), "Saved WAV: %.40s", path);
else
if (ExportWave(wave, path)) {
Platform_OfferFileToUser(path); // desktop: no-op; web: browser download
snprintf(app.exportMessage, sizeof(app.exportMessage), "Saved WAV: %.40s", GetFileName(path));
} else
snprintf(app.exportMessage, sizeof(app.exportMessage), "WAV export failed");
app.exportMessageTimer = 3.0f;
+14
View File
@@ -94,3 +94,17 @@ const char *Platform_GetLastErrorMessage(void);
* Static string — do not modify or free.
*/
const char *Platform_GetTempDir(void);
/* ── File delivery ──────────────────────────────────────────────────────── */
/**
* Hand a just-written file to the user.
*
* On desktop this is a no-op: the file is already on disk at `path` where the
* user asked for it. On the web there is no local disk the user can see, so
* this reads the file back from the virtual filesystem, triggers a browser
* "save as" download named after the file, and removes the temporary copy.
*
* @param path Path the file was just written to.
*/
void Platform_OfferFileToUser(const char *path);
+5
View File
@@ -100,3 +100,8 @@ const char *Platform_GetLastErrorMessage(void) {
const char *Platform_GetTempDir(void) {
return "/tmp";
}
void Platform_OfferFileToUser(const char *path) {
/* Desktop: the file is already on disk where the user wanted it. */
(void)path;
}
+29
View File
@@ -11,6 +11,7 @@
#include <stdarg.h>
#include <stdio.h>
#include <errno.h> /* ENOSYS */
#include <emscripten.h> /* EM_ASM */
/* ── Helpers ─────────────────────────────────────────────────────────── */
@@ -64,3 +65,31 @@ const char *Platform_GetTempDir(void) {
*/
return "/";
}
void Platform_OfferFileToUser(const char *path) {
if (!path) return;
/*
* The file was just written into the in-memory (MEMFS) filesystem, which
* the user can't see. Read it back and hand it to the browser as a
* download, then unlink the temporary copy so we don't leak heap memory.
*/
EM_ASM({
var p = UTF8ToString($0);
try {
var data = FS.readFile(p); // Uint8Array
var name = p.split('/').pop() || 'download';
var blob = new Blob([data], { type: 'application/octet-stream' });
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = name;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(function() { URL.revokeObjectURL(url); }, 1000);
try { FS.unlink(p); } catch (e) {}
} catch (e) {
console.error('rspektrum: download failed for ' + p + ': ' + e);
}
}, path);
}
+5
View File
@@ -127,3 +127,8 @@ const char *Platform_GetTempDir(void) {
}
return temp_path;
}
void Platform_OfferFileToUser(const char *path) {
/* Desktop: the file is already on disk where the user wanted it. */
(void)path;
}
+55
View File
@@ -20,6 +20,23 @@
#include <stdbool.h>
#include <stdio.h>
#ifdef __EMSCRIPTEN__
#include <emscripten/emscripten.h>
// Keep raylib's framebuffer/screen size matched to the browser viewport, so the
// (immediate-mode) UI fills the page and reflows on window resize the same way
// the desktop OS window does. Going through SetWindowSize keeps the screen size,
// GL viewport, and projection consistent; the != guard avoids per-frame churn.
static void SyncCanvasToWindow(void)
{
int w = EM_ASM_INT({ return window.innerWidth; });
int h = EM_ASM_INT({ return window.innerHeight; });
if (w > 0 && h > 0 && (w != GetScreenWidth() || h != GetScreenHeight())) {
SetWindowSize(w, h);
}
}
#endif
// ============================================================================
// Global State (declared extern in spectrogram_types.h)
// ============================================================================
@@ -217,7 +234,18 @@ static void DispatchKeymap(void)
int main(int argc, char* argv[])
{
#ifdef __EMSCRIPTEN__
// FLAG_WINDOW_HIGHDPI is buggy on the web backend: the Emscripten resize
// callback sets the screen size to window.innerWidth, but the GLFW window-
// size callback it triggers divides that by devicePixelRatio when HIGHDPI
// is set. On a HiDPI display the framebuffer and the reported screen size
// desync and the UI renders into a corner. UI scaling is handled by
// GetUIScale() regardless, so the flag is unnecessary here. raylib auto-
// resizes the canvas to the window when FLAG_WINDOW_RESIZABLE is set.
SetConfigFlags(FLAG_VSYNC_HINT | FLAG_WINDOW_RESIZABLE);
#else
SetConfigFlags(FLAG_VSYNC_HINT | FLAG_WINDOW_RESIZABLE | FLAG_WINDOW_HIGHDPI);
#endif
InitWindow(1280, 800, "Spectrogram Viewer");
SetTargetFPS(60);
SetTraceLogLevel(LOG_WARNING); // Suppress INFO texture logs
@@ -320,6 +348,11 @@ int main(int argc, char* argv[])
while (!WindowShouldClose())
{
#ifdef __EMSCRIPTEN__
// Track the browser viewport (fill + reflow on resize, like desktop).
SyncCanvasToWindow();
#endif
// Drag & Drop
if (IsFileDropped()) {
FilePathList dropped = LoadDroppedFiles();
@@ -764,6 +797,27 @@ int main(int argc, char* argv[])
// Processing (incremental across frames)
if (app.loaded && !app.stftComputed) {
#ifdef __EMSCRIPTEN__
// Web build: there are no worker threads, and the desktop path's
// overview-then-deferred-high-res fill depends on many main-loop
// iterations yielding to the browser (which made loading appear to
// stall partway). Compute the full-resolution STFT in one shot so
// the spectrogram is completely ready as soon as the file loads.
ComputeSTFTInit(&app.signal, &app.stft, app.fftSize);
app.skipFactor = 1; // full resolution, no overview stride
ComputeSTFTIncremental(&app.signal, &app.stft, app.fftSize, 0); // computes every segment
AutoScaleAmplitude(&app.stft);
GenerateSpectrogramTexture(&app.stft, &app.spectrogramImage, &app.spectrogramTexture);
app.currentSTFTSegment = app.stft.numSegments;
app.bgHighResSeg = app.stft.numSegments;
app.loadingProgress = 1.0f;
app.stftComputed = true;
app.highResFinished = true;
app.bgFinished = true;
app.isBgProcessing = false;
app.loadingPhase = 0;
SaveToCache();
#else
if (app.loadingPhase == 0) {
// Initialize STFT once
ComputeSTFTInit(&app.signal, &app.stft, app.fftSize);
@@ -802,6 +856,7 @@ int main(int argc, char* argv[])
// Save the overview result to cache (will be overwritten when full-res completes)
SaveToCache();
}
#endif // __EMSCRIPTEN__
}
// Loading overlay (drawn during STFT computation)
+2 -1
View File
@@ -318,8 +318,9 @@ void ExportPNG(const SpectrogramApp* spa, const char* dirPath)
}
if (ExportImage(region, path)) {
Platform_OfferFileToUser(path); // desktop: no-op; web: browser download
snprintf((char*)spa->exportMessage, sizeof(spa->exportMessage),
"Exported: %dx%d %.40s", region.width, region.height, path);
"Exported: %dx%d %.40s", region.width, region.height, GetFileName(path));
} else {
snprintf((char*)spa->exportMessage, sizeof(spa->exportMessage), "Export failed");
}