Table of Contents

Wwise SDK 2019.2.11
Developing a Lowpass Filter Plug-in

Creating a New Project

First, we need to create a new plug-in project with wp.py tools. See Creating a new Plug-in for more details about new arguments.

python "%WWISEROOT%/Scripts/Build/Plugins/wp.py" new --effect -a author_name -n Lowpass -t "First Order Lowpass" -d "Simple First Order Lowpass Filter"
cd Lowpass

We will target the Authoring platform on Windows, so let's call premake:

python "%WWISEROOT%/Scripts/Build/Plugins/wp.py" premake Authoring

Solutions have been created for building the SoundEngine and the Authoring (WwisePlugin) parts.

We can now build our plug-in and confirm that it loads in Wwise.

python "%WWISEROOT%/Scripts/Build/Plugins/wp.py" build -c Release -x x64_vc160 Authoring

Implementing the Filtering Process

Now, we want to add some processing to make our plug-in a little bit more useful. We will implement a simple first order lowpass filter, using this equation:

y[n] = x[n] + (y[n-1] - x[n]) * coeff

where coeff is a floating-point value between 0 and 1.

First, let's create a couple of variables in SoundEnginePlugin/LowpassFX.h to hold our filter's data.

#include <vector>
//...
private:
//...
AkReal32 m_coeff;
std::vector<AkReal32> m_previousOutput;

The variable m_coeff is our filter coefficient as a floating-point value, it will be used for all sound channels. The vector m_previousOutput will hold the last output value of all channels, mandatory to compute the next values of the filter.

To implement the filtering effect, all we have to do is to initialize the coefficient variable, adjust the size of the vector according to the number of channels and then process every sample with the previous formula.

In SoundEnginePlugin/LowpassFX.cpp:

LowpassFX::LowpassFX()
: //...
, m_coeff(0.99f)
//...
AKRESULT LowpassFX::Init(AK::IAkPluginMemAlloc* in_pAllocator, AK::IAkEffectPluginContext* in_pContext, AK::IAkPluginParam* in_pParams, AkAudioFormat& in_rFormat)
{
//...
m_previousOutput.resize(in_rFormat.GetNumChannels(), 0.0f);
//...
}
void LowpassFX::Execute(AkAudioBuffer* io_pBuffer)
{
const AkUInt32 uNumChannels = io_pBuffer->NumChannels();
AkUInt16 uFramesProcessed;
for (AkUInt32 i = 0; i < uNumChannels; ++i)
{
uFramesProcessed = 0;
while (uFramesProcessed < io_pBuffer->uValidFrames)
{
// Apply lowpass filtering per channel.
m_previousOutput[i] = pBuf[uFramesProcessed] =
pBuf[uFramesProcessed] + (m_previousOutput[i] - pBuf[uFramesProcessed]) * m_coeff;
++uFramesProcessed;
}
}
}

Using an RTPC Parameter to Control the Filter's Frequency

At this point, our filter is pretty boring because there is no way to interact with it. The next step is to bind an RTPC parameter to the filter's frequency so that we can change its value in real time. There are four changes to make to allow our plug-in to use an RTPC parameter.

First, we must add its definition in WwisePlugin/Lowpass.xml. There is already a parameter skeleton in the plug-in template. We will use it to define a "Frequency" parameter. In WwisePlugin/Lowpass.xml, replace the placeholder property with this:

<Property Name="Frequency" Type="Real32" SupportRTPCType="Exclusive" DisplayName="Cutoff Frequency">
<UserInterface Step="0.1" Fine="0.001" Decimals="3" UIMax="10000" />
<DefaultValue>1000.0</DefaultValue>
<AudioEnginePropertyID>0</AudioEnginePropertyID>
<Restrictions>
<ValueRestriction>
<Range Type="Real32">
<Min>20.0</Min>
<Max>10000.0</Max>
</Range>
</ValueRestriction>
</Restrictions>
</Property>

Second, we need to update LowpassFXParams.h and LowpassFXParams.cpp in the SoundEnginePlugin folder to reflect our property changes.

In LowpassFXParams.h, update the parameter IDs and the name of the parameter in the LowpassRTPCParams structure.

static const AkPluginParamID PARAM_FREQUENCY_ID = 0;
struct LowpassRTPCParams
{
AkReal32 fFrequency;
};

Update LowpassFXParams.cpp as well:

AKRESULT LowpassFXParams::Init(AK::IAkPluginMemAlloc* in_pAllocator, const void* in_pParamsBlock, AkUInt32 in_ulBlockSize)
{
if (in_ulBlockSize == 0)
{
// Initialize default parameters here
RTPC.fFrequency = 1000.0f;
//...
}
//...
}
AKRESULT LowpassFXParams::SetParamsBlock(const void* in_pParamsBlock, AkUInt32 in_ulBlockSize)
{
//...
// Read bank data here
RTPC.fFrequency = READBANKDATA(AkReal32, pParamsBlock, in_ulBlockSize);
//...
}
AKRESULT LowpassFXParams::SetParam(AkPluginParamID in_paramID, const void* in_pValue, AkUInt32 in_ulParamSize)
{
//...
// Handle parameter change here
case PARAM_FREQUENCY_ID:
RTPC.fFrequency = *((AkReal32*)in_pValue);
//...
}

