quintenpalmer.github.io

My Blog Posts

Introduction

Welcome back to another installment of this music-tech blog! This time we’re getting back into some technical topics, so put on your hard hats and let’s dig in! Our topic this time will be playing back music files and some rudimentary controls we can provide to interface with the playback “sink” as we’ll be calling it today. Here we go!

Welcome to the Rodio

Rodio is a Rust crate that provides a very simple interface for interacting with audio files and the physical hardware on your machine. It is built on top of another crate called cpal which stands for “Cross-Platform Audio Library”, which is what does a lot of the under hood work to resolve what how to talk to the operating system and the physical hardware. As always with this technology, we’re going to let Rodio and CPAL do their heavy lifting and not dig into their inner workings here, but if that interests you, it’s all still open source! Let’s start by learning the definitions of some of the terms that these projects use (and expose).

Terms

Source

A Source in Rodio’s world is something that represents a sound to play, be it a raw sine wave, a flac file, or some other data that could be decoded. As long as it satisfies this Source trait, Rodio will be happy to help you play it back. And conveniently enough, Rodio does provide implementations for Source that decode from common audio file formats, which we will definitely be leveraging!

Output Stream

An Output Stream and [OutputStreamHandle] both represent the controls over the devices that you can stream Source data into. They handle all of the cross-platform logic and do conveniently offer a simple constructor OutputStream::try_default which will try to get a handle on what the OS provides as the default device.

Sink

Rodio also provides a nice struct called a Sink that offers more control over the audio playback, like playing, pausing, appending sources, and more. We will be using this in our demo today.

A (Very) Very Simple Playback Example

Let’s start with something that just plays back a .flac file that we’ve generated with a simple sine wave.

Cargo.toml

[package]
name = "simplestmusicplayback"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
rodio = "0.16"

Almost as bog-standard as a Cargo.toml can get, we just have the aforementioned rodio crate as a dependency.

src/main.rs

use std::fs;
use std::io;
use std::path;

pub fn main() {
    println!("starting...");
    // We get the default device as provided by the host/operating system
    // We don't actually need the `_stream` value that we're ignoring,
    // but the `stream_handle` is how we will build our Sink in the near future
    let (_stream, stream_handle) = rodio::OutputStream::try_default().unwrap();

	// And we pass the `stream_handle` to build a new `Sink`
    let sink = rodio::Sink::try_new(&stream_handle).unwrap();
    println!("we have a sink");

	// We will open the file at `flacs/sample.flac`
    let flac_path = "flacs/sample.flac";
    let path = {
        let mut inner = path::PathBuf::new();
        inner.push(flac_path);
        inner
    };
    println!("we have a path to the \"{}\" file", flac_path);

	// We buffer the file, honestly for this demo it's more for fun,
	// but it is advised in general to buffer
    let file = io::BufReader::new(fs::File::open(path).unwrap());
    println!("we have an open and buffered .flac file");

	// Rodio is very generous and provides a decoder for flac files,
	// which we leverage and add to our sink's queue
    sink.append(rodio::Decoder::new(file).unwrap());
    println!("the sink now has the (buffered) file");

	// Actually play back the file!
    sink.play();
    println!("the sink is playing");

	// If the main thread were to exit then we wouldn't get to listen
	// to this beautiful sine wave!
	// The sink will gladly sleep until playback is done if asked to
    println!("the sink is sleeping until playback is done");
    sink.sleep_until_end();
    println!("the sink is done");

    println!("...exiting");
}

Hopefully those in-line comments demonstrate what is happening at each step along the way. While not exciting, here is what this looks like when you run it, you can copy paste this into a new project if you want to experience this demo.

 cargo run -q
starting...
we have a sink
we have a path to the "flacs/sample.flac" file
we have an open and buffered .flac file
the sink now has the (buffered) file
the sink is playing
the sink is sleeping until playback is done
the sink is done
...exiting

I don’t have much more to add with this small demo, let’s try something a bit more complex.

A Very Simple Playback Example

This will still be relatively simple, but we will use threads and channels to communicate. No actual async though, don’t worry!

Cargo.toml

[package]
name = "simplemusicplayback"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
rodio = "0.16"

Same old rodio dependency here.

src/main.rs

mod backend;
mod client;
mod shared;

fn main() {
    client::looping_main()
}

Also a pretty simple main.rs, I’ve deferred all of the exciting stuff off into these modules, let’s take a look at them.

src/shared.rs

#[derive(Clone, Debug)]
pub enum SinkMessage {
    PlayButton,
    PauseButton,
    LoadSong(String),
}

#[derive(Clone, Debug)]
pub enum SinkCallbackMessage {
    Playing,
    Paused,
    SongEnded,
}

These are just simple messages that we’ll pass back and forth from our backend sink and our basic client.

Let’s take a look at the backend sink in two parts. This first part is our Sink-wrapper; it’s a lot, so hopefully some context before diving in will help. It’s main purpose is to allow asynchronous playback of music with two-way communication between this sink wrapper (the backend) and a client on the other end. The communication is what the shared module above exposes, playing, pausing, loading songs, and communicating the end of songs. Let’s try it:

src/backend.rs

use std::fs;
use std::io;
// multi-producer, single-consumer channels
// are how we will communicate with this backend sink
use std::sync::mpsc;
use std::time;

use crate::shared;

const BLOCKING_TIMEOUT_MILLISECONDS: u64 = 1_000;

pub struct SinkPlayback {
	// We need to keep a handle on the `OutputStream`
	// otherwise Rust will drop it and then we can't play anything back
    _stream: rodio::OutputStream,
	// This is the same `Sink` as the simpler demo!
    sink: rodio::Sink,
    // And we hold on to the `OutputStreamHandle` when we want
    // to create a new `Sink` from the same `OutputStream` we create initially
    stream_handle: rodio::OutputStreamHandle,
    // If this is None there is no loaded song;
    // If this has Some(boolean) the inner bool is whether the song is currently playing
    loaded_song_playing: Option<bool>,
}

impl SinkPlayback {
    pub fn new() -> Self {
	    // Same default-device functionality we're using again here
        let (stream, stream_handle) = rodio::OutputStream::try_default().unwrap();
        SinkPlayback {
	        // As stated in the struct definition,
	        // hold on to the `stream` just so it doesn't get dropped
            _stream: stream,
            sink: rodio::Sink::try_new(&stream_handle).unwrap(),
            stream_handle: stream_handle,
            // We start with no playing audio, so this is `None`
            loaded_song_playing: None,
        }
    }

	// This is the entry-point into this backend-sink's functionality
    pub fn run_forever(
        mut self,
        // It accepts a receiver for new messages to control playback
        rx: mpsc::Receiver<shared::SinkMessage>,
        // And this sender is where it can report things happening
        // Especially when a song ends, which the client wouldn't be able
        // to know on its own
        callback: mpsc::Sender<shared::SinkCallbackMessage>,
    ) {
	    // We just run forever
        loop {
	        // We block for 1 second (1,000 milliseconds) and...
            match rx.recv_timeout(time::Duration::from_millis(BLOCKING_TIMEOUT_MILLISECONDS)) {
	            // If we get a message, we handle it
                Ok(msg) => self.handle_msg(msg, &callback),
                // And if we timed out, then we handle that case as well
                Err(mpsc::RecvTimeoutError::Timeout) => self.handle_timeout(&callback),
                // But if we disconnected, then we just abort
                Err(mpsc::RecvTimeoutError::Disconnected) => {
                    println!("recv sees that all clients have closed");
                    break;
                }
            }
        }
    }

    fn handle_msg(
        &mut self,
        // This is the message that we received from the receiver
        msg: shared::SinkMessage,
        // This is the callback sender to report back up to the client
        callback: &mpsc::Sender<shared::SinkCallbackMessage>,
    ) {
        println!("SINK:\t handling resp: {:?}", msg);
        match msg {
	        // If the client requested to resume playback:
            shared::SinkMessage::PlayButton => match self.loaded_song_playing {
		        // Only do anything if we have a song loaded
                Some(ref mut playing) => {
	                // Set playing to true (we don't care if it was already true)
                    *playing = true;
                    // And actually have the sink resume playback
                    self.sink.play();
		            // Communicate up to the client that we are currently playing
                    callback.send(shared::SinkCallbackMessage::Playing).unwrap();
                }
                None => (),
            },
            // If the client requested to pause playback:
            shared::SinkMessage::PauseButton => match self.loaded_song_playing {
                Some(ref mut playing) => {
	                // Set playing to false (we don't care if it was already false)
                    *playing = false;
                    // And actually have the sink pause playback
                    self.sink.pause();
		            // Communicate up to the client that we are currently paused
                    callback.send(shared::SinkCallbackMessage::Paused).unwrap();
                }
                None => (),
            },
            // If the client requested to load a new song (by path):
            shared::SinkMessage::LoadSong(path) => {
	            // Set that we will have a song and will be playing it
                self.loaded_song_playing = Some(true);
                // Stop the old sink
                self.sink.stop();
                // Build a new sink from our handle
                self.sink = rodio::Sink::try_new(&self.stream_handle).unwrap();

				// Load the file into a buffered reader and append it to the
				// Sink's queue
                let file = io::BufReader::new(fs::File::open(path).unwrap());
                self.sink.append(rodio::Decoder::new(file).unwrap());
                // Begin playback on the loaded file
                self.sink.play();
                // Communicate up to the client that we are currently playing
                callback.send(shared::SinkCallbackMessage::Playing).unwrap();
            }
        }
    }

