August 1998

Low-level wave audio, part 2

by Kent Reisdorph

Last month, in part 1 of this series, we talked about RIFF files and how to use them in low-level audio operations. This month, we'll talk about playing wave audio with the low-level audio functions, including waveOutOpen, waveOutPrepareHeader, waveOutWrite, waveOutUnprepareHeader, and waveOutClose. Finally, we'll show how you can change the volume of the wave output device.

Playing a wave file

Playing a wave file requires several steps. First, you must have data to play. Once you have data, you need to open the wave device, prepare the wave header, and start playback of the wave data. Let's analyze these steps one at a time.

Getting the wave data

Last month we talked about reading wave data from a wave file on disk. That's probably the most obvious way of obtaining wave data. (The OpenBtnClick method in Listing B shows how to read wave data from a file, if you need a refresher course.) A wave file stored as a resource provides another source of wave audio data. The resource can be contained either in a DLL or in your program's executable file. See the article "Playing Wave Resources " for a description of creating and using wave files as resources.

A much less common way of obtaining wave data involves creating the data via some algorithm in your program. For example, you might prefer to create sound effects programmatically rather than loading a wave file from disk. You probably won't use this method of creating wave data very often, though.

Regardless of the source of your wave data, you'll need to allocate a buffer and fill it with wave data before you can move on to the next step. Remember that you must load the wave format header in addition to the data. Again, refer to the OpenBtnClick method in Listing B for an example. Next, you can open the wave audio device in preparation for playback.

Opening the wave-out device

Let's spend a moment talking about wave device identifiers. When you open a wave device, you must specify its device ID. A particular system may have more than one wave output device--for example, a computer may have two sound cards installed. In that case, you'd need to obtain the device ID of the sound card on which you wanted to play your sound. Fortunately, most systems have only one sound card, so you don't have to worry about it much. Windows provides the WAVE_MAPPER constant for use if you're unsure of the sound card's ID. Most of the time, you'll use WAVE_MAPPER and leave it at that. You open a wave output device by calling the waveOutOpen function (all the wave-out functions are located in MMSYSTEM.H). Before you try to open the device, though, it's a good idea to query the device to see whether it supports the format of your wave data. To query a device, call waveOutOpen and specify the WAVE_FORMAT_QUERY flag, as follows:
HWAVEOUT WaveHandle;
int Res = waveOutOpen(&WaveHandle, WAVE_MAPPER, &WaveFmt, 0, 0, WAVE_FORMAT_QUERY);
This code assumes that you previously filled in the wave format in a WAVEFORMATEX structure called WaveFmt (usually obtained when you read a wave file). When called in this way, waveOutOpen checks to see whether the device supports the given format, but doesn't actually open the device. If the wave format is supported by the device, waveOutOpen returns 0--you can then move on to opening the device. If the wave format isn't supported, waveOutOpen returns an error code, usually WAVERR_BADFORMAT (decimal 32). The code that opens the device is almost identical to the preceding example:
Res = waveOutOpen(&WaveHandle, WAVE_MAPPER, &WaveFmt, MAKELONG(Handle, 0), 0, CALLBACK_WINDOW);
There's a lot going on in this function call, so we'll take some time to explain it. For the first parameter, we pass the address of a variable that will contain a handle to the wave output device. If waveOutOpen is successful (returns 0), you can use the handle returned in WaveHandle when calling subsequent wave output functions. In both the preceding examples, we use the WAVE_MAPPER constant for the second parameter, the device ID. This constant tells Windows to select the sound card as the wave output device. The third parameter is a pointer to the structure that holds the wave format data.

The fourth parameter uses the MAKELONG macro to create a DWORD from the form's window handle. This is the proper way of converting a window handle into a DWORD. (I'll explain why we need the form's window handle when I discuss the flags parameter in just a bit.)

The fifth parameter contains any user-defined data. In this case, we aren't using user-defined data, so we pass 0 for this argument. You might use user-defined data to pass additional data to the wave output callback function, as we'll discuss in the next section.

