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 // Floor at 1: a sub-block can't be shorter than one sample, so 0
116 // and 1 both mean "split at every event". Without the floor, an
117 // event sitting exactly on `block_start` (e.g. a Transport at
118 // offset 0, which every wrapper pushes) yields `block_end ==
119 // block_start`, a zero-length sub-block, and a `while block_start
120 // < total` loop that never advances - hanging the audio thread.
121 let min_sub = (min_subblock_samples as usize).max(1);
122
123 // Paranoid allocation check (the `rt-paranoid` feature): one section
124 // spanning the whole per-host-block run, so it covers not just the
125 // plugin's `process` but the framework glue around it - event
126 // dispatch / rebasing, sub-block slicing, output re-basing - which
127 // all run on the audio thread and must be allocation-free too. No-op
128 // and zero-sized when the feature is off. This is the single site
129 // every format wrapper and the test driver route through.
130 let _rt = crate::rt::RtSection::enter();
131
132 while block_start < total {
133 // Find the next split-eligible event at or past
134 // `block_start + min_sub`. Anything before that coalesces
135 // into this sub-block's leading apply batch.
136 let coalesce_until = block_start.saturating_add(min_sub).min(total);
137 let next_split = find_next_split(events, param_infos, event_idx, coalesce_until);
138 let block_end = next_split.map_or(total, |(s, _)| s.min(total));
139
140 // Apply every event with sample_offset < block_end that's
141 // still pending. This is the deferred `set_plain` call that
142 // wrappers used to make eagerly at block start, plus
143 // transport-snapshot updates for `EventBody::Transport`.
144 // Advances `event_idx` past everything consumed.
145 apply_pending_events(events, params, transport, &mut event_idx, block_end);
146
147 // Rebase the in-window events into the scratch list with
148 // sub-block-relative `sample_offset`s. ParamChange entries
149 // get included so plugins that key off them (synths reading
150 // ParamMod, plugins logging) see them at the right time
151 // even though the wrapper has already applied them. Note
152 // events / SysEx get included with rebased offsets.
153 rebase_events_into(events, sub_event_scratch, block_start, block_end);
154
155 let mut sub_buffer = buffer.slice(block_start, block_end - block_start);
156 let sub_output_start = output_events.len();
157
158 let mut ctx = ProcessContext::new(
159 transport,
160 sample_rate,
161 block_end - block_start,
162 output_events,
163 );
164 if let Some(f) = params_fn {
165 ctx = ctx.with_params(f);
166 }
167 if let Some(f) = meters_fn {
168 ctx = ctx.with_meters(f);
169 }
170
171 last_status = plugin.process(&mut sub_buffer, sub_event_scratch, &mut ctx);
172
173 // Re-base any events the plugin pushed during this sub-block
174 // back into block-relative coordinates so the wrapper's
175 // per-event encode loop sees host-block-rate timings.
176 rebase_output_events(output_events, sub_output_start, block_start);
177
178 block_start = block_end;
179 }
180
181 last_status
182}
183
184/// Return the index of the next split-eligible event at sample
185/// `offset >= min_offset`, along with that sample offset.
186///
187/// "Split-eligible" = a `ParamChange` or mono `ParamMod` targeting a
188/// `ParamFlags::CHUNKED` parameter, or any `Transport` event. Note
189/// events (`NoteOn` / `NoteOff` / CC / etc.) don't split; they ride
190/// inside whichever sub-block they fall into via `rebase_events_into`.
191/// Polyphonic mod (`note_id != -1`) doesn't split either - it's a
192/// per-voice offset and subdividing the audio block doesn't help.
193fn find_next_split(
194 events: &EventList,
195 param_infos: &[ParamInfo],
196 from: usize,
197 min_offset: usize,
198) -> Option<(usize, usize)> {
199 for (i, ev) in events.iter().enumerate().skip(from) {
200 let offset = ev.sample_offset as usize;
201 if offset < min_offset {
202 continue;
203 }
204 if is_split_event(&ev.body, param_infos) {
205 return Some((offset, i));
206 }
207 }
208 None
209}
210
211fn is_split_event(body: &EventBody, param_infos: &[ParamInfo]) -> bool {
212 match body {
213 EventBody::ParamChange { id, .. }
214 | EventBody::ParamMod {
215 id, note_id: -1, ..
216 } => is_chunked(*id, param_infos),
217 EventBody::Transport(_) => true,
218 _ => false,
219 }
220}
221
222fn is_chunked(id: u32, param_infos: &[ParamInfo]) -> bool {
223 param_infos
224 .iter()
225 .find(|info| info.id == id)
226 .is_some_and(|info| info.flags.contains(ParamFlags::CHUNKED))
227}
228
229/// Walk `events` from `*event_idx` forward, applying every event with
230/// `sample_offset < block_end` to the param store / transport
231/// snapshot and advancing `*event_idx` past the consumed range.
232///
233/// `ParamChange` writes through to `params.set_plain`; `Transport`
234/// overwrites the per-block snapshot. Note events / `ParamMod` / `SysEx`
235/// are not "applied" - they ride in the rebased sub-event list for
236/// the plugin to process itself; this function just advances past
237/// them so the next split scan starts in the right place.
238fn apply_pending_events(
239 events: &EventList,
240 params: &dyn Params,
241 transport: &mut TransportInfo,
242 event_idx: &mut usize,
243 block_end: usize,
244) {
245 let mut i = *event_idx;
246 for ev in events.iter().skip(i) {
247 if (ev.sample_offset as usize) >= block_end {
248 break;
249 }
250 match ev.body {
251 EventBody::ParamChange { id, value } => {
252 params.set_plain(id, value);
253 }
254 EventBody::Transport(t) => {
255 *transport = t;
256 }
257 // Note events, ParamMod, SysEx: the plugin handles these
258 // via the rebased sub-event list. The apply pass only
259 // advances past them.
260 _ => {}
261 }
262 i += 1;
263 }
264 *event_idx = i;
265}
266
267/// Copy events in `[block_start, block_end)` into `scratch` with
268/// `sample_offset` rebased to sub-block-relative coordinates.
269///
270/// `clear()`s `scratch` first; the backing `Vec` capacity is
271/// preserved across calls so steady-state operation is
272/// allocation-free as long as the wrapper sized the scratch list to
273/// match its input list's capacity.
274///
275/// `SysEx` payloads are copied into the scratch's own byte pool (via
276/// `push_sysex`), so the scratch is self-contained. The plugin only
277/// ever receives the scratch, so `EventList::sysex_bytes` must resolve
278/// against it - copying the body verbatim would leave the rebased entry
279/// pointing at the empty scratch pool and panic on access. The scratch
280/// pool is pre-sized to `SYSEX_POOL_PREALLOC` (it's built with
281/// `EventList::with_capacity`), so the copy stays allocation-free.
282fn rebase_events_into(
283 events: &EventList,
284 scratch: &mut EventList,
285 block_start: usize,
286 block_end: usize,
287) {
288 scratch.clear();
289 for ev in events.iter() {
290 let off = ev.sample_offset as usize;
291 if off < block_start {
292 continue;
293 }
294 if off >= block_end {
295 break;
296 }
297 // Rebase the sample offset. The cast is bounded: `off -
298 // block_start < block_end - block_start <= u32::MAX in
299 // practice` (audio blocks cap at a few thousand samples).
300 #[allow(clippy::cast_possible_truncation)]
301 let rebased_offset = (off - block_start) as u32;
302 match ev.body {
303 // Re-copy the payload so the scratch carries its own pool
304 // entry; a pool-full drop matches the documented `SysEx`
305 // overflow behaviour and can't occur in practice (the
306 // scratch pool matches the source pool's size).
307 EventBody::SysEx { .. } => {
308 let _ = scratch.push_sysex_on_port(
309 rebased_offset,
310 ev.port,
311 events.sysex_bytes(&ev.body),
312 );
313 }
314 body => scratch.push(Event::on_port(rebased_offset, ev.port, body)),
315 }
316 }
317}
318
319/// Shift the `sample_offset` of every output event the plugin
320/// pushed during the just-completed sub-block back into block-relative
321/// coordinates by adding `sub_block_start`.
322///
323/// Output events live in `output_events`; the plugin pushes them
324/// with sub-block-relative offsets (e.g. "MIDI out on sample 10 of
325/// the sub-block"). The wrapper's per-event host-encode loop expects
326/// host-block-rate timings, so shift here once per sub-block.
327fn rebase_output_events(output_events: &mut EventList, from: usize, sub_block_start: usize) {
328 #[allow(clippy::cast_possible_truncation)]
329 let shift = sub_block_start as u32;
330 if shift == 0 {
331 return;
332 }
333 let slice = output_events.events_mut();
334 for ev in slice.iter_mut().skip(from) {
335 ev.sample_offset = ev.sample_offset.saturating_add(shift);
336 }
337}
338
339#[cfg(test)]
340mod tests {
341 use super::*;
342 use crate::events::EVENT_LIST_PREALLOC;
343 use truce_params::{ParamFlags, ParamInfo, ParamRange, ParamUnit, ParamValueKind};
344
345 fn info(id: u32, chunked: bool) -> ParamInfo {
346 let flags = if chunked {
347 ParamFlags::AUTOMATABLE | ParamFlags::CHUNKED
348 } else {
349 ParamFlags::AUTOMATABLE
350 };
351 ParamInfo {
352 id,
353 name: "p",
354 short_name: "p",
355 group: "",
356 range: ParamRange::Linear { min: 0.0, max: 1.0 },
357 default_plain: 0.0,
358 flags,
359 unit: ParamUnit::None,
360 kind: ParamValueKind::Float,
361 midi_map: None,
362 midi_channel: None,
363 }
364 }
365
366 #[test]
367 fn split_only_on_chunked_params() {
368 let infos = [info(0, true), info(1, false)];
369 let mut events = EventList::with_capacity(EVENT_LIST_PREALLOC);
370 events.push(Event::new(
371 100,
372 EventBody::ParamChange { id: 1, value: 0.5 },
373 ));
374 events.push(Event::new(
375 200,
376 EventBody::ParamChange { id: 0, value: 0.5 },
377 ));
378 // Non-chunked param at 100 doesn't split; chunked at 200 does.
379 let next = find_next_split(&events, &infos, 0, 0);
380 assert_eq!(next, Some((200, 1)));
381 }
382
383 #[test]
384 fn min_offset_skips_close_events() {
385 let infos = [info(0, true)];
386 let mut events = EventList::with_capacity(EVENT_LIST_PREALLOC);
387 events.push(Event::new(5, EventBody::ParamChange { id: 0, value: 0.5 }));
388 events.push(Event::new(50, EventBody::ParamChange { id: 0, value: 0.6 }));
389 // min_offset = 32: first event (offset 5) coalesces, second (50) splits.
390 let next = find_next_split(&events, &infos, 0, 32);
391 assert_eq!(next, Some((50, 1)));
392 }
393
394 #[test]
395 fn poly_mod_never_splits() {
396 let infos = [info(0, true)];
397 let mut events = EventList::with_capacity(EVENT_LIST_PREALLOC);
398 events.push(Event::new(
399 100,
400 EventBody::ParamMod {
401 id: 0,
402 note_id: 7,
403 value: 0.1,
404 },
405 ));
406 let next = find_next_split(&events, &infos, 0, 0);
407 assert_eq!(next, None);
408 }
409
410 #[test]
411 fn rebase_drops_out_of_window() {
412 let mut events = EventList::with_capacity(EVENT_LIST_PREALLOC);
413 events.push(Event::new(10, EventBody::ParamChange { id: 0, value: 0.1 }));
414 events.push(Event::new(50, EventBody::ParamChange { id: 0, value: 0.2 }));
415 events.push(Event::new(90, EventBody::ParamChange { id: 0, value: 0.3 }));
416 let mut scratch = EventList::with_capacity(EVENT_LIST_PREALLOC);
417 rebase_events_into(&events, &mut scratch, 40, 80);
418 let collected: Vec<u32> = scratch.iter().map(|e| e.sample_offset).collect();
419 // Only the offset-50 event is in [40, 80); rebased to 10.
420 assert_eq!(collected, vec![10]);
421 }
422
423 #[test]
424 fn rebase_preserves_midi_port() {
425 // The chunker splits the input list into sub-blocks; a
426 // multi-port plugin's per-event port must survive that copy.
427 let mut events = EventList::with_capacity(EVENT_LIST_PREALLOC);
428 events.push(Event::on_port(
429 20,
430 3,
431 EventBody::NoteOn {
432 group: 0,
433 channel: 0,
434 note: 60,
435 velocity: 100,
436 },
437 ));
438 let mut scratch = EventList::with_capacity(EVENT_LIST_PREALLOC);
439 rebase_events_into(&events, &mut scratch, 0, 64);
440 assert_eq!(scratch.iter().map(|e| e.port).collect::<Vec<_>>(), vec![3]);
441 }
442
443 #[test]
444 fn transport_always_splits() {
445 let infos: [ParamInfo; 0] = [];
446 let mut events = EventList::with_capacity(EVENT_LIST_PREALLOC);
447 events.push(Event::new(
448 100,
449 EventBody::Transport(TransportInfo::default()),
450 ));
451 let next = find_next_split(&events, &infos, 0, 0);
452 assert_eq!(next, Some((100, 0)));
453 }
454
455 #[test]
456 fn offset_zero_split_needs_min_offset_at_least_one() {
457 // The `min_subblock_samples.max(1)` clamp in `process_chunked`
458 // rests on this: with `min_offset == 0`, an event sitting
459 // exactly on `block_start` (offset 0 - a Transport every
460 // wrapper pushes) is returned as a split point, so `block_end
461 // == block_start` yields a zero-length sub-block and the loop
462 // never advances (hang). With `min_offset >= 1` that event
463 // coalesces, so `block_end` moves forward and the loop
464 // terminates.
465 let infos: [ParamInfo; 0] = [];
466 let mut events = EventList::with_capacity(EVENT_LIST_PREALLOC);
467 events.push(Event::new(
468 0,
469 EventBody::Transport(TransportInfo::default()),
470 ));
471 // Unclamped (0): the offset-0 event splits at 0 - the hang.
472 assert_eq!(find_next_split(&events, &infos, 0, 0), Some((0, 0)));
473 // Clamped (>= 1): coalesced, no split at the block start.
474 assert_eq!(find_next_split(&events, &infos, 0, 1), None);
475 }
476
477 #[test]
478 fn rebase_copies_sysex_payload_into_scratch() {
479 let mut events = EventList::with_capacity(EVENT_LIST_PREALLOC);
480 events.push_sysex(50, &[0x11, 0x22, 0x33, 0x44]).unwrap();
481 let mut scratch = EventList::with_capacity(EVENT_LIST_PREALLOC);
482 rebase_events_into(&events, &mut scratch, 40, 80);
483
484 let ev = scratch.iter().next().expect("sysex rebased into scratch");
485 assert_eq!(ev.sample_offset, 10); // 50 - 40
486 // Regression: the scratch used to carry the parent's pool
487 // indices against an empty pool, so this access panicked
488 // out-of-bounds. It now resolves against the scratch's own pool.
489 assert_eq!(scratch.sysex_bytes(&ev.body), &[0x11, 0x22, 0x33, 0x44]);
490 }
491}