use serde::{Deserialize, Serialize};
use wasm_bindgen::prelude::*;
const OUTPUT_GAIN: f64 = 1.0;
const RATE_SPRING_OMEGA: f64 = 70.0;
const RATE_SPRING_ZETA: f64 = 0.85;
const POSITION_CATCHUP_SECONDS: f64 = 0.28;
const MOTION_HOLD_SECONDS: f64 = 0.05;
const MOTION_HOLD_RELEASE_SECONDS: f64 = 0.06;
const GRIP_ATTACK_SECONDS: f64 = 0.1;
const GRIP_RELEASE_SECONDS: f64 = 0.045;
const MOTOR_SPINUP_SECONDS: f64 = 0.3;
const MOTOR_BRAKE_SECONDS: f64 = 0.32;
const GRIP_OWNERSHIP: f64 = 0.5;
const STILL_SNAP_SECONDS: f64 = 0.03;
const DEADZONE_RATE: f64 = 0.006;
const DRAG_LOWPASS_MAX_HZ: f64 = 19_000.0;
const DRAG_LOWPASS_RATE_KNEE: f64 = 0.95;
const TRACING_LOSS_START_RATE: f64 = 2.5;
const WOW_REV_SECONDS: f64 = 1.8;
const FLUTTER_HZ: f64 = 6.4;
const CONTACT_NOISE_GAIN: f64 = 0.00008;
const SOURCE_TEXTURE_GAIN: f64 = 0.00018;
const DUST_FLECK_GAIN: f64 = 0.000045;
const CONTACT_IMPULSE_DECAY: f64 = 0.985;
const WINDOW_REQUEST_MARGIN_SECONDS: f64 = 0.75;
const WINDOW_REQUEST_PROJECT_SECONDS: f64 = 0.18;
const WINDOW_MISS_FADE_SECONDS: f64 = 0.006;
#[derive(Clone, Copy, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AcousticConfig {
#[serde(default = "default_max_rate")]
pub max_rate: f64,
#[serde(default = "default_wow_rev_seconds")]
pub wow_rev_seconds: f64,
#[serde(default = "default_flutter_hz")]
pub flutter_hz: f64,
#[serde(default = "default_true")]
pub acoustic_enabled: bool,
#[serde(default = "default_true")]
pub surface_enabled: bool,
}
fn default_max_rate() -> f64 { 10.0 }
fn default_wow_rev_seconds() -> f64 { WOW_REV_SECONDS }
fn default_flutter_hz() -> f64 { FLUTTER_HZ }
fn default_true() -> bool { true }
impl Default for AcousticConfig {
fn default() -> Self {
Self {
max_rate: default_max_rate(),
wow_rev_seconds: default_wow_rev_seconds(),
flutter_hz: default_flutter_hz(),
acoustic_enabled: true,
surface_enabled: true,
}
}
}
#[derive(Clone, Copy, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AcousticStatus {
pub position: f64,
pub effective_rate: f64,
pub requested_window_position: Option<f64>,
pub ended: bool,
pub output_length: usize,
}
#[wasm_bindgen]
pub struct ScratchAcousticDsp {
config: AcousticConfig,
output_sample_rate: f64,
source_sample_rate: f64,
channels: Vec<Vec<f32>>,
total_frames: usize,
window_start: usize,
window_end: usize,
position: f64,
target_position: f64,
rate: f64,
rate_velocity: f64,
target_rate: f64,
wow_phase: f64,
flutter_phase: f64,
drag_lowpass_state: Vec<f64>,
active: bool,
needle_lifted: bool,
hand_contact: bool,
grip: f64,
motor_rate: f64,
motor_delivered_rate: f64,
ended: bool,
contact_impulse: f64,
last_effective_rate: f64,
noise_seed: u32,
last_noise: f64,
last_output_samples: Vec<f64>,
window_miss_frames: usize,
frames_since_motion: usize,
frames_since_window_request: usize,
output: Vec<f32>,
requested_window_position: Option<f64>,
}
#[wasm_bindgen]
impl ScratchAcousticDsp {
#[wasm_bindgen(constructor)]
pub fn new(output_sample_rate: f64, config: JsValue) -> Result<ScratchAcousticDsp, JsValue> {
if !output_sample_rate.is_finite() || output_sample_rate <= 0.0 {
return Err(JsValue::from_str("outputSampleRate must be positive"));
}
let config = if config.is_null() || config.is_undefined() {
AcousticConfig::default()
} else {
serde_wasm_bindgen::from_value(config)
.map_err(|error| JsValue::from_str(&error.to_string()))?
};
if !config.max_rate.is_finite() || config.max_rate <= 0.0 {
return Err(JsValue::from_str("maxRate must be positive"));
}
Ok(Self {
config,
output_sample_rate,
source_sample_rate: 48_000.0,
channels: Vec::new(),
total_frames: 0,
window_start: 0,
window_end: 0,
position: 0.0,
target_position: 0.0,
rate: 0.0,
rate_velocity: 0.0,
target_rate: 0.0,
wow_phase: 0.0,
flutter_phase: 0.0,
drag_lowpass_state: Vec::new(),
active: false,
needle_lifted: false,
hand_contact: false,
grip: 0.0,
motor_rate: 0.0,
motor_delivered_rate: 0.0,
ended: false,
contact_impulse: 0.0,
last_effective_rate: 0.0,
noise_seed: 0x9e37_79b9,
last_noise: 0.0,
last_output_samples: Vec::new(),
window_miss_frames: 0,
frames_since_motion: output_sample_rate as usize,
frames_since_window_request: output_sample_rate as usize,
output: Vec::new(),
requested_window_position: None,
})
}
#[wasm_bindgen(js_name = setWindow)]
pub fn set_window(
&mut self,
channels: JsValue,
source_sample_rate: f64,
window_start: u32,
total_frames: u32,
reset_position: Option<f64>,
) -> Result<(), JsValue> {
if !source_sample_rate.is_finite() || source_sample_rate <= 0.0 {
return Err(JsValue::from_str("sourceSampleRate must be positive"));
}
let channels: Vec<Vec<f32>> = serde_wasm_bindgen::from_value(channels)
.map_err(|error| JsValue::from_str(&error.to_string()))?;
if channels.is_empty() || channels[0].is_empty() {
return Err(JsValue::from_str("at least one non-empty source channel is required"));
}
let length = channels[0].len();
if channels.iter().any(|channel| channel.len() != length) {
return Err(JsValue::from_str("source channels must have equal lengths"));
}
self.source_sample_rate = source_sample_rate;
self.window_start = window_start as usize;
self.window_end = self.window_start.saturating_add(length);
self.total_frames = (total_frames as usize).max(self.window_end);
self.channels = channels;
if let Some(position) = reset_position {
self.reset_position(position);
}
Ok(())
}
#[wasm_bindgen(js_name = clearWindow)]
pub fn clear_window(&mut self) {
self.channels.clear();
self.total_frames = 0;
self.window_start = 0;
self.window_end = 0;
self.reset_position(0.0);
}
#[wasm_bindgen(js_name = start)]
pub fn start(&mut self) {
self.active = true;
self.grip = 0.0;
self.motor_delivered_rate = 0.0;
self.hand_contact = true;
self.position = self.clamp_source_position(self.position.max(self.target_position));
self.target_position = self.position;
self.rate = 0.0;
self.rate_velocity = 0.0;
self.target_rate = 0.0;
self.last_effective_rate = 0.0;
self.frames_since_motion = 0;
self.contact_impulse = 0.0;
self.last_output_samples.clear();
self.window_miss_frames = 0;
self.ended = false;
}
#[wasm_bindgen(js_name = stop)]
pub fn stop(&mut self) {
self.active = false;
self.target_rate = 0.0;
self.contact_impulse = 0.0;
self.last_effective_rate = 0.0;
}
#[wasm_bindgen(js_name = setEffects)]
pub fn set_effects(&mut self, acoustic_enabled: bool, surface_enabled: bool) {
self.config.acoustic_enabled = acoustic_enabled;
self.config.surface_enabled = surface_enabled;
if !surface_enabled {
self.contact_impulse = 0.0;
self.last_noise = 0.0;
}
}
#[wasm_bindgen(js_name = setNeedleLifted)]
pub fn set_needle_lifted(&mut self, lifted: bool) {
self.needle_lifted = lifted;
}
#[wasm_bindgen(js_name = setMotion)]
pub fn set_motion(&mut self, position: f64, rate: f64, impulse: f64) {
self.target_position = self.clamp_source_position(position);
self.target_rate = self.map_rate(rate);
self.frames_since_motion = 0;
if impulse > 0.0 {
self.contact_impulse = self.contact_impulse.max(impulse).clamp(0.0, 1.0);
}
}
#[wasm_bindgen(js_name = setTransport)]
pub fn set_transport(&mut self, hand_contact: bool, motor_rate: f64, hand_rate: f64) {
self.hand_contact = hand_contact;
self.motor_rate = finite_or_zero(motor_rate).clamp(-self.config.max_rate, self.config.max_rate);
if self.motor_rate != 0.0 {
self.ended = false;
}
if hand_contact {
self.target_position = self.position;
self.target_rate = self.map_rate(hand_rate);
self.frames_since_motion = 0;
} else {
self.target_position = self.position;
}
}
#[wasm_bindgen(js_name = setPosition)]
pub fn set_position(&mut self, position: f64, impulse: f64) {
self.position = self.clamp_source_position(position);
self.target_position = self.position;
self.last_output_samples.clear();
self.window_miss_frames = 0;
self.ended = false;
if impulse > 0.0 {
self.contact_impulse = self.contact_impulse.max(impulse).clamp(0.0, 1.0);
}
}
#[wasm_bindgen(js_name = resetPosition)]
pub fn reset_position_export(&mut self, position: f64) {
self.reset_position(position);
}
#[wasm_bindgen(js_name = render)]
pub fn render(&mut self, frame_count: u32, output_channel_count: u32) {
let frame_count = frame_count as usize;
let output_channel_count = (output_channel_count as usize).clamp(1, 2);
self.output.resize(frame_count.saturating_mul(output_channel_count), 0.0);
self.output.fill(0.0);
self.requested_window_position = None;
if frame_count == 0 {
return;
}
if !self.active || self.channels.is_empty() || self.total_frames <= 1 {
return;
}
self.drag_lowpass_state.resize(output_channel_count, 0.0);
self.last_output_samples.resize(output_channel_count, 0.0);
let dt = 1.0 / self.output_sample_rate;
let catchup_frames = (self.source_sample_rate * POSITION_CATCHUP_SECONDS).max(1.0);
let hold_frames = (self.output_sample_rate * MOTION_HOLD_SECONDS).max(1.0) as usize;
let hold_release_frames = (self.output_sample_rate * MOTION_HOLD_RELEASE_SECONDS).max(1.0);
let still_snap_alpha = 1.0 - (-1.0 / (self.output_sample_rate * STILL_SNAP_SECONDS)).exp();
let grip_target = if self.hand_contact { 1.0 } else { 0.0 };
let grip_seconds = if self.hand_contact { GRIP_ATTACK_SECONDS } else { GRIP_RELEASE_SECONDS };
let grip_alpha = 1.0 - (-1.0 / (self.output_sample_rate * grip_seconds)).exp();
let motor_spin_alpha = 1.0 - (-1.0 / (self.output_sample_rate * MOTOR_SPINUP_SECONDS)).exp();
let motor_brake_step = 1.0 / (self.output_sample_rate * MOTOR_BRAKE_SECONDS);
let rate_scale = self.source_sample_rate / self.output_sample_rate;
let miss_fade_frames = (self.output_sample_rate * WINDOW_MISS_FADE_SECONDS).round().max(1.0);
for frame in 0..frame_count {
self.frames_since_motion = self.frames_since_motion.saturating_add(1);
self.grip += (grip_target - self.grip) * grip_alpha;
if self.motor_rate.abs() > self.motor_delivered_rate.abs() {
self.motor_delivered_rate += (self.motor_rate - self.motor_delivered_rate) * motor_spin_alpha;
} else if self.motor_delivered_rate > self.motor_rate {
self.motor_delivered_rate = (self.motor_delivered_rate - motor_brake_step).max(self.motor_rate);
} else {
self.motor_delivered_rate = (self.motor_delivered_rate + motor_brake_step).min(self.motor_rate);
}
let hand_rate = if self.frames_since_motion > hold_frames {
self.target_rate * (-((self.frames_since_motion - hold_frames) as f64) / hold_release_frames).exp()
} else {
self.target_rate
};
let held_target_rate = self.motor_delivered_rate + self.grip * (hand_rate - self.motor_delivered_rate);
self.rate_velocity += (((held_target_rate - self.rate) * RATE_SPRING_OMEGA * RATE_SPRING_OMEGA)
- (2.0 * RATE_SPRING_ZETA * RATE_SPRING_OMEGA * self.rate_velocity)) * dt;
self.rate += self.rate_velocity * dt;
let position_error = self.target_position - self.position;
let mut correction_rate = ((position_error / catchup_frames) * self.grip).clamp(-0.12, 0.12);
if self.grip > GRIP_OWNERSHIP && hand_rate.abs() < DEADZONE_RATE && self.rate.abs() < DEADZONE_RATE {
self.position += position_error * still_snap_alpha;
correction_rate = 0.0;
}
let corrected_rate = self.rate + correction_rate;
let abs_rate = corrected_rate.abs();
let effective_rate = if self.config.acoustic_enabled {
corrected_rate
+ sign_nonzero(corrected_rate, held_target_rate)
* self.advance_wow_flutter(corrected_rate, rate_scale, abs_rate)
} else {
corrected_rate
};
let movement_gain = compute_movement_gain(abs_rate);
let surface_noise = if self.config.surface_enabled { self.next_noise() } else { 0.0 };
let highpassed_noise = if self.config.surface_enabled { surface_noise - self.last_noise } else { 0.0 };
self.last_noise = surface_noise;
let near_realtime_distance = (abs_rate - 1.0).abs();
let realtime_acceleration_dip = 1.0
- 0.88 * (-(near_realtime_distance * near_realtime_distance) / 0.16).exp();
let rate_delta = (corrected_rate - self.last_effective_rate).abs();
let acceleration_noise = (rate_delta * 0.00028 * realtime_acceleration_dip).clamp(0.0, 0.0007);
let contact_noise_gain = compute_contact_noise_gain(abs_rate) + acceleration_noise;
let impulse_noise = if self.config.surface_enabled && self.contact_impulse > 0.0001 {
self.next_noise() * self.contact_impulse * 0.004
} else {
0.0
};
let groove_surface = if self.config.surface_enabled { self.compute_position_surface_noise(self.position, abs_rate) } else { 0.0 };
let source_texture_gain = if self.config.acoustic_enabled { compute_source_texture_gain(abs_rate, rate_delta) } else { 0.0 };
let dust_fleck = if self.config.surface_enabled { self.compute_dust_fleck(self.position, abs_rate) } else { 0.0 };
let contact_texture = if self.config.surface_enabled { (groove_surface * 0.76 + highpassed_noise * 0.18) * contact_noise_gain } else { 0.0 };
let source_direction = sign_nonzero(effective_rate, held_target_rate);
let drag_alpha = if self.config.acoustic_enabled { self.drag_lowpass_alpha(abs_rate) } else { 1.0 };
let miss_fade = if self.window_miss_frames > 0 {
(1.0 - self.window_miss_frames as f64 / miss_fade_frames).clamp(0.0, 1.0)
} else {
1.0
};
let mut missed_window = false;
for channel_index in 0..output_channel_count {
let output_index = frame * output_channel_count + channel_index;
if self.needle_lifted {
self.output[output_index] = 0.0;
continue;
}
let source_index = channel_index.min(self.channels.len() - 1);
let detail = self.sample_channel(source_index, self.position);
let (music, source_texture) = match detail {
None => {
missed_window = true;
(self.last_output_samples[channel_index] * miss_fade, 0.0)
}
Some((sampled, slope, curvature)) => {
let drag_state = self.drag_lowpass_state[channel_index];
let filtered = drag_state + (sampled - drag_state) * drag_alpha;
self.drag_lowpass_state[channel_index] = filtered;
let music = filtered * movement_gain * OUTPUT_GAIN;
self.last_output_samples[channel_index] = music;
let texture = ((slope * 0.48 + curvature * 0.86) * source_direction)
.clamp(-1.0, 1.0)
* source_texture_gain;
(music, texture)
}
};
self.output[output_index] = (music + source_texture + contact_texture + dust_fleck + impulse_noise)
.clamp(-1.0, 1.0) as f32;
}
self.position = self.clamp_source_position(self.position + effective_rate * rate_scale);
if self.grip < GRIP_OWNERSHIP {
self.target_position = self.position;
if !self.ended && self.motor_rate > 0.0 && self.position >= self.total_frames.saturating_sub(3) as f64 {
self.ended = true;
self.motor_rate = 0.0;
}
}
self.last_effective_rate = effective_rate;
self.contact_impulse *= CONTACT_IMPULSE_DECAY;
self.window_miss_frames = if missed_window {
self.window_miss_frames.saturating_add(1)
} else {
0
};
}
self.maybe_request_window(frame_count);
}
#[wasm_bindgen(js_name = renderWindowMissing)]
pub fn render_window_missing(&mut self, frame_count: u32, output_channel_count: u32) {
let frame_count = frame_count as usize;
let output_channel_count = (output_channel_count as usize).clamp(1, 2);
self.output.resize(frame_count.saturating_mul(output_channel_count), 0.0);
let fade_frames = (self.output_sample_rate * WINDOW_MISS_FADE_SECONDS).round().max(1.0);
self.last_output_samples.resize(output_channel_count, 0.0);
for frame in 0..frame_count {
let fade = (1.0 - self.window_miss_frames as f64 / fade_frames).clamp(0.0, 1.0);
for channel_index in 0..output_channel_count {
self.output[frame * output_channel_count + channel_index] =
(self.last_output_samples[channel_index] * fade) as f32;
}
self.window_miss_frames = self.window_miss_frames.saturating_add(1);
}
}
#[wasm_bindgen(getter, js_name = outputPtr)]
pub fn output_ptr(&self) -> *const f32 {
self.output.as_ptr()
}
#[wasm_bindgen(getter, js_name = outputLen)]
pub fn output_len(&self) -> usize {
self.output.len()
}
#[wasm_bindgen(getter)]
pub fn position(&self) -> f64 {
self.position
}
#[wasm_bindgen(getter, js_name = effectiveRate)]
pub fn effective_rate(&self) -> f64 {
self.last_effective_rate
}
#[wasm_bindgen(js_name = takeWindowRequest)]
pub fn take_window_request(&mut self) -> f64 {
self.requested_window_position.take().unwrap_or(-1.0)
}
#[wasm_bindgen(js_name = takeEnded)]
pub fn take_ended(&mut self) -> bool {
let ended = self.ended;
self.ended = false;
ended
}
}
impl ScratchAcousticDsp {
fn reset_position(&mut self, position: f64) {
self.position = self.clamp_source_position(position);
self.target_position = self.position;
self.rate = 0.0;
self.rate_velocity = 0.0;
self.target_rate = 0.0;
self.motor_delivered_rate = 0.0;
self.last_effective_rate = 0.0;
self.frames_since_motion = 0;
self.last_output_samples.clear();
self.window_miss_frames = 0;
}
fn map_rate(&self, rate: f64) -> f64 {
if !rate.is_finite() || rate.abs() < DEADZONE_RATE {
0.0
} else {
rate.clamp(-self.config.max_rate, self.config.max_rate)
}
}
fn clamp_source_position(&self, position: f64) -> f64 {
position.clamp(0.0, self.total_frames.max(self.window_end).saturating_sub(2) as f64)
}
fn next_noise(&mut self) -> f64 {
self.noise_seed = self.noise_seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
self.noise_seed as f64 / 2_147_483_648.0 - 1.0
}
fn hash_noise(index: i64, salt: i32) -> f64 {
let mut value = (index as i32) ^ salt;
value = (value ^ ((value as u32 >> 16) as i32)).wrapping_mul(0x7feb_352d_u32 as i32);
value = (value ^ ((value as u32 >> 15) as i32)).wrapping_mul(0x846c_a68b_u32 as i32);
let unsigned = (value ^ ((value as u32 >> 16) as i32)) as u32;
unsigned as f64 / 2_147_483_648.0 - 1.0
}
fn position_noise(&self, position: f64, spacing: f64, salt: i32) -> f64 {
let scaled = position.max(0.0) / spacing.max(1.0);
let index = scaled.floor() as i64;
let t = scaled - index as f64;
let smooth = t * t * (3.0 - 2.0 * t);
let a = Self::hash_noise(index, salt);
let b = Self::hash_noise(index + 1, salt);
a + (b - a) * smooth
}
fn compute_position_surface_noise(&self, position: f64, abs_rate: f64) -> f64 {
if abs_rate <= DEADZONE_RATE {
return 0.0;
}
let speed_weight = (abs_rate / 2.4).clamp(0.14, 1.0);
let groove_grain = self.position_noise(position, 3.7, 0x0051_f15e);
let groove_bed = self.position_noise(position, 37.0, 0x002d_4a11);
(groove_grain * 0.72 + groove_bed * 0.22) * speed_weight
}
fn compute_dust_fleck(&self, position: f64, abs_rate: f64) -> f64 {
if abs_rate <= 0.03 {
return 0.0;
}
let cell_frames = (self.source_sample_rate * 0.12).round().max(1.0);
let cell = (position.max(0.0) / cell_frames).floor() as i64;
let chance = (Self::hash_noise(cell, 0x006d_2b79) + 1.0) * 0.5;
if chance < 0.996 {
return 0.0;
}
let center = (cell as f64 + 0.5 + Self::hash_noise(cell, 0x004f_1bbc) * 0.28) * cell_frames;
let width = cell_frames * 0.028;
let distance = (position - center).abs() / width.max(1.0);
if distance >= 1.0 {
return 0.0;
}
let envelope = (1.0 - distance).powi(2);
let speed_weight = (abs_rate / 1.4).clamp(0.12, 1.0);
Self::hash_noise(cell, 0x0073_c4d9) * envelope * speed_weight * DUST_FLECK_GAIN
}
fn sample_channel(&self, channel_index: usize, position: f64) -> Option<(f64, f64, f64)> {
let channel = self.channels.get(channel_index)?;
let local = position - self.window_start as f64;
if local < 0.0 || local >= channel.len().saturating_sub(1) as f64 {
return None;
}
let index = local.floor() as usize;
let t = local - index as f64;
let p0 = channel[index.saturating_sub(1)] as f64;
let p1 = channel[index] as f64;
let p2 = channel[(index + 1).min(channel.len() - 1)] as f64;
let p3 = channel[(index + 2).min(channel.len() - 1)] as f64;
let a = p2 - p0;
let b = 2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3;
let c = 3.0 * (p1 - p2) + p3 - p0;
let slope = 0.5 * (a + 2.0 * b * t + 3.0 * c * t * t);
let curvature = (p0 - 2.0 * p1 + p2) * (1.0 - t) + (p1 - 2.0 * p2 + p3) * t;
let sample = p1 + 0.5 * t * (a + t * (b + t * c));
Some((sample, slope, curvature))
}
fn advance_wow_flutter(&mut self, corrected_rate: f64, rate_scale: f64, abs_rate: f64) -> f64 {
if self.source_sample_rate <= 0.0 {
return 0.0;
}
let frames_per_rev = self.config.wow_rev_seconds * self.source_sample_rate;
self.wow_phase += corrected_rate * rate_scale / frames_per_rev;
self.flutter_phase += self.config.flutter_hz / self.output_sample_rate * abs_rate.clamp(0.0, 1.4);
if abs_rate <= 0.18 {
return 0.0;
}
let depth = abs_rate.clamp(0.0, 1.2) * 0.0012;
(self.wow_phase * std::f64::consts::TAU).sin() * depth
+ (self.flutter_phase * std::f64::consts::TAU).sin() * depth * 0.22
}
fn drag_lowpass_alpha(&self, abs_rate: f64) -> f64 {
let speed = (abs_rate / DRAG_LOWPASS_RATE_KNEE).clamp(0.045, 1.0);
let mut cutoff = DRAG_LOWPASS_MAX_HZ * speed.powf(1.3);
if abs_rate > TRACING_LOSS_START_RATE {
cutoff *= (TRACING_LOSS_START_RATE / abs_rate).clamp(0.55, 1.0);
}
1.0 - (-std::f64::consts::TAU * cutoff / self.output_sample_rate).exp()
}
fn maybe_request_window(&mut self, frame_count: usize) {
self.frames_since_window_request = self.frames_since_window_request.saturating_add(frame_count);
let speed = self.last_effective_rate.abs().max(1.0);
let throttle = if speed > 2.0 { 0.03 } else { 0.08 };
if self.frames_since_window_request < (self.output_sample_rate * throttle) as usize || self.channels.is_empty() {
return;
}
let margin = (WINDOW_REQUEST_MARGIN_SECONDS * self.source_sample_rate * (speed * 0.5).max(1.0)).max(256.0);
let projected = self.clamp_source_position(
self.position + self.last_effective_rate * self.source_sample_rate * WINDOW_REQUEST_PROJECT_SECONDS,
);
let request = if self.last_effective_rate < 0.0 {
self.position.min(projected)
} else {
self.position.max(projected)
};
let start = self.window_start as f64;
let end = self.window_end as f64;
if self.position < start + margin
|| self.position > end - margin
|| request < start + margin
|| request > end - margin
{
self.frames_since_window_request = 0;
self.requested_window_position = Some(request);
}
}
}
fn finite_or_zero(value: f64) -> f64 {
if value.is_finite() { value } else { 0.0 }
}
fn sign_nonzero(primary: f64, fallback: f64) -> f64 {
if primary != 0.0 {
primary.signum()
} else if fallback != 0.0 {
fallback.signum()
} else {
1.0
}
}
fn compute_movement_gain(abs_rate: f64) -> f64 {
if abs_rate <= DEADZONE_RATE {
return 0.0;
}
let normalized = abs_rate.clamp(0.0, 10.0);
let realtime_presence = (-((normalized - 1.0) / 0.38).powi(2)).exp();
let underspeed = 0.78 + 0.22 * normalized.max(DEADZONE_RATE).powf(0.1);
let overspeed = 1.0 + (normalized - 1.0).max(0.0) * 0.014;
let acoustic = if normalized <= 1.0 { underspeed } else { overspeed };
(acoustic + realtime_presence * 0.025).clamp(0.68, 1.08)
}
fn compute_contact_noise_gain(abs_rate: f64) -> f64 {
if abs_rate <= DEADZONE_RATE {
return 0.0;
}
let distance = (abs_rate - 1.0).abs();
let realtime_dip = 1.0 - 0.94 * (-(distance * distance) / 0.18).exp();
let slow_rub = ((0.26 - abs_rate) / 0.26).clamp(0.0, 1.0) * 0.36;
let fast_friction = ((abs_rate - 2.2) / 5.5).clamp(0.0, 1.0) * 0.72;
CONTACT_NOISE_GAIN * realtime_dip * (0.24 + slow_rub + fast_friction).clamp(0.08, 1.08)
}
fn compute_source_texture_gain(abs_rate: f64, rate_delta: f64) -> f64 {
if abs_rate <= DEADZONE_RATE {
return 0.0;
}
let distance = (abs_rate - 1.0).abs();
let realtime_dip = 1.0 - 0.72 * (-(distance * distance) / 0.14).exp();
let slow_rub = ((0.42 - abs_rate) / 0.42).clamp(0.0, 1.0);
let speed_lift = (abs_rate / 2.2).clamp(0.0, 1.0);
let acceleration_lift = (rate_delta / 1.6).clamp(0.0, 1.0);
SOURCE_TEXTURE_GAIN
* realtime_dip
* (0.18 + slow_rub * 0.72 + speed_lift * 0.28 + acceleration_lift * 0.38)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn movement_gain_is_silent_in_deadzone() {
assert_eq!(compute_movement_gain(DEADZONE_RATE * 0.5), 0.0);
}
#[test]
fn movement_gain_stays_bounded() {
for rate in [0.01, 0.1, 1.0, 3.0, 10.0] {
let gain = compute_movement_gain(rate);
assert!((0.0..=1.08).contains(&gain));
}
}
#[test]
fn deterministic_hash_noise_is_stable() {
assert_eq!(ScratchAcousticDsp::hash_noise(42, 7), ScratchAcousticDsp::hash_noise(42, 7));
assert_ne!(ScratchAcousticDsp::hash_noise(42, 7), ScratchAcousticDsp::hash_noise(43, 7));
}
}
#[derive(Clone, Copy, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CalibrationAnchor {
pub sample: f64,
pub radial: f64,
}
#[derive(Clone, Debug)]
struct MonotoneInterpolant {
xs: Vec<f64>,
ys: Vec<f64>,
widths: Vec<f64>,
tangents: Vec<f64>,
}
impl MonotoneInterpolant {
fn new(xs: Vec<f64>, ys: Vec<f64>) -> Result<Self, String> {
if xs.len() != ys.len() {
return Err("monotone interpolant requires equal-length xs/ys".to_owned());
}
if xs.len() < 2 {
return Err("monotone interpolant requires at least two anchors".to_owned());
}
for index in 1..xs.len() {
if !xs[index].is_finite() || xs[index] <= xs[index - 1] {
return Err("monotone interpolant requires strictly increasing xs".to_owned());
}
if !ys[index].is_finite() || ys[index] <= ys[index - 1] {
return Err("monotone interpolant requires strictly increasing ys".to_owned());
}
}
let widths = xs.windows(2).map(|pair| pair[1] - pair[0]).collect::<Vec<_>>();
let deltas = ys
.windows(2)
.zip(widths.iter())
.map(|(pair, width)| (pair[1] - pair[0]) / width)
.collect::<Vec<_>>();
let mut tangents = vec![0.0; xs.len()];
for index in 1..xs.len() - 1 {
if deltas[index - 1] * deltas[index] <= 0.0 {
tangents[index] = 0.0;
} else {
let w1 = 2.0 * widths[index] + widths[index - 1];
let w2 = widths[index] + 2.0 * widths[index - 1];
tangents[index] = (w1 + w2) / (w1 / deltas[index - 1] + w2 / deltas[index]);
}
}
tangents[0] = endpoint_slope(
widths[0],
widths.get(1).copied(),
deltas[0],
deltas.get(1).copied(),
);
let last = xs.len() - 1;
tangents[last] = endpoint_slope(
widths[last - 1],
last.checked_sub(2).and_then(|index| widths.get(index).copied()),
deltas[last - 1],
last.checked_sub(2).and_then(|index| deltas.get(index).copied()),
);
Ok(Self {
xs,
ys,
widths,
tangents,
})
}
fn segment_for_x(&self, value: f64) -> usize {
if value <= self.xs[0] {
return 0;
}
if value >= self.xs[self.xs.len() - 1] {
return self.xs.len() - 2;
}
self.xs.partition_point(|candidate| *candidate <= value).saturating_sub(1)
}
fn segment_for_y(&self, value: f64) -> usize {
if value <= self.ys[0] {
return 0;
}
if value >= self.ys[self.ys.len() - 1] {
return self.ys.len() - 2;
}
self.ys.partition_point(|candidate| *candidate <= value).saturating_sub(1)
}
fn hermite(&self, index: usize, t: f64) -> f64 {
let t2 = t * t;
let t3 = t2 * t;
let h00 = 2.0 * t3 - 3.0 * t2 + 1.0;
let h10 = t3 - 2.0 * t2 + t;
let h01 = -2.0 * t3 + 3.0 * t2;
let h11 = t3 - t2;
h00 * self.ys[index]
+ h10 * self.widths[index] * self.tangents[index]
+ h01 * self.ys[index + 1]
+ h11 * self.widths[index] * self.tangents[index + 1]
}
fn evaluate(&self, value: f64) -> f64 {
if value <= self.xs[0] {
return self.ys[0];
}
if value >= self.xs[self.xs.len() - 1] {
return self.ys[self.ys.len() - 1];
}
let index = self.segment_for_x(value);
let t = (value - self.xs[index]) / self.widths[index];
self.hermite(index, t)
}
fn evaluate_inverse(&self, value: f64) -> f64 {
if value <= self.ys[0] {
return self.xs[0];
}
if value >= self.ys[self.ys.len() - 1] {
return self.xs[self.xs.len() - 1];
}
let index = self.segment_for_y(value);
let mut low = 0.0;
let mut high = 1.0;
for _ in 0..40 {
let mid = (low + high) * 0.5;
if self.hermite(index, mid) < value {
low = mid;
} else {
high = mid;
}
}
self.xs[index] + (low + high) * 0.5 * self.widths[index]
}
}
fn endpoint_slope(ha: f64, hb: Option<f64>, da: f64, db: Option<f64>) -> f64 {
let (Some(hb), Some(db)) = (hb, db) else {
return da;
};
let slope = ((2.0 * ha + hb) * da - ha * db) / (ha + hb);
if slope.signum() != da.signum() {
return 0.0;
}
if da.signum() != db.signum() && slope.abs() > (3.0 * da).abs() {
return 3.0 * da;
}
slope
}
#[wasm_bindgen]
pub struct StylusCalibration {
total_samples: f64,
interpolant: Option<MonotoneInterpolant>,
}
#[wasm_bindgen]
impl StylusCalibration {
#[wasm_bindgen(constructor)]
pub fn new(total_samples: f64, anchors: JsValue) -> Result<StylusCalibration, JsValue> {
if !total_samples.is_finite() || total_samples <= 0.0 {
return Err(JsValue::from_str("totalSamples must be positive"));
}
let anchors: Vec<CalibrationAnchor> = serde_wasm_bindgen::from_value(anchors)
.map_err(|error| JsValue::from_str(&error.to_string()))?;
let interpolant = if anchors.is_empty() {
None
} else {
validate_anchors(total_samples, &anchors).map_err(|error| JsValue::from_str(&error))?;
Some(
MonotoneInterpolant::new(
anchors.iter().map(|anchor| anchor.sample).collect(),
anchors.iter().map(|anchor| anchor.radial).collect(),
)
.map_err(|error| JsValue::from_str(&error))?,
)
};
Ok(Self {
total_samples,
interpolant,
})
}
#[wasm_bindgen(getter, js_name = hasGaps)]
pub fn has_gaps(&self) -> bool {
self.interpolant.is_some()
}
#[wasm_bindgen(getter, js_name = totalSamples)]
pub fn total_samples(&self) -> f64 {
self.total_samples
}
#[wasm_bindgen(js_name = sampleToGroove)]
pub fn sample_to_groove(&self, sample: f64) -> f64 {
let sample = sample.clamp(0.0, self.total_samples);
self.interpolant
.as_ref()
.map(|interpolant| interpolant.evaluate(sample).clamp(0.0, 1.0))
.unwrap_or_else(|| (sample / self.total_samples).clamp(0.0, 1.0))
}
#[wasm_bindgen(js_name = grooveToSample)]
pub fn groove_to_sample(&self, groove: f64) -> f64 {
let groove = groove.clamp(0.0, 1.0);
self.interpolant
.as_ref()
.map(|interpolant| interpolant.evaluate_inverse(groove).clamp(0.0, self.total_samples))
.unwrap_or(groove * self.total_samples)
}
}
fn validate_anchors(total_samples: f64, anchors: &[CalibrationAnchor]) -> Result<(), String> {
if anchors.len() < 2 {
return Err("calibration requires at least two anchors".to_owned());
}
if anchors[0].sample != 0.0 || anchors[0].radial != 0.0 {
return Err("calibration must begin at sample 0 and radial 0".to_owned());
}
let last = anchors[anchors.len() - 1];
if last.sample != total_samples || last.radial != 1.0 {
return Err("calibration must end at totalSamples and radial 1".to_owned());
}
for pair in anchors.windows(2) {
if !pair[0].sample.is_finite()
|| !pair[1].sample.is_finite()
|| pair[1].sample <= pair[0].sample
{
return Err("sample anchors must be strictly increasing".to_owned());
}
if !pair[0].radial.is_finite()
|| !pair[1].radial.is_finite()
|| pair[1].radial <= pair[0].radial
{
return Err("radial anchors must be strictly increasing".to_owned());
}
}
Ok(())
}
#[derive(Clone, Copy, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ScratchConfig {
pub max_playback_rate: f64,
pub deadzone_rate: f64,
pub lock_center_rate: f64,
pub lock_width: f64,
pub lock_strength: f64,
pub pointer_filter_seconds: f64,
}
#[derive(Clone, Copy, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ScratchMotion {
pub delta_angle_radians: f64,
pub rotation_degrees: f64,
pub current_time: f64,
pub sample_position: f64,
pub raw_playback_rate: f64,
pub filtered_playback_rate: f64,
pub physical_playback_rate: f64,
}
#[derive(Clone, Copy, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ScratchWindowPlan {
pub start: u32,
pub end: u32,
pub length: u32,
pub needs_update: bool,
}
#[wasm_bindgen]
pub struct ScratchSimulation {
config: ScratchConfig,
active: bool,
pointer_id: i32,
last_angle: f64,
last_time_ms: f64,
filtered_pointer_rate: f64,
current_time: f64,
sample_position: f64,
rotation_degrees: f64,
}
#[wasm_bindgen]
impl ScratchSimulation {
#[wasm_bindgen(constructor)]
pub fn new(config: JsValue) -> Result<ScratchSimulation, JsValue> {
let config: ScratchConfig = serde_wasm_bindgen::from_value(config)
.map_err(|error| JsValue::from_str(&error.to_string()))?;
validate_scratch_config(config).map_err(|error| JsValue::from_str(&error))?;
Ok(Self {
config,
active: false,
pointer_id: -1,
last_angle: 0.0,
last_time_ms: 0.0,
filtered_pointer_rate: 0.0,
current_time: 0.0,
sample_position: 0.0,
rotation_degrees: 0.0,
})
}
#[wasm_bindgen(js_name = begin)]
pub fn begin(
&mut self,
pointer_id: i32,
angle_radians: f64,
time_ms: f64,
current_time: f64,
rotation_degrees: f64,
sample_rate: f64,
duration: f64,
) -> Result<(), JsValue> {
validate_motion_inputs(angle_radians, time_ms, sample_rate, duration)?;
self.active = true;
self.pointer_id = pointer_id;
self.last_angle = angle_radians;
self.last_time_ms = time_ms;
self.filtered_pointer_rate = 0.0;
self.current_time = current_time.clamp(0.0, duration);
self.sample_position = (self.current_time * sample_rate).clamp(0.0, duration * sample_rate);
self.rotation_degrees = rotation_degrees;
Ok(())
}
#[wasm_bindgen(js_name = update)]
pub fn update(
&mut self,
pointer_id: i32,
angle_radians: f64,
time_ms: f64,
duration: f64,
sample_rate: f64,
seconds_per_turn: f64,
needle_lifted: bool,
) -> Result<JsValue, JsValue> {
if !self.active || self.pointer_id != pointer_id {
return Err(JsValue::from_str("scratch pointer is not active"));
}
validate_motion_inputs(angle_radians, time_ms, sample_rate, duration)?;
if !seconds_per_turn.is_finite() || seconds_per_turn <= 0.0 {
return Err(JsValue::from_str("secondsPerTurn must be positive"));
}
let delta_angle = normalize_angle_delta(angle_radians - self.last_angle);
let elapsed_seconds = ((time_ms - self.last_time_ms).max(1.0) / 1000.0).max(0.004);
self.last_angle = angle_radians;
self.last_time_ms = time_ms;
self.rotation_degrees += delta_angle.to_degrees();
let mut raw_playback_rate = 0.0;
let mut physical_playback_rate = 0.0;
if !needle_lifted {
let mapped_delta_seconds = delta_angle / std::f64::consts::TAU * seconds_per_turn;
self.current_time = (self.current_time + mapped_delta_seconds).clamp(0.0, duration);
raw_playback_rate = mapped_delta_seconds / elapsed_seconds;
let alpha = 1.0 - (-elapsed_seconds / self.config.pointer_filter_seconds).exp();
self.filtered_pointer_rate +=
(raw_playback_rate - self.filtered_pointer_rate) * alpha;
physical_playback_rate = map_physical_playback_rate(self.filtered_pointer_rate, self.config);
self.sample_position = (self.current_time * sample_rate).clamp(0.0, duration * sample_rate);
}
serde_wasm_bindgen::to_value(&ScratchMotion {
delta_angle_radians: delta_angle,
rotation_degrees: self.rotation_degrees,
current_time: self.current_time,
sample_position: self.sample_position,
raw_playback_rate,
filtered_playback_rate: self.filtered_pointer_rate,
physical_playback_rate,
})
.map_err(|error| JsValue::from_str(&error.to_string()))
}
#[wasm_bindgen(js_name = finish)]
pub fn finish(&mut self) {
self.active = false;
self.pointer_id = -1;
self.filtered_pointer_rate = 0.0;
}
#[wasm_bindgen(js_name = mapPhysicalPlaybackRate)]
pub fn map_physical_playback_rate(&self, playback_rate: f64) -> f64 {
map_physical_playback_rate(playback_rate, self.config)
}
#[wasm_bindgen(js_name = planWindow)]
pub fn plan_window(
&self,
center_sample_position: f64,
frame_length: u32,
window_frames: u32,
current_window_start: u32,
current_window_end: u32,
margin_frames: u32,
force: bool,
) -> Result<JsValue, JsValue> {
let frame_length = frame_length.max(1);
let window_frames = window_frames.max(1).min(frame_length);
let center = center_sample_position
.round()
.clamp(0.0, f64::from(frame_length.saturating_sub(1))) as u32;
let half = window_frames / 2;
let max_start = frame_length.saturating_sub(window_frames);
let start = center.saturating_sub(half).min(max_start);
let end = start.saturating_add(window_frames).min(frame_length);
let needs_update = force
|| center <= current_window_start.saturating_add(margin_frames)
|| center >= current_window_end.saturating_sub(margin_frames);
serde_wasm_bindgen::to_value(&ScratchWindowPlan {
start,
end,
length: end.saturating_sub(start),
needs_update,
})
.map_err(|error| JsValue::from_str(&error.to_string()))
}
}
fn validate_scratch_config(config: ScratchConfig) -> Result<(), String> {
if !config.max_playback_rate.is_finite() || config.max_playback_rate <= 0.0 {
return Err("maxPlaybackRate must be positive".to_owned());
}
if !config.deadzone_rate.is_finite() || config.deadzone_rate < 0.0 {
return Err("deadzoneRate must be non-negative".to_owned());
}
if !config.lock_center_rate.is_finite() || config.lock_center_rate < 0.0 {
return Err("lockCenterRate must be non-negative".to_owned());
}
if !config.lock_width.is_finite() || config.lock_width <= 0.0 {
return Err("lockWidth must be positive".to_owned());
}
if !config.lock_strength.is_finite() || !(0.0..=1.0).contains(&config.lock_strength) {
return Err("lockStrength must be between 0 and 1".to_owned());
}
if !config.pointer_filter_seconds.is_finite() || config.pointer_filter_seconds <= 0.0 {
return Err("pointerFilterSeconds must be positive".to_owned());
}
Ok(())
}
fn validate_motion_inputs(
angle_radians: f64,
time_ms: f64,
sample_rate: f64,
duration: f64,
) -> Result<(), JsValue> {
if !angle_radians.is_finite() {
return Err(JsValue::from_str("angleRadians must be finite"));
}
if !time_ms.is_finite() {
return Err(JsValue::from_str("timeMs must be finite"));
}
if !sample_rate.is_finite() || sample_rate <= 0.0 {
return Err(JsValue::from_str("sampleRate must be positive"));
}
if !duration.is_finite() || duration < 0.0 {
return Err(JsValue::from_str("duration must be non-negative"));
}
Ok(())
}
fn normalize_angle_delta(delta: f64) -> f64 {
let mut normalized = delta;
while normalized > std::f64::consts::PI {
normalized -= std::f64::consts::TAU;
}
while normalized < -std::f64::consts::PI {
normalized += std::f64::consts::TAU;
}
normalized
}
fn map_physical_playback_rate(playback_rate: f64, config: ScratchConfig) -> f64 {
if !playback_rate.is_finite() || playback_rate.abs() < config.deadzone_rate {
return 0.0;
}
let direction = playback_rate.signum();
let magnitude = playback_rate.abs();
let lock_distance = (magnitude - config.lock_center_rate).abs();
let lock_amount = (-(lock_distance / config.lock_width).powi(2)).exp() * config.lock_strength;
let stabilized = magnitude + (config.lock_center_rate - magnitude) * lock_amount;
(direction * stabilized).clamp(-config.max_playback_rate, config.max_playback_rate)
}
#[cfg(test)]
mod tests {
use super::*;
use approx::assert_abs_diff_eq;
#[test]
fn monotone_mapping_round_trips() {
let interpolant = MonotoneInterpolant::new(
vec![0.0, 100.0, 200.0, 300.0],
vec![0.0, 0.2, 0.8, 1.0],
)
.unwrap();
for sample in [0.0, 25.0, 100.0, 175.0, 250.0, 300.0] {
let radial = interpolant.evaluate(sample);
assert_abs_diff_eq!(interpolant.evaluate_inverse(radial), sample, epsilon = 1e-8);
}
}
#[test]
fn playback_rate_deadzone_and_lock_are_preserved() {
let config = ScratchConfig {
max_playback_rate: 4.0,
deadzone_rate: 0.02,
lock_center_rate: 1.0,
lock_width: 0.1,
lock_strength: 0.5,
pointer_filter_seconds: 0.035,
};
assert_eq!(map_physical_playback_rate(0.01, config), 0.0);
assert_abs_diff_eq!(map_physical_playback_rate(1.0, config), 1.0, epsilon = 1e-12);
assert_eq!(map_physical_playback_rate(10.0, config), 4.0);
}
}