Third, in the WwisePlugin folder, we need to update the Lowpass::GetBankParameters function to write the "Frequency" parameter in the bank:

bool LowpassPlugin::GetBankParameters(const GUID& in_guidPlatform, AK::Wwise::IWriteData* in_pDataWriter) const
{
// Write bank data here
CComVariant varProp;
m_pPSet->GetValue(in_guidPlatform, L"Frequency", varProp);
in_pDataWriter->WriteReal32(varProp.fltVal);
return true;
}

Finally, in our processing loop, we want to use the current frequency to compute the filter's coefficient with this formula:

coeff = exp(-2 * pi * f / sr)

We need to retrieve the current sampling rate,

// LowpassFX.h
private:
//...
AkUInt32 m_sampleRate;
// LowpassFX.cpp
{
//...
m_sampleRate = in_rFormat.uSampleRate;
//...
}

include some math symbols:

// LowpassFX.cpp
#include <cmath>
#ifndef M_PI
#define M_PI 3.14159265359
#endif
//...

and compute the filter coefficient:

// LowpassFX.cpp
void LowpassFX::Execute(AkAudioBuffer* io_pBuffer)
{
m_coeff = static_cast<AkReal32>(exp(-2.0 * M_PI * m_pParams->RTPC.fFrequency / m_sampleRate));
//...
}

Interpolating Parameter Values

It is often not enough to update a processing parameter once per buffer size (the number of samples in an audio buffer channel, usually between 64 and 2048). Especially if this parameter affects the frequency or the gain of the processing; updating the value too slowly can produce zipper noise or clicks in the output sound.

A simple solution to this problem is to linearly interpolate the value over the whole buffer. Here is how we can do this for our frequency parameter.

Just before computing a new frame of audio samples, i.e., at the top of the Execute function in LowpassFX.cpp, we will check if the frequency parameter has changed. To do so, we just ask the AkFXParameterChangeHandler object in the LowpassFXParams class. If the frequency has changed, we compute the variables of the ramp:

Note: The member variable uValidFrames of the AkAudioBuffer object represents the number of valid samples per channel contained in the buffer.
void LowpassFX::Execute(AkAudioBuffer* io_pBuffer)
{
AkReal32 coeffBegin = m_coeff, coeffEnd = 0.0f, coeffStep = 0.0f;
if (m_pParams->m_paramChangeHandler.HasChanged(PARAM_FREQUENCY_ID))
{
coeffEnd = static_cast<AkReal32>(exp(-2.0 * M_PI * m_pParams->RTPC.fFrequency / m_sampleRate));
coeffStep = (coeffEnd - coeffBegin) / io_pBuffer->uValidFrames;
}
//...
}

With this data, all we have to do is to increase coeffBegin by coeffStep for each sample of the frame. We need to do this for each channel of the in/out buffer.

void LowpassFX::Execute(AkAudioBuffer* io_pBuffer)
{
//...
for (AkUInt32 i = 0; i < uNumChannels; ++i)
{
coeffBegin = m_coeff; // restart the ramp.
uFramesProcessed = 0;
while (uFramesProcessed < io_pBuffer->uValidFrames)
{
m_previousOutput[i] = pBuf[uFramesProcessed] =
pBuf[uFramesProcessed] + (m_previousOutput[i] - pBuf[uFramesProcessed]) * coeffBegin;
coeffBegin += coeffStep; // increase by coeffStep every sample.
++uFramesProcessed;
}
}
m_coeff = coeffBegin; // save the current value for the next frame.
}

Now that we have a basic functional plug-in implementing a simple lowpass filter with real-time control over the cutoff frequency, let's talk about design concerns.

Encapsulating the Processing

At this point, all our signal processing logic is written inside the plug-in main class. This is not a good design pattern for many reasons:

  • It's bloating our main plug-in class, and it will quickly become worse as we add new processing to build a complex effect.
  • It will be hard to reuse our filter if we need it for another plug-in, and, especially with this kind of basic processing unit, it's going to happen!
  • It does not respect the single responsibility principle.

Let's refactor our code to encapsulate the filter processing in its own class. Create a file FirstOrderLowpass.h in the SoundEnginePlugin folder with this defintion:

