Click here to Skip to main content
Click here to Skip to main content

Using the G711 standard

By , 28 Jul 2006
 

Introduction

I have been working on a VoIP application, and wanted to implement the G.711 specification, which I found out had two variants: A-law and µ-law. Compounded by the problem of the latter being referred to as "mu-law" and "u-law" in addition to µ-law (ALT-0181, by the way), the documentation for the two is quite poor. Without an ITU login, you can't see the actual standard, and few places online go in to exactly what happens in these encodings. Wikipedia just throws a couple of equations around, but is not terribly helpful. Eventually, I did find various implementations, only one of which was reasonably commented. Of course, that one was the one with the error in it...

So here, in my first CodeProject article, I will explain thoroughly the implementation of G.711 in both of its forms. The code is in C#, but it is simple enough to be ported to, say, Java.

Note that in most contexts, I will use mu instead of µ. Only a few comments and variable names use µ, it's just too strange to think of as a normal character. And although I know the alt-code by heart, it still takes much longer to type.

The Code

The project is arranged into five C# files. One is Program.cs, which is not really important until later, and even then, it isn't so terribly important. The other four are static classes, MuLawEncoder, MuLawDecoder, ALawEncoder, and ALawDecoder, which all do exactly as their names imply.

The static constructors do all of the real work, and store the results in a table. When the Encode or Decode methods are called, they look in the table. At the cost of only a little memory (64 KB per encoder, 0.5KB per decoder), it is definitely worth it.

µ-Law Encoding

The MuLawEncoder class handles the µ-law encoding (surprise!) by looking up values in its own private byte array pcmToMuLawMap. If the index is the unsigned 16-bit PCM value, the value is the unsigned 8-bit µ-law byte.

public const int BIAS = 0x84; //132, or 1000 0100
public const int MAX = 32635; //32767 (max 15-bit integer) minus BIAS

These are the constants used by the encoder. Both will come up later.

static MuLawEncoder()
{
    pcmToMuLawMap = new byte[65536];
    for (int i = short.MinValue; i <= short.MaxValue; i++)
        pcmToMuLawMap[(i & 0xffff)] = encode(i);
}

The static constructor fills the table. It instantiates it as an array of 65536 bytes, and then goes from -37638 to 37637. To make the index of the array not be negative, i is ANDed with 0xffff, making it positive.

The method encode(short i) does exactly what you would expect. However, it is private. This is because the only time it will ever be used is by the constructor.

private static byte encode(int pcm) //16-bit
{
    //Get the sign bit. Shift it for later 
    //use without further modification
    int sign = (pcm & 0x8000) >> 8;
    //If the number is negative, make it 
    //positive (now it's a magnitude)
    if (sign != 0)
        pcm = -pcm;
    //The magnitude must be less than 32635 to avoid overflow
    if (pcm > MAX) pcm = MAX;
    //Add 132 to guarantee a 1 in 
    //the eight bits after the sign bit
    pcm += BIAS;

    /* Finding the "exponent"
    * Bits:
    * 1 2 3 4 5 6 7 8 9 A B C D E F G
    * S 7 6 5 4 3 2 1 0 . . . . . . .
    * We want to find where the first 1 after the sign bit is.
    * We take the corresponding value from
    * the second row as the exponent value.
    * (i.e. if first 1 at position 7 -> exponent = 2) */
    int exponent = 7;
    //Move to the right and decrement exponent until we hit the 1
    for (int expMask = 0x4000; (pcm & expMask) == 0; 
         exponent--, expMask >>= 1) { }

    /* The last part - the "mantissa"
    * We need to take the four bits after the 1 we just found.
    * To get it, we shift 0x0f :
    * 1 2 3 4 5 6 7 8 9 A B C D E F G
    * S 0 0 0 0 0 1 . . . . . . . . . (meaning exponent is 2)
    * . . . . . . . . . . . . 1 1 1 1
    * We shift it 5 times for an exponent of two, meaning
    * we will shift our four bits (exponent + 3) bits.
    * For convenience, we will actually just shift
    * the number, then and with 0x0f. */
    int mantissa = (pcm >> (exponent + 3)) & 0x0f;

    //The mu-law byte bit arrangement 
    //is SEEEMMMM (Sign, Exponent, and Mantissa.)
    byte mulaw = (byte)(sign | exponent << 4 | mantissa);

    //Last is to flip the bits
    return (byte)~mulaw;
}