Finally, we pass the CALLBACK_WINDOW constant for the final parameter. This constant tells Windows that we want any wave-out messages sent to our form's window procedure. Other possibilities for this parameter include CALLBACK_FUNCTION, CALLBACK_EVENT, CALLBACK_THREAD, or CALLBACK_NULL. These flags tell Windows to send the wave-out messages to a callback function, an event, a thread, or not to send messages at all.

Out of these possibilities, the only one you're likely to use is the CALLBACK_FUNCTION flag, which instructs Windows to use a callback function to report wave device status. Unfortunately, you can call only a specific set of Windows functions from within a callback function. Attempting to call any other functions will result in a deadlock. All in all, the CALLBACK_WINDOW flag is the easiest to implement. By the way, this is why we passed the form's window handle in the fourth parameter--because we specified the CALLBACK_WINDOW flag, we had to tell Windows which window procedure to send the messages to. (For more information, see "Wave-Out Messages" in this issue.)

You should always check the return value from waveOutOpen to be sure the device was opened properly and that the wave-out handle is valid. A return value of 0 indicates the function completed successfully. If an error occurs, the return value will be one of the multimedia error codes. To get a textual description of the error message you can call the waveOutGetErrorText function (see the CheckWaveError method in Listing B).

Preparing the wave header

The next step in playing a wave file is to prepare a wave header. The wave header is an instance of the WAVEHDR structure, and includes information about the wave buffer that contains the wave data. Specifically, it holds a pointer to the buffer and the size of the buffer in bytes. After you create and initialize a wave header structure, you call the waveOutPrepareHeader function to assign the wave header to the currently open wave device, as follows:
WAVEHDR WaveHeader;
WaveHeader.lpData = WaveData;
WaveHeader.dwBufferLength = DataSize;
Res = waveOutPrepareHeader( WaveHandle, &WaveHeader, sizeof(WAVEHDR));
This example assumes you have wave data in a buffer called WaveData, and that the size of the buffer is contained in a variable called DataSize. Notice that we pass the waveOutPrepareHeader function the wave handle obtained when we opened the device. We also pass a pointer to the WAVEHDR structure and, for the final parameter, the size of the structure. At this point, the wave output device is open, the header has been prepared, and we're ready to play the data in the buffer.

Starting playback

As is usually the case, the act of playing the wave data is the easiest part. It's the preparation it takes to get to this point that requires all the work! To start playback, call the waveOutWrite function as follows:
Res = waveOutWrite(
  WaveHandle, &WaveHeader, sizeof
	(WAVEHDR));
Once again, the WaveHandle variable tells Windows which device we're sending data to. We pass a pointer to the WaveHeader structure, prepared in the preceding step, as the second parameter. The final parameter is the size of the wave header. If waveOutWrite is successful (returns 0), the wave file starts playing and control is immediately returned to your application. The next step is to detect when the wave file has finished playing so you can clean up the wave header and close the wave device.

Clean up and close

After the wave data has completed playback, you need to unprepare the header that you prepared earlier and close the wave output device. Here's an example of the OnWaveDone function that performs those tasks:
void TForm1::OnWaveDone
	(TMessage& msg)
{
  if (msg.Msg == MM_WOM_DONE) {
    waveOutUnprepareHeader(WaveHandle, &WaveHeader, sizeof(WAVEHDR));
    waveOutClose(WaveHandle);
  }
}
As you can see, there isn't much to it. First we call waveOutUnprepareHeader to unprepare the header. As always, we pass the WaveHandle variable as the first parameter. The second and third parameters are identical to those passed when we called waveOutPrepareHeader earlier. Finally, the waveOutClose function closes the wave device. (If you don't close the wave device, it will be unavailable to other applications or to Windows.) Note that the wave file is always played asynchronously. The waveOutWrite function starts the wave playing and immediately returns control to your application. This means your application is fully operational while the wave file is being played. For this reason, you must use the MM_WOM_DONE message to determine when the file has finished playing. In addition, you'll have to do some more work if you want the sound to be played synchronously.

Setting the wave-out volume

