Skip to main content

record_player/
acoustic.rs

1use js_sys::{Array, Float32Array};
2use serde::{Deserialize, Serialize};
3use std::sync::Arc;
4
5use crate::vinyl_vfx::{VinylVfxContext, VinylVfxProcessor, VINYL_VFX_MAX_SCENE};
6use wasm_bindgen::prelude::*;
7
8use crate::{
9    mechanics::{
10        DeckMechanicalControl, DeckMechanicalError, DeckMechanicalState, DeckMechanicalTelemetry,
11        MotorMode, NormalizedDeckControl, PhysicalDeckConfig,
12    },
13    mixer::{sharp_crossfader_gains, DEFAULT_SHARP_CROSSFADER_WIDTH},
14    resampler::adaptive_sample,
15    scratch_gate::{ScratchGate, ScratchPreset},
16};
17
18const OUTPUT_GAIN: f64 = 1.0;
19const MAX_FINAL_OUTPUT_GAIN: f64 = 4.0;
20const MAX_FINAL_OUTPUT_GAIN_RAMP_MS: f64 = 60_000.0;
21const POSITION_CATCHUP_SECONDS: f64 = 0.28;
22const MOTION_HOLD_SECONDS: f64 = 0.05;
23const MOTION_HOLD_RELEASE_SECONDS: f64 = 0.06;
24// A landing finger develops its force in single-digit milliseconds; a
25// 12 ms attack was the floor under every live catch, gating the hand's
26// weight no matter how hard it pressed.
27const GRIP_ATTACK_SECONDS: f64 = 0.004;
28const GRIP_RELEASE_SECONDS: f64 = 0.045;
29/// Below this residual force a released hand counts as fully separated.
30const GRIP_CONTACT_EPSILON: f64 = 0.02;
31/// A lifting finger's normal force collapses over this span.
32const HAND_RELEASE_SECONDS: f64 = 0.008;
33/// The stylus fades over this span approaching a pinned record edge so a
34/// clamped scratch cannot hold a full-level frozen sample.
35const EDGE_FADE_SECONDS: f64 = 0.01;
36/// The stop gain follows the rate through this smoothing so a hard catch
37/// cannot step the output in one sample. Well under the mechanical
38/// reversal time, so it adds no feelable latency.
39const MOVEMENT_GAIN_SECONDS: f64 = 0.003;
40const GRIP_OWNERSHIP: f64 = 0.5;
41const DEADZONE_RATE: f64 = 0.006;
42/// Full music gain is reached at 2% of nominal speed, not 10%. A cartridge
43/// outputs full-spectrum signal at slow groove velocity (pitched down into
44/// bass — the dub body), going silent only at true standstill. The old 10%
45/// knee muted exactly the gentle-motion region, chopping every turnaround of
46/// a slow scratch. Velocity-responsive: full body for any real motion, taper
47/// only into the deadzone at rest.
48const STOP_GAIN_FULL_RATE: f64 = 0.02;
49/// RIAA time constants (IEC 60098): 3180 us, 318 us, 75 us.
50const RIAA_T1_SECONDS: f64 = 3180.0e-6;
51const RIAA_T2_SECONDS: f64 = 318.0e-6;
52const RIAA_T3_SECONDS: f64 = 75.0e-6;
53/// The speed tilt's high-frequency asymptote is 1/rate, so the rate that
54/// shapes it is held above a floor. Its partner, the cartridge velocity gain,
55/// is `rate`, so the pair's product stays bounded at unity.
56const RIAA_TILT_MIN_RATE: f64 = 0.1;
57const RIAA_TILT_MAX_RATE: f64 = 4.0;
58/// A cartridge really does put out more voltage the faster the groove passes,
59/// without limit. Bound it so a runaway rate cannot blow up the programme.
60const MAX_CARTRIDGE_VELOCITY_GAIN: f64 = 4.0;
61/// Opt-in vinyl voicing seed: a fixed, deliberately non-RIAA curve blended
62/// over the transparent master. A matched cut/playback RIAA pair is exactly
63/// identity, so warmth cannot come from the standard curve. What is left is
64/// the parts that do not cancel — the cartridge and arm losing the top end,
65/// and a real preamp departing from the textbook curve. These are seed
66/// values, not a calibrated hardware profile.
67///
68/// `AcousticConfig.vinyl_voicing_curve` selects one of these shapes. Each is
69/// a real phono-chain mechanism, not an arbitrary EQ:
70#[derive(Clone, Copy, Debug, PartialEq)]
71struct VinylVoicingCurve {
72    /// The cartridge/arm mechanical top-end loss.
73    cartridge_hz: f64,
74    cartridge_q: f64,
75    /// The preamp's departure from flat below the mids.
76    low_hz: f64,
77    low_db: f64,
78    /// The preamp's departure from flat above the mids.
79    high_hz: f64,
80    high_db: f64,
81    shelf_q: f64,
82}
83
84const VINYL_VOICING_CURVES: [VinylVoicingCurve; 3] = [
85    // COIL LOAD: a moving-magnet cartridge's inductance loaded by the cable's
86    // capacitance — a broad top-end shelf with a little body under it. The
87    // first pass was too gentle to hear; these are the characters, not a
88    // subtle shelf, and the ACOUSTICS sliders exist for precise tuning.
89    VinylVoicingCurve {
90        cartridge_hz: 13_000.0,
91        cartridge_q: 0.6,
92        low_hz: 120.0,
93        low_db: 7.0,
94        high_hz: 4_000.0,
95        high_db: -8.0,
96        shelf_q: 0.707,
97    },
98    // TIP MASS: the stylus's own mass and compliance, which mostly costs the
99    // extreme top and leaves the body nearly alone.
100    VinylVoicingCurve {
101        cartridge_hz: 10_000.0,
102        cartridge_q: 0.5,
103        low_hz: 80.0,
104        low_db: 3.0,
105        high_hz: 5_000.0,
106        high_db: -10.0,
107        shelf_q: 0.707,
108    },
109    // CURVE DRIFT: a preamp whose feedback network departed from the RIAA
110    // curve — a low-mid lift and a broad presence dip, no cartridge pole.
111    VinylVoicingCurve {
112        cartridge_hz: 20_000.0,
113        cartridge_q: 0.707,
114        low_hz: 350.0,
115        low_db: 6.0,
116        high_hz: 2_500.0,
117        high_db: -5.0,
118        shelf_q: 0.707,
119    },
120];
121const DRAG_LOWPASS_MAX_HZ: f64 = 19_000.0;
122const DRAG_LOWPASS_RATE_KNEE: f64 = 0.95;
123const TRACING_LOSS_START_RATE: f64 = 2.5;
124const STYLUS_TRACING_CURVATURE_THRESHOLD: f64 = 0.65;
125const STYLUS_TRACING_CURVATURE_FULL_SCALE: f64 = 3.0;
126const PROGRAMME_UPPER_CROSSOVER_HZ: f64 = 5_200.0;
127const PROGRAMME_ACCELERATION_THRESHOLD: f64 = 0.18;
128const PROGRAMME_ACCELERATION_FULL_SCALE: f64 = 0.85;
129const PROGRAMME_DIRECTION_CHANGE_WEIGHT: f64 = 0.65;
130const PROGRAMME_LIMITER_MIN_UPPER_GAIN: f64 = 0.16;
131const PROGRAMME_LIMITER_ATTACK_SECONDS: f64 = 0.00012;
132const PROGRAMME_LIMITER_RELEASE_SECONDS: f64 = 0.032;
133const WOW_REV_SECONDS: f64 = 1.8;
134const FLUTTER_HZ: f64 = 6.4;
135const FREE_PLAYBACK_WOW_DEPTH: f64 = 0.000_24;
136const HAND_SLIP_WOW_DEPTH: f64 = 0.000_18;
137const CONTACT_NOISE_GAIN: f64 = 0.00008;
138const SOURCE_TEXTURE_GAIN: f64 = 0.00018;
139const DUST_FLECK_GAIN: f64 = 0.000045;
140const CONTACT_IMPULSE_DECAY: f64 = 0.985;
141const WINDOW_REQUEST_MARGIN_SECONDS: f64 = 0.75;
142const WINDOW_REQUEST_PROJECT_SECONDS: f64 = 0.18;
143const WINDOW_MISS_FADE_SECONDS: f64 = 0.006;
144const MOMENTARY_CROSSFADER_TRANSITION_SECONDS: f64 = 0.00045;
145const PROGRAMME_END_POSITION_EPSILON_FRAMES: f64 = 1.0e-7;
146const DEFAULT_REPLAY_NOISE_SEED: u32 = 0x9e37_79b9;
147/// Matches EnCodec's fixed-context seam repair: twelve samples on either
148/// side of a join are replaced by one cubic Hermite bridge.
149const SEAM_REPAIR_SAMPLES: usize = 24;
150const LOOSE_SLIPMAT_COUPLING_SCALE: f64 = 0.65;
151const TIGHT_SLIPMAT_COUPLING_SCALE: f64 = 2.0;
152
153// Needle-surface bed and needle-drop foley (original: player.js 3915–4249).
154const LEAD_IN_STATIC_GAIN: f64 = 0.048;
155const DEADWAX_STATIC_GAIN: f64 = 0.052;
156const NEEDLE_SURFACE_SAMPLE_PAD_SECONDS: f64 = 0.05;
157const SURFACE_BED_ATTACK_SECONDS: f64 = 0.08;
158const SURFACE_BED_RELEASE_SECONDS: f64 = 0.16;
159const SURFACE_ENV_FLOOR: f64 = 0.0001;
160const NEEDLE_DROP_BURST_SECONDS: f64 = 0.34;
161const NEEDLE_DROP_BURST_FILTER_HZ: f64 = 6200.0;
162const NEEDLE_DROP_BURST_FILTER_Q: f64 = 0.5;
163const NEEDLE_DROP_THUMP_GAIN: f64 = 0.045;
164const NEEDLE_LIFT_THUMP_GAIN: f64 = 0.022;
165pub const SURFACE_REGION_LEAD_IN: u8 = 0;
166pub const SURFACE_REGION_DEADWAX: u8 = 1;
167
168// RBJ lowpass biquad — matches the Web Audio BiquadFilterNode "lowpass" response.
169#[derive(Clone, Copy, Debug, Default)]
170struct BiquadLowpass {
171    b0: f64,
172    b1: f64,
173    b2: f64,
174    a1: f64,
175    a2: f64,
176    x1: f64,
177    x2: f64,
178    y1: f64,
179    y2: f64,
180}
181
182impl BiquadLowpass {
183    fn new(cutoff_hz: f64, q: f64, sample_rate: f64) -> Self {
184        let w0 = std::f64::consts::TAU * (cutoff_hz / sample_rate).clamp(0.0, 0.5);
185        let alpha = w0.sin() / (2.0 * q.max(1e-4));
186        let cos_w0 = w0.cos();
187        let a0 = 1.0 + alpha;
188        Self {
189            b0: ((1.0 - cos_w0) / 2.0) / a0,
190            b1: (1.0 - cos_w0) / a0,
191            b2: ((1.0 - cos_w0) / 2.0) / a0,
192            a1: (-2.0 * cos_w0) / a0,
193            a2: (1.0 - alpha) / a0,
194            ..Default::default()
195        }
196    }
197
198    fn process(&mut self, x: f64) -> f64 {
199        let y = self.b0 * x + self.b1 * self.x1 + self.b2 * self.x2
200            - self.a1 * self.y1
201            - self.a2 * self.y2;
202        self.x2 = self.x1;
203        self.x1 = x;
204        self.y2 = self.y1;
205        self.y1 = y;
206        y
207    }
208}
209
210/// Stereo-linked limiter for physically demanding programme upper-band motion.
211///
212/// The speed-dependent half of a phono chain.
213///
214/// A lacquer is cut with RIAA pre-emphasis `P` and played back through the
215/// preamp's fixed de-emphasis `D = 1/P`. At nominal speed the two cancel
216/// exactly and the master comes back untouched. Off speed they no longer
217/// cancel: the groove's content shifts in frequency by the play rate while
218/// the preamp's curve stays where it is, so what comes out carries a genuine
219/// speed-dependent tilt
220///
221/// ```text
222///     T(f) = D(f) / D(f/r)
223/// ```
224///
225/// where `D(s) = (1 + s*T2) / ((1 + s*T1)(1 + s*T3))`. Expanding the ratio
226/// gives three first-order sections, each a zero over a pole:
227///
228/// ```text
229///     (1 + s*T2)   (1 + s*T1/r)   (1 + s*T3/r)
230///     ---------- * ------------ * ------------
231///     (1 + s*T2/r) (1 + s*T1)     (1 + s*T3)
232/// ```
233///
234/// This is reproduction, not colour. At `r == 1` every section has its zero
235/// on its pole, so the response is exactly unity and a settled filter passes
236/// the programme through bit-exact — the transparent-master rule holds. The
237/// tilt exists only while the record is off speed, which is the whole point:
238/// a scratched record genuinely does not read the same spectrum as a record
239/// running at 33.
240///
241/// Its high-frequency asymptote is `1/r`, which pairs with the cartridge
242/// velocity gain of `r` to leave presence roughly intact while the bass
243/// scales with speed — slow strokes read thin and quiet, fast strokes read
244/// loud and full, as a real deck does.
245#[derive(Clone, Debug, PartialEq)]
246struct RiaaSpeedTilt {
247    /// `(b0, b1, a1)` per section.
248    sections: [(f64, f64, f64); 3],
249    /// `(x[n-1], y[n-1])` per section, per channel.
250    state: [[(f64, f64); 3]; 2],
251    rate: f64,
252    /// The rate the coefficients are actually built from. `T(f)` is derived
253    /// for a record held at a steady speed, so it is applied quasi-statically
254    /// and its control is eased rather than snapped. Without that, a reversal
255    /// restructures a resonant filter sample by sample and the modulation
256    /// itself lands in the programme as a step.
257    control_rate: f64,
258    sample_rate: f64,
259}
260
261impl RiaaSpeedTilt {
262    fn new(sample_rate: f64) -> Self {
263        let mut tilt = Self {
264            sections: [(1.0, 0.0, 0.0); 3],
265            state: [[(0.0, 0.0); 3]; 2],
266            rate: f64::NAN,
267            control_rate: f64::NAN,
268            sample_rate: if sample_rate.is_finite() && sample_rate > 0.0 {
269                sample_rate
270            } else {
271                48_000.0
272            },
273        };
274        tilt.set_rate(1.0);
275        tilt
276    }
277
278    fn reset(&mut self) {
279        self.state = [[(0.0, 0.0); 3]; 2];
280        self.control_rate = f64::NAN;
281    }
282
283    /// Ease the control toward the record's actual rate. A deck seeded at
284    /// speed starts converged, so steady playback is transparent immediately.
285    fn follow_rate(&mut self, abs_rate: f64, alpha: f64) {
286        let target = finite_or_zero(abs_rate).abs();
287        if self.control_rate.is_nan() {
288            self.control_rate = target;
289        } else {
290            self.control_rate += (target - self.control_rate) * alpha;
291            if (self.control_rate - target).abs() < 1.0e-6 {
292                self.control_rate = target;
293            }
294        }
295        self.set_rate(self.control_rate);
296    }
297
298    /// Bilinear transform of `(1 + s*zero) / (1 + s*pole)`.
299    fn first_order(zero_seconds: f64, pole_seconds: f64, k: f64) -> (f64, f64, f64) {
300        let zero = zero_seconds * k;
301        let pole = pole_seconds * k;
302        let denominator = 1.0 + pole;
303        (
304            (1.0 + zero) / denominator,
305            (1.0 - zero) / denominator,
306            (1.0 - pole) / denominator,
307        )
308    }
309
310    fn set_rate(&mut self, rate: f64) {
311        let rate = finite_or_zero(rate)
312            .abs()
313            .clamp(RIAA_TILT_MIN_RATE, RIAA_TILT_MAX_RATE);
314        // Coefficients only move when the rate does. Steady playback recomputes
315        // nothing, and a scratch resolves at whatever resolution it moves with.
316        if (rate - self.rate).abs() < 1.0e-9 {
317            return;
318        }
319        self.rate = rate;
320        let k = 2.0 * self.sample_rate;
321        self.sections = [
322            Self::first_order(RIAA_T2_SECONDS, RIAA_T2_SECONDS / rate, k),
323            Self::first_order(RIAA_T1_SECONDS / rate, RIAA_T1_SECONDS, k),
324            Self::first_order(RIAA_T3_SECONDS / rate, RIAA_T3_SECONDS, k),
325        ];
326    }
327
328    fn process(&mut self, channel: usize, sample: f64) -> f64 {
329        let Some(state) = self.state.get_mut(channel) else {
330            return sample;
331        };
332        let mut value = sample;
333        for (section, memory) in self.sections.iter().zip(state.iter_mut()) {
334            let (b0, b1, a1) = *section;
335            let (previous_input, previous_output) = *memory;
336            // The two state terms are summed with each other before they
337            // reach the input term. At nominal speed a section's zero sits on
338            // its pole, so `b1 == a1` and the pair cancels to exactly zero,
339            // leaving `b0 * value` with `b0 == 1.0` — bit-exact transparency.
340            // Adding the input first would round that cancellation away.
341            let output = b0 * value + (b1 * previous_input - a1 * previous_output);
342            *memory = (value, output);
343            value = output;
344        }
345        finite_or_zero(value)
346    }
347}
348
349/// A direct-form-I RBJ biquad, used only by the opt-in voicing stage. The
350/// coefficients come from the Audio EQ Cookbook forms so the seed curve is a
351/// plain, inspectable filter rather than a fitted table.
352#[derive(Clone, Copy, Debug, Default)]
353struct VoicingBiquad {
354    b0: f64,
355    b1: f64,
356    b2: f64,
357    a1: f64,
358    a2: f64,
359    x1: f64,
360    x2: f64,
361    y1: f64,
362    y2: f64,
363}
364
365impl VoicingBiquad {
366    fn from_coefficients(b0: f64, b1: f64, b2: f64, a0: f64, a1: f64, a2: f64) -> Self {
367        Self {
368            b0: b0 / a0,
369            b1: b1 / a0,
370            b2: b2 / a0,
371            a1: a1 / a0,
372            a2: a2 / a0,
373            ..Default::default()
374        }
375    }
376
377    fn lowpass(cutoff_hz: f64, q: f64, sample_rate: f64) -> Self {
378        let w0 = std::f64::consts::TAU * (cutoff_hz / sample_rate).clamp(0.0, 0.5);
379        let cos_w0 = w0.cos();
380        let alpha = w0.sin() / (2.0 * q.max(1e-4));
381        Self::from_coefficients(
382            (1.0 - cos_w0) / 2.0,
383            1.0 - cos_w0,
384            (1.0 - cos_w0) / 2.0,
385            1.0 + alpha,
386            -2.0 * cos_w0,
387            1.0 - alpha,
388        )
389    }
390
391    fn low_shelf(freq_hz: f64, q: f64, gain_db: f64, sample_rate: f64) -> Self {
392        let a = 10.0_f64.powf(gain_db / 40.0);
393        let w0 = std::f64::consts::TAU * (freq_hz / sample_rate).clamp(0.0, 0.5);
394        let cos_w0 = w0.cos();
395        let alpha = w0.sin() / (2.0 * q.max(1e-4));
396        let root = 2.0 * a.sqrt() * alpha;
397        Self::from_coefficients(
398            a * ((a + 1.0) - (a - 1.0) * cos_w0 + root),
399            2.0 * a * ((a - 1.0) - (a + 1.0) * cos_w0),
400            a * ((a + 1.0) - (a - 1.0) * cos_w0 - root),
401            (a + 1.0) + (a - 1.0) * cos_w0 + root,
402            -2.0 * ((a - 1.0) + (a + 1.0) * cos_w0),
403            (a + 1.0) + (a - 1.0) * cos_w0 - root,
404        )
405    }
406
407    fn high_shelf(freq_hz: f64, q: f64, gain_db: f64, sample_rate: f64) -> Self {
408        let a = 10.0_f64.powf(gain_db / 40.0);
409        let w0 = std::f64::consts::TAU * (freq_hz / sample_rate).clamp(0.0, 0.5);
410        let cos_w0 = w0.cos();
411        let alpha = w0.sin() / (2.0 * q.max(1e-4));
412        let root = 2.0 * a.sqrt() * alpha;
413        Self::from_coefficients(
414            a * ((a + 1.0) + (a - 1.0) * cos_w0 + root),
415            -2.0 * a * ((a - 1.0) + (a + 1.0) * cos_w0),
416            a * ((a + 1.0) + (a - 1.0) * cos_w0 - root),
417            (a + 1.0) - (a - 1.0) * cos_w0 + root,
418            2.0 * ((a - 1.0) - (a + 1.0) * cos_w0),
419            (a + 1.0) - (a - 1.0) * cos_w0 - root,
420        )
421    }
422
423    fn process(&mut self, x: f64) -> f64 {
424        let y = self.b0 * x + self.b1 * self.x1 + self.b2 * self.x2
425            - self.a1 * self.y1
426            - self.a2 * self.y2;
427        self.x2 = self.x1;
428        self.x1 = x;
429        self.y2 = self.y1;
430        self.y1 = y;
431        y
432    }
433
434    fn reset(&mut self) {
435        self.x1 = 0.0;
436        self.x2 = 0.0;
437        self.y1 = 0.0;
438        self.y2 = 0.0;
439    }
440
441    /// New coefficients over the existing delay state, so changing curve does
442    /// not zero a running filter and click.
443    fn retuned(mut self, previous: &Self) -> Self {
444        self.x1 = previous.x1;
445        self.x2 = previous.x2;
446        self.y1 = previous.y1;
447        self.y2 = previous.y2;
448        self
449    }
450}
451
452/// The opt-in "vinyl voicing" seed curve: the non-cancelling half of a real
453/// phono chain. A matched RIAA cut/playback pair is exactly the identity, so
454/// the audible character of a record is not the standard curve — it is the
455/// mismatch left after it. One inspectable seed is blended over the
456/// transparent master by `amount`. At `amount == 0` the stage adds no
457/// arithmetic to the signal. The shape is one of [`VINYL_VOICING_CURVES`].
458#[derive(Clone, Debug)]
459struct VinylVoicingFilter {
460    curve: usize,
461    sample_rate: f64,
462    cartridge: [VoicingBiquad; 2],
463    low_shelf: [VoicingBiquad; 2],
464    high_shelf: [VoicingBiquad; 2],
465}
466
467impl VinylVoicingFilter {
468    fn new(sample_rate: f64) -> Self {
469        let sample_rate = if sample_rate.is_finite() && sample_rate > 0.0 {
470            sample_rate
471        } else {
472            48_000.0
473        };
474        let mut filter = Self {
475            curve: usize::MAX,
476            sample_rate,
477            cartridge: [VoicingBiquad::default(); 2],
478            low_shelf: [VoicingBiquad::default(); 2],
479            high_shelf: [VoicingBiquad::default(); 2],
480        };
481        filter.set_curve(0);
482        filter
483    }
484
485    fn curve(&self) -> usize {
486        self.curve
487    }
488
489    /// Rebuild the coefficients for one of [`VINYL_VOICING_CURVES`], keeping
490    /// the running delay so a curve change does not click. Unknown indices
491    /// clamp to the last curve; the host validates before it gets here.
492    fn set_curve(&mut self, curve: usize) {
493        let curve = curve.min(VINYL_VOICING_CURVES.len() - 1);
494        if curve == self.curve {
495            return;
496        }
497        self.curve = curve;
498        let spec = VINYL_VOICING_CURVES[curve];
499        for channel in 0..2 {
500            let previous = self.cartridge[channel];
501            self.cartridge[channel] = VoicingBiquad::lowpass(
502                spec.cartridge_hz,
503                spec.cartridge_q,
504                self.sample_rate,
505            )
506            .retuned(&previous);
507            let previous = self.low_shelf[channel];
508            self.low_shelf[channel] = VoicingBiquad::low_shelf(
509                spec.low_hz,
510                spec.shelf_q,
511                spec.low_db,
512                self.sample_rate,
513            )
514            .retuned(&previous);
515            let previous = self.high_shelf[channel];
516            self.high_shelf[channel] = VoicingBiquad::high_shelf(
517                spec.high_hz,
518                spec.shelf_q,
519                spec.high_db,
520                self.sample_rate,
521            )
522            .retuned(&previous);
523        }
524    }
525
526    fn reset(&mut self) {
527        for filter in self
528            .cartridge
529            .iter_mut()
530            .chain(self.low_shelf.iter_mut())
531            .chain(self.high_shelf.iter_mut())
532        {
533            filter.reset();
534        }
535    }
536
537    /// Blend the fixed curve over the dry signal. `amount == 0` returns the
538    /// input unchanged for any finite `wet`, so the default path is bit-exact.
539    fn process(&mut self, channel: usize, sample: f64, amount: f64) -> f64 {
540        if channel >= 2 {
541            return sample;
542        }
543        let dry = sample;
544        let cartridge = self.cartridge[channel].process(dry);
545        let body = self.low_shelf[channel].process(cartridge);
546        let wet = self.high_shelf[channel].process(body);
547        dry + amount * (wet - dry)
548    }
549}
550
551/// A one-pole low-pass and its exact residual form a complementary split. The
552/// shared envelope only scales that residual; the base band is never run
553/// through a blanket low-pass or full-band gain stage.
554#[derive(Clone, Debug, PartialEq)]
555struct HighFrequencyAccelerationLimiter {
556    lowpass: [f64; 2],
557    previous_upper: [f64; 2],
558    previous_velocity: [f64; 2],
559    initialized: [bool; 2],
560    linked_gain: f64,
561    coefficient_sample_rate: f64,
562    split_alpha: f64,
563    attack_alpha: f64,
564    release_alpha: f64,
565    first_derivative_scale: f64,
566    second_derivative_scale: f64,
567}
568
569impl Default for HighFrequencyAccelerationLimiter {
570    fn default() -> Self {
571        Self {
572            lowpass: [0.0; 2],
573            previous_upper: [0.0; 2],
574            previous_velocity: [0.0; 2],
575            initialized: [false; 2],
576            linked_gain: 1.0,
577            coefficient_sample_rate: 0.0,
578            split_alpha: 1.0,
579            attack_alpha: 1.0,
580            release_alpha: 1.0,
581            first_derivative_scale: 1.0,
582            second_derivative_scale: 1.0,
583        }
584    }
585}
586
587impl HighFrequencyAccelerationLimiter {
588    fn reset(&mut self) {
589        *self = Self::default();
590    }
591
592    fn process_frame(
593        &mut self,
594        samples: [f64; 2],
595        channel_count: usize,
596        sample_rate: f64,
597        strength: f64,
598    ) -> [f64; 2] {
599        let channel_count = channel_count.clamp(1, 2);
600        let sample_rate = if sample_rate.is_finite() && sample_rate > 0.0 {
601            sample_rate
602        } else {
603            48_000.0
604        };
605        self.prepare_sample_rate(sample_rate);
606        let strength = finite_or_zero(strength).clamp(0.0, 1.0);
607        let mut base = samples;
608        let mut upper = [0.0; 2];
609        let mut linked_demand = 0.0_f64;
610
611        for channel in 0..channel_count {
612            if !self.initialized[channel] {
613                self.lowpass[channel] = samples[channel];
614                self.previous_upper[channel] = 0.0;
615                self.previous_velocity[channel] = 0.0;
616                self.initialized[channel] = true;
617                continue;
618            }
619
620            self.lowpass[channel] += (samples[channel] - self.lowpass[channel]) * self.split_alpha;
621            base[channel] = self.lowpass[channel];
622            upper[channel] = samples[channel] - base[channel];
623            let velocity = upper[channel] - self.previous_upper[channel];
624            let acceleration = velocity - self.previous_velocity[channel];
625            let direction_change = if velocity * self.previous_velocity[channel] < 0.0 {
626                velocity.abs().min(self.previous_velocity[channel].abs())
627            } else {
628                0.0
629            };
630            let demand = acceleration.abs() * self.second_derivative_scale
631                + direction_change
632                    * self.first_derivative_scale
633                    * PROGRAMME_DIRECTION_CHANGE_WEIGHT;
634            linked_demand = linked_demand.max(demand);
635            self.previous_upper[channel] = upper[channel];
636            self.previous_velocity[channel] = velocity;
637        }
638        for channel in channel_count..2 {
639            self.initialized[channel] = false;
640            self.lowpass[channel] = 0.0;
641            self.previous_upper[channel] = 0.0;
642            self.previous_velocity[channel] = 0.0;
643        }
644
645        if strength <= 0.0 {
646            self.linked_gain = 1.0;
647            return samples;
648        }
649
650        let overload = smoothstep_unit(
651            (linked_demand - PROGRAMME_ACCELERATION_THRESHOLD)
652                / (PROGRAMME_ACCELERATION_FULL_SCALE - PROGRAMME_ACCELERATION_THRESHOLD),
653        );
654        let target_gain = 1.0 - strength * overload * (1.0 - PROGRAMME_LIMITER_MIN_UPPER_GAIN);
655        let envelope_alpha = if target_gain < self.linked_gain {
656            self.attack_alpha
657        } else {
658            self.release_alpha
659        };
660        self.linked_gain = (self.linked_gain + (target_gain - self.linked_gain) * envelope_alpha)
661            .clamp(PROGRAMME_LIMITER_MIN_UPPER_GAIN, 1.0);
662
663        let mut output = samples;
664        for channel in 0..channel_count {
665            output[channel] = base[channel] + upper[channel] * self.linked_gain;
666        }
667        output
668    }
669
670    fn prepare_sample_rate(&mut self, sample_rate: f64) {
671        if self.coefficient_sample_rate == sample_rate {
672            return;
673        }
674        self.coefficient_sample_rate = sample_rate;
675        self.split_alpha =
676            1.0 - (-std::f64::consts::TAU * PROGRAMME_UPPER_CROSSOVER_HZ / sample_rate).exp();
677        self.attack_alpha = 1.0 - (-1.0 / (sample_rate * PROGRAMME_LIMITER_ATTACK_SECONDS)).exp();
678        self.release_alpha = 1.0 - (-1.0 / (sample_rate * PROGRAMME_LIMITER_RELEASE_SECONDS)).exp();
679        self.first_derivative_scale = sample_rate / 48_000.0;
680        self.second_derivative_scale = self.first_derivative_scale * self.first_derivative_scale;
681    }
682}
683
684// Continuous needle-surface bed for lead-in / deadwax traversal.
685#[derive(Clone, Debug)]
686struct SurfaceBed {
687    region: u8,
688    position: f64,
689    looping: bool,
690    elapsed_frames: f64,
691    duration_seconds: f64,
692    gain: f64,
693    filters: [BiquadLowpass; 2],
694}
695
696// One-shot stylus thump: sine 130 Hz → exp → 52 Hz over 70 ms, exp gain envelope.
697#[derive(Clone, Copy, Debug)]
698struct NeedleThump {
699    elapsed_seconds: f64,
700    phase: f64,
701    gain: f64,
702}
703
704// One-shot crackle burst from the surface asset as the stylus settles.
705#[derive(Clone, Debug)]
706struct SurfaceBurst {
707    position: f64,
708    elapsed_frames: f64,
709    peak: f64,
710    filters: [BiquadLowpass; 2],
711}
712
713#[derive(Clone, Copy, Debug, Deserialize)]
714#[serde(rename_all = "camelCase")]
715pub struct AcousticConfig {
716    #[serde(default = "default_max_rate")]
717    pub max_rate: f64,
718    #[serde(default = "default_wow_rev_seconds")]
719    pub wow_rev_seconds: f64,
720    #[serde(default = "default_flutter_hz")]
721    pub flutter_hz: f64,
722    #[serde(default)]
723    pub acoustic_enabled: bool,
724    #[serde(default)]
725    pub surface_enabled: bool,
726    /// Soft cartridge tracing limit derived from source curvature and travel
727    /// velocity. This preserves the existing speed-dependent stylus model.
728    #[serde(default = "default_stylus_tracing_limit")]
729    pub stylus_tracing_limit: f64,
730    /// Stereo-linked upper-band programme acceleration limiter. `0` bypasses
731    /// it exactly; `1` applies the full soft-knee reduction.
732    #[serde(default = "default_high_frequency_acceleration_limit")]
733    pub high_frequency_acceleration_limit: f64,
734    /// Scales the scratch-excited friction terms as a master: contact noise
735    /// (with its acceleration lift), needle-drop impulse noise, and the
736    /// slope/curvature source texture. `1` is the historical level, bit for
737    /// bit. Dust, groove position noise and wear crackle are untouched by it.
738    #[serde(default = "default_texture_scale")]
739    pub texture_scale: f64,
740    /// Per-component levels on the surface bed and the source texture, each
741    /// `1` at the historical level so a default changes no sample. They trim
742    /// what `texture_scale` carries as a master, and each has its own slider
743    /// in ACOUSTICS: the contact roar, the dust flecks, the needle-drop
744    /// impulse, the wear crackle, and the texture the source's own shape
745    /// makes.
746    #[serde(default = "default_texture_scale")]
747    pub contact_gain: f64,
748    #[serde(default = "default_texture_scale")]
749    pub dust_gain: f64,
750    #[serde(default = "default_texture_scale")]
751    pub impulse_gain: f64,
752    #[serde(default = "default_texture_scale")]
753    pub wear_gain: f64,
754    #[serde(default = "default_texture_scale")]
755    pub source_texture_gain: f64,
756    /// Replaces the final hard clamp with a tanh saturator. `false` keeps
757    /// the historical digital clamp, bit for bit. `tanh` has unity slope at
758    /// silence, so small signals render identically and only would-be-clipped
759    /// peaks fold over — the mechanical saturation a real groove has and a
760    /// clamp does not.
761    #[serde(default)]
762    pub soft_clip: bool,
763    /// A magnetic cartridge is a velocity transducer: its output is
764    /// proportional to how fast the groove passes the stylus, so playing at
765    /// rate `r` yields `r * m(r*t)`. The rate factor is the whole law. It is
766    /// exactly 1 at nominal speed, and it reaches silence continuously at
767    /// rest, which is why a stopped record is silent — no stop knee needed.
768    #[serde(default = "default_true")]
769    pub cartridge_velocity_gain: bool,
770    /// The speed-dependent half of the phono chain: a groove cut with RIAA
771    /// pre-emphasis and replayed off speed no longer cancels the preamp's
772    /// fixed de-emphasis. See [`RiaaSpeedTilt`]. Physically the partner of
773    /// `cartridge_velocity_gain`; the two are meant to run together.
774    #[serde(default = "default_true")]
775    pub riaa_speed_tilt: bool,
776    /// A fixed, deliberate mismatch of the RIAA pair: the same pre-emphasis /
777    /// de-emphasis residue as [`RiaaSpeedTilt`], but held at a constant,
778    /// caller-chosen rate rather than following the record. `1.0` is exactly
779    /// the standard curve and is bit-exact transparent. Above `1.0` it trades
780    /// top end for body — the warmth a matched cut and playback cannot
781    /// otherwise produce. Off by default; a seed, not a calibrated hardware
782    /// profile.
783    #[serde(default = "default_riaa_voicing_rate")]
784    pub riaa_voicing_rate: f64,
785    /// Opt-in vinyl voicing amount in `[0, 1]`: blends a fixed seed curve
786    /// (a low-end preamp shelf plus cartridge/arm top-end loss) over the
787    /// transparent master. `0` is bypassed bit-exactly. The full amount is a
788    /// clearly audible warmth, not a subtle shelf. See
789    /// [`VinylVoicingFilter`].
790    #[serde(default)]
791    pub vinyl_voicing: f64,
792    /// Which [`VINYL_VOICING_CURVES`] shape `vinyl_voicing` blends: `0` coil
793    /// load, `1` tip mass, `2` curve drift. Out-of-range values are rejected.
794    #[serde(default)]
795    pub vinyl_voicing_curve: u32,
796}
797
798fn default_true() -> bool {
799    true
800}
801
802fn default_riaa_voicing_rate() -> f64 {
803    1.0
804}
805
806fn default_max_rate() -> f64 {
807    10.0
808}
809fn default_wow_rev_seconds() -> f64 {
810    WOW_REV_SECONDS
811}
812fn default_flutter_hz() -> f64 {
813    FLUTTER_HZ
814}
815fn default_stylus_tracing_limit() -> f64 {
816    0.0
817}
818fn default_high_frequency_acceleration_limit() -> f64 {
819    0.0
820}
821fn default_texture_scale() -> f64 {
822    1.0
823}
824
825impl Default for AcousticConfig {
826    fn default() -> Self {
827        Self {
828            max_rate: default_max_rate(),
829            wow_rev_seconds: default_wow_rev_seconds(),
830            flutter_hz: default_flutter_hz(),
831            acoustic_enabled: false,
832            surface_enabled: false,
833            cartridge_velocity_gain: default_true(),
834            riaa_speed_tilt: default_true(),
835            riaa_voicing_rate: default_riaa_voicing_rate(),
836            vinyl_voicing: 0.0,
837            vinyl_voicing_curve: 0,
838            stylus_tracing_limit: default_stylus_tracing_limit(),
839            high_frequency_acceleration_limit: default_high_frequency_acceleration_limit(),
840            texture_scale: default_texture_scale(),
841            contact_gain: default_texture_scale(),
842            dust_gain: default_texture_scale(),
843            impulse_gain: default_texture_scale(),
844            wear_gain: default_texture_scale(),
845            source_texture_gain: default_texture_scale(),
846            soft_clip: false,
847        }
848    }
849}
850
851#[derive(Clone, Copy, Debug, Serialize)]
852#[serde(rename_all = "camelCase")]
853pub struct AcousticStatus {
854    pub position: f64,
855    pub effective_rate: f64,
856    pub requested_window_position: Option<f64>,
857    pub ended: bool,
858    pub output_length: usize,
859}
860
861#[derive(Clone, Copy, Debug, PartialEq, Eq)]
862#[repr(u32)]
863pub enum DeckRecoveryOperation {
864    RestReset = 1,
865    LockedPlaybackReset = 2,
866    MechanicalAdvance = 3,
867    ServoCaptureReset = 4,
868}
869
870#[derive(Clone, Copy, Debug, PartialEq)]
871pub struct DeckRecoveryDiagnostic {
872    pub count: u64,
873    pub operation: DeckRecoveryOperation,
874    pub error: DeckMechanicalError,
875    pub output_sample_rate: f64,
876    pub source_sample_rate: f64,
877    pub position: f64,
878    pub target_position: f64,
879    pub requested_hand_rate: f64,
880    pub motor_rate: f64,
881    pub grip: f64,
882    pub platter_rate_before: f64,
883    pub record_rate_before: f64,
884    pub platter_turns_before: f64,
885    pub record_turns_before: f64,
886}
887
888#[derive(Clone, Debug)]
889struct AcousticReplaySnapshot {
890    restore_pending: bool,
891    config: AcousticConfig,
892    native_rpm: f64,
893    deck_state: DeckMechanicalState,
894    position: f64,
895    target_position: f64,
896    rate: f64,
897    rate_velocity: f64,
898    target_rate: f64,
899    wow_phase: f64,
900    flutter_phase: f64,
901    platter_rotation_turns: f64,
902    drag_lowpass_state: Vec<f64>,
903    high_frequency_acceleration_limiter: HighFrequencyAccelerationLimiter,
904    riaa_tilt: RiaaSpeedTilt,
905    riaa_voicing: RiaaSpeedTilt,
906    vinyl_voicing: VinylVoicingFilter,
907    surface_voicing: VinylVoicingFilter,
908    voicing_mix: f64,
909    active: bool,
910    needle_lifted: bool,
911    hand_contact: bool,
912    grip: f64,
913    grip_target: f64,
914    release_grip: f64,
915    movement_gain_state: f64,
916    motor_rate: f64,
917    motor_delivered_rate: f64,
918    unpowered_throw_rate: f64,
919    ended: bool,
920    contact_impulse: f64,
921    last_effective_rate: f64,
922    noise_seed: u32,
923    last_noise: f64,
924    last_output_samples: Vec<f64>,
925    last_emitted_samples: Vec<f64>,
926    seam_repair_from: Vec<f64>,
927    seam_repair_remaining: usize,
928    window_miss_frames: usize,
929    window_programme_gain: f64,
930    frames_since_motion: usize,
931    /// Frames between the last two motion samples: how often the host is
932    /// sampling the hand, so the target is dead-reckoned for about that
933    /// long and no longer.
934    motion_interval_frames: usize,
935    /// The rate the sample before the last one carried, so the rate's
936    /// slope across the last interval can be carried through the next.
937    previous_target_rate: f64,
938    frames_since_window_request: usize,
939    scratch_gate: ScratchGate,
940    manual_fader_gain: f64,
941    momentary_crossfader_gain: f64,
942    momentary_crossfader_mix: f64,
943    momentary_crossfader_mix_target: f64,
944    audible_crossfader_gain: f64,
945    output_gain_current: f64,
946    output_gain_target: f64,
947    output_gain_step: f64,
948    output_gain_remaining_frames: usize,
949    surface_bed: Option<SurfaceBed>,
950    needle_thump: Option<NeedleThump>,
951    needle_burst: Option<SurfaceBurst>,
952    // The record's character and its history. A replay is a transaction on
953    // the live deck: it plays a take's world — its press dials, its wear —
954    // and hands the platter back with the record's own. Without these in
955    // the snapshot a replayed take left its dials on the live record, and
956    // its wear on the live maps.
957    eccentricity_mm: f64,
958    warp_mm: f64,
959    stylus_tap_degrees: f64,
960    stylus_tap_level: f64,
961    tap_lowpass_state: [f64; 2],
962    angle_gate_sectors: u32,
963    angle_gate_depth: f64,
964    angle_gate_gain: f64,
965    locked_groove_start: f64,
966    groove_wear_rate: f64,
967    groove_wear: Vec<f32>,
968    pressing_seed: u32,
969    free_spin_drive_per_second: f64,
970    vinyl_vfx: VinylVfxProcessor,
971}
972
973/// One revolution of the record, lifted out of the render by platter angle.
974///
975/// A locked groove is one turn, and a groove cut from it has to be that
976/// turn exactly: from the ring's start, for one revolution of the platter,
977/// at whatever speed and wow the platter has. Cut by time it is wrong by
978/// the pitch, and cut from a tap it is wrong by a buffer. So the render loop
979/// watches the platter's angle frame by frame, begins the capture on the
980/// frame the ring's start comes round, reseeds the take there (a replay of
981/// the log begins from the same seed at the same frame), and ends it on the
982/// frame the angle has advanced by one turn. The block is copied after the
983/// scene and every gain, so what is kept is what was heard.
984struct RevolutionCapture {
985    /// The platter angle the ring starts at, as a fraction of a turn: how a
986    /// free cut (no ring) finds its start and its end.
987    target_phase: f64,
988    /// The ring's first source frame, when there is one. A locked groove's
989    /// seam is where the *position* comes back to its start, and an
990    /// off-centre hole makes the groove lead or lag the platter's angle
991    /// within a turn, so a ring is cut on its own seam rather than on the
992    /// platter's.
993    ring_start: Option<f64>,
994    previous_position: f64,
995    replay_seed: u32,
996    previous_turns: f64,
997    begin_turns: Option<f64>,
998    start_frame: u64,
999    /// The engine's frame the turn closed on (exclusive); zero until then.
1000    end_frame: u64,
1001    start_position: f64,
1002    start_rotation_turns: f64,
1003    block_start: Option<usize>,
1004    block_end: Option<usize>,
1005    channels: usize,
1006    samples: Vec<f32>,
1007    done: bool,
1008    overflow: bool,
1009}
1010
1011#[wasm_bindgen]
1012pub struct ScratchAcousticDsp {
1013    config: AcousticConfig,
1014    output_sample_rate: f64,
1015    source_sample_rate: f64,
1016    native_rpm: f64,
1017    deck_state: DeckMechanicalState,
1018    deck_recovery_count: u64,
1019    last_deck_recovery: Option<DeckRecoveryDiagnostic>,
1020    channels: Arc<Vec<Vec<f32>>>,
1021    /// The vinyl Vfx scene riding this platter: geometry-driven, phase
1022    /// locked to the record's angle like every other press effect.
1023    vinyl_vfx: VinylVfxProcessor,
1024    total_frames: usize,
1025    window_start: usize,
1026    window_end: usize,
1027    position: f64,
1028    target_position: f64,
1029    rate: f64,
1030    rate_velocity: f64,
1031    target_rate: f64,
1032    wow_phase: f64,
1033    flutter_phase: f64,
1034    platter_rotation_turns: f64,
1035    drag_lowpass_state: Vec<f64>,
1036    high_frequency_acceleration_limiter: HighFrequencyAccelerationLimiter,
1037    riaa_tilt: RiaaSpeedTilt,
1038    /// Constant-rate RIAA mismatch for optional vinyl warmth. Always run so a
1039    /// change eases from a warm state and `1.0` stays bit-exact.
1040    riaa_voicing: RiaaSpeedTilt,
1041    /// The fixed seed curve blended by `voicing_mix`.
1042    vinyl_voicing: VinylVoicingFilter,
1043    /// The same curve over the surface bed and wear crackle, which are summed
1044    /// outside the gate. A second filter, because one stateful instance cannot
1045    /// colour two signals at once.
1046    surface_voicing: VinylVoicingFilter,
1047    /// Eased blend of `vinyl_voicing`, so enabling the stage does not click.
1048    voicing_mix: f64,
1049    active: bool,
1050    needle_lifted: bool,
1051    hand_contact: bool,
1052    grip: f64,
1053    grip_target: f64,
1054    release_grip: f64,
1055    movement_gain_state: f64,
1056    motor_rate: f64,
1057    motor_delivered_rate: f64,
1058    unpowered_throw_rate: f64,
1059    /// Motor-off thrust while coasting, in e-folds of rate per second.
1060    /// Zero is a bearing and nothing else.
1061    free_spin_drive_per_second: f64,
1062    /// How far the spindle hole is punched off centre, in millimetres. The
1063    /// groove the stylus reads oscillates once per revolution, and the
1064    /// warble deepens toward the label as the groove radius shrinks —
1065    /// exactly as a mis-punched pressing behaves.
1066    eccentricity_mm: f64,
1067    /// Vertical warp height in millimetres: a once-per-revolution dip in
1068    /// level as the stylus rides over the high spot.
1069    warp_mm: f64,
1070    /// A second stylus this many degrees behind the first. Zero is off.
1071    /// Its delay is angle, not time, so it tightens with pitch and chases a
1072    /// scratch correctly.
1073    stylus_tap_degrees: f64,
1074    stylus_tap_level: f64,
1075    tap_lowpass_state: [f64; 2],
1076    /// Sectors per revolution the angle gate cuts. Zero is off.
1077    angle_gate_sectors: u32,
1078    angle_gate_depth: f64,
1079    angle_gate_gain: f64,
1080    /// A locked groove's first frame, or negative for none: playback wraps
1081    /// each revolution inside [start, start + frames-per-turn) until the
1082    /// host seeks out or clears it.
1083    locked_groove_start: f64,
1084    /// Wear accumulation-and-audibility scale. Zero is a mint pressing.
1085    groove_wear_rate: f64,
1086    /// Position-indexed wear, one bucket per WEAR_BUCKET_FRAMES of source.
1087    /// The record remembers where the stylus has been.
1088    groove_wear: Vec<f32>,
1089    /// Perturbs the surface-noise hashes so each pressing crackles like its
1090    /// own copy. Zero is the classic pattern.
1091    pressing_seed: u32,
1092    ended: bool,
1093    contact_impulse: f64,
1094    last_effective_rate: f64,
1095    noise_seed: u32,
1096    last_noise: f64,
1097    last_output_samples: Vec<f64>,
1098    last_emitted_samples: Vec<f64>,
1099    seam_repair_from: Vec<f64>,
1100    seam_repair_remaining: usize,
1101    window_miss_frames: usize,
1102    window_programme_gain: f64,
1103    frames_since_motion: usize,
1104    /// Frames between the last two motion samples: how often the host is
1105    /// sampling the hand, so the target is dead-reckoned for about that
1106    /// long and no longer.
1107    motion_interval_frames: usize,
1108    /// The rate the sample before the last one carried, so the rate's
1109    /// slope across the last interval can be carried through the next.
1110    previous_target_rate: f64,
1111    frames_since_window_request: usize,
1112    output: Vec<f32>,
1113    scratch_gate: ScratchGate,
1114    scratch_gate_trace: Vec<f32>,
1115    manual_fader_gain: f64,
1116    momentary_crossfader_gain: f64,
1117    momentary_crossfader_mix: f64,
1118    momentary_crossfader_mix_target: f64,
1119    audible_crossfader_gain: f64,
1120    output_gain_current: f64,
1121    output_gain_target: f64,
1122    output_gain_step: f64,
1123    output_gain_remaining_frames: usize,
1124    requested_window_position: Option<f64>,
1125    surface_asset: Arc<Vec<Vec<f32>>>,
1126    surface_asset_rate: f64,
1127    surface_gain_multiplier: f64,
1128    surface_bed: Option<SurfaceBed>,
1129    needle_thump: Option<NeedleThump>,
1130    needle_burst: Option<SurfaceBurst>,
1131    replay_snapshot: Option<Box<AcousticReplaySnapshot>>,
1132    revolution_capture: Option<RevolutionCapture>,
1133    /// Output frames this engine has rendered, the clock a capture's start
1134    /// is stamped on. The host converts it to its own clock by the frames
1135    /// rendered since.
1136    rendered_frame_counter: u64,
1137}
1138
1139#[wasm_bindgen]
1140impl ScratchAcousticDsp {
1141    #[wasm_bindgen(constructor)]
1142    pub fn new(output_sample_rate: f64, config: JsValue) -> Result<ScratchAcousticDsp, JsValue> {
1143        if !output_sample_rate.is_finite() || output_sample_rate <= 0.0 {
1144            return Err(JsValue::from_str("outputSampleRate must be positive"));
1145        }
1146        let config = if config.is_null() || config.is_undefined() {
1147            AcousticConfig::default()
1148        } else {
1149            serde_wasm_bindgen::from_value(config)
1150                .map_err(|error| JsValue::from_str(&error.to_string()))?
1151        };
1152        if !config.max_rate.is_finite() || config.max_rate <= 0.0 {
1153            return Err(JsValue::from_str("maxRate must be positive"));
1154        }
1155        if !valid_unit_interval(config.high_frequency_acceleration_limit) {
1156            return Err(JsValue::from_str(
1157                "highFrequencyAccelerationLimit must be between 0 and 1",
1158            ));
1159        }
1160        if !valid_unit_interval(config.stylus_tracing_limit) {
1161            return Err(JsValue::from_str(
1162                "stylusTracingLimit must be between 0 and 1",
1163            ));
1164        }
1165        if !valid_texture_scale(config.texture_scale) {
1166            return Err(JsValue::from_str(
1167                "textureScale must be between 0 and 4",
1168            ));
1169        }
1170        for gain in [
1171            config.contact_gain,
1172            config.dust_gain,
1173            config.impulse_gain,
1174            config.wear_gain,
1175            config.source_texture_gain,
1176        ] {
1177            if !valid_texture_scale(gain) {
1178                return Err(JsValue::from_str("surface gains must be between 0 and 4"));
1179            }
1180        }
1181        if !valid_riaa_voicing_rate(config.riaa_voicing_rate) {
1182            return Err(JsValue::from_str("riaaVoicing must be positive"));
1183        }
1184        if !valid_unit_interval(config.vinyl_voicing) {
1185            return Err(JsValue::from_str(
1186                "vinylVoicing must be between 0 and 1",
1187            ));
1188        }
1189        if config.vinyl_voicing_curve as usize >= VINYL_VOICING_CURVES.len() {
1190            return Err(JsValue::from_str("vinylVoicingCurve is out of range"));
1191        }
1192        Ok(Self::new_internal(output_sample_rate, config))
1193    }
1194
1195    fn new_internal(output_sample_rate: f64, config: AcousticConfig) -> Self {
1196        let native_rpm = (60.0 / config.wow_rev_seconds.max(1e-6)).clamp(16.0, 90.0);
1197        let deck_state =
1198            DeckMechanicalState::new(production_deck_config(output_sample_rate, native_rpm))
1199                .expect("production deck configuration must be valid");
1200        Self {
1201            config,
1202            output_sample_rate,
1203            source_sample_rate: 48_000.0,
1204            native_rpm,
1205            deck_state,
1206            deck_recovery_count: 0,
1207            last_deck_recovery: None,
1208            channels: Arc::new(Vec::new()),
1209            total_frames: 0,
1210            window_start: 0,
1211            window_end: 0,
1212            position: 0.0,
1213            target_position: 0.0,
1214            rate: 0.0,
1215            rate_velocity: 0.0,
1216            target_rate: 0.0,
1217            wow_phase: 0.0,
1218            flutter_phase: 0.0,
1219            platter_rotation_turns: 0.0,
1220            drag_lowpass_state: Vec::new(),
1221            high_frequency_acceleration_limiter: HighFrequencyAccelerationLimiter::default(),
1222            riaa_tilt: RiaaSpeedTilt::new(output_sample_rate),
1223            riaa_voicing: RiaaSpeedTilt::new(output_sample_rate),
1224            vinyl_voicing: {
1225                let mut filter = VinylVoicingFilter::new(output_sample_rate);
1226                filter.set_curve(config.vinyl_voicing_curve as usize);
1227                filter
1228            },
1229            surface_voicing: {
1230                let mut filter = VinylVoicingFilter::new(output_sample_rate);
1231                filter.set_curve(config.vinyl_voicing_curve as usize);
1232                filter
1233            },
1234            voicing_mix: config.vinyl_voicing,
1235            active: false,
1236            needle_lifted: false,
1237            hand_contact: false,
1238            grip: 0.0,
1239            grip_target: 0.0,
1240            release_grip: 0.0,
1241            movement_gain_state: f64::NAN,
1242            motor_rate: 0.0,
1243            motor_delivered_rate: 0.0,
1244            unpowered_throw_rate: 0.0,
1245            free_spin_drive_per_second: 0.0,
1246            eccentricity_mm: 0.0,
1247            warp_mm: 0.0,
1248            stylus_tap_degrees: 0.0,
1249            stylus_tap_level: 0.0,
1250            tap_lowpass_state: [0.0; 2],
1251            angle_gate_sectors: 0,
1252            angle_gate_depth: 0.0,
1253            angle_gate_gain: 1.0,
1254            locked_groove_start: -1.0,
1255            groove_wear_rate: 0.0,
1256            groove_wear: Vec::new(),
1257            pressing_seed: 0,
1258            vinyl_vfx: VinylVfxProcessor::new(),
1259            ended: false,
1260            contact_impulse: 0.0,
1261            last_effective_rate: 0.0,
1262            noise_seed: DEFAULT_REPLAY_NOISE_SEED,
1263            last_noise: 0.0,
1264            last_output_samples: Vec::new(),
1265            last_emitted_samples: Vec::new(),
1266            seam_repair_from: Vec::new(),
1267            seam_repair_remaining: 0,
1268            window_miss_frames: 0,
1269            window_programme_gain: 1.0,
1270            frames_since_motion: output_sample_rate as usize,
1271            motion_interval_frames: 0,
1272            previous_target_rate: 0.0,
1273            frames_since_window_request: output_sample_rate as usize,
1274            output: Vec::new(),
1275            scratch_gate: ScratchGate::default(),
1276            scratch_gate_trace: Vec::new(),
1277            manual_fader_gain: 1.0,
1278            momentary_crossfader_gain: 1.0,
1279            momentary_crossfader_mix: 0.0,
1280            momentary_crossfader_mix_target: 0.0,
1281            audible_crossfader_gain: 1.0,
1282            output_gain_current: 1.0,
1283            output_gain_target: 1.0,
1284            output_gain_step: 0.0,
1285            output_gain_remaining_frames: 0,
1286            requested_window_position: None,
1287            surface_asset: Arc::new(Vec::new()),
1288            surface_asset_rate: 48_000.0,
1289            surface_gain_multiplier: 1.0,
1290            surface_bed: None,
1291            needle_thump: None,
1292            needle_burst: None,
1293            replay_snapshot: None,
1294            revolution_capture: None,
1295            rendered_frame_counter: 0,
1296        }
1297    }
1298
1299    /// Prepares stable Rust-owned channel storage for a direct AudioWorklet
1300    /// copy. This removes the wasm-bindgen Array traversal from the realtime
1301    /// window replacement path while retaining Rust ownership of source PCM.
1302    #[wasm_bindgen(js_name = prepareWindow)]
1303    pub fn prepare_window(&mut self, channel_count: u32, length: u32) -> Result<(), JsValue> {
1304        let channel_count = channel_count as usize;
1305        let length = length as usize;
1306        if !(1..=2).contains(&channel_count) {
1307            return Err(JsValue::from_str("window channelCount must be 1 or 2"));
1308        }
1309        if length == 0 {
1310            return Err(JsValue::from_str("window length must be positive"));
1311        }
1312
1313        // Window swaps are realtime control work. Reuse the active channel
1314        // allocations when geometry is stable so a normal progressive swap is
1315        // one bounded copy per channel rather than allocation + copy + drop.
1316        let channels = Arc::make_mut(&mut self.channels);
1317        channels.resize_with(channel_count, Vec::new);
1318        for channel in channels {
1319            channel.resize(length, 0.0);
1320        }
1321        Ok(())
1322    }
1323
1324    #[wasm_bindgen(js_name = windowChannelPtr)]
1325    pub fn window_channel_ptr(&mut self, channel_index: u32) -> *mut f32 {
1326        Arc::make_mut(&mut self.channels)
1327            .get_mut(channel_index as usize)
1328            .map_or(std::ptr::null_mut(), |channel| channel.as_mut_ptr())
1329    }
1330
1331    /// Publishes a fully copied prepared window. No allocation occurs on the
1332    /// successful path.
1333    #[wasm_bindgen(js_name = commitWindow)]
1334    pub fn commit_window(
1335        &mut self,
1336        source_sample_rate: f64,
1337        window_start: u32,
1338        total_frames: u32,
1339        reset_position: Option<f64>,
1340    ) -> Result<(), JsValue> {
1341        if !source_sample_rate.is_finite() || source_sample_rate <= 0.0 {
1342            return Err(JsValue::from_str("sourceSampleRate must be positive"));
1343        }
1344        let Some(length) = self
1345            .channels
1346            .first()
1347            .map(Vec::len)
1348            .filter(|length| *length > 0)
1349        else {
1350            return Err(JsValue::from_str("window has not been prepared"));
1351        };
1352        if self.channels.iter().any(|channel| channel.len() != length) {
1353            return Err(JsValue::from_str(
1354                "prepared window channels must have equal lengths",
1355            ));
1356        }
1357
1358        self.source_sample_rate = source_sample_rate;
1359        self.locked_groove_start = -1.0;
1360        // Committing a window is a *page*, not a record: a streamed side
1361        // commits one of these every few seconds, and wear that is
1362        // reallocated on each of them can never reach the fifty passes it is
1363        // scaled for. So the map is only rebuilt when its shape changes,
1364        // which is a differently sized source; wear on the record that is
1365        // playing survives paging.
1366        //
1367        // Clearing wear for a *new* record is the host's call —
1368        // `resetWear("all")` — because only the host knows that the pressing
1369        // changed rather than the window.
1370        let wanted_buckets = if self.groove_wear_rate > 0.0 {
1371            (total_frames as usize) / WEAR_BUCKET_FRAMES + 1
1372        } else {
1373            0
1374        };
1375        if self.groove_wear.len() != wanted_buckets {
1376            self.groove_wear = vec![0.0; wanted_buckets];
1377        }
1378        self.window_start = window_start as usize;
1379        self.window_end = self.window_start.saturating_add(length);
1380        self.total_frames = (total_frames as usize).max(self.window_end);
1381        if let Some(position) = reset_position {
1382            self.reset_position(position);
1383        }
1384        Ok(())
1385    }
1386
1387    /// Clear the phono-stage filters together. The voicing blend returns to
1388    /// its configured target so a deck that starts with voicing on is at the
1389    /// target immediately rather than fading in.
1390    fn reset_phono_filters(&mut self) {
1391        self.riaa_tilt.reset();
1392        self.riaa_voicing.reset();
1393        self.vinyl_voicing.reset();
1394        self.surface_voicing.reset();
1395        self.voicing_mix = self.config.vinyl_voicing;
1396    }
1397
1398    #[wasm_bindgen(js_name = clearWindow)]
1399    pub fn clear_window(&mut self) {
1400        self.channels = Arc::new(Vec::new());
1401        self.total_frames = 0;
1402        self.window_start = 0;
1403        self.window_end = 0;
1404        // Loading a new groove clears the read head, not the physical platter.
1405        // Keep motor velocity and absolute phase continuous while the next PCM
1406        // window becomes available.
1407        self.position = 0.0;
1408        self.target_position = 0.0;
1409        self.target_rate = 0.0;
1410        self.frames_since_motion = 0;
1411        self.last_output_samples.clear();
1412        self.high_frequency_acceleration_limiter.reset();
1413        self.reset_phono_filters();
1414        self.window_miss_frames = 0;
1415        self.window_programme_gain = 1.0;
1416        self.ended = false;
1417    }
1418
1419    #[wasm_bindgen(js_name = start)]
1420    pub fn start(&mut self) {
1421        self.active = true;
1422        self.grip = 0.0;
1423        self.release_grip = 0.0;
1424        self.movement_gain_state = f64::NAN;
1425        self.grip_target = 1.0;
1426        self.motor_delivered_rate = 0.0;
1427        self.hand_contact = true;
1428        // Original: `this.position || this.targetPosition || 0` — first non-zero wins.
1429        let seed_position = if self.position != 0.0 {
1430            self.position
1431        } else {
1432            self.target_position
1433        };
1434        self.position = self.clamp_source_position(seed_position);
1435        self.target_position = self.position;
1436        self.rate = 0.0;
1437        self.rate_velocity = 0.0;
1438        self.target_rate = 0.0;
1439        self.last_effective_rate = 0.0;
1440        self.reset_deck_to_rest_at_current_turns();
1441        self.frames_since_motion = 0;
1442        self.contact_impulse = 0.0;
1443        self.last_output_samples.clear();
1444        self.high_frequency_acceleration_limiter.reset();
1445        self.reset_phono_filters();
1446        self.window_miss_frames = 0;
1447        self.window_programme_gain = 1.0;
1448        self.ended = false;
1449    }
1450
1451    #[wasm_bindgen(js_name = stop)]
1452    pub fn stop(&mut self) {
1453        self.active = false;
1454        self.hand_contact = false;
1455        self.grip_target = 0.0;
1456        self.scratch_gate.release();
1457        self.target_rate = 0.0;
1458        self.unpowered_throw_rate = 0.0;
1459        self.contact_impulse = 0.0;
1460        self.last_effective_rate = 0.0;
1461    }
1462
1463    #[wasm_bindgen(js_name = setEffects)]
1464    pub fn set_effects(&mut self, acoustic_enabled: bool, surface_enabled: bool) {
1465        self.config.acoustic_enabled = acoustic_enabled;
1466        self.config.surface_enabled = surface_enabled;
1467        if !surface_enabled {
1468            self.contact_impulse = 0.0;
1469            self.last_noise = 0.0;
1470            self.surface_bed = None;
1471            self.needle_thump = None;
1472            self.needle_burst = None;
1473        }
1474    }
1475
1476    #[wasm_bindgen(js_name = setManualFaderGain)]
1477    pub fn set_manual_fader_gain(&mut self, gain: f64) -> Result<(), JsValue> {
1478        if !valid_unit_interval(gain) {
1479            return Err(JsValue::from_str("manualFaderGain must be between 0 and 1"));
1480        }
1481        self.manual_fader_gain = gain;
1482        Ok(())
1483    }
1484
1485    #[wasm_bindgen(js_name = setManualCrossfader)]
1486    pub fn set_manual_crossfader(&mut self, position: f64) -> Result<(), JsValue> {
1487        if !valid_unit_interval(position) {
1488            return Err(JsValue::from_str(
1489                "manualCrossfader must be between 0 and 1",
1490            ));
1491        }
1492        self.manual_fader_gain =
1493            f64::from(sharp_crossfader_gains(position as f32, DEFAULT_SHARP_CROSSFADER_WIDTH).0);
1494        Ok(())
1495    }
1496
1497    #[wasm_bindgen(getter, js_name = manualFaderGain)]
1498    pub fn manual_fader_gain(&self) -> f64 {
1499        self.manual_fader_gain
1500    }
1501
1502    /// Overrides the selected technique while the host control is active.
1503    /// The release returns control to the technique through a de-click ramp.
1504    #[wasm_bindgen(js_name = setMomentaryCrossfaderOverride)]
1505    pub fn set_momentary_crossfader_override(&mut self, active: bool, open: bool) {
1506        self.set_crossfader_touch_override(active, f64::from(open));
1507    }
1508
1509    /// Gives a touched host fader temporary control of the audible gate.
1510    /// Releasing the fader returns control to the selected scratch technique.
1511    pub fn set_crossfader_touch_override(&mut self, active: bool, gain: f64) {
1512        if active && gain.is_finite() {
1513            self.momentary_crossfader_gain = gain.clamp(0.0, 1.0);
1514        }
1515        self.momentary_crossfader_mix_target = f64::from(active);
1516    }
1517
1518    /// Reports the final audible fader gain after all technique and host input.
1519    #[wasm_bindgen(getter, js_name = audibleCrossfaderGain)]
1520    pub fn audible_crossfader_gain(&self) -> f64 {
1521        self.audible_crossfader_gain
1522    }
1523
1524    /// Final post-mix gain used by the host for packet and mixer level. A
1525    /// linear ramp starts from the gain active at the next rendered frame.
1526    #[wasm_bindgen(js_name = setOutputGain)]
1527    pub fn set_output_gain(&mut self, gain: f64, ramp_ms: f64) -> Result<(), JsValue> {
1528        if !gain.is_finite() || !(0.0..=MAX_FINAL_OUTPUT_GAIN).contains(&gain) {
1529            return Err(JsValue::from_str("outputGain must be between 0 and 4"));
1530        }
1531        if !ramp_ms.is_finite() || !(0.0..=MAX_FINAL_OUTPUT_GAIN_RAMP_MS).contains(&ramp_ms) {
1532            return Err(JsValue::from_str(
1533                "outputGain rampMs must be between 0 and 60000",
1534            ));
1535        }
1536        if ramp_ms == 0.0 || gain == self.output_gain_current {
1537            self.output_gain_current = gain;
1538            self.output_gain_target = gain;
1539            self.output_gain_step = 0.0;
1540            self.output_gain_remaining_frames = 0;
1541            return Ok(());
1542        }
1543
1544        let ramp_frames = (self.output_sample_rate * ramp_ms / 1_000.0)
1545            .round()
1546            .max(1.0);
1547        if !ramp_frames.is_finite() || ramp_frames > usize::MAX as f64 {
1548            return Err(JsValue::from_str(
1549                "outputGain ramp exceeds the supported frame count",
1550            ));
1551        }
1552        self.output_gain_target = gain;
1553        self.output_gain_remaining_frames = ramp_frames as usize;
1554        self.output_gain_step =
1555            (gain - self.output_gain_current) / self.output_gain_remaining_frames.max(1) as f64;
1556        Ok(())
1557    }
1558
1559    #[wasm_bindgen(js_name = captureReplayState)]
1560    pub fn capture_replay_state(&mut self) {
1561        if let Some(snapshot) = self.replay_snapshot.as_mut() {
1562            snapshot.config = self.config;
1563            snapshot.native_rpm = self.native_rpm;
1564            snapshot.deck_state = self.deck_state;
1565            snapshot.position = self.position;
1566            snapshot.target_position = self.target_position;
1567            snapshot.rate = self.rate;
1568            snapshot.rate_velocity = self.rate_velocity;
1569            snapshot.target_rate = self.target_rate;
1570            snapshot.wow_phase = self.wow_phase;
1571            snapshot.flutter_phase = self.flutter_phase;
1572            snapshot.platter_rotation_turns = self.platter_rotation_turns;
1573            snapshot
1574                .drag_lowpass_state
1575                .clone_from(&self.drag_lowpass_state);
1576            snapshot
1577                .high_frequency_acceleration_limiter
1578                .clone_from(&self.high_frequency_acceleration_limiter);
1579            snapshot.riaa_tilt.clone_from(&self.riaa_tilt);
1580            snapshot.riaa_voicing.clone_from(&self.riaa_voicing);
1581            snapshot.vinyl_voicing.clone_from(&self.vinyl_voicing);
1582            snapshot
1583                .surface_voicing
1584                .clone_from(&self.surface_voicing);
1585            snapshot.voicing_mix = self.voicing_mix;
1586            snapshot.active = self.active;
1587            snapshot.needle_lifted = self.needle_lifted;
1588            snapshot.hand_contact = self.hand_contact;
1589            snapshot.grip = self.grip;
1590            snapshot.grip_target = self.grip_target;
1591            snapshot.motor_rate = self.motor_rate;
1592            snapshot.motor_delivered_rate = self.motor_delivered_rate;
1593            snapshot.unpowered_throw_rate = self.unpowered_throw_rate;
1594            snapshot.ended = self.ended;
1595            snapshot.contact_impulse = self.contact_impulse;
1596            snapshot.last_effective_rate = self.last_effective_rate;
1597            snapshot.noise_seed = self.noise_seed;
1598            snapshot.last_noise = self.last_noise;
1599            snapshot
1600                .last_output_samples
1601                .clone_from(&self.last_output_samples);
1602            snapshot
1603                .last_emitted_samples
1604                .clone_from(&self.last_emitted_samples);
1605            snapshot
1606                .seam_repair_from
1607                .clone_from(&self.seam_repair_from);
1608            snapshot.seam_repair_remaining = self.seam_repair_remaining;
1609            snapshot.window_miss_frames = self.window_miss_frames;
1610            snapshot.window_programme_gain = self.window_programme_gain;
1611            snapshot.frames_since_motion = self.frames_since_motion;
1612            snapshot.motion_interval_frames = self.motion_interval_frames;
1613            snapshot.previous_target_rate = self.previous_target_rate;
1614            snapshot.frames_since_window_request = self.frames_since_window_request;
1615            snapshot.scratch_gate.clone_from(&self.scratch_gate);
1616            snapshot.manual_fader_gain = self.manual_fader_gain;
1617            snapshot.momentary_crossfader_gain = self.momentary_crossfader_gain;
1618            snapshot.momentary_crossfader_mix = self.momentary_crossfader_mix;
1619            snapshot.momentary_crossfader_mix_target = self.momentary_crossfader_mix_target;
1620            snapshot.audible_crossfader_gain = self.audible_crossfader_gain;
1621            snapshot.output_gain_current = self.output_gain_current;
1622            snapshot.output_gain_target = self.output_gain_target;
1623            snapshot.output_gain_step = self.output_gain_step;
1624            snapshot.output_gain_remaining_frames = self.output_gain_remaining_frames;
1625            snapshot.surface_bed.clone_from(&self.surface_bed);
1626            snapshot.needle_thump = self.needle_thump;
1627            snapshot.needle_burst.clone_from(&self.needle_burst);
1628            snapshot.eccentricity_mm = self.eccentricity_mm;
1629            snapshot.warp_mm = self.warp_mm;
1630            snapshot.stylus_tap_degrees = self.stylus_tap_degrees;
1631            snapshot.stylus_tap_level = self.stylus_tap_level;
1632            snapshot.tap_lowpass_state = self.tap_lowpass_state;
1633            snapshot.angle_gate_sectors = self.angle_gate_sectors;
1634            snapshot.angle_gate_depth = self.angle_gate_depth;
1635            snapshot.angle_gate_gain = self.angle_gate_gain;
1636            snapshot.locked_groove_start = self.locked_groove_start;
1637            snapshot.groove_wear_rate = self.groove_wear_rate;
1638            snapshot.groove_wear.clone_from(&self.groove_wear);
1639            snapshot.pressing_seed = self.pressing_seed;
1640            snapshot.free_spin_drive_per_second = self.free_spin_drive_per_second;
1641            snapshot.vinyl_vfx.clone_from(&self.vinyl_vfx);
1642            snapshot.restore_pending = true;
1643            return;
1644        }
1645
1646        self.replay_snapshot = Some(Box::new(AcousticReplaySnapshot {
1647            restore_pending: true,
1648            config: self.config,
1649            native_rpm: self.native_rpm,
1650            deck_state: self.deck_state,
1651            position: self.position,
1652            target_position: self.target_position,
1653            rate: self.rate,
1654            rate_velocity: self.rate_velocity,
1655            target_rate: self.target_rate,
1656            wow_phase: self.wow_phase,
1657            flutter_phase: self.flutter_phase,
1658            platter_rotation_turns: self.platter_rotation_turns,
1659            drag_lowpass_state: self.drag_lowpass_state.clone(),
1660            high_frequency_acceleration_limiter: self.high_frequency_acceleration_limiter.clone(),
1661            riaa_tilt: self.riaa_tilt.clone(),
1662            riaa_voicing: self.riaa_voicing.clone(),
1663            vinyl_voicing: self.vinyl_voicing.clone(),
1664            surface_voicing: self.surface_voicing.clone(),
1665            voicing_mix: self.voicing_mix,
1666            active: self.active,
1667            needle_lifted: self.needle_lifted,
1668            hand_contact: self.hand_contact,
1669            grip: self.grip,
1670            grip_target: self.grip_target,
1671            release_grip: self.release_grip,
1672            movement_gain_state: self.movement_gain_state,
1673            motor_rate: self.motor_rate,
1674            motor_delivered_rate: self.motor_delivered_rate,
1675            unpowered_throw_rate: self.unpowered_throw_rate,
1676            ended: self.ended,
1677            contact_impulse: self.contact_impulse,
1678            last_effective_rate: self.last_effective_rate,
1679            noise_seed: self.noise_seed,
1680            last_noise: self.last_noise,
1681            last_output_samples: self.last_output_samples.clone(),
1682            last_emitted_samples: self.last_emitted_samples.clone(),
1683            seam_repair_from: self.seam_repair_from.clone(),
1684            seam_repair_remaining: self.seam_repair_remaining,
1685            window_miss_frames: self.window_miss_frames,
1686            window_programme_gain: self.window_programme_gain,
1687            frames_since_motion: self.frames_since_motion,
1688            motion_interval_frames: self.motion_interval_frames,
1689            previous_target_rate: self.previous_target_rate,
1690            frames_since_window_request: self.frames_since_window_request,
1691            scratch_gate: self.scratch_gate.clone(),
1692            manual_fader_gain: self.manual_fader_gain,
1693            momentary_crossfader_gain: self.momentary_crossfader_gain,
1694            momentary_crossfader_mix: self.momentary_crossfader_mix,
1695            momentary_crossfader_mix_target: self.momentary_crossfader_mix_target,
1696            audible_crossfader_gain: self.audible_crossfader_gain,
1697            output_gain_current: self.output_gain_current,
1698            output_gain_target: self.output_gain_target,
1699            output_gain_step: self.output_gain_step,
1700            output_gain_remaining_frames: self.output_gain_remaining_frames,
1701            surface_bed: self.surface_bed.clone(),
1702            needle_thump: self.needle_thump,
1703            needle_burst: self.needle_burst.clone(),
1704            eccentricity_mm: self.eccentricity_mm,
1705            warp_mm: self.warp_mm,
1706            stylus_tap_degrees: self.stylus_tap_degrees,
1707            stylus_tap_level: self.stylus_tap_level,
1708            tap_lowpass_state: self.tap_lowpass_state,
1709            angle_gate_sectors: self.angle_gate_sectors,
1710            angle_gate_depth: self.angle_gate_depth,
1711            angle_gate_gain: self.angle_gate_gain,
1712            locked_groove_start: self.locked_groove_start,
1713            groove_wear_rate: self.groove_wear_rate,
1714            groove_wear: self.groove_wear.clone(),
1715            pressing_seed: self.pressing_seed,
1716            free_spin_drive_per_second: self.free_spin_drive_per_second,
1717            vinyl_vfx: self.vinyl_vfx.clone(),
1718        }));
1719    }
1720
1721    #[wasm_bindgen(js_name = restoreReplayState)]
1722    pub fn restore_replay_state(&mut self) -> bool {
1723        let Some(snapshot) = self.replay_snapshot.as_mut() else {
1724            return false;
1725        };
1726        if !snapshot.restore_pending {
1727            return false;
1728        }
1729
1730        // Keep both ownership slots alive. The audio callback only swaps
1731        // values and buffer handles; it never drops the snapshot or its Vecs.
1732        macro_rules! swap_replay_field {
1733            ($field:ident) => {
1734                std::mem::swap(&mut self.$field, &mut snapshot.$field)
1735            };
1736        }
1737        swap_replay_field!(config);
1738        swap_replay_field!(native_rpm);
1739        swap_replay_field!(deck_state);
1740        swap_replay_field!(position);
1741        swap_replay_field!(target_position);
1742        swap_replay_field!(rate);
1743        swap_replay_field!(rate_velocity);
1744        swap_replay_field!(target_rate);
1745        swap_replay_field!(wow_phase);
1746        swap_replay_field!(flutter_phase);
1747        swap_replay_field!(platter_rotation_turns);
1748        swap_replay_field!(drag_lowpass_state);
1749        swap_replay_field!(high_frequency_acceleration_limiter);
1750        swap_replay_field!(riaa_tilt);
1751        swap_replay_field!(riaa_voicing);
1752        swap_replay_field!(vinyl_voicing);
1753        swap_replay_field!(surface_voicing);
1754        swap_replay_field!(voicing_mix);
1755        swap_replay_field!(active);
1756        swap_replay_field!(needle_lifted);
1757        swap_replay_field!(hand_contact);
1758        swap_replay_field!(grip);
1759        swap_replay_field!(release_grip);
1760        swap_replay_field!(movement_gain_state);
1761        swap_replay_field!(grip_target);
1762        swap_replay_field!(motor_rate);
1763        swap_replay_field!(motor_delivered_rate);
1764        swap_replay_field!(unpowered_throw_rate);
1765        swap_replay_field!(ended);
1766        swap_replay_field!(contact_impulse);
1767        swap_replay_field!(last_effective_rate);
1768        swap_replay_field!(noise_seed);
1769        swap_replay_field!(last_noise);
1770        swap_replay_field!(last_output_samples);
1771        swap_replay_field!(last_emitted_samples);
1772        swap_replay_field!(seam_repair_from);
1773        swap_replay_field!(seam_repair_remaining);
1774        swap_replay_field!(window_miss_frames);
1775        swap_replay_field!(window_programme_gain);
1776        swap_replay_field!(frames_since_motion);
1777        swap_replay_field!(motion_interval_frames);
1778        swap_replay_field!(previous_target_rate);
1779        swap_replay_field!(frames_since_window_request);
1780        swap_replay_field!(scratch_gate);
1781        swap_replay_field!(manual_fader_gain);
1782        swap_replay_field!(momentary_crossfader_gain);
1783        swap_replay_field!(momentary_crossfader_mix);
1784        swap_replay_field!(momentary_crossfader_mix_target);
1785        swap_replay_field!(audible_crossfader_gain);
1786        swap_replay_field!(output_gain_current);
1787        swap_replay_field!(output_gain_target);
1788        swap_replay_field!(output_gain_step);
1789        swap_replay_field!(output_gain_remaining_frames);
1790        swap_replay_field!(surface_bed);
1791        swap_replay_field!(needle_thump);
1792        swap_replay_field!(needle_burst);
1793        swap_replay_field!(eccentricity_mm);
1794        swap_replay_field!(warp_mm);
1795        swap_replay_field!(stylus_tap_degrees);
1796        swap_replay_field!(stylus_tap_level);
1797        swap_replay_field!(tap_lowpass_state);
1798        swap_replay_field!(angle_gate_sectors);
1799        swap_replay_field!(angle_gate_depth);
1800        swap_replay_field!(angle_gate_gain);
1801        swap_replay_field!(locked_groove_start);
1802        swap_replay_field!(groove_wear_rate);
1803        swap_replay_field!(groove_wear);
1804        swap_replay_field!(pressing_seed);
1805        swap_replay_field!(free_spin_drive_per_second);
1806        swap_replay_field!(vinyl_vfx);
1807        snapshot.restore_pending = false;
1808        true
1809    }
1810
1811    /// Reinitializes every dynamic input that can color a recorded take.
1812    /// The caller must capture the live state first and restore it after the
1813    /// replay transaction. Static PCM, surface assets and selected controls
1814    /// remain in place.
1815    #[wasm_bindgen(js_name = beginDeterministicReplay)]
1816    pub fn begin_deterministic_replay(
1817        &mut self,
1818        position: f64,
1819        rotation_turns: f64,
1820        replay_seed: u32,
1821    ) -> Result<(), JsValue> {
1822        self.begin_deterministic_replay_from(position, rotation_turns, replay_seed, 0.0)
1823    }
1824
1825    /// `beginDeterministicReplay`, with the platter already turning.
1826    ///
1827    /// A take punched in on a running record starts at speed. Beginning its
1828    /// replay from rest put a spin-up under the first beat that the take
1829    /// never had: the motor model ramped from zero toward the transport the
1830    /// first events set. `rate` is the platter's rate at punch-in, in units
1831    /// of the native speed, and the platter is reset *to* it rather than
1832    /// toward it.
1833    #[wasm_bindgen(js_name = beginDeterministicReplayFrom)]
1834    pub fn begin_deterministic_replay_from(
1835        &mut self,
1836        position: f64,
1837        rotation_turns: f64,
1838        replay_seed: u32,
1839        rate: f64,
1840    ) -> Result<(), JsValue> {
1841        self.begin_replay(position, rotation_turns, replay_seed, rate)
1842            .map_err(JsValue::from_str)
1843    }
1844}
1845
1846impl ScratchAcousticDsp {
1847    /// Takes another engine's record as this engine's own, without a copy.
1848    ///
1849    /// The source PCM sits behind an `Arc`; a headless engine that renders a
1850    /// take's log to audio shares the live deck's record for the length of
1851    /// the render and lets it go. The record's speed, seed and wear come
1852    /// with it, so a log with no world replays on the record as it is.
1853    /// Transport starts from rest at the top of the side.
1854    pub fn share_source(&mut self, other: &ScratchAcousticDsp) {
1855        self.channels = Arc::clone(&other.channels);
1856        self.source_sample_rate = other.source_sample_rate;
1857        self.window_start = other.window_start;
1858        self.window_end = other.window_end;
1859        self.total_frames = other.total_frames;
1860        self.native_rpm = other.native_rpm;
1861        self.pressing_seed = other.pressing_seed;
1862        self.groove_wear_rate = other.groove_wear_rate;
1863        self.groove_wear = other.groove_wear.clone();
1864        self.vinyl_vfx.restore_halo_wear(&other.vinyl_vfx.halo_wear_map());
1865        self.locked_groove_start = -1.0;
1866        self.reset_position(0.0);
1867    }
1868
1869    /// `armRevolutionCapture`, off the wasm binding.
1870    pub fn arm_revolution(
1871        &mut self,
1872        start_position: f64,
1873        max_frames: u32,
1874        replay_seed: u32,
1875    ) -> Result<(), &'static str> {
1876        if !start_position.is_finite() {
1877            return Err("revolution start must be finite");
1878        }
1879        // `max_frames` of zero is a stamp: the capture marks where the ring
1880        // came round and when it closed, and keeps no audio — a groove that
1881        // is its log wants the punch-in, not the wav.
1882        let frames_per_turn = self.source_sample_rate * 60.0 / self.native_rpm.max(f64::EPSILON);
1883        if !(frames_per_turn.is_finite() && frames_per_turn > 0.0) {
1884            return Err("the record has no revolution");
1885        }
1886        // The ring's start as a platter angle: where the platter is now,
1887        // less how far into the ring the needle has got.
1888        let target_phase = if start_position < 0.0 {
1889            self.platter_rotation_turns.rem_euclid(1.0)
1890        } else {
1891            let into_ring = (self.position - start_position).rem_euclid(frames_per_turn);
1892            (self.platter_rotation_turns - into_ring / frames_per_turn).rem_euclid(1.0)
1893        };
1894        self.revolution_capture = Some(RevolutionCapture {
1895            target_phase,
1896            ring_start: if start_position < 0.0 { None } else { Some(start_position) },
1897            previous_position: self.position,
1898            replay_seed,
1899            previous_turns: self.platter_rotation_turns,
1900            begin_turns: None,
1901            start_frame: 0,
1902            end_frame: 0,
1903            start_position: 0.0,
1904            start_rotation_turns: 0.0,
1905            block_start: None,
1906            block_end: None,
1907            channels: 0,
1908            samples: Vec::with_capacity(max_frames as usize * 2),
1909            done: false,
1910            overflow: false,
1911        });
1912        Ok(())
1913    }
1914
1915    /// One render frame, after the platter has stepped: does the ring's
1916    /// start come round on this frame, or has a full turn gone by?
1917    fn revolution_capture_frame(&mut self, frame: usize) {
1918        let turns = self.platter_rotation_turns;
1919        let position = self.position;
1920        let counter = self.rendered_frame_counter;
1921        let mut reseed = None;
1922        if let Some(capture) = self.revolution_capture.as_mut() {
1923            if capture.done {
1924                return;
1925            }
1926            let previous = capture.previous_turns;
1927            capture.previous_turns = turns;
1928            let previous_position = capture.previous_position;
1929            capture.previous_position = position;
1930            // Did the start come round on this frame? On a ring, that is
1931            // the position reaching the ring's first frame going forward —
1932            // by passing it, or by the ring wrapping back onto it. Free,
1933            // it is the platter reaching the angle it was armed at.
1934            let crossed = match capture.ring_start {
1935                Some(start) => {
1936                    let wrapped = position < previous_position
1937                        && previous_position - position > 1.0;
1938                    let passed = previous_position < start && position >= start;
1939                    (wrapped && (position - start).abs() < 1.0) || passed
1940                }
1941                None => {
1942                    if turns <= previous {
1943                        false
1944                    } else {
1945                        let target = capture.target_phase + (previous - capture.target_phase).ceil();
1946                        turns >= target
1947                    }
1948                }
1949            };
1950            match capture.begin_turns {
1951                None => {
1952                    if crossed {
1953                        capture.begin_turns = Some(turns);
1954                        capture.start_frame = counter + frame as u64;
1955                        capture.start_position = position;
1956                        capture.start_rotation_turns = turns;
1957                        capture.block_start = Some(frame);
1958                        reseed = Some(capture.replay_seed);
1959                    }
1960                }
1961                Some(begin) => {
1962                    // One turn on: the ring's seam again, or a full turn of
1963                    // the platter for a free cut.
1964                    let closed = match capture.ring_start {
1965                        Some(_) => crossed && turns > begin + 0.5,
1966                        None => turns >= begin + 1.0,
1967                    };
1968                    if closed {
1969                        capture.block_end = Some(frame);
1970                        capture.end_frame = counter + frame as u64;
1971                        capture.done = true;
1972                    }
1973                }
1974            }
1975        }
1976        if let Some(seed) = reseed {
1977            self.seed_take_capture(seed);
1978        }
1979    }
1980
1981    /// After the block is final: the frames the capture covers, into it.
1982    fn revolution_capture_copy(&mut self, frames: usize, channels: usize) {
1983        let Some(capture) = self.revolution_capture.as_mut() else {
1984            return;
1985        };
1986        if capture.begin_turns.is_none() || (capture.done && capture.block_end.is_none()) {
1987            return;
1988        }
1989        let from = capture.block_start.take().unwrap_or(0).min(frames);
1990        let to = capture.block_end.take().unwrap_or(frames).min(frames);
1991        if capture.channels == 0 {
1992            capture.channels = channels.max(1);
1993        }
1994        if capture.channels != channels {
1995            capture.overflow = true;
1996            capture.done = true;
1997            return;
1998        }
1999        if capture.samples.capacity() == 0 {
2000            return;
2001        }
2002        let wanted = (to.saturating_sub(from)) * channels;
2003        let room = capture.samples.capacity() - capture.samples.len();
2004        if wanted > room {
2005            capture.overflow = true;
2006            capture.done = true;
2007        }
2008        let take = wanted.min(room);
2009        let start = (from * channels).min(self.output.len());
2010        let end = (start + take).min(self.output.len());
2011        capture.samples.extend_from_slice(&self.output[start..end]);
2012    }
2013
2014    /// `beginDeterministicReplayFrom`, off the wasm binding.
2015    ///
2016    /// The C ABI and the tests come in here: a `JsValue` cannot be built on
2017    /// a host target, so an invalid argument on the binding's path is an
2018    /// abort on the phone rather than a refused call.
2019    pub fn begin_replay(
2020        &mut self,
2021        position: f64,
2022        rotation_turns: f64,
2023        replay_seed: u32,
2024        rate: f64,
2025    ) -> Result<(), &'static str> {
2026        if !position.is_finite() {
2027            return Err("replay position must be finite");
2028        }
2029        if !rotation_turns.is_finite() {
2030            return Err("replay rotationTurns must be finite");
2031        }
2032        if !rate.is_finite() || rate.abs() > self.config.max_rate {
2033            return Err("replay rate must be finite and within maxRate");
2034        }
2035
2036        self.active = true;
2037        self.position = self.clamp_source_position(position);
2038        self.target_position = self.position;
2039        self.rate = rate;
2040        self.rate_velocity = 0.0;
2041        self.target_rate = rate;
2042        self.wow_phase = rotation_turns.rem_euclid(1.0);
2043        self.flutter_phase = f64::from(replay_seed) / (f64::from(u32::MAX) + 1.0);
2044        self.platter_rotation_turns = rotation_turns;
2045        self.drag_lowpass_state.clear();
2046        self.high_frequency_acceleration_limiter.reset();
2047        self.reset_phono_filters();
2048        self.hand_contact = false;
2049        self.grip = 0.0;
2050        self.release_grip = 0.0;
2051        self.movement_gain_state = f64::NAN;
2052        self.grip_target = 0.0;
2053        self.motor_rate = rate;
2054        self.motor_delivered_rate = rate;
2055        self.unpowered_throw_rate = 0.0;
2056        self.ended = false;
2057        self.contact_impulse = 0.0;
2058        self.last_effective_rate = rate;
2059        self.deck_state
2060            .reset(rate, rate, rotation_turns, rotation_turns)
2061            .map_err(|_| "replay could not reset the platter")?;
2062        self.noise_seed = if replay_seed == 0 {
2063            DEFAULT_REPLAY_NOISE_SEED
2064        } else {
2065            replay_seed
2066        };
2067        self.last_noise = 0.0;
2068        self.last_output_samples.clear();
2069        self.window_miss_frames = 0;
2070        self.window_programme_gain = 1.0;
2071        self.frames_since_motion = 0;
2072        self.frames_since_window_request = self.output_sample_rate as usize;
2073        self.requested_window_position = None;
2074        self.scratch_gate.reset_for_replay();
2075        self.scratch_gate_trace.clear();
2076        self.momentary_crossfader_gain = 1.0;
2077        self.momentary_crossfader_mix = 0.0;
2078        self.momentary_crossfader_mix_target = 0.0;
2079        self.audible_crossfader_gain = if self.scratch_gate.preset() == ScratchPreset::Baby {
2080            self.manual_fader_gain
2081        } else {
2082            self.scratch_gate.gate()
2083        };
2084        self.surface_bed = None;
2085        self.needle_thump = None;
2086        self.needle_burst = None;
2087        Ok(())
2088    }
2089}
2090
2091#[wasm_bindgen]
2092impl ScratchAcousticDsp {
2093    /// Arms a one-revolution capture from the ring starting at
2094    /// `start_position` (source frames; negative means from wherever the
2095    /// platter is), holding at most `max_frames`, reseeding the take with
2096    /// `replay_seed` on the frame it begins. See `RevolutionCapture`.
2097    #[wasm_bindgen(js_name = armRevolutionCapture)]
2098    pub fn arm_revolution_capture(
2099        &mut self,
2100        start_position: f64,
2101        max_frames: u32,
2102        replay_seed: u32,
2103    ) -> Result<(), JsValue> {
2104        self.arm_revolution(start_position, max_frames, replay_seed)
2105            .map_err(JsValue::from_str)
2106    }
2107
2108    #[wasm_bindgen(js_name = cancelRevolutionCapture)]
2109    pub fn cancel_revolution_capture(&mut self) {
2110        self.revolution_capture = None;
2111    }
2112
2113    #[wasm_bindgen(getter, js_name = revolutionCaptureArmed)]
2114    pub fn revolution_capture_armed(&self) -> bool {
2115        self.revolution_capture.is_some()
2116    }
2117
2118    #[wasm_bindgen(getter, js_name = revolutionCaptureBegan)]
2119    pub fn revolution_capture_began(&self) -> bool {
2120        self.revolution_capture
2121            .as_ref()
2122            .is_some_and(|capture| capture.begin_turns.is_some())
2123    }
2124
2125    #[wasm_bindgen(getter, js_name = revolutionCaptureDone)]
2126    pub fn revolution_capture_done(&self) -> bool {
2127        self.revolution_capture
2128            .as_ref()
2129            .is_some_and(|capture| capture.done)
2130    }
2131
2132    #[wasm_bindgen(getter, js_name = revolutionCaptureOverflowed)]
2133    pub fn revolution_capture_overflowed(&self) -> bool {
2134        self.revolution_capture
2135            .as_ref()
2136            .is_some_and(|capture| capture.overflow)
2137    }
2138
2139    /// This engine's rendered-frame counter at the frame the capture began.
2140    #[wasm_bindgen(getter, js_name = revolutionCaptureStartFrame)]
2141    pub fn revolution_capture_start_frame(&self) -> f64 {
2142        self.revolution_capture
2143            .as_ref()
2144            .map_or(0.0, |capture| capture.start_frame as f64)
2145    }
2146
2147    /// This engine's rendered-frame counter at the frame the capture closed
2148    /// on (exclusive); zero until it has.
2149    #[wasm_bindgen(getter, js_name = revolutionCaptureEndFrame)]
2150    pub fn revolution_capture_end_frame(&self) -> f64 {
2151        self.revolution_capture
2152            .as_ref()
2153            .map_or(0.0, |capture| capture.end_frame as f64)
2154    }
2155
2156    #[wasm_bindgen(getter, js_name = revolutionCaptureStartPosition)]
2157    pub fn revolution_capture_start_position(&self) -> f64 {
2158        self.revolution_capture
2159            .as_ref()
2160            .map_or(0.0, |capture| capture.start_position)
2161    }
2162
2163    #[wasm_bindgen(getter, js_name = revolutionCaptureStartRotationTurns)]
2164    pub fn revolution_capture_start_rotation_turns(&self) -> f64 {
2165        self.revolution_capture
2166            .as_ref()
2167            .map_or(0.0, |capture| capture.start_rotation_turns)
2168    }
2169
2170    #[wasm_bindgen(getter, js_name = revolutionCaptureChannels)]
2171    pub fn revolution_capture_channels(&self) -> u32 {
2172        self.revolution_capture
2173            .as_ref()
2174            .map_or(0, |capture| capture.channels as u32)
2175    }
2176
2177    #[wasm_bindgen(getter, js_name = revolutionCaptureFrames)]
2178    pub fn revolution_capture_frames(&self) -> u32 {
2179        self.revolution_capture
2180            .as_ref()
2181            .map_or(0, |capture| (capture.samples.len() / capture.channels.max(1)) as u32)
2182    }
2183
2184    /// The captured revolution, interleaved, and the capture is over.
2185    #[wasm_bindgen(js_name = takeRevolutionCapture)]
2186    pub fn take_revolution_capture(&mut self) -> Vec<f32> {
2187        self.revolution_capture
2188            .take()
2189            .map_or_else(Vec::new, |capture| capture.samples)
2190    }
2191
2192    /// Output frames rendered so far, the clock `revolutionCaptureStartFrame`
2193    /// is on.
2194    #[wasm_bindgen(getter, js_name = renderedFrames)]
2195    pub fn rendered_frames(&self) -> f64 {
2196        self.rendered_frame_counter as f64
2197    }
2198
2199    #[wasm_bindgen(js_name = setHighFrequencyAccelerationLimit)]
2200    pub fn set_high_frequency_acceleration_limit(&mut self, strength: f64) -> Result<(), JsValue> {
2201        if !valid_unit_interval(strength) {
2202            return Err(JsValue::from_str(
2203                "highFrequencyAccelerationLimit must be between 0 and 1",
2204            ));
2205        }
2206        self.config.high_frequency_acceleration_limit = strength;
2207        Ok(())
2208    }
2209
2210    #[wasm_bindgen(getter, js_name = highFrequencyAccelerationLimit)]
2211    pub fn high_frequency_acceleration_limit(&self) -> f64 {
2212        self.config.high_frequency_acceleration_limit
2213    }
2214
2215    #[wasm_bindgen(js_name = setStylusTracingLimit)]
2216    pub fn set_stylus_tracing_limit(&mut self, strength: f64) -> Result<(), JsValue> {
2217        if !valid_unit_interval(strength) {
2218            return Err(JsValue::from_str(
2219                "stylusTracingLimit must be between 0 and 1",
2220            ));
2221        }
2222        self.config.stylus_tracing_limit = strength;
2223        Ok(())
2224    }
2225
2226    #[wasm_bindgen(getter, js_name = stylusTracingLimit)]
2227    pub fn stylus_tracing_limit(&self) -> f64 {
2228        self.config.stylus_tracing_limit
2229    }
2230
2231    #[wasm_bindgen(js_name = setTextureScale)]
2232    pub fn set_texture_scale(&mut self, scale: f64) -> Result<(), JsValue> {
2233        if !valid_texture_scale(scale) {
2234            return Err(JsValue::from_str(
2235                "textureScale must be between 0 and 4",
2236            ));
2237        }
2238        self.config.texture_scale = scale;
2239        Ok(())
2240    }
2241
2242    #[wasm_bindgen(getter, js_name = textureScale)]
2243    pub fn texture_scale(&self) -> f64 {
2244        self.config.texture_scale
2245    }
2246
2247    /// Per-component levels on the surface bed and source texture, each `0`
2248    /// to `4`, `1` the historical level. A default changes no sample.
2249    #[wasm_bindgen(js_name = setContactGain)]
2250    pub fn set_contact_gain(&mut self, gain: f64) -> Result<(), JsValue> {
2251        self.config.contact_gain = valid_surface_gain(gain)?;
2252        Ok(())
2253    }
2254
2255    #[wasm_bindgen(getter, js_name = contactGain)]
2256    pub fn contact_gain(&self) -> f64 {
2257        self.config.contact_gain
2258    }
2259
2260    #[wasm_bindgen(js_name = setDustGain)]
2261    pub fn set_dust_gain(&mut self, gain: f64) -> Result<(), JsValue> {
2262        self.config.dust_gain = valid_surface_gain(gain)?;
2263        Ok(())
2264    }
2265
2266    #[wasm_bindgen(getter, js_name = dustGain)]
2267    pub fn dust_gain(&self) -> f64 {
2268        self.config.dust_gain
2269    }
2270
2271    #[wasm_bindgen(js_name = setImpulseGain)]
2272    pub fn set_impulse_gain(&mut self, gain: f64) -> Result<(), JsValue> {
2273        self.config.impulse_gain = valid_surface_gain(gain)?;
2274        Ok(())
2275    }
2276
2277    #[wasm_bindgen(getter, js_name = impulseGain)]
2278    pub fn impulse_gain(&self) -> f64 {
2279        self.config.impulse_gain
2280    }
2281
2282    #[wasm_bindgen(js_name = setWearGain)]
2283    pub fn set_wear_gain(&mut self, gain: f64) -> Result<(), JsValue> {
2284        self.config.wear_gain = valid_surface_gain(gain)?;
2285        Ok(())
2286    }
2287
2288    #[wasm_bindgen(getter, js_name = wearGain)]
2289    pub fn wear_gain(&self) -> f64 {
2290        self.config.wear_gain
2291    }
2292
2293    #[wasm_bindgen(js_name = setSourceTextureGain)]
2294    pub fn set_source_texture_gain(&mut self, gain: f64) -> Result<(), JsValue> {
2295        self.config.source_texture_gain = valid_surface_gain(gain)?;
2296        Ok(())
2297    }
2298
2299    #[wasm_bindgen(getter, js_name = sourceTextureGain)]
2300    pub fn source_texture_gain(&self) -> f64 {
2301        self.config.source_texture_gain
2302    }
2303
2304    /// The speed-dependent half of the phono chain, live. At nominal speed
2305    /// the tilt is exactly unity, so toggling it only changes off-speed
2306    /// content — which makes it a clean A/B for how much of scratch's edge
2307    /// is the pre-emphasis mismatch rather than the groove itself.
2308    #[wasm_bindgen(js_name = setRiaaSpeedTilt)]
2309    pub fn set_riaa_speed_tilt(&mut self, enabled: bool) {
2310        self.config.riaa_speed_tilt = enabled;
2311    }
2312
2313    #[wasm_bindgen(getter, js_name = riaaSpeedTilt)]
2314    pub fn riaa_speed_tilt(&self) -> bool {
2315        self.config.riaa_speed_tilt
2316    }
2317
2318    /// Optional constant-rate RIAA mismatch for vinyl warmth, live. `1.0` is
2319    /// the standard curve and is bit-exact transparent; values above it trade
2320    /// top end for body. The rate is clamped to the same `[0.1, 4.0]` window
2321    /// as the speed tilt.
2322    #[wasm_bindgen(js_name = setRiaaVoicing)]
2323    pub fn set_riaa_voicing(&mut self, rate: f64) -> Result<(), JsValue> {
2324        if !valid_riaa_voicing_rate(rate) {
2325            return Err(JsValue::from_str("riaaVoicing must be positive"));
2326        }
2327        self.config.riaa_voicing_rate = rate;
2328        Ok(())
2329    }
2330
2331    #[wasm_bindgen(getter, js_name = riaaVoicing)]
2332    pub fn riaa_voicing(&self) -> f64 {
2333        self.config.riaa_voicing_rate
2334    }
2335
2336    /// Optional vinyl voicing amount in `[0, 1]`, live. `0` is bypassed
2337    /// bit-exactly and the blend eases, so toggling it does not click.
2338    #[wasm_bindgen(js_name = setVinylVoicing)]
2339    pub fn set_vinyl_voicing(&mut self, amount: f64) -> Result<(), JsValue> {
2340        if !valid_unit_interval(amount) {
2341            return Err(JsValue::from_str(
2342                "vinylVoicing must be between 0 and 1",
2343            ));
2344        }
2345        self.config.vinyl_voicing = amount;
2346        Ok(())
2347    }
2348
2349    #[wasm_bindgen(getter, js_name = vinylVoicing)]
2350    pub fn vinyl_voicing(&self) -> f64 {
2351        self.config.vinyl_voicing
2352    }
2353
2354    /// Selects one of the voicing shapes live: `0` coil load, `1` tip mass,
2355    /// `2` curve drift. The running filter keeps its delay, so switching does
2356    /// not click.
2357    #[wasm_bindgen(js_name = setVinylVoicingCurve)]
2358    pub fn set_vinyl_voicing_curve(&mut self, curve: u32) -> Result<(), JsValue> {
2359        if curve as usize >= VINYL_VOICING_CURVES.len() {
2360            return Err(JsValue::from_str("vinylVoicingCurve is out of range"));
2361        }
2362        self.config.vinyl_voicing_curve = curve;
2363        Ok(())
2364    }
2365
2366    #[wasm_bindgen(getter, js_name = vinylVoicingCurve)]
2367    pub fn vinyl_voicing_curve(&self) -> u32 {
2368        self.config.vinyl_voicing_curve
2369    }
2370
2371    #[wasm_bindgen(js_name = setSoftClip)]
2372    pub fn set_soft_clip(&mut self, enabled: bool) {
2373        self.config.soft_clip = enabled;
2374    }
2375
2376    #[wasm_bindgen(getter, js_name = softClip)]
2377    pub fn soft_clip(&self) -> bool {
2378        self.config.soft_clip
2379    }
2380
2381    #[wasm_bindgen(js_name = setScratchPreset)]
2382    pub fn set_scratch_preset(&mut self, name: &str) -> Result<(), JsValue> {
2383        let preset = name
2384            .parse::<ScratchPreset>()
2385            .map_err(|error| JsValue::from_str(&error.to_string()))?;
2386        self.scratch_gate.set_preset(preset);
2387        Ok(())
2388    }
2389
2390    #[wasm_bindgen(js_name = setScratchClicks)]
2391    pub fn set_scratch_clicks(&mut self, clicks: u8) {
2392        self.scratch_gate.set_clicks(clicks);
2393    }
2394
2395    #[wasm_bindgen(js_name = setScratchGateAlgorithmVersion)]
2396    pub fn set_scratch_gate_algorithm_version(&mut self, version: u32) {
2397        self.scratch_gate.set_algorithm_version(version);
2398    }
2399
2400    #[wasm_bindgen(getter, js_name = scratchPreset)]
2401    pub fn scratch_preset(&self) -> String {
2402        self.scratch_gate.preset().as_str().to_owned()
2403    }
2404
2405    #[wasm_bindgen(getter, js_name = scratchClicks)]
2406    pub fn scratch_clicks(&self) -> u8 {
2407        self.scratch_gate.clicks()
2408    }
2409
2410    #[wasm_bindgen(getter, js_name = scratchGateAlgorithmVersion)]
2411    pub fn scratch_gate_algorithm_version(&self) -> u32 {
2412        self.scratch_gate.algorithm_version()
2413    }
2414
2415    #[wasm_bindgen(getter, js_name = scratchGate)]
2416    pub fn scratch_gate(&self) -> f64 {
2417        self.scratch_gate.gate()
2418    }
2419
2420    #[wasm_bindgen(getter, js_name = scratchGateTarget)]
2421    pub fn scratch_gate_target(&self) -> f64 {
2422        self.scratch_gate.target()
2423    }
2424
2425    #[wasm_bindgen(getter, js_name = scratchDirection)]
2426    pub fn scratch_direction(&self) -> i32 {
2427        i32::from(self.scratch_gate.direction())
2428    }
2429
2430    #[wasm_bindgen(getter, js_name = scratchMoving)]
2431    pub fn scratch_moving(&self) -> bool {
2432        self.scratch_gate.moving()
2433    }
2434
2435    #[wasm_bindgen(getter, js_name = scratchGatePhase)]
2436    pub fn scratch_gate_phase(&self) -> f64 {
2437        self.scratch_gate.phase()
2438    }
2439
2440    #[wasm_bindgen(getter, js_name = scratchStrokeProgress)]
2441    pub fn scratch_stroke_progress(&self) -> f64 {
2442        self.scratch_gate.stroke_progress()
2443    }
2444
2445    #[wasm_bindgen(js_name = setNeedleLifted)]
2446    pub fn set_needle_lifted(&mut self, lifted: bool) {
2447        self.needle_lifted = lifted;
2448    }
2449
2450    /// How firmly the hand owns the record's position: the seconds the
2451    /// servo takes to close a position error, and the most it may correct
2452    /// by in rad/s. Non-finite or non-positive values leave that half alone.
2453    #[wasm_bindgen(js_name = setHandServo)]
2454    pub fn set_hand_servo(&mut self, stabilization_seconds: f64, max_correction_rad_s: f64) {
2455        let mut deck_config = self.deck_state.config();
2456        if stabilization_seconds.is_finite() && stabilization_seconds > 0.0 {
2457            deck_config.hand_position_stabilization_seconds = stabilization_seconds.clamp(0.001, 2.0);
2458        }
2459        if max_correction_rad_s.is_finite() && max_correction_rad_s > 0.0 {
2460            deck_config.hand_max_position_correction_rad_s = max_correction_rad_s.clamp(0.01, 200.0);
2461        }
2462        let telemetry = self.deck_state.telemetry();
2463        if let Err(error) = self.deck_state.reconfigure(deck_config) {
2464            self.record_deck_recovery(
2465                DeckRecoveryOperation::MechanicalAdvance,
2466                DeckMechanicalError::InvalidConfig(error),
2467                telemetry,
2468                self.target_rate,
2469            );
2470        }
2471    }
2472
2473    #[wasm_bindgen(js_name = setNativeRpm)]
2474    pub fn set_native_rpm(&mut self, native_rpm: f64) -> Result<(), JsValue> {
2475        if !native_rpm.is_finite() || native_rpm <= 0.0 {
2476            return Err(JsValue::from_str("nativeRpm must be positive"));
2477        }
2478        let native_rpm = native_rpm.clamp(16.0, 90.0);
2479        let telemetry = self.deck_state.telemetry();
2480        let mut deck_config = self.deck_state.config();
2481        deck_config.nominal_rpm = native_rpm;
2482        deck_config.hand_max_position_correction_rad_s =
2483            0.12 * deck_config.nominal_angular_velocity_rad_s();
2484        self.deck_state
2485            .reconfigure(deck_config)
2486            .map_err(|error| JsValue::from_str(&error.to_string()))?;
2487        self.deck_state
2488            .reset(
2489                telemetry.platter_rate,
2490                telemetry.record_rate,
2491                telemetry.platter_angle_turns,
2492                telemetry.record_angle_turns,
2493            )
2494            .map_err(|error| JsValue::from_str(&error.to_string()))?;
2495        self.native_rpm = native_rpm;
2496        Ok(())
2497    }
2498
2499    /// Sets how much the record slips: none at zero, loosest mat at one.
2500    ///
2501    /// The mat is named for what it does, so the number runs with the name.
2502    /// Zero is the tightest coupling the deck has — the record turns with the
2503    /// platter — and one is the loosest mat, which lets the record slide on
2504    /// after the hand leaves it.
2505    #[wasm_bindgen(js_name = setSlipmatResponse)]
2506    pub fn set_slipmat_response(&mut self, slip: f64) -> Result<(), JsValue> {
2507        if !valid_unit_interval(slip) {
2508            return Err(JsValue::from_str("slipmatResponse must be between 0 and 1"));
2509        }
2510        let reference = production_deck_config(self.output_sample_rate, self.native_rpm);
2511        let scale = TIGHT_SLIPMAT_COUPLING_SCALE
2512            + slip * (LOOSE_SLIPMAT_COUPLING_SCALE - TIGHT_SLIPMAT_COUPLING_SCALE);
2513        let mut deck_config = self.deck_state.config();
2514        deck_config.slipmat_static_torque_nm = reference.slipmat_static_torque_nm * scale;
2515        deck_config.slipmat_kinetic_torque_nm = reference.slipmat_kinetic_torque_nm * scale;
2516        deck_config.slipmat_viscous_torque_nm_per_rad_s =
2517            reference.slipmat_viscous_torque_nm_per_rad_s * scale;
2518        // Reconfigure only: a reset here would zero the motor integrator and
2519        // contact modes on every slider tick, wobbling live playback.
2520        self.deck_state
2521            .reconfigure(deck_config)
2522            .map_err(|error| JsValue::from_str(&error.to_string()))?;
2523        Ok(())
2524    }
2525
2526    /// Scales the platter bearing's friction: frictionless at zero, the
2527    /// stock deck at one, heavier beyond.
2528    ///
2529    /// This is the only brake on a platter thrown by hand with the motor
2530    /// off, so it is the knob that decides how long a free spin coasts —
2531    /// from forever at zero to a fast die-off at the top of the range.
2532    #[wasm_bindgen(js_name = setBearingFriction)]
2533    pub fn set_bearing_friction(&mut self, scale: f64) -> Result<(), JsValue> {
2534        if !scale.is_finite() || scale < 0.0 {
2535            return Err(JsValue::from_str("bearingFriction must be zero or more"));
2536        }
2537        let scale = scale.min(16.0);
2538        let reference = production_deck_config(self.output_sample_rate, self.native_rpm);
2539        let mut deck_config = self.deck_state.config();
2540        deck_config.bearing_static_torque_nm = reference.bearing_static_torque_nm * scale;
2541        deck_config.bearing_kinetic_torque_nm = reference.bearing_kinetic_torque_nm * scale;
2542        deck_config.bearing_viscous_torque_nm_per_rad_s =
2543            reference.bearing_viscous_torque_nm_per_rad_s * scale;
2544        // Reconfigure only, as the slipmat setter does: a reset would zero
2545        // the motor integrator and contact modes mid-flight.
2546        self.deck_state
2547            .reconfigure(deck_config)
2548            .map_err(|error| JsValue::from_str(&error.to_string()))?;
2549        Ok(())
2550    }
2551
2552    /// Sets the motor-off thrust: e-folds of rate per second while the
2553    /// platter coasts. Zero is a plain bearing; 0.02 is a solar sail's
2554    /// patience; anything near two doubles the spin faster than a hand
2555    /// could. Clamped there because beyond it the platter is a turbine.
2556    #[wasm_bindgen(js_name = setFreeSpinDrive)]
2557    pub fn set_free_spin_drive(&mut self, per_second: f64) -> Result<(), JsValue> {
2558        if !per_second.is_finite() || per_second < 0.0 {
2559            return Err(JsValue::from_str("freeSpinDrive must be zero or more"));
2560        }
2561        self.free_spin_drive_per_second = per_second.min(2.0);
2562        Ok(())
2563    }
2564
2565    /// Press defects: the spindle hole's eccentricity and the disc's warp,
2566    /// both in millimetres, both once-per-revolution and angle-indexed.
2567    #[wasm_bindgen(js_name = setPressDefects)]
2568    pub fn set_press_defects(
2569        &mut self,
2570        eccentricity_mm: f64,
2571        warp_mm: f64,
2572    ) -> Result<(), JsValue> {
2573        if !eccentricity_mm.is_finite() || eccentricity_mm < 0.0 {
2574            return Err(JsValue::from_str("eccentricity must be zero or more"));
2575        }
2576        if !warp_mm.is_finite() || warp_mm < 0.0 {
2577            return Err(JsValue::from_str("warp must be zero or more"));
2578        }
2579        self.eccentricity_mm = eccentricity_mm.min(3.0);
2580        self.warp_mm = warp_mm.min(4.0);
2581        Ok(())
2582    }
2583
2584    /// A second stylus riding the same groove `degrees` behind the first,
2585    /// mixed at `level`. Zero level lifts it off.
2586    #[wasm_bindgen(js_name = setStylusTap)]
2587    pub fn set_stylus_tap(
2588        &mut self,
2589        degrees: f64,
2590        level: f64,
2591    ) -> Result<(), JsValue> {
2592        if !degrees.is_finite() || !(0.0..=359.0).contains(&degrees) {
2593            return Err(JsValue::from_str("tap degrees must be 0..=359"));
2594        }
2595        if !level.is_finite() || !(0.0..=1.0).contains(&level) {
2596            return Err(JsValue::from_str("tap level must be 0..=1"));
2597        }
2598        self.stylus_tap_degrees = degrees;
2599        self.stylus_tap_level = level;
2600        Ok(())
2601    }
2602
2603    /// The angle gate: `sectors` openings per revolution, cut to `depth`.
2604    /// Zero sectors is off. Geometry, not tempo-sync: it stays locked under
2605    /// scratching and free-spin decay because it is read off the platter.
2606    #[wasm_bindgen(js_name = setAngleGate)]
2607    pub fn set_angle_gate(
2608        &mut self,
2609        sectors: u32,
2610        depth: f64,
2611    ) -> Result<(), JsValue> {
2612        if sectors > 32 {
2613            return Err(JsValue::from_str("gate sectors must be 0..=32"));
2614        }
2615        if !depth.is_finite() || !(0.0..=1.0).contains(&depth) {
2616            return Err(JsValue::from_str("gate depth must be 0..=1"));
2617        }
2618        self.angle_gate_sectors = sectors;
2619        self.angle_gate_depth = depth;
2620        Ok(())
2621    }
2622
2623    /// The live vinyl Vfx scene: the shared geometry-driven processor the
2624    /// offline REMIX render and the iOS deck already play. Scene zero is
2625    /// off; amount rides 0..=1.
2626    #[wasm_bindgen(js_name = setVinylVfx)]
2627    pub fn set_vinyl_vfx(&mut self, scene: u32, amount: f64) -> Result<(), JsValue> {
2628        if scene > VINYL_VFX_MAX_SCENE {
2629            return Err(JsValue::from_str("vinyl vfx scene is out of range"));
2630        }
2631        if !amount.is_finite() || !(0.0..=1.0).contains(&amount) {
2632            return Err(JsValue::from_str("vinyl vfx amount must be 0..=1"));
2633        }
2634        self.vinyl_vfx.set_scene(scene, amount);
2635        Ok(())
2636    }
2637
2638    /// Drops the needle into a locked groove starting at `start_frame`:
2639    /// playback wraps every revolution inside that ring until the host
2640    /// passes a negative frame to clear it. Seeks and hand motion are phase
2641    /// changes inside the ring; they cannot escape it.
2642    #[wasm_bindgen(js_name = setLockedGroove)]
2643    pub fn set_locked_groove(&mut self, start_frame: f64) -> Result<(), JsValue> {
2644        if start_frame.is_nan() {
2645            return Err(JsValue::from_str("locked groove start must be a number"));
2646        }
2647        let previous_position = self.position;
2648        self.locked_groove_start = if start_frame < 0.0 {
2649            -1.0
2650        } else {
2651            start_frame
2652        };
2653        self.enforce_locked_groove();
2654        if (self.position - previous_position).abs() > f64::EPSILON {
2655            self.begin_output_seam_repair();
2656        }
2657        Ok(())
2658    }
2659
2660    /// Groove wear: every pass of the stylus wears where it passed, adding
2661    /// crackle and dulling the highs in the bars that have been played
2662    /// most. `rate` scales both how fast wear accrues and how loudly it
2663    /// reads; zero is a mint pressing. At one, a region is fully worn after
2664    /// roughly fifty passes.
2665    #[wasm_bindgen(js_name = setGrooveWear)]
2666    pub fn set_groove_wear(&mut self, rate: f64) -> Result<(), JsValue> {
2667        if !rate.is_finite() || rate < 0.0 {
2668            return Err(JsValue::from_str("wear rate must be zero or more"));
2669        }
2670        self.groove_wear_rate = rate.min(8.0);
2671        if self.groove_wear_rate > 0.0 && self.groove_wear.is_empty() && self.total_frames > 0 {
2672            self.groove_wear =
2673                vec![0.0; self.total_frames / WEAR_BUCKET_FRAMES + 1];
2674        }
2675        Ok(())
2676    }
2677
2678    /// The wear map, for persistence: a record's biography rides with it.
2679    #[wasm_bindgen(js_name = grooveWearMap)]
2680    pub fn groove_wear_map(&self) -> Vec<f32> {
2681        self.groove_wear.clone()
2682    }
2683
2684    /// Restores a persisted wear map. Length is reconciled to the loaded
2685    /// source; a map from another pressing simply wears the wrong places,
2686    /// which is the caller's mistake to avoid via the record hash.
2687    #[wasm_bindgen(js_name = restoreGrooveWearMap)]
2688    pub fn restore_groove_wear_map(&mut self, map: &[f32]) {
2689        let len = if self.total_frames > 0 {
2690            self.total_frames / WEAR_BUCKET_FRAMES + 1
2691        } else {
2692            map.len()
2693        };
2694        let mut restored = vec![0.0_f32; len];
2695        for (slot, value) in restored.iter_mut().zip(map.iter()) {
2696            *slot = value.clamp(0.0, 1.0);
2697        }
2698        self.groove_wear = restored;
2699    }
2700
2701    /// The halo, for a take's world.
2702    #[wasm_bindgen(js_name = haloWearMap)]
2703    pub fn halo_wear_map(&self) -> Vec<f32> {
2704        self.vinyl_vfx.halo_wear_map()
2705    }
2706
2707    #[wasm_bindgen(js_name = restoreHaloWearMap)]
2708    pub fn restore_halo_wear_map(&mut self, map: &[f32]) {
2709        self.vinyl_vfx.restore_halo_wear(map);
2710    }
2711
2712    /// The seed a take is cut under.
2713    ///
2714    /// A replay begins from `replay_seed` — its surface noise and its flutter
2715    /// phase are derived from the take's identity — so the recording has to
2716    /// have begun from the same place or the two can never agree. Called at
2717    /// punch-in with the take's seed: the noise generator and the flutter
2718    /// phase are re-seeded, and nothing else moves, because the platter is
2719    /// live and a hand may be on it. Noise is noise, so the join is silent.
2720    #[wasm_bindgen(js_name = seedTakeCapture)]
2721    pub fn seed_take_capture(&mut self, replay_seed: u32) {
2722        self.noise_seed = if replay_seed == 0 {
2723            DEFAULT_REPLAY_NOISE_SEED
2724        } else {
2725            replay_seed
2726        };
2727        self.last_noise = 0.0;
2728        self.flutter_phase = f64::from(replay_seed) / (f64::from(u32::MAX) + 1.0);
2729        // The wow runs on its own clock, and a replay begins it at the
2730        // platter's angle — so the recording begins it there too. A once-
2731        // per-revolution phase step, taken while the record is live: a
2732        // fraction of a cent for one turn.
2733        self.wow_phase = self.platter_rotation_turns.rem_euclid(1.0);
2734    }
2735
2736    /// Clears accumulated wear, by scope.
2737    ///
2738    /// Wear is the one thing here meant to outlive a pass, so it only goes
2739    /// away when something says so — one of these scopes, or a new record
2740    /// on the platter. The scopes are the three things that actually
2741    /// accumulate, and nothing else in the engine does:
2742    ///
2743    /// - `groove` — the WEAR dial's map, one bucket per
2744    ///   `WEAR_BUCKET_FRAMES` of source, so it wears where the stylus went.
2745    /// - `halo` — WORN HALO's bins, indexed by phase within one revolution,
2746    ///   so it wears where on the *turn* the stylus went. A different
2747    ///   quantity from `groove`, and cleared separately.
2748    /// - `polar` — the revolution memory ADJACENT GHOST, THREE NEEDLES and
2749    ///   SPLIT WALLS read back from, and the filters riding on it.
2750    /// - `all` — the three above.
2751    #[wasm_bindgen(js_name = resetWear)]
2752    pub fn reset_wear(&mut self, scope: &str) -> Result<(), JsValue> {
2753        let Some(scope) = WearScope::parse(scope) else {
2754            return Err(JsValue::from_str(
2755                "wear scope must be groove, halo, polar or all",
2756            ));
2757        };
2758        self.reset_wear_scope(scope);
2759        Ok(())
2760    }
2761
2762    /// The meters' numbers, one scalar at a time.
2763    ///
2764    /// `wearSummary` allocates a string, which the render thread must not do
2765    /// at telemetry rate, so the worklet reads these instead and the summary
2766    /// is kept for one-shot queries. Each walks its buffer and allocates
2767    /// nothing.
2768    #[wasm_bindgen(getter, js_name = grooveWearLevel)]
2769    pub fn groove_wear_level(&self) -> f64 {
2770        if self.groove_wear.is_empty() {
2771            return 0.0;
2772        }
2773        self.groove_wear
2774            .iter()
2775            .map(|value| f64::from(*value))
2776            .sum::<f64>()
2777            / self.groove_wear.len() as f64
2778    }
2779
2780    #[wasm_bindgen(getter, js_name = grooveWearPeak)]
2781    pub fn groove_wear_peak(&self) -> f64 {
2782        self.groove_wear
2783            .iter()
2784            .fold(0.0_f64, |peak, value| peak.max(f64::from(*value)))
2785    }
2786
2787    #[wasm_bindgen(getter, js_name = grooveWearBuckets)]
2788    pub fn groove_wear_buckets(&self) -> u32 {
2789        self.groove_wear.len() as u32
2790    }
2791
2792    #[wasm_bindgen(getter, js_name = haloWearLevel)]
2793    pub fn halo_wear_level(&self) -> f64 {
2794        self.vinyl_vfx.wear_level()
2795    }
2796
2797    #[wasm_bindgen(getter, js_name = haloWearPeak)]
2798    pub fn halo_wear_peak(&self) -> f64 {
2799        self.vinyl_vfx.wear_peak()
2800    }
2801
2802    #[wasm_bindgen(getter, js_name = polarFill)]
2803    pub fn polar_fill(&self) -> f64 {
2804        self.vinyl_vfx.polar_fill_ratio()
2805    }
2806
2807    /// Every accumulator's real allocation, in bytes.
2808    #[wasm_bindgen(getter, js_name = wearBytes)]
2809    pub fn wear_bytes(&self) -> u32 {
2810        (self.vinyl_vfx.memory_bytes()
2811            + self.groove_wear.len() * std::mem::size_of::<f32>()) as u32
2812    }
2813
2814    /// What each accumulator is holding — the numbers behind the meters.
2815    ///
2816    /// Levels are 0..=1 and bytes are the real allocation, so a deck can
2817    /// report what the revolution memory actually costs rather than quoting
2818    /// a constant that drifts when the bin counts change.
2819    #[wasm_bindgen(js_name = wearSummary)]
2820    pub fn wear_summary(&self) -> String {
2821        let groove_level = if self.groove_wear.is_empty() {
2822            0.0
2823        } else {
2824            self.groove_wear
2825                .iter()
2826                .map(|value| f64::from(*value))
2827                .sum::<f64>()
2828                / self.groove_wear.len() as f64
2829        };
2830        let groove_peak = self
2831            .groove_wear
2832            .iter()
2833            .fold(0.0_f64, |peak, value| peak.max(f64::from(*value)));
2834        serde_json::json!({
2835            "grooveRate": self.groove_wear_rate,
2836            "grooveLevel": groove_level,
2837            "groovePeak": groove_peak,
2838            "grooveBuckets": self.groove_wear.len(),
2839            "grooveBytes": self.groove_wear.len() * std::mem::size_of::<f32>(),
2840            "grooveBucketFrames": WEAR_BUCKET_FRAMES,
2841            "haloLevel": self.vinyl_vfx.wear_level(),
2842            "haloPeak": self.vinyl_vfx.wear_peak(),
2843            "haloBins": VinylVfxProcessor::wear_bin_count(),
2844            "polarFill": self.vinyl_vfx.polar_fill_ratio(),
2845            "polarBins": VinylVfxProcessor::polar_bin_count(),
2846            "vfxBytes": self.vinyl_vfx.memory_bytes(),
2847            "scene": self.vinyl_vfx.scene(),
2848            "totalBytes": self.vinyl_vfx.memory_bytes()
2849                + self.groove_wear.len() * std::mem::size_of::<f32>(),
2850        })
2851        .to_string()
2852    }
2853
2854    /// Seeds this pressing's surface character. Two pressings of the same
2855    /// track crackle like two copies, not two files; zero keeps the classic
2856    /// pattern.
2857    #[wasm_bindgen(js_name = setPressingSeed)]
2858    pub fn set_pressing_seed(&mut self, seed: u32) {
2859        self.pressing_seed = seed;
2860    }
2861
2862    #[wasm_bindgen(getter, js_name = nativeRpm)]
2863    pub fn native_rpm(&self) -> f64 {
2864        self.native_rpm
2865    }
2866
2867    #[wasm_bindgen(getter, js_name = platterRotationTurns)]
2868    pub fn platter_rotation_turns(&self) -> f64 {
2869        self.platter_rotation_turns
2870    }
2871
2872    /// Counts rejected deck steps that used the last valid motion state.
2873    #[wasm_bindgen(getter, js_name = deckRecoveryCount)]
2874    pub fn deck_recovery_count(&self) -> u64 {
2875        self.deck_recovery_count
2876    }
2877
2878    #[wasm_bindgen(js_name = setMotion)]
2879    pub fn set_motion(&mut self, position: f64, rate: f64, impulse: f64) {
2880        self.target_position = self.normalize_locked_groove_position(
2881            self.clamp_source_position(position),
2882        );
2883        self.previous_target_rate = self.target_rate;
2884        self.target_rate = self.map_rate(rate);
2885        self.motion_interval_frames = self.frames_since_motion;
2886        self.frames_since_motion = 0;
2887        if impulse > 0.0 {
2888            self.contact_impulse = self.contact_impulse.max(impulse).clamp(0.0, 1.0);
2889        }
2890    }
2891
2892    #[wasm_bindgen(js_name = setTransport)]
2893    pub fn set_transport(
2894        &mut self,
2895        hand_contact: bool,
2896        motor_rate: f64,
2897        hand_rate: f64,
2898        grip: f64,
2899    ) {
2900        let released_hand = self.hand_contact && !hand_contact;
2901        if released_hand {
2902            // A lifting finger's normal force collapses over a few
2903            // milliseconds rather than in a single sample.
2904            self.release_grip = self.grip;
2905        } else if hand_contact {
2906            self.release_grip = 0.0;
2907            self.movement_gain_state = f64::NAN;
2908        }
2909        let motor_rate =
2910            finite_or_zero(motor_rate).clamp(-self.config.max_rate, self.config.max_rate);
2911        if released_hand && motor_rate.abs() < DEADZONE_RATE {
2912            self.unpowered_throw_rate = self
2913                .last_effective_rate
2914                .clamp(-self.config.max_rate, self.config.max_rate);
2915        } else if hand_contact || motor_rate.abs() >= DEADZONE_RATE {
2916            self.unpowered_throw_rate = 0.0;
2917        }
2918        self.hand_contact = hand_contact;
2919        self.grip_target = if hand_contact {
2920            if grip.is_finite() {
2921                grip.clamp(0.0, 1.0)
2922            } else {
2923                1.0
2924            }
2925        } else {
2926            0.0
2927        };
2928        if !hand_contact {
2929            self.scratch_gate.release();
2930        }
2931        self.motor_rate = motor_rate;
2932        if self.motor_rate != 0.0 {
2933            self.ended = false;
2934        }
2935        if hand_contact {
2936            self.target_position = self.position;
2937            self.target_rate = self.map_rate(hand_rate);
2938            self.frames_since_motion = 0;
2939        } else {
2940            self.target_position = self.position;
2941        }
2942    }
2943
2944    #[wasm_bindgen(js_name = setPosition)]
2945    pub fn set_position(&mut self, position: f64, impulse: f64) {
2946        self.begin_output_seam_repair();
2947        self.position = self.normalize_locked_groove_position(
2948            self.clamp_source_position(position),
2949        );
2950        self.target_position = self.position;
2951        self.high_frequency_acceleration_limiter.reset();
2952        self.window_miss_frames = 0;
2953        self.window_programme_gain = 1.0;
2954        self.ended = false;
2955        if impulse > 0.0 {
2956            self.contact_impulse = self.contact_impulse.max(impulse).clamp(0.0, 1.0);
2957        }
2958    }
2959
2960    #[wasm_bindgen(js_name = resetPosition)]
2961    pub fn reset_position_export(&mut self, position: f64) {
2962        self.reset_position(position);
2963    }
2964
2965    #[wasm_bindgen(js_name = render)]
2966    pub fn render(&mut self, frame_count: u32, output_channel_count: u32) -> u32 {
2967        let frame_count = frame_count as usize;
2968        let output_channel_count = (output_channel_count as usize).clamp(1, 2);
2969        let vfx_start_turns = self.platter_rotation_turns;
2970        let vfx_start_position = self.position;
2971        self.output
2972            .resize(frame_count.saturating_mul(output_channel_count), 0.0);
2973        self.output.fill(0.0);
2974        self.scratch_gate_trace.resize(frame_count, 1.0);
2975        self.requested_window_position = None;
2976        if frame_count == 0 {
2977            return 0;
2978        }
2979        if !self.active || self.channels.is_empty() || self.total_frames <= 1 {
2980            let gate_contact = self.active && self.hand_contact;
2981            let intent_rate = if gate_contact { self.target_rate } else { 0.0 };
2982            let rendered_rate = if gate_contact {
2983                self.last_effective_rate
2984            } else {
2985                0.0
2986            };
2987            self.advance_scratch_gate_trace(frame_count, gate_contact, intent_rate, rendered_rate);
2988            self.mix_foley(frame_count, output_channel_count);
2989            self.apply_crossfader_trace(frame_count, output_channel_count);
2990            self.apply_output_gain(frame_count, output_channel_count);
2991            self.apply_vinyl_vfx(
2992                frame_count,
2993                output_channel_count,
2994                vfx_start_turns,
2995                vfx_start_position,
2996            );
2997            self.revolution_capture_copy(frame_count, output_channel_count);
2998            self.rendered_frame_counter += frame_count as u64;
2999            return u32::try_from(frame_count).unwrap_or(u32::MAX);
3000        }
3001        self.drag_lowpass_state.resize(output_channel_count, 0.0);
3002        self.last_output_samples.resize(output_channel_count, 0.0);
3003        let dt = 1.0 / self.output_sample_rate;
3004        let hold_frames = (self.output_sample_rate * MOTION_HOLD_SECONDS).max(1.0) as usize;
3005        let hold_release_frames = (self.output_sample_rate * MOTION_HOLD_RELEASE_SECONDS).max(1.0);
3006        // The hand is trusted to keep moving until the hold says it has
3007        // stopped — the same span the rate feed-forward was always trusted
3008        // for. A shorter reach froze the target while the rate still ran,
3009        // and a firm hand's servo then balanced the two and stalled the
3010        // record a few frames short of the target.
3011        let reach_frames = hold_frames;
3012        let grip_seconds = if self.grip_target > self.grip {
3013            GRIP_ATTACK_SECONDS
3014        } else {
3015            GRIP_RELEASE_SECONDS
3016        };
3017        let grip_alpha = 1.0 - (-1.0 / (self.output_sample_rate * grip_seconds)).exp();
3018        let rate_scale = self.source_sample_rate / self.output_sample_rate;
3019        let miss_fade_frames = (self.output_sample_rate * WINDOW_MISS_FADE_SECONDS)
3020            .round()
3021            .max(1.0);
3022
3023        let mut rendered_frames = frame_count;
3024        let mut ended_this_render = false;
3025        for frame in 0..frame_count {
3026            // Lock ownership is checked before the source is sampled. A seek
3027            // or scratch target outside the ring therefore cannot leak even
3028            // one sample from another revolution.
3029            self.enforce_locked_groove();
3030            self.frames_since_motion = self.frames_since_motion.saturating_add(1);
3031            // The host samples the hand at its pointer rate, and the hand
3032            // keeps moving in between. Held still until the next sample
3033            // arrived, the target dragged the record back to where the hand
3034            // *was*: at sixty hertz a steady stroke became a stop and a
3035            // lurch every sixteen milliseconds, which is the chop heard
3036            // under every scratch. The target moves at the hand's own rate
3037            // until the next sample says otherwise, so a steady hand asks
3038            // nothing of the servo and only a change of speed does.
3039            // A hand that is speeding up or slowing down carries on doing so
3040            // until the next sample: the rate's slope across the last
3041            // interval is carried through this one. Held flat, a changing
3042            // hand accrued half the acceleration times the interval squared
3043            // every sample, which the servo then had to remove as a jerk.
3044            let reckoned_rate = if self.hand_contact
3045                && self.frames_since_motion <= reach_frames
3046                && self.motion_interval_frames > 0
3047            {
3048                let slope = (self.target_rate - self.previous_target_rate)
3049                    / self.motion_interval_frames as f64;
3050                self.map_rate(self.target_rate + slope * self.frames_since_motion as f64)
3051            } else {
3052                self.target_rate
3053            };
3054            if self.hand_contact && self.frames_since_motion <= reach_frames {
3055                self.target_position = self.clamp_source_position(
3056                    self.target_position + reckoned_rate * rate_scale,
3057                );
3058            }
3059            self.grip += (self.grip_target - self.grip) * grip_alpha;
3060            // A still hand cannot reclaim angle that slipped underneath it.
3061            // Once motion input stops, the anchor follows the record instead
3062            // of winching it back to the original grab frame.
3063            if self.hand_contact && self.frames_since_motion > hold_frames {
3064                self.target_position +=
3065                    (self.position - self.target_position) / (hold_release_frames * 0.25);
3066            }
3067            let hand_rate = if self.frames_since_motion > hold_frames {
3068                self.target_rate
3069                    * (-((self.frames_since_motion - hold_frames) as f64) / hold_release_frames)
3070                        .exp()
3071            } else {
3072                reckoned_rate
3073            };
3074            let held_target_rate = if self.hand_contact {
3075                hand_rate
3076            } else if self.motor_rate.abs() >= DEADZONE_RATE {
3077                self.motor_rate
3078            } else {
3079                self.unpowered_throw_rate
3080            };
3081            let corrected_rate = self.advance_deck_mechanics(hand_rate);
3082            self.revolution_capture_frame(frame);
3083            let abs_rate = corrected_rate.abs();
3084            let effective_rate = if self.config.acoustic_enabled {
3085                corrected_rate
3086                    + sign_nonzero(corrected_rate, held_target_rate)
3087                        * self.advance_wow_flutter(corrected_rate, rate_scale, abs_rate)
3088            } else {
3089                corrected_rate
3090            };
3091            // An off-centre hole swings the groove radius the stylus reads
3092            // once per revolution: pitch deviation is eccentricity over
3093            // groove radius, so the warble deepens toward the label. Angle-
3094            // indexed, not clocked — scrub the platter and the warble
3095            // scrubs with it; a decaying free spin slows its own wobble.
3096            let effective_rate = if self.eccentricity_mm > 0.0 {
3097                let walked = if self.total_frames > 0 {
3098                    (self.position / self.total_frames as f64).clamp(0.0, 1.0)
3099                } else {
3100                    0.0
3101                };
3102                let groove_radius_mm = SINGLE_OUTER_GROOVE_MM
3103                    + (SINGLE_INNER_GROOVE_MM - SINGLE_OUTER_GROOVE_MM) * walked;
3104                let deviation = self.eccentricity_mm / groove_radius_mm;
3105                effective_rate
3106                    * (1.0
3107                        + deviation
3108                            * (self.platter_rotation_turns
3109                                * std::f64::consts::TAU)
3110                                .sin())
3111            } else {
3112                effective_rate
3113            };
3114            // Warp lifts the stylus over the high spot once per revolution;
3115            // the gate cuts sectors out of the same rotation. Both read the
3116            // record's angle, and both are smoothed a little so an edge is
3117            // a chop, not a click.
3118            let warp_gain = if self.warp_mm > 0.0 {
3119                let lift = ((self.platter_rotation_turns * std::f64::consts::TAU).sin()
3120                    * 0.5
3121                    + 0.5)
3122                    .powi(3);
3123                1.0 - (self.warp_mm * 0.18).min(0.8) * lift
3124            } else {
3125                1.0
3126            };
3127            let gate_target = if self.angle_gate_sectors > 0 {
3128                let sector_phase = (self.platter_rotation_turns
3129                    * f64::from(self.angle_gate_sectors))
3130                .rem_euclid(1.0);
3131                if sector_phase < 0.5 {
3132                    1.0
3133                } else {
3134                    1.0 - self.angle_gate_depth
3135                }
3136            } else {
3137                1.0
3138            };
3139            let gate_alpha =
3140                1.0 - (-1.0 / (self.output_sample_rate * 0.0015)).exp();
3141            self.angle_gate_gain += (gate_target - self.angle_gate_gain) * gate_alpha;
3142            // Wear: the stylus takes a little from wherever it passes, and
3143            // reads back what every earlier pass has taken.
3144            let worn = if self.groove_wear_rate > 0.0 && !self.groove_wear.is_empty() {
3145                let bucket = ((self.position.max(0.0) as usize)
3146                    / WEAR_BUCKET_FRAMES)
3147                    .min(self.groove_wear.len() - 1);
3148                if !self.needle_lifted && abs_rate > DEADZONE_RATE {
3149                    let accumulated = f64::from(self.groove_wear[bucket])
3150                        + abs_rate * dt * self.groove_wear_rate;
3151                    self.groove_wear[bucket] = accumulated.min(1.0) as f32;
3152                }
3153                f64::from(self.groove_wear[bucket]) * self.groove_wear_rate.min(1.0)
3154            } else {
3155                0.0
3156            };
3157            self.scratch_gate_trace[frame] =
3158                self.scratch_gate
3159                    .process(dt, self.hand_contact, hand_rate, effective_rate)
3160                    as f32;
3161            let movement_gain_target =
3162                compute_movement_gain(
3163                    abs_rate,
3164                    self.config.acoustic_enabled,
3165                    self.config.cartridge_velocity_gain,
3166                );
3167            if self.movement_gain_state.is_nan() {
3168                // First render after a start or reset: the deck is already
3169                // wherever it is, so the gain begins there — a deck seeded
3170                // at speed renders bit-exact from its first frame.
3171                self.movement_gain_state = movement_gain_target;
3172            } else {
3173                let movement_gain_alpha =
3174                    1.0 - (-1.0 / (self.output_sample_rate * MOVEMENT_GAIN_SECONDS)).exp();
3175                self.movement_gain_state +=
3176                    (movement_gain_target - self.movement_gain_state) * movement_gain_alpha;
3177                // Converged is equal: steady playback must stay bit-exact.
3178                if (self.movement_gain_state - movement_gain_target).abs() < 1.0e-4 {
3179                    self.movement_gain_state = movement_gain_target;
3180                }
3181            }
3182            let movement_gain = self.movement_gain_state;
3183            let surface_noise = if self.config.surface_enabled {
3184                self.next_noise()
3185            } else {
3186                0.0
3187            };
3188            let highpassed_noise = if self.config.surface_enabled {
3189                surface_noise - self.last_noise
3190            } else {
3191                0.0
3192            };
3193            self.last_noise = surface_noise;
3194            let near_realtime_distance = (abs_rate - 1.0).abs();
3195            let realtime_acceleration_dip =
3196                1.0 - 0.88 * (-(near_realtime_distance * near_realtime_distance) / 0.16).exp();
3197            let rate_delta = (corrected_rate - self.last_effective_rate).abs();
3198            let acceleration_noise =
3199                (rate_delta * 0.00028 * realtime_acceleration_dip).clamp(0.0, 0.0007);
3200            let contact_noise_gain =
3201                (compute_contact_noise_gain(abs_rate) + acceleration_noise)
3202                    * self.config.texture_scale;
3203            let impulse_noise = if self.config.surface_enabled && self.contact_impulse > 0.0001 {
3204                self.next_noise()
3205                    * self.contact_impulse
3206                    * 0.004
3207                    * self.config.texture_scale
3208                    * self.config.impulse_gain
3209            } else {
3210                0.0
3211            };
3212            let groove_surface = if self.config.surface_enabled {
3213                self.compute_position_surface_noise(self.position, abs_rate)
3214            } else {
3215                0.0
3216            };
3217            let source_texture_gain = if self.config.acoustic_enabled {
3218                compute_source_texture_gain(abs_rate, rate_delta)
3219                    * self.config.texture_scale
3220                    * self.config.source_texture_gain
3221            } else {
3222                0.0
3223            };
3224            let dust_fleck = if self.config.surface_enabled {
3225                self.compute_dust_fleck(self.position, abs_rate) * self.config.dust_gain
3226            } else {
3227                0.0
3228            };
3229            let contact_texture = if self.config.surface_enabled {
3230                (groove_surface * 0.76 + highpassed_noise * 0.18)
3231                    * contact_noise_gain
3232                    * self.config.contact_gain
3233            } else {
3234                0.0
3235            };
3236            // A stylus pinned against a clamped record edge reads nothing:
3237            // fade the programme out approaching the pin instead of holding
3238            // a full-level frozen sample there.
3239            let edge_fade_frames = (self.source_sample_rate * EDGE_FADE_SECONDS).max(1.0);
3240            let programme_end = self.total_frames.max(self.window_end).saturating_sub(2) as f64;
3241            let start_distance = self.position.max(0.0);
3242            let end_distance = (programme_end - self.position).max(0.0);
3243            // Only the edge being pushed into fades; playing away from an
3244            // edge stays bit-exact.
3245            let pinned_distance = if effective_rate < 0.0 {
3246                start_distance
3247            } else if effective_rate > 0.0 {
3248                end_distance
3249            } else {
3250                start_distance.min(end_distance)
3251            };
3252            let edge_gain = if self.surface_bed.is_some() {
3253                1.0
3254            } else {
3255                smoothstep_unit(pinned_distance / edge_fade_frames)
3256            };
3257            let source_direction = sign_nonzero(effective_rate, held_target_rate);
3258            let drag_alpha = if self.config.acoustic_enabled {
3259                self.drag_lowpass_alpha(abs_rate)
3260            } else {
3261                1.0
3262            };
3263            let mut missed_window = false;
3264            let mut programme = [0.0_f64; 2];
3265            let mut source_textures = [0.0_f64; 2];
3266
3267            // The preamp's de-emphasis curve does not move with the record, so
3268            // the tilt is shaped by how fast the groove is actually passing.
3269            if self.config.riaa_speed_tilt {
3270                let alpha =
3271                    1.0 - (-1.0 / (self.output_sample_rate * MOVEMENT_GAIN_SECONDS)).exp();
3272                self.riaa_tilt.follow_rate(abs_rate, alpha);
3273            }
3274            // The opt-in fixed mismatch eases toward its configured rate from
3275            // the same warm state, so a change never restructures the filter in
3276            // one sample. At the default 1.0 it is bit-exact and costless.
3277            {
3278                let alpha =
3279                    1.0 - (-1.0 / (self.output_sample_rate * MOVEMENT_GAIN_SECONDS)).exp();
3280                self.riaa_voicing
3281                    .follow_rate(self.config.riaa_voicing_rate, alpha);
3282            }
3283            // Same easing for the voicing blend, so switching the stage on or
3284            // off ramps over a few milliseconds instead of stepping.
3285            let voicing_target = self.config.vinyl_voicing.clamp(0.0, 1.0);
3286            if self.voicing_mix.is_nan() {
3287                self.voicing_mix = voicing_target;
3288            } else {
3289                let voicing_alpha =
3290                    1.0 - (-1.0 / (self.output_sample_rate * MOVEMENT_GAIN_SECONDS)).exp();
3291                self.voicing_mix += (voicing_target - self.voicing_mix) * voicing_alpha;
3292                // Converged is equal: a fully-on or fully-off stage is exact.
3293                if (self.voicing_mix - voicing_target).abs() < 1.0e-6 {
3294                    self.voicing_mix = voicing_target;
3295                }
3296            }
3297            let voicing_mix = self.voicing_mix;
3298            // The curve only moves when the host selects another character.
3299            let voicing_curve = self.config.vinyl_voicing_curve as usize;
3300            if self.vinyl_voicing.curve() != voicing_curve {
3301                self.vinyl_voicing.set_curve(voicing_curve);
3302            }
3303            if self.surface_voicing.curve() != voicing_curve {
3304                self.surface_voicing.set_curve(voicing_curve);
3305            }
3306
3307            for channel_index in 0..output_channel_count {
3308                if self.needle_lifted {
3309                    continue;
3310                }
3311                let source_index = channel_index.min(self.channels.len() - 1);
3312                // Original: a stationary stylus (movementGain 0) never reads the window and
3313                // never flags a window miss — the sample is a plain 0 through the drag filter.
3314                let detail = if movement_gain > 0.0 {
3315                    self.sample_channel(source_index, self.position, effective_rate * rate_scale)
3316                } else {
3317                    Some((0.0, 0.0, 0.0))
3318                };
3319                let (music, source_texture) = match detail {
3320                    None => {
3321                        missed_window = true;
3322                        (self.last_output_samples[channel_index], 0.0)
3323                    }
3324                    Some((sampled, slope, curvature)) => {
3325                        let drag_state = self.drag_lowpass_state[channel_index];
3326                        let tracing_alpha = stylus_tracing_alpha(
3327                            drag_alpha,
3328                            curvature,
3329                            abs_rate,
3330                            self.config.stylus_tracing_limit,
3331                        )
3332                        // A worn groove reads dull before it reads noisy.
3333                            * (1.0 - 0.45 * worn);
3334                        let filtered = drag_state + (sampled - drag_state) * tracing_alpha;
3335                        self.drag_lowpass_state[channel_index] = filtered;
3336                        let music = filtered * movement_gain * OUTPUT_GAIN;
3337                        self.last_output_samples[channel_index] = music;
3338                        let texture = ((slope * 0.48 + curvature * 0.86) * source_direction)
3339                            .clamp(-1.0, 1.0)
3340                            * source_texture_gain;
3341                        (music, texture)
3342                    }
3343                };
3344                // The second stylus reads the same spiral a fixed angle
3345                // behind: delay measured in degrees, so it tightens with
3346                // pitch and chases a scratch. A touch duller than the first
3347                // stylus, as a trailing needle is.
3348                let music = if self.stylus_tap_level > 0.0
3349                    && movement_gain > 0.0
3350                    && !self.needle_lifted
3351                {
3352                    let frames_per_turn = self.source_sample_rate * 60.0
3353                        / self.native_rpm.max(1.0);
3354                    let tap_position = self.position
3355                        - self.stylus_tap_degrees / 360.0 * frames_per_turn;
3356                    let tap = if tap_position >= 0.0 {
3357                        self.sample_channel(
3358                            source_index,
3359                            tap_position,
3360                            effective_rate * rate_scale,
3361                        )
3362                        .map(|(sample, _, _)| sample)
3363                        .unwrap_or(0.0)
3364                    } else {
3365                        0.0
3366                    };
3367                    let state = self.tap_lowpass_state[channel_index];
3368                    let dulled = state + (tap - state) * 0.35;
3369                    self.tap_lowpass_state[channel_index] = dulled;
3370                    music
3371                        + dulled
3372                            * self.stylus_tap_level
3373                            * movement_gain
3374                            * OUTPUT_GAIN
3375                } else {
3376                    music
3377                };
3378                // Both styli feed one phono stage, and so does the texture the
3379                // source's own shape makes: the preamp colours the whole
3380                // cartridge output, not the music alone.
3381                let cartridge = music + source_texture;
3382                let mut staged = if self.config.riaa_speed_tilt {
3383                    self.riaa_tilt.process(channel_index, cartridge)
3384                } else {
3385                    cartridge
3386                };
3387                // Then the optional constant-rate RIAA mismatch and the seed
3388                // voicing curve, both part of the same phono stage.
3389                staged = self.riaa_voicing.process(channel_index, staged);
3390                if voicing_mix > 0.0 {
3391                    staged = self
3392                        .vinyl_voicing
3393                        .process(channel_index, staged, voicing_mix);
3394                }
3395                programme[channel_index] = staged;
3396                source_textures[channel_index] = 0.0;
3397            }
3398
3399            let programme = self.high_frequency_acceleration_limiter.process_frame(
3400                programme,
3401                output_channel_count,
3402                self.output_sample_rate,
3403                self.config.high_frequency_acceleration_limit,
3404            );
3405            for channel_index in 0..output_channel_count {
3406                let output_index = frame * output_channel_count + channel_index;
3407                if self.needle_lifted {
3408                    self.output[output_index] = 0.0;
3409                    continue;
3410                }
3411                // Wear's crackle rides outside the gate — the groove's
3412                // damage keeps hissing while the gate chops the music,
3413                // which is what a gated worn record does.
3414                let wear_crackle = if worn > 0.0 && self.config.surface_enabled {
3415                    self.next_noise()
3416                        * worn
3417                        * 0.012
3418                        * (abs_rate / 1.4).clamp(0.1, 1.0)
3419                        * self.config.wear_gain
3420                } else {
3421                    0.0
3422                };
3423                // The surface bed and the wear crackle are stylus output too,
3424                // so the phono stage colours them with the same curve. They
3425                // still ride outside the gate above.
3426                let surface_bed = contact_texture + dust_fleck + impulse_noise + wear_crackle;
3427                let surface_bed = if voicing_mix > 0.0 {
3428                    self.surface_voicing
3429                        .process(channel_index, surface_bed, voicing_mix)
3430                } else {
3431                    surface_bed
3432                };
3433                let mixed = (programme[channel_index]
3434                    + source_textures[channel_index])
3435                    * self.window_programme_gain
3436                    * edge_gain
3437                    * warp_gain
3438                    * self.angle_gate_gain
3439                    + surface_bed;
3440                // A clamp rectifies overs into broadband grit; tanh folds
3441                // them the way a saturating stage does. Unity slope at
3442                // silence keeps small signals identical either way.
3443                self.output[output_index] = if self.config.soft_clip {
3444                    mixed.tanh()
3445                } else {
3446                    mixed.clamp(-1.0, 1.0)
3447                } as f32;
3448            }
3449
3450            // A lifted stylus is not in the groove, so nothing is reading the
3451            // programme. The platter keeps turning underneath — its angle
3452            // still advances, and the revolution counters with it — but the
3453            // read head holds where it was left. Dropping the needle back at
3454            // the same radius lands at the same point in the programme, not
3455            // wherever playback would have run on to in the meantime.
3456            let advanced = if self.needle_lifted {
3457                self.position
3458            } else {
3459                self.position + effective_rate * rate_scale
3460            };
3461            self.position = if self.locked_groove_start >= 0.0 {
3462                self.normalize_locked_groove_position(advanced)
3463            } else {
3464                self.clamp_source_position(advanced)
3465            };
3466            if self.grip < GRIP_OWNERSHIP {
3467                self.target_position = self.position;
3468                let physical_surface_region_active = self.surface_bed.is_some();
3469                if !physical_surface_region_active
3470                    && !self.ended
3471                    && self.motor_rate > 0.0
3472                    && self.position + PROGRAMME_END_POSITION_EPSILON_FRAMES
3473                        >= self.total_frames.saturating_sub(3) as f64
3474                {
3475                    self.ended = true;
3476                    self.motor_rate = 0.0;
3477                    rendered_frames = frame + 1;
3478                    ended_this_render = true;
3479                }
3480            }
3481            self.last_effective_rate = effective_rate;
3482            self.contact_impulse *= CONTACT_IMPULSE_DECAY;
3483            self.window_miss_frames = if missed_window {
3484                self.window_miss_frames.saturating_add(1)
3485            } else {
3486                0
3487            };
3488            let programme_fade_step = 1.0 / miss_fade_frames;
3489            self.window_programme_gain = if missed_window {
3490                (self.window_programme_gain - programme_fade_step).max(0.0)
3491            } else {
3492                (self.window_programme_gain + programme_fade_step).min(1.0)
3493            };
3494            if ended_this_render {
3495                break;
3496            }
3497        }
3498        self.mix_foley(rendered_frames, output_channel_count);
3499        self.apply_crossfader_trace(rendered_frames, output_channel_count);
3500        self.apply_output_gain(rendered_frames, output_channel_count);
3501        self.apply_output_seam_repair(rendered_frames, output_channel_count);
3502        self.maybe_request_window(rendered_frames);
3503        self.apply_vinyl_vfx(
3504            rendered_frames,
3505            output_channel_count,
3506            vfx_start_turns,
3507            vfx_start_position,
3508        );
3509        self.revolution_capture_copy(rendered_frames, output_channel_count);
3510        self.rendered_frame_counter += rendered_frames as u64;
3511        u32::try_from(rendered_frames).unwrap_or(u32::MAX)
3512    }
3513
3514    /// The scene rides the finished block in record coordinates — the
3515    /// same call the shared bridge makes after its own render.
3516    fn apply_vinyl_vfx(
3517        &mut self,
3518        frame_count: usize,
3519        channel_count: usize,
3520        start_turns: f64,
3521        start_position: f64,
3522    ) {
3523        if frame_count == 0 {
3524            return;
3525        }
3526        let context = VinylVfxContext {
3527            sample_rate: self.output_sample_rate,
3528            rpm: self.native_rpm,
3529            start_turns,
3530            end_turns: self.platter_rotation_turns,
3531            start_position,
3532            end_position: self.position,
3533            total_frames: self.total_frames.max(1),
3534            pressing_seed: self.pressing_seed,
3535        };
3536        let sample_count = frame_count
3537            .saturating_mul(channel_count)
3538            .min(self.output.len());
3539        let output = std::mem::take(&mut self.output);
3540        let mut output = output;
3541        self.vinyl_vfx.process_interleaved(
3542            &mut output[..sample_count],
3543            channel_count,
3544            context,
3545        );
3546        self.output = output;
3547    }
3548
3549    #[wasm_bindgen(js_name = renderWindowMissing)]
3550    pub fn render_window_missing(&mut self, frame_count: u32, output_channel_count: u32) {
3551        let frame_count = frame_count as usize;
3552        let output_channel_count = (output_channel_count as usize).clamp(1, 2);
3553        self.output
3554            .resize(frame_count.saturating_mul(output_channel_count), 0.0);
3555        self.scratch_gate_trace.resize(frame_count, 1.0);
3556        let fade_frames = (self.output_sample_rate * WINDOW_MISS_FADE_SECONDS)
3557            .round()
3558            .max(1.0);
3559        self.last_output_samples.resize(output_channel_count, 0.0);
3560        let fade_step = 1.0 / fade_frames;
3561        for frame in 0..frame_count {
3562            let fade = self.window_programme_gain;
3563            for channel_index in 0..output_channel_count {
3564                self.output[frame * output_channel_count + channel_index] =
3565                    (self.last_output_samples[channel_index] * fade) as f32;
3566            }
3567            self.window_programme_gain = (self.window_programme_gain - fade_step).max(0.0);
3568            self.window_miss_frames = self.window_miss_frames.saturating_add(1);
3569        }
3570        let gate_contact = self.active && self.hand_contact;
3571        let intent_rate = if gate_contact { self.target_rate } else { 0.0 };
3572        let rendered_rate = if gate_contact {
3573            self.last_effective_rate
3574        } else {
3575            0.0
3576        };
3577        self.advance_scratch_gate_trace(frame_count, gate_contact, intent_rate, rendered_rate);
3578        self.mix_foley(frame_count, output_channel_count);
3579        self.apply_crossfader_trace(frame_count, output_channel_count);
3580        self.apply_output_gain(frame_count, output_channel_count);
3581    }
3582
3583    /// Render only cartridge/surface foley while keeping the programme readhead
3584    /// fixed. Lead-in and run-out are physical platter regions, not permission
3585    /// to sample the first or last seconds of programme audio underneath them.
3586    #[wasm_bindgen(js_name = renderSurface)]
3587    pub fn render_surface(&mut self, frame_count: u32, output_channel_count: u32) {
3588        let frame_count = frame_count as usize;
3589        let output_channel_count = (output_channel_count as usize).clamp(1, 2);
3590        self.output
3591            .resize(frame_count.saturating_mul(output_channel_count), 0.0);
3592        self.output.fill(0.0);
3593        self.scratch_gate_trace.resize(frame_count, 1.0);
3594        if frame_count == 0 {
3595            return;
3596        }
3597
3598        let dt = 1.0 / self.output_sample_rate;
3599        let rate_scale = self.source_sample_rate / self.output_sample_rate;
3600        for frame in 0..frame_count {
3601            self.advance_deck_mechanics(0.0);
3602            let abs_rate = self.rate.abs();
3603            self.last_effective_rate = if self.config.acoustic_enabled {
3604                self.rate
3605                    + sign_nonzero(self.rate, self.motor_delivered_rate)
3606                        * self.advance_wow_flutter(self.rate, rate_scale, abs_rate)
3607            } else {
3608                self.rate
3609            };
3610            self.scratch_gate_trace[frame] = self.scratch_gate.process(dt, false, 0.0, 0.0) as f32;
3611        }
3612        self.mix_foley(frame_count, output_channel_count);
3613        self.apply_crossfader_trace(frame_count, output_channel_count);
3614        self.apply_output_gain(frame_count, output_channel_count);
3615    }
3616
3617    #[wasm_bindgen(getter, js_name = outputPtr)]
3618    pub fn output_ptr(&self) -> *const f32 {
3619        self.output.as_ptr()
3620    }
3621
3622    #[wasm_bindgen(getter, js_name = outputLen)]
3623    pub fn output_len(&self) -> usize {
3624        self.output.len()
3625    }
3626
3627    #[wasm_bindgen(getter)]
3628    pub fn position(&self) -> f64 {
3629        self.position
3630    }
3631
3632    #[wasm_bindgen(getter, js_name = effectiveRate)]
3633    pub fn effective_rate(&self) -> f64 {
3634        self.last_effective_rate
3635    }
3636
3637    #[wasm_bindgen(js_name = takeWindowRequest)]
3638    pub fn take_window_request(&mut self) -> f64 {
3639        self.requested_window_position.take().unwrap_or(-1.0)
3640    }
3641
3642    #[wasm_bindgen(js_name = takeEnded)]
3643    pub fn take_ended(&mut self) -> bool {
3644        let ended = self.ended;
3645        self.ended = false;
3646        ended
3647    }
3648
3649    /// Decoded needle-surface asset PCM (original `assets/audio/needle-surface.opus`),
3650    /// provided by the host off the real-time thread.
3651    #[wasm_bindgen(js_name = setSurfaceAsset)]
3652    pub fn set_surface_asset(&mut self, channels: Array, sample_rate: f64) -> Result<(), JsValue> {
3653        if !sample_rate.is_finite() || sample_rate <= 0.0 {
3654            return Err(JsValue::from_str(
3655                "surface asset sampleRate must be positive",
3656            ));
3657        }
3658        let mut copied = Vec::with_capacity(channels.length() as usize);
3659        for value in channels.iter() {
3660            if !value.is_instance_of::<Float32Array>() {
3661                return Err(JsValue::from_str(
3662                    "surface asset channels must be Float32Array values",
3663                ));
3664            }
3665            let typed = Float32Array::new(&value);
3666            let mut samples = vec![0.0_f32; typed.length() as usize];
3667            typed.copy_to(&mut samples);
3668            copied.push(samples);
3669        }
3670        if copied.is_empty() || copied[0].is_empty() {
3671            return Err(JsValue::from_str(
3672                "surface asset requires at least one non-empty channel",
3673            ));
3674        }
3675        self.surface_asset = Arc::new(copied);
3676        self.surface_asset_rate = sample_rate;
3677        Ok(())
3678    }
3679
3680    /// Mobile speaker compensation (original `resolveNeedleSurfaceGain`: ×2.25 on mobile).
3681    #[wasm_bindgen(js_name = setSurfaceGainMultiplier)]
3682    pub fn set_surface_gain_multiplier(&mut self, multiplier: f64) {
3683        self.surface_gain_multiplier = if multiplier.is_finite() && multiplier > 0.0 {
3684            multiplier
3685        } else {
3686            1.0
3687        };
3688    }
3689
3690    /// Start the lead-in (region 0) or deadwax (region 1) surface bed.
3691    #[wasm_bindgen(js_name = startSurfaceRegion)]
3692    pub fn start_surface_region(&mut self, region: u8, duration_seconds: f64) {
3693        if !(duration_seconds > 0.0) || self.needle_lifted || !self.config.surface_enabled {
3694            return;
3695        }
3696        self.high_frequency_acceleration_limiter.reset();
3697        let (gain, filter_hz, filter_q) = if region == SURFACE_REGION_DEADWAX {
3698            (DEADWAX_STATIC_GAIN, 4600.0, 0.4)
3699        } else {
3700            (LEAD_IN_STATIC_GAIN, 5200.0, 0.45)
3701        };
3702        let (offset, selected_looping) = self.select_surface_sample(duration_seconds);
3703        let looping = if region == SURFACE_REGION_DEADWAX {
3704            true
3705        } else {
3706            selected_looping
3707        };
3708        let filter = BiquadLowpass::new(filter_hz, filter_q, self.output_sample_rate);
3709        if region == SURFACE_REGION_DEADWAX {
3710            let end = self.total_frames.saturating_sub(2) as f64;
3711            self.position = self.position.max(end);
3712            self.target_position = self.position;
3713        }
3714        self.ended = false;
3715        self.surface_bed = Some(SurfaceBed {
3716            region,
3717            position: offset * self.surface_asset_rate,
3718            looping,
3719            elapsed_frames: 0.0,
3720            duration_seconds,
3721            gain: gain * self.surface_gain_multiplier,
3722            filters: [filter, filter],
3723        });
3724    }
3725
3726    #[wasm_bindgen(js_name = stopSurfaceRegion)]
3727    pub fn stop_surface_region(&mut self) {
3728        self.surface_bed = None;
3729    }
3730
3731    /// One-shot needle-drop foley: stylus thump plus a settling crackle burst.
3732    #[wasm_bindgen(js_name = triggerNeedleDrop)]
3733    pub fn trigger_needle_drop(&mut self) {
3734        if self.needle_lifted || !self.config.surface_enabled {
3735            return;
3736        }
3737        self.needle_thump = Some(NeedleThump {
3738            elapsed_seconds: 0.0,
3739            phase: 0.0,
3740            gain: NEEDLE_DROP_THUMP_GAIN * self.surface_gain_multiplier,
3741        });
3742        let (offset, _) = self.select_surface_sample(NEEDLE_DROP_BURST_SECONDS);
3743        let filter = BiquadLowpass::new(
3744            NEEDLE_DROP_BURST_FILTER_HZ,
3745            NEEDLE_DROP_BURST_FILTER_Q,
3746            self.output_sample_rate,
3747        );
3748        self.needle_burst = Some(SurfaceBurst {
3749            position: offset * self.surface_asset_rate,
3750            elapsed_frames: 0.0,
3751            peak: LEAD_IN_STATIC_GAIN * 1.9 * self.surface_gain_multiplier,
3752            filters: [filter, filter],
3753        });
3754    }
3755
3756    /// Starts a lighter stylus-release thump and a short crackle burst.
3757    #[wasm_bindgen(js_name = triggerNeedleLift)]
3758    pub fn trigger_needle_lift(&mut self) {
3759        if !self.config.surface_enabled {
3760            return;
3761        }
3762        self.needle_thump = Some(NeedleThump {
3763            elapsed_seconds: 0.0,
3764            phase: 0.0,
3765            gain: NEEDLE_LIFT_THUMP_GAIN * self.surface_gain_multiplier,
3766        });
3767        let (offset, _) = self.select_surface_sample(NEEDLE_DROP_BURST_SECONDS);
3768        let filter = BiquadLowpass::new(
3769            NEEDLE_DROP_BURST_FILTER_HZ,
3770            NEEDLE_DROP_BURST_FILTER_Q,
3771            self.output_sample_rate,
3772        );
3773        self.needle_burst = Some(SurfaceBurst {
3774            position: offset * self.surface_asset_rate,
3775            elapsed_frames: 0.0,
3776            peak: LEAD_IN_STATIC_GAIN * 0.8 * self.surface_gain_multiplier,
3777            filters: [filter, filter],
3778        });
3779    }
3780}
3781
3782impl ScratchAcousticDsp {
3783    /// Creates the shared player for a native host.
3784    pub fn new_native(output_sample_rate: f64, config: AcousticConfig) -> Result<Self, String> {
3785        if !output_sample_rate.is_finite() || output_sample_rate <= 0.0 {
3786            return Err("output sample rate must be positive".to_owned());
3787        }
3788        if !config.max_rate.is_finite() || config.max_rate <= 0.0 {
3789            return Err("maximum rate must be positive".to_owned());
3790        }
3791        if !valid_unit_interval(config.high_frequency_acceleration_limit) {
3792            return Err("high-frequency acceleration limit must be between 0 and 1".to_owned());
3793        }
3794        if !valid_unit_interval(config.stylus_tracing_limit) {
3795            return Err("stylus tracing limit must be between 0 and 1".to_owned());
3796        }
3797        if !valid_texture_scale(config.texture_scale) {
3798            return Err("texture scale must be between 0 and 4".to_owned());
3799        }
3800        Ok(Self::new_internal(output_sample_rate, config))
3801    }
3802
3803    pub fn deck_recovery_diagnostic(&self) -> Option<DeckRecoveryDiagnostic> {
3804        self.last_deck_recovery
3805    }
3806
3807    /// Installs host-decoded surface PCM without routing native audio through
3808    /// JavaScript typed arrays. Native mono renderers use the first channel,
3809    /// while stereo renderers preserve both channels exactly as the WASM host
3810    /// does through `setSurfaceAsset`.
3811    pub fn set_surface_asset_native(
3812        &mut self,
3813        channels: &[&[f32]],
3814        sample_rate: f64,
3815    ) -> Result<(), String> {
3816        self.set_surface_asset_owned_native(
3817            channels.iter().map(|channel| channel.to_vec()).collect(),
3818            sample_rate,
3819        )
3820    }
3821
3822    /// Installs already-owned native surface PCM with an O(1) audio-state
3823    /// swap. Hosts can allocate and copy the large asset before taking their
3824    /// render-state lock.
3825    pub fn set_surface_asset_owned_native(
3826        &mut self,
3827        channels: Vec<Vec<f32>>,
3828        sample_rate: f64,
3829    ) -> Result<(), String> {
3830        self.set_surface_asset_owned_native_deferred(channels, sample_rate)
3831            .map(drop)
3832    }
3833
3834    /// Installs owned surface PCM and returns the previous allocation so a
3835    /// native host can retire it after releasing its realtime-state mutex.
3836    pub fn set_surface_asset_owned_native_deferred(
3837        &mut self,
3838        channels: Vec<Vec<f32>>,
3839        sample_rate: f64,
3840    ) -> Result<Arc<Vec<Vec<f32>>>, String> {
3841        if !sample_rate.is_finite() || sample_rate <= 0.0 {
3842            return Err("surface asset sample rate must be positive".to_owned());
3843        }
3844        if !(1..=2).contains(&channels.len()) {
3845            return Err("surface asset must have one or two channels".to_owned());
3846        }
3847        let length = channels[0].len();
3848        if length == 0 {
3849            return Err("surface asset must contain samples".to_owned());
3850        }
3851        if channels.iter().any(|channel| channel.len() != length) {
3852            return Err("surface asset channels must have equal lengths".to_owned());
3853        }
3854
3855        let retired = std::mem::replace(&mut self.surface_asset, Arc::new(channels));
3856        self.surface_asset_rate = sample_rate;
3857        Ok(retired)
3858    }
3859
3860    /// Replaces the complete source window with host-owned PCM.
3861    pub fn replace_window_native(
3862        &mut self,
3863        channels: &[&[f32]],
3864        source_sample_rate: f64,
3865        reset_position: Option<f64>,
3866    ) -> Result<(), String> {
3867        self.replace_window_owned_native(
3868            channels.iter().map(|channel| channel.to_vec()).collect(),
3869            source_sample_rate,
3870            reset_position,
3871        )
3872    }
3873
3874    /// Replaces native source PCM with an O(1) ownership swap. The host must
3875    /// build the channel vectors before entering its real-time state lock.
3876    pub fn replace_window_owned_native(
3877        &mut self,
3878        channels: Vec<Vec<f32>>,
3879        source_sample_rate: f64,
3880        reset_position: Option<f64>,
3881    ) -> Result<(), String> {
3882        self.replace_window_owned_native_deferred(channels, source_sample_rate, reset_position)
3883            .map(drop)
3884    }
3885
3886    /// Publishes a complete native source window and returns the previous Arc
3887    /// so its potentially large backing allocation can be dropped after the
3888    /// host releases the realtime-state mutex.
3889    pub fn replace_window_owned_native_deferred(
3890        &mut self,
3891        channels: Vec<Vec<f32>>,
3892        source_sample_rate: f64,
3893        reset_position: Option<f64>,
3894    ) -> Result<Arc<Vec<Vec<f32>>>, String> {
3895        if !source_sample_rate.is_finite() || source_sample_rate <= 0.0 {
3896            return Err("source sample rate must be positive".to_owned());
3897        }
3898        if !(1..=2).contains(&channels.len()) {
3899            return Err("source must have one or two channels".to_owned());
3900        }
3901        let length = channels[0].len();
3902        if length == 0 {
3903            return Err("source must contain samples".to_owned());
3904        }
3905        if channels.iter().any(|channel| channel.len() != length) {
3906            return Err("source channels must have equal lengths".to_owned());
3907        }
3908
3909        let retired = std::mem::replace(&mut self.channels, Arc::new(channels));
3910        self.source_sample_rate = source_sample_rate;
3911        self.window_start = 0;
3912        self.window_end = length;
3913        self.total_frames = length;
3914        if let Some(position) = reset_position {
3915            self.reset_position(position);
3916        }
3917        Ok(retired)
3918    }
3919
3920    /// Extends the current native source without resetting transport state.
3921    pub fn extend_window_native(
3922        &mut self,
3923        channels: &[&[f32]],
3924        source_sample_rate: f64,
3925    ) -> Result<(), String> {
3926        self.extend_window_owned_native(
3927            channels.iter().map(|channel| channel.to_vec()).collect(),
3928            source_sample_rate,
3929        )
3930    }
3931
3932    /// Extends the current native source from already-owned PCM. Native hosts
3933    /// can copy each decode chunk before entering their render-state lock. If
3934    /// the initial window reserved the final programme capacity, publication
3935    /// is a bounded O(chunk) copy with no allocation or prefix rebuild.
3936    pub fn extend_window_owned_native(
3937        &mut self,
3938        mut channels: Vec<Vec<f32>>,
3939        source_sample_rate: f64,
3940    ) -> Result<(), String> {
3941        if !source_sample_rate.is_finite() || source_sample_rate <= 0.0 {
3942            return Err("source sample rate must be positive".to_owned());
3943        }
3944        if channels.len() != self.channels.len() || channels.is_empty() {
3945            return Err("source channel count must match the current window".to_owned());
3946        }
3947        if (source_sample_rate - self.source_sample_rate).abs() > f64::EPSILON {
3948            return Err("source sample rate must match the current window".to_owned());
3949        }
3950        let length = channels[0].len();
3951        if length == 0 {
3952            return Err("source must contain samples".to_owned());
3953        }
3954        if channels.iter().any(|channel| channel.len() != length) {
3955            return Err("source channels must have equal lengths".to_owned());
3956        }
3957
3958        for (destination, source) in Arc::make_mut(&mut self.channels)
3959            .iter_mut()
3960            .zip(&mut channels)
3961        {
3962            destination.append(source);
3963        }
3964        self.window_end = self.window_end.saturating_add(length);
3965        self.total_frames = self.total_frames.saturating_add(length);
3966        if self.active && self.motor_rate != 0.0 {
3967            self.ended = false;
3968        }
3969        Ok(())
3970    }
3971
3972    /// Replaces a range in the current native source without resetting transport.
3973    ///
3974    /// The range can extend the source. Missing frames between the old source
3975    /// end and the new range are silent.
3976    pub fn replace_window_range_native(
3977        &mut self,
3978        channels: &[&[f32]],
3979        start_frame: usize,
3980        source_sample_rate: f64,
3981    ) -> Result<(), String> {
3982        if !source_sample_rate.is_finite() || source_sample_rate <= 0.0 {
3983            return Err("source sample rate must be positive".to_owned());
3984        }
3985        if channels.len() != self.channels.len() || channels.is_empty() {
3986            return Err("source channel count must match the current window".to_owned());
3987        }
3988        if (source_sample_rate - self.source_sample_rate).abs() > f64::EPSILON {
3989            return Err("source sample rate must match the current window".to_owned());
3990        }
3991        let length = channels[0].len();
3992        if length == 0 {
3993            return Err("source must contain samples".to_owned());
3994        }
3995        if channels.iter().any(|channel| channel.len() != length) {
3996            return Err("source channels must have equal lengths".to_owned());
3997        }
3998        let end_frame = start_frame
3999            .checked_add(length)
4000            .ok_or_else(|| "source range is too large".to_owned())?;
4001
4002        for (destination, source) in Arc::make_mut(&mut self.channels).iter_mut().zip(channels) {
4003            if destination.len() < end_frame {
4004                destination.resize(end_frame, 0.0);
4005            }
4006            destination[start_frame..end_frame].copy_from_slice(source);
4007        }
4008        self.window_end = self
4009            .window_start
4010            .saturating_add(self.channels.first().map_or(0, Vec::len));
4011        self.total_frames = self.total_frames.max(self.window_end);
4012        if self.active && self.motor_rate != 0.0 {
4013            self.ended = false;
4014        }
4015        Ok(())
4016    }
4017
4018    /// Returns an immutable, constant-time snapshot of the native PCM window.
4019    /// Native hosts use it to build append/range replacements away from their
4020    /// realtime render mutex, then publish the result with
4021    /// `replace_window_owned_native` as one ownership swap.
4022    pub fn native_window_snapshot(&self) -> (Arc<Vec<Vec<f32>>>, f64) {
4023        (Arc::clone(&self.channels), self.source_sample_rate)
4024    }
4025
4026    /// Returns the interleaved output from the most recent render call.
4027    pub fn rendered_samples(&self) -> &[f32] {
4028        &self.output
4029    }
4030
4031    // Original `selectNeedleSurfaceSample`: pad 0.05 s, loop when the asset is shorter
4032    // than the requested duration + pad, random offset within the remaining span.
4033    // Divergence noted in the audit: uses the DSP LCG instead of Math.random().
4034    fn select_surface_sample(&mut self, duration_seconds: f64) -> (f64, bool) {
4035        if self.surface_asset.is_empty() {
4036            // Synthetic fallback (original: "needle surface asset unavailable;
4037            // synthesizing groove noise") — noise has no meaningful offset.
4038            return (0.0, true);
4039        }
4040        let buffer_duration = self.surface_asset[0].len() as f64 / self.surface_asset_rate;
4041        let requested = duration_seconds.max(0.0);
4042        let looping = buffer_duration <= requested + NEEDLE_SURFACE_SAMPLE_PAD_SECONDS;
4043        let max_offset = if looping {
4044            (buffer_duration - NEEDLE_SURFACE_SAMPLE_PAD_SECONDS).max(0.0)
4045        } else {
4046            (buffer_duration - requested - NEEDLE_SURFACE_SAMPLE_PAD_SECONDS).max(0.0)
4047        };
4048        let random01 = (self.next_noise() + 1.0) * 0.5;
4049        (
4050            if max_offset > 0.0 {
4051                random01 * max_offset
4052            } else {
4053                0.0
4054            },
4055            looping,
4056        )
4057    }
4058
4059    fn surface_asset_sample(&self, channel_index: usize, position: f64, looping: bool) -> f64 {
4060        if self.surface_asset.is_empty() {
4061            return 0.0;
4062        }
4063        let channel = &self.surface_asset[channel_index.min(self.surface_asset.len() - 1)];
4064        let len = channel.len();
4065        if len == 0 {
4066            return 0.0;
4067        }
4068        let mut index = position.floor() as i64;
4069        if looping {
4070            index = index.rem_euclid(len as i64);
4071        } else if index < 0 || index >= len as i64 {
4072            return 0.0;
4073        }
4074        channel[index as usize] as f64
4075    }
4076
4077    // Original bed gain automation: setValue(0.0001) → linearRamp(gain, +80 ms) →
4078    // hold → linearRamp(0.0001) over the final 160 ms.
4079    fn surface_bed_envelope(elapsed_seconds: f64, duration_seconds: f64, gain: f64) -> f64 {
4080        let fade_start =
4081            (duration_seconds - SURFACE_BED_RELEASE_SECONDS).max(SURFACE_BED_ATTACK_SECONDS);
4082        if elapsed_seconds < SURFACE_BED_ATTACK_SECONDS {
4083            SURFACE_ENV_FLOOR
4084                + (gain - SURFACE_ENV_FLOOR) * (elapsed_seconds / SURFACE_BED_ATTACK_SECONDS)
4085        } else if elapsed_seconds < fade_start {
4086            gain
4087        } else if elapsed_seconds < duration_seconds {
4088            let t = (elapsed_seconds - fade_start) / (duration_seconds - fade_start).max(1e-9);
4089            gain + (SURFACE_ENV_FLOOR - gain) * t
4090        } else {
4091            0.0
4092        }
4093    }
4094
4095    // Original burst automation: 0.0001 → peak @14 ms → peak×0.32 @120 ms → 0.0001 @340 ms.
4096    fn burst_envelope(elapsed_seconds: f64, peak: f64) -> f64 {
4097        if elapsed_seconds < 0.014 {
4098            SURFACE_ENV_FLOOR + (peak - SURFACE_ENV_FLOOR) * (elapsed_seconds / 0.014)
4099        } else if elapsed_seconds < 0.12 {
4100            let t = (elapsed_seconds - 0.014) / (0.12 - 0.014);
4101            peak + (peak * 0.32 - peak) * t
4102        } else if elapsed_seconds < NEEDLE_DROP_BURST_SECONDS {
4103            let t = (elapsed_seconds - 0.12) / (NEEDLE_DROP_BURST_SECONDS - 0.12);
4104            (peak * 0.32) + (SURFACE_ENV_FLOOR - peak * 0.32) * t
4105        } else {
4106            0.0
4107        }
4108    }
4109
4110    // Original thump: sine 130 Hz exponentialRamp→ 52 Hz @70 ms; gain 0.0001
4111    // exponentialRamp→ gain @6 ms exponentialRamp→ 0.0001 @95 ms; stops at 100 ms.
4112    fn thump_value(thump: &mut NeedleThump, dt: f64) -> Option<f64> {
4113        let t = thump.elapsed_seconds;
4114        if t >= 0.1 {
4115            return None;
4116        }
4117        let frequency = if t < 0.07 {
4118            130.0 * (52.0_f64 / 130.0).powf(t / 0.07)
4119        } else {
4120            52.0
4121        };
4122        let envelope = if t < 0.006 {
4123            SURFACE_ENV_FLOOR * (thump.gain / SURFACE_ENV_FLOOR).powf(t / 0.006)
4124        } else if t < 0.095 {
4125            thump.gain * (SURFACE_ENV_FLOOR / thump.gain).powf((t - 0.006) / (0.095 - 0.006))
4126        } else {
4127            SURFACE_ENV_FLOOR
4128        };
4129        let value = (thump.phase * std::f64::consts::TAU).sin() * envelope;
4130        thump.phase += frequency * dt;
4131        thump.elapsed_seconds += dt;
4132        Some(value)
4133    }
4134
4135    // Mixes the surface bed, thump, and burst into the interleaved output buffer.
4136    // These run regardless of transport state — the original routed them as
4137    // independent WebAudio nodes into the same output mix.
4138    fn advance_scratch_gate_trace(
4139        &mut self,
4140        frame_count: usize,
4141        hand_contact: bool,
4142        intent_rate: f64,
4143        rendered_rate: f64,
4144    ) {
4145        let dt = 1.0 / self.output_sample_rate;
4146        for frame in 0..frame_count {
4147            self.scratch_gate_trace[frame] =
4148                self.scratch_gate
4149                    .process(dt, hand_contact, intent_rate, rendered_rate) as f32;
4150        }
4151    }
4152
4153    fn apply_crossfader_trace(&mut self, frame_count: usize, output_channel_count: usize) {
4154        let dt = 1.0 / self.output_sample_rate;
4155        let alpha = if dt.is_finite() && dt > 0.0 {
4156            1.0 - (-dt / MOMENTARY_CROSSFADER_TRANSITION_SECONDS).exp()
4157        } else {
4158            1.0
4159        };
4160        for frame in 0..frame_count {
4161            self.momentary_crossfader_mix = (self.momentary_crossfader_mix
4162                + (self.momentary_crossfader_mix_target - self.momentary_crossfader_mix) * alpha)
4163                .clamp(0.0, 1.0);
4164            let technique_gain = if self.scratch_gate.preset() == ScratchPreset::Baby {
4165                self.manual_fader_gain
4166            } else {
4167                f64::from(self.scratch_gate_trace[frame])
4168            };
4169            let gain = (technique_gain * (1.0 - self.momentary_crossfader_mix)
4170                + self.momentary_crossfader_gain * self.momentary_crossfader_mix)
4171                .clamp(0.0, 1.0);
4172            self.scratch_gate_trace[frame] = gain as f32;
4173            self.audible_crossfader_gain = gain;
4174            for channel_index in 0..output_channel_count {
4175                self.output[frame * output_channel_count + channel_index] *= gain as f32;
4176            }
4177        }
4178    }
4179
4180    fn apply_output_gain(&mut self, frame_count: usize, output_channel_count: usize) {
4181        if frame_count == 0
4182            || (self.output_gain_remaining_frames == 0 && self.output_gain_current == 1.0)
4183        {
4184            return;
4185        }
4186
4187        for frame in 0..frame_count {
4188            let gain = self.output_gain_current as f32;
4189            if gain != 1.0 {
4190                for channel_index in 0..output_channel_count {
4191                    self.output[frame * output_channel_count + channel_index] *= gain;
4192                }
4193            }
4194            if self.output_gain_remaining_frames > 0 {
4195                self.output_gain_remaining_frames -= 1;
4196                if self.output_gain_remaining_frames == 0 {
4197                    self.output_gain_current = self.output_gain_target;
4198                    self.output_gain_step = 0.0;
4199                } else {
4200                    self.output_gain_current += self.output_gain_step;
4201                }
4202            }
4203        }
4204    }
4205
4206    fn mix_foley(&mut self, frame_count: usize, output_channel_count: usize) {
4207        if !self.config.surface_enabled
4208            || (self.surface_bed.is_none()
4209                && self.needle_thump.is_none()
4210                && self.needle_burst.is_none())
4211        {
4212            return;
4213        }
4214        let dt = 1.0 / self.output_sample_rate;
4215        let asset_step = self.surface_asset_rate / self.output_sample_rate;
4216        for frame in 0..frame_count {
4217            let mut per_channel = [0.0_f64; 2];
4218            let synthetic_surface = self.surface_asset.is_empty();
4219            let mut fallback_bed = [0.0_f64; 2];
4220            let mut fallback_burst = [0.0_f64; 2];
4221            if synthetic_surface && self.surface_bed.is_some() {
4222                for sample in fallback_bed.iter_mut().take(output_channel_count.min(2)) {
4223                    *sample = self.next_noise();
4224                }
4225            }
4226            if synthetic_surface && self.needle_burst.is_some() {
4227                for sample in fallback_burst.iter_mut().take(output_channel_count.min(2)) {
4228                    *sample = self.next_noise();
4229                }
4230            }
4231            if let Some(bed) = self.surface_bed.clone() {
4232                let elapsed_seconds = bed.elapsed_frames * dt;
4233                let hold_deadwax_end =
4234                    bed.region == SURFACE_REGION_DEADWAX && elapsed_seconds >= bed.duration_seconds;
4235                if bed.region != SURFACE_REGION_DEADWAX
4236                    && elapsed_seconds >= bed.duration_seconds + 0.02
4237                {
4238                    self.surface_bed = None;
4239                } else {
4240                    let envelope = if hold_deadwax_end {
4241                        bed.gain * 0.72
4242                    } else {
4243                        Self::surface_bed_envelope(elapsed_seconds, bed.duration_seconds, bed.gain)
4244                    };
4245                    for channel_index in 0..output_channel_count.min(2) {
4246                        let raw = if synthetic_surface {
4247                            fallback_bed[channel_index]
4248                        } else {
4249                            self.surface_asset_sample(channel_index, bed.position, bed.looping)
4250                        };
4251                        if let Some(active_bed) = self.surface_bed.as_mut() {
4252                            per_channel[channel_index] +=
4253                                active_bed.filters[channel_index].process(raw) * envelope;
4254                        }
4255                    }
4256                    if let Some(active_bed) = self.surface_bed.as_mut() {
4257                        active_bed.position += asset_step;
4258                        active_bed.elapsed_frames += 1.0;
4259                    }
4260                }
4261            }
4262            if let Some(mut thump) = self.needle_thump.take() {
4263                if let Some(value) = Self::thump_value(&mut thump, dt) {
4264                    for channel_value in per_channel.iter_mut().take(output_channel_count.min(2)) {
4265                        *channel_value += value;
4266                    }
4267                    self.needle_thump = Some(thump);
4268                }
4269            }
4270            if let Some(burst) = self.needle_burst.clone() {
4271                let elapsed_seconds = burst.elapsed_frames * dt;
4272                if elapsed_seconds >= NEEDLE_DROP_BURST_SECONDS + 0.02 {
4273                    self.needle_burst = None;
4274                } else {
4275                    let envelope = Self::burst_envelope(elapsed_seconds, burst.peak);
4276                    for channel_index in 0..output_channel_count.min(2) {
4277                        let raw = if synthetic_surface {
4278                            fallback_burst[channel_index]
4279                        } else {
4280                            self.surface_asset_sample(channel_index, burst.position, false)
4281                        };
4282                        if let Some(active_burst) = self.needle_burst.as_mut() {
4283                            per_channel[channel_index] +=
4284                                active_burst.filters[channel_index].process(raw) * envelope;
4285                        }
4286                    }
4287                    if let Some(active_burst) = self.needle_burst.as_mut() {
4288                        active_burst.position += asset_step;
4289                        active_burst.elapsed_frames += 1.0;
4290                    }
4291                }
4292            }
4293            for channel_index in 0..output_channel_count.min(2) {
4294                let output_index = frame * output_channel_count + channel_index;
4295                if let Some(slot) = self.output.get_mut(output_index) {
4296                    *slot = (*slot as f64 + per_channel[channel_index]).clamp(-1.0, 1.0) as f32;
4297                }
4298            }
4299        }
4300    }
4301
4302    fn reset_deck_to_rest_at_current_turns(&mut self) {
4303        let before = self.deck_state.telemetry();
4304        let turns = if self.platter_rotation_turns.is_finite() {
4305            self.platter_rotation_turns
4306        } else {
4307            self.record_deck_recovery(
4308                DeckRecoveryOperation::RestReset,
4309                DeckMechanicalError::InvalidControl {
4310                    field: "platterRotationTurns",
4311                },
4312                before,
4313                0.0,
4314            );
4315            self.platter_rotation_turns = 0.0;
4316            0.0
4317        };
4318        if let Err(error) = self.deck_state.reset(0.0, 0.0, turns, turns) {
4319            self.record_deck_recovery(DeckRecoveryOperation::RestReset, error, before, 0.0);
4320            self.platter_rotation_turns = 0.0;
4321            let _ = self.deck_state.reset(0.0, 0.0, 0.0, 0.0);
4322        }
4323    }
4324
4325    fn record_deck_recovery(
4326        &mut self,
4327        operation: DeckRecoveryOperation,
4328        error: DeckMechanicalError,
4329        before: DeckMechanicalTelemetry,
4330        requested_hand_rate: f64,
4331    ) {
4332        self.deck_recovery_count = self.deck_recovery_count.saturating_add(1);
4333        self.last_deck_recovery = Some(DeckRecoveryDiagnostic {
4334            count: self.deck_recovery_count,
4335            operation,
4336            error,
4337            output_sample_rate: self.output_sample_rate,
4338            source_sample_rate: self.source_sample_rate,
4339            position: self.position,
4340            target_position: self.target_position,
4341            requested_hand_rate,
4342            motor_rate: self.motor_rate,
4343            grip: self.grip,
4344            platter_rate_before: before.platter_rate,
4345            record_rate_before: before.record_rate,
4346            platter_turns_before: before.platter_angle_turns,
4347            record_turns_before: before.record_angle_turns,
4348        });
4349    }
4350
4351    fn reset_position(&mut self, position: f64) {
4352        self.position = self.clamp_source_position(position);
4353        self.target_position = self.position;
4354        self.rate = 0.0;
4355        self.rate_velocity = 0.0;
4356        self.target_rate = 0.0;
4357        self.motor_delivered_rate = 0.0;
4358        self.unpowered_throw_rate = 0.0;
4359        self.last_effective_rate = 0.0;
4360        self.reset_deck_to_rest_at_current_turns();
4361        self.frames_since_motion = 0;
4362        self.last_output_samples.clear();
4363        self.high_frequency_acceleration_limiter.reset();
4364        self.window_miss_frames = 0;
4365        self.window_programme_gain = 1.0;
4366    }
4367
4368    fn map_rate(&self, rate: f64) -> f64 {
4369        if !rate.is_finite() || rate.abs() < DEADZONE_RATE {
4370            0.0
4371        } else {
4372            rate.clamp(-self.config.max_rate, self.config.max_rate)
4373        }
4374    }
4375
4376    fn clamp_source_position(&self, position: f64) -> f64 {
4377        let programme_end = self.total_frames.max(self.window_end).saturating_sub(2) as f64;
4378        let max_position = match &self.surface_bed {
4379            Some(bed) if bed.region == SURFACE_REGION_DEADWAX => {
4380                let overrun = (bed.duration_seconds.max(0.0) * self.source_sample_rate).ceil();
4381                programme_end + overrun.max(0.0)
4382            }
4383            _ => programme_end,
4384        };
4385        position.clamp(0.0, max_position)
4386    }
4387
4388    fn normalize_locked_groove_position(&self, position: f64) -> f64 {
4389        if self.locked_groove_start < 0.0 {
4390            return position;
4391        }
4392        let frames_per_turn =
4393            self.source_sample_rate * 60.0 / self.native_rpm.max(1.0);
4394        self.locked_groove_start
4395            + (position - self.locked_groove_start).rem_euclid(frames_per_turn)
4396    }
4397
4398    /// Signed shortest distance between two positions in the groove domain.
4399    ///
4400    /// A locked groove is circular. Once playback crosses its seam, a small
4401    /// forward hand movement has a numerically low target and a numerically
4402    /// high current position. Subtracting those values directly makes the
4403    /// hand servo demand almost one full revolution backwards. Pointer
4404    /// samples are incremental and stay below half a turn, so the shortest
4405    /// circular displacement preserves their physical direction.
4406    fn locked_groove_position_delta(&self, target: f64, current: f64) -> f64 {
4407        if self.locked_groove_start < 0.0 {
4408            return target - current;
4409        }
4410        let frames_per_turn =
4411            self.source_sample_rate * 60.0 / self.native_rpm.max(1.0);
4412        let forward = (target - current).rem_euclid(frames_per_turn);
4413        if forward > frames_per_turn * 0.5 {
4414            forward - frames_per_turn
4415        } else {
4416            forward
4417        }
4418    }
4419
4420    fn enforce_locked_groove(&mut self) {
4421        if self.locked_groove_start < 0.0 {
4422            return;
4423        }
4424        self.position = self.normalize_locked_groove_position(self.position);
4425        self.target_position =
4426            self.normalize_locked_groove_position(self.target_position);
4427    }
4428
4429    fn next_noise(&mut self) -> f64 {
4430        self.noise_seed = self
4431            .noise_seed
4432            .wrapping_mul(1_664_525)
4433            .wrapping_add(1_013_904_223);
4434        self.noise_seed as f64 / 2_147_483_648.0 - 1.0
4435    }
4436
4437    fn hash_noise(index: i64, salt: i32) -> f64 {
4438        let mut value = (index as i32) ^ salt;
4439        value = (value ^ ((value as u32 >> 16) as i32)).wrapping_mul(0x7feb_352d_u32 as i32);
4440        value = (value ^ ((value as u32 >> 15) as i32)).wrapping_mul(0x846c_a68b_u32 as i32);
4441        let unsigned = (value ^ ((value as u32 >> 16) as i32)) as u32;
4442        unsigned as f64 / 2_147_483_648.0 - 1.0
4443    }
4444
4445    fn position_noise(&self, position: f64, spacing: f64, salt: i32) -> f64 {
4446        let scaled = position.max(0.0) / spacing.max(1.0);
4447        let index = scaled.floor() as i64;
4448        let t = scaled - index as f64;
4449        let smooth = t * t * (3.0 - 2.0 * t);
4450        let a = Self::hash_noise(index, salt);
4451        let b = Self::hash_noise(index + 1, salt);
4452        a + (b - a) * smooth
4453    }
4454
4455    fn compute_position_surface_noise(&self, position: f64, abs_rate: f64) -> f64 {
4456        if abs_rate <= DEADZONE_RATE {
4457            return 0.0;
4458        }
4459        let speed_weight = (abs_rate / 2.4).clamp(0.14, 1.0);
4460        // The pressing seed folds into every position hash, so each copy
4461        // carries its own crackle — always the same crackle for that copy.
4462        let groove_grain =
4463            self.position_noise(position, 3.7, 0x0051_f15e ^ self.pressing_seed as i32);
4464        let groove_bed =
4465            self.position_noise(position, 37.0, 0x002d_4a11 ^ self.pressing_seed as i32);
4466        (groove_grain * 0.72 + groove_bed * 0.22) * speed_weight
4467    }
4468
4469    fn compute_dust_fleck(&self, position: f64, abs_rate: f64) -> f64 {
4470        if abs_rate <= 0.03 {
4471            return 0.0;
4472        }
4473        let cell_frames = (self.source_sample_rate * 0.12).round().max(1.0);
4474        let cell = (position.max(0.0) / cell_frames).floor() as i64;
4475        let chance =
4476            (Self::hash_noise(cell, 0x006d_2b79 ^ self.pressing_seed as i32) + 1.0) * 0.5;
4477        if chance < 0.996 {
4478            return 0.0;
4479        }
4480        let center = (cell as f64
4481            + 0.5
4482            + Self::hash_noise(cell, 0x004f_1bbc ^ self.pressing_seed as i32) * 0.28)
4483            * cell_frames;
4484        let width = cell_frames * 0.028;
4485        let distance = (position - center).abs() / width.max(1.0);
4486        if distance >= 1.0 {
4487            return 0.0;
4488        }
4489        let envelope = (1.0 - distance).powi(2);
4490        let speed_weight = (abs_rate / 1.4).clamp(0.12, 1.0);
4491        Self::hash_noise(cell, 0x0073_c4d9 ^ self.pressing_seed as i32)
4492            * envelope
4493            * speed_weight
4494            * DUST_FLECK_GAIN
4495    }
4496
4497    fn sample_channel(
4498        &self,
4499        channel_index: usize,
4500        position: f64,
4501        source_step: f64,
4502    ) -> Option<(f64, f64, f64)> {
4503        let channel = self.channels.get(channel_index)?;
4504        let local = position - self.window_start as f64;
4505        if local < 0.0 || local >= channel.len().saturating_sub(1) as f64 {
4506            return None;
4507        }
4508        let index = local.floor() as usize;
4509        let t = local - index as f64;
4510        let global_index = self.window_start as f64 + index as f64;
4511        let p0 = self.repaired_source_sample(
4512            channel_index,
4513            global_index - 1.0,
4514            source_step,
4515        )?;
4516        let p1 = self.repaired_source_sample(channel_index, global_index, source_step)?;
4517        let p2 = self.repaired_source_sample(
4518            channel_index,
4519            global_index + 1.0,
4520            source_step,
4521        )?;
4522        let p3 = self.repaired_source_sample(
4523            channel_index,
4524            global_index + 2.0,
4525            source_step,
4526        )?;
4527        let a = p2 - p0;
4528        let b = 2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3;
4529        let c = 3.0 * (p1 - p2) + p3 - p0;
4530        let slope = 0.5 * (a + 2.0 * b * t + 3.0 * c * t * t);
4531        let curvature = (p0 - 2.0 * p1 + p2) * (1.0 - t) + (p1 - 2.0 * p2 + p3) * t;
4532        let sample = self.repaired_source_sample(channel_index, position, source_step)?;
4533        Some((sample, slope, curvature))
4534    }
4535
4536    /// Reads a locked revolution through the same 24-sample cubic Hermite
4537    /// bridge as fixed-context EnCodec chunks. Its anchor and duration stay
4538    /// exact because only samples around the circular join are replaced.
4539    fn repaired_source_sample(
4540        &self,
4541        channel_index: usize,
4542        position: f64,
4543        source_step: f64,
4544    ) -> Option<f64> {
4545        let raw = |source_position: f64| {
4546            let channel = self.channels.get(channel_index)?;
4547            let local = (source_position - self.window_start as f64)
4548                .clamp(0.0, channel.len().saturating_sub(2) as f64);
4549            adaptive_sample(channel, local, source_step)
4550        };
4551        if self.locked_groove_start < 0.0 {
4552            return raw(position);
4553        }
4554
4555        let turn = self.source_sample_rate * 60.0 / self.native_rpm.max(1.0);
4556        let phase = (position - self.locked_groove_start).rem_euclid(turn);
4557        let each_side = SEAM_REPAIR_SAMPLES as f64 / 2.0;
4558        let offset = if phase >= turn - each_side {
4559            phase - turn
4560        } else if phase < each_side {
4561            phase
4562        } else {
4563            return raw(position);
4564        };
4565
4566        let tail = self.locked_groove_start + turn;
4567        let y0 = raw(tail - each_side - 1.0)?;
4568        let m0 = raw(tail - each_side)? - y0;
4569        let y1 = raw(self.locked_groove_start + each_side)?;
4570        let m1 = y1 - raw(self.locked_groove_start + each_side - 1.0)?;
4571        let span = SEAM_REPAIR_SAMPLES as f64;
4572        let t = (offset + each_side + 1.0) / (span + 1.0);
4573        let t2 = t * t;
4574        let t3 = t2 * t;
4575        let h00 = 2.0 * t3 - 3.0 * t2 + 1.0;
4576        let h10 = t3 - 2.0 * t2 + t;
4577        let h01 = -2.0 * t3 + 3.0 * t2;
4578        let h11 = t3 - t2;
4579        Some(h00 * y0 + h10 * span * m0 + h01 * y1 + h11 * span * m1)
4580    }
4581
4582    fn begin_output_seam_repair(&mut self) {
4583        if self.last_emitted_samples.is_empty() {
4584            return;
4585        }
4586        self.seam_repair_from
4587            .clone_from(&self.last_emitted_samples);
4588        self.seam_repair_remaining = SEAM_REPAIR_SAMPLES;
4589    }
4590
4591    /// A nudge is a transport join rather than a source join. Ease from the
4592    /// final emitted value into the new stream with a zero-slope cubic, with
4593    /// no added frames or callback latency.
4594    fn apply_output_seam_repair(&mut self, frames: usize, channels: usize) {
4595        if frames == 0 || channels == 0 {
4596            return;
4597        }
4598        let repair_frames = frames.min(self.seam_repair_remaining);
4599        for frame in 0..repair_frames {
4600            let completed = SEAM_REPAIR_SAMPLES - self.seam_repair_remaining + frame + 1;
4601            let t = completed as f64 / SEAM_REPAIR_SAMPLES as f64;
4602            let weight = t * t * (3.0 - 2.0 * t);
4603            for channel in 0..channels {
4604                let index = frame * channels + channel;
4605                let from = self
4606                    .seam_repair_from
4607                    .get(channel)
4608                    .copied()
4609                    .unwrap_or(0.0);
4610                let next = f64::from(self.output[index]);
4611                self.output[index] = (from + (next - from) * weight) as f32;
4612            }
4613        }
4614        self.seam_repair_remaining -= repair_frames;
4615        self.last_emitted_samples.resize(channels, 0.0);
4616        let last = (frames - 1) * channels;
4617        for channel in 0..channels {
4618            self.last_emitted_samples[channel] = f64::from(self.output[last + channel]);
4619        }
4620    }
4621
4622    fn advance_deck_mechanics(&mut self, hand_rate: f64) -> f64 {
4623        let before = self.deck_state.telemetry();
4624        if !self.hand_contact
4625            && self.motor_rate.abs() >= DEADZONE_RATE
4626            && before.platter_rate == self.motor_rate
4627            && before.record_rate == self.motor_rate
4628        {
4629            let turn_step = self.motor_rate * self.native_rpm / (60.0 * self.output_sample_rate);
4630            if let Err(error) = self.deck_state.reset(
4631                self.motor_rate,
4632                self.motor_rate,
4633                before.platter_angle_turns + turn_step,
4634                before.record_angle_turns + turn_step,
4635            ) {
4636                self.record_deck_recovery(
4637                    DeckRecoveryOperation::LockedPlaybackReset,
4638                    error,
4639                    before,
4640                    hand_rate,
4641                );
4642            } else {
4643                self.rate_velocity = 0.0;
4644                self.rate = self.motor_rate;
4645                self.motor_delivered_rate = self.motor_rate;
4646                self.platter_rotation_turns = before.record_angle_turns + turn_step;
4647                return self.motor_rate;
4648            }
4649        }
4650        // A lifted finger eases off over a short force collapse instead of
4651        // dropping its normal force in a single sample. The position servo
4652        // ends at release; only the fading friction remains.
4653        if !self.hand_contact && self.release_grip > 0.0 {
4654            self.release_grip *= (-(1.0 / self.output_sample_rate) / HAND_RELEASE_SECONDS).exp();
4655            if self.release_grip <= GRIP_CONTACT_EPSILON {
4656                self.release_grip = 0.0;
4657            }
4658        }
4659        let hand_engaged = self.hand_contact || self.release_grip > 0.0;
4660        let hand_target_angle_turns = if self.hand_contact && self.grip > 0.0 {
4661            let frames_per_turn =
4662                self.source_sample_rate * 60.0 / self.native_rpm.max(f64::EPSILON);
4663            Some(
4664                before.record_angle_turns
4665                    + self.locked_groove_position_delta(
4666                        self.target_position,
4667                        self.position,
4668                    ) / frames_per_turn,
4669            )
4670        } else {
4671            None
4672        };
4673        // A drive only acts on a free coast: hand off, motor off, platter
4674        // still turning. It multiplies the throw rate by e^(drive·dt) and
4675        // lets the motor servo chase the growing target, so the bearing's
4676        // friction still pushes back through the same mechanics.
4677        let coasting_drive = self.free_spin_drive_per_second > 0.0
4678            && !hand_engaged
4679            && self.motor_rate.abs() < DEADZONE_RATE
4680            && self.unpowered_throw_rate.abs() >= DEADZONE_RATE;
4681        if coasting_drive {
4682            let growth =
4683                (self.free_spin_drive_per_second / self.output_sample_rate).exp();
4684            self.unpowered_throw_rate = (self.unpowered_throw_rate * growth)
4685                .clamp(-self.config.max_rate, self.config.max_rate);
4686        }
4687        let motor_mode = if self.motor_rate.abs() >= DEADZONE_RATE {
4688            MotorMode::Servo
4689        } else if coasting_drive {
4690            MotorMode::Servo
4691        } else if hand_engaged || self.unpowered_throw_rate.abs() >= DEADZONE_RATE {
4692            MotorMode::Off
4693        } else {
4694            MotorMode::Brake
4695        };
4696        let normalized = NormalizedDeckControl {
4697            motor_mode,
4698            motor_rate: if coasting_drive {
4699                self.unpowered_throw_rate
4700            } else {
4701                self.motor_rate
4702            },
4703            hand_contact: hand_engaged,
4704            hand_target_angle_turns,
4705            hand_rate,
4706            grip: if self.hand_contact {
4707                self.grip
4708            } else {
4709                self.release_grip
4710            },
4711            stylus_torque_nm: 0.0,
4712        };
4713        let control = DeckMechanicalControl::from_normalized(self.deck_state.config(), normalized);
4714        let mut telemetry = match self
4715            .deck_state
4716            .advance(1.0 / self.output_sample_rate, control)
4717        {
4718            Ok(telemetry) => telemetry,
4719            Err(error) => {
4720                // The mechanical step is transactional. Keep its last valid state
4721                // for this sample, then retry the current control on the next one.
4722                // A rejected step must not unwind through a real-time callback.
4723                self.record_deck_recovery(
4724                    DeckRecoveryOperation::MechanicalAdvance,
4725                    error,
4726                    before,
4727                    hand_rate,
4728                );
4729                self.rate_velocity = 0.0;
4730                self.rate = before.record_rate;
4731                self.motor_delivered_rate = before.platter_rate;
4732                self.platter_rotation_turns = before.record_angle_turns;
4733                return before.record_rate;
4734            }
4735        };
4736        let servo_capture_error = 1.0e-5;
4737        if !self.hand_contact
4738            && self.motor_rate.abs() >= DEADZONE_RATE
4739            && (telemetry.platter_rate - self.motor_rate).abs() < servo_capture_error
4740            && (telemetry.record_rate - self.motor_rate).abs() < servo_capture_error
4741        {
4742            if let Err(error) = self.deck_state.reset(
4743                self.motor_rate,
4744                self.motor_rate,
4745                telemetry.platter_angle_turns,
4746                telemetry.record_angle_turns,
4747            ) {
4748                self.record_deck_recovery(
4749                    DeckRecoveryOperation::ServoCaptureReset,
4750                    error,
4751                    telemetry,
4752                    hand_rate,
4753                );
4754            } else {
4755                telemetry = self.deck_state.telemetry();
4756            }
4757        }
4758        self.rate_velocity = (telemetry.record_rate - self.rate) * self.output_sample_rate;
4759        self.rate = telemetry.record_rate;
4760        self.motor_delivered_rate = telemetry.platter_rate;
4761        self.platter_rotation_turns = telemetry.record_angle_turns;
4762        if self.unpowered_throw_rate.abs() >= DEADZONE_RATE {
4763            self.unpowered_throw_rate = telemetry.record_rate;
4764            if self.unpowered_throw_rate.abs() < DEADZONE_RATE {
4765                self.unpowered_throw_rate = 0.0;
4766            }
4767        }
4768        telemetry.record_rate
4769    }
4770
4771    fn advance_wow_flutter(&mut self, corrected_rate: f64, rate_scale: f64, abs_rate: f64) -> f64 {
4772        if self.source_sample_rate <= 0.0 {
4773            return 0.0;
4774        }
4775        let frames_per_rev = (60.0 / self.native_rpm.max(1e-6)) * self.source_sample_rate;
4776        self.wow_phase += corrected_rate * rate_scale / frames_per_rev;
4777        self.flutter_phase +=
4778            self.config.flutter_hz / self.output_sample_rate * abs_rate.clamp(0.0, 1.4);
4779        if abs_rate <= 0.18 {
4780            return 0.0;
4781        }
4782        let free_depth = abs_rate.clamp(0.0, 1.2) * FREE_PLAYBACK_WOW_DEPTH;
4783        let hand_slip = if self.hand_contact {
4784            self.grip * (self.motor_delivered_rate - corrected_rate).abs().min(2.0)
4785        } else {
4786            0.0
4787        };
4788        let depth = free_depth + hand_slip * HAND_SLIP_WOW_DEPTH;
4789        (self.wow_phase * std::f64::consts::TAU).sin() * depth
4790            + (self.flutter_phase * std::f64::consts::TAU).sin() * depth * 0.22
4791    }
4792
4793    fn drag_lowpass_alpha(&self, abs_rate: f64) -> f64 {
4794        let speed = (abs_rate / DRAG_LOWPASS_RATE_KNEE).clamp(0.045, 1.0);
4795        let mut cutoff = DRAG_LOWPASS_MAX_HZ * speed.powf(1.3);
4796        if abs_rate > TRACING_LOSS_START_RATE {
4797            cutoff *= (TRACING_LOSS_START_RATE / abs_rate).clamp(0.55, 1.0);
4798        }
4799        1.0 - (-std::f64::consts::TAU * cutoff / self.output_sample_rate).exp()
4800    }
4801
4802    fn maybe_request_window(&mut self, frame_count: usize) {
4803        self.frames_since_window_request =
4804            self.frames_since_window_request.saturating_add(frame_count);
4805        let speed = self.last_effective_rate.abs().max(1.0);
4806        let throttle = if speed > 2.0 { 0.03 } else { 0.08 };
4807        if self.frames_since_window_request < (self.output_sample_rate * throttle) as usize
4808            || self.channels.is_empty()
4809        {
4810            return;
4811        }
4812        let start = self.window_start as f64;
4813        let end = self.window_end as f64;
4814        let window_span = (end - start).max(1.0);
4815        // A fixed high-rate margin can consume half of a smaller bounded
4816        // window and request a replacement every throttle interval. Keep both
4817        // the edge margin and directional look-ahead within one-sixth of the
4818        // active span: normal-speed values stay unchanged, while ±8–16x still
4819        // retain a useful reverse runway after a centered swap.
4820        let directional_runway = (window_span / 6.0).max(256.0);
4821        let margin =
4822            (WINDOW_REQUEST_MARGIN_SECONDS * self.source_sample_rate * (speed * 0.5).max(1.0))
4823                .max(256.0)
4824                .min(directional_runway);
4825        let projected_offset =
4826            (self.last_effective_rate * self.source_sample_rate * WINDOW_REQUEST_PROJECT_SECONDS)
4827                .clamp(-directional_runway, directional_runway);
4828        let projected = self.clamp_source_position(self.position + projected_offset);
4829        let request = if self.last_effective_rate < 0.0 {
4830            self.position.min(projected)
4831        } else {
4832            self.position.max(projected)
4833        };
4834        let approaching_active_edge = if self.last_effective_rate < 0.0 {
4835            self.window_start > 0 && (self.position < start + margin || request < start + margin)
4836        } else if self.last_effective_rate > 0.0 {
4837            self.window_end < self.total_frames
4838                && (self.position > end - margin || request > end - margin)
4839        } else {
4840            false
4841        };
4842        if approaching_active_edge {
4843            self.frames_since_window_request = 0;
4844            self.requested_window_position = Some(request);
4845        }
4846    }
4847}
4848
4849fn finite_or_zero(value: f64) -> f64 {
4850    if value.is_finite() {
4851        value
4852    } else {
4853        0.0
4854    }
4855}
4856
4857/// The 7-inch single's groove band, outer to inner, in millimetres. The
4858/// off-centre warble is eccentricity over groove radius, so it deepens as
4859/// the stylus walks in — the number a mis-punched 45 actually produces.
4860const SINGLE_OUTER_GROOVE_MM: f64 = 84.0;
4861const SINGLE_INNER_GROOVE_MM: f64 = 54.0;
4862
4863/// The three things in the engine that accumulate, and the word each is
4864/// asked for by.
4865///
4866/// Nothing else in the deck holds history: every other effect is a filter
4867/// or a gain that starts from wherever the signal leaves it. These are the
4868/// exceptions, and they are the reason a take cannot be reproduced from its
4869/// gesture stream alone.
4870#[derive(Clone, Copy, Debug, PartialEq, Eq)]
4871pub enum WearScope {
4872    /// The WEAR dial's map, one bucket per `WEAR_BUCKET_FRAMES` of source:
4873    /// it wears where along the record the stylus went.
4874    Groove,
4875    /// WORN HALO's bins, indexed by phase within one revolution: it wears
4876    /// where around the *turn* the stylus went. A different quantity from
4877    /// `Groove`, and cleared separately.
4878    Halo,
4879    /// The revolution memory ADJACENT GHOST, THREE NEEDLES and SPLIT WALLS
4880    /// read back from, and the filters riding on it.
4881    Polar,
4882    /// The three above.
4883    All,
4884}
4885
4886impl WearScope {
4887    pub fn parse(scope: &str) -> Option<Self> {
4888        match scope {
4889            "groove" => Some(Self::Groove),
4890            "halo" => Some(Self::Halo),
4891            "polar" => Some(Self::Polar),
4892            "all" => Some(Self::All),
4893            _ => None,
4894        }
4895    }
4896
4897    pub const fn as_str(self) -> &'static str {
4898        match self {
4899            Self::Groove => "groove",
4900            Self::Halo => "halo",
4901            Self::Polar => "polar",
4902            Self::All => "all",
4903        }
4904    }
4905}
4906
4907impl ScratchAcousticDsp {
4908    /// Clears one accumulator, off the wasm binding.
4909    ///
4910    /// `resetWear` is the browser's door onto this; the C ABI and the tests
4911    /// come in here instead, because a `JsValue` cannot be built off wasm32.
4912    pub fn reset_wear_scope(&mut self, scope: WearScope) {
4913        match scope {
4914            WearScope::Groove => self.groove_wear.fill(0.0),
4915            WearScope::Halo => self.vinyl_vfx.reset_wear(),
4916            WearScope::Polar => self.vinyl_vfx.reset_transient_state(),
4917            WearScope::All => {
4918                self.groove_wear.fill(0.0);
4919                self.vinyl_vfx.reset_all();
4920            }
4921        }
4922    }
4923}
4924
4925/// Source frames per wear bucket. At 48k this is about 21 ms of groove —
4926/// fine enough that a scratched bar wears where the scratching happened.
4927pub const WEAR_BUCKET_FRAMES: usize = 1024;
4928
4929fn production_deck_config(output_sample_rate: f64, native_rpm: f64) -> PhysicalDeckConfig {
4930    let mut config = PhysicalDeckConfig::high_torque_dj_seed();
4931    config.nominal_rpm = native_rpm.clamp(16.0, 90.0);
4932    config.integration_hz = output_sample_rate.clamp(1_000.0, 768_000.0);
4933    // The loose hand servo the deck shipped with, restored: the tight
4934    // physical seed (4 ms, 25 rad/s) won `hand-spin.mjs` offline and lost
4935    // in the hand on the phone. Measured-better, felt-worse — so the pair
4936    // chosen by ear stands until something felt beats it, and the seed's
4937    // stays reachable through `set_hand_servo`. The dead-reckoned target
4938    // that shipped alongside it is kept; only the servo goes back.
4939    config.hand_position_stabilization_seconds = POSITION_CATCHUP_SECONDS;
4940    config.hand_max_position_correction_rad_s = 0.12 * config.nominal_angular_velocity_rad_s();
4941    config
4942}
4943
4944fn valid_unit_interval(value: f64) -> bool {
4945    value.is_finite() && (0.0..=1.0).contains(&value)
4946}
4947
4948/// The friction terms scale from silent to four times the historical level.
4949fn valid_texture_scale(value: f64) -> bool {
4950    value.is_finite() && (0.0..=4.0).contains(&value)
4951}
4952
4953/// One per-component surface gain, checked and returned for the setter.
4954fn valid_surface_gain(value: f64) -> Result<f64, JsValue> {
4955    if valid_texture_scale(value) {
4956        Ok(value)
4957    } else {
4958        Err(JsValue::from_str("surface gains must be between 0 and 4"))
4959    }
4960}
4961
4962/// The fixed RIAA voicing is a rate, so it only has to be positive and
4963/// finite; the tilt clamps it to its own `[0.1, 4.0]` window.
4964fn valid_riaa_voicing_rate(value: f64) -> bool {
4965    value.is_finite() && value > 0.0
4966}
4967
4968fn sign_nonzero(primary: f64, fallback: f64) -> f64 {
4969    if primary != 0.0 {
4970        primary.signum()
4971    } else if fallback != 0.0 {
4972        fallback.signum()
4973    } else {
4974        1.0
4975    }
4976}
4977
4978fn compute_movement_gain(
4979    abs_rate: f64,
4980    acoustic_enabled: bool,
4981    cartridge_velocity_gain: bool,
4982) -> f64 {
4983    // A magnetic cartridge is a velocity transducer, so playing the groove at
4984    // rate `r` puts out `r * m(r*t)`: the level rides the rate. That is one
4985    // law for the whole range, exactly 1.0 at nominal speed and continuously
4986    // silent at rest, so it needs no stop knee — a stationary record is quiet
4987    // because nothing is moving past the coils, not because a gate closed.
4988    let stop_gain = if cartridge_velocity_gain {
4989        abs_rate.min(MAX_CARTRIDGE_VELOCITY_GAIN)
4990    } else {
4991        smoothstep_unit(abs_rate / STOP_GAIN_FULL_RATE)
4992    };
4993    if !acoustic_enabled {
4994        return stop_gain;
4995    }
4996    let normalized = abs_rate.clamp(0.0, 10.0);
4997    let underspeed = 0.78 + 0.22 * normalized.max(DEADZONE_RATE).powf(0.1);
4998    let overspeed = 1.0 + (normalized - 1.0).max(0.0) * 0.014;
4999    let acoustic = if normalized <= 1.0 {
5000        underspeed
5001    } else {
5002        overspeed
5003    };
5004    let ceiling = if cartridge_velocity_gain {
5005        MAX_CARTRIDGE_VELOCITY_GAIN * 1.08
5006    } else {
5007        1.08
5008    };
5009    (acoustic * stop_gain).clamp(0.0, ceiling)
5010}
5011
5012/// Approximate the finite acceleration a cartridge can trace. Curvature is the
5013/// local second difference of groove displacement; traversing it faster raises
5014/// acceleration with velocity squared. Instead of hard clipping that demand,
5015/// reduce the existing tracing-filter cutoff through a smooth knee.
5016fn stylus_tracing_alpha(base_alpha: f64, curvature: f64, abs_rate: f64, strength: f64) -> f64 {
5017    let base_alpha = finite_or_zero(base_alpha).clamp(0.0, 1.0);
5018    let strength = finite_or_zero(strength).clamp(0.0, 1.0);
5019    if strength <= 0.0 || abs_rate <= 0.75 || curvature == 0.0 {
5020        return base_alpha;
5021    }
5022    let demand = curvature.abs() * abs_rate * abs_rate;
5023    let overload = smoothstep_unit(
5024        (demand - STYLUS_TRACING_CURVATURE_THRESHOLD)
5025            / (STYLUS_TRACING_CURVATURE_FULL_SCALE - STYLUS_TRACING_CURVATURE_THRESHOLD),
5026    );
5027    let velocity_presence = smoothstep_unit((abs_rate - 0.75) / (4.0 - 0.75));
5028    let cutoff_scale = (1.0 - strength * overload * velocity_presence).clamp(0.16, 1.0);
5029    1.0 - (1.0 - base_alpha).powf(cutoff_scale)
5030}
5031
5032fn smoothstep_unit(value: f64) -> f64 {
5033    let value = finite_or_zero(value).clamp(0.0, 1.0);
5034    value * value * (3.0 - 2.0 * value)
5035}
5036
5037fn compute_contact_noise_gain(abs_rate: f64) -> f64 {
5038    if abs_rate <= DEADZONE_RATE {
5039        return 0.0;
5040    }
5041    let distance = (abs_rate - 1.0).abs();
5042    let realtime_dip = 1.0 - 0.94 * (-(distance * distance) / 0.18).exp();
5043    let slow_rub = ((0.26 - abs_rate) / 0.26).clamp(0.0, 1.0) * 0.36;
5044    let fast_friction = ((abs_rate - 2.2) / 5.5).clamp(0.0, 1.0) * 0.72;
5045    CONTACT_NOISE_GAIN * realtime_dip * (0.24 + slow_rub + fast_friction).clamp(0.08, 1.08)
5046}
5047
5048fn compute_source_texture_gain(abs_rate: f64, rate_delta: f64) -> f64 {
5049    if abs_rate <= DEADZONE_RATE {
5050        return 0.0;
5051    }
5052    let distance = (abs_rate - 1.0).abs();
5053    let realtime_dip = 1.0 - 0.72 * (-(distance * distance) / 0.14).exp();
5054    let slow_rub = ((0.42 - abs_rate) / 0.42).clamp(0.0, 1.0);
5055    let speed_lift = (abs_rate / 2.2).clamp(0.0, 1.0);
5056    let acceleration_lift = (rate_delta / 1.6).clamp(0.0, 1.0);
5057    SOURCE_TEXTURE_GAIN
5058        * realtime_dip
5059        * (0.18 + slow_rub * 0.72 + speed_lift * 0.28 + acceleration_lift * 0.38)
5060}
5061
5062#[cfg(test)]
5063mod tests {
5064    use super::*;
5065
5066    fn seed_deck_rates(
5067        dsp: &mut ScratchAcousticDsp,
5068        platter_rate: f64,
5069        record_rate: f64,
5070        turns: f64,
5071    ) {
5072        dsp.deck_state
5073            .reset(platter_rate, record_rate, turns, turns)
5074            .unwrap();
5075        dsp.motor_delivered_rate = platter_rate;
5076        dsp.rate = record_rate;
5077        dsp.rate_velocity = 0.0;
5078        dsp.last_effective_rate = record_rate;
5079        dsp.platter_rotation_turns = turns;
5080    }
5081
5082
5083
5084    #[test]
5085    fn soft_clip_folds_peaks_and_preserves_silence() {
5086        assert!(!AcousticConfig::default().soft_clip);
5087        // Pinned tanh behaviour: unity slope at silence, folded peaks.
5088        assert_eq!(0.0_f64.tanh(), 0.0);
5089        assert!((0.5_f64.tanh() - 0.462_117_157_260_009_74).abs() < 1e-15);
5090        assert!(3.0_f64.tanh() < 1.0 && 3.0_f64.tanh() > 0.99);
5091        assert_eq!((-2.0_f64.tanh()), -(2.0_f64.tanh()));
5092        let mut dsp = simulation_dsp();
5093        dsp.set_soft_clip(true);
5094        assert!(dsp.soft_clip());
5095        dsp.set_soft_clip(false);
5096        assert!(!dsp.soft_clip());
5097    }
5098
5099    #[test]
5100    fn riaa_speed_tilt_toggles_live() {
5101        assert!(AcousticConfig::default().riaa_speed_tilt);
5102        let mut dsp = simulation_dsp();
5103        dsp.set_riaa_speed_tilt(false);
5104        assert!(!dsp.riaa_speed_tilt());
5105        dsp.set_riaa_speed_tilt(true);
5106        assert!(dsp.riaa_speed_tilt());
5107    }
5108
5109    #[test]
5110    fn texture_scale_defaults_to_the_historical_level() {
5111        assert_eq!(AcousticConfig::default().texture_scale, 1.0);
5112        // The friction predicate admits silence through four times history.
5113        assert!(valid_texture_scale(0.0));
5114        assert!(valid_texture_scale(1.0));
5115        assert!(valid_texture_scale(4.0));
5116        assert!(!valid_texture_scale(-0.1));
5117        assert!(!valid_texture_scale(4.1));
5118        assert!(!valid_texture_scale(f64::NAN));
5119        assert!(!valid_texture_scale(f64::INFINITY));
5120        // The live setter wires through on the accept path.
5121        let mut dsp = simulation_dsp();
5122        assert!(dsp.set_texture_scale(0.5).is_ok());
5123        assert_eq!(dsp.texture_scale(), 0.5);
5124    }
5125
5126
5127
5128    fn simulation_dsp() -> ScratchAcousticDsp {
5129        let mut config = AcousticConfig::default();
5130        config.acoustic_enabled = true;
5131        config.surface_enabled = true;
5132        config.stylus_tracing_limit = 0.72;
5133        config.high_frequency_acceleration_limit = 0.35;
5134        let mut dsp = ScratchAcousticDsp::new_internal(48_000.0, config);
5135        dsp.source_sample_rate = 48_000.0;
5136        dsp.channels = Arc::new(vec![vec![0.0_f32; 4_800_000]]);
5137        dsp.window_start = 0;
5138        dsp.window_end = 4_800_000;
5139        dsp.total_frames = 4_800_000;
5140        dsp
5141    }
5142
5143    /// Runs the motor up to speed so the effect tests measure a settled
5144    /// platter, not the spin-up.
5145    fn settle_motor(dsp: &mut ScratchAcousticDsp) {
5146        dsp.start();
5147        dsp.set_transport(false, 1.0, 0.0, 0.0);
5148        for _ in 0..375 {
5149            dsp.render(128, 1); // one second
5150        }
5151    }
5152
5153    #[test]
5154    fn off_centre_hole_swings_the_rate_once_per_revolution() {
5155        let mut dsp = simulation_dsp();
5156        dsp.set_native_rpm(90.0).unwrap();
5157        dsp.set_press_defects(1.5, 0.0).unwrap();
5158        settle_motor(&mut dsp);
5159
5160        // One revolution at 90 rpm and 48k is 32,000 source frames. Walk it
5161        // frame by frame and watch the instantaneous advance breathe.
5162        let mut minimum = f64::INFINITY;
5163        let mut maximum = f64::NEG_INFINITY;
5164        let mut total = 0.0;
5165        let frames = 32_000;
5166        for _ in 0..frames {
5167            let before = dsp.position;
5168            dsp.render(1, 1);
5169            let advance = dsp.position - before;
5170            minimum = minimum.min(advance);
5171            maximum = maximum.max(advance);
5172            total += advance;
5173        }
5174        // Eccentricity 1.5 mm over the 84→54 mm groove band is a ±1.8%-ish
5175        // warble at the outer edge; the mean over a whole turn cancels.
5176        assert!(maximum - minimum > 0.02, "spread {}", maximum - minimum);
5177        let mean = total / frames as f64;
5178        assert!((mean - 1.0).abs() < 0.01, "mean advance {mean}");
5179    }
5180
5181    #[test]
5182    fn angle_gate_cuts_its_sectors_out_of_the_turn() {
5183        let mut dsp = simulation_dsp();
5184        dsp.set_native_rpm(90.0).unwrap();
5185        dsp.set_angle_gate(4, 1.0).unwrap();
5186        // A steady tone so the gate has something to chop.
5187        let tone: Vec<f32> = (0..4_800_000)
5188            .map(|i| ((i as f64 * 0.05).sin() * 0.5) as f32)
5189            .collect();
5190        dsp.channels = Arc::new(vec![tone]);
5191        settle_motor(&mut dsp);
5192
5193        // Walk to a sector boundary first — the spin-up leaves the platter
5194        // at an arbitrary angle — then split one revolution into its eight
5195        // half-sectors. The mean level must alternate around the turn.
5196        for _ in 0..40_000 {
5197            let phase = (dsp.platter_rotation_turns * 4.0).rem_euclid(1.0);
5198            if phase < 0.005 {
5199                break;
5200            }
5201            dsp.render(1, 1);
5202        }
5203        let mut spans = Vec::new();
5204        for _ in 0..8 {
5205            let mut energy = 0.0_f64;
5206            for _ in 0..4_000 {
5207                dsp.render(1, 1);
5208                energy += f64::from(dsp.rendered_samples()[0]).abs();
5209            }
5210            spans.push(energy / 4_000.0);
5211        }
5212        let even: Vec<f64> = spans.iter().copied().step_by(2).collect();
5213        let odd: Vec<f64> = spans.iter().copied().skip(1).step_by(2).collect();
5214        let floor = |values: &[f64]| values.iter().cloned().fold(f64::INFINITY, f64::min);
5215        let ceiling = |values: &[f64]| values.iter().cloned().fold(0.0, f64::max);
5216        // One parity is the open sectors, the other the cut — which is
5217        // which depends only on where the boundary walk landed.
5218        let alternates = floor(&even) > ceiling(&odd) * 3.0
5219            || floor(&odd) > ceiling(&even) * 3.0;
5220        assert!(alternates, "spans did not alternate: {spans:?}");
5221    }
5222
5223    #[test]
5224    fn locked_groove_holds_until_cleared() {
5225        let mut dsp = simulation_dsp();
5226        dsp.set_native_rpm(90.0).unwrap();
5227        settle_motor(&mut dsp);
5228        let frames_per_turn = 32_000.0;
5229        let start = dsp.position;
5230        dsp.set_locked_groove(start).unwrap();
5231
5232        // Three revolutions of rendering never leave the ring.
5233        for _ in 0..750 {
5234            dsp.render(128, 1);
5235        }
5236        assert!(
5237            dsp.position >= start && dsp.position < start + frames_per_turn,
5238            "escaped to {} from a ring at {start}",
5239            dsp.position,
5240        );
5241
5242        // Only an explicit clear lets the next revolution walk out.
5243        dsp.set_locked_groove(-1.0).unwrap();
5244        for _ in 0..300 {
5245            dsp.render(128, 1);
5246        }
5247        assert!(
5248            dsp.position >= start + frames_per_turn,
5249            "still inside at {}",
5250            dsp.position,
5251        );
5252    }
5253
5254    #[test]
5255    fn locked_groove_keeps_exact_anchor_and_wraps_every_transport_target() {
5256        let mut dsp = simulation_dsp();
5257        dsp.set_native_rpm(90.0).unwrap();
5258        let frames_per_turn = 32_000.0;
5259        dsp.set_position(190_000.375, 0.0);
5260        let start = dsp.position;
5261        dsp.set_locked_groove(start).unwrap();
5262
5263        assert_eq!(dsp.position, start, "arming moved the needle");
5264
5265        dsp.set_position(start + frames_per_turn * 2.25, 0.0);
5266        assert!((dsp.position - (start + frames_per_turn * 0.25)).abs() < 1e-9);
5267
5268        dsp.set_position(start - frames_per_turn * 0.25, 0.0);
5269        assert!((dsp.position - (start + frames_per_turn * 0.75)).abs() < 1e-9);
5270
5271        dsp.set_motion(start + frames_per_turn * 3.5, -1.0, 0.0);
5272        assert!((dsp.target_position - (start + frames_per_turn * 0.5)).abs() < 1e-9);
5273
5274        dsp.render(1, 1);
5275        assert!(dsp.position >= start && dsp.position < start + frames_per_turn);
5276        assert!(
5277            dsp.target_position >= start
5278                && dsp.target_position < start + frames_per_turn
5279        );
5280    }
5281
5282    #[test]
5283    fn locked_groove_scratch_servo_uses_circular_distance_across_the_seam() {
5284        let mut dsp = simulation_dsp();
5285        dsp.set_native_rpm(90.0).unwrap();
5286        let frames_per_turn = 32_000.0;
5287        let start = 190_000.375;
5288        dsp.set_position(start, 0.0);
5289        dsp.set_locked_groove(start).unwrap();
5290
5291        let tail = start + frames_per_turn - 4.0;
5292        let head = start + 6.0;
5293        assert_eq!(dsp.locked_groove_position_delta(head, tail), 10.0);
5294        assert_eq!(dsp.locked_groove_position_delta(tail, head), -10.0);
5295
5296        dsp.set_locked_groove(-1.0).unwrap();
5297        assert_eq!(dsp.locked_groove_position_delta(head, tail), 10.0 - frames_per_turn);
5298    }
5299
5300    #[test]
5301    fn locked_groove_uses_codec_hermite_repair_at_its_circular_seam() {
5302        let mut dsp = simulation_dsp();
5303        dsp.set_native_rpm(90.0).unwrap();
5304        let start = 190_000.0;
5305        let turn = 32_000.0;
5306        let channel = Arc::make_mut(&mut dsp.channels)
5307            .first_mut()
5308            .unwrap();
5309        channel[start as usize..(start + turn / 2.0) as usize].fill(1.0);
5310        channel[(start + turn / 2.0) as usize..(start + turn) as usize]
5311            .fill(-1.0);
5312        dsp.set_position(start, 0.0);
5313        dsp.set_locked_groove(start).unwrap();
5314
5315        let tail = dsp
5316            .repaired_source_sample(0, start + turn - 0.001, 1.0)
5317            .unwrap();
5318        let head = dsp.repaired_source_sample(0, start, 1.0).unwrap();
5319
5320        assert!((head - tail).abs() < 0.2, "repaired jump was {}", head - tail);
5321        assert_eq!(
5322            dsp.repaired_source_sample(0, start + 100.0, 1.0),
5323            Some(1.0)
5324        );
5325    }
5326
5327    #[test]
5328    fn transport_jump_eases_over_the_codec_repair_span() {
5329        let mut dsp = simulation_dsp();
5330        dsp.last_emitted_samples = vec![0.8];
5331        dsp.begin_output_seam_repair();
5332        dsp.output = vec![-0.8; SEAM_REPAIR_SAMPLES];
5333
5334        dsp.apply_output_seam_repair(SEAM_REPAIR_SAMPLES, 1);
5335
5336        assert!(dsp.output[0] > 0.79);
5337        assert_eq!(dsp.output[SEAM_REPAIR_SAMPLES - 1], -0.8);
5338        assert_eq!(dsp.seam_repair_remaining, 0);
5339    }
5340
5341    #[test]
5342    fn second_stylus_echoes_at_its_angle() {
5343        let mut dsp = simulation_dsp();
5344        dsp.set_native_rpm(90.0).unwrap();
5345        // 90 degrees behind is a quarter turn: 8,000 source frames.
5346        dsp.set_stylus_tap(90.0, 0.9).unwrap();
5347        let mut source = vec![0.0_f32; 4_800_000];
5348        for value in source.iter_mut().skip(200_000).take(64) {
5349            *value = 0.9;
5350        }
5351        dsp.channels = Arc::new(vec![source]);
5352        settle_motor(&mut dsp);
5353        // Drop the needle just short of the impulse so the render reaches
5354        // both the strike and its echo a quarter turn later.
5355        dsp.set_position(190_000.0, 0.0);
5356
5357        let start = dsp.position;
5358        let mut peaks: Vec<(usize, f64)> = Vec::new();
5359        for frame in 0..40_000_usize {
5360            dsp.render(1, 1);
5361            let level = f64::from(dsp.rendered_samples()[0]).abs();
5362            if level > 0.02 {
5363                peaks.push((frame, level));
5364            }
5365        }
5366        assert!(!peaks.is_empty(), "the impulse never played");
5367        let first = peaks.first().unwrap().0;
5368        let expected_gap = 8_000.0;
5369        let echo = peaks
5370            .iter()
5371            .find(|(frame, _)| (*frame as f64 - first as f64) > expected_gap * 0.5)
5372            .map(|(frame, _)| *frame as f64 - first as f64);
5373        let gap = echo.expect("no echo followed the stylus");
5374        assert!(
5375            (gap - expected_gap).abs() < 400.0,
5376            "echo landed {gap} frames behind, wanted ~{expected_gap} (start {start})",
5377        );
5378    }
5379
5380    #[test]
5381    fn wear_accrues_where_the_stylus_passes_and_survives_restore() {
5382        let mut dsp = simulation_dsp();
5383        dsp.set_native_rpm(90.0).unwrap();
5384        dsp.set_groove_wear(4.0).unwrap();
5385        settle_motor(&mut dsp);
5386
5387        let bucket = (dsp.position as usize) / WEAR_BUCKET_FRAMES;
5388        for _ in 0..75 {
5389            dsp.render(128, 1); // a quarter second onward from here
5390        }
5391        let walked_end = (dsp.position as usize) / WEAR_BUCKET_FRAMES;
5392        let worn: f32 = dsp.groove_wear[bucket..=walked_end]
5393            .iter()
5394            .copied()
5395            .fold(0.0, f32::max);
5396        assert!(worn > 0.0, "the pass left no wear");
5397        let far = dsp.groove_wear[walked_end + 500];
5398        assert_eq!(far, 0.0, "unplayed groove wore anyway");
5399
5400        // The biography survives a save and restore.
5401        let map = dsp.groove_wear_map();
5402        let mut fresh = simulation_dsp();
5403        fresh.set_groove_wear(4.0).unwrap();
5404        fresh.restore_groove_wear_map(&map);
5405        assert_eq!(fresh.groove_wear[bucket], dsp.groove_wear[bucket]);
5406    }
5407
5408    #[test]
5409    fn each_accumulator_clears_on_its_own_scope() {
5410        use crate::VINYL_VFX_WORN_HALO;
5411        let mut dsp = simulation_dsp();
5412        dsp.set_native_rpm(90.0).unwrap();
5413        dsp.set_groove_wear(4.0).unwrap();
5414        dsp.set_vinyl_vfx(VINYL_VFX_WORN_HALO, 1.0).unwrap();
5415        settle_motor(&mut dsp);
5416        for _ in 0..75 {
5417            dsp.render(128, 1);
5418        }
5419
5420        assert!(dsp.vinyl_vfx.wear_level() > 0.0, "the halo never wore");
5421        let groove_before = dsp.groove_wear_map();
5422        assert!(
5423            groove_before.iter().any(|value| *value > 0.0),
5424            "the groove never wore"
5425        );
5426
5427        // A scope clears its own accumulator and leaves the others standing.
5428        dsp.reset_wear_scope(WearScope::Halo);
5429        assert_eq!(dsp.vinyl_vfx.wear_level(), 0.0, "halo survived its reset");
5430        assert_eq!(
5431            dsp.groove_wear_map(),
5432            groove_before,
5433            "the groove map was cleared by the halo's scope"
5434        );
5435
5436        dsp.reset_wear_scope(WearScope::Groove);
5437        assert!(
5438            dsp.groove_wear_map().iter().all(|value| *value == 0.0),
5439            "groove survived its reset"
5440        );
5441
5442        assert_eq!(WearScope::parse("nonsense"), None, "an unknown scope parsed");
5443        assert_eq!(WearScope::parse("halo"), Some(WearScope::Halo));
5444    }
5445
5446    #[test]
5447    fn a_replay_hands_back_the_record_it_found() {
5448        use crate::{VINYL_VFX_ADJACENT_GHOST, VINYL_VFX_WORN_HALO};
5449        let mut dsp = simulation_dsp();
5450        dsp.set_native_rpm(90.0).unwrap();
5451        dsp.set_groove_wear(4.0).unwrap();
5452        dsp.set_press_defects(1.25, 0.5).unwrap();
5453        dsp.set_stylus_tap(90.0, 0.4).unwrap();
5454        dsp.set_angle_gate(8, 0.6).unwrap();
5455        dsp.set_pressing_seed(77);
5456        dsp.set_free_spin_drive(0.1).unwrap();
5457        dsp.set_vinyl_vfx(VINYL_VFX_WORN_HALO, 1.0).unwrap();
5458        settle_motor(&mut dsp);
5459        for _ in 0..75 {
5460            dsp.render(128, 1);
5461        }
5462        let groove_before = dsp.groove_wear_map();
5463        let halo_before = dsp.halo_wear_map();
5464        assert!(groove_before.iter().any(|value| *value > 0.0));
5465        assert!(halo_before.iter().any(|value| *value > 0.0));
5466
5467        // The take's world: a flat record, a different scene, no wear.
5468        dsp.capture_replay_state();
5469        dsp.begin_deterministic_replay_from(0.0, 0.0, 12_345, 1.0)
5470            .unwrap();
5471        dsp.set_press_defects(0.0, 0.0).unwrap();
5472        dsp.set_stylus_tap(0.0, 0.0).unwrap();
5473        dsp.set_angle_gate(0, 0.0).unwrap();
5474        dsp.set_pressing_seed(0);
5475        dsp.set_free_spin_drive(0.0).unwrap();
5476        dsp.set_vinyl_vfx(VINYL_VFX_ADJACENT_GHOST, 0.5).unwrap();
5477        dsp.reset_wear_scope(WearScope::All);
5478        dsp.set_transport(false, 1.0, 0.0, 0.0);
5479        for _ in 0..40 {
5480            dsp.render(128, 1);
5481        }
5482        assert_ne!(dsp.groove_wear_map(), groove_before);
5483        assert_eq!(dsp.vinyl_vfx.scene(), VINYL_VFX_ADJACENT_GHOST);
5484
5485        assert!(dsp.restore_replay_state());
5486        assert_eq!(dsp.eccentricity_mm, 1.25, "the replay left its hole on the record");
5487        assert_eq!(dsp.warp_mm, 0.5);
5488        assert_eq!(dsp.stylus_tap_degrees, 90.0);
5489        assert_eq!(dsp.stylus_tap_level, 0.4);
5490        assert_eq!(dsp.angle_gate_sectors, 8);
5491        assert_eq!(dsp.angle_gate_depth, 0.6);
5492        assert_eq!(dsp.pressing_seed, 77);
5493        assert_eq!(dsp.free_spin_drive_per_second, 0.1);
5494        assert_eq!(dsp.vinyl_vfx.scene(), VINYL_VFX_WORN_HALO);
5495        assert_eq!(dsp.groove_wear_map(), groove_before, "the replay wore the live record");
5496        assert_eq!(dsp.halo_wear_map(), halo_before, "the replay cleared the live halo");
5497    }
5498
5499    #[test]
5500    fn a_replay_from_a_rate_starts_at_speed() {
5501        let mut dsp = simulation_dsp();
5502        dsp.set_native_rpm(45.0).unwrap();
5503        settle_motor(&mut dsp);
5504        dsp.capture_replay_state();
5505        dsp.begin_deterministic_replay_from(1_000.0, 0.25, 9, 1.0).unwrap();
5506        assert_eq!(dsp.rate, 1.0);
5507        assert_eq!(dsp.motor_rate, 1.0);
5508        assert_eq!(dsp.motor_delivered_rate, 1.0);
5509        dsp.set_transport(false, 1.0, 0.0, 0.0);
5510        let before = dsp.position;
5511        dsp.render(128, 1);
5512        // One quantum on, the platter has moved a full quantum's worth at
5513        // speed rather than a spin-up's worth.
5514        assert!(dsp.position - before > 100.0, "the platter spun up from rest");
5515        assert!(dsp.restore_replay_state());
5516
5517        // From rest is still from rest.
5518        dsp.capture_replay_state();
5519        dsp.begin_deterministic_replay(1_000.0, 0.25, 9).unwrap();
5520        assert_eq!(dsp.rate, 0.0);
5521        assert!(dsp.restore_replay_state());
5522        assert!(dsp.begin_replay(0.0, 0.0, 1, 99.0).is_err());
5523    }
5524
5525    #[test]
5526    fn a_take_is_cut_from_its_own_seed() {
5527        let mut dsp = simulation_dsp();
5528        dsp.seed_take_capture(0xdead_beef);
5529        assert_eq!(dsp.noise_seed, 0xdead_beef);
5530        assert_eq!(dsp.flutter_phase, f64::from(0xdead_beef_u32) / (f64::from(u32::MAX) + 1.0));
5531        dsp.seed_take_capture(0);
5532        assert_eq!(dsp.noise_seed, DEFAULT_REPLAY_NOISE_SEED);
5533        dsp.platter_rotation_turns = 3.25;
5534        dsp.wow_phase = 0.9;
5535        dsp.seed_take_capture(5);
5536        assert!((dsp.wow_phase - 0.25).abs() < 1e-12, "the wow was not brought to the platter");
5537
5538        let mut halo = vec![0.0_f32; 4];
5539        halo[2] = 0.5;
5540        dsp.restore_halo_wear_map(&halo);
5541        let restored = dsp.halo_wear_map();
5542        assert_eq!(restored[2], 0.5);
5543        assert_eq!(restored.len(), VinylVfxProcessor::wear_bin_count());
5544    }
5545
5546    #[test]
5547    fn a_revolution_is_cut_by_angle_from_the_ring_start() {
5548        let mut dsp = simulation_dsp();
5549        dsp.set_native_rpm(45.0).unwrap();
5550        settle_motor(&mut dsp);
5551        let frames_per_turn = dsp.source_sample_rate * 60.0 / 45.0;
5552        // A ring a little way in, the needle a third of a turn past its
5553        // start when CUT lands.
5554        let ring = 4_000.0;
5555        dsp.set_locked_groove(ring).unwrap();
5556        dsp.reset_position(ring + frames_per_turn / 3.0);
5557        for _ in 0..20 {
5558            dsp.render(128, 2);
5559        }
5560        let seed = 0x1234_5678;
5561        dsp.arm_revolution(ring, 200_000, seed).unwrap();
5562        assert!(dsp.revolution_capture_armed());
5563
5564        // Render on, keeping every block, until the turn is in.
5565        let mut rendered = Vec::new();
5566        let mut counter_before = dsp.rendered_frame_counter;
5567        let mut blocks = 0;
5568        let mut begin_seen_at = None;
5569        while !dsp.revolution_capture_done() && blocks < 2_000 {
5570            dsp.render(128, 2);
5571            rendered.extend_from_slice(&dsp.output[..256]);
5572            if begin_seen_at.is_none() && dsp.revolution_capture_began() {
5573                begin_seen_at = Some((counter_before, dsp.revolution_capture_start_frame() as u64));
5574            }
5575            counter_before = dsp.rendered_frame_counter;
5576            blocks += 1;
5577        }
5578        assert!(dsp.revolution_capture_done(), "the turn never closed");
5579        assert!(!dsp.revolution_capture_overflowed());
5580        let start_frame = dsp.revolution_capture_start_frame() as u64;
5581        let start_position = dsp.revolution_capture_start_position();
5582        // Began on the ring's start, not where CUT landed: within a frame's
5583        // travel of the ring.
5584        assert!(
5585            (start_position - ring).abs() < 1.0,
5586            "began at {start_position}, ring at {ring}"
5587        );
5588        // Not on the first frame: the needle had two thirds of a turn to go.
5589        let first_counter = begin_seen_at.expect("began").0;
5590        assert!(start_frame > first_counter, "began before the ring came round");
5591        // Exactly one turn long at this rate, to the frame.
5592        let frames = dsp.revolution_capture_frames() as f64;
5593        let expected = frames_per_turn / dsp.rate.max(f64::EPSILON);
5594        assert!(
5595            (frames - expected).abs() <= 2.0,
5596            "captured {frames} frames for a turn of {expected}"
5597        );
5598        // The seed went on at the crossing.
5599        assert_eq!(dsp.noise_seed != seed, true, "noise has advanced past the seed");
5600        // And what was kept is what was rendered, frame for frame.
5601        let offset = ((start_frame - (dsp.rendered_frame_counter - rendered.len() as u64 / 2)) * 2) as usize;
5602        let kept = dsp.take_revolution_capture();
5603        assert_eq!(kept.len(), frames as usize * 2);
5604        assert_eq!(&kept[..], &rendered[offset..offset + kept.len()]);
5605        assert!(!dsp.revolution_capture_armed());
5606    }
5607
5608    #[test]
5609    fn paging_a_window_keeps_the_wear_the_record_has_earned() {
5610        use crate::VINYL_VFX_ADJACENT_GHOST;
5611        const WINDOW_FRAMES: usize = 48_000 * 6;
5612        let mut dsp = simulation_dsp();
5613        dsp.set_native_rpm(90.0).unwrap();
5614        dsp.set_groove_wear(4.0).unwrap();
5615        dsp.set_vinyl_vfx(VINYL_VFX_ADJACENT_GHOST, 1.0).unwrap();
5616        settle_motor(&mut dsp);
5617        for _ in 0..75 {
5618            dsp.render(128, 1);
5619        }
5620        let groove_before = dsp.groove_wear_map();
5621        let polar_before = dsp.vinyl_vfx.polar_fill_ratio();
5622        assert!(groove_before.iter().any(|value| *value > 0.0));
5623        assert!(polar_before > 0.0);
5624
5625        // A streamed side commits one of these every few seconds. Wear that
5626        // reset here could never reach the fifty passes it is scaled for.
5627        let total = dsp.total_frames as u32;
5628        dsp.prepare_window(1, WINDOW_FRAMES as u32).unwrap();
5629        dsp.commit_window(48_000.0, 0, total, None).unwrap();
5630
5631        assert_eq!(
5632            dsp.groove_wear_map(),
5633            groove_before,
5634            "paging a window wiped the groove's wear"
5635        );
5636        assert_eq!(
5637            dsp.vinyl_vfx.polar_fill_ratio(),
5638            polar_before,
5639            "paging a window wiped the revolution memory"
5640        );
5641
5642        // A new record is the host's call, and clears all three.
5643        dsp.reset_wear_scope(WearScope::All);
5644        assert!(dsp.groove_wear_map().iter().all(|value| *value == 0.0));
5645        assert_eq!(dsp.vinyl_vfx.polar_fill_ratio(), 0.0);
5646        assert_eq!(dsp.vinyl_vfx.wear_level(), 0.0);
5647    }
5648
5649    #[test]
5650    fn the_wear_summary_reports_what_the_meters_show() {
5651        let mut dsp = simulation_dsp();
5652        dsp.set_groove_wear(1.0).unwrap();
5653        let summary: serde_json::Value =
5654            serde_json::from_str(&dsp.wear_summary()).expect("summary is not JSON");
5655        assert_eq!(summary["haloBins"], 2_048);
5656        assert_eq!(summary["polarBins"], 131_072);
5657        assert_eq!(summary["grooveBucketFrames"], WEAR_BUCKET_FRAMES);
5658        // 1 MiB of samples, 1 MiB of write tags, 8 KiB of wear bins.
5659        assert_eq!(summary["vfxBytes"], 2 * 1_048_576 + 2_048 * 4);
5660        assert_eq!(summary["polarFill"], 0.0);
5661    }
5662
5663    #[test]
5664    fn pressing_seed_gives_each_copy_its_own_crackle() {
5665        let mut dsp = simulation_dsp();
5666        let a = dsp.compute_position_surface_noise(96_000.0, 1.0);
5667        dsp.set_pressing_seed(0x5eed_1234);
5668        let b = dsp.compute_position_surface_noise(96_000.0, 1.0);
5669        dsp.set_pressing_seed(0x5eed_1234);
5670        let c = dsp.compute_position_surface_noise(96_000.0, 1.0);
5671        assert_ne!(a, b, "the seed changed nothing");
5672        assert_eq!(b, c, "the same copy must always crackle the same");
5673    }
5674
5675    #[test]
5676    fn rejected_deck_step_keeps_last_valid_motion_without_panicking() {
5677        let mut dsp = simulation_dsp();
5678        seed_deck_rates(&mut dsp, 0.42, 0.37, 12.0);
5679        dsp.hand_contact = true;
5680        dsp.grip = 1.0;
5681        dsp.motor_rate = 1.0;
5682        dsp.output_sample_rate = f64::NAN;
5683
5684        let rate = dsp.advance_deck_mechanics(-1.0);
5685
5686        assert!((rate - 0.37).abs() < 1.0e-12);
5687        assert!((dsp.rate - 0.37).abs() < 1.0e-12);
5688        assert!((dsp.motor_delivered_rate - 0.42).abs() < 1.0e-12);
5689        assert!((dsp.platter_rotation_turns - 12.0).abs() < 1.0e-12);
5690        assert_eq!(dsp.deck_recovery_count(), 1);
5691        let diagnostic = dsp
5692            .deck_recovery_diagnostic()
5693            .expect("a rejected step must retain its exact diagnostic");
5694        assert_eq!(diagnostic.count, 1);
5695        assert_eq!(
5696            diagnostic.operation,
5697            DeckRecoveryOperation::MechanicalAdvance
5698        );
5699        assert_eq!(diagnostic.error, DeckMechanicalError::InvalidDuration);
5700        assert_eq!(diagnostic.requested_hand_rate, -1.0);
5701        assert!((diagnostic.platter_rate_before - 0.42).abs() < 1.0e-12);
5702        assert!((diagnostic.record_rate_before - 0.37).abs() < 1.0e-12);
5703        assert_eq!(diagnostic.platter_turns_before, 12.0);
5704        assert_eq!(diagnostic.record_turns_before, 12.0);
5705    }
5706
5707    #[test]
5708    fn nominal_playback_remains_valid_after_many_record_turns() {
5709        let mut dsp = simulation_dsp();
5710        seed_deck_rates(&mut dsp, 1.0, 1.0, 2_000.0);
5711        dsp.hand_contact = false;
5712        dsp.motor_rate = 1.0;
5713
5714        for _ in 0..48_000 {
5715            assert_eq!(dsp.advance_deck_mechanics(0.0), 1.0);
5716        }
5717
5718        assert_eq!(dsp.deck_recovery_count(), 0);
5719        assert!(dsp.platter_rotation_turns > 2_000.5);
5720    }
5721
5722    #[test]
5723    fn stopped_contact_from_build_23_diagnostic_never_recovers() {
5724        let mut dsp = simulation_dsp();
5725        let turns = 61.250_890_548_885_84;
5726        let residual_rate = -2.246_824_675_286_976e-23;
5727        seed_deck_rates(&mut dsp, residual_rate, residual_rate, turns);
5728        dsp.position = 3_714_943.0;
5729        dsp.target_position = 3_714_943.0;
5730        dsp.hand_contact = true;
5731        dsp.grip = 0.988_256_371_542_977_2;
5732        dsp.grip_target = dsp.grip;
5733        dsp.motor_rate = 0.0;
5734        dsp.active = true;
5735
5736        assert_eq!(dsp.render(12_000, 2), 12_000);
5737
5738        assert_eq!(dsp.deck_recovery_count(), 0);
5739        assert!(dsp.deck_recovery_diagnostic().is_none());
5740        assert_eq!(dsp.effective_rate(), 0.0);
5741    }
5742
5743    fn scratch_signal_dsp(preset: ScratchPreset, rate: f64) -> ScratchAcousticDsp {
5744        let mut dsp = ScratchAcousticDsp::new_internal(48_000.0, AcousticConfig::default());
5745        dsp.source_sample_rate = 48_000.0;
5746        dsp.channels = Arc::new(vec![vec![0.5_f32; 48_000]]);
5747        dsp.window_start = 0;
5748        dsp.window_end = 48_000;
5749        dsp.total_frames = 48_000;
5750        dsp.set_effects(false, false);
5751        dsp.set_scratch_preset(preset.as_str()).unwrap();
5752        dsp.start();
5753        dsp.set_position(24_000.0, 0.0);
5754        dsp.set_transport(true, 0.0, rate, 1.0);
5755        dsp.set_motion(24_000.0, rate, 0.0);
5756        dsp.grip = 1.0;
5757        seed_deck_rates(&mut dsp, rate, rate, 0.0);
5758        dsp
5759    }
5760
5761    #[test]
5762    fn native_host_uses_the_shared_transport_and_renderer() {
5763        let mut dsp = ScratchAcousticDsp::new_native(48_000.0, AcousticConfig::default()).unwrap();
5764        let source = vec![0.25_f32; 48_000];
5765
5766        dsp.replace_window_native(&[source.as_slice()], 48_000.0, Some(24_000.0))
5767            .unwrap();
5768        dsp.set_effects(false, false);
5769        dsp.start();
5770        dsp.set_transport(false, 1.0, 0.0, 0.0);
5771        let mut rendered_programme = false;
5772        for _ in 0..32 {
5773            assert_eq!(dsp.render(512, 1), 512);
5774            rendered_programme |= dsp.rendered_samples().iter().any(|sample| *sample != 0.0);
5775        }
5776
5777        assert_eq!(dsp.rendered_samples().len(), 512);
5778        assert!(rendered_programme);
5779        assert!(dsp.position() > 0.0);
5780        assert!(dsp.platter_rotation_turns() > 0.0);
5781    }
5782
5783    #[test]
5784    fn prepared_window_reuses_rust_channel_allocations() {
5785        const WINDOW_FRAMES: usize = 48_000 * 6;
5786        let mut dsp = ScratchAcousticDsp::new_internal(48_000.0, AcousticConfig::default());
5787        dsp.prepare_window(2, WINDOW_FRAMES as u32).unwrap();
5788        let first_pointers = [dsp.channels[0].as_ptr(), dsp.channels[1].as_ptr()];
5789        Arc::make_mut(&mut dsp.channels)[0][17] = 0.25;
5790        Arc::make_mut(&mut dsp.channels)[1][17] = -0.25;
5791        dsp.commit_window(48_000.0, 500, 2_000_000, Some(144_000.0))
5792            .unwrap();
5793
5794        assert_eq!(dsp.window_start, 500);
5795        assert_eq!(dsp.window_end, 500 + WINDOW_FRAMES);
5796        assert_eq!(dsp.total_frames, 2_000_000);
5797        assert_eq!(dsp.position, 144_000.0);
5798
5799        dsp.prepare_window(2, WINDOW_FRAMES as u32).unwrap();
5800        assert_eq!(dsp.channels[0].as_ptr(), first_pointers[0]);
5801        assert_eq!(dsp.channels[1].as_ptr(), first_pointers[1]);
5802        assert_eq!(dsp.channels[0][17], 0.25);
5803        assert_eq!(dsp.channels[1][17], -0.25);
5804    }
5805
5806    #[test]
5807    fn six_second_window_prefetch_is_bounded_without_high_rate_request_churn() {
5808        const WINDOW_FRAMES: usize = 48_000 * 6;
5809        const WINDOW_START: usize = 1_000_000;
5810        let half_window = WINDOW_FRAMES as f64 / 2.0;
5811        let runway = WINDOW_FRAMES as f64 / 6.0;
5812
5813        for rate in [8.0, 10.0, 16.0, -8.0, -10.0, -16.0] {
5814            let mut dsp = ScratchAcousticDsp::new_internal(48_000.0, AcousticConfig::default());
5815            dsp.source_sample_rate = 48_000.0;
5816            dsp.channels = Arc::new(vec![vec![0.0; WINDOW_FRAMES]]);
5817            dsp.window_start = WINDOW_START;
5818            dsp.window_end = WINDOW_START + WINDOW_FRAMES;
5819            dsp.total_frames = 8_000_000;
5820            dsp.position = WINDOW_START as f64 + half_window;
5821            dsp.last_effective_rate = rate;
5822            dsp.frames_since_window_request = 48_000;
5823
5824            dsp.maybe_request_window(0);
5825            assert!(
5826                dsp.requested_window_position.is_none(),
5827                "{rate}x requested immediately from the centre"
5828            );
5829
5830            dsp.position = if rate > 0.0 {
5831                dsp.window_end as f64 - runway + 1.0
5832            } else {
5833                dsp.window_start as f64 + runway - 1.0
5834            };
5835            dsp.frames_since_window_request = 48_000;
5836            dsp.maybe_request_window(0);
5837            let request = dsp
5838                .requested_window_position
5839                .take()
5840                .unwrap_or_else(|| panic!("{rate}x did not request near its travel edge"));
5841            assert!(
5842                (request - dsp.position).abs() <= runway + f64::EPSILON,
5843                "{rate}x projected beyond its bounded runway"
5844            );
5845
5846            dsp.window_start = (request - half_window).round() as usize;
5847            dsp.window_end = dsp.window_start + WINDOW_FRAMES;
5848            dsp.frames_since_window_request = 48_000;
5849            dsp.maybe_request_window(0);
5850            assert!(
5851                dsp.requested_window_position.is_none(),
5852                "{rate}x immediately churned after a centered replacement"
5853            );
5854        }
5855    }
5856
5857    #[test]
5858    fn window_prefetch_ignores_trailing_and_terminal_physical_edges() {
5859        const WINDOW_FRAMES: usize = 48_000 * 6;
5860        let mut dsp = ScratchAcousticDsp::new_internal(48_000.0, AcousticConfig::default());
5861        dsp.source_sample_rate = 48_000.0;
5862        dsp.channels = Arc::new(vec![vec![0.0; WINDOW_FRAMES]]);
5863        dsp.total_frames = 2_000_000;
5864
5865        dsp.window_start = 0;
5866        dsp.window_end = WINDOW_FRAMES;
5867        dsp.position = 1_000.0;
5868        dsp.last_effective_rate = 1.0;
5869        dsp.frames_since_window_request = 48_000;
5870        dsp.maybe_request_window(0);
5871        assert!(
5872            dsp.requested_window_position.is_none(),
5873            "forward playback churned against the start-anchored edge"
5874        );
5875
5876        dsp.window_start = dsp.total_frames - WINDOW_FRAMES;
5877        dsp.window_end = dsp.total_frames;
5878        dsp.position = dsp.window_end as f64 - 1_000.0;
5879        dsp.last_effective_rate = -1.0;
5880        dsp.frames_since_window_request = 48_000;
5881        dsp.maybe_request_window(0);
5882        assert!(
5883            dsp.requested_window_position.is_none(),
5884            "reverse playback churned against the end-anchored edge"
5885        );
5886
5887        dsp.position = dsp.window_end as f64 - 1_000.0;
5888        dsp.last_effective_rate = 1.0;
5889        dsp.frames_since_window_request = 48_000;
5890        dsp.maybe_request_window(0);
5891        assert!(
5892            dsp.requested_window_position.is_none(),
5893            "forward playback requested beyond the physical programme end"
5894        );
5895
5896        dsp.window_start = 0;
5897        dsp.window_end = WINDOW_FRAMES;
5898        dsp.position = 1_000.0;
5899        dsp.last_effective_rate = -1.0;
5900        dsp.frames_since_window_request = 48_000;
5901        dsp.maybe_request_window(0);
5902        assert!(
5903            dsp.requested_window_position.is_none(),
5904            "reverse playback requested before the physical programme start"
5905        );
5906    }
5907
5908    fn output_rms(dsp: &ScratchAcousticDsp) -> f64 {
5909        (dsp.output
5910            .iter()
5911            .map(|sample| f64::from(*sample).powi(2))
5912            .sum::<f64>()
5913            / dsp.output.len().max(1) as f64)
5914            .sqrt()
5915    }
5916
5917    fn rms(samples: &[f64]) -> f64 {
5918        (samples.iter().map(|sample| sample * sample).sum::<f64>() / samples.len().max(1) as f64)
5919            .sqrt()
5920    }
5921
5922    fn second_difference_rms(samples: &[f64]) -> f64 {
5923        let differences = samples
5924            .windows(3)
5925            .map(|window| window[2] - 2.0 * window[1] + window[0])
5926            .collect::<Vec<_>>();
5927        rms(&differences)
5928    }
5929
5930    fn tone_amplitude(samples: &[f64], sample_rate: f64, frequency: f64) -> f64 {
5931        let (sine, cosine) = samples.iter().enumerate().fold(
5932            (0.0, 0.0),
5933            |(sine_sum, cosine_sum), (index, sample)| {
5934                let phase = std::f64::consts::TAU * frequency * index as f64 / sample_rate;
5935                (
5936                    sine_sum + sample * phase.sin(),
5937                    cosine_sum + sample * phase.cos(),
5938                )
5939            },
5940        );
5941        2.0 * sine.hypot(cosine) / samples.len().max(1) as f64
5942    }
5943
5944    fn limit_mono(samples: &[f64], strength: f64) -> (Vec<f64>, f64) {
5945        let mut limiter = HighFrequencyAccelerationLimiter::default();
5946        let mut minimum_gain = 1.0_f64;
5947        let output = samples
5948            .iter()
5949            .map(|sample| {
5950                let output = limiter.process_frame([*sample, 0.0], 1, 48_000.0, strength)[0];
5951                minimum_gain = minimum_gain.min(limiter.linked_gain);
5952                output
5953            })
5954            .collect();
5955        (output, minimum_gain)
5956    }
5957
5958    // Mirrors the worklet's exact message sequence for a canvas scratch:
5959    // play (motor 1×), settle, hand grab, drag backwards at −1× with motion
5960    // updates every 16 ms. The rendered groove must follow the hand.
5961    #[test]
5962    fn hand_drag_backwards_overrides_the_motor() {
5963        let mut dsp = simulation_dsp();
5964        dsp.start();
5965        dsp.set_position(2_400_000.0, 0.0);
5966        dsp.set_transport(false, 1.0, 0.0, 0.0);
5967        for _ in 0..375 {
5968            dsp.render(128, 2); // 1 s: motor reaches nominal speed
5969        }
5970        assert!(
5971            dsp.last_effective_rate > 0.9,
5972            "motor should be at speed, got {}",
5973            dsp.last_effective_rate
5974        );
5975        let grab_position = dsp.position;
5976        dsp.set_transport(true, 1.0, 0.0, 1.0);
5977        let mut hand_position = grab_position;
5978        let mut min_rate = f64::MAX;
5979        for step in 0..60 {
5980            hand_position -= 768.0; // −1× for 16 ms
5981            dsp.set_transport(true, 1.0, -1.0, 1.0);
5982            dsp.set_motion(hand_position, -1.0, 0.0);
5983            for _ in 0..6 {
5984                dsp.render(128, 2);
5985            }
5986            if step >= 30 {
5987                min_rate = min_rate.min(dsp.last_effective_rate);
5988            }
5989        }
5990        assert!(
5991            dsp.last_effective_rate < -0.7,
5992            "hand should own the record after ~1 s of dragging, got rate {}",
5993            dsp.last_effective_rate
5994        );
5995        assert!(
5996            dsp.position < grab_position,
5997            "groove should have moved backwards: grab {} now {}",
5998            grab_position,
5999            dsp.position
6000        );
6001        let _ = min_rate;
6002    }
6003
6004    #[test]
6005    fn deliberate_grab_reaches_platter_ownership_without_a_hundred_ms_lag() {
6006        let mut dsp = simulation_dsp();
6007        dsp.start();
6008        dsp.set_position(2_400_000.0, 0.0);
6009        dsp.set_transport(false, 1.0, 0.0, 0.0);
6010        dsp.render(48_000, 1);
6011        dsp.set_transport(true, 1.0, -1.0, 1.0);
6012        dsp.set_motion(dsp.position - 960.0, -1.0, 0.0);
6013        dsp.render(960, 1);
6014        assert!(dsp.grip > 0.80, "20 ms grab grip was {}", dsp.grip);
6015    }
6016
6017    #[test]
6018    fn motor_start_grab_and_release_are_directionally_symmetric() {
6019        let mut traces = Vec::new();
6020        for direction in [-1.0, 1.0] {
6021            let mut dsp = simulation_dsp();
6022            dsp.set_effects(false, false);
6023            dsp.start();
6024            dsp.set_position(2_400_000.0, 0.0);
6025            let initial_turns = dsp.platter_rotation_turns;
6026            dsp.set_transport(false, direction, 0.0, 0.0);
6027            dsp.render(9_600, 1);
6028            let spinup_rate = dsp.last_effective_rate;
6029            let spinup_turns = dsp.platter_rotation_turns - initial_turns;
6030            assert_eq!(spinup_rate.signum(), direction);
6031            assert!(
6032                (0.99..1.015).contains(&spinup_rate.abs()),
6033                "200 ms startup rate was {spinup_rate}",
6034            );
6035            assert_eq!(spinup_turns.signum(), direction);
6036
6037            dsp.render(38_400, 1);
6038            let steady_rate = dsp.last_effective_rate;
6039            assert_eq!(steady_rate.signum(), direction);
6040            assert!(steady_rate.abs() > 0.94);
6041
6042            let grab_position = dsp.position;
6043            dsp.set_transport(true, direction, 0.0, 1.0);
6044            dsp.set_motion(grab_position, 0.0, 0.0);
6045            dsp.render(2_400, 1);
6046            let grabbed_rate = dsp.last_effective_rate;
6047            assert!(dsp.grip > 0.98);
6048            assert!(
6049                grabbed_rate.abs() < steady_rate.abs() * 0.20,
6050                "50 ms full grab retained rate {grabbed_rate}",
6051            );
6052
6053            dsp.set_transport(false, direction, 0.0, 0.0);
6054            dsp.render(4_800, 1);
6055            let caught_rate = dsp.last_effective_rate;
6056            assert_eq!(caught_rate.signum(), direction);
6057            assert!(
6058                caught_rate.abs() > 0.75,
6059                "100 ms motor recovery reached only {caught_rate}",
6060            );
6061            traces.push((
6062                spinup_rate,
6063                spinup_turns,
6064                steady_rate,
6065                grabbed_rate,
6066                caught_rate,
6067            ));
6068        }
6069
6070        let reverse = traces[0];
6071        let forward = traces[1];
6072        for (reverse_value, forward_value) in [
6073            (reverse.0, forward.0),
6074            (reverse.1, forward.1),
6075            (reverse.2, forward.2),
6076            (reverse.3, forward.3),
6077            (reverse.4, forward.4),
6078        ] {
6079            assert!(
6080                (reverse_value + forward_value).abs() < 1e-10,
6081                "directional mechanics differed: reverse {reverse_value}, forward {forward_value}",
6082            );
6083        }
6084    }
6085
6086    #[test]
6087    fn less_slip_catches_the_powered_platter_sooner() {
6088        fn rate_after_release(response: f64) -> f64 {
6089            let mut dsp = simulation_dsp();
6090            dsp.set_effects(false, false);
6091            dsp.set_slipmat_response(response).unwrap();
6092            dsp.start();
6093            dsp.set_position(2_400_000.0, 0.0);
6094            dsp.set_transport(false, 1.0, 0.0, 0.0);
6095            dsp.render(48_000, 1);
6096            dsp.set_transport(true, 1.0, -1.0, 1.0);
6097            dsp.set_motion(dsp.position - 9_600.0, -1.0, 0.0);
6098            dsp.render(9_600, 1);
6099            dsp.set_transport(false, 1.0, 0.0, 0.0);
6100            dsp.render(2_400, 1);
6101            dsp.last_effective_rate
6102        }
6103
6104        // Zero slip is the tightest mat, so it catches soonest.
6105        let tight = rate_after_release(0.0);
6106        let loose = rate_after_release(1.0);
6107        assert!(
6108            tight > loose + 0.20,
6109            "no-slip {tight} did not clear full-slip {loose}",
6110        );
6111    }
6112
6113    #[test]
6114    fn powered_start_uses_a_high_torque_ramp_before_servo_capture() {
6115        let mut dsp = simulation_dsp();
6116        dsp.set_effects(false, false);
6117        dsp.start();
6118        dsp.set_position(2_400_000.0, 0.0);
6119        dsp.set_transport(false, 1.0, 0.0, 0.0);
6120        let mut rates = Vec::new();
6121        for _ in 0..4 {
6122            dsp.render(2_400, 1);
6123            rates.push(dsp.last_effective_rate);
6124        }
6125        assert!((0.22..0.34).contains(&rates[0]), "50 ms: {}", rates[0]);
6126        assert!((0.48..0.64).contains(&rates[1]), "100 ms: {}", rates[1]);
6127        assert!((0.75..0.91).contains(&rates[2]), "150 ms: {}", rates[2]);
6128        assert!((0.99..1.015).contains(&rates[3]), "200 ms: {}", rates[3]);
6129        let first_increment = rates[1] - rates[0];
6130        let second_increment = rates[2] - rates[1];
6131        assert!((first_increment - second_increment).abs() < 0.04);
6132    }
6133
6134    #[test]
6135    fn partial_pressure_changes_takeover_acceleration() {
6136        fn rate_after_grab(grip: f64) -> f64 {
6137            let mut dsp = simulation_dsp();
6138            dsp.set_effects(false, false);
6139            dsp.start();
6140            dsp.set_position(2_400_000.0, 0.0);
6141            dsp.set_transport(false, 1.0, 0.0, 0.0);
6142            dsp.render(48_000, 1);
6143            dsp.set_transport(true, 1.0, -1.0, grip);
6144            dsp.set_motion(dsp.position - 2_400.0, -1.0, 0.0);
6145            dsp.render(2_400, 1);
6146            dsp.last_effective_rate
6147        }
6148
6149        // A single fingertip (0.45) bears ~4 N and is still mid-takeover
6150        // at the window's edge; a full-grip hand bears 40 N and has long
6151        // since reversed the record and matched the stroke.
6152        let partial = rate_after_grab(0.45);
6153        let full = rate_after_grab(1.0);
6154        assert!(partial > -0.55, "partial pressure reached {partial}");
6155        assert!(full < -0.75, "full pressure reached {full}");
6156        assert!(partial - full > 0.3);
6157    }
6158
6159    #[test]
6160    fn commanded_grip_controls_slipmat_coupling() {
6161        fn drag_with_grip(grip: f64) -> ScratchAcousticDsp {
6162            let mut dsp = simulation_dsp();
6163            dsp.start();
6164            dsp.set_position(2_400_000.0, 0.0);
6165            dsp.set_transport(false, 1.0, 0.0, 0.0);
6166            dsp.render(48_000, 1);
6167            let mut hand_position = dsp.position;
6168            for _ in 0..40 {
6169                hand_position -= 768.0;
6170                dsp.set_transport(true, 1.0, -1.0, grip);
6171                dsp.set_motion(hand_position, -1.0, 0.0);
6172                dsp.render(768, 1);
6173            }
6174            dsp
6175        }
6176
6177        let light = drag_with_grip(0.2);
6178        let firm = drag_with_grip(1.0);
6179        assert!(
6180            light.last_effective_rate > 0.25,
6181            "light contact should let the powered platter slip forward, got {}",
6182            light.last_effective_rate,
6183        );
6184        assert!(
6185            firm.last_effective_rate < -0.65,
6186            "firm contact should reverse the record, got {}",
6187            firm.last_effective_rate,
6188        );
6189        assert!((light.grip_target - 0.2).abs() < f64::EPSILON);
6190        assert!((firm.grip_target - 1.0).abs() < f64::EPSILON);
6191    }
6192
6193    #[test]
6194    fn hand_rate_uses_only_the_stop_deadzone_and_safety_limit() {
6195        let dsp = simulation_dsp();
6196        assert_eq!(dsp.map_rate(DEADZONE_RATE * 0.5), 0.0);
6197        assert_eq!(dsp.map_rate(1.0), 1.0);
6198        assert_eq!(dsp.map_rate(-1.0), -1.0);
6199        assert_eq!(dsp.map_rate(0.70), 0.70);
6200        assert_eq!(dsp.map_rate(-0.70), -0.70);
6201        assert_eq!(dsp.map_rate(100.0), dsp.config.max_rate);
6202    }
6203
6204    /// A hand turning the record at exactly nominal speed, sampled at the
6205    /// pointer rate, has to turn it steadily: the read advances the same
6206    /// amount in every block and never stands still. Before the target was
6207    /// dead-reckoned between samples the record stopped and lurched at sixty
6208    /// hertz — over a sixteen-millisecond period the smallest block advanced
6209    /// under a tenth of the largest.
6210    #[test]
6211    fn a_steady_hand_sampled_at_sixty_hertz_turns_the_record_steadily() {
6212        for updates_per_second in [30.0_f64, 60.0, 120.0] {
6213            let mut dsp = simulation_dsp();
6214            dsp.set_effects(false, false);
6215            dsp.start();
6216            let start = 2_400_000.0;
6217            dsp.set_position(start, 0.0);
6218            dsp.set_transport(true, 0.0, 1.0, 1.0);
6219            dsp.set_motion(start, 1.0, 0.22);
6220            dsp.grip = 1.0;
6221            seed_deck_rates(&mut dsp, 1.0, 1.0, 0.0);
6222            let period = (48_000.0 / updates_per_second).round() as usize;
6223            let block = 128;
6224            let mut elapsed = 0usize;
6225            let mut advances = Vec::new();
6226            let mut worst_error = 0.0_f64;
6227            // Two seconds: the first half settles the grab, the second is
6228            // measured.
6229            while elapsed < 96_000 {
6230                dsp.set_motion(start + elapsed as f64, 1.0, 0.0);
6231                let mut within = 0usize;
6232                while within < period {
6233                    let before = dsp.position;
6234                    dsp.render(block as u32, 1);
6235                    within += block;
6236                    elapsed += block;
6237                    if elapsed >= 48_000 {
6238                        advances.push(dsp.position - before);
6239                        worst_error = worst_error.max((dsp.position - (start + elapsed as f64)).abs());
6240                    }
6241                }
6242            }
6243            let smallest = advances.iter().cloned().fold(f64::INFINITY, f64::min);
6244            let largest = advances.iter().cloned().fold(0.0, f64::max);
6245            assert!(
6246                smallest > largest * 0.8,
6247                "{updates_per_second} Hz: the record stopped and lurched — blocks advanced between {smallest:.1} and {largest:.1} frames",
6248            );
6249            assert!(
6250                worst_error < 48.0,
6251                "{updates_per_second} Hz: the read fell {worst_error:.1} frames from the hand",
6252            );
6253        }
6254    }
6255
6256    /// A hand that changes speed — a stroke that swings a quarter turn
6257    /// either way at half a hertz — is followed by the record within a
6258    /// millisecond at full grip. This is a probe as much as a test: the
6259    /// message says how far the read fell behind the hand.
6260    #[test]
6261    fn a_stroking_hand_sampled_at_sixty_hertz_is_followed_without_a_lurch() {
6262        // A lazy quarter-turn swing at half a hertz, and a tenth-of-a-turn
6263        // flick at two hertz — a scratch stroke.
6264        //
6265        // These budgets are the loose servo's measured standing, not a
6266        // target, and they are wider than the tight physical seed's: the
6267        // seed held the swing inside a quarter of a millisecond and the
6268        // flick inside one, where the loose pair reads 0.37 ms and 3.1 ms.
6269        // The seed was reverted anyway, because it lost in the hand on the
6270        // phone — the deck ships the pair chosen by ear, so these are the
6271        // numbers that can be regressed against.
6272        //
6273        // What the test actually pins is the dead-reckoned target. A frozen
6274        // one put the record seven milliseconds — 336 frames — behind a
6275        // stroking hand and lurched every sixteen, and no amount of servo
6276        // stiffness fixes that.
6277        for (turns, hertz, within_frames) in [(0.25, 0.5, 24.0), (0.1, 2.0, 168.0)] {
6278            stroke_is_followed(turns, hertz, within_frames);
6279        }
6280    }
6281
6282    fn stroke_is_followed(turns: f64, hertz: f64, within_frames: f64) {
6283        let mut dsp = simulation_dsp();
6284        dsp.set_effects(false, false);
6285        dsp.start();
6286        let start = 2_400_000.0;
6287        let frames_per_turn = 48_000.0 * 60.0 / 45.0;
6288        let amplitude = turns * frames_per_turn;
6289        let omega = 2.0 * std::f64::consts::PI * hertz;
6290        let p = |t: f64| amplitude * (omega * t).sin();
6291        let r = |t: f64| amplitude * omega * (omega * t).cos() / 48_000.0;
6292        dsp.set_position(start, 0.0);
6293        dsp.set_transport(true, 0.0, r(0.0), 1.0);
6294        dsp.set_motion(start + p(0.0), r(0.0), 0.22);
6295        dsp.grip = 1.0;
6296        let period = 800usize;
6297        let block = 128usize;
6298        let mut elapsed = 0usize;
6299        let mut worst = 0.0_f64;
6300        let mut worst_at = 0.0;
6301        let mut trace = String::new();
6302        while elapsed < 4 * 48_000 {
6303            let t = elapsed as f64 / 48_000.0;
6304            dsp.set_motion(start + p(t), r(t), 0.0);
6305            let mut within = 0usize;
6306            while within < period {
6307                dsp.render(block as u32, 1);
6308                within += block;
6309                elapsed += block;
6310                let now = elapsed as f64 / 48_000.0;
6311                if now > 1.0 {
6312                    let signed = dsp.position - (start + p(now));
6313                    let error = signed.abs();
6314                    if error > worst { worst = error; worst_at = now; }
6315                    // A row every fifty milliseconds over one stroke: the
6316                    // signed error against the hand's rate and acceleration,
6317                    // so a lag can be read as viscous, inertial or constant.
6318                    if now > 2.30 && now <= 2.80 && elapsed % 480 == 0 {
6319                        trace.push_str(&format!(
6320                            "\n  t {now:.3}  err {:+7.2} ms  hand {:+.3}  record {:+.3}  platter {:+.3}  target {:+.3}",
6321                            signed / 48.0, r(now), dsp.rate, dsp.motor_delivered_rate, dsp.target_rate
6322                        ));
6323                    }
6324                }
6325            }
6326        }
6327        assert!(
6328            worst < within_frames,
6329            "{turns} turn at {hertz} Hz: the read fell {worst:.1} frames ({:.2} ms) behind the hand at {worst_at:.3} s{trace}",
6330            worst / 48.0,
6331        );
6332    }
6333
6334    #[test]
6335    fn signed_unpowered_throw_coasts_while_explicit_motor_stop_brakes() {
6336        let mut traces = Vec::new();
6337        for direction in [-1.0, 1.0] {
6338            let mut thrown = simulation_dsp();
6339            thrown.set_effects(false, false);
6340            thrown.start();
6341            thrown.set_position(2_400_000.0, 0.0);
6342            thrown.set_transport(true, 0.0, direction, 1.0);
6343            thrown.set_motion(thrown.position + direction * 24_000.0, direction, 0.0);
6344            thrown.grip = 1.0;
6345            seed_deck_rates(&mut thrown, direction, direction, 0.0);
6346            let throw_turns = thrown.platter_rotation_turns;
6347            thrown.set_transport(false, 0.0, 0.0, 0.0);
6348            thrown.render(9_600, 1);
6349            let coast_rate = thrown.last_effective_rate;
6350            let coast_turns = thrown.platter_rotation_turns - throw_turns;
6351            assert_eq!(coast_rate.signum(), direction);
6352            assert!(
6353                coast_rate.abs() > 0.55,
6354                "{direction} bearing throw lost momentum too quickly: {coast_rate}",
6355            );
6356            assert_eq!(coast_turns.signum(), direction);
6357
6358            let mut braked = simulation_dsp();
6359            braked.set_effects(false, false);
6360            braked.start();
6361            braked.set_position(2_400_000.0, 0.0);
6362            braked.set_transport(false, direction, 0.0, 0.0);
6363            braked.render(48_000, 1);
6364            braked.set_transport(false, 0.0, 0.0, 0.0);
6365            braked.render(19_200, 1);
6366            let brake_rate = braked.last_effective_rate;
6367            assert!(
6368                brake_rate.abs() < 0.05,
6369                "{direction} powered brake retained rate {brake_rate}",
6370            );
6371            traces.push((coast_rate, coast_turns, brake_rate));
6372        }
6373
6374        let reverse = traces[0];
6375        let forward = traces[1];
6376        for (reverse_value, forward_value) in [
6377            (reverse.0, forward.0),
6378            (reverse.1, forward.1),
6379            (reverse.2, forward.2),
6380        ] {
6381            assert!(
6382                (reverse_value + forward_value).abs() < 1e-10,
6383                "directional mechanics differed: reverse {reverse_value}, forward {forward_value}",
6384            );
6385        }
6386    }
6387
6388    #[test]
6389    fn wow_phase_follows_the_configured_physical_revolution() {
6390        let mut dsp = simulation_dsp();
6391        dsp.set_native_rpm(45.0).unwrap();
6392        let frames_per_revolution = (dsp.source_sample_rate * 60.0 / 45.0).round() as usize;
6393        for _ in 0..frames_per_revolution {
6394            dsp.advance_wow_flutter(1.0, 1.0, 1.0);
6395        }
6396        assert!((dsp.wow_phase - 1.0).abs() < 1e-9);
6397    }
6398
6399    #[test]
6400    fn residual_wow_flutter_is_subtle_and_hand_slip_can_increase_it() {
6401        fn peak_modulation(dsp: &mut ScratchAcousticDsp, rate: f64) -> f64 {
6402            let mut peak = 0.0_f64;
6403            for _ in 0..96_000 {
6404                peak = peak.max(dsp.advance_wow_flutter(rate, 1.0, rate.abs()).abs());
6405            }
6406            peak
6407        }
6408
6409        let mut free = simulation_dsp();
6410        free.hand_contact = false;
6411        free.motor_delivered_rate = 1.0;
6412        let free_peak = peak_modulation(&mut free, 1.0);
6413        assert!((0.000_20..0.000_31).contains(&free_peak), "{free_peak}");
6414
6415        let mut slipping = simulation_dsp();
6416        slipping.hand_contact = true;
6417        slipping.grip = 1.0;
6418        slipping.motor_delivered_rate = 2.0;
6419        let slip_peak = peak_modulation(&mut slipping, 1.0);
6420        assert!(slip_peak > free_peak * 1.5, "{free_peak} -> {slip_peak}");
6421        assert!(slip_peak < 0.000_55, "{slip_peak}");
6422    }
6423
6424    #[test]
6425    fn needle_interaction_texture_is_quiet_at_one_x_and_rises_during_drag() {
6426        let one_x_contact = compute_contact_noise_gain(1.0);
6427        let slow_contact = compute_contact_noise_gain(0.20);
6428        let one_x_texture = compute_source_texture_gain(1.0, 0.0);
6429        let slow_drag_texture = compute_source_texture_gain(0.20, 0.04);
6430        assert!(slow_contact > one_x_contact * 8.0);
6431        assert!(slow_drag_texture > one_x_texture * 4.0);
6432        assert!(one_x_contact < 2.0e-6);
6433        assert!(one_x_texture < 2.0e-5);
6434    }
6435
6436    #[test]
6437    fn surface_only_render_spins_platter_without_advancing_or_leaking_programme() {
6438        let mut dsp = scratch_signal_dsp(ScratchPreset::Baby, 0.0);
6439        dsp.set_transport(false, 1.0, 0.0, 0.0);
6440        let programme_position = dsp.position;
6441        dsp.render_surface(48_000, 1);
6442        assert_eq!(dsp.position, programme_position);
6443        assert!(dsp.last_effective_rate > 0.9);
6444        assert_eq!(output_rms(&dsp), 0.0);
6445    }
6446
6447    #[test]
6448    fn platter_rotation_telemetry_integrates_rendered_rate() {
6449        let mut dsp = simulation_dsp();
6450        dsp.set_native_rpm(45.0).unwrap();
6451        dsp.set_effects(false, false);
6452        dsp.hand_contact = false;
6453        dsp.motor_rate = 1.0;
6454        seed_deck_rates(&mut dsp, 1.0, 1.0, 0.0);
6455        dsp.render_surface(48_000, 1);
6456        assert!(
6457            (dsp.platter_rotation_turns() - 0.75).abs() < 1e-6,
6458            "integrated {} turns",
6459            dsp.platter_rotation_turns(),
6460        );
6461    }
6462
6463    #[test]
6464    fn movement_gain_reaches_silence_continuously_at_rest() {
6465        assert_eq!(compute_movement_gain(0.0, false, false), 0.0);
6466        assert!(compute_movement_gain(DEADZONE_RATE * 0.5, false, false) > 0.0);
6467        assert!(
6468            compute_movement_gain(DEADZONE_RATE, false, false)
6469                > compute_movement_gain(DEADZONE_RATE * 0.5, false, false)
6470        );
6471        assert_eq!(compute_movement_gain(STOP_GAIN_FULL_RATE, false, false), 1.0);
6472    }
6473
6474    /// Steady-state gain of the tilt at DC and at Nyquist, by driving it.
6475    fn tilt_gain(rate: f64, alternating: bool) -> f64 {
6476        let mut tilt = RiaaSpeedTilt::new(48_000.0);
6477        tilt.set_rate(rate);
6478        let mut last = 0.0;
6479        for n in 0..400_000 {
6480            let input = if alternating && n % 2 == 1 { -1.0 } else { 1.0 };
6481            last = tilt.process(0, input) * input;
6482        }
6483        last
6484    }
6485
6486    /// A lifted stylus reads nothing, so the programme position holds while
6487    /// the platter keeps turning underneath it.
6488    #[test]
6489    fn lifted_needle_holds_the_programme_position() {
6490        let mut dsp = scratch_signal_dsp(ScratchPreset::Baby, 0.0);
6491        dsp.set_transport(false, 1.0, 0.0, 0.0);
6492        seed_deck_rates(&mut dsp, 1.0, 1.0, 0.0);
6493        dsp.render(480, 1);
6494        let playing_from = dsp.position;
6495        dsp.render(4_800, 1);
6496        assert!(
6497            dsp.position > playing_from,
6498            "a tracking stylus must advance the programme"
6499        );
6500
6501        dsp.set_needle_lifted(true);
6502        let lifted_at = dsp.position;
6503        let turns_at = dsp.platter_rotation_turns();
6504        dsp.render(48_000, 1);
6505        assert_eq!(
6506            dsp.position, lifted_at,
6507            "a lifted stylus advanced the programme it is not touching"
6508        );
6509        assert!(
6510            dsp.platter_rotation_turns() > turns_at + 0.5,
6511            "the platter should keep turning under a lifted stylus"
6512        );
6513
6514        // Dropped back down, it reads on from where it was left.
6515        dsp.set_needle_lifted(false);
6516        dsp.render(4_800, 1);
6517        assert!(dsp.position > lifted_at, "the stylus did not resume reading");
6518    }
6519
6520    /// Magnitude of the analog RIAA playback curve `D` at an angular
6521    /// frequency: `(1 + s*T2) / ((1 + s*T1)(1 + s*T3))`.
6522    fn riaa_playback_magnitude(angular_frequency: f64) -> f64 {
6523        let term = |time_constant: f64| {
6524            (1.0 + (angular_frequency * time_constant).powi(2)).sqrt()
6525        };
6526        term(RIAA_T2_SECONDS) / (term(RIAA_T1_SECONDS) * term(RIAA_T3_SECONDS))
6527    }
6528
6529    /// Magnitude of the built filter at a digital frequency, straight from the
6530    /// coefficients: `|b0 + b1 e^-jw| / |1 + a1 e^-jw|` per section.
6531    fn tilt_magnitude(tilt: &RiaaSpeedTilt, frequency_hz: f64) -> f64 {
6532        let omega = std::f64::consts::TAU * frequency_hz / 48_000.0;
6533        let cos_omega = omega.cos();
6534        tilt.sections.iter().fold(1.0, |gain, (b0, b1, a1)| {
6535            let numerator = (b0 * b0 + b1 * b1 + 2.0 * b0 * b1 * cos_omega).sqrt();
6536            let denominator = (1.0 + a1 * a1 + 2.0 * a1 * cos_omega).sqrt();
6537            gain * numerator / denominator
6538        })
6539    }
6540
6541    /// The coefficients are checked against the analog prototype they claim to
6542    /// be, not merely against themselves. A bilinear transform maps the
6543    /// digital frequency `f` to the analog frequency `2*fs*tan(pi*f/fs)`, so
6544    /// the built filter must match `|D(w)/D(w/r)|` evaluated there exactly.
6545    ///
6546    /// Harvested from `physical/riaa.rs` before that module was removed: it
6547    /// used the same unprewarped bilinear form (`scale = 2*sample_rate`, and
6548    /// `b0/b1/a1` in this same layout), so this pins the house convention
6549    /// rather than leaving the tilt to vouch for itself.
6550    #[test]
6551    fn riaa_speed_tilt_matches_the_analog_curve_it_claims_to_be() {
6552        for rate in [0.25, 0.5, 2.0, 4.0] {
6553            let mut tilt = RiaaSpeedTilt::new(48_000.0);
6554            tilt.set_rate(rate);
6555            for frequency in [20.0, 50.0, 100.0, 200.0, 500.0, 1_000.0, 2_000.0, 5_000.0] {
6556                // The bilinear frequency mapping, applied honestly rather than
6557                // assuming the analog and digital axes coincide.
6558                let analog = 2.0
6559                    * 48_000.0
6560                    * (std::f64::consts::PI * frequency / 48_000.0).tan();
6561                let expected = riaa_playback_magnitude(analog)
6562                    / riaa_playback_magnitude(analog / rate);
6563                let measured = tilt_magnitude(&tilt, frequency);
6564                assert!(
6565                    (measured - expected).abs() < 1.0e-9,
6566                    "rate {rate} at {frequency} Hz: built {measured}, analog curve {expected}"
6567                );
6568            }
6569        }
6570    }
6571
6572    /// The whole justification for the tilt: at nominal speed the cut
6573    /// pre-emphasis and the preamp de-emphasis cancel exactly, so a settled
6574    /// filter is bit-exact and the transparent-master rule holds.
6575    #[test]
6576    fn riaa_speed_tilt_is_exactly_unity_at_nominal_speed() {
6577        let mut tilt = RiaaSpeedTilt::new(48_000.0);
6578        tilt.set_rate(1.0);
6579        let mut phase = 0.0_f64;
6580        for _ in 0..10_000 {
6581            phase += 0.1;
6582            let input = phase.sin() * 0.7;
6583            assert_eq!(
6584                tilt.process(0, input),
6585                input,
6586                "nominal speed must pass the programme through untouched"
6587            );
6588        }
6589    }
6590
6591    /// Off speed the two curves no longer cancel. DC is untouched (both
6592    /// curves are flat there), while the top end scales as 1/rate — which is
6593    /// what pairs with the cartridge's own rate gain.
6594    #[test]
6595    fn riaa_speed_tilt_shapes_only_off_speed_content() {
6596        assert!((tilt_gain(1.0, false) - 1.0).abs() < 1.0e-9);
6597        assert!((tilt_gain(1.0, true) - 1.0).abs() < 1.0e-9);
6598        for rate in [0.25, 0.5, 2.0, 4.0] {
6599            assert!(
6600                (tilt_gain(rate, false) - 1.0).abs() < 1.0e-6,
6601                "rate {rate} shifted DC, but both curves are flat there"
6602            );
6603            assert!(
6604                (tilt_gain(rate, true) - 1.0 / rate).abs() < 1.0e-6,
6605                "rate {rate} did not scale the top end as 1/rate"
6606            );
6607        }
6608    }
6609
6610    /// The pair is the point: velocity gain scales everything by rate and the
6611    /// tilt takes the top back by 1/rate, so a slow stroke reads thin and
6612    /// quiet while a fast one reads loud and full.
6613    #[test]
6614    fn cartridge_and_tilt_together_leave_presence_and_scale_body() {
6615        for rate in [0.25, 0.5, 2.0] {
6616            let velocity = compute_movement_gain(rate, false, true);
6617            let body = velocity * tilt_gain(rate, false);
6618            let presence = velocity * tilt_gain(rate, true);
6619            assert!(
6620                (body - rate).abs() < 1.0e-6,
6621                "body at rate {rate} should scale with speed"
6622            );
6623            assert!(
6624                (presence - 1.0).abs() < 1.0e-6,
6625                "presence at rate {rate} should survive the speed change"
6626            );
6627        }
6628    }
6629
6630    /// Both opt-in voicing stages must be neutral out of the box, or the
6631    /// transparent-master rule is broken by default.
6632    #[test]
6633    fn default_config_leaves_voicing_transparent() {
6634        let config = AcousticConfig::default();
6635        assert_eq!(config.riaa_voicing_rate, 1.0);
6636        assert_eq!(config.vinyl_voicing, 0.0);
6637    }
6638
6639    /// The fixed-rate voicing is the same filter as the speed tilt, so at a
6640    /// rate above nominal it holds DC flat and softens the top by `1/rate` —
6641    /// the only way a matched RIAA pair can colour anything.
6642    #[test]
6643    fn riaa_voicing_matches_the_fixed_riaa_curve() {
6644        assert!((tilt_gain(1.3, false) - 1.0).abs() < 1.0e-6);
6645        assert!((tilt_gain(1.3, true) - 1.0 / 1.3).abs() < 1.0e-6);
6646        // And the default rate is the identity, not merely close to it.
6647        assert!((tilt_gain(1.0, true) - 1.0).abs() < 1.0e-9);
6648    }
6649
6650    #[test]
6651    fn riaa_voicing_setter_validates_and_round_trips() {
6652        let mut dsp = simulation_dsp();
6653        assert_eq!(dsp.riaa_voicing(), 1.0);
6654        dsp.set_riaa_voicing(1.25).unwrap();
6655        assert_eq!(dsp.riaa_voicing(), 1.25);
6656        // The rejection predicate is checked directly: building the
6657        // wasm-bindgen error is not possible off the wasm target.
6658        assert!(!valid_riaa_voicing_rate(0.0));
6659        assert!(!valid_riaa_voicing_rate(-1.0));
6660        assert!(!valid_riaa_voicing_rate(f64::NAN));
6661        assert!(valid_riaa_voicing_rate(0.5));
6662    }
6663
6664    /// Steady-state gain of the seed curve, measured by driving a sine and
6665    /// comparing RMS in and out. Phase is irrelevant to a magnitude read.
6666    fn voicing_gain(filter: &mut VinylVoicingFilter, frequency_hz: f64, amount: f64) -> f64 {
6667        let sample_rate = 48_000.0;
6668        let omega = std::f64::consts::TAU * frequency_hz / sample_rate;
6669        let period = (sample_rate / frequency_hz).max(1.0) as usize;
6670        let settle = period * 40;
6671        let measure = period * 200;
6672        let mut phase = 0.0_f64;
6673        let mut input_energy = 0.0_f64;
6674        let mut output_energy = 0.0_f64;
6675        for n in 0..(settle + measure) {
6676            phase += omega;
6677            let input = phase.sin();
6678            let output = filter.process(0, input, amount);
6679            if n >= settle {
6680                input_energy += input * input;
6681                output_energy += output * output;
6682            }
6683        }
6684        (output_energy / input_energy).sqrt()
6685    }
6686
6687    /// The seed curve must actually colour the programme: a real lift in the
6688    /// body and a real dulling of the top, not a shelf so small the ear
6689    /// cannot tell it moved. Measured on the pray4me reference, the old
6690    /// ±1.5 dB seed was under 1 dB of change on the track and read as no
6691    /// effect; this pins a clearly audible tilt.
6692    #[test]
6693    fn vinyl_voicing_seed_curve_adds_body_and_softens_the_top() {
6694        let mut filter = VinylVoicingFilter::new(48_000.0);
6695        let body = voicing_gain(&mut filter, 60.0, 1.0);
6696        let upper_mid = voicing_gain(&mut filter, 1_000.0, 1.0);
6697        let top = voicing_gain(&mut filter, 15_000.0, 1.0);
6698        let body_db = 20.0 * body.log10();
6699        let mid_db = 20.0 * upper_mid.log10();
6700        let top_db = 20.0 * top.log10();
6701        assert!(
6702            (4.0..=10.0).contains(&body_db),
6703            "voicing body {body_db} dB is not a usable lift"
6704        );
6705        assert!(
6706            (-24.0..=-8.0).contains(&top_db),
6707            "voicing top {top_db} dB is not a usable dulling"
6708        );
6709        assert!(
6710            mid_db.abs() < 1.5,
6711            "voicing moved the midrange {mid_db} dB; it should leave it alone"
6712        );
6713    }
6714
6715    /// `amount == 0` must be bit-exact, not approximately transparent, for
6716    /// every curve.
6717    #[test]
6718    fn vinyl_voicing_is_bit_exact_at_zero() {
6719        for curve in 0..VINYL_VOICING_CURVES.len() {
6720            let mut filter = VinylVoicingFilter::new(48_000.0);
6721            filter.set_curve(curve);
6722            let mut phase = 0.0_f64;
6723            for _ in 0..10_000 {
6724                phase += 0.13;
6725                let input = (phase.sin() * 0.8).clamp(-1.0, 1.0);
6726                assert_eq!(filter.process(0, input, 0.0), input);
6727                assert_eq!(filter.process(1, input, 0.0), input);
6728            }
6729        }
6730    }
6731
6732    #[test]
6733    fn vinyl_voicing_setter_validates_and_round_trips() {
6734        let mut dsp = simulation_dsp();
6735        assert_eq!(dsp.vinyl_voicing(), 0.0);
6736        dsp.set_vinyl_voicing(0.5).unwrap();
6737        assert_eq!(dsp.vinyl_voicing(), 0.5);
6738        assert!(!valid_unit_interval(1.5));
6739        assert!(!valid_unit_interval(-0.1));
6740        assert!(!valid_unit_interval(f64::NAN));
6741        assert!(valid_unit_interval(0.5));
6742    }
6743
6744    /// Every curve is a usable tilt: body up, top down, the mids alone.
6745    #[test]
6746    fn every_voicing_curve_tilts_body_up_and_top_down() {
6747        for curve in 0..VINYL_VOICING_CURVES.len() {
6748            let mut filter = VinylVoicingFilter::new(48_000.0);
6749            filter.set_curve(curve);
6750            let body = 20.0 * voicing_gain(&mut filter, 60.0, 1.0).log10();
6751            let mid = 20.0 * voicing_gain(&mut filter, 1_000.0, 1.0).log10();
6752            let top = 20.0 * voicing_gain(&mut filter, 15_000.0, 1.0).log10();
6753            assert!(body > 0.5, "curve {curve} body {body} dB");
6754            assert!(top < -1.0, "curve {curve} top {top} dB");
6755            assert!(body - top > 4.0, "curve {curve} tilt {} dB too small", body - top);
6756            assert!(mid.abs() < 3.0, "curve {curve} mid {mid} dB");
6757        }
6758    }
6759
6760    /// The curves are genuinely different shapes, not one shape at three
6761    /// amounts: tip mass costs the top and leaves the body, curve drift is
6762    /// the mildest, and coil load sits between them at the very top.
6763    #[test]
6764    fn voicing_curves_are_distinct_shapes() {
6765        let mut coil = VinylVoicingFilter::new(48_000.0);
6766        coil.set_curve(0);
6767        let mut tip = VinylVoicingFilter::new(48_000.0);
6768        tip.set_curve(1);
6769        let mut drift = VinylVoicingFilter::new(48_000.0);
6770        drift.set_curve(2);
6771        let coil_top = voicing_gain(&mut coil, 15_000.0, 1.0);
6772        let tip_top = voicing_gain(&mut tip, 15_000.0, 1.0);
6773        let drift_top = voicing_gain(&mut drift, 15_000.0, 1.0);
6774        assert!(
6775            tip_top < coil_top,
6776            "tip mass {tip_top} should dull more than coil load {coil_top}"
6777        );
6778        assert!(
6779            drift_top > tip_top,
6780            "curve drift {drift_top} should dull less than tip mass {tip_top}"
6781        );
6782        // Tip mass is the top-only shape: its body lift is the smallest.
6783        let tip_body = voicing_gain(&mut tip, 60.0, 1.0);
6784        let coil_body = voicing_gain(&mut coil, 60.0, 1.0);
6785        assert!(
6786            tip_body < coil_body,
6787            "tip mass body {tip_body} should sit under coil load {coil_body}"
6788        );
6789    }
6790
6791    #[test]
6792    fn vinyl_voicing_curve_setter_round_trips() {
6793        let mut dsp = simulation_dsp();
6794        assert_eq!(dsp.vinyl_voicing_curve(), 0);
6795        for curve in 0..VINYL_VOICING_CURVES.len() as u32 {
6796            dsp.set_vinyl_voicing_curve(curve).unwrap();
6797            assert_eq!(dsp.vinyl_voicing_curve(), curve);
6798        }
6799        assert!(VINYL_VOICING_CURVES.len() >= 3);
6800    }
6801
6802    /// The stage really is in the programme path, not just unit-tested. A
6803    /// settled constant programme is the low shelf's easiest target: with the
6804    /// seed curve off it renders the source sample unchanged, and with it on
6805    /// it comes out lifted but bounded.
6806    #[test]
6807    fn vinyl_voicing_reaches_the_rendered_programme() {
6808        fn render_settled_dc(voicing: f64) -> f32 {
6809            let mut config = AcousticConfig::default();
6810            config.vinyl_voicing = voicing;
6811            let mut dsp = ScratchAcousticDsp::new_internal(48_000.0, config);
6812            dsp.source_sample_rate = 48_000.0;
6813            dsp.channels = Arc::new(vec![vec![0.5_f32; 96_000]]);
6814            dsp.window_start = 0;
6815            dsp.window_end = 96_000;
6816            dsp.total_frames = 96_000;
6817            dsp.set_effects(false, false);
6818            dsp.start();
6819            dsp.set_transport(false, 1.0, 0.0, 0.0);
6820            dsp.render(48_000, 1);
6821            dsp.render(512, 1);
6822            *dsp.rendered_samples().last().unwrap()
6823        }
6824
6825        let dry = render_settled_dc(0.0);
6826        let voiced = render_settled_dc(1.0);
6827        assert!(
6828            (dry - 0.5).abs() < 1.0e-4,
6829            "the default path must pass the source through, got {dry}"
6830        );
6831        assert!(
6832            voiced > dry + 1.0e-3,
6833            "the voicing stage did not reach the programme: {dry} vs {voiced}"
6834        );
6835        assert!(
6836            voiced < 0.5 * 2.5,
6837            "the voicing lifted a settled programme past its seed bound: {voiced}"
6838        );
6839    }
6840
6841    /// The surface gains are trims: each is exactly one by default, so the
6842    /// historical path is bit for bit.
6843    #[test]
6844    fn surface_gains_default_to_unity() {
6845        let config = AcousticConfig::default();
6846        for gain in [
6847            config.contact_gain,
6848            config.dust_gain,
6849            config.impulse_gain,
6850            config.wear_gain,
6851            config.source_texture_gain,
6852        ] {
6853            assert_eq!(gain, 1.0);
6854        }
6855    }
6856
6857    #[test]
6858    fn surface_gain_setters_round_trip() {
6859        let mut dsp = simulation_dsp();
6860        dsp.set_contact_gain(2.0).unwrap();
6861        assert_eq!(dsp.contact_gain(), 2.0);
6862        dsp.set_dust_gain(0.5).unwrap();
6863        assert_eq!(dsp.dust_gain(), 0.5);
6864        dsp.set_impulse_gain(3.0).unwrap();
6865        assert_eq!(dsp.impulse_gain(), 3.0);
6866        dsp.set_wear_gain(0.0).unwrap();
6867        assert_eq!(dsp.wear_gain(), 0.0);
6868        dsp.set_source_texture_gain(4.0).unwrap();
6869        assert_eq!(dsp.source_texture_gain(), 4.0);
6870        // The rejection predicate is checked directly: building the
6871        // wasm-bindgen error is not possible off the wasm target.
6872        assert!(!valid_texture_scale(-0.1));
6873        assert!(!valid_texture_scale(4.1));
6874        assert!(!valid_texture_scale(f64::NAN));
6875        assert!(valid_texture_scale(1.0));
6876    }
6877
6878    /// The surface bed is stylus output, so a character colours it with the
6879    /// same curve as the music. A needle-drop impulse over a silent programme
6880    /// leaves only the bed to hear.
6881    #[test]
6882    fn voicing_colours_the_surface_bed() {
6883        fn render_impulse(voicing: f64) -> f32 {
6884            let mut config = AcousticConfig::default();
6885            config.surface_enabled = true;
6886            config.vinyl_voicing = voicing;
6887            let mut dsp = ScratchAcousticDsp::new_internal(48_000.0, config);
6888            dsp.source_sample_rate = 48_000.0;
6889            dsp.channels = Arc::new(vec![vec![0.0_f32; 4_800]]);
6890            dsp.window_start = 0;
6891            dsp.window_end = 4_800;
6892            dsp.total_frames = 4_800;
6893            dsp.start();
6894            dsp.set_transport(false, 1.0, 0.0, 0.0);
6895            dsp.contact_impulse = 1.0;
6896            dsp.render(8, 1);
6897            *dsp.rendered_samples().last().unwrap()
6898        }
6899
6900        let dry = render_impulse(0.0);
6901        let wet = render_impulse(1.0);
6902        assert!(dry != 0.0, "no surface impulse rendered to colour");
6903        assert!(
6904            dry != wet,
6905            "voicing did not reach the surface bed: {dry} vs {wet}"
6906        );
6907    }
6908
6909    /// A cartridge is a velocity transducer: output rides the rate, exactly
6910    /// 1.0 at nominal speed and continuously silent at rest.
6911    #[test]
6912    fn cartridge_velocity_gain_is_linear_in_rate() {
6913        assert_eq!(compute_movement_gain(0.0, false, true), 0.0);
6914        assert_eq!(compute_movement_gain(1.0, false, true), 1.0);
6915        for rate in [0.02, 0.1, 0.25, 0.5, 1.0, 2.0, 3.0] {
6916            assert!(
6917                (compute_movement_gain(rate, false, true) - rate).abs() < 1.0e-12,
6918                "rate {rate} did not read back as its own gain"
6919            );
6920        }
6921        // Bounded, so a runaway rate cannot blow up the programme.
6922        assert_eq!(
6923            compute_movement_gain(50.0, false, true),
6924            MAX_CARTRIDGE_VELOCITY_GAIN
6925        );
6926    }
6927
6928    /// The velocity law needs no stop knee: it is already continuous to
6929    /// silence, so nothing has to gate the programme off at rest.
6930    #[test]
6931    fn cartridge_velocity_gain_needs_no_stop_knee() {
6932        let mut previous = 0.0;
6933        for step in 0..64 {
6934            let rate = f64::from(step) / 64.0 * STOP_GAIN_FULL_RATE * 2.0;
6935            let gain = compute_movement_gain(rate, false, true);
6936            assert!(gain >= previous, "gain went backwards at rate {rate}");
6937            assert!(gain - previous < 0.01, "gain stepped at rate {rate}");
6938            previous = gain;
6939        }
6940    }
6941
6942    #[test]
6943    fn movement_gain_stays_bounded() {
6944        for rate in [0.01, 0.1, 1.0, 3.0, 10.0] {
6945            let gain = compute_movement_gain(rate, true, false);
6946            assert!((0.0..=1.08).contains(&gain));
6947            let velocity = compute_movement_gain(rate, true, true);
6948            assert!((0.0..=MAX_CARTRIDGE_VELOCITY_GAIN * 1.08).contains(&velocity));
6949        }
6950        assert_eq!(compute_movement_gain(1.0, true, false), 1.0);
6951        assert_eq!(compute_movement_gain(1.0, true, true), 1.0);
6952    }
6953
6954    #[test]
6955    fn default_moving_playback_has_no_unmeasured_speed_gain() {
6956        // With the cartridge law off, the dry path is flat above the knee:
6957        // no invented speed colour, which is what this has always pinned.
6958        for rate in [0.1, 0.5, 1.0, 2.0, 8.0] {
6959            assert_eq!(compute_movement_gain(rate, false, false), 1.0);
6960        }
6961    }
6962
6963    #[test]
6964    fn default_rapid_reversal_has_no_stop_deadzone_click() {
6965        let mut dsp = scratch_signal_dsp(ScratchPreset::Baby, 1.0);
6966        dsp.render(512, 1);
6967        dsp.set_motion(dsp.position, -1.0, 0.0);
6968
6969        let mut prior = dsp.rendered_samples().last().copied().unwrap_or_default();
6970        let mut maximum_step = 0.0_f32;
6971        let mut crossed_zero = false;
6972        for _ in 0..4_800 {
6973            dsp.render(1, 1);
6974            let sample = dsp.rendered_samples()[0];
6975            maximum_step = maximum_step.max((sample - prior).abs());
6976            prior = sample;
6977            crossed_zero |= dsp.effective_rate() < 0.0;
6978        }
6979
6980        assert!(crossed_zero, "the test motion did not reverse the record");
6981        assert!(
6982            maximum_step < 0.01,
6983            "the stop deadzone produced a {maximum_step} full-scale sample step"
6984        );
6985    }
6986
6987    #[test]
6988    fn stylus_tracing_limit_preserves_the_existing_curvature_velocity_model() {
6989        let base_alpha = 0.90;
6990        assert_eq!(stylus_tracing_alpha(base_alpha, 3.0, 0.5, 1.0), base_alpha,);
6991        assert_eq!(stylus_tracing_alpha(base_alpha, 3.0, 4.0, 0.0), base_alpha,);
6992        let moderate = stylus_tracing_alpha(base_alpha, 1.0, 2.0, 0.72);
6993        let demanding = stylus_tracing_alpha(base_alpha, 3.0, 4.0, 0.72);
6994        assert!((0.0..base_alpha).contains(&moderate));
6995        assert!((0.0..moderate).contains(&demanding));
6996    }
6997
6998    #[test]
6999    fn limiter_defaults_serde_names_and_strengths_are_distinct_and_validated() {
7000        let defaults = AcousticConfig::default();
7001        assert!(!defaults.acoustic_enabled);
7002        assert!(!defaults.surface_enabled);
7003        assert_eq!(defaults.stylus_tracing_limit, 0.0);
7004        assert_eq!(defaults.high_frequency_acceleration_limit, 0.0);
7005
7006        let decoded: AcousticConfig = serde_json::from_value(serde_json::json!({
7007            "stylusTracingLimit": 0.44,
7008            "highFrequencyAccelerationLimit": 0.66
7009        }))
7010        .unwrap();
7011        assert_eq!(decoded.stylus_tracing_limit, 0.44);
7012        assert_eq!(decoded.high_frequency_acceleration_limit, 0.66);
7013
7014        let mut dsp = simulation_dsp();
7015        dsp.set_stylus_tracing_limit(0.25).unwrap();
7016        assert_eq!(dsp.stylus_tracing_limit(), 0.25);
7017        dsp.set_high_frequency_acceleration_limit(0.0).unwrap();
7018        assert_eq!(dsp.high_frequency_acceleration_limit(), 0.0);
7019        dsp.set_high_frequency_acceleration_limit(1.0).unwrap();
7020        assert_eq!(dsp.high_frequency_acceleration_limit(), 1.0);
7021        assert!(!valid_unit_interval(-0.01));
7022        assert!(!valid_unit_interval(f64::NAN));
7023        assert!(!valid_unit_interval(1.1));
7024    }
7025
7026    #[test]
7027    fn default_nominal_playback_preserves_aligned_pcm_samples_exactly() {
7028        const START: usize = 64;
7029        const FRAMES: usize = 256;
7030        let left = (0..1_024)
7031            .map(|frame| ((frame as i32 % 97) - 48) as f32 / 64.0)
7032            .collect::<Vec<_>>();
7033        let right = (0..1_024)
7034            .map(|frame| ((frame as i32 % 83) - 41) as f32 / 64.0)
7035            .collect::<Vec<_>>();
7036        let mut dsp = ScratchAcousticDsp::new_internal(48_000.0, AcousticConfig::default());
7037        dsp.replace_window_owned_native(
7038            vec![left.clone(), right.clone()],
7039            48_000.0,
7040            Some(START as f64),
7041        )
7042        .unwrap();
7043        dsp.active = true;
7044        dsp.hand_contact = false;
7045        dsp.grip = 0.0;
7046        dsp.grip_target = 0.0;
7047        dsp.motor_rate = 1.0;
7048        seed_deck_rates(&mut dsp, 1.0, 1.0, 0.0);
7049
7050        assert_eq!(dsp.render(FRAMES as u32, 2), FRAMES as u32);
7051
7052        let expected = (START..START + FRAMES)
7053            .flat_map(|frame| [left[frame], right[frame]])
7054            .collect::<Vec<_>>();
7055        assert_eq!(dsp.rendered_samples(), expected);
7056        assert_eq!(dsp.position(), (START + FRAMES) as f64);
7057        assert_eq!(dsp.effective_rate(), 1.0);
7058    }
7059
7060    #[test]
7061    fn high_frequency_acceleration_limit_zero_is_an_exact_bypass() {
7062        let samples = (0..8_192)
7063            .map(|index| {
7064                let time = index as f64 / 48_000.0;
7065                0.31 * (std::f64::consts::TAU * 437.0 * time).sin()
7066                    + 0.47 * (std::f64::consts::TAU * 11_300.0 * time).sin()
7067            })
7068            .collect::<Vec<_>>();
7069        let (output, minimum_gain) = limit_mono(&samples, 0.0);
7070        assert_eq!(output, samples);
7071        assert_eq!(minimum_gain, 1.0);
7072    }
7073
7074    #[test]
7075    fn high_frequency_acceleration_limit_preserves_low_frequency_programme() {
7076        let samples = (0..12_000)
7077            .map(|index| 0.65 * (std::f64::consts::TAU * 440.0 * index as f64 / 48_000.0).sin())
7078            .collect::<Vec<_>>();
7079        let (output, minimum_gain) = limit_mono(&samples, 1.0);
7080        let error = output
7081            .iter()
7082            .zip(samples.iter())
7083            .map(|(output, input)| (output - input).abs())
7084            .fold(0.0_f64, f64::max);
7085        assert!(error < 1e-10, "low-frequency peak error was {error}");
7086        assert_eq!(minimum_gain, 1.0);
7087    }
7088
7089    #[test]
7090    fn high_frequency_acceleration_limit_keeps_benign_brightness() {
7091        let samples = (0..12_000)
7092            .map(|index| 0.12 * (std::f64::consts::TAU * 7_000.0 * index as f64 / 48_000.0).sin())
7093            .collect::<Vec<_>>();
7094        let (output, minimum_gain) = limit_mono(&samples, 1.0);
7095        let input_rms = rms(&samples[1_024..]);
7096        let output_rms = rms(&output[1_024..]);
7097        assert!(
7098            output_rms > input_rms * 0.96,
7099            "benign HF changed from {input_rms} to {output_rms}"
7100        );
7101        assert!(minimum_gain > 0.94, "benign HF gain reached {minimum_gain}");
7102    }
7103
7104    #[test]
7105    fn high_frequency_acceleration_limit_reduces_harsh_burst_without_full_band_collapse() {
7106        let samples = (0..9_600)
7107            .map(|index| {
7108                let time = index as f64 / 48_000.0;
7109                let low = 0.34 * (std::f64::consts::TAU * 440.0 * time).sin();
7110                let high = if (2_400..7_200).contains(&index) {
7111                    0.50 * (std::f64::consts::TAU * 11_000.0 * time).sin()
7112                } else {
7113                    0.0
7114                };
7115                low + high
7116            })
7117            .collect::<Vec<_>>();
7118        let (default_output, default_minimum_gain) = limit_mono(&samples, 0.35);
7119        let (output, minimum_gain) = limit_mono(&samples, 1.0);
7120        let analysis = 3_000..6_600;
7121        let input_burst = &samples[analysis.clone()];
7122        let default_burst = &default_output[analysis.clone()];
7123        let output_burst = &output[analysis];
7124        let input_acceleration = second_difference_rms(input_burst);
7125        let default_acceleration = second_difference_rms(default_burst);
7126        let output_acceleration = second_difference_rms(output_burst);
7127        assert!(
7128            default_acceleration < input_acceleration * 0.90,
7129            "default burst acceleration {input_acceleration} -> {default_acceleration}"
7130        );
7131        assert!(
7132            default_minimum_gain < 0.88,
7133            "default harsh-burst gain only reached {default_minimum_gain}"
7134        );
7135        assert!(
7136            output_acceleration < input_acceleration * 0.72,
7137            "burst acceleration {input_acceleration} -> {output_acceleration}"
7138        );
7139        assert!(
7140            rms(output_burst) > rms(input_burst) * 0.50,
7141            "programme RMS collapsed from {} to {}",
7142            rms(input_burst),
7143            rms(output_burst),
7144        );
7145        let input_low = tone_amplitude(input_burst, 48_000.0, 440.0);
7146        let output_low = tone_amplitude(output_burst, 48_000.0, 440.0);
7147        assert!(
7148            output_low > input_low * 0.97,
7149            "440 Hz component collapsed from {input_low} to {output_low}"
7150        );
7151        assert!(
7152            minimum_gain < 0.65,
7153            "harsh burst only reached {minimum_gain}"
7154        );
7155    }
7156
7157    #[test]
7158    fn high_frequency_acceleration_limit_is_bounded_and_stereo_linked() {
7159        let mut stereo = HighFrequencyAccelerationLimiter::default();
7160        let mut right_only = HighFrequencyAccelerationLimiter::default();
7161        let mut stereo_right = Vec::new();
7162        let mut solo_right = Vec::new();
7163        let mut minimum_gain = 1.0_f64;
7164        for index in 0..7_200 {
7165            let time = index as f64 / 48_000.0;
7166            let left = if index % 2 == 0 { 0.72 } else { -0.72 };
7167            let right = 0.12 * (std::f64::consts::TAU * 7_000.0 * time).sin();
7168            let linked = stereo.process_frame([left, right], 2, 48_000.0, 1.0);
7169            let solo = right_only.process_frame([right, 0.0], 1, 48_000.0, 1.0);
7170            minimum_gain = minimum_gain.min(stereo.linked_gain);
7171            assert!((PROGRAMME_LIMITER_MIN_UPPER_GAIN..=1.0).contains(&stereo.linked_gain));
7172            assert!(linked.into_iter().all(f64::is_finite));
7173            stereo_right.push(linked[1]);
7174            solo_right.push(solo[0]);
7175        }
7176        assert!(minimum_gain < 0.40);
7177        assert!(
7178            rms(&stereo_right[1_024..]) < rms(&solo_right[1_024..]) * 0.70,
7179            "linked right RMS {} vs solo {}",
7180            rms(&stereo_right[1_024..]),
7181            rms(&solo_right[1_024..]),
7182        );
7183    }
7184
7185    #[test]
7186    fn high_frequency_acceleration_limiter_releases_transparently() {
7187        let mut limiter = HighFrequencyAccelerationLimiter::default();
7188        for index in 0..2_400 {
7189            let sample = if index % 2 == 0 { 0.8 } else { -0.8 };
7190            limiter.process_frame([sample, 0.0], 1, 48_000.0, 1.0);
7191        }
7192        assert!(limiter.linked_gain < 0.40);
7193        for _ in 0..9_600 {
7194            limiter.process_frame([0.0, 0.0], 1, 48_000.0, 1.0);
7195        }
7196        assert!(
7197            limiter.linked_gain > 0.99,
7198            "release ended at {}",
7199            limiter.linked_gain,
7200        );
7201    }
7202
7203    #[test]
7204    fn surface_only_render_bypasses_programme_acceleration_limiter() {
7205        let mut bypass = simulation_dsp();
7206        let mut limited = simulation_dsp();
7207        let surface = (0..48_000)
7208            .map(|index| {
7209                (0.2 * (std::f64::consts::TAU * 8_000.0 * index as f64 / 48_000.0).sin()) as f32
7210            })
7211            .collect::<Vec<_>>();
7212        bypass.surface_asset = Arc::new(vec![surface.clone(), surface.clone()]);
7213        limited.surface_asset = Arc::new(vec![surface.clone(), surface]);
7214        bypass.set_high_frequency_acceleration_limit(0.0).unwrap();
7215        limited.set_high_frequency_acceleration_limit(1.0).unwrap();
7216        bypass.trigger_needle_drop();
7217        limited.trigger_needle_drop();
7218        bypass.render_surface(4_096, 2);
7219        limited.render_surface(4_096, 2);
7220        assert_eq!(bypass.output, limited.output);
7221        assert_eq!(
7222            bypass.high_frequency_acceleration_limiter,
7223            limited.high_frequency_acceleration_limiter,
7224        );
7225    }
7226
7227    #[test]
7228    fn manual_fader_defaults_to_an_exact_noop_and_validates_range() {
7229        let mut dsp = simulation_dsp();
7230        assert_eq!(dsp.manual_fader_gain(), 1.0);
7231        dsp.output = vec![0.8, -0.4, 0.25, -1.0];
7232        let unchanged = dsp.output.clone();
7233        dsp.scratch_gate_trace = vec![1.0, 1.0];
7234        dsp.apply_crossfader_trace(2, 2);
7235        assert_eq!(dsp.output, unchanged);
7236
7237        dsp.output.clone_from(&unchanged);
7238        dsp.scratch_gate_trace = vec![0.25, 0.5];
7239        dsp.set_manual_fader_gain(0.4).unwrap();
7240        dsp.apply_crossfader_trace(2, 2);
7241        let mut expected = unchanged;
7242        for frame in 0..2 {
7243            for channel in 0..2 {
7244                expected[frame * 2 + channel] *= 0.4;
7245            }
7246        }
7247        assert_eq!(dsp.output, expected);
7248        assert_eq!(dsp.manual_fader_gain(), 0.4);
7249        assert!(!valid_unit_interval(-0.01));
7250        assert!(!valid_unit_interval(f64::INFINITY));
7251        assert!(!valid_unit_interval(1.01));
7252    }
7253
7254    #[test]
7255    fn manual_crossfader_uses_the_shared_sharp_rust_curve() {
7256        let mut dsp = simulation_dsp();
7257        for (position, expected) in [(0.0, 0.0), (0.04, 0.5), (0.08, 1.0), (0.5, 1.0)] {
7258            dsp.set_manual_crossfader(position).unwrap();
7259            assert!(
7260                (dsp.manual_fader_gain() - expected).abs() < 1e-6,
7261                "position {position} produced {}",
7262                dsp.manual_fader_gain(),
7263            );
7264        }
7265        assert_eq!(
7266            crate::PlayerConfig::default().sharp_crossfader_width,
7267            DEFAULT_SHARP_CROSSFADER_WIDTH,
7268        );
7269    }
7270
7271    #[test]
7272    fn automatic_preset_owns_the_real_fader_while_baby_uses_manual_control() {
7273        let mut baby = simulation_dsp();
7274        baby.output = vec![0.8, -0.4];
7275        baby.scratch_gate_trace = vec![1.0];
7276        baby.set_manual_fader_gain(0.0).unwrap();
7277        baby.apply_crossfader_trace(1, 2);
7278        assert_eq!(baby.output, vec![0.0, -0.0]);
7279
7280        let mut automatic = simulation_dsp();
7281        automatic.set_scratch_preset("stab").unwrap();
7282        automatic.output = vec![0.8, -0.4];
7283        automatic.scratch_gate_trace = vec![0.25];
7284        automatic.set_manual_fader_gain(0.0).unwrap();
7285        automatic.apply_crossfader_trace(1, 2);
7286        assert_eq!(automatic.output, vec![0.2, -0.1]);
7287    }
7288
7289    #[test]
7290    fn held_momentary_crossfader_overrides_every_selected_technique() {
7291        let mut close = simulation_dsp();
7292        close.set_scratch_preset("stab").unwrap();
7293        close.set_momentary_crossfader_override(true, false);
7294        close.output = vec![1.0; 2_048];
7295        close.scratch_gate_trace = vec![1.0; 1_024];
7296        close.apply_crossfader_trace(1_024, 2);
7297        assert!(close.audible_crossfader_gain() < 1.0e-12);
7298        assert!(close.output[2_046].abs() < 1.0e-12);
7299
7300        let mut open = simulation_dsp();
7301        open.set_scratch_preset("crab").unwrap();
7302        open.set_momentary_crossfader_override(true, true);
7303        open.output = vec![1.0; 2_048];
7304        open.scratch_gate_trace = vec![0.0; 1_024];
7305        open.apply_crossfader_trace(1_024, 2);
7306        assert!(1.0 - open.audible_crossfader_gain() < 1.0e-12);
7307        assert!(1.0 - open.output[2_046] < 1.0e-12);
7308
7309        open.set_momentary_crossfader_override(false, false);
7310        open.output.fill(1.0);
7311        open.scratch_gate_trace.fill(0.0);
7312        open.apply_crossfader_trace(1_024, 2);
7313        assert!(open.audible_crossfader_gain() < 1.0e-12);
7314        assert!(open.output[2_046].abs() < 1.0e-12);
7315    }
7316
7317    #[test]
7318    fn clearing_media_preserves_live_platter_velocity_and_phase() {
7319        let mut dsp = simulation_dsp();
7320        dsp.motor_delivered_rate = 0.82;
7321        dsp.rate = 0.79;
7322        dsp.rate_velocity = 0.03;
7323        dsp.last_effective_rate = 0.8;
7324        dsp.platter_rotation_turns = 17.25;
7325
7326        dsp.clear_window();
7327
7328        assert_eq!(dsp.motor_delivered_rate, 0.82);
7329        assert_eq!(dsp.rate, 0.79);
7330        assert_eq!(dsp.rate_velocity, 0.03);
7331        assert_eq!(dsp.last_effective_rate, 0.8);
7332        assert_eq!(dsp.platter_rotation_turns, 17.25);
7333        assert_eq!(dsp.position, 0.0);
7334        assert_eq!(dsp.target_position, 0.0);
7335    }
7336
7337    #[test]
7338    fn programme_end_returns_the_exact_rendered_prefix_and_zeroes_the_suffix() {
7339        let mut dsp = ScratchAcousticDsp::new_internal(48_000.0, AcousticConfig::default());
7340        dsp.source_sample_rate = 48_000.0;
7341        dsp.channels = Arc::new(vec![vec![0.5_f32; 512], vec![-0.5_f32; 512]]);
7342        dsp.window_start = 0;
7343        dsp.window_end = 512;
7344        dsp.total_frames = 512;
7345        dsp.set_effects(false, false);
7346        dsp.start();
7347        dsp.set_position(472.0, 0.0);
7348        dsp.set_transport(false, 1.0, 0.0, 0.0);
7349        seed_deck_rates(&mut dsp, 1.0, 1.0, 0.0);
7350        let quantum_ramp_ms = 128.0 * 1_000.0 / dsp.output_sample_rate;
7351        dsp.set_output_gain(0.0, quantum_ramp_ms).unwrap();
7352
7353        let turns_before = dsp.platter_rotation_turns;
7354        let rendered = dsp.render(128, 2);
7355
7356        assert_eq!(rendered, 37);
7357        assert!(dsp.take_ended());
7358        assert!(!dsp.take_ended());
7359        assert_eq!(dsp.position, 509.0);
7360        assert!(dsp.output[..rendered as usize * 2]
7361            .iter()
7362            .any(|sample| *sample != 0.0));
7363        assert!(dsp.output[rendered as usize * 2..]
7364            .iter()
7365            .all(|sample| *sample == 0.0));
7366        let expected_turns = 37.0 * dsp.native_rpm / (60.0 * dsp.output_sample_rate);
7367        assert!((dsp.platter_rotation_turns - turns_before - expected_turns).abs() < 1e-12);
7368        assert_eq!(dsp.output_gain_remaining_frames, 128 - rendered as usize);
7369        assert!((dsp.output_gain_current - 91.0 / 128.0).abs() < 1e-12);
7370    }
7371
7372    #[test]
7373    fn replay_snapshot_restores_dynamic_dsp_state_without_restarting_inertia() {
7374        let mut dsp = simulation_dsp();
7375        Arc::make_mut(&mut dsp.channels)[0].fill(0.5);
7376        dsp.set_effects(false, false);
7377        dsp.start();
7378        dsp.set_position(2_400_000.0, 0.0);
7379        dsp.set_transport(false, 1.0, 0.0, 0.0);
7380        seed_deck_rates(&mut dsp, 1.0, 1.0, 12.5);
7381        dsp.manual_fader_gain = 0.73;
7382        dsp.window_miss_frames = 17;
7383        dsp.window_programme_gain = 0.42;
7384        dsp.capture_replay_state();
7385
7386        dsp.start();
7387        dsp.set_position(10.0, 0.0);
7388        dsp.set_transport(true, 0.0, -4.0, 1.0);
7389        dsp.platter_rotation_turns = -3.0;
7390        dsp.manual_fader_gain = 0.0;
7391        dsp.window_miss_frames = 0;
7392        dsp.window_programme_gain = 1.0;
7393
7394        assert!(dsp.restore_replay_state());
7395        assert!(!dsp.restore_replay_state());
7396        assert_eq!(dsp.position, 2_400_000.0);
7397        assert_eq!(dsp.motor_delivered_rate, 1.0);
7398        assert_eq!(dsp.rate, 1.0);
7399        assert_eq!(dsp.last_effective_rate, 1.0);
7400        assert_eq!(dsp.platter_rotation_turns, 12.5);
7401        assert_eq!(dsp.manual_fader_gain, 0.73);
7402        assert_eq!(dsp.window_miss_frames, 17);
7403        assert_eq!(dsp.window_programme_gain, 0.42);
7404        dsp.render(32, 2);
7405        assert!(dsp.last_effective_rate > 0.99);
7406        assert!(dsp.output.iter().any(|sample| *sample != 0.0));
7407    }
7408
7409    #[test]
7410    fn replay_restore_retains_snapshot_and_swaps_heap_storage() {
7411        let mut dsp = ScratchAcousticDsp::new_internal(48_000.0, AcousticConfig::default());
7412        dsp.drag_lowpass_state = vec![0.1, 0.2];
7413        dsp.last_output_samples = vec![0.3, 0.4];
7414        dsp.capture_replay_state();
7415
7416        let snapshot = dsp.replay_snapshot.as_ref().unwrap();
7417        let snapshot_address = (&**snapshot) as *const AcousticReplaySnapshot;
7418        let captured_drag_address = snapshot.drag_lowpass_state.as_ptr();
7419        let captured_output_address = snapshot.last_output_samples.as_ptr();
7420
7421        dsp.begin_deterministic_replay(0.0, 0.0, 1).unwrap();
7422        let replay_drag_address = dsp.drag_lowpass_state.as_ptr();
7423        let replay_output_address = dsp.last_output_samples.as_ptr();
7424        assert_ne!(captured_drag_address, replay_drag_address);
7425        assert_ne!(captured_output_address, replay_output_address);
7426
7427        assert!(dsp.restore_replay_state());
7428        assert_eq!(dsp.drag_lowpass_state, vec![0.1, 0.2]);
7429        assert_eq!(dsp.last_output_samples, vec![0.3, 0.4]);
7430        assert_eq!(dsp.drag_lowpass_state.as_ptr(), captured_drag_address);
7431        assert_eq!(dsp.last_output_samples.as_ptr(), captured_output_address);
7432
7433        let snapshot = dsp.replay_snapshot.as_ref().unwrap();
7434        assert_eq!(
7435            (&**snapshot) as *const AcousticReplaySnapshot,
7436            snapshot_address
7437        );
7438        assert_eq!(snapshot.drag_lowpass_state.as_ptr(), replay_drag_address);
7439        assert_eq!(snapshot.last_output_samples.as_ptr(), replay_output_address);
7440        assert!(!snapshot.restore_pending);
7441        assert!(!dsp.restore_replay_state());
7442
7443        dsp.capture_replay_state();
7444        let snapshot = dsp.replay_snapshot.as_ref().unwrap();
7445        assert_eq!(
7446            (&**snapshot) as *const AcousticReplaySnapshot,
7447            snapshot_address
7448        );
7449        assert!(snapshot.restore_pending);
7450    }
7451
7452    #[test]
7453    #[ignore = "manual replay-restore microbenchmark"]
7454    fn benchmark_replay_restore_without_reclamation() {
7455        const ITERATIONS: u32 = 1_000_000;
7456
7457        let mut dsp = ScratchAcousticDsp::new_internal(48_000.0, AcousticConfig::default());
7458        dsp.drag_lowpass_state = vec![0.1, 0.2];
7459        dsp.last_output_samples = vec![0.3, 0.4];
7460        dsp.capture_replay_state();
7461        dsp.begin_deterministic_replay(0.0, 0.0, 1).unwrap();
7462
7463        let started = std::time::Instant::now();
7464        for _ in 0..ITERATIONS {
7465            dsp.replay_snapshot.as_mut().unwrap().restore_pending = true;
7466            std::hint::black_box(dsp.restore_replay_state());
7467        }
7468        let nanoseconds_per_restore =
7469            started.elapsed().as_secs_f64() * 1_000_000_000.0 / f64::from(ITERATIONS);
7470        eprintln!("replay restore: {nanoseconds_per_restore:.2} ns/operation");
7471    }
7472
7473    #[test]
7474    fn deterministic_replay_initialization_resets_dynamic_state_and_restores_live_state() {
7475        let mut dsp = simulation_dsp();
7476        dsp.start();
7477        dsp.set_position(2_400_000.0, 0.0);
7478        dsp.set_transport(false, 1.0, 0.0, 0.0);
7479        dsp.motor_delivered_rate = 0.81;
7480        dsp.rate = 0.77;
7481        dsp.wow_phase = 0.63;
7482        dsp.flutter_phase = 0.42;
7483        dsp.noise_seed = 17;
7484        dsp.manual_fader_gain = 0.73;
7485        dsp.capture_replay_state();
7486
7487        dsp.set_scratch_preset("crab").unwrap();
7488        dsp.set_scratch_clicks(8);
7489        dsp.set_manual_fader_gain(0.4).unwrap();
7490        dsp.set_output_gain(0.75, 0.0).unwrap();
7491        dsp.begin_deterministic_replay(24_000.0, -2.25, 0x4d2c_6df3)
7492            .unwrap();
7493        let first = (
7494            dsp.position,
7495            dsp.wow_phase,
7496            dsp.flutter_phase,
7497            dsp.platter_rotation_turns,
7498            dsp.noise_seed,
7499            dsp.scratch_gate(),
7500            dsp.scratch_gate_phase(),
7501            dsp.scratch_direction(),
7502        );
7503        assert_eq!(dsp.scratch_preset(), "crab");
7504        assert_eq!(dsp.scratch_clicks(), 8);
7505        assert_eq!(dsp.manual_fader_gain(), 0.4);
7506        assert_eq!(dsp.output_gain_current, 0.75);
7507        assert!(dsp.drag_lowpass_state.is_empty());
7508        assert!(dsp.last_output_samples.is_empty());
7509        assert!(dsp.surface_bed.is_none());
7510        assert!(dsp.needle_thump.is_none());
7511        assert!(dsp.needle_burst.is_none());
7512
7513        dsp.set_transport(true, 0.0, -4.0, 1.0);
7514        dsp.set_motion(23_000.0, -4.0, 0.8);
7515        dsp.render(2_048, 2);
7516        assert_ne!(dsp.noise_seed, first.4);
7517        dsp.begin_deterministic_replay(24_000.0, -2.25, 0x4d2c_6df3)
7518            .unwrap();
7519        let second = (
7520            dsp.position,
7521            dsp.wow_phase,
7522            dsp.flutter_phase,
7523            dsp.platter_rotation_turns,
7524            dsp.noise_seed,
7525            dsp.scratch_gate(),
7526            dsp.scratch_gate_phase(),
7527            dsp.scratch_direction(),
7528        );
7529        assert_eq!(second, first);
7530
7531        assert!(dsp.restore_replay_state());
7532        assert_eq!(dsp.position, 2_400_000.0);
7533        assert_eq!(dsp.motor_delivered_rate, 0.81);
7534        assert_eq!(dsp.rate, 0.77);
7535        assert_eq!(dsp.wow_phase, 0.63);
7536        assert_eq!(dsp.flutter_phase, 0.42);
7537        assert_eq!(dsp.noise_seed, 17);
7538        assert_eq!(dsp.manual_fader_gain, 0.73);
7539        assert_eq!(dsp.scratch_preset(), "baby");
7540    }
7541
7542    #[test]
7543    fn output_gain_unity_preserves_normal_render_bit_for_bit() {
7544        let mut default = scratch_signal_dsp(ScratchPreset::Baby, 1.0);
7545        let mut explicit_unity = scratch_signal_dsp(ScratchPreset::Baby, 1.0);
7546        explicit_unity.set_output_gain(1.0, 12.0).unwrap();
7547        default.render(2_048, 2);
7548        explicit_unity.render(2_048, 2);
7549        assert_eq!(default.output, explicit_unity.output);
7550    }
7551
7552    #[test]
7553    fn output_gain_reaches_its_linear_ramp_target() {
7554        let mut dsp = simulation_dsp();
7555        let four_frames_ms = 4.0 * 1_000.0 / dsp.output_sample_rate;
7556        dsp.set_output_gain(0.0, four_frames_ms).unwrap();
7557        dsp.output = vec![1.0; 8];
7558        dsp.apply_output_gain(4, 2);
7559
7560        assert_eq!(dsp.output, vec![1.0, 1.0, 0.75, 0.75, 0.5, 0.5, 0.25, 0.25],);
7561        assert_eq!(dsp.output_gain_current, 0.0);
7562        assert_eq!(dsp.output_gain_target, 0.0);
7563        assert_eq!(dsp.output_gain_step, 0.0);
7564        assert_eq!(dsp.output_gain_remaining_frames, 0);
7565
7566        dsp.output = vec![1.0; 2];
7567        dsp.apply_output_gain(1, 2);
7568        assert_eq!(dsp.output, vec![0.0, 0.0]);
7569    }
7570
7571    #[test]
7572    fn replay_snapshot_restores_output_gain_mid_ramp() {
7573        let mut dsp = simulation_dsp();
7574        let four_frames_ms = 4.0 * 1_000.0 / dsp.output_sample_rate;
7575        dsp.set_output_gain(0.25, four_frames_ms).unwrap();
7576        dsp.output = vec![1.0; 2];
7577        dsp.apply_output_gain(2, 1);
7578        dsp.capture_replay_state();
7579
7580        assert_eq!(dsp.output_gain_current, 0.625);
7581        assert_eq!(dsp.output_gain_target, 0.25);
7582        assert_eq!(dsp.output_gain_step, -0.1875);
7583        assert_eq!(dsp.output_gain_remaining_frames, 2);
7584
7585        dsp.set_output_gain(2.0, 0.0).unwrap();
7586        assert!(dsp.restore_replay_state());
7587        assert_eq!(dsp.output_gain_current, 0.625);
7588        assert_eq!(dsp.output_gain_target, 0.25);
7589        assert_eq!(dsp.output_gain_step, -0.1875);
7590        assert_eq!(dsp.output_gain_remaining_frames, 2);
7591
7592        dsp.output = vec![1.0; 2];
7593        dsp.apply_output_gain(2, 1);
7594        assert_eq!(dsp.output, vec![0.625, 0.4375]);
7595        assert_eq!(dsp.output_gain_current, 0.25);
7596        assert_eq!(dsp.output_gain_remaining_frames, 0);
7597    }
7598
7599    #[test]
7600    fn manual_fader_gain_one_preserves_normal_render_bit_for_bit() {
7601        let mut default = scratch_signal_dsp(ScratchPreset::Baby, 1.0);
7602        let mut explicit_unity = scratch_signal_dsp(ScratchPreset::Baby, 1.0);
7603        explicit_unity.set_manual_fader_gain(1.0).unwrap();
7604        default.render(2_048, 2);
7605        explicit_unity.render(2_048, 2);
7606        assert_eq!(default.output, explicit_unity.output);
7607    }
7608
7609    #[test]
7610    fn window_miss_holds_position_and_resumes_with_a_bounded_fade() {
7611        let mut dsp = scratch_signal_dsp(ScratchPreset::Baby, 1.0);
7612        dsp.render(512, 1);
7613        let full_level = *dsp.output.last().unwrap();
7614        // The hand target in this fixture never advances, so the record eases
7615        // off against it and the cartridge's output eases with it. The resume
7616        // is measured against the programme level the deck's own rate implies
7617        // at that moment, not against a level captured at a faster one.
7618        let full_gain = dsp.movement_gain_state;
7619        let held_position = dsp.position;
7620
7621        dsp.render_window_missing(64, 1);
7622        let short_miss_tail = *dsp.output.last().unwrap();
7623        assert_eq!(dsp.position, held_position);
7624        assert!(short_miss_tail.abs() < full_level.abs());
7625
7626        dsp.render(128, 1);
7627        let short_resume_head = dsp.output[0];
7628        assert!((short_resume_head - short_miss_tail).abs() < 0.01);
7629        assert!(dsp.position > held_position);
7630        assert!(dsp.output[127].abs() > short_resume_head.abs());
7631
7632        let second_held_position = dsp.position;
7633        dsp.render_window_missing(512, 1);
7634        let long_miss_tail = *dsp.output.last().unwrap();
7635        assert_eq!(dsp.position, second_held_position);
7636        assert!(long_miss_tail.abs() < 1e-7);
7637
7638        dsp.render(128, 1);
7639        assert!(dsp.output[0].abs() < 1e-7);
7640        assert!(dsp.output[127].abs() > dsp.output[0].abs());
7641        assert!(dsp.position > second_held_position);
7642        dsp.render(512, 1);
7643        let recovered =
7644            f64::from(full_level.abs()) * (dsp.movement_gain_state / full_gain);
7645        assert!(f64::from((*dsp.output.last().unwrap()).abs()) > recovered * 0.95);
7646    }
7647
7648    #[test]
7649    fn manual_fader_scales_window_miss_and_surface_outputs() {
7650        let mut miss_unity = simulation_dsp();
7651        let mut miss_scaled = simulation_dsp();
7652        miss_unity.last_output_samples = vec![0.8, -0.4];
7653        miss_scaled.last_output_samples = vec![0.8, -0.4];
7654        miss_scaled.set_manual_fader_gain(0.5).unwrap();
7655        miss_unity.render_window_missing(32, 2);
7656        miss_scaled.render_window_missing(32, 2);
7657        for (unity, scaled) in miss_unity.output.iter().zip(&miss_scaled.output) {
7658            assert_eq!(*scaled, *unity * 0.5);
7659        }
7660
7661        let mut surface_unity = simulation_dsp();
7662        let mut surface_scaled = simulation_dsp();
7663        surface_scaled.set_manual_fader_gain(0.25).unwrap();
7664        surface_unity.trigger_needle_drop();
7665        surface_scaled.trigger_needle_drop();
7666        surface_unity.render_surface(4_096, 2);
7667        surface_scaled.render_surface(4_096, 2);
7668        assert!(surface_unity
7669            .output
7670            .iter()
7671            .any(|sample| sample.abs() > 1e-6));
7672        for (unity, scaled) in surface_unity.output.iter().zip(&surface_scaled.output) {
7673            assert_eq!(*scaled, *unity * 0.25);
7674        }
7675    }
7676
7677    #[test]
7678    fn output_gain_scales_window_miss_and_surface_outputs() {
7679        let mut miss_unity = simulation_dsp();
7680        let mut miss_scaled = simulation_dsp();
7681        miss_unity.last_output_samples = vec![0.8, -0.4];
7682        miss_scaled.last_output_samples = vec![0.8, -0.4];
7683        miss_scaled.set_output_gain(0.5, 0.0).unwrap();
7684        miss_unity.render_window_missing(32, 2);
7685        miss_scaled.render_window_missing(32, 2);
7686        for (unity, scaled) in miss_unity.output.iter().zip(&miss_scaled.output) {
7687            assert_eq!(*scaled, *unity * 0.5);
7688        }
7689
7690        let mut surface_unity = simulation_dsp();
7691        let mut surface_scaled = simulation_dsp();
7692        surface_scaled.set_output_gain(0.25, 0.0).unwrap();
7693        surface_unity.trigger_needle_drop();
7694        surface_scaled.trigger_needle_drop();
7695        surface_unity.render_surface(4_096, 2);
7696        surface_scaled.render_surface(4_096, 2);
7697        assert!(surface_unity
7698            .output
7699            .iter()
7700            .any(|sample| sample.abs() > 1e-6));
7701        for (unity, scaled) in surface_unity.output.iter().zip(&surface_scaled.output) {
7702            assert_eq!(*scaled, *unity * 0.25);
7703        }
7704    }
7705
7706    #[test]
7707    fn missing_surface_asset_uses_bounded_synthetic_bed_and_burst_without_panicking() {
7708        let mut bed = simulation_dsp();
7709        assert!(bed.surface_asset.is_empty());
7710        bed.start_surface_region(SURFACE_REGION_LEAD_IN, 0.20);
7711        bed.render_surface(4_096, 2);
7712        assert!(bed.output.iter().any(|sample| sample.abs() > 1e-7));
7713        assert!(bed
7714            .output
7715            .iter()
7716            .all(|sample| sample.is_finite() && (-1.0..=1.0).contains(sample)));
7717
7718        let mut drop = simulation_dsp();
7719        assert!(drop.surface_asset.is_empty());
7720        drop.trigger_needle_drop();
7721        drop.render_surface(8_192, 2);
7722        assert!(drop
7723            .output
7724            .iter()
7725            .all(|sample| sample.is_finite() && (-1.0..=1.0).contains(sample)));
7726        let after_thump = 5_280 * 2;
7727        assert!(
7728            drop.output[after_thump..]
7729                .iter()
7730                .any(|sample| sample.abs() > 1e-7),
7731            "synthetic crackle burst should outlive the 100 ms thump"
7732        );
7733    }
7734
7735    #[test]
7736    fn needle_lift_foley_remains_audible_while_programme_is_silent() {
7737        let mut lift = simulation_dsp();
7738        lift.set_needle_lifted(true);
7739        lift.trigger_needle_lift();
7740        lift.render_surface(4_096, 2);
7741        assert!(lift.output.iter().any(|sample| sample.abs() > 1e-7));
7742        assert!(lift
7743            .output
7744            .iter()
7745            .all(|sample| sample.is_finite() && (-1.0..=1.0).contains(sample)));
7746    }
7747
7748    #[test]
7749    fn disabling_surface_effects_clears_and_suppresses_all_foley() {
7750        let mut dsp = simulation_dsp();
7751        dsp.start_surface_region(SURFACE_REGION_LEAD_IN, 0.20);
7752        dsp.trigger_needle_drop();
7753        assert!(dsp.surface_bed.is_some());
7754        assert!(dsp.needle_thump.is_some());
7755        assert!(dsp.needle_burst.is_some());
7756
7757        dsp.set_effects(true, false);
7758        assert!(dsp.surface_bed.is_none());
7759        assert!(dsp.needle_thump.is_none());
7760        assert!(dsp.needle_burst.is_none());
7761        dsp.start_surface_region(SURFACE_REGION_LEAD_IN, 0.20);
7762        dsp.trigger_needle_drop();
7763        assert!(dsp.surface_bed.is_none());
7764        assert!(dsp.needle_thump.is_none());
7765        assert!(dsp.needle_burst.is_none());
7766        dsp.render_surface(4_096, 2);
7767        assert!(dsp.output.iter().all(|sample| *sample == 0.0));
7768    }
7769
7770    #[test]
7771    fn deterministic_hash_noise_is_stable() {
7772        assert_eq!(
7773            ScratchAcousticDsp::hash_noise(42, 7),
7774            ScratchAcousticDsp::hash_noise(42, 7)
7775        );
7776        assert_ne!(
7777            ScratchAcousticDsp::hash_noise(42, 7),
7778            ScratchAcousticDsp::hash_noise(43, 7)
7779        );
7780    }
7781
7782    #[test]
7783    fn scratch_gate_is_applied_to_rendered_deck_audio() {
7784        let mut baby = scratch_signal_dsp(ScratchPreset::Baby, -1.0);
7785        baby.render(512, 1);
7786        baby.render(1024, 1);
7787        let baby_rms = output_rms(&baby);
7788
7789        let mut stab = scratch_signal_dsp(ScratchPreset::Stab, -1.0);
7790        stab.render(512, 1);
7791        stab.render(1024, 1);
7792        let stab_rms = output_rms(&stab);
7793
7794        assert!(
7795            baby_rms > 0.35,
7796            "baby should pass the groove, got {baby_rms}"
7797        );
7798        assert!(
7799            stab_rms < baby_rms * 0.02,
7800            "reverse stab should cut the groove: baby={baby_rms}, stab={stab_rms}"
7801        );
7802    }
7803
7804    #[test]
7805    fn scratch_gate_reversal_commits_on_the_rendered_motion_crossing() {
7806        let mut dsp = scratch_signal_dsp(ScratchPreset::Transform, 8.0);
7807        dsp.render(512, 1);
7808        assert_eq!(dsp.scratch_direction(), 1);
7809
7810        dsp.set_motion(dsp.position, -8.0, 0.0);
7811        dsp.render(320, 1);
7812        assert_eq!(dsp.scratch_direction(), 1);
7813        assert!(dsp.last_effective_rate > 0.0);
7814
7815        let mut confirmation_frames = 0;
7816        while dsp.scratch_direction() > 0 && confirmation_frames < 24_000 {
7817            dsp.render(1, 1);
7818            confirmation_frames += 1;
7819        }
7820        assert_eq!(dsp.scratch_direction(), -1);
7821        assert!(dsp.last_effective_rate < 0.0);
7822        assert!(dsp.scratch_gate_phase() > 0.0);
7823        assert!(confirmation_frames < 24_000);
7824    }
7825
7826    #[test]
7827    fn releasing_the_record_reopens_gate_for_motor_handoff() {
7828        let mut dsp = scratch_signal_dsp(ScratchPreset::Stab, -1.0);
7829        dsp.render(1024, 1);
7830        assert!(dsp.scratch_gate() < 0.01);
7831
7832        dsp.set_transport(false, 1.0, 0.0, 0.0);
7833        dsp.render(1024, 1);
7834        assert!(dsp.scratch_gate() > 0.99);
7835        assert!(output_rms(&dsp) > 0.25);
7836    }
7837}
7838
7839#[derive(Clone, Copy, Debug, Deserialize, Serialize)]
7840#[serde(rename_all = "camelCase")]
7841pub struct CalibrationAnchor {
7842    pub sample: f64,
7843    pub radial: f64,
7844}
7845
7846#[derive(Clone, Copy, Debug, Deserialize)]
7847#[serde(rename_all = "camelCase")]
7848struct ProgrammeCalibrationGap {
7849    start_sample: f64,
7850    end_sample: f64,
7851    radial_start_normalized: f64,
7852    radial_end_normalized: f64,
7853}
7854
7855#[derive(Clone, Debug, Deserialize)]
7856#[serde(rename_all = "camelCase")]
7857struct ProgrammeCalibrationMap {
7858    total_samples: f64,
7859    #[serde(default)]
7860    gaps: Vec<ProgrammeCalibrationGap>,
7861}
7862
7863#[derive(Clone, Debug)]
7864struct MonotoneInterpolant {
7865    xs: Vec<f64>,
7866    ys: Vec<f64>,
7867    widths: Vec<f64>,
7868    tangents: Vec<f64>,
7869}
7870
7871impl MonotoneInterpolant {
7872    fn new(xs: Vec<f64>, ys: Vec<f64>) -> Result<Self, String> {
7873        if xs.len() != ys.len() {
7874            return Err("monotone interpolant requires equal-length xs/ys".to_owned());
7875        }
7876        if xs.len() < 2 {
7877            return Err("monotone interpolant requires at least two anchors".to_owned());
7878        }
7879        for index in 1..xs.len() {
7880            if !xs[index].is_finite() || xs[index] <= xs[index - 1] {
7881                return Err("monotone interpolant requires strictly increasing xs".to_owned());
7882            }
7883            if !ys[index].is_finite() || ys[index] <= ys[index - 1] {
7884                return Err("monotone interpolant requires strictly increasing ys".to_owned());
7885            }
7886        }
7887        let widths = xs
7888            .windows(2)
7889            .map(|pair| pair[1] - pair[0])
7890            .collect::<Vec<_>>();
7891        let deltas = ys
7892            .windows(2)
7893            .zip(widths.iter())
7894            .map(|(pair, width)| (pair[1] - pair[0]) / width)
7895            .collect::<Vec<_>>();
7896        let mut tangents = vec![0.0; xs.len()];
7897        for index in 1..xs.len() - 1 {
7898            if deltas[index - 1] * deltas[index] <= 0.0 {
7899                tangents[index] = 0.0;
7900            } else {
7901                let w1 = 2.0 * widths[index] + widths[index - 1];
7902                let w2 = widths[index] + 2.0 * widths[index - 1];
7903                tangents[index] = (w1 + w2) / (w1 / deltas[index - 1] + w2 / deltas[index]);
7904            }
7905        }
7906        tangents[0] = endpoint_slope(
7907            widths[0],
7908            widths.get(1).copied(),
7909            deltas[0],
7910            deltas.get(1).copied(),
7911        );
7912        let last = xs.len() - 1;
7913        tangents[last] = endpoint_slope(
7914            widths[last - 1],
7915            last.checked_sub(2)
7916                .and_then(|index| widths.get(index).copied()),
7917            deltas[last - 1],
7918            last.checked_sub(2)
7919                .and_then(|index| deltas.get(index).copied()),
7920        );
7921        Ok(Self {
7922            xs,
7923            ys,
7924            widths,
7925            tangents,
7926        })
7927    }
7928
7929    fn segment_for_x(&self, value: f64) -> usize {
7930        if value <= self.xs[0] {
7931            return 0;
7932        }
7933        if value >= self.xs[self.xs.len() - 1] {
7934            return self.xs.len() - 2;
7935        }
7936        self.xs
7937            .partition_point(|candidate| *candidate <= value)
7938            .saturating_sub(1)
7939    }
7940
7941    fn segment_for_y(&self, value: f64) -> usize {
7942        if value <= self.ys[0] {
7943            return 0;
7944        }
7945        if value >= self.ys[self.ys.len() - 1] {
7946            return self.ys.len() - 2;
7947        }
7948        self.ys
7949            .partition_point(|candidate| *candidate <= value)
7950            .saturating_sub(1)
7951    }
7952
7953    fn hermite(&self, index: usize, t: f64) -> f64 {
7954        let t2 = t * t;
7955        let t3 = t2 * t;
7956        let h00 = 2.0 * t3 - 3.0 * t2 + 1.0;
7957        let h10 = t3 - 2.0 * t2 + t;
7958        let h01 = -2.0 * t3 + 3.0 * t2;
7959        let h11 = t3 - t2;
7960        h00 * self.ys[index]
7961            + h10 * self.widths[index] * self.tangents[index]
7962            + h01 * self.ys[index + 1]
7963            + h11 * self.widths[index] * self.tangents[index + 1]
7964    }
7965
7966    fn evaluate(&self, value: f64) -> f64 {
7967        if value <= self.xs[0] {
7968            return self.ys[0];
7969        }
7970        if value >= self.xs[self.xs.len() - 1] {
7971            return self.ys[self.ys.len() - 1];
7972        }
7973        let index = self.segment_for_x(value);
7974        let t = (value - self.xs[index]) / self.widths[index];
7975        self.hermite(index, t)
7976    }
7977
7978    fn evaluate_inverse(&self, value: f64) -> f64 {
7979        if value <= self.ys[0] {
7980            return self.xs[0];
7981        }
7982        if value >= self.ys[self.ys.len() - 1] {
7983            return self.xs[self.xs.len() - 1];
7984        }
7985        let index = self.segment_for_y(value);
7986        let mut low = 0.0;
7987        let mut high = 1.0;
7988        for _ in 0..40 {
7989            let mid = (low + high) * 0.5;
7990            if self.hermite(index, mid) < value {
7991                low = mid;
7992            } else {
7993                high = mid;
7994            }
7995        }
7996        self.xs[index] + (low + high) * 0.5 * self.widths[index]
7997    }
7998}
7999
8000fn endpoint_slope(ha: f64, hb: Option<f64>, da: f64, db: Option<f64>) -> f64 {
8001    let (Some(hb), Some(db)) = (hb, db) else {
8002        return da;
8003    };
8004    let slope = ((2.0 * ha + hb) * da - ha * db) / (ha + hb);
8005    if slope.signum() != da.signum() {
8006        return 0.0;
8007    }
8008    if da.signum() != db.signum() && slope.abs() > (3.0 * da).abs() {
8009        return 3.0 * da;
8010    }
8011    slope
8012}
8013
8014#[wasm_bindgen]
8015pub struct StylusCalibration {
8016    total_samples: f64,
8017    interpolant: Option<MonotoneInterpolant>,
8018}
8019
8020#[wasm_bindgen]
8021impl StylusCalibration {
8022    #[wasm_bindgen(constructor)]
8023    pub fn new(total_samples: f64, anchors: JsValue) -> Result<StylusCalibration, JsValue> {
8024        if !total_samples.is_finite() || total_samples <= 0.0 {
8025            return Err(JsValue::from_str("totalSamples must be positive"));
8026        }
8027        let anchors: Vec<CalibrationAnchor> = serde_wasm_bindgen::from_value(anchors)
8028            .map_err(|error| JsValue::from_str(&error.to_string()))?;
8029        let interpolant = if anchors.is_empty() {
8030            None
8031        } else {
8032            validate_anchors(total_samples, &anchors).map_err(|error| JsValue::from_str(&error))?;
8033            Some(
8034                MonotoneInterpolant::new(
8035                    anchors.iter().map(|anchor| anchor.sample).collect(),
8036                    anchors.iter().map(|anchor| anchor.radial).collect(),
8037                )
8038                .map_err(|error| JsValue::from_str(&error))?,
8039            )
8040        };
8041        Ok(Self {
8042            total_samples,
8043            interpolant,
8044        })
8045    }
8046
8047    #[wasm_bindgen(js_name = fromProgrammeMap)]
8048    pub fn from_programme_map(programme: JsValue) -> Result<StylusCalibration, JsValue> {
8049        let programme: ProgrammeCalibrationMap = serde_wasm_bindgen::from_value(programme)
8050            .map_err(|error| JsValue::from_str(&error.to_string()))?;
8051        Self::try_from_programme_map(programme).map_err(|error| JsValue::from_str(&error))
8052    }
8053
8054    #[wasm_bindgen(getter, js_name = hasGaps)]
8055    pub fn has_gaps(&self) -> bool {
8056        self.interpolant.is_some()
8057    }
8058
8059    #[wasm_bindgen(getter, js_name = totalSamples)]
8060    pub fn total_samples(&self) -> f64 {
8061        self.total_samples
8062    }
8063
8064    #[wasm_bindgen(js_name = sampleToGroove)]
8065    pub fn sample_to_groove(&self, sample: f64) -> f64 {
8066        let sample = sample.clamp(0.0, self.total_samples);
8067        self.interpolant
8068            .as_ref()
8069            .map(|interpolant| interpolant.evaluate(sample).clamp(0.0, 1.0))
8070            .unwrap_or_else(|| (sample / self.total_samples).clamp(0.0, 1.0))
8071    }
8072
8073    #[wasm_bindgen(js_name = grooveToSample)]
8074    pub fn groove_to_sample(&self, groove: f64) -> f64 {
8075        let groove = groove.clamp(0.0, 1.0);
8076        self.interpolant
8077            .as_ref()
8078            .map(|interpolant| {
8079                interpolant
8080                    .evaluate_inverse(groove)
8081                    .clamp(0.0, self.total_samples)
8082            })
8083            .unwrap_or(groove * self.total_samples)
8084    }
8085}
8086
8087impl StylusCalibration {
8088    /// Creates the shared stylus calibration from a programme-map JSON object.
8089    pub fn from_programme_map_json_native(value: &str) -> Result<StylusCalibration, String> {
8090        let programme: ProgrammeCalibrationMap =
8091            serde_json::from_str(value).map_err(|error| error.to_string())?;
8092        Self::try_from_programme_map(programme)
8093    }
8094
8095    fn try_from_programme_map(
8096        programme: ProgrammeCalibrationMap,
8097    ) -> Result<StylusCalibration, String> {
8098        let total_samples = programme.total_samples;
8099        if !total_samples.is_finite() || total_samples <= 0.0 || total_samples.fract() != 0.0 {
8100            return Err("programme map requires a positive integer totalSamples".to_owned());
8101        }
8102        if programme.gaps.is_empty() {
8103            return Ok(Self {
8104                total_samples,
8105                interpolant: None,
8106            });
8107        }
8108
8109        let mut indexed_gaps = programme.gaps.into_iter().enumerate().collect::<Vec<_>>();
8110        for (index, gap) in &indexed_gaps {
8111            if !gap.start_sample.is_finite()
8112                || !gap.end_sample.is_finite()
8113                || gap.start_sample.fract() != 0.0
8114                || gap.end_sample.fract() != 0.0
8115            {
8116                return Err(format!(
8117                    "gap {index}: startSample and endSample must be finite integers"
8118                ));
8119            }
8120            if !gap.radial_start_normalized.is_finite() || !gap.radial_end_normalized.is_finite() {
8121                return Err(format!(
8122                    "gap {index}: radialStartNormalized and radialEndNormalized must be finite"
8123                ));
8124            }
8125        }
8126        indexed_gaps.sort_by(|left, right| left.1.start_sample.total_cmp(&right.1.start_sample));
8127
8128        let mut anchors = Vec::with_capacity(indexed_gaps.len() * 2 + 2);
8129        anchors.push(CalibrationAnchor {
8130            sample: 0.0,
8131            radial: 0.0,
8132        });
8133        let mut previous_end_sample = 0.0;
8134        let mut previous_radial_end = 0.0;
8135        for (index, gap) in indexed_gaps {
8136            if gap.start_sample < previous_end_sample {
8137                return Err(format!("gap {index}: sample regions must not overlap"));
8138            }
8139            if !(gap.start_sample > 0.0
8140                && gap.start_sample < gap.end_sample
8141                && gap.end_sample <= total_samples)
8142            {
8143                return Err(format!(
8144                    "gap {index}: requires 0 < startSample < endSample <= totalSamples"
8145                ));
8146            }
8147            if gap.radial_start_normalized < previous_radial_end {
8148                return Err(format!("gap {index}: radial regions must not overlap"));
8149            }
8150            if !(gap.radial_start_normalized > 0.0
8151                && gap.radial_start_normalized < gap.radial_end_normalized
8152                && gap.radial_end_normalized <= 1.0)
8153            {
8154                return Err(format!(
8155                    "gap {index}: requires 0 < radialStartNormalized < radialEndNormalized <= 1"
8156                ));
8157            }
8158            anchors.push(CalibrationAnchor {
8159                sample: gap.start_sample,
8160                radial: gap.radial_start_normalized,
8161            });
8162            anchors.push(CalibrationAnchor {
8163                sample: gap.end_sample,
8164                radial: gap.radial_end_normalized,
8165            });
8166            previous_end_sample = gap.end_sample;
8167            previous_radial_end = gap.radial_end_normalized;
8168        }
8169        anchors.push(CalibrationAnchor {
8170            sample: total_samples,
8171            radial: 1.0,
8172        });
8173        validate_anchors(total_samples, &anchors)?;
8174        let interpolant = MonotoneInterpolant::new(
8175            anchors.iter().map(|anchor| anchor.sample).collect(),
8176            anchors.iter().map(|anchor| anchor.radial).collect(),
8177        )?;
8178        Ok(Self {
8179            total_samples,
8180            interpolant: Some(interpolant),
8181        })
8182    }
8183}
8184
8185fn validate_anchors(total_samples: f64, anchors: &[CalibrationAnchor]) -> Result<(), String> {
8186    if anchors.len() < 2 {
8187        return Err("calibration requires at least two anchors".to_owned());
8188    }
8189    if anchors[0].sample != 0.0 || anchors[0].radial != 0.0 {
8190        return Err("calibration must begin at sample 0 and radial 0".to_owned());
8191    }
8192    let last = anchors[anchors.len() - 1];
8193    if last.sample != total_samples || last.radial != 1.0 {
8194        return Err("calibration must end at totalSamples and radial 1".to_owned());
8195    }
8196    for pair in anchors.windows(2) {
8197        if !pair[0].sample.is_finite()
8198            || !pair[1].sample.is_finite()
8199            || pair[1].sample <= pair[0].sample
8200        {
8201            return Err("sample anchors must be strictly increasing".to_owned());
8202        }
8203        if !pair[0].radial.is_finite()
8204            || !pair[1].radial.is_finite()
8205            || pair[1].radial <= pair[0].radial
8206        {
8207            return Err("radial anchors must be strictly increasing".to_owned());
8208        }
8209    }
8210    Ok(())
8211}
8212
8213#[derive(Clone, Copy, Debug, Deserialize)]
8214#[serde(rename_all = "camelCase")]
8215pub struct ScratchConfig {
8216    pub max_playback_rate: f64,
8217    pub deadzone_rate: f64,
8218    pub lock_center_rate: f64,
8219    pub lock_width: f64,
8220    pub lock_strength: f64,
8221    pub pointer_filter_seconds: f64,
8222}
8223
8224#[derive(Clone, Copy, Debug, Serialize)]
8225#[serde(rename_all = "camelCase")]
8226pub struct ScratchMotion {
8227    pub delta_angle_radians: f64,
8228    pub rotation_degrees: f64,
8229    pub current_time: f64,
8230    pub sample_position: f64,
8231    pub raw_playback_rate: f64,
8232    pub filtered_playback_rate: f64,
8233    pub physical_playback_rate: f64,
8234}
8235
8236#[derive(Clone, Copy, Debug, Serialize)]
8237#[serde(rename_all = "camelCase")]
8238pub struct ScratchWindowPlan {
8239    pub start: u32,
8240    pub end: u32,
8241    pub length: u32,
8242    pub needs_update: bool,
8243}
8244
8245#[wasm_bindgen]
8246pub struct ScratchSimulation {
8247    config: ScratchConfig,
8248    active: bool,
8249    pointer_id: i32,
8250    last_angle: f64,
8251    last_time_ms: f64,
8252    filtered_pointer_rate: f64,
8253    current_time: f64,
8254    sample_position: f64,
8255    rotation_degrees: f64,
8256}
8257
8258#[wasm_bindgen]
8259impl ScratchSimulation {
8260    #[wasm_bindgen(constructor)]
8261    pub fn new(config: JsValue) -> Result<ScratchSimulation, JsValue> {
8262        let config: ScratchConfig = serde_wasm_bindgen::from_value(config)
8263            .map_err(|error| JsValue::from_str(&error.to_string()))?;
8264        validate_scratch_config(config).map_err(|error| JsValue::from_str(&error))?;
8265        Ok(Self {
8266            config,
8267            active: false,
8268            pointer_id: -1,
8269            last_angle: 0.0,
8270            last_time_ms: 0.0,
8271            filtered_pointer_rate: 0.0,
8272            current_time: 0.0,
8273            sample_position: 0.0,
8274            rotation_degrees: 0.0,
8275        })
8276    }
8277
8278    #[wasm_bindgen(js_name = begin)]
8279    pub fn begin(
8280        &mut self,
8281        pointer_id: i32,
8282        angle_radians: f64,
8283        time_ms: f64,
8284        current_time: f64,
8285        rotation_degrees: f64,
8286        sample_rate: f64,
8287        duration: f64,
8288    ) -> Result<(), JsValue> {
8289        validate_motion_inputs(angle_radians, time_ms, sample_rate, duration)?;
8290        self.active = true;
8291        self.pointer_id = pointer_id;
8292        self.last_angle = angle_radians;
8293        self.last_time_ms = time_ms;
8294        self.filtered_pointer_rate = 0.0;
8295        self.current_time = current_time.clamp(0.0, duration);
8296        self.sample_position = (self.current_time * sample_rate).clamp(0.0, duration * sample_rate);
8297        self.rotation_degrees = rotation_degrees;
8298        Ok(())
8299    }
8300
8301    #[wasm_bindgen(js_name = update)]
8302    pub fn update(
8303        &mut self,
8304        pointer_id: i32,
8305        angle_radians: f64,
8306        time_ms: f64,
8307        duration: f64,
8308        sample_rate: f64,
8309        seconds_per_turn: f64,
8310        needle_lifted: bool,
8311    ) -> Result<JsValue, JsValue> {
8312        if !self.active || self.pointer_id != pointer_id {
8313            return Err(JsValue::from_str("scratch pointer is not active"));
8314        }
8315        validate_motion_inputs(angle_radians, time_ms, sample_rate, duration)?;
8316        if !seconds_per_turn.is_finite() || seconds_per_turn <= 0.0 {
8317            return Err(JsValue::from_str("secondsPerTurn must be positive"));
8318        }
8319        let delta_angle = normalize_angle_delta(angle_radians - self.last_angle);
8320        let elapsed_seconds = ((time_ms - self.last_time_ms).max(1.0) / 1000.0).max(0.004);
8321        self.last_angle = angle_radians;
8322        self.last_time_ms = time_ms;
8323        self.rotation_degrees += delta_angle.to_degrees();
8324        let mut raw_playback_rate = 0.0;
8325        let mut physical_playback_rate = 0.0;
8326        if !needle_lifted {
8327            let mapped_delta_seconds = delta_angle / std::f64::consts::TAU * seconds_per_turn;
8328            self.current_time = (self.current_time + mapped_delta_seconds).clamp(0.0, duration);
8329            raw_playback_rate = mapped_delta_seconds / elapsed_seconds;
8330            let alpha = 1.0 - (-elapsed_seconds / self.config.pointer_filter_seconds).exp();
8331            self.filtered_pointer_rate += (raw_playback_rate - self.filtered_pointer_rate) * alpha;
8332            physical_playback_rate =
8333                map_physical_playback_rate(self.filtered_pointer_rate, self.config);
8334            self.sample_position =
8335                (self.current_time * sample_rate).clamp(0.0, duration * sample_rate);
8336        }
8337        serde_wasm_bindgen::to_value(&ScratchMotion {
8338            delta_angle_radians: delta_angle,
8339            rotation_degrees: self.rotation_degrees,
8340            current_time: self.current_time,
8341            sample_position: self.sample_position,
8342            raw_playback_rate,
8343            filtered_playback_rate: self.filtered_pointer_rate,
8344            physical_playback_rate,
8345        })
8346        .map_err(|error| JsValue::from_str(&error.to_string()))
8347    }
8348
8349    #[wasm_bindgen(js_name = finish)]
8350    pub fn finish(&mut self) {
8351        self.active = false;
8352        self.pointer_id = -1;
8353        self.filtered_pointer_rate = 0.0;
8354    }
8355
8356    #[wasm_bindgen(js_name = mapPhysicalPlaybackRate)]
8357    pub fn map_physical_playback_rate(&self, playback_rate: f64) -> f64 {
8358        map_physical_playback_rate(playback_rate, self.config)
8359    }
8360
8361    #[wasm_bindgen(js_name = planWindow)]
8362    pub fn plan_window(
8363        &self,
8364        center_sample_position: f64,
8365        frame_length: u32,
8366        window_frames: u32,
8367        current_window_start: u32,
8368        current_window_end: u32,
8369        margin_frames: u32,
8370        force: bool,
8371    ) -> Result<JsValue, JsValue> {
8372        let frame_length = frame_length.max(1);
8373        let window_frames = window_frames.max(1).min(frame_length);
8374        let center = center_sample_position
8375            .round()
8376            .clamp(0.0, f64::from(frame_length.saturating_sub(1))) as u32;
8377        let half = window_frames / 2;
8378        let max_start = frame_length.saturating_sub(window_frames);
8379        let start = center.saturating_sub(half).min(max_start);
8380        let end = start.saturating_add(window_frames).min(frame_length);
8381        let needs_update = force
8382            || center <= current_window_start.saturating_add(margin_frames)
8383            || center >= current_window_end.saturating_sub(margin_frames);
8384        serde_wasm_bindgen::to_value(&ScratchWindowPlan {
8385            start,
8386            end,
8387            length: end.saturating_sub(start),
8388            needs_update,
8389        })
8390        .map_err(|error| JsValue::from_str(&error.to_string()))
8391    }
8392}
8393
8394fn validate_scratch_config(config: ScratchConfig) -> Result<(), String> {
8395    if !config.max_playback_rate.is_finite() || config.max_playback_rate <= 0.0 {
8396        return Err("maxPlaybackRate must be positive".to_owned());
8397    }
8398    if !config.deadzone_rate.is_finite() || config.deadzone_rate < 0.0 {
8399        return Err("deadzoneRate must be non-negative".to_owned());
8400    }
8401    if !config.lock_center_rate.is_finite() || config.lock_center_rate < 0.0 {
8402        return Err("lockCenterRate must be non-negative".to_owned());
8403    }
8404    if !config.lock_width.is_finite() || config.lock_width <= 0.0 {
8405        return Err("lockWidth must be positive".to_owned());
8406    }
8407    if !config.lock_strength.is_finite() || !(0.0..=1.0).contains(&config.lock_strength) {
8408        return Err("lockStrength must be between 0 and 1".to_owned());
8409    }
8410    if !config.pointer_filter_seconds.is_finite() || config.pointer_filter_seconds <= 0.0 {
8411        return Err("pointerFilterSeconds must be positive".to_owned());
8412    }
8413    Ok(())
8414}
8415
8416fn validate_motion_inputs(
8417    angle_radians: f64,
8418    time_ms: f64,
8419    sample_rate: f64,
8420    duration: f64,
8421) -> Result<(), JsValue> {
8422    if !angle_radians.is_finite() {
8423        return Err(JsValue::from_str("angleRadians must be finite"));
8424    }
8425    if !time_ms.is_finite() {
8426        return Err(JsValue::from_str("timeMs must be finite"));
8427    }
8428    if !sample_rate.is_finite() || sample_rate <= 0.0 {
8429        return Err(JsValue::from_str("sampleRate must be positive"));
8430    }
8431    if !duration.is_finite() || duration < 0.0 {
8432        return Err(JsValue::from_str("duration must be non-negative"));
8433    }
8434    Ok(())
8435}
8436
8437fn normalize_angle_delta(delta: f64) -> f64 {
8438    let mut normalized = delta;
8439    while normalized > std::f64::consts::PI {
8440        normalized -= std::f64::consts::TAU;
8441    }
8442    while normalized < -std::f64::consts::PI {
8443        normalized += std::f64::consts::TAU;
8444    }
8445    normalized
8446}
8447
8448fn map_physical_playback_rate(playback_rate: f64, config: ScratchConfig) -> f64 {
8449    if !playback_rate.is_finite() || playback_rate.abs() < config.deadzone_rate {
8450        return 0.0;
8451    }
8452    let direction = playback_rate.signum();
8453    let magnitude = playback_rate.abs();
8454    let lock_distance = (magnitude - config.lock_center_rate).abs();
8455    let lock_amount = (-(lock_distance / config.lock_width).powi(2)).exp() * config.lock_strength;
8456    let stabilized = magnitude + (config.lock_center_rate - magnitude) * lock_amount;
8457    (direction * stabilized).clamp(-config.max_playback_rate, config.max_playback_rate)
8458}
8459
8460#[cfg(test)]
8461mod scratch_tests {
8462    use super::*;
8463    use approx::assert_abs_diff_eq;
8464
8465    #[test]
8466    fn monotone_mapping_round_trips() {
8467        let interpolant =
8468            MonotoneInterpolant::new(vec![0.0, 100.0, 200.0, 300.0], vec![0.0, 0.2, 0.8, 1.0])
8469                .unwrap();
8470        for sample in [0.0, 25.0, 100.0, 175.0, 250.0, 300.0] {
8471            let radial = interpolant.evaluate(sample);
8472            assert_abs_diff_eq!(interpolant.evaluate_inverse(radial), sample, epsilon = 1e-8);
8473        }
8474    }
8475
8476    #[test]
8477    fn programme_gap_calibration_pins_both_visible_edges_and_round_trips() {
8478        let calibration = StylusCalibration::try_from_programme_map(ProgrammeCalibrationMap {
8479            total_samples: 9_000_000.0,
8480            gaps: vec![ProgrammeCalibrationGap {
8481                start_sample: 4_000_000.0,
8482                end_sample: 4_096_000.0,
8483                radial_start_normalized: 0.421,
8484                radial_end_normalized: 0.429,
8485            }],
8486        })
8487        .unwrap();
8488
8489        assert!(calibration.has_gaps());
8490        assert_abs_diff_eq!(
8491            calibration.sample_to_groove(4_000_000.0),
8492            0.421,
8493            epsilon = 1e-12
8494        );
8495        assert_abs_diff_eq!(
8496            calibration.sample_to_groove(4_096_000.0),
8497            0.429,
8498            epsilon = 1e-12
8499        );
8500        for sample in [0.0, 1_000_000.0, 4_000_000.0, 4_048_000.0, 8_000_000.0] {
8501            let groove = calibration.sample_to_groove(sample);
8502            assert_abs_diff_eq!(calibration.groove_to_sample(groove), sample, epsilon = 1e-4);
8503        }
8504        let gap_midpoint = calibration.groove_to_sample(0.425);
8505        assert!((4_000_000.0..=4_096_000.0).contains(&gap_midpoint));
8506    }
8507
8508    #[test]
8509    fn programme_gap_calibration_rejects_overlapping_or_flat_anchors() {
8510        let overlapping = StylusCalibration::try_from_programme_map(ProgrammeCalibrationMap {
8511            total_samples: 10_000.0,
8512            gaps: vec![
8513                ProgrammeCalibrationGap {
8514                    start_sample: 2_000.0,
8515                    end_sample: 3_000.0,
8516                    radial_start_normalized: 0.2,
8517                    radial_end_normalized: 0.3,
8518                },
8519                ProgrammeCalibrationGap {
8520                    start_sample: 2_500.0,
8521                    end_sample: 4_000.0,
8522                    radial_start_normalized: 0.4,
8523                    radial_end_normalized: 0.5,
8524                },
8525            ],
8526        });
8527        assert_eq!(
8528            overlapping.err().unwrap(),
8529            "gap 1: sample regions must not overlap"
8530        );
8531
8532        let flat = StylusCalibration::try_from_programme_map(ProgrammeCalibrationMap {
8533            total_samples: 10_000.0,
8534            gaps: vec![ProgrammeCalibrationGap {
8535                start_sample: 2_000.0,
8536                end_sample: 3_000.0,
8537                radial_start_normalized: 0.2,
8538                radial_end_normalized: 0.2,
8539            }],
8540        });
8541        assert_eq!(
8542            flat.err().unwrap(),
8543            "gap 0: requires 0 < radialStartNormalized < radialEndNormalized <= 1"
8544        );
8545    }
8546
8547    #[test]
8548    fn playback_rate_deadzone_and_lock_are_preserved() {
8549        let config = ScratchConfig {
8550            max_playback_rate: 4.0,
8551            deadzone_rate: 0.02,
8552            lock_center_rate: 1.0,
8553            lock_width: 0.1,
8554            lock_strength: 0.5,
8555            pointer_filter_seconds: 0.035,
8556        };
8557        assert_eq!(map_physical_playback_rate(0.01, config), 0.0);
8558        assert_abs_diff_eq!(
8559            map_physical_playback_rate(1.0, config),
8560            1.0,
8561            epsilon = 1e-12
8562        );
8563        assert_eq!(map_physical_playback_rate(10.0, config), 4.0);
8564    }
8565}