Skip to content

fix(audio): async decode playback edge cases#186

Merged
thewrz merged 13 commits into
mainfrom
feat/issue-152
Jul 6, 2026
Merged

fix(audio): async decode playback edge cases#186
thewrz merged 13 commits into
mainfrom
feat/issue-152

Conversation

@thewrz

@thewrz thewrz commented Jun 27, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • defer cold-cache UI ownership until decode succeeds, while warm cached plays still highlight immediately
  • coalesce same-id cold decode requests onto one blocking decode and apply the latest dispatch when it lands
  • drop decode results for sounds removed from the library and reconcile active/pending playback on rescan
  • evict waveform envelopes alongside PCM cache victims

Design decisions

Validation

  • cargo test app::playback
  • cargo clippy -- -D warnings
  • cargo test

Closes #152

Summary by CodeRabbit

  • New Features
    • Cold-start playback now updates the current track highlight immediately while audio decodes in the background, ensuring a responsive “now playing” experience.
  • Bug Fixes
    • Stopping all playback fully blocks any in-flight decode results from restarting audio or reapplying highlights.
    • Removing tracks mid-decode no longer leaves stale visuals, cached results, or queued decode state.
    • Rescanning the library now reconciles playback with the latest catalog and stops voices for removed tracks.
    • Waveform visuals are improved by evicting cached envelopes without blanking the currently playing waveform.

@coderabbitai

coderabbitai Bot commented Jun 27, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 74709c5c-980f-404c-bf34-64ffd797b269

📥 Commits

Reviewing files that changed from the base of the PR and between 671605d and 6077158.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (5)
  • src/app/mod.rs
  • src/app/playback.rs
  • src/app/playback/cache_tests.rs
  • src/app/playback/test_support.rs
  • src/app/playback/tests.rs

📝 Walkthrough

Walkthrough

Adds pending-decode tracking for cold playback, enforces decode ownership and library membership before applying results, clears stale decode state on stop and rescan, updates playback tests, and extracts AudioHandle into its own module.

Changes

Async decode edge-case fixes

Layer / File(s) Summary
Pending decode state and cold request routing
src/app/mod.rs, src/app/playback.rs
HonkHonk gains pending_decodes, cold requests queue PendingDecode work, and repeated cold presses for the same sound update the existing pending dispatch instead of spawning another decode task.
Decode landing and ownership checks
src/app/playback.rs, src/ui/now_playing.rs
handle_decoded only applies results while the matching pending decode still owns the voice and the sound exists; successes cache PCM and evict waveform envelopes, while failures clear optimistic UI only for the owning press.
StopAll and rescan reconciliation
src/app/mod.rs
PlaybackStarted comment text is updated, StopAll clears pending decodes, and RescanLibrary now reconciles playback state against the refreshed library.
Playback test updates and removals
src/app/playback/tests.rs, src/app/playback/cache_tests.rs, src/app/playback/test_support.rs, src/app/mod.rs, src/app/recording.rs
Playback tests move to a separate module and add coverage for cold-decode ownership, library removal, reconciliation, envelope eviction, StopAll behavior, and decode ordering; obsolete inline tests are removed and recording tests now assert optimistic play plus queued pending ids.

AudioHandle module extraction

Layer / File(s) Summary
AudioHandle relocated to dedicated module
src/audio/handle.rs, src/audio/engine.rs, src/audio/mod.rs
AudioHandle is defined in handle.rs, removed from engine.rs, spawn() constructs it through from_parts, and module re-exports are updated.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant UI
  participant HonkHonk
  participant DecodeTask
  participant NowPlaying

  UI->>HonkHonk: request_play(sound)
  HonkHonk->>HonkHonk: set playing optimistically
  HonkHonk->>DecodeTask: queue or update PendingDecode
  DecodeTask-->>HonkHonk: Decoded / Err
  alt decode succeeds and sound exists
    HonkHonk->>NowPlaying: remove stale envelope
    HonkHonk->>HonkHonk: cache PCM and start playback
  else decode fails or sound removed
    HonkHonk->>HonkHonk: clear owned optimistic UI
  end
