Skip to main content

record_player/
engine.rs

1use crate::*;
2use thiserror::Error;
3
4#[derive(Debug, Error, PartialEq)]
5pub enum PlayerError {
6    #[error("operation requires ready audio")]
7    AudioNotReady,
8    #[error("scratch is already active")]
9    ScratchAlreadyActive,
10    #[error("scratch is not active")]
11    ScratchNotActive,
12    #[error("invalid number")]
13    InvalidNumber,
14}
15
16#[derive(Debug, Clone)]
17pub struct PlayerEngine {
18    config: PlayerConfig,
19    state: PlayerState,
20    commands: Vec<HostCommand>,
21    two_deck_mode: bool,
22    revision: u64,
23}
24
25impl PlayerEngine {
26    pub fn new(config: PlayerConfig) -> Self { Self { config, state: PlayerState::default(), commands: Vec::new(), two_deck_mode: false, revision: 0 } }
27    pub fn state(&self) -> &PlayerState { &self.state }
28    pub fn revision(&self) -> u64 { self.revision }
29    pub fn view(&self) -> PlayerViewState { PlayerViewState::from_state(&self.state, self.revision) }
30    pub fn drain_commands(&mut self) -> Vec<HostCommand> { std::mem::take(&mut self.commands) }
31    fn deck(&self, id: DeckId) -> &DeckState { &self.state.decks[id.index()] }
32    fn deck_mut(&mut self, id: DeckId) -> &mut DeckState { &mut self.state.decks[id.index()] }
33
34    pub fn dispatch(&mut self, event: PlayerEvent) -> Result<(), PlayerError> {
35        match event {
36            PlayerEvent::HydrateDeck { deck, loaded, status, duration_seconds, current_seconds, playing, transport_on, needle_lifted, playback_rate, channel_gain } => {
37                if !duration_seconds.is_finite() || !current_seconds.is_finite() || !playback_rate.is_finite() || !channel_gain.is_finite() { return Err(PlayerError::InvalidNumber); }
38                let d = self.deck_mut(deck);
39                d.loaded = loaded;
40                d.playback.load_status = status;
41                d.playback.duration_seconds = duration_seconds.max(0.0);
42                d.playback.current_seconds = current_seconds.clamp(0.0, d.playback.duration_seconds.max(current_seconds));
43                d.playback.playing = playing;
44                d.playback.playback_rate = playback_rate;
45                d.transport.motor_on = transport_on;
46                d.needle.lifted = needle_lifted;
47                d.mixer.channel_gain = channel_gain.clamp(0.0, 1.0);
48            }
49            PlayerEvent::SetActiveDeck { deck } => self.state.active_deck = deck,
50            PlayerEvent::PlaybackPositionObserved { deck, seconds } => {
51                if !seconds.is_finite() { return Err(PlayerError::InvalidNumber); }
52                if !self.deck(deck).scratch.active && self.deck(deck).playback.suspended_at_seconds.is_none() {
53                    let duration = self.deck(deck).playback.duration_seconds;
54                    self.deck_mut(deck).playback.current_seconds = seconds.clamp(0.0, duration.max(seconds));
55                }
56            }
57            PlayerEvent::PlaybackEnded { deck } => {
58                let duration = self.deck(deck).playback.duration_seconds;
59                let d = self.deck_mut(deck);
60                d.playback.playing = false;
61                d.playback.current_seconds = duration;
62                self.commands.push(HostCommand::RefreshView);
63            }
64            PlayerEvent::SetLoadState { deck, status, loaded, duration_seconds } => {
65                if !duration_seconds.is_finite() { return Err(PlayerError::InvalidNumber); }
66                {
67                    let d = self.deck_mut(deck);
68                    d.loaded = loaded;
69                    d.playback.load_status = status;
70                    d.playback.duration_seconds = duration_seconds.max(0.0);
71                }
72                let should_start = status == LoadStatus::Ready
73                    && self.deck(deck).playback.pending_play_when_ready
74                    && self.deck(deck).transport.motor_on;
75                if should_start {
76                    let offset_seconds = self.deck(deck).playback.current_seconds;
77                    let rate = self.deck(deck).playback.playback_rate;
78                    let d = self.deck_mut(deck);
79                    d.playback.pending_play_when_ready = false;
80                    d.playback.playing = true;
81                    self.commands.push(HostCommand::StartPacketPlayback { deck, offset_seconds, rate, platter_handoff: false });
82                }
83            }
84            PlayerEvent::ToggleTransport { deck } => { let running = !self.deck(deck).transport.motor_on; self.set_transport(deck, running); }
85            PlayerEvent::SetTransport { deck, running } => self.set_transport(deck, running),
86            PlayerEvent::TogglePlayback { deck } => self.toggle_playback(deck)?,
87            PlayerEvent::SetNeedle { deck, lifted, observed_playback_seconds } => self.set_needle(deck, lifted, observed_playback_seconds)?,
88            PlayerEvent::Seek { deck, seconds } => self.seek(deck, seconds)?,
89            PlayerEvent::SetPlaybackRate { deck, rate } => {
90                if !rate.is_finite() { return Err(PlayerError::InvalidNumber); }
91                self.deck_mut(deck).playback.playback_rate = rate;
92                if self.deck(deck).playback.playing { self.commands.push(HostCommand::StartPacketPlayback { deck, offset_seconds: self.deck(deck).playback.current_seconds, rate, platter_handoff: true }); }
93            }
94            PlayerEvent::StartTimedRegion { region, now_ms, duration_seconds } => self.start_region(region, now_ms, duration_seconds)?,
95            PlayerEvent::StopTimedRegion { region, completed } => self.stop_region(region, completed),
96            PlayerEvent::TimedRegionElapsed { region } => self.finish_region(region),
97            PlayerEvent::StartClipLoop { clip_id } => { self.stop_regions(); self.state.clip_loop.active = true; self.state.clip_loop.clip_id = Some(clip_id); self.commands.push(HostCommand::RefreshView); }
98            PlayerEvent::StopClipLoop => self.stop_clip_loop(),
99            PlayerEvent::BeginScratch { deck, pointer_id, playback_seconds, rotation_degrees } => self.begin_scratch(deck, pointer_id, playback_seconds, rotation_degrees)?,
100            PlayerEvent::MoveScratch { deck, position_frames, rendered_position_frames, rate, rotation_degrees, impulse } => self.move_scratch(deck, position_frames, rendered_position_frames, rate, rotation_degrees, impulse)?,
101            PlayerEvent::ScratchRenderedPosition { deck, rendered_position_frames } => { if !rendered_position_frames.is_finite() { return Err(PlayerError::InvalidNumber); } if self.deck(deck).scratch.active { self.deck_mut(deck).scratch.rendered_position_frames = rendered_position_frames.max(0.0); } },
102            PlayerEvent::EndScratch { deck, rendered_position_frames, rotation_degrees, resume_playback, save_sample, can_platter_handoff } => self.end_scratch(deck, rendered_position_frames, rotation_degrees, resume_playback, save_sample, can_platter_handoff)?,
103            PlayerEvent::SetCrossfader { value } => { self.state.decks[0].mixer.crossfader = value.clamp(0.0, 1.0); self.emit_mixer(); }
104            PlayerEvent::SetChannelGain { deck, value } => { self.deck_mut(deck).mixer.channel_gain = value.clamp(0.0, 1.0); self.emit_mixer(); }
105            PlayerEvent::SetTwoDeckMode { enabled } => { self.two_deck_mode = enabled; self.emit_mixer(); }
106            PlayerEvent::Tick { now_ms } => { if !now_ms.is_finite() { return Err(PlayerError::InvalidNumber); } self.tick_regions(now_ms); }
107        }
108        self.revision = self.revision.wrapping_add(1);
109        Ok(())
110    }
111
112    fn set_transport(&mut self, deck: DeckId, running: bool) {
113        self.deck_mut(deck).transport.motor_on = running;
114        self.commands.push(HostCommand::SetMotor { deck, running });
115        let rate = if running { self.deck(deck).playback.playback_rate } else { 0.0 };
116        self.commands.push(HostCommand::SetScratchTransport { deck, hand_contact: false, motor_rate: rate });
117        if !running {
118            self.deck_mut(deck).playback.playing = false;
119            self.deck_mut(deck).playback.suspended_at_seconds = None;
120            self.commands.push(HostCommand::StopPacketPlayback { deck, platter_handoff: true });
121        }
122        self.commands.push(HostCommand::RefreshView);
123    }
124
125    fn toggle_playback(&mut self, deck: DeckId) -> Result<(), PlayerError> {
126        if self.state.clip_loop.active { self.stop_clip_loop(); self.set_needle(deck, true, self.deck(deck).playback.current_seconds)?; return Ok(()); }
127        if self.deck(deck).scratch.active { let rendered = self.deck(deck).scratch.rendered_position_frames; let rot = self.deck(deck).scratch.base_rotation_degrees; self.end_scratch(deck, rendered, rot, false, false, false)?; self.set_needle(deck, true, self.deck(deck).playback.current_seconds)?; return Ok(()); }
128        if self.state.lead_in.active { self.set_needle(deck, true, self.deck(deck).playback.current_seconds)?; self.stop_region(SurfaceRegion::LeadIn, false); self.commands.push(HostCommand::PublishTransportIntent); return Ok(()); }
129        if self.state.deadwax.active { self.set_needle(deck, true, self.deck(deck).playback.current_seconds)?; self.stop_region(SurfaceRegion::Deadwax, false); self.commands.push(HostCommand::PublishTransportIntent); return Ok(()); }
130        if self.deck(deck).playback.load_status != LoadStatus::Ready {
131            let running = !self.deck(deck).transport.motor_on; self.set_transport(deck, running); self.deck_mut(deck).needle.lifted = true;
132            if self.deck(deck).playback.load_status == LoadStatus::Loading { self.deck_mut(deck).playback.pending_play_when_ready = running; }
133            return Ok(());
134        }
135        if self.deck(deck).playback.playing {
136            self.deck_mut(deck).playback.playing = false;
137            self.deck_mut(deck).playback.suspended_at_seconds = None;
138            self.commands.push(HostCommand::StopPacketPlayback { deck, platter_handoff: false });
139        } else {
140            self.deck_mut(deck).transport.motor_on = true;
141            self.deck_mut(deck).needle.lifted = false;
142            self.deck_mut(deck).playback.suspended_at_seconds = None;
143            self.deck_mut(deck).playback.playing = true;
144            self.commands.push(HostCommand::SetMotor { deck, running: true });
145            self.commands.push(HostCommand::SetPacketGain { deck, gain: 1.0, ramp_ms: 0 });
146            self.commands.push(HostCommand::StartPacketPlayback { deck, offset_seconds: self.deck(deck).playback.current_seconds, rate: self.deck(deck).playback.playback_rate, platter_handoff: false });
147        }
148        self.commands.push(HostCommand::RefreshView); Ok(())
149    }
150
151    fn freezable(&self, deck: DeckId) -> bool { deck != DeckId::A || (!self.state.lead_in.active && !self.state.deadwax.active && !self.state.clip_loop.active && !self.deck(deck).scratch.active) }
152
153    fn set_needle(&mut self, deck: DeckId, lifted: bool, observed: f64) -> Result<(), PlayerError> {
154        if !observed.is_finite() { return Err(PlayerError::InvalidNumber); }
155        if self.deck(deck).needle.lifted == lifted {
156            return Ok(());
157        }
158        self.deck_mut(deck).needle.lifted = lifted;
159        if lifted {
160            if self.deck(deck).playback.playing && self.deck(deck).transport.motor_on && self.freezable(deck) {
161                let t = observed.clamp(0.0, self.deck(deck).playback.duration_seconds.max(0.0));
162                let d = self.deck_mut(deck); d.playback.suspended_at_seconds = Some(t); d.playback.current_seconds = t;
163            }
164            self.commands.push(HostCommand::SetPacketGain { deck, gain: 0.0, ramp_ms: 0 });
165        } else {
166            self.commands.push(HostCommand::SetPacketGain { deck, gain: 1.0, ramp_ms: 0 });
167            if let Some(t) = self.deck_mut(deck).playback.suspended_at_seconds.take() {
168                if self.deck(deck).transport.motor_on && self.freezable(deck) {
169                    self.deck_mut(deck).playback.current_seconds = t;
170                    self.commands.push(HostCommand::SetScratchPosition { deck, position_frames: t * self.config.sample_rate, impulse: 0.25 });
171                    self.commands.push(HostCommand::StartPacketPlayback { deck, offset_seconds: t, rate: self.deck(deck).playback.playback_rate, platter_handoff: true });
172                }
173            }
174        }
175        self.emit_mixer(); self.commands.push(HostCommand::RefreshView); Ok(())
176    }
177
178    fn seek(&mut self, deck: DeckId, seconds: f64) -> Result<(), PlayerError> {
179        if !seconds.is_finite() { return Err(PlayerError::InvalidNumber); }
180        let t = seconds.clamp(0.0, self.deck(deck).playback.duration_seconds.max(0.0)); self.deck_mut(deck).playback.current_seconds = t;
181        self.commands.push(HostCommand::SeekPacketPlayback { deck, offset_seconds: t });
182        self.commands.push(HostCommand::SetScratchPosition { deck, position_frames: t * self.config.sample_rate, impulse: 0.0 }); Ok(())
183    }
184
185    fn start_region(&mut self, region: SurfaceRegion, now_ms: f64, duration: f64) -> Result<(), PlayerError> {
186        if !now_ms.is_finite() || !duration.is_finite() || duration <= 0.0 { return Err(PlayerError::InvalidNumber); }
187        let deck = DeckId::A;
188        if self.deck(deck).needle.lifted || self.deck(deck).playback.load_status != LoadStatus::Ready || self.deck(deck).scratch.active || self.state.clip_loop.active { return Err(PlayerError::AudioNotReady); }
189        self.stop_regions();
190        let target = match region { SurfaceRegion::LeadIn => &mut self.state.lead_in, SurfaceRegion::Deadwax => &mut self.state.deadwax, SurfaceRegion::Programme => return Err(PlayerError::InvalidNumber) };
191        target.active = true; target.completed = false; target.started_at_ms = now_ms; target.duration_ms = duration * 1000.0;
192        self.deck_mut(deck).playback.playing = true;
193        self.commands.push(HostCommand::StopPacketPlayback { deck, platter_handoff: false });
194        self.commands.push(HostCommand::StartSurfaceRegion { region, duration_seconds: duration }); self.commands.push(HostCommand::RefreshView); Ok(())
195    }
196    fn stop_regions(&mut self) { if self.state.lead_in.active { self.stop_region(SurfaceRegion::LeadIn, false); } if self.state.deadwax.active { self.stop_region(SurfaceRegion::Deadwax, false); } }
197    fn stop_region(&mut self, region: SurfaceRegion, completed: bool) { let target = match region { SurfaceRegion::LeadIn => &mut self.state.lead_in, SurfaceRegion::Deadwax => &mut self.state.deadwax, SurfaceRegion::Programme => return }; if target.active { target.active = false; target.completed = completed; self.commands.push(HostCommand::StopSurfaceRegion { region }); self.commands.push(HostCommand::RefreshView); } }
198    fn finish_region(&mut self, region: SurfaceRegion) { self.stop_region(region, true); if region == SurfaceRegion::LeadIn { let t = self.deck(DeckId::A).playback.current_seconds; self.commands.push(HostCommand::StartPacketPlayback { deck: DeckId::A, offset_seconds: t, rate: self.deck(DeckId::A).playback.playback_rate, platter_handoff: true }); } else if region == SurfaceRegion::Deadwax { self.deck_mut(DeckId::A).playback.playing = false; } }
199    fn stop_clip_loop(&mut self) { if self.state.clip_loop.active { self.state.clip_loop = ClipLoopState::default(); self.commands.push(HostCommand::StopClipLoop); self.commands.push(HostCommand::RefreshView); } }
200
201    fn begin_scratch(&mut self, deck: DeckId, pointer_id: i32, seconds: f64, rotation: f64) -> Result<(), PlayerError> {
202        if self.deck(deck).scratch.active { return Err(PlayerError::ScratchAlreadyActive); }
203        if self.deck(deck).playback.load_status != LoadStatus::Ready { return Err(PlayerError::AudioNotReady); }
204        self.stop_regions(); self.stop_clip_loop();
205        let was_playing = self.deck(deck).playback.playing;
206        if was_playing { self.commands.push(HostCommand::StopPacketPlayback { deck, platter_handoff: true }); }
207        let frames = seconds * self.config.sample_rate;
208        let d = self.deck_mut(deck); d.needle.lifted = false; d.playback.playing = false; d.playback.current_seconds = seconds; d.scratch = ScratchState { active: true, pointer_id: Some(pointer_id), was_playing, started_at_seconds: seconds, target_position_frames: frames, rendered_position_frames: frames, target_rate: 0.0, base_rotation_degrees: rotation };
209        self.commands.push(HostCommand::SetPacketGain { deck, gain: 1.0, ramp_ms: 0 });
210        self.commands.push(HostCommand::SetScratchTransport { deck, hand_contact: true, motor_rate: self.deck(deck).playback.playback_rate });
211        self.commands.push(HostCommand::SetScratchTarget { deck, position_frames: frames, rate: 0.0, impulse: 0.0 });
212        self.commands.push(HostCommand::CaptureScratchStart { deck, start_seconds: seconds, rotation_degrees: rotation }); self.commands.push(HostCommand::RefreshView); Ok(())
213    }
214
215    fn move_scratch(&mut self, deck: DeckId, position: f64, rendered: f64, rate: f32, rotation: f64, impulse: f32) -> Result<(), PlayerError> {
216        if !self.deck(deck).scratch.active { return Err(PlayerError::ScratchNotActive); }
217        let sample_rate = self.config.sample_rate;
218        let d = self.deck_mut(deck); d.scratch.target_position_frames = position.max(0.0); d.scratch.rendered_position_frames = rendered.max(0.0); d.scratch.target_rate = rate; d.scratch.base_rotation_degrees = rotation; d.playback.current_seconds = position.max(0.0) / sample_rate;
219        self.commands.push(HostCommand::SetScratchTarget { deck, position_frames: position.max(0.0), rate, impulse: impulse.clamp(0.0, 1.0) }); self.commands.push(HostCommand::RefreshView); Ok(())
220    }
221
222    fn end_scratch(&mut self, deck: DeckId, rendered: f64, rotation: f64, resume: bool, save: bool, handoff: bool) -> Result<(), PlayerError> {
223        if !self.deck(deck).scratch.active { return Err(PlayerError::ScratchNotActive); }
224        let was_playing = self.deck(deck).scratch.was_playing;
225        let seconds = rendered.max(0.0) / self.config.sample_rate;
226        self.commands.push(HostCommand::CaptureScratchFinish { deck, end_seconds: seconds, rotation_degrees: rotation, save_sample: save });
227        { let d = self.deck_mut(deck); d.scratch = ScratchState::default(); d.playback.current_seconds = seconds; d.playback.playing = was_playing && resume; }
228        self.commands.push(HostCommand::SetScratchTransport { deck, hand_contact: false, motor_rate: if self.deck(deck).transport.motor_on { self.deck(deck).playback.playback_rate } else { 0.0 } });
229        if was_playing && resume { self.commands.push(HostCommand::StartPacketPlayback { deck, offset_seconds: seconds, rate: self.deck(deck).playback.playback_rate, platter_handoff: handoff }); }
230        self.commands.push(HostCommand::RefreshView); Ok(())
231    }
232
233    fn tick_regions(&mut self, now_ms: f64) {
234        let lead_elapsed = self.state.lead_in.active && now_ms >= self.state.lead_in.started_at_ms + self.state.lead_in.duration_ms;
235        let dead_elapsed = self.state.deadwax.active && now_ms >= self.state.deadwax.started_at_ms + self.state.deadwax.duration_ms;
236        if lead_elapsed { self.finish_region(SurfaceRegion::LeadIn); }
237        if dead_elapsed { self.finish_region(SurfaceRegion::Deadwax); }
238    }
239
240    fn emit_mixer(&mut self) {
241        let x = self.state.decks[0].mixer.crossfader;
242        let (a, b) = sharp_crossfader_gains(x, self.config.sharp_crossfader_width);
243        let a = a * self.state.decks[0].mixer.channel_gain;
244        let b_needle = if self.state.decks[1].needle.lifted { 0.0 } else { 1.0 };
245        let b = if self.two_deck_mode && self.state.decks[1].loaded { b * self.state.decks[1].mixer.channel_gain * b_needle } else { 0.0 };
246        self.commands.push(HostCommand::SetMixerTrackGain { track: 0, gain: a, ramp_ms: 12 });
247        self.commands.push(HostCommand::SetMixerTrackGain { track: 1, gain: b, ramp_ms: 12 });
248    }
249}
250
251impl Default for PlayerEngine { fn default() -> Self { Self::new(PlayerConfig::default()) } }
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256    fn ready(e: &mut PlayerEngine) { e.dispatch(PlayerEvent::SetLoadState { deck: DeckId::A, status: LoadStatus::Ready, loaded: true, duration_seconds: 180.0 }).unwrap(); e.drain_commands(); }
257
258    #[test] fn needle_freezes_and_resumes_same_groove() {
259        let mut e=PlayerEngine::default(); ready(&mut e); e.dispatch(PlayerEvent::TogglePlayback { deck: DeckId::A }).unwrap(); e.drain_commands();
260        e.dispatch(PlayerEvent::SetNeedle { deck: DeckId::A, lifted: true, observed_playback_seconds: 12.25 }).unwrap();
261        assert_eq!(e.state().decks[0].playback.suspended_at_seconds, Some(12.25)); e.drain_commands();
262        e.dispatch(PlayerEvent::SetNeedle { deck: DeckId::A, lifted: false, observed_playback_seconds: 99.0 }).unwrap();
263        assert!(e.drain_commands().iter().any(|c| matches!(c, HostCommand::StartPacketPlayback { offset_seconds, platter_handoff: true, .. } if (*offset_seconds-12.25).abs()<1e-9)));
264    }
265    #[test] fn needle_reassertion_is_idempotent() {
266        let mut e=PlayerEngine::default(); ready(&mut e);
267        e.dispatch(PlayerEvent::SetNeedle { deck: DeckId::A, lifted: true, observed_playback_seconds: 10.0 }).unwrap();
268        assert!(e.drain_commands().is_empty());
269        e.dispatch(PlayerEvent::TogglePlayback { deck: DeckId::A }).unwrap(); e.drain_commands();
270        e.dispatch(PlayerEvent::SetNeedle { deck: DeckId::A, lifted: false, observed_playback_seconds: 0.0 }).unwrap();
271        assert!(e.drain_commands().is_empty());
272    }
273    #[test] fn explicit_stop_clears_suspended_resume_bookmark() {
274        let mut e=PlayerEngine::default(); ready(&mut e);
275        e.dispatch(PlayerEvent::TogglePlayback { deck: DeckId::A }).unwrap(); e.drain_commands();
276        e.dispatch(PlayerEvent::SetNeedle { deck: DeckId::A, lifted: true, observed_playback_seconds: 12.25 }).unwrap();
277        assert_eq!(e.state().decks[0].playback.suspended_at_seconds, Some(12.25));
278        e.drain_commands();
279        e.dispatch(PlayerEvent::TogglePlayback { deck: DeckId::A }).unwrap();
280        assert_eq!(e.state().decks[0].playback.suspended_at_seconds, None);
281        e.drain_commands();
282        e.dispatch(PlayerEvent::SetNeedle { deck: DeckId::A, lifted: false, observed_playback_seconds: 99.0 }).unwrap();
283        assert!(
284            !e.drain_commands().iter().any(|c| matches!(c, HostCommand::StartPacketPlayback { platter_handoff: true, .. }))
285        );
286    }
287    #[test] fn scratch_release_uses_rendered_not_target_position() {
288        let mut e=PlayerEngine::default(); ready(&mut e); e.dispatch(PlayerEvent::TogglePlayback { deck: DeckId::A }).unwrap(); e.drain_commands();
289        e.dispatch(PlayerEvent::BeginScratch { deck: DeckId::A, pointer_id: 1, playback_seconds: 2.0, rotation_degrees: 40.0 }).unwrap(); e.drain_commands();
290        e.dispatch(PlayerEvent::MoveScratch { deck: DeckId::A, position_frames: 200000.0, rendered_position_frames: 180000.0, rate: 1.0, rotation_degrees: 80.0, impulse: 0.0 }).unwrap(); e.drain_commands();
291        e.dispatch(PlayerEvent::EndScratch { deck: DeckId::A, rendered_position_frames: 180000.0, rotation_degrees: 80.0, resume_playback: true, save_sample: true, can_platter_handoff: true }).unwrap();
292        assert!(e.drain_commands().iter().any(|c| matches!(c, HostCommand::StartPacketPlayback { offset_seconds, .. } if (*offset_seconds-3.75).abs()<1e-9)));
293    }
294    #[test] fn sharp_crossfader_preserves_full_middle() { let (a,b)=sharp_crossfader_gains(0.5,0.08); assert_eq!((a,b),(1.0,1.0)); }
295    #[test] fn scratch_interrupts_surface_region() {
296        let mut e=PlayerEngine::default(); ready(&mut e); e.state.decks[0].needle.lifted=false;
297        e.dispatch(PlayerEvent::StartTimedRegion { region: SurfaceRegion::LeadIn, now_ms: 1.0, duration_seconds: 2.0 }).unwrap(); e.drain_commands();
298        e.dispatch(PlayerEvent::BeginScratch { deck: DeckId::A, pointer_id: 1, playback_seconds: 0.0, rotation_degrees: 0.0 }).unwrap();
299        assert!(!e.state().lead_in.active);
300    }
301}