Skip to content

improve(editor): mix export audio from contiguous slices - #2

Open
kyzer1023 wants to merge 164 commits into
mainfrom
cursor/export-audio-sample-slice-fb48
Open

kyzer1023 wants to merge 164 commits into
mainfrom
cursor/export-audio-sample-slice-fb48

Conversation

@kyzer1023

@kyzer1023 kyzer1023 commented Oct 19, 2026 •

Copy link
Copy Markdown
Owner

Streaming MP4 export mixes through ExportAudioView. That type only implemented sample(), so cap_audio::render_audio always took the per-sample fallback for ExportAudioSource::Streaming. VoiceSource::Original already forwards sample_slice, and VoiceAudio / AudioData already provide it.

ExportAudioView::sample_slice returns the buffered window shifted by source_start * channels. A range that is not fully inside the window returns None, and mixing falls back to sample(). Same samples, same multiply-add, so the output is bit-identical.

The first cut of the stereo slice path used chunks_exact().zip() into mix_stereo. Under the shipping profile (opt-level = s, thin LTO, codegen-units = 1) that iterator did not inline, so each stereo frame paid a call and the mix was slower than indexing (~0.93×). mix_stereo_slice is #[inline(always)] and walks the returned slice with a plain indexed loop. StereoMode is chosen once per block. Mono still uses the existing slice loop. Indexed fallback is unchanged.

This pull request is against kyzer1023/Cap:main. The available PR tool opens on this fork and could not target CapSoftware/Cap:main. The branch is two commits on top of this fork's main (fe43d283c): d6b1a8e66 adds sample_slice, and 7beebecab inlines the stereo slice mix. It does not touch files in CapSoftware#2521 or CapSoftware#1786.

Test

offset_view_slice_and_indexed_mixing_are_bit_identical builds a view with source_start = 1537 and compares the slice path with an indexed source that leaves sample_slice as None. Mono and stereo (including MonoR), in-window blocks, ranges that start before the window, and a window shorter than the requested range all match on f32::to_bits. The in-window case asserts the slice path is actually taken.

contiguous_and_indexed_mixing_preserve_sample_bits_and_output_bounds still matches the previous expected bit pattern, including a stereo MonoR slice mix.

cargo fmt --all and cargo clippy -p cap-audio --tests --no-deps -- -D warnings are clean. cargo test -p cap-audio --lib contiguous_and_indexed_mixing_preserve_sample_bits_and_output_bounds and cargo test -p cap-editor --lib offset_view_slice_and_indexed_mixing_are_bit_identical passed.

Benchmark

rustc 1.88.0. One hour of audio at 48 kHz (172,800,000 frames) mixed in 4096-frame blocks, source_start = 12_345, unity gain, StereoMode::Stereo. The indexed path is today's behavior (sample_slice returns None). Checksums are of every output sample's to_bits, computed before the timed passes. Both passes and both optimization levels produced the same checksums as each other and as the previous slice implementation, so the paths are bit-identical. The second pass is the warm headline.

Workspace release profile (opt-level = s, thin LTO, codegen-units = 1):

channels pass indexed slice speedup checksum
mono 0 345.6 ms 276.8 ms 1.25× 0x97051bbf7d6bd218
mono 1 345.1 ms 274.5 ms 1.26× 0x97051bbf7d6bd218
stereo 0 486.4 ms 388.9 ms 1.25× 0x9554694b9c8eec00
stereo 1 484.1 ms 403.1 ms 1.20× 0x9554694b9c8eec00

opt-level = 3 (LTO off, codegen-units = 16), same machine and toolchain:

channels pass indexed slice speedup checksum
mono 0 292.4 ms 203.9 ms 1.43× 0x97051bbf7d6bd218
mono 1 292.6 ms 202.2 ms 1.45× 0x97051bbf7d6bd218
stereo 0 858.1 ms 305.4 ms 2.81× 0x9554694b9c8eec00
stereo 1 855.4 ms 305.2 ms 2.80× 0x9554694b9c8eec00

Under the shipping profile both layouts are faster than indexing (mono 1.26×, stereo 1.20× on the warm pass). At opt-level = 3 the same paths are 1.45× and 2.80×.

Open in Web Open in Cursor 

richiemcilroy and others added 30 commits September 21, 2026 11:46
`CAP_GPUI_AUTO_TIMELINE_BENCH=1` times a synchronous `Window::draw` of the
editor the way `camera_bench` does for the camera bubble, after the
`CAP_GPUI_AUTO_EDITOR` project is open and its waveforms have landed. It
reports mean / p50 / p95 / max draw time for five scenarios: playback at
the default zoom, playback zoomed out to the whole clip, a paused hover
sweep, a zoomed-in following playback, and a clean draw with nothing
notified. The playing scenarios dirty the timeline through the production
`invalidate_playback_chrome`.

The draw-and-report helpers move out of `camera_bench` into `draw_bench`
so both benches share them.
…ints

