August 92 - AROUND AND AROUND: MULTI-BUFFERING SOUNDS
AROUND AND AROUND: MULTI-BUFFERING SOUNDS
NEIL DAY
The main problem with digital audio is that the data often exceeds the amount of
available memory, forcing programmers to resort to multiple-buffering schemes.
This article presents one such technique, in the form of a program called MultiBuffer,
and explores some interesting things you can do along the way.
When dealing with digital audio, you're frequently going to find yourself in situations
where the sample you want to play won't fit in the memory you have available. This
leaves you with several alternatives: you can play shorter sounds; you can try to
squeeze the sound down to a more manageable size by resampling it at a lower
frequency or by compressing it (both of which will degrade the fidelity of the sound);
or you can try to fool the machine into thinking it has the whole sample at its disposal.
In cases where you don't want to compromise length or quality, trickery is your only
option.
If you've spent any time with the Sound Manager, you no doubt have run across the
routine SndPlayDoubleBuffer, which provides one reasonably straightforward method
of implementing a double-buffering scheme. The advantage of using
SndPlayDoubleBuffer is that it allows you to get a fairly customized double- buffering
solution up and running with very little work. You need only write a priming routine
for setting up the buffers and filling them initially, a DoubleBack procedure that takes
care of swapping buffers and setting flags, and a read service routine for filling
exhausted buffers; the Sound Manager handles all the other details.
SndPlayDoubleBuffer is in fact used by the Sound Manager's own play- from-disk
routine, SndStartFilePlay.
If your program will simply play from disk, your best bet is probably either
SndPlayDoubleBuffer or SndStartFilePlay. Both offer good performance painlessly,
saving you development time and avoiding the need to understand the Sound Manager to
any great degree. If, however, you want to do some snazzier things with your sound
support, such as adding effects processing, a deeper understanding of multiple
buffering is essential. Read on . . .
PROCESSING SOUNDS WITH THE ASC
Audio support on the Macintosh computer is handled by the Apple Sound Chip (ASC),
which takes care of converting the digital representation of your sound back to analog,
which can then be played by a speaker attached to your Macintosh. (See "Sound: From
Physical to Digital and Back" for a description of this process.)
You can think of the ASC as a digital-to-analog converter with two 1K buffers to hold
the data to be processed. When either of the buffers reaches the half-full mark, the
ASC generates an interrupt to let the Sound Manager know that it's about to run out of
data. Because of this, it's important to makesure that your buffers are a multiple of
512 bytes, since in an attempt to keep the ASC happy the Sound Manager will pad your
data with silence if you choose an "odd" buffer size. In the worst case this can lead to
annoying and mysterious silences between buffers, and at best it will hurt your
performance. This doesn't mean that you need to limit yourself to 512- or 1024-byte
buffers: The Sound Manager takes care of feeding large buffers to the ASC a chunk at a
time so that you don't have to worry about it. As long as your sound is small enough to
fit into available memory, you can play it simply by passing the Sound Manager a
pointer to the buffer containing the sample.
Assuming that the ASC's buffers never run dry, it will produce what seems to be a
continuous sound. As long as you can keep handing it data at a rate greater than or
equal to the speed at which it can process the data, there won't be any gaps in the
playback. Even the best-quality samples, like those found on audio CDs, play back at
the leisurely rate of 44,100 sample frames per second (a frame consists of two
sample points, one for each channel of sound), a rate that the processors of 68020-
based Macintosh computers and SCSI devices can keep up with. All you need to do is
hand one buffer to the Sound Manager to play while you're filling another. When the
buffer that's currently playing is exhausted, you pass the recently filled one to the
Sound Manager and refill the empty one. This process is the digital equivalent of the
venerable bucket brigade technique for fighting fires.
CONTINUOUS SOUND MANAGEMENT
This section discusses a general strategy for actually making a multibuffering scheme
work. First, however, I want to touch on some of the properties and features of the
Sound Manager that we'll exploit to accomplish multibuffering. If you're already
familiar with the Sound Manager, you may want to skip ahead to the section "When
You're Done, Ask for More.
CHANNELS, QUEUES, COMMANDS, AND CALLBACKS
The atomic entity in the Sound Manager is a channel. A channel is essentially a
command queue linked to a synthesizer. As a programmer, you issue commands to the
channel through the Sound Manager functions SndDoCommand and SndDoImmediate. The
Sound Manager executes the commands asynchronously, returning control to the caller
so that your application can continue with its work. The difference between the two
functions is that SndDoCommand will always add your request to a queue, whereas
SndDoImmediate bypasses the queuing mechanism and executes the request
immediately. It's important to understand that at the lowest level the Sound Manager
always executes asynchronously--your program regains control immediately,
whether the call is queued or not.
We're interested here in two sound commands, bufferCmd and callBackCmd.
• bufferCmd sends a buffer off to the Sound Manager to be played. The buffer
contains not only the sample, but also a header that describes the
characteristics of the sound, such as the length of the sample, the rate at
which it was sampled, and so on.
• callBackCmd causes the Sound Manager to execute a user-defined routine.
You specify this routine when you initialize the sound channel with the Sound
Manager function SndNewChannel. Be aware that the routine you specify
executes at interrupt time, so your application's globals won't be intact, and
the rules regarding what you can and can't do at interrupt time definitely
apply.
WHEN YOU'RE DONE, ASK FOR MORE
The key to achieving continuous playback of your samples is always to have data
available to the ASC. To keep the ASC happily fed with data, your code needs to know
when the current buffer is exhausted, so that it can be there to hand over another
buffer. Most asynchronous I/O systems provide completion routines that notify the
application when an event terminates. Unfortunately, such a routine is not included in
the current incarnation of the output portion of the Sound Manager. In the absence of
a completion routine, the best way to accomplish this type of notification is to queue a
callBackCmd immediately following a bufferCmd. For the purpose of this discussion,
the bufferCmd-callBackCmd pair can be considered a unit and will be referred to as
aframe from here on. Since it's often not practical to play an entire sample in one
frame, you'll probably need to break it up into smaller pieces and pass it to the Sound
Manager a frame at a time. Figure 1 illustrates howa sample too large to fit in memory
is broken up into frames consisting of a bufferCmd and callBackCmd.
To further reinforce the illusion of a frame being a standalone entity, it's useful to
encapsulate the bufferCmd-callBackCmd pair in a routine. A bare-bones version of a
QueueFrame routine might look like this:
OSErr QueueFrame (SndChannelPtr chan, Ptr sndData)
OSErr err;
SndCommand command;
command.cmd = bufferCmd;
command.param1 = nil;
command.param2 = (long) sndData;
err = SndDoCommand (chan, &command, false);
if (err)
return err;
command.cmd = callBackCmd;
command.param1 = nil;
command.param2 = nil;
err = SndDoCommand (chan, &command, false);
if (err)
return err;
}
By queuing up another frame from the callback procedure, you can start a chain
reaction that will keep the sample playing. Be sure to have another frame ready and
waiting before the Sound Manager finishes playing the current frame. Failure to do
this will cause a gap in the playback of the sound, often referred to aslatency .
Two important factors that can cause latency are the speed of your source of sound data
and the total size of the buffers you're using. The faster your data source, the smaller
the buffer size you can get away with, while slower sources require larger buffers.
For example, if you're reading from a SCSI device with an average access time of 15
milliseconds, you can keep continuous playback going with a total of about 10K of
buffer space; if your source is LocalTalk, plan on using significantly larger buffers.
You may need to experiment to find the optimal buffer size.
A third factor that can contribute to latency is the speed at which your callback code
executes. It's very important to do as little work as possible within this routine, and
in extreme cases it may be advantageous to write this segment of your code in assembly
language. Of course, faster 68000-family machines will let you get away with more
processing; a routine that may require hand coding to run on a Macintosh Plus can
probably be whipped off in quick-and-dirty C on a Macintosh Quadra. As is the case
with all time-critical code on the Macintosh, it's important to take into account all the
platforms your code may run on.
Once you've compensated for any potential latency problems, this method of chaining
completion routines has a couple of advantages:
• After you start the process by queuing the first frame, the "reaction" is
self- sustaining; it will terminate either when you kill it or when it runs out
of data.
• Once started, the process is fully asynchronous. This gives your
application the ability to continue with other tasks.
Figure 1 Dividing a Sample into Frames
Your callback procedure must take care of three major functions. It must queue the
next frame, refill the buffer that was just exhausted, and update the buffer indices. In
pseudocode, the procedure is as follows:
CallbackService ()
// Place the next full buffer in the queue.
//
QueueFrame (ourChannel, fullBuffer);
//
// Refill the buffer we just finished playing.
//
GetMoreData (emptyBuffer);
//
// Figure out what the next set of buffers will be.
//
SwapBuffers (fullBuffer, emptyBuffer);
}
WHAT MAKES MULTIBUFFER DIFFERENT?
The previous section discussed general tactics for multiple-buffering models and for
chaining callback routines; these would be used by any continuous play module.
MultiBuffer, included on theDeveloper CD Series disc, uses these basic concepts as its
foundation, but differs in several important ways in order to address some
performance issues and attain a higher level of flexibility.
Thus far the discussion has centered onplayback-driven buffering models, in which
the completion routine is keyed to the playback. This model, on which MultiBuffer is
based, is appropriate for applications that play from a storage device or that play from
synthesis. Playing from a real-time source, such as a sound input device or a data
stream coming over a network, requires asource-driven buffering model, in which the
callback is associated with the read routine. There's little difference between these two
models, but using the wrong model can lead to the loss of small amounts of sound data.
The major design goal for MultiBuffer was to make it modular enough to be easily
customized. It includes an independent procedure for reading data from the source
(ReadProc), as well as a procedure for processing the raw data obtained from the
ReadProc (ProcessingProc). MultiBuffer also allows you to work with more than two
buffers; simply modify the constant kNumBuffers. In some situations, more than two
buffers can be handy, such as instances where you want to reduce the lag between
playback time and real time. Several classes of filter require that you have a fairly
extensive set of data available for processing. Pulse-response filters, low- and
high-pass (first-order) filters, and spectral-compression filters are all examples of
applications in which multiple buffers can simplify implementation. It's important to
realize, however, that using many buffers introduces extra overhead, so your buffer
sizes will need to be correspondingly larger. Because of this added overhead, you can
end up in a Catch-22 situation; there is a point at which the benefit of having more
buffers is negated by the increase in buffer size.
The optional processing procedure allows you to perform some simple modifications on
the data before it's played. It's vital that you keep the issue of latency in mind when
dealing with your processing procedure; it can have a profound effect on the amount of
time required to ready a buffer for play. Since this is a time-critical section of the
buffering code, it's often desirable to write this procedure in assembly language to
squeeze out the highest performance possible.
Because the procedures for reading sound data and for processing the data are separate
modules, MultiBuffer is quite flexible. The program includes a simple example of this
flexibility. One of the playback options is to play an Audio Interchange File Format
(AIFF) file backward. To achieve this, I altered ReadProc to read chunks of the target
file from end to beginning, then used ProcessingProc to reverse the contents of the
buffer. If you take a look in PlayFromFile.c, you'll find that the differences between
the functions ReadProc and ProcessingProc and their counterparts BackReadProc and
BackProcessingProc are minimal.
HOW IT HANGS TOGETHER
MultiBuffer is basically a series of three sequentially chained interrupt routines.
These are the callBackProc, a read completion routine, and a deferred task that takes
care of any processing that needs to be done on the raw data. Deferred tasks are
essentially interrupt routines that get put off until the rest of the regularly scheduled
interrupts have completed. A deferred task won't "starve" important interrupts
executing at a lower priority level. Such tasks also have the advantage of executing
when interrupts have been reenabled, allowing regular interrupt processing to
continue during their execution. Unfortunately, a deferred task is still bound by the
restrictions on moving or purging memory placed on regular interrupt routines.
The routine DoubleBuffer begins the execution. It takes care of setting up private
variables, priming the buffers, and initiating the play that starts the chain reaction.
MultiBuffer is composed of six main files, each of which deals with one of the
functional aspects of MultiBuffer.
• MainApp.c contains the application framework for the MultiBuffer demo.
• DoubleBuffers.c includes all the code for dealing with multibuffering. For
most applications, you shouldn't need to modify any of the code in this file.
• AIFFGoodies.c features fun with parsing AIFF header information. The
basic purpose of the code in this file is to get pertinent information out of an
AIFF file and into a sound header.
• PlayFromFile.c contains the routines for playing an AIFF file forward and
backward.
• PlayFromSynth.c has the code necessary for playing a wave segment as a
continuous sound.
• Oscillator.c is responsible for generating one cycle of a sine wave at a
specified frequency and amplitude.
NUTS AND BOLTS