Skip to main content

ranim/cmd/preview/
audio.rs

1//! Preview audio playback (native only).
2//!
3//! The scene's mixed audio buffer is played through rodio. The player keeps
4//! the output device open across play/pause cycles; its position readout is a
5//! wall-clock estimate over the playback rate, clamped to the buffer duration
6//! — the audio device consumes in real time, so both advance together and
7//! never accumulate drift.
8
9use std::{sync::Arc, time::Duration, time::Instant};
10
11use rodio::{
12    Sink,
13    source::{SeekError, Source},
14};
15
16use ranim_core::{
17    SceneEvaluator,
18    audio::{MASTER_CHANNELS, MASTER_SAMPLE_RATE},
19};
20
21/// The scene's mixed audio buffer, produced in one offline pass and shared
22/// with the playback device.
23#[derive(Clone)]
24pub(crate) struct MixedAudio {
25    pcm: Arc<[f32]>,
26    total_secs: f64,
27}
28
29impl MixedAudio {
30    /// The scene's baked audio, shared from the seal-time mix.
31    pub fn new(evaluator: &SceneEvaluator, total_secs: f64) -> Self {
32        Self {
33            pcm: evaluator.audio().clone(),
34            total_secs,
35        }
36    }
37
38    fn is_empty(&self) -> bool {
39        self.pcm.is_empty()
40    }
41
42    fn total_secs(&self) -> f64 {
43        self.total_secs
44    }
45}
46
47/// Interleaved stereo PCM source with sample-accurate absolute seeks.
48struct PcmSource {
49    pcm: Arc<[f32]>,
50    next: usize,
51}
52
53impl Iterator for PcmSource {
54    type Item = f32;
55
56    fn next(&mut self) -> Option<f32> {
57        let sample = self.pcm.get(self.next).copied();
58        if sample.is_some() {
59            self.next += 1;
60        }
61        sample
62    }
63}
64
65impl Source for PcmSource {
66    fn current_span_len(&self) -> Option<usize> {
67        Some(self.pcm.len() - self.next)
68    }
69
70    fn channels(&self) -> u16 {
71        MASTER_CHANNELS
72    }
73
74    fn sample_rate(&self) -> u32 {
75        MASTER_SAMPLE_RATE
76    }
77
78    fn total_duration(&self) -> Option<Duration> {
79        Some(Duration::from_secs_f64(
80            self.pcm.len() as f64 / (MASTER_SAMPLE_RATE as f64 * MASTER_CHANNELS as f64),
81        ))
82    }
83
84    fn try_seek(&mut self, pos: Duration) -> Result<(), SeekError> {
85        let frame = (pos.as_secs_f64() * MASTER_SAMPLE_RATE as f64) as usize;
86        self.next = (frame * MASTER_CHANNELS as usize).min(self.pcm.len());
87        Ok(())
88    }
89}
90
91enum PlayerState {
92    Playing { started_at: Instant, base_sec: f64 },
93    Paused { at_sec: f64 },
94}
95
96/// Scrub voices play at reduced volume and silence themselves once the
97/// playhead stops moving.
98const SCRUB_GAIN: f32 = 0.6;
99const SCRUB_IDLE: Duration = Duration::from_millis(120);
100
101/// Owns the output device and the playback sink for one scene's audio.
102pub(crate) struct AudioPlayer {
103    mixed: MixedAudio,
104    sink: Sink,
105    // The output stream must outlive the sink; dropped last.
106    _stream: rodio::OutputStream,
107    state: PlayerState,
108    speed: f64,
109    /// Last scrub move while a scrub voice is audible; `None` otherwise.
110    scrub_at: Option<Instant>,
111}
112
113impl AudioPlayer {
114    /// Open the default output device and prepare the scene buffer for
115    /// playback. Returns `None` when the scene is silent or no device is
116    /// available (the preview then runs on the wall clock).
117    pub fn try_new(mixed: MixedAudio) -> Option<Self> {
118        if mixed.is_empty() {
119            return None;
120        }
121        let stream = rodio::OutputStreamBuilder::open_default_stream().ok()?;
122        let sink = Sink::connect_new(stream.mixer());
123        sink.pause();
124        Some(Self {
125            mixed,
126            sink,
127            _stream: stream,
128            state: PlayerState::Paused { at_sec: 0.0 },
129            speed: 1.0,
130            scrub_at: None,
131        })
132    }
133
134    /// Current media position in seconds; frozen while paused.
135    pub fn pos_secs(&self) -> f64 {
136        match self.state {
137            PlayerState::Playing {
138                started_at,
139                base_sec,
140            } => (base_sec + started_at.elapsed().as_secs_f64() * self.speed)
141                .clamp(0.0, self.mixed.total_secs()),
142            PlayerState::Paused { at_sec } => at_sec,
143        }
144    }
145
146    /// Start playing from `sec` at `speed`.
147    pub fn play_from(&mut self, sec: f64, speed: f64) {
148        let sec = sec.clamp(0.0, self.mixed.total_secs());
149        self.speed = speed;
150        self.sink.set_volume(1.0);
151        self.sink.set_speed(speed as f32);
152        self.sink.clear();
153        self.sink.append(PcmSource {
154            pcm: self.mixed.pcm.clone(),
155            next: (sec * MASTER_SAMPLE_RATE as f64) as usize * MASTER_CHANNELS as usize,
156        });
157        self.sink.play();
158        self.scrub_at = None;
159        self.state = PlayerState::Playing {
160            started_at: Instant::now(),
161            base_sec: sec,
162        };
163    }
164
165    /// Stop playback and freeze the position.
166    pub fn pause(&mut self) {
167        let pos = self.pos_secs();
168        self.scrub_at = None;
169        self.sink.set_volume(1.0);
170        self.sink.pause();
171        self.state = PlayerState::Paused { at_sec: pos };
172    }
173
174    /// Seek to `sec`, staying paused or playing as-is.
175    pub fn seek_to(&mut self, sec: f64) {
176        let sec = sec.clamp(0.0, self.mixed.total_secs());
177        match self.state {
178            PlayerState::Playing { .. } => {
179                self.ensure_source_at(sec);
180                self.state = PlayerState::Playing {
181                    started_at: Instant::now(),
182                    base_sec: sec,
183                };
184            }
185            PlayerState::Paused { .. } => {
186                self.state = PlayerState::Paused { at_sec: sec };
187            }
188        }
189    }
190
191    /// Point the playback source at `sec`.
192    ///
193    /// Seeks in place — but if the source already ran to the end and left
194    /// the queue empty (scrubbing to the scene end does this), a seek is a
195    /// no-op and the source must be re-appended at the target instead.
196    fn ensure_source_at(&mut self, sec: f64) {
197        if self.sink.empty() {
198            self.sink.clear();
199            self.sink.append(PcmSource {
200                pcm: self.mixed.pcm.clone(),
201                next: (sec * MASTER_SAMPLE_RATE as f64) as usize * MASTER_CHANNELS as usize,
202            });
203        } else {
204            let _ = self.sink.try_seek(Duration::from_secs_f64(sec));
205        }
206    }
207
208    /// Audible scrubbing while paused: point the playback voice at `sec` at
209    /// reduced volume. Between scrub moves it simply plays on, so a drag
210    /// hears the audio under the cursor; [`Self::end_stale_scrub`] silences
211    /// it once the playhead stops moving.
212    pub fn scrub(&mut self, sec: f64) {
213        let sec = sec.clamp(0.0, self.mixed.total_secs());
214        self.sink.set_volume(SCRUB_GAIN);
215        self.ensure_source_at(sec);
216        self.sink.play();
217        self.scrub_at = Some(Instant::now());
218        self.state = PlayerState::Playing {
219            started_at: Instant::now(),
220            base_sec: sec,
221        };
222    }
223
224    /// Silence the scrub voice after the playhead stopped moving.
225    pub fn end_stale_scrub(&mut self) {
226        let Some(last_move) = self.scrub_at else {
227            return;
228        };
229        if last_move.elapsed() < SCRUB_IDLE {
230            return;
231        }
232        self.pause();
233    }
234
235    /// Whether a scrub voice is still audible.
236    pub fn scrub_voice_active(&self) -> bool {
237        self.scrub_at.is_some()
238    }
239
240    /// Change the playback speed, rebasing the position clock.
241    pub fn set_speed(&mut self, speed: f64) {
242        let pos = self.pos_secs();
243        self.speed = speed;
244        self.sink.set_speed(speed as f32);
245        if matches!(self.state, PlayerState::Playing { .. }) {
246            self.state = PlayerState::Playing {
247                started_at: Instant::now(),
248                base_sec: pos,
249            };
250        }
251    }
252}