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