Hello! This is a fun topic
I apologize if this is verbose or is information you are already aware of, but hopefully its useful for multiple people.
With Quartz it is important to keep a few things in mind:
- Blueprints run at the game frame rate (i.e. 60 frames/sec).
- The Quartz Clock is ticked by the Audio Engine, which is generating audio in buffers.
At a high level, Quartz does 2 things:
1.) It sends events back to the game thread so VFX can line up w/ scheduled audio
2.) It allows for the sample-accurate scheduling of audio rendering
Neither of these things adhere to the “wall-clock” accuracy it seems you think you need.
for 1.) As soon as you’re receiving notifications back from Quartz on the Game Thread (i.e. in BP), you are dealing with error as high as tens of milliseconds. This is not avoidable. If you needed sub-4ms accuracy in BP, your game would have to maintain 250+fps.
for 2.) It is important for your use case to understand what Quartz is actually doing and what “sample accurate” means mechanically:
Say Quartz receives requests to play sounds on a certain boundary. Say it decides that boundary is 2058 audio frames in the future. And lets say the audio engine is generating buffers of 1024 audio frames.
The order of evets is as follows:
-
Quartz receives a request to play sound X on the next “bar”.
-
Quartz calculates this means the sound should start in 2058 audio frames
-
The Audio Engine is about to render 1024 samples, it ticks quartz forward 1024 frames.
-
Quartz says, this sound should not play in this buffer (2058 > 1024), but now it should start in 1,034 frames (2,058-1,024)
-
Some time in the future, the Audio Engine is about to render the next 1024 samples, it ticks quartz forward again, 1024 frames.
-
Quartz says, this sound should not play in this buffer (1034 > 1024) BUT we are getting close so Quartz sends a command back to the game thread to trigger that delegate, (so any VFX that want to appear in sync with the sound can start on the next video frame.) The sound should now start in 10 audio frames (1,034 - 1,024)
-
Some time in the future, the Audio Engine is about to render the next 1024 samples, it ticks Quartz forward again, 1024 frames.
-
Quartz says, this sound SHOULD play in this buffer (10 < 1024), so it stages the sound for playback in the upcoming buffer render, and sets it up so the sound will render through a 10-sample delay line (this is how the playback of the sound becomes SAMPLE accurate and not just BUFFER accurate).
For the Metronome delegates: those are also fired when Quartz is ticked. When Quartz is notified that 1,024 samples are about to be played, it sees if any metronome boundaries (i.e. quarter-note) are “occurring” during the chunk of time that next buffer represents. If multiple occurrences of a metronome boundary are occurring in that buffer, only one delegate will be fired. (So if your BPM is fast and/or you are subscribing to a small boundary such as a thirty-second note, you may not be getting all the delegates you are hoping for).
Additionally, the digestion of BP delegates is a victim to fluctuations in the framerate of your application. Any game-thread stutters or hangs will delay the digestion.
So, Quartz is not a wall-clock, end-all, time keeper that will notify your blueprint with <5 milliseconds of accuracy. It is a system that lets you interact w/ the audio engine in a sample-accurate way by scheduling ahead, and it will notify BP as accurately as possible so that gameplay and VFX can appear in sync with the sound that is playing.
I hope that is helpful and clears up temporal expectations. Feel free to follow up w/ more info on your specific needs, but at a high-level it sounds like your system design might need to shift from thinking the gameplay logic can be “real time” (not possible) to your gameplay logic “thinking ahead” and scheduling things with the audio engine.
If you need things accurately executed on the Audio Render Thread w/ Quartz, you can inherit from IQuartzQuantizedCommand and schedule it like any of the commands already provided in the engine.
Here is the implementation of a quantized Play Quantized command, you can see how it interacts w/ the Audio Mixer Source manager to control the onset of the target sound:
TSharedPtr<IQuartzQuantizedCommand> FQuantizedPlayCommand::GetDeepCopyOfDerivedObject() const
{
TSharedPtr<FQuantizedPlayCommand> NewCopy = MakeShared<FQuantizedPlayCommand>();
NewCopy->OwningClockPtr = OwningClockPtr;
NewCopy->SourceID = SourceID;
return NewCopy;
}
void FQuantizedPlayCommand::OnQueuedCustom(const FQuartzQuantizedCommandInitInfo& InCommandInitInfo)
{
OwningClockPtr = InCommandInitInfo.OwningClockPointer;
SourceID = InCommandInitInfo.SourceID;
bIsCanceled = false;
// access source manager through owning clock (via clock manager)
FMixerSourceManager* SourceManager = OwningClockPtr->GetSourceManager();
if (SourceManager)
{
SourceManager->PauseSoundForQuantizationCommand(SourceID);
}
else
{
// cancel ourselves (no source manager may mean we are running without an audio device)
if (ensure(OwningClockPtr))
{
OwningClockPtr->CancelQuantizedCommand(TSharedPtr<IQuartzQuantizedCommand>(this));
}
}
}
void FQuantizedPlayCommand::OnFinalCallbackCustom(int32 InNumFramesLeft)
{
// Access source manager through owning clock (via clock manager)
check(OwningClockPtr && OwningClockPtr->GetSourceManager());
// This was canceled before the active sound hit the source manager.
// Calling CancelCustom() make sure we stop the associated sound.
if (bIsCanceled)
{
CancelCustom();
return;
}
// access source manager through owning clock (via clock manager)
// Owning Clock Ptr may be nullptr if this command was canceled.
if (OwningClockPtr)
{
FMixerSourceManager* SourceManager = OwningClockPtr->GetSourceManager();
if (SourceManager)
{
SourceManager->SetSubBufferDelayForSound(SourceID, InNumFramesLeft);
SourceManager->UnPauseSoundForQuantizationCommand(SourceID);
}
else
{
// cancel ourselves (no source manager may mean we are running without an audio device)
OwningClockPtr->CancelQuantizedCommand(TSharedPtr<IQuartzQuantizedCommand>(this));
}
}
}
void FQuantizedPlayCommand::CancelCustom()
{
bIsCanceled = true;
if (OwningClockPtr)
{
FMixerSourceManager* SourceManager = OwningClockPtr->GetSourceManager();
FMixerDevice* MixerDevice = OwningClockPtr->GetMixerDevice();
if (MixerDevice && SourceManager && MixerDevice->IsAudioRenderingThread())
{
// if we don't UnPause first, this function will be called by FMixerSourceManager::StopInternal()
SourceManager->UnPauseSoundForQuantizationCommand(SourceID); // (avoid infinite recursion)
SourceManager->CancelQuantizedSound(SourceID);
}
}
}
static const FName PlayCommandName("Play Command");
FName FQuantizedPlayCommand::GetCommandName() const
{
return PlayCommandName;
}
TLDR: Quartz is sample accurate relative to Audio Rendering, and only when on the Audio Render Thread (i.e. inside of a Quantized Command object).
The Blueprint side of Quartz is not sample accurate, and is limited by your game’s frame-rate
Neither are “wall-clock” accurate, as audio is rendered in blocks of samples (of a few ms), and Quartz is updated at this rate.