The timeline repaints on every playhead tick during playback, and each
repaint rebuilt the mic and system waveform fills with lyon even when
their inputs had not changed. Zoomed out to a whole 180 s clip the two
fills tessellate to about 34,000 vertices, which cost the editor around
16 ms per draw on `main`, a full 60 Hz frame budget.

`waveform_path` now takes one `WaveformGeometry` value, which doubles as
the key of a `WaveformPaths` cache on `TimelineModel`: one slot per clip
segment and track, compared on every builder input with the peak table
matched by `Arc` identity, swept each render so only last frame's visible
paths are kept, and carried across `rebuild_timeline` like the waveforms.
The paint closure clones the stored path on a hit; colour, opacity, mask
and DPI scale are still applied by `paint_path` per paint. Once the view
follow-scrolls, the range changes every tick and the cache misses, so
scrolling playback costs what it did before.

Timeline draw benchmark, 180 s clip, 5 interleaved runs, median p50:
playing at the default zoom before the view follows 1.12 ms -> 0.70 ms,
playing zoomed out 15.7 ms -> 0.80 ms, paused hover zoomed out
16.1 ms -> 0.96 ms, scrolling playback (every draw misses)
1.39 ms -> 1.36 ms.
… in place

The Linux Instant camera compositor converted every screen frame to RGBA and back, and rebuilt the camera overlay and its mask, just to blend a small camera rectangle. Blend directly into packed RGB screen frames, rebuild the overlay only when a new camera frame arrives, and compute the mask once. The old conversion path remains the fallback for other formats.
…rame

ReadSample hands back a null sample for stream ticks (gaps in the stream),
and read_sample reported those as Ok(None), the same as the end of the
stream. The decoder then stopped a batch early and answered with an older
frame. Read again until a sample, the end-of-stream flag or an error.
…est state machine

The decoder thread mixed batching, a mid-batch readahead slide, deferred
requests and four fallback chains in one loop. The policy now lives in
FrameRequests, which never touches Media Foundation: the thread takes in
requests, asks it for the next step (seek, decode one sample, or wait) and
does it. Tests drive it with a fake sample stream.

Once the decoder moves past a requested frame, the request gets the newest
sample at or below that frame. Requests are served lowest first, each with
its own seek when decoding forward cannot reach it, so the "first sample
after" fallback only applies to a frame before the stream's first sample.
The cache keeps every sample since the last seek and evicts the oldest, so
the newest sample at or below a frame is always cached.

Behaviour changes:
- Several requests in flight (export transitions between two segments of
  one recording decode both through the same decoder) each get their own
  frame. The highest used to be the target, and a lower one far from it got
  the target's frame.
- A request behind the decoder with nothing cached at or below it seeks back.
  Within half the cache size it used to get the next decoded, later frame.
- Every request slides the readahead, cache hits included, so a sequential
  reader no longer drains the cache while the decoder idles.
- Readahead stops at its frame count, or before the next sample would evict
  the one answering the latest request (frames too big for the cache budget
  used to get a later frame); the per-batch time budget is gone.
- Samples outside the old cache window are cached too, so a frame after a
  VFR gap past the window gets its own sample, not the one before the gap.
- A failed seek answers what waits from the cache instead of reading on from
  the old position as if it had landed.

Export output is byte-identical to the previous decoder on the reference
recording.
The decoder thread copied each sample to a staging texture and mapped it
straight away, so it sat blocked in Map while the GPU decoded and copied, and
the GPU sat idle while the CPU copied the frame out. read_nv12 now queues the
copy of the next sample before mapping the current one, through a ring of two
staging textures created with the decoder.

Samples are copied from Media Foundation's output surface directly into
staging, skipping the pool texture that only the disabled zero-copy path
needs. The rendering decoder caches the CPU NV12 data itself and drops the
zero-copy fields its frames could never fill, along with the two zero-copy
constructors that were their only producers.
… job

An export that ffprobe cannot read, that has the wrong frame count, or that
falls outside the requested size is now an invalid run instead of a timing
sample. Benchmark processes start suspended and resume only once they are in
the job object, so nothing they spawn escapes its CPU accounting, and a failed
assignment kills the process. A note says why playback forces FFmpeg.
…up-on-events

perf(recording): read the cursor image only on ticks that record an event
…ccluded-wake

fix(gpui): don't draw a hidden window when it wakes from idle
…t-waits-for-work

fix(desktop-gpui): hold the automatic update prompt until recording or export finishes
…window-frame

fix(gpui): end a frame quietly when its window has already closed
…ized-studio-backup

fix(recording): drop the original media backup once a plain Studio stop is published
…-install-shared-crate

fix(desktop-gpui): install the CLI from Settings with the shared cli-install crate
…erprint

perf(recording): compare the cursor bitmap before encoding its TIFF while the pointer moves
…single-frames

perf(gpui): draw one frame per wake while a window is idle instead of restarting the display link
…cierge-migration-20260921

feat: add Cap Pro Loom concierge migration
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.