Skip to content

fix(audio): prevent WKWebView audio session teardown after backgrounding (#41)#486

Merged
jamiepine merged 1 commit intomainfrom
fix/audio-session-keepalive
Apr 19, 2026
Merged

fix(audio): prevent WKWebView audio session teardown after backgrounding (#41)#486
jamiepine merged 1 commit intomainfrom
fix/audio-session-keepalive

Conversation

@jamiepine
Copy link
Copy Markdown
Owner

@jamiepine jamiepine commented Apr 19, 2026

Summary

  • Adds an always-on AudioKeepAlive component mounted at the app root that plays a silent looping WAV through a plain <audio> element, keeping macOS's CoreAudio session warm for the lifetime of the app.
  • Fixes the long-standing bug where leaving Voicebox backgrounded for long enough puts the WaveSurfer WebAudio output into a dead state — play() resolves, timeupdate fires, but no sound reaches the speakers.

Why

Closes #41. Also addresses the internal "WebAudio playback dies after audio-session interruption" note in docs/PROJECT_STATUS.md.

I'd previously tried:

  • Swapping WaveSurfer backend away from WebAudio → introduced more bugs, not viable.
  • Remounting the player on wake → a freshly-created AudioContext is born suspended and doesn't fix the problem.
  • Calling ctx.resume() on play/visibilitychange (AudioContext.state based fix) → didn't help either, because the teardown is below the WebAudio layer. Confirmed in this session: once broken, a full cmd+R JS reload (which destroys every AudioContext and recreates them) still produces no audio. Only relaunching the Tauri process restores it. That rules out anything at the JS/WebAudio level.

The remaining plausible cause is WKWebView's audio output session getting torn down by the OS after prolonged idle. A known workaround (used by Spotify/YouTube Music web) is to keep at least one audio element perpetually playing silent PCM, which keeps the session bound.

Implementation

  • app/src/components/AudioPlayer/AudioKeepAlive.tsx — generates a 1-second silent 8 kHz WAV in memory, wraps it in a blob URL, and loops it through a plain <audio> element at full volume (true PCM silence — not muted, because muted media can be optimized away).
  • app/src/components/AppFrame/AppFrame.tsx — mounts <AudioKeepAlive /> once at the root so it's always running.
  • Retries play() on the first pointer/keydown if autoplay is blocked, and on visibilitychange/focus/pageshow in case the webview ever pauses it.

Test plan

  • Run dev build fresh (bun tauri dev), confirm no audible output and no console errors from the keep-alive.
  • Play something normally via the main AudioPlayer — verify unaffected.
  • Background the app (cmd+tab / minimize / screen lock) for an extended period (30 min+ or long enough to previously trigger the bug). Return to foreground and play a generation. Expect: audio plays without needing to relaunch.
  • Check that silent WAV loop doesn't show up in the macOS "app is making sound" indicator as annoying.
  • Confirm no regression in the Stories-editor playback path (useStoryPlayback uses its own AudioContext and should be unaffected).

🤖 Generated with Claude Code


Note

Medium Risk
Introduces a continuously-playing hidden audio element and global event listeners, which could affect autoplay behavior, resource usage, or platform-specific audio handling despite being contained to the app shell.

Overview
Prevents macOS WKWebView/Tauri audio output from going “dead” after extended idle/backgrounding by mounting a new root-level AudioKeepAlive component in AppFrame.

AudioKeepAlive generates a silent WAV blob and loops it via a plain Audio element, retrying play() on user gestures and on wake/visibility events, with cleanup that revokes the blob URL and removes listeners.

Reviewed by Cursor Bugbot for commit 010c3d5. Configure here.

Summary by CodeRabbit

  • New Features
    • Enhanced audio session persistence to maintain playback continuity when the app is backgrounded, minimizing interruptions during idle periods.

…ing (#41)

Keep a silent looping <audio> element mounted at the app root so macOS
never tears down the CoreAudio session. Without this, backgrounding the
app long enough leaves WaveSurfer's AudioContext in a state where play()
resolves and timeupdate fires, but no audio reaches the output — and not
even cmd+R (full JS reload) restores it, only a full app relaunch.

Uses a zero-PCM WAV blob at full volume rather than a muted element,
since WebKit can optimize muted media away and defeat the purpose.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 19, 2026

📝 Walkthrough

Walkthrough

A new AudioKeepAlive component was added and integrated into AppFrame to maintain audio session persistence by looping a silent WAV track, with retry logic triggered on user gestures and visibility state changes.

Changes

Cohort / File(s) Summary
AppFrame Integration
app/src/components/AppFrame/AppFrame.tsx
Integrated AudioKeepAlive component, imported and rendered immediately after TitleBarDragRegion, independent of route-based editor selection logic.
Audio Keep-Alive Component
app/src/components/AudioPlayer/AudioKeepAlive.tsx
New component that constructs and plays a looping silent WAV file; implements retry logic on pointerdown, keydown, visibilitychange, focus, and pageshow events to maintain audio session; handles cleanup on unmount.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~12 minutes

Poem

🐰 A silent song loops through the day,
Keeping audio close, never stray,
When other apps steal the speaker's voice,
This tiny component will hear, rejoice!
One decibel of quiet might just save the play! 🎵

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The PR title clearly and specifically describes the main change: preventing WKWebView audio session teardown after backgrounding, with the issue number reference (#41) providing full context.
Linked Issues check ✅ Passed The PR directly addresses issue #41 by implementing AudioKeepAlive component to maintain audio session and prevent teardown when app is backgrounded or loses audio output focus.
Out of Scope Changes check ✅ Passed All changes are directly scoped to the audio session persistence problem: AudioKeepAlive component implementation and AppFrame integration, with no unrelated modifications.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/audio-session-keepalive

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
app/src/components/AudioPlayer/AudioKeepAlive.tsx (1)

58-61: Optional: self-remove gesture listeners after playback starts.

pointerdown and keydown are attached with { once: false } and live for the entire app lifetime, firing on every keystroke/click. tryPlay short-circuits when !paused, so overhead is negligible, but you can avoid the work entirely by removing these two listeners once play() resolves successfully. The visibilitychange/focus/pageshow listeners already cover the resume-after-background case, so the gesture listeners are only needed until the first successful start.

♻️ Suggested refactor
     const tryPlay = () => {
       if (!audioRef.current) return;
       if (!audioRef.current.paused) return;
-      audioRef.current.play().catch((err) => {
-        debug.log('[AudioKeepAlive] play blocked (will retry on next gesture):', err);
-      });
+      audioRef.current
+        .play()
+        .then(() => {
+          window.removeEventListener('pointerdown', onGesture);
+          window.removeEventListener('keydown', onGesture);
+        })
+        .catch((err) => {
+          debug.log('[AudioKeepAlive] play blocked (will retry on next gesture):', err);
+        });
     };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/components/AudioPlayer/AudioKeepAlive.tsx` around lines 58 - 61, The
gesture listeners (added via window.addEventListener for 'pointerdown' and
'keydown' using the onGesture handler) remain for the app lifetime; update
tryPlay (or its promise resolution path) to remove those two listeners once
play() succeeds so they don't fire forever — either have tryPlay call
window.removeEventListener('pointerdown', onGesture) and
window.removeEventListener('keydown', onGesture) after a successful play or
resolve a promise that then removes them; keep the existing
visibility/focus/pageshow handlers as-is and only remove the gesture listeners
after the first successful start to avoid unnecessary work.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@app/src/components/AudioPlayer/AudioKeepAlive.tsx`:
- Around line 58-61: The gesture listeners (added via window.addEventListener
for 'pointerdown' and 'keydown' using the onGesture handler) remain for the app
lifetime; update tryPlay (or its promise resolution path) to remove those two
listeners once play() succeeds so they don't fire forever — either have tryPlay
call window.removeEventListener('pointerdown', onGesture) and
window.removeEventListener('keydown', onGesture) after a successful play or
resolve a promise that then removes them; keep the existing
visibility/focus/pageshow handlers as-is and only remove the gesture listeners
after the first successful start to avoid unnecessary work.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: fe47ea78-41fb-4931-af49-1f5fbb58185c

📥 Commits

Reviewing files that changed from the base of the PR and between 28aa963 and 010c3d5.

📒 Files selected for processing (2)
  • app/src/components/AppFrame/AppFrame.tsx
  • app/src/components/AudioPlayer/AudioKeepAlive.tsx

@jamiepine jamiepine merged commit d3a4433 into main Apr 19, 2026
3 checks passed
jamiepine added a commit that referenced this pull request Apr 19, 2026
…ates

- Delete docs/TROUBLESHOOTING.md; the canonical troubleshooting guide now
  lives under docs/content/docs/overview/troubleshooting.mdx so it's served
  from docs.voicebox.sh alongside the rest of the docs.
- CONTRIBUTING.md + README.md: repoint "Troubleshooting" references to the
  new MDX path. README gets a top-level callout so users hit the guide
  before filing an issue.
- PROJECT_STATUS.md: refresh issue/PR counts, document the flash-attn
  warning (cosmetic on all platforms; CUDA-only, fallback is PyTorch SDPA
  which is near-FA2 on Ampere+) with per-platform context + community
  Windows wheels + SageAttention/xformers alternatives, add WebAudio
  audio-session bug note (tracked separately in PR #486), and expand the
  Qwen 0.6B→1.7B MLX fallback explanation for triage.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
jamiepine added a commit that referenced this pull request Apr 19, 2026
… on GitHub) (#487)

* fix(landing): use qwen_custom_voice in API example (instruct is CustomVoice-only)

The curl snippet showed engine: "qwen" alongside an instruct field, but base
Qwen3-TTS has no instruct path — that's a Qwen CustomVoice feature.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(landing): use a realistic UUID for profile_id in API example

Profile IDs are str(uuid.uuid4()), not slugs (see backend/services/profiles.py:175).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(landing): add polished /download page — no more dumping users on GitHub

Users were clicking download, landing on the GitHub releases page, and filing
confused comments along the lines of "I ended up on some blog site called
GitHub." We now route every download CTA through a dedicated /download page
that auto-triggers the platform-specific download and gives users a polished
post-click experience with donate + docs + AI help prompts.

- New /download page:
  - Big app logo + "Your download has started" messaging.
  - Auto-detects platform from ?platform=X or navigator.userAgent.
  - Programmatically clicks a hidden anchor to trigger the file download
    without leaving the page.
  - Platform-specific buttons as a visible fallback for "download not
    working" / manual-pick.
  - Personal donate spiel + Buy Me a Coffee button.
  - Resources grid: docs, DeepWiki ("got questions? ask AI"), GitHub.
- Landing page download section cards now link to /download?platform=X
  instead of the asset URL directly.
- /download/[platform] (used by README/docs links) now redirects to the
  /download page rather than straight to the asset or to GitHub on error.
- Drops unused downloadLinks state from the landing page.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(landing): use official platform brand icons via simple-icons

The hand-rolled Linux SVG path wasn't actually Tux — it was a symmetric
placeholder shape. Apple/Windows were close but not canonical either.

- Apple + Linux: pulled from @icons-pack/react-simple-icons (SiApple, SiLinux).
- Windows: simple-icons drops the Microsoft mark over trademark policy, so
  the Windows 11 flag is inlined from Microsoft's public brand guidance.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(landing): route Download CTAs to /download page, not the section anchor

Hero CTA, navbar link, and footer link were all scrolling to #download
(the section at the bottom of the page) instead of going to the new
/download page that triggers the actual download.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(landing): run dev server on Node instead of Bun runtime

Bun runtime + Next 16 Turbopack dev server intermittently trips a
JavaScriptCore allocator panic ('pas panic: deallocation did fail ...
Alloc bit not set') after a few requests. Dropping --bun keeps Bun as
the package manager but runs next dev on Node, which is stable.

Build + start keep --bun since one-shot invocations don't exhibit the
allocator drift.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(landing): route Linux users to /linux-install instead of attempting download

No prebuilt Linux binary exists yet (see /linux-install for build-from-source
instructions). The /download page previously treated Linux like the other
platforms — auto-triggering a non-existent AppImage and offering a dead
manual button.

- /download page: if platform resolves to 'linux' via ?platform or UA detect,
  window.location.replace('/linux-install') — never try to auto-download.
- Manual Linux card: label changed to "Build from source" and links to
  /linux-install (no download attribute, no asset URL).
- /download/linux pretty URL: 307s straight to /linux-install.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: consolidate troubleshooting into the MDX docs site + status updates

- Delete docs/TROUBLESHOOTING.md; the canonical troubleshooting guide now
  lives under docs/content/docs/overview/troubleshooting.mdx so it's served
  from docs.voicebox.sh alongside the rest of the docs.
- CONTRIBUTING.md + README.md: repoint "Troubleshooting" references to the
  new MDX path. README gets a top-level callout so users hit the guide
  before filing an issue.
- PROJECT_STATUS.md: refresh issue/PR counts, document the flash-attn
  warning (cosmetic on all platforms; CUDA-only, fallback is PyTorch SDPA
  which is near-FA2 on Ampere+) with per-platform context + community
  Windows wheels + SageAttention/xformers alternatives, add WebAudio
  audio-session bug note (tracked separately in PR #486), and expand the
  Qwen 0.6B→1.7B MLX fallback explanation for triage.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(landing): address PR #487 review feedback

- Preserve canonical camelCase platform aliases (macArm, macIntel) in the
  /download/[platform] redirect so those URLs don't lose their platform param.
- Add accessible title + role="img" to the inline Windows SVG so it passes
  Biome's a11y rule and announces to screen readers.
- On /api/releases fetch failure, show an explicit error state with a single
  intentional link to GitHub releases — no more silent GitHub fallback or
  disabled-button UX lie. Keeps normies off GitHub unless they opt in.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

MacOS bug: no audio once another app uses the same output

1 participant