// FirstOrderLowpass.h
#pragma once
class FirstOrderLowpass
{
public:
FirstOrderLowpass();
~FirstOrderLowpass();
void Setup(AkUInt32 in_sampleRate);
void SetFrequency(AkReal32 in_newFrequency);
void Execute(AkReal32* io_pBuffer, AkUInt16 in_uValidFrames);
private:
AkUInt32 m_sampleRate;
AkReal32 m_frequency;
AkReal32 m_coeff;
AkReal32 m_previousOutput;
bool m_frequencyChanged;
};

And add the implementation in a file called FirstOrderLowpass.cpp:

// FirstOrderLowpass.cpp
#include "FirstOrderLowpass.h"
#include <cmath>
#ifndef M_PI
#define M_PI 3.14159265359
#endif
FirstOrderLowpass::FirstOrderLowpass()
: m_sampleRate(0)
, m_frequency(0.0f)
, m_coeff(0.0f)
, m_previousOutput(0.0f)
, m_frequencyChanged(false)
{
}
FirstOrderLowpass::~FirstOrderLowpass() {}
void FirstOrderLowpass::Setup(AkUInt32 in_sampleRate)
{
m_sampleRate = in_sampleRate;
}
void FirstOrderLowpass::SetFrequency(AkReal32 in_newFrequency)
{
if (m_sampleRate > 0)
{
m_frequency = in_newFrequency;
m_frequencyChanged = true;
}
}
void FirstOrderLowpass::Execute(AkReal32* io_pBuffer, AkUInt16 in_uValidFrames)
{
AkReal32 coeffBegin = m_coeff, coeffEnd = 0.0f, coeffStep = 0.0f;
if (m_frequencyChanged)
{
coeffEnd = static_cast<AkReal32>(exp(-2.0 * M_PI * m_frequency / m_sampleRate));
coeffStep = (coeffEnd - coeffBegin) / in_uValidFrames;
m_frequencyChanged = false;
}
AkUInt16 uFramesProcessed = 0;
while (uFramesProcessed < in_uValidFrames)
{
m_previousOutput = io_pBuffer[uFramesProcessed] =
io_pBuffer[uFramesProcessed] + (m_previousOutput - io_pBuffer[uFramesProcessed]) * coeffBegin;
coeffBegin += coeffStep;
++uFramesProcessed;
}
m_coeff = coeffBegin;
}

Then, all we have to do in our main plug-in class is to create a vector of FirstOrderLowpass objects (one per audio channel), call their Setup function and start using them.

// LowpassFX.h
#include "FirstOrderLowpass.h"
#include <vector>
//...
private:
std::vector<FirstOrderLowpass> m_filter;
// LowpassFX.cpp
{
//...
m_filter.resize(in_rFormat.GetNumChannels());
for (auto & filterChannel : m_filter) { filterChannel.Setup(in_rFormat.uSampleRate); }
//...
}
void LowpassFX::Execute(AkAudioBuffer* io_pBuffer)
{
if (m_pParams->m_paramChangeHandler.HasChanged(PARAM_FREQUENCY_ID))
{
for (auto & filterChannel : m_filter) { filterChannel.SetFrequency(m_pParams->RTPC.fFrequency); }
}
const AkUInt32 uNumChannels = io_pBuffer->NumChannels();
for (AkUInt32 i = 0; i < uNumChannels; ++i)
{
m_filter[i].Execute(pBuf, io_pBuffer->uValidFrames);
}
}
AkSampleType * GetChannel(AkUInt32 in_uIndex)
Definition: AkCommonDefs.h:372
Audiokinetic namespace.
#define AK_RESTRICT
Refers to the __restrict compilation flag available on some platforms.
Definition: AkTypes.h:63
AkForceInline AkUInt32 NumChannels() const
Get the number of channels.
Definition: AkCommonDefs.h:284
AKRESULT
Standard function call result.
Definition: AkTypes.h:122
AKSOUNDENGINE_API AKRESULT Init(const AkCommSettings &in_settings)
AkUInt16 uValidFrames
Number of valid sample frames in the audio buffer.
Definition: AkCommonDefs.h:450
uint16_t AkUInt16
Unsigned 16-bit integer.
Definition: AkTypes.h:84
#define READBANKDATA(_Type, _Ptr, _Size)
Read and return bank data of a given type, incrementing running pointer and decrementing block size f...
float AkReal32
32-bit floating point
Definition: AkTypes.h:103
AkInt16 AkPluginParamID
Source or effect plug-in parameter ID.
Definition: AkTypes.h:71
AkForceInline AkUInt32 GetNumChannels() const
Definition: AkCommonDefs.h:72
uint32_t AkUInt32
Unsigned 32-bit integer.
Definition: AkTypes.h:85
Defines the parameters of an audio buffer format.
Definition: AkCommonDefs.h:60
virtual bool WriteReal32(float in_fReal32)=0
AkUInt32 uSampleRate
Number of samples per second.
Definition: AkCommonDefs.h:61