Skip to main content

truce_core/
chunked_process.rs

1//! Sample-accurate parameter-dependent chunking.
2//!
3//! Splits a host audio block into sub-blocks at the
4//! `sample_offset` of every `ParamChange` (for chunkable parameters)
5//! and every `Transport` event, calling `plugin.process()` once per
6//! sub-block. `set_plain` for parameter events is deferred to the
7//! sub-block boundary where the event actually sits, so smoothers
8//! see `set_target` at the right sample instead of at sample 0 of
9//! the whole audio block.
10//!
11//! Every format wrapper routes its `process()` call through
12//! [`process_chunked`]. On formats whose host events all carry
13//! `sample_offset = 0` (VST2, AAX, LV2 in v1, AU until ramp decoding
14//! lands) the loop runs once per block and the splitting machinery
15//! is inert.
16
17use truce_params::{ParamFlags, ParamInfo, Params};
18
19use crate::buffer::AudioBuffer;
20use crate::config::ProcessMode;
21use crate::events::{Event, EventBody, EventList, TransportInfo};
22use crate::plugin::PluginRuntime;
23use crate::process::{ProcessContext, ProcessStatus};
24use crate::sample::Sample;
25
26/// Inputs to [`process_chunked`].
27///
28/// Bundled into a struct because the call has eight load-bearing
29/// references plus a couple of value fields and a positional argument
30/// list at that width is unreadable at the call site (every wrapper
31/// would invent its own helper). Construct one per `process()` call.
32pub struct ChunkedProcess<'a> {
33    /// Sorted, block-rate event stream from the host (param changes,
34    /// transport changes, MIDI). The chunker walks this once forward;
35    /// it does not mutate the list.
36    pub events: &'a EventList,
37    /// Per-instance scratch list pre-allocated to the same capacity
38    /// as `events`. Used to hold the per-sub-block rebased view of
39    /// `events`; `clear()`-ed at the start of every sub-block so the
40    /// backing `Vec` capacity is preserved across blocks. Wrappers
41    /// hold this alongside their input / output event lists.
42    pub sub_event_scratch: &'a mut EventList,
43    /// Initial transport snapshot for the block. Mutated in place
44    /// as the chunker walks past `EventBody::Transport` events; the
45    /// per-sub-block `ProcessContext` reads from this so the plugin
46    /// sees the right tempo / position for the sub-block it's in.
47    pub transport: &'a mut TransportInfo,
48    /// Host sample rate, plumbed through to each per-sub-block
49    /// `ProcessContext`.
50    pub sample_rate: f64,
51    /// Live processing mode for this block, stamped onto every
52    /// per-sub-block `ProcessContext`. Wrappers read it from the host
53    /// each block (VST3 `processMode`, LV2 freewheel port) or cache it
54    /// from a set-once callback (CLAP / AU).
55    pub process_mode: ProcessMode,
56    /// Plugin's outbound event queue. The chunker re-bases outbound
57    /// events back to block-relative coordinates before the wrapper
58    /// hands them to the host: the plugin pushes events with
59    /// sub-block-relative offsets, the chunker shifts them by the
60    /// sub-block's start sample.
61    pub output_events: &'a mut EventList,
62    /// Optional read-side params closure plumbed through to each
63    /// per-sub-block `ProcessContext`. Same shape as
64    /// `ProcessContext::with_params`.
65    pub params_fn: Option<&'a dyn Fn(u32) -> f64>,
66    /// Optional meter-write closure plumbed through likewise.
67    pub meters_fn: Option<&'a dyn Fn(u32, f32)>,
68    /// Static param metadata - the chunker keys `is_chunked(id)`
69    /// off `ParamFlags::CHUNKED` here. Wrappers cache this once
70    /// when the plugin instantiates (via
71    /// [`Params::param_infos_static`]) and pass the same slice on
72    /// every block.
73    pub param_infos: &'a [ParamInfo],
74    /// Minimum sub-block size in samples. From
75    /// [`crate::info::AutomationConfig::min_subblock_samples`].
76    /// Events whose `sample_offset` falls within
77    /// `min_subblock_samples` of the current sub-block start are
78    /// coalesced into that sub-block's leading `apply_pending_events`
79    /// batch instead of triggering a split.
80    pub min_subblock_samples: u32,
81}
82
83/// Walk the audio block in sub-block chunks, calling
84/// `plugin.process()` once per chunk with the events that land in
85/// `[block_start, block_end)` rebased to sub-block-relative offsets.
86///
87/// Returns the `ProcessStatus` returned by the *last* sub-block; for
88/// `Tail(N)` the plugin's own clock is the authority, so propagating
89/// the last call's value is the cheapest correct rule.
90///
91/// Allocation-free: the rebased event list lives in
92/// `sub_event_scratch` (capacity preserved across calls) and the
93/// audio buffer sub-views are zero-copy via
94/// [`AudioBuffer::slice`].
95pub fn process_chunked<S, P>(
96    plugin: &mut P,
97    params: &dyn Params,
98    buffer: &mut AudioBuffer<S>,
99    args: ChunkedProcess<'_>,
100) -> ProcessStatus
101where
102    S: Sample,
103    P: PluginRuntime<Sample = S>,
104{
105    let ChunkedProcess {
106        events,
107        sub_event_scratch,
108        transport,
109        sample_rate,
110        process_mode,
111        output_events,
112        params_fn,
113        meters_fn,
114        param_infos,
115        min_subblock_samples,
116    } = args;
117
118    let total = buffer.num_samples();
119    let mut block_start = 0usize;
120    let mut event_idx = 0usize;
121    let mut last_status = ProcessStatus::Normal;
122    // Floor at 1: a sub-block can't be shorter than one sample, so 0
123    // and 1 both mean "split at every event". Without the floor, an
124    // event sitting exactly on `block_start` (e.g. a Transport at
125    // offset 0, which every wrapper pushes) yields `block_end ==
126    // block_start`, a zero-length sub-block, and a `while block_start
127    // < total` loop that never advances - hanging the audio thread.
128    let min_sub = (min_subblock_samples as usize).max(1);
129
130    // Paranoid allocation check (the `rt-paranoid` feature): one section
131    // spanning the whole per-host-block run, so it covers not just the
132    // plugin's `process` but the framework glue around it - event
133    // dispatch / rebasing, sub-block slicing, output re-basing - which
134    // all run on the audio thread and must be allocation-free too. No-op
135    // and zero-sized when the feature is off. This is the single site
136    // every format wrapper and the test driver route through.
137    let _rt = crate::rt::RtSection::enter();
138
139    while block_start < total {
140        // Find the next split-eligible event at or past
141        // `block_start + min_sub`. Anything before that coalesces
142        // into this sub-block's leading apply batch.
143        let coalesce_until = block_start.saturating_add(min_sub).min(total);
144        let next_split = find_next_split(events, param_infos, event_idx, coalesce_until);
145        let block_end = next_split.map_or(total, |(s, _)| s.min(total));
146
147        // Apply every event with sample_offset < block_end that's
148        // still pending. This is the deferred `set_plain` call that
149        // wrappers used to make eagerly at block start, plus
150        // transport-snapshot updates for `EventBody::Transport`.
151        // Advances `event_idx` past everything consumed.
152        apply_pending_events(events, params, transport, &mut event_idx, block_end);
153
154        // Rebase the in-window events into the scratch list with
155        // sub-block-relative `sample_offset`s. ParamChange entries
156        // get included so plugins that key off them (synths reading
157        // ParamMod, plugins logging) see them at the right time
158        // even though the wrapper has already applied them. Note
159        // events / SysEx get included with rebased offsets.
160        rebase_events_into(events, sub_event_scratch, block_start, block_end);
161
162        let mut sub_buffer = buffer.slice(block_start, block_end - block_start);
163        let sub_output_start = output_events.len();
164
165        let mut ctx = ProcessContext::new(
166            transport,
167            sample_rate,
168            block_end - block_start,
169            output_events,
170        )
171        .with_process_mode(process_mode);
172        if let Some(f) = params_fn {
173            ctx = ctx.with_params(f);
174        }
175        if let Some(f) = meters_fn {
176            ctx = ctx.with_meters(f);
177        }
178
179        last_status = plugin.process(&mut sub_buffer, sub_event_scratch, &mut ctx);
180
181        // Re-base any events the plugin pushed during this sub-block
182        // back into block-relative coordinates so the wrapper's
183        // per-event encode loop sees host-block-rate timings.
184        rebase_output_events(output_events, sub_output_start, block_start);
185
186        block_start = block_end;
187    }
188
189    last_status
190}
191
192/// Return the index of the next split-eligible event at sample
193/// `offset >= min_offset`, along with that sample offset.
194///
195/// "Split-eligible" = a `ParamChange` or mono `ParamMod` targeting a
196/// `ParamFlags::CHUNKED` parameter, or any `Transport` event. Note
197/// events (`NoteOn` / `NoteOff` / CC / etc.) don't split; they ride
198/// inside whichever sub-block they fall into via `rebase_events_into`.
199/// Polyphonic mod (`note_id != -1`) doesn't split either - it's a
200/// per-voice offset and subdividing the audio block doesn't help.
201fn find_next_split(
202    events: &EventList,
203    param_infos: &[ParamInfo],
204    from: usize,
205    min_offset: usize,
206) -> Option<(usize, usize)> {
207    for (i, ev) in events.iter().enumerate().skip(from) {
208        let offset = ev.sample_offset as usize;
209        if offset < min_offset {
210            continue;
211        }
212        if is_split_event(&ev.body, param_infos) {
213            return Some((offset, i));
214        }
215    }
216    None
217}
218
219fn is_split_event(body: &EventBody, param_infos: &[ParamInfo]) -> bool {
220    match body {
221        EventBody::ParamChange { id, .. }
222        | EventBody::ParamMod {
223            id, note_id: -1, ..
224        } => is_chunked(*id, param_infos),
225        EventBody::Transport(_) => true,
226        _ => false,
227    }
228}
229
230fn is_chunked(id: u32, param_infos: &[ParamInfo]) -> bool {
231    param_infos
232        .iter()
233        .find(|info| info.id == id)
234        .is_some_and(|info| info.flags.contains(ParamFlags::CHUNKED))
235}
236
237/// Walk `events` from `*event_idx` forward, applying every event with
238/// `sample_offset < block_end` to the param store / transport
239/// snapshot and advancing `*event_idx` past the consumed range.
240///
241/// `ParamChange` writes through to `params.set_plain`; `Transport`
242/// overwrites the per-block snapshot. Note events / `ParamMod` / `SysEx`
243/// are not "applied" - they ride in the rebased sub-event list for
244/// the plugin to process itself; this function just advances past
245/// them so the next split scan starts in the right place.
246fn apply_pending_events(
247    events: &EventList,
248    params: &dyn Params,
249    transport: &mut TransportInfo,
250    event_idx: &mut usize,
251    block_end: usize,
252) {
253    let mut i = *event_idx;
254    for ev in events.iter().skip(i) {
255        if (ev.sample_offset as usize) >= block_end {
256            break;
257        }
258        match ev.body {
259            EventBody::ParamChange { id, value } => {
260                params.set_plain(id, value);
261            }
262            EventBody::Transport(t) => {
263                *transport = t;
264            }
265            // Note events, ParamMod, SysEx: the plugin handles these
266            // via the rebased sub-event list. The apply pass only
267            // advances past them.
268            _ => {}
269        }
270        i += 1;
271    }
272    *event_idx = i;
273}
274
275/// Copy events in `[block_start, block_end)` into `scratch` with
276/// `sample_offset` rebased to sub-block-relative coordinates.
277///
278/// `clear()`s `scratch` first; the backing `Vec` capacity is
279/// preserved across calls so steady-state operation is
280/// allocation-free as long as the wrapper sized the scratch list to
281/// match its input list's capacity.
282///
283/// `SysEx` payloads are copied into the scratch's own byte pool (via
284/// `push_sysex`), so the scratch is self-contained. The plugin only
285/// ever receives the scratch, so `EventList::sysex_bytes` must resolve
286/// against it - copying the body verbatim would leave the rebased entry
287/// pointing at the empty scratch pool and panic on access. The scratch
288/// pool is pre-sized to `SYSEX_POOL_PREALLOC` (it's built with
289/// `EventList::with_capacity`), so the copy stays allocation-free.
290fn rebase_events_into(
291    events: &EventList,
292    scratch: &mut EventList,
293    block_start: usize,
294    block_end: usize,
295) {
296    scratch.clear();
297    for ev in events.iter() {
298        let off = ev.sample_offset as usize;
299        if off < block_start {
300            continue;
301        }
302        if off >= block_end {
303            break;
304        }
305        // Rebase the sample offset. The cast is bounded: `off -
306        // block_start < block_end - block_start <= u32::MAX in
307        // practice` (audio blocks cap at a few thousand samples).
308        #[allow(clippy::cast_possible_truncation)]
309        let rebased_offset = (off - block_start) as u32;
310        match ev.body {
311            // Re-copy the payload so the scratch carries its own pool
312            // entry; a pool-full drop matches the documented `SysEx`
313            // overflow behaviour and can't occur in practice (the
314            // scratch pool matches the source pool's size).
315            EventBody::SysEx { .. } => {
316                let _ = scratch.push_sysex_on_port(
317                    rebased_offset,
318                    ev.port,
319                    events.sysex_bytes(&ev.body),
320                );
321            }
322            body => scratch.push(Event::on_port(rebased_offset, ev.port, body)),
323        }
324    }
325}
326
327/// Shift the `sample_offset` of every output event the plugin
328/// pushed during the just-completed sub-block back into block-relative
329/// coordinates by adding `sub_block_start`.
330///
331/// Output events live in `output_events`; the plugin pushes them
332/// with sub-block-relative offsets (e.g. "MIDI out on sample 10 of
333/// the sub-block"). The wrapper's per-event host-encode loop expects
334/// host-block-rate timings, so shift here once per sub-block.
335fn rebase_output_events(output_events: &mut EventList, from: usize, sub_block_start: usize) {
336    #[allow(clippy::cast_possible_truncation)]
337    let shift = sub_block_start as u32;
338    if shift == 0 {
339        return;
340    }
341    let slice = output_events.events_mut();
342    for ev in slice.iter_mut().skip(from) {
343        ev.sample_offset = ev.sample_offset.saturating_add(shift);
344    }
345}
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350    use crate::events::EVENT_LIST_PREALLOC;
351    use truce_params::{ParamFlags, ParamInfo, ParamRange, ParamUnit, ParamValueKind};
352
353    fn info(id: u32, chunked: bool) -> ParamInfo {
354        let flags = if chunked {
355            ParamFlags::AUTOMATABLE | ParamFlags::CHUNKED
356        } else {
357            ParamFlags::AUTOMATABLE
358        };
359        ParamInfo {
360            id,
361            name: "p",
362            short_name: "p",
363            group: "",
364            range: ParamRange::Linear { min: 0.0, max: 1.0 },
365            default_plain: 0.0,
366            flags,
367            unit: ParamUnit::None,
368            kind: ParamValueKind::Float,
369            midi_map: None,
370            midi_channel: None,
371        }
372    }
373
374    #[test]
375    fn split_only_on_chunked_params() {
376        let infos = [info(0, true), info(1, false)];
377        let mut events = EventList::with_capacity(EVENT_LIST_PREALLOC);
378        events.push(Event::new(
379            100,
380            EventBody::ParamChange { id: 1, value: 0.5 },
381        ));
382        events.push(Event::new(
383            200,
384            EventBody::ParamChange { id: 0, value: 0.5 },
385        ));
386        // Non-chunked param at 100 doesn't split; chunked at 200 does.
387        let next = find_next_split(&events, &infos, 0, 0);
388        assert_eq!(next, Some((200, 1)));
389    }
390
391    #[test]
392    fn min_offset_skips_close_events() {
393        let infos = [info(0, true)];
394        let mut events = EventList::with_capacity(EVENT_LIST_PREALLOC);
395        events.push(Event::new(5, EventBody::ParamChange { id: 0, value: 0.5 }));
396        events.push(Event::new(50, EventBody::ParamChange { id: 0, value: 0.6 }));
397        // min_offset = 32: first event (offset 5) coalesces, second (50) splits.
398        let next = find_next_split(&events, &infos, 0, 32);
399        assert_eq!(next, Some((50, 1)));
400    }
401
402    #[test]
403    fn poly_mod_never_splits() {
404        let infos = [info(0, true)];
405        let mut events = EventList::with_capacity(EVENT_LIST_PREALLOC);
406        events.push(Event::new(
407            100,
408            EventBody::ParamMod {
409                id: 0,
410                note_id: 7,
411                value: 0.1,
412            },
413        ));
414        let next = find_next_split(&events, &infos, 0, 0);
415        assert_eq!(next, None);
416    }
417
418    #[test]
419    fn rebase_drops_out_of_window() {
420        let mut events = EventList::with_capacity(EVENT_LIST_PREALLOC);
421        events.push(Event::new(10, EventBody::ParamChange { id: 0, value: 0.1 }));
422        events.push(Event::new(50, EventBody::ParamChange { id: 0, value: 0.2 }));
423        events.push(Event::new(90, EventBody::ParamChange { id: 0, value: 0.3 }));
424        let mut scratch = EventList::with_capacity(EVENT_LIST_PREALLOC);
425        rebase_events_into(&events, &mut scratch, 40, 80);
426        let collected: Vec<u32> = scratch.iter().map(|e| e.sample_offset).collect();
427        // Only the offset-50 event is in [40, 80); rebased to 10.
428        assert_eq!(collected, vec![10]);
429    }
430
431    #[test]
432    fn rebase_preserves_midi_port() {
433        // The chunker splits the input list into sub-blocks; a
434        // multi-port plugin's per-event port must survive that copy.
435        let mut events = EventList::with_capacity(EVENT_LIST_PREALLOC);
436        events.push(Event::on_port(
437            20,
438            3,
439            EventBody::NoteOn {
440                group: 0,
441                channel: 0,
442                note: 60,
443                velocity: 100,
444            },
445        ));
446        let mut scratch = EventList::with_capacity(EVENT_LIST_PREALLOC);
447        rebase_events_into(&events, &mut scratch, 0, 64);
448        assert_eq!(scratch.iter().map(|e| e.port).collect::<Vec<_>>(), vec![3]);
449    }
450
451    #[test]
452    fn transport_always_splits() {
453        let infos: [ParamInfo; 0] = [];
454        let mut events = EventList::with_capacity(EVENT_LIST_PREALLOC);
455        events.push(Event::new(
456            100,
457            EventBody::Transport(TransportInfo::default()),
458        ));
459        let next = find_next_split(&events, &infos, 0, 0);
460        assert_eq!(next, Some((100, 0)));
461    }
462
463    #[test]
464    fn offset_zero_split_needs_min_offset_at_least_one() {
465        // The `min_subblock_samples.max(1)` clamp in `process_chunked`
466        // rests on this: with `min_offset == 0`, an event sitting
467        // exactly on `block_start` (offset 0 - a Transport every
468        // wrapper pushes) is returned as a split point, so `block_end
469        // == block_start` yields a zero-length sub-block and the loop
470        // never advances (hang). With `min_offset >= 1` that event
471        // coalesces, so `block_end` moves forward and the loop
472        // terminates.
473        let infos: [ParamInfo; 0] = [];
474        let mut events = EventList::with_capacity(EVENT_LIST_PREALLOC);
475        events.push(Event::new(
476            0,
477            EventBody::Transport(TransportInfo::default()),
478        ));
479        // Unclamped (0): the offset-0 event splits at 0 - the hang.
480        assert_eq!(find_next_split(&events, &infos, 0, 0), Some((0, 0)));
481        // Clamped (>= 1): coalesced, no split at the block start.
482        assert_eq!(find_next_split(&events, &infos, 0, 1), None);
483    }
484
485    #[test]
486    fn rebase_copies_sysex_payload_into_scratch() {
487        let mut events = EventList::with_capacity(EVENT_LIST_PREALLOC);
488        events.push_sysex(50, &[0x11, 0x22, 0x33, 0x44]).unwrap();
489        let mut scratch = EventList::with_capacity(EVENT_LIST_PREALLOC);
490        rebase_events_into(&events, &mut scratch, 40, 80);
491
492        let ev = scratch.iter().next().expect("sysex rebased into scratch");
493        assert_eq!(ev.sample_offset, 10); // 50 - 40
494        // Regression: the scratch used to carry the parent's pool
495        // indices against an empty pool, so this access panicked
496        // out-of-bounds. It now resolves against the scratch's own pool.
497        assert_eq!(scratch.sysex_bytes(&ev.body), &[0x11, 0x22, 0x33, 0x44]);
498    }
499}