1use std::fmt;
2use std::str::FromStr;
3
4use serde::{Deserialize, Serialize};
5use thiserror::Error;
6
7pub const MIN_SCRATCH_CLICKS: u8 = 1;
8pub const MAX_SCRATCH_CLICKS: u8 = 8;
9pub const SCRATCH_GATE_ALGORITHM_VERSION: u32 = 10;
10pub const SCRATCH_GATE_SNAPSHOT_VERSION: u32 = 4;
11pub const SCRATCH_PERFORMANCE_SNAPSHOT_VERSION: u32 = 4;
12pub const MAXIMUM_SCRATCH_RECORD_RATE: f64 = 20.0;
13const MAXIMUM_SCRATCH_RECORD_RATE_ROUNDOFF: f64 = 1.0e-10;
14const MAXIMUM_FRAME_DELTA_SECONDS: f64 = 1.0 / 8_000.0;
15
16const MOTION_ONSET_RATE: f64 = 0.035;
17const REST_RATE: f64 = 0.018;
18const INTENT_REVERSAL_RATE: f64 = 0.055;
19const MAX_PREDICTED_REVERSAL_OUTGOING_RATE: f64 = 0.35;
20const ONSET_CONFIRM_SECONDS: f64 = 0.004;
21const REVERSAL_CONFIRM_SECONDS: f64 = 0.006;
22const REST_CONFIRM_SECONDS: f64 = 0.012;
23const MIN_LEARNED_SPAN: f64 = 0.04;
24const MAX_LEARNED_SPAN: f64 = 0.80;
25const SPAN_OBSERVATION_WEIGHTS: [f64; 4] = [1.0, 0.70, 0.55, 0.40];
26const OBSERVATIONS_FOR_FULL_SPAN_CONFIDENCE: u32 = 4;
27const DRUM_MAX_OPEN_SECONDS: f64 = 0.055;
28const DRUM_OPEN_SPAN_FRACTION: f64 = 0.18;
29const DRUM_ACCELERATION_FILTER_SECONDS: f64 = 0.008;
30const DRUM_ACCELERATION_TRIGGER: f64 = 6.0;
31const DRUM_TRIGGER_MIN_RATE: f64 = 0.14;
32const DRUM_REFRACTORY_SECONDS: f64 = 0.045;
33const STAB_OPEN_START_FRACTION: f64 = 0.04;
34const CHIRP_EDGE_MUTE_FRACTION: f64 = 0.04;
35const TRANSFORM_OPEN_FRACTION: f64 = 0.24;
36const FLARE_NOTCH_HALF_WIDTH: f64 = 0.07;
37const CRAB_BURST_START: f64 = 0.18;
38const CRAB_BURST_END: f64 = 0.72;
39const CRAB_PULSE_HALF_WIDTH: f64 = 0.035;
40const CRAB_PULSE_WIDTH_BUDGET: f64 = 0.22;
41const OPEN_SLOW_TIME_CONSTANT_SECONDS: f64 = 0.0015;
42const OPEN_FAST_TIME_CONSTANT_SECONDS: f64 = 0.00035;
43const CLOSE_SLOW_TIME_CONSTANT_SECONDS: f64 = 0.0020;
44const CLOSE_FAST_TIME_CONSTANT_SECONDS: f64 = 0.00045;
45
46#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
52#[repr(u8)]
53#[serde(rename_all = "lowercase")]
54pub enum ScratchPreset {
55 #[default]
56 Baby,
57 Stab,
58 Chirp,
59 Transform,
60 Flare,
61 Crab,
62 Orbit,
63 Drum,
64}
65
66impl ScratchPreset {
67 pub const ALL: [Self; 8] = [
68 Self::Baby,
69 Self::Stab,
70 Self::Chirp,
71 Self::Transform,
72 Self::Flare,
73 Self::Crab,
74 Self::Orbit,
75 Self::Drum,
76 ];
77
78 pub const fn as_str(self) -> &'static str {
79 match self {
80 Self::Baby => "baby",
81 Self::Stab => "stab",
82 Self::Chirp => "chirp",
83 Self::Transform => "transform",
84 Self::Flare => "flare",
85 Self::Crab => "crab",
86 Self::Orbit => "orbit",
87 Self::Drum => "drum",
88 }
89 }
90
91 pub const fn id(self) -> u8 {
92 self as u8
93 }
94
95 pub const fn from_id(id: u8) -> Option<Self> {
96 match id {
97 0 => Some(Self::Baby),
98 1 => Some(Self::Stab),
99 2 => Some(Self::Chirp),
100 3 => Some(Self::Transform),
101 4 => Some(Self::Flare),
102 5 => Some(Self::Crab),
103 6 => Some(Self::Orbit),
104 7 => Some(Self::Drum),
105 _ => None,
106 }
107 }
108
109 pub const fn default_clicks(self) -> u8 {
110 match self {
111 Self::Baby | Self::Stab | Self::Chirp | Self::Flare | Self::Drum => 1,
112 Self::Transform | Self::Orbit => 2,
113 Self::Crab => 4,
114 }
115 }
116
117 pub const fn uses_clicks(self) -> bool {
119 matches!(
120 self,
121 Self::Transform | Self::Flare | Self::Crab | Self::Orbit
122 )
123 }
124
125 pub const fn initial_stroke_span(self) -> f64 {
129 match self {
130 Self::Baby | Self::Stab | Self::Chirp => 0.22,
131 Self::Transform => 2.0 / 9.5,
132 Self::Flare => 1.0 / 6.4,
133 Self::Crab => 4.0 / 18.0,
134 Self::Orbit => 2.0 / 7.2,
135 Self::Drum => 0.12,
136 }
137 }
138
139 pub const fn descriptor(self) -> ScratchPresetDescriptor {
140 ScratchPresetDescriptor {
141 preset: self,
142 id: self.id(),
143 name: self.as_str(),
144 default_clicks: self.default_clicks(),
145 uses_clicks: self.uses_clicks(),
146 uses_manual_crossfader: matches!(self, Self::Baby),
147 }
148 }
149}
150
151#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
153#[serde(rename_all = "camelCase")]
154pub struct ScratchPresetDescriptor {
155 pub preset: ScratchPreset,
156 pub id: u8,
157 pub name: &'static str,
158 pub default_clicks: u8,
159 pub uses_clicks: bool,
160 pub uses_manual_crossfader: bool,
161}
162
163pub const SCRATCH_PRESET_CATALOG: [ScratchPresetDescriptor; 8] = [
164 ScratchPreset::Baby.descriptor(),
165 ScratchPreset::Stab.descriptor(),
166 ScratchPreset::Chirp.descriptor(),
167 ScratchPreset::Transform.descriptor(),
168 ScratchPreset::Flare.descriptor(),
169 ScratchPreset::Crab.descriptor(),
170 ScratchPreset::Orbit.descriptor(),
171 ScratchPreset::Drum.descriptor(),
172];
173
174impl fmt::Display for ScratchPreset {
175 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
176 formatter.write_str(self.as_str())
177 }
178}
179
180#[derive(Clone, Debug, Eq, PartialEq)]
181pub struct ScratchPresetParseError(String);
182
183impl fmt::Display for ScratchPresetParseError {
184 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
185 write!(formatter, "unknown scratch preset {:?}", self.0)
186 }
187}
188
189impl std::error::Error for ScratchPresetParseError {}
190
191impl FromStr for ScratchPreset {
192 type Err = ScratchPresetParseError;
193
194 fn from_str(value: &str) -> Result<Self, Self::Err> {
195 match value.trim().to_ascii_lowercase().as_str() {
196 "baby" => Ok(Self::Baby),
197 "stab" => Ok(Self::Stab),
198 "chirp" => Ok(Self::Chirp),
199 "transform" | "transformer" => Ok(Self::Transform),
200 "flare" => Ok(Self::Flare),
201 "crab" => Ok(Self::Crab),
202 "orbit" => Ok(Self::Orbit),
203 "drum" => Ok(Self::Drum),
204 _ => Err(ScratchPresetParseError(value.to_owned())),
205 }
206 }
207}
208
209#[derive(Clone, Copy, Debug, Eq, PartialEq)]
210enum MotionEvent {
211 None,
212 Onset,
213 Resume,
214 Reversal,
215 Rest,
216}
217
218#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
226#[serde(rename_all = "camelCase")]
227pub struct ScratchGate {
228 preset: ScratchPreset,
229 clicks: u8,
230 contact_active: bool,
231 direction: i8,
232 moving: bool,
233 pending_direction: i8,
234 pending_seconds: f64,
235 pending_stroke_travel: f64,
236 rest_seconds: f64,
237 stroke_travel: f64,
238 forward_learned_span: f64,
239 reverse_learned_span: f64,
240 forward_span_observations: u32,
241 reverse_span_observations: u32,
242 phase: f64,
243 gate: f64,
244 target: f64,
245 drum_filtered_rendered_speed: f64,
246 drum_filter_delta_seconds: f64,
247 drum_filter_alpha: f64,
248 drum_attack_armed: bool,
249 drum_open: bool,
250 drum_elapsed: f64,
251 drum_travel: f64,
252 drum_refractory: f64,
253}
254
255#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
257#[serde(rename_all = "camelCase")]
258pub struct ScratchGateSnapshot {
259 version: u32,
260 state: ScratchGate,
261}
262
263#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
265#[repr(u8)]
266#[serde(rename_all = "camelCase")]
267pub enum ScratchCrossfaderOwner {
268 Manual = 0,
269 AutomaticPreset = 1,
270}
271
272#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
279#[serde(rename_all = "camelCase")]
280pub struct ScratchPerformanceInput {
281 pub delta_seconds: f64,
282 pub hand_contact: bool,
283 pub intent_record_rate: f64,
284 pub rendered_record_rate: f64,
285 pub rendered_source_travel_seconds: f64,
286 pub manual_crossfader_gain: f64,
287}
288
289impl ScratchPerformanceInput {
290 fn validate(mut self) -> Result<Self, ScratchPerformanceError> {
291 if !self.delta_seconds.is_finite()
292 || self.delta_seconds <= 0.0
293 || self.delta_seconds > MAXIMUM_FRAME_DELTA_SECONDS
294 {
295 return Err(ScratchPerformanceError::InvalidInput {
296 field: "deltaSeconds",
297 });
298 }
299 for (field, value) in [
300 ("intentRecordRate", self.intent_record_rate),
301 ("renderedRecordRate", self.rendered_record_rate),
302 ] {
303 if !value.is_finite()
304 || value.abs() > MAXIMUM_SCRATCH_RECORD_RATE + MAXIMUM_SCRATCH_RECORD_RATE_ROUNDOFF
305 {
306 return Err(ScratchPerformanceError::InvalidInput { field });
307 }
308 }
309 self.intent_record_rate = self
310 .intent_record_rate
311 .clamp(-MAXIMUM_SCRATCH_RECORD_RATE, MAXIMUM_SCRATCH_RECORD_RATE);
312 self.rendered_record_rate = self
313 .rendered_record_rate
314 .clamp(-MAXIMUM_SCRATCH_RECORD_RATE, MAXIMUM_SCRATCH_RECORD_RATE);
315 let maximum_travel = MAXIMUM_SCRATCH_RECORD_RATE * self.delta_seconds;
316 let travel_roundoff = MAXIMUM_SCRATCH_RECORD_RATE_ROUNDOFF * self.delta_seconds
317 + 32.0 * f64::EPSILON * maximum_travel.max(self.rendered_source_travel_seconds.abs());
318 if !self.rendered_source_travel_seconds.is_finite()
319 || self.rendered_source_travel_seconds.abs() > maximum_travel + travel_roundoff
320 {
321 return Err(ScratchPerformanceError::InvalidInput {
322 field: "renderedSourceTravelSeconds",
323 });
324 }
325 self.rendered_source_travel_seconds = self
326 .rendered_source_travel_seconds
327 .clamp(-maximum_travel, maximum_travel);
328 if !self.manual_crossfader_gain.is_finite()
329 || !(0.0..=1.0).contains(&self.manual_crossfader_gain)
330 {
331 return Err(ScratchPerformanceError::InvalidInput {
332 field: "manualCrossfaderGain",
333 });
334 }
335 Ok(self)
336 }
337}
338
339#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
341#[serde(rename_all = "camelCase")]
342pub struct ScratchPerformanceOutput {
343 pub preset: ScratchPreset,
344 pub clicks: u8,
345 pub audible_gain: f64,
346 pub automatic_gate_gain: f64,
347 pub automatic_gate_target: f64,
348 pub owner: ScratchCrossfaderOwner,
349 pub direction: i8,
350 pub moving: bool,
351 pub phase: f64,
352 pub stroke_progress: f64,
353 pub span_prediction_confidence: f64,
354}
355
356#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
358#[serde(rename_all = "camelCase")]
359pub struct ScratchPerformanceSnapshot {
360 version: u32,
361 gate: ScratchGateSnapshot,
362 audible_gain: f64,
363}
364
365#[derive(Clone, Copy, Debug, PartialEq)]
367pub struct ScratchPerformance {
368 gate: ScratchGate,
369 audible_gain: f64,
370}
371
372impl Default for ScratchPerformance {
373 fn default() -> Self {
374 Self::new(ScratchPreset::Baby)
375 }
376}
377
378impl ScratchPerformance {
379 pub fn new(preset: ScratchPreset) -> Self {
380 Self {
381 gate: ScratchGate::new(preset),
382 audible_gain: 1.0,
383 }
384 }
385
386 pub const fn catalog() -> &'static [ScratchPresetDescriptor; 8] {
387 &SCRATCH_PRESET_CATALOG
388 }
389
390 pub fn preset(&self) -> ScratchPreset {
391 self.gate.preset()
392 }
393
394 pub fn set_preset(&mut self, preset: ScratchPreset) {
395 self.gate.set_preset(preset);
396 }
397
398 pub fn clicks(&self) -> u8 {
399 self.gate.clicks()
400 }
401
402 pub fn set_clicks(&mut self, clicks: u8) {
403 self.gate.set_clicks(clicks);
404 }
405
406 pub fn audible_gain(&self) -> f64 {
407 self.audible_gain
408 }
409
410 pub fn gate(&self) -> &ScratchGate {
411 &self.gate
412 }
413
414 pub fn output(&self) -> ScratchPerformanceOutput {
416 ScratchPerformanceOutput {
417 preset: self.gate.preset(),
418 clicks: self.gate.clicks(),
419 audible_gain: self.audible_gain,
420 automatic_gate_gain: self.gate.gate(),
421 automatic_gate_target: self.gate.target(),
422 owner: self.crossfader_owner(),
423 direction: self.gate.direction(),
424 moving: self.gate.moving(),
425 phase: self.gate.phase(),
426 stroke_progress: self.gate.stroke_progress(),
427 span_prediction_confidence: self.gate.span_prediction_confidence(),
428 }
429 }
430
431 pub fn process_frame(
433 &mut self,
434 input: ScratchPerformanceInput,
435 ) -> Result<ScratchPerformanceOutput, ScratchPerformanceError> {
436 let input = input.validate()?;
437 self.gate.process_with_travel(
438 input.delta_seconds,
439 input.hand_contact,
440 input.intent_record_rate,
441 input.rendered_record_rate,
442 input.rendered_source_travel_seconds,
443 );
444 let owner = self.crossfader_owner();
445 let target = match owner {
446 ScratchCrossfaderOwner::Manual => input.manual_crossfader_gain,
447 ScratchCrossfaderOwner::AutomaticPreset => self.gate.target(),
448 };
449 let speed = input.rendered_record_rate.abs();
450 advance_gain_envelope(&mut self.audible_gain, target, input.delta_seconds, speed);
451 Ok(self.output())
452 }
453
454 pub fn snapshot(&self) -> ScratchPerformanceSnapshot {
455 ScratchPerformanceSnapshot {
456 version: SCRATCH_PERFORMANCE_SNAPSHOT_VERSION,
457 gate: self.gate.snapshot(),
458 audible_gain: self.audible_gain,
459 }
460 }
461
462 pub fn restore(
464 &mut self,
465 snapshot: &ScratchPerformanceSnapshot,
466 ) -> Result<(), ScratchPerformanceError> {
467 if snapshot.version != SCRATCH_PERFORMANCE_SNAPSHOT_VERSION {
468 return Err(
469 ScratchPerformanceError::UnsupportedPerformanceSnapshotVersion {
470 version: snapshot.version,
471 },
472 );
473 }
474 if !snapshot.audible_gain.is_finite() || !(0.0..=1.0).contains(&snapshot.audible_gain) {
475 return Err(ScratchPerformanceError::InvalidSnapshot {
476 field: "audibleGain",
477 });
478 }
479 let mut restored_gate = self.gate.clone();
480 restored_gate.restore(&snapshot.gate)?;
481 self.gate = restored_gate;
482 self.audible_gain = snapshot.audible_gain;
483 Ok(())
484 }
485
486 fn crossfader_owner(&self) -> ScratchCrossfaderOwner {
487 if self.gate.preset() == ScratchPreset::Baby {
488 ScratchCrossfaderOwner::Manual
489 } else {
490 ScratchCrossfaderOwner::AutomaticPreset
491 }
492 }
493}
494
495#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)]
496pub enum ScratchPerformanceError {
497 #[error("scratch performance input field {field} is invalid")]
498 InvalidInput { field: &'static str },
499 #[error("scratch gate snapshot version {version} is unsupported")]
500 UnsupportedGateSnapshotVersion { version: u32 },
501 #[error("scratch performance snapshot version {version} is unsupported")]
502 UnsupportedPerformanceSnapshotVersion { version: u32 },
503 #[error("scratch snapshot field {field} is invalid")]
504 InvalidSnapshot { field: &'static str },
505}
506
507impl Default for ScratchGate {
508 fn default() -> Self {
509 Self::new(ScratchPreset::Baby)
510 }
511}
512
513impl ScratchGate {
514 pub fn new(preset: ScratchPreset) -> Self {
515 Self {
516 preset,
517 clicks: preset.default_clicks(),
518 contact_active: false,
519 direction: 0,
520 moving: false,
521 pending_direction: 0,
522 pending_seconds: 0.0,
523 pending_stroke_travel: 0.0,
524 rest_seconds: 0.0,
525 stroke_travel: 0.0,
526 forward_learned_span: preset.initial_stroke_span(),
527 reverse_learned_span: preset.initial_stroke_span(),
528 forward_span_observations: 0,
529 reverse_span_observations: 0,
530 phase: 0.0,
531 gate: 1.0,
532 target: 1.0,
533 drum_filtered_rendered_speed: 0.0,
534 drum_filter_delta_seconds: 0.0,
535 drum_filter_alpha: 1.0,
536 drum_attack_armed: false,
537 drum_open: false,
538 drum_elapsed: 0.0,
539 drum_travel: 0.0,
540 drum_refractory: DRUM_REFRACTORY_SECONDS,
541 }
542 }
543
544 pub fn preset(&self) -> ScratchPreset {
545 self.preset
546 }
547
548 pub fn set_preset(&mut self, preset: ScratchPreset) {
549 if preset != self.preset {
550 self.forward_learned_span = preset.initial_stroke_span();
551 self.reverse_learned_span = preset.initial_stroke_span();
552 self.forward_span_observations = 0;
553 self.reverse_span_observations = 0;
554 }
555 self.preset = preset;
556 self.clicks = preset.default_clicks();
557 self.reset_phrase();
558 self.target = 1.0;
559 }
560
561 pub fn clicks(&self) -> u8 {
562 self.clicks
563 }
564
565 pub fn set_clicks(&mut self, clicks: u8) {
566 self.clicks = clicks.clamp(MIN_SCRATCH_CLICKS, MAX_SCRATCH_CLICKS);
567 self.update_phase();
568 }
569
570 pub fn algorithm_version(&self) -> u32 {
571 SCRATCH_GATE_ALGORITHM_VERSION
572 }
573
574 pub fn set_algorithm_version(&mut self, _version: u32) {}
578
579 pub fn reset_for_replay(&mut self) {
582 let preset = self.preset;
583 let clicks = self.clicks;
584 *self = Self::new(preset);
585 self.set_clicks(clicks);
586 }
587
588 pub fn gate(&self) -> f64 {
589 self.gate
590 }
591
592 pub fn target(&self) -> f64 {
593 self.target
594 }
595
596 pub fn direction(&self) -> i8 {
597 self.direction
598 }
599
600 pub fn moving(&self) -> bool {
601 self.moving
602 }
603
604 pub fn phase(&self) -> f64 {
605 self.phase
606 }
607
608 pub fn stroke_progress(&self) -> f64 {
609 (self.stroke_travel / self.learned_span().max(MIN_LEARNED_SPAN)).clamp(0.0, 1.0)
610 }
611
612 fn repeating_phrase_progress(&self) -> f64 {
613 (self.stroke_travel / self.learned_span().max(MIN_LEARNED_SPAN)).rem_euclid(1.0)
614 }
615
616 pub fn learned_span(&self) -> f64 {
620 self.learned_span_for_direction(self.direction)
621 }
622
623 pub fn learned_span_for_direction(&self, direction: i8) -> f64 {
625 if direction < 0 {
626 self.reverse_learned_span
627 } else {
628 self.forward_learned_span
629 }
630 }
631
632 pub fn span_prediction_confidence(&self) -> f64 {
636 self.span_prediction_confidence_for_direction(self.direction)
637 }
638
639 pub fn span_prediction_confidence_for_direction(&self, direction: i8) -> f64 {
643 let observations = if direction < 0 {
644 self.reverse_span_observations
645 } else {
646 self.forward_span_observations
647 };
648 f64::from(observations.min(OBSERVATIONS_FOR_FULL_SPAN_CONFIDENCE))
649 / f64::from(OBSERVATIONS_FOR_FULL_SPAN_CONFIDENCE)
650 }
651
652 pub fn snapshot(&self) -> ScratchGateSnapshot {
653 ScratchGateSnapshot {
654 version: SCRATCH_GATE_SNAPSHOT_VERSION,
655 state: self.clone(),
656 }
657 }
658
659 pub fn restore(
661 &mut self,
662 snapshot: &ScratchGateSnapshot,
663 ) -> Result<(), ScratchPerformanceError> {
664 if snapshot.version != SCRATCH_GATE_SNAPSHOT_VERSION {
665 return Err(ScratchPerformanceError::UnsupportedGateSnapshotVersion {
666 version: snapshot.version,
667 });
668 }
669 snapshot.state.validate_snapshot_state()?;
670 self.clone_from(&snapshot.state);
671 Ok(())
672 }
673
674 pub fn release(&mut self) {
677 if self.contact_active {
678 self.reset_phrase();
679 }
680 self.contact_active = false;
681 self.target = 1.0;
682 }
683
684 pub fn process(
686 &mut self,
687 delta_seconds: f64,
688 hand_contact: bool,
689 intent_rate: f64,
690 rendered_rate: f64,
691 ) -> f64 {
692 let dt = finite_nonnegative(delta_seconds).min(0.1);
693 let intent_rate = finite_or_zero(intent_rate)
694 .clamp(-MAXIMUM_SCRATCH_RECORD_RATE, MAXIMUM_SCRATCH_RECORD_RATE);
695 let rendered_rate = finite_or_zero(rendered_rate)
696 .clamp(-MAXIMUM_SCRATCH_RECORD_RATE, MAXIMUM_SCRATCH_RECORD_RATE);
697 self.process_with_travel(
698 dt,
699 hand_contact,
700 intent_rate,
701 rendered_rate,
702 rendered_rate * dt,
703 )
704 }
705
706 fn process_with_travel(
707 &mut self,
708 dt: f64,
709 hand_contact: bool,
710 intent_rate: f64,
711 rendered_rate: f64,
712 rendered_source_travel_seconds: f64,
713 ) -> f64 {
714 self.drum_refractory = (self.drum_refractory + dt).min(DRUM_REFRACTORY_SECONDS);
715 if !hand_contact {
716 self.release();
717 self.advance_envelope(dt, intent_rate, rendered_rate);
718 return self.gate;
719 }
720
721 if !self.contact_active {
722 self.contact_active = true;
723 self.reset_phrase();
724 }
725
726 let completed_stroke_direction = self.direction;
727 let (event, confirmed_travel) = self.update_motion(
728 dt,
729 intent_rate,
730 rendered_rate,
731 rendered_source_travel_seconds,
732 );
733 if event == MotionEvent::Reversal {
734 self.learn_completed_stroke(completed_stroke_direction);
735 self.stroke_travel = confirmed_travel;
736 self.phase = 0.0;
737 } else if matches!(event, MotionEvent::Onset | MotionEvent::Resume) {
738 self.stroke_travel += confirmed_travel;
739 }
740
741 let rendered_stroke_travel =
742 if self.moving && sign(rendered_source_travel_seconds) == self.direction {
743 rendered_source_travel_seconds.abs()
744 } else {
745 0.0
746 };
747 if self.moving {
748 let current_frame_is_already_confirmed = matches!(
749 event,
750 MotionEvent::Onset | MotionEvent::Resume | MotionEvent::Reversal
751 );
752 if !current_frame_is_already_confirmed {
753 self.stroke_travel += rendered_stroke_travel;
754 }
755 self.update_phase();
756 }
757
758 if self.preset == ScratchPreset::Drum {
759 let acceleration = self.filter_drum_acceleration(dt, rendered_rate);
760 self.update_drum(
761 event,
762 dt,
763 rendered_rate,
764 rendered_stroke_travel,
765 intent_rate,
766 acceleration,
767 );
768 }
769 self.target = self.compute_target(intent_rate, rendered_rate);
770 self.advance_envelope(dt, intent_rate, rendered_rate);
771 self.gate
772 }
773
774 fn reset_phrase(&mut self) {
775 self.direction = 0;
776 self.moving = false;
777 self.pending_direction = 0;
778 self.pending_seconds = 0.0;
779 self.pending_stroke_travel = 0.0;
780 self.rest_seconds = 0.0;
781 self.stroke_travel = 0.0;
782 self.phase = 0.0;
783 self.drum_filtered_rendered_speed = 0.0;
784 self.drum_attack_armed = false;
785 self.drum_open = false;
786 self.drum_elapsed = 0.0;
787 self.drum_travel = 0.0;
788 self.drum_refractory = DRUM_REFRACTORY_SECONDS;
789 }
790
791 fn validate_snapshot_state(&self) -> Result<(), ScratchPerformanceError> {
792 if !(MIN_SCRATCH_CLICKS..=MAX_SCRATCH_CLICKS).contains(&self.clicks) {
793 return invalid_snapshot("clicks");
794 }
795 if ![-1, 0, 1].contains(&self.direction) {
796 return invalid_snapshot("direction");
797 }
798 if ![-1, 0, 1].contains(&self.pending_direction) {
799 return invalid_snapshot("pendingDirection");
800 }
801 if self.moving && self.direction == 0 {
802 return invalid_snapshot("moving");
803 }
804 if self.pending_direction == 0
805 && (self.pending_seconds != 0.0 || self.pending_stroke_travel != 0.0)
806 {
807 return invalid_snapshot("pendingDirection");
808 }
809 if !self.contact_active
810 && (self.direction != 0
811 || self.moving
812 || self.pending_direction != 0
813 || self.stroke_travel != 0.0)
814 {
815 return invalid_snapshot("contactActive");
816 }
817 for (field, value) in [
818 ("pendingSeconds", self.pending_seconds),
819 ("pendingStrokeTravel", self.pending_stroke_travel),
820 ("restSeconds", self.rest_seconds),
821 ("strokeTravel", self.stroke_travel),
822 ("drumFilterDeltaSeconds", self.drum_filter_delta_seconds),
823 ("drumElapsed", self.drum_elapsed),
824 ("drumTravel", self.drum_travel),
825 ("drumRefractory", self.drum_refractory),
826 ] {
827 if !value.is_finite() || value < 0.0 {
828 return invalid_snapshot(field);
829 }
830 }
831 for (field, value) in [
832 ("forwardLearnedSpan", self.forward_learned_span),
833 ("reverseLearnedSpan", self.reverse_learned_span),
834 ] {
835 if !value.is_finite() || !(MIN_LEARNED_SPAN..=MAX_LEARNED_SPAN).contains(&value) {
836 return invalid_snapshot(field);
837 }
838 }
839 for (field, value) in [
840 ("phase", self.phase),
841 ("gate", self.gate),
842 ("target", self.target),
843 ("drumFilterAlpha", self.drum_filter_alpha),
844 ] {
845 if !value.is_finite() || !(0.0..=1.0).contains(&value) {
846 return invalid_snapshot(field);
847 }
848 }
849 if !self.drum_filtered_rendered_speed.is_finite()
850 || !(0.0..=MAXIMUM_SCRATCH_RECORD_RATE).contains(&self.drum_filtered_rendered_speed)
851 {
852 return invalid_snapshot("drumFilteredRenderedSpeed");
853 }
854 if self.drum_filter_delta_seconds > 0.1 {
855 return invalid_snapshot("drumFilterDeltaSeconds");
856 }
857 if self.drum_refractory > DRUM_REFRACTORY_SECONDS {
858 return invalid_snapshot("drumRefractory");
859 }
860 if self.drum_open && self.drum_elapsed >= DRUM_MAX_OPEN_SECONDS {
861 return invalid_snapshot("drumElapsed");
862 }
863 Ok(())
864 }
865
866 fn update_motion(
867 &mut self,
868 dt: f64,
869 intent_rate: f64,
870 rendered_rate: f64,
871 rendered_source_travel_seconds: f64,
872 ) -> (MotionEvent, f64) {
873 let both_at_rest = intent_rate.abs() <= REST_RATE && rendered_rate.abs() <= REST_RATE;
874 if both_at_rest {
875 self.rest_seconds += dt;
876 self.clear_pending_direction();
877 if self.moving && self.rest_seconds >= REST_CONFIRM_SECONDS {
878 self.moving = false;
879 return (MotionEvent::Rest, 0.0);
880 }
881 return (MotionEvent::None, 0.0);
882 }
883 self.rest_seconds = 0.0;
884
885 let rendered_direction = if rendered_rate.abs() >= MOTION_ONSET_RATE {
886 sign(rendered_rate)
887 } else {
888 0
889 };
890 let candidate = if self.direction != 0
891 && rendered_direction != 0
892 && rendered_direction != self.direction
893 {
894 rendered_direction
895 } else {
896 self.direction_candidate(intent_rate, rendered_rate)
897 };
898 if candidate == 0 {
899 self.clear_pending_direction();
900 return (MotionEvent::None, 0.0);
901 }
902
903 if self.moving && candidate == self.direction {
904 self.clear_pending_direction();
905 return (MotionEvent::None, 0.0);
906 }
907
908 let confirmation_seconds = if self.direction != 0 && candidate != self.direction {
909 REVERSAL_CONFIRM_SECONDS
910 } else {
911 ONSET_CONFIRM_SECONDS
912 };
913 if self.pending_direction == candidate {
914 self.pending_seconds += dt;
915 } else {
916 self.pending_direction = candidate;
917 self.pending_seconds = dt;
918 self.pending_stroke_travel = 0.0;
919 }
920 if sign(rendered_source_travel_seconds) == candidate {
921 self.pending_stroke_travel += rendered_source_travel_seconds.abs();
922 }
923 let physical_motion_confirms_direction =
924 sign(rendered_rate) == candidate && rendered_rate.abs() >= MOTION_ONSET_RATE;
925 let reverses_confirmed_direction = self.direction != 0 && candidate != self.direction;
926 if reverses_confirmed_direction && !physical_motion_confirms_direction {
927 return (MotionEvent::None, 0.0);
928 }
929 if !physical_motion_confirms_direction
930 && (self.pending_seconds < confirmation_seconds
931 || !predicted_direction_is_physically_plausible(candidate, rendered_rate))
932 {
933 return (MotionEvent::None, 0.0);
934 }
935
936 let confirmed_travel = self.pending_stroke_travel;
937 self.clear_pending_direction();
938 let event = if self.direction == 0 {
939 self.direction = candidate;
940 self.moving = true;
941 MotionEvent::Onset
942 } else if candidate != self.direction {
943 self.direction = candidate;
944 self.moving = true;
945 MotionEvent::Reversal
946 } else {
947 self.moving = true;
948 MotionEvent::Resume
949 };
950 (event, confirmed_travel)
951 }
952
953 fn direction_candidate(&self, intent_rate: f64, rendered_rate: f64) -> i8 {
954 let intent_threshold =
955 if self.direction != 0 && sign(intent_rate) != 0 && sign(intent_rate) != self.direction
956 {
957 INTENT_REVERSAL_RATE
958 } else {
959 MOTION_ONSET_RATE
960 };
961 if intent_rate.abs() >= intent_threshold {
962 return sign(intent_rate);
963 }
964 if rendered_rate.abs() >= MOTION_ONSET_RATE {
965 return sign(rendered_rate);
966 }
967 0
968 }
969
970 fn clear_pending_direction(&mut self) {
971 self.pending_direction = 0;
972 self.pending_seconds = 0.0;
973 self.pending_stroke_travel = 0.0;
974 }
975
976 fn learn_completed_stroke(&mut self, completed_direction: i8) {
977 if self.stroke_travel <= 0.0 || ![-1, 1].contains(&completed_direction) {
978 return;
979 }
980 let observed = self.stroke_travel.clamp(MIN_LEARNED_SPAN, MAX_LEARNED_SPAN);
981 let (learned_span, observations) = if completed_direction < 0 {
982 (
983 &mut self.reverse_learned_span,
984 &mut self.reverse_span_observations,
985 )
986 } else {
987 (
988 &mut self.forward_learned_span,
989 &mut self.forward_span_observations,
990 )
991 };
992 let observed_weight = SPAN_OBSERVATION_WEIGHTS
993 [(*observations).min(SPAN_OBSERVATION_WEIGHTS.len() as u32 - 1) as usize];
994 *learned_span = lerp(*learned_span, observed, observed_weight)
995 .clamp(MIN_LEARNED_SPAN, MAX_LEARNED_SPAN);
996 *observations = observations.saturating_add(1);
997 }
998
999 fn update_phase(&mut self) {
1000 let progress = self.stroke_progress();
1001 if !self.preset.uses_clicks() {
1002 self.phase = progress;
1003 return;
1004 }
1005 self.phase = (self.repeating_phrase_progress() * f64::from(self.clicks)).rem_euclid(1.0);
1006 }
1007
1008 fn update_drum(
1009 &mut self,
1010 event: MotionEvent,
1011 dt: f64,
1012 rendered_rate: f64,
1013 rendered_stroke_travel: f64,
1014 intent_rate: f64,
1015 acceleration: f64,
1016 ) {
1017 let requested_speed = intent_rate.abs().max(rendered_rate.abs());
1018 if matches!(event, MotionEvent::Onset | MotionEvent::Reversal)
1019 && requested_speed >= DRUM_TRIGGER_MIN_RATE
1020 {
1021 self.drum_attack_armed = true;
1022 } else if event == MotionEvent::Rest {
1023 self.drum_attack_armed = false;
1024 }
1025
1026 let rendered_motion_matches = self.moving
1027 && sign(rendered_rate) == self.direction
1028 && rendered_rate.abs() >= DRUM_TRIGGER_MIN_RATE;
1029 if self.drum_attack_armed
1030 && rendered_motion_matches
1031 && self.drum_refractory >= DRUM_REFRACTORY_SECONDS
1032 {
1033 self.trigger_drum();
1034 self.drum_attack_armed = false;
1035 }
1036
1037 let acceleration_trigger = rendered_motion_matches
1038 && rendered_rate.abs() >= DRUM_TRIGGER_MIN_RATE
1039 && acceleration >= DRUM_ACCELERATION_TRIGGER
1040 && self.drum_refractory >= DRUM_REFRACTORY_SECONDS;
1041 if acceleration_trigger {
1042 self.trigger_drum();
1043 }
1044
1045 if self.drum_open {
1046 self.drum_elapsed += dt;
1047 self.drum_travel += rendered_stroke_travel;
1048 if self.drum_elapsed >= DRUM_MAX_OPEN_SECONDS
1049 || self.drum_travel >= self.learned_span() * DRUM_OPEN_SPAN_FRACTION
1050 {
1051 self.drum_open = false;
1052 }
1053 }
1054 }
1055
1056 fn filter_drum_acceleration(&mut self, dt: f64, rendered_rate: f64) -> f64 {
1057 if dt <= 0.0 {
1058 return 0.0;
1059 }
1060 if dt != self.drum_filter_delta_seconds {
1063 self.drum_filter_delta_seconds = dt;
1064 self.drum_filter_alpha = 1.0 - (-dt / DRUM_ACCELERATION_FILTER_SECONDS).exp();
1065 }
1066 let previous = self.drum_filtered_rendered_speed;
1067 self.drum_filtered_rendered_speed +=
1068 (rendered_rate.abs() - previous) * self.drum_filter_alpha;
1069 ((self.drum_filtered_rendered_speed - previous) / dt).max(0.0)
1070 }
1071
1072 fn trigger_drum(&mut self) {
1073 self.drum_open = true;
1074 self.drum_elapsed = 0.0;
1075 self.drum_travel = 0.0;
1076 self.drum_refractory = 0.0;
1077 }
1078
1079 fn compute_target(&self, intent_rate: f64, rendered_rate: f64) -> f64 {
1080 let reversal_is_pending = self.pending_direction != 0
1081 && self.direction != 0
1082 && self.pending_direction != self.direction;
1083 let record_is_at_rest = intent_rate.abs() <= REST_RATE && rendered_rate.abs() <= REST_RATE;
1084 match self.preset {
1085 ScratchPreset::Baby => 1.0,
1086 ScratchPreset::Stab => f64::from(
1087 self.moving
1088 && self.direction > 0
1089 && !reversal_is_pending
1090 && !record_is_at_rest
1091 && self.stroke_progress() >= STAB_OPEN_START_FRACTION,
1092 ),
1093 ScratchPreset::Chirp => {
1094 if !self.moving || reversal_is_pending || record_is_at_rest {
1095 0.0
1096 } else {
1097 f64::from(self.stroke_progress() >= CHIRP_EDGE_MUTE_FRACTION)
1098 }
1099 }
1100 ScratchPreset::Transform => {
1101 f64::from(self.moving && self.phase < TRANSFORM_OPEN_FRACTION)
1102 }
1103 ScratchPreset::Flare => {
1104 if !self.moving || self.direction < 0 {
1105 1.0
1106 } else {
1107 f64::from(distance_from_midpoint(self.phase) >= FLARE_NOTCH_HALF_WIDTH)
1108 }
1109 }
1110 ScratchPreset::Crab => f64::from(
1111 self.moving && crab_pulse_is_open(self.repeating_phrase_progress(), self.clicks),
1112 ),
1113 ScratchPreset::Orbit => {
1114 if !self.moving {
1115 1.0
1116 } else {
1117 f64::from(distance_from_midpoint(self.phase) >= FLARE_NOTCH_HALF_WIDTH)
1118 }
1119 }
1120 ScratchPreset::Drum => f64::from(self.drum_open),
1121 }
1122 }
1123
1124 fn advance_envelope(&mut self, dt: f64, _intent_rate: f64, rendered_rate: f64) {
1125 if dt <= 0.0 {
1126 return;
1127 }
1128 let speed = rendered_rate.abs();
1129 let confidence = smoothstep(MOTION_ONSET_RATE, 2.0, speed);
1130 let time_constant = if self.target >= self.gate {
1131 lerp(
1132 OPEN_SLOW_TIME_CONSTANT_SECONDS,
1133 OPEN_FAST_TIME_CONSTANT_SECONDS,
1134 confidence,
1135 )
1136 } else {
1137 lerp(
1138 CLOSE_SLOW_TIME_CONSTANT_SECONDS,
1139 CLOSE_FAST_TIME_CONSTANT_SECONDS,
1140 confidence,
1141 )
1142 };
1143 let alpha = 1.0 - (-dt / time_constant).exp();
1144 self.gate = (self.gate + (self.target - self.gate) * alpha).clamp(0.0, 1.0);
1145 }
1146}
1147
1148fn finite_or_zero(value: f64) -> f64 {
1149 if value.is_finite() {
1150 value
1151 } else {
1152 0.0
1153 }
1154}
1155
1156fn finite_nonnegative(value: f64) -> f64 {
1157 if value.is_finite() && value > 0.0 {
1158 value
1159 } else {
1160 0.0
1161 }
1162}
1163
1164fn invalid_snapshot<T>(field: &'static str) -> Result<T, ScratchPerformanceError> {
1165 Err(ScratchPerformanceError::InvalidSnapshot { field })
1166}
1167
1168fn advance_gain_envelope(gain: &mut f64, target: f64, dt: f64, speed: f64) {
1169 let confidence = smoothstep(MOTION_ONSET_RATE, 2.0, speed);
1170 let time_constant = if target >= *gain {
1171 lerp(
1172 OPEN_SLOW_TIME_CONSTANT_SECONDS,
1173 OPEN_FAST_TIME_CONSTANT_SECONDS,
1174 confidence,
1175 )
1176 } else {
1177 lerp(
1178 CLOSE_SLOW_TIME_CONSTANT_SECONDS,
1179 CLOSE_FAST_TIME_CONSTANT_SECONDS,
1180 confidence,
1181 )
1182 };
1183 let alpha = 1.0 - (-dt / time_constant).exp();
1184 *gain = (*gain + (target - *gain) * alpha).clamp(0.0, 1.0);
1185}
1186
1187fn sign(value: f64) -> i8 {
1188 if value > 0.0 {
1189 1
1190 } else if value < 0.0 {
1191 -1
1192 } else {
1193 0
1194 }
1195}
1196
1197fn predicted_direction_is_physically_plausible(candidate: i8, rendered_rate: f64) -> bool {
1198 sign(rendered_rate) == candidate || rendered_rate.abs() <= MAX_PREDICTED_REVERSAL_OUTGOING_RATE
1199}
1200
1201fn smoothstep(edge0: f64, edge1: f64, value: f64) -> f64 {
1202 let t = ((value - edge0) / (edge1 - edge0)).clamp(0.0, 1.0);
1203 t * t * (3.0 - 2.0 * t)
1204}
1205
1206fn lerp(start: f64, end: f64, amount: f64) -> f64 {
1207 start + (end - start) * amount
1208}
1209
1210fn distance_from_midpoint(phase: f64) -> f64 {
1211 (phase - 0.5).abs()
1212}
1213
1214fn crab_pulse_is_open(progress: f64, clicks: u8) -> bool {
1215 let clicks = clicks.clamp(MIN_SCRATCH_CLICKS, MAX_SCRATCH_CLICKS);
1216 let pulse_half_width = CRAB_PULSE_HALF_WIDTH.min(CRAB_PULSE_WIDTH_BUDGET / f64::from(clicks));
1217 if progress < CRAB_BURST_START - pulse_half_width
1218 || progress > CRAB_BURST_END + pulse_half_width
1219 {
1220 return false;
1221 }
1222 if clicks == 1 {
1223 let center = (CRAB_BURST_START + CRAB_BURST_END) * 0.5;
1224 return (progress - center).abs() <= pulse_half_width;
1225 }
1226 let spacing = (CRAB_BURST_END - CRAB_BURST_START) / f64::from(clicks - 1);
1227 for pulse in 0..clicks {
1228 let center = CRAB_BURST_START + f64::from(pulse) * spacing;
1229 if (progress - center).abs() <= pulse_half_width {
1230 return true;
1231 }
1232 }
1233 false
1234}
1235
1236#[cfg(test)]
1237mod tests {
1238 use super::*;
1239
1240 const SAMPLE_RATE: f64 = 48_000.0;
1241
1242 fn performance_input(
1243 contact: bool,
1244 intent: f64,
1245 rendered: f64,
1246 manual_gain: f64,
1247 ) -> ScratchPerformanceInput {
1248 ScratchPerformanceInput {
1249 delta_seconds: 1.0 / SAMPLE_RATE,
1250 hand_contact: contact,
1251 intent_record_rate: intent,
1252 rendered_record_rate: rendered,
1253 rendered_source_travel_seconds: rendered / SAMPLE_RATE,
1254 manual_crossfader_gain: manual_gain,
1255 }
1256 }
1257
1258 fn run(gate: &mut ScratchGate, seconds: f64, contact: bool, intent: f64, rendered: f64) {
1259 let frames = (seconds * SAMPLE_RATE).round() as usize;
1260 for _ in 0..frames {
1261 gate.process(1.0 / SAMPLE_RATE, contact, intent, rendered);
1262 }
1263 }
1264
1265 fn settle_direction(gate: &mut ScratchGate, direction: f64) {
1266 run(gate, 0.010, true, direction, direction);
1267 assert!(gate.moving());
1268 assert_eq!(gate.direction(), sign(direction));
1269 }
1270
1271 fn count_target_runs(preset: ScratchPreset, clicks: u8, target: f64) -> usize {
1272 let mut gate = ScratchGate::new(preset);
1273 gate.set_clicks(clicks);
1274 gate.contact_active = true;
1275 gate.direction = 1;
1276 gate.moving = true;
1277
1278 let rate = 1.0;
1279 let mut previous = gate.compute_target(rate, rate);
1280 let mut runs = usize::from(previous == target);
1281 let frames = (gate.learned_span() * SAMPLE_RATE).floor() as usize;
1282 for _ in 0..frames {
1283 gate.process(1.0 / SAMPLE_RATE, true, rate, rate);
1284 let current = gate.target();
1285 if current == target && previous != target {
1286 runs += 1;
1287 }
1288 previous = current;
1289 }
1290 runs
1291 }
1292
1293 fn target_at_stroke_progress(
1294 preset: ScratchPreset,
1295 clicks: u8,
1296 direction: i8,
1297 progress: f64,
1298 speed: f64,
1299 ) -> f64 {
1300 let mut gate = ScratchGate::new(preset);
1301 gate.set_clicks(clicks);
1302 gate.contact_active = true;
1303 gate.direction = direction;
1304 gate.moving = direction != 0;
1305 gate.stroke_travel = gate.learned_span() * progress;
1306 gate.update_phase();
1307 gate.compute_target(f64::from(direction) * speed, f64::from(direction) * speed)
1308 }
1309
1310 fn count_maximum_rate_target_runs(
1311 preset: ScratchPreset,
1312 clicks: u8,
1313 counted_target: f64,
1314 ) -> usize {
1315 let mut gate = ScratchGate::new(preset);
1316 gate.set_clicks(clicks);
1317 let frames = (preset.initial_stroke_span() / MAXIMUM_SCRATCH_RECORD_RATE * SAMPLE_RATE)
1320 .ceil() as usize
1321 + 1;
1322 let mut previous = None;
1323 let mut runs = 0;
1324 for _ in 0..frames {
1325 gate.process(
1326 1.0 / SAMPLE_RATE,
1327 true,
1328 MAXIMUM_SCRATCH_RECORD_RATE,
1329 MAXIMUM_SCRATCH_RECORD_RATE,
1330 );
1331 if gate.stroke_progress() >= 1.0 {
1332 break;
1333 }
1334 let target = gate.target();
1335 if target == counted_target && previous != Some(counted_target) {
1336 runs += 1;
1337 }
1338 previous = Some(target);
1339 }
1340 runs
1341 }
1342
1343 fn audio_fixture_sample(frame: usize, transient: bool) -> f64 {
1344 if transient {
1345 let age = frame % 257;
1346 if age < 96 {
1347 (-(age as f64) / 13.0).exp()
1348 } else {
1349 0.0
1350 }
1351 } else {
1352 (std::f64::consts::TAU * 997.0 * frame as f64 / SAMPLE_RATE).sin()
1353 }
1354 }
1355
1356 #[test]
1357 fn parses_every_preset_and_exposes_defaults() {
1358 let expected_clicks = [1, 1, 1, 2, 1, 4, 2, 1];
1359 let uses_clicks = [false, false, false, true, true, true, true, false];
1360 for ((preset, clicks), expected_uses_clicks) in ScratchPreset::ALL
1361 .into_iter()
1362 .zip(expected_clicks)
1363 .zip(uses_clicks)
1364 {
1365 assert_eq!(preset.as_str().parse::<ScratchPreset>().unwrap(), preset);
1366 assert_eq!(preset.default_clicks(), clicks);
1367 assert_eq!(preset.uses_clicks(), expected_uses_clicks);
1368 assert!(preset.initial_stroke_span() >= MIN_LEARNED_SPAN);
1369 }
1370 assert_eq!(
1371 "transformer".parse::<ScratchPreset>().unwrap(),
1372 ScratchPreset::Transform
1373 );
1374 assert!("scribble".parse::<ScratchPreset>().is_err());
1375 assert_eq!(ScratchPerformance::catalog(), &SCRATCH_PRESET_CATALOG);
1376 for (id, descriptor) in SCRATCH_PRESET_CATALOG.into_iter().enumerate() {
1377 assert_eq!(descriptor.id, id as u8);
1378 assert_eq!(
1379 ScratchPreset::from_id(descriptor.id),
1380 Some(descriptor.preset)
1381 );
1382 assert_eq!(descriptor.name, descriptor.preset.as_str());
1383 assert_eq!(
1384 descriptor.default_clicks,
1385 descriptor.preset.default_clicks()
1386 );
1387 assert_eq!(descriptor.uses_clicks, descriptor.preset.uses_clicks());
1388 assert_eq!(
1389 descriptor.uses_manual_crossfader,
1390 descriptor.preset == ScratchPreset::Baby
1391 );
1392 }
1393 assert_eq!(ScratchPreset::from_id(8), None);
1394 }
1395
1396 #[test]
1397 fn pvc_005_fixture_matches_canonical_constants_and_claim_limits() {
1398 let fixture: serde_json::Value = serde_json::from_str(include_str!(
1399 "../tests/fixtures/pvc_005_scratch_semantics.json"
1400 ))
1401 .unwrap();
1402 assert_eq!(fixture["schemaVersion"], 4);
1403 assert_eq!(fixture["caseId"], "PVC-005");
1404 assert_eq!(fixture["sampleRateHz"], SAMPLE_RATE);
1405 assert_eq!(fixture["algorithmVersion"], SCRATCH_GATE_ALGORITHM_VERSION);
1406 assert_eq!(
1407 fixture["snapshotVersions"]["gate"],
1408 SCRATCH_GATE_SNAPSHOT_VERSION
1409 );
1410 assert_eq!(
1411 fixture["snapshotVersions"]["performance"],
1412 SCRATCH_PERFORMANCE_SNAPSHOT_VERSION
1413 );
1414
1415 let limits = &fixture["limits"];
1416 assert_eq!(limits["minimumClicks"], MIN_SCRATCH_CLICKS);
1417 assert_eq!(limits["maximumClicks"], MAX_SCRATCH_CLICKS);
1418 assert_eq!(limits["maximumRecordRate"], MAXIMUM_SCRATCH_RECORD_RATE);
1419 assert_eq!(
1420 limits["maximumFrameDeltaSeconds"],
1421 MAXIMUM_FRAME_DELTA_SECONDS
1422 );
1423 assert_eq!(limits["minimumLearnedSpanSourceSeconds"], MIN_LEARNED_SPAN);
1424 assert_eq!(limits["maximumLearnedSpanSourceSeconds"], MAX_LEARNED_SPAN);
1425
1426 let motion = &fixture["motionModel"];
1427 for (name, value) in [
1428 ("onsetRate", MOTION_ONSET_RATE),
1429 ("restRate", REST_RATE),
1430 ("intentReversalRate", INTENT_REVERSAL_RATE),
1431 (
1432 "maximumPredictedReversalOutgoingRate",
1433 MAX_PREDICTED_REVERSAL_OUTGOING_RATE,
1434 ),
1435 ("physicalDirectionConfirmationRate", MOTION_ONSET_RATE),
1436 ("intentOnsetConfirmSeconds", ONSET_CONFIRM_SECONDS),
1437 ("intentReversalConfirmSeconds", REVERSAL_CONFIRM_SECONDS),
1438 ("restConfirmSeconds", REST_CONFIRM_SECONDS),
1439 ] {
1440 assert_eq!(motion[name], value, "{name}");
1441 }
1442 assert_eq!(
1443 motion["predictionObservationWeights"],
1444 serde_json::json!(SPAN_OBSERVATION_WEIGHTS)
1445 );
1446 assert_eq!(
1447 motion["observationsForFullConfidence"],
1448 OBSERVATIONS_FOR_FULL_SPAN_CONFIDENCE
1449 );
1450
1451 let envelope = &fixture["declickEnvelope"];
1452 for (name, value) in [
1453 (
1454 "openSlowTimeConstantSeconds",
1455 OPEN_SLOW_TIME_CONSTANT_SECONDS,
1456 ),
1457 (
1458 "openFastTimeConstantSeconds",
1459 OPEN_FAST_TIME_CONSTANT_SECONDS,
1460 ),
1461 (
1462 "closeSlowTimeConstantSeconds",
1463 CLOSE_SLOW_TIME_CONSTANT_SECONDS,
1464 ),
1465 (
1466 "closeFastTimeConstantSeconds",
1467 CLOSE_FAST_TIME_CONSTANT_SECONDS,
1468 ),
1469 ] {
1470 assert_eq!(envelope[name], value, "{name}");
1471 }
1472
1473 let techniques = fixture["techniques"].as_array().unwrap();
1474 assert_eq!(techniques.len(), SCRATCH_PRESET_CATALOG.len());
1475 for descriptor in SCRATCH_PRESET_CATALOG {
1476 let technique = &techniques[usize::from(descriptor.id)];
1477 assert_eq!(technique["id"], descriptor.id);
1478 assert_eq!(technique["name"], descriptor.name);
1479 assert_eq!(technique["defaultClicks"], descriptor.default_clicks);
1480 assert_eq!(technique["usesClicks"], descriptor.uses_clicks);
1481 assert_eq!(
1482 technique["usesManualCrossfader"],
1483 descriptor.uses_manual_crossfader
1484 );
1485 assert_eq!(
1486 technique["initialStrokeSpanSourceSeconds"],
1487 descriptor.preset.initial_stroke_span()
1488 );
1489 }
1490 assert_eq!(
1491 techniques[1]["forwardOpenStartFraction"],
1492 STAB_OPEN_START_FRACTION
1493 );
1494 assert_eq!(techniques[1]["forwardOpenUntilReversalIntent"], true);
1495 assert_eq!(techniques[1]["reverseTarget"], 0.0);
1496 assert_eq!(techniques[2]["edgeMuteFraction"], CHIRP_EDGE_MUTE_FRACTION);
1497 assert_eq!(techniques[2]["closesOnReversalIntent"], true);
1498 assert_eq!(techniques[2]["opensAfterPhysicalDirectionChange"], true);
1499 assert_eq!(techniques[2]["restTarget"], 0.0);
1500 assert_eq!(techniques[3]["acceptedAlias"], "transformer");
1501 assert_eq!(techniques[3]["openFraction"], TRANSFORM_OPEN_FRACTION);
1502 assert_eq!(techniques[4]["notchHalfWidth"], FLARE_NOTCH_HALF_WIDTH);
1503 assert_eq!(techniques[4]["notchedDirections"], serde_json::json!([1]));
1504 assert_eq!(techniques[5]["burstFirstCenterFraction"], CRAB_BURST_START);
1505 assert_eq!(techniques[5]["burstLastCenterFraction"], CRAB_BURST_END);
1506 assert_eq!(
1507 techniques[5]["maximumPulseHalfWidthFraction"],
1508 CRAB_PULSE_HALF_WIDTH
1509 );
1510 assert_eq!(
1511 techniques[5]["pulseWidthBudgetFraction"],
1512 CRAB_PULSE_WIDTH_BUDGET
1513 );
1514 assert_eq!(techniques[6]["notchHalfWidth"], FLARE_NOTCH_HALF_WIDTH);
1515 assert_eq!(
1516 techniques[6]["notchedDirections"],
1517 serde_json::json!([1, -1])
1518 );
1519 for (name, value) in [
1520 ("maximumOpenSeconds", DRUM_MAX_OPEN_SECONDS),
1521 ("openSpanFraction", DRUM_OPEN_SPAN_FRACTION),
1522 (
1523 "accelerationFilterSeconds",
1524 DRUM_ACCELERATION_FILTER_SECONDS,
1525 ),
1526 (
1527 "accelerationTriggerRatePerSecond",
1528 DRUM_ACCELERATION_TRIGGER,
1529 ),
1530 ("minimumTriggerRate", DRUM_TRIGGER_MIN_RATE),
1531 ("refractorySeconds", DRUM_REFRACTORY_SECONDS),
1532 ] {
1533 assert_eq!(techniques[7][name], value, "{name}");
1534 }
1535
1536 let fixtures = &fixture["fixtures"];
1537 assert_eq!(
1538 fixtures["sampleRateInvariance"]["sampleRatesHz"],
1539 serde_json::json!([44_100.0, 48_000.0, 96_000.0])
1540 );
1541 assert_eq!(fixtures["sampleRateInvariance"]["durationSeconds"], 0.073);
1542 assert_eq!(fixtures["sampleRateInvariance"]["recordRate"], 0.9);
1543 assert_eq!(
1544 fixtures["sampleRateInvariance"]["maximumPhaseDifference"],
1545 0.001
1546 );
1547 assert_eq!(
1548 fixtures["sampleRateInvariance"]["maximumAudibleGainDifference"],
1549 0.01
1550 );
1551 assert_eq!(
1552 fixtures["physicalTravelClock"]["deltaSeconds"],
1553 1.0 / SAMPLE_RATE
1554 );
1555 assert_eq!(fixtures["physicalTravelClock"]["intentRecordRate"], 4.0);
1556 assert_eq!(fixtures["physicalTravelClock"]["renderedRecordRate"], 0.5);
1557 assert_eq!(
1558 fixtures["physicalTravelClock"]["renderedSourceTravelSeconds"],
1559 0.0
1560 );
1561 assert_eq!(fixtures["physicalTravelClock"]["renderedFrames"], 2_000);
1562 assert_eq!(
1563 fixtures["physicalTravelClock"]["expectedStrokeProgress"],
1564 0.0
1565 );
1566 assert_eq!(fixtures["firstStrokePrediction"]["initialConfidence"], 0.0);
1567 assert_eq!(
1568 fixtures["firstStrokePrediction"]["firstObservedSpanSourceSeconds"],
1569 0.123
1570 );
1571 assert_eq!(
1572 fixtures["firstStrokePrediction"]["confidenceAfterFirstObservation"],
1573 0.25
1574 );
1575 assert_eq!(
1576 fixtures["firstStrokePrediction"]["firstObservedDirection"],
1577 "forward"
1578 );
1579 assert_eq!(
1580 fixtures["firstStrokePrediction"]["oppositeDirectionConfidenceAfterFirstObservation"],
1581 0.0
1582 );
1583 assert_eq!(
1584 fixtures["firstStrokePrediction"]["directionSpecificLearning"],
1585 true
1586 );
1587 assert_eq!(
1588 fixtures["firstStrokePrediction"]["fullConfidenceObservationCount"],
1589 OBSERVATIONS_FOR_FULL_SPAN_CONFIDENCE
1590 );
1591 assert_eq!(
1592 fixtures["clickEndpoint"]["clickCounts"],
1593 serde_json::json!([1, 4, 8])
1594 );
1595 assert_eq!(fixtures["clickEndpoint"]["renderedPredictedSpans"], 3.0);
1596 assert_eq!(
1597 fixtures["clickEndpoint"]["expectedRunCountRule"],
1598 "selected click count per rendered phrase"
1599 );
1600 assert_eq!(fixtures["drumPacketization"]["frameCount"], 8_000);
1601 assert_eq!(
1602 fixtures["drumPacketization"]["renderedRateBeforeFrame3000"],
1603 0.5
1604 );
1605 assert_eq!(
1606 fixtures["drumPacketization"]["renderedRateFromFrame3000"],
1607 1.5
1608 );
1609 assert_eq!(
1610 fixtures["drumPacketization"]["intentRates"],
1611 serde_json::json!([0.35, 0.85])
1612 );
1613 assert_eq!(
1614 fixtures["drumPacketization"]["intentPacketFrames"],
1615 serde_json::json!([1, 64])
1616 );
1617 assert_eq!(
1618 fixtures["drumPacketization"]["expectedEquality"],
1619 "exact target, automatic gate, and audible gain"
1620 );
1621 assert_eq!(fixtures["rapidReversal"]["strokeCount"], 32);
1622 assert_eq!(
1623 fixtures["rapidReversal"]["recordRates"],
1624 serde_json::json!([8.0, -8.0])
1625 );
1626 assert_eq!(fixtures["rapidReversal"]["framesPerStroke"], 400);
1627 assert_eq!(fixtures["rapidReversal"]["expectedEndpointTarget"], 0.0);
1628 assert_eq!(fixtures["maximumRateReversal"]["strokeCount"], 64);
1629 assert_eq!(
1630 fixtures["maximumRateReversal"]["recordRates"],
1631 serde_json::json!([20.0, -20.0])
1632 );
1633 assert_eq!(fixtures["maximumRateReversal"]["framesPerStroke"], 300);
1634 assert_eq!(
1635 fixtures["maximumRateReversal"]["expectedGainBounds"],
1636 serde_json::json!([0.0, 1.0])
1637 );
1638 let physical_confirmation = &fixtures["physicalMaximumRateConfirmation"];
1639 assert_eq!(
1640 physical_confirmation["recordRate"],
1641 MAXIMUM_SCRATCH_RECORD_RATE
1642 );
1643 assert_eq!(
1644 physical_confirmation["formerIntentOnsetDelaySeconds"],
1645 ONSET_CONFIRM_SECONDS
1646 );
1647 assert_eq!(
1648 physical_confirmation["formerOnsetBufferedSourceSeconds"],
1649 ONSET_CONFIRM_SECONDS * MAXIMUM_SCRATCH_RECORD_RATE
1650 );
1651 assert_eq!(
1652 physical_confirmation["formerIntentReversalDelaySeconds"],
1653 REVERSAL_CONFIRM_SECONDS
1654 );
1655 assert_eq!(
1656 physical_confirmation["formerReversalBufferedSourceSeconds"],
1657 REVERSAL_CONFIRM_SECONDS * MAXIMUM_SCRATCH_RECORD_RATE
1658 );
1659 assert_eq!(
1660 physical_confirmation["stabSeedSpanSourceSeconds"],
1661 ScratchPreset::Stab.initial_stroke_span()
1662 );
1663 assert_eq!(
1664 physical_confirmation["formerOnsetSeedSpanFraction"],
1665 ONSET_CONFIRM_SECONDS * MAXIMUM_SCRATCH_RECORD_RATE
1666 / ScratchPreset::Stab.initial_stroke_span()
1667 );
1668 assert_eq!(
1669 physical_confirmation["formerReversalSeedSpanFraction"],
1670 REVERSAL_CONFIRM_SECONDS * MAXIMUM_SCRATCH_RECORD_RATE
1671 / ScratchPreset::Stab.initial_stroke_span()
1672 );
1673 assert_eq!(
1674 physical_confirmation["expectedFirstStabAttack"],
1675 "preserved"
1676 );
1677 assert_eq!(
1678 physical_confirmation["expectedEarlyClickRunCountRule"],
1679 "selected click count"
1680 );
1681 assert_eq!(fixtures["snapshotReplay"]["preset"], "crab");
1682 assert_eq!(fixtures["snapshotReplay"]["clicks"], 5);
1683 assert_eq!(fixtures["snapshotReplay"]["replayedFrames"], 128);
1684 assert_eq!(
1685 fixtures["snapshotReplay"]["expectedEquality"],
1686 "exact output structure"
1687 );
1688 assert_eq!(fixtures["allocation"]["preset"], "crab");
1689 assert_eq!(fixtures["allocation"]["clicks"], 8);
1690 assert_eq!(fixtures["allocation"]["renderedFrames"], 10_000);
1691 assert_eq!(fixtures["allocation"]["expectedAllocations"], 0);
1692
1693 assert_eq!(
1694 fixture["claimLimits"],
1695 serde_json::json!([
1696 "This case validates helper topology, state, ownership, and deterministic timing.",
1697 "This case does not validate the timing against measured DJ crossfader traces.",
1698 "The technique fractions and Drum thresholds are provisional calibration values.",
1699 "The first-stroke seed is not a measured endpoint prediction.",
1700 "Physical-player, C ABI, and native Swift tests verify the production gain path.",
1701 "This case does not prove WASM or browser integration."
1702 ])
1703 );
1704 }
1705
1706 #[test]
1707 fn clicks_are_clamped_to_the_supported_range() {
1708 let mut gate = ScratchGate::default();
1709 gate.set_clicks(0);
1710 assert_eq!(gate.clicks(), MIN_SCRATCH_CLICKS);
1711 gate.set_clicks(u8::MAX);
1712 assert_eq!(gate.clicks(), MAX_SCRATCH_CLICKS);
1713 }
1714
1715 #[test]
1716 fn technique_contract_has_defining_direction_and_fader_edges() {
1717 assert_eq!(
1718 target_at_stroke_progress(ScratchPreset::Baby, 1, 1, 0.5, 2.0),
1719 1.0
1720 );
1721 assert_eq!(
1722 target_at_stroke_progress(ScratchPreset::Baby, 1, -1, 0.5, 2.0),
1723 1.0
1724 );
1725
1726 for (progress, target) in [(0.039, 0.0), (0.041, 1.0), (0.50, 1.0), (0.99, 1.0)] {
1727 assert_eq!(
1728 target_at_stroke_progress(ScratchPreset::Stab, 1, 1, progress, 2.0),
1729 target,
1730 "stab at {progress}"
1731 );
1732 }
1733 assert_eq!(
1734 target_at_stroke_progress(ScratchPreset::Stab, 1, -1, 0.16, 2.0),
1735 0.0
1736 );
1737
1738 for direction in [1, -1] {
1739 assert_eq!(
1740 target_at_stroke_progress(ScratchPreset::Chirp, 1, direction, 0.039, 2.0,),
1741 0.0
1742 );
1743 assert_eq!(
1744 target_at_stroke_progress(ScratchPreset::Chirp, 1, direction, 0.041, 2.0,),
1745 1.0
1746 );
1747 }
1748
1749 assert_eq!(
1750 target_at_stroke_progress(ScratchPreset::Transform, 2, 1, 0.119, 2.0),
1751 1.0
1752 );
1753 assert_eq!(
1754 target_at_stroke_progress(ScratchPreset::Transform, 2, 1, 0.121, 2.0),
1755 0.0
1756 );
1757 assert_eq!(
1758 target_at_stroke_progress(ScratchPreset::Transform, 2, 1, 0.500, 2.0),
1759 1.0
1760 );
1761
1762 assert_eq!(
1763 target_at_stroke_progress(ScratchPreset::Flare, 1, 1, 0.429, 2.0),
1764 1.0
1765 );
1766 assert_eq!(
1767 target_at_stroke_progress(ScratchPreset::Flare, 1, 1, 0.431, 2.0),
1768 0.0
1769 );
1770 assert_eq!(
1771 target_at_stroke_progress(ScratchPreset::Flare, 1, -1, 0.500, 2.0),
1772 1.0,
1773 "the Flare preset represents one forward flare, not a two-stroke orbit"
1774 );
1775
1776 assert_eq!(
1777 target_at_stroke_progress(ScratchPreset::Crab, 4, 1, 0.10, 2.0),
1778 0.0
1779 );
1780 for center in [0.18, 0.36, 0.54, 0.72] {
1781 assert_eq!(
1782 target_at_stroke_progress(ScratchPreset::Crab, 4, 1, center, 2.0),
1783 1.0,
1784 "crab pulse at {center}"
1785 );
1786 }
1787 assert_eq!(
1788 target_at_stroke_progress(ScratchPreset::Crab, 4, 1, 0.80, 2.0),
1789 0.0
1790 );
1791
1792 for direction in [1, -1] {
1793 assert_eq!(
1794 target_at_stroke_progress(ScratchPreset::Orbit, 2, direction, 0.26, 2.0),
1795 0.0
1796 );
1797 assert_eq!(
1798 target_at_stroke_progress(ScratchPreset::Orbit, 2, direction, 0.35, 2.0),
1799 1.0
1800 );
1801 }
1802
1803 let mut drum = ScratchGate::new(ScratchPreset::Drum);
1804 settle_direction(&mut drum, 0.5);
1805 assert_eq!(drum.target(), 1.0);
1806 run(&mut drum, DRUM_MAX_OPEN_SECONDS, true, 0.5, 0.5);
1807 assert_eq!(drum.target(), 0.0);
1808 }
1809
1810 #[test]
1811 fn every_technique_tracks_record_direction_with_continuous_same_sample_gain() {
1812 let maximum_alpha = 1.0 - (-(1.0 / SAMPLE_RATE) / 0.00035_f64).exp();
1813 for preset in ScratchPreset::ALL {
1814 let mut performance = ScratchPerformance::new(preset);
1815 let mut previous_gain = performance.audible_gain();
1816 let mut last_output = performance.output();
1817 for direction in [1.0, -1.0] {
1818 for _ in 0..((0.010 * SAMPLE_RATE) as usize) {
1819 last_output = performance
1820 .process_frame(performance_input(true, direction, direction, 1.0))
1821 .unwrap();
1822 assert_eq!(last_output.audible_gain, performance.audible_gain());
1823 assert!((0.0..=1.0).contains(&last_output.audible_gain));
1824 assert!(
1825 (last_output.audible_gain - previous_gain).abs() <= maximum_alpha + 1.0e-12,
1826 "{preset:?} introduced an unbounded gain step"
1827 );
1828 previous_gain = last_output.audible_gain;
1829 }
1830 assert_eq!(last_output.direction, sign(direction), "{preset:?}");
1831 assert_eq!(last_output.preset, preset);
1832 assert_eq!(last_output.clicks, preset.default_clicks());
1833 }
1834 }
1835 }
1836
1837 #[test]
1838 fn baby_stays_open_in_both_directions_and_at_rest() {
1839 let mut gate = ScratchGate::new(ScratchPreset::Baby);
1840 run(&mut gate, 0.05, true, 1.0, 1.0);
1841 assert_eq!(gate.target(), 1.0);
1842 run(&mut gate, 0.05, true, -1.0, -1.0);
1843 assert_eq!(gate.target(), 1.0);
1844 run(&mut gate, 0.05, true, 0.0, 0.0);
1845 assert_eq!(gate.target(), 1.0);
1846 assert!(gate.gate() > 0.999);
1847 }
1848
1849 #[test]
1850 fn stab_opens_only_on_confirmed_forward_motion() {
1851 let mut gate = ScratchGate::new(ScratchPreset::Stab);
1852 settle_direction(&mut gate, 1.0);
1853 run(&mut gate, 0.008, true, 1.0, 1.0);
1854 assert_eq!(gate.target(), 1.0);
1855 assert!(gate.gate() > 0.95);
1856
1857 run(&mut gate, 0.012, true, -1.0, -1.0);
1858 assert_eq!(gate.direction(), -1);
1859 assert_eq!(gate.target(), 0.0);
1860 run(&mut gate, 0.008, true, -1.0, -1.0);
1861 assert!(gate.gate() < 0.05);
1862 }
1863
1864 #[test]
1865 fn stab_keeps_the_forward_stroke_audible_and_mutes_the_return() {
1866 let mut gate = ScratchGate::new(ScratchPreset::Stab);
1867 settle_direction(&mut gate, 1.0);
1868 assert_eq!(gate.target(), 1.0);
1869 let later_in_stroke = gate.learned_span() * 0.30;
1870 run(&mut gate, later_in_stroke, true, 1.0, 1.0);
1871 assert_eq!(gate.direction(), 1);
1872 assert_eq!(gate.target(), 1.0);
1873 gate.process(1.0 / SAMPLE_RATE, true, -1.0, 0.2);
1874 assert_eq!(gate.direction(), 1);
1875 assert_eq!(gate.target(), 0.0);
1876 run(&mut gate, 0.020, true, -1.0, -1.0);
1877 assert_eq!(gate.direction(), -1);
1878 assert_eq!(gate.target(), 0.0);
1879 }
1880
1881 #[test]
1882 fn physical_maximum_rate_onset_preserves_the_first_stab_attack() {
1883 let mut gate = ScratchGate::new(ScratchPreset::Stab);
1884 let frames_through_attack =
1885 (2.0 * STAB_OPEN_START_FRACTION * ScratchPreset::Stab.initial_stroke_span()
1886 / MAXIMUM_SCRATCH_RECORD_RATE
1887 * SAMPLE_RATE)
1888 .ceil() as usize;
1889 for frame in 0..frames_through_attack {
1890 gate.process(
1891 1.0 / SAMPLE_RATE,
1892 true,
1893 MAXIMUM_SCRATCH_RECORD_RATE,
1894 MAXIMUM_SCRATCH_RECORD_RATE,
1895 );
1896 if frame == 0 {
1897 assert_eq!(gate.direction(), 1);
1898 assert!(gate.moving());
1899 assert_eq!(gate.target(), 0.0);
1900 }
1901 }
1902 assert_eq!(gate.target(), 1.0);
1903 }
1904
1905 #[test]
1906 fn physical_maximum_rate_onset_preserves_every_early_click_event() {
1907 let clicks = 4;
1908 for (preset, counted_target) in [
1909 (ScratchPreset::Transform, 1.0),
1910 (ScratchPreset::Flare, 0.0),
1911 (ScratchPreset::Crab, 1.0),
1912 (ScratchPreset::Orbit, 0.0),
1913 ] {
1914 assert_eq!(
1915 count_maximum_rate_target_runs(preset, clicks, counted_target),
1916 usize::from(clicks),
1917 "{preset:?} lost an early event at maximum rate"
1918 );
1919 }
1920 }
1921
1922 #[test]
1923 fn outgoing_physical_motion_rejects_predicted_reversal_until_the_crossing_sample() {
1924 let mut gate = ScratchGate::new(ScratchPreset::Transform);
1925 gate.process(
1926 1.0 / SAMPLE_RATE,
1927 true,
1928 MAXIMUM_SCRATCH_RECORD_RATE,
1929 MAXIMUM_SCRATCH_RECORD_RATE,
1930 );
1931 assert_eq!(gate.direction(), 1);
1932
1933 for _ in 0..((REVERSAL_CONFIRM_SECONDS * SAMPLE_RATE).ceil() as usize + 16) {
1934 gate.process(
1935 1.0 / SAMPLE_RATE,
1936 true,
1937 -MAXIMUM_SCRATCH_RECORD_RATE,
1938 MAXIMUM_SCRATCH_RECORD_RATE,
1939 );
1940 }
1941 assert_eq!(gate.direction(), 1);
1942
1943 gate.process(
1944 1.0 / SAMPLE_RATE,
1945 true,
1946 -MAXIMUM_SCRATCH_RECORD_RATE,
1947 -MAXIMUM_SCRATCH_RECORD_RATE,
1948 );
1949 assert_eq!(gate.direction(), -1);
1950 assert!(
1951 gate.stroke_progress()
1952 < 2.0 * MAXIMUM_SCRATCH_RECORD_RATE
1953 / SAMPLE_RATE
1954 / ScratchPreset::Transform.initial_stroke_span()
1955 );
1956 }
1957
1958 #[test]
1959 fn jitter_does_not_create_a_false_reversal() {
1960 let mut gate = ScratchGate::new(ScratchPreset::Transform);
1961 settle_direction(&mut gate, 0.8);
1962 for _ in 0..20 {
1963 run(&mut gate, 0.001, true, -0.08, 0.4);
1964 run(&mut gate, 0.001, true, 0.4, 0.4);
1965 }
1966 assert_eq!(gate.direction(), 1);
1967 assert!(gate.moving());
1968 }
1969
1970 #[test]
1971 fn intent_cannot_commit_reversal_before_rendered_rate_crosses_zero() {
1972 let mut gate = ScratchGate::new(ScratchPreset::Transform);
1973 settle_direction(&mut gate, 0.8);
1974 run(&mut gate, 0.007, true, -0.7, 0.2);
1975 assert_eq!(gate.direction(), 1);
1976 assert_eq!(gate.pending_direction, -1);
1977
1978 gate.process(1.0 / SAMPLE_RATE, true, -0.7, -0.2);
1979 assert_eq!(gate.direction(), -1);
1980 assert!(gate.phase() < 0.02);
1981 }
1982
1983 #[test]
1984 fn outgoing_motion_keeps_the_existing_stroke_until_physical_reversal() {
1985 let mut gate = ScratchGate::new(ScratchPreset::Transform);
1986 settle_direction(&mut gate, 0.8);
1987 run(&mut gate, 0.030, true, 0.8, 0.8);
1988 let outgoing_progress = gate.stroke_progress();
1989
1990 run(&mut gate, 0.007, true, -0.8, 0.3);
1991 assert_eq!(gate.direction(), 1);
1992 assert!(gate.stroke_progress() > outgoing_progress);
1993
1994 gate.process(1.0 / SAMPLE_RATE, true, -0.8, -0.3);
1995 assert_eq!(gate.direction(), -1);
1996 assert!(gate.phase() > 0.0);
1997 }
1998
1999 #[test]
2000 fn confirmed_reversal_keeps_audible_travel_from_the_confirmation_window() {
2001 let mut gate = ScratchGate::new(ScratchPreset::Transform);
2002 settle_direction(&mut gate, 1.0);
2003 run(&mut gate, 0.030, true, 1.0, 1.0);
2004 run(&mut gate, 0.0061, true, -8.0, -8.0);
2005 assert_eq!(gate.direction(), -1);
2006 assert!(gate.stroke_travel > 0.048);
2007 assert!(gate.stroke_progress() > 0.20);
2008 assert!(gate.phase() > 0.40);
2009 }
2010
2011 #[test]
2012 fn confirmed_onset_keeps_audible_travel_from_the_confirmation_window() {
2013 let mut gate = ScratchGate::new(ScratchPreset::Transform);
2014 run(&mut gate, 0.0041, true, 8.0, 8.0);
2015 assert_eq!(gate.direction(), 1);
2016 assert!(gate.stroke_progress() > 0.15);
2017 }
2018
2019 #[test]
2020 fn rejected_intent_reversal_discards_pending_state_without_changing_direction() {
2021 let mut gate = ScratchGate::new(ScratchPreset::Transform);
2022 settle_direction(&mut gate, 1.0);
2023 run(&mut gate, 0.030, true, 1.0, 1.0);
2024 let travel_before_jitter = gate.stroke_travel;
2025 let phase_before_jitter = gate.phase();
2026
2027 run(&mut gate, 0.003, true, -8.0, 8.0);
2028 assert_eq!(gate.direction(), 1);
2029 assert!((gate.stroke_travel - (travel_before_jitter + 0.024)).abs() < 1.0e-12);
2030 assert_ne!(gate.phase(), phase_before_jitter);
2031 assert!(gate.pending_seconds > 0.002);
2032 assert_eq!(gate.pending_stroke_travel, 0.0);
2033
2034 run(&mut gate, 0.001, true, 1.0, 1.0);
2035 assert_eq!(gate.pending_stroke_travel, 0.0);
2036 assert!((gate.stroke_travel - (travel_before_jitter + 0.025)).abs() < 1.0e-12);
2037 }
2038
2039 #[test]
2040 fn confirmed_reversal_travel_is_sample_rate_invariant() {
2041 let trace = |sample_rate: f64| {
2042 let run_at_rate = |gate: &mut ScratchGate, seconds: f64, rate: f64| {
2043 let frames = (seconds * sample_rate).round() as usize;
2044 for _ in 0..frames {
2045 gate.process(1.0 / sample_rate, true, rate, rate);
2046 }
2047 };
2048 let mut gate = ScratchGate::new(ScratchPreset::Transform);
2049 run_at_rate(&mut gate, 0.010, 1.0);
2050 run_at_rate(&mut gate, 0.030, 1.0);
2051 run_at_rate(&mut gate, 0.0065, -8.0);
2052 assert_eq!(gate.direction(), -1);
2053 (gate.phase(), gate.stroke_progress())
2054 };
2055
2056 let at_44 = trace(44_100.0);
2057 let at_48 = trace(48_000.0);
2058 let at_96 = trace(96_000.0);
2059 for (left, right) in [(at_44, at_48), (at_48, at_96)] {
2060 assert!((left.0 - right.0).abs() < 0.004, "{left:?} != {right:?}");
2061 assert!((left.1 - right.1).abs() < 0.002, "{left:?} != {right:?}");
2062 }
2063 }
2064
2065 #[test]
2066 fn rendered_rate_is_a_direction_fallback() {
2067 let mut gate = ScratchGate::new(ScratchPreset::Transform);
2068 run(&mut gate, 0.006, true, 0.0, -0.7);
2069 assert_eq!(gate.direction(), -1);
2070 assert!(gate.moving());
2071 }
2072
2073 #[test]
2074 fn rest_freezes_phase_and_same_direction_resume_preserves_it() {
2075 let mut gate = ScratchGate::new(ScratchPreset::Transform);
2076 settle_direction(&mut gate, 0.8);
2077 run(&mut gate, 0.025, true, 0.8, 0.8);
2078 let phase_before_rest = gate.phase();
2079 run(&mut gate, 0.030, true, 0.0, 0.0);
2080 assert!(!gate.moving());
2081 assert_eq!(gate.phase(), phase_before_rest);
2082
2083 run(&mut gate, 0.006, true, 0.8, 0.8);
2084 assert_eq!(gate.direction(), 1);
2085 assert!(gate.phase() >= phase_before_rest);
2086 }
2087
2088 #[test]
2089 fn reversal_learns_only_the_completed_direction_span() {
2090 let mut gate = ScratchGate::new(ScratchPreset::Transform);
2091 settle_direction(&mut gate, 1.0);
2092 run(&mut gate, 0.30, true, 1.0, 1.0);
2093 let initial_forward_span = gate.learned_span_for_direction(1);
2094 let initial_reverse_span = gate.learned_span_for_direction(-1);
2095 run(&mut gate, 0.0061, true, -1.0, -1.0);
2096 assert_eq!(gate.direction(), -1);
2097 assert!(gate.phase() > 0.02 && gate.phase() < 0.06);
2098 assert!(gate.learned_span_for_direction(1) > initial_forward_span);
2099 assert_eq!(gate.learned_span_for_direction(-1), initial_reverse_span);
2100 assert_eq!(gate.span_prediction_confidence_for_direction(1), 0.25);
2101 assert_eq!(gate.span_prediction_confidence_for_direction(-1), 0.0);
2102 assert_eq!(gate.span_prediction_confidence(), 0.0);
2103 }
2104
2105 #[test]
2106 fn transform_phase_tracks_travel_and_click_count() {
2107 let mut one_click = ScratchGate::new(ScratchPreset::Transform);
2108 one_click.set_clicks(1);
2109 settle_direction(&mut one_click, 1.0);
2110 run(&mut one_click, 0.030, true, 1.0, 1.0);
2111
2112 let mut two_clicks = ScratchGate::new(ScratchPreset::Transform);
2113 two_clicks.set_clicks(2);
2114 settle_direction(&mut two_clicks, 1.0);
2115 run(&mut two_clicks, 0.030, true, 1.0, 1.0);
2116 assert!((two_clicks.phase() - (2.0 * one_click.phase()).rem_euclid(1.0)).abs() < 1e-9);
2117
2118 let phase_before = one_click.phase();
2119 run(&mut one_click, 0.010, true, 2.0, 2.0);
2120 assert!(one_click.phase() - phase_before > 0.08);
2121 }
2122
2123 #[test]
2124 fn transform_has_a_closed_baseline_with_brief_uniform_taps() {
2125 let mut gate = ScratchGate::new(ScratchPreset::Transform);
2126 gate.set_clicks(2);
2127 gate.contact_active = true;
2128 gate.direction = 1;
2129 gate.moving = true;
2130 let frames = (gate.learned_span() * SAMPLE_RATE) as usize;
2131 let mut open = 0;
2132 let mut closed = 0;
2133 for _ in 0..frames {
2134 gate.process(1.0 / SAMPLE_RATE, true, 1.0, 1.0);
2135 if gate.target() == 1.0 {
2136 open += 1;
2137 } else {
2138 closed += 1;
2139 }
2140 }
2141 assert!(
2142 closed > open * 2,
2143 "open {open} frames, closed {closed} frames"
2144 );
2145 assert_eq!(count_target_runs(ScratchPreset::Transform, 2, 1.0), 2);
2146 }
2147
2148 #[test]
2149 fn click_driven_techniques_create_one_pulse_or_notch_per_selected_click() {
2150 for clicks in [1, 4, 8] {
2151 for preset in [ScratchPreset::Transform, ScratchPreset::Crab] {
2152 assert_eq!(
2153 count_target_runs(preset, clicks, 1.0),
2154 usize::from(clicks),
2155 "{preset:?} at {clicks} clicks"
2156 );
2157 }
2158 for preset in [ScratchPreset::Flare, ScratchPreset::Orbit] {
2159 assert_eq!(
2160 count_target_runs(preset, clicks, 0.0),
2161 usize::from(clicks),
2162 "{preset:?} at {clicks} clicks"
2163 );
2164 }
2165 }
2166 }
2167
2168 #[test]
2169 fn click_driven_techniques_repeat_for_continuous_record_motion() {
2170 for clicks in [1, 4, 8] {
2171 for (preset, counted_target) in [
2172 (ScratchPreset::Transform, 1.0),
2173 (ScratchPreset::Flare, 0.0),
2174 (ScratchPreset::Crab, 1.0),
2175 (ScratchPreset::Orbit, 0.0),
2176 ] {
2177 let mut gate = ScratchGate::new(preset);
2178 gate.set_clicks(clicks);
2179 gate.contact_active = true;
2180 gate.direction = 1;
2181 gate.moving = true;
2182 let mut previous = gate.compute_target(1.0, 1.0);
2183 let mut runs = usize::from(previous == counted_target);
2184 let frames = (gate.learned_span() * 3.0 * SAMPLE_RATE).floor() as usize;
2185 for _ in 0..frames.saturating_sub(1) {
2186 gate.process(1.0 / SAMPLE_RATE, true, 1.0, 1.0);
2187 let current = gate.target();
2188 if current == counted_target && previous != counted_target {
2189 runs += 1;
2190 }
2191 previous = current;
2192 }
2193 assert_eq!(runs, usize::from(clicks) * 3, "{preset:?} at {clicks}");
2194 assert_eq!(gate.stroke_progress(), 1.0);
2195 }
2196 }
2197 }
2198
2199 #[test]
2200 fn first_stroke_seed_has_zero_confidence_until_one_stroke_is_observed() {
2201 let mut gate = ScratchGate::new(ScratchPreset::Orbit);
2202 assert_eq!(gate.span_prediction_confidence(), 0.0);
2203 assert_eq!(
2204 gate.learned_span(),
2205 ScratchPreset::Orbit.initial_stroke_span()
2206 );
2207
2208 gate.stroke_travel = 0.123;
2209 gate.learn_completed_stroke(1);
2210 assert_eq!(gate.learned_span(), 0.123);
2211 assert_eq!(gate.span_prediction_confidence(), 0.25);
2212 assert_eq!(
2213 gate.learned_span_for_direction(-1),
2214 ScratchPreset::Orbit.initial_stroke_span()
2215 );
2216 assert_eq!(gate.span_prediction_confidence_for_direction(-1), 0.0);
2217
2218 for observed in [0.124, 0.122, 0.123] {
2219 gate.stroke_travel = observed;
2220 gate.learn_completed_stroke(1);
2221 }
2222 assert_eq!(gate.span_prediction_confidence(), 1.0);
2223 assert!((gate.learned_span() - 0.123).abs() < 0.001);
2224 }
2225
2226 #[test]
2227 fn asymmetric_direction_spans_clock_clicks_independently() {
2228 let mut gate = ScratchGate::new(ScratchPreset::Transform);
2229 gate.set_clicks(2);
2230 gate.stroke_travel = 0.08;
2231 gate.learn_completed_stroke(1);
2232 gate.stroke_travel = 0.32;
2233 gate.learn_completed_stroke(-1);
2234
2235 gate.contact_active = true;
2236 gate.moving = true;
2237 gate.stroke_travel = 0.02;
2238 gate.direction = 1;
2239 gate.update_phase();
2240 assert!((gate.stroke_progress() - 0.25).abs() < 1.0e-12);
2241 assert_eq!(gate.compute_target(1.0, 1.0), 0.0);
2242
2243 gate.direction = -1;
2244 gate.update_phase();
2245 assert!((gate.stroke_progress() - 0.0625).abs() < 1.0e-12);
2246 assert_eq!(gate.compute_target(-1.0, -1.0), 1.0);
2247 }
2248
2249 #[test]
2250 fn current_gate_ignores_click_count_for_non_click_techniques() {
2251 for preset in [
2252 ScratchPreset::Baby,
2253 ScratchPreset::Stab,
2254 ScratchPreset::Chirp,
2255 ScratchPreset::Drum,
2256 ] {
2257 let trace = |clicks| {
2258 let mut gate = ScratchGate::new(preset);
2259 gate.set_clicks(clicks);
2260 run(&mut gate, 0.043, true, 0.9, 0.9);
2261 (gate.phase(), gate.target(), gate.gate())
2262 };
2263 let one = trace(1);
2264 let eight = trace(8);
2265 assert_eq!(eight, one, "{preset:?}");
2266 }
2267 }
2268
2269 #[test]
2270 fn gate_reports_only_the_current_algorithm() {
2271 let mut gate = ScratchGate::new(ScratchPreset::Chirp);
2272 assert_eq!(gate.algorithm_version(), SCRATCH_GATE_ALGORITHM_VERSION);
2273 gate.set_algorithm_version(SCRATCH_GATE_ALGORITHM_VERSION);
2274 assert_eq!(gate.algorithm_version(), SCRATCH_GATE_ALGORITHM_VERSION);
2275 }
2276
2277 #[test]
2278 fn travel_patterns_keep_groove_landmarks_at_different_velocities() {
2279 for preset in [
2280 ScratchPreset::Transform,
2281 ScratchPreset::Flare,
2282 ScratchPreset::Crab,
2283 ScratchPreset::Orbit,
2284 ] {
2285 let trace = |rate: f64| {
2286 let mut gate = ScratchGate::new(preset);
2287 gate.contact_active = true;
2288 gate.direction = 1;
2289 gate.moving = true;
2290 run(&mut gate, 0.020 / rate, true, rate, rate);
2291 (gate.phase(), gate.target())
2292 };
2293 let slow = trace(0.5);
2294 let fast = trace(2.0);
2295 assert!((slow.0 - fast.0).abs() < 1e-9, "{preset:?}");
2296 assert_eq!(slow.1, fast.1, "{preset:?}");
2297 }
2298 }
2299
2300 #[test]
2301 fn chirp_mutes_each_physical_direction_edge() {
2302 let mut gate = ScratchGate::new(ScratchPreset::Chirp);
2303 gate.contact_active = true;
2304 gate.moving = true;
2305 for direction in [1, -1] {
2306 gate.direction = direction;
2307 gate.stroke_travel = gate.learned_span() * 0.039;
2308 gate.update_phase();
2309 assert_eq!(
2310 gate.compute_target(f64::from(direction), f64::from(direction)),
2311 0.0
2312 );
2313 gate.stroke_travel = gate.learned_span() * 0.041;
2314 gate.update_phase();
2315 assert_eq!(
2316 gate.compute_target(f64::from(direction), f64::from(direction)),
2317 1.0
2318 );
2319 }
2320 }
2321
2322 #[test]
2323 fn chirp_opens_after_each_edge_and_closes_before_the_turn() {
2324 let mut gate = ScratchGate::new(ScratchPreset::Chirp);
2325 run(&mut gate, 0.005, true, 1.0, 1.0);
2326 assert_eq!(gate.target(), 0.0);
2327 run(&mut gate, 0.005, true, 1.0, 1.0);
2328 assert_eq!(gate.target(), 1.0);
2329
2330 gate.process(1.0 / SAMPLE_RATE, true, -1.0, 0.2);
2331 assert_eq!(gate.direction(), 1);
2332 assert_eq!(gate.target(), 0.0);
2333
2334 gate.process(1.0 / SAMPLE_RATE, true, -1.0, -1.0);
2335 assert_eq!(gate.direction(), -1);
2336 assert_eq!(gate.target(), 0.0);
2337 run(&mut gate, 0.010, true, -1.0, -1.0);
2338 assert_eq!(gate.target(), 1.0);
2339 }
2340
2341 #[test]
2342 fn chirp_closes_on_reversal_intent_before_the_record_changes_direction() {
2343 let mut gate = ScratchGate::new(ScratchPreset::Chirp);
2344 settle_direction(&mut gate, 1.0);
2345 run(&mut gate, 0.010, true, 1.0, 1.0);
2346 assert_eq!(gate.target(), 1.0);
2347
2348 gate.process(1.0 / SAMPLE_RATE, true, -1.0, 0.2);
2349 assert_eq!(gate.direction(), 1);
2350 assert_eq!(gate.pending_direction, -1);
2351 assert_eq!(gate.target(), 0.0);
2352
2353 gate.process(1.0 / SAMPLE_RATE, true, -1.0, -0.2);
2354 assert_eq!(gate.direction(), -1);
2355 assert_eq!(gate.target(), 0.0);
2356 }
2357
2358 #[test]
2359 fn chirp_pause_does_not_expose_the_forward_endpoint() {
2360 let mut gate = ScratchGate::new(ScratchPreset::Chirp);
2361 run(&mut gate, 0.020, true, 1.0, 1.0);
2362 assert_eq!(gate.target(), 1.0);
2363 let phase_at_endpoint = gate.phase();
2364
2365 gate.process(1.0 / SAMPLE_RATE, true, 0.0, 0.0);
2366 assert_eq!(gate.phase(), phase_at_endpoint);
2367 assert_eq!(gate.target(), 0.0);
2368
2369 run(&mut gate, 0.030, true, 0.0, 0.0);
2370 assert!(!gate.moving());
2371
2372 run(&mut gate, 0.030, false, 0.0, 0.0);
2373 assert_eq!(gate.target(), 1.0);
2374 }
2375
2376 #[test]
2377 fn flare_and_orbit_are_open_at_rest_and_cut_mid_cycle() {
2378 for preset in [ScratchPreset::Flare, ScratchPreset::Orbit] {
2379 let mut gate = ScratchGate::new(preset);
2380 run(&mut gate, 0.02, true, 0.0, 0.0);
2381 assert_eq!(gate.target(), 1.0);
2382 gate.set_clicks(1);
2383 settle_direction(&mut gate, 1.0);
2384 let travel_to_midpoint = gate.learned_span() * 0.5 - gate.stroke_travel;
2385 run(&mut gate, travel_to_midpoint, true, 1.0, 1.0);
2386 assert_eq!(gate.target(), 0.0, "{preset:?}");
2387 }
2388 }
2389
2390 #[test]
2391 fn flare_is_one_sided_while_orbit_repeats_the_notch_on_return() {
2392 for (preset, expected) in [(ScratchPreset::Flare, 1.0), (ScratchPreset::Orbit, 0.0)] {
2393 let mut gate = ScratchGate::new(preset);
2394 gate.set_clicks(1);
2395 gate.contact_active = true;
2396 gate.direction = -1;
2397 gate.moving = true;
2398 gate.stroke_travel = gate.learned_span() * 0.5;
2399 gate.update_phase();
2400 assert_eq!(gate.compute_target(-1.0, -1.0), expected, "{preset:?}");
2401 }
2402 }
2403
2404 #[test]
2405 fn crab_is_closed_at_rest_and_pulses_while_moving() {
2406 let mut gate = ScratchGate::new(ScratchPreset::Crab);
2407 run(&mut gate, 0.020, true, 0.0, 0.0);
2408 assert_eq!(gate.target(), 0.0);
2409 settle_direction(&mut gate, 1.0);
2410 assert_eq!(gate.target(), 0.0);
2411 let travel_to_first_pulse = gate.learned_span() * CRAB_BURST_START - gate.stroke_travel;
2412 run(&mut gate, travel_to_first_pulse, true, 1.0, 1.0);
2413 assert_eq!(gate.target(), 1.0);
2414 let travel_past_burst = gate.learned_span() * (CRAB_BURST_END + 0.05) - gate.stroke_travel;
2415 run(&mut gate, travel_past_burst, true, 1.0, 1.0);
2416 assert_eq!(gate.target(), 0.0);
2417 }
2418
2419 #[test]
2420 fn crab_is_a_clustered_finger_burst_not_a_transform_duty_variant() {
2421 let clicks = 4;
2422 assert!(!crab_pulse_is_open(0.0, clicks));
2423 assert!(!crab_pulse_is_open(0.90, clicks));
2424 let samples = 20_000;
2425 let mut previous = false;
2426 let mut runs = 0;
2427 for index in 0..=samples {
2428 let progress = index as f64 / samples as f64;
2429 let open = crab_pulse_is_open(progress, clicks);
2430 if open && !previous {
2431 runs += 1;
2432 }
2433 previous = open;
2434 }
2435 assert_eq!(runs, usize::from(clicks));
2436 assert!(crab_pulse_is_open(CRAB_BURST_START, clicks));
2437 assert_eq!(
2438 crab_pulse_is_open(0.10, clicks),
2439 false,
2440 "crab must stay closed where transform starts open"
2441 );
2442 }
2443
2444 #[test]
2445 fn drum_opens_on_attack_then_closes_and_respects_refractory() {
2446 let mut gate = ScratchGate::new(ScratchPreset::Drum);
2447 settle_direction(&mut gate, 0.5);
2448 assert_eq!(gate.target(), 1.0);
2449 run(&mut gate, 0.034, true, 0.5, 0.5);
2450 assert_eq!(gate.target(), 0.0);
2451
2452 gate.process(1.0 / SAMPLE_RATE, true, 1.5, 1.5);
2453 assert_eq!(gate.target(), 0.0, "early acceleration must not retrigger");
2454 run(&mut gate, 0.003, true, 1.5, 0.5);
2455 gate.process(1.0 / SAMPLE_RATE, true, 1.5, 1.5);
2456 assert_eq!(gate.target(), 1.0);
2457 }
2458
2459 #[test]
2460 fn drum_reversal_does_not_spend_its_hit_on_outgoing_motion() {
2461 let mut gate = ScratchGate::new(ScratchPreset::Drum);
2462 settle_direction(&mut gate, 0.5);
2463 run(&mut gate, 0.070, true, 0.5, 0.5);
2464 assert_eq!(gate.target(), 0.0);
2465
2466 run(&mut gate, 0.007, true, -8.0, 0.5);
2467 assert_eq!(gate.direction(), 1);
2468 assert_eq!(gate.target(), 0.0);
2469
2470 run(&mut gate, 0.010, true, -8.0, 0.5);
2471 assert_eq!(gate.direction(), 1);
2472 assert_eq!(gate.target(), 0.0);
2473
2474 gate.process(1.0 / SAMPLE_RATE, true, -8.0, -8.0);
2475 assert_eq!(gate.target(), 1.0);
2476 }
2477
2478 #[test]
2479 fn drum_ignores_slow_onsets_and_small_pointer_rate_steps() {
2480 let mut slow = ScratchGate::new(ScratchPreset::Drum);
2481 settle_direction(&mut slow, 0.05);
2482 assert_eq!(slow.target(), 0.0);
2483
2484 let mut jitter = ScratchGate::new(ScratchPreset::Drum);
2485 settle_direction(&mut jitter, 0.5);
2486 run(&mut jitter, 0.065, true, 0.5, 0.5);
2487 assert_eq!(jitter.target(), 0.0);
2488 run(&mut jitter, 0.012, true, 0.51, 0.5);
2489 assert_eq!(jitter.target(), 0.0);
2490 }
2491
2492 #[test]
2493 fn drum_rendered_acceleration_is_sample_rate_invariant() {
2494 let trigger_at = |sample_rate: f64| {
2495 let mut gate = ScratchGate::new(ScratchPreset::Drum);
2496 let run_at_rate = |gate: &mut ScratchGate, seconds: f64, intent: f64, rendered: f64| {
2497 let frames = (seconds * sample_rate).round() as usize;
2498 for _ in 0..frames {
2499 gate.process(1.0 / sample_rate, true, intent, rendered);
2500 }
2501 };
2502 run_at_rate(&mut gate, 0.070, 0.5, 0.5);
2503 assert_eq!(gate.target(), 0.0);
2504 run_at_rate(&mut gate, 0.001, 0.5, 1.5);
2505 gate.target()
2506 };
2507
2508 assert_eq!(trigger_at(44_100.0), 1.0);
2509 assert_eq!(trigger_at(48_000.0), 1.0);
2510 assert_eq!(trigger_at(96_000.0), 1.0);
2511 }
2512
2513 #[test]
2514 fn drum_is_invariant_to_intent_event_packetization_for_same_physical_trajectory() {
2515 let trace = |packet_frames: usize| {
2516 let mut performance = ScratchPerformance::new(ScratchPreset::Drum);
2517 let mut result = Vec::with_capacity(8_000);
2518 for frame in 0..8_000 {
2519 let rendered = if frame < 3_000 { 0.5 } else { 1.5 };
2520 let packet = frame / packet_frames;
2521 let intent = if packet % 2 == 0 { 0.35 } else { 0.85 };
2522 let output = performance
2523 .process_frame(performance_input(true, intent, rendered, 0.0))
2524 .unwrap();
2525 result.push((
2526 output.automatic_gate_target,
2527 output.automatic_gate_gain,
2528 output.audible_gain,
2529 ));
2530 }
2531 result
2532 };
2533
2534 assert_eq!(trace(1), trace(64));
2535 }
2536
2537 #[test]
2538 fn releasing_contact_always_reopens_the_gate() {
2539 let mut gate = ScratchGate::new(ScratchPreset::Stab);
2540 settle_direction(&mut gate, -1.0);
2541 run(&mut gate, 0.010, true, -1.0, -1.0);
2542 assert!(gate.gate() < 0.05);
2543 gate.release();
2544 assert_eq!(gate.target(), 1.0);
2545 assert_eq!(gate.direction(), 0);
2546 run(&mut gate, 0.012, false, 0.0, 0.0);
2547 assert!(gate.gate() > 0.99);
2548 }
2549
2550 #[test]
2551 fn declick_envelope_is_bounded_and_not_instantaneous() {
2552 let mut gate = ScratchGate::new(ScratchPreset::Stab);
2553 settle_direction(&mut gate, -1.0);
2554 assert!(gate.gate() > 0.0 && gate.gate() < 1.0);
2555 for _ in 0..20_000 {
2556 let value = gate.process(1.0 / SAMPLE_RATE, true, -1.0, -1.0);
2557 assert!((0.0..=1.0).contains(&value));
2558 }
2559 }
2560
2561 #[test]
2562 fn click_gate_envelope_bounds_added_discontinuity_on_audio_fixtures() {
2563 let fastest_attack_alpha = 1.0 - (-(1.0 / SAMPLE_RATE) / 0.00035_f64).exp();
2564
2565 for transient in [false, true] {
2566 for clicks in [1, 4, 8] {
2567 for preset in [
2568 ScratchPreset::Transform,
2569 ScratchPreset::Flare,
2570 ScratchPreset::Crab,
2571 ScratchPreset::Orbit,
2572 ] {
2573 let mut gate = ScratchGate::new(preset);
2574 gate.set_clicks(clicks);
2575 gate.contact_active = true;
2576 gate.direction = 1;
2577 gate.moving = true;
2578
2579 let rate = 8.0;
2580 let frames = (gate.learned_span() / rate * SAMPLE_RATE).floor() as usize;
2581 let mut previous_input = audio_fixture_sample(0, transient);
2582 let mut previous_output = previous_input * gate.gate();
2583 for frame in 1..frames {
2584 let gain = gate.process(1.0 / SAMPLE_RATE, true, rate, rate);
2585 let input = audio_fixture_sample(frame, transient);
2586 let output = input * gain;
2587 let input_step = (input - previous_input).abs();
2588 let output_step = (output - previous_output).abs();
2589 assert!(
2590 output_step <= input_step + fastest_attack_alpha + 1e-12,
2591 "{preset:?} at {clicks} clicks added too much discontinuity: \
2592 output {output_step}, input {input_step}"
2593 );
2594 previous_input = input;
2595 previous_output = output;
2596 }
2597 }
2598 }
2599 }
2600 }
2601
2602 #[test]
2603 fn stable_open_and_closed_gate_rms_is_bounded_on_audio_fixtures() {
2604 let measure = |gate: &mut ScratchGate, direction: f64, transient: bool| {
2605 run(gate, 0.020, true, direction, direction);
2606 let frames = (0.012 * SAMPLE_RATE) as usize;
2607 let mut input_energy = 0.0;
2608 let mut output_energy = 0.0;
2609 for frame in 0..frames {
2610 let input = audio_fixture_sample(frame, transient);
2611 let output = input * gate.process(1.0 / SAMPLE_RATE, true, direction, direction);
2612 input_energy += input * input;
2613 output_energy += output * output;
2614 }
2615 (output_energy / input_energy).sqrt()
2616 };
2617
2618 for transient in [false, true] {
2619 let closed_ratio = measure(&mut ScratchGate::new(ScratchPreset::Stab), -1.0, transient);
2620 let open_ratio = measure(&mut ScratchGate::new(ScratchPreset::Stab), 1.0, transient);
2621 assert!(closed_ratio < 1e-6, "closed RMS ratio was {closed_ratio}");
2622 assert!(open_ratio > 0.999, "open RMS ratio was {open_ratio}");
2623 }
2624 }
2625
2626 #[test]
2627 fn gate_timing_is_sample_rate_invariant() {
2628 let render = |sample_rate: f64| {
2629 let mut gate = ScratchGate::new(ScratchPreset::Transform);
2630 let frames = (sample_rate * 0.073).round() as usize;
2631 for _ in 0..frames {
2632 gate.process(1.0 / sample_rate, true, 0.9, 0.9);
2633 }
2634 (gate.phase(), gate.gate(), gate.direction())
2635 };
2636 let at_44 = render(44_100.0);
2637 let at_48 = render(48_000.0);
2638 let at_96 = render(96_000.0);
2639 assert_eq!(at_44.2, at_48.2);
2640 assert_eq!(at_48.2, at_96.2);
2641 assert!((at_44.0 - at_48.0).abs() < 0.001);
2642 assert!((at_48.0 - at_96.0).abs() < 0.001);
2643 assert!((at_44.1 - at_96.1).abs() < 0.01);
2644 }
2645
2646 #[test]
2647 fn performance_timing_is_sample_rate_invariant() {
2648 let render = |sample_rate: f64| {
2649 let mut performance = ScratchPerformance::new(ScratchPreset::Transform);
2650 let input = ScratchPerformanceInput {
2651 delta_seconds: 1.0 / sample_rate,
2652 hand_contact: true,
2653 intent_record_rate: 0.9,
2654 rendered_record_rate: 0.9,
2655 rendered_source_travel_seconds: 0.9 / sample_rate,
2656 manual_crossfader_gain: 0.0,
2657 };
2658 let frames = (sample_rate * 0.073).round() as usize;
2659 let mut output = performance.process_frame(input).unwrap();
2660 for _ in 1..frames {
2661 output = performance.process_frame(input).unwrap();
2662 }
2663 output
2664 };
2665 let at_44 = render(44_100.0);
2666 let at_48 = render(48_000.0);
2667 let at_96 = render(96_000.0);
2668 assert_eq!(at_44.direction, at_48.direction);
2669 assert_eq!(at_48.direction, at_96.direction);
2670 assert!((at_44.phase - at_48.phase).abs() < 0.001);
2671 assert!((at_48.phase - at_96.phase).abs() < 0.001);
2672 assert!((at_44.audible_gain - at_96.audible_gain).abs() < 0.01);
2673 }
2674
2675 #[test]
2676 fn physical_rendered_motion_is_the_only_source_of_pattern_travel() {
2677 let mut performance = ScratchPerformance::new(ScratchPreset::Transform);
2678 let mut no_travel = performance_input(true, 4.0, 0.5, 1.0);
2679 no_travel.rendered_source_travel_seconds = 0.0;
2680 for _ in 0..2_000 {
2681 performance.process_frame(no_travel).unwrap();
2682 }
2683 assert_eq!(performance.gate().stroke_progress(), 0.0);
2684
2685 for _ in 0..2_000 {
2686 performance
2687 .process_frame(performance_input(true, 4.0, 0.5, 1.0))
2688 .unwrap();
2689 }
2690 assert!(performance.gate().stroke_progress() > 0.0);
2691 }
2692
2693 #[test]
2694 fn baby_uses_manual_gain_and_automatic_presets_own_the_output() {
2695 let mut performance = ScratchPerformance::new(ScratchPreset::Baby);
2696 let mut output = performance
2697 .process_frame(performance_input(false, 0.0, 0.0, 0.0))
2698 .unwrap();
2699 assert_eq!(output.owner, ScratchCrossfaderOwner::Manual);
2700 for _ in 0..2_000 {
2701 output = performance
2702 .process_frame(performance_input(false, 0.0, 0.0, 0.0))
2703 .unwrap();
2704 }
2705 assert!(output.audible_gain < 1.0e-9);
2706
2707 performance.set_preset(ScratchPreset::Stab);
2708 for _ in 0..8_000 {
2709 output = performance
2710 .process_frame(performance_input(true, 1.0, 1.0, 0.0))
2711 .unwrap();
2712 }
2713 assert_eq!(output.owner, ScratchCrossfaderOwner::AutomaticPreset);
2714 assert!(
2715 output.audible_gain > 0.999,
2716 "stab keeps the forward stroke audible"
2717 );
2718 for _ in 0..2_000 {
2719 output = performance
2720 .process_frame(performance_input(true, -1.0, 0.2, 0.0))
2721 .unwrap();
2722 }
2723 assert!(
2724 output.audible_gain < 1.0e-9,
2725 "stab closes before the physical return"
2726 );
2727
2728 performance.set_preset(ScratchPreset::Baby);
2729 for _ in 0..2_000 {
2730 output = performance
2731 .process_frame(performance_input(true, 0.0, 0.0, 0.75))
2732 .unwrap();
2733 }
2734 assert!((output.audible_gain - 0.75).abs() < 1.0e-9);
2735 }
2736
2737 #[test]
2738 fn equivalent_rendered_travel_ignores_positive_intent_jitter_and_coalescing() {
2739 let trace = |coalesced: bool| {
2740 let mut performance = ScratchPerformance::new(ScratchPreset::Orbit);
2741 performance.set_clicks(4);
2742 for frame in 0..12_000 {
2743 let intent = if coalesced {
2744 if (frame / 64) % 2 == 0 {
2745 0.65
2746 } else {
2747 0.85
2748 }
2749 } else if frame % 2 == 0 {
2750 0.70
2751 } else {
2752 0.80
2753 };
2754 performance
2755 .process_frame(performance_input(true, intent, 0.75, 1.0))
2756 .unwrap();
2757 }
2758 (
2759 performance.gate().direction(),
2760 performance.gate().phase(),
2761 performance.gate().stroke_progress(),
2762 performance.gate().target(),
2763 )
2764 };
2765 assert_eq!(trace(false), trace(true));
2766 }
2767
2768 #[test]
2769 fn rapid_physical_reversals_remain_bounded_and_hide_each_endpoint() {
2770 let mut performance = ScratchPerformance::new(ScratchPreset::Chirp);
2771 for stroke in 0..32 {
2772 let direction = if stroke % 2 == 0 { 8.0 } else { -8.0 };
2773 for _ in 0..400 {
2774 let output = performance
2775 .process_frame(performance_input(true, direction, direction, 1.0))
2776 .unwrap();
2777 assert!((0.0..=1.0).contains(&output.audible_gain));
2778 }
2779 let pending_direction = -direction;
2780 let endpoint = performance
2781 .process_frame(performance_input(
2782 true,
2783 pending_direction,
2784 direction * 0.02,
2785 1.0,
2786 ))
2787 .unwrap();
2788 assert_eq!(endpoint.automatic_gate_target, 0.0);
2789 }
2790 }
2791
2792 #[test]
2793 fn maximum_rate_reversals_remain_finite_and_bounded() {
2794 let mut performance = ScratchPerformance::new(ScratchPreset::Crab);
2795 performance.set_clicks(8);
2796 for stroke in 0..64 {
2797 let rate = if stroke % 2 == 0 { 20.0 } else { -20.0 };
2798 for _ in 0..300 {
2799 let output = performance
2800 .process_frame(performance_input(true, rate, rate, 0.5))
2801 .unwrap();
2802 assert!(output.audible_gain.is_finite());
2803 assert!((0.0..=1.0).contains(&output.audible_gain));
2804 assert!(output.phase.is_finite());
2805 assert!((0.0..=1.0).contains(&output.phase));
2806 }
2807 }
2808 }
2809
2810 #[test]
2811 fn maximum_rate_roundoff_clamps_to_the_exact_supported_boundary() {
2812 let mut exact = ScratchPerformance::new(ScratchPreset::Transform);
2813 let mut rounded = exact;
2814 let exact_travel = MAXIMUM_SCRATCH_RECORD_RATE / SAMPLE_RATE;
2815 let mut exact_input = performance_input(
2816 true,
2817 MAXIMUM_SCRATCH_RECORD_RATE,
2818 MAXIMUM_SCRATCH_RECORD_RATE,
2819 1.0,
2820 );
2821 exact_input.rendered_source_travel_seconds = exact_travel;
2822 let mut rounded_input = exact_input;
2823 rounded_input.intent_record_rate =
2824 MAXIMUM_SCRATCH_RECORD_RATE + 0.5 * MAXIMUM_SCRATCH_RECORD_RATE_ROUNDOFF;
2825 rounded_input.rendered_record_rate =
2826 MAXIMUM_SCRATCH_RECORD_RATE + 0.5 * MAXIMUM_SCRATCH_RECORD_RATE_ROUNDOFF;
2827 rounded_input.rendered_source_travel_seconds =
2828 exact_travel + 0.5 * MAXIMUM_SCRATCH_RECORD_RATE_ROUNDOFF * rounded_input.delta_seconds;
2829
2830 assert_eq!(
2831 rounded.process_frame(rounded_input),
2832 exact.process_frame(exact_input)
2833 );
2834 assert_eq!(rounded.snapshot(), exact.snapshot());
2835 }
2836
2837 #[test]
2838 fn performance_snapshot_restore_repeats_every_output_frame() {
2839 let mut performance = ScratchPerformance::new(ScratchPreset::Crab);
2840 performance.set_clicks(5);
2841 for _ in 0..1_000 {
2842 performance
2843 .process_frame(performance_input(true, 1.2, 0.9, 0.4))
2844 .unwrap();
2845 }
2846 let snapshot = performance.snapshot();
2847 let mut expected = [performance
2848 .process_frame(performance_input(true, -1.4, -0.7, 0.2))
2849 .unwrap(); 128];
2850 for output in &mut expected[1..] {
2851 *output = performance
2852 .process_frame(performance_input(true, -1.4, -0.7, 0.2))
2853 .unwrap();
2854 }
2855 performance.restore(&snapshot).unwrap();
2856 for expected_output in expected {
2857 assert_eq!(
2858 performance
2859 .process_frame(performance_input(true, -1.4, -0.7, 0.2))
2860 .unwrap(),
2861 expected_output
2862 );
2863 }
2864 }
2865
2866 #[test]
2867 fn invalid_input_and_snapshot_restore_are_transactional() {
2868 let mut performance = ScratchPerformance::new(ScratchPreset::Orbit);
2869 performance
2870 .process_frame(performance_input(true, 1.0, 1.0, 0.5))
2871 .unwrap();
2872 let before = performance.snapshot();
2873 let mut invalid_input = performance_input(true, 1.0, 1.0, 0.5);
2874 invalid_input.rendered_record_rate = f64::NAN;
2875 assert_eq!(
2876 performance.process_frame(invalid_input),
2877 Err(ScratchPerformanceError::InvalidInput {
2878 field: "renderedRecordRate"
2879 })
2880 );
2881 assert_eq!(performance.snapshot(), before);
2882
2883 let mut excessive_rate = performance_input(true, 1.0, 1.0, 0.5);
2884 excessive_rate.rendered_record_rate = MAXIMUM_SCRATCH_RECORD_RATE + 0.001;
2885 assert_eq!(
2886 performance.process_frame(excessive_rate),
2887 Err(ScratchPerformanceError::InvalidInput {
2888 field: "renderedRecordRate"
2889 })
2890 );
2891 assert_eq!(performance.snapshot(), before);
2892
2893 let mut excessive_travel = performance_input(true, 1.0, 1.0, 0.5);
2894 excessive_travel.rendered_source_travel_seconds =
2895 (MAXIMUM_SCRATCH_RECORD_RATE + 0.001) * excessive_travel.delta_seconds;
2896 assert_eq!(
2897 performance.process_frame(excessive_travel),
2898 Err(ScratchPerformanceError::InvalidInput {
2899 field: "renderedSourceTravelSeconds"
2900 })
2901 );
2902 assert_eq!(performance.snapshot(), before);
2903
2904 let mut previous_performance_version = before;
2905 previous_performance_version.version = SCRATCH_PERFORMANCE_SNAPSHOT_VERSION - 1;
2906 assert_eq!(
2907 performance.restore(&previous_performance_version),
2908 Err(
2909 ScratchPerformanceError::UnsupportedPerformanceSnapshotVersion {
2910 version: SCRATCH_PERFORMANCE_SNAPSHOT_VERSION - 1
2911 }
2912 )
2913 );
2914 assert_eq!(performance.snapshot(), before);
2915
2916 let mut previous_gate_version = before;
2917 previous_gate_version.gate.version = SCRATCH_GATE_SNAPSHOT_VERSION - 1;
2918 assert_eq!(
2919 performance.restore(&previous_gate_version),
2920 Err(ScratchPerformanceError::UnsupportedGateSnapshotVersion {
2921 version: SCRATCH_GATE_SNAPSHOT_VERSION - 1
2922 })
2923 );
2924 assert_eq!(performance.snapshot(), before);
2925
2926 let mut invalid_snapshot = before.clone();
2927 invalid_snapshot.gate.state.phase = f64::NAN;
2928 assert_eq!(
2929 performance.restore(&invalid_snapshot),
2930 Err(ScratchPerformanceError::InvalidSnapshot { field: "phase" })
2931 );
2932 assert_eq!(performance.snapshot(), before);
2933 }
2934
2935 #[test]
2936 fn performance_snapshot_round_trips_through_json() {
2937 let mut performance = ScratchPerformance::new(ScratchPreset::Flare);
2938 for _ in 0..2_000 {
2939 performance
2940 .process_frame(performance_input(true, 0.8, 0.7, 0.3))
2941 .unwrap();
2942 }
2943 let snapshot = performance.snapshot();
2944 let json = serde_json::to_string(&snapshot).unwrap();
2945 let decoded: ScratchPerformanceSnapshot = serde_json::from_str(&json).unwrap();
2946 assert_eq!(decoded, snapshot);
2947 }
2948
2949 #[test]
2950 fn performance_render_path_does_not_allocate() {
2951 let mut performance = ScratchPerformance::new(ScratchPreset::Crab);
2952 performance.set_clicks(8);
2953 let input = performance_input(true, 8.0, 7.5, 0.25);
2954 assert_no_alloc::assert_no_alloc(|| {
2955 for _ in 0..10_000 {
2956 let output = performance.process_frame(input).unwrap();
2957 std::hint::black_box(output);
2958 }
2959 });
2960 }
2961
2962 #[test]
2963 fn manual_and_automatic_gain_steps_use_a_bounded_declick_envelope() {
2964 let maximum_alpha = 1.0 - (-(1.0 / SAMPLE_RATE) / 0.00035_f64).exp();
2965 for preset in [ScratchPreset::Baby, ScratchPreset::Transform] {
2966 let mut performance = ScratchPerformance::new(preset);
2967 let input = if preset == ScratchPreset::Baby {
2968 performance_input(true, 8.0, 8.0, 0.0)
2969 } else {
2970 performance_input(true, -8.0, -8.0, 1.0)
2971 };
2972 let before = performance.audible_gain();
2973 let after = performance.process_frame(input).unwrap().audible_gain;
2974 assert!(after <= before);
2975 assert!(before - after <= maximum_alpha + 1.0e-12);
2976 }
2977 }
2978}