Skip to main content

record_player/
scratch_gate.rs

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/// Intent-aware automatic crossfader patterns for common scratch techniques.
47///
48/// `Baby` leaves the stored manual crossfader in control. Every other preset
49/// owns the real audible crossfader through this gate. The host can still keep
50/// the manual position intact for an immediate return to `Baby`.
51#[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    /// Reports whether click count changes this technique's audible pattern.
118    pub const fn uses_clicks(self) -> bool {
119        matches!(
120            self,
121            Self::Transform | Self::Flare | Self::Crab | Self::Orbit
122        )
123    }
124
125    /// Returns the provisional stroke span in source seconds.
126    ///
127    /// Prediction confidence remains zero until one stroke completes.
128    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/// Stable catalog data for native and Web hosts.
152#[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/// Audio-rate, travel-based scratch crossfader gate.
219///
220/// `intent_rate` should be the filtered/target hand rate and
221/// `rendered_rate` should be the rate actually used to advance audible PCM.
222/// Direction changes use intent for responsiveness and rendered motion as a
223/// fallback, while pattern phase follows audible travel in the confirmed
224/// stroke direction.
225#[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/// Stores all automatic gate state that can affect later audio frames.
256#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
257#[serde(rename_all = "camelCase")]
258pub struct ScratchGateSnapshot {
259    version: u32,
260    state: ScratchGate,
261}
262
263/// Identifies which control owns the audible deck gain.
264#[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/// Supplies one same-sample physical motion result to the scratch helper.
273///
274/// Both rates use nominal playback speed as one. `rendered_record_rate` must
275/// come from the physical deck state for the audio frame being gated.
276/// `rendered_source_travel_seconds` is the exact signed record-angle change
277/// divided by nominal angular velocity.
278#[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/// Reports the exact gain and motion state for one audio frame.
340#[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/// Stores all helper state that can affect later audible gain.
357#[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/// Canonical allocation-free scratch helper for physical player consumers.
366#[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    /// Reports the current output state without advancing the helper.
415    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    /// Processes one frame after the physical mechanics step for that frame.
432    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    /// Restores only after the complete snapshot passes validation.
463    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    /// Retains source compatibility while legacy consumers migrate.
575    ///
576    /// The canonical gate does not emulate replaced algorithms.
577    pub fn set_algorithm_version(&mut self, _version: u32) {}
578
579    /// Starts a recorded performance from one defined gate state while
580    /// retaining its selected technique and click count.
581    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    /// Returns the estimate for the active direction.
617    ///
618    /// Before motion starts, this method returns the forward seed.
619    pub fn learned_span(&self) -> f64 {
620        self.learned_span_for_direction(self.direction)
621    }
622
623    /// Returns one direction's learned source-travel span.
624    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    /// Returns confidence for the active direction.
633    ///
634    /// Before motion starts, this method returns forward confidence.
635    pub fn span_prediction_confidence(&self) -> f64 {
636        self.span_prediction_confidence_for_direction(self.direction)
637    }
638
639    /// Returns zero before the first direction-specific observation.
640    ///
641    /// Confidence reaches one after four observations in that direction.
642    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    /// Restores only after the complete gate state passes validation.
660    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    /// Marks the record as released immediately. The target opens at once;
675    /// the returned gain still reaches it through the de-click envelope.
676    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    /// Advances the gate by one audio frame and returns its de-clicked gain.
685    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        // The physical deck rate can contain solver-scale velocity changes.
1061        // This time-based filter prevents sample-rate-dependent attacks.
1062        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        // Count exactly one learned stroke span. The phrase repeats once
1318        // travel passes the span, so frames beyond it start a fresh run.
1319        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}