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//!
17//! Design rationale and per-format coverage notes live in
18//! `truce-docs/docs/internal/parameter-dependent-chunking.md`.
19
20use truce_params::{ParamFlags, ParamInfo, Params};
21
22use crate::buffer::AudioBuffer;
23use crate::events::{Event, EventBody, EventList, TransportInfo};
24use crate::plugin::PluginRuntime;
25use crate::process::{ProcessContext, ProcessStatus};
26use crate::sample::Sample;
27
28/// Inputs to [`process_chunked`].
29///
30/// Bundled into a struct because the call has eight load-bearing
31/// references plus a couple of value fields and a positional argument
32/// list at that width is unreadable at the call site (every wrapper
33/// would invent its own helper). Construct one per `process()` call.
34pub struct ChunkedProcess<'a> {
35    /// Sorted, block-rate event stream from the host (param changes,
36    /// transport changes, MIDI). The chunker walks this once forward;
37    /// it does not mutate the list.
38    pub events: &'a EventList,
39    /// Per-instance scratch list pre-allocated to the same capacity
40    /// as `events`. Used to hold the per-sub-block rebased view of
41    /// `events`; `clear()`-ed at the start of every sub-block so the
42    /// backing `Vec` capacity is preserved across blocks. Wrappers
43    /// hold this alongside their input / output event lists.
44    pub sub_event_scratch: &'a mut EventList,
45    /// Initial transport snapshot for the block. Mutated in place
46    /// as the chunker walks past `EventBody::Transport` events; the
47    /// per-sub-block `ProcessContext` reads from this so the plugin
48    /// sees the right tempo / position for the sub-block it's in.
49    pub transport: &'a mut TransportInfo,
50    /// Host sample rate, plumbed through to each per-sub-block
51    /// `ProcessContext`.
52    pub sample_rate: f64,
53    /// Plugin's outbound event queue. The chunker re-bases outbound
54    /// events back to block-relative coordinates before the wrapper
55    /// hands them to the host: the plugin pushes events with
56    /// sub-block-relative offsets, the chunker shifts them by the
57    /// sub-block's start sample.
58    pub output_events: &'a mut EventList,
59    /// Optional read-side params closure plumbed through to each
60    /// per-sub-block `ProcessContext`. Same shape as
61    /// `ProcessContext::with_params`.
62    pub params_fn: Option<&'a dyn Fn(u32) -> f64>,
63    /// Optional meter-write closure plumbed through likewise.
64    pub meters_fn: Option<&'a dyn Fn(u32, f32)>,
65    /// Static param metadata - the chunker keys `is_chunked(id)`
66    /// off `ParamFlags::CHUNKED` here. Wrappers cache this once
67    /// when the plugin instantiates (via
68    /// [`Params::param_infos_static`]) and pass the same slice on
69    /// every block.
70    pub param_infos: &'a [ParamInfo],
71    /// Minimum sub-block size in samples. From
72    /// [`crate::info::AutomationConfig::min_subblock_samples`].
73    /// Events whose `sample_offset` falls within
74    /// `min_subblock_samples` of the current sub-block start are
75    /// coalesced into that sub-block's leading `apply_pending_events`
76    /// batch instead of triggering a split.
77    pub min_subblock_samples: u32,
78}
79
80/// Walk the audio block in sub-block chunks, calling
81/// `plugin.process()` once per chunk with the events that land in
82/// `[block_start, block_end)` rebased to sub-block-relative offsets.
83///
84/// Returns the `ProcessStatus` returned by the *last* sub-block; for
85/// `Tail(N)` the plugin's own clock is the authority, so propagating
86/// the last call's value is the cheapest correct rule.
87///
88/// Allocation-free: the rebased event list lives in
89/// `sub_event_scratch` (capacity preserved across calls) and the
90/// audio buffer sub-views are zero-copy via
91/// [`AudioBuffer::slice`].
92pub fn process_chunked<S, P>(
93    plugin: &mut P,
94    params: &dyn Params,
95    buffer: &mut AudioBuffer<S>,
96    args: ChunkedProcess<'_>,
97) -> ProcessStatus
98where
99    S: Sample,
100    P: PluginRuntime<Sample = S>,
101{
102    let ChunkedProcess {
103        events,
104        sub_event_scratch,
105        transport,
106        sample_rate,
107        output_events,
108        params_fn,
109        meters_fn,
110        param_infos,
111        min_subblock_samples,
112    } = args;
113
114    let total = buffer.num_samples();
115    let mut block_start = 0usize;
116    let mut event_idx = 0usize;
117    let mut last_status = ProcessStatus::Normal;
118    let min_sub = min_subblock_samples as usize;
119
120    while block_start < total {
121        // Find the next split-eligible event at or past
122        // `block_start + min_sub`. Anything before that coalesces
123        // into this sub-block's leading apply batch.
124        let coalesce_until = block_start.saturating_add(min_sub).min(total);
125        let next_split = find_next_split(events, param_infos, event_idx, coalesce_until);
126        let block_end = next_split.map_or(total, |(s, _)| s.min(total));
127
128        // Apply every event with sample_offset < block_end that's
129        // still pending. This is the deferred `set_plain` call that
130        // wrappers used to make eagerly at block start, plus
131        // transport-snapshot updates for `EventBody::Transport`.
132        // Advances `event_idx` past everything consumed.
133        apply_pending_events(events, params, transport, &mut event_idx, block_end);
134
135        // Rebase the in-window events into the scratch list with
136        // sub-block-relative `sample_offset`s. ParamChange entries
137        // get included so plugins that key off them (synths reading
138        // ParamMod, plugins logging) see them at the right time
139        // even though the wrapper has already applied them. Note
140        // events / SysEx get included with rebased offsets.
141        rebase_events_into(events, sub_event_scratch, block_start, block_end);
142
143        let mut sub_buffer = buffer.slice(block_start, block_end - block_start);
144        let sub_output_start = output_events.len();
145
146        let mut ctx = ProcessContext::new(
147            transport,
148            sample_rate,
149            block_end - block_start,
150            output_events,
151        );
152        if let Some(f) = params_fn {
153            ctx = ctx.with_params(f);
154        }
155        if let Some(f) = meters_fn {
156            ctx = ctx.with_meters(f);
157        }
158
159        last_status = plugin.process(&mut sub_buffer, sub_event_scratch, &mut ctx);
160
161        // Re-base any events the plugin pushed during this sub-block
162        // back into block-relative coordinates so the wrapper's
163        // per-event encode loop sees host-block-rate timings.
164        rebase_output_events(output_events, sub_output_start, block_start);
165
166        block_start = block_end;
167    }
168
169    last_status
170}
171
172/// Return the index of the next split-eligible event at sample
173/// `offset >= min_offset`, along with that sample offset.
174///
175/// "Split-eligible" = a `ParamChange` or mono `ParamMod` targeting a
176/// `ParamFlags::CHUNKED` parameter, or any `Transport` event. Note
177/// events (`NoteOn` / `NoteOff` / CC / etc.) don't split; they ride
178/// inside whichever sub-block they fall into via `rebase_events_into`.
179/// Polyphonic mod (`note_id != -1`) doesn't split either - it's a
180/// per-voice offset and subdividing the audio block doesn't help.
181fn find_next_split(
182    events: &EventList,
183    param_infos: &[ParamInfo],
184    from: usize,
185    min_offset: usize,
186) -> Option<(usize, usize)> {
187    for (i, ev) in events.iter().enumerate().skip(from) {
188        let offset = ev.sample_offset as usize;
189        if offset < min_offset {
190            continue;
191        }
192        if is_split_event(&ev.body, param_infos) {
193            return Some((offset, i));
194        }
195    }
196    None
197}
198
199fn is_split_event(body: &EventBody, param_infos: &[ParamInfo]) -> bool {
200    match body {
201        EventBody::ParamChange { id, .. }
202        | EventBody::ParamMod {
203            id, note_id: -1, ..
204        } => is_chunked(*id, param_infos),
205        EventBody::Transport(_) => true,
206        _ => false,
207    }
208}
209
210fn is_chunked(id: u32, param_infos: &[ParamInfo]) -> bool {
211    param_infos
212        .iter()
213        .find(|info| info.id == id)
214        .is_some_and(|info| info.flags.contains(ParamFlags::CHUNKED))
215}
216
217/// Walk `events` from `*event_idx` forward, applying every event with
218/// `sample_offset < block_end` to the param store / transport
219/// snapshot and advancing `*event_idx` past the consumed range.
220///
221/// `ParamChange` writes through to `params.set_plain`; `Transport`
222/// overwrites the per-block snapshot. Note events / `ParamMod` / `SysEx`
223/// are not "applied" - they ride in the rebased sub-event list for
224/// the plugin to process itself; this function just advances past
225/// them so the next split scan starts in the right place.
226fn apply_pending_events(
227    events: &EventList,
228    params: &dyn Params,
229    transport: &mut TransportInfo,
230    event_idx: &mut usize,
231    block_end: usize,
232) {
233    let mut i = *event_idx;
234    for ev in events.iter().skip(i) {
235        if (ev.sample_offset as usize) >= block_end {
236            break;
237        }
238        match ev.body {
239            EventBody::ParamChange { id, value } => {
240                params.set_plain(id, value);
241            }
242            EventBody::Transport(t) => {
243                *transport = t;
244            }
245            // Note events, ParamMod, SysEx: the plugin handles these
246            // via the rebased sub-event list. The apply pass only
247            // advances past them.
248            _ => {}
249        }
250        i += 1;
251    }
252    *event_idx = i;
253}
254
255/// Copy events in `[block_start, block_end)` into `scratch` with
256/// `sample_offset` rebased to sub-block-relative coordinates.
257///
258/// `clear()`s `scratch` first; the backing `Vec` capacity is
259/// preserved across calls so steady-state operation is
260/// allocation-free as long as the wrapper sized the scratch list to
261/// match its input list's capacity.
262///
263/// `SysEx` payload bytes are NOT copied - the rebased entry carries
264/// the same `pool_offset` / `len` indices and the wrapper holds the
265/// original `events` for the duration of this `process_chunked`
266/// call, so `EventList::sysex_bytes` continues to resolve correctly
267/// when the plugin queries via the parent list. Plugins that read
268/// `SysEx` payloads from the *scratch* list will get an empty slice
269/// because `scratch.sysex_pool` is empty; the contract here is "the
270/// scratch is a timing view, not a self-contained list" and the
271/// audio thread can resolve payloads only against `events`.
272fn rebase_events_into(
273    events: &EventList,
274    scratch: &mut EventList,
275    block_start: usize,
276    block_end: usize,
277) {
278    scratch.clear();
279    for ev in events.iter() {
280        let off = ev.sample_offset as usize;
281        if off < block_start {
282            continue;
283        }
284        if off >= block_end {
285            break;
286        }
287        // Rebase the sample offset and copy the body verbatim. The
288        // cast is bounded: `off - block_start < block_end -
289        // block_start <= u32::MAX in practice` (audio blocks cap at
290        // a few thousand samples).
291        #[allow(clippy::cast_possible_truncation)]
292        let rebased_offset = (off - block_start) as u32;
293        scratch.push(Event {
294            sample_offset: rebased_offset,
295            body: ev.body,
296        });
297    }
298}
299
300/// Shift the `sample_offset` of every output event the plugin
301/// pushed during the just-completed sub-block back into block-relative
302/// coordinates by adding `sub_block_start`.
303///
304/// Output events live in `output_events`; the plugin pushes them
305/// with sub-block-relative offsets (e.g. "MIDI out on sample 10 of
306/// the sub-block"). The wrapper's per-event host-encode loop expects
307/// host-block-rate timings, so shift here once per sub-block.
308fn rebase_output_events(output_events: &mut EventList, from: usize, sub_block_start: usize) {
309    #[allow(clippy::cast_possible_truncation)]
310    let shift = sub_block_start as u32;
311    if shift == 0 {
312        return;
313    }
314    let slice = output_events.events_mut();
315    for ev in slice.iter_mut().skip(from) {
316        ev.sample_offset = ev.sample_offset.saturating_add(shift);
317    }
318}
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323    use crate::events::EVENT_LIST_PREALLOC;
324    use truce_params::{ParamFlags, ParamInfo, ParamRange, ParamUnit, ParamValueKind};
325
326    fn info(id: u32, chunked: bool) -> ParamInfo {
327        let flags = if chunked {
328            ParamFlags::AUTOMATABLE | ParamFlags::CHUNKED
329        } else {
330            ParamFlags::AUTOMATABLE
331        };
332        ParamInfo {
333            id,
334            name: "p",
335            short_name: "p",
336            group: "",
337            range: ParamRange::Linear { min: 0.0, max: 1.0 },
338            default_plain: 0.0,
339            flags,
340            unit: ParamUnit::None,
341            kind: ParamValueKind::Float,
342        }
343    }
344
345    #[test]
346    fn split_only_on_chunked_params() {
347        let infos = [info(0, true), info(1, false)];
348        let mut events = EventList::with_capacity(EVENT_LIST_PREALLOC);
349        events.push(Event {
350            sample_offset: 100,
351            body: EventBody::ParamChange { id: 1, value: 0.5 },
352        });
353        events.push(Event {
354            sample_offset: 200,
355            body: EventBody::ParamChange { id: 0, value: 0.5 },
356        });
357        // Non-chunked param at 100 doesn't split; chunked at 200 does.
358        let next = find_next_split(&events, &infos, 0, 0);
359        assert_eq!(next, Some((200, 1)));
360    }
361
362    #[test]
363    fn min_offset_skips_close_events() {
364        let infos = [info(0, true)];
365        let mut events = EventList::with_capacity(EVENT_LIST_PREALLOC);
366        events.push(Event {
367            sample_offset: 5,
368            body: EventBody::ParamChange { id: 0, value: 0.5 },
369        });
370        events.push(Event {
371            sample_offset: 50,
372            body: EventBody::ParamChange { id: 0, value: 0.6 },
373        });
374        // min_offset = 32: first event (offset 5) coalesces, second (50) splits.
375        let next = find_next_split(&events, &infos, 0, 32);
376        assert_eq!(next, Some((50, 1)));
377    }
378
379    #[test]
380    fn poly_mod_never_splits() {
381        let infos = [info(0, true)];
382        let mut events = EventList::with_capacity(EVENT_LIST_PREALLOC);
383        events.push(Event {
384            sample_offset: 100,
385            body: EventBody::ParamMod {
386                id: 0,
387                note_id: 7,
388                value: 0.1,
389            },
390        });
391        let next = find_next_split(&events, &infos, 0, 0);
392        assert_eq!(next, None);
393    }
394
395    #[test]
396    fn rebase_drops_out_of_window() {
397        let mut events = EventList::with_capacity(EVENT_LIST_PREALLOC);
398        events.push(Event {
399            sample_offset: 10,
400            body: EventBody::ParamChange { id: 0, value: 0.1 },
401        });
402        events.push(Event {
403            sample_offset: 50,
404            body: EventBody::ParamChange { id: 0, value: 0.2 },
405        });
406        events.push(Event {
407            sample_offset: 90,
408            body: EventBody::ParamChange { id: 0, value: 0.3 },
409        });
410        let mut scratch = EventList::with_capacity(EVENT_LIST_PREALLOC);
411        rebase_events_into(&events, &mut scratch, 40, 80);
412        let collected: Vec<u32> = scratch.iter().map(|e| e.sample_offset).collect();
413        // Only the offset-50 event is in [40, 80); rebased to 10.
414        assert_eq!(collected, vec![10]);
415    }
416
417    #[test]
418    fn transport_always_splits() {
419        let infos: [ParamInfo; 0] = [];
420        let mut events = EventList::with_capacity(EVENT_LIST_PREALLOC);
421        events.push(Event {
422            sample_offset: 100,
423            body: EventBody::Transport(TransportInfo::default()),
424        });
425        let next = find_next_split(&events, &infos, 0, 0);
426        assert_eq!(next, Some((100, 0)));
427    }
428}