3
\$\begingroup\$

I'm a mechanical engineer/amateur programmer trying to learn modern C++. I'm working on a personal project where I'm building a library that uses PortAudio to abstract some basic audio processing, specifically pitch and volume extraction from microphone input as a way to facilitate that learning. I'm as interested in the overall design of a library structure (with the goals of being maintainable, extensible, and easy to use and understand) as I am with the specifics of what the code is doing.

The overall idea of the library (PitchHound) is that the client code will:

  1. Create a configuration object that specifies various sampling parameters, and contains a smart pointer to a subclass of a processing object that will implement one of many different algorithms for extracting pitch from a sound wave (FFT, autocorrelation, cepstral, etc...)

  2. Instantiate a PitchHound object passing the configuration object to the constructor. The PitchHound object spawns a background thread that reads from a PortAudio stream, uses the processing object to extract the dominant pitch(es), and then store them in a std::vector protected by a mutex.

  3. The client code will have to periodically call an update() method on the PitchHound object that allows it to check if there is data ready from the background thread. If there is, the PitchHound object will call a callback function, or series of callback functions, that effectively pass data back to client code.

Here are some of my main questions:

Using callbacks instead of directly passing back the data

The intended use of the library is for games and game-like research tools that extract pitch and volume information from microphones. One of the things I'd like to be able to do is to allow the client code to configure the PitchHound object to behave in different ways. For example, maybe the client code simply wants an event to be triggered when a particular pitch is detected, or when the overall volume of a frame is above a certain threshold. Or maybe the client wants two or more of these things. It seemed easier and cleaner to allow the main object to pass the thread of execution back to one or several different points in client code from a single update() method than to force the client to call and check the results of several different methods.

Does that make sense? Are there any serious disadvantages to that decision?

Configuration object and PitchHound constructor

The configuration object has a smart pointer to a processing object for the PitchHound object to use to extract the pitch. In the constructor of the PitchHound object, the configuration object specified by the pointer gets copied into an internal field of the class for the background thread to access. I have a couple of questions about this.

Does it make sense? Should I be passing and storing the object, or should I be passing and storing a pointer to the config object? I assume that as its written the PitchHound object is making a copy of the config object, because using a unique_ptr instead of a shared_ptr fails to compile. Is there a best practice for a situation like this?

PitchHound destructor closes thread

The destructor for the PitchHound object is where I attempt to shut down and join the background thread. Is that a good idea? Should it be done in a different place, or a different way?

Additionally, the rule of three (or five) suggests that in overriding the constructor and destructor I should be writing assignment operators. Is that applicable here because of the thread? What would an assignment operator need to look like for this object?

Use of processing abstract class and implementation specific subclasses

Using the abstract class thing with different subclasses for different pitch detection algorithms is similar to what I would do in C# with an interface. Does this make sense in C++? Is this overkill, would I be better off using a function pointer or some sort of enum and conditional branching? Assuming that I wanted to make it easy to add new methods of pitch detection, and potentially even have the client be able to write their own pitch detection algorithm external to the library, is this a good way to do it?

Ok, that's all I can think of for now. Any and all criticism and coaching is extremely welcome and appreciated. Thanks in advance.

Header file:

#ifndef PITCH_HOUND_PITCHHOUND_H
#define PITCH_HOUND_PITCHHOUND_H

#include <thread>
#include <atomic>
#include <vector>
#include <mutex>
#include "portaudio.h"

/*! PitchResult object stores a single result from the pitch extraction
 * that contains both the detected peak and the energy in that peak
 */
struct PitchResult
{
    float peak;
    float energy;
};

/*! Abstract class PitchProcessor, to be inherited by classes that implement
 * different methods of extracting pitch from raw signal data, such as the
 * Fast Fourier Transform, Autocorrelation, and Cepstral analysis.
 */
class PitchProcessor
{
public:
    virtual std::vector<PitchResult> process(float[], size_t) = 0;
};

/*! Fast Fourier transform pitch extraction object.  Initantiated with a
 *  the number of bins and returns up to the n-largest peaks by energy as
 *  specified by resultCount
 */
class FftProcessor : public PitchProcessor
{
public:
    FftProcessor(size_t fftBins, size_t resultCount);
    virtual std::vector<PitchResult> process(float[], size_t) override;

private:
    size_t fftSize;
    size_t peakCount;
};

/*! Configuration struct, stores the sample rate, the sample size (and thus the
 * sampling frequency), and the PitchProcessor object to perform the actual
 * extraction of the highest energy frequencies.
 */
struct PitchHoundConfig
{
    size_t sampleRate;
    size_t sampleSize;
    std::shared_ptr<PitchProcessor> processor;
};

/*! The PitchHound object is the main working object.  It spawns a method that runs
 * on a background thread that uses blocking input from PortAudio to extract data from
 * a microphone device.  The background thread performs the extraction of pitch
 * information from the sound data by using a subclass of PitchProcessor.  The user of
 * the class must use the update() method periodically to check for available data,
 * at which time the PitchHound object will return data through callback functions.
 */
class PitchHound
{
public:

    bool isRunning() { return (bool)isActive; }
    void start();
    std::function<void(std::vector<PitchResult>&)> dataCallback;

