home

Duke on Fluidsynth

January 13, 2018 ❖ Tags: writeup, programming, video-games, audio, c++

My first experiences with Duke Nukem 3D were with EDuke32 ages ago. This was back when I was running Windows Vista, and while my memory is a bit lacking, I swear that I had working music then. Ever since I made the switch to Linux, I haven't had working music playback in EDuke. Frustrated at the fact that my past few years of Duke 3D have been devoid of all sound besides the screams of death and Duke's trash talking, I've finally decided to troubleshoot it.

My first hypothesis was that there was a build flag for music support, and that the binaries for EDuke in my distribution's package repository were compiled without it. This led me to look at the Linux build instructions, which specifically mention an EDUKE32_MUSIC_CMD environment variable for specifying an external MIDI player to use. This tipped me off on the issue: my version of EDuke couldn't play MIDI. This made sense, since all of the other game sounds were working just fine. I set the TiMidity++ command-line tool as the external MIDI player, as I've had luck using TiMidity++ with QZDoom, and it worked on the first try. This victory was short-lived, however, as the game froze the second I started up the first episode. I figured that EDuke was waiting on the TiMidity++ process to die off, which is when I decided to crack open the source code.

The code revealed that on Linux platforms, EDuke uses SDL2_Mixer for music output. I'm mildly familiar with it; it's a wrapper around the SDL audio module, providing loaders for several sound formats such as OGG and MIDI. Unfortunately, it seems incapable of playing MIDI on my system. Some further research revealed that for MIDI playback, SDL2_Mixer can use either FluidSynth, or an internal version of TiMidity. This reminded me of an issue I had when I first installed GZDoom on my machine: soundfonts.

You're supposed to be able to specify a default soundfont for FluidSynth in /etc/conf.d/fluidsynth, but in my experiences with the command-line tool, this is ignored entirely. Similarly, a default soundfont can be specified in /etc/timidity++/timidity.cfg, but the only things I've used that have respected that are QZDoom and the TiMidity++ command-line tool. Compiling SDL2_Mixer from source and forcing it to use the internal version of TiMidity has the same issue as before.

I suspect that the reason for this is the fragmentation of TiMidity releases. SDL2_Mixer has an internal version of TiMidity. So does QZDoom. It seems to be one of those libraries that just gets copied into version control because it's small enough, like that Vorbis decoder by RAD Game Tools. This has the consequence that it will almost never be updated, and you may have several programs using different, incompatible versions of it. In the case of QZDoom, the copyright header in timidity.cpp is dated 1995.

I looked at libTiMidity in hopes of debugging the issue, which is when I realized that some versions of TiMidity literally do not support specifying a default soundfont, which would explain why SDL2_Mixer is dead silent.

else if (!strcmp(w[0], "soundfont") ||
         !strcmp(w[0], "font"))
{
  /* "soundfont" sf_file "remove"
   * "soundfont sf_file ["order=" order] ["cutoff=" cutoff]
   *                    ["reso=" reso] ["amp=" amp]
   * "font" "exclude" bank preset keynote
   * "font" "order" order bank preset keynote
   */
  DEBUG_MSG("FIXME: Implement \"%s\" in TiMidity config.\n", w[0]);
}

Alright, so TiMidity isn't the way to go at all, and FluidSynth has issues specifying a default soundfont via configuration files, but perhaps the FluidSynth API exposes a means of specifying a soundfont. Fortunately, this was easy to check as FluidSynth has the best documentation I've ever seen from a library written in C. The developer documentation is rich with examples, and one of them even involves what we're looking for. Loading a soundfont with FluidSynth turns out to be as easy as calling fluid_synth_sfload.

