I have made a plugin with lookahead and linear phase and it reports the latency correctly to the daw in realtime for both lookahead and linear phase.
In most daws that i have tested in; the delay compensation is working; correcting the outputted audio based on the current reported latency. However, with Ableton(11) it requires a stop/start on playback/record for delay compensation to correct itself for the new latency reported.
I am doing things like:
void Processor::parameterChanged(const juce::String& parameterID, float newValue)
{
if (parameterID == ParameterIDs::LOOKAHEAD || parameterID == ParameterIDs::LINEAR_PHASE)
{
// Update latency immediately
updateLatencyCompensation();
// More subtle Ableton PDC workaround - just set the refresh flag
needsLatencyRefresh.store(true);
}
}
and:
void Processor::updateLatencyCompensation()
{
// Calculate current lookahead latency
int currentLookahead = 0;
if (currentProfile == 4 && lookaheadParam)
{
float lookaheadMs = lookaheadParam->load();
if (lookaheadMs > 0.1f)
{
currentLookahead = static_cast(lookaheadMs * 0.001 * currentSampleRate);
currentLookahead = juce::jlimit(0, static_cast(0.050 * currentSampleRate), currentLookahead);
}
}
// Calculate FIR filter latency
int firLatency = 0;
if (linearPhaseParam && (*linearPhaseParam > 0.5f))
{
for (const auto& filter : sidechainFilters)
{
if (filter.useLinearPhase)
{
if (filter.lpfFIR.coefficients)
firLatency = std::max(firLatency,
(int)((filter.lpfFIR.coefficients->getFilterOrder() - 1) / 2));
if (filter.hpfFIR.coefficients)
firLatency = std::max(firLatency,
(int)((filter.hpfFIR.coefficients->getFilterOrder() - 1) / 2));
}
}
}
int totalLatency = currentLookahead + firLatency;
// ALWAYS report latency, even if unchanged (Ableton needs this)
setLatencySamples(totalLatency);
// Multiple notifications for maximum compatibility
updateHostDisplay(ChangeDetails().withLatencyChanged(true));
updateHostDisplay(ChangeDetails().withParameterInfoChanged(true));
updateHostDisplay(ChangeDetails().withNonParameterStateChanged(true));
lastReportedLatency = totalLatency;
}
calling updateLatencyCompensation() at the end of prepareToPlay(), end of prepareFIRFilters(), during updateParameters().
I am also doing this at the start of processBlock:
// Handle delay line updates when latency changes
if (delayLinesNeedUpdate.load())
{
// Flush delay lines to match new latency setting
// This ensures smooth transition without audio glitches
for (auto& delayLine : juceDelayLines)
{
// Create a small buffer of silence to flush the delay
juce::AudioBuffer<float> silenceBuffer(1, 256);
silenceBuffer.clear();
float* data = silenceBuffer.getWritePointer(0);
juce::dsp::AudioBlock<float> block(&data, 1, 256);
juce::dsp::ProcessContextReplacing<float> context(block);
// Process silence through delay to flush old samples
delayLine.process(context);
}
delayLinesNeedUpdate.store(false);
}
// ABLETON FIX: Force latency refresh in processBlock
// This ensures Ableton picks up latency changes during playback
if (needsLatencyRefresh.load())
{
updateHostDisplay(ChangeDetails().withLatencyChanged(true));
needsLatencyRefresh.store(false);
}
// ============================================================================
// REPORT LATENCY ON FIRST PROCESSBLOCK (after state is loaded)
// This ensures Ableton gets the correct latency with restored parameters
// ============================================================================
static bool latencyReportedToAudioThread = false;
if (!latencyReportedToAudioThread && currentSampleRate > 0.0)
{
// Calculate current lookahead (respects loaded plugin state)
int currentLookahead = 0;
if (currentProfile == 4 && lookaheadParam)
{
float lookaheadMs = lookaheadParam->load();
if (lookaheadMs > 0.1f)
{
currentLookahead = static_cast(lookaheadMs * 0.001 * currentSampleRate);
currentLookahead = juce::jlimit(0, static_cast(0.050 * currentSampleRate), currentLookahead);
}
}
// Calculate FIR filter latency
int firLatency = 0;
if (linearPhaseParam && (*linearPhaseParam > 0.5f))
{
for (const auto& filter : sidechainFilters)
{
if (filter.useLinearPhase)
{
if (filter.lpfFIR.coefficients)
firLatency = std::max(firLatency,
(int)((filter.lpfFIR.coefficients->getFilterOrder() - 1) / 2));
if (filter.hpfFIR.coefficients)
firLatency = std::max(firLatency,
(int)((filter.hpfFIR.coefficients->getFilterOrder() - 1) / 2));
}
}
}
int totalLatency = currentLookahead + firLatency;
// Report to DAW
if (totalLatency != lastReportedLatency)
{
setLatencySamples(totalLatency);
lastReportedLatency = totalLatency;
updateHostDisplay(); // Force DAW to acknowledge
}
latencyReportedToAudioThread = true;
}
Any help is appreciated as I just want Ableton to compensate for the latency being reported in realtime.
Is the answer to report the max latency when lookahead or linear phase are enabled and then do the delay compensation within the plugin itself?
if you want to let users change lookahead through a slider, the most common way is to add a latency enabled button. If the latency is enabled, request the maximum latency in samples and put two different delays for main-chain and side-chain. After that you only change the side-chain latency without request a new latency in samples.
for linear phase, audio discontinuity is expected but you may use some interpolation to mitigate it. In my plugin I just leave it there as I am a bit lazy
There are several problems in your code:
you should always call setLatencySamples(*) on the message thread. use AsyncUpdater.
the following code is incorrect.
if (delayLinesNeedUpdate.load())
{
// ***
delayLinesNeedUpdate.store(false);
}
You should use:
if (delayLinesNeedUpdate.exchange(false, std::memory_order::acquire)) {
}
updateLatencyCompensation() can be called from message thread & audio thread. If filter.hpfFIR.coefficients->getFilterOrder() is not thread safe, you need a workaround.
Thank you for your reply. So, to confirm before I go balls deep into the code again:
Instead of reporting the exact latency to the daw at all times, when lookahead is enabled we report the max latency in samples to the daw and if linear phase is enabled report that latency to the daw also.
Delay compensation should be handled in the plugin completely and do not rely on the daw pdc as each daw is different and cannot be trusted.
Lookahead needs two different delaylines (main-chain and side-chain) then we only change the side-chain latency, then when the user changes lookahead amount we only change the side-chain latency without the need to report a new latency in samples to the daw.
Also, I should always call setLatencySamples(*) on the message thread using AsyncUpdater.
Delay compensation should be handled in the plugin completely and do not rely on the daw pdc as each daw is different and cannot be trusted.
You should trust DAW in handling PDC corrrectly. If you report the change frequently, DAW can still handle it, but it may produce artifact. By using two delay lines you can smooth the change in your plugin.
Interesting, because when I load Fabfilter C2 and enable lookahead it reports 20ms, the max amount, and it reports 20ms no matter what, and thats in every daw. This makes me think its telling the daw to adjust for 20ms of latency but then actually doing the delay compensation inside the plugin, and just outputting audio with 20ms of delay all the time lookahead is enabled no matter what the user sets in the plugin.
OK, so we are saying to handle the delay compensation in the plugin and then just report the 50ms or 51.04ms (lookahead or lookahead + linear phase) to the daw and let the daw just handle the max latency.
My plugin does 50ms lookahead I should speak in samples really, I think its 2204 samples for lookahead and 50 samples for linear phase off the top of my head.
I have done everything you suggested @zsliu98 but yet I am still having to do a stop/start in Ableton for the PDC to correctly adjust for the latency even though im reporting just max latency and not dynamically updating the amount to the daw (handling delay compensation internally).
I assume its my AsyncUpdater implementation being incorrect or something.
void Processor::updateLatencyCompensation()
{
if (juce::MessageManager::getInstance()->isThisTheMessageThread())
{
// We're on message thread - update immediately
updateLatencyCompensationOnMessageThread();
}
else
{
// We're on audio thread - use async update for safety
if (latencyUpdater)
latencyUpdater->triggerAsyncUpdate();
}
}
void Processor::updateLatencyCompensationOnMessageThread()
{
if (currentSampleRate <= 0.0)
return;
// Calculate FIXED maximum latencies based on enabled features
bool lookaheadEnabled = (currentProfile == 4 && lookaheadParam && lookaheadParam->load() > 0.1f);
bool linearPhaseEnabled = (linearPhaseParam && (*linearPhaseParam > 0.5f));
int fixedLatency = 0;
if (lookaheadEnabled && linearPhaseEnabled)
{
// 50ms lookahead + 50 samples linear phase = 2255 samples at 44.1kHz
fixedLatency = static_cast<int>(0.050 * currentSampleRate) + 50;
}
else if (lookaheadEnabled)
{
// 50ms maximum lookahead = 2205 samples at 44.1kHz
fixedLatency = static_cast<int>(0.050 * currentSampleRate);
}
else if (linearPhaseEnabled)
{
// Linear phase only = 50 samples
fixedLatency = 50;
}
else
{
// Nothing enabled = 0 samples
fixedLatency = 0;
}
// Only update if changed
if (fixedLatency != lastReportedLatency)
{
setLatencySamples(fixedLatency);
lastReportedLatency = fixedLatency;
updateHostDisplay(ChangeDetails().withLatencyChanged(true));
DBG("=== FIXED LATENCY UPDATED ===");
DBG("Profile: " << currentProfile);
DBG("Lookahead Enabled: " << (lookaheadEnabled ? "YES" : "NO"));
DBG("Linear Phase Enabled: " << (linearPhaseEnabled ? "YES" : "NO"));
DBG("FIXED LATENCY REPORTED: " << fixedLatency << " samples ("
<< (fixedLatency / (float)currentSampleRate * 1000.0f) << "ms)");
}
}