perf: event-driven idle + lazy audio device for near-zero idle CPU

raylib's frame limiter partial-busy-waits ~5% of every frame interval, so
capping the FPS couldn't get idle CPU below ~5% of a core, and the audio
output device's mixing thread spun continuously even when nothing played.

- Idle render: go fully event-driven when idle. While active (focused +
  something animating, or within a 0.5s grace window) run at ACTIVE_FPS;
  otherwise EnableEventWaiting() so EndDrawing's PollInputEvents blocks on
  glfwWaitEvents until the next input/window event. Unfocused windows skip
  drawing entirely and block. Headless render is exempt (its hidden window
  gets no events). Replaces the earlier fixed-FPS idle throttle.
- ACTIVE_FPS lowered 60 -> 30: ample for a non-game UI, halves active cost.
- Lazy audio device: open it on demand at first playback (EnsureAudioDevice)
  and release it once idle and not playing (ReleaseAudioDevice), reopened on
  the next play. Never closes mid-playback (gated on !isPlaying), so audio
  always finishes first. Shutdown is guarded with IsAudioDeviceReady().

Idle/backgrounded window now drops to ~0% CPU (no render busy-wait, no audio
thread); snaps back instantly on input.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-05-29 13:02:14 -07:00
parent 2befb42d71
commit f89e1b7eab
3 changed files with 69 additions and 29 deletions
+16
View File
@@ -216,12 +216,28 @@ static float* BuildSelectionAudio(int* outNumSamples)
return regionSamples; return regionSamples;
} }
void EnsureAudioDevice(void)
{
if (!IsAudioDeviceReady()) InitAudioDevice();
}
void ReleaseAudioDevice(void)
{
if (!IsAudioDeviceReady()) return;
if (AudioPlaybackSound.frameCount != 0) {
UnloadSound(AudioPlaybackSound);
AudioPlaybackSound = (Sound){ 0 };
}
CloseAudioDevice();
}
void PlaySelectedRegion(void) void PlaySelectedRegion(void)
{ {
int numSamples = 0; int numSamples = 0;
float* regionSamples = BuildSelectionAudio(&numSamples); float* regionSamples = BuildSelectionAudio(&numSamples);
if (!regionSamples) return; if (!regionSamples) return;
EnsureAudioDevice(); // opened on demand; released again once playback ends
if (AudioPlaybackSound.frameCount != 0) UnloadSound(AudioPlaybackSound); if (AudioPlaybackSound.frameCount != 0) UnloadSound(AudioPlaybackSound);
Wave wave = { .data = regionSamples, .frameCount = (unsigned int)numSamples, Wave wave = { .data = regionSamples, .frameCount = (unsigned int)numSamples,
+8
View File
@@ -10,8 +10,16 @@ bool LoadWavFile(const char* filepath, AudioSignal* signal);
void FreeSignal(AudioSignal* signal); void FreeSignal(AudioSignal* signal);
// Play the current time/frequency selection (applies an FFT bandpass filter). // Play the current time/frequency selection (applies an FFT bandpass filter).
// Opens the audio output device on demand (see EnsureAudioDevice).
void PlaySelectedRegion(void); void PlaySelectedRegion(void);
// Audio output device lifecycle. The device is opened lazily (only when
// something actually plays) and released while idle, so a backgrounded window
// isn't holding the output device open — keeping it open costs steady CPU in
// miniaudio's mixing thread.
void EnsureAudioDevice(void); // InitAudioDevice() if not already initialized
void ReleaseAudioDevice(void); // unload the playback sound + CloseAudioDevice()
// Save the current selection (band-limited, time-cropped — same processing as // Save the current selection (band-limited, time-cropped — same processing as
// playback) as a mono WAV in dirPath. Reports the result via app.exportMessage. // playback) as a mono WAV in dirPath. Reports the result via app.exportMessage.
void ExportSelectionWAV(const char* dirPath); void ExportSelectionWAV(const char* dirPath);
+43 -27
View File
@@ -70,12 +70,11 @@ static bool IsUserInteracting(void)
return false; return false;
} }
// Idle FPS throttle. raylib re-renders the whole scene every frame regardless // Idle power management. raylib re-renders the whole scene every frame, so an
// of whether anything changed, so an idle window still pins the GPU (and, on a // idle window otherwise pins the GPU (and, on software GL, the CPU) at the
// software GL stack, the CPU) at 60 fps. We drop to IDLE_FPS whenever nothing // target rate. We run at ACTIVE_FPS while something needs animating, then go
// needs animating, and snap back to ACTIVE_FPS the moment activity resumes. // fully event-driven (block until input) when idle — see the loop below.
#define ACTIVE_FPS 60 #define ACTIVE_FPS 30 // plenty for a non-game UI; halves active-frame cost
#define IDLE_FPS 10
#define IDLE_GRACE_SECONDS 0.5 // stay at full rate briefly after the last activity #define IDLE_GRACE_SECONDS 0.5 // stay at full rate briefly after the last activity
/** /**
@@ -621,9 +620,10 @@ int main(int argc, char* argv[])
} }
#endif #endif
InitWindow(headless ? reqW : 1280, headless ? reqH : 800, "Spectrogram Viewer"); InitWindow(headless ? reqW : 1280, headless ? reqH : 800, "Spectrogram Viewer");
SetTargetFPS(60); SetTargetFPS(ACTIVE_FPS);
SetTraceLogLevel(LOG_WARNING); // Suppress INFO texture logs SetTraceLogLevel(LOG_WARNING); // Suppress INFO texture logs
InitAudioDevice(); // Audio device is opened lazily on first playback (see EnsureAudioDevice)
// and released while idle, so an idle/backgrounded window holds no device.
SetExitKey(KEY_NULL); // ESC should not close the window SetExitKey(KEY_NULL); // ESC should not close the window
// Save original working directory so command-line args resolve correctly // Save original working directory so command-line args resolve correctly
@@ -782,26 +782,42 @@ int main(int argc, char* argv[])
// Track the browser viewport (fill + reflow on resize, like desktop). // Track the browser viewport (fill + reflow on resize, like desktop).
SyncCanvasToWindow(); SyncCanvasToWindow();
#else #else
// When the window isn't focused there's nothing for the user to see, so // Desktop power management. raylib's frame limiter partial-busy-waits
// skip the entire frame: pump window events (so focus regain and the // ~5% of every frame interval, so capping the FPS can't get idle CPU
// close button still register) and idle, with zero drawing. The // below ~5% of a core. Instead we go fully event-driven when idle:
// headless render uses a hidden window that never reports focus, so it // block in PollInputEvents() (glfwWaitEvents) until an input/window
// is exempt — otherwise it would never draw its single capture frame. // event arrives, so an idle/backgrounded window costs ~0% CPU and only
if (!headless && !IsWindowFocused()) { // redraws on demand. Headless render (hidden window, single frame) opts
// out — its window never receives events, so waiting would deadlock.
if (!headless) {
static double lastActive = -1000.0;
static int waiting = -1; // -1 unset, 0 = active (poll), 1 = idle (event-wait)
bool focused = IsWindowFocused();
if (focused && IsAppActive()) lastActive = GetTime();
// Active = focused AND something needs animating (or just did, within
// the grace window). Anything else is a static frame we can sleep on.
bool active = focused && (GetTime() - lastActive < IDLE_GRACE_SECONDS);
if (active) {
if (waiting != 0) { DisableEventWaiting(); SetTargetFPS(ACTIVE_FPS); waiting = 0; }
} else {
// Idle: no busy-wait limiter; EndDrawing's PollInputEvents blocks.
if (waiting != 1) { SetTargetFPS(0); EnableEventWaiting(); waiting = 1; }
// Release the output device while idle — but never mid-playback
// (isPlaying gates it, so audio always finishes first). Reopened
// on the next play. Safe on the unfocused path too: only closes
// when nothing is playing.
if (!app.isPlaying && IsAudioDeviceReady()) ReleaseAudioDevice();
if (!focused) {
// Unfocused: nothing to show. Block on events (refocus/close)
// without drawing at all.
PollInputEvents(); PollInputEvents();
WaitTime(0.1); // ~10 Hz event poll, no rendering
continue; continue;
} }
// Focused but idle: fall through to draw this one static frame,
// Idle FPS throttle (desktop only; the browser drives RAF/vsync itself). // then EndDrawing blocks until the next event.
// Keep full rate while active or within a short grace window after the }
// last activity, then fall back to a low idle rate.
{
static double lastActive = -1000.0;
static int curFps = ACTIVE_FPS;
if (IsAppActive()) lastActive = GetTime();
int wantFps = (GetTime() - lastActive < IDLE_GRACE_SECONDS) ? ACTIVE_FPS : IDLE_FPS;
if (wantFps != curFps) { SetTargetFPS(wantFps); curFps = wantFps; }
} }
#endif #endif
@@ -1682,7 +1698,7 @@ int main(int argc, char* argv[])
TraceLog(LOG_INFO, "Shutting down..."); TraceLog(LOG_INFO, "Shutting down...");
if (mainFont.texture.id != 0) UnloadFont(mainFont); if (mainFont.texture.id != 0) UnloadFont(mainFont);
if (AudioPlaybackSound.frameCount != 0) UnloadSound(AudioPlaybackSound); if (IsAudioDeviceReady() && AudioPlaybackSound.frameCount != 0) UnloadSound(AudioPlaybackSound);
if (app.stftComputed) { FreeSTFT(&app.stft); UnloadImage(app.spectrogramImage); UnloadTexture(app.spectrogramTexture); } if (app.stftComputed) { FreeSTFT(&app.stft); UnloadImage(app.spectrogramImage); UnloadTexture(app.spectrogramTexture); }
if (app.visibleTexture.id != 0) UnloadTexture(app.visibleTexture); if (app.visibleTexture.id != 0) UnloadTexture(app.visibleTexture);
app.visibleTexture = (Texture2D){ 0 }; app.visibleTexture = (Texture2D){ 0 };
@@ -1693,7 +1709,7 @@ int main(int argc, char* argv[])
free(app.reassignBuffer); free(app.reassignBuffer);
FreeMlnl(&app.annotations); FreeMlnl(&app.annotations);
FreeSignal(&app.signal); FreeSignal(&app.signal);
CloseAudioDevice(); if (IsAudioDeviceReady()) CloseAudioDevice();
CloseWindow(); CloseWindow();
return headlessRc; return headlessRc;
} }