Naturally, you'll want to be able to set the volume at which your wave data plays--and it's pretty easy to do once you have a valid wave-out device. You can use the waveOutGetVolume and waveOutSetVolume functions to get and set the volume, respectively. The volume is stored in a DWORD. The high word specifies the right channel volume setting, and the low word specifies the left channel volume setting. If the device doesn't have the capability of setting the right and left channel volumes independently, then the low word sets the volume and the high word is ignored. A value of 0 represents no volume, and a value of 0xFFFF specifies full volume. For instance, the following code will set the volume of both channels to 50%:
waveOutSetVolume(WaveHandle, 0x80008000);
In this case, we're using the handle returned from waveOutOpen to set the volume. You can use a device ID rather than a wave-out handle if you prefer. Since the sound card is typically wave device 0, the following code will set the sound card volume to full volume:
waveOutSetVolume(0, 0xFFFFFFFF);
This method has the benefit of not requiring an open wave out device in order to set the volume. Setting the volume is as easy as that. Note that waveOutSetVolume sets the volume only for the wave output device, not the master volume. The master volume can be set only through the multimedia mixer control (but that's a discussion best saved for another article).

The big picture

Listings Aand B contain the header file and source for an example program that demonstrates the concepts discussed in this article and in the companion article, "Playing Wave Resources." The example contains the primary components shown in Table A.

Table A: Example program components
ComponentName
TButtonOpenBtn
TButtonPlayBtn
TComboBoxResources
TMemoMemo1
TEditLoopEdit
TTrackBarVolumeBar

This program loads wave data either from a file or from one of four resources bound to the program's EXE file. Once the data is loaded, you can click the Play button to play the wave data. The TrackBar component allows you to change the output volume, and the Memo component reports the status of various operations as they occur. An Edit component allows you to specify the number of times to play the wave data. Figure A shows the application after we loaded and played several sound resources.

Figure A: Our sample program loads and plays wave data.
[ Figure A ]

 

Conclusion

Is it worth all this work just to play a simple sound? It certainly is if you need complete control over your wave audio data. Whenever possible, use the PlaySound function to play wave audio files. Save the use of the low-level wave audio functions for those times when you need the kind of power they provide. Next month, in part 3 of this series, we'll tackle recording wave audio.

Listing A: PlayWavU.H

//---------------------------------------------
#ifndef PlayWavUH
#define PlayWavUH
//---------------------------------------------
#include <Classes.hpp>
#include <Controls.hpp>
#include <StdCtrls.hpp>
#include <Forms.hpp>
#include <Dialogs.hpp>
#include <mmsystem.h>
#include <ComCtrls.hpp>
//---------------------------------------------
class TForm1 : public TForm
{
__published:  // IDE-managed Components
  TButton *PlayBtn;
  TOpenDialog *OpenDialog1;
  TMemo *Memo1;
  TGroupBox *GroupBox1;
  TLabel *Label1;
  TButton *OpenBtn;
  TComboBox *Resources;
  TGroupBox *GroupBox2;
  TLabel *Label3;
  TTrackBar *VolumeBar;
  TLabel *Label2;
  TEdit *LoopEdit;
  TLabel *Label4;
  void __fastcall OpenBtnClick
	(TObject *Sender);
  void __fastcall PlayBtnClick
	(TObject *Sender);
  void __fastcall FormCreate
	(TObject *Sender);
  void __fastcall FormDestroy
	(TObject *Sender);
  void __fastcall ResourcesChange
	(TObject *Sender);
  void __fastcall VolumeBarChange
	(TObject *Sender);
private:  // User declarations
  char* WaveData;
  WAVEFORMATEX WaveFmt;
  HWAVEOUT WaveHandle;
  int DataSize;
  WAVEHDR WaveHeader;
  void CheckWaveError(DWORD code);
  void CheckMMIOError(DWORD code);
  void OnWaveDone(TMessage& msg);
public:    // User declarations
__fastcall TForm1(TComponent* Owner);
  BEGIN_MESSAGE_MAP
    MESSAGE_HANDLER(
      MM_WOM_DONE, TMessage, 
	OnWaveDone)
  END_MESSAGE_MAP(TForm)
};
//---------------------------------------------
extern PACKAGE TForm1 *Form1;
//---------------------------------------------
#endif
Listing B: PlayWavU.CPP
//---------------------------------------------
#include <vcl.h>
#pragma hdrstop

#include "PlayWavU.h"
//---------------------------------------------
#pragma package(smart_init)
#pragma resource "*.dfm"
TForm1 *Form1;
//---------------------------------------------
__fastcall TForm1::TForm1 (TComponent* Owner) : TForm(Owner)
{
}
//---------------------------------------------
void __fastcall TForm1::FormCreate(TObject *Sender)
{
  WaveData = 0;
  // Set the volume bar position to the
  // current volume setting.
  DWORD volume;
  waveOutGetVolume(0, &volume);
  VolumeBar->Position = 0xFFFF - LOWORD(volume);
}
//---------------------------------------------
void __fastcall TForm1::FormDestroy(TObject *Sender)
{
  // Be sure to delete the buffer.
  if (WaveData)
    delete[] WaveData;
}
//---------------------------------------------
void __fastcall TForm1::OpenBtnClick(TObject *Sender)
{
  // Show the Open Dialog.
  if (!OpenDialog1->Execute()) return;

  // Declare the structures we'll need.
  MMCKINFO ChunkInfo;
  MMCKINFO FormatChunkInfo;
  MMCKINFO DataChunkInfo;

  // Zero out the ChunkInfo structure.
  memset(&ChunkInfo, 0, sizeof
	(MMCKINFO));

  // Open the file.
  HMMIO handle = mmioOpen(
  OpenDialog1->FileName.c_str(), 0,
	  MMIO_READ);
  if (!handle) {
    MessageBox(0, 
     "Error opening file.", 
	"Error Message", 0);
    return;
  }

  // Find the RIFF chunk.
  DWORD Res = mmioDescend(handle, &ChunkInfo, 0, MMIO_FINDRIFF);
  CheckMMIOError(Res);

  // Descend into the format chunk.
  FormatChunkInfo.ckid = mmioStringToFOURCC("fmt", 0);
  Res = mmioDescend(handle, &FormatChunkInfo, &ChunkInfo, MMIO_FINDCHUNK);
  CheckMMIOError(Res);

  // Read the wave format.
  memset(&WaveFmt, 0, sizeof(WAVEFORMATEX));
  // mmioRead and mmioWrite return the number of
  // bytes read or written so don't call the
  // CheckMMIOError fucntion for those.
  mmioRead(handle, (char*)&WaveFmt, FormatChunkInfo.cksize);

  // Ascend out of the format chunk.
  Res = mmioAscend(handle, &FormatChunkInfo, 0);
  CheckMMIOError(Res);

  // Descend into the data chunk.
  DataChunkInfo.ckid = mmioStringToFOURCC("data", 0);
  Res = mmioDescend(handle, &DataChunkInfo, &ChunkInfo,	MMIO_FINDCHUNK);
  CheckMMIOError(Res);

  // Read the data into a buffer.
  DataSize = DataChunkInfo.cksize;
  if (WaveData)
    delete[] WaveData;
  WaveData = new char[DataSize];
  mmioRead(handle, WaveData, DataSize);

  // Close the file
  mmioClose(handle, 0);
  Memo1->Lines->Add(String
	(DataSize) +
    " bytes of wave data loaded 
	from file.");
  PlayBtn->Enabled = true;
}
//---------------------------------------------
void TForm1::CheckMMIOError (DWORD code)
{
  // Report an mmio error, if 
	one occurred.
  if (code == 0) return;
  char buff[256];
  wsprintf(buff, 
    "MMIO Error. Error Code: %d", code);
  Application->
    MessageBox(buff, "MMIO Error", 0);
}
//---------------------------------------------
void __fastcall TForm1::PlayBtnClick(TObject *Sender)
{
  // Query the device and see if it can play
  // this wave format. If so, open the device.
  int Res = waveOutOpen(&WaveHandle, WAVE_MAPPER, &WaveFmt,  0, 0, WAVE_FORMAT_QUERY);
  CheckWaveError(Res);
  if (Res) return;
  Res = waveOutOpen(&WaveHandle, WAVE_MAPPER, &WaveFmt, 
    MAKELONG(Handle, 0), 0, CALLBACK_WINDOW);
  CheckWaveError(Res);

  // Set up the wave header.
  memset(&WaveHeader, 0, sizeof(WaveHeader));
  WaveHeader.lpData = WaveData;
  WaveHeader.dwBufferLength = DataSize;
  // If the LoopEdit contains a value greater
  // than 1 then set the loop count and flags.
  int loops = LoopEdit->Text.ToIntDef(0);
  if (loops > 1) {
    WaveHeader.dwLoops = loops;
    WaveHeader.dwFlags = 
      WHDR_BEGINLOOP | WHDR_ENDLOOP;
  }

  // Prepare the wave header.
  Res = waveOutPrepareHeader(WaveHandle, &WaveHeader, sizeof(WAVEHDR));
  CheckWaveError(Res);

  // Start playback.
  Res = waveOutWrite(WaveHandle, &WaveHeader, sizeof(WAVEHDR));
  CheckWaveError(Res);
  Memo1->Lines->Add("Playback started...");
}
//---------------------------------------------
void TForm1::CheckWaveError (DWORD code)
{
  // Report a wave out error, if one occurred.
  char buff[256];
  if (code == 0) return;
  waveOutGetErrorText(code, buff, sizeof(buff));
  MessageBox(Handle, buff, "Wave Error", MB_OK);
}
//---------------------------------------------
void TForm1::OnWaveDone (TMessage& msg)
{
  // We only care about the WOM_DONE message.
  // When we get this message we know that the
  // sound has finished playing. We can then
  // unprepare the header and close the device.

  if (msg.Msg == WOM_DONE) {
    Memo1->Lines->Add("Playback finished.");
    int Res = waveOutUnprepareHeader(WaveHandle, &WaveHeader, sizeof(WAVEHDR));
    CheckWaveError(Res);
    Res = waveOutClose(WaveHandle);
    CheckWaveError(Res);
  }
}
//---------------------------------------------
void __fastcall TForm1::ResourcesChange	(TObject *Sender)
{
  // Load a wave resource into a
	 TResourceStream
  TResourceStream* res = new TResourceStream((int)HInstance, 
    Resources->Items->Strings[Resources->ItemIndex], "Wave");

  // Locate the fmt chunk.
  res->Position = 0;
  int x, i;
  int ckid = mmioStringToFOURCC("fmt ", 0);
  for (i=0;i<100;i++) {
    res->Read(&x, 4);
    if (x == ckid) break;
    res->Position -= 3;
  }

  // Read the size of the format data.
  int size;
  res->Read(&size, 4);

  // Read the wave format.
  memset(&WaveFmt, 0, sizeof(WAVEFORMATEX));
  res->Read(&WaveFmt, size);

  // Locate the data chunk and get the size.
  ckid = mmioStringToFOURCC("data", 0);
  for (int i=0;i<100;i++) {
    res->Read(&x, 4);
    if (x == ckid) break;
    res->Position -= 3;
  }
  res->Read(&size, 4);

  // Allocate the buffer. Free first if needed.
  if (WaveData)
    delete[] WaveData;
  DataSize = size;
  WaveData = new char[DataSize];

  // Read the wave data into the buffer.
  res->Read(WaveData, DataSize);
  delete res;

  PlayBtn->Enabled = true;
  Memo1->Lines->Add(String(DataSize) + 
   " bytes of wave data loaded from 
	resource.");
}
//---------------------------------------------
void __fastcall TForm1::VolumeBarChange	(TObject *Sender)
{
  // Set the volume to the current 
  // track bar position.
  int value = 0xFFFF - VolumeBar->Position;
  waveOutSetVolume(0, MAKELONG(value, value));
}
//---------------------------------------------

Kent Reisdorph is a editor of the C++Builder Developer's Journal as well as director of systems and services at TurboPower Software Company, and a member of TeamB, Borland's volunteer online support group. He's the author of Teach Yourself C++Builder in 21 Days and Teach Yourself C++Builder in 14 Days. You can contact Kent at editor@bridgespublishing.com.