Loading

Possibly related PRs

  • wrzonance/HonkHonk#137: Related NowPlaying envelope work overlaps with the new envelope eviction path in this PR.
  • wrzonance/HonkHonk#172: Related playback coordination changes touch the same PlaybackDispatch/voice_id-driven async decode path.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is concise and clearly describes the main change: fixing async audio decode playback edge cases.
Linked Issues check ✅ Passed The changes address all listed #152 goals: cold-fail desync, rescan invalidation, envelope eviction, and same-id decode coalescing.
Out of Scope Changes check ✅ Passed No clear out-of-scope code changes are present; the refactors and tests support the async-decode fixes.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ 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 feat/issue-152

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

thewrz and others added 2 commits June 29, 2026 20:26
#152 deferred the cold-press highlight until decode succeeded, so the first
press of every sound in a session showed no feedback until decode completed —
against the snappy-UI doctrine (#111: keep the optimistic instant highlight,
never slow the click->play path).

Restore it: a cold cache miss claims `playing` and the pending now-playing
state immediately, then the async decode confirms the playhead on success. A
decode failure, or a sound removed mid-decode, now releases the optimistic
highlight through a shared owns-the-UI guard, so it is never left stuck on a
sound that produced no audio. The generation/terminal-event invariant is
preserved: a superseded older press never retakes a newer press's highlight.

Reviving the optimistic path also un-orphans now_playing::pending(), which
#152 had left as dead code.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…undary testable

Two review follow-ups for #152, plus the test infrastructure they needed.

