I want to make a program in assembly/8086/masm/dosbox that turns the keyboard into various musical instruments so i need to be able to play some .wav files to produce the required sounds.I am aware of beep char and producing sound via sending frequencies to pc speaker (ports 41h,42h and 61h) but both ways are clearly not going to get me there.
I searched around and found that I need to use int 21h
for opening files, knowledge of .wav format and knowledge of sound programming using Sound Blaster.
Unfortunately I couldn't find any helpful documentation on how to use the Sound Blaster in Dosbox (or in general) so kindly if you can help me with my problem of how to play .wav files on dosbox or if you have any workarounds I am all ears ( more accurate eyes).
Though the question may classify as off-topic1 I believe it could be a precious resource to have on this site.
So I'm attempting to respond it.
A few notes on the environment:
I've used TASM as the assembler, there is no particular reason behind this choice but childhood memories.
The code should be compatible with MASM.
I'm using DOSBox to emulate a DOS environment.
DOSBox ships with a preconfigured SoundBlaster 16 card.
TASM can be run under DOSBox without any problem.
A scanned version of the TASM 5 manual2 is available online.
Though no uncommon syntax has been used, being unfamiliar with the assembler directives makes any code harder to read and understand.
The TASM 5 pack is available online.
Assembling, general source format and debugging
As a matter of convenience, the code developed for this answer can be found on GitHub.
The binary format is the MZ executable with memory model SMALL, one data segment named
_DATI
3 and one code segment named_CODE
.Each segment is defined multiple times for convenience4, both segments are PUBLIC so all these different definitions are merged together by the linker, resulting in just two segments5.
The sources target the 8086 as per OP request.
The sources use conditional macro and symbolic values6 in order to be configurable, only three values need to be adjusted eventually.
The default values match the default configuration of DOSBox.
We will see the configuration soon.
Due to the not elementary nature of this task, debugging is essential.
To facilitate it, TASM and TLINK can be instructed to generate, and include, debugging symbols.
Coupled with the use of TD debugging is greatly simplified.
Assemble the sources with
to generates full debugging symbols.
Use
td sb16
to debug the program.Some notes on debugging:
int 03h
(opcode CC) instruction where you want TD to break. This is handy to debug the ISR.Soundcard configuration
The SoundBlaster 16 (SB16) had a simple DSP that when filled with digital samples converted them into an analogue output.
To read the samples the card took advantage of a special transfer mode called Direct Memory Access (DMA), the chip that handled such transfers was capable of handling 4x2 in flight data movements.
The SB16 had a jumper, or a switch, to configure the channel to use to read the samplings.
When a block of sampling was over the card requested the attention of the CPU through an interrupt, the chip handling the interrupts had 8x2 request lines.
The SB16 had another jumper to select the Interrupt ReQuest line (IRQ) to use.
Finally, as every legacy device, the SB16 was mapped in the IO address space where it occupied sixteen continuous bytes.
The starting address, a.k.a. base address, of this block was configurable too. A part was fixed and a part was variable, the base address had a form of 2x0h where x was configurable.
All these options are reflected in the DOSBox configuration file.
The program given has been tested with these options7:
Sources configuration
Though this is a premature introduction to the sources, it is handy to present the configuration constants now that we have just seen the DOSBox configurations.
In the file
cfg.asm
there are these constantsThe values here must reflect the ones present in the DOSBox config file.
Every other constant defined in the file is for the use of the program and not intended to be modified unless you know what you are doing8.
The
cfg.asm
has nothing else of interest and won't be discussed again.How to play samples
After a long introduction, we are now ready to see how to play a buffer of samples.
A very good and synthetic reference is [available here]tutorial/documentation on the SB16 here.
This answer is basically an implementation of what is written there, with some verbose explanation.
These are the step we will follow:
To playback a buffer of samples the step requested are:
The goal is to play this WAV file of a Super Mario Bros coin.
Sources organization
There are seven files:
sb16.asm
is the main file that includes the others.It performs the steps above.
cfg.asm
contains the configuration constants.buffer.asm
contains the routines for allocating the samples buffer.data.asm
contains the routines that fill the buffer.This is the file to edit to adapt the source to other goals.
isr.asm
contains the routines that set the ISR and the ISR itself.dma.asm
contains the routines that program the DMA.dsp.asm
contains the routines that program the DSP.In general, the files are short.
The sample buffer
The high-level process is as follow: the card is given a buffer to read, when done it triggers an interrupt and stops; the software then update the buffer and restart the playback.
The drawback with this method is that it introduces pauses in the playback that present themselves as audible "clicks".
The DMA and the DSP support a mode called auto-initialize where, when the end of the buffer is reached, the transfer and the playback start over from the start.
This is good for a cyclic static buffer but won't help for an ever-updating buffer.
The trick is to program the DMA to transfer a block twice as large as the block the DSP is programmed to read. This will make the card generate an interrupt at the middle of the buffer.
The software will then resume the playback immediately and then update the half just read. This is explained in the diagram below.
How big should the buffer be?
I have chose a size of 1/100 sec at 44100 samples per second, mono, 16-bit per sample. This is 441 samples times 1 audio channel times 2 bytes per sample.
This is the block size. Since we have two blocks, the buffer size should be twice as much.
In practice, it is four times as much (in the end, it is about 3.5 KiB).
The big problem with the buffer is that it must not cross a physical 64KiB boundary9.
Note that this is not the same as not crossing a 64KiB logical boundary (which is impossible without changing segment).
I couldn't find a suitable allocation routine in the Ralf Brown Interrupt List, so I proceeded by abstracting the behaviour in two routines.
AllocateBuffer
that must set the variablesbufferOffset
andbufferSegment
with the far pointer to the allocated buffer of size at leastBLOCK_SIZE * 2
.Upon return, if
CF
it means the procedure failed.FreeBufferIfAllocated
that is called to free the buffer. It is up to this procedure to check if a buffer was effectively allocated or not.The default implementation statically allocates in the data segment a buffer that is twice as needed, as said.
My reasoning was that if this unmoveable buffer crosses a 64KiB boundary than it is split into two halves, L and H, and it is true that L + H = BLOCK_SIZE * 2 * 2.
Since the worst case scenario is when L = H, i.e. the buffer is split in the middle, the double size gives a size of BLOCK_SIZE * 2 * 2 / 2 = BLOCK_SIZE * 2 in the worst case scenario for both L and H.
This guarantees us that we can always find a half as large as BLOCK_SIZE * 2, which is what we needed.
The
AllocateBuffer
just find an appropriate half and set the value of the far pointer mentioned above.FreeBufferIfAllocated
does nothing.Note that by "buffer" I mean two "blocks" and a "block" is the unit of playback.
What format should the buffer use?
To keep the things simple, the DSP is programmed to playback 16-bit mono samplings.
However, the procedures that fill the blocks have been abstracted into
data.asm
.UpdateBuffer
is called by the ISR to update a block.The parameters are
They are used to compute the offset into the buffer with this code
The rest of the procedure read a block of samples from the WAV file.
If the file has ended, the file pointer is reset back to the beginning to implement a cycling playback.
BEWARE You are called in an ISR context, while the ACK and the EOI have already been issued, you must not clobber any register.
Failing to respect this rule will result in difficult to understand bugs and possibly freezes.
InitBuffer
is called one at the beginning to initialize the buffer if needed.The current implementation opens the file coin.wav10, read the sample rate and set the file pointer to the data section.
This procedure uses the CF to signal an error. If the CF is set, an error has been encountered and DX holds a pointer to a $ terminated string that will be printed.
FinitBuffer
used at the end to free the buffer resources.The buffer memory itself is freed as said above.
This is called even if
InitBuffer
fails.We will talk about the WAV reading below.
Installing the ISR
I assume you are familiar with the IVT.
I suggest reading about the twos 8259A PIC used to routes IRQs.
In shorts:
The file
isr.asm
is very short.The routine
SwapISRs
swap the current ISR pointer for the IRQ of the SB16 with a local pointer.Initially, this pointer points to the ISR
Sb16Isr
, so that the first call toSwapISRs
will install our ISR.The second call will restore the original one.
Sb16Isr
does a few things:UpdateBuffer
.NOTE
SwapISRs
also toggles the bit for the IRQ mask. It assumes that the IRQ is masked at the beginning of the program. You may want to change this to a more robust setting (or restart DOSBox if you abruptly interrupt the program).Programming the DMA controller
The SB16 was an ISA card, it couldn't read the memory directly.
To solve this problem the DMA chip, 8357 was invented.
It had four channels, independently configurable, that when triggered performed a read from the memory to the ISA bus or vice-versa.
There were two DMA controllers, the first one handled only 8-bit data transfers and channels 0-3.
The second one used 16-bit data transfers and handled the channels 4-7.
We are going to use the 16-bit transfers so, the DMA channel must be one of 5-7 (channel four is a bit special).
The SB16 can also use 8-bit transfers, so it has two configurations for the DMA channel: one for the 8-bit moves and one for the 16-bit moves.
Each channel, but channel four, has three parts:
The address is a physical address (linear)! So in theory only the first 64KiB were accessible.
The page number was used as the upper part of the address.
However, the counter logic is still 16-bit, so the pointer to the data to read/write still wrap around at 64 KiB boundaries (should be 128 KiB for 16-bit).
The
dma.asm
files contain a single routineSetDMA
that given the logical start address and the size, program the DMA.There isn't anything esoteric here besides a few arithmetic to compute the value to use.
The mode is Single mode and auto-initialization is on.
The document about the SB16 programming liked at the beginning has a very clear step-by-step procedure on this.
Programming the DSP
The SB16 IO layout was as follow:
The file
dsp.asm
contains the basic routinesResetDSP
,WriteDSP
andReadDSP
that performs a reset, write a byte to the DSP after waiting for right conditions, read a byte from the DSP.The DSP is used through commands.
To set the sampling of the playback use the command
41h
, followed by the low byte of the sampling frequency and then by the high byte.The routine
SetSampling
takes the sampling frequency in AX and set it.To playback use the command
b6h
, followed by a mode byte and then by the block length (two bytes, low byte first).The routine
StartPlayback
takes the sampling frequency in AX, the mode byte in BL and the size in CX and start a playback (after setting the sample rate).Note that the DSP doesn't need to know the address of the buffer, it just triggers the channel request pin of the DMA and it will have the data on the bus.
It is the DMA that have to know where the buffer is.
To stop a playback use the command
d5h
.StopPlayback
does this.Playing the WAV file
What the demo program do is playing the coin.wav file.
This is file is specific it is a 16-bit mono file.
The demo program doesn't parse the full RIFF format (you can see this nice page, it is hardwired to work with that specific file.
Though any file with identical format, yet different data, should do.
After the steps introduce at the beginning, the program simply wait for a keystroke.
After that it performs all the de-initializations (including stopping the playback) and exit.
To continue from here, you have "only" to properly implement the routine in
data.asm
.It should be straightforward to make each key plays a different file.
If the number of file is small I would open all the files in
InitBuffer
, then insb16.asm
implement a loop likedwhere each jump gets the file handle to play. (a lookup table would be better).
Then:
xchg
the new file pointer withfileHandle
(used byUpdateBuffer
).I leave to you how to make the playback stop when the key is released and resume when it is pressed.
Code
sb16.asm
cfg.asm
dma.asm
isr.asm
data.asm
1 For example because it asks for a non-trivial amount of code or for a resource.
2 Beware that the Table Of Content has some pages switched.
3
_DATA
is already defined.4 Each source file redefine those segments if used.
5 The symbols
_DATI
and_CODE
can be used to denote the segment part of the starting address of the final segments.6 I don't remember the exact technical name for the
EQU
values.7 These values are DOSBox defaults but be sure to check the config file anyway.
8 Specially because TASM lacks supports for a lot of conditional and a bit of bit-arithmetic is needed to set some value.
9 This should be 128KiB for 16-bit DMA, which we are using, but I don't remember exactly and didn't want to experiment.
10 Beware of DOS limitations on file names.