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;
24const GRIP_ATTACK_SECONDS: f64 = 0.004;
28const GRIP_RELEASE_SECONDS: f64 = 0.045;
29const GRIP_CONTACT_EPSILON: f64 = 0.02;
31const HAND_RELEASE_SECONDS: f64 = 0.008;
33const EDGE_FADE_SECONDS: f64 = 0.01;
36const MOVEMENT_GAIN_SECONDS: f64 = 0.003;
40const GRIP_OWNERSHIP: f64 = 0.5;
41const DEADZONE_RATE: f64 = 0.006;
42const STOP_GAIN_FULL_RATE: f64 = 0.02;
49const RIAA_T1_SECONDS: f64 = 3180.0e-6;
51const RIAA_T2_SECONDS: f64 = 318.0e-6;
52const RIAA_T3_SECONDS: f64 = 75.0e-6;
53const RIAA_TILT_MIN_RATE: f64 = 0.1;
57const RIAA_TILT_MAX_RATE: f64 = 4.0;
58const MAX_CARTRIDGE_VELOCITY_GAIN: f64 = 4.0;
61#[derive(Clone, Copy, Debug, PartialEq)]
71struct VinylVoicingCurve {
72 cartridge_hz: f64,
74 cartridge_q: f64,
75 low_hz: f64,
77 low_db: f64,
78 high_hz: f64,
80 high_db: f64,
81 shelf_q: f64,
82}
83
84const VINYL_VOICING_CURVES: [VinylVoicingCurve; 3] = [
85 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 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 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;
147const SEAM_REPAIR_SAMPLES: usize = 24;
150const LOOSE_SLIPMAT_COUPLING_SCALE: f64 = 0.65;
151const TIGHT_SLIPMAT_COUPLING_SCALE: f64 = 2.0;
152
153const 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#[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#[derive(Clone, Debug, PartialEq)]
246struct RiaaSpeedTilt {
247 sections: [(f64, f64, f64); 3],
249 state: [[(f64, f64); 3]; 2],
251 rate: f64,
252 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 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 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 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 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#[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 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#[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 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 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#[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#[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#[derive(Clone, Copy, Debug)]
698struct NeedleThump {
699 elapsed_seconds: f64,
700 phase: f64,
701 gain: f64,
702}
703
704#[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 #[serde(default = "default_stylus_tracing_limit")]
729 pub stylus_tracing_limit: f64,
730 #[serde(default = "default_high_frequency_acceleration_limit")]
733 pub high_frequency_acceleration_limit: f64,
734 #[serde(default = "default_texture_scale")]
739 pub texture_scale: f64,
740 #[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 #[serde(default)]
762 pub soft_clip: bool,
763 #[serde(default = "default_true")]
769 pub cartridge_velocity_gain: bool,
770 #[serde(default = "default_true")]
775 pub riaa_speed_tilt: bool,
776 #[serde(default = "default_riaa_voicing_rate")]
784 pub riaa_voicing_rate: f64,
785 #[serde(default)]
791 pub vinyl_voicing: f64,
792 #[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 motion_interval_frames: usize,
935 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 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
973struct RevolutionCapture {
985 target_phase: f64,
988 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 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 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 riaa_voicing: RiaaSpeedTilt,
1041 vinyl_voicing: VinylVoicingFilter,
1043 surface_voicing: VinylVoicingFilter,
1047 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 free_spin_drive_per_second: f64,
1062 eccentricity_mm: f64,
1067 warp_mm: f64,
1070 stylus_tap_degrees: f64,
1074 stylus_tap_level: f64,
1075 tap_lowpass_state: [f64; 2],
1076 angle_gate_sectors: u32,
1078 angle_gate_depth: f64,
1079 angle_gate_gain: f64,
1080 locked_groove_start: f64,
1084 groove_wear_rate: f64,
1086 groove_wear: Vec<f32>,
1089 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 motion_interval_frames: usize,
1108 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 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 #[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 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 #[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 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 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 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 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 #[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 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 #[wasm_bindgen(getter, js_name = audibleCrossfaderGain)]
1520 pub fn audible_crossfader_gain(&self) -> f64 {
1521 self.audible_crossfader_gain
1522 }
1523
1524 #[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 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 #[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 #[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 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 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 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 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 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 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 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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 self.deck_state
2521 .reconfigure(deck_config)
2522 .map_err(|error| JsValue::from_str(&error.to_string()))?;
2523 Ok(())
2524 }
2525
2526 #[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 self.deck_state
2547 .reconfigure(deck_config)
2548 .map_err(|error| JsValue::from_str(&error.to_string()))?;
2549 Ok(())
2550 }
2551
2552 #[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 #[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 #[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(°rees) {
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 #[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 #[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 #[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 #[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 #[wasm_bindgen(js_name = grooveWearMap)]
2680 pub fn groove_wear_map(&self) -> Vec<f32> {
2681 self.groove_wear.clone()
2682 }
2683
2684 #[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 #[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 #[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 self.wow_phase = self.platter_rotation_turns.rem_euclid(1.0);
2734 }
2735
2736 #[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 #[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 #[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 #[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 #[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 #[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 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 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 self.enforce_locked_groove();
3030 self.frames_since_motion = self.frames_since_motion.saturating_add(1);
3031 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 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 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 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 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 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 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 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 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 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 {
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 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 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 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 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 * (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 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 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 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 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 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 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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 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 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 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 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 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 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 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 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 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 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 pub fn native_window_snapshot(&self) -> (Arc<Vec<Vec<f32>>>, f64) {
4023 (Arc::clone(&self.channels), self.source_sample_rate)
4024 }
4025
4026 pub fn rendered_samples(&self) -> &[f32] {
4028 &self.output
4029 }
4030
4031 fn select_surface_sample(&mut self, duration_seconds: f64) -> (f64, bool) {
4035 if self.surface_asset.is_empty() {
4036 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 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 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 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 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 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 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 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 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 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 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 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 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
4857const SINGLE_OUTER_GROOVE_MM: f64 = 84.0;
4861const SINGLE_INNER_GROOVE_MM: f64 = 54.0;
4862
4863#[derive(Clone, Copy, Debug, PartialEq, Eq)]
4871pub enum WearScope {
4872 Groove,
4875 Halo,
4879 Polar,
4882 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 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
4925pub 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 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
4948fn valid_texture_scale(value: f64) -> bool {
4950 value.is_finite() && (0.0..=4.0).contains(&value)
4951}
4952
4953fn 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
4962fn 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 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
5012fn 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 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 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 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 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); }
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 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 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 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 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 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 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 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 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 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); }
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 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 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 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 assert!(dsp.position - before > 100.0, "the platter spun up from rest");
5515 assert!(dsp.restore_replay_state());
5516
5517 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 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 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 assert!(
5585 (start_position - ring).abs() < 1.0,
5586 "began at {start_position}, ring at {ring}"
5587 );
5588 let first_counter = begin_seen_at.expect("began").0;
5590 assert!(start_frame > first_counter, "began before the ring came round");
5591 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 assert_eq!(dsp.noise_seed != seed, true, "noise has advanced past the seed");
5600 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 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 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 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 #[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); }
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; 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 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 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 #[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 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 #[test]
6261 fn a_stroking_hand_sampled_at_sixty_hertz_is_followed_without_a_lurch() {
6262 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 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 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 #[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 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 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 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 #[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 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 #[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 #[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 #[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 #[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 #[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 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 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 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 #[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 #[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 #[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 #[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 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 #[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 #[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 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 #[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 #[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 assert_eq!(
6923 compute_movement_gain(50.0, false, true),
6924 MAX_CARTRIDGE_VELOCITY_GAIN
6925 );
6926 }
6927
6928 #[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 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 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 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}