    PitchHound(PitchHoundConfig&);
    ~PitchHound();

    void update();

private:
    PitchHoundConfig config;

    std::atomic<bool> terminate;
    std::atomic<bool> isActive;
    std::atomic<bool> isDataReady;
    std::mutex resultGuard;

    std::thread backgroundThread;
    std::vector<PitchResult> results;

    void backgroundWork();
};

#endif //PITCH_HOUND_PITCHHOUND_H

Implementation file:

#include "PitchHound.h"
#include <thread>
#include <iostream>
#include <cmath>

/*! Class constructor
 *
 * @param cfg - PitchHoundConfig object containing the sample and processing info
 */
PitchHound::PitchHound(PitchHoundConfig &cfg)
{
    terminate = false;
    config = cfg;
}

/*! Destructor attempts to terminate and join the background thread
 */
PitchHound::~PitchHound()
{
    terminate = true;
    backgroundThread.join();
}

/*! Starts the background thread which handles the audio input and processing
 */
void PitchHound::start()
{
    isActive = true;
    backgroundThread = std::thread(&PitchHound::backgroundWork, this);
}

/*! An update method intended to be called at semi-regular intervals by the client
 * code and on the same thread that the client code wants execution directed to when
 * various events happen, such as data being ready, or a certain intensity being
 * reached, or similar.  In a game, for example, this should be called in the game loop.
 */
void PitchHound::update()
{
    if (isDataReady)
    {
        resultGuard.lock();
        auto resultCopy = std::vector<PitchResult>(results);
        resultGuard.unlock();

        isDataReady = false;
        if (dataCallback != nullptr)
        {
            dataCallback(resultCopy);
        }
    }
}

/*! This method runs on the background thread.  It opens a PortAudio stream, then
 * uses blocking input to read from the stream at regular intervals and process the
 * resulting data.
 */
void PitchHound::backgroundWork()
{
    float data[config.sampleSize];

    // Attempt to initialize PortAudio.  If it fails, terminate the background worker.
    if (Pa_Initialize() != paNoError)
    {
        isActive = false;
        return;
    }

    // Prepare the input parameters
    PaStreamParameters inputParameters;
    inputParameters.device = Pa_GetDefaultInputDevice();
    inputParameters.channelCount = 1;
    inputParameters.sampleFormat = paFloat32;
    inputParameters.suggestedLatency = Pa_GetDeviceInfo( inputParameters.device )->defaultHighInputLatency ;
    inputParameters.hostApiSpecificStreamInfo = NULL;

    // Define the stream
    PaStream *stream = NULL;

    // Open the stream
    auto openStreamError = Pa_OpenStream(&stream, &inputParameters, NULL, config.sampleRate, config.sampleSize, paClipOff, NULL, NULL );

    if (openStreamError == paNoError)
    {
        // Start the stream
        Pa_StartStream(stream);

        while (!terminate)
        {
            // Pa_ReadStream blocks until the data buffer is filled
            if (Pa_ReadStream(stream, data, config.sampleSize ) == paNoError)
            {
                // Process the results and store them to the results field.  Should this
                resultGuard.lock();
                results = config.processor->process(data, config.sampleSize);
                resultGuard.unlock();

                isDataReady = true;
            }
            else
                break;
        }

        //
        Pa_StopStream(stream);
        Pa_CloseStream(stream);
    }

    isActive = false;
    // Clean up the PortAudio environment
    Pa_Terminate();

}

// ================================================================================

/*! Constructor for the FftProcessor
 *
 * @param fftBins - the number of bins in the FFT, should be a power of 2
 * @param resultCount - the max number of highest energy peaks to return
 */
FftProcessor::FftProcessor(size_t fftBins, size_t resultCount)
{
    fftSize = fftBins;
    peakCount = resultCount;
}

/*! Processes the raw data from the sound card to extract the highest energy
 * frequencies and return them as a std::vector.
 *
 * @param data - pointer to an array of floating point values from the sound card
 * @param nSamples - number of samples in data
 * @return a std::vector of the highest energy frequencies
 */
std::vector<PitchResult> FftProcessor::process(float *data, size_t nSamples)
{
    std::vector<PitchResult> results;

    // The implementation of a simple FFT pitch extraction algorithm goes here,
    // and fills the result vector with up to a certain number of dominant frequencies,
    // then returns it.

    return results;
}

Sample usage:

#include <iostream>
#include "PitchHound.h"

void dataIsReady(std::vector<PitchResult> &values)
{
    // Do something with the data here
}

int main() 
{
    // Prepare the configuration object
    PitchHoundConfig config;
    config.sampleSize = 2048;
    config.sampleRate = 22050;

    // Is this the right way to do this?
    config.processor.reset(new FftProcessor(config.sampleSize, 10));


    // Prepare the main PitchHound object and set the callback
    PitchHound audio(config);
    audio.dataCallback = dataIsReady;

    // Start the background thread
    audio.start();

    while (audio.isRunning())
    {
        // Imagine that this is a game or processing loop of some sort
        // Do some stuff here

        // Update the PitchHound object and let it trigger a callback
        // if it's has data
        audio.update();
    }

    return 0;
}
\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Browse other questions tagged or ask your own question.