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}