Personal Log »

Coding my own sound drivers for 16-bit DOS

My estimation on how long would it take me to complete my current project “Alien Intruder” was a bit off, and it is because I subestimated –or more like I forgot– that I had to write my own sound driver.

In Gold Mine Run! I used the excellent libmikmod, that gives you everything you need, supporting mixing of music and sound effects. Still, it wasn’t that easy to use, and it supports DJGPP. Which is 32-bit, and I’m targeting 16-bit with the IA-16 GCC port.

So I decided I would write my own driver, how hard can it be?

The plan was supporting:

  • FM for music, via AdLib compatibility.
  • 1 channel 8-bit PCM sound via Sound Blaster.

And I have all that now, but it took me longer than expected because the docs I found were missing small details, and my own incompetence, of course.

Playing an IMF file, like the ones used in “Wolfenstein 3D” for example, is very easy. They are just an array of triplets (register, value and time) that you send to the sound card at an specific frequency, which was my first problem: I didn’t change the Programmable Interval Timer (PIT) chip from the default 18.2065Hz in Gold Mine Run!, and that was fine for MikMod, but not good enough for the FM playback.

So I had to do that, and it took me a bit to get it right because I had something else getting in the way and I though I was reprogramming the PIT wrong.

At the end it was simple:

    /* disable interrupts */
    disable();
    outportb(0x43, 0x36);
    outportb(0x40, TIMER_SPEED % 256);
    outportb(0x40, TIMER_SPEED / 256);
    /* enable interrupts back */
    enable();

With TIMER_SPEED calculated based on the desired timer rate dividing 1193182, which is the clock frequency of the PIT.

Then, to restore the PIT, we do:

    /* disable interrupts */
    disable();
    outportb(0x43, 0x36);
    outportb(0x40, 0xff);
    outportb(0x40, 0xff);
    /* enable interrupts back */
    enable();

Which goes back to the default (1193182 / 18.2065 gives us a TIMER_SPEED of 65535).

With that out of the way, we use the interrupt handler to send data to the sound card (those “register and value” from the triplets I mentioned before). And that’s all, other that I need to learn to use a tracker that can export to this format. It will be OK, I’m sure of that.

Programming the Sound Blaster for PCM sound is not too complicated, but it has two parts: setup the DMA (the hard bits), and writing to the DSP (easy and well documented, for example Programming the Sound Blaster DSP).

To keep things simple I’m focusing on “plain” Sound Blaster, without using the new features of later revisions (e.g. the Sound Blaster 16 has other ways of setting up the DMA), and it should work fine with most configurations, but I kind of expect things to be a bit standard via the BLASTER environment variable. For most people playing the game with DosBox it will be fine, and I don’t need to support all the weird configurations out there.

There were two gotchas in this part:

  • the memory used by the DMA can’t cross a 64k page physical boundary.
  • for whatever reason, when I used a large buffer for the DMA(say 48K), I got pops and weird noise in some samples.

My first big mistake, that took me some time to fix, was that I’m using DOS’ int 21h service 48h to allocate memory, and that returns a segment. So if you ask for 64k, that won’t cross a page boundary, right? Well, that is not correct, the page you get is virtual.

The way to calculate and find the page boundary is using a 20-bit memory address. If the end of our buffer is on a different segment on that 20-bit address, we are crossing a boundary.

A simple way of dealing with this is allocating double the memory you need (for example 32K for a 16K buffer), and if the end of the first 16K are on a different segment, just use the second 16K that are guaranteed then to fit on a 64k page.

    /* falloc is my own "far alloc" as I implemented far pointers */
    dma_buffer = falloc(MAX_SAMPLE_LEN * 2);
    if (!dma_buffer)
        return 0;

    dma_start = dma_buffer;
    /* 20-bit address */
    dma_linear = (dma_buffer >> 16) << 4;

    /* avoid crossing a page boundary */
    if ((dma_linear >> 16) != ((dma_linear + MAX_SAMPLE_LEN) >> 16))
    {
        dma_linear += MAX_SAMPLE_LEN;
        dma_start += MAX_SAMPLE_LEN;
    }

    /* these won't change */
    dma_page = (dma_linear >> 16) & 0xff;
    dma_offs = dma_linear & 0xffff;

I keep dma_buffer unaltered because I need it to free the memory with DOS later on, but my working buffer is really pointed by dma_start. Also the DMA works with the 20-bit address (also called linear), so I pre-calculate the values here.

After this is just matter of copying the sample I want to play (in my case MAX_SAMPLE_LEN is 16K), setup the DMA to use the buffer and the right size (depending on the sample), and ask the DSP so play it.

I implemented a simple priority based playback, like I do in my 8-bit games, so a sample plays only if there is nothing playing already, or the sample you want to play has higher priority than what is currently playing.

I track what I’m playing by setting a variable when I start playing a sample, and clearing it when the IRQ interrupt is triggered by the sound card when it has finished playing a sample.

With this priority table, the action sounds fine with only one channel and saving myself from doing mixing of multiple channels:

enum {
    FXJUMP = 0,
    FXLASER,
    FXIMPACT,
    FXPICKUP,
    FXTIME,
    FXWARP,
    FXBLAST,
    FXBOMB,
    FXEXPLO,
    FXCREW,
    FXDEAD,
    FXBLIP,
};

Initially I thought I could load all my samples in a 64K page –being careful with the size of the samples–, and play them from there; but for whatever reason, even if the memory is not crossing a page boundary, the playback isn’t right and there are unexpected pops or noises in real hardware (and not DosBox). I don’t know what is this, and I can only test with the excellent 86Box, so I decided to use a smaller buffer and just copy there the sample I want to play.

This has a performance hit, but so far the game plays nice on a 286 –as long as the VGA card is decent–, so I’m not concerned.

Anyway, I didn’t want to make this a tutorial, and is not; but I explained a bit what I have been doing. The game is progressing nicely, but I’m not going to suggest any release date this time!

Would you like to discuss the post? You can send me an email!