Skip to main content

ranim/cmd/preview/
playback.rs

1//! The preview playback state machine: one clock, two strategies.
2//!
3//! While playing there is exactly one source of time. If the scene carries
4//! audio and an output device is available, the audio player is the clock and
5//! each repaint reads its position; otherwise the wall clock advances the
6//! playhead. In both cases the visual is a pure function sampled at the clock
7//! reading — nothing is evaluated between repaints, and errors never
8//! accumulate because the playhead is always *read*, never integrated.
9//!
10//! Paused, there is no clock at all: the playhead is a plain position
11//! variable only moved by scrubbing, stepping, or reading the clock one last
12//! time when pausing.
13
14use web_time::Instant;
15
16use ranim_core::SceneEvaluator;
17
18use super::audio::{AudioPlayer, MixedAudio};
19
20/// The active clock while playing.
21enum PlaybackClock {
22    Wall {
23        started_at: Instant,
24        base_sec: f64,
25        speed: f64,
26    },
27    Audio {
28        player: AudioPlayer,
29    },
30}
31
32impl PlaybackClock {
33    fn pos_secs(&self) -> f64 {
34        match self {
35            PlaybackClock::Wall {
36                started_at,
37                base_sec,
38                speed,
39            } => base_sec + started_at.elapsed().as_secs_f64() * speed,
40            PlaybackClock::Audio { player } => player.pos_secs(),
41        }
42    }
43}
44
45/// Playback engine for the preview: owns the persistent audio player (so the
46/// output device survives pause cycles) and the active clock while playing.
47pub(crate) struct PlaybackEngine {
48    clock: Option<PlaybackClock>,
49    player: Option<AudioPlayer>,
50}
51
52impl PlaybackEngine {
53    /// Build an engine for a scene, preparing its mixed audio for playback.
54    pub fn new(evaluator: &SceneEvaluator) -> Self {
55        Self {
56            clock: None,
57            player: Self::build_player(evaluator),
58        }
59    }
60
61    fn build_player(evaluator: &SceneEvaluator) -> Option<AudioPlayer> {
62        if !evaluator.has_audio() {
63            return None;
64        }
65        let total_secs = evaluator.total_secs();
66        match AudioPlayer::try_new(MixedAudio::new(evaluator, total_secs)) {
67            Some(player) => Some(player),
68            None => {
69                tracing::warn!("no audio output device available; previewing without sound");
70                None
71            }
72        }
73    }
74
75    /// Whether playback is running.
76    pub fn is_playing(&self) -> bool {
77        self.clock.is_some()
78    }
79
80    /// Start playing from `from` at `speed`, wrapping to 0.0 at `total`.
81    /// Returns the effective start position.
82    pub fn play(&mut self, from: f64, total: f64, speed: f64) -> f64 {
83        let start = if from >= total { 0.0 } else { from };
84        self.clock = Some(match self.player.take() {
85            Some(mut player) => {
86                player.play_from(start, speed);
87                PlaybackClock::Audio { player }
88            }
89            None => PlaybackClock::Wall {
90                started_at: Instant::now(),
91                base_sec: start,
92                speed,
93            },
94        });
95        start
96    }
97
98    /// Stop playback and return the frozen position.
99    pub fn pause(&mut self) -> f64 {
100        match self.clock.take() {
101            Some(PlaybackClock::Wall {
102                started_at,
103                base_sec,
104                speed,
105            }) => base_sec + started_at.elapsed().as_secs_f64() * speed,
106            Some(PlaybackClock::Audio { mut player }) => {
107                player.pause();
108                let pos = player.pos_secs();
109                self.player = Some(player);
110                pos
111            }
112            None => 0.0,
113        }
114    }
115
116    /// The live position while playing; `None` when paused.
117    pub fn pos_secs(&self) -> Option<f64> {
118        self.clock.as_ref().map(PlaybackClock::pos_secs)
119    }
120
121    /// Rebase the active clock at `sec` (scrubbing). While paused, drive a
122    /// scrub voice instead so the drag stays audible.
123    pub fn scrub_to(&mut self, sec: f64) {
124        match self.clock.as_mut() {
125            Some(PlaybackClock::Wall {
126                started_at,
127                base_sec,
128                ..
129            }) => {
130                *base_sec = sec;
131                *started_at = Instant::now();
132            }
133            Some(PlaybackClock::Audio { player }) => player.seek_to(sec),
134            None => {
135                if let Some(player) = &mut self.player {
136                    player.scrub(sec);
137                }
138            }
139        }
140    }
141
142    /// Silence the scrub voice once the playhead stopped moving while
143    /// paused. Returns whether a scrub voice is still audible (the caller
144    /// should keep repainting so the staleness check can run).
145    pub fn tick_scrub(&mut self) -> bool {
146        if self.clock.is_none()
147            && let Some(player) = &mut self.player
148        {
149            player.end_stale_scrub();
150            return player.scrub_voice_active();
151        }
152        false
153    }
154
155    /// Change the playback speed, rebasing the active clock.
156    pub fn set_speed(&mut self, speed: f64) {
157        match self.clock.as_mut() {
158            Some(PlaybackClock::Wall {
159                started_at,
160                base_sec,
161                speed: old_speed,
162            }) => {
163                let pos = *base_sec + started_at.elapsed().as_secs_f64() * *old_speed;
164                *base_sec = pos;
165                *started_at = Instant::now();
166                *old_speed = speed;
167            }
168            Some(PlaybackClock::Audio { player }) => player.set_speed(speed),
169            None => {}
170        }
171    }
172}