Skip to main content

tono_core/runtime/
performance.rs

1//! performance — a running Program: sample-accurate transport, a bounded
2//! scheduled-command queue, stingers, click-free program swaps, metrics, and
3//! deterministic command capture/replay (ADR 0005).
4//!
5//! A [`Performance`] is the runtime half of `Song::compile` → `Program`: the
6//! host schedules commands at frames, beats, bars, markers, or sections, and
7//! the render executes them at exact frames in submission order — no Python,
8//! game loop, or OS timer ever needs to wake on a musical boundary. The
9//! callback path performs no allocation beyond first-use growth of the
10//! pre-sized scratch (see [`SCRATCH_FRAMES`]): a stinger is rendered at
11//! schedule time, so firing one mid-callback only mixes a pre-rendered
12//! buffer — no render, no allocation on the render path.
13//!
14//! This API is **stable** — frozen at 1.10.0-rc.1 (docs/api-tiers.md).
15
16use std::collections::VecDeque;
17use std::sync::Arc;
18
19use super::engine::Ramp;
20use super::{AudioSource, SCRATCH_FRAMES, StreamSource, Transport, TransportState, Tween};
21use crate::dsl::SoundDoc;
22use crate::program::Program;
23
24/// The command queue's capacity. A full queue rejects the new command (the
25/// caller decides what to drop) and counts it — the defined exhaustion
26/// behavior (ADR 0005).
27pub const COMMAND_QUEUE_CAP: usize = 4096;
28
29/// The program-swap crossfade length in frames (~21 ms at 48 kHz): long
30/// enough to be click-free, short enough to read as a cut at a bar line.
31const SWAP_FADE_FRAMES: usize = 1024;
32
33/// The master-gain ramp length in frames: gain rides are click-free without
34/// waiting a beat.
35const GAIN_RAMP_FRAMES: usize = 256;
36
37/// Where a command lands on the timeline, resolved to an exact frame at
38/// schedule time (the transport lives on the control side, so musical
39/// resolution never touches the render path).
40#[derive(Debug, Clone, PartialEq)]
41pub enum At {
42    /// The next frame.
43    Immediate,
44    /// An absolute frame.
45    Frame(u64),
46    /// An absolute beat (through the tempo map).
47    Beat(f64),
48    /// An absolute bar (through the meter map and pickup).
49    Bar(u32),
50    /// The next whole beat after the current position.
51    NextBeat,
52    /// The next bar line after the current position.
53    NextBar,
54    /// A named marker's position.
55    Marker(String),
56    /// A named section's first bar.
57    Section(String),
58}
59
60/// One schedulable runtime command.
61#[derive(Debug, Clone)]
62pub enum Command {
63    /// Start or resume the transport.
64    Play,
65    /// Hold the transport.
66    Pause,
67    /// Stop and rewind.
68    Stop,
69    /// Seek to a beat (the song source follows, deterministically).
70    SeekBeat(f64),
71    /// Seek to a bar.
72    SeekBar(u32),
73    /// Seek to a named section's first bar.
74    SeekSection(String),
75    /// Loop a bar range.
76    SetLoopBars(u32, u32),
77    /// Clear the loop.
78    ClearLoop,
79    /// Master gain (ramped, click-free).
80    SetGain(f32),
81    /// Swap to a different program (crossfaded; the new program starts from
82    /// its frame 0 with its own transport).
83    Swap(Arc<Program>),
84    /// Fire a one-shot over the song (already rendered — the render happened
85    /// at schedule time, never on the render path).
86    Stinger {
87        /// The pre-rendered interleaved stereo samples. Owning the buffer in
88        /// the command makes captures self-contained and releases it when no
89        /// queued, captured, or active stinger refers to it.
90        samples: Arc<[f32]>,
91        /// The stinger's gain.
92        gain: f32,
93    },
94}
95
96/// A command pinned to an exact frame with its submission order — the unit
97/// of deterministic replay.
98#[derive(Debug, Clone)]
99pub struct TimestampedCommand {
100    /// The frame the command executes at.
101    pub at_frame: u64,
102    /// Submission order — the deterministic tie-break for identical frames.
103    pub seq: u64,
104    /// The command.
105    pub command: Command,
106}
107
108/// Why a schedule call failed.
109#[derive(Debug, Clone, PartialEq, Eq)]
110pub enum PerformanceError {
111    /// The queue is full; the command was NOT accepted (and counted).
112    QueueFull,
113    /// The `At` named a marker or section the program doesn't have.
114    UnknownPosition(String),
115    /// The swap target failed to load (hash or version — the last valid
116    /// program keeps running).
117    BadProgram(String),
118}
119
120impl std::fmt::Display for PerformanceError {
121    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
122        match self {
123            PerformanceError::QueueFull => {
124                f.write_str("the command queue is full — the command was rejected and counted")
125            }
126            PerformanceError::UnknownPosition(name) => {
127                write!(f, "no marker or section named '{name}'")
128            }
129            PerformanceError::BadProgram(why) => write!(f, "program rejected: {why}"),
130        }
131    }
132}
133
134impl std::error::Error for PerformanceError {}
135
136/// A point-in-time health snapshot — read off the audio path; all counters
137/// advance on the render side without formatting or allocation.
138#[derive(Debug, Clone, Default, PartialEq, Eq)]
139pub struct PerformanceMetrics {
140    /// Frames rendered since start.
141    pub frames_rendered: u64,
142    /// Commands executed at their exact frames.
143    pub commands_executed: u64,
144    /// Commands rejected by a full queue.
145    pub commands_dropped: u64,
146    /// Deepest the queue has been.
147    pub queue_depth_max: usize,
148    /// Program swaps performed.
149    pub swaps: u64,
150    /// Stingers fired.
151    pub stingers_fired: u64,
152}
153
154/// The song's playback source: native streaming when the program is
155/// streamable (byte-identical to the bounce), the pre-rendered bounce
156/// otherwise. Seeks are deterministic: the stream rebuilds and fast-forwards
157/// (O(distance)); the buffer just moves its cursor.
158enum SongSource {
159    Stream {
160        doc: Box<SoundDoc>,
161        source: Box<StreamSource>,
162        inter: Vec<f32>,
163    },
164    Buffer {
165        left: Vec<f32>,
166        right: Vec<f32>,
167        pos: usize,
168    },
169}
170
171impl SongSource {
172    /// Build the source for a program: native streaming when possible, the
173    /// pre-rendered bounce otherwise.
174    fn build(program: &Program) -> SongSource {
175        if let Some(source) = StreamSource::from_doc(&program.doc) {
176            SongSource::Stream {
177                doc: Box::new(program.doc.clone()),
178                source: Box::new(source),
179                inter: vec![0.0; SCRATCH_FRAMES * 2],
180            }
181        } else {
182            let (left, right) = program.render_stereo();
183            SongSource::Buffer {
184                left,
185                right,
186                pos: 0,
187            }
188        }
189    }
190
191    fn fill(&mut self, left: &mut [f32], right: &mut [f32]) {
192        match self {
193            SongSource::Stream { source, inter, .. } => {
194                let n = left.len();
195                if inter.len() < n * 2 {
196                    inter.resize(n * 2, 0.0);
197                }
198                source.fill(&mut inter[..n * 2]);
199                for i in 0..n {
200                    left[i] = inter[i * 2];
201                    right[i] = inter[i * 2 + 1];
202                }
203            }
204            SongSource::Buffer {
205                left: l,
206                right: r,
207                pos,
208            } => {
209                let n = left.len();
210                let avail = l.len().saturating_sub(*pos);
211                let take = avail.min(n);
212                left[..take].copy_from_slice(&l[*pos..*pos + take]);
213                right[..take].copy_from_slice(&r[*pos..*pos + take]);
214                for i in take..n {
215                    left[i] = 0.0;
216                    right[i] = 0.0;
217                }
218                *pos += take;
219            }
220        }
221    }
222
223    fn seek(&mut self, frame: usize) {
224        match self {
225            SongSource::Stream { doc, source, .. } => {
226                **source = StreamSource::from_doc(doc).expect("was streamable");
227                let mut remaining = frame;
228                let mut block = [0.0f32; 2048];
229                while remaining > 0 {
230                    let take = remaining.min(1024);
231                    source.fill(&mut block[..take * 2]);
232                    remaining -= take;
233                }
234            }
235            SongSource::Buffer { pos, .. } => *pos = frame,
236        }
237    }
238}
239
240/// A sounding stinger: a buffer pre-rendered at schedule time with its play
241/// head and declick gain ramp. Mixed into the output without touching the
242/// allocator (capacity is reserved when the stinger is scheduled).
243struct ActiveStinger {
244    /// The interleaved stereo buffer, rendered at the program's sample rate.
245    buf: Arc<[f32]>,
246    /// Play head in samples (frames × 2).
247    pos: usize,
248    /// The declick ramp: 1.0 → the scheduled gain over 2 ms (the same shape
249    /// the engine applies to a fresh one-shot).
250    gain: Ramp,
251}
252
253/// A running program — see the module docs.
254pub struct Performance {
255    program: Arc<Program>,
256    song: SongSource,
257    transport: Transport,
258    queue: VecDeque<TimestampedCommand>,
259    /// Program sources pre-built at schedule time, keyed by the command's
260    /// seq — the build (a full probe render or bounce) never happens on the
261    /// audio path (same discipline as stinger pre-rendering).
262    swap_sources: std::collections::HashMap<u64, SongSource>,
263    clock: u64,
264    seq: u64,
265    master_gain: f32,
266    gain_ramp: Option<(f32, f32, usize)>, // (from, to, frames left)
267    fade: Option<(SongSource, usize)>,    // (outgoing source, frames left)
268    metrics: PerformanceMetrics,
269    capture: Option<Vec<TimestampedCommand>>,
270    sample_rate: u32,
271    /// Stingers sounding now (pre-reserved at schedule time).
272    stingers: Vec<ActiveStinger>,
273    scratch_l: Vec<f32>,
274    scratch_r: Vec<f32>,
275    scratch_fl: Vec<f32>,
276    scratch_fr: Vec<f32>,
277    scratch_e: Vec<f32>,
278}
279
280impl Performance {
281    /// Load a compiled program, stopped at frame 0. The engine's sample rate
282    /// is the program's.
283    pub fn new(program: Arc<Program>) -> Self {
284        let sr = program.meta.sample_rate;
285        let transport = Transport::for_program(&program.meta);
286        let song = SongSource::build(&program);
287        Performance {
288            program,
289            song,
290            transport,
291            queue: VecDeque::with_capacity(COMMAND_QUEUE_CAP.min(64)),
292            swap_sources: std::collections::HashMap::new(),
293            clock: 0,
294            seq: 0,
295            master_gain: 1.0,
296            gain_ramp: None,
297            fade: None,
298            metrics: PerformanceMetrics::default(),
299            capture: None,
300            sample_rate: sr,
301            stingers: Vec::new(),
302            scratch_l: vec![0.0; SCRATCH_FRAMES],
303            scratch_r: vec![0.0; SCRATCH_FRAMES],
304            scratch_fl: vec![0.0; SCRATCH_FRAMES],
305            scratch_fr: vec![0.0; SCRATCH_FRAMES],
306            scratch_e: vec![0.0; SCRATCH_FRAMES * 2],
307        }
308    }
309
310    /// The running program.
311    pub fn program(&self) -> &Arc<Program> {
312        &self.program
313    }
314
315    /// The transport (position, loops — seek helpers are also commands).
316    pub fn transport(&self) -> &Transport {
317        &self.transport
318    }
319
320    /// The current master gain.
321    pub fn master_gain(&self) -> f32 {
322        self.master_gain
323    }
324
325    /// A health snapshot (queue depth is sampled at call time).
326    pub fn metrics(&self) -> PerformanceMetrics {
327        self.metrics.clone()
328    }
329
330    /// The current queue depth.
331    pub fn queue_depth(&self) -> usize {
332        self.queue.len()
333    }
334
335    /// The command clock (frames rendered).
336    pub fn clock(&self) -> u64 {
337        self.clock
338    }
339
340    /// Resolve an [`At`] to an absolute frame, now.
341    fn resolve(&self, at: &At) -> Result<u64, PerformanceError> {
342        Ok(match at {
343            At::Immediate => self.clock,
344            At::Frame(f) => *f,
345            At::Beat(b) => self.deadline_for_transport_frame(self.transport.frame_at_beat(*b)),
346            At::Bar(b) => self.deadline_for_transport_frame(self.transport.frame_at_bar(*b)),
347            At::NextBeat => {
348                let pos = self.transport.position_beats();
349                self.deadline_for_transport_frame(self.transport.frame_at_beat(pos.floor() + 1.0))
350            }
351            At::NextBar => self.deadline_for_transport_frame(
352                self.transport
353                    .frame_at_bar(self.transport.position_bars().floor() as u32 + 1),
354            ),
355            At::Marker(name) => {
356                let marker = self
357                    .program
358                    .meta
359                    .markers
360                    .iter()
361                    .find(|m| &m.name == name)
362                    .ok_or_else(|| PerformanceError::UnknownPosition(name.clone()))?;
363                self.deadline_for_transport_frame(self.transport.frame_at_beat(marker.at.to_f64()))
364            }
365            At::Section(name) => {
366                let section = self
367                    .program
368                    .meta
369                    .sections
370                    .iter()
371                    .find(|s| &s.name == name)
372                    .ok_or_else(|| PerformanceError::UnknownPosition(name.clone()))?;
373                self.deadline_for_transport_frame(self.transport.frame_at_bar(section.bar))
374            }
375        })
376    }
377
378    /// Map a song-timeline frame to the render clock used by the queue. Seeks,
379    /// pauses, loops, and swaps let those clocks diverge, so musical positions
380    /// are distances from the current playhead rather than raw song frames.
381    fn deadline_for_transport_frame(&self, target: u64) -> u64 {
382        let position = self.transport.position_frames();
383        let distance = if target >= position {
384            target - position
385        } else if let Some((start, end)) = self.transport.loop_range()
386            && (start..end).contains(&target)
387            && position < end
388        {
389            (end - position).saturating_add(target - start)
390        } else {
391            0
392        };
393        self.clock.saturating_add(distance)
394    }
395
396    fn require_queue_room(&mut self) -> Result<(), PerformanceError> {
397        if self.queue.len() < COMMAND_QUEUE_CAP {
398            return Ok(());
399        }
400        self.metrics.commands_dropped += 1;
401        Err(PerformanceError::QueueFull)
402    }
403
404    fn enqueue(&mut self, command: Command, frame: u64) -> u64 {
405        debug_assert!(self.queue.len() < COMMAND_QUEUE_CAP);
406        self.seq += 1;
407        let stamped = TimestampedCommand {
408            at_frame: frame,
409            seq: self.seq,
410            command,
411        };
412        // Swap sources build HERE, at schedule time (a full probe render or
413        // bounce — O(duration)), never inside `fill` (see execute).
414        if let Command::Swap(program) = &stamped.command {
415            self.swap_sources
416                .insert(stamped.seq, SongSource::build(program));
417        }
418        let pos = self
419            .queue
420            .iter()
421            .position(|c| (c.at_frame, c.seq) > (stamped.at_frame, stamped.seq))
422            .unwrap_or(self.queue.len());
423        if let Some(capture) = &mut self.capture {
424            capture.push(stamped.clone());
425        }
426        self.queue.insert(pos, stamped);
427        self.metrics.queue_depth_max = self.metrics.queue_depth_max.max(self.queue.len());
428        self.seq
429    }
430
431    /// Schedule a command at `at`. Musical positions resolve to exact frames
432    /// now; identical frames execute in submission order. A full queue
433    /// rejects the command (counted in the metrics).
434    pub fn schedule(&mut self, command: Command, at: At) -> Result<u64, PerformanceError> {
435        let frame = self.resolve(&at)?;
436        self.require_queue_room()?;
437        Ok(self.enqueue(command, frame))
438    }
439
440    /// Schedule a stinger: render the doc NOW — the fire inside [`fill`](AudioSource::fill)
441    /// then only mixes a pre-rendered buffer, so the render path never
442    /// renders or allocates at fire time — and fire it at `at` with `gain`.
443    pub fn stinger(&mut self, doc: &SoundDoc, gain: f32, at: At) -> Result<u64, PerformanceError> {
444        let frame = self.resolve(&at)?;
445        self.require_queue_room()?;
446        // Stingers render at the program's rate: the runtime's one internal
447        // rate (resampling to the device belongs at the adapter).
448        let mut doc = doc.clone();
449        doc.sample_rate = self.sample_rate;
450        let (left, right) = crate::player::render_stereo(&doc);
451        let mut buf = Vec::with_capacity(left.len() * 2);
452        for i in 0..left.len() {
453            buf.push(left[i]);
454            buf.push(right[i]);
455        }
456        let samples: Arc<[f32]> = buf.into();
457        // Pre-reserve voice room for every queued stinger (including this
458        // one): the fire then never grows the Vec on the render path.
459        let pending = self
460            .queue
461            .iter()
462            .filter(|c| matches!(c.command, Command::Stinger { .. }))
463            .count()
464            + 1;
465        self.stingers.reserve(pending);
466        Ok(self.enqueue(Command::Stinger { samples, gain }, frame))
467    }
468
469    /// Schedule a program swap (crossfaded at `at`). The target must load
470    /// clean — a rejected target changes nothing (the last valid program
471    /// keeps running).
472    pub fn swap_to(&mut self, program: Arc<Program>, at: At) -> Result<u64, PerformanceError> {
473        if program.program_version > crate::program::PROGRAM_VERSION {
474            return Err(PerformanceError::BadProgram(format!(
475                "program version {} is newer than supported ({})",
476                program.program_version,
477                crate::program::PROGRAM_VERSION
478            )));
479        }
480        if program.hash != program.computed_hash() {
481            return Err(PerformanceError::BadProgram(
482                "hash mismatch — the program was edited after compilation".into(),
483            ));
484        }
485        if program.meta.sample_rate != self.sample_rate
486            || program.doc.sample_rate != self.sample_rate
487        {
488            return Err(PerformanceError::BadProgram(format!(
489                "sample rate mismatch — performance is {} Hz, program metadata is {} Hz, and its document is {} Hz",
490                self.sample_rate, program.meta.sample_rate, program.doc.sample_rate
491            )));
492        }
493        self.schedule(Command::Swap(program), at)
494    }
495
496    /// A quantized section transition: seek to the section's first bar at
497    /// the next bar line (or immediately with `At::Immediate`). The latest
498    /// transition wins: scheduling one while another is pending drops the
499    /// older pending seek (defined interruption behavior). An unknown
500    /// section is rejected now — never a silent no-op later.
501    pub fn transition_to_section(&mut self, name: &str, at: At) -> Result<u64, PerformanceError> {
502        if !self.program.meta.sections.iter().any(|s| s.name == name) {
503            return Err(PerformanceError::UnknownPosition(name.to_string()));
504        }
505        let frame = self.resolve(&at)?;
506        // Drop pending section seeks; executed ones are history.
507        let replaced: Vec<u64> = self
508            .queue
509            .iter()
510            .filter(|c| matches!(c.command, Command::SeekSection(_)))
511            .map(|c| c.seq)
512            .collect();
513        self.queue
514            .retain(|c| !matches!(c.command, Command::SeekSection(_)));
515        if let Some(capture) = &mut self.capture {
516            capture.retain(|c| !replaced.contains(&c.seq));
517        }
518        self.require_queue_room()?;
519        Ok(self.enqueue(Command::SeekSection(name.to_string()), frame))
520    }
521
522    /// Start recording scheduled commands for deterministic replay.
523    pub fn start_capture(&mut self) {
524        self.capture = Some(Vec::new());
525    }
526
527    /// Stop recording and take the captured commands.
528    pub fn stop_capture(&mut self) -> Vec<TimestampedCommand> {
529        self.capture.take().unwrap_or_default()
530    }
531
532    /// Replay captured commands at their recorded frames, in order.
533    pub fn replay(&mut self, commands: &[TimestampedCommand]) {
534        // Same pre-reservation as `stinger()`: replayed stingers must not
535        // grow the voice Vec at fire time either (control side here).
536        let stingers = commands
537            .iter()
538            .filter(|c| matches!(c.command, Command::Stinger { .. }))
539            .count();
540        if stingers > 0 {
541            self.stingers.reserve(stingers);
542        }
543        for c in commands {
544            // Bypass capture (a replay isn't a new session) and the queue cap
545            // is respected: a captured queue always fits again. Swap sources
546            // build here too — replay IS schedule time.
547            let frame = c.at_frame;
548            if self.queue.len() >= COMMAND_QUEUE_CAP {
549                self.metrics.commands_dropped += 1;
550                continue;
551            }
552            self.seq += 1;
553            if let Command::Swap(program) = &c.command {
554                self.swap_sources
555                    .insert(self.seq, SongSource::build(program));
556            }
557            self.queue.push_back(TimestampedCommand {
558                at_frame: frame,
559                seq: self.seq,
560                command: c.command.clone(),
561            });
562        }
563        self.queue
564            .make_contiguous()
565            .sort_by_key(|c| (c.at_frame, c.seq));
566    }
567
568    /// A state snapshot: transport position/state, master gain, loop range.
569    /// Applying it returns the performance to this exact control state.
570    pub fn snapshot(&self) -> PerformanceSnapshot {
571        PerformanceSnapshot {
572            position: self.transport.position_frames(),
573            state: self.transport.state(),
574            master_gain: self.master_gain,
575            loop_range: self.transport.loop_range(),
576        }
577    }
578
579    /// Restore a snapshot (deterministic: the song source re-seeks).
580    pub fn apply_snapshot(&mut self, snapshot: &PerformanceSnapshot) {
581        self.master_gain = snapshot.master_gain;
582        if let Some((start, end)) = snapshot.loop_range {
583            self.transport.set_loop_frames(start, end);
584        } else {
585            self.transport.clear_loop();
586        }
587        match snapshot.state {
588            TransportState::Stopped => self.transport.stop(),
589            TransportState::Playing => self.transport.play(),
590            TransportState::Paused => self.transport.pause(),
591        }
592        self.transport.seek_frame(snapshot.position);
593        self.song.seek(self.transport.position_frames() as usize);
594    }
595
596    /// Execute one command (at its exact frame).
597    fn execute(&mut self, stamped: TimestampedCommand) {
598        self.metrics.commands_executed += 1;
599        match stamped.command {
600            Command::Play => self.transport.play(),
601            Command::Pause => self.transport.pause(),
602            Command::Stop => {
603                self.transport.stop();
604                self.song.seek(0);
605            }
606            Command::SeekBeat(beat) => {
607                let frame = self.transport.frame_at_beat(beat);
608                self.transport.seek_frame(frame);
609                self.song.seek(frame as usize);
610            }
611            Command::SeekBar(bar) => {
612                let frame = self.transport.frame_at_bar(bar);
613                self.transport.seek_frame(frame);
614                self.song.seek(frame as usize);
615            }
616            Command::SeekSection(name) => {
617                if let Some(section) = self.program.meta.sections.iter().find(|s| s.name == name) {
618                    let frame = self.transport.frame_at_bar(section.bar);
619                    self.transport.seek_frame(frame);
620                    self.song.seek(frame as usize);
621                }
622            }
623            Command::SetLoopBars(start, end) => {
624                self.transport.set_loop_bars(start, end);
625            }
626            Command::ClearLoop => self.transport.clear_loop(),
627            Command::SetGain(gain) => {
628                let from = self.current_gain();
629                self.master_gain = gain.clamp(0.0, 2.0);
630                self.gain_ramp = Some((from, self.master_gain, GAIN_RAMP_FRAMES));
631            }
632            Command::Swap(program) => {
633                // The source was pre-built at schedule time (off the audio
634                // path); a foreign id (a hand-built command that never went
635                // through schedule) is inert, like a foreign stinger.
636                if let Some(new_source) = self.swap_sources.remove(&stamped.seq) {
637                    let outgoing = std::mem::replace(&mut self.song, new_source);
638                    self.fade = Some((outgoing, SWAP_FADE_FRAMES));
639                    self.transport = Transport::for_program(&program.meta);
640                    self.transport.play();
641                    self.program = program;
642                    self.metrics.swaps += 1;
643                }
644            }
645            Command::Stinger { samples, gain } => {
646                let mut ramp = Ramp::new(1.0);
647                ramp.set(gain.max(0.0), Tween::ms(2.0, self.sample_rate));
648                self.stingers.push(ActiveStinger {
649                    buf: samples,
650                    pos: 0,
651                    gain: ramp,
652                });
653                self.metrics.stingers_fired += 1;
654            }
655        }
656    }
657
658    /// The gain currently applied (mid-ramp aware).
659    fn current_gain(&self) -> f32 {
660        match &self.gain_ramp {
661            Some((from, to, left)) => {
662                from + (to - from) * (1.0 - *left as f32 / GAIN_RAMP_FRAMES as f32)
663            }
664            None => self.master_gain,
665        }
666    }
667
668    /// Render one slice of song + stingers + fade into the interleaved
669    /// output, advancing the transport. `out` is stereo-interleaved. All
670    /// buffers are the pre-sized scratch fields — no allocation here.
671    fn render_slice(&mut self, out: &mut [f32]) {
672        let frames = out.len() / 2;
673        if self.scratch_l.len() < frames {
674            self.scratch_l.resize(frames, 0.0);
675            self.scratch_r.resize(frames, 0.0);
676            self.scratch_fl.resize(frames, 0.0);
677            self.scratch_fr.resize(frames, 0.0);
678        }
679        if self.scratch_e.len() < out.len() {
680            self.scratch_e.resize(out.len(), 0.0);
681        }
682        if self.transport.is_playing() {
683            // Loop-aware: a slice spanning the loop end is rendered in
684            // chunks, so the post-wrap part of the slice comes from the
685            // loop start — not from past the loop end.
686            let mut done = 0usize;
687            while done < frames {
688                let chunk = match self.transport.loop_range() {
689                    Some((_, end)) => {
690                        let pos = self.transport.position_frames();
691                        (end.saturating_sub(pos) as usize).min(frames - done)
692                    }
693                    None => frames - done,
694                };
695                if chunk == 0 {
696                    // At the loop end: wrap before rendering further.
697                    let advance = self.transport.advance(0);
698                    if advance.wrapped
699                        && let Some((start, _)) = self.transport.loop_range()
700                    {
701                        self.song.seek(start as usize);
702                    }
703                    continue;
704                }
705                self.song.fill(
706                    &mut self.scratch_l[done..done + chunk],
707                    &mut self.scratch_r[done..done + chunk],
708                );
709                let advance = self.transport.advance(chunk as u64);
710                done += chunk;
711                if advance.wrapped
712                    && let Some((start, _)) = self.transport.loop_range()
713                {
714                    self.song.seek(start as usize);
715                }
716                if advance.finished {
717                    // Stopped at the program end: silence the rest.
718                    self.scratch_l[done..frames].fill(0.0);
719                    self.scratch_r[done..frames].fill(0.0);
720                    break;
721                }
722            }
723        } else {
724            self.scratch_l[..frames].fill(0.0);
725            self.scratch_r[..frames].fill(0.0);
726        }
727        // The outgoing program's tail during a swap crossfade (rendered here;
728        // its weight advances per frame in the mix loop below).
729        let fading = self.fade.is_some();
730        if fading {
731            let (outgoing, _) = self.fade.as_mut().expect("checked");
732            outgoing.fill(
733                &mut self.scratch_fl[..frames],
734                &mut self.scratch_fr[..frames],
735            );
736        }
737        // Stingers: pre-rendered buffers mixed straight in — no render, no
738        // allocation on this path (the render happened at schedule time).
739        {
740            let out_frames = frames;
741            let stereo = &mut self.scratch_e[..out.len()];
742            stereo.fill(0.0);
743            for s in &mut self.stingers {
744                let take = ((s.buf.len() - s.pos) / 2).min(out_frames);
745                for f in 0..take {
746                    let g = s.gain.tick();
747                    stereo[f * 2] += s.buf[s.pos + f * 2] * g;
748                    stereo[f * 2 + 1] += s.buf[s.pos + f * 2 + 1] * g;
749                }
750                s.pos += take * 2;
751            }
752            self.stingers.retain(|s| s.pos < s.buf.len());
753        }
754        // The gain ramp and the swap crossfade advance per FRAME, so their
755        // trajectories are identical under any block size — a slice's length
756        // must never shape the ramp (the transport and command frames are
757        // already blocking-invariant).
758        let mut fade_left = self.fade.as_ref().map(|(_, left)| *left);
759        for i in 0..frames {
760            let g = if let Some((from, to, left)) = self.gain_ramp {
761                let u = (GAIN_RAMP_FRAMES - left) as f32 / GAIN_RAMP_FRAMES as f32;
762                self.gain_ramp = (left > 1).then_some((from, to, left - 1));
763                from + (to - from) * u
764            } else {
765                self.master_gain
766            };
767            let mut left = self.scratch_l[i] * g;
768            let mut right = self.scratch_r[i] * g;
769            if let Some(fl) = fade_left.filter(|fl| *fl > 0) {
770                let fade_u = fl as f32 / SWAP_FADE_FRAMES as f32;
771                fade_left = Some(fl - 1);
772                left += self.scratch_fl[i] * fade_u;
773                right += self.scratch_fr[i] * fade_u;
774            }
775            out[i * 2] = left + self.scratch_e[i * 2];
776            out[i * 2 + 1] = right + self.scratch_e[i * 2 + 1];
777        }
778        // Commit the crossfade progress: done when its frames ran out.
779        if let Some((_, left)) = &mut self.fade {
780            match fade_left {
781                Some(fl) if fl > 0 => *left = fl,
782                _ => self.fade = None,
783            }
784        }
785        self.metrics.frames_rendered += frames as u64;
786    }
787}
788
789impl AudioSource for Performance {
790    /// Render interleaved stereo, executing due commands at their exact
791    /// frames (submission order on ties) — the block is split at command
792    /// boundaries so a command never lands early or late.
793    fn fill(&mut self, out: &mut [f32]) -> usize {
794        let frames = out.len() / 2;
795        let mut done = 0usize;
796        while done < frames {
797            let due = self
798                .queue
799                .front()
800                .filter(|c| c.at_frame <= self.clock + (frames - done) as u64)
801                .map(|c| c.at_frame);
802            match due {
803                Some(at) => {
804                    let at = (at.saturating_sub(self.clock) as usize).min(frames - done);
805                    if at > 0 {
806                        self.render_slice(&mut out[done * 2..(done + at) * 2]);
807                        self.clock += at as u64;
808                        done += at;
809                    }
810                    let stamped = self.queue.pop_front().expect("front was due");
811                    self.execute(stamped);
812                }
813                None => {
814                    self.render_slice(&mut out[done * 2..]);
815                    self.clock += (frames - done) as u64;
816                    done = frames;
817                }
818            }
819        }
820        frames
821    }
822}
823
824/// A point-in-time control snapshot (see [`Performance::snapshot`]).
825#[derive(Debug, Clone, PartialEq)]
826pub struct PerformanceSnapshot {
827    /// Transport position in frames.
828    pub position: u64,
829    /// Transport state.
830    pub state: TransportState,
831    /// Master gain.
832    pub master_gain: f32,
833    /// Loop range in frames.
834    pub loop_range: Option<(u64, u64)>,
835}
836
837#[cfg(test)]
838mod tests {
839    use super::*;
840    use crate::dsl::{Adsr, SeqWave};
841    use crate::song::{CompileOptions, Song, note};
842
843    fn amp() -> Adsr {
844        Adsr {
845            a: 0.005,
846            d: 0.1,
847            s: 0.8,
848            r: 0.2,
849            punch: 0.0,
850        }
851    }
852
853    fn demo_program() -> Arc<Program> {
854        let mut song = Song::new("perf-demo", 120.0);
855        song.add_track("bass", SeqWave::Bass, amp());
856        song.add_track("keys", SeqWave::Epiano, amp());
857        song.add_pattern("riff", 1, vec![note(0, 4, "C2"), note(8, 4, "G2")]);
858        song.add_pattern("stab", 1, vec![note(0, 2, "C4"), note(6, 2, "D#4")]);
859        song.arrange_repeat("bass", "riff", 0, 4);
860        song.arrange_repeat("keys", "stab", 0, 4);
861        song.sections.push(crate::song::Section {
862            name: "second".into(),
863            bar: 2,
864            bars: 2,
865        });
866        Arc::new(song.compile(&CompileOptions::default()).unwrap())
867    }
868
869    fn other_program() -> Arc<Program> {
870        let mut song = Song::new("perf-other", 100.0);
871        song.add_track("lead", SeqWave::Square, amp());
872        song.tracks[0].notes.push(note(0, 16, "A4"));
873        Arc::new(song.compile(&CompileOptions::default()).unwrap())
874    }
875
876    fn other_program_at(sample_rate: u32) -> Arc<Program> {
877        let mut song = Song::new("perf-other-rate", 100.0);
878        song.add_track("lead", SeqWave::Square, amp());
879        song.tracks[0].notes.push(note(0, 16, "A4"));
880        Arc::new(
881            song.compile(&CompileOptions {
882                sample_rate: Some(sample_rate),
883                ..CompileOptions::default()
884            })
885            .unwrap(),
886        )
887    }
888
889    fn stinger_doc() -> SoundDoc {
890        serde_json::from_str(
891            r#"{ "name": "blip", "duration": 0.2, "root": { "type": "mul", "inputs": [
892                { "type": "sawtooth", "freq": 880 },
893                { "type": "env", "a": 0.0, "d": 0.05, "s": 0.0, "r": 0.01 } ] } }"#,
894        )
895        .unwrap()
896    }
897
898    fn bounce_interleaved(program: &Program) -> Vec<f32> {
899        let (l, r) = program.render_stereo();
900        let mut out = Vec::with_capacity(l.len() * 2);
901        for i in 0..l.len() {
902            out.push(l[i]);
903            out.push(r[i]);
904        }
905        out
906    }
907
908    fn fill_all(p: &mut Performance, frames: usize, block: usize) -> Vec<f32> {
909        let mut got = Vec::with_capacity(frames * 2);
910        while got.len() < frames * 2 {
911            let take = block.min(frames - got.len() / 2);
912            let mut buf = vec![0.0f32; take * 2];
913            p.fill(&mut buf);
914            got.extend_from_slice(&buf);
915        }
916        got
917    }
918
919    fn bits(s: &[f32]) -> Vec<u32> {
920        s.iter().map(|x| x.to_bits()).collect()
921    }
922
923    #[test]
924    fn plays_byte_identical_to_the_bounce() {
925        let program = demo_program();
926        let expected = bounce_interleaved(&program);
927        let mut p = Performance::new(program.clone());
928        assert!(program.is_streamable(), "the demo streams natively");
929        p.schedule(Command::Play, At::Immediate).unwrap();
930        for block in [1usize, 7, 64, 333, 4096] {
931            let mut p = Performance::new(program.clone());
932            p.schedule(Command::Play, At::Immediate).unwrap();
933            let got = fill_all(&mut p, expected.len() / 2, block);
934            assert_eq!(bits(&got), bits(&expected), "block size {block} diverged");
935        }
936    }
937
938    #[test]
939    fn scheduled_gain_lands_on_the_exact_frame() {
940        let program = demo_program();
941        let expected = bounce_interleaved(&program);
942        let at = 10_000usize;
943        let mut p = Performance::new(program);
944        p.schedule(Command::Play, At::Immediate).unwrap();
945        p.schedule(Command::SetGain(0.0), At::Frame(at as u64))
946            .unwrap();
947        let got = fill_all(&mut p, expected.len() / 2, 512);
948        // Before the command: byte-identical to the bounce.
949        assert_eq!(bits(&got[..at * 2]), bits(&expected[..at * 2]));
950        // Well after it (past the ramp and its block-boundary wind-down):
951        // silence.
952        let tail_rms: f32 = {
953            let start = (at + 1_024) * 2;
954            let sum: f32 = got[start..].iter().map(|x| x * x).sum();
955            (sum / (got.len() - start) as f32).sqrt()
956        };
957        assert_eq!(tail_rms, 0.0, "gain 0 after the ramp");
958        assert_eq!(p.metrics().commands_executed, 2);
959    }
960
961    #[test]
962    fn seek_and_loop_reproduce_the_bounce_region() {
963        let program = demo_program();
964        let expected = bounce_interleaved(&program);
965        // Seek to bar 2 (beat 8, 4 s at 120 BPM — wait: 8 beats × 0.5 s = 4 s;
966        // the demo is longer than that? length is 4 bars + tail).
967        let bar2 = {
968            let t = Transport::for_program(&program.meta);
969            t.frame_at_bar(2) as usize
970        };
971        let mut p = Performance::new(program.clone());
972        p.schedule(Command::SeekBar(2), At::Immediate).unwrap();
973        p.schedule(Command::Play, At::Immediate).unwrap();
974        let got = fill_all(&mut p, 8_000, 512);
975        assert_eq!(bits(&got), bits(&expected[bar2 * 2..(bar2 + 8_000) * 2]));
976        // Loop bars 1..2: the wrapped second pass repeats the region.
977        let mut p = Performance::new(program);
978        p.schedule(Command::SetLoopBars(1, 2), At::Immediate)
979            .unwrap();
980        p.schedule(Command::SeekBar(1), At::Immediate).unwrap();
981        p.schedule(Command::Play, At::Immediate).unwrap();
982        let bar1 = {
983            let t = Transport::for_program(&p.program().meta);
984            t.frame_at_bar(1) as usize
985        };
986        let span = bar2 - bar1;
987        let got = fill_all(&mut p, span + 2_000, 256);
988        assert_eq!(
989            bits(&got[..2_000 * 2]),
990            bits(&expected[bar1 * 2..(bar1 + 2_000) * 2]),
991            "first pass"
992        );
993        assert_eq!(
994            bits(&got[span * 2..(span + 2_000) * 2]),
995            bits(&expected[bar1 * 2..(bar1 + 2_000) * 2]),
996            "the wrapped pass repeats the loop region exactly"
997        );
998    }
999
1000    #[test]
1001    fn section_transition_lands_on_the_section_bar() {
1002        let program = demo_program();
1003        let expected = bounce_interleaved(&program);
1004        let bar2 = {
1005            let t = Transport::for_program(&program.meta);
1006            t.frame_at_bar(2) as usize
1007        };
1008        let mut p = Performance::new(program);
1009        p.schedule(Command::Play, At::Immediate).unwrap();
1010        p.transition_to_section("second", At::Frame(5_000)).unwrap();
1011        let got = fill_all(&mut p, 12_000, 512);
1012        assert_eq!(
1013            bits(&got[5_000 * 2..7_000 * 2]),
1014            bits(&expected[bar2 * 2..(bar2 + 2_000) * 2]),
1015            "post-transition audio is the section's first bar"
1016        );
1017        // An unknown section is a structured error, not a silence.
1018        let mut p = Performance::new(demo_program());
1019        assert!(p.transition_to_section("nope", At::Immediate).is_err());
1020    }
1021
1022    #[test]
1023    fn stinger_fires_on_the_exact_beat() {
1024        let program = demo_program();
1025        let stinger = stinger_doc();
1026        // Beat 4 at 120 BPM = 2 s = frame 88 200 at 44 100.
1027        let at = {
1028            let t = Transport::for_program(&program.meta);
1029            t.frame_at_beat(4.0) as usize
1030        };
1031        let mut p = Performance::new(program.clone());
1032        p.schedule(Command::Play, At::Immediate).unwrap();
1033        p.stinger(&stinger, 1.0, At::Beat(4.0)).unwrap();
1034        let got = fill_all(&mut p, at + 4_000, 512);
1035        // The stinger's first sample lands exactly on the beat (the blip's
1036        // env has a 0 attack, so the onset is immediate and large).
1037        let before = &got[(at - 8) * 2..at * 2];
1038        let after = &got[at * 2..(at + 64) * 2];
1039        let bmax = before.iter().fold(0.0f32, |m, x| m.max(x.abs()));
1040        let amax = after.iter().fold(0.0f32, |m, x| m.max(x.abs()));
1041        assert!(amax > bmax, "the stinger onset lands on the beat");
1042        // The onset lands within the beat's first frames (the blip's env has
1043        // a 0 attack, so sample 0 is 0 and the saw is audible immediately).
1044        let mix_window = &got[at * 2..(at + 64) * 2];
1045        let bounce = bounce_interleaved(&program);
1046        let diverges = mix_window
1047            .iter()
1048            .zip(&bounce[at * 2..(at + 64) * 2])
1049            .any(|(a, b)| (a - b).abs() > 1e-6);
1050        assert!(diverges, "the mix at the beat carries the stinger");
1051        assert_eq!(p.metrics().stingers_fired, 1);
1052    }
1053
1054    #[test]
1055    fn swap_crossfades_deterministically() {
1056        let run = || {
1057            let mut p = Performance::new(demo_program());
1058            p.schedule(Command::Play, At::Immediate).unwrap();
1059            p.swap_to(other_program(), At::Frame(20_000)).unwrap();
1060            fill_all(&mut p, 60_000, 512)
1061        };
1062        let a = run();
1063        let b = run();
1064        assert_eq!(bits(&a), bits(&b), "the swap is deterministic");
1065        // Click-free: no outlier discontinuity at the swap point (the
1066        // crossfade blends the two programs).
1067        let window = &a[19_000 * 2..21_500 * 2];
1068        let max_step = window
1069            .windows(2)
1070            .map(|w| (w[1] - w[0]).abs())
1071            .fold(0.0f32, f32::max);
1072        assert!(max_step < 1.5, "no click at the swap ({max_step})");
1073        assert_eq!(Performance::new(demo_program()).metrics().swaps, 0);
1074    }
1075
1076    #[test]
1077    fn capture_and_replay_reproduces_the_take() {
1078        let program = demo_program();
1079        let scripted = |p: &mut Performance| {
1080            p.schedule(Command::Play, At::Immediate).unwrap();
1081            p.schedule(Command::SetGain(0.5), At::Frame(8_000)).unwrap();
1082            p.schedule(Command::SeekBar(1), At::Frame(12_000)).unwrap();
1083            p.schedule(Command::SetGain(1.0), At::Frame(16_000))
1084                .unwrap();
1085        };
1086        let mut p = Performance::new(program.clone());
1087        p.start_capture();
1088        scripted(&mut p);
1089        let captured = p.stop_capture();
1090        assert_eq!(captured.len(), 4);
1091        let take_a = fill_all(&mut p, 40_000, 512);
1092
1093        let mut q = Performance::new(program);
1094        q.replay(&captured);
1095        let take_b = fill_all(&mut q, 40_000, 512);
1096        assert_eq!(bits(&take_a), bits(&take_b), "replay reproduces the take");
1097    }
1098
1099    #[test]
1100    fn captured_stinger_replays_on_a_fresh_performance() {
1101        let program = demo_program();
1102        let mut p = Performance::new(program.clone());
1103        p.start_capture();
1104        p.schedule(Command::Play, At::Immediate).unwrap();
1105        p.stinger(&stinger_doc(), 0.75, At::Frame(1_000)).unwrap();
1106        let captured = p.stop_capture();
1107        let take_a = fill_all(&mut p, 12_000, 333);
1108
1109        let mut q = Performance::new(program);
1110        q.replay(&captured);
1111        let take_b = fill_all(&mut q, 12_000, 512);
1112        assert_eq!(bits(&take_a), bits(&take_b));
1113        assert_eq!(q.metrics().stingers_fired, 1);
1114    }
1115
1116    #[test]
1117    fn musical_deadlines_are_relative_to_the_current_transport() {
1118        let mut p = Performance::new(demo_program());
1119        p.schedule(Command::Play, At::Immediate).unwrap();
1120        fill_all(&mut p, 1_000, 512);
1121        p.schedule(Command::Pause, At::Immediate).unwrap();
1122        fill_all(&mut p, 5_000, 512);
1123        assert_eq!(p.clock(), 6_000);
1124        assert_eq!(p.transport().position_frames(), 1_000);
1125
1126        let next_beat = p.transport().frame_at_beat(1.0);
1127        p.schedule(Command::SetGain(0.5), At::NextBeat).unwrap();
1128        let queued = p.queue.back().unwrap();
1129        assert_eq!(queued.at_frame, 6_000 + next_beat - 1_000);
1130    }
1131
1132    #[test]
1133    fn swap_rejects_a_different_sample_rate_in_core() {
1134        let mut p = Performance::new(demo_program());
1135        let err = p
1136            .swap_to(other_program_at(48_000), At::Immediate)
1137            .unwrap_err();
1138        assert!(matches!(err, PerformanceError::BadProgram(_)));
1139        assert_eq!(p.program().meta.sample_rate, 44_100);
1140    }
1141
1142    #[test]
1143    fn a_full_queue_rejects_and_counts() {
1144        let mut p = Performance::new(demo_program());
1145        for i in 0..COMMAND_QUEUE_CAP {
1146            p.schedule(Command::SetGain(0.5), At::Frame(1_000_000 + i as u64))
1147                .unwrap();
1148        }
1149        let err = p.schedule(Command::Play, At::Immediate).unwrap_err();
1150        assert_eq!(err, PerformanceError::QueueFull);
1151        assert_eq!(p.metrics().commands_dropped, 1);
1152        assert_eq!(p.queue_depth(), COMMAND_QUEUE_CAP);
1153    }
1154
1155    #[test]
1156    fn snapshot_restores_the_control_state() {
1157        let mut p = Performance::new(demo_program());
1158        p.schedule(Command::Play, At::Immediate).unwrap();
1159        fill_all(&mut p, 10_000, 512);
1160        let snap = p.snapshot();
1161        let take_a = fill_all(&mut p, 4_000, 512);
1162        p.apply_snapshot(&snap);
1163        let take_b = fill_all(&mut p, 4_000, 512);
1164        assert_eq!(
1165            bits(&take_a),
1166            bits(&take_b),
1167            "the snapshot replays the position"
1168        );
1169
1170        let mut stopped = Performance::new(demo_program());
1171        stopped.transport.seek_frame(12_345);
1172        let snap = stopped.snapshot();
1173        stopped.apply_snapshot(&snap);
1174        assert_eq!(stopped.transport.state(), TransportState::Stopped);
1175        assert_eq!(stopped.transport.position_frames(), 12_345);
1176    }
1177
1178    #[test]
1179    fn gain_ride_and_swap_fade_are_block_size_invariant() {
1180        // The ramp and crossfade advance per frame, so any blocking yields
1181        // the same bytes (the threaded soak hammers this across threads).
1182        let run_gain = |block: usize| {
1183            let mut p = Performance::new(demo_program());
1184            p.schedule(Command::Play, At::Immediate).unwrap();
1185            p.schedule(Command::SetGain(0.5), At::Frame(1_000)).unwrap();
1186            p.schedule(Command::SetGain(1.0), At::Frame(1_500)).unwrap();
1187            fill_all(&mut p, 8_000, block)
1188        };
1189        for block in [1usize, 7, 333, 512, 4096] {
1190            assert_eq!(
1191                bits(&run_gain(block)),
1192                bits(&run_gain(512)),
1193                "gain ride diverged at block size {block}"
1194            );
1195        }
1196        let run_swap = |block: usize| {
1197            let mut p = Performance::new(demo_program());
1198            p.schedule(Command::Play, At::Immediate).unwrap();
1199            p.swap_to(other_program(), At::Frame(1_000)).unwrap();
1200            fill_all(&mut p, 8_000, block)
1201        };
1202        for block in [1usize, 7, 333, 512, 4096] {
1203            assert_eq!(
1204                bits(&run_swap(block)),
1205                bits(&run_swap(512)),
1206                "swap crossfade diverged at block size {block}"
1207            );
1208        }
1209    }
1210}