Featured resource
pluralsight tech forecast
2025 Tech Forecast

Which technologies will dominate in 2025? And what skills do you need to keep up?

Check it out
Hamburger Icon
  • Labs icon Lab
  • Core Tech
Labs

Guided: Build a CLI Application to Decode WAV Files in C

In this lab, you will build a Command-Line Interface (CLI) application in C that decodes WAV files to extract and display the metadata of these files. You'll learn how to work with binary files and understand the structure of the WAV file format. Additionally, you'll gain experience in using data structures for storing and manipulating complex data, formatting strings, and handling command-line arguments.

Labs

Path Info

Level
Clock icon Intermediate
Duration
Clock icon 40m
Published
Clock icon Dec 01, 2023

Contact sales

By filling out this form and clicking submit, you acknowledge our privacy policy.

Table of Contents

  1. Challenge

    Introduction

    Welcome to the lab Guided: Build a CLI Application to Decode WAV Files in C++.

    In this lab, you will create a C++ program for extracting metadata from WAV audio files. Through a step-by-step process, you will learn how to read binary file formats, understand the structure of WAV files, handle command-line arguments, and more. ---

    Familiarizing with the Program Structure

    You'll begin with a basic setup and incrementally develop the program.

    There are three main files:

    • WAVUtils.h: This header file contains declarations for processing the WAV file.
    • WAVUtils.cpp: The source file with logic and definitions for processing the WAV file.
    • Main.cpp: The application's entry point, handling command-line arguments and directing program flow. You won't have to modify this file.

    In WAVUtils.h:

    • The RIFF_HEADER and WAVE_HEADER constants will be used to validate WAV files.
    • Examine the OperationStatus enum, which represents various operation outcomes.
    • Also, note the incomplete WAVHeader struct. You will fill this out later with relevant WAV metadata fields.

    Moving to WAVUtils.cpp, you will implement:

    • readWAVHeader for reading and validating the WAV file header.
    • getWAVMetadataAsString to format and return WAV metadata as a string.
    • getFilenameFromArgs to extract the WAV file path from the command-line arguments.

    Finally, there's main.cpp, the program entry point:

    • First, it extracts from the command-line argument the path to the WAV file.
    • It then attempts to process the WAV file.
    • Finally, a switch-case block handles the outcomes from readWAVHeader.

    There are four test files in the wav_files directory:

    • sample.wav: This is a valid WAV file. Running your program with it should display the extracted metadata successfully.
    • truncated_wav_file.wav: Use this file to test the FileReadError scenario. It's designed to simulate a situation where reading the file's data encounters an issue due to the file being incomplete or corrupted.
    • removed_riff_header.wav: This file will help you test the InvalidRIFFHeader condition. It lacks the correct RIFF header, so your program should appropriately detect this and return the corresponding error status.
    • removed_wave_header.wav: This file is for testing the InvalidWAVEHeader condition. It's missing the WAVE header, so your program should identify the issue and report it.

    You can compile and run the current program using the Run button at the bottom-right corner of the Terminal. However, the program won't do anything yet.

    After familiarizing yourself with the setup, you can start diving into the coding process. You'll fill in the missing pieces step by step, and by the end, you'll have a deeper understanding of handling binary files, working with C++ data structures, and more.

    If you find yourself stuck at any point, remember that there's a solution directory available. You can refer to it for guidance or to confirm your approach. However, try to solve the problems on your own first, as this is the best way to learn.

    Let's begin!

  2. Challenge

    Step 1. Creating and Utilizing Data Structures

    The Structure of a WAV File

    A WAV file (Waveform Audio File Format) is a common audio file format used for storing raw, uncompressed audio data. It was developed by Microsoft and IBM. The format is a subset of Microsoft’s Resource Interchange File Format (RIFF) specification, which is used for storing data, in particular, for many types of multimedia files on Windows.

    WAV files are structured in chunks, each serving a specific purpose. For this lab, the header chunk is crucial as it contains metadata about the audio file. Let's explore the key components of a WAV file header:

    1. RIFF Header: The first four bytes of the WAV file are always RIFF in ASCII. This indicates that the file is a Resource Interchange File Format (RIFF) file.

    2. File Size: Following the RIFF header is a 32-bit integer specifying the size of the file. This size doesn't include the first 8 bytes (RIFF header and itself).

    3. WAVE Header: After the file size, the next four bytes are the "WAVE" header, specifying that this is a WAV file.

    4. Format Chunk: This starts with a fmt subchunk identifier followed by a 32-bit chunk size. This chunk contains information like audio format, number of channels, sample rate, byte rate, block align, and bits per sample. It's important to note that for PCM (uncompressed WAV), the audio format is 1.

    5. Data Chunk: This chunk begins with the data subchunk identifier and is followed by a 32-bit size value, indicating the size of the audio data that follows.

    Here's a table that summarizes this information (sample values are given for a 5-second stereo audio clip at a 44100 Hz sample rate and 16 bits per sample): | Positions | Sample Value | Description | |-----------|--------------|-----------------------------------------------------| | 1 - 4 | "RIFF" | It's always "RIFF" | | 5 - 8 | 882,036 | Size of the overall file in bytes (32-bit integer) | | 9 -12 | "WAVE" | File type, it's always "WAVE" | | 13-16 | "fmt " | Format chunk marker | | 17-20 | 16 | Length of format data | | 21-22 | 1 | Format type | | 23-24 | 2 | Number of channels | | 25-28 | 44100 | Sample Rate, the number of samples per second | | 29-32 | 176400 | (Sample Rate * BitsPerSample * Channels) / 8 | | 33-34 | 4 | (BitsPerSample * Channels) / 8 | | 35-36 | 16 | Bits per sample | | 37-40 | "data" | Marks the beginning of the data section | | 41-44 | 882,000 | Size of the data section |

    In the WAVHeader struct, you need to accurately represent these elements. The structure will start with the RIFF header, followed by the file size, WAVE header, and so on, as per the WAV format specification.

    But, what exactly is a struct? ---

    What is a Struct?

    In C++, a struct is a way to group different variables (which can be of different types) under a single name. Structs are similar to classes in that they can contain data members and member functions, but they default to public access instead of private.

    You can define a struct using the struct keyword, followed by the struct name and a block of code defining its variables. For example, if you want to represent a student in a program, you might use a struct to hold their name, ID number, and grade:

    struct Student {
        std::string name;
        int id;
        float grade;
    };
    

    You can create an instance of this struct and access its individual elements using the dot operator:

    Student student1;
    student1.name = "Alice";
    student1.id = 1;
    student1.grade = 89.5;
    

    Alternatively, you can initialize a struct using curly braces ({}) with values for its members in the order they are declared. Here's an example:

    Student s = {"John Doe", 2, 88.5f};
    

    In this example:

    • "John Doe" initializes the name member.
    • 2 initializes the id member.
    • 88.5f initializes the grade member.
  3. Challenge

    Step 2. Opening a Binary File

    Opening a Binary File with std::ifstream

    In C++, std::ifstream (input file stream) is used to read data from files. To use it, first of all, you need to include the <fstream> header in your program. This header contains the declarations for the file stream classes:

    #include <fstream>
    

    Next, you need to create an instance of std::ifstream and open the file using the constructor or the open method. However, when dealing with binary files like WAV files, it's important to open the file in binary mode by specifying the std::ios::binary flag. This ensures that the file is read as a binary stream, preserving the binary data format.

    Here's an example using the open method to open a WAV file:

    std::ifstream file;
    file.open("example.wav", std::ios::binary);
    

    And here's an example using the constructor:

    std::ifstream file("example.wav", std::ios::binary);
    

    It could be the case that the file you're trying to open doesn't exist, so it's a good practice to always check if the file has been successfully opened. This can be done using the is_open() method:

    if (!file.is_open()) {
    // Handle error, such as file not found
    }
    

    However, when you use the std::ifstream constructor to open a file, this can be also done by checking the state of the file stream. If the file couldn't be opened, the stream's state is set to fail, which can be checked using an if statement:

    if (!file) {
        // Handle file open error
    }
    

    Finally, after reading the file (which will be implemented in the next step), you can explicit call to the method close() to close the file:

    file.close();
    

    However, this is optional due to the C++ support for RAII (Resource Acquisition Is Initialization). RAII ensures resource management (like memory, file handles, etc.) is tied to the lifetime of objects. When an std::ifstream object is destroyed, which happens automatically when it goes out of scope, its destructor is called. This destructor takes care of releasing resources, including closing the file that the stream is associated with.

  4. Challenge

    Step 3. Reading the WAV File Metadata

    Reading the WAV Header

    To read data from a file, use the read function, a member of std::ifstream:

    istream& read(char* s, streamsize n)
    

    This function needs two things:

    • s: A pointer to a buffer where the data will be stored.
    • n: The number of bytes to read. In other words, the size of the data to read.

    It returns a reference to the stream (std::istream&). If the read fails (due to reaching the end of the file or an error), the failbit is set.

    Here's an example where read is used to extract 10 bytes from example.bin and store them in buffer:

    char buffer[10];
    std::ifstream file("example.bin", std::ios::binary);
    file.read(buffer, 10);
    if (!file) {
      // Handle read error or end of file
    }
    

    Notice that the read function expects a char* (a pointer to a character array), however, you want to store the data using another type, a struct. To resolve this mismatch, you can use reinterpret_cast.

    reinterpret_cast is used to convert any pointer type to any other pointer type, including unrelated types. It tells the compiler to treat a sequence of bits (the object representation) as if it had the new type. In this case, reinterpret_cast is used to interpret the bytes read from the file as a WAVHeader struct.

    This works because under the hood, a pointer value is just a memory address. reinterpret_cast doesn't provide any type of checking or adjustment and doesn't change the actual content of the variable; it just reinterprets the binary data of the variable as another type.

    Consider this example where you have a struct MyStruct and you need to read data into it using the read function:

    struct MyStruct {
        // Structure fields
    };
    
    MyStruct data;
    std::ifstream file("example.bin", std::ios::binary);
    
    if (!file.read(reinterpret_cast<char*>(&data), sizeof(MyStruct))) {
        // Handle read error
    }
    

    In this code:

    • MyStruct data; declares a variable of type MyStruct.
    • reinterpret_cast<char*>(&data) is used to cast the address of data to a char*.
    • sizeof(MyStruct) determines the number of bytes to read, which is the size of MyStruct.
  5. Challenge

    Step 4. Validating the WAV Header

    Validating the Header Values

    Once you've read the header, it's important to check if it's valid. Imagine if you didn't and tried to process an invalid WAV file. Best case, your program just wouldn't work correctly. Worst case, it could crash or produce incorrect results. By verifying the file format upfront, you ensure that your program behaves reliably and can handle invalid input gracefully.

    For example, you can check if certain headers contain the correct value according to the specification. There are many ways to do this, depending on your specific needs and preferences.

    1. Using strncmp:

    strncmp is a function that compares up to a specified number of characters between two C-style strings (null-terminated character array). It takes three parameters: the first two are pointers to the strings to be compared, and the third is the maximum number of characters to compare. The function returns 0 if the compared characters match or a non-zero value otherwise. This method is particularly efficient for fixed-length strings:

    #include <cstring>
    #include <string>
    
    // ...
    
    const std::string EXPECTED = "Expected";
    
    // strncmp compares the first 4 characters of header.firstField with EXPECTED
    if (strncmp(header.firstField.data(), EXPECTED.c_str(), 4) != 0) {
        // Handle error if they don't match
    }
    

    2. Comparison Using std::string:

    This approach involves creating std::string objects from arrays or buffers and using the == operator for comparison. The == operator checks if two std::string objects are identical (i.e., have the same length and characters). This method is more readable and intuitive for those familiar with std::string, but it has a slight performance overhead due to the creation of a temporary std::string object:

    #include <string>
    
    // ...
    
    const std::string EXPECTED = "Expected";
    
    // Creating a string from header.firstField and comparing it with EXPECTED
    if (std::string(header.firstField.begin(), header.firstField.end()) != EXPECTED) {
        // Handle error if they don't match
    }
    

    3. Direct Array Comparison with std::equal:

    std::equal is used to compare the elements of two sequences (e.g., arrays, containers). It takes two pairs of iterators as parameters, which define the start and end of each sequence. It returns true if all corresponding elements in the sequences are equal. This method is efficient for fixed-size sequences:

    #include <algorithm>
    #include <string>
    
    // ...
    
    const std::string EXPECTED = "Expected";
    
    // Using std::equal to compare elements in header.firstField with EXPECTED
    if (!std::equal(header.firstField.begin(), header.firstField.end(), EXPECTED.begin())) {
        // Handle error if they don't match
    }
    

    4. Using std::memcmp:

    std::memcmp compares two blocks of memory byte-by-byte. It is particularly useful for binary data comparison. The function takes three parameters: the pointers to the two memory blocks to compare and the number of bytes to compare. The function returns 0 if the memory blocks are identical for the specified number of bytes or a non-zero value otherwise:

    #include <cstring>
    #include <string>
    
    // ...
    
    const std::string EXPECTED = "Expected";
    
    // std::memcmp compares the memory of the first 4 bytes in header.firstField with EXPECTED
    if (std::memcmp(header.firstField.data(), EXPECTED.c_str(), 4) != 0) {
        // Handle error if they don't match
    }
    
  6. Challenge

    Step 5. Displaying Metadata

    Using std::stringstream for String Formatting

    std::stringstream is a stream class provided by the C++ Standard Library, included in the <sstream> header. It's a powerful tool for string manipulation and formatting. Essentially, it allows you to treat a string like a stream, just like you would with file streams, but with the flexibility and ease of use that comes with working with strings in memory.

    Using std::stringstream for string formatting, especially in cases where you need to build a string from various data types, offers several advantages:

    1. Ease of Concatenation: It simplifies the process of concatenating strings and other data types. You can insert integers, floating-point numbers, and other types into the stream without needing to convert them first to strings.

    2. Improved Readability and Maintenance: It keeps your code clean and readable. Instead of using a series of + operators for concatenation, which can get messy and hard to read, stringstream provides a more streamlined approach.

    3. Efficient Memory Management: stringstream is often more efficient in handling memory allocations, especially when dealing with multiple concatenations. When you use + to concatenate strings, each operation can potentially result in a new string being created, which is less efficient.

    Once you're done formatting your string in the stringstream, you extract the final string using the str() method. This method returns a std::string containing the current contents of the stream.

    Consider the following code snippet:

    #include <sstream>
    
    // ...
    
    std::stringstream ss;
    ss << "Info 1: " << var1 << "\n";
    ss << "Info 2: " << var2 << "\n";
    // ... other string formatting
    
    std::string formattedStr = ss.str();
    

    In this example, ss acts as a stream into which you insert pieces of data directly without needing to convert them to strings. In the end, ss.str() gives you the formatted string. As you can see, this results in cleaner, more maintainable code compared to traditional string concatenation methods.

  7. Challenge

    Step 6. Handling Command-Line Arguments

    Understanding Command-Line Arguments in C++

    When you run a program from the command line, you can pass additional information to it, such as input files or configuration options, in the form of arguments:

    ./myprogram arg1 arg2
    

    In C++, command-line arguments are made accessible to the program via two parameters the main function takes, int argc and char* argv[]:

    • argc (Argument Count): This is an integer that represents the number of command-line arguments passed to the program. The count includes the name of the program itself, so if you run ./myprogram input.wav, argc will be 2.

    • argv (Argument Vector): This is an array of C-style strings (char*) representing the actual arguments. argv[0] is always the name of the program as it was invoked, and argv[1], argv[2], etc., are the subsequent arguments passed. For the previous example, argv[0] would be ./myprogram, and argv[1] would be input.wav.

    This way, if you want to extract the value of the first argument, you can do it by accessing argv[1]. However, it's important to first check that this argument exists. This is where argc becomes useful. If argc is 2, you can safely use argv[1]. By checking the value of argc, you can determine if the expected number of arguments has been passed to the program and handle cases where arguments are missing.

  8. Challenge

    Conclusion

    Congratulations on completing this Code Lab!

    To compile and run the program, click the Run button located at the bottom-right of the Terminal.

    This button will compile your Main.cpp, WAVUtils.cpp, and WAVUtils.h files into an executable named WAVDecoder. After compilation, the program will automatically run with the following command:

    ./WAVDecoder wav_files/sample.wav
    

    Here, ./WAVDecoder is your executable, and wav_files/sample.wav is the path to a sample WAV file provided for testing.

    Remember that you have other three WAV files in the wav_files directory to test the program:

    • truncated_wav_file.wav: Use this file to test the FileReadError scenario.
    • removed_riff_header.wav: This file will help you test the InvalidRIFFHeader condition.
    • removed_wave_header.wav*: Similarly, this file is for testing the InvalidWAVEHeader. ---

    Extending the Program

    Here are some ideas to expand the capabilities of the program for further learning.

    • Additional Checks: For example, you can add checks for the fmt header. Additionally, if the audio format is PCM (Pulse-Code Modulation), the fmt_chunk_size should be 16, as this is the standard size for PCM format. More information, along with links to additional resources, can be found on the Wikipedia page for the WAV Format.

    • Support for Additional Information: Your current program focuses on basic WAV metadata. How about extending it to extract and display more detailed information? For example, you could include the 'data' chunk size. Additionally, for the audio format, instead of just showing OTHER, you can display the name of other common audio formats, such as:

      • IEEE Float: Represented by the value 3, this format is used for floating-point samples.
      • ALAW: Represented by the value 6, this is an audio compression format.
      • Mu-Law (µ-Law): Represented by the value 7, it's another compression format used in telephony.
      • GSM 6.10: Represented by the value 49, this is a standard for audio compression in telephony.
    • Batch Processing: Enhance the program to handle multiple files at once. This could involve processing an entire directory of WAV files, which would be a great exercise in working with filesystems in C++.

    • Interactive Command-Line Options: Expand the command-line functionality to include interactive options, like a help command, or options to specify what metadata to display. This would involve more advanced parsing of command-line arguments and would make the program more flexible and user-friendly.

    • Error Correction and Recovery: Implement features to correct or recover from common errors in WAV files, such as incorrect header information or minor corruptions in the data.


    Related Courses on Pluralsight's Library

    If you're interested in further honing your C++ skills or exploring more topics, Pluralsight offers several excellent courses in the following paths:

    These courses cover many aspects of C++ programming. I encourage you to check them out to continue your learning journey in C++.

Esteban Herrera has more than twelve years of experience in the software development industry. Having worked in many roles and projects, he has found his passion in programming with Java and JavaScript. Nowadays, he spends all his time learning new things, writing articles, teaching programming, and enjoying his kids.

What's a lab?

Hands-on Labs are real environments created by industry experts to help you learn. These environments help you gain knowledge and experience, practice without compromising your system, test without risk, destroy without fear, and let you learn from your mistakes. Hands-on Labs: practice your skills before delivering in the real world.

Provided environment for hands-on practice

We will provide the credentials and environment necessary for you to practice right within your browser.

Guided walkthrough

Follow along with the author’s guided walkthrough and build something new in your provided environment!

Did you know?

On average, you retain 75% more of your learning if you get time for practice.