The comments say everything that needs to be said.

The public methods all access the table, rather than do reprocessing. They are all called MuLawEncode, with different arguments. It is redundant, but so be it. The Encode overloads:

public static byte MuLawEncode(int pcm) { /*...*/ }
public static byte MuLawEncode(short pcm) { /*...*/ }
public static byte[] MuLawEncode(int[] data) { /*...*/ }
public static byte[] MuLawEncode(short[] data) { /*...*/ }
public static void[] MuLawEncode(byte[] data, byte[] target) 
    { /*Suggested by Nathan Allan*/ }
public static byte[] MuLawEncode(byte[] data) {
    int size = data.Length / 2;
    byte[] encoded = new byte[size];
    for (int i = 0; i < size; i++)
    encoded[i] = MuLawEncode((data[2 * i + 1] << 8) | data[2 * i]);
    return encoded;
}

The last takes an array of bytes in Little-Endian order. Thus, it is special, and gets to be displayed.

The last thing in the MuLawEncoder class is the ZeroTrap. Apparently, it is not so great of a thing to send an all-zero µ-law byte, so when the trap is enabled, an all-zero µ-law byte is replaced instead, by 0x02. By default, this trap is disabled.

Normally, the zero trap is a boolean, but here, there is no need. Since the unsigned PCM value 33000 maps to 0x00, we know that if the table reads 0x00, the zero trap is off. See:

public bool ZeroTrap
{
    get { return (pcmToMuLawMap[33000] != 0); }
    set
    {
        byte val = (byte)(value ? 2 : 0);
        for (int i = 32768; i <= 33924; i++)
            pcmToMuLawMap[i] = val;
    }
}

When the zero trap is assigned, the program will go through the table and assign either 0x00 or 0x02 to all of the places that map to 0x00 normally. These are the values in the range [32768, 33924].

µ-Law Decoding

In yet another major surprise, this is done in the MuLawDecoder class. This uses the same table lookup technique as the above, but has an array of type short, since the values are 16-bit signed PCM values.

static MuLawDecoder()
{
    muLawToPcmMap = new short[256];
    for (byte i = 0; i < byte.MaxValue; i++)
        muLawToPcmMap[i] = decode(i);
}

private static short decode(byte mulaw)
{
    //Flip all the bits
    mulaw = (byte)~mulaw;

    //Pull out the value of the sign bit
    int sign = mulaw & 0x80;
    //Pull out and shift over the value of the exponent
    int exponent = (mulaw & 0x70) >> 4;
    //Pull out the four bits of data
    int data = mulaw & 0x0f;

    //Add on the implicit fifth bit (we know 
    //the four data bits followed a one bit)
    data |= 0x10;
    /* Add a 1 to the end of the data by 
    * shifting over and adding one. Why?
    * Mu-law is not a one-to-one function. 
    * There is a range of values that all
    * map to the same mu-law byte. 
    * Adding a one to the end essentially adds a
    * "half byte", which means that 
    * the decoding will return the value in the
    * middle of that range. Otherwise, the mu-law
    * decoding would always be
    * less than the original data. */
    data <<= 1;
    data += 1;
    /* Shift the five bits to where they need
    * to be: left (exponent + 2) places
    * Why (exponent + 2) ?
    * 1 2 3 4 5 6 7 8 9 A B C D E F G
    * . 7 6 5 4 3 2 1 0 . . . . . . . <-- starting bit (based on exponent)
    * . . . . . . . . . . 1 x x x x 1 <-- our data
    * We need to move the one under the value of the exponent,
    * which means it must move (exponent + 2) times
    */
    data <<= exponent + 2;
    //Remember, we added to the original,
    //so we need to subtract from the final
    data -= MuLawEncoder.BIAS;
    //If the sign bit is 0, the number 
    //is positive. Otherwise, negative.
    return (short)(sign == 0 ? data : -data);
}

Again, the comments explain the magic.

And again, the main function is overloaded:

public static short MuLawDecode(byte mulaw) { /*...*/ }
public static short[] MuLawDecode(byte[] data) { /*...*/ }
public static void MuLawDecode(byte[] data, out short[] decoded) { /*...*/ }
public static void MuLawDecode(byte[] data, out byte[] decoded)
{
    int size = data.Length;
    decoded = new byte[size * 2];
    for (int i = 0; i < size; i++)
    {
        //First byte is the less significant byte
        decoded[2 * i] = (byte)(muLawToPcmMap[data[i]] & 0xff);
        //Second byte is the more significant byte
        decoded[2 * i + 1] = (byte)(muLawToPcmMap[data[i]] >> 8);
    }
}

The out parameters are used because otherwise there would be no way to separate the two MuLawDecode functions that both take a byte array. And again, the Little-Endian byte order is displayed.

A-law Encoding

A-law is even worse documented than µ-law. The implementations are even worse in terms of commenting, so this took a bit longer to figure out. In the end, it is indeed similar to µ-law's implementation, despite how different the A-law C code looks from the µ-law C code.

There is no zero trap, and the ALawEncode overloads are identical to the MuLawEncode overloads, so the only difference is the encode(short i) method, and that the MAX is 0x7fff instead of (0x7fff-0x84) like in MuLawEncoder.

private static byte encode(int pcm)
{
    //Get the sign bit. Shift it for later use 
    //without further modification
    int sign = (pcm & 0x8000) >> 8;
    //If the number is negative, 
    //make it positive (now it's a magnitude)
    if (sign != 0)
        pcm = -pcm;
    //The magnitude must fit in 15 bits to avoid overflow
    if (pcm > MAX) pcm = MAX;

    /* Finding the "exponent"
     * Bits:
     * 1 2 3 4 5 6 7 8 9 A B C D E F G
     * S 7 6 5 4 3 2 1 0 0 0 0 0 0 0 0
     * We want to find where the first 1 after the sign bit is.
     * We take the corresponding value 
     * from the second row as the exponent value.
     * (i.e. if first 1 at position 7 -> exponent = 2)
     * The exponent is 0 if the 1 is not found in bits 2 through 8.
     * This means the exponent is 0 even if the "first 1" doesn't exist.
     */
    int exponent = 7;
    //Move to the right and decrement exponent 
    //until we hit the 1 or the exponent hits 0
    for (int expMask = 0x4000; (pcm & expMask) == 0 
         && exponent>0; exponent--, expMask >>= 1) { }

    /* The last part - the "mantissa"
     * We need to take the four bits after the 1 we just found.
     * To get it, we shift 0x0f :
     * 1 2 3 4 5 6 7 8 9 A B C D E F G
     * S 0 0 0 0 0 1 . . . . . . . . . (say that exponent is 2)
     * . . . . . . . . . . . . 1 1 1 1
     * We shift it 5 times for an exponent of two, meaning
     * we will shift our four bits (exponent + 3) bits.
     * For convenience, we will actually just
     * shift the number, then AND with 0x0f. 
     * 
     * NOTE: If the exponent is 0:
     * 1 2 3 4 5 6 7 8 9 A B C D E F G
     * S 0 0 0 0 0 0 0 Z Y X W V U T S (we know nothing about bit 9)
     * . . . . . . . . . . . . 1 1 1 1
     * We want to get ZYXW, which means a shift of 4 instead of 3
     */
    int mantissa = (pcm >> ((exponent == 0) ? 4 : (exponent + 3))) & 0x0f;

    //The a-law byte bit arrangement is SEEEMMMM 
    //(Sign, Exponent, and Mantissa.)
    byte alaw = (byte)(sign | exponent << 4 | mantissa);

    //Last is to flip every other bit, and the sign bit (0xD5 = 1101 0101)
    return (byte)(alaw^0xD5);
}

Even this has only subtle differences. The mask, lack of bias, and the zero exponent weirdness are the key differences.

A-Law Decoding

The only difference between this and MuLawDecoder is the decode(short i) method.

