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