Skip to main content

mlua_pulse/
playback.rs

1//! Lightweight playback preview wrappers around `tunes::engine::AudioEngine`.
2
3use crate::composition::PulseSong;
4use crate::error::{PulseError, PulseResult};
5use crate::files::PulseImportedMidi;
6use std::rc::Rc;
7use tunes::engine::{AudioEngine, SoundId};
8use tunes::track::Mixer;
9
10#[derive(Clone)]
11/// Shared playback engine used by Lua playback handles.
12pub struct PulsePlaybackEngine {
13    engine: Rc<AudioEngine>,
14}
15
16#[derive(Clone)]
17/// Handle for a real-time or looping sound started by `PulsePlaybackEngine`.
18pub struct PulsePlaybackHandle {
19    engine: Rc<AudioEngine>,
20    sound_id: SoundId,
21}
22
23impl std::fmt::Debug for PulsePlaybackHandle {
24    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
25        formatter
26            .debug_struct("PulsePlaybackHandle")
27            .field("sound_id", &self.sound_id)
28            .finish_non_exhaustive()
29    }
30}
31
32/// A value that can be converted into a `tunes` mixer for playback.
33pub enum PulsePlayable<'a> {
34    /// A song built with the mlua-pulse composition DSL.
35    Song(&'a PulseSong),
36    /// A MIDI file imported through `PulseImportedMidi`.
37    ImportedMidi(&'a PulseImportedMidi),
38}
39
40fn playback_error(error: impl std::fmt::Display) -> PulseError {
41    PulseError::PlaybackFailed {
42        message: error.to_string(),
43    }
44}
45
46/// Converts a playback target into a fresh `tunes` mixer.
47///
48/// # Errors
49///
50/// Returns an error when the source song cannot be converted to a mixer.
51pub fn mixer_from_playable(playable: PulsePlayable<'_>) -> PulseResult<Mixer> {
52    match playable {
53        PulsePlayable::Song(song) => song.to_mixer(),
54        PulsePlayable::ImportedMidi(imported) => Ok(imported.cloned_mixer()),
55    }
56}
57
58/// Validates a playback-rate multiplier.
59///
60/// # Errors
61///
62/// Returns an error when `rate` is zero, negative, or not finite.
63pub fn validate_playback_rate(rate: f32) -> PulseResult<f32> {
64    if !rate.is_finite() || rate <= 0.0 {
65        return Err(PulseError::InvalidPlaybackRate { rate });
66    }
67    Ok(rate)
68}
69
70/// Validates a real-time playback volume.
71///
72/// # Errors
73///
74/// Returns an error when `volume` is outside `0..=1`.
75pub fn validate_playback_volume(volume: f32) -> PulseResult<f32> {
76    if !volume.is_finite() || !(0.0..=1.0).contains(&volume) {
77        return Err(PulseError::InvalidPlaybackVolume { volume });
78    }
79    Ok(volume)
80}
81
82/// Validates a real-time playback pan value.
83///
84/// # Errors
85///
86/// Returns an error when `pan` is outside `-1..=1`.
87pub fn validate_playback_pan(pan: f32) -> PulseResult<f32> {
88    if !pan.is_finite() || !(-1.0..=1.0).contains(&pan) {
89        return Err(PulseError::InvalidPlaybackPan { pan });
90    }
91    Ok(pan)
92}
93
94impl PulsePlaybackEngine {
95    /// Creates a playback engine using `tunes::engine::AudioEngine::new`.
96    ///
97    /// # Errors
98    ///
99    /// Returns a playback error when the audio engine cannot initialize.
100    pub fn new() -> PulseResult<Self> {
101        let engine = AudioEngine::new().map_err(playback_error)?;
102        Ok(Self {
103            engine: Rc::new(engine),
104        })
105    }
106
107    /// Plays a target immediately and waits for `tunes` to schedule playback.
108    ///
109    /// # Errors
110    ///
111    /// Returns an error when mixer creation or playback scheduling fails.
112    pub fn play(&self, playable: PulsePlayable<'_>) -> PulseResult<()> {
113        let mixer = mixer_from_playable(playable)?;
114        self.engine.play_mixer(&mixer).map_err(playback_error)
115    }
116
117    /// Starts real-time playback and returns a handle for later control.
118    ///
119    /// # Errors
120    ///
121    /// Returns an error when mixer creation or playback scheduling fails.
122    pub fn play_realtime(&self, playable: PulsePlayable<'_>) -> PulseResult<PulsePlaybackHandle> {
123        let mixer = mixer_from_playable(playable)?;
124        let sound_id = self
125            .engine
126            .play_mixer_realtime(&mixer)
127            .map_err(playback_error)?;
128        Ok(PulsePlaybackHandle::from_parts(
129            Rc::clone(&self.engine),
130            sound_id,
131        ))
132    }
133
134    /// Starts looping playback and returns a handle for later control.
135    ///
136    /// # Errors
137    ///
138    /// Returns an error when mixer creation or playback scheduling fails.
139    pub fn play_looping(&self, playable: PulsePlayable<'_>) -> PulseResult<PulsePlaybackHandle> {
140        let mixer = mixer_from_playable(playable)?;
141        let sound_id = self.engine.play_looping(&mixer).map_err(playback_error)?;
142        Ok(PulsePlaybackHandle::from_parts(
143            Rc::clone(&self.engine),
144            sound_id,
145        ))
146    }
147
148    /// Plays a target with a playback-rate multiplier.
149    ///
150    /// # Errors
151    ///
152    /// Returns an error when `rate` is invalid or playback fails.
153    pub fn play_at_rate(&self, playable: PulsePlayable<'_>, rate: f32) -> PulseResult<()> {
154        let rate = validate_playback_rate(rate)?;
155        let mixer = mixer_from_playable(playable)?;
156        self.engine
157            .play_mixer_at_rate(&mixer, rate)
158            .map_err(playback_error)
159    }
160
161    /// Starts real-time playback with a playback-rate multiplier.
162    ///
163    /// # Errors
164    ///
165    /// Returns an error when `rate` is invalid or playback fails.
166    pub fn play_realtime_at_rate(
167        &self,
168        playable: PulsePlayable<'_>,
169        rate: f32,
170    ) -> PulseResult<PulsePlaybackHandle> {
171        let rate = validate_playback_rate(rate)?;
172        let mixer = mixer_from_playable(playable)?;
173        let sound_id = self
174            .engine
175            .play_mixer_realtime_at_rate(&mixer, rate)
176            .map_err(playback_error)?;
177        Ok(PulsePlaybackHandle::from_parts(
178            Rc::clone(&self.engine),
179            sound_id,
180        ))
181    }
182}
183
184impl PulsePlaybackHandle {
185    /// Creates a handle from an engine reference and `tunes` sound identifier.
186    #[must_use]
187    pub fn from_parts(engine: Rc<AudioEngine>, sound_id: SoundId) -> Self {
188        Self { engine, sound_id }
189    }
190
191    /// Returns the underlying `tunes` sound identifier.
192    #[must_use]
193    pub fn sound_id(&self) -> SoundId {
194        self.sound_id
195    }
196
197    /// Stops playback and returns a cloned handle for Lua chainability.
198    ///
199    /// # Errors
200    ///
201    /// Returns an error when the engine rejects the stop request.
202    pub fn stop(&self) -> PulseResult<Self> {
203        self.engine.stop(self.sound_id).map_err(playback_error)?;
204        Ok(self.clone())
205    }
206
207    /// Sets playback volume and returns a cloned handle for Lua chainability.
208    ///
209    /// # Errors
210    ///
211    /// Returns an error when `volume` is invalid or the engine rejects the request.
212    pub fn volume(&self, volume: f32) -> PulseResult<Self> {
213        let volume = validate_playback_volume(volume)?;
214        self.engine
215            .set_volume(self.sound_id, volume)
216            .map_err(playback_error)?;
217        Ok(self.clone())
218    }
219
220    /// Sets playback stereo pan and returns a cloned handle for Lua chainability.
221    ///
222    /// # Errors
223    ///
224    /// Returns an error when `pan` is invalid or the engine rejects the request.
225    pub fn pan(&self, pan: f32) -> PulseResult<Self> {
226        let pan = validate_playback_pan(pan)?;
227        self.engine
228            .set_pan(self.sound_id, pan)
229            .map_err(playback_error)?;
230        Ok(self.clone())
231    }
232
233    /// Sets playback rate and returns a cloned handle for Lua chainability.
234    ///
235    /// # Errors
236    ///
237    /// Returns an error when `rate` is invalid or the engine rejects the request.
238    pub fn rate(&self, rate: f32) -> PulseResult<Self> {
239        let rate = validate_playback_rate(rate)?;
240        self.engine
241            .set_playback_rate(self.sound_id, rate)
242            .map_err(playback_error)?;
243        Ok(self.clone())
244    }
245
246    /// Pauses playback and returns a cloned handle for Lua chainability.
247    ///
248    /// # Errors
249    ///
250    /// Returns an error when the engine rejects the pause request.
251    pub fn pause(&self) -> PulseResult<Self> {
252        self.engine.pause(self.sound_id).map_err(playback_error)?;
253        Ok(self.clone())
254    }
255
256    /// Resumes playback and returns a cloned handle for Lua chainability.
257    ///
258    /// # Errors
259    ///
260    /// Returns an error when the engine rejects the resume request.
261    pub fn resume(&self) -> PulseResult<Self> {
262        self.engine.resume(self.sound_id).map_err(playback_error)?;
263        Ok(self.clone())
264    }
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270    use tunes::composition::Tempo;
271
272    #[test]
273    fn playback_rate_rejects_zero_negative_and_non_finite_values() {
274        assert_eq!(
275            validate_playback_rate(0.0)
276                .expect_err("zero rate should fail")
277                .to_string(),
278            "invalid playback rate: 0"
279        );
280        assert_eq!(
281            validate_playback_rate(-1.0)
282                .expect_err("negative rate should fail")
283                .to_string(),
284            "invalid playback rate: -1"
285        );
286        assert_eq!(
287            validate_playback_rate(f32::NAN)
288                .expect_err("NaN rate should fail")
289                .to_string(),
290            "invalid playback rate: NaN"
291        );
292        assert_eq!(validate_playback_rate(1.25).unwrap(), 1.25);
293    }
294
295    #[test]
296    fn playback_volume_accepts_unit_range_only() {
297        assert_eq!(validate_playback_volume(0.0).unwrap(), 0.0);
298        assert_eq!(validate_playback_volume(1.0).unwrap(), 1.0);
299        assert_eq!(
300            validate_playback_volume(1.5)
301                .expect_err("too-loud volume should fail")
302                .to_string(),
303            "invalid playback volume: 1.5"
304        );
305        assert_eq!(
306            validate_playback_volume(f32::INFINITY)
307                .expect_err("infinite volume should fail")
308                .to_string(),
309            "invalid playback volume: inf"
310        );
311    }
312
313    #[test]
314    fn playback_pan_accepts_stereo_range_only() {
315        assert_eq!(validate_playback_pan(-1.0).unwrap(), -1.0);
316        assert_eq!(validate_playback_pan(1.0).unwrap(), 1.0);
317        assert_eq!(
318            validate_playback_pan(2.0)
319                .expect_err("pan outside stereo range should fail")
320                .to_string(),
321            "invalid playback pan: 2"
322        );
323        assert_eq!(
324            validate_playback_pan(f32::NEG_INFINITY)
325                .expect_err("non-finite pan should fail")
326                .to_string(),
327            "invalid playback pan: -inf"
328        );
329    }
330
331    #[test]
332    fn playback_handle_exposes_sound_id() {
333        let engine = Rc::new(AudioEngine::new().expect("audio engine should initialize"));
334        let handle = PulsePlaybackHandle::from_parts(engine, 42);
335
336        assert_eq!(handle.sound_id(), 42);
337    }
338
339    #[test]
340    fn playable_song_builds_a_mixer_without_playing_audio() {
341        let song = PulseSong::new();
342
343        let mixer = mixer_from_playable(PulsePlayable::Song(&song));
344
345        assert!(mixer.is_ok());
346    }
347
348    #[test]
349    fn playable_imported_midi_clones_its_mixer_without_playing_audio() {
350        let imported = PulseImportedMidi::new(Mixer::new(Tempo::new(120.0)));
351
352        let mixer = mixer_from_playable(PulsePlayable::ImportedMidi(&imported));
353
354        assert!(mixer.is_ok());
355    }
356
357    #[test]
358    fn playable_song_preserves_master_effects_in_mixer() {
359        let effect =
360            crate::effects::effect_from_options("eq", crate::effects::EffectOptions::Default)
361                .expect("default eq should parse");
362        let song = PulseSong::new().with_master_effect(effect);
363
364        let mixer = mixer_from_playable(PulsePlayable::Song(&song))
365            .expect("song with master effect should build mixer");
366
367        assert!(mixer.master.eq.is_some());
368    }
369
370    #[test]
371    fn playback_engine_can_be_constructed_without_playing_audio() {
372        let engine = PulsePlaybackEngine::new();
373
374        assert!(engine.is_ok());
375    }
376
377    #[test]
378    fn handle_control_methods_validate_arguments_before_forwarding() {
379        let engine = Rc::new(AudioEngine::new().expect("audio engine should initialize"));
380        let handle = PulsePlaybackHandle::from_parts(engine, 99);
381
382        assert_eq!(
383            handle
384                .volume(1.5)
385                .expect_err("invalid volume should fail before engine call")
386                .to_string(),
387            "invalid playback volume: 1.5"
388        );
389        assert_eq!(
390            handle
391                .pan(2.0)
392                .expect_err("invalid pan should fail before engine call")
393                .to_string(),
394            "invalid playback pan: 2"
395        );
396        assert_eq!(
397            handle
398                .rate(0.0)
399                .expect_err("invalid rate should fail before engine call")
400                .to_string(),
401            "invalid playback rate: 0"
402        );
403    }
404
405    #[test]
406    fn engine_rate_methods_validate_arguments_before_playing() {
407        let engine = PulsePlaybackEngine::new().expect("playback engine should initialize");
408        let song = PulseSong::new();
409
410        assert_eq!(
411            engine
412                .play_at_rate(PulsePlayable::Song(&song), 0.0)
413                .expect_err("invalid rate should fail before playback")
414                .to_string(),
415            "invalid playback rate: 0"
416        );
417        assert_eq!(
418            engine
419                .play_realtime_at_rate(PulsePlayable::Song(&song), f32::INFINITY)
420                .expect_err("invalid rate should fail before playback")
421                .to_string(),
422            "invalid playback rate: inf"
423        );
424    }
425
426    #[test]
427    fn playback_handle_invalid_controls_are_chainable_lua_methods() {
428        let lua = mlua::Lua::new();
429        let engine = Rc::new(AudioEngine::new().expect("audio engine should initialize"));
430        let handle = PulsePlaybackHandle::from_parts(engine, 7);
431
432        lua.globals().set("handle", handle).unwrap();
433        lua.load(
434            r#"
435            assert(type(handle.stop) == "function")
436            assert(type(handle.volume) == "function")
437            assert(type(handle.pan) == "function")
438            assert(type(handle.rate) == "function")
439            assert(type(handle.pause) == "function")
440            assert(type(handle.resume) == "function")
441
442            local ok_volume = pcall(function()
443              handle:volume(1.5)
444            end)
445            assert(ok_volume == false)
446
447            local ok_pan = pcall(function()
448              handle:pan(2)
449            end)
450            assert(ok_pan == false)
451
452            local ok_rate = pcall(function()
453              handle:rate(0)
454            end)
455            assert(ok_rate == false)
456            "#,
457        )
458        .exec()
459        .expect("handle methods should be visible and validate before engine calls");
460    }
461}