HomePage Delphi Library
TAPI / Wave API / Delphi 4, 5
By Robert Keith Elias and Alan C. Moore, Ph.D.
Extending TAPI
Playing and Recording Sounds during Telephony Calls
In this article, we'll demonstrate how to build a program in Delphi 4 using TAPI and the Multimedia API to play and record telephone messages to and from files. We'll also show how to capture digital-tone key presses after a call has been placed or received.
As a foundation, the example program we'll develop here uses the work that Major Ken Kyler and Alan Moore developed in the series of articles last summer. We'll begin by briefly reviewing essential TAPI concepts. We'll then provide an overview of the TAPI and the Multimedia API functions that we'll use to implement this new functionality.
After that review, we'll provide a detailed explanation of the code needed to play .WAV files to, and record them from, a phone line, and capture digital-tone key presses. We'll conclude by providing important information you need to know: additional software you'll need to use this functionality, some of the limitations and failings of TAPI, and where you can find more information.
TAPI Basics
As in the previous series of articles, we'll make extensive use of the TAPI.pas conversion produced by Project JEDI (http://www.delphi-jedi.org). We'll also use the Multimedia APIs (mainly the Wave API) included in mmsystem.pas. One could easily be intimidated by either of these large collections of structures and functions. Fortunately, we can ignore most of TAPI's 125 functions, and most of those in mmsystem.pas, and still accomplish quite a lot.
However, we do need to know what we're doing. First we need to understand the difference between lines, phones, calls, addresses, and IDs.
Lines. A line device generally refers to a modem or a similar piece of hardware connected to a telephone line. TAPI doesn't communicate directly with a line/modem. Instead it uses a TSP (Telephone Service Provider). A TSP is just a fancy name for a driver, written by the modem/equipment supplier, to communicate with TAPI.
Many TAPI functions are designed to talk to TSPs that communicate with sophisticated telephone equipment. Such TSPs are needed for large offices where, for example, dozens of calls may arrive at nearly the same time on a single line. People who work with such systems must be able to manage call conferencing and call transferring, among other tasks. Unfortunately, the TAPI documentation rarely states the context within which a function is intended to operate. As we'll discuss later, TAPI has certain limitations along with the functionality we'll be using here.
Phones. Another TAPI device is the phone. You might assume that "phone" is synonymous with "handset." That would be a mistake. In TAPI, the phone is the speaker and microphone attached to your computer. You use phone devices to redirect caller output to the speaker and to redirect input from the user (via the microphone) to the telephone line. If you're not interested in using the speakers, you can ignore those functions that take the form phoneXxxxx.
Calls. The key event in the TAPI universe is the call. It begins at the precise moment when Windows sends a message to your application telling you it has picked up the line. This is done through a callback routine. Callback routines are used throughout the Windows API to provide a means for Windows to send information back to a calling application. Our application uses three callback routines: one for playing sounds, one for recording sounds, and the one used by TAPI.
Most calls are initiated with the lineAnswer or lineMakeCall function. These and similar functions are asynchronous. This means that when the function is called successfully, it immediately returns with a positive number (1, 2, etc.). However, the function isn't really complete until a confirming message is sent back via the callback routine with the same positive number in the dwParam1 argument, and zero in the dwParam2 argument.
Addresses and IDs. An address consists of a string of characters (letters, digits, and control characters) that provide a path to a phone or other device. That other device could be a modem or a computer. While addresses are often just phone numbers, they can also provide a path to a network or Internet address.
IDs are simply handles to devices. In the case of TAPI, we're usually most interested in the handle to the logical line we get by calling the lineGetID function. As complex as TAPI can be, at least we don't have to work directly with the COM port.
The TAPI and Wave API Link
Before we investigate the multimedia API, we need to investigate the link between TAPI and the Multimedia API, particularly the Wave API. That link is tenuous. Despite its complexity, no existing version of TAPI includes routines for playing or recording .WAV or other sound files. The next Windows NT incarnation, Windows 2000, may include further sound playing and recording functionality within TAPI. At least we've heard such rumors. Now, however, TAPI simply provides a handle called a device ID toward which an application can direct the input and output of .WAV files using Wave API functions. We use TAPI's lineGetID function to get this device ID.
The Wave API
The Wave API is a sub-API consisting mainly of functions and related structures that begin with the prefix "Wave." To work with this API, we need to understand the structure of .WAV files.
Most sounds played in the Windows environment are produced by .WAV files of which there are various types. For most of this discussion, we'll consider only simple uncompressed PCM (Pulse Code Modulation) .WAV files. There are at least 30 compressed formats, which we'll briefly discuss later. A simplified format of a complete PCM .WAV file is as shown in Figure 1. .WAV files are actually a subcategory of RIFF (Resource Interchange File Format) files. In Figure 1 you'll notice that the first four characters are RIFF, which identifies them as a RIFF file.
Segment
Explanation
RIFFxxxx
RIFF identifier; xxxx is the size of the file minus 8 bytes.
Wavefmtxxxx
Wave identifier; fmt subchunk beginning; size of format section (about 50 bytes).
.....
The format section (about 50 bytes long).
.....
Optional extended format information.
factxxxx
xxxx usually equals 4 bytes (number of bytes of fact data).
....
Total number of samples in the file.
dataxxxx
Size of the Wave audio data.
.....
The Wave audio data (most of the file).
.....
Optional ID garbage at the end of the file.
Figure 1: The structure of a Wave RIFF file.
All RIFF files consist of chunks - specially structured groups of data that often contain subchunks. The first chunk in a RIFF file is called the RIFF chunk. In the case of Waveform audio RIFF files, the "RIFF" chunk contains two subchunks: the fmt chunk, which provides information about a Wave form's structure, and the data chunk, which contains the audio data itself. In Figure 1, xxxx represents DWORD/LongWord size values, and the four periods represent data in the files. Identifiers, such as RIFF and Wave appear in the file exactly as shown.
The fact chunk is optional in a simple PCM .WAV file. As shown, every .WAV file of this type begins with a header section that is typically about 100 bytes long. The chunks used in the specific .WAV file type are RIFF, fmt, fact, and data. Other possible chunk types in multimedia files include cue and playlist. Except for RIFF and data, these chunks can be in any order.
Why do we need to know this? If we want to record or play a .WAV file using the low level multimedia input/output functions, we'll need to correctly write or read the elements of the header file. The xxxx parts simply indicate the size of their respective chunks in numbers of bytes, making it possible to write or read the precise number of bytes in our audio data. Once we've correctly filled in the header chunks, we just feed them to the multimedia input/output functions, and the data is automatically written to, or read from, memory. We'll explore the details when we describe the code.
Let's take a closer look at some of the chunks, particularly the fmt and fact chunks. The fact chunk can be calculated from the fmt chunk, so we'll deal with that first. Figure 2 shows the name, type, and use of each field in the fmt chunk.
Element Name
Data Type
Represents
wFormatTag
Word
The format of data in a .WAV file.
nChannels
Word
The number of channels (i.e. mono [1], stereo [2], etc.).
nSamplesPerSec
DWord
The number of samples written (or read) per second.
nAvgBytesPerSec
DWord
The number of bytes written (or read) per second.
nBlockAlign
Word
The minimum size (in bytes) of a readable data block.
wBitsPerSample
Word
The number of bits per sample of mono data.
cbSize
Word
The size in bytes of extended compression info.
Figure 2: Elements of the fmt chunk.
If you've worked with electronic sound, you've probably encountered the word "sample." A sample contains an elemental unit of sound. The sample size is given in wBitsPerSample. For uncompressed (PCM) data the size is usually 8, 16, 24, or 32 bits wide. The wider the size, the higher quality the sound. Because we're using a telephone line, we can use low-quality resolution. Therefore, we'll work with either 8 or 16 bits in wBitsPerSample. When using a compression algorithm, this value is usually smaller.
The sample rate, which indicates how many samples are fed to the speaker each second, is given in nSamplesPerSec. This is typically described in Hertz; thus 8,000 Hz is 8,000 nSamplesPerSecond. Common audio sampling rates are 8,000, 11,025, 22,050, and 44,100 Hz. Again, because analog telephone lines function at 3,100 Hz, nothing above 8,000 Hz will produce noticeably improved performance.
Once we know wBitsPerSample and nSamplesPerSec, nAvgBytesPerSec and nBlockAlign are easy to calculate (at least for a simple PCM file):
nAvgBytesPerSec = nSamplesPerSec * wBitsPerSample/8
nBlockAlign = nChannels * wBitsPerSample/8
The only meaningful piece of information in the fact chunk is dwFileSize, a count of the samples in the .WAV file. It's easy to calculate from the above information:
dwFileSize = (Total play time in seconds) * nSamplesPerSec
Figure 3 shows actual numbers for two identical files of 10.286 seconds each using different Wave formats: the first an uncompressed PCM file, and the second a compressed IMA ADPCM file. The PCM file is 164,696 bytes, and the ADPCM file is 41,788 bytes. (The same file compressed using DSP Group TrueSpeech is 11,066 bytes.)
Field
PCM file
IMA ADPCM file
fmt
18
20
wFormatTag
Wave_FORMAT_PCM (1)
Wave_FORMAT_IMA_ADPCM (17)
nChannels
1
1
nSamplesPerSec
8000
8000
nAvgBytesPerSec
16000
4055
nBlockAlign
2
256
wBitsPerSample
16
4
cbSize
0
2
fact
4
4
dwFileSize
82294
82294
Actual file size
164,696 bytes
41,788 bytes
Figure 3: Comparison of PCM and IMA ADPCM files.
The first thing you'll notice is that nAvgBytesPerSec and nBlockAlign can't be calculated from the formulas we've given. In fact, we were unable to determine how to calculate these values from the published documentation.
It gets even more interesting. According to the documentation that comes with Unimodem/V (the Microsoft TSP for voice modems), the supported formats are: IMA ADPCM at 4,800 Hz, 7,200 Hz, or 8,000 Hz; and Rockwell ADPCM. However, this was not confirmed by our experience. Even though the IMA ADPCM is explicitly said to be supported, we were able to record (input from the modem) only in the uncompressed PCM format and only at 8,000 Hz with wBitsPerSample set to 16.
We should point out that PCM is very costly in memory and disk usage. In principle it's possible to convert PCM files to one of the compressed formats; however, it would first be necessary to convert the msacm.h header file into a Pascal format (another task for Project JEDI, or ambitious developer).
On the other hand, we were able to play (output to the modem) any .WAV file for which we had an installed CODEC, provided that nSamplesPerSec was 8 kHz. For example, we were able to play the Female Operator.wav file that comes with some versions of Windows 95. This file is encoded at 8,000 Hz using the TrueSpeech compression format. IMA ADPCM and TrueSpeech are two of the five audio compression formats that come with Windows 95. To see the codecs currently installed on your system, look in the Windows registry at:
HKEY_LOCAL_MACHINE\System\CurrentControlSet\Control\MediaResources\acm
or in Control Panel via multimedia/Advanced/Audio Compression Codecs.
Writing the Code
Having discussed the basic issues, we're almost ready to delve into the code. To test the code, you'll need a modem that can handle voice as well as data (most of the newer ones do), Unimodem/V, and TAPI 1.4 or TAPI2.x at a minimum. Note that Unimodem/V comes with Windows 98. You can perform the following test to determine whether Unimodem/V has been successfully installed on your machine. Look under Multimedia in Control Panel and ensure you see the following two lines:
Voice Modem Wave #00 Line
Voice Modem Wave #00 Handset
You may also want to study Microsoft's TAPI documentation, and the WaveForm documentation. As we mentioned earlier, the TAPI code for our application is based on code first published in Delphi Informant in the Summer of 1998. We took the liberty of making certain changes to that code, and added quite a bit of new code.
First we changed the variable names to match those in the Microsoft API documentation, which makes the code easier to follow. We appended a record instance prefix - rTAPI - to these names to make it easy to distinguish these variables from those used for playing sounds. The latter have a prefix of either rWvR (record Wave) and rWvP (play Wave). See Glbvars.pas for the full declaration of these structures (available for download; see end of article for details). We've also included many comments in the code.
We've included routines for dialing out and answering calls. Being able to record conversations after dialing out may seem useless. However, it eliminates the need for someone to call you to test the play and record functions if you only have one telephone line. Because the modem cannot distinguish between digit tones generated by the local handset and a remote handset, you could implement code to play or record messages by pressing the keypad on either a remote or the local telephone. However, the present version doesn't include this functionality. Now let's investigate the code for playing a sound.
Using Wave API to Play a Sound
The waveOutWrite function plays Wave data to the phone line through the modem. However, the task is hardly trivial. Of course we could use the PlaySound or sndPlaySound functions to play .WAV files, but they don't work in this context because they always play to the speaker by default. The waveOutWrite function provides the needed format translation and redirection capabilities we need. Thus, the modem will receive the sound data.
Nine steps are needed to play a .WAV file to a modem line. We have numbered these steps in the code in the playWav.pas file, shown in Listing One:
Get the COM port handle where output will be sent.
Open the .WAV file.
Locate the format information in the .WAV file's header and copy that information into a WAVEFORMATEX structure.
Prepare and lock a memory buffer for the Wave data.
Copy the Wave data into a memory buffer.
Prepare a WAVEHDR structure to describe the memory buffer in which the Wave data is held.
Load the Wave conversion drivers and prepare a callback routine.
Play the .WAV file.
Clean up everything.
Let's discuss the details. The first step, getting the COM port handle, will be familiar to readers who followed the earlier series of articles. For this step we use the TAPI function, lineGetID. If you want to send a message to the line, use this function with a format like:
lineGetID(,,,,'wave/out');
If you want to record from the line you use:
lineGetID(,,,,'wave/in');
The Wave API will use this handle (a number like 0, 1, 2, etc.) to find the modem port to which Wave data will be sent. This information is sent in a predefined TAPI structure called a VARSTRING via lineGetID (see Figure 4).
lineGetID( // Get a physical device ID.
rTapi.hLine, // Handle to line from lineOpen.
0, // Subaddress of rTapi.hLine.
// From lineMakeCall (rTapi.hCall replaced by 0 here).
myZeroHC,
// Use information from rTapi.hLine (not rTapi.hCall).
LINECALLSELECT_LINE,
// Pointer to VARSTRING structure where information
// is returned.
lpVarString(Port),
// "device class" (COM, Wave/out etc).
szDeviceClass);
Figure 4: Finding a modem port to send Wave data.
In the second step, open the .WAV file. This time we'll use the mmioOpen function, one of the multimedia input/output functions. This special purpose function is especially useful for accessing multimedia data. With the file open, we need to locate the format information in the header. For this we'll use the mmioDescend function twice, followed by GetMem. The latter allocates memory for the rWvP.lpWaveFormat field based on the size of the chunk.
Then we deal with the data in the WAVE subchunk. We collect the value in the DWORD xxxx, which immediately follows the WAVEfmt marker. This gives us the size of the format section, which begins right after this xxxx. We place an implicit pointer here for the mmioRead operation in the next step. In that step we copy the format information into a WAVEFORMATex structure. We use the mmioRead function to place the .WAV file format information into a predefined WAVEFORMATex structure pointed to by our variable, lpWaveFormat. The WAVEFORMATex structure is identical to the fmt structure just given. Now we can access the bits per sample as lpWaveFormat^.wBitsPerSample.
You might be wondering if we could have just copied this information straight from the header using regular Delphi streaming methods (or block read and block write), rather than using the multimedia input/output functions described here. Of course we could, but that would involve writing even more code. These multimedia functions are designed for working with this kind of data and save us some additional work.
Next we prepare and lock a memory buffer for the Wave data. For this operation we again use mmioDescend and GetMem. This time we collect the value in the DWORD xxxx, which immediately follows the data marker. This is the size of the data section, which constitutes most of the file. This data begins right after the xxxx, where again an implicit pointer is placed for the mmioRead operation that we undertake in the next step.
Now we copy the Wave data into a memory buffer. We use mmioRead to place the .WAV file data information into a memory buffer pointed to by our variable, lpWaveData, which was returned by GetMem. Because .WAV files for telephones are usually quite small, this may be practical; however, for most operations it's common practice to use at least two buffers (double buffering) so that waveOutWrite can play the contents of one buffer while data is being loaded into the other.
At this point we can close the .WAV file because everything we need is already in memory. We prepare a WAVEHDR structure that describes the memory buffer where Wave data is held. For this we first use GetMem to provide a block of memory for the WAVEHDR structure and a pointer to it that we'll name lpWaveHdr. A WAVEHDR structure has the format shown in Figure 5.
type
PWaveHdr = ^TWaveHdr;
{$EXTERNALSYM wavehdr_tag}
wavehdr_tag = record
lpData: PChar; // Pointer to locked data buffer.
dwBufferLength: DWORD; // Length of data buffer.
dwBytesRecorded: DWORD; // Used for input only.
dwUser: DWORD; // For client's use.
dwFlags: DWORD; // Assorted flags (see defines).
dwLoops: DWORD; // Loop control counter.
lpNext: PWaveHdr; // Reserved for driver.
reserved: DWORD; // Reserved for driver.
end;
Figure 5: WAVEHDR structure (record) as defined in mmsystem.pas.
We copy our pointer to the Wave data, lpWaveData, into our structure at lpWaveHdr^.lpData, and the buffer length into lpWaveHdr^.dwBufferLength. We use the information in this structure in the waveOutPrepareHeader and waveOutWrite functions. Conveniently, waveOutWrite also updates this structure as it plays the .WAV file.
In the next step we need to load the Wave conversion drivers and prepare the callback routine. The waveOutOpen function performs these two steps. Most voice modems come with drivers to convert .WAV files to voice signals. A few can even take the Wave output directly without special drivers.
To check for this functionality, waveOutOpen is usually called once for a test with the WAVE_FORMAT_QUERY flag set, and with the WAVE_MAPPED flag not set. If waveOutOpen returns an error, it's called again with WAVE_MAPPED set. Otherwise WAVE_MAPPED isn't set. If WAVE_MAPPED is set, more resources are used and the operation will be slower. The waveOutOpen function also defines the address of a callback routine. Windows sends messages to this callback routine when playing has started, stopped, or paused. There are three possible messages the Wave API can send to this callback routine:
WOM_OPEN is sent when waveOutOpen is called.
WOM_DONE is sent when waveOutWrite is finished playing data, or waveOutReset is called.
WOM_CLOSE is sent when waveOutClose has completed a close.
Finally we're ready to play the .WAV file. To play a block of Wave data, we first call waveOutPrepareHeader. If we're swapping blocks to save memory (double buffering), we must call this function for each block before we call waveOutWrite. We use the waveOutWrite function to play the .WAV file to the phone line.
Finally, we need to clean things up. After our application receives the WOM_DONE message, our code calls the various cleanup functions, freeing resources. If an error has occurred, the file is closed, waveOutUnprepareHeader is called, and allocated memory resources are freed depending on the flags set when the finally clause is reached in the try..finally block.
Recording Sounds with the Wave API
As with the process of playing sound, recording sound also involves a number of steps. (Due to space constraints, the file discussed here, recordWv.pas, is not listed in this article. However, it is available for download; see end of article for details.) Again, to make the process easy to follow, we've included abundant comments in the code and have numbered the steps:
Get the COM port handle where modem input will be received.
Use waveInOpen to determine if the Wave API supports the Wave format on the selected port.
Use waveInOpen to open the line device for recording, and inform the Wave API that we want all messages sent to the main winproc callback routine.
Allocate the memory buffer in which the Wave data will be stored.
Use waveInPrepareHeader to tell the Wave API the address of the WAVEHDR structure; we can use this structure to exchange messages with the Wave API. We also put the address of the memory allocated for the Wave data in this structure.
Use waveInAddBuffer to prepare everything for recording.
Call waveInStart to begin recording from the line.
Wait for a WIM_DATA callback message to inform us that playing has completed or has been stopped; on error, clean up everything.
Save the data to a file: create the file where the Wave data will be saved; use the information from the WAVEHDR structure to update the Wave header file, then write the header to the file; and write the Wave data to the file.
Clean up everything (step 8 again, but not because of error).
As before, we begin by getting a handle to the COM port. This step is identical to the one we used for playing, except now lineGetID receives a wave/in message instead of a wave/out message.
Next we need to determine if the Wave format and port are ok. You'll recall that when we were playing a .WAV file, we began by opening it and examining the contents of the tWAVEFORMATEX header structure. Once we have that information we can then use:
waveOutOpen(,,,,WAVE_FORMAT_QUERY)
to determine if the format was ok.
Here we have predefined the tWAVEFORMATEX structure in a record named cnrRecordedMsgFormat. This time we can use waveInOpen to find out immediately if there is a problem with our proposed data format. As you may recall from the general introduction, a tWAVEFORMATEX structure includes nSamplesPerSec set to 8,000 Hz and wBitsPerSample set to 16 bits for a standard uncompressed PCM Wave format.
Now we're ready to open the line and establish our callback routine. If waveInOpen returns successfully in step 2, we need to call it again to do some real work. First we need a handle to the Wave API, which we'll use for subsequent calls to other Wave API functions. We'll store this handle in rWvR.hWaveRDevice. We'll also provide a pointer to our tWAVEFORMATEX structure for other purposes.
We also supply the CALLBACK_WINDOW flag to tell the Wave API that we want all messages sent to the program's main callback routine, located in the DefaultHandler procedure at the beginning of the main unit. The three possible messages that the Wave API can send to this callback routine are:
WIM_OPEN sent when waveInOpen is called;
WIM_DATA sent when the buffer is full or waveInReset is called; or
WIM_CLOSE sent when waveInClose is called to close the operation.
The one that is most useful for us is WIM_DATA. When the DefaultHandler receives a WIM_DATA message, we call routines to save the .WAV file and perform the required clean up.
It's worth mentioning that during early versions of this program, we attempted to use a specialized callback routine (like the one used for the TAPI functions) by specifying the CALLBACK_FUNCTION flag. However, the program tends to hang if any but a limited number of functions, such as PostMessage, are called from within this function, so we decided to use the main callback routine to simplify things.
In the next step, we allocate memory for the Wave data. Having verified that the modem port and the Wave format are okay, we now allocate 60 seconds of memory using the following formula:
cn60SecMemSize:
DWORD = DWORD(60) // 60 seconds msg storage time.
* DWORD(8000) // nSamplesPerSec (8kHz).
* DWORD(16) // wBitsPerSample.
div DWORD(8); // bits in word.
This adds up to 960,000 bytes, hardly an insignificant amount. If memory is an issue, it's possible to use the technique of double buffering, switching back and forth between two much smaller blocks of memory. While the Waveform audio device is processing one memory buffer, the application can process the other.
Next we need to setup the message-exchanging structure with the Wave API. If we were switching back and forth between blocks of memory, we would need a WAVEHDR structure for each buffer and use it for much of the communication between the Wave API and our application (see Figure 6).
cnrRWaveHdr: WAVEHDR = ( // Wave header for data/file.
lpData : nil; // Pointer to locked data buffer.
dwBufferLength : 0; // Length of data buffer.
dwBytesRecorded : 0; // # of bytes recorded.
dwUser : 0; // Put anything you want in here.
dwFlags : 0; // Flags describing buffer state.
dwLoops : 0; // Loop control counter.
lpNext : nil; // Reserved for driver.
reserved : 0); // Reserved for driver.
Figure 6: A WAVEHDR structure for each buffer to use it for much of the communication between the Wave API and our application.
For our simplified application, we'll be putting just the address of the memory location into which the Wave data will be written. We put the address into lpData, and the total size of the available memory in dwBufferLength. After we're finished recording, we'll retrieve the amount of data actually recorded from dwBytesRecorded. This is necessary because recording can end early under a variety of circumstances. The function, waveInPrepareHeader, takes care of informing the Wave API about our WAVEHDR structure after we've put data into it.
Recording
Before recording, we must make final preparations. Once the waveInPrepareHeader function has informed the Wave API about the WAVEHDR structure, the waveInAddBuffer function must actually set it up in preparation for playing. If we're using double buffering, we must prepare separate WAVEHDR structures for each memory buffer and set up each with waveInPrepareHeader. When a particular buffer has been filled, its associated dwFlags is set to WHDR_DONE, so that the buffer can be saved and then set up again.
The waveInPrepareHeader and waveInAddBuffer functions are always used together; the former to prepare the data buffer headers, and the latter to actually place the data buffer on the input queue to be recorded. For large files these data blocks can be re-used once the application has recorded the material in them. However, a detailed discussion of this process is beyond the scope of this article.
Finally we are ready to begin recording from the line. The waveInStart function begins recording sound data coming from the phone line to a memory buffer. At this point we must wait for a WIM_DATA completion message. Once the memory buffer is full, a WIM_DATA message is sent to the application's main callback routine, the DefaultHandler. In our case, we use this message as a trigger to call the ShutDownRecording function. If there are no errors, the first thing it does is call the SaveMessageTooFile routine.
Now we're ready to save the data to a file, a process of three steps. First we create a file with the name msg#.wav. If we record more than one message while the application is open, the first one is named msg1.wav and the second msg2.wav. The resulting Waves can easily be played simply by creating shortcuts, and then clicking on them. If you rename one or the other to Greeting.wav, it can also be played back over the telephone line.
Next we update our custom Wave header record named cnrRiffWaveHeader (the one with the RIFF, fmt, fact, and data chucks), filling it with information returned to us from the WAVEHDR structure in dwBytesRecorded. This quantity is essential to calculating the correct values for the Wave header.
Once these values have been calculated, the header is saved to the open file. Finally we write the Wave data to the file. Here again, dwBytesRecorded comes in handy. We close the file and proceed to clean everything up. The primary cleanup steps (apart from freeing memory) consist of executing two functions, waveInUnprepareHeader and waveInClose. You need to call waveInUnprepareHeader before you free memory for its associated buffer. The waveInClose function simply closes the Waveform input device and marks all pending buffers as done.
TAPI Problems and Limitations
As convenient and helpful as TAPI can be, it does have its problems and limitations. As already mentioned, TAPI uses TSPs (drivers) to communicate with modems. The most generally annoying characteristic of TAPI is that on inexpensive standard modems (anything under US$300), a call's progress cannot be monitored. Specifically, you can't tell when someone has answered an outgoing call, or when the line is hung up on the other end. It may be possible to do this by recording line activity with Wave functions and examining the noise level. However, we don't think that would be a trivial task.
Another problem that's commonly reported is that digit detection seems to be disabled after a .WAV file finishes playing on certain modems. The program we have created may not have this problem because we reinitialize TAPI every time a call is completed (instead of reusing the line handles and TAPI instances). While this might eliminate certain problems, we consider it extremely inelegant. Also, it might cause problems if we're working with multiple lines or multiple applications sharing the same line.
A further limitation is that with standard modems, you can't collect information from the line before a call is open. Therefore, there is no obvious way of detecting that someone is dialing out on a line connected to the modem unless a user opens a call first from within the application. There is also no method for knowing if an incoming call is a voice, fax, or data call before the call is picked up. In fact, even determining this after the call has been picked up presents an interesting challenge. Finally, because Unimodem/V isn't supported on Windows NT below version 5.0 (Windows 2000), there is no support for voice in that environment.
Conclusion
We are at the end of a rather long journey that has enabled us to add sound playing and recording capabilities to our TAPI applications. Along the way we have learned more about the various Windows APIs and how they work together. As far as exploring the Wave API, we have only just scratched the surface - just enough to get the job done. In an upcoming article, Dr Moore will explore this API in more detail, and will explain other facets of it. He will also devote an upcoming "File | New" column to multimedia and communications resources.
We used a number of online sources while preparing this article. One such site contains much information on programming with sound; visit http://www.wotsit.org, or the Usenet newsgroup at news:comp.os.ms-windows.programmer.multimedia.
The files referenced in this article are available for download.
Robert Keith Elias is an independent developer, consultant and practicing Broccolist based in Quebec, Canada. He specializes in Windows programming with Borland Delphi and Web site development. He can be reached at mailto:[email protected].
Alan Moore is a Professor of Music at Kentucky State University, specializing in music composition and music theory. He has been developing education-related applications with the Borland languages for more than 10 years. He has published a number of articles in various technical journals. Using Delphi, he specializes in writing custom components and implementing multimedia capabilities in applications, particularly sound and music. You can reach Alan on the Internet at mailto:[email protected].
Begin Listing One - playWav.pas
// Telephony Program developed by Robert Keith Elias and
// Alan C. Moore for Delphi Informant article. Some TAPI
// code based on earlier article code by Major Kenneth
// Kyler and Alan C. Moore; TAPI.pas produced by Project
// JEDI.
// The code in this unit plays a wave file to a telephone
// line. It also contains functions used by the recordWv
// unit. mmreg.h contains important definitions.
unit playWav;
interface
uses
Tapi, Windows, Messages, SysUtils, Dialogs, mmsystem,
Forms;
function playASound : Boolean;
function ShutDownPlaying1 : Boolean;
function ShutDownPlaying2 : Boolean;
function MidPlayShutDown : Boolean;
function GetPortHandle(szDeviceClass:pChar): THandle;
function mmioFOURCC(A:Char; B:Char; C:Char; D:Char): DWORD;
implementation
uses
GetErr, TelSnd1, glbVars;
// KEY LINK between TAPI and the Wave API/MMAPI.
// This function returns the port handle, to or from which
// szDeviceClass data ('wave/in' etc) is received or sent.
// Must have access to handle to open line AFTER
// lineMakeCall()!!
function GetPortHandle(szDeviceClass: pChar): THandle;
type
LPPort = ^TPort;
TPort = record
VarString: TVarString; // VARSTRING Inside structure
hComm: THandle; // Device # placed here
szDeviceName: array [1..1] of Char;
end;
var
Port : LPPort;
PortSize, ErrNo : Longint;
S : string;
PS : PChar;
ActMsg : Word;
myZero : Int;
myZeroHC : HCALL;
begin
myZero := 0;
myZeroHC := LPHCALL(@myZero)^;
Result := High(THandle);
if not rTapi.bLineOpen3 then begin
Form1.Memo.Lines.Add(
'lineGetID error: LineOpen flag not True.');
Exit;
end;
PortSize := sizeof(TPort);
GetMem(Port, PortSize); // Allocate memory.
// Put size into structure.
Port^.VarString.dwTotalSize := PortSize;
try
repeat
// Get a physical device ID.
ErrNo := lineGetID(
rTapi.hLine, // Handle to line from lineOpen.
0, // Subaddress of rTapi.hLine.
myZeroHC, // From lineMakeCall.
// Use info from rTapi.hLine (not rTapi.hCall).
LINECALLSELECT_LINE,
// Pointer to struct where info returned.
lpVarString(Port),
// "Device class" (comm, wave/out etc.).
szDeviceClass);
if (ErrNo <> 0) then
begin
ActMsg := GetlineGetIDErr(ErrNo,PS); S := PS;
S := S+' Port# = '+IntToStr(Int64(Port^.hComm));
Form1.Memo.Lines.Add('lineGetID error: ' + S);
Exit;
end
else
if (Port^.VarString.dwNeededSize >
Port^.VarString.dwTotalSize) then
begin
PortSize := Port^.VarString.dwNeededSize;
FreeMem(Port);
GetMem(Port, PortSize);
Port^.VarString.dwTotalSize := PortSize;
ErrNo := -1;
end
else
// Port handle returned from here.
Result := Port^.hComm;
until ErrNo = 0;
finally
FreeMem(Port);
end;
end;
// The mmsystem.pas unit did not implement this function,
// so we wrote it ourselves. All it does is take four
// letters and stuff them backwards into a DWORD.
function mmioFOURCC(A:Char; B:Char; C:Char; D:Char): DWORD;
begin
Result := DWORD(A) or DWORD(B) shl 8 or
DWORD(C) shl 16 or DWORD(D) shl 24;
end;
// All global Multimedia variables are stored in a
// structure typed as TMMPlayWvStruct in the glbVars.pas
// unit, and instantiated as rWvP.
function playASound: Boolean;
var
S : string;
szWaveFile : array[0..254] of Char;
mmckinfoParent,
mmckinfoSubchunk : MMCKINFO; // The chunk structure.
dwFmtSize,
dwDataSize : DWORD;
myErrMsg : MMRESULT;
// Nested Functions and Procedures called from within
// PlayASound.
procedure SetDefaults;
begin
rWvP.bPlayWaveStarted := True;
// False when waveOutWrite succeeds.
rWvP.bNeedErrWaveShutdown := True;
rWvP.bWaveFileIOOpen := False;
rWvP.bWaveFormatMemAloc := False;
rWvP.bWaveDataMemAloc := False;
rWvP.bWaveHdrMemAloc := False;
rWvP.bwaveOutOpen := False;
rWvP.bWavePrepHeader := False;
Form1.RecordMsg.Enabled := False; // Buttons.
Form1.PlayMsg.Enabled := False;
Form1.StopPlaying.Enabled := True;
end;
// STEP 0
// Find the wave file in the current directory where the
// *.exe file is and copy it into szWaveFile.
procedure GetWaveFileInCurrentDir;
begin
S := '';
S := ExtractFilePath(Application.exename);
S := S + 'greeting.wav';
StrPCopy(szWaveFile,S);
end;
// STEP 1
// Get the port handle for the phone LINE device
// (0,1,2 etc.) stored in (rWvP.dwWaveOutID).
function GetThePortHandle: Boolean;
begin
Result := True;
rWvP.dwWaveOutID := GetPortHandle('wave/out');
if rWvP.dwWaveOutID = High(THandle) then begin
S := 'phonedevice dwWaveOutID# ERROR: ' +
IntToStr(Int64(rWvP.dwWaveOutID));
Form1.Memo.Lines.Add(S);
Result := False;
end;
end;
// STEP 2
// In step 0 we stored the wavefile name in szWaveFile.
// Here mmioOpen() opens the file for read with an
// 8K buffer.
function StoreWaveFileName: Boolean;
begin
Result := True;
rWvP.hndlMMio := mmioOpen(szWaveFile, nil,
MMIO_READ or MMIO_ALLOCBUF);
if (rWvP.hndlMMio = 0) then
begin
S := ' mmioOpen ERROR.';
Form1.Memo.Lines.Add('Open wave file msg: ' + S);
Result := False;
end
else
rWvP.bWaveFileIOOpen := True;
end;
// STEP 3a
// Move an implicit pointer to the data following the
// 'WAVEfmt' tag. mmckinfoParent and mmckinfoSubchunk are
// both instances of a predefined structure named
// MMCKINFO which has the following format:
// ckid: FOURCC; - chunk ID
// cksize: DWORD; - chunk size
// fccType: FOURCC; - form type or list type
// dwDataOffset: DWORD - offset of chunk's data portion
// dwFlags: DWORD; - flags used by MMIO functions
// mmioDescend() first moves an implicit pointer to end
// of 'WAVE' then to the end of 'fmt' filling
// mmckinfoSubchunk with data. All we're after is
// *.cksize which is right after 'fmt ' in the file.
// Prime candidate for conversion to a Tstream.
// Fill mmckinfoParent struct w/ info about the WAV file.
function FillmmCkInfoParentStruc: Boolean;
begin
Result := True;
mmckinfoParent.fccType := mmioFOURCC('W','A','V','E');
if (mmioDescend(rWvP.hndlMMio,
PMMCKINFO(@mmckinfoParent), nil,
MMIO_FINDRIFF)) <> 0 then begin
Form1.Memo.Lines.Add('mmioDescend into WAVE ERROR');
Result := False;
end
end;
// Fill mmckinfoSubchunk struc with info about the
// WAV header.
function FillmmCkInfoSubchunkStruc: Boolean;
begin
Result := True;
mmckinfoSubchunk.ckid := mmioFOURCC('f','m','t',' ');
if (mmioDescend(rWvP.hndlMMio, @mmckinfoSubchunk,
@mmckinfoParent, MMIO_FINDCHUNK)) <> 0 then begin
Form1.Memo.Lines.Add(' mmioDescend into ''fmt '' ERROR.');
Result := False;
end;
end;
// STEP 3b
// Now the amount of data between the 'fmt xxxx' and
// 'data' markers is in mmckinfoSubchunk.cksize so we can
// allocate memory for a WAVEFORMATEX structure and then
// use mmioRead() to fill it with all the info about the
// wave data, like sample rate etc. Allocate memory for
// the fmt data in wave header in a WAVEFORMATEX struct.
function GetfmtDataMem: Boolean;
begin
Result := True;
dwFmtSize := mmckinfoSubchunk.cksize;
GetMem(rWvP.lpWaveFormat, dwFmtSize);
if rWvP.lpWaveFormat = nil then
begin
Form1.Memo.Lines.Add('AllocMem ERROR.');
Result := False;
end
else
rWvP.bWaveFormatMemAloc := True;
end;
// Read the format data placed in rWvP.lpWaveFormat
// chunk.
function ReadFormatData: Boolean;
begin
Result := True;
if mmioRead(rWvP.hndlMMio, LPSTR(rWvP.lpWaveFormat),
dwFmtSize) <> LONG(dwFmtSize) then
begin
Form1.Memo.Lines.Add(
'mmioRead of rWvP.lpWaveFormat ERROR.');
Result := False;
end
else
begin
S := S + CRLF;
S := S + 'cbSize = ' +
IntToStr(Int64(rWvP.lpWaveFormat^.cbSize));
Form1.Memo.Lines.Add(S);
end;
end;
// STEP 4a
// Now as in 3a, we find the size of all the wave data
// which is located in the DWORD immediately after the
// 'data' tag and we allocate the necessary memory.
// Find the WAVE only data subchunk in the file.
function FindWAVEDataSubchunk: Boolean;
begin
Result := True;
mmckinfoSubchunk.ckid := mmioFOURCC('d','a','t','a');
if mmioDescend(rWvP.hndlMMio, @mmckinfoSubchunk,
@mmckinfoParent, MMIO_FINDCHUNK) <> 0 then begin
Form1.Memo.Lines.Add('mmioDescend into data ERROR.');
Result := False;
end;
end;
// Get the true size of the WAVE only data
// (DWORD after 'data').
function GetWAVEDataSize: Boolean;
begin
Result := True;
dwDataSize := mmckinfoSubchunk.cksize;
if dwDataSize = DWORD(0) then
begin
Form1.Memo.Lines.Add('dwDataSize ERROR.');
Result := False;
end;
end;
// Allocate (and LOCK) memory for the waveform data.
// If file to big for mem we probably have a problem.
function AllocateWaveDataMem: Boolean;
begin
Result := True;
GetMem(rWvP.lpWaveData, dwDataSize);
if rWvP.lpWaveData = nil then
begin
Form1.Memo.Lines.Add(
'GetMem for rWvP.lpWaveData ERROR.');
Result := False;
end
else
begin
rWvP.bWaveDataMemAloc := True;
S := 'AllocMem for rWvP.lpWaveData OK. ' +
'Points to : '+IntToStr(Int64(rWvP.lpWaveData));
Form1.Memo.Lines.Add(S);
end;
end;
// STEP 4b (Try using Tfilestream.read some fine day.)
// We use mmioRead to move all wave data into memory.
// rWvP.lpWaveData will be a pointer to that data.
// Read the waveform data into memory at rWvP.lpWaveData.
function ReadWaveformData: Boolean;
begin
Result := True;
if mmioRead(rWvP.hndlMMio, LPSTR(rWvP.lpWaveData),
dwDataSize) <> LONG(dwDataSize) then
begin
Form1.Memo.Lines.Add(
'mmioRead of rWvP.lpWaveData ERROR.');
Result := False;
end;
end;
// Data now in memory so close the file, rWvP.hndlMMio
// is the handle from step 2.
function CloseWaveFile: Boolean;
begin
Result := True;
rWvP.bWaveFileIOOpen := False;
if mmioClose(rWvP.hndlMMio, 0) <> 0 then
begin
Form1.Memo.Lines.Add('mmioClose ERROR.');
Result := False;
end;
end;
// STEP 5
// We use GetMem() to create the space for a WAVEHDR
// structure and then we fill it with the sizeof and a
// pointer to the wave data. Allocate mem for a WAVEHDR
// struc (waveform data header).
function GetWaveHdrMem: Boolean;
begin
Result := True;
GetMem(rWvP.lpWaveHdr, DWORD(sizeof(WAVEHDR)));
if rWvP.lpWaveHdr = nil then
begin
Form1.Memo.Lines.Add(
'AllocMem for rWvP.lpWaveHdr ERROR.');
Result := False;
end
else
begin
rWvP.bWaveHdrMemAloc := True;
S := 'AllocMem for lpWaveHdr OK. Points to : ' +
IntToStr(Int64(rWvP.lpWaveHdr));
Form1.Memo.Lines.Add(S);
end;
end;
procedure FillWaveHdrStruc;
begin
rWvP.lpWaveHdr^.lpData := rWvP.lpWaveData;
rWvP.lpWaveHdr^.dwBufferLength := dwDataSize;
rWvP.lpWaveHdr^.dwFlags := DWORD(0);
rWvP.lpWaveHdr^.dwLoops := DWORD(0);
rWvP.lpWaveHdr^.dwUser := DWORD(0);
end;
// STEP 6
// The first call to waveOutOpen checks if everything is
// OK (could be used to see if we need the WAVE_MAPPED
// flag.) The second call creates a handle and a CALLBACK
// address for use by waveOutOpen when playing starts.
// Should add a loop control with a WAVE_FORMAT variable
// that includes WAVE_MAPPED if the first test fails
// without it.
function TestWaveFileDev: Boolean;
begin
myErrMsg := waveOutOpen(PHWAVEOUT(nil),
// Device 0,1, or 2 (the phone line).
WORD(rWvP.dwWaveOutID),
// Format data in here.
PWAVEFORMATEX(rWvP.lpWaveFormat),
0, 0, WAVE_FORMAT_QUERY or WAVE_MAPPED);
Result := myErrMsg=0;
if not Result then begin
Form1.Memo.Lines.Add(
'Wave file in unsupported format ERROR.');
S := 'Unlisted error.';
if myErrMsg = MMSYSERR_ERROR then
S := '1MMSYSERR_ERROR ';
if myErrMsg = MMSYSERR_BADDEVICEID then
S := '1MMSYSERR_BADDEVICEID ';
if myErrMsg = MMSYSERR_ALLOCATED then
S := '1MMSYSERR_ALLOCATED ';
if myErrMsg = MMSYSERR_NODRIVER then
S := '1MMSYSERR_NODRIVER ';
if myErrMsg = MMSYSERR_NOMEM then
S := '1MMSYSERR_NOMEM ';
if myErrMsg = WAVERR_BADFORMAT then
S := '1WAVERR_BADFORMAT ';
if myErrMsg = WAVERR_SYNC then
S := '1WAVERR_SYNC ';
Form1.Memo.Lines.Add(S);
end;
end;
// Open the wave device corresponding to the line.
function OpenWaveOutDev: Boolean;
begin
Result := True;
myErrMsg := waveOutOpen(
// Used by waveOut /Close & /Reset (@hPWave).
@rWvP.hWavePDevice,
// Device 0,1, or 2 (the phone line).
WORD(rWvP.dwWaveOutID),
// Format data in here.
PWAVEFORMATEX(rWvP.lpWaveFormat),
Form1_Hndl, // Callback to winproc in main form.
0,
CALLBACK_WINDOW or WAVE_MAPPED);
Result := myErrMsg = 0;
if not Result then
begin
Form1.Memo.Lines.Add('Open wave device. ERROR.');
S := 'Unlisted error.';
if myErrMsg = MMSYSERR_ERROR then
S := '2MMSYSERR_ERROR ';
if myErrMsg = MMSYSERR_BADDEVICEID then
S := '2MMSYSERR_BADDEVICEID';
if myErrMsg = MMSYSERR_ALLOCATED then
S := '2MMSYSERR_ALLOCATED ';
if myErrMsg = MMSYSERR_NODRIVER then
S := '2MMSYSERR_NODRIVER ';
if myErrMsg = MMSYSERR_NOMEM then
S := '2MMSYSERR_NOMEM ';
if myErrMsg = WAVERR_BADFORMAT then
S := '2WAVERR_BADFORMAT ';
if myErrMsg = WAVERR_SYNC then
S := '2WAVERR_SYNC ';
Form1.Memo.Lines.Add(S);
end
else
begin
rWvP.bwaveOutOpen := True;
FreeMem(rWvP.lpWaveFormat);
rWvP.bWaveFormatMemAloc := False;
S := 'waveOutOpen: rWvP.hWavePDevice handle is: ' +
IntToStr(Int64(rWvP.hWavePDevice));
Form1.Memo.Lines.Add(S);
end;
end;
// STEP 7
// waveOutPrepareHeader and waveOutWrite actually play
// the file to the line. If you play a lot of little
// blocks, then waveOutUnPrepareHeader must be called
// before recalling these two functions.
function PlayWaveFIle: Boolean;
begin
Result := True;
// Prepare the message header for playing.
if waveOutPrepareHeader(rWvP.hWavePDevice,
rWvP.lpWaveHdr, sizeof(WAVEHDR)) <> 0 then
begin
Form1.Memo.Lines.Add('Message header prep: ERROR.');
Result := False;
Exit;
end
else
begin
rWvP.bWavePrepHeader := True;
Form1.Memo.Lines.Add('Message header prep OK.');
end;
// Play the message right from the data segment,
// and set the play message flag on return.
if waveOutWrite (rWvP.hWavePDevice, rWvP.lpWaveHdr,
sizeof(WAVEHDR)) <> 0 then begin
Form1.Memo.Lines.Add(
'waveOutWrite (play message): ERROR.');
Result := False;
Exit;
end;
end;
// STEP 8
// If there is an error then clean up mess here. Otherwise
// a WOM_DONE message from the callback will call cleanups.
procedure CleanEverythingUp;
begin
Form1.Memo.Lines.Add(#13);
Form1.Memo.Lines.Add('Reached the ''finally''. ');
if rWvP.bNeedErrWaveShutdown = True then
if rWvP.bwaveOutOpen = True then
ShutDownPlaying1
else
ShutDownPlaying2;
end;
begin // playASound begins.
Result := False;
SetDefaults;
GetWaveFileInCurrentDir;
try
// If any of the following steps fail, there's no point
// in trying to continue; we just Exit with an error
// message in our main memo.
if not GetThePortHandle then Exit;
if not StoreWaveFileName then Exit;
if not FillmmCkInfoParentStruc then Exit;
if not FillmmCkInfoSubchunkStruc then Exit;
if not GetfmtDataMem then Exit;
if not ReadFormatData then Exit;
if not FindWAVEDataSubchunk then Exit;
if not GetWAVEDataSize then Exit;
if not AllocateWaveDataMem then Exit;
if not ReadWaveformData then Exit;
if not CloseWaveFile then Exit;
if not GetWaveHdrMem then Exit;
FillWaveHdrStruc;
if not TestWaveFileDev then Exit;
if not OpenWaveOutDev then Exit;
if not PlayWaveFIle then
Exit
else
begin
// Set the play message flag.
Result := True;
rWvP.bNeedErrWaveShutdown := False;
Form1.Memo.Lines.Add(
'waveOutWrite (play message): OK.');
end;
finally
CleanEverythingUp;
end;
end;
// This is the response to WOM_DONE in the main Callback
// (DefaultHandler), and thus (sometimes) indirectly to
// waveOutReset. Can also be called if
// rWvP.bNeedErrWaveShutdown, above is True.
function ShutDownPlaying1: Boolean;
begin
Result := false;
if rWvP.bWavePrepHeader = True then
if waveOutUnprepareHeader(HWAVEOUT(rWvP.hWavePDevice),
PWAVEHDR(rWvP.lpWaveHdr), sizeof(WAVEHDR)) <> 0 then
Form1.Memo.Lines.Add('waveOutUnprepareHeader ERROR');
else
begin
rWvP.bWavePrepHeader := False;
Form1.Memo.Lines.Add('waveOutUnprepareHeader OK');
end;
if waveOutClose(rWvP.hWavePDevice) = 0 then
Form1.Memo.Lines.Add('CloseOK');
rWvP.bwaveOutOpen := False;
end;
// This is the response to WOM_CLOSE from DefaultHandler,
// (mainCallback), and thus indirectly to waveOutClose.
// Can also be called if rWvP.bNeedErrWaveShutdown, above
// is True.
function ShutDownPlaying2: Boolean;
begin
Result := false;
if rWvP.bWaveFileIOOpen = True then
mmioClose(rWvP.hndlMMio, 0);
rWvP.bWaveFileIOOpen := False;
if rWvP.bWaveFormatMemAloc = True then
FreeMem(rWvP.lpWaveFormat);
rWvP.bWaveFormatMemAloc := False;
if rWvP.bWaveDataMemAloc = True then
FreeMem(rWvP.lpWaveData);
rWvP.bWaveDataMemAloc := False;
if rWvP.bWaveHdrMemAloc = True then
FreeMem(rWvP.lpWaveHdr);
rWvP.bWaveHdrMemAloc := False;
rWvP.bNeedErrWaveShutdown := True;
rWvP.bPlayWaveStarted := False;
Form1.ButtonsToDefault;
Form1.Memo.Lines.Add(
'******* END OF WV_CALLBACK procedure ******');
end;
// If there is a request to end playing before end of wave
// is reached, this routine does the shutdown.
function MidPlayShutDown: Boolean;
begin
Result := True;
if (rWvP.bPlayWaveStarted = True) then
if (rWvP.bNeedErrWaveShutdown = False) then
begin // Playing also in progress.
Form1.StopPlaying.Enabled := False;
if waveOutReset(rWvP.hWavePDevice) = 0 then
Form1.Memo.Lines.Add('ResetOK');
// Wait for playing to terminate.
while (rWvP.bPlayWaveStarted = True) do
Application.ProcessMessages;
end
else
// If play routine has started but playing hasn't,
// force user to click quit twice.
begin
Result := False;
Exit;
end;
end;
end.
End Listing One
--------------------------------------------------------------------------------
Informant Communications Group, Inc.
10519 E. Stockton Blvd., Suite 100
Elk Grove, CA 95624-9703
Phone: (916) 686-6610 • Fax: (916) 686-8497
Copyright (c) 2000 Informant Communications Group. All Rights Reserved. • Site Use Agreement • Send feedback to the Webmaster • Important information about privacy