- reconcile_playback_with_library now sends StopVoice for the highlighted
  sound when its library entry vanishes mid-play, instead of only clearing the
  UI and leaving the engine to honk the removed sound to completion ("ghost
  honk"). The highlighted voice id is the current play_generation, since only
  the owning press holds the highlight.
- Add a test-only command tap to AudioHandle (the pipewire command channel
  can't be drained synchronously, so no test could previously assert what
  reached the engine). Pin the core invariant the PR is about: a cold press
  fires exactly one Play when its decode lands, and a decode landing after
  StopAll is stale and fires none.
- Extract AudioHandle + test_handle out of the over-budget engine.rs (927
  lines) into a focused handle.rs (78), so the tap lands in a compliant file
  and engine.rs shrinks rather than grows.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@thewrz thewrz marked this pull request as ready for review July 6, 2026 05:01

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
src/ui/now_playing.rs (1)

101-104: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Consider guarding against evicting the currently displayed waveform.

remove_envelope unconditionally deletes the cached envelope. If the PCM-cache LRU eviction victim happens to be the sound currently shown via active_id (e.g., still playing), the waveform would blank out mid-playback until the sound's envelope is regenerated on a future decode. Existing tests only cover eviction of a non-active sound (pcm_eviction_removes_matching_waveform_envelope), not the currently-playing case.

This is a narrow, self-recovering visual glitch rather than a functional break, but worth a quick check on whether the eviction call site (evict_waveform_envelopes in src/app/playback.rs) can ever target the active sound given the AudioStore's LRU recency semantics.

💡 Optional defensive guard
     pub(crate) fn remove_envelope(&mut self, id: &str) {
-        self.envelopes.remove(id);
+        if self.active_id.as_deref() != Some(id) {
+            self.envelopes.remove(id);
+        }
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/ui/now_playing.rs` around lines 101 - 104, Guard the waveform cache
eviction path so the currently displayed/playing envelope is not removed. Update
remove_envelope in NowPlaying to check against the active_id state (or add the
protection at evict_waveform_envelopes in playback handling) before deleting
from envelopes, so the active sound keeps its waveform visible until playback
changes. Keep the existing non-active eviction behavior intact and verify the
PCM eviction path cannot blank the waveform for the active item.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@src/ui/now_playing.rs`:
- Around line 101-104: Guard the waveform cache eviction path so the currently
displayed/playing envelope is not removed. Update remove_envelope in NowPlaying
to check against the active_id state (or add the protection at
evict_waveform_envelopes in playback handling) before deleting from envelopes,
so the active sound keeps its waveform visible until playback changes. Keep the
existing non-active eviction behavior intact and verify the PCM eviction path
cannot blank the waveform for the active item.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 17d76f7c-326f-45d7-9ee5-71028fda152e

📥 Commits

Reviewing files that changed from the base of the PR and between 1278248 and 369317c.

📒 Files selected for processing (8)
  • src/app/mod.rs
  • src/app/playback.rs
  • src/app/playback/tests.rs
  • src/app/recording.rs
  • src/audio/engine.rs
  • src/audio/handle.rs
  • src/audio/mod.rs
  • src/ui/now_playing.rs

thewrz and others added 3 commits July 5, 2026 23:16
Fixes the CI lint (cargo fmt --check) failure on this branch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A decode cancelled by StopAll/interrupt shares its sound id with a
re-press's fresh pending entry. Landing first, it consumed that entry:
a stale error cleared the new press's optimistic highlight and the real
decode result was then dropped on arrival, so the sound never played.
Pending entries are now consumed only by the task that owns them
(matching voice id); stale landings fall through and are dropped.

Codex review finding [P2] on PR #186.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A late decode evicting the active sound's PCM under cache pressure also
removed its waveform envelope, blanking the visible waveform mid-play
even though the engine keeps playing from its own Arc. The active
sound's envelope is now exempt from eviction.

Codex review finding [P3] / CodeRabbit nitpick on PR #186.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@thewrz

thewrz commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

Second-reviewer pass (Codex GPT-5.5, adversarial) + CodeRabbit nitpick, all addressed:

  • Codex [P2] — stale same-id decode consumes the new pending decode: confirmed and fixed in 4b84ff8. pending_decode_for now only consumes a pending entry when the landing task's voice id matches task_voice_id; a decode cancelled by StopAll/interrupt can no longer clear a re-press's optimistic highlight or starve its real result. Regression test: stale_same_id_decode_does_not_consume_new_pending_decode.
  • Codex [P3] / CodeRabbit nitpick — active waveform evicted mid-play: confirmed (both reviewers converged on this) and fixed in 671605d. NowPlaying::remove_envelope now exempts the active sound; playback continues from the engine's Arc, so the visible waveform no longer blanks when its PCM is the LRU victim. Regression test: pcm_eviction_keeps_active_waveform_envelope.
  • The CI lint failure was a pure cargo fmt diff in src/audio/mod.rs, fixed in bfab2c7.

Verified locally: fmt clean, clippy -D warnings clean, full test suite green (hermetic XDG override).

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
src/app/playback/tests.rs (1)

236-245: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Prefer captured generation values over hardcoded literals.

dispatch(&app, 2) / dispatch(&app, 1) hardcode the expected generation counter values, whereas the sibling test below (stale_same_id_decode_does_not_consume_new_pending_decode) captures app.play_generation right after each press. Hardcoding is fragile if the generation counter's starting value or increment behavior changes later.

♻️ Suggested tweak
     let _ = app.request_play(&a, false);
+    let gen_a = app.play_generation;
     let _ = app.request_play(&b, false);
-    let _ = app.handle_decoded("b".into(), Ok(pcm(4)), dispatch(&app, 2));
+    let gen_b = app.play_generation;
+    let _ = app.handle_decoded("b".into(), Ok(pcm(4)), dispatch(&app, gen_b));
     assert!(app.now_playing.envelope("b").is_some());

     // The late older decode for A lands and evicts B's PCM under cache
     // pressure while B is still the active now-playing sound.
-    let _ = app.handle_decoded("a".into(), Ok(pcm(8)), dispatch(&app, 1));
+    let _ = app.handle_decoded("a".into(), Ok(pcm(8)), dispatch(&app, gen_a));
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/playback/tests.rs` around lines 236 - 245, The concurrent playback
test uses hardcoded generation values in dispatch calls, which should instead be
captured from the app state like the sibling test does. Update the test around
request_play and handle_decoded to read the current play generation after each
press and pass those captured values into dispatch, using the existing
app.play_generation and dispatch helpers to keep the assertions resilient to
generation counter changes.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/app/playback/tests.rs`:
- Around line 228-298: The test module is growing past the 400-line limit, so
split the long `src/app/playback/tests.rs` file before adding more cases. Move
the new coverage out of the current
`pcm_eviction_keeps_active_waveform_envelope` and
`stale_same_id_decode_does_not_consume_new_pending_decode` area into a separate,
scenario-focused test file or module (for example, grouping decode ownership and
eviction tests) and keep `tests.rs` as a smaller entry point.

---

Nitpick comments:
In `@src/app/playback/tests.rs`:
- Around line 236-245: The concurrent playback test uses hardcoded generation
values in dispatch calls, which should instead be captured from the app state
like the sibling test does. Update the test around request_play and
handle_decoded to read the current play generation after each press and pass
those captured values into dispatch, using the existing app.play_generation and
dispatch helpers to keep the assertions resilient to generation counter changes.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 8c8f816d-01fe-4c4c-8cb5-e4c71cf99620

📥 Commits

Reviewing files that changed from the base of the PR and between 369317c and 671605d.

📒 Files selected for processing (4)
  • src/app/playback.rs
  • src/app/playback/tests.rs
  • src/audio/mod.rs
  • src/ui/now_playing.rs
🚧 Files skipped from review as they are similar to previous changes (3)
  • src/ui/now_playing.rs
  • src/audio/mod.rs
  • src/app/playback.rs

Comment thread src/app/playback/tests.rs Outdated
thewrz and others added 4 commits July 5, 2026 23:36
Both advisories are DoS-via-untrusted-XML in quick-xml < 0.41. The sole
consumer is wayland-scanner, a build-time proc-macro parsing vendored
Wayland protocol XML; no untrusted XML is parsed at runtime, so neither
issue is reachable in the shipped binary. wayland-scanner 0.31.10
(latest) still pins quick-xml ^0.39 — drop these ignores when it moves
to 0.41.

Closes #192.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
anyhow 1.0.102 is flagged unsound (Error::downcast_mut UB after
Error::context); 1.0.103 is the patched release already on main.
Regenerates the Flatpak cargo-sources.json for the lock change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
tests.rs hit 465 lines after the decode-ownership regressions landed.
Fixtures move to test_support.rs; warm-path, eviction, and library-
reconciliation tests move to cache_tests.rs; decode-ownership lifecycle
tests stay in tests.rs. No test behavior changes.

Addresses CodeRabbit review on PR #186.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rals

Addresses CodeRabbit nitpick on PR #186; keeps the concurrent-decode
tests resilient to generation-counter changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@thewrz

thewrz commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

Follow-up items from the latest CodeRabbit pass, both addressed:

  • 400-line cap on playback/tests.rs (review thread): fixed in ce256ee — tests split into test_support.rs / tests.rs / cache_tests.rs, all under the cap.
  • Hardcoded generation literals (body nitpick): fixed in 86240ec — the concurrent-decode tests now capture app.play_generation after each press instead of hardcoding 1/2.

Also bumped anyhow 1.0.102 → 1.0.103 (f902f83): the old lock on this branch tripped RUSTSEC-2026-0190 (unsound Error::downcast_mut) in the Deny job; 1.0.103 is the patched release already on main. Flatpak cargo-sources.json regenerated for the lock change. Full suite green locally.

@thewrz thewrz merged commit 18b6979 into main Jul 6, 2026
6 of 7 checks passed
@thewrz thewrz deleted the feat/issue-152 branch July 6, 2026 14:59
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.

perf(audio): async-decode edge cases — cold-fail desync, rescan invalidation, envelope cap, decode coalescing

1 participant