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 {
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            // Original STOP (player.js 12347–12351): the motor turns off (the
399            // platter brakes), the needle lifts, and any surface region stops.
400            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        // Moving the tonearm is an intentional programme cue. If the stylus was
498        // sitting in lead-in/deadwax surface playback, leave that region and put
499        // the packet engine back under the needle. This keeps "needle down +
500        // motor running" as the only state needed to hear the groove again.
501        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        // Programme-end detection stops the DSP's internal motor at the final
569        // source frame. Reassert the physical platter target even when the core
570        // transport was already on so lead-in/run-out foley inherits live RPM.
571        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            // A physical-region transition keeps the platter state alive. The
597            // worklet may already have entered automatic run-out on the exact
598            // programme-end frame, so this stop must be an idempotent handoff.
599            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        // Original mixer ramps default to 5 ms (player-audio-mixer.js rampGain).
886        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}