fix(audio): prevent WKWebView audio session teardown after backgrounding (#41)#486
fix(audio): prevent WKWebView audio session teardown after backgrounding (#41)#486
Conversation
…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>
📝 WalkthroughWalkthroughA new Changes
Estimated code review effort🎯 2 (Simple) | ⏱️ ~12 minutes Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
🧹 Nitpick comments (1)
app/src/components/AudioPlayer/AudioKeepAlive.tsx (1)
58-61: Optional: self-remove gesture listeners after playback starts.
pointerdownandkeydownare attached with{ once: false }and live for the entire app lifetime, firing on every keystroke/click.tryPlayshort-circuits when!paused, so overhead is negligible, but you can avoid the work entirely by removing these two listeners onceplay()resolves successfully. Thevisibilitychange/focus/pageshowlisteners 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
📒 Files selected for processing (2)
app/src/components/AppFrame/AppFrame.tsxapp/src/components/AudioPlayer/AudioKeepAlive.tsx
…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>
… 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>
Summary
AudioKeepAlivecomponent 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.play()resolves,timeupdatefires, 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:
AudioContextis born suspended and doesn't fix the problem.ctx.resume()on play/visibilitychange (AudioContext.statebased fix) → didn't help either, because the teardown is below the WebAudio layer. Confirmed in this session: once broken, a fullcmd+RJS 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 — notmuted, because muted media can be optimized away).app/src/components/AppFrame/AppFrame.tsx— mounts<AudioKeepAlive />once at the root so it's always running.play()on the first pointer/keydown if autoplay is blocked, and onvisibilitychange/focus/pageshowin case the webview ever pauses it.Test plan
bun tauri dev), confirm no audible output and no console errors from the keep-alive.useStoryPlaybackuses 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
AudioKeepAlivecomponent inAppFrame.AudioKeepAlivegenerates a silent WAV blob and loops it via a plainAudioelement, retryingplay()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