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
18#[cfg(not(target_family = "wasm"))]
19use super::audio::{AudioPlayer, MixedAudio};
20
21/// The active clock while playing.
22enum PlaybackClock {
23    Wall {
24        started_at: Instant,
25        base_sec: f64,
26        speed: f64,
27    },
28    #[cfg(not(target_family = "wasm"))]
29    Audio { player: AudioPlayer },
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            #[cfg(not(target_family = "wasm"))]
41            PlaybackClock::Audio { player } => player.pos_secs(),
42        }
43    }
44}
45
46/// Playback engine for the preview: owns the persistent audio player (so the
47/// output device survives pause cycles) and the active clock while playing.
48pub(crate) struct PlaybackEngine {
49    clock: Option<PlaybackClock>,
50    #[cfg(not(target_family = "wasm"))]
51    player: Option<AudioPlayer>,
52}
53
54impl PlaybackEngine {
55    /// Build an engine for a scene, preparing its mixed audio for playback.
56    pub fn new(evaluator: &SceneEvaluator) -> Self {
57        #[cfg(target_family = "wasm")]
58        let _ = evaluator;
59        Self {
60            clock: None,
61            #[cfg(not(target_family = "wasm"))]
62            player: Self::build_player(evaluator),
63        }
64    }
65
66    #[cfg(not(target_family = "wasm"))]
67    fn build_player(evaluator: &SceneEvaluator) -> Option<AudioPlayer> {
68        if !evaluator.has_audio() {
69            return None;
70        }
71        let total_secs = evaluator.total_secs();
72        match AudioPlayer::try_new(MixedAudio::new(evaluator, total_secs)) {
73            Some(player) => Some(player),
74            None => {
75                tracing::warn!("no audio output device available; previewing without sound");
76                None
77            }
78        }
79    }
80
81    /// Whether playback is running.
82    pub fn is_playing(&self) -> bool {
83        self.clock.is_some()
84    }
85
86    /// Start playing from `from` at `speed`, wrapping to 0.0 at `total`.
87    /// Returns the effective start position.
88    pub fn play(&mut self, from: f64, total: f64, speed: f64) -> f64 {
89        let start = if from >= total { 0.0 } else { from };
90        self.clock = Some({
91            #[cfg(not(target_family = "wasm"))]
92            match self.player.take() {
93                Some(mut player) => {
94                    player.play_from(start, speed);
95                    PlaybackClock::Audio { player }
96                }
97                None => PlaybackClock::Wall {
98                    started_at: Instant::now(),
99                    base_sec: start,
100                    speed,
101                },
102            }
103            #[cfg(target_family = "wasm")]
104            PlaybackClock::Wall {
105                started_at: Instant::now(),
106                base_sec: start,
107                speed,
108            }
109        });
110        start
111    }
112
113    /// Stop playback and return the frozen position.
114    pub fn pause(&mut self) -> f64 {
115        match self.clock.take() {
116            Some(PlaybackClock::Wall {
117                started_at,
118                base_sec,
119                speed,
120            }) => base_sec + started_at.elapsed().as_secs_f64() * speed,
121            #[cfg(not(target_family = "wasm"))]
122            Some(PlaybackClock::Audio { mut player }) => {
123                player.pause();
124                let pos = player.pos_secs();
125                self.player = Some(player);
126                pos
127            }
128            None => 0.0,
129        }
130    }
131
132    /// The live position while playing; `None` when paused.
133    pub fn pos_secs(&self) -> Option<f64> {
134        self.clock.as_ref().map(PlaybackClock::pos_secs)
135    }
136
137    /// Rebase the active clock at `sec` (scrubbing). While paused, drive a
138    /// scrub voice instead so the drag stays audible.
139    pub fn scrub_to(&mut self, sec: f64) {
140        match self.clock.as_mut() {
141            Some(PlaybackClock::Wall {
142                started_at,
143                base_sec,
144                ..
145            }) => {
146                *base_sec = sec;
147                *started_at = Instant::now();
148            }
149            #[cfg(not(target_family = "wasm"))]
150            Some(PlaybackClock::Audio { player }) => player.seek_to(sec),
151            #[cfg(not(target_family = "wasm"))]
152            None => {
153                if let Some(player) = &mut self.player {
154                    player.scrub(sec);
155                }
156            }
157            #[cfg(target_family = "wasm")]
158            None => {}
159        }
160    }
161
162    /// Silence the scrub voice once the playhead stopped moving while
163    /// paused. Returns whether a scrub voice is still audible (the caller
164    /// should keep repainting so the staleness check can run).
165    pub fn tick_scrub(&mut self) -> bool {
166        #[cfg(not(target_family = "wasm"))]
167        if self.clock.is_none()
168            && let Some(player) = &mut self.player
169        {
170            player.end_stale_scrub();
171            return player.scrub_voice_active();
172        }
173        false
174    }
175
176    /// Change the playback speed, rebasing the active clock.
177    pub fn set_speed(&mut self, speed: f64) {
178        match self.clock.as_mut() {
179            Some(PlaybackClock::Wall {
180                started_at,
181                base_sec,
182                speed: old_speed,
183            }) => {
184                let pos = *base_sec + started_at.elapsed().as_secs_f64() * *old_speed;
185                *base_sec = pos;
186                *started_at = Instant::now();
187                *old_speed = speed;
188            }
189            #[cfg(not(target_family = "wasm"))]
190            Some(PlaybackClock::Audio { player }) => player.set_speed(speed),
191            None => {}
192        }
193    }
194
195    /// Swap in a freshly built scene: stop playback and rebuild the audio
196    /// player from the new scene's audio plane.
197    pub fn reload_scene(&mut self, evaluator: &SceneEvaluator) {
198        self.clock = None;
199        #[cfg(target_family = "wasm")]
200        let _ = evaluator;
201        #[cfg(not(target_family = "wasm"))]
202        {
203            self.player = Self::build_player(evaluator);
204        }
205    }
206}