private static short decode(byte alaw)
{
    //Invert every other bit, 
    //and the sign bit (0xD5 = 1101 0101)
    alaw ^= 0xD5;

    //Pull out the value of the sign bit
    int sign = alaw & 0x80;
    //Pull out and shift over the value of the exponent
    int exponent = (alaw & 0x70) >> 4;
    //Pull out the four bits of data
    int data = alaw & 0x0f;

    //Shift the data four bits to the left
    data <<= 4;
    //Add 8 to put the result in the middle 
    //of the range (like adding a half)
    data += 8;
    
    //If the exponent is not 0, then we know the four bits followed a 1,
    //and can thus add this implicit 1 with 0x100.
    if (exponent != 0)
        data += 0x100;
    /* Shift the bits to where they need to be: left (exponent - 1) places
     * Why (exponent - 1) ?
     * 1 2 3 4 5 6 7 8 9 A B C D E F G
     * . 7 6 5 4 3 2 1 . . . . . . . . <-- starting bit (based on exponent)
     * . . . . . . . Z x x x x 1 0 0 0 <-- our data (Z is 0 only when <BR>     * exponent is 0)
     * We need to move the one under the value of the exponent,
     * which means it must move (exponent - 1) times
     * It also means shifting is unnecessary if exponent is 0 or 1.
     */
    if (exponent > 1)
        data <<= (exponent - 1);

    return (short)(sign == 0 ? data : -data);
}

That's it for the encoders and decoders.

Program.cs

The program included in the source package runs the ALawEncoder and MuLawEncoder on a random series of data, and averages the percent errors. It then displays the average errors for each codec for the full range.

Some results:

On the range [1,32767]: µ-Law: 1.14%, A-Law: 1.26%
On the range [-32767,-1]: µ-Law: 1.14%, A-Law: 1.16%

Conclusion

A-law and µ-law are not so complicated, especially when. laid out in plain sight. I hope this is useful. After all, G.711 can make 16-bit samples, take up 8 bits, or 50% compression. At 8KHz sampling, that turns a 128Kbps stream into a 64Kbps stream.

Edited July 28th, 2006 to fix error and add new overload suggested by Nathan Allan

License

This article has no explicit license attached to it but may contain usage terms in the article text or the download files themselves. If in doubt please contact the author via the discussion board below.

A list of licenses authors might use can be found here

About the Author

Marc Sweetgall
United States United States
Member
Marc started proramming on a TI-83 Plus, and slowly moved through the languages: QBasic, VB, C++, and then Java. J# provided a good stepping stone into .NET, and now he uses primarily Java and C#.

Sign Up to vote   Poor Excellent
Add a reason or comment to your vote: x
Votes of 3 or less require a comment

Comments and Discussions

 
You must Sign In to use this message board.
Search this forum  
    Spacing  Noise  Layout  Per page   
QuestionLicensing Under BSDprofessionalMember 997809211 Apr '13 - 10:52 
Hi Marc,
 
I wanted to convey my appreciation for the code that you’ve created and offered for our use. We think it will be a great match to assist us in solving a problem for one of our customers.
 
In order for us to use your code, our License Compliance department requires that we gain explicit permission to use and redistribute your code. When other engineers at this site have run into this concern, they have approached the owners of the code to allow us to use their software product under the terms of an academic license (for example, the BSD or MIT licenses). Other authors have been agreeable in all the cases that I know about with this approach. By doing this, we can be certain that the author is aware of our planned use, and has given us permission for that use. Equally it provides protections for the code owner.
 
Thanks for considering our request,
Ryan Steed
QuestionLots of noise and echo soundmemberrajesh@198911 Apr '13 - 0:03 
I am using A-law codec and face two much noise in voice call.
 
How to handle noise in voice? and which code cause the noise?
QuestionWav file to ulaw filememberzackqwer14 Jan '13 - 5:54 
is it posible to convert wav file to ulaw or alaw file? is the header should change or modify it?
QuestionBug or not?membera.nemec10 Aug '12 - 0:06 
Hi all,
 
looking at this code:
 
muLawToPcmMap = new short[256];
for (byte i = 0; i < byte.MaxValue; i++)
     muLawToPcmMap[i] = decode(i);
 
it seems to me that muLawToPcmMap[255] never gets filled because byte.MaxValue is 255.
 
Is it a bug or is it expected behaviour that the last position in the array is not initialized? I would expect the condition to be i <= byte.MaxValue.
 
Thanks
 
Alex
GeneralPort for Silverlight 4memberAndreas Niedermair2 Jan '12 - 20:55 
Hi - I've done a port for silverlight 4 - you can contact me via with the contact-information in the contact-tab Smile | :)
QuestionCommercial Licensing for a PortmemberMember 82061741 Sep '11 - 13:39 
Hello Marc,

We would like to port your code to Java and use it in our commercial software. Are there are any limitations in porting this code?

Best,
 
