diff --git a/src/audio.c b/src/audio.c index 817af56..b9364d6 100644 --- a/src/audio.c +++ b/src/audio.c @@ -216,12 +216,28 @@ static float* BuildSelectionAudio(int* outNumSamples) 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) { int numSamples = 0; float* regionSamples = BuildSelectionAudio(&numSamples); if (!regionSamples) return; + EnsureAudioDevice(); // opened on demand; released again once playback ends if (AudioPlaybackSound.frameCount != 0) UnloadSound(AudioPlaybackSound); Wave wave = { .data = regionSamples, .frameCount = (unsigned int)numSamples, diff --git a/src/audio.h b/src/audio.h index 50a3d8e..8f55b10 100644 --- a/src/audio.h +++ b/src/audio.h @@ -10,8 +10,16 @@ bool LoadWavFile(const char* filepath, AudioSignal* signal); void FreeSignal(AudioSignal* signal); // Play the current time/frequency selection (applies an FFT bandpass filter). +// Opens the audio output device on demand (see EnsureAudioDevice). 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 // playback) as a mono WAV in dirPath. Reports the result via app.exportMessage. void ExportSelectionWAV(const char* dirPath); diff --git a/src/spectrogram.c b/src/spectrogram.c index e034b0c..b390d9e 100644 --- a/src/spectrogram.c +++ b/src/spectrogram.c @@ -70,12 +70,11 @@ static bool IsUserInteracting(void) return false; } -// Idle FPS throttle. raylib re-renders the whole scene every frame regardless -// of whether anything changed, so an idle window still pins the GPU (and, on a -// software GL stack, the CPU) at 60 fps. We drop to IDLE_FPS whenever nothing -// needs animating, and snap back to ACTIVE_FPS the moment activity resumes. -#define ACTIVE_FPS 60 -#define IDLE_FPS 10 +// Idle power management. raylib re-renders the whole scene every frame, so an +// idle window otherwise pins the GPU (and, on software GL, the CPU) at the +// target rate. We run at ACTIVE_FPS while something needs animating, then go +// fully event-driven (block until input) when idle — see the loop below. +#define ACTIVE_FPS 30 // plenty for a non-game UI; halves active-frame cost #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 InitWindow(headless ? reqW : 1280, headless ? reqH : 800, "Spectrogram Viewer"); - SetTargetFPS(60); + SetTargetFPS(ACTIVE_FPS); 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 // 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). SyncCanvasToWindow(); #else - // When the window isn't focused there's nothing for the user to see, so - // skip the entire frame: pump window events (so focus regain and the - // close button still register) and idle, with zero drawing. The - // headless render uses a hidden window that never reports focus, so it - // is exempt — otherwise it would never draw its single capture frame. - if (!headless && !IsWindowFocused()) { - PollInputEvents(); - WaitTime(0.1); // ~10 Hz event poll, no rendering - continue; - } - - // Idle FPS throttle (desktop only; the browser drives RAF/vsync itself). - // Keep full rate while active or within a short grace window after the - // last activity, then fall back to a low idle rate. - { + // Desktop power management. raylib's frame limiter partial-busy-waits + // ~5% of every frame interval, so capping the FPS can't get idle CPU + // below ~5% of a core. Instead we go fully event-driven when idle: + // block in PollInputEvents() (glfwWaitEvents) until an input/window + // event arrives, so an idle/backgrounded window costs ~0% CPU and only + // 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 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; } + 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(); + continue; + } + // Focused but idle: fall through to draw this one static frame, + // then EndDrawing blocks until the next event. + } } #endif @@ -1682,7 +1698,7 @@ int main(int argc, char* argv[]) TraceLog(LOG_INFO, "Shutting down..."); 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.visibleTexture.id != 0) UnloadTexture(app.visibleTexture); app.visibleTexture = (Texture2D){ 0 }; @@ -1693,7 +1709,7 @@ int main(int argc, char* argv[]) free(app.reassignBuffer); FreeMlnl(&app.annotations); FreeSignal(&app.signal); - CloseAudioDevice(); + if (IsAudioDeviceReady()) CloseAudioDevice(); CloseWindow(); return headlessRc; }