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 {
27 let mut state = PlayerState::default();
28 for deck in &mut state.decks {
29 deck.playback.source_sample_rate = config.sample_rate;
30 }
31 Self {
32 config,
33 state,
34 commands: Vec::new(),
35 two_deck_mode: false,
36 revision: 0,
37 }
38 }
39 pub fn state(&self) -> &PlayerState {
40 &self.state
41 }
42 pub fn revision(&self) -> u64 {
43 self.revision
44 }
45 pub fn view(&self) -> PlayerViewState {
46 PlayerViewState::from_state(&self.state, self.revision)
47 }
48 pub fn drain_commands(&mut self) -> Vec<HostCommand> {
49 std::mem::take(&mut self.commands)
50 }
51 fn deck(&self, id: DeckId) -> &DeckState {
52 &self.state.decks[id.index()]
53 }
54 fn deck_mut(&mut self, id: DeckId) -> &mut DeckState {
55 &mut self.state.decks[id.index()]
56 }
57
58 pub fn dispatch(&mut self, event: PlayerEvent) -> Result<(), PlayerError> {
59 match event {
60 PlayerEvent::HydrateDeck {
61 deck,
62 loaded,
63 status,
64 duration_seconds,
65 current_seconds,
66 playing,
67 transport_on,
68 needle_lifted,
69 playback_rate,
70 channel_gain,
71 } => {
72 if !duration_seconds.is_finite()
73 || !current_seconds.is_finite()
74 || !playback_rate.is_finite()
75 || !channel_gain.is_finite()
76 {
77 return Err(PlayerError::InvalidNumber);
78 }
79 let d = self.deck_mut(deck);
80 d.loaded = loaded;
81 d.playback.load_status = status;
82 d.playback.duration_seconds = duration_seconds.max(0.0);
83 d.playback.current_seconds =
84 current_seconds.clamp(0.0, d.playback.duration_seconds.max(current_seconds));
85 d.playback.playing = playing;
86 d.playback.playback_rate = playback_rate;
87 d.transport.motor_on = transport_on;
88 d.needle.lifted = needle_lifted;
89 d.mixer.channel_gain = channel_gain.clamp(0.0, 1.0);
90 }
91 PlayerEvent::SetActiveDeck { deck } => self.state.active_deck = deck,
92 PlayerEvent::SetSourceSampleRate { deck, sample_rate } => {
93 if !sample_rate.is_finite() || sample_rate <= 0.0 {
94 return Err(PlayerError::InvalidNumber);
95 }
96 let previous = self.source_sample_rate(deck);
97 let scale = sample_rate / previous;
98 let d = self.deck_mut(deck);
99 d.playback.source_sample_rate = sample_rate;
100 if d.scratch.active {
101 d.scratch.target_position_frames *= scale;
102 d.scratch.rendered_position_frames *= scale;
103 }
104 }
105 PlayerEvent::PlaybackPositionObserved { deck, seconds } => {
106 if !seconds.is_finite() {
107 return Err(PlayerError::InvalidNumber);
108 }
109 if !self.deck(deck).scratch.active
110 && self.deck(deck).playback.suspended_at_seconds.is_none()
111 {
112 let duration = self.deck(deck).playback.duration_seconds;
113 self.deck_mut(deck).playback.current_seconds =
114 seconds.clamp(0.0, duration.max(seconds));
115 }
116 }
117 PlayerEvent::PlaybackEnded { deck } => {
118 let duration = self.deck(deck).playback.duration_seconds;
119 let d = self.deck_mut(deck);
120 d.playback.playing = false;
121 d.playback.current_seconds = duration;
122 self.commands.push(HostCommand::RefreshView);
123 }
124 PlayerEvent::SetLoadState {
125 deck,
126 status,
127 loaded,
128 duration_seconds,
129 } => {
130 if !duration_seconds.is_finite() {
131 return Err(PlayerError::InvalidNumber);
132 }
133 let was_playing = self.deck(deck).playback.playing;
134 {
135 let d = self.deck_mut(deck);
136 d.loaded = loaded;
137 d.playback.load_status = status;
138 d.playback.duration_seconds = duration_seconds.max(0.0);
139 if !loaded {
140 d.playback.playing = false;
141 d.playback.pending_play_when_ready = d.transport.motor_on;
142 d.playback.suspended_at_seconds = None;
143 }
144 }
145 if !loaded && was_playing {
146 self.commands.push(HostCommand::StopPacketPlayback {
147 deck,
148 platter_handoff: true,
149 });
150 }
151 let should_start = status == LoadStatus::Ready
152 && self.deck(deck).transport.motor_on
153 && !self.deck(deck).needle.lifted
154 && !self.deck(deck).playback.playing;
155 if should_start {
156 let offset_seconds = self.deck(deck).playback.current_seconds;
157 let rate = self.deck(deck).playback.playback_rate;
158 let d = self.deck_mut(deck);
159 d.playback.pending_play_when_ready = false;
160 d.playback.playing = true;
161 self.commands.push(HostCommand::StartPacketPlayback {
162 deck,
163 offset_seconds,
164 rate,
165 platter_handoff: false,
166 });
167 }
168 }
169 PlayerEvent::ToggleTransport { deck } => {
170 let running = !self.deck(deck).transport.motor_on;
171 self.set_transport(deck, running);
172 }
173 PlayerEvent::SetTransport { deck, running } => self.set_transport(deck, running),
174 PlayerEvent::TogglePlayback { deck } => self.toggle_playback(deck)?,
175 PlayerEvent::SetNeedle {
176 deck,
177 lifted,
178 observed_playback_seconds,
179 } => self.set_needle(deck, lifted, observed_playback_seconds)?,
180 PlayerEvent::Seek { deck, seconds } => self.seek(deck, seconds)?,
181 PlayerEvent::SetPlaybackRate { deck, rate } => {
182 if !rate.is_finite() {
183 return Err(PlayerError::InvalidNumber);
184 }
185 self.deck_mut(deck).playback.playback_rate = rate;
186 if self.deck(deck).transport.motor_on {
187 self.commands.push(HostCommand::SetScratchTransport {
188 deck,
189 hand_contact: self.deck(deck).scratch.active,
190 motor_rate: rate,
191 grip: self.deck(deck).scratch.grip,
192 output_frame: 0,
193 });
194 }
195 if self.deck(deck).playback.playing {
196 self.commands.push(HostCommand::StartPacketPlayback {
197 deck,
198 offset_seconds: self.deck(deck).playback.current_seconds,
199 rate,
200 platter_handoff: true,
201 });
202 }
203 }
204 PlayerEvent::StartTimedRegion {
205 region,
206 now_ms,
207 duration_seconds,
208 } => self.start_region(region, now_ms, duration_seconds)?,
209 PlayerEvent::StopTimedRegion { region, completed } => {
210 self.stop_region(region, completed)
211 }
212 PlayerEvent::TimedRegionElapsed { region } => self.finish_region(region),
213 PlayerEvent::StartClipLoop { clip_id } => {
214 self.stop_regions();
215 self.state.clip_loop.active = true;
216 self.state.clip_loop.clip_id = Some(clip_id);
217 self.commands.push(HostCommand::RefreshView);
218 }
219 PlayerEvent::StopClipLoop => self.stop_clip_loop(),
220 PlayerEvent::BeginScratch {
221 deck,
222 pointer_id,
223 playback_seconds,
224 rotation_degrees,
225 rate,
226 impulse,
227 grip,
228 output_frame,
229 } => self.begin_scratch(
230 deck,
231 pointer_id,
232 playback_seconds,
233 rotation_degrees,
234 rate,
235 impulse,
236 grip,
237 output_frame,
238 )?,
239 PlayerEvent::MoveScratch {
240 deck,
241 position_frames,
242 rendered_position_frames,
243 rate,
244 rotation_degrees,
245 impulse,
246 grip,
247 output_frame,
248 } => self.move_scratch(
249 deck,
250 position_frames,
251 rendered_position_frames,
252 rate,
253 rotation_degrees,
254 impulse,
255 grip,
256 output_frame,
257 )?,
258 PlayerEvent::ScratchRenderedPosition {
259 deck,
260 rendered_position_frames,
261 } => {
262 if !rendered_position_frames.is_finite() {
263 return Err(PlayerError::InvalidNumber);
264 }
265 if self.deck(deck).scratch.active {
266 self.deck_mut(deck).scratch.rendered_position_frames =
267 rendered_position_frames.max(0.0);
268 }
269 }
270 PlayerEvent::EndScratch {
271 deck,
272 rendered_position_frames,
273 rotation_degrees,
274 resume_playback,
275 save_sample,
276 can_platter_handoff,
277 output_frame,
278 } => self.end_scratch(
279 deck,
280 rendered_position_frames,
281 rotation_degrees,
282 resume_playback,
283 save_sample,
284 can_platter_handoff,
285 output_frame,
286 )?,
287 PlayerEvent::SetCrossfader { value } => {
288 if !value.is_finite() {
289 return Err(PlayerError::InvalidNumber);
290 }
291 self.state.decks[0].mixer.crossfader = value.clamp(0.0, 1.0);
292 self.emit_mixer();
293 }
294 PlayerEvent::SetChannelGain { deck, value } => {
295 if !value.is_finite() {
296 return Err(PlayerError::InvalidNumber);
297 }
298 self.deck_mut(deck).mixer.channel_gain = value.clamp(0.0, 1.0);
299 self.emit_mixer();
300 }
301 PlayerEvent::SetTwoDeckMode { enabled } => {
302 self.two_deck_mode = enabled;
303 self.emit_mixer();
304 }
305 PlayerEvent::Tick { now_ms } => {
306 if !now_ms.is_finite() {
307 return Err(PlayerError::InvalidNumber);
308 }
309 self.tick_regions(now_ms);
310 }
311 }
312 self.revision = self.revision.wrapping_add(1);
313 Ok(())
314 }
315
316 fn set_transport(&mut self, deck: DeckId, running: bool) {
317 self.deck_mut(deck).transport.motor_on = running;
318 self.commands.push(HostCommand::SetMotor { deck, running });
319 let rate = if running {
320 self.deck(deck).playback.playback_rate
321 } else {
322 0.0
323 };
324 self.commands.push(HostCommand::SetScratchTransport {
325 deck,
326 hand_contact: false,
327 motor_rate: rate,
328 grip: 0.0,
329 output_frame: 0,
330 });
331 if running {
332 let should_play = self.deck(deck).playback.load_status == LoadStatus::Ready
333 && !self.deck(deck).needle.lifted
334 && !self.deck(deck).playback.playing;
335 if should_play {
336 let offset_seconds = self.deck(deck).playback.current_seconds;
337 let playback_rate = self.deck(deck).playback.playback_rate;
338 self.deck_mut(deck).playback.playing = true;
339 self.commands.push(HostCommand::SetPacketGain {
340 deck,
341 gain: 1.0,
342 ramp_ms: 0,
343 });
344 self.commands.push(HostCommand::StartPacketPlayback {
345 deck,
346 offset_seconds,
347 rate: playback_rate,
348 platter_handoff: false,
349 });
350 }
351 } else {
352 self.deck_mut(deck).playback.playing = false;
353 self.deck_mut(deck).playback.pending_play_when_ready = false;
354 self.deck_mut(deck).playback.suspended_at_seconds = None;
355 self.commands.push(HostCommand::StopPacketPlayback {
356 deck,
357 platter_handoff: true,
358 });
359 }
360 self.commands.push(HostCommand::RefreshView);
361 }
362
363 fn toggle_playback(&mut self, deck: DeckId) -> Result<(), PlayerError> {
364 if self.state.clip_loop.active {
365 self.stop_clip_loop();
366 self.set_needle(deck, true, self.deck(deck).playback.current_seconds)?;
367 return Ok(());
368 }
369 if self.deck(deck).scratch.active {
370 let rendered = self.deck(deck).scratch.rendered_position_frames;
371 let rot = self.deck(deck).scratch.base_rotation_degrees;
372 self.end_scratch(deck, rendered, rot, false, false, false, 0)?;
373 self.set_needle(deck, true, self.deck(deck).playback.current_seconds)?;
374 return Ok(());
375 }
376 if self.state.lead_in.active {
377 self.set_needle(deck, true, self.deck(deck).playback.current_seconds)?;
378 self.stop_region(SurfaceRegion::LeadIn, false);
379 self.commands.push(HostCommand::PublishTransportIntent);
380 return Ok(());
381 }
382 if self.state.deadwax.active {
383 self.set_needle(deck, true, self.deck(deck).playback.current_seconds)?;
384 self.stop_region(SurfaceRegion::Deadwax, false);
385 self.commands.push(HostCommand::PublishTransportIntent);
386 return Ok(());
387 }
388 if self.deck(deck).playback.load_status != LoadStatus::Ready {
389 let running = !self.deck(deck).transport.motor_on;
390 self.set_transport(deck, running);
391 self.deck_mut(deck).needle.lifted = true;
392 if self.deck(deck).playback.load_status == LoadStatus::Loading {
393 self.deck_mut(deck).playback.pending_play_when_ready = running;
394 }
395 return Ok(());
396 }
397 if self.deck(deck).playback.playing {
398 self.set_transport(deck, false);
401 self.deck_mut(deck).needle.lifted = true;
402 self.stop_regions();
403 } else {
404 self.deck_mut(deck).transport.motor_on = true;
405 self.deck_mut(deck).needle.lifted = false;
406 self.deck_mut(deck).playback.suspended_at_seconds = None;
407 self.deck_mut(deck).playback.playing = true;
408 self.commands.push(HostCommand::SetMotor {
409 deck,
410 running: true,
411 });
412 self.commands.push(HostCommand::SetPacketGain {
413 deck,
414 gain: 1.0,
415 ramp_ms: 0,
416 });
417 self.commands.push(HostCommand::StartPacketPlayback {
418 deck,
419 offset_seconds: self.deck(deck).playback.current_seconds,
420 rate: self.deck(deck).playback.playback_rate,
421 platter_handoff: false,
422 });
423 }
424 self.commands.push(HostCommand::RefreshView);
425 Ok(())
426 }
427
428 fn freezable(&self, deck: DeckId) -> bool {
429 deck != DeckId::A
430 || (!self.state.lead_in.active
431 && !self.state.deadwax.active
432 && !self.state.clip_loop.active
433 && !self.deck(deck).scratch.active)
434 }
435
436 fn set_needle(&mut self, deck: DeckId, lifted: bool, observed: f64) -> Result<(), PlayerError> {
437 if !observed.is_finite() {
438 return Err(PlayerError::InvalidNumber);
439 }
440 if self.deck(deck).needle.lifted == lifted {
441 return Ok(());
442 }
443 self.deck_mut(deck).needle.lifted = lifted;
444 if lifted {
445 if self.deck(deck).playback.playing
446 && self.deck(deck).transport.motor_on
447 && self.freezable(deck)
448 {
449 let t = observed.clamp(0.0, self.deck(deck).playback.duration_seconds.max(0.0));
450 let d = self.deck_mut(deck);
451 d.playback.suspended_at_seconds = Some(t);
452 d.playback.current_seconds = t;
453 }
454 self.commands.push(HostCommand::SetPacketGain {
455 deck,
456 gain: 0.0,
457 ramp_ms: 0,
458 });
459 } else {
460 self.commands.push(HostCommand::SetPacketGain {
461 deck,
462 gain: 1.0,
463 ramp_ms: 0,
464 });
465 self.deck_mut(deck).playback.suspended_at_seconds = None;
466 if self.deck(deck).transport.motor_on
467 && self.deck(deck).playback.load_status == LoadStatus::Ready
468 && self.freezable(deck)
469 {
470 let t = observed.clamp(0.0, self.deck(deck).playback.duration_seconds.max(0.0));
471 self.deck_mut(deck).playback.current_seconds = t;
472 self.deck_mut(deck).playback.playing = true;
473 self.commands.push(HostCommand::SetScratchPosition {
474 deck,
475 position_frames: t * self.source_sample_rate(deck),
476 impulse: 0.25,
477 });
478 self.commands.push(HostCommand::StartPacketPlayback {
479 deck,
480 offset_seconds: t,
481 rate: self.deck(deck).playback.playback_rate,
482 platter_handoff: true,
483 });
484 }
485 }
486 self.emit_mixer();
487 self.commands.push(HostCommand::RefreshView);
488 Ok(())
489 }
490
491 fn seek(&mut self, deck: DeckId, seconds: f64) -> Result<(), PlayerError> {
492 if !seconds.is_finite() {
493 return Err(PlayerError::InvalidNumber);
494 }
495 let t = seconds.clamp(0.0, self.deck(deck).playback.duration_seconds.max(0.0));
496
497 if deck == DeckId::A && (self.state.lead_in.active || self.state.deadwax.active) {
502 self.stop_regions();
503 }
504
505 self.deck_mut(deck).playback.suspended_at_seconds = None;
506 self.deck_mut(deck).playback.current_seconds = t;
507 self.commands.push(HostCommand::SetScratchPosition {
508 deck,
509 position_frames: t * self.source_sample_rate(deck),
510 impulse: 0.0,
511 });
512
513 let should_restart = self.deck(deck).transport.motor_on
514 && !self.deck(deck).needle.lifted
515 && self.deck(deck).playback.load_status == LoadStatus::Ready
516 && !self.deck(deck).scratch.active
517 && !self.state.clip_loop.active;
518
519 if should_restart {
520 self.deck_mut(deck).playback.suspended_at_seconds = None;
521 self.deck_mut(deck).playback.playing = true;
522 self.commands.push(HostCommand::SetPacketGain {
523 deck,
524 gain: 1.0,
525 ramp_ms: 0,
526 });
527 self.commands.push(HostCommand::StartPacketPlayback {
528 deck,
529 offset_seconds: t,
530 rate: self.deck(deck).playback.playback_rate,
531 platter_handoff: true,
532 });
533 } else {
534 self.commands.push(HostCommand::SeekPacketPlayback {
535 deck,
536 offset_seconds: t,
537 });
538 }
539
540 self.commands.push(HostCommand::RefreshView);
541 Ok(())
542 }
543
544 fn start_region(
545 &mut self,
546 region: SurfaceRegion,
547 now_ms: f64,
548 duration: f64,
549 ) -> Result<(), PlayerError> {
550 if !now_ms.is_finite() || !duration.is_finite() || duration <= 0.0 {
551 return Err(PlayerError::InvalidNumber);
552 }
553 let deck = DeckId::A;
554 if self.deck(deck).playback.load_status != LoadStatus::Ready
555 || self.deck(deck).scratch.active
556 || self.state.clip_loop.active
557 {
558 return Err(PlayerError::AudioNotReady);
559 }
560 self.stop_regions();
561 if !self.deck(deck).transport.motor_on {
562 self.deck_mut(deck).transport.motor_on = true;
563 self.commands.push(HostCommand::SetMotor {
564 deck,
565 running: true,
566 });
567 }
568 self.commands.push(HostCommand::SetScratchTransport {
572 deck,
573 hand_contact: false,
574 motor_rate: self.deck(deck).playback.playback_rate,
575 grip: 0.0,
576 output_frame: 0,
577 });
578 self.deck_mut(deck).needle.lifted = false;
579 let target = match region {
580 SurfaceRegion::LeadIn => &mut self.state.lead_in,
581 SurfaceRegion::Deadwax => &mut self.state.deadwax,
582 SurfaceRegion::Programme => return Err(PlayerError::InvalidNumber),
583 };
584 target.active = true;
585 target.completed = false;
586 target.started_at_ms = now_ms;
587 target.duration_ms = duration * 1000.0;
588 self.deck_mut(deck).playback.playing = true;
589 self.commands.push(HostCommand::SetPacketGain {
590 deck,
591 gain: 1.0,
592 ramp_ms: 0,
593 });
594 self.commands.push(HostCommand::StopPacketPlayback {
595 deck,
596 platter_handoff: true,
600 });
601 self.commands.push(HostCommand::StartSurfaceRegion {
602 region,
603 duration_seconds: duration,
604 });
605 self.commands.push(HostCommand::RefreshView);
606 Ok(())
607 }
608 fn stop_regions(&mut self) {
609 if self.state.lead_in.active {
610 self.stop_region(SurfaceRegion::LeadIn, false);
611 }
612 if self.state.deadwax.active {
613 self.stop_region(SurfaceRegion::Deadwax, false);
614 }
615 }
616 fn stop_region(&mut self, region: SurfaceRegion, completed: bool) {
617 let target = match region {
618 SurfaceRegion::LeadIn => &mut self.state.lead_in,
619 SurfaceRegion::Deadwax => &mut self.state.deadwax,
620 SurfaceRegion::Programme => return,
621 };
622 if target.active {
623 target.active = false;
624 target.completed = completed;
625 self.commands
626 .push(HostCommand::StopSurfaceRegion { region });
627 self.commands.push(HostCommand::RefreshView);
628 }
629 }
630 fn finish_region(&mut self, region: SurfaceRegion) {
631 if region == SurfaceRegion::Deadwax {
632 self.state.deadwax.completed = true;
633 self.commands.push(HostCommand::RefreshView);
634 return;
635 }
636 self.stop_region(region, true);
637 if region == SurfaceRegion::LeadIn {
638 let t = self.deck(DeckId::A).playback.current_seconds;
639 self.commands.push(HostCommand::StartPacketPlayback {
640 deck: DeckId::A,
641 offset_seconds: t,
642 rate: self.deck(DeckId::A).playback.playback_rate,
643 platter_handoff: true,
644 });
645 }
646 }
647 fn stop_clip_loop(&mut self) {
648 if self.state.clip_loop.active {
649 self.state.clip_loop = ClipLoopState::default();
650 self.commands.push(HostCommand::StopClipLoop);
651 self.commands.push(HostCommand::RefreshView);
652 }
653 }
654
655 fn begin_scratch(
656 &mut self,
657 deck: DeckId,
658 pointer_id: i32,
659 seconds: f64,
660 rotation: f64,
661 rate: f32,
662 impulse: f32,
663 grip: f32,
664 output_frame: u64,
665 ) -> Result<(), PlayerError> {
666 if !seconds.is_finite()
667 || !rotation.is_finite()
668 || !rate.is_finite()
669 || !impulse.is_finite()
670 || !grip.is_finite()
671 {
672 return Err(PlayerError::InvalidNumber);
673 }
674 if self.deck(deck).scratch.active {
675 return Err(PlayerError::ScratchAlreadyActive);
676 }
677 if self.deck(deck).playback.load_status != LoadStatus::Ready {
678 return Err(PlayerError::AudioNotReady);
679 }
680 self.stop_regions();
681 self.stop_clip_loop();
682 let seconds = seconds.clamp(0.0, self.deck(deck).playback.duration_seconds.max(0.0));
683 let was_playing = self.deck(deck).playback.playing;
684 let frames = seconds * self.source_sample_rate(deck);
685 let needle_lifted = self.deck(deck).needle.lifted;
686 let d = self.deck_mut(deck);
687 d.playback.current_seconds = seconds;
688 d.scratch = ScratchState {
689 active: true,
690 pointer_id: Some(pointer_id),
691 was_playing,
692 started_at_seconds: seconds,
693 target_position_frames: frames,
694 rendered_position_frames: frames,
695 target_rate: rate,
696 grip: grip.clamp(0.0, 1.0),
697 base_rotation_degrees: rotation,
698 };
699 self.commands.push(HostCommand::SetPacketGain {
700 deck,
701 gain: if needle_lifted { 0.0 } else { 1.0 },
702 ramp_ms: 0,
703 });
704 self.commands.push(HostCommand::SetScratchTransport {
705 deck,
706 hand_contact: true,
707 motor_rate: if self.deck(deck).transport.motor_on {
708 self.deck(deck).playback.playback_rate
709 } else {
710 0.0
711 },
712 grip: grip.clamp(0.0, 1.0),
713 output_frame,
714 });
715 self.commands.push(HostCommand::SetScratchTarget {
716 deck,
717 position_frames: frames,
718 rate,
719 impulse: impulse.clamp(0.0, 1.0),
720 output_frame,
721 });
722 self.commands.push(HostCommand::CaptureScratchStart {
723 deck,
724 start_seconds: seconds,
725 rotation_degrees: rotation,
726 output_frame,
727 });
728 self.commands.push(HostCommand::RefreshView);
729 Ok(())
730 }
731
732 fn move_scratch(
733 &mut self,
734 deck: DeckId,
735 position: f64,
736 rendered: f64,
737 rate: f32,
738 rotation: f64,
739 impulse: f32,
740 grip: f32,
741 output_frame: u64,
742 ) -> Result<(), PlayerError> {
743 if !position.is_finite()
744 || !rendered.is_finite()
745 || !rate.is_finite()
746 || !rotation.is_finite()
747 || !impulse.is_finite()
748 || !grip.is_finite()
749 {
750 return Err(PlayerError::InvalidNumber);
751 }
752 if !self.deck(deck).scratch.active {
753 return Err(PlayerError::ScratchNotActive);
754 }
755 let sample_rate = self.source_sample_rate(deck);
756 let d = self.deck_mut(deck);
757 d.scratch.target_position_frames = position.max(0.0);
758 d.scratch.rendered_position_frames = rendered.max(0.0);
759 d.scratch.target_rate = rate;
760 d.scratch.grip = grip.clamp(0.0, 1.0);
761 d.scratch.base_rotation_degrees = rotation;
762 d.playback.current_seconds = position.max(0.0) / sample_rate;
763 self.commands.push(HostCommand::SetScratchTransport {
764 deck,
765 hand_contact: true,
766 motor_rate: if self.deck(deck).transport.motor_on {
767 self.deck(deck).playback.playback_rate
768 } else {
769 0.0
770 },
771 grip: grip.clamp(0.0, 1.0),
772 output_frame,
773 });
774 self.commands.push(HostCommand::SetScratchTarget {
775 deck,
776 position_frames: position.max(0.0),
777 rate,
778 impulse: impulse.clamp(0.0, 1.0),
779 output_frame,
780 });
781 self.commands.push(HostCommand::RefreshView);
782 Ok(())
783 }
784
785 fn end_scratch(
786 &mut self,
787 deck: DeckId,
788 rendered: f64,
789 rotation: f64,
790 resume: bool,
791 save: bool,
792 handoff: bool,
793 output_frame: u64,
794 ) -> Result<(), PlayerError> {
795 if !rendered.is_finite() || !rotation.is_finite() {
796 return Err(PlayerError::InvalidNumber);
797 }
798 if !self.deck(deck).scratch.active {
799 return Err(PlayerError::ScratchNotActive);
800 }
801 let was_playing = self.deck(deck).scratch.was_playing;
802 let seconds = rendered.max(0.0) / self.source_sample_rate(deck);
803 self.commands.push(HostCommand::CaptureScratchFinish {
804 deck,
805 end_seconds: seconds,
806 rotation_degrees: rotation,
807 save_sample: save,
808 output_frame,
809 });
810 let should_resume = was_playing && resume;
811 {
812 let d = self.deck_mut(deck);
813 d.scratch = ScratchState::default();
814 d.playback.current_seconds = seconds;
815 d.playback.playing = should_resume;
816 }
817 self.commands.push(HostCommand::SetScratchTransport {
818 deck,
819 hand_contact: false,
820 motor_rate: if self.deck(deck).transport.motor_on {
821 self.deck(deck).playback.playback_rate
822 } else {
823 0.0
824 },
825 grip: 0.0,
826 output_frame,
827 });
828 if should_resume {
829 if !handoff {
830 self.commands.push(HostCommand::StartPacketPlayback {
831 deck,
832 offset_seconds: seconds,
833 rate: self.deck(deck).playback.playback_rate,
834 platter_handoff: false,
835 });
836 }
837 } else {
838 let keep_silent_platter_running =
839 self.deck(deck).needle.lifted && self.deck(deck).transport.motor_on;
840 self.commands.push(HostCommand::StopPacketPlayback {
841 deck,
842 platter_handoff: keep_silent_platter_running,
843 });
844 }
845 self.commands.push(HostCommand::RefreshView);
846 Ok(())
847 }
848
849 fn source_sample_rate(&self, deck: DeckId) -> f64 {
850 let rate = self.deck(deck).playback.source_sample_rate;
851 if rate.is_finite() && rate > 0.0 {
852 rate
853 } else {
854 self.config.sample_rate
855 }
856 }
857
858 fn tick_regions(&mut self, now_ms: f64) {
859 let lead_elapsed = self.state.lead_in.active
860 && now_ms >= self.state.lead_in.started_at_ms + self.state.lead_in.duration_ms;
861 if lead_elapsed {
862 self.finish_region(SurfaceRegion::LeadIn);
863 }
864 if self.state.deadwax.active
865 && now_ms >= self.state.deadwax.started_at_ms + self.state.deadwax.duration_ms
866 {
867 self.state.deadwax.completed = true;
868 }
869 }
870
871 fn emit_mixer(&mut self) {
872 let x = self.state.decks[0].mixer.crossfader;
873 let (a, b) = sharp_crossfader_gains(x, self.config.sharp_crossfader_width);
874 let a = a * self.state.decks[0].mixer.channel_gain;
875 let b_needle = if self.state.decks[1].needle.lifted {
876 0.0
877 } else {
878 1.0
879 };
880 let b = if self.two_deck_mode && self.state.decks[1].loaded {
881 b * self.state.decks[1].mixer.channel_gain * b_needle
882 } else {
883 0.0
884 };
885 self.commands.push(HostCommand::SetMixerTrackGain {
887 track: 0,
888 gain: a,
889 ramp_ms: 5,
890 });
891 self.commands.push(HostCommand::SetMixerTrackGain {
892 track: 1,
893 gain: b,
894 ramp_ms: 5,
895 });
896 }
897}
898
899impl Default for PlayerEngine {
900 fn default() -> Self {
901 Self::new(PlayerConfig::default())
902 }
903}
904
905#[cfg(test)]
906mod tests {
907 use super::*;
908 fn ready(e: &mut PlayerEngine) {
909 e.dispatch(PlayerEvent::SetLoadState {
910 deck: DeckId::A,
911 status: LoadStatus::Ready,
912 loaded: true,
913 duration_seconds: 180.0,
914 })
915 .unwrap();
916 e.drain_commands();
917 }
918
919 #[test]
920 fn needle_freezes_and_resumes_same_groove() {
921 let mut e = PlayerEngine::default();
922 ready(&mut e);
923 e.dispatch(PlayerEvent::TogglePlayback { deck: DeckId::A })
924 .unwrap();
925 e.drain_commands();
926 e.dispatch(PlayerEvent::SetNeedle {
927 deck: DeckId::A,
928 lifted: true,
929 observed_playback_seconds: 12.25,
930 })
931 .unwrap();
932 assert_eq!(
933 e.state().decks[0].playback.suspended_at_seconds,
934 Some(12.25)
935 );
936 e.drain_commands();
937 e.dispatch(PlayerEvent::SetNeedle {
938 deck: DeckId::A,
939 lifted: false,
940 observed_playback_seconds: 12.25,
941 })
942 .unwrap();
943 assert!(e.drain_commands().iter().any(|c| matches!(c, HostCommand::StartPacketPlayback { offset_seconds, platter_handoff: true, .. } if (*offset_seconds-12.25).abs()<1e-9)));
944 }
945
946 #[test]
947 fn empty_deck_motor_survives_loading_until_the_needle_can_start_programme() {
948 let mut engine = PlayerEngine::default();
949 engine
950 .dispatch(PlayerEvent::SetTransport {
951 deck: DeckId::A,
952 running: true,
953 })
954 .unwrap();
955 assert!(engine.state().decks[0].transport.motor_on);
956 assert!(!engine.state().decks[0].playback.playing);
957 assert!(engine.drain_commands().iter().any(|command| matches!(
958 command,
959 HostCommand::SetMotor {
960 deck: DeckId::A,
961 running: true
962 }
963 )));
964
965 engine
966 .dispatch(PlayerEvent::SetLoadState {
967 deck: DeckId::A,
968 status: LoadStatus::Loading,
969 loaded: false,
970 duration_seconds: 0.0,
971 })
972 .unwrap();
973 assert!(engine.state().decks[0].transport.motor_on);
974 assert!(engine.state().decks[0].needle.lifted);
975
976 ready(&mut engine);
977 assert!(engine.state().decks[0].transport.motor_on);
978 assert!(!engine.state().decks[0].playback.playing);
979 engine
980 .dispatch(PlayerEvent::SetNeedle {
981 deck: DeckId::A,
982 lifted: false,
983 observed_playback_seconds: 0.0,
984 })
985 .unwrap();
986 assert!(engine.state().decks[0].playback.playing);
987 assert!(engine.drain_commands().iter().any(|command| matches!(
988 command,
989 HostCommand::StartPacketPlayback {
990 deck: DeckId::A,
991 offset_seconds,
992 ..
993 } if offset_seconds.abs() < f64::EPSILON
994 )));
995 }
996
997 #[test]
998 fn lowered_needle_uses_current_arm_position_not_stale_lift_position() {
999 let mut e = PlayerEngine::default();
1000 ready(&mut e);
1001 e.dispatch(PlayerEvent::TogglePlayback { deck: DeckId::A })
1002 .unwrap();
1003 e.drain_commands();
1004 e.dispatch(PlayerEvent::SetNeedle {
1005 deck: DeckId::A,
1006 lifted: true,
1007 observed_playback_seconds: 12.25,
1008 })
1009 .unwrap();
1010 assert_eq!(
1011 e.state().decks[0].playback.suspended_at_seconds,
1012 Some(12.25)
1013 );
1014 e.drain_commands();
1015 e.dispatch(PlayerEvent::SetNeedle {
1016 deck: DeckId::A,
1017 lifted: false,
1018 observed_playback_seconds: 99.0,
1019 })
1020 .unwrap();
1021 assert!(e.drain_commands().iter().any(|c| matches!(c, HostCommand::StartPacketPlayback { offset_seconds, platter_handoff: true, .. } if (*offset_seconds-99.0).abs()<1e-9)));
1022 }
1023 #[test]
1024 fn seek_rearms_packet_playback_after_deadwax_with_needle_down() {
1025 let mut e = PlayerEngine::default();
1026 ready(&mut e);
1027 e.state.decks[0].transport.motor_on = true;
1028 e.state.decks[0].needle.lifted = false;
1029 e.state.decks[0].playback.playing = false;
1030 e.state.decks[0].playback.current_seconds = 120.0;
1031 e.drain_commands();
1032 e.dispatch(PlayerEvent::Seek {
1033 deck: DeckId::A,
1034 seconds: 12.0,
1035 })
1036 .unwrap();
1037 let commands = e.drain_commands();
1038 assert!(commands.iter().any(|c| matches!(c, HostCommand::StartPacketPlayback { deck: DeckId::A, offset_seconds, .. } if (*offset_seconds - 12.0).abs() < 0.001)));
1039 assert!(e.state().decks[0].playback.playing);
1040 }
1041
1042 #[test]
1043 fn needle_reassertion_is_idempotent() {
1044 let mut e = PlayerEngine::default();
1045 ready(&mut e);
1046 e.dispatch(PlayerEvent::SetNeedle {
1047 deck: DeckId::A,
1048 lifted: true,
1049 observed_playback_seconds: 10.0,
1050 })
1051 .unwrap();
1052 assert!(e.drain_commands().is_empty());
1053 e.dispatch(PlayerEvent::TogglePlayback { deck: DeckId::A })
1054 .unwrap();
1055 e.drain_commands();
1056 e.dispatch(PlayerEvent::SetNeedle {
1057 deck: DeckId::A,
1058 lifted: false,
1059 observed_playback_seconds: 0.0,
1060 })
1061 .unwrap();
1062 assert!(e.drain_commands().is_empty());
1063 }
1064 #[test]
1065 fn explicit_stop_clears_suspended_resume_bookmark() {
1066 let mut e = PlayerEngine::default();
1067 ready(&mut e);
1068 e.dispatch(PlayerEvent::TogglePlayback { deck: DeckId::A })
1069 .unwrap();
1070 e.drain_commands();
1071 e.dispatch(PlayerEvent::SetNeedle {
1072 deck: DeckId::A,
1073 lifted: true,
1074 observed_playback_seconds: 12.25,
1075 })
1076 .unwrap();
1077 assert_eq!(
1078 e.state().decks[0].playback.suspended_at_seconds,
1079 Some(12.25)
1080 );
1081 e.drain_commands();
1082 e.dispatch(PlayerEvent::TogglePlayback { deck: DeckId::A })
1083 .unwrap();
1084 assert_eq!(e.state().decks[0].playback.suspended_at_seconds, None);
1085 e.drain_commands();
1086 e.dispatch(PlayerEvent::SetNeedle {
1087 deck: DeckId::A,
1088 lifted: false,
1089 observed_playback_seconds: 99.0,
1090 })
1091 .unwrap();
1092 assert!(!e.drain_commands().iter().any(|c| matches!(
1093 c,
1094 HostCommand::StartPacketPlayback {
1095 platter_handoff: true,
1096 ..
1097 }
1098 )));
1099 }
1100 #[test]
1101 fn scratch_release_uses_rendered_not_target_position() {
1102 let mut e = PlayerEngine::default();
1103 ready(&mut e);
1104 e.dispatch(PlayerEvent::TogglePlayback { deck: DeckId::A })
1105 .unwrap();
1106 e.drain_commands();
1107 e.dispatch(PlayerEvent::BeginScratch {
1108 deck: DeckId::A,
1109 pointer_id: 1,
1110 playback_seconds: 2.0,
1111 rotation_degrees: 40.0,
1112 rate: 0.0,
1113 impulse: 0.22,
1114 grip: 1.0,
1115 output_frame: 0,
1116 })
1117 .unwrap();
1118 e.drain_commands();
1119 e.dispatch(PlayerEvent::MoveScratch {
1120 deck: DeckId::A,
1121 position_frames: 200000.0,
1122 rendered_position_frames: 180000.0,
1123 rate: 1.0,
1124 rotation_degrees: 80.0,
1125 impulse: 0.0,
1126 grip: 1.0,
1127 output_frame: 0,
1128 })
1129 .unwrap();
1130 e.drain_commands();
1131 e.dispatch(PlayerEvent::EndScratch {
1132 deck: DeckId::A,
1133 rendered_position_frames: 180000.0,
1134 rotation_degrees: 80.0,
1135 resume_playback: true,
1136 save_sample: true,
1137 can_platter_handoff: true,
1138 output_frame: 34_567,
1139 })
1140 .unwrap();
1141 let commands = e.drain_commands();
1142 assert!(commands.iter().any(|command| matches!(
1143 command,
1144 HostCommand::CaptureScratchFinish {
1145 output_frame: 34_567,
1146 ..
1147 }
1148 )));
1149 assert!(commands.iter().any(|command| matches!(
1150 command,
1151 HostCommand::SetScratchTransport {
1152 hand_contact: false,
1153 output_frame: 34_567,
1154 ..
1155 }
1156 )));
1157 assert!(!commands
1158 .iter()
1159 .any(|c| matches!(c, HostCommand::StartPacketPlayback { .. })));
1160 assert!((e.state().decks[0].playback.current_seconds - 3.75).abs() < 1e-9);
1161 assert!(e.state().decks[0].playback.playing);
1162 }
1163 #[test]
1164 fn scratch_positions_use_the_programme_source_clock() {
1165 for source_rate in [44_100.0, 48_000.0, 96_000.0] {
1166 let mut e = PlayerEngine::default();
1167 ready(&mut e);
1168 e.dispatch(PlayerEvent::SetSourceSampleRate {
1169 deck: DeckId::A,
1170 sample_rate: source_rate,
1171 })
1172 .unwrap();
1173 e.dispatch(PlayerEvent::TogglePlayback { deck: DeckId::A })
1174 .unwrap();
1175 e.drain_commands();
1176 e.dispatch(PlayerEvent::BeginScratch {
1177 deck: DeckId::A,
1178 pointer_id: 1,
1179 playback_seconds: 2.0,
1180 rotation_degrees: 0.0,
1181 rate: 0.0,
1182 impulse: 0.22,
1183 grip: 1.0,
1184 output_frame: 0,
1185 })
1186 .unwrap();
1187 let expected_start = source_rate * 2.0;
1188 assert!(e.drain_commands().iter().any(|command| matches!(command,
1189 HostCommand::SetScratchTarget { position_frames, .. } if (*position_frames - expected_start).abs() < f64::EPSILON
1190 )));
1191 e.dispatch(PlayerEvent::EndScratch {
1192 deck: DeckId::A,
1193 rendered_position_frames: source_rate * 3.0,
1194 rotation_degrees: 0.0,
1195 resume_playback: true,
1196 save_sample: false,
1197 can_platter_handoff: true,
1198 output_frame: 0,
1199 })
1200 .unwrap();
1201 assert!((e.state().decks[0].playback.current_seconds - 3.0).abs() < f64::EPSILON);
1202 }
1203 }
1204 #[test]
1205 fn scratching_a_lifted_record_stays_silent_and_lifted() {
1206 let mut e = PlayerEngine::default();
1207 ready(&mut e);
1208 assert!(e.state().decks[0].needle.lifted);
1209 e.dispatch(PlayerEvent::BeginScratch {
1210 deck: DeckId::A,
1211 pointer_id: 7,
1212 playback_seconds: 2.0,
1213 rotation_degrees: 0.0,
1214 rate: 0.0,
1215 impulse: 0.22,
1216 grip: 1.0,
1217 output_frame: 0,
1218 })
1219 .unwrap();
1220 let commands = e.drain_commands();
1221 assert!(e.state().decks[0].needle.lifted);
1222 assert!(commands.iter().any(
1223 |command| matches!(command, HostCommand::SetPacketGain { gain, .. } if *gain == 0.0)
1224 ));
1225 }
1226 #[test]
1227 fn scratch_begin_intent_is_clamped_and_forwarded_to_the_audio_engine() {
1228 let mut e = PlayerEngine::default();
1229 ready(&mut e);
1230 e.dispatch(PlayerEvent::BeginScratch {
1231 deck: DeckId::A,
1232 pointer_id: 7,
1233 playback_seconds: 2.0,
1234 rotation_degrees: 0.0,
1235 rate: -1.25,
1236 impulse: 0.6,
1237 grip: 0.25,
1238 output_frame: 12_345,
1239 })
1240 .unwrap();
1241 assert!((e.state().decks[0].scratch.target_rate - -1.25).abs() < f32::EPSILON);
1242 assert!((e.state().decks[0].scratch.grip - 0.25).abs() < f32::EPSILON);
1243 let commands = e.drain_commands();
1244 assert!(commands.iter().any(|command| matches!(
1245 command,
1246 HostCommand::SetScratchTransport { hand_contact: true, grip, output_frame: 12_345, .. }
1247 if (*grip - 0.25).abs() < f32::EPSILON
1248 )));
1249 assert!(commands.iter().any(|command| matches!(
1250 command,
1251 HostCommand::SetScratchTarget { rate, impulse, output_frame: 12_345, .. }
1252 if (*rate - -1.25).abs() < f32::EPSILON
1253 && (*impulse - 0.6).abs() < f32::EPSILON
1254 )));
1255
1256 e.dispatch(PlayerEvent::MoveScratch {
1257 deck: DeckId::A,
1258 position_frames: 96_000.0,
1259 rendered_position_frames: 96_000.0,
1260 rate: 0.5,
1261 rotation_degrees: 20.0,
1262 impulse: 0.0,
1263 grip: 2.0,
1264 output_frame: 23_456,
1265 })
1266 .unwrap();
1267 assert_eq!(e.state().decks[0].scratch.grip, 1.0);
1268 assert!(e.drain_commands().iter().any(|command| matches!(
1269 command,
1270 HostCommand::SetScratchTransport { hand_contact: true, grip, output_frame: 23_456, .. }
1271 if (*grip - 1.0).abs() < f32::EPSILON
1272 )));
1273 }
1274 #[test]
1275 fn scratch_release_restarts_from_rendered_position_without_handoff() {
1276 let mut e = PlayerEngine::default();
1277 ready(&mut e);
1278 e.dispatch(PlayerEvent::TogglePlayback { deck: DeckId::A })
1279 .unwrap();
1280 e.drain_commands();
1281 e.dispatch(PlayerEvent::BeginScratch {
1282 deck: DeckId::A,
1283 pointer_id: 1,
1284 playback_seconds: 2.0,
1285 rotation_degrees: 0.0,
1286 rate: 0.0,
1287 impulse: 0.22,
1288 grip: 1.0,
1289 output_frame: 0,
1290 })
1291 .unwrap();
1292 e.drain_commands();
1293 e.dispatch(PlayerEvent::EndScratch {
1294 deck: DeckId::A,
1295 rendered_position_frames: 144_000.0,
1296 rotation_degrees: 0.0,
1297 resume_playback: true,
1298 save_sample: false,
1299 can_platter_handoff: false,
1300 output_frame: 0,
1301 })
1302 .unwrap();
1303 assert!(e.drain_commands().iter().any(|command| matches!(command,
1304 HostCommand::StartPacketPlayback { offset_seconds, platter_handoff: false, .. } if (*offset_seconds - 3.0).abs() < f64::EPSILON
1305 )));
1306 }
1307 #[test]
1308 fn scratch_release_without_resume_stops_the_dsp() {
1309 let mut e = PlayerEngine::default();
1310 ready(&mut e);
1311 e.dispatch(PlayerEvent::TogglePlayback { deck: DeckId::A })
1312 .unwrap();
1313 e.drain_commands();
1314 e.dispatch(PlayerEvent::BeginScratch {
1315 deck: DeckId::A,
1316 pointer_id: 1,
1317 playback_seconds: 2.0,
1318 rotation_degrees: 0.0,
1319 rate: 0.0,
1320 impulse: 0.22,
1321 grip: 1.0,
1322 output_frame: 0,
1323 })
1324 .unwrap();
1325 e.drain_commands();
1326 e.dispatch(PlayerEvent::EndScratch {
1327 deck: DeckId::A,
1328 rendered_position_frames: 96_000.0,
1329 rotation_degrees: 0.0,
1330 resume_playback: false,
1331 save_sample: false,
1332 can_platter_handoff: true,
1333 output_frame: 0,
1334 })
1335 .unwrap();
1336 assert!(!e.state().decks[0].playback.playing);
1337 assert!(e.drain_commands().iter().any(|command| matches!(
1338 command,
1339 HostCommand::StopPacketPlayback {
1340 platter_handoff: false,
1341 ..
1342 }
1343 )));
1344 }
1345 #[test]
1346 fn changing_rpm_updates_the_live_motor_target() {
1347 let mut e = PlayerEngine::default();
1348 ready(&mut e);
1349 e.dispatch(PlayerEvent::SetTransport {
1350 deck: DeckId::A,
1351 running: true,
1352 })
1353 .unwrap();
1354 e.drain_commands();
1355 e.dispatch(PlayerEvent::SetPlaybackRate {
1356 deck: DeckId::A,
1357 rate: 45.0 / 33.333333,
1358 })
1359 .unwrap();
1360 assert!(e.drain_commands().iter().any(|command| matches!(command,
1361 HostCommand::SetScratchTransport { hand_contact: false, motor_rate, .. } if (*motor_rate - 45.0 / 33.333333).abs() < f32::EPSILON
1362 )));
1363 }
1364 #[test]
1365 fn scratch_release_transition_matrix_is_consistent() {
1366 for motor_on in [false, true] {
1367 for needle_lifted in [false, true] {
1368 for was_playing in [false, true] {
1369 for resume in [false, true] {
1370 for handoff in [false, true] {
1371 let mut e = PlayerEngine::default();
1372 ready(&mut e);
1373 e.state.decks[0].transport.motor_on = motor_on;
1374 e.state.decks[0].needle.lifted = needle_lifted;
1375 e.state.decks[0].playback.playing = was_playing;
1376 e.dispatch(PlayerEvent::BeginScratch {
1377 deck: DeckId::A,
1378 pointer_id: 1,
1379 playback_seconds: 1.0,
1380 rotation_degrees: 0.0,
1381 rate: 0.0,
1382 impulse: 0.22,
1383 grip: 1.0,
1384 output_frame: 0,
1385 })
1386 .unwrap();
1387 e.drain_commands();
1388 e.dispatch(PlayerEvent::EndScratch {
1389 deck: DeckId::A,
1390 rendered_position_frames: 48_000.0,
1391 rotation_degrees: 0.0,
1392 resume_playback: resume,
1393 save_sample: false,
1394 can_platter_handoff: handoff,
1395 output_frame: 0,
1396 })
1397 .unwrap();
1398 let commands = e.drain_commands();
1399 let should_resume = was_playing && resume;
1400 assert_eq!(e.state.decks[0].needle.lifted, needle_lifted);
1401 assert_eq!(e.state.decks[0].playback.playing, should_resume);
1402 assert_eq!(
1403 commands.iter().any(|command| matches!(
1404 command,
1405 HostCommand::StartPacketPlayback {
1406 platter_handoff: false,
1407 ..
1408 }
1409 )),
1410 should_resume && !handoff,
1411 );
1412 assert_eq!(
1413 commands.iter().any(|command| matches!(
1414 command,
1415 HostCommand::StopPacketPlayback {
1416 platter_handoff: false,
1417 ..
1418 }
1419 )),
1420 !should_resume && !(needle_lifted && motor_on),
1421 );
1422 assert_eq!(
1423 commands.iter().any(|command| matches!(
1424 command,
1425 HostCommand::StopPacketPlayback {
1426 platter_handoff: true,
1427 ..
1428 }
1429 )),
1430 !should_resume && needle_lifted && motor_on,
1431 );
1432 }
1433 }
1434 }
1435 }
1436 }
1437 }
1438 #[test]
1439 fn sharp_crossfader_preserves_full_middle() {
1440 let (a, b) = sharp_crossfader_gains(0.5, DEFAULT_SHARP_CROSSFADER_WIDTH);
1441 assert_eq!((a, b), (1.0, 1.0));
1442 }
1443
1444 #[test]
1445 fn deadwax_transition_preserves_platter_handoff_and_command_order() {
1446 let mut engine = PlayerEngine::default();
1447 ready(&mut engine);
1448 engine
1449 .dispatch(PlayerEvent::TogglePlayback { deck: DeckId::A })
1450 .unwrap();
1451 engine.drain_commands();
1452 engine
1453 .dispatch(PlayerEvent::PlaybackEnded { deck: DeckId::A })
1454 .unwrap();
1455 engine.drain_commands();
1456
1457 engine
1458 .dispatch(PlayerEvent::StartTimedRegion {
1459 region: SurfaceRegion::Deadwax,
1460 now_ms: 10.0,
1461 duration_seconds: 3.6,
1462 })
1463 .unwrap();
1464 let commands = engine.drain_commands();
1465 let scratch_transport = commands
1466 .iter()
1467 .position(|command| {
1468 matches!(
1469 command,
1470 HostCommand::SetScratchTransport {
1471 hand_contact: false,
1472 ..
1473 }
1474 )
1475 })
1476 .unwrap();
1477 let stop_packet = commands
1478 .iter()
1479 .position(|command| {
1480 matches!(
1481 command,
1482 HostCommand::StopPacketPlayback {
1483 platter_handoff: true,
1484 ..
1485 }
1486 )
1487 })
1488 .unwrap();
1489 let start_surface = commands
1490 .iter()
1491 .position(|command| {
1492 matches!(
1493 command,
1494 HostCommand::StartSurfaceRegion {
1495 region: SurfaceRegion::Deadwax,
1496 ..
1497 }
1498 )
1499 })
1500 .unwrap();
1501 assert!(scratch_transport < stop_packet && stop_packet < start_surface);
1502 assert!(!commands
1503 .iter()
1504 .any(|command| matches!(command, HostCommand::SetMotor { .. })));
1505 }
1506 #[test]
1507 fn scratch_interrupts_surface_region() {
1508 let mut e = PlayerEngine::default();
1509 ready(&mut e);
1510 e.state.decks[0].needle.lifted = false;
1511 e.dispatch(PlayerEvent::StartTimedRegion {
1512 region: SurfaceRegion::LeadIn,
1513 now_ms: 1.0,
1514 duration_seconds: 2.0,
1515 })
1516 .unwrap();
1517 e.drain_commands();
1518 e.dispatch(PlayerEvent::BeginScratch {
1519 deck: DeckId::A,
1520 pointer_id: 1,
1521 playback_seconds: 0.0,
1522 rotation_degrees: 0.0,
1523 rate: 0.0,
1524 impulse: 0.22,
1525 grip: 1.0,
1526 output_frame: 0,
1527 })
1528 .unwrap();
1529 assert!(!e.state().lead_in.active);
1530 }
1531}