CS 33: Lab #09

CS 33: Computer Organization


LAB 09: Reading ID3v2.2 Tags

Due 11:59pm Monday, December 1.

The program handin33 will only submit files in the cs33/lab/09 directory. (You should run update33 first to set up the directory and create any necessary files.)

WARNING!!! This program will probably take you longer than the average program in the class. There is lots of text to read below, and the program has lots of little details that you can mess up. START EARLY AND WORK WITH A PARTNER!

Your program must follow these following guidelines:

  • the file should be named using the name provided,
  • your code should be compiled using a Makefile,
  • your code should be adequately commented,
  • your program should follow all input and output guidelines exactly, and
  • your program should gracefully report errors and exit, instead of crashing.

There is only one program this week, but the program is complex. I once again encourage you to work with a partner.

The goal of this week's assignment is to read in an MP3 file and display the contents of its ID3 tag. For those of you who are unfamiliar, MP3 files are files that contain compressed audio and may optionally contain information specifying the artist, song title, album name, etc. This additional non-audio data comprises the ID3 tag.

In 1996, the first specification for ID3 tags, ID3v1, was created. While this specification was simple, there were complaints about the format. One problem with the specification was the small amount of space (only 128 bytes) that was made available for storing non-audio data. Artist names, song titles, and album names all had to be 30 characters or less. This meant that you couldn't store "Bruce Springsteen and the E-Street Band" as the artist for their "Live/1975-85" album. Since the maximum length of the artist name was 30 characters, you'd only be able to store "Bruce Springsteen and the E-St".

In 1998, the ID3v2 specification was created. This allowed much more non-audio data (256 megabytes instead of 128 bytes) to be stored, with no limits on the number of characters that could be stored for any of the data. One new piece of non-audio data that can be stored is an image (of the album cover, for example). Along with information such as artist and album name, the program you write will be able to extract this image.

The most popular version of the ID3 specification is ID3v2.3. However, if you import a CD in iTunes, iTunes uses the older ID3v2.2 specification. iTunes can read the new specification, and can even convert your MP3 to use any one of 5 different specifications; but, if you don't do anything to your MP3's after you've imported them, you've got ID3v2.2 tags. (At least, this was true for me on both Windows and Mac, and I'm fairly certain I haven't messed with any settings.)

