2002-12-13 08:33:25 +00:00
|
|
|
/* D3D implementation that uses multiple streams.
|
|
|
|
|
*
|
|
|
|
|
* Each sound gets its own stream, which allows for very little startup
|
|
|
|
|
* latency for each sound and lower CPU usage. */
|
|
|
|
|
|
2003-02-16 04:02:21 +00:00
|
|
|
#include "global.h"
|
2002-12-13 08:33:25 +00:00
|
|
|
#include "RageSoundDriver_DSound.h"
|
|
|
|
|
#include "DSoundHelpers.h"
|
|
|
|
|
|
2003-01-24 22:28:23 +00:00
|
|
|
#include "RageSoundManager.h"
|
|
|
|
|
#include "RageException.h"
|
|
|
|
|
#include "RageUtil.h"
|
|
|
|
|
#include "RageSound.h"
|
|
|
|
|
#include "RageLog.h"
|
2003-07-22 07:47:27 +00:00
|
|
|
|
2002-12-13 08:33:25 +00:00
|
|
|
#include "SDL.h"
|
|
|
|
|
|
|
|
|
|
const int channels = 2;
|
2004-01-12 02:13:30 +00:00
|
|
|
const int bytes_per_frame = 2 * channels; /* 16-bit */
|
2002-12-13 08:33:25 +00:00
|
|
|
const int samplerate = 44100;
|
2003-01-20 09:22:30 +00:00
|
|
|
|
|
|
|
|
/* The total write-ahead. Don't make this *too* high; we fill the entire
|
|
|
|
|
* buffer when we start playing, so it can cause frame skips. This should be
|
|
|
|
|
* high enough that sound cards won't skip. */
|
2003-04-23 19:30:12 +00:00
|
|
|
const int buffersize_frames = 1024*8; /* in frames */
|
2004-01-12 02:13:30 +00:00
|
|
|
const int buffersize = buffersize_frames * bytes_per_frame; /* in bytes */
|
2002-12-13 08:33:25 +00:00
|
|
|
|
2002-12-21 07:21:49 +00:00
|
|
|
const int num_chunks = 8;
|
|
|
|
|
const int chunksize = buffersize / num_chunks;
|
|
|
|
|
|
2002-12-13 08:33:25 +00:00
|
|
|
int RageSound_DSound::MixerThread_start(void *p)
|
|
|
|
|
{
|
|
|
|
|
((RageSound_DSound *) p)->MixerThread();
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void RageSound_DSound::MixerThread()
|
|
|
|
|
{
|
2002-12-21 02:19:59 +00:00
|
|
|
/* SOUNDMAN will be set once RageSoundManager's ctor returns and
|
|
|
|
|
* assigns it; we might get here before that happens, though. */
|
|
|
|
|
while(!SOUNDMAN && !shutdown) Sleep(10);
|
|
|
|
|
|
2002-12-21 07:21:49 +00:00
|
|
|
if(!SetThreadPriority(GetCurrentThread(), THREAD_PRIORITY_ABOVE_NORMAL))
|
2002-12-21 08:44:51 +00:00
|
|
|
LOG->Warn(werr_ssprintf(GetLastError(), "Failed to set sound thread priority"));
|
2002-12-21 07:21:49 +00:00
|
|
|
|
2002-12-13 08:33:25 +00:00
|
|
|
while(!shutdown) {
|
2003-08-05 01:32:51 +00:00
|
|
|
CHECKPOINT;
|
2002-12-31 00:54:54 +00:00
|
|
|
|
2003-12-22 03:47:26 +00:00
|
|
|
/* Sleep for the size of one chunk, or until we're signalled. */
|
2003-03-17 20:02:20 +00:00
|
|
|
const int chunksize_frames = buffersize_frames / num_chunks;
|
|
|
|
|
float sleep_secs = (float(chunksize_frames) / samplerate);
|
2003-12-22 03:47:26 +00:00
|
|
|
WaitForSingleObject( m_ThreadWakeupEvent, int(1000 * sleep_secs) );
|
2002-12-13 08:33:25 +00:00
|
|
|
|
2003-08-05 01:32:51 +00:00
|
|
|
CHECKPOINT;
|
2002-12-13 08:33:25 +00:00
|
|
|
LockMutex L(SOUNDMAN->lock);
|
2002-12-20 07:00:12 +00:00
|
|
|
for(unsigned i = 0; i < stream_pool.size(); ++i)
|
2002-12-13 08:33:25 +00:00
|
|
|
{
|
|
|
|
|
if(stream_pool[i]->state == stream_pool[i]->INACTIVE)
|
|
|
|
|
continue; /* inactive */
|
|
|
|
|
|
2003-01-07 03:55:43 +00:00
|
|
|
while(stream_pool[i]->GetData(false))
|
2002-12-21 07:21:49 +00:00
|
|
|
;
|
2002-12-13 08:33:25 +00:00
|
|
|
}
|
|
|
|
|
}
|
2003-11-01 06:41:14 +00:00
|
|
|
|
|
|
|
|
/* I'm not sure why, but if we don't stop the stream now, then the thread will take
|
|
|
|
|
* 90ms (our buffer size) longer to close. */
|
|
|
|
|
for(unsigned i = 0; i < stream_pool.size(); ++i)
|
|
|
|
|
if(stream_pool[i]->state != stream_pool[i]->INACTIVE)
|
2004-01-12 23:10:14 +00:00
|
|
|
stream_pool[i]->pcm->Stop();
|
2002-12-13 08:33:25 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void RageSound_DSound::Update(float delta)
|
|
|
|
|
{
|
|
|
|
|
/* SoundStopped might erase sounds out from under us, so make a copy
|
|
|
|
|
* of the sound list. */
|
|
|
|
|
vector<stream *> str = stream_pool;
|
2003-03-06 08:10:28 +00:00
|
|
|
ASSERT(SOUNDMAN);
|
2002-12-13 08:33:25 +00:00
|
|
|
LockMutex L(SOUNDMAN->lock);
|
|
|
|
|
|
|
|
|
|
for(unsigned i = 0; i < str.size(); ++i)
|
|
|
|
|
{
|
2002-12-21 23:15:01 +00:00
|
|
|
if(str[i]->state != str[i]->STOPPING) continue;
|
2002-12-21 07:21:49 +00:00
|
|
|
|
2004-01-12 23:10:14 +00:00
|
|
|
int ps = str[i]->pcm->GetPosition();
|
2002-12-22 08:13:33 +00:00
|
|
|
if(ps < str[i]->flush_pos)
|
2002-12-13 08:33:25 +00:00
|
|
|
continue; /* stopping but still flushing */
|
|
|
|
|
|
|
|
|
|
/* The sound has stopped and flushed all of its buffers. */
|
|
|
|
|
if(str[i]->snd != NULL)
|
2002-12-22 08:13:33 +00:00
|
|
|
str[i]->snd->StopPlaying();
|
2002-12-13 08:33:25 +00:00
|
|
|
str[i]->snd = NULL;
|
|
|
|
|
|
2004-01-12 23:10:14 +00:00
|
|
|
str[i]->pcm->Stop();
|
2002-12-13 08:33:25 +00:00
|
|
|
str[i]->state = str[i]->INACTIVE;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* If init is true, we're filling the buffer while it's stopped, so put
|
|
|
|
|
* data in the current buffer (where the play cursor is); otherwise put
|
|
|
|
|
* it in the opposite buffer. */
|
2003-01-07 03:55:43 +00:00
|
|
|
bool RageSound_DSound::stream::GetData(bool init)
|
2002-12-13 08:33:25 +00:00
|
|
|
{
|
2003-08-05 01:32:51 +00:00
|
|
|
CHECKPOINT;
|
2002-12-13 08:33:25 +00:00
|
|
|
|
2002-12-21 07:21:49 +00:00
|
|
|
char *locked_buf;
|
|
|
|
|
unsigned len;
|
2004-01-12 23:10:14 +00:00
|
|
|
const int play_pos = pcm->GetOutputPosition();
|
|
|
|
|
const int cur_play_pos = pcm->GetPosition();
|
2002-12-13 08:33:25 +00:00
|
|
|
|
|
|
|
|
if(init) {
|
2002-12-21 07:21:49 +00:00
|
|
|
/* We're initializing; fill the entire buffer. The buffer is supposed to
|
|
|
|
|
* be empty, so this should never fail. */
|
2004-01-12 23:10:14 +00:00
|
|
|
if(!pcm->get_output_buf(&locked_buf, &len, buffersize))
|
2002-12-21 07:21:49 +00:00
|
|
|
ASSERT(0);
|
2002-12-13 08:33:25 +00:00
|
|
|
} else {
|
2002-12-21 07:21:49 +00:00
|
|
|
/* Just fill one chunk. */
|
2004-01-12 23:10:14 +00:00
|
|
|
if(!pcm->get_output_buf(&locked_buf, &len, chunksize))
|
2002-12-21 07:21:49 +00:00
|
|
|
return false;
|
2002-12-13 08:33:25 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* It might be INACTIVE, when we're prebuffering. We just don't want to
|
|
|
|
|
* fill anything in STOPPING; in that case, we just clear the audio buffer. */
|
|
|
|
|
if(state != STOPPING)
|
2002-12-21 07:21:49 +00:00
|
|
|
{
|
2004-01-12 02:37:51 +00:00
|
|
|
int bytes_read = 0;
|
|
|
|
|
int bytes_left = len;
|
2002-12-21 07:21:49 +00:00
|
|
|
|
2004-01-10 10:44:28 +00:00
|
|
|
/* Does the sound have a start time? */
|
|
|
|
|
if( !start_time.IsZero() )
|
|
|
|
|
{
|
2004-01-12 02:37:51 +00:00
|
|
|
/* If the sound is supposed to start at a time past this buffer, insert silence. */
|
2004-01-10 10:44:28 +00:00
|
|
|
const int iFramesUntilThisBuffer = play_pos - cur_play_pos;
|
2004-01-12 02:37:51 +00:00
|
|
|
const float fSecondsBeforeStart = -start_time.Ago();
|
2004-01-12 23:10:14 +00:00
|
|
|
const int iFramesBeforeStart = int(fSecondsBeforeStart * pcm->GetSampleRate());
|
2004-01-12 03:52:23 +00:00
|
|
|
const int iSilentFramesInThisBuffer = iFramesBeforeStart-iFramesUntilThisBuffer;
|
|
|
|
|
const int iSilentBytesInThisBuffer = clamp( iSilentFramesInThisBuffer * bytes_per_frame, 0, bytes_left );
|
2004-01-10 10:44:28 +00:00
|
|
|
|
|
|
|
|
memset( locked_buf, 0, iSilentBytesInThisBuffer );
|
|
|
|
|
bytes_read += iSilentBytesInThisBuffer;
|
|
|
|
|
bytes_left -= iSilentBytesInThisBuffer;
|
|
|
|
|
|
|
|
|
|
if( !iSilentBytesInThisBuffer )
|
|
|
|
|
start_time.SetZero();
|
|
|
|
|
}
|
|
|
|
|
|
2004-01-12 02:37:51 +00:00
|
|
|
int got = snd->GetPCM( locked_buf+bytes_read, len-bytes_read, play_pos + (bytes_read/bytes_per_frame));
|
2004-01-10 10:44:28 +00:00
|
|
|
bytes_read += got;
|
|
|
|
|
|
2004-01-12 02:37:51 +00:00
|
|
|
if( bytes_read < (int) len )
|
2004-01-10 10:44:28 +00:00
|
|
|
{
|
2002-12-21 07:21:49 +00:00
|
|
|
/* Fill the remainder of the buffer with silence. */
|
2004-01-10 10:44:28 +00:00
|
|
|
memset( locked_buf+got, 0, len-bytes_read );
|
2002-12-21 07:21:49 +00:00
|
|
|
|
|
|
|
|
/* STOPPING tells the mixer thread to release the stream once str->flush_bufs
|
2004-01-10 10:44:28 +00:00
|
|
|
* buffers have been flushed. */
|
2002-12-21 07:21:49 +00:00
|
|
|
state = STOPPING;
|
|
|
|
|
|
2004-01-12 03:52:23 +00:00
|
|
|
/* Keep playing until the data we currently have locked has played. */
|
2004-01-12 23:10:14 +00:00
|
|
|
flush_pos = pcm->GetOutputPosition();
|
2002-12-21 07:21:49 +00:00
|
|
|
}
|
|
|
|
|
} else {
|
2002-12-13 08:33:25 +00:00
|
|
|
/* Silence the buffer. */
|
|
|
|
|
memset(locked_buf, 0, len);
|
|
|
|
|
}
|
2002-12-21 07:21:49 +00:00
|
|
|
|
2004-01-12 23:10:14 +00:00
|
|
|
pcm->release_output_buf(locked_buf, len);
|
2002-12-21 07:21:49 +00:00
|
|
|
|
|
|
|
|
return true;
|
2002-12-13 08:33:25 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
RageSound_DSound::stream::~stream()
|
|
|
|
|
{
|
2004-01-12 23:10:14 +00:00
|
|
|
delete pcm;
|
2002-12-13 08:33:25 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
RageSound_DSound::RageSound_DSound()
|
|
|
|
|
{
|
|
|
|
|
shutdown = false;
|
|
|
|
|
|
2002-12-21 07:21:49 +00:00
|
|
|
/* Don't bother wasting time trying to create buffers if we're
|
|
|
|
|
* emulated. This also gives us better diagnostic information. */
|
|
|
|
|
if(ds.IsEmulated())
|
2002-12-21 08:15:35 +00:00
|
|
|
RageException::ThrowNonfatal("Driver unusable (emulated device)");
|
2002-12-13 08:33:25 +00:00
|
|
|
|
|
|
|
|
/* Create a bunch of streams and put them into the stream pool. */
|
|
|
|
|
for(int i = 0; i < 32; ++i) {
|
2002-12-21 07:21:49 +00:00
|
|
|
DSoundBuf *newbuf;
|
2002-12-13 08:33:25 +00:00
|
|
|
try {
|
2002-12-21 07:21:49 +00:00
|
|
|
newbuf = new DSoundBuf(ds,
|
|
|
|
|
DSoundBuf::HW_HARDWARE,
|
2003-04-25 04:10:18 +00:00
|
|
|
channels, DSoundBuf::DYNAMIC_SAMPLERATE, 16, buffersize);
|
2002-12-21 08:15:35 +00:00
|
|
|
} catch(const RageException &e) {
|
2002-12-13 08:33:25 +00:00
|
|
|
/* If we didn't get at least 8, fail. */
|
|
|
|
|
if(i >= 8) break; /* OK */
|
|
|
|
|
|
|
|
|
|
/* Clean up; the dtor won't be called. */
|
|
|
|
|
for(int n = 0; n < i; ++n)
|
|
|
|
|
delete stream_pool[n];
|
|
|
|
|
|
|
|
|
|
if(i)
|
|
|
|
|
{
|
|
|
|
|
/* We created at least one hardware buffer. */
|
2003-10-22 01:52:59 +00:00
|
|
|
LOG->Trace("Could only create %i buffers; need at least 8 (failed with %s). DirectSound driver can't be used.", i, e.what());
|
2002-12-21 08:15:35 +00:00
|
|
|
RageException::ThrowNonfatal("Driver unusable (not enough hardware buffers)");
|
2002-12-13 08:33:25 +00:00
|
|
|
}
|
2002-12-21 08:15:35 +00:00
|
|
|
RageException::ThrowNonfatal("Driver unusable (no hardware buffers)");
|
2002-12-13 08:33:25 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
stream *s = new stream;
|
2004-01-12 23:10:14 +00:00
|
|
|
s->pcm = newbuf;
|
2002-12-13 08:33:25 +00:00
|
|
|
stream_pool.push_back(s);
|
|
|
|
|
}
|
2003-01-19 19:55:24 +00:00
|
|
|
|
2002-12-13 08:33:25 +00:00
|
|
|
LOG->Trace("Got %i hardware buffers", stream_pool.size());
|
|
|
|
|
|
2003-01-19 19:55:24 +00:00
|
|
|
/* Set channel volumes. */
|
|
|
|
|
VolumeChanged();
|
|
|
|
|
|
2003-12-22 03:47:26 +00:00
|
|
|
m_ThreadWakeupEvent = CreateEvent( NULL, false, false, NULL );
|
2003-08-05 01:32:51 +00:00
|
|
|
MixingThread.SetName("Mixer thread");
|
|
|
|
|
MixingThread.Create( MixerThread_start, this );
|
2002-12-13 08:33:25 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
RageSound_DSound::~RageSound_DSound()
|
|
|
|
|
{
|
|
|
|
|
/* Signal the mixing thread to quit. */
|
|
|
|
|
shutdown = true;
|
2003-01-19 19:55:24 +00:00
|
|
|
LOG->Trace("Shutting down mixer thread ...");
|
|
|
|
|
LOG->Flush();
|
2003-08-05 01:32:51 +00:00
|
|
|
MixingThread.Wait();
|
2003-01-19 19:55:24 +00:00
|
|
|
LOG->Trace("Mixer thread shut down.");
|
|
|
|
|
LOG->Flush();
|
2002-12-13 08:33:25 +00:00
|
|
|
|
|
|
|
|
for(unsigned i = 0; i < stream_pool.size(); ++i)
|
|
|
|
|
delete stream_pool[i];
|
2003-12-22 03:47:26 +00:00
|
|
|
|
|
|
|
|
CloseHandle( m_ThreadWakeupEvent );
|
2002-12-13 08:33:25 +00:00
|
|
|
}
|
|
|
|
|
|
2003-01-19 19:55:24 +00:00
|
|
|
void RageSound_DSound::VolumeChanged()
|
|
|
|
|
{
|
|
|
|
|
for(unsigned i = 0; i < stream_pool.size(); ++i)
|
|
|
|
|
{
|
2004-01-12 23:10:14 +00:00
|
|
|
stream_pool[i]->pcm->SetVolume(SOUNDMAN->GetMixVolume());
|
2003-01-19 19:55:24 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2002-12-13 08:33:25 +00:00
|
|
|
void RageSound_DSound::StartMixing(RageSound *snd)
|
|
|
|
|
{
|
2002-12-20 07:00:12 +00:00
|
|
|
LockMutex L(SOUNDMAN->lock);
|
|
|
|
|
|
2002-12-13 08:33:25 +00:00
|
|
|
/* Find an unused buffer. */
|
|
|
|
|
unsigned i;
|
|
|
|
|
for(i = 0; i < stream_pool.size(); ++i) {
|
|
|
|
|
if(stream_pool[i]->state == stream_pool[i]->INACTIVE)
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if(i == stream_pool.size()) {
|
2002-12-21 08:15:35 +00:00
|
|
|
/* We don't have a free sound buffer. Fake it. */
|
2004-01-14 00:10:34 +00:00
|
|
|
/* XXX: too big of a hack for too rare of a case */
|
|
|
|
|
// SOUNDMAN->AddFakeSound(snd);
|
2002-12-21 02:19:59 +00:00
|
|
|
return;
|
2002-12-13 08:33:25 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* Give the stream to the playing sound and remove it from the pool. */
|
|
|
|
|
stream_pool[i]->snd = snd;
|
2004-01-12 23:10:14 +00:00
|
|
|
stream_pool[i]->pcm->SetSampleRate(snd->GetSampleRate());
|
2004-01-10 10:44:28 +00:00
|
|
|
stream_pool[i]->start_time = snd->GetStartTime();
|
2002-12-13 08:33:25 +00:00
|
|
|
|
|
|
|
|
/* Pre-buffer the stream. */
|
|
|
|
|
/* There are two buffers of data; fill them both ahead of time so the
|
|
|
|
|
* sound can start almost immediately. */
|
2003-01-07 03:55:43 +00:00
|
|
|
stream_pool[i]->GetData(true);
|
2004-01-12 23:10:14 +00:00
|
|
|
stream_pool[i]->pcm->Play();
|
2002-12-13 08:33:25 +00:00
|
|
|
|
|
|
|
|
/* Normally, at this point we should still be INACTIVE, in which case,
|
|
|
|
|
* tell the mixer thread to start mixing this channel. However, if it's
|
|
|
|
|
* been changed to STOPPING, then we actually finished the whole file
|
2003-01-07 03:55:43 +00:00
|
|
|
* in the prebuffering GetData calls above, so leave it alone and let it
|
2002-12-13 08:33:25 +00:00
|
|
|
* finish on its own. */
|
|
|
|
|
if(stream_pool[i]->state == stream_pool[i]->INACTIVE)
|
|
|
|
|
stream_pool[i]->state = stream_pool[i]->PLAYING;
|
|
|
|
|
|
2003-12-22 03:47:26 +00:00
|
|
|
/* Kick the decoder thread, so it'll start this sound up quickly. */
|
|
|
|
|
L.Unlock();
|
|
|
|
|
SetEvent( m_ThreadWakeupEvent );
|
|
|
|
|
|
2003-07-09 20:40:28 +00:00
|
|
|
// LOG->Trace("new sound assigned to channel %i", i);
|
2002-12-13 08:33:25 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* Called by a RageSound; asks us to stop mixing them. When this
|
|
|
|
|
* call completes, snd->GetPCM (which runs in a separate thread) will
|
|
|
|
|
* not be running and will not be called unless StartMixing is called
|
|
|
|
|
* again. */
|
|
|
|
|
void RageSound_DSound::StopMixing(RageSound *snd)
|
|
|
|
|
{
|
|
|
|
|
ASSERT(snd != NULL);
|
|
|
|
|
LockMutex L(SOUNDMAN->lock);
|
|
|
|
|
|
|
|
|
|
unsigned i;
|
|
|
|
|
for(i = 0; i < stream_pool.size(); ++i)
|
|
|
|
|
if(stream_pool[i]->snd == snd) break;
|
|
|
|
|
|
|
|
|
|
if(i == stream_pool.size()) {
|
|
|
|
|
LOG->Trace("not stopping a sound because it's not playing");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* STOPPING tells the mixer thread to release the stream once str->flush_bufs
|
|
|
|
|
* buffers have been flushed. */
|
|
|
|
|
stream_pool[i]->state = stream_pool[i]->STOPPING;
|
|
|
|
|
|
|
|
|
|
/* Flush two buffers worth of data. */
|
2004-01-12 23:10:14 +00:00
|
|
|
stream_pool[i]->flush_pos = stream_pool[i]->pcm->GetOutputPosition();
|
2002-12-13 08:33:25 +00:00
|
|
|
|
|
|
|
|
/* This function is called externally (by RageSound) to stop immediately.
|
|
|
|
|
* We need to prevent SoundStopped from being called; it should only be
|
|
|
|
|
* called when we stop implicitely at the end of a sound. Set snd to NULL. */
|
|
|
|
|
stream_pool[i]->snd = NULL;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
int RageSound_DSound::GetPosition(const RageSound *snd) const
|
|
|
|
|
{
|
|
|
|
|
LockMutex L(SOUNDMAN->lock);
|
|
|
|
|
|
|
|
|
|
unsigned i;
|
|
|
|
|
for(i = 0; i < stream_pool.size(); ++i)
|
|
|
|
|
if(stream_pool[i]->snd == snd) break;
|
|
|
|
|
|
|
|
|
|
if(i == stream_pool.size())
|
2003-10-21 23:29:59 +00:00
|
|
|
RageException::Throw("GetPosition: Sound %s is not being played", snd->GetLoadedFilePath().c_str());
|
2002-12-13 08:33:25 +00:00
|
|
|
|
|
|
|
|
ASSERT(i != stream_pool.size());
|
|
|
|
|
|
2004-01-12 23:10:14 +00:00
|
|
|
return stream_pool[i]->pcm->GetPosition();
|
2002-12-13 08:33:25 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/*
|
2004-01-12 23:20:52 +00:00
|
|
|
* Copyright (c) 2002-2004 by the person(s) listed below. All rights reserved.
|
2002-12-13 08:33:25 +00:00
|
|
|
*
|
|
|
|
|
* Glenn Maynard
|
|
|
|
|
*/
|