Writing a drop-in replacement for the SDL2_Mixer MIDI driver is uncomplicated because Duke3D maintains a structured API for its music drivers. There are two drivers in the source tree, currently: the original Apogee Sound System implementation (source/duke3d/src/music.cpp), and the reimplementation using SDL2_Mixer (source/duke3d/src/sdlmusic.cpp). To make things simple, we'll just replace sdlmusic.cpp and define the following routines:

  • const char *MUSIC_ErrorString(int32_t ErrorNumber)
  • int32_t MUSIC_Init(int32_t SoundCard, int32_t Address)
  • int32_t MUSIC_Shutdown(void)
  • void MUSIC_SetVolume(int32_t volume)
  • int32_t MUSIC_GetVolume(void)
  • void MUSIC_SetLoopFlag(int32_t loopflag)
  • void MUSIC_Continue(void)
  • void MUSIC_Pause(void)
  • int32_t MUSIC_StopSong(void)
  • int32_t MUSIC_PlaySong(char *song, int32_t loopflag)
  • int32_t MUSIC_InitMidi(int32_t card, midifuncs *Funcs, int32_t Address)
  • void MUSIC_Update(void)

The names are very descriptive in this case, and the routines themselves are quite simple. Routines that return an int32_t are just returning an error code (MUSIC_Ok or MUSIC_Error), with the exception of MUSIC_GetVolume, which returns the volume on a scale of 0 to 255. In our case, most of these will be stubs. For example, MUSIC_Update and MUSIC_Continue are irrelevant for FluidSynth.

Also, it's worth mentioning that the "song" parameter to MUSIC_PlaySong isn't a filename, it's a pointer to an in-memory version of the MIDI file. FluidSynth supports reading MIDI files from memory, but unlike SDL2_Mixer's in-memory MIDI loader, the file's size has to be explicitly specified. I dug up a specification of the format and hacked together a little routine to figure out the size. It isn't particularly important, but I wanted to mention it because it worked on the first try, which warranted some celebration.

char *tracks;
size_t file_size;
uint16_t num_tracks;

tracks = song + 0x14;
num_tracks = *((uint16_t *) (song + 0x10));
file_size = 0x14; // Size of the MIDI header.

while (num_tracks--) {
    uint16_t track_size;

    if (!memcmp(tracks, "MTrk", 4)) {
        break;
    }

    track_size = *((uint16_t *) (tracks + 0x04));
    file_size += track_size + 0x08;
    tracks += track_size + 0x08;
}

This all ended up being simple enough that I was able to get MIDI playback working in under an hour on a Friday night. Yeah. I had some friends who wanted to go out that night, but I stayed home and wrote a MIDI driver instead. (That isn't the real reason, I'm not that much of a loser).

Unfortunately, because I was just hacking it together quickly, the initial implementation had a few issues:

  • No error reporting (MUSIC_ErrorString just returns "Nothing to see here…")
  • Doesn't use modern C++, and only loosely follows the EDuke32 code style.
  • Directly includes the FluidSynth headers, which seems to be a taboo in the EDuke codebase.
  • MUSIC_StopSong will shutdown and reinitialize the entire audio driver just to flush whatever's currently playing out of the player.
  • Replaces sdlmusic.cpp, instead of being an independent source file that can be included at compile time.
  • No volume controls.
  • Soundfont and audio backend are hardcoded to my system.

The first three were quite easy to fix, and as I don't have any plans to push this upstream, they were really non-issues. The thing with MUSIC_StopSong is also kind of a non-issue, as reinitializing the audio system is the only way to flush the FluidSynth player right now. That fifth issue is also something I'm not going to deal with unless someone confronts me about getting this included upstream, because this is a lot easier to maintain as a drop-in replacement.

Volume controls were extremely trivial to implement, as the only thing the driver has to do is expose MUSIC_SetVolume. The routine receives a number on the interval [0, 255], where 0 is the quietest, and 255 is the loudest. FluidSynth provides a 'synth.gain' setting, which is essentially volume, but it instead accepts numbers on the interval [0.0, 10.0].

The naive approach (which is what I did the first time around) is to multiply the parameter by some scalar (10.0 / 255) to fit on the interval of [0.0, 10.0]. This was quite painful for my poor little ears. So I instead scaled the number to fit on the interval of [0.0, 1.0].

Finally, specifying the soundfont is something I'll address in the future. My patch adds some stuff to the EDuke options menu for specifying an audio backend (alsa, pulse, etc), but I have yet to figure out how to make an option that's stored as a string.

If you want to check out my patchset, you can view the repository here, and here's a demo video:

Comments for this page

    Click here to write a comment on this post.