Since specifications have important differences between them, and since it doesn't make sense for you to write a data extractor for each of the 5 available ID3 versions, you're going to write the extractor for the ID3v2.2 specification. This means that if you use iTunes to import CDs, you've already got a bunch of MP3s which you can try this on. If you don't use iTunes (or you don't use the MP3 format), you're not out of luck: I have provided each of you with seven MP3 files to play with. Six of them are copyrighted by major record labels, and to avoid their wrath, I have snipped them down to 30 seconds each. The seventh song ("Happy Together") is performed by a friend of mine's band, and they allow you to download their songs from their MySpace page, so you've got the song in its entirety.

You can find the sample music files in the following directory, where username is your username on the CS machines: /scratch/cs33/username/. The files are located there (instead of via update33) to avoid issues with your quota, and they will be deleted shortly after the assignment is due.

You can work out the details of how to do the extraction by reading the ID3v2.2 reference. But, you'd probably do much better reading my distilled version below.

Your program will be called id3.c. To run your program, you will type ./id3 <filename.mp3> on the command line, substituting <filename.mp3> with the actual name of the MP3 file. When you run the program, you will print out the information in the ID3 tags, skipping over some fields which you won't worry about, and write the image file to the disk for later viewing.

You will need to store lots of variable-sized strings and byte-arrays in this assignment. All strings and arrays should be dynamically allocated. There are almost no cases in the assignment where using a statically allocated array makes sense, and even if you think it does, use a dynamically allocated array anyway (for practice). You won't have to allocate any multi-dimensional arrays.

C reference

You should probably not really read this part now. Instead, skim the titles of the bullet points and come back to them when you're writing your program and need to know how to do one of these things.

Here are the new C things you'll need to know for the lab:

  1. Command-line arguments: So far, whenever we've written the main function, we've specified that it took no parameters. However, main can be written to take two parameters, as follows:
    int main(int argc, char **argv) {
      return 0;
    • argc stores the number of command-line arguments.
    • argv stores each of the arguments. Notice that the type of argv is char **. This is because argv is a dynamically allocated two-dimensional array of characters. Since a string in C is a one-dimensional array of characters, another interpretation is that argv is a one-dimensional array of strings.
    There is always at least one command-line argument since the name of the program you ran is stored in argv[0]. So, if you typed ./id3 Happy_Together.mp3, argc would equal 2, argv[0] would equal ./id3, and argv[1] would equal Happy_Together.mp3

    Your program will exit with an informative error message if the user runs the program with specifying an MP3 file as an argument.

  2. Printing error messages and exiting gracefully: In the previous sentence, I mentioned that you should display an informative error message if the user does not specify an MP3 file as a command-line argument. Throughout the program, you'll do similar things. For example, if the MP3 file contains ID3v2.3 tags, you'll tell the user that your program can only handle ID3v2.2 tags, then exit the program.
    • Normal print statements have their output sent to standard out. However, it is typical for error messages to be sent to standard error. To do this, you need the fprintf function. Here is an example which you should be able to adapt as necessary:
        for (i = 0; i < 5; i++) {
          fprintf(stderr, "Message %d sent to standard error.\n", i);
    • We talked previously about how when your program exits, main typically returns 0 to indicate successful completion. After a program-ending error occurs, you'll want first print an error message to standard error, and then you'll want to exit the program and indicate a failure. To do this, you use the function exit which takes as a parameter the return code you'd like to send. To indicate failure, you'll use exit(1);. Adapting the code above:
        for (i = 0; i < 5; i++) {
          fprintf(stderr, "Message %d sent to standard error.\n", i);
          if (i == 3) {
            exit(1);  /* Exit, indicating a failure */
  3. Reading binary data from a file: To read the ID3 information from the MP3 file, you'll need to use four new functions, and two new data types.
    • The first data type you'll be using is called a file pointer, and you declare it as follows:
        FILE *fptr;    /* fptr is a file pointer */
      The file pointer contains information such as how to find the file on disk, and where in the file the next byte you'll read will come from.
    • You'll be reading individual bytes from the file. There is no "byte" data type in C, but the char type is defined to be only 1 byte, so you can use that to store a byte of data. One problem is that the char type is signed (which means it stores a two's complement value) and you don't want to interpret the bytes you read from the file as signed values. So, you'll use the unsigned char type:
        unsigned char uc;
      You use unsigned characters just as you would regular characters.
    • You'll need to associate the file pointer with a particular file. You'll do this by using the fopen function as follows:
        fptr = fopen(filename, "rb");  /* filename is a string storing the name of a file */
      If fptr equals NULL after calling fopen, there was an error. If that happens, print an informative error message and exit. The string "rb" indicates that the file will be open for reading (r) and that the contents of the file should be interpreted as binary data (b), not text data.
    • You'll need to read data from the file one byte at a time. To do this, you'll use the fgetc function as follows:
        uc = fgetc(fptr);
      If uc returns EOF, you have unexpectedly reached the end of the file. Each time you call fgetc, the file pointer moves one byte forward in the file.
    • A file pointer is a pointer to a dynamically created structure stored on the heap. However, when you're done using a file, you don't use free on the pointer name to deallocate the space. Instead, you use the fclose function:
      The fclose function ensures that the operating system is told that the file is no longer in use, and ensures that if the file was being written to, that the write completes before the memory is freed.
    • To find out how many bytes you are from the start of the file (helpful for knowing when you should stop reading), use the function ftell:
         int position;
         position = ftell(fptr);  /* number of bytes read from the start */
  4. Writing binary data to a file: To write binary data to a file, you'll once again employ file pointers and unsigned chars. You'll also need fopen and fclose, and a new function, fputc. The following code opens two files, one for reading and one for writing. It reads the ten bytes from one file, and writes them to the second file. It carelessly does no error checking.
      unsigned char uc, result;
      FILE *inptr, *outptr;
      int i;
      inptr = fopen("first", "rb");
      outptr = fopen("second", "wb");  /* "w" == "write" */
      for (i = 0; i < 10; i++) {
        uc = fgetc(inptr);
        result = fputc(uc, outptr);  /* if result == EOF, there was an error */

ID3 reference

You should definitely read this part now! And no skimming! There are lots of important details. If you're unclear, read the relevant portion of the ID3v2.2 reference. If you're still unclear, come find me.

ID3v2 header

When you open the MP3 file, the ID3 tag, if it exists, will be at the beginning of the file and will start with the following header of 10 bytes: ID3abcdefg, where ID3 are literally the characters 'I', 'D', and '3'. If the file does not begin with these three letters, it does not contain an ID3v2 tag.

The letters a-g are each one byte (bytes 3-9, counting from 0) and represent the following:

  • a and b (bytes 3 and 4): The major and minor revision of the ID3v2 standard, interpreted as ID3v2.a.b. Unless a is 2, you're not dealing with the ID3v2.2 specification and it's an error.
  • c is used to hold special flags which you won't deal with, so unless c is the byte 0x00, it's an error.
  • d, e, f, and g are used to store the size of the tag (excluding these first 10 bytes you've just read). You'll need to convert these into an integer. For reasons which I think must have to do with international standards, each of d-g use only the lower 7 bits. Concatenating each of these 7 bits together, then converting to decimal, will give you the correct result. For example, if d-g are (in hex): 00 00 02 01, the size of the tag is 257 bytes. That is because we keep only 7 bits from each byte, yielding:
    d        e        f        g
    0x00     0x00     0x02     0x01       (HEX)
    00000000 00000000 00000010 00000001   (BINARY)
    (--> removing topmost bit from each byte)
    _0000000 _0000000 _0000010 _0000001
    (--> rewriting)
        0000 00000000 00000001 00000001  = 256 + 1 = 257
    (HINT: Use left shift and addition. This should take no more than a few lines to convert.)

ID3v2 frames

After the header come a series of frames. Each frame begins with a frame header consisting of a frame name (three characters == three bytes) followed by the size of the frame (three bytes).
  • Unlike in the ID3v2 header, the size field of a frame is interpreted more naturally, with all 8 bits being used. So, if the three size bytes are 00 02 01, the size of the frame is 513 bytes (not counting the 6 bytes in the frame header that you just read).
You will only handle text frames and the PIC (picture) frame. All text frames have a name starting with the letter 'T' (and no frames start with a 'T' unless they are text frames). You will process frames one at a time until either:
  • ...you have read the entire ID3 tag (which you'll know because ftell will tell you how far into the file you are, and the ID3v2 header told you how many bytes to expect), or
  • ...the first byte of the name of a frame is 0x00.

Here is how you will deal with the frames:

  • If you come across a frame that does not start with the letter 'T' or start "PIC", you will skip that frame. In order to skip that frame, you'll need to skip past all of the bytes that make up that frame in order to get to the next frame. To skip a whole bunch of bytes, you can use the fseek function:
      int numbytes = 513;
      fseek(fptr, numbytes, SEEK_CUR);  /* move 513 bytes from the current (SEEK_CUR) location */
  • If you come across a text frame, the first byte (after the header) specifies the encoding, which should always be 0x00 (indicating an encoding called ISO-8859-1). Dealing with other encodings is beyond the scope of this assignment, so, simply check to be sure the byte is 0x00. Following the encoding byte is the text contents of the frame. You'll print out the name of the frame, followed by a colon, followed by a space, followed by the text contents of the frame. For example, in the Happy_Together.mp3 file, the ID3v2 tag has a frame called TP1 which stores the string "The Garden Path", and you'd print out this:
    TP1: The Garden Path
    You should not do any post-processing of the frame data (such as changing TP1 into "Lead Artist" as stated in the ID3v2 specification).
  • If you come across a picture (PIC) frame, you'll dump the contents of the picture into a file.
    1. Like a text frame, the first byte after the header is the encoding; check to be sure it's 0x00.
    2. The next three bytes are the image format, which will be the characters "JPG" or "PNG". Store these characters so that you can use them in the name of the file where you'll save the picture.
    3. The next byte specifies the picture type which you'll just ignore. (See the specification for details if you'd like.)
    4. Next is a description which is a series of bytes terminated with 0x00. You'll need to keep reading bytes from the file until you get to the 0x00. You won't need to store this description. It is highly likely that the description is empty (that is, the very first byte you'll read is 0x00), but you aren't guaranteed that that will be true for every file, so be careful.
    5. Finally, you get to the picture data. Open an output file whose name is derived from the name of the MP3 file and the image format. For example, the image format of the Happy_Together.mp3 file is PNG, so you'll make the output file called "Happy_Together.PNG". To make your life easier, you can assume that all input files end with ".mp3". After you've opened that file, you'll read in all of the remaining bytes of the picture frame one character at a time, then write them out to the output file. (Be sure to close the output file when you're done writing!)

Congratulations, you made it all the way down here! There are no specific functions that you have to write, but I would expect that writing the following functions would be helpful:
  • unsigned char *readBytesAsString(FILE* fp, int b);
    Given a file pointer, fp, and a number of bytes, b, to read in from the file, dynamically allocate a b+1 byte array, read b bytes from the file, and add '\0' to the end of the array, creating a proper C string. Return the pointer to the array.
  • int readBytesAsInteger(FILE* fp, int b);
    Given a file pointer, fp, and a number of bytes, b, to read in from the file, convert these bytes into an integer. This function will get called as part of determining the size of each frame (and b will always equal 3). Do not use it to determine the size of the whole ID3v2 tag, since that has the funny 7-bit rule (see above). Return the size.
You'll probably want a function called readID3 which reads the header and then loops calling a function called readFrame. The readFrame function determines the type of frame and either calls processTextFrame (if it's a text frame), processPictureFrame (if it's a picture frame), or ignores (and skips past) the frame.

When you're done with memory, be sure you free it. When you're done with a file, be sure you fclose it.

NOTE: If you run the program from your cs33/labs/09 directory on an MP3 in the /scratch/cs33/your-username/ directory, the output image will also be saved in the /scratch directory. To verify that your image saved properly, you can run display filename from the command prompt.

To view the actual binary contents of the MP3 file, you can use the program hexedit. For example, this command will open the specified MP3 file:

hexedit Happy_Together.mp3
Use page-up and page-down to look around the file, and Ctrl-C to quit. Despite the message, pressing F1 does not seem to provide Help.