Dave S
QuestionALAW to FLACmembertsdaemon22 Jun '11 - 6:40 
Have some file in G711 ALAW, need FLAC, write code:
 
FileStream fs = new FileStream(wavpath, FileMode.Open, FileAccess.Read);
WaveReader wr = new WaveReader(fs);
byte[] pcm = new byte[wr.Data[0].Length*4];
byte[] alaw = new byte[wr.Data[0].Length*4];
int i = 0;
foreach(int frame in wr.Data[0])
{
for(int ii = 0;ii<4;ii++)
alaw[i*4+ii] = (byte)(frame >> ii*8);
i++;
}
ALawDecoder.ALawDecode(alaw,out pcm);
AudioPCMConfig conf = new AudioPCMConfig(16,1,wr.SampleRate);
AudioBuffer buff = new AudioBuffer(conf,pcm,pcm.Length/2);
FlakeWriter fr = new FlakeWriter(flacpath, conf);
fr.Write(buff);
fr.Close();
 
but FLAC files doesn't play. Where i'm wrong?
Used WaveReaderDLL for open wav and CUETools for flac
QuestionNortel VBK?memberMember 167491827 Aug '10 - 4:41 
Anyone managed to decode a Nortel VBK message?   As created by the Nortel Callpilot application.   The file appears to be some kind of g711 audio in a package.   The header of the file [as viewed in ghex2] is "Nortel Multimedia....g711".
QuestionLicencingmemberrainer.hochdorfer27 Apr '10 - 22:08 
Hello Marc,
 
We would like to use your code in our software. Are there are any copyright limitations using this code?
 
Specifically, are there any restrictions using your code as part of a commercial software application?
 
Kind Regards,
Rainer
GeneralThanksmemberGSc_Dev28 Oct '08 - 2:19 
Hi Marc,
 
thank you for this library. It works very good for our audio application. I found your solution already six months ago.
 
Guenter
Generalpcm to g.711memberpasalos20 May '08 - 23:47 
Hi every1!!1 great code indeed! but the code creates random values. How can i change it to insert my pcm file and encode it? Im trying to learn C# but i have a lot of probs with this language. PLz if y know answer me cos i need it for a project, Thnx alot!!! my mail also: dostass@hotmail.com
GeneralPCM to uLaw - lots of noicememberMember 158205014 Feb '08 - 0:45 
Hi,
Thanks a lot for this nice tutorial.
 
I'm encoding PCM 16 bps to uLaw.
I get lots of noise on the uLaw signal.
The more the input is louder the more the output has noises.
 
Any idea?
 
Thanks.
GeneralRe: PCM to uLaw - lots of noicememberpasalos28 May '08 - 2:47 
HI!! sorry to interrupt y but i would like to know the code (with the project) that y implemented the g.711 cos i cant since this code encodes random values. THnx for your time...
Generalported to javamemberbansal_rajeshkr13 Sep '07 - 23:17 
I want to change this code to java for this thing i need help that what is procedure behind this code.So that i can understand all theory related to this conversion.
 
Rajesh Bansal
Software developer
TSSL

GeneralOverhead for memory plz chkmemberbansal_rajeshkr13 Sep '07 - 21:51 
static MuLawEncoder()
{
pcmToMuLawMap = new byte[65536];
for (int i = short.MinValue; i <= short.MaxValue; i++)
pcmToMuLawMap[(i & 0xffff)] = encode(i);
}
in above code we are only using 1 to 32767 value in pcmTpMuLawMap then why we are taking a array of 65536
because value of (i & 0xffff) is always positive.
Please chk if i m wrong.;)
 
Rajesh Bansal
Software developer
TSSL

GeneralLicensing... for FMJ, an open-source project.memberKenLars997 Jun '07 - 12:28 
I'd like to use a Java port of this code in FMJ, and open-source implementation of JMF. Would you mind if I did so, either under a BSD or LGPL License? I'll give you full credit, of course.
 
Ken Larson
AnswerRe: Licensing... for FMJ, an open-source project.memberMarc Sweetgall15 Jun '07 - 19:38 
Feel free. I don't really know much about the different licensing, but I have no issues with it being used. In fact, I would be glad to have someone use it.
QuestionSource Neededmemberamessbee22 Feb '07 - 2:00 
Hi Marc,
 
