Here is all you need to know to get started using WAAPI with Unreal. For the most part, this article doesn’t contain any information that is not already somewhere in the Audiokinetic documentation, Wwise Up On Air videos or on this very blog. Having said that, I thought it would still be beneficial to put all the knowledge together in one place and give some concrete examples that are specific for Unreal.
When I started this journey, I found it hard to figure out how to do most of the things I’m going to show you. I had the feeling some information was unclear, sometimes even cryptic. The more I pushed myself through it and started building stuff, the more I realised all the pieces were there in front of me, I just wasn’t connecting the dots.
Of course, if I was a C++ and JSON expert it would have been much easier but chances are you aren’t either, so maybe this article helps you out. Ideally, you need solid knowledge of Wwise and decent competency in C++ to follow along.
Table Of Contents
Wwise API vs WAAPI
Enabling WAAPI
What is WAMP?
Using WAAPI on Unreal
WAAPI Remote Procedure Calls
Getting to know JSON
Populating JSON Objects
Adding necessary Includes and Modules
Building arguments and extracting resulting data
Encapsulating a WAAPI call
Using the options field
The SoundEngine duality
Using WAQL with WAAPI
Setting data on objects & importing audio
Name conflicts, lists and list modes
Modifying properties on an object
Creating new objects
Parsing raw JSON strings
Creating new Events
Importing Audio
Subscribing to topics
Using WAAPI directly on Blueprints
Creating a Profiler Controller
Building a Wwise hierarchy from Unreal data assets
Wwise API vs WAAPI
First of all, what is WAAPI? Is it the same thing as just the Wwise API?
This is something that was confusing to me for a while, in part because of my own inexperience and in part because WAAPI can do many things the regular API can, but not the other way around.
The Wwise API contains all the functions and classes that we use to send information from our game to the Wwise audio engine. This includes familiar things like posting events or setting RTPCs.
This API would include most of the information that you see under the Wwise SDK section on the documentation website. Since we are working with Unreal, we can also include here the specific classes that the game engine implementation uses.
On the other hand, WAAPI (Wwise Authoring API) allows you to communicate, not with the Wwise audio engine running within our game but with the Wwise Authoring App. That is, the software we use to get our sounds and game syncs ready for our project.
WAAPI can be used with many programming languages and it generally uses JSON to send information back and forth. This was an additional barrier of entry for me since I have barely used JSON before, even less so with Unreal.
Enabling WAAPI
In order to use WAAPI, we need to allow it on the Wwise user preferences. As you can see, there is a HTTP port and a WAMP port. The latter is the one that we would be interested in since it allows for more functionality and it is in fact the recommended way to use WAAPI.
What is WAMP?
This acronym stands for Web Application Messaging Protocol. It is a general purpose communication protocol used by different applications to speak with each other. The “Web Application” part might sound weird but it hints at the idea of WAAPI essentially working like a server connection. Not so different in concept to connecting a game build to the Wwise Profiler.
Using WAAPI on Unreal
As I mentioned earlier, WAAPI is language and platform agnostic; so, among many other options, it can be used on Unreal with C++ (even directly on Blueprints!) Audiokinetic gives us a class ready to be used within the Unreal Wwise plugin. Have a look at FAkWaapiClient if you want to get an idea of what’s possible, we will use a few functions from this class during this article.
Generally, there are two ways you can use WAMP to talk with the Wwise authoring app:
- Remote Procedure Call (RPC): We ask Wwise for some specific information and we get an answer. Yes, you can use RPC to ask for an RTPC. Wow.
- Subscribe/Publish (Pub/Sub): We start listening to a specific topic and we get a callback when “the thing” happens. Similar to an observer pattern.
WAAPI Remote Procedure Calls
Let’s start by using the remote procedure call workflow. Have a quick look here and you will find all the available functions that we can use.
We use FAkWaapiClient::Call to ask Wwise about the information we want. Knowing this was the correct class and function to use took me a bit to figure out because, call me stupid, but I could not find any explicit information about this in the documentation. Once I found it, I started to see it everywhere: videos, code examples, classes within the Wwise plugin itself, etc…
Anyway, this is the function we need. In theory, you can also bypass FAkWaapiClient and do the calls directly to the engine but no need to complicate things.
See how FAkWaapiClient::Call has two overloads, one takes FStrings and there is another one that takes a FJsonObject, which belongs to the Unreal native JSON implementation. I found that the latter option is more comfortable to use in most cases, at least while the arguments we pass are simple. More on directly passing strings later.
So the function takes the following parameters and returns true if successful. I won’t cover the optional parameters here but I think they are self-explanatory.
- URI: The specific function that will be called.
- For FAkWaapiClient, the format needs to be like so: ak::wwise::core::profiler::getCursorTime.
- Arguments: The data the above function is expecting.
- Options: Additional information to modify which data the function will return.
- Return: The resulting information. This can contain many types of data.
Notice how the arguments and options parameters are TSharedRef<FJsonObject> type while the return is a TSharedPtr<JsonObject>. Since apparently I can’t read, I confused these for a while and was getting a bunch of errors. Don’t be like me!
These come from the Unreal smart pointers library and you can read more about them but here is the gist: The main difference between the two is that TSharedRef has to point to an object that can’t be null which makes sense because we just create them to be passed to the Call function as const references. On the other hand, TSharedPtr can be null since a call result doesn’t always contain data and these are passed as non const references.
Something else that you will see in my code examples is that sometimes I make the JSON objects with MakeShared() and others with MakeShareable(). The former is lighter but the object has to have a public constructor while the latter supports private constructors and other customised behaviour albeit with a heavier weight. For our purposes, we can pretty much use either and to be honest I’ve been using them interchangeably mostly because of me copy-pasting sample code from vanilla Wwise classes and my own previous work.
Getting to know JSON
JSON is an open standard to exchange “human readable” (emphasis mine) data between different systems. It makes sense for WAAPI to use it because it is language agnostic so it can be used by many different software stacks.
Sure, but what’s in a JSON object? You could say they are like a C# dictionary (or C++ map) where we store data in key value pairs.
The “key” is always a string, while the “value” can be primitive variables, objects or arrays containing any of the above. See an example below of a JSON object containing two key value pairs:
{ "type" : "Sound", "@volume" , 10.5 }
Populating JSON Objects
So how do we build these JSON objects? Looking at the documentation examples or AK’s WAAPI hands-on videos, the JSON arguments usually look a bit like this:
{ "parent": "{7A12D08F-B0D9-4403-9EFA-2E6338C197C1}", "type": "Sound", "name": "Boom" }
Or like this!
{ "objects": [ { "object": "\\Interactive Music Hierarchy\\Default Work Unit", "children": [ { "type": "MusicSegment", "name": "MultiTrack Segment", "import": { "files": [ { "audioFile": "c:\\path\\track1.wav" }, { "audioFile": "c:\\path\\track2.wav" } ] } } ] } ], "onNameConflict": "merge" }
We could place or pass these JSON structures as raw strings directly in our code but that’s not the most efficient or scalable way of doing things, particularly if the objects are big. A better option might be to use FJsonObject functions to build the objects programmatically.
One of the main reasons I’m writing this article is to give some tangible examples on how to build these objects. It’s not easy to find some real world examples using Unreal, C++ and WAAPI so if you don’t have much experience manipulating JSON objects, the syntax and workflow might be tricky at first.
Adding necessary Includes and Modules
Before we dive in we need to make sure we can use Wwise and JSON in our Unreal project. You will need to add something like this at the top of your cpp file:
#include "../Plugins/Wwise/Source/AkAudio/Public/AkWaapiClient.h"
No need to include any JSON includes because the above already points to it.
We do need to add both Json, JsonUtilities and AkAudio to our project build file which should be named something like this: UnrealProjectName.Build.cs
For reference, all my examples were built on Wwise 2023.1 and Unreal 5.3.
Building arguments and extracting resulting data
Let's use the getCursorTime function as our first example. The documentation says the following:
As you can see, this function is expecting an argument that will determine if we want the user cursor (where the user clicks on the profiler) or the capture time cursor (where the current capture is). A star (*) indicates that the argument is always required.
At the end, we will get an integer with the resulting timecode value. That’s what we are expecting if all goes well!
So let’s build our JSON object. We can declare the argument like so:
TSharedRef<FJsonObject> args = MakeShared<FJsonObject>();
See how we use TSharedRef because that’s what the function is expecting. Then, we build the object with the function “MakeShared”.
Now that we have our JSON Object, we need to modify it so we can pass the proper information to the function. See how we need to pass a particular string field of the type “cursor” and we choose “capture” since we want to find the capture time, not the user’s.
To get this done, we can use some handy functions native to FJsonObject like:
TSharedRef<FJsonObject> args = MakeShared<FJsonObject>(); args->SetStringField("cursor", "capture");
It took me a while to figure out those two lines of code above because, again, I could not find any specific examples at first. Doing some searches on GitHub helped me find some inspiration and I also found some Wwise classes doing WAAPI calls but I only found them when I knew what to look for!
So let’s create objects for the options and the result too. See how we don’t need to modify any fields here and also how “result” uses a different type of pointer:
TSharedRef<FJsonObject> options = MakeShared<FJsonObject>(); TSharedPtr<FJsonObject> result = MakeShared<FJsonObject>();
Finally, we can see FAkWaapiClient is a singleton so we can just get the instance and call the function:
FAkWaapiClient::Get()->Call(ak::wwise::core::profiler::getCursorTime, args, options, result);
Since it returns a bool if successful, we can use it to make sure things worked as expected.
If successful, we can then “extract” the int value from the resulting JSON object by getting the integer field.
All put together looks like this:
TSharedRef<FJsonObject> args = MakeShared<FJsonObject>(); args->SetStringField("cursor", "capture"); TSharedRef<FJsonObject> options = MakeShared<FJsonObject>(); TSharedPtr<FJsonObject> result = MakeShared<FJsonObject>(); if (FAkWaapiClient::Get()->Call(ak::wwise::core::profiler::getCursorTime, args, options, result)) { if (result) { int rawTime = result->GetIntegerField("return"); } }
Success! See how this time we use a get function to find the integer value that we know the resulting JSON object must contain.
This int we get here is the time in milliseconds so now we just need to convert this into timecode or whatever is needed and do something useful with it like printing it on screen in Unreal.
Encapsulating a WAAPI call
Let’s see another example now where we encapsulate a WAAPI call into a function that can be called from other classes or from a blueprint.
So I created a new actor component class and wrote this function:
bool UMyActorComponent::SendWwiseConsoleMessage(FString MessageToDisplay) { TSharedRef<FJsonObject> args = MakeShared<FJsonObject>(); args->SetStringField("message", MessageToDisplay); TSharedRef<FJsonObject> options = MakeShared<FJsonObject>(); TSharedPtr<FJsonObject> result; return FAkWaapiClient::Get()->Call(ak::soundengine::postMsgMonitor, args, options, result); }
As you can see, the function asks for a string (the message to be displayed) and returns a bool which would be true if the operation was successful.
The syntax and logic is even simpler than before because we don’t even need to parse the result.
This is how this looks on a blueprint:
And we get the message on the Wwise capture log (Profiler):
It shows up as an error, and it is not possible yet to choose a different log level with this function.
You can also use ak.wwise.core.log.addItem if you want to choose the severity level although in that case you don’t seem able to log on the profiler view which is probably the most likely place to be seen by sound designers.
Using the options field
Now we can try another function with more complex arguments. Let’s say, for example, that we want to use “ak.soundengine.getState” to know which state is currently being used in Wwise for a certain state group.
Let’s have a look at the documentation:
As you can see, we can pass the argument in a few different ways but all of them are just a string that will point to our state group in one way or another. There are also some options we can use. The result field looks like this:
This was confusing to me at first. It looks like the options and the result are kind of the same? This is because you use the options to tell Wwise precisely which data you want the result to include. What if you leave the option field blank? Well, in that case you just get the first two fields: the ID and the name. I found this out by just experimenting with the code but then I found this quote in the docs:
“Additionally, queries take options which can specify:
return: Specifies what to return from the objects. If not specified, the default is ['id', 'name'].”
So we build our JSON objects as before but for the options field, you will see the type is an array. Because of this, we need to construct the objects in a more convoluted way. One way to do it is:
TSharedRef<FJsonObject> options = MakeShared<FJsonObject>(); TArray<TSharedPtr<FJsonValue>> optionsArray { MakeShareable(new FJsonValueString("notes")), MakeShareable(new FJsonValueString("id")), MakeShareable(new FJsonValueString("name")) }; options->SetArrayField("return", optionsArray);
As you can see, we first declare the object as usual. We then declare an array of JsonValues which will contain our data. This kind of makes sense but it took me a bit to figure out that the array is not supposed to be made of FJsonObject but FJsonValue.
So we populate the array and then we set the field using “return” as the key. I find it confusing that the name of the options array is “return” instead of something like “options” but maybe that’s just me.
On the other hand, the argument field is simple enough in this case, we just need to specify our state group. For this, we will use its path. Remember to double up any backslash because just one backslash is an escape character.
So the whole thing looks like this:
FString UMyActorComponent::WaapiCall() { //Args Object TSharedRef<FJsonObject> args = MakeShared<FJsonObject>(); args->SetStringField("stateGroup", "\\States\\Default Work Unit\\TestState"); //Options Object TSharedRef<FJsonObject> options = MakeShared<FJsonObject>(); TArray<TSharedPtr<FJsonValue>> optionsArray { MakeShareable(new FJsonValueString("notes")), MakeShareable(new FJsonValueString("id")), MakeShareable(new FJsonValueString("name")) }; options->SetArrayField("return", optionsArray); TSharedPtr<FJsonObject> result; FString resultString = "Something went wrong..."; if (FAkWaapiClient::Get()->Call(ak::soundengine::getState, args, options, result)) { if (result) { TSharedPtr<FJsonObject> resultingState = result->GetObjectField("return"); FString stateName = resultingState->GetStringField("name"); FString stateNotes = resultingState->GetStringField("notes"); resultString = "We found the state: "; resultString.Append(stateName); resultString.Append(stateNotes); return resultString; } return resultString; } return resultString; }
Of course you could make this into a function that takes a state name or path but for this example, I just hard-coded it. One tricky thing is that in this case the result Json object doesn’t directly contain the fields that we are looking for so we can’t use GetStringField on it the same way we did earlier. What we need to do is get the object value under the key “return” and then get the good stuff from that second object.
This wasn’t obvious to me at all because I was expecting that I would just use the result object itself or maybe that the object would contain an array but that doesn’t seem to be the case.
Anyway, we can now return a string with the resulting information that you can then display in your project, for example. In this case, I’m using the name and notes field.
Notice that the above will only be accurate if the Wwise authoring app is connected to the game. When disconnected, you will still get a value but this will be whatever is in the authoring app, which could just be the last one sent by the game or one you have just set via the Soundcaster. This has some big implications which I will now discuss in more detail.
The SoundEngine duality
Instead of using WAAPI, we can use the Wwise SoundEngine on Unreal (or a game build) to get a particular state via the regular Wwise API.
For this to work, you will need to have “WwiseSoundEngine” on your “UnrealProjectName.Build.cs” file and then use an include to the sound engine like:
#include "../Plugins/Wwise/Source/WwiseSoundEngine/Public/Wwise/API/WwiseSoundEngineAPI.h"
We are now set to query a state via the API. Functionally, this could superficially look very similar to what we were doing with WAAPI on the last section but there are important differences.
AkStateID outState = 0; auto* SoundEngine = IWwiseSoundEngineAPI::Get(); if (SoundEngine) { auto QueryObj = SoundEngine->Query; if (QueryObj) { QueryObj->GetState("TestState", outState); } } if (outState != 0 ) { GEngine->AddOnScreenDebugMessage(-1, 20.0f, FColor::Cyan, FString::SanitizeFloat(outState)); }
The most obvious one is that WAAPI calls will get information from the SoundEngine running on the authoring app so you can’t rely on these for driving gameplay logic since that won’t always be available. More importantly, WAAPI will only give you accurate information if you are connected to the game, which makes it most useful for developing, testing and profiling.
On the other hand, regular API calls will get the information from the SoundEngine running on the Unreal editor or game build itself. Because of that, they can be used to drive gameplay logic since they will always be available and they will reflect the situation in the game but the information obtainable is limited.
Keep in mind that all of the above applies when we are talking about any WAAPI function with an URI that starts with “ak.soundengine” since all of these have their regular API counterpart.
In conclusion, is good to keep in mind that, during development, there are two SoundEngines in play, the one running on the engine/game and the one running on the authoring app. We can query either of them. Here is a summary using again the example of getting a state:
GetState via WAAPI | GetSate via regular API | |
---|---|---|
SoundEngine | Instance running on WwiseAuthoring | Instance running on game engine editor or game build. |
Accuracy | Only accurate when connected to WwiseAuthoring. When disconnected, will give last data the game sent before disconnecting OR whatever data the user sets with the SoundCaster |
Always reflects game situation accurately. It can be used to drive gameplay if desired. |
Data available | A lot of data since you are accessing WwiseAuthoring. | Just the state short ID. |
Using WAQL with WAAPI
WAQL is the Wwise Authoring Query Language. In a nutshell, it allows you to find many different Wwise objects like buses, containers or events by using very specific and modular criteria. The queries can be further filtered to get any desired results.
We can leverage the power of WAQL together with WAAPI to find very specific objects in Wwise and then potentially modify them in some way. Let’s first see how we can obtain our objects.
We will use ak.wwise.core.object.get since this function takes WAQL strings. Note that this same function also works with the from/transform format but this approach is older and not the recommended method anymore. See more about that here. This is how the arguments look:
The return will be an array containing whatever data we want. How do we decide that? With the options field in a very similar way to what we did with the getState function.
So let’s build a function and blueprint node that takes a WAQL string and spits out a string with the info we want. Of course, we could make this even better by giving the user the ability to choose which information to get, maybe as parameters in the function, but for now let’s keep it simple.
So we declare the function on our header file:
UFUNCTION(BlueprintCallable, BlueprintCosmetic, Category = "CustomWaapi") FString GetWwiseObjectWAQL(FString WAQL);
And we then define our function in a similar way to what we have been doing previously:
FString UMyActorComponent::GetWwiseObjectWAQL(FString WAQL) { TSharedRef<FJsonObject> args = MakeShared<FJsonObject>(); args->SetStringField("waql", WAQL); TSharedRef<FJsonObject> options = MakeShared<FJsonObject>(); TArray<TSharedPtr<FJsonValue>> optionsArray { MakeShareable(new FJsonValueString("notes")), MakeShareable(new FJsonValueString("id")), MakeShareable(new FJsonValueString("name")), MakeShareable(new FJsonValueString("path")) }; options->SetArrayField("return", optionsArray); TSharedPtr<FJsonObject> result; FString waqlResult = "Query was not successful"; FAkWaapiClient::Get()->Call(ak::wwise::core::object::get, args, options, result); if (result) { waqlResult = "Query was successful: "; TArray<TSharedPtr<FJsonValue>> resultArray = result->GetArrayField("return"); for (auto arrayMember : resultArray) { FString memberString = arrayMember->AsObject()->GetStringField("name"); memberString.Append(" ("); memberString.Append(arrayMember->AsObject()->GetStringField("path")); memberString.Append("), "); waqlResult.Append(memberString); waqlResult.Append("\n"); } return waqlResult; } return waqlResult; }
If you look closely, you will see that, yet again, the way we need to parse the result field is a bit different. It contains an array with the information we want but this is not an array of strings. It’s an array of FJsonValue, so we need to cast them as objects and then call GetStringField on each of them.
So we just cycle through the array and gather the information we need. For this simple example, I’m just appending all the info to a string but I’m pretty sure you can see how you could do much more with this.
So let’s test with some prompts. For example, we can use “$ from type Event” to get all the events on the project.
And we indeed get a list of events printed on Unreal.
WAQL is very powerful and versatile, see an introduction here and have a look at the references too, but I won’t show you much more of it since I want to focus on WAAPI.
Well, let’s do one more:
We ask for a sound object whose volume is negative and as you can see we get the expected result.
The takeaway here is that we can pass very specific WAQL queries to WAAPI through ak.wwise.core.object.get and then use this information in code or blueprints in any way we see fit. A possible application would be to use this to build something akin to unit tests where you make sure things are set up in Wwise in the way you expect. For example, switch containers in a footsteps system being populated and configured properly.
I was also thinking that we could leverage the fact that we are using WAAPI from Unreal and list information based on any AkComponent that we have in game.
Setting data on objects & importing audio
Up until now, we have been asking Wwise for data, we have basically been “reading” Wwise. But much of the power of WAAPI resides in actually modifying the Wwise project.
Imagine creating work units with complex hierarchies with a few clicks, importing audio in a systematic way or building a bus system from data residing somewhere outside Wwise, like an Unreal data file.
If we are using level specific (I mean levels/areas in our game here) Wwise events, we could create these programmatically from some Unreal assets that contain the level information. We could directly translate level conditions and settings to actions on our events.
We could also do things like keep Unreal assets in sync with Wwise assets. For example, imagine each time we create a new physical material in Unreal, the corresponding switch is created in Wwise and added to the proper switch group.
Cool, so how do we do all of this? Looking at the docs, we have a bunch of functions that will help us achieve all the above but it can surely get complicated fast. I encourage you to read this particular page to get a sense of the possibilities and limitations.
In any case, I want to highlight four functions:
- ak.wwise.core.object.create: Create new objects as children of a given parent. Easy enough to use but not recommended anymore since it has some limitations.
- ak.wwise.core.audio.import: Creates objects and also imports new audio to them.
- ak.wwise.core.object.setProperty: Sets a single property on a single object. No options, no return. Simple and easy but limited.
- ak.wwise.core.object.set: This is the king. It does all the above! Added quite recently on Wwise 2022 and supercharged on version 2023, it allows batch creation of hierarchies of objects, importing audio, setting properties… you name it. Very powerful but the complexity of the JSON object or string that you have to build can get out of hand pretty quick (more on that later).
As you can see, we have a bunch of options but for the most part, ak.wwise.core.object.set is what Audiokinetic recommends for most things so that’s the one I’m going to use in most of my examples.
Name conflicts, lists and list modes
Before we go into it, we need to think about what would happen if we try to create an object that already exists. On the other hand, what if we try to set a property like an RTPC when some other RTPCs are already there?
We can use the “onNameConflict” field to decide what happens if the object already has children with the same name. Let’s see the options (text taken from the docs):
- fail: The create function returns an error. (default)
- replace: The object at destination is deleted (including its children), and a new object is created.
- rename: A new unique name is automatically assigned for the new object, appending numbers to it.
- merge: The object at destination is re-used, and the specified properties, references and children are merged to the destination leaving untouched the rest of the object.
As you can see, depending on the case, we might want to wipe out the object and build it from scratch (replace) or just modify the specific values that are different in our JSON object (merge).
On the other hand, objects also use this concept of “lists”. These lists contain different arrays of objects like for example RTPCs on a Sound or Effects on a bus. So when using ak.wwise.core.object.set, if we want to modify any of these lists, we can specify how this will happen on the “listMode” field. Looking at the docs, the options are:
- append: Add the new objects to the list if possible, keeping existing objects. Some lists might not permit duplicate equivalent objects: e.g., some properties of RTPCs in the RTPC list are exclusive so there can only be one RTPC with that property.
- replaceAll: Remove all existing objects and add the new objects, with the duplicate restriction.
Modifying properties on an object
Let’s start by just using ak.wwise.core.object.set to modify an already existing object so we can see the syntax.
Looking at the documentation, you will see the arguments need to include an array of objects containing at least one object. The syntax for creating this is a bit tricky if you haven’t used FJson before. Let’s say we want to modify one particular sound object by adding some notes, changing the volume and the low pass filter amount.
If you look at all the documentation examples or at the Audiokinetic WAAPI videos, they always use raw JSON strings. So for our example, that would look something like this:
{ "objects": [ { "object": "\\Actor-Mixer Hierarchy\\Default Work Unit\\Sound1", "notes": "Hello!", "@Volume": 15.7, "@LowPass": 25 } ], "onNameConflict": "merge" }
We have two options, we can either directly use the above as a raw or literal string on the WAAPI call or we can construct the JSON object the same way we have been doing it up until now.
In my opinion, the former is more readable, although it is true that as soon as you need to create complex structures it becomes a bit harder to follow. But the problem with this approach is that generally we want to build our structures programmatically so using JSON functions might be the best option.
So let’s see how to build the above JSON object. We first declare both the arguments object and the object which represents the sound we want to modify (only one for now).
TSharedRef<FJsonObject> args = MakeShared<FJsonObject>(); TSharedRef<FJsonObject> objectToModify = MakeShared<FJsonObject>();
See how both are just regular JSON objects. Now, let’s set all the fields in our object:
objectToModify->SetStringField("object", "\\Actor-Mixer Hierarchy\\DefaultWorkUnit\\Sound1"); objectToModify->SetStringField("notes", "Hello!"); objectToModify->SetNumberField("@Volume", 15.7f); objectToModify->SetNumberField("@LowPass", 25);
Here comes the tricky part. We now need to add our object to the argument object somehow but ak.wwise.core.object.set is expecting an array of objects so we need to construct this array like so:
TArray<TSharedPtr<FJsonValue>> argsArray;
Now, to be able to populate the array we need to use FJsonValue but what we have is an object. We need to use FJsonValueObject as an intermediary. So we do something like this:
TSharedRef<FJsonValueObject> ObjectValues = MakeShareable(new FJsonValueObject(objectToModify));
Honestly, the above line wasn’t intuitive for me at all! I found it on a 2015 post on the Unreal forum but hey, it works. After using this for a while, I found some Wwise plugin classes that build arguments in the same way which gave me confidence I was on the right track. I wish I’d have checked earlier! FAkWaapiClient, AkWaapiUtils or SWaapiPicker contain some good examples.
So now we just need to add that FJsonValueObject to the array and then add the array to the arguments object. We finally create the remaining objects and voilà. All put together looks like this:
void UMyActorComponent::WaapiObjectSetModify() { TSharedRef<FJsonObject> args = MakeShared<FJsonObject>(); // The object we want to modify TSharedRef<FJsonObject> objectToModify = MakeShared<FJsonObject>(); objectToModify->SetStringField("object", "\\Actor-Mixer Hierarchy\\Default Work Unit\\Sound1"); objectToModify->SetStringField("notes", "Hello!"); objectToModify->SetNumberField("@Volume", 15.7f); objectToModify->SetNumberField("@LowPass", 25); TArray<TSharedPtr<FJsonValue>> argsArray; TSharedRef<FJsonValueObject> ObjectValues = MakeShareable(new FJsonValueObject(objectToModify)); argsArray.Add(ObjectValues); args->SetArrayField("objects", argsArray); args->SetStringField("onNameConflict", "merge"); TSharedRef<FJsonObject> options = MakeShared<FJsonObject>(); TSharedPtr<FJsonObject> result; FAkWaapiClient::Get()->Call(ak::wwise::core::object::set, args, options, result); }
Cool. This works but I hope you can see how the code can get ridiculously complicated when you want to modify a bunch of objects with just one call. A better approach could be to encapsulate the above function and make a call per object from somewhere else. Maybe add a comfortable way for the user to choose which attributes to modify. In this example we just hardcoded the object to modify but potentially we could use WAQL to find a very specific group of objects.
Lastly, see how some of our object fields use the names that we can see on the ak.wwise.core.object.set documentation (like “notes”) while other fields are not on that page. The latter are the ones that have a ‘@’ in front of their names like “@Volume”. These are properties coming directly from the object itself, which in this case is a Sound. You can find here all these different objects and their values. As a rule of thumb, you can change any property listed on each object reference page using ‘@’ in front of the property name.
Creating new objects
Let’s see another example where we create new objects from scratch. I won’t go into much detail about how the JSON objects are built because it is basically the same workflow as earlier.
Keep in mind that when you want to create new objects, you always create them as children of something else. This parent would usually be a work unit, virtual folder or possibly any type of container. You can also create new work units with ak.wwise.core.object.set and if you want to make them on your hierarchy root, you can use that same root as the parent, like for example "\\Actor-Mixer Hierarchy".
Anyway, for this demonstration we want to create two sound objects under the default work unit. I encourage you to carefully read the code below and try to follow it:
void UMyActorComponent::WaapiObjectSetCreate() { TSharedRef<FJsonObject> args = MakeShared<FJsonObject>(); // WorkUnit TSharedRef<FJsonObject> workUnit = MakeShared<FJsonObject>(); workUnit->SetStringField("object", "\\Actor-Mixer Hierarchy\\Default Work Unit"); // Child Sound 1 TSharedRef<FJsonObject> childOne = MakeShared<FJsonObject>(); childOne->SetStringField("type", "Sound"); childOne->SetStringField("name", "GeneratedSoundTest"); TSharedRef<FJsonValueObject> childOneValues = MakeShareable(new FJsonValueObject(childOne)); // Child Sound 2 TSharedRef<FJsonObject> childTwo = MakeShared<FJsonObject>(); childTwo->SetStringField("type", "Sound"); childTwo->SetStringField("name", "GeneratedSoundTestOther"); TSharedRef<FJsonValueObject> childTwoValues = MakeShareable(new FJsonValueObject(childTwo)); // Children Array TArray<TSharedPtr<FJsonValue>> childrenArray; childrenArray.Add(childOneValues); childrenArray.Add(childTwoValues); workUnit->SetArrayField("children", childrenArray); TSharedRef<FJsonValueObject> ObjectValues = MakeShareable(new FJsonValueObject(workUnit)); TArray<TSharedPtr<FJsonValue>> argsArray; argsArray.Add(ObjectValues); args->SetArrayField("objects", argsArray); args->SetStringField("onNameConflict", "merge"); TSharedRef<FJsonObject> options = MakeShared<FJsonObject>(); TSharedPtr<FJsonObject> result; FAkWaapiClient::Get()->Call(ak::wwise::core::object::set, args, options, result); }
I have to stress again that I haven’t encapsulated any of the code into functions because I wanted to keep it explicit and didactic but obviously the above method would be too rigid to use in real life.
I have tried to show you the syntax and how JSON object building works. But if you plan to build a WAAPI based tool in Unreal or elsewhere, the heavy lifting it’s going to come from how you want to organise and format your data in a way that WAAPI understands. Think about how a member of the audio team would decide which objects are created and how they are configured.
This could be done with some kind of wizard, reading the information from a data file that lives in Unreal or maybe even directly from Unreal levels or blueprints. Whatever it is, you will need a bunch of helper classes to build JSON objects in a modular way. All the syntax I have shown should give you enough to get started and I even have a final, big example at the end of this post showing all of the above.
Unless… you prefer to forget about building JSON objects with code and want to just build them by typing them. In case you want to go this route, let’s see how it would be done.
Parsing raw JSON strings
The biggest advantage of this option is that you can directly copy and paste all the examples from the Wwise documents so at least you have a lot of content to use as a base.
The not so good part is that modifying the structures to suit your needs is not so comfortable and, as the complexity increases, the “human readable” trait JSON is supposed to have goes out of the window. Of course, this option is not going to work if you want to build your arguments programmatically but having said that, there are many tools online to parse and modify JSON strings that are ready for you to use. That’s the advantage of JSON being a ubiquitous protocol.
So I have built a small function that takes raw or literal JSON strings and spits out JSON Objects ready to use by the WAAPI call function. Let’s have a look:
TSharedRef<FJsonObject> UMyActorComponent::ParseJSON(FString RawJson) { // Parsing raw strings into JSON TSharedRef<FJsonObject> argsParsed = MakeShared<FJsonObject>(); TSharedPtr<FJsonObject> argsPtr = MakeShared<FJsonObject>(); TSharedRef<TJsonReader<TCHAR>> JsonReader = TJsonReaderFactory<TCHAR>::Create(RawJson); FJsonSerializer::Deserialize(JsonReader, argsPtr); argsParsed = argsPtr.ToSharedRef(); return argsParsed; }
As you can see, it even gives you the pointer to the JSON object so you can directly use it.
Crucially, there is also an overload on the WAAPI client call function that directly takes strings instead of JSON objects so the above might not be needed anyway but it could be useful if you use a mix of both methods.
In any case, there is something else we need to do. As you can see my function asks for a FString but it is quite important that this string is formatted properly. Wherever this string lives, you need to make sure it is either a raw string or if it is not, it needs to contain all the proper escape characters and be defined in one single line. You can use online JSON parsers to take a human readable JSON and turn it into a compact version. See the before and after here:
//Before: Normal Json { "objects": [ { "object": "\\Actor-Mixer Hierarchy\\Default Work Unit\\Sound1", "notes": "Hello!", "@Volume": 15.7, "@LowPass": 25 } ], "onNameConflict": "merge" } //After: Compact Json with escape characters {\r\n\t\t\"objects\": \r\n\t\t[\r\n\t\t\t{\r\n\t\t\t\t\"object\": \"\\\\Actor-Mixer Hierarchy\\\\Default Work Unit\\\\Sound1\",\r\n\t\t\t\t\"notes\": \"Hello!\",\r\n\t\t\t\t\"@Volume\" : 15.7,\r\n\t\t\t\t\"@LowPass\": 25\r\n\t\t\t}\r\n\t\t],\r\n\t\t\"onNameConflict\": \"merge\"\r\n}
Both of the above options would work. The former is more readable but you need to deal with raw (literal) strings which can be a pain. The latter is pretty much unreadable but can be used directly. Pick your poison!
Creating new Events
Let’s now see how we can create new events programmatically. This can get a bit complicated because our arguments object will contain an array of objects which are some kind of event parent (like a work unit). Each of these will contain an array of Events objects which will, in turn, contain an array of event actions. So yeah, as soon as you start to nest arrays, things get a bit complicated.
Here is an example of how we can do this, I have kept things simple to show you what’s possible. This code would not be very usable on a real project but it is still informative. We will create one event in one work unit. The event will contain two actions. For a real use case, of course, you would need to be able to create any number of the above.
So first, we declare our arguments object and our event parent which will just be the default event work unit in this case.
TSharedRef<FJsonObject> args = MakeShared<FJsonObject>(); // Event Parent TSharedRef<FJsonObject> eventParent = MakeShared<FJsonObject>(); eventParent->SetStringField("object", "\\Events\\Default Work Unit");
Now we need to build our Event. In order to do this, we need some way to choose which actions we want to add to it. So I created a struct that contains event action data:
struct EventActionData { int ActionType = 1; float Delay = 0.0f; int Scope = 0; FString Target = ""; };
We now need a function that builds the events for us when we pass it an array of actions and other settings. So I wrote this:
TSharedPtr<FJsonValue> UMyActorComponent::BuildEventJson(FString EventName, TArray<EventActionData> Actions, FString Target) { // Event object TSharedRef<FJsonObject> event = MakeShared<FJsonObject>(); event->SetStringField("type", "Event"); event->SetStringField("name", EventName); TArray<TSharedPtr<FJsonValue>> ActionsArray; int number = 0; // Actions for (auto action : Actions) { FString numberString = FString::FromInt(number); TSharedRef<FJsonObject> ActionObject = MakeShared<FJsonObject>(); ActionObject->SetStringField("name", numberString); ActionObject->SetStringField("type", "Action"); ActionObject->SetNumberField("@ActionType", action.ActionType); ActionObject->SetStringField("@Target", Target); ActionObject->SetNumberField("@Scope", action.Scope); ActionObject->SetNumberField("@Delay", action.Delay); TSharedRef<FJsonValueObject> ActionValueObject = MakeShareable(new FJsonValueObject(ActionObject)); ActionsArray.Add(ActionValueObject); number++; } event->SetArrayField("children", ActionsArray); return MakeShareable(new FJsonValueObject(event)); }
Let’s see what the above is doing. As you can see, we pass the event name, an array of actions we want to execute and the target (which audio object to play, stop, etc). This example simplifies things by just using the same target for all actions but in theory you would use the target passed through the EventActionData object.
So we first create the event JSON object, we set the type and name. We then iterate through our actions array and we create an object per action which contains all the necessary data. I only used some of the properties of an action but of course there are more.
It seems like I had to give each action a name, even though the documentation examples use an empty string. As far as I can see, this name is useless and is not displayed anywhere on the authoring app so I just gave each action an arbitrary name.
Once we set all the data, we turn our object into a value object and add it to the array. We then set our event object and return it.
Cool, so let’s use the above function. First let’s set some action data. As you can see, action types and scope use an int which is not ideal. I didn’t do it here but I would turn this into enums if possible.
Anyway, we create our actions, add them to an array and then we can call our nice function so it spits out a ready to use event object.
// Create Event One EventActionData ActionPlay; ActionPlay.Delay = 0.5f; EventActionData ActionStop; ActionStop.ActionType = 2; ActionStop.Scope = 1; TArray<EventActionData> ActionsArray = { ActionStop, ActionPlay }; TSharedPtr<FJsonValue> EventOne = BuildEventJson("EventOne", ActionsArray, Target);
We are now ready to build the rest of our objects. We first build our events array. As I said earlier, let’s just use one event here to keep things simple. We then set our events array to our event parent and make all the necessary conversions to value objects.
Now we can finally set the last fields on our arguments object. Using “merge” and “append” is usually a good idea to make sure we create events additively. It could be useful to use “replaceAll” instead of “append” in case you want to change the target or any other settings of the actions on the fly.
// Children Array TArray<TSharedPtr<FJsonValue>> eventsArray; eventsArray.Add(EventOne); eventParent->SetArrayField("children", eventsArray); TSharedRef<FJsonValueObject> EventParentValues = MakeShareable(new FJsonValueObject(eventParent)); TArray<TSharedPtr<FJsonValue>> argsArray; argsArray.Add(EventParentValues); args->SetArrayField("objects", argsArray); args->SetStringField("onNameConflict", "merge"); args->SetStringField("listMode", "append"); TSharedRef<FJsonObject> options = MakeShared<FJsonObject>(); TSharedPtr<FJsonObject> result; FAkWaapiClient::Get()->Call(ak::wwise::core::object::set, args, options, result);
So finally, we execute this from somewhere in Unreal and we get this thing of beauty:
Importing Audio
We can also have a look at how to import new audio into Wwise. This works in a very similar way to the previous examples but the amount of objects and arrays we need to create to just import one file starts to get large and is easy to get confused.
Again, I have kept the code explicit but this is not how you should do things! Ideally we would have a method to create sounds and another to create sound sources. Maybe another to define audio files plus some helper structs and enums. In any case, I still think the following code still is a useful exercise if you want to learn the basic way to structure big JSON objects:
void UMyActorComponent::WaapiImportAudio() { TSharedRef<FJsonObject> args = MakeShared<FJsonObject>(); // Parent TSharedRef<FJsonObject> soundParent = MakeShared<FJsonObject>(); soundParent->SetStringField("object", "\\Actor-Mixer Hierarchy\\Default Work Unit"); // Sound Object TSharedPtr<FJsonObject> soundOne = MakeShared<FJsonObject>(); soundOne->SetStringField("type", "Sound"); soundOne->SetStringField("name", "NewSoundTest"); // AudioFileSource Object TSharedPtr<FJsonObject> sourceOne = MakeShared<FJsonObject>(); sourceOne->SetStringField("type", "AudioFileSource"); sourceOne->SetStringField("name", "mySourceOne"); // Files to import TSharedPtr<FJsonObject> files = MakeShared<FJsonObject>(); // File One TSharedPtr<FJsonObject> fileOne = MakeShared<FJsonObject>(); fileOne->SetStringField("audioFile", "C:\\Users\\example\\Downloads\\exampleSound.wav"); fileOne->SetStringField("originalsSubFolder", "ImportingTest"); TSharedRef<FJsonValueObject> fileOneValueObject = MakeShareable(new FJsonValueObject(fileOne)); // Make Files Array TArray<TSharedPtr<FJsonValue>> filesArray; filesArray.Add(fileOneValueObject); files->SetArrayField("files", filesArray); sourceOne->SetObjectField("import", files); TSharedRef<FJsonValueObject> sourceValueObject = MakeShareable(new FJsonValueObject(sourceOne)); // Make Sources Array TArray<TSharedPtr<FJsonValue>> sourcesArray; sourcesArray.Add(sourceValueObject); soundOne->SetArrayField("children", sourcesArray); TSharedRef<FJsonValueObject> soundValueObject = MakeShareable(new FJsonValueObject(soundOne)); // Make Sounds Array TArray<TSharedPtr<FJsonValue>> soundsArray ; soundsArray.Add(soundValueObject); soundParent->SetArrayField("children", soundsArray); TSharedRef<FJsonValueObject> SoundParentValues = MakeShareable(new FJsonValueObject(soundParent)); TArray<TSharedPtr<FJsonValue>> argsArray; argsArray.Add(SoundParentValues); args->SetArrayField("objects", argsArray); args->SetStringField("onNameConflict", "merge"); args->SetStringField("listMode", "append"); TSharedRef<FJsonObject> options = MakeShared<FJsonObject>(); TSharedPtr<FJsonObject> result; FAkWaapiClient::Get()->Call(ak::wwise::core::object::set, args, options, result); }
A few things to note about the above. See how the “import” field within the audioSource object takes a files object, not an array. Then this “files” object contains an array of all the files to import.
Also, you can determine which folder within the originals we want to copy the file to. In the case of a regular sound, the path will be relative to “Originals\SFX”. If your path contains a folder that doesn’t exist yet, it will be created.
Lastly, the above code works for me and the new sound shows up in Wwise instantly but playing it doesn’t work and I get a media not found error. If I restart Wwise, then all is good and I can play the new audio. This seems to be a bug.
Subscribing to topics
As we discussed at the start, there are two ways you can use WAMP to talk with the Wwise authoring app:
- Remote Procedure Call (RPC): We ask Wwise for some specific information and we get an answer.
- Subscribe/Publish (Pub/Sub): We start listening to a specific topic and we can then know when this is happening. Similar to an observer pattern.
Up until now, we have been using RPC (Remote Procedure Call) to get information from Wwise. This is useful when we want to get certain data at a certain time but sometimes we want Wwise to tell us when something has changed. We can accomplish this with the other WAMP protocol called Subscribe/Publish.
In the documentation, we can see all the possible topics we can subscribe to. Yet again, the syntax to make this work feels a bit cryptic at first and the docs contain no examples. Digging through Wwise plugin classes, I was able to find some implementations to get some inspiration.
We used the function “Call” for RPC but now we will need to use a different function: “Subscribe”. The basic idea is that we first subscribe to a certain occurrence, passing a function that we want to be executed when Wwise says the thing happened.
Let’s look at FAkWaapiClient::Subscribe. The function takes the following parameters:
- URI: The specific topic we want to subscribe to.
- Options: Additional information to modify which data the function will return.
- Callback: The function we want to call when the topic happens.
- SubscriptionID: An int so we can tell the difference between different subscriptions.
- Result: Some extra information about the thing we subscribed too.
Cool, so let’s see how to use all the above. I’m using ak.wwise.core.profiler.stateChanged as an example since it’s quite straightforward.
To be able to follow along, you will need to at least have a basic understanding of function pointers and lambdas.
The most important thing we need to build is the callback. You will see FAkWaapiClient::Subscribe takes a WampEventCallback type. There are a few ways to create these but the easiest way I found was to use a lambda. So we do the following:
auto callback = WampEventCallback::CreateLambda([this](uint64_t id, TSharedPtr<FJsonObject> jsonObject) { const TSharedPtr<FJsonObject> itemObj = jsonObject->GetObjectField("stateGroup"); if (itemObj != nullptr) { FString StringToPrint = "The state group: "; const FString stateGroupName = itemObj->GetStringField("name"); const TSharedPtr<FJsonObject> state = jsonObject->GetObjectField("state"); const FString stateName = state->GetStringField("name"); subscriptionID = id; StringToPrint.Append(stateGroupName); StringToPrint.Append(" changed to "); StringToPrint.Append(stateName); GEngine->AddOnScreenDebugMessage(-1, 5.0f, FColor::Green, StringToPrint); } });
That syntax is going to look VERY weird if you are not familiar with lambdas. If that’s the case I encourage you to learn about them and then it will all make a bit more sense.
As you can see, what we are doing is creating the callback variable and our function (the thing that we want to do when a state changes) all in one single declaration. In this case, we just grab the state and state group names and print them on the Unreal editor. See how we also assign the subscription ID to a member variable so we can use it later to unsubscribe.
Let’s now create the options object. If we want to know which state the state group is using, we need to specify this here. We then create the other fields and use the subscribe function:
// Options Object TSharedRef<FJsonObject> options = MakeShareable(new FJsonObject()); TArray<TSharedPtr<FJsonValue>> optionsArray { MakeShareable(new FJsonValueString("path")), MakeShareable(new FJsonValueString("id")), MakeShareable(new FJsonValueString("name")) }; options->SetArrayField("return", optionsArray); TSharedPtr<FJsonObject> result; uint64 SubscriptionId = 0; if (FAkWaapiClient::Get()->Subscribe(ak::wwise::core::profiler::stateChanged, options, callback, SubscriptionId, result)) { GEngine->AddOnScreenDebugMessage(-1, 5.0f, FColor::Green, "Subscribed!"); }
And that’s pretty much it! The above works but remember that you need Wwise to be connected to Unreal. This is because, as I discussed earlier, WAAPI operates on the SoundEngine within the authoring app. You don’t need to be making a capture though.
Before we forget, let’s also unsubscribe to prevent our function from firing multiple times. So here I’m just unsubscribing on end play:
void UMyActorComponent::EndPlay(const EEndPlayReason::Type EndPlayReason) { Super::EndPlay(EndPlayReason); FString out_result = ""; if (subscriptionID != 0 && FAkWaapiClient::Get()->Unsubscribe(subscriptionID, out_result)) { GEngine->AddOnScreenDebugMessage(-1, 5.0f, FColor::Blue, "UnSubscribed!"); } }
Using WAAPI directly on Blueprints
We now have a very good idea of how to manage JSON objects and WAAPI with C++. What if I told you you can also do all this with Blueprints? The Wwise plugin for Unreal gives us all we need to make calls and subscribe with WAAPI. I haven’t used this extensively but I will give you a few examples so you see how it works. If you have read until this point, this is going to be easy.
See this example where we get the connection status:
As you can see, there is a node we can use to call WAAPI. We need to give it our arguments and URI as inputs. To do this, we can create variables with the proper type (see below).
In this case, the args and options are just empty. See how for both the URI and field names, we can use this very handy helper next to the default value which gives us a list of values to use:
Once we make the call, we just grab the bool field with the name “IsConnected” and that’s it!
Let’s see one more. Here we want to solo an specific object. This could be handy if we want to, for example, solo the audio playing in a certain emitter without needing to go to Wwise, we can just do everything from Unreal.
For this example, we need to build our arguments object. We first set an array of string fields which will contain the Wwise objects that we want to solo. In this case, I’m just soloing a particular sound (“Object to Solo”). See how this array of strings is set on our argument object. We also set the bool field to true because we want to do a solo, not the opposite. Finally, we do the call and it all works!
I hope that gives you a taste of how you can do a lot just using blueprints. Of course, C++ gives you more flexibility and power but for quick tools and helpers, blueprints can be more than enough.
Creating a Profiler Controller
Before we wrap up, I wanted to show two more examples with more realistic use cases. Let’s build a little helper that we can use to start profiler captures directly from Unreal.
There are many ways to do this, depending on your requirements. For this example, I made a member bool variable within my example class that we can make true from a function. We could call this function from the Unreal console, using a keyboard shortcut, from a particular volume in the game world, etc. Many possibilities!
void UMyActorComponent::WaapiStartCapture() { m_activeWwiseCapture = true; }
Once our variable is set to true, we check the connection status on tick:
{ // Update connected to Wwise status TSharedRef<FJsonObject> args = MakeShared<FJsonObject>(); TSharedRef<FJsonObject> options = MakeShared<FJsonObject>(); TSharedPtr<FJsonObject> result; FAkWaapiClient::Get()->Call(ak::wwise::core::remote::getConnectionStatus, args, options, result); bool connected; m_isConnectedToWwwise = result && result->TryGetBoolField("isConnected", connected) && connected; }
Now we have an additional variable, m_isConnectedToWwwise, which we can use to know the actual connection status. I’m updating this variable every frame, since this would only be used on debug builds, I don’t mind the CPU cost. Ideally, Audiokinetic would provide topics you can subscribe to that indicate when a connection is obtained and lost but this doesn’t seem to be the case (yet).
Next, and also on tick, we try connecting to Wwise if we are not connected already. Notice how we are trying to connect to the local host but in theory you could use any other IP:
if (!m_isConnectedToWwwise) { TSharedRef<FJsonObject> args = MakeShared<FJsonObject>(); args->SetStringField("host", "127.0.0.1"); TSharedRef<FJsonObject> options = MakeShared<FJsonObject>(); TSharedPtr<FJsonObject> result; if (FAkWaapiClient::Get()->Call(ak::wwise::core::remote::connect, args, options, result)) { GEngine->AddOnScreenDebugMessage(-1, 5.0f, FColor::Green, "Connected To Wwise"); } }
The above code will connect synchronously, that is, it will stop Unreal’s execution until the connection is established, which usually takes a second or two. Again, I don’t mind this, since this is merely a development tool and it won’t be in the final game in any way.
Once connected, we can start a capture:
{ // Start Capture TSharedRef<FJsonObject> args = MakeShared<FJsonObject>(); TSharedRef<FJsonObject> options = MakeShared<FJsonObject>(); TSharedPtr<FJsonObject> result; FAkWaapiClient::Get()->Call(ak::wwise::core::profiler::startCapture, args, options, result); }
Cool. We are now capturing and I thought it would be nice to show some extra information on the Unreal editor. For example, we can show how many voices are playing and some data about each of them. This will be called once per tick so the information will update frequently.
// Get Voices TSharedRef<FJsonObject> args = MakeShared<FJsonObject>(); args->SetStringField("time", "capture"); TSharedRef<FJsonObject> options = MakeShared<FJsonObject>(); TArray<TSharedPtr<FJsonValue>> optionsArray { MakeShareable(new FJsonValueString("isVirtual")), MakeShareable(new FJsonValueString("objectName")), MakeShareable(new FJsonValueString("gameObjectName")), MakeShareable(new FJsonValueString("pipelineID")) }; options->SetArrayField("return", optionsArray); TSharedPtr<FJsonObject> result = MakeShared<FJsonObject>(); if (FAkWaapiClient::Get()->Call(ak::wwise::core::profiler::getVoices, args, options, result)) { auto voices = result->GetArrayField("return"); int numberOfVoices = voices.Num(); int voiceCount = 0; for (auto voice : voices) { FString voiceString = "Voice #"; voiceCount++; voiceString.Append(FString::FromInt(voiceCount)); voiceString.Append(" - "); FString voiceName = voice->AsObject()->GetStringField("objectName"); voiceString.Append(voiceName); voiceString.Append(" - "); FString gameObjectName = voice->AsObject()->GetStringField("gameObjectName"); voiceString.Append(gameObjectName); voiceString.Append(" - "); FString virtualString = "Is Not Virtual"; if (voice->AsObject()->GetBoolField("isVirtual")) { virtualString = "Is Virtual"; } voiceString.Append(virtualString); auto pipelineID = voice->AsObject()->GetIntegerField("pipelineID"); TSharedRef<FJsonObject> argsVolume = MakeShared<FJsonObject>(); argsVolume->SetStringField("time", "capture"); argsVolume->SetNumberField("voicePipelineID", pipelineID); TSharedRef<FJsonObject> optionsVolume = MakeShared<FJsonObject>(); TSharedPtr<FJsonObject> resultVolume = MakeShared<FJsonObject>(); if (FAkWaapiClient::Get()->Call(ak::wwise::core::profiler::getVoiceContributions, argsVolume, optionsVolume, resultVolume)) { if (resultVolume) { auto volume = resultVolume->GetObjectField("return")->GetNumberField("volume"); voiceString.Append(" ("); voiceString.Append(FString::SanitizeFloat(volume)); voiceString.Append(" dB.)"); } } GEngine->AddOnScreenDebugMessage(-1, 0.0f, FColor::Green, voiceString); } GEngine->AddOnScreenDebugMessage(-1, 0.0f, FColor::Green, FString::Printf(TEXT("We found %i voices:"), numberOfVoices)); }
The above code is a bit more involved so let’s have a quick look. We first build the necessary objects to call the getVoices function. See how we need to specify when we want our data to come from. In this case, we pass “capture” as the argument which means use the current capture time. We also define our options object so it contains all the information we want to use.
This gives us an array of objects, each object being one voice. We count how many we have and extract all the data that we want to display. In this case, I’m building a string to be printed for each voice.
Notice how getting the voice volume requires us to call getVoiceContributions which in turn needs the voice pipeline ID. With this call we get the final voice volume in dB as shown at the top of the voice inspector.
This is how it looks in Unreal, the values update each frame:
I thought it would also be nice to display total CPU, so here is the code I used to do that:
{ // Print total CPU. TSharedRef<FJsonObject> args = MakeShared<FJsonObject>(); args->SetStringField("time", "capture"); TSharedRef<FJsonObject> options = MakeShared<FJsonObject>(); TSharedPtr<FJsonObject> resultCPU = MakeShared<FJsonObject>(); if (FAkWaapiClient::Get()->Call(ak::wwise::core::profiler::getCpuUsage, args, options, resultCPU)) { if (resultCPU) { auto cpuarray = resultCPU->GetArrayField("return"); float totalCpuValue = 0.0f; for (auto cpuElement : cpuarray) { totalCpuValue = totalCpuValue + cpuElement->AsObject()->GetNumberField("percentInclusive"); } FString cpuString = "Total CPU Usage is: "; cpuString.Append(FString::SanitizeFloat(totalCpuValue)); cpuString.Append(" %"); GEngine->AddOnScreenDebugMessage(-1, 0.0f, FColor::Cyan, cpuString); } } }
As you can see, getCpuUsage gives us an array of all the elements contributing to CPU usage so I’m just going through them and summing the inclusive CPU values.
The result is going to be the same CPU usage as you can see on the advanced profiler with the advantage of you being able to see it at a glance within the Unreal editor. One issue though is that this number will jump too quickly if you get it each frame making it not very readable so you might want to average it out over time.
Here is how the debug looks with the CPU value:
In any case, the above is simply an example, I’m sure you are already thinking of some other information that you would like to display.
Finally, I created the following function which I call from EndPlay to make sure we stop the capture and disconnect from Wwise once we stop PIE. Of course, you don’t need to do this if you don’t want to. Ideally, we would have some settings users can tweak to decide if they want their connection and/or capture to stop when the game ends.
void UMyActorComponent::WaapiStopCapture() { m_activeWwiseCapture = false; TSharedRef<FJsonObject> args = MakeShared<FJsonObject>(); TSharedRef<FJsonObject> options = MakeShared<FJsonObject>(); TSharedPtr<FJsonObject> result; if (FAkWaapiClient::Get()->Call(ak::wwise::core::profiler::stopCapture, args, options, result)) { GEngine->AddOnScreenDebugMessage(-1, 5.0f, FColor::Red, "Stopping Capture"); } if (FAkWaapiClient::Get()->Call(ak::wwise::core::remote::disconnect, args, options, result)) { GEngine->AddOnScreenDebugMessage(-1, 5.0f, FColor::Red, "Disconnecting"); } }
Building a Wwise hierarchy from Unreal data assets
Finally, I used everything I’ve learnt and built something bigger that looks closer to a real project feature. Before explaining, let’s see how it looks:
We have a game where enemies are procedurally generated. We want to play audio on each enemy action but this audio needs to be different depending on the type and rarity. One way to deal with this is having a single event that plays a big switch container structure where we rely on different switch values to get to the proper sound.
The idea then is to have code automatically creating this structure in Wwise by reading the enemy data from Unreal. This data could live anywhere, even outside Unreal like in a spreadsheet or a CSV file. In any case, since this is a tutorial focused in Unreal, I went with an Unreal data asset file.
I was initially going to do a walkthrough of the whole code but it has gotten too long so I will just explain things at a high level. If you want to read the code, you can find it here. I’ve included both the header and cpp. I absolutely encourage you to do so.
This time I wrote everything in a more systematic and modular way. It is closer to how a real project would look like. There was a lot of repetition when creating different types of objects so I wrote different generic helper functions to rely on. Ideally, these would sit in some kind of custom WAAPI library static class but in my example they all just sit in the same class.
I have to mention that I’m barely doing any error or null checking so certainly the code is not production ready and for sure will produce errors and bugs as soon as the user starts to mess around too much. In any case, creating this was a great learning exercise so let’s see what we have here.
The enemy data sits on a class which inherits from UDataAsset. This class contains all the serialised data describing enemy variants plus all the logic to send this information to Wwise. We can then create assets from this class, maybe you could have one of these assets per level of the game.
As we have mentioned, our goal is to use all this data to create a switch container hierarchy. For this purpose, we ask the user to specify where in Wwise they want the necessary game syncs and containers to be created. The user can also specify a folder containing audio files to be imported and an originals folder is used to keep things tidy. See all of that data above.
The second section on the data file contains all the enemy information. This would probably be information that game designers would fill out. Things like how many elemental types, how many rarity tiers and how many types of actions. The system makes a few assumptions like for example there should always be one array member for each of these types.
As you can see, for each of these elements we can choose whether we want to “Update Wwise Structure” which essentially means our switch container structure will contain this element. In other words, tick this box if you want this element to show up in Wwise.
There are also optional properties we can apply to each object (each switch container or SoundSFX object). For simplicity, I only added support for number based properties but this could easily be expanded to string and bool based ones. Adding support for references or lists (like adding effect share sets or RTPCs) would be a bit more complicated though.
All of the above data is taken into account by the code and with the pressing of a button, Wwise gets instantly populated with all the necessary objects.
On the code side of things, we have custom enums and structs to show the information in a more clear way. In order for all the above to work I built functions which:
- Create switch group game syncs and its children.
- Create switch containers.
- Assign switch children to the switch container group.
- Set a numeric property (like volume or pitch) on an object (like a switch or random container).
- These can be set as overrides or additively.
- Get a numeric property value from any object.
- Create game parameters (RTPC) game syncs.
- Set an RTPC on a certain object (including adding the RTPC curve).
- Create SoundSFX objects.
- Import audio at the correct place by following a naming convention on the audio file.
This was certainly the biggest thing I’ve built with WAAPI and it took me some time to get everything working. Helper functions are a must, I think, so you can avoid building arguments again and again. I would probably move this to a static WAAPI helper class.
Even though the above system works, I had to make some trade-offs. Let’s see pros and cons:
Pros:
- Making the structure for the first time is super quick and easy. No need to fiddle with dozens of containers. No need to copy paste stuff, renaming containers, etc…
- Is easy to modify numeric Wwise properties in one go, like for example make all the death sounds -2dB. This can be very useful for HDR mixing.
- If a new enemy type, rarity or action is added, the system will create all the containers with one click. Everything will be in its place.
- If you have all the audio in one folder and with the proper naming convention, every audio asset will be imported automatically.
- When importing audio, you can select which original sub-folder to use.
- Even better, if you do a second pass on the audio and replace the files within the folder, a simple click will replace all the files and move them to the correct place. This is a life saver! I could also add source control to this but I didn’t.
- If you mess up, you can undo the whole process in Wwise.
- Changes in volume, lowpass, pitch, etc can be additive or override.
- Once you apply your changes, the default behaviour is that the properties are cleared out, since I don’t expect you want to apply them again and again.
- Adding an RTPC to the main, top switch container checks if one already exists so we don’t add copies on top of each other or wipe out existing ones.
Cons:
- You will have problems as soon as you start to mess with container names in Wwise as we rely on the names to know what we are doing. Maybe the system could be made more robust by relying on IDs instead?
- Although it is true you can set properties in specific containers, there is no way to be ultra specific, like say only modify the Death action within the ice type enemies. This could be added later, though.
- No support for keeping different versions within the same audio source. Doable if needed.
- If a designer removes an enemy type, rarity or action, the system won’t delete it from Wwise. I thought this would be safer but it also means that your data file and Wwise project can get out of sync. Is all about the trade-offs.
- The system assumes a fixed in stone hierarchy of Type->Rarity->Action for the switch container structure. Adding a new trait like “Age” would need a code change although it would not be a terribly hard thing to do. Same applies for changing the order of the traits, like filtering for Rarity first instead of Type. A flexible system where traits can be created and moved around freely is possible but more complicated to build.
- The RTPC functions create a very simple curve, I didn’t give the user the ability to define it. This could be done via an Unreal float curve if necessary.
- Is not possible to add RTPCs to other child switch containers or objects via the data asset. I started to build this but I was starting to get entangled in too much feature creep so I needed to cut somewhere.
All in all, I think this is a very good example to show you the kinds of things you could build with WAAPI and how Unreal data can be used to populate hierarchies in Wwise.
Links and Resources
WAAPI RPC functions
WAAPI Subscribe topics
WAAPI Examples
Importing audio and creating structures
Querying Wwise
Intro to WAQL
WAQL Reference
Wwise Objects Reference
AK Blog - Introducing WAAPI
AK Blog - Step by Step WAAPI Example
AK Blog - Simplifying WAAPI
AK Blog - WAAPI is for everyone
AK Blog - Introducing WAQL
AK Blog - Building tools with WAAPI and Python
Wwise Up On Air - WAAPI (2019)
Wwise Up On Air - WAAPI (2022)
Wwise Up On Air - WAAPI (2024)
Working with string literals in C++
JSON Formatter
Comments