Skip to main content

record_player/
acoustic.rs

1use serde::{Deserialize, Serialize};
2use wasm_bindgen::prelude::*;
3
4const OUTPUT_GAIN: f64 = 1.0;
5const RATE_SPRING_OMEGA: f64 = 70.0;
6const RATE_SPRING_ZETA: f64 = 0.85;
7const POSITION_CATCHUP_SECONDS: f64 = 0.28;
8const MOTION_HOLD_SECONDS: f64 = 0.05;
9const MOTION_HOLD_RELEASE_SECONDS: f64 = 0.06;
10const GRIP_ATTACK_SECONDS: f64 = 0.1;
11const GRIP_RELEASE_SECONDS: f64 = 0.045;
12const MOTOR_SPINUP_SECONDS: f64 = 0.3;
13const MOTOR_BRAKE_SECONDS: f64 = 0.32;
14const GRIP_OWNERSHIP: f64 = 0.5;
15const STILL_SNAP_SECONDS: f64 = 0.03;
16const DEADZONE_RATE: f64 = 0.006;
17const DRAG_LOWPASS_MAX_HZ: f64 = 19_000.0;
18const DRAG_LOWPASS_RATE_KNEE: f64 = 0.95;
19const TRACING_LOSS_START_RATE: f64 = 2.5;
20const WOW_REV_SECONDS: f64 = 1.8;
21const FLUTTER_HZ: f64 = 6.4;
22const CONTACT_NOISE_GAIN: f64 = 0.00008;
23const SOURCE_TEXTURE_GAIN: f64 = 0.00018;
24const DUST_FLECK_GAIN: f64 = 0.000045;
25const CONTACT_IMPULSE_DECAY: f64 = 0.985;
26const WINDOW_REQUEST_MARGIN_SECONDS: f64 = 0.75;
27const WINDOW_REQUEST_PROJECT_SECONDS: f64 = 0.18;
28const WINDOW_MISS_FADE_SECONDS: f64 = 0.006;
29
30#[derive(Clone, Copy, Debug, Deserialize)]
31#[serde(rename_all = "camelCase")]
32pub struct AcousticConfig {
33    #[serde(default = "default_max_rate")]
34    pub max_rate: f64,
35    #[serde(default = "default_wow_rev_seconds")]
36    pub wow_rev_seconds: f64,
37    #[serde(default = "default_flutter_hz")]
38    pub flutter_hz: f64,
39    #[serde(default = "default_true")]
40    pub acoustic_enabled: bool,
41    #[serde(default = "default_true")]
42    pub surface_enabled: bool,
43}
44
45fn default_max_rate() -> f64 { 10.0 }
46fn default_wow_rev_seconds() -> f64 { WOW_REV_SECONDS }
47fn default_flutter_hz() -> f64 { FLUTTER_HZ }
48fn default_true() -> bool { true }
49
50impl Default for AcousticConfig {
51    fn default() -> Self {
52        Self {
53            max_rate: default_max_rate(),
54            wow_rev_seconds: default_wow_rev_seconds(),
55            flutter_hz: default_flutter_hz(),
56            acoustic_enabled: true,
57            surface_enabled: true,
58        }
59    }
60}
61
62#[derive(Clone, Copy, Debug, Serialize)]
63#[serde(rename_all = "camelCase")]
64pub struct AcousticStatus {
65    pub position: f64,
66    pub effective_rate: f64,
67    pub requested_window_position: Option<f64>,
68    pub ended: bool,
69    pub output_length: usize,
70}
71
72#[wasm_bindgen]
73pub struct ScratchAcousticDsp {
74    config: AcousticConfig,
75    output_sample_rate: f64,
76    source_sample_rate: f64,
77    channels: Vec<Vec<f32>>,
78    total_frames: usize,
79    window_start: usize,
80    window_end: usize,
81    position: f64,
82    target_position: f64,
83    rate: f64,
84    rate_velocity: f64,
85    target_rate: f64,
86    wow_phase: f64,
87    flutter_phase: f64,
88    drag_lowpass_state: Vec<f64>,
89    active: bool,
90    needle_lifted: bool,
91    hand_contact: bool,
92    grip: f64,
93    motor_rate: f64,
94    motor_delivered_rate: f64,
95    ended: bool,
96    contact_impulse: f64,
97    last_effective_rate: f64,
98    noise_seed: u32,
99    last_noise: f64,
100    last_output_samples: Vec<f64>,
101    window_miss_frames: usize,
102    frames_since_motion: usize,
103    frames_since_window_request: usize,
104    output: Vec<f32>,
105    requested_window_position: Option<f64>,
106}
107
108#[wasm_bindgen]
109impl ScratchAcousticDsp {
110    #[wasm_bindgen(constructor)]
111    pub fn new(output_sample_rate: f64, config: JsValue) -> Result<ScratchAcousticDsp, JsValue> {
112        if !output_sample_rate.is_finite() || output_sample_rate <= 0.0 {
113            return Err(JsValue::from_str("outputSampleRate must be positive"));
114        }
115        let config = if config.is_null() || config.is_undefined() {
116            AcousticConfig::default()
117        } else {
118            serde_wasm_bindgen::from_value(config)
119                .map_err(|error| JsValue::from_str(&error.to_string()))?
120        };
121        if !config.max_rate.is_finite() || config.max_rate <= 0.0 {
122            return Err(JsValue::from_str("maxRate must be positive"));
123        }
124        Ok(Self {
125            config,
126            output_sample_rate,
127            source_sample_rate: 48_000.0,
128            channels: Vec::new(),
129            total_frames: 0,
130            window_start: 0,
131            window_end: 0,
132            position: 0.0,
133            target_position: 0.0,
134            rate: 0.0,
135            rate_velocity: 0.0,
136            target_rate: 0.0,
137            wow_phase: 0.0,
138            flutter_phase: 0.0,
139            drag_lowpass_state: Vec::new(),
140            active: false,
141            needle_lifted: false,
142            hand_contact: false,
143            grip: 0.0,
144            motor_rate: 0.0,
145            motor_delivered_rate: 0.0,
146            ended: false,
147            contact_impulse: 0.0,
148            last_effective_rate: 0.0,
149            noise_seed: 0x9e37_79b9,
150            last_noise: 0.0,
151            last_output_samples: Vec::new(),
152            window_miss_frames: 0,
153            frames_since_motion: output_sample_rate as usize,
154            frames_since_window_request: output_sample_rate as usize,
155            output: Vec::new(),
156            requested_window_position: None,
157        })
158    }
159
160    #[wasm_bindgen(js_name = setWindow)]
161    pub fn set_window(
162        &mut self,
163        channels: JsValue,
164        source_sample_rate: f64,
165        window_start: u32,
166        total_frames: u32,
167        reset_position: Option<f64>,
168    ) -> Result<(), JsValue> {
169        if !source_sample_rate.is_finite() || source_sample_rate <= 0.0 {
170            return Err(JsValue::from_str("sourceSampleRate must be positive"));
171        }
172        let channels: Vec<Vec<f32>> = serde_wasm_bindgen::from_value(channels)
173            .map_err(|error| JsValue::from_str(&error.to_string()))?;
174        if channels.is_empty() || channels[0].is_empty() {
175            return Err(JsValue::from_str("at least one non-empty source channel is required"));
176        }
177        let length = channels[0].len();
178        if channels.iter().any(|channel| channel.len() != length) {
179            return Err(JsValue::from_str("source channels must have equal lengths"));
180        }
181        self.source_sample_rate = source_sample_rate;
182        self.window_start = window_start as usize;
183        self.window_end = self.window_start.saturating_add(length);
184        self.total_frames = (total_frames as usize).max(self.window_end);
185        self.channels = channels;
186        if let Some(position) = reset_position {
187            self.reset_position(position);
188        }
189        Ok(())
190    }
191
192    #[wasm_bindgen(js_name = clearWindow)]
193    pub fn clear_window(&mut self) {
194        self.channels.clear();
195        self.total_frames = 0;
196        self.window_start = 0;
197        self.window_end = 0;
198        self.reset_position(0.0);
199    }
200
201    #[wasm_bindgen(js_name = start)]
202    pub fn start(&mut self) {
203        self.active = true;
204        self.grip = 0.0;
205        self.motor_delivered_rate = 0.0;
206        self.hand_contact = true;
207        self.position = self.clamp_source_position(self.position.max(self.target_position));
208        self.target_position = self.position;
209        self.rate = 0.0;
210        self.rate_velocity = 0.0;
211        self.target_rate = 0.0;
212        self.last_effective_rate = 0.0;
213        self.frames_since_motion = 0;
214        self.contact_impulse = 0.0;
215        self.last_output_samples.clear();
216        self.window_miss_frames = 0;
217        self.ended = false;
218    }
219
220    #[wasm_bindgen(js_name = stop)]
221    pub fn stop(&mut self) {
222        self.active = false;
223        self.target_rate = 0.0;
224        self.contact_impulse = 0.0;
225        self.last_effective_rate = 0.0;
226    }
227
228    #[wasm_bindgen(js_name = setEffects)]
229    pub fn set_effects(&mut self, acoustic_enabled: bool, surface_enabled: bool) {
230        self.config.acoustic_enabled = acoustic_enabled;
231        self.config.surface_enabled = surface_enabled;
232        if !surface_enabled {
233            self.contact_impulse = 0.0;
234            self.last_noise = 0.0;
235        }
236    }
237
238    #[wasm_bindgen(js_name = setNeedleLifted)]
239    pub fn set_needle_lifted(&mut self, lifted: bool) {
240        self.needle_lifted = lifted;
241    }
242
243    #[wasm_bindgen(js_name = setMotion)]
244    pub fn set_motion(&mut self, position: f64, rate: f64, impulse: f64) {
245        self.target_position = self.clamp_source_position(position);
246        self.target_rate = self.map_rate(rate);
247        self.frames_since_motion = 0;
248        if impulse > 0.0 {
249            self.contact_impulse = self.contact_impulse.max(impulse).clamp(0.0, 1.0);
250        }
251    }
252
253    #[wasm_bindgen(js_name = setTransport)]
254    pub fn set_transport(&mut self, hand_contact: bool, motor_rate: f64, hand_rate: f64) {
255        self.hand_contact = hand_contact;
256        self.motor_rate = finite_or_zero(motor_rate).clamp(-self.config.max_rate, self.config.max_rate);
257        if self.motor_rate != 0.0 {
258            self.ended = false;
259        }
260        if hand_contact {
261            self.target_position = self.position;
262            self.target_rate = self.map_rate(hand_rate);
263            self.frames_since_motion = 0;
264        } else {
265            self.target_position = self.position;
266        }
267    }
268
269    #[wasm_bindgen(js_name = setPosition)]
270    pub fn set_position(&mut self, position: f64, impulse: f64) {
271        self.position = self.clamp_source_position(position);
272        self.target_position = self.position;
273        self.last_output_samples.clear();
274        self.window_miss_frames = 0;
275        self.ended = false;
276        if impulse > 0.0 {
277            self.contact_impulse = self.contact_impulse.max(impulse).clamp(0.0, 1.0);
278        }
279    }
280
281    #[wasm_bindgen(js_name = resetPosition)]
282    pub fn reset_position_export(&mut self, position: f64) {
283        self.reset_position(position);
284    }
285
286    #[wasm_bindgen(js_name = render)]
287    pub fn render(&mut self, frame_count: u32, output_channel_count: u32) {
288        let frame_count = frame_count as usize;
289        let output_channel_count = (output_channel_count as usize).clamp(1, 2);
290        self.output.resize(frame_count.saturating_mul(output_channel_count), 0.0);
291        self.output.fill(0.0);
292        self.requested_window_position = None;
293        if frame_count == 0 {
294            return;
295        }
296        if !self.active || self.channels.is_empty() || self.total_frames <= 1 {
297            return;
298        }
299        self.drag_lowpass_state.resize(output_channel_count, 0.0);
300        self.last_output_samples.resize(output_channel_count, 0.0);
301        let dt = 1.0 / self.output_sample_rate;
302        let catchup_frames = (self.source_sample_rate * POSITION_CATCHUP_SECONDS).max(1.0);
303        let hold_frames = (self.output_sample_rate * MOTION_HOLD_SECONDS).max(1.0) as usize;
304        let hold_release_frames = (self.output_sample_rate * MOTION_HOLD_RELEASE_SECONDS).max(1.0);
305        let still_snap_alpha = 1.0 - (-1.0 / (self.output_sample_rate * STILL_SNAP_SECONDS)).exp();
306        let grip_target = if self.hand_contact { 1.0 } else { 0.0 };
307        let grip_seconds = if self.hand_contact { GRIP_ATTACK_SECONDS } else { GRIP_RELEASE_SECONDS };
308        let grip_alpha = 1.0 - (-1.0 / (self.output_sample_rate * grip_seconds)).exp();
309        let motor_spin_alpha = 1.0 - (-1.0 / (self.output_sample_rate * MOTOR_SPINUP_SECONDS)).exp();
310        let motor_brake_step = 1.0 / (self.output_sample_rate * MOTOR_BRAKE_SECONDS);
311        let rate_scale = self.source_sample_rate / self.output_sample_rate;
312        let miss_fade_frames = (self.output_sample_rate * WINDOW_MISS_FADE_SECONDS).round().max(1.0);
313
314        for frame in 0..frame_count {
315            self.frames_since_motion = self.frames_since_motion.saturating_add(1);
316            self.grip += (grip_target - self.grip) * grip_alpha;
317            if self.motor_rate.abs() > self.motor_delivered_rate.abs() {
318                self.motor_delivered_rate += (self.motor_rate - self.motor_delivered_rate) * motor_spin_alpha;
319            } else if self.motor_delivered_rate > self.motor_rate {
320                self.motor_delivered_rate = (self.motor_delivered_rate - motor_brake_step).max(self.motor_rate);
321            } else {
322                self.motor_delivered_rate = (self.motor_delivered_rate + motor_brake_step).min(self.motor_rate);
323            }
324            let hand_rate = if self.frames_since_motion > hold_frames {
325                self.target_rate * (-((self.frames_since_motion - hold_frames) as f64) / hold_release_frames).exp()
326            } else {
327                self.target_rate
328            };
329            let held_target_rate = self.motor_delivered_rate + self.grip * (hand_rate - self.motor_delivered_rate);
330            self.rate_velocity += (((held_target_rate - self.rate) * RATE_SPRING_OMEGA * RATE_SPRING_OMEGA)
331                - (2.0 * RATE_SPRING_ZETA * RATE_SPRING_OMEGA * self.rate_velocity)) * dt;
332            self.rate += self.rate_velocity * dt;
333            let position_error = self.target_position - self.position;
334            let mut correction_rate = ((position_error / catchup_frames) * self.grip).clamp(-0.12, 0.12);
335            if self.grip > GRIP_OWNERSHIP && hand_rate.abs() < DEADZONE_RATE && self.rate.abs() < DEADZONE_RATE {
336                self.position += position_error * still_snap_alpha;
337                correction_rate = 0.0;
338            }
339            let corrected_rate = self.rate + correction_rate;
340            let abs_rate = corrected_rate.abs();
341            let effective_rate = if self.config.acoustic_enabled {
342                corrected_rate
343                    + sign_nonzero(corrected_rate, held_target_rate)
344                        * self.advance_wow_flutter(corrected_rate, rate_scale, abs_rate)
345            } else {
346                corrected_rate
347            };
348            let movement_gain = compute_movement_gain(abs_rate);
349            let surface_noise = if self.config.surface_enabled { self.next_noise() } else { 0.0 };
350            let highpassed_noise = if self.config.surface_enabled { surface_noise - self.last_noise } else { 0.0 };
351            self.last_noise = surface_noise;
352            let near_realtime_distance = (abs_rate - 1.0).abs();
353            let realtime_acceleration_dip = 1.0
354                - 0.88 * (-(near_realtime_distance * near_realtime_distance) / 0.16).exp();
355            let rate_delta = (corrected_rate - self.last_effective_rate).abs();
356            let acceleration_noise = (rate_delta * 0.00028 * realtime_acceleration_dip).clamp(0.0, 0.0007);
357            let contact_noise_gain = compute_contact_noise_gain(abs_rate) + acceleration_noise;
358            let impulse_noise = if self.config.surface_enabled && self.contact_impulse > 0.0001 {
359                self.next_noise() * self.contact_impulse * 0.004
360            } else {
361                0.0
362            };
363            let groove_surface = if self.config.surface_enabled { self.compute_position_surface_noise(self.position, abs_rate) } else { 0.0 };
364            let source_texture_gain = if self.config.acoustic_enabled { compute_source_texture_gain(abs_rate, rate_delta) } else { 0.0 };
365            let dust_fleck = if self.config.surface_enabled { self.compute_dust_fleck(self.position, abs_rate) } else { 0.0 };
366            let contact_texture = if self.config.surface_enabled { (groove_surface * 0.76 + highpassed_noise * 0.18) * contact_noise_gain } else { 0.0 };
367            let source_direction = sign_nonzero(effective_rate, held_target_rate);
368            let drag_alpha = if self.config.acoustic_enabled { self.drag_lowpass_alpha(abs_rate) } else { 1.0 };
369            let miss_fade = if self.window_miss_frames > 0 {
370                (1.0 - self.window_miss_frames as f64 / miss_fade_frames).clamp(0.0, 1.0)
371            } else {
372                1.0
373            };
374            let mut missed_window = false;
375
376            for channel_index in 0..output_channel_count {
377                let output_index = frame * output_channel_count + channel_index;
378                if self.needle_lifted {
379                    self.output[output_index] = 0.0;
380                    continue;
381                }
382                let source_index = channel_index.min(self.channels.len() - 1);
383                let detail = self.sample_channel(source_index, self.position);
384                let (music, source_texture) = match detail {
385                    None => {
386                        missed_window = true;
387                        (self.last_output_samples[channel_index] * miss_fade, 0.0)
388                    }
389                    Some((sampled, slope, curvature)) => {
390                        let drag_state = self.drag_lowpass_state[channel_index];
391                        let filtered = drag_state + (sampled - drag_state) * drag_alpha;
392                        self.drag_lowpass_state[channel_index] = filtered;
393                        let music = filtered * movement_gain * OUTPUT_GAIN;
394                        self.last_output_samples[channel_index] = music;
395                        let texture = ((slope * 0.48 + curvature * 0.86) * source_direction)
396                            .clamp(-1.0, 1.0)
397                            * source_texture_gain;
398                        (music, texture)
399                    }
400                };
401                self.output[output_index] = (music + source_texture + contact_texture + dust_fleck + impulse_noise)
402                    .clamp(-1.0, 1.0) as f32;
403            }
404
405            self.position = self.clamp_source_position(self.position + effective_rate * rate_scale);
406            if self.grip < GRIP_OWNERSHIP {
407                self.target_position = self.position;
408                if !self.ended && self.motor_rate > 0.0 && self.position >= self.total_frames.saturating_sub(3) as f64 {
409                    self.ended = true;
410                    self.motor_rate = 0.0;
411                }
412            }
413            self.last_effective_rate = effective_rate;
414            self.contact_impulse *= CONTACT_IMPULSE_DECAY;
415            self.window_miss_frames = if missed_window {
416                self.window_miss_frames.saturating_add(1)
417            } else {
418                0
419            };
420        }
421        self.maybe_request_window(frame_count);
422    }
423
424    #[wasm_bindgen(js_name = renderWindowMissing)]
425    pub fn render_window_missing(&mut self, frame_count: u32, output_channel_count: u32) {
426        let frame_count = frame_count as usize;
427        let output_channel_count = (output_channel_count as usize).clamp(1, 2);
428        self.output.resize(frame_count.saturating_mul(output_channel_count), 0.0);
429        let fade_frames = (self.output_sample_rate * WINDOW_MISS_FADE_SECONDS).round().max(1.0);
430        self.last_output_samples.resize(output_channel_count, 0.0);
431        for frame in 0..frame_count {
432            let fade = (1.0 - self.window_miss_frames as f64 / fade_frames).clamp(0.0, 1.0);
433            for channel_index in 0..output_channel_count {
434                self.output[frame * output_channel_count + channel_index] =
435                    (self.last_output_samples[channel_index] * fade) as f32;
436            }
437            self.window_miss_frames = self.window_miss_frames.saturating_add(1);
438        }
439    }
440
441    #[wasm_bindgen(getter, js_name = outputPtr)]
442    pub fn output_ptr(&self) -> *const f32 {
443        self.output.as_ptr()
444    }
445
446    #[wasm_bindgen(getter, js_name = outputLen)]
447    pub fn output_len(&self) -> usize {
448        self.output.len()
449    }
450
451    #[wasm_bindgen(getter)]
452    pub fn position(&self) -> f64 {
453        self.position
454    }
455
456    #[wasm_bindgen(getter, js_name = effectiveRate)]
457    pub fn effective_rate(&self) -> f64 {
458        self.last_effective_rate
459    }
460
461    #[wasm_bindgen(js_name = takeWindowRequest)]
462    pub fn take_window_request(&mut self) -> f64 {
463        self.requested_window_position.take().unwrap_or(-1.0)
464    }
465
466    #[wasm_bindgen(js_name = takeEnded)]
467    pub fn take_ended(&mut self) -> bool {
468        let ended = self.ended;
469        self.ended = false;
470        ended
471    }
472}
473
474impl ScratchAcousticDsp {
475    fn reset_position(&mut self, position: f64) {
476        self.position = self.clamp_source_position(position);
477        self.target_position = self.position;
478        self.rate = 0.0;
479        self.rate_velocity = 0.0;
480        self.target_rate = 0.0;
481        self.motor_delivered_rate = 0.0;
482        self.last_effective_rate = 0.0;
483        self.frames_since_motion = 0;
484        self.last_output_samples.clear();
485        self.window_miss_frames = 0;
486    }
487
488    fn map_rate(&self, rate: f64) -> f64 {
489        if !rate.is_finite() || rate.abs() < DEADZONE_RATE {
490            0.0
491        } else {
492            rate.clamp(-self.config.max_rate, self.config.max_rate)
493        }
494    }
495
496    fn clamp_source_position(&self, position: f64) -> f64 {
497        position.clamp(0.0, self.total_frames.max(self.window_end).saturating_sub(2) as f64)
498    }
499
500    fn next_noise(&mut self) -> f64 {
501        self.noise_seed = self.noise_seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
502        self.noise_seed as f64 / 2_147_483_648.0 - 1.0
503    }
504
505    fn hash_noise(index: i64, salt: i32) -> f64 {
506        let mut value = (index as i32) ^ salt;
507        value = (value ^ ((value as u32 >> 16) as i32)).wrapping_mul(0x7feb_352d_u32 as i32);
508        value = (value ^ ((value as u32 >> 15) as i32)).wrapping_mul(0x846c_a68b_u32 as i32);
509        let unsigned = (value ^ ((value as u32 >> 16) as i32)) as u32;
510        unsigned as f64 / 2_147_483_648.0 - 1.0
511    }
512
513    fn position_noise(&self, position: f64, spacing: f64, salt: i32) -> f64 {
514        let scaled = position.max(0.0) / spacing.max(1.0);
515        let index = scaled.floor() as i64;
516        let t = scaled - index as f64;
517        let smooth = t * t * (3.0 - 2.0 * t);
518        let a = Self::hash_noise(index, salt);
519        let b = Self::hash_noise(index + 1, salt);
520        a + (b - a) * smooth
521    }
522
523    fn compute_position_surface_noise(&self, position: f64, abs_rate: f64) -> f64 {
524        if abs_rate <= DEADZONE_RATE {
525            return 0.0;
526        }
527        let speed_weight = (abs_rate / 2.4).clamp(0.14, 1.0);
528        let groove_grain = self.position_noise(position, 3.7, 0x0051_f15e);
529        let groove_bed = self.position_noise(position, 37.0, 0x002d_4a11);
530        (groove_grain * 0.72 + groove_bed * 0.22) * speed_weight
531    }
532
533    fn compute_dust_fleck(&self, position: f64, abs_rate: f64) -> f64 {
534        if abs_rate <= 0.03 {
535            return 0.0;
536        }
537        let cell_frames = (self.source_sample_rate * 0.12).round().max(1.0);
538        let cell = (position.max(0.0) / cell_frames).floor() as i64;
539        let chance = (Self::hash_noise(cell, 0x006d_2b79) + 1.0) * 0.5;
540        if chance < 0.996 {
541            return 0.0;
542        }
543        let center = (cell as f64 + 0.5 + Self::hash_noise(cell, 0x004f_1bbc) * 0.28) * cell_frames;
544        let width = cell_frames * 0.028;
545        let distance = (position - center).abs() / width.max(1.0);
546        if distance >= 1.0 {
547            return 0.0;
548        }
549        let envelope = (1.0 - distance).powi(2);
550        let speed_weight = (abs_rate / 1.4).clamp(0.12, 1.0);
551        Self::hash_noise(cell, 0x0073_c4d9) * envelope * speed_weight * DUST_FLECK_GAIN
552    }
553
554    fn sample_channel(&self, channel_index: usize, position: f64) -> Option<(f64, f64, f64)> {
555        let channel = self.channels.get(channel_index)?;
556        let local = position - self.window_start as f64;
557        if local < 0.0 || local >= channel.len().saturating_sub(1) as f64 {
558            return None;
559        }
560        let index = local.floor() as usize;
561        let t = local - index as f64;
562        let p0 = channel[index.saturating_sub(1)] as f64;
563        let p1 = channel[index] as f64;
564        let p2 = channel[(index + 1).min(channel.len() - 1)] as f64;
565        let p3 = channel[(index + 2).min(channel.len() - 1)] as f64;
566        let a = p2 - p0;
567        let b = 2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3;
568        let c = 3.0 * (p1 - p2) + p3 - p0;
569        let slope = 0.5 * (a + 2.0 * b * t + 3.0 * c * t * t);
570        let curvature = (p0 - 2.0 * p1 + p2) * (1.0 - t) + (p1 - 2.0 * p2 + p3) * t;
571        let sample = p1 + 0.5 * t * (a + t * (b + t * c));
572        Some((sample, slope, curvature))
573    }
574
575    fn advance_wow_flutter(&mut self, corrected_rate: f64, rate_scale: f64, abs_rate: f64) -> f64 {
576        if self.source_sample_rate <= 0.0 {
577            return 0.0;
578        }
579        let frames_per_rev = self.config.wow_rev_seconds * self.source_sample_rate;
580        self.wow_phase += corrected_rate * rate_scale / frames_per_rev;
581        self.flutter_phase += self.config.flutter_hz / self.output_sample_rate * abs_rate.clamp(0.0, 1.4);
582        if abs_rate <= 0.18 {
583            return 0.0;
584        }
585        let depth = abs_rate.clamp(0.0, 1.2) * 0.0012;
586        (self.wow_phase * std::f64::consts::TAU).sin() * depth
587            + (self.flutter_phase * std::f64::consts::TAU).sin() * depth * 0.22
588    }
589
590    fn drag_lowpass_alpha(&self, abs_rate: f64) -> f64 {
591        let speed = (abs_rate / DRAG_LOWPASS_RATE_KNEE).clamp(0.045, 1.0);
592        let mut cutoff = DRAG_LOWPASS_MAX_HZ * speed.powf(1.3);
593        if abs_rate > TRACING_LOSS_START_RATE {
594            cutoff *= (TRACING_LOSS_START_RATE / abs_rate).clamp(0.55, 1.0);
595        }
596        1.0 - (-std::f64::consts::TAU * cutoff / self.output_sample_rate).exp()
597    }
598
599    fn maybe_request_window(&mut self, frame_count: usize) {
600        self.frames_since_window_request = self.frames_since_window_request.saturating_add(frame_count);
601        let speed = self.last_effective_rate.abs().max(1.0);
602        let throttle = if speed > 2.0 { 0.03 } else { 0.08 };
603        if self.frames_since_window_request < (self.output_sample_rate * throttle) as usize || self.channels.is_empty() {
604            return;
605        }
606        let margin = (WINDOW_REQUEST_MARGIN_SECONDS * self.source_sample_rate * (speed * 0.5).max(1.0)).max(256.0);
607        let projected = self.clamp_source_position(
608            self.position + self.last_effective_rate * self.source_sample_rate * WINDOW_REQUEST_PROJECT_SECONDS,
609        );
610        let request = if self.last_effective_rate < 0.0 {
611            self.position.min(projected)
612        } else {
613            self.position.max(projected)
614        };
615        let start = self.window_start as f64;
616        let end = self.window_end as f64;
617        if self.position < start + margin
618            || self.position > end - margin
619            || request < start + margin
620            || request > end - margin
621        {
622            self.frames_since_window_request = 0;
623            self.requested_window_position = Some(request);
624        }
625    }
626}
627
628fn finite_or_zero(value: f64) -> f64 {
629    if value.is_finite() { value } else { 0.0 }
630}
631
632fn sign_nonzero(primary: f64, fallback: f64) -> f64 {
633    if primary != 0.0 {
634        primary.signum()
635    } else if fallback != 0.0 {
636        fallback.signum()
637    } else {
638        1.0
639    }
640}
641
642fn compute_movement_gain(abs_rate: f64) -> f64 {
643    if abs_rate <= DEADZONE_RATE {
644        return 0.0;
645    }
646    let normalized = abs_rate.clamp(0.0, 10.0);
647    let realtime_presence = (-((normalized - 1.0) / 0.38).powi(2)).exp();
648    let underspeed = 0.78 + 0.22 * normalized.max(DEADZONE_RATE).powf(0.1);
649    let overspeed = 1.0 + (normalized - 1.0).max(0.0) * 0.014;
650    let acoustic = if normalized <= 1.0 { underspeed } else { overspeed };
651    (acoustic + realtime_presence * 0.025).clamp(0.68, 1.08)
652}
653
654fn compute_contact_noise_gain(abs_rate: f64) -> f64 {
655    if abs_rate <= DEADZONE_RATE {
656        return 0.0;
657    }
658    let distance = (abs_rate - 1.0).abs();
659    let realtime_dip = 1.0 - 0.94 * (-(distance * distance) / 0.18).exp();
660    let slow_rub = ((0.26 - abs_rate) / 0.26).clamp(0.0, 1.0) * 0.36;
661    let fast_friction = ((abs_rate - 2.2) / 5.5).clamp(0.0, 1.0) * 0.72;
662    CONTACT_NOISE_GAIN * realtime_dip * (0.24 + slow_rub + fast_friction).clamp(0.08, 1.08)
663}
664
665fn compute_source_texture_gain(abs_rate: f64, rate_delta: f64) -> f64 {
666    if abs_rate <= DEADZONE_RATE {
667        return 0.0;
668    }
669    let distance = (abs_rate - 1.0).abs();
670    let realtime_dip = 1.0 - 0.72 * (-(distance * distance) / 0.14).exp();
671    let slow_rub = ((0.42 - abs_rate) / 0.42).clamp(0.0, 1.0);
672    let speed_lift = (abs_rate / 2.2).clamp(0.0, 1.0);
673    let acceleration_lift = (rate_delta / 1.6).clamp(0.0, 1.0);
674    SOURCE_TEXTURE_GAIN
675        * realtime_dip
676        * (0.18 + slow_rub * 0.72 + speed_lift * 0.28 + acceleration_lift * 0.38)
677}
678
679#[cfg(test)]
680mod tests {
681    use super::*;
682
683    #[test]
684    fn movement_gain_is_silent_in_deadzone() {
685        assert_eq!(compute_movement_gain(DEADZONE_RATE * 0.5), 0.0);
686    }
687
688    #[test]
689    fn movement_gain_stays_bounded() {
690        for rate in [0.01, 0.1, 1.0, 3.0, 10.0] {
691            let gain = compute_movement_gain(rate);
692            assert!((0.0..=1.08).contains(&gain));
693        }
694    }
695
696    #[test]
697    fn deterministic_hash_noise_is_stable() {
698        assert_eq!(ScratchAcousticDsp::hash_noise(42, 7), ScratchAcousticDsp::hash_noise(42, 7));
699        assert_ne!(ScratchAcousticDsp::hash_noise(42, 7), ScratchAcousticDsp::hash_noise(43, 7));
700    }
701}
702
703#[derive(Clone, Copy, Debug, Deserialize, Serialize)]
704#[serde(rename_all = "camelCase")]
705pub struct CalibrationAnchor {
706    pub sample: f64,
707    pub radial: f64,
708}
709
710#[derive(Clone, Debug)]
711struct MonotoneInterpolant {
712    xs: Vec<f64>,
713    ys: Vec<f64>,
714    widths: Vec<f64>,
715    tangents: Vec<f64>,
716}
717
718impl MonotoneInterpolant {
719    fn new(xs: Vec<f64>, ys: Vec<f64>) -> Result<Self, String> {
720        if xs.len() != ys.len() {
721            return Err("monotone interpolant requires equal-length xs/ys".to_owned());
722        }
723        if xs.len() < 2 {
724            return Err("monotone interpolant requires at least two anchors".to_owned());
725        }
726        for index in 1..xs.len() {
727            if !xs[index].is_finite() || xs[index] <= xs[index - 1] {
728                return Err("monotone interpolant requires strictly increasing xs".to_owned());
729            }
730            if !ys[index].is_finite() || ys[index] <= ys[index - 1] {
731                return Err("monotone interpolant requires strictly increasing ys".to_owned());
732            }
733        }
734        let widths = xs.windows(2).map(|pair| pair[1] - pair[0]).collect::<Vec<_>>();
735        let deltas = ys
736            .windows(2)
737            .zip(widths.iter())
738            .map(|(pair, width)| (pair[1] - pair[0]) / width)
739            .collect::<Vec<_>>();
740        let mut tangents = vec![0.0; xs.len()];
741        for index in 1..xs.len() - 1 {
742            if deltas[index - 1] * deltas[index] <= 0.0 {
743                tangents[index] = 0.0;
744            } else {
745                let w1 = 2.0 * widths[index] + widths[index - 1];
746                let w2 = widths[index] + 2.0 * widths[index - 1];
747                tangents[index] = (w1 + w2) / (w1 / deltas[index - 1] + w2 / deltas[index]);
748            }
749        }
750        tangents[0] = endpoint_slope(
751            widths[0],
752            widths.get(1).copied(),
753            deltas[0],
754            deltas.get(1).copied(),
755        );
756        let last = xs.len() - 1;
757        tangents[last] = endpoint_slope(
758            widths[last - 1],
759            last.checked_sub(2).and_then(|index| widths.get(index).copied()),
760            deltas[last - 1],
761            last.checked_sub(2).and_then(|index| deltas.get(index).copied()),
762        );
763        Ok(Self {
764            xs,
765            ys,
766            widths,
767            tangents,
768        })
769    }
770
771    fn segment_for_x(&self, value: f64) -> usize {
772        if value <= self.xs[0] {
773            return 0;
774        }
775        if value >= self.xs[self.xs.len() - 1] {
776            return self.xs.len() - 2;
777        }
778        self.xs.partition_point(|candidate| *candidate <= value).saturating_sub(1)
779    }
780
781    fn segment_for_y(&self, value: f64) -> usize {
782        if value <= self.ys[0] {
783            return 0;
784        }
785        if value >= self.ys[self.ys.len() - 1] {
786            return self.ys.len() - 2;
787        }
788        self.ys.partition_point(|candidate| *candidate <= value).saturating_sub(1)
789    }
790
791    fn hermite(&self, index: usize, t: f64) -> f64 {
792        let t2 = t * t;
793        let t3 = t2 * t;
794        let h00 = 2.0 * t3 - 3.0 * t2 + 1.0;
795        let h10 = t3 - 2.0 * t2 + t;
796        let h01 = -2.0 * t3 + 3.0 * t2;
797        let h11 = t3 - t2;
798        h00 * self.ys[index]
799            + h10 * self.widths[index] * self.tangents[index]
800            + h01 * self.ys[index + 1]
801            + h11 * self.widths[index] * self.tangents[index + 1]
802    }
803
804    fn evaluate(&self, value: f64) -> f64 {
805        if value <= self.xs[0] {
806            return self.ys[0];
807        }
808        if value >= self.xs[self.xs.len() - 1] {
809            return self.ys[self.ys.len() - 1];
810        }
811        let index = self.segment_for_x(value);
812        let t = (value - self.xs[index]) / self.widths[index];
813        self.hermite(index, t)
814    }
815
816    fn evaluate_inverse(&self, value: f64) -> f64 {
817        if value <= self.ys[0] {
818            return self.xs[0];
819        }
820        if value >= self.ys[self.ys.len() - 1] {
821            return self.xs[self.xs.len() - 1];
822        }
823        let index = self.segment_for_y(value);
824        let mut low = 0.0;
825        let mut high = 1.0;
826        for _ in 0..40 {
827            let mid = (low + high) * 0.5;
828            if self.hermite(index, mid) < value {
829                low = mid;
830            } else {
831                high = mid;
832            }
833        }
834        self.xs[index] + (low + high) * 0.5 * self.widths[index]
835    }
836}
837
838fn endpoint_slope(ha: f64, hb: Option<f64>, da: f64, db: Option<f64>) -> f64 {
839    let (Some(hb), Some(db)) = (hb, db) else {
840        return da;
841    };
842    let slope = ((2.0 * ha + hb) * da - ha * db) / (ha + hb);
843    if slope.signum() != da.signum() {
844        return 0.0;
845    }
846    if da.signum() != db.signum() && slope.abs() > (3.0 * da).abs() {
847        return 3.0 * da;
848    }
849    slope
850}
851
852#[wasm_bindgen]
853pub struct StylusCalibration {
854    total_samples: f64,
855    interpolant: Option<MonotoneInterpolant>,
856}
857
858#[wasm_bindgen]
859impl StylusCalibration {
860    #[wasm_bindgen(constructor)]
861    pub fn new(total_samples: f64, anchors: JsValue) -> Result<StylusCalibration, JsValue> {
862        if !total_samples.is_finite() || total_samples <= 0.0 {
863            return Err(JsValue::from_str("totalSamples must be positive"));
864        }
865        let anchors: Vec<CalibrationAnchor> = serde_wasm_bindgen::from_value(anchors)
866            .map_err(|error| JsValue::from_str(&error.to_string()))?;
867        let interpolant = if anchors.is_empty() {
868            None
869        } else {
870            validate_anchors(total_samples, &anchors).map_err(|error| JsValue::from_str(&error))?;
871            Some(
872                MonotoneInterpolant::new(
873                    anchors.iter().map(|anchor| anchor.sample).collect(),
874                    anchors.iter().map(|anchor| anchor.radial).collect(),
875                )
876                .map_err(|error| JsValue::from_str(&error))?,
877            )
878        };
879        Ok(Self {
880            total_samples,
881            interpolant,
882        })
883    }
884
885    #[wasm_bindgen(getter, js_name = hasGaps)]
886    pub fn has_gaps(&self) -> bool {
887        self.interpolant.is_some()
888    }
889
890    #[wasm_bindgen(getter, js_name = totalSamples)]
891    pub fn total_samples(&self) -> f64 {
892        self.total_samples
893    }
894
895    #[wasm_bindgen(js_name = sampleToGroove)]
896    pub fn sample_to_groove(&self, sample: f64) -> f64 {
897        let sample = sample.clamp(0.0, self.total_samples);
898        self.interpolant
899            .as_ref()
900            .map(|interpolant| interpolant.evaluate(sample).clamp(0.0, 1.0))
901            .unwrap_or_else(|| (sample / self.total_samples).clamp(0.0, 1.0))
902    }
903
904    #[wasm_bindgen(js_name = grooveToSample)]
905    pub fn groove_to_sample(&self, groove: f64) -> f64 {
906        let groove = groove.clamp(0.0, 1.0);
907        self.interpolant
908            .as_ref()
909            .map(|interpolant| interpolant.evaluate_inverse(groove).clamp(0.0, self.total_samples))
910            .unwrap_or(groove * self.total_samples)
911    }
912}
913
914fn validate_anchors(total_samples: f64, anchors: &[CalibrationAnchor]) -> Result<(), String> {
915    if anchors.len() < 2 {
916        return Err("calibration requires at least two anchors".to_owned());
917    }
918    if anchors[0].sample != 0.0 || anchors[0].radial != 0.0 {
919        return Err("calibration must begin at sample 0 and radial 0".to_owned());
920    }
921    let last = anchors[anchors.len() - 1];
922    if last.sample != total_samples || last.radial != 1.0 {
923        return Err("calibration must end at totalSamples and radial 1".to_owned());
924    }
925    for pair in anchors.windows(2) {
926        if !pair[0].sample.is_finite()
927            || !pair[1].sample.is_finite()
928            || pair[1].sample <= pair[0].sample
929        {
930            return Err("sample anchors must be strictly increasing".to_owned());
931        }
932        if !pair[0].radial.is_finite()
933            || !pair[1].radial.is_finite()
934            || pair[1].radial <= pair[0].radial
935        {
936            return Err("radial anchors must be strictly increasing".to_owned());
937        }
938    }
939    Ok(())
940}
941
942#[derive(Clone, Copy, Debug, Deserialize)]
943#[serde(rename_all = "camelCase")]
944pub struct ScratchConfig {
945    pub max_playback_rate: f64,
946    pub deadzone_rate: f64,
947    pub lock_center_rate: f64,
948    pub lock_width: f64,
949    pub lock_strength: f64,
950    pub pointer_filter_seconds: f64,
951}
952
953#[derive(Clone, Copy, Debug, Serialize)]
954#[serde(rename_all = "camelCase")]
955pub struct ScratchMotion {
956    pub delta_angle_radians: f64,
957    pub rotation_degrees: f64,
958    pub current_time: f64,
959    pub sample_position: f64,
960    pub raw_playback_rate: f64,
961    pub filtered_playback_rate: f64,
962    pub physical_playback_rate: f64,
963}
964
965#[derive(Clone, Copy, Debug, Serialize)]
966#[serde(rename_all = "camelCase")]
967pub struct ScratchWindowPlan {
968    pub start: u32,
969    pub end: u32,
970    pub length: u32,
971    pub needs_update: bool,
972}
973
974#[wasm_bindgen]
975pub struct ScratchSimulation {
976    config: ScratchConfig,
977    active: bool,
978    pointer_id: i32,
979    last_angle: f64,
980    last_time_ms: f64,
981    filtered_pointer_rate: f64,
982    current_time: f64,
983    sample_position: f64,
984    rotation_degrees: f64,
985}
986
987#[wasm_bindgen]
988impl ScratchSimulation {
989    #[wasm_bindgen(constructor)]
990    pub fn new(config: JsValue) -> Result<ScratchSimulation, JsValue> {
991        let config: ScratchConfig = serde_wasm_bindgen::from_value(config)
992            .map_err(|error| JsValue::from_str(&error.to_string()))?;
993        validate_scratch_config(config).map_err(|error| JsValue::from_str(&error))?;
994        Ok(Self {
995            config,
996            active: false,
997            pointer_id: -1,
998            last_angle: 0.0,
999            last_time_ms: 0.0,
1000            filtered_pointer_rate: 0.0,
1001            current_time: 0.0,
1002            sample_position: 0.0,
1003            rotation_degrees: 0.0,
1004        })
1005    }
1006
1007    #[wasm_bindgen(js_name = begin)]
1008    pub fn begin(
1009        &mut self,
1010        pointer_id: i32,
1011        angle_radians: f64,
1012        time_ms: f64,
1013        current_time: f64,
1014        rotation_degrees: f64,
1015        sample_rate: f64,
1016        duration: f64,
1017    ) -> Result<(), JsValue> {
1018        validate_motion_inputs(angle_radians, time_ms, sample_rate, duration)?;
1019        self.active = true;
1020        self.pointer_id = pointer_id;
1021        self.last_angle = angle_radians;
1022        self.last_time_ms = time_ms;
1023        self.filtered_pointer_rate = 0.0;
1024        self.current_time = current_time.clamp(0.0, duration);
1025        self.sample_position = (self.current_time * sample_rate).clamp(0.0, duration * sample_rate);
1026        self.rotation_degrees = rotation_degrees;
1027        Ok(())
1028    }
1029
1030    #[wasm_bindgen(js_name = update)]
1031    pub fn update(
1032        &mut self,
1033        pointer_id: i32,
1034        angle_radians: f64,
1035        time_ms: f64,
1036        duration: f64,
1037        sample_rate: f64,
1038        seconds_per_turn: f64,
1039        needle_lifted: bool,
1040    ) -> Result<JsValue, JsValue> {
1041        if !self.active || self.pointer_id != pointer_id {
1042            return Err(JsValue::from_str("scratch pointer is not active"));
1043        }
1044        validate_motion_inputs(angle_radians, time_ms, sample_rate, duration)?;
1045        if !seconds_per_turn.is_finite() || seconds_per_turn <= 0.0 {
1046            return Err(JsValue::from_str("secondsPerTurn must be positive"));
1047        }
1048        let delta_angle = normalize_angle_delta(angle_radians - self.last_angle);
1049        let elapsed_seconds = ((time_ms - self.last_time_ms).max(1.0) / 1000.0).max(0.004);
1050        self.last_angle = angle_radians;
1051        self.last_time_ms = time_ms;
1052        self.rotation_degrees += delta_angle.to_degrees();
1053        let mut raw_playback_rate = 0.0;
1054        let mut physical_playback_rate = 0.0;
1055        if !needle_lifted {
1056            let mapped_delta_seconds = delta_angle / std::f64::consts::TAU * seconds_per_turn;
1057            self.current_time = (self.current_time + mapped_delta_seconds).clamp(0.0, duration);
1058            raw_playback_rate = mapped_delta_seconds / elapsed_seconds;
1059            let alpha = 1.0 - (-elapsed_seconds / self.config.pointer_filter_seconds).exp();
1060            self.filtered_pointer_rate +=
1061                (raw_playback_rate - self.filtered_pointer_rate) * alpha;
1062            physical_playback_rate = map_physical_playback_rate(self.filtered_pointer_rate, self.config);
1063            self.sample_position = (self.current_time * sample_rate).clamp(0.0, duration * sample_rate);
1064        }
1065        serde_wasm_bindgen::to_value(&ScratchMotion {
1066            delta_angle_radians: delta_angle,
1067            rotation_degrees: self.rotation_degrees,
1068            current_time: self.current_time,
1069            sample_position: self.sample_position,
1070            raw_playback_rate,
1071            filtered_playback_rate: self.filtered_pointer_rate,
1072            physical_playback_rate,
1073        })
1074        .map_err(|error| JsValue::from_str(&error.to_string()))
1075    }
1076
1077    #[wasm_bindgen(js_name = finish)]
1078    pub fn finish(&mut self) {
1079        self.active = false;
1080        self.pointer_id = -1;
1081        self.filtered_pointer_rate = 0.0;
1082    }
1083
1084    #[wasm_bindgen(js_name = mapPhysicalPlaybackRate)]
1085    pub fn map_physical_playback_rate(&self, playback_rate: f64) -> f64 {
1086        map_physical_playback_rate(playback_rate, self.config)
1087    }
1088
1089    #[wasm_bindgen(js_name = planWindow)]
1090    pub fn plan_window(
1091        &self,
1092        center_sample_position: f64,
1093        frame_length: u32,
1094        window_frames: u32,
1095        current_window_start: u32,
1096        current_window_end: u32,
1097        margin_frames: u32,
1098        force: bool,
1099    ) -> Result<JsValue, JsValue> {
1100        let frame_length = frame_length.max(1);
1101        let window_frames = window_frames.max(1).min(frame_length);
1102        let center = center_sample_position
1103            .round()
1104            .clamp(0.0, f64::from(frame_length.saturating_sub(1))) as u32;
1105        let half = window_frames / 2;
1106        let max_start = frame_length.saturating_sub(window_frames);
1107        let start = center.saturating_sub(half).min(max_start);
1108        let end = start.saturating_add(window_frames).min(frame_length);
1109        let needs_update = force
1110            || center <= current_window_start.saturating_add(margin_frames)
1111            || center >= current_window_end.saturating_sub(margin_frames);
1112        serde_wasm_bindgen::to_value(&ScratchWindowPlan {
1113            start,
1114            end,
1115            length: end.saturating_sub(start),
1116            needs_update,
1117        })
1118        .map_err(|error| JsValue::from_str(&error.to_string()))
1119    }
1120}
1121
1122fn validate_scratch_config(config: ScratchConfig) -> Result<(), String> {
1123    if !config.max_playback_rate.is_finite() || config.max_playback_rate <= 0.0 {
1124        return Err("maxPlaybackRate must be positive".to_owned());
1125    }
1126    if !config.deadzone_rate.is_finite() || config.deadzone_rate < 0.0 {
1127        return Err("deadzoneRate must be non-negative".to_owned());
1128    }
1129    if !config.lock_center_rate.is_finite() || config.lock_center_rate < 0.0 {
1130        return Err("lockCenterRate must be non-negative".to_owned());
1131    }
1132    if !config.lock_width.is_finite() || config.lock_width <= 0.0 {
1133        return Err("lockWidth must be positive".to_owned());
1134    }
1135    if !config.lock_strength.is_finite() || !(0.0..=1.0).contains(&config.lock_strength) {
1136        return Err("lockStrength must be between 0 and 1".to_owned());
1137    }
1138    if !config.pointer_filter_seconds.is_finite() || config.pointer_filter_seconds <= 0.0 {
1139        return Err("pointerFilterSeconds must be positive".to_owned());
1140    }
1141    Ok(())
1142}
1143
1144fn validate_motion_inputs(
1145    angle_radians: f64,
1146    time_ms: f64,
1147    sample_rate: f64,
1148    duration: f64,
1149) -> Result<(), JsValue> {
1150    if !angle_radians.is_finite() {
1151        return Err(JsValue::from_str("angleRadians must be finite"));
1152    }
1153    if !time_ms.is_finite() {
1154        return Err(JsValue::from_str("timeMs must be finite"));
1155    }
1156    if !sample_rate.is_finite() || sample_rate <= 0.0 {
1157        return Err(JsValue::from_str("sampleRate must be positive"));
1158    }
1159    if !duration.is_finite() || duration < 0.0 {
1160        return Err(JsValue::from_str("duration must be non-negative"));
1161    }
1162    Ok(())
1163}
1164
1165fn normalize_angle_delta(delta: f64) -> f64 {
1166    let mut normalized = delta;
1167    while normalized > std::f64::consts::PI {
1168        normalized -= std::f64::consts::TAU;
1169    }
1170    while normalized < -std::f64::consts::PI {
1171        normalized += std::f64::consts::TAU;
1172    }
1173    normalized
1174}
1175
1176fn map_physical_playback_rate(playback_rate: f64, config: ScratchConfig) -> f64 {
1177    if !playback_rate.is_finite() || playback_rate.abs() < config.deadzone_rate {
1178        return 0.0;
1179    }
1180    let direction = playback_rate.signum();
1181    let magnitude = playback_rate.abs();
1182    let lock_distance = (magnitude - config.lock_center_rate).abs();
1183    let lock_amount = (-(lock_distance / config.lock_width).powi(2)).exp() * config.lock_strength;
1184    let stabilized = magnitude + (config.lock_center_rate - magnitude) * lock_amount;
1185    (direction * stabilized).clamp(-config.max_playback_rate, config.max_playback_rate)
1186}
1187
1188#[cfg(test)]
1189mod tests {
1190    use super::*;
1191    use approx::assert_abs_diff_eq;
1192
1193    #[test]
1194    fn monotone_mapping_round_trips() {
1195        let interpolant = MonotoneInterpolant::new(
1196            vec![0.0, 100.0, 200.0, 300.0],
1197            vec![0.0, 0.2, 0.8, 1.0],
1198        )
1199        .unwrap();
1200        for sample in [0.0, 25.0, 100.0, 175.0, 250.0, 300.0] {
1201            let radial = interpolant.evaluate(sample);
1202            assert_abs_diff_eq!(interpolant.evaluate_inverse(radial), sample, epsilon = 1e-8);
1203        }
1204    }
1205
1206    #[test]
1207    fn playback_rate_deadzone_and_lock_are_preserved() {
1208        let config = ScratchConfig {
1209            max_playback_rate: 4.0,
1210            deadzone_rate: 0.02,
1211            lock_center_rate: 1.0,
1212            lock_width: 0.1,
1213            lock_strength: 0.5,
1214            pointer_filter_seconds: 0.035,
1215        };
1216        assert_eq!(map_physical_playback_rate(0.01, config), 0.0);
1217        assert_abs_diff_eq!(map_physical_playback_rate(1.0, config), 1.0, epsilon = 1e-12);
1218        assert_eq!(map_physical_playback_rate(10.0, config), 4.0);
1219    }
1220}