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:
+3
-1
@@ -79,7 +79,9 @@ done
|
|||||||
# Linker flags
|
# Linker flags
|
||||||
# The font lives at resources/fonts/DejaVuSansMono.ttf and is loaded relative to
|
# 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.
|
# 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
|
if [ "$BUILD_TYPE" = "debug" ]; then
|
||||||
LDFLAGS="$LDFLAGS -g -O0 -s ASSERTIONS=1"
|
LDFLAGS="$LDFLAGS -g -O0 -s ASSERTIONS=1"
|
||||||
|
|||||||
+4
-3
@@ -253,9 +253,10 @@ void ExportSelectionWAV(const char* dirPath)
|
|||||||
|
|
||||||
Wave wave = { .data = samples, .frameCount = (unsigned int)numSamples,
|
Wave wave = { .data = samples, .frameCount = (unsigned int)numSamples,
|
||||||
.sampleRate = (unsigned int)app.signal.sampleRate, .sampleSize = 32, .channels = 1 };
|
.sampleRate = (unsigned int)app.signal.sampleRate, .sampleSize = 32, .channels = 1 };
|
||||||
if (ExportWave(wave, path))
|
if (ExportWave(wave, path)) {
|
||||||
snprintf(app.exportMessage, sizeof(app.exportMessage), "Saved WAV: %.40s", path);
|
Platform_OfferFileToUser(path); // desktop: no-op; web: browser download
|
||||||
else
|
snprintf(app.exportMessage, sizeof(app.exportMessage), "Saved WAV: %.40s", GetFileName(path));
|
||||||
|
} else
|
||||||
snprintf(app.exportMessage, sizeof(app.exportMessage), "WAV export failed");
|
snprintf(app.exportMessage, sizeof(app.exportMessage), "WAV export failed");
|
||||||
app.exportMessageTimer = 3.0f;
|
app.exportMessageTimer = 3.0f;
|
||||||
|
|
||||||
|
|||||||
@@ -94,3 +94,17 @@ const char *Platform_GetLastErrorMessage(void);
|
|||||||
* Static string — do not modify or free.
|
* Static string — do not modify or free.
|
||||||
*/
|
*/
|
||||||
const char *Platform_GetTempDir(void);
|
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);
|
||||||
|
|||||||
@@ -100,3 +100,8 @@ const char *Platform_GetLastErrorMessage(void) {
|
|||||||
const char *Platform_GetTempDir(void) {
|
const char *Platform_GetTempDir(void) {
|
||||||
return "/tmp";
|
return "/tmp";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void Platform_OfferFileToUser(const char *path) {
|
||||||
|
/* Desktop: the file is already on disk where the user wanted it. */
|
||||||
|
(void)path;
|
||||||
|
}
|
||||||
|
|||||||
+30
-1
@@ -10,7 +10,8 @@
|
|||||||
#include <string.h>
|
#include <string.h>
|
||||||
#include <stdarg.h>
|
#include <stdarg.h>
|
||||||
#include <stdio.h>
|
#include <stdio.h>
|
||||||
#include <errno.h> /* ENOSYS */
|
#include <errno.h> /* ENOSYS */
|
||||||
|
#include <emscripten.h> /* EM_ASM */
|
||||||
|
|
||||||
/* ── Helpers ─────────────────────────────────────────────────────────── */
|
/* ── Helpers ─────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
@@ -64,3 +65,31 @@ const char *Platform_GetTempDir(void) {
|
|||||||
*/
|
*/
|
||||||
return "/";
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -127,3 +127,8 @@ const char *Platform_GetTempDir(void) {
|
|||||||
}
|
}
|
||||||
return temp_path;
|
return temp_path;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void Platform_OfferFileToUser(const char *path) {
|
||||||
|
/* Desktop: the file is already on disk where the user wanted it. */
|
||||||
|
(void)path;
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,6 +20,23 @@
|
|||||||
#include <stdbool.h>
|
#include <stdbool.h>
|
||||||
#include <stdio.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)
|
// Global State (declared extern in spectrogram_types.h)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -217,7 +234,18 @@ static void DispatchKeymap(void)
|
|||||||
|
|
||||||
int main(int argc, char* argv[])
|
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);
|
SetConfigFlags(FLAG_VSYNC_HINT | FLAG_WINDOW_RESIZABLE | FLAG_WINDOW_HIGHDPI);
|
||||||
|
#endif
|
||||||
InitWindow(1280, 800, "Spectrogram Viewer");
|
InitWindow(1280, 800, "Spectrogram Viewer");
|
||||||
SetTargetFPS(60);
|
SetTargetFPS(60);
|
||||||
SetTraceLogLevel(LOG_WARNING); // Suppress INFO texture logs
|
SetTraceLogLevel(LOG_WARNING); // Suppress INFO texture logs
|
||||||
@@ -320,6 +348,11 @@ int main(int argc, char* argv[])
|
|||||||
|
|
||||||
while (!WindowShouldClose())
|
while (!WindowShouldClose())
|
||||||
{
|
{
|
||||||
|
#ifdef __EMSCRIPTEN__
|
||||||
|
// Track the browser viewport (fill + reflow on resize, like desktop).
|
||||||
|
SyncCanvasToWindow();
|
||||||
|
#endif
|
||||||
|
|
||||||
// Drag & Drop
|
// Drag & Drop
|
||||||
if (IsFileDropped()) {
|
if (IsFileDropped()) {
|
||||||
FilePathList dropped = LoadDroppedFiles();
|
FilePathList dropped = LoadDroppedFiles();
|
||||||
@@ -764,6 +797,27 @@ int main(int argc, char* argv[])
|
|||||||
|
|
||||||
// Processing (incremental across frames)
|
// Processing (incremental across frames)
|
||||||
if (app.loaded && !app.stftComputed) {
|
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) {
|
if (app.loadingPhase == 0) {
|
||||||
// Initialize STFT once
|
// Initialize STFT once
|
||||||
ComputeSTFTInit(&app.signal, &app.stft, app.fftSize);
|
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)
|
// Save the overview result to cache (will be overwritten when full-res completes)
|
||||||
SaveToCache();
|
SaveToCache();
|
||||||
}
|
}
|
||||||
|
#endif // __EMSCRIPTEN__
|
||||||
}
|
}
|
||||||
|
|
||||||
// Loading overlay (drawn during STFT computation)
|
// Loading overlay (drawn during STFT computation)
|
||||||
|
|||||||
@@ -318,8 +318,9 @@ void ExportPNG(const SpectrogramApp* spa, const char* dirPath)
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (ExportImage(region, path)) {
|
if (ExportImage(region, path)) {
|
||||||
|
Platform_OfferFileToUser(path); // desktop: no-op; web: browser download
|
||||||
snprintf((char*)spa->exportMessage, sizeof(spa->exportMessage),
|
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 {
|
} else {
|
||||||
snprintf((char*)spa->exportMessage, sizeof(spa->exportMessage), "Export failed");
|
snprintf((char*)spa->exportMessage, sizeof(spa->exportMessage), "Export failed");
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user