Skip to content

perf(audio): async decode + PCM/waveform caching to kill click-to-fire lag #151

Description

@thewrz

Problem

There is a subtle, length-dependent lag when firing playback: the longer the
audio file, the more noticeable the delay between click and sound. Root cause:
play_sound_entry (src/app.rs) calls crate::audio::decode() synchronously
inside Iced's update() loop
on every play, decoding the entire file to PCM
(decoder.rs:decode_packets). That cost scales with file length and blocks the
UI thread.

Secondary waste on the same hot path:

  • The waveform envelope is cached in-memory per id (good) but is lost on
    every restart
    and its generation is coupled to the synchronous decode.
  • The playhead length reads decoded.duration — available only because we fully
    decoded — even though duration_ms is already known cheaply from metadata.
  • Per-sound volume is applied with an O(n) map().collect() copy of the PCM on
    every play.

The render side is already optimized (persistent canvas::Cache, ADR-009 / #137)
— this is purely the click → decode → fire path.

Goal

Make firing playback responsive regardless of file length, and stop redoing
length-scaling work on every play, in a memory-conscious way. Two slices:

A — Async decode + in-memory caches. Move decode off the UI thread into an
iced::Task keyed by the play_generation token (#149) so stale/superseded
decodes are dropped (correct rapid-fire ordering). Add a byte-capped LRU
decoded-PCM cache
so a hot, rapidly re-fired sound (incl. long ones) fires
instantly without re-decoding, while cold sounds evict past the cap. Move
per-sound volume into the engine so the cached canonical PCM Arc is shared
with the engine (no per-play copy).

B — Persisted waveform envelope. Persist the 1024-bucket envelope
(~4 KB/sound) under $XDG_DATA_HOME/honkhonk/waveforms/<id>.bin, generated
lazily (piggybacks A's decode) and loaded on demand on later sessions.
Invalidate via a stored (size, mtime) fingerprint; format is versioned so a
future content-hash key (gated by a cheap stat pre-check) can be added without a
rewrite.

Explicitly out of scope (future)

Mix/overlap playback (multi-voice "mix vs. stop-and-retrigger" setting),
content-hash keying, eager whole-library pre-generation, and trimming — all
designed-around but not built here.

Acceptance

  • Firing a long sound never blocks the UI; the tile highlights instantly.
  • Re-firing the same (warm) sound does not re-decode.
  • Envelope survives restart (no recompute when the file is unchanged).
  • Covered by unit tests in the default cargo test run.

Design spec: docs/superpowers/specs/2026-06-23-playback-decode-and-waveform-caching-design.md.

Metadata

Metadata

Assignees

No one assigned

    Labels

    audioPipeWire, decode, playbackenhancementNew feature or requeststateConfig, library, persistence

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    Status
    Done

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions