Muser
Typical hobbyist programmer projects are a todo shopping list app, an editor,
a hobby operating system, a space invaders game. Added to this list should be
a music player. I must have written half a dozen music players by now—one
of them even in C# on Windows (!) which is pretty far out of my usual
comfort zone.
A music player app brings together an interesting mix of fields of expertise;
it’s audio-visual, but also needs UI design. I want the execution to be
lean and mean, and it has to be cross-platform: Linux, Mac, Windows.
And so I settled on Rust with SDL3.
Purple Haze
SDL with Rust can be challenging. Previous experiences with SDL version 2
were rough. First off, SDL3 plays much nicer with Rust.
The lifetimes on textures are still there (and I could rant about why
that’s bad for 20 minutes, but I won’t).
Secondly, the SDL bindings are kind of incomplete for SDL_mixer, which is
the audio part. A brilliant move on the part of the SDL Rust developers:
provided are socalled SDL “sys” crates that are unsafe bindings plugged
straight into C. This allows us to write the audio code, albeit in unsafe
Rust.
For producing sound with SDL you need a Mixer, which is the thing that sends
sound data to the audio device. The Mixer can mix multiple Tracks, which are
audio channels. For playing music we will use only one Track.
Lastly you need Audio data, which is the mp3 (or ogg, or wav) music file.
unsafe {
if !sdl3_mixer_sys::mixer::MIX_Init() {
panic!("failed to initialize SDL sound mixer");
}
let mixer = sdl3_mixer_sys::mixer::MIX_CreateMixerDevice(
sdl3_sys::audio::SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK,
std::ptr::null(),
);
if mixer.is_null() {
panic!("failed to open default audio output device");
}
let track = sdl3_mixer_sys::mixer::MIX_CreateTrack(mixer);
if track.is_null() {
panic!("failed to allocate sound mixer track");
}
let c_filename = CString::new(filename
.to_string_lossy()
.into_owned())
.unwrap();
let audio_data = sdl3_mixer_sys::mixer::MIX_LoadAudio(mixer,
c_filename.as_ptr(), false);
if audio_data.is_null() {
log::error!("MIX_LoadAudio() failed: {:?}", filename);
}
...
}
You don’t see it from this code snippet, but mixer, track, and
audio_data are pointers (not references!) pointing straight into C land.
Yes, it’s unsafe. But we do check for null pointers, so don’t worry about it
too much.
For neatness, let’s keep them in a struct:
struct AudioPlayer {
// this is all unsafe pointing directly into C memory
mixer: *mut sdl3_mixer_sys::mixer::MIX_Mixer,
track: *mut sdl3_mixer_sys::mixer::MIX_Track,
audio_data: *mut sdl3_mixer_sys::mixer::MIX_Audio,
}
In order to prevent memory leaks we must implement the Drop trait
for this structure:
impl Drop for AudioPlayer {
fn drop(&mut self) {
unsafe {
if !self.audio_data.is_null() {
sdl3_mixer_sys::mixer::MIX_DestroyAudio(self.audio_data);
}
assert!(!self.track.is_null());
sdl3_mixer_sys::mixer::MIX_DestroyTrack(self.track);
assert!(!self.mixer.is_null());
sdl3_mixer_sys::mixer::MIX_DestroyMixer(self.mixer);
}
}
}
Now, we can play music by sticking the Audio data onto a Track and
issueing the MIX_PlayTrack function. This function takes some “properties”
parameter, which are flags like: loop this track, or fade in / fade out,
etcetera.
We don’t need anything like that here, so pass an empty set of properties.
if !sdl3_mixer_sys::mixer::MIX_SetTrackAudio(track, audio_data) {
log::error!("MIX_SetTrackAudio() failed");
return;
}
let props = sdl3_sys::properties::SDL_CreateProperties();
if props == 0 {
log::error!("failed to allocate SDL3 properties");
return;
}
if !sdl3_mixer_sys::mixer::MIX_PlayTrack(track, props) {
log::error!("MIX_PlayTrack() failed");
} else {
log::info!("now playing: {:?}", filename.file_name().unwrap());
}
When The Music’s Over
Now that we have music playing, it would be nice to know when a track ended, so we can start the next song. Oh boy, what are we asking for? Now we are getting into the nitty gritty of letting Rust talk to C and vice versa.
When the song ends, SDL_mixer can call a callback function. The callback
function is in principle a C function, but we’re coding in Rust.
The callback function takes a C pointer to custom user data, so it’s a C type
of void* really.
Here is what we’ll do: the struct AudioPlayer will have a boolean flag
saying whether the song has ended. We pass this struct as custom user data
to the callback handler. The callback handler will set the flag.
// this code is under impl AudioPlayer
fn register_stopped_callback(&mut self) {
self.song_ended = false;
unsafe {
sdl3_mixer_sys::mixer::MIX_SetTrackStoppedCallback(
self.track,
Some(callback_handler),
self as *mut AudioPlayer as *mut std::ffi::c_void,
);
}
}
The callback hander is a C function (written in Rust) that takes a
void pointer to custom user data (the struct AudioPlayer), and also
the current track (which we don’t use).
unsafe extern "C" fn callback_handler(
userdata: *mut std::ffi::c_void,
_track: *mut sdl3_mixer_sys::mixer::MIX_Track,
) {
if userdata.is_null() {
return;
}
let audio_player = unsafe { &mut *(userdata as *mut AudioPlayer) };
audio_player.song_ended = true;
}
The main loop (every SDL app has a main loop) just checks the song_ended
flag. If the song did end, then start the next song.
Radio Ga Ga
Arguably I could have done all of this in C, but I didn’t. The rest of the application is pure Rust, much like any other (safe) Rust code.
But the real shocker is: half of the credit should go to AI. I vibe coded multiple prototypes in just one day using Google Gemini. I did spend considerable time afterwards refining and refactoring, but honestly, I don’t think I would have gotten good results as fast without AI.
I don’t particularly love how AI has taken over coding (and music! and art!). Admittedly Gemini is fast and quite capable at coding in Rust. I actually learned some things from it, too.