It really is so beneficial article that I love it. And you are right that it tough to get a clear description of G.711 codec. Can you please share your source where you got G.711 explanation from?
 
thanks,
 
mudassir.
AnswerRe: Source NeededmemberMarc Sweetgall15 Jun '07 - 19:41 
This was a combination of a large number of different sources. There is no one source that had all of this in it. A lot was actually deconstructing code, and seeing where the code fit in to the rough explanations that had been outlined. That's a part of why there are so many comments in the code itself.
QuestionLicensing?memberneilcam17 Dec '06 - 23:31 

Hi Marc,
 
Are there any licensing conditions attached to the source code or
can it be reused freely without restriction?
 
Thanks,
 
Neil
QuestionTrim silence from recordingmemberkoenvi6 Sep '06 - 22:37 
Great work!
This article has been very helpful in understanding g711 coding.
Next step in my project is to detect silence and delete it from the file.
Any idea how to tackle this?
I'm guessing I should just read the sample values from PCM data and throw out everything below a minimum level. But the samples that I suspect to be silence, have the value 8. Does this mean there is always some signal?
 
Any help is appreciated!
 
Regards,
 
Koen
GeneralTest G.711 [modified]memberhtphuc01t16 Sep '06 - 17:31 
Hi all
I was using, and i realize noise.
How to check noise and test G.711
Thanks
 

 

-- modified at 21:26 Thursday 7th September, 2006
GeneralVOIP implementationmemberconfunded21 Aug '06 - 5:27 
hello...
that is some excellent work u have done . i am trying to implement VOIP as a part of my project. do you think this is feasible . if so ... how can i go about implementing it. any important tips?? i'd appreciate it.
thank u

QuestionAny plans for G.729?memberTamir Gal30 Jul '06 - 4:11 
Hi Marc,
 
G.711 is very nice, and indeed the implementation is not so complicated.
Do you plan to implement other codecs such as G.729?
 
Cheers,
Tamir
 

GeneralDefect in one of the Decode overloadsmemberNathan Allan12 Jul '06 - 14:53 
Thanks for this great work.   The following overload has a critical defect:
      public static void MuLawDecode(byte[] data, out byte[] decoded)
...the body should read:
            ...
            //First byte is the less significant byte
            decoded[2 * i] = (byte)(muLawToPcmMap[data[i]] & 0xff);
            //Second byte is the more significant byte
            decoded[2 * i + 1] = (byte)(muLawToPcmMap[data[i]] >> 8);
            ...
ALawDecode has the same problem with the corresponding overload.
 
Also, I have added overloads to both Decode and Encode that I find useful, here they are in case you wish to include them in the base:
 
          /// <summary>
          /// Decode an array of mu-law encoded bytes
          /// </summary>
          /// <param name="data">An array of mu-law encoded bytes</param>
          /// <param name="decoded">An array of bytes in Little-Endian format containing the results, the caller must allocate this array and ensure that it is at least twice the specified size.</param>
          /// <param name="size">The number of elements in the data array to process. </param>
          public static void MuLawDecode(byte[] data, byte[] decoded, int size)
          {
               for (int i = 0; i < size; i++)
               {
                    //First byte is the less significant byte
                    decoded[2 * i] = (byte)(muLawToPcmMap[data[i]] & 0xff);
                    //Second byte is the more significant byte
                    decoded[2 * i + 1] = (byte)(muLawToPcmMap[data[i]] >> 8);
               }
          }
 
            /// <summary>
            /// Encode an array of pcm values
            /// </summary>
            /// <param name="source">An array of bytes in Little-Endian format</param>
            /// <param name="target">A caller constructed array to receive the mu-law bytes.   This array should be at least half the size of the source.</param>
            public static void MuLawEncode(byte[] source, byte[] target)
            {
                  int size = source.Length / 2;
                  for (int i = 0; i < size; i++)
                        target[i] = MuLawEncode((source[2 * i + 1] << 8) | source[2 * i]);
          }              
Thanks!
 
--
Nathan Allan

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Rant Rant    Admin Admin   

Permalink | Advertise | Privacy | Mobile
Web01 | 2.6.130516.1 | Last Updated 28 Jul 2006
Article Copyright 2006 by Marc Sweetgall
Everything else Copyright © CodeProject, 1999-2013
Terms of Use
Layout: fixed | fluid