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}