	// When we handle a timeout, we may still need to communicate up the client with the sender
    fn handle_timeout(&mut self, callback: &mpsc::Sender<shared::SinkCallbackMessage>) {
        match self.loaded_song_playing {
            Some(_playing) => {
	            // If we have a loaded song and it's length is 0, then we are done with playback of this track
                if self.sink.len() == 0 {
                    println!("SINK:\ttimeout on recv poll and we noticed the song was over");
                    // Set that we no longer have an active track
                    // that we're playing
                    self.loaded_song_playing = None;

                    // And communicate that up to the client
                    callback
                        .send(shared::SinkCallbackMessage::SongEnded)
                        .unwrap();
                }
            }
            None => (),
        }
    }
}

Ok, that was the most I’m going to throw at you today, hopefully you can follow what’s going on there. The rest of the file is just a convenient “constructor” that will start this sink running forever in a seperate thread and return the sender/receiver to communicate with the spawned thread.

src/backend.rs (continued)

use std::sync::mpsc;
use std::thread;

use crate::shared;

pub fn create_backend_with_client_and_callback() -> (
    mpsc::Sender<shared::SinkMessage>,
    mpsc::Receiver<shared::SinkCallbackMessage>,
) {
	// Create the channel to send data into the sink
    let (sender_for_client, recv_for_backend) = mpsc::channel();

	// Create the channel to receive data from the sink
    let (callback_from_backend, callback_to_client) = mpsc::channel();

	// Spawn the `run_forever` function with the correct receiver and sender for the sink
    thread::spawn(move || run_forever(recv_for_backend, callback_from_backend));

	// Return the correct sender and receiver for the client
    (sender_for_client, callback_to_client)
}

fn run_forever(
    rx: mpsc::Receiver<shared::SinkMessage>,
    callback: mpsc::Sender<shared::SinkCallbackMessage>,
) {
    println!("SINK:\tstarting to listen...");

	// Create the sink...
    let sink = SinkPlayback::new();

	// ...and let it rip!
    sink.run_forever(rx, callback);

    println!("SINK:\tdone listening");
}


// And then all of the code you just saw

Ok, that’s the backend, that’s the worst of it, I promise! Let’s look at a simple client now:

All this client does it, for each of the 5 files baked into its source code:

src/client.rs

use std::sync::mpsc;
use std::thread;
use std::time;

use crate::backend;
use crate::shared;

// This is our entry point for our client
pub fn looping_main() {
	// We create the sink and get the channels to communicate with it
    let (sender, rx) = backend::create_backend_with_client_and_callback();
    // And then we just play these 5 files!
    play_tone_with_pause(&sender, &rx, "flacs/four.flac".to_string());
    play_tone_with_pause(&sender, &rx, "flacs/three.flac".to_string());
    play_tone_with_pause(&sender, &rx, "flacs/two_higher.flac".to_string());
    play_tone_with_pause(&sender, &rx, "flacs/two_lower.flac".to_string());
    play_tone_with_pause(&sender, &rx, "flacs/one.flac".to_string());
}

// This should just do what it says on the tin; let's verify
fn play_tone_with_pause(
	// We have access to the sender to the sink
    sender: &mpsc::Sender<shared::SinkMessage>,
    // And the callback from the sink
    rx: &mpsc::Receiver<shared::SinkCallbackMessage>,
    // And the file to play, easy!
    filename: String,
) {
	// Just remember this is relating to pausing, we'll come back to this
    let mut should_pause_once = true;

	// Tell the Sink that we want to load a the provided file
    sender
        .send(shared::SinkMessage::LoadSong(filename))
        .unwrap();

	// This will break, it shouldn't actually run forever
    loop {
	    // Again, we block for 1 second, not neccessarily to match up
	    // with the backend sink, but just so that we can stop to do things
	    // (read: pause [and just for fun])) while the sink is running
        match rx.recv_timeout(time::Duration::from_millis(1_000)) {
	        // If we do get a message back!
            Ok(msg) => match msg {
	            // Just log that we now know we are playing
                shared::SinkCallbackMessage::Playing => println!("we heard that we are playing"),
                // Just log that we now know we are paused
                shared::SinkCallbackMessage::Paused => println!("we heard that we are paused"),
                // If the song has ended, then let's break out of this loop
                shared::SinkCallbackMessage::SongEnded => {
                    println!("we learned that the song ended");
                    break;
                }
            },
            // If we got a timeout,
            Err(mpsc::RecvTimeoutError::Timeout) => {
                println!("waited for a second");
                // If we still should issue a pause
                if should_pause_once {
                    println!("pausing for two second");
                    // Actually tell the sink that we want to pause
                    sender.send(shared::SinkMessage::PauseButton).unwrap();
                    // Wait 2 seconds...
                    thread::sleep(time::Duration::from_millis(2_000));
                    println!("resuming play after those seconds");
                    // Then start playback again!
                    sender.send(shared::SinkMessage::PlayButton).unwrap();
                    // Mark that we should no longer pause
                    // and just let playback resume until it ends
                    should_pause_once = false;
                }
            }
            // If we are ever disconnected, log so and just break out of the loop
            Err(mpsc::RecvTimeoutError::Disconnected) => {
                println!("recv sees that all clients have closed");
                break;
            }
        }
    }
}

Hopefully that wasn’t so bad. Feel free to read over both of those a few times, just to keep the different senders and receivers straight. That’s all I have planned for this post, let’s just see (unfortunately I will not be uploading a video with sound, you’ll have to try that yourself) how it looks when this demo runs:

 $ cargo run -q
SINK:	starting to listen...
SINK:	 handling resp: LoadSong("flacs/four.flac")
we heard that we are playing
waited for a second
pausing for two second
SINK:	 handling resp: PauseButton
resuming play after those seconds
we heard that we are paused
SINK:	 handling resp: PlayButton
we heard that we are playing
waited for a second
SINK:	timeout on recv poll and we noticed the song was over
we learned that the song ended
SINK:	 handling resp: LoadSong("flacs/three.flac")
we heard that we are playing
waited for a second
pausing for two second
SINK:	 handling resp: PauseButton
resuming play after those seconds
we heard that we are paused
SINK:	 handling resp: PlayButton
we heard that we are playing
waited for a second
waited for a second
waited for a second
SINK:	timeout on recv poll and we noticed the song was over
we learned that the song ended
SINK:	 handling resp: LoadSong("flacs/two_higher.flac")
we heard that we are playing
waited for a second
pausing for two second
SINK:	 handling resp: PauseButton
resuming play after those seconds
we heard that we are paused
SINK:	 handling resp: PlayButton
we heard that we are playing
waited for a second
waited for a second
waited for a second
SINK:	timeout on recv poll and we noticed the song was over
we learned that the song ended
SINK:	 handling resp: LoadSong("flacs/two_lower.flac")
we heard that we are playing
waited for a second
pausing for two second
SINK:	 handling resp: PauseButton
resuming play after those seconds
we heard that we are paused
SINK:	 handling resp: PlayButton
we heard that we are playing
waited for a second
waited for a second
SINK:	timeout on recv poll and we noticed the song was over
we learned that the song ended
SINK:	 handling resp: LoadSong("flacs/one.flac")
we heard that we are playing
waited for a second
pausing for two second
SINK:	 handling resp: PauseButton
resuming play after those seconds
we heard that we are paused
SINK:	 handling resp: PlayButton
we heard that we are playing
waited for a second
waited for a second
SINK:	timeout on recv poll and we noticed the song was over
waited for a second
we learned that the song ended
recv sees that all clients have closed

Ok, it is pretty boring without the timing information seeing when things are logged and without the sound to verify that it is actually paused and playing, but just for posterity there it is. If you want to run this yourself to actually see and hear it live…

Uploaded Code Examples

As always (when applicable) I have the code from this post available on github. Feel free to try it out and tinker with it, if you like. Ok, that’s all for today, let’s wrap this up and talk about future installments.

Conclusion

Hopefully this shed some light on how to interface with Sources, Streams, and Sinks to provide (in this case, questionably) useful audio playback. In the next post, I want to tie this in with the primitive Iced GUI we had been building in previous posts; stay tuned for that. Until then, take care!